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 %>