Browse Source
refactor(geo): centralize geolocation logic into a unified utility
refactor(geo): centralize geolocation logic into a unified utility
Extract geolocation detection and storage logic from `QuestionBirthplace` and `QuestionPhone` into a new `geo-region.ts` library. This provides a single source of truth for user region data, including city, country, and phone calling codes. - Create `src/lib/geo-region.ts` to manage GeoIP fetching, caching, and subscription-based updates. - Refactor `QuestionBirthplace` to use the new `getUserGeoRegion` utility instead of inline fetch logic. - Refactor `QuestionPhone` to use the unified storage and retrieval mechanism for phone calling codes. - Add unit tests in `ui-config.test.tsx` to verify auto-detection behavior when `enable_geoip` is enabled.master
4 changed files with 319 additions and 225 deletions
-
105src/components/Componentes/question-birthplace.tsx
-
185src/components/Componentes/question-phone.tsx
-
43src/components/Componentes/ui-config.test.tsx
-
211src/lib/geo-region.ts
@ -0,0 +1,211 @@ |
|||||
|
"use client"; |
||||
|
|
||||
|
import { PhoneNumberUtil } from "google-libphonenumber"; |
||||
|
import { http } from "./http"; |
||||
|
|
||||
|
export type UserGeoRegion = { |
||||
|
ip?: string; |
||||
|
city?: string; |
||||
|
country?: string; |
||||
|
countryCode?: string; // e.g. "IR", "US", "GB"
|
||||
|
phoneCode?: string; // e.g. "+98", "+1", "+44"
|
||||
|
}; |
||||
|
|
||||
|
const phoneUtil = PhoneNumberUtil.getInstance(); |
||||
|
|
||||
|
const STORAGE_KEY = "user_geo_region"; |
||||
|
const PHONE_STORAGE_KEY = "geoIPPhoneCode"; |
||||
|
|
||||
|
let cachedRegion: UserGeoRegion | null = null; |
||||
|
let geoRegionPromise: Promise<UserGeoRegion> | null = null; |
||||
|
const listeners = new Set<(region: UserGeoRegion) => void>(); |
||||
|
|
||||
|
export function resetUserGeoRegionForTesting() { |
||||
|
cachedRegion = null; |
||||
|
geoRegionPromise = null; |
||||
|
listeners.clear(); |
||||
|
} |
||||
|
|
||||
|
export function subscribeToUserGeoRegion( |
||||
|
fn: (region: UserGeoRegion) => void, |
||||
|
): () => void { |
||||
|
listeners.add(fn); |
||||
|
return () => { |
||||
|
listeners.delete(fn); |
||||
|
}; |
||||
|
} |
||||
|
|
||||
|
export function getStoredUserGeoRegion(): UserGeoRegion | null { |
||||
|
if (cachedRegion) return cachedRegion; |
||||
|
if (typeof window !== "undefined") { |
||||
|
try { |
||||
|
const stored = localStorage.getItem(STORAGE_KEY); |
||||
|
if (stored) { |
||||
|
const parsed = JSON.parse(stored) as UserGeoRegion; |
||||
|
if (parsed && (parsed.country || parsed.phoneCode)) { |
||||
|
cachedRegion = parsed; |
||||
|
return parsed; |
||||
|
} |
||||
|
} |
||||
|
} catch {} |
||||
|
} |
||||
|
return null; |
||||
|
} |
||||
|
|
||||
|
export function setStoredUserGeoRegion(region: UserGeoRegion) { |
||||
|
cachedRegion = region; |
||||
|
if (typeof window !== "undefined") { |
||||
|
try { |
||||
|
localStorage.setItem(STORAGE_KEY, JSON.stringify(region)); |
||||
|
if (region.phoneCode) { |
||||
|
localStorage.setItem(PHONE_STORAGE_KEY, region.phoneCode); |
||||
|
} |
||||
|
} catch {} |
||||
|
} |
||||
|
listeners.forEach((fn) => fn(region)); |
||||
|
} |
||||
|
|
||||
|
function resolvePhoneCodeFromCountryCode(isoCode?: string): string | undefined { |
||||
|
if (!isoCode) return undefined; |
||||
|
try { |
||||
|
const callingCode = phoneUtil.getCountryCodeForRegion( |
||||
|
isoCode.toUpperCase(), |
||||
|
); |
||||
|
if (callingCode) { |
||||
|
return `+${callingCode}`; |
||||
|
} |
||||
|
} catch {} |
||||
|
return undefined; |
||||
|
} |
||||
|
|
||||
|
export function getUserGeoRegion(): Promise<UserGeoRegion> { |
||||
|
const existing = getStoredUserGeoRegion(); |
||||
|
if (existing && (existing.city || existing.country)) { |
||||
|
return Promise.resolve(existing); |
||||
|
} |
||||
|
|
||||
|
if (geoRegionPromise) { |
||||
|
return geoRegionPromise; |
||||
|
} |
||||
|
|
||||
|
geoRegionPromise = (async () => { |
||||
|
try { |
||||
|
// 1. Primary: Habib Backend User Region API (/account/auth/user/region/)
|
||||
|
try { |
||||
|
const response = await http.get<{ |
||||
|
ip?: string; |
||||
|
country?: string; |
||||
|
country_code?: string; |
||||
|
city?: string; |
||||
|
}>("/account/auth/user/region/", { |
||||
|
timeout: 3000, |
||||
|
}); |
||||
|
|
||||
|
const data = response.data; |
||||
|
if (data && (data.country || data.country_code || data.city)) { |
||||
|
const phoneCode = resolvePhoneCodeFromCountryCode(data.country_code); |
||||
|
const region: UserGeoRegion = { |
||||
|
ip: data.ip, |
||||
|
city: data.city, |
||||
|
country: data.country, |
||||
|
countryCode: data.country_code, |
||||
|
phoneCode: phoneCode || "+44", |
||||
|
}; |
||||
|
setStoredUserGeoRegion(region); |
||||
|
return region; |
||||
|
} |
||||
|
} catch { |
||||
|
// Fallback to secondary geo endpoints
|
||||
|
} |
||||
|
|
||||
|
// 2. Secondary fallback: ipapi.co with 2s timeout
|
||||
|
const controller = new AbortController(); |
||||
|
const timeoutId = setTimeout(() => controller.abort(), 2000); |
||||
|
try { |
||||
|
const res = await fetch("https://ipapi.co/json/", { |
||||
|
signal: controller.signal, |
||||
|
}); |
||||
|
clearTimeout(timeoutId); |
||||
|
if (res?.ok) { |
||||
|
const data = await res.json(); |
||||
|
if ( |
||||
|
data && |
||||
|
(data.country_name || data.city || data.country_calling_code) |
||||
|
) { |
||||
|
const rawPhone = data.country_calling_code |
||||
|
? String(data.country_calling_code).trim() |
||||
|
: ""; |
||||
|
const phoneCode = rawPhone.startsWith("+") |
||||
|
? rawPhone |
||||
|
: rawPhone |
||||
|
? `+${rawPhone}` |
||||
|
: "+44"; |
||||
|
const region: UserGeoRegion = { |
||||
|
ip: data.ip, |
||||
|
city: data.city, |
||||
|
country: data.country_name, |
||||
|
countryCode: data.country_code, |
||||
|
phoneCode, |
||||
|
}; |
||||
|
setStoredUserGeoRegion(region); |
||||
|
return region; |
||||
|
} |
||||
|
} |
||||
|
} catch { |
||||
|
clearTimeout(timeoutId); |
||||
|
} |
||||
|
|
||||
|
// 3. Tertiary fallback: ipwho.is with 2s timeout
|
||||
|
const secondaryController = new AbortController(); |
||||
|
const secondaryTimeoutId = setTimeout( |
||||
|
() => secondaryController.abort(), |
||||
|
2000, |
||||
|
); |
||||
|
try { |
||||
|
const res = await fetch("https://ipwho.is/", { |
||||
|
signal: secondaryController.signal, |
||||
|
}); |
||||
|
clearTimeout(secondaryTimeoutId); |
||||
|
if (res?.ok) { |
||||
|
const data = await res.json(); |
||||
|
if (data && (data.country || data.city || data.calling_code)) { |
||||
|
const rawPhone = data.calling_code |
||||
|
? String(data.calling_code).trim() |
||||
|
: ""; |
||||
|
const phoneCode = rawPhone.startsWith("+") |
||||
|
? rawPhone |
||||
|
: rawPhone |
||||
|
? `+${rawPhone}` |
||||
|
: "+44"; |
||||
|
const region: UserGeoRegion = { |
||||
|
ip: data.ip, |
||||
|
city: data.city, |
||||
|
country: data.country, |
||||
|
countryCode: data.country_code, |
||||
|
phoneCode, |
||||
|
}; |
||||
|
setStoredUserGeoRegion(region); |
||||
|
return region; |
||||
|
} |
||||
|
} |
||||
|
} catch { |
||||
|
clearTimeout(secondaryTimeoutId); |
||||
|
} |
||||
|
|
||||
|
// 4. Default fallback
|
||||
|
const defaultRegion: UserGeoRegion = { |
||||
|
phoneCode: "+44", |
||||
|
}; |
||||
|
setStoredUserGeoRegion(defaultRegion); |
||||
|
return defaultRegion; |
||||
|
} catch { |
||||
|
const defaultRegion: UserGeoRegion = { |
||||
|
phoneCode: "+44", |
||||
|
}; |
||||
|
setStoredUserGeoRegion(defaultRegion); |
||||
|
return defaultRegion; |
||||
|
} |
||||
|
})(); |
||||
|
|
||||
|
return geoRegionPromise; |
||||
|
} |
||||
Write
Preview
Loading…
Cancel
Save
Reference in new issue