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