/** * Funnel Tracking Utilities * * Generic funnel tracking that works with any multi-step flow. * All state is held in memory (consent-free). */ // ───────────────────────────────────────────────────────────────────────────── // Types // ───────────────────────────────────────────────────────────────────────────── export interface FunnelStep { name: string; timestamp: number; metadata?: Record; } export interface FunnelState { funnelId: string; sessionId: string; entryReferrer: string; entryTimestamp: number; steps: FunnelStep[]; metadata?: Record; } export interface FunnelEventPayload { funnelId: string; sessionId: string; step: string; stepIndex: number; timeFromEntryMs: number; timeFromPreviousStepMs: number | null; entryReferrer: string; metadata?: Record; } // ───────────────────────────────────────────────────────────────────────────── // In-Memory State (Consent-Free) // ───────────────────────────────────────────────────────────────────────────── // Module-level state - resets on tab close let currentSessionId: string | null = null; const activeFunnels = new Map(); /** * Get or create a session ID for the current SPA lifecycle. */ function getSessionId(): string { if (!currentSessionId) { currentSessionId = `${Date.now()}_${Math.random().toString(36).substring(2, 11)}`; } return currentSessionId; } // ───────────────────────────────────────────────────────────────────────────── // Funnel Operations // ───────────────────────────────────────────────────────────────────────────── /** * Start a new funnel. * * Call this when the user enters the first step of a conversion flow. * * @example * ```ts * // User lands on signup page * startFunnel('signup', { plan: 'pro' }); * ``` */ export function startFunnel( funnelId: string, metadata?: Record, ): FunnelState { const sessionId = getSessionId(); const now = Date.now(); const state: FunnelState = { funnelId, sessionId, entryReferrer: typeof document !== 'undefined' ? document.referrer || 'direct' : 'unknown', entryTimestamp: now, steps: [], metadata, }; activeFunnels.set(funnelId, state); return state; } /** * Track a funnel step completion. * * @example * ```ts * // User completes email verification step * trackFunnelStep('signup', 'email_verified', { method: 'magic_link' }); * ``` */ export function trackFunnelStep( funnelId: string, stepName: string, metadata?: Record, ): FunnelEventPayload | null { const state = activeFunnels.get(funnelId); if (!state) { console.warn(`[Funnel] No active funnel found: ${funnelId}. Call startFunnel first.`); return null; } const now = Date.now(); const previousStep = state.steps[state.steps.length - 1]; // Add step to state state.steps.push({ name: stepName, timestamp: now, metadata, }); // Build event payload const payload: FunnelEventPayload = { funnelId, sessionId: state.sessionId, step: stepName, stepIndex: state.steps.length, timeFromEntryMs: now - state.entryTimestamp, timeFromPreviousStepMs: previousStep ? now - previousStep.timestamp : null, entryReferrer: state.entryReferrer, metadata: { ...state.metadata, ...metadata, }, }; // Send to analytics backend sendFunnelEvent(payload); return payload; } /** * Mark funnel as completed (conversion). * * @example * ```ts * // User successfully signed up * completeFunnel('signup', { userId: 'usr_123' }); * ``` */ export function completeFunnel( funnelId: string, metadata?: Record, ): FunnelEventPayload | null { const payload = trackFunnelStep(funnelId, 'completed', metadata); // Clean up funnel state after completion activeFunnels.delete(funnelId); return payload; } /** * Abandon a funnel (user explicitly exits). * * @example * ```ts * // User clicks "Cancel" on signup * abandonFunnel('signup', { reason: 'user_cancelled' }); * ``` */ export function abandonFunnel( funnelId: string, metadata?: Record, ): FunnelEventPayload | null { const payload = trackFunnelStep(funnelId, 'abandoned', metadata); activeFunnels.delete(funnelId); return payload; } /** * Get current funnel state. */ export function getFunnelState(funnelId: string): FunnelState | null { return activeFunnels.get(funnelId) || null; } /** * Check if a funnel is active. */ export function isFunnelActive(funnelId: string): boolean { return activeFunnels.has(funnelId); } // ───────────────────────────────────────────────────────────────────────────── // API Communication // ───────────────────────────────────────────────────────────────────────────── const ANALYTICS_ENDPOINT = '/api/track/funnel'; async function sendFunnelEvent(payload: FunnelEventPayload): Promise { try { await fetch(ANALYTICS_ENDPOINT, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload), keepalive: true, // Survive page unload }); } catch { // Fail silently - analytics shouldn't break user experience console.debug('[Funnel] Failed to send event:', payload.step); } } // ───────────────────────────────────────────────────────────────────────────── // Cleanup on Page Unload // ───────────────────────────────────────────────────────────────────────────── if (typeof window !== 'undefined') { window.addEventListener('beforeunload', () => { // Track abandonment for all active funnels on page unload for (const [funnelId] of activeFunnels) { abandonFunnel(funnelId, { reason: 'page_unload' }); } }); }