EventBuzz_Website/src/pages/PhotoWallUploadPage.tsx

626 lines
22 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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 (
<div className="flex w-full max-w-[354px] flex-col items-center">
<div className="w-full rounded-xl shadow-[0_8px_12px_6px_rgba(0,0,0,0.15),0_4px_4px_rgba(0,0,0,0.3)]">
<div className="rounded-xl card-bg text-[var(--theme-card-text-color)] shadow-md px-6 py-6">{children}</div>
</div>
</div>
);
}
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<HTMLInputElement>(null);
const resultImgRef = useRef<HTMLImageElement>(null);
const [meta, setMeta] = useState<PhotoWallUploadMeta | null>(null);
const [loading, setLoading] = useState(true);
const [err, setErr] = useState<string | null>(null);
const [file, setFile] = useState<File | null>(null);
const [previewUrl, setPreviewUrl] = useState<string | null>(null);
const [values, setValues] = useState<Record<string, unknown>>({});
const [uploaderName, setUploaderName] = useState("");
const [submitting, setSubmitting] = useState(false);
const [picking, setPicking] = useState(false);
const [done, setDone] = useState<UploadDoneState | null>(null);
const [actionMsg, setActionMsg] = useState<string | null>(null);
const [cropSession, setCropSession] = useState<CropSession | null>(null);
/** Cached bytes for share/download — avoids CORS/network fail when opening the OS sheet. */
const [shareFile, setShareFile] = useState<File | null>(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 (
<AuthShell showLogo={false}>
<WhiteCard>
<p className="text-center text-sm text-red-600">Missing upload link</p>
</WhiteCard>
</AuthShell>
);
}
if (loading) {
return (
<AuthShell showLogo={false}>
<WhiteCard>
<p className="text-center text-sm text-[var(--theme-card-text-color)]/70">Loading…</p>
</WhiteCard>
</AuthShell>
);
}
if (err && !meta) {
return (
<AuthShell showLogo={false}>
<WhiteCard>
<p className="text-center text-sm text-red-600">{err}</p>
</WhiteCard>
</AuthShell>
);
}
if (done) {
const canDownload = Boolean(meta?.allowFramedDownload && (done.fileUrl || done.previewUrl));
const showShare = Boolean((done.fileUrl || done.previewUrl) && enabledSharePlatforms.length > 0);
return (
<AuthShell showLogo={false}>
<WhiteCard>
{meta?.event ? (
<div className="mb-4">
<PhotoWallEventHeader event={meta.event} display={meta.eventHeader || null} />
</div>
) : null}
<h1 className="mb-2 text-center text-[22px] font-semibold text-[var(--theme-card-text-color)]">
{done.framed ? "Your framed photo" : "Photo uploaded"}
</h1>
<p className="mb-4 text-center text-sm text-[var(--theme-card-text-color)]/80">
{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."}
</p>
{(done.fileUrl || done.previewUrl) ? (
<div className="mb-4 overflow-hidden rounded-lg border border-[#e5e5e5] bg-[#f8f8f8]">
<img
ref={resultImgRef}
crossOrigin="anonymous"
src={done.previewUrl || done.fileUrl || undefined}
alt={done.framed ? "Your framed photo" : "Uploaded photo"}
className="mx-auto max-h-[360px] w-full object-contain"
onError={(e) => {
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;
}
}}
/>
</div>
) : null}
<div className="flex flex-col gap-2">
<button
type="button"
onClick={resetForNewUpload}
className="inline-flex h-11 w-full items-center justify-center gap-2 rounded-lg bg-[#D32F2F theme-btn-bg px-4 text-sm font-semibold text-white hover:bg-[#b71c1c]"
>
<ImagePlus className="h-4 w-4" />
Edit / Upload new photo
</button>
{canDownload ? (
<button
type="button"
onClick={() => 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 className="h-4 w-4" />
Download photo
</button>
) : null}
</div>
{showShare ? (
<div className="mt-4">
<p className="mb-2 text-center text-xs font-semibold uppercase tracking-wide text-[var(--theme-card-text-color)]/80">
Share
</p>
{useNativeShare ? (
<button
type="button"
onClick={() => 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]"
>
<Share2 className="h-4 w-4" />
Share
</button>
) : (
<div className="grid grid-cols-2 gap-2">
{enabledSharePlatforms.map((item) => (
<button
key={item.key}
type="button"
onClick={() => 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]"
>
<SharePlatformIcon platform={item.key} />
{item.label}
</button>
))}
</div>
)}
</div>
) : null}
{actionMsg ? <p className="mt-3 text-center text-xs text-[#666]">{actionMsg}</p> : null}
</WhiteCard>
</AuthShell>
);
}
return (
<AuthShell showLogo={false}>
<WhiteCard>
{meta?.event ? (
<div className="mb-4">
<PhotoWallEventHeader event={meta.event} display={meta.eventHeader || null} />
</div>
) : null}
<h1 className="mb-1 text-center text-[22px] font-semibold text-[var(--theme-card-text-color)]">Upload your photo</h1>
<p className="mb-5 text-center text-sm text-[var(--theme-card-text-color)]/80">
{meta?.form?.description || "Add a selfie to the event photo wall."}
</p>
{meta?.accessMode === "login" && !loggedIn ? (
<div className="space-y-3">
<p className="text-center text-sm text-[var(--theme-card-text-color)]/80">Sign in to upload your photo for this event.</p>
<button
type="button"
onClick={() => 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
</button>
</div>
) : (
<form onSubmit={(e) => void onSubmit(e)} className="space-y-4">
<div>
<input
ref={fileRef}
type="file"
accept="image/*"
capture="user"
className="hidden"
onChange={(e) => void handleFileChange(e.target.files?.[0] || null)}
/>
<button
type="button"
disabled={picking}
onClick={() => fileRef.current?.click()}
className="flex w-full flex-col items-center justify-center gap-2 rounded-lg border-2 border-dashed border-[#ccc] bg-[#fafafa] px-4 py-8 text-sm text-[#666] hover:border-[#D32F2F] hover:bg-[#fff5f5]"
>
{previewUrl ? (
<img src={previewUrl} alt="Preview" className="max-h-48 rounded-md object-contain" />
) : (
<>
<ImagePlus className="h-8 w-8 text-[#D32F2F]" />
<span>{picking ? "Preparing…" : "Tap to take or choose a photo"}</span>
</>
)}
</button>
{file ? (
<p className="mt-2 truncate text-center text-xs text-[var(--theme-card-text-color)]/80">{file.name}</p>
) : null}
</div>
{meta?.askUploaderName ? (
<label className="block text-sm">
<span className="mb-1 block font-medium text-[var(--theme-card-text-color)]">
Your name <span className="text-red-500">*</span>
</span>
<input
type="text"
value={uploaderName}
onChange={(e) => setUploaderName(e.target.value)}
className="h-10 w-full rounded-lg border border-[rgba(116,112,112,0.3)] px-3 text-sm transition-colors duration-300 ease-in-out focus:border-[var(--theme-button-color)] focus:outline-none focus:ring-2 focus:ring-[var(--theme-button-color)]/30"
placeholder="Name shown on the wall"
required
/>
</label>
) : null}
{fields.map((f) => {
const key = fieldKey(f);
if (!key) return null;
const required = Boolean(f.required);
if (f.type === "textarea") {
return (
<label key={key} className="block text-sm">
<span className="mb-1 block font-medium text-[var(--theme-card-text-color)]">
{f.label}
{required ? <span className="text-red-500"> *</span> : null}
</span>
<textarea
value={String(values[key] ?? "")}
onChange={(e) => setValues((v) => ({ ...v, [key]: e.target.value }))}
required={required}
rows={3}
className="w-full rounded-lg border border-[rgba(116,112,112,0.3)] px-3 py-2 text-sm transition-colors duration-300 ease-in-out focus:border-[var(--theme-button-color)] focus:outline-none focus:ring-2 focus:ring-[var(--theme-button-color)]/30"
/>
</label>
);
}
return (
<label key={key} className="block text-sm">
<span className="mb-1 block font-medium text-[var(--theme-card-text-color)]">
{f.label}
{required ? <span className="text-red-500"> *</span> : null}
</span>
<input
type={f.type === "email" ? "email" : f.type === "tel" ? "tel" : "text"}
value={String(values[key] ?? "")}
onChange={(e) => setValues((v) => ({ ...v, [key]: e.target.value }))}
required={required}
className="h-10 w-full rounded-lg border border-[rgba(116,112,112,0.3)] px-3 text-sm transition-colors duration-300 ease-in-out focus:border-[var(--theme-button-color)] focus:outline-none focus:ring-2 focus:ring-[var(--theme-button-color)]/30"
/>
</label>
);
})}
{err ? <p className="text-center text-sm text-red-600">{err}</p> : null}
<button
type="submit"
disabled={!file || submitting || picking}
className="inline-flex h-11 w-full items-center justify-center rounded-lg bg-[#D32F2F theme-btn-bg px-4 text-sm font-semibold text-white hover:bg-[#b71c1c] disabled:opacity-50"
>
{submitting ? "Uploading…" : "Upload"}
</button>
</form>
)}
</WhiteCard>
{cropSession && meta ? (
<PhotoWallCropModal
imageSrc={cropSession.imageSrc}
sourceFileName={cropSession.sourceFileName}
aspect={
Math.max(1, Number(meta.maxPhotoWidth) || 1080) /
Math.max(1, Number(meta.maxPhotoHeight) || 1080)
}
onCancel={() => {
URL.revokeObjectURL(cropSession.imageSrc);
setCropSession(null);
}}
onConfirm={(cropped) => {
URL.revokeObjectURL(cropSession.imageSrc);
setCropSession(null);
applySelectedFile(cropped);
}}
/>
) : null}
</AuthShell>
);
}