You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 
 
 

836 lines
29 KiB

"use client";
import { useCallback, useEffect, useRef, useState } from "react";
import { createPortal } from "react-dom";
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";
import QuestionTitle from "./question-title";
import { LoadingThreeDot } from "./loading-three-dot";
import { useSheetScrollLock } from "./use-sheet-scroll-lock";
import { Input } from "@/components/ui/input";
import {
getUserGeoRegion,
getStoredUserGeoRegion,
subscribeToUserGeoRegion,
} from "@/lib/geo-region";
import {
isInFlutterWebView,
requestAutoLocation,
pickManualLocation,
} from "@/lib/webview-actions";
const EXIT_ANIMATION_MS = 220;
type QuestionBirthplaceProps = {
question: QuestionField;
disabled?: boolean;
};
type BirthplaceValue = {
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: 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)",
"United Kingdom (UK)",
);
str = str.replace("United States (US, USA, America)", "United States (US)");
str = str.replace(
"United Arab Emirates (UAE, Dubai, Abu Dhabi)",
"United Arab Emirates (UAE)",
);
str = str.replace("Congo (Congo-Brazzaville)", "Congo");
str = str.replace("Czechia (Czech Republic)", "Czechia");
str = str.replace("Myanmar (formerly Burma)", "Myanmar");
str = str.replace(
"امارات متحده عربی (دبی، ابوظبی، UAE)",
"امارات متحده عربی (UAE)",
);
str = str.replace(
"بریتانیا (انگلستان، اسکاتلند، ولز، ایرلند شمالی، UK)",
"بریتانیا (UK)",
);
str = str.replace(
"ایالات متحده آمریکا (آمریکا، US، USA)",
"ایالات متحده آمریکا (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 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 idx = str.indexOf(" - ");
return splitLocation(str.slice(0, idx).trim(), str.slice(idx + 3).trim());
}
if (isKnownCountry(str)) {
return { country: str, city: "" };
}
return { country: "", city: str };
}
return { country: "", city: "" };
}
export function QuestionBirthplace({
question,
disabled,
}: QuestionBirthplaceProps) {
const { dictionary: t, locale } = useI18n();
const { getAnswerValue, setAnswerValue, isLoading } = useQuestionAnswers();
const rawValue = getAnswerValue(question);
const isResidence = question.ui_config?.enable_geoip === true;
const storedRegion = isResidence ? getStoredUserGeoRegion() : null;
const initial = parseValue(rawValue);
const hasSavedAnswer = Boolean(
initial.country?.trim() ||
initial.city?.trim() ||
(typeof rawValue === "string" && rawValue.trim().length > 0),
);
const [mode, setMode] = useState<"auto" | "manual">(() => {
if (typeof window !== "undefined") {
const stored = localStorage.getItem(`residence_mode_${question.id}`);
if (stored === "manual") return "manual";
if (stored === "auto") return "auto";
}
return "auto";
});
const isInitialManual = mode === "manual";
const localizedInitialCountry = hasSavedAnswer
? resolveCountryName(initial.country, locale) || initial.country
: "";
const initialCity = hasSavedAnswer ? initial.city || "" : "";
const initialLoc =
localizedInitialCountry || initialCity
? [localizedInitialCountry, initialCity].filter(Boolean).join(", ")
: "";
const [selectedCountry, setSelectedCountry] = useState(
() => localizedInitialCountry || "",
);
const [cityInput, setCityInput] = useState(() => initialCity);
const cityInputStateRef = useRef(initialCity);
const selectedCountryStateRef = useRef(localizedInitialCountry || "");
const lastCoordsRef = useRef<{ latitude?: number; longitude?: number } | undefined>(
storedRegion?.latitude && storedRegion?.longitude
? { latitude: storedRegion.latitude, longitude: storedRegion.longitude }
: undefined,
);
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 [isDetecting, setIsDetecting] = useState(false);
const [detectedLocation, setDetectedLocation] = useState(initialLoc);
useEffect(() => {
isMountedRef.current = true;
return () => {
isMountedRef.current = false;
if (debounceTimerRef.current) {
clearTimeout(debounceTimerRef.current);
}
};
}, []);
const closeSheet = useCallback(() => {
if (isClosing) return;
setIsClosing(true);
window.setTimeout(() => {
if (isMountedRef.current) {
setIsOpen(false);
setIsClosing(false);
setSearchQuery("");
}
}, EXIT_ANIMATION_MS);
}, [isClosing]);
const openSheet = useCallback(() => {
if (disabled) return;
setIsOpen(true);
setIsClosing(false);
}, [disabled]);
useSheetScrollLock(isOpen, { onBack: closeSheet });
// Handle escape key
useEffect(() => {
if (!isOpen) return;
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === "Escape") {
closeSheet();
}
};
window.addEventListener("keydown", handleKeyDown);
return () => window.removeEventListener("keydown", handleKeyDown);
}, [isOpen, closeSheet]);
const lastInternalAnswerRef = useRef<BirthplaceValue | string | null>(null);
const updateAnswers = useCallback(
(country: string, city: string) => {
const cleanCountry = country?.trim() || "";
const cleanCity = city?.trim() || "";
const payload =
cleanCountry || cleanCity
? { country: cleanCountry, city: cleanCity }
: null;
lastInternalAnswerRef.current = payload;
setAnswerValue(question, payload);
},
[question, setAnswerValue],
);
const handleAutoClick = async () => {
if (typeof window !== "undefined") {
localStorage.setItem(`residence_mode_${question.id}`, "auto");
}
setMode("auto");
setIsDetecting(true);
try {
if (isInFlutterWebView()) {
const data = await requestAutoLocation();
if (!isMountedRef.current) return;
lastCoordsRef.current = {
latitude: data.latitude,
longitude: data.longitude,
};
const rawCountry = data.country || data.country_code || "";
const country =
resolveCountryName(rawCountry, locale) || rawCountry || "";
const city = data.city || "";
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);
}
} else {
const region = await getUserGeoRegion(true);
if (!isMountedRef.current) return;
const city = region.city || "";
const rawCountry = region.country || region.countryCode || "";
const country =
resolveCountryName(rawCountry, locale) || rawCountry || "";
if (region.latitude && region.longitude) {
lastCoordsRef.current = {
latitude: region.latitude,
longitude: region.longitude,
};
}
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);
}
}
} catch (err) {
console.warn("Auto location error:", err);
} finally {
if (isMountedRef.current) {
setIsDetecting(false);
}
}
};
const handleManualClick = async () => {
if (typeof window !== "undefined") {
localStorage.setItem(`residence_mode_${question.id}`, "manual");
}
setMode("manual");
if (isInFlutterWebView()) {
try {
const data = await pickManualLocation(lastCoordsRef.current);
if (data && isMountedRef.current) {
lastCoordsRef.current = {
latitude: data.latitude,
longitude: data.longitude,
};
const rawCountry = data.country || data.country_code || "";
const country =
resolveCountryName(rawCountry, locale) || rawCountry || "";
const city = data.city || "";
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);
}
}
} catch (err) {
console.warn("Manual map pick error:", err);
}
}
};
// 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) || updated.country;
if (resolvedC && resolvedC !== selectedCountry) {
setSelectedCountry(resolvedC);
selectedCountryStateRef.current = resolvedC;
}
if (updated.city !== cityInput) {
setCityInput(updated.city);
cityInputStateRef.current = updated.city;
}
if (resolvedC || updated.city) {
setDetectedLocation([resolvedC, updated.city].filter(Boolean).join(", "));
}
lastInternalAnswerRef.current = rawValue as BirthplaceValue | string | null;
}, [rawValue, locale, selectedCountry, cityInput]);
const options = getCountryList(locale);
const filteredOptions = options.filter((option) =>
option.toLowerCase().includes(searchQuery.toLowerCase()),
);
const handleSelectCountry = (country: string) => {
if (typeof window !== "undefined") {
localStorage.setItem(`residence_mode_${question.id}`, "manual");
}
setSelectedCountry(country);
selectedCountryStateRef.current = country;
closeSheet();
updateAnswers(country, cityInputStateRef.current);
setDetectedLocation(
[country, cityInputStateRef.current].filter(Boolean).join(", "),
);
window.setTimeout(() => {
cityInputRef.current?.focus({ preventScroll: true });
}, EXIT_ANIMATION_MS);
};
const handleCityChange = (e: React.ChangeEvent<HTMLInputElement>) => {
if (typeof window !== "undefined") {
localStorage.setItem(`residence_mode_${question.id}`, "manual");
}
const newCity = e.target.value;
cityInputStateRef.current = newCity;
setCityInput(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";
const selectCountryPlaceholder = isRtl ? "انتخاب کشور" : "Select country";
const cityPlaceholder =
question.extras?.placeHolder ||
(isRtl ? "شهر، منطقه یا محله" : "City, region, or neighborhood");
const searchPlaceholder =
locale === "fa"
? "جستجو..."
: locale === "ar"
? "بحث..."
: locale === "tr"
? "Ara..."
: "Search...";
const noResultsText =
locale === "fa"
? "موردی یافت نشد"
: locale === "ar"
? "لم يتم العثور على نتائج"
: "No options found";
return (
<div
className={[
"relative flex w-full flex-col gap-3 transition-opacity duration-200",
disabled ? "pointer-events-none opacity-30" : "",
].join(" ")}
>
<QuestionTitle question={question} />
{isResidence ? (
<>
{/* GeoIP Auto/Manual selector UI */}
<div className="flex flex-col gap-3.5 w-full py-1">
{/* Left Side: Location Info */}
<div className="flex items-center gap-2.5 min-w-0">
<svg
width="16"
height="20"
viewBox="0 0 16 20"
fill="none"
className="shrink-0 text-[#181818]"
>
<path
d="M8 0C3.58 0 0 3.58 0 8C0 13.54 8 20 8 20C8 20 16 13.54 16 8C16 3.58 12.42 0 8 0ZM8 11C6.34 11 5 9.66 5 8C5 6.34 6.34 5 8 5C9.66 5 11 6.34 11 8C11 9.66 9.66 11 8 11Z"
fill="currentColor"
/>
</svg>
<span className="text-[16px] font-bold text-[#181818]">
{isDetecting
? locale === "fa"
? "در حال شناسایی..."
: "Detecting..."
: detectedLocation ||
(locale === "fa" ? "نامشخص" : "Unknown")}
</span>
</div>
{/* Right Side: Toggle Buttons */}
<div className="flex items-center gap-2 w-full">
{/* Auto Button */}
<button
type="button"
onClick={handleAutoClick}
disabled={isDetecting}
className={[
"flex flex-1 items-center justify-center gap-1.5 h-[46px] rounded-xl text-[14px] font-bold cursor-pointer transition-all shadow-sm",
mode === "auto"
? "bg-[#F2465F] text-white"
: "bg-white border border-[#D0D5DD] text-[#344054] hover:bg-gray-50",
].join(" ")}
>
{isDetecting ? (
<LoadingThreeDot />
) : (
<>
<svg
width="16"
height="16"
viewBox="0 0 16 16"
fill="none"
className="shrink-0"
>
<circle
cx="8"
cy="8"
r="6"
stroke="currentColor"
strokeWidth="2"
/>
<circle cx="8" cy="8" r="2" fill="currentColor" />
<path
d="M8 0V3M8 13V16M0 8H3M13 8H16"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
/>
</svg>
<span>{locale === "fa" ? "خودکار" : "Auto"}</span>
</>
)}
</button>
{/* Manual Button */}
<button
type="button"
onClick={handleManualClick}
className={[
"flex flex-1 items-center justify-center gap-1.5 h-[46px] rounded-xl text-[14px] font-bold cursor-pointer transition-all shadow-sm",
mode === "manual"
? "bg-[#F2465F] text-white"
: "bg-white border border-[#D0D5DD] text-[#344054] hover:bg-gray-50",
].join(" ")}
>
<svg
width="16"
height="16"
viewBox="0 0 16 16"
fill="none"
className="shrink-0"
>
<path
d="M1 3.5L5 1.5L11 4.5L15 2.5V12.5L11 14.5L5 11.5L1 13.5V3.5Z"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
/>
<path
d="M5 1.5V11.5M11 4.5V14.5"
stroke="currentColor"
strokeWidth="2"
/>
</svg>
<span>{locale === "fa" ? "دستی" : "Manual"}</span>
</button>
</div>
</div>
</>
) : (
<>
{/* 1. Country Selection Trigger */}
<div className="relative w-full">
<button
type="button"
disabled={disabled}
onClick={openSheet}
className={[
"flex h-[54px] w-full items-center justify-between rounded-[16px] border bg-white px-4.5 text-start transition-all cursor-pointer outline-none",
isOpen
? "border-[#6F6F6F] ring-1 ring-[#6F6F6F]"
: "border-[#D0D5DD] hover:border-[#98A2B3]",
].join(" ")}
>
<span
className={[
"text-[15px] font-medium truncate",
selectedCountry ? "text-[#181818]" : "text-[#667085]",
].join(" ")}
>
{selectedCountry || selectCountryPlaceholder}
</span>
<svg
width="16"
height="10"
viewBox="0 0 16 10"
fill="none"
className={[
"shrink-0 transition-transform duration-200",
isOpen ? "rotate-180" : "",
].join(" ")}
>
<path
d="M14.75 1.25L7.75 8.25L0.75 1.25"
stroke="#344054"
strokeWidth="1.75"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
</button>
</div>
{/* 2. City Text Input */}
<div className="w-full">
<input
ref={cityInputRef}
type="text"
data-no-auto-focus
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"
/>
</div>
</>
)}
{/* Country Selection Bottom Sheet Modal */}
{isOpen &&
createPortal(
<div
className={[
"fixed inset-0 z-50 flex items-end justify-center transition-all duration-[220ms] animate-in fade-in",
isClosing ? "bg-black/0 opacity-0" : "bg-black/55 opacity-100",
].join(" ")}
role="dialog"
aria-modal="true"
aria-label={selectCountryPlaceholder}
onWheel={(event) => event.stopPropagation()}
onTouchStart={(event) => event.stopPropagation()}
onTouchMove={(event) => event.stopPropagation()}
onTouchEnd={(event) => event.stopPropagation()}
onClick={(e) => {
if (e.target === e.currentTarget) {
closeSheet();
}
}}
>
<section
className={[
"flex h-[82svh] min-h-[82svh] max-h-[82svh] w-full flex-col overflow-hidden rounded-t-[22px] bg-white pb-[env(safe-area-inset-bottom)] shadow-[0_-10px_32px_rgba(0,0,0,0.12)] transition-transform duration-[220ms] ease-out will-change-transform sm:max-w-[375px] animate-in slide-in-from-bottom",
isClosing ? "translate-y-full" : "translate-y-0",
].join(" ")}
onClick={(e) => e.stopPropagation()}
>
{/* Drag Handle Notch */}
<div className="flex w-full justify-center pt-3 pb-1 cursor-grab">
<div className="h-1.25 w-10 rounded-full bg-[#D0D5DD]" />
</div>
{/* Header with Title and Close Button */}
<div className="flex items-center justify-between px-5 pt-2 pb-3 border-b border-[#F2F4F7]">
<h3 className="text-[17px] font-bold text-[#181818] truncate pr-2">
{selectCountryPlaceholder}
</h3>
<button
type="button"
onClick={closeSheet}
className="flex size-8 shrink-0 items-center justify-center rounded-full text-[#667085] hover:bg-[#F2F4F7] hover:text-[#181818] transition-colors cursor-pointer"
aria-label="Close"
>
<svg
width="18"
height="18"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2.2"
strokeLinecap="round"
strokeLinejoin="round"
>
<line x1="18" y1="6" x2="6" y2="18" />
<line x1="6" y1="6" x2="18" y2="18" />
</svg>
</button>
</div>
{/* Search Bar */}
<div className="px-5 pt-3.5 pb-2">
<div className="flex h-[46px] w-full items-center gap-2.5 rounded-[14px] bg-[#F2F4F7] px-3.5 transition-colors focus-within:bg-[#EAECF0]">
<svg
aria-hidden="true"
width="18"
height="18"
viewBox="0 0 18 18"
fill="none"
className="shrink-0 text-[#667085]"
>
<path
d="M8.25 14.25C11.5637 14.25 14.25 11.5637 14.25 8.25C14.25 4.93629 11.5637 2.25 8.25 2.25C4.93629 2.25 2.25 4.93629 2.25 8.25C2.25 11.5637 4.93629 14.25 8.25 14.25Z"
stroke="#667085"
strokeWidth="1.75"
strokeLinecap="round"
strokeLinejoin="round"
/>
<path
d="M15.75 15.75L12.5 12.5"
stroke="#667085"
strokeWidth="1.75"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
<input
type="text"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
placeholder={searchPlaceholder}
className="flex-1 bg-transparent text-[14px] font-medium text-[#181818] outline-none placeholder:text-[#98A2B3]"
/>
{searchQuery ? (
<button
type="button"
onClick={() => setSearchQuery("")}
className="text-[#667085] hover:text-[#181818] text-xs font-semibold p-1 cursor-pointer"
>
</button>
) : null}
</div>
</div>
{/* Country Options List */}
<div
ref={listRef}
className="flex min-h-0 flex-1 flex-col gap-2 overflow-y-auto overscroll-contain px-5 py-3"
>
{filteredOptions.length > 0 ? (
filteredOptions.map((option) => {
const isSelected = selectedCountry === option;
return (
<button
key={option}
type="button"
onClick={() => handleSelectCountry(option)}
className={[
"flex w-full items-center gap-3.5 rounded-[16px] p-3.5 text-start transition-all cursor-pointer border",
isSelected
? "bg-[#FFF4F5] border-[#F0445B]/30 text-[#181818] shadow-xs"
: "bg-[#FAFAFA] hover:bg-[#F2F4F7] border-transparent text-[#181818]",
].join(" ")}
>
<div
className={[
"size-[22px] shrink-0 rounded-full transition-all duration-150 flex items-center justify-center",
isSelected
? "border-[6px] border-[#F0445B] bg-white"
: "border-[2px] border-[#98A2B3] bg-white",
].join(" ")}
/>
<span
className={
isSelected
? "font-bold text-[#181818] text-[15px]"
: "font-semibold text-[#344054] text-[15px]"
}
>
{option}
</span>
</button>
);
})
) : (
<div className="py-8 text-center text-[14px] text-[#667085]">
{noResultsText}
</div>
)}
</div>
</section>
</div>,
document.body,
)}
</div>
);
}
export default QuestionBirthplace;