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 (
+
+ );
}
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 ? (
-
- ) : 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 (