diff --git a/src/lib/geo-region.test.ts b/src/lib/geo-region.test.ts new file mode 100644 index 0000000..e07e7a6 --- /dev/null +++ b/src/lib/geo-region.test.ts @@ -0,0 +1,168 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { + getUserGeoRegion, + getStoredUserGeoRegion, + setStoredUserGeoRegion, + resetUserGeoRegionForTesting, +} from "./geo-region"; +import { http } from "./http"; + +vi.mock("./http", () => ({ + http: { + get: vi.fn(), + }, +})); + +describe("geo-region", () => { + const originalHabibApp = window.HabibApp; + const originalAddFlutterResponseListener = window.addFlutterResponseListener; + + beforeEach(() => { + vi.restoreAllMocks(); + localStorage.clear(); + resetUserGeoRegionForTesting(); + }); + + afterEach(() => { + window.HabibApp = originalHabibApp; + window.addFlutterResponseListener = originalAddFlutterResponseListener; + resetUserGeoRegionForTesting(); + }); + + describe("getStoredUserGeoRegion / setStoredUserGeoRegion", () => { + it("returns null when nothing is stored", () => { + expect(getStoredUserGeoRegion()).toBeNull(); + }); + + it("persists region and retrieves from memory and localStorage", () => { + const sample = { + ip: "1.2.3.4", + city: "Tehran", + country: "Iran", + countryCode: "IR", + phoneCode: "+98", + }; + setStoredUserGeoRegion(sample); + expect(getStoredUserGeoRegion()).toEqual(sample); + expect(localStorage.getItem("user_geo_region")).toBe( + JSON.stringify(sample), + ); + expect(localStorage.getItem("geoIPPhoneCode")).toBe("+98"); + }); + }); + + describe("getUserGeoRegion", () => { + it("returns stored region immediately if not forced", async () => { + const cached = { + city: "London", + country: "United Kingdom", + countryCode: "GB", + phoneCode: "+44", + }; + setStoredUserGeoRegion(cached); + + const result = await getUserGeoRegion(false); + expect(result).toEqual(cached); + expect(http.get).not.toHaveBeenCalled(); + }); + + it("fetches via Flutter bridge when in Flutter WebView", async () => { + let listenerCallback: ((event: any) => void) | undefined; + window.addFlutterResponseListener = vi.fn().mockImplementation((cb) => { + listenerCallback = cb; + return () => {}; + }); + + const mockPostMessage = vi.fn().mockImplementation(() => { + setTimeout(() => { + listenerCallback?.({ + action: "get_location", + success: true, + data: { + ip: "5.6.7.8", + city: "Isfahan", + country: "Iran", + country_code: "IR", + }, + }); + }, 10); + }); + + window.HabibApp = { postMessage: mockPostMessage }; + + const result = await getUserGeoRegion(true); + + expect(mockPostMessage).toHaveBeenCalledWith( + JSON.stringify({ action: "get_location" }), + ); + expect(result).toEqual({ + ip: "5.6.7.8", + city: "Isfahan", + country: "Iran", + countryCode: "IR", + phoneCode: "+98", + }); + expect(getStoredUserGeoRegion()?.countryCode).toBe("IR"); + }); + + it("fetches from HTTP backend when in standard browser", 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", + city: "Shiraz", + country: "Iran", + countryCode: "IR", + phoneCode: "+98", + }); + }); + + it("falls back to HTTP backend if Flutter bridge returns failure", async () => { + let listenerCallback: ((event: any) => void) | undefined; + window.addFlutterResponseListener = vi.fn().mockImplementation((cb) => { + listenerCallback = cb; + return () => {}; + }); + + const mockPostMessage = vi.fn().mockImplementation(() => { + setTimeout(() => { + listenerCallback?.({ + action: "get_location", + success: false, + }); + }, 10); + }); + + 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"); + expect(result.country).toBe("Iran"); + expect(result.phoneCode).toBe("+98"); + }); + }); +}); diff --git a/src/lib/geo-region.ts b/src/lib/geo-region.ts index 7948f3f..c433d48 100644 --- a/src/lib/geo-region.ts +++ b/src/lib/geo-region.ts @@ -43,7 +43,10 @@ export function getStoredUserGeoRegion(): UserGeoRegion | null { const stored = localStorage.getItem(STORAGE_KEY); if (stored) { const parsed = JSON.parse(stored) as UserGeoRegion; - if (parsed && (parsed.country || parsed.phoneCode)) { + if ( + parsed && + (parsed.country || parsed.phoneCode || parsed.city || parsed.countryCode) + ) { cachedRegion = parsed; return parsed; } @@ -79,158 +82,330 @@ function resolvePhoneCodeFromCountryCode(isoCode?: string): string | undefined { return undefined; } -export function getUserGeoRegion(force = false): Promise { - if (!force) { - const existing = getStoredUserGeoRegion(); - if (existing && (existing.city || existing.country)) { - return Promise.resolve(existing); - } - } else { - cachedRegion = null; - geoRegionPromise = null; - } +/** + * 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, + }); - if (geoRegionPromise) { - return geoRegionPromise; - } + 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; - geoRegionPromise = (async () => { - try { - // 1. Primary: Habib Backend User Region API (/account/auth/user/region/) - try { - console.log("[GEO_AUTO_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_AUTO_LOG] 📥 Backend Raw Response:", JSON.stringify(data)); - if (data && (data.country || data.country_code || data.city)) { - const isoCode = - data.country_code || - (data.country && data.country.trim().length === 2 - ? data.country.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_AUTO_LOG] 🌍 Resolved Country: FA=${countryFa} | EN=${countryName} | Code=${isoCode} | City=${data.city || "None"} | IP=${data.ip || "None"}`, - ); + 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, + ); + } + // 2. Secondary fallback: ipapi.co with 2s timeout + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), 2000); + try { + console.log("[GEO_BRIDGE_LOG] 🔄 Falling back to ipapi.co..."); + 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: countryName, - countryCode: isoCode, - phoneCode: phoneCode || "+44", + country: data.country_name, + countryCode: data.country_code, + phoneCode, }; setStoredUserGeoRegion(region); return region; } - } catch (err: any) { - console.error( - "[GEO_AUTO_LOG] ❌ Backend Region Error:", - err?.response?.data || err?.message || err, - ); } + } catch { + clearTimeout(timeoutId); + } - // 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; - } + // 3. Tertiary fallback: ipwho.is with 2s timeout + const secondaryController = new AbortController(); + const secondaryTimeoutId = setTimeout( + () => secondaryController.abort(), + 2000, + ); + try { + console.log("[GEO_BRIDGE_LOG] 🔄 Falling back to ipwho.is..."); + 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(timeoutId); } + } catch { + clearTimeout(secondaryTimeoutId); + } - // 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; + // 4. Default fallback + console.log("[GEO_BRIDGE_LOG] ⚠️ Using default fallback region (+44)..."); + const defaultRegion: UserGeoRegion = { + phoneCode: "+44", + }; + setStoredUserGeoRegion(defaultRegion); + return defaultRegion; + } catch { + const defaultRegion: UserGeoRegion = { + phoneCode: "+44", + }; + setStoredUserGeoRegion(defaultRegion); + return defaultRegion; + } +} + +/** + * Fetch geo region via Flutter bridge protocol. + */ +function fetchFlutterBridgeGeoRegion(): Promise { + return new Promise((resolve) => { + let resolved = false; + let unsubscribe: (() => void) | undefined; + let timer: ReturnType | null = null; + + const finish = (region: UserGeoRegion) => { + if (resolved) return; + resolved = true; + if (timer) { + clearTimeout(timer); + timer = null; + } + if (unsubscribe) { + try { + unsubscribe(); + } catch {} + unsubscribe = undefined; + } + resolve(region); + }; + + console.log( + "[GEO_BRIDGE_LOG] 🌉 Registering Flutter response listener for get_location...", + ); + + // 1) Register a listener with window.addFlutterResponseListener + if ( + typeof window !== "undefined" && + typeof window.addFlutterResponseListener === "function" + ) { + unsubscribe = window.addFlutterResponseListener( + (event: FlutterResponseEvent) => { + if (!event) return; + const action = event.action?.toLowerCase(); + // 2) Listens for event.action === "get_location" + if (action === "get_location") { + console.log( + "[GEO_BRIDGE_LOG] 📥 Flutter get_location response received:", + JSON.stringify(event), + ); + + const data = (event.data || (event as any).payload) as { + ip?: string; + country?: string; + country_code?: string; + city?: string; + } | undefined; + + if ( + event.success && + 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 from Flutter bridge: FA=${countryFa} | EN=${countryName} | Code=${isoCode} | City=${data.city || "None"} | IP=${data.ip || "None"} | Phone=${phoneCode || "+44"}`, + ); + + const region: UserGeoRegion = { + ip: data.ip, + city: data.city, + country: countryName, + countryCode: isoCode, + phoneCode: phoneCode || "+44", + }; + setStoredUserGeoRegion(region); + finish(region); + } else { + console.warn( + "[GEO_BRIDGE_LOG] ⚠️ Flutter get_location unsuccessful or empty, falling back to HTTP", + ); + fetchHttpGeoRegion() + .then(finish) + .catch(() => finish({ phoneCode: "+44" })); + } } - } - } catch { - clearTimeout(secondaryTimeoutId); + }, + ); + } + + // 3) Posts message: window.HabibApp.postMessage(JSON.stringify({ action: "get_location" })) + try { + if (typeof window !== "undefined" && window.HabibApp?.postMessage) { + console.log( + "[GEO_BRIDGE_LOG] 📤 Posting { action: 'get_location' } to window.HabibApp...", + ); + window.HabibApp.postMessage(JSON.stringify({ action: "get_location" })); + } else if ( + typeof window !== "undefined" && + typeof (window as any).sendToFlutter === "function" + ) { + console.log( + "[GEO_BRIDGE_LOG] 📤 Sending get_location via sendToFlutter...", + ); + (window as any).sendToFlutter("get_location"); } + } catch (e) { + console.error( + "[GEO_BRIDGE_LOG] ❌ Failed to post get_location message:", + e, + ); + } - // 4. Default fallback - const defaultRegion: UserGeoRegion = { - phoneCode: "+44", - }; - setStoredUserGeoRegion(defaultRegion); - return defaultRegion; - } catch { - const defaultRegion: UserGeoRegion = { - phoneCode: "+44", - }; - setStoredUserGeoRegion(defaultRegion); - return defaultRegion; + // 4) Has a timeout fallback of 4000ms: if no response from Flutter, fallback to calling http.get("/account/auth/user/region/") + timer = setTimeout(() => { + if (!resolved) { + console.warn( + "[GEO_BRIDGE_LOG] ⏱️ Flutter bridge get_location timed out after 4000ms, falling back to HTTP...", + ); + fetchHttpGeoRegion() + .then(finish) + .catch(() => finish({ phoneCode: "+44" })); + } + }, 4000); + }); +} + +export function getUserGeoRegion(force = false): Promise { + // If !force: Check cachedRegion or getStoredUserGeoRegion(). If present, return it immediately. + if (!force) { + const existing = cachedRegion || getStoredUserGeoRegion(); + if ( + existing && + (existing.city || existing.country || existing.phoneCode || existing.countryCode) + ) { + console.log( + "[GEO_BRIDGE_LOG] 📦 Returning cached/stored user geo region:", + JSON.stringify(existing), + ); + return Promise.resolve(existing); } - })(); + } else { + cachedRegion = null; + geoRegionPromise = null; + } + + if (geoRegionPromise) { + return geoRegionPromise; + } + + // Check if running inside Flutter Webview: typeof window !== "undefined" && window.HabibApp?.postMessage + const isFlutter = + typeof window !== "undefined" && + (Boolean(window.HabibApp?.postMessage) || + typeof (window as any).sendToFlutter === "function"); + + if (isFlutter) { + console.log( + "[GEO_BRIDGE_LOG] 📱 Inside Flutter WebView detected, requesting location via Flutter bridge", + ); + geoRegionPromise = fetchFlutterBridgeGeoRegion(); + } else { + console.log( + "[GEO_BRIDGE_LOG] 🌐 Standard browser environment detected, requesting location via HTTP", + ); + geoRegionPromise = fetchHttpGeoRegion(); + } return geoRegionPromise; } diff --git a/src/types/window.d.ts b/src/types/window.d.ts index 85af656..a72dd9d 100644 --- a/src/types/window.d.ts +++ b/src/types/window.d.ts @@ -19,6 +19,10 @@ declare global { // get_location latitude?: number; longitude?: number; + ip?: string; + country?: string; + country_code?: string; + city?: string; // get_view_paddings / safe_area_changed (flat edges) top?: number; bottom?: number;