/** * Client analytics: GTM bootstrap, Consent Mode v2, dataLayer helpers. * * Setup (ops) — also documented on PUBLIC_GTM_ID in root `.env.example`: * 1. Create a GA4 property in Google Analytics. * 2. Create a GTM web container; set PUBLIC_GTM_ID=GTM-XXXX in root `.env`. * 3. In GTM: GA4 Configuration tag with Consent Settings requiring * analytics_storage (and ad_* for ads tags); publish the container. * 4. SPA page views: Custom Event trigger `page_view` (from afterNavigate). * Do NOT also enable GTM History Change / enhanced measurement page_view — * that would double-count. * * Primary loading is GTM only — do not hard-code a GA measurement ID here. */ import { browser } from "$app/environment"; import { env } from "$env/dynamic/public"; import { CONSENT_STORAGE_KEY, CONSENT_WAIT_FOR_UPDATE_MS, DENIED_CONSENT_DEFAULTS, parseStoredConsent, preferencesToConsentSignals, type ConsentModeSignals, type ConsentPreferences } from "./analytics/consent-mode"; import { resolveGtmId } from "./analytics/gtm-id"; import { toEcommerceObject, type Ga4EcommerceFields } from "./analytics/ecommerce"; export type { ConsentModeSignals, ConsentPreferences }; export type { Ga4EcommerceFields, Ga4EcommerceItem, Ga4ItemCategory } from "./analytics/ecommerce"; export { CONSENT_STORAGE_KEY, CONSENT_VERSION, acceptAllPreferences, estimatedSkusBucket, parseStoredConsent, preferencesToConsentSignals, rejectNonEssentialPreferences, serializeConsent } from "./analytics/consent-mode"; export { resolveGtmId } from "./analytics/gtm-id"; export { buildCreditPackEcommerce, buildSubscriptionEcommerce, claimPurchaseTracking, resolveCheckoutEcommerceFromParams, safeCheckoutSessionId } from "./analytics/ecommerce"; type DataLayer = Array | IArguments | unknown[]>; declare global { interface Window { dataLayer?: DataLayer; gtag?: (...args: unknown[]) => void; __descrybeGtmLoaded?: string; __descrybeConsentDefaulted?: boolean; } } function ensureDataLayer(): DataLayer { if (!browser) return []; window.dataLayer = window.dataLayer ?? []; return window.dataLayer; } function ensureGtag(): void { if (!browser) return; ensureDataLayer(); if (typeof window.gtag === "function") return; window.gtag = function gtag(...args: unknown[]) { ensureDataLayer().push(args); }; } /** True when the user granted analytics_storage via the CMP. */ export function isAnalyticsGranted(): boolean { if (!browser) return false; try { const stored = parseStoredConsent(localStorage.getItem(CONSENT_STORAGE_KEY)); return stored?.analytics === true; } catch { return false; } } /** Call before GTM injects — EU/EEA default denied until CMP update. */ export function ensureConsentDefaults(): void { if (!browser || window.__descrybeConsentDefaulted) return; ensureGtag(); window.gtag?.("consent", "default", { ...DENIED_CONSENT_DEFAULTS, wait_for_update: CONSENT_WAIT_FOR_UPDATE_MS }); window.__descrybeConsentDefaulted = true; } export function updateConsentMode(prefs: ConsentPreferences): void { if (!browser) return; ensureGtag(); ensureConsentDefaults(); const signals = preferencesToConsentSignals(prefs); window.gtag?.("consent", "update", signals); pushDataLayer({ event: "consent_update", analytics_storage: signals.analytics_storage, ad_storage: signals.ad_storage, ad_user_data: signals.ad_user_data, ad_personalization: signals.ad_personalization }); } export function pushDataLayer(payload: Record): void { if (!browser) return; ensureDataLayer().push(payload); } /** * Custom event helper. No-ops until analytics_storage is granted via CMP. * Never pass email, password, tokens, names, phone, SKU/GTIN/title, or API keys. */ export function trackEvent(event: string, params?: Record): void { if (!browser) return; if (!isAnalyticsGranted()) return; const name = event.trim(); if (!name) return; pushDataLayer({ event: name, ...(params ?? {}) }); } /** * GA4 ecommerce custom event (GTM). Clears prior ecommerce, then pushes * `{ event, ecommerce: { currency?, value?, transaction_id?, items } }`. * Consent-gated like trackEvent. Never pass PII (email, cus_*, names). */ export function trackEcommerceEvent( event: string, ecommerce: Ga4EcommerceFields, extra?: Record ): void { if (!browser) return; if (!isAnalyticsGranted()) return; const name = event.trim(); if (!name) return; pushDataLayer({ ecommerce: null }); pushDataLayer({ event: name, ecommerce: toEcommerceObject(ecommerce), ...(extra ?? {}) }); } /** * SPA page_view for GTM (Custom Event trigger: page_view). * Gated on analytics consent. Prefer this over GTM History Change. */ export function trackPageview(path: string, opts?: { title?: string; location?: string }): void { if (!browser) return; if (!isAnalyticsGranted()) return; pushDataLayer({ event: "page_view", page_path: path, page_title: opts?.title ?? document.title, page_location: opts?.location ?? window.location.href }); } export function resolveConfiguredGtmId(): string | null { return resolveGtmId(env.PUBLIC_GTM_ID); } /** Load GTM container once when PUBLIC_GTM_ID is a valid GTM-XXXX id. */ export function loadGoogleTagManager(gtmId = resolveConfiguredGtmId()): string | null { if (!browser) return null; const id = resolveGtmId(gtmId); if (!id) return null; if (window.__descrybeGtmLoaded === id) return id; ensureConsentDefaults(); ensureDataLayer().push({ "gtm.start": Date.now(), event: "gtm.js" }); const script = document.createElement("script"); script.async = true; script.src = `https://www.googletagmanager.com/gtm.js?id=${encodeURIComponent(id)}`; document.head.appendChild(script); window.__descrybeGtmLoaded = id; return id; }