parent
commit
1ee86b4400
  1. 36
      .zcode/plans/plan-sess_b396caff-afc5-4bc2-93b4-abe26fb2b685.md
  2. 64
      src/app/api/proxy/route.ts
  3. 82
      src/app/globals.css
  4. 2
      src/app/layout.tsx
  5. 358
      src/app/questions-list/[slug]/question-detail-client.test.tsx
  6. 186
      src/app/questions-list/[slug]/question-detail-client.tsx
  7. 413
      src/app/questions-list/page.tsx
  8. 53
      src/app/questions-list/section-prefetch.test.ts
  9. 24
      src/app/questions-list/section-prefetch.ts
  10. 17
      src/app/questions-list/sections-request.tsx
  11. 127
      src/components/Componentes/question-answer-storage.tsx
  12. 257
      src/components/Componentes/question-answer.test.tsx
  13. 485
      src/components/Componentes/question-birthplace.tsx
  14. 38
      src/components/Componentes/question-card.tsx
  15. 348
      src/components/Componentes/question-date-sheet.tsx
  16. 60
      src/components/Componentes/question-date.test.tsx
  17. 293
      src/components/Componentes/question-date.tsx
  18. 390
      src/components/Componentes/question-phone.tsx
  19. 2
      src/components/Componentes/question-progress-tracker.tsx
  20. 4
      src/components/Componentes/question-renderer.tsx
  21. 247
      src/components/Componentes/question-sheet.test.tsx
  22. 416
      src/components/Componentes/question-sheet.tsx
  23. 345
      src/components/Componentes/question-snap-list.tsx
  24. 31
      src/components/Componentes/required-steps-card.test.tsx
  25. 86
      src/components/Componentes/required-steps-card.tsx
  26. 205
      src/components/Componentes/schema-question-flow.integration.test.tsx
  27. 72
      src/components/Componentes/token-switcher.tsx
  28. 166
      src/components/Componentes/ui-config.test.tsx
  29. 152
      src/components/Componentes/use-sheet-scroll-lock.ts
  30. 4
      src/hooks/marriage/query-keys.ts
  31. 28
      src/hooks/marriage/types.ts
  32. 87
      src/hooks/marriage/use-form-schema.ts
  33. 98
      src/hooks/marriage/use-section-data.test.ts
  34. 209
      src/hooks/marriage/use-section-data.ts
  35. 1
      src/lib/marriage-profile-contract.ts
  36. 38
      src/lib/schema-adapter-overview.test.ts
  37. 66
      src/lib/schema-adapter.ts
  38. 8
      src/translations/locales/ar.json
  39. 8
      src/translations/locales/az.json
  40. 8
      src/translations/locales/bn.json
  41. 8
      src/translations/locales/da.json
  42. 8
      src/translations/locales/de.json
  43. 8
      src/translations/locales/en.json
  44. 8
      src/translations/locales/es.json
  45. 8
      src/translations/locales/fa.json
  46. 8
      src/translations/locales/fr.json
  47. 8
      src/translations/locales/gu.json
  48. 8
      src/translations/locales/ha.json
  49. 10
      src/translations/locales/he.json
  50. 8
      src/translations/locales/hi.json
  51. 10
      src/translations/locales/id.json
  52. 10
      src/translations/locales/ks.json
  53. 10
      src/translations/locales/pt.json
  54. 8
      src/translations/locales/ru.json
  55. 10
      src/translations/locales/sw.json
  56. 10
      src/translations/locales/tg.json
  57. 10
      src/translations/locales/tr.json
  58. 10
      src/translations/locales/ul.json
  59. 10
      src/translations/locales/ur.json
  60. 10
      src/translations/locales/uz.json
  61. 8
      src/translations/locales/zh.json

36
.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 متنی (حرکت به بالا با کیبورد).

64
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<string, number>,
) {
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<Uint8Array, Uint8Array>({
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,

82
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 {

2
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",
};

358
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"
/>
</QueryClientProvider>
</QueryClientProvider>,
);
// 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"
/>
</QueryClientProvider>
</QueryClientProvider>,
);
// 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(
<QueryClientProvider client={queryClient}>
<QuestionDetailClient
closeLabel="Close"
continueLabel="Continue"
description="Desc"
informationLabel="Info"
itemSlug="profile_test"
questionsListHref="/questions-list"
title="Route Fallback Title"
/>
</QueryClientProvider>,
);
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(
<QueryClientProvider client={queryClient}>
<QuestionDetailClient
closeLabel="Close"
continueLabel="Continue"
description="Desc"
informationLabel="Info"
itemSlug="profile_test"
questionsListHref="/questions-list"
title="Route Fallback Title"
/>
</QueryClientProvider>,
);
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(
<QueryClientProvider client={queryClient}>
<QuestionDetailClient
closeLabel="Close"
continueLabel="Continue"
description="Desc"
informationLabel="Info"
itemSlug="profile_test"
questionsListHref="/questions-list"
title="Route Fallback Title"
/>
</QueryClientProvider>,
);
expect(screen.getByText("Route Fallback Title")).toBeDefined();
expect(screen.getByRole("status")).toBeDefined();
expect(document.querySelector(".shimmer-bg")).toBeNull();
});
});

186
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)}
>
<QuestionRenderer
question={question}
dobQuestion={dobQuestion}
/>
<QuestionRenderer question={question} dobQuestion={dobQuestion} />
</div>
);
})}
@ -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 (
<PageLoadingSkeleton
compact
variant={isCattellSlug || isGlasserSlug ? "test" : "questions"}
/>
);
if (!isAssessment) {
const loadingTitle = overviewItem?.title || title;
return (
<>
<PageBackground disabled />
<main className="-mx-[17px] flex h-svh flex-col overflow-hidden bg-[#F7F1F0]">
<StickyHeader
sticky={false}
className="question-detail-header shrink-0"
>
<div className="flex items-center gap-4">
<NavigationButton
className="shrink-0"
variant="transparent"
icon="close"
iconLabel={closeLabel}
onClick={() => router.push(questionsListHref)}
/>
<h1 className="min-w-0 flex-1 truncate text-center text-[14px] font-semibold text-white">
{loadingTitle}
</h1>
<NavigationButton
className="shrink-0"
variant="transparent"
icon="info"
iconLabel={informationLabel}
helpTitle={loadingTitle}
helpDescription={description}
/>
</div>
</StickyHeader>
<div className="flex min-h-0 flex-1 items-center justify-center pb-[76px]">
<span
role="status"
className="size-5 animate-spin rounded-full border-2 border-[#E03950]/25 border-t-[#E03950] motion-reduce:animate-none"
/>
</div>
<FixToTheEnd className="bg-[#F7F1F0]/95">
<Button disabled>{continueLabel}</Button>
</FixToTheEnd>
</main>
</>
);
}
return <PageLoadingSkeleton compact variant="test" />;
} else if (!item) {
return null;
}
@ -416,7 +528,10 @@ export default function QuestionDetailClient({
<>
<PageBackground disabled />
<main className="-mx-[17px] flex h-svh flex-col overflow-hidden bg-[#F7F1F0]">
<StickyHeader sticky={false} className="shrink-0">
<StickyHeader
sticky={false}
className="question-detail-header shrink-0"
>
<div className="flex items-center gap-4">
<NavigationButton
className="shrink-0"
@ -572,7 +687,8 @@ export default function QuestionDetailClient({
}
const dobQuestion = visibleQuestions.find(
(question) => question.ui_config?.isDob === true || question.type === "date",
(question) =>
question.ui_config?.isDob === true || question.type === "date",
);
return (
@ -583,10 +699,12 @@ export default function QuestionDetailClient({
slug={item.slug}
questions={visibleQuestions}
locale={locale}
schemaVersion={schema?.version ?? 1}
>
<main className="-mx-[17px] flex h-svh flex-col overflow-hidden bg-[#F7F1F0]">
<StickyHeader sticky={false} className="shrink-0">
<StickyHeader
sticky={false}
className="question-detail-header shrink-0"
>
<div className="flex items-center gap-4">
<QuestionExitNavigationButton
className="shrink-0"

413
src/app/questions-list/page.tsx

@ -1,38 +1,48 @@
"use client";
import { useQueryClient } from "@tanstack/react-query";
import Image from "next/image";
import { useRouter } from "next/navigation";
import { useCallback, useEffect, useMemo, useState } from "react";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { IoClose } from "react-icons/io5";
import {
getSubmitPath,
hasCompletedMarriageProfileBasics,
} from "@/lib/get-submit-path";
import QuestionCard from "@/components/Componentes/question-card";
import RequiredStepsCard from "@/components/Componentes/required-steps-card";
import Button from "@/components/Componentes/button";
import ErrorToast from "@/components/Componentes/error-toast";
import { FixToTheEnd } from "@/components/Componentes/fix-to-the-end";
import InformationSheet from "@/components/Componentes/information-sheet";
import NavigationButton from "@/components/Componentes/navigation-button";
import { getQuestionAnswersStorageKey } from "@/components/Componentes/question-answer-storage";
import { FixToTheEnd } from "@/components/Componentes/fix-to-the-end";
import { LoadingSkeleton } from "@/components/Componentes/loading-skeleton";
import { PageBackground } from "@/components/Componentes/page-background";
import ErrorToast from "@/components/Componentes/error-toast";
import { useQueryClient } from "@tanstack/react-query";
import { getQuestionAnswersStorageKey } from "@/components/Componentes/question-answer-storage";
import QuestionCard from "@/components/Componentes/question-card";
import RequiredStepsCard from "@/components/Componentes/required-steps-card";
import type { MarriageField } from "@/hooks/marriage/types";
import { marriageQueryKeys } from "@/hooks/marriage/query-keys";
import {
getFormSection,
useFormOverviewQuery,
} from "@/hooks/marriage/use-form-schema";
import { useStartMarriageMatchMutation } from "@/hooks/marriage/use-match-start";
import { useMarriageProfileQuery } from "@/hooks/marriage/use-profile-main";
import { useFormSchemaQuery } from "@/hooks/marriage/use-form-schema";
import { convertSchemaToFrontendItems } from "@/lib/schema-adapter";
import type { QuestionListItem } from "@/lib/schema-adapter";
import {
applyProfilePatchResultToCache,
updateMarriageSectionData,
} from "@/hooks/marriage/use-section-data";
import { useCloseServiceOnBack } from "@/hooks/use-close-service-on-back";
import { getAssessmentLocalProgress } from "@/lib/assessment-progress";
import {
getSubmitPath,
hasCompletedMarriageProfileBasics,
} from "@/lib/get-submit-path";
import { REQUIRED_PROFILE_SECTION_COUNT } from "@/lib/marriage-profile-contract";
import {
clearMatchStartGrace,
markMatchStarted,
} from "@/lib/match-start-grace";
import type { QuestionListItem } from "@/lib/schema-adapter";
import { convertOverviewToFrontendItems } from "@/lib/schema-adapter";
import { localizePath } from "@/translations/config";
import { useI18n } from "@/translations/provider";
import { prefetchSectionsWithBoundedConcurrency } from "./section-prefetch";
import SectionsRequest from "./sections-request";
import { getAssessmentLocalProgress } from "@/lib/assessment-progress";
export default function QuestionsListPage() {
useCloseServiceOnBack();
@ -41,8 +51,10 @@ export default function QuestionsListPage() {
const queryClient = useQueryClient();
const { data: profile, isLoading: isProfileLoading } =
useMarriageProfileQuery();
const { data: schema, isLoading: isSchemaLoading } =
useFormSchemaQuery("profile", locale);
const { data: overview, isLoading: isSchemaLoading } = useFormOverviewQuery(
"profile",
locale,
);
const isSectionsLoading = false;
@ -53,7 +65,8 @@ export default function QuestionsListPage() {
const isProfileRedirecting =
profileTargetPath !== null &&
profileTargetPath !== "/questions-list" &&
(!hasCompletedMarriageProfileBasics(profile) || profile?.can_edit_profile === false);
(!hasCompletedMarriageProfileBasics(profile) ||
profile?.can_edit_profile === false);
useEffect(() => {
if (isProfileRedirecting && profileTargetPath) {
@ -76,10 +89,12 @@ export default function QuestionsListPage() {
const [selectedSection, setSelectedSection] =
useState<QuestionListItem | null>(null);
const questionListItems = useMemo(
() => convertSchemaToFrontendItems(schema, locale),
[schema, locale],
() => convertOverviewToFrontendItems(overview),
[overview],
);
const [localAssessmentProgress, setLocalAssessmentProgress] = useState<Map<string, number>>(new Map());
const [localAssessmentProgress, setLocalAssessmentProgress] = useState<
Map<string, number>
>(new Map());
useEffect(() => {
const next = new Map<string, number>();
@ -94,20 +109,26 @@ export default function QuestionsListPage() {
}
}
setLocalAssessmentProgress(next);
}, [schema]);
}, [overview]);
const sectionProgressBySlug = useMemo(() => {
const progressBySlug = new Map<string, number>();
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<string | null>(null);
const syncPromiseRef = useRef<Promise<void> | 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<string>(
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<string>(
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<string>());
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() {
</header>
<div className="relative mt-4 space-y-5">
{/* Required Steps Card Skeleton (Hosseinieh Card Style) */}
<div className="rounded-[15px] bg-[#40506A] p-4 text-white shadow-[0_18px_34px_rgba(38,52,73,0.16)] flex items-center justify-between gap-5">
<div className="min-w-0 flex-1 space-y-2.5">
<div className="flex items-center gap-3">
<span className="size-6 shrink-0 rounded-full shimmer-white-bg" />
<span className="h-4 w-32 rounded-md shimmer-white-bg block" />
</div>
<div className="space-y-1.5 pt-0.5">
<span className="h-3 w-4/5 rounded-md shimmer-white-bg block" />
<span className="h-3 w-3/5 rounded-md shimmer-white-bg block" />
</div>
</div>
<div className="size-[60px] shrink-0 rounded-full shimmer-white-bg flex items-center justify-center p-1.5">
<div className="size-full rounded-full bg-[#40506A]" />
</div>
</div>
<RequiredStepsCard
completed={displayedRequiredSections}
total={REQUIRED_PROFILE_SECTION_COUNT}
/>
{/* Section Card Skeletons (solid blocks like the Meet/checkup
AppShimmer loading one sweep band runs across each card) */}
<div className="space-y-3">
{Array.from({ length: 6 }).map((_, idx) => (
<div
key={idx}
className="shimmer-bg h-[84px] rounded-[20px]"
/>
<div key={idx} className="shimmer-bg h-[84px] rounded-[20px]" />
))}
</div>
</div>
<FixToTheEnd>
<LoadingSkeleton className="h-[52px] w-full rounded-[14px]" />
<Button aria-label={t["Find Matches"]} disabled>
<span className="flex items-center justify-center gap-2.5">
<Image
src="/assets/images/noun-wedding-rings-6540466 1.svg"
alt=""
aria-hidden="true"
width={28}
height={28}
className="shrink-0"
loading="eager"
fetchPriority="high"
/>
<span className="leading-none font-semibold">
{t["Submit"]}
</span>
</span>
</Button>
</FixToTheEnd>
</main>
</>
@ -395,7 +608,7 @@ export default function QuestionsListPage() {
className="text-left"
/>
) : null}
<SectionsRequest />
<SectionsRequest sections={overview?.sections} />
<PageBackground disabled />
<main
@ -436,8 +649,8 @@ export default function QuestionsListPage() {
<div className="relative">
<div className="mt-4">
<RequiredStepsCard
items={questionListItems}
progressBySlug={sectionProgressBySlug}
completed={displayedRequiredSections}
total={REQUIRED_PROFILE_SECTION_COUNT}
/>
</div>
@ -448,6 +661,8 @@ export default function QuestionsListPage() {
item={item}
progress={sectionProgressBySlug.get(item.slug) ?? null}
onInfoClick={(section) => setSelectedSection(section)}
onNearViewport={enqueueViewportPrefetch}
onPrefetch={prefetchSection}
/>
))}
</section>

53
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<void>((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<void>>()
.mockRejectedValueOnce(new Error("offline"))
.mockResolvedValue(undefined);
await prefetchSectionsWithBoundedConcurrency(
[section("one"), section("two"), section("three")],
prefetch,
() => false,
);
expect(prefetch).toHaveBeenCalledTimes(3);
});
});

24
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<unknown>,
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()]);
}

17
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 {

127
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<void>;
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<QuestionAnswersByKey>({});
const [hasPendingSync, setHasPendingSync] = useState(false);
const { isPending: isSaving, mutateAsync } =
useUpdateMarriageSectionDataMutation(slug);
useUpdateMarriageSectionDataMutation(slug, locale);
const answersRef = useRef<QuestionAnswersByKey>({});
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<MarriageField[]>([]);
const schemaVersionRef = useRef(schemaVersion);
const dirtyKeysRef = useRef(new Set<string>());
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 {

257
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(
<QueryClientProvider client={queryClient}>
<QuestionAnswersProvider
slug="test_slug"
questions={[
{ 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 }] },
{
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 },
],
},
]}
>
<TestComponent slug="test_slug" />
</QuestionAnswersProvider>
</QueryClientProvider>
</QueryClientProvider>,
);
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(
<QueryClientProvider client={queryClient}>
<QuestionAnswersProvider
slug="test_slug"
questions={[
{ 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 }] },
{
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 },
],
},
]}
>
<TestComponent slug="test_slug" />
</QuestionAnswersProvider>
</QueryClientProvider>
</QueryClientProvider>,
);
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(
<QueryClientProvider client={queryClient}>
<QuestionAnswersProvider
slug="test_slug"
questions={[
{
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 },
],
},
{
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 },
],
},
]}
>
<TestComponent slug="test_slug" />
</QuestionAnswersProvider>
</QueryClientProvider>,
);
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 <div />;
}
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } });
const queryClient = new QueryClient({
defaultOptions: { queries: { retry: false } },
});
render(
<QueryClientProvider client={queryClient}>
<QuestionAnswersProvider slug="test_slug" questions={[]}>
<HydrationTestComponent />
</QuestionAnswersProvider>
</QueryClientProvider>
</QueryClientProvider>,
);
// 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");

485
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<HTMLDivElement>(null);
const cityInputRef = useRef<HTMLInputElement>(null);
const listRef = useRef<HTMLDivElement>(null);
const searchInputRef = useRef<HTMLInputElement>(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<HTMLDivElement>) => {
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<HTMLInputElement>) => {
@ -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 (
<div
ref={containerRef}
className={[
"relative flex w-full flex-col gap-3 transition-opacity duration-200",
disabled ? "pointer-events-none opacity-30" : "",
@ -425,12 +435,12 @@ export function QuestionBirthplace({
{mode === "manual" && (
<div className="flex flex-col gap-3 w-full animate-in fade-in slide-in-from-top-2 duration-200">
{/* Country Selection Dropdown */}
{/* Country Selection Trigger */}
<div className="relative w-full">
<button
type="button"
disabled={disabled}
onClick={() => setIsOpen(!isOpen)}
onClick={openSheet}
className={[
"flex h-[54px] w-full items-center justify-between rounded-[16px] border bg-white px-4.5 text-start transition-all cursor-pointer outline-none",
isOpen
@ -465,100 +475,14 @@ export function QuestionBirthplace({
/>
</svg>
</button>
{isOpen && (
<div className="absolute top-[calc(100%+8px)] left-0 z-50 flex w-full flex-col gap-3.5 rounded-[20px] bg-white p-4.5 shadow-[0_12px_36px_rgba(0,0,0,0.09)] border border-[#EAECF0] animate-in fade-in zoom-in-95 duration-150">
{options.length > 3 || searchQuery ? (
<div className="flex h-[46px] w-full items-center gap-2.5 rounded-[12px] bg-[#EFEFEF] px-3.5 transition-colors focus-within:bg-[#E8E8E8]">
<svg
width="18"
height="18"
viewBox="0 0 18 18"
fill="none"
className="shrink-0 text-[#667085]"
>
<path
d="M8.25 14.25C11.5637 14.25 14.25 11.5637 14.25 8.25C14.25 4.93629 11.5637 2.25 8.25 2.25C4.93629 2.25 2.25 4.93629 2.25 8.25C2.25 11.5637 4.93629 14.25 8.25 14.25Z"
stroke="#667085"
strokeWidth="1.75"
strokeLinecap="round"
strokeLinejoin="round"
/>
<path
d="M15.75 15.75L12.5 12.5"
stroke="#667085"
strokeWidth="1.75"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
<input
ref={searchInputRef}
type="text"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
placeholder="Search..."
className="flex-1 bg-transparent text-[14px] font-medium text-[#181818] outline-none placeholder:text-[#98A2B3]"
/>
{searchQuery ? (
<button
type="button"
onClick={() => setSearchQuery("")}
className="text-[#667085] hover:text-[#181818] text-xs font-semibold"
>
</button>
) : null}
</div>
) : null}
<div
ref={listRef}
onWheel={handleListWheel}
onTouchStart={(e) => 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 (
<button
key={option}
type="button"
onClick={() => handleSelectCountry(option)}
className="flex w-full items-center gap-3 text-start cursor-pointer group/opt py-0.5"
>
<div
className={[
"size-[20px] shrink-0 rounded-full transition-all duration-150",
isSelected
? "bg-[#F2465F] shadow-xs"
: "border-[2px] border-[#344054] bg-transparent group-hover/opt:border-[#181818]",
].join(" ")}
/>
<span className="text-[15px] font-bold text-[#181818]">
{option}
</span>
</button>
);
})
) : (
<span className="py-2 text-[13px] text-[#667085]">
No options found
</span>
)}
</div>
</div>
)}
</div>
{/* City Text Input */}
<div className="w-full">
<input
ref={cityInputRef}
type="text"
data-no-auto-focus
disabled={disabled}
value={cityInput}
onChange={handleCityChange}
@ -571,12 +495,12 @@ export function QuestionBirthplace({
</>
) : (
<>
{/* 1. Country Selection Dropdown */}
{/* 1. Country Selection Trigger */}
<div className="relative w-full">
<button
type="button"
disabled={disabled}
onClick={() => setIsOpen(!isOpen)}
onClick={openSheet}
className={[
"flex h-[54px] w-full items-center justify-between rounded-[16px] border bg-white px-4.5 text-start transition-all cursor-pointer outline-none",
isOpen
@ -611,100 +535,14 @@ export function QuestionBirthplace({
/>
</svg>
</button>
{isOpen && (
<div className="absolute top-[calc(100%+8px)] left-0 z-50 flex w-full flex-col gap-3.5 rounded-[20px] bg-white p-4.5 shadow-[0_12px_36px_rgba(0,0,0,0.09)] border border-[#EAECF0] animate-in fade-in zoom-in-95 duration-150">
{options.length > 3 || searchQuery ? (
<div className="flex h-[46px] w-full items-center gap-2.5 rounded-[12px] bg-[#EFEFEF] px-3.5 transition-colors focus-within:bg-[#E8E8E8]">
<svg
width="18"
height="18"
viewBox="0 0 18 18"
fill="none"
className="shrink-0 text-[#667085]"
>
<path
d="M8.25 14.25C11.5637 14.25 14.25 11.5637 14.25 8.25C14.25 4.93629 11.5637 2.25 8.25 2.25C4.93629 2.25 2.25 4.93629 2.25 8.25C2.25 11.5637 4.93629 14.25 8.25 14.25Z"
stroke="#667085"
strokeWidth="1.75"
strokeLinecap="round"
strokeLinejoin="round"
/>
<path
d="M15.75 15.75L12.5 12.5"
stroke="#667085"
strokeWidth="1.75"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
<input
ref={searchInputRef}
type="text"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
placeholder="Search..."
className="flex-1 bg-transparent text-[14px] font-medium text-[#181818] outline-none placeholder:text-[#98A2B3]"
/>
{searchQuery ? (
<button
type="button"
onClick={() => setSearchQuery("")}
className="text-[#667085] hover:text-[#181818] text-xs font-semibold"
>
</button>
) : null}
</div>
) : null}
<div
ref={listRef}
onWheel={handleListWheel}
onTouchStart={(e) => 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 (
<button
key={option}
type="button"
onClick={() => handleSelectCountry(option)}
className="flex w-full items-center gap-3 text-start cursor-pointer group/opt py-0.5"
>
<div
className={[
"size-[20px] shrink-0 rounded-full transition-all duration-150",
isSelected
? "bg-[#F2465F] shadow-xs"
: "border-[2px] border-[#344054] bg-transparent group-hover/opt:border-[#181818]",
].join(" ")}
/>
<span className="text-[15px] font-bold text-[#181818]">
{option}
</span>
</button>
);
})
) : (
<span className="py-2 text-[13px] text-[#667085]">
No options found
</span>
)}
</div>
</div>
)}
</div>
{/* 2. City Text Input */}
<div className="w-full">
<input
ref={cityInputRef}
type="text"
data-no-auto-focus
disabled={disabled}
value={cityInput}
onChange={handleCityChange}
@ -714,6 +552,163 @@ export function QuestionBirthplace({
</div>
</>
)}
{/* Country Selection Bottom Sheet Modal */}
{isOpen &&
createPortal(
<div
className={[
"fixed inset-0 z-50 flex items-end justify-center transition-all duration-[220ms] animate-in fade-in",
isClosing ? "bg-black/0 opacity-0" : "bg-black/55 opacity-100",
].join(" ")}
role="dialog"
aria-modal="true"
aria-label={selectCountryPlaceholder}
onWheel={(event) => event.stopPropagation()}
onTouchStart={(event) => event.stopPropagation()}
onTouchMove={(event) => event.stopPropagation()}
onTouchEnd={(event) => event.stopPropagation()}
onClick={(e) => {
if (e.target === e.currentTarget) {
closeSheet();
}
}}
>
<section
className={[
"flex max-h-[85vh] w-full flex-col overflow-hidden rounded-t-[24px] bg-white shadow-[0_-12px_40px_rgba(0,0,0,0.15)] transition-transform duration-[220ms] ease-out will-change-transform sm:max-w-[440px] animate-in slide-in-from-bottom",
isClosing ? "translate-y-full" : "translate-y-0",
].join(" ")}
onClick={(e) => e.stopPropagation()}
>
{/* Drag Handle Notch */}
<div className="flex w-full justify-center pt-3 pb-1 cursor-grab">
<div className="h-1.25 w-10 rounded-full bg-[#D0D5DD]" />
</div>
{/* Header with Title and Close Button */}
<div className="flex items-center justify-between px-5 pt-2 pb-3 border-b border-[#F2F4F7]">
<h3 className="text-[17px] font-bold text-[#181818] truncate pr-2">
{selectCountryPlaceholder}
</h3>
<button
type="button"
onClick={closeSheet}
className="flex size-8 shrink-0 items-center justify-center rounded-full text-[#667085] hover:bg-[#F2F4F7] hover:text-[#181818] transition-colors cursor-pointer"
aria-label="Close"
>
<svg
width="18"
height="18"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2.2"
strokeLinecap="round"
strokeLinejoin="round"
>
<line x1="18" y1="6" x2="6" y2="18" />
<line x1="6" y1="6" x2="18" y2="18" />
</svg>
</button>
</div>
{/* Search Bar */}
<div className="px-5 pt-3.5 pb-2">
<div className="flex h-[46px] w-full items-center gap-2.5 rounded-[14px] bg-[#F2F4F7] px-3.5 transition-colors focus-within:bg-[#EAECF0]">
<svg
aria-hidden="true"
width="18"
height="18"
viewBox="0 0 18 18"
fill="none"
className="shrink-0 text-[#667085]"
>
<path
d="M8.25 14.25C11.5637 14.25 14.25 11.5637 14.25 8.25C14.25 4.93629 11.5637 2.25 8.25 2.25C4.93629 2.25 2.25 4.93629 2.25 8.25C2.25 11.5637 4.93629 14.25 8.25 14.25Z"
stroke="#667085"
strokeWidth="1.75"
strokeLinecap="round"
strokeLinejoin="round"
/>
<path
d="M15.75 15.75L12.5 12.5"
stroke="#667085"
strokeWidth="1.75"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
<input
type="text"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
placeholder={searchPlaceholder}
className="flex-1 bg-transparent text-[14px] font-medium text-[#181818] outline-none placeholder:text-[#98A2B3]"
/>
{searchQuery ? (
<button
type="button"
onClick={() => setSearchQuery("")}
className="text-[#667085] hover:text-[#181818] text-xs font-semibold p-1 cursor-pointer"
>
</button>
) : null}
</div>
</div>
{/* Country Options List */}
<div
ref={listRef}
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 = selectedCountry === option;
return (
<button
key={option}
type="button"
onClick={() => handleSelectCountry(option)}
className={[
"flex w-full items-center gap-3.5 rounded-[16px] p-3.5 text-start transition-all cursor-pointer border",
isSelected
? "bg-[#FFF4F5] border-[#F0445B]/30 text-[#181818] shadow-xs"
: "bg-[#FAFAFA] hover:bg-[#F2F4F7] border-transparent text-[#181818]",
].join(" ")}
>
<div
className={[
"size-[22px] shrink-0 rounded-full transition-all duration-150 flex items-center justify-center",
isSelected
? "border-[6px] border-[#F0445B] bg-white"
: "border-[2px] border-[#98A2B3] bg-white",
].join(" ")}
/>
<span
className={
isSelected
? "font-bold text-[#181818] text-[15px]"
: "font-semibold text-[#344054] text-[15px]"
}
>
{option}
</span>
</button>
);
})
) : (
<div className="py-8 text-center text-[14px] text-[#667085]">
{noResultsText}
</div>
)}
</div>
</section>
</div>,
document.body,
)}
</div>
);
}

38
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<HTMLElement>(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 (
<Link
href={localizePath(`/questions-list/${item.slug}`, locale)}
aria-label={t["Open {title}"].replace("{title}", item.title)}
className="block rounded-[20px] focus-visible:outline-2 focus-visible:outline-offset-4 focus-visible:outline-[#F26C85]"
onFocus={() => onPrefetch?.(item)}
onPointerDown={() => onPrefetch?.(item)}
onPointerEnter={() => onPrefetch?.(item)}
>
<article
ref={cardRef}
id={item.slug}
data-question-slug={item.slug}
className="rounded-[20px] border border-white/80 bg-white px-3 py-3 shadow-[0_12px_28px_rgba(15,23,42,0.05)] transition-transform duration-200 hover:-translate-y-0.5"
@ -52,7 +84,11 @@ export function QuestionCard({
<div className="flex items-start gap-2.5">
<div className="rounded-[13px] bg-linear-to-br from-[#E03950]/15 to-[#E03950]/0 p-px shadow-[0_8px_18px_rgba(240,67,99,0.18)]">
<div className="relative flex h-[44px] w-[44px] shrink-0 items-center justify-center rounded-[12px] bg-linear-to-br from-[#E03950]/15 to-[#FE6F82]/15 text-white">
<UiIcon name={iconName} aria-hidden="true" className="size-[22px]" />
<UiIcon
name={iconName}
aria-hidden="true"
className="size-[22px]"
/>
</div>
</div>

348
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<HTMLDivElement>(null);
const settleTimeoutRef = useRef<number | null>(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 (
<div
ref={listRef}
aria-label={ariaLabel}
onScroll={handleScroll}
onTouchStart={(event) => 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"
>
<div style={{ height: WHEEL_EDGE_PADDING }} aria-hidden="true" />
{items.map((item, index) => {
const isSelected = index === selectedIndex;
return (
<div
key={item.value}
aria-selected={isSelected}
className="flex h-12 snap-center items-center justify-center"
>
<span
className={[
"text-[15px] transition-colors",
isSelected
? "font-bold text-[#181818]"
: "font-medium text-[#667085]",
].join(" ")}
>
{item.label}
</span>
</div>
);
})}
<div style={{ height: WHEEL_EDGE_PADDING }} aria-hidden="true" />
</div>
);
}
export function QuestionDateSheet({
question,
value,
onApply,
onClose,
}: QuestionDateSheetProps) {
const { dictionary: t, locale } = useI18n();
const [isClosing, setIsClosing] = useState(false);
const parts = value ? value.split("-") : [];
const [selectedYear, setSelectedYear] = useState(parts[0] || YEARS[0]);
const [selectedMonth, setSelectedMonth] = useState(
parts[1] || MONTH_VALUES[0],
);
const [selectedDay, setSelectedDay] = useState(parts[2] || "01");
const dirtyRef = useRef(false);
useSheetScrollLock(true);
const closeSheet = useCallback(() => {
setIsClosing(true);
window.setTimeout(onClose, EXIT_ANIMATION_MS);
}, [onClose]);
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === "Escape") {
closeSheet();
}
};
window.addEventListener("keydown", handleKeyDown);
return () => window.removeEventListener("keydown", handleKeyDown);
}, [closeSheet]);
const months = useMemo(() => {
return MONTH_VALUES.map((val, idx) => {
const date = new Date(2000, idx, 15);
const label = new Intl.DateTimeFormat(`${locale}-u-ca-gregory`, {
month: "long",
}).format(date);
return { value: val, label };
});
}, [locale]);
const days = useMemo(() => {
return Array.from({ length: 31 }, (_, i) => {
const num = i + 1;
const val = num < 10 ? `0${num}` : `${num}`;
return {
value: val,
label: new Intl.NumberFormat(locale).format(num),
};
});
}, [locale]);
const years = useMemo(() => {
return YEARS.map((y) => ({
value: y,
label: new Intl.NumberFormat(locale, { useGrouping: false }).format(
Number.parseInt(y, 10),
),
}));
}, [locale]);
const dayIndex = Math.max(
0,
days.findIndex((d) => d.value === selectedDay),
);
const monthIndex = Math.max(
0,
months.findIndex((m) => m.value === selectedMonth),
);
const yearIndex = Math.max(
0,
years.findIndex((y) => y.value === selectedYear),
);
// Keep the day valid for the selected month/year (Feb, leap years, ...).
useEffect(() => {
const maxDay = daysInMonth(selectedYear, selectedMonth);
const dayNum = Number.parseInt(selectedDay, 10);
if (dayNum > maxDay) {
setSelectedDay(String(maxDay).padStart(2, "0"));
}
}, [selectedYear, selectedMonth, selectedDay]);
// Apply the picked date immediately while spinning the wheels, so the
// field above the sheet always reflects the current selection.
useEffect(() => {
if (!dirtyRef.current) return;
const maxDay = daysInMonth(selectedYear, selectedMonth);
const dayNum = Math.min(Number.parseInt(selectedDay, 10), maxDay);
const formattedDay = String(dayNum).padStart(2, "0");
onApply(
`${selectedYear}-${selectedMonth.padStart(2, "0")}-${formattedDay}`,
);
}, [selectedYear, selectedMonth, selectedDay, onApply]);
const handleSelect = useCallback((part: DatePart, index: number) => {
dirtyRef.current = true;
if (part === "day") {
setSelectedDay(String(index + 1).padStart(2, "0"));
} else if (part === "month") {
setSelectedMonth(MONTH_VALUES[index]);
} else {
setSelectedYear(YEARS[index]);
}
}, []);
const dayLabel = t.Day || "Day";
const monthLabel = t.Month || "Month";
const yearLabel = t.Year || "Year";
return createPortal(
<div
className="fixed inset-0 z-50 flex items-end justify-center bg-transparent"
role="dialog"
aria-modal="true"
aria-label={question.title}
onKeyDown={(event) => {
if (event.key === "Escape") closeSheet();
}}
onClick={(event) => {
if (event.target === event.currentTarget) closeSheet();
}}
>
<section
className={[
"flex w-full flex-col overflow-hidden rounded-t-[22px] bg-white pb-[calc(16px+env(safe-area-inset-bottom))] shadow-[0_-10px_32px_rgba(0,0,0,0.12)] transition-transform duration-[300ms] ease-out sm:max-w-[375px] animate-in slide-in-from-bottom",
isClosing ? "translate-y-full" : "translate-y-0",
].join(" ")}
>
<div className="flex items-center justify-between border-b border-[#F2F4F7] px-5 pb-3 pt-2">
<h3 className="truncate pr-2 text-[17px] font-bold text-[#181818]">
{question.title}
</h3>
<button
type="button"
onClick={closeSheet}
className="flex size-8 shrink-0 cursor-pointer items-center justify-center rounded-full text-[#667085] transition-colors hover:bg-[#F2F4F7] hover:text-[#181818]"
aria-label="Close"
>
<svg
aria-hidden="true"
width="18"
height="18"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2.2"
strokeLinecap="round"
strokeLinejoin="round"
>
<line x1="18" y1="6" x2="6" y2="18" />
<line x1="6" y1="6" x2="18" y2="18" />
</svg>
</button>
</div>
<div className="px-5 pt-4">
<div className="grid grid-cols-3 gap-2 pb-2 text-center text-[11px] font-semibold text-[#667085]">
<span>{dayLabel}</span>
<span>{monthLabel}</span>
<span>{yearLabel}</span>
</div>
<div className="relative">
<div
aria-hidden="true"
className="pointer-events-none absolute inset-x-0 top-1/2 h-12 -translate-y-1/2 rounded-[8px] bg-[#F2F4F7]"
/>
<div className="relative flex gap-2">
<WheelColumn
ariaLabel={dayLabel}
items={days}
selectedIndex={dayIndex}
onSelect={(index) => handleSelect("day", index)}
/>
<WheelColumn
ariaLabel={monthLabel}
items={months}
selectedIndex={monthIndex}
onSelect={(index) => handleSelect("month", index)}
/>
<WheelColumn
ariaLabel={yearLabel}
items={years}
selectedIndex={yearIndex}
onSelect={(index) => handleSelect("year", index)}
/>
</div>
</div>
</div>
</section>
</div>,
document.body,
);
}
export default QuestionDateSheet;

60
src/components/Componentes/question-date.test.tsx

@ -0,0 +1,60 @@
import { fireEvent, render, screen } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { QuestionField } from "@/lib/schema-adapter";
import { QuestionDate } from "./question-date";
let answerValue: string | null = null;
vi.mock("@/translations/provider", () => ({
useI18n: () => ({
locale: "en",
dictionary: { Age: "Age", Day: "Day", Month: "Month", Year: "Year" },
}),
}));
vi.mock("./question-answer-storage", () => ({
useQuestionAnswers: () => ({
getAnswerValue: () => answerValue,
setAnswerValue: vi.fn(),
}),
}));
const question = {
id: "personal_identity.date_of_birth",
title: "Date of Birth",
type: "date",
order: 1,
required: true,
baseRequired: true,
isVisible: true,
description: "",
tooltip: "",
extras: { placeHolder: "YYYY-MM-DD", range: [0, 0], options: [] },
options: [],
ui_config: { isDob: true },
} as QuestionField;
describe("QuestionDate", () => {
beforeEach(() => {
answerValue = null;
});
it("hides the derived age until a birth date exists", () => {
const { rerender } = render(<QuestionDate question={question} />);
expect(screen.queryByText("Age")).toBeNull();
answerValue = "1979-05-15";
rerender(<QuestionDate question={question} />);
expect(screen.getByText("Age")).toBeDefined();
expect(screen.getByDisplayValue(/^\d+$/)).toBeDefined();
});
it("renders calendar years without thousands separators", () => {
render(<QuestionDate question={question} />);
fireEvent.click(screen.getByRole("button", { name: /select date/i }));
const yearWheel = screen.getByLabelText("Year");
expect(yearWheel.textContent).not.toMatch(/[,٬]/);
});
});

293
src/components/Componentes/question-date.tsx

@ -1,9 +1,10 @@
"use client";
import { useEffect, useMemo, useState } from "react";
import { useMemo, useState } from "react";
import type { QuestionField } from "@/lib/schema-adapter";
import { useI18n } from "@/translations/provider";
import { useQuestionAnswers } from "./question-answer-storage";
import QuestionDateSheet from "./question-date-sheet";
import QuestionTitle from "./question-title";
type QuestionDateProps = {
@ -11,80 +12,35 @@ type QuestionDateProps = {
disabled?: boolean;
};
const MONTH_VALUES = [
"01",
"02",
"03",
"04",
"05",
"06",
"07",
"08",
"09",
"10",
"11",
"12",
];
const DAYS = Array.from({ length: 31 }, (_, i) => {
const num = i + 1;
const val = num < 10 ? `0${num}` : `${num}`;
return { value: val, label: `${num}` };
});
const MIN_AGE = 18;
const currentYear = new Date().getFullYear();
const maxBirthYear = currentYear - MIN_AGE;
const YEARS = Array.from({ length: 80 }, (_, i) =>
(maxBirthYear - i).toString(),
);
export function QuestionDate({
question,
disabled,
}: QuestionDateProps) {
const { locale } = useI18n();
function parseDateParts(dateValue: string): {
year: string;
month: string;
day: string;
} {
const parts = dateValue ? dateValue.split("-") : [];
return {
year: parts[0] || "",
month: parts[1] || "",
day: parts[2] || "",
};
}
export function QuestionDate({ question, disabled }: QuestionDateProps) {
const { dictionary: t, locale } = useI18n();
const { getAnswerValue, setAnswerValue } = useQuestionAnswers();
const value = getAnswerValue(question);
const dateValue = typeof value === "string" ? value : "";
const { year, month, day } = parseDateParts(dateValue);
const months = useMemo(() => {
return MONTH_VALUES.map((val, idx) => {
const date = new Date(2000, idx, 15);
const label = new Intl.DateTimeFormat(`${locale}-u-ca-gregory`, {
month: "long",
}).format(date);
return { value: val, label };
});
}, [locale]);
const [selectedYear, setSelectedYear] = useState(() => {
const parts = dateValue ? dateValue.split("-") : [];
return parts[0] || "";
});
const [selectedMonth, setSelectedMonth] = useState(() => {
const parts = dateValue ? dateValue.split("-") : [];
return parts[1] || "";
});
const [selectedDay, setSelectedDay] = useState(() => {
const parts = dateValue ? dateValue.split("-") : [];
return parts[2] || "";
});
useEffect(() => {
const parts = dateValue ? dateValue.split("-") : [];
if (parts.length === 3) {
setSelectedYear(parts[0] || "");
setSelectedMonth(parts[1] || "");
setSelectedDay(parts[2] || "");
}
}, [dateValue]);
const [isOpen, setIsOpen] = useState(false);
const calculatedAge = useMemo(() => {
if (!selectedYear || !selectedMonth || !selectedDay) return null;
const y = Number.parseInt(selectedYear, 10);
const m = Number.parseInt(selectedMonth, 10);
const d = Number.parseInt(selectedDay, 10);
if (!year || !month || !day) return null;
const y = Number.parseInt(year, 10);
const m = Number.parseInt(month, 10);
const d = Number.parseInt(day, 10);
if (
!y ||
@ -108,37 +64,35 @@ export function QuestionDate({
}
return age >= 0 ? age : null;
}, [selectedYear, selectedMonth, selectedDay]);
}, [year, month, day]);
const isUnder18 = calculatedAge !== null && calculatedAge < MIN_AGE;
const updateDate = (y: string, m: string, d: string) => {
setSelectedYear(y);
setSelectedMonth(m);
setSelectedDay(d);
if (y && m && d) {
const formattedMonth = m.padStart(2, "0");
const formattedDay = d.padStart(2, "0");
setAnswerValue(
question, `${y}-${formattedMonth}-${formattedDay}`,
);
} else {
setAnswerValue(question, "");
}
};
const handleDayChange = (newDay: string) => {
updateDate(selectedYear, selectedMonth, newDay);
const displayDate = useMemo(() => {
if (!year || !month || !day) return "";
const date = new Date(
Number.parseInt(year, 10),
Number.parseInt(month, 10) - 1,
Number.parseInt(day, 10),
);
return new Intl.DateTimeFormat(`${locale}-u-ca-gregory`, {
day: "numeric",
month: "long",
year: "numeric",
}).format(date);
}, [year, month, day, locale]);
const openSheet = () => {
if (disabled) return;
setIsOpen(true);
};
const handleMonthChange = (newMonth: string) => {
updateDate(selectedYear, newMonth, selectedDay);
const handleApply = (formattedDate: string) => {
setAnswerValue(question, formattedDate);
};
const handleYearChange = (newYear: string) => {
updateDate(newYear, selectedMonth, selectedDay);
};
const placeholder =
t["Select date"] || (locale === "fa" ? "انتخاب تاریخ" : "Select date");
return (
<div
@ -149,82 +103,93 @@ export function QuestionDate({
>
<QuestionTitle question={question} />
{/* 3 Select Dropdowns: Day, Month, Year */}
<div className="grid grid-cols-3 gap-2">
{/* Day / روز */}
<select
value={selectedDay}
onChange={(e) => handleDayChange(e.target.value)}
disabled={disabled}
aria-label="Day"
className="h-[54px] w-full cursor-pointer rounded-[16px] border border-[#D0D5DD] bg-white px-3 text-[15px] font-medium text-[#181818] outline-none transition-all hover:border-[#98A2B3] focus:border-[#6F6F6F] focus:ring-1 focus:ring-[#6F6F6F]"
>
<option value="">Day</option>
{DAYS.map((d) => (
<option key={d.value} value={d.value}>
{d.label}
</option>
))}
</select>
{/* Month */}
<select
value={selectedMonth}
onChange={(e) => handleMonthChange(e.target.value)}
disabled={disabled}
aria-label="Month"
className="h-[54px] w-full cursor-pointer rounded-[16px] border border-[#D0D5DD] bg-white px-3 text-[15px] font-medium text-[#181818] outline-none transition-all hover:border-[#98A2B3] focus:border-[#6F6F6F] focus:ring-1 focus:ring-[#6F6F6F]"
>
<option value="">Month</option>
{months.map((m) => (
<option key={m.value} value={m.value}>
{m.label}
</option>
))}
</select>
{/* Year */}
<select
value={selectedYear}
onChange={(e) => handleYearChange(e.target.value)}
disabled={disabled}
aria-label="Year"
className="h-[54px] w-full cursor-pointer rounded-[16px] border border-[#D0D5DD] bg-white px-3 text-[15px] font-medium text-[#181818] outline-none transition-all hover:border-[#98A2B3] focus:border-[#6F6F6F] focus:ring-1 focus:ring-[#6F6F6F]"
{/* Date Trigger Field (single input, like the Flutter app's DOB field) */}
<button
type="button"
disabled={disabled}
onClick={openSheet}
aria-haspopup="dialog"
className={[
"flex h-[54px] w-full cursor-pointer items-center gap-2.5 rounded-[16px] border bg-white px-4 text-start transition-all outline-none",
isOpen
? "border-[#6F6F6F] ring-1 ring-[#6F6F6F]"
: "border-[#D0D5DD] hover:border-[#98A2B3]",
].join(" ")}
>
<svg
aria-hidden="true"
width="20"
height="20"
viewBox="0 0 20 20"
fill="none"
className="shrink-0 text-[#667085]"
>
<option value="">Year</option>
{YEARS.map((y) => (
<option key={y} value={y}>
{y}
</option>
))}
</select>
</div>
{/* Display Calculated Age */}
<div className="mt-2.5 flex w-full flex-col gap-2">
<span className="block group-14 leading-tight font-semibold text-[#181818]">
Age
</span>
<input
type="text"
disabled
readOnly
value={calculatedAge !== null ? calculatedAge : ""}
<path
d="M15.5 3H14V2.25C14 1.836 13.664 1.5 13.25 1.5C12.836 1.5 12.5 1.836 12.5 2.25V3H7.5V2.25C7.5 1.836 7.166 1.5 6.75 1.5C6.336 1.5 6 1.836 6 2.25V3H4.5C3.671 3 3 3.671 3 4.5V15.5C3 16.329 3.671 17 4.5 17H15.5C16.329 17 17 16.329 17 15.5V4.5C17 3.671 16.329 3 15.5 3ZM15.5 15.5H4.5V7H15.5V15.5Z"
fill="currentColor"
/>
</svg>
<span
className={[
"h-[54px] w-full rounded-[16px] border px-4.5 text-[15px] font-medium outline-none cursor-not-allowed disabled:bg-[#F5F2F1] disabled:text-[#7C7472]",
isUnder18
? "border-[#F2465F] text-[#F2465F] ring-1 ring-[#F2465F]"
: "border-[#D0D5DD] text-[#7C7472]",
"flex-1 truncate text-[15px] font-medium",
displayDate ? "text-[#181818]" : "text-[#667085]",
].join(" ")}
/>
{isUnder18 ? (
<span className="block text-[11px] font-semibold text-[#F2465F]">
{locale === "fa"
? "حداقل سن برای ثبت‌نام ۱۸ سال می‌باشد."
: "Minimum age required for registration is 18 years."}
>
{displayDate || placeholder}
</span>
<svg
aria-hidden="true"
width="16"
height="10"
viewBox="0 0 16 10"
fill="none"
className="shrink-0 transition-transform duration-200"
>
<path
d="M14.75 1.25L7.75 8.25L0.75 1.25"
stroke="#344054"
strokeWidth="1.75"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
</button>
{calculatedAge !== null ? (
<div className="mt-2.5 flex w-full flex-col gap-2">
<span className="group-14 block leading-tight font-semibold text-[#181818]">
{t.Age || (locale === "fa" ? "سن" : "Age")}
</span>
) : null}
</div>
<input
type="text"
disabled
readOnly
value={calculatedAge}
className={[
"h-[54px] w-full cursor-not-allowed rounded-[16px] border px-4.5 text-[15px] font-medium outline-none disabled:bg-[#F5F2F1] disabled:text-[#7C7472]",
isUnder18
? "border-[#F2465F] text-[#F2465F] ring-1 ring-[#F2465F]"
: "border-[#D0D5DD] text-[#7C7472]",
].join(" ")}
/>
{isUnder18 ? (
<span className="block text-[11px] font-semibold text-[#F2465F]">
{locale === "fa"
? "حداقل سن برای ثبت‌نام ۱۸ سال می‌باشد."
: "Minimum age required for registration is 18 years."}
</span>
) : null}
</div>
) : null}
{isOpen && (
<QuestionDateSheet
question={question}
value={dateValue}
onApply={handleApply}
onClose={() => setIsOpen(false)}
/>
)}
</div>
);
}

390
src/components/Componentes/question-phone.tsx

@ -1,13 +1,16 @@
"use client";
import { PhoneNumberFormat, PhoneNumberUtil } from "google-libphonenumber";
import { useEffect, useRef, useState, useMemo, useCallback } from "react";
import type { QuestionField } from "@/lib/schema-adapter";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { createPortal } from "react-dom";
import type { MarriagePhoneFieldValue } from "@/hooks/marriage/types";
import type { QuestionField } from "@/lib/schema-adapter";
import { useI18n } from "@/translations/provider";
import { useQuestionAnswers } from "./question-answer-storage";
import QuestionTitle from "./question-title";
import { LoadingSkeleton } from "./loading-skeleton";
import { useI18n } from "@/translations/provider";
import { useSheetScrollLock } from "./use-sheet-scroll-lock";
const EXIT_ANIMATION_MS = 300;
type QuestionPhoneProps = {
question: QuestionField;
@ -226,7 +229,7 @@ export function QuestionPhone({
countryCode = "+44",
disabled,
}: QuestionPhoneProps) {
const { locale } = useI18n();
const { dictionary: t, locale } = useI18n();
const { getAnswerValue, setAnswerValue, isLoading } = useQuestionAnswers();
const value = getAnswerValue(question);
const defaultCodeValue = countryCode.trim() || "+44";
@ -263,29 +266,25 @@ export function QuestionPhone({
const hasFetchedIpRef = useRef(false);
const userInteractedRef = useRef(false);
const needsIpFetch = useCallback(() => {
if (typeof window === "undefined") return false;
if (localStorage.getItem("geoIPPhoneCode")) return false;
if (localStorage.getItem("hasCheckedGeoIPPhone")) return false;
return true;
}, []);
const [isResolvingCode, setIsResolvingCode] = useState(() => {
if (isLoading) return true;
const hasSavedValue =
value &&
((isMarriagePhoneFieldValue(value) &&
(value.countryCode || value.phoneNumber)) ||
(typeof value === "string" && value.trim().length > 0));
if (hasSavedValue) return false;
return needsIpFetch();
});
const [isOpen, setIsOpen] = useState(false);
const [isClosing, setIsClosing] = useState(false);
const [searchQuery, setSearchQuery] = useState("");
const containerRef = useRef<HTMLDivElement>(null);
const listRef = useRef<HTMLDivElement>(null);
const searchInputRef = useRef<HTMLInputElement>(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]);
const normalizedPhoneState = getNormalizedPhoneValue(codeValue, phoneValue);
const showInvalidState =
@ -333,7 +332,7 @@ export function QuestionPhone({
code: `+${callingCode}`,
flag,
});
} catch (e) {}
} catch {}
}
return list.sort((a, b) => a.name.localeCompare(b.name));
@ -348,6 +347,13 @@ export function QuestionPhone({
return match ? match.flag : "🏳️";
}, [codeValue, countryList]);
const selectedCountry = useMemo(() => {
const cleanActiveCode = codeValue.replace(/[^\d]/g, "");
return countryList.find(
(country) => country.code.replace(/[^\d]/g, "") === cleanActiveCode,
);
}, [codeValue, countryList]);
const filteredCountries = useMemo(() => {
const q = searchQuery.toLowerCase().trim();
if (!q) return countryList;
@ -357,48 +363,17 @@ export function QuestionPhone({
);
}, [countryList, searchQuery]);
// 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]);
useSheetScrollLock(isOpen);
useEffect(() => {
if (isOpen) {
const timer = setTimeout(() => {
searchInputRef.current?.focus();
}, 50);
return () => clearTimeout(timer);
}
}, [isOpen]);
if (!isOpen) return;
// Close dropdown on click outside
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 handleKeyDown = (event: KeyboardEvent) => {
if (event.key === "Escape") closeSheet();
};
}, []);
window.addEventListener("keydown", handleKeyDown);
return () => window.removeEventListener("keydown", handleKeyDown);
}, [closeSheet, isOpen]);
// Sync state if external value changes (e.g. backend data loaded)
useEffect(() => {
@ -497,7 +472,7 @@ export function QuestionPhone({
.then((res) => res.json())
.then((data) => {
clearTimeout(timeoutId);
if (data && data.country_calling_code) {
if (data?.country_calling_code) {
applyCode(data.country_calling_code);
} else {
throw new Error("No calling code in response");
@ -515,7 +490,7 @@ export function QuestionPhone({
.then((res) => res.json())
.then((data) => {
clearTimeout(secondaryTimeoutId);
if (data && data.calling_code) {
if (data?.calling_code) {
applyCode(data.calling_code);
} else {
applyFallback();
@ -561,13 +536,13 @@ export function QuestionPhone({
setCodeValue(selectedCode);
setPhoneValue(truncatedPhone);
updateStoredValue(selectedCode, truncatedPhone);
setIsOpen(false);
setSearchQuery("");
closeSheet();
};
const selectCountryTitle = t["Select country"] || question.title;
return (
<div
ref={containerRef}
data-question-answered={isAnswered ? "true" : "false"}
data-question-type={question.type}
className={[
@ -589,12 +564,13 @@ export function QuestionPhone({
<button
type="button"
disabled={disabled}
onClick={() => setIsOpen(!isOpen)}
onClick={openSheet}
className="flex items-center gap-1.5 h-full px-2 rounded-lg hover:bg-gray-50 active:bg-gray-100 transition-colors cursor-pointer outline-none border-0 text-[15px] font-medium leading-none text-[#181818] tabular-nums"
>
<span>{activeFlag}</span>
<span>{codeValue || defaultCodeValue}</span>
<svg
aria-hidden="true"
width="10"
height="6"
viewBox="0 0 10 6"
@ -613,41 +589,33 @@ export function QuestionPhone({
/>
</svg>
</button>
<span
aria-hidden="true"
className="h-5 w-px bg-[#181818]/35 ml-1"
/>
<span aria-hidden="true" className="h-5 w-px bg-[#181818]/35 ml-1" />
</div>
<span className="flex min-w-0 flex-1 items-center pr-4">
<input
type="tel"
inputMode="tel"
disabled={disabled}
placeholder={question.extras.placeHolder?.replace(
/^\+\d+\s*/,
"",
)}
value={phoneValue}
maxLength={getMaxLengthForCountry(codeValue)}
onChange={(event) => {
userInteractedRef.current = true;
if (typeof window !== "undefined") {
localStorage.setItem("geoIPPhoneCode", codeValue);
localStorage.setItem("hasCheckedGeoIPPhone", "true");
}
const nextPhoneValue = sanitizePhoneNumber(
event.target.value,
);
const maxLen = getMaxLengthForCountry(codeValue);
const truncatedPhone = nextPhoneValue.slice(0, maxLen);
setPhoneValue(truncatedPhone);
updateStoredValue(codeValue, truncatedPhone);
}}
dir="ltr"
className="h-full w-full border-0 bg-transparent p-0 text-left text-[15px] font-medium leading-none text-[#181818] tabular-nums outline-none placeholder:text-[#98A2B3]"
/>
</span>
<input
type="tel"
inputMode="tel"
disabled={disabled}
placeholder={question.extras.placeHolder?.replace(/^\+\d+\s*/, "")}
value={phoneValue}
maxLength={getMaxLengthForCountry(codeValue)}
onChange={(event) => {
userInteractedRef.current = true;
if (typeof window !== "undefined") {
localStorage.setItem("geoIPPhoneCode", codeValue);
localStorage.setItem("hasCheckedGeoIPPhone", "true");
}
const nextPhoneValue = sanitizePhoneNumber(event.target.value);
const maxLen = getMaxLengthForCountry(codeValue);
const truncatedPhone = nextPhoneValue.slice(0, maxLen);
setPhoneValue(truncatedPhone);
updateStoredValue(codeValue, truncatedPhone);
}}
dir="ltr"
className="h-full w-full border-0 bg-transparent p-0 text-left text-[15px] font-medium leading-none text-[#181818] tabular-nums outline-none placeholder:text-[#98A2B3]"
/>
</span>
</div>
{showInvalidState ? (
<span className="block group-10 font-semibold text-[#F2465F]">
@ -655,90 +623,146 @@ export function QuestionPhone({
</span>
) : null}
{/* Dropdown Options Panel */}
{isOpen && (
<div className="absolute top-[calc(100%+8px)] left-0 z-50 flex w-full flex-col gap-3.5 rounded-[20px] bg-white p-4.5 shadow-[0_12px_36px_rgba(0,0,0,0.09)] border border-[#EAECF0] animate-in fade-in zoom-in-95 duration-150">
{/* Search Input Bar */}
<div className="flex h-[46px] w-full items-center gap-2.5 rounded-[12px] bg-[#EFEFEF] px-3.5 transition-colors focus-within:bg-[#E8E8E8]">
<svg
width="18"
height="18"
viewBox="0 0 18 18"
fill="none"
className="shrink-0 text-[#667085]"
>
<path
d="M8.25 14.25C11.5637 14.25 14.25 11.5637 14.25 8.25C14.25 4.93629 11.5637 2.25 8.25 2.25C4.93629 2.25 2.25 4.93629 2.25 8.25C2.25 11.5637 4.93629 14.25 8.25 14.25Z"
stroke="#667085"
strokeWidth="1.75"
strokeLinecap="round"
strokeLinejoin="round"
/>
<path
d="M15.75 15.75L12.5 12.5"
stroke="#667085"
strokeWidth="1.75"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
<input
ref={searchInputRef}
type="text"
value={searchQuery}
onChange={(e) => 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 ? (
<button
type="button"
onClick={() => setSearchQuery("")}
className="text-[#667085] hover:text-[#181818] text-xs font-semibold"
>
</button>
) : null}
</div>
{/* Country List */}
{/* The phone field moves up while the country sheet enters from below. */}
{isOpen &&
createPortal(
<div
ref={listRef}
className="flex max-h-[220px] flex-col gap-3.5 overflow-y-auto overscroll-contain pr-1"
className="fixed inset-0 z-50 flex items-end justify-center bg-transparent"
role="dialog"
aria-label={selectCountryTitle}
onKeyDown={(event) => {
if (event.key === "Escape") closeSheet();
}}
onClick={(event) => {
if (event.target === event.currentTarget) closeSheet();
}}
>
{filteredCountries.length > 0 ? (
filteredCountries.map((c) => {
return (
<button
key={`${c.name}-${c.code}`}
type="button"
onClick={() => handleSelectCountryCode(c.code)}
className="flex w-full items-center justify-between text-start cursor-pointer group/opt py-0.5"
<section
className={[
"flex h-[68svh] w-full flex-col overflow-hidden rounded-t-[22px] bg-white pb-[env(safe-area-inset-bottom)] shadow-[0_-10px_32px_rgba(0,0,0,0.12)] transition-transform duration-[300ms] ease-out sm:max-w-[375px] animate-in slide-in-from-bottom",
isClosing ? "translate-y-full" : "translate-y-0",
].join(" ")}
>
<header className="shrink-0 px-5 pb-2 pt-1">
<h2 className="text-[17px] font-bold leading-6 text-[#181818]">
{selectCountryTitle}
</h2>
</header>
<div className="shrink-0 border-b border-[#EAECF0] px-5 pb-3">
<div className="flex h-[46px] w-full items-center gap-2.5 rounded-[12px] bg-[#F2F4F7] px-3.5 transition-colors focus-within:bg-[#EAECF0]">
<svg
width="18"
height="18"
viewBox="0 0 18 18"
fill="none"
className="shrink-0 text-[#667085]"
aria-hidden="true"
>
<div className="flex items-center gap-3">
<span className="text-[20px]">{c.flag}</span>
<span className="text-[15px] font-bold text-[#181818]">
{c.name}
</span>
</div>
<span className="text-[15px] font-semibold text-[#667085] tabular-nums">
{c.code}
</span>
</button>
);
})
) : (
<span className="py-2 text-[13px] text-[#667085]">
{locale === "fa" ? "موردی یافت نشد" : "No options found"}
</span>
)}
</div>
</div>
)}
<path
d="M8.25 14.25C11.5637 14.25 14.25 11.5637 14.25 8.25C14.25 4.93629 11.5637 2.25 8.25 2.25C4.93629 2.25 2.25 4.93629 2.25 8.25C2.25 11.5637 4.93629 14.25 8.25 14.25Z"
stroke="#667085"
strokeWidth="1.75"
strokeLinecap="round"
strokeLinejoin="round"
/>
<path
d="M15.75 15.75L12.5 12.5"
stroke="#667085"
strokeWidth="1.75"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
<input
ref={searchInputRef}
type="text"
value={searchQuery}
onChange={(e) => 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 ? (
<button
type="button"
onClick={() => setSearchQuery("")}
aria-label={selectCountryTitle}
className="flex size-7 items-center justify-center rounded-md text-[#667085] hover:bg-white hover:text-[#181818] text-xs font-semibold"
>
</button>
) : null}
</div>
</div>
<div
ref={listRef}
onTouchStart={(event) => 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 (
<button
key={`${c.name}-${c.code}`}
type="button"
onClick={() => handleSelectCountryCode(c.code)}
className="flex min-h-[50px] w-full items-center gap-3 border-b border-[#F2F4F7] px-0 text-start cursor-pointer transition-colors hover:bg-[#F9FAFB] focus-visible:outline-2 focus-visible:outline-offset-[-2px] focus-visible:outline-[#667085]"
>
<span className="text-[20px]" aria-hidden="true">
{c.flag}
</span>
<span className="flex min-w-0 flex-1 items-center justify-between gap-3">
<span className="truncate text-[15px] font-semibold text-[#181818]">
{c.name}
</span>
<span
dir="ltr"
className="shrink-0 text-[14px] font-medium text-[#667085] tabular-nums"
>
{c.code}
</span>
</span>
{c === selectedCountry ? (
<svg
aria-hidden="true"
width="20"
height="20"
viewBox="0 0 20 20"
fill="none"
className="shrink-0 text-[#F0445B]"
>
<path
d="M4 10.25L8 14L16 6"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
) : (
<span aria-hidden="true" className="w-5 shrink-0" />
)}
</button>
);
})
) : (
<span className="py-2 text-[13px] text-[#667085]">
{locale === "fa" ? "موردی یافت نشد" : "No options found"}
</span>
)}
</div>
</section>
</div>,
document.body,
)}
</div>
);
}

2
src/components/Componentes/question-progress-tracker.tsx

@ -171,7 +171,7 @@ export function QuestionProgressTracker({
onChange={updateProgress}
onInput={updateProgress}
>
<div className="w-full shrink-0 px-[17px] pt-2 pb-[24px]">
<div className="question-progress w-full shrink-0 px-[17px] pt-2 pb-[24px]">
<div
aria-label={`Answered questions: ${answered} of ${safeTotal}`}
aria-valuemax={safeTotal}

4
src/components/Componentes/question-renderer.tsx

@ -5,7 +5,7 @@ import QuestionBirthplace from "./question-birthplace";
import QuestionButton from "./question-button";
import QuestionCheckbox from "./question-checkbox";
import QuestionDate from "./question-date";
import QuestionDropdown from "./question-dropdown";
import QuestionSheet from "./question-sheet";
import QuestionFile from "./question-file";
import QuestionNumber from "./question-number";
import QuestionPhone from "./question-phone";
@ -62,7 +62,7 @@ export function QuestionRenderer({
);
case "dropdown":
return (
<QuestionDropdown
<QuestionSheet
question={question}
disabled={disabled}
/>

247
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(
<div className="app-shell">
<QueryClientProvider client={queryClient}>
<QuestionAnswersProvider slug="test" questions={[qDropdown]}>
<QuestionSheet question={qDropdown} />
</QuestionAnswersProvider>
</QueryClientProvider>
</div>,
);
// 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(
<QueryClientProvider client={queryClient}>
<QuestionAnswersProvider slug="test" questions={[qMulti]}>
<QuestionSheet question={qMulti} />
</QuestionAnswersProvider>
</QueryClientProvider>,
);
// 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(
<QueryClientProvider client={queryClient}>
<QuestionAnswersProvider slug="test" questions={[question]}>
<QuestionSheet question={question} />
</QuestionAnswersProvider>
</QueryClientProvider>,
);
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(
<QueryClientProvider client={queryClient}>
<QuestionAnswersProvider slug="test" questions={[question]}>
<QuestionSheet question={question} />
</QuestionAnswersProvider>
</QueryClientProvider>,
);
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(
<QueryClientProvider client={queryClient}>
<QuestionAnswersProvider slug="test" questions={[question]}>
<QuestionSheet question={question} />
</QuestionAnswersProvider>
</QueryClientProvider>,
);
fireEvent.click(screen.getByRole("button", { name: "انتخاب" }));
expect(screen.getByRole("dialog").querySelector("section")).toHaveClass(
"h-auto",
"max-h-[82svh]",
);
});
});

416
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<HTMLDivElement>(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 (
<div
className={[
"relative flex w-full flex-col gap-2.5 transition-opacity duration-200",
disabled ? "pointer-events-none opacity-30" : "",
].join(" ")}
>
<QuestionTitle question={question} />
{/* Select Trigger Button (Input style) */}
<button
type="button"
disabled={disabled}
onClick={openSheet}
className={[
"flex h-[54px] w-full items-center justify-between rounded-[16px] border bg-white px-4.5 text-start transition-all cursor-pointer outline-none",
isOpen
? "border-[#6F6F6F] ring-1 ring-[#6F6F6F]"
: "border-[#D0D5DD] hover:border-[#98A2B3]",
].join(" ")}
>
<span
className={[
"text-[15px] font-medium truncate",
hasSelectedValue ? "text-[#181818]" : "text-[#667085]",
].join(" ")}
>
{displayLabel}
</span>
<svg
aria-hidden="true"
width="16"
height="10"
viewBox="0 0 16 10"
fill="none"
className={[
"shrink-0 transition-transform duration-200",
isOpen ? "rotate-180" : "",
].join(" ")}
>
<path
d="M14.75 1.25L7.75 8.25L0.75 1.25"
stroke="#344054"
strokeWidth="1.75"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
</button>
{/* The active field moves up while the options sheet enters from below. */}
{isOpen &&
createPortal(
<div
className="fixed inset-0 z-50 flex items-end justify-center bg-transparent"
role="dialog"
aria-label={question.title}
onKeyDown={(e) => {
if (e.key === "Escape") closeSheet();
}}
onClick={(event) => {
if (event.target === event.currentTarget) closeSheet();
}}
>
<section
className={[
"flex w-full flex-col overflow-hidden rounded-t-[22px] bg-white pb-[env(safe-area-inset-bottom)] shadow-[0_-10px_32px_rgba(0,0,0,0.12)] transition-transform duration-[300ms] ease-out sm:max-w-[375px] animate-in slide-in-from-bottom",
isCompact ? "h-auto max-h-[82svh]" : "h-[82svh]",
isClosing ? "translate-y-full" : "translate-y-0",
].join(" ")}
>
<div className="flex items-center justify-between px-5 pt-2 pb-3 border-b border-[#F2F4F7]">
<h3 className="text-[17px] font-bold text-[#181818] truncate pr-2">
{question.title}
</h3>
<button
type="button"
onClick={closeSheet}
className="flex size-8 shrink-0 items-center justify-center rounded-full text-[#667085] hover:bg-[#F2F4F7] hover:text-[#181818] transition-colors cursor-pointer"
aria-label="Close"
>
<svg
aria-hidden="true"
width="18"
height="18"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2.2"
strokeLinecap="round"
strokeLinejoin="round"
>
<line x1="18" y1="6" x2="6" y2="18" />
<line x1="6" y1="6" x2="18" y2="18" />
</svg>
</button>
</div>
{/* Search Bar */}
{showSearch && (
<div className="px-5 pt-3.5 pb-2">
<div className="flex h-[46px] w-full items-center gap-2.5 rounded-[14px] bg-[#F2F4F7] px-3.5 transition-colors focus-within:bg-[#EAECF0]">
<svg
aria-hidden="true"
width="18"
height="18"
viewBox="0 0 18 18"
fill="none"
className="shrink-0 text-[#667085]"
>
<path
d="M8.25 14.25C11.5637 14.25 14.25 11.5637 14.25 8.25C14.25 4.93629 11.5637 2.25 8.25 2.25C4.93629 2.25 2.25 4.93629 2.25 8.25C2.25 11.5637 4.93629 14.25 8.25 14.25Z"
stroke="#667085"
strokeWidth="1.75"
strokeLinecap="round"
strokeLinejoin="round"
/>
<path
d="M15.75 15.75L12.5 12.5"
stroke="#667085"
strokeWidth="1.75"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
<input
type="text"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
placeholder={searchPlaceholder}
className="flex-1 bg-transparent text-[14px] font-medium text-[#181818] outline-none placeholder:text-[#98A2B3]"
/>
{searchQuery ? (
<button
type="button"
onClick={() => setSearchQuery("")}
className="text-[#667085] hover:text-[#181818] text-xs font-semibold p-1 cursor-pointer"
>
</button>
) : null}
</div>
</div>
)}
{/* Options List */}
<div
ref={listRef}
onTouchStart={(event) => 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 (
<button
key={option.id}
type="button"
onClick={() => {
if (isMulti) {
toggleMultiOption(option.id);
} else {
handleSelectSingle(option.id);
}
}}
className={[
"flex min-h-12 w-full items-start gap-3 rounded-lg border px-3 py-3 text-start transition-colors cursor-pointer",
isSelected
? "bg-[#FFF4F5] border-[#F0445B]/30 text-[#181818]"
: "bg-white hover:bg-[#F2F4F7] border-[#EAECF0] text-[#181818]",
].join(" ")}
>
{/* Indicator Icon */}
{isMulti ? (
<div
className={[
"size-[22px] shrink-0 rounded-[6px] transition-all duration-150 mt-0.5 flex items-center justify-center",
isSelected
? "bg-[#F0445B] text-white shadow-xs"
: "border-[2px] border-[#98A2B3] bg-white",
].join(" ")}
>
{isSelected && (
<svg
aria-hidden="true"
width="12"
height="10"
viewBox="0 0 12 10"
fill="none"
>
<path
d="M1.5 5L4.5 8L10.5 2"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
)}
</div>
) : (
<div
className={[
"size-[22px] shrink-0 rounded-full transition-all duration-150 mt-0.5 flex items-center justify-center",
isSelected
? "border-[6px] border-[#F0445B] bg-white"
: "border-[2px] border-[#98A2B3] bg-white",
].join(" ")}
/>
)}
<span className="text-[15px] leading-snug flex-1">
{option.label.includes(" - ") ? (
(() => {
const parts = option.label.split(" - ");
const title = parts[0];
const description = parts.slice(1).join(" - ");
return (
<span className="flex flex-col gap-1 text-start">
<span className="font-bold text-[#181818]">
{title}
</span>
<ExplanationUiFont>
{description}
</ExplanationUiFont>
</span>
);
})()
) : (
<span
className={
isSelected
? "font-bold text-[#181818]"
: "font-semibold text-[#344054]"
}
>
{option.label}
</span>
)}
</span>
</button>
);
})
) : (
<div className="py-8 text-center text-[14px] text-[#667085]">
{noResultsText}
</div>
)}
</div>
{/* Bottom Actions for Multi-Select */}
{isMulti && (
<div className="border-t border-[#F2F4F7] bg-white p-4 pt-3 pb-[calc(14px+env(safe-area-inset-bottom))]">
<Button
type="button"
variant="default"
onClick={closeSheet}
className="w-full h-[50px] rounded-[14px] text-[15px] font-bold shadow-[0_8px_20px_rgba(240,68,91,0.25)]"
>
{selectedList.length > 0
? `${confirmText} (${selectedList.length})`
: confirmText}
</Button>
</div>
)}
</section>
</div>,
document.body,
)}
</div>
);
}
export default QuestionSheet;

345
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<number | null>(null);
const touchStartYRef = useRef<number | null>(null);
const questionRefs = useRef<Array<HTMLDivElement | null>>([]);
const containerRef = useRef<HTMLElement | null>(null);
const previousActiveIndexRef = useRef<number | null>(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<SnapDragState>({
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<number>([
...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<HTMLElement>) => {
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<HTMLElement>) => {
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<HTMLElement>) => {
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 (
<section
ref={containerRef}
aria-label="Questions"
className={[
"relative touch-none overflow-hidden focus-visible:outline-none",
"question-snap-list relative touch-none overflow-hidden focus-visible:outline-none",
"flex-1 min-h-0 pt-4 pb-4",
className,
]
.filter(Boolean)
.join(" ")}
onTouchCancel={handleTouchCancel}
onTouchEnd={handleTouchEnd}
onTouchMove={handleTouchMove}
onTouchStart={handleTouchStart}
@ -326,7 +617,7 @@ export function QuestionSnapList({
: String(question);
let containerStyles = "";
let wrapperStyles = "w-full";
let wrapperStyles = "w-full my-auto shrink-0";
if (isActive) {
containerStyles =
@ -336,19 +627,13 @@ export function QuestionSnapList({
: "w-full my-auto shrink-0";
} else if (isPrev) {
containerStyles =
"top-4 left-0 z-0 opacity-0 pointer-events-none scale-[0.98]";
wrapperStyles =
"w-full max-h-[110px] overflow-hidden pointer-events-none";
"top-0 left-0 z-0 opacity-0 pointer-events-none -translate-y-full";
} else if (isNext) {
containerStyles =
"bottom-4 left-0 z-0 opacity-0 pointer-events-none scale-[0.98]";
wrapperStyles =
"w-full max-h-[110px] overflow-hidden pointer-events-none";
"top-0 left-0 z-0 opacity-0 pointer-events-none translate-y-full";
} else {
containerStyles =
"top-1/2 left-0 -translate-y-1/2 z-0 opacity-0 pointer-events-none scale-[0.95]";
wrapperStyles =
"w-full max-h-[110px] overflow-hidden pointer-events-none";
}
return (
@ -361,11 +646,13 @@ export function QuestionSnapList({
aria-hidden={isActive ? undefined : true}
inert={isActive ? undefined : true}
className={[
"absolute flex w-full flex-col justify-center transition-all duration-300 ease-out px-[17px]",
"question-snap-item absolute flex w-full flex-col justify-center transition-all duration-300 ease-out px-[17px]",
containerStyles,
].join(" ")}
>
<div className={wrapperStyles}>{question}</div>
<div className={`question-snap-content ${wrapperStyles}`}>
{question}
</div>
{isActive && (
<div className="h-[20%] shrink min-h-0" aria-hidden="true" />
)}

31
src/components/Componentes/required-steps-card.test.tsx

@ -0,0 +1,31 @@
import { render, screen } from "@testing-library/react";
import { describe, expect, it, vi } from "vitest";
import RequiredStepsCard from "./required-steps-card";
vi.mock("@/translations/provider", () => ({
useI18n: () => ({
dictionary: new Proxy({}, { get: (_target, key) => String(key) }),
}),
}));
describe("RequiredStepsCard", () => {
it("renders a real empty 0/9 state without a shimmer", () => {
const { container } = render(<RequiredStepsCard completed={0} total={9} />);
expect(screen.getByText("0/9")).toBeDefined();
expect(container.querySelector(".shimmer-white-bg")).toBeNull();
expect(container.querySelectorAll("circle")[1].getAttribute("stroke-dashoffset")).toBe(
String(2 * Math.PI * 25),
);
});
it("renders required section completion independently from optional assessments", () => {
const { container } = render(<RequiredStepsCard completed={4} total={9} />);
expect(screen.getByText("4/9")).toBeDefined();
const offset = Number(
container.querySelectorAll("circle")[1].getAttribute("stroke-dashoffset"),
);
expect(offset).toBeCloseTo(2 * Math.PI * 25 * (5 / 9));
});
});

86
src/components/Componentes/required-steps-card.tsx

@ -1,63 +1,24 @@
"use client";
import { useMemo } from "react";
import { IoAlert, IoCheckmark } from "react-icons/io5";
import { useFormSchemaQuery } from "@/hooks/marriage/use-form-schema";
import { convertSchemaToFrontendItems } from "@/lib/schema-adapter";
import { useI18n } from "@/translations/provider";
import type { QuestionListItem } from "@/lib/schema-adapter";
type RequiredStepsCardProps = {
items?: QuestionListItem[];
progressBySlug?: Map<string, number>;
completed: number;
total: number;
};
type RequiredStep = {
slug: string;
required: boolean;
progress: number;
};
function getRequiredStepStats(steps: RequiredStep[]) {
const requiredSteps = steps.filter((step) => step.required);
const completedSteps = requiredSteps.filter((step) => step.progress >= 100);
return {
completed: completedSteps.length,
total: requiredSteps.length,
};
}
const RING_RADIUS = 25;
const RING_CIRCUMFERENCE = 2 * Math.PI * RING_RADIUS;
export default function RequiredStepsCard({
items,
progressBySlug,
}: RequiredStepsCardProps = {}) {
const { dictionary: t, locale } = useI18n();
const { data: schema } = useFormSchemaQuery("profile", locale);
const questionListItems = useMemo(
() => items ?? convertSchemaToFrontendItems(schema, locale),
[items, schema, locale],
);
const steps: RequiredStep[] = useMemo(() => {
return questionListItems.map((item) => {
let progress = item.progress;
if (progressBySlug && typeof progressBySlug.get(item.slug) === "number") {
progress = progressBySlug.get(item.slug) ?? 0;
}
return {
slug: item.slug,
required: Boolean(item.required),
progress,
};
});
}, [questionListItems, progressBySlug]);
const { completed, total } = getRequiredStepStats(steps);
const completion = total > 0 ? Math.round((completed / total) * 100) : 0;
completed,
total,
}: RequiredStepsCardProps) {
const { dictionary: t } = useI18n();
const normalizedCompleted = Math.max(0, Math.min(completed, total));
const completion = total > 0 ? normalizedCompleted / total : 0;
const ringOffset = RING_CIRCUMFERENCE * (1 - completion);
const isCompleted = total > 0 && completed === total;
return (
@ -94,16 +55,27 @@ export default function RequiredStepsCard({
<div
role="img"
aria-label={t["{completed} of {total} required steps completed"]
.replace("{completed}", String(completed))
.replace("{completed}", String(normalizedCompleted))
.replace("{total}", String(total))}
className="relative flex h-[60px] w-[60px] shrink-0 items-center justify-center rounded-full"
style={{
background: `conic-gradient(#FFFFFF ${completion}%,rgba(255,255,255,0.24) 0)`,
}}
className="relative flex h-[60px] w-[60px] shrink-0 items-center justify-center"
>
<div className="absolute inset-[6px] rounded-full bg-[#40506A]" />
<svg aria-hidden="true" className="absolute inset-0 -rotate-90" viewBox="0 0 60 60">
<circle cx="30" cy="30" r={RING_RADIUS} fill="none" stroke="rgba(255,255,255,0.24)" strokeWidth="5" />
<circle
cx="30"
cy="30"
r={RING_RADIUS}
fill="none"
stroke="#FFFFFF"
strokeWidth="5"
strokeLinecap="round"
strokeDasharray={RING_CIRCUMFERENCE}
strokeDashoffset={ringOffset}
className="transition-[stroke-dashoffset] duration-500 ease-out motion-reduce:transition-none"
/>
</svg>
<span className="relative group-14 leading-none font-bold tracking-[-0.02em]">
{completed}/{total}
{normalizedCompleted}/{total}
</span>
</div>
</div>

205
src/components/Componentes/schema-question-flow.integration.test.tsx

@ -1,24 +1,42 @@
import React from "react";
import { render, screen, fireEvent, waitFor, cleanup } from "@testing-library/react";
import {
render,
screen,
fireEvent,
waitFor,
cleanup,
} from "@testing-library/react";
import { describe, it, expect, afterEach, beforeEach, vi } from "vitest";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { QuestionAnswersProvider, useQuestionAnswers } from "./question-answer-storage";
import {
QuestionAnswersProvider,
useQuestionAnswers,
} from "./question-answer-storage";
import { convertSchemaToFrontendItems } from "@/lib/schema-adapter";
import { useFormSchemaQuery, type FormSchemaResponse } from "@/hooks/marriage/use-form-schema";
import {
useFormOverviewQuery,
useFormSectionQuery,
type FormSchemaResponse,
} from "@/hooks/marriage/use-form-schema";
import { useMarriageProfileQuery } from "@/hooks/marriage/use-profile-main";
import { useMarriageSectionDataQuery, useUpdateMarriageSectionDataMutation } from "@/hooks/marriage/use-section-data";
import {
useMarriageSectionDataQuery,
useUpdateMarriageSectionDataMutation,
} from "@/hooks/marriage/use-section-data";
import { QuestionRadio } from "./question-radio";
import { QuestionCheckbox } from "./question-checkbox";
import QuestionDetailClient from "@/app/questions-list/[slug]/question-detail-client";
vi.mock("@/hooks/marriage/use-form-schema", () => ({
useFormSchemaQuery: vi.fn(),
useFormOverviewQuery: vi.fn(),
useFormSectionQuery: vi.fn(),
}));
vi.mock("@/hooks/marriage/use-profile-main", () => ({
useMarriageProfileQuery: vi.fn(),
}));
vi.mock("@/hooks/marriage/use-section-data", () => ({
applyProfilePatchResultToCache: vi.fn(),
useMarriageSectionDataQuery: vi.fn(),
useUpdateMarriageSectionDataMutation: vi.fn(),
}));
@ -34,10 +52,9 @@ vi.mock("next/navigation", () => ({
const mockSchema = {
form_id: "profile",
version: 1,
answers: {
"q_radio": { value: "A", option_id: "opt_a" },
"q_check": { value: ["B", "C"], option_id: ["opt_b", "opt_c"] }
q_radio: { value: "A", option_id: "opt_a" },
q_check: { value: ["B", "C"], option_id: ["opt_b", "opt_c"] },
},
sections: [
{
@ -53,43 +70,78 @@ const mockSchema = {
title: "Card",
order: 2, // Messed up order
questions: [
{
id: "q_child", title: "Child Q", type: "text", order: 20,
required: false, is_required: true, is_visible: false,
ui_config: {}, options: []
{
id: "q_child",
title: "Child Q",
type: "text",
order: 20,
required: false,
is_required: true,
is_visible: false,
ui_config: {},
options: [],
},
{
id: "q_radio", title: "Radio Q", type: "radio", order: 5,
required: true, is_required: true, is_visible: true,
ui_config: {},
{
id: "q_radio",
title: "Radio Q",
type: "radio",
order: 5,
required: true,
is_required: true,
is_visible: true,
ui_config: {},
options: [
{ id: "opt_a_dummy", value: "A_DUMMY", label: "Option A Dummy", order: 2 },
{ id: "opt_a", value: "A", label: "Option A Canonical", order: 1 }
]
{
id: "opt_a_dummy",
value: "A_DUMMY",
label: "Option A Dummy",
order: 2,
},
{
id: "opt_a",
value: "A",
label: "Option A Canonical",
order: 1,
},
],
},
{
id: "q_check", title: "Check Q", type: "checkbox", order: 10,
required: false, is_required: false, is_visible: true,
ui_config: {},
{
id: "q_check",
title: "Check Q",
type: "checkbox",
order: 10,
required: false,
is_required: false,
is_visible: true,
ui_config: {},
options: [
{ id: "opt_c", value: "C", label: "Option C", order: 2 },
{ id: "opt_b", value: "B", label: "Option B", order: 1 }
]
}
]
{ id: "opt_b", value: "B", label: "Option B", order: 1 },
],
},
],
},
{
id: "card2",
title: "Card 2",
order: 1, // Card 2 should come first
questions: [
{ id: "q_first", title: "First Q", type: "text", order: 1, required: true, is_visible: true, ui_config: {}, options: [] },
]
}
]
}
{
id: "q_first",
title: "First Q",
type: "text",
order: 1,
required: true,
is_visible: true,
ui_config: {},
options: [],
},
],
},
],
},
],
progress: { sections_progress: {} }
progress: { sections_progress: {} },
};
describe("Schema Question Flow Integration", () => {
@ -98,7 +150,15 @@ describe("Schema Question Flow Integration", () => {
beforeEach(() => {
capturedPayload = null;
const updateMutateAsync = vi.fn(async (payload) => {
capturedPayload = payload;
capturedPayload = {
...payload,
fields: [
...(capturedPayload?.fields ?? []).filter(
(field: any) => field.key !== payload.fields[0]?.key,
),
...payload.fields,
],
};
return payload;
});
(useUpdateMarriageSectionDataMutation as any).mockReturnValue({
@ -113,18 +173,43 @@ describe("Schema Question Flow Integration", () => {
slug: "sec1",
data: [
{ key: "q_radio", type: "radio", value: "A", option_id: "opt_a" },
{ key: "q_check", type: "checkbox", value: ["B", "C"], option_id: ["opt_b", "opt_c"] }
]
{
key: "q_check",
type: "checkbox",
value: ["B", "C"],
option_id: ["opt_b", "opt_c"],
},
],
},
isLoading: false,
});
(useFormSchemaQuery as any).mockReturnValue({
data: mockSchema,
(useFormOverviewQuery as any).mockReturnValue({
data: {
...mockSchema,
sections: mockSchema.sections.map(({ cards, ...section }) => ({
...section,
kind: "profile",
progress: { current_step: 0, total_steps: 0, completion_percent: 0 },
})),
},
isLoading: false,
isFetching: false,
error: null,
refetch: vi.fn(),
});
(useFormSectionQuery as any).mockReturnValue({
data: {
section: mockSchema.sections[0],
answers: mockSchema.answers,
progress: mockSchema.progress,
section_progress: {
current_step: 0,
total_steps: 0,
completion_percent: 0,
},
},
isLoading: false,
});
});
afterEach(() => {
@ -132,20 +217,22 @@ describe("Schema Question Flow Integration", () => {
});
it("should enforce ordering, correctly hydrate canonical values via options, and hide invisible children", async () => {
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } });
const queryClient = new QueryClient({
defaultOptions: { queries: { retry: false } },
});
// Use convertSchemaToFrontendItems directly (no mock)
const frontendItems = convertSchemaToFrontendItems(
mockSchema as unknown as FormSchemaResponse,
"en",
);
// Sort logic validation
expect(frontendItems[0].questions[0].id).toBe("q_first");
expect(frontendItems[0].questions[1].id).toBe("q_radio");
expect(frontendItems[0].questions[2].id).toBe("q_check");
expect(frontendItems[0].questions[3].id).toBe("q_child");
// Validate options sort
expect(frontendItems[0].questions[1].options[0].id).toBe("opt_a");
@ -160,13 +247,15 @@ describe("Schema Question Flow Integration", () => {
questionsListHref="/list"
title="Title"
/>
</QueryClientProvider>
</QueryClientProvider>,
);
// Wait for hydration and rendering
await waitFor(() => {
// Radio hydration
const radioInput = screen.getByLabelText("Option A Canonical") as HTMLInputElement;
const radioInput = screen.getByLabelText(
"Option A Canonical",
) as HTMLInputElement;
expect(radioInput.checked).toBe(true);
// Checkbox hydration
@ -177,15 +266,17 @@ describe("Schema Question Flow Integration", () => {
// Child should not be rendered
expect(screen.queryByText("Child Q")).toBeNull();
// q_child is required=false but is_required=true, if it was visible it should show *.
// Let's modify schema dynamically to test child visibility and requirement
});
// Interact to fire payload
const radioDummy = screen.getByLabelText("Option A Dummy") as HTMLInputElement;
const radioDummy = screen.getByLabelText(
"Option A Dummy",
) as HTMLInputElement;
fireEvent.click(radioDummy);
// Uncheck Option B and Check Option C (Wait, they are both checked by default from hydration)
const checkB = screen.getByLabelText("Option B") as HTMLInputElement;
fireEvent.click(checkB); // Should now be unchecked
@ -195,10 +286,14 @@ describe("Schema Question Flow Integration", () => {
await waitFor(() => {
expect(capturedPayload).not.toBeNull();
const radioPayload = capturedPayload.fields.find((f: any) => f.key === "q_radio");
const radioPayload = capturedPayload.fields.find(
(f: any) => f.key === "q_radio",
);
expect(radioPayload.option_id).toBe("opt_a_dummy");
const checkPayload = capturedPayload.fields.find((f: any) => f.key === "q_check");
const checkPayload = capturedPayload.fields.find(
(f: any) => f.key === "q_check",
);
expect(checkPayload.option_id).toEqual(["opt_c"]); // Only C is checked now
});
});
@ -206,15 +301,21 @@ describe("Schema Question Flow Integration", () => {
it("should redirect and not render static questions on schema failure", () => {
replaceMock.mockClear();
(useFormSchemaQuery as any).mockReturnValue({
(useFormOverviewQuery as any).mockReturnValue({
data: undefined,
isLoading: false,
isFetching: false,
error: new Error("Network Error"),
refetch: vi.fn(),
});
(useFormSectionQuery as any).mockReturnValue({
data: undefined,
isLoading: false,
});
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } });
const queryClient = new QueryClient({
defaultOptions: { queries: { retry: false } },
});
render(
<QueryClientProvider client={queryClient}>
@ -227,7 +328,7 @@ describe("Schema Question Flow Integration", () => {
questionsListHref="/list"
title="Title"
/>
</QueryClientProvider>
</QueryClientProvider>,
);
// Should redirect to questions list

72
src/components/Componentes/token-switcher.tsx

@ -2,11 +2,6 @@
import { useEffect, useState } from "react";
import { MdOutlineSwitchAccount } from "react-icons/md";
import {
IoClose,
IoTrashOutline,
IoKeyOutline,
} from "react-icons/io5";
import { getClientCookie, setClientCookie } from "@/lib/cookies";
import { setCachedMarriageEntryPath } from "@/lib/entry-route-cache";
@ -29,9 +24,11 @@ export function TokenSwitcher({
variant = "default",
className,
}: TokenSwitcherProps) {
const [mounted, setMounted] = useState(false);
const [isOpen, setIsOpen] = useState(false);
const [currentToken, setCurrentToken] = useState<string>("");
const [customTokenInput, setCustomTokenInput] = useState<string>("");
const [isCustomInputOpen, setIsCustomInputOpen] = useState(false);
useEffect(() => {
if (typeof window !== "undefined") {
@ -175,41 +172,42 @@ export function TokenSwitcher({
return (
<>
<div className="fixed top-4 left-4 z-[9999]">
<button
type="button"
onClick={() => setIsOpen(true)}
aria-label="تغییر توکن کاربر (تست)"
title="تغییر توکن (آقا / خانم)"
className={[
"inline-flex items-center gap-1.5 rounded-[15px] px-3 py-2 text-xs font-bold transition-all active:scale-95 shadow-md border border-slate-200/80 dark:border-slate-700/80 backdrop-blur-md cursor-pointer",
variant === "transparent"
? "bg-black/40 text-white hover:bg-black/50"
: "bg-white/95 text-slate-800 hover:bg-white dark:bg-slate-900/95 dark:text-slate-100",
className,
]
.filter(Boolean)
.join(" ")}
>
<MdOutlineSwitchAccount className="size-5 text-rose-500" />
<span>
{isMale
? "آقا 👨"
: isMale2
? "آقا ۲ 👨"
: isFemale
? "خانم 👩"
: isNoToken
? "بدون توکن 👤"
: "سفارشی 🔑"}
</span>
</button>
</div>
<button
type="button"
onClick={() => setIsOpen(true)}
aria-label="تغییر توکن کاربر (تست)"
title="تغییر توکن (آقا / خانم)"
className={[
"inline-flex items-center gap-1.5 rounded-[15px] px-3 py-2 text-xs font-bold transition-all active:scale-95 shadow-md border border-slate-200/80 dark:border-slate-700/80 backdrop-blur-md",
variant === "transparent"
? "bg-black/40 text-white hover:bg-black/50"
: "bg-white/95 text-slate-800 hover:bg-white dark:bg-slate-900/95 dark:text-slate-100",
className,
]
.filter(Boolean)
.join(" ")}
>
<MdOutlineSwitchAccount className="size-5 text-rose-500" />
<span>
{isMale
? "آقا 👨"
: isMale2
? "آقا ۲ 👨"
: isFemale
? "خانم 👩"
: isNoToken
? "بدون توکن 👤"
: "سفارشی 🔑"}
</span>
</button>
{isOpen && (
<div className="fixed inset-0 z-[10000] flex items-center justify-center bg-black/50 p-4 backdrop-blur-xs animate-in fade-in duration-200">
<div
className="fixed inset-0 z-[100000] flex items-center justify-center bg-black/60 p-4 backdrop-blur-xs animate-in fade-in duration-200"
onClick={() => setIsOpen(false)}
>
<div
className="w-full max-w-sm rounded-2xl bg-white p-5 shadow-xl dark:bg-slate-900 border border-slate-100 dark:border-slate-800 max-h-[90vh] flex flex-col"
className="w-full max-w-sm rounded-2xl bg-white p-5 shadow-2xl dark:bg-slate-900 border border-slate-100 dark:border-slate-800 max-h-[90vh] flex flex-col"
onClick={(e) => e.stopPropagation()}
>
{/* Modal Header */}

166
src/components/Componentes/ui-config.test.tsx

@ -1,4 +1,10 @@
import { render, screen, cleanup, fireEvent, waitFor } from "@testing-library/react";
import {
render,
screen,
cleanup,
fireEvent,
waitFor,
} from "@testing-library/react";
import { describe, it, expect, vi, afterEach } from "vitest";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { QuestionAnswersProvider } from "./question-answer-storage";
@ -15,32 +21,58 @@ describe("UI Config based behavior", () => {
cleanup();
});
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } });
const queryClient = new QueryClient({
defaultOptions: { queries: { retry: false } },
});
it("should trigger GeoIP only when ui_config.enable_geoip is true", () => {
// With totally random title but ui_config.enable_geoip = true
const qWithGeo = { id: "q1", title: "Random Title Here", type: "birthplace", order: 1, required: true, isVisible: true, description: "", tooltip: "", extras: {}, options: [], ui_config: { enable_geoip: true } } as any;
const qWithGeo = {
id: "q1",
title: "Random Title Here",
type: "birthplace",
order: 1,
required: true,
isVisible: true,
description: "",
tooltip: "",
extras: {},
options: [],
ui_config: { enable_geoip: true },
} as any;
const { rerender } = render(
<QueryClientProvider client={queryClient}>
<QuestionAnswersProvider slug="test" questions={[qWithGeo]}>
<QuestionBirthplace question={qWithGeo} />
</QuestionAnswersProvider>
</QueryClientProvider>
</QueryClientProvider>,
);
// Auto button is present when GeoIP is active
expect(screen.getByText("خودکار")).toBeDefined();
// With title "residence" but no ui_config
const qWithoutGeo = { id: "q2", title: "residence test", type: "birthplace", order: 1, required: true, isVisible: true, description: "", tooltip: "", extras: {}, options: [], ui_config: {} } as any;
const qWithoutGeo = {
id: "q2",
title: "residence test",
type: "birthplace",
order: 1,
required: true,
isVisible: true,
description: "",
tooltip: "",
extras: {},
options: [],
ui_config: {},
} as any;
rerender(
<QueryClientProvider client={queryClient}>
<QuestionAnswersProvider slug="test" questions={[qWithoutGeo]}>
<QuestionBirthplace question={qWithoutGeo} />
</QuestionAnswersProvider>
</QueryClientProvider>
</QueryClientProvider>,
);
expect(screen.queryByText("خودکار")).toBeNull();
@ -48,28 +80,52 @@ describe("UI Config based behavior", () => {
it("should trigger currency behavior only when ui_config.currency_enabled is true", () => {
// Title is random, but currency_enabled is true
const qWithCurrency = { id: "q1", title: "Random Income", type: "number", order: 1, required: true, isVisible: true, description: "", tooltip: "", extras: { placeHolder: "", range: [0, 0] }, options: [], ui_config: { currency_enabled: true } } as any;
const qWithCurrency = {
id: "q1",
title: "Random Income",
type: "number",
order: 1,
required: true,
isVisible: true,
description: "",
tooltip: "",
extras: { placeHolder: "", range: [0, 0] },
options: [],
ui_config: { currency_enabled: true },
} as any;
const { rerender } = render(
<QueryClientProvider client={queryClient}>
<QuestionAnswersProvider slug="test" questions={[qWithCurrency]}>
<QuestionNumber question={qWithCurrency} />
</QuestionAnswersProvider>
</QueryClientProvider>
</QueryClientProvider>,
);
// Dropdown arrow should be rendered in currency mode (path is in SVG)
expect(screen.getByRole("img", { name: "Dropdown chevron" })).toBeDefined();
// Title is "Monthly Income", but currency_enabled is false
const qWithoutCurrency = { id: "q2", title: "Monthly Income", type: "number", order: 1, required: true, isVisible: true, description: "", tooltip: "", extras: { placeHolder: "", range: [0, 0] }, options: [], ui_config: {} } as any;
const qWithoutCurrency = {
id: "q2",
title: "Monthly Income",
type: "number",
order: 1,
required: true,
isVisible: true,
description: "",
tooltip: "",
extras: { placeHolder: "", range: [0, 0] },
options: [],
ui_config: {},
} as any;
rerender(
<QueryClientProvider client={queryClient}>
<QuestionAnswersProvider slug="test" questions={[qWithoutCurrency]}>
<QuestionNumber question={qWithoutCurrency} />
</QuestionAnswersProvider>
</QueryClientProvider>
</QueryClientProvider>,
);
expect(screen.queryByRole("img", { name: "Dropdown chevron" })).toBeNull();
@ -77,38 +133,108 @@ describe("UI Config based behavior", () => {
it("should render error message from validation.errorMessage or fallback to locale", async () => {
// Title is random, type is email, custom error message
const qCustomError = { id: "q1", title: "Random Email", type: "email", order: 1, required: true, isVisible: true, description: "", tooltip: "", extras: { placeHolder: "" }, options: [], validation: { errorMessage: "Custom Backend Error" } } as any;
const qCustomError = {
id: "q1",
title: "Random Email",
type: "email",
order: 1,
required: true,
isVisible: true,
description: "",
tooltip: "",
extras: { placeHolder: "" },
options: [],
validation: { errorMessage: "Custom Backend Error" },
} as any;
const { rerender } = render(
<QueryClientProvider client={queryClient}>
<QuestionAnswersProvider slug="test" questions={[qCustomError]}>
<QuestionText question={qCustomError} />
</QuestionAnswersProvider>
</QueryClientProvider>
</QueryClientProvider>,
);
const input = screen.getByRole("textbox");
fireEvent.change(input, { target: { value: 'invalid_email' } });
fireEvent.change(input, { target: { value: "invalid_email" } });
await waitFor(() => {
expect(screen.getByText("Custom Backend Error")).toBeDefined();
});
const qFallbackError = { id: "q2", title: "Random Email", type: "email", order: 1, required: true, isVisible: true, description: "", tooltip: "", extras: { placeHolder: "" }, options: [], validation: {} } as any;
const qFallbackError = {
id: "q2",
title: "Random Email",
type: "email",
order: 1,
required: true,
isVisible: true,
description: "",
tooltip: "",
extras: { placeHolder: "" },
options: [],
validation: {},
} as any;
rerender(
<QueryClientProvider client={queryClient}>
<QuestionAnswersProvider slug="test" questions={[qFallbackError]}>
<QuestionText question={qFallbackError} />
</QuestionAnswersProvider>
</QueryClientProvider>
</QueryClientProvider>,
);
const input2 = screen.getByRole("textbox");
fireEvent.change(input2, { target: { value: 'invalid_email_again' } });
fireEvent.change(input2, { target: { value: "invalid_email_again" } });
await waitFor(() => {
expect(screen.getByText("یک آدرس ایمیل معتبر وارد کنید.")).toBeDefined();
});
});
it("should preserve city input when country is selected or changed in birthplace", async () => {
const qBirthplace = {
id: "birthplace_test",
title: "Birthplace",
type: "birthplace",
order: 1,
required: true,
isVisible: true,
description: "",
tooltip: "",
extras: { placeHolder: "City, region" },
options: [],
ui_config: {},
} as any;
render(
<QueryClientProvider client={queryClient}>
<QuestionAnswersProvider slug="test" questions={[qBirthplace]}>
<QuestionBirthplace question={qBirthplace} />
</QuestionAnswersProvider>
</QueryClientProvider>,
);
// Type city into input
const cityInput = screen.getByPlaceholderText("City, region");
expect(document.activeElement).not.toBe(cityInput);
fireEvent.change(cityInput, { target: { value: "Tehran" } });
expect((cityInput as HTMLInputElement).value).toBe("Tehran");
// Open country dropdown
const countryButton = screen.getByText("انتخاب کشور");
fireEvent.click(countryButton);
expect(document.body.classList.contains("dropdown-open")).toBe(true);
// Select Iran
const iranOption = screen.getByRole("button", { name: "ایران" });
fireEvent.click(iranOption);
// City should still be Tehran!
expect((cityInput as HTMLInputElement).value).toBe("Tehran");
await waitFor(() => {
expect(document.activeElement).toBe(cityInput);
expect(document.body.classList.contains("dropdown-open")).toBe(false);
});
});
});

152
src/components/Componentes/use-sheet-scroll-lock.ts

@ -0,0 +1,152 @@
"use client";
import { useEffect } from "react";
import { viewPaddingsBridge } from "@/lib/view-paddings";
let activeSheetCount = 0;
let hasFocusedKeyboardInput = false;
let bodyHadDropdownClass = false;
let initialBodyOverflow = "";
let initialHtmlOverflow = "";
let initialAppShellOverflow = "";
let initialAppShellTouchAction = "";
let lockedAppShell: HTMLElement | null = null;
export function useSheetScrollLock(isOpen: boolean) {
useEffect(() => {
if (!isOpen) return;
if (activeSheetCount === 0) {
bodyHadDropdownClass = document.body.classList.contains("dropdown-open");
initialBodyOverflow = document.body.style.overflow;
initialHtmlOverflow = document.documentElement.style.overflow;
lockedAppShell = document.querySelector<HTMLElement>(".app-shell");
initialAppShellOverflow = lockedAppShell?.style.overflowY ?? "";
initialAppShellTouchAction = lockedAppShell?.style.touchAction ?? "";
}
activeSheetCount += 1;
document.body.classList.add("dropdown-open");
document.body.classList.add("question-sheet-open");
document.body.style.overflow = "hidden";
document.documentElement.style.overflow = "hidden";
if (lockedAppShell) {
lockedAppShell.style.overflowY = "hidden";
lockedAppShell.style.touchAction = "none";
}
return () => {
activeSheetCount = Math.max(0, activeSheetCount - 1);
if (activeSheetCount === 0) {
document.body.style.overflow = initialBodyOverflow;
document.documentElement.style.overflow = initialHtmlOverflow;
if (lockedAppShell) {
lockedAppShell.style.overflowY = initialAppShellOverflow;
lockedAppShell.style.touchAction = initialAppShellTouchAction;
lockedAppShell = null;
}
if (!bodyHadDropdownClass) {
document.body.classList.remove("dropdown-open");
}
document.body.classList.remove("question-sheet-open");
}
};
}, [isOpen]);
}
const NON_KEYBOARD_INPUT_TYPES = new Set([
"button",
"checkbox",
"color",
"file",
"hidden",
"image",
"radio",
"range",
"reset",
"submit",
]);
function isKeyboardInputTarget(target: EventTarget | null): boolean {
if (target instanceof HTMLTextAreaElement) {
return !target.disabled && !target.readOnly;
}
if (target instanceof HTMLInputElement) {
const type = (target.getAttribute("type") ?? "text").toLowerCase();
return (
!NON_KEYBOARD_INPUT_TYPES.has(type) &&
!target.disabled &&
!target.readOnly
);
}
if (target instanceof HTMLElement) {
return target.isContentEditable;
}
return false;
}
function syncQuestionInputOpenClass() {
if (hasFocusedKeyboardInput) {
document.body.classList.add("question-keyboard-open");
} else {
document.body.classList.remove("question-keyboard-open");
}
}
/**
* Moves the active question up (same animation the dropdown sheet uses) while
* a keyboard input is focused, so the mobile keyboard cannot cover it.
*/
export function useQuestionInputFocusSync() {
useEffect(() => {
const handleFocusIn = (event: FocusEvent) => {
if (!isKeyboardInputTarget(event.target)) return;
hasFocusedKeyboardInput = true;
syncQuestionInputOpenClass();
};
const handleFocusOut = (event: FocusEvent) => {
if (!isKeyboardInputTarget(event.target)) return;
window.setTimeout(() => {
hasFocusedKeyboardInput = isKeyboardInputTarget(document.activeElement);
syncQuestionInputOpenClass();
}, 0);
};
const unsubscribeConfig = viewPaddingsBridge.subscribeConfig((config) => {
if (config.keyboardHeight > 0) {
hasFocusedKeyboardInput = isKeyboardInputTarget(document.activeElement);
} else {
// Android Back can hide Flutter's keyboard without dispatching blur.
hasFocusedKeyboardInput = false;
}
syncQuestionInputOpenClass();
});
const initialViewportHeight = window.visualViewport?.height ?? 0;
const handleViewportResize = () => {
const viewportHeight = window.visualViewport?.height ?? 0;
if (
initialViewportHeight > 0 &&
viewportHeight >= initialViewportHeight - 40
) {
hasFocusedKeyboardInput = false;
syncQuestionInputOpenClass();
}
};
document.addEventListener("focusin", handleFocusIn);
document.addEventListener("focusout", handleFocusOut);
window.visualViewport?.addEventListener("resize", handleViewportResize);
return () => {
document.removeEventListener("focusin", handleFocusIn);
document.removeEventListener("focusout", handleFocusOut);
window.visualViewport?.removeEventListener(
"resize",
handleViewportResize,
);
unsubscribeConfig();
hasFocusedKeyboardInput = false;
document.body.classList.remove("question-keyboard-open");
};
}, []);
}

4
src/hooks/marriage/query-keys.ts

@ -10,6 +10,10 @@ export const marriageQueryKeys = {
"contact-info",
] as const,
profile: () => [...marriageQueryKeys.all, "profile"] as const,
formOverview: (formId: string, locale: string) =>
[...marriageQueryKeys.all, "form-overview", formId, locale] as const,
formSection: (formId: string, slug: string, locale: string) =>
[...marriageQueryKeys.all, "form-section", formId, slug, locale] as const,
sectionData: (slug: string) =>
[...marriageQueryKeys.sections(), slug, "data"] as const,
sections: () => [...marriageQueryKeys.all, "sections"] as const,

28
src/hooks/marriage/types.ts

@ -151,12 +151,38 @@ export type MarriageSectionData = {
};
export type UpdateMarriageSectionDataPayload = {
version?: number;
current_step: number;
total_steps?: number;
fields: MarriageField[];
};
export type MarriageQuestionState = {
is_visible: boolean;
is_required: boolean;
};
export type ProfileAnswersPatchResult = {
answers: Record<
string,
{ value: MarriageFieldValue; option_id?: string | string[] }
>;
cleared_answer_ids?: string[];
question_states?: Record<string, MarriageQuestionState>;
affected_sections: Record<
string,
{ current_step: number; total_steps: number; completion_percent: number }
>;
progress: {
current_step: number;
total_steps: number;
completion_percent: number;
sections_progress: Record<
string,
{ current_step: number; total_steps: number; completion_percent: number }
>;
};
};
export type StartMarriageMatchResponse = {
detail: string;
};

87
src/hooks/marriage/use-form-schema.ts

@ -45,6 +45,8 @@ export interface FormSection {
order: number;
estimated_minutes: number;
cards: FormCard[];
kind?: "profile" | "assessment";
assessment_type?: "cattell" | "glasser";
}
export interface FormProgressInfo {
@ -55,7 +57,6 @@ export interface FormProgressInfo {
export interface FormSchemaResponse {
form_id: string;
version: number;
sections: FormSection[];
answers: Record<string, { value: any; option_id?: string | string[] }>;
progress: {
@ -67,9 +68,77 @@ export interface FormSchemaResponse {
is_completed?: boolean;
}
export async function getFormSchema(formId: string, locale: string): Promise<FormSchemaResponse> {
export interface FormOverviewSection extends Omit<FormSection, "cards"> {
kind: "profile" | "assessment";
assessment_type?: "cattell" | "glasser";
question_count?: number;
progress: FormProgressInfo;
}
export interface FormOverviewResponse {
form_id: string;
sections: FormOverviewSection[];
progress: FormSchemaResponse["progress"];
}
export interface FormSectionResponse {
form_id: string;
section: FormSection;
answers: FormSchemaResponse["answers"];
progress: FormSchemaResponse["progress"];
section_progress: FormProgressInfo;
}
export async function getFormOverview(formId: string, locale: string) {
const { data } = await http.get<FormOverviewResponse>(
`/api/marriage/forms/${formId}/overview/?lang=${locale}`,
);
return data;
}
export function useFormOverviewQuery(formId: string, locale: string) {
return useQuery({
queryKey: marriageQueryKeys.formOverview(formId, locale),
queryFn: () => getFormOverview(formId, locale),
staleTime: 30 * 1000,
refetchOnMount: "always",
refetchOnWindowFocus: false,
});
}
export async function getFormSection(
formId: string,
slug: string,
locale: string,
) {
const { data } = await http.get<FormSectionResponse>(
`/api/marriage/forms/${formId}/sections/${encodeURIComponent(slug)}/?lang=${locale}`,
);
return data;
}
export function useFormSectionQuery(
formId: string,
slug: string,
locale: string,
enabled = true,
) {
return useQuery({
queryKey: marriageQueryKeys.formSection(formId, slug, locale),
queryFn: () => getFormSection(formId, slug, locale),
enabled,
staleTime: 30 * 1000,
refetchOnMount: false,
refetchOnWindowFocus: false,
});
}
export async function getFormSchema(
formId: string,
locale: string,
): Promise<FormSchemaResponse> {
const { data } = await http.get<FormSchemaResponse>(
`/api/marriage/forms/${formId}/?lang=${locale}`
`/api/marriage/forms/${formId}/?lang=${locale}`,
);
return data;
}
@ -77,7 +146,7 @@ export async function getFormSchema(formId: string, locale: string): Promise<For
export function useFormSchemaQuery<TData = FormSchemaResponse>(
formId: string,
locale: string,
options?: QueryOptions<FormSchemaResponse, TData>
options?: QueryOptions<FormSchemaResponse, TData>,
) {
return useQuery({
staleTime: 30 * 1000,
@ -90,7 +159,6 @@ export function useFormSchemaQuery<TData = FormSchemaResponse>(
}
export interface SaveAnswersPayload {
version: number;
answers: Array<{
question_id: string;
value: any;
@ -98,17 +166,20 @@ export interface SaveAnswersPayload {
}>;
}
export async function saveFormAnswers(formId: string, payload: SaveAnswersPayload): Promise<FormSchemaResponse> {
export async function saveFormAnswers(
formId: string,
payload: SaveAnswersPayload,
): Promise<FormSchemaResponse> {
const { data } = await http.put<FormSchemaResponse>(
`/api/marriage/forms/${formId}/answers/`,
payload
payload,
);
return data;
}
export function useSaveFormAnswersMutation(
formId: string,
options?: MutationOptions<FormSchemaResponse, SaveAnswersPayload>
options?: MutationOptions<FormSchemaResponse, SaveAnswersPayload>,
) {
const queryClient = useQueryClient();

98
src/hooks/marriage/use-section-data.test.ts

@ -0,0 +1,98 @@
import { QueryClient } from "@tanstack/react-query";
import { beforeEach, describe, expect, it } from "vitest";
import { marriageQueryKeys } from "./query-keys";
import type { ProfileAnswersPatchResult } from "./types";
import { applyProfilePatchResultToCache } from "./use-section-data";
describe("applyProfilePatchResultToCache", () => {
let queryClient: QueryClient;
beforeEach(() => {
queryClient = new QueryClient();
window.localStorage.clear();
});
it("applies cleared answers, question states, and cross-section progress", () => {
const sectionAKey = marriageQueryKeys.formSection(
"profile",
"section-a",
"en",
);
const sectionBKey = marriageQueryKeys.formSection(
"profile",
"section-b",
"en",
);
queryClient.setQueryData(sectionAKey, {
answers: { parent: { value: "yes" } },
section: { cards: [{ questions: [{ id: "parent", is_visible: true }] }] },
});
queryClient.setQueryData(sectionBKey, {
answers: { child: { value: "old" } },
section: {
cards: [
{
questions: [{ id: "child", is_visible: true, is_required: true }],
},
],
},
});
queryClient.setQueryData(marriageQueryKeys.formOverview("profile", "en"), {
sections: [
{ id: "section-a", progress: {} },
{ id: "section-b", progress: {} },
],
});
window.localStorage.setItem(
"marriage:sections:section-b:answers:v2",
JSON.stringify({
fields: [{ key: "child", value: "old" }],
pending_keys: ["child"],
pending_sync: true,
}),
);
const result: ProfileAnswersPatchResult = {
answers: { parent: { value: "no" } },
cleared_answer_ids: ["child"],
question_states: {
child: { is_visible: false, is_required: false },
},
affected_sections: {
"section-a": {
current_step: 1,
total_steps: 1,
completion_percent: 100,
},
"section-b": {
current_step: 0,
total_steps: 0,
completion_percent: 100,
},
},
progress: {
current_step: 1,
total_steps: 1,
completion_percent: 100,
sections_progress: {},
},
};
applyProfilePatchResultToCache(queryClient, "en", result);
const sectionB = queryClient.getQueryData<any>(sectionBKey);
expect(sectionB.answers.child).toBeUndefined();
expect(sectionB.section.cards[0].questions[0]).toMatchObject({
is_visible: false,
is_required: false,
});
expect(sectionB.section_progress.current_step).toBe(0);
expect(
window.localStorage.getItem("marriage:sections:section-b:answers:v2"),
).toBeNull();
const overview = queryClient.getQueryData<any>(
marriageQueryKeys.formOverview("profile", "en"),
);
expect(overview.sections[1].progress.total_steps).toBe(0);
});
});

209
src/hooks/marriage/use-section-data.ts

@ -1,46 +1,63 @@
"use client";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import {
type QueryClient,
useMutation,
useQueryClient,
} from "@tanstack/react-query";
import { http } from "@/lib/http";
import type { MutationOptions, QueryOptions } from "./options";
import { pathParam } from "./path-param";
import type { MutationOptions } from "./options";
import { marriageQueryKeys } from "./query-keys";
import type {
MarriageSectionData,
ProfileAnswersPatchResult,
UpdateMarriageSectionDataPayload,
} from "./types";
import type { FormSchemaResponse } from "./use-form-schema";
import { useFormSchemaQuery } from "./use-form-schema";
import { useFormSectionQuery } from "./use-form-schema";
const ANSWERS_ENDPOINT = "/api/marriage/forms/profile/answers/";
type AnswersPayloadItem = {
question_id: string;
value: unknown;
option_id?: unknown;
};
async function patchAnswers(
answersPayload: AnswersPayloadItem[],
): Promise<ProfileAnswersPatchResult> {
const response = await http.patch<ProfileAnswersPatchResult>(
ANSWERS_ENDPOINT,
{ answers: answersPayload },
);
return response.data;
}
export async function updateMarriageSectionData(
slug: string,
payload: UpdateMarriageSectionDataPayload,
) {
): Promise<ProfileAnswersPatchResult & { sectionData: MarriageSectionData }> {
const answersPayload = payload.fields.map((f) => ({
question_id: f.key,
value: f.value,
option_id: (f as any).option_id || undefined,
option_id: f.option_id || undefined,
}));
const { data } = await http.put<FormSchemaResponse>(
`/api/marriage/forms/profile/answers/`,
{
version: payload.version,
answers: answersPayload,
}
);
const data = await patchAnswers(answersPayload);
const prog = data.progress.sections_progress[slug] || {
current_step: 0,
total_steps: 0,
completion_percent: 0.0,
};
const prog = data.affected_sections[slug] ||
data.progress.sections_progress[slug] || {
current_step: 0,
total_steps: 0,
completion_percent: 0.0,
};
return {
schema: data,
progress: data.progress,
answers: data.answers,
cleared_answer_ids: data.cleared_answer_ids,
question_states: data.question_states,
affected_sections: data.affected_sections,
sectionData: {
slug,
data: payload.fields,
@ -52,27 +69,150 @@ export async function updateMarriageSectionData(
};
}
export function applyProfilePatchResultToCache(
queryClient: QueryClient,
locale: string,
data: ProfileAnswersPatchResult,
) {
const cleared = new Set(data.cleared_answer_ids ?? []);
if (typeof window !== "undefined" && cleared.size > 0) {
for (let index = 0; index < window.localStorage.length; index += 1) {
const storageKey = window.localStorage.key(index);
if (!storageKey?.startsWith("marriage:sections:")) continue;
const rawValue = window.localStorage.getItem(storageKey);
if (!rawValue) continue;
try {
const stored = JSON.parse(rawValue);
if (!Array.isArray(stored.fields)) continue;
const fields = stored.fields.filter(
(field: { key?: string }) => !field.key || !cleared.has(field.key),
);
if (fields.length === stored.fields.length) continue;
if (fields.length === 0) {
window.localStorage.removeItem(storageKey);
index -= 1;
continue;
}
stored.fields = fields;
stored.pending_keys = Array.isArray(stored.pending_keys)
? stored.pending_keys.filter((key: string) => !cleared.has(key))
: [];
stored.pending_sync = stored.pending_keys.length > 0;
window.localStorage.setItem(storageKey, JSON.stringify(stored));
} catch {
// Leave malformed drafts untouched; normal hydration handles them.
}
}
}
const patchSection = (current: any, slug: string) => {
if (!current) return current;
const sectionProgress =
data.affected_sections[slug] ?? data.progress.sections_progress[slug];
const answers = { ...(current.answers ?? {}) };
Object.entries(data.answers ?? {}).forEach(([key, answer]) => {
answers[key] = answer;
});
cleared.forEach((key) => {
delete answers[key];
});
const questionStates = data.question_states ?? {};
const section = current.section
? {
...current.section,
cards: current.section.cards?.map((card: any) => ({
...card,
questions: card.questions?.map((question: any) =>
questionStates[question.id]
? { ...question, ...questionStates[question.id] }
: question,
),
})),
}
: current.section;
return {
...current,
answers,
section,
progress: data.progress,
...(sectionProgress ? { section_progress: sectionProgress } : {}),
};
};
queryClient
.getQueriesData({
queryKey: marriageQueryKeys
.formSection("profile", "", locale)
.slice(0, 3),
})
.forEach(([queryKey, current]) => {
const slug = String(queryKey[3] ?? "");
queryClient.setQueryData(queryKey, patchSection(current, slug));
});
queryClient.setQueryData(
marriageQueryKeys.formOverview("profile", locale),
(current: any) =>
current
? {
...current,
progress: data.progress,
sections: current.sections.map((section: any) => {
const progress =
data.affected_sections[section.id] ??
data.progress.sections_progress[section.id];
return progress ? { ...section, progress } : section;
}),
}
: current,
);
}
export function useMarriageSectionDataQuery(
slug: string | null | undefined,
locale: string,
) {
const normalizedSlug = slug ?? "";
return useFormSchemaQuery<MarriageSectionData>("profile", locale, {
enabled: Boolean(normalizedSlug),
select: (schema) => {
const section = schema.sections.find((item) => item.id === normalizedSlug);
const fields = section?.cards.flatMap((card) => card.questions.map((question) => {
const answer = schema.answers[question.id];
return { key: question.id, label: question.title, type: question.type, value: answer?.value ?? null, option_id: answer?.option_id };
})) || [];
const progress = schema.progress.sections_progress[normalizedSlug] || { current_step: 0, total_steps: 0, completion_percent: 0 };
return { slug: normalizedSlug, data: fields, ...progress, updated_at: null };
},
});
const query = useFormSectionQuery(
"profile",
normalizedSlug,
locale,
Boolean(normalizedSlug),
);
return {
...query,
data: query.data
? (() => {
const section = query.data.section;
const fields = section.cards.flatMap((card) =>
card.questions.map((question) => {
const answer = query.data?.answers[question.id];
return {
key: question.id,
label: question.title,
type: question.type,
value: answer?.value ?? null,
option_id: answer?.option_id,
};
}),
);
const progress = query.data.section_progress || {
current_step: 0,
total_steps: 0,
completion_percent: 0,
};
return {
slug: normalizedSlug,
data: fields,
...progress,
updated_at: null,
};
})()
: undefined,
};
}
export function useUpdateMarriageSectionDataMutation(
slug: string,
locale: string,
options?: MutationOptions<
Awaited<ReturnType<typeof updateMarriageSectionData>>,
UpdateMarriageSectionDataPayload
@ -84,10 +224,7 @@ export function useUpdateMarriageSectionDataMutation(
...options,
mutationFn: (payload) => updateMarriageSectionData(slug, payload),
onSuccess: async (data, variables, onMutateResult, context) => {
queryClient.setQueriesData<FormSchemaResponse>(
{ queryKey: ["marriage", "form-schema", "profile"] },
data.schema,
);
applyProfilePatchResultToCache(queryClient, locale, data);
await options?.onSuccess?.(data, variables, onMutateResult, context);
},
});

1
src/lib/marriage-profile-contract.ts

@ -0,0 +1 @@
export const REQUIRED_PROFILE_SECTION_COUNT = 9;

38
src/lib/schema-adapter-overview.test.ts

@ -0,0 +1,38 @@
import { describe, expect, it } from "vitest";
import { convertOverviewToFrontendItems } from "./schema-adapter";
import type { FormOverviewResponse } from "@/hooks/marriage/use-form-schema";
describe("convertOverviewToFrontendItems", () => {
it("maps overview metadata without materializing questions", () => {
const overview: FormOverviewResponse = {
form_id: "profile",
progress: {
current_step: 1,
total_steps: 2,
completion_percent: 50,
sections_progress: {},
},
sections: [
{
id: "personal",
title: "Personal",
icon: "user-circle",
is_required: true,
order: 1,
estimated_minutes: 2,
kind: "profile",
progress: { current_step: 1, total_steps: 2, completion_percent: 50 },
},
],
};
expect(convertOverviewToFrontendItems(overview)).toEqual([
expect.objectContaining({
slug: "personal",
progress: 50,
questions: [],
checkpoints: [],
}),
]);
});
});

66
src/lib/schema-adapter.ts

@ -2,6 +2,7 @@ import type {
FormSchemaResponse,
FormSection,
FormQuestion,
FormOverviewResponse,
} from "@/hooks/marriage/use-form-schema";
import { defaultLocale, type Locale } from "@/translations/config";
@ -26,8 +27,6 @@ export type QuestionAudienceRule = {
minAge?: number;
};
export type QuestionField = {
id: string;
title: string;
@ -45,7 +44,12 @@ export type QuestionField = {
audience?: QuestionAudienceRule;
requiredWhen?: QuestionAudienceRule;
showGuardianNotice?: boolean;
options: { id: string; value: string | number; label: string; order: number }[];
options: {
id: string;
value: string | number;
label: string;
order: number;
}[];
};
export type QuestionListItem = {
@ -71,7 +75,10 @@ const iconMap: Record<string, QuestionCardIcon> = {
"layout-grid": "checklist",
};
export function mapBackendQuestionToFrontend(bq: FormQuestion, index: number): QuestionField {
export function mapBackendQuestionToFrontend(
bq: FormQuestion,
index: number,
): QuestionField {
return {
id: bq.id,
title: bq.title || "Untitled",
@ -92,20 +99,26 @@ export function mapBackendQuestionToFrontend(bq: FormQuestion, index: number): Q
noSearch: bq.ui_config?.noSearch,
},
showGuardianNotice: bq.show_guardian_notice,
options: [...(bq.options || [])].sort((a, b) => (a.order ?? 0) - (b.order ?? 0)),
options: [...(bq.options || [])].sort(
(a, b) => (a.order ?? 0) - (b.order ?? 0),
),
};
}
export function mapBackendSectionToFrontend(
section: FormSection,
progress: number
progress: number,
): QuestionListItem {
const allQuestions: QuestionField[] = [];
let index = 0;
const cards = [...(section.cards || [])].sort((a, b) => (a.order ?? 0) - (b.order ?? 0));
const cards = [...(section.cards || [])].sort(
(a, b) => (a.order ?? 0) - (b.order ?? 0),
);
cards.forEach((card) => {
const questions = [...(card.questions || [])].sort((a, b) => (a.order ?? 0) - (b.order ?? 0));
const questions = [...(card.questions || [])].sort(
(a, b) => (a.order ?? 0) - (b.order ?? 0),
);
questions.forEach((q) => {
const fq = mapBackendQuestionToFrontend(q, index);
allQuestions.push(fq);
@ -116,7 +129,9 @@ export function mapBackendSectionToFrontend(
return {
slug: section.id,
title: section.title,
estimate: section.estimated_minutes ? `${section.estimated_minutes} min` : "5 min",
estimate: section.estimated_minutes
? `${section.estimated_minutes} min`
: "5 min",
progress: progress,
icon: iconMap[section.icon] ?? "details",
required: section.is_required,
@ -130,7 +145,7 @@ export function mapBackendSectionToFrontend(
export function convertSchemaToFrontendItems(
schema: FormSchemaResponse | undefined,
locale: Locale = defaultLocale
locale: Locale = defaultLocale,
): QuestionListItem[] {
if (!schema) return [];
@ -140,5 +155,32 @@ export function convertSchemaToFrontendItems(
return mapBackendSectionToFrontend(sec, progress);
});
return rawItems.sort((a, b) => (schema.sections.find(s => s.id === a.slug)?.order ?? 0) - (schema.sections.find(s => s.id === b.slug)?.order ?? 0));
return rawItems.sort(
(a, b) =>
(schema.sections.find((s) => s.id === a.slug)?.order ?? 0) -
(schema.sections.find((s) => s.id === b.slug)?.order ?? 0),
);
}
export function convertOverviewToFrontendItems(
overview: FormOverviewResponse | undefined,
): QuestionListItem[] {
if (!overview) return [];
return [...overview.sections]
.sort((a, b) => a.order - b.order)
.map((section) => ({
slug: section.id,
title: section.title,
estimate: section.estimated_minutes
? `${section.estimated_minutes} min`
: "5 min",
progress: section.progress.completion_percent,
icon: iconMap[section.icon] ?? "details",
required: section.is_required,
showInfoBadge: false,
summary: "",
checkpoints: [],
tooltip: "",
questions: [],
}));
}

8
src/translations/locales/ar.json

@ -754,5 +754,9 @@
"Please select the reason for cancellation:": "Please select the reason for cancellation:",
"Swipe to continue": "Swipe to continue",
"Swipe to confirm cancellation": "Swipe to confirm cancellation",
"Failed to update profile basic details. Please try again.": "Failed to update profile basic details. Please try again."
}
"Failed to update profile basic details. Please try again.": "Failed to update profile basic details. Please try again.",
"Select date": "اختر التاريخ",
"Day": "اليوم",
"Month": "الشهر",
"Year": "السنة"
}

8
src/translations/locales/az.json

@ -754,5 +754,9 @@
"Please select the reason for cancellation:": "Please select the reason for cancellation:",
"Swipe to continue": "Swipe to continue",
"Swipe to confirm cancellation": "Swipe to confirm cancellation",
"Failed to update profile basic details. Please try again.": "Failed to update profile basic details. Please try again."
}
"Failed to update profile basic details. Please try again.": "Failed to update profile basic details. Please try again.",
"Select date": "Tarix seçin",
"Day": "Gün",
"Month": "Ay",
"Year": "İl"
}

8
src/translations/locales/bn.json

@ -754,5 +754,9 @@
"Please select the reason for cancellation:": "Please select the reason for cancellation:",
"Swipe to continue": "Swipe to continue",
"Swipe to confirm cancellation": "Swipe to confirm cancellation",
"Failed to update profile basic details. Please try again.": "Failed to update profile basic details. Please try again."
}
"Failed to update profile basic details. Please try again.": "Failed to update profile basic details. Please try again.",
"Select date": "তারিখ নির্বাচন করুন",
"Day": "দিন",
"Month": "মাস",
"Year": "বছর"
}

8
src/translations/locales/da.json

@ -754,5 +754,9 @@
"Please select the reason for cancellation:": "Please select the reason for cancellation:",
"Swipe to continue": "Swipe to continue",
"Swipe to confirm cancellation": "Swipe to confirm cancellation",
"Failed to update profile basic details. Please try again.": "Failed to update profile basic details. Please try again."
}
"Failed to update profile basic details. Please try again.": "Failed to update profile basic details. Please try again.",
"Select date": "Vælg dato",
"Day": "Dag",
"Month": "Måned",
"Year": "År"
}

8
src/translations/locales/de.json

@ -754,5 +754,9 @@
"Please select the reason for cancellation:": "Please select the reason for cancellation:",
"Swipe to continue": "Swipe to continue",
"Swipe to confirm cancellation": "Swipe to confirm cancellation",
"Failed to update profile basic details. Please try again.": "Failed to update profile basic details. Please try again."
}
"Failed to update profile basic details. Please try again.": "Failed to update profile basic details. Please try again.",
"Select date": "Datum auswählen",
"Day": "Tag",
"Month": "Monat",
"Year": "Jahr"
}

8
src/translations/locales/en.json

@ -804,5 +804,9 @@
"Helps individuals better understand their own needs as well as their partner’s motivations, preferences, and emotional priorities.": "Helps individuals better understand their own needs as well as their partner’s motivations, preferences, and emotional priorities.",
"Supporting More Suitable Match Recommendations": "Supporting More Suitable Match Recommendations",
"Combining needs and personality assessments with interviews and other marriage criteria can help make partner recommendations more personalized and well-matched.": "Combining needs and personality assessments with interviews and other marriage criteria can help make partner recommendations more personalized and well-matched.",
"Failed to update profile basic details. Please try again.": "Failed to update profile basic details. Please try again."
}
"Failed to update profile basic details. Please try again.": "Failed to update profile basic details. Please try again.",
"Select date": "Select date",
"Day": "Day",
"Month": "Month",
"Year": "Year"
}

8
src/translations/locales/es.json

@ -754,5 +754,9 @@
"Please select the reason for cancellation:": "Please select the reason for cancellation:",
"Swipe to continue": "Swipe to continue",
"Swipe to confirm cancellation": "Swipe to confirm cancellation",
"Failed to update profile basic details. Please try again.": "Failed to update profile basic details. Please try again."
}
"Failed to update profile basic details. Please try again.": "Failed to update profile basic details. Please try again.",
"Select date": "Seleccionar fecha",
"Day": "Día",
"Month": "Mes",
"Year": "Año"
}

8
src/translations/locales/fa.json

@ -804,5 +804,9 @@
"Helps individuals better understand their own needs as well as their partner’s motivations, preferences, and emotional priorities.": "به افراد کمک می‌کند تا نیازهای خود و همچنین انگیزه‌ها، ترجیحات و اولویت‌های عاطفی شریک زندگی خود را بهتر درک کنند.",
"Supporting More Suitable Match Recommendations": "پشتیبانی از پیشنهادهای همسریابی مناسب‌تر",
"Combining needs and personality assessments with interviews and other marriage criteria can help make partner recommendations more personalized and well-matched.": "ترکیب ارزیابی نیازها و شخصیت با مصاحبه‌ها و سایر معیارهای ازدواج می‌تواند به شخصی‌سازی و هماهنگی بیشتر پیشنهادهای شریک زندگی کمک کند.",
"Failed to update profile basic details. Please try again.": "به‌روزرسانی اطلاعات اولیه پروفایل ناموفق بود. لطفاً دوباره تلاش کنید."
}
"Failed to update profile basic details. Please try again.": "به‌روزرسانی اطلاعات اولیه پروفایل ناموفق بود. لطفاً دوباره تلاش کنید.",
"Select date": "انتخاب تاریخ",
"Day": "روز",
"Month": "ماه",
"Year": "سال"
}

8
src/translations/locales/fr.json

@ -754,5 +754,9 @@
"Please select the reason for cancellation:": "Please select the reason for cancellation:",
"Swipe to continue": "Swipe to continue",
"Swipe to confirm cancellation": "Swipe to confirm cancellation",
"Failed to update profile basic details. Please try again.": "Failed to update profile basic details. Please try again."
}
"Failed to update profile basic details. Please try again.": "Failed to update profile basic details. Please try again.",
"Select date": "Sélectionner la date",
"Day": "Jour",
"Month": "Mois",
"Year": "Année"
}

8
src/translations/locales/gu.json

@ -754,5 +754,9 @@
"Please select the reason for cancellation:": "Please select the reason for cancellation:",
"Swipe to continue": "Swipe to continue",
"Swipe to confirm cancellation": "Swipe to confirm cancellation",
"Failed to update profile basic details. Please try again.": "Failed to update profile basic details. Please try again."
}
"Failed to update profile basic details. Please try again.": "Failed to update profile basic details. Please try again.",
"Select date": "તારીખ પસંદ કરો",
"Day": "દિવસ",
"Month": "મહિનો",
"Year": "વર્ષ"
}

8
src/translations/locales/ha.json

@ -754,5 +754,9 @@
"Please select the reason for cancellation:": "Please select the reason for cancellation:",
"Swipe to continue": "Swipe to continue",
"Swipe to confirm cancellation": "Swipe to confirm cancellation",
"Failed to update profile basic details. Please try again.": "Failed to update profile basic details. Please try again."
}
"Failed to update profile basic details. Please try again.": "Failed to update profile basic details. Please try again.",
"Select date": "Zaɓi kwanan wata",
"Day": "Rana",
"Month": "Wata",
"Year": "Shekara"
}

10
src/translations/locales/he.json

@ -280,5 +280,11 @@
"Please select the reason for cancellation:": "Please select the reason for cancellation:",
"Swipe to continue": "Swipe to continue",
"Swipe to confirm cancellation": "Swipe to confirm cancellation",
"Failed to update profile basic details. Please try again.": "Failed to update profile basic details. Please try again."
}
"Failed to update profile basic details. Please try again.": "Failed to update profile basic details. Please try again.",
"Select date": "בחר תאריך",
"Day": "יום",
"Month": "חודש",
"Year": "שנה",
"Age": "גיל",
"Confirm": "אישור"
}

8
src/translations/locales/hi.json

@ -754,5 +754,9 @@
"Please select the reason for cancellation:": "Please select the reason for cancellation:",
"Swipe to continue": "Swipe to continue",
"Swipe to confirm cancellation": "Swipe to confirm cancellation",
"Failed to update profile basic details. Please try again.": "Failed to update profile basic details. Please try again."
}
"Failed to update profile basic details. Please try again.": "Failed to update profile basic details. Please try again.",
"Select date": "तारीख़ चुनें",
"Day": "दिन",
"Month": "महीना",
"Year": "साल"
}

10
src/translations/locales/id.json

@ -280,5 +280,11 @@
"Please select the reason for cancellation:": "Please select the reason for cancellation:",
"Swipe to continue": "Swipe to continue",
"Swipe to confirm cancellation": "Swipe to confirm cancellation",
"Failed to update profile basic details. Please try again.": "Failed to update profile basic details. Please try again."
}
"Failed to update profile basic details. Please try again.": "Failed to update profile basic details. Please try again.",
"Select date": "Pilih tanggal",
"Day": "Tanggal",
"Month": "Bulan",
"Year": "Tahun",
"Age": "Usia",
"Confirm": "Konfirmasi"
}

10
src/translations/locales/ks.json

@ -280,5 +280,11 @@
"Please select the reason for cancellation:": "Please select the reason for cancellation:",
"Swipe to continue": "Swipe to continue",
"Swipe to confirm cancellation": "Swipe to confirm cancellation",
"Failed to update profile basic details. Please try again.": "Failed to update profile basic details. Please try again."
}
"Failed to update profile basic details. Please try again.": "Failed to update profile basic details. Please try again.",
"Select date": "تاریخ منتخب کریں",
"Day": "دوہ",
"Month": "ماہ",
"Year": "ؤری",
"Age": "عمر",
"Confirm": "تصدیق کریں"
}

10
src/translations/locales/pt.json

@ -280,5 +280,11 @@
"Please select the reason for cancellation:": "Please select the reason for cancellation:",
"Swipe to continue": "Swipe to continue",
"Swipe to confirm cancellation": "Swipe to confirm cancellation",
"Failed to update profile basic details. Please try again.": "Failed to update profile basic details. Please try again."
}
"Failed to update profile basic details. Please try again.": "Failed to update profile basic details. Please try again.",
"Select date": "Selecionar data",
"Day": "Dia",
"Month": "Mês",
"Year": "Ano",
"Age": "Idade",
"Confirm": "Confirmar"
}

8
src/translations/locales/ru.json

@ -759,5 +759,9 @@
"{days} days remaining of your subscription.": "{days} дней до конца вашей подписки.",
"⚠️ This section is completely confidential and is only used for matching and review by advisors.": "⚠️ Этот раздел полностью конфиденциальен и используется только консультантами для сопоставления и проверки.",
"Please select the reason for cancellation:": "Пожалуйста, выберите причину отмены:",
"Failed to update profile basic details. Please try again.": "Failed to update profile basic details. Please try again."
}
"Failed to update profile basic details. Please try again.": "Failed to update profile basic details. Please try again.",
"Select date": "Выберите дату",
"Day": "День",
"Month": "Месяц",
"Year": "Год"
}

10
src/translations/locales/sw.json

@ -280,5 +280,11 @@
"Please select the reason for cancellation:": "Please select the reason for cancellation:",
"Swipe to continue": "Swipe to continue",
"Swipe to confirm cancellation": "Swipe to confirm cancellation",
"Failed to update profile basic details. Please try again.": "Failed to update profile basic details. Please try again."
}
"Failed to update profile basic details. Please try again.": "Failed to update profile basic details. Please try again.",
"Select date": "Chagua tarehe",
"Day": "Siku",
"Month": "Mwezi",
"Year": "Mwaka",
"Age": "Umri",
"Confirm": "Thibitisha"
}

10
src/translations/locales/tg.json

@ -280,5 +280,11 @@
"Please select the reason for cancellation:": "Please select the reason for cancellation:",
"Swipe to continue": "Swipe to continue",
"Swipe to confirm cancellation": "Swipe to confirm cancellation",
"Failed to update profile basic details. Please try again.": "Failed to update profile basic details. Please try again."
}
"Failed to update profile basic details. Please try again.": "Failed to update profile basic details. Please try again.",
"Select date": "Санаро интихоб кунед",
"Day": "Рӯз",
"Month": "Моҳ",
"Year": "Сол",
"Age": "Синну сол",
"Confirm": "Тасдиқ кунед"
}

10
src/translations/locales/tr.json

@ -280,5 +280,11 @@
"Please select the reason for cancellation:": "Please select the reason for cancellation:",
"Swipe to continue": "Swipe to continue",
"Swipe to confirm cancellation": "Swipe to confirm cancellation",
"Failed to update profile basic details. Please try again.": "Failed to update profile basic details. Please try again."
}
"Failed to update profile basic details. Please try again.": "Failed to update profile basic details. Please try again.",
"Select date": "Tarih seçin",
"Day": "Gün",
"Month": "Ay",
"Year": "Yıl",
"Age": "Yaş",
"Confirm": "Onayla"
}

10
src/translations/locales/ul.json

@ -280,5 +280,11 @@
"Please select the reason for cancellation:": "Please select the reason for cancellation:",
"Swipe to continue": "Swipe to continue",
"Swipe to confirm cancellation": "Swipe to confirm cancellation",
"Failed to update profile basic details. Please try again.": "Failed to update profile basic details. Please try again."
}
"Failed to update profile basic details. Please try again.": "Failed to update profile basic details. Please try again.",
"Select date": "چېسلا تاللاڭ",
"Day": "كۈن",
"Month": "ئاي",
"Year": "يىل",
"Age": "يېشى",
"Confirm": "جەزملەش"
}

10
src/translations/locales/ur.json

@ -280,5 +280,11 @@
"Please select the reason for cancellation:": "Please select the reason for cancellation:",
"Swipe to continue": "Swipe to continue",
"Swipe to confirm cancellation": "Swipe to confirm cancellation",
"Failed to update profile basic details. Please try again.": "Failed to update profile basic details. Please try again."
}
"Failed to update profile basic details. Please try again.": "Failed to update profile basic details. Please try again.",
"Select date": "تاریخ منتخب کریں",
"Day": "دن",
"Month": "مہینہ",
"Year": "سال",
"Age": "عمر",
"Confirm": "تصدیق کریں"
}

10
src/translations/locales/uz.json

@ -280,5 +280,11 @@
"Please select the reason for cancellation:": "Please select the reason for cancellation:",
"Swipe to continue": "Swipe to continue",
"Swipe to confirm cancellation": "Swipe to confirm cancellation",
"Failed to update profile basic details. Please try again.": "Failed to update profile basic details. Please try again."
}
"Failed to update profile basic details. Please try again.": "Failed to update profile basic details. Please try again.",
"Select date": "Sanani tanlang",
"Day": "Kun",
"Month": "Oy",
"Year": "Yil",
"Age": "Yosh",
"Confirm": "Tasdiqlash"
}

8
src/translations/locales/zh.json

@ -754,5 +754,9 @@
"Please select the reason for cancellation:": "Please select the reason for cancellation:",
"Swipe to continue": "Swipe to continue",
"Swipe to confirm cancellation": "Swipe to confirm cancellation",
"Failed to update profile basic details. Please try again.": "Failed to update profile basic details. Please try again."
}
"Failed to update profile basic details. Please try again.": "Failed to update profile basic details. Please try again.",
"Select date": "选择日期",
"Day": "日",
"Month": "月",
"Year": "年"
}
Loading…
Cancel
Save