Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 4 additions & 3 deletions app/src/Router.tsx
Original file line number Diff line number Diff line change
@@ -1,17 +1,18 @@
import { createBrowserRouter, Navigate, RouterProvider } from 'react-router-dom';
import { createBrowserRouter, RouterProvider } from 'react-router-dom';
import Layout from './components/Layout';
import HomePage from './pages/Home.page';
import PoliciesPage from './pages/Policies.page';
import PopulationsPage from './pages/Populations.page';
import SimulationsPage from './pages/Simulations.page';
import { CountryGuard } from './routing/guards/CountryGuard';
import { RedirectToCountry } from './routing/RedirectToCountry';

const router = createBrowserRouter(
[
{
path: '/',
// TODO: Replace with dynamic default country based on user location/preferences
element: <Navigate to="/us" replace />,
// Dynamically detect and redirect to user's country
element: <RedirectToCountry />,
},
{
path: '/:countryId',
Expand Down
5 changes: 0 additions & 5 deletions app/src/reducers/metadataReducer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -71,12 +71,7 @@ const metadataSlice = createSlice({
})
.addCase(fetchMetadataThunk.fulfilled, (state, action) => {
const { data, country } = action.payload;
console.log('SKLOGS Data:');
console.log(data);

const body = data.result;
console.log('SKLOGS Body:');
console.log(body);

state.loading = false;
state.error = null;
Expand Down
100 changes: 100 additions & 0 deletions app/src/routing/RedirectToCountry.tsx
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 />;
}
47 changes: 47 additions & 0 deletions app/src/routing/geolocation/GeolocationService.ts
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();
38 changes: 38 additions & 0 deletions app/src/routing/geolocation/countryMapper.ts
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';
}
44 changes: 44 additions & 0 deletions app/src/routing/geolocation/providers/BrowserProvider.ts
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;
}
}
}
70 changes: 70 additions & 0 deletions app/src/routing/geolocation/providers/IpApiCoProvider.ts
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
*/
20 changes: 20 additions & 0 deletions app/src/routing/geolocation/types.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
/**
* Types for geolocation detection services
*/

export interface GeolocationProvider {
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;
}
Loading