+
diff --git a/app/javascript/dashboard/components-next/integration-card/IntegrationCard.vue b/app/javascript/dashboard/components-next/integration-card/IntegrationCard.vue
new file mode 100644
index 000000000..8766a9664
--- /dev/null
+++ b/app/javascript/dashboard/components-next/integration-card/IntegrationCard.vue
@@ -0,0 +1,30 @@
+
+
+
+
+
+
+
+
+
+
{{ title }}
+
+ {{ description }}
+
+
+
+
+
+
diff --git a/app/javascript/dashboard/components-next/vertical-tabs/VerticalTabs.vue b/app/javascript/dashboard/components-next/vertical-tabs/VerticalTabs.vue
new file mode 100644
index 000000000..b4acae398
--- /dev/null
+++ b/app/javascript/dashboard/components-next/vertical-tabs/VerticalTabs.vue
@@ -0,0 +1,55 @@
+
+
+
+
+
+
+
+
+
+
diff --git a/app/javascript/dashboard/i18n/locale/en/helpCenter.json b/app/javascript/dashboard/i18n/locale/en/helpCenter.json
index e69853f7a..251f6dbb9 100644
--- a/app/javascript/dashboard/i18n/locale/en/helpCenter.json
+++ b/app/javascript/dashboard/i18n/locale/en/helpCenter.json
@@ -404,6 +404,12 @@
"PLACEHOLDER": "Search for articles"
}
},
+ "BREADCRUMB": {
+ "ARTICLES": "Articles",
+ "CATEGORIES": "Categories",
+ "LOCALES": "Locales",
+ "SETTINGS": "Settings"
+ },
"CATEGORY": {
"ADD": {
"TITLE": "Create a category",
@@ -890,12 +896,6 @@
"LABEL": "Slug",
"PLACEHOLDER": "Portal slug"
},
- "LIVE_CHAT_WIDGET": {
- "LABEL": "Live chat widget",
- "PLACEHOLDER": "Select live chat widget",
- "HELP_TEXT": "Select a live chat widget that will appear on your help center",
- "NONE_OPTION": "No widget"
- },
"BRAND_COLOR": {
"LABEL": "Brand color"
},
@@ -972,6 +972,72 @@
},
"SAVE": "Save changes"
},
+ "INTEGRATIONS": {
+ "HEADER": "Integrations",
+ "DESCRIPTION": "Connect tools that extend what your help center can do.",
+ "LIVE_CHAT": {
+ "TITLE": "Live chat with Chatwoot",
+ "DESCRIPTION": "Show a live chat widget on your help center so visitors can reach you while they read.",
+ "PLACEHOLDER": "Select live chat widget",
+ "NONE_OPTION": "No widget"
+ },
+ "GTM": {
+ "TITLE": "Google Tag Manager",
+ "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": {
+ "GENERAL": "General",
+ "DOMAIN": "Domain",
+ "APPEARANCE": "Appearance",
+ "INTEGRATIONS": "Integrations"
+ },
"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..f403f428d 100644
--- a/app/models/concerns/portal_config_schema.rb
+++ b/app/models/concerns/portal_config_schema.rb
@@ -43,6 +43,20 @@ module PortalConfigSchema
'popular_content' => {
'type' => %w[object null],
'additionalProperties' => POPULAR_CONTENT_SCHEMA
+ },
+ # Analytics ids grouped under one object (see Portal::ANALYTICS_CONFIG_FORMATS).
+ 'analytics' => {
+ 'type' => %w[object null],
+ 'properties' => {
+ 'gtm_container_id' => { 'type' => %w[string null] },
+ 'ga4_measurement_id' => { 'type' => %w[string null] },
+ 'hotjar_site_id' => { 'type' => %w[string null] },
+ 'plausible_domain' => { 'type' => %w[string null] },
+ 'amplitude_api_key' => { 'type' => %w[string null] },
+ 'clarity_project_id' => { 'type' => %w[string null] },
+ 'meta_pixel_id' => { 'type' => %w[string null] }
+ },
+ 'additionalProperties' => false
}
},
'required' => [],
diff --git a/app/models/portal.rb b/app/models/portal.rb
index b1f387b49..57132b2f0 100644
--- a/app/models/portal.rb
+++ b/app/models/portal.rb
@@ -46,15 +46,38 @@ 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 }
scope :active, -> { where(archived: false) }
+ # Analytics id fields and the format each must match. Formats keep values safe to
+ # interpolate into markup. Add a provider here and its snippet in _portal_analytics.html.erb.
+ 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
+
# 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
+ CONFIG_JSON_KEYS = %w[allowed_locales default_locale draft_locales website_token social_profiles layout
+ locale_translations popular_content analytics].freeze
+
+ def analytics
+ value = config_value('analytics')
+ value.is_a?(Hash) ? value : {}
+ end
+
+ # Reader per analytics id (e.g. portal.ga4_measurement_id) so the snippet partials stay simple.
+ ANALYTICS_CONFIG_FORMATS.each_key do |key|
+ define_method(key) { analytics[key].presence }
+ end
# Max number of recommended categories/articles shown per locale.
POPULAR_CATEGORY_LIMIT = 3
@@ -147,6 +170,15 @@ class Portal < ApplicationRecord
errors.add(:config, 'default locale cannot be drafted.') if draft_locale?(default_locale)
end
+ def validate_analytics
+ ANALYTICS_CONFIG_FORMATS.each do |key, format|
+ value = analytics[key]
+ next if value.blank?
+
+ errors.add(:config, "#{key.humanize} is invalid") unless value.to_s.match?(format)
+ 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..24f712a60 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.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..277c66d55
--- /dev/null
+++ b/app/views/layouts/_portal_analytics.html.erb
@@ -0,0 +1,77 @@
+<%# 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
new file mode 100644
index 000000000..cda3667a3
--- /dev/null
+++ b/app/views/layouts/_portal_analytics_noscript.html.erb
@@ -0,0 +1,12 @@
+<% if @portal.gtm_container_id.present? %>
+
+
+
+<% end %>
+<% if @portal.meta_pixel_id.present? %>
+
+
+
+<% end %>
diff --git a/app/views/layouts/_portal_head.html.erb b/app/views/layouts/_portal_head.html.erb
index 64e24a150..e77139c78 100644
--- a/app/views/layouts/_portal_head.html.erb
+++ b/app/views/layouts/_portal_head.html.erb
@@ -23,6 +23,9 @@
<% end %>
+<%# Skip analytics on internal plain-layout previews (dashboard article preview iframe) so they don't count as visitor traffic. %>
+<%= render 'layouts/portal_analytics' unless @is_plain_layout_enabled %>
+
<% 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..c38c575ff 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_noscript' %>
<%= render 'public/api/v1/portals/documentation_layout/topbar',
diff --git a/app/views/layouts/portal.html.erb b/app/views/layouts/portal.html.erb
index 8b1fbfa7b..f23e414eb 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_noscript' %>
<%= render 'public/api/v1/portals/header', portal: @portal %>
diff --git a/config/locales/en.yml b/config/locales/en.yml
index 2b2cb458c..e7029c2c3 100644
--- a/config/locales/en.yml
+++ b/config/locales/en.yml
@@ -594,6 +594,8 @@ en:
agent_capacity_policy:
inbox_already_assigned: 'Inbox has already been assigned to this policy'
portals:
+ analytics:
+ invalid_configuration: 'Invalid analytics configuration'
articles:
captain_not_available: 'Translation requires Captain to be enabled for this account'
locale_not_available: 'Locale not available in this portal'
diff --git a/spec/controllers/api/v1/accounts/portals_controller_spec.rb b/spec/controllers/api/v1/accounts/portals_controller_spec.rb
index 54a548857..8e60dfba1 100644
--- a/spec/controllers/api/v1/accounts/portals_controller_spec.rb
+++ b/spec/controllers/api/v1/accounts/portals_controller_spec.rb
@@ -175,11 +175,21 @@ RSpec.describe 'Api::V1::Accounts::Portals', type: :request do
'layout' => 'classic',
'social_profiles' => {},
'locale_translations' => {},
- 'popular_content' => {}
+ 'popular_content' => {},
+ 'analytics' => {}
}
)
end
+ it 'allows administrators to set analytics config' do
+ put "/api/v1/accounts/#{account.id}/portals/#{portal.slug}",
+ params: { portal: { config: { analytics: { ga4_measurement_id: 'G-ADMIN12345' } } } },
+ headers: admin.create_new_auth_token
+
+ expect(response).to have_http_status(:success)
+ expect(portal.reload.config['analytics']).to eq('ga4_measurement_id' => 'G-ADMIN12345')
+ end
+
it 'preserves drafted locales when draft_locales is omitted' do
portal.update!(config: { allowed_locales: %w[en es fr], draft_locales: ['es'], default_locale: 'en' })
diff --git a/spec/enterprise/controllers/enterprise/api/v1/accounts/portals_controller_spec.rb b/spec/enterprise/controllers/enterprise/api/v1/accounts/portals_controller_spec.rb
index 48e6c9e00..e4d604c7c 100644
--- a/spec/enterprise/controllers/enterprise/api/v1/accounts/portals_controller_spec.rb
+++ b/spec/enterprise/controllers/enterprise/api/v1/accounts/portals_controller_spec.rb
@@ -85,6 +85,17 @@ RSpec.describe 'Enterprise Portal API', type: :request do
json_response = response.parsed_body
expect(json_response['name']).to eq('updated_portal')
end
+
+ it 'ignores analytics config for knowledge_base_manage users' do
+ put "/api/v1/accounts/#{account.id}/portals/#{portal.slug}",
+ params: { portal: { name: 'updated_portal', config: { analytics: { ga4_measurement_id: 'G-KBMANAGER1' } } } },
+ headers: agent_with_role.create_new_auth_token,
+ as: :json
+
+ expect(response).to have_http_status(:success)
+ expect(portal.reload.name).to eq('updated_portal')
+ expect(portal.config['analytics']).to be_blank
+ end
end
end
diff --git a/theme/icons.js b/theme/icons.js
index fa737bd30..ad0dd808d 100644
--- a/theme/icons.js
+++ b/theme/icons.js
@@ -490,5 +490,15 @@ export const icons = {
width: 14,
height: 14,
},
+ plausible: {
+ body: ``,
+ width: 512,
+ height: 512,
+ },
+ 'microsoft-clarity': {
+ body: ``,
+ width: 48,
+ height: 48,
+ },
/** Ends */
};