Browse Source

feat: implement question component suite with internationalization support and dynamic help modals

front-test-2
ghorbani 2 weeks ago
parent
commit
626e807d87
  1. 1
      next.config.ts
  2. 13
      src/components/Componentes/question-answer-storage.tsx
  3. 67
      src/components/Componentes/question-text.tsx
  4. 37
      src/components/Componentes/question-textarea.tsx
  5. 17
      src/components/Componentes/question-title.tsx
  6. 2
      src/translations/locales/en.json
  7. 2
      src/translations/locales/fa.json

1
next.config.ts

@ -5,6 +5,7 @@ const nextConfig: NextConfig = {
output: "standalone",
allowedDevOrigins: ["192.168.1.64"],
turbopack: {},
devIndicators: false,
// Compression
compress: true,

13
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

67
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<NodeJS.Timeout | null>(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({
>
<input
type="text"
value={isMuted ? "" : String(value ?? "")}
onChange={(e) =>
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({
>
<QuestionTitle question={question} />
<input
type="text"
value={isMuted ? "" : String(value ?? "")}
onChange={(e) =>
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 {

37
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<NodeJS.Timeout | null>(null);
useEffect(() => {
setLocalValue(String(value ?? ""));
}, [value]);
const handleChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
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({
>
<QuestionTitle question={question} />
<textarea
value={String(value ?? "")}
onChange={(e) =>
setAnswerValue(question, questionIndex, e.target.value)
}
value={localValue}
onChange={handleChange}
onBlur={handleBlur}
placeholder={question.extras.placeHolder}
disabled={disabled}
rows={5}

17
src/components/Componentes/question-title.tsx

@ -20,13 +20,28 @@ export function QuestionTitle({ question, className }: QuestionTitleProps) {
question.title === "Monthly Income" ||
question.title === "میزان درآمد ماهانه";
const isPreviousMarriageDuration =
question.title === "Previous Marriage Duration" ||
question.title === "مدت ازدواج یا عقد قبلی" ||
(question.englishTitle || question.title)
.toLowerCase()
.includes("marriage duration");
const alreadyHasUnit =
question.title.includes("Years") ||
question.title.includes("سال");
const titleText =
question.title +
(isMonthlyIncome
? locale === "fa"
? " (تقریبی)"
: " (approximately)"
: "");
: isPreviousMarriageDuration && !alreadyHasUnit
? locale === "fa"
? " (سال)"
: " (Years)"
: "");
const words = titleText.split(" ");
const lastWord = words[words.length - 1];
const remainingTitle = words.slice(0, -1).join(" ");

2
src/translations/locales/en.json

@ -486,7 +486,7 @@
"Prefer non-political": "Prefer non-political",
"Prefer not to continue after marriage": "Prefer not to continue after marriage",
"Preference for Living with Family": "Preference for Living with Family",
"Previous Marriage Duration": "Previous Marriage Duration",
"Previous Marriage Duration": "Previous Marriage Duration (Years)",
"Previous Page": "Previous Page",
"Private (Advisors Only)": "Private (Advisors Only)",
"Processing / Pending Residence Status": "Processing / Pending Residence Status",

2
src/translations/locales/fa.json

@ -486,7 +486,7 @@
"Prefer non-political": "ترجیح می‌دهم سیاسی نباشد.",
"Prefer not to continue after marriage": "ترجیح می‌دهم بعد از ازدواج ادامه تحصیل ندهد.",
"Preference for Living with Family": "زندگی با خانواده بعد از ازدواج",
"Previous Marriage Duration": "مدت ازدواج یا عقد قبلی",
"Previous Marriage Duration": "مدت ازدواج یا عقد قبلی (سال)",
"Previous Page": "صفحه قبلی",
"Private (Advisors Only)": "اطلاعات محرمانه (فقط کارشناسان)",
"Processing / Pending Residence Status": "در حال پیگیری وضعیت اقامت",

Loading…
Cancel
Save