apps/web/src/lib/gtm.ts
1/**
2 * Google Tag Manager + Consent Mode v2.
3 *
4 * GTM is bootstrapped synchronously from {@link bootstrapGtm} in entry-client so consent defaults,
5 * the first consent update, and `gtm.js` injection happen in a fixed order before page-view events.
6 * Consent **defaults** are pushed before `gtm.js` so denied states get cookieless GA behaviour;
7 * configure the GA4 property accordingly.
8 */
9
10import type { StoredConsent } from '~/lib/cookie-consent/consent-state'
11import { isStapeCutoverEnabled, loadStapeOnce } from '~/lib/stape'
12
13declare const __GTM_PREVIEW__: boolean
14
15declare global {
16 interface Window {
17 dataLayer?: Record<string, unknown>[]
18 gtag?: (...args: unknown[]) => void
19 }
20}
21
22/** Must match the noscript iframe id in entry-server.tsx. */
23export const FALLBACK_GTM_ID = 'GTM-WFRHDXC'
24
25/** Synthetic consent: no analytics / ads until explicit grant (also used when consent is still null). */
26function consentForGtag(consent: StoredConsent | null): StoredConsent {
27 if (consent) {
28 return consent
29 }
30 return {
31 v: 2,
32 functional: false,
33 analytics: false,
34 marketing: false,
35 decidedAt: 0,
36 }
37}
38
39function applyConsentUpdateFromStored(c: StoredConsent): void {
40 const gtag = ensureGtag()
41 gtag('consent', 'update', {
42 analytics_storage: c.analytics ? 'granted' : 'denied',
43 ad_storage: c.marketing ? 'granted' : 'denied',
44 ad_user_data: c.marketing ? 'granted' : 'denied',
45 ad_personalization: c.marketing ? 'granted' : 'denied',
46 })
47}
48
49let gtmInjected = false
50let consentDefaultPushed = false
51const SCROLL_DEPTH_THRESHOLDS = [25, 50, 75, 90] as const
52let firedScrollDepthThresholds = new Set<number>()
53let scrollDepthUnsubscribe: (() => void) | null = null
54
55function ensureDataLayer(): unknown[] {
56 if (typeof window === 'undefined') {
57 return []
58 }
59 const dl = (window.dataLayer ?? []) as unknown[]
60 window.dataLayer = dl as Record<string, unknown>[]
61 return dl
62}
63
64type ScrollDepthProgressEvent = {
65 progress: number
66}
67
68type ScrollDepthSubscriber = (
69 callback: (event: ScrollDepthProgressEvent) => void,
70) => () => void
71
72export function resetScrollDepthThresholds(): void {
73 firedScrollDepthThresholds = new Set<number>()
74}
75
76/**
77 * Tracks Lenis progress and emits one custom dataLayer event per threshold per page view.
78 * Idempotent: safe to call multiple times, only subscribes once.
79 */
80export function startScrollDepthTracking(
81 subscribe: ScrollDepthSubscriber,
82): void {
83 if (typeof window === 'undefined' || scrollDepthUnsubscribe) {
84 return
85 }
86 scrollDepthUnsubscribe = subscribe(({ progress }) => {
87 const percent = Math.round(progress * 100)
88 for (const threshold of SCROLL_DEPTH_THRESHOLDS) {
89 if (percent < threshold || firedScrollDepthThresholds.has(threshold)) {
90 continue
91 }
92 firedScrollDepthThresholds.add(threshold)
93 ensureDataLayer().push({
94 event: 'page_scroll_depth',
95 scrollDepthThreshold: threshold,
96 })
97 }
98 })
99}
100
101/** Mirrors Google’s `function gtag(){dataLayer.push(arguments)}` stub for Consent Mode. */
102function ensureGtag(): (...args: unknown[]) => void {
103 ensureDataLayer()
104 if (typeof window.gtag !== 'function') {
105 window.gtag = function gtag() {
106 // biome-ignore lint/complexity/noArguments: Google’s stub pushes `arguments` onto dataLayer
107 ensureDataLayer().push(arguments)
108 }
109 }
110 return window.gtag
111}
112
113/** `consent default` must run once before `gtm.js` so denied states get cookieless GA behaviour. */
114function pushConsentDefaultOnce(): void {
115 if (consentDefaultPushed) {
116 return
117 }
118 consentDefaultPushed = true
119 const gtag = ensureGtag()
120 gtag('consent', 'default', {
121 ad_storage: 'denied',
122 ad_user_data: 'denied',
123 ad_personalization: 'denied',
124 analytics_storage: 'denied',
125 wait_for_update: 500,
126 })
127}
128
129const GTM_ID_RE = /^GTM-[A-Z0-9]+$/i
130
131/** Returns the trimmed id when it matches `GTM-XXXXXX`. */
132function resolveGtmId(id: string | null | undefined): string {
133 const trimmed = id?.trim()
134 if (trimmed && GTM_ID_RE.test(trimmed)) {
135 return trimmed
136 }
137 return FALLBACK_GTM_ID
138}
139
140/**
141 * Injects `gtm.js` once with the provided container id (after consent default + first update are
142 * queued on dataLayer). Falls back to {@link FALLBACK_GTM_ID} when `gtmId` is empty / malformed.
143 */
144export function initGtm(gtmId?: string | null): void {
145 if (
146 typeof window === 'undefined' ||
147 typeof document === 'undefined' ||
148 gtmInjected
149 ) {
150 return
151 }
152
153 const resolvedId = resolveGtmId(gtmId)
154 gtmInjected = true
155
156 const dl = ensureDataLayer() as Record<string, unknown>[]
157 dl.push({
158 'gtm.start': Date.now(),
159 event: 'gtm.js',
160 })
161
162 // const stagingSuffix = __GTM_PREVIEW__
163 // ? '>m_auth=qknfcdYWEjE2ZaEa7844_Q>m_preview=env-601>m_cookies_win=x'
164 // : ''
165
166 const script = document.createElement('script')
167 script.async = true
168 script.src = `https://www.googletagmanager.com/gtm.js?id=${encodeURIComponent(resolvedId)}`
169 document.head.appendChild(script)
170}
171
172/**
173 * Pushes Consent Mode `default` + `update` onto `dataLayer` without injecting `gtm.js`.
174 */
175export function pushConsentMode(consent: StoredConsent | null): void {
176 if (typeof window === 'undefined') {
177 return
178 }
179 pushConsentDefaultOnce()
180 applyConsentUpdateFromStored(consentForGtag(consent))
181}
182
183/**
184 * Pushes Consent Mode (default + update) and loads exactly one container loader.
185 *
186 * Valid Stape config → Stape Custom Loader only.
187 * Otherwise → Google `gtm.js`. Never runs both.
188 */
189export function syncGtmConsentMode(
190 consent: StoredConsent | null,
191 gtmId: string | null | undefined,
192): void {
193 if (typeof window === 'undefined') {
194 return
195 }
196 pushConsentMode(consent)
197 const useStape = isStapeCutoverEnabled()
198 if (useStape) {
199 const outcome = loadStapeOnce({ diagnostics: true })
200 console.info('[stape] syncGtmConsentMode → Stape loader', { outcome })
201 return
202 }
203 console.info('[stape] syncGtmConsentMode → Google gtm.js', {
204 gtmId: gtmId?.trim() || '(fallback)',
205 })
206 initGtm(gtmId)
207}
208