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