'use client'; import { useEffect } from 'react'; import { usePathname } from 'next/navigation'; import { ROUTES } from '@theme/routes'; const DASHBOARD_URL = process.env.NEXT_PUBLIC_PZ_DASHBOARD_URL; const SERVICE = process.env.NEXT_PUBLIC_PZ_PROJECT_NAME || 'projectzeronext'; const SAMPLE_RATE = Number(process.env.NEXT_PUBLIC_PZ_DATALAYER_SAMPLE ?? '1'); // Sampling never drops these — the dashboard's silence alarms watch them. const CRITICAL_EVENTS = ['purchase', 'begin_checkout']; // Only these payload keys are forwarded; dataLayer objects can carry PII // (email, phone) that must never leave the browser. const PAYLOAD_WHITELIST = ['ecommerce', 'currency', 'value', 'items']; const FLUSH_INTERVAL_MS = 10_000; const MAX_BATCH = 200; // GA4 ecommerce events imply the page type more reliably than the pretty // URL (which only the backend can resolve to product/category). const EVENT_PAGE_TYPES: Record = { view_item: 'pdp', select_item: 'plp', view_item_list: 'plp', view_cart: 'basket', begin_checkout: 'checkout', add_shipping_info: 'checkout', add_payment_info: 'checkout', purchase: 'order-success' }; type QueuedEvent = { event: string; pageType: string; pageUrl: string; sessionId: string; timestamp: number; payload?: Record; }; const queue: QueuedEvent[] = []; // The observer remounts with the app template on every route change and // replays the persistent dataLayer array; this keeps replays idempotent. const captured = new WeakSet(); const resolvePageTypeFromPath = (pathname: string): string => { if (pathname === ROUTES.HOME) return 'home'; if (pathname.startsWith(ROUTES.LIST)) return 'plp'; if (pathname.startsWith(ROUTES.BASKET)) return 'basket'; if (pathname.startsWith(ROUTES.CHECKOUT_COMPLETED)) return 'order-success'; if (pathname.startsWith(ROUTES.CHECKOUT)) return 'checkout'; if (pathname.startsWith('/account') || pathname.startsWith('/users')) { return 'account'; } return 'other'; }; const getSessionId = () => { try { let id = sessionStorage.getItem('pz.dl.sid'); if (!id) { id = crypto.randomUUID(); sessionStorage.setItem('pz.dl.sid', id); } return id; } catch { return 'no-session'; } }; const flush = () => { if (!queue.length || !DASHBOARD_URL) return; const events = queue.splice(0, MAX_BATCH); const body = JSON.stringify({ service: SERVICE, events }); // String body → text/plain → no CORS preflight; survives page unload. if (!navigator.sendBeacon(`${DASHBOARD_URL}/api/datalayer`, body)) { fetch(`${DASHBOARD_URL}/api/datalayer`, { method: 'POST', body, keepalive: true }).catch(() => { // Observability must never break the storefront. }); } }; export const DataLayerObserver = () => { const pathname = usePathname(); useEffect(() => { if (!DASHBOARD_URL) return; const sessionId = getSessionId(); // Deterministic per-session sampling: same session, same decision. const sampled = sessionId === 'no-session' || (parseInt(sessionId.slice(0, 8), 16) & 0xffff) / 0xffff < SAMPLE_RATE; const capture = (item: unknown) => { if (!item || typeof item !== 'object') return; if (captured.has(item)) return; captured.add(item); const record = item as Record; if (typeof record.event !== 'string' || !record.event) return; // GTM internals (gtm.js, gtm.dom, gtm.load...) are noise, not tags. if (record.event.startsWith('gtm.')) return; if (!sampled && !CRITICAL_EVENTS.includes(record.event)) return; const payload: Record = {}; PAYLOAD_WHITELIST.forEach((key) => { if (record[key] !== undefined) payload[key] = record[key]; }); queue.push({ event: record.event, pageType: (typeof record.page_type === 'string' && record.page_type) || EVENT_PAGE_TYPES[record.event] || resolvePageTypeFromPath(window.location.pathname), pageUrl: window.location.href, sessionId, timestamp: Date.now(), payload: Object.keys(payload).length ? payload : undefined }); }; window.dataLayer = window.dataLayer || []; // Replay events pushed before the observer mounted (early page_views). window.dataLayer.forEach(capture); const original = window.dataLayer.push.bind(window.dataLayer); window.dataLayer.push = (...items: unknown[]) => { items.forEach(capture); return original(...items); }; const timer = setInterval(flush, FLUSH_INTERVAL_MS); const onVisibilityChange = () => { if (document.visibilityState === 'hidden') flush(); }; document.addEventListener('visibilitychange', onVisibilityChange); window.addEventListener('pagehide', flush); return () => { clearInterval(timer); document.removeEventListener('visibilitychange', onVisibilityChange); window.removeEventListener('pagehide', flush); window.dataLayer.push = original; flush(); }; // Mount once; route changes are read from window.location at capture time. }, []); // Route change = fresh page view context; flush the previous page's batch. useEffect(() => { flush(); }, [pathname]); return null; };