|
|
|
|
@ -29,6 +29,7 @@ import { getAttendeeParticipant, getAttendeeToken } from "../lib/attendeeAuth";
|
|
|
|
|
import {
|
|
|
|
|
canUseNativeShareSheet,
|
|
|
|
|
downloadImageFromUrl,
|
|
|
|
|
ensureShareableImageFile,
|
|
|
|
|
fetchImageFile,
|
|
|
|
|
sharePhotoWallImage,
|
|
|
|
|
shareViaNativeSheet,
|
|
|
|
|
@ -58,11 +59,60 @@ const SHARE_LABELS: Array<{ key: keyof PhotoWallSharePlatforms; label: string }>
|
|
|
|
|
{ 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 ShareIcon({
|
|
|
|
|
platform,
|
|
|
|
|
className = "h-4 w-4",
|
|
|
|
|
}: {
|
|
|
|
|
platform: keyof PhotoWallSharePlatforms;
|
|
|
|
|
className?: string;
|
|
|
|
|
}) {
|
|
|
|
|
if (platform === "linkedin") return <Linkedin className={className} />;
|
|
|
|
|
if (platform === "facebook") return <Facebook className={className} />;
|
|
|
|
|
if (platform === "instagram") return <Instagram className={className} />;
|
|
|
|
|
if (platform === "x") {
|
|
|
|
|
return (
|
|
|
|
|
<svg viewBox="0 0 24 24" className={className} fill="currentColor" aria-hidden>
|
|
|
|
|
<path d="M18.244 2.25h3.308l-7.227 8.26 8.502 11.24H16.17l-4.714-6.231-5.401 6.231H2.744l7.73-8.835L1.254 2.25H8.08l4.253 5.622L18.244 2.25zm-1.161 17.52h1.833L7.084 4.126H5.117z" />
|
|
|
|
|
</svg>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
if (platform === "whatsapp") {
|
|
|
|
|
return (
|
|
|
|
|
<svg viewBox="0 0 24 24" className={className} fill="currentColor" aria-hidden>
|
|
|
|
|
<path d="M17.472 14.382c-.297-.149-1.758-.867-2.03-.967-.273-.099-.471-.148-.67.15-.197.297-.767.966-.94 1.164-.173.199-.347.223-.644.075-.297-.15-1.255-.463-2.39-1.475-.883-.788-1.48-1.761-1.653-2.059-.173-.297-.018-.458.13-.606.134-.133.298-.347.446-.52.149-.174.198-.298.298-.497.099-.198.05-.371-.025-.52-.075-.149-.669-1.612-.916-2.207-.242-.579-.487-.5-.669-.51-.173-.008-.371-.01-.57-.01-.198 0-.52.074-.792.372-.272.297-1.04 1.016-1.04 2.479 0 1.462 1.065 2.875 1.213 3.074.149.198 2.096 3.2 5.077 4.487.709.306 1.262.489 1.694.625.712.227 1.36.195 1.871.118.571-.085 1.758-.719 2.006-1.413.248-.694.248-1.289.173-1.413-.074-.124-.272-.198-.57-.347m-5.421 7.403h-.004a9.87 9.87 0 01-5.031-1.378l-.361-.214-3.741.982.998-3.648-.235-.374a9.86 9.86 0 01-1.51-5.26c.001-5.45 4.436-9.884 9.888-9.884 2.64 0 5.122 1.03 6.988 2.898a9.825 9.825 0 012.893 6.994c-.003 5.45-4.435 9.884-9.885 9.884m8.413-18.297A11.815 11.815 0 0012.05 0C5.495 0 .16 5.335.157 11.892c0 2.096.547 4.142 1.588 5.945L.057 24l6.305-1.654a11.882 11.882 0 005.683 1.448h.005c6.554 0 11.89-5.335 11.893-11.893a11.821 11.821 0 00-3.48-8.413z" />
|
|
|
|
|
</svg>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
if (platform === "snapchat") {
|
|
|
|
|
return (
|
|
|
|
|
<svg viewBox="0 0 24 24" className={className} fill="currentColor" aria-hidden>
|
|
|
|
|
<path d="M12.206.793c.99 0 4.347.276 5.93 3.821.529 1.193.403 3.219.322 4.847a3.753 3.753 0 002.3.718c.386 0 .829-.119.829-.538 0-.364.214-1.28.917-1.28.646 0 1.239.685.839 1.961-.165.456.428 1.47 1.56 1.47.646 0 .839.321.839.538 0 .718-1.239 2.049-4.026 2.119.373 2.627 2.362 3.858 3.687 4.097.272.047.53.315.53.639 0 .593-.61.793-1.239.793-.214 0-.646-.047-1.3-.047-.646 0-1.364.119-2.013.538-1.239.793-2.666 2.134-5.287 2.134-2.7 0-4.026-1.341-5.287-2.134-.646-.419-1.364-.538-2.013-.538-.654 0-1.086.047-1.3.047-.629 0-1.239-.2-1.239-.793 0-.324.258-.592.53-.639 1.325-.239 3.314-1.47 3.687-4.097-2.787-.07-4.026-1.401-4.026-2.119 0-.217.193-.538.839-.538 1.132 0 1.725-1.014 1.56-1.47-.4-1.276.193-1.961.839-1.961.703 0 .917.916.917 1.28 0 .419.443.538.829.538a3.753 3.753 0 002.3-.718c-.081-1.628-.207-3.654.322-4.847C7.859 1.069 11.216.793 12.206.793z" />
|
|
|
|
|
</svg>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
return <Share2 className={className} />;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function IconActionButton({
|
|
|
|
|
label,
|
|
|
|
|
onClick,
|
|
|
|
|
children,
|
|
|
|
|
}: {
|
|
|
|
|
label: string;
|
|
|
|
|
onClick: () => void;
|
|
|
|
|
children: ReactNode;
|
|
|
|
|
}) {
|
|
|
|
|
return (
|
|
|
|
|
<button
|
|
|
|
|
type="button"
|
|
|
|
|
title={label}
|
|
|
|
|
aria-label={label}
|
|
|
|
|
onClick={onClick}
|
|
|
|
|
className="inline-flex h-9 w-9 shrink-0 items-center justify-center rounded-lg border border-[#ddd] bg-white text-[#333] hover:bg-[#fafafa]"
|
|
|
|
|
>
|
|
|
|
|
{children}
|
|
|
|
|
</button>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function MatchCard({
|
|
|
|
|
@ -81,8 +131,9 @@ function MatchCard({
|
|
|
|
|
enabledSharePlatforms: Array<{ key: keyof PhotoWallSharePlatforms; label: string }>;
|
|
|
|
|
}) {
|
|
|
|
|
const imgRef = useRef<HTMLImageElement>(null);
|
|
|
|
|
const shareMenuRef = useRef<HTMLDivElement>(null);
|
|
|
|
|
const [shareFile, setShareFile] = useState<File | null>(null);
|
|
|
|
|
const [actionMsg, setActionMsg] = useState<string | null>(null);
|
|
|
|
|
const [shareMenuOpen, setShareMenuOpen] = useState(false);
|
|
|
|
|
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;
|
|
|
|
|
@ -92,6 +143,7 @@ function MatchCard({
|
|
|
|
|
if (!match.fileUrl) return;
|
|
|
|
|
let cancelled = false;
|
|
|
|
|
void fetchImageFile(match.fileUrl, match.originalFileName || "event-photo.jpg")
|
|
|
|
|
.then((f) => ensureShareableImageFile(f, match.originalFileName || "event-photo.jpg"))
|
|
|
|
|
.then((f) => {
|
|
|
|
|
if (!cancelled) setShareFile(f);
|
|
|
|
|
})
|
|
|
|
|
@ -103,8 +155,18 @@ function MatchCard({
|
|
|
|
|
};
|
|
|
|
|
}, [match.fileUrl, match.originalFileName]);
|
|
|
|
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
if (!shareMenuOpen) return;
|
|
|
|
|
const onPointerDown = (event: PointerEvent) => {
|
|
|
|
|
const el = shareMenuRef.current;
|
|
|
|
|
if (el && !el.contains(event.target as Node)) setShareMenuOpen(false);
|
|
|
|
|
};
|
|
|
|
|
document.addEventListener("pointerdown", onPointerDown);
|
|
|
|
|
return () => document.removeEventListener("pointerdown", onPointerDown);
|
|
|
|
|
}, [shareMenuOpen]);
|
|
|
|
|
|
|
|
|
|
const onDownload = async () => {
|
|
|
|
|
setActionMsg(null);
|
|
|
|
|
setShareMenuOpen(false);
|
|
|
|
|
try {
|
|
|
|
|
if (shareFile) {
|
|
|
|
|
const objectUrl = URL.createObjectURL(shareFile);
|
|
|
|
|
@ -115,121 +177,104 @@ function MatchCard({
|
|
|
|
|
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");
|
|
|
|
|
} catch {
|
|
|
|
|
/* keep the card quiet */
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const onNativeShare = async () => {
|
|
|
|
|
setActionMsg(null);
|
|
|
|
|
try {
|
|
|
|
|
const result = await shareViaNativeSheet({
|
|
|
|
|
await shareViaNativeSheet({
|
|
|
|
|
file: shareFile,
|
|
|
|
|
imageUrl: downloadSrc,
|
|
|
|
|
imgEl: imgRef.current,
|
|
|
|
|
title: eventName,
|
|
|
|
|
text: caption,
|
|
|
|
|
filesOnly: true,
|
|
|
|
|
});
|
|
|
|
|
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,
|
|
|
|
|
);
|
|
|
|
|
} catch {
|
|
|
|
|
/* keep the card quiet */
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const onShare = async (platform: keyof PhotoWallSharePlatforms) => {
|
|
|
|
|
if (!downloadSrc && !shareFile) return;
|
|
|
|
|
setActionMsg(null);
|
|
|
|
|
setShareMenuOpen(false);
|
|
|
|
|
try {
|
|
|
|
|
const result = await sharePhotoWallImage({
|
|
|
|
|
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");
|
|
|
|
|
} catch {
|
|
|
|
|
/* keep the card quiet */
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
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 className={`rounded-lg border border-[#e5e5e5] bg-[#f8f8f8]${shareMenuOpen ? " relative z-20" : ""}`}>
|
|
|
|
|
<div className="overflow-hidden rounded-t-lg bg-white">
|
|
|
|
|
<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>
|
|
|
|
|
<div className="p-2">
|
|
|
|
|
<div ref={shareMenuRef} className="flex flex-nowrap items-center justify-center gap-1.5">
|
|
|
|
|
{allowDownload ? (
|
|
|
|
|
<IconActionButton label="Download" onClick={() => void onDownload()}>
|
|
|
|
|
<Download className="h-4 w-4" />
|
|
|
|
|
</IconActionButton>
|
|
|
|
|
) : null}
|
|
|
|
|
{showShare ? (
|
|
|
|
|
<div className="relative">
|
|
|
|
|
<IconActionButton
|
|
|
|
|
label="Share"
|
|
|
|
|
onClick={() => {
|
|
|
|
|
if (useNativeShare) {
|
|
|
|
|
void onNativeShare();
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
setShareMenuOpen((open) => !open);
|
|
|
|
|
}}
|
|
|
|
|
>
|
|
|
|
|
<Share2 className="h-4 w-4" />
|
|
|
|
|
</IconActionButton>
|
|
|
|
|
{showShare && !useNativeShare && shareMenuOpen ? (
|
|
|
|
|
<div className="absolute right-0 top-full z-30 mt-1 min-w-[11.5rem] divide-y divide-[#f0f0f0] overflow-hidden rounded-lg border border-[#e5e5e5] bg-white shadow-[0_8px_24px_rgba(0,0,0,0.16)]">
|
|
|
|
|
{enabledSharePlatforms.map((item) => (
|
|
|
|
|
<button
|
|
|
|
|
key={item.key}
|
|
|
|
|
type="button"
|
|
|
|
|
onClick={() => void onShare(item.key)}
|
|
|
|
|
className="flex w-full items-center gap-2 px-3 py-2 text-left text-xs font-semibold text-[#333] hover:bg-[#f7f7f7]"
|
|
|
|
|
>
|
|
|
|
|
<ShareIcon platform={item.key} className="h-4 w-4 shrink-0" />
|
|
|
|
|
{item.label}
|
|
|
|
|
</button>
|
|
|
|
|
))}
|
|
|
|
|
</div>
|
|
|
|
|
) : null}
|
|
|
|
|
</div>
|
|
|
|
|
)
|
|
|
|
|
) : null}
|
|
|
|
|
{actionMsg ? <p className="text-center text-[11px] text-[#666]">{actionMsg}</p> : null}
|
|
|
|
|
) : null}
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
|