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	// 	? '&gtm_auth=qknfcdYWEjE2ZaEa7844_Q&gtm_preview=env-601&gtm_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