Browse Source

feat: implement sheet scroll locking and keyboard-aware input focus management for mobile responsiveness

Dev
mortezaei 1 week ago
parent
commit
9ee426ddc7
  1. 36
      .zcode/plans/plan-sess_b396caff-afc5-4bc2-93b4-abe26fb2b685.md
  2. 42
      src/app/globals.css
  3. 92
      src/app/questions-list/[slug]/question-detail-client.test.tsx
  4. 64
      src/app/questions-list/[slug]/question-detail-client.tsx
  5. 113
      src/app/questions-list/page.tsx
  6. 53
      src/app/questions-list/section-prefetch.test.ts
  7. 24
      src/app/questions-list/section-prefetch.ts
  8. 23
      src/components/Componentes/question-answer-storage.tsx
  9. 391
      src/components/Componentes/question-birthplace.tsx
  10. 348
      src/components/Componentes/question-date-sheet.tsx
  11. 60
      src/components/Componentes/question-date.test.tsx
  12. 247
      src/components/Componentes/question-date.tsx
  13. 214
      src/components/Componentes/question-phone.tsx
  14. 4
      src/components/Componentes/question-renderer.tsx
  15. 182
      src/components/Componentes/question-sheet.test.tsx
  16. 416
      src/components/Componentes/question-sheet.tsx
  17. 345
      src/components/Componentes/question-snap-list.tsx
  18. 4
      src/components/Componentes/schema-question-flow.integration.test.tsx
  19. 158
      src/components/Componentes/ui-config.test.tsx
  20. 125
      src/components/Componentes/use-sheet-scroll-lock.ts
  21. 2
      src/hooks/marriage/types.ts
  22. 4
      src/hooks/marriage/use-form-schema.ts
  23. 1
      src/hooks/marriage/use-section-data.test.ts
  24. 29
      src/hooks/marriage/use-section-data.ts
  25. 1
      src/lib/schema-adapter-overview.test.ts
  26. 6
      src/translations/locales/ar.json
  27. 6
      src/translations/locales/az.json
  28. 6
      src/translations/locales/bn.json
  29. 6
      src/translations/locales/da.json
  30. 6
      src/translations/locales/de.json
  31. 6
      src/translations/locales/en.json
  32. 6
      src/translations/locales/es.json
  33. 6
      src/translations/locales/fa.json
  34. 6
      src/translations/locales/fr.json
  35. 6
      src/translations/locales/gu.json
  36. 6
      src/translations/locales/ha.json
  37. 8
      src/translations/locales/he.json
  38. 6
      src/translations/locales/hi.json
  39. 8
      src/translations/locales/id.json
  40. 8
      src/translations/locales/ks.json
  41. 8
      src/translations/locales/pt.json
  42. 6
      src/translations/locales/ru.json
  43. 8
      src/translations/locales/sw.json
  44. 8
      src/translations/locales/tg.json
  45. 8
      src/translations/locales/tr.json
  46. 8
      src/translations/locales/ul.json
  47. 8
      src/translations/locales/ur.json
  48. 8
      src/translations/locales/uz.json
  49. 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 متنی (حرکت به بالا با کیبورد).

42
src/app/globals.css

@ -229,6 +229,48 @@ body[data-page-background="custom"] .app-shell {
background-image: var(--page-background-image);
}
body.dropdown-open .app-shell {
overflow-y: hidden;
}
.question-detail-header {
max-height: 120px;
overflow: hidden;
transition:
transform 300ms ease-in-out,
opacity 220ms ease-out,
max-height 300ms ease-in-out,
margin 300ms ease-in-out;
}
body.question-input-open .app-shell .question-detail-header {
max-height: 0;
margin-top: -80px;
transform: translateY(-150px);
opacity: 0;
pointer-events: none;
}
body.question-input-open .app-shell .question-snap-list {
padding-top: 0;
padding-bottom: 0;
}
body.question-input-open
.app-shell
.question-snap-item[aria-current="step"] {
top: 0;
bottom: 0;
}
body.question-input-open
.app-shell
.question-snap-item[aria-current="step"]
.question-snap-content {
margin-top: 0;
margin-bottom: 0;
}
.page-background-none,
.page-background-custom,
.page-background-default {

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

@ -76,7 +76,7 @@ describe("QuestionDetailClient Validation", () => {
});
(useFormOverviewQuery as any).mockReturnValue({
data: { version: 1 },
data: {},
isLoading: false,
});
(useFormSectionQuery as any).mockReturnValue({
@ -292,7 +292,6 @@ describe("QuestionDetailClient Validation", () => {
]);
(useFormSectionQuery as any).mockReturnValue({
data: {
version: 1,
section: { cards: [] },
answers: {},
section_progress: { completion_percent: 0 },
@ -322,4 +321,93 @@ describe("QuestionDetailClient Validation", () => {
// Profile questions render directly, no start button
expect(screen.getByText(/Dynamic ID/)).toBeDefined();
});
it("renders a stable profile loading shell while section data is cold", () => {
(convertOverviewToFrontendItems as any).mockReturnValue([
{
slug: "profile_test",
title: "Overview Profile Title",
questions: [],
},
]);
(useFormSectionQuery as any).mockReturnValue({
data: undefined,
isLoading: true,
});
(useCattellQuestionsQuery as any).mockReturnValue({
data: undefined,
isLoading: false,
});
(useGlasserQuestionsQuery as any).mockReturnValue({
data: undefined,
isLoading: false,
});
const queryClient = new QueryClient({
defaultOptions: { queries: { retry: false } },
});
render(
<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("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();
});
});

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

@ -2,6 +2,8 @@
import { useRouter } from "next/navigation";
import { useEffect, useMemo, useState } from "react";
import Button from "@/components/Componentes/button";
import { FixToTheEnd } from "@/components/Componentes/fix-to-the-end";
import { PageLoadingSkeleton } from "@/components/Componentes/page-loading-skeleton";
import NavigationButton from "@/components/Componentes/navigation-button";
import { PageBackground } from "@/components/Componentes/page-background";
@ -334,11 +336,56 @@ export default function QuestionDetailClient({
}, [isSchemaLoading, item, questionsListHref, router]);
if (isSchemaLoading) {
if (!isAssessment) {
const loadingTitle = overviewItem?.title || title;
return (
<PageLoadingSkeleton
compact
variant={isCattellSlug || isGlasserSlug ? "test" : "questions"}
<>
<PageBackground disabled />
<main className="-mx-[17px] flex h-svh flex-col overflow-hidden bg-[#F7F1F0]">
<StickyHeader
sticky={false}
className="question-detail-header shrink-0"
>
<div className="flex items-center gap-4">
<NavigationButton
className="shrink-0"
variant="transparent"
icon="close"
iconLabel={closeLabel}
onClick={() => router.push(questionsListHref)}
/>
<h1 className="min-w-0 flex-1 truncate text-center text-[14px] font-semibold text-white">
{loadingTitle}
</h1>
<NavigationButton
className="shrink-0"
variant="transparent"
icon="info"
iconLabel={informationLabel}
helpTitle={loadingTitle}
helpDescription={description}
/>
</div>
</StickyHeader>
<div className="flex min-h-0 flex-1 items-center justify-center pb-[76px]">
<span
role="status"
className="size-5 animate-spin rounded-full border-2 border-[#E03950]/25 border-t-[#E03950] motion-reduce:animate-none"
/>
</div>
<FixToTheEnd className="bg-[#F7F1F0]/95">
<Button disabled>{continueLabel}</Button>
</FixToTheEnd>
</main>
</>
);
}
return (
<PageLoadingSkeleton compact variant="test" />
);
} else if (!item) {
return null;
@ -480,7 +527,10 @@ export default function QuestionDetailClient({
<>
<PageBackground disabled />
<main className="-mx-[17px] flex h-svh flex-col overflow-hidden bg-[#F7F1F0]">
<StickyHeader sticky={false} className="shrink-0">
<StickyHeader
sticky={false}
className="question-detail-header shrink-0"
>
<div className="flex items-center gap-4">
<NavigationButton
className="shrink-0"
@ -648,10 +698,12 @@ export default function QuestionDetailClient({
slug={item.slug}
questions={visibleQuestions}
locale={locale}
schemaVersion={sectionResponse?.version ?? overview?.version ?? 1}
>
<main className="-mx-[17px] flex h-svh flex-col overflow-hidden bg-[#F7F1F0]">
<StickyHeader sticky={false} className="shrink-0">
<StickyHeader
sticky={false}
className="question-detail-header shrink-0"
>
<div className="flex items-center gap-4">
<QuestionExitNavigationButton
className="shrink-0"

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

@ -15,6 +15,7 @@ import { getQuestionAnswersStorageKey } from "@/components/Componentes/question-
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,
@ -40,6 +41,7 @@ import type { QuestionListItem } from "@/lib/schema-adapter";
import { convertOverviewToFrontendItems } from "@/lib/schema-adapter";
import { localizePath } from "@/translations/config";
import { useI18n } from "@/translations/provider";
import { prefetchSectionsWithBoundedConcurrency } from "./section-prefetch";
import SectionsRequest from "./sections-request";
export default function QuestionsListPage() {
@ -215,8 +217,15 @@ export default function QuestionsListPage() {
const [isSyncing, setIsSyncing] = useState(false);
const [toastMessage, setToastMessage] = useState<string | null>(null);
const syncPromiseRef = useRef<Promise<void> | null>(null);
const syncPendingAnswers = useCallback(async () => {
if (!overview) return;
if (syncPromiseRef.current) {
return syncPromiseRef.current;
}
const task = (async () => {
const pendingSections: Array<{
storageKey: string;
storedValue: {
@ -239,9 +248,16 @@ export default function QuestionsListPage() {
const rawValue = window.localStorage.getItem(storageKey);
if (!rawValue) continue;
const storedValue = JSON.parse(rawValue);
if (!storedValue.pending_sync || !Array.isArray(storedValue.fields))
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
@ -261,7 +277,6 @@ export default function QuestionsListPage() {
if (pendingSections.length === 0) return;
const result = await updateMarriageSectionData(pendingSections[0].slug, {
version: overview.version,
current_step: pendingSections[0].storedValue.current_step,
fields: pendingSections.flatMap((section) => section.fields),
});
@ -280,6 +295,15 @@ export default function QuestionsListPage() {
window.localStorage.setItem(storageKey, JSON.stringify(storedValue));
}
}
})();
syncPromiseRef.current = task;
try {
await task;
setIsSyncError(false);
} finally {
syncPromiseRef.current = null;
}
}, [locale, overview, queryClient, questionListItems]);
const prefetchSection = useCallback(
@ -292,7 +316,7 @@ export default function QuestionsListPage() {
)
return;
void queryClient.prefetchQuery({
queryKey: ["marriage", "form-section", "profile", item.slug, locale],
queryKey: marriageQueryKeys.formSection("profile", item.slug, locale),
queryFn: () => getFormSection("profile", item.slug, locale),
staleTime: 30 * 1000,
});
@ -316,13 +340,11 @@ export default function QuestionsListPage() {
.catch(() => undefined)
.then(() =>
queryClient.fetchQuery({
queryKey: [
"marriage",
"form-section",
queryKey: marriageQueryKeys.formSection(
"profile",
item.slug,
locale,
],
),
queryFn: () => getFormSection("profile", item.slug, locale),
staleTime: 30 * 1000,
}),
@ -335,66 +357,62 @@ export default function QuestionsListPage() {
const prefetchQueueStarted = useRef(false);
useEffect(() => {
if (prefetchQueueStarted.current || !overview) return;
const likelySections = questionListItems
const profileSections = questionListItems
.filter(
(item) =>
item.required &&
(sectionProgressBySlug.get(item.slug) ?? item.progress) < 100,
item.slug !== "personality_test" &&
item.slug !== "glasser_5_needs_test",
)
.slice(0, 2);
if (likelySections.length === 0) return;
.sort((first, second) => {
const firstPriority =
first.required &&
(sectionProgressBySlug.get(first.slug) ?? first.progress) < 100
? 0
: 1;
const secondPriority =
second.required &&
(sectionProgressBySlug.get(second.slug) ?? second.progress) < 100
? 0
: 1;
return firstPriority - secondPriority;
});
if (profileSections.length === 0) return;
prefetchQueueStarted.current = true;
let cancelled = false;
const runQueue = async () => {
for (const item of likelySections) {
if (cancelled) return;
try {
await queryClient.fetchQuery({
queryKey: [
"marriage",
"form-section",
void prefetchSectionsWithBoundedConcurrency(
profileSections,
(item) =>
queryClient.fetchQuery({
queryKey: marriageQueryKeys.formSection(
"profile",
item.slug,
locale,
],
),
queryFn: () => getFormSection("profile", item.slug, locale),
staleTime: 30 * 1000,
});
} catch {
// A later interaction or navigation can retry without blocking the list.
}
}
};
const schedule = () => void runQueue();
const idleWindow = window as Window & {
requestIdleCallback?: (
callback: () => void,
options?: { timeout: number },
) => number;
cancelIdleCallback?: (handle: number) => void;
};
const isIdleScheduled = Boolean(idleWindow.requestIdleCallback);
const handle = idleWindow.requestIdleCallback
? idleWindow.requestIdleCallback(schedule, { timeout: 750 })
: globalThis.setTimeout(schedule, 150);
}),
() => cancelled,
);
return () => {
cancelled = true;
if (isIdleScheduled) idleWindow.cancelIdleCallback?.(handle as number);
else globalThis.clearTimeout(handle);
};
}, [locale, overview, queryClient, questionListItems, sectionProgressBySlug]);
useEffect(() => {
void syncPendingAnswers().catch(() => setIsSyncError(true));
void syncPendingAnswers().catch((err) => {
console.warn("Background draft sync:", err);
});
const handleOnline = () => {
void syncPendingAnswers().catch(() => setIsSyncError(true));
void syncPendingAnswers().catch((err) => {
console.warn("Background draft sync on online:", err);
});
};
window.addEventListener("online", handleOnline);
return () => window.removeEventListener("online", handleOnline);
}, [syncPendingAnswers]);
useEffect(() => {
if (startMatchMutation.isError || isSyncError) {
if (startMatchMutation.isError) {
setToastMessage(
t[
"Sending the match request failed. Please check your connection and try again."
@ -403,10 +421,7 @@ export default function QuestionsListPage() {
}
}, [
startMatchMutation.isError,
isSyncError,
t[
"Sending the match request failed. Please check your connection and try again."
],
t,
]);
const handleCloseToast = () => {

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

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

@ -61,7 +61,6 @@ type QuestionAnswersProviderProps = {
questions: readonly QuestionField[];
slug: string;
locale?: string;
schemaVersion?: number;
};
const QuestionAnswersContext =
@ -303,7 +302,6 @@ export function QuestionAnswersProvider({
questions,
slug,
locale = "en",
schemaVersion = 1,
}: QuestionAnswersProviderProps) {
const storageKey = useMemo(() => getQuestionAnswersStorageKey(slug), [slug]);
const queryClient = useQueryClient();
@ -319,7 +317,6 @@ export function QuestionAnswersProvider({
const storageKeyRef = useRef(storageKey);
const slugRef = useRef(slug);
const backendFieldsRef = useRef<MarriageField[]>([]);
const schemaVersionRef = useRef(schemaVersion);
const dirtyKeysRef = useRef(new Set<string>());
const { data: profile } = useMarriageProfileQuery();
@ -334,16 +331,11 @@ export function QuestionAnswersProvider({
slugRef.current = slug;
questionsRef.current = questions;
backendFieldsRef.current = serverSectionData?.data || [];
schemaVersionRef.current = Math.max(
schemaVersionRef.current,
schemaVersion,
);
}, [
answers,
hasPendingSync,
slug,
questions,
schemaVersion,
serverSectionData?.data,
]);
@ -453,10 +445,16 @@ export function QuestionAnswersProvider({
question.type === "textarea" ||
question.type === "number";
const delay = isTextLike ? 1000 : 0;
// The date wheel applies its value on every scroll settle; debounce
// it like text inputs so each spin does not fire a PATCH.
const isDebounced = isTextLike || question.type === "date";
const delay = isDebounced ? 1000 : 0;
syncTimeoutRef.current = setTimeout(() => {
void flushAnswersRef.current();
void flushAnswersRef.current().catch(() => {
// The draft stays pending so the next edit or exit can retry.
});
}, delay);
return nextAnswers;
@ -491,7 +489,6 @@ export function QuestionAnswersProvider({
const payload = {
...fullPayload,
fields: pendingFields,
version: schemaVersionRef.current,
};
const revision = answersRevisionRef.current;
@ -500,9 +497,6 @@ export function QuestionAnswersProvider({
}
const request = mutateAsync(payload).then((result) => {
if (result?.version) {
schemaVersionRef.current = result.version;
}
const cleared = new Set(result?.cleared_answer_ids ?? []);
const nextAnswers = { ...answersRef.current };
const questionById = new Map(
@ -621,7 +615,6 @@ export function QuestionAnswersProvider({
fetch(getKeepalivePatchUrl(slugRef.current), {
body: JSON.stringify({
version: schemaVersionRef.current,
answers: answersPayload,
}),
credentials: "include",

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

@ -1,12 +1,16 @@
"use client";
import { useCallback, useEffect, useRef, useState } from "react";
import { createPortal } from "react-dom";
import { getCountryList, COUNTRIES_EN, COUNTRIES_FA } from "@/data/countries";
import type { QuestionField } from "@/lib/schema-adapter";
import { useI18n } from "@/translations/provider";
import { useQuestionAnswers } from "./question-answer-storage";
import QuestionTitle from "./question-title";
import { LoadingThreeDot } from "./loading-three-dot";
import { useSheetScrollLock } from "./use-sheet-scroll-lock";
const EXIT_ANIMATION_MS = 220;
type QuestionBirthplaceProps = {
question: QuestionField;
@ -88,10 +92,12 @@ export function QuestionBirthplace({
const [cityInput, setCityInput] = useState(initial.city);
const [isOpen, setIsOpen] = useState(false);
const [isClosing, setIsClosing] = useState(false);
const [searchQuery, setSearchQuery] = useState("");
const containerRef = useRef<HTMLDivElement>(null);
const listRef = useRef<HTMLDivElement>(null);
const searchInputRef = useRef<HTMLInputElement>(null);
const cityInputRef = useRef<HTMLInputElement>(null);
const listRef = useRef<HTMLDivElement>(null);
const isMountedRef = useRef(true);
const isResidence = question.ui_config?.enable_geoip === true;
@ -99,6 +105,57 @@ export function QuestionBirthplace({
const [isDetecting, setIsDetecting] = useState(false);
const [detectedLocation, setDetectedLocation] = useState("");
useSheetScrollLock(isOpen);
useEffect(() => {
isMountedRef.current = true;
return () => {
isMountedRef.current = false;
};
}, []);
const closeSheet = useCallback(() => {
if (isClosing) return;
setIsClosing(true);
window.setTimeout(() => {
if (isMountedRef.current) {
setIsOpen(false);
setIsClosing(false);
setSearchQuery("");
}
}, EXIT_ANIMATION_MS);
}, [isClosing]);
const openSheet = useCallback(() => {
if (disabled) return;
setIsOpen(true);
setIsClosing(false);
}, [disabled]);
// Handle escape key
useEffect(() => {
if (!isOpen) return;
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === "Escape") {
closeSheet();
}
};
window.addEventListener("keydown", handleKeyDown);
return () => window.removeEventListener("keydown", handleKeyDown);
}, [isOpen, closeSheet]);
// Auto focus search input when sheet opens
useEffect(() => {
if (isOpen && !isClosing) {
const timer = setTimeout(() => {
searchInputRef.current?.focus();
}, 100);
return () => clearTimeout(timer);
}
}, [isOpen, isClosing]);
const updateAnswers = (country: string, city: string) => {
const formatted =
city && country ? `${city}, ${country}` : city || country || null;
@ -199,9 +256,12 @@ export function QuestionBirthplace({
localStorage.setItem("hasCheckedGeoIPResidence", "true");
}
setMode("manual");
setSelectedCountry("");
setCityInput("");
updateAnswers("", "");
const parsed = parseValue(rawValue);
const country = selectedCountry || parsed.country;
const city = cityInput || parsed.city;
setSelectedCountry(country);
setCityInput(city);
updateAnswers(country, city);
};
// Synchronize state if rawValue changes externally
@ -218,62 +278,6 @@ export function QuestionBirthplace({
}
}, [rawValue]);
// Lock page scroll when dropdown is open
useEffect(() => {
if (isOpen) {
document.body.classList.add("dropdown-open");
document.body.style.overflow = "hidden";
document.documentElement.style.overflow = "hidden";
} else {
document.body.classList.remove("dropdown-open");
document.body.style.overflow = "";
document.documentElement.style.overflow = "";
}
return () => {
document.body.classList.remove("dropdown-open");
document.body.style.overflow = "";
document.documentElement.style.overflow = "";
};
}, [isOpen]);
useEffect(() => {
if (isOpen) {
const timer = setTimeout(() => {
searchInputRef.current?.focus();
}, 50);
return () => clearTimeout(timer);
}
}, [isOpen]);
const handleListWheel = useCallback((e: React.WheelEvent<HTMLDivElement>) => {
e.stopPropagation();
const el = listRef.current;
if (!el) return;
const atTop = el.scrollTop <= 0 && e.deltaY < 0;
const atBottom =
el.scrollTop + el.clientHeight >= el.scrollHeight && e.deltaY > 0;
if (atTop || atBottom) {
e.preventDefault();
}
}, []);
useEffect(() => {
function handleClickOutside(event: MouseEvent) {
if (
containerRef.current &&
!containerRef.current.contains(event.target as Node)
) {
setIsOpen(false);
}
}
document.addEventListener("mousedown", handleClickOutside);
return () => {
document.removeEventListener("mousedown", handleClickOutside);
};
}, []);
const options = getCountryList(locale);
const filteredOptions = options.filter((option) =>
option.toLowerCase().includes(searchQuery.toLowerCase()),
@ -284,9 +288,11 @@ export function QuestionBirthplace({
localStorage.setItem("hasCheckedGeoIPResidence", "true");
}
setSelectedCountry(country);
setCityInput("");
setIsOpen(false);
updateAnswers(country, "");
closeSheet();
updateAnswers(country, cityInput);
window.setTimeout(() => {
cityInputRef.current?.focus({ preventScroll: true });
}, EXIT_ANIMATION_MS);
};
const handleCityChange = (e: React.ChangeEvent<HTMLInputElement>) => {
@ -304,9 +310,24 @@ export function QuestionBirthplace({
question.extras?.placeHolder ||
(isRtl ? "شهر، منطقه یا محله" : "City, region, or neighborhood");
const searchPlaceholder =
locale === "fa"
? "جستجو..."
: locale === "ar"
? "بحث..."
: locale === "tr"
? "Ara..."
: "Search...";
const noResultsText =
locale === "fa"
? "موردی یافت نشد"
: locale === "ar"
? "لم يتم العثور على نتائج"
: "No options found";
return (
<div
ref={containerRef}
className={[
"relative flex w-full flex-col gap-3 transition-opacity duration-200",
disabled ? "pointer-events-none opacity-30" : "",
@ -425,12 +446,12 @@ export function QuestionBirthplace({
{mode === "manual" && (
<div className="flex flex-col gap-3 w-full animate-in fade-in slide-in-from-top-2 duration-200">
{/* Country Selection Dropdown */}
{/* Country Selection Trigger */}
<div className="relative w-full">
<button
type="button"
disabled={disabled}
onClick={() => setIsOpen(!isOpen)}
onClick={openSheet}
className={[
"flex h-[54px] w-full items-center justify-between rounded-[16px] border bg-white px-4.5 text-start transition-all cursor-pointer outline-none",
isOpen
@ -465,100 +486,14 @@ export function QuestionBirthplace({
/>
</svg>
</button>
{isOpen && (
<div className="absolute top-[calc(100%+8px)] left-0 z-50 flex w-full flex-col gap-3.5 rounded-[20px] bg-white p-4.5 shadow-[0_12px_36px_rgba(0,0,0,0.09)] border border-[#EAECF0] animate-in fade-in zoom-in-95 duration-150">
{options.length > 3 || searchQuery ? (
<div className="flex h-[46px] w-full items-center gap-2.5 rounded-[12px] bg-[#EFEFEF] px-3.5 transition-colors focus-within:bg-[#E8E8E8]">
<svg
width="18"
height="18"
viewBox="0 0 18 18"
fill="none"
className="shrink-0 text-[#667085]"
>
<path
d="M8.25 14.25C11.5637 14.25 14.25 11.5637 14.25 8.25C14.25 4.93629 11.5637 2.25 8.25 2.25C4.93629 2.25 2.25 4.93629 2.25 8.25C2.25 11.5637 4.93629 14.25 8.25 14.25Z"
stroke="#667085"
strokeWidth="1.75"
strokeLinecap="round"
strokeLinejoin="round"
/>
<path
d="M15.75 15.75L12.5 12.5"
stroke="#667085"
strokeWidth="1.75"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
<input
ref={searchInputRef}
type="text"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
placeholder="Search..."
className="flex-1 bg-transparent text-[14px] font-medium text-[#181818] outline-none placeholder:text-[#98A2B3]"
/>
{searchQuery ? (
<button
type="button"
onClick={() => setSearchQuery("")}
className="text-[#667085] hover:text-[#181818] text-xs font-semibold"
>
</button>
) : null}
</div>
) : null}
<div
ref={listRef}
onWheel={handleListWheel}
onTouchStart={(e) => e.stopPropagation()}
onTouchMove={(e) => e.stopPropagation()}
onTouchEnd={(e) => e.stopPropagation()}
className="flex max-h-[220px] flex-col gap-3.5 overflow-y-auto overscroll-contain pr-1"
>
{filteredOptions.length > 0 ? (
filteredOptions.map((option) => {
const isSelected = selectedCountry === option;
return (
<button
key={option}
type="button"
onClick={() => handleSelectCountry(option)}
className="flex w-full items-center gap-3 text-start cursor-pointer group/opt py-0.5"
>
<div
className={[
"size-[20px] shrink-0 rounded-full transition-all duration-150",
isSelected
? "bg-[#F2465F] shadow-xs"
: "border-[2px] border-[#344054] bg-transparent group-hover/opt:border-[#181818]",
].join(" ")}
/>
<span className="text-[15px] font-bold text-[#181818]">
{option}
</span>
</button>
);
})
) : (
<span className="py-2 text-[13px] text-[#667085]">
No options found
</span>
)}
</div>
</div>
)}
</div>
{/* City Text Input */}
<div className="w-full">
<input
ref={cityInputRef}
type="text"
data-no-auto-focus
disabled={disabled}
value={cityInput}
onChange={handleCityChange}
@ -571,12 +506,12 @@ export function QuestionBirthplace({
</>
) : (
<>
{/* 1. Country Selection Dropdown */}
{/* 1. Country Selection Trigger */}
<div className="relative w-full">
<button
type="button"
disabled={disabled}
onClick={() => setIsOpen(!isOpen)}
onClick={openSheet}
className={[
"flex h-[54px] w-full items-center justify-between rounded-[16px] border bg-white px-4.5 text-start transition-all cursor-pointer outline-none",
isOpen
@ -611,12 +546,89 @@ export function QuestionBirthplace({
/>
</svg>
</button>
</div>
{/* 2. City Text Input */}
<div className="w-full">
<input
ref={cityInputRef}
type="text"
data-no-auto-focus
disabled={disabled}
value={cityInput}
onChange={handleCityChange}
placeholder={cityPlaceholder}
className="h-[54px] w-full rounded-[16px] border border-[#D0D5DD] bg-white px-4.5 text-[15px] font-medium text-[#181818] placeholder:text-[#98A2B3] hover:border-[#98A2B3] focus:border-[#6F6F6F] focus:ring-1 focus:ring-[#6F6F6F] outline-none transition-all"
/>
</div>
</>
)}
{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]">
{/* 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"
@ -643,28 +655,25 @@ export function QuestionBirthplace({
type="text"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
placeholder="Search..."
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"
className="text-[#667085] hover:text-[#181818] text-xs font-semibold p-1 cursor-pointer"
>
</button>
) : null}
</div>
) : null}
</div>
{/* Country Options List */}
<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"
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) => {
@ -675,44 +684,42 @@ export function QuestionBirthplace({
key={option}
type="button"
onClick={() => handleSelectCountry(option)}
className="flex w-full items-center gap-3 text-start cursor-pointer group/opt py-0.5"
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-[20px] shrink-0 rounded-full transition-all duration-150",
"size-[22px] shrink-0 rounded-full transition-all duration-150 flex items-center justify-center",
isSelected
? "bg-[#F2465F] shadow-xs"
: "border-[2px] border-[#344054] bg-transparent group-hover/opt:border-[#181818]",
? "border-[6px] border-[#F0445B] bg-white"
: "border-[2px] border-[#98A2B3] bg-white",
].join(" ")}
/>
<span className="text-[15px] font-bold text-[#181818]">
<span
className={
isSelected
? "font-bold text-[#181818] text-[15px]"
: "font-semibold text-[#344054] text-[15px]"
}
>
{option}
</span>
</button>
);
})
) : (
<span className="py-2 text-[13px] text-[#667085]">
No options found
</span>
)}
</div>
<div className="py-8 text-center text-[14px] text-[#667085]">
{noResultsText}
</div>
)}
</div>
{/* 2. City Text Input */}
<div className="w-full">
<input
type="text"
disabled={disabled}
value={cityInput}
onChange={handleCityChange}
placeholder={cityPlaceholder}
className="h-[54px] w-full rounded-[16px] border border-[#D0D5DD] bg-white px-4.5 text-[15px] font-medium text-[#181818] placeholder:text-[#98A2B3] hover:border-[#98A2B3] focus:border-[#6F6F6F] focus:ring-1 focus:ring-[#6F6F6F] outline-none transition-all"
/>
</div>
</>
</section>
</div>,
document.body,
)}
</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(/[,٬]/);
});
});

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

@ -1,9 +1,10 @@
"use client";
import { useEffect, useMemo, useState } from "react";
import { useMemo, useState } from "react";
import type { QuestionField } from "@/lib/schema-adapter";
import { useI18n } from "@/translations/provider";
import { useQuestionAnswers } from "./question-answer-storage";
import QuestionDateSheet from "./question-date-sheet";
import QuestionTitle from "./question-title";
type QuestionDateProps = {
@ -11,80 +12,35 @@ type QuestionDateProps = {
disabled?: boolean;
};
const MONTH_VALUES = [
"01",
"02",
"03",
"04",
"05",
"06",
"07",
"08",
"09",
"10",
"11",
"12",
];
const DAYS = Array.from({ length: 31 }, (_, i) => {
const num = i + 1;
const val = num < 10 ? `0${num}` : `${num}`;
return { value: val, label: `${num}` };
});
const MIN_AGE = 18;
const currentYear = new Date().getFullYear();
const maxBirthYear = currentYear - MIN_AGE;
const YEARS = Array.from({ length: 80 }, (_, i) =>
(maxBirthYear - i).toString(),
);
export function QuestionDate({
question,
disabled,
}: QuestionDateProps) {
const { locale } = useI18n();
function parseDateParts(dateValue: string): {
year: string;
month: string;
day: string;
} {
const parts = dateValue ? dateValue.split("-") : [];
return {
year: parts[0] || "",
month: parts[1] || "",
day: parts[2] || "",
};
}
export function QuestionDate({ question, disabled }: QuestionDateProps) {
const { dictionary: t, locale } = useI18n();
const { getAnswerValue, setAnswerValue } = useQuestionAnswers();
const value = getAnswerValue(question);
const dateValue = typeof value === "string" ? value : "";
const { year, month, day } = parseDateParts(dateValue);
const months = useMemo(() => {
return MONTH_VALUES.map((val, idx) => {
const date = new Date(2000, idx, 15);
const label = new Intl.DateTimeFormat(`${locale}-u-ca-gregory`, {
month: "long",
}).format(date);
return { value: val, label };
});
}, [locale]);
const [selectedYear, setSelectedYear] = useState(() => {
const parts = dateValue ? dateValue.split("-") : [];
return parts[0] || "";
});
const [selectedMonth, setSelectedMonth] = useState(() => {
const parts = dateValue ? dateValue.split("-") : [];
return parts[1] || "";
});
const [selectedDay, setSelectedDay] = useState(() => {
const parts = dateValue ? dateValue.split("-") : [];
return parts[2] || "";
});
useEffect(() => {
const parts = dateValue ? dateValue.split("-") : [];
if (parts.length === 3) {
setSelectedYear(parts[0] || "");
setSelectedMonth(parts[1] || "");
setSelectedDay(parts[2] || "");
}
}, [dateValue]);
const [isOpen, setIsOpen] = useState(false);
const calculatedAge = useMemo(() => {
if (!selectedYear || !selectedMonth || !selectedDay) return null;
const y = Number.parseInt(selectedYear, 10);
const m = Number.parseInt(selectedMonth, 10);
const d = Number.parseInt(selectedDay, 10);
if (!year || !month || !day) return null;
const y = Number.parseInt(year, 10);
const m = Number.parseInt(month, 10);
const d = Number.parseInt(day, 10);
if (
!y ||
@ -108,37 +64,35 @@ export function QuestionDate({
}
return age >= 0 ? age : null;
}, [selectedYear, selectedMonth, selectedDay]);
}, [year, month, day]);
const isUnder18 = calculatedAge !== null && calculatedAge < MIN_AGE;
const updateDate = (y: string, m: string, d: string) => {
setSelectedYear(y);
setSelectedMonth(m);
setSelectedDay(d);
if (y && m && d) {
const formattedMonth = m.padStart(2, "0");
const formattedDay = d.padStart(2, "0");
setAnswerValue(
question, `${y}-${formattedMonth}-${formattedDay}`,
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),
);
} else {
setAnswerValue(question, "");
}
};
return new Intl.DateTimeFormat(`${locale}-u-ca-gregory`, {
day: "numeric",
month: "long",
year: "numeric",
}).format(date);
}, [year, month, day, locale]);
const handleDayChange = (newDay: string) => {
updateDate(selectedYear, selectedMonth, newDay);
const openSheet = () => {
if (disabled) return;
setIsOpen(true);
};
const handleMonthChange = (newMonth: string) => {
updateDate(selectedYear, newMonth, selectedDay);
const handleApply = (formattedDate: string) => {
setAnswerValue(question, formattedDate);
};
const handleYearChange = (newYear: string) => {
updateDate(newYear, selectedMonth, selectedDay);
};
const placeholder =
t["Select date"] || (locale === "fa" ? "انتخاب تاریخ" : "Select date");
return (
<div
@ -149,69 +103,70 @@ export function QuestionDate({
>
<QuestionTitle question={question} />
{/* 3 Select Dropdowns: Day, Month, Year */}
<div className="grid grid-cols-3 gap-2">
{/* Day / روز */}
<select
value={selectedDay}
onChange={(e) => handleDayChange(e.target.value)}
{/* Date Trigger Field (single input, like the Flutter app's DOB field) */}
<button
type="button"
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]"
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(" ")}
>
<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]"
<svg
aria-hidden="true"
width="20"
height="20"
viewBox="0 0 20 20"
fill="none"
className="shrink-0 text-[#667085]"
>
<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]"
<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={[
"flex-1 truncate text-[15px] font-medium",
displayDate ? "text-[#181818]" : "text-[#667085]",
].join(" ")}
>
<option value="">Year</option>
{YEARS.map((y) => (
<option key={y} value={y}>
{y}
</option>
))}
</select>
</div>
{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>
{/* Display Calculated Age */}
{calculatedAge !== null ? (
<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 className="group-14 block leading-tight font-semibold text-[#181818]">
{t.Age || (locale === "fa" ? "سن" : "Age")}
</span>
<input
type="text"
disabled
readOnly
value={calculatedAge !== null ? calculatedAge : ""}
value={calculatedAge}
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]",
"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]",
@ -225,6 +180,16 @@ export function QuestionDate({
</span>
) : null}
</div>
) : null}
{isOpen && (
<QuestionDateSheet
question={question}
value={dateValue}
onApply={handleApply}
onClose={() => setIsOpen(false)}
/>
)}
</div>
);
}

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

@ -1,13 +1,16 @@
"use client";
import { PhoneNumberFormat, PhoneNumberUtil } from "google-libphonenumber";
import { useEffect, useRef, useState, useMemo, useCallback } from "react";
import type { QuestionField } from "@/lib/schema-adapter";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { createPortal } from "react-dom";
import type { MarriagePhoneFieldValue } from "@/hooks/marriage/types";
import type { QuestionField } from "@/lib/schema-adapter";
import { useI18n } from "@/translations/provider";
import { useQuestionAnswers } from "./question-answer-storage";
import QuestionTitle from "./question-title";
import { LoadingSkeleton } from "./loading-skeleton";
import { useI18n } from "@/translations/provider";
import { useSheetScrollLock } from "./use-sheet-scroll-lock";
const EXIT_ANIMATION_MS = 300;
type QuestionPhoneProps = {
question: QuestionField;
@ -226,7 +229,7 @@ export function QuestionPhone({
countryCode = "+44",
disabled,
}: QuestionPhoneProps) {
const { locale } = useI18n();
const { dictionary: t, locale } = useI18n();
const { getAnswerValue, setAnswerValue, isLoading } = useQuestionAnswers();
const value = getAnswerValue(question);
const defaultCodeValue = countryCode.trim() || "+44";
@ -263,29 +266,25 @@ export function QuestionPhone({
const hasFetchedIpRef = useRef(false);
const userInteractedRef = useRef(false);
const needsIpFetch = useCallback(() => {
if (typeof window === "undefined") return false;
if (localStorage.getItem("geoIPPhoneCode")) return false;
if (localStorage.getItem("hasCheckedGeoIPPhone")) return false;
return true;
}, []);
const [isResolvingCode, setIsResolvingCode] = useState(() => {
if (isLoading) return true;
const hasSavedValue =
value &&
((isMarriagePhoneFieldValue(value) &&
(value.countryCode || value.phoneNumber)) ||
(typeof value === "string" && value.trim().length > 0));
if (hasSavedValue) return false;
return needsIpFetch();
});
const [isOpen, setIsOpen] = useState(false);
const [isClosing, setIsClosing] = useState(false);
const [searchQuery, setSearchQuery] = useState("");
const containerRef = useRef<HTMLDivElement>(null);
const listRef = useRef<HTMLDivElement>(null);
const searchInputRef = useRef<HTMLInputElement>(null);
const closeSheet = useCallback(() => {
setIsClosing(true);
window.setTimeout(() => {
setIsOpen(false);
setIsClosing(false);
setSearchQuery("");
}, EXIT_ANIMATION_MS);
}, []);
const openSheet = useCallback(() => {
if (disabled) return;
setIsOpen(true);
setIsClosing(false);
}, [disabled]);
const normalizedPhoneState = getNormalizedPhoneValue(codeValue, phoneValue);
const showInvalidState =
@ -333,7 +332,7 @@ export function QuestionPhone({
code: `+${callingCode}`,
flag,
});
} catch (e) {}
} catch {}
}
return list.sort((a, b) => a.name.localeCompare(b.name));
@ -348,6 +347,13 @@ export function QuestionPhone({
return match ? match.flag : "🏳️";
}, [codeValue, countryList]);
const selectedCountry = useMemo(() => {
const cleanActiveCode = codeValue.replace(/[^\d]/g, "");
return countryList.find(
(country) => country.code.replace(/[^\d]/g, "") === cleanActiveCode,
);
}, [codeValue, countryList]);
const filteredCountries = useMemo(() => {
const q = searchQuery.toLowerCase().trim();
if (!q) return countryList;
@ -357,48 +363,17 @@ export function QuestionPhone({
);
}, [countryList, searchQuery]);
// Lock page scroll when dropdown is open
useEffect(() => {
if (isOpen) {
document.body.classList.add("dropdown-open");
document.body.style.overflow = "hidden";
document.documentElement.style.overflow = "hidden";
} else {
document.body.classList.remove("dropdown-open");
document.body.style.overflow = "";
document.documentElement.style.overflow = "";
}
return () => {
document.body.classList.remove("dropdown-open");
document.body.style.overflow = "";
document.documentElement.style.overflow = "";
};
}, [isOpen]);
useSheetScrollLock(isOpen);
useEffect(() => {
if (isOpen) {
const timer = setTimeout(() => {
searchInputRef.current?.focus();
}, 50);
return () => clearTimeout(timer);
}
}, [isOpen]);
if (!isOpen) return;
// Close dropdown on click outside
useEffect(() => {
function handleClickOutside(event: MouseEvent) {
if (
containerRef.current &&
!containerRef.current.contains(event.target as Node)
) {
setIsOpen(false);
}
}
document.addEventListener("mousedown", handleClickOutside);
return () => {
document.removeEventListener("mousedown", handleClickOutside);
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === "Escape") closeSheet();
};
}, []);
window.addEventListener("keydown", handleKeyDown);
return () => window.removeEventListener("keydown", handleKeyDown);
}, [closeSheet, isOpen]);
// Sync state if external value changes (e.g. backend data loaded)
useEffect(() => {
@ -497,7 +472,7 @@ export function QuestionPhone({
.then((res) => res.json())
.then((data) => {
clearTimeout(timeoutId);
if (data && data.country_calling_code) {
if (data?.country_calling_code) {
applyCode(data.country_calling_code);
} else {
throw new Error("No calling code in response");
@ -515,7 +490,7 @@ export function QuestionPhone({
.then((res) => res.json())
.then((data) => {
clearTimeout(secondaryTimeoutId);
if (data && data.calling_code) {
if (data?.calling_code) {
applyCode(data.calling_code);
} else {
applyFallback();
@ -561,13 +536,13 @@ export function QuestionPhone({
setCodeValue(selectedCode);
setPhoneValue(truncatedPhone);
updateStoredValue(selectedCode, truncatedPhone);
setIsOpen(false);
setSearchQuery("");
closeSheet();
};
const selectCountryTitle = t["Select country"] || question.title;
return (
<div
ref={containerRef}
data-question-answered={isAnswered ? "true" : "false"}
data-question-type={question.type}
className={[
@ -589,12 +564,13 @@ export function QuestionPhone({
<button
type="button"
disabled={disabled}
onClick={() => setIsOpen(!isOpen)}
onClick={openSheet}
className="flex items-center gap-1.5 h-full px-2 rounded-lg hover:bg-gray-50 active:bg-gray-100 transition-colors cursor-pointer outline-none border-0 text-[15px] font-medium leading-none text-[#181818] tabular-nums"
>
<span>{activeFlag}</span>
<span>{codeValue || defaultCodeValue}</span>
<svg
aria-hidden="true"
width="10"
height="6"
viewBox="0 0 10 6"
@ -613,20 +589,14 @@ export function QuestionPhone({
/>
</svg>
</button>
<span
aria-hidden="true"
className="h-5 w-px bg-[#181818]/35 ml-1"
/>
<span aria-hidden="true" className="h-5 w-px bg-[#181818]/35 ml-1" />
</div>
<span className="flex min-w-0 flex-1 items-center pr-4">
<input
type="tel"
inputMode="tel"
disabled={disabled}
placeholder={question.extras.placeHolder?.replace(
/^\+\d+\s*/,
"",
)}
placeholder={question.extras.placeHolder?.replace(/^\+\d+\s*/, "")}
value={phoneValue}
maxLength={getMaxLengthForCountry(codeValue)}
onChange={(event) => {
@ -635,9 +605,7 @@ export function QuestionPhone({
localStorage.setItem("geoIPPhoneCode", codeValue);
localStorage.setItem("hasCheckedGeoIPPhone", "true");
}
const nextPhoneValue = sanitizePhoneNumber(
event.target.value,
);
const nextPhoneValue = sanitizePhoneNumber(event.target.value);
const maxLen = getMaxLengthForCountry(codeValue);
const truncatedPhone = nextPhoneValue.slice(0, maxLen);
@ -655,17 +623,41 @@ export function QuestionPhone({
</span>
) : null}
{/* Dropdown Options Panel */}
{isOpen && (
<div className="absolute top-[calc(100%+8px)] left-0 z-50 flex w-full flex-col gap-3.5 rounded-[20px] bg-white p-4.5 shadow-[0_12px_36px_rgba(0,0,0,0.09)] border border-[#EAECF0] animate-in fade-in zoom-in-95 duration-150">
{/* Search Input Bar */}
<div className="flex h-[46px] w-full items-center gap-2.5 rounded-[12px] bg-[#EFEFEF] px-3.5 transition-colors focus-within:bg-[#E8E8E8]">
{/* The phone field moves up while the country sheet enters from below. */}
{isOpen &&
createPortal(
<div
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();
}}
>
<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"
>
<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"
@ -687,6 +679,7 @@ export function QuestionPhone({
type="text"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
aria-label={selectCountryTitle}
placeholder={
locale === "fa"
? "جستجوی کشور یا پیش‌شماره..."
@ -698,17 +691,21 @@ export function QuestionPhone({
<button
type="button"
onClick={() => setSearchQuery("")}
className="text-[#667085] hover:text-[#181818] text-xs font-semibold"
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>
{/* Country List */}
<div
ref={listRef}
className="flex max-h-[220px] flex-col gap-3.5 overflow-y-auto overscroll-contain pr-1"
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) => {
@ -717,17 +714,42 @@ export function QuestionPhone({
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"
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]"
>
<div className="flex items-center gap-3">
<span className="text-[20px]">{c.flag}</span>
<span className="text-[15px] font-bold text-[#181818]">
<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>
</div>
<span className="text-[15px] font-semibold text-[#667085] tabular-nums">
<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>
);
})
@ -737,7 +759,9 @@ export function QuestionPhone({
</span>
)}
</div>
</div>
</section>
</div>,
document.body,
)}
</div>
);

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

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

182
src/components/Componentes/question-sheet.test.tsx

@ -0,0 +1,182 @@
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import {
cleanup,
fireEvent,
render,
screen,
waitFor,
} from "@testing-library/react";
import { afterEach, describe, expect, it, vi } from "vitest";
import type { QuestionField } from "@/lib/schema-adapter";
import { QuestionAnswersProvider } from "./question-answer-storage";
import { QuestionSheet } from "./question-sheet";
vi.mock("@/translations/provider", () => ({
useI18n: vi.fn(() => ({ locale: "fa", dictionary: { Confirm: "تایید" } })),
}));
vi.mock("@/hooks/marriage/use-section-data", () => ({
applyProfilePatchResultToCache: vi.fn(),
useMarriageSectionDataQuery: vi.fn(() => ({
data: undefined,
isLoading: false,
})),
useUpdateMarriageSectionDataMutation: vi.fn(() => ({
mutateAsync: vi.fn().mockResolvedValue({}),
isPending: false,
})),
}));
describe("QuestionSheet component", () => {
afterEach(() => {
cleanup();
document.body.classList.remove("dropdown-open");
document.body.classList.remove("question-input-open");
});
const queryClient = new QueryClient({
defaultOptions: { queries: { retry: false } },
});
it("renders trigger and opens bottom sheet on click", async () => {
const qDropdown = {
id: "q_nat",
title: "تابعیت و شهروندی",
type: "dropdown",
order: 1,
required: true,
isVisible: true,
description: "",
tooltip: "",
extras: { placeHolder: "انتخاب کشور" },
options: [
{ id: "iran", value: "Iran", label: "ایران", order: 1 },
{ id: "germany", value: "Germany", label: "آلمان", order: 2 },
{ id: "canada", value: "Canada", label: "کانادا", order: 3 },
],
ui_config: {},
} as QuestionField;
render(
<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-input-open")).toBe(true);
expect(
(document.querySelector(".app-shell") as HTMLElement).style.overflowY,
).toBe("hidden");
expect(screen.getByRole("button", { name: "ایران" })).toBeDefined();
expect(screen.getByRole("button", { name: "آلمان" })).toBeDefined();
// Select Iran
const iranBtn = screen.getByRole("button", { name: "ایران" });
fireEvent.click(iranBtn);
// Trigger should now show Iran
await waitFor(() => {
expect(screen.getByText("ایران")).toBeDefined();
expect(document.body.classList.contains("dropdown-open")).toBe(false);
expect(document.body.classList.contains("question-input-open")).toBe(
false,
);
expect(
(document.querySelector(".app-shell") as HTMLElement).style.overflowY,
).toBe("");
});
});
it("supports multi-selection with confirm button", async () => {
const qMulti = {
id: "q_lang",
title: "سایر زبان‌ها",
type: "dropdown",
order: 2,
required: false,
isVisible: true,
description: "",
tooltip: "",
extras: { placeHolder: "انتخاب زبان‌ها", range: [0, 5] },
options: [
{ id: "en", value: "English", label: "انگلیسی", order: 1 },
{ id: "ar", value: "Arabic", label: "عربی", order: 2 },
{ id: "fr", value: "French", label: "فرانسوی", order: 3 },
],
ui_config: {},
} as QuestionField;
render(
<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-input-open")).toBe(
false,
);
});
});
});

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 searchInputRef = useRef<HTMLInputElement>(null);
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 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 h-[82svh] 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(" ")}
>
<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
ref={searchInputRef}
type="text"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
placeholder={searchPlaceholder}
className="flex-1 bg-transparent text-[14px] font-medium text-[#181818] outline-none placeholder:text-[#98A2B3]"
/>
{searchQuery ? (
<button
type="button"
onClick={() => setSearchQuery("")}
className="text-[#667085] hover:text-[#181818] text-xs font-semibold p-1 cursor-pointer"
>
</button>
) : null}
</div>
</div>
)}
{/* Options List */}
<div
ref={listRef}
onTouchStart={(event) => event.stopPropagation()}
onTouchMove={(event) => event.stopPropagation()}
onTouchEnd={(event) => event.stopPropagation()}
className="flex min-h-0 flex-1 flex-col gap-2 overflow-y-auto overscroll-contain px-5 py-3"
>
{filteredOptions.length > 0 ? (
filteredOptions.map((option) => {
const isSelected = isMulti
? selectedList.includes(option.id)
: singleValue === option.id;
return (
<button
key={option.id}
type="button"
onClick={() => {
if (isMulti) {
toggleMultiOption(option.id);
} else {
handleSelectSingle(option.id);
}
}}
className={[
"flex min-h-12 w-full items-start gap-3 rounded-lg border px-3 py-3 text-start transition-colors cursor-pointer",
isSelected
? "bg-[#FFF4F5] border-[#F0445B]/30 text-[#181818]"
: "bg-white hover:bg-[#F2F4F7] border-[#EAECF0] text-[#181818]",
].join(" ")}
>
{/* Indicator Icon */}
{isMulti ? (
<div
className={[
"size-[22px] shrink-0 rounded-[6px] transition-all duration-150 mt-0.5 flex items-center justify-center",
isSelected
? "bg-[#F0445B] text-white shadow-xs"
: "border-[2px] border-[#98A2B3] bg-white",
].join(" ")}
>
{isSelected && (
<svg
aria-hidden="true"
width="12"
height="10"
viewBox="0 0 12 10"
fill="none"
>
<path
d="M1.5 5L4.5 8L10.5 2"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
)}
</div>
) : (
<div
className={[
"size-[22px] shrink-0 rounded-full transition-all duration-150 mt-0.5 flex items-center justify-center",
isSelected
? "border-[6px] border-[#F0445B] bg-white"
: "border-[2px] border-[#98A2B3] bg-white",
].join(" ")}
/>
)}
<span className="text-[15px] leading-snug flex-1">
{option.label.includes(" - ") ? (
(() => {
const parts = option.label.split(" - ");
const title = parts[0];
const description = parts.slice(1).join(" - ");
return (
<span className="flex flex-col gap-1 text-start">
<span className="font-bold text-[#181818]">
{title}
</span>
<ExplanationUiFont>
{description}
</ExplanationUiFont>
</span>
);
})()
) : (
<span
className={
isSelected
? "font-bold text-[#181818]"
: "font-semibold text-[#344054]"
}
>
{option.label}
</span>
)}
</span>
</button>
);
})
) : (
<div className="py-8 text-center text-[14px] text-[#667085]">
{noResultsText}
</div>
)}
</div>
{/* Bottom Actions for Multi-Select */}
{isMulti && (
<div className="border-t border-[#F2F4F7] bg-white p-4 pt-3 pb-[calc(14px+env(safe-area-inset-bottom))]">
<Button
type="button"
variant="default"
onClick={closeSheet}
className="w-full h-[50px] rounded-[14px] text-[15px] font-bold shadow-[0_8px_20px_rgba(240,68,91,0.25)]"
>
{selectedList.length > 0
? `${confirmText} (${selectedList.length})`
: confirmText}
</Button>
</div>
)}
</section>
</div>,
document.body,
)}
</div>
);
}
export default QuestionSheet;

345
src/components/Componentes/question-snap-list.tsx

@ -11,14 +11,44 @@ import {
} from "react";
import { useI18n } from "@/translations/provider";
import { useQuestionProgress } from "./question-progress-tracker";
import { useQuestionInputFocusSync } from "./use-sheet-scroll-lock";
const WHEEL_GESTURE_IDLE_MS = 320;
const TOUCH_MIN_DISTANCE = 8;
const DRAG_ENGAGE_DISTANCE = 10;
const DRAG_COMMIT_RATIO = 0.3;
const DRAG_FLICK_VELOCITY = 0.55;
const SNAP_ANIMATION_MS = 340;
const SNAP_EASE = "cubic-bezier(0.22, 1, 0.36, 1)";
const RUBBER_BAND_RESISTANCE = 0.4;
const AUTO_FOCUS_SELECTOR = [
"textarea:not([disabled])",
'input[type="text"]:not([disabled])',
'input[type="number"]:not([disabled])',
"textarea:not([disabled]):not([data-no-auto-focus])",
'input[type="text"]:not([disabled]):not([data-no-auto-focus])',
'input[type="number"]:not([disabled]):not([data-no-auto-focus])',
].join(", ");
const DRAG_IGNORE_SELECTOR = [
"input",
"textarea",
"select",
'[contenteditable="true"]',
"[data-snap-drag-ignore]",
].join(", ");
type SnapDragState = {
pointerDown: boolean;
ignored: boolean;
engaged: boolean;
animating: boolean;
baseOffset: number;
startY: number;
lastY: number;
lastMoveTime: number;
velocity: number;
offset: number;
height: number;
trioIndices: number[];
cleanupTimer: number | null;
};
type QuestionSnapListProps = {
children: ReactNode;
@ -41,15 +71,36 @@ export function QuestionSnapList({
}: QuestionSnapListProps) {
const { dictionary: t } = useI18n();
const { isCompleted } = useQuestionProgress();
useQuestionInputFocusSync();
const questions = Children.toArray(children);
const wheelLockedRef = useRef(false);
const wheelUnlockTimeoutRef = useRef<number | null>(null);
const touchStartYRef = useRef<number | null>(null);
const questionRefs = useRef<Array<HTMLDivElement | null>>([]);
const containerRef = useRef<HTMLElement | null>(null);
const previousActiveIndexRef = useRef<number | null>(null);
const [activeIndex, setActiveIndex] = useState(0);
const activeIndexRef = useRef(activeIndex);
activeIndexRef.current = activeIndex;
const questionsCountRef = useRef(questions.length);
questionsCountRef.current = questions.length;
const dragRef = useRef<SnapDragState>({
pointerDown: false,
ignored: false,
engaged: false,
animating: false,
baseOffset: 0,
startY: 0,
lastY: 0,
lastMoveTime: 0,
velocity: 0,
offset: 0,
height: 0,
trioIndices: [],
cleanupTimer: null,
});
const stepQuestion = useCallback(
(direction: 1 | -1) => {
@ -223,9 +274,97 @@ export function QuestionSnapList({
if (wheelUnlockTimeoutRef.current !== null) {
window.clearTimeout(wheelUnlockTimeoutRef.current);
}
if (dragRef.current.cleanupTimer !== null) {
window.clearTimeout(dragRef.current.cleanupTimer);
}
};
}, []);
const readTranslateY = useCallback((element: HTMLElement): number => {
const transform = window.getComputedStyle(element).transform;
if (!transform || transform === "none") {
return 0;
}
try {
return new DOMMatrix(transform).m42;
} catch {
return 0;
}
}, []);
const styleDragPanel = useCallback(
(element: HTMLDivElement, transform: string, animate: boolean) => {
element.style.transition = animate
? `transform ${SNAP_ANIMATION_MS}ms ${SNAP_EASE}`
: "none";
element.style.transform = transform;
element.style.opacity = "1";
element.style.zIndex = "10";
},
[],
);
// Positive offset slides the active panel up (towards the next question),
// exactly like following a finger in a reels-style pager.
const applyDragOffset = useCallback(
(offset: number, animate: boolean) => {
const index = activeIndexRef.current;
const height = dragRef.current.height;
const panels: Array<[HTMLDivElement | null, number]> = [
[questionRefs.current[index], -offset],
[questionRefs.current[index + 1], height - offset],
[questionRefs.current[index - 1], -height - offset],
];
for (const [element, translateY] of panels) {
if (!element) continue;
styleDragPanel(element, `translateY(${translateY}px)`, animate);
}
},
[styleDragPanel],
);
const clearDragStyles = useCallback(() => {
const drag = dragRef.current;
const index = activeIndexRef.current;
const targets = new Set<number>([
...drag.trioIndices,
index - 1,
index,
index + 1,
]);
for (const position of targets) {
const element = questionRefs.current[position];
if (!element) continue;
element.style.transition = "";
element.style.transform = "";
element.style.opacity = "";
element.style.zIndex = "";
}
drag.trioIndices = [];
}, []);
const snapPanelsTo = useCallback(
(offset: number) => {
const drag = dragRef.current;
const index = activeIndexRef.current;
drag.animating = true;
drag.trioIndices = [index - 1, index, index + 1];
applyDragOffset(offset, true);
if (drag.cleanupTimer !== null) {
window.clearTimeout(drag.cleanupTimer);
}
drag.cleanupTimer = window.setTimeout(() => {
drag.cleanupTimer = null;
drag.animating = false;
clearDragStyles();
}, SNAP_ANIMATION_MS + 40);
},
[applyDragOffset, clearDragStyles],
);
const handleWheel = useCallback(
(event: React.WheelEvent<HTMLElement>) => {
if (document.body.classList.contains("dropdown-open")) {
@ -255,9 +394,152 @@ export function QuestionSnapList({
if (document.body.classList.contains("dropdown-open")) {
return;
}
touchStartYRef.current = event.touches[0]?.clientY ?? null;
const drag = dragRef.current;
if (drag.cleanupTimer !== null) {
window.clearTimeout(drag.cleanupTimer);
drag.cleanupTimer = null;
}
const target = event.target as HTMLElement | null;
drag.ignored = Boolean(target?.closest?.(DRAG_IGNORE_SELECTOR));
drag.height =
containerRef.current?.getBoundingClientRect().height ??
window.innerHeight;
if (drag.animating) {
// Grabbed mid-snap (or mid wheel transition): freeze the panels
// exactly where they currently are and continue the drag from there.
const activeElement = questionRefs.current[activeIndexRef.current];
if (activeElement) {
drag.baseOffset = -readTranslateY(activeElement);
applyDragOffset(drag.baseOffset, false);
} else {
drag.baseOffset = 0;
}
drag.animating = false;
drag.engaged = true;
} else {
drag.baseOffset = 0;
drag.engaged = false;
}
drag.pointerDown = true;
drag.velocity = 0;
drag.offset = drag.baseOffset;
drag.startY = event.touches[0]?.clientY ?? 0;
drag.lastY = drag.startY;
drag.lastMoveTime = event.timeStamp || performance.now();
touchStartYRef.current = drag.startY;
},
[],
[applyDragOffset, readTranslateY],
);
const handleTouchMove = useCallback(
(event: React.TouchEvent<HTMLElement>) => {
if (document.body.classList.contains("dropdown-open")) {
return;
}
const drag = dragRef.current;
if (!drag.pointerDown) {
event.preventDefault();
return;
}
if (drag.ignored) {
return;
}
const y = event.touches[0]?.clientY ?? drag.lastY;
if (!drag.engaged) {
if (Math.abs(drag.startY - y) < DRAG_ENGAGE_DISTANCE) {
event.preventDefault();
return;
}
drag.engaged = true;
}
event.preventDefault();
if (event.touches.length > 1) {
drag.engaged = false;
drag.pointerDown = false;
snapPanelsTo(0);
return;
}
const now = event.timeStamp || performance.now();
const deltaTime = now - drag.lastMoveTime;
if (deltaTime > 0) {
const instantVelocity = (drag.lastY - y) / deltaTime;
drag.velocity = drag.velocity * 0.72 + instantVelocity * 0.28;
}
drag.lastY = y;
drag.lastMoveTime = now;
const index = activeIndexRef.current;
const canNext = index < questionsCountRef.current - 1;
const canPrev = index > 0;
let offset = drag.baseOffset + (drag.startY - y);
if (offset > 0 && !canNext) {
offset *= RUBBER_BAND_RESISTANCE;
}
if (offset < 0 && !canPrev) {
offset *= RUBBER_BAND_RESISTANCE;
}
drag.offset = offset;
applyDragOffset(offset, false);
},
[applyDragOffset, snapPanelsTo],
);
const finishDrag = useCallback(
(cancelled: boolean) => {
const drag = dragRef.current;
if (!drag.pointerDown) {
return;
}
drag.pointerDown = false;
touchStartYRef.current = null;
if (!drag.engaged) {
return;
}
drag.engaged = false;
const index = activeIndexRef.current;
const canNext = index < questionsCountRef.current - 1;
const canPrev = index > 0;
const flicked = Math.abs(drag.velocity) > DRAG_FLICK_VELOCITY;
const draggedFar =
Math.abs(drag.offset) > drag.height * DRAG_COMMIT_RATIO;
let direction: 0 | 1 | -1 = 0;
if (!cancelled && (draggedFar || flicked)) {
if ((drag.offset > 0 || drag.velocity > 0) && canNext) {
direction = 1;
} else if ((drag.offset < 0 || drag.velocity < 0) && canPrev) {
direction = -1;
}
}
if (direction === 0) {
snapPanelsTo(0);
return;
}
const nextIndex = index + direction;
snapPanelsTo(direction * drag.height);
onQuestionExit?.(index, nextIndex);
onQuestionTransition?.(index, nextIndex);
setActiveIndex(nextIndex);
},
[onQuestionExit, onQuestionTransition, snapPanelsTo],
);
const handleTouchEnd = useCallback(
@ -266,9 +548,22 @@ export function QuestionSnapList({
return;
}
const drag = dragRef.current;
if (drag.engaged) {
finishDrag(false);
return;
}
if (!drag.pointerDown) {
return;
}
drag.pointerDown = false;
// Fallback for very fast flicks whose touchmove never engaged the
// drag layer: fall back to the distance-based step.
const startY = touchStartYRef.current;
const endY = event.changedTouches[0]?.clientY;
touchStartYRef.current = null;
if (startY === null || endY === undefined || questions.length < 2) {
@ -283,18 +578,12 @@ export function QuestionSnapList({
stepQuestion(distance > 0 ? 1 : -1);
},
[questions.length, stepQuestion],
[finishDrag, questions.length, stepQuestion],
);
const handleTouchMove = useCallback(
(event: React.TouchEvent<HTMLElement>) => {
if (document.body.classList.contains("dropdown-open")) {
return;
}
event.preventDefault();
},
[],
);
const handleTouchCancel = useCallback(() => {
finishDrag(true);
}, [finishDrag]);
if (questions.length === 0) {
return null;
@ -302,14 +591,16 @@ export function QuestionSnapList({
return (
<section
ref={containerRef}
aria-label="Questions"
className={[
"relative touch-none overflow-hidden focus-visible:outline-none",
"question-snap-list relative touch-none overflow-hidden focus-visible:outline-none",
"flex-1 min-h-0 pt-4 pb-4",
className,
]
.filter(Boolean)
.join(" ")}
onTouchCancel={handleTouchCancel}
onTouchEnd={handleTouchEnd}
onTouchMove={handleTouchMove}
onTouchStart={handleTouchStart}
@ -326,7 +617,7 @@ export function QuestionSnapList({
: String(question);
let containerStyles = "";
let wrapperStyles = "w-full";
let wrapperStyles = "w-full my-auto shrink-0";
if (isActive) {
containerStyles =
@ -336,19 +627,13 @@ export function QuestionSnapList({
: "w-full my-auto shrink-0";
} else if (isPrev) {
containerStyles =
"top-4 left-0 z-0 opacity-0 pointer-events-none scale-[0.98]";
wrapperStyles =
"w-full max-h-[110px] overflow-hidden pointer-events-none";
"top-0 left-0 z-0 opacity-0 pointer-events-none -translate-y-full";
} else if (isNext) {
containerStyles =
"bottom-4 left-0 z-0 opacity-0 pointer-events-none scale-[0.98]";
wrapperStyles =
"w-full max-h-[110px] overflow-hidden pointer-events-none";
"top-0 left-0 z-0 opacity-0 pointer-events-none translate-y-full";
} else {
containerStyles =
"top-1/2 left-0 -translate-y-1/2 z-0 opacity-0 pointer-events-none scale-[0.95]";
wrapperStyles =
"w-full max-h-[110px] overflow-hidden pointer-events-none";
}
return (
@ -361,11 +646,13 @@ export function QuestionSnapList({
aria-hidden={isActive ? undefined : true}
inert={isActive ? undefined : true}
className={[
"absolute flex w-full flex-col justify-center transition-all duration-300 ease-out px-[17px]",
"question-snap-item absolute flex w-full flex-col justify-center transition-all duration-300 ease-out px-[17px]",
containerStyles,
].join(" ")}
>
<div className={wrapperStyles}>{question}</div>
<div className={`question-snap-content ${wrapperStyles}`}>
{question}
</div>
{isActive && (
<div className="h-[20%] shrink min-h-0" aria-hidden="true" />
)}

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

@ -52,7 +52,6 @@ vi.mock("next/navigation", () => ({
const mockSchema = {
form_id: "profile",
version: 1,
answers: {
q_radio: { value: "A", option_id: "opt_a" },
q_check: { value: ["B", "C"], option_id: ["opt_b", "opt_c"] },
@ -160,7 +159,7 @@ describe("Schema Question Flow Integration", () => {
...payload.fields,
],
};
return { ...payload, version: 2 };
return payload;
});
(useUpdateMarriageSectionDataMutation as any).mockReturnValue({
mutateAsync: updateMutateAsync,
@ -200,7 +199,6 @@ describe("Schema Question Flow Integration", () => {
});
(useFormSectionQuery as any).mockReturnValue({
data: {
version: 1,
section: mockSchema.sections[0],
answers: mockSchema.answers,
progress: mockSchema.progress,

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

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

@ -0,0 +1,125 @@
"use client";
import { useEffect } from "react";
let activeSheetCount = 0;
let activeKeyboardInputCount = 0;
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-input-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");
}
if (activeKeyboardInputCount === 0) {
document.body.classList.remove("question-input-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 (activeSheetCount > 0 || activeKeyboardInputCount > 0) {
document.body.classList.add("question-input-open");
} else {
document.body.classList.remove("question-input-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;
activeKeyboardInputCount += 1;
syncQuestionInputOpenClass();
};
const handleFocusOut = (event: FocusEvent) => {
if (!isKeyboardInputTarget(event.target)) return;
activeKeyboardInputCount = Math.max(0, activeKeyboardInputCount - 1);
syncQuestionInputOpenClass();
};
document.addEventListener("focusin", handleFocusIn);
document.addEventListener("focusout", handleFocusOut);
return () => {
document.removeEventListener("focusin", handleFocusIn);
document.removeEventListener("focusout", handleFocusOut);
activeKeyboardInputCount = 0;
if (activeSheetCount === 0) {
document.body.classList.remove("question-input-open");
}
};
}, []);
}

2
src/hooks/marriage/types.ts

@ -151,7 +151,6 @@ export type MarriageSectionData = {
};
export type UpdateMarriageSectionDataPayload = {
version?: number;
current_step: number;
total_steps?: number;
fields: MarriageField[];
@ -163,7 +162,6 @@ export type MarriageQuestionState = {
};
export type ProfileAnswersPatchResult = {
version: number;
answers: Record<
string,
{ value: MarriageFieldValue; option_id?: string | string[] }

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

@ -57,7 +57,6 @@ export interface FormProgressInfo {
export interface FormSchemaResponse {
form_id: string;
version: number;
sections: FormSection[];
answers: Record<string, { value: any; option_id?: string | string[] }>;
progress: {
@ -78,14 +77,12 @@ export interface FormOverviewSection extends Omit<FormSection, "cards"> {
export interface FormOverviewResponse {
form_id: string;
version: number;
sections: FormOverviewSection[];
progress: FormSchemaResponse["progress"];
}
export interface FormSectionResponse {
form_id: string;
version: number;
section: FormSection;
answers: FormSchemaResponse["answers"];
progress: FormSchemaResponse["progress"];
@ -162,7 +159,6 @@ export function useFormSchemaQuery<TData = FormSchemaResponse>(
}
export interface SaveAnswersPayload {
version: number;
answers: Array<{
question_id: string;
value: any;

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

@ -53,7 +53,6 @@ describe("applyProfilePatchResultToCache", () => {
);
const result: ProfileAnswersPatchResult = {
version: 38,
answers: { parent: { value: "no" } },
cleared_answer_ids: ["child"],
question_states: {

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

@ -15,6 +15,24 @@ import type {
} from "./types";
import { useFormSectionQuery } from "./use-form-schema";
const ANSWERS_ENDPOINT = "/api/marriage/forms/profile/answers/";
type AnswersPayloadItem = {
question_id: string;
value: unknown;
option_id?: unknown;
};
async function patchAnswers(
answersPayload: AnswersPayloadItem[],
): Promise<ProfileAnswersPatchResult> {
const response = await http.patch<ProfileAnswersPatchResult>(
ANSWERS_ENDPOINT,
{ answers: answersPayload },
);
return response.data;
}
export async function updateMarriageSectionData(
slug: string,
payload: UpdateMarriageSectionDataPayload,
@ -25,13 +43,7 @@ export async function updateMarriageSectionData(
option_id: f.option_id || undefined,
}));
const { data } = await http.patch<ProfileAnswersPatchResult>(
`/api/marriage/forms/profile/answers/`,
{
version: payload.version,
answers: answersPayload,
},
);
const data = await patchAnswers(answersPayload);
const prog = data.affected_sections[slug] ||
data.progress.sections_progress[slug] || {
@ -41,7 +53,6 @@ export async function updateMarriageSectionData(
};
return {
version: data.version,
progress: data.progress,
answers: data.answers,
cleared_answer_ids: data.cleared_answer_ids,
@ -120,7 +131,6 @@ export function applyProfilePatchResultToCache(
: current.section;
return {
...current,
version: data.version,
answers,
section,
progress: data.progress,
@ -144,7 +154,6 @@ export function applyProfilePatchResultToCache(
current
? {
...current,
version: data.version,
progress: data.progress,
sections: current.sections.map((section: any) => {
const progress =

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

@ -6,7 +6,6 @@ describe("convertOverviewToFrontendItems", () => {
it("maps overview metadata without materializing questions", () => {
const overview: FormOverviewResponse = {
form_id: "profile",
version: 2,
progress: {
current_step: 1,
total_steps: 2,

6
src/translations/locales/ar.json

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

6
src/translations/locales/az.json

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

6
src/translations/locales/bn.json

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

6
src/translations/locales/da.json

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

6
src/translations/locales/de.json

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

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.",
"Supporting More Suitable Match Recommendations": "Supporting More Suitable Match Recommendations",
"Combining needs and personality assessments with interviews and other marriage criteria can help make partner recommendations more personalized and well-matched.": "Combining needs and personality assessments with interviews and other marriage criteria can help make partner recommendations more personalized and well-matched.",
"Failed to update profile basic details. Please try again.": "Failed to update profile basic details. Please try again."
"Failed to update profile basic details. Please try again.": "Failed to update profile basic details. Please try again.",
"Select date": "Select date",
"Day": "Day",
"Month": "Month",
"Year": "Year"
}

6
src/translations/locales/es.json

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

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.": "به افراد کمک می‌کند تا نیازهای خود و همچنین انگیزه‌ها، ترجیحات و اولویت‌های عاطفی شریک زندگی خود را بهتر درک کنند.",
"Supporting More Suitable Match Recommendations": "پشتیبانی از پیشنهادهای همسریابی مناسب‌تر",
"Combining needs and personality assessments with interviews and other marriage criteria can help make partner recommendations more personalized and well-matched.": "ترکیب ارزیابی نیازها و شخصیت با مصاحبه‌ها و سایر معیارهای ازدواج می‌تواند به شخصی‌سازی و هماهنگی بیشتر پیشنهادهای شریک زندگی کمک کند.",
"Failed to update profile basic details. Please try again.": "به‌روزرسانی اطلاعات اولیه پروفایل ناموفق بود. لطفاً دوباره تلاش کنید."
"Failed to update profile basic details. Please try again.": "به‌روزرسانی اطلاعات اولیه پروفایل ناموفق بود. لطفاً دوباره تلاش کنید.",
"Select date": "انتخاب تاریخ",
"Day": "روز",
"Month": "ماه",
"Year": "سال"
}

6
src/translations/locales/fr.json

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

6
src/translations/locales/gu.json

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

6
src/translations/locales/ha.json

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

8
src/translations/locales/he.json

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

6
src/translations/locales/hi.json

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

8
src/translations/locales/id.json

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

8
src/translations/locales/ks.json

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

8
src/translations/locales/pt.json

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

6
src/translations/locales/ru.json

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

8
src/translations/locales/sw.json

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

8
src/translations/locales/tg.json

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

8
src/translations/locales/tr.json

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

8
src/translations/locales/ul.json

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

8
src/translations/locales/ur.json

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

8
src/translations/locales/uz.json

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

6
src/translations/locales/zh.json

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