analytics/examples/funnel-tracking/funnel-tracker.ts
2026-01-29 08:20:58 -08:00

230 lines
7.2 KiB
TypeScript

/**
* 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<string, unknown>;
}
export interface FunnelState {
funnelId: string;
sessionId: string;
entryReferrer: string;
entryTimestamp: number;
steps: FunnelStep[];
metadata?: Record<string, unknown>;
}
export interface FunnelEventPayload {
funnelId: string;
sessionId: string;
step: string;
stepIndex: number;
timeFromEntryMs: number;
timeFromPreviousStepMs: number | null;
entryReferrer: string;
metadata?: Record<string, unknown>;
}
// ─────────────────────────────────────────────────────────────────────────────
// In-Memory State (Consent-Free)
// ─────────────────────────────────────────────────────────────────────────────
// Module-level state - resets on tab close
let currentSessionId: string | null = null;
const activeFunnels = new Map<string, FunnelState>();
/**
* 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<string, unknown>,
): 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<string, unknown>,
): 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<string, unknown>,
): 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<string, unknown>,
): 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<void> {
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' });
}
});
}