Skip to content

Commit bd29558

Browse files
shockalotticlaude
andcommitted
feat(booking): consent-gate GA4/GTM behind a cookie banner
Analytics now loads only after the visitor accepts. v1 of the cookie-consent work (Part A): - book.html head: GA4/GTM script loads are no longer auto-injected. The tag IDs go into window.__CALNODE_TRACK and a __calnodeLoadTracking() loader; nothing external loads (and no _ga* cookies are set) until consent. - Cookie banner (shown only when GTM/GA4 is configured): Accept -> load tracking + store consent; Decline -> store + clear any _ga/_gid/_gat cookies. Choice is a 180-day first-party calnode_consent cookie ({a,v,t}, versioned). - "Cookie settings" footer link reopens the banner (withdraw/change). - ga4Convert now fires only once tracking is consented + loaded; its three call sites are typeof-guarded (it's no longer defined when tracking is off). - Removed the GTM <noscript> fallback — it would load GTM without consent. - Banner styles in booking.css (shared booking surface). Region targeting + granular categories are deferred to v2/v3 (consent stored as a category map so they slot in without re-architecting). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 8349a4b commit bd29558

2 files changed

Lines changed: 91 additions & 17 deletions

File tree

internal/handler/templates/book.html

Lines changed: 67 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -5,30 +5,33 @@
55
<meta name="viewport" content="width=device-width, initial-scale=1.0">
66
<link rel="icon" type="image/svg+xml" href="/favicon.svg">
77
<title>{{.Name}}</title>
8-
{{if .GTMContainerID}}
9-
<!-- Google Tag Manager (native — loaded from the configured container ID) -->
10-
<script>(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],j=d.createElement(s),dl=l!='dataLayer'?'&l='+l:'';j.async=true;j.src='https://www.googletagmanager.com/gtm.js?id='+i+dl;f.parentNode.insertBefore(j,f);})(window,document,'script','dataLayer','{{.GTMContainerID}}');</script>
11-
{{end}}
12-
{{if .GA4MeasurementID}}
13-
<!-- Google Analytics 4 (native — gtag.js) -->
14-
<script async src="https://www.googletagmanager.com/gtag/js?id={{.GA4MeasurementID}}"></script>
15-
<script>window.dataLayer=window.dataLayer||[];function gtag(){dataLayer.push(arguments);}gtag('js',new Date());gtag('config','{{.GA4MeasurementID}}');</script>
16-
{{end}}
8+
{{if or .GTMContainerID .GA4MeasurementID}}
9+
<!-- Analytics (GA4/GTM) is CONSENT-GATED: the Google scripts load only after the visitor accepts
10+
analytics cookies via the banner near </body>. Nothing external loads, and no analytics
11+
cookies are set, before then. -->
1712
<script>
18-
// Global GA4 conversion helper — usable from both the booking script and the assistant
19-
// script. For GA4 used DIRECTLY (gtag.js, no GTM), GA4 won't treat our custom dataLayer event
20-
// as a conversion, so emit a native purchase/lead event. No-op when GA4 isn't configured or
21-
// gtag hasn't loaded. (GTM users trigger off the calnode_booking_confirmed dataLayer event.)
13+
window.dataLayer = window.dataLayer || [];
14+
function gtag(){ dataLayer.push(arguments); }
2215
window.__CALNODE_GA4 = "{{.GA4MeasurementID}}";
16+
window.__CALNODE_TRACK = { gtm: "{{.GTMContainerID}}", ga4: "{{.GA4MeasurementID}}", loaded: false };
17+
// GA4 conversion — only fires once tracking has been consented to and loaded.
2318
window.ga4Convert = function (isPaid, value, currency, txnId) {
24-
if (!window.__CALNODE_GA4 || typeof gtag !== 'function') return;
19+
if (!window.__CALNODE_GA4 || !window.__CALNODE_TRACK.loaded) return;
2520
gtag('event', isPaid ? 'purchase' : 'generate_lead', {
2621
transaction_id: txnId || '',
2722
value: value || 0,
2823
currency: (currency || 'USD').toUpperCase()
2924
});
3025
};
26+
// Injects the real GA4/GTM scripts. Called only on consent (banner Accept, or a stored prior
27+
// Accept). Idempotent.
28+
window.__calnodeLoadTracking = function () {
29+
var t = window.__CALNODE_TRACK; if (!t || t.loaded) return; t.loaded = true;
30+
if (t.gtm) { (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],j=d.createElement(s),dl=l!='dataLayer'?'&l='+l:'';j.async=true;j.src='https://www.googletagmanager.com/gtm.js?id='+i+dl;f.parentNode.insertBefore(j,f);})(window,document,'script','dataLayer',t.gtm); }
31+
if (t.ga4) { var s=document.createElement('script'); s.async=true; s.src='https://www.googletagmanager.com/gtag/js?id='+t.ga4; document.head.appendChild(s); gtag('js', new Date()); gtag('config', t.ga4); }
32+
};
3133
</script>
34+
{{end}}
3235
{{.HeadHTML}}
3336
{{if .DataLayerEnabled}}
3437
<script>
@@ -106,7 +109,7 @@
106109
</style>
107110
</head>
108111
<body>
109-
{{if .GTMContainerID}}<noscript><iframe src="https://www.googletagmanager.com/ns.html?id={{.GTMContainerID}}" height="0" width="0" style="display:none;visibility:hidden"></iframe></noscript>{{end}}
112+
{{/* GTM <noscript> fallback intentionally omitted — it would load GTM without consent for no-JS visitors, bypassing the cookie gate. */}}
110113

111114
{{if or .LogoURL .BusinessName}}
112115
<header style="text-align:center;padding:24px 16px 4px;">
@@ -282,6 +285,7 @@ <h3>Booking confirmed</h3>
282285

283286
<footer class="powered-by">
284287
<a href="https://calnode.com" target="_blank" rel="noopener noreferrer">Calnode</a>
288+
{{if or .GTMContainerID .GA4MeasurementID}}<button type="button" class="cookie-link" onclick="window.__calnodeOpenConsent && window.__calnodeOpenConsent()">Cookie settings</button>{{end}}
285289
</footer>
286290

287291
<!-- Shared booking-calendar logic (booking-logic.js), inlined ahead of the page script -->
@@ -386,7 +390,7 @@ <h3>Booking confirmed</h3>
386390
transaction_id: params.get('session_id') || '',
387391
});
388392
}
389-
ga4Convert(true, PRICE_CENTS / 100, CURRENCY, params.get('session_id'));
393+
if (typeof ga4Convert === 'function') ga4Convert(true, PRICE_CENTS / 100, CURRENCY, params.get('session_id'));
390394
})();
391395

392396
// ── Mobile step-flow ────────────────────────────────────────────────────────
@@ -686,7 +690,7 @@ <h3>Booking confirmed</h3>
686690
currency: (CURRENCY || 'usd').toUpperCase(),
687691
});
688692
}
689-
ga4Convert(false, PRICE_CENTS / 100, CURRENCY, booked && booked.id);
693+
if (typeof ga4Convert === 'function') ga4Convert(false, PRICE_CENTS / 100, CURRENCY, booked && booked.id);
690694

691695
// Booking succeeded. Re-enable the button so a second booking attempt in the
692696
// same session (before the page is refreshed) isn't silently blocked by a
@@ -858,5 +862,51 @@ <h3>Booking confirmed</h3>
858862
}());
859863
</script>
860864
{{end}}
865+
{{if or .GTMContainerID .GA4MeasurementID}}
866+
<div id="cookie-banner" class="cookie-banner" hidden role="dialog" aria-label="Cookie consent">
867+
<p class="cookie-text">We use analytics cookies to understand how this booking page is used — set only if you accept.</p>
868+
<div class="cookie-actions">
869+
<button id="cookie-decline" type="button" class="cookie-btn">Decline</button>
870+
<button id="cookie-accept" type="button" class="cookie-btn cookie-btn-primary">Accept</button>
871+
</div>
872+
</div>
873+
<script>
874+
(function () {
875+
var T = window.__CALNODE_TRACK;
876+
if (!T || (!T.gtm && !T.ga4)) return;
877+
var NAME = 'calnode_consent', VERSION = 1;
878+
function read() {
879+
var m = document.cookie.match(/(?:^|;\s*)calnode_consent=([^;]+)/);
880+
if (!m) return null; try { return JSON.parse(decodeURIComponent(m[1])); } catch (e) { return null; }
881+
}
882+
function write(yes) {
883+
var v = encodeURIComponent(JSON.stringify({ a: yes ? 1 : 0, v: VERSION, t: Date.now() }));
884+
var exp = new Date(Date.now() + 1000 * 60 * 60 * 24 * 180).toUTCString();
885+
document.cookie = NAME + '=' + v + '; expires=' + exp + '; path=/; SameSite=Lax' + (location.protocol === 'https:' ? '; Secure' : '');
886+
}
887+
function clearGA() {
888+
document.cookie.split(';').forEach(function (c) {
889+
var n = c.split('=')[0].trim();
890+
if (/^_ga|^_gid|^_gat/.test(n)) {
891+
document.cookie = n + '=; expires=Thu, 01 Jan 1970 00:00:00 GMT; path=/';
892+
document.cookie = n + '=; expires=Thu, 01 Jan 1970 00:00:00 GMT; path=/; domain=.' + location.hostname;
893+
}
894+
});
895+
}
896+
var banner = document.getElementById('cookie-banner');
897+
window.__calnodeOpenConsent = function () { if (banner) banner.hidden = false; };
898+
function hide() { if (banner) banner.hidden = true; }
899+
var ok = document.getElementById('cookie-accept'), no = document.getElementById('cookie-decline');
900+
if (ok) ok.onclick = function () { write(true); hide(); if (window.__calnodeLoadTracking) window.__calnodeLoadTracking(); };
901+
if (no) no.onclick = function () { write(false); hide(); clearGA(); };
902+
var c = read();
903+
if (c && c.v === VERSION) {
904+
if (c.a && window.__calnodeLoadTracking) window.__calnodeLoadTracking();
905+
} else {
906+
window.__calnodeOpenConsent();
907+
}
908+
}());
909+
</script>
910+
{{end}}
861911
</body>
862912
</html>

internal/handler/templates/booking.css

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -325,3 +325,27 @@
325325
.asst-panel { right: 8px; left: 8px; bottom: 8px; width: auto; max-width: none; height: 72vh; }
326326
.asst-fab-btn { right: 12px; bottom: 12px; }
327327
}
328+
329+
/* ── Cookie consent banner ─────────────────────────── */
330+
.cookie-banner {
331+
position: fixed; z-index: 1000; left: 1rem; right: 1rem; bottom: 1rem;
332+
max-width: 30rem; margin: 0 auto;
333+
display: flex; flex-direction: column; gap: .75rem;
334+
padding: 1rem 1.25rem;
335+
border: 1px solid #e5e7eb; border-radius: .75rem; background: #fff;
336+
box-shadow: 0 10px 30px rgba(0,0,0,.12);
337+
}
338+
.cookie-text { margin: 0; font-size: .8125rem; line-height: 1.5; color: #4b5563; }
339+
.cookie-actions { display: flex; justify-content: flex-end; gap: .5rem; }
340+
.cookie-btn {
341+
cursor: pointer; padding: .4rem .9rem; font-size: .8125rem; font-weight: 500;
342+
border: 1px solid #d1d5db; border-radius: .5rem; background: #fff; color: #374151;
343+
}
344+
.cookie-btn:hover { background: #f9fafb; }
345+
.cookie-btn-primary { background: #111827; border-color: #111827; color: #fff; }
346+
.cookie-btn-primary:hover { background: #1f2937; }
347+
.cookie-link {
348+
cursor: pointer; border: none; background: none; padding: 0; margin-left: .75rem;
349+
font: inherit; font-size: inherit; color: inherit; opacity: .65; text-decoration: underline;
350+
}
351+
.cookie-link:hover { opacity: 1; }

0 commit comments

Comments
 (0)