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.esm.js
CHANGED
|
@@ -549,6 +549,277 @@ function checkAndSetLocationConsent(msisdn) {
|
|
|
549
549
|
return false;
|
|
550
550
|
}
|
|
551
551
|
|
|
552
|
+
/**
|
|
553
|
+
* IP Geolocation Service
|
|
554
|
+
* Fetches location data (country, region, city) from user's IP address
|
|
555
|
+
* Uses ipwho.is API (no API key required)
|
|
556
|
+
*
|
|
557
|
+
* Stores all keys dynamically from the API response, including nested objects
|
|
558
|
+
* This ensures we capture all available data and any new fields added by the API
|
|
559
|
+
*/
|
|
560
|
+
/**
|
|
561
|
+
* Get complete IP location data from ipwho.is API (HIGH PRIORITY)
|
|
562
|
+
* This is the primary method - gets IP, location, connection, and all data in one call
|
|
563
|
+
* No API key required
|
|
564
|
+
*
|
|
565
|
+
* @returns Promise<IPLocation | null> - Complete IP location data, or null if unavailable
|
|
566
|
+
*
|
|
567
|
+
* @example
|
|
568
|
+
* ```typescript
|
|
569
|
+
* const location = await getCompleteIPLocation();
|
|
570
|
+
* console.log('IP:', location?.ip);
|
|
571
|
+
* console.log('Country:', location?.country);
|
|
572
|
+
* console.log('ISP:', location?.connection?.isp);
|
|
573
|
+
* ```
|
|
574
|
+
*/
|
|
575
|
+
async function getCompleteIPLocation() {
|
|
576
|
+
// Skip if we're in an environment without fetch (SSR)
|
|
577
|
+
if (typeof fetch === 'undefined') {
|
|
578
|
+
return null;
|
|
579
|
+
}
|
|
580
|
+
try {
|
|
581
|
+
// Call ipwho.is without IP parameter - it auto-detects user's IP and returns everything
|
|
582
|
+
// This is the HIGH PRIORITY source - gets IP, location, connection, timezone, flag, etc. in one call
|
|
583
|
+
const response = await fetch('https://ipwho.is/', {
|
|
584
|
+
method: 'GET',
|
|
585
|
+
headers: {
|
|
586
|
+
Accept: 'application/json',
|
|
587
|
+
},
|
|
588
|
+
// Add timeout to prevent hanging
|
|
589
|
+
signal: AbortSignal.timeout(5000),
|
|
590
|
+
});
|
|
591
|
+
if (!response.ok) {
|
|
592
|
+
return null;
|
|
593
|
+
}
|
|
594
|
+
const data = await response.json();
|
|
595
|
+
// ipwho.is returns success field
|
|
596
|
+
if (data.success === false) {
|
|
597
|
+
return null;
|
|
598
|
+
}
|
|
599
|
+
// Store all keys dynamically from the response
|
|
600
|
+
// This ensures we capture all fields, including nested objects and any new fields
|
|
601
|
+
const locationData = {
|
|
602
|
+
ip: data.ip,
|
|
603
|
+
// Map all fields from the API response dynamically
|
|
604
|
+
...Object.keys(data).reduce((acc, key) => {
|
|
605
|
+
// Store all keys and their values, preserving nested objects
|
|
606
|
+
acc[key] = data[key];
|
|
607
|
+
return acc;
|
|
608
|
+
}, {}),
|
|
609
|
+
};
|
|
610
|
+
// Add backward compatibility mappings for existing code
|
|
611
|
+
if (data.latitude !== undefined) {
|
|
612
|
+
locationData.lat = data.latitude;
|
|
613
|
+
}
|
|
614
|
+
if (data.longitude !== undefined) {
|
|
615
|
+
locationData.lon = data.longitude;
|
|
616
|
+
}
|
|
617
|
+
if (data.country_code !== undefined) {
|
|
618
|
+
locationData.countryCode = data.country_code;
|
|
619
|
+
}
|
|
620
|
+
if (data.region !== undefined) {
|
|
621
|
+
locationData.regionName = data.region;
|
|
622
|
+
}
|
|
623
|
+
if (data.connection?.isp !== undefined) {
|
|
624
|
+
locationData.isp = data.connection.isp;
|
|
625
|
+
}
|
|
626
|
+
if (data.connection?.org !== undefined) {
|
|
627
|
+
locationData.org = data.connection.org;
|
|
628
|
+
}
|
|
629
|
+
if (data.connection?.asn !== undefined) {
|
|
630
|
+
locationData.as = `AS${data.connection.asn}`;
|
|
631
|
+
}
|
|
632
|
+
if (data.timezone?.id !== undefined) {
|
|
633
|
+
locationData.timezone = data.timezone.id;
|
|
634
|
+
}
|
|
635
|
+
locationData.query = data.ip;
|
|
636
|
+
return locationData;
|
|
637
|
+
}
|
|
638
|
+
catch (error) {
|
|
639
|
+
// Silently fail - don't break user experience
|
|
640
|
+
if (error.name !== 'AbortError') {
|
|
641
|
+
console.warn('[IP Geolocation] Error fetching complete IP location from ipwho.is:', error.message);
|
|
642
|
+
}
|
|
643
|
+
return null;
|
|
644
|
+
}
|
|
645
|
+
}
|
|
646
|
+
/**
|
|
647
|
+
* Get public IP address using ipwho.is API (FALLBACK - lower priority)
|
|
648
|
+
* This is kept for backward compatibility and as a fallback
|
|
649
|
+
* Prefer getCompleteIPLocation() which gets everything in one call
|
|
650
|
+
*
|
|
651
|
+
* @returns Promise<string | null> - The public IP address, or null if unavailable
|
|
652
|
+
*
|
|
653
|
+
* @example
|
|
654
|
+
* ```typescript
|
|
655
|
+
* const ip = await getPublicIP();
|
|
656
|
+
* console.log('Your IP:', ip); // e.g., "203.0.113.42"
|
|
657
|
+
* ```
|
|
658
|
+
*/
|
|
659
|
+
async function getPublicIP() {
|
|
660
|
+
// Try to get complete location first (includes IP)
|
|
661
|
+
const completeLocation = await getCompleteIPLocation();
|
|
662
|
+
if (completeLocation?.ip) {
|
|
663
|
+
return completeLocation.ip;
|
|
664
|
+
}
|
|
665
|
+
// Fallback: try direct IP fetch (less efficient, lower priority)
|
|
666
|
+
try {
|
|
667
|
+
const response = await fetch('https://ipwho.is/', {
|
|
668
|
+
method: 'GET',
|
|
669
|
+
headers: {
|
|
670
|
+
Accept: 'application/json',
|
|
671
|
+
},
|
|
672
|
+
signal: AbortSignal.timeout(5000),
|
|
673
|
+
});
|
|
674
|
+
if (!response.ok) {
|
|
675
|
+
return null;
|
|
676
|
+
}
|
|
677
|
+
const data = await response.json();
|
|
678
|
+
if (data.success === false) {
|
|
679
|
+
return null;
|
|
680
|
+
}
|
|
681
|
+
return data.ip || null;
|
|
682
|
+
}
|
|
683
|
+
catch (error) {
|
|
684
|
+
if (error.name !== 'AbortError') {
|
|
685
|
+
console.warn('[IP Geolocation] Error fetching public IP (fallback):', error.message);
|
|
686
|
+
}
|
|
687
|
+
return null;
|
|
688
|
+
}
|
|
689
|
+
}
|
|
690
|
+
/**
|
|
691
|
+
* Get location from IP address using ipwho.is API (HIGH PRIORITY)
|
|
692
|
+
* Free tier: No API key required
|
|
693
|
+
*
|
|
694
|
+
* Stores all keys dynamically from the API response, including nested objects
|
|
695
|
+
* This ensures we capture all available data and any new fields added by the API
|
|
696
|
+
*
|
|
697
|
+
* Note: If you don't have an IP yet, use getCompleteIPLocation() which gets everything in one call
|
|
698
|
+
*/
|
|
699
|
+
async function getIPLocation(ip) {
|
|
700
|
+
// Skip localhost/private IPs (these can't be geolocated)
|
|
701
|
+
if (!ip ||
|
|
702
|
+
ip === '0.0.0.0' ||
|
|
703
|
+
ip === '::1' ||
|
|
704
|
+
ip.startsWith('127.') ||
|
|
705
|
+
ip.startsWith('192.168.') ||
|
|
706
|
+
ip.startsWith('10.') ||
|
|
707
|
+
ip.startsWith('172.') ||
|
|
708
|
+
ip.startsWith('::ffff:127.')) {
|
|
709
|
+
console.log(`[IP Geolocation] Skipping localhost/private IP: ${ip} (geolocation not available for local IPs)`);
|
|
710
|
+
return null;
|
|
711
|
+
}
|
|
712
|
+
try {
|
|
713
|
+
// Using ipwho.is API (no API key required)
|
|
714
|
+
const response = await fetch(`https://ipwho.is/${ip}`, {
|
|
715
|
+
method: 'GET',
|
|
716
|
+
headers: {
|
|
717
|
+
Accept: 'application/json',
|
|
718
|
+
},
|
|
719
|
+
// Add timeout to prevent hanging
|
|
720
|
+
signal: AbortSignal.timeout(5000),
|
|
721
|
+
});
|
|
722
|
+
if (!response.ok) {
|
|
723
|
+
console.warn(`[IP Geolocation] Failed to fetch location for IP ${ip}: ${response.status}`);
|
|
724
|
+
return null;
|
|
725
|
+
}
|
|
726
|
+
const data = await response.json();
|
|
727
|
+
// ipwho.is returns success field
|
|
728
|
+
if (data.success === false) {
|
|
729
|
+
console.warn(`[IP Geolocation] API error for IP ${ip}: ${data.message || 'Unknown error'}`);
|
|
730
|
+
return null;
|
|
731
|
+
}
|
|
732
|
+
// Store all keys dynamically from the response
|
|
733
|
+
// This ensures we capture all fields, including nested objects and any new fields
|
|
734
|
+
const locationData = {
|
|
735
|
+
ip: data.ip || ip,
|
|
736
|
+
// Map all fields from the API response dynamically
|
|
737
|
+
...Object.keys(data).reduce((acc, key) => {
|
|
738
|
+
// Store all keys and their values, preserving nested objects
|
|
739
|
+
acc[key] = data[key];
|
|
740
|
+
return acc;
|
|
741
|
+
}, {}),
|
|
742
|
+
};
|
|
743
|
+
// Add backward compatibility mappings for existing code
|
|
744
|
+
if (data.latitude !== undefined) {
|
|
745
|
+
locationData.lat = data.latitude;
|
|
746
|
+
}
|
|
747
|
+
if (data.longitude !== undefined) {
|
|
748
|
+
locationData.lon = data.longitude;
|
|
749
|
+
}
|
|
750
|
+
if (data.country_code !== undefined) {
|
|
751
|
+
locationData.countryCode = data.country_code;
|
|
752
|
+
}
|
|
753
|
+
if (data.region !== undefined) {
|
|
754
|
+
locationData.regionName = data.region;
|
|
755
|
+
}
|
|
756
|
+
if (data.connection?.isp !== undefined) {
|
|
757
|
+
locationData.isp = data.connection.isp;
|
|
758
|
+
}
|
|
759
|
+
if (data.connection?.org !== undefined) {
|
|
760
|
+
locationData.org = data.connection.org;
|
|
761
|
+
}
|
|
762
|
+
if (data.connection?.asn !== undefined) {
|
|
763
|
+
locationData.as = `AS${data.connection.asn}`;
|
|
764
|
+
}
|
|
765
|
+
if (data.timezone?.id !== undefined) {
|
|
766
|
+
locationData.timezone = data.timezone.id;
|
|
767
|
+
}
|
|
768
|
+
locationData.query = data.ip || ip;
|
|
769
|
+
return locationData;
|
|
770
|
+
}
|
|
771
|
+
catch (error) {
|
|
772
|
+
// Silently fail - don't break user experience
|
|
773
|
+
if (error.name !== 'AbortError') {
|
|
774
|
+
console.warn(`[IP Geolocation] Error fetching location for IP ${ip}:`, error.message);
|
|
775
|
+
}
|
|
776
|
+
return null;
|
|
777
|
+
}
|
|
778
|
+
}
|
|
779
|
+
/**
|
|
780
|
+
* Get IP address from request headers
|
|
781
|
+
* Handles various proxy headers (x-forwarded-for, x-real-ip, etc.)
|
|
782
|
+
*/
|
|
783
|
+
function getIPFromRequest(req) {
|
|
784
|
+
// Try various headers that proxies/load balancers use
|
|
785
|
+
const forwardedFor = req.headers?.get?.('x-forwarded-for') ||
|
|
786
|
+
req.headers?.['x-forwarded-for'] ||
|
|
787
|
+
req.headers?.['X-Forwarded-For'];
|
|
788
|
+
if (forwardedFor) {
|
|
789
|
+
// x-forwarded-for can contain multiple IPs, take the first one
|
|
790
|
+
const ips = forwardedFor.split(',').map((ip) => ip.trim());
|
|
791
|
+
const ip = ips[0];
|
|
792
|
+
if (ip && ip !== '0.0.0.0') {
|
|
793
|
+
return ip;
|
|
794
|
+
}
|
|
795
|
+
}
|
|
796
|
+
const realIP = req.headers?.get?.('x-real-ip') ||
|
|
797
|
+
req.headers?.['x-real-ip'] ||
|
|
798
|
+
req.headers?.['X-Real-IP'];
|
|
799
|
+
if (realIP && realIP !== '0.0.0.0') {
|
|
800
|
+
return realIP.trim();
|
|
801
|
+
}
|
|
802
|
+
// Try req.ip (from Express/Next.js)
|
|
803
|
+
if (req.ip && req.ip !== '0.0.0.0') {
|
|
804
|
+
return req.ip;
|
|
805
|
+
}
|
|
806
|
+
// For localhost, detect if we're running locally
|
|
807
|
+
if (typeof window === 'undefined') {
|
|
808
|
+
const hostname = req.headers?.get?.('host') || req.headers?.['host'];
|
|
809
|
+
if (hostname &&
|
|
810
|
+
(hostname.includes('localhost') ||
|
|
811
|
+
hostname.includes('127.0.0.1') ||
|
|
812
|
+
hostname.startsWith('192.168.'))) {
|
|
813
|
+
return '127.0.0.1'; // Localhost IP
|
|
814
|
+
}
|
|
815
|
+
}
|
|
816
|
+
// If no IP found and we're in development, return localhost
|
|
817
|
+
if (typeof process !== 'undefined' && process.env?.NODE_ENV === 'development') {
|
|
818
|
+
return '127.0.0.1'; // Localhost for development
|
|
819
|
+
}
|
|
820
|
+
return '0.0.0.0';
|
|
821
|
+
}
|
|
822
|
+
|
|
552
823
|
/**
|
|
553
824
|
* Location Detector
|
|
554
825
|
* Detects GPS location with consent management, falls back to IP-based location API
|
|
@@ -813,7 +1084,8 @@ class LocationDetector {
|
|
|
813
1084
|
/**
|
|
814
1085
|
* Get location from IP-based public API (client-side)
|
|
815
1086
|
* Works without user permission, good fallback when GPS is unavailable
|
|
816
|
-
* Uses
|
|
1087
|
+
* Uses ipwho.is API (no API key required)
|
|
1088
|
+
* Stores all keys dynamically from the API response
|
|
817
1089
|
*/
|
|
818
1090
|
static async getIPBasedLocation() {
|
|
819
1091
|
// Return cached IP location if available
|
|
@@ -849,51 +1121,55 @@ class LocationDetector {
|
|
|
849
1121
|
}
|
|
850
1122
|
this.ipLocationFetchingRef.current = true;
|
|
851
1123
|
try {
|
|
852
|
-
//
|
|
853
|
-
//
|
|
854
|
-
|
|
855
|
-
|
|
856
|
-
|
|
857
|
-
|
|
858
|
-
|
|
859
|
-
|
|
860
|
-
|
|
861
|
-
|
|
862
|
-
|
|
863
|
-
|
|
1124
|
+
// HIGH PRIORITY: Get complete IP location data from ipwho.is in one call
|
|
1125
|
+
// This gets IP, location, connection, timezone, flag, and all other data at once
|
|
1126
|
+
// More efficient than making separate calls
|
|
1127
|
+
let ipLocation = await getCompleteIPLocation();
|
|
1128
|
+
// If complete location fetch failed, try fallback: get IP first, then location
|
|
1129
|
+
if (!ipLocation) {
|
|
1130
|
+
console.log('[Location] Primary ipwho.is call failed, trying fallback...');
|
|
1131
|
+
const publicIP = await getPublicIP();
|
|
1132
|
+
if (publicIP) {
|
|
1133
|
+
// Fallback: Get location from IP using ipwho.is API
|
|
1134
|
+
ipLocation = await getIPLocation(publicIP);
|
|
1135
|
+
}
|
|
864
1136
|
}
|
|
865
|
-
|
|
866
|
-
|
|
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;
|
|
1137
|
+
if (!ipLocation) {
|
|
1138
|
+
throw new Error('Could not fetch location data from ipwho.is');
|
|
875
1139
|
}
|
|
876
1140
|
// Convert IP location to LocationInfo format
|
|
1141
|
+
// Map all available fields from the IP location response
|
|
1142
|
+
// Handle timezone which can be either a string or an object
|
|
1143
|
+
const timezoneValue = typeof ipLocation.timezone === 'string'
|
|
1144
|
+
? ipLocation.timezone
|
|
1145
|
+
: ipLocation.timezone?.id || undefined;
|
|
877
1146
|
const locationResult = {
|
|
878
|
-
lat:
|
|
879
|
-
lon:
|
|
1147
|
+
lat: ipLocation.latitude ?? ipLocation.lat ?? null,
|
|
1148
|
+
lon: ipLocation.longitude ?? ipLocation.lon ?? null,
|
|
880
1149
|
accuracy: null, // IP-based location has no accuracy metric
|
|
881
1150
|
permission: 'granted', // IP location doesn't require permission
|
|
882
1151
|
source: 'ip',
|
|
883
1152
|
ts: new Date().toISOString(),
|
|
884
|
-
ip:
|
|
885
|
-
country:
|
|
886
|
-
countryCode:
|
|
887
|
-
city:
|
|
888
|
-
region:
|
|
889
|
-
timezone:
|
|
1153
|
+
ip: ipLocation.ip || undefined,
|
|
1154
|
+
country: ipLocation.country || undefined,
|
|
1155
|
+
countryCode: ipLocation.country_code || ipLocation.countryCode || undefined,
|
|
1156
|
+
city: ipLocation.city || undefined,
|
|
1157
|
+
region: ipLocation.region || ipLocation.regionName || undefined,
|
|
1158
|
+
timezone: timezoneValue,
|
|
890
1159
|
};
|
|
891
|
-
|
|
1160
|
+
// Store the full IP location data in a custom field for access to all keys
|
|
1161
|
+
// This preserves all dynamic keys from the API response
|
|
1162
|
+
locationResult.ipLocationData = ipLocation;
|
|
1163
|
+
console.log('[Location] IP-based location obtained from ipwho.is:', {
|
|
892
1164
|
ip: locationResult.ip,
|
|
893
1165
|
lat: locationResult.lat,
|
|
894
1166
|
lon: locationResult.lon,
|
|
895
1167
|
city: locationResult.city,
|
|
896
1168
|
country: locationResult.country,
|
|
1169
|
+
continent: ipLocation.continent,
|
|
1170
|
+
timezone: locationResult.timezone,
|
|
1171
|
+
isp: ipLocation.connection?.isp,
|
|
1172
|
+
connection: ipLocation.connection,
|
|
897
1173
|
});
|
|
898
1174
|
this.lastIPLocationRef.current = locationResult;
|
|
899
1175
|
return locationResult;
|
|
@@ -1730,6 +2006,73 @@ class MetricsCollector {
|
|
|
1730
2006
|
// Global metrics collector instance
|
|
1731
2007
|
const metricsCollector = new MetricsCollector();
|
|
1732
2008
|
|
|
2009
|
+
/**
|
|
2010
|
+
* Transform IP location data from API format (snake_case) to backend-expected format (camelCase)
|
|
2011
|
+
* This ensures compatibility with the analytics backend integration
|
|
2012
|
+
*
|
|
2013
|
+
* @param ipLocation - Raw IP location data from ipwho.is API
|
|
2014
|
+
* @returns Transformed IP location data matching backend schema
|
|
2015
|
+
*/
|
|
2016
|
+
function transformIPLocationForBackend(ipLocation) {
|
|
2017
|
+
if (!ipLocation) {
|
|
2018
|
+
return null;
|
|
2019
|
+
}
|
|
2020
|
+
// Transform to match backend expected format (camelCase)
|
|
2021
|
+
const transformed = {
|
|
2022
|
+
// Basic fields
|
|
2023
|
+
ip: ipLocation.ip,
|
|
2024
|
+
country: ipLocation.country,
|
|
2025
|
+
countryCode: ipLocation.country_code || ipLocation.countryCode,
|
|
2026
|
+
region: ipLocation.region || ipLocation.regionName,
|
|
2027
|
+
city: ipLocation.city,
|
|
2028
|
+
postal: ipLocation.postal,
|
|
2029
|
+
capital: ipLocation.capital,
|
|
2030
|
+
callingCode: ipLocation.calling_code || ipLocation.callingCode,
|
|
2031
|
+
// Geographic fields
|
|
2032
|
+
continent: ipLocation.continent,
|
|
2033
|
+
continentCode: ipLocation.continent_code || ipLocation.continentCode,
|
|
2034
|
+
lat: ipLocation.latitude ?? ipLocation.lat,
|
|
2035
|
+
lon: ipLocation.longitude ?? ipLocation.lon,
|
|
2036
|
+
borders: ipLocation.borders,
|
|
2037
|
+
// Network fields
|
|
2038
|
+
type: ipLocation.type,
|
|
2039
|
+
isEu: ipLocation.is_eu ?? ipLocation.isEu,
|
|
2040
|
+
// ISP/Connection - preserve connection object and also add top-level isp
|
|
2041
|
+
isp: ipLocation.connection?.isp || ipLocation.isp,
|
|
2042
|
+
connection: ipLocation.connection ? {
|
|
2043
|
+
asn: ipLocation.connection.asn,
|
|
2044
|
+
org: ipLocation.connection.org,
|
|
2045
|
+
isp: ipLocation.connection.isp,
|
|
2046
|
+
domain: ipLocation.connection.domain,
|
|
2047
|
+
} : undefined,
|
|
2048
|
+
// Timezone - store both simple string and full details object
|
|
2049
|
+
timezone: typeof ipLocation.timezone === 'string'
|
|
2050
|
+
? ipLocation.timezone
|
|
2051
|
+
: ipLocation.timezone?.id,
|
|
2052
|
+
timezoneDetails: ipLocation.timezone && typeof ipLocation.timezone === 'object' ? {
|
|
2053
|
+
id: ipLocation.timezone.id,
|
|
2054
|
+
abbr: ipLocation.timezone.abbr,
|
|
2055
|
+
isDst: ipLocation.timezone.is_dst,
|
|
2056
|
+
offset: ipLocation.timezone.offset,
|
|
2057
|
+
utc: ipLocation.timezone.utc,
|
|
2058
|
+
currentTime: ipLocation.timezone.current_time,
|
|
2059
|
+
} : undefined,
|
|
2060
|
+
// Flag - transform to camelCase
|
|
2061
|
+
flag: ipLocation.flag ? {
|
|
2062
|
+
img: ipLocation.flag.img,
|
|
2063
|
+
emoji: ipLocation.flag.emoji,
|
|
2064
|
+
emojiUnicode: ipLocation.flag.emoji_unicode,
|
|
2065
|
+
} : undefined,
|
|
2066
|
+
};
|
|
2067
|
+
// Remove undefined values to keep the payload clean
|
|
2068
|
+
Object.keys(transformed).forEach(key => {
|
|
2069
|
+
if (transformed[key] === undefined) {
|
|
2070
|
+
delete transformed[key];
|
|
2071
|
+
}
|
|
2072
|
+
});
|
|
2073
|
+
return transformed;
|
|
2074
|
+
}
|
|
2075
|
+
|
|
1733
2076
|
/**
|
|
1734
2077
|
* Analytics Service
|
|
1735
2078
|
* Sends analytics events to your backend API
|
|
@@ -1993,6 +2336,8 @@ class AnalyticsService {
|
|
|
1993
2336
|
* Track user journey with full context
|
|
1994
2337
|
*/
|
|
1995
2338
|
static async trackUserJourney({ sessionId, pageUrl, networkInfo, deviceInfo, location, attribution, ipLocation, userId, customData, pageVisits = 1, interactions = 0, }) {
|
|
2339
|
+
// Transform IP location data to match backend expected format (camelCase)
|
|
2340
|
+
const transformedIPLocation = transformIPLocationForBackend(ipLocation);
|
|
1996
2341
|
await this.trackEvent({
|
|
1997
2342
|
sessionId,
|
|
1998
2343
|
pageUrl,
|
|
@@ -2004,7 +2349,8 @@ class AnalyticsService {
|
|
|
2004
2349
|
userId: userId ?? sessionId,
|
|
2005
2350
|
customData: {
|
|
2006
2351
|
...customData,
|
|
2007
|
-
|
|
2352
|
+
// Store transformed IP location in customData for backend integration
|
|
2353
|
+
...(transformedIPLocation && { ipLocation: transformedIPLocation }),
|
|
2008
2354
|
},
|
|
2009
2355
|
eventName: 'page_view', // Auto-tracked as page view
|
|
2010
2356
|
});
|
|
@@ -2075,6 +2421,13 @@ class AnalyticsService {
|
|
|
2075
2421
|
}
|
|
2076
2422
|
const finalSessionId = context?.sessionId || autoContext?.sessionId || 'unknown';
|
|
2077
2423
|
const finalPageUrl = context?.pageUrl || autoContext?.pageUrl || '';
|
|
2424
|
+
// Extract IP location from location object if available
|
|
2425
|
+
const locationData = context?.location || autoContext?.location;
|
|
2426
|
+
const ipLocationData = locationData && typeof locationData === 'object'
|
|
2427
|
+
? locationData?.ipLocationData
|
|
2428
|
+
: undefined;
|
|
2429
|
+
// Transform IP location data to match backend expected format
|
|
2430
|
+
const transformedIPLocation = transformIPLocationForBackend(ipLocationData);
|
|
2078
2431
|
await this.trackEvent({
|
|
2079
2432
|
sessionId: finalSessionId,
|
|
2080
2433
|
pageUrl: finalPageUrl,
|
|
@@ -2085,7 +2438,11 @@ class AnalyticsService {
|
|
|
2085
2438
|
userId: context?.userId || finalSessionId,
|
|
2086
2439
|
eventName,
|
|
2087
2440
|
eventParameters: parameters || {},
|
|
2088
|
-
customData:
|
|
2441
|
+
customData: {
|
|
2442
|
+
...(parameters || {}),
|
|
2443
|
+
// Store transformed IP location in customData for backend integration
|
|
2444
|
+
...(transformedIPLocation && { ipLocation: transformedIPLocation }),
|
|
2445
|
+
},
|
|
2089
2446
|
});
|
|
2090
2447
|
}
|
|
2091
2448
|
/**
|
|
@@ -2376,6 +2733,8 @@ function useAnalytics(options = {}) {
|
|
|
2376
2733
|
if (autoSend) {
|
|
2377
2734
|
// Send after idle to not block paint
|
|
2378
2735
|
const send = async () => {
|
|
2736
|
+
// Extract IP location data if available (stored in ipLocationData field)
|
|
2737
|
+
const ipLocationData = loc?.ipLocationData;
|
|
2379
2738
|
await AnalyticsService.trackUserJourney({
|
|
2380
2739
|
sessionId: getOrCreateUserId(),
|
|
2381
2740
|
pageUrl: typeof window !== 'undefined' ? window.location.href : '',
|
|
@@ -2383,6 +2742,7 @@ function useAnalytics(options = {}) {
|
|
|
2383
2742
|
deviceInfo: dev,
|
|
2384
2743
|
location: loc,
|
|
2385
2744
|
attribution: attr,
|
|
2745
|
+
ipLocation: ipLocationData,
|
|
2386
2746
|
customData: config?.enableLocation ? { locationEnabled: true } : undefined,
|
|
2387
2747
|
});
|
|
2388
2748
|
};
|
|
@@ -2398,6 +2758,8 @@ function useAnalytics(options = {}) {
|
|
|
2398
2758
|
const logEvent = useCallback(async (customData) => {
|
|
2399
2759
|
if (!sessionId || !networkInfo || !deviceInfo)
|
|
2400
2760
|
return;
|
|
2761
|
+
// Extract IP location data if available (stored in ipLocationData field)
|
|
2762
|
+
const ipLocationData = location ? location?.ipLocationData : undefined;
|
|
2401
2763
|
await AnalyticsService.trackUserJourney({
|
|
2402
2764
|
sessionId,
|
|
2403
2765
|
pageUrl: typeof window !== 'undefined' ? window.location.href : '',
|
|
@@ -2405,6 +2767,7 @@ function useAnalytics(options = {}) {
|
|
|
2405
2767
|
deviceInfo,
|
|
2406
2768
|
location: location ?? undefined,
|
|
2407
2769
|
attribution: attribution ?? undefined,
|
|
2770
|
+
ipLocation: ipLocationData,
|
|
2408
2771
|
userId: sessionId,
|
|
2409
2772
|
customData,
|
|
2410
2773
|
});
|
|
@@ -2542,166 +2905,5 @@ function useAnalytics(options = {}) {
|
|
|
2542
2905
|
]);
|
|
2543
2906
|
}
|
|
2544
2907
|
|
|
2545
|
-
|
|
2546
|
-
* IP Geolocation Service
|
|
2547
|
-
* Fetches location data (country, region, city) from user's IP address
|
|
2548
|
-
* Uses free tier of ip-api.com (no API key required, 45 requests/minute)
|
|
2549
|
-
*/
|
|
2550
|
-
/**
|
|
2551
|
-
* Get public IP address using ip-api.com
|
|
2552
|
-
* Free tier: 45 requests/minute, no API key required
|
|
2553
|
-
*
|
|
2554
|
-
* @returns Promise<string | null> - The public IP address, or null if unavailable
|
|
2555
|
-
*
|
|
2556
|
-
* @example
|
|
2557
|
-
* ```typescript
|
|
2558
|
-
* const ip = await getPublicIP();
|
|
2559
|
-
* console.log('Your IP:', ip); // e.g., "203.0.113.42"
|
|
2560
|
-
* ```
|
|
2561
|
-
*/
|
|
2562
|
-
async function getPublicIP() {
|
|
2563
|
-
// Skip if we're in an environment without fetch (SSR)
|
|
2564
|
-
if (typeof fetch === 'undefined') {
|
|
2565
|
-
return null;
|
|
2566
|
-
}
|
|
2567
|
-
try {
|
|
2568
|
-
// Call ip-api.com without IP parameter - it auto-detects user's IP
|
|
2569
|
-
// Using HTTPS endpoint for better security
|
|
2570
|
-
const response = await fetch('https://ip-api.com/json/?fields=status,message,query', {
|
|
2571
|
-
method: 'GET',
|
|
2572
|
-
headers: {
|
|
2573
|
-
Accept: 'application/json',
|
|
2574
|
-
},
|
|
2575
|
-
// Add timeout to prevent hanging
|
|
2576
|
-
signal: AbortSignal.timeout(5000),
|
|
2577
|
-
});
|
|
2578
|
-
if (!response.ok) {
|
|
2579
|
-
return null;
|
|
2580
|
-
}
|
|
2581
|
-
const data = await response.json();
|
|
2582
|
-
// ip-api.com returns status field
|
|
2583
|
-
if (data.status === 'fail') {
|
|
2584
|
-
return null;
|
|
2585
|
-
}
|
|
2586
|
-
return data.query || null;
|
|
2587
|
-
}
|
|
2588
|
-
catch (error) {
|
|
2589
|
-
// Silently fail - don't break user experience
|
|
2590
|
-
if (error.name !== 'AbortError') {
|
|
2591
|
-
console.warn('[IP Geolocation] Error fetching public IP:', error.message);
|
|
2592
|
-
}
|
|
2593
|
-
return null;
|
|
2594
|
-
}
|
|
2595
|
-
}
|
|
2596
|
-
/**
|
|
2597
|
-
* Get location from IP address using ip-api.com
|
|
2598
|
-
* Free tier: 45 requests/minute, no API key required
|
|
2599
|
-
*
|
|
2600
|
-
* Alternative services:
|
|
2601
|
-
* - ipapi.co (requires API key for production)
|
|
2602
|
-
* - ipgeolocation.io (requires API key)
|
|
2603
|
-
* - ip-api.com (free tier available)
|
|
2604
|
-
*/
|
|
2605
|
-
async function getIPLocation(ip) {
|
|
2606
|
-
// Skip localhost/private IPs (these can't be geolocated)
|
|
2607
|
-
if (!ip ||
|
|
2608
|
-
ip === '0.0.0.0' ||
|
|
2609
|
-
ip === '::1' ||
|
|
2610
|
-
ip.startsWith('127.') ||
|
|
2611
|
-
ip.startsWith('192.168.') ||
|
|
2612
|
-
ip.startsWith('10.') ||
|
|
2613
|
-
ip.startsWith('172.') ||
|
|
2614
|
-
ip.startsWith('::ffff:127.')) {
|
|
2615
|
-
console.log(`[IP Geolocation] Skipping localhost/private IP: ${ip} (geolocation not available for local IPs)`);
|
|
2616
|
-
return null;
|
|
2617
|
-
}
|
|
2618
|
-
try {
|
|
2619
|
-
// Using ip-api.com free tier (JSON format)
|
|
2620
|
-
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`, {
|
|
2621
|
-
method: 'GET',
|
|
2622
|
-
headers: {
|
|
2623
|
-
Accept: 'application/json',
|
|
2624
|
-
},
|
|
2625
|
-
// Add timeout to prevent hanging
|
|
2626
|
-
signal: AbortSignal.timeout(3000),
|
|
2627
|
-
});
|
|
2628
|
-
if (!response.ok) {
|
|
2629
|
-
console.warn(`[IP Geolocation] Failed to fetch location for IP ${ip}: ${response.status}`);
|
|
2630
|
-
return null;
|
|
2631
|
-
}
|
|
2632
|
-
const data = await response.json();
|
|
2633
|
-
// ip-api.com returns status field
|
|
2634
|
-
if (data.status === 'fail') {
|
|
2635
|
-
console.warn(`[IP Geolocation] API error for IP ${ip}: ${data.message}`);
|
|
2636
|
-
return null;
|
|
2637
|
-
}
|
|
2638
|
-
return {
|
|
2639
|
-
ip: data.query || ip,
|
|
2640
|
-
country: data.country || undefined,
|
|
2641
|
-
countryCode: data.countryCode || undefined,
|
|
2642
|
-
region: data.region || undefined,
|
|
2643
|
-
regionName: data.regionName || undefined,
|
|
2644
|
-
city: data.city || undefined,
|
|
2645
|
-
lat: data.lat || undefined,
|
|
2646
|
-
lon: data.lon || undefined,
|
|
2647
|
-
timezone: data.timezone || undefined,
|
|
2648
|
-
isp: data.isp || undefined,
|
|
2649
|
-
org: data.org || undefined,
|
|
2650
|
-
as: data.as || undefined,
|
|
2651
|
-
query: data.query || ip,
|
|
2652
|
-
};
|
|
2653
|
-
}
|
|
2654
|
-
catch (error) {
|
|
2655
|
-
// Silently fail - don't break user experience
|
|
2656
|
-
if (error.name !== 'AbortError') {
|
|
2657
|
-
console.warn(`[IP Geolocation] Error fetching location for IP ${ip}:`, error.message);
|
|
2658
|
-
}
|
|
2659
|
-
return null;
|
|
2660
|
-
}
|
|
2661
|
-
}
|
|
2662
|
-
/**
|
|
2663
|
-
* Get IP address from request headers
|
|
2664
|
-
* Handles various proxy headers (x-forwarded-for, x-real-ip, etc.)
|
|
2665
|
-
*/
|
|
2666
|
-
function getIPFromRequest(req) {
|
|
2667
|
-
// Try various headers that proxies/load balancers use
|
|
2668
|
-
const forwardedFor = req.headers?.get?.('x-forwarded-for') ||
|
|
2669
|
-
req.headers?.['x-forwarded-for'] ||
|
|
2670
|
-
req.headers?.['X-Forwarded-For'];
|
|
2671
|
-
if (forwardedFor) {
|
|
2672
|
-
// x-forwarded-for can contain multiple IPs, take the first one
|
|
2673
|
-
const ips = forwardedFor.split(',').map((ip) => ip.trim());
|
|
2674
|
-
const ip = ips[0];
|
|
2675
|
-
if (ip && ip !== '0.0.0.0') {
|
|
2676
|
-
return ip;
|
|
2677
|
-
}
|
|
2678
|
-
}
|
|
2679
|
-
const realIP = req.headers?.get?.('x-real-ip') ||
|
|
2680
|
-
req.headers?.['x-real-ip'] ||
|
|
2681
|
-
req.headers?.['X-Real-IP'];
|
|
2682
|
-
if (realIP && realIP !== '0.0.0.0') {
|
|
2683
|
-
return realIP.trim();
|
|
2684
|
-
}
|
|
2685
|
-
// Try req.ip (from Express/Next.js)
|
|
2686
|
-
if (req.ip && req.ip !== '0.0.0.0') {
|
|
2687
|
-
return req.ip;
|
|
2688
|
-
}
|
|
2689
|
-
// For localhost, detect if we're running locally
|
|
2690
|
-
if (typeof window === 'undefined') {
|
|
2691
|
-
const hostname = req.headers?.get?.('host') || req.headers?.['host'];
|
|
2692
|
-
if (hostname &&
|
|
2693
|
-
(hostname.includes('localhost') ||
|
|
2694
|
-
hostname.includes('127.0.0.1') ||
|
|
2695
|
-
hostname.startsWith('192.168.'))) {
|
|
2696
|
-
return '127.0.0.1'; // Localhost IP
|
|
2697
|
-
}
|
|
2698
|
-
}
|
|
2699
|
-
// If no IP found and we're in development, return localhost
|
|
2700
|
-
if (typeof process !== 'undefined' && process.env?.NODE_ENV === 'development') {
|
|
2701
|
-
return '127.0.0.1'; // Localhost for development
|
|
2702
|
-
}
|
|
2703
|
-
return '0.0.0.0';
|
|
2704
|
-
}
|
|
2705
|
-
|
|
2706
|
-
export { AnalyticsService, AttributionDetector, DeviceDetector, LocationDetector, NetworkDetector, QueueManager, checkAndSetLocationConsent, clearLocationConsent, clearSession, useAnalytics as default, getIPFromRequest, getIPLocation, getLocationConsentTimestamp, getOrCreateSession, getOrCreateUserId, getPublicIP, getSession, hasLocationConsent, initDebug, loadJSON, loadSessionJSON, logger, saveJSON, saveSessionJSON, setLocationConsentGranted, trackPageVisit, updateSessionActivity, useAnalytics };
|
|
2908
|
+
export { AnalyticsService, AttributionDetector, DeviceDetector, LocationDetector, NetworkDetector, QueueManager, checkAndSetLocationConsent, clearLocationConsent, clearSession, useAnalytics as default, getCompleteIPLocation, getIPFromRequest, getIPLocation, getLocationConsentTimestamp, getOrCreateSession, getOrCreateUserId, getPublicIP, getSession, hasLocationConsent, initDebug, loadJSON, loadSessionJSON, logger, saveJSON, saveSessionJSON, setLocationConsentGranted, trackPageVisit, transformIPLocationForBackend, updateSessionActivity, useAnalytics };
|
|
2707
2909
|
//# sourceMappingURL=index.esm.js.map
|