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 */ };