user-analytics-tracker 1.2.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.
@@ -0,0 +1,1811 @@
1
+ import { useEffect, useState, useRef, useCallback, useMemo } from 'react';
2
+
3
+ /**
4
+ * Network Type Detector
5
+ * Detects WiFi, Mobile Data (Cellular), Hotspot, Ethernet, or Unknown
6
+ */
7
+ class NetworkDetector {
8
+ static detect() {
9
+ if (typeof navigator === 'undefined') {
10
+ return { type: 'unknown' };
11
+ }
12
+ const c = navigator.connection ||
13
+ navigator.mozConnection ||
14
+ navigator.webkitConnection;
15
+ if (!c) {
16
+ // Fallback: guess based on user agent
17
+ const isMobile = /Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i.test(navigator.userAgent);
18
+ return { type: isMobile ? 'cellular' : 'wifi' };
19
+ }
20
+ let type = 'unknown';
21
+ // Method 1: Direct type property (most reliable)
22
+ if (c.type) {
23
+ const connectionType = c.type.toLowerCase();
24
+ switch (connectionType) {
25
+ case 'wifi':
26
+ case 'wlan':
27
+ type = 'wifi';
28
+ break;
29
+ case 'cellular':
30
+ case '2g':
31
+ case '3g':
32
+ case '4g':
33
+ case '5g':
34
+ type = 'cellular';
35
+ break;
36
+ case 'ethernet':
37
+ type = 'ethernet';
38
+ break;
39
+ case 'none':
40
+ type = 'unknown';
41
+ break;
42
+ default:
43
+ type = connectionType;
44
+ }
45
+ }
46
+ // Method 2: Heuristic-based detection using multiple signals
47
+ // Note: effectiveType indicates speed/quality, NOT connection type
48
+ // A WiFi connection can have effectiveType "4g" if it's fast
49
+ else {
50
+ const downlink = c.downlink || 0;
51
+ const rtt = c.rtt || 0;
52
+ const saveData = c.saveData || false;
53
+ c.effectiveType?.toLowerCase() || '';
54
+ const isMobileDevice = /Android|iPhone|iPad|iPod/i.test(navigator.userAgent);
55
+ const isDesktop = !isMobileDevice;
56
+ // Data saver mode strongly suggests cellular
57
+ if (saveData) {
58
+ type = 'cellular';
59
+ }
60
+ // Desktop/laptop devices are almost always WiFi or Ethernet (not cellular)
61
+ else if (isDesktop) {
62
+ // Very high speeds (>50 Mbps) are likely Ethernet
63
+ // Medium-high speeds (10-50 Mbps) are likely WiFi
64
+ // Lower speeds could be WiFi or Ethernet depending on connection quality
65
+ if (downlink > 50) {
66
+ type = 'ethernet';
67
+ }
68
+ else if (downlink > 5) {
69
+ type = 'wifi';
70
+ }
71
+ else {
72
+ // Low speed on desktop - likely poor WiFi, but still WiFi
73
+ type = 'wifi';
74
+ }
75
+ }
76
+ // Mobile device: need to distinguish between WiFi, cellular, and hotspot
77
+ else {
78
+ // Very fast connection (>20 Mbps) on mobile = almost certainly WiFi
79
+ if (downlink > 20) {
80
+ type = 'wifi';
81
+ }
82
+ // Fast connection (10-20 Mbps) = likely WiFi (even with moderate RTT)
83
+ // WiFi can have RTT up to ~150ms depending on router/network quality
84
+ else if (downlink >= 10) {
85
+ type = 'wifi';
86
+ }
87
+ // Medium-fast connection (5-10 Mbps) with reasonable latency = likely WiFi
88
+ else if (downlink >= 5 && rtt < 150) {
89
+ type = 'wifi';
90
+ }
91
+ // Medium speed (1-5 Mbps) with low latency = likely WiFi
92
+ else if (downlink >= 1 && rtt < 100) {
93
+ type = 'wifi';
94
+ }
95
+ // Very slow connection with high latency = likely hotspot
96
+ else if (downlink > 0 && downlink < 1 && rtt > 300) {
97
+ type = 'hotspot';
98
+ }
99
+ // Medium speed with high latency = likely hotspot
100
+ else if (downlink >= 1 && downlink < 5 && rtt > 200) {
101
+ type = 'hotspot';
102
+ }
103
+ // Low speed with very high latency = likely hotspot
104
+ else if (downlink >= 1 && downlink < 3 && rtt > 250) {
105
+ type = 'hotspot';
106
+ }
107
+ // Otherwise, default to cellular for mobile devices
108
+ // But prefer WiFi if we have decent speed indicators
109
+ else if (downlink >= 3) {
110
+ type = 'wifi';
111
+ }
112
+ else {
113
+ type = 'cellular';
114
+ }
115
+ }
116
+ }
117
+ // Additional hotspot detection: Check for tethering indicators
118
+ // Some browsers expose this via connection.type = 'wifi' but with mobile-like characteristics
119
+ // Only override if we're very confident it's a hotspot (very slow + high latency)
120
+ if (type === 'wifi' && c.downlink && c.downlink < 2 && c.rtt && c.rtt > 250) {
121
+ const isMobileUA = /Android|iPhone|iPad|iPod/i.test(navigator.userAgent);
122
+ if (isMobileUA) {
123
+ // Mobile device with wifi but very slow connection + high latency = likely hotspot
124
+ type = 'hotspot';
125
+ }
126
+ }
127
+ return {
128
+ type,
129
+ effectiveType: c?.effectiveType,
130
+ downlink: c?.downlink,
131
+ rtt: c?.rtt,
132
+ saveData: c?.saveData,
133
+ connectionType: c?.type,
134
+ };
135
+ }
136
+ }
137
+
138
+ var networkDetector = /*#__PURE__*/Object.freeze({
139
+ __proto__: null,
140
+ NetworkDetector: NetworkDetector
141
+ });
142
+
143
+ /**
144
+ * Device Information Detector
145
+ * Detects device type, OS, browser, and hardware specs
146
+ */
147
+ class DeviceDetector {
148
+ static async getRealDeviceInfo() {
149
+ const nau = navigator.userAgentData;
150
+ if (nau?.getHighEntropyValues) {
151
+ try {
152
+ const v = await nau.getHighEntropyValues([
153
+ 'platform',
154
+ 'platformVersion',
155
+ 'model',
156
+ 'uaFullVersion',
157
+ 'brands',
158
+ ]);
159
+ return {
160
+ platform: v.platform || 'Unknown',
161
+ platformVersion: v.platformVersion || 'Unknown',
162
+ model: v.model || 'Unknown',
163
+ fullVersion: v.uaFullVersion || 'Unknown',
164
+ brands: v.brands || [],
165
+ };
166
+ }
167
+ catch {
168
+ // Fallback to UA parsing
169
+ }
170
+ }
171
+ const ua = navigator.userAgent;
172
+ let platform = 'Unknown', platformVersion = 'Unknown', model = 'Unknown';
173
+ if (/Android/i.test(ua)) {
174
+ platform = 'Android';
175
+ platformVersion = ua.match(/Android\s([\d.]+)/)?.[1] || 'Unknown';
176
+ const androidModelMatch = ua.match(/;\s*([^;)]+)\s*\)/);
177
+ if (androidModelMatch) {
178
+ const deviceStr = androidModelMatch[1];
179
+ if (deviceStr && deviceStr.length < 50) {
180
+ model = deviceStr.trim();
181
+ }
182
+ }
183
+ if (model === 'Unknown') {
184
+ const buildMatch = ua.match(/Build\/([A-Z0-9_-]+)/);
185
+ if (buildMatch) {
186
+ const codename = buildMatch[1].split('_')[0];
187
+ if (codename && codename.length > 2 && codename.length < 20) {
188
+ model = codename;
189
+ }
190
+ }
191
+ }
192
+ }
193
+ else if (/iPhone|iPad|iPod/i.test(ua)) {
194
+ platform = 'iOS';
195
+ const m = ua.match(/OS\s(\d+[._]\d+(?:[._]\d+)?)/);
196
+ platformVersion = m ? m[1].replace(/_/g, '.') : 'Unknown';
197
+ if (/iPad/i.test(ua)) {
198
+ if (/iPad13/i.test(ua))
199
+ model = 'iPad Pro 12.9" (5th gen)';
200
+ else if (/iPad14/i.test(ua))
201
+ model = 'iPad Pro 11" (3rd gen)';
202
+ else if (/iPad11/i.test(ua))
203
+ model = 'iPad (9th gen)';
204
+ else if (/iPad12/i.test(ua))
205
+ model = 'iPad mini (6th gen)';
206
+ else
207
+ model = 'iPad';
208
+ }
209
+ else if (/iPhone/i.test(ua)) {
210
+ if (/iPhone15/i.test(ua))
211
+ model = 'iPhone 15';
212
+ else if (/iPhone14/i.test(ua))
213
+ model = 'iPhone 14';
214
+ else if (/iPhone13/i.test(ua))
215
+ model = 'iPhone 13';
216
+ else if (/iPhone12/i.test(ua))
217
+ model = 'iPhone 12';
218
+ else if (/iPhone11/i.test(ua))
219
+ model = 'iPhone 11';
220
+ else {
221
+ const modelMatch = ua.match(/iPhone\s*OS\s+\d+[._]\d+[^;]*;\s*([^)]+)/);
222
+ model = modelMatch ? modelMatch[1].trim() : 'iPhone';
223
+ }
224
+ }
225
+ else {
226
+ model = 'iOS Device';
227
+ }
228
+ }
229
+ else if (/Mac OS X/i.test(ua)) {
230
+ platform = 'macOS';
231
+ const m = ua.match(/Mac OS X\s(\d+[._]\d+(?:[._]\d+)?)/);
232
+ platformVersion = m ? m[1].replace(/_/g, '.') : 'Unknown';
233
+ model = 'Mac';
234
+ }
235
+ else if (/Windows NT/i.test(ua)) {
236
+ platform = 'Windows';
237
+ if (/Windows NT 10\.0/i.test(ua))
238
+ platformVersion = '10/11';
239
+ else if (/Windows NT 6\.3/i.test(ua))
240
+ platformVersion = '8.1';
241
+ else if (/Windows NT 6\.2/i.test(ua))
242
+ platformVersion = '8';
243
+ else if (/Windows NT 6\.1/i.test(ua))
244
+ platformVersion = '7';
245
+ }
246
+ else if (/CrOS/i.test(ua)) {
247
+ platform = 'Chrome OS';
248
+ const versionMatch = ua.match(/CrOS\s+[^\s]+\s+(\d+\.\d+\.\d+)/);
249
+ platformVersion = versionMatch ? versionMatch[1] : 'Unknown';
250
+ }
251
+ else if (/Linux/i.test(ua)) {
252
+ platform = 'Linux';
253
+ if (/Ubuntu/i.test(ua)) {
254
+ platform = 'Ubuntu';
255
+ const ubuntuMatch = ua.match(/Ubuntu[/\s](\d+\.\d+)/);
256
+ platformVersion = ubuntuMatch ? ubuntuMatch[1] : 'Unknown';
257
+ }
258
+ }
259
+ return { platform, platformVersion, model, fullVersion: 'Unknown' };
260
+ }
261
+ static detectBrowser(ua) {
262
+ if (/Chrome/i.test(ua) && !/Edg|OPR/i.test(ua))
263
+ return {
264
+ browser: 'Chrome',
265
+ version: ua.match(/Chrome\/(\d+\.\d+)/)?.[1] || 'Unknown',
266
+ };
267
+ if (/Firefox/i.test(ua))
268
+ return {
269
+ browser: 'Firefox',
270
+ version: ua.match(/Firefox\/(\d+\.\d+)/)?.[1] || 'Unknown',
271
+ };
272
+ if (/Safari/i.test(ua) && !/Chrome/i.test(ua))
273
+ return {
274
+ browser: 'Safari',
275
+ version: ua.match(/Version\/(\d+\.\d+)/)?.[1] || 'Unknown',
276
+ };
277
+ if (/Edg/i.test(ua))
278
+ return {
279
+ browser: 'Edge',
280
+ version: ua.match(/Edg\/(\d+\.\d+)/)?.[1] || 'Unknown',
281
+ };
282
+ if (/OPR/i.test(ua))
283
+ return {
284
+ browser: 'Opera',
285
+ version: ua.match(/OPR\/(\d+\.\d+)/)?.[1] || 'Unknown',
286
+ };
287
+ return { browser: 'Unknown', version: 'Unknown' };
288
+ }
289
+ static async detect() {
290
+ if (typeof navigator === 'undefined') {
291
+ return this.getDefaultDeviceInfo();
292
+ }
293
+ const ua = navigator.userAgent;
294
+ const real = await this.getRealDeviceInfo();
295
+ // OS detection
296
+ let os = real.platform || 'Unknown';
297
+ let osVersion = real.platformVersion || 'Unknown';
298
+ if (/Android/i.test(ua)) {
299
+ os = 'Android';
300
+ osVersion =
301
+ real.platformVersion !== 'Unknown'
302
+ ? real.platformVersion
303
+ : ua.match(/Android\s([\d.]+)/)?.[1] || 'Unknown';
304
+ }
305
+ else if (/iPhone|iPad|iPod/i.test(ua)) {
306
+ os = 'iOS';
307
+ osVersion =
308
+ ua.match(/OS\s(\d+[._]\d+(?:[._]\d+)?)/)?.[1]?.replace(/_/g, '.') || 'Unknown';
309
+ }
310
+ else if (/Mac OS X/i.test(ua)) {
311
+ os = 'macOS';
312
+ osVersion =
313
+ real.platformVersion !== 'Unknown'
314
+ ? real.platformVersion
315
+ : ua.match(/Mac OS X\s(\d+[._]\d+(?:[._]\d+)?)/)?.[1]?.replace(/_/g, '.') ||
316
+ 'Unknown';
317
+ }
318
+ else if (/Windows NT 10/i.test(ua)) {
319
+ os = 'Windows';
320
+ osVersion = '10/11';
321
+ }
322
+ else if (/CrOS/i.test(ua)) {
323
+ os = 'Chrome OS';
324
+ }
325
+ else if (/Linux/i.test(ua)) {
326
+ os = 'Linux';
327
+ }
328
+ // Device type
329
+ const type = /Android|iPhone|iPod|BlackBerry|IEMobile|Opera Mini/i.test(ua)
330
+ ? 'mobile'
331
+ : /iPad|Tablet|PlayBook|Silk/i.test(ua)
332
+ ? 'tablet'
333
+ : 'desktop';
334
+ // Brand detection
335
+ const brand = this.detectBrand(ua);
336
+ // Browser detection
337
+ const { browser, version: browserVersion } = this.detectBrowser(ua);
338
+ // Model detection
339
+ let deviceModel = real.model !== 'Unknown' ? real.model : 'Unknown';
340
+ if (deviceModel === 'Unknown' || deviceModel === brand) {
341
+ if (/Android/i.test(ua)) {
342
+ const buildMatch = ua.match(/Build\/([A-Z0-9_-]+)/);
343
+ if (buildMatch) {
344
+ const codename = buildMatch[1].split('_')[0];
345
+ if (codename && codename.length > 2 && codename.length < 20) {
346
+ deviceModel = codename;
347
+ }
348
+ }
349
+ if (deviceModel === 'Unknown' || deviceModel === brand) {
350
+ const deviceMatch = ua.match(/;\s*([^;)]+)\s*\)/);
351
+ if (deviceMatch) {
352
+ const cleaned = deviceMatch[1]
353
+ .replace(/^Linux\s+/, '')
354
+ .replace(/^Android\s+/, '')
355
+ .replace(/\s+Build\/.*$/, '')
356
+ .trim();
357
+ if (cleaned && cleaned.length < 50) {
358
+ deviceModel = cleaned;
359
+ }
360
+ }
361
+ }
362
+ }
363
+ if ((deviceModel === 'Unknown' || deviceModel === brand) && /iPhone|iPad|iPod/i.test(ua)) {
364
+ const iosModelMatch = ua.match(/(iPhone|iPad|iPod)[\s\d,]+/);
365
+ if (iosModelMatch) {
366
+ deviceModel = iosModelMatch[0].trim();
367
+ }
368
+ }
369
+ }
370
+ if (deviceModel === 'Unknown') {
371
+ deviceModel = brand;
372
+ }
373
+ // CPU architecture
374
+ const cpuArchitecture = /ARM|ARM64|aarch64/i.test(ua)
375
+ ? 'ARM'
376
+ : /x64|WOW64|Win64|x86_64/i.test(ua)
377
+ ? 'x64'
378
+ : 'x86';
379
+ return {
380
+ type,
381
+ os,
382
+ osVersion,
383
+ browser,
384
+ browserVersion,
385
+ screenResolution: `${window.screen.width}x${window.screen.height}`,
386
+ deviceModel,
387
+ deviceBrand: brand,
388
+ language: navigator.language,
389
+ timezone: Intl.DateTimeFormat().resolvedOptions().timeZone,
390
+ userAgent: ua,
391
+ deviceMemory: navigator.deviceMemory,
392
+ hardwareConcurrency: navigator.hardwareConcurrency || 0,
393
+ touchSupport: 'ontouchstart' in window || navigator.maxTouchPoints > 0,
394
+ pixelRatio: window.devicePixelRatio || 1,
395
+ colorDepth: window.screen.colorDepth,
396
+ orientation: window.screen.orientation ? window.screen.orientation.type : 'unknown',
397
+ cpuArchitecture,
398
+ };
399
+ }
400
+ static detectBrand(ua) {
401
+ return /iPhone|iPad|iPod|Macintosh|Mac OS X/i.test(ua)
402
+ ? 'Apple'
403
+ : /Samsung|SM-|GT-|SCH-|SGH-|SHV-|SHM-|Galaxy|Note|S[0-9]+|A[0-9]+|J[0-9]+|M[0-9]+|F[0-9]+/i.test(ua)
404
+ ? 'Samsung'
405
+ : /Pixel|Nexus|Google|Chrome|Chromebook/i.test(ua)
406
+ ? 'Google'
407
+ : /OnePlus|ONEPLUS|OP[A-Z0-9]+/i.test(ua)
408
+ ? 'OnePlus'
409
+ : /Mi\s|Redmi|Xiaomi|POCO|MI\s|HM\s|M[0-9]+[A-Z]/i.test(ua)
410
+ ? 'Xiaomi'
411
+ : /Huawei|HWI-|HUAWEI|Honor|HONOR|ELE-|VOG-|LIO-|ANA-/i.test(ua)
412
+ ? 'Huawei'
413
+ : /Oppo|OPPO|CPH|OPD|OP[A-Z0-9]+|Reno|Find/i.test(ua)
414
+ ? 'Oppo'
415
+ : /Vivo|VIVO|V[0-9]+|Y[0-9]+|X[0-9]+/i.test(ua)
416
+ ? 'Vivo'
417
+ : /Motorola|Moto|XT[0-9]+|Moto\s/i.test(ua)
418
+ ? 'Motorola'
419
+ : /LG|LGE|LM-|LG-[A-Z0-9]+/i.test(ua)
420
+ ? 'LG'
421
+ : /Sony|Xperia|SO-|SOV|XQ-[A-Z0-9]+/i.test(ua)
422
+ ? 'Sony'
423
+ : /Nokia|TA-[0-9]+|Nokia\s/i.test(ua)
424
+ ? 'Nokia'
425
+ : /Realme|RMX|RM[A-Z0-9]+/i.test(ua)
426
+ ? 'Realme'
427
+ : /Infinix|Infinix\s|X[0-9]+/i.test(ua)
428
+ ? 'Infinix'
429
+ : /Tecno|TECNO|TECNO\s|T[A-Z0-9]+/i.test(ua)
430
+ ? 'Tecno'
431
+ : /Asus|ASUS|ZenFone|ROG/i.test(ua)
432
+ ? 'Asus'
433
+ : /Lenovo|ThinkPad|IdeaPad/i.test(ua)
434
+ ? 'Lenovo'
435
+ : /HP|Hewlett-Packard/i.test(ua)
436
+ ? 'HP'
437
+ : /Dell/i.test(ua)
438
+ ? 'Dell'
439
+ : /Acer/i.test(ua)
440
+ ? 'Acer'
441
+ : /Microsoft|Surface/i.test(ua)
442
+ ? 'Microsoft'
443
+ : 'Unknown';
444
+ }
445
+ static getDefaultDeviceInfo() {
446
+ return {
447
+ type: 'desktop',
448
+ os: 'Unknown',
449
+ osVersion: 'Unknown',
450
+ browser: 'Unknown',
451
+ browserVersion: 'Unknown',
452
+ screenResolution: 'Unknown',
453
+ deviceModel: 'Unknown',
454
+ deviceBrand: 'Unknown',
455
+ language: 'en-US',
456
+ timezone: 'UTC',
457
+ userAgent: 'Server',
458
+ touchSupport: false,
459
+ pixelRatio: 1,
460
+ colorDepth: 24,
461
+ orientation: 'unknown',
462
+ cpuArchitecture: 'x86',
463
+ hardwareConcurrency: 0,
464
+ };
465
+ }
466
+ }
467
+
468
+ var deviceDetector = /*#__PURE__*/Object.freeze({
469
+ __proto__: null,
470
+ DeviceDetector: DeviceDetector
471
+ });
472
+
473
+ /**
474
+ * Location Consent Manager
475
+ * When user enters MSISDN, they implicitly consent to location tracking
476
+ * This utility manages the consent state and prevents unnecessary permission prompts
477
+ */
478
+ const LOCATION_CONSENT_KEY = 'analytics:locationConsent';
479
+ const LOCATION_CONSENT_TIMESTAMP_KEY = 'analytics:locationConsentTimestamp';
480
+ /**
481
+ * Set location consent as granted (when MSISDN is provided)
482
+ */
483
+ function setLocationConsentGranted() {
484
+ if (typeof window === 'undefined')
485
+ return;
486
+ try {
487
+ const timestamp = new Date().toISOString();
488
+ localStorage.setItem(LOCATION_CONSENT_KEY, 'granted');
489
+ localStorage.setItem(LOCATION_CONSENT_TIMESTAMP_KEY, timestamp);
490
+ console.log('[Location Consent] Granted at:', timestamp);
491
+ }
492
+ catch (error) {
493
+ console.warn('[Location Consent] Failed to save consent:', error);
494
+ }
495
+ }
496
+ /**
497
+ * Check if location consent has been granted
498
+ */
499
+ function hasLocationConsent() {
500
+ if (typeof window === 'undefined')
501
+ return false;
502
+ try {
503
+ const consent = localStorage.getItem(LOCATION_CONSENT_KEY);
504
+ return consent === 'granted';
505
+ }
506
+ catch (error) {
507
+ console.warn('[Location Consent] Failed to check consent:', error);
508
+ return false;
509
+ }
510
+ }
511
+ /**
512
+ * Get location consent timestamp
513
+ */
514
+ function getLocationConsentTimestamp() {
515
+ if (typeof window === 'undefined')
516
+ return null;
517
+ try {
518
+ return localStorage.getItem(LOCATION_CONSENT_TIMESTAMP_KEY);
519
+ }
520
+ catch (_error) {
521
+ return null;
522
+ }
523
+ }
524
+ /**
525
+ * Clear location consent (for testing or user revocation)
526
+ */
527
+ function clearLocationConsent() {
528
+ if (typeof window === 'undefined')
529
+ return;
530
+ try {
531
+ localStorage.removeItem(LOCATION_CONSENT_KEY);
532
+ localStorage.removeItem(LOCATION_CONSENT_TIMESTAMP_KEY);
533
+ console.log('[Location Consent] Cleared');
534
+ }
535
+ catch (error) {
536
+ console.warn('[Location Consent] Failed to clear consent:', error);
537
+ }
538
+ }
539
+ /**
540
+ * Check if MSISDN is provided and set consent accordingly
541
+ * Call this whenever MSISDN is detected
542
+ */
543
+ function checkAndSetLocationConsent(msisdn) {
544
+ if (msisdn && typeof msisdn === 'string' && msisdn.trim().length > 0) {
545
+ // User has provided MSISDN, which means they consent to location tracking
546
+ setLocationConsentGranted();
547
+ return true;
548
+ }
549
+ return false;
550
+ }
551
+
552
+ /**
553
+ * Location Detector
554
+ * Detects GPS location with consent management, falls back to IP-based location API
555
+ * IP-based location works automatically without user permission
556
+ */
557
+ class LocationDetector {
558
+ /**
559
+ * Detect location using IP-based API only (no GPS, no permission needed)
560
+ * Fast and automatic - works immediately without user interaction
561
+ */
562
+ static async detectIPOnly() {
563
+ // Return cached IP location if available
564
+ if (this.lastIPLocationRef.current) {
565
+ return this.lastIPLocationRef.current;
566
+ }
567
+ // Get IP-based location (no permission required)
568
+ const ipLocation = await this.getIPBasedLocation();
569
+ this.lastLocationRef.current = ipLocation;
570
+ return ipLocation;
571
+ }
572
+ /**
573
+ * Detect location with automatic consent granted
574
+ * Tries GPS first (if available), then falls back to IP-based location
575
+ * Automatically sets location consent to bypass permission checks
576
+ */
577
+ static async detectWithAutoConsent() {
578
+ // Automatically grant location consent
579
+ setLocationConsentGranted();
580
+ // Clear cache to force fresh detection
581
+ this.lastLocationRef.current = null;
582
+ // Now detect with consent granted
583
+ return this.detect();
584
+ }
585
+ /**
586
+ * Get browser GPS location
587
+ * Respects location consent (set via MSISDN entry)
588
+ * Falls back to IP-based location automatically if GPS fails
589
+ */
590
+ static async detect() {
591
+ // Check if user has granted location consent via MSISDN entry
592
+ const userHasConsent = hasLocationConsent();
593
+ if (this.lastLocationRef.current &&
594
+ userHasConsent &&
595
+ this.lastLocationRef.current.permission !== 'granted') {
596
+ // Consent was granted but cached location has wrong permission - clear cache
597
+ console.log('[Location] Consent detected but cache has wrong permission - clearing cache');
598
+ this.lastLocationRef.current = null;
599
+ }
600
+ // Return cached location if available and permission matches consent status
601
+ if (this.lastLocationRef.current) {
602
+ // If we have consent, ensure cached location reflects it
603
+ if (userHasConsent && this.lastLocationRef.current.permission !== 'granted') {
604
+ // Update cached location to reflect consent
605
+ this.lastLocationRef.current = {
606
+ ...this.lastLocationRef.current,
607
+ permission: 'granted',
608
+ };
609
+ }
610
+ return this.lastLocationRef.current;
611
+ }
612
+ // Prevent multiple simultaneous location requests
613
+ if (this.locationFetchingRef.current) {
614
+ // Return a default promise that will resolve when current fetch completes
615
+ return new Promise((resolve) => {
616
+ const checkInterval = setInterval(() => {
617
+ if (this.lastLocationRef.current) {
618
+ clearInterval(checkInterval);
619
+ resolve(this.lastLocationRef.current);
620
+ }
621
+ else if (!this.locationFetchingRef.current) {
622
+ clearInterval(checkInterval);
623
+ resolve({
624
+ source: 'unknown',
625
+ permission: userHasConsent ? 'granted' : 'prompt',
626
+ });
627
+ }
628
+ }, 50);
629
+ });
630
+ }
631
+ if (typeof navigator === 'undefined' || !('geolocation' in navigator)) {
632
+ // GPS not supported, try IP-based location as fallback
633
+ console.log('[Location] GPS not supported, using IP-based location API...');
634
+ try {
635
+ const ipLocation = await this.getIPBasedLocation();
636
+ this.lastLocationRef.current = ipLocation;
637
+ return ipLocation;
638
+ }
639
+ catch (_ipError) {
640
+ const unsupportedResult = {
641
+ source: 'unknown',
642
+ permission: 'unsupported',
643
+ };
644
+ this.lastLocationRef.current = unsupportedResult;
645
+ return unsupportedResult;
646
+ }
647
+ }
648
+ // Helper with timeout so we never block forever
649
+ // Reduced timeout to 2 seconds for faster fallback to IP
650
+ const withTimeout = (p, ms = 2000) => new Promise((resolve) => {
651
+ let settled = false;
652
+ const t = setTimeout(async () => {
653
+ if (!settled) {
654
+ settled = true;
655
+ // If GPS times out, fallback to IP-based location immediately
656
+ console.log('[Location] GPS timeout, falling back to IP-based location API...');
657
+ try {
658
+ const ipLocation = await this.getIPBasedLocation();
659
+ resolve(ipLocation);
660
+ }
661
+ catch (_ipError) {
662
+ resolve({ source: 'unknown', permission: userHasConsent ? 'granted' : 'prompt' });
663
+ }
664
+ }
665
+ }, ms);
666
+ p.then((v) => {
667
+ if (!settled) {
668
+ settled = true;
669
+ clearTimeout(t);
670
+ resolve(v);
671
+ }
672
+ }).catch(async () => {
673
+ if (!settled) {
674
+ settled = true;
675
+ clearTimeout(t);
676
+ // If GPS fails, fallback to IP-based location
677
+ try {
678
+ const ipLocation = await this.getIPBasedLocation();
679
+ resolve(ipLocation);
680
+ }
681
+ catch (_ipError) {
682
+ resolve({ source: 'unknown', permission: userHasConsent ? 'granted' : 'prompt' });
683
+ }
684
+ }
685
+ });
686
+ });
687
+ const ask = new Promise((resolve) => {
688
+ // If user has consented (via MSISDN entry), treat as granted
689
+ if (userHasConsent) {
690
+ // Only log once to prevent console spam
691
+ if (!this.locationConsentLoggedRef.current) {
692
+ this.locationConsentLoggedRef.current = true;
693
+ console.log('[Location] Consent granted via MSISDN entry, requesting location...');
694
+ }
695
+ this.locationFetchingRef.current = true;
696
+ navigator.geolocation.getCurrentPosition((pos) => {
697
+ this.locationFetchingRef.current = false;
698
+ const locationResult = {
699
+ lat: pos.coords.latitude,
700
+ lon: pos.coords.longitude,
701
+ accuracy: isFinite(pos.coords.accuracy) ? pos.coords.accuracy : null,
702
+ permission: 'granted',
703
+ source: 'gps',
704
+ ts: new Date(pos.timestamp || Date.now()).toISOString(),
705
+ };
706
+ console.log('[Location] GPS coordinates obtained:', {
707
+ lat: locationResult.lat,
708
+ lon: locationResult.lon,
709
+ });
710
+ this.lastLocationRef.current = locationResult;
711
+ resolve(locationResult);
712
+ }, (error) => {
713
+ this.locationFetchingRef.current = false;
714
+ // Log the error to understand why GPS failed
715
+ console.warn('[Location] GPS failed:', {
716
+ code: error.code,
717
+ message: error.message,
718
+ codeMeaning: error.code === 1
719
+ ? 'PERMISSION_DENIED'
720
+ : error.code === 2
721
+ ? 'POSITION_UNAVAILABLE'
722
+ : error.code === 3
723
+ ? 'TIMEOUT'
724
+ : 'UNKNOWN',
725
+ });
726
+ // Fallback to IP-based location when GPS fails
727
+ console.log('[Location] Falling back to IP-based location API...');
728
+ this.getIPBasedLocation()
729
+ .then((ipLocation) => {
730
+ this.lastLocationRef.current = ipLocation;
731
+ resolve(ipLocation);
732
+ })
733
+ .catch((_ipError) => {
734
+ // Even if IP location fails, we still have consent
735
+ const locationResult = {
736
+ permission: 'granted',
737
+ source: 'unknown',
738
+ ts: new Date().toISOString(),
739
+ };
740
+ this.lastLocationRef.current = locationResult;
741
+ resolve(locationResult);
742
+ });
743
+ }, {
744
+ enableHighAccuracy: false,
745
+ timeout: 2000, // Reduced to 2 seconds for faster fallback to IP
746
+ maximumAge: 60000, // Cache for 60 seconds
747
+ });
748
+ return;
749
+ }
750
+ // No consent yet - use IP-based location as primary (no permission needed)
751
+ // This provides automatic location without user interaction
752
+ console.log('[Location] No consent granted, using IP-based location (automatic, no permission needed)...');
753
+ this.locationFetchingRef.current = true;
754
+ // Use IP-based location (no permission needed, works automatically)
755
+ this.getIPBasedLocation()
756
+ .then((ipLocation) => {
757
+ this.locationFetchingRef.current = false;
758
+ this.lastLocationRef.current = ipLocation;
759
+ resolve(ipLocation);
760
+ })
761
+ .catch((_ipError) => {
762
+ this.locationFetchingRef.current = false;
763
+ // If IP fails, try GPS as last resort (but it will likely prompt)
764
+ navigator.permissions?.query({ name: 'geolocation' })
765
+ .then((perm) => {
766
+ const base = {
767
+ permission: perm?.state || 'prompt',
768
+ };
769
+ navigator.geolocation.getCurrentPosition((pos) => {
770
+ this.locationFetchingRef.current = false;
771
+ const locationResult = {
772
+ lat: pos.coords.latitude,
773
+ lon: pos.coords.longitude,
774
+ accuracy: isFinite(pos.coords.accuracy) ? pos.coords.accuracy : null,
775
+ permission: 'granted',
776
+ source: 'gps',
777
+ ts: new Date(pos.timestamp || Date.now()).toISOString(),
778
+ };
779
+ this.lastLocationRef.current = locationResult;
780
+ resolve(locationResult);
781
+ }, () => {
782
+ this.locationFetchingRef.current = false;
783
+ // Both IP and GPS failed
784
+ const locationResult = {
785
+ ...base,
786
+ source: 'unknown',
787
+ ts: new Date().toISOString(),
788
+ };
789
+ this.lastLocationRef.current = locationResult;
790
+ resolve(locationResult);
791
+ }, {
792
+ enableHighAccuracy: false,
793
+ timeout: 2000,
794
+ maximumAge: 60000,
795
+ });
796
+ })
797
+ .catch(() => {
798
+ // Permissions API not available; GPS failed, return unknown
799
+ this.locationFetchingRef.current = false;
800
+ const locationResult = {
801
+ source: 'unknown',
802
+ permission: 'prompt',
803
+ ts: new Date().toISOString(),
804
+ };
805
+ this.lastLocationRef.current = locationResult;
806
+ resolve(locationResult);
807
+ });
808
+ });
809
+ });
810
+ // Reduced overall timeout to 2 seconds for faster IP fallback
811
+ return withTimeout(ask, 2000);
812
+ }
813
+ /**
814
+ * Get location from IP-based public API (client-side)
815
+ * Works without user permission, good fallback when GPS is unavailable
816
+ * Uses ip-api.com free tier (no API key required, 45 requests/minute)
817
+ */
818
+ static async getIPBasedLocation() {
819
+ // Return cached IP location if available
820
+ if (this.lastIPLocationRef.current) {
821
+ return this.lastIPLocationRef.current;
822
+ }
823
+ // Prevent multiple simultaneous requests
824
+ if (this.ipLocationFetchingRef.current) {
825
+ return new Promise((resolve) => {
826
+ const checkInterval = setInterval(() => {
827
+ if (this.lastIPLocationRef.current) {
828
+ clearInterval(checkInterval);
829
+ resolve(this.lastIPLocationRef.current);
830
+ }
831
+ else if (!this.ipLocationFetchingRef.current) {
832
+ clearInterval(checkInterval);
833
+ resolve({
834
+ source: 'unknown',
835
+ permission: 'granted',
836
+ });
837
+ }
838
+ }, 50);
839
+ });
840
+ }
841
+ // Skip if we're in an environment without fetch (SSR)
842
+ if (typeof fetch === 'undefined') {
843
+ const fallback = {
844
+ source: 'unknown',
845
+ permission: 'unsupported',
846
+ };
847
+ this.lastIPLocationRef.current = fallback;
848
+ return fallback;
849
+ }
850
+ this.ipLocationFetchingRef.current = true;
851
+ try {
852
+ // Call ip-api.com without IP parameter - it auto-detects user's IP
853
+ // Using HTTPS endpoint for better security
854
+ const response = await fetch('https://ip-api.com/json/?fields=status,message,country,countryCode,region,regionName,city,lat,lon,timezone,query', {
855
+ method: 'GET',
856
+ headers: {
857
+ Accept: 'application/json',
858
+ },
859
+ // Add timeout to prevent hanging
860
+ signal: AbortSignal.timeout(5000),
861
+ });
862
+ if (!response.ok) {
863
+ throw new Error(`HTTP ${response.status}`);
864
+ }
865
+ const data = await response.json();
866
+ // ip-api.com returns status field
867
+ if (data.status === 'fail') {
868
+ console.warn(`[Location] IP API error: ${data.message}`);
869
+ const fallback = {
870
+ source: 'unknown',
871
+ permission: 'granted',
872
+ };
873
+ this.lastIPLocationRef.current = fallback;
874
+ return fallback;
875
+ }
876
+ // Convert IP location to LocationInfo format
877
+ const locationResult = {
878
+ lat: data.lat || null,
879
+ lon: data.lon || null,
880
+ accuracy: null, // IP-based location has no accuracy metric
881
+ permission: 'granted', // IP location doesn't require permission
882
+ source: 'ip',
883
+ ts: new Date().toISOString(),
884
+ ip: data.query || null, // Public IP address
885
+ country: data.country || undefined,
886
+ countryCode: data.countryCode || undefined,
887
+ city: data.city || undefined,
888
+ region: data.regionName || data.region || undefined,
889
+ timezone: data.timezone || undefined,
890
+ };
891
+ console.log('[Location] IP-based location obtained:', {
892
+ ip: locationResult.ip,
893
+ lat: locationResult.lat,
894
+ lon: locationResult.lon,
895
+ city: locationResult.city,
896
+ country: locationResult.country,
897
+ });
898
+ this.lastIPLocationRef.current = locationResult;
899
+ return locationResult;
900
+ }
901
+ catch (error) {
902
+ // Silently fail - don't break user experience
903
+ if (error.name !== 'AbortError') {
904
+ console.warn('[Location] IP-based location fetch failed:', error.message);
905
+ }
906
+ const fallback = {
907
+ source: 'unknown',
908
+ permission: 'granted',
909
+ };
910
+ this.lastIPLocationRef.current = fallback;
911
+ return fallback;
912
+ }
913
+ finally {
914
+ this.ipLocationFetchingRef.current = false;
915
+ }
916
+ }
917
+ /**
918
+ * Clear location cache (useful when consent is granted)
919
+ */
920
+ static clearCache() {
921
+ this.lastLocationRef.current = null;
922
+ this.lastIPLocationRef.current = null;
923
+ this.locationFetchingRef.current = false;
924
+ this.ipLocationFetchingRef.current = false;
925
+ this.locationConsentLoggedRef.current = false;
926
+ console.log('[Location] Cache cleared - will re-fetch with consent');
927
+ }
928
+ }
929
+ LocationDetector.locationFetchingRef = { current: false };
930
+ LocationDetector.lastLocationRef = { current: null };
931
+ LocationDetector.locationConsentLoggedRef = { current: false };
932
+ LocationDetector.ipLocationFetchingRef = { current: false };
933
+ LocationDetector.lastIPLocationRef = { current: null };
934
+
935
+ var locationDetector = /*#__PURE__*/Object.freeze({
936
+ __proto__: null,
937
+ LocationDetector: LocationDetector
938
+ });
939
+
940
+ /**
941
+ * Storage utilities for analytics tracking
942
+ */
943
+ const loadJSON = (key) => {
944
+ if (typeof window === 'undefined')
945
+ return null;
946
+ try {
947
+ const s = localStorage.getItem(key);
948
+ return s ? JSON.parse(s) : null;
949
+ }
950
+ catch {
951
+ return null;
952
+ }
953
+ };
954
+ const saveJSON = (key, obj) => {
955
+ if (typeof window === 'undefined')
956
+ return;
957
+ try {
958
+ localStorage.setItem(key, JSON.stringify(obj));
959
+ }
960
+ catch {
961
+ // Silently fail if storage is unavailable
962
+ }
963
+ };
964
+ const loadSessionJSON = (key) => {
965
+ if (typeof window === 'undefined')
966
+ return null;
967
+ try {
968
+ const s = sessionStorage.getItem(key);
969
+ return s ? JSON.parse(s) : null;
970
+ }
971
+ catch {
972
+ return null;
973
+ }
974
+ };
975
+ const saveSessionJSON = (key, obj) => {
976
+ if (typeof window === 'undefined')
977
+ return;
978
+ try {
979
+ sessionStorage.setItem(key, JSON.stringify(obj));
980
+ }
981
+ catch {
982
+ // Silently fail if storage is unavailable
983
+ }
984
+ };
985
+ /**
986
+ * Generate or retrieve a user ID from localStorage
987
+ */
988
+ function getOrCreateUserId(length = 8) {
989
+ if (typeof window === 'undefined') {
990
+ return `server-${Date.now()}`;
991
+ }
992
+ const storageKey = 'analytics:userId';
993
+ let userId = localStorage.getItem(storageKey);
994
+ if (!userId) {
995
+ const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789';
996
+ let result = '';
997
+ for (let i = 0; i < length; i++) {
998
+ result += chars.charAt(Math.floor(Math.random() * chars.length));
999
+ }
1000
+ userId = result;
1001
+ localStorage.setItem(storageKey, userId);
1002
+ }
1003
+ return userId;
1004
+ }
1005
+ /**
1006
+ * Track page visits with localStorage
1007
+ */
1008
+ function trackPageVisit() {
1009
+ if (typeof window === 'undefined')
1010
+ return 1;
1011
+ const storedCount = localStorage.getItem('analytics:pageVisits');
1012
+ const newCount = storedCount ? parseInt(storedCount, 10) + 1 : 1;
1013
+ localStorage.setItem('analytics:pageVisits', newCount.toString());
1014
+ return newCount;
1015
+ }
1016
+
1017
+ var storage = /*#__PURE__*/Object.freeze({
1018
+ __proto__: null,
1019
+ getOrCreateUserId: getOrCreateUserId,
1020
+ loadJSON: loadJSON,
1021
+ loadSessionJSON: loadSessionJSON,
1022
+ saveJSON: saveJSON,
1023
+ saveSessionJSON: saveSessionJSON,
1024
+ trackPageVisit: trackPageVisit
1025
+ });
1026
+
1027
+ const UTM_KEYS = [
1028
+ 'utm_source',
1029
+ 'utm_medium',
1030
+ 'utm_campaign',
1031
+ 'utm_term',
1032
+ 'utm_content',
1033
+ 'gclid',
1034
+ 'fbclid',
1035
+ 'ttclid',
1036
+ 'msclkid',
1037
+ 'dmclid',
1038
+ ];
1039
+ const FIRST_TOUCH_KEY = 'analytics:firstTouch';
1040
+ const LAST_TOUCH_KEY = 'analytics:lastTouch';
1041
+ const SESSION_START_KEY = 'analytics:sessionStart';
1042
+ function pickUtm(url) {
1043
+ const out = {
1044
+ utm_source: null,
1045
+ utm_medium: null,
1046
+ utm_campaign: null,
1047
+ utm_term: null,
1048
+ utm_content: null,
1049
+ gclid: null,
1050
+ fbclid: null,
1051
+ ttclid: null,
1052
+ msclkid: null,
1053
+ dmclid: null,
1054
+ };
1055
+ UTM_KEYS.forEach((k) => {
1056
+ const v = url.searchParams.get(k);
1057
+ if (v)
1058
+ out[k] = v;
1059
+ });
1060
+ return out;
1061
+ }
1062
+ function anyCampaignParams(utm) {
1063
+ return UTM_KEYS.some((k) => !!utm[k]);
1064
+ }
1065
+ function getReferrerDomain(ref) {
1066
+ if (!ref)
1067
+ return null;
1068
+ try {
1069
+ return new URL(ref).hostname;
1070
+ }
1071
+ catch {
1072
+ return null;
1073
+ }
1074
+ }
1075
+ function getNavigationType() {
1076
+ if (typeof window === 'undefined' || typeof performance === 'undefined') {
1077
+ return { type: 'unknown', isReload: false, isBackForward: false };
1078
+ }
1079
+ const entries = performance.getEntriesByType('navigation');
1080
+ const navType = entries?.[0]?.type || 'navigate';
1081
+ const typeMap = {
1082
+ navigate: 'navigate',
1083
+ reload: 'reload',
1084
+ back_forward: 'back_forward',
1085
+ prerender: 'prerender',
1086
+ };
1087
+ const type = typeMap[navType] ?? 'unknown';
1088
+ return {
1089
+ type,
1090
+ isReload: type === 'reload',
1091
+ isBackForward: type === 'back_forward',
1092
+ };
1093
+ }
1094
+ /**
1095
+ * Attribution Detector
1096
+ * Detects UTM parameters, referrer, navigation type, and tracks first/last touch
1097
+ */
1098
+ class AttributionDetector {
1099
+ static detect() {
1100
+ if (typeof window === 'undefined') {
1101
+ return this.getDefaultAttribution();
1102
+ }
1103
+ const landingUrl = window.location.href;
1104
+ const url = new URL(landingUrl);
1105
+ const { type, isReload, isBackForward } = getNavigationType();
1106
+ const referrerUrl = document.referrer || null;
1107
+ const referrerDomain = getReferrerDomain(referrerUrl);
1108
+ const utm = pickUtm(url);
1109
+ // Per-tab session start
1110
+ const sessionStart = loadSessionJSON(SESSION_START_KEY) ||
1111
+ (() => {
1112
+ const ts = new Date().toISOString();
1113
+ saveSessionJSON(SESSION_START_KEY, ts);
1114
+ return ts;
1115
+ })();
1116
+ // First/last touch tracking
1117
+ const existingFirst = loadJSON(FIRST_TOUCH_KEY);
1118
+ if (!existingFirst && anyCampaignParams(utm)) {
1119
+ saveJSON(FIRST_TOUCH_KEY, { ...utm, referrerDomain, ts: new Date().toISOString() });
1120
+ }
1121
+ if (anyCampaignParams(utm)) {
1122
+ saveJSON(LAST_TOUCH_KEY, { ...utm, referrerDomain, ts: new Date().toISOString() });
1123
+ }
1124
+ const firstTouch = loadJSON(FIRST_TOUCH_KEY);
1125
+ const lastTouch = loadJSON(LAST_TOUCH_KEY);
1126
+ return {
1127
+ landingUrl,
1128
+ path: url.pathname + url.search,
1129
+ hostname: url.hostname,
1130
+ referrerUrl,
1131
+ referrerDomain,
1132
+ navigationType: type,
1133
+ isReload,
1134
+ isBackForward,
1135
+ ...utm,
1136
+ firstTouch,
1137
+ lastTouch,
1138
+ sessionStart,
1139
+ };
1140
+ }
1141
+ static getDefaultAttribution() {
1142
+ return {
1143
+ landingUrl: '',
1144
+ path: '',
1145
+ hostname: '',
1146
+ referrerUrl: null,
1147
+ referrerDomain: null,
1148
+ navigationType: 'unknown',
1149
+ isReload: false,
1150
+ isBackForward: false,
1151
+ utm_source: null,
1152
+ utm_medium: null,
1153
+ utm_campaign: null,
1154
+ utm_term: null,
1155
+ utm_content: null,
1156
+ gclid: null,
1157
+ fbclid: null,
1158
+ ttclid: null,
1159
+ msclkid: null,
1160
+ dmclid: null,
1161
+ firstTouch: null,
1162
+ lastTouch: null,
1163
+ sessionStart: null,
1164
+ };
1165
+ }
1166
+ }
1167
+
1168
+ var attributionDetector = /*#__PURE__*/Object.freeze({
1169
+ __proto__: null,
1170
+ AttributionDetector: AttributionDetector
1171
+ });
1172
+
1173
+ /**
1174
+ * Analytics Service
1175
+ * Sends analytics events to your backend API
1176
+ *
1177
+ * Supports both relative paths (e.g., '/api/analytics') and full URLs (e.g., 'https://your-server.com/api/analytics')
1178
+ */
1179
+ class AnalyticsService {
1180
+ /**
1181
+ * Configure the analytics API endpoint
1182
+ *
1183
+ * @param config - Configuration object
1184
+ * @param config.apiEndpoint - Your backend API endpoint URL
1185
+ * - Relative path: '/api/analytics' (sends to same domain)
1186
+ * - Full URL: 'https://your-server.com/api/analytics' (sends to your server)
1187
+ *
1188
+ * @example
1189
+ * ```typescript
1190
+ * // Use your own server
1191
+ * AnalyticsService.configure({
1192
+ * apiEndpoint: 'https://api.yourcompany.com/analytics'
1193
+ * });
1194
+ *
1195
+ * // Or use relative path (same domain)
1196
+ * AnalyticsService.configure({
1197
+ * apiEndpoint: '/api/analytics'
1198
+ * });
1199
+ * ```
1200
+ */
1201
+ static configure(config) {
1202
+ this.apiEndpoint = config.apiEndpoint;
1203
+ }
1204
+ /**
1205
+ * Generate a random event ID
1206
+ */
1207
+ static generateEventId() {
1208
+ const arr = new Uint32Array(4);
1209
+ if (typeof crypto !== 'undefined' && crypto.getRandomValues) {
1210
+ crypto.getRandomValues(arr);
1211
+ return Array.from(arr)
1212
+ .map((n) => n.toString(16))
1213
+ .join('');
1214
+ }
1215
+ // Fallback for environments without crypto
1216
+ return Math.random().toString(36).substring(2) + Date.now().toString(36);
1217
+ }
1218
+ /**
1219
+ * Track user journey/analytics event
1220
+ */
1221
+ static async trackEvent(event) {
1222
+ const payload = {
1223
+ ...event,
1224
+ timestamp: new Date(),
1225
+ eventId: this.generateEventId(),
1226
+ };
1227
+ try {
1228
+ const res = await fetch(this.apiEndpoint, {
1229
+ method: 'POST',
1230
+ headers: { 'Content-Type': 'application/json' },
1231
+ keepalive: true, // Allows sending during unload on some browsers
1232
+ body: JSON.stringify(payload),
1233
+ });
1234
+ if (!res.ok) {
1235
+ console.warn('[Analytics] Send failed:', await res.text());
1236
+ }
1237
+ else if (typeof process !== 'undefined' && process.env?.NODE_ENV === 'development') {
1238
+ console.log('[Analytics] Event sent successfully');
1239
+ }
1240
+ }
1241
+ catch (err) {
1242
+ // Don't break user experience - silently fail
1243
+ console.warn('[Analytics] Failed to send event:', err);
1244
+ }
1245
+ }
1246
+ /**
1247
+ * Track user journey with full context
1248
+ */
1249
+ static async trackUserJourney({ sessionId, pageUrl, networkInfo, deviceInfo, location, attribution, ipLocation, userId, customData, pageVisits = 1, interactions = 0, }) {
1250
+ await this.trackEvent({
1251
+ sessionId,
1252
+ pageUrl,
1253
+ networkInfo,
1254
+ deviceInfo,
1255
+ location,
1256
+ attribution,
1257
+ ipLocation,
1258
+ userId: userId ?? sessionId,
1259
+ customData: {
1260
+ ...customData,
1261
+ ...(ipLocation && { ipLocation }),
1262
+ },
1263
+ eventName: 'page_view', // Auto-tracked as page view
1264
+ });
1265
+ }
1266
+ /**
1267
+ * Track a custom event (Firebase/GA-style)
1268
+ * Automatically collects device, network, location context if available
1269
+ *
1270
+ * @param eventName - Name of the event (e.g., 'button_click', 'purchase', 'sign_up')
1271
+ * @param parameters - Event-specific parameters (optional)
1272
+ * @param context - Optional context override (auto-collected if not provided)
1273
+ *
1274
+ * @example
1275
+ * ```typescript
1276
+ * // Simple event tracking
1277
+ * AnalyticsService.logEvent('button_click', {
1278
+ * button_name: 'signup',
1279
+ * button_location: 'header'
1280
+ * });
1281
+ *
1282
+ * // Purchase event
1283
+ * AnalyticsService.logEvent('purchase', {
1284
+ * transaction_id: 'T12345',
1285
+ * value: 29.99,
1286
+ * currency: 'USD',
1287
+ * items: [{ id: 'item1', name: 'Product 1', price: 29.99 }]
1288
+ * });
1289
+ * ```
1290
+ */
1291
+ static async logEvent(eventName, parameters, context) {
1292
+ // Auto-collect context if not provided (requires dynamic imports)
1293
+ let autoContext = null;
1294
+ if (!context) {
1295
+ // Try to auto-collect context from window/global if available
1296
+ if (typeof window !== 'undefined') {
1297
+ try {
1298
+ // Import dynamically to avoid circular dependencies
1299
+ const { getOrCreateUserId } = await Promise.resolve().then(function () { return storage; });
1300
+ const { NetworkDetector } = await Promise.resolve().then(function () { return networkDetector; });
1301
+ const { DeviceDetector } = await Promise.resolve().then(function () { return deviceDetector; });
1302
+ const { LocationDetector } = await Promise.resolve().then(function () { return locationDetector; });
1303
+ const { AttributionDetector } = await Promise.resolve().then(function () { return attributionDetector; });
1304
+ autoContext = {
1305
+ sessionId: getOrCreateUserId(),
1306
+ pageUrl: window.location.href,
1307
+ networkInfo: NetworkDetector.detect(),
1308
+ deviceInfo: await DeviceDetector.detect(),
1309
+ location: await LocationDetector.detect().catch(() => undefined),
1310
+ attribution: AttributionDetector.detect(),
1311
+ };
1312
+ }
1313
+ catch (error) {
1314
+ // If auto-collection fails, use minimal context
1315
+ const { getOrCreateUserId } = await Promise.resolve().then(function () { return storage; });
1316
+ autoContext = {
1317
+ sessionId: getOrCreateUserId(),
1318
+ pageUrl: typeof window !== 'undefined' ? window.location.href : '',
1319
+ };
1320
+ }
1321
+ }
1322
+ else {
1323
+ // SSR environment - use minimal context
1324
+ autoContext = {
1325
+ sessionId: 'unknown',
1326
+ pageUrl: '',
1327
+ };
1328
+ }
1329
+ }
1330
+ const finalSessionId = context?.sessionId || autoContext?.sessionId || 'unknown';
1331
+ const finalPageUrl = context?.pageUrl || autoContext?.pageUrl || '';
1332
+ await this.trackEvent({
1333
+ sessionId: finalSessionId,
1334
+ pageUrl: finalPageUrl,
1335
+ networkInfo: context?.networkInfo || autoContext?.networkInfo,
1336
+ deviceInfo: context?.deviceInfo || autoContext?.deviceInfo,
1337
+ location: context?.location || autoContext?.location,
1338
+ attribution: context?.attribution || autoContext?.attribution,
1339
+ userId: context?.userId || finalSessionId,
1340
+ eventName,
1341
+ eventParameters: parameters || {},
1342
+ customData: parameters || {},
1343
+ });
1344
+ }
1345
+ /**
1346
+ * Track a page view event (Firebase/GA-style)
1347
+ * Automatically collects device, network, location context
1348
+ *
1349
+ * @param pageName - Optional page name (defaults to current URL pathname)
1350
+ * @param parameters - Optional page view parameters
1351
+ *
1352
+ * @example
1353
+ * ```typescript
1354
+ * // Track current page view
1355
+ * AnalyticsService.trackPageView();
1356
+ *
1357
+ * // Track with custom page name
1358
+ * AnalyticsService.trackPageView('/dashboard', {
1359
+ * page_title: 'Dashboard',
1360
+ * user_type: 'premium'
1361
+ * });
1362
+ * ```
1363
+ */
1364
+ static async trackPageView(pageName, parameters) {
1365
+ const page = pageName || (typeof window !== 'undefined' ? window.location.pathname : '');
1366
+ await this.logEvent('page_view', {
1367
+ page_name: page,
1368
+ page_title: typeof document !== 'undefined' ? document.title : undefined,
1369
+ ...parameters,
1370
+ });
1371
+ }
1372
+ }
1373
+ AnalyticsService.apiEndpoint = '/api/analytics';
1374
+
1375
+ /**
1376
+ * React Hook for Analytics Tracking
1377
+ * Provides device, network, location, and attribution data
1378
+ */
1379
+ /**
1380
+ * React hook for analytics tracking
1381
+ *
1382
+ * @example
1383
+ * ```tsx
1384
+ * const { sessionId, networkInfo, deviceInfo, logEvent } = useAnalytics({
1385
+ * autoSend: true,
1386
+ * config: { apiEndpoint: '/api/analytics' }
1387
+ * });
1388
+ * ```
1389
+ */
1390
+ function useAnalytics(options = {}) {
1391
+ const { autoSend = true, config, onReady } = options;
1392
+ // Configure analytics service if endpoint provided
1393
+ useEffect(() => {
1394
+ if (config?.apiEndpoint) {
1395
+ AnalyticsService.configure({ apiEndpoint: config.apiEndpoint });
1396
+ }
1397
+ }, [config?.apiEndpoint]);
1398
+ const [networkInfo, setNetworkInfo] = useState(null);
1399
+ const [deviceInfo, setDeviceInfo] = useState(null);
1400
+ const [attribution, setAttribution] = useState(null);
1401
+ const [sessionId, setSessionId] = useState(null);
1402
+ const [pageVisits, setPageVisits] = useState(1);
1403
+ const [interactions, setInteractions] = useState(0);
1404
+ const [location, setLocation] = useState(null);
1405
+ // Guards to prevent infinite loops
1406
+ const didInit = useRef(false);
1407
+ const sessionLoggedRef = useRef(false);
1408
+ const locationFetchingRef = useRef(false);
1409
+ const lastLocationRef = useRef(null);
1410
+ const locationConsentLoggedRef = useRef(false);
1411
+ // Expose function to clear location cache (for when consent is granted)
1412
+ useEffect(() => {
1413
+ if (typeof window !== 'undefined') {
1414
+ window.__clearLocationCache = () => {
1415
+ LocationDetector.clearCache();
1416
+ lastLocationRef.current = null;
1417
+ locationFetchingRef.current = false;
1418
+ locationConsentLoggedRef.current = false;
1419
+ };
1420
+ }
1421
+ }, []);
1422
+ const refresh = useCallback(async () => {
1423
+ const net = NetworkDetector.detect();
1424
+ const dev = await DeviceDetector.detect();
1425
+ const attr = AttributionDetector.detect();
1426
+ const uid = getOrCreateUserId();
1427
+ const pv = trackPageVisit();
1428
+ // Check consent status - if consent exists but cached location doesn't reflect it, re-fetch
1429
+ const hasConsent = hasLocationConsent();
1430
+ const shouldRefetchLocation = !lastLocationRef.current ||
1431
+ (hasConsent && lastLocationRef.current.permission !== 'granted');
1432
+ // Fetch location if needed
1433
+ let loc;
1434
+ if (!locationFetchingRef.current && shouldRefetchLocation) {
1435
+ locationFetchingRef.current = true;
1436
+ try {
1437
+ loc = await LocationDetector.detect();
1438
+ lastLocationRef.current = loc;
1439
+ // If we have consent, ensure location reflects it
1440
+ if (hasConsent && loc.permission !== 'granted') {
1441
+ loc = {
1442
+ ...loc,
1443
+ permission: 'granted',
1444
+ };
1445
+ lastLocationRef.current = loc;
1446
+ }
1447
+ }
1448
+ finally {
1449
+ locationFetchingRef.current = false;
1450
+ }
1451
+ }
1452
+ else {
1453
+ // Use cached location, but update permission if consent exists
1454
+ loc =
1455
+ lastLocationRef.current ||
1456
+ { source: 'unknown', permission: hasConsent ? 'granted' : 'prompt' };
1457
+ // If we have consent but cached location doesn't reflect it, update it
1458
+ if (hasConsent && loc.permission !== 'granted') {
1459
+ loc = {
1460
+ ...loc,
1461
+ permission: 'granted',
1462
+ };
1463
+ lastLocationRef.current = loc;
1464
+ }
1465
+ }
1466
+ setNetworkInfo(net);
1467
+ setDeviceInfo(dev);
1468
+ setAttribution(attr);
1469
+ setSessionId(uid);
1470
+ setPageVisits(pv);
1471
+ setLocation(loc);
1472
+ // Call onReady callback if provided
1473
+ if (onReady && !sessionLoggedRef.current) {
1474
+ onReady({
1475
+ sessionId: uid,
1476
+ networkInfo: net,
1477
+ deviceInfo: dev,
1478
+ location: loc,
1479
+ attribution: attr,
1480
+ });
1481
+ }
1482
+ return { net, dev, attr, loc };
1483
+ }, [onReady]);
1484
+ // Initialize on mount
1485
+ useEffect(() => {
1486
+ if (didInit.current)
1487
+ return;
1488
+ didInit.current = true;
1489
+ (async () => {
1490
+ const { net, dev, attr, loc } = await refresh();
1491
+ if (autoSend) {
1492
+ // Send after idle to not block paint
1493
+ const send = async () => {
1494
+ await AnalyticsService.trackUserJourney({
1495
+ sessionId: getOrCreateUserId(),
1496
+ pageUrl: typeof window !== 'undefined' ? window.location.href : '',
1497
+ networkInfo: net,
1498
+ deviceInfo: dev,
1499
+ location: loc,
1500
+ attribution: attr,
1501
+ customData: config?.enableLocation ? { locationEnabled: true } : undefined,
1502
+ });
1503
+ };
1504
+ if (typeof window !== 'undefined' && window.requestIdleCallback) {
1505
+ window.requestIdleCallback(send);
1506
+ }
1507
+ else {
1508
+ setTimeout(send, 0);
1509
+ }
1510
+ }
1511
+ })();
1512
+ }, [autoSend, refresh, config?.enableLocation]);
1513
+ const logEvent = useCallback(async (customData) => {
1514
+ if (!sessionId || !networkInfo || !deviceInfo)
1515
+ return;
1516
+ await AnalyticsService.trackUserJourney({
1517
+ sessionId,
1518
+ pageUrl: typeof window !== 'undefined' ? window.location.href : '',
1519
+ networkInfo,
1520
+ deviceInfo,
1521
+ location: location ?? undefined,
1522
+ attribution: attribution ?? undefined,
1523
+ userId: sessionId,
1524
+ customData,
1525
+ });
1526
+ setInteractions((prev) => prev + 1);
1527
+ }, [sessionId, networkInfo, deviceInfo, location, attribution]);
1528
+ /**
1529
+ * Track a custom event (Firebase/GA-style)
1530
+ * Automatically uses current session context
1531
+ *
1532
+ * @param eventName - Name of the event (e.g., 'button_click', 'purchase')
1533
+ * @param parameters - Event-specific parameters (optional)
1534
+ *
1535
+ * @example
1536
+ * ```tsx
1537
+ * const { trackEvent } = useAnalytics();
1538
+ *
1539
+ * // Track button click
1540
+ * trackEvent('button_click', {
1541
+ * button_name: 'signup',
1542
+ * button_location: 'header'
1543
+ * });
1544
+ *
1545
+ * // Track purchase
1546
+ * trackEvent('purchase', {
1547
+ * transaction_id: 'T12345',
1548
+ * value: 29.99,
1549
+ * currency: 'USD'
1550
+ * });
1551
+ * ```
1552
+ */
1553
+ const trackEvent = useCallback(async (eventName, parameters) => {
1554
+ // Wait for context to be available
1555
+ if (!sessionId || !networkInfo || !deviceInfo) {
1556
+ // If context not ready, still track but with auto-collected context
1557
+ await AnalyticsService.logEvent(eventName, parameters);
1558
+ return;
1559
+ }
1560
+ // Use hook context for more accurate tracking
1561
+ await AnalyticsService.logEvent(eventName, parameters, {
1562
+ sessionId,
1563
+ pageUrl: typeof window !== 'undefined' ? window.location.href : '',
1564
+ networkInfo,
1565
+ deviceInfo,
1566
+ location: location ?? undefined,
1567
+ attribution: attribution ?? undefined,
1568
+ userId: sessionId,
1569
+ });
1570
+ setInteractions((prev) => prev + 1);
1571
+ }, [sessionId, networkInfo, deviceInfo, location, attribution]);
1572
+ /**
1573
+ * Track a page view event (Firebase/GA-style)
1574
+ * Automatically uses current session context
1575
+ *
1576
+ * @param pageName - Optional page name (defaults to current pathname)
1577
+ * @param parameters - Optional page view parameters
1578
+ *
1579
+ * @example
1580
+ * ```tsx
1581
+ * const { trackPageView } = useAnalytics();
1582
+ *
1583
+ * // Track current page
1584
+ * trackPageView();
1585
+ *
1586
+ * // Track with custom name
1587
+ * trackPageView('/dashboard', {
1588
+ * page_title: 'Dashboard',
1589
+ * user_type: 'premium'
1590
+ * });
1591
+ * ```
1592
+ */
1593
+ const trackPageView = useCallback(async (pageName, parameters) => {
1594
+ // Wait for context to be available
1595
+ if (!sessionId || !networkInfo || !deviceInfo) {
1596
+ // If context not ready, still track but with auto-collected context
1597
+ await AnalyticsService.trackPageView(pageName, parameters);
1598
+ return;
1599
+ }
1600
+ // Use hook context for more accurate tracking
1601
+ const page = pageName || (typeof window !== 'undefined' ? window.location.pathname : '');
1602
+ await AnalyticsService.logEvent('page_view', {
1603
+ page_name: page,
1604
+ page_title: typeof document !== 'undefined' ? document.title : undefined,
1605
+ ...parameters,
1606
+ }, {
1607
+ sessionId,
1608
+ pageUrl: typeof window !== 'undefined' ? window.location.href : '',
1609
+ networkInfo,
1610
+ deviceInfo,
1611
+ location: location ?? undefined,
1612
+ attribution: attribution ?? undefined,
1613
+ userId: sessionId,
1614
+ });
1615
+ setInteractions((prev) => prev + 1);
1616
+ }, [sessionId, networkInfo, deviceInfo, location, attribution]);
1617
+ const incrementInteraction = useCallback(() => {
1618
+ setInteractions((n) => n + 1);
1619
+ }, []);
1620
+ return useMemo(() => ({
1621
+ sessionId,
1622
+ networkInfo,
1623
+ deviceInfo,
1624
+ location,
1625
+ attribution,
1626
+ pageVisits, // Used in return
1627
+ interactions, // Used in return
1628
+ logEvent,
1629
+ trackEvent,
1630
+ trackPageView,
1631
+ incrementInteraction,
1632
+ refresh,
1633
+ }), [
1634
+ sessionId,
1635
+ networkInfo,
1636
+ deviceInfo,
1637
+ location,
1638
+ attribution,
1639
+ pageVisits,
1640
+ interactions,
1641
+ logEvent,
1642
+ trackEvent,
1643
+ trackPageView,
1644
+ incrementInteraction,
1645
+ refresh,
1646
+ ]);
1647
+ }
1648
+
1649
+ /**
1650
+ * IP Geolocation Service
1651
+ * Fetches location data (country, region, city) from user's IP address
1652
+ * Uses free tier of ip-api.com (no API key required, 45 requests/minute)
1653
+ */
1654
+ /**
1655
+ * Get public IP address using ip-api.com
1656
+ * Free tier: 45 requests/minute, no API key required
1657
+ *
1658
+ * @returns Promise<string | null> - The public IP address, or null if unavailable
1659
+ *
1660
+ * @example
1661
+ * ```typescript
1662
+ * const ip = await getPublicIP();
1663
+ * console.log('Your IP:', ip); // e.g., "203.0.113.42"
1664
+ * ```
1665
+ */
1666
+ async function getPublicIP() {
1667
+ // Skip if we're in an environment without fetch (SSR)
1668
+ if (typeof fetch === 'undefined') {
1669
+ return null;
1670
+ }
1671
+ try {
1672
+ // Call ip-api.com without IP parameter - it auto-detects user's IP
1673
+ // Using HTTPS endpoint for better security
1674
+ const response = await fetch('https://ip-api.com/json/?fields=status,message,query', {
1675
+ method: 'GET',
1676
+ headers: {
1677
+ Accept: 'application/json',
1678
+ },
1679
+ // Add timeout to prevent hanging
1680
+ signal: AbortSignal.timeout(5000),
1681
+ });
1682
+ if (!response.ok) {
1683
+ return null;
1684
+ }
1685
+ const data = await response.json();
1686
+ // ip-api.com returns status field
1687
+ if (data.status === 'fail') {
1688
+ return null;
1689
+ }
1690
+ return data.query || null;
1691
+ }
1692
+ catch (error) {
1693
+ // Silently fail - don't break user experience
1694
+ if (error.name !== 'AbortError') {
1695
+ console.warn('[IP Geolocation] Error fetching public IP:', error.message);
1696
+ }
1697
+ return null;
1698
+ }
1699
+ }
1700
+ /**
1701
+ * Get location from IP address using ip-api.com
1702
+ * Free tier: 45 requests/minute, no API key required
1703
+ *
1704
+ * Alternative services:
1705
+ * - ipapi.co (requires API key for production)
1706
+ * - ipgeolocation.io (requires API key)
1707
+ * - ip-api.com (free tier available)
1708
+ */
1709
+ async function getIPLocation(ip) {
1710
+ // Skip localhost/private IPs (these can't be geolocated)
1711
+ if (!ip ||
1712
+ ip === '0.0.0.0' ||
1713
+ ip === '::1' ||
1714
+ ip.startsWith('127.') ||
1715
+ ip.startsWith('192.168.') ||
1716
+ ip.startsWith('10.') ||
1717
+ ip.startsWith('172.') ||
1718
+ ip.startsWith('::ffff:127.')) {
1719
+ console.log(`[IP Geolocation] Skipping localhost/private IP: ${ip} (geolocation not available for local IPs)`);
1720
+ return null;
1721
+ }
1722
+ try {
1723
+ // Using ip-api.com free tier (JSON format)
1724
+ const response = await fetch(`http://ip-api.com/json/${ip}?fields=status,message,country,countryCode,region,regionName,city,lat,lon,timezone,isp,org,as,query`, {
1725
+ method: 'GET',
1726
+ headers: {
1727
+ Accept: 'application/json',
1728
+ },
1729
+ // Add timeout to prevent hanging
1730
+ signal: AbortSignal.timeout(3000),
1731
+ });
1732
+ if (!response.ok) {
1733
+ console.warn(`[IP Geolocation] Failed to fetch location for IP ${ip}: ${response.status}`);
1734
+ return null;
1735
+ }
1736
+ const data = await response.json();
1737
+ // ip-api.com returns status field
1738
+ if (data.status === 'fail') {
1739
+ console.warn(`[IP Geolocation] API error for IP ${ip}: ${data.message}`);
1740
+ return null;
1741
+ }
1742
+ return {
1743
+ ip: data.query || ip,
1744
+ country: data.country || undefined,
1745
+ countryCode: data.countryCode || undefined,
1746
+ region: data.region || undefined,
1747
+ regionName: data.regionName || undefined,
1748
+ city: data.city || undefined,
1749
+ lat: data.lat || undefined,
1750
+ lon: data.lon || undefined,
1751
+ timezone: data.timezone || undefined,
1752
+ isp: data.isp || undefined,
1753
+ org: data.org || undefined,
1754
+ as: data.as || undefined,
1755
+ query: data.query || ip,
1756
+ };
1757
+ }
1758
+ catch (error) {
1759
+ // Silently fail - don't break user experience
1760
+ if (error.name !== 'AbortError') {
1761
+ console.warn(`[IP Geolocation] Error fetching location for IP ${ip}:`, error.message);
1762
+ }
1763
+ return null;
1764
+ }
1765
+ }
1766
+ /**
1767
+ * Get IP address from request headers
1768
+ * Handles various proxy headers (x-forwarded-for, x-real-ip, etc.)
1769
+ */
1770
+ function getIPFromRequest(req) {
1771
+ // Try various headers that proxies/load balancers use
1772
+ const forwardedFor = req.headers?.get?.('x-forwarded-for') ||
1773
+ req.headers?.['x-forwarded-for'] ||
1774
+ req.headers?.['X-Forwarded-For'];
1775
+ if (forwardedFor) {
1776
+ // x-forwarded-for can contain multiple IPs, take the first one
1777
+ const ips = forwardedFor.split(',').map((ip) => ip.trim());
1778
+ const ip = ips[0];
1779
+ if (ip && ip !== '0.0.0.0') {
1780
+ return ip;
1781
+ }
1782
+ }
1783
+ const realIP = req.headers?.get?.('x-real-ip') ||
1784
+ req.headers?.['x-real-ip'] ||
1785
+ req.headers?.['X-Real-IP'];
1786
+ if (realIP && realIP !== '0.0.0.0') {
1787
+ return realIP.trim();
1788
+ }
1789
+ // Try req.ip (from Express/Next.js)
1790
+ if (req.ip && req.ip !== '0.0.0.0') {
1791
+ return req.ip;
1792
+ }
1793
+ // For localhost, detect if we're running locally
1794
+ if (typeof window === 'undefined') {
1795
+ const hostname = req.headers?.get?.('host') || req.headers?.['host'];
1796
+ if (hostname &&
1797
+ (hostname.includes('localhost') ||
1798
+ hostname.includes('127.0.0.1') ||
1799
+ hostname.startsWith('192.168.'))) {
1800
+ return '127.0.0.1'; // Localhost IP
1801
+ }
1802
+ }
1803
+ // If no IP found and we're in development, return localhost
1804
+ if (typeof process !== 'undefined' && process.env?.NODE_ENV === 'development') {
1805
+ return '127.0.0.1'; // Localhost for development
1806
+ }
1807
+ return '0.0.0.0';
1808
+ }
1809
+
1810
+ export { AnalyticsService, AttributionDetector, DeviceDetector, LocationDetector, NetworkDetector, checkAndSetLocationConsent, clearLocationConsent, useAnalytics as default, getIPFromRequest, getIPLocation, getLocationConsentTimestamp, getOrCreateUserId, getPublicIP, hasLocationConsent, loadJSON, loadSessionJSON, saveJSON, saveSessionJSON, setLocationConsentGranted, trackPageVisit, useAnalytics };
1811
+ //# sourceMappingURL=index.esm.js.map