78 changed files with 2079 additions and 7516 deletions
-
30package-lock.json
-
11src/app/[lang]/questions-list/[slug]/page.tsx
-
5src/app/api/proxy/route.ts
-
82src/app/intro/page.tsx
-
2src/app/new-match/page.tsx
-
25src/app/questions-list/[slug]/page.tsx
-
275src/app/questions-list/[slug]/question-detail-client.test.tsx
-
512src/app/questions-list/[slug]/question-detail-client.tsx
-
87src/app/questions-list/page.tsx
-
6src/app/questions-list/sections-request.tsx
-
100src/app/terms/page.test.tsx
-
59src/app/terms/page.tsx
-
72src/components/Componentes/conditional-questions.tsx
-
10src/components/Componentes/fix-to-the-end.tsx
-
524src/components/Componentes/progress-helper.ts
-
171src/components/Componentes/question-answer-storage.tsx
-
260src/components/Componentes/question-answer.test.tsx
-
12src/components/Componentes/question-birthplace.tsx
-
6src/components/Componentes/question-button.tsx
-
2src/components/Componentes/question-card.tsx
-
49src/components/Componentes/question-checkbox.tsx
-
38src/components/Componentes/question-date.tsx
-
188src/components/Componentes/question-dropdown.tsx
-
18src/components/Componentes/question-exit-navigation-button.tsx
-
20src/components/Componentes/question-file.tsx
-
38src/components/Componentes/question-number.tsx
-
8src/components/Componentes/question-phone.tsx
-
18src/components/Componentes/question-photo.tsx
-
28src/components/Componentes/question-radio.tsx
-
29src/components/Componentes/question-renderer.tsx
-
22src/components/Componentes/question-section-flow.tsx
-
14src/components/Componentes/question-slider.tsx
-
35src/components/Componentes/question-text.tsx
-
10src/components/Componentes/question-textarea.tsx
-
49src/components/Componentes/question-title.tsx
-
78src/components/Componentes/required-steps-card.tsx
-
235src/components/Componentes/schema-question-flow.integration.test.tsx
-
265src/components/Componentes/slider-page.test.tsx
-
96src/components/Componentes/slider-page.tsx
-
17src/components/Componentes/slider-slide-one.tsx
-
1src/components/Componentes/test-questions-flow.tsx
-
114src/components/Componentes/ui-config.test.tsx
-
1067src/data/cattell-fallback.ts
-
183src/data/glasser-fallback.ts
-
345src/data/question-data.ts
-
1870src/data/questions/en.json
-
1870src/data/questions/fa.json
-
47src/data/section-slug-map.ts
-
4src/hooks/marriage/types.ts
-
9src/hooks/marriage/use-form-schema.ts
-
196src/hooks/marriage/use-section-data.ts
-
27src/lib/auth-bridge.ts
-
88src/lib/get-submit-path.test.ts
-
144src/lib/schema-adapter.ts
-
3src/translations/locales/ar.json
-
3src/translations/locales/az.json
-
3src/translations/locales/bn.json
-
3src/translations/locales/da.json
-
3src/translations/locales/de.json
-
4src/translations/locales/en.json
-
3src/translations/locales/es.json
-
4src/translations/locales/fa.json
-
3src/translations/locales/fr.json
-
3src/translations/locales/gu.json
-
3src/translations/locales/ha.json
-
3src/translations/locales/he.json
-
3src/translations/locales/hi.json
-
3src/translations/locales/id.json
-
3src/translations/locales/ks.json
-
3src/translations/locales/pt.json
-
3src/translations/locales/ru.json
-
3src/translations/locales/sw.json
-
3src/translations/locales/tg.json
-
3src/translations/locales/tr.json
-
3src/translations/locales/ul.json
-
3src/translations/locales/ur.json
-
3src/translations/locales/uz.json
-
3src/translations/locales/zh.json
@ -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; |
||||
@ -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(); |
||||
|
}); |
||||
|
}); |
||||
@ -0,0 +1,100 @@ |
|||||
|
import { render, screen, fireEvent, waitFor, cleanup } from '@testing-library/react'; |
||||
|
import { vi, describe, it, expect, beforeEach, afterEach } from 'vitest'; |
||||
|
import TermsRoute from './page'; |
||||
|
|
||||
|
const mockUseMarriageProfileQuery = vi.fn(); |
||||
|
const mockReplace = vi.fn(); |
||||
|
|
||||
|
vi.mock('@/hooks/marriage/use-profile-main', () => ({ |
||||
|
useMarriageProfileQuery: () => mockUseMarriageProfileQuery(), |
||||
|
})); |
||||
|
|
||||
|
vi.mock('next/navigation', () => ({ |
||||
|
useRouter: () => ({ |
||||
|
replace: mockReplace, |
||||
|
}), |
||||
|
})); |
||||
|
|
||||
|
vi.mock('@/translations/provider', () => ({ |
||||
|
useI18n: () => ({ |
||||
|
locale: 'en', |
||||
|
dictionary: { |
||||
|
"Failed to load profile. Please try again.": "Failed to load profile. Please try again.", |
||||
|
"Retry": "Retry", |
||||
|
}, |
||||
|
}), |
||||
|
})); |
||||
|
|
||||
|
vi.mock('@/translations/config', () => ({ |
||||
|
localizePath: (path: string) => path, |
||||
|
})); |
||||
|
|
||||
|
vi.mock('@/components/Componentes/slider-page', () => ({ |
||||
|
default: () => <div data-testid="slider-page">SliderPage</div>, |
||||
|
})); |
||||
|
|
||||
|
describe('TermsRoute Guard', () => { |
||||
|
beforeEach(() => { |
||||
|
vi.clearAllMocks(); |
||||
|
}); |
||||
|
|
||||
|
afterEach(() => { |
||||
|
cleanup(); |
||||
|
}); |
||||
|
|
||||
|
it('renders loading state when isLoading is true', () => { |
||||
|
mockUseMarriageProfileQuery.mockReturnValue({ isLoading: true }); |
||||
|
render(<TermsRoute />); |
||||
|
expect(screen.queryByTestId('slider-page')).not.toBeInTheDocument(); |
||||
|
// The spinner should be visible, maybe just check for a div. We can check slider is absent.
|
||||
|
}); |
||||
|
|
||||
|
it('renders Error/Retry when isError is true', () => { |
||||
|
const mockRefetch = vi.fn(); |
||||
|
mockUseMarriageProfileQuery.mockReturnValue({ isError: true, refetch: mockRefetch }); |
||||
|
render(<TermsRoute />); |
||||
|
|
||||
|
expect(screen.getByText('Failed to load profile. Please try again.')).toBeInTheDocument(); |
||||
|
const retryBtn = screen.getByText('Retry'); |
||||
|
fireEvent.click(retryBtn); |
||||
|
expect(mockRefetch).toHaveBeenCalled(); |
||||
|
expect(screen.queryByTestId('slider-page')).not.toBeInTheDocument(); |
||||
|
}); |
||||
|
|
||||
|
it('pending_onboarding => SliderPage renders', () => { |
||||
|
mockUseMarriageProfileQuery.mockReturnValue({ |
||||
|
isLoading: false, |
||||
|
data: { status: 'pending_onboarding' } |
||||
|
}); |
||||
|
render(<TermsRoute />); |
||||
|
|
||||
|
expect(screen.getByTestId('slider-page')).toBeInTheDocument(); |
||||
|
expect(mockReplace).not.toHaveBeenCalled(); |
||||
|
}); |
||||
|
|
||||
|
it('pending_info => redirects to Questions and SliderPage is NOT rendered', async () => { |
||||
|
mockUseMarriageProfileQuery.mockReturnValue({ |
||||
|
isLoading: false, |
||||
|
data: { status: 'pending_info' } |
||||
|
}); |
||||
|
render(<TermsRoute />); |
||||
|
|
||||
|
expect(screen.queryByTestId('slider-page')).not.toBeInTheDocument(); |
||||
|
await waitFor(() => { |
||||
|
expect(mockReplace).toHaveBeenCalled(); // Should redirect to /questions-list or /questions-list/personal_info
|
||||
|
}); |
||||
|
}); |
||||
|
|
||||
|
it('waiting => redirects to Finding/Waiting Page and SliderPage is NOT rendered', async () => { |
||||
|
mockUseMarriageProfileQuery.mockReturnValue({ |
||||
|
isLoading: false, |
||||
|
data: { status: 'waiting' } |
||||
|
}); |
||||
|
render(<TermsRoute />); |
||||
|
|
||||
|
expect(screen.queryByTestId('slider-page')).not.toBeInTheDocument(); |
||||
|
await waitFor(() => { |
||||
|
expect(mockReplace).toHaveBeenCalledWith('/finding-match'); |
||||
|
}); |
||||
|
}); |
||||
|
}); |
||||
@ -1,5 +1,64 @@ |
|||||
|
"use client"; |
||||
|
|
||||
|
import { useEffect } from "react"; |
||||
|
import { useRouter } from "next/navigation"; |
||||
|
import { useMarriageProfileQuery } from "@/hooks/marriage/use-profile-main"; |
||||
|
import { getSubmitPath } from "@/lib/get-submit-path"; |
||||
import SliderPage from "@/components/Componentes/slider-page"; |
import SliderPage from "@/components/Componentes/slider-page"; |
||||
|
import Button from "@/components/Componentes/button"; |
||||
|
import { useI18n } from "@/translations/provider"; |
||||
|
import { localizePath } from "@/translations/config"; |
||||
|
|
||||
export default function TermsRoute() { |
export default function TermsRoute() { |
||||
|
const { data: profile, isLoading, isError, refetch } = useMarriageProfileQuery(); |
||||
|
const router = useRouter(); |
||||
|
const { locale, dictionary: t } = useI18n(); |
||||
|
|
||||
|
useEffect(() => { |
||||
|
if (!isLoading && profile) { |
||||
|
if (profile.status !== "pending_onboarding") { |
||||
|
const target = getSubmitPath(profile); |
||||
|
if (target !== "/terms") { |
||||
|
router.replace(localizePath(target, locale)); |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
}, [profile, isLoading, router, locale]); |
||||
|
|
||||
|
if (isLoading) { |
||||
|
return ( |
||||
|
<div className="flex h-[100dvh] items-center justify-center bg-[#F5F5F5]"> |
||||
|
<div className="size-8 animate-spin rounded-full border-4 border-[#F14B46] border-t-transparent" /> |
||||
|
</div> |
||||
|
); |
||||
|
} |
||||
|
|
||||
|
if (isError) { |
||||
|
return ( |
||||
|
<div className="flex h-[100dvh] flex-col items-center justify-center p-4 bg-[#F5F5F5]"> |
||||
|
<p className="mb-4 text-center font-medium text-[#F14B46]"> |
||||
|
{/* @ts-expect-error - missing key */} |
||||
|
{t["Failed to load profile. Please try again."] || "Failed to load profile. Please try again."} |
||||
|
</p> |
||||
|
<div className="w-full max-w-[200px]"> |
||||
|
<Button onClick={() => refetch()}> |
||||
|
{/* @ts-expect-error - missing key */} |
||||
|
{t["Retry"] || "Retry"} |
||||
|
</Button> |
||||
|
</div> |
||||
|
</div> |
||||
|
); |
||||
|
} |
||||
|
|
||||
|
if (profile && profile.status !== "pending_onboarding") { |
||||
|
// If target is /terms but status is not pending_onboarding, we should still not render SliderPage
|
||||
|
// to prevent showing Onboarding to users who already finished it but have an unknown status.
|
||||
|
return ( |
||||
|
<div className="flex h-[100dvh] items-center justify-center bg-[#F5F5F5]"> |
||||
|
<div className="size-8 animate-spin rounded-full border-4 border-[#F14B46] border-t-transparent" /> |
||||
|
</div> |
||||
|
); |
||||
|
} |
||||
|
|
||||
return <SliderPage />; |
return <SliderPage />; |
||||
} |
} |
||||
@ -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; |
|
||||
@ -1,524 +0,0 @@ |
|||||
import { |
|
||||
isQuestionRequiredForProfile, |
|
||||
isQuestionVisibleForProfile, |
|
||||
type QuestionListItem, |
|
||||
} from "@/data/question-data"; |
|
||||
import type { |
|
||||
MarriageFieldValue, |
|
||||
MarriageGender, |
|
||||
} from "@/hooks/marriage/types"; |
|
||||
import { hasQuestionAnswerValue } from "./question-answer-storage"; |
|
||||
|
|
||||
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; |
|
||||
} |
|
||||
|
|
||||
function isFieldAnswered( |
|
||||
q: Record<string, unknown>, |
|
||||
field: Record<string, unknown> | undefined, |
|
||||
): boolean { |
|
||||
if (!field || !hasQuestionAnswerValue(field.value as MarriageFieldValue)) { |
|
||||
return false; |
|
||||
} |
|
||||
if (q.type === "birthplace") { |
|
||||
const strVal = String(field.value); |
|
||||
const parts = strVal.split(",").map((p) => p.trim()); |
|
||||
return parts.length >= 2 && parts[0].length > 0 && parts[1].length > 0; |
|
||||
} |
|
||||
return true; |
|
||||
} |
|
||||
|
|
||||
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 slugifyTitle(title: string) { |
|
||||
const slug = title |
|
||||
.normalize("NFKD") |
|
||||
.replace(/[\u0300-\u036f]/g, "") |
|
||||
.toLowerCase() |
|
||||
.replace(/[^a-z0-9]+/g, "_") |
|
||||
.replace(/^_+|_+$/g, ""); |
|
||||
return slug || `field_${hashString(title)}`; |
|
||||
} |
|
||||
|
|
||||
export function getLocalSectionProgress( |
|
||||
item: QuestionListItem, |
|
||||
profile: { gender?: MarriageGender | null } | null | undefined, |
|
||||
age: number | null, |
|
||||
): number | null { |
|
||||
try { |
|
||||
if (typeof window === "undefined") return null; |
|
||||
|
|
||||
if (item.slug === "personality_test" || item.slug === "glasser_5_needs_test") { |
|
||||
const draftKey = `marriage:tests:${item.slug}:draft`; |
|
||||
const draftRaw = window.localStorage.getItem(draftKey); |
|
||||
if (draftRaw) { |
|
||||
try { |
|
||||
const parsed = JSON.parse(draftRaw); |
|
||||
if (parsed && typeof parsed.answers === "object" && parsed.answers !== null) { |
|
||||
const answeredCount = Object.keys(parsed.answers).length; |
|
||||
const totalCount = item.slug === "personality_test" ? 187 : 35; |
|
||||
return Math.max( |
|
||||
0, |
|
||||
Math.min( |
|
||||
100, |
|
||||
Math.round((answeredCount / totalCount) * 100), |
|
||||
), |
|
||||
); |
|
||||
} |
|
||||
} catch {} |
|
||||
} |
|
||||
return null; |
|
||||
} |
|
||||
|
|
||||
const fields: Record<string, unknown>[] = []; |
|
||||
let hasFoundStorage = false; |
|
||||
|
|
||||
const mainKey = `marriage:sections:${item.slug}:answers`; |
|
||||
const mainRaw = window.localStorage.getItem(mainKey); |
|
||||
if (mainRaw) { |
|
||||
try { |
|
||||
const parsed = JSON.parse(mainRaw); |
|
||||
if ( |
|
||||
parsed && |
|
||||
Array.isArray(parsed.fields) && |
|
||||
parsed.fields.length > 0 |
|
||||
) { |
|
||||
fields.push(...parsed.fields); |
|
||||
hasFoundStorage = true; |
|
||||
} |
|
||||
} catch {} |
|
||||
} |
|
||||
|
|
||||
if (item.slug === "family_marital_history") { |
|
||||
const fbRaw = window.localStorage.getItem( |
|
||||
"marriage:sections:family_background:answers", |
|
||||
); |
|
||||
if (fbRaw) { |
|
||||
try { |
|
||||
const parsed = JSON.parse(fbRaw); |
|
||||
if (parsed && Array.isArray(parsed.fields)) { |
|
||||
fields.push(...parsed.fields); |
|
||||
hasFoundStorage = true; |
|
||||
} |
|
||||
} catch {} |
|
||||
} |
|
||||
|
|
||||
const mhRaw = window.localStorage.getItem( |
|
||||
"marriage:sections:marital_history_children:answers", |
|
||||
); |
|
||||
if (mhRaw) { |
|
||||
try { |
|
||||
const parsed = JSON.parse(mhRaw); |
|
||||
if (parsed && Array.isArray(parsed.fields)) { |
|
||||
fields.push(...parsed.fields); |
|
||||
hasFoundStorage = true; |
|
||||
} |
|
||||
} catch {} |
|
||||
} |
|
||||
} |
|
||||
|
|
||||
if (!hasFoundStorage || fields.length === 0) { |
|
||||
return null; |
|
||||
} |
|
||||
|
|
||||
const profileContext = { |
|
||||
age, |
|
||||
gender: profile?.gender, |
|
||||
}; |
|
||||
|
|
||||
const hasDobQuestion = item.questions.some( |
|
||||
(q) => q.title === "Date of Birth" || q.title === "تاریخ تولد", |
|
||||
); |
|
||||
|
|
||||
const profileVisible = item.questions |
|
||||
.filter((question) => { |
|
||||
if ( |
|
||||
hasDobQuestion && |
|
||||
(question.title === "Age" || question.title === "سن") |
|
||||
) { |
|
||||
return false; |
|
||||
} |
|
||||
return isQuestionVisibleForProfile(question, profileContext); |
|
||||
}) |
|
||||
.map((question) => ({ |
|
||||
...question, |
|
||||
required: isQuestionRequiredForProfile(question, profileContext), |
|
||||
})); |
|
||||
|
|
||||
const getQuestionFieldKey = (question: any, questionIndex: number) => { |
|
||||
const index = |
|
||||
question.originalIndex !== undefined |
|
||||
? question.originalIndex |
|
||||
: questionIndex; |
|
||||
return `q${index + 1}_${slugifyTitle(question.englishTitle || question.title)}`; |
|
||||
}; |
|
||||
|
|
||||
const findAnswer = (question: any, questionIndex: number) => { |
|
||||
const key = getQuestionFieldKey(question, questionIndex); |
|
||||
const engSlug = slugifyTitle(question.englishTitle || question.title); |
|
||||
const field = fields.find( |
|
||||
(f) => |
|
||||
f && |
|
||||
(f.key === key || |
|
||||
f.label === question.title || |
|
||||
f.label === question.englishTitle || |
|
||||
(typeof f.key === "string" && |
|
||||
(f.key.endsWith(`.${engSlug}`) || f.key.endsWith(`_${engSlug}`)))), |
|
||||
); |
|
||||
return field?.value; |
|
||||
}; |
|
||||
|
|
||||
// Find employment status question by title or by choices length (10)
|
|
||||
const employmentQuestionIndex = profileVisible.findIndex((q) => { |
|
||||
return ( |
|
||||
q.title === "Employment Status" || |
|
||||
q.title === "وضعیت اشتغال" || |
|
||||
(q.type === "dropdown" && q.extras?.options?.length === 10) |
|
||||
); |
|
||||
}); |
|
||||
|
|
||||
let employmentSelectedOptionIndex = -1; |
|
||||
if (employmentQuestionIndex !== -1) { |
|
||||
const employmentQuestion = profileVisible[employmentQuestionIndex]; |
|
||||
const ans = findAnswer(employmentQuestion, employmentQuestionIndex); |
|
||||
if (ans) { |
|
||||
employmentSelectedOptionIndex = |
|
||||
employmentQuestion.extras?.options?.indexOf(String(ans)) ?? -1; |
|
||||
} |
|
||||
} |
|
||||
|
|
||||
// Find Parents' Survival Status question index and check selected index
|
|
||||
const survivalStatusQuestionIndex = profileVisible.findIndex((q) => { |
|
||||
return ( |
|
||||
q.title === "Parents' Survival Status" || |
|
||||
q.title === "وضعیت حیات والدین" |
|
||||
); |
|
||||
}); |
|
||||
|
|
||||
let survivalSelectedOptionIndex = -1; |
|
||||
if (survivalStatusQuestionIndex !== -1) { |
|
||||
const survivalQuestion = profileVisible[survivalStatusQuestionIndex]; |
|
||||
const ans = findAnswer(survivalQuestion, survivalStatusQuestionIndex); |
|
||||
if (ans) { |
|
||||
survivalSelectedOptionIndex = |
|
||||
survivalQuestion.extras?.options?.indexOf(String(ans)) ?? -1; |
|
||||
} |
|
||||
} |
|
||||
|
|
||||
// Find Parents' Marital Status question index and check selected index
|
|
||||
const maritalStatusQuestionIndex = profileVisible.findIndex((q) => { |
|
||||
return ( |
|
||||
q.title === "Parents' Marital Status" || q.title === "وضعیت تأهل والدین" |
|
||||
); |
|
||||
}); |
|
||||
|
|
||||
let isCircumstancesSelected = false; |
|
||||
if (maritalStatusQuestionIndex !== -1) { |
|
||||
const maritalQuestion = profileVisible[maritalStatusQuestionIndex]; |
|
||||
const ans = findAnswer(maritalQuestion, maritalStatusQuestionIndex); |
|
||||
if (ans) { |
|
||||
const selectedIdx = |
|
||||
maritalQuestion.extras?.options?.indexOf(String(ans)) ?? -1; |
|
||||
isCircumstancesSelected = selectedIdx === 2; |
|
||||
} |
|
||||
} |
|
||||
|
|
||||
// Find Current Marital Status question index and check selected index
|
|
||||
const currentMaritalQuestionIndex = profileVisible.findIndex((q) => { |
|
||||
return ( |
|
||||
q.title === "Current Marital Status" || q.title === "وضعیت تأهل فعلی" |
|
||||
); |
|
||||
}); |
|
||||
|
|
||||
let maritalSelectedIndex = -1; |
|
||||
if (currentMaritalQuestionIndex !== -1) { |
|
||||
const maritalQuestion = profileVisible[currentMaritalQuestionIndex]; |
|
||||
const ans = findAnswer(maritalQuestion, currentMaritalQuestionIndex); |
|
||||
if (ans) { |
|
||||
maritalSelectedIndex = |
|
||||
maritalQuestion.extras?.options?.indexOf(String(ans)) ?? -1; |
|
||||
} |
|
||||
} |
|
||||
|
|
||||
// Find Children and Guardianship Status question index
|
|
||||
const custodyQuestionIndex = profileVisible.findIndex((q) => { |
|
||||
return ( |
|
||||
q.title === "Children and Guardianship Status" || |
|
||||
q.title === "وضعیت فرزند و تکفل" |
|
||||
); |
|
||||
}); |
|
||||
|
|
||||
let hasChildrenSelected = false; |
|
||||
let hasAnyGuardianshipSelected = false; |
|
||||
|
|
||||
if (custodyQuestionIndex !== -1) { |
|
||||
const custodyQuestion = profileVisible[custodyQuestionIndex]; |
|
||||
const ans = findAnswer(custodyQuestion, custodyQuestionIndex); |
|
||||
if (ans) { |
|
||||
const ansList = Array.isArray(ans) ? ans.map(String) : [String(ans)]; |
|
||||
hasChildrenSelected = ansList.some( |
|
||||
(val) => val.includes("Have children") || val.includes("فرزند دارم"), |
|
||||
); |
|
||||
hasAnyGuardianshipSelected = ansList.some( |
|
||||
(val) => |
|
||||
val.includes("Have children") || |
|
||||
val.includes("فرزند دارم") || |
|
||||
val.includes("under my guardianship") || |
|
||||
val.includes("تحت تکفل"), |
|
||||
); |
|
||||
} |
|
||||
} |
|
||||
|
|
||||
const filtered = profileVisible.filter((question, index) => { |
|
||||
// Check if this is one of the marital status/children questions
|
|
||||
if (currentMaritalQuestionIndex !== -1) { |
|
||||
const isDuration = |
|
||||
index === currentMaritalQuestionIndex + 1 || |
|
||||
question.title === "Previous Marriage Duration" || |
|
||||
question.title === "مدت ازدواج یا عقد قبلی"; |
|
||||
|
|
||||
const isSeparation = |
|
||||
index === currentMaritalQuestionIndex + 2 || |
|
||||
question.title === "Reason for Separation" || |
|
||||
question.title === "علت جدایی، در صورت وجود"; |
|
||||
|
|
||||
const isCustody = |
|
||||
index === currentMaritalQuestionIndex + 3 || |
|
||||
question.title === "Children and Guardianship Status" || |
|
||||
question.title === "وضعیت فرزند و تکفل"; |
|
||||
|
|
||||
const isChildrenCount = |
|
||||
index === currentMaritalQuestionIndex + 4 || |
|
||||
question.title === "Number of Children" || |
|
||||
question.title === "تعداد فرزندان"; |
|
||||
|
|
||||
const isChildrenExplanation = |
|
||||
index === currentMaritalQuestionIndex + 5 || |
|
||||
question.title === "Short Children/Guardianship Explanation" || |
|
||||
question.title === "توضیح کوتاه درباره شرایط فرزند یا تکفل"; |
|
||||
|
|
||||
if (isDuration) { |
|
||||
return [1, 2, 3].includes(maritalSelectedIndex); |
|
||||
} |
|
||||
if (isSeparation) { |
|
||||
return [1, 2].includes(maritalSelectedIndex); |
|
||||
} |
|
||||
if (isCustody) { |
|
||||
return [2, 3].includes(maritalSelectedIndex); |
|
||||
} |
|
||||
if (isChildrenCount) { |
|
||||
return [2, 3].includes(maritalSelectedIndex) && hasChildrenSelected; |
|
||||
} |
|
||||
if (isChildrenExplanation) { |
|
||||
return ( |
|
||||
[2, 3].includes(maritalSelectedIndex) && hasAnyGuardianshipSelected |
|
||||
); |
|
||||
} |
|
||||
} |
|
||||
|
|
||||
// Check if this is one of the three job-related questions
|
|
||||
if (employmentQuestionIndex !== -1) { |
|
||||
const isJobTitle = |
|
||||
index === employmentQuestionIndex + 1 || |
|
||||
question.title === "Job Title" || |
|
||||
question.title === "عنوان شغلی"; |
|
||||
|
|
||||
const isWorkLocation = |
|
||||
index === employmentQuestionIndex + 2 || |
|
||||
question.title === "Work Location" || |
|
||||
question.title === "محل فعالیت"; |
|
||||
|
|
||||
const isMonthlyIncome = |
|
||||
index === employmentQuestionIndex + 3 || |
|
||||
question.title === "Monthly Income" || |
|
||||
question.title === "میزان درآمد ماهانه"; |
|
||||
|
|
||||
if (isJobTitle || isWorkLocation || isMonthlyIncome) { |
|
||||
// If no employment status is selected yet, hide them by default
|
|
||||
if (employmentSelectedOptionIndex === -1) { |
|
||||
return false; |
|
||||
} |
|
||||
|
|
||||
// Options logic:
|
|
||||
// Show all 3 for: index 0 (Full-time), 1 (Part-time), 2 (Self-employed), 3 (Entrepreneur), 5 (Working Student)
|
|
||||
const showAll = [0, 1, 2, 3, 5].includes( |
|
||||
employmentSelectedOptionIndex, |
|
||||
); |
|
||||
// Hide all 3 for: index 4 (Student), 6 (Student & Job Seeking), 7 (Job Seeking / Unemployed), 8 (Homemaker)
|
|
||||
const hideAll = [4, 6, 7, 8].includes(employmentSelectedOptionIndex); |
|
||||
// Special Retired logic: index 9 (Retired)
|
|
||||
const isRetired = employmentSelectedOptionIndex === 9; |
|
||||
|
|
||||
if (showAll) { |
|
||||
return true; |
|
||||
} |
|
||||
if (hideAll) { |
|
||||
return false; |
|
||||
} |
|
||||
if (isRetired) { |
|
||||
if (isJobTitle || isMonthlyIncome) { |
|
||||
return true; |
|
||||
} |
|
||||
if (isWorkLocation) { |
|
||||
return false; |
|
||||
} |
|
||||
} |
|
||||
return false; |
|
||||
} |
|
||||
} |
|
||||
|
|
||||
// Check Parents' Survival Status to decide if Parents' Marital Status is visible
|
|
||||
if (survivalStatusQuestionIndex !== -1) { |
|
||||
const isParentsMaritalStatus = |
|
||||
question.title === "Parents' Marital Status" || |
|
||||
question.title === "وضعیت تأهل والدین"; |
|
||||
|
|
||||
if (isParentsMaritalStatus) { |
|
||||
return survivalSelectedOptionIndex === 0; |
|
||||
} |
|
||||
} |
|
||||
|
|
||||
// Default dependsOn logic
|
|
||||
if (question.logic?.dependsOn) { |
|
||||
const { title, values } = question.logic.dependsOn; |
|
||||
const dependentQuestionIndex = profileVisible.findIndex( |
|
||||
(q) => q.title === title, |
|
||||
); |
|
||||
|
|
||||
if (dependentQuestionIndex !== -1) { |
|
||||
const dependentQuestion = profileVisible[dependentQuestionIndex]; |
|
||||
const answer = findAnswer(dependentQuestion, dependentQuestionIndex); |
|
||||
if (Array.isArray(answer)) { |
|
||||
return answer.some((ans) => values.includes(String(ans))); |
|
||||
} |
|
||||
return values.includes(String(answer)); |
|
||||
} |
|
||||
return false; |
|
||||
} |
|
||||
return true; |
|
||||
}); |
|
||||
|
|
||||
const activeQuestions = filtered.map((question) => { |
|
||||
if ( |
|
||||
question.title === "Short Family Description" || |
|
||||
question.title === "توضیح کوتاه درباره خانواده" |
|
||||
) { |
|
||||
return { |
|
||||
...question, |
|
||||
required: isCircumstancesSelected, |
|
||||
}; |
|
||||
} |
|
||||
|
|
||||
if ( |
|
||||
question.title === "Previous Marriage Duration" || |
|
||||
question.title === "مدت ازدواج یا عقد قبلی" || |
|
||||
question.title === "Number of Children" || |
|
||||
question.title === "تعداد فرزندان" || |
|
||||
question.title === "Short Children/Guardianship Explanation" || |
|
||||
question.title === "توضیح کوتاه درباره شرایط فرزند یا تکفل" || |
|
||||
question.title === "Additional details about family responsibility" || |
|
||||
question.title === "توضیحات تکمیلی درباره مسئولیت خانوادگی" || |
|
||||
question.title === "Do the supported individual(s) live with you?" || |
|
||||
question.title === "آیا فرد یا افراد تحت حمایت با شما زندگی میکنند؟" || |
|
||||
question.title === "What is the custody status of your child(ren)?" || |
|
||||
question.title === "وضعیت حضانت فرزند یا فرزندان شما چگونه است؟" || |
|
||||
question.title === |
|
||||
"Does the custody, visitation, or relocation schedule impact your residence or immigration?" || |
|
||||
question.title === |
|
||||
"آیا برنامه حضانت، ملاقات یا جابهجایی فرزند بر محل زندگی یا امکان مهاجرت شما تأثیر میگذارد؟" || |
|
||||
question.title === |
|
||||
"What is the payment or receipt status of child support?" || |
|
||||
question.title === |
|
||||
"وضعیت پرداخت یا دریافت نفقه و حمایت مالی فرزند چگونه است؟" || |
|
||||
question.title === |
|
||||
"Acceptance of necessary communication between future spouse and the other parent" || |
|
||||
question.title === "پذیرش ارتباط ضروری همسر آینده با والد دیگرِ فرزند" || |
|
||||
question.title === "پذیرش ارتباط ضروری همسر آینده با والد دیگر فرزند" |
|
||||
) { |
|
||||
return { |
|
||||
...question, |
|
||||
required: true, |
|
||||
}; |
|
||||
} |
|
||||
return question; |
|
||||
}); |
|
||||
|
|
||||
const requiredQuestions = activeQuestions.filter((q) => q.required); |
|
||||
|
|
||||
if (requiredQuestions.length === 0) { |
|
||||
return 100; |
|
||||
} |
|
||||
|
|
||||
const answeredCount = requiredQuestions.filter((q) => { |
|
||||
const idx = profileVisible.indexOf(q); |
|
||||
const key = getQuestionFieldKey(q, idx); |
|
||||
const engSlug = slugifyTitle(q.englishTitle || q.title); |
|
||||
const field = fields.find( |
|
||||
(f) => |
|
||||
f && |
|
||||
(f.key === key || |
|
||||
f.label === q.title || |
|
||||
f.label === q.englishTitle || |
|
||||
(typeof f.key === "string" && |
|
||||
(f.key.endsWith(`.${engSlug}`) || f.key.endsWith(`_${engSlug}`)))), |
|
||||
); |
|
||||
return isFieldAnswered(q, field); |
|
||||
}).length; |
|
||||
|
|
||||
return Math.max( |
|
||||
0, |
|
||||
Math.min( |
|
||||
100, |
|
||||
Math.round((answeredCount / requiredQuestions.length) * 100), |
|
||||
), |
|
||||
); |
|
||||
} catch (_e) { |
|
||||
return null; |
|
||||
} |
|
||||
} |
|
||||
@ -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); |
||||
|
}); |
||||
|
}); |
||||
@ -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(); |
||||
|
}); |
||||
|
}); |
||||
@ -0,0 +1,265 @@ |
|||||
|
import { render, screen, fireEvent, waitFor, cleanup } from '@testing-library/react'; |
||||
|
import userEvent from '@testing-library/user-event'; |
||||
|
import { vi, describe, it, expect, beforeEach, afterEach } from 'vitest'; |
||||
|
import SliderPage from './slider-page'; |
||||
|
|
||||
|
const mockMutateAsync = vi.fn(); |
||||
|
const mockGetMarriageProfile = vi.fn(); |
||||
|
const mockSetQueryData = vi.fn(); |
||||
|
const mockReplace = vi.fn(); |
||||
|
const mockRemoveItem = vi.spyOn(Storage.prototype, 'removeItem'); |
||||
|
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; |
||||
|
|
||||
|
vi.mock('@/hooks/marriage/use-profile-basic', () => ({ |
||||
|
useUpdateMarriageProfileBasicMutation: () => ({ |
||||
|
mutateAsync: mockMutateAsync, |
||||
|
isPending: false, |
||||
|
}), |
||||
|
})); |
||||
|
|
||||
|
vi.mock('@/hooks/marriage/use-profile-main', () => ({ |
||||
|
getMarriageProfile: (...args: any[]) => mockGetMarriageProfile(...args), |
||||
|
useMarriageProfileQuery: () => ({ data: undefined, refetch: vi.fn() }), |
||||
|
})); |
||||
|
|
||||
|
vi.mock('@/hooks/marriage/query-keys', () => ({ |
||||
|
marriageQueryKeys: { |
||||
|
profile: () => ['marriage', 'profile'], |
||||
|
}, |
||||
|
})); |
||||
|
|
||||
|
vi.mock('next/navigation', () => ({ |
||||
|
useRouter: () => ({ |
||||
|
replace: mockReplace, |
||||
|
back: vi.fn(), |
||||
|
}), |
||||
|
})); |
||||
|
|
||||
|
vi.mock('@/translations/provider', () => ({ |
||||
|
useI18n: () => ({ |
||||
|
locale: 'en', |
||||
|
dictionary: { |
||||
|
"Failed to update profile basic details. Please try again.": "Failed to update profile basic details. Please try again.", |
||||
|
"Accept & Continue": "Accept & Continue", |
||||
|
}, |
||||
|
}), |
||||
|
})); |
||||
|
|
||||
|
vi.mock('@/translations/config', () => ({ |
||||
|
localizePath: (path: string, locale: string) => path, |
||||
|
})); |
||||
|
|
||||
|
describe('SliderPage', () => { |
||||
|
let queryClient: QueryClient; |
||||
|
|
||||
|
beforeEach(() => { |
||||
|
vi.resetAllMocks(); |
||||
|
queryClient = new QueryClient(); |
||||
|
vi.spyOn(queryClient, 'setQueryData'); |
||||
|
}); |
||||
|
|
||||
|
afterEach(() => { |
||||
|
vi.useRealTimers(); |
||||
|
cleanup(); |
||||
|
}); |
||||
|
|
||||
|
const navigateToFinalSlide = async () => { |
||||
|
render( |
||||
|
<QueryClientProvider client={queryClient}> |
||||
|
<SliderPage /> |
||||
|
</QueryClientProvider> |
||||
|
); |
||||
|
const dots = screen.getAllByRole('button', { name: /go to slide/i }); |
||||
|
fireEvent.click(dots[4]); // Go directly to slide 5
|
||||
|
return await screen.findByText('Finish'); |
||||
|
}; |
||||
|
|
||||
|
it('a. Success: PATCH ok, GET ok -> replace', async () => { |
||||
|
const finishBtn = await navigateToFinalSlide(); |
||||
|
const initialRemoveCount = mockRemoveItem.mock.calls.filter(call => call[0] === 'marriage-slider-answers').length; |
||||
|
|
||||
|
let resolveGet: any; |
||||
|
|
||||
|
mockMutateAsync.mockResolvedValueOnce({ |
||||
|
status: 'pending_info', |
||||
|
gender: 'female', |
||||
|
is_registering_for_self: true |
||||
|
}); |
||||
|
|
||||
|
mockGetMarriageProfile.mockReturnValueOnce(new Promise(resolve => { |
||||
|
resolveGet = resolve; |
||||
|
})); |
||||
|
|
||||
|
fireEvent.click(finishBtn); |
||||
|
|
||||
|
// Wait for PATCH to resolve and GET to be called
|
||||
|
await waitFor(() => { |
||||
|
expect(mockMutateAsync).toHaveBeenCalled(); |
||||
|
expect(mockGetMarriageProfile).toHaveBeenCalled(); |
||||
|
}); |
||||
|
|
||||
|
// Assert navigation and localStorage clear did not happen before GET resolves
|
||||
|
expect(mockReplace).not.toHaveBeenCalled(); |
||||
|
const currentRemoveCount = mockRemoveItem.mock.calls.filter(call => call[0] === 'marriage-slider-answers').length; |
||||
|
expect(currentRemoveCount).toBe(initialRemoveCount); |
||||
|
|
||||
|
// Resolve GET
|
||||
|
resolveGet({ |
||||
|
status: 'pending_info', |
||||
|
gender: 'female', |
||||
|
is_registering_for_self: true |
||||
|
}); |
||||
|
|
||||
|
await waitFor(() => { |
||||
|
expect(queryClient.setQueryData).toHaveBeenCalled(); |
||||
|
const finalRemoveCount = mockRemoveItem.mock.calls.filter(call => call[0] === 'marriage-slider-answers').length; |
||||
|
expect(finalRemoveCount).toBe(initialRemoveCount + 1); |
||||
|
expect(mockReplace).toHaveBeenCalledWith('/questions-list/personal_info'); |
||||
|
}); |
||||
|
}); |
||||
|
|
||||
|
it('b. PATCH failure: no nav, Error UI, Retry works', async () => { |
||||
|
const finishBtn = await navigateToFinalSlide(); |
||||
|
|
||||
|
const initialRemoveCount = mockRemoveItem.mock.calls.filter(call => call[0] === 'marriage-slider-answers').length; |
||||
|
|
||||
|
mockMutateAsync.mockRejectedValueOnce(new Error('Network Error')); |
||||
|
|
||||
|
fireEvent.click(finishBtn); |
||||
|
|
||||
|
await waitFor(() => { |
||||
|
expect(screen.getByRole('alert')).toHaveTextContent('Failed to update profile basic details. Please try again.'); |
||||
|
}); |
||||
|
expect(mockReplace).not.toHaveBeenCalled(); |
||||
|
|
||||
|
const currentRemoveCount = mockRemoveItem.mock.calls.filter(call => call[0] === 'marriage-slider-answers').length; |
||||
|
expect(currentRemoveCount).toBe(initialRemoveCount); |
||||
|
|
||||
|
// Retry succeeds
|
||||
|
mockMutateAsync.mockResolvedValueOnce({ |
||||
|
status: 'pending_info', |
||||
|
gender: 'female', |
||||
|
is_registering_for_self: true |
||||
|
}); |
||||
|
mockGetMarriageProfile.mockResolvedValueOnce({ |
||||
|
status: 'pending_info', |
||||
|
gender: 'female', |
||||
|
is_registering_for_self: true |
||||
|
}); |
||||
|
|
||||
|
const retryBtn = screen.getByText('Finish'); |
||||
|
fireEvent.click(retryBtn); |
||||
|
|
||||
|
await waitFor(() => { |
||||
|
expect(mockReplace).toHaveBeenCalledWith('/questions-list/personal_info'); |
||||
|
}); |
||||
|
|
||||
|
const finalRemoveCount = mockRemoveItem.mock.calls.filter(call => call[0] === 'marriage-slider-answers').length; |
||||
|
expect(finalRemoveCount).toBe(initialRemoveCount + 1); |
||||
|
}); |
||||
|
|
||||
|
it('c. Slow request: Promise pending -> no redirect', async () => { |
||||
|
const finishBtn = await navigateToFinalSlide(); |
||||
|
vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout', 'setInterval', 'clearInterval', 'Date'] }); |
||||
|
|
||||
|
let resolvePatch: any; |
||||
|
mockMutateAsync.mockReturnValueOnce(new Promise(resolve => { |
||||
|
resolvePatch = resolve; |
||||
|
})); |
||||
|
mockGetMarriageProfile.mockResolvedValueOnce({ |
||||
|
status: 'pending_info', |
||||
|
gender: 'female', |
||||
|
is_registering_for_self: true |
||||
|
}); |
||||
|
|
||||
|
fireEvent.click(finishBtn); |
||||
|
|
||||
|
await vi.advanceTimersByTimeAsync(6000); |
||||
|
|
||||
|
expect(mockReplace).not.toHaveBeenCalled(); |
||||
|
|
||||
|
resolvePatch({ |
||||
|
status: 'pending_info', |
||||
|
gender: 'female', |
||||
|
is_registering_for_self: true |
||||
|
}); |
||||
|
|
||||
|
vi.useRealTimers(); |
||||
|
|
||||
|
await waitFor(() => { |
||||
|
expect(mockReplace).toHaveBeenCalledWith('/questions-list/personal_info'); |
||||
|
}); |
||||
|
}); |
||||
|
|
||||
|
it('d. Double-check failure: GET returns pending_onboarding -> Error UI', async () => { |
||||
|
const finishBtn = await navigateToFinalSlide(); |
||||
|
|
||||
|
mockMutateAsync.mockResolvedValueOnce({ |
||||
|
status: 'pending_info', |
||||
|
gender: 'female', |
||||
|
is_registering_for_self: true |
||||
|
}); |
||||
|
|
||||
|
mockGetMarriageProfile.mockResolvedValueOnce({ |
||||
|
status: 'pending_onboarding', |
||||
|
gender: 'female', |
||||
|
is_registering_for_self: true |
||||
|
}); |
||||
|
|
||||
|
fireEvent.click(finishBtn); |
||||
|
|
||||
|
await waitFor(() => { |
||||
|
expect(screen.getByRole('alert')).toHaveTextContent('Failed to update profile basic details. Please try again.'); |
||||
|
}); |
||||
|
expect(mockReplace).not.toHaveBeenCalled(); |
||||
|
}); |
||||
|
|
||||
|
it('e. Double click: only 1 request', async () => { |
||||
|
const finishBtn = await navigateToFinalSlide(); |
||||
|
|
||||
|
let resolvePatch: any; |
||||
|
mockMutateAsync.mockReturnValueOnce(new Promise(resolve => { |
||||
|
resolvePatch = resolve; |
||||
|
})); |
||||
|
mockGetMarriageProfile.mockResolvedValueOnce({ |
||||
|
status: 'pending_info', |
||||
|
gender: 'female', |
||||
|
is_registering_for_self: true |
||||
|
}); |
||||
|
|
||||
|
fireEvent.click(finishBtn); |
||||
|
fireEvent.click(finishBtn); |
||||
|
fireEvent.click(finishBtn); |
||||
|
|
||||
|
expect(mockMutateAsync).toHaveBeenCalledTimes(1); |
||||
|
|
||||
|
resolvePatch({ status: 'pending_info', gender: 'female', is_registering_for_self: true }); |
||||
|
|
||||
|
await waitFor(() => { |
||||
|
expect(mockReplace).toHaveBeenCalled(); |
||||
|
}); |
||||
|
}); |
||||
|
|
||||
|
it('f. Double-check failure: GET returns waiting -> Error UI', async () => { |
||||
|
const finishBtn = await navigateToFinalSlide(); |
||||
|
|
||||
|
mockMutateAsync.mockResolvedValueOnce({ |
||||
|
status: 'pending_info', |
||||
|
gender: 'female', |
||||
|
is_registering_for_self: true |
||||
|
}); |
||||
|
|
||||
|
mockGetMarriageProfile.mockResolvedValueOnce({ |
||||
|
status: 'waiting', |
||||
|
gender: 'female', |
||||
|
is_registering_for_self: true |
||||
|
}); |
||||
|
|
||||
|
fireEvent.click(finishBtn); |
||||
|
|
||||
|
await waitFor(() => { |
||||
|
expect(screen.getByRole('alert')).toHaveTextContent('Failed to update profile basic details. Please try again.'); |
||||
|
}); |
||||
|
expect(mockReplace).not.toHaveBeenCalled(); |
||||
|
}); |
||||
|
}); |
||||
@ -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
File diff suppressed because it is too large
View File
@ -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: "از یادگیری چیزهای جدید همراه با بازی، هیجان و نشاط چقدر استقبال میکنید؟", |
|
||||
}, |
|
||||
]; |
|
||||
@ -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
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
File diff suppressed because it is too large
View File
@ -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; |
|
||||
} |
|
||||
@ -0,0 +1,88 @@ |
|||||
|
import { describe, it, expect, vi, beforeEach } from 'vitest'; |
||||
|
import { getSubmitPath } from './get-submit-path'; |
||||
|
import * as firstEntryHelper from './first-entry-helper'; |
||||
|
import * as matchStartGrace from './match-start-grace'; |
||||
|
import type { MarriageProfileResponse } from '@/hooks/marriage/types'; |
||||
|
|
||||
|
vi.mock('./first-entry-helper', () => ({ |
||||
|
isFirstEntryCompleted: vi.fn(), |
||||
|
})); |
||||
|
|
||||
|
vi.mock('./match-start-grace', () => ({ |
||||
|
clearLegacyMatchSubmittedFlag: vi.fn(), |
||||
|
isWithinMatchStartGrace: vi.fn(), |
||||
|
})); |
||||
|
|
||||
|
describe('getSubmitPath', () => { |
||||
|
beforeEach(() => { |
||||
|
vi.clearAllMocks(); |
||||
|
vi.mocked(firstEntryHelper.isFirstEntryCompleted).mockReturnValue(true); |
||||
|
vi.mocked(matchStartGrace.isWithinMatchStartGrace).mockReturnValue(false); |
||||
|
}); |
||||
|
|
||||
|
const baseProfile: MarriageProfileResponse = { |
||||
|
status: 'pending_onboarding', |
||||
|
id: 1, |
||||
|
gender: 'male', |
||||
|
is_registering_for_self: true, |
||||
|
}; |
||||
|
|
||||
|
it('pending_onboarding returns /terms', () => { |
||||
|
expect(getSubmitPath({ ...baseProfile, status: 'pending_onboarding' })).toBe('/terms'); |
||||
|
}); |
||||
|
|
||||
|
it('waiting returns /finding-match', () => { |
||||
|
expect(getSubmitPath({ ...baseProfile, status: 'waiting' })).toBe('/finding-match'); |
||||
|
}); |
||||
|
|
||||
|
it('pending_info returns questions path', () => { |
||||
|
expect(getSubmitPath({ ...baseProfile, status: 'pending_info' })).toBe('/questions-list'); |
||||
|
|
||||
|
vi.mocked(firstEntryHelper.isFirstEntryCompleted).mockReturnValue(false); |
||||
|
expect(getSubmitPath({ ...baseProfile, status: 'pending_info' })).toBe('/questions-list/personal_info'); |
||||
|
}); |
||||
|
|
||||
|
it('matched returns /request-accepted', () => { |
||||
|
expect(getSubmitPath({ ...baseProfile, status: 'matched' })).toBe('/request-accepted'); |
||||
|
}); |
||||
|
|
||||
|
describe('active_case priority', () => { |
||||
|
it('returns /new-match when status="pending_info" but active_case is introduced and action pending', () => { |
||||
|
const profileWithCase = { |
||||
|
...baseProfile, |
||||
|
status: 'pending_info' as const, |
||||
|
active_case: { |
||||
|
status: 'introduced' as const, |
||||
|
my_action: 'pending' as const, |
||||
|
}, |
||||
|
}; |
||||
|
// Type assertion added because the mock profile object is lacking many properties of the full MarriageProfileResponse,
|
||||
|
// but it contains everything needed for the logic being tested.
|
||||
|
expect(getSubmitPath(profileWithCase as any)).toBe('/new-match'); |
||||
|
}); |
||||
|
|
||||
|
it('returns /request-accepted when status="pending_info" but active_case is payment_done', () => { |
||||
|
const profileWithCase = { |
||||
|
...baseProfile, |
||||
|
status: 'pending_info' as const, |
||||
|
active_case: { |
||||
|
status: 'payment_done' as const, |
||||
|
my_action: 'pending' as const, |
||||
|
}, |
||||
|
}; |
||||
|
expect(getSubmitPath(profileWithCase as any)).toBe('/request-accepted'); |
||||
|
}); |
||||
|
|
||||
|
it('returns /request-sent when status="waiting" but active_case is male_accepted and action done', () => { |
||||
|
const profileWithCase = { |
||||
|
...baseProfile, |
||||
|
status: 'waiting' as const, |
||||
|
active_case: { |
||||
|
status: 'male_accepted' as const, |
||||
|
my_action: 'done' as const, |
||||
|
}, |
||||
|
}; |
||||
|
expect(getSubmitPath(profileWithCase as any)).toBe('/request-sent'); |
||||
|
}); |
||||
|
}); |
||||
|
}); |
||||
@ -0,0 +1,144 @@ |
|||||
|
import type { |
||||
|
FormSchemaResponse, |
||||
|
FormSection, |
||||
|
FormQuestion, |
||||
|
} from "@/hooks/marriage/use-form-schema"; |
||||
|
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> = { |
||||
|
"user-circle": "profile", |
||||
|
school: "education", |
||||
|
"heart-handshake": "details", |
||||
|
"file-text": "contact", |
||||
|
"layout-grid": "checklist", |
||||
|
}; |
||||
|
|
||||
|
export function mapBackendQuestionToFrontend(bq: FormQuestion, index: number): QuestionField { |
||||
|
return { |
||||
|
id: bq.id, |
||||
|
title: bq.title || "Untitled", |
||||
|
type: bq.type, |
||||
|
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, |
||||
|
validation: bq.validation, |
||||
|
ui_config: bq.ui_config, |
||||
|
description: bq.description || "", |
||||
|
tooltip: bq.tooltip || "", |
||||
|
extras: { |
||||
|
placeHolder: bq.placeholder || "", |
||||
|
options: bq.options?.map((o) => o.label) || [], |
||||
|
range: bq.ui_config?.range || [0, 0], |
||||
|
noSearch: bq.ui_config?.noSearch, |
||||
|
}, |
||||
|
showGuardianNotice: bq.show_guardian_notice, |
||||
|
options: [...(bq.options || [])].sort((a, b) => (a.order ?? 0) - (b.order ?? 0)), |
||||
|
}; |
||||
|
} |
||||
|
|
||||
|
export function mapBackendSectionToFrontend( |
||||
|
section: FormSection, |
||||
|
progress: number |
||||
|
): QuestionListItem { |
||||
|
const allQuestions: QuestionField[] = []; |
||||
|
let index = 0; |
||||
|
|
||||
|
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); |
||||
|
index++; |
||||
|
}); |
||||
|
}); |
||||
|
|
||||
|
return { |
||||
|
slug: section.id, |
||||
|
title: section.title, |
||||
|
estimate: section.estimated_minutes ? `${section.estimated_minutes} min` : "5 min", |
||||
|
progress: progress, |
||||
|
icon: iconMap[section.icon] ?? "details", |
||||
|
required: section.is_required, |
||||
|
showInfoBadge: false, |
||||
|
summary: "", |
||||
|
checkpoints: allQuestions.map((q) => q.title), |
||||
|
tooltip: "", |
||||
|
questions: allQuestions, |
||||
|
}; |
||||
|
} |
||||
|
|
||||
|
export function convertSchemaToFrontendItems( |
||||
|
schema: FormSchemaResponse | undefined, |
||||
|
locale: Locale = defaultLocale |
||||
|
): QuestionListItem[] { |
||||
|
if (!schema) return []; |
||||
|
|
||||
|
const rawItems = schema.sections.map((sec) => { |
||||
|
const progInfo = schema.progress?.sections_progress?.[sec.id]; |
||||
|
const progress = progInfo ? progInfo.completion_percent : 0; |
||||
|
return mapBackendSectionToFrontend(sec, progress); |
||||
|
}); |
||||
|
|
||||
|
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)); |
||||
|
} |
||||
Write
Preview
Loading…
Cancel
Save
Reference in new issue