Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7d55725780 | ||
|
|
2ed7394adc | ||
|
|
a727c14a2c | ||
|
|
5aa1a768f9 |
@@ -79,7 +79,8 @@ class Api::V1::Accounts::PortalsController < Api::V1::Accounts::BaseController
|
|||||||
params.require(:portal).permit(
|
params.require(:portal).permit(
|
||||||
:id, :color, :custom_domain, :header_text, :homepage_link,
|
:id, :color, :custom_domain, :header_text, :homepage_link,
|
||||||
:name, :page_title, :slug, :archived,
|
:name, :page_title, :slug, :archived,
|
||||||
{ config: [:default_locale, :layout, { allowed_locales: [] }, { draft_locales: [] },
|
{ config: [:default_locale, :layout, *Portal::ANALYTICS_CONFIG_FORMATS.keys.map(&:to_sym),
|
||||||
|
{ allowed_locales: [] }, { draft_locales: [] },
|
||||||
{ social_profiles: %i[facebook x instagram linkedin youtube tiktok github whatsapp] }] }
|
{ social_profiles: %i[facebook x instagram linkedin youtube tiktok github whatsapp] }] }
|
||||||
)
|
)
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { useRoute } from 'vue-router';
|
|||||||
import { useMapGetter } from 'dashboard/composables/store.js';
|
import { useMapGetter } from 'dashboard/composables/store.js';
|
||||||
|
|
||||||
import PaginationFooter from 'dashboard/components-next/pagination/PaginationFooter.vue';
|
import PaginationFooter from 'dashboard/components-next/pagination/PaginationFooter.vue';
|
||||||
|
import PortalAvatar from 'dashboard/components-next/HelpCenter/PortalAvatar.vue';
|
||||||
import Button from 'dashboard/components-next/button/Button.vue';
|
import Button from 'dashboard/components-next/button/Button.vue';
|
||||||
import PortalSwitcher from 'dashboard/components-next/HelpCenter/PortalSwitcher/PortalSwitcher.vue';
|
import PortalSwitcher from 'dashboard/components-next/HelpCenter/PortalSwitcher/PortalSwitcher.vue';
|
||||||
import CreatePortalDialog from 'dashboard/components-next/HelpCenter/PortalSwitcher/CreatePortalDialog.vue';
|
import CreatePortalDialog from 'dashboard/components-next/HelpCenter/PortalSwitcher/CreatePortalDialog.vue';
|
||||||
@@ -30,6 +31,10 @@ defineProps({
|
|||||||
type: Boolean,
|
type: Boolean,
|
||||||
default: true,
|
default: true,
|
||||||
},
|
},
|
||||||
|
breadcrumbLabel: {
|
||||||
|
type: String,
|
||||||
|
default: '',
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const emit = defineEmits(['update:currentPage']);
|
const emit = defineEmits(['update:currentPage']);
|
||||||
@@ -44,10 +49,15 @@ const portals = useMapGetter('portals/allPortals');
|
|||||||
|
|
||||||
const currentPortalSlug = computed(() => route.params.portalSlug);
|
const currentPortalSlug = computed(() => route.params.portalSlug);
|
||||||
|
|
||||||
const activePortalName = computed(() => {
|
const activePortal = computed(() =>
|
||||||
return portals.value?.find(portal => portal.slug === currentPortalSlug.value)
|
portals.value?.find(portal => portal.slug === currentPortalSlug.value)
|
||||||
?.name;
|
);
|
||||||
});
|
|
||||||
|
const activePortalName = computed(() => activePortal.value?.name);
|
||||||
|
|
||||||
|
const activePortalLogo = computed(
|
||||||
|
() => activePortal.value?.logo?.file_url || ''
|
||||||
|
);
|
||||||
|
|
||||||
const updateCurrentPage = page => {
|
const updateCurrentPage = page => {
|
||||||
emit('update:currentPage', page);
|
emit('update:currentPage', page);
|
||||||
@@ -65,32 +75,45 @@ const togglePortalSwitcher = () => {
|
|||||||
v-if="showHeaderTitle"
|
v-if="showHeaderTitle"
|
||||||
class="flex items-center justify-start h-20 gap-2"
|
class="flex items-center justify-start h-20 gap-2"
|
||||||
>
|
>
|
||||||
<span
|
<nav v-if="activePortalName" class="flex items-center gap-2">
|
||||||
v-if="activePortalName"
|
<div class="flex items-center gap-1.5">
|
||||||
class="text-xl font-medium text-n-slate-12"
|
<PortalAvatar
|
||||||
>
|
:src="activePortalLogo"
|
||||||
{{ activePortalName }}
|
:name="activePortalName"
|
||||||
</span>
|
:size="24"
|
||||||
<div v-if="activePortalName" class="relative group">
|
|
||||||
<OnClickOutside @trigger="showPortalSwitcher = false">
|
|
||||||
<Button
|
|
||||||
icon="i-lucide-chevron-down"
|
|
||||||
variant="ghost"
|
|
||||||
color="slate"
|
|
||||||
size="xs"
|
|
||||||
class="rounded-md group-hover:bg-n-slate-3 hover:bg-n-slate-3"
|
|
||||||
@click="togglePortalSwitcher"
|
|
||||||
/>
|
/>
|
||||||
|
<span class="text-lg font-medium text-n-slate-12">
|
||||||
|
{{ activePortalName }}
|
||||||
|
</span>
|
||||||
|
<div class="relative group">
|
||||||
|
<OnClickOutside @trigger="showPortalSwitcher = false">
|
||||||
|
<Button
|
||||||
|
icon="i-lucide-chevron-down"
|
||||||
|
:variant="showPortalSwitcher ? 'faded' : 'ghost'"
|
||||||
|
color="slate"
|
||||||
|
size="xs"
|
||||||
|
class="rounded-md group-hover:bg-n-slate-3 hover:bg-n-slate-3 [&>span]:size-4"
|
||||||
|
@click="togglePortalSwitcher"
|
||||||
|
/>
|
||||||
|
|
||||||
<PortalSwitcher
|
<PortalSwitcher
|
||||||
v-if="showPortalSwitcher"
|
v-if="showPortalSwitcher"
|
||||||
class="absolute ltr:left-0 rtl:right-0 top-9"
|
class="absolute ltr:left-0 rtl:right-0 top-9"
|
||||||
@close="showPortalSwitcher = false"
|
@close="showPortalSwitcher = false"
|
||||||
@create-portal="createPortalDialogRef.dialogRef.open()"
|
@create-portal="createPortalDialogRef.dialogRef.open()"
|
||||||
/>
|
/>
|
||||||
</OnClickOutside>
|
</OnClickOutside>
|
||||||
<CreatePortalDialog ref="createPortalDialogRef" />
|
<CreatePortalDialog ref="createPortalDialogRef" />
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<template v-if="breadcrumbLabel">
|
||||||
|
<div class="w-0.5 h-4 rounded-2xl bg-n-weak shrink-0" />
|
||||||
|
<span class="pl-1.5 text-lg font-medium text-n-slate-11">
|
||||||
|
{{ breadcrumbLabel }}
|
||||||
|
</span>
|
||||||
|
</template>
|
||||||
|
</nav>
|
||||||
</div>
|
</div>
|
||||||
<slot name="header-actions" />
|
<slot name="header-actions" />
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -256,6 +256,7 @@ watch(
|
|||||||
:total-items="articlesCount"
|
:total-items="articlesCount"
|
||||||
:items-per-page="25"
|
:items-per-page="25"
|
||||||
:header="portalName"
|
:header="portalName"
|
||||||
|
:breadcrumb-label="$t('HELP_CENTER.BREADCRUMB.ARTICLES')"
|
||||||
:show-pagination-footer="shouldShowPaginationFooter"
|
:show-pagination-footer="shouldShowPaginationFooter"
|
||||||
@update:current-page="handlePageChange"
|
@update:current-page="handlePageChange"
|
||||||
>
|
>
|
||||||
|
|||||||
+4
-1
@@ -112,7 +112,10 @@ const reorderCategories = async reorderedGroup => {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<HelpCenterLayout :show-pagination-footer="false">
|
<HelpCenterLayout
|
||||||
|
:show-pagination-footer="false"
|
||||||
|
:breadcrumb-label="$t('HELP_CENTER.BREADCRUMB.CATEGORIES')"
|
||||||
|
>
|
||||||
<template #header-actions>
|
<template #header-actions>
|
||||||
<CategoryHeaderControls
|
<CategoryHeaderControls
|
||||||
:categories="categories"
|
:categories="categories"
|
||||||
|
|||||||
+4
-1
@@ -31,7 +31,10 @@ const localeCount = computed(() => props.locales?.length);
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<HelpCenterLayout :show-pagination-footer="false">
|
<HelpCenterLayout
|
||||||
|
:show-pagination-footer="false"
|
||||||
|
:breadcrumb-label="$t('HELP_CENTER.BREADCRUMB.LOCALES')"
|
||||||
|
>
|
||||||
<template #header-actions>
|
<template #header-actions>
|
||||||
<div class="flex items-center justify-between">
|
<div class="flex items-center justify-between">
|
||||||
<div class="flex items-center gap-4">
|
<div class="flex items-center gap-4">
|
||||||
|
|||||||
+2
-45
@@ -12,8 +12,7 @@ import { isValidSlug } from 'shared/helpers/Validators';
|
|||||||
|
|
||||||
import Button from 'dashboard/components-next/button/Button.vue';
|
import Button from 'dashboard/components-next/button/Button.vue';
|
||||||
import Input from 'dashboard/components-next/input/Input.vue';
|
import Input from 'dashboard/components-next/input/Input.vue';
|
||||||
import Avatar from 'dashboard/components-next/avatar/Avatar.vue';
|
import PortalAvatar from 'dashboard/components-next/HelpCenter/PortalAvatar.vue';
|
||||||
import ComboBox from 'dashboard/components-next/combobox/ComboBox.vue';
|
|
||||||
import ColorPicker from 'dashboard/components-next/colorpicker/ColorPicker.vue';
|
import ColorPicker from 'dashboard/components-next/colorpicker/ColorPicker.vue';
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
@@ -42,31 +41,12 @@ const state = reactive({
|
|||||||
slug: '',
|
slug: '',
|
||||||
widgetColor: '',
|
widgetColor: '',
|
||||||
homePageLink: '',
|
homePageLink: '',
|
||||||
liveChatWidgetInboxId: '',
|
|
||||||
logoUrl: '',
|
logoUrl: '',
|
||||||
avatarBlobId: '',
|
avatarBlobId: '',
|
||||||
});
|
});
|
||||||
|
|
||||||
const originalState = reactive({ ...state });
|
const originalState = reactive({ ...state });
|
||||||
|
|
||||||
const liveChatWidgets = computed(() => {
|
|
||||||
const inboxes = store.getters['inboxes/getInboxes'];
|
|
||||||
const widgetOptions = inboxes
|
|
||||||
.filter(inbox => inbox.channel_type === 'Channel::WebWidget')
|
|
||||||
.map(inbox => ({
|
|
||||||
value: inbox.id,
|
|
||||||
label: inbox.name,
|
|
||||||
}));
|
|
||||||
|
|
||||||
return [
|
|
||||||
{
|
|
||||||
value: '',
|
|
||||||
label: t('HELP_CENTER.PORTAL_SETTINGS.FORM.LIVE_CHAT_WIDGET.NONE_OPTION'),
|
|
||||||
},
|
|
||||||
...widgetOptions,
|
|
||||||
];
|
|
||||||
});
|
|
||||||
|
|
||||||
const rules = {
|
const rules = {
|
||||||
name: { required, minLength: minLength(2) },
|
name: { required, minLength: minLength(2) },
|
||||||
slug: {
|
slug: {
|
||||||
@@ -116,7 +96,6 @@ watch(
|
|||||||
widgetColor: newVal.color,
|
widgetColor: newVal.color,
|
||||||
homePageLink: newVal.homepage_link,
|
homePageLink: newVal.homepage_link,
|
||||||
slug: newVal.slug,
|
slug: newVal.slug,
|
||||||
liveChatWidgetInboxId: newVal.inbox?.id || '',
|
|
||||||
});
|
});
|
||||||
if (newVal.logo) {
|
if (newVal.logo) {
|
||||||
const {
|
const {
|
||||||
@@ -148,7 +127,6 @@ const handleUpdatePortal = () => {
|
|||||||
header_text: state.headerText,
|
header_text: state.headerText,
|
||||||
homepage_link: state.homePageLink,
|
homepage_link: state.homePageLink,
|
||||||
blob_id: state.avatarBlobId,
|
blob_id: state.avatarBlobId,
|
||||||
inbox_id: state.liveChatWidgetInboxId,
|
|
||||||
};
|
};
|
||||||
emit('updatePortal', portal);
|
emit('updatePortal', portal);
|
||||||
};
|
};
|
||||||
@@ -204,12 +182,11 @@ const handleAvatarDelete = () => {
|
|||||||
<label class="mb-0.5 text-sm font-medium text-gray-900 dark:text-gray-50">
|
<label class="mb-0.5 text-sm font-medium text-gray-900 dark:text-gray-50">
|
||||||
{{ t('HELP_CENTER.PORTAL_SETTINGS.FORM.AVATAR.LABEL') }}
|
{{ t('HELP_CENTER.PORTAL_SETTINGS.FORM.AVATAR.LABEL') }}
|
||||||
</label>
|
</label>
|
||||||
<Avatar
|
<PortalAvatar
|
||||||
:src="state.logoUrl"
|
:src="state.logoUrl"
|
||||||
:name="state.name"
|
:name="state.name"
|
||||||
:size="72"
|
:size="72"
|
||||||
allow-upload
|
allow-upload
|
||||||
icon-name="i-lucide-building-2"
|
|
||||||
@upload="handleAvatarUpload"
|
@upload="handleAvatarUpload"
|
||||||
@delete="handleAvatarDelete"
|
@delete="handleAvatarDelete"
|
||||||
/>
|
/>
|
||||||
@@ -303,26 +280,6 @@ const handleAvatarDelete = () => {
|
|||||||
@blur="v$.slug.$touch()"
|
@blur="v$.slug.$touch()"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div
|
|
||||||
class="grid items-start justify-between w-full gap-2 grid-cols-[200px,1fr]"
|
|
||||||
>
|
|
||||||
<label
|
|
||||||
class="text-sm font-medium whitespace-nowrap py-2.5 text-n-slate-12"
|
|
||||||
>
|
|
||||||
{{ t('HELP_CENTER.PORTAL_SETTINGS.FORM.LIVE_CHAT_WIDGET.LABEL') }}
|
|
||||||
</label>
|
|
||||||
<ComboBox
|
|
||||||
v-model="state.liveChatWidgetInboxId"
|
|
||||||
:options="liveChatWidgets"
|
|
||||||
:placeholder="
|
|
||||||
t('HELP_CENTER.PORTAL_SETTINGS.FORM.LIVE_CHAT_WIDGET.PLACEHOLDER')
|
|
||||||
"
|
|
||||||
:message="
|
|
||||||
t('HELP_CENTER.PORTAL_SETTINGS.FORM.LIVE_CHAT_WIDGET.HELP_TEXT')
|
|
||||||
"
|
|
||||||
class="[&>div>button:not(.focused)]:!outline-n-weak"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div
|
<div
|
||||||
class="grid items-start justify-between w-full gap-2 grid-cols-[200px,1fr]"
|
class="grid items-start justify-between w-full gap-2 grid-cols-[200px,1fr]"
|
||||||
>
|
>
|
||||||
|
|||||||
+15
-5
@@ -1,5 +1,5 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import { computed, ref } from 'vue';
|
import { computed, ref, watch } from 'vue';
|
||||||
import { useI18n } from 'vue-i18n';
|
import { useI18n } from 'vue-i18n';
|
||||||
import { useAccount } from 'dashboard/composables/useAccount';
|
import { useAccount } from 'dashboard/composables/useAccount';
|
||||||
|
|
||||||
@@ -62,6 +62,7 @@ const { isOnChatwootCloud } = useAccount();
|
|||||||
const addCustomDomainDialogRef = ref(null);
|
const addCustomDomainDialogRef = ref(null);
|
||||||
const dnsConfigurationDialogRef = ref(null);
|
const dnsConfigurationDialogRef = ref(null);
|
||||||
const updatedDomainAddress = ref('');
|
const updatedDomainAddress = ref('');
|
||||||
|
const pendingDomain = ref('');
|
||||||
|
|
||||||
const customDomainAddress = computed(
|
const customDomainAddress = computed(
|
||||||
() => props.activePortal?.custom_domain || ''
|
() => props.activePortal?.custom_domain || ''
|
||||||
@@ -111,14 +112,23 @@ const updatePortalConfiguration = customDomain => {
|
|||||||
id: props.activePortal?.id,
|
id: props.activePortal?.id,
|
||||||
custom_domain: customDomain,
|
custom_domain: customDomain,
|
||||||
};
|
};
|
||||||
|
pendingDomain.value = customDomain;
|
||||||
emit('updatePortalConfiguration', portal);
|
emit('updatePortalConfiguration', portal);
|
||||||
addCustomDomainDialogRef.value.dialogRef.close();
|
addCustomDomainDialogRef.value.dialogRef.close();
|
||||||
if (customDomain) {
|
|
||||||
updatedDomainAddress.value = customDomain;
|
|
||||||
dnsConfigurationDialogRef.value.dialogRef.open();
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// The save above is fire-and-forget, so the DNS instructions dialog must not
|
||||||
|
// open until the domain is actually persisted — otherwise a failed save still
|
||||||
|
// advances to the next step. We open it once the stored value reflects the
|
||||||
|
// submitted domain.
|
||||||
|
watch(customDomainAddress, savedDomain => {
|
||||||
|
if (!pendingDomain.value || savedDomain !== pendingDomain.value) return;
|
||||||
|
|
||||||
|
pendingDomain.value = '';
|
||||||
|
updatedDomainAddress.value = savedDomain;
|
||||||
|
dnsConfigurationDialogRef.value.dialogRef.open();
|
||||||
|
});
|
||||||
|
|
||||||
const closeDNSConfigurationDialog = () => {
|
const closeDNSConfigurationDialog = () => {
|
||||||
updatedDomainAddress.value = '';
|
updatedDomainAddress.value = '';
|
||||||
dnsConfigurationDialogRef.value.dialogRef.close();
|
dnsConfigurationDialogRef.value.dialogRef.close();
|
||||||
|
|||||||
+79
@@ -0,0 +1,79 @@
|
|||||||
|
<script setup>
|
||||||
|
import { computed, ref } from 'vue';
|
||||||
|
import { useI18n } from 'vue-i18n';
|
||||||
|
|
||||||
|
import PortalBaseSettings from './PortalBaseSettings.vue';
|
||||||
|
import ConfirmDeletePortalDialog from './ConfirmDeletePortalDialog.vue';
|
||||||
|
import Button from 'dashboard/components-next/button/Button.vue';
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
activePortal: { type: Object, required: true },
|
||||||
|
isFetching: { type: Boolean, default: false },
|
||||||
|
});
|
||||||
|
|
||||||
|
const emit = defineEmits(['updatePortal', 'deletePortal']);
|
||||||
|
|
||||||
|
const { t } = useI18n();
|
||||||
|
|
||||||
|
const confirmDeletePortalDialogRef = ref(null);
|
||||||
|
|
||||||
|
const activePortalName = computed(() => props.activePortal?.name || '');
|
||||||
|
|
||||||
|
const handleUpdatePortal = portal => {
|
||||||
|
emit('updatePortal', portal);
|
||||||
|
};
|
||||||
|
|
||||||
|
const openConfirmDeletePortalDialog = () => {
|
||||||
|
confirmDeletePortalDialogRef.value.dialogRef.open();
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDeletePortal = () => {
|
||||||
|
emit('deletePortal', props.activePortal);
|
||||||
|
confirmDeletePortalDialogRef.value.dialogRef.close();
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="flex flex-col w-full gap-4">
|
||||||
|
<PortalBaseSettings
|
||||||
|
:active-portal="activePortal"
|
||||||
|
:is-fetching="isFetching"
|
||||||
|
@update-portal="handleUpdatePortal"
|
||||||
|
/>
|
||||||
|
<div class="w-full h-px bg-n-weak" />
|
||||||
|
<div class="flex items-end justify-between w-full gap-4">
|
||||||
|
<div class="flex flex-col gap-2">
|
||||||
|
<h6 class="text-base font-medium text-n-slate-12">
|
||||||
|
{{
|
||||||
|
t(
|
||||||
|
'HELP_CENTER.PORTAL_SETTINGS.CONFIGURATION_FORM.DELETE_PORTAL.HEADER'
|
||||||
|
)
|
||||||
|
}}
|
||||||
|
</h6>
|
||||||
|
<span class="text-sm text-n-slate-11">
|
||||||
|
{{
|
||||||
|
t(
|
||||||
|
'HELP_CENTER.PORTAL_SETTINGS.CONFIGURATION_FORM.DELETE_PORTAL.DESCRIPTION'
|
||||||
|
)
|
||||||
|
}}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
:label="
|
||||||
|
t(
|
||||||
|
'HELP_CENTER.PORTAL_SETTINGS.CONFIGURATION_FORM.DELETE_PORTAL.BUTTON',
|
||||||
|
{ portalName: activePortalName }
|
||||||
|
)
|
||||||
|
"
|
||||||
|
color="ruby"
|
||||||
|
class="max-w-56 !w-fit"
|
||||||
|
@click="openConfirmDeletePortalDialog"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<ConfirmDeletePortalDialog
|
||||||
|
ref="confirmDeletePortalDialogRef"
|
||||||
|
:active-portal-name="activePortalName"
|
||||||
|
@delete-portal="handleDeletePortal"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
+244
@@ -0,0 +1,244 @@
|
|||||||
|
<script setup>
|
||||||
|
import { computed, reactive, watch } from 'vue';
|
||||||
|
import { useI18n } from 'vue-i18n';
|
||||||
|
import { useStore } from 'dashboard/composables/store';
|
||||||
|
import { useBranding } from 'shared/composables/useBranding';
|
||||||
|
|
||||||
|
import Icon from 'dashboard/components-next/icon/Icon.vue';
|
||||||
|
import Input from 'dashboard/components-next/input/Input.vue';
|
||||||
|
import ComboBox from 'dashboard/components-next/combobox/ComboBox.vue';
|
||||||
|
import Button from 'dashboard/components-next/button/Button.vue';
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
activePortal: { type: Object, required: true },
|
||||||
|
isFetching: { type: Boolean, default: false },
|
||||||
|
});
|
||||||
|
|
||||||
|
const emit = defineEmits(['updatePortalConfiguration']);
|
||||||
|
|
||||||
|
const { t } = useI18n();
|
||||||
|
const store = useStore();
|
||||||
|
const { replaceInstallationName } = useBranding();
|
||||||
|
|
||||||
|
// Mirrors Portal::ANALYTICS_CONFIG_FORMATS on the backend.
|
||||||
|
const ANALYTICS_PROVIDERS = [
|
||||||
|
{
|
||||||
|
key: 'gtm_container_id',
|
||||||
|
i18nKey: 'GTM',
|
||||||
|
icon: 'i-logos-google-tag-manager',
|
||||||
|
format: /^GTM-[A-Z0-9]+$/,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'ga4_measurement_id',
|
||||||
|
i18nKey: 'GA4',
|
||||||
|
icon: 'i-logos-google-analytics',
|
||||||
|
format: /^G-[A-Z0-9]+$/,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'hotjar_site_id',
|
||||||
|
i18nKey: 'HOTJAR',
|
||||||
|
icon: 'i-logos-hotjar-icon',
|
||||||
|
format: /^\d+$/,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'plausible_domain',
|
||||||
|
i18nKey: 'PLAUSIBLE',
|
||||||
|
icon: 'i-woot-plausible',
|
||||||
|
format: /^[a-z0-9]([a-z0-9.-]*[a-z0-9])?$/i,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'amplitude_api_key',
|
||||||
|
i18nKey: 'AMPLITUDE',
|
||||||
|
icon: 'i-logos-amplitude-icon',
|
||||||
|
format: /^[a-z0-9]+$/i,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'clarity_project_id',
|
||||||
|
i18nKey: 'CLARITY',
|
||||||
|
icon: 'i-woot-microsoft-clarity',
|
||||||
|
format: /^[a-z0-9]+$/i,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'meta_pixel_id',
|
||||||
|
i18nKey: 'META_PIXEL',
|
||||||
|
icon: 'i-logos-meta-icon',
|
||||||
|
format: /^\d+$/,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const portalConfig = computed(() => props.activePortal?.config || {});
|
||||||
|
|
||||||
|
const liveChatWidgets = computed(() => {
|
||||||
|
const widgetOptions = store.getters['inboxes/getInboxes']
|
||||||
|
.filter(inbox => inbox.channel_type === 'Channel::WebWidget')
|
||||||
|
.map(inbox => ({ value: inbox.id, label: inbox.name }));
|
||||||
|
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
value: '',
|
||||||
|
label: t(
|
||||||
|
'HELP_CENTER.PORTAL_SETTINGS.INTEGRATIONS.LIVE_CHAT.NONE_OPTION'
|
||||||
|
),
|
||||||
|
},
|
||||||
|
...widgetOptions,
|
||||||
|
];
|
||||||
|
});
|
||||||
|
|
||||||
|
const state = reactive({
|
||||||
|
liveChatWidgetInboxId: '',
|
||||||
|
...Object.fromEntries(ANALYTICS_PROVIDERS.map(({ key }) => [key, ''])),
|
||||||
|
});
|
||||||
|
const originalState = reactive({ ...state });
|
||||||
|
|
||||||
|
const resetFromPortal = () => {
|
||||||
|
state.liveChatWidgetInboxId = props.activePortal?.inbox?.id || '';
|
||||||
|
ANALYTICS_PROVIDERS.forEach(({ key }) => {
|
||||||
|
state[key] = portalConfig.value[key] || '';
|
||||||
|
});
|
||||||
|
Object.assign(originalState, state);
|
||||||
|
};
|
||||||
|
|
||||||
|
watch(() => props.activePortal, resetFromPortal, {
|
||||||
|
immediate: true,
|
||||||
|
deep: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
const liveChatTitle = computed(() =>
|
||||||
|
replaceInstallationName(
|
||||||
|
t('HELP_CENTER.PORTAL_SETTINGS.INTEGRATIONS.LIVE_CHAT.TITLE')
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
const trimmedAnalyticsValues = computed(() =>
|
||||||
|
Object.fromEntries(
|
||||||
|
ANALYTICS_PROVIDERS.map(({ key }) => [key, state[key].trim()])
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
const invalidAnalyticsKeys = computed(() =>
|
||||||
|
ANALYTICS_PROVIDERS.filter(({ key, format }) => {
|
||||||
|
const value = trimmedAnalyticsValues.value[key];
|
||||||
|
return value !== '' && !format.test(value);
|
||||||
|
}).map(({ key }) => key)
|
||||||
|
);
|
||||||
|
|
||||||
|
const isInvalid = key => invalidAnalyticsKeys.value.includes(key);
|
||||||
|
|
||||||
|
const hasChanges = computed(
|
||||||
|
() =>
|
||||||
|
state.liveChatWidgetInboxId !== originalState.liveChatWidgetInboxId ||
|
||||||
|
ANALYTICS_PROVIDERS.some(
|
||||||
|
({ key }) => trimmedAnalyticsValues.value[key] !== originalState[key]
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleSave = () => {
|
||||||
|
emit('updatePortalConfiguration', {
|
||||||
|
id: props.activePortal.id,
|
||||||
|
slug: props.activePortal.slug,
|
||||||
|
inbox_id: state.liveChatWidgetInboxId,
|
||||||
|
config: { ...trimmedAnalyticsValues.value },
|
||||||
|
});
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="flex flex-col w-full gap-6">
|
||||||
|
<div class="flex flex-col gap-2">
|
||||||
|
<h6 class="text-base font-medium text-n-slate-12">
|
||||||
|
{{ t('HELP_CENTER.PORTAL_SETTINGS.INTEGRATIONS.HEADER') }}
|
||||||
|
</h6>
|
||||||
|
<span class="text-sm text-n-slate-11">
|
||||||
|
{{ t('HELP_CENTER.PORTAL_SETTINGS.INTEGRATIONS.DESCRIPTION') }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
class="flex flex-col gap-4 p-4 rounded-xl outline outline-1 outline-n-weak"
|
||||||
|
>
|
||||||
|
<div class="flex items-start gap-3">
|
||||||
|
<div
|
||||||
|
class="flex items-center justify-center rounded-lg size-9 shrink-0 bg-n-alpha-2 text-n-slate-12"
|
||||||
|
>
|
||||||
|
<Icon icon="i-lucide-messages-square" class="size-5" />
|
||||||
|
</div>
|
||||||
|
<div class="flex flex-col gap-0.5">
|
||||||
|
<h6 class="text-sm font-medium text-n-slate-12">
|
||||||
|
{{ liveChatTitle }}
|
||||||
|
</h6>
|
||||||
|
<span class="text-sm text-n-slate-11">
|
||||||
|
{{
|
||||||
|
t(
|
||||||
|
'HELP_CENTER.PORTAL_SETTINGS.INTEGRATIONS.LIVE_CHAT.DESCRIPTION'
|
||||||
|
)
|
||||||
|
}}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<ComboBox
|
||||||
|
v-model="state.liveChatWidgetInboxId"
|
||||||
|
:options="liveChatWidgets"
|
||||||
|
:placeholder="
|
||||||
|
t('HELP_CENTER.PORTAL_SETTINGS.INTEGRATIONS.LIVE_CHAT.PLACEHOLDER')
|
||||||
|
"
|
||||||
|
class="[&>div>button:not(.focused)]:!outline-n-weak"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
v-for="provider in ANALYTICS_PROVIDERS"
|
||||||
|
:key="provider.key"
|
||||||
|
class="flex flex-col gap-4 p-4 rounded-xl outline outline-1 outline-n-weak"
|
||||||
|
>
|
||||||
|
<div class="flex items-start gap-3">
|
||||||
|
<div
|
||||||
|
class="flex items-center justify-center rounded-lg size-9 shrink-0 bg-n-alpha-2 text-n-slate-12"
|
||||||
|
>
|
||||||
|
<Icon :icon="provider.icon" class="size-5" />
|
||||||
|
</div>
|
||||||
|
<div class="flex flex-col gap-0.5">
|
||||||
|
<h6 class="text-sm font-medium text-n-slate-12">
|
||||||
|
{{
|
||||||
|
t(
|
||||||
|
`HELP_CENTER.PORTAL_SETTINGS.INTEGRATIONS.${provider.i18nKey}.TITLE`
|
||||||
|
)
|
||||||
|
}}
|
||||||
|
</h6>
|
||||||
|
<span class="text-sm text-n-slate-11">
|
||||||
|
{{
|
||||||
|
t(
|
||||||
|
`HELP_CENTER.PORTAL_SETTINGS.INTEGRATIONS.${provider.i18nKey}.DESCRIPTION`
|
||||||
|
)
|
||||||
|
}}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Input
|
||||||
|
v-model="state[provider.key]"
|
||||||
|
:placeholder="
|
||||||
|
t(
|
||||||
|
`HELP_CENTER.PORTAL_SETTINGS.INTEGRATIONS.${provider.i18nKey}.PLACEHOLDER`
|
||||||
|
)
|
||||||
|
"
|
||||||
|
:message="
|
||||||
|
isInvalid(provider.key)
|
||||||
|
? t(
|
||||||
|
`HELP_CENTER.PORTAL_SETTINGS.INTEGRATIONS.${provider.i18nKey}.INVALID`
|
||||||
|
)
|
||||||
|
: t(
|
||||||
|
`HELP_CENTER.PORTAL_SETTINGS.INTEGRATIONS.${provider.i18nKey}.HELP`
|
||||||
|
)
|
||||||
|
"
|
||||||
|
:message-type="isInvalid(provider.key) ? 'error' : 'info'"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex justify-end">
|
||||||
|
<Button
|
||||||
|
:label="t('HELP_CENTER.PORTAL_SETTINGS.INTEGRATIONS.SAVE')"
|
||||||
|
:disabled="!hasChanges || invalidAnalyticsKeys.length > 0 || isFetching"
|
||||||
|
@click="handleSave"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
+83
-72
@@ -5,12 +5,12 @@ import { useI18n } from 'vue-i18n';
|
|||||||
import { useMapGetter } from 'dashboard/composables/store.js';
|
import { useMapGetter } from 'dashboard/composables/store.js';
|
||||||
|
|
||||||
import HelpCenterLayout from 'dashboard/components-next/HelpCenter/HelpCenterLayout.vue';
|
import HelpCenterLayout from 'dashboard/components-next/HelpCenter/HelpCenterLayout.vue';
|
||||||
import PortalBaseSettings from 'dashboard/components-next/HelpCenter/Pages/PortalSettingsPage/PortalBaseSettings.vue';
|
import PortalGeneralSettings from './PortalGeneralSettings.vue';
|
||||||
import PortalConfigurationSettings from './PortalConfigurationSettings.vue';
|
import PortalConfigurationSettings from './PortalConfigurationSettings.vue';
|
||||||
import PortalLayoutContentSettings from './PortalLayoutContentSettings.vue';
|
import PortalLayoutContentSettings from './PortalLayoutContentSettings.vue';
|
||||||
import ConfirmDeletePortalDialog from 'dashboard/components-next/HelpCenter/Pages/PortalSettingsPage/ConfirmDeletePortalDialog.vue';
|
import PortalIntegrationsSettings from './PortalIntegrationsSettings.vue';
|
||||||
import Spinner from 'dashboard/components-next/spinner/Spinner.vue';
|
import Spinner from 'dashboard/components-next/spinner/Spinner.vue';
|
||||||
import Button from 'dashboard/components-next/button/Button.vue';
|
import Icon from 'dashboard/components-next/icon/Icon.vue';
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
portals: {
|
portals: {
|
||||||
@@ -34,7 +34,30 @@ const emit = defineEmits([
|
|||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
const route = useRoute();
|
const route = useRoute();
|
||||||
|
|
||||||
const confirmDeletePortalDialogRef = ref(null);
|
const SETTINGS_TABS = [
|
||||||
|
{
|
||||||
|
id: 'general',
|
||||||
|
labelKey: 'HELP_CENTER.PORTAL_SETTINGS.NAV.GENERAL',
|
||||||
|
icon: 'i-lucide-settings-2',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'domain',
|
||||||
|
labelKey: 'HELP_CENTER.PORTAL_SETTINGS.NAV.DOMAIN',
|
||||||
|
icon: 'i-lucide-globe',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'appearance',
|
||||||
|
labelKey: 'HELP_CENTER.PORTAL_SETTINGS.NAV.APPEARANCE',
|
||||||
|
icon: 'i-lucide-palette',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'integrations',
|
||||||
|
labelKey: 'HELP_CENTER.PORTAL_SETTINGS.NAV.INTEGRATIONS',
|
||||||
|
icon: 'i-lucide-blocks',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const activeTab = ref('general');
|
||||||
|
|
||||||
const currentPortalSlug = computed(() => route.params.portalSlug);
|
const currentPortalSlug = computed(() => route.params.portalSlug);
|
||||||
|
|
||||||
@@ -45,8 +68,6 @@ const activePortal = computed(() => {
|
|||||||
return props.portals?.find(portal => portal.slug === currentPortalSlug.value);
|
return props.portals?.find(portal => portal.slug === currentPortalSlug.value);
|
||||||
});
|
});
|
||||||
|
|
||||||
const activePortalName = computed(() => activePortal.value?.name || '');
|
|
||||||
|
|
||||||
const isLoading = computed(() => props.isFetching || isSwitchingPortal.value);
|
const isLoading = computed(() => props.isFetching || isSwitchingPortal.value);
|
||||||
|
|
||||||
const handleUpdatePortal = portal => {
|
const handleUpdatePortal = portal => {
|
||||||
@@ -65,18 +86,16 @@ const handleSendCnameInstructions = payload => {
|
|||||||
emit('sendCnameInstructions', payload);
|
emit('sendCnameInstructions', payload);
|
||||||
};
|
};
|
||||||
|
|
||||||
const openConfirmDeletePortalDialog = () => {
|
const handleDeletePortal = portal => {
|
||||||
confirmDeletePortalDialogRef.value.dialogRef.open();
|
emit('deletePortal', portal);
|
||||||
};
|
|
||||||
|
|
||||||
const handleDeletePortal = () => {
|
|
||||||
emit('deletePortal', activePortal.value);
|
|
||||||
confirmDeletePortalDialogRef.value.dialogRef.close();
|
|
||||||
};
|
};
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<HelpCenterLayout :show-pagination-footer="false">
|
<HelpCenterLayout
|
||||||
|
:show-pagination-footer="false"
|
||||||
|
:breadcrumb-label="t('HELP_CENTER.BREADCRUMB.SETTINGS')"
|
||||||
|
>
|
||||||
<template #content>
|
<template #content>
|
||||||
<div
|
<div
|
||||||
v-if="isLoading"
|
v-if="isLoading"
|
||||||
@@ -84,68 +103,60 @@ const handleDeletePortal = () => {
|
|||||||
>
|
>
|
||||||
<Spinner />
|
<Spinner />
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div v-else-if="activePortal" class="flex items-start w-full gap-8">
|
||||||
v-else-if="activePortal"
|
<nav class="sticky top-0 flex flex-col w-48 gap-0.5 shrink-0 py-1">
|
||||||
class="flex flex-col w-full gap-4 max-w-[40rem] pb-8"
|
<button
|
||||||
>
|
v-for="tab in SETTINGS_TABS"
|
||||||
<PortalBaseSettings
|
:key="tab.id"
|
||||||
:active-portal="activePortal"
|
type="button"
|
||||||
:is-fetching="isFetching"
|
class="flex items-center w-full h-9 gap-2 px-2.5 text-sm transition-colors rounded-lg"
|
||||||
@update-portal="handleUpdatePortal"
|
:class="
|
||||||
/>
|
activeTab === tab.id
|
||||||
<div class="w-full h-px bg-n-weak" />
|
? 'bg-n-alpha-2 text-n-slate-12 font-medium'
|
||||||
<PortalConfigurationSettings
|
: 'text-n-slate-11 hover:bg-n-alpha-1 hover:text-n-slate-12'
|
||||||
:active-portal="activePortal"
|
|
||||||
:is-fetching="isFetching"
|
|
||||||
:is-fetching-status="isFetchingSSLStatus"
|
|
||||||
@update-portal-configuration="handleUpdatePortalConfiguration"
|
|
||||||
@refresh-status="fetchSSLStatus"
|
|
||||||
@send-cname-instructions="handleSendCnameInstructions"
|
|
||||||
/>
|
|
||||||
<div class="w-full h-px bg-n-weak" />
|
|
||||||
<PortalLayoutContentSettings
|
|
||||||
:active-portal="activePortal"
|
|
||||||
:is-fetching="isFetching"
|
|
||||||
@update-portal-configuration="handleUpdatePortalConfiguration"
|
|
||||||
/>
|
|
||||||
<div class="w-full h-px bg-n-weak" />
|
|
||||||
<div class="flex items-end justify-between w-full gap-4">
|
|
||||||
<div class="flex flex-col gap-2">
|
|
||||||
<h6 class="text-base font-medium text-n-slate-12">
|
|
||||||
{{
|
|
||||||
t(
|
|
||||||
'HELP_CENTER.PORTAL_SETTINGS.CONFIGURATION_FORM.DELETE_PORTAL.HEADER'
|
|
||||||
)
|
|
||||||
}}
|
|
||||||
</h6>
|
|
||||||
<span class="text-sm text-n-slate-11">
|
|
||||||
{{
|
|
||||||
t(
|
|
||||||
'HELP_CENTER.PORTAL_SETTINGS.CONFIGURATION_FORM.DELETE_PORTAL.DESCRIPTION'
|
|
||||||
)
|
|
||||||
}}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<Button
|
|
||||||
:label="
|
|
||||||
t(
|
|
||||||
'HELP_CENTER.PORTAL_SETTINGS.CONFIGURATION_FORM.DELETE_PORTAL.BUTTON',
|
|
||||||
{
|
|
||||||
portalName: activePortalName,
|
|
||||||
}
|
|
||||||
)
|
|
||||||
"
|
"
|
||||||
color="ruby"
|
:aria-current="activeTab === tab.id ? 'page' : undefined"
|
||||||
class="max-w-56 !w-fit"
|
@click="activeTab = tab.id"
|
||||||
@click="openConfirmDeletePortalDialog"
|
>
|
||||||
|
<Icon :icon="tab.icon" class="shrink-0 size-4" />
|
||||||
|
{{ t(tab.labelKey) }}
|
||||||
|
</button>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<div class="flex flex-col flex-1 min-w-0 max-w-[40rem] pb-8">
|
||||||
|
<PortalGeneralSettings
|
||||||
|
v-if="activeTab === 'general'"
|
||||||
|
:active-portal="activePortal"
|
||||||
|
:is-fetching="isFetching"
|
||||||
|
@update-portal="handleUpdatePortal"
|
||||||
|
@delete-portal="handleDeletePortal"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<PortalConfigurationSettings
|
||||||
|
v-else-if="activeTab === 'domain'"
|
||||||
|
:active-portal="activePortal"
|
||||||
|
:is-fetching="isFetching"
|
||||||
|
:is-fetching-status="isFetchingSSLStatus"
|
||||||
|
@update-portal-configuration="handleUpdatePortalConfiguration"
|
||||||
|
@refresh-status="fetchSSLStatus"
|
||||||
|
@send-cname-instructions="handleSendCnameInstructions"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<PortalLayoutContentSettings
|
||||||
|
v-else-if="activeTab === 'appearance'"
|
||||||
|
:active-portal="activePortal"
|
||||||
|
:is-fetching="isFetching"
|
||||||
|
@update-portal-configuration="handleUpdatePortalConfiguration"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<PortalIntegrationsSettings
|
||||||
|
v-else-if="activeTab === 'integrations'"
|
||||||
|
:active-portal="activePortal"
|
||||||
|
:is-fetching="isFetching"
|
||||||
|
@update-portal-configuration="handleUpdatePortalConfiguration"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
<ConfirmDeletePortalDialog
|
|
||||||
ref="confirmDeletePortalDialogRef"
|
|
||||||
:active-portal-name="activePortalName"
|
|
||||||
@delete-portal="handleDeletePortal"
|
|
||||||
/>
|
|
||||||
</HelpCenterLayout>
|
</HelpCenterLayout>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -0,0 +1,27 @@
|
|||||||
|
<script setup>
|
||||||
|
import Avatar from 'dashboard/components-next/avatar/Avatar.vue';
|
||||||
|
|
||||||
|
// Renders a portal's logo consistently across the help center — the layout
|
||||||
|
// header, the portal switcher, and portal settings — so the icon fallback
|
||||||
|
// and shape stay in one place instead of being copied per call site.
|
||||||
|
defineProps({
|
||||||
|
src: { type: String, default: '' },
|
||||||
|
name: { type: String, default: '' },
|
||||||
|
size: { type: Number, default: 24 },
|
||||||
|
allowUpload: { type: Boolean, default: false },
|
||||||
|
});
|
||||||
|
|
||||||
|
const emit = defineEmits(['upload', 'delete']);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<Avatar
|
||||||
|
:src="src"
|
||||||
|
:name="name"
|
||||||
|
:size="size"
|
||||||
|
:allow-upload="allowUpload"
|
||||||
|
icon-name="i-lucide-building-2"
|
||||||
|
@upload="emit('upload', $event)"
|
||||||
|
@delete="emit('delete')"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
+2
-4
@@ -6,7 +6,7 @@ import { useMapGetter, useStore } from 'dashboard/composables/store.js';
|
|||||||
import { buildPortalURL } from 'dashboard/helper/portalHelper';
|
import { buildPortalURL } from 'dashboard/helper/portalHelper';
|
||||||
|
|
||||||
import Button from 'dashboard/components-next/button/Button.vue';
|
import Button from 'dashboard/components-next/button/Button.vue';
|
||||||
import Avatar from 'dashboard/components-next/avatar/Avatar.vue';
|
import PortalAvatar from 'dashboard/components-next/HelpCenter/PortalAvatar.vue';
|
||||||
|
|
||||||
const emit = defineEmits(['close', 'createPortal']);
|
const emit = defineEmits(['close', 'createPortal']);
|
||||||
|
|
||||||
@@ -151,13 +151,11 @@ const redirectToPortalHomePage = () => {
|
|||||||
<span class="text-sm font-medium truncate text-n-slate-12">
|
<span class="text-sm font-medium truncate text-n-slate-12">
|
||||||
{{ portal.name || '' }}
|
{{ portal.name || '' }}
|
||||||
</span>
|
</span>
|
||||||
<Avatar
|
<PortalAvatar
|
||||||
v-if="portal"
|
v-if="portal"
|
||||||
:name="portal.name"
|
:name="portal.name"
|
||||||
:src="getPortalThumbnailSrc(portal)"
|
:src="getPortalThumbnailSrc(portal)"
|
||||||
:size="20"
|
:size="20"
|
||||||
icon-name="i-lucide-building-2"
|
|
||||||
rounded-full
|
|
||||||
/>
|
/>
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -404,6 +404,12 @@
|
|||||||
"PLACEHOLDER": "Search for articles"
|
"PLACEHOLDER": "Search for articles"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"BREADCRUMB": {
|
||||||
|
"ARTICLES": "Articles",
|
||||||
|
"CATEGORIES": "Categories",
|
||||||
|
"LOCALES": "Locales",
|
||||||
|
"SETTINGS": "Settings"
|
||||||
|
},
|
||||||
"CATEGORY": {
|
"CATEGORY": {
|
||||||
"ADD": {
|
"ADD": {
|
||||||
"TITLE": "Create a category",
|
"TITLE": "Create a category",
|
||||||
@@ -806,12 +812,6 @@
|
|||||||
"LABEL": "Slug",
|
"LABEL": "Slug",
|
||||||
"PLACEHOLDER": "Portal slug"
|
"PLACEHOLDER": "Portal slug"
|
||||||
},
|
},
|
||||||
"LIVE_CHAT_WIDGET": {
|
|
||||||
"LABEL": "Live chat widget",
|
|
||||||
"PLACEHOLDER": "Select live chat widget",
|
|
||||||
"HELP_TEXT": "Select a live chat widget that will appear on your help center",
|
|
||||||
"NONE_OPTION": "No widget"
|
|
||||||
},
|
|
||||||
"BRAND_COLOR": {
|
"BRAND_COLOR": {
|
||||||
"LABEL": "Brand color"
|
"LABEL": "Brand color"
|
||||||
},
|
},
|
||||||
@@ -888,6 +888,72 @@
|
|||||||
},
|
},
|
||||||
"SAVE": "Save changes"
|
"SAVE": "Save changes"
|
||||||
},
|
},
|
||||||
|
"INTEGRATIONS": {
|
||||||
|
"HEADER": "Integrations",
|
||||||
|
"DESCRIPTION": "Connect tools that extend what your help center can do.",
|
||||||
|
"LIVE_CHAT": {
|
||||||
|
"TITLE": "Live chat with Chatwoot",
|
||||||
|
"DESCRIPTION": "Show a live chat widget on your help center so visitors can reach you while they read.",
|
||||||
|
"PLACEHOLDER": "Select live chat widget",
|
||||||
|
"NONE_OPTION": "No widget"
|
||||||
|
},
|
||||||
|
"GTM": {
|
||||||
|
"TITLE": "Google Tag Manager",
|
||||||
|
"DESCRIPTION": "Add analytics and marketing tags by connecting your Tag Manager container.",
|
||||||
|
"PLACEHOLDER": "GTM-XXXXXXX",
|
||||||
|
"HELP": "Find this in your Google Tag Manager workspace. Leave empty to disable.",
|
||||||
|
"INVALID": "Enter a valid container ID, for example GTM-XXXXXXX."
|
||||||
|
},
|
||||||
|
"GA4": {
|
||||||
|
"TITLE": "Google Analytics 4",
|
||||||
|
"DESCRIPTION": "Measure traffic and visitor behavior on your help center with Google Analytics.",
|
||||||
|
"PLACEHOLDER": "G-XXXXXXXXXX",
|
||||||
|
"HELP": "Find the measurement ID under Admin → Data streams in Google Analytics. Leave empty to disable.",
|
||||||
|
"INVALID": "Enter a valid measurement ID, for example G-XXXXXXXXXX."
|
||||||
|
},
|
||||||
|
"HOTJAR": {
|
||||||
|
"TITLE": "Hotjar",
|
||||||
|
"DESCRIPTION": "Understand how visitors use your help center with heatmaps and session recordings.",
|
||||||
|
"PLACEHOLDER": "1234567",
|
||||||
|
"HELP": "Find your site ID under Sites & organizations in Hotjar. Leave empty to disable.",
|
||||||
|
"INVALID": "Enter a numeric Hotjar site ID."
|
||||||
|
},
|
||||||
|
"PLAUSIBLE": {
|
||||||
|
"TITLE": "Plausible",
|
||||||
|
"DESCRIPTION": "Track help center traffic with privacy-friendly Plausible Analytics.",
|
||||||
|
"PLACEHOLDER": "example.com",
|
||||||
|
"HELP": "Enter the domain configured in your Plausible site settings. Leave empty to disable.",
|
||||||
|
"INVALID": "Enter a valid domain, for example example.com."
|
||||||
|
},
|
||||||
|
"AMPLITUDE": {
|
||||||
|
"TITLE": "Amplitude",
|
||||||
|
"DESCRIPTION": "Analyze visitor behavior on your help center with Amplitude.",
|
||||||
|
"PLACEHOLDER": "0123456789abcdef0123456789abcdef",
|
||||||
|
"HELP": "Find the API key in your Amplitude project settings. Leave empty to disable.",
|
||||||
|
"INVALID": "Enter a valid Amplitude API key."
|
||||||
|
},
|
||||||
|
"CLARITY": {
|
||||||
|
"TITLE": "Microsoft Clarity",
|
||||||
|
"DESCRIPTION": "Capture heatmaps and session recordings with Microsoft Clarity.",
|
||||||
|
"PLACEHOLDER": "abcd1234ef",
|
||||||
|
"HELP": "Find the project ID in your Clarity project settings. Leave empty to disable.",
|
||||||
|
"INVALID": "Enter a valid Clarity project ID."
|
||||||
|
},
|
||||||
|
"META_PIXEL": {
|
||||||
|
"TITLE": "Meta Pixel",
|
||||||
|
"DESCRIPTION": "Measure ad conversions and build audiences from help center visits with Meta Pixel.",
|
||||||
|
"PLACEHOLDER": "123456789012345",
|
||||||
|
"HELP": "Find the pixel ID in Meta Events Manager. Leave empty to disable.",
|
||||||
|
"INVALID": "Enter a numeric pixel ID."
|
||||||
|
},
|
||||||
|
"SAVE": "Save changes"
|
||||||
|
},
|
||||||
|
"NAV": {
|
||||||
|
"GENERAL": "General",
|
||||||
|
"DOMAIN": "Domain",
|
||||||
|
"APPEARANCE": "Appearance",
|
||||||
|
"INTEGRATIONS": "Integrations"
|
||||||
|
},
|
||||||
"API": {
|
"API": {
|
||||||
"CREATE_PORTAL": {
|
"CREATE_PORTAL": {
|
||||||
"SUCCESS_MESSAGE": "Portal created successfully",
|
"SUCCESS_MESSAGE": "Portal created successfully",
|
||||||
|
|||||||
+28
-1
@@ -43,10 +43,28 @@ class Portal < ApplicationRecord
|
|||||||
validates :slug, presence: true, uniqueness: true
|
validates :slug, presence: true, uniqueness: true
|
||||||
validates :custom_domain, uniqueness: true, allow_nil: true
|
validates :custom_domain, uniqueness: true, allow_nil: true
|
||||||
validate :config_json_format
|
validate :config_json_format
|
||||||
|
validate :analytics_config_format
|
||||||
|
|
||||||
scope :active, -> { where(archived: false) }
|
scope :active, -> { where(archived: false) }
|
||||||
|
|
||||||
CONFIG_JSON_KEYS = %w[allowed_locales default_locale draft_locales website_token social_profiles layout].freeze
|
# Analytics values are restricted to shapes safe to interpolate into the help center
|
||||||
|
# markup (no quotes/angle brackets). Mirrored by ANALYTICS_PROVIDERS in the frontend.
|
||||||
|
ANALYTICS_CONFIG_FORMATS = {
|
||||||
|
'gtm_container_id' => /\AGTM-[A-Z0-9]+\z/,
|
||||||
|
'ga4_measurement_id' => /\AG-[A-Z0-9]+\z/,
|
||||||
|
'hotjar_site_id' => /\A\d+\z/,
|
||||||
|
'plausible_domain' => /\A[a-z0-9]([a-z0-9.-]*[a-z0-9])?\z/i,
|
||||||
|
'amplitude_api_key' => /\A[a-z0-9]+\z/i,
|
||||||
|
'clarity_project_id' => /\A[a-z0-9]+\z/i,
|
||||||
|
'meta_pixel_id' => /\A\d+\z/
|
||||||
|
}.freeze
|
||||||
|
|
||||||
|
CONFIG_JSON_KEYS = (%w[allowed_locales default_locale draft_locales website_token social_profiles layout] +
|
||||||
|
ANALYTICS_CONFIG_FORMATS.keys).freeze
|
||||||
|
|
||||||
|
ANALYTICS_CONFIG_FORMATS.each_key do |key|
|
||||||
|
define_method(key) { config_value(key).presence }
|
||||||
|
end
|
||||||
|
|
||||||
def file_base_data
|
def file_base_data
|
||||||
{
|
{
|
||||||
@@ -104,6 +122,15 @@ class Portal < ApplicationRecord
|
|||||||
|
|
||||||
private
|
private
|
||||||
|
|
||||||
|
def analytics_config_format
|
||||||
|
ANALYTICS_CONFIG_FORMATS.each do |key, format|
|
||||||
|
value = config_value(key)
|
||||||
|
next if value.blank?
|
||||||
|
|
||||||
|
errors.add(:config, "#{key.humanize} is invalid") unless value.to_s.match?(format)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
def config_json_format
|
def config_json_format
|
||||||
self.config = persisted_config.merge((config || {}).deep_stringify_keys)
|
self.config = persisted_config.merge((config || {}).deep_stringify_keys)
|
||||||
config['allowed_locales'] = allowed_locale_codes
|
config['allowed_locales'] = allowed_locale_codes
|
||||||
|
|||||||
@@ -18,6 +18,13 @@ json.config do
|
|||||||
json.default_locale portal.default_locale
|
json.default_locale portal.default_locale
|
||||||
json.layout portal.layout
|
json.layout portal.layout
|
||||||
json.social_profiles portal.social_profiles
|
json.social_profiles portal.social_profiles
|
||||||
|
json.gtm_container_id portal.gtm_container_id
|
||||||
|
json.ga4_measurement_id portal.ga4_measurement_id
|
||||||
|
json.hotjar_site_id portal.hotjar_site_id
|
||||||
|
json.plausible_domain portal.plausible_domain
|
||||||
|
json.amplitude_api_key portal.amplitude_api_key
|
||||||
|
json.clarity_project_id portal.clarity_project_id
|
||||||
|
json.meta_pixel_id portal.meta_pixel_id
|
||||||
end
|
end
|
||||||
|
|
||||||
if portal.channel_web_widget
|
if portal.channel_web_widget
|
||||||
|
|||||||
@@ -0,0 +1,76 @@
|
|||||||
|
<%# Analytics tags. Every value below is validated against Portal::ANALYTICS_CONFIG_FORMATS, %>
|
||||||
|
<%# so it is restricted to a safe shape (no quotes/angle brackets) before interpolation. %>
|
||||||
|
<% if @portal.gtm_container_id.present? %>
|
||||||
|
<!-- Google Tag Manager -->
|
||||||
|
<script>
|
||||||
|
(function(w,d,s,l,i){w[l]=w[l]||[];w[l].push({'gtm.start':
|
||||||
|
new Date().getTime(),event:'gtm.js'});var f=d.getElementsByTagName(s)[0],
|
||||||
|
j=d.createElement(s),dl=l!='dataLayer'?'&l='+l:'';j.async=true;j.src=
|
||||||
|
'https://www.googletagmanager.com/gtm.js?id='+i+dl;f.parentNode.insertBefore(j,f);
|
||||||
|
})(window,document,'script','dataLayer','<%= j @portal.gtm_container_id %>');
|
||||||
|
</script>
|
||||||
|
<!-- End Google Tag Manager -->
|
||||||
|
<% end %>
|
||||||
|
<% if @portal.ga4_measurement_id.present? %>
|
||||||
|
<!-- Google Analytics 4 -->
|
||||||
|
<script async src="https://www.googletagmanager.com/gtag/js?id=<%= @portal.ga4_measurement_id %>"></script>
|
||||||
|
<script>
|
||||||
|
window.dataLayer = window.dataLayer || [];
|
||||||
|
function gtag(){dataLayer.push(arguments);}
|
||||||
|
gtag('js', new Date());
|
||||||
|
gtag('config', '<%= j @portal.ga4_measurement_id %>');
|
||||||
|
</script>
|
||||||
|
<!-- End Google Analytics 4 -->
|
||||||
|
<% end %>
|
||||||
|
<% if @portal.hotjar_site_id.present? %>
|
||||||
|
<!-- Hotjar -->
|
||||||
|
<script>
|
||||||
|
(function(h,o,t,j,a,r){
|
||||||
|
h.hj=h.hj||function(){(h.hj.q=h.hj.q||[]).push(arguments)};
|
||||||
|
h._hjSettings={hjid:<%= @portal.hotjar_site_id.to_i %>,hjsv:6};
|
||||||
|
a=o.getElementsByTagName('head')[0];
|
||||||
|
r=o.createElement('script');r.async=1;
|
||||||
|
r.src=t+h._hjSettings.hjid+j+h._hjSettings.hjsv;
|
||||||
|
a.appendChild(r);
|
||||||
|
})(window,document,'https://static.hotjar.com/c/hotjar-','.js?sv=');
|
||||||
|
</script>
|
||||||
|
<!-- End Hotjar -->
|
||||||
|
<% end %>
|
||||||
|
<% if @portal.plausible_domain.present? %>
|
||||||
|
<!-- Plausible -->
|
||||||
|
<script defer data-domain="<%= @portal.plausible_domain %>" src="https://plausible.io/js/script.js"></script>
|
||||||
|
<!-- End Plausible -->
|
||||||
|
<% end %>
|
||||||
|
<% if @portal.amplitude_api_key.present? %>
|
||||||
|
<!-- Amplitude -->
|
||||||
|
<script src="https://cdn.amplitude.com/script/<%= @portal.amplitude_api_key %>.js"></script>
|
||||||
|
<script>window.amplitude.init('<%= j @portal.amplitude_api_key %>');</script>
|
||||||
|
<!-- End Amplitude -->
|
||||||
|
<% end %>
|
||||||
|
<% if @portal.clarity_project_id.present? %>
|
||||||
|
<!-- Microsoft Clarity -->
|
||||||
|
<script>
|
||||||
|
(function(c,l,a,r,i,t,y){
|
||||||
|
c[a]=c[a]||function(){(c[a].q=c[a].q||[]).push(arguments)};
|
||||||
|
t=l.createElement(r);t.async=1;t.src="https://www.clarity.ms/tag/"+i;
|
||||||
|
y=l.getElementsByTagName(r)[0];y.parentNode.insertBefore(t,y);
|
||||||
|
})(window, document, "clarity", "script", "<%= j @portal.clarity_project_id %>");
|
||||||
|
</script>
|
||||||
|
<!-- End Microsoft Clarity -->
|
||||||
|
<% end %>
|
||||||
|
<% if @portal.meta_pixel_id.present? %>
|
||||||
|
<!-- Meta Pixel -->
|
||||||
|
<script>
|
||||||
|
!function(f,b,e,v,n,t,s)
|
||||||
|
{if(f.fbq)return;n=f.fbq=function(){n.callMethod?
|
||||||
|
n.callMethod.apply(n,arguments):n.queue.push(arguments)};
|
||||||
|
if(!f._fbq)f._fbq=n;n.push=n;n.loaded=!0;n.version='2.0';
|
||||||
|
n.queue=[];t=b.createElement(e);t.async=!0;
|
||||||
|
t.src=v;s=b.getElementsByTagName(e)[0];
|
||||||
|
s.parentNode.insertBefore(t,s)}(window, document,'script',
|
||||||
|
'https://connect.facebook.net/en_US/fbevents.js');
|
||||||
|
fbq('init', '<%= j @portal.meta_pixel_id %>');
|
||||||
|
fbq('track', 'PageView');
|
||||||
|
</script>
|
||||||
|
<!-- End Meta Pixel -->
|
||||||
|
<% end %>
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
<% if @portal.gtm_container_id.present? %>
|
||||||
|
<!-- Google Tag Manager (noscript) -->
|
||||||
|
<noscript><iframe src="https://www.googletagmanager.com/ns.html?id=<%= @portal.gtm_container_id %>"
|
||||||
|
height="0" width="0" style="display:none;visibility:hidden"></iframe></noscript>
|
||||||
|
<!-- End Google Tag Manager (noscript) -->
|
||||||
|
<% end %>
|
||||||
|
<% if @portal.meta_pixel_id.present? %>
|
||||||
|
<!-- Meta Pixel (noscript) -->
|
||||||
|
<noscript><img height="1" width="1" style="display:none"
|
||||||
|
src="https://www.facebook.com/tr?id=<%= @portal.meta_pixel_id %>&ev=PageView&noscript=1"/></noscript>
|
||||||
|
<!-- End Meta Pixel (noscript) -->
|
||||||
|
<% end %>
|
||||||
@@ -3,6 +3,8 @@
|
|||||||
<meta name="turbolinks-cache-control" content="no-cache">
|
<meta name="turbolinks-cache-control" content="no-cache">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
|
||||||
|
<%= render 'layouts/portal_analytics' %>
|
||||||
|
|
||||||
<%= vite_client_tag %>
|
<%= vite_client_tag %>
|
||||||
<%= vite_javascript_tag 'portal' %>
|
<%= vite_javascript_tag 'portal' %>
|
||||||
<style>
|
<style>
|
||||||
|
|||||||
@@ -4,6 +4,7 @@
|
|||||||
<%= render 'layouts/portal_head' %>
|
<%= render 'layouts/portal_head' %>
|
||||||
</head>
|
</head>
|
||||||
<body class="font-inter">
|
<body class="font-inter">
|
||||||
|
<%= render 'layouts/portal_analytics_noscript' %>
|
||||||
<div id="portal" class="antialiased">
|
<div id="portal" class="antialiased">
|
||||||
<main class="flex flex-col min-h-screen bg-white main-content dark:bg-slate-900" role="main">
|
<main class="flex flex-col min-h-screen bg-white main-content dark:bg-slate-900" role="main">
|
||||||
<%= render 'public/api/v1/portals/documentation_layout/topbar',
|
<%= render 'public/api/v1/portals/documentation_layout/topbar',
|
||||||
|
|||||||
@@ -4,6 +4,7 @@
|
|||||||
<%= render 'layouts/portal_head' %>
|
<%= render 'layouts/portal_head' %>
|
||||||
</head>
|
</head>
|
||||||
<body class="font-default">
|
<body class="font-default">
|
||||||
|
<%= render 'layouts/portal_analytics_noscript' %>
|
||||||
<div id="portal" class="antialiased">
|
<div id="portal" class="antialiased">
|
||||||
<main class="flex flex-col min-h-screen bg-white main-content dark:bg-slate-900" role="main">
|
<main class="flex flex-col min-h-screen bg-white main-content dark:bg-slate-900" role="main">
|
||||||
<%= render 'public/api/v1/portals/header', portal: @portal unless @is_plain_layout_enabled %>
|
<%= render 'public/api/v1/portals/header', portal: @portal unless @is_plain_layout_enabled %>
|
||||||
|
|||||||
@@ -173,7 +173,14 @@ RSpec.describe 'Api::V1::Accounts::Portals', type: :request do
|
|||||||
],
|
],
|
||||||
'default_locale' => 'en',
|
'default_locale' => 'en',
|
||||||
'layout' => 'classic',
|
'layout' => 'classic',
|
||||||
'social_profiles' => {}
|
'social_profiles' => {},
|
||||||
|
'gtm_container_id' => nil,
|
||||||
|
'ga4_measurement_id' => nil,
|
||||||
|
'hotjar_site_id' => nil,
|
||||||
|
'plausible_domain' => nil,
|
||||||
|
'amplitude_api_key' => nil,
|
||||||
|
'clarity_project_id' => nil,
|
||||||
|
'meta_pixel_id' => nil
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -428,5 +428,15 @@ export const icons = {
|
|||||||
width: 15,
|
width: 15,
|
||||||
height: 15,
|
height: 15,
|
||||||
},
|
},
|
||||||
|
plausible: {
|
||||||
|
body: `<linearGradient id="SVGcy95AgrO" x1="189.056" x2="296.848" y1="470.428" y2="659.063" gradientTransform="translate(0 -278.024)" gradientUnits="userSpaceOnUse"><stop offset="0" stop-color="#909cf7"/><stop offset="1" stop-color="#4b38d8"/></linearGradient><path fill="url(#SVGcy95AgrO)" d="M448.6 192.9c-9.3 89.2-87.3 155.5-177 155.5H237v81.7c0 45.2-36.7 81.9-81.9 81.9h-64c-15.8 0-28.7-12.8-28.7-28.7V315.2l43-60.3c7.8-10.9 22.1-15 34.4-9.8l24.5 10.2c12.3 5.2 26.6 1.1 34.3-9.8l57.3-80.4c7.7-10.9 22-14.9 34.3-9.7l47.1 19.8c12.3 5.2 26.6 1.1 34.3-9.8l55.1-77.3c17.4 30.4 25.9 66.5 21.9 104.8"/><linearGradient id="SVGjYVYSdaH" x1="130.554" x2="241.634" y1="266.456" y2="460.846" gradientTransform="translate(0 -278.024)" gradientUnits="userSpaceOnUse"><stop offset="0" stop-color="#909cf7"/><stop offset="1" stop-color="#4b38d8"/></linearGradient><path fill="url(#SVGjYVYSdaH)" d="M90.6 246.4c7-9.9 17.2-17.4 29.1-19.7c9.3-1.8 18.4-.8 26.9 2.7l24.4 10.2c1.4.6 2.9.9 4.4.9c3.7 0 7.2-1.8 9.4-4.8l56.3-78.9c7-9.8 17.2-17.4 29.1-19.7c9.2-1.8 18.3-.8 26.7 2.7l47.1 19.8c1.4.6 2.9.9 4.4.9c3.7 0 7.2-1.8 9.4-4.8l59-82.8C385.3 28.7 333.7 0 275.3 0H91.1C75.3 0 62.5 12.8 62.5 28.7v257.1z"/>`,
|
||||||
|
width: 512,
|
||||||
|
height: 512,
|
||||||
|
},
|
||||||
|
'microsoft-clarity': {
|
||||||
|
body: `<path fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" d="M24 7.69a2.1 2.1 0 0 0-1.823 1.052l-16.4 28.412A2.104 2.104 0 0 0 7.6 40.31h32.8a2.104 2.104 0 0 0 1.823-3.156l-16.4-28.412A2.1 2.1 0 0 0 24 7.689"/><path fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" d="m32.656 20.58l-20.349 5.262l29.821 13.565"/>`,
|
||||||
|
width: 48,
|
||||||
|
height: 48,
|
||||||
|
},
|
||||||
/** Ends */
|
/** Ends */
|
||||||
};
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user