"use client"; import { PhoneNumberFormat, PhoneNumberUtil } from "google-libphonenumber"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { createPortal } from "react-dom"; import type { MarriagePhoneFieldValue } from "@/hooks/marriage/types"; import type { QuestionField } from "@/lib/schema-adapter"; import { useI18n } from "@/translations/provider"; import { useQuestionAnswers } from "./question-answer-storage"; import QuestionTitle from "./question-title"; import { useSheetScrollLock } from "./use-sheet-scroll-lock"; const EXIT_ANIMATION_MS = 300; type QuestionPhoneProps = { question: QuestionField; countryCode?: string; disabled?: boolean; }; type PhoneValueParts = { codeValue: string; phoneValue: string; }; const phoneUtil = PhoneNumberUtil.getInstance(); function isMarriagePhoneFieldValue( value: unknown, ): value is MarriagePhoneFieldValue { if (!value || typeof value !== "object") { return false; } const phoneValue = value as Partial; return ( typeof phoneValue.countryCode === "string" && typeof phoneValue.phoneNumber === "string" ); } function readPhoneValue(value: unknown, fallbackCode: string): PhoneValueParts { if (value === null) { return { codeValue: fallbackCode, phoneValue: "", }; } if (isMarriagePhoneFieldValue(value)) { return { codeValue: value.countryCode ? `+${value.countryCode.replace(/^\+/, "")}` : fallbackCode, phoneValue: value.phoneNumber, }; } if (typeof value !== "string") { return { codeValue: fallbackCode, phoneValue: "", }; } if (value.length === 0) { return { codeValue: fallbackCode, phoneValue: "", }; } const separatorIndex = value.indexOf(" "); if (separatorIndex >= 0) { return { codeValue: value.slice(0, separatorIndex), phoneValue: value.slice(separatorIndex + 1), }; } if (value.startsWith("+")) { try { const parsedNumber = phoneUtil.parse(value); const countryCode = parsedNumber.getCountryCode(); const nationalNumber = String(parsedNumber.getNationalNumber()); return { codeValue: countryCode ? `+${countryCode}` : fallbackCode, phoneValue: nationalNumber, }; } catch { return { codeValue: fallbackCode, phoneValue: value, }; } } return { codeValue: fallbackCode, phoneValue: value, }; } function getMaxLengthForCountry(codeValue: string): number { try { const cleanCode = codeValue.replace(/[^\d]/g, ""); if (!cleanCode) return 15; const countryCode = Number(cleanCode); const regionCode = phoneUtil.getRegionCodeForCountryCode(countryCode); if (!regionCode || regionCode === "ZZ") return 15; const exampleMobile = phoneUtil.getExampleNumberForType( regionCode, 1, // MOBILE ); const exampleGeneral = phoneUtil.getExampleNumber(regionCode); const lenMobile = exampleMobile ? String(exampleMobile.getNationalNumber()).length : 0; const lenGeneral = exampleGeneral ? String(exampleGeneral.getNationalNumber()).length : 0; const baseLen = Math.max(lenMobile, lenGeneral, 8); return baseLen + 1; } catch { return 15; } } function writePhoneValue(codeValue: string, phoneValue: string) { if (!codeValue && !phoneValue) { return null; } if (codeValue && phoneValue) { return `${codeValue} ${phoneValue}`; } if (codeValue) { return codeValue.startsWith("+") ? codeValue : `${codeValue} `; } return phoneValue; } function toStoredPhoneValue( codeValue: string, phoneValue: string, ): MarriagePhoneFieldValue | null { const normalizedCountryCode = sanitizeCountryCode(codeValue).replace( /^\+/, "", ); const normalizedPhoneNumber = phoneValue.trim().replace(/\s+/g, ""); if (!normalizedCountryCode && !normalizedPhoneNumber) { return null; } return { countryCode: normalizedCountryCode, phoneNumber: normalizedPhoneNumber, }; } function sanitizeCountryCode(value: string) { const sanitized = value.replace(/[^\d+]/g, ""); if (sanitized.length === 0) { return ""; } return sanitized.startsWith("+") ? `+${sanitized.slice(1).replace(/\+/g, "")}` : `+${sanitized.replace(/\+/g, "")}`; } function sanitizePhoneNumber(value: string) { return value.replace(/[^\d\s\-().]/g, ""); } function getNormalizedPhoneValue(codeValue: string, phoneValue: string) { const nextCodeValue = sanitizeCountryCode(codeValue); const nextPhoneValue = phoneValue.trim(); if (nextCodeValue.length === 0 && nextPhoneValue.length === 0) { return { isValid: !nextPhoneValue.length, normalizedValue: null, }; } if (nextCodeValue.length === 0 || nextPhoneValue.length === 0) { return { isValid: false, normalizedValue: null, }; } try { const parsedNumber = phoneUtil.parse(`${nextCodeValue} ${nextPhoneValue}`); if (!phoneUtil.isValidNumber(parsedNumber)) { return { isValid: false, normalizedValue: null, }; } return { isValid: true, normalizedValue: phoneUtil.format(parsedNumber, PhoneNumberFormat.E164), }; } catch { return { isValid: false, normalizedValue: null, }; } } export function QuestionPhone({ question, countryCode = "+44", disabled, }: QuestionPhoneProps) { const { dictionary: t, locale } = useI18n(); const { getAnswerValue, setAnswerValue, isLoading } = useQuestionAnswers(); const value = getAnswerValue(question); const defaultCodeValue = countryCode.trim() || "+44"; const getCachedOrSavedCode = useCallback((): string | null => { if (typeof window === "undefined") return null; return localStorage.getItem("geoIPPhoneCode"); }, []); const initialCode = useMemo(() => { if (isMarriagePhoneFieldValue(value) && value.countryCode) { return value.countryCode.startsWith("+") ? value.countryCode : `+${value.countryCode}`; } if (typeof value === "string" && value.length > 0) { const parts = readPhoneValue(value, defaultCodeValue); if (parts.codeValue && parts.codeValue !== defaultCodeValue) { return parts.codeValue; } } const cached = getCachedOrSavedCode(); if (cached) return cached; return defaultCodeValue; }, [value, defaultCodeValue, getCachedOrSavedCode]); const initialPhone = useMemo(() => { return readPhoneValue(value, defaultCodeValue).phoneValue; }, [value, defaultCodeValue]); const [codeValue, setCodeValue] = useState(initialCode); const [phoneValue, setPhoneValue] = useState(initialPhone); const lastCommittedValueRef = useRef(value); const hasFetchedIpRef = useRef(false); const userInteractedRef = useRef(false); const [isOpen, setIsOpen] = useState(false); const [isClosing, setIsClosing] = useState(false); const [searchQuery, setSearchQuery] = useState(""); const listRef = useRef(null); const searchInputRef = useRef(null); const closeSheet = useCallback(() => { setIsClosing(true); window.setTimeout(() => { setIsOpen(false); setIsClosing(false); setSearchQuery(""); }, EXIT_ANIMATION_MS); }, []); const openSheet = useCallback(() => { if (disabled) return; setIsOpen(true); setIsClosing(false); }, [disabled]); const normalizedPhoneState = getNormalizedPhoneValue(codeValue, phoneValue); const showInvalidState = codeValue.trim().length > 0 && phoneValue.trim().length > 0 && !normalizedPhoneState.isValid; const isAnswered = question.required === false ? normalizedPhoneState.isValid || phoneValue.trim().length === 0 : normalizedPhoneState.isValid; const countryList = useMemo(() => { const regions = phoneUtil.getSupportedRegions(); const displayNames = new Intl.DisplayNames([locale || "en"], { type: "region", }); const list: { name: string; code: string; flag: string }[] = []; const seen = new Set(); for (const region of regions) { try { const callingCode = phoneUtil.getCountryCodeForRegion(region); if (!callingCode) continue; let name = displayNames.of(region); if (!name) continue; if (region === "US") name = locale === "fa" ? "ایالات متحده آمریکا" : "United States"; if (region === "GB") name = locale === "fa" ? "بریتانیا" : "United Kingdom"; const key = `${name}_+${callingCode}`; if (seen.has(key)) continue; seen.add(key); const codePoints = region .toUpperCase() .split("") .map((char) => 127397 + char.charCodeAt(0)); const flag = String.fromCodePoint(...codePoints); list.push({ name, code: `+${callingCode}`, flag, }); } catch {} } return list.sort((a, b) => a.name.localeCompare(b.name)); }, [locale]); const activeFlag = useMemo(() => { if (!codeValue) return "🏳️"; const cleanActiveCode = codeValue.replace(/[^\d]/g, ""); const match = countryList.find( (c) => c.code.replace(/[^\d]/g, "") === cleanActiveCode, ); return match ? match.flag : "🏳️"; }, [codeValue, countryList]); const selectedCountry = useMemo(() => { const cleanActiveCode = codeValue.replace(/[^\d]/g, ""); return countryList.find( (country) => country.code.replace(/[^\d]/g, "") === cleanActiveCode, ); }, [codeValue, countryList]); const filteredCountries = useMemo(() => { const q = searchQuery.toLowerCase().trim(); if (!q) return countryList; return countryList.filter( (c) => c.name.toLowerCase().includes(q) || c.code.toLowerCase().includes(q), ); }, [countryList, searchQuery]); useSheetScrollLock(isOpen, { onBack: closeSheet }); useEffect(() => { if (!isOpen) return; const handleKeyDown = (event: KeyboardEvent) => { if (event.key === "Escape") closeSheet(); }; window.addEventListener("keydown", handleKeyDown); return () => window.removeEventListener("keydown", handleKeyDown); }, [closeSheet, isOpen]); // Sync state if external value changes (e.g. backend data loaded) useEffect(() => { if (value === lastCommittedValueRef.current) { return; } const nextValue = readPhoneValue(value, defaultCodeValue); const cachedCode = getCachedOrSavedCode(); const resolvedCode = (isMarriagePhoneFieldValue(value) && value.countryCode) || (typeof value === "string" && value.trim().length > 0 && nextValue.codeValue !== defaultCodeValue) ? nextValue.codeValue : cachedCode || defaultCodeValue; const maxLen = getMaxLengthForCountry(resolvedCode); const truncatedPhone = nextValue.phoneValue.slice(0, maxLen); setCodeValue(resolvedCode); setPhoneValue(truncatedPhone); lastCommittedValueRef.current = value; }, [defaultCodeValue, value, getCachedOrSavedCode]); // Non-blocking background IP resolution on first visit useEffect(() => { if (isLoading) return; if (hasFetchedIpRef.current) return; if (userInteractedRef.current) return; // If user already has a saved value from backend, use it — no IP check needed const hasSavedValue = value && ((isMarriagePhoneFieldValue(value) && (value.countryCode || value.phoneNumber)) || (typeof value === "string" && value.trim().length > 0)); if (hasSavedValue) { hasFetchedIpRef.current = true; if (typeof window !== "undefined") { localStorage.setItem("hasCheckedGeoIPPhone", "true"); if (isMarriagePhoneFieldValue(value) && value.countryCode) { const code = value.countryCode.startsWith("+") ? value.countryCode : `+${value.countryCode}`; localStorage.setItem("geoIPPhoneCode", code); setCodeValue(code); } } return; } // Check if we already fetched IP in a previous session or determined code if (typeof window !== "undefined") { const cachedCode = localStorage.getItem("geoIPPhoneCode"); const alreadyChecked = localStorage.getItem("hasCheckedGeoIPPhone"); if (cachedCode || alreadyChecked === "true") { hasFetchedIpRef.current = true; if (cachedCode) { setCodeValue(cachedCode); } return; } } // First time ever — fetch country code from IP with strict 1.5s timeout hasFetchedIpRef.current = true; const applyCode = (code: string) => { if (userInteractedRef.current) return; const ipCode = code.startsWith("+") ? code : `+${code}`; setCodeValue(ipCode); if (typeof window !== "undefined") { localStorage.setItem("geoIPPhoneCode", ipCode); localStorage.setItem("hasCheckedGeoIPPhone", "true"); } }; const applyFallback = () => { if (userInteractedRef.current) return; const fallbackCode = "+44"; setCodeValue(fallbackCode); if (typeof window !== "undefined") { localStorage.setItem("geoIPPhoneCode", fallbackCode); localStorage.setItem("hasCheckedGeoIPPhone", "true"); } }; const controller = new AbortController(); const timeoutId = setTimeout(() => controller.abort(), 1500); fetch("https://ipapi.co/json/", { signal: controller.signal }) .then((res) => res.json()) .then((data) => { clearTimeout(timeoutId); if (data?.country_calling_code) { applyCode(data.country_calling_code); } else { throw new Error("No calling code in response"); } }) .catch(() => { clearTimeout(timeoutId); const secondaryController = new AbortController(); const secondaryTimeoutId = setTimeout( () => secondaryController.abort(), 1500, ); fetch("https://ipwho.is/", { signal: secondaryController.signal }) .then((res) => res.json()) .then((data) => { clearTimeout(secondaryTimeoutId); if (data?.calling_code) { applyCode(data.calling_code); } else { applyFallback(); } }) .catch(() => { clearTimeout(secondaryTimeoutId); applyFallback(); }); }); return () => { clearTimeout(timeoutId); }; }, [isLoading, value]); const updateStoredValue = (nextCodeValue: string, nextPhoneValue: string) => { const draftValue = writePhoneValue(nextCodeValue, nextPhoneValue); const nextPhoneState = getNormalizedPhoneValue( nextCodeValue, nextPhoneValue, ); const nextValue = draftValue === null ? null : nextPhoneState.isValid ? toStoredPhoneValue(nextCodeValue, nextPhoneValue) : null; lastCommittedValueRef.current = nextValue; setAnswerValue(question, nextValue); }; const handleSelectCountryCode = (selectedCode: string) => { userInteractedRef.current = true; if (typeof window !== "undefined") { localStorage.setItem("geoIPPhoneCode", selectedCode); localStorage.setItem("hasCheckedGeoIPPhone", "true"); } const maxLen = getMaxLengthForCountry(selectedCode); const truncatedPhone = phoneValue.slice(0, maxLen); setCodeValue(selectedCode); setPhoneValue(truncatedPhone); updateStoredValue(selectedCode, truncatedPhone); closeSheet(); }; const selectCountryTitle = t["Select country"] || question.title; const searchPlaceholder = locale === "fa" ? "جستجو..." : locale === "ar" ? "بحث..." : locale === "tr" ? "Ara..." : "Search..."; const noResultsText = locale === "fa" ? "موردی یافت نشد" : locale === "ar" ? "لم يتم العثور على نتائج" : "No options found"; return (
{ userInteractedRef.current = true; if (typeof window !== "undefined") { localStorage.setItem("geoIPPhoneCode", codeValue); localStorage.setItem("hasCheckedGeoIPPhone", "true"); } const nextPhoneValue = sanitizePhoneNumber(event.target.value); const maxLen = getMaxLengthForCountry(codeValue); const truncatedPhone = nextPhoneValue.slice(0, maxLen); setPhoneValue(truncatedPhone); updateStoredValue(codeValue, truncatedPhone); }} dir="ltr" className="h-full w-full border-0 bg-transparent p-0 text-left text-[15px] font-medium leading-none text-[#181818] tabular-nums outline-none placeholder:text-[#98A2B3]" />
{showInvalidState ? ( Enter a valid phone number with country code. ) : null} {/* The phone field moves up while the country sheet enters from below. */} {isOpen && createPortal(
{ if (event.key === "Escape") closeSheet(); }} onClick={(event) => { if (event.target === event.currentTarget) closeSheet(); }} >
{/* Header with Title and Close Button */}

{selectCountryTitle}

{/* Search Bar */}
setSearchQuery(e.target.value)} placeholder={searchPlaceholder} className="flex-1 bg-transparent text-[14px] font-medium text-[#181818] outline-none placeholder:text-[#98A2B3]" /> {searchQuery ? ( ) : null}
{/* Country Options List */}
event.stopPropagation()} onTouchMove={(event) => event.stopPropagation()} onTouchEnd={(event) => event.stopPropagation()} className="flex min-h-0 flex-1 flex-col gap-2 overflow-y-auto overscroll-contain px-5 py-3" > {filteredCountries.length > 0 ? ( filteredCountries.map((c) => { const cleanCode = c.code.replace(/[^\d]/g, ""); const activeCleanCode = (codeValue || "").replace( /[^\d]/g, "", ); const isSelected = cleanCode === activeCleanCode; return ( ); }) ) : (
{noResultsText}
)}
, document.body, )}
); } export default QuestionPhone;