473 lines
15 KiB
TypeScript
473 lines
15 KiB
TypeScript
import {
|
|
useCallback,
|
|
useEffect,
|
|
useMemo,
|
|
useRef,
|
|
useState,
|
|
type ReactNode,
|
|
} from "react";
|
|
import {
|
|
Download,
|
|
Facebook,
|
|
Instagram,
|
|
Linkedin,
|
|
Search,
|
|
Share2,
|
|
} from "lucide-react";
|
|
import { useParams } from "react-router-dom";
|
|
import { AuthShell } from "../components/auth/AuthShell";
|
|
import { PhotoWallEventHeader } from "../components/PhotoWallEventHeader";
|
|
import {
|
|
getPhotoSearchMeta,
|
|
getVisitorLoginPreview,
|
|
searchPhotoSearchMatches,
|
|
type PhotoSearchMatch,
|
|
type PhotoSearchMeta,
|
|
type PhotoWallSharePlatforms,
|
|
} from "../lib/api";
|
|
import { getAttendeeParticipant, getAttendeeToken } from "../lib/attendeeAuth";
|
|
import {
|
|
canUseNativeShareSheet,
|
|
downloadImageFromUrl,
|
|
fetchImageFile,
|
|
sharePhotoWallImage,
|
|
shareViaNativeSheet,
|
|
} from "../lib/photoWallShare";
|
|
import {
|
|
EB_VISITOR_CONTEXT_KEY,
|
|
setPostAuthReturnPath,
|
|
type EbVisitorContext,
|
|
} from "../types/visitorContext";
|
|
|
|
function WhiteCard({ children, wide }: { children: ReactNode; wide?: boolean }) {
|
|
return (
|
|
<div className={`flex w-full ${wide ? "max-w-[480px]" : "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 bg-white px-6 py-6">{children}</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
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" },
|
|
];
|
|
|
|
function ShareIcon({ platform }: { platform: keyof PhotoWallSharePlatforms }) {
|
|
if (platform === "linkedin") return <Linkedin className="h-4 w-4" />;
|
|
if (platform === "facebook") return <Facebook className="h-4 w-4" />;
|
|
if (platform === "instagram") return <Instagram className="h-4 w-4" />;
|
|
return <Share2 className="h-4 w-4" />;
|
|
}
|
|
|
|
function MatchCard({
|
|
match,
|
|
eventName,
|
|
allowDownload,
|
|
showShare,
|
|
useNativeShare,
|
|
enabledSharePlatforms,
|
|
}: {
|
|
match: PhotoSearchMatch;
|
|
eventName: string;
|
|
allowDownload: boolean;
|
|
showShare: boolean;
|
|
useNativeShare: boolean;
|
|
enabledSharePlatforms: Array<{ key: keyof PhotoWallSharePlatforms; label: string }>;
|
|
}) {
|
|
const imgRef = useRef<HTMLImageElement>(null);
|
|
const [shareFile, setShareFile] = useState<File | null>(null);
|
|
const [actionMsg, setActionMsg] = useState<string | null>(null);
|
|
const caption = `Check out my photo from ${eventName}!`;
|
|
/** Grid preview uses CDN thumb (`url`) only — never the API file proxy. */
|
|
const previewSrc = match.url || undefined;
|
|
const downloadSrc = match.fileUrl || match.url || undefined;
|
|
|
|
useEffect(() => {
|
|
if (!match.fileUrl) return;
|
|
let cancelled = false;
|
|
void fetchImageFile(match.fileUrl, match.originalFileName || "event-photo.jpg")
|
|
.then((f) => {
|
|
if (!cancelled) setShareFile(f);
|
|
})
|
|
.catch(() => {
|
|
/* ignore — share can still try URL / canvas */
|
|
});
|
|
return () => {
|
|
cancelled = true;
|
|
};
|
|
}, [match.fileUrl, match.originalFileName]);
|
|
|
|
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 || "event-photo.jpg";
|
|
document.body.appendChild(a);
|
|
a.click();
|
|
a.remove();
|
|
URL.revokeObjectURL(objectUrl);
|
|
setActionMsg("Download started");
|
|
return;
|
|
}
|
|
if (!downloadSrc) return;
|
|
await downloadImageFromUrl(downloadSrc, match.originalFileName || "event-photo.jpg");
|
|
setActionMsg("Download started");
|
|
} catch (e) {
|
|
setActionMsg(e instanceof Error ? e.message : "Download failed");
|
|
}
|
|
};
|
|
|
|
const onNativeShare = async () => {
|
|
setActionMsg(null);
|
|
try {
|
|
const result = await shareViaNativeSheet({
|
|
file: shareFile,
|
|
imageUrl: downloadSrc,
|
|
imgEl: imgRef.current,
|
|
title: eventName,
|
|
text: caption,
|
|
});
|
|
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) => {
|
|
if (!downloadSrc && !shareFile) return;
|
|
setActionMsg(null);
|
|
try {
|
|
const result = await sharePhotoWallImage({
|
|
platform,
|
|
imageUrl: downloadSrc || "",
|
|
file: shareFile,
|
|
title: eventName,
|
|
text: caption,
|
|
});
|
|
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");
|
|
}
|
|
};
|
|
|
|
return (
|
|
<div className="overflow-hidden rounded-lg border border-[#e5e5e5] bg-[#f8f8f8]">
|
|
<img
|
|
ref={imgRef}
|
|
crossOrigin="anonymous"
|
|
src={previewSrc || undefined}
|
|
alt={match.originalFileName || "Matched photo"}
|
|
className="mx-auto max-h-[220px] w-full object-contain bg-white"
|
|
onError={(e) => {
|
|
const img = e.currentTarget;
|
|
// Retry without CORS for CDN; do not switch to API file proxy for display.
|
|
if (img.crossOrigin) {
|
|
img.crossOrigin = null;
|
|
if (previewSrc) img.src = previewSrc;
|
|
}
|
|
}}
|
|
/>
|
|
<div className="space-y-2 p-3">
|
|
{allowDownload ? (
|
|
<button
|
|
type="button"
|
|
onClick={() => void onDownload()}
|
|
className="inline-flex h-9 w-full items-center justify-center gap-2 rounded-lg border border-[#ddd] bg-white px-3 text-xs font-semibold text-[#333] hover:bg-[#fafafa]"
|
|
>
|
|
<Download className="h-3.5 w-3.5" />
|
|
Download
|
|
</button>
|
|
) : null}
|
|
{showShare ? (
|
|
useNativeShare ? (
|
|
<button
|
|
type="button"
|
|
onClick={() => void onNativeShare()}
|
|
className="inline-flex h-9 w-full items-center justify-center gap-2 rounded-lg border border-[#ddd] bg-white px-3 text-xs font-semibold text-[#333] hover:bg-[#fafafa]"
|
|
>
|
|
<Share2 className="h-3.5 w-3.5" />
|
|
Share
|
|
</button>
|
|
) : (
|
|
<div className="grid grid-cols-2 gap-1.5">
|
|
{enabledSharePlatforms.map((item) => (
|
|
<button
|
|
key={item.key}
|
|
type="button"
|
|
onClick={() => void onShare(item.key)}
|
|
className="inline-flex h-8 items-center justify-center gap-1 rounded-lg border border-[#ddd] bg-white px-2 text-[11px] font-semibold text-[#333] hover:bg-[#fafafa]"
|
|
>
|
|
<ShareIcon platform={item.key} />
|
|
{item.label}
|
|
</button>
|
|
))}
|
|
</div>
|
|
)
|
|
) : null}
|
|
{actionMsg ? <p className="text-center text-[11px] text-[#666]">{actionMsg}</p> : null}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
export function PhotoSearchPage() {
|
|
const { searchToken } = useParams<{ searchToken: string }>();
|
|
const fileRef = useRef<HTMLInputElement>(null);
|
|
const [meta, setMeta] = useState<PhotoSearchMeta | 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 [searching, setSearching] = useState(false);
|
|
const [matches, setMatches] = useState<PhotoSearchMatch[] | null>(null);
|
|
const [searched, setSearched] = useState(false);
|
|
|
|
const load = useCallback(async () => {
|
|
if (!searchToken) return;
|
|
setLoading(true);
|
|
setErr(null);
|
|
try {
|
|
const data = await getPhotoSearchMeta(searchToken);
|
|
setMeta(data);
|
|
} catch (e) {
|
|
setMeta(null);
|
|
setErr(e instanceof Error ? e.message : "Photo search not available");
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
}, [searchToken]);
|
|
|
|
useEffect(() => {
|
|
void load();
|
|
}, [load]);
|
|
|
|
useEffect(() => {
|
|
return () => {
|
|
if (previewUrl) URL.revokeObjectURL(previewUrl);
|
|
};
|
|
}, [previewUrl]);
|
|
|
|
const loggedIn = Boolean(getAttendeeToken() && getAttendeeParticipant());
|
|
const enabledSharePlatforms = useMemo(
|
|
() => SHARE_LABELS.filter((item) => Boolean(meta?.sharePlatforms?.[item.key])),
|
|
[meta?.sharePlatforms],
|
|
);
|
|
const useNativeShare = useMemo(() => canUseNativeShareSheet(), []);
|
|
const showShare = enabledSharePlatforms.length > 0;
|
|
const allowDownload = meta?.allowDownload !== false;
|
|
|
|
const clearFile = () => {
|
|
setFile(null);
|
|
setPreviewUrl((prev) => {
|
|
if (prev) URL.revokeObjectURL(prev);
|
|
return null;
|
|
});
|
|
if (fileRef.current) fileRef.current.value = "";
|
|
};
|
|
|
|
const onPick = (selected: File | null) => {
|
|
if (!selected) {
|
|
clearFile();
|
|
return;
|
|
}
|
|
if (!selected.type.startsWith("image/")) {
|
|
setErr("Please choose an image file");
|
|
return;
|
|
}
|
|
setErr(null);
|
|
setMatches(null);
|
|
setSearched(false);
|
|
setFile(selected);
|
|
setPreviewUrl((prev) => {
|
|
if (prev) URL.revokeObjectURL(prev);
|
|
return URL.createObjectURL(selected);
|
|
});
|
|
};
|
|
|
|
const onSearch = async () => {
|
|
if (!searchToken || !file || !meta) return;
|
|
setSearching(true);
|
|
setErr(null);
|
|
try {
|
|
const result = await searchPhotoSearchMatches(searchToken, file);
|
|
setMatches(result.matches);
|
|
setSearched(true);
|
|
} catch (e) {
|
|
setMatches(null);
|
|
setSearched(false);
|
|
setErr(e instanceof Error ? e.message : "Search failed");
|
|
} finally {
|
|
setSearching(false);
|
|
}
|
|
};
|
|
|
|
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-search/${searchToken}`);
|
|
window.location.assign(`/login?type=event&id=${encodeURIComponent(meta.eventId)}`);
|
|
} catch {
|
|
setErr("Could not open login");
|
|
}
|
|
};
|
|
|
|
if (!searchToken) {
|
|
return (
|
|
<AuthShell showLogo={false}>
|
|
<WhiteCard>
|
|
<p className="text-center text-sm text-red-600">Missing search link</p>
|
|
</WhiteCard>
|
|
</AuthShell>
|
|
);
|
|
}
|
|
|
|
if (loading) {
|
|
return (
|
|
<AuthShell showLogo={false}>
|
|
<WhiteCard>
|
|
<p className="text-center text-sm text-[#666]">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>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<AuthShell showLogo={false}>
|
|
<WhiteCard wide={Boolean(matches && matches.length > 0)}>
|
|
{meta?.event ? (
|
|
<div className="mb-4">
|
|
<PhotoWallEventHeader
|
|
event={meta.event}
|
|
display={meta.eventHeader || null}
|
|
eyebrow="Photo Search"
|
|
/>
|
|
</div>
|
|
) : null}
|
|
|
|
<h1 className="mb-1 text-center text-[22px] font-semibold text-[#1a1a1a]">Find Photos</h1>
|
|
<p className="mb-5 text-center text-sm text-[#666]">
|
|
Upload a selfie to find photos of you from {meta?.eventName || "the event"}.
|
|
</p>
|
|
|
|
{meta?.accessMode === "login" && !loggedIn ? (
|
|
<div className="space-y-3">
|
|
<p className="text-center text-sm text-[#666]">Sign in to search for your photos.</p>
|
|
<button
|
|
type="button"
|
|
onClick={() => void goLogin()}
|
|
className="inline-flex h-11 w-full items-center justify-center rounded-lg bg-[#D32F2F] px-4 text-sm font-semibold text-white hover:bg-[#b71c1c]"
|
|
>
|
|
Sign in to continue
|
|
</button>
|
|
</div>
|
|
) : (
|
|
<div className="space-y-4">
|
|
<div>
|
|
<input
|
|
ref={fileRef}
|
|
type="file"
|
|
accept="image/*"
|
|
capture="user"
|
|
className="hidden"
|
|
onChange={(e) => onPick(e.target.files?.[0] || null)}
|
|
/>
|
|
{previewUrl ? (
|
|
<div className="mb-3 overflow-hidden rounded-lg border border-[#e5e5e5] bg-[#f8f8f8]">
|
|
<img src={previewUrl} alt="Your selfie" className="mx-auto max-h-[240px] w-full object-contain" />
|
|
</div>
|
|
) : null}
|
|
<button
|
|
type="button"
|
|
onClick={() => fileRef.current?.click()}
|
|
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]"
|
|
>
|
|
{file ? "Change photo" : "Choose photo"}
|
|
</button>
|
|
</div>
|
|
|
|
<button
|
|
type="button"
|
|
disabled={!file || searching}
|
|
onClick={() => void onSearch()}
|
|
className="inline-flex h-11 w-full items-center justify-center gap-2 rounded-lg bg-[#D32F2F] px-4 text-sm font-semibold text-white hover:bg-[#b71c1c] disabled:opacity-55"
|
|
>
|
|
<Search className="h-4 w-4" />
|
|
{searching ? "Searching…" : "Find photos"}
|
|
</button>
|
|
|
|
{err ? <p className="text-center text-sm text-red-600">{err}</p> : null}
|
|
|
|
{searched ? (
|
|
<div className="space-y-3 pt-2">
|
|
<p className="text-center text-sm font-semibold text-[#333]">
|
|
{matches && matches.length > 0
|
|
? `${matches.length} match${matches.length === 1 ? "" : "es"}`
|
|
: "No matches found"}
|
|
</p>
|
|
{matches && matches.length > 0 ? (
|
|
<div className="grid grid-cols-2 gap-3">
|
|
{matches.map((m) => (
|
|
<MatchCard
|
|
key={m._id}
|
|
match={m}
|
|
eventName={meta?.eventName || "the event"}
|
|
allowDownload={allowDownload}
|
|
showShare={showShare}
|
|
useNativeShare={useNativeShare}
|
|
enabledSharePlatforms={enabledSharePlatforms}
|
|
/>
|
|
))}
|
|
</div>
|
|
) : null}
|
|
</div>
|
|
) : null}
|
|
</div>
|
|
)}
|
|
</WhiteCard>
|
|
</AuthShell>
|
|
);
|
|
}
|