/** * UTM Parameter Helpers * * Functions for extracting, combining, and mapping UTM parameters * for marketing campaign attribution. */ import { UTM_PARAM_NAMES, type CampaignProperties, type UTMProperties, type UTMProperty, } from "../types/utm"; /** * Extracts UTM parameters from the current page URL. * Parses window.location.search for the 10 tracked UTM/click-ID params. * @returns UTMProperties with any found values, or null if none found. */ export function getUtmParametersFromURL(): UTMProperties | null { if (typeof window === "undefined") return null; const searchParams = new URLSearchParams(window.location.search); const utms: UTMProperties = {}; let found = false; for (const param of UTM_PARAM_NAMES) { const value = searchParams.get(param); if (value) { utms[param] = value; found = true; } } return found ? utms : null; } /** * Smart-merges existing (cookie) UTMs with newly arrived (URL) UTMs. * * Logic: * - If the same campaign (campaign, medium, source, campaign_id, adgroup_id, * fbclid, gclid, msclkid all match): preserve existing utm_content and * utm_term (first-touch attribution for ad content/keyword). * - If only campaign-level UTMs changed (no new content/term provided): * carry forward existing content/term from cookie. * - If completely different campaign: new UTMs fully overwrite. * * @param existing - UTM values currently stored in cookie. * @param incoming - UTM values extracted from the current URL. * @returns Merged UTMProperties. */ export function combineExistingAndNewUTMs( existing: UTMProperties | null, incoming: UTMProperties | null ): UTMProperties { if (!incoming) return existing || {}; if (!existing) return incoming; // Campaign-level keys used to determine if it's the "same campaign" const campaignKeys: UTMProperty[] = [ "utm_campaign", "utm_medium", "utm_source", "utm_campaign_id", "utm_adgroup_id", "fbclid", "gclid", "msclkid", ]; const isSameCampaign = campaignKeys.every( key => (existing[key] || "") === (incoming[key] || "") ); if (isSameCampaign) { // Same campaign — preserve existing content/term (first-touch) return { ...incoming, utm_content: existing.utm_content || incoming.utm_content, utm_term: existing.utm_term || incoming.utm_term, }; } // Different campaign — check if incoming has content/term const hasNewContent = !!incoming.utm_content; const hasNewTerm = !!incoming.utm_term; return { ...incoming, // Carry forward existing content/term only if incoming doesn't provide them ...(!hasNewContent && existing.utm_content && { utm_content: existing.utm_content }), ...(!hasNewTerm && existing.utm_term && { utm_term: existing.utm_term }), }; } /** * Generates synthetic UTM parameters for organic/direct traffic * based on document.referrer. * * - No referrer → direct / direct * - Google/Bing → organic / google or bing * - Other external site (not windstream.com) → referral / * - Internal (windstream.com) → null (no cookie set) * * @returns UTMProperties for organic traffic, or null for internal nav. */ export function getOrganicTrafficUtmParameters(): UTMProperties | null { if (typeof document === "undefined") return null; const referrer = document.referrer; if (!referrer) { return { utm_medium: "direct", utm_source: "direct" }; } let referrerHost: string; try { referrerHost = new URL(referrer).hostname.toLowerCase(); } catch { return { utm_medium: "direct", utm_source: "direct" }; } // Internal navigation — don't set organic UTMs if ( referrerHost.includes("windstream.com") || referrerHost.includes("gokinetic.com") ) { return null; } // Search engines → organic if (referrerHost.includes("google.")) { return { utm_medium: "organic", utm_source: "google" }; } if (referrerHost.includes("bing.")) { return { utm_medium: "organic", utm_source: "bing" }; } // External referral return { utm_medium: "referral", utm_source: referrerHost }; } /** * Maps UTMProperties to Segment's campaign context schema. * @param utms - The UTM properties (typically from cookie). * @returns CampaignProperties for Segment context. */ export function getCampaignProperties( utms: UTMProperties | null ): CampaignProperties | null { if (!utms) return null; const campaign: CampaignProperties = {}; if (utms.utm_campaign) campaign.name = utms.utm_campaign; if (utms.utm_source) campaign.source = utms.utm_source; if (utms.utm_medium) campaign.medium = utms.utm_medium; if (utms.utm_term) campaign.term = utms.utm_term; if (utms.utm_content) campaign.content = utms.utm_content; if (utms.utm_campaign_id) campaign.campaign_id = utms.utm_campaign_id; if (utms.utm_adgroup_id) campaign.adgroup_id = utms.utm_adgroup_id; if (utms.gclid) campaign.gclid = utms.gclid; if (utms.fbclid) campaign.fbclid = utms.fbclid; if (utms.msclkid) campaign.msclkid = utms.msclkid; return Object.keys(campaign).length > 0 ? campaign : null; } /** * Builds a URL with non-UTM query params preserved and UTM params stripped. * Used by the Link/Button component when preserveQueryParameters is true. * * @param href - The target URL/path. * @param currentSearch - The current page's search string (e.g. "?foo=bar&utm_source=google"). * @returns The href with non-UTM params merged and UTM params removed. */ export function buildPreservedQueryHref( href: string, currentSearch: string ): string { if (!currentSearch) return href; const currentParams = new URLSearchParams(currentSearch); const utmSet = new Set(UTM_PARAM_NAMES); // Also strip common non-UTM params that shouldn't carry forward const stripParams = new Set([...utmSet, "searchtext", "page"]); // Parse the target href to separate path and existing query let basePath: string; let targetParams: URLSearchParams; try { // Handle both absolute URLs and relative paths if (href.startsWith("http://") || href.startsWith("https://")) { const url = new URL(href); basePath = url.origin + url.pathname; targetParams = url.searchParams; } else { const [path, query] = href.split("?"); basePath = path; targetParams = new URLSearchParams(query || ""); } } catch { return href; } // Start with target's existing params const merged = new URLSearchParams(targetParams); // Add current page params that aren't UTM and aren't already in target currentParams.forEach((value, key) => { if (!stripParams.has(key.toLowerCase()) && !merged.has(key)) { merged.set(key, value); } }); const queryString = merged.toString(); return queryString ? `${basePath}?${queryString}` : basePath; }