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. 342
      src/app/questions-list/[slug]/question-detail-client.test.tsx
  6. 162
      src/app/questions-list/[slug]/question-detail-client.tsx
  7. 411
      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. 123
      src/components/Componentes/question-answer-storage.tsx
  12. 255
      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. 185
      src/components/Componentes/schema-question-flow.integration.test.tsx
  27. 72
      src/components/Componentes/token-switcher.tsx
  28. 158
      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. 64
      src/lib/schema-adapter.ts
  38. 6
      src/translations/locales/ar.json
  39. 6
      src/translations/locales/az.json
  40. 6
      src/translations/locales/bn.json
  41. 6
      src/translations/locales/da.json
  42. 6
      src/translations/locales/de.json
  43. 6
      src/translations/locales/en.json
  44. 6
      src/translations/locales/es.json
  45. 6
      src/translations/locales/fa.json
  46. 6
      src/translations/locales/fr.json
  47. 6
      src/translations/locales/gu.json
  48. 6
      src/translations/locales/ha.json
  49. 8
      src/translations/locales/he.json
  50. 6
      src/translations/locales/hi.json
  51. 8
      src/translations/locales/id.json
  52. 8
      src/translations/locales/ks.json
  53. 8
      src/translations/locales/pt.json
  54. 6
      src/translations/locales/ru.json
  55. 8
      src/translations/locales/sw.json
  56. 8
      src/translations/locales/tg.json
  57. 8
      src/translations/locales/tr.json
  58. 8
      src/translations/locales/ul.json
  59. 8
      src/translations/locales/ur.json
  60. 8
      src/translations/locales/uz.json
  61. 6
      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 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 { class ProxyError extends Error {
constructor( constructor(
@ -131,7 +130,11 @@ function getRequestHeaders(request: NextRequest, targetUrl: URL) {
getCookieValue(cookieHeader, "token") ?? getCookieValue(cookieHeader, "token") ??
getCookieValue(cookieHeader, "auth_token"); getCookieValue(cookieHeader, "auth_token");
if (cookieToken && cookieToken !== "NO_TOKEN" && cookieToken.trim() !== "") {
if (
cookieToken &&
cookieToken !== "NO_TOKEN" &&
cookieToken.trim() !== ""
) {
headers.set("authorization", `Token ${cookieToken}`); headers.set("authorization", `Token ${cookieToken}`);
} }
} }
@ -154,7 +157,7 @@ function getRequestHeaders(request: NextRequest, targetUrl: URL) {
candidate !== null && isLocale(candidate), candidate !== null && isLocale(candidate),
) ?? "en"; ) ?? "en";
headers.set("accept-encoding", "identity");
headers.set("accept-encoding", "gzip, br");
headers.set("accept-language", lang); headers.set("accept-language", lang);
headers.set("x-user-language", lang); headers.set("x-user-language", lang);
headers.set("http_x_user_language", lang); headers.set("http_x_user_language", lang);
@ -268,9 +271,9 @@ function logProxyRequest(
function logProxyResponse( function logProxyResponse(
upstreamResponse: Response, upstreamResponse: Response,
responseBody: ArrayBuffer,
metrics: Record<string, number>,
) { ) {
if (!shouldLogProxy) {
if (!shouldLogProxyTiming) {
return; return;
} }
@ -278,19 +281,7 @@ function logProxyResponse(
status: upstreamResponse.status, status: upstreamResponse.status,
statusText: upstreamResponse.statusText, statusText: upstreamResponse.statusText,
headers: headersToObject(upstreamResponse.headers), 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) { async function proxyRequest(request: NextRequest) {
try { try {
const proxyStartedAt = performance.now();
const targetUrl = getTargetUrl(request); const targetUrl = getTargetUrl(request);
const requestBody = const requestBody =
request.method === "GET" || request.method === "HEAD" request.method === "GET" || request.method === "HEAD"
@ -309,15 +301,14 @@ async function proxyRequest(request: NextRequest) {
logProxyRequest(request, targetUrl, requestHeaders, requestBody); logProxyRequest(request, targetUrl, requestHeaders, requestBody);
const upstreamStartedAt = performance.now();
const upstreamResponse = await fetch(targetUrl, { const upstreamResponse = await fetch(targetUrl, {
method: request.method, method: request.method,
headers: requestHeaders, headers: requestHeaders,
body: requestBody, body: requestBody,
cache: "no-store", cache: "no-store",
}); });
const responseBody = await upstreamResponse.arrayBuffer();
logProxyResponse(upstreamResponse, responseBody);
const upstreamTtfb = performance.now() - upstreamStartedAt;
const responseHeaders = getResponseHeaders(upstreamResponse.headers); const responseHeaders = getResponseHeaders(upstreamResponse.headers);
@ -328,6 +319,31 @@ async function proxyRequest(request: NextRequest) {
); );
responseHeaders.set("Pragma", "no-cache"); responseHeaders.set("Pragma", "no-cache");
responseHeaders.set("Expires", "0"); 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, { return new Response(responseBody, {
status: upstreamResponse.status, status: upstreamResponse.status,

82
src/app/globals.css

@ -145,16 +145,22 @@
} }
html { html {
min-height: 100%;
width: 100%;
height: 100%;
overflow: hidden;
overscroll-behavior: none;
} }
body { body {
min-height: 100vh;
width: 100%;
height: 100%;
margin: 0; margin: 0;
display: flex; display: flex;
justify-content: center; justify-content: center;
overflow: hidden;
color: var(--foreground); color: var(--foreground);
font-family: "Segoe UI", Tahoma, Geneva, Verdana, sans-serif; font-family: "Segoe UI", Tahoma, Geneva, Verdana, sans-serif;
overscroll-behavior: none;
} }
html:lang(ar) body, html:lang(ar) body,
@ -180,15 +186,21 @@ html:lang(ar) body,
.app-shell { .app-shell {
width: 100%; width: 100%;
min-height: 100vh;
height: 100%;
height: 100dvh;
padding-inline: 17px; padding-inline: 17px;
padding-bottom: var(--safe-bottom, 0px); padding-bottom: var(--safe-bottom, 0px);
box-sizing: border-box; box-sizing: border-box;
overflow-x: hidden;
overflow-y: auto;
background-color: var(--background); background-color: var(--background);
background-image: var(--default-page-background-image); background-image: var(--default-page-background-image);
background-position: top; background-position: top;
background-repeat: no-repeat; background-repeat: no-repeat;
background-size: cover; background-size: cover;
overscroll-behavior: none;
touch-action: pan-y;
-webkit-overflow-scrolling: touch;
} }
html[data-web-bootstrap="pending"] .app-shell { html[data-web-bootstrap="pending"] .app-shell {
@ -217,6 +229,70 @@ body[data-page-background="custom"] .app-shell {
background-image: var(--page-background-image); 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-none,
.page-background-custom, .page-background-custom,
.page-background-default { .page-background-default {

2
src/app/layout.tsx

@ -44,6 +44,8 @@ export const viewport: Viewport = {
width: "device-width", width: "device-width",
initialScale: 1, initialScale: 1,
maximumScale: 1, maximumScale: 1,
userScalable: false,
viewportFit: "cover",
themeColor: "#ffffff", themeColor: "#ffffff",
}; };

342
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 { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import QuestionDetailClient from "./question-detail-client"; import QuestionDetailClient from "./question-detail-client";
import { useCattellQuestionsQuery } from "@/hooks/marriage/use-cattell"; import { useCattellQuestionsQuery } from "@/hooks/marriage/use-cattell";
import { useGlasserQuestionsQuery } from "@/hooks/marriage/use-glasser"; import { useGlasserQuestionsQuery } from "@/hooks/marriage/use-glasser";
import { useMarriageProfileQuery } from "@/hooks/marriage/use-profile-main"; 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"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
vi.mock("next/navigation", () => ({ vi.mock("next/navigation", () => ({
@ -13,7 +25,10 @@ vi.mock("next/navigation", () => ({
})); }));
vi.mock("@/translations/provider", () => ({ 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", () => ({ vi.mock("@/hooks/marriage/use-cattell", () => ({
@ -31,7 +46,8 @@ vi.mock("@/hooks/marriage/use-profile-main", () => ({
})); }));
vi.mock("@/hooks/marriage/use-form-schema", () => ({ vi.mock("@/hooks/marriage/use-form-schema", () => ({
useFormSchemaQuery: vi.fn(),
useFormOverviewQuery: vi.fn(),
useFormSectionQuery: vi.fn(),
})); }));
vi.mock("@/hooks/marriage/use-habcoin-payment", () => ({ vi.mock("@/hooks/marriage/use-habcoin-payment", () => ({
@ -39,7 +55,8 @@ vi.mock("@/hooks/marriage/use-habcoin-payment", () => ({
})); }));
vi.mock("@/lib/schema-adapter", () => ({ vi.mock("@/lib/schema-adapter", () => ({
convertSchemaToFrontendItems: vi.fn(),
convertOverviewToFrontendItems: vi.fn(),
mapBackendSectionToFrontend: vi.fn(),
})); }));
describe("QuestionDetailClient Validation", () => { describe("QuestionDetailClient Validation", () => {
@ -58,20 +75,24 @@ describe("QuestionDetailClient Validation", () => {
isLoading: false, isLoading: false,
}); });
(useFormSchemaQuery as any).mockReturnValue({
(useFormOverviewQuery as any).mockReturnValue({
data: {}, data: {},
isLoading: false, isLoading: false,
}); });
(useFormSectionQuery as any).mockReturnValue({
data: undefined,
isLoading: false,
});
}); });
const setupTest = (slug: string, cattellData: any, glasserData: any) => { const setupTest = (slug: string, cattellData: any, glasserData: any) => {
// Mock schema adapter to return an item for the requested slug // Mock schema adapter to return an item for the requested slug
(convertSchemaToFrontendItems as any).mockReturnValue([
(convertOverviewToFrontendItems as any).mockReturnValue([
{ {
slug: slug, slug: slug,
title: "Test", title: "Test",
questions: []
}
questions: [],
},
]); ]);
(useCattellQuestionsQuery as any).mockReturnValue({ (useCattellQuestionsQuery as any).mockReturnValue({
@ -107,7 +128,7 @@ describe("QuestionDetailClient Validation", () => {
questionsListHref="/questions-list" questionsListHref="/questions-list"
title="Test" title="Test"
/> />
</QueryClientProvider>
</QueryClientProvider>,
); );
// Intro screen might render first; if Start is available, click it to mount questions flow // Intro screen might render first; if Start is available, click it to mount questions flow
@ -118,19 +139,23 @@ describe("QuestionDetailClient Validation", () => {
}; };
it("should render correctly when Cattell API data is completely valid", () => { 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 // Retry UI should NOT be present
expect(screen.queryByText("Retry")).toBeNull(); expect(screen.queryByText("Retry")).toBeNull();
@ -141,22 +166,30 @@ describe("QuestionDetailClient Validation", () => {
it("should render Retry UI when Cattell API data is empty", () => { it("should render Retry UI when Cattell API data is empty", () => {
setupTest("personality_test", { questions: [] }, null); 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(); expect(screen.getAllByText("Retry")).toBeDefined();
}); });
it("should render Retry UI when Cattell options are invalid (schema failure) and trigger refetch on Retry", async () => { 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);
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();
expect(
screen.getAllByText("No questions found for this test."),
).toBeDefined();
const retryBtn = screen.getAllByText("Retry")[0]; const retryBtn = screen.getAllByText("Retry")[0];
fireEvent.click(retryBtn); fireEvent.click(retryBtn);
@ -179,8 +212,8 @@ describe("QuestionDetailClient Validation", () => {
{ id: "o4", label: "O4", value: 4 }, { id: "o4", label: "O4", value: 4 },
{ id: "o5", label: "O5", value: 5 }, { id: "o5", label: "O5", value: 5 },
], ],
}
]
},
],
}); });
expect(screen.queryByText("Retry")).toBeNull(); expect(screen.queryByText("Retry")).toBeNull();
@ -200,10 +233,12 @@ describe("QuestionDetailClient Validation", () => {
{ label: "O3", value: 3 }, { label: "O3", value: 3 },
{ label: "O4", value: 4 }, { 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]; const retryBtn = screen.getAllByText("Retry")[0];
fireEvent.click(retryBtn); fireEvent.click(retryBtn);
@ -213,43 +248,57 @@ describe("QuestionDetailClient Validation", () => {
}); });
it("should render profile questions using ID-based data flow", () => { 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", slug: "profile_test",
title: "Profile Form", 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({ const queryClient = new QueryClient({
defaultOptions: { queries: { retry: false } }, defaultOptions: { queries: { retry: false } },
@ -266,10 +315,155 @@ describe("QuestionDetailClient Validation", () => {
questionsListHref="/questions-list" questionsListHref="/questions-list"
title="Profile Test" title="Profile Test"
/> />
</QueryClientProvider>
</QueryClientProvider>,
); );
// Profile questions render directly, no start button // Profile questions render directly, no start button
expect(screen.getByText(/Dynamic ID/)).toBeDefined(); 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();
});
}); });

162
src/app/questions-list/[slug]/question-detail-client.tsx

@ -2,6 +2,8 @@
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { useEffect, useMemo, useState } from "react"; 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 { PageLoadingSkeleton } from "@/components/Componentes/page-loading-skeleton";
import NavigationButton from "@/components/Componentes/navigation-button"; import NavigationButton from "@/components/Componentes/navigation-button";
import { PageBackground } from "@/components/Componentes/page-background"; import { PageBackground } from "@/components/Componentes/page-background";
@ -19,15 +21,26 @@ import TestQuestionsFlow, {
type TestQuestion, type TestQuestion,
} from "@/components/Componentes/test-questions-flow"; } from "@/components/Componentes/test-questions-flow";
import { import {
getGlasserQuestions,
useGlasserQuestionsQuery, useGlasserQuestionsQuery,
useSubmitGlasserAssessmentMutation, useSubmitGlasserAssessmentMutation,
} from "@/hooks/marriage/use-glasser"; } from "@/hooks/marriage/use-glasser";
import { import {
getCattellQuestions,
useCattellQuestionsQuery, useCattellQuestionsQuery,
useSubmitCattellAssessmentMutation, useSubmitCattellAssessmentMutation,
} from "@/hooks/marriage/use-cattell"; } 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 { defaultLocale, type Locale } from "@/translations/config";
import { useI18n } from "@/translations/provider"; import { useI18n } from "@/translations/provider";
@ -102,7 +115,9 @@ function QuestionFlowWrapper({
let isAnswered = hasAnswer; let isAnswered = hasAnswer;
if (hasAnswer) { if (hasAnswer) {
const isEmailQuestion = question.type === "email" || question.validation?.format === "email";
const isEmailQuestion =
question.type === "email" ||
question.validation?.format === "email";
if (isEmailQuestion) { if (isEmailQuestion) {
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
isAnswered = emailRegex.test(String(answer).trim()); isAnswered = emailRegex.test(String(answer).trim());
@ -126,10 +141,7 @@ function QuestionFlowWrapper({
data-question-disabled="false" data-question-disabled="false"
data-question-answered={String(isAnswered)} data-question-answered={String(isAnswered)}
> >
<QuestionRenderer
question={question}
dobQuestion={dobQuestion}
/>
<QuestionRenderer question={question} dobQuestion={dobQuestion} />
</div> </div>
); );
})} })}
@ -151,6 +163,7 @@ export default function QuestionDetailClient({
const { dictionary: t } = useI18n(); const { dictionary: t } = useI18n();
const [isTestStarted, setIsTestStarted] = useState(false); const [isTestStarted, setIsTestStarted] = useState(false);
const [hasTestProgress, setHasTestProgress] = useState(false); const [hasTestProgress, setHasTestProgress] = useState(false);
const queryClient = useQueryClient();
useEffect(() => { useEffect(() => {
if (typeof window !== "undefined") { if (typeof window !== "undefined") {
@ -174,12 +187,37 @@ export default function QuestionDetailClient({
} }
}, [itemSlug, isTestStarted]); }, [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 isCattellSlug = itemSlug === "personality_test";
const isGlasserSlug = itemSlug === "glasser_5_needs_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, { const cattellQuery = useCattellQuestionsQuery(locale, {
enabled: isCattellSlug && isTestStarted, enabled: isCattellSlug && isTestStarted,
@ -193,6 +231,30 @@ export default function QuestionDetailClient({
}); });
const submitGlasserMutation = useSubmitGlasserAssessmentMutation(); 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 cattellTestQuestions: TestQuestion[] = useMemo(() => {
const questionsList = cattellQuery.data?.questions || []; const questionsList = cattellQuery.data?.questions || [];
@ -225,7 +287,14 @@ export default function QuestionDetailClient({
q.text && q.text &&
q.options && q.options &&
q.options.length === 5 && q.options.length === 5 &&
q.options.every((o: any) => o.id && o.label && typeof o.value === 'number' && o.value >= 1 && o.value <= 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)) { if (questionsList.length > 0 && !questionsList.every(isValidGlasser)) {
console.error("Invalid Glasser API response schema"); console.error("Invalid Glasser API response schema");
@ -270,12 +339,55 @@ export default function QuestionDetailClient({
}, [isSchemaLoading, item, questionsListHref, router]); }, [isSchemaLoading, item, questionsListHref, router]);
if (isSchemaLoading) { 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) { } else if (!item) {
return null; return null;
} }
@ -416,7 +528,10 @@ export default function QuestionDetailClient({
<> <>
<PageBackground disabled /> <PageBackground disabled />
<main className="-mx-[17px] flex h-svh flex-col overflow-hidden bg-[#F7F1F0]"> <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"> <div className="flex items-center gap-4">
<NavigationButton <NavigationButton
className="shrink-0" className="shrink-0"
@ -572,7 +687,8 @@ export default function QuestionDetailClient({
} }
const dobQuestion = visibleQuestions.find( const dobQuestion = visibleQuestions.find(
(question) => question.ui_config?.isDob === true || question.type === "date",
(question) =>
question.ui_config?.isDob === true || question.type === "date",
); );
return ( return (
@ -583,10 +699,12 @@ export default function QuestionDetailClient({
slug={item.slug} slug={item.slug}
questions={visibleQuestions} questions={visibleQuestions}
locale={locale} locale={locale}
schemaVersion={schema?.version ?? 1}
> >
<main className="-mx-[17px] flex h-svh flex-col overflow-hidden bg-[#F7F1F0]"> <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"> <div className="flex items-center gap-4">
<QuestionExitNavigationButton <QuestionExitNavigationButton
className="shrink-0" className="shrink-0"

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

@ -1,38 +1,48 @@
"use client"; "use client";
import { useQueryClient } from "@tanstack/react-query";
import Image from "next/image"; import Image from "next/image";
import { useRouter } from "next/navigation"; 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 { 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 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 InformationSheet from "@/components/Componentes/information-sheet";
import NavigationButton from "@/components/Componentes/navigation-button"; 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 { 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 { useStartMarriageMatchMutation } from "@/hooks/marriage/use-match-start";
import { useMarriageProfileQuery } from "@/hooks/marriage/use-profile-main"; 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 { 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 { import {
clearMatchStartGrace, clearMatchStartGrace,
markMatchStarted, markMatchStarted,
} from "@/lib/match-start-grace"; } from "@/lib/match-start-grace";
import type { QuestionListItem } from "@/lib/schema-adapter";
import { convertOverviewToFrontendItems } from "@/lib/schema-adapter";
import { localizePath } from "@/translations/config"; import { localizePath } from "@/translations/config";
import { useI18n } from "@/translations/provider"; import { useI18n } from "@/translations/provider";
import { prefetchSectionsWithBoundedConcurrency } from "./section-prefetch";
import SectionsRequest from "./sections-request"; import SectionsRequest from "./sections-request";
import { getAssessmentLocalProgress } from "@/lib/assessment-progress";
export default function QuestionsListPage() { export default function QuestionsListPage() {
useCloseServiceOnBack(); useCloseServiceOnBack();
@ -41,8 +51,10 @@ export default function QuestionsListPage() {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const { data: profile, isLoading: isProfileLoading } = const { data: profile, isLoading: isProfileLoading } =
useMarriageProfileQuery(); useMarriageProfileQuery();
const { data: schema, isLoading: isSchemaLoading } =
useFormSchemaQuery("profile", locale);
const { data: overview, isLoading: isSchemaLoading } = useFormOverviewQuery(
"profile",
locale,
);
const isSectionsLoading = false; const isSectionsLoading = false;
@ -53,7 +65,8 @@ export default function QuestionsListPage() {
const isProfileRedirecting = const isProfileRedirecting =
profileTargetPath !== null && profileTargetPath !== null &&
profileTargetPath !== "/questions-list" && profileTargetPath !== "/questions-list" &&
(!hasCompletedMarriageProfileBasics(profile) || profile?.can_edit_profile === false);
(!hasCompletedMarriageProfileBasics(profile) ||
profile?.can_edit_profile === false);
useEffect(() => { useEffect(() => {
if (isProfileRedirecting && profileTargetPath) { if (isProfileRedirecting && profileTargetPath) {
@ -76,10 +89,12 @@ export default function QuestionsListPage() {
const [selectedSection, setSelectedSection] = const [selectedSection, setSelectedSection] =
useState<QuestionListItem | null>(null); useState<QuestionListItem | null>(null);
const questionListItems = useMemo( 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(() => { useEffect(() => {
const next = new Map<string, number>(); const next = new Map<string, number>();
@ -94,18 +109,24 @@ export default function QuestionsListPage() {
} }
} }
setLocalAssessmentProgress(next); setLocalAssessmentProgress(next);
}, [schema]);
}, [overview]);
const sectionProgressBySlug = useMemo(() => { const sectionProgressBySlug = useMemo(() => {
const progressBySlug = new Map<string, number>(); 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) => { 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 // Add fallback for combined section from the schema adapter which attaches it to questionListItems directly
@ -116,15 +137,63 @@ export default function QuestionsListPage() {
}); });
return progressBySlug; return progressBySlug;
}, [schema, questionListItems, localAssessmentProgress]);
}, [overview, questionListItems, localAssessmentProgress]);
const requiredQuestionListItems = useMemo( const requiredQuestionListItems = useMemo(
() => questionListItems.filter((item) => Boolean(item.required)), () => questionListItems.filter((item) => Boolean(item.required)),
[questionListItems], [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(() => { const allRequiredSectionsCompleted = useMemo(() => {
if (requiredQuestionListItems.length === 0) {
if (!hasValidRequiredContract) {
return false; return false;
} }
@ -132,7 +201,11 @@ export default function QuestionsListPage() {
const progress = sectionProgressBySlug.get(item.slug) ?? 0; const progress = sectionProgressBySlug.get(item.slug) ?? 0;
return progress >= 100; return progress >= 100;
}); });
}, [requiredQuestionListItems, sectionProgressBySlug]);
}, [
hasValidRequiredContract,
requiredQuestionListItems,
sectionProgressBySlug,
]);
const profileStatus = profile?.status; const profileStatus = profile?.status;
const isProfileSuspended = profileStatus === "suspended"; const isProfileSuspended = profileStatus === "suspended";
@ -144,70 +217,209 @@ export default function QuestionsListPage() {
const [isSyncing, setIsSyncing] = useState(false); const [isSyncing, setIsSyncing] = useState(false);
const [toastMessage, setToastMessage] = useState<string | null>(null); const [toastMessage, setToastMessage] = useState<string | null>(null);
const syncPromiseRef = useRef<Promise<void> | null>(null);
const syncPendingAnswers = useCallback(async () => { 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(() => { useEffect(() => {
void syncPendingAnswers().catch(() => setIsSyncError(true));
void syncPendingAnswers().catch((err) => {
console.warn("Background draft sync:", err);
});
const handleOnline = () => { const handleOnline = () => {
void syncPendingAnswers().catch(() => setIsSyncError(true));
void syncPendingAnswers().catch((err) => {
console.warn("Background draft sync on online:", err);
});
}; };
window.addEventListener("online", handleOnline); window.addEventListener("online", handleOnline);
return () => window.removeEventListener("online", handleOnline); return () => window.removeEventListener("online", handleOnline);
}, [syncPendingAnswers]); }, [syncPendingAnswers]);
useEffect(() => { useEffect(() => {
if (startMatchMutation.isError || isSyncError) {
if (startMatchMutation.isError) {
setToastMessage( setToastMessage(
t[ t[
"Sending the match request failed. Please check your connection and try again." "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 = () => { const handleCloseToast = () => {
setToastMessage(null); setToastMessage(null);
@ -289,37 +501,38 @@ export default function QuestionsListPage() {
</header> </header>
<div className="relative mt-4 space-y-5"> <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 {/* Section Card Skeletons (solid blocks like the Meet/checkup
AppShimmer loading one sweep band runs across each card) */} AppShimmer loading one sweep band runs across each card) */}
<div className="space-y-3"> <div className="space-y-3">
{Array.from({ length: 6 }).map((_, idx) => ( {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>
</div> </div>
<FixToTheEnd> <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> </FixToTheEnd>
</main> </main>
</> </>
@ -395,7 +608,7 @@ export default function QuestionsListPage() {
className="text-left" className="text-left"
/> />
) : null} ) : null}
<SectionsRequest />
<SectionsRequest sections={overview?.sections} />
<PageBackground disabled /> <PageBackground disabled />
<main <main
@ -436,8 +649,8 @@ export default function QuestionsListPage() {
<div className="relative"> <div className="relative">
<div className="mt-4"> <div className="mt-4">
<RequiredStepsCard <RequiredStepsCard
items={questionListItems}
progressBySlug={sectionProgressBySlug}
completed={displayedRequiredSections}
total={REQUIRED_PROFILE_SECTION_COUNT}
/> />
</div> </div>
@ -448,6 +661,8 @@ export default function QuestionsListPage() {
item={item} item={item}
progress={sectionProgressBySlug.get(item.slug) ?? null} progress={sectionProgressBySlug.get(item.slug) ?? null}
onInfoClick={(section) => setSelectedSection(section)} onInfoClick={(section) => setSelectedSection(section)}
onNearViewport={enqueueViewportPrefetch}
onPrefetch={prefetchSection}
/> />
))} ))}
</section> </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 { IoClose } from "react-icons/io5";
import Button from "@/components/Componentes/button"; import Button from "@/components/Componentes/button";
import InformationSheet from "@/components/Componentes/information-sheet"; 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 = [ const bookingTerms = [
"All provided information is held in strict confidence.", "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?', '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; ] 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 [hasSeenSheet, setHasSeenSheet] = useState(true);
const hasNoProgression = useMemo(() => { const hasNoProgression = useMemo(() => {
@ -35,11 +36,13 @@ export default function SectionsRequest() {
} }
return sections.every( return sections.every(
(section) => section.current_step <= 0 && section.completion_percent <= 0,
(section) =>
section.progress.current_step <= 0 &&
section.progress.completion_percent <= 0,
); );
}, [sections]); }, [sections]);
const isOpen = isSuccess && hasNoProgression && !hasSeenSheet;
const isOpen = Boolean(sections) && hasNoProgression && !hasSeenSheet;
useEffect(() => { useEffect(() => {
try { try {

123
src/components/Componentes/question-answer-storage.tsx

@ -11,7 +11,6 @@ import {
useRef, useRef,
useState, useState,
} from "react"; } from "react";
import type { QuestionField } from "@/lib/schema-adapter";
import { pathParam } from "@/hooks/marriage/path-param"; import { pathParam } from "@/hooks/marriage/path-param";
import { marriageQueryKeys } from "@/hooks/marriage/query-keys"; import { marriageQueryKeys } from "@/hooks/marriage/query-keys";
import type { import type {
@ -22,10 +21,12 @@ import type {
} from "@/hooks/marriage/types"; } from "@/hooks/marriage/types";
import { useMarriageProfileQuery } from "@/hooks/marriage/use-profile-main"; import { useMarriageProfileQuery } from "@/hooks/marriage/use-profile-main";
import { import {
applyProfilePatchResultToCache,
useMarriageSectionDataQuery, useMarriageSectionDataQuery,
useUpdateMarriageSectionDataMutation, useUpdateMarriageSectionDataMutation,
} from "@/hooks/marriage/use-section-data"; } from "@/hooks/marriage/use-section-data";
import { getApiRequestUrl } from "@/lib/http"; import { getApiRequestUrl } from "@/lib/http";
import type { QuestionField } from "@/lib/schema-adapter";
const STORAGE_VERSION = 2; const STORAGE_VERSION = 2;
@ -47,16 +48,11 @@ type FlushAnswersOptions = {
type QuestionAnswersContextValue = { type QuestionAnswersContextValue = {
flushAnswers: (options?: FlushAnswersOptions) => Promise<void>; flushAnswers: (options?: FlushAnswersOptions) => Promise<void>;
getAnswerValue: (
question: QuestionField,
) => MarriageFieldValue | undefined;
getAnswerValue: (question: QuestionField) => MarriageFieldValue | undefined;
hasPendingSync: boolean; hasPendingSync: boolean;
isSaving: boolean; isSaving: boolean;
isLoading: boolean; isLoading: boolean;
setAnswerValue: (
question: QuestionField,
value: MarriageFieldValue,
) => void;
setAnswerValue: (question: QuestionField, value: MarriageFieldValue) => void;
backendFields: MarriageField[]; backendFields: MarriageField[];
}; };
@ -65,7 +61,6 @@ type QuestionAnswersProviderProps = {
questions: readonly QuestionField[]; questions: readonly QuestionField[];
slug: string; slug: string;
locale?: string; locale?: string;
schemaVersion?: number;
}; };
const QuestionAnswersContext = const QuestionAnswersContext =
@ -122,13 +117,11 @@ function isMarriagePhoneFieldValue(
); );
} }
function createQuestionField( function createQuestionField(
question: QuestionField, question: QuestionField,
value: MarriageFieldValue, value: MarriageFieldValue,
): MarriageField { ): MarriageField {
let option_id = undefined;
let option_id: string | string[] | undefined;
if (question.options && Array.isArray(question.options)) { if (question.options && Array.isArray(question.options)) {
if (question.type === "checkbox" && Array.isArray(value)) { if (question.type === "checkbox" && Array.isArray(value)) {
@ -201,9 +194,7 @@ function createPayload(
backendFields?: MarriageField[], backendFields?: MarriageField[],
): UpdateMarriageSectionDataPayload { ): UpdateMarriageSectionDataPayload {
const fields = getOrderedFields(answers, questions, backendFields); 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 { return {
current_step: getCurrentStep(fields, questions, backendFields), current_step: getCurrentStep(fields, questions, backendFields),
@ -249,7 +240,9 @@ function readStoredAnswers(storageKey: string, slug: string) {
storedValue.slug === slug && storedValue.slug === slug &&
(storedValue.pending_sync ?? fields.length > 0), (storedValue.pending_sync ?? fields.length > 0),
pendingKeys: Array.isArray(storedValue.pending_keys) 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), : fields.map((field) => field.key),
}; };
} catch { } catch {
@ -295,9 +288,7 @@ function writeStoredAnswers(
} }
function getKeepalivePatchUrl(slug: string) { function getKeepalivePatchUrl(slug: string) {
return getApiRequestUrl(
`/api/marriage/forms/profile/answers/`,
);
return getApiRequestUrl(`/api/marriage/forms/profile/answers/`);
} }
function getCsrfToken() { function getCsrfToken() {
@ -311,14 +302,13 @@ export function QuestionAnswersProvider({
questions, questions,
slug, slug,
locale = "en", locale = "en",
schemaVersion = 1,
}: QuestionAnswersProviderProps) { }: QuestionAnswersProviderProps) {
const storageKey = useMemo(() => getQuestionAnswersStorageKey(slug), [slug]); const storageKey = useMemo(() => getQuestionAnswersStorageKey(slug), [slug]);
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const [answers, setAnswers] = useState<QuestionAnswersByKey>({}); const [answers, setAnswers] = useState<QuestionAnswersByKey>({});
const [hasPendingSync, setHasPendingSync] = useState(false); const [hasPendingSync, setHasPendingSync] = useState(false);
const { isPending: isSaving, mutateAsync } = const { isPending: isSaving, mutateAsync } =
useUpdateMarriageSectionDataMutation(slug);
useUpdateMarriageSectionDataMutation(slug, locale);
const answersRef = useRef<QuestionAnswersByKey>({}); const answersRef = useRef<QuestionAnswersByKey>({});
const hasPendingSyncRef = useRef(false); const hasPendingSyncRef = useRef(false);
const answersRevisionRef = useRef(0); const answersRevisionRef = useRef(0);
@ -327,7 +317,6 @@ export function QuestionAnswersProvider({
const storageKeyRef = useRef(storageKey); const storageKeyRef = useRef(storageKey);
const slugRef = useRef(slug); const slugRef = useRef(slug);
const backendFieldsRef = useRef<MarriageField[]>([]); const backendFieldsRef = useRef<MarriageField[]>([]);
const schemaVersionRef = useRef(schemaVersion);
const dirtyKeysRef = useRef(new Set<string>()); const dirtyKeysRef = useRef(new Set<string>());
const { data: profile } = useMarriageProfileQuery(); const { data: profile } = useMarriageProfileQuery();
@ -342,8 +331,13 @@ export function QuestionAnswersProvider({
slugRef.current = slug; slugRef.current = slug;
questionsRef.current = questions; questionsRef.current = questions;
backendFieldsRef.current = serverSectionData?.data || []; 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(() => { useEffect(() => {
storageKeyRef.current = storageKey; storageKeyRef.current = storageKey;
@ -377,7 +371,7 @@ export function QuestionAnswersProvider({
const nextKeys = Object.keys(finalAnswers); const nextKeys = Object.keys(finalAnswers);
if (prevKeys.length === nextKeys.length) { if (prevKeys.length === nextKeys.length) {
const isSame = prevKeys.every( const isSame = prevKeys.every(
(k) => prev[k]?.value === finalAnswers[k]?.value
(k) => prev[k]?.value === finalAnswers[k]?.value,
); );
if (isSame) return prev; if (isSame) return prev;
} }
@ -416,10 +410,7 @@ export function QuestionAnswersProvider({
); );
const setAnswerValue = useCallback( const setAnswerValue = useCallback(
(
question: QuestionField,
value: MarriageFieldValue,
) => {
(question: QuestionField, value: MarriageFieldValue) => {
if (!canEdit) { if (!canEdit) {
return; return;
} }
@ -454,10 +445,16 @@ export function QuestionAnswersProvider({
question.type === "textarea" || question.type === "textarea" ||
question.type === "number"; 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(() => { syncTimeoutRef.current = setTimeout(() => {
void flushAnswersRef.current();
void flushAnswersRef.current().catch(() => {
// The draft stays pending so the next edit or exit can retry.
});
}, delay); }, delay);
return nextAnswers; return nextAnswers;
@ -482,11 +479,10 @@ export function QuestionAnswersProvider({
} }
const fullPayload = createPayload(answersRef.current, questionsRef.current, backendFieldsRef.current); 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 = { const payload = {
...fullPayload, ...fullPayload,
fields: fieldsToSend,
fields: nextDirtyKey ? fullPayload.fields.filter((field) => field.key === nextDirtyKey) : [],
version: schemaVersionRef.current, version: schemaVersionRef.current,
}; };
const revision = answersRevisionRef.current; const revision = answersRevisionRef.current;
@ -496,9 +492,30 @@ export function QuestionAnswersProvider({
} }
const request = mutateAsync(payload).then((result) => { 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; flushPromiseRef.current = request;
@ -519,7 +536,12 @@ export function QuestionAnswersProvider({
return; return;
} }
dirtyKeysRef.current.clear();
if (nextDirtyKey) dirtyKeysRef.current.delete(nextDirtyKey);
if (dirtyKeysRef.current.size > 0) {
await flushAnswersRef.current();
return;
}
hasPendingSyncRef.current = false; hasPendingSyncRef.current = false;
setHasPendingSync(false); setHasPendingSync(false);
@ -533,7 +555,7 @@ export function QuestionAnswersProvider({
[], [],
); );
}, },
[mutateAsync, canEdit],
[mutateAsync, canEdit, locale, queryClient],
); );
const flushAnswersRef = useRef(flushAnswers); const flushAnswersRef = useRef(flushAnswers);
@ -554,9 +576,15 @@ export function QuestionAnswersProvider({
return; 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; const revision = answersRevisionRef.current;
if (payload.fields.length === 0) { if (payload.fields.length === 0) {
@ -580,13 +608,12 @@ export function QuestionAnswersProvider({
fetch(getKeepalivePatchUrl(slugRef.current), { fetch(getKeepalivePatchUrl(slugRef.current), {
body: JSON.stringify({ body: JSON.stringify({
version: schemaVersionRef.current,
answers: answersPayload, answers: answersPayload,
}), }),
credentials: "include", credentials: "include",
headers, headers,
keepalive: true, keepalive: true,
method: "PUT",
method: "PATCH",
}) })
.then((response) => { .then((response) => {
if (!response.ok) { if (!response.ok) {
@ -598,7 +625,9 @@ export function QuestionAnswersProvider({
} }
hasPendingSyncRef.current = false; 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; const stillPending = dirtyKeysRef.current.size > 0;
hasPendingSyncRef.current = stillPending; hasPendingSyncRef.current = stillPending;
setHasPendingSync(stillPending); setHasPendingSync(stillPending);
@ -675,9 +704,7 @@ export function useQuestionAnswers() {
return context; return context;
} }
export function useQuestionAnswer(
question: QuestionField,
) {
export function useQuestionAnswer(question: QuestionField) {
const context = useContext(QuestionAnswersContext); const context = useContext(QuestionAnswersContext);
return { return {

255
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 { 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 { useFormSchemaQuery } from "@/hooks/marriage/use-form-schema";
import { useMarriageProfileQuery } from "@/hooks/marriage/use-profile-main"; 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", () => ({ vi.mock("@/hooks/marriage/use-form-schema", () => ({
useFormSchemaQuery: vi.fn(), useFormSchemaQuery: vi.fn(),
@ -14,6 +26,7 @@ vi.mock("@/hooks/marriage/use-profile-main", () => ({
useMarriageProfileQuery: vi.fn(), useMarriageProfileQuery: vi.fn(),
})); }));
vi.mock("@/hooks/marriage/use-section-data", () => ({ vi.mock("@/hooks/marriage/use-section-data", () => ({
applyProfilePatchResultToCache: vi.fn(),
useMarriageSectionDataQuery: vi.fn(), useMarriageSectionDataQuery: vi.fn(),
useUpdateMarriageSectionDataMutation: vi.fn(), useUpdateMarriageSectionDataMutation: vi.fn(),
})); }));
@ -28,8 +41,22 @@ function TestComponent({ slug }: { slug: string }) {
data-testid="set-radio" data-testid="set-radio"
onClick={() => onClick={() =>
setAnswerValue( 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" data-testid="set-checkbox"
onClick={() => onClick={() =>
setAnswerValue( 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 () => { 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( render(
<QueryClientProvider client={queryClient}> <QueryClientProvider client={queryClient}>
<QuestionAnswersProvider <QuestionAnswersProvider
slug="test_slug" slug="test_slug"
questions={[ 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" /> <TestComponent slug="test_slug" />
</QuestionAnswersProvider> </QuestionAnswersProvider>
</QueryClientProvider>
</QueryClientProvider>,
); );
fireEvent.click(screen.getByTestId("set-radio")); 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 () => { 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( render(
<QueryClientProvider client={queryClient}> <QueryClientProvider client={queryClient}>
<QuestionAnswersProvider <QuestionAnswersProvider
slug="test_slug" slug="test_slug"
questions={[ 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" /> <TestComponent slug="test_slug" />
</QuestionAnswersProvider> </QuestionAnswersProvider>
</QueryClientProvider>
</QueryClientProvider>,
); );
fireEvent.click(screen.getByTestId("set-checkbox")); 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", () => { it("should sort schema questions and options by order correctly", () => {
const mockSchema = { const mockSchema = {
sections: [ sections: [
@ -155,25 +285,52 @@ describe("Question Answer & Schema Integration", () => {
title: "Card", title: "Card",
order: 2, order: 2,
questions: [ 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", id: "card2",
title: "Card 2", title: "Card 2",
order: 1, order: 1,
questions: [ 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; } as any;
const frontendItems = convertSchemaToFrontendItems(mockSchema, "en"); const frontendItems = convertSchemaToFrontendItems(mockSchema, "en");
@ -193,14 +350,24 @@ describe("Question Answer & Schema Integration", () => {
data: { data: {
slug: "test_slug", slug: "test_slug",
data: [ 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, isLoading: false,
}); });
let capturedValues: any = {};
const capturedValues: any = {};
function HydrationTestComponent() { function HydrationTestComponent() {
const { getAnswerValue } = useQuestionAnswers(); const { getAnswerValue } = useQuestionAnswers();
@ -209,14 +376,16 @@ describe("Question Answer & Schema Integration", () => {
return <div />; return <div />;
} }
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } });
const queryClient = new QueryClient({
defaultOptions: { queries: { retry: false } },
});
render( render(
<QueryClientProvider client={queryClient}> <QueryClientProvider client={queryClient}>
<QuestionAnswersProvider slug="test_slug" questions={[]}> <QuestionAnswersProvider slug="test_slug" questions={[]}>
<HydrationTestComponent /> <HydrationTestComponent />
</QuestionAnswersProvider> </QuestionAnswersProvider>
</QueryClientProvider>
</QueryClientProvider>,
); );
// Give it a moment to reconcile useEffect in QuestionAnswersProvider // Give it a moment to reconcile useEffect in QuestionAnswersProvider
@ -242,13 +411,23 @@ describe("Question Answer & Schema Integration", () => {
title: "Card", title: "Card",
order: 1, order: 1,
questions: [ 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; } as any;
const frontendItems = convertSchemaToFrontendItems(mockSchema, "en"); const frontendItems = convertSchemaToFrontendItems(mockSchema, "en");

485
src/components/Componentes/question-birthplace.tsx

@ -1,12 +1,16 @@
"use client"; "use client";
import { useCallback, useEffect, useRef, useState } from "react"; import { useCallback, useEffect, useRef, useState } from "react";
import { createPortal } from "react-dom";
import { getCountryList, COUNTRIES_EN, COUNTRIES_FA } from "@/data/countries"; import { getCountryList, COUNTRIES_EN, COUNTRIES_FA } from "@/data/countries";
import type { QuestionField } from "@/lib/schema-adapter"; import type { QuestionField } from "@/lib/schema-adapter";
import { useI18n } from "@/translations/provider"; import { useI18n } from "@/translations/provider";
import { useQuestionAnswers } from "./question-answer-storage"; import { useQuestionAnswers } from "./question-answer-storage";
import QuestionTitle from "./question-title"; import QuestionTitle from "./question-title";
import { LoadingThreeDot } from "./loading-three-dot"; import { LoadingThreeDot } from "./loading-three-dot";
import { useSheetScrollLock } from "./use-sheet-scroll-lock";
const EXIT_ANIMATION_MS = 220;
type QuestionBirthplaceProps = { type QuestionBirthplaceProps = {
question: QuestionField; question: QuestionField;
@ -88,10 +92,11 @@ export function QuestionBirthplace({
const [cityInput, setCityInput] = useState(initial.city); const [cityInput, setCityInput] = useState(initial.city);
const [isOpen, setIsOpen] = useState(false); const [isOpen, setIsOpen] = useState(false);
const [isClosing, setIsClosing] = useState(false);
const [searchQuery, setSearchQuery] = useState(""); const [searchQuery, setSearchQuery] = useState("");
const containerRef = useRef<HTMLDivElement>(null);
const cityInputRef = useRef<HTMLInputElement>(null);
const listRef = useRef<HTMLDivElement>(null); const listRef = useRef<HTMLDivElement>(null);
const searchInputRef = useRef<HTMLInputElement>(null);
const isMountedRef = useRef(true);
const isResidence = question.ui_config?.enable_geoip === true; const isResidence = question.ui_config?.enable_geoip === true;
@ -99,6 +104,47 @@ export function QuestionBirthplace({
const [isDetecting, setIsDetecting] = useState(false); const [isDetecting, setIsDetecting] = useState(false);
const [detectedLocation, setDetectedLocation] = useState(""); 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 updateAnswers = (country: string, city: string) => {
const formatted = const formatted =
city && country ? `${city}, ${country}` : city || country || null; city && country ? `${city}, ${country}` : city || country || null;
@ -199,9 +245,12 @@ export function QuestionBirthplace({
localStorage.setItem("hasCheckedGeoIPResidence", "true"); localStorage.setItem("hasCheckedGeoIPResidence", "true");
} }
setMode("manual"); 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 // Synchronize state if rawValue changes externally
@ -218,62 +267,6 @@ export function QuestionBirthplace({
} }
}, [rawValue]); }, [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 options = getCountryList(locale);
const filteredOptions = options.filter((option) => const filteredOptions = options.filter((option) =>
option.toLowerCase().includes(searchQuery.toLowerCase()), option.toLowerCase().includes(searchQuery.toLowerCase()),
@ -284,9 +277,11 @@ export function QuestionBirthplace({
localStorage.setItem("hasCheckedGeoIPResidence", "true"); localStorage.setItem("hasCheckedGeoIPResidence", "true");
} }
setSelectedCountry(country); 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>) => { const handleCityChange = (e: React.ChangeEvent<HTMLInputElement>) => {
@ -304,9 +299,24 @@ export function QuestionBirthplace({
question.extras?.placeHolder || question.extras?.placeHolder ||
(isRtl ? "شهر، منطقه یا محله" : "City, region, or neighborhood"); (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 ( return (
<div <div
ref={containerRef}
className={[ className={[
"relative flex w-full flex-col gap-3 transition-opacity duration-200", "relative flex w-full flex-col gap-3 transition-opacity duration-200",
disabled ? "pointer-events-none opacity-30" : "", disabled ? "pointer-events-none opacity-30" : "",
@ -425,12 +435,12 @@ export function QuestionBirthplace({
{mode === "manual" && ( {mode === "manual" && (
<div className="flex flex-col gap-3 w-full animate-in fade-in slide-in-from-top-2 duration-200"> <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"> <div className="relative w-full">
<button <button
type="button" type="button"
disabled={disabled} disabled={disabled}
onClick={() => setIsOpen(!isOpen)}
onClick={openSheet}
className={[ 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", "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 isOpen
@ -465,100 +475,14 @@ export function QuestionBirthplace({
/> />
</svg> </svg>
</button> </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> </div>
{/* City Text Input */} {/* City Text Input */}
<div className="w-full"> <div className="w-full">
<input <input
ref={cityInputRef}
type="text" type="text"
data-no-auto-focus
disabled={disabled} disabled={disabled}
value={cityInput} value={cityInput}
onChange={handleCityChange} onChange={handleCityChange}
@ -571,12 +495,12 @@ export function QuestionBirthplace({
</> </>
) : ( ) : (
<> <>
{/* 1. Country Selection Dropdown */}
{/* 1. Country Selection Trigger */}
<div className="relative w-full"> <div className="relative w-full">
<button <button
type="button" type="button"
disabled={disabled} disabled={disabled}
onClick={() => setIsOpen(!isOpen)}
onClick={openSheet}
className={[ 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", "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 isOpen
@ -611,100 +535,14 @@ export function QuestionBirthplace({
/> />
</svg> </svg>
</button> </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> </div>
{/* 2. City Text Input */} {/* 2. City Text Input */}
<div className="w-full"> <div className="w-full">
<input <input
ref={cityInputRef}
type="text" type="text"
data-no-auto-focus
disabled={disabled} disabled={disabled}
value={cityInput} value={cityInput}
onChange={handleCityChange} onChange={handleCityChange}
@ -714,6 +552,163 @@ export function QuestionBirthplace({
</div> </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> </div>
); );
} }

38
src/components/Componentes/question-card.tsx

@ -1,4 +1,5 @@
import Link from "next/link"; import Link from "next/link";
import { useEffect, useRef } from "react";
import { IoInformation } from "react-icons/io5"; import { IoInformation } from "react-icons/io5";
import type { QuestionCardIcon, QuestionListItem } from "@/lib/schema-adapter"; import type { QuestionCardIcon, QuestionListItem } from "@/lib/schema-adapter";
import { localizePath } from "@/translations/config"; import { localizePath } from "@/translations/config";
@ -9,6 +10,8 @@ type QuestionCardProps = {
item: QuestionListItem; item: QuestionListItem;
progress?: number | null; progress?: number | null;
onInfoClick?: (item: QuestionListItem) => void; onInfoClick?: (item: QuestionListItem) => void;
onNearViewport?: (item: QuestionListItem) => void;
onPrefetch?: (item: QuestionListItem) => void;
}; };
const RADIUS = 8; const RADIUS = 8;
@ -29,6 +32,8 @@ export function QuestionCard({
item, item,
progress = item.progress, progress = item.progress,
onInfoClick, onInfoClick,
onNearViewport,
onPrefetch,
}: QuestionCardProps) { }: QuestionCardProps) {
const { dictionary: t, locale } = useI18n(); const { dictionary: t, locale } = useI18n();
const hasProgress = typeof progress === "number" && Number.isFinite(progress); const hasProgress = typeof progress === "number" && Number.isFinite(progress);
@ -37,14 +42,41 @@ export function QuestionCard({
: 0; : 0;
const dashOffset = CIRCUMFERENCE - (normalizedProgress / 100) * CIRCUMFERENCE; const dashOffset = CIRCUMFERENCE - (normalizedProgress / 100) * CIRCUMFERENCE;
const iconName = iconNameMap[item.icon]; 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 ( return (
<Link <Link
href={localizePath(`/questions-list/${item.slug}`, locale)} href={localizePath(`/questions-list/${item.slug}`, locale)}
aria-label={t["Open {title}"].replace("{title}", item.title)} 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]" 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 <article
ref={cardRef}
id={item.slug} id={item.slug}
data-question-slug={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" 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="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="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"> <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>
</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"; "use client";
import { useEffect, useMemo, useState } from "react";
import { useMemo, useState } from "react";
import type { QuestionField } from "@/lib/schema-adapter"; import type { QuestionField } from "@/lib/schema-adapter";
import { useI18n } from "@/translations/provider"; import { useI18n } from "@/translations/provider";
import { useQuestionAnswers } from "./question-answer-storage"; import { useQuestionAnswers } from "./question-answer-storage";
import QuestionDateSheet from "./question-date-sheet";
import QuestionTitle from "./question-title"; import QuestionTitle from "./question-title";
type QuestionDateProps = { type QuestionDateProps = {
@ -11,80 +12,35 @@ type QuestionDateProps = {
disabled?: boolean; 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 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 { getAnswerValue, setAnswerValue } = useQuestionAnswers();
const value = getAnswerValue(question); const value = getAnswerValue(question);
const dateValue = typeof value === "string" ? value : ""; 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(() => { 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 ( if (
!y || !y ||
@ -108,37 +64,35 @@ export function QuestionDate({
} }
return age >= 0 ? age : null; return age >= 0 ? age : null;
}, [selectedYear, selectedMonth, selectedDay]);
}, [year, month, day]);
const isUnder18 = calculatedAge !== null && calculatedAge < MIN_AGE; 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 ( return (
<div <div
@ -149,82 +103,93 @@ export function QuestionDate({
> >
<QuestionTitle question={question} /> <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={[ 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(" ")} ].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> </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> </div>
); );
} }

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

@ -1,13 +1,16 @@
"use client"; "use client";
import { PhoneNumberFormat, PhoneNumberUtil } from "google-libphonenumber"; 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 { MarriagePhoneFieldValue } from "@/hooks/marriage/types";
import type { QuestionField } from "@/lib/schema-adapter";
import { useI18n } from "@/translations/provider";
import { useQuestionAnswers } from "./question-answer-storage"; import { useQuestionAnswers } from "./question-answer-storage";
import QuestionTitle from "./question-title"; 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 = { type QuestionPhoneProps = {
question: QuestionField; question: QuestionField;
@ -226,7 +229,7 @@ export function QuestionPhone({
countryCode = "+44", countryCode = "+44",
disabled, disabled,
}: QuestionPhoneProps) { }: QuestionPhoneProps) {
const { locale } = useI18n();
const { dictionary: t, locale } = useI18n();
const { getAnswerValue, setAnswerValue, isLoading } = useQuestionAnswers(); const { getAnswerValue, setAnswerValue, isLoading } = useQuestionAnswers();
const value = getAnswerValue(question); const value = getAnswerValue(question);
const defaultCodeValue = countryCode.trim() || "+44"; const defaultCodeValue = countryCode.trim() || "+44";
@ -263,29 +266,25 @@ export function QuestionPhone({
const hasFetchedIpRef = useRef(false); const hasFetchedIpRef = useRef(false);
const userInteractedRef = 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 [isOpen, setIsOpen] = useState(false);
const [isClosing, setIsClosing] = useState(false);
const [searchQuery, setSearchQuery] = useState(""); const [searchQuery, setSearchQuery] = useState("");
const containerRef = useRef<HTMLDivElement>(null);
const listRef = useRef<HTMLDivElement>(null); const listRef = useRef<HTMLDivElement>(null);
const searchInputRef = useRef<HTMLInputElement>(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 normalizedPhoneState = getNormalizedPhoneValue(codeValue, phoneValue);
const showInvalidState = const showInvalidState =
@ -333,7 +332,7 @@ export function QuestionPhone({
code: `+${callingCode}`, code: `+${callingCode}`,
flag, flag,
}); });
} catch (e) {}
} catch {}
} }
return list.sort((a, b) => a.name.localeCompare(b.name)); return list.sort((a, b) => a.name.localeCompare(b.name));
@ -348,6 +347,13 @@ export function QuestionPhone({
return match ? match.flag : "🏳️"; return match ? match.flag : "🏳️";
}, [codeValue, countryList]); }, [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 filteredCountries = useMemo(() => {
const q = searchQuery.toLowerCase().trim(); const q = searchQuery.toLowerCase().trim();
if (!q) return countryList; if (!q) return countryList;
@ -357,48 +363,17 @@ export function QuestionPhone({
); );
}, [countryList, searchQuery]); }, [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(() => { 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) // Sync state if external value changes (e.g. backend data loaded)
useEffect(() => { useEffect(() => {
@ -497,7 +472,7 @@ export function QuestionPhone({
.then((res) => res.json()) .then((res) => res.json())
.then((data) => { .then((data) => {
clearTimeout(timeoutId); clearTimeout(timeoutId);
if (data && data.country_calling_code) {
if (data?.country_calling_code) {
applyCode(data.country_calling_code); applyCode(data.country_calling_code);
} else { } else {
throw new Error("No calling code in response"); throw new Error("No calling code in response");
@ -515,7 +490,7 @@ export function QuestionPhone({
.then((res) => res.json()) .then((res) => res.json())
.then((data) => { .then((data) => {
clearTimeout(secondaryTimeoutId); clearTimeout(secondaryTimeoutId);
if (data && data.calling_code) {
if (data?.calling_code) {
applyCode(data.calling_code); applyCode(data.calling_code);
} else { } else {
applyFallback(); applyFallback();
@ -561,13 +536,13 @@ export function QuestionPhone({
setCodeValue(selectedCode); setCodeValue(selectedCode);
setPhoneValue(truncatedPhone); setPhoneValue(truncatedPhone);
updateStoredValue(selectedCode, truncatedPhone); updateStoredValue(selectedCode, truncatedPhone);
setIsOpen(false);
setSearchQuery("");
closeSheet();
}; };
const selectCountryTitle = t["Select country"] || question.title;
return ( return (
<div <div
ref={containerRef}
data-question-answered={isAnswered ? "true" : "false"} data-question-answered={isAnswered ? "true" : "false"}
data-question-type={question.type} data-question-type={question.type}
className={[ className={[
@ -589,12 +564,13 @@ export function QuestionPhone({
<button <button
type="button" type="button"
disabled={disabled} 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" 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>{activeFlag}</span>
<span>{codeValue || defaultCodeValue}</span> <span>{codeValue || defaultCodeValue}</span>
<svg <svg
aria-hidden="true"
width="10" width="10"
height="6" height="6"
viewBox="0 0 10 6" viewBox="0 0 10 6"
@ -613,41 +589,33 @@ export function QuestionPhone({
/> />
</svg> </svg>
</button> </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> </div>
<span className="flex min-w-0 flex-1 items-center pr-4"> <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> </div>
{showInvalidState ? ( {showInvalidState ? (
<span className="block group-10 font-semibold text-[#F2465F]"> <span className="block group-10 font-semibold text-[#F2465F]">
@ -655,90 +623,146 @@ export function QuestionPhone({
</span> </span>
) : null} ) : 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 <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> </div>
); );
} }

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

@ -171,7 +171,7 @@ export function QuestionProgressTracker({
onChange={updateProgress} onChange={updateProgress}
onInput={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 <div
aria-label={`Answered questions: ${answered} of ${safeTotal}`} aria-label={`Answered questions: ${answered} of ${safeTotal}`}
aria-valuemax={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 QuestionButton from "./question-button";
import QuestionCheckbox from "./question-checkbox"; import QuestionCheckbox from "./question-checkbox";
import QuestionDate from "./question-date"; import QuestionDate from "./question-date";
import QuestionDropdown from "./question-dropdown";
import QuestionSheet from "./question-sheet";
import QuestionFile from "./question-file"; import QuestionFile from "./question-file";
import QuestionNumber from "./question-number"; import QuestionNumber from "./question-number";
import QuestionPhone from "./question-phone"; import QuestionPhone from "./question-phone";
@ -62,7 +62,7 @@ export function QuestionRenderer({
); );
case "dropdown": case "dropdown":
return ( return (
<QuestionDropdown
<QuestionSheet
question={question} question={question}
disabled={disabled} 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"; } from "react";
import { useI18n } from "@/translations/provider"; import { useI18n } from "@/translations/provider";
import { useQuestionProgress } from "./question-progress-tracker"; import { useQuestionProgress } from "./question-progress-tracker";
import { useQuestionInputFocusSync } from "./use-sheet-scroll-lock";
const WHEEL_GESTURE_IDLE_MS = 320; const WHEEL_GESTURE_IDLE_MS = 320;
const TOUCH_MIN_DISTANCE = 8; 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 = [ 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(", "); ].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 = { type QuestionSnapListProps = {
children: ReactNode; children: ReactNode;
@ -41,15 +71,36 @@ export function QuestionSnapList({
}: QuestionSnapListProps) { }: QuestionSnapListProps) {
const { dictionary: t } = useI18n(); const { dictionary: t } = useI18n();
const { isCompleted } = useQuestionProgress(); const { isCompleted } = useQuestionProgress();
useQuestionInputFocusSync();
const questions = Children.toArray(children); const questions = Children.toArray(children);
const wheelLockedRef = useRef(false); const wheelLockedRef = useRef(false);
const wheelUnlockTimeoutRef = useRef<number | null>(null); const wheelUnlockTimeoutRef = useRef<number | null>(null);
const touchStartYRef = useRef<number | null>(null); const touchStartYRef = useRef<number | null>(null);
const questionRefs = useRef<Array<HTMLDivElement | null>>([]); const questionRefs = useRef<Array<HTMLDivElement | null>>([]);
const containerRef = useRef<HTMLElement | null>(null);
const previousActiveIndexRef = useRef<number | null>(null); const previousActiveIndexRef = useRef<number | null>(null);
const [activeIndex, setActiveIndex] = useState(0); 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( const stepQuestion = useCallback(
(direction: 1 | -1) => { (direction: 1 | -1) => {
@ -223,9 +274,97 @@ export function QuestionSnapList({
if (wheelUnlockTimeoutRef.current !== null) { if (wheelUnlockTimeoutRef.current !== null) {
window.clearTimeout(wheelUnlockTimeoutRef.current); 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( const handleWheel = useCallback(
(event: React.WheelEvent<HTMLElement>) => { (event: React.WheelEvent<HTMLElement>) => {
if (document.body.classList.contains("dropdown-open")) { if (document.body.classList.contains("dropdown-open")) {
@ -255,9 +394,152 @@ export function QuestionSnapList({
if (document.body.classList.contains("dropdown-open")) { if (document.body.classList.contains("dropdown-open")) {
return; 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( const handleTouchEnd = useCallback(
@ -266,9 +548,22 @@ export function QuestionSnapList({
return; 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 startY = touchStartYRef.current;
const endY = event.changedTouches[0]?.clientY; const endY = event.changedTouches[0]?.clientY;
touchStartYRef.current = null; touchStartYRef.current = null;
if (startY === null || endY === undefined || questions.length < 2) { if (startY === null || endY === undefined || questions.length < 2) {
@ -283,18 +578,12 @@ export function QuestionSnapList({
stepQuestion(distance > 0 ? 1 : -1); 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) { if (questions.length === 0) {
return null; return null;
@ -302,14 +591,16 @@ export function QuestionSnapList({
return ( return (
<section <section
ref={containerRef}
aria-label="Questions" aria-label="Questions"
className={[ 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", "flex-1 min-h-0 pt-4 pb-4",
className, className,
] ]
.filter(Boolean) .filter(Boolean)
.join(" ")} .join(" ")}
onTouchCancel={handleTouchCancel}
onTouchEnd={handleTouchEnd} onTouchEnd={handleTouchEnd}
onTouchMove={handleTouchMove} onTouchMove={handleTouchMove}
onTouchStart={handleTouchStart} onTouchStart={handleTouchStart}
@ -326,7 +617,7 @@ export function QuestionSnapList({
: String(question); : String(question);
let containerStyles = ""; let containerStyles = "";
let wrapperStyles = "w-full";
let wrapperStyles = "w-full my-auto shrink-0";
if (isActive) { if (isActive) {
containerStyles = containerStyles =
@ -336,19 +627,13 @@ export function QuestionSnapList({
: "w-full my-auto shrink-0"; : "w-full my-auto shrink-0";
} else if (isPrev) { } else if (isPrev) {
containerStyles = 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) { } else if (isNext) {
containerStyles = 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 { } else {
containerStyles = containerStyles =
"top-1/2 left-0 -translate-y-1/2 z-0 opacity-0 pointer-events-none scale-[0.95]"; "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 ( return (
@ -361,11 +646,13 @@ export function QuestionSnapList({
aria-hidden={isActive ? undefined : true} aria-hidden={isActive ? undefined : true}
inert={isActive ? undefined : true} inert={isActive ? undefined : true}
className={[ 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, containerStyles,
].join(" ")} ].join(" ")}
> >
<div className={wrapperStyles}>{question}</div>
<div className={`question-snap-content ${wrapperStyles}`}>
{question}
</div>
{isActive && ( {isActive && (
<div className="h-[20%] shrink min-h-0" aria-hidden="true" /> <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"; "use client";
import { useMemo } from "react";
import { IoAlert, IoCheckmark } from "react-icons/io5"; 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 { useI18n } from "@/translations/provider";
import type { QuestionListItem } from "@/lib/schema-adapter";
type RequiredStepsCardProps = { 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({ 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; const isCompleted = total > 0 && completed === total;
return ( return (
@ -94,16 +55,27 @@ export default function RequiredStepsCard({
<div <div
role="img" role="img"
aria-label={t["{completed} of {total} required steps completed"] aria-label={t["{completed} of {total} required steps completed"]
.replace("{completed}", String(completed))
.replace("{completed}", String(normalizedCompleted))
.replace("{total}", String(total))} .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]"> <span className="relative group-14 leading-none font-bold tracking-[-0.02em]">
{completed}/{total}
{normalizedCompleted}/{total}
</span> </span>
</div> </div>
</div> </div>

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

@ -1,24 +1,42 @@
import React from "react"; 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 { describe, it, expect, afterEach, beforeEach, vi } from "vitest";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; 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 { 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 { 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 { QuestionRadio } from "./question-radio";
import { QuestionCheckbox } from "./question-checkbox"; import { QuestionCheckbox } from "./question-checkbox";
import QuestionDetailClient from "@/app/questions-list/[slug]/question-detail-client"; import QuestionDetailClient from "@/app/questions-list/[slug]/question-detail-client";
vi.mock("@/hooks/marriage/use-form-schema", () => ({ vi.mock("@/hooks/marriage/use-form-schema", () => ({
useFormSchemaQuery: vi.fn(),
useFormOverviewQuery: vi.fn(),
useFormSectionQuery: vi.fn(),
})); }));
vi.mock("@/hooks/marriage/use-profile-main", () => ({ vi.mock("@/hooks/marriage/use-profile-main", () => ({
useMarriageProfileQuery: vi.fn(), useMarriageProfileQuery: vi.fn(),
})); }));
vi.mock("@/hooks/marriage/use-section-data", () => ({ vi.mock("@/hooks/marriage/use-section-data", () => ({
applyProfilePatchResultToCache: vi.fn(),
useMarriageSectionDataQuery: vi.fn(), useMarriageSectionDataQuery: vi.fn(),
useUpdateMarriageSectionDataMutation: vi.fn(), useUpdateMarriageSectionDataMutation: vi.fn(),
})); }));
@ -34,10 +52,9 @@ vi.mock("next/navigation", () => ({
const mockSchema = { const mockSchema = {
form_id: "profile", form_id: "profile",
version: 1,
answers: { 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: [ sections: [
{ {
@ -54,42 +71,77 @@ const mockSchema = {
order: 2, // Messed up order order: 2, // Messed up order
questions: [ 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,
id: "q_radio",
title: "Radio Q",
type: "radio",
order: 5,
required: true,
is_required: true,
is_visible: true,
ui_config: {}, ui_config: {},
options: [ 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,
id: "q_check",
title: "Check Q",
type: "checkbox",
order: 10,
required: false,
is_required: false,
is_visible: true,
ui_config: {}, ui_config: {},
options: [ options: [
{ id: "opt_c", value: "C", label: "Option C", order: 2 }, { 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", id: "card2",
title: "Card 2", title: "Card 2",
order: 1, // Card 2 should come first order: 1, // Card 2 should come first
questions: [ 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", () => { describe("Schema Question Flow Integration", () => {
@ -98,7 +150,15 @@ describe("Schema Question Flow Integration", () => {
beforeEach(() => { beforeEach(() => {
capturedPayload = null; capturedPayload = null;
const updateMutateAsync = vi.fn(async (payload) => { 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; return payload;
}); });
(useUpdateMarriageSectionDataMutation as any).mockReturnValue({ (useUpdateMarriageSectionDataMutation as any).mockReturnValue({
@ -113,18 +173,43 @@ describe("Schema Question Flow Integration", () => {
slug: "sec1", slug: "sec1",
data: [ data: [
{ key: "q_radio", type: "radio", value: "A", option_id: "opt_a" }, { 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, 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, isLoading: false,
isFetching: false, isFetching: false,
error: null, error: null,
refetch: vi.fn(), 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(() => { afterEach(() => {
@ -132,7 +217,9 @@ describe("Schema Question Flow Integration", () => {
}); });
it("should enforce ordering, correctly hydrate canonical values via options, and hide invisible children", async () => { 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) // Use convertSchemaToFrontendItems directly (no mock)
const frontendItems = convertSchemaToFrontendItems( const frontendItems = convertSchemaToFrontendItems(
@ -160,13 +247,15 @@ describe("Schema Question Flow Integration", () => {
questionsListHref="/list" questionsListHref="/list"
title="Title" title="Title"
/> />
</QueryClientProvider>
</QueryClientProvider>,
); );
// Wait for hydration and rendering // Wait for hydration and rendering
await waitFor(() => { await waitFor(() => {
// Radio hydration // 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); expect(radioInput.checked).toBe(true);
// Checkbox hydration // Checkbox hydration
@ -183,7 +272,9 @@ describe("Schema Question Flow Integration", () => {
}); });
// Interact to fire payload // 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); fireEvent.click(radioDummy);
// Uncheck Option B and Check Option C (Wait, they are both checked by default from hydration) // Uncheck Option B and Check Option C (Wait, they are both checked by default from hydration)
@ -195,10 +286,14 @@ describe("Schema Question Flow Integration", () => {
await waitFor(() => { await waitFor(() => {
expect(capturedPayload).not.toBeNull(); 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"); 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 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", () => { it("should redirect and not render static questions on schema failure", () => {
replaceMock.mockClear(); replaceMock.mockClear();
(useFormSchemaQuery as any).mockReturnValue({
(useFormOverviewQuery as any).mockReturnValue({
data: undefined, data: undefined,
isLoading: false, isLoading: false,
isFetching: false, isFetching: false,
error: new Error("Network Error"), error: new Error("Network Error"),
refetch: vi.fn(), 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( render(
<QueryClientProvider client={queryClient}> <QueryClientProvider client={queryClient}>
@ -227,7 +328,7 @@ describe("Schema Question Flow Integration", () => {
questionsListHref="/list" questionsListHref="/list"
title="Title" title="Title"
/> />
</QueryClientProvider>
</QueryClientProvider>,
); );
// Should redirect to questions list // Should redirect to questions list

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

@ -2,11 +2,6 @@
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { MdOutlineSwitchAccount } from "react-icons/md"; import { MdOutlineSwitchAccount } from "react-icons/md";
import {
IoClose,
IoTrashOutline,
IoKeyOutline,
} from "react-icons/io5";
import { getClientCookie, setClientCookie } from "@/lib/cookies"; import { getClientCookie, setClientCookie } from "@/lib/cookies";
import { setCachedMarriageEntryPath } from "@/lib/entry-route-cache"; import { setCachedMarriageEntryPath } from "@/lib/entry-route-cache";
@ -29,9 +24,11 @@ export function TokenSwitcher({
variant = "default", variant = "default",
className, className,
}: TokenSwitcherProps) { }: TokenSwitcherProps) {
const [mounted, setMounted] = useState(false);
const [isOpen, setIsOpen] = useState(false); const [isOpen, setIsOpen] = useState(false);
const [currentToken, setCurrentToken] = useState<string>(""); const [currentToken, setCurrentToken] = useState<string>("");
const [customTokenInput, setCustomTokenInput] = useState<string>(""); const [customTokenInput, setCustomTokenInput] = useState<string>("");
const [isCustomInputOpen, setIsCustomInputOpen] = useState(false);
useEffect(() => { useEffect(() => {
if (typeof window !== "undefined") { if (typeof window !== "undefined") {
@ -175,41 +172,42 @@ export function TokenSwitcher({
return ( 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 && ( {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 <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()} onClick={(e) => e.stopPropagation()}
> >
{/* Modal Header */} {/* Modal Header */}

158
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 { describe, it, expect, vi, afterEach } from "vitest";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { QuestionAnswersProvider } from "./question-answer-storage"; import { QuestionAnswersProvider } from "./question-answer-storage";
@ -15,32 +21,58 @@ describe("UI Config based behavior", () => {
cleanup(); 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", () => { it("should trigger GeoIP only when ui_config.enable_geoip is true", () => {
// With totally random title but ui_config.enable_geoip = 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( const { rerender } = render(
<QueryClientProvider client={queryClient}> <QueryClientProvider client={queryClient}>
<QuestionAnswersProvider slug="test" questions={[qWithGeo]}> <QuestionAnswersProvider slug="test" questions={[qWithGeo]}>
<QuestionBirthplace question={qWithGeo} /> <QuestionBirthplace question={qWithGeo} />
</QuestionAnswersProvider> </QuestionAnswersProvider>
</QueryClientProvider>
</QueryClientProvider>,
); );
// Auto button is present when GeoIP is active // Auto button is present when GeoIP is active
expect(screen.getByText("خودکار")).toBeDefined(); expect(screen.getByText("خودکار")).toBeDefined();
// With title "residence" but no ui_config // 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( rerender(
<QueryClientProvider client={queryClient}> <QueryClientProvider client={queryClient}>
<QuestionAnswersProvider slug="test" questions={[qWithoutGeo]}> <QuestionAnswersProvider slug="test" questions={[qWithoutGeo]}>
<QuestionBirthplace question={qWithoutGeo} /> <QuestionBirthplace question={qWithoutGeo} />
</QuestionAnswersProvider> </QuestionAnswersProvider>
</QueryClientProvider>
</QueryClientProvider>,
); );
expect(screen.queryByText("خودکار")).toBeNull(); 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", () => { it("should trigger currency behavior only when ui_config.currency_enabled is true", () => {
// Title is random, but 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( const { rerender } = render(
<QueryClientProvider client={queryClient}> <QueryClientProvider client={queryClient}>
<QuestionAnswersProvider slug="test" questions={[qWithCurrency]}> <QuestionAnswersProvider slug="test" questions={[qWithCurrency]}>
<QuestionNumber question={qWithCurrency} /> <QuestionNumber question={qWithCurrency} />
</QuestionAnswersProvider> </QuestionAnswersProvider>
</QueryClientProvider>
</QueryClientProvider>,
); );
// Dropdown arrow should be rendered in currency mode (path is in SVG) // Dropdown arrow should be rendered in currency mode (path is in SVG)
expect(screen.getByRole("img", { name: "Dropdown chevron" })).toBeDefined(); expect(screen.getByRole("img", { name: "Dropdown chevron" })).toBeDefined();
// Title is "Monthly Income", but currency_enabled is false // 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( rerender(
<QueryClientProvider client={queryClient}> <QueryClientProvider client={queryClient}>
<QuestionAnswersProvider slug="test" questions={[qWithoutCurrency]}> <QuestionAnswersProvider slug="test" questions={[qWithoutCurrency]}>
<QuestionNumber question={qWithoutCurrency} /> <QuestionNumber question={qWithoutCurrency} />
</QuestionAnswersProvider> </QuestionAnswersProvider>
</QueryClientProvider>
</QueryClientProvider>,
); );
expect(screen.queryByRole("img", { name: "Dropdown chevron" })).toBeNull(); 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 () => { it("should render error message from validation.errorMessage or fallback to locale", async () => {
// Title is random, type is email, custom error message // 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( const { rerender } = render(
<QueryClientProvider client={queryClient}> <QueryClientProvider client={queryClient}>
<QuestionAnswersProvider slug="test" questions={[qCustomError]}> <QuestionAnswersProvider slug="test" questions={[qCustomError]}>
<QuestionText question={qCustomError} /> <QuestionText question={qCustomError} />
</QuestionAnswersProvider> </QuestionAnswersProvider>
</QueryClientProvider>
</QueryClientProvider>,
); );
const input = screen.getByRole("textbox"); const input = screen.getByRole("textbox");
fireEvent.change(input, { target: { value: 'invalid_email' } });
fireEvent.change(input, { target: { value: "invalid_email" } });
await waitFor(() => { await waitFor(() => {
expect(screen.getByText("Custom Backend Error")).toBeDefined(); 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( rerender(
<QueryClientProvider client={queryClient}> <QueryClientProvider client={queryClient}>
<QuestionAnswersProvider slug="test" questions={[qFallbackError]}> <QuestionAnswersProvider slug="test" questions={[qFallbackError]}>
<QuestionText question={qFallbackError} /> <QuestionText question={qFallbackError} />
</QuestionAnswersProvider> </QuestionAnswersProvider>
</QueryClientProvider>
</QueryClientProvider>,
); );
const input2 = screen.getByRole("textbox"); const input2 = screen.getByRole("textbox");
fireEvent.change(input2, { target: { value: 'invalid_email_again' } });
fireEvent.change(input2, { target: { value: "invalid_email_again" } });
await waitFor(() => { await waitFor(() => {
expect(screen.getByText("یک آدرس ایمیل معتبر وارد کنید.")).toBeDefined(); 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", "contact-info",
] as const, ] as const,
profile: () => [...marriageQueryKeys.all, "profile"] 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) => sectionData: (slug: string) =>
[...marriageQueryKeys.sections(), slug, "data"] as const, [...marriageQueryKeys.sections(), slug, "data"] as const,
sections: () => [...marriageQueryKeys.all, "sections"] as const, sections: () => [...marriageQueryKeys.all, "sections"] as const,

28
src/hooks/marriage/types.ts

@ -151,12 +151,38 @@ export type MarriageSectionData = {
}; };
export type UpdateMarriageSectionDataPayload = { export type UpdateMarriageSectionDataPayload = {
version?: number;
current_step: number; current_step: number;
total_steps?: number; total_steps?: number;
fields: MarriageField[]; 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 = { export type StartMarriageMatchResponse = {
detail: string; detail: string;
}; };

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

@ -45,6 +45,8 @@ export interface FormSection {
order: number; order: number;
estimated_minutes: number; estimated_minutes: number;
cards: FormCard[]; cards: FormCard[];
kind?: "profile" | "assessment";
assessment_type?: "cattell" | "glasser";
} }
export interface FormProgressInfo { export interface FormProgressInfo {
@ -55,7 +57,6 @@ export interface FormProgressInfo {
export interface FormSchemaResponse { export interface FormSchemaResponse {
form_id: string; form_id: string;
version: number;
sections: FormSection[]; sections: FormSection[];
answers: Record<string, { value: any; option_id?: string | string[] }>; answers: Record<string, { value: any; option_id?: string | string[] }>;
progress: { progress: {
@ -67,9 +68,77 @@ export interface FormSchemaResponse {
is_completed?: boolean; 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>( const { data } = await http.get<FormSchemaResponse>(
`/api/marriage/forms/${formId}/?lang=${locale}`
`/api/marriage/forms/${formId}/?lang=${locale}`,
); );
return data; return data;
} }
@ -77,7 +146,7 @@ export async function getFormSchema(formId: string, locale: string): Promise<For
export function useFormSchemaQuery<TData = FormSchemaResponse>( export function useFormSchemaQuery<TData = FormSchemaResponse>(
formId: string, formId: string,
locale: string, locale: string,
options?: QueryOptions<FormSchemaResponse, TData>
options?: QueryOptions<FormSchemaResponse, TData>,
) { ) {
return useQuery({ return useQuery({
staleTime: 30 * 1000, staleTime: 30 * 1000,
@ -90,7 +159,6 @@ export function useFormSchemaQuery<TData = FormSchemaResponse>(
} }
export interface SaveAnswersPayload { export interface SaveAnswersPayload {
version: number;
answers: Array<{ answers: Array<{
question_id: string; question_id: string;
value: any; 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>( const { data } = await http.put<FormSchemaResponse>(
`/api/marriage/forms/${formId}/answers/`, `/api/marriage/forms/${formId}/answers/`,
payload
payload,
); );
return data; return data;
} }
export function useSaveFormAnswersMutation( export function useSaveFormAnswersMutation(
formId: string, formId: string,
options?: MutationOptions<FormSchemaResponse, SaveAnswersPayload>
options?: MutationOptions<FormSchemaResponse, SaveAnswersPayload>,
) { ) {
const queryClient = useQueryClient(); 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"; "use client";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import {
type QueryClient,
useMutation,
useQueryClient,
} from "@tanstack/react-query";
import { http } from "@/lib/http"; 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 { marriageQueryKeys } from "./query-keys";
import type { import type {
MarriageSectionData, MarriageSectionData,
ProfileAnswersPatchResult,
UpdateMarriageSectionDataPayload, UpdateMarriageSectionDataPayload,
} from "./types"; } 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( export async function updateMarriageSectionData(
slug: string, slug: string,
payload: UpdateMarriageSectionDataPayload, payload: UpdateMarriageSectionDataPayload,
) {
): Promise<ProfileAnswersPatchResult & { sectionData: MarriageSectionData }> {
const answersPayload = payload.fields.map((f) => ({ const answersPayload = payload.fields.map((f) => ({
question_id: f.key, question_id: f.key,
value: f.value, 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 { 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: { sectionData: {
slug, slug,
data: payload.fields, 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( export function useMarriageSectionDataQuery(
slug: string | null | undefined, slug: string | null | undefined,
locale: string, locale: string,
) { ) {
const normalizedSlug = slug ?? ""; 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( export function useUpdateMarriageSectionDataMutation(
slug: string, slug: string,
locale: string,
options?: MutationOptions< options?: MutationOptions<
Awaited<ReturnType<typeof updateMarriageSectionData>>, Awaited<ReturnType<typeof updateMarriageSectionData>>,
UpdateMarriageSectionDataPayload UpdateMarriageSectionDataPayload
@ -84,10 +224,7 @@ export function useUpdateMarriageSectionDataMutation(
...options, ...options,
mutationFn: (payload) => updateMarriageSectionData(slug, payload), mutationFn: (payload) => updateMarriageSectionData(slug, payload),
onSuccess: async (data, variables, onMutateResult, context) => { 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); 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: [],
}),
]);
});
});

64
src/lib/schema-adapter.ts

@ -2,6 +2,7 @@ import type {
FormSchemaResponse, FormSchemaResponse,
FormSection, FormSection,
FormQuestion, FormQuestion,
FormOverviewResponse,
} from "@/hooks/marriage/use-form-schema"; } from "@/hooks/marriage/use-form-schema";
import { defaultLocale, type Locale } from "@/translations/config"; import { defaultLocale, type Locale } from "@/translations/config";
@ -26,8 +27,6 @@ export type QuestionAudienceRule = {
minAge?: number; minAge?: number;
}; };
export type QuestionField = { export type QuestionField = {
id: string; id: string;
title: string; title: string;
@ -45,7 +44,12 @@ export type QuestionField = {
audience?: QuestionAudienceRule; audience?: QuestionAudienceRule;
requiredWhen?: QuestionAudienceRule; requiredWhen?: QuestionAudienceRule;
showGuardianNotice?: boolean; 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 = { export type QuestionListItem = {
@ -71,7 +75,10 @@ const iconMap: Record<string, QuestionCardIcon> = {
"layout-grid": "checklist", "layout-grid": "checklist",
}; };
export function mapBackendQuestionToFrontend(bq: FormQuestion, index: number): QuestionField {
export function mapBackendQuestionToFrontend(
bq: FormQuestion,
index: number,
): QuestionField {
return { return {
id: bq.id, id: bq.id,
title: bq.title || "Untitled", title: bq.title || "Untitled",
@ -92,20 +99,26 @@ export function mapBackendQuestionToFrontend(bq: FormQuestion, index: number): Q
noSearch: bq.ui_config?.noSearch, noSearch: bq.ui_config?.noSearch,
}, },
showGuardianNotice: bq.show_guardian_notice, 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( export function mapBackendSectionToFrontend(
section: FormSection, section: FormSection,
progress: number
progress: number,
): QuestionListItem { ): QuestionListItem {
const allQuestions: QuestionField[] = []; const allQuestions: QuestionField[] = [];
let index = 0; 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) => { 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) => { questions.forEach((q) => {
const fq = mapBackendQuestionToFrontend(q, index); const fq = mapBackendQuestionToFrontend(q, index);
allQuestions.push(fq); allQuestions.push(fq);
@ -116,7 +129,9 @@ export function mapBackendSectionToFrontend(
return { return {
slug: section.id, slug: section.id,
title: section.title, 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, progress: progress,
icon: iconMap[section.icon] ?? "details", icon: iconMap[section.icon] ?? "details",
required: section.is_required, required: section.is_required,
@ -130,7 +145,7 @@ export function mapBackendSectionToFrontend(
export function convertSchemaToFrontendItems( export function convertSchemaToFrontendItems(
schema: FormSchemaResponse | undefined, schema: FormSchemaResponse | undefined,
locale: Locale = defaultLocale
locale: Locale = defaultLocale,
): QuestionListItem[] { ): QuestionListItem[] {
if (!schema) return []; if (!schema) return [];
@ -140,5 +155,32 @@ export function convertSchemaToFrontendItems(
return mapBackendSectionToFrontend(sec, progress); 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: [],
}));
} }

6
src/translations/locales/ar.json

@ -754,5 +754,9 @@
"Please select the reason for cancellation:": "Please select the reason for cancellation:", "Please select the reason for cancellation:": "Please select the reason for cancellation:",
"Swipe to continue": "Swipe to continue", "Swipe to continue": "Swipe to continue",
"Swipe to confirm cancellation": "Swipe to confirm cancellation", "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": "السنة"
} }

6
src/translations/locales/az.json

@ -754,5 +754,9 @@
"Please select the reason for cancellation:": "Please select the reason for cancellation:", "Please select the reason for cancellation:": "Please select the reason for cancellation:",
"Swipe to continue": "Swipe to continue", "Swipe to continue": "Swipe to continue",
"Swipe to confirm cancellation": "Swipe to confirm cancellation", "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"
} }

6
src/translations/locales/bn.json

@ -754,5 +754,9 @@
"Please select the reason for cancellation:": "Please select the reason for cancellation:", "Please select the reason for cancellation:": "Please select the reason for cancellation:",
"Swipe to continue": "Swipe to continue", "Swipe to continue": "Swipe to continue",
"Swipe to confirm cancellation": "Swipe to confirm cancellation", "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": "বছর"
} }

6
src/translations/locales/da.json

@ -754,5 +754,9 @@
"Please select the reason for cancellation:": "Please select the reason for cancellation:", "Please select the reason for cancellation:": "Please select the reason for cancellation:",
"Swipe to continue": "Swipe to continue", "Swipe to continue": "Swipe to continue",
"Swipe to confirm cancellation": "Swipe to confirm cancellation", "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"
} }

6
src/translations/locales/de.json

@ -754,5 +754,9 @@
"Please select the reason for cancellation:": "Please select the reason for cancellation:", "Please select the reason for cancellation:": "Please select the reason for cancellation:",
"Swipe to continue": "Swipe to continue", "Swipe to continue": "Swipe to continue",
"Swipe to confirm cancellation": "Swipe to confirm cancellation", "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"
} }

6
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.", "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", "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.", "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"
} }

6
src/translations/locales/es.json

@ -754,5 +754,9 @@
"Please select the reason for cancellation:": "Please select the reason for cancellation:", "Please select the reason for cancellation:": "Please select the reason for cancellation:",
"Swipe to continue": "Swipe to continue", "Swipe to continue": "Swipe to continue",
"Swipe to confirm cancellation": "Swipe to confirm cancellation", "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"
} }

6
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.": "به افراد کمک می‌کند تا نیازهای خود و همچنین انگیزه‌ها، ترجیحات و اولویت‌های عاطفی شریک زندگی خود را بهتر درک کنند.", "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.": "به‌روزرسانی اطلاعات اولیه پروفایل ناموفق بود. لطفاً دوباره تلاش کنید.",
"Select date": "انتخاب تاریخ",
"Day": "روز",
"Month": "ماه",
"Year": "سال"
} }

6
src/translations/locales/fr.json

@ -754,5 +754,9 @@
"Please select the reason for cancellation:": "Please select the reason for cancellation:", "Please select the reason for cancellation:": "Please select the reason for cancellation:",
"Swipe to continue": "Swipe to continue", "Swipe to continue": "Swipe to continue",
"Swipe to confirm cancellation": "Swipe to confirm cancellation", "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"
} }

6
src/translations/locales/gu.json

@ -754,5 +754,9 @@
"Please select the reason for cancellation:": "Please select the reason for cancellation:", "Please select the reason for cancellation:": "Please select the reason for cancellation:",
"Swipe to continue": "Swipe to continue", "Swipe to continue": "Swipe to continue",
"Swipe to confirm cancellation": "Swipe to confirm cancellation", "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": "વર્ષ"
} }

6
src/translations/locales/ha.json

@ -754,5 +754,9 @@
"Please select the reason for cancellation:": "Please select the reason for cancellation:", "Please select the reason for cancellation:": "Please select the reason for cancellation:",
"Swipe to continue": "Swipe to continue", "Swipe to continue": "Swipe to continue",
"Swipe to confirm cancellation": "Swipe to confirm cancellation", "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"
} }

8
src/translations/locales/he.json

@ -280,5 +280,11 @@
"Please select the reason for cancellation:": "Please select the reason for cancellation:", "Please select the reason for cancellation:": "Please select the reason for cancellation:",
"Swipe to continue": "Swipe to continue", "Swipe to continue": "Swipe to continue",
"Swipe to confirm cancellation": "Swipe to confirm cancellation", "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": "אישור"
} }

6
src/translations/locales/hi.json

@ -754,5 +754,9 @@
"Please select the reason for cancellation:": "Please select the reason for cancellation:", "Please select the reason for cancellation:": "Please select the reason for cancellation:",
"Swipe to continue": "Swipe to continue", "Swipe to continue": "Swipe to continue",
"Swipe to confirm cancellation": "Swipe to confirm cancellation", "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/id.json

@ -280,5 +280,11 @@
"Please select the reason for cancellation:": "Please select the reason for cancellation:", "Please select the reason for cancellation:": "Please select the reason for cancellation:",
"Swipe to continue": "Swipe to continue", "Swipe to continue": "Swipe to continue",
"Swipe to confirm cancellation": "Swipe to confirm cancellation", "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"
} }

8
src/translations/locales/ks.json

@ -280,5 +280,11 @@
"Please select the reason for cancellation:": "Please select the reason for cancellation:", "Please select the reason for cancellation:": "Please select the reason for cancellation:",
"Swipe to continue": "Swipe to continue", "Swipe to continue": "Swipe to continue",
"Swipe to confirm cancellation": "Swipe to confirm cancellation", "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/pt.json

@ -280,5 +280,11 @@
"Please select the reason for cancellation:": "Please select the reason for cancellation:", "Please select the reason for cancellation:": "Please select the reason for cancellation:",
"Swipe to continue": "Swipe to continue", "Swipe to continue": "Swipe to continue",
"Swipe to confirm cancellation": "Swipe to confirm cancellation", "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"
} }

6
src/translations/locales/ru.json

@ -759,5 +759,9 @@
"{days} days remaining of your subscription.": "{days} дней до конца вашей подписки.", "{days} days remaining of your subscription.": "{days} дней до конца вашей подписки.",
"⚠️ This section is completely confidential and is only used for matching and review by advisors.": "⚠️ Этот раздел полностью конфиденциальен и используется только консультантами для сопоставления и проверки.", "⚠️ This section is completely confidential and is only used for matching and review by advisors.": "⚠️ Этот раздел полностью конфиденциальен и используется только консультантами для сопоставления и проверки.",
"Please select the reason for cancellation:": "Пожалуйста, выберите причину отмены:", "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": "Год"
} }

8
src/translations/locales/sw.json

@ -280,5 +280,11 @@
"Please select the reason for cancellation:": "Please select the reason for cancellation:", "Please select the reason for cancellation:": "Please select the reason for cancellation:",
"Swipe to continue": "Swipe to continue", "Swipe to continue": "Swipe to continue",
"Swipe to confirm cancellation": "Swipe to confirm cancellation", "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"
} }

8
src/translations/locales/tg.json

@ -280,5 +280,11 @@
"Please select the reason for cancellation:": "Please select the reason for cancellation:", "Please select the reason for cancellation:": "Please select the reason for cancellation:",
"Swipe to continue": "Swipe to continue", "Swipe to continue": "Swipe to continue",
"Swipe to confirm cancellation": "Swipe to confirm cancellation", "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/tr.json

@ -280,5 +280,11 @@
"Please select the reason for cancellation:": "Please select the reason for cancellation:", "Please select the reason for cancellation:": "Please select the reason for cancellation:",
"Swipe to continue": "Swipe to continue", "Swipe to continue": "Swipe to continue",
"Swipe to confirm cancellation": "Swipe to confirm cancellation", "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"
} }

8
src/translations/locales/ul.json

@ -280,5 +280,11 @@
"Please select the reason for cancellation:": "Please select the reason for cancellation:", "Please select the reason for cancellation:": "Please select the reason for cancellation:",
"Swipe to continue": "Swipe to continue", "Swipe to continue": "Swipe to continue",
"Swipe to confirm cancellation": "Swipe to confirm cancellation", "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/ur.json

@ -280,5 +280,11 @@
"Please select the reason for cancellation:": "Please select the reason for cancellation:", "Please select the reason for cancellation:": "Please select the reason for cancellation:",
"Swipe to continue": "Swipe to continue", "Swipe to continue": "Swipe to continue",
"Swipe to confirm cancellation": "Swipe to confirm cancellation", "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/uz.json

@ -280,5 +280,11 @@
"Please select the reason for cancellation:": "Please select the reason for cancellation:", "Please select the reason for cancellation:": "Please select the reason for cancellation:",
"Swipe to continue": "Swipe to continue", "Swipe to continue": "Swipe to continue",
"Swipe to confirm cancellation": "Swipe to confirm cancellation", "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"
} }

6
src/translations/locales/zh.json

@ -754,5 +754,9 @@
"Please select the reason for cancellation:": "Please select the reason for cancellation:", "Please select the reason for cancellation:": "Please select the reason for cancellation:",
"Swipe to continue": "Swipe to continue", "Swipe to continue": "Swipe to continue",
"Swipe to confirm cancellation": "Swipe to confirm cancellation", "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