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