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,89 @@
|
|
|
1
|
+
import { PaymentGateway } from './PaymentGateway.js';
|
|
2
|
+
import { logger } from '../utils/logger.js';
|
|
3
|
+
|
|
4
|
+
export class StripeGateway extends PaymentGateway {
|
|
5
|
+
constructor(config = {}) {
|
|
6
|
+
super('stripe', config);
|
|
7
|
+
this.checkRedirectReturn();
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
canHandle(buttonOrContext) {
|
|
11
|
+
if (super.canHandle(buttonOrContext)) return true;
|
|
12
|
+
|
|
13
|
+
if (typeof HTMLElement !== 'undefined' && buttonOrContext instanceof HTMLElement) {
|
|
14
|
+
const text = buttonOrContext.innerText?.toLowerCase() || '';
|
|
15
|
+
const id = buttonOrContext.id?.toLowerCase() || '';
|
|
16
|
+
const cls = buttonOrContext.className?.toLowerCase() || '';
|
|
17
|
+
if (text.includes('stripe') || id.includes('stripe') || cls.includes('stripe')) {
|
|
18
|
+
return true;
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
return false;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Automatically detect and attribute Stripe 3DS/redirect returns
|
|
26
|
+
*/
|
|
27
|
+
checkRedirectReturn() {
|
|
28
|
+
if (typeof window === 'undefined') return;
|
|
29
|
+
|
|
30
|
+
try {
|
|
31
|
+
const params = new URLSearchParams(window.location.search);
|
|
32
|
+
const paymentIntentId = params.get('payment_intent');
|
|
33
|
+
const redirectStatus = params.get('redirect_status');
|
|
34
|
+
|
|
35
|
+
if (paymentIntentId && redirectStatus) {
|
|
36
|
+
logger.info(`Detected Stripe return redirect: status=${redirectStatus}, id=${paymentIntentId}`);
|
|
37
|
+
|
|
38
|
+
if (redirectStatus === 'succeeded') {
|
|
39
|
+
this.recordPaymentSuccess({
|
|
40
|
+
transactionId: paymentIntentId,
|
|
41
|
+
status: redirectStatus,
|
|
42
|
+
source: 'stripe_redirect',
|
|
43
|
+
});
|
|
44
|
+
} else if (redirectStatus === 'failed') {
|
|
45
|
+
this.recordPaymentFailure({
|
|
46
|
+
code: 'stripe_redirect_failed',
|
|
47
|
+
message: `Stripe redirect finished with status: ${redirectStatus}`,
|
|
48
|
+
transactionId: paymentIntentId,
|
|
49
|
+
});
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
} catch (err) {
|
|
53
|
+
logger.debug('Error checking Stripe redirect return:', err);
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Instrument a Stripe instance to automatically intercept confirmPayment / confirmCardPayment
|
|
59
|
+
*/
|
|
60
|
+
wrapStripe(stripeInstance) {
|
|
61
|
+
if (!stripeInstance) return stripeInstance;
|
|
62
|
+
|
|
63
|
+
const originalConfirmPayment = stripeInstance.confirmPayment?.bind(stripeInstance);
|
|
64
|
+
if (originalConfirmPayment) {
|
|
65
|
+
stripeInstance.confirmPayment = async (options) => {
|
|
66
|
+
this.recordPaymentAttempt({ method: 'confirmPayment', options });
|
|
67
|
+
try {
|
|
68
|
+
const result = await originalConfirmPayment(options);
|
|
69
|
+
if (result.error) {
|
|
70
|
+
this.recordPaymentFailure(result.error);
|
|
71
|
+
} else if (result.paymentIntent?.status === 'succeeded') {
|
|
72
|
+
this.recordPaymentSuccess({
|
|
73
|
+
transactionId: result.paymentIntent.id,
|
|
74
|
+
amount: result.paymentIntent.amount / 100,
|
|
75
|
+
currency: result.paymentIntent.currency,
|
|
76
|
+
paymentIntent: result.paymentIntent,
|
|
77
|
+
});
|
|
78
|
+
}
|
|
79
|
+
return result;
|
|
80
|
+
} catch (err) {
|
|
81
|
+
this.recordPaymentFailure(err);
|
|
82
|
+
throw err;
|
|
83
|
+
}
|
|
84
|
+
};
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
return stripeInstance;
|
|
88
|
+
}
|
|
89
|
+
}
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
export { PaymentGateway } from './PaymentGateway.js';
|
|
2
|
+
export { StripeGateway } from './StripeGateway.js';
|
|
3
|
+
export { RazorpayGateway } from './RazorpayGateway.js';
|
|
4
|
+
export { PayPalGateway } from './PayPalGateway.js';
|
|
5
|
+
export { GenericGateway } from './GenericGateway.js';
|
|
6
|
+
export { GatewayRegistry, gatewayRegistry } from './GatewayRegistry.js';
|
package/src/index.js
CHANGED
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
import { configManager, getConfig, setConfig } from './config/index.js';
|
|
2
|
+
import { getVisitorId, setVisitorId } from './signals/visitor.js';
|
|
3
|
+
import {
|
|
4
|
+
recordTouch,
|
|
5
|
+
recordProductTouch,
|
|
6
|
+
identifyVisitor,
|
|
7
|
+
createCheckoutSession,
|
|
8
|
+
buildPayload,
|
|
9
|
+
} from './signals/touchEngine.js';
|
|
10
|
+
import { readTouchHistory, getTouchSignature } from './signals/session.js';
|
|
11
|
+
import { extractMarketingParams } from './signals/params.js';
|
|
12
|
+
import { parseReferrer } from './signals/referrer.js';
|
|
13
|
+
import {
|
|
14
|
+
gatewayRegistry,
|
|
15
|
+
PaymentGateway,
|
|
16
|
+
StripeGateway,
|
|
17
|
+
RazorpayGateway,
|
|
18
|
+
PayPalGateway,
|
|
19
|
+
GenericGateway,
|
|
20
|
+
} from './gateways/index.js';
|
|
21
|
+
import { initPaymentButtonDetection } from './dom/buttonDetector.js';
|
|
22
|
+
import { logger } from './utils/logger.js';
|
|
23
|
+
|
|
24
|
+
let buttonDetectorCleanup = null;
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Main SDK initialization method.
|
|
28
|
+
* Configures API keys, starts tracking, registers gateways, and activates button detector.
|
|
29
|
+
*/
|
|
30
|
+
export function init(userConfig = {}) {
|
|
31
|
+
const config = configManager.init(userConfig);
|
|
32
|
+
|
|
33
|
+
// Configure gateways with user settings
|
|
34
|
+
if (config.gateways) {
|
|
35
|
+
gatewayRegistry.configureAll(config.gateways);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// Initialize payment button listener
|
|
39
|
+
if (buttonDetectorCleanup) {
|
|
40
|
+
buttonDetectorCleanup();
|
|
41
|
+
}
|
|
42
|
+
buttonDetectorCleanup = initPaymentButtonDetection(config.buttonSelector);
|
|
43
|
+
|
|
44
|
+
// Auto-record touchpoint if autoTrack enabled (default true)
|
|
45
|
+
if (config.autoTrack && typeof window !== 'undefined') {
|
|
46
|
+
// Run after current event loop turn to allow page title & DOM to settle
|
|
47
|
+
setTimeout(() => {
|
|
48
|
+
recordTouch().catch((err) => {
|
|
49
|
+
logger.error('Auto touch tracking encountered an error:', err);
|
|
50
|
+
});
|
|
51
|
+
}, 0);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
logger.info('ElysiumSDK initialized successfully');
|
|
55
|
+
return Elysium;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Update active configuration at runtime
|
|
60
|
+
*/
|
|
61
|
+
export function configure(updates = {}) {
|
|
62
|
+
const config = setConfig(updates);
|
|
63
|
+
if (updates.gateways) {
|
|
64
|
+
gatewayRegistry.configureAll(updates.gateways);
|
|
65
|
+
}
|
|
66
|
+
if (updates.buttonSelector && buttonDetectorCleanup) {
|
|
67
|
+
buttonDetectorCleanup();
|
|
68
|
+
buttonDetectorCleanup = initPaymentButtonDetection(config.buttonSelector);
|
|
69
|
+
}
|
|
70
|
+
return config;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Explicit helper to record payment success
|
|
75
|
+
*/
|
|
76
|
+
export async function recordPaymentSuccess(details = {}) {
|
|
77
|
+
const gatewayName = details.gateway || 'generic';
|
|
78
|
+
const gateway = gatewayRegistry.get(gatewayName) || gatewayRegistry.generic;
|
|
79
|
+
return gateway.recordPaymentSuccess(details);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Explicit helper to record payment failure
|
|
84
|
+
*/
|
|
85
|
+
export async function recordPaymentFailure(details = {}) {
|
|
86
|
+
const gatewayName = details.gateway || 'generic';
|
|
87
|
+
const gateway = gatewayRegistry.get(gatewayName) || gatewayRegistry.generic;
|
|
88
|
+
return gateway.recordPaymentFailure(details);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Explicit helper to record payment attempt
|
|
93
|
+
*/
|
|
94
|
+
export async function recordPaymentAttempt(details = {}) {
|
|
95
|
+
const gatewayName = details.gateway || 'generic';
|
|
96
|
+
const gateway = gatewayRegistry.get(gatewayName) || gatewayRegistry.generic;
|
|
97
|
+
return gateway.recordPaymentAttempt(details);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Register a custom payment gateway
|
|
102
|
+
*/
|
|
103
|
+
export function registerGateway(name, gatewayInstance) {
|
|
104
|
+
return gatewayRegistry.register(name, gatewayInstance);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Retrieve a registered payment gateway by name
|
|
109
|
+
*/
|
|
110
|
+
export function getGateway(name) {
|
|
111
|
+
return gatewayRegistry.get(name);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// Unified SDK namespace object
|
|
115
|
+
export const Elysium = {
|
|
116
|
+
init,
|
|
117
|
+
configure,
|
|
118
|
+
getConfig,
|
|
119
|
+
getVisitorId,
|
|
120
|
+
setVisitorId,
|
|
121
|
+
recordTouch,
|
|
122
|
+
recordProductTouch,
|
|
123
|
+
identifyVisitor,
|
|
124
|
+
createCheckoutSession,
|
|
125
|
+
buildPayload,
|
|
126
|
+
readTouchHistory,
|
|
127
|
+
getTouchSignature,
|
|
128
|
+
extractMarketingParams,
|
|
129
|
+
parseReferrer,
|
|
130
|
+
initPaymentButtonDetection,
|
|
131
|
+
registerGateway,
|
|
132
|
+
getGateway,
|
|
133
|
+
recordPaymentAttempt,
|
|
134
|
+
recordPaymentSuccess,
|
|
135
|
+
recordPaymentFailure,
|
|
136
|
+
gateways: {
|
|
137
|
+
PaymentGateway,
|
|
138
|
+
StripeGateway,
|
|
139
|
+
RazorpayGateway,
|
|
140
|
+
PayPalGateway,
|
|
141
|
+
GenericGateway,
|
|
142
|
+
registry: gatewayRegistry,
|
|
143
|
+
},
|
|
144
|
+
};
|
|
145
|
+
|
|
146
|
+
// Expose on global window object if running in browser
|
|
147
|
+
if (typeof window !== 'undefined') {
|
|
148
|
+
window.Elysium = Elysium;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
export default Elysium;
|
|
@@ -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
|
+
}
|