590 lines
21 KiB
TypeScript
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>
|
|
);
|
|
}
|