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.
 
 
 
 
 
 

324 lines
10 KiB

"use client";
import Image from "next/image";
import { type ReactNode, useCallback, useEffect, useId, useRef, useState } from "react";
import type { QuestionField } from "@/lib/schema-adapter";
import { useUploadTmpMediaMutation } from "@/hooks/marriage/use-upload-tmp-media";
import { getApiRequestUrl } from "@/lib/http";
import { isInFlutterWebView, uploadFile } from "@/lib/webview-actions";
import { useQuestionAnswers } from "./question-answer-storage";
import QuestionTitle from "./question-title";
import { LoadingSkeleton } from "./loading-skeleton";
type QuestionPhotoProps = {
question: QuestionField;
description?: ReactNode;
disabled?: boolean;
};
export function QuestionPhoto({
question,
description,
disabled,
}: QuestionPhotoProps) {
const inputId = useId();
const [localPreviewUrl, setLocalPreviewUrl] = useState<string | null>(null);
const [isUploading, setIsUploading] = useState(false);
const acceptedFiles =
question.extras?.options && question.extras.options.length > 0
? question.extras.options.join(",")
: "image/*";
const descriptionContent = description ?? question.description;
const { getAnswerValue, setAnswerValue, registerPendingUpload } =
useQuestionAnswers();
const storedValue = getAnswerValue(question);
const isInitiatorRef = useRef(false);
const pendingDeferredRef = useRef<{
resolve: (val?: any) => void;
reject: (err?: any) => void;
} | null>(null);
const uploadTmpMediaMutation = useUploadTmpMediaMutation({
onSuccess: (response) => {
if (response.path) {
setAnswerValue(question, response.path);
setLocalPreviewUrl(response.path);
}
setIsUploading(false);
},
onError: (error) => {
console.error("Photo upload error:", error);
setIsUploading(false);
},
});
const isPending = uploadTmpMediaMutation.isPending || isUploading;
// Listen for upload_file responses from Flutter WebView if active
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":
case "progress":
setIsUploading(true);
if (event.data?.files?.[0]?.base64) {
const b64 = event.data.files[0].base64;
setLocalPreviewUrl(b64);
}
break;
case "completed": {
setIsUploading(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);
setLocalPreviewUrl(remoteUrl);
pendingDeferredRef.current?.resolve(remoteUrl);
pendingDeferredRef.current = null;
} else if (file?.base64) {
const b64 = file.base64;
setLocalPreviewUrl(b64);
const uploadPromise = fetch(b64)
.then((res) => res.blob())
.then((blob) => {
const f = new File([blob], file.name ?? "photo.jpg", {
type: blob.type || "image/jpeg",
});
return uploadTmpMediaMutation.mutateAsync(f);
})
.then((res) => {
if (res?.path) {
setAnswerValue(question, res.path);
setLocalPreviewUrl(res.path);
pendingDeferredRef.current?.resolve(res.path);
} else {
pendingDeferredRef.current?.resolve();
}
pendingDeferredRef.current = null;
})
.catch((err) => {
pendingDeferredRef.current?.reject(err);
pendingDeferredRef.current = null;
});
registerPendingUpload?.(question.id, uploadPromise);
} else {
pendingDeferredRef.current?.resolve();
pendingDeferredRef.current = null;
}
break;
}
case "cancelled":
case "failed":
setIsUploading(false);
isInitiatorRef.current = false;
pendingDeferredRef.current?.reject(new Error("Upload failed or cancelled"));
pendingDeferredRef.current = null;
break;
}
});
return () => {
unsubscribe?.();
};
}, [question, setAnswerValue, registerPendingUpload, uploadTmpMediaMutation]);
const handleFlutterPick = useCallback(() => {
const extensions = (question.extras?.options ?? []).map((o) =>
o.replace(/^\./, "").toLowerCase(),
);
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 ||
"gallery";
const pickerType =
question.ui_config?.picker_type ||
question.ui_config?.source ||
"gallery";
uploadFile({
requestId: String(question.id),
mediaType: "image",
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.length > 0 ? extensions : ["jpg", "jpeg", "png", "webp"],
maxBytes: 10_485_760, // 10 MB
title:
typeof question.title === "string" ? question.title : "Upload Photo",
});
}, [question, registerPendingUpload]);
const handleBrowserFileChange = (files: FileList | null) => {
const file = files?.[0];
if (!file) return;
// Create synchronous object URL for instant UI preview only
const objectUrl = URL.createObjectURL(file);
setLocalPreviewUrl(objectUrl);
// Trigger background upload and register promise with central storage
setIsUploading(true);
const uploadPromise = uploadTmpMediaMutation
.mutateAsync(file)
.then((res) => {
if (res?.path) {
setAnswerValue(question, res.path);
setLocalPreviewUrl(res.path);
}
setIsUploading(false);
})
.catch((err) => {
console.error("Photo upload error:", err);
setIsUploading(false);
});
registerPendingUpload?.(question.id, uploadPromise);
};
const handleLabelClick = (e: React.MouseEvent) => {
if (isInFlutterWebView()) {
e.preventDefault();
handleFlutterPick();
}
};
// Determine photo URL to display
let displayUrl: string | null = localPreviewUrl;
if (
!displayUrl &&
typeof storedValue === "string" &&
storedValue.trim().length > 0
) {
const val = storedValue.trim();
if (
val.startsWith("http://") ||
val.startsWith("https://") ||
val.startsWith("blob:") ||
val.startsWith("data:")
) {
displayUrl = val;
} else {
displayUrl = getApiRequestUrl(val);
}
}
const hasAnswer = Boolean(displayUrl);
return (
<div
data-question-answered={hasAnswer ? "true" : "false"}
className={[
"flex w-full flex-col items-center text-center transition-opacity duration-200 gap-4",
disabled ? "pointer-events-none opacity-30" : "",
].join(" ")}
>
<QuestionTitle
question={{ ...question, description: "" }}
className="justify-center text-center"
/>
<input
id={inputId}
type="file"
accept={acceptedFiles}
onChange={(event) => handleBrowserFileChange(event.target.files)}
disabled={disabled}
className="sr-only"
/>
<label
htmlFor={inputId}
onClick={handleLabelClick}
className="flex w-full cursor-pointer flex-col items-center"
>
<span className="flex w-full flex-col items-center">
<div className="relative flex h-[92px] w-[86px] items-center justify-center">
{displayUrl ? (
<img
src={displayUrl}
alt={typeof question.title === "string" ? question.title : ""}
className="h-[86px] w-[86px] rounded-full object-cover border border-[#D7DBE2]"
/>
) : (
<Image
src="/assets/images/Frame 2095586679.svg"
alt=""
aria-hidden="true"
width={86}
height={92}
className="h-[92px] w-[86px]"
/>
)}
{/* Red camera badge when image is uploaded */}
{displayUrl && (
<span className="absolute bottom-[2px] right-0 flex h-7 w-7 items-center justify-center rounded-full bg-[#F0445B]">
<svg
width="14"
height="14"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
className="text-white"
>
<path d="M14.5 4h-5L7 7H4a2 2 0 0 0-2 2v9a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2V9a2 2 0 0 0-2-2h-3l-2.5-3z" />
<circle cx="12" cy="13" r="3" />
</svg>
</span>
)}
{/* Loading spinner during upload matching Flutter ProfileAvatarWidget */}
{isPending && (
<div className="absolute inset-0 flex items-center justify-center rounded-full bg-black/40 backdrop-blur-[1px] z-10 transition-all duration-200">
<div className="h-7 w-7 animate-spin rounded-full border-[2.5px] border-white border-t-transparent shadow-sm" />
</div>
)}
</div>
{descriptionContent ? (
<span className="mt-4 block max-w-[350px] group-10 leading-[1.35] font-semibold text-[#D44747]">
{descriptionContent}
</span>
) : null}
</span>
</label>
</div>
);
}
export default QuestionPhoto;