getu-attribution-v2-sdk 0.1.0
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 +775 -0
- package/dist/core/AttributionSDK.d.ts +63 -0
- package/dist/core/AttributionSDK.d.ts.map +1 -0
- package/dist/core/AttributionSDK.js +709 -0
- package/dist/getuai-attribution.min.js +1 -0
- package/dist/index.d.ts +85 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.esm.js +1859 -0
- package/dist/index.esm.js.map +1 -0
- package/dist/index.js +2017 -0
- package/dist/queue/index.d.ts +51 -0
- package/dist/queue/index.d.ts.map +1 -0
- package/dist/queue/index.js +215 -0
- package/dist/storage/index.d.ts +46 -0
- package/dist/storage/index.d.ts.map +1 -0
- package/dist/storage/index.js +363 -0
- package/dist/types/index.d.ts +101 -0
- package/dist/types/index.d.ts.map +1 -0
- package/dist/types/index.js +32 -0
- package/dist/utils/index.d.ts +36 -0
- package/dist/utils/index.d.ts.map +1 -0
- package/dist/utils/index.js +297 -0
- package/package.json +63 -0
|
@@ -0,0 +1,709 @@
|
|
|
1
|
+
import { EventType, Currency, defaultEndpoint, } from "../types";
|
|
2
|
+
import { AttributionStorageManager } from "../storage";
|
|
3
|
+
import { EventQueueManager, EventHttpClient } from "../queue";
|
|
4
|
+
import { generateId, getTimestamp, extractUTMParams, getCurrentUrl, getReferrer, getUserAgent, getPageTitle, generateSessionId, isOnline, ConsoleLogger, addUTMToURL, shouldExcludeDomain, isExternalURL, filterUTMParams, cleanURL, getQueryString, getQueryParams, } from "../utils";
|
|
5
|
+
export class AttributionSDK {
|
|
6
|
+
constructor(config) {
|
|
7
|
+
this.session = null;
|
|
8
|
+
this.initialized = false;
|
|
9
|
+
this.autoTrackEnabled = false;
|
|
10
|
+
this.pageViewTrackTimes = new Map();
|
|
11
|
+
// SPA tracking internals
|
|
12
|
+
this.spaTrackingEnabled = false;
|
|
13
|
+
this.lastTrackedPath = "";
|
|
14
|
+
this.originalPushState = null;
|
|
15
|
+
this.originalReplaceState = null;
|
|
16
|
+
this.popstateHandler = null;
|
|
17
|
+
this.config = {
|
|
18
|
+
apiEndpoint: defaultEndpoint,
|
|
19
|
+
batchSize: 100,
|
|
20
|
+
batchInterval: 5000,
|
|
21
|
+
maxRetries: 3,
|
|
22
|
+
retryDelay: 1000,
|
|
23
|
+
enableDebug: false,
|
|
24
|
+
autoTrack: false,
|
|
25
|
+
autoTrackPageView: false,
|
|
26
|
+
sessionTimeout: 30 * 60 * 1000, // 30 minutes
|
|
27
|
+
enableCrossDomainUTM: true,
|
|
28
|
+
crossDomainUTMParams: [
|
|
29
|
+
"utm_source",
|
|
30
|
+
"utm_medium",
|
|
31
|
+
"utm_campaign",
|
|
32
|
+
"utm_term",
|
|
33
|
+
"utm_content",
|
|
34
|
+
],
|
|
35
|
+
excludeDomains: [],
|
|
36
|
+
autoCleanUTM: true,
|
|
37
|
+
pageViewDebounceInterval: 5000, // 5 seconds default
|
|
38
|
+
...config,
|
|
39
|
+
};
|
|
40
|
+
this.logger = new ConsoleLogger(this.config.enableDebug);
|
|
41
|
+
this.storage = new AttributionStorageManager(this.logger);
|
|
42
|
+
this.httpClient = new EventHttpClient(this.logger, this.config.apiKey, this.config.apiEndpoint || defaultEndpoint, this.config.maxRetries, this.config.retryDelay);
|
|
43
|
+
this.queue = new EventQueueManager(this.logger, this.config.apiKey, this.config.apiEndpoint || defaultEndpoint, this.config.batchSize, this.config.batchInterval, this.config.maxRetries, this.config.retryDelay, (events) => this.httpClient.sendEvents(events));
|
|
44
|
+
}
|
|
45
|
+
async init() {
|
|
46
|
+
if (this.initialized) {
|
|
47
|
+
this.logger.warn("SDK already initialized");
|
|
48
|
+
return;
|
|
49
|
+
}
|
|
50
|
+
try {
|
|
51
|
+
this.logger.info("Initializing GetuAI Attribution SDK");
|
|
52
|
+
// Initialize storage
|
|
53
|
+
await this.storage.init();
|
|
54
|
+
// Initialize session
|
|
55
|
+
this.initializeSession();
|
|
56
|
+
// Extract and store UTM data from current URL
|
|
57
|
+
this.extractAndStoreUTMData();
|
|
58
|
+
// Setup auto-tracking if enabled
|
|
59
|
+
if (this.config.autoTrack) {
|
|
60
|
+
this.setupAutoTracking();
|
|
61
|
+
}
|
|
62
|
+
// Setup online/offline handlers
|
|
63
|
+
this.setupNetworkHandlers();
|
|
64
|
+
// Setup page visibility handlers
|
|
65
|
+
this.setupVisibilityHandlers();
|
|
66
|
+
// Setup beforeunload handler
|
|
67
|
+
this.setupBeforeUnloadHandler();
|
|
68
|
+
this.initialized = true;
|
|
69
|
+
this.logger.info("🚀 GetuAI Attribution SDK initialized successfully");
|
|
70
|
+
this.logger.info("📄 Auto track page view = " + this.config.autoTrackPageView);
|
|
71
|
+
// Auto track page view if enabled (independent from autoTrack)
|
|
72
|
+
// Also enables SPA route tracking automatically
|
|
73
|
+
if (this.config.autoTrackPageView) {
|
|
74
|
+
this.logger.info("📄 Auto track page view enabled (including SPA route tracking)");
|
|
75
|
+
// Record the initial path for SPA tracking
|
|
76
|
+
this.lastTrackedPath = this.getCurrentPath();
|
|
77
|
+
// Setup SPA tracking for route changes
|
|
78
|
+
this.setupSPATracking();
|
|
79
|
+
setTimeout(() => {
|
|
80
|
+
// Fire asynchronously; do not block initialization
|
|
81
|
+
this.trackPageView()
|
|
82
|
+
.then(() => {
|
|
83
|
+
this.logger.info("✅ Auto track page view completed");
|
|
84
|
+
})
|
|
85
|
+
.catch((error) => this.logger.error("❌ Auto track page view failed:", error));
|
|
86
|
+
}, 100);
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
catch (error) {
|
|
90
|
+
this.logger.error("Failed to initialize SDK:", error);
|
|
91
|
+
throw error;
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
// Track custom event
|
|
95
|
+
async trackEvent(eventType, eventData, tracking_user_id, revenue, currency = Currency.USD) {
|
|
96
|
+
if (!this.initialized) {
|
|
97
|
+
this.logger.warn("SDK not initialized, event not tracked");
|
|
98
|
+
return;
|
|
99
|
+
}
|
|
100
|
+
try {
|
|
101
|
+
const currentUrl = getCurrentUrl();
|
|
102
|
+
// Try to fetch public IP (best-effort)
|
|
103
|
+
let ip_address = null;
|
|
104
|
+
try {
|
|
105
|
+
ip_address = await this.fetchPublicIP();
|
|
106
|
+
}
|
|
107
|
+
catch (e) {
|
|
108
|
+
// ignore
|
|
109
|
+
}
|
|
110
|
+
const pageContext = {
|
|
111
|
+
domain: typeof window !== "undefined" ? window.location.hostname : null,
|
|
112
|
+
path: typeof window !== "undefined" ? window.location.pathname : null,
|
|
113
|
+
title: getPageTitle(),
|
|
114
|
+
referrer: getReferrer(),
|
|
115
|
+
url: currentUrl.split("?")[0],
|
|
116
|
+
querystring: getQueryString(currentUrl),
|
|
117
|
+
query_params: getQueryParams(currentUrl),
|
|
118
|
+
ip_address: ip_address,
|
|
119
|
+
};
|
|
120
|
+
const sessionContext = {
|
|
121
|
+
session_id: this.session?.sessionId,
|
|
122
|
+
start_time: this.session?.startTime,
|
|
123
|
+
last_activity: this.session?.lastActivity,
|
|
124
|
+
page_views: this.session?.pageViews,
|
|
125
|
+
};
|
|
126
|
+
const event = {
|
|
127
|
+
event_id: generateId(),
|
|
128
|
+
event_type: eventType,
|
|
129
|
+
tracking_user_id: tracking_user_id,
|
|
130
|
+
timestamp: getTimestamp(),
|
|
131
|
+
event_data: eventData,
|
|
132
|
+
context: { page: pageContext, session: sessionContext },
|
|
133
|
+
revenue: revenue,
|
|
134
|
+
currency: currency,
|
|
135
|
+
...this.getUTMParams(),
|
|
136
|
+
};
|
|
137
|
+
this.logger.debug(`Tracking event: ${eventType}`, event);
|
|
138
|
+
// Add to queue
|
|
139
|
+
this.queue.add(event);
|
|
140
|
+
}
|
|
141
|
+
catch (error) {
|
|
142
|
+
this.logger.error(`Failed to track event ${eventType}:`, error);
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
// Track page view
|
|
146
|
+
async trackPageView(pageData, tracking_user_id) {
|
|
147
|
+
const currentUrl = getCurrentUrl();
|
|
148
|
+
// Use URL without query string as key for debouncing
|
|
149
|
+
const pageKey = currentUrl.split("?")[0];
|
|
150
|
+
const now = Date.now();
|
|
151
|
+
const debounceInterval = this.config.pageViewDebounceInterval || 5000;
|
|
152
|
+
// Check if this page was tracked recently
|
|
153
|
+
const lastTrackTime = this.pageViewTrackTimes.get(pageKey);
|
|
154
|
+
if (lastTrackTime && now - lastTrackTime < debounceInterval) {
|
|
155
|
+
this.logger.debug(`Page view debounced: ${pageKey} (last tracked ${now - lastTrackTime}ms ago)`);
|
|
156
|
+
return;
|
|
157
|
+
}
|
|
158
|
+
const pageEventData = {
|
|
159
|
+
url: currentUrl,
|
|
160
|
+
title: getPageTitle(),
|
|
161
|
+
referrer: getReferrer(),
|
|
162
|
+
user_agent: getUserAgent(),
|
|
163
|
+
...pageData,
|
|
164
|
+
};
|
|
165
|
+
await this.trackEvent(EventType.PAGE_VIEW, pageEventData, tracking_user_id);
|
|
166
|
+
// Update last track time for this page
|
|
167
|
+
this.pageViewTrackTimes.set(pageKey, now);
|
|
168
|
+
// Clean up old entries to prevent memory leak (keep only entries from last hour)
|
|
169
|
+
this.cleanupPageViewTrackTimes();
|
|
170
|
+
}
|
|
171
|
+
// Clean up old page view track times to prevent memory leak
|
|
172
|
+
cleanupPageViewTrackTimes() {
|
|
173
|
+
const oneHourAgo = Date.now() - 60 * 60 * 1000;
|
|
174
|
+
for (const [pageKey, trackTime] of this.pageViewTrackTimes.entries()) {
|
|
175
|
+
if (trackTime < oneHourAgo) {
|
|
176
|
+
this.pageViewTrackTimes.delete(pageKey);
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
// Best-effort fetch of public IP address
|
|
181
|
+
async fetchPublicIP() {
|
|
182
|
+
try {
|
|
183
|
+
const controller = new AbortController();
|
|
184
|
+
const timeout = setTimeout(() => controller.abort(), 2000);
|
|
185
|
+
const response = await fetch("https://api.ipify.org?format=json", {
|
|
186
|
+
signal: controller.signal,
|
|
187
|
+
headers: { Accept: "application/json" },
|
|
188
|
+
});
|
|
189
|
+
clearTimeout(timeout);
|
|
190
|
+
if (!response.ok) {
|
|
191
|
+
return null;
|
|
192
|
+
}
|
|
193
|
+
const data = await response.json();
|
|
194
|
+
const ip = typeof data?.ip === "string" ? data.ip : null;
|
|
195
|
+
return ip;
|
|
196
|
+
}
|
|
197
|
+
catch (error) {
|
|
198
|
+
this.logger.debug("Public IP fetch failed", error);
|
|
199
|
+
return null;
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
// Track purchase
|
|
203
|
+
async trackPurchase(tracking_user_id, revenue, currency = Currency.USD, purchaseData) {
|
|
204
|
+
await this.trackEvent(EventType.PURCHASE, purchaseData, tracking_user_id, revenue, currency);
|
|
205
|
+
}
|
|
206
|
+
// Track login
|
|
207
|
+
async trackLogin(tracking_user_id, loginData) {
|
|
208
|
+
await this.trackEvent(EventType.LOGIN, loginData, tracking_user_id);
|
|
209
|
+
}
|
|
210
|
+
// Track signup
|
|
211
|
+
async trackSignup(tracking_user_id, signupData) {
|
|
212
|
+
await this.trackEvent(EventType.SIGNUP, signupData, tracking_user_id);
|
|
213
|
+
}
|
|
214
|
+
// Track form submission
|
|
215
|
+
async trackFormSubmit(tracking_user_id, formData) {
|
|
216
|
+
await this.trackEvent(EventType.FORM_SUBMIT, formData, tracking_user_id);
|
|
217
|
+
}
|
|
218
|
+
// Get attribution data
|
|
219
|
+
getAttributionData() {
|
|
220
|
+
return this.storage.getUTMData();
|
|
221
|
+
}
|
|
222
|
+
// Manually add UTM parameters to a URL
|
|
223
|
+
addUTMToURL(url) {
|
|
224
|
+
if (!this.config.enableCrossDomainUTM) {
|
|
225
|
+
return url;
|
|
226
|
+
}
|
|
227
|
+
const attributionData = this.getAttributionData();
|
|
228
|
+
if (!attributionData) {
|
|
229
|
+
return url;
|
|
230
|
+
}
|
|
231
|
+
const utmParams = filterUTMParams(attributionData.lastTouch, this.config.crossDomainUTMParams);
|
|
232
|
+
return addUTMToURL(url, utmParams);
|
|
233
|
+
}
|
|
234
|
+
// Get current UTM parameters as object
|
|
235
|
+
getCurrentUTMParams() {
|
|
236
|
+
const attributionData = this.getAttributionData();
|
|
237
|
+
if (!attributionData) {
|
|
238
|
+
return {};
|
|
239
|
+
}
|
|
240
|
+
return filterUTMParams(attributionData.lastTouch, this.config.crossDomainUTMParams);
|
|
241
|
+
}
|
|
242
|
+
// Get UTM parameters for events
|
|
243
|
+
getUTMParams() {
|
|
244
|
+
const attributionData = this.getAttributionData();
|
|
245
|
+
if (!attributionData) {
|
|
246
|
+
this.logger.debug("No attribution data available for UTM params");
|
|
247
|
+
return {};
|
|
248
|
+
}
|
|
249
|
+
const utmParams = {
|
|
250
|
+
utm_source: attributionData.lastTouch.utm_source || null,
|
|
251
|
+
utm_medium: attributionData.lastTouch.utm_medium || null,
|
|
252
|
+
utm_campaign: attributionData.lastTouch.utm_campaign || null,
|
|
253
|
+
utm_term: attributionData.lastTouch.utm_term || null,
|
|
254
|
+
utm_content: attributionData.lastTouch.utm_content || null,
|
|
255
|
+
};
|
|
256
|
+
// Filter out empty values and return null for empty strings
|
|
257
|
+
const filteredParams = {};
|
|
258
|
+
if (utmParams.utm_source && utmParams.utm_source.trim() !== "") {
|
|
259
|
+
filteredParams.utm_source = utmParams.utm_source;
|
|
260
|
+
}
|
|
261
|
+
else {
|
|
262
|
+
filteredParams.utm_source = null;
|
|
263
|
+
}
|
|
264
|
+
if (utmParams.utm_medium && utmParams.utm_medium.trim() !== "") {
|
|
265
|
+
filteredParams.utm_medium = utmParams.utm_medium;
|
|
266
|
+
}
|
|
267
|
+
else {
|
|
268
|
+
filteredParams.utm_medium = null;
|
|
269
|
+
}
|
|
270
|
+
if (utmParams.utm_campaign && utmParams.utm_campaign.trim() !== "") {
|
|
271
|
+
filteredParams.utm_campaign = utmParams.utm_campaign;
|
|
272
|
+
}
|
|
273
|
+
else {
|
|
274
|
+
filteredParams.utm_campaign = null;
|
|
275
|
+
}
|
|
276
|
+
if (utmParams.utm_term && utmParams.utm_term.trim() !== "") {
|
|
277
|
+
filteredParams.utm_term = utmParams.utm_term;
|
|
278
|
+
}
|
|
279
|
+
else {
|
|
280
|
+
filteredParams.utm_term = null;
|
|
281
|
+
}
|
|
282
|
+
if (utmParams.utm_content && utmParams.utm_content.trim() !== "") {
|
|
283
|
+
filteredParams.utm_content = utmParams.utm_content;
|
|
284
|
+
}
|
|
285
|
+
else {
|
|
286
|
+
filteredParams.utm_content = null;
|
|
287
|
+
}
|
|
288
|
+
this.logger.debug("UTM params for event:", filteredParams);
|
|
289
|
+
return filteredParams;
|
|
290
|
+
}
|
|
291
|
+
// Initialize user session
|
|
292
|
+
initializeSession() {
|
|
293
|
+
const existingSession = this.storage.getSession();
|
|
294
|
+
const now = Date.now();
|
|
295
|
+
if (existingSession &&
|
|
296
|
+
now - existingSession.lastActivity < this.config.sessionTimeout) {
|
|
297
|
+
// Extend existing session
|
|
298
|
+
this.session = {
|
|
299
|
+
...existingSession,
|
|
300
|
+
lastActivity: now,
|
|
301
|
+
};
|
|
302
|
+
}
|
|
303
|
+
else {
|
|
304
|
+
// Create new session
|
|
305
|
+
this.session = {
|
|
306
|
+
sessionId: generateSessionId(),
|
|
307
|
+
startTime: now,
|
|
308
|
+
lastActivity: now,
|
|
309
|
+
pageViews: 0,
|
|
310
|
+
};
|
|
311
|
+
}
|
|
312
|
+
this.storage.storeSession(this.session);
|
|
313
|
+
this.logger.debug("Session initialized:", this.session);
|
|
314
|
+
}
|
|
315
|
+
// Extract and store UTM data from current URL
|
|
316
|
+
extractAndStoreUTMData() {
|
|
317
|
+
const currentUrl = getCurrentUrl();
|
|
318
|
+
const utmParams = extractUTMParams(currentUrl);
|
|
319
|
+
this.logger.debug("Extracting UTM params from URL:", currentUrl);
|
|
320
|
+
this.logger.debug("Found UTM params:", utmParams);
|
|
321
|
+
if (Object.keys(utmParams).length === 0) {
|
|
322
|
+
this.logger.debug("No UTM parameters found in URL");
|
|
323
|
+
return;
|
|
324
|
+
}
|
|
325
|
+
const utmData = {
|
|
326
|
+
utm_source: utmParams.utm_source || "",
|
|
327
|
+
utm_medium: utmParams.utm_medium || "",
|
|
328
|
+
utm_campaign: utmParams.utm_campaign || "",
|
|
329
|
+
utm_term: utmParams.utm_term || "",
|
|
330
|
+
utm_content: utmParams.utm_content || "",
|
|
331
|
+
timestamp: Date.now(),
|
|
332
|
+
};
|
|
333
|
+
const existingData = this.getAttributionData();
|
|
334
|
+
const newData = {
|
|
335
|
+
firstTouch: existingData?.firstTouch || utmData,
|
|
336
|
+
lastTouch: utmData,
|
|
337
|
+
touchpoints: existingData?.touchpoints || [],
|
|
338
|
+
expiresAt: Date.now() + 30 * 24 * 60 * 60 * 1000, // 30 days
|
|
339
|
+
};
|
|
340
|
+
// Add to touchpoints if different from last touch
|
|
341
|
+
if (!existingData ||
|
|
342
|
+
existingData.lastTouch.utm_source !== utmData.utm_source ||
|
|
343
|
+
existingData.lastTouch.utm_campaign !== utmData.utm_campaign) {
|
|
344
|
+
newData.touchpoints.push(utmData);
|
|
345
|
+
}
|
|
346
|
+
this.storage.storeUTMData(newData);
|
|
347
|
+
this.logger.info("UTM data extracted and stored successfully:", utmData);
|
|
348
|
+
// Clean URL after storing UTM data to avoid displaying parameters in plain text
|
|
349
|
+
if (this.config.autoCleanUTM) {
|
|
350
|
+
cleanURL();
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
// Setup auto-tracking
|
|
354
|
+
setupAutoTracking() {
|
|
355
|
+
this.autoTrackEnabled = true;
|
|
356
|
+
// Track form interactions
|
|
357
|
+
this.setupFormTracking();
|
|
358
|
+
// Track link clicks
|
|
359
|
+
this.setupLinkTracking();
|
|
360
|
+
this.logger.info("Auto-tracking enabled");
|
|
361
|
+
}
|
|
362
|
+
// Setup form tracking
|
|
363
|
+
setupFormTracking() {
|
|
364
|
+
document.addEventListener("submit", (event) => {
|
|
365
|
+
try {
|
|
366
|
+
const form = event.target;
|
|
367
|
+
if (!form) {
|
|
368
|
+
return;
|
|
369
|
+
}
|
|
370
|
+
const fieldValues = this.serializeFormFields(form);
|
|
371
|
+
this.trackEvent(EventType.FORM_SUBMIT, {
|
|
372
|
+
...fieldValues,
|
|
373
|
+
form_id: form.id || form.name,
|
|
374
|
+
form_action: form.action,
|
|
375
|
+
form_method: form.method,
|
|
376
|
+
});
|
|
377
|
+
}
|
|
378
|
+
catch (error) {
|
|
379
|
+
this.logger.error("Failed to auto-track form submit:", error);
|
|
380
|
+
}
|
|
381
|
+
});
|
|
382
|
+
}
|
|
383
|
+
// Serialize all fields within a form to a flat key-value map
|
|
384
|
+
serializeFormFields(form) {
|
|
385
|
+
const result = {};
|
|
386
|
+
try {
|
|
387
|
+
const formData = new FormData(form);
|
|
388
|
+
// Accumulate multiple values for the same name as arrays
|
|
389
|
+
for (const [name, value] of formData.entries()) {
|
|
390
|
+
const serializedValue = this.serializeFormValue(value);
|
|
391
|
+
if (Object.prototype.hasOwnProperty.call(result, name)) {
|
|
392
|
+
const existing = result[name];
|
|
393
|
+
if (Array.isArray(existing)) {
|
|
394
|
+
existing.push(serializedValue);
|
|
395
|
+
result[name] = existing;
|
|
396
|
+
}
|
|
397
|
+
else {
|
|
398
|
+
result[name] = [existing, serializedValue];
|
|
399
|
+
}
|
|
400
|
+
}
|
|
401
|
+
else {
|
|
402
|
+
result[name] = serializedValue;
|
|
403
|
+
}
|
|
404
|
+
}
|
|
405
|
+
// Ensure all named fields are represented, including unchecked radios/checkboxes
|
|
406
|
+
const elements = Array.from(form.elements);
|
|
407
|
+
// Group elements by name
|
|
408
|
+
const nameToElements = new Map();
|
|
409
|
+
for (const el of elements) {
|
|
410
|
+
const name = el.name;
|
|
411
|
+
if (!name || el.disabled)
|
|
412
|
+
continue;
|
|
413
|
+
if (!nameToElements.has(name))
|
|
414
|
+
nameToElements.set(name, []);
|
|
415
|
+
nameToElements.get(name).push(el);
|
|
416
|
+
}
|
|
417
|
+
nameToElements.forEach((els, name) => {
|
|
418
|
+
// Determine the dominant type for a group (radio/checkbox take precedence)
|
|
419
|
+
const hasRadio = els.some((e) => e.type === "radio");
|
|
420
|
+
const hasCheckbox = els.some((e) => e.type === "checkbox");
|
|
421
|
+
const hasFile = els.some((e) => e.type === "file");
|
|
422
|
+
const hasSelectMultiple = els.some((e) => e.tagName === "SELECT" && e.multiple);
|
|
423
|
+
const hasPassword = els.some((e) => e.type === "password");
|
|
424
|
+
if (hasCheckbox) {
|
|
425
|
+
const checkedValues = els
|
|
426
|
+
.filter((e) => e.type === "checkbox")
|
|
427
|
+
.filter((e) => e.checked)
|
|
428
|
+
.map((e) => e.value || "on");
|
|
429
|
+
if (!Object.prototype.hasOwnProperty.call(result, name)) {
|
|
430
|
+
result[name] = checkedValues; // [] when none checked
|
|
431
|
+
}
|
|
432
|
+
else if (!Array.isArray(result[name])) {
|
|
433
|
+
result[name] = [result[name], ...checkedValues];
|
|
434
|
+
}
|
|
435
|
+
return;
|
|
436
|
+
}
|
|
437
|
+
if (hasRadio) {
|
|
438
|
+
const checked = els
|
|
439
|
+
.filter((e) => e.type === "radio")
|
|
440
|
+
.find((e) => e.checked);
|
|
441
|
+
if (!Object.prototype.hasOwnProperty.call(result, name)) {
|
|
442
|
+
result[name] = checked ? checked.value : null;
|
|
443
|
+
}
|
|
444
|
+
return;
|
|
445
|
+
}
|
|
446
|
+
if (hasSelectMultiple) {
|
|
447
|
+
const select = els.find((e) => e.tagName === "SELECT" && e.multiple);
|
|
448
|
+
if (select) {
|
|
449
|
+
const selectedValues = Array.from(select.selectedOptions).map((opt) => opt.value);
|
|
450
|
+
if (!Object.prototype.hasOwnProperty.call(result, name)) {
|
|
451
|
+
result[name] = selectedValues; // [] when none selected
|
|
452
|
+
}
|
|
453
|
+
return;
|
|
454
|
+
}
|
|
455
|
+
}
|
|
456
|
+
if (hasFile) {
|
|
457
|
+
const input = els.find((e) => e.type === "file");
|
|
458
|
+
if (input) {
|
|
459
|
+
const files = input.files ? Array.from(input.files) : [];
|
|
460
|
+
if (!Object.prototype.hasOwnProperty.call(result, name)) {
|
|
461
|
+
result[name] = files.map((f) => this.serializeFormValue(f));
|
|
462
|
+
}
|
|
463
|
+
return;
|
|
464
|
+
}
|
|
465
|
+
}
|
|
466
|
+
if (hasPassword) {
|
|
467
|
+
// Mask password fields
|
|
468
|
+
if (Object.prototype.hasOwnProperty.call(result, name)) {
|
|
469
|
+
result[name] =
|
|
470
|
+
typeof result[name] === "string" ? "*****" : result[name];
|
|
471
|
+
}
|
|
472
|
+
else {
|
|
473
|
+
result[name] = "*****";
|
|
474
|
+
}
|
|
475
|
+
return;
|
|
476
|
+
}
|
|
477
|
+
// For other inputs/selects/textarea, ensure at least an entry exists
|
|
478
|
+
if (!Object.prototype.hasOwnProperty.call(result, name)) {
|
|
479
|
+
const first = els[0];
|
|
480
|
+
if (first.tagName === "SELECT") {
|
|
481
|
+
const select = first;
|
|
482
|
+
result[name] = select.multiple
|
|
483
|
+
? Array.from(select.selectedOptions).map((o) => o.value)
|
|
484
|
+
: select.value;
|
|
485
|
+
}
|
|
486
|
+
else if (first.type) {
|
|
487
|
+
result[name] = first.value ?? "";
|
|
488
|
+
}
|
|
489
|
+
else {
|
|
490
|
+
result[name] = first.value ?? "";
|
|
491
|
+
}
|
|
492
|
+
}
|
|
493
|
+
});
|
|
494
|
+
}
|
|
495
|
+
catch (error) {
|
|
496
|
+
this.logger.error("Failed to serialize form fields:", error);
|
|
497
|
+
}
|
|
498
|
+
return result;
|
|
499
|
+
}
|
|
500
|
+
// Convert FormData value to a serializable value
|
|
501
|
+
serializeFormValue(value) {
|
|
502
|
+
if (value instanceof File) {
|
|
503
|
+
return {
|
|
504
|
+
file_name: value.name,
|
|
505
|
+
file_size: value.size,
|
|
506
|
+
file_type: value.type,
|
|
507
|
+
};
|
|
508
|
+
}
|
|
509
|
+
return value;
|
|
510
|
+
}
|
|
511
|
+
// Setup link tracking
|
|
512
|
+
setupLinkTracking() {
|
|
513
|
+
document.addEventListener("click", (event) => {
|
|
514
|
+
const target = event.target;
|
|
515
|
+
const link = target.closest("a");
|
|
516
|
+
if (link) {
|
|
517
|
+
const href = link.href;
|
|
518
|
+
const isExternal = isExternalURL(href);
|
|
519
|
+
if (isExternal) {
|
|
520
|
+
// Handle cross-domain UTM passing
|
|
521
|
+
this.handleCrossDomainUTM(link, event);
|
|
522
|
+
}
|
|
523
|
+
}
|
|
524
|
+
});
|
|
525
|
+
}
|
|
526
|
+
// Handle cross-domain UTM parameter passing
|
|
527
|
+
handleCrossDomainUTM(link, event) {
|
|
528
|
+
if (!this.config.enableCrossDomainUTM) {
|
|
529
|
+
return;
|
|
530
|
+
}
|
|
531
|
+
const href = link.href;
|
|
532
|
+
// Check if domain should be excluded
|
|
533
|
+
if (shouldExcludeDomain(href, this.config.excludeDomains)) {
|
|
534
|
+
this.logger.debug(`Domain excluded from UTM passing: ${href}`);
|
|
535
|
+
return;
|
|
536
|
+
}
|
|
537
|
+
// Get current UTM data
|
|
538
|
+
const attributionData = this.getAttributionData();
|
|
539
|
+
if (!attributionData) {
|
|
540
|
+
this.logger.debug("No UTM data available for cross-domain passing");
|
|
541
|
+
return;
|
|
542
|
+
}
|
|
543
|
+
// Filter UTM parameters based on configuration
|
|
544
|
+
const utmParams = filterUTMParams(attributionData.lastTouch, this.config.crossDomainUTMParams);
|
|
545
|
+
if (Object.keys(utmParams).length === 0) {
|
|
546
|
+
this.logger.debug("No UTM parameters to pass");
|
|
547
|
+
return;
|
|
548
|
+
}
|
|
549
|
+
// Add UTM parameters to URL
|
|
550
|
+
const enhancedURL = addUTMToURL(href, utmParams);
|
|
551
|
+
if (enhancedURL !== href) {
|
|
552
|
+
// Update the link href
|
|
553
|
+
link.href = enhancedURL;
|
|
554
|
+
this.logger.debug("UTM parameters added to external link:", {
|
|
555
|
+
original: href,
|
|
556
|
+
enhanced: enhancedURL,
|
|
557
|
+
utmParams,
|
|
558
|
+
});
|
|
559
|
+
this.logger.debug("Cross-domain UTM passed:", {
|
|
560
|
+
link_url: enhancedURL,
|
|
561
|
+
original_url: href,
|
|
562
|
+
utm_params_passed: utmParams,
|
|
563
|
+
});
|
|
564
|
+
}
|
|
565
|
+
}
|
|
566
|
+
// Setup network handlers
|
|
567
|
+
setupNetworkHandlers() {
|
|
568
|
+
window.addEventListener("online", () => {
|
|
569
|
+
this.logger.info("Network connection restored");
|
|
570
|
+
this.queue.flush();
|
|
571
|
+
});
|
|
572
|
+
window.addEventListener("offline", () => {
|
|
573
|
+
this.logger.warn("Network connection lost");
|
|
574
|
+
});
|
|
575
|
+
}
|
|
576
|
+
// Setup visibility handlers
|
|
577
|
+
setupVisibilityHandlers() {
|
|
578
|
+
document.addEventListener("visibilitychange", () => {
|
|
579
|
+
if (document.visibilityState === "visible") {
|
|
580
|
+
this.updateSessionActivity();
|
|
581
|
+
}
|
|
582
|
+
});
|
|
583
|
+
}
|
|
584
|
+
// Setup beforeunload handler
|
|
585
|
+
setupBeforeUnloadHandler() {
|
|
586
|
+
window.addEventListener("beforeunload", () => {
|
|
587
|
+
this.updateSessionActivity();
|
|
588
|
+
this.queue.flush();
|
|
589
|
+
});
|
|
590
|
+
}
|
|
591
|
+
// Get current path (pathname + search) for comparison
|
|
592
|
+
getCurrentPath() {
|
|
593
|
+
if (typeof window === "undefined")
|
|
594
|
+
return "";
|
|
595
|
+
return window.location.pathname + window.location.search;
|
|
596
|
+
}
|
|
597
|
+
// Setup SPA (Single Page Application) route tracking
|
|
598
|
+
setupSPATracking() {
|
|
599
|
+
if (typeof window === "undefined" || typeof history === "undefined") {
|
|
600
|
+
this.logger.warn("⚠️ SPA tracking not available in this environment");
|
|
601
|
+
return;
|
|
602
|
+
}
|
|
603
|
+
if (this.spaTrackingEnabled) {
|
|
604
|
+
this.logger.warn("⚠️ SPA tracking already enabled");
|
|
605
|
+
return;
|
|
606
|
+
}
|
|
607
|
+
this.spaTrackingEnabled = true;
|
|
608
|
+
this.lastTrackedPath = this.getCurrentPath();
|
|
609
|
+
// Store original methods
|
|
610
|
+
this.originalPushState = history.pushState.bind(history);
|
|
611
|
+
this.originalReplaceState = history.replaceState.bind(history);
|
|
612
|
+
// Create a handler for route changes
|
|
613
|
+
const handleRouteChange = (changeType) => {
|
|
614
|
+
const newPath = this.getCurrentPath();
|
|
615
|
+
// Only track if path actually changed
|
|
616
|
+
if (newPath === this.lastTrackedPath) {
|
|
617
|
+
this.logger.debug(`🔄 [SPA] Route change detected (${changeType}) but path unchanged: ${newPath}`);
|
|
618
|
+
return;
|
|
619
|
+
}
|
|
620
|
+
this.logger.debug(`🔄 [SPA] Route change detected (${changeType}): ${this.lastTrackedPath} -> ${newPath}`);
|
|
621
|
+
this.lastTrackedPath = newPath;
|
|
622
|
+
// Delay tracking to allow page title and content to update (100ms is sufficient for most frameworks)
|
|
623
|
+
setTimeout(() => {
|
|
624
|
+
this.trackPageView()
|
|
625
|
+
.then(() => {
|
|
626
|
+
this.logger.debug(`✅ [SPA] Page view tracked for: ${newPath}`);
|
|
627
|
+
})
|
|
628
|
+
.catch((error) => {
|
|
629
|
+
this.logger.error(`❌ [SPA] Failed to track page view:`, error);
|
|
630
|
+
});
|
|
631
|
+
}, 100);
|
|
632
|
+
};
|
|
633
|
+
// Override history.pushState
|
|
634
|
+
history.pushState = (data, unused, url) => {
|
|
635
|
+
const result = this.originalPushState(data, unused, url);
|
|
636
|
+
handleRouteChange("pushState");
|
|
637
|
+
return result;
|
|
638
|
+
};
|
|
639
|
+
// Override history.replaceState
|
|
640
|
+
history.replaceState = (data, unused, url) => {
|
|
641
|
+
const result = this.originalReplaceState(data, unused, url);
|
|
642
|
+
handleRouteChange("replaceState");
|
|
643
|
+
return result;
|
|
644
|
+
};
|
|
645
|
+
// Listen for popstate (browser back/forward)
|
|
646
|
+
this.popstateHandler = () => {
|
|
647
|
+
handleRouteChange("popstate");
|
|
648
|
+
};
|
|
649
|
+
window.addEventListener("popstate", this.popstateHandler);
|
|
650
|
+
this.logger.info("🔄 SPA tracking setup completed");
|
|
651
|
+
}
|
|
652
|
+
// Cleanup SPA tracking (restore original methods)
|
|
653
|
+
cleanupSPATracking() {
|
|
654
|
+
if (!this.spaTrackingEnabled)
|
|
655
|
+
return;
|
|
656
|
+
// Restore original history methods
|
|
657
|
+
if (this.originalPushState) {
|
|
658
|
+
history.pushState = this.originalPushState;
|
|
659
|
+
this.originalPushState = null;
|
|
660
|
+
}
|
|
661
|
+
if (this.originalReplaceState) {
|
|
662
|
+
history.replaceState = this.originalReplaceState;
|
|
663
|
+
this.originalReplaceState = null;
|
|
664
|
+
}
|
|
665
|
+
// Remove popstate listener
|
|
666
|
+
if (this.popstateHandler) {
|
|
667
|
+
window.removeEventListener("popstate", this.popstateHandler);
|
|
668
|
+
this.popstateHandler = null;
|
|
669
|
+
}
|
|
670
|
+
this.spaTrackingEnabled = false;
|
|
671
|
+
this.logger.info("🔄 SPA tracking cleaned up");
|
|
672
|
+
}
|
|
673
|
+
// Update session activity
|
|
674
|
+
updateSessionActivity() {
|
|
675
|
+
if (this.session) {
|
|
676
|
+
this.session.lastActivity = Date.now();
|
|
677
|
+
this.storage.storeSession(this.session);
|
|
678
|
+
}
|
|
679
|
+
}
|
|
680
|
+
// Flush all pending events
|
|
681
|
+
async flush() {
|
|
682
|
+
await this.queue.flush();
|
|
683
|
+
}
|
|
684
|
+
// Get SDK status
|
|
685
|
+
getStatus() {
|
|
686
|
+
return {
|
|
687
|
+
initialized: this.initialized,
|
|
688
|
+
session: this.session,
|
|
689
|
+
queueSize: this.queue.size(),
|
|
690
|
+
online: isOnline(),
|
|
691
|
+
crossDomainUTM: {
|
|
692
|
+
enabled: this.config.enableCrossDomainUTM || true,
|
|
693
|
+
currentParams: this.getCurrentUTMParams(),
|
|
694
|
+
},
|
|
695
|
+
spaTracking: {
|
|
696
|
+
enabled: this.spaTrackingEnabled,
|
|
697
|
+
currentPath: this.lastTrackedPath,
|
|
698
|
+
},
|
|
699
|
+
};
|
|
700
|
+
}
|
|
701
|
+
// Destroy SDK instance
|
|
702
|
+
destroy() {
|
|
703
|
+
this.queue.clear();
|
|
704
|
+
this.autoTrackEnabled = false;
|
|
705
|
+
this.cleanupSPATracking();
|
|
706
|
+
this.initialized = false;
|
|
707
|
+
this.logger.info("🗑️ SDK destroyed");
|
|
708
|
+
}
|
|
709
|
+
}
|