import {
useCallback,
useEffect,
useMemo,
useRef,
useState,
type FormEvent,
type ReactNode,
} from "react";
import {
Download,
ImagePlus,
Share2,
} from "lucide-react";
import { useParams } from "react-router-dom";
import { AuthShell } from "../components/auth/AuthShell";
import { PhotoWallCropModal } from "../components/PhotoWallCropModal";
import { PhotoWallEventHeader } from "../components/PhotoWallEventHeader";
import { SharePlatformIcon } from "../components/SharePlatformIcon";
import {
getPhotoWallUploadMeta,
getVisitorLoginPreview,
uploadPhotoWallSelfie,
type PhotoWallSharePlatforms,
type PhotoWallPublicFormField,
type PhotoWallUploadMeta,
} from "../lib/api";
import { getAttendeeParticipant, getAttendeeToken } from "../lib/attendeeAuth";
import { getImageNaturalSize, ratiosMatch } from "../lib/cropImage";
import {
canUseNativeShareSheet,
downloadImageFromUrl,
fetchImageFile,
ensureShareableImageFile,
sharePhotoWallImage,
shareViaNativeSheet,
} from "../lib/photoWallShare";
import {
EB_VISITOR_CONTEXT_KEY,
setPostAuthReturnPath,
type EbVisitorContext,
} from "../types/visitorContext";
function fieldKey(f: PhotoWallPublicFormField): string {
return String(f.name || f.label || "").trim();
}
function WhiteCard({ children }: { children: ReactNode }) {
return (
);
}
type CropSession = {
imageSrc: string;
sourceFileName: string;
};
type UploadDoneState = {
awaitingApproval: boolean;
previewUrl: string | null;
/** CORS-safe API URL for download/share */
fileUrl: string | null;
framed: boolean;
};
const SHARE_LABELS: Array<{ key: keyof PhotoWallSharePlatforms; label: string }> = [
{ key: "linkedin", label: "LinkedIn" },
{ key: "facebook", label: "Facebook" },
{ key: "instagram", label: "Instagram" },
{ key: "snapchat", label: "Snapchat" },
{ key: "x", label: "X" },
{ key: "whatsapp", label: "WhatsApp" },
];
export function PhotoWallUploadPage() {
const { uploadToken } = useParams<{ uploadToken: string }>();
const fileRef = useRef(null);
const resultImgRef = useRef(null);
const [meta, setMeta] = useState(null);
const [loading, setLoading] = useState(true);
const [err, setErr] = useState(null);
const [file, setFile] = useState(null);
const [previewUrl, setPreviewUrl] = useState(null);
const [values, setValues] = useState>({});
const [uploaderName, setUploaderName] = useState("");
const [submitting, setSubmitting] = useState(false);
const [picking, setPicking] = useState(false);
const [done, setDone] = useState(null);
const [actionMsg, setActionMsg] = useState(null);
const [cropSession, setCropSession] = useState(null);
/** Cached bytes for share/download — avoids CORS/network fail when opening the OS sheet. */
const [shareFile, setShareFile] = useState(null);
const load = useCallback(async () => {
if (!uploadToken) return;
setLoading(true);
setErr(null);
try {
const data = await getPhotoWallUploadMeta(uploadToken);
setMeta(data);
} catch (e) {
setMeta(null);
setErr(e instanceof Error ? e.message : "Photo wall not available");
} finally {
setLoading(false);
}
}, [uploadToken]);
useEffect(() => {
void load();
}, [load]);
useEffect(() => {
return () => {
if (previewUrl) URL.revokeObjectURL(previewUrl);
};
}, [previewUrl]);
useEffect(() => {
return () => {
if (cropSession?.imageSrc) URL.revokeObjectURL(cropSession.imageSrc);
};
}, [cropSession]);
const fields = useMemo(
() => (meta?.form?.fields || []).filter((f) => f.label?.trim() && f.type !== "section"),
[meta],
);
const loggedIn = Boolean(getAttendeeToken() && getAttendeeParticipant());
const enabledSharePlatforms = useMemo(
() => SHARE_LABELS.filter((item) => Boolean(meta?.sharePlatforms?.[item.key])),
[meta?.sharePlatforms],
);
const useNativeShare = useMemo(() => canUseNativeShareSheet(), []);
const clearSelectedPhoto = useCallback(() => {
setFile(null);
setPreviewUrl((prev) => {
if (prev) URL.revokeObjectURL(prev);
return null;
});
if (fileRef.current) fileRef.current.value = "";
}, []);
useEffect(() => {
if (!done?.fileUrl) return;
let cancelled = false;
void fetchImageFile(done.fileUrl, "photo-wall.jpg")
.then((f) => {
if (!cancelled) setShareFile(f);
})
.catch(() => {
/* keep any local shareFile from upload */
});
return () => {
cancelled = true;
};
}, [done?.fileUrl]);
const resetForNewUpload = useCallback(() => {
setDone(null);
setShareFile(null);
setActionMsg(null);
setErr(null);
clearSelectedPhoto();
}, [clearSelectedPhoto]);
const applySelectedFile = useCallback((next: File) => {
setFile(next);
setPreviewUrl((prev) => {
if (prev) URL.revokeObjectURL(prev);
return URL.createObjectURL(next);
});
}, []);
const handleFileChange = async (selected: File | null) => {
if (!selected) {
clearSelectedPhoto();
return;
}
if (!selected.type.startsWith("image/")) {
setErr("Please choose an image file");
return;
}
setErr(null);
setPicking(true);
try {
const requireCrop = Boolean(meta?.requireUploadCrop);
if (!requireCrop) {
applySelectedFile(selected);
return;
}
const maxW = Math.max(1, Number(meta?.maxPhotoWidth) || 1080);
const maxH = Math.max(1, Number(meta?.maxPhotoHeight) || 1080);
const natural = await getImageNaturalSize(selected);
if (ratiosMatch(natural.width, natural.height, maxW, maxH)) {
applySelectedFile(selected);
return;
}
const src = URL.createObjectURL(selected);
setCropSession({ imageSrc: src, sourceFileName: selected.name || "photo.jpg" });
} catch (e) {
setErr(e instanceof Error ? e.message : "Could not process image");
} finally {
setPicking(false);
if (fileRef.current) fileRef.current.value = "";
}
};
const onSubmit = async (e: FormEvent) => {
e.preventDefault();
if (!uploadToken || !file || !meta) return;
if (meta.askUploaderName && !uploaderName.trim()) {
setErr("Please enter your name");
return;
}
setSubmitting(true);
setErr(null);
setActionMsg(null);
try {
const localFile = file;
const result = await uploadPhotoWallSelfie(uploadToken, file, values, {
displayName: meta.askUploaderName ? uploaderName.trim() : undefined,
});
// Keep local bytes immediately so Share works even if the API file proxy fails (CORS/network).
setShareFile(localFile);
setDone({
awaitingApproval: result.awaitingApproval,
previewUrl: result.previewUrl,
fileUrl: result.fileUrl,
framed: result.framed,
});
clearSelectedPhoto();
// Normalize HEIC/etc. in the background so iOS accepts the file in the OS share sheet.
void ensureShareableImageFile(localFile, "photo-wall.jpg").then((shareable) => {
setShareFile((prev) => {
// Don't clobber a framed file already prefetched from the API.
if (prev && prev !== localFile) return prev;
return shareable;
});
});
} catch (e2) {
setErr(e2 instanceof Error ? e2.message : "Upload failed");
} finally {
setSubmitting(false);
}
};
const onDownload = async () => {
setActionMsg(null);
try {
if (shareFile) {
const objectUrl = URL.createObjectURL(shareFile);
const a = document.createElement("a");
a.href = objectUrl;
a.download = shareFile.name || "photo-wall.jpg";
document.body.appendChild(a);
a.click();
a.remove();
URL.revokeObjectURL(objectUrl);
setActionMsg("Download started");
return;
}
const src = done?.fileUrl || done?.previewUrl;
if (!src) return;
await downloadImageFromUrl(src, "photo-wall.jpg");
setActionMsg("Download started");
} catch (e) {
setActionMsg(e instanceof Error ? e.message : "Download failed");
}
};
const shareCaption = `Check out my photo from ${meta?.eventName || "the event"}!`;
const onNativeShare = async () => {
const src = done?.fileUrl || done?.previewUrl;
setActionMsg(null);
try {
// Pass cached File first so share runs without a prior network await (iOS gesture).
const result = await shareViaNativeSheet({
file: shareFile,
imageUrl: src,
imgEl: resultImgRef.current,
title: meta?.eventName || "Photo wall",
text: shareCaption,
});
if (result === "shared") setActionMsg("Shared");
else setActionMsg(null);
} catch (e) {
const msg = e instanceof Error ? e.message : "Share failed";
setActionMsg(
/load failed|failed to fetch/i.test(msg)
? "Could not share this photo. Try Download, then share from your photos."
: msg,
);
}
};
const onShare = async (platform: keyof PhotoWallSharePlatforms) => {
const src = done?.fileUrl || done?.previewUrl;
if (!src && !shareFile) return;
setActionMsg(null);
try {
const result = await sharePhotoWallImage({
platform,
imageUrl: src || "",
file: shareFile,
title: meta?.eventName || "Photo wall",
text: shareCaption,
});
if (result === "shared") {
setActionMsg(
platform === "whatsapp" || platform === "facebook" || platform === "x" || platform === "linkedin"
? "Opening app with your photo link"
: "Shared",
);
} else if (result === "cancelled") {
setActionMsg(null);
} else {
setActionMsg("Photo saved and caption copied — paste / attach it in the app");
}
} catch (e) {
setActionMsg(e instanceof Error ? e.message : "Share failed");
}
};
const goLogin = async () => {
if (!meta?.eventId) return;
try {
const preview = await getVisitorLoginPreview(meta.eventId);
const ctx: EbVisitorContext = {
type: "event",
eventId: meta.eventId,
event: preview,
fetchedAt: new Date().toISOString(),
};
localStorage.setItem(EB_VISITOR_CONTEXT_KEY, JSON.stringify(ctx));
setPostAuthReturnPath(`/photo-wall/${uploadToken}`);
window.location.assign(`/login?type=event&id=${encodeURIComponent(meta.eventId)}`);
} catch {
setErr("Could not open login");
}
};
if (!uploadToken) {
return (
Missing upload link
);
}
if (loading) {
return (
Loading…
);
}
if (err && !meta) {
return (
{err}
);
}
if (done) {
const canDownload = Boolean(meta?.allowFramedDownload && (done.fileUrl || done.previewUrl));
const showShare = Boolean((done.fileUrl || done.previewUrl) && enabledSharePlatforms.length > 0);
return (
{meta?.event ? (
) : null}
{done.framed ? "Your framed photo" : "Photo uploaded"}
{done.awaitingApproval
? "Thanks! Your photo is waiting for approval."
: done.framed
? "Here’s your generated photo. You can edit, download, or share it."
: "Thanks! Your photo was added to the wall."}
{(done.fileUrl || done.previewUrl) ? (
{
const img = e.currentTarget;
// Retry without CORS mode if CDN blocks anonymous reads.
if (img.crossOrigin) {
img.crossOrigin = null;
img.src = done.previewUrl || done.fileUrl || img.src;
return;
}
if (done.fileUrl && img.src !== done.fileUrl) {
img.src = done.fileUrl;
}
}}
/>
) : null}
Edit / Upload new photo
{canDownload ? (
void onDownload()}
className="inline-flex h-11 w-full items-center justify-center gap-2 rounded-lg border border-[#ddd] bg-white px-4 text-sm font-semibold text-[#333] hover:bg-[#fafafa]"
>
Download photo
) : null}
{showShare ? (
Share
{useNativeShare ? (
void onNativeShare()}
className="inline-flex h-11 w-full items-center justify-center gap-2 rounded-lg border border-[#ddd] bg-white px-4 text-sm font-semibold text-[#333] hover:bg-[#fafafa]"
>
Share
) : (
{enabledSharePlatforms.map((item) => (
void onShare(item.key)}
className="inline-flex h-10 items-center justify-center gap-2 rounded-lg border border-[#ddd] bg-white px-3 text-xs font-semibold text-[#333] hover:bg-[#fafafa]"
>
{item.label}
))}
)}
) : null}
{actionMsg ? {actionMsg}
: null}
);
}
return (
{meta?.event ? (
) : null}
Upload your photo
{meta?.form?.description || "Add a selfie to the event photo wall."}
{meta?.accessMode === "login" && !loggedIn ? (
Sign in to upload your photo for this event.
void goLogin()}
className="inline-flex h-11 w-full items-center justify-center rounded-lg bg-[var(--theme-button-color)] px-4 text-sm font-semibold text-white hover:bg-[#b71c1c]"
>
Sign in to continue
) : (
)}
{cropSession && meta ? (
{
URL.revokeObjectURL(cropSession.imageSrc);
setCropSession(null);
}}
onConfirm={(cropped) => {
URL.revokeObjectURL(cropSession.imageSrc);
setCropSession(null);
applySelectedFile(cropped);
}}
/>
) : null}
);
}