/** * UTM parameter extraction and persistence for first-touch attribution. * * UTM parameters are captured on first page load and stored in sessionStorage. * They are never overwritten during the session (first-touch attribution). */ // Type declarations for browser APIs declare const window: { location: { search: string; href: string; hostname: string } } | undefined; declare const sessionStorage: { getItem: (key: string) => string | null; setItem: (key: string, value: string) => void; removeItem: (key: string) => void; } | undefined; /** * Stored attribution data. */ export interface StoredAttribution { utmSource?: string; utmMedium?: string; utmCampaign?: string; utmContent?: string; utmTerm?: string; referrer?: string; landingPage?: string; capturedAt: number; } const STORAGE_KEY = 'analytics_attribution'; /** * Type guard to validate stored attribution data. */ function isStoredAttribution(obj: unknown): obj is StoredAttribution { if (typeof obj !== 'object' || obj === null) { return false; } const candidate = obj as Record; // capturedAt is required and must be a number if (typeof candidate.capturedAt !== 'number') { return false; } // All other fields are optional strings const optionalStringFields = ['utmSource', 'utmMedium', 'utmCampaign', 'utmContent', 'utmTerm', 'referrer', 'landingPage']; for (const field of optionalStringFields) { if (field in candidate && typeof candidate[field] !== 'string' && candidate[field] !== undefined) { return false; } } return true; } /** * Extract UTM parameters from the current URL. * * @returns Object with UTM parameters (undefined for missing params) */ export function extractUtmParams(): Partial { if (typeof window === 'undefined') { return {}; } const params = new URLSearchParams(window.location.search); return { utmSource: params.get('utm_source') ?? undefined, utmMedium: params.get('utm_medium') ?? undefined, utmCampaign: params.get('utm_campaign') ?? undefined, utmContent: params.get('utm_content') ?? undefined, utmTerm: params.get('utm_term') ?? undefined, }; } /** * Get the current referrer, filtering out same-origin referrers. * * @param currentDomain - Optional current domain to filter * @returns External referrer URL or undefined */ export function getExternalReferrer(currentDomain?: string): string | undefined { if (typeof document === 'undefined' || !document.referrer) { return undefined; } try { const referrerUrl = new URL(document.referrer); const currentHost = currentDomain ?? window?.location?.hostname; // Filter out same-origin referrers if (currentHost && referrerUrl.hostname === currentHost) { return undefined; } return document.referrer; } catch { return undefined; } } /** * Capture first-touch attribution from UTM parameters and referrer. * * This function should be called once on page load. It will: * 1. Check if attribution is already stored (first-touch, never overwrite) * 2. Extract UTM params and referrer if not stored * 3. Store the attribution data in sessionStorage * * @returns The stored attribution data */ export function captureAttribution(): StoredAttribution | null { if (typeof sessionStorage === 'undefined') { return null; } // Check for existing attribution (first-touch - never overwrite) const existing = sessionStorage.getItem(STORAGE_KEY); if (existing) { try { const parsed: unknown = JSON.parse(existing); if (isStoredAttribution(parsed)) { return parsed; } // Invalid attribution format, will re-capture } catch { // Corrupted JSON, will re-capture } } // Extract UTM params and referrer const utmParams = extractUtmParams(); const referrer = getExternalReferrer(); const landingPage = typeof window !== 'undefined' ? window.location.href : undefined; // Only store if we have some attribution data if ( utmParams.utmSource || utmParams.utmMedium || utmParams.utmCampaign || referrer ) { const attribution: StoredAttribution = { ...utmParams, referrer, landingPage, capturedAt: Date.now(), }; sessionStorage.setItem(STORAGE_KEY, JSON.stringify(attribution)); return attribution; } // No attribution data - still record landing page for direct traffic const directAttribution: StoredAttribution = { landingPage, capturedAt: Date.now(), }; sessionStorage.setItem(STORAGE_KEY, JSON.stringify(directAttribution)); return directAttribution; } /** * Get stored attribution data. * * @returns Stored attribution or null if not captured */ export function getStoredAttribution(): StoredAttribution | null { if (typeof sessionStorage === 'undefined') { return null; } const stored = sessionStorage.getItem(STORAGE_KEY); if (!stored) { return null; } try { const parsed: unknown = JSON.parse(stored); if (isStoredAttribution(parsed)) { return parsed; } // Invalid attribution format return null; } catch { // Corrupted JSON return null; } } /** * Clear stored attribution (for testing or session reset). */ export function clearAttribution(): void { if (typeof sessionStorage !== 'undefined') { sessionStorage.removeItem(STORAGE_KEY); } } /** * Build a URL with UTM parameters appended. * * @param baseUrl - Base URL to append UTMs to * @param utm - UTM parameters * @returns URL with UTM query parameters */ export function buildUtmUrl( baseUrl: string, utm: { source?: string; medium?: string; campaign?: string; content?: string; term?: string; }, ): string { const url = new URL(baseUrl); if (utm.source) url.searchParams.set('utm_source', utm.source); if (utm.medium) url.searchParams.set('utm_medium', utm.medium); if (utm.campaign) url.searchParams.set('utm_campaign', utm.campaign); if (utm.content) url.searchParams.set('utm_content', utm.content); if (utm.term) url.searchParams.set('utm_term', utm.term); return url.toString(); }