diff --git a/package-lock.json b/package-lock.json index 88eba46..503debc 100644 --- a/package-lock.json +++ b/package-lock.json @@ -136,6 +136,7 @@ "integrity": "sha512-5FgxM4dLQpMJHSiVATk8foW263dVHQHBVpXYiimNECVWG01f4nFyEbQixeT6Mwvg7TayREJ2gpKl3o2RoMdnqw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@babel/code-frame": "^8.0.0", "@babel/generator": "^8.0.0", @@ -906,6 +907,7 @@ } ], "license": "MIT", + "peer": true, "engines": { "node": ">=20.19.0" }, @@ -954,6 +956,7 @@ } ], "license": "MIT", + "peer": true, "engines": { "node": ">=20.19.0" } @@ -2231,7 +2234,6 @@ "integrity": "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@babel/helper-validator-identifier": "^7.29.7", "js-tokens": "^4.0.0", @@ -2247,7 +2249,6 @@ "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=6.9.0" } @@ -2257,8 +2258,7 @@ "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", "dev": true, - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/@testing-library/jest-dom": { "version": "7.0.0", @@ -2337,8 +2337,7 @@ "resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz", "integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==", "dev": true, - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/@types/chai": { "version": "5.2.3", @@ -2392,6 +2391,7 @@ "integrity": "sha512-orrrD74MBUyK8jOAD/r0+lfa1I2MO6I+vAkmAWzMYbCcgrN4lCrmK52gRFQq/JRxfYPfonkr4b0jcY7Olqdqbw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "undici-types": "~6.21.0" } @@ -2402,6 +2402,7 @@ "integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "csstype": "^3.2.2" } @@ -2412,6 +2413,7 @@ "integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==", "dev": true, "license": "MIT", + "peer": true, "peerDependencies": { "@types/react": "^19.2.0" } @@ -2547,7 +2549,6 @@ "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=8" } @@ -2558,7 +2559,6 @@ "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=10" }, @@ -2646,6 +2646,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "baseline-browser-mapping": "^2.10.44", "caniuse-lite": "^1.0.30001806", @@ -2828,8 +2829,7 @@ "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz", "integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==", "dev": true, - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/dunder-proto": { "version": "1.0.1", @@ -3240,6 +3240,7 @@ "integrity": "sha512-ECi4Fi2f7BdJtUKTflYRTiaMxIB0O6zfR1fX0GXpUrf6flp8QIYn1UT20YQqdSOfk2dfkCwS8LAFoJDEppNK5Q==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@asamuzakjp/css-color": "^5.1.11", "@asamuzakjp/dom-selector": "^7.1.1", @@ -3578,7 +3579,6 @@ "integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==", "dev": true, "license": "MIT", - "peer": true, "bin": { "lz-string": "bin/bin.js" } @@ -3773,6 +3773,7 @@ "integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=12" }, @@ -3814,7 +3815,6 @@ "integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "ansi-regex": "^5.0.1", "ansi-styles": "^5.0.0", @@ -3848,6 +3848,7 @@ "resolved": "https://registry.npmjs.org/react/-/react-19.2.4.tgz", "integrity": "sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ==", "license": "MIT", + "peer": true, "engines": { "node": ">=0.10.0" } @@ -3857,6 +3858,7 @@ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.4.tgz", "integrity": "sha512-AXJdLo8kgMbimY95O2aKQqsz2iWi9jMgKJhRBAxECE4IFxfcazB2LmzloIoibJI3C12IlY20+KFaLv+71bUJeQ==", "license": "MIT", + "peer": true, "dependencies": { "scheduler": "^0.27.0" }, @@ -3878,8 +3880,7 @@ "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz", "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==", "dev": true, - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/redent": { "version": "3.0.0", @@ -4273,6 +4274,7 @@ "integrity": "sha512-EU/eS7BH3XROHh2YnBefjM6DBKA6ZeMZEYQbj7NLWg5wHYlhB8B/Mayd5XsgWq+NFYccDOTemRpdETWR6Ka/lw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "lightningcss": "^1.33.0", "picomatch": "^4.0.5", diff --git a/src/app/[lang]/questions-list/[slug]/page.tsx b/src/app/[lang]/questions-list/[slug]/page.tsx index 1c42417..7dc4bab 100644 --- a/src/app/[lang]/questions-list/[slug]/page.tsx +++ b/src/app/[lang]/questions-list/[slug]/page.tsx @@ -1,14 +1,3 @@ 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; diff --git a/src/app/api/proxy/route.ts b/src/app/api/proxy/route.ts index cecf4ca..0c60ff4 100644 --- a/src/app/api/proxy/route.ts +++ b/src/app/api/proxy/route.ts @@ -131,7 +131,7 @@ function getRequestHeaders(request: NextRequest, targetUrl: URL) { getCookieValue(cookieHeader, "token") ?? getCookieValue(cookieHeader, "auth_token"); - if (cookieToken && cookieToken !== "NO_TOKEN") { + if (cookieToken && cookieToken !== "NO_TOKEN" && cookieToken.trim() !== "") { headers.set("authorization", `Token ${cookieToken}`); } } @@ -143,8 +143,9 @@ function getRequestHeaders(request: NextRequest, targetUrl: URL) { // Fall back to the development default token when nothing else provided auth. if (!headers.has("authorization")) { + const isDevelopment = process.env.NODE_ENV !== "production"; const defaultToken = process.env.NEXT_PUBLIC_DEFAULT_TOKEN; - if (defaultToken && defaultToken !== "NO_TOKEN") { + if (isDevelopment && defaultToken && defaultToken !== "NO_TOKEN") { headers.set("authorization", `Token ${defaultToken}`); } } diff --git a/src/app/intro/page.tsx b/src/app/intro/page.tsx index 6a42bac..d0444dd 100644 --- a/src/app/intro/page.tsx +++ b/src/app/intro/page.tsx @@ -2,12 +2,11 @@ import Image from "next/image"; import { useRouter } from "next/navigation"; -import { useEffect, useRef, useState } from "react"; +import { useState } from "react"; import Button from "@/components/Componentes/button"; import PageHeader from "@/components/Componentes/page-header"; import ReportActionsSheet from "@/components/Componentes/report-actions-sheet"; import VideoPlayer from "@/components/Componentes/video-player"; -import type { MarriageProfileResponse } from "@/hooks/marriage/types"; import { useMarriageConfigQuery } from "@/hooks/marriage/use-marriage-config"; import { useMarriageProfileQuery } from "@/hooks/marriage/use-profile-main"; import { authBridge } from "@/lib/auth-bridge"; @@ -15,8 +14,6 @@ import { getSubmitPath } from "@/lib/get-submit-path"; import { localizePath } from "@/translations/config"; import { useI18n } from "@/translations/provider"; -const REDIRECT_SESSION_KEY = "redirect"; - export default function Intro() { const router = useRouter(); const { dictionary: t, locale } = useI18n(); @@ -26,80 +23,10 @@ export default function Intro() { }); const [isReportSheetOpen, setIsReportSheetOpen] = useState(false); const [isSubmitting, setIsSubmitting] = useState(false); - const [isCheckingRedirect, setIsCheckingRedirect] = useState(true); const [isPlayerOpen, setIsPlayerOpen] = useState(false); - const isRedirectingRef = useRef(false); const { data: config } = useMarriageConfigQuery(); - useEffect(() => { - let isCancelled = false; - - const redirectIfNeeded = async () => { - if (isRedirectingRef.current) { - return; - } - - try { - const isAuthenticated = authBridge.isAuthenticated(); - - if (!isAuthenticated) { - if (!isCancelled) { - setIsCheckingRedirect(false); - } - return; - } - - isRedirectingRef.current = true; - - if (!isCancelled) { - setIsCheckingRedirect(true); - } - - const profileResponse = profile ?? (await refetch()).data; - const nextPath = localizePath(getSubmitPath(profileResponse), locale); - if (typeof window !== "undefined") { - try { - window.sessionStorage.removeItem(REDIRECT_SESSION_KEY); - } catch (e) { - console.warn("sessionStorage is not accessible:", e); - } - } - - if (!isCancelled) { - const currentPath = - typeof window !== "undefined" ? window.location.pathname : ""; - if (currentPath === nextPath) { - setIsCheckingRedirect(false); - } else { - router.replace(nextPath); - } - return; - } - } catch (error) { - console.error("Error in redirectIfNeeded:", error); - if (!isCancelled) { - setIsCheckingRedirect(false); - } - } finally { - isRedirectingRef.current = false; - } - }; - - void redirectIfNeeded(); - - const unsubscribe = window.addFlutterResponseListener?.((event) => { - if (event.action === "login" && event.success) { - void redirectIfNeeded(); - } - }); - - return () => { - isCancelled = true; - unsubscribe?.(); - }; - }, [locale, profile, refetch, router]); - const handleSubmit = async () => { if (isSubmitting) { return; @@ -111,7 +38,8 @@ export default function Intro() { if (!authBridge.isAuthenticated()) { const token = await authBridge.ensureToken(); if (!token) { - console.warn("No token from bridge – proceeding with fallback path"); + console.warn("No token from bridge – login was not completed"); + return; } } @@ -140,10 +68,6 @@ export default function Intro() { } }; - if (isCheckingRedirect) { - return null; - } - return (
{isReportSheetOpen && ( diff --git a/src/app/new-match/page.tsx b/src/app/new-match/page.tsx index ec46360..3d575dd 100644 --- a/src/app/new-match/page.tsx +++ b/src/app/new-match/page.tsx @@ -501,7 +501,7 @@ export default function NewMatchPage() { {profile?.can_edit_profile === false && (
- getQuestionListItems(locale).map((item) => item.slug), - ), - ); - - return Array.from(slugs).map((slug) => ({ slug })); -} - type QuestionDetailPageProps = { params: Promise<{ lang?: string; @@ -35,15 +19,10 @@ export default async function QuestionDetailPage({ ? `/${locale}/questions-list` : "/questions-list"; const t = getDictionary(locale); - const item = getQuestionListItemBySlug(slug, locale); - - if (!item) { - notFound(); - } return ( ({ + 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( + + + + ); + + // 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( + + + + ); + + // Profile questions render directly, no start button + expect(screen.getByText(/Dynamic ID/)).toBeDefined(); + }); +}); diff --git a/src/app/questions-list/[slug]/question-detail-client.tsx b/src/app/questions-list/[slug]/question-detail-client.tsx index e044906..79f2d81 100644 --- a/src/app/questions-list/[slug]/question-detail-client.tsx +++ b/src/app/questions-list/[slug]/question-detail-client.tsx @@ -18,25 +18,16 @@ import TestIntroPage from "@/components/Componentes/test-intro-page"; import TestQuestionsFlow, { type TestQuestion, } from "@/components/Componentes/test-questions-flow"; -import { cattellFallbackQuestions } from "@/data/cattell-fallback"; -import { glasserFallbackQuestions } from "@/data/glasser-fallback"; import { - getQuestionListItemBySlug, - isQuestionListItemVisibleForProfile, - isQuestionRequiredForProfile, - isQuestionVisibleForProfile, - type QuestionField, -} from "@/data/question-data"; -import type { MarriageGender } from "@/hooks/marriage/types"; + useGlasserQuestionsQuery, + useSubmitGlasserAssessmentMutation, +} from "@/hooks/marriage/use-glasser"; import { useCattellQuestionsQuery, useSubmitCattellAssessmentMutation, } from "@/hooks/marriage/use-cattell"; -import { - useGlasserQuestionsQuery, - useSubmitGlasserAssessmentMutation, -} from "@/hooks/marriage/use-glasser"; -import { useMarriageProfileQuery } from "@/hooks/marriage/use-profile-main"; +import { useFormSchemaQuery } from "@/hooks/marriage/use-form-schema"; +import { convertSchemaToFrontendItems, type QuestionField } from "@/lib/schema-adapter"; import { defaultLocale, type Locale } from "@/translations/config"; import { useI18n } from "@/translations/provider"; @@ -70,353 +61,24 @@ function getQuestionStorageKey(slug: string) { return `marriage:sections:${slug}:answers`; } -function parseStoredAge(value: unknown) { - if (typeof value === "number" && Number.isFinite(value)) { - return value; - } - - if (typeof value === "string") { - const trimmedValue = value.trim(); - - if (!trimmedValue) { - return null; - } - - const numericAge = Number(trimmedValue); - - if (Number.isFinite(numericAge)) { - return numericAge; - } - - const dateOfBirth = new Date(trimmedValue); - - if (Number.isNaN(dateOfBirth.getTime())) { - return null; - } - - const today = new Date(); - let age = today.getFullYear() - dateOfBirth.getFullYear(); - const hasBirthdayPassed = - today.getMonth() > dateOfBirth.getMonth() || - (today.getMonth() === dateOfBirth.getMonth() && - today.getDate() >= dateOfBirth.getDate()); - - if (!hasBirthdayPassed) { - age -= 1; - } - - return age >= 0 ? age : null; - } - - return null; -} - -function getStoredAge() { - try { - const rawValue = window.localStorage.getItem( - getQuestionStorageKey("personal_info"), - ); - - if (!rawValue) { - return null; - } - - const storedAnswers = JSON.parse(rawValue) as StoredAnswers; - const ageField = storedAnswers.fields?.find((field) => { - const f = field as { key?: string; type?: string; label?: string }; - return ( - f.type === "number" || - f.label === "Age" || - f.label === "ط³ظ†" || - (typeof f.key === "string" && - (f.key.endsWith("_age") || f.key.endsWith("_sn"))) - ); - }); - - if (ageField) { - return parseStoredAge(ageField.value); - } - - const dateOfBirthField = storedAnswers.fields?.find((field) => { - const f = field as { key?: string; type?: string; label?: string }; - return ( - f.type === "date" || - f.label === "Date of Birth" || - f.label === "طھط§ط±غŒط® طھظˆظ„ط¯" || - (typeof f.key === "string" && - (f.key.endsWith("_date_of_birth") || f.key.endsWith("_tarykh_twld"))) - ); - }); - - return parseStoredAge(dateOfBirthField?.value); - } catch { - return null; - } -} - function QuestionFlowWrapper({ visibleQuestions, itemSlug, dobQuestion, - dobQuestionIndex, continueLabel, questionsListHref, }: { visibleQuestions: QuestionField[]; itemSlug: string; dobQuestion?: QuestionField; - dobQuestionIndex?: number; requiredQuestionsCount: number; continueLabel: string; questionsListHref: string; }) { const { getAnswerValue } = useQuestionAnswers(); - const dynamicQuestions = useMemo(() => { - // Find employment status question by title or by choices length (10) - const employmentQuestionIndex = visibleQuestions.findIndex((q) => { - const enTitle = q.englishTitle || q.title; - return ( - enTitle === "Employment Status" || - (q.type === "dropdown" && q.extras.options?.length === 10) - ); - }); - - let employmentSelectedOptionIndex = -1; - if (employmentQuestionIndex !== -1) { - const employmentQuestion = visibleQuestions[employmentQuestionIndex]; - const ans = getAnswerValue(employmentQuestion, employmentQuestionIndex); - if (ans) { - employmentSelectedOptionIndex = - employmentQuestion.extras.options?.indexOf(String(ans)) ?? -1; - } - } - - // Find Parents' Survival Status question index and check selected index - const survivalStatusQuestionIndex = visibleQuestions.findIndex((q) => { - const enTitle = q.englishTitle || q.title; - return enTitle === "Parents' Survival Status"; - }); - - let survivalSelectedOptionIndex = -1; - if (survivalStatusQuestionIndex !== -1) { - const survivalQuestion = visibleQuestions[survivalStatusQuestionIndex]; - const ans = getAnswerValue(survivalQuestion, survivalStatusQuestionIndex); - if (ans) { - survivalSelectedOptionIndex = - survivalQuestion.extras.options?.indexOf(String(ans)) ?? -1; - } - } - - // Find Parents' Marital Status question index and check selected index - const maritalStatusQuestionIndex = visibleQuestions.findIndex((q) => { - const enTitle = q.englishTitle || q.title; - return enTitle === "Parents' Marital Status"; - }); - - let isCircumstancesSelected = false; - if (maritalStatusQuestionIndex !== -1) { - const maritalQuestion = visibleQuestions[maritalStatusQuestionIndex]; - const ans = getAnswerValue(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 = visibleQuestions.findIndex((q) => { - const enTitle = q.englishTitle || q.title; - return enTitle === "Current Marital Status"; - }); - - let maritalSelectedIndex = -1; - if (currentMaritalQuestionIndex !== -1) { - const maritalQuestion = visibleQuestions[currentMaritalQuestionIndex]; - const ans = getAnswerValue(maritalQuestion, currentMaritalQuestionIndex); - if (ans) { - maritalSelectedIndex = - maritalQuestion.extras.options?.indexOf(String(ans)) ?? -1; - } - } - - // Find Children and Guardianship Status question index - const custodyQuestionIndex = visibleQuestions.findIndex((q) => { - const enTitle = q.englishTitle || q.title; - return enTitle === "Children and Guardianship Status"; - }); - - let hasChildrenSelected = false; - let hasAnyGuardianshipSelected = false; - - if (custodyQuestionIndex !== -1) { - const custodyQuestion = visibleQuestions[custodyQuestionIndex]; - const ans = getAnswerValue(custodyQuestion, custodyQuestionIndex); - if (ans) { - const ansList = Array.isArray(ans) ? ans.map(String) : [String(ans)]; - const selectedIndices = ansList.map((val) => - custodyQuestion.extras.options?.indexOf(val), - ); - hasChildrenSelected = selectedIndices.some( - (idx) => idx === 1 || idx === 2, - ); - hasAnyGuardianshipSelected = selectedIndices.some( - (idx) => idx === 2 || idx === 3, - ); - } - } - - const filtered = visibleQuestions.filter((question, index) => { - const enTitle = question.englishTitle || question.title; - // Check if this is one of the marital status/children questions - if (currentMaritalQuestionIndex !== -1) { - const isDuration = - index === currentMaritalQuestionIndex + 1 || - enTitle === "Previous Marriage Duration"; - - const isSeparation = - index === currentMaritalQuestionIndex + 2 || - enTitle === "Reason for Separation"; - - const isCustody = - index === currentMaritalQuestionIndex + 3 || - enTitle === "Children and Guardianship Status"; - - const isChildrenCount = - index === currentMaritalQuestionIndex + 4 || - enTitle === "Number of Children"; - - const isChildrenExplanation = - index === currentMaritalQuestionIndex + 5 || - enTitle === "Short Children/Guardianship Explanation"; - - 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 || enTitle === "Job Title"; - - const isWorkLocation = - index === employmentQuestionIndex + 2 || enTitle === "Work Location"; - - const isMonthlyIncome = - index === employmentQuestionIndex + 3 || enTitle === "Monthly Income"; - - 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 = enTitle === "Parents' Marital Status"; - - if (isParentsMaritalStatus) { - return survivalSelectedOptionIndex === 0; - } - } - - // Default dependsOn logic - if (question.logic?.dependsOn) { - const { title, values } = question.logic.dependsOn; - const dependentQuestionIndex = visibleQuestions.findIndex( - (q) => q.title === title, - ); - - if (dependentQuestionIndex !== -1) { - const dependentQuestion = visibleQuestions[dependentQuestionIndex]; - const answer = getAnswerValue( - dependentQuestion, - dependentQuestionIndex, - ); - if (Array.isArray(answer)) { - return answer.some((ans) => values.includes(String(ans))); - } - return values.includes(String(answer)); - } - return false; - } - return true; - }); - - return filtered.map((question) => { - const enTitle = question.englishTitle || question.title; - if (enTitle === "Short Family Description") { - return { - ...question, - required: isCircumstancesSelected, - }; - } - - if ( - enTitle === "Previous Marriage Duration" || - enTitle === "Number of Children" || - enTitle === "Short Children/Guardianship Explanation" || - enTitle === "Additional details about family responsibility" || - enTitle === "Do the supported individual(s) live with you?" || - enTitle === "What is the custody status of your child(ren)?" || - enTitle === - "Does the custody, visitation, or relocation schedule impact your residence or immigration?" || - enTitle === "What is the payment or receipt status of child support?" || - enTitle === - "Acceptance of necessary communication between future spouse and the other parent" - ) { - return { - ...question, - required: true, - }; - } - return question; - }); - }, [visibleQuestions, getAnswerValue]); + // dynamicQuestions is now exactly what the backend gives as visible + const dynamicQuestions = visibleQuestions; const requiredCount = useMemo( () => dynamicQuestions.filter((q) => q.required).length, @@ -435,23 +97,12 @@ function QuestionFlowWrapper({ questions={dynamicQuestions} > {dynamicQuestions.map((question, index) => { - let originalIndex = visibleQuestions.indexOf(question); - if (originalIndex === -1) { - // Spread-copied questions lose reference equality; fall back to title - originalIndex = visibleQuestions.findIndex( - (q) => - (q.englishTitle || q.title) === - (question.englishTitle || question.title), - ); - } - const answer = getAnswerValue(question, originalIndex); + const answer = getAnswerValue(question); const hasAnswer = hasQuestionAnswerValue(answer ?? null); let isAnswered = hasAnswer; if (hasAnswer) { - const isEmailQuestion = (question.englishTitle || question.title) - .toLowerCase() - .includes("email"); + const isEmailQuestion = question.type === "email" || question.validation?.format === "email"; if (isEmailQuestion) { const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; isAnswered = emailRegex.test(String(answer).trim()); @@ -467,19 +118,17 @@ function QuestionFlowWrapper({ return (
); @@ -525,11 +174,9 @@ export default function QuestionDetailClient({ } }, [itemSlug, isTestStarted]); - const { data: profile, isLoading: isProfileLoading } = - useMarriageProfileQuery(); - const profileGender = profile?.gender; - const age = getStoredAge(); - const item = getQuestionListItemBySlug(itemSlug, locale); + const { data: schema, isLoading: isSchemaLoading } = useFormSchemaQuery("profile", locale); + const items = useMemo(() => convertSchemaToFrontendItems(schema, locale), [schema, locale]); + const item = items.find((i) => i.slug === itemSlug); const isCattellSlug = itemSlug === "personality_test"; const isGlasserSlug = itemSlug === "glasser_5_needs_test"; @@ -546,58 +193,44 @@ export default function QuestionDetailClient({ }); const submitGlasserMutation = useSubmitGlasserAssessmentMutation(); - const profileContext = useMemo( - () => ({ - age, - gender: profileGender as MarriageGender | null | undefined, - }), - [age, profileGender], - ); - const cattellTestQuestions: TestQuestion[] = useMemo(() => { - const questionsList = - cattellQuery.data?.questions && cattellQuery.data.questions.length > 0 - ? cattellQuery.data.questions - : cattellFallbackQuestions; - - return questionsList.map((q) => { - const rawOptions = - q.options && q.options.length > 0 - ? q.options - : locale === "fa" - ? ["ط¨ظ„ظ‡", "ط¨ظ‡ ط§ظ†ط¯ط§ط²ظ‡ ع©ط§ظپغŒ ظˆط§ط¶ط­ ظ†غŒط³طھ", "ظ†ظ‡"] - : ["Yes", "Not clear enough", "No"]; - const mappedOptions = rawOptions.map((optText, idx) => ({ - label: optText, - value: idx === 0 ? "A" : idx === 1 ? "B" : "C", - })); - return { - id: q.question_number, - text: q.text, - options: mappedOptions, - }; - }); - }, [cattellQuery.data, locale]); + const questionsList = cattellQuery.data?.questions || []; + + // Strict schema validation for Cattell + const isValidCattell = (q: any) => + q.question_number && + q.text && + q.options && + q.options.length === 3 && + q.options.every((o: any) => o.id && o.label && o.value); + + if (questionsList.length > 0 && !questionsList.every(isValidCattell)) { + console.error("Invalid Cattell API response schema"); + return []; + } + + return questionsList.map((q) => ({ + id: q.question_number, + text: q.text, + options: q.options || [], + })); + }, [cattellQuery.data]); const glasserTestQuestions: TestQuestion[] = useMemo(() => { - const questionsList = - glasserQuery.data?.questions && glasserQuery.data.questions.length > 0 - ? glasserQuery.data.questions - : glasserFallbackQuestions; - - const defaultGlasserOptions = [ - { - label: locale === "fa" ? "خیلی کم" : "Very Low", - value: 1, - }, - { label: locale === "fa" ? "کم" : "Low", value: 2 }, - { label: locale === "fa" ? "متوسط" : "Moderate", value: 3 }, - { label: locale === "fa" ? "زیاد" : "High", value: 4 }, - { - label: locale === "fa" ? "خیلی زیاد" : "Very High", - value: 5, - }, - ]; + const questionsList = glasserQuery.data?.questions || []; + + // Strict schema validation for Glasser + const isValidGlasser = (q: any) => + q.question_number && + q.text && + q.options && + q.options.length === 5 && + q.options.every((o: any) => o.id && o.label && typeof o.value === 'number' && o.value >= 1 && o.value <= 5); + + if (questionsList.length > 0 && !questionsList.every(isValidGlasser)) { + console.error("Invalid Glasser API response schema"); + return []; + } return questionsList.map((q) => ({ id: q.question_number, @@ -608,34 +241,22 @@ export default function QuestionDetailClient({ : "factor_code" in q ? (q.factor_code as string) : undefined, - options: defaultGlasserOptions, + options: q.options || [], })); - }, [glasserQuery.data, locale]); + }, [glasserQuery.data]); const visibleQuestions = useMemo(() => { if (!item) { return []; } - const hasDobQuestion = item.questions.some( - (q) => q.title === "Date of Birth" || q.title === "طھط§ط±غŒط® طھظˆظ„ط¯", - ); - return item.questions - .filter((question) => { - if ( - hasDobQuestion && - (question.title === "Age" || question.title === "ط³ظ†") - ) { - return false; - } - return isQuestionVisibleForProfile(question, profileContext); - }) + .filter((question) => (question as any).isVisible !== false) .map((question) => ({ ...question, - required: isQuestionRequiredForProfile(question, profileContext), + required: Boolean(question.required), })); - }, [item, profileContext]); + }, [item]); const requiredQuestionsCount = useMemo( () => visibleQuestions.filter((q) => q.required).length, @@ -643,28 +264,19 @@ export default function QuestionDetailClient({ ); useEffect(() => { - if (isProfileLoading) { - return; + if (!isSchemaLoading && !item) { + router.replace(questionsListHref); } + }, [isSchemaLoading, item, questionsListHref, router]); - if (!item || isQuestionListItemVisibleForProfile(item, profileContext)) { - return; - } - - router.replace(questionsListHref); - }, [isProfileLoading, item, profileContext, questionsListHref, router]); - - if (!profile && isProfileLoading && item) { + if (isSchemaLoading) { return ( ); - } else if ( - !item || - !isQuestionListItemVisibleForProfile(item, profileContext) - ) { + } else if (!item) { return null; } @@ -961,10 +573,7 @@ export default function QuestionDetailClient({ } const dobQuestion = visibleQuestions.find( - (question) => question.title === "Date of Birth", - ); - const dobQuestionIndex = visibleQuestions.findIndex( - (question) => question.title === "Date of Birth", + (question) => question.ui_config?.isDob === true || question.type === "date", ); return ( @@ -1001,7 +610,6 @@ export default function QuestionDetailClient({ visibleQuestions={visibleQuestions} itemSlug={item.slug} dobQuestion={dobQuestion} - dobQuestionIndex={dobQuestionIndex} requiredQuestionsCount={requiredQuestionsCount} continueLabel={continueLabel} questionsListHref={questionsListHref} diff --git a/src/app/questions-list/page.tsx b/src/app/questions-list/page.tsx index 78dca41..f369e36 100644 --- a/src/app/questions-list/page.tsx +++ b/src/app/questions-list/page.tsx @@ -5,29 +5,23 @@ import { useRouter } from "next/navigation"; import { useEffect, useMemo, useState } from "react"; import { IoClose } from "react-icons/io5"; import { getSubmitPath } from "@/lib/get-submit-path"; -import { - getLocalSectionProgress, - getStoredAge, -} from "@/components/Componentes/progress-helper"; import QuestionCard from "@/components/Componentes/question-card"; import RequiredStepsCard from "@/components/Componentes/required-steps-card"; import Button from "@/components/Componentes/button"; import InformationSheet from "@/components/Componentes/information-sheet"; import NavigationButton from "@/components/Componentes/navigation-button"; +import { getQuestionAnswersStorageKey } from "@/components/Componentes/question-answer-storage"; import { FixToTheEnd } from "@/components/Componentes/fix-to-the-end"; import { LoadingSkeleton } from "@/components/Componentes/loading-skeleton"; import { PageBackground } from "@/components/Componentes/page-background"; import ErrorToast from "@/components/Componentes/error-toast"; -import { - getQuestionListItems, - isQuestionListItemVisibleForProfile, - type QuestionListItem, -} from "@/data/question-data"; -import { toFrontendSlug } from "@/data/section-slug-map"; import { useQueryClient } from "@tanstack/react-query"; import { useStartMarriageMatchMutation } from "@/hooks/marriage/use-match-start"; import { useMarriageProfileQuery } from "@/hooks/marriage/use-profile-main"; import { useMarriageSectionsQuery } from "@/hooks/marriage/use-sections"; +import { useFormSchemaQuery } from "@/hooks/marriage/use-form-schema"; +import { convertSchemaToFrontendItems } from "@/lib/schema-adapter"; +import type { QuestionListItem } from "@/lib/schema-adapter"; import { useCloseServiceOnBack } from "@/hooks/use-close-service-on-back"; import { clearMatchStartGrace, @@ -47,6 +41,8 @@ export default function QuestionsListPage() { useMarriageProfileQuery(); const { data: sections, isLoading: isSectionsLoading } = useMarriageSectionsQuery(); + const { data: schema, isLoading: isSchemaLoading } = + useFormSchemaQuery("profile", locale); useEffect(() => { triggerSilentReload(queryClient); @@ -68,59 +64,28 @@ export default function QuestionsListPage() { const [selectedSection, setSelectedSection] = useState(null); const questionListItems = useMemo( - () => - getQuestionListItems(locale).filter((item) => - isQuestionListItemVisibleForProfile(item, { - gender: profile?.gender, - }), - ), - [locale, profile?.gender], + () => convertSchemaToFrontendItems(schema, locale), + [schema, locale], ); const sectionProgressBySlug = useMemo(() => { const progressBySlug = new Map(); - const age = getStoredAge(); - sections?.forEach((section) => { - const frontendSlug = toFrontendSlug(section.slug); - const progress = Math.max( - 0, - Math.min(100, Math.round(section.completion_percent)), - ); - progressBySlug.set(frontendSlug, progress); - progressBySlug.set(section.slug, progress); - }); - - const fbSec = sections?.find((s) => s.slug === "family_background"); - const mhSec = sections?.find((s) => s.slug === "marital_history"); - if (fbSec || mhSec) { - const fbTotal = fbSec?.total_steps ?? 6; - const fbCurrent = fbSec - ? Math.round((fbSec.completion_percent / 100) * fbTotal) - : 0; - const mhTotal = mhSec?.total_steps ?? 6; - const mhCurrent = mhSec - ? Math.round((mhSec.completion_percent / 100) * mhTotal) - : 0; - const combinedProgress = - fbTotal + mhTotal > 0 - ? ((fbCurrent + mhCurrent) / (fbTotal + mhTotal)) * 100 - : 0; - progressBySlug.set( - "family_marital_history", - Math.max(0, Math.min(100, Math.round(combinedProgress))), - ); + if (schema?.progress?.sections_progress) { + Object.entries(schema.progress.sections_progress).forEach(([slug, prog]) => { + progressBySlug.set(slug, Math.max(0, Math.min(100, Math.round(prog.completion_percent)))); + }); } - + + // Add fallback for combined section from the schema adapter which attaches it to questionListItems directly questionListItems.forEach((item) => { - const localProgress = getLocalSectionProgress(item, profile, age); - if (localProgress !== null) { - progressBySlug.set(item.slug, localProgress); + if (!progressBySlug.has(item.slug)) { + progressBySlug.set(item.slug, item.progress); } }); return progressBySlug; - }, [sections, questionListItems, profile]); + }, [schema, questionListItems]); const requiredQuestionListItems = useMemo( () => questionListItems.filter((item) => Boolean(item.required)), @@ -189,21 +154,11 @@ export default function QuestionsListPage() { setIsSyncError(false); try { - const slugsToCheck = [ - "personal_info", - "contact_residence_family_communication", - "appearance_health_activity", - "education_career_economic_status", - "family_marital_history", - "beliefs_lifestyle_boundaries", - "future_spouse_criteria", - "identity_verification", - ]; + const slugsToCheck = questionListItems.map(item => item.slug); for (const slug of slugsToCheck) { - const rawValue = window.localStorage.getItem( - `marriage:sections:${slug}:answers`, - ); + const storageKey = getQuestionAnswersStorageKey(slug); + const rawValue = window.localStorage.getItem(storageKey); if (rawValue) { const storedValue = JSON.parse(rawValue); if (storedValue.pending_sync && storedValue.fields) { @@ -218,7 +173,7 @@ export default function QuestionsListPage() { storedValue.pending_sync = false; window.localStorage.setItem( - `marriage:sections:${slug}:answers`, + storageKey, JSON.stringify(storedValue), ); } @@ -235,7 +190,7 @@ export default function QuestionsListPage() { } }; - if ((!profile || !sections) && (isProfileLoading || isSectionsLoading)) { + if (isProfileLoading || isSectionsLoading || isSchemaLoading) { return ( <> diff --git a/src/app/questions-list/sections-request.tsx b/src/app/questions-list/sections-request.tsx index d3e429f..7f7aa57 100644 --- a/src/app/questions-list/sections-request.tsx +++ b/src/app/questions-list/sections-request.tsx @@ -4,9 +4,13 @@ import { useEffect, useMemo, useState } from "react"; import { IoClose } from "react-icons/io5"; import Button from "@/components/Componentes/button"; import InformationSheet from "@/components/Componentes/information-sheet"; -import { bookingTerms } from "@/data/question-data"; import { useMarriageSectionsQuery } from "@/hooks/marriage/use-sections"; +const bookingTerms = [ + "All provided information is held in strict confidence.", + "Data is utilized exclusively to ensure optimal matchmaking accuracy.", +]; + const FIRST_ENTRY_TERMS_SEEN_KEY = "marriage:first-entry-terms-seen"; const FIRST_ENTRY_TERMS = [ diff --git a/src/app/terms/page.test.tsx b/src/app/terms/page.test.tsx new file mode 100644 index 0000000..2a993ba --- /dev/null +++ b/src/app/terms/page.test.tsx @@ -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: () =>
SliderPage
, +})); + +describe('TermsRoute Guard', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + afterEach(() => { + cleanup(); + }); + + it('renders loading state when isLoading is true', () => { + mockUseMarriageProfileQuery.mockReturnValue({ isLoading: true }); + render(); + 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(); + + 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(); + + 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(); + + 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(); + + expect(screen.queryByTestId('slider-page')).not.toBeInTheDocument(); + await waitFor(() => { + expect(mockReplace).toHaveBeenCalledWith('/finding-match'); + }); + }); +}); diff --git a/src/app/terms/page.tsx b/src/app/terms/page.tsx index d308264..a76607c 100644 --- a/src/app/terms/page.tsx +++ b/src/app/terms/page.tsx @@ -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 Button from "@/components/Componentes/button"; +import { useI18n } from "@/translations/provider"; +import { localizePath } from "@/translations/config"; 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 ( +
+
+
+ ); + } + + if (isError) { + return ( +
+

+ {/* @ts-expect-error - missing key */} + {t["Failed to load profile. Please try again."] || "Failed to load profile. Please try again."} +

+
+ +
+
+ ); + } + + 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 ( +
+
+
+ ); + } + return ; } diff --git a/src/components/Componentes/conditional-questions.tsx b/src/components/Componentes/conditional-questions.tsx deleted file mode 100644 index 88240b1..0000000 --- a/src/components/Componentes/conditional-questions.tsx +++ /dev/null @@ -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 ( - - ); - } - - return ( -
- -
- {matchedRule.subQuestions.map((subQuestion, index) => { - // Sub-question index is calculated uniquely to prevent conflicts - const subIndex = parentQuestionIndex * 100 + index + 1; - return ( -
- -
- ); - })} -
-
- ); -} - -export default ConditionalQuestions; diff --git a/src/components/Componentes/fix-to-the-end.tsx b/src/components/Componentes/fix-to-the-end.tsx index 1e6c058..41a96a7 100644 --- a/src/components/Componentes/fix-to-the-end.tsx +++ b/src/components/Componentes/fix-to-the-end.tsx @@ -4,13 +4,19 @@ import type { ReactNode } from "react"; type FixToTheEndProps = { children: ReactNode; + className?: string; }; -export function FixToTheEnd({ children }: FixToTheEndProps) { +export function FixToTheEnd({ children, className }: FixToTheEndProps) { return (
{children}
diff --git a/src/components/Componentes/progress-helper.ts b/src/components/Componentes/progress-helper.ts deleted file mode 100644 index 06ad5c1..0000000 --- a/src/components/Componentes/progress-helper.ts +++ /dev/null @@ -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) => - 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) => - 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, - field: Record | 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[] = []; - 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; - } -} diff --git a/src/components/Componentes/question-answer-storage.tsx b/src/components/Componentes/question-answer-storage.tsx index d025ea7..3f3465d 100644 --- a/src/components/Componentes/question-answer-storage.tsx +++ b/src/components/Componentes/question-answer-storage.tsx @@ -11,8 +11,7 @@ import { useRef, useState, } from "react"; -import type { QuestionField } from "@/data/question-data"; -import { toBackendSlug } from "@/data/section-slug-map"; +import type { QuestionField } from "@/lib/schema-adapter"; import { pathParam } from "@/hooks/marriage/path-param"; import { marriageQueryKeys } from "@/hooks/marriage/query-keys"; import type { @@ -28,7 +27,7 @@ import { } from "@/hooks/marriage/use-section-data"; import { getApiRequestUrl } from "@/lib/http"; -const STORAGE_VERSION = 1; +const STORAGE_VERSION = 2; type QuestionAnswersByKey = Record; @@ -49,14 +48,12 @@ type QuestionAnswersContextValue = { flushAnswers: (options?: FlushAnswersOptions) => Promise; getAnswerValue: ( question: QuestionField, - questionIndex: number, ) => MarriageFieldValue | undefined; hasPendingSync: boolean; isSaving: boolean; isLoading: boolean; setAnswerValue: ( question: QuestionField, - questionIndex: number, value: MarriageFieldValue, ) => void; backendFields: MarriageField[]; @@ -71,37 +68,8 @@ type QuestionAnswersProviderProps = { const QuestionAnswersContext = createContext(null); -function hashString(value: string) { - let hash = 0; - - for (let index = 0; index < value.length; index += 1) { - hash = (hash * 31 + value.charCodeAt(index)) >>> 0; - } - - return hash.toString(36); -} - -function slugifyQuestionTitle(title: string) { - const slug = title - .normalize("NFKD") - .replace(/[\u0300-\u036f]/g, "") - .toLowerCase() - .replace(/[^a-z0-9]+/g, "_") - .replace(/^_+|_+$/g, ""); - - return slug || `field_${hashString(title)}`; -} - -function getQuestionFieldKey(question: QuestionField, questionIndex: number) { - const index = - question.originalIndex !== undefined - ? question.originalIndex - : questionIndex; - return `q${index + 1}_${slugifyQuestionTitle(question.englishTitle || question.title)}`; -} - export function getQuestionAnswersStorageKey(slug: string) { - return `marriage:sections:${slug}:answers`; + return `marriage:sections:${slug}:answers:v${STORAGE_VERSION}`; } export function hasQuestionAnswerValue(value: MarriageFieldValue) { @@ -151,66 +119,35 @@ function isMarriagePhoneFieldValue( ); } -function findQuestionFieldKey( - question: QuestionField, - questionIndex: number, - answers?: QuestionAnswersByKey, - backendFields?: MarriageField[], -): string { - const legacyKey = getQuestionFieldKey(question, questionIndex); - - if (backendFields && backendFields.length > 0) { - const engSlug = slugifyQuestionTitle(question.englishTitle || question.title); - - // 1. Try to match by slug - const matchBySlug = backendFields.find((f) => - typeof f.key === "string" && (f.key.endsWith(`.${engSlug}`) || f.key.endsWith(`_${engSlug}`)) - ); - if (matchBySlug) return matchBySlug.key; - - // 2. Try to match by label - const matchByLabel = backendFields.find((f) => - f.label === question.title || f.label === question.englishTitle - ); - if (matchByLabel) return matchByLabel.key; - - // 3. Fallback to index - if (backendFields[questionIndex]) { - return backendFields[questionIndex].key; - } - } - if (!answers) return legacyKey; - - const engSlug = slugifyQuestionTitle(question.englishTitle || question.title); - const foundEntry = Object.values(answers).find( - (f) => - f && - (f.key === legacyKey || - f.label === question.title || - f.label === question.englishTitle || - (typeof f.key === "string" && - (f.key.endsWith(`.${engSlug}`) || f.key.endsWith(`_${engSlug}`)))), - ); - - return foundEntry?.key || legacyKey; -} function createQuestionField( question: QuestionField, - questionIndex: number, value: MarriageFieldValue, - currentAnswers?: QuestionAnswersByKey, - backendFields?: MarriageField[], ): MarriageField { - const key = findQuestionFieldKey(question, questionIndex, currentAnswers, backendFields); + let option_id = undefined; + + if (question.options && Array.isArray(question.options)) { + if (question.type === "checkbox" && Array.isArray(value)) { + option_id = value; + } else { + const selectedOpt = question.options.find((opt) => opt.id === value); + if (selectedOpt) { + option_id = selectedOpt.id; + } + } + } + + const key = question.id; + return { key, label: question.title, type: question.type, value, private: question.private, - }; + option_id: option_id, + } as MarriageField; } function getOrderedFields( @@ -221,8 +158,8 @@ function getOrderedFields( const orderedFields: MarriageField[] = []; const orderedKeys = new Set(); - questions.forEach((question, index) => { - const key = findQuestionFieldKey(question, index, answers, backendFields); + questions.forEach((question) => { + const key = question.id; const field = answers[key]; if (field) { @@ -245,16 +182,11 @@ function getCurrentStep( questions: readonly QuestionField[], backendFields?: MarriageField[], ) { - return questions.filter((question, index) => { - if (!question.required || question.logic?.dependsOn) { + return questions.filter((question) => { + if (!question.required || !question.isVisible) { return false; } - const key = findQuestionFieldKey( - question, - index, - fieldsToAnswers(fields), - backendFields - ); + const key = question.id; const field = fields.find((f) => f.key === key); return field && hasQuestionAnswerValue(field.value); }).length; @@ -267,7 +199,7 @@ function createPayload( ): UpdateMarriageSectionDataPayload { const fields = getOrderedFields(answers, questions, backendFields); const targetQuestions = questions.filter( - (q) => q.required && !q.logic?.dependsOn, + (q) => q.required && q.isVisible, ); return { @@ -279,7 +211,14 @@ function createPayload( function fieldsToAnswers(fields: MarriageField[]) { return fields.reduce((nextAnswers, field) => { - nextAnswers[field.key] = field; + if (field.option_id !== undefined && field.option_id !== null) { + nextAnswers[field.key] = { + ...field, + value: field.option_id, + }; + } else { + nextAnswers[field.key] = field; + } return nextAnswers; }, {}); } @@ -346,9 +285,8 @@ function writeStoredAnswers( } function getKeepalivePatchUrl(slug: string) { - const backendSlug = toBackendSlug(slug); return getApiRequestUrl( - `/api/marriage/sections/${pathParam(backendSlug)}/data/`, + `/api/marriage/forms/profile/answers/`, ); } @@ -453,29 +391,22 @@ export function QuestionAnswersProvider({ }, []); const getAnswerValue = useCallback( - (question: QuestionField, questionIndex: number) => { - const key = findQuestionFieldKey(question, questionIndex, answers, serverSectionData?.data || undefined); + (question: QuestionField) => { + const key = question.id; return answers[key]?.value; }, - [answers, serverSectionData?.data], + [answers], ); const setAnswerValue = useCallback( ( question: QuestionField, - questionIndex: number, value: MarriageFieldValue, ) => { if (!canEdit) { return; } - const field = createQuestionField( - question, - questionIndex, - value, - answersRef.current, - serverSectionData?.data || undefined, - ); + const field = createQuestionField(question, value); setAnswers((currentAnswers) => { const nextAnswers = { @@ -584,14 +515,7 @@ export function QuestionAnswersProvider({ return; } - // The combined family card must be split across two backend endpoints by - // updateMarriageSectionData. Sending its full payload to either endpoint - // would overwrite the other half of the profile. The local pending draft - // remains available and is retried on the next visit. - if ( - slugRef.current === "family_marital_history" || - flushPromiseRef.current - ) { + if (flushPromiseRef.current) { return; } @@ -611,12 +535,18 @@ export function QuestionAnswersProvider({ headers["X-CSRFToken"] = csrfToken; } + const answersPayload = payload.fields.map((f) => ({ + question_id: f.key, + value: f.value, + option_id: (f as any).option_id || undefined, + })); + fetch(getKeepalivePatchUrl(slugRef.current), { - body: JSON.stringify(payload), + body: JSON.stringify({ answers: answersPayload }), credentials: "include", headers, keepalive: true, - method: "PATCH", + method: "PUT", }) .then((response) => { if (!response.ok) { @@ -702,14 +632,13 @@ export function useQuestionAnswers() { export function useQuestionAnswer( question: QuestionField, - questionIndex: number, ) { const context = useContext(QuestionAnswersContext); return { setValue: (value: MarriageFieldValue) => { - context?.setAnswerValue(question, questionIndex, value); + context?.setAnswerValue(question, value); }, - value: context?.getAnswerValue(question, questionIndex), + value: context?.getAnswerValue(question), }; } diff --git a/src/components/Componentes/question-answer.test.tsx b/src/components/Componentes/question-answer.test.tsx new file mode 100644 index 0000000..fee2389 --- /dev/null +++ b/src/components/Componentes/question-answer.test.tsx @@ -0,0 +1,260 @@ +import { render, screen, fireEvent, waitFor, cleanup } from "@testing-library/react"; +import { describe, it, expect, afterEach, beforeEach, vi } from "vitest"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { QuestionAnswersProvider, useQuestionAnswers } from "./question-answer-storage"; +import { convertSchemaToFrontendItems } from "@/lib/schema-adapter"; +import { useFormSchemaQuery } from "@/hooks/marriage/use-form-schema"; +import { useMarriageProfileQuery } from "@/hooks/marriage/use-profile-main"; +import { useMarriageSectionDataQuery, useUpdateMarriageSectionDataMutation } from "@/hooks/marriage/use-section-data"; + +vi.mock("@/hooks/marriage/use-form-schema", () => ({ + useFormSchemaQuery: vi.fn(), +})); +vi.mock("@/hooks/marriage/use-profile-main", () => ({ + useMarriageProfileQuery: vi.fn(), +})); +vi.mock("@/hooks/marriage/use-section-data", () => ({ + useMarriageSectionDataQuery: vi.fn(), + useUpdateMarriageSectionDataMutation: vi.fn(), +})); + +// Dummy component to interact with the context +function TestComponent({ slug }: { slug: string }) { + const { setAnswerValue, flushAnswers } = useQuestionAnswers(); + + return ( +
+ + + + + +
+ ); +} + +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( + + + + + + ); + + 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( + + + + + + ); + + 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
; + } + + const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + + render( + + + + + + ); + + // 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); + }); +}); diff --git a/src/components/Componentes/question-birthplace.tsx b/src/components/Componentes/question-birthplace.tsx index dc0c42c..8c6337c 100644 --- a/src/components/Componentes/question-birthplace.tsx +++ b/src/components/Componentes/question-birthplace.tsx @@ -2,7 +2,7 @@ import { useCallback, useEffect, useRef, useState } from "react"; import { getCountryList, COUNTRIES_EN, COUNTRIES_FA } from "@/data/countries"; -import type { QuestionField } from "@/data/question-data"; +import type { QuestionField } from "@/lib/schema-adapter"; import { useI18n } from "@/translations/provider"; import { useQuestionAnswers } from "./question-answer-storage"; import QuestionTitle from "./question-title"; @@ -10,7 +10,6 @@ import { LoadingThreeDot } from "./loading-three-dot"; type QuestionBirthplaceProps = { question: QuestionField; - questionIndex: number; disabled?: boolean; }; @@ -78,12 +77,11 @@ function parseValue(rawValue: unknown): { country: string; city: string } { export function QuestionBirthplace({ question, - questionIndex, disabled, }: QuestionBirthplaceProps) { const { dictionary: t, locale } = useI18n(); const { getAnswerValue, setAnswerValue, isLoading } = useQuestionAnswers(); - const rawValue = getAnswerValue(question, questionIndex); + const rawValue = getAnswerValue(question); const initial = parseValue(rawValue); const [selectedCountry, setSelectedCountry] = useState(initial.country); @@ -95,9 +93,7 @@ export function QuestionBirthplace({ const listRef = useRef(null); const searchInputRef = useRef(null); - const isResidence = - question.title.toLowerCase().includes("residence") || - question.title.includes("سکونت"); + const isResidence = question.ui_config?.enable_geoip === true; const [mode, setMode] = useState<"auto" | "manual">("auto"); const [isDetecting, setIsDetecting] = useState(false); @@ -106,7 +102,7 @@ export function QuestionBirthplace({ const updateAnswers = (country: string, city: string) => { const formatted = city && country ? `${city}, ${country}` : city || country || null; - setAnswerValue(question, questionIndex, formatted); + setAnswerValue(question, formatted); }; // GeoIP detection logic diff --git a/src/components/Componentes/question-button.tsx b/src/components/Componentes/question-button.tsx index 93f7fe9..614444c 100644 --- a/src/components/Componentes/question-button.tsx +++ b/src/components/Componentes/question-button.tsx @@ -1,22 +1,20 @@ "use client"; import { IoInformation } from "react-icons/io5"; -import type { QuestionField } from "@/data/question-data"; +import type { QuestionField } from "@/lib/schema-adapter"; import { useQuestionAnswer } from "./question-answer-storage"; import QuestionTitle from "./question-title"; type QuestionButtonProps = { question: QuestionField; - questionIndex: number; disabled?: boolean; }; export function QuestionButton({ question, - questionIndex, disabled, }: QuestionButtonProps) { - const { setValue, value } = useQuestionAnswer(question, questionIndex); + const { setValue, value } = useQuestionAnswer(question); const isAnswered = value === true; return ( diff --git a/src/components/Componentes/question-card.tsx b/src/components/Componentes/question-card.tsx index 36435bb..ac04c1c 100644 --- a/src/components/Componentes/question-card.tsx +++ b/src/components/Componentes/question-card.tsx @@ -9,7 +9,7 @@ import { IoPerson, IoSchool, } from "react-icons/io5"; -import type { QuestionCardIcon, QuestionListItem } from "@/data/question-data"; +import type { QuestionCardIcon, QuestionListItem } from "@/lib/schema-adapter"; import { localizePath } from "@/translations/config"; import { useI18n } from "@/translations/provider"; diff --git a/src/components/Componentes/question-checkbox.tsx b/src/components/Componentes/question-checkbox.tsx index 98ae73f..5a784c6 100644 --- a/src/components/Componentes/question-checkbox.tsx +++ b/src/components/Componentes/question-checkbox.tsx @@ -1,23 +1,21 @@ "use client"; -import type { QuestionField } from "@/data/question-data"; +import type { QuestionField } from "@/lib/schema-adapter"; import { useQuestionAnswers } from "./question-answer-storage"; import QuestionTitle from "./question-title"; type QuestionCheckboxProps = { question: QuestionField; - questionIndex: number; disabled?: boolean; }; export function QuestionCheckbox({ question, - questionIndex, disabled, }: QuestionCheckboxProps) { - const options = question.extras.options || []; + const options = question.options || []; const { getAnswerValue, setAnswerValue } = useQuestionAnswers(); - const rawValue = getAnswerValue(question, questionIndex); + const rawValue = getAnswerValue(question); const value = Array.isArray(rawValue) ? rawValue @@ -29,40 +27,21 @@ export function QuestionCheckbox({ return null; } - const toggleOption = (option: string) => { + const toggleOption = (optionId: string) => { let nextValue: string[]; - if (value.includes(option)) { - nextValue = value.filter((v) => v !== option); + if (value.includes(optionId)) { + nextValue = value.filter((v) => v !== optionId); } else { - nextValue = [...value, option]; - } - - const exclusiveOption = options.find( - (o) => - o.includes("مهم نیست") || - o.includes("Doesn't matter") || - o.includes("No children") || - o.includes("فرزندی ندارم.") || - o.includes("مسئولیت مستمری ندارم") || - o.includes("do not have any ongoing responsibility"), - ); - if (exclusiveOption) { - if (option === exclusiveOption) { - nextValue = [exclusiveOption]; - } else if (nextValue.includes(exclusiveOption)) { - nextValue = nextValue.filter((v) => v !== exclusiveOption); - } + nextValue = [...value, optionId]; } setAnswerValue( - question, - questionIndex, - nextValue.length > 0 ? nextValue : null, + question, nextValue.length > 0 ? nextValue : null, ); }; const isShortOptions = - options.length <= 4 && options.every((opt) => opt.length <= 15); + options.length <= 4 && options.every((opt) => opt.label.length <= 15); return (
{options.map((option) => { - const optionId = `checkbox-${questionIndex}-${option}`; - const isSelected = value.includes(option); + const optionId = `checkbox-${question.id}-${option.id}`; + const isSelected = value.includes(option.id); return (
)} - {option} + {option.label} ); })} diff --git a/src/components/Componentes/question-date.tsx b/src/components/Componentes/question-date.tsx index caa559b..da1f88d 100644 --- a/src/components/Componentes/question-date.tsx +++ b/src/components/Componentes/question-date.tsx @@ -1,30 +1,29 @@ "use client"; import { useEffect, useMemo, useState } from "react"; -import type { QuestionField } from "@/data/question-data"; +import type { QuestionField } from "@/lib/schema-adapter"; import { useI18n } from "@/translations/provider"; import { useQuestionAnswers } from "./question-answer-storage"; import QuestionTitle from "./question-title"; type QuestionDateProps = { question: QuestionField; - questionIndex: number; disabled?: boolean; }; -const MONTH_VALUES = [ - "01", - "02", - "03", - "04", - "05", - "06", - "07", - "08", - "09", - "10", - "11", - "12", +const MONTHS = [ + { value: "01", label: "01 - January" }, + { value: "02", label: "02 - February" }, + { value: "03", label: "03 - March" }, + { value: "04", label: "04 - April" }, + { value: "05", label: "05 - May" }, + { value: "06", label: "06 - June" }, + { value: "07", label: "07 - July" }, + { value: "08", label: "08 - August" }, + { value: "09", label: "09 - September" }, + { value: "10", label: "10 - October" }, + { value: "11", label: "11 - November" }, + { value: "12", label: "12 - December" }, ]; const DAYS = Array.from({ length: 31 }, (_, i) => { @@ -42,12 +41,11 @@ const YEARS = Array.from({ length: 80 }, (_, i) => export function QuestionDate({ question, - questionIndex, disabled, }: QuestionDateProps) { const { locale } = useI18n(); const { getAnswerValue, setAnswerValue } = useQuestionAnswers(); - const value = getAnswerValue(question, questionIndex); + const value = getAnswerValue(question); const dateValue = typeof value === "string" ? value : ""; const months = useMemo(() => { @@ -123,12 +121,10 @@ export function QuestionDate({ const formattedMonth = m.padStart(2, "0"); const formattedDay = d.padStart(2, "0"); setAnswerValue( - question, - questionIndex, - `${y}-${formattedMonth}-${formattedDay}`, + question, `${y}-${formattedMonth}-${formattedDay}`, ); } else { - setAnswerValue(question, questionIndex, ""); + setAnswerValue(question, ""); } }; diff --git a/src/components/Componentes/question-dropdown.tsx b/src/components/Componentes/question-dropdown.tsx index bc4539a..7033bb6 100644 --- a/src/components/Componentes/question-dropdown.tsx +++ b/src/components/Componentes/question-dropdown.tsx @@ -2,33 +2,29 @@ import { useCallback, useEffect, useRef, useState } from "react"; import { ExplanationUiFont } from "./explanation-ui-font"; -import { getCountryList } from "@/data/countries"; -import { getLanguageList } from "@/data/languages"; -import { getNationalityList } from "@/data/nationalities"; -import type { QuestionField } from "@/data/question-data"; +import type { QuestionField } from "@/lib/schema-adapter"; import { useI18n } from "@/translations/provider"; import { useQuestionAnswers } from "./question-answer-storage"; import QuestionTitle from "./question-title"; type QuestionDropdownProps = { question: QuestionField; - questionIndex: number; disabled?: boolean; }; export function QuestionDropdown({ question, - questionIndex, disabled, }: QuestionDropdownProps) { - const { locale, dictionary: t } = useI18n(); + const { dictionary: t } = useI18n(); const { getAnswerValue, setAnswerValue } = useQuestionAnswers(); - const rawValue = getAnswerValue(question, questionIndex); + const rawValue = getAnswerValue(question); const isMulti = Array.isArray(rawValue) || question.type === "checkbox" || (question.extras?.range && question.extras.range[1] > 1); + const selectedList = Array.isArray(rawValue) ? rawValue : typeof rawValue === "string" && rawValue @@ -100,154 +96,49 @@ export function QuestionDropdown({ } }, [isOpen]); - const isPersonalityTraits = - question.title === "Your Personality Traits" || - question.title === "ویژگی‌های شخصیتی خودتان"; - - const isHobbies = - question.title === "Your Hobbies and Main Interests" || - question.title === "سرگرمی‌ها و علایق اصلی"; - - const isSmokingSubstances = - question.title === "Red Lines for Smoking, Alcohol, and Substances" || - question.title === "خط قرمزهای مربوط به دخانیات، الکل و مواد در همسر آینده"; - - const isOtherQuestion = - isPersonalityTraits || isHobbies || isSmokingSubstances; - - const otherOptionName = t["Other"] || "Other"; - - let options = question.extras.options || []; - const isNationalityDropdown = - question.title === "Current Nationality / Citizenship" || - question.title === "ملیت / تابعیت فعلی"; - - const isCountryDropdown = - !isNationalityDropdown && - (options.includes("United States") || - options.includes("ایالات متحده آمریکا") || - options.includes("Iran") || - options.includes("ایران")); - - if (isNationalityDropdown) { - options = getNationalityList(locale); - } else if (isCountryDropdown) { - options = getCountryList(locale); - } - - const isLanguageDropdown = - options.includes("Persian") || - options.includes("فارسی") || - options.includes("English") || - options.includes("انگلیسی"); - - if (isLanguageDropdown) { - options = getLanguageList(locale); - } - - if (isOtherQuestion) { - options = [...options, otherOptionName]; - } + const options = question.options || []; const showSearch = (() => { - if (question.extras.noSearch) return false; - const titleLower = question.title.toLowerCase(); - return ( - isCountryDropdown || - isNationalityDropdown || - isLanguageDropdown || - titleLower.includes("country") || - titleLower.includes("کشور") || - titleLower.includes("city") || - titleLower.includes("شهر") || - titleLower.includes("currency") || - titleLower.includes("ارز") || - titleLower.includes("nationality") || - titleLower.includes("ملیت") || - titleLower.includes("language") || - titleLower.includes("زبان") || - options.length > 10 - ); + if (question.extras?.noSearch) return false; + return options.length > 10; })(); const filteredOptions = options.filter((option) => - option.toLowerCase().includes(searchQuery.toLowerCase()), + option.label.toLowerCase().includes(searchQuery.toLowerCase()), ); - const getCleanLabel = (val: string) => { - const base = val.split(" - ")[0]; - if (isOtherQuestion) { - if (base === otherOptionName || base.startsWith(`${otherOptionName}: `)) { - return otherOptionName; - } - } - return base; + const getCleanLabel = (optId: string) => { + const opt = options.find((o) => o.id === optId); + if (!opt) return optId; + return opt.label.split(" - ")[0]; }; const displayLabel = isMulti ? selectedList.length > 0 ? selectedList.map(getCleanLabel).join(", ") - : question.extras.placeHolder || "Select" + : question.extras?.placeHolder || "Select" : singleValue ? getCleanLabel(singleValue) - : question.extras.placeHolder || "Select"; + : question.extras?.placeHolder || "Select"; const hasSelectedValue = isMulti ? selectedList.length > 0 : Boolean(singleValue); - const otherValueIndex = selectedList.findIndex( - (v) => v === otherOptionName || v.startsWith(`${otherOptionName}: `), - ); - const isOtherSelected = otherValueIndex !== -1; - const currentOtherText = isOtherSelected - ? selectedList[otherValueIndex].startsWith(`${otherOptionName}: `) - ? selectedList[otherValueIndex].slice(otherOptionName.length + 2) - : "" - : ""; - - const toggleMultiOption = (option: string) => { - const isOtherOpt = isOtherQuestion && option === otherOptionName; + const toggleMultiOption = (optionId: string) => { let nextValue: string[]; - if (isOtherQuestion && isOtherOpt) { - const hasOther = selectedList.some( - (v) => v === otherOptionName || v.startsWith(`${otherOptionName}: `), - ); - if (hasOther) { - nextValue = selectedList.filter( - (v) => v !== otherOptionName && !v.startsWith(`${otherOptionName}: `), - ); - } else { - nextValue = [...selectedList, otherOptionName]; - } + if (selectedList.includes(optionId)) { + nextValue = selectedList.filter((v) => v !== optionId); } else { - if (selectedList.includes(option)) { - nextValue = selectedList.filter((v) => v !== option); - } else { - nextValue = [...selectedList, option]; - } + nextValue = [...selectedList, optionId]; } setAnswerValue( - question, - questionIndex, - nextValue.length > 0 ? nextValue : null, + question, nextValue.length > 0 ? nextValue : null, ); }; - const handleOtherTextChange = (text: string) => { - if (otherValueIndex !== -1) { - const nextValue = [...selectedList]; - if (text.trim() === "") { - nextValue[otherValueIndex] = otherOptionName; - } else { - nextValue[otherValueIndex] = `${otherOptionName}: ${text}`; - } - setAnswerValue(question, questionIndex, nextValue); - } - }; - return (
- {/* RENDER TEXT FIELD IF OTHER IS SELECTED */} - {isOtherQuestion && isOtherSelected ? ( -
- handleOtherTextChange(e.target.value)} - placeholder={ - isPersonalityTraits - ? (t["Write other options..."] ?? "Write other options...") - : isSmokingSubstances - ? locale === "fa" - ? "خط قرمزهای دیگر را بنویسید..." - : "Write other red lines..." - : locale === "fa" - ? "سرگرمی‌ها یا علایق دیگر را بنویسید..." - : "Write other hobbies or interests..." - } - className="h-[48px] w-full rounded-[14px] border border-[#D0D5DD] bg-white px-4 text-[14px] font-medium text-[#181818] outline-none transition-all placeholder:text-[#98A2B3] hover:border-[#98A2B3] focus:border-[#6F6F6F] focus:ring-1 focus:ring-[#6F6F6F]" - /> -
- ) : null} - {/* Dropdown Options Panel */} {isOpen && (
@@ -383,20 +251,18 @@ export function QuestionDropdown({ {filteredOptions.length > 0 ? ( filteredOptions.map((option) => { const isSelected = isMulti - ? isOtherQuestion && option === otherOptionName - ? isOtherSelected - : selectedList.includes(option) - : singleValue === option; + ? selectedList.includes(option.id) + : singleValue === option.id; return ( diff --git a/src/components/Componentes/question-exit-navigation-button.tsx b/src/components/Componentes/question-exit-navigation-button.tsx index 368cbd8..fa34631 100644 --- a/src/components/Componentes/question-exit-navigation-button.tsx +++ b/src/components/Componentes/question-exit-navigation-button.tsx @@ -25,28 +25,32 @@ export function QuestionExitNavigationButton({ const { locale } = useI18n(); const { flushAnswers } = useQuestionAnswers(); + const [isNavigating, setIsNavigating] = useState(false); + return ( { + disabled={props.disabled || isNavigating} + onClick={async (event) => { props.onClick?.(event); - if (event.defaultPrevented) { + if (event.defaultPrevented || isNavigating) { return; } event.preventDefault(); + setIsNavigating(true); try { markFirstEntryCompleted(); - void flushAnswers({ force: true }); + await flushAnswers({ force: true }); } catch { // ignore + } finally { + triggerSilentReload(queryClient); + const target = localizePath(exitHref || "/questions-list", locale); + router.push(target); } - - triggerSilentReload(queryClient); - const target = localizePath(exitHref || "/questions-list", locale); - router.push(target); }} /> ); diff --git a/src/components/Componentes/question-file.tsx b/src/components/Componentes/question-file.tsx index 61981c1..48e8d8d 100644 --- a/src/components/Componentes/question-file.tsx +++ b/src/components/Componentes/question-file.tsx @@ -2,7 +2,7 @@ import Image from "next/image"; import { useCallback, useEffect, useState } from "react"; -import type { QuestionField } from "@/data/question-data"; +import type { QuestionField } from "@/lib/schema-adapter"; import { useUploadTmpMediaMutation } from "@/hooks/marriage/use-upload-tmp-media"; import { getApiRequestUrl } from "@/lib/http"; import { isInFlutterWebView, uploadFile } from "@/lib/webview-actions"; @@ -12,7 +12,6 @@ import { LoadingSkeleton } from "./loading-skeleton"; type QuestionFileProps = { question: QuestionField; - questionIndex: number; disabled?: boolean; }; @@ -59,11 +58,10 @@ function isImageFile( export function QuestionFile({ question, - questionIndex, disabled, }: QuestionFileProps) { const { getAnswerValue, setAnswerValue } = useQuestionAnswers(); - const storedValue = getAnswerValue(question, questionIndex); + const storedValue = getAnswerValue(question); const initialFileName = typeof storedValue === "string" && storedValue.trim().length > 0 @@ -94,7 +92,7 @@ export function QuestionFile({ const uploadTmpMediaMutation = useUploadTmpMediaMutation({ onSuccess: (response) => { if (response.path) { - setAnswerValue(question, questionIndex, response.path); + setAnswerValue(question, response.path); } }, onError: (error) => { @@ -120,7 +118,7 @@ export function QuestionFile({ const fileName = event.data.files[0].name ?? null; setSelectedFileName(fileName); if (fileName) { - setAnswerValue(question, questionIndex, fileName); + setAnswerValue(question, fileName); } } break; @@ -130,7 +128,7 @@ export function QuestionFile({ setIsFlutterPicking(false); const file = event.data?.files?.[0]; if (file?.url) { - setAnswerValue(question, questionIndex, file.url); + setAnswerValue(question, file.url); setSelectedFileName(file.name ?? "uploaded"); setFilePreviewUrl(file.url); } else if (file?.base64) { @@ -161,7 +159,7 @@ export function QuestionFile({ return () => { unsubscribe?.(); }; - }, [question, questionIndex, setAnswerValue, uploadTmpMediaMutation]); + }, [question, setAnswerValue, uploadTmpMediaMutation]); /** Handle file pick in Flutter WebView via upload_file action. */ const handleFlutterPick = useCallback(() => { @@ -190,12 +188,12 @@ export function QuestionFile({ if (!file) { setSelectedFileName(null); setFilePreviewUrl(null); - setAnswerValue(question, questionIndex, null); + setAnswerValue(question, null); return; } setSelectedFileName(file.name); - setAnswerValue(question, questionIndex, file.name); + setAnswerValue(question, file.name); if (file.type.startsWith("image/")) { const objectUrl = URL.createObjectURL(file); @@ -212,7 +210,7 @@ export function QuestionFile({ e.preventDefault(); setSelectedFileName(null); setFilePreviewUrl(null); - setAnswerValue(question, questionIndex, null); + setAnswerValue(question, null); }; const inWebView = isInFlutterWebView(); diff --git a/src/components/Componentes/question-number.tsx b/src/components/Componentes/question-number.tsx index 990f972..7270c84 100644 --- a/src/components/Componentes/question-number.tsx +++ b/src/components/Componentes/question-number.tsx @@ -1,14 +1,13 @@ "use client"; import { useEffect, useMemo, useRef, useState } from "react"; -import type { QuestionField } from "@/data/question-data"; +import type { QuestionField } from "@/lib/schema-adapter"; import { useI18n } from "@/translations/provider"; import { useQuestionAnswers } from "./question-answer-storage"; import QuestionTitle from "./question-title"; type QuestionNumberProps = { question: QuestionField; - questionIndex: number; disabled?: boolean; derivedFromQuestion?: QuestionField; derivedFromQuestionIndex?: number; @@ -18,32 +17,30 @@ const NUMBER_INPUT_PATTERN = /^-?\d*\.?\d*$/; export default function QuestionNumber({ question, - questionIndex, disabled, derivedFromQuestion, derivedFromQuestionIndex, }: QuestionNumberProps) { const { dictionary: t, locale } = useI18n(); const { getAnswerValue, setAnswerValue } = useQuestionAnswers(); - const value = getAnswerValue(question, questionIndex); + const value = getAnswerValue(question); const derivedValue = derivedFromQuestion && derivedFromQuestionIndex !== undefined - ? getAnswerValue(derivedFromQuestion, derivedFromQuestionIndex) + ? getAnswerValue(derivedFromQuestion) : null; useEffect(() => { if (derivedFromQuestion && typeof derivedValue === "string") { const age = calculateAge(derivedValue); if (age !== String(value)) { - setAnswerValue(question, questionIndex, age); + setAnswerValue(question, age); } } }, [ derivedFromQuestion, derivedValue, question, - questionIndex, setAnswerValue, value, ]); @@ -54,9 +51,9 @@ export default function QuestionNumber({ value.length > 0 && !NUMBER_INPUT_PATTERN.test(value) ) { - setAnswerValue(question, questionIndex, null); + setAnswerValue(question, null); } - }, [question, questionIndex, setAnswerValue, value]); + }, [question, setAnswerValue, value]); const [min, max] = question.extras.range; @@ -78,15 +75,14 @@ export default function QuestionNumber({ ? rawInputValue : ""; - const isMonthlyIncome = - question.title === "Monthly Income" || - question.title === "میزان درآمد ماهانه"; + const isMonthlyIncome = question.ui_config?.currency_enabled === true; + const currencyStorageKey = question.ui_config?.currency_storage_key || "marriage:income:currency"; const countryName = useMemo(() => getCountryFromStorage(), []); const [currencyCode, setCurrencyCode] = useState(() => { if (typeof window !== "undefined") { - const stored = window.localStorage.getItem("marriage:income:currency"); + const stored = window.localStorage.getItem(currencyStorageKey); if (stored) return stored; } return getCurrencyForCountry(countryName); @@ -99,7 +95,7 @@ export default function QuestionNumber({ useEffect(() => { if (typeof window !== "undefined") { - const stored = window.localStorage.getItem("marriage:income:currency"); + const stored = window.localStorage.getItem(currencyStorageKey); if (stored) { setCurrencyCode(stored); return; @@ -215,13 +211,11 @@ export default function QuestionNumber({ setLocalTextValue(finalFormatted); if (cleanValue === "") { - setAnswerValue(question, questionIndex, null); + setAnswerValue(question, null); } else { const parsed = parseFloat(cleanValue); setAnswerValue( - question, - questionIndex, - Number.isNaN(parsed) ? cleanValue : parsed, + question, Number.isNaN(parsed) ? cleanValue : parsed, ); } }} @@ -330,7 +324,7 @@ export default function QuestionNumber({ setCurrencyCode(c.code); if (typeof window !== "undefined") { window.localStorage.setItem( - "marriage:income:currency", + currencyStorageKey, c.code, ); } @@ -398,13 +392,11 @@ export default function QuestionNumber({ } if (nextValue === "") { - setAnswerValue(question, questionIndex, null); + setAnswerValue(question, null); } else { const parsed = parseFloat(nextValue); setAnswerValue( - question, - questionIndex, - Number.isNaN(parsed) ? nextValue : parsed, + question, Number.isNaN(parsed) ? nextValue : parsed, ); } }} diff --git a/src/components/Componentes/question-phone.tsx b/src/components/Componentes/question-phone.tsx index ff5a91c..9642162 100644 --- a/src/components/Componentes/question-phone.tsx +++ b/src/components/Componentes/question-phone.tsx @@ -2,7 +2,7 @@ import { PhoneNumberFormat, PhoneNumberUtil } from "google-libphonenumber"; import { useEffect, useRef, useState, useMemo, useCallback } from "react"; -import type { QuestionField } from "@/data/question-data"; +import type { QuestionField } from "@/lib/schema-adapter"; import type { MarriagePhoneFieldValue } from "@/hooks/marriage/types"; import { useQuestionAnswers } from "./question-answer-storage"; import QuestionTitle from "./question-title"; @@ -11,7 +11,6 @@ import { useI18n } from "@/translations/provider"; type QuestionPhoneProps = { question: QuestionField; - questionIndex: number; countryCode?: string; disabled?: boolean; }; @@ -224,13 +223,12 @@ function getNormalizedPhoneValue(codeValue: string, phoneValue: string) { export function QuestionPhone({ question, - questionIndex, countryCode = "+44", disabled, }: QuestionPhoneProps) { const { locale } = useI18n(); const { getAnswerValue, setAnswerValue, isLoading } = useQuestionAnswers(); - const value = getAnswerValue(question, questionIndex); + const value = getAnswerValue(question); const defaultCodeValue = countryCode.trim() || "+44"; const getCachedOrSavedCode = useCallback((): string | null => { @@ -548,7 +546,7 @@ export function QuestionPhone({ : null; lastCommittedValueRef.current = nextValue; - setAnswerValue(question, questionIndex, nextValue); + setAnswerValue(question, nextValue); }; const handleSelectCountryCode = (selectedCode: string) => { diff --git a/src/components/Componentes/question-photo.tsx b/src/components/Componentes/question-photo.tsx index e0c2808..374b127 100644 --- a/src/components/Componentes/question-photo.tsx +++ b/src/components/Componentes/question-photo.tsx @@ -2,7 +2,7 @@ import Image from "next/image"; import { type ReactNode, useCallback, useEffect, useId, useState } from "react"; -import type { QuestionField } from "@/data/question-data"; +import type { QuestionField } from "@/lib/schema-adapter"; import { useUploadTmpMediaMutation } from "@/hooks/marriage/use-upload-tmp-media"; import { getApiRequestUrl } from "@/lib/http"; import { isInFlutterWebView, uploadFile } from "@/lib/webview-actions"; @@ -12,14 +12,12 @@ import { LoadingSkeleton } from "./loading-skeleton"; type QuestionPhotoProps = { question: QuestionField; - questionIndex: number; description?: ReactNode; disabled?: boolean; }; export function QuestionPhoto({ question, - questionIndex, description, disabled, }: QuestionPhotoProps) { @@ -34,12 +32,12 @@ export function QuestionPhoto({ const descriptionContent = description ?? question.description; const { getAnswerValue, setAnswerValue } = useQuestionAnswers(); - const storedValue = getAnswerValue(question, questionIndex); + const storedValue = getAnswerValue(question); const uploadTmpMediaMutation = useUploadTmpMediaMutation({ onSuccess: (response) => { if (response.path) { - setAnswerValue(question, questionIndex, response.path); + setAnswerValue(question, response.path); } }, onError: (error) => { @@ -64,19 +62,19 @@ export function QuestionPhoto({ if (event.data?.files?.[0]?.base64) { const b64 = event.data.files[0].base64; setLocalPreviewUrl(b64); - setAnswerValue(question, questionIndex, b64); + setAnswerValue(question, b64); } break; case "completed": { setIsFlutterPicking(false); const file = event.data?.files?.[0]; if (file?.url) { - setAnswerValue(question, questionIndex, file.url); + setAnswerValue(question, file.url); setLocalPreviewUrl(file.url); } else if (file?.base64) { const b64 = file.base64; setLocalPreviewUrl(b64); - setAnswerValue(question, questionIndex, b64); + setAnswerValue(question, b64); fetch(b64) .then((res) => res.blob()) .then((blob) => { @@ -98,7 +96,7 @@ export function QuestionPhoto({ return () => { unsubscribe?.(); }; - }, [question, questionIndex, setAnswerValue, uploadTmpMediaMutation]); + }, [question, setAnswerValue, uploadTmpMediaMutation]); const handleFlutterPick = useCallback(() => { const extensions = (question.extras?.options ?? []).map((o) => @@ -126,7 +124,7 @@ export function QuestionPhoto({ // Create synchronous object URL for instant preview & immediate state update const objectUrl = URL.createObjectURL(file); setLocalPreviewUrl(objectUrl); - setAnswerValue(question, questionIndex, objectUrl); + setAnswerValue(question, objectUrl); // Trigger background upload uploadTmpMediaMutation.mutate(file); diff --git a/src/components/Componentes/question-radio.tsx b/src/components/Componentes/question-radio.tsx index 392b8d9..327e133 100644 --- a/src/components/Componentes/question-radio.tsx +++ b/src/components/Componentes/question-radio.tsx @@ -1,24 +1,22 @@ "use client"; import { ExplanationUiFont } from "./explanation-ui-font"; -import type { QuestionField } from "@/data/question-data"; +import type { QuestionField } from "@/lib/schema-adapter"; import { useQuestionAnswers } from "./question-answer-storage"; import QuestionTitle from "./question-title"; type QuestionRadioProps = { question: QuestionField; - questionIndex: number; disabled?: boolean; }; export function QuestionRadio({ question, - questionIndex, disabled, }: QuestionRadioProps) { - const options = question.extras.options || []; + const options = question.options || []; const { getAnswerValue, setAnswerValue } = useQuestionAnswers(); - const value = getAnswerValue(question, questionIndex); + const value = getAnswerValue(question); if (options.length === 0) { return null; @@ -26,7 +24,7 @@ export function QuestionRadio({ // Render horizontally if all options are short (e.g. Single, Divorced, Widowed) const isShortOptions = - options.length <= 4 && options.every((opt) => opt.length <= 15); + options.length <= 4 && options.every((opt) => opt.label.length <= 15); return (
{options.map((option) => { - const optionId = `question-${questionIndex}-${option}`; - const isSelected = String(value) === option; + const optionId = `question-${question.id}-${option.id}`; + const isSelected = String(value) === option.id; return (
)} - {option.includes(" - ") ? ( + {option.label.includes(" - ") ? ( (() => { - const parts = option.split(" - "); + const parts = option.label.split(" - "); const title = parts[0]; const description = parts.slice(1).join(" - "); return ( @@ -105,7 +103,7 @@ export function QuestionRadio({ ); })() ) : ( - {option} + {option.label} )} ); diff --git a/src/components/Componentes/question-renderer.tsx b/src/components/Componentes/question-renderer.tsx index 9b37965..f9843b1 100644 --- a/src/components/Componentes/question-renderer.tsx +++ b/src/components/Componentes/question-renderer.tsx @@ -1,6 +1,6 @@ "use client"; -import type { QuestionField } from "@/data/question-data"; +import type { QuestionField } from "@/lib/schema-adapter"; import QuestionBirthplace from "./question-birthplace"; import QuestionButton from "./question-button"; import QuestionCheckbox from "./question-checkbox"; @@ -17,22 +17,17 @@ import QuestionTextarea from "./question-textarea"; type QuestionRendererProps = { question: QuestionField; - questionIndex: number; disabled?: boolean; dobQuestion?: QuestionField; - dobQuestionIndex?: number; }; export function QuestionRenderer({ question, - questionIndex, disabled, dobQuestion, - dobQuestionIndex, }: QuestionRendererProps) { - const enTitle = (question.englishTitle || question.title).toLowerCase(); const compactTextHeight = - enTitle.includes("email") || enTitle.includes("duration") + question.type === "email" || question.ui_config?.compact === true ? "h-[54px]" : undefined; @@ -41,7 +36,6 @@ export function QuestionRenderer({ return ( ); @@ -49,7 +43,6 @@ export function QuestionRenderer({ return ( ); @@ -57,7 +50,6 @@ export function QuestionRenderer({ return ( ); @@ -65,7 +57,6 @@ export function QuestionRenderer({ return ( ); @@ -73,7 +64,6 @@ export function QuestionRenderer({ return ( ); @@ -81,22 +71,18 @@ export function QuestionRenderer({ return ( ); case "number": if ( - question.title === "Age" && - dobQuestion && - dobQuestionIndex !== undefined + question.ui_config?.derivedFromDob === true && + dobQuestion ) { return ( ); @@ -105,7 +91,6 @@ export function QuestionRenderer({ return ( ); @@ -113,7 +98,6 @@ export function QuestionRenderer({ return ( ); @@ -121,7 +105,6 @@ export function QuestionRenderer({ return ( ); @@ -129,7 +112,6 @@ export function QuestionRenderer({ return ( ); @@ -138,7 +120,6 @@ export function QuestionRenderer({ return ( ); @@ -146,7 +127,6 @@ export function QuestionRenderer({ return ( @@ -155,7 +135,6 @@ export function QuestionRenderer({ return ( ); diff --git a/src/components/Componentes/question-section-flow.tsx b/src/components/Componentes/question-section-flow.tsx index db48d33..dee33c9 100644 --- a/src/components/Componentes/question-section-flow.tsx +++ b/src/components/Componentes/question-section-flow.tsx @@ -12,10 +12,8 @@ import QuestionProgressTracker, { useQuestionProgress, } from "./question-progress-tracker"; import QuestionSnapList from "./question-snap-list"; -import type { QuestionField } from "@/data/question-data"; +import type { QuestionField } from "@/lib/schema-adapter"; import NoticeBox from "./notice-box"; -import { getStoredAge } from "./progress-helper"; -import { useMarriageProfileQuery } from "@/hooks/marriage/use-profile-main"; import AnswerPaceSheet from "@/app/questions-list/[slug]/answer-pace-sheet"; import { FixToTheEnd } from "./fix-to-the-end"; import Button from "./button"; @@ -56,7 +54,7 @@ function SectionFlowContent({ void flushAnswers({ force: true }); }, [flushAnswers]); - const handleSubmit = useCallback(() => { + const handleSubmit = useCallback(async () => { if (isSubmitting) { return; } @@ -64,14 +62,14 @@ function SectionFlowContent({ try { markFirstEntryCompleted(); - void flushAnswers({ force: true }); + await flushAnswers({ force: true }); } catch { // ignore + } finally { + triggerSilentReload(queryClient); + const target = localizePath(exitHref || "/questions-list", locale); + router.push(target); } - - triggerSilentReload(queryClient); - const target = localizePath(exitHref || "/questions-list", locale); - router.push(target); }, [exitHref, flushAnswers, locale, router, isSubmitting, queryClient]); const markOptionalQuestionsPassed = useCallback( @@ -85,13 +83,9 @@ function SectionFlowContent({ [markQuestionPassed, optionalQuestionIndexes], ); - const { data: profile } = useMarriageProfileQuery(); - const isFemale = profile?.gender === "female"; - const age = getStoredAge(); const activeQuestion = questions?.[activeQuestionIndex]; const showNotice = activeQuestion?.showGuardianNotice; - const isUnder27 = - age !== null ? isFemale && age < 27 : (activeQuestion?.required ?? false); + const isUnder27 = activeQuestion?.required ?? false; return ( <> diff --git a/src/components/Componentes/question-slider.tsx b/src/components/Componentes/question-slider.tsx index 91c8240..2797c2a 100644 --- a/src/components/Componentes/question-slider.tsx +++ b/src/components/Componentes/question-slider.tsx @@ -1,27 +1,25 @@ "use client"; import { useLayoutEffect, useRef, useState } from "react"; -import type { QuestionField } from "@/data/question-data"; +import type { QuestionField } from "@/lib/schema-adapter"; import { useI18n } from "@/translations/provider"; import { useQuestionAnswers } from "./question-answer-storage"; import QuestionTitle from "./question-title"; type QuestionSliderProps = { question: QuestionField; - questionIndex: number; disabled?: boolean; }; export function QuestionSlider({ question, - questionIndex, disabled, }: QuestionSliderProps) { const { dictionary: t } = useI18n(); const [min, max] = question.extras.range; const initialValue = Math.round((min + max) / 2); const { getAnswerValue, setAnswerValue } = useQuestionAnswers(); - const storedValue = getAnswerValue(question, questionIndex); + const storedValue = getAnswerValue(question); const isDesiredAgeRange = false; @@ -115,12 +113,12 @@ export function QuestionSlider({ if (isDesiredAgeRange) { const handleFromChange = (newFrom: number) => { const val = Math.min(newFrom, toVal); - setAnswerValue(question, questionIndex, `${val}-${toVal}`); + setAnswerValue(question, `${val}-${toVal}`); }; const handleToChange = (newTo: number) => { const val = Math.max(newTo, fromVal); - setAnswerValue(question, questionIndex, `${fromVal}-${val}`); + setAnswerValue(question, `${fromVal}-${val}`); }; const progressFrom = @@ -246,9 +244,7 @@ export function QuestionSlider({ value={value} onChange={(event) => setAnswerValue( - question, - questionIndex, - Number(event.target.value), + question, Number(event.target.value), ) } disabled={disabled} diff --git a/src/components/Componentes/question-text.tsx b/src/components/Componentes/question-text.tsx index 1e19ea4..940198a 100644 --- a/src/components/Componentes/question-text.tsx +++ b/src/components/Componentes/question-text.tsx @@ -1,14 +1,13 @@ "use client"; import { useEffect, useRef, useState } from "react"; -import type { QuestionField } from "@/data/question-data"; +import type { QuestionField } from "@/lib/schema-adapter"; import { useI18n } from "@/translations/provider"; import { useQuestionAnswers } from "./question-answer-storage"; import QuestionTitle from "./question-title"; type QuestionTextProps = { question: QuestionField; - questionIndex: number; description?: string; disabled?: boolean; heightClassName?: string; @@ -22,14 +21,13 @@ function toEnglishDigits(str: string): string { export default function QuestionText({ question, - questionIndex, description, disabled, heightClassName: _heightClassName, }: QuestionTextProps) { - const { dictionary: t } = useI18n(); + const { dictionary: t, locale } = useI18n(); const { getAnswerValue, setAnswerValue } = useQuestionAnswers(); - const value = getAnswerValue(question, questionIndex); + const value = getAnswerValue(question); const isMuted = value === "-"; const [localValue, setLocalValue] = useState( @@ -41,9 +39,7 @@ export default function QuestionText({ setLocalValue(isMuted ? "" : String(value ?? "")); }, [value, isMuted]); - const enTitleLower = (question.englishTitle || question.title).toLowerCase(); - const isNumericQuestion = - question.type === "number" || enTitleLower.includes("duration"); + const isNumericQuestion = question.type === "number" || question.validation?.format === "number"; const handleChange = (val: string) => { let nextVal = val; @@ -58,7 +54,7 @@ export default function QuestionText({ } debounceTimerRef.current = setTimeout(() => { - setAnswerValue(question, questionIndex, nextVal); + setAnswerValue(question, nextVal); }, 300); }; @@ -66,13 +62,11 @@ export default function QuestionText({ if (debounceTimerRef.current) { clearTimeout(debounceTimerRef.current); } - setAnswerValue(question, questionIndex, localValue); + setAnswerValue(question, localValue); }; const stringValue = localValue.trim(); - const isEmailQuestion = (question.englishTitle || question.title) - .toLowerCase() - .includes("email"); + const isEmailQuestion = question.type === "email" || question.validation?.format === "email"; const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; const isValidEmail = !isEmailQuestion || emailRegex.test(stringValue); @@ -83,9 +77,7 @@ export default function QuestionText({ ? isValidEmail || stringValue.length === 0 : isValidEmail && stringValue.length > 0; - const isMarjaQuestion = - (question.englishTitle || question.title) === - "Marja' al-Taqlid (Religious Authority)"; + const isMarjaQuestion = question.ui_config?.showNotPriorityCheckbox === true; if (isEmailQuestion) { return ( @@ -119,9 +111,10 @@ export default function QuestionText({
{showInvalidState ? ( - {/[\u0600-\u06FF]/.test(question.title) - ? "یک آدرس ایمیل معتبر وارد کنید." - : "Enter a valid email address."} + {question.validation?.errorMessage || + (locale === "fa" + ? "یک آدرس ایمیل معتبر وارد کنید." + : "Enter a valid email address.")} ) : null} {description ? ( @@ -172,9 +165,9 @@ export default function QuestionText({ clearTimeout(debounceTimerRef.current); } if (e.target.checked) { - setAnswerValue(question, questionIndex, "-"); + setAnswerValue(question, "-"); } else { - setAnswerValue(question, questionIndex, ""); + setAnswerValue(question, ""); } }} className="h-[18px] w-[18px] shrink-0 rounded border-[#D0D5DD] text-[#F2465F] focus:ring-[#F2465F] accent-[#F2465F] cursor-pointer" diff --git a/src/components/Componentes/question-textarea.tsx b/src/components/Componentes/question-textarea.tsx index 0fc0dc0..3302e36 100644 --- a/src/components/Componentes/question-textarea.tsx +++ b/src/components/Componentes/question-textarea.tsx @@ -1,27 +1,25 @@ "use client"; import { useEffect, useRef, useState } from "react"; -import type { QuestionField } from "@/data/question-data"; +import type { QuestionField } from "@/lib/schema-adapter"; import { useI18n } from "@/translations/provider"; import { useQuestionAnswers } from "./question-answer-storage"; import QuestionTitle from "./question-title"; type QuestionTextareaProps = { question: QuestionField; - questionIndex: number; description?: string; disabled?: boolean; }; export function QuestionTextarea({ question, - questionIndex, description, disabled, }: QuestionTextareaProps) { const { dictionary: t } = useI18n(); const { getAnswerValue, setAnswerValue } = useQuestionAnswers(); - const value = getAnswerValue(question, questionIndex); + const value = getAnswerValue(question); const [localValue, setLocalValue] = useState(String(value ?? "")); const debounceTimerRef = useRef(null); @@ -39,7 +37,7 @@ export function QuestionTextarea({ } debounceTimerRef.current = setTimeout(() => { - setAnswerValue(question, questionIndex, val); + setAnswerValue(question, val); }, 300); }; @@ -47,7 +45,7 @@ export function QuestionTextarea({ if (debounceTimerRef.current) { clearTimeout(debounceTimerRef.current); } - setAnswerValue(question, questionIndex, localValue); + setAnswerValue(question, localValue); }; const stringValue = localValue.trim(); diff --git a/src/components/Componentes/question-title.tsx b/src/components/Componentes/question-title.tsx index 2ad40b5..a157a86 100644 --- a/src/components/Componentes/question-title.tsx +++ b/src/components/Componentes/question-title.tsx @@ -3,9 +3,8 @@ import { useState } from "react"; import { IoEyeOff } from "react-icons/io5"; import HelpModal from "./help-modal"; -import type { QuestionField } from "@/data/question-data"; +import type { QuestionField } from "@/lib/schema-adapter"; import { useI18n } from "@/translations/provider"; -import pathMap from "@/translations/path_to_english.json"; type QuestionTitleProps = { question: QuestionField; @@ -16,32 +15,18 @@ export function QuestionTitle({ question, className }: QuestionTitleProps) { const { dictionary: t, locale } = useI18n(); const [isHelpOpen, setIsHelpOpen] = useState(false); - const isMonthlyIncome = - question.title === "Monthly Income" || - question.title === "میزان درآمد ماهانه"; + let suffix = ""; + if (question.ui_config?.suffix) { + suffix = ` ${question.ui_config.suffix}`; + } else if (question.ui_config?.suffix_i18n) { + suffix = ` ${question.ui_config.suffix_i18n[locale] || question.ui_config.suffix_i18n.en || ""}`; + } else if (question.ui_config?.showApproximateSuffix) { + suffix = locale === "fa" ? " (تقریبی)" : " (approximately)"; + } else if (question.ui_config?.showYearsSuffix) { + suffix = locale === "fa" ? " (سال)" : " (Years)"; + } - const isPreviousMarriageDuration = - question.title === "Previous Marriage Duration" || - question.title === "مدت ازدواج یا عقد قبلی" || - (question.englishTitle || question.title) - .toLowerCase() - .includes("marriage duration"); - - const alreadyHasUnit = - question.title.includes("Years") || - question.title.includes("سال"); - - const titleText = - question.title + - (isMonthlyIncome - ? locale === "fa" - ? " (تقریبی)" - : " (approximately)" - : isPreviousMarriageDuration && !alreadyHasUnit - ? locale === "fa" - ? " (سال)" - : " (Years)" - : ""); + const titleText = question.title + suffix; const words = titleText.split(" "); const lastWord = words[words.length - 1]; const remainingTitle = words.slice(0, -1).join(" "); @@ -93,15 +78,7 @@ export function QuestionTitle({ question, className }: QuestionTitleProps) { setIsHelpOpen(false)} - description={(() => { - const fullPath = "questions." + question.tooltip; - const englishVal = (pathMap as Record)[ - fullPath - ]; - return englishVal - ? (t as any)[englishVal] || englishVal - : question.tooltip; - })()} + description={question.tooltip} /> ) : null} diff --git a/src/components/Componentes/required-steps-card.tsx b/src/components/Componentes/required-steps-card.tsx index bfea451..c03fc4c 100644 --- a/src/components/Componentes/required-steps-card.tsx +++ b/src/components/Componentes/required-steps-card.tsx @@ -2,16 +2,10 @@ import { useMemo } from "react"; import { IoAlert, IoCheckmark } from "react-icons/io5"; -import { getLocalSectionProgress, getStoredAge } from "./progress-helper"; -import { - getQuestionListItems, - isQuestionListItemVisibleForProfile, - type QuestionListItem, -} from "@/data/question-data"; -import { toFrontendSlug } from "@/data/section-slug-map"; -import { useMarriageProfileQuery } from "@/hooks/marriage/use-profile-main"; -import { useMarriageSectionsQuery } from "@/hooks/marriage/use-sections"; +import { useFormSchemaQuery } from "@/hooks/marriage/use-form-schema"; +import { convertSchemaToFrontendItems } from "@/lib/schema-adapter"; import { useI18n } from "@/translations/provider"; +import type { QuestionListItem } from "@/lib/schema-adapter"; type RequiredStepsCardProps = { items?: QuestionListItem[]; @@ -39,73 +33,19 @@ export default function RequiredStepsCard({ progressBySlug, }: RequiredStepsCardProps = {}) { const { dictionary: t, locale } = useI18n(); - const { data: profile } = useMarriageProfileQuery(); - const { data: sections } = useMarriageSectionsQuery(); + const { data: schema } = useFormSchemaQuery("profile", locale); const questionListItems = useMemo( - () => - items ?? - getQuestionListItems(locale).filter((item) => - isQuestionListItemVisibleForProfile(item, { - gender: profile?.gender, - }), - ), - [items, locale, profile?.gender], + () => items ?? convertSchemaToFrontendItems(schema, locale), + [items, schema, locale], ); const steps: RequiredStep[] = useMemo(() => { - const age = getStoredAge(); - type SectionType = NonNullable[number]; - const sectionMap = new Map(); - sections?.forEach((s) => { - sectionMap.set(toFrontendSlug(s.slug), s); - sectionMap.set(s.slug, s); - }); - return questionListItems.map((item) => { - const localProgress = getLocalSectionProgress(item, profile, age); - const section = sectionMap.get(item.slug); + let progress = item.progress; - let progress = 0; - if (localProgress !== null) { - progress = localProgress; - } else if ( - progressBySlug && - typeof progressBySlug.get(item.slug) === "number" - ) { + if (progressBySlug && typeof progressBySlug.get(item.slug) === "number") { progress = progressBySlug.get(item.slug) ?? 0; - } else if (item.slug === "family_marital_history" && sections) { - const fbSec = sections.find((s) => s.slug === "family_background"); - const mhSec = sections.find((s) => s.slug === "marital_history"); - if (fbSec || mhSec) { - const fbTotal = fbSec?.total_steps ?? 6; - const fbCurrent = fbSec - ? Math.round((fbSec.completion_percent / 100) * fbTotal) - : 0; - const mhTotal = mhSec?.total_steps ?? 6; - const mhCurrent = mhSec - ? Math.round((mhSec.completion_percent / 100) * mhTotal) - : 0; - progress = - fbTotal + mhTotal > 0 - ? Math.max( - 0, - Math.min( - 100, - Math.round( - ((fbCurrent + mhCurrent) / (fbTotal + mhTotal)) * 100, - ), - ), - ) - : 0; - } - } else if (section) { - progress = Math.max( - 0, - Math.min(100, Math.round(section.completion_percent)), - ); - } else { - progress = item.progress; } return { @@ -114,7 +54,7 @@ export default function RequiredStepsCard({ progress, }; }); - }, [questionListItems, progressBySlug, sections, profile]); + }, [questionListItems, progressBySlug]); const { completed, total } = getRequiredStepStats(steps); const completion = total > 0 ? Math.round((completed / total) * 100) : 0; diff --git a/src/components/Componentes/schema-question-flow.integration.test.tsx b/src/components/Componentes/schema-question-flow.integration.test.tsx new file mode 100644 index 0000000..fd9b3bb --- /dev/null +++ b/src/components/Componentes/schema-question-flow.integration.test.tsx @@ -0,0 +1,235 @@ +import React from "react"; +import { render, screen, fireEvent, waitFor, cleanup } from "@testing-library/react"; +import { describe, it, expect, afterEach, beforeEach, vi } from "vitest"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { QuestionAnswersProvider, useQuestionAnswers } from "./question-answer-storage"; +import { convertSchemaToFrontendItems } from "@/lib/schema-adapter"; +import { useFormSchemaQuery } from "@/hooks/marriage/use-form-schema"; +import { useMarriageProfileQuery } from "@/hooks/marriage/use-profile-main"; +import { useMarriageSectionDataQuery, useUpdateMarriageSectionDataMutation } from "@/hooks/marriage/use-section-data"; +import { QuestionRadio } from "./question-radio"; +import { QuestionCheckbox } from "./question-checkbox"; + +import QuestionDetailClient from "@/app/questions-list/[slug]/question-detail-client"; + +vi.mock("@/hooks/marriage/use-form-schema", () => ({ + useFormSchemaQuery: vi.fn(), +})); +vi.mock("@/hooks/marriage/use-profile-main", () => ({ + useMarriageProfileQuery: vi.fn(), +})); +vi.mock("@/hooks/marriage/use-section-data", () => ({ + useMarriageSectionDataQuery: vi.fn(), + useUpdateMarriageSectionDataMutation: vi.fn(), +})); + +export const replaceMock = vi.fn(); +vi.mock("next/navigation", () => ({ + useRouter: () => ({ + push: vi.fn(), + replace: (...args: any[]) => replaceMock(...args), + back: vi.fn(), + }), +})); + +const mockSchema = { + form_id: "profile", + version: 1, + answers: { + "q_radio": { value: "A", option_id: "opt_a" }, + "q_check": { value: ["B", "C"], option_id: ["opt_b", "opt_c"] } + }, + sections: [ + { + id: "sec1", + title: "Section", + order: 1, + icon: "user-circle", + is_required: true, + estimated_minutes: 5, + cards: [ + { + id: "card1", + title: "Card", + order: 2, // Messed up order + questions: [ + { + id: "q_child", title: "Child Q", type: "text", order: 20, + required: false, is_required: true, is_visible: false, + ui_config: {}, options: [] + }, + { + id: "q_radio", title: "Radio Q", type: "radio", order: 5, + required: true, is_required: true, is_visible: true, + ui_config: {}, + options: [ + { id: "opt_a_dummy", value: "A_DUMMY", label: "Option A Dummy", order: 2 }, + { id: "opt_a", value: "A", label: "Option A Canonical", order: 1 } + ] + }, + { + id: "q_check", title: "Check Q", type: "checkbox", order: 10, + required: false, is_required: false, is_visible: true, + ui_config: {}, + options: [ + { id: "opt_c", value: "C", label: "Option C", order: 2 }, + { id: "opt_b", value: "B", label: "Option B", order: 1 } + ] + } + ] + }, + { + id: "card2", + title: "Card 2", + order: 1, // Card 2 should come first + questions: [ + { id: "q_first", title: "First Q", type: "text", order: 1, required: true, is_visible: true, ui_config: {}, options: [] }, + ] + } + ] + } + ], + progress: { sections_progress: {} } +}; + +describe("Schema Question Flow Integration", () => { + let capturedPayload: any = null; + + beforeEach(() => { + capturedPayload = null; + const updateMutateAsync = vi.fn(async (payload) => { + capturedPayload = payload; + return payload; + }); + (useUpdateMarriageSectionDataMutation as any).mockReturnValue({ + mutateAsync: updateMutateAsync, + isPending: false, + }); + (useMarriageProfileQuery as any).mockReturnValue({ + data: { can_edit_profile: true }, + }); + (useMarriageSectionDataQuery as any).mockReturnValue({ + data: { + slug: "sec1", + data: [ + { key: "q_radio", type: "radio", value: "A", option_id: "opt_a" }, + { key: "q_check", type: "checkbox", value: ["B", "C"], option_id: ["opt_b", "opt_c"] } + ] + }, + isLoading: false, + }); + (useFormSchemaQuery as any).mockReturnValue({ + data: mockSchema, + isLoading: false, + isFetching: false, + error: null, + refetch: vi.fn(), + }); + }); + + afterEach(() => { + cleanup(); + }); + + it("should enforce ordering, correctly hydrate canonical values via options, and hide invisible children", async () => { + const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + + // Use convertSchemaToFrontendItems directly (no mock) + const frontendItems = convertSchemaToFrontendItems(mockSchema, "en"); + + // Sort logic validation + expect(frontendItems[0].questions[0].id).toBe("q_first"); + expect(frontendItems[0].questions[1].id).toBe("q_radio"); + expect(frontendItems[0].questions[2].id).toBe("q_check"); + expect(frontendItems[0].questions[3].id).toBe("q_child"); + + // Validate options sort + expect(frontendItems[0].questions[1].options[0].id).toBe("opt_a"); + + render( + + + + ); + + // 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( + + + + ); + + // Should redirect to questions list + expect(replaceMock).toHaveBeenCalledWith("/list"); + // Static text should not exist + expect(screen.queryByText("First Q")).toBeNull(); + }); +}); diff --git a/src/components/Componentes/slider-page.test.tsx b/src/components/Componentes/slider-page.test.tsx new file mode 100644 index 0000000..8b8b317 --- /dev/null +++ b/src/components/Componentes/slider-page.test.tsx @@ -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( + + + + ); + 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(); + }); +}); diff --git a/src/components/Componentes/slider-page.tsx b/src/components/Componentes/slider-page.tsx index 90272e7..888dc6d 100644 --- a/src/components/Componentes/slider-page.tsx +++ b/src/components/Componentes/slider-page.tsx @@ -3,6 +3,7 @@ import { useRouter } from "next/navigation"; import type { TouchEvent } from "react"; import { useEffect, useState } from "react"; +import { useQueryClient } from "@tanstack/react-query"; import { useUpdateMarriageProfileBasicMutation } from "@/hooks/marriage/use-profile-basic"; import { localizePath } from "@/translations/config"; import { useI18n } from "@/translations/provider"; @@ -29,6 +30,10 @@ export default function SliderPage() { const [hasReadRules, setHasReadRules] = useState(false); const [touchStartX, setTouchStartX] = useState(null); const updateProfileBasicMutation = useUpdateMarriageProfileBasicMutation(); + const queryClient = useQueryClient(); + const [submitError, setSubmitError] = useState(null); + const [isSubmitting, setIsSubmitting] = useState(false); + const { dictionary: t } = useI18n(); const hasFinalNotice = true; const maxSlideIndex = FINAL_SLIDE_COUNT - 1; const displaySlideCount = FINAL_SLIDE_COUNT; @@ -54,36 +59,51 @@ export default function SliderPage() { }; const completeSlider = async () => { - const navigateAway = () => { - if (typeof window !== "undefined") { - window.localStorage.removeItem(SLIDER_ANSWERS_STORAGE_KEY); - // Use hard navigation instead of router.push() to ensure the - // destination page loads with a fresh auth state. SPA (soft) - // navigation sometimes leaves stale query-cache / token state - // that causes the profile query to return 401 and the page to - // stay stuck on the loading spinner. - window.location.href = localizePath( - "/questions-list/personal_info", - locale, - ); - return; - } - router.push(localizePath("/questions-list/personal_info", locale)); - }; + if (isSubmitting) return; + setIsSubmitting(true); + setSubmitError(null); - // Safety timeout — navigate after 5 seconds even if the request hangs - const timeout = setTimeout(navigateAway, 5000); + const genderPayload = selectedGender === "man" ? "male" : "female"; + const isRegisteringPayload = selectedRegistration === "self"; try { - await updateProfileBasicMutation.mutateAsync({ - gender: selectedGender === "man" ? "male" : "female", - is_registering_for_self: selectedRegistration === "self", + const patchResponse = await updateProfileBasicMutation.mutateAsync({ + gender: genderPayload, + is_registering_for_self: isRegisteringPayload, }); + + if ( + patchResponse.status === "pending_onboarding" || + patchResponse.gender !== genderPayload || + patchResponse.is_registering_for_self !== isRegisteringPayload + ) { + throw new Error("Invalid PATCH response"); + } + + const { getMarriageProfile } = await import("@/hooks/marriage/use-profile-main"); + const freshProfile = await getMarriageProfile(); + + if ( + freshProfile.status !== "pending_info" || + freshProfile.gender !== genderPayload || + freshProfile.is_registering_for_self !== isRegisteringPayload + ) { + throw new Error("Invalid GET double-check response"); + } + + const { marriageQueryKeys } = await import("@/hooks/marriage/query-keys"); + queryClient.setQueryData(marriageQueryKeys.profile(), freshProfile); + + if (typeof window !== "undefined") { + window.localStorage.removeItem(SLIDER_ANSWERS_STORAGE_KEY); + } + + router.replace(localizePath("/questions-list/personal_info", locale)); } catch (error) { - console.warn("Failed to update profile basic details:", error); + console.error("Failed to complete onboarding:", error); + setSubmitError(t["Failed to update profile basic details. Please try again."] || "Failed to update profile basic details. Please try again."); } finally { - clearTimeout(timeout); - navigateAway(); + setIsSubmitting(false); } }; @@ -117,7 +137,16 @@ export default function SliderPage() { router.back()} + onClick={() => { + if (typeof window !== "undefined") { + try { + window.sessionStorage.setItem("skip_intro_redirect", "true"); + } catch (e) { + console.warn("sessionStorage is not accessible:", e); + } + } + router.back(); + }} />