Compare commits

...
Author SHA1 Message Date
Pranav 7d55725780 feat(help-center): add more analytics integrations to portal settings
Adds Google Analytics 4, Hotjar, Plausible, Amplitude, Microsoft Clarity,
and Meta Pixel alongside Google Tag Manager. Provider IDs are stored in
portal config, validated against safe formats, and rendered as official
embed snippets on the public help center.
2026-07-22 18:26:25 -07:00
Pranav 2ed7394adc Update header, add a sidebar to the design 2026-05-19 11:52:51 -07:00
Pranav a727c14a2c gtm_container_id to settings 2026-05-19 11:43:55 -07:00
Pranav 5aa1a768f9 Add GTM to the settings page 2026-05-19 11:43:06 -07:00
22 changed files with 731 additions and 165 deletions
@@ -79,7 +79,8 @@ class Api::V1::Accounts::PortalsController < Api::V1::Accounts::BaseController
params.require(:portal).permit(
:id, :color, :custom_domain, :header_text, :homepage_link,
: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] }] }
)
end
@@ -5,6 +5,7 @@ import { useRoute } from 'vue-router';
import { useMapGetter } from 'dashboard/composables/store.js';
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 PortalSwitcher from 'dashboard/components-next/HelpCenter/PortalSwitcher/PortalSwitcher.vue';
import CreatePortalDialog from 'dashboard/components-next/HelpCenter/PortalSwitcher/CreatePortalDialog.vue';
@@ -30,6 +31,10 @@ defineProps({
type: Boolean,
default: true,
},
breadcrumbLabel: {
type: String,
default: '',
},
});
const emit = defineEmits(['update:currentPage']);
@@ -44,10 +49,15 @@ const portals = useMapGetter('portals/allPortals');
const currentPortalSlug = computed(() => route.params.portalSlug);
const activePortalName = computed(() => {
return portals.value?.find(portal => portal.slug === currentPortalSlug.value)
?.name;
});
const activePortal = computed(() =>
portals.value?.find(portal => portal.slug === currentPortalSlug.value)
);
const activePortalName = computed(() => activePortal.value?.name);
const activePortalLogo = computed(
() => activePortal.value?.logo?.file_url || ''
);
const updateCurrentPage = page => {
emit('update:currentPage', page);
@@ -65,32 +75,45 @@ const togglePortalSwitcher = () => {
v-if="showHeaderTitle"
class="flex items-center justify-start h-20 gap-2"
>
<span
v-if="activePortalName"
class="text-xl font-medium text-n-slate-12"
>
{{ activePortalName }}
</span>
<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"
<nav v-if="activePortalName" class="flex items-center gap-2">
<div class="flex items-center gap-1.5">
<PortalAvatar
:src="activePortalLogo"
:name="activePortalName"
:size="24"
/>
<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
v-if="showPortalSwitcher"
class="absolute ltr:left-0 rtl:right-0 top-9"
@close="showPortalSwitcher = false"
@create-portal="createPortalDialogRef.dialogRef.open()"
/>
</OnClickOutside>
<CreatePortalDialog ref="createPortalDialogRef" />
</div>
<PortalSwitcher
v-if="showPortalSwitcher"
class="absolute ltr:left-0 rtl:right-0 top-9"
@close="showPortalSwitcher = false"
@create-portal="createPortalDialogRef.dialogRef.open()"
/>
</OnClickOutside>
<CreatePortalDialog ref="createPortalDialogRef" />
</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>
<slot name="header-actions" />
</div>
@@ -256,6 +256,7 @@ watch(
:total-items="articlesCount"
:items-per-page="25"
:header="portalName"
:breadcrumb-label="$t('HELP_CENTER.BREADCRUMB.ARTICLES')"
:show-pagination-footer="shouldShowPaginationFooter"
@update:current-page="handlePageChange"
>
@@ -112,7 +112,10 @@ const reorderCategories = async reorderedGroup => {
</script>
<template>
<HelpCenterLayout :show-pagination-footer="false">
<HelpCenterLayout
:show-pagination-footer="false"
:breadcrumb-label="$t('HELP_CENTER.BREADCRUMB.CATEGORIES')"
>
<template #header-actions>
<CategoryHeaderControls
:categories="categories"
@@ -31,7 +31,10 @@ const localeCount = computed(() => props.locales?.length);
</script>
<template>
<HelpCenterLayout :show-pagination-footer="false">
<HelpCenterLayout
:show-pagination-footer="false"
:breadcrumb-label="$t('HELP_CENTER.BREADCRUMB.LOCALES')"
>
<template #header-actions>
<div class="flex items-center justify-between">
<div class="flex items-center gap-4">
@@ -12,8 +12,7 @@ import { isValidSlug } from 'shared/helpers/Validators';
import Button from 'dashboard/components-next/button/Button.vue';
import Input from 'dashboard/components-next/input/Input.vue';
import Avatar from 'dashboard/components-next/avatar/Avatar.vue';
import ComboBox from 'dashboard/components-next/combobox/ComboBox.vue';
import PortalAvatar from 'dashboard/components-next/HelpCenter/PortalAvatar.vue';
import ColorPicker from 'dashboard/components-next/colorpicker/ColorPicker.vue';
const props = defineProps({
@@ -42,31 +41,12 @@ const state = reactive({
slug: '',
widgetColor: '',
homePageLink: '',
liveChatWidgetInboxId: '',
logoUrl: '',
avatarBlobId: '',
});
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 = {
name: { required, minLength: minLength(2) },
slug: {
@@ -116,7 +96,6 @@ watch(
widgetColor: newVal.color,
homePageLink: newVal.homepage_link,
slug: newVal.slug,
liveChatWidgetInboxId: newVal.inbox?.id || '',
});
if (newVal.logo) {
const {
@@ -148,7 +127,6 @@ const handleUpdatePortal = () => {
header_text: state.headerText,
homepage_link: state.homePageLink,
blob_id: state.avatarBlobId,
inbox_id: state.liveChatWidgetInboxId,
};
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">
{{ t('HELP_CENTER.PORTAL_SETTINGS.FORM.AVATAR.LABEL') }}
</label>
<Avatar
<PortalAvatar
:src="state.logoUrl"
:name="state.name"
:size="72"
allow-upload
icon-name="i-lucide-building-2"
@upload="handleAvatarUpload"
@delete="handleAvatarDelete"
/>
@@ -303,26 +280,6 @@ const handleAvatarDelete = () => {
@blur="v$.slug.$touch()"
/>
</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
class="grid items-start justify-between w-full gap-2 grid-cols-[200px,1fr]"
>
@@ -1,5 +1,5 @@
<script setup>
import { computed, ref } from 'vue';
import { computed, ref, watch } from 'vue';
import { useI18n } from 'vue-i18n';
import { useAccount } from 'dashboard/composables/useAccount';
@@ -62,6 +62,7 @@ const { isOnChatwootCloud } = useAccount();
const addCustomDomainDialogRef = ref(null);
const dnsConfigurationDialogRef = ref(null);
const updatedDomainAddress = ref('');
const pendingDomain = ref('');
const customDomainAddress = computed(
() => props.activePortal?.custom_domain || ''
@@ -111,14 +112,23 @@ const updatePortalConfiguration = customDomain => {
id: props.activePortal?.id,
custom_domain: customDomain,
};
pendingDomain.value = customDomain;
emit('updatePortalConfiguration', portal);
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 = () => {
updatedDomainAddress.value = '';
dnsConfigurationDialogRef.value.dialogRef.close();
@@ -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>
@@ -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>
@@ -5,12 +5,12 @@ import { useI18n } from 'vue-i18n';
import { useMapGetter } from 'dashboard/composables/store.js';
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 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 Button from 'dashboard/components-next/button/Button.vue';
import Icon from 'dashboard/components-next/icon/Icon.vue';
const props = defineProps({
portals: {
@@ -34,7 +34,30 @@ const emit = defineEmits([
const { t } = useI18n();
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);
@@ -45,8 +68,6 @@ const activePortal = computed(() => {
return props.portals?.find(portal => portal.slug === currentPortalSlug.value);
});
const activePortalName = computed(() => activePortal.value?.name || '');
const isLoading = computed(() => props.isFetching || isSwitchingPortal.value);
const handleUpdatePortal = portal => {
@@ -65,18 +86,16 @@ const handleSendCnameInstructions = payload => {
emit('sendCnameInstructions', payload);
};
const openConfirmDeletePortalDialog = () => {
confirmDeletePortalDialogRef.value.dialogRef.open();
};
const handleDeletePortal = () => {
emit('deletePortal', activePortal.value);
confirmDeletePortalDialogRef.value.dialogRef.close();
const handleDeletePortal = portal => {
emit('deletePortal', portal);
};
</script>
<template>
<HelpCenterLayout :show-pagination-footer="false">
<HelpCenterLayout
:show-pagination-footer="false"
:breadcrumb-label="t('HELP_CENTER.BREADCRUMB.SETTINGS')"
>
<template #content>
<div
v-if="isLoading"
@@ -84,68 +103,60 @@ const handleDeletePortal = () => {
>
<Spinner />
</div>
<div
v-else-if="activePortal"
class="flex flex-col w-full gap-4 max-w-[40rem] pb-8"
>
<PortalBaseSettings
:active-portal="activePortal"
:is-fetching="isFetching"
@update-portal="handleUpdatePortal"
/>
<div class="w-full h-px bg-n-weak" />
<PortalConfigurationSettings
: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,
}
)
<div v-else-if="activePortal" class="flex items-start w-full gap-8">
<nav class="sticky top-0 flex flex-col w-48 gap-0.5 shrink-0 py-1">
<button
v-for="tab in SETTINGS_TABS"
:key="tab.id"
type="button"
class="flex items-center w-full h-9 gap-2 px-2.5 text-sm transition-colors rounded-lg"
:class="
activeTab === tab.id
? 'bg-n-alpha-2 text-n-slate-12 font-medium'
: 'text-n-slate-11 hover:bg-n-alpha-1 hover:text-n-slate-12'
"
color="ruby"
class="max-w-56 !w-fit"
@click="openConfirmDeletePortalDialog"
:aria-current="activeTab === tab.id ? 'page' : undefined"
@click="activeTab = tab.id"
>
<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>
</template>
<ConfirmDeletePortalDialog
ref="confirmDeletePortalDialogRef"
:active-portal-name="activePortalName"
@delete-portal="handleDeletePortal"
/>
</HelpCenterLayout>
</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>
@@ -6,7 +6,7 @@ import { useMapGetter, useStore } from 'dashboard/composables/store.js';
import { buildPortalURL } from 'dashboard/helper/portalHelper';
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']);
@@ -151,13 +151,11 @@ const redirectToPortalHomePage = () => {
<span class="text-sm font-medium truncate text-n-slate-12">
{{ portal.name || '' }}
</span>
<Avatar
<PortalAvatar
v-if="portal"
:name="portal.name"
:src="getPortalThumbnailSrc(portal)"
:size="20"
icon-name="i-lucide-building-2"
rounded-full
/>
</Button>
</div>
@@ -404,6 +404,12 @@
"PLACEHOLDER": "Search for articles"
}
},
"BREADCRUMB": {
"ARTICLES": "Articles",
"CATEGORIES": "Categories",
"LOCALES": "Locales",
"SETTINGS": "Settings"
},
"CATEGORY": {
"ADD": {
"TITLE": "Create a category",
@@ -806,12 +812,6 @@
"LABEL": "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": {
"LABEL": "Brand color"
},
@@ -888,6 +888,72 @@
},
"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": {
"CREATE_PORTAL": {
"SUCCESS_MESSAGE": "Portal created successfully",
+28 -1
View File
@@ -43,10 +43,28 @@ class Portal < ApplicationRecord
validates :slug, presence: true, uniqueness: true
validates :custom_domain, uniqueness: true, allow_nil: true
validate :config_json_format
validate :analytics_config_format
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
{
@@ -104,6 +122,15 @@ class Portal < ApplicationRecord
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
self.config = persisted_config.merge((config || {}).deep_stringify_keys)
config['allowed_locales'] = allowed_locale_codes
@@ -18,6 +18,13 @@ json.config do
json.default_locale portal.default_locale
json.layout portal.layout
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
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 %>
+2
View File
@@ -3,6 +3,8 @@
<meta name="turbolinks-cache-control" content="no-cache">
<meta name="viewport" content="width=device-width, initial-scale=1">
<%= render 'layouts/portal_analytics' %>
<%= vite_client_tag %>
<%= vite_javascript_tag 'portal' %>
<style>
@@ -4,6 +4,7 @@
<%= render 'layouts/portal_head' %>
</head>
<body class="font-inter">
<%= render 'layouts/portal_analytics_noscript' %>
<div id="portal" class="antialiased">
<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',
+1
View File
@@ -4,6 +4,7 @@
<%= render 'layouts/portal_head' %>
</head>
<body class="font-default">
<%= render 'layouts/portal_analytics_noscript' %>
<div id="portal" class="antialiased">
<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 %>
@@ -173,7 +173,14 @@ RSpec.describe 'Api::V1::Accounts::Portals', type: :request do
],
'default_locale' => 'en',
'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
+10
View File
@@ -428,5 +428,15 @@ export const icons = {
width: 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 */
};