+ className="size-4.5 shrink-0 text-[#667085]"
+ />
diff --git a/src/components/Componentes/question-card.tsx b/src/components/Componentes/question-card.tsx
index a663fde..31b5119 100644
--- a/src/components/Componentes/question-card.tsx
+++ b/src/components/Componentes/question-card.tsx
@@ -1,6 +1,6 @@
import Link from "next/link";
import { useEffect, useRef } from "react";
-import { IoInformation } from "react-icons/io5";
+import { Ic } from "@/icons";
import {
type QuestionCardIcon,
type QuestionListItem,
@@ -138,7 +138,7 @@ export function QuestionCard({
}}
className="flex h-[17px] w-[17px] cursor-pointer items-center justify-center rounded-[6px] bg-[#747474] text-white border-0 p-0 hover:bg-[#606060] transition-colors"
>
-
diff --git a/src/components/Componentes/question-dropdown.tsx b/src/components/Componentes/question-dropdown.tsx
index a090d14..a2f55e2 100644
--- a/src/components/Componentes/question-dropdown.tsx
+++ b/src/components/Componentes/question-dropdown.tsx
@@ -1,6 +1,7 @@
"use client";
import { useCallback, useEffect, useRef, useState } from "react";
+import { Ic } from "@/icons";
import { ExplanationUiFont } from "./explanation-ui-font";
import type { QuestionField } from "@/lib/schema-adapter";
import { useI18n } from "@/translations/provider";
@@ -23,7 +24,16 @@ export function QuestionDropdown({
const isMulti =
Array.isArray(rawValue) ||
question.type === "checkbox" ||
+ question.type === "multi_select" ||
+ question.type === "multiselect" ||
+ Boolean(question.ui_config?.is_multi) ||
+ Boolean(question.ui_config?.multiple) ||
+ (question.validation?.max && Number(question.validation.max) > 1) ||
(question.extras?.range && question.extras.range[1] > 1);
+
+ const maxSelect =
+ question.ui_config?.max_select ||
+ (question.validation?.max ? Number(question.validation.max) : undefined);
const selectedList = Array.isArray(rawValue)
? rawValue
@@ -131,6 +141,9 @@ export function QuestionDropdown({
if (selectedList.includes(optionId)) {
nextValue = selectedList.filter((v) => v !== optionId);
} else {
+ if (maxSelect && selectedList.length >= maxSelect) {
+ return;
+ }
nextValue = [...selectedList, optionId];
}
@@ -169,25 +182,14 @@ export function QuestionDropdown({
>
{displayLabel}
-
+ />
{/* Dropdown Options Panel */}
@@ -196,29 +198,11 @@ export function QuestionDropdown({
{/* Search Input Bar */}
{showSearch ? (
-
+ className="size-4.5 shrink-0 text-[#667085]"
+ />
{
answerMap[q.id] = val;
});
-const mockMutateAsync = vi.fn(async (file: File) => ({
+const mockUploadTmpMedia = vi.fn(async (file: File) => ({
path: `/media/tmp/${file.name}`,
+ name: file.name,
}));
+const mockMutateAsync = mockUploadTmpMedia;
+
vi.mock("@/hooks/marriage/use-upload-tmp-media", () => ({
+ uploadTmpMedia: (file: File, onProgress?: (p: number) => void) => {
+ onProgress?.(100);
+ return mockUploadTmpMedia(file);
+ },
useUploadTmpMediaMutation: () => ({
mutate: vi.fn(),
- mutateAsync: mockMutateAsync,
+ mutateAsync: mockUploadTmpMedia,
isPending: false,
isError: false,
}),
@@ -71,7 +78,7 @@ describe("QuestionFile Component (Multiple Upload)", () => {
beforeEach(() => {
answerMap = {};
mockSetAnswerValue.mockClear();
- mockMutateAsync.mockClear();
+ mockUploadTmpMedia.mockClear();
vi.clearAllMocks();
});
@@ -79,13 +86,13 @@ describe("QuestionFile Component (Multiple Upload)", () => {
cleanup();
});
- it("renders empty dropzone when no files are uploaded without formats text", () => {
+ it("renders empty dropzone when no files are uploaded", () => {
render(
);
expect(screen.getByText(/Valid Identification/)).toBeInTheDocument();
expect(screen.getByText(/Passport, National ID, or Driver's License/)).toBeInTheDocument();
- expect(screen.getByText("upload certificates")).toBeInTheDocument();
- expect(screen.queryByText("pdf, jpg, jpeg, png")).not.toBeInTheDocument();
+ expect(screen.getByText(/Upload documents/)).toBeInTheDocument();
+ expect(screen.getByText(/pdf, jpg, jpeg, png/)).toBeInTheDocument();
});
it("renders legacy single file stored as string correctly", () => {
@@ -95,7 +102,6 @@ describe("QuestionFile Component (Multiple Upload)", () => {
expect(screen.getByText("passport.pdf")).toBeInTheDocument();
expect(screen.getByText(/Add another document/)).toBeInTheDocument();
- expect(screen.getByText(/(1\/4)/)).toBeInTheDocument();
});
it("renders multiple stored files correctly", () => {
@@ -111,13 +117,12 @@ describe("QuestionFile Component (Multiple Upload)", () => {
expect(screen.getByAltText("doc2.jpg")).toBeInTheDocument();
expect(screen.getByAltText("doc3.png")).toBeInTheDocument();
expect(screen.getByText(/Add another document/)).toBeInTheDocument();
- expect(screen.getByText(/(3\/4)/)).toBeInTheDocument();
});
it("uploads a new file and transitions from empty state to list with Add button", async () => {
- render(
);
+ const { container } = render(
);
- const input = screen.getByLabelText("Upload files");
+ const input = container.querySelector('input[type="file"]') as HTMLInputElement;
expect(input).toBeInTheDocument();
const file = new File(["dummy content"], "national_id.pdf", {
@@ -130,31 +135,32 @@ describe("QuestionFile Component (Multiple Upload)", () => {
await waitFor(() => {
expect(mockMutateAsync).toHaveBeenCalledWith(file);
- expect(mockSetAnswerValue).toHaveBeenCalledWith(mockFileQuestion, [
+ expect(mockSetAnswerValue).toHaveBeenCalledWith(
+ mockFileQuestion,
"/media/tmp/national_id.pdf",
- ]);
+ );
});
expect(screen.getByText("national_id.pdf")).toBeInTheDocument();
expect(screen.getByText(/Add another document/)).toBeInTheDocument();
});
- it("allows adding 2nd, 3rd, 4th file and disables upload at 4 items", async () => {
+ it("allows adding multiple files", async () => {
answerMap[mockFileQuestion.id] = [
"/media/marriage/media/file1.pdf",
"/media/marriage/media/file2.pdf",
"/media/marriage/media/file3.pdf",
];
- render(
);
+ const { container } = render(
);
expect(screen.getByText("file1.pdf")).toBeInTheDocument();
expect(screen.getByText("file2.pdf")).toBeInTheDocument();
expect(screen.getByText("file3.pdf")).toBeInTheDocument();
- expect(screen.getByText(/(3\/4)/)).toBeInTheDocument();
+ expect(screen.getByText(/Add another document/)).toBeInTheDocument();
// Add 4th file
- const addInput = screen.getByLabelText("Add file");
+ const addInput = container.querySelector('input[type="file"]') as HTMLInputElement;
const fourthFile = new File(["content"], "file4.jpg", { type: "image/jpeg" });
await act(async () => {
@@ -169,13 +175,9 @@ describe("QuestionFile Component (Multiple Upload)", () => {
"/media/tmp/file4.jpg",
]);
});
-
- // Now 4 items are present, max files reached badge should be displayed
- expect(screen.getByText("Maximum of 4 files uploaded")).toBeInTheDocument();
- expect(screen.queryByText(/Add another document/)).not.toBeInTheDocument();
});
- it("deleting an item removes it from list, preserves remaining items, and restores Add button", async () => {
+ it("deleting an item removes it from list and updates answer", async () => {
answerMap[mockFileQuestion.id] = [
"/media/marriage/media/file1.pdf",
"/media/marriage/media/file2.jpg",
@@ -185,8 +187,6 @@ describe("QuestionFile Component (Multiple Upload)", () => {
render(
);
- expect(screen.getByText("Maximum of 4 files uploaded")).toBeInTheDocument();
-
const deleteButtons = screen.getAllByTitle("Remove document");
expect(deleteButtons).toHaveLength(4);
@@ -201,9 +201,7 @@ describe("QuestionFile Component (Multiple Upload)", () => {
"/media/marriage/media/file4.pdf",
]);
- // Now 3 items remain, add button should be visible again
expect(screen.getByText(/Add another document/)).toBeInTheDocument();
- expect(screen.getByText(/(3\/4)/)).toBeInTheDocument();
});
it("deleting the only item clears answer back to null and returns to empty dropzone", async () => {
@@ -219,6 +217,6 @@ describe("QuestionFile Component (Multiple Upload)", () => {
});
expect(mockSetAnswerValue).toHaveBeenCalledWith(mockFileQuestion, null);
- expect(screen.getByText("upload certificates")).toBeInTheDocument();
+ expect(screen.getByText(/Upload documents/)).toBeInTheDocument();
});
});
diff --git a/src/components/Componentes/question-file.tsx b/src/components/Componentes/question-file.tsx
index 63c0015..ccd6558 100644
--- a/src/components/Componentes/question-file.tsx
+++ b/src/components/Componentes/question-file.tsx
@@ -175,7 +175,8 @@ export function QuestionFile({
const t =
FILE_I18N[locale] ?? FILE_I18N[locale?.slice(0, 2)] ?? FILE_I18N.fa;
- const { getAnswerValue, setAnswerValue } = useQuestionAnswers();
+ const { getAnswerValue, setAnswerValue, registerPendingUpload } =
+ useQuestionAnswers();
const storedValue = getAnswerValue(question);
const parseInitialDocs = useCallback(
@@ -236,6 +237,10 @@ export function QuestionFile({
const [isUploading, setIsUploading] = useState(false);
const [uploadProgress, setUploadProgress] = useState
(null);
const isInitiatorRef = useRef(false);
+ const pendingDeferredRef = useRef<{
+ resolve: (val?: any) => void;
+ reject: (err?: any) => void;
+ } | null>(null);
const fileInputRef = useRef(null);
// Sync state when answers load asynchronously from server/cache
@@ -354,6 +359,11 @@ export function QuestionFile({
);
return nextDocs;
});
+ pendingDeferredRef.current?.resolve(newDocs);
+ pendingDeferredRef.current = null;
+ } else {
+ pendingDeferredRef.current?.resolve([]);
+ pendingDeferredRef.current = null;
}
break;
}
@@ -362,6 +372,8 @@ export function QuestionFile({
setIsUploading(false);
setUploadProgress(null);
isInitiatorRef.current = false;
+ pendingDeferredRef.current?.reject(new Error("File upload failed or cancelled"));
+ pendingDeferredRef.current = null;
break;
}
});
@@ -379,12 +391,22 @@ export function QuestionFile({
const mediaType = resolveMediaType(extensions);
isInitiatorRef.current = true;
+ setIsUploading(true);
+
+ let resolveFn!: (val?: any) => void;
+ let rejectFn!: (err?: any) => void;
+ const uploadPromise = new Promise((resolve, reject) => {
+ resolveFn = resolve;
+ rejectFn = reject;
+ });
+ pendingDeferredRef.current = { resolve: resolveFn, reject: rejectFn };
+ registerPendingUpload?.(question.id, uploadPromise);
uploadFile({
requestId: String(question.id),
mediaType,
multiple: true,
- source: "gallery",
+ source: (question.ui_config?.source as any) || "file_system",
returnAs: "upload",
uploadUrl: `${process.env.NEXT_PUBLIC_API_BASE_URL ?? ""}/upload-tmp-media/`,
fieldName: "file",
@@ -393,52 +415,57 @@ export function QuestionFile({
title:
typeof question.title === "string" ? question.title : "Upload File",
});
- }, [question]);
+ }, [question, registerPendingUpload]);
/** Handle file pick via browser (fallback). */
- async function handleBrowserFileChange(files: FileList | null) {
+ function handleBrowserFileChange(files: FileList | null) {
if (!files || files.length === 0) return;
setIsUploading(true);
setUploadProgress(0);
- const newDocs: UploadedDoc[] = [];
-
- for (let i = 0; i < files.length; i++) {
- const file = files[i];
- try {
- const res = await uploadTmpMedia(file, (p) => setUploadProgress(p));
- if (res.path) {
- const resolved = res.path.startsWith("http")
- ? res.path
- : getApiRequestUrl(res.path);
- newDocs.push({
- url: res.path,
- name: res.name || file.name,
- previewUrl: resolved,
- });
+
+ const uploadTask = (async () => {
+ const newDocs: UploadedDoc[] = [];
+
+ for (let i = 0; i < files.length; i++) {
+ const file = files[i];
+ try {
+ const res = await uploadTmpMedia(file, (p) => setUploadProgress(p));
+ if (res.path) {
+ const resolved = res.path.startsWith("http")
+ ? res.path
+ : getApiRequestUrl(res.path);
+ newDocs.push({
+ url: res.path,
+ name: res.name || file.name,
+ previewUrl: resolved,
+ });
+ }
+ } catch (err) {
+ console.error("Failed to upload tmp file:", file.name, err);
}
- } catch (err) {
- console.error("Failed to upload tmp file:", file.name, err);
}
- }
- setIsUploading(false);
- setUploadProgress(null);
-
- if (newDocs.length > 0) {
- setUploadedDocs((prev) => {
- const nextDocs = [...prev, ...newDocs];
- setAnswerValue(
- question,
- nextDocs.length === 1 ? nextDocs[0].url : nextDocs.map((d) => d.url),
- );
- return nextDocs;
- });
- }
+ setIsUploading(false);
+ setUploadProgress(null);
- if (fileInputRef.current) {
- fileInputRef.current.value = "";
- }
+ if (newDocs.length > 0) {
+ setUploadedDocs((prev) => {
+ const nextDocs = [...prev, ...newDocs];
+ setAnswerValue(
+ question,
+ nextDocs.length === 1 ? nextDocs[0].url : nextDocs.map((d) => d.url),
+ );
+ return nextDocs;
+ });
+ }
+
+ if (fileInputRef.current) {
+ fileInputRef.current.value = "";
+ }
+ })();
+
+ registerPendingUpload?.(question.id, uploadTask);
}
const handleRemoveDoc = (indexToRemove: number, e: React.MouseEvent) => {
diff --git a/src/components/Componentes/question-phone.tsx b/src/components/Componentes/question-phone.tsx
index 050c093..a77dbc6 100644
--- a/src/components/Componentes/question-phone.tsx
+++ b/src/components/Componentes/question-phone.tsx
@@ -7,6 +7,7 @@ import {
} from "google-libphonenumber";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { createPortal } from "react-dom";
+import { Ic } from "@/icons";
import type { MarriagePhoneFieldValue } from "@/hooks/marriage/types";
import { http } from "@/lib/http";
import type { QuestionField } from "@/lib/schema-adapter";
@@ -826,49 +827,18 @@ export function QuestionPhone({
className="flex size-8 shrink-0 items-center justify-center rounded-full text-[#667085] hover:bg-[#F2F4F7] hover:text-[#181818] transition-colors cursor-pointer"
aria-label="Close"
>
-
+
-
+ className="size-4.5 shrink-0 text-[#667085]"
+ />
void;
+ reject: (err?: any) => void;
+ } | null>(null);
+
const uploadTmpMediaMutation = useUploadTmpMediaMutation({
onSuccess: (response) => {
if (response.path) {
@@ -69,7 +75,6 @@ export function QuestionPhoto({
if (event.data?.files?.[0]?.base64) {
const b64 = event.data.files[0].base64;
setLocalPreviewUrl(b64);
- setAnswerValue(question, b64);
}
break;
case "completed": {
@@ -84,18 +89,37 @@ export function QuestionPhoto({
if (remoteUrl) {
setAnswerValue(question, remoteUrl);
setLocalPreviewUrl(remoteUrl);
+ pendingDeferredRef.current?.resolve(remoteUrl);
+ pendingDeferredRef.current = null;
} else if (file?.base64) {
const b64 = file.base64;
setLocalPreviewUrl(b64);
- setAnswerValue(question, b64);
- fetch(b64)
+ const uploadPromise = fetch(b64)
.then((res) => res.blob())
.then((blob) => {
const f = new File([blob], file.name ?? "photo.jpg", {
type: blob.type || "image/jpeg",
});
- uploadTmpMediaMutation.mutate(f);
+ return uploadTmpMediaMutation.mutateAsync(f);
+ })
+ .then((res) => {
+ if (res?.path) {
+ setAnswerValue(question, res.path);
+ setLocalPreviewUrl(res.path);
+ pendingDeferredRef.current?.resolve(res.path);
+ } else {
+ pendingDeferredRef.current?.resolve();
+ }
+ pendingDeferredRef.current = null;
+ })
+ .catch((err) => {
+ pendingDeferredRef.current?.reject(err);
+ pendingDeferredRef.current = null;
});
+ registerPendingUpload?.(question.id, uploadPromise);
+ } else {
+ pendingDeferredRef.current?.resolve();
+ pendingDeferredRef.current = null;
}
break;
}
@@ -103,6 +127,8 @@ export function QuestionPhoto({
case "failed":
setIsUploading(false);
isInitiatorRef.current = false;
+ pendingDeferredRef.current?.reject(new Error("Upload failed or cancelled"));
+ pendingDeferredRef.current = null;
break;
}
});
@@ -110,7 +136,7 @@ export function QuestionPhoto({
return () => {
unsubscribe?.();
};
- }, [question, setAnswerValue, uploadTmpMediaMutation]);
+ }, [question, setAnswerValue, registerPendingUpload, uploadTmpMediaMutation]);
const handleFlutterPick = useCallback(() => {
const extensions = (question.extras?.options ?? []).map((o) =>
@@ -118,11 +144,21 @@ export function QuestionPhoto({
);
isInitiatorRef.current = true;
+ setIsUploading(true);
+
+ let resolveFn!: (val?: any) => void;
+ let rejectFn!: (err?: any) => void;
+ const uploadPromise = new Promise((resolve, reject) => {
+ resolveFn = resolve;
+ rejectFn = reject;
+ });
+ pendingDeferredRef.current = { resolve: resolveFn, reject: rejectFn };
+ registerPendingUpload?.(question.id, uploadPromise);
uploadFile({
requestId: String(question.id),
mediaType: "image",
- source: "gallery",
+ source: (question.ui_config?.source as any) || "gallery",
returnAs: "upload",
uploadUrl: `${process.env.NEXT_PUBLIC_API_BASE_URL ?? ""}/upload-tmp-media/`,
fieldName: "file",
@@ -132,20 +168,33 @@ export function QuestionPhoto({
title:
typeof question.title === "string" ? question.title : "Upload Photo",
});
- }, [question]);
+ }, [question, registerPendingUpload]);
const handleBrowserFileChange = (files: FileList | null) => {
const file = files?.[0];
if (!file) return;
- // Create synchronous object URL for instant preview & immediate state update
+ // Create synchronous object URL for instant UI preview only
const objectUrl = URL.createObjectURL(file);
setLocalPreviewUrl(objectUrl);
- setAnswerValue(question, objectUrl);
- // Trigger background upload
+ // Trigger background upload and register promise with central storage
setIsUploading(true);
- uploadTmpMediaMutation.mutate(file);
+ const uploadPromise = uploadTmpMediaMutation
+ .mutateAsync(file)
+ .then((res) => {
+ if (res?.path) {
+ setAnswerValue(question, res.path);
+ setLocalPreviewUrl(res.path);
+ }
+ setIsUploading(false);
+ })
+ .catch((err) => {
+ console.error("Photo upload error:", err);
+ setIsUploading(false);
+ });
+
+ registerPendingUpload?.(question.id, uploadPromise);
};
const handleLabelClick = (e: React.MouseEvent) => {
diff --git a/src/components/Componentes/question-section-flow.tsx b/src/components/Componentes/question-section-flow.tsx
index 7887955..beb0e55 100644
--- a/src/components/Componentes/question-section-flow.tsx
+++ b/src/components/Componentes/question-section-flow.tsx
@@ -47,7 +47,7 @@ function SectionFlowContent({
}) {
const router = useRouter();
const { dictionary: t, locale } = useI18n();
- const { flushAnswers } = useQuestionAnswers();
+ const { flushAnswers, isUploadingMedia } = useQuestionAnswers();
const { markQuestionPassed, isCompleted } = useQuestionProgress();
const [activeQuestionIndex, setActiveQuestionIndex] = useState(0);
const [isSubmitting, setIsSubmitting] = useState(false);
@@ -240,8 +240,8 @@ function SectionFlowContent({
)}
@@ -714,7 +713,7 @@ export function TokenSwitcher({
title="پاک کردن اطلاعات و شروع مجدد"
className="size-8 rounded-lg flex items-center justify-center text-slate-400 hover:text-rose-600 hover:bg-rose-50 dark:hover:bg-rose-950/40 transition-colors cursor-pointer"
>
-
+