diff --git a/src/components/Componentes/question-file.tsx b/src/components/Componentes/question-file.tsx index 011b42d..f9e6fbe 100644 --- a/src/components/Componentes/question-file.tsx +++ b/src/components/Componentes/question-file.tsx @@ -3,7 +3,10 @@ import Image from "next/image"; import { useCallback, useEffect, useRef, useState } from "react"; import type { QuestionField } from "@/lib/schema-adapter"; -import { useUploadTmpMediaMutation } from "@/hooks/marriage/use-upload-tmp-media"; +import { + uploadTmpMedia, + useUploadTmpMediaMutation, +} from "@/hooks/marriage/use-upload-tmp-media"; import { getApiRequestUrl } from "@/lib/http"; import { isInFlutterWebView, uploadFile } from "@/lib/webview-actions"; import { useQuestionAnswers } from "./question-answer-storage"; @@ -56,6 +59,12 @@ function isImageFile( return /\.(jpg|jpeg|png|webp|gif|svg|bmp|avif)$/i.test(nameToCheck); } +export type UploadedDoc = { + url: string; + name: string; + previewUrl?: string; +}; + export function QuestionFile({ question, disabled, @@ -63,28 +72,60 @@ export function QuestionFile({ const { getAnswerValue, setAnswerValue } = useQuestionAnswers(); const storedValue = getAnswerValue(question); - const initialFileName = - typeof storedValue === "string" && storedValue.trim().length > 0 - ? (storedValue.split("/").pop() ?? storedValue) - : null; - - const initialFileUrl = - typeof storedValue === "string" && storedValue.trim().length > 0 - ? storedValue.startsWith("http") || - storedValue.startsWith("blob:") || - storedValue.startsWith("data:") - ? storedValue - : getApiRequestUrl(storedValue) - : null; - - const [selectedFileName, setSelectedFileName] = useState( - initialFileName, - ); - const [filePreviewUrl, setFilePreviewUrl] = useState( - initialFileUrl, + const parseInitialDocs = useCallback((stored: unknown): UploadedDoc[] => { + if (!stored) return []; + if (Array.isArray(stored)) { + return stored + .filter((item): item is string | Record => Boolean(item)) + .map((item) => { + if (typeof item === "string") { + const resolvedUrl = + item.startsWith("http") || + item.startsWith("blob:") || + item.startsWith("data:") + ? item + : getApiRequestUrl(item); + return { + url: item, + previewUrl: resolvedUrl, + name: item.split("/").pop() || "document", + }; + } + const url = (item.url || item.path || "") as string; + const name = (item.name || url.split("/").pop() || "document") as string; + const resolvedUrl = + url.startsWith("http") || + url.startsWith("blob:") || + url.startsWith("data:") + ? url + : getApiRequestUrl(url); + return { url, name, previewUrl: resolvedUrl }; + }); + } + if (typeof stored === "string" && stored.trim().length > 0) { + const resolvedUrl = + stored.startsWith("http") || + stored.startsWith("blob:") || + stored.startsWith("data:") + ? stored + : getApiRequestUrl(stored); + return [ + { + url: stored, + name: stored.split("/").pop() || "document", + previewUrl: resolvedUrl, + }, + ]; + } + return []; + }, []); + + const [uploadedDocs, setUploadedDocs] = useState(() => + parseInitialDocs(storedValue), ); + const [isUploading, setIsUploading] = useState(false); const isInitiatorRef = useRef(false); - const [isFlutterPicking, setIsFlutterPicking] = useState(false); + const fileInputRef = useRef(null); const acceptedFiles = (question.extras?.options ?? []) .map((option) => option.replace(/^\./, "")) @@ -93,17 +134,32 @@ export function QuestionFile({ const uploadTmpMediaMutation = useUploadTmpMediaMutation({ onSuccess: (response) => { if (response.path) { - setAnswerValue(question, response.path); - setSelectedFileName(response.name ?? response.path.split("/").pop() ?? "uploaded"); - setFilePreviewUrl(response.path); + const resolved = response.path.startsWith("http") + ? response.path + : getApiRequestUrl(response.path); + const newDoc: UploadedDoc = { + url: response.path, + name: response.name ?? response.path.split("/").pop() ?? "uploaded", + previewUrl: resolved, + }; + setUploadedDocs((prev) => { + const nextDocs = [...prev, newDoc]; + setAnswerValue( + question, + nextDocs.length === 1 ? nextDocs[0].url : nextDocs.map((d) => d.url), + ); + return nextDocs; + }); } + setIsUploading(false); }, onError: (error) => { console.error("File upload error:", error); + setIsUploading(false); }, }); - const isPending = uploadTmpMediaMutation.isPending || isFlutterPicking; + const isPending = uploadTmpMediaMutation.isPending || isUploading; // Listen for upload_file responses from Flutter useEffect(() => { @@ -116,52 +172,66 @@ export function QuestionFile({ switch (event.status) { case "picking": - setIsFlutterPicking(true); break; case "picked": - if (event.data?.files?.[0]) { - const fileName = event.data.files[0].name ?? null; - setSelectedFileName(fileName); - } - break; case "progress": + setIsUploading(true); break; case "completed": { - setIsFlutterPicking(false); + setIsUploading(false); isInitiatorRef.current = false; - const file = event.data?.files?.[0]; - const remoteUrl = - file?.path || - file?.url || - (file?.data?.path as string | undefined) || - (file?.data?.url as string | undefined); - if (remoteUrl) { - setAnswerValue(question, remoteUrl); - setSelectedFileName(file?.name ?? remoteUrl.split("/").pop() ?? "uploaded"); - setFilePreviewUrl(remoteUrl); - } else if (file?.base64) { - const b64 = file.base64; - setSelectedFileName(file.name ?? "upload"); - setFilePreviewUrl(b64); - fetch(b64) - .then((res) => res.blob()) - .then((blob) => { - const f = new File([blob], file.name ?? "upload", { - type: blob.type, - }); - uploadTmpMediaMutation.mutate(f); + const incomingFiles = event.data?.files || []; + const newDocs: UploadedDoc[] = []; + + for (const f of incomingFiles) { + const remoteUrl = + f?.path || + f?.url || + (f?.data?.path as string | undefined) || + (f?.data?.url as string | undefined); + if (remoteUrl) { + const resolved = + remoteUrl.startsWith("http") || + remoteUrl.startsWith("blob:") || + remoteUrl.startsWith("data:") + ? remoteUrl + : getApiRequestUrl(remoteUrl); + newDocs.push({ + url: remoteUrl, + name: f?.name || remoteUrl.split("/").pop() || "document", + previewUrl: resolved, }); + } else if (f?.base64) { + const b64 = f.base64; + fetch(b64) + .then((res) => res.blob()) + .then((blob) => { + const fileObj = new File([blob], f.name ?? "document", { + type: blob.type, + }); + uploadTmpMediaMutation.mutate(fileObj); + }); + } + } + + 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; + }); } break; } case "cancelled": - setIsFlutterPicking(false); - isInitiatorRef.current = false; - break; case "failed": - setIsFlutterPicking(false); + setIsUploading(false); isInitiatorRef.current = false; - console.error("upload_file failed:", event.message); break; } }); @@ -179,11 +249,11 @@ export function QuestionFile({ const mediaType = resolveMediaType(extensions); isInitiatorRef.current = true; - setIsFlutterPicking(true); uploadFile({ requestId: String(question.id), mediaType, + multiple: true, source: "gallery", returnAs: "upload", uploadUrl: `${process.env.NEXT_PUBLIC_API_BASE_URL ?? ""}/upload-tmp-media/`, @@ -195,180 +265,236 @@ export function QuestionFile({ }); }, [question]); - /** Handle file pick via browser (fallback). */ - function handleBrowserFileChange(files: FileList | null) { - const file = files?.[0]; - - if (!file) { - setSelectedFileName(null); - setFilePreviewUrl(null); - setAnswerValue(question, null); - return; + /** Handle file pick via browser (fallback). */ + async function handleBrowserFileChange(files: FileList | null) { + if (!files || files.length === 0) return; + + setIsUploading(true); + const newDocs: UploadedDoc[] = []; + + for (let i = 0; i < files.length; i++) { + const file = files[i]; + try { + const res = await uploadTmpMedia(file); + 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); + } } - setSelectedFileName(file.name); - setAnswerValue(question, file.name); - - if (file.type.startsWith("image/")) { - const objectUrl = URL.createObjectURL(file); - setFilePreviewUrl(objectUrl); - } else { - setFilePreviewUrl(null); + setIsUploading(false); + + 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; + }); } - uploadTmpMediaMutation.mutate(file); + if (fileInputRef.current) { + fileInputRef.current.value = ""; + } } - const handleRemoveFile = (e: React.MouseEvent) => { + const handleRemoveDoc = (indexToRemove: number, e: React.MouseEvent) => { e.stopPropagation(); e.preventDefault(); - setSelectedFileName(null); - setFilePreviewUrl(null); - setAnswerValue(question, null); + setUploadedDocs((prev) => { + const nextDocs = prev.filter((_, idx) => idx !== indexToRemove); + if (nextDocs.length === 0) { + setAnswerValue(question, null); + } else if (nextDocs.length === 1) { + setAnswerValue(question, nextDocs[0].url); + } else { + setAnswerValue(question, nextDocs.map((d) => d.url)); + } + return nextDocs; + }); + }; + + const handleTriggerPick = (e?: React.MouseEvent) => { + if (e) { + e.stopPropagation(); + } + if (isInFlutterWebView()) { + handleFlutterPick(); + } else { + fileInputRef.current?.click(); + } }; const inWebView = isInFlutterWebView(); - const isUploaded = Boolean(selectedFileName || storedValue); - const currentFileName = - selectedFileName ?? - (typeof storedValue === "string" ? storedValue.split("/").pop() : null); - const currentFileUrl = - filePreviewUrl ?? - (typeof storedValue === "string" && storedValue.trim().length > 0 - ? storedValue.startsWith("http") || - storedValue.startsWith("blob:") || - storedValue.startsWith("data:") - ? storedValue - : getApiRequestUrl(storedValue) - : null); - - const isImg = isImageFile(currentFileName, currentFileUrl); + const hasDocuments = uploadedDocs.length > 0; return (
- { - if (e.key === "Enter" || e.key === " ") handleFlutterPick(); - } - : undefined - } - > - {/* Fallback: browser file input (hidden in WebView or when uploaded) */} - {!inWebView && !isUploaded && ( - handleBrowserFileChange(event.target.files)} - className="absolute inset-0 z-10 h-full w-full cursor-pointer opacity-0" - /> - )} - {isUploaded ? ( - /* ────── UPLOADED STATE ────── */ -
- {isImg && currentFileUrl ? ( - /* Image Preview (Left design in screenshot) */ - {currentFileName - ) : ( - /* Document / PDF Preview (Right design in screenshot) */ -
-
-
+ {hasDocuments ? ( + /* ────── UPLOADED MULTI-DOCUMENT STATE ────── */ +
+
+ {uploadedDocs.map((doc, index) => { + const isImg = isImageFile(doc.name, doc.previewUrl ?? doc.url); + return ( +
- - - - - PDF - -
- - {currentFileName ?? "document"} - -
- )} - - {/* Trash Button in Bottom-Right */} + {/* Left: Thumbnail or PDF Icon */} +
+ {isImg && doc.previewUrl ? ( + {doc.name} + ) : ( +
+ + + + + + PDF + +
+ )} + + {/* Middle: File Name */} + + {doc.name} + +
+ + {/* Right: Individual Trash Button */} + +
+ ); + })} +
+ + {/* Add Another Document Button */} - - {isPending && ( -
- -
- )}
- ) : /* ────── DEFAULT EMPTY STATE ────── */ - isPending ? ( - ) : ( - <> + /* ────── DEFAULT EMPTY STATE ────── */ +
handleTriggerPick()} + onKeyDown={(e) => { + if (e.key === "Enter" || e.key === " ") handleTriggerPick(); + }} + className="flex w-full cursor-pointer flex-col items-center justify-center py-4" + > Upload - - {selectedFileName ?? "upload certificates"} + + بارگذاری مدارک (کارت شناسایی، پاسپورت و...) - {uploadTmpMediaMutation.isError ? ( - - Upload failed. Please try again. - - ) : acceptedFiles ? ( + {acceptedFiles ? ( {acceptedFiles} ) : null} - +
+ )} + + {/* Smooth Circular Loading Spinner Overlay (Matching Flutter ProfileAvatar) */} + {isPending && ( +
+
+ در حال آپلود در تمپ... +
)} - +
); }