"use client"; import Image from "next/image"; import { useCallback, useEffect, useRef, useState } from "react"; import type { QuestionField } from "@/lib/schema-adapter"; import { uploadTmpMedia, useUploadTmpMediaMutation, } from "@/hooks/marriage/use-upload-tmp-media"; import { getApiRequestUrl, resolveMediaUrl } 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 QuestionFileProps = { question: QuestionField; disabled?: boolean; debugProgress?: number; initialDocs?: UploadedDoc[]; }; const FILE_I18N: Record< string, { uploadPrompt: string; addMore: string; uploading: string; remove: string; uploadFailed: string; defaultDoc: string; } > = { fa: { uploadPrompt: "بارگذاری مدارک (کارت شناسایی، پاسپورت و...)", addMore: "افزودن مدرک دیگر", uploading: "در حال آپلود در تمپ...", remove: "حذف مدرک", uploadFailed: "خطا در آپلود. لطفاً دوباره تلاش کنید.", defaultDoc: "مدرک", }, ar: { uploadPrompt: "تحميل المستندات (بطاقة الهوية، جواز السفر...)", addMore: "إضافة مستند آخر", uploading: "جاري التحميل...", remove: "حذف المستند", uploadFailed: "فشل التحميل. يرجى المحاولة مرة أخرى.", defaultDoc: "مستند", }, en: { uploadPrompt: "Upload documents (ID card, passport...)", addMore: "Add another document", uploading: "Uploading to temporary storage...", remove: "Remove document", uploadFailed: "Upload failed. Please try again.", defaultDoc: "document", }, tr: { uploadPrompt: "Belgeleri yükleyin (Kimlik kartı, pasaport...)", addMore: "Başka bir belge ekle", uploading: "Yükleniyor...", remove: "Belgeyi kaldır", uploadFailed: "Yükleme başarısız oldu. Lütfen tekrar deneyin.", defaultDoc: "belge", }, ur: { uploadPrompt: "دستاویزات اپ لوڈ کریں (شناختی کارڈ، پاسپورٹ...)", addMore: "ایک اور دستاویز شامل کریں", uploading: "اپ لوڈ ہو رہا ہے...", remove: "دستاویز حذف کریں", uploadFailed: "اپ لوڈ ناکام ہو گیا۔ براہ کرم دوبارہ کوشش کریں۔", defaultDoc: "دستاویز", }, ru: { uploadPrompt: "Загрузить документы (удостоверение личности, паспорт...)", addMore: "Добавить еще документ", uploading: "Загрузка...", remove: "Удалить документ", uploadFailed: "Ошибка загрузки. Пожалуйста, попробуйте снова.", defaultDoc: "документ", }, de: { uploadPrompt: "Dokumente hochladen (Personalausweis, Reisepass...)", addMore: "Weitere Datei hinzufügen", uploading: "Wird hochgeladen...", remove: "Dokument entfernen", uploadFailed: "Upload fehlgeschlagen. Bitte erneut versuchen.", defaultDoc: "Dokument", }, fr: { uploadPrompt: "Télécharger des documents (carte d'identité, passeport...)", addMore: "Ajouter un autre document", uploading: "Téléchargement en cours...", remove: "Supprimer le document", uploadFailed: "Échec du téléchargement. Veuillez réessayer.", defaultDoc: "document", }, es: { uploadPrompt: "Subir documentos (DNI, pasaporte...)", addMore: "Agregar otro documento", uploading: "Subiendo...", remove: "Eliminar documento", uploadFailed: "Error al subir. Inténtalo de nuevo.", defaultDoc: "documento", }, zh: { uploadPrompt: "上传证件(身份证、护照等)", addMore: "添加其他文件", uploading: "上传中...", remove: "删除文件", uploadFailed: "上传失败,请重试。", defaultDoc: "文件", }, id: { uploadPrompt: "Unggah dokumen (KTP, paspor...)", addMore: "Tambah dokumen lain", uploading: "Mengunggah...", remove: "Hapus dokumen", uploadFailed: "Unggah gagal. Silakan coba lagi.", defaultDoc: "dokumen", }, }; /** Map question file extensions to upload_file mediaType. */ function resolveMediaType( extensions: string[], ): "image" | "video" | "audio" | "file" { const lower = extensions.map((e) => e.replace(/^\./, "").toLowerCase()); const allImages = lower.every((e) => ["jpg", "jpeg", "png", "webp", "gif", "bmp", "svg"].includes(e), ); if (allImages) return "image"; const allVideo = lower.every((e) => ["mp4", "mov", "avi", "mkv", "webm"].includes(e), ); if (allVideo) return "video"; const allAudio = lower.every((e) => ["mp3", "m4a", "wav", "aac", "ogg"].includes(e), ); if (allAudio) return "audio"; return "file"; } function isImageFile( fileName?: string | null, fileUrl?: string | null, ): boolean { if ( fileUrl && (fileUrl.startsWith("data:image/") || fileUrl.startsWith("blob:")) ) { if (fileUrl.startsWith("data:application/pdf")) return false; if (fileName && /\.(pdf|doc|docx|xls|xlsx|txt|zip|rar)$/i.test(fileName)) { return false; } return true; } const nameToCheck = fileName || fileUrl || ""; return /\.(jpg|jpeg|png|webp|gif|svg|bmp|avif)$/i.test(nameToCheck); } function base64ToFile(dataUrl: string, filename = "document"): File { const parts = dataUrl.split(","); const mimeMatch = parts[0]?.match(/:(.*?);/); const mime = mimeMatch ? mimeMatch[1] : "application/octet-stream"; const base64Data = parts[1] || parts[0]; const binaryString = atob(base64Data); const len = binaryString.length; const bytes = new Uint8Array(len); for (let i = 0; i < len; i++) { bytes[i] = binaryString.charCodeAt(i); } return new File([bytes], filename, { type: mime }); } export type UploadedDoc = { url: string; name: string; previewUrl?: string; }; export function QuestionFile({ question, disabled, debugProgress, initialDocs, }: QuestionFileProps) { const { locale } = useI18n(); const t = FILE_I18N[locale] ?? FILE_I18N[locale?.slice(0, 2)] ?? FILE_I18N.fa; const { getAnswerValue, setAnswerValue, registerPendingUpload } = useQuestionAnswers(); const storedValue = getAnswerValue(question); 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 = resolveMediaUrl(item) ?? item; return { url: item, previewUrl: resolvedUrl, name: item.split("/").pop() || t.defaultDoc, }; } const url = (item.url || item.path || "") as string; const name = (item.name || url.split("/").pop() || t.defaultDoc) as string; const resolvedUrl = resolveMediaUrl(url) ?? url; return { url, name, previewUrl: resolvedUrl }; }); } if (typeof stored === "string" && stored.trim().length > 0) { const resolvedUrl = resolveMediaUrl(stored) ?? stored; return [ { url: stored, name: stored.split("/").pop() || t.defaultDoc, previewUrl: resolvedUrl, }, ]; } return []; }, [t.defaultDoc], ); const [uploadedDocs, setUploadedDocs] = useState(() => initialDocs ?? parseInitialDocs(storedValue), ); const [isUploading, setIsUploading] = useState(debugProgress !== undefined); const [uploadProgress, setUploadProgress] = useState( debugProgress ?? null, ); const [toastMessage, setToastMessage] = useState(null); const [imgErrorSet, setImgErrorSet] = useState>(() => new Set()); const activeBlobUrlsRef = useRef>(new Map()); const isInitiatorRef = useRef(false); const pendingDeferredRef = useRef<{ resolve: (val?: any) => void; reject: (err?: any) => void; } | null>(null); const fileInputRef = useRef(null); // Revoke blob URLs on unmount useEffect(() => { return () => { for (const blobUrl of activeBlobUrlsRef.current.values()) { URL.revokeObjectURL(blobUrl); } activeBlobUrlsRef.current.clear(); }; }, []); // Sync state when answers load asynchronously from server/cache useEffect(() => { if (storedValue !== undefined && storedValue !== null) { setUploadedDocs(parseInitialDocs(storedValue)); } }, [storedValue, parseInitialDocs]); const acceptedFiles = (question.extras?.options ?? []) .map((option) => option.replace(/^\./, "")) .join(", "); const uploadTmpMediaMutation = useUploadTmpMediaMutation({ onSuccess: (response) => { if (response.path) { const resolved = resolveMediaUrl(response.path) ?? response.path; const newDoc: UploadedDoc = { url: response.path, name: response.name ?? response.path.split("/").pop() ?? t.defaultDoc, 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); setUploadProgress(null); }, onError: (error) => { console.error("File upload error:", error); setIsUploading(false); setUploadProgress(null); setToastMessage(t.uploadFailed); }, }); const isPending = uploadTmpMediaMutation.isPending || isUploading; // Listen for upload_file responses from Flutter useEffect(() => { if (!isInFlutterWebView()) return; 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": setIsUploading(true); setUploadProgress(0); break; case "progress": { setIsUploading(true); const p = (event.data as any)?.progress; if (typeof p === "number") { setUploadProgress(Math.round(p)); } break; } case "completed": { setIsUploading(false); setUploadProgress(null); isInitiatorRef.current = false; 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 = resolveMediaUrl(remoteUrl) ?? remoteUrl; newDocs.push({ url: remoteUrl, name: f?.name || remoteUrl.split("/").pop() || t.defaultDoc, previewUrl: resolved, }); } else if (f?.base64) { try { const fileObj = base64ToFile(f.base64, f.name ?? "document"); uploadTmpMediaMutation.mutate(fileObj); } catch (e) { console.error("Failed to decode base64 file:", e); } } } 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; }); pendingDeferredRef.current?.resolve(newDocs); pendingDeferredRef.current = null; } else { pendingDeferredRef.current?.resolve([]); pendingDeferredRef.current = null; } break; } case "cancelled": case "failed": setIsUploading(false); setUploadProgress(null); isInitiatorRef.current = false; pendingDeferredRef.current?.reject(new Error("File upload failed or cancelled")); pendingDeferredRef.current = null; break; } }); return () => { unsubscribe?.(); }; }, [question, setAnswerValue, t.defaultDoc, uploadTmpMediaMutation]); /** Handle file pick in Flutter WebView via upload_file action. */ const handleFlutterPick = useCallback(() => { const extensions = (question.extras?.options ?? []).map((o) => o.replace(/^\./, "").toLowerCase(), ); 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); const pickerSource = question.ui_config?.source || question.ui_config?.picker_type || "file_system"; const pickerType = question.ui_config?.picker_type || question.ui_config?.source || "file_system"; uploadFile({ requestId: String(question.id), mediaType, multiple: true, source: pickerSource as any, picker_type: pickerType as any, returnAs: "upload", uploadUrl: `${window.location.origin}/api/proxy?__proxyPath=/upload-tmp-media/`, fieldName: "file", allowedExtensions: extensions, maxBytes: 10_485_760, // 10 MB title: typeof question.title === "string" ? question.title : "Upload File", }); }, [question, registerPendingUpload]); /** Handle file pick via browser (fallback). */ function handleBrowserFileChange(files: FileList | null) { if (!files || files.length === 0) return; setIsUploading(true); setUploadProgress(0); setToastMessage(null); // Create instant blob previews for image files const blobPreviews: { file: File; blobUrl: string | null }[] = []; for (let i = 0; i < files.length; i++) { const file = files[i]; const isImg = file.type.startsWith("image/"); if (isImg) { const blobUrl = URL.createObjectURL(file); blobPreviews.push({ file, blobUrl }); } else { blobPreviews.push({ file, blobUrl: null }); } } const uploadTask = (async () => { const newDocs: UploadedDoc[] = []; let hasError = false; for (let i = 0; i < blobPreviews.length; i++) { const { file, blobUrl } = blobPreviews[i]; try { const res = await uploadTmpMedia(file, (p) => setUploadProgress(p)); if (res.path) { const resolved = resolveMediaUrl(res.path) ?? res.path; // Use blob preview URL if available (instant), otherwise use resolved backend URL const previewUrl = blobUrl ?? resolved; if (blobUrl) { activeBlobUrlsRef.current.set(res.path, blobUrl); } newDocs.push({ url: res.path, name: res.name || file.name, previewUrl, }); } } catch (err) { console.error("Failed to upload tmp file:", file.name, err); // Revoke blob URL on failure if (blobUrl) { URL.revokeObjectURL(blobUrl); } hasError = true; } } setIsUploading(false); setUploadProgress(null); if (hasError && newDocs.length === 0) { setToastMessage(t.uploadFailed); } 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) => { e.stopPropagation(); e.preventDefault(); setUploadedDocs((prev) => { const removedDoc = prev[indexToRemove]; // Revoke blob URL if exists if (removedDoc) { const blobUrl = activeBlobUrlsRef.current.get(removedDoc.url); if (blobUrl) { URL.revokeObjectURL(blobUrl); activeBlobUrlsRef.current.delete(removedDoc.url); } } const nextDocs = prev.filter((_, idx) => idx !== indexToRemove); // Clear image error tracking for removed index setImgErrorSet((prevSet) => { const newSet = new Set(); for (const errIdx of prevSet) { if (errIdx < indexToRemove) newSet.add(errIdx); else if (errIdx > indexToRemove) newSet.add(errIdx - 1); } return newSet; }); 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 hasDocuments = uploadedDocs.length > 0; return (
{toastMessage && ( setToastMessage(null)} /> )} {/* Hidden browser input for fallback */} {!inWebView && ( handleBrowserFileChange(event.target.files)} className="sr-only" /> )}
handleTriggerPick() : undefined} onKeyDown={ !hasDocuments ? (e) => { if (e.key === "Enter" || e.key === " ") handleTriggerPick(); } : undefined } className={[ "relative flex w-full flex-col items-center justify-center rounded-[29px] border-2 border-dashed border-[#8D8D8D] bg-[#F7F7F7] text-center transition-colors duration-200 hover:border-[#6F6F6F] overflow-hidden select-none", hasDocuments ? "min-h-[156px] p-3.5" : "aspect-[727/330] min-h-[156px] cursor-pointer p-4", ].join(" ")} > {hasDocuments ? ( /* ────── UPLOADED MULTI-DOCUMENT STATE ────── */
{uploadedDocs.map((doc, index) => { const isImg = isImageFile(doc.name, doc.previewUrl ?? doc.url); const hasThumbError = imgErrorSet.has(index); const ext = doc.name?.split(".").pop()?.toUpperCase() || "FILE"; return (
{/* Left: Thumbnail or File Icon */}
{isImg && doc.previewUrl && !hasThumbError ? ( {doc.name} { setImgErrorSet((prev) => new Set(prev).add(index)); }} className="h-12 w-12 shrink-0 rounded-[10px] object-cover border border-[#E5E7EB]" /> ) : (
{ext.length <= 4 ? ext : "FILE"}
)} {/* Middle: File Name */} {doc.name}
{/* Right: Individual Trash Button */}
); })}
{/* Add Another Document Button */}
) : ( /* ────── DEFAULT EMPTY STATE ────── */
Upload {t.uploadPrompt} {uploadTmpMediaMutation.isError ? ( {t.uploadFailed} ) : acceptedFiles ? ( {acceptedFiles} ) : null}
)} {/* Smooth Circular Loading Spinner Overlay with Upload Progress */} {isPending && (
{uploadProgress !== null && uploadProgress > 0 && ( {uploadProgress}% )}
{t.uploading}
)}
); } export default QuestionFile;