Browse Source

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

staging
Ali Alavi 10 hours ago
parent
commit
8f8bf008ac
  1. 16
      next.config.ts
  2. 206
      src/components/Componentes/question-photo.test.tsx
  3. 58
      src/components/Componentes/question-photo.tsx

16
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",
},
],
},

206
src/components/Componentes/question-photo.test.tsx

@ -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<string, unknown> = {};
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(<QuestionPhoto question={mockPhotoQuestion} />);
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(<QuestionPhoto question={mockPhotoQuestion} />);
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(<QuestionPhoto question={mockPhotoQuestion} />);
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(<QuestionPhoto question={mockPhotoQuestion} />);
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(<QuestionPhoto question={mockPhotoQuestion} />);
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();
});
});

58
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<string | null>(null);
const [isUploading, setIsUploading] = useState(false);
const [toastMessage, setToastMessage] = useState<string | null>(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 && (
<ErrorToast
message={toastMessage}
onClose={() => setToastMessage(null)}
/>
)}
<QuestionTitle
question={question}
question={{ ...question, description: "" }}
className="justify-center text-center"
/>
@ -281,11 +264,11 @@ export function QuestionPhoto({
className="flex w-full cursor-pointer flex-col items-center"
>
<span className="flex w-full flex-col items-center">
<div className="relative flex h-[92px] w-[86px] items-start justify-center">
{hasAnswer && displayUrl ? (
<div className="relative flex h-[92px] w-[86px] items-center justify-center">
{displayUrl ? (
<img
src={displayUrl}
alt=""
alt={typeof question.title === "string" ? question.title : ""}
className="h-[86px] w-[86px] rounded-full object-cover border border-[#D7DBE2]"
/>
) : (
@ -300,8 +283,8 @@ export function QuestionPhoto({
)}
{/* Red camera badge when image is uploaded */}
{hasAnswer && !isPending && (
<span className="absolute bottom-0 right-0 flex h-7 w-7 items-center justify-center rounded-full bg-[#F0445B]">
{displayUrl && (
<span className="absolute bottom-[2px] right-0 flex h-7 w-7 items-center justify-center rounded-full bg-[#F0445B]">
<svg
width="14"
height="14"
@ -319,13 +302,19 @@ export function QuestionPhoto({
</span>
)}
{/* Circular loading spinner during upload without black overlay */}
{/* Loading spinner during upload matching Flutter ProfileAvatarWidget */}
{isPending && (
<div className="absolute top-0 left-0 flex h-[86px] w-[86px] items-center justify-center rounded-full pointer-events-none z-10">
<div className="size-7 animate-spin rounded-full border-[2.5px] border-[#F0445B]/25 border-t-[#F0445B]" />
<div className="absolute inset-0 flex items-center justify-center rounded-full bg-black/40 backdrop-blur-[1px] z-10 transition-all duration-200">
<div className="h-7 w-7 animate-spin rounded-full border-[2.5px] border-white border-t-transparent shadow-sm" />
</div>
)}
</div>
{descriptionContent ? (
<span className="mt-4 block max-w-[350px] group-10 leading-[1.35] font-semibold text-[#D44747]">
{descriptionContent}
</span>
) : null}
</span>
</label>
</div>
@ -333,4 +322,3 @@ export function QuestionPhoto({
}
export default QuestionPhoto;
Loading…
Cancel
Save