From a3614622745c666fb32facba60648eea076a8612 Mon Sep 17 00:00:00 2001 From: iamsivin Date: Wed, 22 Jul 2026 11:37:19 +0530 Subject: [PATCH] feat: add analytics providers to help center --- .../api/v1/accounts/portals_controller.rb | 1 + .../PortalAnalyticsSettings.vue | 148 ++++++++++++++++++ .../PortalSettingsPage/PortalSettings.vue | 11 ++ .../dashboard/i18n/locale/en/helpCenter.json | 22 +++ .../store/modules/helpCenterPortals/index.js | 1 + app/models/concerns/portal_config_schema.rb | 5 + app/models/portal.rb | 31 +++- .../v1/accounts/portals/_portal.json.jbuilder | 1 + app/views/layouts/_portal_analytics.html.erb | 65 ++++++++ .../layouts/_portal_analytics_body.html.erb | 7 + app/views/layouts/_portal_head.html.erb | 2 + .../layouts/portal.html+documentation.erb | 1 + app/views/layouts/portal.html+plain.erb | 1 + app/views/layouts/portal.html.erb | 1 + 14 files changed, 296 insertions(+), 1 deletion(-) create mode 100644 app/javascript/dashboard/components-next/HelpCenter/Pages/PortalSettingsPage/PortalAnalyticsSettings.vue create mode 100644 app/views/layouts/_portal_analytics.html.erb create mode 100644 app/views/layouts/_portal_analytics_body.html.erb diff --git a/app/controllers/api/v1/accounts/portals_controller.rb b/app/controllers/api/v1/accounts/portals_controller.rb index bdaf82f6c..ad874ed35 100644 --- a/app/controllers/api/v1/accounts/portals_controller.rb +++ b/app/controllers/api/v1/accounts/portals_controller.rb @@ -80,6 +80,7 @@ class Api::V1::Accounts::PortalsController < Api::V1::Accounts::BaseController :id, :color, :custom_domain, :header_text, :homepage_link, :name, :page_title, :slug, :archived, { config: [:default_locale, :layout, { allowed_locales: [] }, { draft_locales: [] }, + { analytics: %i[ga4 gtm clarity hotjar meta_pixel] }, { 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: [] } } }] } diff --git a/app/javascript/dashboard/components-next/HelpCenter/Pages/PortalSettingsPage/PortalAnalyticsSettings.vue b/app/javascript/dashboard/components-next/HelpCenter/Pages/PortalSettingsPage/PortalAnalyticsSettings.vue new file mode 100644 index 000000000..4fa76427b --- /dev/null +++ b/app/javascript/dashboard/components-next/HelpCenter/Pages/PortalSettingsPage/PortalAnalyticsSettings.vue @@ -0,0 +1,148 @@ + + + diff --git a/app/javascript/dashboard/components-next/HelpCenter/Pages/PortalSettingsPage/PortalSettings.vue b/app/javascript/dashboard/components-next/HelpCenter/Pages/PortalSettingsPage/PortalSettings.vue index eccf988cc..0f2a1db40 100644 --- a/app/javascript/dashboard/components-next/HelpCenter/Pages/PortalSettingsPage/PortalSettings.vue +++ b/app/javascript/dashboard/components-next/HelpCenter/Pages/PortalSettingsPage/PortalSettings.vue @@ -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" /> +
diff --git a/app/javascript/dashboard/i18n/locale/en/helpCenter.json b/app/javascript/dashboard/i18n/locale/en/helpCenter.json index e69853f7a..28b2bc5f3 100644 --- a/app/javascript/dashboard/i18n/locale/en/helpCenter.json +++ b/app/javascript/dashboard/i18n/locale/en/helpCenter.json @@ -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", diff --git a/app/javascript/dashboard/store/modules/helpCenterPortals/index.js b/app/javascript/dashboard/store/modules/helpCenterPortals/index.js index 4feb098c0..a046afd3d 100755 --- a/app/javascript/dashboard/store/modules/helpCenterPortals/index.js +++ b/app/javascript/dashboard/store/modules/helpCenterPortals/index.js @@ -33,6 +33,7 @@ const state = { allFetched: false, isFetching: false, isSwitching: false, + isFetchingSSLStatus: false, }, }; diff --git a/app/models/concerns/portal_config_schema.rb b/app/models/concerns/portal_config_schema.rb index 7e506a076..6f0b0d48b 100644 --- a/app/models/concerns/portal_config_schema.rb +++ b/app/models/concerns/portal_config_schema.rb @@ -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' => [], diff --git a/app/models/portal.rb b/app/models/portal.rb index b1f387b49..44e7180da 100644 --- a/app/models/portal.rb +++ b/app/models/portal.rb @@ -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 diff --git a/app/views/api/v1/accounts/portals/_portal.json.jbuilder b/app/views/api/v1/accounts/portals/_portal.json.jbuilder index 2e4e78f1c..29269a75e 100644 --- a/app/views/api/v1/accounts/portals/_portal.json.jbuilder +++ b/app/views/api/v1/accounts/portals/_portal.json.jbuilder @@ -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 diff --git a/app/views/layouts/_portal_analytics.html.erb b/app/views/layouts/_portal_analytics.html.erb new file mode 100644 index 000000000..a747824e8 --- /dev/null +++ b/app/views/layouts/_portal_analytics.html.erb @@ -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? %> + + + +<% end %> + +<% if analytics['gtm'].present? %> + + +<% end %> + +<% if analytics['clarity'].present? %> + + +<% end %> + +<% if analytics['hotjar'].present? %> + + +<% end %> + +<% if analytics['meta_pixel'].present? %> + + +<% end %> diff --git a/app/views/layouts/_portal_analytics_body.html.erb b/app/views/layouts/_portal_analytics_body.html.erb new file mode 100644 index 000000000..bd064a792 --- /dev/null +++ b/app/views/layouts/_portal_analytics_body.html.erb @@ -0,0 +1,7 @@ +<%# GTM noscript fallback; must sit right after (invalid in ). %> +<% if @portal.analytics['gtm'].present? %> + + +<% end %> diff --git a/app/views/layouts/_portal_head.html.erb b/app/views/layouts/_portal_head.html.erb index 64e24a150..5ddb8df9d 100644 --- a/app/views/layouts/_portal_head.html.erb +++ b/app/views/layouts/_portal_head.html.erb @@ -23,6 +23,8 @@ <% 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 %> diff --git a/app/views/layouts/portal.html+documentation.erb b/app/views/layouts/portal.html+documentation.erb index 45bf3dec9..c97b1d3f1 100644 --- a/app/views/layouts/portal.html+documentation.erb +++ b/app/views/layouts/portal.html+documentation.erb @@ -4,6 +4,7 @@ <%= render 'layouts/portal_head' %> + <%= render 'layouts/portal_analytics_body' %>
<%= render 'public/api/v1/portals/documentation_layout/topbar', diff --git a/app/views/layouts/portal.html+plain.erb b/app/views/layouts/portal.html+plain.erb index 61f5257d5..9c671ead1 100644 --- a/app/views/layouts/portal.html+plain.erb +++ b/app/views/layouts/portal.html+plain.erb @@ -4,6 +4,7 @@ <%= render 'layouts/portal_head' %> + <%= render 'layouts/portal_analytics_body' %>
<%= yield %> diff --git a/app/views/layouts/portal.html.erb b/app/views/layouts/portal.html.erb index 8b1fbfa7b..81d5c8e01 100644 --- a/app/views/layouts/portal.html.erb +++ b/app/views/layouts/portal.html.erb @@ -4,6 +4,7 @@ <%= render 'layouts/portal_head' %> + <%= render 'layouts/portal_analytics_body' %>
<%= render 'public/api/v1/portals/header', portal: @portal %>