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/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..0a20475 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -145,16 +145,22 @@ } html { - min-height: 100%; + width: 100%; + height: 100%; + overflow: hidden; + overscroll-behavior: none; } body { - min-height: 100vh; + width: 100%; + height: 100%; margin: 0; display: flex; justify-content: center; + overflow: hidden; color: var(--foreground); font-family: "Segoe UI", Tahoma, Geneva, Verdana, sans-serif; + overscroll-behavior: none; } html:lang(ar) body, @@ -180,15 +186,21 @@ html:lang(ar) body, .app-shell { width: 100%; - min-height: 100vh; + height: 100%; + height: 100dvh; padding-inline: 17px; padding-bottom: var(--safe-bottom, 0px); box-sizing: border-box; + overflow-x: hidden; + overflow-y: auto; background-color: var(--background); background-image: var(--default-page-background-image); background-position: top; background-repeat: no-repeat; background-size: cover; + overscroll-behavior: none; + touch-action: pan-y; + -webkit-overflow-scrolling: touch; } html[data-web-bootstrap="pending"] .app-shell { @@ -217,6 +229,70 @@ 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; +} + +.question-progress, +.question-snap-content { + transition: + transform 300ms ease-in-out, + opacity 220ms ease-out, + max-height 300ms ease-in-out, + padding 300ms ease-in-out; +} + +body.question-sheet-open .app-shell .question-detail-header { + max-height: 0; + margin-top: -80px; + transform: translateY(-150px); + opacity: 0; + pointer-events: none; +} + +body.question-sheet-open .app-shell .question-progress { + max-height: 0; + padding-top: 0; + padding-bottom: 0; + opacity: 0; + overflow: hidden; +} + +body.question-sheet-open .app-shell .question-snap-list { + padding-top: 0; + padding-bottom: 0; +} + +body.question-sheet-open .app-shell .question-snap-item[aria-current="step"] { + top: 0; + bottom: 0; +} + +body.question-sheet-open + .app-shell + .question-snap-item[aria-current="step"] + .question-snap-content { + margin-top: 0; + margin-bottom: 0; +} + +body.question-keyboard-open + .app-shell + .question-snap-item[aria-current="step"] + .question-snap-content { + transform: translateY(-48px); +} + .page-background-none, .page-background-custom, .page-background-default { 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..49713c1 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({ + + (useFormOverviewQuery as any).mockReturnValue({ data: {}, 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,57 @@ 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: { + section: { cards: [] }, + answers: {}, + section_progress: { completion_percent: 0 }, + }, + isLoading: false, + }); + (mapBackendSectionToFrontend as any).mockReturnValue(profileItem); const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } }, @@ -266,10 +315,155 @@ describe("QuestionDetailClient Validation", () => { questionsListHref="/questions-list" title="Profile Test" /> - + , ); // 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("renders cached section questions while the overview refreshes", () => { + const cachedItem = { + slug: "profile_test", + title: "Cached Profile Form", + questions: [ + { + id: "cached_question", + title: "Cached question", + type: "text", + order: 1, + required: true, + isVisible: true, + private: false, + description: "", + tooltip: "", + extras: { placeHolder: "", range: [0, 0], options: [] }, + options: [], + }, + ], + }; + (convertOverviewToFrontendItems as any).mockReturnValue([]); + (useFormOverviewQuery as any).mockReturnValue({ + data: undefined, + isLoading: true, + }); + (useFormSectionQuery as any).mockReturnValue({ + data: { + section: { cards: [] }, + answers: {}, + section_progress: { completion_percent: 0 }, + }, + isLoading: false, + }); + (mapBackendSectionToFrontend as any).mockReturnValue(cachedItem); + + const queryClient = new QueryClient({ + defaultOptions: { queries: { retry: false } }, + }); + render( + + + , + ); + + expect(screen.getByRole("textbox")).toBeDefined(); + expect(screen.queryByRole("status")).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 6dc0a30..8031612 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"; @@ -19,15 +21,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 +115,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 +141,7 @@ function QuestionFlowWrapper({ data-question-disabled="false" data-question-answered={String(isAnswered)} > - + ); })} @@ -151,6 +163,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 +187,37 @@ 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 (!isAssessment && sectionResponse) { + return mapBackendSectionToFrontend( + sectionResponse.section, + sectionResponse.section_progress.completion_percent, + ); + } + return overviewItem; + }, [isAssessment, overviewItem, sectionResponse]); + const isSchemaLoading = isAssessment + ? isOverviewLoading + : !sectionResponse && (isOverviewLoading || isSectionLoading); const cattellQuery = useCattellQuestionsQuery(locale, { enabled: isCattellSlug && isTestStarted, @@ -193,15 +231,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 +280,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"); @@ -270,12 +339,55 @@ export default function QuestionDetailClient({ }, [isSchemaLoading, item, questionsListHref, router]); if (isSchemaLoading) { - return ( - - ); + if (!isAssessment) { + const loadingTitle = overviewItem?.title || title; + + return ( + <> + +
+ +
+ router.push(questionsListHref)} + /> +

+ {loadingTitle} +

+ +
+
+ +
+ +
+ + + + +
+ + ); + } + + return ; } else if (!item) { return null; } @@ -416,7 +528,10 @@ export default function QuestionDetailClient({ <>
- +
question.ui_config?.isDob === true || question.type === "date", + (question) => + question.ui_config?.isDob === true || question.type === "date", ); return ( @@ -583,10 +699,12 @@ export default function QuestionDetailClient({ slug={item.slug} questions={visibleQuestions} locale={locale} - schemaVersion={schema?.version ?? 1} >
- +
{ if (isProfileRedirecting && profileTargetPath) { @@ -76,10 +89,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 +109,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 +137,63 @@ 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 +201,11 @@ 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"; @@ -144,70 +217,209 @@ export default function QuestionsListPage() { const [isSyncing, setIsSyncing] = useState(false); const [toastMessage, setToastMessage] = useState(null); + const syncPromiseRef = useRef | null>(null); + const syncPendingAnswers = useCallback(async () => { - if (!schema) return; - const { updateMarriageSectionData } = await import( - "@/hooks/marriage/use-section-data" - ); + if (!overview) return; + if (syncPromiseRef.current) { + return syncPromiseRef.current; + } - let currentVersion = schema.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; - const pendingKeys = new Set( - Array.isArray(storedValue.pending_keys) - ? storedValue.pending_keys - : storedValue.fields.map((field: { key: string }) => field.key), - ); - for (const field of storedValue.fields) { - if (!pendingKeys.has(field.key)) continue; - const result = await updateMarriageSectionData(item.slug, { - version: currentVersion, - current_step: storedValue.current_step, - fields: [field], + 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; + + 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, }); - queryClient.setQueryData( - ["marriage", "form-schema", "profile", locale], - result.schema, + } + + 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), ); - currentVersion = result.schema.version; - pendingKeys.delete(field.key); - storedValue.pending_keys = [...pendingKeys]; - window.localStorage.setItem(storageKey, JSON.stringify(storedValue)); + storedValue.pending_sync = false; + storedValue.pending_keys = []; + if (storedValue.fields.length === 0) { + window.localStorage.removeItem(storageKey); + } else { + window.localStorage.setItem(storageKey, JSON.stringify(storedValue)); + } } - storedValue.pending_sync = false; - window.localStorage.setItem(storageKey, JSON.stringify(storedValue)); + })(); + + syncPromiseRef.current = task; + try { + await task; + setIsSyncError(false); + } finally { + syncPromiseRef.current = null; } - }, [locale, queryClient, questionListItems, schema]); + }, [locale, overview, queryClient, 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: marriageQueryKeys.formSection("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: marriageQueryKeys.formSection( + "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 profileSections = questionListItems + .filter( + (item) => + item.slug !== "personality_test" && + item.slug !== "glasser_5_needs_test", + ) + .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; + void prefetchSectionsWithBoundedConcurrency( + profileSections, + (item) => + queryClient.fetchQuery({ + queryKey: marriageQueryKeys.formSection("profile", item.slug, locale), + queryFn: () => getFormSection("profile", item.slug, locale), + staleTime: 30 * 1000, + }), + () => cancelled, + ); + return () => { + cancelled = true; + }; + }, [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." ], ); } - }, [ - startMatchMutation.isError, - isSyncError, - t[ - "Sending the match request failed. Please check your connection and try again." - ], - ]); + }, [startMatchMutation.isError, t]); const handleCloseToast = () => { setToastMessage(null); @@ -289,37 +501,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 +608,7 @@ export default function QuestionsListPage() { className="text-left" /> ) : null} - +
@@ -448,6 +661,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/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/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 7c6f8a7..c7ea0f3 100644 --- a/src/components/Componentes/question-answer-storage.tsx +++ b/src/components/Componentes/question-answer-storage.tsx @@ -11,7 +11,6 @@ import { useRef, useState, } from "react"; -import type { QuestionField } from "@/lib/schema-adapter"; import { pathParam } from "@/hooks/marriage/path-param"; import { marriageQueryKeys } from "@/hooks/marriage/query-keys"; import type { @@ -22,10 +21,12 @@ import type { } from "@/hooks/marriage/types"; import { useMarriageProfileQuery } from "@/hooks/marriage/use-profile-main"; import { + applyProfilePatchResultToCache, useMarriageSectionDataQuery, useUpdateMarriageSectionDataMutation, } from "@/hooks/marriage/use-section-data"; import { getApiRequestUrl } from "@/lib/http"; +import type { QuestionField } from "@/lib/schema-adapter"; const STORAGE_VERSION = 2; @@ -47,16 +48,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[]; }; @@ -65,7 +61,6 @@ type QuestionAnswersProviderProps = { questions: readonly QuestionField[]; slug: string; locale?: string; - schemaVersion?: number; }; const QuestionAnswersContext = @@ -122,14 +117,12 @@ function isMarriagePhoneFieldValue( ); } - - function createQuestionField( question: QuestionField, value: MarriageFieldValue, ): MarriageField { - let option_id = undefined; - + let option_id: string | string[] | 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() { @@ -311,14 +302,13 @@ export function QuestionAnswersProvider({ questions, slug, locale = "en", - schemaVersion = 1, }: QuestionAnswersProviderProps) { const storageKey = useMemo(() => getQuestionAnswersStorageKey(slug), [slug]); const queryClient = useQueryClient(); 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); @@ -327,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(); @@ -342,8 +331,13 @@ 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]); + }, [ + answers, + hasPendingSync, + slug, + questions, + serverSectionData?.data, + ]); useEffect(() => { storageKeyRef.current = storageKey; @@ -377,7 +371,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 +410,7 @@ export function QuestionAnswersProvider({ ); const setAnswerValue = useCallback( - ( - question: QuestionField, - value: MarriageFieldValue, - ) => { + (question: QuestionField, value: MarriageFieldValue) => { if (!canEdit) { return; } @@ -454,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; @@ -482,11 +479,10 @@ export function QuestionAnswersProvider({ } const fullPayload = createPayload(answersRef.current, questionsRef.current, backendFieldsRef.current); - const dirtyFields = fullPayload.fields.filter((field) => dirtyKeysRef.current.has(field.key)); - const fieldsToSend = dirtyFields.length > 0 ? dirtyFields : fullPayload.fields; + const nextDirtyKey = fullPayload.fields.find((field) => dirtyKeysRef.current.has(field.key))?.key; const payload = { ...fullPayload, - fields: fieldsToSend, + fields: nextDirtyKey ? fullPayload.fields.filter((field) => field.key === nextDirtyKey) : [], version: schemaVersionRef.current, }; const revision = answersRevisionRef.current; @@ -496,9 +492,30 @@ export function QuestionAnswersProvider({ } const request = mutateAsync(payload).then((result) => { - if (result?.schema?.version) { - schemaVersionRef.current = result.schema.version; - } + const cleared = new Set(result?.cleared_answer_ids ?? []); + const nextAnswers = { ...answersRef.current }; + const questionById = new Map( + questionsRef.current.map((question) => [question.id, question]), + ); + Object.entries(result?.answers ?? {}).forEach(([key, answer]) => { + const question = questionById.get(key); + if (question) { + nextAnswers[key] = { + key, + label: question.title, + type: question.type, + value: answer.option_id ?? answer.value, + option_id: answer.option_id, + } as MarriageField; + } + }); + cleared.forEach((key) => { + delete nextAnswers[key]; + }); + answersRef.current = nextAnswers; + setAnswers(nextAnswers); + applyProfilePatchResultToCache(queryClient, locale, result); + return undefined; }); flushPromiseRef.current = request; @@ -519,7 +536,12 @@ export function QuestionAnswersProvider({ return; } - dirtyKeysRef.current.clear(); + if (nextDirtyKey) dirtyKeysRef.current.delete(nextDirtyKey); + + if (dirtyKeysRef.current.size > 0) { + await flushAnswersRef.current(); + return; + } hasPendingSyncRef.current = false; setHasPendingSync(false); @@ -533,7 +555,7 @@ export function QuestionAnswersProvider({ [], ); }, - [mutateAsync, canEdit], + [mutateAsync, canEdit, locale, queryClient], ); const flushAnswersRef = useRef(flushAnswers); @@ -554,9 +576,15 @@ 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 payload = { ...fullPayload, fields: pendingFields.slice(0, 1) }; + 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 }; const revision = answersRevisionRef.current; if (payload.fields.length === 0) { @@ -580,13 +608,12 @@ export function QuestionAnswersProvider({ fetch(getKeepalivePatchUrl(slugRef.current), { body: JSON.stringify({ - version: schemaVersionRef.current, answers: answersPayload, }), credentials: "include", headers, keepalive: true, - method: "PUT", + method: "PATCH", }) .then((response) => { if (!response.ok) { @@ -598,7 +625,9 @@ export function QuestionAnswersProvider({ } hasPendingSyncRef.current = false; - dirtyKeysRef.current.delete(payload.fields[0].key); + payload.fields.forEach((field) => { + dirtyKeysRef.current.delete(field.key); + }); const stillPending = dirtyKeysRef.current.size > 0; hasPendingSyncRef.current = stillPending; setHasPendingSync(stillPending); @@ -675,9 +704,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-answer.test.tsx b/src/components/Componentes/question-answer.test.tsx index 1a50e0b..0f99de7 100644 --- a/src/components/Componentes/question-answer.test.tsx +++ b/src/components/Componentes/question-answer.test.tsx @@ -1,11 +1,23 @@ -import { render, screen, fireEvent, waitFor, cleanup } from "@testing-library/react"; -import { describe, it, expect, afterEach, beforeEach, vi } from "vitest"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; -import { QuestionAnswersProvider, useQuestionAnswers } from "./question-answer-storage"; -import { convertSchemaToFrontendItems } from "@/lib/schema-adapter"; +import { + cleanup, + fireEvent, + render, + screen, + waitFor, +} from "@testing-library/react"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { useFormSchemaQuery } from "@/hooks/marriage/use-form-schema"; import { useMarriageProfileQuery } from "@/hooks/marriage/use-profile-main"; -import { useMarriageSectionDataQuery, useUpdateMarriageSectionDataMutation } from "@/hooks/marriage/use-section-data"; +import { + useMarriageSectionDataQuery, + useUpdateMarriageSectionDataMutation, +} from "@/hooks/marriage/use-section-data"; +import { convertSchemaToFrontendItems } from "@/lib/schema-adapter"; +import { + QuestionAnswersProvider, + useQuestionAnswers, +} from "./question-answer-storage"; vi.mock("@/hooks/marriage/use-form-schema", () => ({ useFormSchemaQuery: vi.fn(), @@ -14,6 +26,7 @@ vi.mock("@/hooks/marriage/use-profile-main", () => ({ useMarriageProfileQuery: vi.fn(), })); vi.mock("@/hooks/marriage/use-section-data", () => ({ + applyProfilePatchResultToCache: vi.fn(), useMarriageSectionDataQuery: vi.fn(), useUpdateMarriageSectionDataMutation: vi.fn(), })); @@ -28,8 +41,22 @@ function TestComponent({ slug }: { slug: string }) { data-testid="set-radio" onClick={() => setAnswerValue( - { id: "q1", type: "radio", title: "Q1", order: 1, required: true, baseRequired: true, isVisible: true, description: "", tooltip: "", extras: { placeHolder: "", options: [], range: [0,0] }, options: [{ id: "opt1", value: "A", label: "Option A", order: 1 }] }, - "opt1" + { + id: "q1", + type: "radio", + title: "Q1", + order: 1, + required: true, + baseRequired: true, + isVisible: true, + description: "", + tooltip: "", + extras: { placeHolder: "", options: [], range: [0, 0] }, + options: [ + { id: "opt1", value: "A", label: "Option A", order: 1 }, + ], + }, + "opt1", ) } > @@ -40,8 +67,23 @@ function TestComponent({ slug }: { slug: string }) { data-testid="set-checkbox" onClick={() => setAnswerValue( - { id: "q2", type: "checkbox", title: "Q2", order: 2, required: true, baseRequired: true, isVisible: true, description: "", tooltip: "", extras: { placeHolder: "", options: [], range: [0,0] }, options: [{ id: "opt2", value: "B", label: "Option B", order: 1 }, { id: "opt3", value: "C", label: "Option C", order: 2 }] }, - ["opt2", "opt3"] + { + id: "q2", + type: "checkbox", + title: "Q2", + order: 2, + required: true, + baseRequired: true, + isVisible: true, + description: "", + tooltip: "", + extras: { placeHolder: "", options: [], range: [0, 0] }, + options: [ + { id: "opt2", value: "B", label: "Option B", order: 1 }, + { id: "opt3", value: "C", label: "Option C", order: 2 }, + ], + }, + ["opt2", "opt3"], ) } > @@ -87,19 +129,35 @@ describe("Question Answer & Schema Integration", () => { }); it("should send option_id instead of label/value for radio", async () => { - const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + const queryClient = new QueryClient({ + defaultOptions: { queries: { retry: false } }, + }); render( - + , ); fireEvent.click(screen.getByTestId("set-radio")); @@ -114,19 +172,36 @@ describe("Question Answer & Schema Integration", () => { }); it("should send array of option_ids for checkbox", async () => { - const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + const queryClient = new QueryClient({ + defaultOptions: { queries: { retry: false } }, + }); render( - + , ); fireEvent.click(screen.getByTestId("set-checkbox")); @@ -139,6 +214,61 @@ describe("Question Answer & Schema Integration", () => { }); }); + it("batches all dirty answers into one mutation", async () => { + const queryClient = new QueryClient({ + defaultOptions: { queries: { retry: false } }, + }); + + render( + + + + + , + ); + + fireEvent.click(screen.getByTestId("set-radio")); + fireEvent.click(screen.getByTestId("set-checkbox")); + fireEvent.click(screen.getByTestId("save")); + + await waitFor(() => expect(capturedPayload?.fields).toHaveLength(2)); + }); + it("should sort schema questions and options by order correctly", () => { const mockSchema = { sections: [ @@ -155,25 +285,52 @@ describe("Question Answer & Schema Integration", () => { title: "Card", order: 2, questions: [ - { id: "q1", title: "Q1", type: "text", order: 10, required: true, is_visible: true, ui_config: {}, options: [] }, - { id: "q2", title: "Q2", type: "text", order: 5, required: true, is_visible: true, ui_config: {}, options: [ - { id: "opt1", value: "A", label: "Option A", order: 2 }, - { id: "opt2", value: "B", label: "Option B", order: 1 } - ] }, - ] + { + id: "q1", + title: "Q1", + type: "text", + order: 10, + required: true, + is_visible: true, + ui_config: {}, + options: [], + }, + { + id: "q2", + title: "Q2", + type: "text", + order: 5, + required: true, + is_visible: true, + ui_config: {}, + options: [ + { id: "opt1", value: "A", label: "Option A", order: 2 }, + { id: "opt2", value: "B", label: "Option B", order: 1 }, + ], + }, + ], }, { id: "card2", title: "Card 2", order: 1, questions: [ - { id: "q3", title: "Q3", type: "text", order: 1, required: true, is_visible: true, ui_config: {}, options: [] }, - ] - } - ] - } + { + id: "q3", + title: "Q3", + type: "text", + order: 1, + required: true, + is_visible: true, + ui_config: {}, + options: [], + }, + ], + }, + ], + }, ], - progress: { sections_progress: {} } + progress: { sections_progress: {} }, } as any; const frontendItems = convertSchemaToFrontendItems(mockSchema, "en"); @@ -193,14 +350,24 @@ describe("Question Answer & Schema Integration", () => { data: { slug: "test_slug", data: [ - { key: "q_radio", type: "radio", value: "Server Canonical Value", option_id: "opt_radio" }, - { key: "q_check", type: "checkbox", value: ["Server Val 1", "Server Val 2"], option_id: ["opt_check1", "opt_check2"] }, + { + key: "q_radio", + type: "radio", + value: "Server Canonical Value", + option_id: "opt_radio", + }, + { + key: "q_check", + type: "checkbox", + value: ["Server Val 1", "Server Val 2"], + option_id: ["opt_check1", "opt_check2"], + }, ], }, isLoading: false, }); - - let capturedValues: any = {}; + + const capturedValues: any = {}; function HydrationTestComponent() { const { getAnswerValue } = useQuestionAnswers(); @@ -209,14 +376,16 @@ describe("Question Answer & Schema Integration", () => { return
; } - const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + const queryClient = new QueryClient({ + defaultOptions: { queries: { retry: false } }, + }); render( - + , ); // Give it a moment to reconcile useEffect in QuestionAnswersProvider @@ -242,13 +411,23 @@ describe("Question Answer & Schema Integration", () => { title: "Card", order: 1, questions: [ - { id: "q1", title: "Q1", type: "text", order: 1, required: false, is_required: true, is_visible: true, ui_config: {}, options: [] }, - ] - } - ] - } + { + id: "q1", + title: "Q1", + type: "text", + order: 1, + required: false, + is_required: true, + is_visible: true, + ui_config: {}, + options: [], + }, + ], + }, + ], + }, ], - progress: { sections_progress: {} } + progress: { sections_progress: {} }, } as any; const frontendItems = convertSchemaToFrontendItems(mockSchema, "en"); diff --git a/src/components/Componentes/question-birthplace.tsx b/src/components/Componentes/question-birthplace.tsx index 8c6337c..706385b 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,11 @@ 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 cityInputRef = useRef(null); const listRef = useRef(null); - const searchInputRef = useRef(null); + const isMountedRef = useRef(true); const isResidence = question.ui_config?.enable_geoip === true; @@ -99,6 +104,47 @@ 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]); + const updateAnswers = (country: string, city: string) => { const formatted = city && country ? `${city}, ${country}` : city || country || null; @@ -199,9 +245,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 +267,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 +277,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 +299,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-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/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-progress-tracker.tsx b/src/components/Componentes/question-progress-tracker.tsx index 6f324e2..e13bbff 100644 --- a/src/components/Componentes/question-progress-tracker.tsx +++ b/src/components/Componentes/question-progress-tracker.tsx @@ -171,7 +171,7 @@ export function QuestionProgressTracker({ onChange={updateProgress} onInput={updateProgress} > -
+
diff --git a/src/components/Componentes/question-sheet.test.tsx b/src/components/Componentes/question-sheet.test.tsx new file mode 100644 index 0000000..f3d2581 --- /dev/null +++ b/src/components/Componentes/question-sheet.test.tsx @@ -0,0 +1,247 @@ +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-sheet-open"); + document.body.classList.remove("question-keyboard-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-sheet-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-sheet-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-sheet-open")).toBe( + false, + ); + }); + }); + + it("opens a searchable sheet without focusing search", () => { + const question = { + id: "q_country", + title: "کشور", + type: "dropdown", + required: true, + extras: { placeHolder: "انتخاب کشور" }, + options: Array.from({ length: 6 }, (_, index) => ({ + id: `country-${index}`, + value: `country-${index}`, + label: `کشور ${index + 1}`, + order: index + 1, + })), + ui_config: {}, + } as QuestionField; + + render( + + + + + , + ); + + fireEvent.click(screen.getByRole("button", { name: /انتخاب کشور/i })); + + expect(screen.getByPlaceholderText("جستجو...")).not.toHaveFocus(); + expect(screen.getByRole("dialog").querySelector("section")).toHaveClass( + "h-[82svh]", + ); + }); + + it("sizes a short options sheet to its content", () => { + const question = { + id: "q_short", + title: "انتخاب کوتاه", + type: "dropdown", + required: true, + extras: { placeHolder: "انتخاب" }, + options: Array.from({ length: 4 }, (_, index) => ({ + id: `option-${index}`, + value: `option-${index}`, + label: `گزینه ${index + 1}`, + order: index + 1, + })), + ui_config: {}, + } as QuestionField; + + render( + + + + + , + ); + + fireEvent.click(screen.getByRole("button", { name: "انتخاب" })); + + expect(screen.getByRole("dialog").querySelector("section")).toHaveClass( + "h-auto", + "max-h-[82svh]", + ); + }); +}); diff --git a/src/components/Componentes/question-sheet.tsx b/src/components/Componentes/question-sheet.tsx new file mode 100644 index 0000000..86efa9d --- /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 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 isCompact = 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 && (