Browse Source

chore(staging): auto-merge origin/master into staging

staging
Ali Alavi 2 weeks ago
parent
commit
10db6240d1
  1. 18
      next.config.ts
  2. BIN
      public/assets/fonts/ArabicYekanXRegular.woff2
  3. BIN
      public/assets/fonts/Faminela.woff2
  4. BIN
      public/assets/fonts/NotoNastaliqUrdu.woff2
  5. BIN
      public/assets/fonts/SegoeUIBold.woff2
  6. BIN
      public/assets/fonts/YekanXFaNum-R.woff2
  7. BIN
      public/assets/fonts/faminela.woff2
  8. BIN
      public/assets/fonts/segoeui.woff2
  9. BIN
      public/fonts/Amiri/Amiri-Bold.woff2
  10. BIN
      public/fonts/Amiri/Amiri-BoldItalic.woff2
  11. BIN
      public/fonts/Amiri/Amiri-Italic.woff2
  12. BIN
      public/fonts/Amiri/Amiri-Regular.woff2
  13. BIN
      public/fonts/Faminela/Faminela.woff2
  14. BIN
      public/fonts/segoeui/SegoeUIBold.woff2
  15. BIN
      public/fonts/segoeui/segoeui.woff2
  16. BIN
      public/fonts/urdu/NotoNastaliqUrdu.woff2
  17. BIN
      public/fonts/yekanx-arabic/ArabicYekanXRegular.woff2
  18. BIN
      public/fonts/yekanx/YekanXFaNum-R.woff2
  19. 19
      src/app/globals.css
  20. 87
      src/app/questions-list/[slug]/answer-pace-sheet.test.tsx
  21. 20
      src/app/questions-list/[slug]/question-detail-client.tsx
  22. 11
      src/app/questions-list/questions-list-client.tsx
  23. 108
      src/components/Componentes/information-sheet.test.tsx
  24. 131
      src/components/Componentes/information-sheet.tsx
  25. 18
      src/components/Componentes/question-sheet.test.tsx
  26. 7
      src/components/Componentes/question-sheet.tsx
  27. 32
      src/components/Componentes/ui-icon.tsx
  28. 8
      src/hooks/marriage/use-form-schema.ts

18
next.config.ts

@ -86,16 +86,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",
}, },
], ],
}, },
@ -119,6 +115,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",
},
],
},
]; ];
}, },
}; };

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

19
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;

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}

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(" ")}
> >

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

@ -1169,18 +1169,18 @@ describe("QuestionSheet component", () => {
const shortBtn = screen.getByRole("button", { name: "موافق" }); const shortBtn = screen.getByRole("button", { name: "موافق" });
const longBtn = screen.getByRole("button", { name: longText }); const longBtn = screen.getByRole("button", { name: longText });
// Short button has baseline min-height: 52px
expect(shortBtn.style.minHeight).toBe("52px");
// Long button dynamically scales based on character count (>= 114px)
const expectedMinHeight = `${calculateOptionMinHeight(longText)}px`;
expect(longBtn.style.minHeight).toBe(expectedMinHeight);
expect(calculateOptionMinHeight(longText)).toBeGreaterThanOrEqual(114);
// Card container has h-auto and shrink-0 to prevent flex clamping
// 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("h-auto");
expect(longBtn).toHaveClass("shrink-0"); expect(longBtn).toHaveClass("shrink-0");
expect(longBtn).toHaveClass("box-border"); expect(longBtn).toHaveClass("box-border");
expect(longBtn.style.minHeight).toBe("");
}); });
}); });
}); });

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

@ -987,8 +987,6 @@ export function QuestionSheet({ question, disabled }: QuestionSheetProps) {
!isSelected && !isSelected &&
Boolean(maxSelect && localSelectedList.length >= maxSelect); Boolean(maxSelect && localSelectedList.length >= maxSelect);
const minHeightPx = calculateOptionMinHeight(option.label);
return ( return (
<button <button
key={option.id} key={option.id}
@ -1003,11 +1001,8 @@ export function QuestionSheet({ question, disabled }: QuestionSheetProps) {
handleSelectSingle(option.id); handleSelectSingle(option.id);
} }
}} }}
style={{
minHeight: `${minHeightPx}px`,
}}
className={[ className={[
"flex 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",
"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]" ? "opacity-35 cursor-not-allowed bg-[#F9FAFB] border-[#F2F4F7] text-[#98A2B3]"
: isSelected : isSelected

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;
} }

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

@ -110,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,
}); });
@ -137,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,
}); });
} }
@ -159,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,

Loading…
Cancel
Save