From 2e55e3395e6643a70c2b02f893c8e27e3435e95f Mon Sep 17 00:00:00 2001 From: mortezaei Date: Sat, 22 Aug 2026 03:43:05 +0330 Subject: [PATCH] fix(geo): update geo region and birthplace handling --- .../Componentes/question-birthplace.tsx | 209 +++++++++++++----- .../Componentes/question-phone.test.tsx | 156 +++++++------ src/components/Componentes/ui-config.test.tsx | 125 +++++++++++ src/lib/geo-region.test.ts | 53 ++--- src/lib/geo-region.ts | 147 +++--------- 5 files changed, 406 insertions(+), 284 deletions(-) diff --git a/src/components/Componentes/question-birthplace.tsx b/src/components/Componentes/question-birthplace.tsx index 23afe36..692af23 100644 --- a/src/components/Componentes/question-birthplace.tsx +++ b/src/components/Componentes/question-birthplace.tsx @@ -2,7 +2,7 @@ import { useCallback, useEffect, useRef, useState } from "react"; import { createPortal } from "react-dom"; -import { getCountryList, resolveCountryName, isKnownCountry, COUNTRIES_EN, COUNTRIES_FA } from "@/data/countries"; +import { getCountryList, resolveCountryName, isKnownCountry } from "@/data/countries"; import type { QuestionField } from "@/lib/schema-adapter"; import { useI18n } from "@/translations/provider"; import { useQuestionAnswers } from "./question-answer-storage"; @@ -137,12 +137,34 @@ export function QuestionBirthplace({ const storedRegion = isResidence ? getStoredUserGeoRegion() : null; const initial = parseValue(rawValue); + const hasSavedAnswer = Boolean( + initial.country?.trim() || + initial.city?.trim() || + (typeof rawValue === "string" && rawValue.trim().length > 0), + ); + + const [mode, setMode] = useState<"auto" | "manual">(() => { + if (typeof window !== "undefined") { + const stored = localStorage.getItem(`residence_mode_${question.id}`); + if (stored === "manual" || stored === "auto") return stored; + const legacyChecked = localStorage.getItem("hasCheckedGeoIPResidence"); + if (legacyChecked === "true") return "manual"; + } + return "auto"; + }); + + const isInitialManual = mode === "manual"; + const localizedInitialCountry = - resolveCountryName(initial.country || storedRegion?.country, locale) || + resolveCountryName(initial.country, locale) || initial.country || - storedRegion?.country || - ""; - const initialCity = initial.city || storedRegion?.city || ""; + (!hasSavedAnswer && !isInitialManual && storedRegion?.country + ? resolveCountryName(storedRegion.country, locale) || storedRegion.country + : ""); + + const initialCity = + initial.city || (!hasSavedAnswer && !isInitialManual && storedRegion?.city ? storedRegion.city : ""); + const initialLoc = localizedInitialCountry || initialCity ? [localizedInitialCountry, initialCity].filter(Boolean).join(", ") @@ -155,6 +177,17 @@ export function QuestionBirthplace({ () => initialCity, ); + const cityInputStateRef = useRef(initialCity); + const selectedCountryStateRef = useRef(localizedInitialCountry || ""); + + useEffect(() => { + cityInputStateRef.current = cityInput; + }, [cityInput]); + + useEffect(() => { + selectedCountryStateRef.current = selectedCountry; + }, [selectedCountry]); + const [isOpen, setIsOpen] = useState(false); const [isClosing, setIsClosing] = useState(false); const [searchQuery, setSearchQuery] = useState(""); @@ -164,7 +197,6 @@ export function QuestionBirthplace({ const isFocusedRef = useRef(false); const debounceTimerRef = useRef(null); - const [mode, setMode] = useState<"auto" | "manual">("auto"); const [isDetecting, setIsDetecting] = useState(false); const [detectedLocation, setDetectedLocation] = useState(initialLoc); const hasAutoDetectedRef = useRef(false); @@ -227,67 +259,120 @@ export function QuestionBirthplace({ }; // 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); - const cName = resolveCountryName(parsed.country, locale) || parsed.country; - if (cName || parsed.city) { - const loc = [cName, parsed.city].filter(Boolean).join(", "); - if (cName) setSelectedCountry(cName); - if (parsed.city) setCityInput(parsed.city); - setDetectedLocation(loc); - setMode("auto"); - return; + 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); + const cName = + resolveCountryName(parsed.country, locale) || parsed.country; + if (cName || parsed.city) { + const loc = [cName, parsed.city].filter(Boolean).join(", "); + if (cName) { + setSelectedCountry(cName); + selectedCountryStateRef.current = cName; + } + if (parsed.city) { + setCityInput(parsed.city); + cityInputStateRef.current = parsed.city; + } + setDetectedLocation(loc); + + const storedMode = + typeof window !== "undefined" + ? localStorage.getItem(`residence_mode_${question.id}`) || + (localStorage.getItem("hasCheckedGeoIPResidence") === "true" + ? "manual" + : null) + : null; + + if (storedMode === "manual") { + setMode("manual"); + } else if (storedMode === "auto") { + setMode("auto"); + } + return; + } } - } - - setIsDetecting(true); - - try { - const region = await getUserGeoRegion(force); - if (!isMountedRef.current) return; - - const city = region.city || ""; - const rawCountry = region.country || region.countryCode || ""; - const country = resolveCountryName(rawCountry, locale) || rawCountry; - if (country || city) { - setSelectedCountry(country); - setCityInput(city); - const loc = [country, city].filter(Boolean).join(", "); - setDetectedLocation(loc); - updateAnswers(country, city); - setMode("auto"); - } else { - setMode("manual"); - } - } catch { - if (isMountedRef.current) { - setMode("manual"); + setIsDetecting(true); + + try { + const region = await getUserGeoRegion(force); + if (!isMountedRef.current) return; + + const city = region.city || ""; + const rawCountry = region.country || region.countryCode || ""; + const country = resolveCountryName(rawCountry, locale) || rawCountry; + + if (country || city) { + setSelectedCountry(country); + selectedCountryStateRef.current = country; + setCityInput(city); + cityInputStateRef.current = city; + const loc = [country, city].filter(Boolean).join(", "); + setDetectedLocation(loc); + updateAnswers(country, city); + setMode("auto"); + if (typeof window !== "undefined") { + localStorage.setItem(`residence_mode_${question.id}`, "auto"); + } + } else { + setMode("manual"); + if (typeof window !== "undefined") { + localStorage.setItem(`residence_mode_${question.id}`, "manual"); + } + } + } catch { + if (isMountedRef.current) { + setMode("manual"); + } + } finally { + if (isMountedRef.current) { + setIsDetecting(false); + } } - } finally { - if (isMountedRef.current) { - setIsDetecting(false); - } - } - }, [rawValue, locale, question, setAnswerValue]); + }, + [rawValue, locale, question, setAnswerValue], + ); useEffect(() => { if (isLoading) return; if (isResidence && !hasAutoDetectedRef.current) { hasAutoDetectedRef.current = true; - void detectLocation(); + const storedMode = + typeof window !== "undefined" + ? localStorage.getItem(`residence_mode_${question.id}`) || + (localStorage.getItem("hasCheckedGeoIPResidence") === "true" + ? "manual" + : null) + : null; + + if (storedMode === "manual") { + setMode("manual"); + return; + } + + const parsed = parseValue(rawValue); + if (!parsed.country && !parsed.city) { + void detectLocation(); + } else { + void detectLocation(false); + } } - }, [isResidence, isLoading, detectLocation]); + }, [isResidence, isLoading, detectLocation, rawValue, question.id]); const handleAutoClick = () => { + if (typeof window !== "undefined") { + localStorage.setItem(`residence_mode_${question.id}`, "auto"); + } setMode("auto"); detectLocation(true); }; const handleManualClick = () => { if (typeof window !== "undefined") { + localStorage.setItem(`residence_mode_${question.id}`, "manual"); localStorage.setItem("hasCheckedGeoIPResidence", "true"); } setMode("manual"); @@ -297,9 +382,11 @@ export function QuestionBirthplace({ selectedCountry || parsed.country; const country = resolvedC; - const city = cityInput || parsed.city; + const city = cityInput !== "" ? cityInput : parsed.city; setSelectedCountry(country); + selectedCountryStateRef.current = country; setCityInput(city); + cityInputStateRef.current = city; updateAnswers(country, city); setDetectedLocation([country, city].filter(Boolean).join(", ")); }; @@ -331,9 +418,11 @@ export function QuestionBirthplace({ const resolvedC = resolveCountryName(updated.country, locale) || updated.country; if (resolvedC !== selectedCountry) { setSelectedCountry(resolvedC); + selectedCountryStateRef.current = resolvedC; } if (updated.city !== cityInput) { setCityInput(updated.city); + cityInputStateRef.current = updated.city; } if (resolvedC || updated.city) { setDetectedLocation([resolvedC, updated.city].filter(Boolean).join(", ")); @@ -348,12 +437,14 @@ export function QuestionBirthplace({ const handleSelectCountry = (country: string) => { if (typeof window !== "undefined") { + localStorage.setItem(`residence_mode_${question.id}`, "manual"); localStorage.setItem("hasCheckedGeoIPResidence", "true"); } setSelectedCountry(country); + selectedCountryStateRef.current = country; closeSheet(); - updateAnswers(country, cityInput); - setDetectedLocation([country, cityInput].filter(Boolean).join(", ")); + updateAnswers(country, cityInputStateRef.current); + setDetectedLocation([country, cityInputStateRef.current].filter(Boolean).join(", ")); window.setTimeout(() => { cityInputRef.current?.focus({ preventScroll: true }); }, EXIT_ANIMATION_MS); @@ -361,18 +452,20 @@ export function QuestionBirthplace({ const handleCityChange = (e: React.ChangeEvent) => { if (typeof window !== "undefined") { + localStorage.setItem(`residence_mode_${question.id}`, "manual"); localStorage.setItem("hasCheckedGeoIPResidence", "true"); } const newCity = e.target.value; + cityInputStateRef.current = newCity; setCityInput(newCity); - setDetectedLocation([selectedCountry, newCity].filter(Boolean).join(", ")); + setDetectedLocation([selectedCountryStateRef.current, newCity].filter(Boolean).join(", ")); if (debounceTimerRef.current) { clearTimeout(debounceTimerRef.current); } debounceTimerRef.current = setTimeout(() => { - updateAnswers(selectedCountry, newCity); - }, 250); + updateAnswers(selectedCountryStateRef.current, newCity); + }, 200); }; const handleCityFocus = () => { @@ -384,7 +477,7 @@ export function QuestionBirthplace({ if (debounceTimerRef.current) { clearTimeout(debounceTimerRef.current); } - updateAnswers(selectedCountry, cityInput); + updateAnswers(selectedCountryStateRef.current, cityInputStateRef.current); }; const isRtl = locale === "fa" || locale === "ar" || locale === "ur"; @@ -580,6 +673,8 @@ export function QuestionBirthplace({ disabled={disabled} value={cityInput} onChange={handleCityChange} + onFocus={handleCityFocus} + onBlur={handleCityBlur} placeholder={cityPlaceholder} /> diff --git a/src/components/Componentes/question-phone.test.tsx b/src/components/Componentes/question-phone.test.tsx index 0bf7d4d..5900f81 100644 --- a/src/components/Componentes/question-phone.test.tsx +++ b/src/components/Componentes/question-phone.test.tsx @@ -9,20 +9,13 @@ import { import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import type { QuestionField } from "@/lib/schema-adapter"; import { QuestionPhone, resetGeoPhoneStateForTesting } from "./question-phone"; +import { setStoredUserGeoRegion } from "@/lib/geo-region"; let answerMap: Record = {}; const mockSetAnswerValue = vi.fn((q: QuestionField, val: unknown) => { answerMap[q.id] = val; }); -const httpMocks = vi.hoisted(() => ({ - get: vi.fn(), -})); - -vi.mock("@/lib/http", () => ({ - http: { get: httpMocks.get }, -})); - vi.mock("@/translations/provider", () => ({ useI18n: () => ({ locale: "en", @@ -73,27 +66,35 @@ const phoneQuestion2: QuestionField = { }; describe("QuestionPhone IP country detection and shimmer", () => { + let flutterListeners: Array<(event: any) => void> = []; + beforeEach(() => { answerMap = {}; mockSetAnswerValue.mockClear(); localStorage.clear(); resetGeoPhoneStateForTesting(); - vi.restoreAllMocks(); - httpMocks.get.mockReset(); + flutterListeners = []; + + window.addFlutterResponseListener = vi.fn().mockImplementation((listener) => { + flutterListeners.push(listener); + return () => { + const idx = flutterListeners.indexOf(listener); + if (idx >= 0) flutterListeners.splice(idx, 1); + }; + }); + + window.HabibApp = { + postMessage: vi.fn(), + }; }); afterEach(() => { + delete (window as any).HabibApp; + delete (window as any).addFlutterResponseListener; cleanup(); }); - it("renders single unified shimmer on country button while IP request is pending, then shows resolved country code from Habib user region API", async () => { - let resolveRegion!: (value: unknown) => void; - const regionPromise = new Promise((resolve) => { - resolveRegion = resolve; - }); - - httpMocks.get.mockReturnValue(regionPromise); - + it("renders single unified shimmer on country button while Flutter get_location is pending, then shows resolved country code", async () => { const { container } = render(); const shimmerElements = container.querySelectorAll(".shimmer-bg"); @@ -102,12 +103,16 @@ describe("QuestionPhone IP country detection and shimmer", () => { expect(input.classList.contains("shimmer-bg")).toBe(false); await act(async () => { - resolveRegion({ - data: { - country: "Iran", - country_code: "IR", - }, - }); + flutterListeners.forEach((l) => + l({ + action: "get_location", + success: true, + data: { + country: "Iran", + country_code: "IR", + }, + }), + ); }); await waitFor(() => { @@ -117,12 +122,20 @@ describe("QuestionPhone IP country detection and shimmer", () => { }); }); - it("falls back to default region without calling external fetch when Habib region API fails", async () => { - httpMocks.get.mockRejectedValue(new Error("Network failure")); + it("falls back to default region without calling external fetch when Flutter bridge returns failure", async () => { const fetchSpy = vi.spyOn(globalThis, "fetch"); const { container } = render(); + await act(async () => { + flutterListeners.forEach((l) => + l({ + action: "get_location", + success: false, + }), + ); + }); + await waitFor(() => { expect(container.querySelectorAll(".shimmer-bg").length).toBe(0); expect(screen.getByText("+44")).toBeDefined(); @@ -133,11 +146,8 @@ describe("QuestionPhone IP country detection and shimmer", () => { expect(fetchSpy).not.toHaveBeenCalled(); }); - it("shows default country code when all IP requests fail", async () => { - httpMocks.get.mockRejectedValue(new Error("Network failure")); - vi.spyOn(globalThis, "fetch").mockRejectedValue( - new Error("Network failure"), - ); + it("shows default country code when bridge is not available", async () => { + delete (window as any).HabibApp; const { container } = render( , @@ -150,14 +160,7 @@ describe("QuestionPhone IP country detection and shimmer", () => { }); }); - it("fetches IP country code only once when multiple fields are rendered and updates both", async () => { - let resolveRegion!: (value: unknown) => void; - const regionPromise = new Promise((resolve) => { - resolveRegion = resolve; - }); - - httpMocks.get.mockReturnValue(regionPromise); - + it("fetches Flutter location only once when multiple fields are rendered and updates both", async () => { render( <> @@ -165,15 +168,21 @@ describe("QuestionPhone IP country detection and shimmer", () => { , ); - expect(httpMocks.get).toHaveBeenCalledTimes(1); + expect(window.HabibApp?.postMessage).toHaveBeenCalledWith( + JSON.stringify({ action: "get_location" }), + ); await act(async () => { - resolveRegion({ - data: { - country: "Iran", - country_code: "IR", - }, - }); + flutterListeners.forEach((l) => + l({ + action: "get_location", + success: true, + data: { + country: "Iran", + country_code: "IR", + }, + }), + ); }); await waitFor(() => { @@ -195,38 +204,32 @@ describe("QuestionPhone IP country detection and shimmer", () => { expect(container.querySelectorAll(".shimmer-bg").length).toBe(0); expect(screen.getByText("+1")).toBeDefined(); expect(screen.getByDisplayValue("202-555-0143")).toBeDefined(); - expect(httpMocks.get).not.toHaveBeenCalled(); }); it("does not overwrite manual selection when user manually interacts", async () => { - let resolveRegion!: (value: unknown) => void; - const regionPromise = new Promise((resolve) => { - resolveRegion = resolve; - }); - - httpMocks.get.mockReturnValue(regionPromise); - render(); const input = screen.getByRole("textbox"); fireEvent.change(input, { target: { value: "7400123456" } }); await act(async () => { - resolveRegion({ - data: { - country: "Iran", - country_code: "IR", - }, - }); + flutterListeners.forEach((l) => + l({ + action: "get_location", + success: true, + data: { + country: "Iran", + country_code: "IR", + }, + }), + ); }); expect(screen.getByDisplayValue("7400 123456")).toBeDefined(); }); it("formats Iranian phone numbers as 3-3-4 Telegram style as digits are typed", async () => { - httpMocks.get.mockResolvedValue({ - data: { country: "Iran", country_code: "IR" }, - }); + setStoredUserGeoRegion({ country: "Iran", countryCode: "IR", phoneCode: "+98" }); render(); @@ -246,25 +249,22 @@ describe("QuestionPhone IP country detection and shimmer", () => { }); it("shows dynamic placeholder based on resolved country code", async () => { - let resolveRegion!: (value: unknown) => void; - const regionPromise = new Promise((resolve) => { - resolveRegion = resolve; - }); - - httpMocks.get.mockReturnValue(regionPromise); - render(); const input = screen.getByRole("textbox") as HTMLInputElement; expect(input.placeholder).toBe("7400 123456"); await act(async () => { - resolveRegion({ - data: { - country: "Iran", - country_code: "IR", - }, - }); + flutterListeners.forEach((l) => + l({ + action: "get_location", + success: true, + data: { + country: "Iran", + country_code: "IR", + }, + }), + ); }); await waitFor(() => { @@ -273,9 +273,7 @@ describe("QuestionPhone IP country detection and shimmer", () => { }); it("does not show validation error on active typing, only shows error on blur if incomplete", async () => { - httpMocks.get.mockResolvedValue({ - data: { country: "Iran", country_code: "IR" }, - }); + setStoredUserGeoRegion({ country: "Iran", countryCode: "IR", phoneCode: "+98" }); const { container } = render( , diff --git a/src/components/Componentes/ui-config.test.tsx b/src/components/Componentes/ui-config.test.tsx index 4523ef2..befe4bf 100644 --- a/src/components/Componentes/ui-config.test.tsx +++ b/src/components/Componentes/ui-config.test.tsx @@ -392,6 +392,131 @@ describe("UI Config based behavior", () => { fireEvent.blur(cityInput); expect((cityInput as HTMLInputElement).value).toBe("تهران"); }); + + it("should allow typing and clearing city in current_residence manual mode without duplication", async () => { + const qResidence = { + id: "residence_manual_typing_test", + title: "محل سکونت فعلی", + type: "birthplace", + order: 1, + required: true, + isVisible: true, + description: "", + tooltip: "", + extras: { placeHolder: "شهر، منطقه یا محله" }, + options: [], + ui_config: { enable_geoip: true }, + } as any; + + render( + + + + + , + ); + + // Switch to manual mode + const manualButton = screen.getByRole("button", { name: /دستی|Manual/i }); + fireEvent.click(manualButton); + + const cityInput = screen.getByPlaceholderText("شهر، منطقه یا محله"); + fireEvent.focus(cityInput); + + // Type "اصفهان" + fireEvent.change(cityInput, { target: { value: "اصفهان" } }); + expect((cityInput as HTMLInputElement).value).toBe("اصفهان"); + + // Clear the input completely + fireEvent.change(cityInput, { target: { value: "" } }); + expect((cityInput as HTMLInputElement).value).toBe(""); + + fireEvent.blur(cityInput); + expect((cityInput as HTMLInputElement).value).toBe(""); + + // Type again "شیراز" + fireEvent.focus(cityInput); + fireEvent.change(cityInput, { target: { value: "شیراز" } }); + expect((cityInput as HTMLInputElement).value).toBe("شیراز"); + + fireEvent.blur(cityInput); + expect((cityInput as HTMLInputElement).value).toBe("شیراز"); + }); + + it("should preserve manual mode and custom entered city upon section re-entry", async () => { + const qResidence = { + id: "residence_persistence_test", + title: "محل سکونت فعلی", + type: "birthplace", + order: 1, + required: true, + isVisible: true, + description: "", + tooltip: "", + extras: { placeHolder: "شهر، منطقه یا محله" }, + options: [], + ui_config: { enable_geoip: true }, + } as any; + + const { unmount } = render( + + + + + , + ); + + // Click Manual + const manualBtn = screen.getByRole("button", { name: /دستی|Manual/i }); + fireEvent.click(manualBtn); + + const cityInput = screen.getByPlaceholderText("شهر، منطقه یا محله"); + fireEvent.change(cityInput, { target: { value: "یزد" } }); + fireEvent.blur(cityInput); + + expect((cityInput as HTMLInputElement).value).toBe("یزد"); + + // Simulate leaving the section (unmount) + unmount(); + + const { marriageQueryKeys } = await import("@/hooks/marriage/query-keys"); + queryClient.setQueryData( + marriageQueryKeys.formSection("profile", "test-persistence", "fa"), + { + section: { + id: "sec1", + slug: "test-persistence", + title: "Sec", + cards: [{ id: "c1", title: "Card", questions: [qResidence] }], + }, + answers: { + residence_persistence_test: { + value: { country: "Iran", city: "یزد" }, + }, + }, + }, + ); + + // Simulate re-entering the section + render( + + + + + , + ); + + // Verify it stays in manual mode with "یزد" + await waitFor(() => { + const reenteredCityInput = screen.getByPlaceholderText("شهر، منطقه یا محله"); + expect((reenteredCityInput as HTMLInputElement).value).toBe("یزد"); + }); + }); }); + diff --git a/src/lib/geo-region.test.ts b/src/lib/geo-region.test.ts index e07e7a6..4b0076a 100644 --- a/src/lib/geo-region.test.ts +++ b/src/lib/geo-region.test.ts @@ -5,13 +5,6 @@ import { setStoredUserGeoRegion, resetUserGeoRegionForTesting, } from "./geo-region"; -import { http } from "./http"; - -vi.mock("./http", () => ({ - http: { - get: vi.fn(), - }, -})); describe("geo-region", () => { const originalHabibApp = window.HabibApp; @@ -63,7 +56,6 @@ describe("geo-region", () => { const result = await getUserGeoRegion(false); expect(result).toEqual(cached); - expect(http.get).not.toHaveBeenCalled(); }); it("fetches via Flutter bridge when in Flutter WebView", async () => { @@ -105,33 +97,31 @@ describe("geo-region", () => { expect(getStoredUserGeoRegion()?.countryCode).toBe("IR"); }); - it("fetches from HTTP backend when in standard browser", async () => { + it("resolves from fallback storage without making any HTTP calls when outside Flutter", async () => { delete (window as any).HabibApp; - (http.get as any).mockResolvedValueOnce({ - data: { - ip: "10.0.0.1", - city: "Shiraz", - country: "Iran", - country_code: "IR", - }, - }); - - const result = await getUserGeoRegion(true); - - expect(http.get).toHaveBeenCalledWith("/account/auth/user/region/", { - timeout: 4000, - }); - expect(result).toEqual({ - ip: "10.0.0.1", + const cached = { city: "Shiraz", country: "Iran", countryCode: "IR", phoneCode: "+98", - }); + }; + setStoredUserGeoRegion(cached); + + const result = await getUserGeoRegion(false); + expect(result).toEqual(cached); }); - it("falls back to HTTP backend if Flutter bridge returns failure", async () => { + it("falls back to stored region if Flutter bridge returns failure", async () => { + const existing = { + ip: "192.168.1.1", + city: "Mashhad", + country: "Iran", + countryCode: "IR", + phoneCode: "+98", + }; + setStoredUserGeoRegion(existing); + let listenerCallback: ((event: any) => void) | undefined; window.addFlutterResponseListener = vi.fn().mockImplementation((cb) => { listenerCallback = cb; @@ -149,15 +139,6 @@ describe("geo-region", () => { window.HabibApp = { postMessage: mockPostMessage }; - (http.get as any).mockResolvedValueOnce({ - data: { - ip: "192.168.1.1", - city: "Mashhad", - country: "Iran", - country_code: "IR", - }, - }); - const result = await getUserGeoRegion(true); expect(result.city).toBe("Mashhad"); expect(result.countryCode).toBe("IR"); diff --git a/src/lib/geo-region.ts b/src/lib/geo-region.ts index d2cb734..3b8ee5c 100644 --- a/src/lib/geo-region.ts +++ b/src/lib/geo-region.ts @@ -1,7 +1,6 @@ "use client"; import { PhoneNumberUtil } from "google-libphonenumber"; -import { http } from "./http"; import { resolveCountryName } from "@/data/countries"; export type UserGeoRegion = { @@ -82,104 +81,30 @@ function resolvePhoneCodeFromCountryCode(isoCode?: string): string | undefined { return undefined; } -/** - * Fetch geo region from backend HTTP API with multi-tier public fallbacks. - */ -async function fetchHttpGeoRegion(): Promise { - try { - // 1. Primary: Habib Backend User Region API (/account/auth/user/region/) - try { - console.log( - "[GEO_BRIDGE_LOG] 🚀 Requesting /account/auth/user/region/ from backend...", - ); - const response = await http.get<{ - ip?: string; - country?: string; - country_code?: string; - city?: string; - }>("/account/auth/user/region/", { - timeout: 4000, - }); - - const data = response.data; - console.log( - "[GEO_BRIDGE_LOG] 📥 Backend Raw Response:", - JSON.stringify(data), - ); - if (data && (data.country || data.country_code || data.city || data.ip)) { - const rawCountry = data.country || data.country_code || ""; - const isoCode = - data.country_code || - (rawCountry && rawCountry.trim().length === 2 - ? rawCountry.trim().toUpperCase() - : undefined); - const phoneCode = resolvePhoneCodeFromCountryCode(isoCode); - const countryName = - resolveCountryName(data.country || isoCode, "en") || data.country; - const countryFa = - resolveCountryName(data.country || isoCode, "fa") || countryName; - - console.log( - `[GEO_BRIDGE_LOG] 🌍 Resolved Country: FA=${countryFa} | EN=${countryName} | Code=${isoCode} | City=${data.city || "None"} | IP=${data.ip || "None"}`, - ); - - const region: UserGeoRegion = { - ip: data.ip, - city: data.city, - country: countryName, - countryCode: isoCode, - phoneCode: phoneCode || "+44", - }; - setStoredUserGeoRegion(region); - return region; - } - } catch (err: any) { - console.error( - "[GEO_BRIDGE_LOG] ❌ Backend Region Error:", - err?.response?.data || err?.message || err, - ); - } - - // Default fallback: preserve previously cached/stored region if present - const existing = getStoredUserGeoRegion(); - if ( - existing && - (existing.country || existing.phoneCode || existing.countryCode || existing.city) - ) { - console.log( - "[GEO_BRIDGE_LOG] 📦 Preserving existing stored user geo region:", - JSON.stringify(existing), - ); - setStoredUserGeoRegion(existing); - return existing; - } - - console.log("[GEO_BRIDGE_LOG] ⚠️ Using default fallback region (+44)..."); - const defaultRegion: UserGeoRegion = { - phoneCode: "+44", - }; - setStoredUserGeoRegion(defaultRegion); - return defaultRegion; - } catch { - const existing = getStoredUserGeoRegion(); - if ( - existing && - (existing.country || existing.phoneCode || existing.countryCode || existing.city) - ) { - setStoredUserGeoRegion(existing); - return existing; - } - - const defaultRegion: UserGeoRegion = { - phoneCode: "+44", - }; - setStoredUserGeoRegion(defaultRegion); - return defaultRegion; +function getFallbackGeoRegion(): UserGeoRegion { + const existing = getStoredUserGeoRegion(); + if ( + existing && + (existing.country || existing.phoneCode || existing.countryCode || existing.city) + ) { + console.log( + "[GEO_BRIDGE_LOG] 📦 Preserving existing stored user geo region:", + JSON.stringify(existing), + ); + return existing; } + + console.log("[GEO_BRIDGE_LOG] ⚠️ Using default fallback region (+44)..."); + const defaultRegion: UserGeoRegion = { + phoneCode: "+44", + }; + setStoredUserGeoRegion(defaultRegion); + return defaultRegion; } /** - * Fetch geo region via Flutter bridge protocol. + * Fetch geo region strictly via Flutter bridge action protocol ('get_location'). + * No direct backend HTTP requests are made. */ function fetchFlutterBridgeGeoRegion(): Promise { return new Promise((resolve) => { @@ -264,11 +189,9 @@ function fetchFlutterBridgeGeoRegion(): Promise { finish(region); } else { console.warn( - "[GEO_BRIDGE_LOG] ⚠️ Flutter get_location unsuccessful or empty, falling back to HTTP", + "[GEO_BRIDGE_LOG] ⚠️ Flutter get_location unsuccessful or empty, falling back to local cached default", ); - fetchHttpGeoRegion() - .then(finish) - .catch(() => finish({ phoneCode: "+44" })); + finish(getFallbackGeoRegion()); } } }, @@ -298,20 +221,23 @@ function fetchFlutterBridgeGeoRegion(): Promise { ); } - // 4) Has a timeout fallback of 4000ms: if no response from Flutter, fallback to calling http.get("/account/auth/user/region/") + // 4) Has a timeout fallback of 4000ms: if no response from Flutter, fallback to cached / default region (no HTTP) timer = setTimeout(() => { if (!resolved) { console.warn( - "[GEO_BRIDGE_LOG] ⏱️ Flutter bridge get_location timed out after 4000ms, falling back to HTTP...", + "[GEO_BRIDGE_LOG] ⏱️ Flutter bridge get_location timed out after 4000ms, using fallback region...", ); - fetchHttpGeoRegion() - .then(finish) - .catch(() => finish({ phoneCode: "+44" })); + finish(getFallbackGeoRegion()); } }, 4000); }); } +/** + * Get user geo region. + * Uses Flutter bridge action ('get_location') exclusively for location detection. + * Never performs direct HTTP requests. + */ export function getUserGeoRegion(force = false): Promise { // If !force: Check cachedRegion or getStoredUserGeoRegion(). If present, return it immediately. if (!force) { @@ -341,20 +267,17 @@ export function getUserGeoRegion(force = false): Promise { (Boolean(window.HabibApp?.postMessage) || typeof (window as any).sendToFlutter === "function"); - const isDev = process.env.NODE_ENV === "development"; - - if (isFlutter && !isDev) { + if (isFlutter) { console.log( - "[GEO_BRIDGE_LOG] 📱 Inside Flutter WebView detected, requesting location via Flutter bridge", + "[GEO_BRIDGE_LOG] 📱 Inside Flutter WebView detected, requesting location via Flutter bridge action 'get_location'", ); geoRegionPromise = fetchFlutterBridgeGeoRegion(); } else { console.log( - isDev && isFlutter - ? "[GEO_BRIDGE_LOG] 🛠️ Development environment (npm run dev) detected, skipping Flutter bridge action and requesting location via HTTP" - : "[GEO_BRIDGE_LOG] 🌐 Standard browser environment detected, requesting location via HTTP", + "[GEO_BRIDGE_LOG] 🌐 Standard browser / non-Flutter environment: resolving from cached storage or default fallback (no HTTP calls)", ); - geoRegionPromise = fetchHttpGeoRegion(); + const fallback = getFallbackGeoRegion(); + geoRegionPromise = Promise.resolve(fallback); } return geoRegionPromise;