From 79e7564ffb31133f5bcc085c9c0a2b3b0a7d0b11 Mon Sep 17 00:00:00 2001 From: mortezaei Date: Sun, 16 Aug 2026 13:20:31 +0330 Subject: [PATCH 1/5] 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: [], + })); } From 1b809ea0136d06ef98a5f78ffaa6dcad71f704b5 Mon Sep 17 00:00:00 2001 From: mortezaei Date: Sun, 16 Aug 2026 14:49:51 +0330 Subject: [PATCH 2/5] refactor: implement batching for question answer updates and optimize mutation handling --- src/components/Componentes/token-switcher.tsx | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/src/components/Componentes/token-switcher.tsx b/src/components/Componentes/token-switcher.tsx index bdeba04..f614661 100644 --- a/src/components/Componentes/token-switcher.tsx +++ b/src/components/Componentes/token-switcher.tsx @@ -1,7 +1,8 @@ "use client"; -import { useEffect, useState } from "react"; +import { useEffect, useRef, useState } from "react"; import { MdOutlineSwitchAccount } from "react-icons/md"; +import { IoClose, IoTrashOutline, IoKeyOutline } from "react-icons/io5"; import { getClientCookie, setClientCookie } from "@/lib/cookies"; import { setCachedMarriageEntryPath } from "@/lib/entry-route-cache"; @@ -31,10 +32,13 @@ export function TokenSwitcher({ className, isFloating = true, }: TokenSwitcherProps) { + const [mounted, setMounted] = useState(false); const [isOpen, setIsOpen] = useState(false); const [currentToken, setCurrentToken] = useState(""); const [customTokenInput, setCustomTokenInput] = useState(""); const [isCustomInputOpen, setIsCustomInputOpen] = useState(false); + const [position, setPosition] = useState<{ x: number; y: number } | null>(null); + const containerRef = useRef(null); useEffect(() => { setMounted(true); @@ -255,6 +259,7 @@ export function TokenSwitcher({ return ( <> + {badgeContent} {isOpen && ( -
+
setIsOpen(false)} + >
e.stopPropagation()} > {/* Modal Header */} diff --git a/src/hooks/marriage/types.ts b/src/hooks/marriage/types.ts index 1e6b348..1c331d4 100644 --- a/src/hooks/marriage/types.ts +++ b/src/hooks/marriage/types.ts @@ -157,6 +157,34 @@ export type UpdateMarriageSectionDataPayload = { fields: MarriageField[]; }; +export type MarriageQuestionState = { + is_visible: boolean; + is_required: boolean; +}; + +export type ProfileAnswersPatchResult = { + version: number; + answers: Record< + string, + { value: MarriageFieldValue; option_id?: string | string[] } + >; + cleared_answer_ids?: string[]; + question_states?: Record; + affected_sections: Record< + string, + { current_step: number; total_steps: number; completion_percent: number } + >; + progress: { + current_step: number; + total_steps: number; + completion_percent: number; + sections_progress: Record< + string, + { current_step: number; total_steps: number; completion_percent: number } + >; + }; +}; + export type StartMarriageMatchResponse = { detail: string; }; diff --git a/src/hooks/marriage/use-section-data.test.ts b/src/hooks/marriage/use-section-data.test.ts new file mode 100644 index 0000000..9a36108 --- /dev/null +++ b/src/hooks/marriage/use-section-data.test.ts @@ -0,0 +1,99 @@ +import { QueryClient } from "@tanstack/react-query"; +import { beforeEach, describe, expect, it } from "vitest"; +import { marriageQueryKeys } from "./query-keys"; +import type { ProfileAnswersPatchResult } from "./types"; +import { applyProfilePatchResultToCache } from "./use-section-data"; + +describe("applyProfilePatchResultToCache", () => { + let queryClient: QueryClient; + + beforeEach(() => { + queryClient = new QueryClient(); + window.localStorage.clear(); + }); + + it("applies cleared answers, question states, and cross-section progress", () => { + const sectionAKey = marriageQueryKeys.formSection( + "profile", + "section-a", + "en", + ); + const sectionBKey = marriageQueryKeys.formSection( + "profile", + "section-b", + "en", + ); + queryClient.setQueryData(sectionAKey, { + answers: { parent: { value: "yes" } }, + section: { cards: [{ questions: [{ id: "parent", is_visible: true }] }] }, + }); + queryClient.setQueryData(sectionBKey, { + answers: { child: { value: "old" } }, + section: { + cards: [ + { + questions: [{ id: "child", is_visible: true, is_required: true }], + }, + ], + }, + }); + queryClient.setQueryData(marriageQueryKeys.formOverview("profile", "en"), { + sections: [ + { id: "section-a", progress: {} }, + { id: "section-b", progress: {} }, + ], + }); + window.localStorage.setItem( + "marriage:sections:section-b:answers:v2", + JSON.stringify({ + fields: [{ key: "child", value: "old" }], + pending_keys: ["child"], + pending_sync: true, + }), + ); + + const result: ProfileAnswersPatchResult = { + version: 38, + answers: { parent: { value: "no" } }, + cleared_answer_ids: ["child"], + question_states: { + child: { is_visible: false, is_required: false }, + }, + affected_sections: { + "section-a": { + current_step: 1, + total_steps: 1, + completion_percent: 100, + }, + "section-b": { + current_step: 0, + total_steps: 0, + completion_percent: 100, + }, + }, + progress: { + current_step: 1, + total_steps: 1, + completion_percent: 100, + sections_progress: {}, + }, + }; + + applyProfilePatchResultToCache(queryClient, "en", result); + + const sectionB = queryClient.getQueryData(sectionBKey); + expect(sectionB.answers.child).toBeUndefined(); + expect(sectionB.section.cards[0].questions[0]).toMatchObject({ + is_visible: false, + is_required: false, + }); + expect(sectionB.section_progress.current_step).toBe(0); + expect( + window.localStorage.getItem("marriage:sections:section-b:answers:v2"), + ).toBeNull(); + const overview = queryClient.getQueryData( + marriageQueryKeys.formOverview("profile", "en"), + ); + expect(overview.sections[1].progress.total_steps).toBe(0); + }); +}); diff --git a/src/hooks/marriage/use-section-data.ts b/src/hooks/marriage/use-section-data.ts index 298f5a9..db37e72 100644 --- a/src/hooks/marriage/use-section-data.ts +++ b/src/hooks/marriage/use-section-data.ts @@ -1,40 +1,37 @@ "use client"; -import { useMutation, useQueryClient } from "@tanstack/react-query"; +import { + type QueryClient, + useMutation, + useQueryClient, +} from "@tanstack/react-query"; import { http } from "@/lib/http"; import type { MutationOptions } from "./options"; import { marriageQueryKeys } from "./query-keys"; import type { MarriageSectionData, + ProfileAnswersPatchResult, UpdateMarriageSectionDataPayload, } from "./types"; -import { - type FormSchemaResponse, - useFormSectionQuery, -} from "./use-form-schema"; +import { useFormSectionQuery } from "./use-form-schema"; export async function updateMarriageSectionData( slug: string, payload: UpdateMarriageSectionDataPayload, -) { +): Promise { const answersPayload = payload.fields.map((f) => ({ question_id: f.key, value: f.value, - option_id: (f as any).option_id || undefined, + option_id: f.option_id || undefined, })); - 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 { data } = await http.patch( + `/api/marriage/forms/profile/answers/`, + { + version: payload.version, + answers: answersPayload, + }, + ); const prog = data.affected_sections[slug] || data.progress.sections_progress[slug] || { @@ -47,6 +44,9 @@ export async function updateMarriageSectionData( version: data.version, progress: data.progress, answers: data.answers, + cleared_answer_ids: data.cleared_answer_ids, + question_states: data.question_states, + affected_sections: data.affected_sections, sectionData: { slug, data: payload.fields, @@ -58,6 +58,105 @@ export async function updateMarriageSectionData( }; } +export function applyProfilePatchResultToCache( + queryClient: QueryClient, + locale: string, + data: ProfileAnswersPatchResult, +) { + const cleared = new Set(data.cleared_answer_ids ?? []); + if (typeof window !== "undefined" && cleared.size > 0) { + for (let index = 0; index < window.localStorage.length; index += 1) { + const storageKey = window.localStorage.key(index); + if (!storageKey?.startsWith("marriage:sections:")) continue; + const rawValue = window.localStorage.getItem(storageKey); + if (!rawValue) continue; + try { + const stored = JSON.parse(rawValue); + if (!Array.isArray(stored.fields)) continue; + const fields = stored.fields.filter( + (field: { key?: string }) => !field.key || !cleared.has(field.key), + ); + if (fields.length === stored.fields.length) continue; + if (fields.length === 0) { + window.localStorage.removeItem(storageKey); + index -= 1; + continue; + } + stored.fields = fields; + stored.pending_keys = Array.isArray(stored.pending_keys) + ? stored.pending_keys.filter((key: string) => !cleared.has(key)) + : []; + stored.pending_sync = stored.pending_keys.length > 0; + window.localStorage.setItem(storageKey, JSON.stringify(stored)); + } catch { + // Leave malformed drafts untouched; normal hydration handles them. + } + } + } + const patchSection = (current: any, slug: string) => { + if (!current) return current; + const sectionProgress = + data.affected_sections[slug] ?? data.progress.sections_progress[slug]; + const answers = { ...(current.answers ?? {}) }; + Object.entries(data.answers ?? {}).forEach(([key, answer]) => { + answers[key] = answer; + }); + cleared.forEach((key) => { + delete answers[key]; + }); + const questionStates = data.question_states ?? {}; + const section = current.section + ? { + ...current.section, + cards: current.section.cards?.map((card: any) => ({ + ...card, + questions: card.questions?.map((question: any) => + questionStates[question.id] + ? { ...question, ...questionStates[question.id] } + : question, + ), + })), + } + : current.section; + return { + ...current, + version: data.version, + answers, + section, + progress: data.progress, + ...(sectionProgress ? { section_progress: sectionProgress } : {}), + }; + }; + + queryClient + .getQueriesData({ + queryKey: marriageQueryKeys + .formSection("profile", "", locale) + .slice(0, 3), + }) + .forEach(([queryKey, current]) => { + const slug = String(queryKey[3] ?? ""); + queryClient.setQueryData(queryKey, patchSection(current, slug)); + }); + queryClient.setQueryData( + marriageQueryKeys.formOverview("profile", locale), + (current: any) => + current + ? { + ...current, + version: data.version, + progress: data.progress, + sections: current.sections.map((section: any) => { + const progress = + data.affected_sections[section.id] ?? + data.progress.sections_progress[section.id]; + return progress ? { ...section, progress } : section; + }), + } + : current, + ); +} + export function useMarriageSectionDataQuery( slug: string | null | undefined, locale: string, @@ -116,35 +215,7 @@ export function useUpdateMarriageSectionDataMutation( ...options, mutationFn: (payload) => updateMarriageSectionData(slug, payload), onSuccess: async (data, variables, onMutateResult, context) => { - 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, - ); + applyProfilePatchResultToCache(queryClient, locale, data); await options?.onSuccess?.(data, variables, onMutateResult, context); }, }); From 9ee426ddc773f26d6e6f446568fab94c9b7c5201 Mon Sep 17 00:00:00 2001 From: mortezaei Date: Mon, 17 Aug 2026 11:00:34 +0330 Subject: [PATCH 4/5] feat: implement sheet scroll locking and keyboard-aware input focus management for mobile responsiveness --- ...ss_b396caff-afc5-4bc2-93b4-abe26fb2b685.md | 36 ++ src/app/globals.css | 42 ++ .../[slug]/question-detail-client.test.tsx | 92 +++- .../[slug]/question-detail-client.tsx | 66 ++- src/app/questions-list/page.tsx | 223 ++++---- .../questions-list/section-prefetch.test.ts | 53 ++ src/app/questions-list/section-prefetch.ts | 24 + .../Componentes/question-answer-storage.tsx | 23 +- .../Componentes/question-birthplace.tsx | 497 +++++++++--------- .../Componentes/question-date-sheet.tsx | 348 ++++++++++++ .../Componentes/question-date.test.tsx | 60 +++ src/components/Componentes/question-date.tsx | 293 +++++------ src/components/Componentes/question-phone.tsx | 390 +++++++------- .../Componentes/question-renderer.tsx | 4 +- .../Componentes/question-sheet.test.tsx | 182 +++++++ src/components/Componentes/question-sheet.tsx | 416 +++++++++++++++ .../Componentes/question-snap-list.tsx | 345 +++++++++++- .../schema-question-flow.integration.test.tsx | 4 +- src/components/Componentes/ui-config.test.tsx | 166 +++++- .../Componentes/use-sheet-scroll-lock.ts | 125 +++++ src/hooks/marriage/types.ts | 2 - src/hooks/marriage/use-form-schema.ts | 4 - src/hooks/marriage/use-section-data.test.ts | 1 - src/hooks/marriage/use-section-data.ts | 29 +- src/lib/schema-adapter-overview.test.ts | 1 - src/translations/locales/ar.json | 8 +- src/translations/locales/az.json | 8 +- src/translations/locales/bn.json | 8 +- src/translations/locales/da.json | 8 +- src/translations/locales/de.json | 8 +- src/translations/locales/en.json | 8 +- src/translations/locales/es.json | 8 +- src/translations/locales/fa.json | 8 +- src/translations/locales/fr.json | 8 +- src/translations/locales/gu.json | 8 +- src/translations/locales/ha.json | 8 +- src/translations/locales/he.json | 10 +- src/translations/locales/hi.json | 8 +- src/translations/locales/id.json | 10 +- src/translations/locales/ks.json | 10 +- src/translations/locales/pt.json | 10 +- src/translations/locales/ru.json | 8 +- src/translations/locales/sw.json | 10 +- src/translations/locales/tg.json | 10 +- src/translations/locales/tr.json | 10 +- src/translations/locales/ul.json | 10 +- src/translations/locales/ur.json | 10 +- src/translations/locales/uz.json | 10 +- src/translations/locales/zh.json | 8 +- 49 files changed, 2798 insertions(+), 840 deletions(-) create mode 100644 .zcode/plans/plan-sess_b396caff-afc5-4bc2-93b4-abe26fb2b685.md create mode 100644 src/app/questions-list/section-prefetch.test.ts create mode 100644 src/app/questions-list/section-prefetch.ts create mode 100644 src/components/Componentes/question-date-sheet.tsx create mode 100644 src/components/Componentes/question-date.test.tsx create mode 100644 src/components/Componentes/question-sheet.test.tsx create mode 100644 src/components/Componentes/question-sheet.tsx create mode 100644 src/components/Componentes/use-sheet-scroll-lock.ts diff --git a/.zcode/plans/plan-sess_b396caff-afc5-4bc2-93b4-abe26fb2b685.md b/.zcode/plans/plan-sess_b396caff-afc5-4bc2-93b4-abe26fb2b685.md new file mode 100644 index 0000000..ed4013d --- /dev/null +++ b/.zcode/plans/plan-sess_b396caff-afc5-4bc2-93b4-abe26fb2b685.md @@ -0,0 +1,36 @@ +## هدف +اسلاید بین سوالات مثل ریلز اینستاگرام / شورتز یوتیوب: محتوا 1:1 با انگشت حرکت کند، رها کردن بر اساس سرعت (flick) یا مسافت یا commit به بعدی/قبلی یا برگشت، rubber-band در دو انتها، و انیمیشن قابل مقطوع‌سازی (grab mid-animation). + +## تغییرات — فقط `src/components/Componentes/question-snap-list.tsx` + +### 1. لایه‌ی درگ با دنبال کردن انگشت (بدون re-render در هر فریم) +- در `touchstart`: ثبت نقطه/زمان شروع + ارتفاع کانتینر. اگر لمس روی کنترل‌های تعاملی شروع شود (`input, textarea, select, [contenteditable], [data-snap-drag-ignore]`) درگ فعال نمی‌شود تا با تایپ/اسلایدر تداخل نکند. +- در `touchmove` بعد از عبور از آستانه ~۱۰px: اعمال مستقیم `transform` روی سه پنل (فعال، بعدی، قبلی) از طریق ref (بدون state ری‌اکت) → 60fps: + - فعال: `translateY(-offset)` — بالا می‌رود با انگشت + - بعدی: `translateY(height - offset)` — از پایین به‌صورت پنل کامل وارد می‌شود + - قبلی: `translateY(-height - offset)` — از بالا وارد می‌شود +- **Rubber-band**: در سوال اول/آخر، آفست با ضریب مقاومت 0.4 فشرده می‌شود. +- محاسبه‌ی velocity با میانگین متحرک نمایی روی نمونه‌های حرکت. + +### 2. تصمیم رها کردن (touchend) +- commit به بعدی/قبلی اگر: مسافت > 30% ارتفاع **یا** |velocity| > 0.55 px/ms (flick). +- وگرنه spring-back با انیمیشن `cubic-bezier(0.22,1,0.36,1)` ~340ms. +- هنگام commit: همان callback های فعلی (`onQuestionExit`, `onQuestionTransition`) + `setActiveIndex` فراخوانی می‌شود؛ انیمیشن inline ادامه پیدا می‌کند و بعد از پایان، style های inline پاک می‌شوند تا state کلاسی بدون پرش تحویل داده شود. + +### 3. قابل مقطوع‌سازی +- اگر وسط انیمیشن snap دوباره انگشت بگذارد: موقعیت فعلی با `getComputedStyle + DOMMatrix` خوانده و فریز می‌شود و درگ از همان نقطه ادامه می‌یابد (مثل ریلز). + +### 4. حذف پیش‌نمایش‌های ۱۱۰px (peek) +- برای اسلاید پنل کامل ریلز-مانند، پنل‌های قبل/بعد در حالت سکون کاملاً خارج از دید (`translate-y-full` / `-translate-y-full`) قرار می‌گیرند و wrapper های `max-h-[110px] overflow-hidden` حذف می‌شوند. خودِ درگ جای affinity بصری را می‌گیرد. +- wheel دسکتاپ سرجایش می‌ماند و حالا به‌صورت اسلاید کامل انیمیت می‌شود (transition کلاس موجود). + +### 5. حفظ تمام رفتارهای فعلی +حفاظ `dropdown-open` (شیت باز = بدون درگ)، فوکوس خودکار input سوال فعال، init روی اولین سوال بی‌پاسخ، `alignTop`، اسپیسر 20%، `inert`/`aria`، `touchcancel` → برگشت، چند-لمسی → نادیده. + +## خارج از scope +- تغییر رفتار wheel/کیبورد دسکتاپ، مرور‌سازی horizontal، تغییر auto-focus. +- شیت کد کشور phone (همان 68svh) — طبق خواسته فقط QuestionSheet تغییر کرد. + +## راستی‌آزمایی +- `npx vitest run` + `npx tsc --noEmit` (یا biome). +- تست دستی در مرورگر با viewport موبایل: درگ با ماوس (شبیه‌سازی touch)، wheel، اسکرین‌شات قبل/بعد رها کردن، بررسی مرز rubber-band در اولین/آخرین سوال، باز کردن شیت nationality (تغییر 82svh قبلی) و فوکوس input متنی (حرکت به بالا با کیبورد). \ No newline at end of file diff --git a/src/app/globals.css b/src/app/globals.css index f7fbd93..cafe0f4 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -229,6 +229,48 @@ body[data-page-background="custom"] .app-shell { background-image: var(--page-background-image); } +body.dropdown-open .app-shell { + overflow-y: hidden; +} + +.question-detail-header { + max-height: 120px; + overflow: hidden; + transition: + transform 300ms ease-in-out, + opacity 220ms ease-out, + max-height 300ms ease-in-out, + margin 300ms ease-in-out; +} + +body.question-input-open .app-shell .question-detail-header { + max-height: 0; + margin-top: -80px; + transform: translateY(-150px); + opacity: 0; + pointer-events: none; +} + +body.question-input-open .app-shell .question-snap-list { + padding-top: 0; + padding-bottom: 0; +} + +body.question-input-open + .app-shell + .question-snap-item[aria-current="step"] { + top: 0; + bottom: 0; +} + +body.question-input-open + .app-shell + .question-snap-item[aria-current="step"] + .question-snap-content { + margin-top: 0; + margin-bottom: 0; +} + .page-background-none, .page-background-custom, .page-background-default { 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 8ac165d..8259310 100644 --- a/src/app/questions-list/[slug]/question-detail-client.test.tsx +++ b/src/app/questions-list/[slug]/question-detail-client.test.tsx @@ -76,7 +76,7 @@ describe("QuestionDetailClient Validation", () => { }); (useFormOverviewQuery as any).mockReturnValue({ - data: { version: 1 }, + data: {}, isLoading: false, }); (useFormSectionQuery as any).mockReturnValue({ @@ -292,7 +292,6 @@ describe("QuestionDetailClient Validation", () => { ]); (useFormSectionQuery as any).mockReturnValue({ data: { - version: 1, section: { cards: [] }, answers: {}, section_progress: { completion_percent: 0 }, @@ -322,4 +321,93 @@ describe("QuestionDetailClient Validation", () => { // Profile questions render directly, no start button expect(screen.getByText(/Dynamic ID/)).toBeDefined(); }); + + it("renders a stable profile loading shell while section data is cold", () => { + (convertOverviewToFrontendItems as any).mockReturnValue([ + { + slug: "profile_test", + title: "Overview Profile Title", + questions: [], + }, + ]); + (useFormSectionQuery as any).mockReturnValue({ + data: undefined, + isLoading: true, + }); + (useCattellQuestionsQuery as any).mockReturnValue({ + data: undefined, + isLoading: false, + }); + (useGlasserQuestionsQuery as any).mockReturnValue({ + data: undefined, + isLoading: false, + }); + + const queryClient = new QueryClient({ + defaultOptions: { queries: { retry: false } }, + }); + + render( + + + , + ); + + expect(screen.getByText("Overview Profile Title")).toBeDefined(); + expect(screen.getByRole("status")).toBeDefined(); + expect(screen.getByRole("button", { name: "Close" })).toBeDefined(); + expect(screen.getByRole("button", { name: "Info" })).toBeDefined(); + expect(screen.getByRole("button", { name: "Continue" })).toBeDisabled(); + expect(document.querySelector(".shimmer-bg")).toBeNull(); + }); + + it("uses the route title when the overview is also cold", () => { + (convertOverviewToFrontendItems as any).mockReturnValue([]); + (useFormOverviewQuery as any).mockReturnValue({ + data: undefined, + isLoading: true, + }); + (useFormSectionQuery as any).mockReturnValue({ + data: undefined, + isLoading: true, + }); + (useCattellQuestionsQuery as any).mockReturnValue({ + data: undefined, + isLoading: false, + }); + (useGlasserQuestionsQuery as any).mockReturnValue({ + data: undefined, + isLoading: false, + }); + + const queryClient = new QueryClient({ + defaultOptions: { queries: { retry: false } }, + }); + + render( + + + , + ); + + expect(screen.getByText("Route Fallback Title")).toBeDefined(); + expect(screen.getByRole("status")).toBeDefined(); + expect(document.querySelector(".shimmer-bg")).toBeNull(); + }); }); diff --git a/src/app/questions-list/[slug]/question-detail-client.tsx b/src/app/questions-list/[slug]/question-detail-client.tsx index f9c924e..44f5864 100644 --- a/src/app/questions-list/[slug]/question-detail-client.tsx +++ b/src/app/questions-list/[slug]/question-detail-client.tsx @@ -2,6 +2,8 @@ import { useRouter } from "next/navigation"; import { useEffect, useMemo, useState } from "react"; +import Button from "@/components/Componentes/button"; +import { FixToTheEnd } from "@/components/Componentes/fix-to-the-end"; import { PageLoadingSkeleton } from "@/components/Componentes/page-loading-skeleton"; import NavigationButton from "@/components/Componentes/navigation-button"; import { PageBackground } from "@/components/Componentes/page-background"; @@ -334,11 +336,56 @@ export default function QuestionDetailClient({ }, [isSchemaLoading, item, questionsListHref, router]); if (isSchemaLoading) { + if (!isAssessment) { + const loadingTitle = overviewItem?.title || title; + + return ( + <> + +
+ +
+ router.push(questionsListHref)} + /> +

+ {loadingTitle} +

+ +
+
+ +
+ +
+ + + + +
+ + ); + } + return ( - + ); } else if (!item) { return null; @@ -480,7 +527,10 @@ export default function QuestionDetailClient({ <>
- +
- +
(null); + const syncPromiseRef = useRef | null>(null); + const syncPendingAnswers = useCallback(async () => { if (!overview) return; - const pendingSections: Array<{ - storageKey: string; - storedValue: { - current_step: number; - fields: MarriageField[]; - pending_keys: string[]; - pending_sync: boolean; - }; - fields: MarriageField[]; - slug: string; - }> = []; - for (const item of questionListItems) { - if ( - item.slug === "personality_test" || - item.slug === "glasser_5_needs_test" - ) { - continue; - } - 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; - const pendingKeys = new Set( - Array.isArray(storedValue.pending_keys) - ? storedValue.pending_keys - : storedValue.fields.map((field: { key: string }) => field.key), - ); - const pendingFields = storedValue.fields.filter( - (field: { key: string }) => pendingKeys.has(field.key), - ); - if (pendingFields.length === 0) continue; - pendingSections.push({ - storageKey, - storedValue, - fields: pendingFields, - slug: item.slug, - }); + if (syncPromiseRef.current) { + return syncPromiseRef.current; } - if (pendingSections.length === 0) return; - const result = await updateMarriageSectionData(pendingSections[0].slug, { - version: overview.version, - current_step: pendingSections[0].storedValue.current_step, - fields: pendingSections.flatMap((section) => section.fields), - }); - applyProfilePatchResultToCache(queryClient, locale, result); - const cleared = new Set(result.cleared_answer_ids ?? []); + const task = (async () => { + const pendingSections: Array<{ + storageKey: string; + storedValue: { + current_step: number; + fields: MarriageField[]; + pending_keys: string[]; + pending_sync: boolean; + }; + fields: MarriageField[]; + slug: string; + }> = []; + for (const item of questionListItems) { + if ( + item.slug === "personality_test" || + item.slug === "glasser_5_needs_test" + ) { + continue; + } + const storageKey = getQuestionAnswersStorageKey(item.slug); + const rawValue = window.localStorage.getItem(storageKey); + if (!rawValue) continue; - for (const { storageKey, storedValue } of pendingSections) { - storedValue.fields = storedValue.fields.filter( - (field: { key: string }) => !cleared.has(field.key), - ); - storedValue.pending_sync = false; - storedValue.pending_keys = []; - if (storedValue.fields.length === 0) { - window.localStorage.removeItem(storageKey); - } else { - window.localStorage.setItem(storageKey, JSON.stringify(storedValue)); + let storedValue: any; + try { + storedValue = JSON.parse(rawValue); + } catch { + continue; + } + + if (!storedValue || !storedValue.pending_sync || !Array.isArray(storedValue.fields)) { + continue; + } + const pendingKeys = new Set( + Array.isArray(storedValue.pending_keys) + ? storedValue.pending_keys + : storedValue.fields.map((field: { key: string }) => field.key), + ); + const pendingFields = storedValue.fields.filter( + (field: { key: string }) => pendingKeys.has(field.key), + ); + if (pendingFields.length === 0) continue; + pendingSections.push({ + storageKey, + storedValue, + fields: pendingFields, + slug: item.slug, + }); } + + if (pendingSections.length === 0) return; + const result = await updateMarriageSectionData(pendingSections[0].slug, { + current_step: pendingSections[0].storedValue.current_step, + fields: pendingSections.flatMap((section) => section.fields), + }); + applyProfilePatchResultToCache(queryClient, locale, result); + const cleared = new Set(result.cleared_answer_ids ?? []); + + for (const { storageKey, storedValue } of pendingSections) { + storedValue.fields = storedValue.fields.filter( + (field: { key: string }) => !cleared.has(field.key), + ); + storedValue.pending_sync = false; + storedValue.pending_keys = []; + if (storedValue.fields.length === 0) { + window.localStorage.removeItem(storageKey); + } else { + window.localStorage.setItem(storageKey, JSON.stringify(storedValue)); + } + } + })(); + + syncPromiseRef.current = task; + try { + await task; + setIsSyncError(false); + } finally { + syncPromiseRef.current = null; } }, [locale, overview, queryClient, questionListItems]); @@ -292,7 +316,7 @@ export default function QuestionsListPage() { ) return; void queryClient.prefetchQuery({ - queryKey: ["marriage", "form-section", "profile", item.slug, locale], + queryKey: marriageQueryKeys.formSection("profile", item.slug, locale), queryFn: () => getFormSection("profile", item.slug, locale), staleTime: 30 * 1000, }); @@ -316,13 +340,11 @@ export default function QuestionsListPage() { .catch(() => undefined) .then(() => queryClient.fetchQuery({ - queryKey: [ - "marriage", - "form-section", + queryKey: marriageQueryKeys.formSection( "profile", item.slug, locale, - ], + ), queryFn: () => getFormSection("profile", item.slug, locale), staleTime: 30 * 1000, }), @@ -335,66 +357,62 @@ export default function QuestionsListPage() { const prefetchQueueStarted = useRef(false); useEffect(() => { if (prefetchQueueStarted.current || !overview) return; - const likelySections = questionListItems + const profileSections = questionListItems .filter( (item) => - item.required && - (sectionProgressBySlug.get(item.slug) ?? item.progress) < 100, + item.slug !== "personality_test" && + item.slug !== "glasser_5_needs_test", ) - .slice(0, 2); - if (likelySections.length === 0) return; + .sort((first, second) => { + const firstPriority = + first.required && + (sectionProgressBySlug.get(first.slug) ?? first.progress) < 100 + ? 0 + : 1; + const secondPriority = + second.required && + (sectionProgressBySlug.get(second.slug) ?? second.progress) < 100 + ? 0 + : 1; + return firstPriority - secondPriority; + }); + if (profileSections.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", + void prefetchSectionsWithBoundedConcurrency( + profileSections, + (item) => + queryClient.fetchQuery({ + queryKey: marriageQueryKeys.formSection( "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); + }), + () => cancelled, + ); 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)); + void syncPendingAnswers().catch((err) => { + console.warn("Background draft sync:", err); + }); const handleOnline = () => { - void syncPendingAnswers().catch(() => setIsSyncError(true)); + void syncPendingAnswers().catch((err) => { + console.warn("Background draft sync on online:", err); + }); }; window.addEventListener("online", handleOnline); return () => window.removeEventListener("online", handleOnline); }, [syncPendingAnswers]); useEffect(() => { - if (startMatchMutation.isError || isSyncError) { + if (startMatchMutation.isError) { setToastMessage( t[ "Sending the match request failed. Please check your connection and try again." @@ -403,10 +421,7 @@ export default function QuestionsListPage() { } }, [ startMatchMutation.isError, - isSyncError, - t[ - "Sending the match request failed. Please check your connection and try again." - ], + t, ]); const handleCloseToast = () => { diff --git a/src/app/questions-list/section-prefetch.test.ts b/src/app/questions-list/section-prefetch.test.ts new file mode 100644 index 0000000..472d150 --- /dev/null +++ b/src/app/questions-list/section-prefetch.test.ts @@ -0,0 +1,53 @@ +import { describe, expect, it, vi } from "vitest"; +import type { QuestionListItem } from "@/lib/schema-adapter"; +import { prefetchSectionsWithBoundedConcurrency } from "./section-prefetch"; + +function section(slug: string): QuestionListItem { + return { slug } as QuestionListItem; +} + +describe("prefetchSectionsWithBoundedConcurrency", () => { + it("starts promptly and runs at most two section requests at once", async () => { + const sections = [section("one"), section("two"), section("three")]; + const releases: Array<() => void> = []; + let activeRequests = 0; + let maximumActiveRequests = 0; + const prefetch = vi.fn(async () => { + activeRequests += 1; + maximumActiveRequests = Math.max(maximumActiveRequests, activeRequests); + await new Promise((resolve) => releases.push(resolve)); + activeRequests -= 1; + }); + + const queue = prefetchSectionsWithBoundedConcurrency( + sections, + prefetch, + () => false, + ); + + expect(prefetch).toHaveBeenCalledTimes(2); + expect(maximumActiveRequests).toBe(2); + + releases.shift()?.(); + await vi.waitFor(() => expect(prefetch).toHaveBeenCalledTimes(3)); + expect(maximumActiveRequests).toBe(2); + + releases.splice(0).forEach((release) => release()); + await queue; + }); + + it("continues after a background request fails", async () => { + const prefetch = vi + .fn<(item: QuestionListItem) => Promise>() + .mockRejectedValueOnce(new Error("offline")) + .mockResolvedValue(undefined); + + await prefetchSectionsWithBoundedConcurrency( + [section("one"), section("two"), section("three")], + prefetch, + () => false, + ); + + expect(prefetch).toHaveBeenCalledTimes(3); + }); +}); diff --git a/src/app/questions-list/section-prefetch.ts b/src/app/questions-list/section-prefetch.ts new file mode 100644 index 0000000..ac15939 --- /dev/null +++ b/src/app/questions-list/section-prefetch.ts @@ -0,0 +1,24 @@ +import type { QuestionListItem } from "@/lib/schema-adapter"; + +export async function prefetchSectionsWithBoundedConcurrency( + sections: readonly QuestionListItem[], + prefetch: (section: QuestionListItem) => Promise, + isCancelled: () => boolean, +) { + let nextSectionIndex = 0; + + const runWorker = async () => { + while (!isCancelled()) { + const section = sections[nextSectionIndex++]; + if (!section) return; + + try { + await prefetch(section); + } catch { + // Interaction or navigation can retry a failed background prefetch. + } + } + }; + + await Promise.all([runWorker(), runWorker()]); +} diff --git a/src/components/Componentes/question-answer-storage.tsx b/src/components/Componentes/question-answer-storage.tsx index 3329edb..bcf3019 100644 --- a/src/components/Componentes/question-answer-storage.tsx +++ b/src/components/Componentes/question-answer-storage.tsx @@ -61,7 +61,6 @@ type QuestionAnswersProviderProps = { questions: readonly QuestionField[]; slug: string; locale?: string; - schemaVersion?: number; }; const QuestionAnswersContext = @@ -303,7 +302,6 @@ export function QuestionAnswersProvider({ questions, slug, locale = "en", - schemaVersion = 1, }: QuestionAnswersProviderProps) { const storageKey = useMemo(() => getQuestionAnswersStorageKey(slug), [slug]); const queryClient = useQueryClient(); @@ -319,7 +317,6 @@ export function QuestionAnswersProvider({ const storageKeyRef = useRef(storageKey); const slugRef = useRef(slug); const backendFieldsRef = useRef([]); - const schemaVersionRef = useRef(schemaVersion); const dirtyKeysRef = useRef(new Set()); const { data: profile } = useMarriageProfileQuery(); @@ -334,16 +331,11 @@ 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, ]); @@ -453,10 +445,16 @@ export function QuestionAnswersProvider({ question.type === "textarea" || question.type === "number"; - const delay = isTextLike ? 1000 : 0; + // The date wheel applies its value on every scroll settle; debounce + // it like text inputs so each spin does not fire a PATCH. + const isDebounced = isTextLike || question.type === "date"; + + const delay = isDebounced ? 1000 : 0; syncTimeoutRef.current = setTimeout(() => { - void flushAnswersRef.current(); + void flushAnswersRef.current().catch(() => { + // The draft stays pending so the next edit or exit can retry. + }); }, delay); return nextAnswers; @@ -491,7 +489,6 @@ export function QuestionAnswersProvider({ const payload = { ...fullPayload, fields: pendingFields, - version: schemaVersionRef.current, }; const revision = answersRevisionRef.current; @@ -500,9 +497,6 @@ export function QuestionAnswersProvider({ } const request = mutateAsync(payload).then((result) => { - if (result?.version) { - schemaVersionRef.current = result.version; - } const cleared = new Set(result?.cleared_answer_ids ?? []); const nextAnswers = { ...answersRef.current }; const questionById = new Map( @@ -621,7 +615,6 @@ export function QuestionAnswersProvider({ fetch(getKeepalivePatchUrl(slugRef.current), { body: JSON.stringify({ - version: schemaVersionRef.current, answers: answersPayload, }), credentials: "include", diff --git a/src/components/Componentes/question-birthplace.tsx b/src/components/Componentes/question-birthplace.tsx index 8c6337c..ddc9caa 100644 --- a/src/components/Componentes/question-birthplace.tsx +++ b/src/components/Componentes/question-birthplace.tsx @@ -1,12 +1,16 @@ "use client"; import { useCallback, useEffect, useRef, useState } from "react"; +import { createPortal } from "react-dom"; import { getCountryList, COUNTRIES_EN, COUNTRIES_FA } from "@/data/countries"; import type { QuestionField } from "@/lib/schema-adapter"; import { useI18n } from "@/translations/provider"; import { useQuestionAnswers } from "./question-answer-storage"; import QuestionTitle from "./question-title"; import { LoadingThreeDot } from "./loading-three-dot"; +import { useSheetScrollLock } from "./use-sheet-scroll-lock"; + +const EXIT_ANIMATION_MS = 220; type QuestionBirthplaceProps = { question: QuestionField; @@ -88,10 +92,12 @@ export function QuestionBirthplace({ const [cityInput, setCityInput] = useState(initial.city); const [isOpen, setIsOpen] = useState(false); + const [isClosing, setIsClosing] = useState(false); const [searchQuery, setSearchQuery] = useState(""); - const containerRef = useRef(null); - const listRef = useRef(null); const searchInputRef = useRef(null); + const cityInputRef = useRef(null); + const listRef = useRef(null); + const isMountedRef = useRef(true); const isResidence = question.ui_config?.enable_geoip === true; @@ -99,6 +105,57 @@ export function QuestionBirthplace({ const [isDetecting, setIsDetecting] = useState(false); const [detectedLocation, setDetectedLocation] = useState(""); + useSheetScrollLock(isOpen); + + useEffect(() => { + isMountedRef.current = true; + return () => { + isMountedRef.current = false; + }; + }, []); + + const closeSheet = useCallback(() => { + if (isClosing) return; + setIsClosing(true); + window.setTimeout(() => { + if (isMountedRef.current) { + setIsOpen(false); + setIsClosing(false); + setSearchQuery(""); + } + }, EXIT_ANIMATION_MS); + }, [isClosing]); + + const openSheet = useCallback(() => { + if (disabled) return; + setIsOpen(true); + setIsClosing(false); + }, [disabled]); + + // Handle escape key + useEffect(() => { + if (!isOpen) return; + + const handleKeyDown = (e: KeyboardEvent) => { + if (e.key === "Escape") { + closeSheet(); + } + }; + + window.addEventListener("keydown", handleKeyDown); + return () => window.removeEventListener("keydown", handleKeyDown); + }, [isOpen, closeSheet]); + + // Auto focus search input when sheet opens + useEffect(() => { + if (isOpen && !isClosing) { + const timer = setTimeout(() => { + searchInputRef.current?.focus(); + }, 100); + return () => clearTimeout(timer); + } + }, [isOpen, isClosing]); + const updateAnswers = (country: string, city: string) => { const formatted = city && country ? `${city}, ${country}` : city || country || null; @@ -199,9 +256,12 @@ export function QuestionBirthplace({ localStorage.setItem("hasCheckedGeoIPResidence", "true"); } setMode("manual"); - setSelectedCountry(""); - setCityInput(""); - updateAnswers("", ""); + const parsed = parseValue(rawValue); + const country = selectedCountry || parsed.country; + const city = cityInput || parsed.city; + setSelectedCountry(country); + setCityInput(city); + updateAnswers(country, city); }; // Synchronize state if rawValue changes externally @@ -218,62 +278,6 @@ export function QuestionBirthplace({ } }, [rawValue]); - // Lock page scroll when dropdown is open - useEffect(() => { - if (isOpen) { - document.body.classList.add("dropdown-open"); - document.body.style.overflow = "hidden"; - document.documentElement.style.overflow = "hidden"; - } else { - document.body.classList.remove("dropdown-open"); - document.body.style.overflow = ""; - document.documentElement.style.overflow = ""; - } - return () => { - document.body.classList.remove("dropdown-open"); - document.body.style.overflow = ""; - document.documentElement.style.overflow = ""; - }; - }, [isOpen]); - - useEffect(() => { - if (isOpen) { - const timer = setTimeout(() => { - searchInputRef.current?.focus(); - }, 50); - return () => clearTimeout(timer); - } - }, [isOpen]); - - const handleListWheel = useCallback((e: React.WheelEvent) => { - e.stopPropagation(); - const el = listRef.current; - if (!el) return; - - const atTop = el.scrollTop <= 0 && e.deltaY < 0; - const atBottom = - el.scrollTop + el.clientHeight >= el.scrollHeight && e.deltaY > 0; - - if (atTop || atBottom) { - e.preventDefault(); - } - }, []); - - useEffect(() => { - function handleClickOutside(event: MouseEvent) { - if ( - containerRef.current && - !containerRef.current.contains(event.target as Node) - ) { - setIsOpen(false); - } - } - document.addEventListener("mousedown", handleClickOutside); - return () => { - document.removeEventListener("mousedown", handleClickOutside); - }; - }, []); - const options = getCountryList(locale); const filteredOptions = options.filter((option) => option.toLowerCase().includes(searchQuery.toLowerCase()), @@ -284,9 +288,11 @@ export function QuestionBirthplace({ localStorage.setItem("hasCheckedGeoIPResidence", "true"); } setSelectedCountry(country); - setCityInput(""); - setIsOpen(false); - updateAnswers(country, ""); + closeSheet(); + updateAnswers(country, cityInput); + window.setTimeout(() => { + cityInputRef.current?.focus({ preventScroll: true }); + }, EXIT_ANIMATION_MS); }; const handleCityChange = (e: React.ChangeEvent) => { @@ -304,9 +310,24 @@ export function QuestionBirthplace({ question.extras?.placeHolder || (isRtl ? "شهر، منطقه یا محله" : "City, region, or neighborhood"); + const searchPlaceholder = + locale === "fa" + ? "جستجو..." + : locale === "ar" + ? "بحث..." + : locale === "tr" + ? "Ara..." + : "Search..."; + + const noResultsText = + locale === "fa" + ? "موردی یافت نشد" + : locale === "ar" + ? "لم يتم العثور على نتائج" + : "No options found"; + return (
- {/* Country Selection Dropdown */} + {/* Country Selection Trigger */}
- - {isOpen && ( -
- {options.length > 3 || searchQuery ? ( -
- - - - - setSearchQuery(e.target.value)} - placeholder="Search..." - className="flex-1 bg-transparent text-[14px] font-medium text-[#181818] outline-none placeholder:text-[#98A2B3]" - /> - {searchQuery ? ( - - ) : null} -
- ) : null} - -
e.stopPropagation()} - onTouchMove={(e) => e.stopPropagation()} - onTouchEnd={(e) => e.stopPropagation()} - className="flex max-h-[220px] flex-col gap-3.5 overflow-y-auto overscroll-contain pr-1" - > - {filteredOptions.length > 0 ? ( - filteredOptions.map((option) => { - const isSelected = selectedCountry === option; - - return ( - - ); - }) - ) : ( - - No options found - - )} -
-
- )}
{/* City Text Input */}
) : ( <> - {/* 1. Country Selection Dropdown */} + {/* 1. Country Selection Trigger */}
- - {isOpen && ( -
- {options.length > 3 || searchQuery ? ( -
- - - - - setSearchQuery(e.target.value)} - placeholder="Search..." - className="flex-1 bg-transparent text-[14px] font-medium text-[#181818] outline-none placeholder:text-[#98A2B3]" - /> - {searchQuery ? ( - - ) : null} -
- ) : null} - -
e.stopPropagation()} - onTouchMove={(e) => e.stopPropagation()} - onTouchEnd={(e) => e.stopPropagation()} - className="flex max-h-[220px] flex-col gap-3.5 overflow-y-auto overscroll-contain pr-1" - > - {filteredOptions.length > 0 ? ( - filteredOptions.map((option) => { - const isSelected = selectedCountry === option; - - return ( - - ); - }) - ) : ( - - No options found - - )} -
-
- )}
{/* 2. City Text Input */}
)} + + {/* Country Selection Bottom Sheet Modal */} + {isOpen && + createPortal( +
event.stopPropagation()} + onTouchStart={(event) => event.stopPropagation()} + onTouchMove={(event) => event.stopPropagation()} + onTouchEnd={(event) => event.stopPropagation()} + onClick={(e) => { + if (e.target === e.currentTarget) { + closeSheet(); + } + }} + > +
e.stopPropagation()} + > + {/* Drag Handle Notch */} +
+
+
+ + {/* Header with Title and Close Button */} +
+

+ {selectCountryPlaceholder} +

+ +
+ + {/* Search Bar */} +
+
+ + setSearchQuery(e.target.value)} + placeholder={searchPlaceholder} + className="flex-1 bg-transparent text-[14px] font-medium text-[#181818] outline-none placeholder:text-[#98A2B3]" + /> + {searchQuery ? ( + + ) : null} +
+
+ + {/* Country Options List */} +
+ {filteredOptions.length > 0 ? ( + filteredOptions.map((option) => { + const isSelected = selectedCountry === option; + + return ( + + ); + }) + ) : ( +
+ {noResultsText} +
+ )} +
+
+
, + document.body, + )}
); } diff --git a/src/components/Componentes/question-date-sheet.tsx b/src/components/Componentes/question-date-sheet.tsx new file mode 100644 index 0000000..6b9514e --- /dev/null +++ b/src/components/Componentes/question-date-sheet.tsx @@ -0,0 +1,348 @@ +"use client"; + +import { + type ReactNode, + useCallback, + useEffect, + useMemo, + useRef, + useState, +} from "react"; +import { createPortal } from "react-dom"; +import type { QuestionField } from "@/lib/schema-adapter"; +import { useI18n } from "@/translations/provider"; +import { useSheetScrollLock } from "./use-sheet-scroll-lock"; + +const EXIT_ANIMATION_MS = 300; +const WHEEL_ITEM_HEIGHT = 48; +const WHEEL_EDGE_PADDING = 108; +const SCROLL_SETTLE_MS = 90; + +const MONTH_VALUES = [ + "01", + "02", + "03", + "04", + "05", + "06", + "07", + "08", + "09", + "10", + "11", + "12", +]; + +const MIN_AGE = 18; +const currentYear = new Date().getFullYear(); +const maxBirthYear = currentYear - MIN_AGE; +const YEARS = Array.from({ length: 80 }, (_, i) => + (maxBirthYear - i).toString(), +); + +function daysInMonth(year: string, month: string): number { + const y = Number.parseInt(year, 10); + const m = Number.parseInt(month, 10); + if (!y || !m) return 31; + return new Date(y, m, 0).getDate(); +} + +type DatePart = "day" | "month" | "year"; + +type QuestionDateSheetProps = { + question: QuestionField; + value: string; + onApply: (formattedDate: string) => void; + onClose: () => void; +}; + +function WheelColumn({ + ariaLabel, + items, + selectedIndex, + onSelect, +}: { + ariaLabel: string; + items: Array<{ value: string; label: ReactNode }>; + selectedIndex: number; + onSelect: (index: number) => void; +}) { + const listRef = useRef(null); + const settleTimeoutRef = useRef(null); + const initialScrollDoneRef = useRef(false); + + useEffect(() => { + const frame = window.requestAnimationFrame(() => { + const list = listRef.current; + if (!list || initialScrollDoneRef.current) return; + initialScrollDoneRef.current = true; + list.scrollTop = selectedIndex * WHEEL_ITEM_HEIGHT; + }); + return () => window.cancelAnimationFrame(frame); + }, []); + + useEffect(() => { + return () => { + if (settleTimeoutRef.current !== null) { + window.clearTimeout(settleTimeoutRef.current); + } + }; + }, []); + + const handleScroll = useCallback(() => { + const list = listRef.current; + if (!list) return; + if (settleTimeoutRef.current !== null) { + window.clearTimeout(settleTimeoutRef.current); + } + settleTimeoutRef.current = window.setTimeout(() => { + settleTimeoutRef.current = null; + const index = Math.max( + 0, + Math.min( + items.length - 1, + Math.round(list.scrollTop / WHEEL_ITEM_HEIGHT), + ), + ); + if (index !== selectedIndex) { + onSelect(index); + } + }, SCROLL_SETTLE_MS); + }, [items.length, onSelect, selectedIndex]); + + return ( +
event.stopPropagation()} + onTouchMove={(event) => event.stopPropagation()} + onTouchEnd={(event) => event.stopPropagation()} + className="h-[264px] flex-1 snap-y snap-mandatory overflow-y-auto overscroll-contain [scrollbar-width:none] [&::-webkit-scrollbar]:hidden" + > + {showInvalidState ? ( @@ -655,90 +623,146 @@ export function QuestionPhone({ ) : null} - {/* Dropdown Options Panel */} - {isOpen && ( -
- {/* Search Input Bar */} -
- - - - - setSearchQuery(e.target.value)} - placeholder={ - locale === "fa" - ? "جستجوی کشور یا پیش‌شماره..." - : "Search country or dial code..." - } - className="flex-1 bg-transparent text-[14px] font-medium text-[#181818] outline-none placeholder:text-[#98A2B3]" - /> - {searchQuery ? ( - - ) : null} -
- - {/* Country List */} + {/* The phone field moves up while the country sheet enters from below. */} + {isOpen && + createPortal(
{ + if (event.key === "Escape") closeSheet(); + }} + onClick={(event) => { + if (event.target === event.currentTarget) closeSheet(); + }} > - {filteredCountries.length > 0 ? ( - filteredCountries.map((c) => { - return ( - - ); - }) - ) : ( - - {locale === "fa" ? "موردی یافت نشد" : "No options found"} - - )} -
-
- )} + + + + setSearchQuery(e.target.value)} + aria-label={selectCountryTitle} + placeholder={ + locale === "fa" + ? "جستجوی کشور یا پیش‌شماره..." + : "Search country or dial code..." + } + className="flex-1 bg-transparent text-[14px] font-medium text-[#181818] outline-none placeholder:text-[#98A2B3]" + /> + {searchQuery ? ( + + ) : null} +
+
+ +
event.stopPropagation()} + onTouchMove={(event) => event.stopPropagation()} + onTouchEnd={(event) => event.stopPropagation()} + className="flex min-h-0 flex-1 flex-col overflow-y-auto overscroll-contain px-5" + > + {filteredCountries.length > 0 ? ( + filteredCountries.map((c) => { + return ( + + ); + }) + ) : ( + + {locale === "fa" ? "موردی یافت نشد" : "No options found"} + + )} +
+ +
, + document.body, + )}
); } diff --git a/src/components/Componentes/question-renderer.tsx b/src/components/Componentes/question-renderer.tsx index f9843b1..dcf20c7 100644 --- a/src/components/Componentes/question-renderer.tsx +++ b/src/components/Componentes/question-renderer.tsx @@ -5,7 +5,7 @@ import QuestionBirthplace from "./question-birthplace"; import QuestionButton from "./question-button"; import QuestionCheckbox from "./question-checkbox"; import QuestionDate from "./question-date"; -import QuestionDropdown from "./question-dropdown"; +import QuestionSheet from "./question-sheet"; import QuestionFile from "./question-file"; import QuestionNumber from "./question-number"; import QuestionPhone from "./question-phone"; @@ -62,7 +62,7 @@ export function QuestionRenderer({ ); case "dropdown": return ( - diff --git a/src/components/Componentes/question-sheet.test.tsx b/src/components/Componentes/question-sheet.test.tsx new file mode 100644 index 0000000..6f7ae67 --- /dev/null +++ b/src/components/Componentes/question-sheet.test.tsx @@ -0,0 +1,182 @@ +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { + cleanup, + fireEvent, + render, + screen, + waitFor, +} from "@testing-library/react"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import type { QuestionField } from "@/lib/schema-adapter"; +import { QuestionAnswersProvider } from "./question-answer-storage"; +import { QuestionSheet } from "./question-sheet"; + +vi.mock("@/translations/provider", () => ({ + useI18n: vi.fn(() => ({ locale: "fa", dictionary: { Confirm: "تایید" } })), +})); + +vi.mock("@/hooks/marriage/use-section-data", () => ({ + applyProfilePatchResultToCache: vi.fn(), + useMarriageSectionDataQuery: vi.fn(() => ({ + data: undefined, + isLoading: false, + })), + useUpdateMarriageSectionDataMutation: vi.fn(() => ({ + mutateAsync: vi.fn().mockResolvedValue({}), + isPending: false, + })), +})); + +describe("QuestionSheet component", () => { + afterEach(() => { + cleanup(); + document.body.classList.remove("dropdown-open"); + document.body.classList.remove("question-input-open"); + }); + + const queryClient = new QueryClient({ + defaultOptions: { queries: { retry: false } }, + }); + + it("renders trigger and opens bottom sheet on click", async () => { + const qDropdown = { + id: "q_nat", + title: "تابعیت و شهروندی", + type: "dropdown", + order: 1, + required: true, + isVisible: true, + description: "", + tooltip: "", + extras: { placeHolder: "انتخاب کشور" }, + options: [ + { id: "iran", value: "Iran", label: "ایران", order: 1 }, + { id: "germany", value: "Germany", label: "آلمان", order: 2 }, + { id: "canada", value: "Canada", label: "کانادا", order: 3 }, + ], + ui_config: {}, + } as QuestionField; + + render( +
+ + + + + +
, + ); + + // Initial placeholder displayed in trigger + expect(screen.getByText("انتخاب کشور")).toBeDefined(); + + // Click trigger to open bottom sheet + const trigger = screen.getByRole("button", { name: /انتخاب کشور/i }); + fireEvent.click(trigger); + + // Bottom sheet dialog should be visible + expect(screen.getByRole("dialog")).toBeDefined(); + expect(document.body.classList.contains("dropdown-open")).toBe(true); + expect(document.body.classList.contains("question-input-open")).toBe(true); + expect( + (document.querySelector(".app-shell") as HTMLElement).style.overflowY, + ).toBe("hidden"); + expect(screen.getByRole("button", { name: "ایران" })).toBeDefined(); + expect(screen.getByRole("button", { name: "آلمان" })).toBeDefined(); + + // Select Iran + const iranBtn = screen.getByRole("button", { name: "ایران" }); + fireEvent.click(iranBtn); + + // Trigger should now show Iran + await waitFor(() => { + expect(screen.getByText("ایران")).toBeDefined(); + expect(document.body.classList.contains("dropdown-open")).toBe(false); + expect(document.body.classList.contains("question-input-open")).toBe( + false, + ); + expect( + (document.querySelector(".app-shell") as HTMLElement).style.overflowY, + ).toBe(""); + }); + }); + + it("supports multi-selection with confirm button", async () => { + const qMulti = { + id: "q_lang", + title: "سایر زبان‌ها", + type: "dropdown", + order: 2, + required: false, + isVisible: true, + description: "", + tooltip: "", + extras: { placeHolder: "انتخاب زبان‌ها", range: [0, 5] }, + options: [ + { id: "en", value: "English", label: "انگلیسی", order: 1 }, + { id: "ar", value: "Arabic", label: "عربی", order: 2 }, + { id: "fr", value: "French", label: "فرانسوی", order: 3 }, + ], + ui_config: {}, + } as QuestionField; + + render( + + + + + , + ); + + // Click trigger + const trigger = screen.getByRole("button", { name: /انتخاب زبان‌ها/i }); + fireEvent.click(trigger); + + // Select English and French + const enBtn = screen.getByRole("button", { name: "انگلیسی" }); + const frBtn = screen.getByRole("button", { name: "فرانسوی" }); + fireEvent.click(enBtn); + fireEvent.click(frBtn); + + // Confirm button should show count + const confirmBtn = screen.getByRole("button", { name: /تایید \(2\)/i }); + expect(confirmBtn).toBeDefined(); + fireEvent.click(confirmBtn); + + // Trigger displays selected items + await waitFor(() => { + expect(screen.getByText("انگلیسی, فرانسوی")).toBeDefined(); + }); + }); + + it("closes the inline selector with Escape", async () => { + const question = { + id: "q_drag", + title: "کشور محل اقامت", + type: "dropdown", + required: true, + extras: { placeHolder: "انتخاب کشور" }, + options: [{ id: "iran", value: "Iran", label: "ایران", order: 1 }], + ui_config: {}, + } as QuestionField; + + render( + + + + + , + ); + + fireEvent.click(screen.getByRole("button", { name: /انتخاب کشور/i })); + fireEvent.keyDown(screen.getByRole("dialog"), { key: "Escape" }); + + await waitFor(() => { + expect(screen.queryByRole("dialog")).toBeNull(); + expect(document.body.classList.contains("dropdown-open")).toBe(false); + expect(document.body.classList.contains("question-input-open")).toBe( + false, + ); + }); + }); +}); diff --git a/src/components/Componentes/question-sheet.tsx b/src/components/Componentes/question-sheet.tsx new file mode 100644 index 0000000..bf8fc8d --- /dev/null +++ b/src/components/Componentes/question-sheet.tsx @@ -0,0 +1,416 @@ +"use client"; + +import { useCallback, useEffect, useRef, useState } from "react"; +import { createPortal } from "react-dom"; +import type { QuestionField } from "@/lib/schema-adapter"; +import { useI18n } from "@/translations/provider"; +import { Button } from "./button"; +import { ExplanationUiFont } from "./explanation-ui-font"; +import { useQuestionAnswers } from "./question-answer-storage"; +import QuestionTitle from "./question-title"; +import { useSheetScrollLock } from "./use-sheet-scroll-lock"; + +const EXIT_ANIMATION_MS = 300; + +export type QuestionSheetProps = { + question: QuestionField; + disabled?: boolean; +}; + +export function QuestionSheet({ question, disabled }: QuestionSheetProps) { + const { dictionary: t, locale } = useI18n(); + const { getAnswerValue, setAnswerValue } = useQuestionAnswers(); + 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 + ? [rawValue] + : []; + const singleValue = typeof rawValue === "string" ? rawValue : ""; + + const [isOpen, setIsOpen] = useState(false); + const [isClosing, setIsClosing] = useState(false); + const [searchQuery, setSearchQuery] = useState(""); + const searchInputRef = useRef(null); + const listRef = useRef(null); + const closeSheet = useCallback(() => { + setIsClosing(true); + window.setTimeout(() => { + setIsOpen(false); + setIsClosing(false); + setSearchQuery(""); + }, EXIT_ANIMATION_MS); + }, []); + + const openSheet = useCallback(() => { + if (disabled) return; + setIsOpen(true); + setIsClosing(false); + }, [disabled]); + + useSheetScrollLock(isOpen); + + // Handle escape key + useEffect(() => { + if (!isOpen) return; + + const handleKeyDown = (e: KeyboardEvent) => { + if (e.key === "Escape") { + closeSheet(); + } + }; + + window.addEventListener("keydown", handleKeyDown); + return () => window.removeEventListener("keydown", handleKeyDown); + }, [isOpen, closeSheet]); + + const options = question.options || []; + + const showSearch = (() => { + if (question.extras?.noSearch) return false; + return options.length > 5; + })(); + + const filteredOptions = options.filter((option) => + option.label.toLowerCase().includes(searchQuery.toLowerCase()), + ); + + const getCleanLabel = (optId: string) => { + const opt = options.find((o) => o.id === optId); + if (!opt) return optId; + return opt.label.split(" - ")[0]; + }; + + const isRtl = locale === "fa" || locale === "ar" || locale === "ur"; + const defaultPlaceholder = isRtl ? "انتخاب کنید" : "Select"; + + const displayLabel = isMulti + ? selectedList.length > 0 + ? selectedList.map(getCleanLabel).join(", ") + : question.extras?.placeHolder || defaultPlaceholder + : singleValue + ? getCleanLabel(singleValue) + : question.extras?.placeHolder || defaultPlaceholder; + + const hasSelectedValue = isMulti + ? selectedList.length > 0 + : Boolean(singleValue); + + const toggleMultiOption = (optionId: string) => { + let nextValue: string[]; + if (selectedList.includes(optionId)) { + nextValue = selectedList.filter((v) => v !== optionId); + } else { + nextValue = [...selectedList, optionId]; + } + setAnswerValue(question, nextValue.length > 0 ? nextValue : null); + }; + + const handleSelectSingle = (optionId: string) => { + setAnswerValue(question, optionId); + closeSheet(); + }; + + const searchPlaceholder = + locale === "fa" + ? "جستجو..." + : locale === "ar" + ? "بحث..." + : locale === "tr" + ? "Ara..." + : "Search..."; + + const noResultsText = + locale === "fa" + ? "موردی یافت نشد" + : locale === "ar" + ? "لم يتم العثور على نتائج" + : "No options found"; + + const confirmText = t.Confirm || (isRtl ? "تایید" : "Confirm"); + + return ( +
+ + + {/* Select Trigger Button (Input style) */} + + + {/* The active field moves up while the options sheet enters from below. */} + {isOpen && + createPortal( +
{ + if (e.key === "Escape") closeSheet(); + }} + onClick={(event) => { + if (event.target === event.currentTarget) closeSheet(); + }} + > +
+
+

+ {question.title} +

+ +
+ + {/* Search Bar */} + {showSearch && ( +
+
+ + setSearchQuery(e.target.value)} + placeholder={searchPlaceholder} + className="flex-1 bg-transparent text-[14px] font-medium text-[#181818] outline-none placeholder:text-[#98A2B3]" + /> + {searchQuery ? ( + + ) : null} +
+
+ )} + + {/* Options List */} +
event.stopPropagation()} + onTouchMove={(event) => event.stopPropagation()} + onTouchEnd={(event) => event.stopPropagation()} + className="flex min-h-0 flex-1 flex-col gap-2 overflow-y-auto overscroll-contain px-5 py-3" + > + {filteredOptions.length > 0 ? ( + filteredOptions.map((option) => { + const isSelected = isMulti + ? selectedList.includes(option.id) + : singleValue === option.id; + + return ( + + ); + }) + ) : ( +
+ {noResultsText} +
+ )} +
+ + {/* Bottom Actions for Multi-Select */} + {isMulti && ( +
+ +
+ )} +
+
, + document.body, + )} +
+ ); +} + +export default QuestionSheet; diff --git a/src/components/Componentes/question-snap-list.tsx b/src/components/Componentes/question-snap-list.tsx index eed789e..6728f90 100644 --- a/src/components/Componentes/question-snap-list.tsx +++ b/src/components/Componentes/question-snap-list.tsx @@ -11,14 +11,44 @@ import { } from "react"; import { useI18n } from "@/translations/provider"; import { useQuestionProgress } from "./question-progress-tracker"; +import { useQuestionInputFocusSync } from "./use-sheet-scroll-lock"; const WHEEL_GESTURE_IDLE_MS = 320; const TOUCH_MIN_DISTANCE = 8; +const DRAG_ENGAGE_DISTANCE = 10; +const DRAG_COMMIT_RATIO = 0.3; +const DRAG_FLICK_VELOCITY = 0.55; +const SNAP_ANIMATION_MS = 340; +const SNAP_EASE = "cubic-bezier(0.22, 1, 0.36, 1)"; +const RUBBER_BAND_RESISTANCE = 0.4; const AUTO_FOCUS_SELECTOR = [ - "textarea:not([disabled])", - 'input[type="text"]:not([disabled])', - 'input[type="number"]:not([disabled])', + "textarea:not([disabled]):not([data-no-auto-focus])", + 'input[type="text"]:not([disabled]):not([data-no-auto-focus])', + 'input[type="number"]:not([disabled]):not([data-no-auto-focus])', ].join(", "); +const DRAG_IGNORE_SELECTOR = [ + "input", + "textarea", + "select", + '[contenteditable="true"]', + "[data-snap-drag-ignore]", +].join(", "); + +type SnapDragState = { + pointerDown: boolean; + ignored: boolean; + engaged: boolean; + animating: boolean; + baseOffset: number; + startY: number; + lastY: number; + lastMoveTime: number; + velocity: number; + offset: number; + height: number; + trioIndices: number[]; + cleanupTimer: number | null; +}; type QuestionSnapListProps = { children: ReactNode; @@ -41,15 +71,36 @@ export function QuestionSnapList({ }: QuestionSnapListProps) { const { dictionary: t } = useI18n(); const { isCompleted } = useQuestionProgress(); + useQuestionInputFocusSync(); const questions = Children.toArray(children); const wheelLockedRef = useRef(false); const wheelUnlockTimeoutRef = useRef(null); const touchStartYRef = useRef(null); const questionRefs = useRef>([]); + const containerRef = useRef(null); const previousActiveIndexRef = useRef(null); const [activeIndex, setActiveIndex] = useState(0); - + const activeIndexRef = useRef(activeIndex); + activeIndexRef.current = activeIndex; + const questionsCountRef = useRef(questions.length); + questionsCountRef.current = questions.length; + + const dragRef = useRef({ + pointerDown: false, + ignored: false, + engaged: false, + animating: false, + baseOffset: 0, + startY: 0, + lastY: 0, + lastMoveTime: 0, + velocity: 0, + offset: 0, + height: 0, + trioIndices: [], + cleanupTimer: null, + }); const stepQuestion = useCallback( (direction: 1 | -1) => { @@ -223,9 +274,97 @@ export function QuestionSnapList({ if (wheelUnlockTimeoutRef.current !== null) { window.clearTimeout(wheelUnlockTimeoutRef.current); } + if (dragRef.current.cleanupTimer !== null) { + window.clearTimeout(dragRef.current.cleanupTimer); + } }; }, []); + const readTranslateY = useCallback((element: HTMLElement): number => { + const transform = window.getComputedStyle(element).transform; + if (!transform || transform === "none") { + return 0; + } + + try { + return new DOMMatrix(transform).m42; + } catch { + return 0; + } + }, []); + + const styleDragPanel = useCallback( + (element: HTMLDivElement, transform: string, animate: boolean) => { + element.style.transition = animate + ? `transform ${SNAP_ANIMATION_MS}ms ${SNAP_EASE}` + : "none"; + element.style.transform = transform; + element.style.opacity = "1"; + element.style.zIndex = "10"; + }, + [], + ); + + // Positive offset slides the active panel up (towards the next question), + // exactly like following a finger in a reels-style pager. + const applyDragOffset = useCallback( + (offset: number, animate: boolean) => { + const index = activeIndexRef.current; + const height = dragRef.current.height; + + const panels: Array<[HTMLDivElement | null, number]> = [ + [questionRefs.current[index], -offset], + [questionRefs.current[index + 1], height - offset], + [questionRefs.current[index - 1], -height - offset], + ]; + + for (const [element, translateY] of panels) { + if (!element) continue; + styleDragPanel(element, `translateY(${translateY}px)`, animate); + } + }, + [styleDragPanel], + ); + + const clearDragStyles = useCallback(() => { + const drag = dragRef.current; + const index = activeIndexRef.current; + const targets = new Set([ + ...drag.trioIndices, + index - 1, + index, + index + 1, + ]); + for (const position of targets) { + const element = questionRefs.current[position]; + if (!element) continue; + element.style.transition = ""; + element.style.transform = ""; + element.style.opacity = ""; + element.style.zIndex = ""; + } + drag.trioIndices = []; + }, []); + + const snapPanelsTo = useCallback( + (offset: number) => { + const drag = dragRef.current; + const index = activeIndexRef.current; + drag.animating = true; + drag.trioIndices = [index - 1, index, index + 1]; + applyDragOffset(offset, true); + if (drag.cleanupTimer !== null) { + window.clearTimeout(drag.cleanupTimer); + } + drag.cleanupTimer = window.setTimeout(() => { + drag.cleanupTimer = null; + drag.animating = false; + clearDragStyles(); + }, SNAP_ANIMATION_MS + 40); + }, + [applyDragOffset, clearDragStyles], + ); + const handleWheel = useCallback( (event: React.WheelEvent) => { if (document.body.classList.contains("dropdown-open")) { @@ -255,9 +394,152 @@ export function QuestionSnapList({ if (document.body.classList.contains("dropdown-open")) { return; } - touchStartYRef.current = event.touches[0]?.clientY ?? null; + + const drag = dragRef.current; + if (drag.cleanupTimer !== null) { + window.clearTimeout(drag.cleanupTimer); + drag.cleanupTimer = null; + } + + const target = event.target as HTMLElement | null; + drag.ignored = Boolean(target?.closest?.(DRAG_IGNORE_SELECTOR)); + + drag.height = + containerRef.current?.getBoundingClientRect().height ?? + window.innerHeight; + + if (drag.animating) { + // Grabbed mid-snap (or mid wheel transition): freeze the panels + // exactly where they currently are and continue the drag from there. + const activeElement = questionRefs.current[activeIndexRef.current]; + if (activeElement) { + drag.baseOffset = -readTranslateY(activeElement); + applyDragOffset(drag.baseOffset, false); + } else { + drag.baseOffset = 0; + } + drag.animating = false; + drag.engaged = true; + } else { + drag.baseOffset = 0; + drag.engaged = false; + } + + drag.pointerDown = true; + drag.velocity = 0; + drag.offset = drag.baseOffset; + drag.startY = event.touches[0]?.clientY ?? 0; + drag.lastY = drag.startY; + drag.lastMoveTime = event.timeStamp || performance.now(); + touchStartYRef.current = drag.startY; }, - [], + [applyDragOffset, readTranslateY], + ); + + const handleTouchMove = useCallback( + (event: React.TouchEvent) => { + if (document.body.classList.contains("dropdown-open")) { + return; + } + + const drag = dragRef.current; + + if (!drag.pointerDown) { + event.preventDefault(); + return; + } + + if (drag.ignored) { + return; + } + + const y = event.touches[0]?.clientY ?? drag.lastY; + + if (!drag.engaged) { + if (Math.abs(drag.startY - y) < DRAG_ENGAGE_DISTANCE) { + event.preventDefault(); + return; + } + drag.engaged = true; + } + + event.preventDefault(); + + if (event.touches.length > 1) { + drag.engaged = false; + drag.pointerDown = false; + snapPanelsTo(0); + return; + } + + const now = event.timeStamp || performance.now(); + const deltaTime = now - drag.lastMoveTime; + if (deltaTime > 0) { + const instantVelocity = (drag.lastY - y) / deltaTime; + drag.velocity = drag.velocity * 0.72 + instantVelocity * 0.28; + } + drag.lastY = y; + drag.lastMoveTime = now; + + const index = activeIndexRef.current; + const canNext = index < questionsCountRef.current - 1; + const canPrev = index > 0; + let offset = drag.baseOffset + (drag.startY - y); + if (offset > 0 && !canNext) { + offset *= RUBBER_BAND_RESISTANCE; + } + if (offset < 0 && !canPrev) { + offset *= RUBBER_BAND_RESISTANCE; + } + drag.offset = offset; + + applyDragOffset(offset, false); + }, + [applyDragOffset, snapPanelsTo], + ); + + const finishDrag = useCallback( + (cancelled: boolean) => { + const drag = dragRef.current; + if (!drag.pointerDown) { + return; + } + drag.pointerDown = false; + touchStartYRef.current = null; + + if (!drag.engaged) { + return; + } + drag.engaged = false; + + const index = activeIndexRef.current; + const canNext = index < questionsCountRef.current - 1; + const canPrev = index > 0; + const flicked = Math.abs(drag.velocity) > DRAG_FLICK_VELOCITY; + const draggedFar = + Math.abs(drag.offset) > drag.height * DRAG_COMMIT_RATIO; + + let direction: 0 | 1 | -1 = 0; + if (!cancelled && (draggedFar || flicked)) { + if ((drag.offset > 0 || drag.velocity > 0) && canNext) { + direction = 1; + } else if ((drag.offset < 0 || drag.velocity < 0) && canPrev) { + direction = -1; + } + } + + if (direction === 0) { + snapPanelsTo(0); + return; + } + + const nextIndex = index + direction; + snapPanelsTo(direction * drag.height); + onQuestionExit?.(index, nextIndex); + onQuestionTransition?.(index, nextIndex); + setActiveIndex(nextIndex); + }, + [onQuestionExit, onQuestionTransition, snapPanelsTo], ); const handleTouchEnd = useCallback( @@ -266,9 +548,22 @@ export function QuestionSnapList({ return; } + const drag = dragRef.current; + + if (drag.engaged) { + finishDrag(false); + return; + } + + if (!drag.pointerDown) { + return; + } + drag.pointerDown = false; + + // Fallback for very fast flicks whose touchmove never engaged the + // drag layer: fall back to the distance-based step. const startY = touchStartYRef.current; const endY = event.changedTouches[0]?.clientY; - touchStartYRef.current = null; if (startY === null || endY === undefined || questions.length < 2) { @@ -283,18 +578,12 @@ export function QuestionSnapList({ stepQuestion(distance > 0 ? 1 : -1); }, - [questions.length, stepQuestion], + [finishDrag, questions.length, stepQuestion], ); - const handleTouchMove = useCallback( - (event: React.TouchEvent) => { - if (document.body.classList.contains("dropdown-open")) { - return; - } - event.preventDefault(); - }, - [], - ); + const handleTouchCancel = useCallback(() => { + finishDrag(true); + }, [finishDrag]); if (questions.length === 0) { return null; @@ -302,14 +591,16 @@ export function QuestionSnapList({ return (
-
{question}
+
+ {question} +
{isActive && (