Browse Source

Merge branch 'master' into Dev

Dev
parent
commit
747495262e
  1. 7
      src/app/questions-list/[slug]/question-detail-client.tsx
  2. 60
      src/components/Componentes/error-toast.tsx
  3. 39
      src/components/Componentes/question-answer-storage.tsx
  4. 93
      src/components/Componentes/question-birthplace.test.ts
  5. 345
      src/components/Componentes/question-birthplace.tsx
  6. 16
      src/components/Componentes/question-number.tsx
  7. 169
      src/components/Componentes/question-phone.test.tsx
  8. 47
      src/components/Componentes/question-section-flow.tsx
  9. 76
      src/components/Componentes/question-sheet.tsx
  10. 4
      src/components/Componentes/slider-page.test.tsx
  11. 29
      src/components/Componentes/slider-page.tsx
  12. 241
      src/components/Componentes/ui-config.test.tsx
  13. 22
      src/data/countries.ts
  14. 6
      src/hooks/marriage/types.ts
  15. 149
      src/lib/geo-region.test.ts
  16. 332
      src/lib/geo-region.ts
  17. 19
      src/lib/marriage-field-formatter.ts
  18. 4
      src/types/window.d.ts

7
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;
}

60
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 (
<div
className={[
"fixed top-4 left-1/2 z-50 flex w-[calc(100%-34px)] max-w-[341px] -translate-x-1/2 items-center gap-3 rounded-xl border p-4 shadow-lg backdrop-blur-md transition-all duration-300",
"fixed top-3.5 left-1/2 z-50 flex w-[calc(100%-32px)] max-w-[360px] -translate-x-1/2 items-start justify-between gap-3 rounded-[16px] bg-[#141414]/95 p-3.5 px-4 text-white shadow-[0_10px_30px_rgba(0,0,0,0.35)] backdrop-blur-md border border-white/5 border-t-[2.5px] transition-all duration-300 ease-out",
getBorderColor(),
isVisible
? "translate-y-0 opacity-100 scale-100"
: "-translate-y-4 opacity-0 scale-95",
isSuccess
? "bg-green-50/95 dark:bg-green-950/95 text-green-800 dark:text-green-200 border-green-100/80 dark:border-green-900/50"
: "bg-red-50/95 dark:bg-red-950/95 text-red-800 dark:text-red-200 border-red-100/80 dark:border-red-900/50",
].join(" ")}
role="alert"
>
{isSuccess ? (
<IoCheckmarkCircle className="size-5 shrink-0 text-green-500" />
) : (
<IoAlertCircle className="size-5 shrink-0 text-red-500" />
)}
<span className="flex-1 text-sm font-semibold leading-normal">
{message}
</span>
<div className="flex flex-1 flex-col text-start min-w-0">
{title && (
<span className="text-[15px] font-bold text-white leading-tight truncate mb-1">
{title}
</span>
)}
<span
className={[
"text-[#E0E0E0] leading-snug break-words",
title ? "text-[13px] text-white/80" : "text-[14px] font-medium text-white",
].join(" ")}
>
{message}
</span>
</div>
<button
type="button"
onClick={handleClose}
className={[
"flex size-6 items-center justify-center rounded-lg transition-colors",
isSuccess
? "text-green-500 hover:bg-green-100/50 dark:hover:bg-green-900/50"
: "text-red-500 hover:bg-red-100/50 dark:hover:bg-red-900/50",
].join(" ")}
className="flex size-7 shrink-0 items-center justify-center rounded-lg text-white/70 hover:text-white transition-colors cursor-pointer self-start -me-1 -mt-0.5"
aria-label="Close toast"
>
<IoClose className="size-4" />
<IoClose className="size-5" />
</button>
</div>
);
}

39
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<MarriagePhoneFieldValue>;
if (
typeof phone.countryCode === "string" ||
typeof phone.phoneNumber === "string"
) {
return Boolean(phone.countryCode?.trim() || phone.phoneNumber?.trim());
}
const bp = value as Partial<MarriageBirthplaceFieldValue>;
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<MarriageBirthplaceFieldValue>;
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;
}

93
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",
});
});
});

345
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<HTMLInputElement>(null);
const listRef = useRef<HTMLDivElement>(null);
const isMountedRef = useRef(true);
const isFocusedRef = useRef(false);
const debounceTimerRef = useRef<NodeJS.Timeout | null>(null);
const [mode, setMode] = useState<"auto" | "manual">("auto");
const [isDetecting, setIsDetecting] = useState(false);
const [detectedLocation, setDetectedLocation] = useState(initialLoc);
const hasAutoDetectedRef = useRef(false);
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<BirthplaceValue | string | null>(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<HTMLInputElement>) => {
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}
/>
</div>
@ -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"
/>

16
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

169
src/components/Componentes/question-phone.test.tsx

@ -9,20 +9,13 @@ import {
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { QuestionField } from "@/lib/schema-adapter";
import { QuestionPhone, resetGeoPhoneStateForTesting } from "./question-phone";
import { setStoredUserGeoRegion } from "@/lib/geo-region";
let answerMap: Record<string, unknown> = {};
const mockSetAnswerValue = vi.fn((q: QuestionField, val: unknown) => {
answerMap[q.id] = val;
});
const httpMocks = vi.hoisted(() => ({
get: vi.fn(),
}));
vi.mock("@/lib/http", () => ({
http: { get: httpMocks.get },
}));
vi.mock("@/translations/provider", () => ({
useI18n: () => ({
locale: "en",
@ -73,27 +66,35 @@ const phoneQuestion2: QuestionField = {
};
describe("QuestionPhone IP country detection and shimmer", () => {
let flutterListeners: Array<(event: any) => void> = [];
beforeEach(() => {
answerMap = {};
mockSetAnswerValue.mockClear();
localStorage.clear();
resetGeoPhoneStateForTesting();
vi.restoreAllMocks();
httpMocks.get.mockReset();
flutterListeners = [];
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(<QuestionPhone question={phoneQuestion1} />);
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(<QuestionPhone question={phoneQuestion1} />);
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(
<QuestionPhone question={phoneQuestion1} countryCode="+44" />,
@ -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(
<>
<QuestionPhone question={phoneQuestion1} />
@ -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(<QuestionPhone question={phoneQuestion1} countryCode="+44" />);
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(<QuestionPhone question={phoneQuestion1} countryCode="+98" />);
@ -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(<QuestionPhone question={phoneQuestion1} countryCode="+44" />);
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(
<QuestionPhone question={phoneQuestion1} countryCode="+98" />,

47
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<string | null>(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" ? (
<DevTapInstrumentation />
) : null}
{errorMessage && (
<ErrorToast
message={errorMessage}
onClose={() => setErrorMessage(null)}
duration={5000}
variant="error"
/>
)}
<FixToTheEnd>
<Button
disabled={!isCompleted || isSubmitting}

76
src/components/Componentes/question-sheet.tsx

@ -14,6 +14,7 @@ import { registerCompactQuestionSheet } from "./question-viewport-coordinator";
import { useSheetScrollLock } from "./use-sheet-scroll-lock";
const EXIT_ANIMATION_MS = 300;
const EMPTY_ARRAY: string[] = [];
export type QuestionSheetProps = {
question: QuestionField;
@ -30,11 +31,11 @@ export function QuestionSheet({ question, disabled }: QuestionSheetProps) {
question.type === "checkbox" ||
(question.extras?.range && question.extras.range[1] > 1);
const selectedList = Array.isArray(rawValue)
? rawValue
: typeof rawValue === "string" && rawValue
? [rawValue]
: [];
const selectedList = useMemo(() => {
if (Array.isArray(rawValue)) return rawValue;
if (typeof rawValue === "string" && rawValue) return [rawValue];
return EMPTY_ARRAY;
}, [rawValue]);
const singleValue = typeof rawValue === "string" ? rawValue : "";
const [isOpen, setIsOpen] = useState(false);
@ -42,22 +43,45 @@ export function QuestionSheet({ question, disabled }: QuestionSheetProps) {
const [searchQuery, setSearchQuery] = useState("");
const listRef = useRef<HTMLDivElement>(null);
const sheetRef = useRef<HTMLElement>(null);
const [localSelectedList, setLocalSelectedList] = useState<string[]>(selectedList);
const localSelectedListRef = useRef<string[]>(selectedList);
useEffect(() => {
localSelectedListRef.current = localSelectedList;
}, [localSelectedList]);
useEffect(() => {
if (!isOpen) {
setLocalSelectedList(selectedList);
localSelectedListRef.current = selectedList;
}
}, [isOpen, selectedList]);
const closeSheet = useCallback(() => {
setIsClosing(true);
if (isMulti) {
const currentList = localSelectedListRef.current;
setAnswerValue(
question,
currentList.length > 0 ? currentList : null,
);
}
window.setTimeout(() => {
setIsOpen(false);
setIsClosing(false);
setSearchQuery("");
}, EXIT_ANIMATION_MS);
}, []);
}, [isMulti, question, setAnswerValue]);
const openSheet = useCallback(() => {
if (disabled) return;
setLocalSelectedList(selectedList);
localSelectedListRef.current = selectedList;
setIsOpen(true);
setIsClosing(false);
}, [disabled]);
}, [disabled, selectedList]);
useSheetScrollLock(isOpen, { onBack: closeSheet });
useSheetScrollLock(isOpen && !isClosing, { onBack: closeSheet });
// Handle escape key
useEffect(() => {
@ -275,13 +299,25 @@ export function QuestionSheet({ question, disabled }: QuestionSheetProps) {
: Boolean(singleValue);
const toggleMultiOption = (optionId: string) => {
let nextValue: string[];
if (selectedList.includes(optionId)) {
nextValue = selectedList.filter((v) => v !== optionId);
} else {
nextValue = [...selectedList, optionId];
}
setAnswerValue(question, nextValue.length > 0 ? nextValue : null);
setLocalSelectedList((prev) => {
let nextValue: string[];
if (prev.includes(optionId)) {
nextValue = prev.filter((v) => v !== optionId);
} else {
nextValue = [...prev, optionId];
}
localSelectedListRef.current = nextValue;
return nextValue;
});
};
const handleConfirmMulti = () => {
const currentList = localSelectedListRef.current;
setAnswerValue(
question,
currentList.length > 0 ? currentList : null,
);
closeSheet();
};
const handleSelectSingle = (optionId: string) => {
@ -476,7 +512,7 @@ export function QuestionSheet({ question, disabled }: QuestionSheetProps) {
{filteredOptions.length > 0 ? (
filteredOptions.map((option) => {
const isSelected = isMulti
? selectedList.includes(option.id)
? localSelectedList.includes(option.id)
: singleValue === option.id;
return (
@ -581,11 +617,11 @@ export function QuestionSheet({ question, disabled }: QuestionSheetProps) {
<Button
type="button"
variant="default"
onClick={closeSheet}
className="w-full h-[50px] rounded-[14px] text-[15px] font-bold shadow-[0_8px_20px_rgba(240,68,91,0.25)]"
onClick={handleConfirmMulti}
className="w-full h-[50px] rounded-[14px] text-[15px] font-bold shadow-[0_8px_20px_rgba(240,68,91,0.25)] cursor-pointer"
>
{selectedList.length > 0
? `${confirmText} (${selectedList.length})`
{localSelectedList.length > 0
? `${confirmText} (${localSelectedList.length})`
: confirmText}
</Button>
</div>

4
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();

29
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 (
<div className="relative flex h-[100dvh] max-h-[100dvh] flex-col overflow-hidden">
{submitError && (
<ErrorToast
message={submitError}
onClose={() => setSubmitError(null)}
/>
)}
<header className="relative -mx-[17px] rounded-b-[32px] px-[17px] pt-[max(12px,calc(var(--safe-top)+4px))] pb-3">
<div className="relative flex items-center justify-between">
<NavigationButton
@ -211,18 +221,11 @@ export default function SliderPage({ onClose }: SliderPageProps = {}) {
disabled={!hasReadRules}
/>
) : activeSlide === maxSlideIndex ? (
<div className="flex flex-col gap-3">
{submitError && (
<div role="alert" aria-live="assertive" className="text-sm text-red-500 bg-red-50 p-3 rounded-xl border border-red-200">
{submitError}
</div>
)}
<SliderFinalActions
onBack={goToPreviousSlide}
isFinishing={isSubmitting}
onFinish={completeSlider}
/>
</div>
<SliderFinalActions
onBack={goToPreviousSlide}
isFinishing={isSubmitting}
onFinish={completeSlider}
/>
) : (
<SliderStepActions
onBack={goToPreviousSlide}

241
src/components/Componentes/ui-config.test.tsx

@ -117,7 +117,7 @@ describe("UI Config based behavior", () => {
);
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(
<QueryClientProvider client={queryClient}>
<QuestionAnswersProvider slug="test-select-first" questions={[qBirthplace]}>
<QuestionBirthplace question={qBirthplace} />
</QuestionAnswersProvider>
</QueryClientProvider>,
);
// 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(
<QueryClientProvider client={queryClient}>
<QuestionAnswersProvider slug="test-typing" questions={[qBirthplace]}>
<QuestionBirthplace question={qBirthplace} />
</QuestionAnswersProvider>
</QueryClientProvider>,
);
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(
<QueryClientProvider client={queryClient}>
<QuestionAnswersProvider slug="test-residence-manual" questions={[qResidence]}>
<QuestionBirthplace question={qResidence} />
</QuestionAnswersProvider>
</QueryClientProvider>,
);
// Switch to manual mode
const manualButton = screen.getByRole("button", { name: /دستی|Manual/i });
fireEvent.click(manualButton);
const cityInput = screen.getByPlaceholderText("شهر، منطقه یا محله");
fireEvent.focus(cityInput);
// Type "اصفهان"
fireEvent.change(cityInput, { target: { value: "اصفهان" } });
expect((cityInput as HTMLInputElement).value).toBe("اصفهان");
// Clear the input completely
fireEvent.change(cityInput, { target: { value: "" } });
expect((cityInput as HTMLInputElement).value).toBe("");
fireEvent.blur(cityInput);
expect((cityInput as HTMLInputElement).value).toBe("");
// Type again "شیراز"
fireEvent.focus(cityInput);
fireEvent.change(cityInput, { target: { value: "شیراز" } });
expect((cityInput as HTMLInputElement).value).toBe("شیراز");
fireEvent.blur(cityInput);
expect((cityInput as HTMLInputElement).value).toBe("شیراز");
});
it("should preserve manual mode and custom entered city upon section re-entry", async () => {
const qResidence = {
id: "residence_persistence_test",
title: "محل سکونت فعلی",
type: "birthplace",
order: 1,
required: true,
isVisible: true,
description: "",
tooltip: "",
extras: { placeHolder: "شهر، منطقه یا محله" },
options: [],
ui_config: { enable_geoip: true },
} as any;
const { unmount } = render(
<QueryClientProvider client={queryClient}>
<QuestionAnswersProvider slug="test-persistence" questions={[qResidence]}>
<QuestionBirthplace question={qResidence} />
</QuestionAnswersProvider>
</QueryClientProvider>,
);
// Click Manual
const manualBtn = screen.getByRole("button", { name: /دستی|Manual/i });
fireEvent.click(manualBtn);
const cityInput = screen.getByPlaceholderText("شهر، منطقه یا محله");
fireEvent.change(cityInput, { target: { value: "یزد" } });
fireEvent.blur(cityInput);
expect((cityInput as HTMLInputElement).value).toBe("یزد");
// Simulate leaving the section (unmount)
unmount();
const { marriageQueryKeys } = await import("@/hooks/marriage/query-keys");
queryClient.setQueryData(
marriageQueryKeys.formSection("profile", "test-persistence", "fa"),
{
section: {
id: "sec1",
slug: "test-persistence",
title: "Sec",
cards: [{ id: "c1", title: "Card", questions: [qResidence] }],
},
answers: {
residence_persistence_test: {
value: { country: "Iran", city: "یزد" },
},
},
},
);
// Simulate re-entering the section
render(
<QueryClientProvider client={queryClient}>
<QuestionAnswersProvider
slug="test-persistence"
locale="fa"
questions={[qResidence]}
>
<QuestionBirthplace question={qResidence} />
</QuestionAnswersProvider>
</QueryClientProvider>,
);
// Verify it stays in manual mode with "یزد"
await waitFor(() => {
const reenteredCityInput = screen.getByPlaceholderText("شهر، منطقه یا محله");
expect((reenteredCityInput as HTMLInputElement).value).toBe("یزد");
});
});
});

22
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;
}

6
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 = {

149
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");
});
});
});

332
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<UserGeoRegion> {
return new Promise<UserGeoRegion>((resolve) => {
let resolved = false;
let unsubscribe: (() => void) | undefined;
let timer: ReturnType<typeof setTimeout> | 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<UserGeoRegion> {
// 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<UserGeoRegion> {
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;
}

19
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<MarriageBirthplaceFieldValue>;
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(", ");

4
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;

Loading…
Cancel
Save