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.
 
 
 
 
 

445 lines
14 KiB

"use client";
import { useEffect, useMemo, useState } from "react";
import type { QuestionField } from "@/lib/schema-adapter";
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";
import { resolveDefaultCurrency } from "@/data/currencies";
import { getStoredUserGeoRegion } from "@/lib/geo-region";
import { CurrencySheet } from "./currency-sheet";
type QuestionNumberProps = {
question: QuestionField;
disabled?: boolean;
derivedFromQuestion?: QuestionField;
derivedFromQuestionIndex?: number;
};
const PERSIAN_DIGITS = ["۰", "۱", "۲", "۳", "۴", "۵", "۶", "۷", "۸", "۹"];
const ARABIC_DIGITS = ["٠", "١", "٢", "٣", "٤", "٥", "٦", "٧", "٨", "٩"];
export function normalizeNumberString(val: string): string {
if (!val) return "";
let result = "";
for (let i = 0; i < val.length; i++) {
const char = val[i];
const pIdx = PERSIAN_DIGITS.indexOf(char);
if (pIdx !== -1) {
result += String(pIdx);
continue;
}
const aIdx = ARABIC_DIGITS.indexOf(char);
if (aIdx !== -1) {
result += String(aIdx);
continue;
}
if (char === "٫") {
result += ".";
continue;
}
result += char;
}
return result;
}
const NUMBER_INPUT_PATTERN = /^-?\d*\.?\d*$/;
export default function QuestionNumber({
question,
disabled,
derivedFromQuestion,
derivedFromQuestionIndex,
}: QuestionNumberProps) {
const { dictionary: t, locale } = useI18n();
const { getAnswerValue, setAnswerValue } = useQuestionAnswers();
const value = getAnswerValue(question);
const derivedValue =
derivedFromQuestion && derivedFromQuestionIndex !== undefined
? getAnswerValue(derivedFromQuestion)
: null;
useEffect(() => {
if (derivedFromQuestion && typeof derivedValue === "string") {
const age = calculateAge(derivedValue);
if (age !== String(value)) {
setAnswerValue(question, age);
}
}
}, [
derivedFromQuestion,
derivedValue,
question,
setAnswerValue,
value,
]);
useEffect(() => {
if (typeof value === "string" && value.length > 0) {
const normalized = normalizeNumberString(value);
console.log(
`[NUM_LOG] useEffect normalize: id=${question.id}, value="${value}", normalized="${normalized}"`,
);
if (!NUMBER_INPUT_PATTERN.test(normalized)) {
console.log(
`[NUM_LOG] useEffect clearing invalid value: id=${question.id}, value="${value}"`,
);
setAnswerValue(question, null);
} else if (normalized !== value) {
const parsed = parseFloat(normalized);
console.log(
`[NUM_LOG] useEffect updating normalized: id=${question.id}, parsed=${parsed}`,
);
setAnswerValue(
question,
Number.isNaN(parsed) ? normalized : parsed,
);
}
}
}, [question, setAnswerValue, value]);
const [min, max] = question.extras.range;
const numValue =
typeof value === "number"
? value
: typeof value === "string"
? parseFloat(normalizeNumberString(value))
: NaN;
const isOutOfRange = useMemo(() => {
if (Number.isNaN(numValue)) return false;
if (min !== 0 && numValue < min) return true;
if (max !== 0 && numValue > max) return true;
return false;
}, [numValue, min, max]);
const rawInputValue = value == null ? "" : String(value);
const normalizedRaw = normalizeNumberString(rawInputValue);
const inputValue = NUMBER_INPUT_PATTERN.test(normalizedRaw)
? normalizedRaw
: "";
console.log(
`[NUM_LOG] Render: id=${question.id}, value=${JSON.stringify(value)}, inputValue="${inputValue}", isOutOfRange=${isOutOfRange}`,
);
const isMonthlyIncome = question.ui_config?.currency_enabled === true;
const currencyStorageKey =
question.ui_config?.currency_storage_key || "marriage:income:currency";
const countryName = useMemo(() => getCountryFromStorage(), []);
const [currencyCode, setCurrencyCode] = useState(() => {
if (typeof window !== "undefined") {
const stored = window.localStorage.getItem(currencyStorageKey);
if (stored) return stored;
}
const geo = getStoredUserGeoRegion();
return resolveDefaultCurrency({
countryCode: geo?.countryCode,
countryName: countryName || geo?.country,
fallbackLocale: locale,
});
});
const [isCurrencySheetOpen, setIsCurrencySheetOpen] = useState(false);
useEffect(() => {
if (typeof window !== "undefined") {
const stored = window.localStorage.getItem(currencyStorageKey);
if (stored) {
setCurrencyCode(stored);
return;
}
}
const geo = getStoredUserGeoRegion();
const derived = resolveDefaultCurrency({
countryCode: geo?.countryCode,
countryName: countryName || geo?.country,
fallbackLocale: locale,
});
setCurrencyCode(derived);
}, [countryName, currencyStorageKey, locale]);
const placeholderCurrency = useMemo(() => {
if (currencyCode === "TOMAN") {
return locale === "fa" || locale === "fa-ir" ? "تومان" : "TOMAN";
}
return currencyCode;
}, [currencyCode, locale]);
const dynamicPlaceholder = useMemo(() => {
if (!isMonthlyIncome) {
return question.extras.placeHolder;
}
return locale === "fa" || locale === "fa-ir"
? `مثال: ۴۰۰۰ ${placeholderCurrency}`
: `e.g. 4000 ${placeholderCurrency}`;
}, [
isMonthlyIncome,
question.extras.placeHolder,
placeholderCurrency,
locale,
]);
const [localTextValue, setLocalTextValue] = useState(() =>
formatNumberWithCommas(inputValue),
);
useEffect(() => {
const formatted = formatNumberWithCommas(
value == null ? "" : String(value),
);
const cleanLocal = localTextValue.replace(/,/g, "");
const cleanFormatted = formatted.replace(/,/g, "");
if (cleanFormatted !== cleanLocal) {
setLocalTextValue(formatted);
}
}, [value, localTextValue]);
if (isMonthlyIncome) {
return (
<div
className={[
"flex w-full flex-col gap-2 transition-opacity duration-200",
disabled ? "pointer-events-none opacity-30" : "",
].join(" ")}
>
<QuestionTitle question={question} />
<div className="flex gap-3 w-full relative">
<div className="flex-1 min-w-0">
<Input
type="text"
inputMode="decimal"
required={question.required && !disabled}
disabled={disabled || Boolean(derivedFromQuestion)}
placeholder={dynamicPlaceholder}
value={localTextValue}
onChange={(event) => {
const nextValue = event.target.value;
const normalized = normalizeNumberString(nextValue);
const cleanValue = normalized.replace(/,/g, "");
if (
cleanValue !== "" &&
cleanValue !== "-" &&
!NUMBER_INPUT_PATTERN.test(cleanValue)
) {
return;
}
const formatted = formatNumberWithCommas(cleanValue);
const finalFormatted = normalized.endsWith(".")
? `${formatted}.`
: formatted;
setLocalTextValue(finalFormatted);
if (cleanValue === "" || cleanValue === "-") {
setAnswerValue(question, null);
} else {
const parsed = parseFloat(cleanValue);
setAnswerValue(
question,
Number.isNaN(parsed) ? cleanValue : parsed,
);
}
}}
className={[
"h-[54px] w-full rounded-[16px] border px-4.5 text-[15px] font-medium text-[#181818] outline-none transition-all placeholder:text-[#98A2B3] focus:border-[#6F6F6F] focus:ring-1 focus:ring-[#6F6F6F] disabled:bg-[#F5F2F1] disabled:text-[#7C7472]",
isOutOfRange
? "border-[#F2465F] ring-1 ring-[#F2465F]"
: "border-[#D0D5DD] hover:border-[#98A2B3] bg-white",
].join(" ")}
/>
</div>
<div className="w-[110px] shrink-0 relative">
<button
type="button"
disabled={disabled}
onClick={() => setIsCurrencySheetOpen(true)}
className={[
"flex h-[54px] w-full items-center justify-between rounded-[16px] border bg-white px-3.5 text-start transition-all cursor-pointer outline-none",
isCurrencySheetOpen
? "border-[#6F6F6F] ring-1 ring-[#6F6F6F]"
: "border-[#D0D5DD] hover:border-[#98A2B3]",
].join(" ")}
>
<span className="text-[15px] font-medium text-[#181818] truncate">
{placeholderCurrency}
</span>
<svg
width="12"
height="8"
viewBox="0 0 16 10"
fill="none"
role="img"
aria-label="Dropdown chevron"
className={[
"shrink-0 transition-transform duration-200 text-[#344054] ml-1",
isCurrencySheetOpen ? "rotate-180" : "",
].join(" ")}
>
<path
d="M14.75 1.25L7.75 8.25L0.75 1.25"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
</button>
</div>
</div>
<CurrencySheet
isOpen={isCurrencySheetOpen}
onClose={() => setIsCurrencySheetOpen(false)}
selectedCurrency={currencyCode}
onSelectCurrency={(code) => {
setCurrencyCode(code);
if (typeof window !== "undefined") {
window.localStorage.setItem(currencyStorageKey, code);
}
}}
/>
{isOutOfRange ? (
<span className="block text-[10px] font-semibold text-[#F2465F]">
{t[
"The value entered seems incorrect. Please provide a realistic value."
] || `Please enter a value between ${min} and ${max}`}
</span>
) : null}
</div>
);
}
return (
<div
className={[
"flex w-full flex-col gap-2 transition-opacity duration-200",
disabled ? "pointer-events-none opacity-30" : "",
].join(" ")}
>
<QuestionTitle question={question} />
<Input
type="text"
inputMode="numeric"
required={question.required && !disabled}
disabled={disabled || Boolean(derivedFromQuestion)}
placeholder={question.extras.placeHolder}
value={inputValue}
onChange={(event) => {
const raw = event.target.value;
const normalized = normalizeNumberString(raw);
const cleaned = normalized.replace(/[^0-9.-]/g, "");
console.log(
`[NUM_LOG] onChange: id=${question.id}, raw="${raw}", normalized="${normalized}", cleaned="${cleaned}"`,
);
if (cleaned === "" || cleaned === "-") {
console.log(`[NUM_LOG] onChange clearing value (empty)`);
setAnswerValue(question, null);
return;
}
if (!NUMBER_INPUT_PATTERN.test(cleaned)) {
console.log(`[NUM_LOG] onChange rejected pattern: "${cleaned}"`);
return;
}
const parsed = parseFloat(cleaned);
console.log(
`[NUM_LOG] onChange calling setAnswerValue with:`,
Number.isNaN(parsed) ? cleaned : parsed,
);
setAnswerValue(
question,
Number.isNaN(parsed) ? cleaned : parsed,
);
}}
className={[
"h-[54px] w-full rounded-[16px] border px-4.5 text-[15px] font-medium text-[#181818] outline-none transition-all placeholder:text-[#98A2B3] focus:border-[#6F6F6F] focus:ring-1 focus:ring-[#6F6F6F] disabled:bg-[#F5F2F1] disabled:text-[#7C7472]",
isOutOfRange
? "border-[#F2465F] ring-1 ring-[#F2465F]"
: "border-[#D0D5DD] hover:border-[#98A2B3] bg-white",
].join(" ")}
/>
{isOutOfRange ? (
<span className="block text-[10px] font-semibold text-[#F2465F]">
{t[
"The value entered seems incorrect. Please provide a realistic value."
] || `Please enter a value between ${min} and ${max}`}
</span>
) : null}
</div>
);
}
function calculateAge(dateOfBirth: string) {
const birthDate = new Date(dateOfBirth);
if (Number.isNaN(birthDate.getTime())) {
return "";
}
const today = new Date();
let age = today.getFullYear() - birthDate.getFullYear();
const monthDifference = today.getMonth() - birthDate.getMonth();
if (
monthDifference < 0 ||
(monthDifference === 0 && today.getDate() < birthDate.getDate())
) {
age -= 1;
}
return String(Math.max(age, 0));
}
function getCountryFromStorage(): string {
if (typeof window === "undefined") return "";
try {
const rawValue = window.localStorage.getItem(
"marriage:sections:contact_residence_family_communication:answers",
);
if (!rawValue) return "";
const storedValue = JSON.parse(rawValue);
const field = storedValue.fields?.find(
(f: { type?: string; key?: string; value?: unknown }) =>
f.type === "birthplace" ||
f.key?.includes("current_residence") ||
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(",").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
}
return "";
}
function formatNumberWithCommas(val: string): string {
if (!val) return "";
const parts = val.split(".");
parts[0] = parts[0].replace(/\B(?=(\d{3})+(?!\d))/g, ",");
return parts.join(".");
}