Skip to content

Commit 60ace13

Browse files
committed
basic attribution
1 parent 9cfed46 commit 60ace13

File tree

12 files changed

+1929
-0
lines changed

12 files changed

+1929
-0
lines changed

pkg/attribution/README.md

Lines changed: 686 additions & 0 deletions
Large diffs are not rendered by default.

pkg/attribution/jest.config.ts

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
import type { Config } from 'jest';
2+
import { execSync } from 'child_process';
3+
import { name } from './package.json';
4+
5+
const rootDirs = execSync(`pnpm --filter ${name}... exec pwd`)
6+
.toString()
7+
.split('\n')
8+
.filter(Boolean)
9+
.map((dir) => `${dir}/dist`);
10+
11+
const config: Config = {
12+
clearMocks: true,
13+
roots: ['<rootDir>/src', ...rootDirs],
14+
coverageProvider: 'v8',
15+
moduleDirectories: ['node_modules', 'src'],
16+
moduleNameMapper: { '^@imtbl/(.*)$': '<rootDir>/../../node_modules/@imtbl/$1/src' },
17+
testEnvironment: 'jsdom',
18+
transform: {
19+
'^.+\\.(t|j)sx?$': '@swc/jest',
20+
},
21+
transformIgnorePatterns: [],
22+
};
23+
24+
export default config;
25+

pkg/attribution/package.json

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
{
2+
"name": "@imtbl/attribution",
3+
"description": "Minimal marketing attribution package for web - replacement for AppsFlyer/Adjust",
4+
"version": "0.0.0",
5+
"author": "Immutable",
6+
"bugs": "https://github.com/immutable/ts-immutable-sdk/issues",
7+
"dependencies": {},
8+
"devDependencies": {
9+
"@swc/core": "^1.3.36",
10+
"@swc/jest": "^0.2.37",
11+
"@types/jest": "^29.4.3",
12+
"@types/node": "^18.14.2",
13+
"@typescript-eslint/eslint-plugin": "^5.57.1",
14+
"@typescript-eslint/parser": "^5.57.1",
15+
"eslint": "^8.40.0",
16+
"jest": "^29.4.3",
17+
"jest-environment-jsdom": "^29.4.3",
18+
"prettier": "^2.8.7",
19+
"ts-node": "^10.9.1",
20+
"tsup": "8.3.0",
21+
"typescript": "^5.6.2"
22+
},
23+
"engines": {
24+
"node": ">=20.11.0"
25+
},
26+
"exports": {
27+
"development": {
28+
"types": "./src/index.ts",
29+
"browser": "./dist/browser/index.js",
30+
"require": "./dist/node/index.cjs",
31+
"default": "./dist/node/index.js"
32+
},
33+
"default": {
34+
"types": "./dist/types/index.d.ts",
35+
"browser": "./dist/browser/index.js",
36+
"require": "./dist/node/index.cjs",
37+
"default": "./dist/node/index.js"
38+
}
39+
},
40+
"files": [
41+
"dist"
42+
],
43+
"homepage": "https://github.com/immutable/ts-immutable-sdk#readme",
44+
"license": "Apache-2.0",
45+
"main": "dist/node/index.cjs",
46+
"module": "dist/node/index.js",
47+
"browser": "dist/browser/index.js",
48+
"publishConfig": {
49+
"access": "public"
50+
},
51+
"repository": "immutable/ts-immutable-sdk.git",
52+
"scripts": {
53+
"build": "pnpm transpile && pnpm typegen",
54+
"transpile": "tsup src/index.ts --config ../../tsup.config.js",
55+
"typegen": "tsc --customConditions default --emitDeclarationOnly --outDir dist/types",
56+
"pack:root": "pnpm pack --pack-destination $(dirname $(pnpm root -w))",
57+
"lint": "eslint ./src --ext .ts,.jsx,.tsx --max-warnings=0",
58+
"test": "jest",
59+
"test:watch": "jest --watch",
60+
"typecheck": "tsc --customConditions default --noEmit --jsx preserve"
61+
},
62+
"type": "module",
63+
"types": "./dist/types/index.d.ts"
64+
}
65+

pkg/attribution/src/attribution.ts

Lines changed: 147 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,147 @@
1+
/**
2+
* Attribution data extracted from URL parameters and referrer
3+
*/
4+
export interface AttributionData {
5+
/** Campaign source (e.g., 'google', 'facebook') */
6+
source?: string;
7+
/** Campaign medium (e.g., 'cpc', 'email') */
8+
medium?: string;
9+
/** Campaign name */
10+
campaign?: string;
11+
/** Campaign term (keywords) */
12+
term?: string;
13+
/** Campaign content (A/B testing) */
14+
content?: string;
15+
/** Referrer URL */
16+
referrer?: string;
17+
/** Landing page URL */
18+
landingPage?: string;
19+
/** First touch timestamp */
20+
firstTouchTime?: number;
21+
/** Last touch timestamp */
22+
lastTouchTime?: number;
23+
/** Custom attribution parameters */
24+
custom?: Record<string, string>;
25+
}
26+
27+
/**
28+
* Parse URL parameters for attribution data
29+
*/
30+
export function parseAttributionFromUrl(url?: string): AttributionData {
31+
const urlObj = typeof window !== 'undefined' && !url
32+
? new URL(window.location.href)
33+
: url
34+
? new URL(url, typeof window !== 'undefined' ? window.location.origin : 'https://example.com')
35+
: null;
36+
37+
if (!urlObj) {
38+
return {};
39+
}
40+
41+
const params = urlObj.searchParams;
42+
const attribution: AttributionData = {
43+
landingPage: urlObj.href,
44+
firstTouchTime: Date.now(),
45+
lastTouchTime: Date.now(),
46+
custom: {},
47+
};
48+
49+
// Standard UTM parameters
50+
const utmSource = params.get('utm_source');
51+
const utmMedium = params.get('utm_medium');
52+
const utmCampaign = params.get('utm_campaign');
53+
const utmTerm = params.get('utm_term');
54+
const utmContent = params.get('utm_content');
55+
56+
if (utmSource) attribution.source = utmSource;
57+
if (utmMedium) attribution.medium = utmMedium;
58+
if (utmCampaign) attribution.campaign = utmCampaign;
59+
if (utmTerm) attribution.term = utmTerm;
60+
if (utmContent) attribution.content = utmContent;
61+
62+
// AppsFlyer parameters (af_*)
63+
const afSource = params.get('af_source') || params.get('pid');
64+
const afMedium = params.get('af_medium') || params.get('c');
65+
const afCampaign = params.get('af_campaign') || params.get('af_c');
66+
const afAdset = params.get('af_adset') || params.get('af_adset_id');
67+
const afAd = params.get('af_ad') || params.get('af_ad_id');
68+
69+
if (afSource && !attribution.source) attribution.source = afSource;
70+
if (afMedium && !attribution.medium) attribution.medium = afMedium;
71+
if (afCampaign && !attribution.campaign) attribution.campaign = afCampaign;
72+
if (afAdset) attribution.custom = { ...attribution.custom, af_adset: afAdset };
73+
if (afAd) attribution.custom = { ...attribution.custom, af_ad: afAd };
74+
75+
// Adjust parameters (adjust_*)
76+
const adjustSource = params.get('adjust_source') || params.get('network');
77+
const adjustCampaign = params.get('adjust_campaign') || params.get('campaign');
78+
const adjustAdgroup = params.get('adjust_adgroup') || params.get('adgroup');
79+
const adjustCreative = params.get('adjust_creative') || params.get('creative');
80+
81+
if (adjustSource && !attribution.source) attribution.source = adjustSource;
82+
if (adjustCampaign && !attribution.campaign) attribution.campaign = adjustCampaign;
83+
if (adjustAdgroup) attribution.custom = { ...attribution.custom, adjust_adgroup: adjustAdgroup };
84+
if (adjustCreative) attribution.custom = { ...attribution.custom, adjust_creative: adjustCreative };
85+
86+
// Referrer
87+
if (typeof document !== 'undefined' && document.referrer) {
88+
try {
89+
const referrerUrl = new URL(document.referrer);
90+
attribution.referrer = referrerUrl.hostname;
91+
} catch {
92+
attribution.referrer = document.referrer;
93+
}
94+
}
95+
96+
// Collect any remaining custom parameters
97+
for (const [key, value] of params.entries()) {
98+
if (
99+
!key.startsWith('utm_') &&
100+
!key.startsWith('af_') &&
101+
!key.startsWith('adjust_') &&
102+
key !== 'pid' &&
103+
key !== 'c' &&
104+
key !== 'network' &&
105+
key !== 'campaign' &&
106+
key !== 'adgroup' &&
107+
key !== 'creative'
108+
) {
109+
attribution.custom = attribution.custom || {};
110+
attribution.custom[key] = value;
111+
}
112+
}
113+
114+
return attribution;
115+
}
116+
117+
/**
118+
* Merge new attribution data with existing data
119+
*/
120+
export function mergeAttributionData(
121+
existing: AttributionData | null,
122+
incoming: AttributionData,
123+
): AttributionData {
124+
if (!existing) {
125+
return incoming;
126+
}
127+
128+
return {
129+
...existing,
130+
// Keep first touch time from existing
131+
firstTouchTime: existing.firstTouchTime || incoming.firstTouchTime,
132+
// Update last touch time
133+
lastTouchTime: incoming.lastTouchTime || Date.now(),
134+
// Merge custom parameters
135+
custom: {
136+
...existing.custom,
137+
...incoming.custom,
138+
},
139+
// Prefer existing source/medium/campaign unless incoming has values
140+
source: incoming.source || existing.source,
141+
medium: incoming.medium || existing.medium,
142+
campaign: incoming.campaign || existing.campaign,
143+
term: incoming.term || existing.term,
144+
content: incoming.content || existing.content,
145+
};
146+
}
147+

pkg/attribution/src/deeplink.ts

Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,87 @@
1+
import type { AttributionData } from './attribution';
2+
3+
/**
4+
* Deep link data extracted from URL parameters
5+
*/
6+
export interface DeepLinkData {
7+
/** Deep link path (e.g., '/product/123') */
8+
path?: string;
9+
/** Deep link value (alternative to path) */
10+
value?: string;
11+
/** All deep link parameters */
12+
params?: Record<string, string>;
13+
/** Full deep link URL */
14+
url?: string;
15+
}
16+
17+
/**
18+
* Common deep link parameter names used by AppsFlyer/Adjust
19+
*/
20+
const DEEP_LINK_PARAM_NAMES = [
21+
'deep_link',
22+
'deep_link_value',
23+
'deep_link_path',
24+
'af_dp', // AppsFlyer deep link path
25+
'af_dl', // AppsFlyer deep link value
26+
'af_web_dp', // AppsFlyer web deep link path
27+
'adjust_deeplink', // Adjust deep link
28+
'deeplink',
29+
'deeplink_path',
30+
'deeplink_value',
31+
];
32+
33+
/**
34+
* Extract deep link data from attribution data
35+
*/
36+
export function extractDeepLinkData(attribution: AttributionData | null): DeepLinkData | null {
37+
if (!attribution || !attribution.custom) {
38+
return null;
39+
}
40+
41+
const deepLink: DeepLinkData = {
42+
params: {},
43+
};
44+
45+
// Check for common deep link parameter names
46+
for (const paramName of DEEP_LINK_PARAM_NAMES) {
47+
const value = attribution.custom[paramName];
48+
if (value) {
49+
if (paramName.includes('path') || paramName === 'af_dp' || paramName === 'af_web_dp') {
50+
deepLink.path = value;
51+
} else if (paramName.includes('value') || paramName === 'af_dl') {
52+
deepLink.value = value;
53+
} else {
54+
// Generic deep link parameter
55+
deepLink.path = deepLink.path || value;
56+
deepLink.value = deepLink.value || value;
57+
}
58+
}
59+
}
60+
61+
// If no standard deep link params found, check for any custom params
62+
// that might be deep link related
63+
if (!deepLink.path && !deepLink.value && Object.keys(attribution.custom).length > 0) {
64+
// Use first custom param as potential deep link
65+
const firstParam = Object.entries(attribution.custom)[0];
66+
if (firstParam) {
67+
deepLink.value = firstParam[1];
68+
deepLink.params = { [firstParam[0]]: firstParam[1] };
69+
}
70+
} else {
71+
// Include all custom params as deep link params
72+
deepLink.params = { ...attribution.custom };
73+
}
74+
75+
// Include landing page URL if available
76+
if (attribution.landingPage) {
77+
deepLink.url = attribution.landingPage;
78+
}
79+
80+
// Return null if no deep link data found
81+
if (!deepLink.path && !deepLink.value && Object.keys(deepLink.params || {}).length === 0) {
82+
return null;
83+
}
84+
85+
return deepLink;
86+
}
87+

0 commit comments

Comments
 (0)