Skip to content
Merged
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
10 changes: 10 additions & 0 deletions packages/audience/pixel/.eslintrc.cjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
module.exports = {
extends: ['../../../.eslintrc'],
parserOptions: {
project: './tsconfig.eslint.json',
tsconfigRootDir: __dirname,
},
rules: {
'no-underscore-dangle': ['error', { allow: ['__imtbl', '_loaded'] }],
},
};
13 changes: 13 additions & 0 deletions packages/audience/pixel/jest.config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
import type { Config } from 'jest';

const config: Config = {
roots: ['<rootDir>/src'],
moduleDirectories: ['node_modules', 'src'],
testEnvironment: 'jsdom',
transform: {
'^.+\\.(t|j)sx?$': '@swc/jest',
},
transformIgnorePatterns: [],
};

export default config;
43 changes: 43 additions & 0 deletions packages/audience/pixel/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
{
"name": "@imtbl/pixel",
"description": "Immutable Tracking Pixel — drop-in JavaScript snippet for device fingerprint, page view, and attribution data",
"version": "0.0.0",
"author": "Immutable",
"private": true,
"bugs": "https://github.com/immutable/ts-immutable-sdk/issues",
"dependencies": {},
"devDependencies": {
"@swc/core": "^1.4.2",
"@swc/jest": "^0.2.37",
"@types/jest": "^29.5.12",
"eslint": "^8.56.0",
"jest": "^29.7.0",
"jest-environment-jsdom": "^29.4.3",
"tsup": "^8.3.0",
"typescript": "^5.6.2"
},
"exports": {
"development": {
"types": "./src/index.ts",
"default": "./dist/imtbl.js"
},
"default": {
"types": "./dist/types/index.d.ts",
"default": "./dist/imtbl.js"
}
},
"files": ["dist"],
"main": "dist/imtbl.js",
"homepage": "https://github.com/immutable/ts-immutable-sdk#readme",
"repository": "immutable/ts-immutable-sdk.git",
"scripts": {
"build": "pnpm transpile && pnpm typegen",
"transpile": "tsup",
"typegen": "tsc --customConditions default --emitDeclarationOnly --outDir dist/types",
"lint": "eslint ./src --ext .ts --max-warnings=0",
"test": "jest --passWithNoTests",
"typecheck": "tsc --customConditions default --noEmit"
},
"type": "module",
"types": "./dist/types/index.d.ts"
}
132 changes: 132 additions & 0 deletions packages/audience/pixel/src/attribution.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,132 @@
import { collectAttribution, clearAttribution } from './attribution';

const STORAGE_KEY = '__imtbl_attribution';

beforeEach(() => {
sessionStorage.clear();
jest.restoreAllMocks();
});

function setLocation(url: string) {
Object.defineProperty(window, 'location', {
value: new URL(url),
writable: true,
configurable: true,
});
}

describe('collectAttribution', () => {
it('parses UTM parameters from the URL', () => {
setLocation(
'https://example.com/?utm_source=google&utm_medium=cpc&utm_campaign=spring&utm_content=banner&utm_term=nft',
);

const result = collectAttribution();
expect(result.utm_source).toBe('google');
expect(result.utm_medium).toBe('cpc');
expect(result.utm_campaign).toBe('spring');
expect(result.utm_content).toBe('banner');
expect(result.utm_term).toBe('nft');
});

it('parses ad network click IDs', () => {
setLocation(
'https://example.com/?gclid=abc&dclid=dc1&fbclid=fb2&ttclid=tt3&msclkid=ms4&li_fat_id=li5',
);

const result = collectAttribution();
expect(result.gclid).toBe('abc');
expect(result.dclid).toBe('dc1');
expect(result.fbclid).toBe('fb2');
expect(result.ttclid).toBe('tt3');
expect(result.msclkid).toBe('ms4');
expect(result.li_fat_id).toBe('li5');
});

it('captures referrer and landing page', () => {
setLocation('https://game.example.com/landing');
Object.defineProperty(document, 'referrer', {
value: 'https://google.com/search?q=nft',
configurable: true,
});

const result = collectAttribution();
expect(result.referrer).toBe('https://google.com/search?q=nft');
expect(result.landing_page).toBe('https://game.example.com/landing');
});

it('caches in sessionStorage and returns cached on second call', () => {
setLocation('https://example.com/?utm_source=google');

const first = collectAttribution();
expect(first.utm_source).toBe('google');

// Change URL — should still return cached value
setLocation('https://example.com/?utm_source=facebook');
const second = collectAttribution();
expect(second.utm_source).toBe('google');
});

it('parses referral_code from the URL', () => {
setLocation('https://example.com/?referral_code=PARTNER42');

const result = collectAttribution();
expect(result.referral_code).toBe('PARTNER42');
});

it('sets touchpoint_type to click when UTMs are present', () => {
setLocation('https://example.com/?utm_source=google');

const result = collectAttribution();
expect(result.touchpoint_type).toBe('click');
});

it('sets touchpoint_type to click when a click ID is present', () => {
setLocation('https://example.com/?gclid=abc123');

const result = collectAttribution();
expect(result.touchpoint_type).toBe('click');
});

it('does not set touchpoint_type when no UTMs or click IDs are present', () => {
setLocation('https://example.com/');
Object.defineProperty(document, 'referrer', { value: 'https://other.com', configurable: true });

const result = collectAttribution();
expect(result.touchpoint_type).toBeUndefined();
});

it('returns empty attribution when no params are present', () => {
setLocation('https://example.com/');
Object.defineProperty(document, 'referrer', { value: '', configurable: true });

const result = collectAttribution();
expect(result.utm_source).toBeUndefined();
expect(result.gclid).toBeUndefined();
expect(result.referrer).toBeUndefined();
});

it('handles sessionStorage being unavailable', () => {
setLocation('https://example.com/?utm_source=twitter');
jest.spyOn(Storage.prototype, 'getItem').mockImplementation(() => {
throw new Error('storage disabled');
});
jest.spyOn(Storage.prototype, 'setItem').mockImplementation(() => {
throw new Error('storage disabled');
});

const result = collectAttribution();
expect(result.utm_source).toBe('twitter');
});
});

describe('clearAttribution', () => {
it('removes cached attribution from sessionStorage', () => {
setLocation('https://example.com/?utm_source=google');
collectAttribution();
expect(sessionStorage.getItem(STORAGE_KEY)).not.toBeNull();

clearAttribution();
expect(sessionStorage.getItem(STORAGE_KEY)).toBeNull();
});
});
114 changes: 114 additions & 0 deletions packages/audience/pixel/src/attribution.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
const UTM_PARAMS = [
'utm_source',
'utm_medium',
'utm_campaign',
'utm_content',
'utm_term',
] as const;

const CLICK_ID_PARAMS = [
Comment thread
bkbooth marked this conversation as resolved.
'gclid',
'dclid',
'fbclid',
'ttclid',
'msclkid',
'li_fat_id',
] as const;

const STORAGE_KEY = '__imtbl_attribution';

export interface Attribution {
utm_source?: string;
utm_medium?: string;
utm_campaign?: string;
utm_content?: string;
utm_term?: string;
gclid?: string;
dclid?: string;
fbclid?: string;
ttclid?: string;
msclkid?: string;
li_fat_id?: string;
referral_code?: string;
referrer?: string;
landing_page?: string;
touchpoint_type?: string;
}

type AttributionKey = keyof Attribution;

function parseParams(url: string): Attribution {
let params: URLSearchParams;
try {
params = new URL(url).searchParams;
} catch {
return {};
}

const result: Attribution = {};
for (const key of [...UTM_PARAMS, ...CLICK_ID_PARAMS]) {
const value = params.get(key);
if (value) {
result[key as AttributionKey] = value;
}
}

const referralCode = params.get('referral_code');
if (referralCode) {
result.referral_code = referralCode;
}

return result;
}

function loadFromStorage(): Attribution | null {
try {
const raw = sessionStorage.getItem(STORAGE_KEY);
return raw ? (JSON.parse(raw) as Attribution) : null;
} catch {
return null;
}
}

function saveToStorage(attribution: Attribution): void {
try {
sessionStorage.setItem(STORAGE_KEY, JSON.stringify(attribution));
} catch {
// sessionStorage may be unavailable (private browsing, storage full)
}
}

export function collectAttribution(): Attribution {
const cached = loadFromStorage();
if (cached) return cached;

const urlParams = typeof window !== 'undefined' && window.location
? parseParams(window.location.href)
: {};

const referrer = typeof document !== 'undefined' ? document.referrer || undefined : undefined;
const landingPage = typeof window !== 'undefined' && window.location
? window.location.href
: undefined;

const hasClickId = CLICK_ID_PARAMS.some((key) => key in urlParams);
const hasUtm = UTM_PARAMS.some((key) => key in urlParams);

const attribution: Attribution = {
...urlParams,
referrer,
landing_page: landingPage,
touchpoint_type: hasClickId || hasUtm ? 'click' : undefined,
};

saveToStorage(attribution);
return attribution;
}

export function clearAttribution(): void {
try {
sessionStorage.removeItem(STORAGE_KEY);
} catch {
// noop
}
}
8 changes: 8 additions & 0 deletions packages/audience/pixel/src/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
export { collectAttribution, clearAttribution } from './attribution';
export type { Attribution } from './attribution';

export { createLoader } from './loader';
export type { Command, ImtblGlobal } from './loader';

export { generateSnippet } from './snippet';
export type { SnippetOptions } from './snippet';
Loading
Loading