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.
This commit is contained in:
Pranav
2026-07-22 18:26:25 -07:00
parent 2ed7394adc
commit 7d55725780
9 changed files with 256 additions and 40 deletions
@@ -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, :gtm_container_id, { 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
@@ -20,8 +20,51 @@ const { t } = useI18n();
const store = useStore();
const { replaceInstallationName } = useBranding();
// Mirrors Portal::GTM_CONTAINER_ID_FORMAT on the backend.
const GTM_CONTAINER_ID_FORMAT = /^GTM-[A-Z0-9]+$/;
// 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 || {});
@@ -43,13 +86,15 @@ const liveChatWidgets = computed(() => {
const state = reactive({
liveChatWidgetInboxId: '',
gtmContainerId: '',
...Object.fromEntries(ANALYTICS_PROVIDERS.map(({ key }) => [key, ''])),
});
const originalState = reactive({ ...state });
const resetFromPortal = () => {
state.liveChatWidgetInboxId = props.activePortal?.inbox?.id || '';
state.gtmContainerId = portalConfig.value.gtm_container_id || '';
ANALYTICS_PROVIDERS.forEach(({ key }) => {
state[key] = portalConfig.value[key] || '';
});
Object.assign(originalState, state);
};
@@ -64,18 +109,27 @@ const liveChatTitle = computed(() =>
)
);
const trimmedGtmId = computed(() => state.gtmContainerId.trim());
const isGtmInvalid = computed(
() =>
trimmedGtmId.value !== '' &&
!GTM_CONTAINER_ID_FORMAT.test(trimmedGtmId.value)
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 ||
trimmedGtmId.value !== originalState.gtmContainerId
ANALYTICS_PROVIDERS.some(
({ key }) => trimmedAnalyticsValues.value[key] !== originalState[key]
)
);
const handleSave = () => {
@@ -83,7 +137,7 @@ const handleSave = () => {
id: props.activePortal.id,
slug: props.activePortal.slug,
inbox_id: state.liveChatWidgetInboxId,
config: { gtm_container_id: trimmedGtmId.value },
config: { ...trimmedAnalyticsValues.value },
});
};
</script>
@@ -132,41 +186,57 @@ const handleSave = () => {
</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="i-ri-google-fill" class="size-5" />
<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.GTM.TITLE') }}
{{
t(
`HELP_CENTER.PORTAL_SETTINGS.INTEGRATIONS.${provider.i18nKey}.TITLE`
)
}}
</h6>
<span class="text-sm text-n-slate-11">
{{ t('HELP_CENTER.PORTAL_SETTINGS.INTEGRATIONS.GTM.DESCRIPTION') }}
{{
t(
`HELP_CENTER.PORTAL_SETTINGS.INTEGRATIONS.${provider.i18nKey}.DESCRIPTION`
)
}}
</span>
</div>
</div>
<Input
v-model="state.gtmContainerId"
v-model="state[provider.key]"
:placeholder="
t('HELP_CENTER.PORTAL_SETTINGS.INTEGRATIONS.GTM.PLACEHOLDER')
t(
`HELP_CENTER.PORTAL_SETTINGS.INTEGRATIONS.${provider.i18nKey}.PLACEHOLDER`
)
"
:message="
isGtmInvalid
? t('HELP_CENTER.PORTAL_SETTINGS.INTEGRATIONS.GTM.INVALID')
: t('HELP_CENTER.PORTAL_SETTINGS.INTEGRATIONS.GTM.HELP')
isInvalid(provider.key)
? t(
`HELP_CENTER.PORTAL_SETTINGS.INTEGRATIONS.${provider.i18nKey}.INVALID`
)
: t(
`HELP_CENTER.PORTAL_SETTINGS.INTEGRATIONS.${provider.i18nKey}.HELP`
)
"
:message-type="isGtmInvalid ? 'error' : 'info'"
:message-type="isInvalid(provider.key) ? 'error' : 'info'"
/>
</div>
<div class="flex justify-end">
<Button
:label="t('HELP_CENTER.PORTAL_SETTINGS.INTEGRATIONS.SAVE')"
:disabled="!hasChanges || isGtmInvalid || isFetching"
:disabled="!hasChanges || invalidAnalyticsKeys.length > 0 || isFetching"
@click="handleSave"
/>
</div>
@@ -899,11 +899,53 @@
},
"GTM": {
"TITLE": "Google Tag Manager",
"DESCRIPTION": "Add analytics and marketing tags, like Google Analytics, by connecting your Tag Manager container.",
"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": {
+24 -13
View File
@@ -43,15 +43,28 @@ class Portal < ApplicationRecord
validates :slug, presence: true, uniqueness: true
validates :custom_domain, uniqueness: true, allow_nil: true
validate :config_json_format
validate :gtm_container_id_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 gtm_container_id].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
# Google Tag Manager container IDs look like `GTM-XXXXXX`. Restricting the value to this
# shape keeps it safe to interpolate into the help center markup (no quotes/angle brackets).
GTM_CONTAINER_ID_FORMAT = /\AGTM-[A-Z0-9]+\z/
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
{
@@ -107,17 +120,15 @@ class Portal < ApplicationRecord
config_value('social_profiles') || {}
end
def gtm_container_id
config_value('gtm_container_id').presence
end
private
def gtm_container_id_format
value = config_value('gtm_container_id')
return if value.blank?
def analytics_config_format
ANALYTICS_CONFIG_FORMATS.each do |key, format|
value = config_value(key)
next if value.blank?
errors.add(:config, 'Google Tag Manager container ID is invalid') unless value.to_s.match?(GTM_CONTAINER_ID_FORMAT)
errors.add(:config, "#{key.humanize} is invalid") unless value.to_s.match?(format)
end
end
def config_json_format
@@ -19,6 +19,12 @@ json.config do
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
+65 -2
View File
@@ -1,5 +1,5 @@
<%# Google Tag Manager. The container ID is validated against Portal::GTM_CONTAINER_ID_FORMAT, %>
<%# so it is restricted to `GTM-` + alphanumerics and is safe to interpolate here. %>
<%# 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>
@@ -11,3 +11,66 @@
</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 %>
@@ -4,3 +4,9 @@
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 %>
@@ -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
+10
View File
@@ -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 */
};