/** * analytics.ts — GA4 + first-party event collection. * * Two independent sinks: * 1. GA4 (gtag) — only when VITE_GA_MEASUREMENT_ID is set. * 2. First-party — POST {API}/api/v2/events -> Supabase `events` table. * * Both sinks are gated on consent so a single switch controls tracking. * Events fired before gtag.js finishes loading are queued on window.dataLayer * by Google's own snippet below, so early page_views are never dropped. */ export type ConsentState = 'granted' | 'denied'; const CONSENT_KEY = 'getcert_consent_v1'; const GA_MEASUREMENT_ID = String(import.meta.env.VITE_GA_MEASUREMENT_ID || '').trim(); const API_BASE_URL = String(import.meta.env.VITE_API_BASE_URL || '') .trim() .replace(/\/$/, ''); /** * Consent applied when the visitor has not made an explicit choice yet. * * 'granted' -> opt-out banner: tracking runs immediately, the banner offers a * Decline button. Best data coverage; fine when the primary * audience is outside the EEA. * 'denied' -> opt-in banner: nothing is sent until the visitor accepts. * Strictest GDPR posture, at the cost of most page views. */ const DEFAULT_CONSENT: ConsentState = 'granted'; const SESSION_KEY = 'getcert_sid_v1'; declare global { interface Window { dataLayer?: unknown[]; gtag?: (...args: unknown[]) => void; } } let gaLoaded = false; let explicitChoice: boolean | null = null; function readStoredConsent(): ConsentState | null { try { const raw = localStorage.getItem(CONSENT_KEY); return raw === 'granted' || raw === 'denied' ? raw : null; } catch { return null; } } export function getConsent(): ConsentState { return readStoredConsent() ?? DEFAULT_CONSENT; } export function hasExplicitConsentChoice(): boolean { if (explicitChoice !== null) return explicitChoice; const stored = readStoredConsent(); explicitChoice = stored !== null; return explicitChoice; } export function hasAnalyticsConsent(): boolean { return getConsent() === 'granted'; } function ensureDataLayer(): unknown[] { if (!Array.isArray(window.dataLayer)) window.dataLayer = []; return window.dataLayer; } function pushGtag(...args: unknown[]): void { if (!GA_MEASUREMENT_ID) return; const gtag = window.gtag; if (!gtag) { ensureDataLayer().push(args); return; } gtag(...args); } /** Stable anonymous session id for stitching first-party rows together. */ export function getSessionId(): string { try { let sid = sessionStorage.getItem(SESSION_KEY); if (!sid) { sid = typeof crypto !== 'undefined' && 'randomUUID' in crypto ? crypto.randomUUID() : `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}`; sessionStorage.setItem(SESSION_KEY, sid); } return sid; } catch { return 'anonymous'; } } function injectGaScript(): void { if (!GA_MEASUREMENT_ID || gaLoaded) return; if (document.querySelector(`script[src*="${GA_MEASUREMENT_ID}"]`)) { gaLoaded = true; return; } // Google's standard loader: defines gtag + dataLayer queue before the script. window.dataLayer = window.dataLayer || []; const gtagImpl = (...args: unknown[]) => { (window.dataLayer as unknown[][]).push(args); }; window.gtag = gtagImpl as typeof window.gtag; gtagImpl('js', new Date()); gtagImpl('consent', 'default', { ad_storage: 'denied', ad_user_data: 'denied', ad_personalization: 'denied', analytics_storage: getConsent() === 'granted' ? 'granted' : 'denied', wait_for_update: 500, }); gtagImpl('config', GA_MEASUREMENT_ID, { send_page_view: false, anonymize_ip: true, }); const script = document.createElement('script'); script.async = true; script.src = `https://www.googletagmanager.com/gtag/js?id=${encodeURIComponent(GA_MEASUREMENT_ID)}`; script.onload = () => { gaLoaded = true; }; script.onerror = () => { gaLoaded = false; }; document.head.appendChild(script); } /** Load GA4 if measurement id is configured and consent allows it. */ export function initAnalytics(): void { if (!GA_MEASUREMENT_ID) return; if (hasAnalyticsConsent()) injectGaScript(); } /** Persist a consent choice, then load or revoke tracking accordingly. */ export function setConsent(state: ConsentState): void { try { localStorage.setItem(CONSENT_KEY, state); } catch { /* storage unavailable — in-memory choice below still applies */ } explicitChoice = true; pushGtag('consent', 'update', { analytics_storage: state, ad_storage: state, ad_user_data: state, ad_personalization: state, }); if (state === 'granted') { injectGaScript(); } } function sendFirstParty(name: string, payload: Record): void { if (!API_BASE_URL || !hasAnalyticsConsent()) return; const body = JSON.stringify({ name, ...payload, sessionId: getSessionId() }); try { // keepalive lets the request survive an in-flight navigation. void fetch(`${API_BASE_URL}/api/v2/events`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body, keepalive: true, credentials: 'omit', }).catch(() => {}); } catch { /* analytics must never surface as a user-facing error */ } } const EVENT_NAME_RE = /^[a-z][a-z0-9_]{0,63}$/; /** * Fire one event at both sinks. * `name` must be snake_case (GA4 event-name rules). */ export function track(name: string, params: Record = {}): void { if (!EVENT_NAME_RE.test(name)) return; if (!hasAnalyticsConsent()) return; if (GA_MEASUREMENT_ID) { pushGtag('event', name, params); } sendFirstParty(name, params); } /** SPA page_view — fired by AnalyticsBridge on every location change. */ export function trackPageView(path: string, title: string, referrer: string): void { if (!hasAnalyticsConsent()) return; const params = { page_location: `${window.location.origin}${path}`, page_path: path, page_title: title, referrer: referrer || undefined, }; if (GA_MEASUREMENT_ID) { pushGtag('event', 'page_view', params); } sendFirstParty('page_view', { path, title, referrer: referrer || null }); } /** Captures a caught or uncaught JS error at both sinks. */ export function trackError( source: 'global' | 'boundary' | 'promise', error: unknown, extra: { componentStack?: string } = {}, ): void { const err = error instanceof Error ? error : new Error(String(error)); track('js_error', { error_source: source, error_name: err.name || 'Error', error_message: (err.message || 'unknown').slice(0, 300), page_path: window.location.pathname, ...(extra.componentStack ? { component_stack: extra.componentStack.slice(0, 400) } : {}), }); }