diff --git a/FUNCTIONALITY_BRIEF.md b/FUNCTIONALITY_BRIEF.md
new file mode 100644
index 0000000..fbfb827
--- /dev/null
+++ b/FUNCTIONALITY_BRIEF.md
@@ -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.
diff --git a/src/App.tsx b/src/App.tsx
index b13a98c..1c5dd63 100644
--- a/src/App.tsx
+++ b/src/App.tsx
@@ -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() {
} />
} />
} />
+ } />
+ } />
+ } />
} />
} />
} />
diff --git a/src/components/PhotoWallEventHeader.tsx b/src/components/PhotoWallEventHeader.tsx
index 93022c8..cec5c9f 100644
--- a/src/components/PhotoWallEventHeader.tsx
+++ b/src/components/PhotoWallEventHeader.tsx
@@ -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 ? (
-
Photo Wall
+ {eyebrowText ? (
+
{eyebrowText}
+ ) : null}
{showTitle ? (
{event.name}
@@ -133,9 +146,9 @@ export function PhotoWallEventHeader({
) : null}
- ) : (
-
Photo Wall
- )}
+ ) : eyebrowText ? (
+
{eyebrowText}
+ ) : null}
);
}
diff --git a/src/lib/api.ts b/src/lib/api.ts
index fefe81a..26e409f 100644
--- a/src/lib/api.ts
+++ b/src/lib/api.ts
@@ -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 {
+ 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 {
+ 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 = {};
+ 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 {
+ 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 = {};
+ 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 = {};
+ 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 & { _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,
+ };
+}
diff --git a/src/lib/photoWallShare.ts b/src/lib/photoWallShare.ts
index 7525a2c..244ffa3 100644
--- a/src/lib/photoWallShare.ts
+++ b/src/lib/photoWallShare.ts
@@ -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 = {
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 {
- 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 {
+ 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((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 {
+ 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 (must be CORS-clean / same-origin). */
+export async function fileFromHtmlImage(
+ img: HTMLImageElement,
+ filename = "photo-wall.jpg",
+): Promise {
+ 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((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 {
+ 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 {
+ 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 => {
+ 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 {
@@ -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);
}
diff --git a/src/pages/AiAvatarPage.tsx b/src/pages/AiAvatarPage.tsx
new file mode 100644
index 0000000..eb94650
--- /dev/null
+++ b/src/pages/AiAvatarPage.tsx
@@ -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 (
+
+ {
+ 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;
+ }
+ }}
+ />
+