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.
@@ -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
+ }