Add attendee photo search, social share, and AI avatar flows.

Also document the visitor app in a non-technical brief and make photo-wall sharing more reliable on mobile.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Ravi Gulhane 2026-08-14 12:51:59 +05:30
parent 229e3cdcda
commit aea5bea7cb
9 changed files with 2509 additions and 42 deletions

189
FUNCTIONALITY_BRIEF.md Normal file
View File

@ -0,0 +1,189 @@
# EventBuzz Attendee App — Functionality Brief
This is the **attendee-facing EventBuzz experience**: the phone-friendly website people open at an event (typically from a QR code, invite, or join link). It is not the organiser dashboard. Organisers set up the event elsewhere; attendees use this app to join, follow the programme, take part, and share moments.
---
## In one sentence
Guests join an event with a short code, then use their phone to follow the agenda, ask questions, take quizzes and polls, earn points, give feedback, and create branded photos to share.
---
## Who it is for
**Attendees** use it throughout the event: finding sessions, interacting during talks, and collecting photos afterwards.
**Speakers and hosts** can also use dedicated **on-stage screens** (opened from a special link) so the room can see live questions, poll results, and a photo wall on the projector.
**Organisers** do not run the event from this app, but they control what attendees see: branding, who can register, which sessions are live, and which extras (feedback, ratings, photos, rewards) are switched on.
---
## Getting in
Attendees do not need to hunt for an app store listing. They open a link or scan a QR, then:
1. **Enter a join code** (from an invite, slide, or poster), or land on a link that already includes the code.
2. See the **event name, logo, dates, and venue**.
3. Sign in the way the organiser has chosen:
- **Open / guest access** — continue with little or no friction (email may still be asked).
- **Email one-time code (OTP)** — a code is sent to their inbox.
- **Magic link** — a sign-in link is emailed (it expires after a short time).
- **Invite only** — they must also enter an invite code.
If registration needs extra details (name, phone, custom questions), they complete a **profile** before entering. Some events require **organiser approval** first; those guests see a waiting screen until they are accepted.
Returning guests who already registered can sign in again even if new registrations have closed.
---
## Event home
Once inside, attendees land on the **event hub**:
- Event name, description, banner image, and organiser branding
- Dates and location
- **Full agenda**, grouped by day, with session times and details
- **My Sessions** — a personal list of talks they have bookmarked
- Optional **event ratings** (if the organiser has turned that on)
This is the home base they can return to from anywhere in the app.
---
## Sessions and the agenda
Each session has its own page with:
- Title, type (for example symposium or panel), time, duration, and room
- Option to **bookmark** it for later
- **Key people** — speakers, moderators, and similar, with photo, title, bio, organisation, and public links (LinkedIn, X, website) when provided
- **My Sessions** and the **full agenda** again, so people can jump to the next talk without going home
- Live **Q&A** and **quizzes / polls** for that room (when the organiser has them running)
Organisers can hide individual tabs (key people, my sessions, or agenda) per session if they want a simpler room experience.
**Tracks** (parallel programmes, halls, or streams) have their own landing page when someone joins with a track code.
---
## Live Q&A
During a session, attendees can:
- **Ask a question** from their phone (including anonymously if they are not fully signed in)
- See questions from the rest of the room as they come in
- **Upvote or downvote** questions so popular ones rise
On the **presenter screen**, shortlisted questions can be shown to the room so the speaker can answer what the audience actually cares about.
---
## Quizzes, polls, and surveys
Organisers can push live activities into a session. Attendees are taken into them (including automatically when something goes live) and can:
- Answer **quizzes** (scored, with right/wrong answers)
- Vote in **polls**
- Complete **surveys** (including text answers and ratings)
Question types include single choice, multiple choice, free text, and star ratings. Quizzes can run on a **timer**. After submitting, people may see their own answers, the correct answers (when the host allows it), and live result summaries.
**On-stage presentation screens** let the host:
- Put a question on air
- Show live response charts
- See who has answered
- Show a **quiz leaderboard**
- Reveal the correct answer when they choose
- Switch between Q&A and quiz/poll views for the same session
---
## Rewards and leaderboard
Participation can earn **points**. Typical ways to earn them include registering, asking or voting on questions, taking quizzes or polls, and submitting feedback or ratings. Organisers can also adjust points manually.
Attendees can:
- See their **total points** and a history of how they earned them
- Open a **leaderboard** of top participants
- See **their own rank** among the group
A celebration can appear when points are awarded, so the reward feels immediate.
---
## Feedback and ratings
When the organiser enables them, attendees can fill in **custom forms**:
- **Feedback** — comments and structured questions about the event or a session
- **Ratings** — typically star ratings and similar scores, including an overall event rating on the event home
Forms are built by the organiser (required fields, multiple choice, checkboxes, stars, and so on). Submission is a simple one-screen flow on the phone.
---
## Photo experiences
These are usually opened from a dedicated QR or link at a booth or after a session. They are designed for sharing on social media.
### Photo wall
Attendees take or upload a selfie (cropping to the right shape if needed). The photo can be placed in an event **frame**, and the organiser can ask for a name and extra fields. Photos may wait for **approval** before they appear.
A **live photo wall** on a large screen shows approved photos as they come in.
People can **download** their framed photo and **share** it to LinkedIn, Facebook, Instagram, Snapchat, X, WhatsApp, or the phone’s native share sheet — whichever the organiser has allowed.
### Find my photos
Attendees upload a selfie and the app **finds photos of them** from the event. Matches can be downloaded and shared the same way.
### Social share (branded frames)
Guests pick an official **event frame**, add their photo, crop it, and walk away with a ready-to-post image.
### AI avatar
Guests pick a themed avatar template, upload a selfie, and the app generates a **stylised portrait** of them in that look. They can download and share the result.
---
## Moving around the app
A floating **quick menu** (on session and related screens) jumps to:
- Event home
- Rewards
- Leaderboard
- Feedback and ratings (when those are on for the session)
- All questions
The header shows the **event logo**, a **points total**, and sign-out. The layout is built for a phone in the hand at a venue.
---
## Branding and trust
Each event can show its own **logo, banner, name, and colours**. Legal pages for **privacy** and **terms of service** are available from the marketing/legal area of the site.
---
## Typical attendee journey
1. Scan a poster QR or type a code from a slide.
2. Sign in (or continue as a guest) and complete a profile if asked.
3. Browse the agenda, bookmark sessions, and open the current talk.
4. Ask questions, vote, and take a live poll or quiz.
5. Earn points and check the leaderboard.
6. Rate the session or send feedback.
7. At a photo booth or from a follow-up link, create a framed photo, find pictures of themselves, or generate an AI avatar — then share it.
---
## What this app is not
It is **not** the organiser control panel (creating events, building forms, managing attendees). It is **not** a check-in / badge-scanning tool. Those live in other EventBuzz products. This app is what **guests and the room** use during and around the event.

View File

@ -20,6 +20,9 @@ import { QuizPollPresentPage } from "./pages/QuizPollPresentPage";
import { QuestionsPresentPage } from "./pages/QuestionsPresentPage";
import { PhotoWallUploadPage } from "./pages/PhotoWallUploadPage";
import { PhotoWallPresentPage } from "./pages/PhotoWallPresentPage";
import { PhotoSearchPage } from "./pages/PhotoSearchPage";
import { SocialSharePage } from "./pages/SocialSharePage";
import { AiAvatarPage } from "./pages/AiAvatarPage";
import { CodeEntryPage } from "./pages/CodeEntryPage";
import { PrivacyPolicyPage } from "./pages/PrivacyPolicyPage";
import { TermsOfServicePage } from "./pages/TermsOfServicePage";
@ -45,6 +48,9 @@ export default function App() {
<Route path="/questions-present/:sessionId" element={<QuestionsPresentPage />} />
<Route path="/photo-wall/:uploadToken" element={<PhotoWallUploadPage />} />
<Route path="/photo-wall-present/:presentToken" element={<PhotoWallPresentPage />} />
<Route path="/photo-search/:searchToken" element={<PhotoSearchPage />} />
<Route path="/social-share/:uploadToken" element={<SocialSharePage />} />
<Route path="/ai-avatar/:uploadToken" element={<AiAvatarPage />} />
<Route path="/questions" element={<Questions />} />
<Route path="/ask" element={<Navigate to="/questions" replace />} />
<Route path="/feedback" element={<FeedbackPage />} />

View File

@ -48,11 +48,15 @@ function formatEventDateRange(ev: PhotoWallPublicEvent): string {
export function PhotoWallEventHeader({
event,
display,
/** Small label above the event title. Default "Photo Wall"; pass null/"" to hide. */
eyebrow = "Photo Wall",
}: {
event: PhotoWallPublicEvent;
display?: PhotoWallEventHeaderDisplay | null;
eyebrow?: string | null;
}) {
const d = resolveDisplay(display);
const eyebrowText = eyebrow == null ? "" : String(eyebrow).trim();
const typeLabel = formatEventType(event.eventType);
const dateLine = formatEventDateRange(event);
const kind = formatLocationKind(event.locationType);
@ -87,7 +91,14 @@ export function PhotoWallEventHeader({
);
}
if (!showBanner && !showLogo && !showTitle && !showSubtitle && metaItems.length === 0) {
if (
!showBanner &&
!showLogo &&
!showTitle &&
!showSubtitle &&
metaItems.length === 0 &&
!eyebrowText
) {
return null;
}
@ -109,7 +120,9 @@ export function PhotoWallEventHeader({
) : null}
{showTitle || showSubtitle || metaItems.length > 0 ? (
<div className="flex w-full flex-col items-center gap-2">
<p className="font-inter text-base font-semibold text-[#24282f]">Photo Wall</p>
{eyebrowText ? (
<p className="font-inter text-base font-semibold text-[#24282f]">{eyebrowText}</p>
) : null}
{showTitle ? (
<h1 className="font-inter text-[28px] font-bold leading-tight text-[#24282f] sm:text-[32px]">
{event.name}
@ -133,9 +146,9 @@ export function PhotoWallEventHeader({
</div>
) : null}
</div>
) : (
<p className="font-inter text-base font-semibold text-[#24282f]">Photo Wall</p>
)}
) : eyebrowText ? (
<p className="font-inter text-base font-semibold text-[#24282f]">{eyebrowText}</p>
) : null}
</header>
);
}

View File

@ -1074,9 +1074,7 @@ export async function uploadPhotoWallSelfie(
? fileUrlRelative.startsWith("http")
? fileUrlRelative
: `${base}${fileUrlRelative.startsWith("/") ? "" : "/"}${fileUrlRelative}`
: photoId
? `${base}/api/public/photo-wall/upload/${encodeURIComponent(uploadToken)}/photos/${encodeURIComponent(photoId)}/file`
: null;
: data?.previewUrl || framedUrl || photoUrl || null;
return {
awaitingApproval: Boolean(data?.awaitingApproval),
previewUrl: data?.previewUrl || framedUrl || photoUrl,
@ -1097,3 +1095,247 @@ export async function getPhotoWallPresent(presentToken: string): Promise<PhotoWa
if (!body || typeof body !== "object" || !("data" in body)) throw new Error("Invalid response");
return (body as { data: PhotoWallPresentData }).data;
}
export type PhotoSearchMeta = {
eventId: string;
eventName: string;
event?: PhotoWallPublicEvent | null;
eventHeader?: PhotoWallEventHeaderDisplay | null;
accessMode: "open" | "login";
allowDownload: boolean;
sharePlatforms?: PhotoWallSharePlatforms;
};
export type PhotoSearchMatch = {
_id: string;
event: string;
organization: string;
fileName?: string;
originalFileName?: string;
url: string;
fileUrl: string;
mimeType?: string;
size?: number;
uploadedAt?: string;
similarity?: number;
};
export async function getPhotoSearchMeta(searchToken: string): Promise<PhotoSearchMeta> {
const base = getBaseUrl();
const url = `${base}/api/public/photo-search/${encodeURIComponent(searchToken)}`;
const res = await fetch(url);
const body = await parseJson(res);
if (!res.ok) throw new Error(messageFromBody(body));
if (!body || typeof body !== "object" || !("data" in body)) throw new Error("Invalid response");
return (body as { data: PhotoSearchMeta }).data;
}
export type SocialSharePublicFrame = {
_id: string;
name: string;
frameImageUrl: string | null;
frameCanvasWidth?: number;
frameCanvasHeight?: number;
/** Photo slot size on the frame canvas — used for upload crop aspect. */
photoSlot?: { w: number; h: number } | null;
};
export type SocialShareMeta = {
eventId: string;
eventName: string;
event?: PhotoWallPublicEvent | null;
eventHeader?: PhotoWallEventHeaderDisplay | null;
accessMode: "open" | "login";
requireUploadCrop?: boolean;
maxPhotoWidth?: number;
maxPhotoHeight?: number;
allowDownload: boolean;
sharePlatforms?: PhotoWallSharePlatforms;
frames: SocialSharePublicFrame[];
};
export async function getSocialShareMeta(uploadToken: string): Promise<SocialShareMeta> {
const base = getBaseUrl();
const url = `${base}/api/public/social-share/${encodeURIComponent(uploadToken)}`;
const res = await fetch(url);
const body = await parseJson(res);
if (!res.ok) throw new Error(messageFromBody(body));
if (!body || typeof body !== "object" || !("data" in body)) throw new Error("Invalid response");
return (body as { data: SocialShareMeta }).data;
}
export async function uploadSocialSharePhoto(
uploadToken: string,
file: File,
frameId: string,
): Promise<{
previewUrl: string | null;
fileUrl: string | null;
framed: boolean;
outputId: string | null;
}> {
const base = getBaseUrl();
const url = `${base}/api/public/social-share/${encodeURIComponent(uploadToken)}`;
const fd = new FormData();
fd.append("file", file, file.name);
fd.append("frameId", frameId);
const headers: Record<string, string> = {};
const token = getAttendeeToken();
if (token) headers.Authorization = `Bearer ${token}`;
const res = await fetch(url, { method: "POST", headers, body: fd });
const body = await parseJson(res);
if (!res.ok) throw new Error(messageFromBody(body));
if (!body || typeof body !== "object" || !("data" in body)) throw new Error("Invalid response");
const data = (
body as {
data: {
previewUrl?: string;
fileUrl?: string;
framed?: boolean;
outputId?: string;
};
}
).data;
const fileUrlRelative = data?.fileUrl || null;
const fileUrl = fileUrlRelative
? fileUrlRelative.startsWith("http")
? fileUrlRelative
: `${base}${fileUrlRelative.startsWith("/") ? "" : "/"}${fileUrlRelative}`
: data?.previewUrl || null;
return {
previewUrl: data?.previewUrl || null,
fileUrl,
framed: Boolean(data?.framed),
outputId: data?.outputId ? String(data.outputId) : null,
};
}
export type AiAvatarPublicTemplate = {
_id: string;
name: string;
imageUrl: string | null;
/** Crop aspect for selfie (default square). */
photoSlot?: { w: number; h: number } | null;
};
export type AiAvatarMeta = {
eventId: string;
eventName: string;
event?: PhotoWallPublicEvent | null;
eventHeader?: PhotoWallEventHeaderDisplay | null;
accessMode: "open" | "login";
allowDownload: boolean;
sharePlatforms?: PhotoWallSharePlatforms;
avatars: AiAvatarPublicTemplate[];
};
export async function getAiAvatarMeta(uploadToken: string): Promise<AiAvatarMeta> {
const base = getBaseUrl();
const url = `${base}/api/public/ai-avatar/${encodeURIComponent(uploadToken)}`;
const res = await fetch(url);
const body = await parseJson(res);
if (!res.ok) throw new Error(messageFromBody(body));
if (!body || typeof body !== "object" || !("data" in body)) throw new Error("Invalid response");
return (body as { data: AiAvatarMeta }).data;
}
export async function uploadAiAvatarPhoto(
uploadToken: string,
file: File,
avatarId: string,
): Promise<{
previewUrl: string | null;
fileUrl: string | null;
outputId: string | null;
}> {
const base = getBaseUrl();
const url = `${base}/api/public/ai-avatar/${encodeURIComponent(uploadToken)}`;
const fd = new FormData();
fd.append("file", file, file.name);
fd.append("avatarId", avatarId);
const headers: Record<string, string> = {};
const token = getAttendeeToken();
if (token) headers.Authorization = `Bearer ${token}`;
const res = await fetch(url, { method: "POST", headers, body: fd });
const body = await parseJson(res);
if (!res.ok) throw new Error(messageFromBody(body));
if (!body || typeof body !== "object" || !("data" in body)) throw new Error("Invalid response");
const data = (
body as {
data: {
previewUrl?: string;
fileUrl?: string;
outputId?: string;
};
}
).data;
const fileUrlRelative = data?.fileUrl || null;
const fileUrl = fileUrlRelative
? fileUrlRelative.startsWith("http")
? fileUrlRelative
: `${base}${fileUrlRelative.startsWith("/") ? "" : "/"}${fileUrlRelative}`
: data?.previewUrl || null;
return {
previewUrl: data?.previewUrl || null,
fileUrl,
outputId: data?.outputId ? String(data.outputId) : null,
};
}
export async function searchPhotoSearchMatches(
searchToken: string,
file: File,
): Promise<{ collectionExists: boolean; matchCount: number; matches: PhotoSearchMatch[] }> {
const base = getBaseUrl();
const url = `${base}/api/public/photo-search/${encodeURIComponent(searchToken)}`;
const fd = new FormData();
fd.append("files", file, file.name);
const headers: Record<string, string> = {};
const token = getAttendeeToken();
if (token) headers.Authorization = `Bearer ${token}`;
const res = await fetch(url, { method: "POST", headers, body: fd });
const body = await parseJson(res);
if (!res.ok) throw new Error(messageFromBody(body));
if (!body || typeof body !== "object" || !("data" in body)) throw new Error("Invalid response");
const data = (
body as {
data: {
collectionExists?: boolean;
matchCount?: number;
matches?: Array<Partial<PhotoSearchMatch> & { _id?: string; fileUrl?: string; url?: string }>;
};
}
).data;
const matches = (data?.matches || []).map((m) => {
const id = m._id ? String(m._id) : "";
const fileUrlRelative = m.fileUrl || null;
const fileUrl = fileUrlRelative
? fileUrlRelative.startsWith("http")
? fileUrlRelative
: `${base}${fileUrlRelative.startsWith("/") ? "" : "/"}${fileUrlRelative}`
: typeof m.url === "string" && m.url.trim()
? m.url.trim()
: "";
return {
_id: id,
event: String(m.event || ""),
organization: String(m.organization || ""),
fileName: m.fileName,
originalFileName: m.originalFileName,
// CDN thumb for display — do not fall back to API fileUrl
url: typeof m.url === "string" && m.url.trim() ? m.url.trim() : "",
fileUrl,
mimeType: m.mimeType,
size: m.size,
uploadedAt: m.uploadedAt,
similarity: typeof m.similarity === "number" ? m.similarity : undefined,
} satisfies PhotoSearchMatch;
});
return {
collectionExists: Boolean(data?.collectionExists),
matchCount: typeof data?.matchCount === "number" ? data.matchCount : matches.length,
matches,
};
}

View File

@ -1,8 +1,8 @@
/** Download + social share helpers for photo-wall visitor success screen.
*
* Platform buttons open that app (or its web share intent) — not the iOS system share sheet.
* Instagram / Snapchat cannot receive an image via web intent, so we save the photo,
* copy the caption, then open the app.
* On phones: prefer the OS share sheet (`navigator.share`) so the user picks the app.
* On desktop: platform buttons open that app / web intent. Instagram / Snapchat cannot
* receive an image via web intent, so we save the photo, copy the caption, then open the app.
*/
export type PhotoWallSharePlatform =
@ -24,21 +24,96 @@ const PLATFORM_APP_HOME: Record<PhotoWallSharePlatform, string> = {
whatsapp: "https://wa.me/",
};
function isMobileUa(): boolean {
export function isMobileUa(): boolean {
if (typeof navigator === "undefined") return false;
return /iPhone|iPad|iPod|Android/i.test(navigator.userAgent);
const ua = navigator.userAgent || "";
const touchMac =
/\bMacintosh\b/i.test(ua) &&
typeof navigator.maxTouchPoints === "number" &&
navigator.maxTouchPoints > 1;
return /iPhone|iPad|iPod|Android/i.test(ua) || touchMac;
}
async function fetchImageFile(url: string, filename = "photo-wall.jpg"): Promise<File> {
const res = await fetch(url, { mode: "cors" });
/** True when we should show one Share button that opens the OS share sheet. */
export function canUseNativeShareSheet(): boolean {
if (typeof navigator === "undefined") return false;
if (!isMobileUa()) return false;
return typeof navigator.share === "function";
}
function friendlyFetchError(err: unknown): Error {
const msg = err instanceof Error ? err.message : String(err || "");
// Safari often surfaces network/CORS failures as "Load failed" / "Failed to fetch"
if (/load failed|failed to fetch|networkerror|access control/i.test(msg)) {
return new Error("Could not load photo for sharing. Try Download, then share from your photos.");
}
return err instanceof Error ? err : new Error("Could not load image for sharing");
}
/** Convert HEIC / unknown types to JPEG so iOS `canShare({ files })` accepts them. */
export async function ensureShareableImageFile(file: File, filename = "photo-wall.jpg"): Promise<File> {
const type = (file.type || "").toLowerCase();
if (type === "image/jpeg" || type === "image/png" || type === "image/webp") {
return file;
}
try {
const bitmap = await createImageBitmap(file);
const canvas = document.createElement("canvas");
canvas.width = bitmap.width;
canvas.height = bitmap.height;
const ctx = canvas.getContext("2d");
if (!ctx) return file;
ctx.drawImage(bitmap, 0, 0);
bitmap.close?.();
const blob = await new Promise<Blob | null>((resolve) =>
canvas.toBlob((b) => resolve(b), "image/jpeg", 0.92),
);
if (!blob?.size) return file;
return new File([blob], filename.replace(/\.\w+$/, "") + ".jpg", { type: "image/jpeg" });
} catch {
return file;
}
}
export async function fetchImageFile(url: string, filename = "photo-wall.jpg"): Promise<File> {
let res: Response;
try {
res = await fetch(url, { mode: "cors", credentials: "omit", cache: "no-store" });
} catch (err) {
throw friendlyFetchError(err);
}
if (!res.ok) throw new Error("Could not load image for sharing");
const blob = await res.blob();
if (!blob.size) throw new Error("Could not load image for sharing");
const type = blob.type && blob.type.startsWith("image/") ? blob.type : "image/jpeg";
const ext = type.includes("png") ? "png" : type.includes("webp") ? "webp" : "jpg";
const name = filename.replace(/\.\w+$/, "") + `.${ext}`;
return new File([blob], name, { type });
}
/** Read pixels from an already-displayed <img> (must be CORS-clean / same-origin). */
export async function fileFromHtmlImage(
img: HTMLImageElement,
filename = "photo-wall.jpg",
): Promise<File | null> {
if (!img?.naturalWidth || !img.naturalHeight) return null;
try {
const canvas = document.createElement("canvas");
canvas.width = img.naturalWidth;
canvas.height = img.naturalHeight;
const ctx = canvas.getContext("2d");
if (!ctx) return null;
ctx.drawImage(img, 0, 0);
const blob = await new Promise<Blob | null>((resolve) =>
canvas.toBlob((b) => resolve(b), "image/jpeg", 0.92),
);
if (!blob?.size) return null;
return new File([blob], filename.replace(/\.\w+$/, "") + ".jpg", { type: "image/jpeg" });
} catch {
return null;
}
}
export async function downloadImageFromUrl(url: string, filename = "photo-wall.jpg") {
const file = await fetchImageFile(url, filename);
const objectUrl = URL.createObjectURL(file);
@ -143,13 +218,113 @@ async function saveImageAndOpenApp(
return "downloaded";
}
async function resolveShareFile(options: {
file?: File | null;
imageUrl?: string | null;
imgEl?: HTMLImageElement | null;
}): Promise<File | null> {
if (options.file && options.file.size > 0) return options.file;
if (options.imageUrl) {
try {
return await fetchImageFile(options.imageUrl, "photo-wall.jpg");
} catch {
/* try canvas next */
}
}
if (options.imgEl) {
const fromImg = await fileFromHtmlImage(options.imgEl);
if (fromImg) return fromImg;
}
return null;
}
/**
* Share to a specific platform. Does NOT use navigator.share (that always opens the
* iOS system sheet and ignores which button was tapped).
* Open the OS share sheet (iOS / Android) with the photo + caption.
* Falls back to text/url-only share if the image bytes cannot be loaded.
*
* Critical: do not `await` network/canvas work before the first `navigator.share`
* when a File is already in hand — iOS Safari drops the user gesture and fails
* with "Load failed".
*/
export async function shareViaNativeSheet(options: {
imageUrl?: string | null;
file?: File | null;
imgEl?: HTMLImageElement | null;
title?: string;
text?: string;
}): Promise<SharePhotoWallResult> {
const title = options.title || "EventBuzz";
const text = options.text || `Check out my photo from ${title}!`;
const imageUrl = options.imageUrl || undefined;
if (typeof navigator.share !== "function") {
throw new Error("Sharing is not supported on this device");
}
const readyFile = options.file && options.file.size > 0 ? options.file : null;
const tryShareFiles = async (file: File): Promise<SharePhotoWallResult | "skip"> => {
const payloadWithFiles: ShareData = { title, text, files: [file] };
try {
const canFiles =
typeof navigator.canShare !== "function" || navigator.canShare(payloadWithFiles);
if (!canFiles) return "skip";
await navigator.share(payloadWithFiles);
return "shared";
} catch (err) {
const name = err instanceof DOMException ? err.name : "";
if (name === "AbortError") return "cancelled";
return "skip";
}
};
// 1) Prefer already-cached bytes — no await before share (keeps iOS user gesture).
if (readyFile) {
const result = await tryShareFiles(readyFile);
if (result !== "skip") return result;
}
// 2) Text/url-only share still before any fetch (gesture-safe fallback).
const payloadTextOnly: ShareData = imageUrl
? { title, text, url: imageUrl }
: { title, text };
try {
await navigator.share(payloadTextOnly);
return "shared";
} catch (err) {
const name = err instanceof DOMException ? err.name : "";
if (name === "AbortError") return "cancelled";
// Continue — try loading bytes for a second attempt (gesture may already be gone).
}
// 3) Last resort: fetch / canvas, then share (may only work on Android).
if (!readyFile) {
const file = await resolveShareFile({
file: null,
imageUrl,
imgEl: options.imgEl,
});
if (file) {
const result = await tryShareFiles(file);
if (result !== "skip") return result;
}
}
throw new Error("Could not share this photo. Try Download, then share from your photos.");
}
/**
* Share to a specific platform (desktop / non–share-sheet path).
* Does NOT use navigator.share so each button can target a different app.
*/
export async function sharePhotoWallImage(options: {
platform: PhotoWallSharePlatform;
imageUrl: string;
file?: File | null;
title?: string;
text?: string;
}): Promise<SharePhotoWallResult> {
@ -158,7 +333,7 @@ export async function sharePhotoWallImage(options: {
const imageUrl = options.imageUrl;
const platform = options.platform;
const intent = platformShareUrl(platform, imageUrl, text);
const intent = imageUrl ? platformShareUrl(platform, imageUrl, text) : null;
if (intent) {
// Copy caption so users can paste if the app only receives the URL.
await copyText(`${text}\n${imageUrl}`);
@ -166,7 +341,13 @@ export async function sharePhotoWallImage(options: {
return "shared";
}
// Instagram / Snapchat: save file locally, copy caption, open the app.
const file = await fetchImageFile(imageUrl, "photo-wall.jpg");
// Instagram / Snapchat (or missing public URL): save file locally, copy caption, open the app.
const file =
options.file && options.file.size > 0
? options.file
: imageUrl
? await fetchImageFile(imageUrl, "photo-wall.jpg")
: null;
if (!file) throw new Error("Could not load image for sharing");
return saveImageAndOpenApp(platform, file, text);
}

664
src/pages/AiAvatarPage.tsx Normal file
View File

@ -0,0 +1,664 @@
import {
useCallback,
useEffect,
useMemo,
useRef,
useState,
type ReactNode,
} from "react";
import {
Download,
Facebook,
Instagram,
Linkedin,
Loader2,
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 {
getAiAvatarMeta,
getVisitorLoginPreview,
uploadAiAvatarPhoto,
type AiAvatarMeta,
type AiAvatarPublicTemplate,
type PhotoWallSharePlatforms,
} from "../lib/api";
import { getAttendeeParticipant, getAttendeeToken } from "../lib/attendeeAuth";
import { getImageNaturalSize, ratiosMatch } from "../lib/cropImage";
import {
canUseNativeShareSheet,
downloadImageFromUrl,
ensureShareableImageFile,
fetchImageFile,
sharePhotoWallImage,
shareViaNativeSheet,
} from "../lib/photoWallShare";
import {
EB_VISITOR_CONTEXT_KEY,
setPostAuthReturnPath,
type EbVisitorContext,
} from "../types/visitorContext";
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 bg-white px-6 py-6">{children}</div>
</div>
</div>
);
}
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" },
];
/** Rotating copy while WaveSpeed runs — keeps visitors engaged; not a real status feed. */
const GENERATING_PIPELINE_MESSAGES = [
"Uploading your selfie…",
"Detecting your face…",
"Aligning features with the avatar…",
"Blending skin tones…",
"Matching lighting and shadows…",
"Applying AI head swap…",
"Refining facial details…",
"Smoothing edges…",
"Polishing the final look…",
"Almost there — finishing your avatar…",
];
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" />;
}
export function AiAvatarPage() {
const { uploadToken } = useParams<{ uploadToken: string }>();
const fileRef = useRef<HTMLInputElement>(null);
const resultImgRef = useRef<HTMLImageElement>(null);
const [meta, setMeta] = useState<AiAvatarMeta | null>(null);
const [loading, setLoading] = useState(true);
const [err, setErr] = useState<string | null>(null);
const [selectedAvatar, setSelectedAvatar] = useState<AiAvatarPublicTemplate | null>(null);
const [file, setFile] = useState<File | null>(null);
const [previewUrl, setPreviewUrl] = useState<string | null>(null);
const [submitting, setSubmitting] = useState(false);
const [pipelineMsgIndex, setPipelineMsgIndex] = useState(0);
const [picking, setPicking] = useState(false);
const [done, setDone] = useState<DoneState | null>(null);
const [actionMsg, setActionMsg] = useState<string | null>(null);
const [cropSession, setCropSession] = useState<CropSession | null>(null);
const [shareFile, setShareFile] = useState<File | null>(null);
useEffect(() => {
if (!submitting) {
setPipelineMsgIndex(0);
return;
}
const id = window.setInterval(() => {
setPipelineMsgIndex((i) => Math.min(i + 1, GENERATING_PIPELINE_MESSAGES.length - 1));
}, 2800);
return () => window.clearInterval(id);
}, [submitting]);
const load = useCallback(async () => {
if (!uploadToken) return;
setLoading(true);
setErr(null);
try {
const data = await getAiAvatarMeta(uploadToken);
setMeta(data);
} catch (e) {
setMeta(null);
setErr(e instanceof Error ? e.message : "AI Avatar 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(() => {
if (!done?.fileUrl) return;
let cancelled = false;
void fetchImageFile(done.fileUrl, "ai-avatar.jpg")
.then((f) => {
if (!cancelled) setShareFile(f);
})
.catch(() => {
/* keep local */
});
return () => {
cancelled = true;
};
}, [done?.fileUrl]);
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 shareCaption = `Check out my AI avatar from ${meta?.eventName || "the event"}!`;
const clearSelectedPhoto = useCallback(() => {
setFile(null);
setPreviewUrl((prev) => {
if (prev) URL.revokeObjectURL(prev);
return null;
});
if (fileRef.current) fileRef.current.value = "";
}, []);
const applySelectedFile = useCallback((next: File) => {
setFile(next);
setPreviewUrl((prev) => {
if (prev) URL.revokeObjectURL(prev);
return URL.createObjectURL(next);
});
}, []);
// Square selfie crop for head swap (avatar photoSlot, else 1:1).
const cropTarget = useMemo(() => {
const slot = selectedAvatar?.photoSlot;
if (slot?.w && slot?.h) {
return { w: Math.max(1, slot.w), h: Math.max(1, slot.h) };
}
return { w: 1080, h: 1080 };
}, [selectedAvatar]);
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 {
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 || "selfie.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 () => {
if (!uploadToken || !file || !meta || !selectedAvatar) return;
setSubmitting(true);
setErr(null);
setActionMsg(null);
try {
const result = await uploadAiAvatarPhoto(uploadToken, file, selectedAvatar._id);
setShareFile(null);
setDone({
previewUrl: result.previewUrl,
fileUrl: result.fileUrl,
});
clearSelectedPhoto();
if (result.fileUrl) {
void fetchImageFile(result.fileUrl, "ai-avatar.jpg")
.then((f) => setShareFile(f))
.catch(() => {
void ensureShareableImageFile(file, "ai-avatar.jpg").then(setShareFile);
});
}
} catch (e2) {
setErr(e2 instanceof Error ? e2.message : "Could not create avatar");
} finally {
setSubmitting(false);
}
};
const resetForNew = () => {
setDone(null);
setShareFile(null);
setActionMsg(null);
setErr(null);
setSelectedAvatar(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 || "ai-avatar.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, "ai-avatar.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 || "AI Avatar",
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 || "AI Avatar",
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(`/ai-avatar/${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 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>
);
}
if (submitting) {
const step = pipelineMsgIndex + 1;
const total = GENERATING_PIPELINE_MESSAGES.length;
const progressPct = Math.round((step / total) * 100);
return (
<AuthShell showLogo={false}>
<WhiteCard>
{meta?.event ? (
<div className="mb-4">
<PhotoWallEventHeader
event={meta.event}
display={meta.eventHeader || null}
eyebrow="AI Avatar"
/>
</div>
) : null}
<div className="flex flex-col items-center py-4 text-center">
<div className="mb-5 flex h-16 w-16 items-center justify-center rounded-full bg-[#fdecea]">
<Loader2 className="h-8 w-8 animate-spin text-[#D32F2F]" aria-hidden />
</div>
<h1 className="mb-2 text-[22px] font-semibold text-[#1a1a1a]">Creating your avatar</h1>
<p
key={pipelineMsgIndex}
className="mb-5 min-h-[2.5rem] text-sm text-[#666] transition-opacity duration-300"
aria-live="polite"
>
{GENERATING_PIPELINE_MESSAGES[pipelineMsgIndex]}
</p>
<div className="mb-2 h-1.5 w-full overflow-hidden rounded-full bg-[#eee]">
<div
className="h-full rounded-full bg-[#D32F2F] transition-[width] duration-700 ease-out"
style={{ width: `${progressPct}%` }}
/>
</div>
<p className="text-xs text-[#999]">This can take up to a minute — hang tight</p>
</div>
</WhiteCard>
</AuthShell>
);
}
if (done) {
return (
<AuthShell showLogo={false}>
<WhiteCard>
{meta?.event ? (
<div className="mb-4">
<PhotoWallEventHeader
event={meta.event}
display={meta.eventHeader || null}
eyebrow="AI Avatar"
/>
</div>
) : null}
<h1 className="mb-2 text-center text-[22px] font-semibold text-[#1a1a1a]">Your avatar</h1>
<p className="mb-4 text-center text-sm text-[#666]">
Here’s your AI avatar. You can download or share it.
</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="Your AI avatar"
className="mx-auto max-h-[360px] w-full object-contain"
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;
}
}}
/>
</div>
) : null}
<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 bg-[#D32F2F] px-4 text-sm font-semibold text-white hover:bg-[#b71c1c]"
>
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-[#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-[#888]">
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]"
>
<ShareIcon 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}
eyebrow="AI Avatar"
/>
</div>
) : null}
{meta?.accessMode === "login" && !loggedIn ? (
<div className="space-y-3">
<h1 className="mb-1 text-center text-[22px] font-semibold text-[#1a1a1a]">AI Avatar</h1>
<p className="text-center text-sm text-[#666]">Sign in to create your AI avatar.</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>
) : !selectedAvatar ? (
<div className="space-y-4">
<h1 className="mb-1 text-center text-[22px] font-semibold text-[#1a1a1a]">Choose an avatar</h1>
<p className="text-center text-sm text-[#666]">Pick an avatar, then upload your selfie.</p>
{!meta?.avatars?.length ? (
<p className="text-center text-sm text-[#666]">No avatars available yet.</p>
) : (
<div className="grid grid-cols-2 gap-3">
{meta.avatars.map((avatar) => (
<button
key={avatar._id}
type="button"
onClick={() => setSelectedAvatar(avatar)}
className="overflow-hidden rounded-lg border border-[#e5e5e5] bg-[#f8f8f8] text-left hover:border-[#D32F2F]"
>
<div className="aspect-square w-full bg-white">
{avatar.imageUrl ? (
<img
src={avatar.imageUrl}
alt={avatar.name}
className="h-full w-full object-contain"
/>
) : (
<div className="flex h-full items-center justify-center text-xs text-[#999]">
No preview
</div>
)}
</div>
<p className="truncate px-2 py-2 text-center text-xs font-semibold text-[#333]">
{avatar.name}
</p>
</button>
))}
</div>
)}
{err ? <p className="text-center text-sm text-red-600">{err}</p> : null}
</div>
) : (
<div className="space-y-4">
<button
type="button"
onClick={() => {
setSelectedAvatar(null);
clearSelectedPhoto();
setErr(null);
}}
className="text-sm font-semibold text-[#D32F2F]"
>
← Change avatar
</button>
<h1 className="mb-1 text-center text-[22px] font-semibold text-[#1a1a1a]">Upload selfie</h1>
<p className="text-center text-sm text-[#666]">
Avatar: <span className="font-semibold text-[#333]">{selectedAvatar.name}</span>
</p>
<input
ref={fileRef}
type="file"
accept="image/*"
capture="user"
className="hidden"
onChange={(e) => void handleFileChange(e.target.files?.[0] || null)}
/>
{previewUrl ? (
<div className="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"
disabled={picking}
onClick={() => fileRef.current?.click()}
className="inline-flex h-11 w-full items-center justify-center rounded-lg border border-[#ddd] bg-white px-4 text-sm font-semibold text-[#333] hover:bg-[#fafafa] disabled:opacity-55"
>
{picking ? "Processing…" : file ? "Change selfie" : "Choose selfie"}
</button>
<button
type="button"
disabled={!file || submitting}
onClick={() => void onSubmit()}
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] disabled:opacity-55"
>
{submitting ? "Creating avatar…" : "Create avatar"}
</button>
{err ? <p className="text-center text-sm text-red-600">{err}</p> : null}
</div>
)}
</WhiteCard>
{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}
</AuthShell>
);
}

View File

@ -0,0 +1,472 @@
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>
);
}

View File

@ -29,7 +29,14 @@ import {
} from "../lib/api";
import { getAttendeeParticipant, getAttendeeToken } from "../lib/attendeeAuth";
import { getImageNaturalSize, ratiosMatch } from "../lib/cropImage";
import { downloadImageFromUrl, sharePhotoWallImage } from "../lib/photoWallShare";
import {
canUseNativeShareSheet,
downloadImageFromUrl,
fetchImageFile,
ensureShareableImageFile,
sharePhotoWallImage,
shareViaNativeSheet,
} from "../lib/photoWallShare";
import {
EB_VISITOR_CONTEXT_KEY,
setPostAuthReturnPath,
@ -82,6 +89,7 @@ function ShareIcon({ platform }: { platform: keyof PhotoWallSharePlatforms }) {
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);
@ -94,6 +102,8 @@ export function PhotoWallUploadPage() {
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;
@ -136,6 +146,7 @@ export function PhotoWallUploadPage() {
() => SHARE_LABELS.filter((item) => Boolean(meta?.sharePlatforms?.[item.key])),
[meta?.sharePlatforms],
);
const useNativeShare = useMemo(() => canUseNativeShareSheet(), []);
const clearSelectedPhoto = useCallback(() => {
setFile(null);
@ -146,8 +157,24 @@ export function PhotoWallUploadPage() {
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();
@ -206,9 +233,12 @@ export function PhotoWallUploadPage() {
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,
@ -216,6 +246,14 @@ export function PhotoWallUploadPage() {
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 {
@ -224,10 +262,22 @@ export function PhotoWallUploadPage() {
};
const onDownload = async () => {
const src = done?.fileUrl || done?.previewUrl;
if (!src) return;
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) {
@ -235,16 +285,43 @@ export function PhotoWallUploadPage() {
}
};
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) return;
if (!src && !shareFile) return;
setActionMsg(null);
try {
const result = await sharePhotoWallImage({
platform,
imageUrl: src,
imageUrl: src || "",
file: shareFile,
title: meta?.eventName || "Photo wall",
text: `Check out my photo from ${meta?.eventName || "the event"}!`,
text: shareCaption,
});
if (result === "shared") {
setActionMsg(
@ -334,13 +411,21 @@ export function PhotoWallUploadPage() {
{(done.fileUrl || done.previewUrl) ? (
<div className="mb-4 overflow-hidden rounded-lg border border-[#e5e5e5] bg-[#f8f8f8]">
<img
src={done.fileUrl || done.previewUrl || undefined}
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;
if (done.previewUrl && img.src !== done.previewUrl) {
img.src = done.previewUrl;
// 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;
}
}}
/>
@ -371,6 +456,16 @@ export function PhotoWallUploadPage() {
<p className="mb-2 text-center text-xs font-semibold uppercase tracking-wide text-[#888]">
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
@ -384,6 +479,7 @@ export function PhotoWallUploadPage() {
</button>
))}
</div>
)}
</div>
) : null}
{actionMsg ? <p className="mt-3 text-center text-xs text-[#666]">{actionMsg}</p> : null}

View File

@ -0,0 +1,604 @@
import {
useCallback,
useEffect,
useMemo,
useRef,
useState,
type ReactNode,
} from "react";
import {
Download,
Facebook,
Instagram,
Linkedin,
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 {
getSocialShareMeta,
getVisitorLoginPreview,
uploadSocialSharePhoto,
type PhotoWallSharePlatforms,
type SocialShareMeta,
type SocialSharePublicFrame,
} from "../lib/api";
import { getAttendeeParticipant, getAttendeeToken } from "../lib/attendeeAuth";
import { getImageNaturalSize, ratiosMatch } from "../lib/cropImage";
import {
canUseNativeShareSheet,
downloadImageFromUrl,
ensureShareableImageFile,
fetchImageFile,
sharePhotoWallImage,
shareViaNativeSheet,
} from "../lib/photoWallShare";
import {
EB_VISITOR_CONTEXT_KEY,
setPostAuthReturnPath,
type EbVisitorContext,
} from "../types/visitorContext";
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 bg-white px-6 py-6">{children}</div>
</div>
</div>
);
}
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" },
];
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" />;
}
export function SocialSharePage() {
const { uploadToken } = useParams<{ uploadToken: string }>();
const fileRef = useRef<HTMLInputElement>(null);
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 [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 [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(() => {
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 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 shareCaption = `Check out my photo from ${meta?.eventName || "the event"}!`;
const clearSelectedPhoto = useCallback(() => {
setFile(null);
setPreviewUrl((prev) => {
if (prev) URL.revokeObjectURL(prev);
return null;
});
if (fileRef.current) fileRef.current.value = "";
}, []);
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) {
clearSelectedPhoto();
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);
if (fileRef.current) fileRef.current.value = "";
}
};
const onSubmit = async () => {
if (!uploadToken || !file || !meta || !selectedFrame) return;
setSubmitting(true);
setErr(null);
setActionMsg(null);
try {
const result = await uploadSocialSharePhoto(uploadToken, file, selectedFrame._id);
setShareFile(null);
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);
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");
}
};
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(`/social-share/${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 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>
);
}
if (done) {
return (
<AuthShell showLogo={false}>
<WhiteCard>
{meta?.event ? (
<div className="mb-4">
<PhotoWallEventHeader
event={meta.event}
display={meta.eventHeader || null}
eyebrow="Social Share"
/>
</div>
) : null}
<h1 className="mb-2 text-center text-[22px] font-semibold text-[#1a1a1a]">Your photo</h1>
<p className="mb-4 text-center text-sm text-[#666]">
Here’s your framed photo. You can download or share it.
</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="Your framed photo"
className="mx-auto max-h-[360px] w-full object-contain"
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;
}
}}
/>
</div>
) : null}
<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 bg-[#D32F2F] px-4 text-sm font-semibold text-white hover:bg-[#b71c1c]"
>
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-[#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-[#888]">
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]"
>
<ShareIcon 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}
eyebrow="Social Share"
/>
</div>
) : null}
{meta?.accessMode === "login" && !loggedIn ? (
<div className="space-y-3">
<h1 className="mb-1 text-center text-[22px] font-semibold text-[#1a1a1a]">Social Share</h1>
<p className="text-center text-sm text-[#666]">Sign in to create and share your photo.</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>
) : !selectedFrame ? (
<div className="space-y-4">
<h1 className="mb-1 text-center text-[22px] font-semibold text-[#1a1a1a]">Choose a frame</h1>
<p className="text-center text-sm text-[#666]">Pick a frame, then upload your photo.</p>
{!meta?.frames?.length ? (
<p className="text-center text-sm text-[#666]">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-[#e5e5e5] bg-[#f8f8f8] text-left hover:border-[#D32F2F]"
>
<div className="aspect-square w-full bg-white">
{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-[#999]">
No preview
</div>
)}
</div>
<p className="truncate px-2 py-2 text-center text-xs font-semibold text-[#333]">
{frame.name}
</p>
</button>
))}
</div>
)}
{err ? <p className="text-center text-sm text-red-600">{err}</p> : null}
</div>
) : (
<div className="space-y-4">
<button
type="button"
onClick={() => {
setSelectedFrame(null);
clearSelectedPhoto();
setErr(null);
}}
className="text-sm font-semibold text-[#D32F2F]"
>
← Change frame
</button>
<h1 className="mb-1 text-center text-[22px] font-semibold text-[#1a1a1a]">Upload photo</h1>
<p className="text-center text-sm text-[#666]">
Frame: <span className="font-semibold text-[#333]">{selectedFrame.name}</span>
</p>
<input
ref={fileRef}
type="file"
accept="image/*"
capture="user"
className="hidden"
onChange={(e) => void handleFileChange(e.target.files?.[0] || null)}
/>
{previewUrl ? (
<div className="overflow-hidden rounded-lg border border-[#e5e5e5] bg-[#f8f8f8]">
<img src={previewUrl} alt="Your photo" className="mx-auto max-h-[240px] w-full object-contain" />
</div>
) : null}
<button
type="button"
disabled={picking}
onClick={() => fileRef.current?.click()}
className="inline-flex h-11 w-full items-center justify-center rounded-lg border border-[#ddd] bg-white px-4 text-sm font-semibold text-[#333] hover:bg-[#fafafa] disabled:opacity-55"
>
{picking ? "Processing…" : file ? "Change photo" : "Choose photo"}
</button>
<button
type="button"
disabled={!file || submitting}
onClick={() => void onSubmit()}
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] disabled:opacity-55"
>
{submitting ? "Creating…" : "Create photo"}
</button>
{err ? <p className="text-center text-sm text-red-600">{err}</p> : null}
</div>
)}
</WhiteCard>
{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}
</AuthShell>
);
}