"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(); // Module-level singleton state for IP phone country resolution let cachedGeoCountryCode: string | null = null; let geoIpPromise: Promise | null = null; const geoListeners = new Set<(code: string) => void>(); export function resetGeoPhoneStateForTesting() { cachedGeoCountryCode = null; geoIpPromise = null; geoListeners.clear(); } function getStoredGeoCode(): string | null { if (cachedGeoCountryCode) return cachedGeoCountryCode; if (typeof window !== "undefined") { try { const stored = localStorage.getItem("geoIPPhoneCode"); if (stored) { cachedGeoCountryCode = stored; return stored; } } catch {} } return null; } export function setManuallySelectedGeoCode(code: string) { cachedGeoCountryCode = code; if (typeof window !== "undefined") { try { localStorage.setItem("geoIPPhoneCode", code); localStorage.setItem("hasCheckedGeoIPPhone", "true"); } catch {} } } export function fetchGeoCountryCode(defaultCode = "+44"): Promise { const existing = getStoredGeoCode(); if (existing) { return Promise.resolve(existing); } if (geoIpPromise) { return geoIpPromise.then((res) => res || defaultCode); } geoIpPromise = (async () => { try { // 1. Primary: 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?.country_calling_code) { const rawCode = String(data.country_calling_code).trim(); const formatted = rawCode.startsWith("+") ? rawCode : `+${rawCode}`; setManuallySelectedGeoCode(formatted); geoListeners.forEach((fn) => { fn(formatted); }); return formatted; } } } catch { clearTimeout(timeoutId); } // 2. Secondary 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?.calling_code) { const rawCode = String(data.calling_code).trim(); const formatted = rawCode.startsWith("+") ? rawCode : `+${rawCode}`; setManuallySelectedGeoCode(formatted); geoListeners.forEach((fn) => { fn(formatted); }); return formatted; } } } catch { clearTimeout(secondaryTimeoutId); } // 3. Fallback to default if (typeof window !== "undefined") { try { localStorage.setItem("geoIPPhoneCode", defaultCode); localStorage.setItem("hasCheckedGeoIPPhone", "true"); } catch {} } cachedGeoCountryCode = defaultCode; geoListeners.forEach((fn) => { fn(defaultCode); }); return defaultCode; } catch { if (typeof window !== "undefined") { try { localStorage.setItem("geoIPPhoneCode", defaultCode); localStorage.setItem("hasCheckedGeoIPPhone", "true"); } catch {} } cachedGeoCountryCode = defaultCode; geoListeners.forEach((fn) => { fn(defaultCode); }); return defaultCode; } })(); return geoIpPromise.then((res) => res || defaultCode); } 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 } = useQuestionAnswers(); const value = getAnswerValue(question); const defaultCodeValue = countryCode.trim() || "+44"; const userInteractedRef = useRef(false); // Check if we already have a saved / existing value from profile / backend const hasExplicitValue = useMemo(() => { if (isMarriagePhoneFieldValue(value) && value.countryCode) { return true; } if (typeof value === "string" && value.trim().length > 0) { const parts = readPhoneValue(value, defaultCodeValue); return Boolean(parts.codeValue && parts.codeValue !== defaultCodeValue); } return false; }, [value, defaultCodeValue]); const initialCachedCode = useMemo(() => { return getStoredGeoCode(); }, []); const [isResolvingCountry, setIsResolvingCountry] = useState(() => { if (hasExplicitValue) return false; if (initialCachedCode) return false; return true; }); 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 = getStoredGeoCode(); if (cached) return cached; return defaultCodeValue; }, [value, defaultCodeValue]); 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 [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 || isResolvingCountry) return; setIsOpen(true); setIsClosing(false); }, [disabled, isResolvingCountry]); // IP Resolution Effect useEffect(() => { if (hasExplicitValue || !isResolvingCountry || userInteractedRef.current) { return; } let isMounted = true; const onGeoCodeResolved = (resolvedCode: string) => { if (!isMounted || userInteractedRef.current) return; setCodeValue(resolvedCode); setIsResolvingCountry(false); }; geoListeners.add(onGeoCodeResolved); fetchGeoCountryCode(defaultCodeValue) .then((resolvedCode) => { if (!isMounted) return; if (!userInteractedRef.current) { setCodeValue(resolvedCode || defaultCodeValue); } setIsResolvingCountry(false); }) .catch(() => { if (!isMounted) return; if (!userInteractedRef.current) { setCodeValue(defaultCodeValue); } setIsResolvingCountry(false); }); return () => { isMounted = false; geoListeners.delete(onGeoCodeResolved); }; }, [hasExplicitValue, isResolvingCountry, defaultCodeValue]); 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, ""); if (cleanActiveCode === "44") { const gb = countryList.find( (c) => c.name.includes("United Kingdom") || c.name.includes("بریتانیا"), ); if (gb) return gb.flag; } if (cleanActiveCode === "1") { const us = countryList.find( (c) => c.name.includes("United States") || c.name.includes("ایالات متحده"), ); if (us) return us.flag; } 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, ""); if (cleanActiveCode === "44") { const gb = countryList.find( (c) => c.name.includes("United Kingdom") || c.name.includes("بریتانیا"), ); if (gb) return gb; } if (cleanActiveCode === "1") { const us = countryList.find( (c) => c.name.includes("United States") || c.name.includes("ایالات متحده"), ); if (us) return us; } 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 = getStoredGeoCode(); const explicit = (isMarriagePhoneFieldValue(value) && Boolean(value.countryCode)) || (typeof value === "string" && value.trim().length > 0 && nextValue.codeValue !== defaultCodeValue); if (explicit) { setIsResolvingCountry(false); setCodeValue(nextValue.codeValue); const maxLen = getMaxLengthForCountry(nextValue.codeValue); setPhoneValue(nextValue.phoneValue.slice(0, maxLen)); } else if (value !== null && value !== undefined) { const resolvedCode = cachedCode || defaultCodeValue; setCodeValue(resolvedCode); const maxLen = getMaxLengthForCountry(resolvedCode); setPhoneValue(nextValue.phoneValue.slice(0, maxLen)); } lastCommittedValueRef.current = value; }, [defaultCodeValue, 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; setIsResolvingCountry(false); setManuallySelectedGeoCode(selectedCode); 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; setIsResolvingCountry(false); setManuallySelectedGeoCode(codeValue); 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;