diff --git a/next.config.ts b/next.config.ts index 225be5f..e68a3e3 100644 --- a/next.config.ts +++ b/next.config.ts @@ -20,10 +20,6 @@ const nextConfig: NextConfig = { protocol: "https", hostname: "habibapp.com", }, - { - protocol: "https", - hostname: "habib.nwhco.ir", - }, ], }, @@ -85,20 +81,12 @@ const nextConfig: NextConfig = { ], }, { - // Application HTML pages – must never serve stale HTML so hashed JS chunks match current deployment + // 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", - }, - { - key: "Expires", - value: "0", + value: "public, max-age=0, stale-while-revalidate=86400", }, ], }, diff --git a/src/components/Componentes/question-photo.test.tsx b/src/components/Componentes/question-photo.test.tsx deleted file mode 100644 index 96dc452..0000000 --- a/src/components/Componentes/question-photo.test.tsx +++ /dev/null @@ -1,206 +0,0 @@ -import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react"; -import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import type { QuestionField } from "@/lib/schema-adapter"; -import { QuestionPhoto } from "./question-photo"; - -let answerMap: Record = {}; -const mockSetAnswerValue = vi.fn((q: QuestionField, val: unknown) => { - answerMap[q.id] = val; -}); -const mockRegisterPendingUpload = vi.fn(); - -const mockUploadTmpMedia = vi.fn(async (file: File) => ({ - path: `/media/tmp/${file.name}`, - name: file.name, -})); - -vi.mock("@/hooks/marriage/use-upload-tmp-media", () => ({ - useUploadTmpMediaMutation: ({ - onSuccess, - onError, - }: { - onSuccess?: (data: any) => void; - onError?: (err: any) => void; - }) => ({ - mutate: (file: File) => { - mockUploadTmpMedia(file) - .then((res) => onSuccess?.(res)) - .catch((err) => onError?.(err)); - }, - mutateAsync: async (file: File) => { - try { - const res = await mockUploadTmpMedia(file); - onSuccess?.(res); - return res; - } catch (err) { - onError?.(err); - throw err; - } - }, - isPending: false, - isError: false, - }), -})); - -vi.mock("./question-answer-storage", () => ({ - useQuestionAnswers: () => ({ - getAnswerValue: (q: QuestionField) => answerMap[q.id] ?? null, - setAnswerValue: mockSetAnswerValue, - registerPendingUpload: mockRegisterPendingUpload, - isLoading: false, - }), -})); - -vi.mock("@/translations/provider", () => ({ - useI18n: () => ({ - locale: "en", - dictionary: { - "Photo upload failed. Please try again.": "Photo upload failed. Please try again.", - "Failed to load image. Please try uploading again.": "Failed to load image. Please try uploading again.", - }, - }), -})); - -const mockPhotoQuestion: QuestionField = { - id: "documents_verification.face_photo", - title: "Face Photo", - type: "photo", - order: 1, - required: true, - baseRequired: true, - isVisible: true, - description: "Recent clear face photo", - tooltip: "", - extras: { - options: [".jpg", ".jpeg", ".png"], - } as any, - options: [], -}; - -describe("QuestionPhoto Component", () => { - beforeEach(() => { - answerMap = {}; - mockSetAnswerValue.mockClear(); - mockUploadTmpMedia.mockClear(); - mockRegisterPendingUpload.mockClear(); - vi.clearAllMocks(); - }); - - afterEach(() => { - cleanup(); - }); - - it("renders default avatar placeholder when no photo uploaded", () => { - const { container } = render(); - - expect(screen.getByText(/Face/)).toBeInTheDocument(); - expect(screen.getByText(/Photo/)).toBeInTheDocument(); - expect(screen.getByText("Recent clear face photo")).toBeInTheDocument(); - // Default placeholder SVG is rendered - expect(container.querySelector("img[src*='Frame 2095586679.svg']")).toBeInTheDocument(); - }); - - it("renders uploaded image and camera badge when answer is provided", () => { - answerMap[mockPhotoQuestion.id] = "https://example.com/avatar.jpg"; - const { container } = render(); - - const img = container.querySelector("img[src='https://example.com/avatar.jpg']"); - expect(img).toBeInTheDocument(); - expect(img).toHaveClass("rounded-full"); - // Camera badge rendered - expect(container.querySelector(".bg-\\[\\#F0445B\\]")).toBeInTheDocument(); - }); - - it("triggers upload and renders circular spinner without black background overlay during file selection", async () => { - let resolveUpload!: (val: any) => void; - mockUploadTmpMedia.mockImplementationOnce( - () => - new Promise((resolve) => { - resolveUpload = resolve; - }), - ); - - const { container } = render(); - - const fileInput = container.querySelector("input[type='file']") as HTMLInputElement; - const testFile = new File(["dummy content"], "my-photo.jpg", { type: "image/jpeg" }); - - const originalCreateObjectURL = URL.createObjectURL; - URL.createObjectURL = vi.fn(() => "blob:http://localhost/test-blob"); - - try { - fireEvent.change(fileInput, { target: { files: [testFile] } }); - - await waitFor(() => { - const spinner = container.querySelector(".animate-spin"); - expect(spinner).toBeInTheDocument(); - expect(spinner).toHaveClass("border-[#F0445B]/25"); - }); - - expect(container.querySelector(".bg-black\\/40")).toBeNull(); - expect(container.querySelector(".bg-black")).toBeNull(); - - resolveUpload({ path: "/media/uploaded.jpg" }); - - await waitFor(() => { - expect(container.querySelector(".animate-spin")).toBeNull(); - }); - } finally { - URL.createObjectURL = originalCreateObjectURL; - } - }); - - it("shows error toast, reverts to default avatar placeholder, and clears answer when upload fails", async () => { - mockUploadTmpMedia.mockImplementationOnce( - () => Promise.reject(new Error("Network Error")), - ); - - const { container } = render(); - const fileInput = container.querySelector("input[type='file']") as HTMLInputElement; - const testFile = new File(["dummy content"], "bad-photo.jpg", { type: "image/jpeg" }); - - const originalCreateObjectURL = URL.createObjectURL; - const originalRevokeObjectURL = URL.revokeObjectURL; - URL.createObjectURL = vi.fn(() => "blob:http://localhost/bad-blob"); - URL.revokeObjectURL = vi.fn(); - - try { - fireEvent.change(fileInput, { target: { files: [testFile] } }); - - // Error toast should appear - await waitFor(() => { - // Toast message should appear - expect(screen.getByText("Photo upload failed. Please try again.")).toBeInTheDocument(); - - // Answer value is NOT cleared — matches yesterday's behavior - // (error just shows toast, does not wipe the form state) - expect(mockSetAnswerValue).not.toHaveBeenCalledWith(mockPhotoQuestion, null); - - // File input is reset allowing re-upload - expect(fileInput.value).toBe(""); - }); - } finally { - URL.createObjectURL = originalCreateObjectURL; - URL.revokeObjectURL = originalRevokeObjectURL; - } - }); - - it("keeps stored answer when rendered image fails to load (preview-only failure)", async () => { - answerMap[mockPhotoQuestion.id] = "https://example.com/broken-avatar.jpg"; - const { container } = render(); - - const img = container.querySelector("img[src='https://example.com/broken-avatar.jpg']") as HTMLImageElement; - expect(img).toBeInTheDocument(); - - // Trigger image error (e.g. 404 / ERR_CONNECTION_REFUSED) - fireEvent.error(img); - - // The stored answer should NOT be cleared — the file is already on the server. - // This matches yesterday's behavior where preview errors never wiped the answer. - expect(mockSetAnswerValue).not.toHaveBeenCalledWith(mockPhotoQuestion, null); - - // The component should remain in "answered" state via data-question-answered - // because storedValue still holds the uploaded path - expect(container.querySelector("[data-question-answered='true']")).toBeInTheDocument(); - }); -}); diff --git a/src/components/Componentes/question-photo.tsx b/src/components/Componentes/question-photo.tsx index 636c27e..582452b 100644 --- a/src/components/Componentes/question-photo.tsx +++ b/src/components/Componentes/question-photo.tsx @@ -6,10 +6,9 @@ import type { QuestionField } from "@/lib/schema-adapter"; import { useUploadTmpMediaMutation } from "@/hooks/marriage/use-upload-tmp-media"; import { getApiRequestUrl } from "@/lib/http"; import { isInFlutterWebView, uploadFile } from "@/lib/webview-actions"; -import { useI18n } from "@/translations/provider"; -import ErrorToast from "./error-toast"; import { useQuestionAnswers } from "./question-answer-storage"; import QuestionTitle from "./question-title"; +import { LoadingSkeleton } from "./loading-skeleton"; type QuestionPhotoProps = { question: QuestionField; @@ -23,16 +22,15 @@ export function QuestionPhoto({ disabled, }: QuestionPhotoProps) { const inputId = useId(); - const { dictionary: t } = useI18n(); const [localPreviewUrl, setLocalPreviewUrl] = useState(null); const [isUploading, setIsUploading] = useState(false); - const [toastMessage, setToastMessage] = useState(null); const acceptedFiles = question.extras?.options && question.extras.options.length > 0 ? question.extras.options.join(",") : "image/*"; + const descriptionContent = description ?? question.description; const { getAnswerValue, setAnswerValue, registerPendingUpload } = useQuestionAnswers(); const storedValue = getAnswerValue(question); @@ -50,16 +48,10 @@ export function QuestionPhoto({ setLocalPreviewUrl(response.path); } setIsUploading(false); - isInitiatorRef.current = false; }, onError: (error) => { console.error("Photo upload error:", error); setIsUploading(false); - isInitiatorRef.current = false; - setToastMessage( - t["Photo upload failed. Please try again."] ?? - "Photo upload failed. Please try again.", - ); }, }); @@ -69,18 +61,19 @@ export function QuestionPhoto({ useEffect(() => { if (!isInFlutterWebView()) return; - const unsubscribe = window.addFlutterResponseListener?.((event: any) => { + const unsubscribe = window.addFlutterResponseListener?.((event) => { if (event.action !== "upload_file") return; if (event.requestId && String(event.requestId) !== String(question.id)) return; if (!event.requestId && !isInitiatorRef.current) return; switch (event.status) { + case "picking": + break; + case "picked": case "progress": setIsUploading(true); - if (event.data?.base64) { - const b64 = event.data.base64.startsWith("data:") - ? event.data.base64 - : `data:image/jpeg;base64,${event.data.base64}`; + if (event.data?.files?.[0]?.base64) { + const b64 = event.data.files[0].base64; setLocalPreviewUrl(b64); } break; @@ -209,10 +202,6 @@ export function QuestionPhoto({ .catch((err) => { console.error("Photo upload error:", err); setIsUploading(false); - setToastMessage( - t["Photo upload failed. Please try again."] ?? - "Photo upload failed. Please try again.", - ); }); registerPendingUpload?.(question.id, uploadPromise); @@ -256,14 +245,8 @@ export function QuestionPhoto({ disabled ? "pointer-events-none opacity-30" : "", ].join(" ")} > - {toastMessage && ( - setToastMessage(null)} - /> - )} @@ -281,11 +264,11 @@ export function QuestionPhoto({ className="flex w-full cursor-pointer flex-col items-center" > -
- {hasAnswer && displayUrl ? ( +
+ {displayUrl ? ( ) : ( @@ -300,8 +283,8 @@ export function QuestionPhoto({ )} {/* Red camera badge when image is uploaded */} - {hasAnswer && !isPending && ( - + {displayUrl && ( + )} - {/* Circular loading spinner during upload without black overlay */} + {/* Loading spinner during upload matching Flutter ProfileAvatarWidget */} {isPending && ( -
-
+
+
)}
+ + {descriptionContent ? ( + + {descriptionContent} + + ) : null}
@@ -333,4 +322,3 @@ export function QuestionPhoto({ } export default QuestionPhoto; -