getu-attribution-v2-sdk 0.3.1 → 0.3.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,1034 @@
1
+ import { EventType, Currency, defaultEndpoint, DEFAULT_FIELD_MAPPINGS, } 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
+ this.cachedPublicIP = null;
12
+ this.publicIPFetchPromise = null;
13
+ this.initialPageViewTriggered = false;
14
+ // SPA tracking internals
15
+ this.spaTrackingEnabled = false;
16
+ this.lastTrackedPath = "";
17
+ this.originalPushState = null;
18
+ this.originalReplaceState = null;
19
+ this.popstateHandler = null;
20
+ this.config = {
21
+ apiEndpoint: defaultEndpoint,
22
+ batchSize: 100,
23
+ batchInterval: 2000,
24
+ maxRetries: 3,
25
+ retryDelay: 1000,
26
+ enableDebug: false,
27
+ autoTrack: false,
28
+ autoTrackPageView: false,
29
+ sessionTimeout: 30 * 60 * 1000, // 30 minutes
30
+ enableCrossDomainUTM: true,
31
+ crossDomainUTMParams: [
32
+ "utm_source",
33
+ "utm_medium",
34
+ "utm_campaign",
35
+ "utm_term",
36
+ "utm_content",
37
+ ],
38
+ excludeDomains: [],
39
+ autoCleanUTM: true,
40
+ pageViewDebounceInterval: 5000, // 5 seconds default
41
+ ...config,
42
+ };
43
+ this.logger = new ConsoleLogger(this.config.enableDebug);
44
+ this.storage = new AttributionStorageManager(this.logger);
45
+ this.httpClient = new EventHttpClient(this.logger, this.config.apiKey, this.config.apiEndpoint || defaultEndpoint, this.config.maxRetries, this.config.retryDelay);
46
+ 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));
47
+ }
48
+ async init() {
49
+ if (this.initialized) {
50
+ this.logger.warn("SDK already initialized");
51
+ return;
52
+ }
53
+ try {
54
+ this.logger.info("Initializing GetuAI Attribution SDK");
55
+ // Initialize storage in background (IndexedDB failure should not block SDK initialization)
56
+ void this.storage.init().catch((storageError) => {
57
+ this.logger.warn("Storage initialization failed (IndexedDB may be unavailable), continuing with basic features:", storageError);
58
+ });
59
+ // Initialize user ID (from config or existing storage)
60
+ this.initializeUserId();
61
+ // Initialize session
62
+ this.initializeSession();
63
+ // Extract and store UTM data from current URL
64
+ this.extractAndStoreUTMData();
65
+ // Setup auto-tracking if enabled
66
+ if (this.config.autoTrack) {
67
+ this.setupAutoTracking();
68
+ }
69
+ // Setup online/offline handlers
70
+ this.setupNetworkHandlers();
71
+ // Setup page visibility handlers
72
+ this.setupVisibilityHandlers();
73
+ // Setup beforeunload handler
74
+ this.setupBeforeUnloadHandler();
75
+ // Mark initialized as early as possible so auto page view can be queued
76
+ this.initialized = true;
77
+ this.logger.info("🚀 GetuAI Attribution SDK initialized successfully");
78
+ this.logger.info("📄 Auto track page view = " + this.config.autoTrackPageView);
79
+ // Best-effort: retry pending events from last unload/redirect (do not block init)
80
+ void this.retryPendingEvents().catch((error) => {
81
+ this.logger.warn("⚠️ Pending events retry failed", error);
82
+ });
83
+ // Auto track page view if enabled (independent from autoTrack)
84
+ // Also enables SPA route tracking automatically
85
+ if (this.config.autoTrackPageView) {
86
+ this.logger.info("📄 Auto track page view enabled (including SPA route tracking)");
87
+ // Record the initial path for SPA tracking
88
+ this.lastTrackedPath = this.getCurrentPath();
89
+ // Setup SPA tracking for route changes
90
+ this.setupSPATracking();
91
+ // Fire ASAP (microtask) to survive short redirects (e.g. 50ms)
92
+ if (!this.initialPageViewTriggered) {
93
+ this.initialPageViewTriggered = true;
94
+ Promise.resolve()
95
+ .then(() => this.trackPageView())
96
+ .then(() => this.queue.process())
97
+ .then(() => {
98
+ this.logger.info("✅ Auto track page view completed");
99
+ })
100
+ .catch((error) => this.logger.error("❌ Auto track page view failed:", error));
101
+ }
102
+ }
103
+ }
104
+ catch (error) {
105
+ this.logger.error("Failed to initialize SDK:", error);
106
+ throw error;
107
+ }
108
+ }
109
+ // Track custom event
110
+ async trackEvent(eventType, eventData, tracking_user_id, revenue, currency = Currency.USD) {
111
+ if (!this.initialized) {
112
+ this.logger.warn("SDK not initialized, event not tracked");
113
+ return;
114
+ }
115
+ try {
116
+ const currentUrl = getCurrentUrl();
117
+ // Use cached public IP (best-effort). Do not block tracking on network.
118
+ const ip_address = this.cachedPublicIP;
119
+ this.ensurePublicIPFetched();
120
+ const pageContext = {
121
+ domain: typeof window !== "undefined" ? window.location.hostname : null,
122
+ path: typeof window !== "undefined" ? window.location.pathname : null,
123
+ title: getPageTitle(),
124
+ referrer: getReferrer(),
125
+ url: currentUrl.split("?")[0],
126
+ querystring: getQueryString(currentUrl),
127
+ query_params: getQueryParams(currentUrl),
128
+ ip_address: ip_address,
129
+ };
130
+ const sessionContext = {
131
+ session_id: this.session?.sessionId,
132
+ start_time: this.session?.startTime,
133
+ last_activity: this.session?.lastActivity,
134
+ page_views: this.session?.pageViews,
135
+ };
136
+ // Use provided tracking_user_id or fallback to stored user ID
137
+ const finalUserId = tracking_user_id || this.getUserId();
138
+ const event = {
139
+ event_id: generateId(),
140
+ event_type: eventType,
141
+ tracking_user_id: finalUserId || undefined,
142
+ timestamp: getTimestamp(),
143
+ event_data: eventData,
144
+ context: { page: pageContext, session: sessionContext },
145
+ revenue: revenue,
146
+ currency: currency,
147
+ ...this.getUTMParams(),
148
+ };
149
+ this.logger.debug(`Tracking event: ${eventType}`, event);
150
+ // Add to queue
151
+ this.queue.add(event);
152
+ }
153
+ catch (error) {
154
+ this.logger.error(`Failed to track event ${eventType}:`, error);
155
+ }
156
+ }
157
+ // Track page view
158
+ async trackPageView(pageData, tracking_user_id) {
159
+ const currentUrl = getCurrentUrl();
160
+ // Use URL without query string as key for debouncing
161
+ const pageKey = currentUrl.split("?")[0];
162
+ const now = Date.now();
163
+ const debounceInterval = this.config.pageViewDebounceInterval || 5000;
164
+ // Check if this page was tracked recently
165
+ const lastTrackTime = this.pageViewTrackTimes.get(pageKey);
166
+ if (lastTrackTime && now - lastTrackTime < debounceInterval) {
167
+ this.logger.debug(`Page view debounced: ${pageKey} (last tracked ${now - lastTrackTime}ms ago)`);
168
+ return;
169
+ }
170
+ const pageEventData = {
171
+ url: currentUrl,
172
+ title: getPageTitle(),
173
+ referrer: getReferrer(),
174
+ user_agent: getUserAgent(),
175
+ ...pageData,
176
+ };
177
+ await this.trackEvent(EventType.PAGE_VIEW, pageEventData, tracking_user_id);
178
+ // Update last track time for this page
179
+ this.pageViewTrackTimes.set(pageKey, now);
180
+ // Clean up old entries to prevent memory leak (keep only entries from last hour)
181
+ this.cleanupPageViewTrackTimes();
182
+ }
183
+ // Clean up old page view track times to prevent memory leak
184
+ cleanupPageViewTrackTimes() {
185
+ const oneHourAgo = Date.now() - 60 * 60 * 1000;
186
+ for (const [pageKey, trackTime] of this.pageViewTrackTimes.entries()) {
187
+ if (trackTime < oneHourAgo) {
188
+ this.pageViewTrackTimes.delete(pageKey);
189
+ }
190
+ }
191
+ }
192
+ ensurePublicIPFetched() {
193
+ if (this.cachedPublicIP || this.publicIPFetchPromise) {
194
+ return;
195
+ }
196
+ this.publicIPFetchPromise = this.fetchPublicIP()
197
+ .then((ip) => {
198
+ if (ip) {
199
+ this.cachedPublicIP = ip;
200
+ }
201
+ return ip;
202
+ })
203
+ .catch(() => null)
204
+ .finally(() => {
205
+ this.publicIPFetchPromise = null;
206
+ });
207
+ }
208
+ // Best-effort fetch of public IP address
209
+ async fetchPublicIP() {
210
+ try {
211
+ const controller = new AbortController();
212
+ const timeout = setTimeout(() => controller.abort(), 2000);
213
+ const response = await fetch("https://api.ipify.org?format=json", {
214
+ signal: controller.signal,
215
+ headers: { Accept: "application/json" },
216
+ });
217
+ clearTimeout(timeout);
218
+ if (!response.ok) {
219
+ return null;
220
+ }
221
+ const data = await response.json();
222
+ const ip = typeof data?.ip === "string" ? data.ip : null;
223
+ return ip;
224
+ }
225
+ catch (error) {
226
+ this.logger.debug("Public IP fetch failed", error);
227
+ return null;
228
+ }
229
+ }
230
+ // Track purchase
231
+ async trackPurchase(tracking_user_id, revenue, currency = Currency.USD, purchaseData) {
232
+ await this.trackEvent(EventType.PURCHASE, purchaseData, tracking_user_id, revenue, currency);
233
+ }
234
+ // Track login
235
+ async trackLogin(tracking_user_id, loginData) {
236
+ await this.trackEvent(EventType.LOGIN, loginData, tracking_user_id);
237
+ }
238
+ // Track signup
239
+ async trackSignup(tracking_user_id, signupData) {
240
+ await this.trackEvent(EventType.SIGNUP, signupData, tracking_user_id);
241
+ }
242
+ // Track form submission
243
+ async trackFormSubmit(tracking_user_id, formData) {
244
+ await this.trackEvent(EventType.FORM_SUBMIT, formData, tracking_user_id);
245
+ }
246
+ // Track video play
247
+ async trackVideoPlay(tracking_user_id, videoData) {
248
+ await this.trackEvent(EventType.VIDEO_PLAY, videoData, tracking_user_id);
249
+ }
250
+ // Track email verification
251
+ async trackEmailVerification(tracking_user_id, verificationData) {
252
+ await this.trackEvent(EventType.EMAIL_VERIFICATION, verificationData, tracking_user_id);
253
+ }
254
+ // Track audit approved (conversion)
255
+ async trackAuditApproved(tracking_user_id, auditData) {
256
+ await this.trackEvent(EventType.AUDIT_APPROVED, auditData, tracking_user_id);
257
+ }
258
+ // Track purchase with auto user ID (object parameter format)
259
+ async trackPurchaseAuto(options) {
260
+ const userId = options.tracking_user_id || this.getUserId();
261
+ if (!userId) {
262
+ this.logger.error("❌ trackPurchaseAuto requires tracking_user_id. Please set user ID using setUserId() or pass it in options.tracking_user_id.");
263
+ return;
264
+ }
265
+ await this.trackEvent(EventType.PURCHASE, options.purchaseData, userId, options.revenue, options.currency || Currency.USD);
266
+ }
267
+ // Track login with auto user ID (object parameter format)
268
+ async trackLoginAuto(options) {
269
+ const userId = options.tracking_user_id || this.getUserId();
270
+ if (!userId) {
271
+ this.logger.error("❌ trackLoginAuto requires tracking_user_id. Please set user ID using setUserId() or pass it in options.tracking_user_id.");
272
+ return;
273
+ }
274
+ await this.trackEvent(EventType.LOGIN, options.loginData, userId);
275
+ }
276
+ // Track signup with auto user ID (object parameter format)
277
+ async trackSignupAuto(options) {
278
+ const userId = options.tracking_user_id || this.getUserId();
279
+ if (!userId) {
280
+ this.logger.error("❌ trackSignupAuto requires tracking_user_id. Please set user ID using setUserId() or pass it in options.tracking_user_id.");
281
+ return;
282
+ }
283
+ await this.trackEvent(EventType.SIGNUP, options.signupData, userId);
284
+ }
285
+ // Track email verification with auto user ID (object parameter format)
286
+ async trackEmailVerificationAuto(options) {
287
+ const userId = options.tracking_user_id || this.getUserId();
288
+ if (!userId) {
289
+ this.logger.error("❌ trackEmailVerificationAuto requires tracking_user_id. Please set user ID using setUserId() or pass it in options.tracking_user_id.");
290
+ return;
291
+ }
292
+ await this.trackEvent(EventType.EMAIL_VERIFICATION, options.verificationData, userId);
293
+ }
294
+ // Track product view
295
+ async trackProductView(tracking_user_id, productData) {
296
+ await this.trackEvent(EventType.PRODUCT_VIEW, productData, tracking_user_id);
297
+ }
298
+ // Track add to cart
299
+ async trackAddToCart(tracking_user_id, cartData) {
300
+ await this.trackEvent(EventType.ADD_TO_CART, cartData, tracking_user_id);
301
+ }
302
+ // Track page click for user click journey
303
+ async trackPageClick(tracking_user_id, clickData) {
304
+ await this.trackEvent(EventType.PAGE_CLICK, clickData, tracking_user_id);
305
+ }
306
+ // Track button click
307
+ async trackButtonClick(buttonName, tracking_user_id, clickData) {
308
+ await this.trackEvent(EventType.BUTTON_CLICK, { button_name: buttonName, ...clickData }, tracking_user_id);
309
+ }
310
+ // Get attribution data
311
+ getAttributionData() {
312
+ return this.storage.getUTMData();
313
+ }
314
+ // Manually add UTM parameters to a URL
315
+ addUTMToURL(url) {
316
+ if (!this.config.enableCrossDomainUTM) {
317
+ return url;
318
+ }
319
+ const attributionData = this.getAttributionData();
320
+ if (!attributionData) {
321
+ return url;
322
+ }
323
+ const utmParams = filterUTMParams(attributionData.lastTouch, this.config.crossDomainUTMParams);
324
+ return addUTMToURL(url, utmParams);
325
+ }
326
+ // Get current UTM parameters as object
327
+ getCurrentUTMParams() {
328
+ const attributionData = this.getAttributionData();
329
+ if (!attributionData) {
330
+ return {};
331
+ }
332
+ return filterUTMParams(attributionData.lastTouch, this.config.crossDomainUTMParams);
333
+ }
334
+ // Get UTM parameters for events
335
+ getUTMParams() {
336
+ const attributionData = this.getAttributionData();
337
+ if (!attributionData) {
338
+ this.logger.debug("No attribution data available for UTM params");
339
+ return {};
340
+ }
341
+ const utmParams = {
342
+ utm_source: attributionData.lastTouch.utm_source || null,
343
+ utm_medium: attributionData.lastTouch.utm_medium || null,
344
+ utm_campaign: attributionData.lastTouch.utm_campaign || null,
345
+ utm_term: attributionData.lastTouch.utm_term || null,
346
+ utm_content: attributionData.lastTouch.utm_content || null,
347
+ };
348
+ // Filter out empty values and return null for empty strings
349
+ const filteredParams = {};
350
+ if (utmParams.utm_source && utmParams.utm_source.trim() !== "") {
351
+ filteredParams.utm_source = utmParams.utm_source;
352
+ }
353
+ else {
354
+ filteredParams.utm_source = null;
355
+ }
356
+ if (utmParams.utm_medium && utmParams.utm_medium.trim() !== "") {
357
+ filteredParams.utm_medium = utmParams.utm_medium;
358
+ }
359
+ else {
360
+ filteredParams.utm_medium = null;
361
+ }
362
+ if (utmParams.utm_campaign && utmParams.utm_campaign.trim() !== "") {
363
+ filteredParams.utm_campaign = utmParams.utm_campaign;
364
+ }
365
+ else {
366
+ filteredParams.utm_campaign = null;
367
+ }
368
+ if (utmParams.utm_term && utmParams.utm_term.trim() !== "") {
369
+ filteredParams.utm_term = utmParams.utm_term;
370
+ }
371
+ else {
372
+ filteredParams.utm_term = null;
373
+ }
374
+ if (utmParams.utm_content && utmParams.utm_content.trim() !== "") {
375
+ filteredParams.utm_content = utmParams.utm_content;
376
+ }
377
+ else {
378
+ filteredParams.utm_content = null;
379
+ }
380
+ this.logger.debug("UTM params for event:", filteredParams);
381
+ return filteredParams;
382
+ }
383
+ // Initialize user ID
384
+ initializeUserId() {
385
+ // If userId is provided in config, set it (will override existing)
386
+ if (this.config.userId) {
387
+ this.setUserId(this.config.userId);
388
+ this.logger.info(`👤 User ID initialized from config: ${this.config.userId}`);
389
+ }
390
+ else {
391
+ // Check if user ID already exists in storage
392
+ const existingUserId = this.getUserId();
393
+ if (existingUserId) {
394
+ this.logger.debug(`👤 Existing user ID found: ${existingUserId}`);
395
+ }
396
+ else {
397
+ this.logger.debug("👤 No user ID found, will be set when user identifies");
398
+ }
399
+ }
400
+ }
401
+ // Set user ID
402
+ setUserId(userId) {
403
+ if (!userId || userId.trim() === "") {
404
+ this.logger.warn("Cannot set empty user ID");
405
+ return;
406
+ }
407
+ this.storage.setUserId(userId);
408
+ this.logger.info(`👤 User ID set: ${userId}`);
409
+ }
410
+ // Get user ID
411
+ getUserId() {
412
+ return this.storage.getUserId();
413
+ }
414
+ // Remove user ID
415
+ removeUserId() {
416
+ this.storage.removeUserId();
417
+ this.logger.info("👤 User ID removed");
418
+ }
419
+ /**
420
+ * Identify user and set user attributes
421
+ * Used to explicitly set user information, e.g., after login
422
+ *
423
+ * @param userId User unique identifier
424
+ * @param traits User attributes
425
+ *
426
+ * @example
427
+ * sdk.identify('user-123', {
428
+ * email: 'user@example.com',
429
+ * name: 'John Doe',
430
+ * company_name: 'Acme Inc'
431
+ * });
432
+ */
433
+ async identify(userId, traits) {
434
+ if (!this.initialized) {
435
+ this.logger.warn("SDK not initialized, identify call queued");
436
+ return;
437
+ }
438
+ if (!userId || userId.trim() === "") {
439
+ this.logger.warn("Cannot identify with empty user ID");
440
+ return;
441
+ }
442
+ // Set user ID
443
+ this.setUserId(userId);
444
+ // Store user traits
445
+ if (traits) {
446
+ this.storage.setUserTraits(traits);
447
+ this.logger.info(`User identified: ${userId}`, traits);
448
+ }
449
+ // Send identify event to backend
450
+ await this.trackEvent(EventType.FORM_SUBMIT, {
451
+ _identify: true,
452
+ _user_traits: traits,
453
+ tracking_user_id: userId,
454
+ }, userId);
455
+ }
456
+ /**
457
+ * Get current user traits
458
+ */
459
+ getUserTraits() {
460
+ return this.storage.getUserTraits();
461
+ }
462
+ /**
463
+ * Get current session ID
464
+ */
465
+ getSessionId() {
466
+ return this.session?.sessionId || null;
467
+ }
468
+ // Initialize user session
469
+ initializeSession() {
470
+ const existingSession = this.storage.getSession();
471
+ const now = Date.now();
472
+ if (existingSession &&
473
+ now - existingSession.lastActivity < this.config.sessionTimeout) {
474
+ // Extend existing session
475
+ this.session = {
476
+ ...existingSession,
477
+ lastActivity: now,
478
+ };
479
+ }
480
+ else {
481
+ // Create new session
482
+ this.session = {
483
+ sessionId: generateSessionId(),
484
+ startTime: now,
485
+ lastActivity: now,
486
+ pageViews: 0,
487
+ };
488
+ }
489
+ this.storage.storeSession(this.session);
490
+ this.logger.debug("Session initialized:", this.session);
491
+ }
492
+ // Extract and store UTM data from current URL
493
+ extractAndStoreUTMData() {
494
+ const currentUrl = getCurrentUrl();
495
+ const utmParams = extractUTMParams(currentUrl);
496
+ this.logger.debug("Extracting UTM params from URL:", currentUrl);
497
+ this.logger.debug("Found UTM params:", utmParams);
498
+ if (Object.keys(utmParams).length === 0) {
499
+ this.logger.debug("No UTM parameters found in URL");
500
+ return;
501
+ }
502
+ const utmData = {
503
+ utm_source: utmParams.utm_source || "",
504
+ utm_medium: utmParams.utm_medium || "",
505
+ utm_campaign: utmParams.utm_campaign || "",
506
+ utm_term: utmParams.utm_term || "",
507
+ utm_content: utmParams.utm_content || "",
508
+ timestamp: Date.now(),
509
+ };
510
+ const existingData = this.getAttributionData();
511
+ const newData = {
512
+ firstTouch: existingData?.firstTouch || utmData,
513
+ lastTouch: utmData,
514
+ touchpoints: existingData?.touchpoints || [],
515
+ expiresAt: Date.now() + 30 * 24 * 60 * 60 * 1000, // 30 days
516
+ };
517
+ // Add to touchpoints if different from last touch
518
+ if (!existingData ||
519
+ existingData.lastTouch.utm_source !== utmData.utm_source ||
520
+ existingData.lastTouch.utm_campaign !== utmData.utm_campaign) {
521
+ newData.touchpoints.push(utmData);
522
+ }
523
+ this.storage.storeUTMData(newData);
524
+ this.logger.info("UTM data extracted and stored successfully:", utmData);
525
+ // Clean URL after storing UTM data to avoid displaying parameters in plain text
526
+ if (this.config.autoCleanUTM) {
527
+ cleanURL();
528
+ }
529
+ }
530
+ // Setup auto-tracking
531
+ setupAutoTracking() {
532
+ this.autoTrackEnabled = true;
533
+ // Track form interactions
534
+ this.setupFormTracking();
535
+ // Track link clicks
536
+ this.setupLinkTracking();
537
+ this.logger.info("Auto-tracking enabled");
538
+ }
539
+ // Setup form tracking
540
+ setupFormTracking() {
541
+ document.addEventListener("submit", (event) => {
542
+ try {
543
+ const form = event.target;
544
+ if (!form) {
545
+ return;
546
+ }
547
+ const fieldValues = this.serializeFormFields(form);
548
+ // Extract standardized lead fields
549
+ const leadFields = this.extractLeadFields(fieldValues);
550
+ // If email is extracted, automatically store user traits
551
+ if (leadFields.email) {
552
+ this.storage.setUserTraits(leadFields);
553
+ this.logger.debug("Lead fields extracted from form:", leadFields);
554
+ }
555
+ this.trackEvent(EventType.FORM_SUBMIT, {
556
+ ...fieldValues,
557
+ // Add standardized lead fields
558
+ _lead_fields: leadFields,
559
+ form_id: form.id || form.name,
560
+ form_action: form.action,
561
+ form_method: form.method,
562
+ });
563
+ }
564
+ catch (error) {
565
+ this.logger.error("Failed to auto-track form submit:", error);
566
+ }
567
+ });
568
+ }
569
+ // Serialize all fields within a form to a flat key-value map
570
+ serializeFormFields(form) {
571
+ const result = {};
572
+ try {
573
+ const formData = new FormData(form);
574
+ // Accumulate multiple values for the same name as arrays
575
+ for (const [name, value] of formData.entries()) {
576
+ const serializedValue = this.serializeFormValue(value);
577
+ if (Object.prototype.hasOwnProperty.call(result, name)) {
578
+ const existing = result[name];
579
+ if (Array.isArray(existing)) {
580
+ existing.push(serializedValue);
581
+ result[name] = existing;
582
+ }
583
+ else {
584
+ result[name] = [existing, serializedValue];
585
+ }
586
+ }
587
+ else {
588
+ result[name] = serializedValue;
589
+ }
590
+ }
591
+ // Ensure all fields are represented, including unchecked radios/checkboxes.
592
+ // For fields without name attribute, use a stable fallback key (id/aria-label/placeholder/index).
593
+ const elements = Array.from(form.elements);
594
+ // Group elements by key
595
+ const nameToElements = new Map();
596
+ for (let index = 0; index < elements.length; index++) {
597
+ const el = elements[index];
598
+ if (el.disabled)
599
+ continue;
600
+ const tagName = el.tagName.toLowerCase();
601
+ const inputType = el.type?.toLowerCase();
602
+ if (tagName === "button" ||
603
+ inputType === "submit" ||
604
+ inputType === "reset") {
605
+ continue;
606
+ }
607
+ const fieldKey = this.getFormFieldKey(el, index);
608
+ if (!fieldKey) {
609
+ continue;
610
+ }
611
+ if (!nameToElements.has(fieldKey))
612
+ nameToElements.set(fieldKey, []);
613
+ nameToElements.get(fieldKey).push(el);
614
+ }
615
+ nameToElements.forEach((els, name) => {
616
+ // Determine the dominant type for a group (radio/checkbox take precedence)
617
+ const hasRadio = els.some((e) => e.type === "radio");
618
+ const hasCheckbox = els.some((e) => e.type === "checkbox");
619
+ const hasFile = els.some((e) => e.type === "file");
620
+ const hasSelectMultiple = els.some((e) => e.tagName === "SELECT" && e.multiple);
621
+ const hasPassword = els.some((e) => e.type === "password");
622
+ if (hasCheckbox) {
623
+ const checkedValues = els
624
+ .filter((e) => e.type === "checkbox")
625
+ .filter((e) => e.checked)
626
+ .map((e) => e.value || "on");
627
+ if (!Object.prototype.hasOwnProperty.call(result, name)) {
628
+ result[name] = checkedValues; // [] when none checked
629
+ }
630
+ else if (!Array.isArray(result[name])) {
631
+ result[name] = [result[name], ...checkedValues];
632
+ }
633
+ return;
634
+ }
635
+ if (hasRadio) {
636
+ const checked = els
637
+ .filter((e) => e.type === "radio")
638
+ .find((e) => e.checked);
639
+ if (!Object.prototype.hasOwnProperty.call(result, name)) {
640
+ result[name] = checked ? checked.value : null;
641
+ }
642
+ return;
643
+ }
644
+ if (hasSelectMultiple) {
645
+ const select = els.find((e) => e.tagName === "SELECT" && e.multiple);
646
+ if (select) {
647
+ const selectedValues = Array.from(select.selectedOptions).map((opt) => opt.value);
648
+ if (!Object.prototype.hasOwnProperty.call(result, name)) {
649
+ result[name] = selectedValues; // [] when none selected
650
+ }
651
+ return;
652
+ }
653
+ }
654
+ if (hasFile) {
655
+ const input = els.find((e) => e.type === "file");
656
+ if (input) {
657
+ const files = input.files ? Array.from(input.files) : [];
658
+ if (!Object.prototype.hasOwnProperty.call(result, name)) {
659
+ result[name] = files.map((f) => this.serializeFormValue(f));
660
+ }
661
+ return;
662
+ }
663
+ }
664
+ if (hasPassword) {
665
+ // Mask password fields
666
+ if (Object.prototype.hasOwnProperty.call(result, name)) {
667
+ result[name] =
668
+ typeof result[name] === "string" ? "*****" : result[name];
669
+ }
670
+ else {
671
+ result[name] = "*****";
672
+ }
673
+ return;
674
+ }
675
+ // For other inputs/selects/textarea, ensure at least an entry exists
676
+ if (!Object.prototype.hasOwnProperty.call(result, name)) {
677
+ const first = els[0];
678
+ if (first.tagName === "SELECT") {
679
+ const select = first;
680
+ result[name] = select.multiple
681
+ ? Array.from(select.selectedOptions).map((o) => o.value)
682
+ : select.value;
683
+ }
684
+ else if (first.type) {
685
+ result[name] = first.value ?? "";
686
+ }
687
+ else {
688
+ result[name] = first.value ?? "";
689
+ }
690
+ }
691
+ });
692
+ }
693
+ catch (error) {
694
+ this.logger.error("Failed to serialize form fields:", error);
695
+ }
696
+ return result;
697
+ }
698
+ getFormFieldKey(el, index) {
699
+ const name = el.name;
700
+ if (name) {
701
+ return name;
702
+ }
703
+ if (el.id) {
704
+ return `id_${el.id}`;
705
+ }
706
+ const ariaLabel = el.getAttribute("aria-label");
707
+ if (ariaLabel && ariaLabel.trim()) {
708
+ return `aria_${this.normalizeFormFieldKey(ariaLabel)}`;
709
+ }
710
+ const placeholder = el instanceof HTMLInputElement || el instanceof HTMLTextAreaElement
711
+ ? el.placeholder
712
+ : "";
713
+ if (placeholder && placeholder.trim()) {
714
+ return `ph_${this.normalizeFormFieldKey(placeholder)}_${index}`;
715
+ }
716
+ return `field_${index}`;
717
+ }
718
+ normalizeFormFieldKey(raw) {
719
+ return raw
720
+ .trim()
721
+ .toLowerCase()
722
+ .replace(/\s+/g, "_")
723
+ .replace(/[^a-z0-9_]/g, "")
724
+ .slice(0, 40);
725
+ }
726
+ // Convert FormData value to a serializable value
727
+ serializeFormValue(value) {
728
+ if (value instanceof File) {
729
+ return {
730
+ file_name: value.name,
731
+ file_size: value.size,
732
+ file_type: value.type,
733
+ };
734
+ }
735
+ return value;
736
+ }
737
+ /**
738
+ * Extract standardized lead fields from form data
739
+ * @param formData Serialized form data
740
+ * @returns Extracted user traits
741
+ */
742
+ extractLeadFields(formData) {
743
+ const result = {};
744
+ // Iterate through all standard field mappings
745
+ for (const [standardField, aliases] of Object.entries(DEFAULT_FIELD_MAPPINGS)) {
746
+ if (result[standardField])
747
+ continue; // Already found, skip
748
+ // Look for matching field
749
+ for (const alias of aliases || []) {
750
+ const lowerAlias = alias.toLowerCase();
751
+ // Search for matching key in formData
752
+ for (const [key, value] of Object.entries(formData)) {
753
+ if (key.toLowerCase() === lowerAlias ||
754
+ key.toLowerCase().includes(lowerAlias)) {
755
+ if (value && typeof value === "string" && value.trim()) {
756
+ result[standardField] = value.trim();
757
+ break;
758
+ }
759
+ }
760
+ }
761
+ if (result[standardField])
762
+ break;
763
+ }
764
+ }
765
+ // Combine first_name and last_name into name (if name is empty)
766
+ if (!result.name && (result.first_name || result.last_name)) {
767
+ result.name = [result.first_name, result.last_name]
768
+ .filter(Boolean)
769
+ .join(" ")
770
+ .trim();
771
+ }
772
+ return result;
773
+ }
774
+ // Setup link tracking
775
+ setupLinkTracking() {
776
+ document.addEventListener("click", (event) => {
777
+ const target = event.target;
778
+ const link = target.closest("a");
779
+ if (link) {
780
+ const href = link.href;
781
+ const isExternal = isExternalURL(href);
782
+ if (isExternal) {
783
+ // Handle cross-domain UTM passing
784
+ this.handleCrossDomainUTM(link, event);
785
+ }
786
+ }
787
+ });
788
+ }
789
+ // Handle cross-domain UTM parameter passing
790
+ handleCrossDomainUTM(link, event) {
791
+ if (!this.config.enableCrossDomainUTM) {
792
+ return;
793
+ }
794
+ const href = link.href;
795
+ // Check if domain should be excluded
796
+ if (shouldExcludeDomain(href, this.config.excludeDomains)) {
797
+ this.logger.debug(`Domain excluded from UTM passing: ${href}`);
798
+ return;
799
+ }
800
+ // Get current UTM data
801
+ const attributionData = this.getAttributionData();
802
+ if (!attributionData) {
803
+ this.logger.debug("No UTM data available for cross-domain passing");
804
+ return;
805
+ }
806
+ // Filter UTM parameters based on configuration
807
+ const utmParams = filterUTMParams(attributionData.lastTouch, this.config.crossDomainUTMParams);
808
+ if (Object.keys(utmParams).length === 0) {
809
+ this.logger.debug("No UTM parameters to pass");
810
+ return;
811
+ }
812
+ // Add UTM parameters to URL
813
+ const enhancedURL = addUTMToURL(href, utmParams);
814
+ if (enhancedURL !== href) {
815
+ // Update the link href
816
+ link.href = enhancedURL;
817
+ this.logger.debug("UTM parameters added to external link:", {
818
+ original: href,
819
+ enhanced: enhancedURL,
820
+ utmParams,
821
+ });
822
+ this.logger.debug("Cross-domain UTM passed:", {
823
+ link_url: enhancedURL,
824
+ original_url: href,
825
+ utm_params_passed: utmParams,
826
+ });
827
+ }
828
+ }
829
+ // Setup network handlers
830
+ setupNetworkHandlers() {
831
+ window.addEventListener("online", () => {
832
+ this.logger.info("Network connection restored");
833
+ this.queue.flush();
834
+ });
835
+ window.addEventListener("offline", () => {
836
+ this.logger.warn("Network connection lost");
837
+ });
838
+ }
839
+ // Setup visibility handlers
840
+ setupVisibilityHandlers() {
841
+ document.addEventListener("visibilitychange", () => {
842
+ if (document.visibilityState === "hidden") {
843
+ this.flushOnUnload("visibilitychange:hidden");
844
+ return;
845
+ }
846
+ if (document.visibilityState === "visible") {
847
+ this.updateSessionActivity();
848
+ }
849
+ });
850
+ }
851
+ // Setup beforeunload handler
852
+ setupBeforeUnloadHandler() {
853
+ window.addEventListener("beforeunload", () => {
854
+ this.flushOnUnload("beforeunload");
855
+ });
856
+ // More reliable than beforeunload for modern browsers (incl. mobile)
857
+ window.addEventListener("pagehide", () => {
858
+ this.flushOnUnload("pagehide");
859
+ });
860
+ }
861
+ flushOnUnload(reason) {
862
+ try {
863
+ this.updateSessionActivity();
864
+ const pending = this.queue.drainAll();
865
+ if (pending.length === 0) {
866
+ return;
867
+ }
868
+ // Backup first (best-effort). If keepalive succeeds, we'll remove by id.
869
+ this.storage.appendPendingEvents(pending);
870
+ this.logger.debug(`🚚 Unload flush triggered: ${reason}`, {
871
+ count: pending.length,
872
+ });
873
+ // Prefer beacon (avoid preflight) for redirect/unload scenarios.
874
+ const beaconOk = this.httpClient.sendEventsBeacon(pending);
875
+ if (beaconOk) {
876
+ // Best-effort: remove to avoid duplicates (beacon is queued by browser).
877
+ this.storage.removePendingEventsByIds(pending.map((e) => e.event_id));
878
+ return;
879
+ }
880
+ const chunkSize = 20;
881
+ for (let i = 0; i < pending.length; i += chunkSize) {
882
+ const chunk = pending.slice(i, i + chunkSize);
883
+ void this.httpClient
884
+ .sendEventsKeepalive(chunk)
885
+ .then(() => {
886
+ this.storage.removePendingEventsByIds(chunk.map((e) => e.event_id));
887
+ })
888
+ .catch((error) => {
889
+ this.logger.debug("Unload keepalive send failed", error);
890
+ });
891
+ }
892
+ }
893
+ catch (error) {
894
+ this.logger.debug("flushOnUnload failed", error);
895
+ }
896
+ }
897
+ async retryPendingEvents() {
898
+ const pending = this.storage.getPendingEvents();
899
+ if (pending.length === 0) {
900
+ return;
901
+ }
902
+ this.logger.info(`📦 Retrying pending events: ${pending.length}`);
903
+ try {
904
+ await this.httpClient.sendEvents(pending);
905
+ this.storage.removePendingEventsByIds(pending.map((e) => e.event_id));
906
+ this.logger.info(`✅ Pending events sent: ${pending.length}`);
907
+ }
908
+ catch (error) {
909
+ this.logger.warn("⚠️ Pending events retry failed", error);
910
+ }
911
+ }
912
+ // Get current path (pathname + search) for comparison
913
+ getCurrentPath() {
914
+ if (typeof window === "undefined")
915
+ return "";
916
+ return window.location.pathname + window.location.search;
917
+ }
918
+ // Setup SPA (Single Page Application) route tracking
919
+ setupSPATracking() {
920
+ if (typeof window === "undefined" || typeof history === "undefined") {
921
+ this.logger.warn("⚠️ SPA tracking not available in this environment");
922
+ return;
923
+ }
924
+ if (this.spaTrackingEnabled) {
925
+ this.logger.warn("⚠️ SPA tracking already enabled");
926
+ return;
927
+ }
928
+ this.spaTrackingEnabled = true;
929
+ this.lastTrackedPath = this.getCurrentPath();
930
+ // Store original methods
931
+ this.originalPushState = history.pushState.bind(history);
932
+ this.originalReplaceState = history.replaceState.bind(history);
933
+ // Create a handler for route changes
934
+ const handleRouteChange = (changeType) => {
935
+ const newPath = this.getCurrentPath();
936
+ // Only track if path actually changed
937
+ if (newPath === this.lastTrackedPath) {
938
+ this.logger.debug(`🔄 [SPA] Route change detected (${changeType}) but path unchanged: ${newPath}`);
939
+ return;
940
+ }
941
+ this.logger.debug(`🔄 [SPA] Route change detected (${changeType}): ${this.lastTrackedPath} -> ${newPath}`);
942
+ this.lastTrackedPath = newPath;
943
+ // Delay tracking to allow page title and content to update (100ms is sufficient for most frameworks)
944
+ setTimeout(() => {
945
+ this.trackPageView()
946
+ .then(() => {
947
+ // If auto page view is enabled, send ASAP to survive short redirects
948
+ return this.queue.process();
949
+ })
950
+ .then(() => {
951
+ this.logger.debug(`✅ [SPA] Page view tracked for: ${newPath}`);
952
+ })
953
+ .catch((error) => {
954
+ this.logger.error(`❌ [SPA] Failed to track page view:`, error);
955
+ });
956
+ }, 100);
957
+ };
958
+ // Override history.pushState
959
+ history.pushState = (data, unused, url) => {
960
+ const result = this.originalPushState(data, unused, url);
961
+ handleRouteChange("pushState");
962
+ return result;
963
+ };
964
+ // Override history.replaceState
965
+ history.replaceState = (data, unused, url) => {
966
+ const result = this.originalReplaceState(data, unused, url);
967
+ handleRouteChange("replaceState");
968
+ return result;
969
+ };
970
+ // Listen for popstate (browser back/forward)
971
+ this.popstateHandler = () => {
972
+ handleRouteChange("popstate");
973
+ };
974
+ window.addEventListener("popstate", this.popstateHandler);
975
+ this.logger.info("🔄 SPA tracking setup completed");
976
+ }
977
+ // Cleanup SPA tracking (restore original methods)
978
+ cleanupSPATracking() {
979
+ if (!this.spaTrackingEnabled)
980
+ return;
981
+ // Restore original history methods
982
+ if (this.originalPushState) {
983
+ history.pushState = this.originalPushState;
984
+ this.originalPushState = null;
985
+ }
986
+ if (this.originalReplaceState) {
987
+ history.replaceState = this.originalReplaceState;
988
+ this.originalReplaceState = null;
989
+ }
990
+ // Remove popstate listener
991
+ if (this.popstateHandler) {
992
+ window.removeEventListener("popstate", this.popstateHandler);
993
+ this.popstateHandler = null;
994
+ }
995
+ this.spaTrackingEnabled = false;
996
+ this.logger.info("🔄 SPA tracking cleaned up");
997
+ }
998
+ // Update session activity
999
+ updateSessionActivity() {
1000
+ if (this.session) {
1001
+ this.session.lastActivity = Date.now();
1002
+ this.storage.storeSession(this.session);
1003
+ }
1004
+ }
1005
+ // Flush all pending events
1006
+ async flush() {
1007
+ await this.queue.flush();
1008
+ }
1009
+ // Get SDK status
1010
+ getStatus() {
1011
+ return {
1012
+ initialized: this.initialized,
1013
+ session: this.session,
1014
+ queueSize: this.queue.size(),
1015
+ online: isOnline(),
1016
+ crossDomainUTM: {
1017
+ enabled: this.config.enableCrossDomainUTM || true,
1018
+ currentParams: this.getCurrentUTMParams(),
1019
+ },
1020
+ spaTracking: {
1021
+ enabled: this.spaTrackingEnabled,
1022
+ currentPath: this.lastTrackedPath,
1023
+ },
1024
+ };
1025
+ }
1026
+ // Destroy SDK instance
1027
+ destroy() {
1028
+ this.queue.clear();
1029
+ this.autoTrackEnabled = false;
1030
+ this.cleanupSPATracking();
1031
+ this.initialized = false;
1032
+ this.logger.info("🗑️ SDK destroyed");
1033
+ }
1034
+ }