feat: add analytics providers to help center (#15124)
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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"
|
||||
>
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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]"
|
||||
>
|
||||
|
||||
@@ -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,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>
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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",
|
||||
|
||||
@@ -33,6 +33,7 @@ const state = {
|
||||
allFetched: false,
|
||||
isFetching: false,
|
||||
isSwitching: false,
|
||||
isFetchingSSLStatus: false,
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@@ -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' => [],
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
77
app/views/layouts/_portal_analytics.html.erb
Normal file
77
app/views/layouts/_portal_analytics.html.erb
Normal 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 %>
|
||||
12
app/views/layouts/_portal_analytics_noscript.html.erb
Normal file
12
app/views/layouts/_portal_analytics_noscript.html.erb
Normal 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 %>
|
||||
@@ -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 %>
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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 %>
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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' })
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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 */
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user