Browse Source

fix(geo): update geo region and birthplace handling

master
mortezaei 3 days ago
parent
commit
2e55e3395e
  1. 133
      src/components/Componentes/question-birthplace.tsx
  2. 124
      src/components/Componentes/question-phone.test.tsx
  3. 125
      src/components/Componentes/ui-config.test.tsx
  4. 49
      src/lib/geo-region.test.ts
  5. 113
      src/lib/geo-region.ts

133
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<NodeJS.Timeout | null>(null);
const [mode, setMode] = useState<"auto" | "manual">("auto");
const [isDetecting, setIsDetecting] = useState(false);
const [detectedLocation, setDetectedLocation] = useState(initialLoc);
const hasAutoDetectedRef = useRef(false);
@ -227,17 +259,38 @@ export function QuestionBirthplace({
};
// GeoIP detection logic using unified getUserGeoRegion
const detectLocation = useCallback(async (force = false) => {
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;
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);
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;
}
}
@ -254,13 +307,21 @@ export function QuestionBirthplace({
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) {
@ -271,23 +332,47 @@ export function QuestionBirthplace({
setIsDetecting(false);
}
}
}, [rawValue, locale, question, setAnswerValue]);
},
[rawValue, locale, question, setAnswerValue],
);
useEffect(() => {
if (isLoading) return;
if (isResidence && !hasAutoDetectedRef.current) {
hasAutoDetectedRef.current = true;
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<HTMLInputElement>) => {
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}
/>
</div>

124
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<string, unknown> = {};
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 = [];
afterEach(() => {
cleanup();
window.addFlutterResponseListener = vi.fn().mockImplementation((listener) => {
flutterListeners.push(listener);
return () => {
const idx = flutterListeners.indexOf(listener);
if (idx >= 0) flutterListeners.splice(idx, 1);
};
});
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;
window.HabibApp = {
postMessage: vi.fn(),
};
});
httpMocks.get.mockReturnValue(regionPromise);
afterEach(() => {
delete (window as any).HabibApp;
delete (window as any).addFlutterResponseListener;
cleanup();
});
it("renders single unified shimmer on country button while Flutter get_location is pending, then shows resolved country code", async () => {
const { container } = render(<QuestionPhone question={phoneQuestion1} />);
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({
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(<QuestionPhone question={phoneQuestion1} />);
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(
<QuestionPhone question={phoneQuestion1} countryCode="+44" />,
@ -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(
<>
<QuestionPhone question={phoneQuestion1} />
@ -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({
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(<QuestionPhone question={phoneQuestion1} countryCode="+44" />);
const input = screen.getByRole("textbox");
fireEvent.change(input, { target: { value: "7400123456" } });
await act(async () => {
resolveRegion({
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(<QuestionPhone question={phoneQuestion1} countryCode="+98" />);
@ -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(<QuestionPhone question={phoneQuestion1} countryCode="+44" />);
const input = screen.getByRole("textbox") as HTMLInputElement;
expect(input.placeholder).toBe("7400 123456");
await act(async () => {
resolveRegion({
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(
<QuestionPhone question={phoneQuestion1} countryCode="+98" />,

125
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(
<QueryClientProvider client={queryClient}>
<QuestionAnswersProvider slug="test-residence-manual" questions={[qResidence]}>
<QuestionBirthplace question={qResidence} />
</QuestionAnswersProvider>
</QueryClientProvider>,
);
// 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(
<QueryClientProvider client={queryClient}>
<QuestionAnswersProvider slug="test-persistence" questions={[qResidence]}>
<QuestionBirthplace question={qResidence} />
</QuestionAnswersProvider>
</QueryClientProvider>,
);
// 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(
<QueryClientProvider client={queryClient}>
<QuestionAnswersProvider
slug="test-persistence"
locale="fa"
questions={[qResidence]}
>
<QuestionBirthplace question={qResidence} />
</QuestionAnswersProvider>
</QueryClientProvider>,
);
// Verify it stays in manual mode with "یزد"
await waitFor(() => {
const reenteredCityInput = screen.getByPlaceholderText("شهر، منطقه یا محله");
expect((reenteredCityInput as HTMLInputElement).value).toBe("یزد");
});
});
});

49
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",
const cached = {
city: "Shiraz",
country: "Iran",
country_code: "IR",
},
});
const result = await getUserGeoRegion(true);
countryCode: "IR",
phoneCode: "+98",
};
setStoredUserGeoRegion(cached);
expect(http.get).toHaveBeenCalledWith("/account/auth/user/region/", {
timeout: 4000,
const result = await getUserGeoRegion(false);
expect(result).toEqual(cached);
});
expect(result).toEqual({
ip: "10.0.0.1",
city: "Shiraz",
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);
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;
@ -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");

113
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,65 +81,7 @@ function resolvePhoneCodeFromCountryCode(isoCode?: string): string | undefined {
return undefined;
}
/**
* Fetch geo region from backend HTTP API with multi-tier public fallbacks.
*/
async function fetchHttpGeoRegion(): Promise<UserGeoRegion> {
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
function getFallbackGeoRegion(): UserGeoRegion {
const existing = getStoredUserGeoRegion();
if (
existing &&
@ -150,7 +91,6 @@ async function fetchHttpGeoRegion(): Promise<UserGeoRegion> {
"[GEO_BRIDGE_LOG] 📦 Preserving existing stored user geo region:",
JSON.stringify(existing),
);
setStoredUserGeoRegion(existing);
return existing;
}
@ -160,26 +100,11 @@ async function fetchHttpGeoRegion(): Promise<UserGeoRegion> {
};
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;
}
}
/**
* 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<UserGeoRegion> {
return new Promise<UserGeoRegion>((resolve) => {
@ -264,11 +189,9 @@ function fetchFlutterBridgeGeoRegion(): Promise<UserGeoRegion> {
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<UserGeoRegion> {
);
}
// 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<UserGeoRegion> {
// If !force: Check cachedRegion or getStoredUserGeoRegion(). If present, return it immediately.
if (!force) {
@ -341,20 +267,17 @@ export function getUserGeoRegion(force = false): Promise<UserGeoRegion> {
(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;

Loading…
Cancel
Save