Browse Source

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
mortezaei 5 days ago
parent
commit
75144fd6eb
  1. 91
      src/components/Componentes/question-birthplace.tsx
  2. 177
      src/components/Componentes/question-phone.tsx
  3. 43
      src/components/Componentes/ui-config.test.tsx
  4. 211
      src/lib/geo-region.ts

91
src/components/Componentes/question-birthplace.tsx

@ -10,6 +10,7 @@ import QuestionTitle from "./question-title";
import { LoadingThreeDot } from "./loading-three-dot";
import { useSheetScrollLock } from "./use-sheet-scroll-lock";
import { Input } from "@/components/ui/input";
import { getUserGeoRegion } from "@/lib/geo-region";
const EXIT_ANIMATION_MS = 220;
@ -152,89 +153,55 @@ export function QuestionBirthplace({
setAnswerValue(question, formatted);
};
// GeoIP detection logic
const detectLocation = (force = false) => {
const alreadyChecked =
typeof window !== "undefined"
? localStorage.getItem("hasCheckedGeoIPResidence")
: "true";
if (alreadyChecked && !force) {
if (rawValue) {
const parsed = parseValue(rawValue);
if (parsed.country && parsed.city) {
setDetectedLocation(`${parsed.city}, ${parsed.country}`);
setMode("auto");
} else {
setMode("manual");
}
} else {
setMode("auto");
}
return;
}
// If there is already a saved value and we are not forcing, do not fetch again
// GeoIP detection logic using unified getUserGeoRegion
const detectLocation = useCallback(async (force = false) => {
// If there is already a saved answer and we are not forcing, display it
if (rawValue && !force) {
const parsed = parseValue(rawValue);
if (parsed.country && parsed.city) {
setDetectedLocation(`${parsed.city}, ${parsed.country}`);
if (parsed.country || parsed.city) {
const loc = [parsed.city, parsed.country].filter(Boolean).join(", ");
setDetectedLocation(loc);
setMode("auto");
} else {
setMode("manual");
}
return;
}
}
setIsDetecting(true);
if (typeof window !== "undefined") {
localStorage.setItem("hasCheckedGeoIPResidence", "true");
}
fetch("https://ipapi.co/json/")
.then((res) => res.json())
.then((data) => {
if (data && data.city && data.country_name) {
const city = data.city;
const country = data.country_name;
setSelectedCountry(country);
setCityInput(city);
setDetectedLocation(`${city}, ${country}`);
updateAnswers(country, city);
setMode("auto");
} else {
throw new Error("Missing data");
}
})
.catch(() => {
fetch("https://ipwho.is/")
.then((res) => res.json())
.then((data) => {
if (data && data.city && data.country) {
const city = data.city;
const country = data.country;
try {
const region = await getUserGeoRegion();
if (!isMountedRef.current) return;
const city = region.city || "";
const country = region.country || "";
if (city || country) {
const loc = [city, country].filter(Boolean).join(", ");
setSelectedCountry(country);
setCityInput(city);
setDetectedLocation(`${city}, ${country}`);
setDetectedLocation(loc);
updateAnswers(country, city);
setMode("auto");
} else {
setMode("manual");
}
})
.catch(() => {
} catch {
if (isMountedRef.current) {
setMode("manual");
});
})
.finally(() => {
}
} finally {
if (isMountedRef.current) {
setIsDetecting(false);
});
};
}
}
}, [rawValue]);
useEffect(() => {
if (isLoading) return;
if (isResidence) {
detectLocation();
void detectLocation();
}
}, [isResidence, isLoading]);
}, [isResidence, isLoading, detectLocation]);
const handleAutoClick = () => {
setMode("auto");

177
src/components/Componentes/question-phone.tsx

@ -28,162 +28,35 @@ type PhoneValueParts = {
phoneValue: string;
};
const phoneUtil = PhoneNumberUtil.getInstance();
import {
getUserGeoRegion,
getStoredUserGeoRegion,
setStoredUserGeoRegion,
subscribeToUserGeoRegion,
resetUserGeoRegionForTesting,
} from "@/lib/geo-region";
// Module-level singleton state for IP phone country resolution
let cachedGeoCountryCode: string | null = null;
let geoIpPromise: Promise<string | null> | null = null;
const geoListeners = new Set<(code: string) => void>();
const phoneUtil = PhoneNumberUtil.getInstance();
export function resetGeoPhoneStateForTesting() {
cachedGeoCountryCode = null;
geoIpPromise = null;
geoListeners.clear();
resetUserGeoRegionForTesting();
}
function getStoredGeoCode(): string | null {
if (cachedGeoCountryCode) return cachedGeoCountryCode;
if (typeof window !== "undefined") {
try {
const stored = localStorage.getItem("geoIPPhoneCode");
if (stored) {
cachedGeoCountryCode = stored;
return stored;
}
} catch {}
}
return null;
const region = getStoredUserGeoRegion();
return region?.phoneCode || null;
}
export function setManuallySelectedGeoCode(code: string) {
cachedGeoCountryCode = code;
if (typeof window !== "undefined") {
try {
localStorage.setItem("geoIPPhoneCode", code);
localStorage.setItem("hasCheckedGeoIPPhone", "true");
} catch {}
}
}
export function fetchGeoCountryCode(defaultCode = "+44"): Promise<string> {
const existing = getStoredGeoCode();
if (existing) {
return Promise.resolve(existing);
}
if (geoIpPromise) {
return geoIpPromise.then((res) => res || defaultCode);
const current = getStoredUserGeoRegion() || {};
setStoredUserGeoRegion({ ...current, phoneCode: code });
}
geoIpPromise = (async () => {
try {
// 1. Primary: Habib Backend User Region API (/account/auth/user/region/)
try {
const response = await http.get<{
country?: string;
country_code?: string;
city?: string;
}>("/account/auth/user/region/", {
timeout: 2500,
});
const isoCountry = response.data?.country_code;
if (isoCountry) {
const callingCode = phoneUtil.getCountryCodeForRegion(
isoCountry.toUpperCase(),
);
if (callingCode) {
const formatted = `+${callingCode}`;
setManuallySelectedGeoCode(formatted);
geoListeners.forEach((fn) => {
fn(formatted);
});
return formatted;
}
}
} 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?.country_calling_code) {
const rawCode = String(data.country_calling_code).trim();
const formatted = rawCode.startsWith("+") ? rawCode : `+${rawCode}`;
setManuallySelectedGeoCode(formatted);
geoListeners.forEach((fn) => {
fn(formatted);
});
return formatted;
}
}
} 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?.calling_code) {
const rawCode = String(data.calling_code).trim();
const formatted = rawCode.startsWith("+") ? rawCode : `+${rawCode}`;
setManuallySelectedGeoCode(formatted);
geoListeners.forEach((fn) => {
fn(formatted);
});
return formatted;
}
}
} catch {
clearTimeout(secondaryTimeoutId);
}
// 4. Fallback to default
if (typeof window !== "undefined") {
try {
localStorage.setItem("geoIPPhoneCode", defaultCode);
localStorage.setItem("hasCheckedGeoIPPhone", "true");
} catch {}
}
cachedGeoCountryCode = defaultCode;
geoListeners.forEach((fn) => {
fn(defaultCode);
});
return defaultCode;
} catch {
if (typeof window !== "undefined") {
try {
localStorage.setItem("geoIPPhoneCode", defaultCode);
localStorage.setItem("hasCheckedGeoIPPhone", "true");
} catch {}
}
cachedGeoCountryCode = defaultCode;
geoListeners.forEach((fn) => {
fn(defaultCode);
});
return defaultCode;
}
})();
return geoIpPromise.then((res) => res || defaultCode);
export async function fetchGeoCountryCode(
defaultCode = "+44",
): Promise<string> {
const region = await getUserGeoRegion();
return region.phoneCode || defaultCode;
}
function isMarriagePhoneFieldValue(
@ -567,18 +440,18 @@ export function QuestionPhone({
let isMounted = true;
const onGeoCodeResolved = (resolvedCode: string) => {
const unsubscribe = subscribeToUserGeoRegion((region) => {
if (!isMounted || userInteractedRef.current) return;
setCodeValue(resolvedCode);
if (region.phoneCode) {
setCodeValue(region.phoneCode);
if (phoneValue) {
setPhoneValue((prev) =>
formatPhoneNumberAsYouType(prev, resolvedCode),
formatPhoneNumberAsYouType(prev, region.phoneCode!),
);
}
setIsResolvingCountry(false);
};
geoListeners.add(onGeoCodeResolved);
}
});
fetchGeoCountryCode(defaultCodeValue)
.then((resolvedCode) => {
@ -604,7 +477,7 @@ export function QuestionPhone({
return () => {
isMounted = false;
geoListeners.delete(onGeoCodeResolved);
unsubscribe();
};
}, [hasExplicitValue, isResolvingCountry, defaultCodeValue, phoneValue]);

43
src/components/Componentes/ui-config.test.tsx

@ -78,6 +78,49 @@ describe("UI Config based behavior", () => {
expect(screen.queryByText("خودکار")).toBeNull();
});
it("should auto-detect and display city and country when ui_config.enable_geoip is true", async () => {
const qWithGeo = {
id: "q_residence",
title: "Current Residence",
type: "birthplace",
order: 1,
required: true,
isVisible: true,
description: "",
tooltip: "",
extras: {},
options: [],
ui_config: { enable_geoip: true },
} as any;
const { getStoredUserGeoRegion, setStoredUserGeoRegion } = await import(
"@/lib/geo-region"
);
setStoredUserGeoRegion({
city: "Tehran",
country: "Iran",
countryCode: "IR",
phoneCode: "+98",
});
render(
<QueryClientProvider client={queryClient}>
<QuestionAnswersProvider
slug="test"
questions={[qWithGeo]}
initialAnswers={{}}
initialFields={[]}
>
<QuestionBirthplace question={qWithGeo} />
</QuestionAnswersProvider>
</QueryClientProvider>,
);
await waitFor(() => {
expect(screen.getByText("Tehran, Iran")).toBeDefined();
});
});
it("should trigger currency behavior only when ui_config.currency_enabled is true", () => {
// Title is random, but currency_enabled is true
const qWithCurrency = {

211
src/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;
}
Loading…
Cancel
Save