user-analytics-tracker 2.0.0 → 2.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +31 -0
- package/README.md +11 -2
- package/dist/index.cjs.js +219 -192
- package/dist/index.cjs.js.map +1 -1
- package/dist/index.d.cts +47 -14
- package/dist/index.d.ts +47 -14
- package/dist/index.esm.js +219 -192
- package/dist/index.esm.js.map +1 -1
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,34 @@
|
|
|
1
|
+
# [2.1.0](https://github.com/switch-org/analytics-tracker/compare/v2.0.0...v2.1.0) (2025-12-29)
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
### Features
|
|
5
|
+
|
|
6
|
+
* migrate IP geolocation to ipwho.is API with dynamic key storage ([0605f72](https://github.com/switch-org/analytics-tracker/commit/0605f7207baa590b776a3b52cbc055e98a8a22e4))
|
|
7
|
+
|
|
8
|
+
# [2.1.0](https://github.com/switch-org/analytics-tracker/compare/v2.0.0...v2.1.0) (TBD)
|
|
9
|
+
|
|
10
|
+
### Features
|
|
11
|
+
|
|
12
|
+
* **IP Geolocation API Migration**: Migrated from ip-api.com to ipwho.is API for IP-based location tracking
|
|
13
|
+
- More comprehensive location data including continent, flag, connection details, and timezone information
|
|
14
|
+
- Dynamic key storage: All API response keys are automatically stored, including nested objects
|
|
15
|
+
- Future-proof: New fields added by the API are automatically captured without code changes
|
|
16
|
+
- No API key required: Free tier with no authentication needed
|
|
17
|
+
* **Enhanced IP Location Data**: The `IPLocation` interface now includes all fields from the ipwho.is API response
|
|
18
|
+
- New fields: `continent`, `continent_code`, `flag`, `connection`, `timezone` (with full details), `is_eu`, `postal`, `calling_code`, `capital`, `borders`
|
|
19
|
+
- Full backward compatibility: All existing fields (`lat`, `lon`, `countryCode`, etc.) remain available
|
|
20
|
+
- Access to full IP location data in analytics events via `ipLocationData` field
|
|
21
|
+
|
|
22
|
+
### Improvements
|
|
23
|
+
|
|
24
|
+
* Better IP location data coverage with additional metadata
|
|
25
|
+
* Automatic storage of all API response keys, including future additions
|
|
26
|
+
* Improved type definitions for IP location data
|
|
27
|
+
|
|
28
|
+
### Migration Notes
|
|
29
|
+
|
|
30
|
+
This upgrade is **fully backward compatible**. No code changes are required. See the [Upgrade Guide](./docs/upgrade-guide.md) for details on accessing new fields.
|
|
31
|
+
|
|
1
32
|
# [2.0.0](https://github.com/switch-org/analytics-tracker/compare/v1.7.0...v2.0.0) (2025-12-01)
|
|
2
33
|
|
|
3
34
|
|
package/README.md
CHANGED
|
@@ -15,7 +15,8 @@ A comprehensive, lightweight analytics tracking library for React applications.
|
|
|
15
15
|
- 📍 **Location Tracking**:
|
|
16
16
|
- **IP-based location** - Requires user consent (privacy-compliant)
|
|
17
17
|
- **GPS location** - Requires explicit user consent and browser permission
|
|
18
|
-
- Includes public IP address, country, city, region, timezone
|
|
18
|
+
- Includes public IP address, country, city, region, timezone, continent, flag, connection details
|
|
19
|
+
- Dynamic key storage: All IP location API fields are automatically captured
|
|
19
20
|
- Automatic fallback from GPS to IP when GPS unavailable
|
|
20
21
|
- Consent management utilities included
|
|
21
22
|
- 🎯 **Attribution Tracking**: UTM parameters, referrer tracking, first/last touch attribution
|
|
@@ -855,6 +856,14 @@ export async function POST(req: NextRequest) {
|
|
|
855
856
|
|
|
856
857
|
Comprehensive documentation is available in the [`docs/`](./docs) directory:
|
|
857
858
|
|
|
859
|
+
- **[Upgrade Guide](./docs/upgrade-guide.md)** - Step-by-step migration instructions for upgrading between versions
|
|
860
|
+
|
|
861
|
+
- **[Upgrade Guide](./docs/upgrade-guide.md)** - Step-by-step migration instructions for upgrading between versions
|
|
862
|
+
- Breaking changes and compatibility notes
|
|
863
|
+
- New features and improvements
|
|
864
|
+
- Migration examples
|
|
865
|
+
- Troubleshooting upgrade issues
|
|
866
|
+
|
|
858
867
|
- **[Usage Guide](./docs/usage-guide.md)** - Complete guide on how to use the package in your applications
|
|
859
868
|
- Installation instructions
|
|
860
869
|
- Basic and advanced usage examples
|
|
@@ -964,7 +973,7 @@ MIT © [Switch Org](https://github.com/switch-org)
|
|
|
964
973
|
|
|
965
974
|
## 🙏 Acknowledgments
|
|
966
975
|
|
|
967
|
-
- Uses [
|
|
976
|
+
- Uses [ipwho.is](https://ipwho.is/) for free IP geolocation
|
|
968
977
|
- Built with modern web APIs (User-Agent Client Hints, Network Information API, Geolocation API)
|
|
969
978
|
|
|
970
979
|
<!-- ## 📞 Support
|
package/dist/index.cjs.js
CHANGED
|
@@ -553,6 +553,191 @@ function checkAndSetLocationConsent(msisdn) {
|
|
|
553
553
|
return false;
|
|
554
554
|
}
|
|
555
555
|
|
|
556
|
+
/**
|
|
557
|
+
* IP Geolocation Service
|
|
558
|
+
* Fetches location data (country, region, city) from user's IP address
|
|
559
|
+
* Uses ipwho.is API (no API key required)
|
|
560
|
+
*
|
|
561
|
+
* Stores all keys dynamically from the API response, including nested objects
|
|
562
|
+
* This ensures we capture all available data and any new fields added by the API
|
|
563
|
+
*/
|
|
564
|
+
/**
|
|
565
|
+
* Get public IP address using ipwho.is API
|
|
566
|
+
* No API key required
|
|
567
|
+
*
|
|
568
|
+
* @returns Promise<string | null> - The public IP address, or null if unavailable
|
|
569
|
+
*
|
|
570
|
+
* @example
|
|
571
|
+
* ```typescript
|
|
572
|
+
* const ip = await getPublicIP();
|
|
573
|
+
* console.log('Your IP:', ip); // e.g., "203.0.113.42"
|
|
574
|
+
* ```
|
|
575
|
+
*/
|
|
576
|
+
async function getPublicIP() {
|
|
577
|
+
// Skip if we're in an environment without fetch (SSR)
|
|
578
|
+
if (typeof fetch === 'undefined') {
|
|
579
|
+
return null;
|
|
580
|
+
}
|
|
581
|
+
try {
|
|
582
|
+
// Call ipwho.is without IP parameter - it auto-detects user's IP
|
|
583
|
+
// Using HTTPS endpoint for better security
|
|
584
|
+
const response = await fetch('https://ipwho.is/', {
|
|
585
|
+
method: 'GET',
|
|
586
|
+
headers: {
|
|
587
|
+
Accept: 'application/json',
|
|
588
|
+
},
|
|
589
|
+
// Add timeout to prevent hanging
|
|
590
|
+
signal: AbortSignal.timeout(5000),
|
|
591
|
+
});
|
|
592
|
+
if (!response.ok) {
|
|
593
|
+
return null;
|
|
594
|
+
}
|
|
595
|
+
const data = await response.json();
|
|
596
|
+
// ipwho.is returns success field
|
|
597
|
+
if (data.success === false) {
|
|
598
|
+
return null;
|
|
599
|
+
}
|
|
600
|
+
return data.ip || null;
|
|
601
|
+
}
|
|
602
|
+
catch (error) {
|
|
603
|
+
// Silently fail - don't break user experience
|
|
604
|
+
if (error.name !== 'AbortError') {
|
|
605
|
+
console.warn('[IP Geolocation] Error fetching public IP:', error.message);
|
|
606
|
+
}
|
|
607
|
+
return null;
|
|
608
|
+
}
|
|
609
|
+
}
|
|
610
|
+
/**
|
|
611
|
+
* Get location from IP address using ipwho.is API
|
|
612
|
+
* Free tier: No API key required
|
|
613
|
+
*
|
|
614
|
+
* Stores all keys dynamically from the API response, including nested objects
|
|
615
|
+
* This ensures we capture all available data and any new fields added by the API
|
|
616
|
+
*/
|
|
617
|
+
async function getIPLocation(ip) {
|
|
618
|
+
// Skip localhost/private IPs (these can't be geolocated)
|
|
619
|
+
if (!ip ||
|
|
620
|
+
ip === '0.0.0.0' ||
|
|
621
|
+
ip === '::1' ||
|
|
622
|
+
ip.startsWith('127.') ||
|
|
623
|
+
ip.startsWith('192.168.') ||
|
|
624
|
+
ip.startsWith('10.') ||
|
|
625
|
+
ip.startsWith('172.') ||
|
|
626
|
+
ip.startsWith('::ffff:127.')) {
|
|
627
|
+
console.log(`[IP Geolocation] Skipping localhost/private IP: ${ip} (geolocation not available for local IPs)`);
|
|
628
|
+
return null;
|
|
629
|
+
}
|
|
630
|
+
try {
|
|
631
|
+
// Using ipwho.is API (no API key required)
|
|
632
|
+
const response = await fetch(`https://ipwho.is/${ip}`, {
|
|
633
|
+
method: 'GET',
|
|
634
|
+
headers: {
|
|
635
|
+
Accept: 'application/json',
|
|
636
|
+
},
|
|
637
|
+
// Add timeout to prevent hanging
|
|
638
|
+
signal: AbortSignal.timeout(5000),
|
|
639
|
+
});
|
|
640
|
+
if (!response.ok) {
|
|
641
|
+
console.warn(`[IP Geolocation] Failed to fetch location for IP ${ip}: ${response.status}`);
|
|
642
|
+
return null;
|
|
643
|
+
}
|
|
644
|
+
const data = await response.json();
|
|
645
|
+
// ipwho.is returns success field
|
|
646
|
+
if (data.success === false) {
|
|
647
|
+
console.warn(`[IP Geolocation] API error for IP ${ip}: ${data.message || 'Unknown error'}`);
|
|
648
|
+
return null;
|
|
649
|
+
}
|
|
650
|
+
// Store all keys dynamically from the response
|
|
651
|
+
// This ensures we capture all fields, including nested objects and any new fields
|
|
652
|
+
const locationData = {
|
|
653
|
+
ip: data.ip || ip,
|
|
654
|
+
// Map all fields from the API response dynamically
|
|
655
|
+
...Object.keys(data).reduce((acc, key) => {
|
|
656
|
+
// Store all keys and their values, preserving nested objects
|
|
657
|
+
acc[key] = data[key];
|
|
658
|
+
return acc;
|
|
659
|
+
}, {}),
|
|
660
|
+
};
|
|
661
|
+
// Add backward compatibility mappings for existing code
|
|
662
|
+
if (data.latitude !== undefined) {
|
|
663
|
+
locationData.lat = data.latitude;
|
|
664
|
+
}
|
|
665
|
+
if (data.longitude !== undefined) {
|
|
666
|
+
locationData.lon = data.longitude;
|
|
667
|
+
}
|
|
668
|
+
if (data.country_code !== undefined) {
|
|
669
|
+
locationData.countryCode = data.country_code;
|
|
670
|
+
}
|
|
671
|
+
if (data.region !== undefined) {
|
|
672
|
+
locationData.regionName = data.region;
|
|
673
|
+
}
|
|
674
|
+
if (data.connection?.isp !== undefined) {
|
|
675
|
+
locationData.isp = data.connection.isp;
|
|
676
|
+
}
|
|
677
|
+
if (data.connection?.org !== undefined) {
|
|
678
|
+
locationData.org = data.connection.org;
|
|
679
|
+
}
|
|
680
|
+
if (data.connection?.asn !== undefined) {
|
|
681
|
+
locationData.as = `AS${data.connection.asn}`;
|
|
682
|
+
}
|
|
683
|
+
if (data.timezone?.id !== undefined) {
|
|
684
|
+
locationData.timezone = data.timezone.id;
|
|
685
|
+
}
|
|
686
|
+
locationData.query = data.ip || ip;
|
|
687
|
+
return locationData;
|
|
688
|
+
}
|
|
689
|
+
catch (error) {
|
|
690
|
+
// Silently fail - don't break user experience
|
|
691
|
+
if (error.name !== 'AbortError') {
|
|
692
|
+
console.warn(`[IP Geolocation] Error fetching location for IP ${ip}:`, error.message);
|
|
693
|
+
}
|
|
694
|
+
return null;
|
|
695
|
+
}
|
|
696
|
+
}
|
|
697
|
+
/**
|
|
698
|
+
* Get IP address from request headers
|
|
699
|
+
* Handles various proxy headers (x-forwarded-for, x-real-ip, etc.)
|
|
700
|
+
*/
|
|
701
|
+
function getIPFromRequest(req) {
|
|
702
|
+
// Try various headers that proxies/load balancers use
|
|
703
|
+
const forwardedFor = req.headers?.get?.('x-forwarded-for') ||
|
|
704
|
+
req.headers?.['x-forwarded-for'] ||
|
|
705
|
+
req.headers?.['X-Forwarded-For'];
|
|
706
|
+
if (forwardedFor) {
|
|
707
|
+
// x-forwarded-for can contain multiple IPs, take the first one
|
|
708
|
+
const ips = forwardedFor.split(',').map((ip) => ip.trim());
|
|
709
|
+
const ip = ips[0];
|
|
710
|
+
if (ip && ip !== '0.0.0.0') {
|
|
711
|
+
return ip;
|
|
712
|
+
}
|
|
713
|
+
}
|
|
714
|
+
const realIP = req.headers?.get?.('x-real-ip') ||
|
|
715
|
+
req.headers?.['x-real-ip'] ||
|
|
716
|
+
req.headers?.['X-Real-IP'];
|
|
717
|
+
if (realIP && realIP !== '0.0.0.0') {
|
|
718
|
+
return realIP.trim();
|
|
719
|
+
}
|
|
720
|
+
// Try req.ip (from Express/Next.js)
|
|
721
|
+
if (req.ip && req.ip !== '0.0.0.0') {
|
|
722
|
+
return req.ip;
|
|
723
|
+
}
|
|
724
|
+
// For localhost, detect if we're running locally
|
|
725
|
+
if (typeof window === 'undefined') {
|
|
726
|
+
const hostname = req.headers?.get?.('host') || req.headers?.['host'];
|
|
727
|
+
if (hostname &&
|
|
728
|
+
(hostname.includes('localhost') ||
|
|
729
|
+
hostname.includes('127.0.0.1') ||
|
|
730
|
+
hostname.startsWith('192.168.'))) {
|
|
731
|
+
return '127.0.0.1'; // Localhost IP
|
|
732
|
+
}
|
|
733
|
+
}
|
|
734
|
+
// If no IP found and we're in development, return localhost
|
|
735
|
+
if (typeof process !== 'undefined' && process.env?.NODE_ENV === 'development') {
|
|
736
|
+
return '127.0.0.1'; // Localhost for development
|
|
737
|
+
}
|
|
738
|
+
return '0.0.0.0';
|
|
739
|
+
}
|
|
740
|
+
|
|
556
741
|
/**
|
|
557
742
|
* Location Detector
|
|
558
743
|
* Detects GPS location with consent management, falls back to IP-based location API
|
|
@@ -817,7 +1002,8 @@ class LocationDetector {
|
|
|
817
1002
|
/**
|
|
818
1003
|
* Get location from IP-based public API (client-side)
|
|
819
1004
|
* Works without user permission, good fallback when GPS is unavailable
|
|
820
|
-
* Uses
|
|
1005
|
+
* Uses ipwho.is API (no API key required)
|
|
1006
|
+
* Stores all keys dynamically from the API response
|
|
821
1007
|
*/
|
|
822
1008
|
static async getIPBasedLocation() {
|
|
823
1009
|
// Return cached IP location if available
|
|
@@ -853,51 +1039,47 @@ class LocationDetector {
|
|
|
853
1039
|
}
|
|
854
1040
|
this.ipLocationFetchingRef.current = true;
|
|
855
1041
|
try {
|
|
856
|
-
//
|
|
857
|
-
|
|
858
|
-
|
|
859
|
-
|
|
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}`);
|
|
1042
|
+
// Get public IP first, then get location
|
|
1043
|
+
const publicIP = await getPublicIP();
|
|
1044
|
+
if (!publicIP) {
|
|
1045
|
+
throw new Error('Could not determine public IP address');
|
|
868
1046
|
}
|
|
869
|
-
|
|
870
|
-
|
|
871
|
-
if (
|
|
872
|
-
|
|
873
|
-
const fallback = {
|
|
874
|
-
source: 'unknown',
|
|
875
|
-
permission: 'granted',
|
|
876
|
-
};
|
|
877
|
-
this.lastIPLocationRef.current = fallback;
|
|
878
|
-
return fallback;
|
|
1047
|
+
// Get location from IP using ipwho.is API
|
|
1048
|
+
const ipLocation = await getIPLocation(publicIP);
|
|
1049
|
+
if (!ipLocation) {
|
|
1050
|
+
throw new Error('Could not fetch location data');
|
|
879
1051
|
}
|
|
880
1052
|
// Convert IP location to LocationInfo format
|
|
1053
|
+
// Map all available fields from the IP location response
|
|
1054
|
+
// Handle timezone which can be either a string or an object
|
|
1055
|
+
const timezoneValue = typeof ipLocation.timezone === 'string'
|
|
1056
|
+
? ipLocation.timezone
|
|
1057
|
+
: ipLocation.timezone?.id || undefined;
|
|
881
1058
|
const locationResult = {
|
|
882
|
-
lat:
|
|
883
|
-
lon:
|
|
1059
|
+
lat: ipLocation.latitude ?? ipLocation.lat ?? null,
|
|
1060
|
+
lon: ipLocation.longitude ?? ipLocation.lon ?? null,
|
|
884
1061
|
accuracy: null, // IP-based location has no accuracy metric
|
|
885
1062
|
permission: 'granted', // IP location doesn't require permission
|
|
886
1063
|
source: 'ip',
|
|
887
1064
|
ts: new Date().toISOString(),
|
|
888
|
-
ip:
|
|
889
|
-
country:
|
|
890
|
-
countryCode:
|
|
891
|
-
city:
|
|
892
|
-
region:
|
|
893
|
-
timezone:
|
|
1065
|
+
ip: ipLocation.ip || publicIP,
|
|
1066
|
+
country: ipLocation.country || undefined,
|
|
1067
|
+
countryCode: ipLocation.country_code || ipLocation.countryCode || undefined,
|
|
1068
|
+
city: ipLocation.city || undefined,
|
|
1069
|
+
region: ipLocation.region || ipLocation.regionName || undefined,
|
|
1070
|
+
timezone: timezoneValue,
|
|
894
1071
|
};
|
|
1072
|
+
// Store the full IP location data in a custom field for access to all keys
|
|
1073
|
+
// This preserves all dynamic keys from the API response
|
|
1074
|
+
locationResult.ipLocationData = ipLocation;
|
|
895
1075
|
console.log('[Location] IP-based location obtained:', {
|
|
896
1076
|
ip: locationResult.ip,
|
|
897
1077
|
lat: locationResult.lat,
|
|
898
1078
|
lon: locationResult.lon,
|
|
899
1079
|
city: locationResult.city,
|
|
900
1080
|
country: locationResult.country,
|
|
1081
|
+
continent: ipLocation.continent,
|
|
1082
|
+
timezone: locationResult.timezone,
|
|
901
1083
|
});
|
|
902
1084
|
this.lastIPLocationRef.current = locationResult;
|
|
903
1085
|
return locationResult;
|
|
@@ -2380,6 +2562,8 @@ function useAnalytics(options = {}) {
|
|
|
2380
2562
|
if (autoSend) {
|
|
2381
2563
|
// Send after idle to not block paint
|
|
2382
2564
|
const send = async () => {
|
|
2565
|
+
// Extract IP location data if available (stored in ipLocationData field)
|
|
2566
|
+
const ipLocationData = loc?.ipLocationData;
|
|
2383
2567
|
await AnalyticsService.trackUserJourney({
|
|
2384
2568
|
sessionId: getOrCreateUserId(),
|
|
2385
2569
|
pageUrl: typeof window !== 'undefined' ? window.location.href : '',
|
|
@@ -2387,6 +2571,7 @@ function useAnalytics(options = {}) {
|
|
|
2387
2571
|
deviceInfo: dev,
|
|
2388
2572
|
location: loc,
|
|
2389
2573
|
attribution: attr,
|
|
2574
|
+
ipLocation: ipLocationData,
|
|
2390
2575
|
customData: config?.enableLocation ? { locationEnabled: true } : undefined,
|
|
2391
2576
|
});
|
|
2392
2577
|
};
|
|
@@ -2402,6 +2587,8 @@ function useAnalytics(options = {}) {
|
|
|
2402
2587
|
const logEvent = react.useCallback(async (customData) => {
|
|
2403
2588
|
if (!sessionId || !networkInfo || !deviceInfo)
|
|
2404
2589
|
return;
|
|
2590
|
+
// Extract IP location data if available (stored in ipLocationData field)
|
|
2591
|
+
const ipLocationData = location ? location?.ipLocationData : undefined;
|
|
2405
2592
|
await AnalyticsService.trackUserJourney({
|
|
2406
2593
|
sessionId,
|
|
2407
2594
|
pageUrl: typeof window !== 'undefined' ? window.location.href : '',
|
|
@@ -2409,6 +2596,7 @@ function useAnalytics(options = {}) {
|
|
|
2409
2596
|
deviceInfo,
|
|
2410
2597
|
location: location ?? undefined,
|
|
2411
2598
|
attribution: attribution ?? undefined,
|
|
2599
|
+
ipLocation: ipLocationData,
|
|
2412
2600
|
userId: sessionId,
|
|
2413
2601
|
customData,
|
|
2414
2602
|
});
|
|
@@ -2546,167 +2734,6 @@ function useAnalytics(options = {}) {
|
|
|
2546
2734
|
]);
|
|
2547
2735
|
}
|
|
2548
2736
|
|
|
2549
|
-
/**
|
|
2550
|
-
* IP Geolocation Service
|
|
2551
|
-
* Fetches location data (country, region, city) from user's IP address
|
|
2552
|
-
* Uses free tier of ip-api.com (no API key required, 45 requests/minute)
|
|
2553
|
-
*/
|
|
2554
|
-
/**
|
|
2555
|
-
* Get public IP address using ip-api.com
|
|
2556
|
-
* Free tier: 45 requests/minute, no API key required
|
|
2557
|
-
*
|
|
2558
|
-
* @returns Promise<string | null> - The public IP address, or null if unavailable
|
|
2559
|
-
*
|
|
2560
|
-
* @example
|
|
2561
|
-
* ```typescript
|
|
2562
|
-
* const ip = await getPublicIP();
|
|
2563
|
-
* console.log('Your IP:', ip); // e.g., "203.0.113.42"
|
|
2564
|
-
* ```
|
|
2565
|
-
*/
|
|
2566
|
-
async function getPublicIP() {
|
|
2567
|
-
// Skip if we're in an environment without fetch (SSR)
|
|
2568
|
-
if (typeof fetch === 'undefined') {
|
|
2569
|
-
return null;
|
|
2570
|
-
}
|
|
2571
|
-
try {
|
|
2572
|
-
// Call ip-api.com without IP parameter - it auto-detects user's IP
|
|
2573
|
-
// Using HTTPS endpoint for better security
|
|
2574
|
-
const response = await fetch('https://ip-api.com/json/?fields=status,message,query', {
|
|
2575
|
-
method: 'GET',
|
|
2576
|
-
headers: {
|
|
2577
|
-
Accept: 'application/json',
|
|
2578
|
-
},
|
|
2579
|
-
// Add timeout to prevent hanging
|
|
2580
|
-
signal: AbortSignal.timeout(5000),
|
|
2581
|
-
});
|
|
2582
|
-
if (!response.ok) {
|
|
2583
|
-
return null;
|
|
2584
|
-
}
|
|
2585
|
-
const data = await response.json();
|
|
2586
|
-
// ip-api.com returns status field
|
|
2587
|
-
if (data.status === 'fail') {
|
|
2588
|
-
return null;
|
|
2589
|
-
}
|
|
2590
|
-
return data.query || null;
|
|
2591
|
-
}
|
|
2592
|
-
catch (error) {
|
|
2593
|
-
// Silently fail - don't break user experience
|
|
2594
|
-
if (error.name !== 'AbortError') {
|
|
2595
|
-
console.warn('[IP Geolocation] Error fetching public IP:', error.message);
|
|
2596
|
-
}
|
|
2597
|
-
return null;
|
|
2598
|
-
}
|
|
2599
|
-
}
|
|
2600
|
-
/**
|
|
2601
|
-
* Get location from IP address using ip-api.com
|
|
2602
|
-
* Free tier: 45 requests/minute, no API key required
|
|
2603
|
-
*
|
|
2604
|
-
* Alternative services:
|
|
2605
|
-
* - ipapi.co (requires API key for production)
|
|
2606
|
-
* - ipgeolocation.io (requires API key)
|
|
2607
|
-
* - ip-api.com (free tier available)
|
|
2608
|
-
*/
|
|
2609
|
-
async function getIPLocation(ip) {
|
|
2610
|
-
// Skip localhost/private IPs (these can't be geolocated)
|
|
2611
|
-
if (!ip ||
|
|
2612
|
-
ip === '0.0.0.0' ||
|
|
2613
|
-
ip === '::1' ||
|
|
2614
|
-
ip.startsWith('127.') ||
|
|
2615
|
-
ip.startsWith('192.168.') ||
|
|
2616
|
-
ip.startsWith('10.') ||
|
|
2617
|
-
ip.startsWith('172.') ||
|
|
2618
|
-
ip.startsWith('::ffff:127.')) {
|
|
2619
|
-
console.log(`[IP Geolocation] Skipping localhost/private IP: ${ip} (geolocation not available for local IPs)`);
|
|
2620
|
-
return null;
|
|
2621
|
-
}
|
|
2622
|
-
try {
|
|
2623
|
-
// Using ip-api.com free tier (JSON format)
|
|
2624
|
-
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`, {
|
|
2625
|
-
method: 'GET',
|
|
2626
|
-
headers: {
|
|
2627
|
-
Accept: 'application/json',
|
|
2628
|
-
},
|
|
2629
|
-
// Add timeout to prevent hanging
|
|
2630
|
-
signal: AbortSignal.timeout(3000),
|
|
2631
|
-
});
|
|
2632
|
-
if (!response.ok) {
|
|
2633
|
-
console.warn(`[IP Geolocation] Failed to fetch location for IP ${ip}: ${response.status}`);
|
|
2634
|
-
return null;
|
|
2635
|
-
}
|
|
2636
|
-
const data = await response.json();
|
|
2637
|
-
// ip-api.com returns status field
|
|
2638
|
-
if (data.status === 'fail') {
|
|
2639
|
-
console.warn(`[IP Geolocation] API error for IP ${ip}: ${data.message}`);
|
|
2640
|
-
return null;
|
|
2641
|
-
}
|
|
2642
|
-
return {
|
|
2643
|
-
ip: data.query || ip,
|
|
2644
|
-
country: data.country || undefined,
|
|
2645
|
-
countryCode: data.countryCode || undefined,
|
|
2646
|
-
region: data.region || undefined,
|
|
2647
|
-
regionName: data.regionName || undefined,
|
|
2648
|
-
city: data.city || undefined,
|
|
2649
|
-
lat: data.lat || undefined,
|
|
2650
|
-
lon: data.lon || undefined,
|
|
2651
|
-
timezone: data.timezone || undefined,
|
|
2652
|
-
isp: data.isp || undefined,
|
|
2653
|
-
org: data.org || undefined,
|
|
2654
|
-
as: data.as || undefined,
|
|
2655
|
-
query: data.query || ip,
|
|
2656
|
-
};
|
|
2657
|
-
}
|
|
2658
|
-
catch (error) {
|
|
2659
|
-
// Silently fail - don't break user experience
|
|
2660
|
-
if (error.name !== 'AbortError') {
|
|
2661
|
-
console.warn(`[IP Geolocation] Error fetching location for IP ${ip}:`, error.message);
|
|
2662
|
-
}
|
|
2663
|
-
return null;
|
|
2664
|
-
}
|
|
2665
|
-
}
|
|
2666
|
-
/**
|
|
2667
|
-
* Get IP address from request headers
|
|
2668
|
-
* Handles various proxy headers (x-forwarded-for, x-real-ip, etc.)
|
|
2669
|
-
*/
|
|
2670
|
-
function getIPFromRequest(req) {
|
|
2671
|
-
// Try various headers that proxies/load balancers use
|
|
2672
|
-
const forwardedFor = req.headers?.get?.('x-forwarded-for') ||
|
|
2673
|
-
req.headers?.['x-forwarded-for'] ||
|
|
2674
|
-
req.headers?.['X-Forwarded-For'];
|
|
2675
|
-
if (forwardedFor) {
|
|
2676
|
-
// x-forwarded-for can contain multiple IPs, take the first one
|
|
2677
|
-
const ips = forwardedFor.split(',').map((ip) => ip.trim());
|
|
2678
|
-
const ip = ips[0];
|
|
2679
|
-
if (ip && ip !== '0.0.0.0') {
|
|
2680
|
-
return ip;
|
|
2681
|
-
}
|
|
2682
|
-
}
|
|
2683
|
-
const realIP = req.headers?.get?.('x-real-ip') ||
|
|
2684
|
-
req.headers?.['x-real-ip'] ||
|
|
2685
|
-
req.headers?.['X-Real-IP'];
|
|
2686
|
-
if (realIP && realIP !== '0.0.0.0') {
|
|
2687
|
-
return realIP.trim();
|
|
2688
|
-
}
|
|
2689
|
-
// Try req.ip (from Express/Next.js)
|
|
2690
|
-
if (req.ip && req.ip !== '0.0.0.0') {
|
|
2691
|
-
return req.ip;
|
|
2692
|
-
}
|
|
2693
|
-
// For localhost, detect if we're running locally
|
|
2694
|
-
if (typeof window === 'undefined') {
|
|
2695
|
-
const hostname = req.headers?.get?.('host') || req.headers?.['host'];
|
|
2696
|
-
if (hostname &&
|
|
2697
|
-
(hostname.includes('localhost') ||
|
|
2698
|
-
hostname.includes('127.0.0.1') ||
|
|
2699
|
-
hostname.startsWith('192.168.'))) {
|
|
2700
|
-
return '127.0.0.1'; // Localhost IP
|
|
2701
|
-
}
|
|
2702
|
-
}
|
|
2703
|
-
// If no IP found and we're in development, return localhost
|
|
2704
|
-
if (typeof process !== 'undefined' && process.env?.NODE_ENV === 'development') {
|
|
2705
|
-
return '127.0.0.1'; // Localhost for development
|
|
2706
|
-
}
|
|
2707
|
-
return '0.0.0.0';
|
|
2708
|
-
}
|
|
2709
|
-
|
|
2710
2737
|
exports.AnalyticsService = AnalyticsService;
|
|
2711
2738
|
exports.AttributionDetector = AttributionDetector;
|
|
2712
2739
|
exports.DeviceDetector = DeviceDetector;
|