230 lines
6 KiB
TypeScript
Executable file
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();
|
|
}
|