|
|
@ -4,6 +4,7 @@ import Image from "next/image"; |
|
|
import { useCallback, useEffect, useState } from "react"; |
|
|
import { useCallback, useEffect, useState } from "react"; |
|
|
import type { QuestionField } from "@/data/question-data"; |
|
|
import type { QuestionField } from "@/data/question-data"; |
|
|
import { useUploadTmpMediaMutation } from "@/hooks/marriage/use-upload-tmp-media"; |
|
|
import { useUploadTmpMediaMutation } from "@/hooks/marriage/use-upload-tmp-media"; |
|
|
|
|
|
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"; |
|
|
import QuestionTitle from "./question-title"; |
|
|
import QuestionTitle from "./question-title"; |
|
|
@ -37,23 +38,60 @@ function resolveMediaType( |
|
|
return "file"; |
|
|
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 function QuestionFile({ |
|
|
export function QuestionFile({ |
|
|
question, |
|
|
question, |
|
|
questionIndex, |
|
|
questionIndex, |
|
|
disabled, |
|
|
disabled, |
|
|
}: QuestionFileProps) { |
|
|
}: QuestionFileProps) { |
|
|
const [selectedFileName, setSelectedFileName] = useState<string | null>(null); |
|
|
|
|
|
|
|
|
const { getAnswerValue, setAnswerValue } = useQuestionAnswers(); |
|
|
|
|
|
const storedValue = getAnswerValue(question, questionIndex); |
|
|
|
|
|
|
|
|
|
|
|
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 [isFlutterPicking, setIsFlutterPicking] = useState(false); |
|
|
const [isFlutterPicking, setIsFlutterPicking] = useState(false); |
|
|
const acceptedFiles = question.extras.options |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
const acceptedFiles = (question.extras?.options ?? []) |
|
|
.map((option) => option.replace(/^\./, "")) |
|
|
.map((option) => option.replace(/^\./, "")) |
|
|
.join(", "); |
|
|
.join(", "); |
|
|
const { setAnswerValue } = useQuestionAnswers(); |
|
|
|
|
|
|
|
|
|
|
|
const uploadTmpMediaMutation = useUploadTmpMediaMutation({ |
|
|
const uploadTmpMediaMutation = useUploadTmpMediaMutation({ |
|
|
onSuccess: (response) => { |
|
|
onSuccess: (response) => { |
|
|
|
|
|
if (response.path) { |
|
|
setAnswerValue(question, questionIndex, response.path); |
|
|
setAnswerValue(question, questionIndex, response.path); |
|
|
|
|
|
} |
|
|
}, |
|
|
}, |
|
|
onError: () => { |
|
|
|
|
|
setAnswerValue(question, questionIndex, null); |
|
|
|
|
|
|
|
|
onError: (error) => { |
|
|
|
|
|
console.error("File upload error:", error); |
|
|
}, |
|
|
}, |
|
|
}); |
|
|
}); |
|
|
|
|
|
|
|
|
@ -72,28 +110,32 @@ export function QuestionFile({ |
|
|
break; |
|
|
break; |
|
|
case "picked": |
|
|
case "picked": |
|
|
if (event.data?.files?.[0]) { |
|
|
if (event.data?.files?.[0]) { |
|
|
setSelectedFileName(event.data.files[0].name ?? null); |
|
|
|
|
|
|
|
|
const fileName = event.data.files[0].name ?? null; |
|
|
|
|
|
setSelectedFileName(fileName); |
|
|
|
|
|
if (fileName) { |
|
|
|
|
|
setAnswerValue(question, questionIndex, fileName); |
|
|
|
|
|
} |
|
|
} |
|
|
} |
|
|
break; |
|
|
break; |
|
|
case "progress": |
|
|
case "progress": |
|
|
// Could show progress bar – for now just keep pending state
|
|
|
|
|
|
break; |
|
|
break; |
|
|
case "completed": { |
|
|
case "completed": { |
|
|
setIsFlutterPicking(false); |
|
|
setIsFlutterPicking(false); |
|
|
const file = event.data?.files?.[0]; |
|
|
const file = event.data?.files?.[0]; |
|
|
if (file?.url) { |
|
|
if (file?.url) { |
|
|
// Flutter uploaded the file – use the returned URL
|
|
|
|
|
|
setAnswerValue(question, questionIndex, file.url); |
|
|
setAnswerValue(question, questionIndex, file.url); |
|
|
setSelectedFileName(file.name ?? "uploaded"); |
|
|
setSelectedFileName(file.name ?? "uploaded"); |
|
|
|
|
|
setFilePreviewUrl(file.url); |
|
|
} else if (file?.base64) { |
|
|
} else if (file?.base64) { |
|
|
// base64 mode – convert to File and use existing mutation
|
|
|
|
|
|
fetch(file.base64) |
|
|
|
|
|
|
|
|
const b64 = file.base64; |
|
|
|
|
|
setSelectedFileName(file.name ?? "upload"); |
|
|
|
|
|
setFilePreviewUrl(b64); |
|
|
|
|
|
fetch(b64) |
|
|
.then((res) => res.blob()) |
|
|
.then((res) => res.blob()) |
|
|
.then((blob) => { |
|
|
.then((blob) => { |
|
|
const f = new File([blob], file.name ?? "upload", { |
|
|
const f = new File([blob], file.name ?? "upload", { |
|
|
type: blob.type, |
|
|
type: blob.type, |
|
|
}); |
|
|
}); |
|
|
setSelectedFileName(f.name); |
|
|
|
|
|
uploadTmpMediaMutation.mutate(f); |
|
|
uploadTmpMediaMutation.mutate(f); |
|
|
}); |
|
|
}); |
|
|
} |
|
|
} |
|
|
@ -112,12 +154,11 @@ export function QuestionFile({ |
|
|
return () => { |
|
|
return () => { |
|
|
unsubscribe?.(); |
|
|
unsubscribe?.(); |
|
|
}; |
|
|
}; |
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
|
|
|
|
}, [question, questionIndex]); |
|
|
|
|
|
|
|
|
}, [question, questionIndex, setAnswerValue, uploadTmpMediaMutation]); |
|
|
|
|
|
|
|
|
/** Handle file pick in Flutter WebView via upload_file action. */ |
|
|
/** Handle file pick in Flutter WebView via upload_file action. */ |
|
|
const handleFlutterPick = useCallback(() => { |
|
|
const handleFlutterPick = useCallback(() => { |
|
|
const extensions = question.extras.options.map((o) => |
|
|
|
|
|
|
|
|
const extensions = (question.extras?.options ?? []).map((o) => |
|
|
o.replace(/^\./, "").toLowerCase(), |
|
|
o.replace(/^\./, "").toLowerCase(), |
|
|
); |
|
|
); |
|
|
const mediaType = resolveMediaType(extensions); |
|
|
const mediaType = resolveMediaType(extensions); |
|
|
@ -140,18 +181,52 @@ export function QuestionFile({ |
|
|
|
|
|
|
|
|
if (!file) { |
|
|
if (!file) { |
|
|
setSelectedFileName(null); |
|
|
setSelectedFileName(null); |
|
|
|
|
|
setFilePreviewUrl(null); |
|
|
setAnswerValue(question, questionIndex, null); |
|
|
setAnswerValue(question, questionIndex, null); |
|
|
return; |
|
|
return; |
|
|
} |
|
|
} |
|
|
|
|
|
|
|
|
setSelectedFileName(file.name); |
|
|
setSelectedFileName(file.name); |
|
|
|
|
|
setAnswerValue(question, questionIndex, file.name); |
|
|
|
|
|
|
|
|
|
|
|
if (file.type.startsWith("image/")) { |
|
|
|
|
|
const objectUrl = URL.createObjectURL(file); |
|
|
|
|
|
setFilePreviewUrl(objectUrl); |
|
|
|
|
|
} else { |
|
|
|
|
|
setFilePreviewUrl(null); |
|
|
|
|
|
} |
|
|
|
|
|
|
|
|
uploadTmpMediaMutation.mutate(file); |
|
|
uploadTmpMediaMutation.mutate(file); |
|
|
} |
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
const handleRemoveFile = (e: React.MouseEvent) => { |
|
|
|
|
|
e.stopPropagation(); |
|
|
|
|
|
e.preventDefault(); |
|
|
|
|
|
setSelectedFileName(null); |
|
|
|
|
|
setFilePreviewUrl(null); |
|
|
|
|
|
setAnswerValue(question, questionIndex, null); |
|
|
|
|
|
}; |
|
|
|
|
|
|
|
|
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); |
|
|
|
|
|
|
|
|
return ( |
|
|
return ( |
|
|
<div |
|
|
<div |
|
|
|
|
|
data-question-answered={isUploaded ? "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" : "", |
|
|
@ -159,48 +234,120 @@ export function QuestionFile({ |
|
|
> |
|
|
> |
|
|
<QuestionTitle question={question} /> |
|
|
<QuestionTitle question={question} /> |
|
|
<span |
|
|
<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]" |
|
|
|
|
|
|
|
|
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} |
|
|
role={inWebView ? "button" : undefined} |
|
|
tabIndex={inWebView ? 0 : undefined} |
|
|
tabIndex={inWebView ? 0 : undefined} |
|
|
onClick={inWebView ? handleFlutterPick : undefined} |
|
|
|
|
|
|
|
|
onClick={inWebView && !isUploaded ? handleFlutterPick : undefined} |
|
|
onKeyDown={ |
|
|
onKeyDown={ |
|
|
inWebView |
|
|
|
|
|
|
|
|
inWebView && !isUploaded |
|
|
? (e) => { |
|
|
? (e) => { |
|
|
if (e.key === "Enter" || e.key === " ") handleFlutterPick(); |
|
|
if (e.key === "Enter" || e.key === " ") handleFlutterPick(); |
|
|
} |
|
|
} |
|
|
: undefined |
|
|
: undefined |
|
|
} |
|
|
} |
|
|
> |
|
|
> |
|
|
{/* Fallback: browser file input (hidden in WebView) */} |
|
|
|
|
|
{!inWebView && ( |
|
|
|
|
|
|
|
|
{/* Fallback: browser file input (hidden in WebView or when uploaded) */} |
|
|
|
|
|
{!inWebView && !isUploaded && ( |
|
|
<input |
|
|
<input |
|
|
type="file" |
|
|
type="file" |
|
|
accept={question.extras.options.join(",")} |
|
|
|
|
|
disabled={disabled || isPending} |
|
|
|
|
|
|
|
|
accept={acceptedFiles ? acceptedFiles : "*/*"} |
|
|
|
|
|
disabled={disabled} |
|
|
onChange={(event) => handleBrowserFileChange(event.target.files)} |
|
|
onChange={(event) => handleBrowserFileChange(event.target.files)} |
|
|
className="absolute inset-0 z-10 h-full w-full cursor-pointer opacity-0" |
|
|
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" |
|
|
|
|
|
> |
|
|
|
|
|
<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 */} |
|
|
|
|
|
<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" |
|
|
|
|
|
> |
|
|
|
|
|
<svg |
|
|
|
|
|
width="16" |
|
|
|
|
|
height="16" |
|
|
|
|
|
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> |
|
|
|
|
|
|
|
|
|
|
|
{isPending && ( |
|
|
|
|
|
<div className="absolute inset-0 z-30 flex items-center justify-center rounded-[29px] bg-black/40 backdrop-blur-[1px]"> |
|
|
|
|
|
<div className="h-6 w-6 animate-spin rounded-full border-2 border-white border-t-transparent" /> |
|
|
|
|
|
</div> |
|
|
|
|
|
)} |
|
|
|
|
|
</div> |
|
|
|
|
|
) : ( |
|
|
|
|
|
/* ────── DEFAULT EMPTY STATE ────── */ |
|
|
|
|
|
<> |
|
|
<Image |
|
|
<Image |
|
|
src="/assets/images/Image.svg" |
|
|
src="/assets/images/Image.svg" |
|
|
alt="Upload" |
|
|
alt="Upload" |
|
|
width={24} |
|
|
width={24} |
|
|
height={24} |
|
|
height={24} |
|
|
/> |
|
|
/> |
|
|
<span className="mt-3 block text-sm leading-none font-normal text-[#111111]"> |
|
|
|
|
|
|
|
|
<span className="mt-3 block group-12 leading-none font-normal text-[#111111]"> |
|
|
{isPending |
|
|
{isPending |
|
|
? "uploading..." |
|
|
? "uploading..." |
|
|
: (selectedFileName ?? "upload certificates")} |
|
|
: (selectedFileName ?? "upload certificates")} |
|
|
</span> |
|
|
</span> |
|
|
{uploadTmpMediaMutation.isError ? ( |
|
|
{uploadTmpMediaMutation.isError ? ( |
|
|
<span className="mt-2 block text-[10px] leading-none font-bold text-[#D44747]"> |
|
|
|
|
|
|
|
|
<span className="mt-2 block group-10 leading-none font-bold text-[#D44747]"> |
|
|
Upload failed. Please try again. |
|
|
Upload failed. Please try again. |
|
|
</span> |
|
|
</span> |
|
|
) : acceptedFiles ? ( |
|
|
) : acceptedFiles ? ( |
|
|
<span className="mt-2 block text-[10px] 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} |
|
|
|
|
|
</> |
|
|
|
|
|
)} |
|
|
</span> |
|
|
</span> |
|
|
</div> |
|
|
</div> |
|
|
); |
|
|
); |
|
|
@ -208,3 +355,5 @@ export function QuestionFile({ |
|
|
|
|
|
|
|
|
export default QuestionFile; |
|
|
export default QuestionFile; |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|