|
|
@ -3,7 +3,10 @@ |
|
|
import Image from "next/image"; |
|
|
import Image from "next/image"; |
|
|
import { useCallback, useEffect, useRef, useState } from "react"; |
|
|
import { useCallback, useEffect, useRef, useState } from "react"; |
|
|
import type { QuestionField } from "@/lib/schema-adapter"; |
|
|
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 { getApiRequestUrl } from "@/lib/http"; |
|
|
import { isInFlutterWebView, uploadFile } from "@/lib/webview-actions"; |
|
|
import { isInFlutterWebView, uploadFile } from "@/lib/webview-actions"; |
|
|
import { useQuestionAnswers } from "./question-answer-storage"; |
|
|
import { useQuestionAnswers } from "./question-answer-storage"; |
|
|
@ -56,6 +59,12 @@ function isImageFile( |
|
|
return /\.(jpg|jpeg|png|webp|gif|svg|bmp|avif)$/i.test(nameToCheck); |
|
|
return /\.(jpg|jpeg|png|webp|gif|svg|bmp|avif)$/i.test(nameToCheck); |
|
|
} |
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
export type UploadedDoc = { |
|
|
|
|
|
url: string; |
|
|
|
|
|
name: string; |
|
|
|
|
|
previewUrl?: string; |
|
|
|
|
|
}; |
|
|
|
|
|
|
|
|
export function QuestionFile({ |
|
|
export function QuestionFile({ |
|
|
question, |
|
|
question, |
|
|
disabled, |
|
|
disabled, |
|
|
@ -63,28 +72,60 @@ export function QuestionFile({ |
|
|
const { getAnswerValue, setAnswerValue } = useQuestionAnswers(); |
|
|
const { getAnswerValue, setAnswerValue } = useQuestionAnswers(); |
|
|
const storedValue = getAnswerValue(question); |
|
|
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<string | null>( |
|
|
|
|
|
initialFileName, |
|
|
|
|
|
); |
|
|
|
|
|
const [filePreviewUrl, setFilePreviewUrl] = useState<string | null>( |
|
|
|
|
|
initialFileUrl, |
|
|
|
|
|
|
|
|
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() || "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<UploadedDoc[]>(() => |
|
|
|
|
|
parseInitialDocs(storedValue), |
|
|
); |
|
|
); |
|
|
|
|
|
const [isUploading, setIsUploading] = useState(false); |
|
|
const isInitiatorRef = useRef(false); |
|
|
const isInitiatorRef = useRef(false); |
|
|
const [isFlutterPicking, setIsFlutterPicking] = useState(false); |
|
|
|
|
|
|
|
|
const fileInputRef = useRef<HTMLInputElement>(null); |
|
|
|
|
|
|
|
|
const acceptedFiles = (question.extras?.options ?? []) |
|
|
const acceptedFiles = (question.extras?.options ?? []) |
|
|
.map((option) => option.replace(/^\./, "")) |
|
|
.map((option) => option.replace(/^\./, "")) |
|
|
@ -93,17 +134,32 @@ export function QuestionFile({ |
|
|
const uploadTmpMediaMutation = useUploadTmpMediaMutation({ |
|
|
const uploadTmpMediaMutation = useUploadTmpMediaMutation({ |
|
|
onSuccess: (response) => { |
|
|
onSuccess: (response) => { |
|
|
if (response.path) { |
|
|
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) => { |
|
|
onError: (error) => { |
|
|
console.error("File upload error:", 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
|
|
|
// Listen for upload_file responses from Flutter
|
|
|
useEffect(() => { |
|
|
useEffect(() => { |
|
|
@ -116,52 +172,66 @@ export function QuestionFile({ |
|
|
|
|
|
|
|
|
switch (event.status) { |
|
|
switch (event.status) { |
|
|
case "picking": |
|
|
case "picking": |
|
|
setIsFlutterPicking(true); |
|
|
|
|
|
break; |
|
|
break; |
|
|
case "picked": |
|
|
case "picked": |
|
|
if (event.data?.files?.[0]) { |
|
|
|
|
|
const fileName = event.data.files[0].name ?? null; |
|
|
|
|
|
setSelectedFileName(fileName); |
|
|
|
|
|
} |
|
|
|
|
|
break; |
|
|
|
|
|
case "progress": |
|
|
case "progress": |
|
|
|
|
|
setIsUploading(true); |
|
|
break; |
|
|
break; |
|
|
case "completed": { |
|
|
case "completed": { |
|
|
setIsFlutterPicking(false); |
|
|
|
|
|
|
|
|
setIsUploading(false); |
|
|
isInitiatorRef.current = 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; |
|
|
break; |
|
|
} |
|
|
} |
|
|
case "cancelled": |
|
|
case "cancelled": |
|
|
setIsFlutterPicking(false); |
|
|
|
|
|
isInitiatorRef.current = false; |
|
|
|
|
|
break; |
|
|
|
|
|
case "failed": |
|
|
case "failed": |
|
|
setIsFlutterPicking(false); |
|
|
|
|
|
|
|
|
setIsUploading(false); |
|
|
isInitiatorRef.current = false; |
|
|
isInitiatorRef.current = false; |
|
|
console.error("upload_file failed:", event.message); |
|
|
|
|
|
break; |
|
|
break; |
|
|
} |
|
|
} |
|
|
}); |
|
|
}); |
|
|
@ -179,11 +249,11 @@ export function QuestionFile({ |
|
|
const mediaType = resolveMediaType(extensions); |
|
|
const mediaType = resolveMediaType(extensions); |
|
|
|
|
|
|
|
|
isInitiatorRef.current = true; |
|
|
isInitiatorRef.current = true; |
|
|
setIsFlutterPicking(true); |
|
|
|
|
|
|
|
|
|
|
|
uploadFile({ |
|
|
uploadFile({ |
|
|
requestId: String(question.id), |
|
|
requestId: String(question.id), |
|
|
mediaType, |
|
|
mediaType, |
|
|
|
|
|
multiple: true, |
|
|
source: "gallery", |
|
|
source: "gallery", |
|
|
returnAs: "upload", |
|
|
returnAs: "upload", |
|
|
uploadUrl: `${process.env.NEXT_PUBLIC_API_BASE_URL ?? ""}/upload-tmp-media/`, |
|
|
uploadUrl: `${process.env.NEXT_PUBLIC_API_BASE_URL ?? ""}/upload-tmp-media/`, |
|
|
@ -195,180 +265,236 @@ export function QuestionFile({ |
|
|
}); |
|
|
}); |
|
|
}, [question]); |
|
|
}, [question]); |
|
|
|
|
|
|
|
|
/** Handle file pick via browser <input type="file"> (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 <input type="file" multiple> (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.stopPropagation(); |
|
|
e.preventDefault(); |
|
|
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 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 ( |
|
|
return ( |
|
|
<div |
|
|
<div |
|
|
data-question-answered={isUploaded ? "true" : "false"} |
|
|
|
|
|
|
|
|
data-question-answered={hasDocuments ? "true" : "false"} |
|
|
className={[ |
|
|
className={[ |
|
|
"flex w-full flex-col gap-2 transition-opacity duration-200", |
|
|
"flex w-full flex-col gap-2 transition-opacity duration-200", |
|
|
disabled ? "pointer-events-none opacity-30" : "", |
|
|
disabled ? "pointer-events-none opacity-30" : "", |
|
|
].join(" ")} |
|
|
].join(" ")} |
|
|
> |
|
|
> |
|
|
<QuestionTitle question={question} /> |
|
|
<QuestionTitle question={question} /> |
|
|
<span |
|
|
|
|
|
className="relative flex aspect-[727/330] min-h-[156px] w-full cursor-pointer flex-col items-center justify-center rounded-[29px] border-2 border-dashed border-[#8D8D8D] bg-[#F7F7F7] text-center transition-colors duration-200 focus-within:outline-2 focus-within:outline-offset-4 focus-within:outline-[#6F6F6F] hover:border-[#777777] overflow-hidden p-4" |
|
|
|
|
|
role={inWebView ? "button" : undefined} |
|
|
|
|
|
tabIndex={inWebView ? 0 : undefined} |
|
|
|
|
|
onClick={inWebView && !isUploaded ? handleFlutterPick : undefined} |
|
|
|
|
|
onKeyDown={ |
|
|
|
|
|
inWebView && !isUploaded |
|
|
|
|
|
? (e) => { |
|
|
|
|
|
if (e.key === "Enter" || e.key === " ") handleFlutterPick(); |
|
|
|
|
|
} |
|
|
|
|
|
: undefined |
|
|
|
|
|
} |
|
|
|
|
|
> |
|
|
|
|
|
{/* Fallback: browser file input (hidden in WebView or when uploaded) */} |
|
|
|
|
|
{!inWebView && !isUploaded && ( |
|
|
|
|
|
<input |
|
|
|
|
|
type="file" |
|
|
|
|
|
accept={acceptedFiles ? acceptedFiles : "*/*"} |
|
|
|
|
|
disabled={disabled} |
|
|
|
|
|
onChange={(event) => handleBrowserFileChange(event.target.files)} |
|
|
|
|
|
className="absolute inset-0 z-10 h-full w-full cursor-pointer opacity-0" |
|
|
|
|
|
/> |
|
|
|
|
|
)} |
|
|
|
|
|
|
|
|
|
|
|
{isUploaded ? ( |
|
|
|
|
|
/* ────── UPLOADED STATE ────── */ |
|
|
|
|
|
<div className="relative flex h-full w-full flex-col items-center justify-center"> |
|
|
|
|
|
{isImg && currentFileUrl ? ( |
|
|
|
|
|
/* Image Preview (Left design in screenshot) */ |
|
|
|
|
|
<img |
|
|
|
|
|
src={currentFileUrl} |
|
|
|
|
|
alt={currentFileName ?? "Uploaded image"} |
|
|
|
|
|
className="max-h-[120px] max-w-[85%] rounded-[12px] object-contain shadow-xs" |
|
|
|
|
|
/> |
|
|
|
|
|
) : ( |
|
|
|
|
|
/* Document / PDF Preview (Right design in screenshot) */ |
|
|
|
|
|
<div className="flex flex-col items-center justify-center gap-2"> |
|
|
|
|
|
<div className="relative flex h-14 w-11 items-center justify-center rounded-[6px] border border-[#D1D5DB] bg-white shadow-xs"> |
|
|
|
|
|
<svg |
|
|
|
|
|
width="28" |
|
|
|
|
|
height="32" |
|
|
|
|
|
viewBox="0 0 32 36" |
|
|
|
|
|
fill="none" |
|
|
|
|
|
xmlns="http://www.w3.org/2000/svg" |
|
|
|
|
|
|
|
|
{/* 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 |
|
|
|
|
|
className="relative flex min-h-[160px] w-full flex-col items-center justify-center rounded-[29px] border-2 border-dashed border-[#8D8D8D] bg-[#F7F7F7] text-center transition-colors duration-200 focus-within:outline-2 focus-within:outline-offset-4 focus-within:outline-[#6F6F6F] hover:border-[#777777] overflow-hidden p-4" |
|
|
|
|
|
> |
|
|
|
|
|
{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" |
|
|
> |
|
|
> |
|
|
<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-[8px] font-bold text-white leading-none"> |
|
|
|
|
|
PDF |
|
|
|
|
|
</span> |
|
|
|
|
|
</div> |
|
|
|
|
|
<span className="max-w-[240px] truncate text-xs font-semibold text-[#111111]"> |
|
|
|
|
|
{currentFileName ?? "document"} |
|
|
|
|
|
</span> |
|
|
|
|
|
</div> |
|
|
|
|
|
)} |
|
|
|
|
|
|
|
|
|
|
|
{/* Trash Button in Bottom-Right */} |
|
|
|
|
|
|
|
|
{/* 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="حذف مدرک" |
|
|
|
|
|
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 |
|
|
<button |
|
|
type="button" |
|
|
type="button" |
|
|
onClick={handleRemoveFile} |
|
|
|
|
|
title="Remove file" |
|
|
|
|
|
className="absolute bottom-0 right-0 z-20 flex h-8 w-8 items-center justify-center rounded-full bg-[#EAEAEA] text-[#36363C] transition-colors hover:bg-[#DDD] active:scale-95 cursor-pointer shadow-xs" |
|
|
|
|
|
|
|
|
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 |
|
|
<svg |
|
|
width="16" |
|
|
|
|
|
height="16" |
|
|
|
|
|
|
|
|
width="14" |
|
|
|
|
|
height="14" |
|
|
viewBox="0 0 24 24" |
|
|
viewBox="0 0 24 24" |
|
|
fill="none" |
|
|
fill="none" |
|
|
stroke="currentColor" |
|
|
stroke="currentColor" |
|
|
strokeWidth="2" |
|
|
|
|
|
|
|
|
strokeWidth="2.5" |
|
|
strokeLinecap="round" |
|
|
strokeLinecap="round" |
|
|
strokeLinejoin="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" /> |
|
|
|
|
|
|
|
|
<path d="M12 5v14M5 12h14" /> |
|
|
</svg> |
|
|
</svg> |
|
|
|
|
|
<span>افزودن مدرک دیگر</span> |
|
|
</button> |
|
|
</button> |
|
|
|
|
|
|
|
|
{isPending && ( |
|
|
|
|
|
<div className="absolute inset-0 z-30 flex items-center justify-center rounded-[29px] bg-black/40 backdrop-blur-[1px]"> |
|
|
|
|
|
<LoadingSkeleton className="h-full w-full rounded-[29px]" /> |
|
|
|
|
|
</div> |
|
|
|
|
|
)} |
|
|
|
|
|
</div> |
|
|
</div> |
|
|
) : /* ────── DEFAULT EMPTY STATE ────── */ |
|
|
|
|
|
isPending ? ( |
|
|
|
|
|
<LoadingSkeleton className="h-24 w-full rounded-[24px]" /> |
|
|
|
|
|
) : ( |
|
|
) : ( |
|
|
<> |
|
|
|
|
|
|
|
|
/* ────── DEFAULT EMPTY STATE ────── */ |
|
|
|
|
|
<div |
|
|
|
|
|
role="button" |
|
|
|
|
|
tabIndex={0} |
|
|
|
|
|
onClick={() => handleTriggerPick()} |
|
|
|
|
|
onKeyDown={(e) => { |
|
|
|
|
|
if (e.key === "Enter" || e.key === " ") handleTriggerPick(); |
|
|
|
|
|
}} |
|
|
|
|
|
className="flex w-full cursor-pointer flex-col items-center justify-center py-4" |
|
|
|
|
|
> |
|
|
<Image |
|
|
<Image |
|
|
src="/assets/images/Image.svg" |
|
|
src="/assets/images/Image.svg" |
|
|
alt="Upload" |
|
|
alt="Upload" |
|
|
width={24} |
|
|
|
|
|
height={24} |
|
|
|
|
|
|
|
|
width={28} |
|
|
|
|
|
height={28} |
|
|
/> |
|
|
/> |
|
|
<span className="mt-3 block group-12 leading-none font-normal text-[#111111]"> |
|
|
|
|
|
{selectedFileName ?? "upload certificates"} |
|
|
|
|
|
|
|
|
<span className="mt-3 block group-12 leading-none font-semibold text-[#111111]"> |
|
|
|
|
|
بارگذاری مدارک (کارت شناسایی، پاسپورت و...) |
|
|
</span> |
|
|
</span> |
|
|
{uploadTmpMediaMutation.isError ? ( |
|
|
|
|
|
<span className="mt-2 block group-10 leading-none font-bold text-[#D44747]"> |
|
|
|
|
|
Upload failed. Please try again. |
|
|
|
|
|
</span> |
|
|
|
|
|
) : acceptedFiles ? ( |
|
|
|
|
|
|
|
|
{acceptedFiles ? ( |
|
|
<span className="mt-2 block group-10 leading-none font-bold text-[#8B8B8B]"> |
|
|
<span className="mt-2 block group-10 leading-none font-bold text-[#8B8B8B]"> |
|
|
{acceptedFiles} |
|
|
{acceptedFiles} |
|
|
</span> |
|
|
</span> |
|
|
) : null} |
|
|
) : null} |
|
|
</> |
|
|
|
|
|
|
|
|
</div> |
|
|
|
|
|
)} |
|
|
|
|
|
|
|
|
|
|
|
{/* Smooth Circular Loading Spinner Overlay (Matching Flutter ProfileAvatar) */} |
|
|
|
|
|
{isPending && ( |
|
|
|
|
|
<div className="absolute inset-0 z-30 flex flex-col items-center justify-center rounded-[29px] bg-black/40 backdrop-blur-[1px] transition-all duration-200 gap-2"> |
|
|
|
|
|
<div className="h-8 w-8 animate-spin rounded-full border-[2.5px] border-white border-t-transparent shadow-sm" /> |
|
|
|
|
|
<span className="text-xs font-semibold text-white">در حال آپلود در تمپ...</span> |
|
|
|
|
|
</div> |
|
|
)} |
|
|
)} |
|
|
</span> |
|
|
|
|
|
|
|
|
</div> |
|
|
</div> |
|
|
</div> |
|
|
); |
|
|
); |
|
|
} |
|
|
} |
|
|
|