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/components/Componentes/information-sheet.test.tsx b/src/components/Componentes/information-sheet.test.tsx
index e0f0c0d..14f6f24 100644
--- a/src/components/Componentes/information-sheet.test.tsx
+++ b/src/components/Componentes/information-sheet.test.tsx
@@ -131,5 +131,113 @@ describe("InformationSheet", () => {
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/ui-icon.tsx b/src/components/Componentes/ui-icon.tsx
index 160f902..1b73beb 100644
--- a/src/components/Componentes/ui-icon.tsx
+++ b/src/components/Componentes/ui-icon.tsx
@@ -34,7 +34,8 @@ export type UiIconName =
| "personality"
| "glasser"
| "success"
- | "diamond";
+ | "diamond"
+ | "play";
type UiIconProps = SVGProps & {
name: UiIconName;
@@ -527,6 +528,35 @@ export function UiIcon({ name, ...props }: UiIconProps) {
);
+ case "play":
+ return (
+
+ );
+
default:
return null;
}