|
5 | 5 | <meta name="viewport" content="width=device-width, initial-scale=1.0"> |
6 | 6 | <link rel="icon" type="image/svg+xml" href="/favicon.svg"> |
7 | 7 | <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. --> |
17 | 12 | <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); } |
22 | 15 | 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. |
23 | 18 | 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; |
25 | 20 | gtag('event', isPaid ? 'purchase' : 'generate_lead', { |
26 | 21 | transaction_id: txnId || '', |
27 | 22 | value: value || 0, |
28 | 23 | currency: (currency || 'USD').toUpperCase() |
29 | 24 | }); |
30 | 25 | }; |
| 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 | + }; |
31 | 33 | </script> |
| 34 | +{{end}} |
32 | 35 | {{.HeadHTML}} |
33 | 36 | {{if .DataLayerEnabled}} |
34 | 37 | <script> |
|
106 | 109 | </style> |
107 | 110 | </head> |
108 | 111 | <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. */}} |
110 | 113 |
|
111 | 114 | {{if or .LogoURL .BusinessName}} |
112 | 115 | <header style="text-align:center;padding:24px 16px 4px;"> |
@@ -282,6 +285,7 @@ <h3>Booking confirmed</h3> |
282 | 285 |
|
283 | 286 | <footer class="powered-by"> |
284 | 287 | <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}} |
285 | 289 | </footer> |
286 | 290 |
|
287 | 291 | <!-- Shared booking-calendar logic (booking-logic.js), inlined ahead of the page script --> |
@@ -386,7 +390,7 @@ <h3>Booking confirmed</h3> |
386 | 390 | transaction_id: params.get('session_id') || '', |
387 | 391 | }); |
388 | 392 | } |
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')); |
390 | 394 | })(); |
391 | 395 |
|
392 | 396 | // ── Mobile step-flow ──────────────────────────────────────────────────────── |
@@ -686,7 +690,7 @@ <h3>Booking confirmed</h3> |
686 | 690 | currency: (CURRENCY || 'usd').toUpperCase(), |
687 | 691 | }); |
688 | 692 | } |
689 | | - ga4Convert(false, PRICE_CENTS / 100, CURRENCY, booked && booked.id); |
| 693 | + if (typeof ga4Convert === 'function') ga4Convert(false, PRICE_CENTS / 100, CURRENCY, booked && booked.id); |
690 | 694 |
|
691 | 695 | // Booking succeeded. Re-enable the button so a second booking attempt in the |
692 | 696 | // same session (before the page is refreshed) isn't silently blocked by a |
@@ -858,5 +862,51 @@ <h3>Booking confirmed</h3> |
858 | 862 | }()); |
859 | 863 | </script> |
860 | 864 | {{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}} |
861 | 911 | </body> |
862 | 912 | </html> |
0 commit comments