import { useEffect, useRef } from 'react' import { useLocation } from 'react-router-dom' const CONSENT_KEY = 'vbn_analytics_consent_choice' const UTM_PENDING_KEY = 'vbn_pending_utm' function hasConsent(): boolean { return localStorage.getItem(CONSENT_KEY) === 'accepted' } // Capture UTM params immediately on module load, before consent is given. // They're held in sessionStorage so they survive a consent-banner accept. ;(function captureUtmEarly() { try { const params = new URLSearchParams(window.location.search) const utm: Record = {} for (const key of ['utm_source', 'utm_medium', 'utm_campaign', 'utm_content', 'utm_term']) { const val = params.get(key) if (val) utm[key] = val } if (Object.keys(utm).length > 0) { sessionStorage.setItem(UTM_PENDING_KEY, JSON.stringify({ path: window.location.pathname, ...utm })) } } catch { /* sessionStorage unavailable */ } })() // The client gates on a localStorage flag; the server gates on a cookie. // If the cookie is lost (cleared separately, dropped by the browser on a // plain-HTTP deployment, etc.) while localStorage still says "accepted", // the client would otherwise keep believing tracking is on and silently // lose every event forever. Self-heal by clearing the stale flag so the // consent banner reappears instead of leaking data indefinitely. export async function checkAnalyticsConsentResponse(res: Response): Promise { try { const data = await res.json() if (data?.reason === 'no-consent') localStorage.removeItem(CONSENT_KEY) } catch { /* ignore */ } } export function sendEvent(type: string, data: Record): void { if (!hasConsent()) return fetch('/api/analytics/event', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ type, ...data }), keepalive: true, }).then(checkAnalyticsConsentResponse).catch(() => {}) } // --- Scroll depth --- export function useScrollDepthTracking() { const location = useLocation() const milestones = useRef(new Set()) useEffect(() => { milestones.current = new Set() function onScroll() { if (!hasConsent()) return const el = document.documentElement const pct = Math.round((el.scrollTop / (el.scrollHeight - el.clientHeight)) * 100) for (const mark of [25, 50, 75, 90]) { if (pct >= mark && !milestones.current.has(mark)) { milestones.current.add(mark) sendEvent('scroll_depth', { path: location.pathname, depth: mark }) } } } window.addEventListener('scroll', onScroll, { passive: true }) return () => window.removeEventListener('scroll', onScroll) }, [location.pathname]) } // --- Time on page --- export function useTimeOnPage() { const location = useLocation() const enteredAt = useRef(0) const path = useRef(location.pathname) useEffect(() => { enteredAt.current = Date.now() path.current = location.pathname function send() { if (!hasConsent()) return const seconds = Math.round((Date.now() - enteredAt.current) / 1000) if (seconds < 3) return sendEvent('time_on_page', { path: path.current, seconds }) } const onVisibilityChange = () => { if (document.hidden) send() } document.addEventListener('visibilitychange', onVisibilityChange) return () => { document.removeEventListener('visibilitychange', onVisibilityChange) send() } }, [location.pathname]) } // --- UTM capture --- // UTM params are captured into sessionStorage before consent (see top of file). // This hook drains sessionStorage and sends the event once consent is present. // Running without a dep array so it retries after the consent banner triggers a re-render. export function useUTMCapture() { useEffect(() => { if (!hasConsent()) return try { const raw = sessionStorage.getItem(UTM_PENDING_KEY) if (!raw) return const utm = JSON.parse(raw) as Record sessionStorage.removeItem(UTM_PENDING_KEY) sendEvent('utm', utm) } catch { /* ignore */ } }) } // --- All link clicks (internal + external) --- export function useLinkClickTracking() { useEffect(() => { function onClick(e: MouseEvent) { if (!hasConsent()) return const target = (e.target as HTMLElement).closest('a') if (!target) return const href = target.getAttribute('href') ?? '' if (!href || href.startsWith('#') || href.startsWith('mailto:') || href.startsWith('tel:')) return let url: string let internal: boolean if (href.startsWith('http') || href.startsWith('//')) { try { const parsed = new URL(href) if (parsed.hostname === window.location.hostname) { url = parsed.pathname internal = true } else { url = href internal = false } } catch { return } } else if (href.startsWith('/')) { url = href.split('?')[0] internal = true } else { return } sendEvent('link_click', { url: url.slice(0, 500), internal }) } document.addEventListener('click', onClick, { capture: true }) return () => document.removeEventListener('click', onClick, { capture: true }) }, []) }