elysium-attribution 1.0.0 → 1.0.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +365 -0
- package/index.d.ts +213 -0
- package/package.json +34 -6
- package/src/config/index.js +45 -0
- package/src/config/schema.js +85 -0
- package/src/dom/buttonDetector.js +128 -0
- package/src/gateways/GatewayRegistry.js +75 -0
- package/src/gateways/GenericGateway.js +12 -0
- package/src/gateways/PayPalGateway.js +83 -0
- package/src/gateways/PaymentGateway.js +173 -0
- package/src/gateways/RazorpayGateway.js +88 -0
- package/src/gateways/StripeGateway.js +89 -0
- package/src/gateways/index.js +6 -0
- package/src/index.js +151 -0
- package/src/signals/params.js +68 -0
- package/src/signals/referrer.js +106 -0
- package/src/signals/session.js +134 -0
- package/src/signals/touchEngine.js +211 -0
- package/src/signals/transport.js +64 -0
- package/src/signals/visitor.js +82 -0
- package/src/tracking/params.js +68 -0
- package/src/tracking/referrer.js +106 -0
- package/src/tracking/session.js +134 -0
- package/src/tracking/touchTracker.js +202 -0
- package/src/tracking/transport.js +60 -0
- package/src/tracking/visitor.js +82 -0
- package/src/utils/attributionInit.js +56 -3
- package/src/utils/cli.js +69 -17
- package/src/utils/logger.js +31 -0
- package/src/utils/storage.js +110 -0
- package/test/buttonDetector.test.js +26 -0
- package/test/config.test.js +35 -0
- package/test/gateways.test.js +112 -0
- package/test/params.test.js +51 -0
- package/test/referrer.test.js +41 -0
- package/test/session.test.js +112 -0
- package/test/visitor.test.js +35 -0
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
import { getVisitorId, setVisitorId } from './visitor.js';
|
|
2
|
+
import { extractMarketingParams } from './params.js';
|
|
3
|
+
import { parseReferrer } from './referrer.js';
|
|
4
|
+
import { shouldSendTouch, saveSuccessfulTouch, readTouchHistory } from './session.js';
|
|
5
|
+
import { request } from './transport.js';
|
|
6
|
+
import { getConfig } from '../config/index.js';
|
|
7
|
+
import { logger } from '../utils/logger.js';
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Construct full touch payload with parameters, referrer, and visitor ID
|
|
11
|
+
*/
|
|
12
|
+
export function buildPayload(customData = {}) {
|
|
13
|
+
const config = getConfig();
|
|
14
|
+
const visitorId = getVisitorId();
|
|
15
|
+
const { utm, clickIds, cookies, custom } = extractMarketingParams();
|
|
16
|
+
const referrerInfo = parseReferrer();
|
|
17
|
+
|
|
18
|
+
// If UTM source/medium missing, infer from organic/social referrer
|
|
19
|
+
const source = utm.source || referrerInfo.detectedSource || null;
|
|
20
|
+
const medium = utm.medium || referrerInfo.detectedMedium || null;
|
|
21
|
+
|
|
22
|
+
const currentHref = typeof window !== 'undefined' ? window.location.href : '';
|
|
23
|
+
const currentTitle = typeof document !== 'undefined' ? document.title : '';
|
|
24
|
+
|
|
25
|
+
return {
|
|
26
|
+
visitorId,
|
|
27
|
+
touchedAt: new Date().toISOString(),
|
|
28
|
+
source,
|
|
29
|
+
medium,
|
|
30
|
+
campaign: utm.campaign,
|
|
31
|
+
term: utm.term,
|
|
32
|
+
content: utm.content,
|
|
33
|
+
referrer: referrerInfo.referrer,
|
|
34
|
+
landingPage: currentHref,
|
|
35
|
+
pageTitle: currentTitle,
|
|
36
|
+
channel: referrerInfo.channel,
|
|
37
|
+
...clickIds,
|
|
38
|
+
...cookies,
|
|
39
|
+
custom: { ...custom, ...customData },
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Record a marketing touchpoint
|
|
45
|
+
*/
|
|
46
|
+
export async function recordTouch(options = {}) {
|
|
47
|
+
const config = getConfig();
|
|
48
|
+
|
|
49
|
+
if (!config.apiKey) {
|
|
50
|
+
logger.warn('Tracking disabled: apiKey is not configured.');
|
|
51
|
+
return { ok: false, error: 'apiKey missing' };
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// Path regex check if configured (e.g. for product pages only)
|
|
55
|
+
if (config.productPathRegex && typeof window !== 'undefined') {
|
|
56
|
+
const regex =
|
|
57
|
+
config.productPathRegex instanceof RegExp
|
|
58
|
+
? config.productPathRegex
|
|
59
|
+
: new RegExp(config.productPathRegex);
|
|
60
|
+
if (!regex.test(window.location.pathname)) {
|
|
61
|
+
logger.debug('Skipping touch: Path did not match productPathRegex.');
|
|
62
|
+
return { ok: false, skipped: true, reason: 'path_mismatch' };
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const payload = buildPayload(options.customData);
|
|
67
|
+
const history = readTouchHistory();
|
|
68
|
+
|
|
69
|
+
if (!options.force && !shouldSendTouch(payload, history)) {
|
|
70
|
+
logger.debug('Touch not sent: Duplicate or redundant in active session.');
|
|
71
|
+
return { ok: false, skipped: true, reason: 'deduplicated' };
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
logger.info('Sending touch payload:', payload);
|
|
75
|
+
|
|
76
|
+
const res = await request('/v1/touches', {
|
|
77
|
+
method: 'POST',
|
|
78
|
+
body: payload,
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
if (!res.ok) {
|
|
82
|
+
// If request blocked by adblocker, we still save local event so UI/visitor state remains intact
|
|
83
|
+
if (res.blocked) {
|
|
84
|
+
logger.debug('Touch request blocked by client extension; continuing in local offline mode.');
|
|
85
|
+
} else {
|
|
86
|
+
logger.error('Failed to record touch:', res.error);
|
|
87
|
+
return res;
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
const responseData = res.data || {
|
|
92
|
+
id: `local_${Date.now()}`,
|
|
93
|
+
visitorId: payload.visitorId,
|
|
94
|
+
channel: payload.channel || 'direct',
|
|
95
|
+
touchedAt: payload.touchedAt,
|
|
96
|
+
offlineMode: res.blocked ?? false,
|
|
97
|
+
};
|
|
98
|
+
|
|
99
|
+
saveSuccessfulTouch({
|
|
100
|
+
payload,
|
|
101
|
+
response: responseData,
|
|
102
|
+
recordedAt: Date.now(),
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
logger.info('Touch recorded successfully:', responseData);
|
|
106
|
+
|
|
107
|
+
if (typeof window !== 'undefined') {
|
|
108
|
+
window.dispatchEvent(
|
|
109
|
+
new CustomEvent('elysium:touch_recorded', {
|
|
110
|
+
detail: { payload, response: responseData },
|
|
111
|
+
})
|
|
112
|
+
);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
return { ok: true, data: responseData };
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* Backward-compatible product touch tracker from existing touch-tracking.ts
|
|
120
|
+
*/
|
|
121
|
+
export async function recordProductTouch() {
|
|
122
|
+
const config = getConfig();
|
|
123
|
+
const pathRegex = config.productPathRegex || /^\/product\/[^/]+\/?$/;
|
|
124
|
+
if (typeof window !== 'undefined' && !pathRegex.test(window.location.pathname)) {
|
|
125
|
+
return;
|
|
126
|
+
}
|
|
127
|
+
return recordTouch();
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* Identify a visitor with their email and optional user traits
|
|
132
|
+
*/
|
|
133
|
+
export async function identifyVisitor(email, traits = {}) {
|
|
134
|
+
const config = getConfig();
|
|
135
|
+
|
|
136
|
+
if (!config.apiKey) {
|
|
137
|
+
logger.warn('Visitor identification failed: apiKey is not configured.');
|
|
138
|
+
return false;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
if (!email || typeof email !== 'string') {
|
|
142
|
+
logger.error('identifyVisitor requires a valid email string.');
|
|
143
|
+
return false;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
const visitorId = getVisitorId();
|
|
147
|
+
const payload = {
|
|
148
|
+
visitorId,
|
|
149
|
+
email,
|
|
150
|
+
traits,
|
|
151
|
+
identifiedAt: new Date().toISOString(),
|
|
152
|
+
};
|
|
153
|
+
|
|
154
|
+
const res = await request('/v1/identify', {
|
|
155
|
+
method: 'POST',
|
|
156
|
+
body: payload,
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
if (!res.ok) {
|
|
160
|
+
logger.debug('Identify request status:', res.error);
|
|
161
|
+
return false;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
if (res.data && res.data.canonicalVisitorId) {
|
|
165
|
+
setVisitorId(res.data.canonicalVisitorId);
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
logger.info('Visitor identified successfully:', res.data);
|
|
169
|
+
return true;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* Request checkout attribution session token
|
|
174
|
+
*/
|
|
175
|
+
export async function createCheckoutSession() {
|
|
176
|
+
const config = getConfig();
|
|
177
|
+
|
|
178
|
+
if (!config.apiKey) {
|
|
179
|
+
throw new Error('Checkout session was not created: apiKey is not configured.');
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
const visitorId = getVisitorId();
|
|
183
|
+
const res = await request('/v1/checkout-sessions', {
|
|
184
|
+
method: 'POST',
|
|
185
|
+
body: { visitorId },
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
if (!res.ok) {
|
|
189
|
+
// Generate resilient offline session if network/adblocker blocks the API
|
|
190
|
+
return {
|
|
191
|
+
checkoutSessionId: `chk_local_${Date.now()}`,
|
|
192
|
+
token: `tok_local_${Date.now()}`,
|
|
193
|
+
visitorId,
|
|
194
|
+
visitorRecordId: 'local_record',
|
|
195
|
+
touchIds: [],
|
|
196
|
+
touchCount: 1,
|
|
197
|
+
expiresAt: new Date(Date.now() + 3600000).toISOString(),
|
|
198
|
+
};
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
const result = res.data;
|
|
202
|
+
return {
|
|
203
|
+
checkoutSessionId: result.checkoutSessionId,
|
|
204
|
+
token: result.token,
|
|
205
|
+
visitorId: result.visitorId || visitorId,
|
|
206
|
+
visitorRecordId: result.visitorRecordId || '',
|
|
207
|
+
touchIds: Array.isArray(result.touchIds) ? result.touchIds : [],
|
|
208
|
+
touchCount: typeof result.touchCount === 'number' ? result.touchCount : 0,
|
|
209
|
+
expiresAt: result.expiresAt || new Date(Date.now() + 3600000).toISOString(),
|
|
210
|
+
};
|
|
211
|
+
}
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import { getConfig } from '../config/index.js';
|
|
2
|
+
import { logger } from '../utils/logger.js';
|
|
3
|
+
|
|
4
|
+
export async function request(path, options = {}) {
|
|
5
|
+
const config = getConfig();
|
|
6
|
+
const url = `${config.apiBaseUrl}${path}`;
|
|
7
|
+
|
|
8
|
+
const headers = {
|
|
9
|
+
'Content-Type': 'application/json',
|
|
10
|
+
...(config.apiKey ? { 'X-Api-Key': config.apiKey, 'Authorization': `Bearer ${config.apiKey}` } : {}),
|
|
11
|
+
...options.headers,
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
const timeoutMs = options.timeout ?? 8000;
|
|
15
|
+
let controller = null;
|
|
16
|
+
let timeoutId = null;
|
|
17
|
+
|
|
18
|
+
if (typeof AbortController !== 'undefined') {
|
|
19
|
+
controller = new AbortController();
|
|
20
|
+
timeoutId = setTimeout(() => controller.abort(), timeoutMs);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
try {
|
|
24
|
+
const response = await fetch(url, {
|
|
25
|
+
method: options.method || 'POST',
|
|
26
|
+
headers,
|
|
27
|
+
body: options.body ? JSON.stringify(options.body) : undefined,
|
|
28
|
+
keepalive: options.keepalive ?? true,
|
|
29
|
+
signal: controller ? controller.signal : undefined,
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
if (timeoutId) clearTimeout(timeoutId);
|
|
33
|
+
|
|
34
|
+
const contentType = response.headers.get('content-type') || '';
|
|
35
|
+
let data = null;
|
|
36
|
+
if (contentType.includes('application/json')) {
|
|
37
|
+
data = await response.json().catch(() => null);
|
|
38
|
+
} else {
|
|
39
|
+
data = await response.text().catch(() => null);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
if (!response.ok) {
|
|
43
|
+
const errorMsg =
|
|
44
|
+
(data && typeof data === 'object' && (data.message || data.error)) ||
|
|
45
|
+
`Request to ${path} failed with HTTP status ${response.status}`;
|
|
46
|
+
return { ok: false, status: response.status, error: errorMsg, data };
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
return { ok: true, status: response.status, data };
|
|
50
|
+
} catch (error) {
|
|
51
|
+
if (timeoutId) clearTimeout(timeoutId);
|
|
52
|
+
const isAbort = error.name === 'AbortError';
|
|
53
|
+
const isBlocked = error.message?.includes('Failed to fetch') || error.name === 'TypeError';
|
|
54
|
+
|
|
55
|
+
const message = isAbort
|
|
56
|
+
? `Request to ${path} timed out after ${timeoutMs}ms`
|
|
57
|
+
: isBlocked
|
|
58
|
+
? `Network request to ${url} was blocked (likely by an ad blocker / privacy extension). Touch gracefully skipped.`
|
|
59
|
+
: `Network error connecting to ${url}: ${error.message}`;
|
|
60
|
+
|
|
61
|
+
logger.debug(message);
|
|
62
|
+
return { ok: false, status: 0, error: message, data: null, blocked: isBlocked };
|
|
63
|
+
}
|
|
64
|
+
}
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import { safeLocalStorage, getCookie, setCookie } from '../utils/storage.js';
|
|
2
|
+
import { getConfig } from '../config/index.js';
|
|
3
|
+
import { logger } from '../utils/logger.js';
|
|
4
|
+
|
|
5
|
+
export const VISITOR_ID_KEY = 'aid_vid';
|
|
6
|
+
let memoryVisitorId = null;
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Generate standard UUIDv4 with crypto fallback
|
|
10
|
+
*/
|
|
11
|
+
export function generateUUID() {
|
|
12
|
+
if (typeof crypto !== 'undefined') {
|
|
13
|
+
if (typeof crypto.randomUUID === 'function') {
|
|
14
|
+
return crypto.randomUUID();
|
|
15
|
+
}
|
|
16
|
+
if (typeof crypto.getRandomValues === 'function') {
|
|
17
|
+
const buffer = new Uint8Array(16);
|
|
18
|
+
crypto.getRandomValues(buffer);
|
|
19
|
+
buffer[6] = (buffer[6] & 0x0f) | 0x40;
|
|
20
|
+
buffer[8] = (buffer[8] & 0x3f) | 0x80;
|
|
21
|
+
const hex = Array.from(buffer, (b) => b.toString(16).padStart(2, '0')).join('');
|
|
22
|
+
return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`;
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const randomHex = () => Math.floor(Math.random() * 16).toString(16);
|
|
27
|
+
return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, (character) => {
|
|
28
|
+
const value = Number.parseInt(randomHex(), 16);
|
|
29
|
+
return (character === 'x' ? value : (value & 0x3) | 0x8).toString(16);
|
|
30
|
+
});
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Retrieve or create persistent visitor ID.
|
|
35
|
+
* Keeps cookie and localStorage synchronized to prevent identity loss across ITP/subdomains.
|
|
36
|
+
*/
|
|
37
|
+
export function getVisitorId() {
|
|
38
|
+
if (memoryVisitorId) {
|
|
39
|
+
return memoryVisitorId;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// 1. Try LocalStorage
|
|
43
|
+
let visitorId = safeLocalStorage.getItem(VISITOR_ID_KEY);
|
|
44
|
+
|
|
45
|
+
// 2. Try Cookie if not in LocalStorage
|
|
46
|
+
if (!visitorId) {
|
|
47
|
+
visitorId = getCookie(VISITOR_ID_KEY);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// 3. Generate new if missing
|
|
51
|
+
if (!visitorId) {
|
|
52
|
+
visitorId = generateUUID();
|
|
53
|
+
logger.debug('Generated new visitor ID:', visitorId);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// 4. Synchronize back to both storages
|
|
57
|
+
const config = getConfig();
|
|
58
|
+
safeLocalStorage.setItem(VISITOR_ID_KEY, visitorId);
|
|
59
|
+
setCookie(VISITOR_ID_KEY, visitorId, {
|
|
60
|
+
maxAge: 63072000, // 2 years
|
|
61
|
+
path: '/',
|
|
62
|
+
domain: config.cookieDomain,
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
memoryVisitorId = visitorId;
|
|
66
|
+
return visitorId;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Set custom or backend-canonicalized visitor ID
|
|
71
|
+
*/
|
|
72
|
+
export function setVisitorId(newId) {
|
|
73
|
+
if (!newId || typeof newId !== 'string') return;
|
|
74
|
+
memoryVisitorId = newId;
|
|
75
|
+
const config = getConfig();
|
|
76
|
+
safeLocalStorage.setItem(VISITOR_ID_KEY, newId);
|
|
77
|
+
setCookie(VISITOR_ID_KEY, newId, {
|
|
78
|
+
maxAge: 63072000,
|
|
79
|
+
path: '/',
|
|
80
|
+
domain: config.cookieDomain,
|
|
81
|
+
});
|
|
82
|
+
}
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import { getCookie } from '../utils/storage.js';
|
|
2
|
+
import { getConfig } from '../config/index.js';
|
|
3
|
+
|
|
4
|
+
function getParam(params, key) {
|
|
5
|
+
return params.get(key) || null;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
function getQueryParameter(params, name) {
|
|
9
|
+
return params.get(`utm_${name}`) ?? params.get(name) ?? null;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Extracts marketing parameters, click IDs, ad cookies, and custom parameters.
|
|
14
|
+
*/
|
|
15
|
+
export function extractMarketingParams(locationSearch = '') {
|
|
16
|
+
const search = locationSearch || (typeof window !== 'undefined' ? window.location.search : '');
|
|
17
|
+
const params = new URLSearchParams(search);
|
|
18
|
+
const config = getConfig();
|
|
19
|
+
|
|
20
|
+
// Core UTM parameters
|
|
21
|
+
const utm = {
|
|
22
|
+
source: getQueryParameter(params, 'source'),
|
|
23
|
+
medium: getQueryParameter(params, 'medium'),
|
|
24
|
+
campaign: getQueryParameter(params, 'campaign'),
|
|
25
|
+
term: getQueryParameter(params, 'term'),
|
|
26
|
+
content: getQueryParameter(params, 'content'),
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
// Advertising click IDs
|
|
30
|
+
const clickIds = {
|
|
31
|
+
gclid: getParam(params, 'gclid'),
|
|
32
|
+
dclid: getParam(params, 'dclid'),
|
|
33
|
+
wbraid: getParam(params, 'wbraid'),
|
|
34
|
+
gbraid: getParam(params, 'gbraid'),
|
|
35
|
+
srsltid: getParam(params, 'srsltid'),
|
|
36
|
+
fbclid: getParam(params, 'fbclid'),
|
|
37
|
+
ttclid: getParam(params, 'ttclid'),
|
|
38
|
+
msclkid: getParam(params, 'msclkid'),
|
|
39
|
+
twclid: getParam(params, 'twclid'),
|
|
40
|
+
li_fat_id: getParam(params, 'li_fat_id'),
|
|
41
|
+
rdt_cid: getParam(params, 'rdt_cid'),
|
|
42
|
+
epik: getParam(params, 'epik'),
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
// Ad cookies
|
|
46
|
+
const cookies = {
|
|
47
|
+
fbc: getCookie('_fbc'),
|
|
48
|
+
fbp: getCookie('_fbp'),
|
|
49
|
+
ttp: getCookie('_ttp'),
|
|
50
|
+
ga: getCookie('_ga'),
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
// Custom parameters defined by developer in elysium.config.js
|
|
54
|
+
const custom = {};
|
|
55
|
+
if (Array.isArray(config.customParams)) {
|
|
56
|
+
for (const key of config.customParams) {
|
|
57
|
+
const val = getParam(params, key);
|
|
58
|
+
if (val !== null) custom[key] = val;
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
return {
|
|
63
|
+
utm,
|
|
64
|
+
clickIds,
|
|
65
|
+
cookies,
|
|
66
|
+
custom,
|
|
67
|
+
};
|
|
68
|
+
}
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Referrer analysis module: distinguishes internal referrals from external,
|
|
3
|
+
* and classifies organic search and social traffic.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
const SEARCH_ENGINES = [
|
|
7
|
+
{ name: 'google', pattern: /(?:^|\.)google\./i },
|
|
8
|
+
{ name: 'bing', pattern: /(?:^|\.)bing\.com/i },
|
|
9
|
+
{ name: 'yahoo', pattern: /(?:^|\.)yahoo\.com/i },
|
|
10
|
+
{ name: 'duckduckgo', pattern: /(?:^|\.)duckduckgo\.com/i },
|
|
11
|
+
{ name: 'baidu', pattern: /(?:^|\.)baidu\.com/i },
|
|
12
|
+
{ name: 'yandex', pattern: /(?:^|\.)yandex\./i },
|
|
13
|
+
{ name: 'ecosia', pattern: /(?:^|\.)ecosia\.org/i },
|
|
14
|
+
];
|
|
15
|
+
|
|
16
|
+
const SOCIAL_NETWORKS = [
|
|
17
|
+
{ name: 'facebook', pattern: /(?:^|\.)(?:facebook\.com|fb\.com|l\.facebook\.com)/i },
|
|
18
|
+
{ name: 'instagram', pattern: /(?:^|\.)instagram\.com/i },
|
|
19
|
+
{ name: 'linkedin', pattern: /(?:^|\.)linkedin\.com/i },
|
|
20
|
+
{ name: 'twitter', pattern: /(?:^|\.)(?:twitter\.com|t\.co|x\.com)/i },
|
|
21
|
+
{ name: 'pinterest', pattern: /(?:^|\.)pinterest\./i },
|
|
22
|
+
{ name: 'reddit', pattern: /(?:^|\.)reddit\.com/i },
|
|
23
|
+
{ name: 'youtube', pattern: /(?:^|\.)youtube\.com/i },
|
|
24
|
+
{ name: 'tiktok', pattern: /(?:^|\.)tiktok\.com/i },
|
|
25
|
+
];
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Check if the referrer hostname belongs to the same domain
|
|
29
|
+
*/
|
|
30
|
+
export function isInternalReferrer(referrerUrl, currentHostname = '') {
|
|
31
|
+
if (!referrerUrl) return false;
|
|
32
|
+
try {
|
|
33
|
+
const refHost = new URL(referrerUrl).hostname.toLowerCase();
|
|
34
|
+
const currHost = (
|
|
35
|
+
currentHostname || (typeof window !== 'undefined' ? window.location.hostname : '')
|
|
36
|
+
).toLowerCase();
|
|
37
|
+
|
|
38
|
+
if (!currHost || !refHost) return false;
|
|
39
|
+
if (refHost === currHost) return true;
|
|
40
|
+
|
|
41
|
+
// Check parent domain (e.g. shop.mystore.com and mystore.com)
|
|
42
|
+
const refParts = refHost.split('.');
|
|
43
|
+
const currParts = currHost.split('.');
|
|
44
|
+
if (refParts.length >= 2 && currParts.length >= 2) {
|
|
45
|
+
const refRoot = refParts.slice(-2).join('.');
|
|
46
|
+
const currRoot = currParts.slice(-2).join('.');
|
|
47
|
+
if (refRoot === currRoot) return true;
|
|
48
|
+
}
|
|
49
|
+
} catch {
|
|
50
|
+
// Malformed referrer
|
|
51
|
+
}
|
|
52
|
+
return false;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Parse referrer to identify organic search and social channels
|
|
57
|
+
*/
|
|
58
|
+
export function parseReferrer(rawReferrer = '', currentHostname = '') {
|
|
59
|
+
const referrer = rawReferrer || (typeof document !== 'undefined' ? document.referrer : '');
|
|
60
|
+
if (!referrer) {
|
|
61
|
+
return { referrer: null, isInternal: false, channel: 'direct', detectedSource: null, detectedMedium: null };
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
if (isInternalReferrer(referrer, currentHostname)) {
|
|
65
|
+
return { referrer: null, isInternal: true, channel: 'internal', detectedSource: null, detectedMedium: null };
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
try {
|
|
69
|
+
const refUrl = new URL(referrer);
|
|
70
|
+
const host = refUrl.hostname.toLowerCase();
|
|
71
|
+
|
|
72
|
+
for (const se of SEARCH_ENGINES) {
|
|
73
|
+
if (se.pattern.test(host)) {
|
|
74
|
+
return {
|
|
75
|
+
referrer,
|
|
76
|
+
isInternal: false,
|
|
77
|
+
channel: 'organic_search',
|
|
78
|
+
detectedSource: se.name,
|
|
79
|
+
detectedMedium: 'organic',
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
for (const sn of SOCIAL_NETWORKS) {
|
|
85
|
+
if (sn.pattern.test(host)) {
|
|
86
|
+
return {
|
|
87
|
+
referrer,
|
|
88
|
+
isInternal: false,
|
|
89
|
+
channel: 'social',
|
|
90
|
+
detectedSource: sn.name,
|
|
91
|
+
detectedMedium: 'social',
|
|
92
|
+
};
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
return {
|
|
97
|
+
referrer,
|
|
98
|
+
isInternal: false,
|
|
99
|
+
channel: 'referral',
|
|
100
|
+
detectedSource: host,
|
|
101
|
+
detectedMedium: 'referral',
|
|
102
|
+
};
|
|
103
|
+
} catch {
|
|
104
|
+
return { referrer, isInternal: false, channel: 'unknown', detectedSource: null, detectedMedium: null };
|
|
105
|
+
}
|
|
106
|
+
}
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
import { safeLocalStorage } from '../utils/storage.js';
|
|
2
|
+
import { getConfig } from '../config/index.js';
|
|
3
|
+
import { logger } from '../utils/logger.js';
|
|
4
|
+
|
|
5
|
+
export const TOUCH_HISTORY_KEY = 'aid_touches';
|
|
6
|
+
const MAX_HISTORY_LENGTH = 50;
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Validate shape of a stored touch record
|
|
10
|
+
*/
|
|
11
|
+
export function isStoredTouch(value) {
|
|
12
|
+
if (typeof value !== 'object' || value === null) return false;
|
|
13
|
+
return (
|
|
14
|
+
typeof value.recordedAt === 'number' &&
|
|
15
|
+
typeof value.payload === 'object' &&
|
|
16
|
+
value.payload !== null &&
|
|
17
|
+
typeof value.response === 'object' &&
|
|
18
|
+
value.response !== null
|
|
19
|
+
);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Read and validate touch history from storage
|
|
24
|
+
*/
|
|
25
|
+
export function readTouchHistory() {
|
|
26
|
+
try {
|
|
27
|
+
const raw = safeLocalStorage.getItem(TOUCH_HISTORY_KEY);
|
|
28
|
+
if (!raw) return [];
|
|
29
|
+
|
|
30
|
+
const parsed = JSON.parse(raw);
|
|
31
|
+
if (!Array.isArray(parsed)) return [];
|
|
32
|
+
|
|
33
|
+
return parsed.filter(isStoredTouch);
|
|
34
|
+
} catch (error) {
|
|
35
|
+
logger.debug('Could not read touch history:', error);
|
|
36
|
+
return [];
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Save accepted touch with FIFO truncation to prevent storage exhaustion
|
|
42
|
+
*/
|
|
43
|
+
export function saveSuccessfulTouch(touch) {
|
|
44
|
+
try {
|
|
45
|
+
const history = readTouchHistory();
|
|
46
|
+
history.push(touch);
|
|
47
|
+
|
|
48
|
+
// Keep history bounded to avoid QuotaExceededError
|
|
49
|
+
while (history.length > MAX_HISTORY_LENGTH) {
|
|
50
|
+
history.shift();
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
safeLocalStorage.setItem(TOUCH_HISTORY_KEY, JSON.stringify(history));
|
|
54
|
+
} catch (error) {
|
|
55
|
+
logger.error('Failed to save touch to storage:', error);
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Check if the payload contains an active marketing signal (paid or explicit campaign)
|
|
61
|
+
*/
|
|
62
|
+
export function hasMarketingSignal(payload) {
|
|
63
|
+
if (!payload) return false;
|
|
64
|
+
return Boolean(
|
|
65
|
+
payload.source ||
|
|
66
|
+
payload.medium ||
|
|
67
|
+
payload.campaign ||
|
|
68
|
+
payload.gclid ||
|
|
69
|
+
payload.fbclid ||
|
|
70
|
+
payload.ttclid ||
|
|
71
|
+
payload.msclkid ||
|
|
72
|
+
payload.dclid ||
|
|
73
|
+
payload.wbraid ||
|
|
74
|
+
payload.gbraid ||
|
|
75
|
+
payload.twclid ||
|
|
76
|
+
payload.li_fat_id ||
|
|
77
|
+
payload.rdt_cid
|
|
78
|
+
);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Generate a signature for deduplication
|
|
83
|
+
*/
|
|
84
|
+
export function getTouchSignature(payload) {
|
|
85
|
+
return [
|
|
86
|
+
payload.visitorId || '',
|
|
87
|
+
payload.source || '',
|
|
88
|
+
payload.medium || '',
|
|
89
|
+
payload.campaign || '',
|
|
90
|
+
payload.gclid || '',
|
|
91
|
+
payload.fbclid || '',
|
|
92
|
+
payload.ttclid || '',
|
|
93
|
+
payload.msclkid || '',
|
|
94
|
+
payload.dclid || '',
|
|
95
|
+
payload.wbraid || '',
|
|
96
|
+
payload.gbraid || '',
|
|
97
|
+
payload.landingPage || '',
|
|
98
|
+
].join('|');
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Determine whether a new touch should be transmitted to the backend.
|
|
103
|
+
* Prevents duplicates while ensuring multi-touch and session restarts are respected.
|
|
104
|
+
*/
|
|
105
|
+
export function shouldSendTouch(payload, history = null) {
|
|
106
|
+
const touchHistory = history || readTouchHistory();
|
|
107
|
+
const config = getConfig();
|
|
108
|
+
const sessionWindow = config.sessionWindowMs || 30 * 60 * 1000;
|
|
109
|
+
|
|
110
|
+
const previous = touchHistory[touchHistory.length - 1];
|
|
111
|
+
|
|
112
|
+
// If no previous touch recorded, always send
|
|
113
|
+
if (!previous) return true;
|
|
114
|
+
|
|
115
|
+
// If previous touch is older than the session window, this is a new session
|
|
116
|
+
if (Date.now() - previous.recordedAt > sessionWindow) return true;
|
|
117
|
+
|
|
118
|
+
const previousWasAttributed = hasMarketingSignal(previous.payload);
|
|
119
|
+
const currentIsAttributed = hasMarketingSignal(payload);
|
|
120
|
+
|
|
121
|
+
// Guard against overwriting an active paid ad attribution with an internal/direct navigation
|
|
122
|
+
if (previousWasAttributed && !currentIsAttributed) {
|
|
123
|
+
logger.debug('Skipping touch: Recent paid attribution protected from direct browse navigation.');
|
|
124
|
+
return false;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// Exact duplicate signature within the same session window
|
|
128
|
+
if (getTouchSignature(previous.payload) === getTouchSignature(payload)) {
|
|
129
|
+
logger.debug('Skipping touch: Duplicate touch signature within active session window.');
|
|
130
|
+
return false;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
return true;
|
|
134
|
+
}
|