Skip to content

Commit 3cdf46d

Browse files
bkboothclaudenattb8
authored
feat(audience): scaffold @imtbl/pixel package with attribution, loader, and snippet (#2829)
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com> Co-authored-by: Natalie <nf1993@gmail.com>
1 parent 352f909 commit 3cdf46d

15 files changed

Lines changed: 628 additions & 52 deletions
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
module.exports = {
2+
extends: ['../../../.eslintrc'],
3+
parserOptions: {
4+
project: './tsconfig.eslint.json',
5+
tsconfigRootDir: __dirname,
6+
},
7+
rules: {
8+
'no-underscore-dangle': ['error', { allow: ['__imtbl', '_loaded'] }],
9+
},
10+
};
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
import type { Config } from 'jest';
2+
3+
const config: Config = {
4+
roots: ['<rootDir>/src'],
5+
moduleDirectories: ['node_modules', 'src'],
6+
testEnvironment: 'jsdom',
7+
transform: {
8+
'^.+\\.(t|j)sx?$': '@swc/jest',
9+
},
10+
transformIgnorePatterns: [],
11+
};
12+
13+
export default config;
Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
{
2+
"name": "@imtbl/pixel",
3+
"description": "Immutable Tracking Pixel — drop-in JavaScript snippet for device fingerprint, page view, and attribution data",
4+
"version": "0.0.0",
5+
"author": "Immutable",
6+
"private": true,
7+
"bugs": "https://github.com/immutable/ts-immutable-sdk/issues",
8+
"dependencies": {},
9+
"devDependencies": {
10+
"@swc/core": "^1.4.2",
11+
"@swc/jest": "^0.2.37",
12+
"@types/jest": "^29.5.12",
13+
"eslint": "^8.56.0",
14+
"jest": "^29.7.0",
15+
"jest-environment-jsdom": "^29.4.3",
16+
"tsup": "^8.3.0",
17+
"typescript": "^5.6.2"
18+
},
19+
"exports": {
20+
"development": {
21+
"types": "./src/index.ts",
22+
"default": "./dist/imtbl.js"
23+
},
24+
"default": {
25+
"types": "./dist/types/index.d.ts",
26+
"default": "./dist/imtbl.js"
27+
}
28+
},
29+
"files": ["dist"],
30+
"main": "dist/imtbl.js",
31+
"homepage": "https://github.com/immutable/ts-immutable-sdk#readme",
32+
"repository": "immutable/ts-immutable-sdk.git",
33+
"scripts": {
34+
"build": "pnpm transpile && pnpm typegen",
35+
"transpile": "tsup",
36+
"typegen": "tsc --customConditions default --emitDeclarationOnly --outDir dist/types",
37+
"lint": "eslint ./src --ext .ts --max-warnings=0",
38+
"test": "jest --passWithNoTests",
39+
"typecheck": "tsc --customConditions default --noEmit"
40+
},
41+
"type": "module",
42+
"types": "./dist/types/index.d.ts"
43+
}
Lines changed: 132 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,132 @@
1+
import { collectAttribution, clearAttribution } from './attribution';
2+
3+
const STORAGE_KEY = '__imtbl_attribution';
4+
5+
beforeEach(() => {
6+
sessionStorage.clear();
7+
jest.restoreAllMocks();
8+
});
9+
10+
function setLocation(url: string) {
11+
Object.defineProperty(window, 'location', {
12+
value: new URL(url),
13+
writable: true,
14+
configurable: true,
15+
});
16+
}
17+
18+
describe('collectAttribution', () => {
19+
it('parses UTM parameters from the URL', () => {
20+
setLocation(
21+
'https://example.com/?utm_source=google&utm_medium=cpc&utm_campaign=spring&utm_content=banner&utm_term=nft',
22+
);
23+
24+
const result = collectAttribution();
25+
expect(result.utm_source).toBe('google');
26+
expect(result.utm_medium).toBe('cpc');
27+
expect(result.utm_campaign).toBe('spring');
28+
expect(result.utm_content).toBe('banner');
29+
expect(result.utm_term).toBe('nft');
30+
});
31+
32+
it('parses ad network click IDs', () => {
33+
setLocation(
34+
'https://example.com/?gclid=abc&dclid=dc1&fbclid=fb2&ttclid=tt3&msclkid=ms4&li_fat_id=li5',
35+
);
36+
37+
const result = collectAttribution();
38+
expect(result.gclid).toBe('abc');
39+
expect(result.dclid).toBe('dc1');
40+
expect(result.fbclid).toBe('fb2');
41+
expect(result.ttclid).toBe('tt3');
42+
expect(result.msclkid).toBe('ms4');
43+
expect(result.li_fat_id).toBe('li5');
44+
});
45+
46+
it('captures referrer and landing page', () => {
47+
setLocation('https://game.example.com/landing');
48+
Object.defineProperty(document, 'referrer', {
49+
value: 'https://google.com/search?q=nft',
50+
configurable: true,
51+
});
52+
53+
const result = collectAttribution();
54+
expect(result.referrer).toBe('https://google.com/search?q=nft');
55+
expect(result.landing_page).toBe('https://game.example.com/landing');
56+
});
57+
58+
it('caches in sessionStorage and returns cached on second call', () => {
59+
setLocation('https://example.com/?utm_source=google');
60+
61+
const first = collectAttribution();
62+
expect(first.utm_source).toBe('google');
63+
64+
// Change URL — should still return cached value
65+
setLocation('https://example.com/?utm_source=facebook');
66+
const second = collectAttribution();
67+
expect(second.utm_source).toBe('google');
68+
});
69+
70+
it('parses referral_code from the URL', () => {
71+
setLocation('https://example.com/?referral_code=PARTNER42');
72+
73+
const result = collectAttribution();
74+
expect(result.referral_code).toBe('PARTNER42');
75+
});
76+
77+
it('sets touchpoint_type to click when UTMs are present', () => {
78+
setLocation('https://example.com/?utm_source=google');
79+
80+
const result = collectAttribution();
81+
expect(result.touchpoint_type).toBe('click');
82+
});
83+
84+
it('sets touchpoint_type to click when a click ID is present', () => {
85+
setLocation('https://example.com/?gclid=abc123');
86+
87+
const result = collectAttribution();
88+
expect(result.touchpoint_type).toBe('click');
89+
});
90+
91+
it('does not set touchpoint_type when no UTMs or click IDs are present', () => {
92+
setLocation('https://example.com/');
93+
Object.defineProperty(document, 'referrer', { value: 'https://other.com', configurable: true });
94+
95+
const result = collectAttribution();
96+
expect(result.touchpoint_type).toBeUndefined();
97+
});
98+
99+
it('returns empty attribution when no params are present', () => {
100+
setLocation('https://example.com/');
101+
Object.defineProperty(document, 'referrer', { value: '', configurable: true });
102+
103+
const result = collectAttribution();
104+
expect(result.utm_source).toBeUndefined();
105+
expect(result.gclid).toBeUndefined();
106+
expect(result.referrer).toBeUndefined();
107+
});
108+
109+
it('handles sessionStorage being unavailable', () => {
110+
setLocation('https://example.com/?utm_source=twitter');
111+
jest.spyOn(Storage.prototype, 'getItem').mockImplementation(() => {
112+
throw new Error('storage disabled');
113+
});
114+
jest.spyOn(Storage.prototype, 'setItem').mockImplementation(() => {
115+
throw new Error('storage disabled');
116+
});
117+
118+
const result = collectAttribution();
119+
expect(result.utm_source).toBe('twitter');
120+
});
121+
});
122+
123+
describe('clearAttribution', () => {
124+
it('removes cached attribution from sessionStorage', () => {
125+
setLocation('https://example.com/?utm_source=google');
126+
collectAttribution();
127+
expect(sessionStorage.getItem(STORAGE_KEY)).not.toBeNull();
128+
129+
clearAttribution();
130+
expect(sessionStorage.getItem(STORAGE_KEY)).toBeNull();
131+
});
132+
});
Lines changed: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,114 @@
1+
const UTM_PARAMS = [
2+
'utm_source',
3+
'utm_medium',
4+
'utm_campaign',
5+
'utm_content',
6+
'utm_term',
7+
] as const;
8+
9+
const CLICK_ID_PARAMS = [
10+
'gclid',
11+
'dclid',
12+
'fbclid',
13+
'ttclid',
14+
'msclkid',
15+
'li_fat_id',
16+
] as const;
17+
18+
const STORAGE_KEY = '__imtbl_attribution';
19+
20+
export interface Attribution {
21+
utm_source?: string;
22+
utm_medium?: string;
23+
utm_campaign?: string;
24+
utm_content?: string;
25+
utm_term?: string;
26+
gclid?: string;
27+
dclid?: string;
28+
fbclid?: string;
29+
ttclid?: string;
30+
msclkid?: string;
31+
li_fat_id?: string;
32+
referral_code?: string;
33+
referrer?: string;
34+
landing_page?: string;
35+
touchpoint_type?: string;
36+
}
37+
38+
type AttributionKey = keyof Attribution;
39+
40+
function parseParams(url: string): Attribution {
41+
let params: URLSearchParams;
42+
try {
43+
params = new URL(url).searchParams;
44+
} catch {
45+
return {};
46+
}
47+
48+
const result: Attribution = {};
49+
for (const key of [...UTM_PARAMS, ...CLICK_ID_PARAMS]) {
50+
const value = params.get(key);
51+
if (value) {
52+
result[key as AttributionKey] = value;
53+
}
54+
}
55+
56+
const referralCode = params.get('referral_code');
57+
if (referralCode) {
58+
result.referral_code = referralCode;
59+
}
60+
61+
return result;
62+
}
63+
64+
function loadFromStorage(): Attribution | null {
65+
try {
66+
const raw = sessionStorage.getItem(STORAGE_KEY);
67+
return raw ? (JSON.parse(raw) as Attribution) : null;
68+
} catch {
69+
return null;
70+
}
71+
}
72+
73+
function saveToStorage(attribution: Attribution): void {
74+
try {
75+
sessionStorage.setItem(STORAGE_KEY, JSON.stringify(attribution));
76+
} catch {
77+
// sessionStorage may be unavailable (private browsing, storage full)
78+
}
79+
}
80+
81+
export function collectAttribution(): Attribution {
82+
const cached = loadFromStorage();
83+
if (cached) return cached;
84+
85+
const urlParams = typeof window !== 'undefined' && window.location
86+
? parseParams(window.location.href)
87+
: {};
88+
89+
const referrer = typeof document !== 'undefined' ? document.referrer || undefined : undefined;
90+
const landingPage = typeof window !== 'undefined' && window.location
91+
? window.location.href
92+
: undefined;
93+
94+
const hasClickId = CLICK_ID_PARAMS.some((key) => key in urlParams);
95+
const hasUtm = UTM_PARAMS.some((key) => key in urlParams);
96+
97+
const attribution: Attribution = {
98+
...urlParams,
99+
referrer,
100+
landing_page: landingPage,
101+
touchpoint_type: hasClickId || hasUtm ? 'click' : undefined,
102+
};
103+
104+
saveToStorage(attribution);
105+
return attribution;
106+
}
107+
108+
export function clearAttribution(): void {
109+
try {
110+
sessionStorage.removeItem(STORAGE_KEY);
111+
} catch {
112+
// noop
113+
}
114+
}
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
export { collectAttribution, clearAttribution } from './attribution';
2+
export type { Attribution } from './attribution';
3+
4+
export { createLoader } from './loader';
5+
export type { Command, ImtblGlobal } from './loader';
6+
7+
export { generateSnippet } from './snippet';
8+
export type { SnippetOptions } from './snippet';

0 commit comments

Comments
 (0)