diff --git a/next.config.ts b/next.config.ts index 9001e6c..8439580 100644 --- a/next.config.ts +++ b/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/).*)", headers: [ { 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", + }, + ], + }, ]; }, }; diff --git a/public/assets/fonts/ArabicYekanXRegular.woff2 b/public/assets/fonts/ArabicYekanXRegular.woff2 new file mode 100644 index 0000000..1d4f3c1 Binary files /dev/null and b/public/assets/fonts/ArabicYekanXRegular.woff2 differ diff --git a/public/assets/fonts/Faminela.woff2 b/public/assets/fonts/Faminela.woff2 new file mode 100644 index 0000000..9b4d7f6 Binary files /dev/null and b/public/assets/fonts/Faminela.woff2 differ diff --git a/public/assets/fonts/NotoNastaliqUrdu.woff2 b/public/assets/fonts/NotoNastaliqUrdu.woff2 new file mode 100644 index 0000000..194e2c3 Binary files /dev/null and b/public/assets/fonts/NotoNastaliqUrdu.woff2 differ diff --git a/public/assets/fonts/SegoeUIBold.woff2 b/public/assets/fonts/SegoeUIBold.woff2 new file mode 100644 index 0000000..be59e22 Binary files /dev/null and b/public/assets/fonts/SegoeUIBold.woff2 differ diff --git a/public/assets/fonts/YekanXFaNum-R.woff2 b/public/assets/fonts/YekanXFaNum-R.woff2 new file mode 100644 index 0000000..c7df6f8 Binary files /dev/null and b/public/assets/fonts/YekanXFaNum-R.woff2 differ diff --git a/public/assets/fonts/faminela.woff2 b/public/assets/fonts/faminela.woff2 new file mode 100644 index 0000000..66b924f Binary files /dev/null and b/public/assets/fonts/faminela.woff2 differ diff --git a/public/assets/fonts/segoeui.woff2 b/public/assets/fonts/segoeui.woff2 new file mode 100644 index 0000000..68d4918 Binary files /dev/null and b/public/assets/fonts/segoeui.woff2 differ diff --git a/public/fonts/Amiri/Amiri-Bold.woff2 b/public/fonts/Amiri/Amiri-Bold.woff2 new file mode 100644 index 0000000..20a7398 Binary files /dev/null and b/public/fonts/Amiri/Amiri-Bold.woff2 differ diff --git a/public/fonts/Amiri/Amiri-BoldItalic.woff2 b/public/fonts/Amiri/Amiri-BoldItalic.woff2 new file mode 100644 index 0000000..62f0a69 Binary files /dev/null and b/public/fonts/Amiri/Amiri-BoldItalic.woff2 differ diff --git a/public/fonts/Amiri/Amiri-Italic.woff2 b/public/fonts/Amiri/Amiri-Italic.woff2 new file mode 100644 index 0000000..1fcdfce Binary files /dev/null and b/public/fonts/Amiri/Amiri-Italic.woff2 differ diff --git a/public/fonts/Amiri/Amiri-Regular.woff2 b/public/fonts/Amiri/Amiri-Regular.woff2 new file mode 100644 index 0000000..f2732df Binary files /dev/null and b/public/fonts/Amiri/Amiri-Regular.woff2 differ diff --git a/public/fonts/Faminela/Faminela.woff2 b/public/fonts/Faminela/Faminela.woff2 new file mode 100644 index 0000000..9b4d7f6 Binary files /dev/null and b/public/fonts/Faminela/Faminela.woff2 differ diff --git a/public/fonts/segoeui/SegoeUIBold.woff2 b/public/fonts/segoeui/SegoeUIBold.woff2 new file mode 100644 index 0000000..be59e22 Binary files /dev/null and b/public/fonts/segoeui/SegoeUIBold.woff2 differ diff --git a/public/fonts/segoeui/segoeui.woff2 b/public/fonts/segoeui/segoeui.woff2 new file mode 100644 index 0000000..68d4918 Binary files /dev/null and b/public/fonts/segoeui/segoeui.woff2 differ diff --git a/public/fonts/urdu/NotoNastaliqUrdu.woff2 b/public/fonts/urdu/NotoNastaliqUrdu.woff2 new file mode 100644 index 0000000..194e2c3 Binary files /dev/null and b/public/fonts/urdu/NotoNastaliqUrdu.woff2 differ diff --git a/public/fonts/yekanx-arabic/ArabicYekanXRegular.woff2 b/public/fonts/yekanx-arabic/ArabicYekanXRegular.woff2 new file mode 100644 index 0000000..1d4f3c1 Binary files /dev/null and b/public/fonts/yekanx-arabic/ArabicYekanXRegular.woff2 differ diff --git a/public/fonts/yekanx/YekanXFaNum-R.woff2 b/public/fonts/yekanx/YekanXFaNum-R.woff2 new file mode 100644 index 0000000..c7df6f8 Binary files /dev/null and b/public/fonts/yekanx/YekanXFaNum-R.woff2 differ diff --git a/src/app/globals.css b/src/app/globals.css index fd3e457..3a00c75 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -3,7 +3,9 @@ /* ─── App fonts matching Flutter Najm & Hosseinieh ─── */ @font-face { 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.ttf') format('truetype'); font-weight: 400 900; @@ -12,35 +14,40 @@ } @font-face { 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-style: normal; font-display: swap; } @font-face { 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-style: normal; font-display: swap; } @font-face { 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-style: normal; font-display: swap; } @font-face { 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-style: normal; font-display: swap; } @font-face { 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-style: normal; font-display: swap; diff --git a/src/app/questions-list/[slug]/answer-pace-sheet.test.tsx b/src/app/questions-list/[slug]/answer-pace-sheet.test.tsx new file mode 100644 index 0000000..d9add41 --- /dev/null +++ b/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( + , + ); + + expect(screen.queryByRole("dialog")).toBeNull(); + }); + + it("renders when activeQuestionIndex >= 3 and displays inline play icon synchronously without network img tag", () => { + render( + , + ); + + 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 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( + , + ); + + const continueBtn = screen.getByRole("button", { name: "متوجه شدم" }); + fireEvent.click(continueBtn); + await waitFor(() => { + expect(isAnswerPaceSheetSeen()).toBe(true); + }); + }); +}); diff --git a/src/app/questions-list/[slug]/question-detail-client.tsx b/src/app/questions-list/[slug]/question-detail-client.tsx index c610f37..04e28e9 100644 --- a/src/app/questions-list/[slug]/question-detail-client.tsx +++ b/src/app/questions-list/[slug]/question-detail-client.tsx @@ -349,13 +349,18 @@ export default function QuestionDetailClient({ } catch {} return false; }, [isAssessment, overview, itemSlug, profileId]); - const { data: sectionResponse, isLoading: isSectionLoading, isError: isSectionError, refetch: refetchSection } = - useFormSectionQuery( - "profile", - itemSlug, - locale, - Boolean(itemSlug) && !isAssessment, - ); + const { + data: sectionResponse, + isLoading: isSectionLoading, + isPending: isSectionPending, + isError: isSectionError, + refetch: refetchSection, + } = useFormSectionQuery( + "profile", + itemSlug, + locale, + Boolean(itemSlug) && !isAssessment, + ); const items = useMemo( () => convertOverviewToFrontendItems(overview), [overview], @@ -368,14 +373,17 @@ export default function QuestionDetailClient({ sectionResponse.section_progress.completion_percent, ); } - return overviewItem; + if (isAssessment) { + return overviewItem; + } + return null; }, [isAssessment, overviewItem, sectionResponse]); const isSchemaError = isAssessment ? isOverviewError : (isOverviewError || isSectionError) && !sectionResponse; const isSchemaLoading = isAssessment ? isOverviewLoading - : !sectionResponse && (isOverviewLoading || isSectionLoading); + : !sectionResponse && !isSchemaError && (isOverviewLoading || isSectionLoading || isSectionPending); const cattellQuery = useCattellQuestionsQuery(locale, { enabled: isCattellSlug && isTestStarted, @@ -395,13 +403,13 @@ export default function QuestionDetailClient({ void queryClient.prefetchQuery({ queryKey: marriageQueryKeys.cattellQuestions(locale), queryFn: () => getCattellQuestions(locale), - staleTime: 30 * 1000, + staleTime: 3 * 60 * 1000, }); } else if (isGlasserSlug) { void queryClient.prefetchQuery({ queryKey: marriageQueryKeys.glasserQuestions(locale), queryFn: () => getGlasserQuestions(locale), - staleTime: 30 * 1000, + staleTime: 3 * 60 * 1000, }); } }, [ @@ -534,7 +542,7 @@ export default function QuestionDetailClient({
diff --git a/src/app/questions-list/questions-list-client.tsx b/src/app/questions-list/questions-list-client.tsx index dc7577f..149ea00 100644 --- a/src/app/questions-list/questions-list-client.tsx +++ b/src/app/questions-list/questions-list-client.tsx @@ -543,13 +543,13 @@ export default function QuestionsListClient({ void queryClient.prefetchQuery({ queryKey: marriageQueryKeys.cattellQuestions(locale), queryFn: () => getCattellQuestions(locale), - staleTime: 30 * 1000, + staleTime: 3 * 60 * 1000, }); } else { void queryClient.prefetchQuery({ queryKey: marriageQueryKeys.glasserQuestions(locale), queryFn: () => getGlasserQuestions(locale), - staleTime: 30 * 1000, + staleTime: 3 * 60 * 1000, }); } return; @@ -558,7 +558,7 @@ export default function QuestionsListClient({ void queryClient.prefetchQuery({ queryKey: marriageQueryKeys.formSection("profile", item.slug, locale), queryFn: () => getFormSection("profile", item.slug, locale), - staleTime: 30 * 1000, + staleTime: 3 * 60 * 1000, }); }, [locale, queryClient, router], @@ -608,7 +608,7 @@ export default function QuestionsListClient({ void queryClient.prefetchQuery({ queryKey: marriageQueryKeys.formSection("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({ queryKey: marriageQueryKeys.formSection("profile", item.slug, locale), queryFn: () => getFormSection("profile", item.slug, locale), - staleTime: 30 * 1000, + staleTime: 3 * 60 * 1000, }), () => cancelled, ); @@ -1007,6 +1007,7 @@ export default function QuestionsListClient({ > {activeSectionItem ? ( { expect(section?.style.paddingBottom).toBe(""); }); }); + + it("should render inline SVG for play icon preset without network img tag", () => { + render( + , + ); + + const dialog = screen.getByRole("dialog"); + // Should NOT contain an 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( + , + ); + + 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( + , + ); + + let dialog = screen.getByRole("dialog"); + expect(dialog.querySelector("img")).toBeNull(); + expect(dialog.querySelector('svg[viewBox="0 0 36 36"]')).not.toBeNull(); + + rerender( + , + ); + dialog = screen.getByRole("dialog"); + expect(dialog.querySelector("img")).toBeNull(); + expect(dialog.querySelector('svg[viewBox="0 0 36 36"]')).not.toBeNull(); + + rerender( + , + ); + 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( + , + ); + + 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( + Custom Icon} + title="Custom Icon Title" + description="Custom Icon Description" + />, + ); + + expect(screen.getByTestId("custom-icon")).toBeDefined(); + expect(screen.getByText("Custom Icon")).toBeDefined(); + }); }); diff --git a/src/components/Componentes/information-sheet.tsx b/src/components/Componentes/information-sheet.tsx index c5a9af9..0d064d1 100644 --- a/src/components/Componentes/information-sheet.tsx +++ b/src/components/Componentes/information-sheet.tsx @@ -2,10 +2,11 @@ import Image, { type StaticImageData } from "next/image"; 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 Button from "./button"; import { LoadingThreeDot } from "./loading-three-dot"; +import { UiIcon } from "./ui-icon"; import { useHardwareBackHandler } from "@/hooks/use-hardware-back-handler"; import { viewPaddingsBridge } from "@/lib/view-paddings"; import { useI18n } from "@/translations/provider"; @@ -67,7 +68,8 @@ type InformationSheetIcon = alt?: string; width?: number; height?: number; - }; + } + | ReactNode; export type InformationSheetProps = Omit< HTMLAttributes, @@ -177,7 +179,7 @@ const ICON_PRESETS: Record< }; function resolveIcon(icon: InformationSheetIcon | null | undefined) { - if (icon === null) { + if (icon === null || isValidElement(icon)) { return null; } @@ -206,12 +208,121 @@ function resolveIcon(icon: InformationSheetIcon | null | undefined) { ); } - return { - src: icon.src, - alt: icon.alt ?? "Information", - width: icon.width ?? DEFAULT_ICON.width, - height: icon.height ?? DEFAULT_ICON.height, - }; + if (typeof icon === "object" && "src" in icon) { + return { + src: icon.src, + alt: icon.alt ?? "Information", + width: icon.width ?? DEFAULT_ICON.width, + height: icon.height ?? DEFAULT_ICON.height, + }; + } + + 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 ( + + ); + } + + if ( + icon === "warning" || + icon === "warning.svg" || + icon === "/assets/images/Vector.svg" + ) { + return ( + + ); + } + + if ( + icon === "check" || + icon === "Vectorcheck.svg" || + icon === "/assets/images/Vectofdasr.svg" || + icon === "/assets/images/Vectorcheck.svg" + ) { + return ( + + ); + } + + if (typeof icon === "string" && icon.includes("diamond")) { + return ( + + ); + } + + const resolved = resolveIcon(icon); + if (!resolved) return null; + + return ( + {resolved.alt} + ); } export function InformationSheet({ @@ -285,7 +396,7 @@ export function InformationSheet({ } }, [isOpen, isRendered, closeSheet]); - const resolvedIcon = resolveIcon(icon); + const renderedIcon = renderSheetIcon(icon); useHardwareBackHandler(() => { closeSheet(); @@ -521,19 +632,11 @@ export function InformationSheet({ ) : ( <> - {resolvedIcon ? ( - {resolvedIcon.alt} - ) : null} + {renderedIcon}

diff --git a/src/components/Componentes/question-sheet.test.tsx b/src/components/Componentes/question-sheet.test.tsx index e001e88..bbd8747 100644 --- a/src/components/Componentes/question-sheet.test.tsx +++ b/src/components/Componentes/question-sheet.test.tsx @@ -1169,18 +1169,18 @@ describe("QuestionSheet component", () => { const shortBtn = screen.getByRole("button", { name: "موافق" }); 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("shrink-0"); expect(longBtn).toHaveClass("box-border"); + expect(longBtn.style.minHeight).toBe(""); }); }); }); diff --git a/src/components/Componentes/question-sheet.tsx b/src/components/Componentes/question-sheet.tsx index 53d3c7d..e775fd6 100644 --- a/src/components/Componentes/question-sheet.tsx +++ b/src/components/Componentes/question-sheet.tsx @@ -987,8 +987,6 @@ export function QuestionSheet({ question, disabled }: QuestionSheetProps) { !isSelected && Boolean(maxSelect && localSelectedList.length >= maxSelect); - const minHeightPx = calculateOptionMinHeight(option.label); - return (