Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 21 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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:
Expand Down
89 changes: 57 additions & 32 deletions src/lib/functions/utm-cookies.handler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,53 +2,57 @@ 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<keyof UtmParams> = [
'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 = {};

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);
Expand All @@ -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<string>('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;
}
Expand All @@ -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}`;
Expand All @@ -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(';');
Expand All @@ -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,
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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 {
Expand All @@ -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;
Expand All @@ -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) {
Expand All @@ -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();
Expand All @@ -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<string, unknown> {
return typeof value === 'object' && value !== null && !Array.isArray(value);
}
23 changes: 19 additions & 4 deletions src/lib/nudge-app/components/Animation.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, unknown>) => BodymovinAnimation;
}

let ref: Element | undefined = undefined;
let coverRef: HTMLDivElement | undefined = undefined;
Expand All @@ -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,
Expand All @@ -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'});
Expand Down
19 changes: 13 additions & 6 deletions types/src/lib/functions/utm-cookies.handler.d.ts
Original file line number Diff line number Diff line change
@@ -1,30 +1,37 @@
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;
/**
* Appends UTM parameters from the tc_utm cookie to a given URL
* 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 {};
Loading