diff --git a/src/app/questions-list/[slug]/question-detail-client.tsx b/src/app/questions-list/[slug]/question-detail-client.tsx index b296194..4d5a043 100644 --- a/src/app/questions-list/[slug]/question-detail-client.tsx +++ b/src/app/questions-list/[slug]/question-detail-client.tsx @@ -17,6 +17,7 @@ import { } from "@/components/Componentes/question-answer-storage"; import QuestionExitNavigationButton from "@/components/Componentes/question-exit-navigation-button"; import QuestionRenderer from "@/components/Componentes/question-renderer"; +import { parseValue as parseBirthplaceValue } from "@/components/Componentes/question-birthplace"; import QuestionSectionFlow from "@/components/Componentes/question-section-flow"; import StickyHeader from "@/components/Componentes/sticky-header"; import TestIntroPage from "@/components/Componentes/test-intro-page"; @@ -197,10 +198,8 @@ function QuestionFlowWrapper({ const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; isAnswered = emailRegex.test(String(answer).trim()); } else if (question.type === "birthplace") { - const strVal = String(answer); - const parts = strVal.split(",").map((p) => p.trim()); - isAnswered = - parts.length >= 2 && parts[0].length > 0 && parts[1].length > 0; + const parsed = parseBirthplaceValue(answer); + isAnswered = Boolean(parsed.country?.trim() && parsed.city?.trim()); } else if (question.type === "checkbox") { isAnswered = Array.isArray(answer) ? answer.length > 0 : hasAnswer; } diff --git a/src/components/Componentes/error-toast.tsx b/src/components/Componentes/error-toast.tsx index f23b366..b94a390 100644 --- a/src/components/Componentes/error-toast.tsx +++ b/src/components/Componentes/error-toast.tsx @@ -1,16 +1,18 @@ "use client"; import { useEffect, useState } from "react"; -import { IoAlertCircle, IoClose, IoCheckmarkCircle } from "react-icons/io5"; +import { IoClose } from "react-icons/io5"; type ErrorToastProps = { + title?: string; message: string; onClose: () => void; duration?: number; - variant?: "error" | "success"; + variant?: "error" | "success" | "warning" | "info"; }; export default function ErrorToast({ + title, message, onClose, duration = 4000, @@ -32,42 +34,56 @@ export default function ErrorToast({ setTimeout(onClose, 300); }; - const isSuccess = variant === "success"; + const getBorderColor = () => { + switch (variant) { + case "success": + return "border-t-[#10B981]"; + case "warning": + return "border-t-[#F59E0B]"; + case "info": + return "border-t-[#3B82F6]"; + case "error": + default: + return "border-t-[#F0445B]"; + } + }; return (
- {isSuccess ? ( - - ) : ( - - )} - - {message} - +
+ {title && ( + + {title} + + )} + + {message} + +
+
); } + diff --git a/src/components/Componentes/question-answer-storage.tsx b/src/components/Componentes/question-answer-storage.tsx index cdd55a2..0d03e91 100644 --- a/src/components/Componentes/question-answer-storage.tsx +++ b/src/components/Componentes/question-answer-storage.tsx @@ -17,6 +17,7 @@ import type { MarriageField, MarriageFieldValue, MarriagePhoneFieldValue, + MarriageBirthplaceFieldValue, UpdateMarriageSectionDataPayload, } from "@/hooks/marriage/types"; import { useMarriageProfileQuery } from "@/hooks/marriage/use-profile-main"; @@ -80,7 +81,7 @@ export function getQuestionAnswersStorageKey(slug: string) { } export function hasQuestionAnswerValue(value: MarriageFieldValue) { - if (value === null) { + if (value === null || value === undefined) { return false; } @@ -88,9 +89,40 @@ export function hasQuestionAnswerValue(value: MarriageFieldValue) { return value.trim().length > 0; } + if (typeof value === "object") { + if (Array.isArray(value)) { + return value.length > 0; + } + const phone = value as Partial; + if ( + typeof phone.countryCode === "string" || + typeof phone.phoneNumber === "string" + ) { + return Boolean(phone.countryCode?.trim() || phone.phoneNumber?.trim()); + } + const bp = value as Partial; + if (typeof bp.country === "string" || typeof bp.city === "string") { + return Boolean(bp.country?.trim() || bp.city?.trim()); + } + } + return true; } +function isMarriageBirthplaceFieldValue( + value: unknown, +): value is MarriageBirthplaceFieldValue { + if (!value || typeof value !== "object" || Array.isArray(value)) { + return false; + } + + const bpValue = value as Partial; + + return ( + typeof bpValue.country === "string" && typeof bpValue.city === "string" + ); +} + function isMarriageField(value: unknown): value is MarriageField { if (!value || typeof value !== "object") { return false; @@ -107,14 +139,15 @@ function isMarriageField(value: unknown): value is MarriageField { typeof field.value === "number" || typeof field.value === "boolean" || Array.isArray(field.value) || - isMarriagePhoneFieldValue(field.value)) + isMarriagePhoneFieldValue(field.value) || + isMarriageBirthplaceFieldValue(field.value)) ); } function isMarriagePhoneFieldValue( value: unknown, ): value is MarriagePhoneFieldValue { - if (!value || typeof value !== "object") { + if (!value || typeof value !== "object" || Array.isArray(value)) { return false; } diff --git a/src/components/Componentes/question-birthplace.test.ts b/src/components/Componentes/question-birthplace.test.ts new file mode 100644 index 0000000..c2edf60 --- /dev/null +++ b/src/components/Componentes/question-birthplace.test.ts @@ -0,0 +1,93 @@ +import { describe, expect, it } from "vitest"; +import { parseValue } from "./question-birthplace"; + +describe("QuestionBirthplace parseValue", () => { + it("parses empty and null values safely", () => { + expect(parseValue(null)).toEqual({ country: "", city: "" }); + expect(parseValue(undefined)).toEqual({ country: "", city: "" }); + expect(parseValue("")).toEqual({ country: "", city: "" }); + }); + + it("parses structured objects", () => { + expect(parseValue({ country: "Iran", city: "Tehran" })).toEqual({ + country: "Iran", + city: "Tehran", + }); + expect(parseValue({ country: "Mashhad", city: "Iran" })).toEqual({ + country: "Iran", + city: "Mashhad", + }); + expect(parseValue({ country: "مشهد", city: "ایران" })).toEqual({ + country: "ایران", + city: "مشهد", + }); + }); + + it("parses standard 'Country, City' strings", () => { + expect(parseValue("Iran, Tehran")).toEqual({ + country: "Iran", + city: "Tehran", + }); + expect(parseValue("ایران, شیراز")).toEqual({ + country: "ایران", + city: "شیراز", + }); + }); + + it("never swaps country and city even when city equals country name", () => { + // Country selected as 'Afghanistan' and city typed as 'albania' + expect(parseValue("Afghanistan, albania")).toEqual({ + country: "Afghanistan", + city: "albania", + }); + + // Country selected as 'Albania' and city typed as 'Albania' + expect(parseValue("Albania, Albania")).toEqual({ + country: "Albania", + city: "Albania", + }); + + // Country selected as 'United States (US)' and city typed as 'Georgia' + expect(parseValue("United States (US), Georgia")).toEqual({ + country: "United States (US)", + city: "Georgia", + }); + }); + + it("correctly identifies country and city when formatted as 'City, Country'", () => { + expect(parseValue("Mashhad, Iran")).toEqual({ + country: "Iran", + city: "Mashhad", + }); + expect(parseValue("مشهد، ایران")).toEqual({ + country: "ایران", + city: "مشهد", + }); + expect(parseValue("Tehran, IR")).toEqual({ + country: "IR", + city: "Tehran", + }); + }); + + it("parses single country string", () => { + expect(parseValue("Iran")).toEqual({ + country: "Iran", + city: "", + }); + expect(parseValue("ایران")).toEqual({ + country: "ایران", + city: "", + }); + expect(parseValue("Afghanistan")).toEqual({ + country: "Afghanistan", + city: "", + }); + }); + + it("parses single custom city string without matching country", () => { + expect(parseValue("Rey")).toEqual({ + country: "", + city: "Rey", + }); + }); +}); diff --git a/src/components/Componentes/question-birthplace.tsx b/src/components/Componentes/question-birthplace.tsx index 25a706e..2593dd6 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, 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"; @@ -24,19 +24,46 @@ type BirthplaceValue = { city?: string; }; -function parseValue(rawValue: unknown): { country: string; city: string } { +export function parseValue(rawValue: unknown): { country: string; city: string } { if (!rawValue) return { country: "", city: "" }; if (typeof rawValue === "object" && rawValue !== null) { const obj = rawValue as BirthplaceValue; + const rawCountry = typeof obj.country === "string" ? obj.country.trim() : ""; + const rawCity = typeof obj.city === "string" ? obj.city.trim() : ""; + + // If obj has country and city inverted (e.g. { country: "Mashhad", city: "Iran" }) + if (rawCountry && rawCity && !isKnownCountry(rawCountry) && isKnownCountry(rawCity)) { + return { + country: rawCity, + city: rawCountry, + }; + } + // If only country is provided but it is actually a city + if (rawCountry && !rawCity && !isKnownCountry(rawCountry)) { + return { + country: "", + city: rawCountry, + }; + } + // If only city is provided but it is actually a country + if (!rawCountry && rawCity && isKnownCountry(rawCity)) { + return { + country: rawCity, + city: "", + }; + } + return { - country: typeof obj.country === "string" ? obj.country : "", - city: typeof obj.city === "string" ? obj.city : "", + country: rawCountry, + city: rawCity, }; } if (typeof rawValue === "string") { let str = rawValue.trim(); + if (!str) return { country: "", city: "" }; + // Clean legacy country names with parentheses containing commas str = str.replace( "United Kingdom (UK, England, Wales, Scotland, Northern Ireland)", @@ -63,16 +90,33 @@ function parseValue(rawValue: unknown): { country: string; city: string } { "ایالات متحده آمریکا (US)", ); + const splitLocation = (part1: string, part2: string) => { + // 1. If part1 is a known country and part2 is not (or both), part1 is country, part2 is city + if (isKnownCountry(part1)) { + return { country: part1, city: part2 }; + } + // 2. If part1 is not a known country, but part2 is a known country (e.g. "Mashhad, Iran" or "مشهد، ایران") + if (isKnownCountry(part2)) { + return { country: part2, city: part1 }; + } + // 3. Fallback: assume first part is country + return { country: part1, city: part2 }; + }; + if (str.includes(",")) { - const parts = str.split(",").map((s) => s.trim()); - return { city: parts[0] || "", country: parts[1] || "" }; + const idx = str.indexOf(","); + return splitLocation(str.slice(0, idx).trim(), str.slice(idx + 1).trim()); + } + if (str.includes("،")) { + const idx = str.indexOf("،"); + return splitLocation(str.slice(0, idx).trim(), str.slice(idx + 1).trim()); } if (str.includes(" - ")) { - const parts = str.split(" - ").map((s) => s.trim()); - return { country: parts[0] || "", city: parts[1] || "" }; + const idx = str.indexOf(" - "); + return splitLocation(str.slice(0, idx).trim(), str.slice(idx + 3).trim()); } - const isCountry = COUNTRIES_EN.includes(str) || COUNTRIES_FA.includes(str); - if (isCountry) { + + if (isKnownCountry(str)) { return { country: str, city: "" }; } return { country: "", city: str }; @@ -93,14 +137,35 @@ export function QuestionBirthplace({ const storedRegion = isResidence ? getStoredUserGeoRegion() : null; const initial = parseValue(rawValue); - const localizedInitialCountry = resolveCountryName( - initial.country || storedRegion?.country, - locale, + const hasSavedAnswer = Boolean( + initial.country?.trim() || + initial.city?.trim() || + (typeof rawValue === "string" && rawValue.trim().length > 0), ); - const initialCity = initial.city || storedRegion?.city || ""; + + 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; + } + return "auto"; + }); + + const isInitialManual = mode === "manual"; + + const localizedInitialCountry = + resolveCountryName(initial.country, locale) || + initial.country || + (!hasSavedAnswer && !isInitialManual && storedRegion?.country + ? resolveCountryName(storedRegion.country, locale) || storedRegion.country + : ""); + + const initialCity = + initial.city || (!hasSavedAnswer && !isInitialManual && storedRegion?.city ? storedRegion.city : ""); + const initialLoc = localizedInitialCountry || initialCity - ? [initialCity, localizedInitialCountry].filter(Boolean).join(", ") + ? [localizedInitialCountry, initialCity].filter(Boolean).join(", ") : ""; const [selectedCountry, setSelectedCountry] = useState( @@ -110,21 +175,37 @@ 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(""); const cityInputRef = useRef(null); const listRef = useRef(null); const isMountedRef = useRef(true); + 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); useEffect(() => { isMountedRef.current = true; return () => { isMountedRef.current = false; + if (debounceTimerRef.current) { + clearTimeout(debounceTimerRef.current); + } }; }, []); @@ -162,98 +243,182 @@ export function QuestionBirthplace({ return () => window.removeEventListener("keydown", handleKeyDown); }, [isOpen, closeSheet]); + const lastInternalAnswerRef = useRef(null); + const updateAnswers = (country: string, city: string) => { - const formatted = - city && country ? `${city}, ${country}` : city || country || null; - setAnswerValue(question, formatted); + const cleanCountry = country?.trim() || ""; + const cleanCity = city?.trim() || ""; + const payload = + cleanCountry || cleanCity + ? { country: cleanCountry, city: cleanCity } + : null; + lastInternalAnswerRef.current = payload; + setAnswerValue(question, payload); }; // 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); - if (cName || parsed.city) { - const loc = [parsed.city, cName].filter(Boolean).join(", "); - setSelectedCountry(cName); - 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}`) + : null; + + if (storedMode === "manual") { + setMode("manual"); + } else { + 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 (city || country) { - const loc = [city, country].filter(Boolean).join(", "); - setSelectedCountry(country); - setCityInput(city); - setDetectedLocation(loc); - updateAnswers(country, city); - setMode("auto"); - } else { - setMode("manual"); - } - } catch { - if (isMountedRef.current) { - setMode("manual"); - } - } finally { - if (isMountedRef.current) { - setIsDetecting(false); + 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); + } } - } - }, [rawValue, locale]); + }, + [rawValue, locale, question, setAnswerValue], + ); useEffect(() => { if (isLoading) return; - if (isResidence) { - void detectLocation(); + if (isResidence && !hasAutoDetectedRef.current) { + hasAutoDetectedRef.current = true; + const storedMode = + typeof window !== "undefined" + ? localStorage.getItem(`residence_mode_${question.id}`) + : null; + + if (storedMode === "manual") { + setMode("manual"); + return; + } + + const parsed = parseValue(rawValue); + if (!parsed.country && !parsed.city) { + void detectLocation(false); + } 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("hasCheckedGeoIPResidence", "true"); + localStorage.setItem(`residence_mode_${question.id}`, "manual"); } setMode("manual"); const parsed = parseValue(rawValue); - const resolvedC = resolveCountryName(selectedCountry || parsed.country, locale); - const country = resolvedC || selectedCountry || parsed.country; - const city = cityInput || parsed.city; + const resolvedC = + resolveCountryName(selectedCountry || parsed.country, locale) || + selectedCountry || + parsed.country; + const country = resolvedC; + 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(", ")); }; - // Synchronize state if rawValue changes externally + // Synchronize state ONLY if rawValue changes externally (e.g. draft fetch or reset) useEffect(() => { + if (isFocusedRef.current) { + return; + } + if (rawValue === lastInternalAnswerRef.current) { + return; + } + if ( + typeof rawValue === "object" && + rawValue !== null && + typeof lastInternalAnswerRef.current === "object" && + lastInternalAnswerRef.current !== null + ) { + const currentObj = lastInternalAnswerRef.current as BirthplaceValue; + const rawObj = rawValue as BirthplaceValue; + if ( + (rawObj.country?.trim() || "") === (currentObj.country?.trim() || "") && + (rawObj.city?.trim() || "") === (currentObj.city?.trim() || "") + ) { + return; + } + } const updated = parseValue(rawValue); - const resolvedC = resolveCountryName(updated.country, locale); - if (resolvedC && resolvedC !== selectedCountry) { + const resolvedC = resolveCountryName(updated.country, locale) || updated.country; + if (resolvedC !== selectedCountry) { setSelectedCountry(resolvedC); + selectedCountryStateRef.current = resolvedC; } - if (updated.city.trim() !== cityInput.trim()) { + if (updated.city !== cityInput) { setCityInput(updated.city); + cityInputStateRef.current = updated.city; } if (resolvedC || updated.city) { - setDetectedLocation([updated.city, resolvedC].filter(Boolean).join(", ")); + setDetectedLocation([resolvedC, updated.city].filter(Boolean).join(", ")); } + lastInternalAnswerRef.current = rawValue as BirthplaceValue | string | null; }, [rawValue, locale]); const options = getCountryList(locale); @@ -263,11 +428,13 @@ export function QuestionBirthplace({ const handleSelectCountry = (country: string) => { if (typeof window !== "undefined") { - localStorage.setItem("hasCheckedGeoIPResidence", "true"); + localStorage.setItem(`residence_mode_${question.id}`, "manual"); } setSelectedCountry(country); + selectedCountryStateRef.current = country; closeSheet(); - updateAnswers(country, cityInput); + updateAnswers(country, cityInputStateRef.current); + setDetectedLocation([country, cityInputStateRef.current].filter(Boolean).join(", ")); window.setTimeout(() => { cityInputRef.current?.focus({ preventScroll: true }); }, EXIT_ANIMATION_MS); @@ -275,11 +442,31 @@ export function QuestionBirthplace({ const handleCityChange = (e: React.ChangeEvent) => { if (typeof window !== "undefined") { - localStorage.setItem("hasCheckedGeoIPResidence", "true"); + localStorage.setItem(`residence_mode_${question.id}`, "manual"); } const newCity = e.target.value; + cityInputStateRef.current = newCity; setCityInput(newCity); - updateAnswers(selectedCountry, newCity); + setDetectedLocation([selectedCountryStateRef.current, newCity].filter(Boolean).join(", ")); + + if (debounceTimerRef.current) { + clearTimeout(debounceTimerRef.current); + } + debounceTimerRef.current = setTimeout(() => { + updateAnswers(selectedCountryStateRef.current, newCity); + }, 200); + }; + + const handleCityFocus = () => { + isFocusedRef.current = true; + }; + + const handleCityBlur = () => { + isFocusedRef.current = false; + if (debounceTimerRef.current) { + clearTimeout(debounceTimerRef.current); + } + updateAnswers(selectedCountryStateRef.current, cityInputStateRef.current); }; const isRtl = locale === "fa" || locale === "ar" || locale === "ur"; @@ -475,6 +662,8 @@ export function QuestionBirthplace({ disabled={disabled} value={cityInput} onChange={handleCityChange} + onFocus={handleCityFocus} + onBlur={handleCityBlur} placeholder={cityPlaceholder} /> @@ -534,6 +723,8 @@ export function QuestionBirthplace({ disabled={disabled} value={cityInput} onChange={handleCityChange} + onFocus={handleCityFocus} + onBlur={handleCityBlur} placeholder={cityPlaceholder} className="h-[54px] w-full rounded-[16px] border border-[#D0D5DD] bg-white px-4.5 text-[15px] font-medium text-[#181818] placeholder:text-[#98A2B3] hover:border-[#98A2B3] focus:border-[#6F6F6F] focus:ring-1 focus:ring-[#6F6F6F] outline-none transition-all" /> diff --git a/src/components/Componentes/question-number.tsx b/src/components/Componentes/question-number.tsx index 597a342..86e7b0a 100644 --- a/src/components/Componentes/question-number.tsx +++ b/src/components/Componentes/question-number.tsx @@ -6,6 +6,7 @@ import { useI18n } from "@/translations/provider"; import { useQuestionAnswers } from "./question-answer-storage"; import QuestionTitle from "./question-title"; import { Input } from "@/components/ui/input"; +import { isKnownCountry } from "@/data/countries"; type QuestionNumberProps = { question: QuestionField; @@ -465,9 +466,20 @@ function getCountryFromStorage(): string { f.key?.includes("mhl_skwnt_fly"), ); const value = field?.value; + if (typeof value === "object" && value !== null) { + const obj = value as { country?: string; city?: string }; + if (typeof obj.country === "string" && obj.country.trim()) { + return obj.country.trim(); + } + } if (typeof value === "string") { - const parts = value.split(","); - return parts[0]?.trim() || ""; + const parts = value.split(",").map((p) => p.trim()); + if (parts.length >= 2) { + if (isKnownCountry(parts[1])) return parts[1]; + if (isKnownCountry(parts[0])) return parts[0]; + return parts[1]; + } + return parts[0] || ""; } } catch { // Ignore diff --git a/src/components/Componentes/question-phone.test.tsx b/src/components/Componentes/question-phone.test.tsx index 4620e97..895d93d 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,11 +103,15 @@ 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", + }, + }); }); }); @@ -117,42 +122,32 @@ describe("QuestionPhone IP country detection and shimmer", () => { }); }); - it("falls back to secondary fetch when Habib region API fails and shows resolved code", async () => { - httpMocks.get.mockRejectedValue(new Error("Network failure")); - - let resolveIpFetch!: (value: unknown) => void; - const ipPromise = new Promise((resolve) => { - resolveIpFetch = resolve; - }); - - vi.spyOn(globalThis, "fetch").mockImplementation(() => - ipPromise.then( - (data) => - ({ - ok: true, - json: async () => data, - }) as unknown as Response, - ), - ); + 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 () => { - resolveIpFetch({ country_calling_code: "+98" }); + flutterListeners.forEach((l) => { + l({ + action: "get_location", + success: false, + }); + }); }); await waitFor(() => { expect(container.querySelectorAll(".shimmer-bg").length).toBe(0); - expect(screen.getByText("+98")).toBeDefined(); - expect(screen.getByText("🇮🇷")).toBeDefined(); + expect(screen.getByText("+44")).toBeDefined(); + expect(screen.getByText("🇬🇧")).toBeDefined(); }); + + // Verify external fetch was NEVER called + 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( , @@ -165,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( <> @@ -180,14 +168,20 @@ 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", + }, + }); }); }); @@ -210,28 +204,24 @@ 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", + }, + }); }); }); @@ -239,9 +229,7 @@ describe("QuestionPhone IP country detection and shimmer", () => { }); 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(); @@ -261,24 +249,21 @@ 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", + }, + }); }); }); @@ -288,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/question-section-flow.tsx b/src/components/Componentes/question-section-flow.tsx index 69b5aa2..a9056d7 100644 --- a/src/components/Componentes/question-section-flow.tsx +++ b/src/components/Componentes/question-section-flow.tsx @@ -12,6 +12,7 @@ import QuestionProgressTracker, { } from "./question-progress-tracker"; import QuestionSnapList from "./question-snap-list"; import type { QuestionField } from "@/lib/schema-adapter"; +import ErrorToast from "./error-toast"; import NoticeBox from "./notice-box"; import AnswerPaceSheet from "@/app/questions-list/[slug]/answer-pace-sheet"; import { FixToTheEnd } from "./fix-to-the-end"; @@ -50,6 +51,7 @@ function SectionFlowContent({ const { markQuestionPassed, isCompleted } = useQuestionProgress(); const [activeQuestionIndex, setActiveQuestionIndex] = useState(0); const [isSubmitting, setIsSubmitting] = useState(false); + const [errorMessage, setErrorMessage] = useState(null); const handleQuestionExit = useCallback(() => { void flushAnswers({ force: true }); @@ -60,19 +62,46 @@ function SectionFlowContent({ return; } setIsSubmitting(true); + setErrorMessage(null); - try { + const MAX_RETRIES = 3; + let success = false; + + for (let attempt = 1; attempt <= MAX_RETRIES; attempt++) { + try { + await flushAnswers({ force: true }); + success = true; + break; + } catch (err) { + console.warn( + `[CONTINUE] flushAnswers attempt ${attempt}/${MAX_RETRIES} failed:`, + err, + ); + if (attempt < MAX_RETRIES) { + // Silent delay between retries while maintaining loading spinner + await new Promise((resolve) => setTimeout(resolve, 800)); + } + } + } + + if (success) { markFirstEntryCompleted(); - await flushAnswers({ force: true }); - } catch { - // ignore - } finally { if (onExit) { onExit(); } else { const target = localizePath(exitHref || "/questions-list", locale); router.replace(target); } + } else { + setIsSubmitting(false); + const isPersian = locale === "fa" || locale === "fa-ir"; + const isArabic = locale === "ar"; + const msg = isPersian + ? "خطا در اتصال به اینترنت. پاسخ‌ها با سرور همگام نشدند؛ لطفاً اتصال خود را بررسی و دوباره روی ادامه بزنید." + : isArabic + ? "خطأ في الاتصال بالإنترنت. تعذر مزامنة الإجابات مع الخادم؛ يرجى التحقق من الاتصال والمحاولة مرة أخرى." + : "Internet connection error. Answers could not be synced with server. Please check your connection and tap Continue again."; + setErrorMessage(msg); } }, [exitHref, onExit, flushAnswers, locale, router, isSubmitting]); @@ -137,6 +166,14 @@ function SectionFlowContent({ {process.env.NODE_ENV === "development" ? ( ) : null} + {errorMessage && ( + setErrorMessage(null)} + duration={5000} + variant="error" + /> + )} diff --git a/src/components/Componentes/slider-page.test.tsx b/src/components/Componentes/slider-page.test.tsx index 3077a8c..adceb6d 100644 --- a/src/components/Componentes/slider-page.test.tsx +++ b/src/components/Componentes/slider-page.test.tsx @@ -41,7 +41,7 @@ vi.mock('@/translations/provider', () => ({ useI18n: () => ({ locale: 'en', dictionary: { - "Failed to update profile basic details. Please try again.": "Failed to update profile basic details. Please try again.", + "Something went wrong. Please check your internet connection and try again.": "Something went wrong. Please check your internet connection and try again.", "Accept & Continue": "Accept & Continue", }, }), @@ -134,7 +134,7 @@ describe('SliderPage', () => { fireEvent.click(finishBtn); await waitFor(() => { - expect(screen.getByRole('alert')).toHaveTextContent('Failed to update profile basic details. Please try again.'); + expect(screen.getByRole('alert')).toHaveTextContent('Something went wrong. Please check your internet connection and try again.'); }); expect(mockReplace).not.toHaveBeenCalled(); diff --git a/src/components/Componentes/slider-page.tsx b/src/components/Componentes/slider-page.tsx index 60b30c0..30d5129 100644 --- a/src/components/Componentes/slider-page.tsx +++ b/src/components/Componentes/slider-page.tsx @@ -11,6 +11,7 @@ import { import { localizePath } from "@/translations/config"; import { useI18n } from "@/translations/provider"; import Button from "./button"; +import ErrorToast from "./error-toast"; import NavigationButton from "./navigation-button"; import type { GenderAnswer, RegistrationAnswer } from "./slider-slide"; import { SliderSlideFive } from "./slider-slide-five"; @@ -116,13 +117,22 @@ export default function SliderPage({ onClose }: SliderPageProps = {}) { router.replace(localizedTarget); } catch (error) { console.error("Failed to complete onboarding:", error); - setSubmitError(t["Failed to update profile basic details. Please try again."] || "Failed to update profile basic details. Please try again."); + setSubmitError( + t["Something went wrong. Please check your internet connection and try again."] || + "Something went wrong. Please check your internet connection and try again." + ); setIsSubmitting(false); } }; return (
+ {submitError && ( + setSubmitError(null)} + /> + )}
) : activeSlide === maxSlideIndex ? ( -
- {submitError && ( -
- {submitError} -
- )} - -
+ ) : ( { ); await waitFor(() => { - expect(screen.getByText(/Tehran.*(Iran|ایران)/i)).toBeDefined(); + expect(screen.getByText(/(ایران|Iran).*Tehran/i)).toBeDefined(); }); }); @@ -280,4 +280,243 @@ describe("UI Config based behavior", () => { expect(document.body.classList.contains("dropdown-open")).toBe(false); }); }); + + it("should preserve country when typing city after selecting country", async () => { + const qBirthplace = { + id: "birthplace_select_first", + title: "محل تولد", + type: "birthplace", + order: 1, + required: true, + isVisible: true, + description: "", + tooltip: "", + extras: { placeHolder: "شهر، منطقه یا محله" }, + options: [], + ui_config: {}, + } as any; + + render( + + + + + , + ); + + // 1. Open country dropdown first + const countryButton = screen.getByText("انتخاب کشور"); + fireEvent.click(countryButton); + + // 2. Select Germany (آلمان) + const germanyOption = screen.getByRole("button", { name: "آلمان" }); + fireEvent.click(germanyOption); + + await waitFor(() => { + expect(screen.queryByRole("dialog")).toBeNull(); + }); + + // Country button should show Germany + expect(screen.getByText("آلمان")).toBeDefined(); + + // 3. Type city in the input field + const cityInput = screen.getByPlaceholderText("شهر، منطقه یا محله"); + fireEvent.change(cityInput, { target: { value: "برلین" } }); + + // Country button MUST still show Germany and city must show Berlin + expect(screen.getByText("آلمان")).toBeDefined(); + expect((cityInput as HTMLInputElement).value).toBe("برلین"); + + // 4. Type more characters into city field + fireEvent.change(cityInput, { target: { value: "برلین مرکزی" } }); + expect(screen.getByText("آلمان")).toBeDefined(); + expect((cityInput as HTMLInputElement).value).toBe("برلین مرکزی"); + + // 5. Change country to France (فرانسه) + const updatedCountryButton = screen.getByText("آلمان"); + fireEvent.click(updatedCountryButton); + + const franceOption = screen.getByRole("button", { name: "فرانسه" }); + fireEvent.click(franceOption); + + await waitFor(() => { + expect(screen.queryByRole("dialog")).toBeNull(); + }); + + expect(screen.getByText("فرانسه")).toBeDefined(); + expect((cityInput as HTMLInputElement).value).toBe("برلین مرکزی"); + }); + + it("should not duplicate or alter city text when focused and typing continuously", async () => { + const qBirthplace = { + id: "birthplace_typing_test", + title: "محل تولد", + type: "birthplace", + order: 1, + required: true, + isVisible: true, + description: "", + tooltip: "", + extras: { placeHolder: "شهر، منطقه یا محله" }, + options: [], + ui_config: {}, + } as any; + + render( + + + + + , + ); + + const cityInput = screen.getByPlaceholderText("شهر، منطقه یا محله"); + fireEvent.focus(cityInput); + + // Simulate character-by-character typing: ت -> ته -> تهر -> تهرا -> تهران + fireEvent.change(cityInput, { target: { value: "ت" } }); + expect((cityInput as HTMLInputElement).value).toBe("ت"); + + fireEvent.change(cityInput, { target: { value: "ته" } }); + expect((cityInput as HTMLInputElement).value).toBe("ته"); + + fireEvent.change(cityInput, { target: { value: "تهر" } }); + expect((cityInput as HTMLInputElement).value).toBe("تهر"); + + fireEvent.change(cityInput, { target: { value: "تهرا" } }); + expect((cityInput as HTMLInputElement).value).toBe("تهرا"); + + fireEvent.change(cityInput, { target: { value: "تهران" } }); + expect((cityInput as HTMLInputElement).value).toBe("تهران"); + + 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/data/countries.ts b/src/data/countries.ts index fa74570..234d22b 100644 --- a/src/data/countries.ts +++ b/src/data/countries.ts @@ -257,7 +257,7 @@ export const COUNTRIES_FA = [ "فنلاند", "فرانسه", "گابن", - "Gambia", + "گامبیا", "گرجستان", "آلمان", "غنا", @@ -448,3 +448,23 @@ export function resolveCountryName( return trimmed; } + +export function isKnownCountry(countryOrCode: string | undefined | null): boolean { + if (!countryOrCode) return false; + const trimmed = countryOrCode.trim(); + if (!trimmed) return false; + + if (trimmed.length === 2 && /^[a-zA-Z]{2}$/.test(trimmed)) { + return true; + } + + const lower = trimmed.toLowerCase(); + if (COUNTRIES_EN.some((c) => c.toLowerCase() === lower)) { + return true; + } + if (COUNTRIES_FA.some((c) => c.toLowerCase() === lower)) { + return true; + } + + return false; +} diff --git a/src/hooks/marriage/types.ts b/src/hooks/marriage/types.ts index 77d7e6f..a24f866 100644 --- a/src/hooks/marriage/types.ts +++ b/src/hooks/marriage/types.ts @@ -31,12 +31,18 @@ export type MarriagePhoneFieldValue = { phoneNumber: string; }; +export type MarriageBirthplaceFieldValue = { + country: string; + city: string; +}; + export type MarriageFieldValue = | string | string[] | number | boolean | MarriagePhoneFieldValue + | MarriageBirthplaceFieldValue | null; export type MarriageField = { diff --git a/src/lib/geo-region.test.ts b/src/lib/geo-region.test.ts new file mode 100644 index 0000000..4b0076a --- /dev/null +++ b/src/lib/geo-region.test.ts @@ -0,0 +1,149 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { + getUserGeoRegion, + getStoredUserGeoRegion, + setStoredUserGeoRegion, + resetUserGeoRegionForTesting, +} from "./geo-region"; + +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); + }); + + 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("resolves from fallback storage without making any HTTP calls when outside Flutter", async () => { + delete (window as any).HabibApp; + + 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 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; + return () => {}; + }); + + const mockPostMessage = vi.fn().mockImplementation(() => { + setTimeout(() => { + listenerCallback?.({ + action: "get_location", + success: false, + }); + }, 10); + }); + + window.HabibApp = { postMessage: mockPostMessage }; + + 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..7f3c110 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 = { @@ -43,7 +42,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; } @@ -63,7 +65,9 @@ export function setStoredUserGeoRegion(region: UserGeoRegion) { } } catch {} } - listeners.forEach((fn) => fn(region)); + listeners.forEach((fn) => { + fn(region); + }); } function resolvePhoneCodeFromCountryCode(isoCode?: string): string | undefined { @@ -79,10 +83,175 @@ function resolvePhoneCodeFromCountryCode(isoCode?: string): string | undefined { return undefined; } +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 strictly via Flutter bridge action protocol ('get_location'). + * No direct backend HTTP requests are made. + */ +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 local cached default", + ); + finish(getFallbackGeoRegion()); + } + } + }, + ); + } + + // 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) 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, using fallback region...", + ); + 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) { - const existing = getStoredUserGeoRegion(); - if (existing && (existing.city || existing.country)) { + 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 { @@ -94,143 +263,24 @@ export function getUserGeoRegion(force = false): Promise { return geoRegionPromise; } - 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"}`, - ); - - 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_AUTO_LOG] ❌ Backend Region Error:", - err?.response?.data || err?.message || err, - ); - } + // 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"); - // 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; - } - })(); + if (isFlutter) { + console.log( + "[GEO_BRIDGE_LOG] 📱 Inside Flutter WebView detected, requesting location via Flutter bridge action 'get_location'", + ); + geoRegionPromise = fetchFlutterBridgeGeoRegion(); + } else { + console.log( + "[GEO_BRIDGE_LOG] 🌐 Standard browser / non-Flutter environment: resolving from cached storage or default fallback (no HTTP calls)", + ); + const fallback = getFallbackGeoRegion(); + geoRegionPromise = Promise.resolve(fallback); + } return geoRegionPromise; } diff --git a/src/lib/marriage-field-formatter.ts b/src/lib/marriage-field-formatter.ts index 9a4ad25..34f1628 100644 --- a/src/lib/marriage-field-formatter.ts +++ b/src/lib/marriage-field-formatter.ts @@ -2,6 +2,7 @@ import type { MarriageField, MarriageFieldValue, MarriagePhoneFieldValue, + MarriageBirthplaceFieldValue, } from "@/hooks/marriage/types"; import { dictionaries } from "@/translations/dictionaries"; @@ -23,7 +24,7 @@ for (const enKey of Object.keys(dictionaries.en)) { export function isMarriagePhoneFieldValue( value: unknown, ): value is MarriagePhoneFieldValue { - if (!value || typeof value !== "object") { + if (!value || typeof value !== "object" || Array.isArray(value)) { return false; } @@ -35,6 +36,20 @@ export function isMarriagePhoneFieldValue( ); } +export function isMarriageBirthplaceFieldValue( + value: unknown, +): value is MarriageBirthplaceFieldValue { + if (!value || typeof value !== "object" || Array.isArray(value)) { + return false; + } + + const bpValue = value as Partial; + + return ( + typeof bpValue.country === "string" && typeof bpValue.city === "string" + ); +} + export function formatFieldValue(value: MarriageFieldValue): string | null { if (value === null || value === undefined || value === "") { return null; @@ -51,7 +66,7 @@ export function formatFieldValue(value: MarriageFieldValue): string | null { if (typeof value === "object") { if ("country" in value || "city" in value || "state" in value) { const v = value as { country?: string; state?: string; city?: string }; - const parts = [v.country, v.state, v.city] + const parts = [v.city, v.state, v.country] .map((p) => (typeof p === "string" ? p.trim() : "")) .filter(Boolean); return parts.join(", "); 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;