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