Compare commits
6
Commits
develop
...
feat/CW-6641
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d316ee0710 | ||
|
|
0bf535db54 | ||
|
|
22e58e0e4e | ||
|
|
6e46af2381 | ||
|
|
12c5ef0ce5 | ||
|
|
a361462274 |
@@ -79,13 +79,21 @@ 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: [] },
|
||||
{ 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: %i[ga4 gtm clarity hotjar meta_pixel] } if Current.account_user&.administrator?
|
||||
keys
|
||||
end
|
||||
|
||||
def locale_translation_keys
|
||||
params.dig(:portal, :config, :locale_translations)&.keys || []
|
||||
end
|
||||
|
||||
+148
@@ -0,0 +1,148 @@
|
||||
<script setup>
|
||||
import { computed, reactive, watch } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
|
||||
import Button from 'dashboard/components-next/button/Button.vue';
|
||||
import Input from 'dashboard/components-next/input/Input.vue';
|
||||
import Select from 'dashboard/components-next/select/Select.vue';
|
||||
|
||||
const props = defineProps({
|
||||
activePortal: { type: Object, required: true },
|
||||
isFetching: { type: Boolean, default: false },
|
||||
});
|
||||
|
||||
const emit = defineEmits(['updatePortalConfiguration']);
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
// `pattern` mirrors Portal::ANALYTICS_PROVIDERS to flag bad ids before saving.
|
||||
const PROVIDERS = computed(() => [
|
||||
{
|
||||
key: 'ga4',
|
||||
name: t('HELP_CENTER.PORTAL_SETTINGS.ANALYTICS.GA4.LABEL'),
|
||||
placeholder: 'G-XXXXXXXXXX',
|
||||
pattern: /^G-[A-Z0-9]+$/,
|
||||
},
|
||||
{
|
||||
key: 'gtm',
|
||||
name: t('HELP_CENTER.PORTAL_SETTINGS.ANALYTICS.GTM.LABEL'),
|
||||
placeholder: 'GTM-XXXXXXX',
|
||||
pattern: /^GTM-[A-Z0-9]+$/,
|
||||
},
|
||||
{
|
||||
key: 'clarity',
|
||||
name: t('HELP_CENTER.PORTAL_SETTINGS.ANALYTICS.CLARITY.LABEL'),
|
||||
placeholder: 'abcd1234ef',
|
||||
pattern: /^[a-z0-9]+$/,
|
||||
},
|
||||
{
|
||||
key: 'hotjar',
|
||||
name: t('HELP_CENTER.PORTAL_SETTINGS.ANALYTICS.HOTJAR.LABEL'),
|
||||
placeholder: '1234567',
|
||||
pattern: /^\d+$/,
|
||||
},
|
||||
{
|
||||
key: 'meta_pixel',
|
||||
name: t('HELP_CENTER.PORTAL_SETTINGS.ANALYTICS.META_PIXEL.LABEL'),
|
||||
placeholder: '1234567890',
|
||||
pattern: /^\d+$/,
|
||||
},
|
||||
]);
|
||||
|
||||
const portalConfig = computed(() => props.activePortal?.config || {});
|
||||
const providerFor = key =>
|
||||
PROVIDERS.value.find(provider => provider.key === key);
|
||||
|
||||
const state = reactive({ provider: PROVIDERS.value[0].key, id: '' });
|
||||
let saved = { provider: '', id: '' };
|
||||
|
||||
const resetFromPortal = () => {
|
||||
const analytics = portalConfig.value.analytics || {};
|
||||
const [key, value] = Object.entries(analytics)[0] || [];
|
||||
saved = { provider: key || '', id: value || '' };
|
||||
state.provider = saved.provider || PROVIDERS.value[0].key;
|
||||
state.id = saved.id;
|
||||
};
|
||||
|
||||
watch(() => props.activePortal, resetFromPortal, {
|
||||
immediate: true,
|
||||
deep: true,
|
||||
});
|
||||
|
||||
// Clear the id when switching to a provider other than the saved one.
|
||||
watch(
|
||||
() => state.provider,
|
||||
provider => {
|
||||
state.id = provider === saved.provider ? saved.id : '';
|
||||
}
|
||||
);
|
||||
|
||||
const providerOptions = computed(() =>
|
||||
PROVIDERS.value.map(provider => ({
|
||||
value: provider.key,
|
||||
label: provider.name,
|
||||
}))
|
||||
);
|
||||
|
||||
const currentProvider = computed(() => providerFor(state.provider));
|
||||
|
||||
const error = computed(() => {
|
||||
const value = state.id.trim();
|
||||
if (!value || currentProvider.value.pattern.test(value)) return '';
|
||||
return t('HELP_CENTER.PORTAL_SETTINGS.ANALYTICS.INVALID_ID');
|
||||
});
|
||||
|
||||
const hasChanges = computed(() => {
|
||||
const id = state.id.trim();
|
||||
if (!id) return saved.id !== '';
|
||||
return id !== saved.id || state.provider !== saved.provider;
|
||||
});
|
||||
|
||||
const handleSave = () => {
|
||||
const id = state.id.trim();
|
||||
const analytics = id ? { [state.provider]: id } : {};
|
||||
emit('updatePortalConfiguration', {
|
||||
id: props.activePortal.id,
|
||||
slug: props.activePortal.slug,
|
||||
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.ANALYTICS.HEADER') }}
|
||||
</h6>
|
||||
<span class="text-sm text-n-slate-11">
|
||||
{{ t('HELP_CENTER.PORTAL_SETTINGS.ANALYTICS.DESCRIPTION') }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<section class="flex flex-col gap-4">
|
||||
<div class="flex flex-col gap-1">
|
||||
<label class="text-sm font-medium text-n-slate-12">
|
||||
{{ t('HELP_CENTER.PORTAL_SETTINGS.ANALYTICS.PROVIDER') }}
|
||||
</label>
|
||||
<Select v-model="state.provider" :options="providerOptions" />
|
||||
</div>
|
||||
|
||||
<Input
|
||||
v-model="state.id"
|
||||
:label="currentProvider.name"
|
||||
:placeholder="currentProvider.placeholder"
|
||||
:message="error"
|
||||
:message-type="error ? 'error' : 'info'"
|
||||
/>
|
||||
</section>
|
||||
|
||||
<div class="flex justify-end">
|
||||
<Button
|
||||
:label="t('HELP_CENTER.PORTAL_SETTINGS.ANALYTICS.SAVE')"
|
||||
:disabled="!hasChanges || Boolean(error) || isFetching"
|
||||
@click="handleSave"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
+11
@@ -3,11 +3,13 @@ import { computed, ref } from 'vue';
|
||||
import { useRoute } from 'vue-router';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useMapGetter } from 'dashboard/composables/store.js';
|
||||
import { useAdmin } from 'dashboard/composables/useAdmin';
|
||||
|
||||
import HelpCenterLayout from 'dashboard/components-next/HelpCenter/HelpCenterLayout.vue';
|
||||
import PortalBaseSettings from 'dashboard/components-next/HelpCenter/Pages/PortalSettingsPage/PortalBaseSettings.vue';
|
||||
import PortalConfigurationSettings from './PortalConfigurationSettings.vue';
|
||||
import PortalLayoutContentSettings from './PortalLayoutContentSettings.vue';
|
||||
import PortalAnalyticsSettings from './PortalAnalyticsSettings.vue';
|
||||
import ConfirmDeletePortalDialog from 'dashboard/components-next/HelpCenter/Pages/PortalSettingsPage/ConfirmDeletePortalDialog.vue';
|
||||
import Spinner from 'dashboard/components-next/spinner/Spinner.vue';
|
||||
import Button from 'dashboard/components-next/button/Button.vue';
|
||||
@@ -33,6 +35,7 @@ const emit = defineEmits([
|
||||
|
||||
const { t } = useI18n();
|
||||
const route = useRoute();
|
||||
const { isAdmin } = useAdmin();
|
||||
|
||||
const confirmDeletePortalDialogRef = ref(null);
|
||||
|
||||
@@ -108,6 +111,14 @@ const handleDeletePortal = () => {
|
||||
:is-fetching="isFetching"
|
||||
@update-portal-configuration="handleUpdatePortalConfiguration"
|
||||
/>
|
||||
<template v-if="isAdmin">
|
||||
<div class="w-full h-px bg-n-weak" />
|
||||
<PortalAnalyticsSettings
|
||||
:active-portal="activePortal"
|
||||
:is-fetching="isFetching"
|
||||
@update-portal-configuration="handleUpdatePortalConfiguration"
|
||||
/>
|
||||
</template>
|
||||
<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">
|
||||
|
||||
@@ -972,6 +972,28 @@
|
||||
},
|
||||
"SAVE": "Save changes"
|
||||
},
|
||||
"ANALYTICS": {
|
||||
"HEADER": "Analytics",
|
||||
"DESCRIPTION": "Choose an analytics provider and enter its ID. We add the tracking code to every public help center page automatically. Switching providers replaces the previous one.",
|
||||
"PROVIDER": "Provider",
|
||||
"INVALID_ID": "This doesn't look like a valid ID for this provider.",
|
||||
"GA4": {
|
||||
"LABEL": "Google Analytics (GA4)"
|
||||
},
|
||||
"GTM": {
|
||||
"LABEL": "Google Tag Manager"
|
||||
},
|
||||
"CLARITY": {
|
||||
"LABEL": "Microsoft Clarity"
|
||||
},
|
||||
"HOTJAR": {
|
||||
"LABEL": "Hotjar"
|
||||
},
|
||||
"META_PIXEL": {
|
||||
"LABEL": "Meta Pixel"
|
||||
},
|
||||
"SAVE": "Save changes"
|
||||
},
|
||||
"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,11 @@ module PortalConfigSchema
|
||||
'popular_content' => {
|
||||
'type' => %w[object null],
|
||||
'additionalProperties' => POPULAR_CONTENT_SCHEMA
|
||||
},
|
||||
# Analytics ids keyed by provider, e.g. { "ga4" => "G-XXXX" }.
|
||||
'analytics' => {
|
||||
'type' => %w[object null],
|
||||
'additionalProperties' => { 'type' => %w[string null] }
|
||||
}
|
||||
},
|
||||
'required' => [],
|
||||
|
||||
+30
-1
@@ -46,6 +46,7 @@ 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 }
|
||||
@@ -54,7 +55,17 @@ class Portal < ApplicationRecord
|
||||
|
||||
# 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
|
||||
popular_content analytics].freeze
|
||||
|
||||
# Analytics providers and their accepted id formats. Add a provider here and its
|
||||
# snippet in layouts/_portal_analytics.html.erb.
|
||||
ANALYTICS_PROVIDERS = {
|
||||
'ga4' => /\AG-[A-Z0-9]+\z/,
|
||||
'gtm' => /\AGTM-[A-Z0-9]+\z/,
|
||||
'clarity' => /\A[a-z0-9]+\z/,
|
||||
'hotjar' => /\A\d+\z/,
|
||||
'meta_pixel' => /\A\d+\z/
|
||||
}.freeze
|
||||
|
||||
# Max number of recommended categories/articles shown per locale.
|
||||
POPULAR_CATEGORY_LIMIT = 3
|
||||
@@ -132,6 +143,11 @@ class Portal < ApplicationRecord
|
||||
config_value('social_profiles') || {}
|
||||
end
|
||||
|
||||
# Valid analytics ids keyed by provider; drops unknown providers and blank ids.
|
||||
def analytics
|
||||
(config_value('analytics') || {}).select { |provider, id| ANALYTICS_PROVIDERS.key?(provider) && id.present? }
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def normalize_config
|
||||
@@ -147,6 +163,19 @@ class Portal < ApplicationRecord
|
||||
errors.add(:config, 'default locale cannot be drafted.') if draft_locale?(default_locale)
|
||||
end
|
||||
|
||||
def validate_analytics
|
||||
(config_value('analytics') || {}).each do |provider, id|
|
||||
next if id.blank?
|
||||
|
||||
format = ANALYTICS_PROVIDERS[provider]
|
||||
if format.nil?
|
||||
errors.add(:config, "analytics provider #{provider} is not supported")
|
||||
elsif !id.match?(format)
|
||||
errors.add(:config, "analytics id for #{provider} is invalid")
|
||||
end
|
||||
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.config['analytics'] || {}
|
||||
end
|
||||
|
||||
if portal.channel_web_widget
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
<%# Analytics snippets for the portal's configured ids (validated on save). %>
|
||||
<%# The raw output tag emits to_json so JS strings are not HTML-escaped. %>
|
||||
<% analytics = @portal.analytics %>
|
||||
<% nonce = request.content_security_policy_nonce %>
|
||||
|
||||
<% if analytics['ga4'].present? %>
|
||||
<!-- Google Analytics (GA4) -->
|
||||
<script async src="https://www.googletagmanager.com/gtag/js?id=<%= analytics['ga4'] %>" nonce="<%= nonce %>"></script>
|
||||
<script nonce="<%= nonce %>">
|
||||
window.dataLayer = window.dataLayer || [];
|
||||
function gtag(){dataLayer.push(arguments);}
|
||||
gtag('js', new Date());
|
||||
gtag('config', <%== analytics['ga4'].to_json %>);
|
||||
</script>
|
||||
<% end %>
|
||||
|
||||
<% if analytics['gtm'].present? %>
|
||||
<!-- Google Tag Manager -->
|
||||
<script nonce="<%= nonce %>">(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',<%== analytics['gtm'].to_json %>);</script>
|
||||
<% end %>
|
||||
|
||||
<% if analytics['clarity'].present? %>
|
||||
<!-- Microsoft Clarity -->
|
||||
<script nonce="<%= nonce %>">
|
||||
(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", <%== analytics['clarity'].to_json %>);
|
||||
</script>
|
||||
<% end %>
|
||||
|
||||
<% if analytics['hotjar'].present? %>
|
||||
<!-- Hotjar -->
|
||||
<script nonce="<%= nonce %>">
|
||||
(function(h,o,t,j,a,r){
|
||||
h.hj=h.hj||function(){(h.hj.q=h.hj.q||[]).push(arguments)};
|
||||
h._hjSettings={hjid:<%== analytics['hotjar'].to_json %>,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 %>
|
||||
|
||||
<% if analytics['meta_pixel'].present? %>
|
||||
<!-- Meta Pixel -->
|
||||
<script nonce="<%= nonce %>">
|
||||
!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', <%== analytics['meta_pixel'].to_json %>);
|
||||
fbq('track', 'PageView');
|
||||
</script>
|
||||
<% end %>
|
||||
@@ -0,0 +1,7 @@
|
||||
<%# GTM noscript fallback; must sit right after <body> (invalid in <head>). %>
|
||||
<% if @portal.analytics['gtm'].present? %>
|
||||
<!-- Google Tag Manager (noscript) -->
|
||||
<noscript><iframe src="https://www.googletagmanager.com/ns.html?id=<%= @portal.analytics['gtm'] %>"
|
||||
title="Google Tag Manager" height="0" width="0"
|
||||
style="display:none;visibility:hidden"></iframe></noscript>
|
||||
<% end %>
|
||||
@@ -23,6 +23,8 @@
|
||||
<link rel="icon" href="<%= url_for(@portal.logo) %>">
|
||||
<% end %>
|
||||
|
||||
<%= render 'layouts/portal_analytics' %>
|
||||
|
||||
<% 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_body' %>
|
||||
<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_body' %>
|
||||
<div id="portal" class="antialiased">
|
||||
<main class="flex flex-col min-h-screen main-content" role="main">
|
||||
<%= yield %>
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
<%= render 'layouts/portal_head' %>
|
||||
</head>
|
||||
<body class="font-default bg-white dark:bg-slate-900">
|
||||
<%= render 'layouts/portal_analytics_body' %>
|
||||
<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 %>
|
||||
|
||||
@@ -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: 'G-ADMIN12345' } } } },
|
||||
headers: admin.create_new_auth_token
|
||||
|
||||
expect(response).to have_http_status(:success)
|
||||
expect(portal.reload.config['analytics']).to eq('ga4' => '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: '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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user