Browse Source

feat: implement dynamic question renderer system with specialized input components and multi-step form flow support

front-test-2
ghorbani 2 weeks ago
parent
commit
857537fc73
  1. 11
      src/app/[lang]/questions-list/[slug]/page.tsx
  2. 25
      src/app/questions-list/[slug]/page.tsx
  3. 275
      src/app/questions-list/[slug]/question-detail-client.test.tsx
  4. 237
      src/app/questions-list/[slug]/question-detail-client.tsx
  5. 25
      src/app/questions-list/page.tsx
  6. 6
      src/app/questions-list/sections-request.tsx
  7. 72
      src/components/Componentes/conditional-questions.tsx
  8. 54
      src/components/Componentes/progress-helper.ts
  9. 164
      src/components/Componentes/question-answer-storage.tsx
  10. 260
      src/components/Componentes/question-answer.test.tsx
  11. 12
      src/components/Componentes/question-birthplace.tsx
  12. 6
      src/components/Componentes/question-button.tsx
  13. 2
      src/components/Componentes/question-card.tsx
  14. 49
      src/components/Componentes/question-checkbox.tsx
  15. 12
      src/components/Componentes/question-date.tsx
  16. 188
      src/components/Componentes/question-dropdown.tsx
  17. 20
      src/components/Componentes/question-file.tsx
  18. 38
      src/components/Componentes/question-number.tsx
  19. 8
      src/components/Componentes/question-phone.tsx
  20. 18
      src/components/Componentes/question-photo.tsx
  21. 28
      src/components/Componentes/question-radio.tsx
  22. 29
      src/components/Componentes/question-renderer.tsx
  23. 10
      src/components/Componentes/question-section-flow.tsx
  24. 14
      src/components/Componentes/question-slider.tsx
  25. 33
      src/components/Componentes/question-text.tsx
  26. 10
      src/components/Componentes/question-textarea.tsx
  27. 49
      src/components/Componentes/question-title.tsx
  28. 4
      src/components/Componentes/required-steps-card.tsx
  29. 235
      src/components/Componentes/schema-question-flow.integration.test.tsx
  30. 1
      src/components/Componentes/test-questions-flow.tsx
  31. 114
      src/components/Componentes/ui-config.test.tsx
  32. 1067
      src/data/cattell-fallback.ts
  33. 183
      src/data/glasser-fallback.ts
  34. 345
      src/data/question-data.ts
  35. 1870
      src/data/questions/en.json
  36. 1870
      src/data/questions/fa.json
  37. 47
      src/data/section-slug-map.ts
  38. 4
      src/hooks/marriage/types.ts
  39. 9
      src/hooks/marriage/use-form-schema.ts
  40. 105
      src/hooks/marriage/use-section-data.ts
  41. 132
      src/lib/schema-adapter.ts

11
src/app/[lang]/questions-list/[slug]/page.tsx

@ -1,14 +1,3 @@
import QuestionDetailPage from "@/app/questions-list/[slug]/page"; import QuestionDetailPage from "@/app/questions-list/[slug]/page";
import { getQuestionListItems } from "@/data/question-data";
import { locales } from "@/translations/config";
export function generateStaticParams() {
return locales.flatMap((lang) =>
getQuestionListItems(lang).map((item) => ({
lang,
slug: item.slug,
})),
);
}
export default QuestionDetailPage; export default QuestionDetailPage;

25
src/app/questions-list/[slug]/page.tsx

@ -1,23 +1,7 @@
import { notFound } from "next/navigation";
import {
getQuestionListItemBySlug,
getQuestionListItems,
} from "@/data/question-data";
import { defaultLocale, isLocale, locales } from "@/translations/config";
import { defaultLocale, isLocale } from "@/translations/config";
import { getDictionary } from "@/translations/dictionaries"; import { getDictionary } from "@/translations/dictionaries";
import QuestionDetailClient from "./question-detail-client"; import QuestionDetailClient from "./question-detail-client";
export function generateStaticParams() {
const slugs = new Set(
locales.flatMap((locale) =>
getQuestionListItems(locale).map((item) => item.slug),
),
);
return Array.from(slugs).map((slug) => ({ slug }));
}
type QuestionDetailPageProps = { type QuestionDetailPageProps = {
params: Promise<{ params: Promise<{
lang?: string; lang?: string;
@ -35,15 +19,10 @@ export default async function QuestionDetailPage({
? `/${locale}/questions-list` ? `/${locale}/questions-list`
: "/questions-list"; : "/questions-list";
const t = getDictionary(locale); const t = getDictionary(locale);
const item = getQuestionListItemBySlug(slug, locale);
if (!item) {
notFound();
}
return ( return (
<QuestionDetailClient <QuestionDetailClient
itemSlug={item.slug}
itemSlug={slug}
locale={locale} locale={locale}
questionsListHref={questionsListHref} questionsListHref={questionsListHref}
title={t["Answer at Your Own Pace"]} title={t["Answer at Your Own Pace"]}

275
src/app/questions-list/[slug]/question-detail-client.test.tsx

@ -0,0 +1,275 @@
import { render, screen, fireEvent, waitFor, cleanup } from "@testing-library/react";
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import QuestionDetailClient from "./question-detail-client";
import { useCattellQuestionsQuery } from "@/hooks/marriage/use-cattell";
import { useGlasserQuestionsQuery } from "@/hooks/marriage/use-glasser";
import { useMarriageProfileQuery } from "@/hooks/marriage/use-profile-main";
import { useFormSchemaQuery } from "@/hooks/marriage/use-form-schema";
import { convertSchemaToFrontendItems } from "@/lib/schema-adapter";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
vi.mock("next/navigation", () => ({
useRouter: vi.fn(() => ({ replace: vi.fn(), push: vi.fn() })),
}));
vi.mock("@/translations/provider", () => ({
useI18n: vi.fn(() => ({ locale: "en", dictionary: new Proxy({}, { get: (_, key) => key }) })),
}));
vi.mock("@/hooks/marriage/use-cattell", () => ({
useCattellQuestionsQuery: vi.fn(),
useSubmitCattellAssessmentMutation: vi.fn(() => ({ mutateAsync: vi.fn() })),
}));
vi.mock("@/hooks/marriage/use-glasser", () => ({
useGlasserQuestionsQuery: vi.fn(),
useSubmitGlasserAssessmentMutation: vi.fn(() => ({ mutateAsync: vi.fn() })),
}));
vi.mock("@/hooks/marriage/use-profile-main", () => ({
useMarriageProfileQuery: vi.fn(),
}));
vi.mock("@/hooks/marriage/use-form-schema", () => ({
useFormSchemaQuery: vi.fn(),
}));
vi.mock("@/hooks/marriage/use-habcoin-payment", () => ({
useHabcoinPaymentMutation: vi.fn(() => ({ mutateAsync: vi.fn() })),
}));
vi.mock("@/lib/schema-adapter", () => ({
convertSchemaToFrontendItems: vi.fn(),
}));
describe("QuestionDetailClient Validation", () => {
const mockCattellRefetch = vi.fn();
const mockGlasserRefetch = vi.fn();
afterEach(() => {
cleanup();
});
beforeEach(() => {
vi.clearAllMocks();
(useMarriageProfileQuery as any).mockReturnValue({
data: { age: 30, gender: "male" },
isLoading: false,
});
(useFormSchemaQuery as any).mockReturnValue({
data: {},
isLoading: false,
});
});
const setupTest = (slug: string, cattellData: any, glasserData: any) => {
// Mock schema adapter to return an item for the requested slug
(convertSchemaToFrontendItems as any).mockReturnValue([
{
slug: slug,
title: "Test",
questions: []
}
]);
(useCattellQuestionsQuery as any).mockReturnValue({
data: cattellData,
isLoading: false,
isError: false,
refetch: mockCattellRefetch,
});
(useGlasserQuestionsQuery as any).mockReturnValue({
data: glasserData,
isLoading: false,
isError: false,
refetch: mockGlasserRefetch,
});
const queryClient = new QueryClient({
defaultOptions: {
queries: {
retry: false,
},
},
});
render(
<QueryClientProvider client={queryClient}>
<QuestionDetailClient
closeLabel="Close"
continueLabel="Continue"
description="Desc"
informationLabel="Info"
itemSlug={slug}
questionsListHref="/questions-list"
title="Test"
/>
</QueryClientProvider>
);
// Intro screen might render first; if Start is available, click it to mount questions flow
const startButton = screen.queryByText("Start");
if (startButton) {
fireEvent.click(startButton);
}
};
it("should render correctly when Cattell API data is completely valid", () => {
setupTest("personality_test", {
questions: [
{
question_number: 1,
text: "Valid Question Cattell",
options: [
{ id: "opt_a", label: "Opt1", value: "A" },
{ id: "opt_b", label: "Opt2", value: "B" },
{ id: "opt_c", label: "Opt3", value: "C" },
],
}
]
}, null);
// Retry UI should NOT be present
expect(screen.queryByText("Retry")).toBeNull();
// Question text should be visible
expect(screen.getByText("Valid Question Cattell")).toBeDefined();
});
it("should render Retry UI when Cattell API data is empty", () => {
setupTest("personality_test", { questions: [] }, null);
expect(screen.getAllByText("No questions found for this test.")).toBeDefined();
expect(screen.getAllByText("Retry")).toBeDefined();
});
it("should render Retry UI when Cattell options are invalid (schema failure) and trigger refetch on Retry", async () => {
setupTest("personality_test", {
questions: [
{
question_number: 1,
text: "Invalid Question",
options: [{ label: "Opt1", value: "A" }], // Invalid schema
}
]
}, null);
expect(screen.getAllByText("No questions found for this test.")).toBeDefined();
const retryBtn = screen.getAllByText("Retry")[0];
fireEvent.click(retryBtn);
await waitFor(() => {
expect(mockCattellRefetch).toHaveBeenCalled();
});
});
it("should render correctly when Glasser API data is completely valid", () => {
setupTest("glasser_5_needs_test", null, {
questions: [
{
question_number: 1,
text: "Valid Question Glasser",
factor_code: "SUR",
options: [
{ id: "o1", label: "O1", value: 1 },
{ id: "o2", label: "O2", value: 2 },
{ id: "o3", label: "O3", value: 3 },
{ id: "o4", label: "O4", value: 4 },
{ id: "o5", label: "O5", value: 5 },
],
}
]
});
expect(screen.queryByText("Retry")).toBeNull();
expect(screen.getByText("Valid Question Glasser")).toBeDefined();
});
it("should render Retry UI when Glasser options are invalid (schema failure) and trigger refetch on Retry", async () => {
setupTest("glasser_5_needs_test", null, {
questions: [
{
question_number: 1,
text: "Invalid Question Glasser",
factor_code: "SUR",
options: [
{ label: "O1", value: 1 },
{ label: "O2", value: 2 },
{ label: "O3", value: 3 },
{ label: "O4", value: 4 },
],
}
]
});
expect(screen.getAllByText("No questions found for this test.")).toBeDefined();
const retryBtn = screen.getAllByText("Retry")[0];
fireEvent.click(retryBtn);
await waitFor(() => {
expect(mockGlasserRefetch).toHaveBeenCalled();
});
});
it("should render profile questions using ID-based data flow", () => {
(convertSchemaToFrontendItems as any).mockReturnValue([
{
slug: "profile_test",
title: "Profile Form",
questions: [
{
id: "q_123",
title: "Dynamic ID Question",
type: "text",
order: 1,
required: true,
isVisible: true,
private: false,
description: "",
tooltip: "",
extras: {},
options: []
},
{
id: "q_456",
title: "Another ID Question",
type: "radio",
order: 2,
required: false,
isVisible: true,
private: false,
description: "",
tooltip: "",
extras: {},
options: [
{ id: "opt_1", label: "Yes", value: "yes", order: 1 },
{ id: "opt_2", label: "No", value: "no", order: 2 }
]
}
]
}
]);
const queryClient = new QueryClient({
defaultOptions: { queries: { retry: false } },
});
render(
<QueryClientProvider client={queryClient}>
<QuestionDetailClient
closeLabel="Close"
continueLabel="Continue"
description="Desc"
informationLabel="Info"
itemSlug="profile_test"
questionsListHref="/questions-list"
title="Profile Test"
/>
</QueryClientProvider>
);
// Profile questions render directly, no start button
expect(screen.getByText(/Dynamic ID/)).toBeDefined();
});
});

237
src/app/questions-list/[slug]/question-detail-client.tsx

@ -18,24 +18,16 @@ import TestIntroPage from "@/components/Componentes/test-intro-page";
import TestQuestionsFlow, { import TestQuestionsFlow, {
type TestQuestion, type TestQuestion,
} from "@/components/Componentes/test-questions-flow"; } from "@/components/Componentes/test-questions-flow";
import { cattellFallbackQuestions } from "@/data/cattell-fallback";
import { glasserFallbackQuestions } from "@/data/glasser-fallback";
import { import {
isQuestionListItemVisibleForProfile,
type QuestionField,
} from "@/data/question-data";
import type { MarriageGender } from "@/hooks/marriage/types";
useGlasserQuestionsQuery,
useSubmitGlasserAssessmentMutation,
} from "@/hooks/marriage/use-glasser";
import { import {
useCattellQuestionsQuery, useCattellQuestionsQuery,
useSubmitCattellAssessmentMutation, useSubmitCattellAssessmentMutation,
} from "@/hooks/marriage/use-cattell"; } from "@/hooks/marriage/use-cattell";
import {
useGlasserQuestionsQuery,
useSubmitGlasserAssessmentMutation,
} from "@/hooks/marriage/use-glasser";
import { useMarriageProfileQuery } from "@/hooks/marriage/use-profile-main";
import { useFormSchemaQuery } from "@/hooks/marriage/use-form-schema"; import { useFormSchemaQuery } from "@/hooks/marriage/use-form-schema";
import { convertSchemaToFrontendItems } from "@/lib/schema-adapter";
import { convertSchemaToFrontendItems, type QuestionField } from "@/lib/schema-adapter";
import { defaultLocale, type Locale } from "@/translations/config"; import { defaultLocale, type Locale } from "@/translations/config";
import { useI18n } from "@/translations/provider"; import { useI18n } from "@/translations/provider";
@ -69,102 +61,16 @@ function getQuestionStorageKey(slug: string) {
return `marriage:sections:${slug}:answers`; return `marriage:sections:${slug}:answers`;
} }
function parseStoredAge(value: unknown) {
if (typeof value === "number" && Number.isFinite(value)) {
return value;
}
if (typeof value === "string") {
const trimmedValue = value.trim();
if (!trimmedValue) {
return null;
}
const numericAge = Number(trimmedValue);
if (Number.isFinite(numericAge)) {
return numericAge;
}
const dateOfBirth = new Date(trimmedValue);
if (Number.isNaN(dateOfBirth.getTime())) {
return null;
}
const today = new Date();
let age = today.getFullYear() - dateOfBirth.getFullYear();
const hasBirthdayPassed =
today.getMonth() > dateOfBirth.getMonth() ||
(today.getMonth() === dateOfBirth.getMonth() &&
today.getDate() >= dateOfBirth.getDate());
if (!hasBirthdayPassed) {
age -= 1;
}
return age >= 0 ? age : null;
}
return null;
}
function getStoredAge() {
try {
const rawValue = window.localStorage.getItem(
getQuestionStorageKey("personal_info"),
);
if (!rawValue) {
return null;
}
const storedAnswers = JSON.parse(rawValue) as StoredAnswers;
const ageField = storedAnswers.fields?.find((field) => {
const f = field as { key?: string; type?: string; label?: string };
return (
f.type === "number" ||
f.label === "Age" ||
f.label === "ط³ظ†" ||
(typeof f.key === "string" &&
(f.key.endsWith("_age") || f.key.endsWith("_sn")))
);
});
if (ageField) {
return parseStoredAge(ageField.value);
}
const dateOfBirthField = storedAnswers.fields?.find((field) => {
const f = field as { key?: string; type?: string; label?: string };
return (
f.type === "date" ||
f.label === "Date of Birth" ||
f.label === "طھط§ط±غŒط® طھظˆظ„ط¯" ||
(typeof f.key === "string" &&
(f.key.endsWith("_date_of_birth") || f.key.endsWith("_tarykh_twld")))
);
});
return parseStoredAge(dateOfBirthField?.value);
} catch {
return null;
}
}
function QuestionFlowWrapper({ function QuestionFlowWrapper({
visibleQuestions, visibleQuestions,
itemSlug, itemSlug,
dobQuestion, dobQuestion,
dobQuestionIndex,
continueLabel, continueLabel,
questionsListHref, questionsListHref,
}: { }: {
visibleQuestions: QuestionField[]; visibleQuestions: QuestionField[];
itemSlug: string; itemSlug: string;
dobQuestion?: QuestionField; dobQuestion?: QuestionField;
dobQuestionIndex?: number;
requiredQuestionsCount: number; requiredQuestionsCount: number;
continueLabel: string; continueLabel: string;
questionsListHref: string; questionsListHref: string;
@ -191,22 +97,12 @@ function QuestionFlowWrapper({
questions={dynamicQuestions} questions={dynamicQuestions}
> >
{dynamicQuestions.map((question, index) => { {dynamicQuestions.map((question, index) => {
let originalIndex = visibleQuestions.indexOf(question);
if (originalIndex === -1) {
originalIndex = visibleQuestions.findIndex(
(q) =>
(q.englishTitle || q.title) ===
(question.englishTitle || question.title),
);
}
const answer = getAnswerValue(question, originalIndex);
const answer = getAnswerValue(question);
const hasAnswer = hasQuestionAnswerValue(answer ?? null); const hasAnswer = hasQuestionAnswerValue(answer ?? null);
let isAnswered = hasAnswer; let isAnswered = hasAnswer;
if (hasAnswer) { if (hasAnswer) {
const isEmailQuestion = (question.englishTitle || question.title)
.toLowerCase()
.includes("email");
const isEmailQuestion = question.type === "email" || question.validation?.format === "email";
if (isEmailQuestion) { if (isEmailQuestion) {
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
isAnswered = emailRegex.test(String(answer).trim()); isAnswered = emailRegex.test(String(answer).trim());
@ -222,19 +118,17 @@ function QuestionFlowWrapper({
return ( return (
<div <div
key={`${itemSlug}-${question.title}`}
key={question.id}
data-question-required={String(question.required)} data-question-required={String(question.required)}
data-question-optional={String(!question.required)} data-question-optional={String(!question.required)}
data-question-index={index} data-question-index={index}
data-question-original-index={originalIndex}
data-question-original-index={question.order}
data-question-disabled="false" data-question-disabled="false"
data-question-answered={String(isAnswered)} data-question-answered={String(isAnswered)}
> >
<QuestionRenderer <QuestionRenderer
question={question} question={question}
questionIndex={originalIndex}
dobQuestion={dobQuestion} dobQuestion={dobQuestion}
dobQuestionIndex={dobQuestionIndex}
/> />
</div> </div>
); );
@ -280,11 +174,7 @@ export default function QuestionDetailClient({
} }
}, [itemSlug, isTestStarted]); }, [itemSlug, isTestStarted]);
const { data: profile, isLoading: isProfileLoading } =
useMarriageProfileQuery();
const profileGender = profile?.gender;
const age = getStoredAge();
const { data: schema } = useFormSchemaQuery("profile", locale);
const { data: schema, isLoading: isSchemaLoading } = useFormSchemaQuery("profile", locale);
const items = useMemo(() => convertSchemaToFrontendItems(schema, locale), [schema, locale]); const items = useMemo(() => convertSchemaToFrontendItems(schema, locale), [schema, locale]);
const item = items.find((i) => i.slug === itemSlug); const item = items.find((i) => i.slug === itemSlug);
@ -303,58 +193,44 @@ export default function QuestionDetailClient({
}); });
const submitGlasserMutation = useSubmitGlasserAssessmentMutation(); const submitGlasserMutation = useSubmitGlasserAssessmentMutation();
const profileContext = useMemo(
() => ({
age,
gender: profileGender as MarriageGender | null | undefined,
}),
[age, profileGender],
);
const cattellTestQuestions: TestQuestion[] = useMemo(() => { const cattellTestQuestions: TestQuestion[] = useMemo(() => {
const questionsList =
cattellQuery.data?.questions && cattellQuery.data.questions.length > 0
? cattellQuery.data.questions
: cattellFallbackQuestions;
return questionsList.map((q) => {
const rawOptions =
q.options && q.options.length > 0
? q.options
: locale === "fa"
? ["ط¨ظ„ظ‡", "ط¨ظ‡ ط§ظ†ط¯ط§ط²ظ‡ ع©ط§ظپغŒ ظˆط§ط¶ط­ ظ†غŒط³طھ", "ظ†ظ‡"]
: ["Yes", "Not clear enough", "No"];
const mappedOptions = rawOptions.map((optText, idx) => ({
label: optText,
value: idx === 0 ? "A" : idx === 1 ? "B" : "C",
}));
return {
const questionsList = cattellQuery.data?.questions || [];
// Strict schema validation for Cattell
const isValidCattell = (q: any) =>
q.question_number &&
q.text &&
q.options &&
q.options.length === 3 &&
q.options.every((o: any) => o.id && o.label && o.value);
if (questionsList.length > 0 && !questionsList.every(isValidCattell)) {
console.error("Invalid Cattell API response schema");
return [];
}
return questionsList.map((q) => ({
id: q.question_number, id: q.question_number,
text: q.text, text: q.text,
options: mappedOptions,
};
});
}, [cattellQuery.data, locale]);
options: q.options || [],
}));
}, [cattellQuery.data]);
const glasserTestQuestions: TestQuestion[] = useMemo(() => { const glasserTestQuestions: TestQuestion[] = useMemo(() => {
const questionsList =
glasserQuery.data?.questions && glasserQuery.data.questions.length > 0
? glasserQuery.data.questions
: glasserFallbackQuestions;
const defaultGlasserOptions = [
{
label: locale === "fa" ? "خیلی کم" : "Very Low",
value: 1,
},
{ label: locale === "fa" ? "کم" : "Low", value: 2 },
{ label: locale === "fa" ? "متوسط" : "Moderate", value: 3 },
{ label: locale === "fa" ? "زیاد" : "High", value: 4 },
{
label: locale === "fa" ? "خیلی زیاد" : "Very High",
value: 5,
},
];
const questionsList = glasserQuery.data?.questions || [];
// Strict schema validation for Glasser
const isValidGlasser = (q: any) =>
q.question_number &&
q.text &&
q.options &&
q.options.length === 5 &&
q.options.every((o: any) => o.id && o.label && typeof o.value === 'number' && o.value >= 1 && o.value <= 5);
if (questionsList.length > 0 && !questionsList.every(isValidGlasser)) {
console.error("Invalid Glasser API response schema");
return [];
}
return questionsList.map((q) => ({ return questionsList.map((q) => ({
id: q.question_number, id: q.question_number,
@ -365,9 +241,9 @@ export default function QuestionDetailClient({
: "factor_code" in q : "factor_code" in q
? (q.factor_code as string) ? (q.factor_code as string)
: undefined, : undefined,
options: defaultGlasserOptions,
options: q.options || [],
})); }));
}, [glasserQuery.data, locale]);
}, [glasserQuery.data]);
const visibleQuestions = useMemo(() => { const visibleQuestions = useMemo(() => {
if (!item) { if (!item) {
@ -388,28 +264,19 @@ export default function QuestionDetailClient({
); );
useEffect(() => { useEffect(() => {
if (isProfileLoading) {
return;
}
if (!item || isQuestionListItemVisibleForProfile(item, profileContext)) {
return;
}
if (!isSchemaLoading && !item) {
router.replace(questionsListHref); router.replace(questionsListHref);
}, [isProfileLoading, item, profileContext, questionsListHref, router]);
}
}, [isSchemaLoading, item, questionsListHref, router]);
if (!profile && isProfileLoading && item) {
if (isSchemaLoading) {
return ( return (
<PageLoadingSkeleton <PageLoadingSkeleton
compact compact
variant={isCattellSlug || isGlasserSlug ? "test" : "questions"} variant={isCattellSlug || isGlasserSlug ? "test" : "questions"}
/> />
); );
} else if (
!item ||
!isQuestionListItemVisibleForProfile(item, profileContext)
) {
} else if (!item) {
return null; return null;
} }
@ -706,10 +573,7 @@ export default function QuestionDetailClient({
} }
const dobQuestion = visibleQuestions.find( const dobQuestion = visibleQuestions.find(
(question) => question.title === "Date of Birth",
);
const dobQuestionIndex = visibleQuestions.findIndex(
(question) => question.title === "Date of Birth",
(question) => question.ui_config?.isDob === true || question.type === "date",
); );
return ( return (
@ -746,7 +610,6 @@ export default function QuestionDetailClient({
visibleQuestions={visibleQuestions} visibleQuestions={visibleQuestions}
itemSlug={item.slug} itemSlug={item.slug}
dobQuestion={dobQuestion} dobQuestion={dobQuestion}
dobQuestionIndex={dobQuestionIndex}
requiredQuestionsCount={requiredQuestionsCount} requiredQuestionsCount={requiredQuestionsCount}
continueLabel={continueLabel} continueLabel={continueLabel}
questionsListHref={questionsListHref} questionsListHref={questionsListHref}

25
src/app/questions-list/page.tsx

@ -5,15 +5,12 @@ import { useRouter } from "next/navigation";
import { useEffect, useMemo, useState } from "react"; import { useEffect, useMemo, useState } from "react";
import { IoClose } from "react-icons/io5"; import { IoClose } from "react-icons/io5";
import { getSubmitPath } from "@/lib/get-submit-path"; import { getSubmitPath } from "@/lib/get-submit-path";
import {
getLocalSectionProgress,
getStoredAge,
} from "@/components/Componentes/progress-helper";
import QuestionCard from "@/components/Componentes/question-card"; import QuestionCard from "@/components/Componentes/question-card";
import RequiredStepsCard from "@/components/Componentes/required-steps-card"; import RequiredStepsCard from "@/components/Componentes/required-steps-card";
import Button from "@/components/Componentes/button"; import Button from "@/components/Componentes/button";
import InformationSheet from "@/components/Componentes/information-sheet"; import InformationSheet from "@/components/Componentes/information-sheet";
import NavigationButton from "@/components/Componentes/navigation-button"; import NavigationButton from "@/components/Componentes/navigation-button";
import { getQuestionAnswersStorageKey } from "@/components/Componentes/question-answer-storage";
import { FixToTheEnd } from "@/components/Componentes/fix-to-the-end"; import { FixToTheEnd } from "@/components/Componentes/fix-to-the-end";
import { LoadingSkeleton } from "@/components/Componentes/loading-skeleton"; import { LoadingSkeleton } from "@/components/Componentes/loading-skeleton";
import { PageBackground } from "@/components/Componentes/page-background"; import { PageBackground } from "@/components/Componentes/page-background";
@ -24,7 +21,7 @@ import { useMarriageProfileQuery } from "@/hooks/marriage/use-profile-main";
import { useMarriageSectionsQuery } from "@/hooks/marriage/use-sections"; import { useMarriageSectionsQuery } from "@/hooks/marriage/use-sections";
import { useFormSchemaQuery } from "@/hooks/marriage/use-form-schema"; import { useFormSchemaQuery } from "@/hooks/marriage/use-form-schema";
import { convertSchemaToFrontendItems } from "@/lib/schema-adapter"; import { convertSchemaToFrontendItems } from "@/lib/schema-adapter";
import type { QuestionListItem } from "@/data/question-data";
import type { QuestionListItem } from "@/lib/schema-adapter";
import { useCloseServiceOnBack } from "@/hooks/use-close-service-on-back"; import { useCloseServiceOnBack } from "@/hooks/use-close-service-on-back";
import { import {
clearMatchStartGrace, clearMatchStartGrace,
@ -155,21 +152,11 @@ export default function QuestionsListPage() {
setIsSyncError(false); setIsSyncError(false);
try { try {
const slugsToCheck = [
"personal_info",
"contact_residence_family_communication",
"appearance_health_activity",
"education_career_economic_status",
"family_marital_history",
"beliefs_lifestyle_boundaries",
"future_spouse_criteria",
"identity_verification",
];
const slugsToCheck = questionListItems.map(item => item.slug);
for (const slug of slugsToCheck) { for (const slug of slugsToCheck) {
const rawValue = window.localStorage.getItem(
`marriage:sections:${slug}:answers`,
);
const storageKey = getQuestionAnswersStorageKey(slug);
const rawValue = window.localStorage.getItem(storageKey);
if (rawValue) { if (rawValue) {
const storedValue = JSON.parse(rawValue); const storedValue = JSON.parse(rawValue);
if (storedValue.pending_sync && storedValue.fields) { if (storedValue.pending_sync && storedValue.fields) {
@ -184,7 +171,7 @@ export default function QuestionsListPage() {
storedValue.pending_sync = false; storedValue.pending_sync = false;
window.localStorage.setItem( window.localStorage.setItem(
`marriage:sections:${slug}:answers`,
storageKey,
JSON.stringify(storedValue), JSON.stringify(storedValue),
); );
} }

6
src/app/questions-list/sections-request.tsx

@ -4,9 +4,13 @@ import { useEffect, useMemo, useState } from "react";
import { IoClose } from "react-icons/io5"; import { IoClose } from "react-icons/io5";
import Button from "@/components/Componentes/button"; import Button from "@/components/Componentes/button";
import InformationSheet from "@/components/Componentes/information-sheet"; import InformationSheet from "@/components/Componentes/information-sheet";
import { bookingTerms } from "@/data/question-data";
import { useMarriageSectionsQuery } from "@/hooks/marriage/use-sections"; import { useMarriageSectionsQuery } from "@/hooks/marriage/use-sections";
const bookingTerms = [
"All provided information is held in strict confidence.",
"Data is utilized exclusively to ensure optimal matchmaking accuracy.",
];
const FIRST_ENTRY_TERMS_SEEN_KEY = "marriage:first-entry-terms-seen"; const FIRST_ENTRY_TERMS_SEEN_KEY = "marriage:first-entry-terms-seen";
const FIRST_ENTRY_TERMS = [ const FIRST_ENTRY_TERMS = [

72
src/components/Componentes/conditional-questions.tsx

@ -1,72 +0,0 @@
"use client";
import { useQuestionAnswers } from "./question-answer-storage";
import type { QuestionField } from "@/data/question-data";
import QuestionRenderer from "./question-renderer";
type ConditionalQuestionsProps = {
parentQuestion: QuestionField;
parentQuestionIndex: number;
rules: {
values: string[];
subQuestions: QuestionField[];
}[];
dobQuestion?: QuestionField;
dobQuestionIndex?: number;
};
export function ConditionalQuestions({
parentQuestion,
parentQuestionIndex,
rules,
dobQuestion,
dobQuestionIndex,
}: ConditionalQuestionsProps) {
const { getAnswerValue } = useQuestionAnswers();
const parentAnswer = getAnswerValue(parentQuestion, parentQuestionIndex);
// Find if there is a matching rule for the current parent answer
const matchedRule = rules.find((rule) => {
return rule.values.includes(String(parentAnswer));
});
if (!matchedRule) {
return (
<QuestionRenderer
question={parentQuestion}
questionIndex={parentQuestionIndex}
dobQuestion={dobQuestion}
dobQuestionIndex={dobQuestionIndex}
/>
);
}
return (
<div className="flex flex-col gap-5 w-full">
<QuestionRenderer
question={parentQuestion}
questionIndex={parentQuestionIndex}
dobQuestion={dobQuestion}
dobQuestionIndex={dobQuestionIndex}
/>
<div className="flex flex-col gap-5 pl-4 border-l-2 border-[#F2465F]/20 mt-2 animate-in fade-in slide-in-from-top-4 duration-300">
{matchedRule.subQuestions.map((subQuestion, index) => {
// Sub-question index is calculated uniquely to prevent conflicts
const subIndex = parentQuestionIndex * 100 + index + 1;
return (
<div key={subQuestion.title} className="w-full">
<QuestionRenderer
question={subQuestion}
questionIndex={subIndex}
dobQuestion={dobQuestion}
dobQuestionIndex={dobQuestionIndex}
/>
</div>
);
})}
</div>
</div>
);
}
export default ConditionalQuestions;

54
src/components/Componentes/progress-helper.ts

@ -1,54 +0,0 @@
import { type QuestionListItem } from "@/data/question-data";
import type { MarriageGender } from "@/hooks/marriage/types";
export function getStoredAge(): number | null {
try {
if (typeof window === "undefined") return null;
const rawValue = window.localStorage.getItem(
"marriage:sections:personal_info:answers",
);
if (!rawValue) return null;
const storedAnswers = JSON.parse(rawValue);
const ageField = storedAnswers.fields?.find(
(f: Record<string, unknown>) =>
f.type === "number" ||
f.label === "Age" ||
f.label === "سن" ||
(typeof f.key === "string" &&
(f.key.endsWith("_age") || f.key.endsWith("_sn"))),
);
if (ageField && ageField.value !== undefined && ageField.value !== null) {
const num = Number(ageField.value);
if (Number.isFinite(num)) return num;
}
const dobField = storedAnswers.fields?.find(
(f: Record<string, unknown>) =>
f.type === "date" ||
f.label === "Date of Birth" ||
f.label === "تاریخ تولد" ||
(typeof f.key === "string" &&
(f.key.endsWith("_date_of_birth") || f.key.endsWith("_tarykh_twld"))),
);
if (dobField?.value) {
const dob = new Date(String(dobField.value));
if (!Number.isNaN(dob.getTime())) {
const today = new Date();
let age = today.getFullYear() - dob.getFullYear();
const m = today.getMonth() - dob.getMonth();
if (m < 0 || (m === 0 && today.getDate() < dob.getDate())) {
age--;
}
return age >= 0 ? age : null;
}
}
} catch (_e) {}
return null;
}
export function getLocalSectionProgress(
item: QuestionListItem,
profile: { gender?: MarriageGender | null } | null | undefined,
age: number | null,
): number | null {
return null;
}

164
src/components/Componentes/question-answer-storage.tsx

@ -11,8 +11,7 @@ import {
useRef, useRef,
useState, useState,
} from "react"; } from "react";
import type { QuestionField } from "@/data/question-data";
import { toBackendSlug } from "@/data/section-slug-map";
import type { QuestionField } from "@/lib/schema-adapter";
import { pathParam } from "@/hooks/marriage/path-param"; import { pathParam } from "@/hooks/marriage/path-param";
import { marriageQueryKeys } from "@/hooks/marriage/query-keys"; import { marriageQueryKeys } from "@/hooks/marriage/query-keys";
import type { import type {
@ -28,7 +27,7 @@ import {
} from "@/hooks/marriage/use-section-data"; } from "@/hooks/marriage/use-section-data";
import { getApiRequestUrl } from "@/lib/http"; import { getApiRequestUrl } from "@/lib/http";
const STORAGE_VERSION = 1;
const STORAGE_VERSION = 2;
type QuestionAnswersByKey = Record<string, MarriageField>; type QuestionAnswersByKey = Record<string, MarriageField>;
@ -49,14 +48,12 @@ type QuestionAnswersContextValue = {
flushAnswers: (options?: FlushAnswersOptions) => Promise<void>; flushAnswers: (options?: FlushAnswersOptions) => Promise<void>;
getAnswerValue: ( getAnswerValue: (
question: QuestionField, question: QuestionField,
questionIndex: number,
) => MarriageFieldValue | undefined; ) => MarriageFieldValue | undefined;
hasPendingSync: boolean; hasPendingSync: boolean;
isSaving: boolean; isSaving: boolean;
isLoading: boolean; isLoading: boolean;
setAnswerValue: ( setAnswerValue: (
question: QuestionField, question: QuestionField,
questionIndex: number,
value: MarriageFieldValue, value: MarriageFieldValue,
) => void; ) => void;
backendFields: MarriageField[]; backendFields: MarriageField[];
@ -71,37 +68,8 @@ type QuestionAnswersProviderProps = {
const QuestionAnswersContext = const QuestionAnswersContext =
createContext<QuestionAnswersContextValue | null>(null); createContext<QuestionAnswersContextValue | null>(null);
function hashString(value: string) {
let hash = 0;
for (let index = 0; index < value.length; index += 1) {
hash = (hash * 31 + value.charCodeAt(index)) >>> 0;
}
return hash.toString(36);
}
function slugifyQuestionTitle(title: string) {
const slug = title
.normalize("NFKD")
.replace(/[\u0300-\u036f]/g, "")
.toLowerCase()
.replace(/[^a-z0-9]+/g, "_")
.replace(/^_+|_+$/g, "");
return slug || `field_${hashString(title)}`;
}
function getQuestionFieldKey(question: QuestionField, questionIndex: number) {
const index =
question.originalIndex !== undefined
? question.originalIndex
: questionIndex;
return `q${index + 1}_${slugifyQuestionTitle(question.englishTitle || question.title)}`;
}
export function getQuestionAnswersStorageKey(slug: string) { export function getQuestionAnswersStorageKey(slug: string) {
return `marriage:sections:${slug}:answers`;
return `marriage:sections:${slug}:answers:v${STORAGE_VERSION}`;
} }
export function hasQuestionAnswerValue(value: MarriageFieldValue) { export function hasQuestionAnswerValue(value: MarriageFieldValue) {
@ -151,70 +119,26 @@ function isMarriagePhoneFieldValue(
); );
} }
function findQuestionFieldKey(
question: QuestionField,
questionIndex: number,
answers?: QuestionAnswersByKey,
backendFields?: MarriageField[],
): string {
const legacyKey = getQuestionFieldKey(question, questionIndex);
if (backendFields && backendFields.length > 0) {
const engSlug = slugifyQuestionTitle(question.englishTitle || question.title);
// 1. Try to match by slug
const matchBySlug = backendFields.find((f) =>
typeof f.key === "string" && (f.key.endsWith(`.${engSlug}`) || f.key.endsWith(`_${engSlug}`))
);
if (matchBySlug) return matchBySlug.key;
// 2. Try to match by label
const matchByLabel = backendFields.find((f) =>
f.label === question.title || f.label === question.englishTitle
);
if (matchByLabel) return matchByLabel.key;
// 3. Fallback to index
if (backendFields[questionIndex]) {
return backendFields[questionIndex].key;
}
}
if (!answers) return legacyKey;
const engSlug = slugifyQuestionTitle(question.englishTitle || question.title);
const foundEntry = Object.values(answers).find(
(f) =>
f &&
(f.key === legacyKey ||
f.label === question.title ||
f.label === question.englishTitle ||
(typeof f.key === "string" &&
(f.key.endsWith(`.${engSlug}`) || f.key.endsWith(`_${engSlug}`)))),
);
return foundEntry?.key || legacyKey;
}
function createQuestionField( function createQuestionField(
question: QuestionField, question: QuestionField,
questionIndex: number,
value: MarriageFieldValue, value: MarriageFieldValue,
currentAnswers?: QuestionAnswersByKey,
backendFields?: MarriageField[],
): MarriageField { ): MarriageField {
const backendId = (question as any).backendId;
const backendOptions = (question as any).backendOptions;
let option_id = undefined; let option_id = undefined;
if (backendOptions && Array.isArray(backendOptions)) {
const selectedOpt = backendOptions.find((opt: any) => opt.label === value || opt.id === value || opt.value === value);
if (question.options && Array.isArray(question.options)) {
if (question.type === "checkbox" && Array.isArray(value)) {
option_id = value;
} else {
const selectedOpt = question.options.find((opt) => opt.id === value);
if (selectedOpt) { if (selectedOpt) {
option_id = selectedOpt.id; option_id = selectedOpt.id;
} }
} }
}
const key = backendId || findQuestionFieldKey(question, questionIndex, currentAnswers, backendFields);
const key = question.id;
return { return {
key, key,
@ -234,8 +158,8 @@ function getOrderedFields(
const orderedFields: MarriageField[] = []; const orderedFields: MarriageField[] = [];
const orderedKeys = new Set<string>(); const orderedKeys = new Set<string>();
questions.forEach((question, index) => {
const key = findQuestionFieldKey(question, index, answers, backendFields);
questions.forEach((question) => {
const key = question.id;
const field = answers[key]; const field = answers[key];
if (field) { if (field) {
@ -258,16 +182,11 @@ function getCurrentStep(
questions: readonly QuestionField[], questions: readonly QuestionField[],
backendFields?: MarriageField[], backendFields?: MarriageField[],
) { ) {
return questions.filter((question, index) => {
if (!question.required || question.logic?.dependsOn) {
return questions.filter((question) => {
if (!question.required || !question.isVisible) {
return false; return false;
} }
const key = findQuestionFieldKey(
question,
index,
fieldsToAnswers(fields),
backendFields
);
const key = question.id;
const field = fields.find((f) => f.key === key); const field = fields.find((f) => f.key === key);
return field && hasQuestionAnswerValue(field.value); return field && hasQuestionAnswerValue(field.value);
}).length; }).length;
@ -280,7 +199,7 @@ function createPayload(
): UpdateMarriageSectionDataPayload { ): UpdateMarriageSectionDataPayload {
const fields = getOrderedFields(answers, questions, backendFields); const fields = getOrderedFields(answers, questions, backendFields);
const targetQuestions = questions.filter( const targetQuestions = questions.filter(
(q) => q.required && !q.logic?.dependsOn,
(q) => q.required && q.isVisible,
); );
return { return {
@ -292,7 +211,14 @@ function createPayload(
function fieldsToAnswers(fields: MarriageField[]) { function fieldsToAnswers(fields: MarriageField[]) {
return fields.reduce<QuestionAnswersByKey>((nextAnswers, field) => { return fields.reduce<QuestionAnswersByKey>((nextAnswers, field) => {
if (field.option_id !== undefined && field.option_id !== null) {
nextAnswers[field.key] = {
...field,
value: field.option_id,
};
} else {
nextAnswers[field.key] = field; nextAnswers[field.key] = field;
}
return nextAnswers; return nextAnswers;
}, {}); }, {});
} }
@ -359,9 +285,8 @@ function writeStoredAnswers(
} }
function getKeepalivePatchUrl(slug: string) { function getKeepalivePatchUrl(slug: string) {
const backendSlug = toBackendSlug(slug);
return getApiRequestUrl( return getApiRequestUrl(
`/api/marriage/sections/${pathParam(backendSlug)}/data/`,
`/api/marriage/forms/profile/answers/`,
); );
} }
@ -466,29 +391,22 @@ export function QuestionAnswersProvider({
}, []); }, []);
const getAnswerValue = useCallback( const getAnswerValue = useCallback(
(question: QuestionField, questionIndex: number) => {
const key = findQuestionFieldKey(question, questionIndex, answers, serverSectionData?.data || undefined);
(question: QuestionField) => {
const key = question.id;
return answers[key]?.value; return answers[key]?.value;
}, },
[answers, serverSectionData?.data],
[answers],
); );
const setAnswerValue = useCallback( const setAnswerValue = useCallback(
( (
question: QuestionField, question: QuestionField,
questionIndex: number,
value: MarriageFieldValue, value: MarriageFieldValue,
) => { ) => {
if (!canEdit) { if (!canEdit) {
return; return;
} }
const field = createQuestionField(
question,
questionIndex,
value,
answersRef.current,
serverSectionData?.data || undefined,
);
const field = createQuestionField(question, value);
setAnswers((currentAnswers) => { setAnswers((currentAnswers) => {
const nextAnswers = { const nextAnswers = {
@ -597,14 +515,7 @@ export function QuestionAnswersProvider({
return; return;
} }
// The combined family card must be split across two backend endpoints by
// updateMarriageSectionData. Sending its full payload to either endpoint
// would overwrite the other half of the profile. The local pending draft
// remains available and is retried on the next visit.
if (
slugRef.current === "family_marital_history" ||
flushPromiseRef.current
) {
if (flushPromiseRef.current) {
return; return;
} }
@ -624,12 +535,18 @@ export function QuestionAnswersProvider({
headers["X-CSRFToken"] = csrfToken; headers["X-CSRFToken"] = csrfToken;
} }
const answersPayload = payload.fields.map((f) => ({
question_id: f.key,
value: f.value,
option_id: (f as any).option_id || undefined,
}));
fetch(getKeepalivePatchUrl(slugRef.current), { fetch(getKeepalivePatchUrl(slugRef.current), {
body: JSON.stringify(payload),
body: JSON.stringify({ answers: answersPayload }),
credentials: "include", credentials: "include",
headers, headers,
keepalive: true, keepalive: true,
method: "PATCH",
method: "PUT",
}) })
.then((response) => { .then((response) => {
if (!response.ok) { if (!response.ok) {
@ -715,14 +632,13 @@ export function useQuestionAnswers() {
export function useQuestionAnswer( export function useQuestionAnswer(
question: QuestionField, question: QuestionField,
questionIndex: number,
) { ) {
const context = useContext(QuestionAnswersContext); const context = useContext(QuestionAnswersContext);
return { return {
setValue: (value: MarriageFieldValue) => { setValue: (value: MarriageFieldValue) => {
context?.setAnswerValue(question, questionIndex, value);
context?.setAnswerValue(question, value);
}, },
value: context?.getAnswerValue(question, questionIndex),
value: context?.getAnswerValue(question),
}; };
} }

260
src/components/Componentes/question-answer.test.tsx

@ -0,0 +1,260 @@
import { render, screen, fireEvent, waitFor, cleanup } from "@testing-library/react";
import { describe, it, expect, afterEach, beforeEach, vi } from "vitest";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { QuestionAnswersProvider, useQuestionAnswers } from "./question-answer-storage";
import { convertSchemaToFrontendItems } from "@/lib/schema-adapter";
import { useFormSchemaQuery } from "@/hooks/marriage/use-form-schema";
import { useMarriageProfileQuery } from "@/hooks/marriage/use-profile-main";
import { useMarriageSectionDataQuery, useUpdateMarriageSectionDataMutation } from "@/hooks/marriage/use-section-data";
vi.mock("@/hooks/marriage/use-form-schema", () => ({
useFormSchemaQuery: vi.fn(),
}));
vi.mock("@/hooks/marriage/use-profile-main", () => ({
useMarriageProfileQuery: vi.fn(),
}));
vi.mock("@/hooks/marriage/use-section-data", () => ({
useMarriageSectionDataQuery: vi.fn(),
useUpdateMarriageSectionDataMutation: vi.fn(),
}));
// Dummy component to interact with the context
function TestComponent({ slug }: { slug: string }) {
const { setAnswerValue, flushAnswers } = useQuestionAnswers();
return (
<div>
<button
data-testid="set-radio"
onClick={() =>
setAnswerValue(
{ id: "q1", type: "radio", title: "Q1", order: 1, required: true, isVisible: true, description: "", tooltip: "", extras: { placeHolder: "", options: [], range: [0,0] }, options: [{ id: "opt1", value: "A", label: "Option A", order: 1 }] },
"opt1"
)
}
>
Set Radio
</button>
<button
data-testid="set-checkbox"
onClick={() =>
setAnswerValue(
{ id: "q2", type: "checkbox", title: "Q2", order: 2, required: true, isVisible: true, description: "", tooltip: "", extras: { placeHolder: "", options: [], range: [0,0] }, options: [{ id: "opt2", value: "B", label: "Option B", order: 1 }, { id: "opt3", value: "C", label: "Option C", order: 2 }] },
["opt2", "opt3"]
)
}
>
Set Checkbox
</button>
<button data-testid="save" onClick={() => flushAnswers()}>
Save
</button>
</div>
);
}
describe("Question Answer & Schema Integration", () => {
let capturedPayload: any = null;
beforeEach(() => {
capturedPayload = null;
const updateMutateAsync = vi.fn(async (payload) => {
capturedPayload = payload;
return payload;
});
(useUpdateMarriageSectionDataMutation as any).mockReturnValue({
mutateAsync: updateMutateAsync,
isPending: false,
});
(useMarriageProfileQuery as any).mockReturnValue({
data: { can_edit_profile: true },
});
(useMarriageSectionDataQuery as any).mockReturnValue({
data: [],
isLoading: false,
});
(useFormSchemaQuery as any).mockReturnValue({
data: { is_completed: false },
isLoading: false,
isFetching: false,
});
});
afterEach(() => {
cleanup();
});
it("should send option_id instead of label/value for radio", async () => {
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } });
render(
<QueryClientProvider client={queryClient}>
<QuestionAnswersProvider
slug="test_slug"
questions={[
{ id: "q1", type: "radio", title: "Q1", order: 1, required: true, isVisible: true, description: "", tooltip: "", extras: { placeHolder: "", options: [], range: [0,0] }, options: [{ id: "opt1", value: "A", label: "Option A", order: 1 }] },
]}
>
<TestComponent slug="test_slug" />
</QuestionAnswersProvider>
</QueryClientProvider>
);
fireEvent.click(screen.getByTestId("set-radio"));
fireEvent.click(screen.getByTestId("save"));
await waitFor(() => {
expect(capturedPayload).not.toBeNull();
const field = capturedPayload.fields.find((f: any) => f.key === "q1");
expect(field.option_id).toBe("opt1");
expect(field.value).toBe("opt1"); // ui value is option_id
});
});
it("should send array of option_ids for checkbox", async () => {
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } });
render(
<QueryClientProvider client={queryClient}>
<QuestionAnswersProvider
slug="test_slug"
questions={[
{ id: "q2", type: "checkbox", title: "Q2", order: 2, required: true, isVisible: true, description: "", tooltip: "", extras: { placeHolder: "", options: [], range: [0,0] }, options: [{ id: "opt2", value: "B", label: "Option B", order: 1 }, { id: "opt3", value: "C", label: "Option C", order: 2 }] },
]}
>
<TestComponent slug="test_slug" />
</QuestionAnswersProvider>
</QueryClientProvider>
);
fireEvent.click(screen.getByTestId("set-checkbox"));
fireEvent.click(screen.getByTestId("save"));
await waitFor(() => {
expect(capturedPayload).not.toBeNull();
const field = capturedPayload.fields.find((f: any) => f.key === "q2");
expect(field.option_id).toEqual(["opt2", "opt3"]);
});
});
it("should sort schema questions and options by order correctly", () => {
const mockSchema = {
sections: [
{
id: "sec1",
title: "Section",
order: 1,
icon: "user-circle",
is_required: true,
estimated_minutes: 5,
cards: [
{
id: "card1",
title: "Card",
order: 2,
questions: [
{ id: "q1", title: "Q1", type: "text", order: 10, required: true, is_visible: true, ui_config: {}, options: [] },
{ id: "q2", title: "Q2", type: "text", order: 5, required: true, is_visible: true, ui_config: {}, options: [
{ id: "opt1", value: "A", label: "Option A", order: 2 },
{ id: "opt2", value: "B", label: "Option B", order: 1 }
] },
]
},
{
id: "card2",
title: "Card 2",
order: 1,
questions: [
{ id: "q3", title: "Q3", type: "text", order: 1, required: true, is_visible: true, ui_config: {}, options: [] },
]
}
]
}
],
progress: { sections_progress: {} }
} as any;
const frontendItems = convertSchemaToFrontendItems(mockSchema, "en");
const questions = frontendItems[0].questions;
expect(questions[0].id).toBe("q3");
expect(questions[1].id).toBe("q2");
expect(questions[2].id).toBe("q1");
expect(questions[1].options[0].id).toBe("opt2");
expect(questions[1].options[1].id).toBe("opt1");
});
it("should hydrate radio/dropdown/checkbox with option_id instead of canonical value", async () => {
// Mock the backend sending canonical value but keeping option_id
(useMarriageSectionDataQuery as any).mockReturnValue({
data: {
slug: "test_slug",
data: [
{ key: "q_radio", type: "radio", value: "Server Canonical Value", option_id: "opt_radio" },
{ key: "q_check", type: "checkbox", value: ["Server Val 1", "Server Val 2"], option_id: ["opt_check1", "opt_check2"] },
],
},
isLoading: false,
});
let capturedValues: any = {};
function HydrationTestComponent() {
const { getAnswerValue } = useQuestionAnswers();
capturedValues.radio = getAnswerValue({ id: "q_radio" } as any);
capturedValues.check = getAnswerValue({ id: "q_check" } as any);
return <div />;
}
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } });
render(
<QueryClientProvider client={queryClient}>
<QuestionAnswersProvider slug="test_slug" questions={[]}>
<HydrationTestComponent />
</QuestionAnswersProvider>
</QueryClientProvider>
);
// Give it a moment to reconcile useEffect in QuestionAnswersProvider
await waitFor(() => {
expect(capturedValues.radio).toBe("opt_radio");
expect(capturedValues.check).toEqual(["opt_check1", "opt_check2"]);
});
});
it("should respect bq.is_required over bq.required for required state", () => {
const mockSchema = {
sections: [
{
id: "sec1",
title: "Section",
order: 1,
icon: "user-circle",
is_required: true,
estimated_minutes: 5,
cards: [
{
id: "card1",
title: "Card",
order: 1,
questions: [
{ id: "q1", title: "Q1", type: "text", order: 1, required: false, is_required: true, is_visible: true, ui_config: {}, options: [] },
]
}
]
}
],
progress: { sections_progress: {} }
} as any;
const frontendItems = convertSchemaToFrontendItems(mockSchema, "en");
const question = frontendItems[0].questions[0];
expect(question.baseRequired).toBe(false);
expect(question.required).toBe(true);
});
});

12
src/components/Componentes/question-birthplace.tsx

@ -2,7 +2,7 @@
import { useCallback, useEffect, useRef, useState } from "react"; import { useCallback, useEffect, useRef, useState } from "react";
import { getCountryList, COUNTRIES_EN, COUNTRIES_FA } from "@/data/countries"; import { getCountryList, COUNTRIES_EN, COUNTRIES_FA } from "@/data/countries";
import type { QuestionField } from "@/data/question-data";
import type { QuestionField } from "@/lib/schema-adapter";
import { useI18n } from "@/translations/provider"; import { useI18n } from "@/translations/provider";
import { useQuestionAnswers } from "./question-answer-storage"; import { useQuestionAnswers } from "./question-answer-storage";
import QuestionTitle from "./question-title"; import QuestionTitle from "./question-title";
@ -10,7 +10,6 @@ import { LoadingThreeDot } from "./loading-three-dot";
type QuestionBirthplaceProps = { type QuestionBirthplaceProps = {
question: QuestionField; question: QuestionField;
questionIndex: number;
disabled?: boolean; disabled?: boolean;
}; };
@ -78,12 +77,11 @@ function parseValue(rawValue: unknown): { country: string; city: string } {
export function QuestionBirthplace({ export function QuestionBirthplace({
question, question,
questionIndex,
disabled, disabled,
}: QuestionBirthplaceProps) { }: QuestionBirthplaceProps) {
const { dictionary: t, locale } = useI18n(); const { dictionary: t, locale } = useI18n();
const { getAnswerValue, setAnswerValue, isLoading } = useQuestionAnswers(); const { getAnswerValue, setAnswerValue, isLoading } = useQuestionAnswers();
const rawValue = getAnswerValue(question, questionIndex);
const rawValue = getAnswerValue(question);
const initial = parseValue(rawValue); const initial = parseValue(rawValue);
const [selectedCountry, setSelectedCountry] = useState(initial.country); const [selectedCountry, setSelectedCountry] = useState(initial.country);
@ -95,9 +93,7 @@ export function QuestionBirthplace({
const listRef = useRef<HTMLDivElement>(null); const listRef = useRef<HTMLDivElement>(null);
const searchInputRef = useRef<HTMLInputElement>(null); const searchInputRef = useRef<HTMLInputElement>(null);
const isResidence =
question.title.toLowerCase().includes("residence") ||
question.title.includes("سکونت");
const isResidence = question.ui_config?.enable_geoip === true;
const [mode, setMode] = useState<"auto" | "manual">("auto"); const [mode, setMode] = useState<"auto" | "manual">("auto");
const [isDetecting, setIsDetecting] = useState(false); const [isDetecting, setIsDetecting] = useState(false);
@ -106,7 +102,7 @@ export function QuestionBirthplace({
const updateAnswers = (country: string, city: string) => { const updateAnswers = (country: string, city: string) => {
const formatted = const formatted =
city && country ? `${city}, ${country}` : city || country || null; city && country ? `${city}, ${country}` : city || country || null;
setAnswerValue(question, questionIndex, formatted);
setAnswerValue(question, formatted);
}; };
// GeoIP detection logic // GeoIP detection logic

6
src/components/Componentes/question-button.tsx

@ -1,22 +1,20 @@
"use client"; "use client";
import { IoInformation } from "react-icons/io5"; import { IoInformation } from "react-icons/io5";
import type { QuestionField } from "@/data/question-data";
import type { QuestionField } from "@/lib/schema-adapter";
import { useQuestionAnswer } from "./question-answer-storage"; import { useQuestionAnswer } from "./question-answer-storage";
import QuestionTitle from "./question-title"; import QuestionTitle from "./question-title";
type QuestionButtonProps = { type QuestionButtonProps = {
question: QuestionField; question: QuestionField;
questionIndex: number;
disabled?: boolean; disabled?: boolean;
}; };
export function QuestionButton({ export function QuestionButton({
question, question,
questionIndex,
disabled, disabled,
}: QuestionButtonProps) { }: QuestionButtonProps) {
const { setValue, value } = useQuestionAnswer(question, questionIndex);
const { setValue, value } = useQuestionAnswer(question);
const isAnswered = value === true; const isAnswered = value === true;
return ( return (

2
src/components/Componentes/question-card.tsx

@ -9,7 +9,7 @@ import {
IoPerson, IoPerson,
IoSchool, IoSchool,
} from "react-icons/io5"; } from "react-icons/io5";
import type { QuestionCardIcon, QuestionListItem } from "@/data/question-data";
import type { QuestionCardIcon, QuestionListItem } from "@/lib/schema-adapter";
import { localizePath } from "@/translations/config"; import { localizePath } from "@/translations/config";
import { useI18n } from "@/translations/provider"; import { useI18n } from "@/translations/provider";

49
src/components/Componentes/question-checkbox.tsx

@ -1,23 +1,21 @@
"use client"; "use client";
import type { QuestionField } from "@/data/question-data";
import type { QuestionField } from "@/lib/schema-adapter";
import { useQuestionAnswers } from "./question-answer-storage"; import { useQuestionAnswers } from "./question-answer-storage";
import QuestionTitle from "./question-title"; import QuestionTitle from "./question-title";
type QuestionCheckboxProps = { type QuestionCheckboxProps = {
question: QuestionField; question: QuestionField;
questionIndex: number;
disabled?: boolean; disabled?: boolean;
}; };
export function QuestionCheckbox({ export function QuestionCheckbox({
question, question,
questionIndex,
disabled, disabled,
}: QuestionCheckboxProps) { }: QuestionCheckboxProps) {
const options = question.extras.options || [];
const options = question.options || [];
const { getAnswerValue, setAnswerValue } = useQuestionAnswers(); const { getAnswerValue, setAnswerValue } = useQuestionAnswers();
const rawValue = getAnswerValue(question, questionIndex);
const rawValue = getAnswerValue(question);
const value = Array.isArray(rawValue) const value = Array.isArray(rawValue)
? rawValue ? rawValue
@ -29,40 +27,21 @@ export function QuestionCheckbox({
return null; return null;
} }
const toggleOption = (option: string) => {
const toggleOption = (optionId: string) => {
let nextValue: string[]; let nextValue: string[];
if (value.includes(option)) {
nextValue = value.filter((v) => v !== option);
if (value.includes(optionId)) {
nextValue = value.filter((v) => v !== optionId);
} else { } else {
nextValue = [...value, option];
}
const exclusiveOption = options.find(
(o) =>
o.includes("مهم نیست") ||
o.includes("Doesn't matter") ||
o.includes("No children") ||
o.includes("فرزندی ندارم.") ||
o.includes("مسئولیت مستمری ندارم") ||
o.includes("do not have any ongoing responsibility"),
);
if (exclusiveOption) {
if (option === exclusiveOption) {
nextValue = [exclusiveOption];
} else if (nextValue.includes(exclusiveOption)) {
nextValue = nextValue.filter((v) => v !== exclusiveOption);
}
nextValue = [...value, optionId];
} }
setAnswerValue( setAnswerValue(
question,
questionIndex,
nextValue.length > 0 ? nextValue : null,
question, nextValue.length > 0 ? nextValue : null,
); );
}; };
const isShortOptions = const isShortOptions =
options.length <= 4 && options.every((opt) => opt.length <= 15);
options.length <= 4 && options.every((opt) => opt.label.length <= 15);
return ( return (
<div <div
@ -81,12 +60,12 @@ export function QuestionCheckbox({
} }
> >
{options.map((option) => { {options.map((option) => {
const optionId = `checkbox-${questionIndex}-${option}`;
const isSelected = value.includes(option);
const optionId = `checkbox-${question.id}-${option.id}`;
const isSelected = value.includes(option.id);
return ( return (
<label <label
key={option}
key={option.id}
htmlFor={optionId} htmlFor={optionId}
className={[ className={[
"cursor-pointer rounded-[16px] font-semibold text-[15px] transition-all duration-200 active:scale-[0.99] flex items-center gap-3", "cursor-pointer rounded-[16px] font-semibold text-[15px] transition-all duration-200 active:scale-[0.99] flex items-center gap-3",
@ -103,7 +82,7 @@ export function QuestionCheckbox({
id={optionId} id={optionId}
checked={isSelected} checked={isSelected}
disabled={disabled} disabled={disabled}
onChange={() => toggleOption(option)}
onChange={() => toggleOption(option.id)}
className="sr-only" className="sr-only"
/> />
{!isShortOptions && ( {!isShortOptions && (
@ -132,7 +111,7 @@ export function QuestionCheckbox({
)} )}
</div> </div>
)} )}
<span className="flex-1">{option}</span>
<span className="flex-1">{option.label}</span>
</label> </label>
); );
})} })}

12
src/components/Componentes/question-date.tsx

@ -1,14 +1,13 @@
"use client"; "use client";
import { useEffect, useMemo, useState } from "react"; import { useEffect, useMemo, useState } from "react";
import type { QuestionField } from "@/data/question-data";
import type { QuestionField } from "@/lib/schema-adapter";
import { useI18n } from "@/translations/provider"; import { useI18n } from "@/translations/provider";
import { useQuestionAnswers } from "./question-answer-storage"; import { useQuestionAnswers } from "./question-answer-storage";
import QuestionTitle from "./question-title"; import QuestionTitle from "./question-title";
type QuestionDateProps = { type QuestionDateProps = {
question: QuestionField; question: QuestionField;
questionIndex: number;
disabled?: boolean; disabled?: boolean;
}; };
@ -42,12 +41,11 @@ const YEARS = Array.from({ length: 80 }, (_, i) =>
export function QuestionDate({ export function QuestionDate({
question, question,
questionIndex,
disabled, disabled,
}: QuestionDateProps) { }: QuestionDateProps) {
const { locale } = useI18n(); const { locale } = useI18n();
const { getAnswerValue, setAnswerValue } = useQuestionAnswers(); const { getAnswerValue, setAnswerValue } = useQuestionAnswers();
const value = getAnswerValue(question, questionIndex);
const value = getAnswerValue(question);
const dateValue = typeof value === "string" ? value : ""; const dateValue = typeof value === "string" ? value : "";
const [selectedYear, setSelectedYear] = useState(() => { const [selectedYear, setSelectedYear] = useState(() => {
@ -113,12 +111,10 @@ export function QuestionDate({
const formattedMonth = m.padStart(2, "0"); const formattedMonth = m.padStart(2, "0");
const formattedDay = d.padStart(2, "0"); const formattedDay = d.padStart(2, "0");
setAnswerValue( setAnswerValue(
question,
questionIndex,
`${y}-${formattedMonth}-${formattedDay}`,
question, `${y}-${formattedMonth}-${formattedDay}`,
); );
} else { } else {
setAnswerValue(question, questionIndex, "");
setAnswerValue(question, "");
} }
}; };

188
src/components/Componentes/question-dropdown.tsx

@ -2,33 +2,29 @@
import { useCallback, useEffect, useRef, useState } from "react"; import { useCallback, useEffect, useRef, useState } from "react";
import { ExplanationUiFont } from "./explanation-ui-font"; import { ExplanationUiFont } from "./explanation-ui-font";
import { getCountryList } from "@/data/countries";
import { getLanguageList } from "@/data/languages";
import { getNationalityList } from "@/data/nationalities";
import type { QuestionField } from "@/data/question-data";
import type { QuestionField } from "@/lib/schema-adapter";
import { useI18n } from "@/translations/provider"; import { useI18n } from "@/translations/provider";
import { useQuestionAnswers } from "./question-answer-storage"; import { useQuestionAnswers } from "./question-answer-storage";
import QuestionTitle from "./question-title"; import QuestionTitle from "./question-title";
type QuestionDropdownProps = { type QuestionDropdownProps = {
question: QuestionField; question: QuestionField;
questionIndex: number;
disabled?: boolean; disabled?: boolean;
}; };
export function QuestionDropdown({ export function QuestionDropdown({
question, question,
questionIndex,
disabled, disabled,
}: QuestionDropdownProps) { }: QuestionDropdownProps) {
const { locale, dictionary: t } = useI18n();
const { dictionary: t } = useI18n();
const { getAnswerValue, setAnswerValue } = useQuestionAnswers(); const { getAnswerValue, setAnswerValue } = useQuestionAnswers();
const rawValue = getAnswerValue(question, questionIndex);
const rawValue = getAnswerValue(question);
const isMulti = const isMulti =
Array.isArray(rawValue) || Array.isArray(rawValue) ||
question.type === "checkbox" || question.type === "checkbox" ||
(question.extras?.range && question.extras.range[1] > 1); (question.extras?.range && question.extras.range[1] > 1);
const selectedList = Array.isArray(rawValue) const selectedList = Array.isArray(rawValue)
? rawValue ? rawValue
: typeof rawValue === "string" && rawValue : typeof rawValue === "string" && rawValue
@ -100,154 +96,49 @@ export function QuestionDropdown({
} }
}, [isOpen]); }, [isOpen]);
const isPersonalityTraits =
question.title === "Your Personality Traits" ||
question.title === "ویژگی‌های شخصیتی خودتان";
const isHobbies =
question.title === "Your Hobbies and Main Interests" ||
question.title === "سرگرمی‌ها و علایق اصلی";
const isSmokingSubstances =
question.title === "Red Lines for Smoking, Alcohol, and Substances" ||
question.title === "خط قرمزهای مربوط به دخانیات، الکل و مواد در همسر آینده";
const isOtherQuestion =
isPersonalityTraits || isHobbies || isSmokingSubstances;
const otherOptionName = t["Other"] || "Other";
let options = question.extras.options || [];
const isNationalityDropdown =
question.title === "Current Nationality / Citizenship" ||
question.title === "ملیت / تابعیت فعلی";
const isCountryDropdown =
!isNationalityDropdown &&
(options.includes("United States") ||
options.includes("ایالات متحده آمریکا") ||
options.includes("Iran") ||
options.includes("ایران"));
if (isNationalityDropdown) {
options = getNationalityList(locale);
} else if (isCountryDropdown) {
options = getCountryList(locale);
}
const isLanguageDropdown =
options.includes("Persian") ||
options.includes("فارسی") ||
options.includes("English") ||
options.includes("انگلیسی");
if (isLanguageDropdown) {
options = getLanguageList(locale);
}
if (isOtherQuestion) {
options = [...options, otherOptionName];
}
const options = question.options || [];
const showSearch = (() => { const showSearch = (() => {
if (question.extras.noSearch) return false;
const titleLower = question.title.toLowerCase();
return (
isCountryDropdown ||
isNationalityDropdown ||
isLanguageDropdown ||
titleLower.includes("country") ||
titleLower.includes("کشور") ||
titleLower.includes("city") ||
titleLower.includes("شهر") ||
titleLower.includes("currency") ||
titleLower.includes("ارز") ||
titleLower.includes("nationality") ||
titleLower.includes("ملیت") ||
titleLower.includes("language") ||
titleLower.includes("زبان") ||
options.length > 10
);
if (question.extras?.noSearch) return false;
return options.length > 10;
})(); })();
const filteredOptions = options.filter((option) => const filteredOptions = options.filter((option) =>
option.toLowerCase().includes(searchQuery.toLowerCase()),
option.label.toLowerCase().includes(searchQuery.toLowerCase()),
); );
const getCleanLabel = (val: string) => {
const base = val.split(" - ")[0];
if (isOtherQuestion) {
if (base === otherOptionName || base.startsWith(`${otherOptionName}: `)) {
return otherOptionName;
}
}
return base;
const getCleanLabel = (optId: string) => {
const opt = options.find((o) => o.id === optId);
if (!opt) return optId;
return opt.label.split(" - ")[0];
}; };
const displayLabel = isMulti const displayLabel = isMulti
? selectedList.length > 0 ? selectedList.length > 0
? selectedList.map(getCleanLabel).join(", ") ? selectedList.map(getCleanLabel).join(", ")
: question.extras.placeHolder || "Select"
: question.extras?.placeHolder || "Select"
: singleValue : singleValue
? getCleanLabel(singleValue) ? getCleanLabel(singleValue)
: question.extras.placeHolder || "Select";
: question.extras?.placeHolder || "Select";
const hasSelectedValue = isMulti const hasSelectedValue = isMulti
? selectedList.length > 0 ? selectedList.length > 0
: Boolean(singleValue); : Boolean(singleValue);
const otherValueIndex = selectedList.findIndex(
(v) => v === otherOptionName || v.startsWith(`${otherOptionName}: `),
);
const isOtherSelected = otherValueIndex !== -1;
const currentOtherText = isOtherSelected
? selectedList[otherValueIndex].startsWith(`${otherOptionName}: `)
? selectedList[otherValueIndex].slice(otherOptionName.length + 2)
: ""
: "";
const toggleMultiOption = (option: string) => {
const isOtherOpt = isOtherQuestion && option === otherOptionName;
const toggleMultiOption = (optionId: string) => {
let nextValue: string[]; let nextValue: string[];
if (isOtherQuestion && isOtherOpt) {
const hasOther = selectedList.some(
(v) => v === otherOptionName || v.startsWith(`${otherOptionName}: `),
);
if (hasOther) {
nextValue = selectedList.filter(
(v) => v !== otherOptionName && !v.startsWith(`${otherOptionName}: `),
);
} else {
nextValue = [...selectedList, otherOptionName];
}
} else {
if (selectedList.includes(option)) {
nextValue = selectedList.filter((v) => v !== option);
if (selectedList.includes(optionId)) {
nextValue = selectedList.filter((v) => v !== optionId);
} else { } else {
nextValue = [...selectedList, option];
}
nextValue = [...selectedList, optionId];
} }
setAnswerValue( setAnswerValue(
question,
questionIndex,
nextValue.length > 0 ? nextValue : null,
question, nextValue.length > 0 ? nextValue : null,
); );
}; };
const handleOtherTextChange = (text: string) => {
if (otherValueIndex !== -1) {
const nextValue = [...selectedList];
if (text.trim() === "") {
nextValue[otherValueIndex] = otherOptionName;
} else {
nextValue[otherValueIndex] = `${otherOptionName}: ${text}`;
}
setAnswerValue(question, questionIndex, nextValue);
}
};
return ( return (
<div <div
ref={containerRef} ref={containerRef}
@ -299,29 +190,6 @@ export function QuestionDropdown({
</svg> </svg>
</button> </button>
{/* RENDER TEXT FIELD IF OTHER IS SELECTED */}
{isOtherQuestion && isOtherSelected ? (
<div className="mt-1">
<input
type="text"
value={currentOtherText}
onChange={(e) => handleOtherTextChange(e.target.value)}
placeholder={
isPersonalityTraits
? (t["Write other options..."] ?? "Write other options...")
: isSmokingSubstances
? locale === "fa"
? "خط قرمزهای دیگر را بنویسید..."
: "Write other red lines..."
: locale === "fa"
? "سرگرمی‌ها یا علایق دیگر را بنویسید..."
: "Write other hobbies or interests..."
}
className="h-[48px] w-full rounded-[14px] border border-[#D0D5DD] bg-white px-4 text-[14px] font-medium text-[#181818] outline-none transition-all placeholder:text-[#98A2B3] hover:border-[#98A2B3] focus:border-[#6F6F6F] focus:ring-1 focus:ring-[#6F6F6F]"
/>
</div>
) : null}
{/* Dropdown Options Panel */} {/* Dropdown Options Panel */}
{isOpen && ( {isOpen && (
<div className="absolute top-[calc(100%+8px)] left-0 z-50 flex w-full flex-col gap-3.5 rounded-[20px] bg-white p-4.5 shadow-[0_12px_36px_rgba(0,0,0,0.09)] border border-[#EAECF0] animate-in fade-in zoom-in-95 duration-150"> <div className="absolute top-[calc(100%+8px)] left-0 z-50 flex w-full flex-col gap-3.5 rounded-[20px] bg-white p-4.5 shadow-[0_12px_36px_rgba(0,0,0,0.09)] border border-[#EAECF0] animate-in fade-in zoom-in-95 duration-150">
@ -383,20 +251,18 @@ export function QuestionDropdown({
{filteredOptions.length > 0 ? ( {filteredOptions.length > 0 ? (
filteredOptions.map((option) => { filteredOptions.map((option) => {
const isSelected = isMulti const isSelected = isMulti
? isOtherQuestion && option === otherOptionName
? isOtherSelected
: selectedList.includes(option)
: singleValue === option;
? selectedList.includes(option.id)
: singleValue === option.id;
return ( return (
<button <button
key={option}
key={option.id}
type="button" type="button"
onClick={() => { onClick={() => {
if (isMulti) { if (isMulti) {
toggleMultiOption(option);
toggleMultiOption(option.id);
} else { } else {
setAnswerValue(question, questionIndex, option);
setAnswerValue(question, option.id);
setIsOpen(false); setIsOpen(false);
} }
}} }}
@ -426,9 +292,9 @@ export function QuestionDropdown({
)} )}
<span className="text-[15px] text-[#181818] leading-tight flex-1"> <span className="text-[15px] text-[#181818] leading-tight flex-1">
{option.includes(" - ") ? (
{option.label.includes(" - ") ? (
(() => { (() => {
const parts = option.split(" - ");
const parts = option.label.split(" - ");
const title = parts[0]; const title = parts[0];
const description = parts.slice(1).join(" - "); const description = parts.slice(1).join(" - ");
return ( return (
@ -443,7 +309,7 @@ export function QuestionDropdown({
); );
})() })()
) : ( ) : (
<span className="font-bold">{option}</span>
<span className="font-bold">{option.label}</span>
)} )}
</span> </span>
</button> </button>

20
src/components/Componentes/question-file.tsx

@ -2,7 +2,7 @@
import Image from "next/image"; import Image from "next/image";
import { useCallback, useEffect, useState } from "react"; import { useCallback, useEffect, useState } from "react";
import type { QuestionField } from "@/data/question-data";
import type { QuestionField } from "@/lib/schema-adapter";
import { useUploadTmpMediaMutation } from "@/hooks/marriage/use-upload-tmp-media"; import { useUploadTmpMediaMutation } from "@/hooks/marriage/use-upload-tmp-media";
import { getApiRequestUrl } from "@/lib/http"; import { getApiRequestUrl } from "@/lib/http";
import { isInFlutterWebView, uploadFile } from "@/lib/webview-actions"; import { isInFlutterWebView, uploadFile } from "@/lib/webview-actions";
@ -12,7 +12,6 @@ import { LoadingSkeleton } from "./loading-skeleton";
type QuestionFileProps = { type QuestionFileProps = {
question: QuestionField; question: QuestionField;
questionIndex: number;
disabled?: boolean; disabled?: boolean;
}; };
@ -59,11 +58,10 @@ function isImageFile(
export function QuestionFile({ export function QuestionFile({
question, question,
questionIndex,
disabled, disabled,
}: QuestionFileProps) { }: QuestionFileProps) {
const { getAnswerValue, setAnswerValue } = useQuestionAnswers(); const { getAnswerValue, setAnswerValue } = useQuestionAnswers();
const storedValue = getAnswerValue(question, questionIndex);
const storedValue = getAnswerValue(question);
const initialFileName = const initialFileName =
typeof storedValue === "string" && storedValue.trim().length > 0 typeof storedValue === "string" && storedValue.trim().length > 0
@ -94,7 +92,7 @@ export function QuestionFile({
const uploadTmpMediaMutation = useUploadTmpMediaMutation({ const uploadTmpMediaMutation = useUploadTmpMediaMutation({
onSuccess: (response) => { onSuccess: (response) => {
if (response.path) { if (response.path) {
setAnswerValue(question, questionIndex, response.path);
setAnswerValue(question, response.path);
} }
}, },
onError: (error) => { onError: (error) => {
@ -120,7 +118,7 @@ export function QuestionFile({
const fileName = event.data.files[0].name ?? null; const fileName = event.data.files[0].name ?? null;
setSelectedFileName(fileName); setSelectedFileName(fileName);
if (fileName) { if (fileName) {
setAnswerValue(question, questionIndex, fileName);
setAnswerValue(question, fileName);
} }
} }
break; break;
@ -130,7 +128,7 @@ export function QuestionFile({
setIsFlutterPicking(false); setIsFlutterPicking(false);
const file = event.data?.files?.[0]; const file = event.data?.files?.[0];
if (file?.url) { if (file?.url) {
setAnswerValue(question, questionIndex, file.url);
setAnswerValue(question, file.url);
setSelectedFileName(file.name ?? "uploaded"); setSelectedFileName(file.name ?? "uploaded");
setFilePreviewUrl(file.url); setFilePreviewUrl(file.url);
} else if (file?.base64) { } else if (file?.base64) {
@ -161,7 +159,7 @@ export function QuestionFile({
return () => { return () => {
unsubscribe?.(); unsubscribe?.();
}; };
}, [question, questionIndex, setAnswerValue, uploadTmpMediaMutation]);
}, [question, setAnswerValue, uploadTmpMediaMutation]);
/** Handle file pick in Flutter WebView via upload_file action. */ /** Handle file pick in Flutter WebView via upload_file action. */
const handleFlutterPick = useCallback(() => { const handleFlutterPick = useCallback(() => {
@ -190,12 +188,12 @@ export function QuestionFile({
if (!file) { if (!file) {
setSelectedFileName(null); setSelectedFileName(null);
setFilePreviewUrl(null); setFilePreviewUrl(null);
setAnswerValue(question, questionIndex, null);
setAnswerValue(question, null);
return; return;
} }
setSelectedFileName(file.name); setSelectedFileName(file.name);
setAnswerValue(question, questionIndex, file.name);
setAnswerValue(question, file.name);
if (file.type.startsWith("image/")) { if (file.type.startsWith("image/")) {
const objectUrl = URL.createObjectURL(file); const objectUrl = URL.createObjectURL(file);
@ -212,7 +210,7 @@ export function QuestionFile({
e.preventDefault(); e.preventDefault();
setSelectedFileName(null); setSelectedFileName(null);
setFilePreviewUrl(null); setFilePreviewUrl(null);
setAnswerValue(question, questionIndex, null);
setAnswerValue(question, null);
}; };
const inWebView = isInFlutterWebView(); const inWebView = isInFlutterWebView();

38
src/components/Componentes/question-number.tsx

@ -1,14 +1,13 @@
"use client"; "use client";
import { useEffect, useMemo, useRef, useState } from "react"; import { useEffect, useMemo, useRef, useState } from "react";
import type { QuestionField } from "@/data/question-data";
import type { QuestionField } from "@/lib/schema-adapter";
import { useI18n } from "@/translations/provider"; import { useI18n } from "@/translations/provider";
import { useQuestionAnswers } from "./question-answer-storage"; import { useQuestionAnswers } from "./question-answer-storage";
import QuestionTitle from "./question-title"; import QuestionTitle from "./question-title";
type QuestionNumberProps = { type QuestionNumberProps = {
question: QuestionField; question: QuestionField;
questionIndex: number;
disabled?: boolean; disabled?: boolean;
derivedFromQuestion?: QuestionField; derivedFromQuestion?: QuestionField;
derivedFromQuestionIndex?: number; derivedFromQuestionIndex?: number;
@ -18,32 +17,30 @@ const NUMBER_INPUT_PATTERN = /^-?\d*\.?\d*$/;
export default function QuestionNumber({ export default function QuestionNumber({
question, question,
questionIndex,
disabled, disabled,
derivedFromQuestion, derivedFromQuestion,
derivedFromQuestionIndex, derivedFromQuestionIndex,
}: QuestionNumberProps) { }: QuestionNumberProps) {
const { dictionary: t, locale } = useI18n(); const { dictionary: t, locale } = useI18n();
const { getAnswerValue, setAnswerValue } = useQuestionAnswers(); const { getAnswerValue, setAnswerValue } = useQuestionAnswers();
const value = getAnswerValue(question, questionIndex);
const value = getAnswerValue(question);
const derivedValue = const derivedValue =
derivedFromQuestion && derivedFromQuestionIndex !== undefined derivedFromQuestion && derivedFromQuestionIndex !== undefined
? getAnswerValue(derivedFromQuestion, derivedFromQuestionIndex)
? getAnswerValue(derivedFromQuestion)
: null; : null;
useEffect(() => { useEffect(() => {
if (derivedFromQuestion && typeof derivedValue === "string") { if (derivedFromQuestion && typeof derivedValue === "string") {
const age = calculateAge(derivedValue); const age = calculateAge(derivedValue);
if (age !== String(value)) { if (age !== String(value)) {
setAnswerValue(question, questionIndex, age);
setAnswerValue(question, age);
} }
} }
}, [ }, [
derivedFromQuestion, derivedFromQuestion,
derivedValue, derivedValue,
question, question,
questionIndex,
setAnswerValue, setAnswerValue,
value, value,
]); ]);
@ -54,9 +51,9 @@ export default function QuestionNumber({
value.length > 0 && value.length > 0 &&
!NUMBER_INPUT_PATTERN.test(value) !NUMBER_INPUT_PATTERN.test(value)
) { ) {
setAnswerValue(question, questionIndex, null);
setAnswerValue(question, null);
} }
}, [question, questionIndex, setAnswerValue, value]);
}, [question, setAnswerValue, value]);
const [min, max] = question.extras.range; const [min, max] = question.extras.range;
@ -78,15 +75,14 @@ export default function QuestionNumber({
? rawInputValue ? rawInputValue
: ""; : "";
const isMonthlyIncome =
question.title === "Monthly Income" ||
question.title === "میزان درآمد ماهانه";
const isMonthlyIncome = question.ui_config?.currency_enabled === true;
const currencyStorageKey = question.ui_config?.currency_storage_key || "marriage:income:currency";
const countryName = useMemo(() => getCountryFromStorage(), []); const countryName = useMemo(() => getCountryFromStorage(), []);
const [currencyCode, setCurrencyCode] = useState(() => { const [currencyCode, setCurrencyCode] = useState(() => {
if (typeof window !== "undefined") { if (typeof window !== "undefined") {
const stored = window.localStorage.getItem("marriage:income:currency");
const stored = window.localStorage.getItem(currencyStorageKey);
if (stored) return stored; if (stored) return stored;
} }
return getCurrencyForCountry(countryName); return getCurrencyForCountry(countryName);
@ -99,7 +95,7 @@ export default function QuestionNumber({
useEffect(() => { useEffect(() => {
if (typeof window !== "undefined") { if (typeof window !== "undefined") {
const stored = window.localStorage.getItem("marriage:income:currency");
const stored = window.localStorage.getItem(currencyStorageKey);
if (stored) { if (stored) {
setCurrencyCode(stored); setCurrencyCode(stored);
return; return;
@ -215,13 +211,11 @@ export default function QuestionNumber({
setLocalTextValue(finalFormatted); setLocalTextValue(finalFormatted);
if (cleanValue === "") { if (cleanValue === "") {
setAnswerValue(question, questionIndex, null);
setAnswerValue(question, null);
} else { } else {
const parsed = parseFloat(cleanValue); const parsed = parseFloat(cleanValue);
setAnswerValue( setAnswerValue(
question,
questionIndex,
Number.isNaN(parsed) ? cleanValue : parsed,
question, Number.isNaN(parsed) ? cleanValue : parsed,
); );
} }
}} }}
@ -330,7 +324,7 @@ export default function QuestionNumber({
setCurrencyCode(c.code); setCurrencyCode(c.code);
if (typeof window !== "undefined") { if (typeof window !== "undefined") {
window.localStorage.setItem( window.localStorage.setItem(
"marriage:income:currency",
currencyStorageKey,
c.code, c.code,
); );
} }
@ -398,13 +392,11 @@ export default function QuestionNumber({
} }
if (nextValue === "") { if (nextValue === "") {
setAnswerValue(question, questionIndex, null);
setAnswerValue(question, null);
} else { } else {
const parsed = parseFloat(nextValue); const parsed = parseFloat(nextValue);
setAnswerValue( setAnswerValue(
question,
questionIndex,
Number.isNaN(parsed) ? nextValue : parsed,
question, Number.isNaN(parsed) ? nextValue : parsed,
); );
} }
}} }}

8
src/components/Componentes/question-phone.tsx

@ -2,7 +2,7 @@
import { PhoneNumberFormat, PhoneNumberUtil } from "google-libphonenumber"; import { PhoneNumberFormat, PhoneNumberUtil } from "google-libphonenumber";
import { useEffect, useRef, useState, useMemo, useCallback } from "react"; import { useEffect, useRef, useState, useMemo, useCallback } from "react";
import type { QuestionField } from "@/data/question-data";
import type { QuestionField } from "@/lib/schema-adapter";
import type { MarriagePhoneFieldValue } from "@/hooks/marriage/types"; import type { MarriagePhoneFieldValue } from "@/hooks/marriage/types";
import { useQuestionAnswers } from "./question-answer-storage"; import { useQuestionAnswers } from "./question-answer-storage";
import QuestionTitle from "./question-title"; import QuestionTitle from "./question-title";
@ -11,7 +11,6 @@ import { useI18n } from "@/translations/provider";
type QuestionPhoneProps = { type QuestionPhoneProps = {
question: QuestionField; question: QuestionField;
questionIndex: number;
countryCode?: string; countryCode?: string;
disabled?: boolean; disabled?: boolean;
}; };
@ -224,13 +223,12 @@ function getNormalizedPhoneValue(codeValue: string, phoneValue: string) {
export function QuestionPhone({ export function QuestionPhone({
question, question,
questionIndex,
countryCode = "+44", countryCode = "+44",
disabled, disabled,
}: QuestionPhoneProps) { }: QuestionPhoneProps) {
const { locale } = useI18n(); const { locale } = useI18n();
const { getAnswerValue, setAnswerValue, isLoading } = useQuestionAnswers(); const { getAnswerValue, setAnswerValue, isLoading } = useQuestionAnswers();
const value = getAnswerValue(question, questionIndex);
const value = getAnswerValue(question);
const defaultCodeValue = countryCode.trim() || "+44"; const defaultCodeValue = countryCode.trim() || "+44";
const getCachedOrSavedCode = useCallback((): string | null => { const getCachedOrSavedCode = useCallback((): string | null => {
@ -548,7 +546,7 @@ export function QuestionPhone({
: null; : null;
lastCommittedValueRef.current = nextValue; lastCommittedValueRef.current = nextValue;
setAnswerValue(question, questionIndex, nextValue);
setAnswerValue(question, nextValue);
}; };
const handleSelectCountryCode = (selectedCode: string) => { const handleSelectCountryCode = (selectedCode: string) => {

18
src/components/Componentes/question-photo.tsx

@ -2,7 +2,7 @@
import Image from "next/image"; import Image from "next/image";
import { type ReactNode, useCallback, useEffect, useId, useState } from "react"; import { type ReactNode, useCallback, useEffect, useId, useState } from "react";
import type { QuestionField } from "@/data/question-data";
import type { QuestionField } from "@/lib/schema-adapter";
import { useUploadTmpMediaMutation } from "@/hooks/marriage/use-upload-tmp-media"; import { useUploadTmpMediaMutation } from "@/hooks/marriage/use-upload-tmp-media";
import { getApiRequestUrl } from "@/lib/http"; import { getApiRequestUrl } from "@/lib/http";
import { isInFlutterWebView, uploadFile } from "@/lib/webview-actions"; import { isInFlutterWebView, uploadFile } from "@/lib/webview-actions";
@ -12,14 +12,12 @@ import { LoadingSkeleton } from "./loading-skeleton";
type QuestionPhotoProps = { type QuestionPhotoProps = {
question: QuestionField; question: QuestionField;
questionIndex: number;
description?: ReactNode; description?: ReactNode;
disabled?: boolean; disabled?: boolean;
}; };
export function QuestionPhoto({ export function QuestionPhoto({
question, question,
questionIndex,
description, description,
disabled, disabled,
}: QuestionPhotoProps) { }: QuestionPhotoProps) {
@ -34,12 +32,12 @@ export function QuestionPhoto({
const descriptionContent = description ?? question.description; const descriptionContent = description ?? question.description;
const { getAnswerValue, setAnswerValue } = useQuestionAnswers(); const { getAnswerValue, setAnswerValue } = useQuestionAnswers();
const storedValue = getAnswerValue(question, questionIndex);
const storedValue = getAnswerValue(question);
const uploadTmpMediaMutation = useUploadTmpMediaMutation({ const uploadTmpMediaMutation = useUploadTmpMediaMutation({
onSuccess: (response) => { onSuccess: (response) => {
if (response.path) { if (response.path) {
setAnswerValue(question, questionIndex, response.path);
setAnswerValue(question, response.path);
} }
}, },
onError: (error) => { onError: (error) => {
@ -64,19 +62,19 @@ export function QuestionPhoto({
if (event.data?.files?.[0]?.base64) { if (event.data?.files?.[0]?.base64) {
const b64 = event.data.files[0].base64; const b64 = event.data.files[0].base64;
setLocalPreviewUrl(b64); setLocalPreviewUrl(b64);
setAnswerValue(question, questionIndex, b64);
setAnswerValue(question, b64);
} }
break; break;
case "completed": { case "completed": {
setIsFlutterPicking(false); setIsFlutterPicking(false);
const file = event.data?.files?.[0]; const file = event.data?.files?.[0];
if (file?.url) { if (file?.url) {
setAnswerValue(question, questionIndex, file.url);
setAnswerValue(question, file.url);
setLocalPreviewUrl(file.url); setLocalPreviewUrl(file.url);
} else if (file?.base64) { } else if (file?.base64) {
const b64 = file.base64; const b64 = file.base64;
setLocalPreviewUrl(b64); setLocalPreviewUrl(b64);
setAnswerValue(question, questionIndex, b64);
setAnswerValue(question, b64);
fetch(b64) fetch(b64)
.then((res) => res.blob()) .then((res) => res.blob())
.then((blob) => { .then((blob) => {
@ -98,7 +96,7 @@ export function QuestionPhoto({
return () => { return () => {
unsubscribe?.(); unsubscribe?.();
}; };
}, [question, questionIndex, setAnswerValue, uploadTmpMediaMutation]);
}, [question, setAnswerValue, uploadTmpMediaMutation]);
const handleFlutterPick = useCallback(() => { const handleFlutterPick = useCallback(() => {
const extensions = (question.extras?.options ?? []).map((o) => const extensions = (question.extras?.options ?? []).map((o) =>
@ -126,7 +124,7 @@ export function QuestionPhoto({
// Create synchronous object URL for instant preview & immediate state update // Create synchronous object URL for instant preview & immediate state update
const objectUrl = URL.createObjectURL(file); const objectUrl = URL.createObjectURL(file);
setLocalPreviewUrl(objectUrl); setLocalPreviewUrl(objectUrl);
setAnswerValue(question, questionIndex, objectUrl);
setAnswerValue(question, objectUrl);
// Trigger background upload // Trigger background upload
uploadTmpMediaMutation.mutate(file); uploadTmpMediaMutation.mutate(file);

28
src/components/Componentes/question-radio.tsx

@ -1,24 +1,22 @@
"use client"; "use client";
import { ExplanationUiFont } from "./explanation-ui-font"; import { ExplanationUiFont } from "./explanation-ui-font";
import type { QuestionField } from "@/data/question-data";
import type { QuestionField } from "@/lib/schema-adapter";
import { useQuestionAnswers } from "./question-answer-storage"; import { useQuestionAnswers } from "./question-answer-storage";
import QuestionTitle from "./question-title"; import QuestionTitle from "./question-title";
type QuestionRadioProps = { type QuestionRadioProps = {
question: QuestionField; question: QuestionField;
questionIndex: number;
disabled?: boolean; disabled?: boolean;
}; };
export function QuestionRadio({ export function QuestionRadio({
question, question,
questionIndex,
disabled, disabled,
}: QuestionRadioProps) { }: QuestionRadioProps) {
const options = question.extras.options || [];
const options = question.options || [];
const { getAnswerValue, setAnswerValue } = useQuestionAnswers(); const { getAnswerValue, setAnswerValue } = useQuestionAnswers();
const value = getAnswerValue(question, questionIndex);
const value = getAnswerValue(question);
if (options.length === 0) { if (options.length === 0) {
return null; return null;
@ -26,7 +24,7 @@ export function QuestionRadio({
// Render horizontally if all options are short (e.g. Single, Divorced, Widowed) // Render horizontally if all options are short (e.g. Single, Divorced, Widowed)
const isShortOptions = const isShortOptions =
options.length <= 4 && options.every((opt) => opt.length <= 15);
options.length <= 4 && options.every((opt) => opt.label.length <= 15);
return ( return (
<div <div
@ -45,12 +43,12 @@ export function QuestionRadio({
} }
> >
{options.map((option) => { {options.map((option) => {
const optionId = `question-${questionIndex}-${option}`;
const isSelected = String(value) === option;
const optionId = `question-${question.id}-${option.id}`;
const isSelected = String(value) === option.id;
return ( return (
<label <label
key={option}
key={option.id}
htmlFor={optionId} htmlFor={optionId}
className={[ className={[
"cursor-pointer rounded-[16px] font-semibold text-[15px] transition-all duration-200 active:scale-[0.99] flex items-center gap-3", "cursor-pointer rounded-[16px] font-semibold text-[15px] transition-all duration-200 active:scale-[0.99] flex items-center gap-3",
@ -65,11 +63,11 @@ export function QuestionRadio({
<input <input
type="radio" type="radio"
id={optionId} id={optionId}
name={`question-${questionIndex}`}
value={option}
name={`question-${question.id}`}
value={option.id}
checked={isSelected} checked={isSelected}
disabled={disabled} disabled={disabled}
onChange={() => setAnswerValue(question, questionIndex, option)}
onChange={() => setAnswerValue(question, option.id)}
className="sr-only" className="sr-only"
/> />
{!isShortOptions && ( {!isShortOptions && (
@ -86,9 +84,9 @@ export function QuestionRadio({
)} )}
</div> </div>
)} )}
{option.includes(" - ") ? (
{option.label.includes(" - ") ? (
(() => { (() => {
const parts = option.split(" - ");
const parts = option.label.split(" - ");
const title = parts[0]; const title = parts[0];
const description = parts.slice(1).join(" - "); const description = parts.slice(1).join(" - ");
return ( return (
@ -105,7 +103,7 @@ export function QuestionRadio({
); );
})() })()
) : ( ) : (
<span className="flex-1">{option}</span>
<span className="flex-1">{option.label}</span>
)} )}
</label> </label>
); );

29
src/components/Componentes/question-renderer.tsx

@ -1,6 +1,6 @@
"use client"; "use client";
import type { QuestionField } from "@/data/question-data";
import type { QuestionField } from "@/lib/schema-adapter";
import QuestionBirthplace from "./question-birthplace"; import QuestionBirthplace from "./question-birthplace";
import QuestionButton from "./question-button"; import QuestionButton from "./question-button";
import QuestionCheckbox from "./question-checkbox"; import QuestionCheckbox from "./question-checkbox";
@ -17,22 +17,17 @@ import QuestionTextarea from "./question-textarea";
type QuestionRendererProps = { type QuestionRendererProps = {
question: QuestionField; question: QuestionField;
questionIndex: number;
disabled?: boolean; disabled?: boolean;
dobQuestion?: QuestionField; dobQuestion?: QuestionField;
dobQuestionIndex?: number;
}; };
export function QuestionRenderer({ export function QuestionRenderer({
question, question,
questionIndex,
disabled, disabled,
dobQuestion, dobQuestion,
dobQuestionIndex,
}: QuestionRendererProps) { }: QuestionRendererProps) {
const enTitle = (question.englishTitle || question.title).toLowerCase();
const compactTextHeight = const compactTextHeight =
enTitle.includes("email") || enTitle.includes("duration")
question.type === "email" || question.ui_config?.compact === true
? "h-[54px]" ? "h-[54px]"
: undefined; : undefined;
@ -41,7 +36,6 @@ export function QuestionRenderer({
return ( return (
<QuestionBirthplace <QuestionBirthplace
question={question} question={question}
questionIndex={questionIndex}
disabled={disabled} disabled={disabled}
/> />
); );
@ -49,7 +43,6 @@ export function QuestionRenderer({
return ( return (
<QuestionButton <QuestionButton
question={question} question={question}
questionIndex={questionIndex}
disabled={disabled} disabled={disabled}
/> />
); );
@ -57,7 +50,6 @@ export function QuestionRenderer({
return ( return (
<QuestionCheckbox <QuestionCheckbox
question={question} question={question}
questionIndex={questionIndex}
disabled={disabled} disabled={disabled}
/> />
); );
@ -65,7 +57,6 @@ export function QuestionRenderer({
return ( return (
<QuestionDate <QuestionDate
question={question} question={question}
questionIndex={questionIndex}
disabled={disabled} disabled={disabled}
/> />
); );
@ -73,7 +64,6 @@ export function QuestionRenderer({
return ( return (
<QuestionDropdown <QuestionDropdown
question={question} question={question}
questionIndex={questionIndex}
disabled={disabled} disabled={disabled}
/> />
); );
@ -81,22 +71,18 @@ export function QuestionRenderer({
return ( return (
<QuestionFile <QuestionFile
question={question} question={question}
questionIndex={questionIndex}
disabled={disabled} disabled={disabled}
/> />
); );
case "number": case "number":
if ( if (
question.title === "Age" &&
dobQuestion &&
dobQuestionIndex !== undefined
question.ui_config?.derivedFromDob === true &&
dobQuestion
) { ) {
return ( return (
<QuestionNumber <QuestionNumber
question={question} question={question}
questionIndex={questionIndex}
derivedFromQuestion={dobQuestion} derivedFromQuestion={dobQuestion}
derivedFromQuestionIndex={dobQuestionIndex}
disabled={disabled} disabled={disabled}
/> />
); );
@ -105,7 +91,6 @@ export function QuestionRenderer({
return ( return (
<QuestionNumber <QuestionNumber
question={question} question={question}
questionIndex={questionIndex}
disabled={disabled} disabled={disabled}
/> />
); );
@ -113,7 +98,6 @@ export function QuestionRenderer({
return ( return (
<QuestionPhone <QuestionPhone
question={question} question={question}
questionIndex={questionIndex}
disabled={disabled} disabled={disabled}
/> />
); );
@ -121,7 +105,6 @@ export function QuestionRenderer({
return ( return (
<QuestionPhoto <QuestionPhoto
question={question} question={question}
questionIndex={questionIndex}
disabled={disabled} disabled={disabled}
/> />
); );
@ -129,7 +112,6 @@ export function QuestionRenderer({
return ( return (
<QuestionRadio <QuestionRadio
question={question} question={question}
questionIndex={questionIndex}
disabled={disabled} disabled={disabled}
/> />
); );
@ -138,7 +120,6 @@ export function QuestionRenderer({
return ( return (
<QuestionSlider <QuestionSlider
question={question} question={question}
questionIndex={questionIndex}
disabled={disabled} disabled={disabled}
/> />
); );
@ -146,7 +127,6 @@ export function QuestionRenderer({
return ( return (
<QuestionText <QuestionText
question={question} question={question}
questionIndex={questionIndex}
heightClassName={compactTextHeight} heightClassName={compactTextHeight}
disabled={disabled} disabled={disabled}
/> />
@ -155,7 +135,6 @@ export function QuestionRenderer({
return ( return (
<QuestionTextarea <QuestionTextarea
question={question} question={question}
questionIndex={questionIndex}
disabled={disabled} disabled={disabled}
/> />
); );

10
src/components/Componentes/question-section-flow.tsx

@ -12,10 +12,8 @@ import QuestionProgressTracker, {
useQuestionProgress, useQuestionProgress,
} from "./question-progress-tracker"; } from "./question-progress-tracker";
import QuestionSnapList from "./question-snap-list"; import QuestionSnapList from "./question-snap-list";
import type { QuestionField } from "@/data/question-data";
import type { QuestionField } from "@/lib/schema-adapter";
import NoticeBox from "./notice-box"; import NoticeBox from "./notice-box";
import { getStoredAge } from "./progress-helper";
import { useMarriageProfileQuery } from "@/hooks/marriage/use-profile-main";
import AnswerPaceSheet from "@/app/questions-list/[slug]/answer-pace-sheet"; import AnswerPaceSheet from "@/app/questions-list/[slug]/answer-pace-sheet";
import { FixToTheEnd } from "./fix-to-the-end"; import { FixToTheEnd } from "./fix-to-the-end";
import Button from "./button"; import Button from "./button";
@ -85,13 +83,9 @@ function SectionFlowContent({
[markQuestionPassed, optionalQuestionIndexes], [markQuestionPassed, optionalQuestionIndexes],
); );
const { data: profile } = useMarriageProfileQuery();
const isFemale = profile?.gender === "female";
const age = getStoredAge();
const activeQuestion = questions?.[activeQuestionIndex]; const activeQuestion = questions?.[activeQuestionIndex];
const showNotice = activeQuestion?.showGuardianNotice; const showNotice = activeQuestion?.showGuardianNotice;
const isUnder27 =
age !== null ? isFemale && age < 27 : (activeQuestion?.required ?? false);
const isUnder27 = activeQuestion?.required ?? false;
return ( return (
<> <>

14
src/components/Componentes/question-slider.tsx

@ -1,27 +1,25 @@
"use client"; "use client";
import { useLayoutEffect, useRef, useState } from "react"; import { useLayoutEffect, useRef, useState } from "react";
import type { QuestionField } from "@/data/question-data";
import type { QuestionField } from "@/lib/schema-adapter";
import { useI18n } from "@/translations/provider"; import { useI18n } from "@/translations/provider";
import { useQuestionAnswers } from "./question-answer-storage"; import { useQuestionAnswers } from "./question-answer-storage";
import QuestionTitle from "./question-title"; import QuestionTitle from "./question-title";
type QuestionSliderProps = { type QuestionSliderProps = {
question: QuestionField; question: QuestionField;
questionIndex: number;
disabled?: boolean; disabled?: boolean;
}; };
export function QuestionSlider({ export function QuestionSlider({
question, question,
questionIndex,
disabled, disabled,
}: QuestionSliderProps) { }: QuestionSliderProps) {
const { dictionary: t } = useI18n(); const { dictionary: t } = useI18n();
const [min, max] = question.extras.range; const [min, max] = question.extras.range;
const initialValue = Math.round((min + max) / 2); const initialValue = Math.round((min + max) / 2);
const { getAnswerValue, setAnswerValue } = useQuestionAnswers(); const { getAnswerValue, setAnswerValue } = useQuestionAnswers();
const storedValue = getAnswerValue(question, questionIndex);
const storedValue = getAnswerValue(question);
const isDesiredAgeRange = false; const isDesiredAgeRange = false;
@ -115,12 +113,12 @@ export function QuestionSlider({
if (isDesiredAgeRange) { if (isDesiredAgeRange) {
const handleFromChange = (newFrom: number) => { const handleFromChange = (newFrom: number) => {
const val = Math.min(newFrom, toVal); const val = Math.min(newFrom, toVal);
setAnswerValue(question, questionIndex, `${val}-${toVal}`);
setAnswerValue(question, `${val}-${toVal}`);
}; };
const handleToChange = (newTo: number) => { const handleToChange = (newTo: number) => {
const val = Math.max(newTo, fromVal); const val = Math.max(newTo, fromVal);
setAnswerValue(question, questionIndex, `${fromVal}-${val}`);
setAnswerValue(question, `${fromVal}-${val}`);
}; };
const progressFrom = const progressFrom =
@ -246,9 +244,7 @@ export function QuestionSlider({
value={value} value={value}
onChange={(event) => onChange={(event) =>
setAnswerValue( setAnswerValue(
question,
questionIndex,
Number(event.target.value),
question, Number(event.target.value),
) )
} }
disabled={disabled} disabled={disabled}

33
src/components/Componentes/question-text.tsx

@ -1,14 +1,13 @@
"use client"; "use client";
import { useEffect, useRef, useState } from "react"; import { useEffect, useRef, useState } from "react";
import type { QuestionField } from "@/data/question-data";
import type { QuestionField } from "@/lib/schema-adapter";
import { useI18n } from "@/translations/provider"; import { useI18n } from "@/translations/provider";
import { useQuestionAnswers } from "./question-answer-storage"; import { useQuestionAnswers } from "./question-answer-storage";
import QuestionTitle from "./question-title"; import QuestionTitle from "./question-title";
type QuestionTextProps = { type QuestionTextProps = {
question: QuestionField; question: QuestionField;
questionIndex: number;
description?: string; description?: string;
disabled?: boolean; disabled?: boolean;
heightClassName?: string; heightClassName?: string;
@ -22,14 +21,13 @@ function toEnglishDigits(str: string): string {
export default function QuestionText({ export default function QuestionText({
question, question,
questionIndex,
description, description,
disabled, disabled,
heightClassName: _heightClassName, heightClassName: _heightClassName,
}: QuestionTextProps) { }: QuestionTextProps) {
const { dictionary: t } = useI18n();
const { dictionary: t, locale } = useI18n();
const { getAnswerValue, setAnswerValue } = useQuestionAnswers(); const { getAnswerValue, setAnswerValue } = useQuestionAnswers();
const value = getAnswerValue(question, questionIndex);
const value = getAnswerValue(question);
const isMuted = value === "-"; const isMuted = value === "-";
const [localValue, setLocalValue] = useState( const [localValue, setLocalValue] = useState(
@ -41,9 +39,7 @@ export default function QuestionText({
setLocalValue(isMuted ? "" : String(value ?? "")); setLocalValue(isMuted ? "" : String(value ?? ""));
}, [value, isMuted]); }, [value, isMuted]);
const enTitleLower = (question.englishTitle || question.title).toLowerCase();
const isNumericQuestion =
question.type === "number" || enTitleLower.includes("duration");
const isNumericQuestion = question.type === "number" || question.validation?.format === "number";
const handleChange = (val: string) => { const handleChange = (val: string) => {
let nextVal = val; let nextVal = val;
@ -58,7 +54,7 @@ export default function QuestionText({
} }
debounceTimerRef.current = setTimeout(() => { debounceTimerRef.current = setTimeout(() => {
setAnswerValue(question, questionIndex, nextVal);
setAnswerValue(question, nextVal);
}, 300); }, 300);
}; };
@ -66,13 +62,11 @@ export default function QuestionText({
if (debounceTimerRef.current) { if (debounceTimerRef.current) {
clearTimeout(debounceTimerRef.current); clearTimeout(debounceTimerRef.current);
} }
setAnswerValue(question, questionIndex, localValue);
setAnswerValue(question, localValue);
}; };
const stringValue = localValue.trim(); const stringValue = localValue.trim();
const isEmailQuestion = (question.englishTitle || question.title)
.toLowerCase()
.includes("email");
const isEmailQuestion = question.type === "email" || question.validation?.format === "email";
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
const isValidEmail = !isEmailQuestion || emailRegex.test(stringValue); const isValidEmail = !isEmailQuestion || emailRegex.test(stringValue);
@ -83,9 +77,7 @@ export default function QuestionText({
? isValidEmail || stringValue.length === 0 ? isValidEmail || stringValue.length === 0
: isValidEmail && stringValue.length > 0; : isValidEmail && stringValue.length > 0;
const isMarjaQuestion =
(question.englishTitle || question.title) ===
"Marja' al-Taqlid (Religious Authority)";
const isMarjaQuestion = question.ui_config?.showNotPriorityCheckbox === true;
if (isEmailQuestion) { if (isEmailQuestion) {
return ( return (
@ -119,9 +111,10 @@ export default function QuestionText({
</div> </div>
{showInvalidState ? ( {showInvalidState ? (
<span className="block group-10 font-semibold text-[#F2465F]"> <span className="block group-10 font-semibold text-[#F2465F]">
{/[\u0600-\u06FF]/.test(question.title)
{question.validation?.errorMessage ||
(locale === "fa"
? "یک آدرس ایمیل معتبر وارد کنید." ? "یک آدرس ایمیل معتبر وارد کنید."
: "Enter a valid email address."}
: "Enter a valid email address.")}
</span> </span>
) : null} ) : null}
{description ? ( {description ? (
@ -172,9 +165,9 @@ export default function QuestionText({
clearTimeout(debounceTimerRef.current); clearTimeout(debounceTimerRef.current);
} }
if (e.target.checked) { if (e.target.checked) {
setAnswerValue(question, questionIndex, "-");
setAnswerValue(question, "-");
} else { } else {
setAnswerValue(question, questionIndex, "");
setAnswerValue(question, "");
} }
}} }}
className="h-[18px] w-[18px] shrink-0 rounded border-[#D0D5DD] text-[#F2465F] focus:ring-[#F2465F] accent-[#F2465F] cursor-pointer" className="h-[18px] w-[18px] shrink-0 rounded border-[#D0D5DD] text-[#F2465F] focus:ring-[#F2465F] accent-[#F2465F] cursor-pointer"

10
src/components/Componentes/question-textarea.tsx

@ -1,27 +1,25 @@
"use client"; "use client";
import { useEffect, useRef, useState } from "react"; import { useEffect, useRef, useState } from "react";
import type { QuestionField } from "@/data/question-data";
import type { QuestionField } from "@/lib/schema-adapter";
import { useI18n } from "@/translations/provider"; import { useI18n } from "@/translations/provider";
import { useQuestionAnswers } from "./question-answer-storage"; import { useQuestionAnswers } from "./question-answer-storage";
import QuestionTitle from "./question-title"; import QuestionTitle from "./question-title";
type QuestionTextareaProps = { type QuestionTextareaProps = {
question: QuestionField; question: QuestionField;
questionIndex: number;
description?: string; description?: string;
disabled?: boolean; disabled?: boolean;
}; };
export function QuestionTextarea({ export function QuestionTextarea({
question, question,
questionIndex,
description, description,
disabled, disabled,
}: QuestionTextareaProps) { }: QuestionTextareaProps) {
const { dictionary: t } = useI18n(); const { dictionary: t } = useI18n();
const { getAnswerValue, setAnswerValue } = useQuestionAnswers(); const { getAnswerValue, setAnswerValue } = useQuestionAnswers();
const value = getAnswerValue(question, questionIndex);
const value = getAnswerValue(question);
const [localValue, setLocalValue] = useState(String(value ?? "")); const [localValue, setLocalValue] = useState(String(value ?? ""));
const debounceTimerRef = useRef<NodeJS.Timeout | null>(null); const debounceTimerRef = useRef<NodeJS.Timeout | null>(null);
@ -39,7 +37,7 @@ export function QuestionTextarea({
} }
debounceTimerRef.current = setTimeout(() => { debounceTimerRef.current = setTimeout(() => {
setAnswerValue(question, questionIndex, val);
setAnswerValue(question, val);
}, 300); }, 300);
}; };
@ -47,7 +45,7 @@ export function QuestionTextarea({
if (debounceTimerRef.current) { if (debounceTimerRef.current) {
clearTimeout(debounceTimerRef.current); clearTimeout(debounceTimerRef.current);
} }
setAnswerValue(question, questionIndex, localValue);
setAnswerValue(question, localValue);
}; };
const stringValue = localValue.trim(); const stringValue = localValue.trim();

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

@ -3,9 +3,8 @@
import { useState } from "react"; import { useState } from "react";
import { IoEyeOff } from "react-icons/io5"; import { IoEyeOff } from "react-icons/io5";
import HelpModal from "./help-modal"; import HelpModal from "./help-modal";
import type { QuestionField } from "@/data/question-data";
import type { QuestionField } from "@/lib/schema-adapter";
import { useI18n } from "@/translations/provider"; import { useI18n } from "@/translations/provider";
import pathMap from "@/translations/path_to_english.json";
type QuestionTitleProps = { type QuestionTitleProps = {
question: QuestionField; question: QuestionField;
@ -16,32 +15,18 @@ export function QuestionTitle({ question, className }: QuestionTitleProps) {
const { dictionary: t, locale } = useI18n(); const { dictionary: t, locale } = useI18n();
const [isHelpOpen, setIsHelpOpen] = useState(false); const [isHelpOpen, setIsHelpOpen] = useState(false);
const isMonthlyIncome =
question.title === "Monthly Income" ||
question.title === "میزان درآمد ماهانه";
let suffix = "";
if (question.ui_config?.suffix) {
suffix = ` ${question.ui_config.suffix}`;
} else if (question.ui_config?.suffix_i18n) {
suffix = ` ${question.ui_config.suffix_i18n[locale] || question.ui_config.suffix_i18n.en || ""}`;
} else if (question.ui_config?.showApproximateSuffix) {
suffix = locale === "fa" ? " (تقریبی)" : " (approximately)";
} else if (question.ui_config?.showYearsSuffix) {
suffix = locale === "fa" ? " (سال)" : " (Years)";
}
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 titleText = question.title + suffix;
const words = titleText.split(" "); const words = titleText.split(" ");
const lastWord = words[words.length - 1]; const lastWord = words[words.length - 1];
const remainingTitle = words.slice(0, -1).join(" "); const remainingTitle = words.slice(0, -1).join(" ");
@ -93,15 +78,7 @@ export function QuestionTitle({ question, className }: QuestionTitleProps) {
<HelpModal <HelpModal
isOpen={isHelpOpen} isOpen={isHelpOpen}
onClose={() => setIsHelpOpen(false)} onClose={() => setIsHelpOpen(false)}
description={(() => {
const fullPath = "questions." + question.tooltip;
const englishVal = (pathMap as Record<string, string>)[
fullPath
];
return englishVal
? (t as any)[englishVal] || englishVal
: question.tooltip;
})()}
description={question.tooltip}
/> />
</> </>
) : null} ) : null}

4
src/components/Componentes/required-steps-card.tsx

@ -2,11 +2,10 @@
import { useMemo } from "react"; import { useMemo } from "react";
import { IoAlert, IoCheckmark } from "react-icons/io5"; import { IoAlert, IoCheckmark } from "react-icons/io5";
import { useMarriageProfileQuery } from "@/hooks/marriage/use-profile-main";
import { useFormSchemaQuery } from "@/hooks/marriage/use-form-schema"; import { useFormSchemaQuery } from "@/hooks/marriage/use-form-schema";
import { convertSchemaToFrontendItems } from "@/lib/schema-adapter"; import { convertSchemaToFrontendItems } from "@/lib/schema-adapter";
import { useI18n } from "@/translations/provider"; import { useI18n } from "@/translations/provider";
import type { QuestionListItem } from "@/data/question-data";
import type { QuestionListItem } from "@/lib/schema-adapter";
type RequiredStepsCardProps = { type RequiredStepsCardProps = {
items?: QuestionListItem[]; items?: QuestionListItem[];
@ -34,7 +33,6 @@ export default function RequiredStepsCard({
progressBySlug, progressBySlug,
}: RequiredStepsCardProps = {}) { }: RequiredStepsCardProps = {}) {
const { dictionary: t, locale } = useI18n(); const { dictionary: t, locale } = useI18n();
const { data: profile } = useMarriageProfileQuery();
const { data: schema } = useFormSchemaQuery("profile", locale); const { data: schema } = useFormSchemaQuery("profile", locale);
const questionListItems = useMemo( const questionListItems = useMemo(

235
src/components/Componentes/schema-question-flow.integration.test.tsx

@ -0,0 +1,235 @@
import React from "react";
import { render, screen, fireEvent, waitFor, cleanup } from "@testing-library/react";
import { describe, it, expect, afterEach, beforeEach, vi } from "vitest";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { QuestionAnswersProvider, useQuestionAnswers } from "./question-answer-storage";
import { convertSchemaToFrontendItems } from "@/lib/schema-adapter";
import { useFormSchemaQuery } from "@/hooks/marriage/use-form-schema";
import { useMarriageProfileQuery } from "@/hooks/marriage/use-profile-main";
import { useMarriageSectionDataQuery, useUpdateMarriageSectionDataMutation } from "@/hooks/marriage/use-section-data";
import { QuestionRadio } from "./question-radio";
import { QuestionCheckbox } from "./question-checkbox";
import QuestionDetailClient from "@/app/questions-list/[slug]/question-detail-client";
vi.mock("@/hooks/marriage/use-form-schema", () => ({
useFormSchemaQuery: vi.fn(),
}));
vi.mock("@/hooks/marriage/use-profile-main", () => ({
useMarriageProfileQuery: vi.fn(),
}));
vi.mock("@/hooks/marriage/use-section-data", () => ({
useMarriageSectionDataQuery: vi.fn(),
useUpdateMarriageSectionDataMutation: vi.fn(),
}));
export const replaceMock = vi.fn();
vi.mock("next/navigation", () => ({
useRouter: () => ({
push: vi.fn(),
replace: (...args: any[]) => replaceMock(...args),
back: vi.fn(),
}),
}));
const mockSchema = {
form_id: "profile",
version: 1,
answers: {
"q_radio": { value: "A", option_id: "opt_a" },
"q_check": { value: ["B", "C"], option_id: ["opt_b", "opt_c"] }
},
sections: [
{
id: "sec1",
title: "Section",
order: 1,
icon: "user-circle",
is_required: true,
estimated_minutes: 5,
cards: [
{
id: "card1",
title: "Card",
order: 2, // Messed up order
questions: [
{
id: "q_child", title: "Child Q", type: "text", order: 20,
required: false, is_required: true, is_visible: false,
ui_config: {}, options: []
},
{
id: "q_radio", title: "Radio Q", type: "radio", order: 5,
required: true, is_required: true, is_visible: true,
ui_config: {},
options: [
{ id: "opt_a_dummy", value: "A_DUMMY", label: "Option A Dummy", order: 2 },
{ id: "opt_a", value: "A", label: "Option A Canonical", order: 1 }
]
},
{
id: "q_check", title: "Check Q", type: "checkbox", order: 10,
required: false, is_required: false, is_visible: true,
ui_config: {},
options: [
{ id: "opt_c", value: "C", label: "Option C", order: 2 },
{ id: "opt_b", value: "B", label: "Option B", order: 1 }
]
}
]
},
{
id: "card2",
title: "Card 2",
order: 1, // Card 2 should come first
questions: [
{ id: "q_first", title: "First Q", type: "text", order: 1, required: true, is_visible: true, ui_config: {}, options: [] },
]
}
]
}
],
progress: { sections_progress: {} }
};
describe("Schema Question Flow Integration", () => {
let capturedPayload: any = null;
beforeEach(() => {
capturedPayload = null;
const updateMutateAsync = vi.fn(async (payload) => {
capturedPayload = payload;
return payload;
});
(useUpdateMarriageSectionDataMutation as any).mockReturnValue({
mutateAsync: updateMutateAsync,
isPending: false,
});
(useMarriageProfileQuery as any).mockReturnValue({
data: { can_edit_profile: true },
});
(useMarriageSectionDataQuery as any).mockReturnValue({
data: {
slug: "sec1",
data: [
{ key: "q_radio", type: "radio", value: "A", option_id: "opt_a" },
{ key: "q_check", type: "checkbox", value: ["B", "C"], option_id: ["opt_b", "opt_c"] }
]
},
isLoading: false,
});
(useFormSchemaQuery as any).mockReturnValue({
data: mockSchema,
isLoading: false,
isFetching: false,
error: null,
refetch: vi.fn(),
});
});
afterEach(() => {
cleanup();
});
it("should enforce ordering, correctly hydrate canonical values via options, and hide invisible children", async () => {
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } });
// Use convertSchemaToFrontendItems directly (no mock)
const frontendItems = convertSchemaToFrontendItems(mockSchema, "en");
// Sort logic validation
expect(frontendItems[0].questions[0].id).toBe("q_first");
expect(frontendItems[0].questions[1].id).toBe("q_radio");
expect(frontendItems[0].questions[2].id).toBe("q_check");
expect(frontendItems[0].questions[3].id).toBe("q_child");
// Validate options sort
expect(frontendItems[0].questions[1].options[0].id).toBe("opt_a");
render(
<QueryClientProvider client={queryClient}>
<QuestionDetailClient
itemSlug="sec1"
closeLabel="Close"
continueLabel="Continue"
description="Desc"
informationLabel="Info"
questionsListHref="/list"
title="Title"
/>
</QueryClientProvider>
);
// Wait for hydration and rendering
await waitFor(() => {
// Radio hydration
const radioInput = screen.getByLabelText("Option A Canonical") as HTMLInputElement;
expect(radioInput.checked).toBe(true);
// Checkbox hydration
const checkB = screen.getByLabelText("Option B") as HTMLInputElement;
const checkC = screen.getByLabelText("Option C") as HTMLInputElement;
expect(checkB.checked).toBe(true);
expect(checkC.checked).toBe(true);
// Child should not be rendered
expect(screen.queryByText("Child Q")).toBeNull();
// q_child is required=false but is_required=true, if it was visible it should show *.
// Let's modify schema dynamically to test child visibility and requirement
});
// Interact to fire payload
const radioDummy = screen.getByLabelText("Option A Dummy") as HTMLInputElement;
fireEvent.click(radioDummy);
// Uncheck Option B and Check Option C (Wait, they are both checked by default from hydration)
const checkB = screen.getByLabelText("Option B") as HTMLInputElement;
fireEvent.click(checkB); // Should now be unchecked
const submitBtn = screen.getByText("Continue"); // Form submit button
fireEvent.click(submitBtn);
await waitFor(() => {
expect(capturedPayload).not.toBeNull();
const radioPayload = capturedPayload.fields.find((f: any) => f.key === "q_radio");
expect(radioPayload.option_id).toBe("opt_a_dummy");
const checkPayload = capturedPayload.fields.find((f: any) => f.key === "q_check");
expect(checkPayload.option_id).toEqual(["opt_c"]); // Only C is checked now
});
});
it("should redirect and not render static questions on schema failure", () => {
replaceMock.mockClear();
(useFormSchemaQuery as any).mockReturnValue({
data: undefined,
isLoading: false,
isFetching: false,
error: new Error("Network Error"),
refetch: vi.fn(),
});
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } });
render(
<QueryClientProvider client={queryClient}>
<QuestionDetailClient
itemSlug="sec1"
closeLabel="Close"
continueLabel="Continue"
description="Desc"
informationLabel="Info"
questionsListHref="/list"
title="Title"
/>
</QueryClientProvider>
);
// Should redirect to questions list
expect(replaceMock).toHaveBeenCalledWith("/list");
// Static text should not exist
expect(screen.queryByText("First Q")).toBeNull();
});
});

1
src/components/Componentes/test-questions-flow.tsx

@ -13,6 +13,7 @@ import StickyHeader from "./sticky-header";
import TestLoadingScreen from "./test-loading-screen"; import TestLoadingScreen from "./test-loading-screen";
export type QuestionOption = { export type QuestionOption = {
id: string;
label: string; label: string;
value: string | number; value: string | number;
}; };

114
src/components/Componentes/ui-config.test.tsx

@ -0,0 +1,114 @@
import { render, screen, cleanup, fireEvent, waitFor } from "@testing-library/react";
import { describe, it, expect, vi, afterEach } from "vitest";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { QuestionAnswersProvider } from "./question-answer-storage";
import { QuestionBirthplace } from "./question-birthplace";
import QuestionNumber from "./question-number";
import QuestionText from "./question-text";
vi.mock("@/translations/provider", () => ({
useI18n: vi.fn(() => ({ locale: "fa", dictionary: {} })),
}));
describe("UI Config based behavior", () => {
afterEach(() => {
cleanup();
});
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } });
it("should trigger GeoIP only when ui_config.enable_geoip is true", () => {
// With totally random title but ui_config.enable_geoip = true
const qWithGeo = { id: "q1", title: "Random Title Here", type: "birthplace", order: 1, required: true, isVisible: true, description: "", tooltip: "", extras: {}, options: [], ui_config: { enable_geoip: true } } as any;
const { rerender } = render(
<QueryClientProvider client={queryClient}>
<QuestionAnswersProvider slug="test" questions={[qWithGeo]}>
<QuestionBirthplace question={qWithGeo} />
</QuestionAnswersProvider>
</QueryClientProvider>
);
// Auto button is present when GeoIP is active
expect(screen.getByText("خودکار")).toBeDefined();
// With title "residence" but no ui_config
const qWithoutGeo = { id: "q2", title: "residence test", type: "birthplace", order: 1, required: true, isVisible: true, description: "", tooltip: "", extras: {}, options: [], ui_config: {} } as any;
rerender(
<QueryClientProvider client={queryClient}>
<QuestionAnswersProvider slug="test" questions={[qWithoutGeo]}>
<QuestionBirthplace question={qWithoutGeo} />
</QuestionAnswersProvider>
</QueryClientProvider>
);
expect(screen.queryByText("خودکار")).toBeNull();
});
it("should trigger currency behavior only when ui_config.currency_enabled is true", () => {
// Title is random, but currency_enabled is true
const qWithCurrency = { id: "q1", title: "Random Income", type: "number", order: 1, required: true, isVisible: true, description: "", tooltip: "", extras: { placeHolder: "", range: [0, 0] }, options: [], ui_config: { currency_enabled: true } } as any;
const { rerender } = render(
<QueryClientProvider client={queryClient}>
<QuestionAnswersProvider slug="test" questions={[qWithCurrency]}>
<QuestionNumber question={qWithCurrency} />
</QuestionAnswersProvider>
</QueryClientProvider>
);
// Dropdown arrow should be rendered in currency mode (path is in SVG)
expect(screen.getByRole("img", { name: "Dropdown chevron" })).toBeDefined();
// Title is "Monthly Income", but currency_enabled is false
const qWithoutCurrency = { id: "q2", title: "Monthly Income", type: "number", order: 1, required: true, isVisible: true, description: "", tooltip: "", extras: { placeHolder: "", range: [0, 0] }, options: [], ui_config: {} } as any;
rerender(
<QueryClientProvider client={queryClient}>
<QuestionAnswersProvider slug="test" questions={[qWithoutCurrency]}>
<QuestionNumber question={qWithoutCurrency} />
</QuestionAnswersProvider>
</QueryClientProvider>
);
expect(screen.queryByRole("img", { name: "Dropdown chevron" })).toBeNull();
});
it("should render error message from validation.errorMessage or fallback to locale", async () => {
// Title is random, type is email, custom error message
const qCustomError = { id: "q1", title: "Random Email", type: "email", order: 1, required: true, isVisible: true, description: "", tooltip: "", extras: { placeHolder: "" }, options: [], validation: { errorMessage: "Custom Backend Error" } } as any;
const { rerender } = render(
<QueryClientProvider client={queryClient}>
<QuestionAnswersProvider slug="test" questions={[qCustomError]}>
<QuestionText question={qCustomError} />
</QuestionAnswersProvider>
</QueryClientProvider>
);
const input = screen.getByRole("textbox");
fireEvent.change(input, { target: { value: 'invalid_email' } });
await waitFor(() => {
expect(screen.getByText("Custom Backend Error")).toBeDefined();
});
const qFallbackError = { id: "q2", title: "Random Email", type: "email", order: 1, required: true, isVisible: true, description: "", tooltip: "", extras: { placeHolder: "" }, options: [], validation: {} } as any;
rerender(
<QueryClientProvider client={queryClient}>
<QuestionAnswersProvider slug="test" questions={[qFallbackError]}>
<QuestionText question={qFallbackError} />
</QuestionAnswersProvider>
</QueryClientProvider>
);
const input2 = screen.getByRole("textbox");
fireEvent.change(input2, { target: { value: 'invalid_email_again' } });
await waitFor(() => {
expect(screen.getByText("یک آدرس ایمیل معتبر وارد کنید.")).toBeDefined();
});
});
});

1067
src/data/cattell-fallback.ts
File diff suppressed because it is too large
View File

183
src/data/glasser-fallback.ts

@ -1,183 +0,0 @@
export type GlasserFallbackQuestion = {
question_number: number;
factor_code: string;
text: string;
};
export const glasserFallbackQuestions: GlasserFallbackQuestion[] = [
{
question_number: 1,
factor_code: "S",
text: "مسایلی مثل پس انداز، مخارج زندگی، مسکن، آینده شغلی و.. تا چه اندازه ذهن شما را به خود مشغول می دارد؟",
},
{
question_number: 2,
factor_code: "S",
text: "تا چه اندازه به سلامت جسمانی، بهداشت و احتمال ابتلا به بیماری فکر میکنید؟",
},
{
question_number: 3,
factor_code: "S",
text: "شدت میل جنسی خود را چگونه ارزیابی میکنید؟",
},
{
question_number: 4,
factor_code: "S",
text: "در انجام کارها و اقدامات مخاطره انگیز تا چه اندازه محتاطانه عمل میکنید؟",
},
{
question_number: 5,
factor_code: "S",
text: "از روبه رویی با تجارب جدید و شروع راه های ناشناخته تا چه اندازه اجتناب میکنید؟",
},
{
question_number: 6,
factor_code: "S",
text: "از نظر دوستان و همکاران خود چقدر وقت شناس با نظم و ترتیب و دقیق هستید؟",
},
{
question_number: 7,
factor_code: "S",
text: "تا چه اندازه امنیت شغلی و درآمد ثابت برایتان اهمیت دارد؟",
},
{
question_number: 8,
factor_code: "L",
text: "احساس می کنید به چه میزان عشق، صمیمیت و مهرورزی نیاز دارید؟",
},
{
question_number: 9,
factor_code: "L",
text: "تا چه اندازه رفاه و سعادت انسان های دیگر برایتان مهم است؟",
},
{
question_number: 10,
factor_code: "L",
text: "تا چه اندازه تمایل دارید تجارب شخصی، احساسات عمیق و اسرار خود را با دیگران در میان بگذارید؟",
},
{
question_number: 11,
factor_code: "L",
text: "در ایجاد صمیمیت و برقراری ارتباط عاطفی با دیگران چقدر پیش قدم می شوید؟",
},
{
question_number: 12,
factor_code: "L",
text: "تا چه اندازه از دیدن، وقت گذراندن و گفتگو با دوستان و آشنایان خود لذت می برید؟",
},
{
question_number: 13,
factor_code: "L",
text: "میزان بخشندگی، گذشت و فراموش کردن خطای دیگران را در خود چگونه ارزیابی می کنید؟",
},
{
question_number: 14,
factor_code: "L",
text: "تا چه اندازه به ایجاد و حفظ روابط دوستانه، صمیمی و پایدار با دیگران علاقه مند هستید؟",
},
{
question_number: 15,
factor_code: "P",
text: "تا چه اندازه تمایل دارید رهبری گروه‌ها، هدایت پروژه‌ها یا مدیریت امور را بر عهده بگیرید؟",
},
{
question_number: 16,
factor_code: "P",
text: "رسیدن به موفقیت، موقعیت اجتماعی ممتاز و جایگاه برتر تا چه حد برایتان اهمیت دارد؟",
},
{
question_number: 17,
factor_code: "P",
text: "در صورت بروز نقد، مخالفت یا اشتباه از سوی دیگران، تا چه حد رفتار تند، قاطع یا اصلاحی نشان می‌دهید؟",
},
{
question_number: 18,
factor_code: "P",
text: "تا چه اندازه تمایل دارید کارها دقیقاً طبق نظرات، استانداردها و روش‌های شما انجام شوند؟",
},
{
question_number: 19,
factor_code: "P",
text: "در بحث‌ها و گفتگوها تا چه حد اصرار دارید حرف و نظر خود را به عنوان نظر درست به اثبات برسانید؟",
},
{
question_number: 20,
factor_code: "P",
text: "وقتی احساس می‌کنید حق با شماست، تا چه اندازه حاضر به رقابت یا پافشاری بر روی خواسته‌هایتان هستید؟",
},
{
question_number: 21,
factor_code: "P",
text: "دیدگاه شما نسبت به یادگیری، کسب دانش و بالا بردن اطلاعات تخصصی چیست؟",
},
{
question_number: 22,
factor_code: "Fr",
text: "تا چه اندازه تمایل دارید بدون دخالت، نظارت یا دستور دیگران اهداف شخصی خود را دنبال کنید؟",
},
{
question_number: 23,
factor_code: "Fr",
text: "در برابر احساس محدودیت، اجبار یا رعایت قوانین دست و پا گیر تا چه حد واکنش نشان می‌دهید؟",
},
{
question_number: 24,
factor_code: "Fr",
text: "میزان تمایل شما به داشتن حریم خصوصی، زمان تنهایی و استقلال در تصمیم‌گیری‌ها چقدر است؟",
},
{
question_number: 25,
factor_code: "Fr",
text: "تا چه اندازه رهایی از قید و بندها و برنامه‌های فشرده را ترجیح می‌دهید؟",
},
{
question_number: 26,
factor_code: "Fr",
text: "در مواجهه با فشار روانی یا استرس، تا چه حد نیاز دارید فضا و وقت مستقل داشته باشید؟",
},
{
question_number: 27,
factor_code: "Fr",
text: "تا چه اندازه تمایل دارید مسیر زندگی، سبک پوشش یا رفتارهای خود را بر اساس معیارهای فردی انتخاب کنید؟",
},
{
question_number: 28,
factor_code: "Fr",
text: "چقدر برای آزادی اندیشه و ابراز دیدگاه‌های غیرمعمول اهمیت قائل هستید؟",
},
{
question_number: 29,
factor_code: "Fu",
text: "تا چه اندازه اهل شوخ‌طبعی، خنده، لطیفه‌گویی و ایجاد فضای شاد هستید؟",
},
{
question_number: 30,
factor_code: "Fu",
text: "چقدر زمان و هزینه برای سرگرمی، تفریحات بی‌دغدغه و فعالیت‌های مفرح اختصاص می‌دهید؟",
},
{
question_number: 31,
factor_code: "Fu",
text: "تا چه اندازه از کارهای خلاقانه، هنری، بازی و سرگرمی لذت می‌برید؟",
},
{
question_number: 32,
factor_code: "Fu",
text: "چقدر قادر هستید لحظات سخت و جدیت زندگی را با نگاهی سبک، طنزآمیز و مثبت سپری کنید؟",
},
{
question_number: 33,
factor_code: "Fu",
text: "تمایل شما به شرکت در دورهمی‌ها، برنامه‌های تفریحی و سفر با دوستان چقدر است؟",
},
{
question_number: 34,
factor_code: "Fu",
text: "چقدر برای داشتن سرگرمی‌ها (هابی) و فعالیت‌های غیرکاری وقت می‌گذارید؟",
},
{
question_number: 35,
factor_code: "Fu",
text: "از یادگیری چیزهای جدید همراه با بازی، هیجان و نشاط چقدر استقبال می‌کنید؟",
},
];

345
src/data/question-data.ts

@ -1,345 +0,0 @@
import enQuestions from "@/data/questions/en.json";
import faQuestions from "@/data/questions/fa.json";
import type { MarriageGender } from "@/hooks/marriage/types";
import { defaultLocale, type Locale } from "@/translations/config";
import { getDictionary } from "@/translations/dictionaries";
export const bookingTerms = [
"You will be contacted by your consultant.",
"The call may start 10-15 minutes earlier or later than scheduled.",
"Make sure you are available and in a quiet place at least 10 minutes before the session.",
] as const;
export type QuestionCardIcon =
| "profile"
| "education"
| "details"
| "checklist"
| "contact"
| "family_marital";
type QuestionExtras = {
placeHolder: string;
range: [number, number];
options: string[];
noSearch?: boolean;
};
type QuestionAudienceRule = {
genders?: MarriageGender[];
maxAge?: number;
minAge?: number;
};
export type QuestionLogic = {
dependsOn: {
title: string;
values: string[];
};
};
export type QuestionField = {
title: string;
type: string;
required: boolean;
private?: boolean;
description: string;
tooltip: string;
extras: QuestionExtras;
audience?: QuestionAudienceRule;
requiredWhen?: QuestionAudienceRule;
logic?: QuestionLogic;
showGuardianNotice?: boolean;
originalSlug?: string;
originalIndex?: number;
englishTitle?: string;
};
export type QuestionListItem = {
slug: string;
title: string;
estimate: string;
progress: number;
icon: QuestionCardIcon;
required?: boolean;
note?: string;
showInfoBadge?: boolean;
summary: string;
checkpoints: readonly string[];
tooltip: string;
questions: readonly QuestionField[];
audience?: QuestionAudienceRule;
};
type RawQuestionListItem = {
title: string;
icon: string;
slug: string;
required?: boolean;
estimateTime: string;
tooltip: string;
progress: number;
description: string;
questions: QuestionField[];
audience?: QuestionAudienceRule;
};
const iconMap: Record<string, QuestionCardIcon> = {
"user-circle": "profile",
school: "education",
"heart-handshake": "details",
"file-text": "contact",
"layout-grid": "checklist",
};
const questionsByLocale: Record<string, RawQuestionListItem[]> = {
en: enQuestions as RawQuestionListItem[],
fa: faQuestions as RawQuestionListItem[],
};
function mapQuestionListItem(item: RawQuestionListItem): QuestionListItem {
return {
slug: item.slug,
title: item.title,
estimate: item.estimateTime,
progress: item.progress,
icon: iconMap[item.icon] ?? "details",
required: item.required,
showInfoBadge: Boolean(item.tooltip),
summary: item.description,
checkpoints: item.questions.map((question) => question.title),
tooltip: item.tooltip,
questions: item.questions,
};
}
export function getQuestionListItems(locale: Locale = defaultLocale) {
const rawItems =
questionsByLocale[locale] ??
questionsByLocale[defaultLocale] ??
questionsByLocale.en ??
[];
const enItems = questionsByLocale.en || [];
const mappedRawItems = rawItems.map((item) => {
const enItem = enItems.find((e) => e.slug === item.slug);
if (!enItem) return item;
return {
...item,
questions: item.questions.map((q, idx) => {
const enQ = enItem.questions[idx];
return {
...q,
englishTitle: enQ ? enQ.title : q.title,
};
}),
};
});
const items = mappedRawItems.map(mapQuestionListItem);
const fbIndex = items.findIndex((item) => item.slug === "family_background");
const mhIndex = items.findIndex(
(item) => item.slug === "marital_history_children",
);
if (fbIndex !== -1 && mhIndex !== -1) {
const fbItem = items[fbIndex];
const mhItem = items[mhIndex];
const dict = getDictionary(locale);
const mergedTitle =
(dict as any)["Family Background, Marital Status, and Children"] ||
"Family Background, Marital Status, and Children";
const mergedEstimate = (dict as any)["20 minutes"] || "20 minutes";
const fbQuestions = fbItem.questions.map((q, idx) => ({
...q,
originalSlug: "family_background",
originalIndex: idx,
}));
const mhQuestions = mhItem.questions.map((q, idx) => ({
...q,
originalSlug: "marital_history_children",
originalIndex: idx,
}));
const finalQuestions = [...fbQuestions, ...mhQuestions];
const mergedItem: QuestionListItem = {
slug: "family_marital_history",
title: mergedTitle,
estimate: mergedEstimate,
progress: 0,
icon: "family_marital",
required: fbItem.required || mhItem.required,
showInfoBadge: fbItem.showInfoBadge || mhItem.showInfoBadge,
summary: `${fbItem.summary}\n\n${mhItem.summary}`,
checkpoints: [...fbItem.checkpoints, ...mhItem.checkpoints],
tooltip: fbItem.tooltip || mhItem.tooltip,
questions: finalQuestions,
};
const newItems = [...items];
newItems[fbIndex] = mergedItem;
newItems.splice(mhIndex, 1);
// Reorder items: bring future_spouse_criteria and identity_verification above the tests
const personalityIndex = newItems.findIndex(
(item) => item.slug === "personality_test",
);
const spouseItem = newItems.find(
(item) => item.slug === "future_spouse_criteria",
);
const identityItem = newItems.find(
(item) => item.slug === "identity_verification",
);
if (personalityIndex !== -1 && (spouseItem || identityItem)) {
const filtered = newItems.filter(
(item) =>
item.slug !== "future_spouse_criteria" &&
item.slug !== "identity_verification",
);
const insertAt = filtered.findIndex(
(item) => item.slug === "personality_test",
);
if (insertAt !== -1) {
const result = [...filtered];
const itemsToInsert = [];
if (spouseItem) itemsToInsert.push(spouseItem);
if (identityItem) itemsToInsert.push(identityItem);
result.splice(insertAt, 0, ...itemsToInsert);
return result;
}
}
return newItems;
}
// Also handle reordering if fbIndex/mhIndex are not found (fallback)
const personalityIndex = items.findIndex(
(item) => item.slug === "personality_test",
);
const spouseItem = items.find(
(item) => item.slug === "future_spouse_criteria",
);
const identityItem = items.find(
(item) => item.slug === "identity_verification",
);
if (personalityIndex !== -1 && (spouseItem || identityItem)) {
const filtered = items.filter(
(item) =>
item.slug !== "future_spouse_criteria" &&
item.slug !== "identity_verification",
);
const insertAt = filtered.findIndex(
(item) => item.slug === "personality_test",
);
if (insertAt !== -1) {
const result = [...filtered];
const itemsToInsert = [];
if (spouseItem) itemsToInsert.push(spouseItem);
if (identityItem) itemsToInsert.push(identityItem);
result.splice(insertAt, 0, ...itemsToInsert);
return result;
}
}
return items;
}
export function getQuestionListItemBySlug(
slug: string,
locale: Locale = defaultLocale,
) {
return getQuestionListItems(locale).find((item) => item.slug === slug);
}
function matchesAudienceRule(
rule: QuestionAudienceRule | undefined,
profile: {
age?: number | null;
gender?: MarriageGender | null;
},
) {
if (!rule) {
return true;
}
if (
rule.genders?.length &&
profile.gender &&
!rule.genders.includes(profile.gender)
) {
return false;
}
if (
typeof rule.minAge === "number" &&
profile.age != null &&
profile.age < rule.minAge
) {
return false;
}
if (
typeof rule.maxAge === "number" &&
profile.age != null &&
profile.age > rule.maxAge
) {
return false;
}
return true;
}
export function isQuestionListItemVisibleForProfile(
item: QuestionListItem,
profile: {
age?: number | null;
gender?: MarriageGender | null;
},
) {
return matchesAudienceRule(item.audience, profile);
}
export function isQuestionVisibleForProfile(
question: QuestionField,
profile: {
age?: number | null;
gender?: MarriageGender | null;
},
) {
return matchesAudienceRule(question.audience, profile);
}
export function isQuestionRequiredForProfile(
question: QuestionField,
profile: {
age?: number | null;
gender?: MarriageGender | null;
},
) {
return (
question.required ||
(Boolean(question.requiredWhen) &&
matchesAudienceRule(question.requiredWhen, profile))
);
}
export function getRequiredQuestionsCount(
slug: string,
profile: {
age?: number | null;
gender?: MarriageGender | null;
},
locale: Locale = defaultLocale,
) {
const item = getQuestionListItemBySlug(slug, locale);
if (!item) return 0;
return item.questions.filter((q) => isQuestionRequiredForProfile(q, profile))
.length;
}

1870
src/data/questions/en.json
File diff suppressed because it is too large
View File

1870
src/data/questions/fa.json
File diff suppressed because it is too large
View File

47
src/data/section-slug-map.ts

@ -1,47 +0,0 @@
/**
* Maps backend section slugs to their corresponding frontend question list slugs.
* Some backend sections aggregate multiple frontend question categories under a
* single slug, so these two naming systems don't always match 1-to-1.
*/
export const BACKEND_TO_FRONTEND_SLUG_MAP: Record<string, string> = {
personal_identity: "personal_info",
contact_residence: "contact_residence_family_communication",
appearance_health: "appearance_health_activity",
education_career: "education_career_economic_status",
family_background: "family_marital_history",
marital_history: "family_marital_history",
beliefs_lifestyle: "beliefs_lifestyle_boundaries",
spouse_criteria: "future_spouse_criteria",
documents_verification: "identity_verification",
};
/**
* Reverse map: frontend slug backend slug.
* Built automatically from BACKEND_TO_FRONTEND_SLUG_MAP.
*/
export const FRONTEND_TO_BACKEND_SLUG_MAP: Record<string, string> =
Object.fromEntries(
Object.entries(BACKEND_TO_FRONTEND_SLUG_MAP).map(([backend, frontend]) => [
frontend,
backend,
]),
);
/**
* Resolve a frontend question-list slug to the backend section slug
* the API expects. Falls back to the original slug when no mapping exists.
*/
export function toBackendSlug(frontendSlug: string): string {
if (frontendSlug === "marital_history_children") {
return "marital_history";
}
return FRONTEND_TO_BACKEND_SLUG_MAP[frontendSlug] ?? frontendSlug;
}
/**
* Resolve a backend section slug to the frontend question-list slug
* used in the JSON data. Falls back to the original slug.
*/
export function toFrontendSlug(backendSlug: string): string {
return BACKEND_TO_FRONTEND_SLUG_MAP[backendSlug] ?? backendSlug;
}

4
src/hooks/marriage/types.ts

@ -44,6 +44,7 @@ export type MarriageField = {
label: string; label: string;
type: string; type: string;
value: MarriageFieldValue; value: MarriageFieldValue;
option_id?: string | string[];
private?: boolean; private?: boolean;
}; };
@ -202,7 +203,7 @@ export type CattellQuestion = {
question_number: number; question_number: number;
text: string; text: string;
item_type?: string; item_type?: string;
options?: string[];
options: { id: string; label: string; value: string | number }[];
}; };
export type CattellQuestionsResponse = { export type CattellQuestionsResponse = {
@ -237,6 +238,7 @@ export type GlasserQuestion = {
text: string; text: string;
factor?: string; factor?: string;
factor_code: string; factor_code: string;
options: { id: string; label: string; value: string | number }[];
}; };
export type GlasserQuestionsResponse = { export type GlasserQuestionsResponse = {

9
src/hooks/marriage/use-form-schema.ts

@ -9,6 +9,7 @@ export interface FormOption {
id: string; id: string;
value: string; value: string;
label: string; label: string;
order: number;
} }
export interface FormQuestion { export interface FormQuestion {
@ -19,17 +20,20 @@ export interface FormQuestion {
tooltip: string; tooltip: string;
placeholder: string; placeholder: string;
required: boolean; required: boolean;
is_required?: boolean;
show_guardian_notice: boolean; show_guardian_notice: boolean;
validation: Record<string, any>; validation: Record<string, any>;
ui_config: Record<string, any>; ui_config: Record<string, any>;
logic: Record<string, any> | null; logic: Record<string, any> | null;
is_visible: boolean; is_visible: boolean;
order: number;
options: FormOption[]; options: FormOption[];
} }
export interface FormCard { export interface FormCard {
id: string; id: string;
title: string; title: string;
order: number;
questions: FormQuestion[]; questions: FormQuestion[];
} }
@ -38,6 +42,7 @@ export interface FormSection {
title: string; title: string;
icon: string; icon: string;
is_required: boolean; is_required: boolean;
order: number;
estimated_minutes: number; estimated_minutes: number;
cards: FormCard[]; cards: FormCard[];
} }
@ -52,7 +57,7 @@ export interface FormSchemaResponse {
form_id: string; form_id: string;
version: number; version: number;
sections: FormSection[]; sections: FormSection[];
answers: Record<string, { value: any; option_id: string }>;
answers: Record<string, { value: any; option_id?: string | string[] }>;
progress: { progress: {
current_step: number; current_step: number;
total_steps: number; total_steps: number;
@ -89,7 +94,7 @@ export interface SaveAnswersPayload {
answers: Array<{ answers: Array<{
question_id: string; question_id: string;
value: any; value: any;
option_id?: string;
option_id?: string | string[];
}>; }>;
} }

105
src/hooks/marriage/use-section-data.ts

@ -1,8 +1,6 @@
"use client"; "use client";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { getQuestionListItemBySlug } from "@/data/question-data";
import { toBackendSlug } from "@/data/section-slug-map";
import { http } from "@/lib/http"; import { http } from "@/lib/http";
import type { MutationOptions, QueryOptions } from "./options"; import type { MutationOptions, QueryOptions } from "./options";
import { pathParam } from "./path-param"; import { pathParam } from "./path-param";
@ -14,42 +12,7 @@ import type {
import type { FormSchemaResponse } from "./use-form-schema"; import type { FormSchemaResponse } from "./use-form-schema";
function hashString(value: string) {
let hash = 0;
for (let index = 0; index < value.length; index += 1) {
hash = (hash * 31 + value.charCodeAt(index)) >>> 0;
}
return hash.toString(36);
}
function slugifyQuestionTitle(title: string) {
const slug = title
.normalize("NFKD")
.replace(/[\u0300-\u036f]/g, "")
.toLowerCase()
.replace(/[^a-z0-9]+/g, "_")
.replace(/^_+|_+$/g, "");
return slug || `field_${hashString(title)}`;
}
function getQuestionFieldKey(
title: string,
index: number,
englishTitle?: string,
) {
const finalTitle = englishTitle || title;
return `q${index + 1}_${slugifyQuestionTitle(finalTitle)}`;
}
function hasQuestionAnswerValue(value: unknown) {
if (value === null || value === undefined) {
return false;
}
if (typeof value === "string") {
return value.trim().length > 0;
}
return true;
}
export async function getMarriageSectionData( export async function getMarriageSectionData(
slug: string, slug: string,
@ -63,32 +26,11 @@ export async function getMarriageSectionData(
}; };
const lang = getClientCookie("HABIB_LANGUAGE") || getClientCookie("habib_language") || "en"; const lang = getClientCookie("HABIB_LANGUAGE") || getClientCookie("habib_language") || "en";
if (slug === "family_marital_history") {
const [fbData, mhData] = await Promise.all([
getMarriageSectionData("family_background"),
getMarriageSectionData("marital_history_children"),
]);
return {
slug: "family_marital_history",
data: [...(fbData.data || []), ...(mhData.data || [])],
current_step: fbData.current_step + mhData.current_step,
total_steps: fbData.total_steps + mhData.total_steps,
completion_percent:
fbData.total_steps + mhData.total_steps > 0
? ((fbData.current_step + mhData.current_step) /
(fbData.total_steps + mhData.total_steps)) *
100
: 0,
updated_at: fbData.updated_at || mhData.updated_at,
};
}
const backendSlug = toBackendSlug(slug);
const { data } = await http.get<any>( const { data } = await http.get<any>(
`/api/marriage/forms/profile/?lang=${lang}` `/api/marriage/forms/profile/?lang=${lang}`
); );
const sec = data.sections.find((s: any) => s.id === backendSlug);
const sec = data.sections.find((s: any) => s.id === slug);
if (!sec) { if (!sec) {
return { return {
slug, slug,
@ -114,7 +56,7 @@ export async function getMarriageSectionData(
}); });
}); });
const prog = data.progress.sections_progress[backendSlug] || {
const prog = data.progress.sections_progress[slug] || {
current_step: 0, current_step: 0,
total_steps: 0, total_steps: 0,
completion_percent: 0.0, completion_percent: 0.0,
@ -134,46 +76,6 @@ export async function updateMarriageSectionData(
slug: string, slug: string,
payload: UpdateMarriageSectionDataPayload, payload: UpdateMarriageSectionDataPayload,
) { ) {
if (slug === "family_marital_history") {
const answersPayload = payload.fields.map((f) => ({
question_id: f.key,
value: f.value,
option_id: (f as any).option_id || undefined,
}));
const { data } = await http.put<FormSchemaResponse>(
`/api/marriage/forms/profile/answers/`,
{
answers: answersPayload,
}
);
const fbProg = data.progress.sections_progress["family_background"] || {
current_step: 0,
total_steps: 0,
completion_percent: 0.0,
};
const mhProg = data.progress.sections_progress["marital_history"] || {
current_step: 0,
total_steps: 0,
completion_percent: 0.0,
};
return {
slug: "family_marital_history",
data: payload.fields,
current_step: fbProg.current_step + mhProg.current_step,
total_steps: fbProg.total_steps + mhProg.total_steps,
completion_percent:
fbProg.total_steps + mhProg.total_steps > 0
? ((fbProg.current_step + mhProg.current_step) /
(fbProg.total_steps + mhProg.total_steps)) *
100
: 0,
updated_at: new Date().toISOString(),
};
}
const answersPayload = payload.fields.map((f) => ({ const answersPayload = payload.fields.map((f) => ({
question_id: f.key, question_id: f.key,
value: f.value, value: f.value,
@ -187,8 +89,7 @@ export async function updateMarriageSectionData(
} }
); );
const backendSlug = toBackendSlug(slug);
const prog = data.progress.sections_progress[backendSlug] || {
const prog = data.progress.sections_progress[slug] || {
current_step: 0, current_step: 0,
total_steps: 0, total_steps: 0,
completion_percent: 0.0, completion_percent: 0.0,

132
src/lib/schema-adapter.ts

@ -3,13 +3,66 @@ import type {
FormSection, FormSection,
FormQuestion, FormQuestion,
} from "@/hooks/marriage/use-form-schema"; } from "@/hooks/marriage/use-form-schema";
import type {
QuestionListItem,
QuestionField,
QuestionCardIcon,
} from "@/data/question-data";
import { defaultLocale, type Locale } from "@/translations/config"; import { defaultLocale, type Locale } from "@/translations/config";
export type QuestionCardIcon =
| "profile"
| "education"
| "details"
| "checklist"
| "contact"
| "family_marital";
export type QuestionExtras = {
placeHolder: string;
range: [number, number];
options: string[];
noSearch?: boolean;
};
export type QuestionAudienceRule = {
genders?: string[];
maxAge?: number;
minAge?: number;
};
export type QuestionField = {
id: string;
title: string;
type: string;
order: number;
required: boolean;
baseRequired: boolean;
isVisible: boolean;
private?: boolean;
description: string;
tooltip: string;
validation?: any;
ui_config?: any;
extras: QuestionExtras;
audience?: QuestionAudienceRule;
requiredWhen?: QuestionAudienceRule;
showGuardianNotice?: boolean;
options: { id: string; value: string | number; label: string; order: number }[];
};
export type QuestionListItem = {
slug: string;
title: string;
estimate: string;
progress: number;
icon: QuestionCardIcon;
required: boolean;
showInfoBadge?: boolean;
summary: string;
checkpoints: string[];
tooltip?: string;
note?: string;
questions: QuestionField[];
};
const iconMap: Record<string, QuestionCardIcon> = { const iconMap: Record<string, QuestionCardIcon> = {
"user-circle": "profile", "user-circle": "profile",
school: "education", school: "education",
@ -18,13 +71,18 @@ const iconMap: Record<string, QuestionCardIcon> = {
"layout-grid": "checklist", "layout-grid": "checklist",
}; };
export function mapBackendQuestionToFrontend(bq: FormQuestion, index: number, originalSlug?: string): QuestionField {
export function mapBackendQuestionToFrontend(bq: FormQuestion, index: number): QuestionField {
return { return {
id: bq.id,
title: bq.title || "Untitled", title: bq.title || "Untitled",
englishTitle: bq.title,
type: bq.type, type: bq.type,
required: bq.required,
order: bq.order !== undefined ? bq.order : index,
required: bq.is_required !== undefined ? bq.is_required : bq.required,
baseRequired: bq.required,
isVisible: bq.is_visible,
private: bq.ui_config?.private, private: bq.ui_config?.private,
validation: bq.validation,
ui_config: bq.ui_config,
description: bq.description || "", description: bq.description || "",
tooltip: bq.tooltip || "", tooltip: bq.tooltip || "",
extras: { extras: {
@ -33,10 +91,8 @@ export function mapBackendQuestionToFrontend(bq: FormQuestion, index: number, or
range: bq.ui_config?.range || [0, 0], range: bq.ui_config?.range || [0, 0],
noSearch: bq.ui_config?.noSearch, noSearch: bq.ui_config?.noSearch,
}, },
logic: bq.logic ? { dependsOn: bq.logic.dependsOn } : undefined,
showGuardianNotice: bq.show_guardian_notice, showGuardianNotice: bq.show_guardian_notice,
originalSlug: originalSlug,
originalIndex: index,
options: [...(bq.options || [])].sort((a, b) => (a.order ?? 0) - (b.order ?? 0)),
}; };
} }
@ -47,14 +103,11 @@ export function mapBackendSectionToFrontend(
const allQuestions: QuestionField[] = []; const allQuestions: QuestionField[] = [];
let index = 0; let index = 0;
section.cards.forEach((card) => {
card.questions.forEach((q) => {
const fq = mapBackendQuestionToFrontend(q, index, section.id);
fq.required = q.required;
(fq as any).backendId = q.id;
(fq as any).isVisible = q.is_visible;
(fq as any).backendOptions = q.options;
const cards = [...(section.cards || [])].sort((a, b) => (a.order ?? 0) - (b.order ?? 0));
cards.forEach((card) => {
const questions = [...(card.questions || [])].sort((a, b) => (a.order ?? 0) - (b.order ?? 0));
questions.forEach((q) => {
const fq = mapBackendQuestionToFrontend(q, index);
allQuestions.push(fq); allQuestions.push(fq);
index++; index++;
}); });
@ -87,44 +140,5 @@ export function convertSchemaToFrontendItems(
return mapBackendSectionToFrontend(sec, progress); return mapBackendSectionToFrontend(sec, progress);
}); });
const fbIndex = rawItems.findIndex((item) => item.slug === "family_background");
const mhIndex = rawItems.findIndex((item) => item.slug === "marital_history_children" || item.slug === "marital_history");
if (fbIndex !== -1 && mhIndex !== -1) {
const fbItem = rawItems[fbIndex];
const mhItem = rawItems[mhIndex];
const fbProgInfo = schema.progress?.sections_progress?.[fbItem.slug];
const mhProgInfo = schema.progress?.sections_progress?.[mhItem.slug];
const fbTotal = fbProgInfo?.total_steps ?? 0;
const mhTotal = mhProgInfo?.total_steps ?? 0;
const fbCurrent = fbProgInfo?.current_step ?? 0;
const mhCurrent = mhProgInfo?.current_step ?? 0;
let combinedProgress = 0;
if (fbTotal + mhTotal > 0) {
combinedProgress = Math.round(((fbCurrent + mhCurrent) / (fbTotal + mhTotal)) * 100);
}
const mergedItem: QuestionListItem = {
slug: "family_marital_history",
title: locale === "fa" ? "سوابق ازدواج و خانواده" : "Family & Marital History",
estimate: "5 min",
progress: Math.max(0, Math.min(100, combinedProgress)),
icon: "family_marital" as QuestionCardIcon,
required: fbItem.required || mhItem.required,
summary: "",
tooltip: "",
checkpoints: [...fbItem.checkpoints, ...mhItem.checkpoints],
questions: [...fbItem.questions, ...mhItem.questions],
};
const newItems = [...rawItems];
newItems.splice(Math.max(fbIndex, mhIndex), 1);
newItems.splice(Math.min(fbIndex, mhIndex), 1, mergedItem);
return newItems;
}
return rawItems;
return rawItems.sort((a, b) => (schema.sections.find(s => s.id === a.slug)?.order ?? 0) - (schema.sections.find(s => s.id === b.slug)?.order ?? 0));
} }
Loading…
Cancel
Save