You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 
 
 

693 lines
24 KiB

"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<string, unknown> => 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<UploadedDoc[]>(() =>
parseInitialDocs(storedValue),
);
const [isUploading, setIsUploading] = useState(false);
const [uploadProgress, setUploadProgress] = useState<number | null>(null);
const isInitiatorRef = useRef(false);
const pendingDeferredRef = useRef<{
resolve: (val?: any) => void;
reject: (err?: any) => void;
} | null>(null);
const fileInputRef = useRef<HTMLInputElement>(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 <input type="file" multiple> (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 (
<div
data-question-answered={hasDocuments ? "true" : "false"}
className={[
"flex w-full flex-col gap-2 transition-opacity duration-200",
disabled ? "pointer-events-none opacity-30" : "",
].join(" ")}
>
<QuestionTitle question={question} />
{/* Hidden browser input for fallback */}
{!inWebView && (
<input
ref={fileInputRef}
type="file"
multiple
accept={acceptedFiles ? acceptedFiles : "*/*"}
disabled={disabled}
onChange={(event) => handleBrowserFileChange(event.target.files)}
className="sr-only"
/>
)}
<div
role="button"
tabIndex={0}
onClick={!hasDocuments ? () => 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 ────── */
<div className="relative flex w-full flex-col items-center gap-3">
<div className="flex w-full flex-col gap-2">
{uploadedDocs.map((doc, index) => {
const isImg = isImageFile(doc.name, doc.previewUrl ?? doc.url);
return (
<div
key={`${doc.url}-${index}`}
className="relative flex w-full items-center justify-between gap-3 rounded-[16px] border border-[#E5E7EB] bg-white p-2.5 shadow-xs transition-shadow hover:shadow-sm"
>
{/* Left: Thumbnail or PDF Icon */}
<div className="flex items-center gap-3 overflow-hidden">
{isImg && doc.previewUrl ? (
<img
src={doc.previewUrl}
alt={doc.name}
className="h-12 w-12 shrink-0 rounded-[10px] object-cover border border-[#E5E7EB]"
/>
) : (
<div className="relative flex h-12 w-10 shrink-0 items-center justify-center rounded-[6px] border border-[#D1D5DB] bg-[#F9FAFB] shadow-xs">
<svg
width="24"
height="28"
viewBox="0 0 32 36"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M4 0C1.79086 0 0 1.79086 0 4V32C0 34.2091 1.79086 36 4 36H28C30.2091 36 32 34.2091 32 32V10L22 0H4Z"
fill="#E5E7EB"
/>
<path d="M22 0V10H32L22 0Z" fill="#9CA3AF" />
</svg>
<span className="absolute bottom-1 rounded-[3px] bg-[#F0445B] px-1 py-[1px] text-[7px] font-bold text-white leading-none">
PDF
</span>
</div>
)}
{/* Middle: File Name */}
<span className="truncate text-start text-xs font-semibold text-[#1F2024] max-w-[200px]">
{doc.name}
</span>
</div>
{/* Right: Individual Trash Button */}
<button
type="button"
onClick={(e) => handleRemoveDoc(index, e)}
title={t.remove}
className="flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-[#F3F4F6] text-[#4B5563] transition-colors hover:bg-red-50 hover:text-[#EF4444] active:scale-95 cursor-pointer"
>
<svg
width="15"
height="15"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="M3 6h18" />
<path d="M19 6v14c0 1-1 2-2 2H7c-1 0-2-1-2-2V6" />
<path d="M8 6V4c0-1 1-2 2-2h4c1 0 2 1 2 2v2" />
</svg>
</button>
</div>
);
})}
</div>
{/* Add Another Document Button */}
<button
type="button"
onClick={(e) => handleTriggerPick(e)}
className="mt-1 flex items-center justify-center gap-1.5 rounded-full border border-[#D1D5DB] bg-white px-4 py-2 text-xs font-bold text-[#374151] shadow-xs transition-colors hover:bg-[#F9FAFB] hover:border-[#9CA3AF] active:scale-98 cursor-pointer"
>
<svg
width="14"
height="14"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2.5"
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="M12 5v14M5 12h14" />
</svg>
<span>{t.addMore}</span>
</button>
</div>
) : (
/* ────── DEFAULT EMPTY STATE ────── */
<div className="flex w-full flex-col items-center justify-center py-4 pointer-events-none">
<Image
src="/assets/images/Image.svg"
alt="Upload"
width={28}
height={28}
/>
<span className="mt-3 block group-12 leading-none font-semibold text-[#111111]">
{t.uploadPrompt}
</span>
{uploadTmpMediaMutation.isError ? (
<span className="mt-2 block group-10 leading-none font-bold text-[#D44747]">
{t.uploadFailed}
</span>
) : acceptedFiles ? (
<span className="mt-2 block group-10 leading-none font-bold text-[#8B8B8B]">
{acceptedFiles}
</span>
) : null}
</div>
)}
{/* Smooth Circular Loading Spinner Overlay with Upload Progress */}
{isPending && (
<div className="absolute inset-0 z-30 flex flex-col items-center justify-center rounded-[29px] bg-black/50 backdrop-blur-[1px] transition-all duration-200 gap-2">
<div className="relative flex items-center justify-center">
<div className="h-10 w-10 animate-spin rounded-full border-[2.5px] border-white border-t-transparent shadow-sm" />
{uploadProgress !== null && uploadProgress > 0 && (
<span className="absolute text-[10px] font-bold text-white leading-none">
{uploadProgress}%
</span>
)}
</div>
<span className="text-xs font-semibold text-white">
{uploadProgress !== null && uploadProgress > 0
? `${t.uploading} (${uploadProgress}%)`
: t.uploading}
</span>
</div>
)}
</div>
</div>
);
}
export default QuestionFile;