From 7d5572578084f6cfdaadf1e7bc8f7d95fcbaa6e4 Mon Sep 17 00:00:00 2001 From: Pranav Date: Wed, 22 Jul 2026 18:26:25 -0700 Subject: [PATCH] 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. --- .../api/v1/accounts/portals_controller.rb | 3 +- .../PortalIntegrationsSettings.vue | 114 ++++++++++++++---- .../dashboard/i18n/locale/en/helpCenter.json | 44 ++++++- app/models/portal.rb | 37 ++++-- .../v1/accounts/portals/_portal.json.jbuilder | 6 + app/views/layouts/_portal_analytics.html.erb | 67 +++++++++- .../_portal_analytics_noscript.html.erb | 6 + .../v1/accounts/portals_controller_spec.rb | 9 +- theme/icons.js | 10 ++ 9 files changed, 256 insertions(+), 40 deletions(-) diff --git a/app/controllers/api/v1/accounts/portals_controller.rb b/app/controllers/api/v1/accounts/portals_controller.rb index 23f87af73..eb9f15a70 100644 --- a/app/controllers/api/v1/accounts/portals_controller.rb +++ b/app/controllers/api/v1/accounts/portals_controller.rb @@ -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 diff --git a/app/javascript/dashboard/components-next/HelpCenter/Pages/PortalSettingsPage/PortalIntegrationsSettings.vue b/app/javascript/dashboard/components-next/HelpCenter/Pages/PortalSettingsPage/PortalIntegrationsSettings.vue index ab3d260b0..f6dbfd8a0 100644 --- a/app/javascript/dashboard/components-next/HelpCenter/Pages/PortalSettingsPage/PortalIntegrationsSettings.vue +++ b/app/javascript/dashboard/components-next/HelpCenter/Pages/PortalSettingsPage/PortalIntegrationsSettings.vue @@ -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 }, }); }; @@ -132,41 +186,57 @@ const handleSave = () => {
- +
- {{ t('HELP_CENTER.PORTAL_SETTINGS.INTEGRATIONS.GTM.TITLE') }} + {{ + t( + `HELP_CENTER.PORTAL_SETTINGS.INTEGRATIONS.${provider.i18nKey}.TITLE` + ) + }}
- {{ t('HELP_CENTER.PORTAL_SETTINGS.INTEGRATIONS.GTM.DESCRIPTION') }} + {{ + t( + `HELP_CENTER.PORTAL_SETTINGS.INTEGRATIONS.${provider.i18nKey}.DESCRIPTION` + ) + }}
diff --git a/app/javascript/dashboard/i18n/locale/en/helpCenter.json b/app/javascript/dashboard/i18n/locale/en/helpCenter.json index d17e4e12c..f47da3120 100644 --- a/app/javascript/dashboard/i18n/locale/en/helpCenter.json +++ b/app/javascript/dashboard/i18n/locale/en/helpCenter.json @@ -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": { diff --git a/app/models/portal.rb b/app/models/portal.rb index 527a0177f..f6fc09c7b 100644 --- a/app/models/portal.rb +++ b/app/models/portal.rb @@ -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 diff --git a/app/views/api/v1/accounts/portals/_portal.json.jbuilder b/app/views/api/v1/accounts/portals/_portal.json.jbuilder index 2bbda193c..7bff16d2d 100644 --- a/app/views/api/v1/accounts/portals/_portal.json.jbuilder +++ b/app/views/api/v1/accounts/portals/_portal.json.jbuilder @@ -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 diff --git a/app/views/layouts/_portal_analytics.html.erb b/app/views/layouts/_portal_analytics.html.erb index 0812de979..a2f47415b 100644 --- a/app/views/layouts/_portal_analytics.html.erb +++ b/app/views/layouts/_portal_analytics.html.erb @@ -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? %> <% end %> +<% if @portal.ga4_measurement_id.present? %> + + + + +<% end %> +<% if @portal.hotjar_site_id.present? %> + + + +<% end %> +<% if @portal.plausible_domain.present? %> + + + +<% end %> +<% if @portal.amplitude_api_key.present? %> + + + + +<% end %> +<% if @portal.clarity_project_id.present? %> + + + +<% end %> +<% if @portal.meta_pixel_id.present? %> + + + +<% end %> diff --git a/app/views/layouts/_portal_analytics_noscript.html.erb b/app/views/layouts/_portal_analytics_noscript.html.erb index 5bb9023e0..6d677367a 100644 --- a/app/views/layouts/_portal_analytics_noscript.html.erb +++ b/app/views/layouts/_portal_analytics_noscript.html.erb @@ -4,3 +4,9 @@ height="0" width="0" style="display:none;visibility:hidden"> <% end %> +<% if @portal.meta_pixel_id.present? %> + + + +<% end %> diff --git a/spec/controllers/api/v1/accounts/portals_controller_spec.rb b/spec/controllers/api/v1/accounts/portals_controller_spec.rb index 860791c0e..9339de86a 100644 --- a/spec/controllers/api/v1/accounts/portals_controller_spec.rb +++ b/spec/controllers/api/v1/accounts/portals_controller_spec.rb @@ -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 diff --git a/theme/icons.js b/theme/icons.js index 266c7ddfa..4f56fbe55 100644 --- a/theme/icons.js +++ b/theme/icons.js @@ -428,5 +428,15 @@ export const icons = { width: 15, height: 15, }, + plausible: { + body: ``, + width: 512, + height: 512, + }, + 'microsoft-clarity': { + body: ``, + width: 48, + height: 48, + }, /** Ends */ };