Muhammad A. Ghorbani 2 weeks ago
parent
commit
e70f2bee82
  1. 18
      next.config.ts
  2. 3
      package.json
  3. BIN
      public/assets/fonts/ArabicYekanXRegular.woff2
  4. BIN
      public/assets/fonts/Faminela.woff2
  5. BIN
      public/assets/fonts/NotoNastaliqUrdu.woff2
  6. BIN
      public/assets/fonts/SegoeUIBold.woff2
  7. BIN
      public/assets/fonts/YekanXFaNum-R.woff2
  8. BIN
      public/assets/fonts/faminela.woff2
  9. BIN
      public/assets/fonts/segoeui.woff2
  10. BIN
      public/fonts/Amiri/Amiri-Bold.woff2
  11. BIN
      public/fonts/Amiri/Amiri-BoldItalic.woff2
  12. BIN
      public/fonts/Amiri/Amiri-Italic.woff2
  13. BIN
      public/fonts/Amiri/Amiri-Regular.woff2
  14. BIN
      public/fonts/Faminela/Faminela.woff2
  15. BIN
      public/fonts/segoeui/SegoeUIBold.woff2
  16. BIN
      public/fonts/segoeui/segoeui.woff2
  17. BIN
      public/fonts/urdu/NotoNastaliqUrdu.woff2
  18. BIN
      public/fonts/yekanx-arabic/ArabicYekanXRegular.woff2
  19. BIN
      public/fonts/yekanx/YekanXFaNum-R.woff2
  20. 27
      src/app/globals.css
  21. 14
      src/app/layout.tsx
  22. 87
      src/app/questions-list/[slug]/answer-pace-sheet.test.tsx
  23. 20
      src/app/questions-list/[slug]/question-detail-client.tsx
  24. 11
      src/app/questions-list/questions-list-client.tsx
  25. 93
      src/app/sheet-lab/page.tsx
  26. 108
      src/components/Componentes/information-sheet.test.tsx
  27. 131
      src/components/Componentes/information-sheet.tsx
  28. 16
      src/components/Componentes/question-checkbox.tsx
  29. 17
      src/components/Componentes/question-dropdown.tsx
  30. 4
      src/components/Componentes/question-progress-tracker.tsx
  31. 350
      src/components/Componentes/question-sheet.test.tsx
  32. 373
      src/components/Componentes/question-sheet.tsx
  33. 191
      src/components/Componentes/terms-sheet.test.tsx
  34. 416
      src/components/Componentes/terms-sheet.tsx
  35. 32
      src/components/Componentes/ui-icon.tsx
  36. 255
      src/data/country-calling-codes.ts
  37. 10
      src/hooks/marriage/use-form-schema.ts
  38. 37
      src/lib/auth-bridge.test.ts
  39. 10
      src/lib/geo-region.ts
  40. 119
      src/lib/marriage-cookie.test.ts
  41. 132
      src/lib/multi-select-helper.test.ts
  42. 76
      src/lib/multi-select-helper.ts
  43. 2
      src/lib/schema-adapter.ts

18
next.config.ts

@ -82,16 +82,12 @@ const nextConfig: NextConfig = {
], ],
}, },
{ {
// Application HTML pages – no-store to prevent HTML/shell caching in WebView
// Application HTML shell – App Shell + Stale-While-Revalidate pattern for instant WebView rendering
source: "/:path((?!api/|_next/|fonts/|assets/).*)", source: "/:path((?!api/|_next/|fonts/|assets/).*)",
headers: [ headers: [
{ {
key: "Cache-Control", key: "Cache-Control",
value: "no-store, no-cache, must-revalidate, max-age=0",
},
{
key: "Pragma",
value: "no-cache",
value: "public, max-age=0, stale-while-revalidate=86400",
}, },
], ],
}, },
@ -115,6 +111,16 @@ const nextConfig: NextConfig = {
}, },
], ],
}, },
{
// Static fonts inside assets directory are immutable
source: "/assets/fonts/:path*",
headers: [
{
key: "Cache-Control",
value: "public, max-age=31536000, immutable",
},
],
},
]; ];
}, },
}; };

3
package.json

@ -16,8 +16,7 @@
"google-libphonenumber": "^3.2.44", "google-libphonenumber": "^3.2.44",
"next": "^16.2.10", "next": "^16.2.10",
"react": "19.2.4", "react": "19.2.4",
"react-dom": "19.2.4",
"react-icons": "^5.6.0"
"react-dom": "19.2.4"
}, },
"devDependencies": { "devDependencies": {
"@babel/core": "^8.0.1", "@babel/core": "^8.0.1",

BIN
public/assets/fonts/ArabicYekanXRegular.woff2

BIN
public/assets/fonts/Faminela.woff2

BIN
public/assets/fonts/NotoNastaliqUrdu.woff2

BIN
public/assets/fonts/SegoeUIBold.woff2

BIN
public/assets/fonts/YekanXFaNum-R.woff2

BIN
public/assets/fonts/faminela.woff2

BIN
public/assets/fonts/segoeui.woff2

BIN
public/fonts/Amiri/Amiri-Bold.woff2

BIN
public/fonts/Amiri/Amiri-BoldItalic.woff2

BIN
public/fonts/Amiri/Amiri-Italic.woff2

BIN
public/fonts/Amiri/Amiri-Regular.woff2

BIN
public/fonts/Faminela/Faminela.woff2

BIN
public/fonts/segoeui/SegoeUIBold.woff2

BIN
public/fonts/segoeui/segoeui.woff2

BIN
public/fonts/urdu/NotoNastaliqUrdu.woff2

BIN
public/fonts/yekanx-arabic/ArabicYekanXRegular.woff2

BIN
public/fonts/yekanx/YekanXFaNum-R.woff2

27
src/app/globals.css

@ -3,7 +3,9 @@
/* ─── App fonts matching Flutter Najm & Hosseinieh ─── */ /* ─── App fonts matching Flutter Najm & Hosseinieh ─── */
@font-face { @font-face {
font-family: 'Faminela'; font-family: 'Faminela';
src: url('/fonts/Faminela/Faminela.otf') format('opentype'),
src: url('/fonts/Faminela/Faminela.woff2') format('woff2'),
url('/assets/fonts/Faminela.woff2') format('woff2'),
url('/fonts/Faminela/Faminela.otf') format('opentype'),
url('/assets/fonts/Faminela.otf') format('opentype'), url('/assets/fonts/Faminela.otf') format('opentype'),
url('/assets/fonts/faminela.ttf') format('truetype'); url('/assets/fonts/faminela.ttf') format('truetype');
font-weight: 400 900; font-weight: 400 900;
@ -12,35 +14,40 @@
} }
@font-face { @font-face {
font-family: 'YekanX'; font-family: 'YekanX';
src: url('/assets/fonts/YekanXFaNum-R.ttf') format('truetype');
src: url('/assets/fonts/YekanXFaNum-R.woff2') format('woff2'),
url('/assets/fonts/YekanXFaNum-R.ttf') format('truetype');
font-weight: 400; font-weight: 400;
font-style: normal; font-style: normal;
font-display: swap; font-display: swap;
} }
@font-face { @font-face {
font-family: 'YekanXArabic'; font-family: 'YekanXArabic';
src: url('/assets/fonts/ArabicYekanXRegular.ttf') format('truetype');
src: url('/assets/fonts/ArabicYekanXRegular.woff2') format('woff2'),
url('/assets/fonts/ArabicYekanXRegular.ttf') format('truetype');
font-weight: 400; font-weight: 400;
font-style: normal; font-style: normal;
font-display: swap; font-display: swap;
} }
@font-face { @font-face {
font-family: 'NastaliqUrdu'; font-family: 'NastaliqUrdu';
src: url('/assets/fonts/NotoNastaliqUrdu.ttf') format('truetype');
src: url('/assets/fonts/NotoNastaliqUrdu.woff2') format('woff2'),
url('/assets/fonts/NotoNastaliqUrdu.ttf') format('truetype');
font-weight: 400; font-weight: 400;
font-style: normal; font-style: normal;
font-display: swap; font-display: swap;
} }
@font-face { @font-face {
font-family: 'Segoeui'; font-family: 'Segoeui';
src: url('/assets/fonts/segoeui.ttf') format('truetype');
src: url('/assets/fonts/segoeui.woff2') format('woff2'),
url('/assets/fonts/segoeui.ttf') format('truetype');
font-weight: 400 500; font-weight: 400 500;
font-style: normal; font-style: normal;
font-display: swap; font-display: swap;
} }
@font-face { @font-face {
font-family: 'Segoeui'; font-family: 'Segoeui';
src: url('/assets/fonts/SegoeUIBold.ttf') format('truetype');
src: url('/assets/fonts/SegoeUIBold.woff2') format('woff2'),
url('/assets/fonts/SegoeUIBold.ttf') format('truetype');
font-weight: 600 900; font-weight: 600 900;
font-style: normal; font-style: normal;
font-display: swap; font-display: swap;
@ -439,6 +446,14 @@ body.section-overlay-open .app-shell {
pointer-events: none; pointer-events: none;
} }
/* Flutter DraggableScrollableSheet Parity:
Height is scaled against available screen below system status bar,
stopping strictly underneath safe-top (MediaQuery.padding.top in Flutter). */
.flutter-draggable-sheet {
height: calc(var(--sheet-size, 0.75) * (100svh - max(var(--safe-top, 0px), env(safe-area-inset-top, 0px))));
max-height: calc(100svh - max(var(--safe-top, 0px), env(safe-area-inset-top, 0px)));
}
/* ── Section Overlay Slide-in Panel (Exact Flutter Najm Matching: 350ms in, 200ms out, easeInOut) ── */ /* ── Section Overlay Slide-in Panel (Exact Flutter Najm Matching: 350ms in, 200ms out, easeInOut) ── */
.section-overlay { .section-overlay {
position: fixed; position: fixed;

14
src/app/layout.tsx

@ -102,6 +102,20 @@ export default async function RootLayout({
/> />
<link rel="preconnect" href="https://habibapp.com" /> <link rel="preconnect" href="https://habibapp.com" />
<link rel="dns-prefetch" href="https://habibapp.com" /> <link rel="dns-prefetch" href="https://habibapp.com" />
<link
rel="preload"
href="/fonts/Faminela/Faminela.woff2"
as="font"
type="font/woff2"
crossOrigin="anonymous"
/>
<link
rel="preload"
href="/assets/fonts/YekanXFaNum-R.woff2"
as="font"
type="font/woff2"
crossOrigin="anonymous"
/>
{initialMarriageJson && ( {initialMarriageJson && (
<script <script
id="habib-early-marriage-initial-data" id="habib-early-marriage-initial-data"

87
src/app/questions-list/[slug]/answer-pace-sheet.test.tsx

@ -0,0 +1,87 @@
import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import AnswerPaceSheet, {
isAnswerPaceSheetSeen,
markAnswerPaceSheetSeen,
} from "./answer-pace-sheet";
vi.mock("@/translations/provider", () => ({
useI18n: vi.fn(() => ({
locale: "fa",
dictionary: {
"Information sheet": "شیت اطلاعات",
Close: "بستن",
},
})),
}));
vi.mock("@/lib/first-entry-helper", () => ({
isFirstEntryCompleted: vi.fn(() => false),
}));
describe("AnswerPaceSheet", () => {
beforeEach(() => {
window.localStorage.clear();
});
afterEach(() => {
cleanup();
window.localStorage.clear();
});
it("does not render when activeQuestionIndex is less than 3", () => {
render(
<AnswerPaceSheet
activeQuestionIndex={2}
title="با آرامش پاسخ دهید"
description="می‌توانید هر زمان نظرسنجی را متوقف کرده و بعداً ادامه دهید."
continueLabel="متوجه شدم"
/>,
);
expect(screen.queryByRole("dialog")).toBeNull();
});
it("renders when activeQuestionIndex >= 3 and displays inline play icon synchronously without network img tag", () => {
render(
<AnswerPaceSheet
activeQuestionIndex={3}
title="با آرامش پاسخ دهید"
description="می‌توانید هر زمان نظرسنجی را متوقف کرده و بعداً ادامه دهید."
continueLabel="متوجه شدم"
/>,
);
const dialog = screen.getByRole("dialog");
expect(dialog).toBeDefined();
expect(screen.getByText("با آرامش پاسخ دهید")).toBeDefined();
expect(
screen.getByText(
"می‌توانید هر زمان نظرسنجی را متوقف کرده و بعداً ادامه دهید.",
),
).toBeDefined();
// Critical: must render inline SVG play icon instantly with NO <img> tag
expect(dialog.querySelector("img")).toBeNull();
const playSvg = dialog.querySelector('svg[aria-label="Play"]');
expect(playSvg).not.toBeNull();
expect(playSvg?.getAttribute("viewBox")).toBe("0 0 50 50");
});
it("marks sheet as seen and closes on button click", async () => {
render(
<AnswerPaceSheet
activeQuestionIndex={3}
title="با آرامش پاسخ دهید"
description="می‌توانید هر زمان نظرسنجی را متوقف کرده و بعداً ادامه دهید."
continueLabel="متوجه شدم"
/>,
);
const continueBtn = screen.getByRole("button", { name: "متوجه شدم" });
fireEvent.click(continueBtn);
await waitFor(() => {
expect(isAnswerPaceSheetSeen()).toBe(true);
});
});
});

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

@ -349,8 +349,13 @@ export default function QuestionDetailClient({
} catch {} } catch {}
return false; return false;
}, [isAssessment, overview, itemSlug, profileId]); }, [isAssessment, overview, itemSlug, profileId]);
const { data: sectionResponse, isLoading: isSectionLoading, isError: isSectionError, refetch: refetchSection } =
useFormSectionQuery(
const {
data: sectionResponse,
isLoading: isSectionLoading,
isPending: isSectionPending,
isError: isSectionError,
refetch: refetchSection,
} = useFormSectionQuery(
"profile", "profile",
itemSlug, itemSlug,
locale, locale,
@ -368,14 +373,17 @@ export default function QuestionDetailClient({
sectionResponse.section_progress.completion_percent, sectionResponse.section_progress.completion_percent,
); );
} }
if (isAssessment) {
return overviewItem; return overviewItem;
}
return null;
}, [isAssessment, overviewItem, sectionResponse]); }, [isAssessment, overviewItem, sectionResponse]);
const isSchemaError = isAssessment const isSchemaError = isAssessment
? isOverviewError ? isOverviewError
: (isOverviewError || isSectionError) && !sectionResponse; : (isOverviewError || isSectionError) && !sectionResponse;
const isSchemaLoading = isAssessment const isSchemaLoading = isAssessment
? isOverviewLoading ? isOverviewLoading
: !sectionResponse && (isOverviewLoading || isSectionLoading);
: !sectionResponse && !isSchemaError && (isOverviewLoading || isSectionLoading || isSectionPending);
const cattellQuery = useCattellQuestionsQuery(locale, { const cattellQuery = useCattellQuestionsQuery(locale, {
enabled: isCattellSlug && isTestStarted, enabled: isCattellSlug && isTestStarted,
@ -395,13 +403,13 @@ export default function QuestionDetailClient({
void queryClient.prefetchQuery({ void queryClient.prefetchQuery({
queryKey: marriageQueryKeys.cattellQuestions(locale), queryKey: marriageQueryKeys.cattellQuestions(locale),
queryFn: () => getCattellQuestions(locale), queryFn: () => getCattellQuestions(locale),
staleTime: 30 * 1000,
staleTime: 3 * 60 * 1000,
}); });
} else if (isGlasserSlug) { } else if (isGlasserSlug) {
void queryClient.prefetchQuery({ void queryClient.prefetchQuery({
queryKey: marriageQueryKeys.glasserQuestions(locale), queryKey: marriageQueryKeys.glasserQuestions(locale),
queryFn: () => getGlasserQuestions(locale), queryFn: () => getGlasserQuestions(locale),
staleTime: 30 * 1000,
staleTime: 3 * 60 * 1000,
}); });
} }
}, [ }, [
@ -534,7 +542,7 @@ export default function QuestionDetailClient({
<div className="flex min-h-0 flex-1 items-center justify-center pb-[76px]"> <div className="flex min-h-0 flex-1 items-center justify-center pb-[76px]">
<span <span
role="status" role="status"
className="size-5 animate-spin rounded-full border-2 border-[#E03950]/25 border-t-[#E03950] motion-reduce:animate-none"
className="size-8 animate-spin rounded-full border-[2.5px] border-[#E03950]/25 border-t-[#E03950] motion-reduce:animate-none"
/> />
</div> </div>

11
src/app/questions-list/questions-list-client.tsx

@ -543,13 +543,13 @@ export default function QuestionsListClient({
void queryClient.prefetchQuery({ void queryClient.prefetchQuery({
queryKey: marriageQueryKeys.cattellQuestions(locale), queryKey: marriageQueryKeys.cattellQuestions(locale),
queryFn: () => getCattellQuestions(locale), queryFn: () => getCattellQuestions(locale),
staleTime: 30 * 1000,
staleTime: 3 * 60 * 1000,
}); });
} else { } else {
void queryClient.prefetchQuery({ void queryClient.prefetchQuery({
queryKey: marriageQueryKeys.glasserQuestions(locale), queryKey: marriageQueryKeys.glasserQuestions(locale),
queryFn: () => getGlasserQuestions(locale), queryFn: () => getGlasserQuestions(locale),
staleTime: 30 * 1000,
staleTime: 3 * 60 * 1000,
}); });
} }
return; return;
@ -558,7 +558,7 @@ export default function QuestionsListClient({
void queryClient.prefetchQuery({ void queryClient.prefetchQuery({
queryKey: marriageQueryKeys.formSection("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: 3 * 60 * 1000,
}); });
}, },
[locale, queryClient, router], [locale, queryClient, router],
@ -608,7 +608,7 @@ export default function QuestionsListClient({
void queryClient.prefetchQuery({ void queryClient.prefetchQuery({
queryKey: marriageQueryKeys.formSection("profile", profileSections[0].slug, locale), queryKey: marriageQueryKeys.formSection("profile", profileSections[0].slug, locale),
queryFn: () => getFormSection("profile", profileSections[0].slug, locale), queryFn: () => getFormSection("profile", profileSections[0].slug, locale),
staleTime: 30 * 1000,
staleTime: 3 * 60 * 1000,
}); });
} }
@ -626,7 +626,7 @@ export default function QuestionsListClient({
queryClient.fetchQuery({ queryClient.fetchQuery({
queryKey: marriageQueryKeys.formSection("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: 3 * 60 * 1000,
}), }),
() => cancelled, () => cancelled,
); );
@ -1007,6 +1007,7 @@ export default function QuestionsListClient({
> >
{activeSectionItem ? ( {activeSectionItem ? (
<QuestionDetailClient <QuestionDetailClient
key={activeSectionItem.slug}
closeLabel={t["Close questions list"] ?? "Close"} closeLabel={t["Close questions list"] ?? "Close"}
continueLabel={t["Continue"] ?? "Continue"} continueLabel={t["Continue"] ?? "Continue"}
description={activeSectionItem.summary} description={activeSectionItem.summary}

93
src/app/sheet-lab/page.tsx

@ -0,0 +1,93 @@
"use client";
/**
* Standalone dev page for testing QuestionSheet sizing behavior in a browser.
* No login, no backend: profile query data is seeded into the QueryClient so
* QuestionAnswersProvider never needs the API. Hardcoded questions only.
*/
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { useState } from "react";
import QuestionSheet from "@/components/Componentes/question-sheet";
import { QuestionAnswersProvider } from "@/components/Componentes/question-answer-storage";
import { marriageQueryKeys } from "@/hooks/marriage/query-keys";
import type { QuestionField } from "@/lib/schema-adapter";
import { I18nProvider } from "@/translations/provider";
function makeOptions(count: number, prefix: string): QuestionField["options"] {
return Array.from({ length: count }, (_, index) => ({
id: `${prefix}-${index + 1}`,
value: `${prefix}-${index + 1}`,
label: `${prefix} ${index + 1}`,
order: index + 1,
}));
}
const fourOptionQuestion: QuestionField = {
id: "sheet_lab.four_options",
title: "Sheet Lab — 4 options (compact)",
type: "dropdown",
order: 1,
required: false,
baseRequired: false,
isVisible: true,
description: "",
tooltip: "",
extras: { placeHolder: "Select (4)", range: [0, 1], options: [] },
options: makeOptions(4, "Option"),
ui_config: {},
};
const manyOptionQuestion: QuestionField = {
id: "sheet_lab.many_options",
title: "Sheet Lab — 67 options (draggable)",
type: "dropdown",
order: 2,
required: false,
baseRequired: false,
isVisible: true,
description: "",
tooltip: "",
extras: { placeHolder: "Select (67)", range: [0, 1], options: [] },
options: makeOptions(67, "Item"),
ui_config: {},
};
export default function SheetLabPage() {
const [queryClient] = useState(() => {
const qc = new QueryClient({
defaultOptions: {
queries: {
refetchOnMount: false,
refetchOnWindowFocus: false,
refetchOnReconnect: false,
retry: false,
staleTime: Infinity,
},
},
});
// Seed the profile cache so QuestionAnswersProvider never hits the API.
qc.setQueryData(marriageQueryKeys.profile(), {
id: 1,
can_edit_profile: true,
});
return qc;
});
return (
<I18nProvider locale="en">
<QueryClientProvider client={queryClient}>
<QuestionAnswersProvider
slug="sheet-lab"
questions={[fourOptionQuestion, manyOptionQuestion]}
>
<main className="mx-auto flex max-w-[480px] flex-col gap-8 p-4 pt-10">
<h1 className="text-lg font-bold">Sheet Lab</h1>
<QuestionSheet question={fourOptionQuestion} />
<QuestionSheet question={manyOptionQuestion} />
</main>
</QuestionAnswersProvider>
</QueryClientProvider>
</I18nProvider>
);
}

108
src/components/Componentes/information-sheet.test.tsx

@ -131,5 +131,113 @@ describe("InformationSheet", () => {
expect(section?.style.paddingBottom).toBe(""); expect(section?.style.paddingBottom).toBe("");
}); });
}); });
it("should render inline SVG for play icon preset without network img tag", () => {
render(
<InformationSheet
isOpen={true}
icon="play"
title="Answer at Your Own Pace"
description="Test pace description"
/>,
);
const dialog = screen.getByRole("dialog");
// Should NOT contain an <img> tag for play
expect(dialog.querySelector("img")).toBeNull();
// Should contain inline SVG element with viewBox 0 0 50 50 and aria-label "Play"
const playSvg = dialog.querySelector('svg[aria-label="Play"]');
expect(playSvg).not.toBeNull();
expect(playSvg?.getAttribute("viewBox")).toBe("0 0 50 50");
// Path must include the play button path
const path = playSvg?.querySelector("path");
expect(path?.getAttribute("d")).toContain("M20.8568");
});
it("should render default icon as inline play SVG when icon is omitted", () => {
render(
<InformationSheet
isOpen={true}
title="Default Icon Test"
description="Test description"
/>,
);
const dialog = screen.getByRole("dialog");
expect(dialog.querySelector("img")).toBeNull();
const playSvg = dialog.querySelector('svg[aria-label="Play"]');
expect(playSvg).not.toBeNull();
});
it("should render inline SVGs for warning, check, and diamond presets", () => {
const { rerender } = render(
<InformationSheet
isOpen={true}
icon="warning"
title="Warning Title"
description="Warning Description"
/>,
);
let dialog = screen.getByRole("dialog");
expect(dialog.querySelector("img")).toBeNull();
expect(dialog.querySelector('svg[viewBox="0 0 36 36"]')).not.toBeNull();
rerender(
<InformationSheet
isOpen={true}
icon="check"
title="Check Title"
description="Check Description"
/>,
);
dialog = screen.getByRole("dialog");
expect(dialog.querySelector("img")).toBeNull();
expect(dialog.querySelector('svg[viewBox="0 0 36 36"]')).not.toBeNull();
rerender(
<InformationSheet
isOpen={true}
icon="diamond"
title="Diamond Title"
description="Diamond Description"
/>,
);
dialog = screen.getByRole("dialog");
expect(dialog.querySelector("img")).toBeNull();
expect(dialog.querySelector('svg[viewBox="0 0 24 24"]')).not.toBeNull();
});
it("should render no icon when icon is null", () => {
render(
<InformationSheet
isOpen={true}
icon={null}
title="No Icon Title"
description="No Icon Description"
/>,
);
const dialog = screen.getByRole("dialog");
// No play SVG and no img
expect(dialog.querySelector('svg[aria-label="Play"]')).toBeNull();
expect(dialog.querySelector("img")).toBeNull();
});
it("should render custom ReactNode icon directly", () => {
render(
<InformationSheet
isOpen={true}
icon={<div data-testid="custom-icon">Custom Icon</div>}
title="Custom Icon Title"
description="Custom Icon Description"
/>,
);
expect(screen.getByTestId("custom-icon")).toBeDefined();
expect(screen.getByText("Custom Icon")).toBeDefined();
});
}); });

131
src/components/Componentes/information-sheet.tsx

@ -2,10 +2,11 @@
import Image, { type StaticImageData } from "next/image"; import Image, { type StaticImageData } from "next/image";
import type { HTMLAttributes, ReactNode } from "react"; import type { HTMLAttributes, ReactNode } from "react";
import { useCallback, useEffect, useRef, useState } from "react";
import { isValidElement, useCallback, useEffect, useRef, useState } from "react";
import { createPortal } from "react-dom"; import { createPortal } from "react-dom";
import Button from "./button"; import Button from "./button";
import { LoadingThreeDot } from "./loading-three-dot"; import { LoadingThreeDot } from "./loading-three-dot";
import { UiIcon } from "./ui-icon";
import { useHardwareBackHandler } from "@/hooks/use-hardware-back-handler"; import { useHardwareBackHandler } from "@/hooks/use-hardware-back-handler";
import { viewPaddingsBridge } from "@/lib/view-paddings"; import { viewPaddingsBridge } from "@/lib/view-paddings";
import { useI18n } from "@/translations/provider"; import { useI18n } from "@/translations/provider";
@ -67,7 +68,8 @@ type InformationSheetIcon =
alt?: string; alt?: string;
width?: number; width?: number;
height?: number; height?: number;
};
}
| ReactNode;
export type InformationSheetProps = Omit< export type InformationSheetProps = Omit<
HTMLAttributes<HTMLDivElement>, HTMLAttributes<HTMLDivElement>,
@ -177,7 +179,7 @@ const ICON_PRESETS: Record<
}; };
function resolveIcon(icon: InformationSheetIcon | null | undefined) { function resolveIcon(icon: InformationSheetIcon | null | undefined) {
if (icon === null) {
if (icon === null || isValidElement(icon)) {
return null; return null;
} }
@ -206,6 +208,7 @@ function resolveIcon(icon: InformationSheetIcon | null | undefined) {
); );
} }
if (typeof icon === "object" && "src" in icon) {
return { return {
src: icon.src, src: icon.src,
alt: icon.alt ?? "Information", alt: icon.alt ?? "Information",
@ -214,6 +217,114 @@ function resolveIcon(icon: InformationSheetIcon | null | undefined) {
}; };
} }
return null;
}
/**
* Renders the sheet icon synchronously.
* Static icons like "play" (used in AnswerPaceSheet) are rendered as inline SVGs
* to eliminate per-icon HTTP network requests, image decoding latencies, and late pop-in.
*/
function renderSheetIcon(icon: InformationSheetProps["icon"]): ReactNode {
if (icon === null) {
return null;
}
if (isValidElement(icon)) {
return icon;
}
// Play icon preset (default or explicit "play", as in AnswerPaceSheet)
if (
icon === undefined ||
icon === "play" ||
icon === "stash_play-solid.svg" ||
icon === "/assets/images/stash_play-solid.svg"
) {
return (
<UiIcon
name="play"
width={50}
height={50}
className="shrink-0"
aria-label="Play"
/>
);
}
if (
icon === "warning" ||
icon === "warning.svg" ||
icon === "/assets/images/Vector.svg"
) {
return (
<svg
width={36}
height={36}
viewBox="0 0 36 36"
fill="none"
aria-hidden="true"
className="shrink-0"
>
<path
d="M18 0C8.1 0 0 8.1 0 18C0 27.9 8.1 36 18 36C27.9 36 36 27.9 36 18C36 8.1 27.9 0 18 0ZM16.5857 7.71429H19.4143V21.8571H16.5857V7.71429ZM18 29.5714C16.9714 29.5714 16.0714 28.6714 16.0714 27.6429C16.0714 26.6143 16.9714 25.7143 18 25.7143C19.0286 25.7143 19.9286 26.6143 19.9286 27.6429C19.9286 28.6714 19.0286 29.5714 18 29.5714Z"
fill="#D54747"
/>
</svg>
);
}
if (
icon === "check" ||
icon === "Vectorcheck.svg" ||
icon === "/assets/images/Vectofdasr.svg" ||
icon === "/assets/images/Vectorcheck.svg"
) {
return (
<svg
width={36}
height={36}
viewBox="0 0 36 36"
fill="none"
aria-hidden="true"
className="shrink-0"
>
<path
fillRule="evenodd"
clipRule="evenodd"
d="M0 18C0 13.2261 1.89642 8.64773 5.27208 5.27208C8.64773 1.89642 13.2261 0 18 0C22.7739 0 27.3523 1.89642 30.7279 5.27208C34.1036 8.64773 36 13.2261 36 18C36 22.7739 34.1036 27.3523 30.7279 30.7279C27.3523 34.1036 22.7739 36 18 36C13.2261 36 8.64773 34.1036 5.27208 30.7279C1.89642 27.3523 0 22.7739 0 18ZM16.9728 25.704L27.336 12.7488L25.464 11.2512L16.6272 22.2936L10.368 17.0784L8.832 18.9216L16.9728 25.704Z"
fill="#F0445B"
/>
</svg>
);
}
if (typeof icon === "string" && icon.includes("diamond")) {
return (
<UiIcon
name="diamond"
width={48}
height={48}
className="shrink-0"
/>
);
}
const resolved = resolveIcon(icon);
if (!resolved) return null;
return (
<Image
src={resolved.src}
alt={resolved.alt}
width={resolved.width}
height={resolved.height}
priority
unoptimized={typeof resolved.src === "string" && resolved.src.endsWith(".svg")}
/>
);
}
export function InformationSheet({ export function InformationSheet({
isOpen = true, isOpen = true,
icon, icon,
@ -285,7 +396,7 @@ export function InformationSheet({
} }
}, [isOpen, isRendered, closeSheet]); }, [isOpen, isRendered, closeSheet]);
const resolvedIcon = resolveIcon(icon);
const renderedIcon = renderSheetIcon(icon);
useHardwareBackHandler(() => { useHardwareBackHandler(() => {
closeSheet(); closeSheet();
@ -521,19 +632,11 @@ export function InformationSheet({
</div> </div>
) : ( ) : (
<> <>
{resolvedIcon ? (
<Image
src={resolvedIcon.src}
alt={resolvedIcon.alt}
width={resolvedIcon.width}
height={resolvedIcon.height}
priority
/>
) : null}
{renderedIcon}
<h2 <h2
className={[ className={[
resolvedIcon ? "mt-2.5" : "mt-0",
renderedIcon ? "mt-2.5" : "mt-0",
"w-full group-16 leading-[1.2] font-bold tracking-[-0.03em] text-[#171717]", "w-full group-16 leading-[1.2] font-bold tracking-[-0.03em] text-[#171717]",
].join(" ")} ].join(" ")}
> >

16
src/components/Componentes/question-checkbox.tsx

@ -1,5 +1,6 @@
"use client"; "use client";
import { resolveMultiOptionToggle } from "@/lib/multi-select-helper";
import type { QuestionField } from "@/lib/schema-adapter"; import type { QuestionField } from "@/lib/schema-adapter";
import { useQuestionAnswers } from "./question-answer-storage"; import { useQuestionAnswers } from "./question-answer-storage";
import QuestionTitle from "./question-title"; import QuestionTitle from "./question-title";
@ -105,15 +106,12 @@ export function QuestionCheckbox({
(question.validation?.max ? Number(question.validation.max) : undefined); (question.validation?.max ? Number(question.validation.max) : undefined);
const toggleOption = (optionId: string) => { const toggleOption = (optionId: string) => {
let nextValue: string[];
if (value.includes(optionId)) {
nextValue = value.filter((v) => v !== optionId);
} else {
if (maxSelect && value.length >= maxSelect) {
return;
}
nextValue = [...value, optionId];
}
const nextValue = resolveMultiOptionToggle({
currentSelected: value,
optionId,
options,
maxSelect,
});
setAnswerValue( setAnswerValue(
question, nextValue.length > 0 ? nextValue : null, question, nextValue.length > 0 ? nextValue : null,

17
src/components/Componentes/question-dropdown.tsx

@ -3,6 +3,7 @@
import { useCallback, useEffect, useRef, useState } from "react"; import { useCallback, useEffect, useRef, useState } from "react";
import { Ic } from "@/icons"; import { Ic } from "@/icons";
import { ExplanationUiFont } from "./explanation-ui-font"; import { ExplanationUiFont } from "./explanation-ui-font";
import { resolveMultiOptionToggle } from "@/lib/multi-select-helper";
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";
@ -136,16 +137,12 @@ export function QuestionDropdown({
: Boolean(singleValue); : Boolean(singleValue);
const toggleMultiOption = (optionId: string) => { const toggleMultiOption = (optionId: string) => {
let nextValue: string[];
if (selectedList.includes(optionId)) {
nextValue = selectedList.filter((v) => v !== optionId);
} else {
if (maxSelect && selectedList.length >= maxSelect) {
return;
}
nextValue = [...selectedList, optionId];
}
const nextValue = resolveMultiOptionToggle({
currentSelected: selectedList,
optionId,
options,
maxSelect,
});
setAnswerValue( setAnswerValue(
question, nextValue.length > 0 ? nextValue : null, question, nextValue.length > 0 ? nextValue : null,

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

@ -187,9 +187,7 @@ export function QuestionProgressTracker({
> >
<div className="mb-[7px] flex items-center justify-between group-10 leading-none font-normal text-[#747474]"> <div className="mb-[7px] flex items-center justify-between group-10 leading-none font-normal text-[#747474]">
<span> <span>
{t["Completed items"] ||
t["fields to complete"] ||
"Completed items"}
{t["fields to complete"] || "Completed items"}
</span> </span>
<span> <span>
{answered} /{safeTotal} {answered} /{safeTotal}

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

@ -9,7 +9,11 @@ import {
import { afterEach, describe, expect, it, vi } from "vitest"; import { afterEach, describe, expect, it, vi } from "vitest";
import type { QuestionField } from "@/lib/schema-adapter"; import type { QuestionField } from "@/lib/schema-adapter";
import { QuestionAnswersProvider } from "./question-answer-storage"; import { QuestionAnswersProvider } from "./question-answer-storage";
import { QuestionSheet } from "./question-sheet";
import {
QuestionSheet,
calculateOptionMinHeight,
estimateOptionLines,
} from "./question-sheet";
vi.mock("@/translations/provider", () => ({ vi.mock("@/translations/provider", () => ({
useI18n: vi.fn(() => ({ locale: "fa", dictionary: { Confirm: "تایید" } })), useI18n: vi.fn(() => ({ locale: "fa", dictionary: { Confirm: "تایید" } })),
@ -252,9 +256,265 @@ describe("QuestionSheet component", () => {
fireEvent.click(screen.getByRole("button", { name: /انتخاب کشور/i })); fireEvent.click(screen.getByRole("button", { name: /انتخاب کشور/i }));
expect(screen.getByPlaceholderText("جستجو...")).not.toHaveFocus(); expect(screen.getByPlaceholderText("جستجو...")).not.toHaveFocus();
expect(screen.getByRole("dialog").querySelector("section")).toHaveClass(
"h-[82svh]",
expect(
screen.getByRole("dialog").querySelector("section"),
).toHaveClass("flutter-draggable-sheet");
});
it("grows the sheet when an overflowing list is pulled past its top", () => {
const question = {
id: "q_grow",
title: "کشور",
type: "dropdown",
required: true,
extras: { placeHolder: "انتخاب کشور" },
options: Array.from({ length: 7 }, (_, index) => ({
id: `country-${index}`,
value: `country-${index}`,
label: `کشور ${index + 1}`,
order: index + 1,
})),
ui_config: {},
} as QuestionField;
render(
<QueryClientProvider client={queryClient}>
<QuestionAnswersProvider slug="test" questions={[question]}>
<QuestionSheet question={question} />
</QuestionAnswersProvider>
</QueryClientProvider>,
); );
fireEvent.click(screen.getByRole("button", { name: /انتخاب کشور/i }));
const section = screen.getByRole("dialog").querySelector("section")!;
const list = screen.getByTestId("question-sheet-list");
Object.defineProperty(list, "scrollHeight", {
value: 800,
configurable: true,
});
Object.defineProperty(list, "clientHeight", {
value: 400,
configurable: true,
});
fireEvent.touchStart(list, { touches: [{ clientX: 0, clientY: 400 }] });
fireEvent.touchMove(list, { touches: [{ clientX: 0, clientY: 300 }] });
expect(section.style.getPropertyValue("--sheet-size")).toBe(
(0.75 + 100 / window.innerHeight).toFixed(4),
);
});
it("snaps to 1.0 when pulled up significantly and released", () => {
const question = {
id: "q_snap_up",
title: "کشور",
type: "dropdown",
required: true,
extras: { placeHolder: "انتخاب کشور" },
options: Array.from({ length: 7 }, (_, index) => ({
id: `country-${index}`,
value: `country-${index}`,
label: `کشور ${index + 1}`,
order: index + 1,
})),
ui_config: {},
} as QuestionField;
render(
<QueryClientProvider client={queryClient}>
<QuestionAnswersProvider slug="test" questions={[question]}>
<QuestionSheet question={question} />
</QuestionAnswersProvider>
</QueryClientProvider>,
);
fireEvent.click(screen.getByRole("button", { name: /انتخاب کشور/i }));
const section = screen.getByRole("dialog").querySelector("section")!;
const list = screen.getByTestId("question-sheet-list");
Object.defineProperty(list, "scrollHeight", { value: 800, configurable: true });
Object.defineProperty(list, "clientHeight", { value: 400, configurable: true });
// Pull up 100px from 0.75 and release
fireEvent.touchStart(list, { touches: [{ clientX: 0, clientY: 400 }] });
fireEvent.touchMove(list, { touches: [{ clientX: 0, clientY: 300 }] });
fireEvent.touchEnd(list);
// Snaps to 1.0000
expect(section.style.getPropertyValue("--sheet-size")).toBe("1.0000");
});
it("snaps back to 0.75 when pulled up slightly and released", () => {
const question = {
id: "q_snap_back",
title: "کشور",
type: "dropdown",
required: true,
extras: { placeHolder: "انتخاب کشور" },
options: Array.from({ length: 7 }, (_, index) => ({
id: `country-${index}`,
value: `country-${index}`,
label: `کشور ${index + 1}`,
order: index + 1,
})),
ui_config: {},
} as QuestionField;
render(
<QueryClientProvider client={queryClient}>
<QuestionAnswersProvider slug="test" questions={[question]}>
<QuestionSheet question={question} />
</QuestionAnswersProvider>
</QueryClientProvider>,
);
fireEvent.click(screen.getByRole("button", { name: /انتخاب کشور/i }));
const section = screen.getByRole("dialog").querySelector("section")!;
const list = screen.getByTestId("question-sheet-list");
Object.defineProperty(list, "scrollHeight", { value: 800, configurable: true });
Object.defineProperty(list, "clientHeight", { value: 400, configurable: true });
// Pull up only 12px and release
fireEvent.touchStart(list, { touches: [{ clientX: 0, clientY: 400 }] });
fireEvent.touchMove(list, { touches: [{ clientX: 0, clientY: 388 }] });
fireEvent.touchEnd(list);
// Snaps back to 0.7500
expect(section.style.getPropertyValue("--sheet-size")).toBe("0.7500");
});
it("snaps from 1.0 down to 0.75 when pulled down and released", () => {
const question = {
id: "q_snap_down",
title: "کشور",
type: "dropdown",
required: true,
extras: { placeHolder: "انتخاب کشور" },
options: Array.from({ length: 7 }, (_, index) => ({
id: `country-${index}`,
value: `country-${index}`,
label: `کشور ${index + 1}`,
order: index + 1,
})),
ui_config: {},
} as QuestionField;
render(
<QueryClientProvider client={queryClient}>
<QuestionAnswersProvider slug="test" questions={[question]}>
<QuestionSheet question={question} />
</QuestionAnswersProvider>
</QueryClientProvider>,
);
fireEvent.click(screen.getByRole("button", { name: /انتخاب کشور/i }));
const section = screen.getByRole("dialog").querySelector("section")!;
const list = screen.getByTestId("question-sheet-list");
Object.defineProperty(list, "scrollHeight", { value: 800, configurable: true });
Object.defineProperty(list, "clientHeight", { value: 400, configurable: true });
// Expand to 1.0 first
fireEvent.touchStart(list, { touches: [{ clientX: 0, clientY: 400 }] });
fireEvent.touchMove(list, { touches: [{ clientX: 0, clientY: 300 }] });
fireEvent.touchEnd(list);
expect(section.style.getPropertyValue("--sheet-size")).toBe("1.0000");
// Pull down 50px from 1.0 and release
fireEvent.touchStart(list, { touches: [{ clientX: 0, clientY: 300 }] });
fireEvent.touchMove(list, { touches: [{ clientX: 0, clientY: 350 }] });
fireEvent.touchEnd(list);
// Snaps down to 0.7500
expect(section.style.getPropertyValue("--sheet-size")).toBe("0.7500");
});
it("grows the sheet on wheel overscroll at the top of the list", () => {
const question = {
id: "q_wheel",
title: "کشور",
type: "dropdown",
required: true,
extras: { placeHolder: "انتخاب کشور" },
options: Array.from({ length: 7 }, (_, index) => ({
id: `country-${index}`,
value: `country-${index}`,
label: `کشور ${index + 1}`,
order: index + 1,
})),
ui_config: {},
} as QuestionField;
render(
<QueryClientProvider client={queryClient}>
<QuestionAnswersProvider slug="test" questions={[question]}>
<QuestionSheet question={question} />
</QuestionAnswersProvider>
</QueryClientProvider>,
);
fireEvent.click(screen.getByRole("button", { name: /انتخاب کشور/i }));
const section = screen.getByRole("dialog").querySelector("section")!;
const list = screen.getByTestId("question-sheet-list");
Object.defineProperty(list, "scrollHeight", {
value: 800,
configurable: true,
});
Object.defineProperty(list, "clientHeight", {
value: 400,
configurable: true,
});
fireEvent.wheel(list, { deltaY: 120 });
expect(section.style.getPropertyValue("--sheet-size")).toBe(
(0.75 + 120 / window.innerHeight).toFixed(4),
);
});
it("shrinks to the 60% floor and closes when dragged past it", async () => {
const question = {
id: "q_shrink",
title: "کشور",
type: "dropdown",
required: true,
extras: { placeHolder: "انتخاب کشور" },
options: Array.from({ length: 7 }, (_, index) => ({
id: `country-${index}`,
value: `country-${index}`,
label: `کشور ${index + 1}`,
order: index + 1,
})),
ui_config: {},
} as QuestionField;
render(
<QueryClientProvider client={queryClient}>
<QuestionAnswersProvider slug="test" questions={[question]}>
<QuestionSheet question={question} />
</QuestionAnswersProvider>
</QueryClientProvider>,
);
fireEvent.click(screen.getByRole("button", { name: /انتخاب کشور/i }));
const section = screen.getByRole("dialog").querySelector("section")!;
const list = screen.getByTestId("question-sheet-list");
// Pull down at the top: sheet shrinks and clamps at the 0.6 floor.
fireEvent.touchStart(list, { touches: [{ clientX: 0, clientY: 400 }] });
fireEvent.touchMove(list, { touches: [{ clientX: 0, clientY: 560 }] });
expect(section.style.getPropertyValue("--sheet-size")).toBe("0.6000");
// shouldCloseOnMinExtent: dragging below the floor closes the sheet.
fireEvent.touchMove(list, { touches: [{ clientX: 0, clientY: 580 }] });
await waitFor(() => {
expect(screen.queryByRole("dialog")).toBeNull();
});
}); });
it("sizes a short options sheet to its content (4 options)", () => { it("sizes a short options sheet to its content (4 options)", () => {
@ -348,7 +608,7 @@ describe("QuestionSheet component", () => {
const section = screen.getByRole("dialog").querySelector("section"); const section = screen.getByRole("dialog").querySelector("section");
expect(section).toHaveClass("h-auto", "max-h-[82svh]"); expect(section).toHaveClass("h-auto", "max-h-[82svh]");
expect(section).not.toHaveClass("h-[82svh]");
expect(section).not.toHaveClass("flutter-draggable-sheet");
expect(screen.queryByPlaceholderText("جستجو...")).toBeNull(); expect(screen.queryByPlaceholderText("جستجو...")).toBeNull();
}); });
@ -379,7 +639,7 @@ describe("QuestionSheet component", () => {
fireEvent.click(screen.getByRole("button", { name: "انتخاب" })); fireEvent.click(screen.getByRole("button", { name: "انتخاب" }));
const section = screen.getByRole("dialog").querySelector("section"); const section = screen.getByRole("dialog").querySelector("section");
expect(section).toHaveClass("h-[82svh]");
expect(section).toHaveClass("flutter-draggable-sheet");
expect(section).not.toHaveClass("h-auto"); expect(section).not.toHaveClass("h-auto");
expect(screen.getByPlaceholderText("جستجو...")).toBeDefined(); expect(screen.getByPlaceholderText("جستجو...")).toBeDefined();
}); });
@ -843,6 +1103,86 @@ describe("QuestionSheet component", () => {
expect(screen.getByRole("button", { name: "Albania" })).toBeDefined(); expect(screen.getByRole("button", { name: "Albania" })).toBeDefined();
expect(screen.queryByRole("button", { name: "Afghanistan" })).toBeNull(); expect(screen.queryByRole("button", { name: "Afghanistan" })).toBeNull();
}); });
describe("Character-based option height calculation & border enclosure", () => {
it("estimates lines based on character count and word wrapping", () => {
expect(estimateOptionLines("")).toBe(1);
expect(estimateOptionLines("گزینه کوتاه")).toBe(1);
// 55 characters should take at least 2 lines (at 28 chars/line)
expect(
estimateOptionLines("این یک متن دو خطی برای تست گزینه‌های بلند در شیت سوال است"),
).toBeGreaterThanOrEqual(2);
// 115 characters (from user screenshot) should take at least 4 lines
const longText =
"همسر آینده‌ام اهل مشارکت فعال در امور عام‌المنفعه، خیریه و فعالیت‌های داوطلبانه و عام‌المنفعه باشد";
expect(estimateOptionLines(longText, 28)).toBeGreaterThanOrEqual(4);
});
it("calculates minimum height dynamically according to text character length", () => {
// 1 line text defaults to standard minimum of 52px
expect(calculateOptionMinHeight("گزینه کوتاه")).toBe(52);
// Long text (115 chars) dynamically calculates >= 114px to enclose all lines
const longText =
"همسر آینده‌ام اهل مشارکت فعال در امور عام‌المنفعه، خیریه و فعالیت‌های داوطلبانه و عام‌المنفعه باشد";
const calculatedHeight = calculateOptionMinHeight(longText);
expect(calculatedHeight).toBeGreaterThanOrEqual(114);
// Compound options with " - " separator calculate height for both title and description
const compoundText =
"عنوان بلند گزینه - توضیحات تکمیلی درباره این گزینه که چند سطر متن دارد";
const compoundHeight = calculateOptionMinHeight(compoundText);
expect(compoundHeight).toBeGreaterThanOrEqual(74);
});
it("applies dynamic min-height to option card elements to prevent text overflow outside borders", () => {
const longText =
"همسر آینده‌ام اهل مشارکت فعال در امور عام‌المنفعه، خیریه و فعالیت‌های داوطلبانه و عام‌المنفعه باشد";
const longQuestion = {
id: "volunteer_work_preference",
title: "دیدگاه شما در مورد مشارکت در امور خیریه",
type: "dropdown",
order: 1,
required: true,
isVisible: true,
description: "",
tooltip: "",
extras: { placeHolder: "انتخاب کنید" },
options: [
{ id: "short_opt", value: "short", label: "موافق", order: 1 },
{ id: "long_opt", value: "long", label: longText, order: 2 },
],
ui_config: {},
} as QuestionField;
render(
<QueryClientProvider client={queryClient}>
<QuestionAnswersProvider slug="test" questions={[longQuestion]}>
<QuestionSheet question={longQuestion} />
</QuestionAnswersProvider>
</QueryClientProvider>,
);
fireEvent.click(screen.getByRole("button", { name: "انتخاب کنید" }));
const shortBtn = screen.getByRole("button", { name: "موافق" });
const longBtn = screen.getByRole("button", { name: longText });
// Short button has baseline min-height: min-h-[52px]
expect(shortBtn).toHaveClass("min-h-[52px]");
expect(shortBtn).toHaveClass("h-auto");
expect(shortBtn).toHaveClass("shrink-0");
expect(shortBtn).toHaveClass("box-border");
// Long button uses natural content-driven sizing (h-auto, shrink-0, min-h-[52px]) without forced inflated inline minHeight
expect(longBtn).toHaveClass("min-h-[52px]");
expect(longBtn).toHaveClass("h-auto");
expect(longBtn).toHaveClass("shrink-0");
expect(longBtn).toHaveClass("box-border");
expect(longBtn.style.minHeight).toBe("");
});
});
}); });

373
src/components/Componentes/question-sheet.tsx

@ -15,6 +15,7 @@ import {
resolveLanguageName, resolveLanguageName,
getLanguageSearchKeywords, getLanguageSearchKeywords,
} from "@/data/languages"; } from "@/data/languages";
import { resolveMultiOptionToggle } from "@/lib/multi-select-helper";
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 { Button } from "./button"; import { Button } from "./button";
@ -28,6 +29,61 @@ const EXIT_ANIMATION_MS = 200;
const EMPTY_ARRAY: string[] = []; const EMPTY_ARRAY: string[] = [];
const COMPACT_OPTIONS_MAX = 6; const COMPACT_OPTIONS_MAX = 6;
// Parity with najm's DraggableScrollableSheet language sheet
// (initialChildSize: 0.75, minChildSize: 0.6, maxChildSize: 1.0 implicit,
// shouldCloseOnMinExtent: true): the sheet opens at 75% of the screen, grows
// while the option list is pulled past its top, and closes below 60%.
const SHEET_INITIAL_SIZE = 0.75;
const SHEET_MIN_SIZE = 0.6;
const SHEET_MAX_SIZE = 1;
const GESTURE_ENGAGE_PX = 10;
/**
* Character-based dynamic height algorithm for option cards in question sheets.
* Calculates the required minimum height based on character count and word wrapping,
* ensuring the card border completely encloses all lines of text without overflow or clipping.
*/
export function estimateOptionLines(text: string, charsPerLine: number = 28): number {
if (!text) return 1;
const clean = text.trim();
if (clean.length === 0) return 1;
const words = clean.split(/\s+/);
let lines = 1;
let currentLen = 0;
for (const word of words) {
if (currentLen + word.length > charsPerLine && currentLen > 0) {
lines++;
currentLen = word.length;
} else {
currentLen += (currentLen === 0 ? 0 : 1) + word.length;
}
}
const charLines = Math.ceil(clean.length / charsPerLine);
return Math.max(1, lines, charLines);
}
export function calculateOptionMinHeight(label: string): number {
if (!label) return 52;
// If option has " - " separating title and description:
if (label.includes(" - ")) {
const parts = label.split(" - ");
const title = parts[0] || "";
const desc = parts.slice(1).join(" - ") || "";
const titleLines = estimateOptionLines(title, 26);
const descLines = estimateOptionLines(desc, 30);
// Vertical padding 24px + border 2px + title lines * 22px + gap 6px + desc lines * 19px + 4px breathing room
const total = 26 + (titleLines * 22) + 6 + (descLines * 19) + 4;
return Math.max(52, Math.round(total));
}
const lines = estimateOptionLines(label, 28);
// Base padding + border = 26px (py-3: 12px top + 12px bottom + 2px border)
// Each line is ~22px (15px font * 1.45 line-height) + 4px safety buffer
const total = 26 + (lines * 22) + 4;
return Math.max(52, Math.round(total));
}
export type QuestionSheetProps = { export type QuestionSheetProps = {
question: QuestionField; question: QuestionField;
disabled?: boolean; disabled?: boolean;
@ -63,7 +119,10 @@ export function QuestionSheet({ question, disabled }: QuestionSheetProps) {
const [isClosing, setIsClosing] = useState(false); const [isClosing, setIsClosing] = useState(false);
const [searchQuery, setSearchQuery] = useState(""); const [searchQuery, setSearchQuery] = useState("");
const listRef = useRef<HTMLDivElement>(null); const listRef = useRef<HTMLDivElement>(null);
const headerRef = useRef<HTMLDivElement>(null);
const sheetRef = useRef<HTMLElement>(null); const sheetRef = useRef<HTMLElement>(null);
const sheetSizeRef = useRef(SHEET_INITIAL_SIZE);
const restingStateRef = useRef<number>(SHEET_INITIAL_SIZE);
const [localSelectedList, setLocalSelectedList] = useState<string[]>(selectedList); const [localSelectedList, setLocalSelectedList] = useState<string[]>(selectedList);
const localSelectedListRef = useRef<string[]>(selectedList); const localSelectedListRef = useRef<string[]>(selectedList);
@ -100,12 +159,35 @@ export function QuestionSheet({ question, disabled }: QuestionSheetProps) {
const openSheet = useCallback(() => { const openSheet = useCallback(() => {
if (disabled) return; if (disabled) return;
sheetSizeRef.current = SHEET_INITIAL_SIZE;
restingStateRef.current = SHEET_INITIAL_SIZE;
if (sheetRef.current) {
sheetRef.current.style.transition = "";
sheetRef.current.style.setProperty(
"--sheet-size",
SHEET_INITIAL_SIZE.toFixed(4),
);
}
setLocalSelectedList(selectedList); setLocalSelectedList(selectedList);
localSelectedListRef.current = selectedList; localSelectedListRef.current = selectedList;
setIsOpen(true); setIsOpen(true);
setIsClosing(false); setIsClosing(false);
}, [disabled, selectedList]); }, [disabled, selectedList]);
useEffect(() => {
if (isOpen) {
sheetSizeRef.current = SHEET_INITIAL_SIZE;
restingStateRef.current = SHEET_INITIAL_SIZE;
if (sheetRef.current) {
sheetRef.current.style.transition = "";
sheetRef.current.style.setProperty(
"--sheet-size",
SHEET_INITIAL_SIZE.toFixed(4),
);
}
}
}, [isOpen]);
useSheetScrollLock(isOpen, { onBack: closeSheet }); useSheetScrollLock(isOpen, { onBack: closeSheet });
// Handle escape key // Handle escape key
@ -327,6 +409,241 @@ export function QuestionSheet({ question, disabled }: QuestionSheetProps) {
}; };
}, [isCompact, isClosing, isOpen]); }, [isCompact, isClosing, isOpen]);
// Drag-to-resize parity with najm's DraggableScrollableSheet:
// initialChildSize: 0.75, minChildSize: 0.6, maxChildSize: 1.0, shouldCloseOnMinExtent: true
// Snapping: Exactly two resting states (0.75 default and 1.0 expanded below status bar).
useEffect(() => {
if (!isOpen || isClosing || !showSearch) return;
const list = listRef.current;
const sheet = sheetRef.current;
const header = headerRef.current;
if (!sheet) return;
let active = false;
let engaged = false;
let closing = false;
let isHeaderDrag = false;
let startX = 0;
let startY = 0;
let lastY = 0;
let snapTimeout: ReturnType<typeof setTimeout> | null = null;
let wheelSnapTimeout: ReturnType<typeof setTimeout> | null = null;
const listOverflows = () =>
list ? list.scrollHeight > list.clientHeight + 1 : false;
const snapTo = (targetSize: number) => {
sheetSizeRef.current = targetSize;
restingStateRef.current = targetSize;
sheet.style.transition = "height 260ms cubic-bezier(0.16, 1, 0.3, 1)";
sheet.style.setProperty("--sheet-size", targetSize.toFixed(4));
if (snapTimeout) clearTimeout(snapTimeout);
snapTimeout = setTimeout(() => {
if (sheet) {
sheet.style.transition = "";
}
}, 270);
};
const resize = (deltaPx: number) => {
const viewportHeight = window.innerHeight || 1;
const nextSize = Math.min(
SHEET_MAX_SIZE,
Math.max(
SHEET_MIN_SIZE,
sheetSizeRef.current + deltaPx / viewportHeight,
),
);
sheetSizeRef.current = nextSize;
sheet.style.setProperty("--sheet-size", nextSize.toFixed(4));
};
const handleTouchStart = (event: TouchEvent, fromHeader: boolean) => {
if (event.touches.length !== 1) {
active = false;
return;
}
if (snapTimeout) {
clearTimeout(snapTimeout);
snapTimeout = null;
}
sheet.style.transition = "none";
active = true;
engaged = fromHeader;
isHeaderDrag = fromHeader;
startX = event.touches[0].clientX;
startY = event.touches[0].clientY;
lastY = startY;
};
const handleTouchMove = (event: TouchEvent) => {
if (!active || closing || event.touches.length !== 1) return;
const touch = event.touches[0];
const deltaY = lastY - touch.clientY; // finger up = pull for more content
const pulled = startY - touch.clientY; // cumulative finger-up distance
lastY = touch.clientY;
if (!engaged) {
// Commit to the gesture only after a clear vertical drag; calling
// preventDefault on micro-movements kills the synthetic tap on options.
if (Math.abs(pulled) < GESTURE_ENGAGE_PX) return;
if (Math.abs(touch.clientX - startX) >= Math.abs(pulled)) return;
const atTop = !list || list.scrollTop <= 0;
const canGrow =
pulled > 0 &&
atTop &&
listOverflows() &&
sheetSizeRef.current < SHEET_MAX_SIZE - 0.001;
const canShrink = pulled < 0 && atTop;
if (!canGrow && !canShrink) return;
engaged = true;
}
const atTop = !list || list.scrollTop <= 0;
const size = sheetSizeRef.current;
const consume = isHeaderDrag
? true
: deltaY > 0
? atTop && size < SHEET_MAX_SIZE - 0.001
: atTop;
if (!consume) return; // sheet is at its ceiling — the list scrolls
if (event.cancelable) {
event.preventDefault();
}
if (deltaY > 0) {
resize(deltaY);
} else if (size <= SHEET_MIN_SIZE + 0.001) {
closing = true; // shouldCloseOnMinExtent: true
closeSheet();
} else {
resize(deltaY);
}
};
const handleTouchEnd = () => {
if (!active || closing) {
active = false;
engaged = false;
isHeaderDrag = false;
return;
}
active = false;
engaged = false;
isHeaderDrag = false;
const currentSize = sheetSizeRef.current;
const baseline = restingStateRef.current;
const totalPulled = startY - lastY; // > 0 finger pulled up, < 0 finger pulled down
// Flutter shouldCloseOnMinExtent: true when dragged near or below 60% floor
if (currentSize <= SHEET_MIN_SIZE + 0.01) {
closing = true;
closeSheet();
return;
}
// Snapping between only two states: 0.75 (default) and 1.0 (expanded)
if (baseline === SHEET_MAX_SIZE) {
// Dragging down from 1.0: if pulled down >= 30px or size <= 0.94, snap to 0.75
if (totalPulled <= -30 || currentSize <= 0.94) {
snapTo(SHEET_INITIAL_SIZE);
} else {
snapTo(SHEET_MAX_SIZE);
}
} else {
// Dragging from 0.75:
// If pulled down strongly, close
if (totalPulled <= -60) {
closing = true;
closeSheet();
} else if (totalPulled >= 30 || currentSize >= 0.78) {
// Dragged up enough to expand to 1.0
snapTo(SHEET_MAX_SIZE);
} else {
// Snap back to initial 0.75
snapTo(SHEET_INITIAL_SIZE);
}
}
};
const handleWheel = (event: WheelEvent) => {
if (closing) return;
const delta = event.deltaY;
const atTop = !list || list.scrollTop <= 0;
const size = sheetSizeRef.current;
const consume =
delta > 0
? atTop && listOverflows() && size < SHEET_MAX_SIZE - 0.001
: atTop;
if (!consume) return;
if (event.cancelable) {
event.preventDefault();
}
if (delta > 0) {
resize(delta);
} else if (size <= SHEET_MIN_SIZE + 0.001) {
closing = true;
closeSheet();
} else {
resize(delta);
}
if (wheelSnapTimeout) clearTimeout(wheelSnapTimeout);
wheelSnapTimeout = setTimeout(() => {
if (closing) return;
const current = sheetSizeRef.current;
if (current <= SHEET_MIN_SIZE + 0.01) {
closing = true;
closeSheet();
} else if (current >= 0.82) {
snapTo(SHEET_MAX_SIZE);
} else {
snapTo(SHEET_INITIAL_SIZE);
}
}, 160);
};
const onHeaderTouchStart = (e: TouchEvent) => handleTouchStart(e, true);
const onListTouchStart = (e: TouchEvent) => handleTouchStart(e, false);
if (header) {
header.addEventListener("touchstart", onHeaderTouchStart, {
passive: true,
});
}
if (list) {
list.addEventListener("touchstart", onListTouchStart, { passive: true });
list.addEventListener("touchmove", handleTouchMove, { passive: false });
list.addEventListener("touchend", handleTouchEnd, { passive: true });
list.addEventListener("touchcancel", handleTouchEnd, { passive: true });
list.addEventListener("wheel", handleWheel, { passive: false });
}
sheet.addEventListener("touchmove", handleTouchMove, { passive: false });
sheet.addEventListener("touchend", handleTouchEnd, { passive: true });
sheet.addEventListener("touchcancel", handleTouchEnd, { passive: true });
return () => {
if (snapTimeout) clearTimeout(snapTimeout);
if (wheelSnapTimeout) clearTimeout(wheelSnapTimeout);
if (header) {
header.removeEventListener("touchstart", onHeaderTouchStart);
}
if (list) {
list.removeEventListener("touchstart", onListTouchStart);
list.removeEventListener("touchmove", handleTouchMove);
list.removeEventListener("touchend", handleTouchEnd);
list.removeEventListener("touchcancel", handleTouchEnd);
list.removeEventListener("wheel", handleWheel);
}
sheet.removeEventListener("touchmove", handleTouchMove);
sheet.removeEventListener("touchend", handleTouchEnd);
sheet.removeEventListener("touchcancel", handleTouchEnd);
};
}, [isOpen, isClosing, showSearch, closeSheet]);
const filteredOptions = useMemo(() => { const filteredOptions = useMemo(() => {
const rawQ = searchQuery.trim(); const rawQ = searchQuery.trim();
if (!rawQ) return options; if (!rawQ) return options;
@ -396,15 +713,12 @@ export function QuestionSheet({ question, disabled }: QuestionSheetProps) {
const toggleMultiOption = (optionId: string) => { const toggleMultiOption = (optionId: string) => {
setLocalSelectedList((prev) => { setLocalSelectedList((prev) => {
let nextValue: string[];
if (prev.includes(optionId)) {
nextValue = prev.filter((v) => v !== optionId);
} else {
if (maxSelect && prev.length >= maxSelect) {
return prev;
}
nextValue = [...prev, optionId];
}
const nextValue = resolveMultiOptionToggle({
currentSelected: prev,
optionId,
options,
maxSelect,
});
localSelectedListRef.current = nextValue; localSelectedListRef.current = nextValue;
return nextValue; return nextValue;
}); });
@ -525,15 +839,25 @@ export function QuestionSheet({ question, disabled }: QuestionSheetProps) {
> >
<section <section
ref={sheetRef} ref={sheetRef}
style={{
height: !showSearch
? undefined
: "calc(var(--sheet-size, 0.75) * (100svh - max(var(--safe-top, 0px), env(safe-area-inset-top, 0px))))",
maxHeight:
"calc(100svh - max(var(--safe-top, 0px), env(safe-area-inset-top, 0px)))",
}}
className={[ className={[
"flex w-full max-w-[834px] sm:max-w-[540px] 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)]", "flex w-full max-w-[834px] sm:max-w-[540px] 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)]",
!showSearch !showSearch
? "h-auto max-h-[82svh]" ? "h-auto max-h-[82svh]"
: "h-[82svh] min-h-[82svh] max-h-[82svh]",
: "flutter-draggable-sheet",
isClosing ? "flutter-sheet-surface-exit" : "flutter-sheet-surface", isClosing ? "flutter-sheet-surface-exit" : "flutter-sheet-surface",
].join(" ")} ].join(" ")}
> >
<div className="flex items-center justify-between gap-3 px-5 pt-2.5 pb-3 border-b border-[#F2F4F7]">
<div
ref={headerRef}
className="flex items-center justify-between gap-3 px-5 pt-2.5 pb-3 border-b border-[#F2F4F7]"
>
<div className="flex-1 min-w-0 flex flex-col text-start"> <div className="flex-1 min-w-0 flex flex-col text-start">
<h3 className="flex-1 text-[17px] font-bold leading-snug text-[#181818] line-clamp-2 break-words text-start"> <h3 className="flex-1 text-[17px] font-bold leading-snug text-[#181818] line-clamp-2 break-words text-start">
{(t as any)[question.title] || question.title} {(t as any)[question.title] || question.title}
@ -641,6 +965,7 @@ export function QuestionSheet({ question, disabled }: QuestionSheetProps) {
{/* Options List */} {/* Options List */}
<div <div
ref={listRef} ref={listRef}
data-testid="question-sheet-list"
onTouchStart={(event) => event.stopPropagation()} onTouchStart={(event) => event.stopPropagation()}
onTouchMove={(event) => event.stopPropagation()} onTouchMove={(event) => event.stopPropagation()}
onTouchEnd={(event) => event.stopPropagation()} onTouchEnd={(event) => event.stopPropagation()}
@ -667,6 +992,7 @@ export function QuestionSheet({ question, disabled }: QuestionSheetProps) {
key={option.id} key={option.id}
type="button" type="button"
disabled={isOptionDisabled} disabled={isOptionDisabled}
aria-pressed={isMulti ? isSelected : undefined}
onClick={() => { onClick={() => {
if (isOptionDisabled) return; if (isOptionDisabled) return;
if (isMulti) { if (isMulti) {
@ -676,12 +1002,12 @@ export function QuestionSheet({ question, disabled }: QuestionSheetProps) {
} }
}} }}
className={[ className={[
"flex min-h-[52px] w-full items-start gap-3 rounded-[12px] border px-3.5 py-3 text-start transition-colors",
"flex min-h-[52px] h-auto w-full shrink-0 items-start gap-3 rounded-[12px] border px-3.5 py-3 text-start transition-colors select-none box-border",
isOptionDisabled isOptionDisabled
? "opacity-35 cursor-not-allowed bg-[#F9FAFB] border-[#F2F4F7] text-[#98A2B3] pointer-events-none"
? "opacity-35 cursor-not-allowed bg-[#F9FAFB] border-[#F2F4F7] text-[#98A2B3]"
: isSelected : isSelected
? "bg-[#FFF4F5] border-[#F0445B]/40 text-[#181818] cursor-pointer"
: "bg-white hover:bg-[#F2F4F7] border-[#EAECF0] text-[#181818] cursor-pointer",
? "bg-[#FFF4F5] border-[#F0445B]/40 text-[#181818] cursor-pointer active:scale-[0.995]"
: "bg-white hover:bg-[#F2F4F7] border-[#EAECF0] text-[#181818] cursor-pointer active:scale-[0.995]",
].join(" ")} ].join(" ")}
> >
{/* Indicator Icon */} {/* Indicator Icon */}
@ -729,7 +1055,7 @@ export function QuestionSheet({ question, disabled }: QuestionSheetProps) {
</div> </div>
)} )}
<span className="text-[15px] leading-[1.45] flex-1 text-start break-words">
<div className="flex-1 min-w-0 text-start">
{option.label.includes(" - ") ? ( {option.label.includes(" - ") ? (
(() => { (() => {
const parts = option.label.split(" - "); const parts = option.label.split(" - ");
@ -739,7 +1065,7 @@ export function QuestionSheet({ question, disabled }: QuestionSheetProps) {
<span className="flex flex-col gap-1 text-start"> <span className="flex flex-col gap-1 text-start">
<span <span
className={[ className={[
"font-bold",
"font-bold text-[15px] leading-[1.45] break-words [overflow-wrap:anywhere]",
isOptionDisabled isOptionDisabled
? "text-[#98A2B3]" ? "text-[#98A2B3]"
: "text-[#181818]", : "text-[#181818]",
@ -761,18 +1087,19 @@ export function QuestionSheet({ question, disabled }: QuestionSheetProps) {
})() })()
) : ( ) : (
<span <span
className={
className={[
"text-[15px] leading-[1.45] block break-words [overflow-wrap:anywhere]",
isOptionDisabled isOptionDisabled
? "font-normal text-[#98A2B3] block"
? "font-normal text-[#98A2B3]"
: isSelected : isSelected
? "font-bold text-[#181818] block"
: "font-semibold text-[#344054] block"
}
? "font-bold text-[#181818]"
: "font-semibold text-[#344054]",
].join(" ")}
> >
{option.label} {option.label}
</span> </span>
)} )}
</span>
</div>
</button> </button>
); );
}) })

191
src/components/Componentes/terms-sheet.test.tsx

@ -0,0 +1,191 @@
import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import TermsSheet from "./terms-sheet";
vi.mock("@/translations/provider", () => ({
useI18n: () => ({
locale: "fa",
dictionary: {
"terms & conditions": "قوانین و مقررات",
"Got it": "متوجه شدم",
"Close terms and conditions": "بستن قوانین و مقررات",
},
}),
}));
describe("TermsSheet Component (DraggableScrollableSheet Parity)", () => {
beforeEach(() => {
vi.clearAllMocks();
});
afterEach(() => {
cleanup();
});
it("does not render when isOpen is false", () => {
render(<TermsSheet isOpen={false} />);
expect(screen.queryByRole("dialog")).toBeNull();
});
it("renders with initial 75% height when isOpen is true", () => {
render(<TermsSheet isOpen={true} />);
const dialog = screen.getByRole("dialog");
expect(dialog).toBeInTheDocument();
const section = dialog.querySelector("section")!;
expect(section).toHaveClass("flutter-draggable-sheet");
expect(screen.getByText("قوانین و مقررات")).toBeInTheDocument();
expect(screen.getByText("متوجه شدم")).toBeInTheDocument();
});
it("grows sheet when pulling up at top of content", () => {
render(<TermsSheet isOpen={true} />);
const dialog = screen.getByRole("dialog");
const section = dialog.querySelector("section")!;
const content = screen.getByTestId("terms-sheet-content");
Object.defineProperty(content, "scrollHeight", { value: 1200, configurable: true });
Object.defineProperty(content, "clientHeight", { value: 400, configurable: true });
Object.defineProperty(content, "scrollTop", { value: 0, configurable: true });
fireEvent.touchStart(content, { touches: [{ clientX: 0, clientY: 400 }] });
fireEvent.touchMove(content, { touches: [{ clientX: 0, clientY: 300 }] });
expect(section.style.getPropertyValue("--sheet-size")).toBe(
(0.75 + 100 / window.innerHeight).toFixed(4),
);
});
it("snaps to 1.0 when pulled up significantly and released", () => {
render(<TermsSheet isOpen={true} />);
const dialog = screen.getByRole("dialog");
const section = dialog.querySelector("section")!;
const content = screen.getByTestId("terms-sheet-content");
Object.defineProperty(content, "scrollHeight", { value: 1200, configurable: true });
Object.defineProperty(content, "clientHeight", { value: 400, configurable: true });
Object.defineProperty(content, "scrollTop", { value: 0, configurable: true });
// Pull up 100px from 0.75
fireEvent.touchStart(content, { touches: [{ clientX: 0, clientY: 400 }] });
fireEvent.touchMove(content, { touches: [{ clientX: 0, clientY: 300 }] });
fireEvent.touchEnd(content);
// Snaps cleanly to 1.0000
expect(section.style.getPropertyValue("--sheet-size")).toBe("1.0000");
});
it("snaps back to 0.75 when pulled up slightly and released", () => {
render(<TermsSheet isOpen={true} />);
const dialog = screen.getByRole("dialog");
const section = dialog.querySelector("section")!;
const content = screen.getByTestId("terms-sheet-content");
Object.defineProperty(content, "scrollHeight", { value: 1200, configurable: true });
Object.defineProperty(content, "clientHeight", { value: 400, configurable: true });
Object.defineProperty(content, "scrollTop", { value: 0, configurable: true });
// Pull up only 12px (below 30px threshold)
fireEvent.touchStart(content, { touches: [{ clientX: 0, clientY: 400 }] });
fireEvent.touchMove(content, { touches: [{ clientX: 0, clientY: 388 }] });
fireEvent.touchEnd(content);
// Snaps back to 0.7500
expect(section.style.getPropertyValue("--sheet-size")).toBe("0.7500");
});
it("snaps from 1.0 down to 0.75 when pulled down and released", () => {
render(<TermsSheet isOpen={true} />);
const dialog = screen.getByRole("dialog");
const section = dialog.querySelector("section")!;
const content = screen.getByTestId("terms-sheet-content");
Object.defineProperty(content, "scrollHeight", { value: 1200, configurable: true });
Object.defineProperty(content, "clientHeight", { value: 400, configurable: true });
Object.defineProperty(content, "scrollTop", { value: 0, configurable: true });
// Expand to 1.0 first
fireEvent.touchStart(content, { touches: [{ clientX: 0, clientY: 400 }] });
fireEvent.touchMove(content, { touches: [{ clientX: 0, clientY: 300 }] });
fireEvent.touchEnd(content);
expect(section.style.getPropertyValue("--sheet-size")).toBe("1.0000");
// Pull down 50px from 1.0
fireEvent.touchStart(content, { touches: [{ clientX: 0, clientY: 300 }] });
fireEvent.touchMove(content, { touches: [{ clientX: 0, clientY: 350 }] });
fireEvent.touchEnd(content);
// Snaps down to 0.7500
expect(section.style.getPropertyValue("--sheet-size")).toBe("0.7500");
});
it("grows sheet on wheel event at top of content", () => {
render(<TermsSheet isOpen={true} />);
const dialog = screen.getByRole("dialog");
const section = dialog.querySelector("section")!;
const content = screen.getByTestId("terms-sheet-content");
Object.defineProperty(content, "scrollHeight", { value: 1200, configurable: true });
Object.defineProperty(content, "clientHeight", { value: 400, configurable: true });
Object.defineProperty(content, "scrollTop", { value: 0, configurable: true });
fireEvent.wheel(content, { deltaY: 120 });
expect(section.style.getPropertyValue("--sheet-size")).toBe(
(0.75 + 120 / window.innerHeight).toFixed(4),
);
});
it("shrinks to 60% floor and closes when pulled past it (shouldCloseOnMinExtent: true)", async () => {
const handleClose = vi.fn();
render(<TermsSheet isOpen={true} onClose={handleClose} />);
const dialog = screen.getByRole("dialog");
const section = dialog.querySelector("section")!;
const content = screen.getByTestId("terms-sheet-content");
Object.defineProperty(content, "scrollTop", { value: 0, configurable: true });
// Pull down at the top: sheet shrinks and clamps at 0.6 floor
fireEvent.touchStart(content, { touches: [{ clientX: 0, clientY: 400 }] });
fireEvent.touchMove(content, { touches: [{ clientX: 0, clientY: 560 }] });
expect(section.style.getPropertyValue("--sheet-size")).toBe("0.6000");
// Pull further past 0.6: triggers close
fireEvent.touchMove(content, { touches: [{ clientX: 0, clientY: 580 }] });
await waitFor(() => {
expect(handleClose).toHaveBeenCalled();
});
});
it("calls onClose when Got it button is clicked", async () => {
const handleClose = vi.fn();
render(<TermsSheet isOpen={true} onClose={handleClose} />);
const gotItButton = screen.getByRole("button", { name: "متوجه شدم" });
fireEvent.click(gotItButton);
await waitFor(() => {
expect(handleClose).toHaveBeenCalled();
});
});
it("calls onClose when close X button is clicked", async () => {
const handleClose = vi.fn();
render(<TermsSheet isOpen={true} onClose={handleClose} />);
const closeButton = screen.getByRole("button", { name: "بستن قوانین و مقررات" });
fireEvent.click(closeButton);
await waitFor(() => {
expect(handleClose).toHaveBeenCalled();
});
});
});

416
src/components/Componentes/terms-sheet.tsx

@ -1,9 +1,11 @@
"use client"; "use client";
import { useCallback, useEffect, useRef, useState } from "react";
import { createPortal } from "react-dom";
import { Ic } from "@/icons"; import { Ic } from "@/icons";
import Button from "@/components/Componentes/button"; import Button from "@/components/Componentes/button";
import InformationSheet from "@/components/Componentes/information-sheet";
import { useI18n } from "@/translations/provider"; import { useI18n } from "@/translations/provider";
import { useSheetScrollLock } from "./use-sheet-scroll-lock";
export type TermItem = { export type TermItem = {
title?: string; title?: string;
@ -217,45 +219,393 @@ export const TERMS_SECTIONS: TermSection[] = [
}, },
]; ];
const EXIT_ANIMATION_MS = 200;
// Parity with Flutter DraggableScrollableSheet:
// initialChildSize: 0.75, minChildSize: 0.6, maxChildSize: 1.0, shouldCloseOnMinExtent: true
const SHEET_INITIAL_SIZE = 0.75;
const SHEET_MIN_SIZE = 0.6;
const SHEET_MAX_SIZE = 1;
const GESTURE_ENGAGE_PX = 10;
export type TermsSheetProps = { export type TermsSheetProps = {
isOpen?: boolean; isOpen?: boolean;
onClose?: () => void; onClose?: () => void;
}; };
export function TermsSheet({ isOpen, onClose }: TermsSheetProps) {
const { dictionary: t } = useI18n();
export function TermsSheet({ isOpen = false, onClose }: TermsSheetProps) {
const { dictionary: t, locale } = useI18n();
const [mounted, setMounted] = useState(false);
const [isClosing, setIsClosing] = useState(false);
const isClosingRef = useRef(false);
const sheetRef = useRef<HTMLElement>(null);
const headerRef = useRef<HTMLDivElement>(null);
const contentRef = useRef<HTMLDivElement>(null);
const sheetSizeRef = useRef(SHEET_INITIAL_SIZE);
const restingStateRef = useRef<number>(SHEET_INITIAL_SIZE);
const termsTitle = t["terms & conditions"] || "Terms & Conditions"; const termsTitle = t["terms & conditions"] || "Terms & Conditions";
const gotItLabel = t["Got it"] || "Got it"; const gotItLabel = t["Got it"] || "Got it";
const closeAriaLabel =
t["Close terms and conditions"] || "Close terms and conditions";
return (
<InformationSheet
isOpen={Boolean(isOpen)}
icon={null}
showCloseButton={false}
title={({ close }) => (
<span className="flex w-full items-start justify-between gap-3 text-start">
<span className="text-[14px] leading-5 font-bold tracking-normal text-[#8B8B8B]">
useEffect(() => {
setMounted(true);
}, []);
const closeSheet = useCallback(() => {
if (isClosingRef.current) return;
isClosingRef.current = true;
setIsClosing(true);
window.setTimeout(() => {
setIsClosing(false);
isClosingRef.current = false;
onClose?.();
}, EXIT_ANIMATION_MS);
}, [onClose]);
useEffect(() => {
if (isOpen && mounted) {
isClosingRef.current = false;
setIsClosing(false);
sheetSizeRef.current = SHEET_INITIAL_SIZE;
restingStateRef.current = SHEET_INITIAL_SIZE;
if (sheetRef.current) {
sheetRef.current.style.transition = "";
sheetRef.current.style.setProperty(
"--sheet-size",
SHEET_INITIAL_SIZE.toFixed(4),
);
}
}
}, [isOpen, mounted]);
useSheetScrollLock(isOpen, { onBack: closeSheet });
useEffect(() => {
if (!isOpen) return;
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === "Escape") {
closeSheet();
}
};
window.addEventListener("keydown", handleKeyDown);
return () => window.removeEventListener("keydown", handleKeyDown);
}, [isOpen, closeSheet]);
// Drag-to-resize parity with Flutter DraggableScrollableSheet:
// initialChildSize: 0.75, minChildSize: 0.6, maxChildSize: 1.0, shouldCloseOnMinExtent: true
// Snapping: Exactly two resting states (0.75 default and 1.0 expanded below status bar).
useEffect(() => {
if (!isOpen || isClosing) return;
const content = contentRef.current;
const sheet = sheetRef.current;
const header = headerRef.current;
if (!sheet) return;
let active = false;
let engaged = false;
let closing = false;
let isHeaderDrag = false;
let startX = 0;
let startY = 0;
let lastY = 0;
let snapTimeout: ReturnType<typeof setTimeout> | null = null;
let wheelSnapTimeout: ReturnType<typeof setTimeout> | null = null;
const contentOverflows = () =>
content ? content.scrollHeight > content.clientHeight + 1 : false;
const snapTo = (targetSize: number) => {
sheetSizeRef.current = targetSize;
restingStateRef.current = targetSize;
sheet.style.transition = "height 260ms cubic-bezier(0.16, 1, 0.3, 1)";
sheet.style.setProperty("--sheet-size", targetSize.toFixed(4));
if (snapTimeout) clearTimeout(snapTimeout);
snapTimeout = setTimeout(() => {
if (sheet) {
sheet.style.transition = "";
}
}, 270);
};
const resize = (deltaPx: number) => {
const viewportHeight = window.innerHeight || 1;
const nextSize = Math.min(
SHEET_MAX_SIZE,
Math.max(
SHEET_MIN_SIZE,
sheetSizeRef.current + deltaPx / viewportHeight,
),
);
sheetSizeRef.current = nextSize;
sheet.style.setProperty("--sheet-size", nextSize.toFixed(4));
};
const handleTouchStart = (event: TouchEvent, fromHeader: boolean) => {
if (event.touches.length !== 1) {
active = false;
return;
}
if (snapTimeout) {
clearTimeout(snapTimeout);
snapTimeout = null;
}
sheet.style.transition = "none";
active = true;
engaged = fromHeader;
isHeaderDrag = fromHeader;
startX = event.touches[0].clientX;
startY = event.touches[0].clientY;
lastY = startY;
};
const handleTouchMove = (event: TouchEvent) => {
if (!active || closing || event.touches.length !== 1) return;
const touch = event.touches[0];
const deltaY = lastY - touch.clientY; // finger up = grow sheet
const pulled = startY - touch.clientY; // cumulative distance
lastY = touch.clientY;
if (!engaged) {
if (Math.abs(pulled) < GESTURE_ENGAGE_PX) return;
if (Math.abs(touch.clientX - startX) >= Math.abs(pulled)) return;
const atTop = !content || content.scrollTop <= 0;
const canGrow =
pulled > 0 &&
atTop &&
contentOverflows() &&
sheetSizeRef.current < SHEET_MAX_SIZE - 0.001;
const canShrink = pulled < 0 && atTop;
if (!canGrow && !canShrink) return;
engaged = true;
}
const atTop = !content || content.scrollTop <= 0;
const size = sheetSizeRef.current;
const consume = isHeaderDrag
? true
: deltaY > 0
? atTop && size < SHEET_MAX_SIZE - 0.001
: atTop;
if (!consume) return; // at ceiling or scrolling inside content
if (event.cancelable) {
event.preventDefault();
}
if (deltaY > 0) {
resize(deltaY);
} else if (size <= SHEET_MIN_SIZE + 0.001) {
closing = true; // shouldCloseOnMinExtent: true
closeSheet();
} else {
resize(deltaY);
}
};
const handleTouchEnd = () => {
if (!active || closing) {
active = false;
engaged = false;
isHeaderDrag = false;
return;
}
active = false;
engaged = false;
isHeaderDrag = false;
const currentSize = sheetSizeRef.current;
const baseline = restingStateRef.current;
const totalPulled = startY - lastY; // > 0 finger pulled up, < 0 finger pulled down
// Flutter shouldCloseOnMinExtent: true when dragged near or below 60% floor
if (currentSize <= SHEET_MIN_SIZE + 0.01) {
closing = true;
closeSheet();
return;
}
// Snapping between only two states: 0.75 (default) and 1.0 (expanded)
if (baseline === SHEET_MAX_SIZE) {
// Dragging down from 1.0: if pulled down >= 30px or size <= 0.94, snap to 0.75
if (totalPulled <= -30 || currentSize <= 0.94) {
snapTo(SHEET_INITIAL_SIZE);
} else {
snapTo(SHEET_MAX_SIZE);
}
} else {
// Dragging from 0.75:
// If pulled down strongly, close
if (totalPulled <= -60) {
closing = true;
closeSheet();
} else if (totalPulled >= 30 || currentSize >= 0.78) {
// Dragged up enough to expand to 1.0
snapTo(SHEET_MAX_SIZE);
} else {
// Snap back to initial 0.75
snapTo(SHEET_INITIAL_SIZE);
}
}
};
const handleWheel = (event: WheelEvent) => {
if (closing) return;
const delta = event.deltaY;
const atTop = !content || content.scrollTop <= 0;
const size = sheetSizeRef.current;
const consume =
delta > 0
? atTop && contentOverflows() && size < SHEET_MAX_SIZE - 0.001
: atTop;
if (!consume) return;
if (event.cancelable) {
event.preventDefault();
}
if (delta > 0) {
resize(delta);
} else if (size <= SHEET_MIN_SIZE + 0.001) {
closing = true;
closeSheet();
} else {
resize(delta);
}
if (wheelSnapTimeout) clearTimeout(wheelSnapTimeout);
wheelSnapTimeout = setTimeout(() => {
if (closing) return;
const current = sheetSizeRef.current;
if (current <= SHEET_MIN_SIZE + 0.01) {
closing = true;
closeSheet();
} else if (current >= 0.82) {
snapTo(SHEET_MAX_SIZE);
} else {
snapTo(SHEET_INITIAL_SIZE);
}
}, 160);
};
const onHeaderTouchStart = (e: TouchEvent) => handleTouchStart(e, true);
const onContentTouchStart = (e: TouchEvent) => handleTouchStart(e, false);
if (header) {
header.addEventListener("touchstart", onHeaderTouchStart, {
passive: true,
});
}
if (content) {
content.addEventListener("touchstart", onContentTouchStart, {
passive: true,
});
content.addEventListener("touchmove", handleTouchMove, { passive: false });
content.addEventListener("touchend", handleTouchEnd, { passive: true });
content.addEventListener("touchcancel", handleTouchEnd, { passive: true });
content.addEventListener("wheel", handleWheel, { passive: false });
}
sheet.addEventListener("touchmove", handleTouchMove, { passive: false });
sheet.addEventListener("touchend", handleTouchEnd, { passive: true });
sheet.addEventListener("touchcancel", handleTouchEnd, { passive: true });
return () => {
if (snapTimeout) clearTimeout(snapTimeout);
if (wheelSnapTimeout) clearTimeout(wheelSnapTimeout);
if (header) {
header.removeEventListener("touchstart", onHeaderTouchStart);
}
if (content) {
content.removeEventListener("touchstart", onContentTouchStart);
content.removeEventListener("touchmove", handleTouchMove);
content.removeEventListener("touchend", handleTouchEnd);
content.removeEventListener("touchcancel", handleTouchEnd);
content.removeEventListener("wheel", handleWheel);
}
sheet.removeEventListener("touchmove", handleTouchMove);
sheet.removeEventListener("touchend", handleTouchEnd);
sheet.removeEventListener("touchcancel", handleTouchEnd);
};
}, [isOpen, isClosing, mounted, closeSheet]);
if (!mounted || (!isOpen && !isClosing)) return null;
const isRtl =
locale === "fa" ||
locale === "ar" ||
locale === "ur" ||
locale === "he" ||
locale === "ks";
return createPortal(
<div
className={[
"fixed inset-0 z-50 flex items-end justify-center",
isClosing ? "flutter-scrim-exit" : "flutter-scrim",
].join(" ")}
role="dialog"
aria-modal="true"
aria-label={termsTitle}
dir={isRtl ? "rtl" : "ltr"}
onClick={(event) => {
if (event.target === event.currentTarget) closeSheet();
}}
>
<section
ref={sheetRef}
style={{
height:
"calc(var(--sheet-size, 0.75) * (100svh - max(var(--safe-top, 0px), env(safe-area-inset-top, 0px))))",
maxHeight:
"calc(100svh - max(var(--safe-top, 0px), env(safe-area-inset-top, 0px)))",
}}
className={[
"flex w-full max-w-[834px] sm:max-w-[540px] flex-col overflow-hidden rounded-t-[22px] bg-white shadow-[0_-10px_32px_rgba(0,0,0,0.12)]",
"flutter-draggable-sheet",
isClosing ? "flutter-sheet-surface-exit" : "flutter-sheet-surface",
].join(" ")}
>
{/* Drag handle & Header */}
<div ref={headerRef} className="flex flex-col select-none touch-none">
<div className="flex justify-center pt-2.5 pb-1 cursor-grab active:cursor-grabbing">
<div className="h-1.2 w-10 rounded-full bg-[#D0D5DD]" />
</div>
<div className="flex items-center justify-between gap-3 px-5 pt-1 pb-3 border-b border-[#F2F4F7]">
<span className="text-[16px] font-bold text-[#181818]">
{termsTitle} {termsTitle}
</span> </span>
<button <button
type="button" type="button"
aria-label={t["Close terms and conditions"] || "Close terms and conditions"}
className="-mt-0.5 flex size-6 shrink-0 items-center justify-center text-[#8F8F8F] cursor-pointer"
onClick={close}
aria-label={closeAriaLabel}
className="flex size-8 shrink-0 items-center justify-center rounded-full text-[#667085] hover:bg-[#F2F4F7] hover:text-[#181818] transition-colors cursor-pointer border-none bg-transparent"
onClick={closeSheet}
> >
<Ic name="close" aria-hidden="true" className="size-5" /> <Ic name="close" aria-hidden="true" className="size-5" />
</button> </button>
</span>
)}
description={
<div className="h-[56dvh] max-h-[60dvh] space-y-4 overflow-y-auto pr-1 text-start text-[12px] leading-[1.45] text-[#4C4C4C]">
</div>
</div>
{/* Scrollable Terms Content */}
<div
ref={contentRef}
data-testid="terms-sheet-content"
onTouchStart={(event) => event.stopPropagation()}
onTouchMove={(event) => event.stopPropagation()}
onTouchEnd={(event) => event.stopPropagation()}
className="flex-1 min-h-0 overflow-y-auto overscroll-contain px-5 py-4 space-y-4 text-start text-[13px] leading-[1.6] text-[#4C4C4C]"
>
{TERMS_SECTIONS.map((section, idx) => ( {TERMS_SECTIONS.map((section, idx) => (
<div key={idx} className="space-y-2"> <div key={idx} className="space-y-2">
<h4 className="text-[13px] font-bold text-[#F14B46]">
{(t as Record<string, string>)[section.category] || section.category}
<h4 className="text-[14px] font-bold text-[#F14B46]">
{(t as Record<string, string>)[section.category] ||
section.category}
</h4> </h4>
<ul className="space-y-2 list-disc ps-4 marker:text-[#2B2B2B]">
<ul className="space-y-2.5 list-disc ps-4 marker:text-[#2B2B2B]">
{section.items.map((item, itemIdx) => { {section.items.map((item, itemIdx) => {
const translatedTitle = item.title const translatedTitle = item.title
? (t as Record<string, string>)[item.title] || item.title ? (t as Record<string, string>)[item.title] || item.title
@ -266,11 +616,11 @@ export function TermsSheet({ isOpen, onClose }: TermsSheetProps) {
return ( return (
<li key={itemIdx} className="leading-relaxed"> <li key={itemIdx} className="leading-relaxed">
{translatedTitle && ( {translatedTitle && (
<span className="font-bold text-[#262626]">
<span className="font-bold text-[#181818]">
{translatedTitle}:{" "} {translatedTitle}:{" "}
</span> </span>
)} )}
<span className="font-normal text-[#525252]">
<span className="font-normal text-[#475467]">
{translatedDesc} {translatedDesc}
</span> </span>
</li> </li>
@ -280,15 +630,21 @@ export function TermsSheet({ isOpen, onClose }: TermsSheetProps) {
</div> </div>
))} ))}
</div> </div>
}
buttons={({ close }) => (
<Button className="rounded-[8px]" onClick={close}>
{/* Bottom Confirm Action */}
<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)] cursor-pointer"
>
{gotItLabel} {gotItLabel}
</Button> </Button>
)}
onClose={onClose}
className="text-start"
/>
</div>
</section>
</div>,
document.body,
); );
} }

32
src/components/Componentes/ui-icon.tsx

@ -34,7 +34,8 @@ export type UiIconName =
| "personality" | "personality"
| "glasser" | "glasser"
| "success" | "success"
| "diamond";
| "diamond"
| "play";
type UiIconProps = SVGProps<SVGSVGElement> & { type UiIconProps = SVGProps<SVGSVGElement> & {
name: UiIconName; name: UiIconName;
@ -527,6 +528,35 @@ export function UiIcon({ name, ...props }: UiIconProps) {
</svg> </svg>
); );
case "play":
return (
<svg
viewBox="0 0 50 50"
width={props.width ?? 50}
height={props.height ?? 50}
fill="none"
aria-hidden="true"
{...props}
>
<path
d="M20.8568 12.264C17.3839 10.239 13.0234 12.7432 13.0234 16.764V33.2348C13.0234 37.2557 17.3839 39.7598 20.8568 37.7348L34.9755 29.4973C38.4193 27.489 38.4193 22.5098 34.9755 20.5015L20.8568 12.264Z"
fill={`url(#${gradientId})`}
/>
<defs>
<linearGradient
id={gradientId}
x1="37.7375"
y1="38.5196"
x2="16.7157"
y2="14.8915"
gradientUnits="userSpaceOnUse"
>
{PINK_GRADIENT_STOPS}
</linearGradient>
</defs>
</svg>
);
default: default:
return null; return null;
} }

255
src/data/country-calling-codes.ts

@ -0,0 +1,255 @@
/**
* ITU-T E.164 country calling codes mapped by ISO 3166-1 alpha-2 country code.
* Ultra-lightweight (~3KB) replacement for importing the entire google-libphonenumber
* library into the critical bootstrap and I18n bundles.
*/
export const COUNTRY_CALLING_CODES: Readonly<Record<string, string>> = {
AF: "93",
AL: "355",
DZ: "213",
AS: "1",
AD: "376",
AO: "244",
AI: "1",
AG: "1",
AR: "54",
AM: "374",
AW: "297",
AU: "61",
AT: "43",
AZ: "994",
BS: "1",
BH: "973",
BD: "880",
BB: "1",
BY: "375",
BE: "32",
BZ: "501",
BJ: "229",
BM: "1",
BT: "975",
BO: "591",
BA: "387",
BW: "267",
BR: "55",
IO: "246",
VG: "1",
BN: "673",
BG: "359",
BF: "226",
BI: "257",
KH: "855",
CM: "237",
CA: "1",
CV: "238",
KY: "1",
CF: "236",
TD: "235",
CL: "56",
CN: "86",
CX: "61",
CC: "61",
CO: "57",
KM: "269",
CK: "682",
CR: "506",
HR: "385",
CU: "53",
CW: "599",
CY: "357",
CZ: "420",
CD: "243",
DK: "45",
DJ: "253",
DM: "1",
DO: "1",
EC: "593",
EG: "20",
SV: "503",
GQ: "240",
ER: "291",
EE: "372",
SZ: "268",
ET: "251",
FK: "500",
FO: "298",
FJ: "679",
FI: "358",
FR: "33",
GF: "594",
PF: "689",
GA: "241",
GM: "220",
GE: "995",
DE: "49",
GH: "233",
GI: "350",
GR: "30",
GL: "299",
GD: "1",
GP: "590",
GU: "1",
GT: "502",
GG: "44",
GN: "224",
GW: "245",
GY: "592",
HT: "509",
HN: "504",
HK: "852",
HU: "36",
IS: "354",
IN: "91",
ID: "62",
IR: "98",
IQ: "964",
IE: "353",
IM: "44",
IL: "972",
IT: "39",
CI: "225",
JM: "1",
JP: "81",
JE: "44",
JO: "962",
KZ: "7",
KE: "254",
KI: "686",
XK: "383",
KW: "965",
KG: "996",
LA: "856",
LV: "371",
LB: "961",
LS: "266",
LR: "231",
LY: "218",
LI: "423",
LT: "370",
LU: "352",
MO: "853",
MG: "261",
MW: "265",
MY: "60",
MV: "960",
ML: "223",
MT: "356",
MH: "692",
MQ: "596",
MR: "222",
MU: "230",
YT: "262",
MX: "52",
FM: "691",
MD: "373",
MC: "377",
MN: "976",
ME: "382",
MS: "1",
MA: "212",
MZ: "258",
MM: "95",
NA: "264",
NR: "674",
NP: "977",
NL: "31",
NC: "687",
NZ: "64",
NI: "505",
NE: "227",
NG: "234",
NU: "683",
NF: "672",
KP: "850",
MK: "389",
MP: "1",
NO: "47",
OM: "968",
PK: "92",
PW: "680",
PS: "970",
PA: "507",
PG: "675",
PY: "595",
PE: "51",
PH: "63",
PL: "48",
PT: "351",
PR: "1",
QA: "974",
CG: "242",
RE: "262",
RO: "40",
RU: "7",
RW: "250",
BL: "590",
SH: "290",
KN: "1",
LC: "1",
MF: "590",
PM: "508",
VC: "1",
WS: "685",
SM: "378",
ST: "239",
SA: "966",
SN: "221",
RS: "381",
SC: "248",
SL: "232",
SG: "65",
SX: "1",
SK: "421",
SI: "386",
SB: "677",
SO: "252",
ZA: "27",
KR: "82",
SS: "211",
ES: "34",
LK: "94",
SD: "249",
SR: "597",
SE: "46",
CH: "41",
SY: "963",
TW: "886",
TJ: "992",
TZ: "255",
TH: "66",
TL: "670",
TG: "228",
TK: "690",
TO: "676",
TT: "1",
TN: "216",
TR: "90",
TM: "993",
TC: "1",
TV: "688",
UG: "256",
UA: "380",
AE: "971",
GB: "44",
US: "1",
UY: "598",
UZ: "998",
VU: "678",
VA: "379",
VE: "58",
VN: "84",
VI: "1",
WF: "681",
EH: "212",
YE: "967",
ZM: "260",
ZW: "263",
};
export function getCallingCodeForCountry(
isoCode?: string | null,
): string | undefined {
if (!isoCode) return undefined;
const upper = isoCode.trim().toUpperCase();
return COUNTRY_CALLING_CODES[upper];
}

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

@ -10,6 +10,8 @@ export interface FormOption {
value: string; value: string;
label: string; label: string;
order: number; order: number;
/** مانعةالجمع — از دیتابیس (ui_config.exclusive_options) توسط بک‌اند محاسبه می‌شود */
is_exclusive?: boolean;
} }
export interface FormQuestion { export interface FormQuestion {
@ -108,7 +110,7 @@ export function useFormOverviewQuery(formId: string, locale: string) {
return useQuery({ return useQuery({
queryKey: marriageQueryKeys.formOverview(formId, locale), queryKey: marriageQueryKeys.formOverview(formId, locale),
queryFn: () => getFormOverview(formId, locale), queryFn: () => getFormOverview(formId, locale),
staleTime: 30 * 1000,
staleTime: 3 * 60 * 1000,
refetchOnMount: "always", refetchOnMount: "always",
refetchOnWindowFocus: false, refetchOnWindowFocus: false,
}); });
@ -135,8 +137,8 @@ export function useFormSectionQuery(
queryKey: marriageQueryKeys.formSection(formId, slug, locale), queryKey: marriageQueryKeys.formSection(formId, slug, locale),
queryFn: () => getFormSection(formId, slug, locale), queryFn: () => getFormSection(formId, slug, locale),
enabled, enabled,
staleTime: 30 * 1000,
refetchOnMount: false,
staleTime: 3 * 60 * 1000,
refetchOnMount: true,
refetchOnWindowFocus: false, refetchOnWindowFocus: false,
}); });
} }
@ -157,7 +159,7 @@ export function useFormSchemaQuery<TData = FormSchemaResponse>(
options?: QueryOptions<FormSchemaResponse, TData>, options?: QueryOptions<FormSchemaResponse, TData>,
) { ) {
return useQuery({ return useQuery({
staleTime: 30 * 1000,
staleTime: 3 * 60 * 1000,
refetchOnMount: false, refetchOnMount: false,
refetchOnWindowFocus: false, refetchOnWindowFocus: false,
...options, ...options,

37
src/lib/auth-bridge.test.ts

@ -88,4 +88,41 @@ describe("authBridge", () => {
expect(authBridge.getMarriageData()).toEqual(marriagePayload); expect(authBridge.getMarriageData()).toEqual(marriagePayload);
}); });
it("extracts and parses URI-encoded HABIB_MARRIAGE_DATA cookie with Persian text", async () => {
const marriagePayload = {
id: 999,
status: "match_found",
city: "اصفهان",
name: "سارا",
};
mocks.getClientCookie.mockImplementation((name: string) => {
if (name === "HABIB_MARRIAGE_DATA") {
return encodeURIComponent(JSON.stringify(marriagePayload));
}
return null;
});
const { authBridge } = await import("./auth-bridge");
expect(authBridge.getMarriageData()).toEqual(marriagePayload);
});
it("falls back to parsing unencoded HABIB_MARRIAGE_DATA cookie", async () => {
const marriagePayload = {
id: 888,
status: "intro",
city: "تبریز",
};
mocks.getClientCookie.mockImplementation((name: string) => {
if (name === "HABIB_MARRIAGE_DATA") {
return JSON.stringify(marriagePayload);
}
return null;
});
const { authBridge } = await import("./auth-bridge");
expect(authBridge.getMarriageData()).toEqual(marriagePayload);
});
}); });

10
src/lib/geo-region.ts

@ -1,7 +1,7 @@
"use client"; "use client";
import { PhoneNumberUtil } from "google-libphonenumber";
import { resolveCountryName } from "@/data/countries"; import { resolveCountryName } from "@/data/countries";
import { getCallingCodeForCountry } from "@/data/country-calling-codes";
export type UserGeoRegion = { export type UserGeoRegion = {
ip?: string; ip?: string;
@ -13,8 +13,6 @@ export type UserGeoRegion = {
longitude?: number; longitude?: number;
}; };
const phoneUtil = PhoneNumberUtil.getInstance();
const STORAGE_KEY = "user_geo_region"; const STORAGE_KEY = "user_geo_region";
const PHONE_STORAGE_KEY = "geoIPPhoneCode"; const PHONE_STORAGE_KEY = "geoIPPhoneCode";
@ -84,14 +82,10 @@ export function setStoredUserGeoRegion(region: UserGeoRegion) {
function resolvePhoneCodeFromCountryCode(isoCode?: string): string | undefined { function resolvePhoneCodeFromCountryCode(isoCode?: string): string | undefined {
if (!isoCode) return undefined; if (!isoCode) return undefined;
try {
const callingCode = phoneUtil.getCountryCodeForRegion(
isoCode.toUpperCase(),
);
const callingCode = getCallingCodeForCountry(isoCode);
if (callingCode) { if (callingCode) {
return `+${callingCode}`; return `+${callingCode}`;
} }
} catch {}
return undefined; return undefined;
} }

119
src/lib/marriage-cookie.test.ts

@ -0,0 +1,119 @@
import { describe, expect, it } from "vitest";
import { getInitialMarriageProfile } from "@/hooks/marriage/use-profile-main";
import type { MarriageProfileResponse } from "@/hooks/marriage/types";
/**
* Robust parser replicating the layout.tsx and auth-bridge.ts cookie resolution strategy.
*/
export function parseMarriageCookie(
rawCookie: string | null | undefined,
): { parsedJson: any; profile?: MarriageProfileResponse } | null {
if (!rawCookie || !rawCookie.trim()) return null;
const trimmed = rawCookie.trim();
// Strategy 1: Try decoding URI component first (Standard RFC-6265 percent-encoded cookie from Flutter)
try {
const decoded = decodeURIComponent(trimmed);
const parsed = JSON.parse(decoded);
return {
parsedJson: parsed,
profile: getInitialMarriageProfile(parsed),
};
} catch {
// Strategy 2: Fallback for legacy raw unencoded JSON cookies
try {
const parsed = JSON.parse(trimmed);
return {
parsedJson: parsed,
profile: getInitialMarriageProfile(parsed),
};
} catch {
return null;
}
}
}
describe("HABIB_MARRIAGE_DATA Cookie Encoding & Decoding Suite", () => {
it("successfully parses standard URL-encoded JSON with Persian characters (Uri.encodeComponent)", () => {
const originalData = {
id: 101,
status: "match_found",
gender: "male",
city: "تهران",
bio: "مهندس کامپیوتر، علاقه‌مند به کتاب‌خوانی و طبیعت‌گردی",
match_summary: {
id: 202,
gender: "female",
public_info: [
{ label: "شهر", value: "اصفهان" },
{ label: "تحصیلات", value: "کارشناسی ارشد" },
],
},
};
// Simulate Flutter: Uri.encodeComponent(json.encode(marriageData))
const encodedCookie = encodeURIComponent(JSON.stringify(originalData));
// Verify it's purely US-ASCII
expect(/^[\x00-\x7F]*$/.test(encodedCookie)).toBe(true);
const result = parseMarriageCookie(encodedCookie);
expect(result).not.toBeNull();
expect(result?.parsedJson.city).toBe("تهران");
expect(result?.parsedJson.bio).toBe("مهندس کامپیوتر، علاقه‌مند به کتاب‌خوانی و طبیعت‌گردی");
expect(result?.parsedJson.match_summary.public_info[0].value).toBe("اصفهان");
expect(result?.profile?.status).toBe("match_found");
});
it("successfully parses legacy raw unencoded JSON with Persian characters", () => {
const legacyRawCookie = JSON.stringify({
id: 102,
status: "active",
gender: "female",
city: "مشهد",
interests: ["زیارت", "خانواده"],
});
const result = parseMarriageCookie(legacyRawCookie);
expect(result).not.toBeNull();
expect(result?.parsedJson.city).toBe("مشهد");
expect(result?.parsedJson.interests).toEqual(["زیارت", "خانواده"]);
});
it("handles complex strings with percentage signs, quotes, and emojis without crashing", () => {
const complexData = {
id: 103,
status: "match_found",
note: "تطابق با دقت ۱۰۰% و رضایت ۹۵% 💍❤️",
query: "param1=val&param2=50%off",
};
const encodedCookie = encodeURIComponent(JSON.stringify(complexData));
const result = parseMarriageCookie(encodedCookie);
expect(result).not.toBeNull();
expect(result?.parsedJson.note).toBe("تطابق با دقت ۱۰۰% و رضایت ۹۵% 💍❤️");
expect(result?.parsedJson.query).toBe("param1=val&param2=50%off");
});
it("handles unencoded JSON containing raw percent sign gracefully via fallback", () => {
// If an unencoded cookie contains raw %, decodeURIComponent will throw URIError.
// The parser MUST catch it and fallback to JSON.parse directly.
const rawWithPercent = '{"status":"active","progress":"100% completed","city":"شیراز"}';
const result = parseMarriageCookie(rawWithPercent);
expect(result).not.toBeNull();
expect(result?.parsedJson.progress).toBe("100% completed");
expect(result?.parsedJson.city).toBe("شیراز");
});
it("returns null safely for corrupted, empty, or invalid cookies", () => {
expect(parseMarriageCookie(null)).toBeNull();
expect(parseMarriageCookie("")).toBeNull();
expect(parseMarriageCookie(" ")).toBeNull();
expect(parseMarriageCookie("undefined")).toBeNull();
expect(parseMarriageCookie("invalid-not-json")).toBeNull();
expect(parseMarriageCookie("%E0%A4%A")).toBeNull(); // Malformed URI sequence
});
});

132
src/lib/multi-select-helper.test.ts

@ -0,0 +1,132 @@
import { describe, it, expect } from "vitest";
import {
isExclusiveOption,
resolveMultiOptionToggle,
type MultiSelectOption,
} from "./multi-select-helper";
describe("multi-select-helper", () => {
const options: MultiSelectOption[] = [
{ id: "opt1", value: "football", label: "Football" },
{ id: "opt2", value: "swimming", label: "Swimming" },
{ id: "opt3", value: "running", label: "Running" },
{ id: "opt_none", value: "none", label: "None of the above", is_exclusive: true },
];
describe("isExclusiveOption", () => {
it("identifies explicit is_exclusive property", () => {
expect(isExclusiveOption({ id: "custom", is_exclusive: true })).toBe(true);
expect(isExclusiveOption({ id: "custom", is_exclusive: false })).toBe(false);
});
it("accepts the explicit flag and rejects everything else (SSOT: backend flag only)", () => {
expect(isExclusiveOption({ id: "custom", is_exclusive: true })).toBe(true);
expect(isExclusiveOption({ id: "custom", is_exclusive: false })).toBe(false);
expect(isExclusiveOption({ id: "custom" })).toBe(false);
});
it("returns false for missing/empty/string-only input (no flag = not exclusive)", () => {
expect(isExclusiveOption("")).toBe(false);
expect(isExclusiveOption("test.none")).toBe(false);
});
it("does NOT guess from canonical values or slug suffixes (SSOT: backend flag only)", () => {
expect(isExclusiveOption({ id: "test.none", value: "none" })).toBe(false);
expect(isExclusiveOption({ id: "test.no_pets", value: "no_pets" })).toBe(false);
expect(
isExclusiveOption(
"spouse_criteria.appearance_dealbreakers_aspects.no_appearance_feature_alone_makes_difficult",
),
).toBe(false);
expect(
isExclusiveOption({
id: "spouse_criteria.appearance_dealbreakers_aspects.no_appearance_feature_alone_makes_difficult",
value: "no_appearance_feature_alone_makes_difficult",
}),
).toBe(false);
});
});
describe("resolveMultiOptionToggle", () => {
it("adds a regular option when none was selected", () => {
const res = resolveMultiOptionToggle({
currentSelected: [],
optionId: "opt1",
options,
});
expect(res).toEqual(["opt1"]);
});
it("adds multiple regular options sequentially", () => {
const res1 = resolveMultiOptionToggle({
currentSelected: ["opt1"],
optionId: "opt2",
options,
});
expect(res1).toEqual(["opt1", "opt2"]);
const res2 = resolveMultiOptionToggle({
currentSelected: ["opt1", "opt2"],
optionId: "opt3",
options,
});
expect(res2).toEqual(["opt1", "opt2", "opt3"]);
});
it("deselects a regular option when toggled again", () => {
const res = resolveMultiOptionToggle({
currentSelected: ["opt1", "opt2"],
optionId: "opt1",
options,
});
expect(res).toEqual(["opt2"]);
});
it("clears ALL regular options when an exclusive option is selected", () => {
const res = resolveMultiOptionToggle({
currentSelected: ["opt1", "opt2", "opt3"],
optionId: "opt_none",
options,
});
expect(res).toEqual(["opt_none"]);
});
it("deselects the exclusive option when toggled again", () => {
const res = resolveMultiOptionToggle({
currentSelected: ["opt_none"],
optionId: "opt_none",
options,
});
expect(res).toEqual([]);
});
it("clears the exclusive option when a regular option is clicked", () => {
const res = resolveMultiOptionToggle({
currentSelected: ["opt_none"],
optionId: "opt1",
options,
});
expect(res).toEqual(["opt1"]);
});
it("enforces maxSelect for regular options without exclusive conflict", () => {
const res = resolveMultiOptionToggle({
currentSelected: ["opt1", "opt2"],
optionId: "opt3",
options,
maxSelect: 2,
});
expect(res).toEqual(["opt1", "opt2"]); // Capped at 2
});
it("allows selecting exclusive option even when regular options reach maxSelect", () => {
const res = resolveMultiOptionToggle({
currentSelected: ["opt1", "opt2"],
optionId: "opt_none",
options,
maxSelect: 2,
});
expect(res).toEqual(["opt_none"]);
});
});
});

76
src/lib/multi-select-helper.ts

@ -0,0 +1,76 @@
export type MultiSelectOption = {
id: string;
value?: string | number;
label?: string;
is_exclusive?: boolean;
};
/**
* Determine if an option is mutually exclusive (مانعةالجمع) with the other
* options of its question.
*
* Single Source of Truth: exclusivity is configured in the live database
* (via the admin dashboard) and delivered by the backend as the
* `is_exclusive` flag on every option. No hardcoded slug/suffix heuristics.
*/
export function isExclusiveOption(option: MultiSelectOption | string): boolean {
if (!option || typeof option !== "object") return false;
return option.is_exclusive === true;
}
/**
* Resolves toggling an option in a multi-select context with automatic mutual exclusion (مانعةالجمع).
*
* Behavior:
* 1. If an exclusive option is selected:
* - All other selected options are automatically cleared (deselected).
* - Only the exclusive option remains selected.
* 2. If an exclusive option is deselected:
* - It is removed, leaving an empty selection.
* 3. If a regular (non-exclusive) option is selected while an exclusive option was active:
* - The exclusive option is automatically cleared (deselected).
* - The new regular option is selected.
* 4. Respects maxSelect limit for regular options.
*/
export function resolveMultiOptionToggle({
currentSelected,
optionId,
options = [],
maxSelect,
}: {
currentSelected: string[];
optionId: string;
options?: MultiSelectOption[];
maxSelect?: number;
}): string[] {
const targetOption = options.find((o) => o.id === optionId) || { id: optionId };
const isExclusive = isExclusiveOption(targetOption);
const isAlreadySelected = currentSelected.includes(optionId);
// Case 1: Toggling an exclusive option
if (isExclusive) {
if (isAlreadySelected) {
return [];
}
return [optionId];
}
// Case 2: Toggling a regular option that is already selected -> remove it
if (isAlreadySelected) {
return currentSelected.filter((id) => id !== optionId);
}
// Case 3: Adding a regular option that was NOT selected:
// First, filter out any exclusive option(s) from current selection
const cleanSelected = currentSelected.filter((id) => {
const opt = options.find((o) => o.id === id) || { id };
return !isExclusiveOption(opt);
});
// Check maxSelect limit
if (maxSelect && cleanSelected.length >= maxSelect) {
return cleanSelected;
}
return [...cleanSelected, optionId];
}

2
src/lib/schema-adapter.ts

@ -142,6 +142,7 @@ export type QuestionField = {
value: string | number; value: string | number;
label: string; label: string;
order: number; order: number;
is_exclusive?: boolean;
}[]; }[];
}; };
@ -274,6 +275,7 @@ export function mapBackendQuestionToFrontend(
.map((o) => ({ .map((o) => ({
...o, ...o,
label: o.label || o.value || "", label: o.label || o.value || "",
is_exclusive: Boolean(o.is_exclusive),
})), })),
}; };
} }

Loading…
Cancel
Save