From 79e7564ffb31133f5bcc085c9c0a2b3b0a7d0b11 Mon Sep 17 00:00:00 2001 From: mortezaei Date: Sun, 16 Aug 2026 13:20:31 +0330 Subject: [PATCH] feat(marriage): implement form overview API and optimize question loading Refactor the marriage profile and assessment flow to use a new overview endpoint, reducing the need to fetch full question schemas for the initial list view. - Implement `getFormOverview` and `useFormOverviewQuery` to fetch high-level section metadata. - Update `QuestionDetailClient` and `QuestionsListPage` to utilize the overview data for rendering progress and section lists. - Refactor `schema-adapter` to support mapping overview sections to frontend `QuestionListItem` objects. - Optimize proxy performance by adding TTFB metrics and improving encoding support. - Improve UI/UX with better overscroll behavior and viewport settings. - Add unit and integration tests for the new schema adapter and required steps component. --- src/app/api/proxy/route.ts | 64 +++-- src/app/globals.css | 14 +- src/app/layout.tsx | 2 + .../[slug]/question-detail-client.test.tsx | 216 +++++++++------ .../[slug]/question-detail-client.tsx | 117 ++++++-- src/app/questions-list/page.tsx | 260 ++++++++++++++---- src/app/questions-list/sections-request.tsx | 17 +- .../Componentes/question-answer-storage.tsx | 84 +++--- src/components/Componentes/question-card.tsx | 38 ++- .../Componentes/required-steps-card.test.tsx | 31 +++ .../Componentes/required-steps-card.tsx | 86 ++---- .../schema-question-flow.integration.test.tsx | 206 ++++++++++---- src/hooks/marriage/query-keys.ts | 4 + src/hooks/marriage/use-form-schema.ts | 87 +++++- src/hooks/marriage/use-section-data.ts | 127 ++++++--- src/lib/marriage-profile-contract.ts | 1 + src/lib/schema-adapter-overview.test.ts | 39 +++ src/lib/schema-adapter.ts | 66 ++++- 18 files changed, 1064 insertions(+), 395 deletions(-) create mode 100644 src/components/Componentes/required-steps-card.test.tsx create mode 100644 src/lib/marriage-profile-contract.ts create mode 100644 src/lib/schema-adapter-overview.test.ts diff --git a/src/app/api/proxy/route.ts b/src/app/api/proxy/route.ts index 9195d4e..573258b 100644 --- a/src/app/api/proxy/route.ts +++ b/src/app/api/proxy/route.ts @@ -33,10 +33,9 @@ const RESPONSE_HEADERS_TO_DROP = [ ]; const MAX_LOG_BODY_LENGTH = 10_000; -const shouldLogProxy = - process.env.LOG_API_PROXY === "true" || - (process.env.LOG_API_PROXY !== "false" && - process.env.NODE_ENV !== "production"); +const shouldLogProxy = process.env.LOG_API_PROXY === "true"; +const shouldLogProxyTiming = + process.env.LOG_API_PROXY_TIMING === "true" || shouldLogProxy; class ProxyError extends Error { constructor( @@ -131,7 +130,11 @@ function getRequestHeaders(request: NextRequest, targetUrl: URL) { getCookieValue(cookieHeader, "token") ?? getCookieValue(cookieHeader, "auth_token"); - if (cookieToken && cookieToken !== "NO_TOKEN" && cookieToken.trim() !== "") { + if ( + cookieToken && + cookieToken !== "NO_TOKEN" && + cookieToken.trim() !== "" + ) { headers.set("authorization", `Token ${cookieToken}`); } } @@ -154,7 +157,7 @@ function getRequestHeaders(request: NextRequest, targetUrl: URL) { candidate !== null && isLocale(candidate), ) ?? "en"; - headers.set("accept-encoding", "identity"); + headers.set("accept-encoding", "gzip, br"); headers.set("accept-language", lang); headers.set("x-user-language", lang); headers.set("http_x_user_language", lang); @@ -268,9 +271,9 @@ function logProxyRequest( function logProxyResponse( upstreamResponse: Response, - responseBody: ArrayBuffer, + metrics: Record, ) { - if (!shouldLogProxy) { + if (!shouldLogProxyTiming) { return; } @@ -278,19 +281,7 @@ function logProxyResponse( status: upstreamResponse.status, statusText: upstreamResponse.statusText, headers: headersToObject(upstreamResponse.headers), - body: getBodyLogValue( - responseBody, - upstreamResponse.headers.get("content-type") ?? undefined, - ), - response: { - status: upstreamResponse.status, - statusText: upstreamResponse.statusText, - headers: headersToObject(upstreamResponse.headers), - body: getBodyLogValue( - responseBody, - upstreamResponse.headers.get("content-type") ?? undefined, - ), - }, + metrics, }); } @@ -300,6 +291,7 @@ function writeProxyLog(label: string, value: unknown) { async function proxyRequest(request: NextRequest) { try { + const proxyStartedAt = performance.now(); const targetUrl = getTargetUrl(request); const requestBody = request.method === "GET" || request.method === "HEAD" @@ -309,15 +301,14 @@ async function proxyRequest(request: NextRequest) { logProxyRequest(request, targetUrl, requestHeaders, requestBody); + const upstreamStartedAt = performance.now(); const upstreamResponse = await fetch(targetUrl, { method: request.method, headers: requestHeaders, body: requestBody, cache: "no-store", }); - const responseBody = await upstreamResponse.arrayBuffer(); - - logProxyResponse(upstreamResponse, responseBody); + const upstreamTtfb = performance.now() - upstreamStartedAt; const responseHeaders = getResponseHeaders(upstreamResponse.headers); @@ -328,6 +319,31 @@ async function proxyRequest(request: NextRequest) { ); responseHeaders.set("Pragma", "no-cache"); responseHeaders.set("Expires", "0"); + responseHeaders.set( + "Server-Timing", + `upstream-ttfb;dur=${upstreamTtfb.toFixed(1)}`, + ); + responseHeaders.set("X-Proxy-Upstream-TTFB-Ms", upstreamTtfb.toFixed(1)); + + let responseBytes = 0; + const responseBody = + upstreamResponse.body?.pipeThrough( + new TransformStream({ + transform(chunk, controller) { + responseBytes += chunk.byteLength; + controller.enqueue(chunk); + }, + flush() { + const total = performance.now() - proxyStartedAt; + logProxyResponse(upstreamResponse, { + upstreamTtfb: Math.round(upstreamTtfb), + upstreamBody: Math.round(total - upstreamTtfb), + proxyTotal: Math.round(total), + responseBytes, + }); + }, + }), + ) ?? null; return new Response(responseBody, { status: upstreamResponse.status, diff --git a/src/app/globals.css b/src/app/globals.css index a01a36c..9baeee3 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -145,16 +145,22 @@ } html { - min-height: 100%; + height: 100%; + overscroll-behavior: none; + overscroll-behavior-y: none; } body { - min-height: 100vh; + min-height: 100%; margin: 0; display: flex; justify-content: center; color: var(--foreground); font-family: "Segoe UI", Tahoma, Geneva, Verdana, sans-serif; + overscroll-behavior: none; + overscroll-behavior-y: none; + -webkit-overflow-scrolling: touch; + touch-action: pan-y; } html:lang(ar) body, @@ -180,7 +186,7 @@ html:lang(ar) body, .app-shell { width: 100%; - min-height: 100vh; + min-height: 100%; padding-inline: 17px; padding-bottom: var(--safe-bottom, 0px); box-sizing: border-box; @@ -189,6 +195,8 @@ html:lang(ar) body, background-position: top; background-repeat: no-repeat; background-size: cover; + overscroll-behavior: none; + overscroll-behavior-y: none; } html[data-web-bootstrap="pending"] .app-shell { diff --git a/src/app/layout.tsx b/src/app/layout.tsx index 2fe0330..199c673 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -44,6 +44,8 @@ export const viewport: Viewport = { width: "device-width", initialScale: 1, maximumScale: 1, + userScalable: false, + viewportFit: "cover", themeColor: "#ffffff", }; diff --git a/src/app/questions-list/[slug]/question-detail-client.test.tsx b/src/app/questions-list/[slug]/question-detail-client.test.tsx index 9ffc4d9..8ac165d 100644 --- a/src/app/questions-list/[slug]/question-detail-client.test.tsx +++ b/src/app/questions-list/[slug]/question-detail-client.test.tsx @@ -1,11 +1,23 @@ -import { render, screen, fireEvent, waitFor, cleanup } from "@testing-library/react"; +import { + render, + screen, + fireEvent, + waitFor, + cleanup, +} from "@testing-library/react"; import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; import QuestionDetailClient from "./question-detail-client"; import { useCattellQuestionsQuery } from "@/hooks/marriage/use-cattell"; import { useGlasserQuestionsQuery } from "@/hooks/marriage/use-glasser"; import { useMarriageProfileQuery } from "@/hooks/marriage/use-profile-main"; -import { useFormSchemaQuery } from "@/hooks/marriage/use-form-schema"; -import { convertSchemaToFrontendItems } from "@/lib/schema-adapter"; +import { + useFormOverviewQuery, + useFormSectionQuery, +} from "@/hooks/marriage/use-form-schema"; +import { + convertOverviewToFrontendItems, + mapBackendSectionToFrontend, +} from "@/lib/schema-adapter"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; vi.mock("next/navigation", () => ({ @@ -13,7 +25,10 @@ vi.mock("next/navigation", () => ({ })); vi.mock("@/translations/provider", () => ({ - useI18n: vi.fn(() => ({ locale: "en", dictionary: new Proxy({}, { get: (_, key) => key }) })), + useI18n: vi.fn(() => ({ + locale: "en", + dictionary: new Proxy({}, { get: (_, key) => key }), + })), })); vi.mock("@/hooks/marriage/use-cattell", () => ({ @@ -31,7 +46,8 @@ vi.mock("@/hooks/marriage/use-profile-main", () => ({ })); vi.mock("@/hooks/marriage/use-form-schema", () => ({ - useFormSchemaQuery: vi.fn(), + useFormOverviewQuery: vi.fn(), + useFormSectionQuery: vi.fn(), })); vi.mock("@/hooks/marriage/use-habcoin-payment", () => ({ @@ -39,7 +55,8 @@ vi.mock("@/hooks/marriage/use-habcoin-payment", () => ({ })); vi.mock("@/lib/schema-adapter", () => ({ - convertSchemaToFrontendItems: vi.fn(), + convertOverviewToFrontendItems: vi.fn(), + mapBackendSectionToFrontend: vi.fn(), })); describe("QuestionDetailClient Validation", () => { @@ -57,21 +74,25 @@ describe("QuestionDetailClient Validation", () => { data: { age: 30, gender: "male" }, isLoading: false, }); - - (useFormSchemaQuery as any).mockReturnValue({ - data: {}, + + (useFormOverviewQuery as any).mockReturnValue({ + data: { version: 1 }, + isLoading: false, + }); + (useFormSectionQuery as any).mockReturnValue({ + data: undefined, 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([ + (convertOverviewToFrontendItems as any).mockReturnValue([ { slug: slug, title: "Test", - questions: [] - } + questions: [], + }, ]); (useCattellQuestionsQuery as any).mockReturnValue({ @@ -107,9 +128,9 @@ describe("QuestionDetailClient Validation", () => { questionsListHref="/questions-list" title="Test" /> - + , ); - + // Intro screen might render first; if Start is available, click it to mount questions flow const startButton = screen.queryByText("Start"); if (startButton) { @@ -118,20 +139,24 @@ describe("QuestionDetailClient Validation", () => { }; 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); - + 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 @@ -140,25 +165,33 @@ describe("QuestionDetailClient Validation", () => { 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("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(); + 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(); @@ -179,10 +212,10 @@ describe("QuestionDetailClient Validation", () => { { id: "o4", label: "O4", value: 4 }, { id: "o5", label: "O5", value: 5 }, ], - } - ] + }, + ], }); - + expect(screen.queryByText("Retry")).toBeNull(); expect(screen.getByText("Valid Question Glasser")).toBeDefined(); }); @@ -200,12 +233,14 @@ describe("QuestionDetailClient Validation", () => { { label: "O3", value: 3 }, { label: "O4", value: 4 }, ], - } - ] + }, + ], }); - expect(screen.getAllByText("No questions found for this test.")).toBeDefined(); + expect( + screen.getAllByText("No questions found for this test."), + ).toBeDefined(); const retryBtn = screen.getAllByText("Retry")[0]; - + fireEvent.click(retryBtn); await waitFor(() => { expect(mockGlasserRefetch).toHaveBeenCalled(); @@ -213,43 +248,58 @@ describe("QuestionDetailClient Validation", () => { }); it("should render profile questions using ID-based data flow", () => { - (convertSchemaToFrontendItems as any).mockReturnValue([ + const profileItem = { + 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 }, + ], + }, + ], + }; + (convertOverviewToFrontendItems 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 } - ] - } - ] - } + questions: [], + }, ]); + (useFormSectionQuery as any).mockReturnValue({ + data: { + version: 1, + section: { cards: [] }, + answers: {}, + section_progress: { completion_percent: 0 }, + }, + isLoading: false, + }); + (mapBackendSectionToFrontend as any).mockReturnValue(profileItem); const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } }, @@ -266,7 +316,7 @@ describe("QuestionDetailClient Validation", () => { questionsListHref="/questions-list" title="Profile Test" /> - + , ); // Profile questions render directly, no start button diff --git a/src/app/questions-list/[slug]/question-detail-client.tsx b/src/app/questions-list/[slug]/question-detail-client.tsx index 6dc0a30..f9c924e 100644 --- a/src/app/questions-list/[slug]/question-detail-client.tsx +++ b/src/app/questions-list/[slug]/question-detail-client.tsx @@ -19,15 +19,26 @@ import TestQuestionsFlow, { type TestQuestion, } from "@/components/Componentes/test-questions-flow"; import { + getGlasserQuestions, useGlasserQuestionsQuery, useSubmitGlasserAssessmentMutation, } from "@/hooks/marriage/use-glasser"; import { + getCattellQuestions, useCattellQuestionsQuery, useSubmitCattellAssessmentMutation, } from "@/hooks/marriage/use-cattell"; -import { useFormSchemaQuery } from "@/hooks/marriage/use-form-schema"; -import { convertSchemaToFrontendItems, type QuestionField } from "@/lib/schema-adapter"; +import { useQueryClient } from "@tanstack/react-query"; +import { marriageQueryKeys } from "@/hooks/marriage/query-keys"; +import { + useFormOverviewQuery, + useFormSectionQuery, +} from "@/hooks/marriage/use-form-schema"; +import { + convertOverviewToFrontendItems, + mapBackendSectionToFrontend, + type QuestionField, +} from "@/lib/schema-adapter"; import { defaultLocale, type Locale } from "@/translations/config"; import { useI18n } from "@/translations/provider"; @@ -102,7 +113,9 @@ function QuestionFlowWrapper({ let isAnswered = hasAnswer; if (hasAnswer) { - const isEmailQuestion = question.type === "email" || question.validation?.format === "email"; + const isEmailQuestion = + question.type === "email" || + question.validation?.format === "email"; if (isEmailQuestion) { const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; isAnswered = emailRegex.test(String(answer).trim()); @@ -126,10 +139,7 @@ function QuestionFlowWrapper({ data-question-disabled="false" data-question-answered={String(isAnswered)} > - + ); })} @@ -151,6 +161,7 @@ export default function QuestionDetailClient({ const { dictionary: t } = useI18n(); const [isTestStarted, setIsTestStarted] = useState(false); const [hasTestProgress, setHasTestProgress] = useState(false); + const queryClient = useQueryClient(); useEffect(() => { if (typeof window !== "undefined") { @@ -174,12 +185,34 @@ export default function QuestionDetailClient({ } }, [itemSlug, isTestStarted]); - 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"; + const isAssessment = isCattellSlug || isGlasserSlug; + const { data: overview, isLoading: isOverviewLoading } = useFormOverviewQuery( + "profile", + locale, + ); + const { data: sectionResponse, isLoading: isSectionLoading } = + useFormSectionQuery( + "profile", + itemSlug, + locale, + Boolean(itemSlug) && !isAssessment, + ); + const items = useMemo( + () => convertOverviewToFrontendItems(overview), + [overview], + ); + const overviewItem = items.find((candidate) => candidate.slug === itemSlug); + const item = useMemo(() => { + if (!overviewItem || isAssessment || !sectionResponse) return overviewItem; + return mapBackendSectionToFrontend( + sectionResponse.section, + sectionResponse.section_progress.completion_percent, + ); + }, [isAssessment, overviewItem, sectionResponse]); + const isSchemaLoading = + isOverviewLoading || (!isAssessment && isSectionLoading); const cattellQuery = useCattellQuestionsQuery(locale, { enabled: isCattellSlug && isTestStarted, @@ -193,15 +226,39 @@ export default function QuestionDetailClient({ }); const submitGlasserMutation = useSubmitGlasserAssessmentMutation(); + useEffect(() => { + if (!isAssessment || isTestStarted) return; + if (isCattellSlug) { + void queryClient.prefetchQuery({ + queryKey: marriageQueryKeys.cattellQuestions(locale), + queryFn: () => getCattellQuestions(locale), + staleTime: 30 * 1000, + }); + } else if (isGlasserSlug) { + void queryClient.prefetchQuery({ + queryKey: marriageQueryKeys.glasserQuestions(locale), + queryFn: () => getGlasserQuestions(locale), + staleTime: 30 * 1000, + }); + } + }, [ + isAssessment, + isCattellSlug, + isGlasserSlug, + isTestStarted, + locale, + queryClient, + ]); + const cattellTestQuestions: TestQuestion[] = useMemo(() => { 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 && + 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)) { @@ -218,14 +275,21 @@ export default function QuestionDetailClient({ const glasserTestQuestions: TestQuestion[] = useMemo(() => { 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); + 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"); @@ -572,7 +636,8 @@ export default function QuestionDetailClient({ } const dobQuestion = visibleQuestions.find( - (question) => question.ui_config?.isDob === true || question.type === "date", + (question) => + question.ui_config?.isDob === true || question.type === "date", ); return ( @@ -583,7 +648,7 @@ export default function QuestionDetailClient({ slug={item.slug} questions={visibleQuestions} locale={locale} - schemaVersion={schema?.version ?? 1} + schemaVersion={sectionResponse?.version ?? overview?.version ?? 1} >
diff --git a/src/app/questions-list/page.tsx b/src/app/questions-list/page.tsx index 6d958c4..84cb682 100644 --- a/src/app/questions-list/page.tsx +++ b/src/app/questions-list/page.tsx @@ -2,7 +2,7 @@ import Image from "next/image"; import { useRouter } from "next/navigation"; -import { useCallback, useEffect, useMemo, useState } from "react"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { IoClose } from "react-icons/io5"; import { getSubmitPath, @@ -15,14 +15,16 @@ 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 { useQueryClient } from "@tanstack/react-query"; import { useStartMarriageMatchMutation } from "@/hooks/marriage/use-match-start"; import { useMarriageProfileQuery } from "@/hooks/marriage/use-profile-main"; -import { useFormSchemaQuery } from "@/hooks/marriage/use-form-schema"; -import { convertSchemaToFrontendItems } from "@/lib/schema-adapter"; +import { + getFormSection, + useFormOverviewQuery, +} from "@/hooks/marriage/use-form-schema"; +import { convertOverviewToFrontendItems } from "@/lib/schema-adapter"; import type { QuestionListItem } from "@/lib/schema-adapter"; import { useCloseServiceOnBack } from "@/hooks/use-close-service-on-back"; import { @@ -33,6 +35,7 @@ import { localizePath } from "@/translations/config"; import { useI18n } from "@/translations/provider"; import SectionsRequest from "./sections-request"; import { getAssessmentLocalProgress } from "@/lib/assessment-progress"; +import { REQUIRED_PROFILE_SECTION_COUNT } from "@/lib/marriage-profile-contract"; export default function QuestionsListPage() { useCloseServiceOnBack(); @@ -41,8 +44,10 @@ export default function QuestionsListPage() { const queryClient = useQueryClient(); const { data: profile, isLoading: isProfileLoading } = useMarriageProfileQuery(); - const { data: schema, isLoading: isSchemaLoading } = - useFormSchemaQuery("profile", locale); + const { data: overview, isLoading: isSchemaLoading } = useFormOverviewQuery( + "profile", + locale, + ); const isSectionsLoading = false; @@ -76,10 +81,12 @@ export default function QuestionsListPage() { const [selectedSection, setSelectedSection] = useState(null); const questionListItems = useMemo( - () => convertSchemaToFrontendItems(schema, locale), - [schema, locale], + () => convertOverviewToFrontendItems(overview), + [overview], ); - const [localAssessmentProgress, setLocalAssessmentProgress] = useState>(new Map()); + const [localAssessmentProgress, setLocalAssessmentProgress] = useState< + Map + >(new Map()); useEffect(() => { const next = new Map(); @@ -94,20 +101,26 @@ export default function QuestionsListPage() { } } setLocalAssessmentProgress(next); - }, [schema]); + }, [overview]); const sectionProgressBySlug = useMemo(() => { const progressBySlug = new Map(); - 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)))); - }); + if (overview?.progress?.sections_progress) { + Object.entries(overview.progress.sections_progress).forEach( + ([slug, prog]) => { + progressBySlug.set( + slug, + Math.max(0, Math.min(100, Math.round(prog.completion_percent))), + ); + }, + ); } localAssessmentProgress.forEach((progress, slug) => { - if ((progressBySlug.get(slug) ?? 0) < 100) progressBySlug.set(slug, progress); + if ((progressBySlug.get(slug) ?? 0) < 100) + progressBySlug.set(slug, progress); }); - + // Add fallback for combined section from the schema adapter which attaches it to questionListItems directly questionListItems.forEach((item) => { if (!progressBySlug.has(item.slug)) { @@ -116,15 +129,56 @@ export default function QuestionsListPage() { }); return progressBySlug; - }, [schema, questionListItems, localAssessmentProgress]); + }, [overview, questionListItems, localAssessmentProgress]); const requiredQuestionListItems = useMemo( () => questionListItems.filter((item) => Boolean(item.required)), [questionListItems], ); + const completedRequiredSections = useMemo( + () => + requiredQuestionListItems.filter( + (item) => (sectionProgressBySlug.get(item.slug) ?? 0) >= 100, + ).length, + [requiredQuestionListItems, sectionProgressBySlug], + ); + const [displayedRequiredSections, setDisplayedRequiredSections] = useState( + () => completedRequiredSections, + ); + + useEffect(() => { + if (displayedRequiredSections === completedRequiredSections) return; + if (window.matchMedia("(prefers-reduced-motion: reduce)").matches) { + setDisplayedRequiredSections(completedRequiredSections); + return; + } + const direction = completedRequiredSections > displayedRequiredSections ? 1 : -1; + const distance = Math.abs(completedRequiredSections - displayedRequiredSections); + const interval = window.setInterval(() => { + setDisplayedRequiredSections((current) => { + const next = current + direction; + if (next === completedRequiredSections) window.clearInterval(interval); + return next; + }); + }, Math.max(90, Math.floor(500 / distance))); + return () => window.clearInterval(interval); + }, [completedRequiredSections, displayedRequiredSections]); + + const hasValidRequiredContract = + requiredQuestionListItems.length === REQUIRED_PROFILE_SECTION_COUNT; + + useEffect(() => { + if (overview && !hasValidRequiredContract) { + console.error("Required section contract mismatch", { + expected: REQUIRED_PROFILE_SECTION_COUNT, + actual: requiredQuestionListItems.length, + }); + } + }, [hasValidRequiredContract, overview, requiredQuestionListItems.length]); + const allRequiredSectionsCompleted = useMemo(() => { - if (requiredQuestionListItems.length === 0) { + if (!hasValidRequiredContract) { return false; } @@ -132,7 +186,7 @@ export default function QuestionsListPage() { const progress = sectionProgressBySlug.get(item.slug) ?? 0; return progress >= 100; }); - }, [requiredQuestionListItems, sectionProgressBySlug]); + }, [hasValidRequiredContract, requiredQuestionListItems, sectionProgressBySlug]); const profileStatus = profile?.status; const isProfileSuspended = profileStatus === "suspended"; @@ -145,19 +199,20 @@ export default function QuestionsListPage() { const [toastMessage, setToastMessage] = useState(null); const syncPendingAnswers = useCallback(async () => { - if (!schema) return; + if (!overview) return; const { updateMarriageSectionData } = await import( "@/hooks/marriage/use-section-data" ); - let currentVersion = schema.version; + let currentVersion = overview.version; for (const item of questionListItems) { const storageKey = getQuestionAnswersStorageKey(item.slug); const rawValue = window.localStorage.getItem(storageKey); if (!rawValue) continue; const storedValue = JSON.parse(rawValue); - if (!storedValue.pending_sync || !Array.isArray(storedValue.fields)) continue; + if (!storedValue.pending_sync || !Array.isArray(storedValue.fields)) + continue; const pendingKeys = new Set( Array.isArray(storedValue.pending_keys) ? storedValue.pending_keys @@ -170,11 +225,7 @@ export default function QuestionsListPage() { current_step: storedValue.current_step, fields: [field], }); - queryClient.setQueryData( - ["marriage", "form-schema", "profile", locale], - result.schema, - ); - currentVersion = result.schema.version; + currentVersion = result.version; pendingKeys.delete(field.key); storedValue.pending_keys = [...pendingKeys]; window.localStorage.setItem(storageKey, JSON.stringify(storedValue)); @@ -182,7 +233,109 @@ export default function QuestionsListPage() { storedValue.pending_sync = false; window.localStorage.setItem(storageKey, JSON.stringify(storedValue)); } - }, [locale, queryClient, questionListItems, schema]); + }, [overview, questionListItems]); + + const prefetchSection = useCallback( + (item: QuestionListItem) => { + const href = localizePath(`/questions-list/${item.slug}`, locale); + router.prefetch(href); + if ( + item.slug === "personality_test" || + item.slug === "glasser_5_needs_test" + ) + return; + void queryClient.prefetchQuery({ + queryKey: ["marriage", "form-section", "profile", item.slug, locale], + queryFn: () => getFormSection("profile", item.slug, locale), + staleTime: 30 * 1000, + }); + }, + [locale, queryClient, router], + ); + + const viewportPrefetchChain = useRef(Promise.resolve()); + const viewportPrefetchSlugs = useRef(new Set()); + const enqueueViewportPrefetch = useCallback( + (item: QuestionListItem) => { + if ( + item.slug === "personality_test" || + item.slug === "glasser_5_needs_test" || + viewportPrefetchSlugs.current.has(item.slug) + ) { + return; + } + viewportPrefetchSlugs.current.add(item.slug); + viewportPrefetchChain.current = viewportPrefetchChain.current + .catch(() => undefined) + .then(() => + queryClient.fetchQuery({ + queryKey: [ + "marriage", + "form-section", + "profile", + item.slug, + locale, + ], + queryFn: () => getFormSection("profile", item.slug, locale), + staleTime: 30 * 1000, + }), + ) + .then(() => undefined); + }, + [locale, queryClient], + ); + + const prefetchQueueStarted = useRef(false); + useEffect(() => { + if (prefetchQueueStarted.current || !overview) return; + const likelySections = questionListItems + .filter( + (item) => + item.required && + (sectionProgressBySlug.get(item.slug) ?? item.progress) < 100, + ) + .slice(0, 2); + if (likelySections.length === 0) return; + prefetchQueueStarted.current = true; + let cancelled = false; + const runQueue = async () => { + for (const item of likelySections) { + if (cancelled) return; + try { + await queryClient.fetchQuery({ + queryKey: [ + "marriage", + "form-section", + "profile", + item.slug, + locale, + ], + queryFn: () => getFormSection("profile", item.slug, locale), + staleTime: 30 * 1000, + }); + } catch { + // A later interaction or navigation can retry without blocking the list. + } + } + }; + const schedule = () => void runQueue(); + const idleWindow = window as Window & { + requestIdleCallback?: ( + callback: () => void, + options?: { timeout: number }, + ) => number; + cancelIdleCallback?: (handle: number) => void; + }; + const isIdleScheduled = Boolean(idleWindow.requestIdleCallback); + const handle = idleWindow.requestIdleCallback + ? idleWindow.requestIdleCallback(schedule, { timeout: 750 }) + : globalThis.setTimeout(schedule, 150); + return () => { + cancelled = true; + if (isIdleScheduled) idleWindow.cancelIdleCallback?.(handle as number); + else globalThis.clearTimeout(handle); + }; + }, [locale, overview, queryClient, questionListItems, sectionProgressBySlug]); useEffect(() => { void syncPendingAnswers().catch(() => setIsSyncError(true)); @@ -289,37 +442,38 @@ export default function QuestionsListPage() {
- {/* Required Steps Card Skeleton (Hosseinieh Card Style) */} -
-
-
- - -
-
- - -
-
-
-
-
-
+ {/* Section Card Skeletons (solid blocks like the Meet/checkup AppShimmer loading — one sweep band runs across each card) */}
{Array.from({ length: 6 }).map((_, idx) => ( -
+
))}
- +
@@ -395,7 +549,7 @@ export default function QuestionsListPage() { className="text-left" /> ) : null} - +
@@ -448,6 +602,8 @@ export default function QuestionsListPage() { item={item} progress={sectionProgressBySlug.get(item.slug) ?? null} onInfoClick={(section) => setSelectedSection(section)} + onNearViewport={enqueueViewportPrefetch} + onPrefetch={prefetchSection} /> ))} diff --git a/src/app/questions-list/sections-request.tsx b/src/app/questions-list/sections-request.tsx index 22fe017..1008803 100644 --- a/src/app/questions-list/sections-request.tsx +++ b/src/app/questions-list/sections-request.tsx @@ -4,8 +4,7 @@ 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 { useMarriageSectionsQuery } from "@/hooks/marriage/use-sections"; -import { useI18n } from "@/translations/provider"; +import type { FormOverviewSection } from "@/hooks/marriage/use-form-schema"; const bookingTerms = [ "All provided information is held in strict confidence.", @@ -24,9 +23,11 @@ const FIRST_ENTRY_TERMS = [ 'Do you operate based on superficial behavioral adaptations, or are you aware of the deep "source traits" that fundamentally control your decision-making processes?', ] as const; -export default function SectionsRequest() { - const { locale } = useI18n(); - const { data: sections, isSuccess } = useMarriageSectionsQuery(locale); +export default function SectionsRequest({ + sections, +}: { + sections: FormOverviewSection[] | undefined; +}) { const [hasSeenSheet, setHasSeenSheet] = useState(true); const hasNoProgression = useMemo(() => { @@ -35,11 +36,13 @@ export default function SectionsRequest() { } return sections.every( - (section) => section.current_step <= 0 && section.completion_percent <= 0, + (section) => + section.progress.current_step <= 0 && + section.progress.completion_percent <= 0, ); }, [sections]); - const isOpen = isSuccess && hasNoProgression && !hasSeenSheet; + const isOpen = Boolean(sections) && hasNoProgression && !hasSeenSheet; useEffect(() => { try { diff --git a/src/components/Componentes/question-answer-storage.tsx b/src/components/Componentes/question-answer-storage.tsx index ac58692..dc0863a 100644 --- a/src/components/Componentes/question-answer-storage.tsx +++ b/src/components/Componentes/question-answer-storage.tsx @@ -47,16 +47,11 @@ type FlushAnswersOptions = { type QuestionAnswersContextValue = { flushAnswers: (options?: FlushAnswersOptions) => Promise; - getAnswerValue: ( - question: QuestionField, - ) => MarriageFieldValue | undefined; + getAnswerValue: (question: QuestionField) => MarriageFieldValue | undefined; hasPendingSync: boolean; isSaving: boolean; isLoading: boolean; - setAnswerValue: ( - question: QuestionField, - value: MarriageFieldValue, - ) => void; + setAnswerValue: (question: QuestionField, value: MarriageFieldValue) => void; backendFields: MarriageField[]; }; @@ -122,14 +117,12 @@ function isMarriagePhoneFieldValue( ); } - - function createQuestionField( question: QuestionField, value: MarriageFieldValue, ): MarriageField { let option_id = undefined; - + if (question.options && Array.isArray(question.options)) { if (question.type === "checkbox" && Array.isArray(value)) { option_id = value; @@ -142,7 +135,7 @@ function createQuestionField( } const key = question.id; - + return { key, label: question.title, @@ -201,9 +194,7 @@ function createPayload( backendFields?: MarriageField[], ): UpdateMarriageSectionDataPayload { const fields = getOrderedFields(answers, questions, backendFields); - const targetQuestions = questions.filter( - (q) => q.required && q.isVisible, - ); + const targetQuestions = questions.filter((q) => q.required && q.isVisible); return { current_step: getCurrentStep(fields, questions, backendFields), @@ -249,7 +240,9 @@ function readStoredAnswers(storageKey: string, slug: string) { storedValue.slug === slug && (storedValue.pending_sync ?? fields.length > 0), pendingKeys: Array.isArray(storedValue.pending_keys) - ? storedValue.pending_keys.filter((key): key is string => typeof key === "string") + ? storedValue.pending_keys.filter( + (key): key is string => typeof key === "string", + ) : fields.map((field) => field.key), }; } catch { @@ -295,9 +288,7 @@ function writeStoredAnswers( } function getKeepalivePatchUrl(slug: string) { - return getApiRequestUrl( - `/api/marriage/forms/profile/answers/`, - ); + return getApiRequestUrl(`/api/marriage/forms/profile/answers/`); } function getCsrfToken() { @@ -318,7 +309,7 @@ export function QuestionAnswersProvider({ const [answers, setAnswers] = useState({}); const [hasPendingSync, setHasPendingSync] = useState(false); const { isPending: isSaving, mutateAsync } = - useUpdateMarriageSectionDataMutation(slug); + useUpdateMarriageSectionDataMutation(slug, locale); const answersRef = useRef({}); const hasPendingSyncRef = useRef(false); const answersRevisionRef = useRef(0); @@ -342,8 +333,18 @@ export function QuestionAnswersProvider({ slugRef.current = slug; questionsRef.current = questions; backendFieldsRef.current = serverSectionData?.data || []; - schemaVersionRef.current = Math.max(schemaVersionRef.current, schemaVersion); - }, [answers, hasPendingSync, slug, questions, schemaVersion, serverSectionData?.data]); + schemaVersionRef.current = Math.max( + schemaVersionRef.current, + schemaVersion, + ); + }, [ + answers, + hasPendingSync, + slug, + questions, + schemaVersion, + serverSectionData?.data, + ]); useEffect(() => { storageKeyRef.current = storageKey; @@ -377,7 +378,7 @@ export function QuestionAnswersProvider({ const nextKeys = Object.keys(finalAnswers); if (prevKeys.length === nextKeys.length) { const isSame = prevKeys.every( - (k) => prev[k]?.value === finalAnswers[k]?.value + (k) => prev[k]?.value === finalAnswers[k]?.value, ); if (isSame) return prev; } @@ -416,10 +417,7 @@ export function QuestionAnswersProvider({ ); const setAnswerValue = useCallback( - ( - question: QuestionField, - value: MarriageFieldValue, - ) => { + (question: QuestionField, value: MarriageFieldValue) => { if (!canEdit) { return; } @@ -481,11 +479,19 @@ export function QuestionAnswersProvider({ return; } - const fullPayload = createPayload(answersRef.current, questionsRef.current, backendFieldsRef.current); - const nextDirtyKey = fullPayload.fields.find((field) => dirtyKeysRef.current.has(field.key))?.key; + const fullPayload = createPayload( + answersRef.current, + questionsRef.current, + backendFieldsRef.current, + ); + const nextDirtyKey = fullPayload.fields.find((field) => + dirtyKeysRef.current.has(field.key), + )?.key; const payload = { ...fullPayload, - fields: nextDirtyKey ? fullPayload.fields.filter((field) => field.key === nextDirtyKey) : [], + fields: nextDirtyKey + ? fullPayload.fields.filter((field) => field.key === nextDirtyKey) + : [], version: schemaVersionRef.current, }; const revision = answersRevisionRef.current; @@ -495,8 +501,8 @@ export function QuestionAnswersProvider({ } const request = mutateAsync(payload).then((result) => { - if (result?.schema?.version) { - schemaVersionRef.current = result.schema.version; + if (result?.version) { + schemaVersionRef.current = result.version; } }); flushPromiseRef.current = request; @@ -558,8 +564,14 @@ export function QuestionAnswersProvider({ return; } - const fullPayload = createPayload(answersRef.current, questionsRef.current, backendFieldsRef.current); - const pendingFields = fullPayload.fields.filter((field) => dirtyKeysRef.current.has(field.key)); + const fullPayload = createPayload( + answersRef.current, + questionsRef.current, + backendFieldsRef.current, + ); + const pendingFields = fullPayload.fields.filter((field) => + dirtyKeysRef.current.has(field.key), + ); const payload = { ...fullPayload, fields: pendingFields.slice(0, 1) }; const revision = answersRevisionRef.current; @@ -590,7 +602,7 @@ export function QuestionAnswersProvider({ credentials: "include", headers, keepalive: true, - method: "PUT", + method: "PATCH", }) .then((response) => { if (!response.ok) { @@ -679,9 +691,7 @@ export function useQuestionAnswers() { return context; } -export function useQuestionAnswer( - question: QuestionField, -) { +export function useQuestionAnswer(question: QuestionField) { const context = useContext(QuestionAnswersContext); return { diff --git a/src/components/Componentes/question-card.tsx b/src/components/Componentes/question-card.tsx index 43a368e..029584d 100644 --- a/src/components/Componentes/question-card.tsx +++ b/src/components/Componentes/question-card.tsx @@ -1,4 +1,5 @@ import Link from "next/link"; +import { useEffect, useRef } from "react"; import { IoInformation } from "react-icons/io5"; import type { QuestionCardIcon, QuestionListItem } from "@/lib/schema-adapter"; import { localizePath } from "@/translations/config"; @@ -9,6 +10,8 @@ type QuestionCardProps = { item: QuestionListItem; progress?: number | null; onInfoClick?: (item: QuestionListItem) => void; + onNearViewport?: (item: QuestionListItem) => void; + onPrefetch?: (item: QuestionListItem) => void; }; const RADIUS = 8; @@ -29,6 +32,8 @@ export function QuestionCard({ item, progress = item.progress, onInfoClick, + onNearViewport, + onPrefetch, }: QuestionCardProps) { const { dictionary: t, locale } = useI18n(); const hasProgress = typeof progress === "number" && Number.isFinite(progress); @@ -37,14 +42,41 @@ export function QuestionCard({ : 0; const dashOffset = CIRCUMFERENCE - (normalizedProgress / 100) * CIRCUMFERENCE; const iconName = iconNameMap[item.icon]; + const cardRef = useRef(null); + + useEffect(() => { + const node = cardRef.current; + if ( + !node || + !onNearViewport || + typeof IntersectionObserver === "undefined" + ) { + return; + } + const observer = new IntersectionObserver( + ([entry]) => { + if (entry.isIntersecting) { + onNearViewport(item); + observer.disconnect(); + } + }, + { rootMargin: "160px 0px" }, + ); + observer.observe(node); + return () => observer.disconnect(); + }, [item, onNearViewport]); return ( onPrefetch?.(item)} + onPointerDown={() => onPrefetch?.(item)} + onPointerEnter={() => onPrefetch?.(item)} >
-
diff --git a/src/components/Componentes/required-steps-card.test.tsx b/src/components/Componentes/required-steps-card.test.tsx new file mode 100644 index 0000000..236d012 --- /dev/null +++ b/src/components/Componentes/required-steps-card.test.tsx @@ -0,0 +1,31 @@ +import { render, screen } from "@testing-library/react"; +import { describe, expect, it, vi } from "vitest"; +import RequiredStepsCard from "./required-steps-card"; + +vi.mock("@/translations/provider", () => ({ + useI18n: () => ({ + dictionary: new Proxy({}, { get: (_target, key) => String(key) }), + }), +})); + +describe("RequiredStepsCard", () => { + it("renders a real empty 0/9 state without a shimmer", () => { + const { container } = render(); + + expect(screen.getByText("0/9")).toBeDefined(); + expect(container.querySelector(".shimmer-white-bg")).toBeNull(); + expect(container.querySelectorAll("circle")[1].getAttribute("stroke-dashoffset")).toBe( + String(2 * Math.PI * 25), + ); + }); + + it("renders required section completion independently from optional assessments", () => { + const { container } = render(); + + expect(screen.getByText("4/9")).toBeDefined(); + const offset = Number( + container.querySelectorAll("circle")[1].getAttribute("stroke-dashoffset"), + ); + expect(offset).toBeCloseTo(2 * Math.PI * 25 * (5 / 9)); + }); +}); diff --git a/src/components/Componentes/required-steps-card.tsx b/src/components/Componentes/required-steps-card.tsx index c03fc4c..e51168d 100644 --- a/src/components/Componentes/required-steps-card.tsx +++ b/src/components/Componentes/required-steps-card.tsx @@ -1,63 +1,24 @@ "use client"; -import { useMemo } from "react"; import { IoAlert, IoCheckmark } from "react-icons/io5"; -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[]; - progressBySlug?: Map; + completed: number; + total: number; }; -type RequiredStep = { - slug: string; - required: boolean; - progress: number; -}; - -function getRequiredStepStats(steps: RequiredStep[]) { - const requiredSteps = steps.filter((step) => step.required); - const completedSteps = requiredSteps.filter((step) => step.progress >= 100); - - return { - completed: completedSteps.length, - total: requiredSteps.length, - }; -} +const RING_RADIUS = 25; +const RING_CIRCUMFERENCE = 2 * Math.PI * RING_RADIUS; export default function RequiredStepsCard({ - items, - progressBySlug, -}: RequiredStepsCardProps = {}) { - const { dictionary: t, locale } = useI18n(); - const { data: schema } = useFormSchemaQuery("profile", locale); - - const questionListItems = useMemo( - () => items ?? convertSchemaToFrontendItems(schema, locale), - [items, schema, locale], - ); - - const steps: RequiredStep[] = useMemo(() => { - return questionListItems.map((item) => { - let progress = item.progress; - - if (progressBySlug && typeof progressBySlug.get(item.slug) === "number") { - progress = progressBySlug.get(item.slug) ?? 0; - } - - return { - slug: item.slug, - required: Boolean(item.required), - progress, - }; - }); - }, [questionListItems, progressBySlug]); - - const { completed, total } = getRequiredStepStats(steps); - const completion = total > 0 ? Math.round((completed / total) * 100) : 0; + completed, + total, +}: RequiredStepsCardProps) { + const { dictionary: t } = useI18n(); + const normalizedCompleted = Math.max(0, Math.min(completed, total)); + const completion = total > 0 ? normalizedCompleted / total : 0; + const ringOffset = RING_CIRCUMFERENCE * (1 - completion); const isCompleted = total > 0 && completed === total; return ( @@ -94,16 +55,27 @@ export default function RequiredStepsCard({
-
+ - {completed}/{total} + {normalizedCompleted}/{total}
diff --git a/src/components/Componentes/schema-question-flow.integration.test.tsx b/src/components/Componentes/schema-question-flow.integration.test.tsx index 96f22b6..9e543c6 100644 --- a/src/components/Componentes/schema-question-flow.integration.test.tsx +++ b/src/components/Componentes/schema-question-flow.integration.test.tsx @@ -1,19 +1,36 @@ import React from "react"; -import { render, screen, fireEvent, waitFor, cleanup } from "@testing-library/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 { + QuestionAnswersProvider, + useQuestionAnswers, +} from "./question-answer-storage"; import { convertSchemaToFrontendItems } from "@/lib/schema-adapter"; -import { useFormSchemaQuery, type FormSchemaResponse } from "@/hooks/marriage/use-form-schema"; +import { + useFormOverviewQuery, + useFormSectionQuery, + type FormSchemaResponse, +} from "@/hooks/marriage/use-form-schema"; import { useMarriageProfileQuery } from "@/hooks/marriage/use-profile-main"; -import { useMarriageSectionDataQuery, useUpdateMarriageSectionDataMutation } from "@/hooks/marriage/use-section-data"; +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(), + useFormOverviewQuery: vi.fn(), + useFormSectionQuery: vi.fn(), })); vi.mock("@/hooks/marriage/use-profile-main", () => ({ useMarriageProfileQuery: vi.fn(), @@ -36,8 +53,8 @@ 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"] } + q_radio: { value: "A", option_id: "opt_a" }, + q_check: { value: ["B", "C"], option_id: ["opt_b", "opt_c"] }, }, sections: [ { @@ -53,43 +70,78 @@ const mockSchema = { 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_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: {}, + { + 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: "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: {}, + { + 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: "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: [] }, - ] - } - ] - } + { + id: "q_first", + title: "First Q", + type: "text", + order: 1, + required: true, + is_visible: true, + ui_config: {}, + options: [], + }, + ], + }, + ], + }, ], - progress: { sections_progress: {} } + progress: { sections_progress: {} }, }; describe("Schema Question Flow Integration", () => { @@ -98,8 +150,16 @@ describe("Schema Question Flow Integration", () => { beforeEach(() => { capturedPayload = null; const updateMutateAsync = vi.fn(async (payload) => { - capturedPayload = payload; - return payload; + capturedPayload = { + ...payload, + fields: [ + ...(capturedPayload?.fields ?? []).filter( + (field: any) => field.key !== payload.fields[0]?.key, + ), + ...payload.fields, + ], + }; + return { ...payload, version: 2 }; }); (useUpdateMarriageSectionDataMutation as any).mockReturnValue({ mutateAsync: updateMutateAsync, @@ -113,18 +173,44 @@ describe("Schema Question Flow Integration", () => { 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"] } - ] + { + key: "q_check", + type: "checkbox", + value: ["B", "C"], + option_id: ["opt_b", "opt_c"], + }, + ], }, isLoading: false, }); - (useFormSchemaQuery as any).mockReturnValue({ - data: mockSchema, + (useFormOverviewQuery as any).mockReturnValue({ + data: { + ...mockSchema, + sections: mockSchema.sections.map(({ cards, ...section }) => ({ + ...section, + kind: "profile", + progress: { current_step: 0, total_steps: 0, completion_percent: 0 }, + })), + }, isLoading: false, isFetching: false, error: null, refetch: vi.fn(), }); + (useFormSectionQuery as any).mockReturnValue({ + data: { + version: 1, + section: mockSchema.sections[0], + answers: mockSchema.answers, + progress: mockSchema.progress, + section_progress: { + current_step: 0, + total_steps: 0, + completion_percent: 0, + }, + }, + isLoading: false, + }); }); afterEach(() => { @@ -132,20 +218,22 @@ describe("Schema Question Flow Integration", () => { }); it("should enforce ordering, correctly hydrate canonical values via options, and hide invisible children", async () => { - const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + const queryClient = new QueryClient({ + defaultOptions: { queries: { retry: false } }, + }); // Use convertSchemaToFrontendItems directly (no mock) const frontendItems = convertSchemaToFrontendItems( mockSchema as unknown as FormSchemaResponse, "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"); @@ -160,13 +248,15 @@ describe("Schema Question Flow Integration", () => { questionsListHref="/list" title="Title" /> - + , ); // Wait for hydration and rendering await waitFor(() => { // Radio hydration - const radioInput = screen.getByLabelText("Option A Canonical") as HTMLInputElement; + const radioInput = screen.getByLabelText( + "Option A Canonical", + ) as HTMLInputElement; expect(radioInput.checked).toBe(true); // Checkbox hydration @@ -177,15 +267,17 @@ describe("Schema Question Flow Integration", () => { // 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; + 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 @@ -195,10 +287,14 @@ describe("Schema Question Flow Integration", () => { await waitFor(() => { expect(capturedPayload).not.toBeNull(); - const radioPayload = capturedPayload.fields.find((f: any) => f.key === "q_radio"); + 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"); + + const checkPayload = capturedPayload.fields.find( + (f: any) => f.key === "q_check", + ); expect(checkPayload.option_id).toEqual(["opt_c"]); // Only C is checked now }); }); @@ -206,15 +302,21 @@ describe("Schema Question Flow Integration", () => { it("should redirect and not render static questions on schema failure", () => { replaceMock.mockClear(); - (useFormSchemaQuery as any).mockReturnValue({ + (useFormOverviewQuery as any).mockReturnValue({ data: undefined, isLoading: false, isFetching: false, error: new Error("Network Error"), refetch: vi.fn(), }); + (useFormSectionQuery as any).mockReturnValue({ + data: undefined, + isLoading: false, + }); - const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + const queryClient = new QueryClient({ + defaultOptions: { queries: { retry: false } }, + }); render( @@ -227,7 +329,7 @@ describe("Schema Question Flow Integration", () => { questionsListHref="/list" title="Title" /> - + , ); // Should redirect to questions list diff --git a/src/hooks/marriage/query-keys.ts b/src/hooks/marriage/query-keys.ts index 0fe2453..dcdf06b 100644 --- a/src/hooks/marriage/query-keys.ts +++ b/src/hooks/marriage/query-keys.ts @@ -10,6 +10,10 @@ export const marriageQueryKeys = { "contact-info", ] as const, profile: () => [...marriageQueryKeys.all, "profile"] as const, + formOverview: (formId: string, locale: string) => + [...marriageQueryKeys.all, "form-overview", formId, locale] as const, + formSection: (formId: string, slug: string, locale: string) => + [...marriageQueryKeys.all, "form-section", formId, slug, locale] as const, sectionData: (slug: string) => [...marriageQueryKeys.sections(), slug, "data"] as const, sections: () => [...marriageQueryKeys.all, "sections"] as const, diff --git a/src/hooks/marriage/use-form-schema.ts b/src/hooks/marriage/use-form-schema.ts index 820f735..639334b 100644 --- a/src/hooks/marriage/use-form-schema.ts +++ b/src/hooks/marriage/use-form-schema.ts @@ -45,6 +45,8 @@ export interface FormSection { order: number; estimated_minutes: number; cards: FormCard[]; + kind?: "profile" | "assessment"; + assessment_type?: "cattell" | "glasser"; } export interface FormProgressInfo { @@ -67,9 +69,79 @@ export interface FormSchemaResponse { is_completed?: boolean; } -export async function getFormSchema(formId: string, locale: string): Promise { +export interface FormOverviewSection extends Omit { + kind: "profile" | "assessment"; + assessment_type?: "cattell" | "glasser"; + question_count?: number; + progress: FormProgressInfo; +} + +export interface FormOverviewResponse { + form_id: string; + version: number; + sections: FormOverviewSection[]; + progress: FormSchemaResponse["progress"]; +} + +export interface FormSectionResponse { + form_id: string; + version: number; + section: FormSection; + answers: FormSchemaResponse["answers"]; + progress: FormSchemaResponse["progress"]; + section_progress: FormProgressInfo; +} + +export async function getFormOverview(formId: string, locale: string) { + const { data } = await http.get( + `/api/marriage/forms/${formId}/overview/?lang=${locale}`, + ); + return data; +} + +export function useFormOverviewQuery(formId: string, locale: string) { + return useQuery({ + queryKey: marriageQueryKeys.formOverview(formId, locale), + queryFn: () => getFormOverview(formId, locale), + staleTime: 30 * 1000, + refetchOnMount: "always", + refetchOnWindowFocus: false, + }); +} + +export async function getFormSection( + formId: string, + slug: string, + locale: string, +) { + const { data } = await http.get( + `/api/marriage/forms/${formId}/sections/${encodeURIComponent(slug)}/?lang=${locale}`, + ); + return data; +} + +export function useFormSectionQuery( + formId: string, + slug: string, + locale: string, + enabled = true, +) { + return useQuery({ + queryKey: marriageQueryKeys.formSection(formId, slug, locale), + queryFn: () => getFormSection(formId, slug, locale), + enabled, + staleTime: 30 * 1000, + refetchOnMount: false, + refetchOnWindowFocus: false, + }); +} + +export async function getFormSchema( + formId: string, + locale: string, +): Promise { const { data } = await http.get( - `/api/marriage/forms/${formId}/?lang=${locale}` + `/api/marriage/forms/${formId}/?lang=${locale}`, ); return data; } @@ -77,7 +149,7 @@ export async function getFormSchema(formId: string, locale: string): Promise( formId: string, locale: string, - options?: QueryOptions + options?: QueryOptions, ) { return useQuery({ staleTime: 30 * 1000, @@ -98,17 +170,20 @@ export interface SaveAnswersPayload { }>; } -export async function saveFormAnswers(formId: string, payload: SaveAnswersPayload): Promise { +export async function saveFormAnswers( + formId: string, + payload: SaveAnswersPayload, +): Promise { const { data } = await http.put( `/api/marriage/forms/${formId}/answers/`, - payload + payload, ); return data; } export function useSaveFormAnswersMutation( formId: string, - options?: MutationOptions + options?: MutationOptions, ) { const queryClient = useQueryClient(); diff --git a/src/hooks/marriage/use-section-data.ts b/src/hooks/marriage/use-section-data.ts index c303c5e..298f5a9 100644 --- a/src/hooks/marriage/use-section-data.ts +++ b/src/hooks/marriage/use-section-data.ts @@ -2,18 +2,16 @@ import { useMutation, useQueryClient } from "@tanstack/react-query"; import { http } from "@/lib/http"; -import type { MutationOptions, QueryOptions } from "./options"; -import { pathParam } from "./path-param"; +import type { MutationOptions } from "./options"; import { marriageQueryKeys } from "./query-keys"; import type { MarriageSectionData, UpdateMarriageSectionDataPayload, } from "./types"; -import type { FormSchemaResponse } from "./use-form-schema"; -import { useFormSchemaQuery } from "./use-form-schema"; - - - +import { + type FormSchemaResponse, + useFormSectionQuery, +} from "./use-form-schema"; export async function updateMarriageSectionData( slug: string, @@ -25,22 +23,30 @@ export async function updateMarriageSectionData( option_id: (f as any).option_id || undefined, })); - const { data } = await http.put( - `/api/marriage/forms/profile/answers/`, - { - version: payload.version, - answers: answersPayload, - } - ); + const { data } = await http.patch<{ + version: number; + answers: FormSchemaResponse["answers"]; + affected_sections: Record< + string, + { current_step: number; total_steps: number; completion_percent: number } + >; + progress: FormSchemaResponse["progress"]; + }>(`/api/marriage/forms/profile/answers/`, { + version: payload.version, + answers: answersPayload, + }); - const prog = data.progress.sections_progress[slug] || { - current_step: 0, - total_steps: 0, - completion_percent: 0.0, - }; + const prog = data.affected_sections[slug] || + data.progress.sections_progress[slug] || { + current_step: 0, + total_steps: 0, + completion_percent: 0.0, + }; return { - schema: data, + version: data.version, + progress: data.progress, + answers: data.answers, sectionData: { slug, data: payload.fields, @@ -57,22 +63,48 @@ export function useMarriageSectionDataQuery( locale: string, ) { const normalizedSlug = slug ?? ""; - return useFormSchemaQuery("profile", locale, { - enabled: Boolean(normalizedSlug), - select: (schema) => { - const section = schema.sections.find((item) => item.id === normalizedSlug); - const fields = section?.cards.flatMap((card) => card.questions.map((question) => { - const answer = schema.answers[question.id]; - return { key: question.id, label: question.title, type: question.type, value: answer?.value ?? null, option_id: answer?.option_id }; - })) || []; - const progress = schema.progress.sections_progress[normalizedSlug] || { current_step: 0, total_steps: 0, completion_percent: 0 }; - return { slug: normalizedSlug, data: fields, ...progress, updated_at: null }; - }, - }); + const query = useFormSectionQuery( + "profile", + normalizedSlug, + locale, + Boolean(normalizedSlug), + ); + return { + ...query, + data: query.data + ? (() => { + const section = query.data.section; + const fields = section.cards.flatMap((card) => + card.questions.map((question) => { + const answer = query.data?.answers[question.id]; + return { + key: question.id, + label: question.title, + type: question.type, + value: answer?.value ?? null, + option_id: answer?.option_id, + }; + }), + ); + const progress = query.data.section_progress || { + current_step: 0, + total_steps: 0, + completion_percent: 0, + }; + return { + slug: normalizedSlug, + data: fields, + ...progress, + updated_at: null, + }; + })() + : undefined, + }; } export function useUpdateMarriageSectionDataMutation( slug: string, + locale: string, options?: MutationOptions< Awaited>, UpdateMarriageSectionDataPayload @@ -84,9 +116,34 @@ export function useUpdateMarriageSectionDataMutation( ...options, mutationFn: (payload) => updateMarriageSectionData(slug, payload), onSuccess: async (data, variables, onMutateResult, context) => { - queryClient.setQueriesData( - { queryKey: ["marriage", "form-schema", "profile"] }, - data.schema, + queryClient.setQueryData( + marriageQueryKeys.formSection("profile", slug, locale), + (current: any) => + current + ? { + ...current, + version: data.version, + progress: data.progress, + section_progress: data.sectionData, + answers: { ...current.answers, ...data.answers }, + } + : current, + ); + queryClient.setQueryData( + marriageQueryKeys.formOverview("profile", locale), + (current: any) => + current + ? { + ...current, + version: data.version, + progress: data.progress, + sections: current.sections.map((section: any) => + section.id === slug + ? { ...section, progress: data.sectionData } + : section, + ), + } + : current, ); await options?.onSuccess?.(data, variables, onMutateResult, context); }, diff --git a/src/lib/marriage-profile-contract.ts b/src/lib/marriage-profile-contract.ts new file mode 100644 index 0000000..fe74d73 --- /dev/null +++ b/src/lib/marriage-profile-contract.ts @@ -0,0 +1 @@ +export const REQUIRED_PROFILE_SECTION_COUNT = 9; diff --git a/src/lib/schema-adapter-overview.test.ts b/src/lib/schema-adapter-overview.test.ts new file mode 100644 index 0000000..fd5bb5b --- /dev/null +++ b/src/lib/schema-adapter-overview.test.ts @@ -0,0 +1,39 @@ +import { describe, expect, it } from "vitest"; +import { convertOverviewToFrontendItems } from "./schema-adapter"; +import type { FormOverviewResponse } from "@/hooks/marriage/use-form-schema"; + +describe("convertOverviewToFrontendItems", () => { + it("maps overview metadata without materializing questions", () => { + const overview: FormOverviewResponse = { + form_id: "profile", + version: 2, + progress: { + current_step: 1, + total_steps: 2, + completion_percent: 50, + sections_progress: {}, + }, + sections: [ + { + id: "personal", + title: "Personal", + icon: "user-circle", + is_required: true, + order: 1, + estimated_minutes: 2, + kind: "profile", + progress: { current_step: 1, total_steps: 2, completion_percent: 50 }, + }, + ], + }; + + expect(convertOverviewToFrontendItems(overview)).toEqual([ + expect.objectContaining({ + slug: "personal", + progress: 50, + questions: [], + checkpoints: [], + }), + ]); + }); +}); diff --git a/src/lib/schema-adapter.ts b/src/lib/schema-adapter.ts index 5808873..31f27da 100644 --- a/src/lib/schema-adapter.ts +++ b/src/lib/schema-adapter.ts @@ -2,6 +2,7 @@ import type { FormSchemaResponse, FormSection, FormQuestion, + FormOverviewResponse, } from "@/hooks/marriage/use-form-schema"; import { defaultLocale, type Locale } from "@/translations/config"; @@ -26,8 +27,6 @@ export type QuestionAudienceRule = { minAge?: number; }; - - export type QuestionField = { id: string; title: string; @@ -45,7 +44,12 @@ export type QuestionField = { audience?: QuestionAudienceRule; requiredWhen?: QuestionAudienceRule; showGuardianNotice?: boolean; - options: { id: string; value: string | number; label: string; order: number }[]; + options: { + id: string; + value: string | number; + label: string; + order: number; + }[]; }; export type QuestionListItem = { @@ -71,7 +75,10 @@ const iconMap: Record = { "layout-grid": "checklist", }; -export function mapBackendQuestionToFrontend(bq: FormQuestion, index: number): QuestionField { +export function mapBackendQuestionToFrontend( + bq: FormQuestion, + index: number, +): QuestionField { return { id: bq.id, title: bq.title || "Untitled", @@ -92,20 +99,26 @@ export function mapBackendQuestionToFrontend(bq: FormQuestion, index: number): Q noSearch: bq.ui_config?.noSearch, }, showGuardianNotice: bq.show_guardian_notice, - options: [...(bq.options || [])].sort((a, b) => (a.order ?? 0) - (b.order ?? 0)), + options: [...(bq.options || [])].sort( + (a, b) => (a.order ?? 0) - (b.order ?? 0), + ), }; } export function mapBackendSectionToFrontend( section: FormSection, - progress: number + progress: number, ): QuestionListItem { const allQuestions: QuestionField[] = []; let index = 0; - - const cards = [...(section.cards || [])].sort((a, b) => (a.order ?? 0) - (b.order ?? 0)); + + const cards = [...(section.cards || [])].sort( + (a, b) => (a.order ?? 0) - (b.order ?? 0), + ); cards.forEach((card) => { - const questions = [...(card.questions || [])].sort((a, b) => (a.order ?? 0) - (b.order ?? 0)); + const questions = [...(card.questions || [])].sort( + (a, b) => (a.order ?? 0) - (b.order ?? 0), + ); questions.forEach((q) => { const fq = mapBackendQuestionToFrontend(q, index); allQuestions.push(fq); @@ -116,7 +129,9 @@ export function mapBackendSectionToFrontend( return { slug: section.id, title: section.title, - estimate: section.estimated_minutes ? `${section.estimated_minutes} min` : "5 min", + estimate: section.estimated_minutes + ? `${section.estimated_minutes} min` + : "5 min", progress: progress, icon: iconMap[section.icon] ?? "details", required: section.is_required, @@ -130,7 +145,7 @@ export function mapBackendSectionToFrontend( export function convertSchemaToFrontendItems( schema: FormSchemaResponse | undefined, - locale: Locale = defaultLocale + locale: Locale = defaultLocale, ): QuestionListItem[] { if (!schema) return []; @@ -140,5 +155,32 @@ export function convertSchemaToFrontendItems( return mapBackendSectionToFrontend(sec, progress); }); - return rawItems.sort((a, b) => (schema.sections.find(s => s.id === a.slug)?.order ?? 0) - (schema.sections.find(s => s.id === b.slug)?.order ?? 0)); + return rawItems.sort( + (a, b) => + (schema.sections.find((s) => s.id === a.slug)?.order ?? 0) - + (schema.sections.find((s) => s.id === b.slug)?.order ?? 0), + ); +} + +export function convertOverviewToFrontendItems( + overview: FormOverviewResponse | undefined, +): QuestionListItem[] { + if (!overview) return []; + return [...overview.sections] + .sort((a, b) => a.order - b.order) + .map((section) => ({ + slug: section.id, + title: section.title, + estimate: section.estimated_minutes + ? `${section.estimated_minutes} min` + : "5 min", + progress: section.progress.completion_percent, + icon: iconMap[section.icon] ?? "details", + required: section.is_required, + showInfoBadge: false, + summary: "", + checkpoints: [], + tooltip: "", + questions: [], + })); }