diff --git a/src/components/Componentes/question-birthplace.tsx b/src/components/Componentes/question-birthplace.tsx index cd93734..41c17ef 100644 --- a/src/components/Componentes/question-birthplace.tsx +++ b/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 { + // 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) { + const loc = [parsed.city, parsed.country].filter(Boolean).join(", "); + setDetectedLocation(loc); setMode("auto"); + return; } - return; } - // If there is already a saved value and we are not forcing, do not fetch again - if (rawValue && !force) { - const parsed = parseValue(rawValue); - if (parsed.country && parsed.city) { - setDetectedLocation(`${parsed.city}, ${parsed.country}`); + setIsDetecting(true); + + 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(loc); + updateAnswers(country, city); 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; - setSelectedCountry(country); - setCityInput(city); - setDetectedLocation(`${city}, ${country}`); - updateAnswers(country, city); - setMode("auto"); - } else { - setMode("manual"); - } - }) - .catch(() => { - setMode("manual"); - }); - }) - .finally(() => { + } catch { + if (isMountedRef.current) { + setMode("manual"); + } + } 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"); diff --git a/src/components/Componentes/question-phone.tsx b/src/components/Componentes/question-phone.tsx index 0ddfaba..0617f7b 100644 --- a/src/components/Componentes/question-phone.tsx +++ b/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 | 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 {} - } + const current = getStoredUserGeoRegion() || {}; + setStoredUserGeoRegion({ ...current, phoneCode: code }); } -export function fetchGeoCountryCode(defaultCode = "+44"): Promise { - const existing = getStoredGeoCode(); - if (existing) { - return Promise.resolve(existing); - } - - if (geoIpPromise) { - return geoIpPromise.then((res) => res || defaultCode); - } - - 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 { + 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 (phoneValue) { - setPhoneValue((prev) => - formatPhoneNumberAsYouType(prev, resolvedCode), - ); + if (region.phoneCode) { + setCodeValue(region.phoneCode); + if (phoneValue) { + setPhoneValue((prev) => + formatPhoneNumberAsYouType(prev, region.phoneCode!), + ); + } + setIsResolvingCountry(false); } - 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]); diff --git a/src/components/Componentes/ui-config.test.tsx b/src/components/Componentes/ui-config.test.tsx index 0ba597f..ce6ffe7 100644 --- a/src/components/Componentes/ui-config.test.tsx +++ b/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( + + + + + , + ); + + 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 = { diff --git a/src/lib/geo-region.ts b/src/lib/geo-region.ts new file mode 100644 index 0000000..72493a4 --- /dev/null +++ b/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 | 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 { + 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; +}