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.
- package/CHANGELOG.md +59 -0
- package/LICENSE +22 -0
- package/README.md +696 -0
- package/dist/index.cjs.js +1835 -0
- package/dist/index.cjs.js.map +1 -0
- package/dist/index.d.cts +415 -0
- package/dist/index.d.ts +415 -0
- package/dist/index.esm.js +1811 -0
- package/dist/index.esm.js.map +1 -0
- package/package.json +111 -0
|
@@ -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
|