import { useEffect, useRef, useState } from 'react'; import { useLocation } from 'react-router-dom'; type AnalyticsIdentity = { distinctId: string; username: string; ownerSegment: string; planType: string; channel: string; surface: 'product'; identityMode: 'raw' | 'pseudonymous'; }; type ProductAnalyticsContext = { enabled: boolean; websiteId?: string; scriptPath?: string; hostPath?: string; domains?: string; identity?: AnalyticsIdentity | null; }; type PageViewProperties = Record; type UmamiTracker = { track: ( value?: string | ((properties: PageViewProperties) => PageViewProperties), data?: Record, ) => Promise | unknown; identify: (id: string, data?: Record) => Promise | unknown; }; declare global { interface Window { umami?: UmamiTracker; } } const SAFE_QUERY_KEYS = new Set(['category', 'utm_source', 'preview']); let trackerLoad: Promise | null = null; let lastPageViewKey = ''; let lastIdentityKey = ''; export function normalizeProductRoute(pathname: string, search = '') { const params = new URLSearchParams(search); const safe = new URLSearchParams(); for (const key of SAFE_QUERY_KEYS) { const value = params.get(key); if (value) safe.set(key, value.slice(0, 100)); } const query = safe.toString(); return `${pathname || '/'}${query ? `?${query}` : ''}`; } export function resolveProductRouteName(pathname: string) { if (pathname === '/') return 'chat'; if (pathname === '/space') return 'mindspace_home'; if (pathname === '/space/achievements') return 'mindspace_achievements'; if (pathname.startsWith('/space/page/')) return 'mindspace_page'; if (pathname.startsWith('/feedback/')) return 'feedback_detail'; if (pathname === '/feedback') return 'feedback'; return 'other'; } function safeTargetPath(element: Element) { if (!(element instanceof HTMLAnchorElement) || !element.href) return ''; try { const target = new URL(element.href, window.location.href); return target.origin === window.location.origin ? target.pathname : `external:${target.hostname}`; } catch { return ''; } } function actionName(element: Element) { const stableClasses = Array.from(element.classList) .filter((name) => /^[a-z][a-z0-9_-]{1,80}$/i.test(name)) .slice(0, 3) .join('.'); return String( element.getAttribute('data-analytics-action') || element.getAttribute('data-umami-event') || element.getAttribute('aria-label') || element.getAttribute('title') || element.id || stableClasses || element.tagName.toLowerCase(), ).slice(0, 100); } function waitForTracker() { if (window.umami) return Promise.resolve(); return new Promise((resolve, reject) => { const startedAt = Date.now(); const timer = window.setInterval(() => { if (window.umami) { window.clearInterval(timer); resolve(); } else if (Date.now() - startedAt > 5000) { window.clearInterval(timer); reject(new Error('Umami tracker did not initialize')); } }, 25); }); } function loadTracker(context: ProductAnalyticsContext) { if (window.umami) return Promise.resolve(); if (trackerLoad) return trackerLoad; trackerLoad = new Promise((resolve, reject) => { const existing = document.querySelector('#memind-product-analytics'); if (existing) { void waitForTracker().then(resolve, reject); return; } const script = document.createElement('script'); script.id = 'memind-product-analytics'; script.defer = true; script.src = context.scriptPath || '/analytics/script.js'; script.dataset.websiteId = context.websiteId || ''; script.dataset.hostUrl = context.hostPath || '/analytics'; script.dataset.autoTrack = 'false'; if (context.domains) script.dataset.domains = context.domains; script.addEventListener('load', () => void waitForTracker().then(resolve, reject), { once: true }); script.addEventListener('error', () => reject(new Error('Unable to load Umami tracker')), { once: true, }); document.head.appendChild(script); }).catch((error) => { trackerLoad = null; throw error; }); return trackerLoad; } function absoluteRoute(route: string) { return new URL(route, window.location.origin).toString(); } function trackPageView(route: string) { return window.umami?.track((properties) => ({ ...properties, url: absoluteRoute(route), title: document.title, })); } function trackProductEvent( eventName: string, route: string, identity: AnalyticsIdentity | null | undefined, data: Record = {}, ) { return window.umami?.track((properties) => ({ ...properties, name: eventName, url: absoluteRoute(route), title: document.title, data: { surface: 'product', route, route_name: resolveProductRouteName(window.location.pathname), channel: identity?.channel || 'h5', owner_segment: identity?.ownerSegment || 'anonymous', plan_type: identity?.planType || 'anonymous', identity_mode: identity?.identityMode || 'anonymous', ...data, }, })); } export function useProductAnalytics(userId?: string | null) { const location = useLocation(); const [context, setContext] = useState({ enabled: false }); const [ready, setReady] = useState(false); const contextRef = useRef(context); const route = normalizeProductRoute(location.pathname, location.search); const routeRef = useRef(route); contextRef.current = context; routeRef.current = route; useEffect(() => { let cancelled = false; void fetch('/api/analytics/context', { credentials: 'same-origin', cache: 'no-store' }) .then(async (response) => (response.ok ? ((await response.json()) as ProductAnalyticsContext) : null)) .then((next) => { if (!cancelled && next) setContext(next); }) .catch(() => undefined); return () => { cancelled = true; }; }, [userId]); useEffect(() => { if (!context.enabled || !context.websiteId) return; let cancelled = false; void loadTracker(context) .then(() => { if (cancelled) return; const identity = context.identity; if (identity?.distinctId) { const identityKey = `${context.websiteId}:${identity.distinctId}`; if (lastIdentityKey !== identityKey) { lastIdentityKey = identityKey; void window.umami?.identify(identity.distinctId, { username: identity.username, owner_segment: identity.ownerSegment, plan_type: identity.planType, channel: identity.channel, surface: identity.surface, identity_mode: identity.identityMode, }); } } else if (lastIdentityKey) { lastIdentityKey = ''; void window.umami?.identify('', { auth_state: 'anonymous', channel: 'h5', surface: 'product', }); } setReady(true); }) .catch(() => undefined); return () => { cancelled = true; }; }, [context]); useEffect(() => { if (!ready || !context.enabled || !context.websiteId) return; const pageViewKey = `${context.websiteId}:${route}`; if (lastPageViewKey === pageViewKey) return; lastPageViewKey = pageViewKey; void trackPageView(route); const tenSecond = window.setTimeout(() => { void trackProductEvent('product_engaged_10s', route, context.identity); }, 10_000); const thirtySecond = window.setTimeout(() => { void trackProductEvent('product_engaged_30s', route, context.identity); }, 30_000); return () => { window.clearTimeout(tenSecond); window.clearTimeout(thirtySecond); }; }, [context, ready, route]); useEffect(() => { if (!ready || !context.enabled) return; const handleClick = (event: MouseEvent) => { const target = event.target instanceof Element ? event.target.closest('a,button,[role="button"],[data-analytics-action],[data-umami-event]') : null; if (!target) return; void trackProductEvent('product_click', routeRef.current, contextRef.current.identity, { action: actionName(target), element: target.tagName.toLowerCase(), element_id: target.id.slice(0, 100), element_class: Array.from(target.classList).slice(0, 3).join(' ').slice(0, 200), target_path: safeTargetPath(target), }); }; document.addEventListener('click', handleClick, { capture: true, passive: true }); return () => document.removeEventListener('click', handleClick, { capture: true }); }, [context.enabled, ready]); }