From 626e807d87c0fa86e7cc9a15cb3d2a161268d3a9 Mon Sep 17 00:00:00 2001 From: ghorbani Date: Mon, 10 Aug 2026 14:45:38 +0330 Subject: [PATCH] feat: implement question component suite with internationalization support and dynamic help modals --- next.config.ts | 1 + .../Componentes/question-answer-storage.tsx | 13 +++- src/components/Componentes/question-text.tsx | 67 ++++++++++++++++--- .../Componentes/question-textarea.tsx | 37 ++++++++-- src/components/Componentes/question-title.tsx | 17 ++++- src/translations/locales/en.json | 2 +- src/translations/locales/fa.json | 2 +- 7 files changed, 120 insertions(+), 19 deletions(-) diff --git a/next.config.ts b/next.config.ts index 7324719..1401522 100644 --- a/next.config.ts +++ b/next.config.ts @@ -5,6 +5,7 @@ const nextConfig: NextConfig = { output: "standalone", allowedDevOrigins: ["192.168.1.64"], turbopack: {}, + devIndicators: false, // Compression compress: true, diff --git a/src/components/Componentes/question-answer-storage.tsx b/src/components/Componentes/question-answer-storage.tsx index b2e2e7a..d025ea7 100644 --- a/src/components/Componentes/question-answer-storage.tsx +++ b/src/components/Componentes/question-answer-storage.tsx @@ -417,7 +417,18 @@ export function QuestionAnswersProvider({ answersRef.current = finalAnswers; hasPendingSyncRef.current = finalPendingSync; - setAnswers(finalAnswers); + + setAnswers((prev) => { + const prevKeys = Object.keys(prev); + const nextKeys = Object.keys(finalAnswers); + if (prevKeys.length === nextKeys.length) { + const isSame = prevKeys.every( + (k) => prev[k]?.value === finalAnswers[k]?.value + ); + if (isSame) return prev; + } + return finalAnswers; + }); setHasPendingSync(finalPendingSync); // Update localStorage to stay in sync diff --git a/src/components/Componentes/question-text.tsx b/src/components/Componentes/question-text.tsx index c145d66..1e19ea4 100644 --- a/src/components/Componentes/question-text.tsx +++ b/src/components/Componentes/question-text.tsx @@ -1,5 +1,6 @@ "use client"; +import { useEffect, useRef, useState } from "react"; import type { QuestionField } from "@/data/question-data"; import { useI18n } from "@/translations/provider"; import { useQuestionAnswers } from "./question-answer-storage"; @@ -13,6 +14,12 @@ type QuestionTextProps = { heightClassName?: string; }; +function toEnglishDigits(str: string): string { + return str + .replace(/[٠-٩]/g, (d) => String(d.charCodeAt(0) - 1632)) + .replace(/[۰-۹]/g, (d) => String(d.charCodeAt(0) - 1776)); +} + export default function QuestionText({ question, questionIndex, @@ -25,7 +32,44 @@ export default function QuestionText({ const value = getAnswerValue(question, questionIndex); const isMuted = value === "-"; - const stringValue = String(value ?? "").trim(); + const [localValue, setLocalValue] = useState( + isMuted ? "" : String(value ?? ""), + ); + const debounceTimerRef = useRef(null); + + useEffect(() => { + setLocalValue(isMuted ? "" : String(value ?? "")); + }, [value, isMuted]); + + const enTitleLower = (question.englishTitle || question.title).toLowerCase(); + const isNumericQuestion = + question.type === "number" || enTitleLower.includes("duration"); + + const handleChange = (val: string) => { + let nextVal = val; + if (isNumericQuestion) { + nextVal = toEnglishDigits(nextVal).replace(/[^\d]/g, ""); + } + + setLocalValue(nextVal); + + if (debounceTimerRef.current) { + clearTimeout(debounceTimerRef.current); + } + + debounceTimerRef.current = setTimeout(() => { + setAnswerValue(question, questionIndex, nextVal); + }, 300); + }; + + const handleBlur = () => { + if (debounceTimerRef.current) { + clearTimeout(debounceTimerRef.current); + } + setAnswerValue(question, questionIndex, localValue); + }; + + const stringValue = localValue.trim(); const isEmailQuestion = (question.englishTitle || question.title) .toLowerCase() .includes("email"); @@ -65,10 +109,9 @@ export default function QuestionText({ > - setAnswerValue(question, questionIndex, e.target.value) - } + value={localValue} + onChange={(e) => handleChange(e.target.value)} + onBlur={handleBlur} placeholder={question.extras.placeHolder} disabled={disabled || isMuted} className="h-full w-full border-0 bg-transparent px-4.5 text-[15px] font-medium text-[#181818] outline-none placeholder:text-[#98A2B3] disabled:opacity-50 disabled:cursor-not-allowed" @@ -101,11 +144,12 @@ export default function QuestionText({ > - setAnswerValue(question, questionIndex, e.target.value) - } + type={isNumericQuestion ? "tel" : "text"} + inputMode={isNumericQuestion ? "numeric" : undefined} + pattern={isNumericQuestion ? "[0-9]*" : undefined} + value={localValue} + onChange={(e) => handleChange(e.target.value)} + onBlur={handleBlur} placeholder={question.extras.placeHolder} disabled={disabled || isMuted} className={[ @@ -124,6 +168,9 @@ export default function QuestionText({ type="checkbox" checked={isMuted} onChange={(e) => { + if (debounceTimerRef.current) { + clearTimeout(debounceTimerRef.current); + } if (e.target.checked) { setAnswerValue(question, questionIndex, "-"); } else { diff --git a/src/components/Componentes/question-textarea.tsx b/src/components/Componentes/question-textarea.tsx index d2890e1..0fc0dc0 100644 --- a/src/components/Componentes/question-textarea.tsx +++ b/src/components/Componentes/question-textarea.tsx @@ -1,5 +1,6 @@ "use client"; +import { useEffect, useRef, useState } from "react"; import type { QuestionField } from "@/data/question-data"; import { useI18n } from "@/translations/provider"; import { useQuestionAnswers } from "./question-answer-storage"; @@ -22,7 +23,34 @@ export function QuestionTextarea({ const { getAnswerValue, setAnswerValue } = useQuestionAnswers(); const value = getAnswerValue(question, questionIndex); - const stringValue = String(value ?? "").trim(); + const [localValue, setLocalValue] = useState(String(value ?? "")); + const debounceTimerRef = useRef(null); + + useEffect(() => { + setLocalValue(String(value ?? "")); + }, [value]); + + const handleChange = (e: React.ChangeEvent) => { + const val = e.target.value; + setLocalValue(val); + + if (debounceTimerRef.current) { + clearTimeout(debounceTimerRef.current); + } + + debounceTimerRef.current = setTimeout(() => { + setAnswerValue(question, questionIndex, val); + }, 300); + }; + + const handleBlur = () => { + if (debounceTimerRef.current) { + clearTimeout(debounceTimerRef.current); + } + setAnswerValue(question, questionIndex, localValue); + }; + + const stringValue = localValue.trim(); const isAnswered = question.required === false ? true : stringValue.length > 0; @@ -37,10 +65,9 @@ export function QuestionTextarea({ >