"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 } from "@/lib/http"; import { isInFlutterWebView, uploadFile } from "@/lib/webview-actions"; import { useI18n } from "@/translations/provider"; import { useQuestionAnswers } from "./question-answer-storage"; import QuestionTitle from "./question-title"; import { LoadingSkeleton } from "./loading-skeleton"; type QuestionFileProps = { question: QuestionField; disabled?: boolean; }; 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); } export type UploadedDoc = { url: string; name: string; previewUrl?: string; }; export function QuestionFile({ question, disabled, }: 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 = item.startsWith("http") || item.startsWith("blob:") || item.startsWith("data:") ? item : getApiRequestUrl(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 = 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() || t.defaultDoc, previewUrl: resolvedUrl, }, ]; } return []; }, [t.defaultDoc], ); const [uploadedDocs, setUploadedDocs] = useState(() => parseInitialDocs(storedValue), ); 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 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 = response.path.startsWith("http") ? response.path : getApiRequestUrl(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); }, }); 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 = remoteUrl.startsWith("http") || remoteUrl.startsWith("blob:") || remoteUrl.startsWith("data:") ? remoteUrl : getApiRequestUrl(remoteUrl); newDocs.push({ url: remoteUrl, name: f?.name || remoteUrl.split("/").pop() || t.defaultDoc, 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; }); 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: `${process.env.NEXT_PUBLIC_API_BASE_URL ?? ""}/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); 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); } } 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; }); } if (fileInputRef.current) { fileInputRef.current.value = ""; } })(); registerPendingUpload?.(question.id, uploadTask); } const handleRemoveDoc = (indexToRemove: number, e: React.MouseEvent) => { e.stopPropagation(); e.preventDefault(); 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 hasDocuments = uploadedDocs.length > 0; return (
{/* 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); return (
{/* Left: Thumbnail or PDF Icon */}
{isImg && doc.previewUrl ? ( {doc.name} ) : (
PDF
)} {/* 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}% )}
{uploadProgress !== null && uploadProgress > 0 ? `${t.uploading} (${uploadProgress}%)` : t.uploading}
)}
); } export default QuestionFile;