From 615130d2e22ad12de2ca2097b5bc98e6ce884f05 Mon Sep 17 00:00:00 2001 From: "Muhammad A. Ghorbani" Date: Thu, 20 Aug 2026 04:46:21 +0330 Subject: [PATCH] feat: implement dynamic question components and expand multi-language support for form schema definitions --- .../[slug]/question-detail-client.tsx | 37 +- .../Componentes/question-answer-storage.tsx | 65 +- .../Componentes/question-checkbox.tsx | 5 +- src/components/Componentes/question-radio.tsx | 9 +- src/components/Componentes/question-text.tsx | 20 +- .../Componentes/question-textarea.tsx | 19 +- src/components/Componentes/question-title.tsx | 23 +- src/hooks/marriage/use-form-schema.ts | 4 +- src/hooks/marriage/use-section-data.ts | 2 +- src/lib/conditional-rules.test.ts | 568 ++++++++++++++++++ src/lib/schema-adapter.ts | 16 +- src/translations/locales/ar.json | 30 +- src/translations/locales/az.json | 26 +- src/translations/locales/bn.json | 26 +- src/translations/locales/da.json | 28 +- src/translations/locales/de.json | 28 +- src/translations/locales/en.json | 16 +- src/translations/locales/es.json | 26 +- src/translations/locales/fa.json | 30 +- src/translations/locales/fr.json | 26 +- src/translations/locales/gu.json | 28 +- src/translations/locales/ha.json | 28 +- src/translations/locales/he.json | 28 +- src/translations/locales/hi.json | 30 +- src/translations/locales/id.json | 28 +- src/translations/locales/ks.json | 28 +- src/translations/locales/pt.json | 28 +- src/translations/locales/ru.json | 30 +- src/translations/locales/sw.json | 28 +- src/translations/locales/tg.json | 28 +- src/translations/locales/tr.json | 28 +- src/translations/locales/ul.json | 28 +- src/translations/locales/ur.json | 28 +- src/translations/locales/uz.json | 28 +- src/translations/locales/zh.json | 28 +- 35 files changed, 1327 insertions(+), 101 deletions(-) diff --git a/src/app/questions-list/[slug]/question-detail-client.tsx b/src/app/questions-list/[slug]/question-detail-client.tsx index 0584a18..b296194 100644 --- a/src/app/questions-list/[slug]/question-detail-client.tsx +++ b/src/app/questions-list/[slug]/question-detail-client.tsx @@ -104,14 +104,47 @@ function QuestionFlowWrapper({ const { getAnswerValue, answers } = useQuestionAnswers(); const { data: profile } = useMarriageProfileQuery(); + const computedAge = useMemo(() => { + if (typeof profile?.age === "number" && !isNaN(profile.age)) { + return profile.age; + } + const dobAnswer = + answers["personal_identity.date_of_birth"] || + answers["personal_info.date_of_birth"] || + answers["date_of_birth"] || + Object.entries(answers).find( + ([k]) => k.includes("date_of_birth") || k.includes("birth_date"), + )?.[1]; + + const dobVal = + typeof dobAnswer === "object" && dobAnswer !== null && "value" in dobAnswer + ? dobAnswer.value + : dobAnswer; + + if (dobVal && typeof dobVal === "string") { + const parts = dobVal.replace(/\//g, "-").split("-"); + if (parts[0] && !isNaN(Number(parts[0]))) { + const y = Number(parts[0]); + if (y >= 1300 && y <= 1500) { + return Math.max(18, 1403 - y); + } else if (y >= 1900 && y <= 2100) { + const currentYear = new Date().getFullYear(); + return Math.max(18, currentYear - y); + } + } + } + return undefined; + }, [profile?.age, answers]); + const userContext = useMemo( () => ({ gender: profile?.gender, - age: profile?.age, + age: computedAge, }), - [profile?.gender, profile?.age], + [profile?.gender, computedAge], ); + const dynamicQuestions = useMemo(() => { return questions .filter((q) => isQuestionVisible(q, answers, userContext)) diff --git a/src/components/Componentes/question-answer-storage.tsx b/src/components/Componentes/question-answer-storage.tsx index 739633a..d759bf3 100644 --- a/src/components/Componentes/question-answer-storage.tsx +++ b/src/components/Componentes/question-answer-storage.tsx @@ -26,6 +26,7 @@ import { useUpdateMarriageSectionDataMutation, } from "@/hooks/marriage/use-section-data"; import { getApiRequestUrl } from "@/lib/http"; +import { authBridge } from "@/lib/auth-bridge"; import type { QuestionField } from "@/lib/schema-adapter"; import { getScopedSectionDraftKey, @@ -131,14 +132,12 @@ function createQuestionField( ): MarriageField { let option_id: string | string[] | undefined; - if (question.options && Array.isArray(question.options)) { - if (question.type === "checkbox" && Array.isArray(value)) { + if (question.options && Array.isArray(question.options) && question.options.length > 0) { + if (Array.isArray(value)) { option_id = value; - } else { - const selectedOpt = question.options.find((opt) => opt.id === value); - if (selectedOpt) { - option_id = selectedOpt.id; - } + } else if (typeof value === "string" && value) { + const selectedOpt = question.options.find((opt) => opt.id === value || opt.value === value); + option_id = selectedOpt ? selectedOpt.id : value; } } @@ -374,6 +373,15 @@ export function QuestionAnswersProvider({ let finalPendingSync = false; const nextDirtyKeys = new Set(); + // Preserve active in-memory dirty edits so a server query update doesn't overwrite unsynced keystrokes + for (const dirtyKey of dirtyKeysRef.current) { + if (answersRef.current[dirtyKey]) { + finalAnswers[dirtyKey] = answersRef.current[dirtyKey]; + nextDirtyKeys.add(dirtyKey); + finalPendingSync = true; + } + } + // Step 2: If we have a valid profile, read the user-scoped draft if (profileId && canEdit) { const scopedDraft = readScopedSectionDraft(profileId, slug); @@ -526,7 +534,8 @@ export function QuestionAnswersProvider({ const delay = isDebounced ? 1000 : 0; syncTimeoutRef.current = setTimeout(() => { - void flushAnswersRef.current().catch(() => { + void flushAnswersRef.current().catch((err) => { + console.error("[FLUSH] Auto-flush FAILED:", err); // The draft stays pending so the next edit or exit can retry. }); }, delay); @@ -580,13 +589,16 @@ export function QuestionAnswersProvider({ Object.entries(result?.answers ?? {}).forEach(([key, answer]) => { const question = questionById.get(key); if (question) { - nextAnswers[key] = { - key, - label: question.title, - type: question.type, - value: answer.option_id ?? answer.value, - option_id: answer.option_id, - } as MarriageField; + // Only update if there are no newer local dirty edits for this key + if (!dirtyKeysRef.current.has(key)) { + nextAnswers[key] = { + key, + label: question.title, + type: question.type, + value: answer.option_id ?? answer.value, + option_id: answer.option_id, + } as MarriageField; + } } }); cleared.forEach((key) => { @@ -678,11 +690,16 @@ export function QuestionAnswersProvider({ if (csrfToken) { headers["X-CSRFToken"] = csrfToken; } + // Include auth token so the proxy / backend can authenticate the keepalive request + const token = authBridge.getToken(); + if (token) { + headers["Authorization"] = `Token ${token}`; + } const answersPayload = payload.fields.map((f) => ({ question_id: f.key, value: f.value, - option_id: (f as any).option_id || undefined, + option_id: (f as any).option_id ?? undefined, })); fetch(getKeepalivePatchUrl(slugRef.current), { @@ -695,11 +712,14 @@ export function QuestionAnswersProvider({ keepalive: true, method: "PATCH", }) - .then((response) => { + .then(async (response) => { if (!response.ok) { + const text = await response.text().catch(() => ''); + console.error('[KEEPALIVE] Response NOT OK:', response.status, response.statusText, text.slice(0, 500)); return; } + if (revision !== answersRevisionRef.current) { return; } @@ -745,8 +765,17 @@ export function QuestionAnswersProvider({ void queryClient.invalidateQueries({ queryKey: marriageQueryKeys.sectionData(slugRef.current), }); + // Invalidate formSection cache for this slug (all locales) + void queryClient.invalidateQueries({ + queryKey: [...marriageQueryKeys.all, "form-section", "profile", slugRef.current], + }); + // Invalidate formOverview cache (all locales) + void queryClient.invalidateQueries({ + queryKey: [...marriageQueryKeys.all, "form-overview", "profile"], + }); }) - .catch(() => { + .catch((err) => { + console.error('[KEEPALIVE] Fetch FAILED:', err); // The local draft stays marked pending so a later exit can retry. }); }; diff --git a/src/components/Componentes/question-checkbox.tsx b/src/components/Componentes/question-checkbox.tsx index 5a784c6..04c8445 100644 --- a/src/components/Componentes/question-checkbox.tsx +++ b/src/components/Componentes/question-checkbox.tsx @@ -41,7 +41,8 @@ export function QuestionCheckbox({ }; const isShortOptions = - options.length <= 4 && options.every((opt) => opt.label.length <= 15); + options.length <= 4 && + options.every((opt) => (opt.label || opt.value || "").length <= 15); return (
)} - {option.label} + {option.label || option.value || ""} ); })} diff --git a/src/components/Componentes/question-radio.tsx b/src/components/Componentes/question-radio.tsx index 327e133..34ad9b0 100644 --- a/src/components/Componentes/question-radio.tsx +++ b/src/components/Componentes/question-radio.tsx @@ -24,7 +24,8 @@ export function QuestionRadio({ // Render horizontally if all options are short (e.g. Single, Divorced, Widowed) const isShortOptions = - options.length <= 4 && options.every((opt) => opt.label.length <= 15); + options.length <= 4 && + options.every((opt) => (opt.label || opt.value || "").length <= 15); return (
)} - {option.label.includes(" - ") ? ( + {(option.label || "").includes(" - ") ? ( (() => { - const parts = option.label.split(" - "); + const parts = (option.label || "").split(" - "); const title = parts[0]; const description = parts.slice(1).join(" - "); return ( @@ -103,7 +104,7 @@ export function QuestionRadio({ ); })() ) : ( - {option.label} + {option.label || option.value || ""} )} ); diff --git a/src/components/Componentes/question-text.tsx b/src/components/Componentes/question-text.tsx index 360dcd6..2d9c57b 100644 --- a/src/components/Componentes/question-text.tsx +++ b/src/components/Componentes/question-text.tsx @@ -36,11 +36,22 @@ export default function QuestionText({ isMuted ? "" : String(value ?? ""), ); const debounceTimerRef = useRef(null); + const isFocusedRef = useRef(false); useEffect(() => { - setLocalValue(isMuted ? "" : String(value ?? "")); + if (!isFocusedRef.current) { + setLocalValue(isMuted ? "" : String(value ?? "")); + } }, [value, isMuted]); + useEffect(() => { + return () => { + if (debounceTimerRef.current) { + clearTimeout(debounceTimerRef.current); + } + }; + }, []); + const isNumericQuestion = question.type === "number" || question.validation?.format === "number"; const handleChange = (val: string) => { @@ -60,7 +71,12 @@ export default function QuestionText({ }, 300); }; + const handleFocus = () => { + isFocusedRef.current = true; + }; + const handleBlur = () => { + isFocusedRef.current = false; if (debounceTimerRef.current) { clearTimeout(debounceTimerRef.current); } @@ -99,6 +115,7 @@ export default function QuestionText({ hasError={showInvalidState} value={localValue} onChange={(e) => handleChange(e.target.value)} + onFocus={handleFocus} onBlur={handleBlur} placeholder={question.extras.placeHolder} disabled={disabled || isMuted} @@ -136,6 +153,7 @@ export default function QuestionText({ pattern={isNumericQuestion ? "[0-9]*" : undefined} value={localValue} onChange={(e) => handleChange(e.target.value)} + onFocus={handleFocus} onBlur={handleBlur} placeholder={question.extras.placeHolder} disabled={disabled || isMuted} diff --git a/src/components/Componentes/question-textarea.tsx b/src/components/Componentes/question-textarea.tsx index 4ebfbda..40e656c 100644 --- a/src/components/Componentes/question-textarea.tsx +++ b/src/components/Componentes/question-textarea.tsx @@ -25,11 +25,22 @@ export function QuestionTextarea({ const [localValue, setLocalValue] = useState(String(value ?? "")); const debounceTimerRef = useRef(null); + const isFocusedRef = useRef(false); useEffect(() => { - setLocalValue(String(value ?? "")); + if (!isFocusedRef.current) { + setLocalValue(String(value ?? "")); + } }, [value]); + useEffect(() => { + return () => { + if (debounceTimerRef.current) { + clearTimeout(debounceTimerRef.current); + } + }; + }, []); + const handleChange = (e: React.ChangeEvent) => { const val = e.target.value; setLocalValue(val); @@ -43,7 +54,12 @@ export function QuestionTextarea({ }, 300); }; + const handleFocus = () => { + isFocusedRef.current = true; + }; + const handleBlur = () => { + isFocusedRef.current = false; if (debounceTimerRef.current) { clearTimeout(debounceTimerRef.current); } @@ -67,6 +83,7 @@ export function QuestionTextarea({