230 lines
7.2 KiB
TypeScript
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' });
|
|
}
|
|
});
|
|
}
|