feat: add analytics providers to help center (#15124)

This commit is contained in:
Sivin Varghese
2026-08-03 10:39:02 +05:30
committed by GitHub
parent bc7ae88d5e
commit 6347ad1926
26 changed files with 808 additions and 162 deletions

View File

@@ -3,6 +3,7 @@ class Api::V1::Accounts::PortalsController < Api::V1::Accounts::BaseController
before_action :fetch_portal, except: [:index, :create]
before_action :check_authorization
before_action :validate_analytics_params, only: [:create, :update]
before_action :set_current_page, only: [:index]
def index
@@ -23,6 +24,9 @@ class Api::V1::Accounts::PortalsController < Api::V1::Accounts::BaseController
def update
ActiveRecord::Base.transaction do
# Lock the row so concurrent saves merge onto the latest committed config
# instead of a stale snapshot, which would drop the other save's keys.
@portal.lock!
@portal.update!(portal_params.merge(live_chat_widget_params)) if params[:portal].present?
# @portal.custom_domain = parsed_custom_domain
process_attached_logo if params[:blob_id].present?
@@ -75,17 +79,37 @@ class Api::V1::Accounts::PortalsController < Api::V1::Accounts::BaseController
params.permit(:id, :email)
end
def validate_analytics_params
analytics = params.dig(:portal, :config, :analytics)
return if analytics.blank?
valid = analytics.respond_to?(:each_pair) &&
analytics.keys.all? { |key| Portal::ANALYTICS_CONFIG_FORMATS.key?(key.to_s) } &&
analytics.values.all?(String)
return if valid
render json: { error: I18n.t('portals.analytics.invalid_configuration') }, status: :unprocessable_entity
end
def portal_params
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: [] },
{ social_profiles: %i[facebook x instagram linkedin youtube tiktok github whatsapp] },
{ locale_translations: locale_translation_keys.index_with { %i[name page_title header_text] } },
{ popular_content: popular_content_keys.index_with { { category_ids: [], article_ids: [] } } }] }
{ config: config_param_keys }
)
end
def config_param_keys
keys = [:default_locale, :layout, { allowed_locales: [] }, { draft_locales: [] },
{ social_profiles: %i[facebook x instagram linkedin youtube tiktok github whatsapp] },
{ locale_translations: locale_translation_keys.index_with { %i[name page_title header_text] } },
{ popular_content: popular_content_keys.index_with { { category_ids: [], article_ids: [] } } }]
# Analytics injects tracking scripts into every public page, so keep it admin-only even though
# Enterprise lets knowledge_base_manage roles edit other portal settings.
keys << { analytics: Portal::ANALYTICS_CONFIG_FORMATS.keys.map(&:to_sym) } if Current.account_user&.administrator?
keys
end
def locale_translation_keys
params.dig(:portal, :config, :locale_translations)&.keys || []
end

View File

@@ -30,6 +30,10 @@ defineProps({
type: Boolean,
default: true,
},
breadcrumbLabel: {
type: String,
default: '',
},
});
const emit = defineEmits(['update:currentPage']);
@@ -44,10 +48,11 @@ 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 updateCurrentPage = page => {
emit('update:currentPage', page);
@@ -65,32 +70,40 @@ const togglePortalSwitcher = () => {
v-if="showHeaderTitle"
class="flex items-center justify-start h-20 gap-2"
>
<span
v-if="activePortalName"
class="min-w-0 text-xl font-medium truncate text-n-slate-12"
>
{{ activePortalName }}
</span>
<div v-if="activePortalName" class="relative shrink-0 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 min-w-0 gap-3">
<div class="flex items-center min-w-0 gap-1.5">
<span class="text-lg font-medium truncate text-n-slate-12">
{{ activePortalName }}
</span>
<div class="relative shrink-0 group">
<OnClickOutside @trigger="showPortalSwitcher = false">
<Button
icon="i-lucide-chevron-down"
:variant="showPortalSwitcher ? 'faded' : 'ghost'"
slate
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 truncate text-n-slate-12">
{{ breadcrumbLabel }}
</span>
</template>
</nav>
<div class="flex justify-end min-w-0 grow">
<slot name="title-actions" />
</div>

View File

@@ -344,6 +344,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"
>

View File

@@ -123,7 +123,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
v-model:search-query="searchQuery"

View File

@@ -51,7 +51,10 @@ const hasResults = computed(() => filteredLocales.value?.length > 0);
</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">

View File

@@ -13,7 +13,6 @@ 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 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);
};
@@ -303,26 +281,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]"
>

View File

@@ -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>

View File

@@ -0,0 +1,238 @@
<script setup>
import { computed, reactive, watch } from 'vue';
import { useI18n } from 'vue-i18n';
import { useStore, useMapGetter } from 'dashboard/composables/store';
import { useAdmin } from 'dashboard/composables/useAdmin';
import { useBranding } from 'shared/composables/useBranding';
import IntegrationCard from 'dashboard/components-next/integration-card/IntegrationCard.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 uiFlagsIn = useMapGetter('portals/uiFlagsIn');
const { isAdmin } = useAdmin();
const { replaceInstallationName } = useBranding();
// Provider keys and formats, mirroring Portal::ANALYTICS_CONFIG_FORMATS. Admin-only
// (also enforced on the backend) since these inject tracking scripts into public pages.
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 isUpdatingPortal = computed(() => {
const slug = props.activePortal?.slug;
if (slug) return uiFlagsIn.value(slug)?.isUpdating;
return false;
});
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.analytics?.[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 = () => {
const analytics = Object.fromEntries(
Object.entries(trimmedAnalyticsValues.value).filter(([, value]) => value)
);
emit('updatePortalConfiguration', {
id: props.activePortal.id,
slug: props.activePortal.slug,
inbox_id: state.liveChatWidgetInboxId,
config: { analytics },
});
};
</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>
<IntegrationCard
icon="i-lucide-messages-square"
:title="liveChatTitle"
:description="
t('HELP_CENTER.PORTAL_SETTINGS.INTEGRATIONS.LIVE_CHAT.DESCRIPTION')
"
>
<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"
/>
</IntegrationCard>
<template v-if="isAdmin">
<IntegrationCard
v-for="provider in ANALYTICS_PROVIDERS"
:key="provider.key"
:icon="provider.icon"
:title="
t(
`HELP_CENTER.PORTAL_SETTINGS.INTEGRATIONS.${provider.i18nKey}.TITLE`
)
"
:description="
t(
`HELP_CENTER.PORTAL_SETTINGS.INTEGRATIONS.${provider.i18nKey}.DESCRIPTION`
)
"
>
<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'"
/>
</IntegrationCard>
</template>
<div class="flex justify-end">
<Button
:label="t('HELP_CENTER.PORTAL_SETTINGS.INTEGRATIONS.SAVE')"
:disabled="
!hasChanges ||
invalidAnalyticsKeys.length > 0 ||
isFetching ||
isUpdatingPortal
"
:is-loading="isUpdatingPortal"
@click="handleSave"
/>
</div>
</div>
</template>

View File

@@ -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 VerticalTabs from 'dashboard/components-next/vertical-tabs/VerticalTabs.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';
const props = defineProps({
portals: {
@@ -34,7 +34,30 @@ const emit = defineEmits([
const { t } = useI18n();
const route = useRoute();
const confirmDeletePortalDialogRef = ref(null);
const activeTab = ref('general');
const settingsTabs = computed(() => [
{
id: 'general',
label: t('HELP_CENTER.PORTAL_SETTINGS.NAV.GENERAL'),
icon: 'i-lucide-settings-2',
},
{
id: 'domain',
label: t('HELP_CENTER.PORTAL_SETTINGS.NAV.DOMAIN'),
icon: 'i-lucide-globe',
},
{
id: 'appearance',
label: t('HELP_CENTER.PORTAL_SETTINGS.NAV.APPEARANCE'),
icon: 'i-lucide-palette',
},
{
id: 'integrations',
label: t('HELP_CENTER.PORTAL_SETTINGS.NAV.INTEGRATIONS'),
icon: 'i-lucide-blocks',
},
]);
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,48 @@ const handleDeletePortal = () => {
>
<Spinner />
</div>
<div
<VerticalTabs
v-else-if="activePortal"
class="flex flex-col w-full gap-4 max-w-[40rem] pb-8"
v-model="activeTab"
:tabs="settingsTabs"
content-class="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,
}
)
"
color="ruby"
class="max-w-56 !w-fit"
@click="openConfirmDeletePortalDialog"
<template #general>
<PortalGeneralSettings
:active-portal="activePortal"
:is-fetching="isFetching"
@update-portal="handleUpdatePortal"
@delete-portal="handleDeletePortal"
/>
</div>
</div>
</template>
<template #domain>
<PortalConfigurationSettings
:active-portal="activePortal"
:is-fetching="isFetching"
:is-fetching-status="isFetchingSSLStatus"
@update-portal-configuration="handleUpdatePortalConfiguration"
@refresh-status="fetchSSLStatus"
@send-cname-instructions="handleSendCnameInstructions"
/>
</template>
<template #appearance>
<PortalLayoutContentSettings
:active-portal="activePortal"
:is-fetching="isFetching"
@update-portal-configuration="handleUpdatePortalConfiguration"
/>
</template>
<template #integrations>
<PortalIntegrationsSettings
:active-portal="activePortal"
:is-fetching="isFetching"
@update-portal-configuration="handleUpdatePortalConfiguration"
/>
</template>
</VerticalTabs>
</template>
<ConfirmDeletePortalDialog
ref="confirmDeletePortalDialogRef"
:active-portal-name="activePortalName"
@delete-portal="handleDeletePortal"
/>
</HelpCenterLayout>
</template>

View File

@@ -93,7 +93,7 @@ const redirectToPortalHomePage = () => {
<template>
<div
class="pt-5 pb-3 bg-n-alpha-3 backdrop-blur-[100px] outline outline-n-container outline-1 z-50 absolute w-[27.5rem] rounded-xl shadow-md flex flex-col gap-4"
class="pt-5 bg-n-alpha-3 backdrop-blur-[100px] outline outline-n-container outline-1 z-50 absolute w-[27.5rem] max-h-96 rounded-xl shadow-md flex flex-col gap-4"
>
<div
class="flex items-center justify-between gap-4 px-6 pb-3 border-b border-n-alpha-2"
@@ -129,7 +129,10 @@ const redirectToPortalHomePage = () => {
@click="openCreatePortalDialog"
/>
</div>
<div v-if="portals.length > 0" class="flex flex-col gap-2 px-4">
<div
v-if="portals.length > 0"
class="flex flex-col flex-1 min-h-0 gap-2 px-4 pb-3 overflow-y-auto overscroll-contain"
>
<Button
v-for="(portal, index) in portals"
:key="index"
@@ -157,7 +160,6 @@ const redirectToPortalHomePage = () => {
:src="getPortalThumbnailSrc(portal)"
:size="20"
icon-name="i-lucide-building-2"
rounded-full
/>
</Button>
</div>

View File

@@ -0,0 +1,30 @@
<script setup>
import Icon from 'dashboard/components-next/icon/Icon.vue';
defineProps({
icon: { type: [String, Object, Function], required: true },
title: { type: String, required: true },
description: { type: String, default: '' },
});
</script>
<template>
<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="icon" class="size-5" />
</div>
<div class="flex flex-col gap-0.5">
<h6 class="text-sm font-medium text-n-slate-12">{{ title }}</h6>
<span v-if="description" class="text-sm text-n-slate-11">
{{ description }}
</span>
</div>
</div>
<slot />
</div>
</template>

View File

@@ -0,0 +1,55 @@
<script setup>
import Icon from 'dashboard/components-next/icon/Icon.vue';
defineProps({
tabs: {
type: Array,
required: true,
validator: value => value.every(tab => tab.id && tab.label),
},
contentClass: {
type: String,
default: '',
},
});
const activeTab = defineModel({ type: String, required: true });
</script>
<template>
<div class="flex flex-col w-full gap-4 md:flex-row md:items-start md:gap-8">
<!-- Horizontal scrollable tab row on small screens; vertical rail from md up. -->
<nav
class="flex flex-row w-full gap-1 pb-2 overflow-x-auto no-scrollbar border-b shrink-0 border-n-weak md:sticky md:top-0 md:flex-col md:w-48 md:gap-0.5 md:border-b-0 md:overflow-visible"
>
<button
v-for="tab in tabs"
:key="tab.id"
type="button"
class="flex items-center h-9 gap-2 px-2.5 text-sm transition-colors rounded-lg shrink-0 md:w-full"
: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'
"
:aria-current="activeTab === tab.id ? 'page' : undefined"
@click="activeTab = tab.id"
>
<Icon v-if="tab.icon" :icon="tab.icon" class="shrink-0 size-4" />
{{ tab.label }}
</button>
</nav>
<div class="flex flex-col flex-1 w-full min-w-0" :class="contentClass">
<!-- Keep every panel mounted and toggle visibility so unsaved drafts survive tab switches. -->
<div
v-for="tab in tabs"
v-show="activeTab === tab.id"
:key="tab.id"
class="flex flex-col w-full min-w-0"
>
<slot :name="tab.id" />
</div>
</div>
</div>
</template>

View File

@@ -404,6 +404,12 @@
"PLACEHOLDER": "Search for articles"
}
},
"BREADCRUMB": {
"ARTICLES": "Articles",
"CATEGORIES": "Categories",
"LOCALES": "Locales",
"SETTINGS": "Settings"
},
"CATEGORY": {
"ADD": {
"TITLE": "Create a category",
@@ -890,12 +896,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"
},
@@ -972,6 +972,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",

View File

@@ -33,6 +33,7 @@ const state = {
allFetched: false,
isFetching: false,
isSwitching: false,
isFetchingSSLStatus: false,
},
};

View File

@@ -43,6 +43,20 @@ module PortalConfigSchema
'popular_content' => {
'type' => %w[object null],
'additionalProperties' => POPULAR_CONTENT_SCHEMA
},
# Analytics ids grouped under one object (see Portal::ANALYTICS_CONFIG_FORMATS).
'analytics' => {
'type' => %w[object null],
'properties' => {
'gtm_container_id' => { 'type' => %w[string null] },
'ga4_measurement_id' => { 'type' => %w[string null] },
'hotjar_site_id' => { 'type' => %w[string null] },
'plausible_domain' => { 'type' => %w[string null] },
'amplitude_api_key' => { 'type' => %w[string null] },
'clarity_project_id' => { 'type' => %w[string null] },
'meta_pixel_id' => { 'type' => %w[string null] }
},
'additionalProperties' => false
}
},
'required' => [],

View File

@@ -46,15 +46,38 @@ class Portal < ApplicationRecord
validates :color, format: { with: /\A#(?:\h{3}|\h{6})\z/ }, allow_blank: true
before_validation :normalize_config
validate :validate_config
validate :validate_analytics
validates_with JsonSchemaValidator,
schema: PortalConfigSchema::CONFIG_PARAMS_SCHEMA,
attribute_resolver: ->(record) { record.config }
scope :active, -> { where(archived: false) }
# Analytics id fields and the format each must match. Formats keep values safe to
# interpolate into markup. Add a provider here and its snippet in _portal_analytics.html.erb.
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
# TODO: 'website_token' is an unused reserved key; remove with a migration that scrubs it from existing portals' config
CONFIG_JSON_KEYS = %w[allowed_locales default_locale draft_locales website_token social_profiles layout locale_translations
popular_content].freeze
CONFIG_JSON_KEYS = %w[allowed_locales default_locale draft_locales website_token social_profiles layout
locale_translations popular_content analytics].freeze
def analytics
value = config_value('analytics')
value.is_a?(Hash) ? value : {}
end
# Reader per analytics id (e.g. portal.ga4_measurement_id) so the snippet partials stay simple.
ANALYTICS_CONFIG_FORMATS.each_key do |key|
define_method(key) { analytics[key].presence }
end
# Max number of recommended categories/articles shown per locale.
POPULAR_CATEGORY_LIMIT = 3
@@ -147,6 +170,15 @@ class Portal < ApplicationRecord
errors.add(:config, 'default locale cannot be drafted.') if draft_locale?(default_locale)
end
def validate_analytics
ANALYTICS_CONFIG_FORMATS.each do |key, format|
value = analytics[key]
next if value.blank?
errors.add(:config, "#{key.humanize} is invalid") unless value.to_s.match?(format)
end
end
def normalize_locale_codes(locale_codes)
Array(locale_codes).filter_map(&:presence).uniq
end

View File

@@ -20,6 +20,7 @@ json.config do
json.social_profiles portal.social_profiles
json.locale_translations portal.config['locale_translations'] || {}
json.popular_content portal.config['popular_content'] || {}
json.analytics portal.analytics
end
if portal.channel_web_widget

View File

@@ -0,0 +1,77 @@
<%# 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 (analytics-only SDK, not the unified loader) -->
<script src="https://cdn.amplitude.com/libs/analytics-browser-2.45.5-min.js.gz"></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 %>');
// Bind to turbo:load so PageView re-fires on Turbo navigations, not just initial load.
document.addEventListener('turbo:load', function () { fbq('track', 'PageView'); });
</script>
<!-- End Meta Pixel -->
<% end %>

View File

@@ -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 %>"
title="Google Tag Manager" 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 %>

View File

@@ -23,6 +23,9 @@
<link rel="icon" href="<%= url_for(@portal.logo) %>">
<% end %>
<%# Skip analytics on internal plain-layout previews (dashboard article preview iframe) so they don't count as visitor traffic. %>
<%= render 'layouts/portal_analytics' unless @is_plain_layout_enabled %>
<% unless @theme_from_params.blank? %>
<%# this adds the theme from params, ensuring that there a localstorage value set %>
<%# this will further trigger the next script to ensure color mode is toggled without a FOUC %>

View File

@@ -4,6 +4,7 @@
<%= render 'layouts/portal_head' %>
</head>
<body class="font-inter bg-white dark:bg-slate-900">
<%= render 'layouts/portal_analytics_noscript' %>
<div id="portal" class="antialiased">
<main class="flex flex-col min-h-screen main-content" role="main">
<%= render 'public/api/v1/portals/documentation_layout/topbar',

View File

@@ -4,6 +4,7 @@
<%= render 'layouts/portal_head' %>
</head>
<body class="font-default bg-white dark:bg-slate-900">
<%= render 'layouts/portal_analytics_noscript' %>
<div id="portal" class="antialiased">
<main class="flex flex-col min-h-screen main-content" role="main">
<%= render 'public/api/v1/portals/header', portal: @portal %>

View File

@@ -594,6 +594,8 @@ en:
agent_capacity_policy:
inbox_already_assigned: 'Inbox has already been assigned to this policy'
portals:
analytics:
invalid_configuration: 'Invalid analytics configuration'
articles:
captain_not_available: 'Translation requires Captain to be enabled for this account'
locale_not_available: 'Locale not available in this portal'

View File

@@ -175,11 +175,21 @@ RSpec.describe 'Api::V1::Accounts::Portals', type: :request do
'layout' => 'classic',
'social_profiles' => {},
'locale_translations' => {},
'popular_content' => {}
'popular_content' => {},
'analytics' => {}
}
)
end
it 'allows administrators to set analytics config' do
put "/api/v1/accounts/#{account.id}/portals/#{portal.slug}",
params: { portal: { config: { analytics: { ga4_measurement_id: 'G-ADMIN12345' } } } },
headers: admin.create_new_auth_token
expect(response).to have_http_status(:success)
expect(portal.reload.config['analytics']).to eq('ga4_measurement_id' => 'G-ADMIN12345')
end
it 'preserves drafted locales when draft_locales is omitted' do
portal.update!(config: { allowed_locales: %w[en es fr], draft_locales: ['es'], default_locale: 'en' })

View File

@@ -85,6 +85,17 @@ RSpec.describe 'Enterprise Portal API', type: :request do
json_response = response.parsed_body
expect(json_response['name']).to eq('updated_portal')
end
it 'ignores analytics config for knowledge_base_manage users' do
put "/api/v1/accounts/#{account.id}/portals/#{portal.slug}",
params: { portal: { name: 'updated_portal', config: { analytics: { ga4_measurement_id: 'G-KBMANAGER1' } } } },
headers: agent_with_role.create_new_auth_token,
as: :json
expect(response).to have_http_status(:success)
expect(portal.reload.name).to eq('updated_portal')
expect(portal.config['analytics']).to be_blank
end
end
end

View File

@@ -490,5 +490,15 @@ export const icons = {
width: 14,
height: 14,
},
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="#50B5F0" d="M24 5 6 42 24 34Z"/><path fill="#2178C9" d="M24 5 24 34 42 42Z"/><path fill="#14548F" d="M6 42 24 34 42 42Z"/>`,
width: 48,
height: 48,
},
/** Ends */
};