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); 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-none,
.page-background-custom, .page-background-custom,
.page-background-default { .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({ (useFormOverviewQuery as any).mockReturnValue({
data: { version: 1 },
data: {},
isLoading: false, isLoading: false,
}); });
(useFormSectionQuery as any).mockReturnValue({ (useFormSectionQuery as any).mockReturnValue({
@ -292,7 +292,6 @@ describe("QuestionDetailClient Validation", () => {
]); ]);
(useFormSectionQuery as any).mockReturnValue({ (useFormSectionQuery as any).mockReturnValue({
data: { data: {
version: 1,
section: { cards: [] }, section: { cards: [] },
answers: {}, answers: {},
section_progress: { completion_percent: 0 }, section_progress: { completion_percent: 0 },
@ -322,4 +321,93 @@ describe("QuestionDetailClient Validation", () => {
// Profile questions render directly, no start button // Profile questions render directly, no start button
expect(screen.getByText(/Dynamic ID/)).toBeDefined(); expect(screen.getByText(/Dynamic ID/)).toBeDefined();
}); });
it("renders a stable profile loading shell while section data is cold", () => {
(convertOverviewToFrontendItems as any).mockReturnValue([
{
slug: "profile_test",
title: "Overview Profile Title",
questions: [],
},
]);
(useFormSectionQuery as any).mockReturnValue({
data: undefined,
isLoading: true,
});
(useCattellQuestionsQuery as any).mockReturnValue({
data: undefined,
isLoading: false,
});
(useGlasserQuestionsQuery as any).mockReturnValue({
data: undefined,
isLoading: false,
});
const queryClient = new QueryClient({
defaultOptions: { queries: { retry: false } },
});
render(
<QueryClientProvider client={queryClient}>
<QuestionDetailClient
closeLabel="Close"
continueLabel="Continue"
description="Desc"
informationLabel="Info"
itemSlug="profile_test"
questionsListHref="/questions-list"
title="Route Fallback Title"
/>
</QueryClientProvider>,
);
expect(screen.getByText("Overview Profile Title")).toBeDefined();
expect(screen.getByRole("status")).toBeDefined();
expect(screen.getByRole("button", { name: "Close" })).toBeDefined();
expect(screen.getByRole("button", { name: "Info" })).toBeDefined();
expect(screen.getByRole("button", { name: "Continue" })).toBeDisabled();
expect(document.querySelector(".shimmer-bg")).toBeNull();
});
it("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 { useRouter } from "next/navigation";
import { useEffect, useMemo, useState } from "react"; import { useEffect, useMemo, useState } from "react";
import Button from "@/components/Componentes/button";
import { FixToTheEnd } from "@/components/Componentes/fix-to-the-end";
import { PageLoadingSkeleton } from "@/components/Componentes/page-loading-skeleton"; import { PageLoadingSkeleton } from "@/components/Componentes/page-loading-skeleton";
import NavigationButton from "@/components/Componentes/navigation-button"; import NavigationButton from "@/components/Componentes/navigation-button";
import { PageBackground } from "@/components/Componentes/page-background"; import { PageBackground } from "@/components/Componentes/page-background";
@ -334,11 +336,56 @@ export default function QuestionDetailClient({
}, [isSchemaLoading, item, questionsListHref, router]); }, [isSchemaLoading, item, questionsListHref, router]);
if (isSchemaLoading) { if (isSchemaLoading) {
if (!isAssessment) {
const loadingTitle = overviewItem?.title || title;
return ( 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) { } else if (!item) {
return null; return null;
@ -480,7 +527,10 @@ export default function QuestionDetailClient({
<> <>
<PageBackground disabled /> <PageBackground disabled />
<main className="-mx-[17px] flex h-svh flex-col overflow-hidden bg-[#F7F1F0]"> <main className="-mx-[17px] flex h-svh flex-col overflow-hidden bg-[#F7F1F0]">
<StickyHeader sticky={false} className="shrink-0">
<StickyHeader
sticky={false}
className="question-detail-header shrink-0"
>
<div className="flex items-center gap-4"> <div className="flex items-center gap-4">
<NavigationButton <NavigationButton
className="shrink-0" className="shrink-0"
@ -648,10 +698,12 @@ export default function QuestionDetailClient({
slug={item.slug} slug={item.slug}
questions={visibleQuestions} questions={visibleQuestions}
locale={locale} locale={locale}
schemaVersion={sectionResponse?.version ?? overview?.version ?? 1}
> >
<main className="-mx-[17px] flex h-svh flex-col overflow-hidden bg-[#F7F1F0]"> <main className="-mx-[17px] flex h-svh flex-col overflow-hidden bg-[#F7F1F0]">
<StickyHeader sticky={false} className="shrink-0">
<StickyHeader
sticky={false}
className="question-detail-header shrink-0"
>
<div className="flex items-center gap-4"> <div className="flex items-center gap-4">
<QuestionExitNavigationButton <QuestionExitNavigationButton
className="shrink-0" className="shrink-0"

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 QuestionCard from "@/components/Componentes/question-card";
import RequiredStepsCard from "@/components/Componentes/required-steps-card"; import RequiredStepsCard from "@/components/Componentes/required-steps-card";
import type { MarriageField } from "@/hooks/marriage/types"; import type { MarriageField } from "@/hooks/marriage/types";
import { marriageQueryKeys } from "@/hooks/marriage/query-keys";
import { import {
getFormSection, getFormSection,
useFormOverviewQuery, useFormOverviewQuery,
@ -40,6 +41,7 @@ import type { QuestionListItem } from "@/lib/schema-adapter";
import { convertOverviewToFrontendItems } from "@/lib/schema-adapter"; import { convertOverviewToFrontendItems } from "@/lib/schema-adapter";
import { localizePath } from "@/translations/config"; import { localizePath } from "@/translations/config";
import { useI18n } from "@/translations/provider"; import { useI18n } from "@/translations/provider";
import { prefetchSectionsWithBoundedConcurrency } from "./section-prefetch";
import SectionsRequest from "./sections-request"; import SectionsRequest from "./sections-request";
export default function QuestionsListPage() { export default function QuestionsListPage() {
@ -215,8 +217,15 @@ export default function QuestionsListPage() {
const [isSyncing, setIsSyncing] = useState(false); const [isSyncing, setIsSyncing] = useState(false);
const [toastMessage, setToastMessage] = useState<string | null>(null); const [toastMessage, setToastMessage] = useState<string | null>(null);
const syncPromiseRef = useRef<Promise<void> | null>(null);
const syncPendingAnswers = useCallback(async () => { const syncPendingAnswers = useCallback(async () => {
if (!overview) return; if (!overview) return;
if (syncPromiseRef.current) {
return syncPromiseRef.current;
}
const task = (async () => {
const pendingSections: Array<{ const pendingSections: Array<{
storageKey: string; storageKey: string;
storedValue: { storedValue: {
@ -239,9 +248,16 @@ export default function QuestionsListPage() {
const rawValue = window.localStorage.getItem(storageKey); const rawValue = window.localStorage.getItem(storageKey);
if (!rawValue) continue; 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; continue;
}
if (!storedValue || !storedValue.pending_sync || !Array.isArray(storedValue.fields)) {
continue;
}
const pendingKeys = new Set<string>( const pendingKeys = new Set<string>(
Array.isArray(storedValue.pending_keys) Array.isArray(storedValue.pending_keys)
? storedValue.pending_keys ? storedValue.pending_keys
@ -261,7 +277,6 @@ export default function QuestionsListPage() {
if (pendingSections.length === 0) return; if (pendingSections.length === 0) return;
const result = await updateMarriageSectionData(pendingSections[0].slug, { const result = await updateMarriageSectionData(pendingSections[0].slug, {
version: overview.version,
current_step: pendingSections[0].storedValue.current_step, current_step: pendingSections[0].storedValue.current_step,
fields: pendingSections.flatMap((section) => section.fields), fields: pendingSections.flatMap((section) => section.fields),
}); });
@ -280,6 +295,15 @@ export default function QuestionsListPage() {
window.localStorage.setItem(storageKey, JSON.stringify(storedValue)); window.localStorage.setItem(storageKey, JSON.stringify(storedValue));
} }
} }
})();
syncPromiseRef.current = task;
try {
await task;
setIsSyncError(false);
} finally {
syncPromiseRef.current = null;
}
}, [locale, overview, queryClient, questionListItems]); }, [locale, overview, queryClient, questionListItems]);
const prefetchSection = useCallback( const prefetchSection = useCallback(
@ -292,7 +316,7 @@ export default function QuestionsListPage() {
) )
return; return;
void queryClient.prefetchQuery({ void queryClient.prefetchQuery({
queryKey: ["marriage", "form-section", "profile", item.slug, locale],
queryKey: marriageQueryKeys.formSection("profile", item.slug, locale),
queryFn: () => getFormSection("profile", item.slug, locale), queryFn: () => getFormSection("profile", item.slug, locale),
staleTime: 30 * 1000, staleTime: 30 * 1000,
}); });
@ -316,13 +340,11 @@ export default function QuestionsListPage() {
.catch(() => undefined) .catch(() => undefined)
.then(() => .then(() =>
queryClient.fetchQuery({ queryClient.fetchQuery({
queryKey: [
"marriage",
"form-section",
queryKey: marriageQueryKeys.formSection(
"profile", "profile",
item.slug, item.slug,
locale, locale,
],
),
queryFn: () => getFormSection("profile", item.slug, locale), queryFn: () => getFormSection("profile", item.slug, locale),
staleTime: 30 * 1000, staleTime: 30 * 1000,
}), }),
@ -335,66 +357,62 @@ export default function QuestionsListPage() {
const prefetchQueueStarted = useRef(false); const prefetchQueueStarted = useRef(false);
useEffect(() => { useEffect(() => {
if (prefetchQueueStarted.current || !overview) return; if (prefetchQueueStarted.current || !overview) return;
const likelySections = questionListItems
const profileSections = questionListItems
.filter( .filter(
(item) => (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; prefetchQueueStarted.current = true;
let cancelled = false; 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", "profile",
item.slug, item.slug,
locale, locale,
],
),
queryFn: () => getFormSection("profile", item.slug, locale), queryFn: () => getFormSection("profile", item.slug, locale),
staleTime: 30 * 1000, 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 () => { return () => {
cancelled = true; cancelled = true;
if (isIdleScheduled) idleWindow.cancelIdleCallback?.(handle as number);
else globalThis.clearTimeout(handle);
}; };
}, [locale, overview, queryClient, questionListItems, sectionProgressBySlug]); }, [locale, overview, queryClient, questionListItems, sectionProgressBySlug]);
useEffect(() => { useEffect(() => {
void syncPendingAnswers().catch(() => setIsSyncError(true));
void syncPendingAnswers().catch((err) => {
console.warn("Background draft sync:", err);
});
const handleOnline = () => { const handleOnline = () => {
void syncPendingAnswers().catch(() => setIsSyncError(true));
void syncPendingAnswers().catch((err) => {
console.warn("Background draft sync on online:", err);
});
}; };
window.addEventListener("online", handleOnline); window.addEventListener("online", handleOnline);
return () => window.removeEventListener("online", handleOnline); return () => window.removeEventListener("online", handleOnline);
}, [syncPendingAnswers]); }, [syncPendingAnswers]);
useEffect(() => { useEffect(() => {
if (startMatchMutation.isError || isSyncError) {
if (startMatchMutation.isError) {
setToastMessage( setToastMessage(
t[ t[
"Sending the match request failed. Please check your connection and try again." "Sending the match request failed. Please check your connection and try again."
@ -403,10 +421,7 @@ export default function QuestionsListPage() {
} }
}, [ }, [
startMatchMutation.isError, startMatchMutation.isError,
isSyncError,
t[
"Sending the match request failed. Please check your connection and try again."
],
t,
]); ]);
const handleCloseToast = () => { 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[]; questions: readonly QuestionField[];
slug: string; slug: string;
locale?: string; locale?: string;
schemaVersion?: number;
}; };
const QuestionAnswersContext = const QuestionAnswersContext =
@ -303,7 +302,6 @@ export function QuestionAnswersProvider({
questions, questions,
slug, slug,
locale = "en", locale = "en",
schemaVersion = 1,
}: QuestionAnswersProviderProps) { }: QuestionAnswersProviderProps) {
const storageKey = useMemo(() => getQuestionAnswersStorageKey(slug), [slug]); const storageKey = useMemo(() => getQuestionAnswersStorageKey(slug), [slug]);
const queryClient = useQueryClient(); const queryClient = useQueryClient();
@ -319,7 +317,6 @@ export function QuestionAnswersProvider({
const storageKeyRef = useRef(storageKey); const storageKeyRef = useRef(storageKey);
const slugRef = useRef(slug); const slugRef = useRef(slug);
const backendFieldsRef = useRef<MarriageField[]>([]); const backendFieldsRef = useRef<MarriageField[]>([]);
const schemaVersionRef = useRef(schemaVersion);
const dirtyKeysRef = useRef(new Set<string>()); const dirtyKeysRef = useRef(new Set<string>());
const { data: profile } = useMarriageProfileQuery(); const { data: profile } = useMarriageProfileQuery();
@ -334,16 +331,11 @@ export function QuestionAnswersProvider({
slugRef.current = slug; slugRef.current = slug;
questionsRef.current = questions; questionsRef.current = questions;
backendFieldsRef.current = serverSectionData?.data || []; backendFieldsRef.current = serverSectionData?.data || [];
schemaVersionRef.current = Math.max(
schemaVersionRef.current,
schemaVersion,
);
}, [ }, [
answers, answers,
hasPendingSync, hasPendingSync,
slug, slug,
questions, questions,
schemaVersion,
serverSectionData?.data, serverSectionData?.data,
]); ]);
@ -453,10 +445,16 @@ export function QuestionAnswersProvider({
question.type === "textarea" || question.type === "textarea" ||
question.type === "number"; question.type === "number";
const delay = isTextLike ? 1000 : 0;
// The date wheel applies its value on every scroll settle; debounce
// it like text inputs so each spin does not fire a PATCH.
const isDebounced = isTextLike || question.type === "date";
const delay = isDebounced ? 1000 : 0;
syncTimeoutRef.current = setTimeout(() => { syncTimeoutRef.current = setTimeout(() => {
void flushAnswersRef.current();
void flushAnswersRef.current().catch(() => {
// The draft stays pending so the next edit or exit can retry.
});
}, delay); }, delay);
return nextAnswers; return nextAnswers;
@ -491,7 +489,6 @@ export function QuestionAnswersProvider({
const payload = { const payload = {
...fullPayload, ...fullPayload,
fields: pendingFields, fields: pendingFields,
version: schemaVersionRef.current,
}; };
const revision = answersRevisionRef.current; const revision = answersRevisionRef.current;
@ -500,9 +497,6 @@ export function QuestionAnswersProvider({
} }
const request = mutateAsync(payload).then((result) => { const request = mutateAsync(payload).then((result) => {
if (result?.version) {
schemaVersionRef.current = result.version;
}
const cleared = new Set(result?.cleared_answer_ids ?? []); const cleared = new Set(result?.cleared_answer_ids ?? []);
const nextAnswers = { ...answersRef.current }; const nextAnswers = { ...answersRef.current };
const questionById = new Map( const questionById = new Map(
@ -621,7 +615,6 @@ export function QuestionAnswersProvider({
fetch(getKeepalivePatchUrl(slugRef.current), { fetch(getKeepalivePatchUrl(slugRef.current), {
body: JSON.stringify({ body: JSON.stringify({
version: schemaVersionRef.current,
answers: answersPayload, answers: answersPayload,
}), }),
credentials: "include", credentials: "include",

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

@ -1,12 +1,16 @@
"use client"; "use client";
import { useCallback, useEffect, useRef, useState } from "react"; import { useCallback, useEffect, useRef, useState } from "react";
import { createPortal } from "react-dom";
import { getCountryList, COUNTRIES_EN, COUNTRIES_FA } from "@/data/countries"; import { getCountryList, COUNTRIES_EN, COUNTRIES_FA } from "@/data/countries";
import type { QuestionField } from "@/lib/schema-adapter"; import type { QuestionField } from "@/lib/schema-adapter";
import { useI18n } from "@/translations/provider"; import { useI18n } from "@/translations/provider";
import { useQuestionAnswers } from "./question-answer-storage"; import { useQuestionAnswers } from "./question-answer-storage";
import QuestionTitle from "./question-title"; import QuestionTitle from "./question-title";
import { LoadingThreeDot } from "./loading-three-dot"; import { LoadingThreeDot } from "./loading-three-dot";
import { useSheetScrollLock } from "./use-sheet-scroll-lock";
const EXIT_ANIMATION_MS = 220;
type QuestionBirthplaceProps = { type QuestionBirthplaceProps = {
question: QuestionField; question: QuestionField;
@ -88,10 +92,12 @@ export function QuestionBirthplace({
const [cityInput, setCityInput] = useState(initial.city); const [cityInput, setCityInput] = useState(initial.city);
const [isOpen, setIsOpen] = useState(false); const [isOpen, setIsOpen] = useState(false);
const [isClosing, setIsClosing] = useState(false);
const [searchQuery, setSearchQuery] = useState(""); const [searchQuery, setSearchQuery] = useState("");
const containerRef = useRef<HTMLDivElement>(null);
const listRef = useRef<HTMLDivElement>(null);
const searchInputRef = useRef<HTMLInputElement>(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; const isResidence = question.ui_config?.enable_geoip === true;
@ -99,6 +105,57 @@ export function QuestionBirthplace({
const [isDetecting, setIsDetecting] = useState(false); const [isDetecting, setIsDetecting] = useState(false);
const [detectedLocation, setDetectedLocation] = useState(""); const [detectedLocation, setDetectedLocation] = useState("");
useSheetScrollLock(isOpen);
useEffect(() => {
isMountedRef.current = true;
return () => {
isMountedRef.current = false;
};
}, []);
const closeSheet = useCallback(() => {
if (isClosing) return;
setIsClosing(true);
window.setTimeout(() => {
if (isMountedRef.current) {
setIsOpen(false);
setIsClosing(false);
setSearchQuery("");
}
}, EXIT_ANIMATION_MS);
}, [isClosing]);
const openSheet = useCallback(() => {
if (disabled) return;
setIsOpen(true);
setIsClosing(false);
}, [disabled]);
// Handle escape key
useEffect(() => {
if (!isOpen) return;
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === "Escape") {
closeSheet();
}
};
window.addEventListener("keydown", handleKeyDown);
return () => window.removeEventListener("keydown", handleKeyDown);
}, [isOpen, closeSheet]);
// 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 updateAnswers = (country: string, city: string) => {
const formatted = const formatted =
city && country ? `${city}, ${country}` : city || country || null; city && country ? `${city}, ${country}` : city || country || null;
@ -199,9 +256,12 @@ export function QuestionBirthplace({
localStorage.setItem("hasCheckedGeoIPResidence", "true"); localStorage.setItem("hasCheckedGeoIPResidence", "true");
} }
setMode("manual"); setMode("manual");
setSelectedCountry("");
setCityInput("");
updateAnswers("", "");
const parsed = parseValue(rawValue);
const country = selectedCountry || parsed.country;
const city = cityInput || parsed.city;
setSelectedCountry(country);
setCityInput(city);
updateAnswers(country, city);
}; };
// Synchronize state if rawValue changes externally // Synchronize state if rawValue changes externally
@ -218,62 +278,6 @@ export function QuestionBirthplace({
} }
}, [rawValue]); }, [rawValue]);
// Lock page scroll when dropdown is open
useEffect(() => {
if (isOpen) {
document.body.classList.add("dropdown-open");
document.body.style.overflow = "hidden";
document.documentElement.style.overflow = "hidden";
} else {
document.body.classList.remove("dropdown-open");
document.body.style.overflow = "";
document.documentElement.style.overflow = "";
}
return () => {
document.body.classList.remove("dropdown-open");
document.body.style.overflow = "";
document.documentElement.style.overflow = "";
};
}, [isOpen]);
useEffect(() => {
if (isOpen) {
const timer = setTimeout(() => {
searchInputRef.current?.focus();
}, 50);
return () => clearTimeout(timer);
}
}, [isOpen]);
const handleListWheel = useCallback((e: React.WheelEvent<HTMLDivElement>) => {
e.stopPropagation();
const el = listRef.current;
if (!el) return;
const atTop = el.scrollTop <= 0 && e.deltaY < 0;
const atBottom =
el.scrollTop + el.clientHeight >= el.scrollHeight && e.deltaY > 0;
if (atTop || atBottom) {
e.preventDefault();
}
}, []);
useEffect(() => {
function handleClickOutside(event: MouseEvent) {
if (
containerRef.current &&
!containerRef.current.contains(event.target as Node)
) {
setIsOpen(false);
}
}
document.addEventListener("mousedown", handleClickOutside);
return () => {
document.removeEventListener("mousedown", handleClickOutside);
};
}, []);
const options = getCountryList(locale); const options = getCountryList(locale);
const filteredOptions = options.filter((option) => const filteredOptions = options.filter((option) =>
option.toLowerCase().includes(searchQuery.toLowerCase()), option.toLowerCase().includes(searchQuery.toLowerCase()),
@ -284,9 +288,11 @@ export function QuestionBirthplace({
localStorage.setItem("hasCheckedGeoIPResidence", "true"); localStorage.setItem("hasCheckedGeoIPResidence", "true");
} }
setSelectedCountry(country); setSelectedCountry(country);
setCityInput("");
setIsOpen(false);
updateAnswers(country, "");
closeSheet();
updateAnswers(country, cityInput);
window.setTimeout(() => {
cityInputRef.current?.focus({ preventScroll: true });
}, EXIT_ANIMATION_MS);
}; };
const handleCityChange = (e: React.ChangeEvent<HTMLInputElement>) => { const handleCityChange = (e: React.ChangeEvent<HTMLInputElement>) => {
@ -304,9 +310,24 @@ export function QuestionBirthplace({
question.extras?.placeHolder || question.extras?.placeHolder ||
(isRtl ? "شهر، منطقه یا محله" : "City, region, or neighborhood"); (isRtl ? "شهر، منطقه یا محله" : "City, region, or neighborhood");
const searchPlaceholder =
locale === "fa"
? "جستجو..."
: locale === "ar"
? "بحث..."
: locale === "tr"
? "Ara..."
: "Search...";
const noResultsText =
locale === "fa"
? "موردی یافت نشد"
: locale === "ar"
? "لم يتم العثور على نتائج"
: "No options found";
return ( return (
<div <div
ref={containerRef}
className={[ className={[
"relative flex w-full flex-col gap-3 transition-opacity duration-200", "relative flex w-full flex-col gap-3 transition-opacity duration-200",
disabled ? "pointer-events-none opacity-30" : "", disabled ? "pointer-events-none opacity-30" : "",
@ -425,12 +446,12 @@ export function QuestionBirthplace({
{mode === "manual" && ( {mode === "manual" && (
<div className="flex flex-col gap-3 w-full animate-in fade-in slide-in-from-top-2 duration-200"> <div className="flex flex-col gap-3 w-full animate-in fade-in slide-in-from-top-2 duration-200">
{/* Country Selection Dropdown */}
{/* Country Selection Trigger */}
<div className="relative w-full"> <div className="relative w-full">
<button <button
type="button" type="button"
disabled={disabled} disabled={disabled}
onClick={() => setIsOpen(!isOpen)}
onClick={openSheet}
className={[ className={[
"flex h-[54px] w-full items-center justify-between rounded-[16px] border bg-white px-4.5 text-start transition-all cursor-pointer outline-none", "flex h-[54px] w-full items-center justify-between rounded-[16px] border bg-white px-4.5 text-start transition-all cursor-pointer outline-none",
isOpen isOpen
@ -465,100 +486,14 @@ export function QuestionBirthplace({
/> />
</svg> </svg>
</button> </button>
{isOpen && (
<div className="absolute top-[calc(100%+8px)] left-0 z-50 flex w-full flex-col gap-3.5 rounded-[20px] bg-white p-4.5 shadow-[0_12px_36px_rgba(0,0,0,0.09)] border border-[#EAECF0] animate-in fade-in zoom-in-95 duration-150">
{options.length > 3 || searchQuery ? (
<div className="flex h-[46px] w-full items-center gap-2.5 rounded-[12px] bg-[#EFEFEF] px-3.5 transition-colors focus-within:bg-[#E8E8E8]">
<svg
width="18"
height="18"
viewBox="0 0 18 18"
fill="none"
className="shrink-0 text-[#667085]"
>
<path
d="M8.25 14.25C11.5637 14.25 14.25 11.5637 14.25 8.25C14.25 4.93629 11.5637 2.25 8.25 2.25C4.93629 2.25 2.25 4.93629 2.25 8.25C2.25 11.5637 4.93629 14.25 8.25 14.25Z"
stroke="#667085"
strokeWidth="1.75"
strokeLinecap="round"
strokeLinejoin="round"
/>
<path
d="M15.75 15.75L12.5 12.5"
stroke="#667085"
strokeWidth="1.75"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
<input
ref={searchInputRef}
type="text"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
placeholder="Search..."
className="flex-1 bg-transparent text-[14px] font-medium text-[#181818] outline-none placeholder:text-[#98A2B3]"
/>
{searchQuery ? (
<button
type="button"
onClick={() => setSearchQuery("")}
className="text-[#667085] hover:text-[#181818] text-xs font-semibold"
>
</button>
) : null}
</div>
) : null}
<div
ref={listRef}
onWheel={handleListWheel}
onTouchStart={(e) => e.stopPropagation()}
onTouchMove={(e) => e.stopPropagation()}
onTouchEnd={(e) => e.stopPropagation()}
className="flex max-h-[220px] flex-col gap-3.5 overflow-y-auto overscroll-contain pr-1"
>
{filteredOptions.length > 0 ? (
filteredOptions.map((option) => {
const isSelected = selectedCountry === option;
return (
<button
key={option}
type="button"
onClick={() => handleSelectCountry(option)}
className="flex w-full items-center gap-3 text-start cursor-pointer group/opt py-0.5"
>
<div
className={[
"size-[20px] shrink-0 rounded-full transition-all duration-150",
isSelected
? "bg-[#F2465F] shadow-xs"
: "border-[2px] border-[#344054] bg-transparent group-hover/opt:border-[#181818]",
].join(" ")}
/>
<span className="text-[15px] font-bold text-[#181818]">
{option}
</span>
</button>
);
})
) : (
<span className="py-2 text-[13px] text-[#667085]">
No options found
</span>
)}
</div>
</div>
)}
</div> </div>
{/* City Text Input */} {/* City Text Input */}
<div className="w-full"> <div className="w-full">
<input <input
ref={cityInputRef}
type="text" type="text"
data-no-auto-focus
disabled={disabled} disabled={disabled}
value={cityInput} value={cityInput}
onChange={handleCityChange} onChange={handleCityChange}
@ -571,12 +506,12 @@ export function QuestionBirthplace({
</> </>
) : ( ) : (
<> <>
{/* 1. Country Selection Dropdown */}
{/* 1. Country Selection Trigger */}
<div className="relative w-full"> <div className="relative w-full">
<button <button
type="button" type="button"
disabled={disabled} disabled={disabled}
onClick={() => setIsOpen(!isOpen)}
onClick={openSheet}
className={[ className={[
"flex h-[54px] w-full items-center justify-between rounded-[16px] border bg-white px-4.5 text-start transition-all cursor-pointer outline-none", "flex h-[54px] w-full items-center justify-between rounded-[16px] border bg-white px-4.5 text-start transition-all cursor-pointer outline-none",
isOpen isOpen
@ -611,12 +546,89 @@ export function QuestionBirthplace({
/> />
</svg> </svg>
</button> </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 <svg
aria-hidden="true"
width="18" width="18"
height="18" height="18"
viewBox="0 0 18 18" viewBox="0 0 18 18"
@ -643,28 +655,25 @@ export function QuestionBirthplace({
type="text" type="text"
value={searchQuery} value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)} 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]" className="flex-1 bg-transparent text-[14px] font-medium text-[#181818] outline-none placeholder:text-[#98A2B3]"
/> />
{searchQuery ? ( {searchQuery ? (
<button <button
type="button" type="button"
onClick={() => setSearchQuery("")} 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> </button>
) : null} ) : null}
</div> </div>
) : null}
</div>
{/* Country Options List */}
<div <div
ref={listRef} 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.length > 0 ? (
filteredOptions.map((option) => { filteredOptions.map((option) => {
@ -675,44 +684,42 @@ export function QuestionBirthplace({
key={option} key={option}
type="button" type="button"
onClick={() => handleSelectCountry(option)} 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 <div
className={[ 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 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(" ")} ].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} {option}
</span> </span>
</button> </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>
)} )}
</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> </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"; "use client";
import { useEffect, useMemo, useState } from "react";
import { useMemo, useState } from "react";
import type { QuestionField } from "@/lib/schema-adapter"; import type { QuestionField } from "@/lib/schema-adapter";
import { useI18n } from "@/translations/provider"; import { useI18n } from "@/translations/provider";
import { useQuestionAnswers } from "./question-answer-storage"; import { useQuestionAnswers } from "./question-answer-storage";
import QuestionDateSheet from "./question-date-sheet";
import QuestionTitle from "./question-title"; import QuestionTitle from "./question-title";
type QuestionDateProps = { type QuestionDateProps = {
@ -11,80 +12,35 @@ type QuestionDateProps = {
disabled?: boolean; disabled?: boolean;
}; };
const MONTH_VALUES = [
"01",
"02",
"03",
"04",
"05",
"06",
"07",
"08",
"09",
"10",
"11",
"12",
];
const DAYS = Array.from({ length: 31 }, (_, i) => {
const num = i + 1;
const val = num < 10 ? `0${num}` : `${num}`;
return { value: val, label: `${num}` };
});
const MIN_AGE = 18; const MIN_AGE = 18;
const currentYear = new Date().getFullYear();
const maxBirthYear = currentYear - MIN_AGE;
const YEARS = Array.from({ length: 80 }, (_, i) =>
(maxBirthYear - i).toString(),
);
export function QuestionDate({
question,
disabled,
}: QuestionDateProps) {
const { locale } = useI18n();
function parseDateParts(dateValue: string): {
year: string;
month: string;
day: string;
} {
const parts = dateValue ? dateValue.split("-") : [];
return {
year: parts[0] || "",
month: parts[1] || "",
day: parts[2] || "",
};
}
export function QuestionDate({ question, disabled }: QuestionDateProps) {
const { dictionary: t, locale } = useI18n();
const { getAnswerValue, setAnswerValue } = useQuestionAnswers(); const { getAnswerValue, setAnswerValue } = useQuestionAnswers();
const value = getAnswerValue(question); const value = getAnswerValue(question);
const dateValue = typeof value === "string" ? value : ""; const dateValue = typeof value === "string" ? value : "";
const { year, month, day } = parseDateParts(dateValue);
const months = useMemo(() => {
return MONTH_VALUES.map((val, idx) => {
const date = new Date(2000, idx, 15);
const label = new Intl.DateTimeFormat(`${locale}-u-ca-gregory`, {
month: "long",
}).format(date);
return { value: val, label };
});
}, [locale]);
const [selectedYear, setSelectedYear] = useState(() => {
const parts = dateValue ? dateValue.split("-") : [];
return parts[0] || "";
});
const [selectedMonth, setSelectedMonth] = useState(() => {
const parts = dateValue ? dateValue.split("-") : [];
return parts[1] || "";
});
const [selectedDay, setSelectedDay] = useState(() => {
const parts = dateValue ? dateValue.split("-") : [];
return parts[2] || "";
});
useEffect(() => {
const parts = dateValue ? dateValue.split("-") : [];
if (parts.length === 3) {
setSelectedYear(parts[0] || "");
setSelectedMonth(parts[1] || "");
setSelectedDay(parts[2] || "");
}
}, [dateValue]);
const [isOpen, setIsOpen] = useState(false);
const calculatedAge = useMemo(() => { const calculatedAge = useMemo(() => {
if (!selectedYear || !selectedMonth || !selectedDay) return null;
const y = Number.parseInt(selectedYear, 10);
const m = Number.parseInt(selectedMonth, 10);
const d = Number.parseInt(selectedDay, 10);
if (!year || !month || !day) return null;
const y = Number.parseInt(year, 10);
const m = Number.parseInt(month, 10);
const d = Number.parseInt(day, 10);
if ( if (
!y || !y ||
@ -108,37 +64,35 @@ export function QuestionDate({
} }
return age >= 0 ? age : null; return age >= 0 ? age : null;
}, [selectedYear, selectedMonth, selectedDay]);
}, [year, month, day]);
const isUnder18 = calculatedAge !== null && calculatedAge < MIN_AGE; const isUnder18 = calculatedAge !== null && calculatedAge < MIN_AGE;
const updateDate = (y: string, m: string, d: string) => {
setSelectedYear(y);
setSelectedMonth(m);
setSelectedDay(d);
if (y && m && d) {
const formattedMonth = m.padStart(2, "0");
const formattedDay = d.padStart(2, "0");
setAnswerValue(
question, `${y}-${formattedMonth}-${formattedDay}`,
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 ( return (
<div <div
@ -149,69 +103,70 @@ export function QuestionDate({
> >
<QuestionTitle question={question} /> <QuestionTitle question={question} />
{/* 3 Select Dropdowns: Day, Month, Year */}
<div className="grid grid-cols-3 gap-2">
{/* Day / روز */}
<select
value={selectedDay}
onChange={(e) => handleDayChange(e.target.value)}
{/* Date Trigger Field (single input, like the Flutter app's DOB field) */}
<button
type="button"
disabled={disabled} 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"> <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> </span>
<input <input
type="text" type="text"
disabled disabled
readOnly readOnly
value={calculatedAge !== null ? calculatedAge : ""}
value={calculatedAge}
className={[ className={[
"h-[54px] w-full rounded-[16px] border px-4.5 text-[15px] font-medium outline-none cursor-not-allowed disabled:bg-[#F5F2F1] disabled:text-[#7C7472]",
"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 isUnder18
? "border-[#F2465F] text-[#F2465F] ring-1 ring-[#F2465F]" ? "border-[#F2465F] text-[#F2465F] ring-1 ring-[#F2465F]"
: "border-[#D0D5DD] text-[#7C7472]", : "border-[#D0D5DD] text-[#7C7472]",
@ -225,6 +180,16 @@ export function QuestionDate({
</span> </span>
) : null} ) : null}
</div> </div>
) : null}
{isOpen && (
<QuestionDateSheet
question={question}
value={dateValue}
onApply={handleApply}
onClose={() => setIsOpen(false)}
/>
)}
</div> </div>
); );
} }

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

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

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

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

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

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

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

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 = { export type UpdateMarriageSectionDataPayload = {
version?: number;
current_step: number; current_step: number;
total_steps?: number; total_steps?: number;
fields: MarriageField[]; fields: MarriageField[];
@ -163,7 +162,6 @@ export type MarriageQuestionState = {
}; };
export type ProfileAnswersPatchResult = { export type ProfileAnswersPatchResult = {
version: number;
answers: Record< answers: Record<
string, string,
{ value: MarriageFieldValue; option_id?: string | 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 { export interface FormSchemaResponse {
form_id: string; form_id: string;
version: number;
sections: FormSection[]; sections: FormSection[];
answers: Record<string, { value: any; option_id?: string | string[] }>; answers: Record<string, { value: any; option_id?: string | string[] }>;
progress: { progress: {
@ -78,14 +77,12 @@ export interface FormOverviewSection extends Omit<FormSection, "cards"> {
export interface FormOverviewResponse { export interface FormOverviewResponse {
form_id: string; form_id: string;
version: number;
sections: FormOverviewSection[]; sections: FormOverviewSection[];
progress: FormSchemaResponse["progress"]; progress: FormSchemaResponse["progress"];
} }
export interface FormSectionResponse { export interface FormSectionResponse {
form_id: string; form_id: string;
version: number;
section: FormSection; section: FormSection;
answers: FormSchemaResponse["answers"]; answers: FormSchemaResponse["answers"];
progress: FormSchemaResponse["progress"]; progress: FormSchemaResponse["progress"];
@ -162,7 +159,6 @@ export function useFormSchemaQuery<TData = FormSchemaResponse>(
} }
export interface SaveAnswersPayload { export interface SaveAnswersPayload {
version: number;
answers: Array<{ answers: Array<{
question_id: string; question_id: string;
value: any; value: any;

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

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

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

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

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

@ -6,7 +6,6 @@ describe("convertOverviewToFrontendItems", () => {
it("maps overview metadata without materializing questions", () => { it("maps overview metadata without materializing questions", () => {
const overview: FormOverviewResponse = { const overview: FormOverviewResponse = {
form_id: "profile", form_id: "profile",
version: 2,
progress: { progress: {
current_step: 1, current_step: 1,
total_steps: 2, 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:", "Please select the reason for cancellation:": "Please select the reason for cancellation:",
"Swipe to continue": "Swipe to continue", "Swipe to continue": "Swipe to continue",
"Swipe to confirm cancellation": "Swipe to confirm cancellation", "Swipe to confirm cancellation": "Swipe to confirm cancellation",
"Failed to update profile basic details. Please try again.": "Failed to update profile basic details. Please try again."
"Failed to update profile basic details. Please try again.": "Failed to update profile basic details. Please try again.",
"Select date": "اختر التاريخ",
"Day": "اليوم",
"Month": "الشهر",
"Year": "السنة"
} }

6
src/translations/locales/az.json

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

6
src/translations/locales/bn.json

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

6
src/translations/locales/da.json

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

6
src/translations/locales/de.json

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

6
src/translations/locales/en.json

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

6
src/translations/locales/es.json

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

6
src/translations/locales/fa.json

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

6
src/translations/locales/fr.json

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

6
src/translations/locales/gu.json

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

6
src/translations/locales/ha.json

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

8
src/translations/locales/he.json

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

6
src/translations/locales/hi.json

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

8
src/translations/locales/id.json

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

8
src/translations/locales/ks.json

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

8
src/translations/locales/pt.json

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

6
src/translations/locales/ru.json

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

8
src/translations/locales/sw.json

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

8
src/translations/locales/tg.json

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

8
src/translations/locales/tr.json

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

8
src/translations/locales/ul.json

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

8
src/translations/locales/ur.json

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

8
src/translations/locales/uz.json

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

6
src/translations/locales/zh.json

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