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.
985 lines
31 KiB
985 lines
31 KiB
"use client";
|
|
|
|
import {
|
|
AsYouTypeFormatter,
|
|
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 { http } from "@/lib/http";
|
|
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;
|
|
};
|
|
|
|
import {
|
|
getUserGeoRegion,
|
|
getStoredUserGeoRegion,
|
|
setStoredUserGeoRegion,
|
|
subscribeToUserGeoRegion,
|
|
resetUserGeoRegionForTesting,
|
|
} from "@/lib/geo-region";
|
|
|
|
const phoneUtil = PhoneNumberUtil.getInstance();
|
|
|
|
export function resetGeoPhoneStateForTesting() {
|
|
resetUserGeoRegionForTesting();
|
|
}
|
|
|
|
function getStoredGeoCode(): string | null {
|
|
const region = getStoredUserGeoRegion();
|
|
return region?.phoneCode || null;
|
|
}
|
|
|
|
export function setManuallySelectedGeoCode(code: string) {
|
|
const current = getStoredUserGeoRegion() || {};
|
|
setStoredUserGeoRegion({ ...current, phoneCode: code });
|
|
}
|
|
|
|
export async function fetchGeoCountryCode(
|
|
defaultCode = "+44",
|
|
): Promise<string> {
|
|
const region = await getUserGeoRegion();
|
|
return region.phoneCode || defaultCode;
|
|
}
|
|
|
|
function isMarriagePhoneFieldValue(
|
|
value: unknown,
|
|
): value is MarriagePhoneFieldValue {
|
|
if (!value || typeof value !== "object") {
|
|
return false;
|
|
}
|
|
|
|
const phoneValue = value as Partial<MarriagePhoneFieldValue>;
|
|
|
|
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 18;
|
|
if (cleanCode === "98") return 12; // 3 + 1 + 3 + 1 + 4 = 12 chars
|
|
const countryCode = Number(cleanCode);
|
|
const regionCode = phoneUtil.getRegionCodeForCountryCode(countryCode);
|
|
if (!regionCode || regionCode === "ZZ") return 18;
|
|
|
|
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 + 6;
|
|
} catch {
|
|
return 18;
|
|
}
|
|
}
|
|
|
|
function getCountryPlaceholder(
|
|
codeValue: string,
|
|
defaultPlaceholder?: string,
|
|
): string {
|
|
try {
|
|
const cleanCode = codeValue.replace(/[^\d]/g, "");
|
|
if (!cleanCode) {
|
|
return defaultPlaceholder?.replace(/^\+\d+\s*/, "") || "912 345 6789";
|
|
}
|
|
|
|
if (cleanCode === "98") {
|
|
return "912 345 6789";
|
|
}
|
|
|
|
const countryCode = Number(cleanCode);
|
|
const regionCode = phoneUtil.getRegionCodeForCountryCode(countryCode);
|
|
if (!regionCode || regionCode === "ZZ") {
|
|
return defaultPlaceholder?.replace(/^\+\d+\s*/, "") || "912 345 6789";
|
|
}
|
|
|
|
const example =
|
|
phoneUtil.getExampleNumberForType(
|
|
regionCode,
|
|
1, // MOBILE
|
|
) || phoneUtil.getExampleNumber(regionCode);
|
|
|
|
if (!example) {
|
|
return defaultPlaceholder?.replace(/^\+\d+\s*/, "") || "912 345 6789";
|
|
}
|
|
|
|
const intl = phoneUtil.format(example, PhoneNumberFormat.INTERNATIONAL);
|
|
const prefix = `+${cleanCode} `;
|
|
if (intl.startsWith(prefix)) {
|
|
return intl.slice(prefix.length);
|
|
}
|
|
return String(example.getNationalNumber());
|
|
} catch {
|
|
return defaultPlaceholder?.replace(/^\+\d+\s*/, "") || "912 345 6789";
|
|
}
|
|
}
|
|
|
|
function formatPhoneNumberAsYouType(
|
|
rawInput: string,
|
|
codeValue: string,
|
|
): string {
|
|
const cleanCode = codeValue.replace(/[^\d]/g, "");
|
|
const rawDigits = rawInput.replace(/\D/g, "");
|
|
if (!rawDigits) return "";
|
|
|
|
// Special telegram-style handling for Iran (+98): 3 - 3 - 4 (e.g. 912 345 6789)
|
|
if (cleanCode === "98") {
|
|
const digits = rawDigits.startsWith("0") ? rawDigits.slice(1) : rawDigits;
|
|
const max10 = digits.slice(0, 10);
|
|
if (max10.length <= 3) return max10;
|
|
if (max10.length <= 6) return `${max10.slice(0, 3)} ${max10.slice(3)}`;
|
|
return `${max10.slice(0, 3)} ${max10.slice(3, 6)} ${max10.slice(6)}`;
|
|
}
|
|
|
|
const countryCode = Number(cleanCode);
|
|
const regionCode = phoneUtil.getRegionCodeForCountryCode(countryCode);
|
|
if (!regionCode || regionCode === "ZZ") {
|
|
return rawDigits;
|
|
}
|
|
|
|
try {
|
|
const formatter = new AsYouTypeFormatter(regionCode);
|
|
let formatted = "";
|
|
const fullNumber = `+${cleanCode}${rawDigits}`;
|
|
for (const char of fullNumber) {
|
|
formatted = formatter.inputDigit(char);
|
|
}
|
|
const codePrefix = `+${cleanCode} `;
|
|
if (formatted.startsWith(codePrefix)) {
|
|
return formatted.slice(codePrefix.length);
|
|
}
|
|
if (formatted.startsWith(`+${cleanCode}`)) {
|
|
return formatted.slice(`+${cleanCode}`.length).trim();
|
|
}
|
|
return rawDigits;
|
|
} catch {
|
|
return rawDigits;
|
|
}
|
|
}
|
|
|
|
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(() => {
|
|
const rawPhone = readPhoneValue(value, defaultCodeValue).phoneValue;
|
|
return formatPhoneNumberAsYouType(rawPhone, initialCode);
|
|
}, [value, defaultCodeValue, initialCode]);
|
|
|
|
const [codeValue, setCodeValue] = useState(initialCode);
|
|
const [phoneValue, setPhoneValue] = useState(initialPhone);
|
|
const [isFocused, setIsFocused] = useState(false);
|
|
const [isTouched, setIsTouched] = useState(false);
|
|
const lastCommittedValueRef = useRef(value);
|
|
|
|
const dynamicPlaceholder = useMemo(() => {
|
|
return getCountryPlaceholder(
|
|
codeValue || defaultCodeValue,
|
|
question.extras?.placeHolder,
|
|
);
|
|
}, [codeValue, defaultCodeValue, question.extras?.placeHolder]);
|
|
|
|
const [isOpen, setIsOpen] = useState(false);
|
|
const [isClosing, setIsClosing] = useState(false);
|
|
const [searchQuery, setSearchQuery] = useState("");
|
|
const listRef = useRef<HTMLDivElement>(null);
|
|
const searchInputRef = useRef<HTMLInputElement>(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 unsubscribe = subscribeToUserGeoRegion((region) => {
|
|
if (!isMounted || userInteractedRef.current) return;
|
|
if (region.phoneCode) {
|
|
setCodeValue(region.phoneCode);
|
|
if (phoneValue) {
|
|
setPhoneValue((prev) =>
|
|
formatPhoneNumberAsYouType(prev, region.phoneCode!),
|
|
);
|
|
}
|
|
setIsResolvingCountry(false);
|
|
}
|
|
});
|
|
|
|
fetchGeoCountryCode(defaultCodeValue)
|
|
.then((resolvedCode) => {
|
|
if (!isMounted) return;
|
|
if (!userInteractedRef.current) {
|
|
const finalCode = resolvedCode || defaultCodeValue;
|
|
setCodeValue(finalCode);
|
|
if (phoneValue) {
|
|
setPhoneValue((prev) =>
|
|
formatPhoneNumberAsYouType(prev, finalCode),
|
|
);
|
|
}
|
|
}
|
|
setIsResolvingCountry(false);
|
|
})
|
|
.catch(() => {
|
|
if (!isMounted) return;
|
|
if (!userInteractedRef.current) {
|
|
setCodeValue(defaultCodeValue);
|
|
}
|
|
setIsResolvingCountry(false);
|
|
});
|
|
|
|
return () => {
|
|
isMounted = false;
|
|
unsubscribe();
|
|
};
|
|
}, [hasExplicitValue, isResolvingCountry, defaultCodeValue, phoneValue]);
|
|
|
|
const normalizedPhoneState = getNormalizedPhoneValue(codeValue, phoneValue);
|
|
const showInvalidState =
|
|
isTouched &&
|
|
!isFocused &&
|
|
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<string>();
|
|
|
|
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 && !isClosing, { 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 formatted = formatPhoneNumberAsYouType(
|
|
nextValue.phoneValue,
|
|
nextValue.codeValue,
|
|
);
|
|
setPhoneValue(formatted);
|
|
} else if (value !== null && value !== undefined) {
|
|
const resolvedCode = cachedCode || defaultCodeValue;
|
|
setCodeValue(resolvedCode);
|
|
const formatted = formatPhoneNumberAsYouType(
|
|
nextValue.phoneValue,
|
|
resolvedCode,
|
|
);
|
|
setPhoneValue(formatted);
|
|
}
|
|
|
|
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 reformatted = formatPhoneNumberAsYouType(phoneValue, selectedCode);
|
|
setCodeValue(selectedCode);
|
|
setPhoneValue(reformatted);
|
|
updateStoredValue(selectedCode, reformatted);
|
|
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 (
|
|
<div
|
|
data-question-answered={isAnswered ? "true" : "false"}
|
|
data-question-type={question.type}
|
|
className={[
|
|
"relative flex w-full flex-col gap-2 transition-opacity duration-200",
|
|
disabled ? "pointer-events-none opacity-30" : "",
|
|
].join(" ")}
|
|
>
|
|
<QuestionTitle question={question} />
|
|
<div
|
|
dir="ltr"
|
|
data-snap-drag-ignore="true"
|
|
className={[
|
|
"flex h-[54px] w-full items-center rounded-[16px] border bg-white text-[#181818] focus-within:border-[#6F6F6F] focus-within:ring-1 focus-within:ring-[#6F6F6F] transition-all",
|
|
showInvalidState
|
|
? "border-[#F2465F] ring-1 ring-[#F2465F]"
|
|
: "border-[#D0D5DD] hover:border-[#98A2B3]",
|
|
].join(" ")}
|
|
>
|
|
<div className="flex shrink-0 items-center pl-2.5 pr-2">
|
|
<button
|
|
type="button"
|
|
disabled={disabled || isResolvingCountry}
|
|
onClick={openSheet}
|
|
className="flex items-center gap-1.5 h-full px-2 rounded-lg hover:bg-gray-50 active:bg-gray-100 transition-colors cursor-pointer outline-none border-0 text-[15px] font-medium leading-none text-[#181818] tabular-nums"
|
|
>
|
|
{isResolvingCountry ? (
|
|
<div
|
|
className="flex items-center py-1"
|
|
aria-hidden="true"
|
|
>
|
|
<span className="h-[22px] w-[58px] rounded-[6px] shimmer-bg inline-block shrink-0" />
|
|
</div>
|
|
) : (
|
|
<>
|
|
<span>{activeFlag}</span>
|
|
<span>{codeValue || defaultCodeValue}</span>
|
|
<svg
|
|
aria-hidden="true"
|
|
width="10"
|
|
height="6"
|
|
viewBox="0 0 10 6"
|
|
fill="none"
|
|
className={[
|
|
"shrink-0 transition-transform duration-200 text-[#344054]",
|
|
isOpen ? "rotate-180" : "",
|
|
].join(" ")}
|
|
>
|
|
<path
|
|
d="M1 1L5 5L9 1"
|
|
stroke="currentColor"
|
|
strokeWidth="1.5"
|
|
strokeLinecap="round"
|
|
strokeLinejoin="round"
|
|
/>
|
|
</svg>
|
|
</>
|
|
)}
|
|
</button>
|
|
<span aria-hidden="true" className="h-5 w-px bg-[#181818]/35 ml-1" />
|
|
</div>
|
|
<span className="flex min-w-0 flex-1 items-center pr-4">
|
|
<input
|
|
type="tel"
|
|
inputMode="tel"
|
|
data-snap-drag-ignore="true"
|
|
autoComplete="off"
|
|
autoCorrect="off"
|
|
autoCapitalize="none"
|
|
spellCheck="false"
|
|
aria-autocomplete="none"
|
|
data-lpignore="true"
|
|
data-1p-ignore="true"
|
|
data-bwignore="true"
|
|
data-form-type="other"
|
|
disabled={disabled}
|
|
placeholder={dynamicPlaceholder}
|
|
value={phoneValue}
|
|
maxLength={getMaxLengthForCountry(codeValue)}
|
|
onFocus={() => {
|
|
setIsFocused(true);
|
|
}}
|
|
onBlur={() => {
|
|
setIsFocused(false);
|
|
setIsTouched(true);
|
|
}}
|
|
onChange={(event) => {
|
|
userInteractedRef.current = true;
|
|
setIsResolvingCountry(false);
|
|
setManuallySelectedGeoCode(codeValue);
|
|
const formatted = formatPhoneNumberAsYouType(
|
|
event.target.value,
|
|
codeValue,
|
|
);
|
|
|
|
setPhoneValue(formatted);
|
|
updateStoredValue(codeValue, formatted);
|
|
}}
|
|
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]"
|
|
/>
|
|
</span>
|
|
</div>
|
|
{showInvalidState ? (
|
|
<span className="block group-10 font-semibold text-[#F2465F]">
|
|
{t["Enter a valid phone number with country code."] ||
|
|
"Enter a valid phone number with country code."}
|
|
</span>
|
|
) : null}
|
|
|
|
{/* The phone field moves up while the country sheet enters from below. */}
|
|
{isOpen &&
|
|
createPortal(
|
|
<div
|
|
className="fixed inset-0 z-50 flex items-end justify-center bg-transparent"
|
|
role="dialog"
|
|
aria-label={selectCountryTitle}
|
|
onKeyDown={(event) => {
|
|
if (event.key === "Escape") closeSheet();
|
|
}}
|
|
onClick={(event) => {
|
|
if (event.target === event.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-[300ms] ease-out sm:max-w-[375px] animate-in slide-in-from-bottom",
|
|
isClosing ? "translate-y-full" : "translate-y-0",
|
|
].join(" ")}
|
|
>
|
|
{/* 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">
|
|
{selectCountryTitle}
|
|
</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
|
|
aria-hidden="true"
|
|
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
|
|
width="18"
|
|
height="18"
|
|
viewBox="0 0 18 18"
|
|
fill="none"
|
|
className="shrink-0 text-[#667085]"
|
|
aria-hidden="true"
|
|
>
|
|
<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
|
|
ref={searchInputRef}
|
|
type="text"
|
|
name="country_search_field"
|
|
autoComplete="off"
|
|
autoCorrect="off"
|
|
autoCapitalize="none"
|
|
spellCheck="false"
|
|
aria-autocomplete="none"
|
|
data-lpignore="true"
|
|
data-1p-ignore="true"
|
|
data-bwignore="true"
|
|
data-form-type="other"
|
|
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}
|
|
onTouchStart={(event) => 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 (
|
|
<button
|
|
key={`${c.name}-${c.code}`}
|
|
type="button"
|
|
onClick={() => handleSelectCountryCode(c.code)}
|
|
className={[
|
|
"flex min-h-12 w-full items-center gap-3 rounded-lg border px-3 py-3 text-start transition-colors cursor-pointer",
|
|
isSelected
|
|
? "bg-[#FFF4F5] border-[#F0445B]/30 text-[#181818]"
|
|
: "bg-white hover:bg-[#F2F4F7] border-[#EAECF0] text-[#181818]",
|
|
].join(" ")}
|
|
>
|
|
{/* Radio Indicator */}
|
|
<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(" ")}
|
|
/>
|
|
|
|
{/* Country Flag */}
|
|
<span
|
|
className="text-[20px] shrink-0"
|
|
aria-hidden="true"
|
|
>
|
|
{c.flag}
|
|
</span>
|
|
|
|
{/* Country Name & Dial Code */}
|
|
<span className="flex min-w-0 flex-1 items-center justify-between gap-2">
|
|
<span
|
|
className={[
|
|
"truncate text-[15px]",
|
|
isSelected
|
|
? "font-bold text-[#181818]"
|
|
: "font-semibold text-[#344054]",
|
|
].join(" ")}
|
|
>
|
|
{c.name}
|
|
</span>
|
|
<span
|
|
dir="ltr"
|
|
className={[
|
|
"shrink-0 text-[14px] font-medium tabular-nums",
|
|
isSelected ? "text-[#F0445B]" : "text-[#667085]",
|
|
].join(" ")}
|
|
>
|
|
{c.code}
|
|
</span>
|
|
</span>
|
|
</button>
|
|
);
|
|
})
|
|
) : (
|
|
<div className="py-8 text-center text-[14px] text-[#667085]">
|
|
{noResultsText}
|
|
</div>
|
|
)}
|
|
</div>
|
|
</section>
|
|
</div>,
|
|
document.body,
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
export default QuestionPhone;
|