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 ( +
+
+
{children}
+
+
+ ); +} + +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 ; + if (platform === "facebook") return ; + if (platform === "instagram") return ; + return ; +} + +export function AiAvatarPage() { + const { uploadToken } = useParams<{ uploadToken: string }>(); + const fileRef = useRef(null); + const resultImgRef = useRef(null); + const [meta, setMeta] = useState(null); + const [loading, setLoading] = useState(true); + const [err, setErr] = useState(null); + const [selectedAvatar, setSelectedAvatar] = useState(null); + const [file, setFile] = useState(null); + const [previewUrl, setPreviewUrl] = useState(null); + const [submitting, setSubmitting] = useState(false); + const [pipelineMsgIndex, setPipelineMsgIndex] = useState(0); + const [picking, setPicking] = useState(false); + const [done, setDone] = useState(null); + const [actionMsg, setActionMsg] = useState(null); + const [cropSession, setCropSession] = useState(null); + const [shareFile, setShareFile] = useState(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 ( + + +

Missing link

+
+
+ ); + } + + if (loading) { + return ( + + +

Loading…

+
+
+ ); + } + + if (err && !meta) { + return ( + + +

{err}

+
+
+ ); + } + + if (submitting) { + const step = pipelineMsgIndex + 1; + const total = GENERATING_PIPELINE_MESSAGES.length; + const progressPct = Math.round((step / total) * 100); + return ( + + + {meta?.event ? ( +
+ +
+ ) : null} +
+
+ +
+

Creating your avatar

+

+ {GENERATING_PIPELINE_MESSAGES[pipelineMsgIndex]} +

+
+
+
+

This can take up to a minute — hang tight

+
+ + + ); + } + + if (done) { + return ( + + + {meta?.event ? ( +
+ +
+ ) : null} +

Your avatar

+

+ Here’s your AI avatar. You can download or share it. +

+ {(done.fileUrl || done.previewUrl) ? ( +
+ Your AI avatar { + 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; + } + }} + /> +
+ ) : null} +
+ + {allowDownload ? ( + + ) : null} +
+ {showShare ? ( +
+

+ Share +

+ {useNativeShare ? ( + + ) : ( +
+ {enabledSharePlatforms.map((item) => ( + + ))} +
+ )} +
+ ) : null} + {actionMsg ?

{actionMsg}

: null} +
+
+ ); + } + + return ( + + + {meta?.event ? ( +
+ +
+ ) : null} + + {meta?.accessMode === "login" && !loggedIn ? ( +
+

AI Avatar

+

Sign in to create your AI avatar.

+ +
+ ) : !selectedAvatar ? ( +
+

Choose an avatar

+

Pick an avatar, then upload your selfie.

+ {!meta?.avatars?.length ? ( +

No avatars available yet.

+ ) : ( +
+ {meta.avatars.map((avatar) => ( + + ))} +
+ )} + {err ?

{err}

: null} +
+ ) : ( +
+ +

Upload selfie

+

+ Avatar: {selectedAvatar.name} +

+ + void handleFileChange(e.target.files?.[0] || null)} + /> + {previewUrl ? ( +
+ Your selfie +
+ ) : null} + + + {err ?

{err}

: null} +
+ )} +
+ + {cropSession && cropTarget ? ( + { + URL.revokeObjectURL(cropSession.imageSrc); + setCropSession(null); + }} + onConfirm={(cropped) => { + URL.revokeObjectURL(cropSession.imageSrc); + setCropSession(null); + applySelectedFile(cropped); + }} + /> + ) : null} +
+ ); +} diff --git a/src/pages/PhotoSearchPage.tsx b/src/pages/PhotoSearchPage.tsx new file mode 100644 index 0000000..eeb088c --- /dev/null +++ b/src/pages/PhotoSearchPage.tsx @@ -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 ( +
+
+
{children}
+
+
+ ); +} + +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 ; + if (platform === "facebook") return ; + if (platform === "instagram") return ; + return ; +} + +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(null); + const [shareFile, setShareFile] = useState(null); + const [actionMsg, setActionMsg] = useState(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 ( +
+ {match.originalFileName { + 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; + } + }} + /> +
+ {allowDownload ? ( + + ) : null} + {showShare ? ( + useNativeShare ? ( + + ) : ( +
+ {enabledSharePlatforms.map((item) => ( + + ))} +
+ ) + ) : null} + {actionMsg ?

{actionMsg}

: null} +
+
+ ); +} + +export function PhotoSearchPage() { + const { searchToken } = useParams<{ searchToken: string }>(); + const fileRef = useRef(null); + const [meta, setMeta] = useState(null); + const [loading, setLoading] = useState(true); + const [err, setErr] = useState(null); + const [file, setFile] = useState(null); + const [previewUrl, setPreviewUrl] = useState(null); + const [searching, setSearching] = useState(false); + const [matches, setMatches] = useState(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 ( + + +

Missing search link

+
+
+ ); + } + + if (loading) { + return ( + + +

Loading…

+
+
+ ); + } + + if (err && !meta) { + return ( + + +

{err}

+
+
+ ); + } + + return ( + + 0)}> + {meta?.event ? ( +
+ +
+ ) : null} + +

Find Photos

+

+ Upload a selfie to find photos of you from {meta?.eventName || "the event"}. +

+ + {meta?.accessMode === "login" && !loggedIn ? ( +
+

Sign in to search for your photos.

+ +
+ ) : ( +
+
+ onPick(e.target.files?.[0] || null)} + /> + {previewUrl ? ( +
+ Your selfie +
+ ) : null} + +
+ + + + {err ?

{err}

: null} + + {searched ? ( +
+

+ {matches && matches.length > 0 + ? `${matches.length} match${matches.length === 1 ? "" : "es"}` + : "No matches found"} +

+ {matches && matches.length > 0 ? ( +
+ {matches.map((m) => ( + + ))} +
+ ) : null} +
+ ) : null} +
+ )} +
+
+ ); +} diff --git a/src/pages/PhotoWallUploadPage.tsx b/src/pages/PhotoWallUploadPage.tsx index 75421c1..3d5cc13 100644 --- a/src/pages/PhotoWallUploadPage.tsx +++ b/src/pages/PhotoWallUploadPage.tsx @@ -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(null); + const resultImgRef = useRef(null); const [meta, setMeta] = useState(null); const [loading, setLoading] = useState(true); const [err, setErr] = useState(null); @@ -94,6 +102,8 @@ export function PhotoWallUploadPage() { const [done, setDone] = useState(null); const [actionMsg, setActionMsg] = useState(null); const [cropSession, setCropSession] = useState(null); + /** Cached bytes for share/download — avoids CORS/network fail when opening the OS sheet. */ + const [shareFile, setShareFile] = useState(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) ? (
{done.framed { 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,19 +456,30 @@ export function PhotoWallUploadPage() {

Share

-
- {enabledSharePlatforms.map((item) => ( - - ))} -
+ {useNativeShare ? ( + + ) : ( +
+ {enabledSharePlatforms.map((item) => ( + + ))} +
+ )}
) : null} {actionMsg ?

{actionMsg}

: null} diff --git a/src/pages/SocialSharePage.tsx b/src/pages/SocialSharePage.tsx new file mode 100644 index 0000000..23d5259 --- /dev/null +++ b/src/pages/SocialSharePage.tsx @@ -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 ( +
+
+
{children}
+
+
+ ); +} + +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 ; + if (platform === "facebook") return ; + if (platform === "instagram") return ; + return ; +} + +export function SocialSharePage() { + const { uploadToken } = useParams<{ uploadToken: string }>(); + const fileRef = useRef(null); + const resultImgRef = useRef(null); + const [meta, setMeta] = useState(null); + const [loading, setLoading] = useState(true); + const [err, setErr] = useState(null); + const [selectedFrame, setSelectedFrame] = useState(null); + const [file, setFile] = useState(null); + const [previewUrl, setPreviewUrl] = useState(null); + const [submitting, setSubmitting] = useState(false); + const [picking, setPicking] = useState(false); + const [done, setDone] = useState(null); + const [actionMsg, setActionMsg] = useState(null); + const [cropSession, setCropSession] = useState(null); + const [shareFile, setShareFile] = useState(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 ( + + +

Missing link

+
+
+ ); + } + + if (loading) { + return ( + + +

Loading…

+
+
+ ); + } + + if (err && !meta) { + return ( + + +

{err}

+
+
+ ); + } + + if (done) { + return ( + + + {meta?.event ? ( +
+ +
+ ) : null} +

Your photo

+

+ Here’s your framed photo. You can download or share it. +

+ {(done.fileUrl || done.previewUrl) ? ( +
+ Your framed photo { + 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; + } + }} + /> +
+ ) : null} +
+ + {allowDownload ? ( + + ) : null} +
+ {showShare ? ( +
+

+ Share +

+ {useNativeShare ? ( + + ) : ( +
+ {enabledSharePlatforms.map((item) => ( + + ))} +
+ )} +
+ ) : null} + {actionMsg ?

{actionMsg}

: null} +
+
+ ); + } + + return ( + + + {meta?.event ? ( +
+ +
+ ) : null} + + {meta?.accessMode === "login" && !loggedIn ? ( +
+

Social Share

+

Sign in to create and share your photo.

+ +
+ ) : !selectedFrame ? ( +
+

Choose a frame

+

Pick a frame, then upload your photo.

+ {!meta?.frames?.length ? ( +

No frames available yet.

+ ) : ( +
+ {meta.frames.map((frame) => ( + + ))} +
+ )} + {err ?

{err}

: null} +
+ ) : ( +
+ +

Upload photo

+

+ Frame: {selectedFrame.name} +

+ + void handleFileChange(e.target.files?.[0] || null)} + /> + {previewUrl ? ( +
+ Your photo +
+ ) : null} + + + {err ?

{err}

: null} +
+ )} +
+ + {cropSession && cropTarget ? ( + { + URL.revokeObjectURL(cropSession.imageSrc); + setCropSession(null); + }} + onConfirm={(cropped) => { + URL.revokeObjectURL(cropSession.imageSrc); + setCropSession(null); + applySelectedFile(cropped); + }} + /> + ) : null} +
+ ); +}