user-analytics-tracker 2.0.0 → 2.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 +38 -0
- package/README.md +11 -2
- package/dist/index.cjs.js +399 -195
- package/dist/index.cjs.js.map +1 -1
- package/dist/index.d.cts +76 -15
- package/dist/index.d.ts +76 -15
- package/dist/index.esm.js +398 -196
- package/dist/index.esm.js.map +1 -1
- package/package.json +1 -1
package/dist/index.cjs.js
CHANGED
|
@@ -553,6 +553,277 @@ 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 complete IP location data from ipwho.is API (HIGH PRIORITY)
|
|
566
|
+
* This is the primary method - gets IP, location, connection, and all data in one call
|
|
567
|
+
* No API key required
|
|
568
|
+
*
|
|
569
|
+
* @returns Promise<IPLocation | null> - Complete IP location data, or null if unavailable
|
|
570
|
+
*
|
|
571
|
+
* @example
|
|
572
|
+
* ```typescript
|
|
573
|
+
* const location = await getCompleteIPLocation();
|
|
574
|
+
* console.log('IP:', location?.ip);
|
|
575
|
+
* console.log('Country:', location?.country);
|
|
576
|
+
* console.log('ISP:', location?.connection?.isp);
|
|
577
|
+
* ```
|
|
578
|
+
*/
|
|
579
|
+
async function getCompleteIPLocation() {
|
|
580
|
+
// Skip if we're in an environment without fetch (SSR)
|
|
581
|
+
if (typeof fetch === 'undefined') {
|
|
582
|
+
return null;
|
|
583
|
+
}
|
|
584
|
+
try {
|
|
585
|
+
// Call ipwho.is without IP parameter - it auto-detects user's IP and returns everything
|
|
586
|
+
// This is the HIGH PRIORITY source - gets IP, location, connection, timezone, flag, etc. in one call
|
|
587
|
+
const response = await fetch('https://ipwho.is/', {
|
|
588
|
+
method: 'GET',
|
|
589
|
+
headers: {
|
|
590
|
+
Accept: 'application/json',
|
|
591
|
+
},
|
|
592
|
+
// Add timeout to prevent hanging
|
|
593
|
+
signal: AbortSignal.timeout(5000),
|
|
594
|
+
});
|
|
595
|
+
if (!response.ok) {
|
|
596
|
+
return null;
|
|
597
|
+
}
|
|
598
|
+
const data = await response.json();
|
|
599
|
+
// ipwho.is returns success field
|
|
600
|
+
if (data.success === false) {
|
|
601
|
+
return null;
|
|
602
|
+
}
|
|
603
|
+
// Store all keys dynamically from the response
|
|
604
|
+
// This ensures we capture all fields, including nested objects and any new fields
|
|
605
|
+
const locationData = {
|
|
606
|
+
ip: data.ip,
|
|
607
|
+
// Map all fields from the API response dynamically
|
|
608
|
+
...Object.keys(data).reduce((acc, key) => {
|
|
609
|
+
// Store all keys and their values, preserving nested objects
|
|
610
|
+
acc[key] = data[key];
|
|
611
|
+
return acc;
|
|
612
|
+
}, {}),
|
|
613
|
+
};
|
|
614
|
+
// Add backward compatibility mappings for existing code
|
|
615
|
+
if (data.latitude !== undefined) {
|
|
616
|
+
locationData.lat = data.latitude;
|
|
617
|
+
}
|
|
618
|
+
if (data.longitude !== undefined) {
|
|
619
|
+
locationData.lon = data.longitude;
|
|
620
|
+
}
|
|
621
|
+
if (data.country_code !== undefined) {
|
|
622
|
+
locationData.countryCode = data.country_code;
|
|
623
|
+
}
|
|
624
|
+
if (data.region !== undefined) {
|
|
625
|
+
locationData.regionName = data.region;
|
|
626
|
+
}
|
|
627
|
+
if (data.connection?.isp !== undefined) {
|
|
628
|
+
locationData.isp = data.connection.isp;
|
|
629
|
+
}
|
|
630
|
+
if (data.connection?.org !== undefined) {
|
|
631
|
+
locationData.org = data.connection.org;
|
|
632
|
+
}
|
|
633
|
+
if (data.connection?.asn !== undefined) {
|
|
634
|
+
locationData.as = `AS${data.connection.asn}`;
|
|
635
|
+
}
|
|
636
|
+
if (data.timezone?.id !== undefined) {
|
|
637
|
+
locationData.timezone = data.timezone.id;
|
|
638
|
+
}
|
|
639
|
+
locationData.query = data.ip;
|
|
640
|
+
return locationData;
|
|
641
|
+
}
|
|
642
|
+
catch (error) {
|
|
643
|
+
// Silently fail - don't break user experience
|
|
644
|
+
if (error.name !== 'AbortError') {
|
|
645
|
+
console.warn('[IP Geolocation] Error fetching complete IP location from ipwho.is:', error.message);
|
|
646
|
+
}
|
|
647
|
+
return null;
|
|
648
|
+
}
|
|
649
|
+
}
|
|
650
|
+
/**
|
|
651
|
+
* Get public IP address using ipwho.is API (FALLBACK - lower priority)
|
|
652
|
+
* This is kept for backward compatibility and as a fallback
|
|
653
|
+
* Prefer getCompleteIPLocation() which gets everything in one call
|
|
654
|
+
*
|
|
655
|
+
* @returns Promise<string | null> - The public IP address, or null if unavailable
|
|
656
|
+
*
|
|
657
|
+
* @example
|
|
658
|
+
* ```typescript
|
|
659
|
+
* const ip = await getPublicIP();
|
|
660
|
+
* console.log('Your IP:', ip); // e.g., "203.0.113.42"
|
|
661
|
+
* ```
|
|
662
|
+
*/
|
|
663
|
+
async function getPublicIP() {
|
|
664
|
+
// Try to get complete location first (includes IP)
|
|
665
|
+
const completeLocation = await getCompleteIPLocation();
|
|
666
|
+
if (completeLocation?.ip) {
|
|
667
|
+
return completeLocation.ip;
|
|
668
|
+
}
|
|
669
|
+
// Fallback: try direct IP fetch (less efficient, lower priority)
|
|
670
|
+
try {
|
|
671
|
+
const response = await fetch('https://ipwho.is/', {
|
|
672
|
+
method: 'GET',
|
|
673
|
+
headers: {
|
|
674
|
+
Accept: 'application/json',
|
|
675
|
+
},
|
|
676
|
+
signal: AbortSignal.timeout(5000),
|
|
677
|
+
});
|
|
678
|
+
if (!response.ok) {
|
|
679
|
+
return null;
|
|
680
|
+
}
|
|
681
|
+
const data = await response.json();
|
|
682
|
+
if (data.success === false) {
|
|
683
|
+
return null;
|
|
684
|
+
}
|
|
685
|
+
return data.ip || null;
|
|
686
|
+
}
|
|
687
|
+
catch (error) {
|
|
688
|
+
if (error.name !== 'AbortError') {
|
|
689
|
+
console.warn('[IP Geolocation] Error fetching public IP (fallback):', error.message);
|
|
690
|
+
}
|
|
691
|
+
return null;
|
|
692
|
+
}
|
|
693
|
+
}
|
|
694
|
+
/**
|
|
695
|
+
* Get location from IP address using ipwho.is API (HIGH PRIORITY)
|
|
696
|
+
* Free tier: No API key required
|
|
697
|
+
*
|
|
698
|
+
* Stores all keys dynamically from the API response, including nested objects
|
|
699
|
+
* This ensures we capture all available data and any new fields added by the API
|
|
700
|
+
*
|
|
701
|
+
* Note: If you don't have an IP yet, use getCompleteIPLocation() which gets everything in one call
|
|
702
|
+
*/
|
|
703
|
+
async function getIPLocation(ip) {
|
|
704
|
+
// Skip localhost/private IPs (these can't be geolocated)
|
|
705
|
+
if (!ip ||
|
|
706
|
+
ip === '0.0.0.0' ||
|
|
707
|
+
ip === '::1' ||
|
|
708
|
+
ip.startsWith('127.') ||
|
|
709
|
+
ip.startsWith('192.168.') ||
|
|
710
|
+
ip.startsWith('10.') ||
|
|
711
|
+
ip.startsWith('172.') ||
|
|
712
|
+
ip.startsWith('::ffff:127.')) {
|
|
713
|
+
console.log(`[IP Geolocation] Skipping localhost/private IP: ${ip} (geolocation not available for local IPs)`);
|
|
714
|
+
return null;
|
|
715
|
+
}
|
|
716
|
+
try {
|
|
717
|
+
// Using ipwho.is API (no API key required)
|
|
718
|
+
const response = await fetch(`https://ipwho.is/${ip}`, {
|
|
719
|
+
method: 'GET',
|
|
720
|
+
headers: {
|
|
721
|
+
Accept: 'application/json',
|
|
722
|
+
},
|
|
723
|
+
// Add timeout to prevent hanging
|
|
724
|
+
signal: AbortSignal.timeout(5000),
|
|
725
|
+
});
|
|
726
|
+
if (!response.ok) {
|
|
727
|
+
console.warn(`[IP Geolocation] Failed to fetch location for IP ${ip}: ${response.status}`);
|
|
728
|
+
return null;
|
|
729
|
+
}
|
|
730
|
+
const data = await response.json();
|
|
731
|
+
// ipwho.is returns success field
|
|
732
|
+
if (data.success === false) {
|
|
733
|
+
console.warn(`[IP Geolocation] API error for IP ${ip}: ${data.message || 'Unknown error'}`);
|
|
734
|
+
return null;
|
|
735
|
+
}
|
|
736
|
+
// Store all keys dynamically from the response
|
|
737
|
+
// This ensures we capture all fields, including nested objects and any new fields
|
|
738
|
+
const locationData = {
|
|
739
|
+
ip: data.ip || ip,
|
|
740
|
+
// Map all fields from the API response dynamically
|
|
741
|
+
...Object.keys(data).reduce((acc, key) => {
|
|
742
|
+
// Store all keys and their values, preserving nested objects
|
|
743
|
+
acc[key] = data[key];
|
|
744
|
+
return acc;
|
|
745
|
+
}, {}),
|
|
746
|
+
};
|
|
747
|
+
// Add backward compatibility mappings for existing code
|
|
748
|
+
if (data.latitude !== undefined) {
|
|
749
|
+
locationData.lat = data.latitude;
|
|
750
|
+
}
|
|
751
|
+
if (data.longitude !== undefined) {
|
|
752
|
+
locationData.lon = data.longitude;
|
|
753
|
+
}
|
|
754
|
+
if (data.country_code !== undefined) {
|
|
755
|
+
locationData.countryCode = data.country_code;
|
|
756
|
+
}
|
|
757
|
+
if (data.region !== undefined) {
|
|
758
|
+
locationData.regionName = data.region;
|
|
759
|
+
}
|
|
760
|
+
if (data.connection?.isp !== undefined) {
|
|
761
|
+
locationData.isp = data.connection.isp;
|
|
762
|
+
}
|
|
763
|
+
if (data.connection?.org !== undefined) {
|
|
764
|
+
locationData.org = data.connection.org;
|
|
765
|
+
}
|
|
766
|
+
if (data.connection?.asn !== undefined) {
|
|
767
|
+
locationData.as = `AS${data.connection.asn}`;
|
|
768
|
+
}
|
|
769
|
+
if (data.timezone?.id !== undefined) {
|
|
770
|
+
locationData.timezone = data.timezone.id;
|
|
771
|
+
}
|
|
772
|
+
locationData.query = data.ip || ip;
|
|
773
|
+
return locationData;
|
|
774
|
+
}
|
|
775
|
+
catch (error) {
|
|
776
|
+
// Silently fail - don't break user experience
|
|
777
|
+
if (error.name !== 'AbortError') {
|
|
778
|
+
console.warn(`[IP Geolocation] Error fetching location for IP ${ip}:`, error.message);
|
|
779
|
+
}
|
|
780
|
+
return null;
|
|
781
|
+
}
|
|
782
|
+
}
|
|
783
|
+
/**
|
|
784
|
+
* Get IP address from request headers
|
|
785
|
+
* Handles various proxy headers (x-forwarded-for, x-real-ip, etc.)
|
|
786
|
+
*/
|
|
787
|
+
function getIPFromRequest(req) {
|
|
788
|
+
// Try various headers that proxies/load balancers use
|
|
789
|
+
const forwardedFor = req.headers?.get?.('x-forwarded-for') ||
|
|
790
|
+
req.headers?.['x-forwarded-for'] ||
|
|
791
|
+
req.headers?.['X-Forwarded-For'];
|
|
792
|
+
if (forwardedFor) {
|
|
793
|
+
// x-forwarded-for can contain multiple IPs, take the first one
|
|
794
|
+
const ips = forwardedFor.split(',').map((ip) => ip.trim());
|
|
795
|
+
const ip = ips[0];
|
|
796
|
+
if (ip && ip !== '0.0.0.0') {
|
|
797
|
+
return ip;
|
|
798
|
+
}
|
|
799
|
+
}
|
|
800
|
+
const realIP = req.headers?.get?.('x-real-ip') ||
|
|
801
|
+
req.headers?.['x-real-ip'] ||
|
|
802
|
+
req.headers?.['X-Real-IP'];
|
|
803
|
+
if (realIP && realIP !== '0.0.0.0') {
|
|
804
|
+
return realIP.trim();
|
|
805
|
+
}
|
|
806
|
+
// Try req.ip (from Express/Next.js)
|
|
807
|
+
if (req.ip && req.ip !== '0.0.0.0') {
|
|
808
|
+
return req.ip;
|
|
809
|
+
}
|
|
810
|
+
// For localhost, detect if we're running locally
|
|
811
|
+
if (typeof window === 'undefined') {
|
|
812
|
+
const hostname = req.headers?.get?.('host') || req.headers?.['host'];
|
|
813
|
+
if (hostname &&
|
|
814
|
+
(hostname.includes('localhost') ||
|
|
815
|
+
hostname.includes('127.0.0.1') ||
|
|
816
|
+
hostname.startsWith('192.168.'))) {
|
|
817
|
+
return '127.0.0.1'; // Localhost IP
|
|
818
|
+
}
|
|
819
|
+
}
|
|
820
|
+
// If no IP found and we're in development, return localhost
|
|
821
|
+
if (typeof process !== 'undefined' && process.env?.NODE_ENV === 'development') {
|
|
822
|
+
return '127.0.0.1'; // Localhost for development
|
|
823
|
+
}
|
|
824
|
+
return '0.0.0.0';
|
|
825
|
+
}
|
|
826
|
+
|
|
556
827
|
/**
|
|
557
828
|
* Location Detector
|
|
558
829
|
* Detects GPS location with consent management, falls back to IP-based location API
|
|
@@ -817,7 +1088,8 @@ class LocationDetector {
|
|
|
817
1088
|
/**
|
|
818
1089
|
* Get location from IP-based public API (client-side)
|
|
819
1090
|
* Works without user permission, good fallback when GPS is unavailable
|
|
820
|
-
* Uses
|
|
1091
|
+
* Uses ipwho.is API (no API key required)
|
|
1092
|
+
* Stores all keys dynamically from the API response
|
|
821
1093
|
*/
|
|
822
1094
|
static async getIPBasedLocation() {
|
|
823
1095
|
// Return cached IP location if available
|
|
@@ -853,51 +1125,55 @@ class LocationDetector {
|
|
|
853
1125
|
}
|
|
854
1126
|
this.ipLocationFetchingRef.current = true;
|
|
855
1127
|
try {
|
|
856
|
-
//
|
|
857
|
-
//
|
|
858
|
-
|
|
859
|
-
|
|
860
|
-
|
|
861
|
-
|
|
862
|
-
|
|
863
|
-
|
|
864
|
-
|
|
865
|
-
|
|
866
|
-
|
|
867
|
-
|
|
1128
|
+
// HIGH PRIORITY: Get complete IP location data from ipwho.is in one call
|
|
1129
|
+
// This gets IP, location, connection, timezone, flag, and all other data at once
|
|
1130
|
+
// More efficient than making separate calls
|
|
1131
|
+
let ipLocation = await getCompleteIPLocation();
|
|
1132
|
+
// If complete location fetch failed, try fallback: get IP first, then location
|
|
1133
|
+
if (!ipLocation) {
|
|
1134
|
+
console.log('[Location] Primary ipwho.is call failed, trying fallback...');
|
|
1135
|
+
const publicIP = await getPublicIP();
|
|
1136
|
+
if (publicIP) {
|
|
1137
|
+
// Fallback: Get location from IP using ipwho.is API
|
|
1138
|
+
ipLocation = await getIPLocation(publicIP);
|
|
1139
|
+
}
|
|
868
1140
|
}
|
|
869
|
-
|
|
870
|
-
|
|
871
|
-
if (data.status === 'fail') {
|
|
872
|
-
console.warn(`[Location] IP API error: ${data.message}`);
|
|
873
|
-
const fallback = {
|
|
874
|
-
source: 'unknown',
|
|
875
|
-
permission: 'granted',
|
|
876
|
-
};
|
|
877
|
-
this.lastIPLocationRef.current = fallback;
|
|
878
|
-
return fallback;
|
|
1141
|
+
if (!ipLocation) {
|
|
1142
|
+
throw new Error('Could not fetch location data from ipwho.is');
|
|
879
1143
|
}
|
|
880
1144
|
// Convert IP location to LocationInfo format
|
|
1145
|
+
// Map all available fields from the IP location response
|
|
1146
|
+
// Handle timezone which can be either a string or an object
|
|
1147
|
+
const timezoneValue = typeof ipLocation.timezone === 'string'
|
|
1148
|
+
? ipLocation.timezone
|
|
1149
|
+
: ipLocation.timezone?.id || undefined;
|
|
881
1150
|
const locationResult = {
|
|
882
|
-
lat:
|
|
883
|
-
lon:
|
|
1151
|
+
lat: ipLocation.latitude ?? ipLocation.lat ?? null,
|
|
1152
|
+
lon: ipLocation.longitude ?? ipLocation.lon ?? null,
|
|
884
1153
|
accuracy: null, // IP-based location has no accuracy metric
|
|
885
1154
|
permission: 'granted', // IP location doesn't require permission
|
|
886
1155
|
source: 'ip',
|
|
887
1156
|
ts: new Date().toISOString(),
|
|
888
|
-
ip:
|
|
889
|
-
country:
|
|
890
|
-
countryCode:
|
|
891
|
-
city:
|
|
892
|
-
region:
|
|
893
|
-
timezone:
|
|
1157
|
+
ip: ipLocation.ip || undefined,
|
|
1158
|
+
country: ipLocation.country || undefined,
|
|
1159
|
+
countryCode: ipLocation.country_code || ipLocation.countryCode || undefined,
|
|
1160
|
+
city: ipLocation.city || undefined,
|
|
1161
|
+
region: ipLocation.region || ipLocation.regionName || undefined,
|
|
1162
|
+
timezone: timezoneValue,
|
|
894
1163
|
};
|
|
895
|
-
|
|
1164
|
+
// Store the full IP location data in a custom field for access to all keys
|
|
1165
|
+
// This preserves all dynamic keys from the API response
|
|
1166
|
+
locationResult.ipLocationData = ipLocation;
|
|
1167
|
+
console.log('[Location] IP-based location obtained from ipwho.is:', {
|
|
896
1168
|
ip: locationResult.ip,
|
|
897
1169
|
lat: locationResult.lat,
|
|
898
1170
|
lon: locationResult.lon,
|
|
899
1171
|
city: locationResult.city,
|
|
900
1172
|
country: locationResult.country,
|
|
1173
|
+
continent: ipLocation.continent,
|
|
1174
|
+
timezone: locationResult.timezone,
|
|
1175
|
+
isp: ipLocation.connection?.isp,
|
|
1176
|
+
connection: ipLocation.connection,
|
|
901
1177
|
});
|
|
902
1178
|
this.lastIPLocationRef.current = locationResult;
|
|
903
1179
|
return locationResult;
|
|
@@ -1734,6 +2010,73 @@ class MetricsCollector {
|
|
|
1734
2010
|
// Global metrics collector instance
|
|
1735
2011
|
const metricsCollector = new MetricsCollector();
|
|
1736
2012
|
|
|
2013
|
+
/**
|
|
2014
|
+
* Transform IP location data from API format (snake_case) to backend-expected format (camelCase)
|
|
2015
|
+
* This ensures compatibility with the analytics backend integration
|
|
2016
|
+
*
|
|
2017
|
+
* @param ipLocation - Raw IP location data from ipwho.is API
|
|
2018
|
+
* @returns Transformed IP location data matching backend schema
|
|
2019
|
+
*/
|
|
2020
|
+
function transformIPLocationForBackend(ipLocation) {
|
|
2021
|
+
if (!ipLocation) {
|
|
2022
|
+
return null;
|
|
2023
|
+
}
|
|
2024
|
+
// Transform to match backend expected format (camelCase)
|
|
2025
|
+
const transformed = {
|
|
2026
|
+
// Basic fields
|
|
2027
|
+
ip: ipLocation.ip,
|
|
2028
|
+
country: ipLocation.country,
|
|
2029
|
+
countryCode: ipLocation.country_code || ipLocation.countryCode,
|
|
2030
|
+
region: ipLocation.region || ipLocation.regionName,
|
|
2031
|
+
city: ipLocation.city,
|
|
2032
|
+
postal: ipLocation.postal,
|
|
2033
|
+
capital: ipLocation.capital,
|
|
2034
|
+
callingCode: ipLocation.calling_code || ipLocation.callingCode,
|
|
2035
|
+
// Geographic fields
|
|
2036
|
+
continent: ipLocation.continent,
|
|
2037
|
+
continentCode: ipLocation.continent_code || ipLocation.continentCode,
|
|
2038
|
+
lat: ipLocation.latitude ?? ipLocation.lat,
|
|
2039
|
+
lon: ipLocation.longitude ?? ipLocation.lon,
|
|
2040
|
+
borders: ipLocation.borders,
|
|
2041
|
+
// Network fields
|
|
2042
|
+
type: ipLocation.type,
|
|
2043
|
+
isEu: ipLocation.is_eu ?? ipLocation.isEu,
|
|
2044
|
+
// ISP/Connection - preserve connection object and also add top-level isp
|
|
2045
|
+
isp: ipLocation.connection?.isp || ipLocation.isp,
|
|
2046
|
+
connection: ipLocation.connection ? {
|
|
2047
|
+
asn: ipLocation.connection.asn,
|
|
2048
|
+
org: ipLocation.connection.org,
|
|
2049
|
+
isp: ipLocation.connection.isp,
|
|
2050
|
+
domain: ipLocation.connection.domain,
|
|
2051
|
+
} : undefined,
|
|
2052
|
+
// Timezone - store both simple string and full details object
|
|
2053
|
+
timezone: typeof ipLocation.timezone === 'string'
|
|
2054
|
+
? ipLocation.timezone
|
|
2055
|
+
: ipLocation.timezone?.id,
|
|
2056
|
+
timezoneDetails: ipLocation.timezone && typeof ipLocation.timezone === 'object' ? {
|
|
2057
|
+
id: ipLocation.timezone.id,
|
|
2058
|
+
abbr: ipLocation.timezone.abbr,
|
|
2059
|
+
isDst: ipLocation.timezone.is_dst,
|
|
2060
|
+
offset: ipLocation.timezone.offset,
|
|
2061
|
+
utc: ipLocation.timezone.utc,
|
|
2062
|
+
currentTime: ipLocation.timezone.current_time,
|
|
2063
|
+
} : undefined,
|
|
2064
|
+
// Flag - transform to camelCase
|
|
2065
|
+
flag: ipLocation.flag ? {
|
|
2066
|
+
img: ipLocation.flag.img,
|
|
2067
|
+
emoji: ipLocation.flag.emoji,
|
|
2068
|
+
emojiUnicode: ipLocation.flag.emoji_unicode,
|
|
2069
|
+
} : undefined,
|
|
2070
|
+
};
|
|
2071
|
+
// Remove undefined values to keep the payload clean
|
|
2072
|
+
Object.keys(transformed).forEach(key => {
|
|
2073
|
+
if (transformed[key] === undefined) {
|
|
2074
|
+
delete transformed[key];
|
|
2075
|
+
}
|
|
2076
|
+
});
|
|
2077
|
+
return transformed;
|
|
2078
|
+
}
|
|
2079
|
+
|
|
1737
2080
|
/**
|
|
1738
2081
|
* Analytics Service
|
|
1739
2082
|
* Sends analytics events to your backend API
|
|
@@ -1997,6 +2340,8 @@ class AnalyticsService {
|
|
|
1997
2340
|
* Track user journey with full context
|
|
1998
2341
|
*/
|
|
1999
2342
|
static async trackUserJourney({ sessionId, pageUrl, networkInfo, deviceInfo, location, attribution, ipLocation, userId, customData, pageVisits = 1, interactions = 0, }) {
|
|
2343
|
+
// Transform IP location data to match backend expected format (camelCase)
|
|
2344
|
+
const transformedIPLocation = transformIPLocationForBackend(ipLocation);
|
|
2000
2345
|
await this.trackEvent({
|
|
2001
2346
|
sessionId,
|
|
2002
2347
|
pageUrl,
|
|
@@ -2008,7 +2353,8 @@ class AnalyticsService {
|
|
|
2008
2353
|
userId: userId ?? sessionId,
|
|
2009
2354
|
customData: {
|
|
2010
2355
|
...customData,
|
|
2011
|
-
|
|
2356
|
+
// Store transformed IP location in customData for backend integration
|
|
2357
|
+
...(transformedIPLocation && { ipLocation: transformedIPLocation }),
|
|
2012
2358
|
},
|
|
2013
2359
|
eventName: 'page_view', // Auto-tracked as page view
|
|
2014
2360
|
});
|
|
@@ -2079,6 +2425,13 @@ class AnalyticsService {
|
|
|
2079
2425
|
}
|
|
2080
2426
|
const finalSessionId = context?.sessionId || autoContext?.sessionId || 'unknown';
|
|
2081
2427
|
const finalPageUrl = context?.pageUrl || autoContext?.pageUrl || '';
|
|
2428
|
+
// Extract IP location from location object if available
|
|
2429
|
+
const locationData = context?.location || autoContext?.location;
|
|
2430
|
+
const ipLocationData = locationData && typeof locationData === 'object'
|
|
2431
|
+
? locationData?.ipLocationData
|
|
2432
|
+
: undefined;
|
|
2433
|
+
// Transform IP location data to match backend expected format
|
|
2434
|
+
const transformedIPLocation = transformIPLocationForBackend(ipLocationData);
|
|
2082
2435
|
await this.trackEvent({
|
|
2083
2436
|
sessionId: finalSessionId,
|
|
2084
2437
|
pageUrl: finalPageUrl,
|
|
@@ -2089,7 +2442,11 @@ class AnalyticsService {
|
|
|
2089
2442
|
userId: context?.userId || finalSessionId,
|
|
2090
2443
|
eventName,
|
|
2091
2444
|
eventParameters: parameters || {},
|
|
2092
|
-
customData:
|
|
2445
|
+
customData: {
|
|
2446
|
+
...(parameters || {}),
|
|
2447
|
+
// Store transformed IP location in customData for backend integration
|
|
2448
|
+
...(transformedIPLocation && { ipLocation: transformedIPLocation }),
|
|
2449
|
+
},
|
|
2093
2450
|
});
|
|
2094
2451
|
}
|
|
2095
2452
|
/**
|
|
@@ -2380,6 +2737,8 @@ function useAnalytics(options = {}) {
|
|
|
2380
2737
|
if (autoSend) {
|
|
2381
2738
|
// Send after idle to not block paint
|
|
2382
2739
|
const send = async () => {
|
|
2740
|
+
// Extract IP location data if available (stored in ipLocationData field)
|
|
2741
|
+
const ipLocationData = loc?.ipLocationData;
|
|
2383
2742
|
await AnalyticsService.trackUserJourney({
|
|
2384
2743
|
sessionId: getOrCreateUserId(),
|
|
2385
2744
|
pageUrl: typeof window !== 'undefined' ? window.location.href : '',
|
|
@@ -2387,6 +2746,7 @@ function useAnalytics(options = {}) {
|
|
|
2387
2746
|
deviceInfo: dev,
|
|
2388
2747
|
location: loc,
|
|
2389
2748
|
attribution: attr,
|
|
2749
|
+
ipLocation: ipLocationData,
|
|
2390
2750
|
customData: config?.enableLocation ? { locationEnabled: true } : undefined,
|
|
2391
2751
|
});
|
|
2392
2752
|
};
|
|
@@ -2402,6 +2762,8 @@ function useAnalytics(options = {}) {
|
|
|
2402
2762
|
const logEvent = react.useCallback(async (customData) => {
|
|
2403
2763
|
if (!sessionId || !networkInfo || !deviceInfo)
|
|
2404
2764
|
return;
|
|
2765
|
+
// Extract IP location data if available (stored in ipLocationData field)
|
|
2766
|
+
const ipLocationData = location ? location?.ipLocationData : undefined;
|
|
2405
2767
|
await AnalyticsService.trackUserJourney({
|
|
2406
2768
|
sessionId,
|
|
2407
2769
|
pageUrl: typeof window !== 'undefined' ? window.location.href : '',
|
|
@@ -2409,6 +2771,7 @@ function useAnalytics(options = {}) {
|
|
|
2409
2771
|
deviceInfo,
|
|
2410
2772
|
location: location ?? undefined,
|
|
2411
2773
|
attribution: attribution ?? undefined,
|
|
2774
|
+
ipLocation: ipLocationData,
|
|
2412
2775
|
userId: sessionId,
|
|
2413
2776
|
customData,
|
|
2414
2777
|
});
|
|
@@ -2546,167 +2909,6 @@ function useAnalytics(options = {}) {
|
|
|
2546
2909
|
]);
|
|
2547
2910
|
}
|
|
2548
2911
|
|
|
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
2912
|
exports.AnalyticsService = AnalyticsService;
|
|
2711
2913
|
exports.AttributionDetector = AttributionDetector;
|
|
2712
2914
|
exports.DeviceDetector = DeviceDetector;
|
|
@@ -2717,6 +2919,7 @@ exports.checkAndSetLocationConsent = checkAndSetLocationConsent;
|
|
|
2717
2919
|
exports.clearLocationConsent = clearLocationConsent;
|
|
2718
2920
|
exports.clearSession = clearSession;
|
|
2719
2921
|
exports.default = useAnalytics;
|
|
2922
|
+
exports.getCompleteIPLocation = getCompleteIPLocation;
|
|
2720
2923
|
exports.getIPFromRequest = getIPFromRequest;
|
|
2721
2924
|
exports.getIPLocation = getIPLocation;
|
|
2722
2925
|
exports.getLocationConsentTimestamp = getLocationConsentTimestamp;
|
|
@@ -2733,6 +2936,7 @@ exports.saveJSON = saveJSON;
|
|
|
2733
2936
|
exports.saveSessionJSON = saveSessionJSON;
|
|
2734
2937
|
exports.setLocationConsentGranted = setLocationConsentGranted;
|
|
2735
2938
|
exports.trackPageVisit = trackPageVisit;
|
|
2939
|
+
exports.transformIPLocationForBackend = transformIPLocationForBackend;
|
|
2736
2940
|
exports.updateSessionActivity = updateSessionActivity;
|
|
2737
2941
|
exports.useAnalytics = useAnalytics;
|
|
2738
2942
|
//# sourceMappingURL=index.cjs.js.map
|