From 9c3e8ce71da1cbffa5c68395b629644e85f9b9aa Mon Sep 17 00:00:00 2001 From: jmgasper Date: Sun, 30 Aug 2026 14:48:12 +1000 Subject: [PATCH] feat: persist standard UTM attribution --- README.md | 21 +++++ src/lib/functions/utm-cookies.handler.ts | 89 ++++++++++++------- src/lib/nudge-app/components/Animation.svelte | 23 ++++- .../lib/functions/utm-cookies.handler.d.ts | 19 ++-- 4 files changed, 110 insertions(+), 42 deletions(-) diff --git a/README.md b/README.md index b73242e0..2a086709 100644 --- a/README.md +++ b/README.md @@ -20,6 +20,7 @@ For information on how to develop/maintain the UniNav component itself, please s - [3. Update tcUniNav](#3-update-tcuninav) - [Futher Reading](#further-reading) - [Environment Files](#environment-files) + - [UTM Attribution](#utm-attribution) - [Versioning](#versioning) - [API for tcUniNav](#api-for-tcuninav) - [tcUniNav Methods](#tcuninav-methods) @@ -254,6 +255,26 @@ Easiest way to create your local environment file is to copy one of the uni-nav. `uni-nav.env.dev` and `uni-nav.env.prod` are used on the CI/CD process and copied to `S3: tc-uninav-[dev|prod]/securitymanage`. +### UTM Attribution + +Universal Navigation preserves first-touch campaign attribution for the host +applications. On initialization, it reads utm_source, utm_medium, utm_campaign, +utm_id, utm_term, and utm_content from the current URL. If at least one is valid +and tc_utm does not already exist, it stores the sanitized values in that +first-party cookie on the shared Topcoder domain. + +The default lifetime is 30 days. Set VITE_UTM_COOKIE_LIFETIME_DAYS to a positive +day count when an environment needs a different attribution window. Values are +limited to 100 characters and alphanumerics, period, underscore, tilde, and +hyphen. Invalid JSON and unsupported properties are ignored when the cookie is +read. + +The stored values are appended to the sign-up URL and are read by the AWS +Clickstream clients in topcoder-website and platform-ui. The cookie is +first-touch: later campaign visits do not overwrite it until it expires or is +cleared. Universal Navigation does not initialize the analytics SDK itself, +which prevents a host page from reporting duplicate page views and clicks. + ### Versioning There are currently prod and dev instances of the library located at: diff --git a/src/lib/functions/utm-cookies.handler.ts b/src/lib/functions/utm-cookies.handler.ts index 24b2d727..be9572a0 100644 --- a/src/lib/functions/utm-cookies.handler.ts +++ b/src/lib/functions/utm-cookies.handler.ts @@ -2,34 +2,47 @@ import { TC_DOMAIN } from '../config/hosts'; import { getEnvValue } from '../config/env-vars'; // UTM cookie configuration types -interface UtmParams { +export interface UtmParams { utm_source?: string; utm_medium?: string; utm_campaign?: string; + utm_id?: string; + utm_term?: string; + utm_content?: string; } // Cookie configuration constants const TC_UTM_COOKIE_NAME = 'tc_utm'; -const DEFAULT_COOKIE_LIFETIME_DAYS = 3; +const DEFAULT_COOKIE_LIFETIME_DAYS = 30; +const MAX_UTM_VALUE_LENGTH = 100; const COOKIE_PATH = '/'; const COOKIE_SAMESITE = 'Lax'; +const UTM_PARAM_NAMES: Array = [ + 'utm_source', + 'utm_medium', + 'utm_campaign', + 'utm_id', + 'utm_term', + 'utm_content', +]; /** - * Sanitizes a string to remove all characters except A-Z, a-z, 0-9, hyphen (-), underscore (_) + * Sanitizes a UTM token to alphanumerics, dot, underscore, tilde, and hyphen * @param input - The string to sanitize - * @returns Sanitized string + * @returns Sanitized string, bounded to the analytics attribute recommendation + * @throws Does not throw */ export function sanitize(input: string): string { if (!input || typeof input !== 'string') { return ''; } - // Remove all characters except A-Z, a-z, 0-9, hyphen (-), underscore (_) - return input.replace(/[^A-Za-z0-9\-_]/g, ''); + return input.replace(/[^A-Za-z0-9._~-]/g, '').slice(0, MAX_UTM_VALUE_LENGTH); } /** * Extracts and sanitizes UTM parameters from the URL - * @returns Object containing sanitized utm_source, utm_medium, utm_campaign + * @returns Object containing the present, sanitized standard UTM parameters + * @throws Does not throw; browser parsing failures produce an empty object */ function extractUtmParams(): UtmParams { const params: UtmParams = {}; @@ -37,18 +50,9 @@ function extractUtmParams(): UtmParams { try { const searchParams = new URLSearchParams(window.location.search); - const utm_source = searchParams.get('utm_source'); - const utm_medium = searchParams.get('utm_medium'); - const utm_campaign = searchParams.get('utm_campaign'); - - if (utm_source) { - params.utm_source = sanitize(utm_source); - } - if (utm_medium) { - params.utm_medium = sanitize(utm_medium); - } - if (utm_campaign) { - params.utm_campaign = sanitize(utm_campaign); + for (const name of UTM_PARAM_NAMES) { + const value = searchParams.get(name); + if (value) params[name] = sanitize(value); } } catch (error) { console.warn('Error extracting UTM parameters:', error); @@ -60,12 +64,13 @@ function extractUtmParams(): UtmParams { /** * Gets the cookie lifetime from environment variable or uses default * @returns Lifetime in days + * @throws Does not throw; invalid environment values use the 30-day default */ function getCookieLifetimeDays(): number { try { const envValue = getEnvValue('VITE_UTM_COOKIE_LIFETIME_DAYS', String(DEFAULT_COOKIE_LIFETIME_DAYS)); const days = parseInt(envValue, 10); - return isNaN(days) ? DEFAULT_COOKIE_LIFETIME_DAYS : days; + return Number.isInteger(days) && days > 0 ? days : DEFAULT_COOKIE_LIFETIME_DAYS; } catch { return DEFAULT_COOKIE_LIFETIME_DAYS; } @@ -74,6 +79,7 @@ function getCookieLifetimeDays(): number { /** * Gets the cookie domain with leading dot for broader subdomain coverage * @returns Cookie domain (e.g., .topcoder.com) + * @throws Does not throw */ function getCookieDomain(): string { return `.${TC_DOMAIN}`; @@ -83,6 +89,7 @@ function getCookieDomain(): string { * Checks if a cookie with the given name exists * @param name - Cookie name * @returns true if cookie exists, false otherwise + * @throws Does not throw */ function cookieExists(name: string): boolean { const cookies = document.cookie.split(';'); @@ -94,6 +101,8 @@ function cookieExists(name: string): boolean { * @param name - Cookie name * @param value - Cookie value * @param options - Cookie options (domain, path, sameSite, secure, maxAge) + * @returns void after assigning document.cookie + * @throws Does not throw */ function setCookie( name: string, @@ -138,7 +147,9 @@ function setCookie( /** * Initializes UTM cookie handling on page load * Extracts UTM parameters from URL, sanitizes them, and persists to cookie - * Only sets the cookie if it doesn't already exist + * Only sets the cookie if it doesn't already exist, preserving first-touch attribution + * @returns void after persisting first-touch attribution or determining that no write is needed + * @throws Does not throw; browser and cookie errors are logged and ignored */ export function initializeUtmCookieHandler(): void { try { @@ -181,7 +192,8 @@ export function initializeUtmCookieHandler(): void { /** * Retrieves and parses the tc_utm cookie - * @returns Parsed UTM parameters or null if cookie doesn't exist + * @returns Parsed, allow-listed UTM parameters or null if the cookie is absent or invalid + * @throws Does not throw */ export function getUtmCookie(): UtmParams | null { try { @@ -193,7 +205,14 @@ export function getUtmCookie(): UtmParams | null { } const cookieValue = decodeURIComponent(cookieStr.split('=')[1]); - return JSON.parse(cookieValue) as UtmParams; + const parsed = JSON.parse(cookieValue) as unknown; + if (!isRecord(parsed)) return null; + const values = Object.fromEntries(UTM_PARAM_NAMES.flatMap(name => ( + typeof parsed[name] === 'string' + ? [[name, sanitize(parsed[name] as string)]] + : [] + ))) as UtmParams; + return Object.keys(values).length > 0 ? values : null; } catch (error) { console.warn('Error retrieving UTM cookie:', error); return null; @@ -205,6 +224,7 @@ export function getUtmCookie(): UtmParams | null { * Only appends parameters that exist in the cookie * @param url - The base URL to append parameters to * @returns URL with UTM parameters appended, or original URL if no cookie exists + * @throws Does not throw; malformed URLs return the original value */ export function appendUtmParamsToUrl(url: string): string { if (!url) { @@ -219,15 +239,10 @@ export function appendUtmParamsToUrl(url: string): string { try { const urlObj = new URL(url, window.location.origin); - // Append only the UTM parameters that exist in the cookie - if (utmParams.utm_source) { - urlObj.searchParams.set('utm_source', utmParams.utm_source); - } - if (utmParams.utm_medium) { - urlObj.searchParams.set('utm_medium', utmParams.utm_medium); - } - if (utmParams.utm_campaign) { - urlObj.searchParams.set('utm_campaign', utmParams.utm_campaign); + // Append only the UTM parameters that exist in the cookie. + for (const name of UTM_PARAM_NAMES) { + const value = utmParams[name]; + if (value) urlObj.searchParams.set(name, value); } return urlObj.toString(); @@ -236,3 +251,13 @@ export function appendUtmParamsToUrl(url: string): string { return url; } } + +/** + * Narrows decoded cookie JSON to an indexable non-array object. + * @param value - Decoded JSON value + * @returns true when value can safely be inspected by property name + * @throws Does not throw + */ +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} diff --git a/src/lib/nudge-app/components/Animation.svelte b/src/lib/nudge-app/components/Animation.svelte index bdd4f07e..1b64f54e 100644 --- a/src/lib/nudge-app/components/Animation.svelte +++ b/src/lib/nudge-app/components/Animation.svelte @@ -4,7 +4,15 @@ import styles from './Animation.module.scss'; export let animation: string; - export let cover: string; + export let cover = ''; + + interface BodymovinAnimation { + addEventListener: (event: 'data_ready', listener: () => void) => void; + } + + interface Bodymovin { + loadAnimation: (configuration: Record) => BodymovinAnimation; + } let ref: Element | undefined = undefined; let coverRef: HTMLDivElement | undefined = undefined; @@ -14,8 +22,14 @@ dispatch('loaded', {animation: animation === true}); } - const loadAnimation = (path) => { - var animData = { + /** + * Loads the requested nudge animation and reveals it after its data is ready. + * @param path - Animation asset basename + * @returns void after registering the bodymovin ready listener + * @throws If the previously loaded bodymovin bundle does not expose its expected browser API + */ + const loadAnimation = (path: string): void => { + const animData = { container: ref, renderer: 'svg', loop: true, @@ -25,7 +39,8 @@ progressiveLoad: true }, }; - const bmAnim = window['bodymovin'].loadAnimation(animData); + const bodymovin = (window as unknown as Window & { bodymovin: Bodymovin }).bodymovin; + const bmAnim = bodymovin.loadAnimation(animData); bmAnim.addEventListener('data_ready', () => { if (coverRef) { Object.assign(coverRef.style, {display: 'none'}); diff --git a/types/src/lib/functions/utm-cookies.handler.d.ts b/types/src/lib/functions/utm-cookies.handler.d.ts index c3d98446..da3c2a4c 100644 --- a/types/src/lib/functions/utm-cookies.handler.d.ts +++ b/types/src/lib/functions/utm-cookies.handler.d.ts @@ -1,23 +1,30 @@ -interface UtmParams { +export interface UtmParams { utm_source?: string; utm_medium?: string; utm_campaign?: string; + utm_id?: string; + utm_term?: string; + utm_content?: string; } /** - * Sanitizes a string to remove all characters except A-Z, a-z, 0-9, hyphen (-), underscore (_) + * Sanitizes a UTM token to alphanumerics, dot, underscore, tilde, and hyphen * @param input - The string to sanitize - * @returns Sanitized string + * @returns Sanitized string, bounded to the analytics attribute recommendation + * @throws Does not throw */ export declare function sanitize(input: string): string; /** * Initializes UTM cookie handling on page load * Extracts UTM parameters from URL, sanitizes them, and persists to cookie - * Only sets the cookie if it doesn't already exist + * Only sets the cookie if it doesn't already exist, preserving first-touch attribution + * @returns void after persisting first-touch attribution or determining that no write is needed + * @throws Does not throw; browser and cookie errors are logged and ignored */ export declare function initializeUtmCookieHandler(): void; /** * Retrieves and parses the tc_utm cookie - * @returns Parsed UTM parameters or null if cookie doesn't exist + * @returns Parsed, allow-listed UTM parameters or null if the cookie is absent or invalid + * @throws Does not throw */ export declare function getUtmCookie(): UtmParams | null; /** @@ -25,6 +32,6 @@ export declare function getUtmCookie(): UtmParams | null; * Only appends parameters that exist in the cookie * @param url - The base URL to append parameters to * @returns URL with UTM parameters appended, or original URL if no cookie exists + * @throws Does not throw; malformed URLs return the original value */ export declare function appendUtmParamsToUrl(url: string): string; -export {};