generated from mantinedev/vite-template
-
Notifications
You must be signed in to change notification settings - Fork 1
IP-based Country Detection for Auto-redirect #213
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Open
SakshiKekre
wants to merge
5
commits into
main
Choose a base branch
from
feat/user-country-location
base: main
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
Open
Changes from all commits
Commits
Show all changes
5 commits
Select commit
Hold shift + click to select a range
00d1171
InWork Router
SakshiKekre 0840e8b
fix: Remove debug logs
SakshiKekre 6e91e30
feat: Detect default user country based on ip geolocation
SakshiKekre 2b49c0c
fix: Minor cleanup
SakshiKekre 413a58b
fix: Update message to be non-invasive
SakshiKekre File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,100 @@ | ||
| import { useEffect, useState } from 'react'; | ||
| import { Navigate } from 'react-router-dom'; | ||
| import { Box, Loader } from '@mantine/core'; | ||
| import { geolocationService } from './geolocation/GeolocationService'; | ||
|
|
||
| /** | ||
| * Component that redirects users to their country-specific route | ||
| * based on IP geolocation | ||
| */ | ||
| export function RedirectToCountry() { | ||
| const [detectedCountry, setDetectedCountry] = useState<string | null>(null); | ||
| const [isDetecting, setIsDetecting] = useState(true); | ||
|
|
||
| /** | ||
| * Checks if cached country data is still valid (within 4 hours) | ||
| */ | ||
| function getCachedCountry(): string | null { | ||
| const cachedData = localStorage.getItem('detectedCountry'); | ||
| if (!cachedData) { | ||
| return null; | ||
| } | ||
|
|
||
| try { | ||
| const { country, timestamp } = JSON.parse(cachedData); | ||
| const fourHoursInMs = 4 * 60 * 60 * 1000; | ||
|
|
||
| // Check if cache is still valid | ||
| if (Date.now() - timestamp < fourHoursInMs) { | ||
| return country; | ||
| } | ||
|
|
||
| // Cache expired, clean it up | ||
| localStorage.removeItem('detectedCountry'); | ||
| } catch { | ||
| // Invalid cached data format | ||
| localStorage.removeItem('detectedCountry'); | ||
| } | ||
|
|
||
| return null; | ||
| } | ||
|
|
||
| /** | ||
| * Saves detected country to cache with timestamp | ||
| */ | ||
| function cacheCountry(country: string): void { | ||
| localStorage.setItem( | ||
| 'detectedCountry', | ||
| JSON.stringify({ | ||
| country, | ||
| timestamp: Date.now(), | ||
| }) | ||
| ); | ||
| } | ||
|
|
||
| useEffect(() => { | ||
| async function initializeCountryDetection() { | ||
| // Check for cached country first | ||
| const cached = getCachedCountry(); | ||
| if (cached) { | ||
| setDetectedCountry(cached); | ||
| setIsDetecting(false); | ||
| return; | ||
| } | ||
|
|
||
| // Detect country using geolocation service (tries providers in order) | ||
| try { | ||
| const country = await geolocationService.detectCountry(); | ||
| setDetectedCountry(country); | ||
| cacheCountry(country); | ||
| } catch (error) { | ||
| // Fall back to default country on error | ||
| setDetectedCountry('us'); | ||
| } finally { | ||
| setIsDetecting(false); | ||
| } | ||
| } | ||
|
|
||
| initializeCountryDetection(); | ||
| }, []); | ||
|
|
||
| if (isDetecting) { | ||
| return ( | ||
| <Box | ||
| style={{ | ||
| display: 'flex', | ||
| flexDirection: 'column', | ||
| alignItems: 'center', | ||
| justifyContent: 'center', | ||
| height: '100vh', | ||
| gap: '16px', | ||
| }} | ||
| > | ||
| <Loader size="lg" /> | ||
| <div>Loading PolicyEngine...</div> | ||
| </Box> | ||
| ); | ||
| } | ||
|
|
||
| return <Navigate to={`/${detectedCountry || 'us'}`} replace />; | ||
| } | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,47 @@ | ||
| import { getDefaultCountry, mapIsoToRoute } from './countryMapper'; | ||
| import { BrowserProvider } from './providers/BrowserProvider'; | ||
| import { IpApiCoProvider } from './providers/IpApiCoProvider'; | ||
| import { GeolocationProvider } from './types'; | ||
|
|
||
| /** | ||
| * Coordinates geolocation providers to detect user's country. | ||
| * Tries providers in priority order and maps ISO codes to PolicyEngine routes. | ||
| */ | ||
| export class GeolocationService { | ||
| private providers: GeolocationProvider[] = []; | ||
|
|
||
| constructor() { | ||
| // Initialize providers in priority order | ||
| this.providers = [ | ||
| new IpApiCoProvider(), // Primary IP-based provider (30k free/month) | ||
| new BrowserProvider(), // Fallback using browser language | ||
| ]; | ||
|
|
||
| // Sort by priority (lower number = higher priority) | ||
| this.providers.sort((a, b) => a.priority - b.priority); | ||
| } | ||
|
|
||
| /** | ||
| * Detects user's country using available providers in priority order | ||
| * @returns PolicyEngine route code (e.g., 'us', 'uk') | ||
| */ | ||
| async detectCountry(): Promise<string> { | ||
| // Try each provider in priority order | ||
| for (const provider of this.providers) { | ||
| const isoCode = await provider.detect(); | ||
|
|
||
| if (isoCode) { | ||
| const routeCode = mapIsoToRoute(isoCode); | ||
| if (routeCode) { | ||
| return routeCode; | ||
| } | ||
| } | ||
| } | ||
|
|
||
| // If all providers fail or return unsupported countries, use default | ||
| return getDefaultCountry(); | ||
| } | ||
| } | ||
|
|
||
| // Export singleton instance | ||
| export const geolocationService = new GeolocationService(); |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,38 @@ | ||
| /** | ||
| * Maps ISO country codes to PolicyEngine route codes | ||
| */ | ||
|
|
||
| // Mapping from ISO 3166-1 alpha-2 codes to PolicyEngine routes | ||
| const ISO_TO_ROUTE_MAP: Record<string, string> = { | ||
| US: 'us', // United States | ||
| GB: 'uk', // United Kingdom (Great Britain) | ||
| UK: 'uk', // Alternative code sometimes used | ||
| CA: 'ca', // Canada | ||
| NG: 'ng', // Nigeria | ||
| IL: 'il', // Israel | ||
| }; | ||
|
|
||
| /** | ||
| * Converts ISO country code to PolicyEngine route code | ||
| * @param isoCode - ISO 3166-1 alpha-2 country code (e.g., 'US', 'GB') | ||
| * @returns PolicyEngine route code (e.g., 'us', 'uk') or null if not supported | ||
| */ | ||
| export function mapIsoToRoute(isoCode: string): string | null { | ||
| const upperCode = isoCode?.toUpperCase(); | ||
| return ISO_TO_ROUTE_MAP[upperCode] || null; | ||
| } | ||
|
|
||
| /** | ||
| * Checks if a country is supported by PolicyEngine | ||
| * @param isoCode - ISO 3166-1 alpha-2 country code | ||
| */ | ||
| export function isSupportedCountry(isoCode: string): boolean { | ||
| return mapIsoToRoute(isoCode) !== null; | ||
| } | ||
|
|
||
| /** | ||
| * Gets the default country for fallback | ||
| */ | ||
| export function getDefaultCountry(): string { | ||
| return 'us'; | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,44 @@ | ||
| import { GeolocationProvider } from '../types'; | ||
|
|
||
| /** | ||
| * Geolocation provider using browser language settings | ||
| * This is an instant fallback when IP detection fails or is too slow | ||
| */ | ||
| export class BrowserProvider implements GeolocationProvider { | ||
| name = 'Browser'; | ||
| priority = 3; // Lowest priority - final fallback | ||
|
|
||
| async detect(): Promise<string | null> { | ||
| try { | ||
| // Get browser language (e.g., 'en-US', 'en-GB', 'fr-CA') | ||
| const language = navigator.language || (navigator as any).userLanguage; | ||
|
|
||
| if (!language) { | ||
| return null; | ||
| } | ||
|
|
||
| // Extract country code from language tag | ||
| // Format is usually 'language-COUNTRY' (e.g., 'en-US') | ||
| const parts = language.split('-'); | ||
|
|
||
| if (parts.length >= 2) { | ||
| // Return the country part (already in ISO format) | ||
| return parts[1].toUpperCase(); | ||
| } | ||
|
|
||
| // If no country in language tag, try to infer from language | ||
| // This is a simple mapping for common cases | ||
| const languageCountryMap: Record<string, string> = { | ||
| en: 'US', // Default English to US | ||
| fr: 'CA', // French could be CA (PolicyEngine supports Canada) | ||
| he: 'IL', // Hebrew to Israel | ||
| // Add more mappings as needed | ||
| }; | ||
|
|
||
| const langCode = parts[0].toLowerCase(); | ||
| return languageCountryMap[langCode] || null; | ||
| } catch (error) { | ||
| return null; | ||
| } | ||
| } | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,70 @@ | ||
| import { GeolocationProvider } from '../types'; | ||
|
|
||
| /** | ||
| * Geolocation provider using ipapi.co API | ||
| * Free tier: 30,000 requests/month (1,000/day limit) | ||
| * No API key required for free tier | ||
| */ | ||
| export class IpApiCoProvider implements GeolocationProvider { | ||
| name = 'ipapi.co'; | ||
| priority = 1; // Highest priority - use first (free tier) | ||
|
|
||
| // Use country_code endpoint - returns just "US" instead of full JSON | ||
| private apiUrl = 'https://ipapi.co/country_code/'; | ||
| private apiKey: string | undefined; | ||
|
|
||
| constructor(apiKey?: string) { | ||
| // API key is optional - free tier works without it | ||
| this.apiKey = apiKey || import.meta.env.VITE_IPAPI_CO_KEY; | ||
| } | ||
|
|
||
| async detect(): Promise<string | null> { | ||
| try { | ||
| // Add API key to URL if available (for paid tier) | ||
| const url = this.apiKey ? `${this.apiUrl}?key=${this.apiKey}` : this.apiUrl; | ||
|
|
||
| const response = await fetch(url, { | ||
| method: 'GET', | ||
| headers: { | ||
| Accept: 'text/plain', | ||
| }, | ||
| signal: AbortSignal.timeout(1000), // 1 second timeout | ||
| }); | ||
|
|
||
| if (!response.ok) { | ||
| return null; | ||
| } | ||
|
|
||
| // country_code endpoint returns plain text like "US" not JSON | ||
| const countryCode = (await response.text()).trim(); | ||
|
|
||
| // Validate it's a valid 2-letter country code | ||
| if (!countryCode || countryCode.length !== 2) { | ||
| return null; | ||
| } | ||
|
|
||
| return countryCode; | ||
| } catch (error) { | ||
| return null; | ||
| } | ||
| } | ||
| } | ||
|
|
||
| /** | ||
| * ipapi.co API Endpoints: | ||
| * | ||
| * Full data: https://ipapi.co/json/ | ||
| * Country only: https://ipapi.co/country_code/ (returns plain text: "US") | ||
| * Country name: https://ipapi.co/country_name/ (returns: "United States") | ||
| * City: https://ipapi.co/city/ (returns: "Mountain View") | ||
| * | ||
| * Benefits of using country_code endpoint: | ||
| * - Smaller response (just "US" vs full JSON object) | ||
| * - Faster parsing (text vs JSON) | ||
| * - Less bandwidth usage | ||
| * - Same rate limits apply | ||
| * | ||
| * Rate limits (free tier): | ||
| * - 30,000 requests/month | ||
| * - 1,000 requests/day | ||
| */ |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,20 @@ | ||
| /** | ||
| * Types for geolocation detection services | ||
| */ | ||
|
|
||
| export interface GeolocationProvider { | ||
SakshiKekre marked this conversation as resolved.
Show resolved
Hide resolved
|
||
| name: string; | ||
| priority: number; | ||
| detect: () => Promise<string | null>; // Returns ISO country code (e.g., 'US', 'GB') | ||
| } | ||
|
|
||
| export interface IpInfoResponse { | ||
| ip: string; | ||
| city?: string; | ||
| region?: string; | ||
| country: string; // ISO 3166-1 alpha-2 code | ||
| loc?: string; | ||
| org?: string; | ||
| postal?: string; | ||
| timezone?: string; | ||
| } | ||
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.