From e80fbf848c29fc1fbc529090ab5af5b9d6415355 Mon Sep 17 00:00:00 2001 From: Kiro Agent Date: Tue, 19 May 2026 11:37:43 +0530 Subject: [PATCH 1/4] Remove GTM integration to gate analytics on cookie consent Mintlify's integrations.gtm injection fires GTM (and GA4) on every page load with no consent check, before the existing cookie banner can be shown or interacted with. This is an immediate kill switch in response to a California privacy notice; GTM will be re-introduced via a Mintlify custom-script that wires Google Consent Mode v2 to the HubSpot banner, so GA only fires after the user clicks Accept All. Co-Authored-By: Claude Opus 4.7 (1M context) --- docs.json | 5 ----- 1 file changed, 5 deletions(-) diff --git a/docs.json b/docs.json index 70bf39e23..36108e721 100644 --- a/docs.json +++ b/docs.json @@ -9668,11 +9668,6 @@ "destination": "/calls/flutter/events" } ], - "integrations": { - "gtm": { - "tagId": "GTM-59ZJRV2" - } - }, "seo": { "indexing": "all", "metatags": { From 78f7c6b2286f441ecb071eb8899ada47bb328156 Mon Sep 17 00:00:00 2001 From: Kiro Agent Date: Tue, 19 May 2026 13:03:47 +0530 Subject: [PATCH 2/4] Add consent.js to gate analytics behind cookie banner Accept Mintlify auto-injects any .js file in the content tree on every page (same mechanism that loads assets/version-aligner.js). This file is the sole loader of GTM-59ZJRV2 now that `integrations.gtm` is removed from docs.json, and it does three things before GTM is fetched: 1. Sets Google Consent Mode v2 defaults to `denied` for all four storage types so GA4 (G-M5KZ2NFCYL) cannot fire `collect` requests until the user grants consent. 2. Restores any previously-saved choice from localStorage so returning visitors do not re-see the banner act as a tracking gate. 3. Hooks the HubSpot cookie banner via `_hsp.addPrivacyConsentListener` (HubSpot's documented privacy API) and pushes consent updates on Accept All / Decline All / X. GTM is then loaded inline at the end of the IIFE so ordering is guaranteed regardless of where Mintlify positions the script tag. Co-Authored-By: Claude Opus 4.7 (1M context) --- assets/consent.js | 82 +++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 82 insertions(+) create mode 100644 assets/consent.js diff --git a/assets/consent.js b/assets/consent.js new file mode 100644 index 000000000..e385ceb84 --- /dev/null +++ b/assets/consent.js @@ -0,0 +1,82 @@ +/* ------------------------------------------------------------------ */ +/* Consent gating for analytics */ +/* */ +/* Default-deny Google Consent Mode v2 before GTM loads, restore any */ +/* prior choice from localStorage, and bridge the existing HubSpot */ +/* cookie banner to consent updates. Pairs with the removal of */ +/* `integrations.gtm` from docs.json — this script is the sole loader */ +/* of GTM-59ZJRV2 on docs.cometchat.com, so GA4 (G-M5KZ2NFCYL) cannot */ +/* fire `collect` requests until the user clicks Accept All. */ +/* ------------------------------------------------------------------ */ +(function initConsentGate() { + // Prevent double-initialization on client-side navigation. Critical: + // re-running gtag('consent','default') after a prior 'update' would + // silently reset a previously-granted state back to denied. + try { + if (window.__ccConsentGateInitialized__) return; + window.__ccConsentGateInitialized__ = true; + } catch (_) { return; } + + var GTM_ID = 'GTM-59ZJRV2'; + var STORAGE_KEY = 'cc_consent'; + + window.dataLayer = window.dataLayer || []; + function gtag() { window.dataLayer.push(arguments); } + + // 1) Default-deny BEFORE GTM loads. GA4 in GTM respects Consent Mode v2 + // automatically, so this alone blocks the analytics.google.com/g/collect + // hit pre-consent. wait_for_update gives the banner 500ms to send an + // explicit choice before any deferred tags evaluate. + gtag('consent', 'default', { + ad_storage: 'denied', + ad_user_data: 'denied', + ad_personalization: 'denied', + analytics_storage: 'denied', + wait_for_update: 500 + }); + + function applyConsent(granted) { + var v = granted ? 'granted' : 'denied'; + gtag('consent', 'update', { + ad_storage: v, + ad_user_data: v, + ad_personalization: v, + analytics_storage: v + }); + try { window.localStorage.setItem(STORAGE_KEY, v); } catch (_) {} + } + + // 2) Restore previously-recorded choice on return visits. + try { + var saved = window.localStorage.getItem(STORAGE_KEY); + if (saved === 'granted') applyConsent(true); + else if (saved === 'denied') applyConsent(false); + } catch (_) {} + + // 3) Bridge the HubSpot cookie banner. HubSpot tracking (loaded by GTM) + // fires _hsp privacy events on Accept All / Decline All / X, and when + // a prior __hs_opt_out cookie is read. This is HubSpot's documented + // privacy API — survives banner-markup changes. + window._hsp = window._hsp || []; + window._hsp.push(['addPrivacyConsentListener', function (consent) { + var granted = !!(consent && (consent.allowed || + (consent.categories && consent.categories.analytics))); + applyConsent(granted); + }]); + + // 4) Load GTM AFTER default-deny is registered. + (function (w, d, s, l, i) { + w[l] = w[l] || []; + w[l].push({ 'gtm.start': new Date().getTime(), event: 'gtm.js' }); + var f = d.getElementsByTagName(s)[0]; + var j = d.createElement(s); + var dl = l !== 'dataLayer' ? '&l=' + l : ''; + j.async = true; + j.src = 'https://www.googletagmanager.com/gtm.js?id=' + i + dl; + if (f && f.parentNode) { + f.parentNode.insertBefore(j, f); + } else { + (d.head || d.documentElement).appendChild(j); + } + })(window, document, 'script', 'dataLayer', GTM_ID); +})(); From acd751c99e93456fefd3599186abb61c27768839 Mon Sep 17 00:00:00 2001 From: Kiro Agent Date: Tue, 19 May 2026 13:20:01 +0530 Subject: [PATCH 3/4] consent.js: gate on button clicks, not HubSpot _hsp event MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Verifying the preview revealed that HubSpot's _hsp.addPrivacyConsentListener fires immediately on registration with HubSpot's *implicit* consent state. On domains where HubSpot decides no banner is needed (mintlify.app preview, regions without consent requirements, anywhere not in HubSpot's banner-required list), that implicit state is allowed:true — so the previous bridge auto-flipped to granted with no user action, no banner shown, and GA fired with gcs=G111. Replace with capture-phase DOM event delegation matching the banner's known selectors (#hs-eu-confirmation-button, #hs-eu-decline-button) and visible text labels. Consent is now strictly tied to user intent: - If banner shows and user clicks Accept All → consent granted, GA fires. - If banner shows and user clicks Decline All → consent stays denied. - If banner does not show at all → no click, no grant, GA never fires. Co-Authored-By: Claude Opus 4.7 (1M context) --- assets/consent.js | 39 +++++++++++++++++++++++++++++---------- 1 file changed, 29 insertions(+), 10 deletions(-) diff --git a/assets/consent.js b/assets/consent.js index e385ceb84..9eff0188a 100644 --- a/assets/consent.js +++ b/assets/consent.js @@ -53,16 +53,35 @@ else if (saved === 'denied') applyConsent(false); } catch (_) {} - // 3) Bridge the HubSpot cookie banner. HubSpot tracking (loaded by GTM) - // fires _hsp privacy events on Accept All / Decline All / X, and when - // a prior __hs_opt_out cookie is read. This is HubSpot's documented - // privacy API — survives banner-markup changes. - window._hsp = window._hsp || []; - window._hsp.push(['addPrivacyConsentListener', function (consent) { - var granted = !!(consent && (consent.allowed || - (consent.categories && consent.categories.analytics))); - applyConsent(granted); - }]); + // 3) Bridge the cookie banner via DOM click events. We intentionally do + // NOT use HubSpot's _hsp.addPrivacyConsentListener: it fires immediately + // on registration with HubSpot's *implicit* consent state, which on + // domains where HubSpot decides no banner is needed (e.g. preview + // deployments) means allowed:true — auto-granting without any user + // action. Listening for actual button clicks is the only way to gate + // consent on user intent. + function classifyClick(target) { + if (!target || !target.closest) return null; + + // HubSpot stock banner selectors (id-based, most reliable when present) + if (target.closest('#hs-eu-confirmation-button, [data-hs-eu-confirmation-button]')) return true; + if (target.closest('#hs-eu-decline-button, [data-hs-eu-decline-button]')) return false; + + // Text-based fallback for any banner whose buttons match the + // visible labels rendered today. + var btn = target.closest('button, [role="button"]'); + if (!btn) return null; + var txt = (btn.textContent || '').trim().toLowerCase(); + if (txt === 'accept all' || txt === 'accept all cookies' || txt === 'allow all') return true; + if (txt === 'decline all' || txt === 'reject all' || txt === 'decline') return false; + return null; + } + + document.addEventListener('click', function (e) { + var result = classifyClick(e.target); + if (result === true) applyConsent(true); + if (result === false) applyConsent(false); + }, true); // 4) Load GTM AFTER default-deny is registered. (function (w, d, s, l, i) { From b8a51af63b779d6b27637e437c3aa3f449e074f0 Mon Sep 17 00:00:00 2001 From: Kiro Agent Date: Tue, 19 May 2026 13:38:27 +0530 Subject: [PATCH 4/4] consent.js: own banner + GTM gated behind explicit Accept Strict-mode rewrite. Zero requests to analytics.google.com, googletagmanager.com, or any GTM-loaded tracker fire on page load. GTM is only injected after the user clicks Accept All on a banner rendered by this script. Flow: - First visit: gtag default-deny is set, then a fixed bottom-bar banner is rendered with verbatim HubSpot copy + Accept All / Decline All buttons. No analytics scripts load. - Accept All: gtag('consent','update','granted'), GTM loads inline, choice persisted as localStorage.cc_consent='granted', document.documentElement[data-cc-consent='granted']. - Decline All: gtag('consent','update','denied'), GTM never loads, choice persisted as localStorage.cc_consent='denied'. - Return visit: prior choice restored from localStorage; no banner. GTM loads only if previously granted. HubSpot's own banner is suppressed via a CSS rule keyed on the documentElement[data-cc-consent] attribute, so once the user has made a choice HubSpot can't double-prompt them after GTM loads it. Banner UI is plain DOM + inline CSS, scoped under #cc-consent-banner, with dark-mode support via prefers-color-scheme. No external CSS or fonts. role=dialog + aria-label for accessibility. Mobile-friendly single-column layout under 880px viewport. Co-Authored-By: Claude Opus 4.7 (1M context) --- assets/consent.js | 209 +++++++++++++++++++++++++++++++++------------- 1 file changed, 150 insertions(+), 59 deletions(-) diff --git a/assets/consent.js b/assets/consent.js index 9eff0188a..e2ceca405 100644 --- a/assets/consent.js +++ b/assets/consent.js @@ -1,17 +1,15 @@ /* ------------------------------------------------------------------ */ /* Consent gating for analytics */ /* */ -/* Default-deny Google Consent Mode v2 before GTM loads, restore any */ -/* prior choice from localStorage, and bridge the existing HubSpot */ -/* cookie banner to consent updates. Pairs with the removal of */ -/* `integrations.gtm` from docs.json — this script is the sole loader */ -/* of GTM-59ZJRV2 on docs.cometchat.com, so GA4 (G-M5KZ2NFCYL) cannot */ -/* fire `collect` requests until the user clicks Accept All. */ +/* Strict mode: zero requests to any analytics provider until the */ +/* user clicks Accept All on the cookie banner. GTM (and everything */ +/* it loads — GA4 G-M5KZ2NFCYL, HubSpot tracking, etc.) is NOT loaded */ +/* on page load; it is loaded inline only after an explicit Accept. */ +/* */ +/* Pairs with the removal of `integrations.gtm` from docs.json so */ +/* this file is the sole loader of GTM-59ZJRV2 on docs.cometchat.com. */ /* ------------------------------------------------------------------ */ (function initConsentGate() { - // Prevent double-initialization on client-side navigation. Critical: - // re-running gtag('consent','default') after a prior 'update' would - // silently reset a previously-granted state back to denied. try { if (window.__ccConsentGateInitialized__) return; window.__ccConsentGateInitialized__ = true; @@ -19,14 +17,16 @@ var GTM_ID = 'GTM-59ZJRV2'; var STORAGE_KEY = 'cc_consent'; + var PRIVACY_URL = 'https://www.cometchat.com/privacy-policy'; window.dataLayer = window.dataLayer || []; function gtag() { window.dataLayer.push(arguments); } - // 1) Default-deny BEFORE GTM loads. GA4 in GTM respects Consent Mode v2 - // automatically, so this alone blocks the analytics.google.com/g/collect - // hit pre-consent. wait_for_update gives the banner 500ms to send an - // explicit choice before any deferred tags evaluate. + // 1) Default-deny. Sets Google Consent Mode v2 defaults so that + // any downstream Google tag respects the user's not-yet-given + // consent. This is belt-and-suspenders — GTM is also not loaded + // at all until Accept, so in practice no Google script ever sees + // a denied state, it simply never runs. gtag('consent', 'default', { ad_storage: 'denied', ad_user_data: 'denied', @@ -44,58 +44,149 @@ analytics_storage: v }); try { window.localStorage.setItem(STORAGE_KEY, v); } catch (_) {} + try { document.documentElement.setAttribute('data-cc-consent', v); } catch (_) {} } - // 2) Restore previously-recorded choice on return visits. - try { - var saved = window.localStorage.getItem(STORAGE_KEY); - if (saved === 'granted') applyConsent(true); - else if (saved === 'denied') applyConsent(false); - } catch (_) {} + var gtmLoaded = false; + function loadGTM() { + if (gtmLoaded) return; + gtmLoaded = true; + (function (w, d, s, l, i) { + w[l] = w[l] || []; + w[l].push({ 'gtm.start': new Date().getTime(), event: 'gtm.js' }); + var f = d.getElementsByTagName(s)[0]; + var j = d.createElement(s); + var dl = l !== 'dataLayer' ? '&l=' + l : ''; + j.async = true; + j.src = 'https://www.googletagmanager.com/gtm.js?id=' + i + dl; + if (f && f.parentNode) { + f.parentNode.insertBefore(j, f); + } else { + (d.head || d.documentElement).appendChild(j); + } + })(window, document, 'script', 'dataLayer', GTM_ID); + } + + function injectStyles() { + if (document.getElementById('cc-consent-styles')) return; + var style = document.createElement('style'); + style.id = 'cc-consent-styles'; + // Once the user has chosen, suppress HubSpot's own banner so it + // doesn't appear after GTM loads and HubSpot's tracking initializes. + style.textContent = + '#cc-consent-banner{position:fixed;bottom:0;left:0;right:0;z-index:2147483646;' + + 'background:#fff;color:#111827;border-top:1px solid rgba(0,0,0,0.08);' + + 'box-shadow:0 -4px 20px rgba(0,0,0,0.06);' + + 'padding:24px clamp(16px,5vw,64px);' + + 'font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,"Helvetica Neue",Arial,sans-serif;' + + 'font-size:14px;line-height:1.5;display:flex;flex-direction:column;gap:16px}' + + '@media(min-width:880px){#cc-consent-banner{flex-direction:row;align-items:center;' + + 'justify-content:space-between;gap:32px}}' + + '#cc-consent-banner__copy{flex:1;min-width:0}' + + '#cc-consent-banner__copy p{margin:0 0 8px 0;color:inherit}' + + '#cc-consent-banner__copy p:last-child{margin-bottom:0}' + + '#cc-consent-banner__copy a{color:inherit;text-decoration:underline}' + + '#cc-consent-banner__actions{display:flex;gap:8px;flex-shrink:0;' + + 'align-self:stretch;justify-content:flex-end}' + + '@media(min-width:880px){#cc-consent-banner__actions{align-self:auto}}' + + '#cc-consent-banner button{font:inherit;cursor:pointer;padding:10px 20px;' + + 'border-radius:8px;border:1px solid transparent;transition:background .15s ease;' + + 'white-space:nowrap}' + + '#cc-consent-banner__decline{background:#fff;color:#111827;border-color:#d1d5db}' + + '#cc-consent-banner__decline:hover{background:#f9fafb}' + + '#cc-consent-banner__accept{background:#111827;color:#fff}' + + '#cc-consent-banner__accept:hover{background:#1f2937}' + + '@media(prefers-color-scheme:dark){' + + '#cc-consent-banner{background:#0b0d10;color:#f3f4f6;' + + 'border-top-color:rgba(255,255,255,0.08)}' + + '#cc-consent-banner__decline{background:transparent;color:#f3f4f6;' + + 'border-color:rgba(255,255,255,0.18)}' + + '#cc-consent-banner__decline:hover{background:rgba(255,255,255,0.06)}' + + '#cc-consent-banner__accept{background:#f3f4f6;color:#0b0d10}' + + '#cc-consent-banner__accept:hover{background:#e5e7eb}}' + + 'html[data-cc-consent] #hs-banner-parent,' + + 'html[data-cc-consent] [data-hs-banner-host],' + + 'html[data-cc-consent] .hs-banner-wrapper,' + + 'html[data-cc-consent] #hs-eu-cookie-confirmation{display:none!important}'; + (document.head || document.documentElement).appendChild(style); + } + + function renderBanner() { + if (document.getElementById('cc-consent-banner')) return; + if (!document.body) return; - // 3) Bridge the cookie banner via DOM click events. We intentionally do - // NOT use HubSpot's _hsp.addPrivacyConsentListener: it fires immediately - // on registration with HubSpot's *implicit* consent state, which on - // domains where HubSpot decides no banner is needed (e.g. preview - // deployments) means allowed:true — auto-granting without any user - // action. Listening for actual button clicks is the only way to gate - // consent on user intent. - function classifyClick(target) { - if (!target || !target.closest) return null; + injectStyles(); - // HubSpot stock banner selectors (id-based, most reliable when present) - if (target.closest('#hs-eu-confirmation-button, [data-hs-eu-confirmation-button]')) return true; - if (target.closest('#hs-eu-decline-button, [data-hs-eu-decline-button]')) return false; + var banner = document.createElement('div'); + banner.id = 'cc-consent-banner'; + banner.setAttribute('role', 'dialog'); + banner.setAttribute('aria-live', 'polite'); + banner.setAttribute('aria-label', 'Cookie consent'); - // Text-based fallback for any banner whose buttons match the - // visible labels rendered today. - var btn = target.closest('button, [role="button"]'); - if (!btn) return null; - var txt = (btn.textContent || '').trim().toLowerCase(); - if (txt === 'accept all' || txt === 'accept all cookies' || txt === 'allow all') return true; - if (txt === 'decline all' || txt === 'reject all' || txt === 'decline') return false; - return null; + var copy = document.createElement('div'); + copy.id = 'cc-consent-banner__copy'; + var p1 = document.createElement('p'); + p1.appendChild(document.createTextNode( + 'This website stores cookies on your computer. These cookies are used to collect ' + + 'information about how you interact with our website and allow us to remember you. ' + + 'We use this information to improve and customize your browsing experience and for ' + + 'analytics and metrics about our visitors both on this website and other media. ' + + 'To find out more about the cookies we use, see our ' + )); + var link = document.createElement('a'); + link.href = PRIVACY_URL; + link.target = '_blank'; + link.rel = 'noopener'; + link.textContent = 'Privacy Policy'; + p1.appendChild(link); + p1.appendChild(document.createTextNode('.')); + var p2 = document.createElement('p'); + p2.textContent = + 'If you decline, your information won’t be tracked when you visit this website. ' + + 'A single cookie will be used in your browser to remember your preference not to be tracked.'; + copy.appendChild(p1); + copy.appendChild(p2); + + var actions = document.createElement('div'); + actions.id = 'cc-consent-banner__actions'; + var decline = document.createElement('button'); + decline.id = 'cc-consent-banner__decline'; + decline.type = 'button'; + decline.textContent = 'Decline All'; + var accept = document.createElement('button'); + accept.id = 'cc-consent-banner__accept'; + accept.type = 'button'; + accept.textContent = 'Accept All'; + actions.appendChild(decline); + actions.appendChild(accept); + + banner.appendChild(copy); + banner.appendChild(actions); + document.body.appendChild(banner); + + accept.addEventListener('click', function () { + applyConsent(true); + loadGTM(); + banner.remove(); + }); + decline.addEventListener('click', function () { + applyConsent(false); + banner.remove(); + }); } - document.addEventListener('click', function (e) { - var result = classifyClick(e.target); - if (result === true) applyConsent(true); - if (result === false) applyConsent(false); - }, true); + // 2) Apply saved choice, or show banner. + var saved = null; + try { saved = window.localStorage.getItem(STORAGE_KEY); } catch (_) {} - // 4) Load GTM AFTER default-deny is registered. - (function (w, d, s, l, i) { - w[l] = w[l] || []; - w[l].push({ 'gtm.start': new Date().getTime(), event: 'gtm.js' }); - var f = d.getElementsByTagName(s)[0]; - var j = d.createElement(s); - var dl = l !== 'dataLayer' ? '&l=' + l : ''; - j.async = true; - j.src = 'https://www.googletagmanager.com/gtm.js?id=' + i + dl; - if (f && f.parentNode) { - f.parentNode.insertBefore(j, f); - } else { - (d.head || d.documentElement).appendChild(j); - } - })(window, document, 'script', 'dataLayer', GTM_ID); + if (saved === 'granted') { + applyConsent(true); + loadGTM(); + } else if (saved === 'denied') { + applyConsent(false); + } else if (document.body) { + renderBanner(); + } else { + document.addEventListener('DOMContentLoaded', renderBanner); + } })();