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"
+ />
+ )}