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
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(".");
|
|
}
|