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/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 ip-api.com free tier (no API key required, 45 requests/minute)
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
- // Call ip-api.com without IP parameter - it auto-detects user's IP
857
- // Using HTTPS endpoint for better security
858
- const response = await fetch('https://ip-api.com/json/?fields=status,message,country,countryCode,region,regionName,city,lat,lon,timezone,query', {
859
- method: 'GET',
860
- headers: {
861
- Accept: 'application/json',
862
- },
863
- // Add timeout to prevent hanging
864
- signal: AbortSignal.timeout(5000),
865
- });
866
- if (!response.ok) {
867
- throw new Error(`HTTP ${response.status}`);
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
- const data = await response.json();
870
- // ip-api.com returns status field
871
- if (data.status === 'fail') {
872
- console.warn(`[Location] IP API error: ${data.message}`);
873
- const fallback = {
874
- source: 'unknown',
875
- permission: 'granted',
876
- };
877
- this.lastIPLocationRef.current = fallback;
878
- return fallback;
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: data.lat || null,
883
- lon: data.lon || null,
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: data.query || null, // Public IP address
889
- country: data.country || undefined,
890
- countryCode: data.countryCode || undefined,
891
- city: data.city || undefined,
892
- region: data.regionName || data.region || undefined,
893
- timezone: data.timezone || undefined,
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
- console.log('[Location] IP-based location obtained:', {
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
- ...(ipLocation && { ipLocation }),
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: parameters || {},
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