Skip to content

Commit ac5ad0d

Browse files
feat(audience): add ImmutableWebSDK class with attribution and session lifecycle
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 9791814 commit ac5ad0d

5 files changed

Lines changed: 974 additions & 10 deletions

File tree

Lines changed: 124 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,124 @@
1+
import { parseAttribution, attributionToProperties } from './attribution';
2+
3+
beforeEach(() => {
4+
sessionStorage.clear();
5+
});
6+
7+
describe('parseAttribution', () => {
8+
it('parses UTM params from the URL', () => {
9+
Object.defineProperty(window, 'location', {
10+
value: {
11+
search: '?utm_source=youtube&utm_medium=influencer&utm_campaign=launch',
12+
href: 'https://studio.com/shop?utm_source=youtube&utm_medium=influencer&utm_campaign=launch',
13+
},
14+
writable: true,
15+
});
16+
17+
const ctx = parseAttribution();
18+
expect(ctx.utmSource).toBe('youtube');
19+
expect(ctx.utmMedium).toBe('influencer');
20+
expect(ctx.utmCampaign).toBe('launch');
21+
expect(ctx.landingPage).toBe(window.location.href);
22+
});
23+
24+
it('parses click IDs from the URL', () => {
25+
Object.defineProperty(window, 'location', {
26+
value: {
27+
search: '?gclid=abc&fbclid=def&ttclid=ghi&msclkid=jkl&dclid=mno&li_fat_id=pqr',
28+
href: 'https://studio.com/?gclid=abc&fbclid=def&ttclid=ghi&msclkid=jkl&dclid=mno&li_fat_id=pqr',
29+
},
30+
writable: true,
31+
});
32+
33+
const ctx = parseAttribution();
34+
expect(ctx.gclid).toBe('abc');
35+
expect(ctx.fbclid).toBe('def');
36+
expect(ctx.ttclid).toBe('ghi');
37+
expect(ctx.msclkid).toBe('jkl');
38+
expect(ctx.dclid).toBe('mno');
39+
expect(ctx.li_fat_id).toBe('pqr');
40+
});
41+
42+
it('parses ref param as referralCode', () => {
43+
Object.defineProperty(window, 'location', {
44+
value: {
45+
search: '?ref=creator_handle',
46+
href: 'https://studio.com/?ref=creator_handle',
47+
},
48+
writable: true,
49+
});
50+
51+
const ctx = parseAttribution();
52+
expect(ctx.referralCode).toBe('creator_handle');
53+
});
54+
55+
it('returns cached attribution on subsequent calls within session', () => {
56+
Object.defineProperty(window, 'location', {
57+
value: {
58+
search: '?utm_source=first',
59+
href: 'https://studio.com/?utm_source=first',
60+
},
61+
writable: true,
62+
});
63+
64+
const first = parseAttribution();
65+
expect(first.utmSource).toBe('first');
66+
67+
// Change URL (simulating SPA navigation)
68+
Object.defineProperty(window, 'location', {
69+
value: {
70+
search: '',
71+
href: 'https://studio.com/shop',
72+
},
73+
writable: true,
74+
});
75+
76+
const second = parseAttribution();
77+
expect(second.utmSource).toBe('first'); // Still the original
78+
});
79+
80+
it('returns empty context with no params', () => {
81+
Object.defineProperty(window, 'location', {
82+
value: {
83+
search: '',
84+
href: 'https://studio.com/',
85+
},
86+
writable: true,
87+
});
88+
89+
const ctx = parseAttribution();
90+
expect(ctx.utmSource).toBeUndefined();
91+
expect(ctx.gclid).toBeUndefined();
92+
expect(ctx.landingPage).toBe('https://studio.com/');
93+
});
94+
});
95+
96+
describe('attributionToProperties', () => {
97+
it('converts attribution context to flat properties', () => {
98+
const props = attributionToProperties({
99+
utmSource: 'youtube',
100+
utmMedium: 'influencer',
101+
gclid: 'abc',
102+
dclid: 'mno',
103+
li_fat_id: 'pqr',
104+
referralCode: 'ref123',
105+
landingPage: 'https://studio.com/',
106+
});
107+
108+
expect(props).toEqual({
109+
utm_source: 'youtube',
110+
utm_medium: 'influencer',
111+
gclid: 'abc',
112+
dclid: 'mno',
113+
li_fat_id: 'pqr',
114+
referral_code: 'ref123',
115+
landing_page: 'https://studio.com/',
116+
});
117+
});
118+
119+
it('omits undefined fields', () => {
120+
const props = attributionToProperties({ utmSource: 'youtube' });
121+
expect(props).toEqual({ utm_source: 'youtube' });
122+
expect(props).not.toHaveProperty('utm_medium');
123+
});
124+
});
Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,92 @@
1+
import { isBrowser } from '@imtbl/audience-core';
2+
3+
export interface AttributionContext {
4+
utmSource?: string;
5+
utmMedium?: string;
6+
utmCampaign?: string;
7+
utmContent?: string;
8+
utmTerm?: string;
9+
gclid?: string;
10+
fbclid?: string;
11+
ttclid?: string;
12+
msclkid?: string;
13+
dclid?: string;
14+
li_fat_id?: string;
15+
referrer?: string;
16+
referralCode?: string;
17+
landingPage?: string;
18+
}
19+
20+
const SESSION_KEY = '__imtbl_attribution';
21+
22+
function getSessionAttribution(): AttributionContext | undefined {
23+
try {
24+
const raw = sessionStorage.getItem(SESSION_KEY);
25+
return raw ? JSON.parse(raw) : undefined;
26+
} catch {
27+
return undefined;
28+
}
29+
}
30+
31+
function persistSessionAttribution(ctx: AttributionContext): void {
32+
try {
33+
sessionStorage.setItem(SESSION_KEY, JSON.stringify(ctx));
34+
} catch {
35+
// sessionStorage unavailable — attribution won't persist across SPA navigations
36+
}
37+
}
38+
39+
/**
40+
* Parse attribution signals from the current URL.
41+
* Captured once per session and persisted in sessionStorage so SPA
42+
* route changes don't lose the original UTM params.
43+
*/
44+
export function parseAttribution(): AttributionContext {
45+
if (!isBrowser()) return {};
46+
47+
// Return cached attribution for this session if it exists
48+
const cached = getSessionAttribution();
49+
if (cached) return cached;
50+
51+
const params = new URLSearchParams(window.location.search);
52+
53+
const ctx: AttributionContext = {
54+
utmSource: params.get('utm_source') ?? undefined,
55+
utmMedium: params.get('utm_medium') ?? undefined,
56+
utmCampaign: params.get('utm_campaign') ?? undefined,
57+
utmContent: params.get('utm_content') ?? undefined,
58+
utmTerm: params.get('utm_term') ?? undefined,
59+
gclid: params.get('gclid') ?? undefined,
60+
fbclid: params.get('fbclid') ?? undefined,
61+
ttclid: params.get('ttclid') ?? undefined,
62+
msclkid: params.get('msclkid') ?? undefined,
63+
dclid: params.get('dclid') ?? undefined,
64+
li_fat_id: params.get('li_fat_id') ?? undefined,
65+
referrer: document.referrer || undefined,
66+
referralCode: params.get('ref') ?? undefined,
67+
landingPage: window.location.href,
68+
};
69+
70+
persistSessionAttribution(ctx);
71+
return ctx;
72+
}
73+
74+
/** Convert attribution context to flat properties for the first PageMessage. */
75+
export function attributionToProperties(ctx: AttributionContext): Record<string, string> {
76+
const props: Record<string, string> = {};
77+
if (ctx.utmSource) props.utm_source = ctx.utmSource;
78+
if (ctx.utmMedium) props.utm_medium = ctx.utmMedium;
79+
if (ctx.utmCampaign) props.utm_campaign = ctx.utmCampaign;
80+
if (ctx.utmContent) props.utm_content = ctx.utmContent;
81+
if (ctx.utmTerm) props.utm_term = ctx.utmTerm;
82+
if (ctx.gclid) props.gclid = ctx.gclid;
83+
if (ctx.fbclid) props.fbclid = ctx.fbclid;
84+
if (ctx.ttclid) props.ttclid = ctx.ttclid;
85+
if (ctx.msclkid) props.msclkid = ctx.msclkid;
86+
if (ctx.dclid) props.dclid = ctx.dclid;
87+
if (ctx.li_fat_id) props.li_fat_id = ctx.li_fat_id;
88+
if (ctx.referrer) props.referrer = ctx.referrer;
89+
if (ctx.referralCode) props.referral_code = ctx.referralCode;
90+
if (ctx.landingPage) props.landing_page = ctx.landingPage;
91+
return props;
92+
}

packages/audience/web/src/index.ts

Lines changed: 2 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,3 @@
1-
export { ConsentManager } from './consent';
2-
export type { ConsentCallbacks } from './consent';
1+
export { ImmutableWebSDK } from './sdk';
32
export type { WebSDKConfig } from './types';
4-
export type { SessionResult } from './cookie';
5-
export {
6-
getOrCreateSessionId,
7-
getSessionId,
8-
touchSession,
9-
} from './cookie';
10-
export { collectContext } from './context';
11-
export { DebugLogger } from './debug';
3+
export type { Environment, ConsentLevel, UserTraits } from '@imtbl/audience-core';

0 commit comments

Comments
 (0)