platform-codebase/@packages/@infrastructure/analytics-client/src/utm-extractor.ts

230 lines
6 KiB
TypeScript
Executable file

/**
* 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<string, unknown>;
// 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<StoredAttribution> {
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();
}