EventBuzz_Website/src/pages/SocialSharePage.tsx

590 lines
21 KiB
TypeScript

import {
useCallback,
useEffect,
useMemo,
useRef,
useState,
type ReactNode,
} from "react";
import {
Download,
Share2,
ArrowLeft,
} from "lucide-react";
import { useParams } from "react-router-dom";
import { HeaderMobile } from "../components/HeaderMobile";
import { MobileShell } from "../components/MobileShell";
import { PhotoSourcePicker } from "../components/PhotoSourcePicker";
import { PhotoWallCropModal } from "../components/PhotoWallCropModal";
import { PublicEventChrome } from "../components/PublicEventChrome";
import { SharePlatformIcon } from "../components/SharePlatformIcon";
import {
getSocialShareMeta,
uploadSocialSharePhoto,
type PhotoWallSharePlatforms,
type SocialShareMeta,
type SocialSharePublicFrame,
} from "../lib/api";
import { getImageNaturalSize, ratiosMatch } from "../lib/cropImage";
import {
canUseNativeShareSheet,
downloadImageFromUrl,
ensureShareableImageFile,
fetchImageFile,
sharePhotoWallImage,
shareViaNativeSheet,
} from "../lib/photoWallShare";
import { useActivityProfileGate } from "../lib/useActivityProfileGate";
function SocialShareLayout({
children,
meta,
}: {
children: ReactNode;
meta?: SocialShareMeta | null;
}) {
return (
<MobileShell className="relative bg-transparent">
{meta ? (
<PublicEventChrome
event={meta.event}
eventHeader={meta.eventHeader}
eventNameFallback={meta.eventName}
eventId={meta.eventId}
/>
) : (
<HeaderMobile showRewardsAndLogout={false} showBack={false} />
)}
<main className="relative z-0 flex w-full max-w-none flex-1 flex-col pb-8 pt-6">
<div className="flex w-full max-w-none flex-col px-4">{children}</div>
</main>
</MobileShell>
);
}
type CropSession = {
imageSrc: string;
sourceFileName: string;
};
type DoneState = {
previewUrl: string | null;
fileUrl: string | null;
};
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 SocialSharePage() {
const { uploadToken } = useParams<{ uploadToken: string }>();
const resultImgRef = useRef<HTMLImageElement>(null);
const [meta, setMeta] = useState<SocialShareMeta | null>(null);
const [loading, setLoading] = useState(true);
const [err, setErr] = useState<string | null>(null);
const [selectedFrame, setSelectedFrame] = useState<SocialSharePublicFrame | null>(null);
const [displayName, setDisplayName] = useState("");
const [file, setFile] = useState<File | null>(null);
const [previewUrl, setPreviewUrl] = useState<string | null>(null);
const [submitting, setSubmitting] = useState(false);
const [picking, setPicking] = useState(false);
const [done, setDone] = useState<DoneState | null>(null);
const [resultImageReady, setResultImageReady] = useState(false);
const [actionMsg, setActionMsg] = useState<string | null>(null);
const [cropSession, setCropSession] = useState<CropSession | null>(null);
const [shareFile, setShareFile] = useState<File | null>(null);
const load = useCallback(async () => {
if (!uploadToken) return;
setLoading(true);
setErr(null);
try {
const data = await getSocialShareMeta(uploadToken);
setMeta(data);
} catch (e) {
setMeta(null);
setErr(e instanceof Error ? e.message : "Social share 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]);
useEffect(() => {
const src = done?.previewUrl || done?.fileUrl;
if (!done || !src) return;
const img = resultImgRef.current;
if (img?.complete && img.naturalWidth > 0) {
setResultImageReady(true);
}
}, [done]);
useEffect(() => {
if (!done?.fileUrl) return;
let cancelled = false;
void fetchImageFile(done.fileUrl, "social-share.jpg")
.then((f) => {
if (!cancelled) setShareFile(f);
})
.catch(() => {
/* keep local */
});
return () => {
cancelled = true;
};
}, [done?.fileUrl]);
const profileGate = useActivityProfileGate(meta?.eventId, meta?.accessMode, Boolean(meta) && !loading);
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 shareCaption = `Check out my photo from ${meta?.eventName || "the event"}!`;
const clearSelectedPhoto = useCallback(() => {
setFile(null);
setPreviewUrl((prev) => {
if (prev) URL.revokeObjectURL(prev);
return null;
});
}, []);
const applySelectedFile = useCallback((next: File) => {
setFile(next);
setPreviewUrl((prev) => {
if (prev) URL.revokeObjectURL(prev);
return URL.createObjectURL(next);
});
}, []);
const cropTarget = useMemo(() => {
const slot = selectedFrame?.photoSlot;
if (slot?.w && slot?.h) {
return { w: Math.max(1, slot.w), h: Math.max(1, slot.h), fromFrame: true as const };
}
if (meta?.requireUploadCrop) {
return {
w: Math.max(1, Number(meta.maxPhotoWidth) || 1080),
h: Math.max(1, Number(meta.maxPhotoHeight) || 1080),
fromFrame: false as const,
};
}
return null;
}, [selectedFrame, meta?.requireUploadCrop, meta?.maxPhotoWidth, meta?.maxPhotoHeight]);
const handleFileChange = async (selected: File | null) => {
if (!selected) return;
if (!selected.type.startsWith("image/")) {
setErr("Please choose an image file");
return;
}
setErr(null);
setPicking(true);
try {
// Prefer the selected frame's photo-slot aspect (e.g. square slot → crop to 1:1).
// Fall back to Settings → require crop + max W/H when no slot is defined.
if (!cropTarget) {
applySelectedFile(selected);
return;
}
const natural = await getImageNaturalSize(selected);
if (ratiosMatch(natural.width, natural.height, cropTarget.w, cropTarget.h)) {
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);
}
};
const onSubmit = async () => {
if (!uploadToken || !file || !meta || !selectedFrame) return;
setSubmitting(true);
setErr(null);
setActionMsg(null);
try {
const result = await uploadSocialSharePhoto(
uploadToken,
file,
selectedFrame._id,
displayName.trim(),
profileGate.participantId,
);
setShareFile(null);
setResultImageReady(false);
setDone({
previewUrl: result.previewUrl,
fileUrl: result.fileUrl,
});
clearSelectedPhoto();
if (result.fileUrl) {
void fetchImageFile(result.fileUrl, "social-share.jpg")
.then((f) => setShareFile(f))
.catch(() => {
void ensureShareableImageFile(file, "social-share.jpg").then(setShareFile);
});
}
} catch (e2) {
setErr(e2 instanceof Error ? e2.message : "Upload failed");
} finally {
setSubmitting(false);
}
};
const resetForNew = () => {
setDone(null);
setResultImageReady(false);
setShareFile(null);
setActionMsg(null);
setErr(null);
setSelectedFrame(null);
clearSelectedPhoto();
};
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 || "social-share.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, "social-share.jpg");
setActionMsg("Download started");
} catch (e) {
setActionMsg(e instanceof Error ? e.message : "Download failed");
}
};
const onNativeShare = async () => {
const src = done?.fileUrl || done?.previewUrl;
setActionMsg(null);
try {
const result = await shareViaNativeSheet({
file: shareFile,
imageUrl: src,
imgEl: resultImgRef.current,
title: meta?.eventName || "Social Share",
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 || "Social Share",
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");
}
};
if (!uploadToken) {
return (
<SocialShareLayout>
<p className="text-center text-sm text-red-600">Missing link</p>
</SocialShareLayout>
);
}
if (loading || (meta && !profileGate.allowed)) {
return (
<SocialShareLayout>
<p className="text-center text-sm text-[var(--theme-card-text-color)]/80">Loading…</p>
</SocialShareLayout>
);
}
if (err && !meta) {
return (
<SocialShareLayout>
<p className="text-center text-sm text-red-600">{err}</p>
</SocialShareLayout>
);
}
if (done) {
return (
<SocialShareLayout meta={meta}>
<section className="w-full card-bg rounded-lg shadow-md p-6">
<h2 className="mb-2 text-center text-[22px] font-semibold">Your photo</h2>
<p className="mb-4 text-center text-sm text-[var(--theme-card-text-color)]/80">
Here's your framed photo. You can download or share it.
</p>
{(done.fileUrl || done.previewUrl) ? (
<div className="relative mb-4 min-h-[220px] overflow-hidden rounded-lg">
{!resultImageReady ? (
<div
className="absolute inset-0 z-10 flex flex-col items-center justify-center gap-3"
role="status"
aria-live="polite"
aria-label="Loading generated photo"
>
<div
className="size-10 animate-spin rounded-full border-[3px] border-[var(--theme-button-color)]/25 border-t-[var(--theme-button-color)]"
aria-hidden
/>
<p className="text-xs font-medium text-[var(--theme-card-text-color)]/70">Preparing your photo…</p>
</div>
) : null}
<img
ref={resultImgRef}
crossOrigin="anonymous"
src={done.previewUrl || done.fileUrl || undefined}
alt="Your framed photo"
className={`mx-auto max-h-[360px] w-full object-contain transition-opacity duration-300 ${
resultImageReady ? "opacity-100" : "opacity-0"
}`}
onLoad={() => setResultImageReady(true)}
onError={(e) => {
const img = e.currentTarget;
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;
return;
}
setResultImageReady(true);
}}
/>
</div>
) : (
<p className="mb-4 text-center text-sm text-[var(--theme-card-text-color)]/80">Photo is ready, but preview is unavailable.</p>
)}
<div className="flex flex-col gap-2">
<button
type="button"
onClick={resetForNew}
className="inline-flex h-11 w-full items-center justify-center rounded-lg theme-btn-bg px-4 text-sm font-semibold text-white"
>
Create another
</button>
{allowDownload ? (
<button
type="button"
onClick={() => void onDownload()}
className="inline-flex h-11 w-full items-center justify-center gap-2 rounded-lg border border-zinc-300 bg-zinc-100 px-4 text-sm font-semibold text-gray-600"
>
<Download className="size-5" />
Download photo
</button>
) : null}
</div>
{showShare ? (
<div className="mt-2">
{useNativeShare ? (
<button
type="button"
onClick={() => void onNativeShare()}
className="inline-flex h-11 w-full items-center justify-center gap-2 rounded-lg border border-zinc-300 bg-zinc-100 px-4 text-sm font-semibold text-gray-600"
>
<Share2 className="size-5" />
Share
</button>
) : (
<div className="flex flex-wrap justify-center 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 w-auto gap-2 rounded-lg border border-zinc-300 bg-zinc-100 px-3 text-xs font-semibold text-gray-600"
>
<SharePlatformIcon platform={item.key} />
{/* {item.label} */}
</button>
))}
</div>
)}
</div>
) : null}
{actionMsg ? <p className="mt-3 text-center text-xs text-[var(--theme-card-text-color)]/80">{actionMsg}</p> : null}
</section>
</SocialShareLayout>
);
}
return (
<SocialShareLayout meta={meta}>
<section className="w-full card-bg rounded-lg shadow-md p-6">
{!selectedFrame ? (
<div className="space-y-4">
<h2 className="mb-1 text-center text-[22px] font-semibold">Choose a frame</h2>
<p className="text-center text-sm text-[var(--theme-card-text-color)]/80">Pick a frame, then upload your photo.</p>
{!meta?.frames?.length ? (
<p className="text-center text-sm text-[var(--theme-card-text-color)]/80">No frames available yet.</p>
) : (
<div className="grid grid-cols-2 gap-3">
{meta.frames.map((frame) => (
<button
key={frame._id}
type="button"
onClick={() => setSelectedFrame(frame)}
className="overflow-hidden rounded-lg border border-[var(--theme-card-text-color)]/15 text-left hover:border-[var(--theme-card-text-color)]/25 hover:border-[var(--theme-card-text-color)]/30"
>
<div className="aspect-square w-full">
{frame.frameImageUrl ? (
<img
src={frame.frameImageUrl}
alt={frame.name}
className="h-full w-full object-contain"
/>
) : (
<div className="flex h-full items-center justify-center text-xs text-[var(--theme-card-text-color)]/60">
No preview
</div>
)}
</div>
<p className="truncate px-2 py-2 text-center text-xs font-semibold text-[var(--theme-card-text-color)]">
{frame.name}
</p>
</button>
))}
</div>
)}
{err ? <p className="text-center text-sm text-red-600">{err}</p> : null}
</div>
) : (
<div className="space-y-4 text-left">
<button
type="button"
onClick={() => {
setSelectedFrame(null);
clearSelectedPhoto();
setErr(null);
}}
className="text-xs font-semibold inline-flex items-center gap-1 text-[var(--theme-card-text-color)]/80"
>
<span className="flex items-center justify-center w-7 h-7 bg-[var(--theme-card-text-color)]/10 rounded-full">
<ArrowLeft className="size-4" />
</span>Change frame
</button>
<h2 className="mb-2 text-center text-[22px] font-semibold text-[var(--theme-card-text-color)]">Upload photo</h2>
<p className="text-center text-sm text-[var(--theme-card-text-color)]/80">
Selected frame: <span className="font-semibold">{selectedFrame.name}</span>
</p>
{previewUrl ? (
<div className="overflow-hidden rounded-lg">
<img src={previewUrl} alt="Your photo" className="mx-auto max-h-[240px] w-full object-contain" />
</div>
) : null}
<PhotoSourcePicker
disabled={picking}
picking={picking}
hasFile={Boolean(file)}
onPick={(selected) => void handleFileChange(selected)}
/>
<label htmlFor="social-share-display-name" className="flex flex-col gap-1.5 text-sm font-medium text-[var(--theme-card-text-color)]">
Your name
<input
id="social-share-display-name"
type="text"
autoComplete="name"
required
maxLength={80}
value={displayName}
onChange={(event) => setDisplayName(event.target.value)}
className="h-11 w-full rounded-lg border border-[var(--theme-card-text-color)]/20 bg-[var(--theme-card-color)] px-3 text-sm font-normal outline-none focus:border-[var(--theme-button-color)]"
/>
</label>
<button
type="button"
disabled={!file || !displayName.trim() || displayName.trim().length > 80 || submitting}
onClick={() => void onSubmit()}
className="inline-flex h-11 w-full items-center justify-center rounded-lg theme-btn-bg px-4 text-sm font-semibold text-white disabled:opacity-55"
>
{submitting ? "Generating" : "Generate photo"}
</button>
{err ? <p className="text-center text-sm text-red-600">{err}</p> : null}
</div>
)}
</section>
{cropSession && cropTarget ? (
<PhotoWallCropModal
imageSrc={cropSession.imageSrc}
sourceFileName={cropSession.sourceFileName}
aspect={cropTarget.w / cropTarget.h}
onCancel={() => {
URL.revokeObjectURL(cropSession.imageSrc);
setCropSession(null);
}}
onConfirm={(cropped) => {
URL.revokeObjectURL(cropSession.imageSrc);
setCropSession(null);
applySelectedFile(cropped);
}}
/>
) : null}
</SocialShareLayout>
);
}