Add attendee photo search, social share, and AI avatar flows.
Also document the visitor app in a non-technical brief and make photo-wall sharing more reliable on mobile. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
parent
229e3cdcda
commit
aea5bea7cb
|
|
@ -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.
|
||||
|
|
@ -20,6 +20,9 @@ import { QuizPollPresentPage } from "./pages/QuizPollPresentPage";
|
|||
import { QuestionsPresentPage } from "./pages/QuestionsPresentPage";
|
||||
import { PhotoWallUploadPage } from "./pages/PhotoWallUploadPage";
|
||||
import { PhotoWallPresentPage } from "./pages/PhotoWallPresentPage";
|
||||
import { PhotoSearchPage } from "./pages/PhotoSearchPage";
|
||||
import { SocialSharePage } from "./pages/SocialSharePage";
|
||||
import { AiAvatarPage } from "./pages/AiAvatarPage";
|
||||
import { CodeEntryPage } from "./pages/CodeEntryPage";
|
||||
import { PrivacyPolicyPage } from "./pages/PrivacyPolicyPage";
|
||||
import { TermsOfServicePage } from "./pages/TermsOfServicePage";
|
||||
|
|
@ -45,6 +48,9 @@ export default function App() {
|
|||
<Route path="/questions-present/:sessionId" element={<QuestionsPresentPage />} />
|
||||
<Route path="/photo-wall/:uploadToken" element={<PhotoWallUploadPage />} />
|
||||
<Route path="/photo-wall-present/:presentToken" element={<PhotoWallPresentPage />} />
|
||||
<Route path="/photo-search/:searchToken" element={<PhotoSearchPage />} />
|
||||
<Route path="/social-share/:uploadToken" element={<SocialSharePage />} />
|
||||
<Route path="/ai-avatar/:uploadToken" element={<AiAvatarPage />} />
|
||||
<Route path="/questions" element={<Questions />} />
|
||||
<Route path="/ask" element={<Navigate to="/questions" replace />} />
|
||||
<Route path="/feedback" element={<FeedbackPage />} />
|
||||
|
|
|
|||
|
|
@ -48,11 +48,15 @@ function formatEventDateRange(ev: PhotoWallPublicEvent): string {
|
|||
export function PhotoWallEventHeader({
|
||||
event,
|
||||
display,
|
||||
/** Small label above the event title. Default "Photo Wall"; pass null/"" to hide. */
|
||||
eyebrow = "Photo Wall",
|
||||
}: {
|
||||
event: PhotoWallPublicEvent;
|
||||
display?: PhotoWallEventHeaderDisplay | null;
|
||||
eyebrow?: string | null;
|
||||
}) {
|
||||
const d = resolveDisplay(display);
|
||||
const eyebrowText = eyebrow == null ? "" : String(eyebrow).trim();
|
||||
const typeLabel = formatEventType(event.eventType);
|
||||
const dateLine = formatEventDateRange(event);
|
||||
const kind = formatLocationKind(event.locationType);
|
||||
|
|
@ -87,7 +91,14 @@ export function PhotoWallEventHeader({
|
|||
);
|
||||
}
|
||||
|
||||
if (!showBanner && !showLogo && !showTitle && !showSubtitle && metaItems.length === 0) {
|
||||
if (
|
||||
!showBanner &&
|
||||
!showLogo &&
|
||||
!showTitle &&
|
||||
!showSubtitle &&
|
||||
metaItems.length === 0 &&
|
||||
!eyebrowText
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
|
|
@ -109,7 +120,9 @@ export function PhotoWallEventHeader({
|
|||
) : null}
|
||||
{showTitle || showSubtitle || metaItems.length > 0 ? (
|
||||
<div className="flex w-full flex-col items-center gap-2">
|
||||
<p className="font-inter text-base font-semibold text-[#24282f]">Photo Wall</p>
|
||||
{eyebrowText ? (
|
||||
<p className="font-inter text-base font-semibold text-[#24282f]">{eyebrowText}</p>
|
||||
) : null}
|
||||
{showTitle ? (
|
||||
<h1 className="font-inter text-[28px] font-bold leading-tight text-[#24282f] sm:text-[32px]">
|
||||
{event.name}
|
||||
|
|
@ -133,9 +146,9 @@ export function PhotoWallEventHeader({
|
|||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
) : (
|
||||
<p className="font-inter text-base font-semibold text-[#24282f]">Photo Wall</p>
|
||||
)}
|
||||
) : eyebrowText ? (
|
||||
<p className="font-inter text-base font-semibold text-[#24282f]">{eyebrowText}</p>
|
||||
) : null}
|
||||
</header>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
248
src/lib/api.ts
248
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<PhotoWa
|
|||
if (!body || typeof body !== "object" || !("data" in body)) throw new Error("Invalid response");
|
||||
return (body as { data: PhotoWallPresentData }).data;
|
||||
}
|
||||
|
||||
export type PhotoSearchMeta = {
|
||||
eventId: string;
|
||||
eventName: string;
|
||||
event?: PhotoWallPublicEvent | null;
|
||||
eventHeader?: PhotoWallEventHeaderDisplay | null;
|
||||
accessMode: "open" | "login";
|
||||
allowDownload: boolean;
|
||||
sharePlatforms?: PhotoWallSharePlatforms;
|
||||
};
|
||||
|
||||
export type PhotoSearchMatch = {
|
||||
_id: string;
|
||||
event: string;
|
||||
organization: string;
|
||||
fileName?: string;
|
||||
originalFileName?: string;
|
||||
url: string;
|
||||
fileUrl: string;
|
||||
mimeType?: string;
|
||||
size?: number;
|
||||
uploadedAt?: string;
|
||||
similarity?: number;
|
||||
};
|
||||
|
||||
export async function getPhotoSearchMeta(searchToken: string): Promise<PhotoSearchMeta> {
|
||||
const base = getBaseUrl();
|
||||
const url = `${base}/api/public/photo-search/${encodeURIComponent(searchToken)}`;
|
||||
const res = await fetch(url);
|
||||
const body = await parseJson(res);
|
||||
if (!res.ok) throw new Error(messageFromBody(body));
|
||||
if (!body || typeof body !== "object" || !("data" in body)) throw new Error("Invalid response");
|
||||
return (body as { data: PhotoSearchMeta }).data;
|
||||
}
|
||||
|
||||
export type SocialSharePublicFrame = {
|
||||
_id: string;
|
||||
name: string;
|
||||
frameImageUrl: string | null;
|
||||
frameCanvasWidth?: number;
|
||||
frameCanvasHeight?: number;
|
||||
/** Photo slot size on the frame canvas — used for upload crop aspect. */
|
||||
photoSlot?: { w: number; h: number } | null;
|
||||
};
|
||||
|
||||
export type SocialShareMeta = {
|
||||
eventId: string;
|
||||
eventName: string;
|
||||
event?: PhotoWallPublicEvent | null;
|
||||
eventHeader?: PhotoWallEventHeaderDisplay | null;
|
||||
accessMode: "open" | "login";
|
||||
requireUploadCrop?: boolean;
|
||||
maxPhotoWidth?: number;
|
||||
maxPhotoHeight?: number;
|
||||
allowDownload: boolean;
|
||||
sharePlatforms?: PhotoWallSharePlatforms;
|
||||
frames: SocialSharePublicFrame[];
|
||||
};
|
||||
|
||||
export async function getSocialShareMeta(uploadToken: string): Promise<SocialShareMeta> {
|
||||
const base = getBaseUrl();
|
||||
const url = `${base}/api/public/social-share/${encodeURIComponent(uploadToken)}`;
|
||||
const res = await fetch(url);
|
||||
const body = await parseJson(res);
|
||||
if (!res.ok) throw new Error(messageFromBody(body));
|
||||
if (!body || typeof body !== "object" || !("data" in body)) throw new Error("Invalid response");
|
||||
return (body as { data: SocialShareMeta }).data;
|
||||
}
|
||||
|
||||
export async function uploadSocialSharePhoto(
|
||||
uploadToken: string,
|
||||
file: File,
|
||||
frameId: string,
|
||||
): Promise<{
|
||||
previewUrl: string | null;
|
||||
fileUrl: string | null;
|
||||
framed: boolean;
|
||||
outputId: string | null;
|
||||
}> {
|
||||
const base = getBaseUrl();
|
||||
const url = `${base}/api/public/social-share/${encodeURIComponent(uploadToken)}`;
|
||||
const fd = new FormData();
|
||||
fd.append("file", file, file.name);
|
||||
fd.append("frameId", frameId);
|
||||
const headers: Record<string, string> = {};
|
||||
const token = getAttendeeToken();
|
||||
if (token) headers.Authorization = `Bearer ${token}`;
|
||||
const res = await fetch(url, { method: "POST", headers, body: fd });
|
||||
const body = await parseJson(res);
|
||||
if (!res.ok) throw new Error(messageFromBody(body));
|
||||
if (!body || typeof body !== "object" || !("data" in body)) throw new Error("Invalid response");
|
||||
const data = (
|
||||
body as {
|
||||
data: {
|
||||
previewUrl?: string;
|
||||
fileUrl?: string;
|
||||
framed?: boolean;
|
||||
outputId?: string;
|
||||
};
|
||||
}
|
||||
).data;
|
||||
const fileUrlRelative = data?.fileUrl || null;
|
||||
const fileUrl = fileUrlRelative
|
||||
? fileUrlRelative.startsWith("http")
|
||||
? fileUrlRelative
|
||||
: `${base}${fileUrlRelative.startsWith("/") ? "" : "/"}${fileUrlRelative}`
|
||||
: data?.previewUrl || null;
|
||||
return {
|
||||
previewUrl: data?.previewUrl || null,
|
||||
fileUrl,
|
||||
framed: Boolean(data?.framed),
|
||||
outputId: data?.outputId ? String(data.outputId) : null,
|
||||
};
|
||||
}
|
||||
|
||||
export type AiAvatarPublicTemplate = {
|
||||
_id: string;
|
||||
name: string;
|
||||
imageUrl: string | null;
|
||||
/** Crop aspect for selfie (default square). */
|
||||
photoSlot?: { w: number; h: number } | null;
|
||||
};
|
||||
|
||||
export type AiAvatarMeta = {
|
||||
eventId: string;
|
||||
eventName: string;
|
||||
event?: PhotoWallPublicEvent | null;
|
||||
eventHeader?: PhotoWallEventHeaderDisplay | null;
|
||||
accessMode: "open" | "login";
|
||||
allowDownload: boolean;
|
||||
sharePlatforms?: PhotoWallSharePlatforms;
|
||||
avatars: AiAvatarPublicTemplate[];
|
||||
};
|
||||
|
||||
export async function getAiAvatarMeta(uploadToken: string): Promise<AiAvatarMeta> {
|
||||
const base = getBaseUrl();
|
||||
const url = `${base}/api/public/ai-avatar/${encodeURIComponent(uploadToken)}`;
|
||||
const res = await fetch(url);
|
||||
const body = await parseJson(res);
|
||||
if (!res.ok) throw new Error(messageFromBody(body));
|
||||
if (!body || typeof body !== "object" || !("data" in body)) throw new Error("Invalid response");
|
||||
return (body as { data: AiAvatarMeta }).data;
|
||||
}
|
||||
|
||||
export async function uploadAiAvatarPhoto(
|
||||
uploadToken: string,
|
||||
file: File,
|
||||
avatarId: string,
|
||||
): Promise<{
|
||||
previewUrl: string | null;
|
||||
fileUrl: string | null;
|
||||
outputId: string | null;
|
||||
}> {
|
||||
const base = getBaseUrl();
|
||||
const url = `${base}/api/public/ai-avatar/${encodeURIComponent(uploadToken)}`;
|
||||
const fd = new FormData();
|
||||
fd.append("file", file, file.name);
|
||||
fd.append("avatarId", avatarId);
|
||||
const headers: Record<string, string> = {};
|
||||
const token = getAttendeeToken();
|
||||
if (token) headers.Authorization = `Bearer ${token}`;
|
||||
const res = await fetch(url, { method: "POST", headers, body: fd });
|
||||
const body = await parseJson(res);
|
||||
if (!res.ok) throw new Error(messageFromBody(body));
|
||||
if (!body || typeof body !== "object" || !("data" in body)) throw new Error("Invalid response");
|
||||
const data = (
|
||||
body as {
|
||||
data: {
|
||||
previewUrl?: string;
|
||||
fileUrl?: string;
|
||||
outputId?: string;
|
||||
};
|
||||
}
|
||||
).data;
|
||||
const fileUrlRelative = data?.fileUrl || null;
|
||||
const fileUrl = fileUrlRelative
|
||||
? fileUrlRelative.startsWith("http")
|
||||
? fileUrlRelative
|
||||
: `${base}${fileUrlRelative.startsWith("/") ? "" : "/"}${fileUrlRelative}`
|
||||
: data?.previewUrl || null;
|
||||
return {
|
||||
previewUrl: data?.previewUrl || null,
|
||||
fileUrl,
|
||||
outputId: data?.outputId ? String(data.outputId) : null,
|
||||
};
|
||||
}
|
||||
|
||||
export async function searchPhotoSearchMatches(
|
||||
searchToken: string,
|
||||
file: File,
|
||||
): Promise<{ collectionExists: boolean; matchCount: number; matches: PhotoSearchMatch[] }> {
|
||||
const base = getBaseUrl();
|
||||
const url = `${base}/api/public/photo-search/${encodeURIComponent(searchToken)}`;
|
||||
const fd = new FormData();
|
||||
fd.append("files", file, file.name);
|
||||
const headers: Record<string, string> = {};
|
||||
const token = getAttendeeToken();
|
||||
if (token) headers.Authorization = `Bearer ${token}`;
|
||||
const res = await fetch(url, { method: "POST", headers, body: fd });
|
||||
const body = await parseJson(res);
|
||||
if (!res.ok) throw new Error(messageFromBody(body));
|
||||
if (!body || typeof body !== "object" || !("data" in body)) throw new Error("Invalid response");
|
||||
const data = (
|
||||
body as {
|
||||
data: {
|
||||
collectionExists?: boolean;
|
||||
matchCount?: number;
|
||||
matches?: Array<Partial<PhotoSearchMatch> & { _id?: string; fileUrl?: string; url?: string }>;
|
||||
};
|
||||
}
|
||||
).data;
|
||||
|
||||
const matches = (data?.matches || []).map((m) => {
|
||||
const id = m._id ? String(m._id) : "";
|
||||
const fileUrlRelative = m.fileUrl || null;
|
||||
const fileUrl = fileUrlRelative
|
||||
? fileUrlRelative.startsWith("http")
|
||||
? fileUrlRelative
|
||||
: `${base}${fileUrlRelative.startsWith("/") ? "" : "/"}${fileUrlRelative}`
|
||||
: typeof m.url === "string" && m.url.trim()
|
||||
? m.url.trim()
|
||||
: "";
|
||||
return {
|
||||
_id: id,
|
||||
event: String(m.event || ""),
|
||||
organization: String(m.organization || ""),
|
||||
fileName: m.fileName,
|
||||
originalFileName: m.originalFileName,
|
||||
// CDN thumb for display — do not fall back to API fileUrl
|
||||
url: typeof m.url === "string" && m.url.trim() ? m.url.trim() : "",
|
||||
fileUrl,
|
||||
mimeType: m.mimeType,
|
||||
size: m.size,
|
||||
uploadedAt: m.uploadedAt,
|
||||
similarity: typeof m.similarity === "number" ? m.similarity : undefined,
|
||||
} satisfies PhotoSearchMatch;
|
||||
});
|
||||
|
||||
return {
|
||||
collectionExists: Boolean(data?.collectionExists),
|
||||
matchCount: typeof data?.matchCount === "number" ? data.matchCount : matches.length,
|
||||
matches,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,8 +1,8 @@
|
|||
/** Download + social share helpers for photo-wall visitor success screen.
|
||||
*
|
||||
* Platform buttons open that app (or its web share intent) — not the iOS system share sheet.
|
||||
* Instagram / Snapchat cannot receive an image via web intent, so we save the photo,
|
||||
* copy the caption, then open the app.
|
||||
* On phones: prefer the OS share sheet (`navigator.share`) so the user picks the app.
|
||||
* On desktop: platform buttons open that app / web intent. Instagram / Snapchat cannot
|
||||
* receive an image via web intent, so we save the photo, copy the caption, then open the app.
|
||||
*/
|
||||
|
||||
export type PhotoWallSharePlatform =
|
||||
|
|
@ -24,21 +24,96 @@ const PLATFORM_APP_HOME: Record<PhotoWallSharePlatform, string> = {
|
|||
whatsapp: "https://wa.me/",
|
||||
};
|
||||
|
||||
function isMobileUa(): boolean {
|
||||
export function isMobileUa(): boolean {
|
||||
if (typeof navigator === "undefined") return false;
|
||||
return /iPhone|iPad|iPod|Android/i.test(navigator.userAgent);
|
||||
const ua = navigator.userAgent || "";
|
||||
const touchMac =
|
||||
/\bMacintosh\b/i.test(ua) &&
|
||||
typeof navigator.maxTouchPoints === "number" &&
|
||||
navigator.maxTouchPoints > 1;
|
||||
return /iPhone|iPad|iPod|Android/i.test(ua) || touchMac;
|
||||
}
|
||||
|
||||
async function fetchImageFile(url: string, filename = "photo-wall.jpg"): Promise<File> {
|
||||
const res = await fetch(url, { mode: "cors" });
|
||||
/** True when we should show one Share button that opens the OS share sheet. */
|
||||
export function canUseNativeShareSheet(): boolean {
|
||||
if (typeof navigator === "undefined") return false;
|
||||
if (!isMobileUa()) return false;
|
||||
return typeof navigator.share === "function";
|
||||
}
|
||||
|
||||
function friendlyFetchError(err: unknown): Error {
|
||||
const msg = err instanceof Error ? err.message : String(err || "");
|
||||
// Safari often surfaces network/CORS failures as "Load failed" / "Failed to fetch"
|
||||
if (/load failed|failed to fetch|networkerror|access control/i.test(msg)) {
|
||||
return new Error("Could not load photo for sharing. Try Download, then share from your photos.");
|
||||
}
|
||||
return err instanceof Error ? err : new Error("Could not load image for sharing");
|
||||
}
|
||||
|
||||
/** Convert HEIC / unknown types to JPEG so iOS `canShare({ files })` accepts them. */
|
||||
export async function ensureShareableImageFile(file: File, filename = "photo-wall.jpg"): Promise<File> {
|
||||
const type = (file.type || "").toLowerCase();
|
||||
if (type === "image/jpeg" || type === "image/png" || type === "image/webp") {
|
||||
return file;
|
||||
}
|
||||
try {
|
||||
const bitmap = await createImageBitmap(file);
|
||||
const canvas = document.createElement("canvas");
|
||||
canvas.width = bitmap.width;
|
||||
canvas.height = bitmap.height;
|
||||
const ctx = canvas.getContext("2d");
|
||||
if (!ctx) return file;
|
||||
ctx.drawImage(bitmap, 0, 0);
|
||||
bitmap.close?.();
|
||||
const blob = await new Promise<Blob | null>((resolve) =>
|
||||
canvas.toBlob((b) => resolve(b), "image/jpeg", 0.92),
|
||||
);
|
||||
if (!blob?.size) return file;
|
||||
return new File([blob], filename.replace(/\.\w+$/, "") + ".jpg", { type: "image/jpeg" });
|
||||
} catch {
|
||||
return file;
|
||||
}
|
||||
}
|
||||
|
||||
export async function fetchImageFile(url: string, filename = "photo-wall.jpg"): Promise<File> {
|
||||
let res: Response;
|
||||
try {
|
||||
res = await fetch(url, { mode: "cors", credentials: "omit", cache: "no-store" });
|
||||
} catch (err) {
|
||||
throw friendlyFetchError(err);
|
||||
}
|
||||
if (!res.ok) throw new Error("Could not load image for sharing");
|
||||
const blob = await res.blob();
|
||||
if (!blob.size) throw new Error("Could not load image for sharing");
|
||||
const type = blob.type && blob.type.startsWith("image/") ? blob.type : "image/jpeg";
|
||||
const ext = type.includes("png") ? "png" : type.includes("webp") ? "webp" : "jpg";
|
||||
const name = filename.replace(/\.\w+$/, "") + `.${ext}`;
|
||||
return new File([blob], name, { type });
|
||||
}
|
||||
|
||||
/** Read pixels from an already-displayed <img> (must be CORS-clean / same-origin). */
|
||||
export async function fileFromHtmlImage(
|
||||
img: HTMLImageElement,
|
||||
filename = "photo-wall.jpg",
|
||||
): Promise<File | null> {
|
||||
if (!img?.naturalWidth || !img.naturalHeight) return null;
|
||||
try {
|
||||
const canvas = document.createElement("canvas");
|
||||
canvas.width = img.naturalWidth;
|
||||
canvas.height = img.naturalHeight;
|
||||
const ctx = canvas.getContext("2d");
|
||||
if (!ctx) return null;
|
||||
ctx.drawImage(img, 0, 0);
|
||||
const blob = await new Promise<Blob | null>((resolve) =>
|
||||
canvas.toBlob((b) => resolve(b), "image/jpeg", 0.92),
|
||||
);
|
||||
if (!blob?.size) return null;
|
||||
return new File([blob], filename.replace(/\.\w+$/, "") + ".jpg", { type: "image/jpeg" });
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export async function downloadImageFromUrl(url: string, filename = "photo-wall.jpg") {
|
||||
const file = await fetchImageFile(url, filename);
|
||||
const objectUrl = URL.createObjectURL(file);
|
||||
|
|
@ -143,13 +218,113 @@ async function saveImageAndOpenApp(
|
|||
return "downloaded";
|
||||
}
|
||||
|
||||
async function resolveShareFile(options: {
|
||||
file?: File | null;
|
||||
imageUrl?: string | null;
|
||||
imgEl?: HTMLImageElement | null;
|
||||
}): Promise<File | null> {
|
||||
if (options.file && options.file.size > 0) return options.file;
|
||||
|
||||
if (options.imageUrl) {
|
||||
try {
|
||||
return await fetchImageFile(options.imageUrl, "photo-wall.jpg");
|
||||
} catch {
|
||||
/* try canvas next */
|
||||
}
|
||||
}
|
||||
|
||||
if (options.imgEl) {
|
||||
const fromImg = await fileFromHtmlImage(options.imgEl);
|
||||
if (fromImg) return fromImg;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Share to a specific platform. Does NOT use navigator.share (that always opens the
|
||||
* iOS system sheet and ignores which button was tapped).
|
||||
* Open the OS share sheet (iOS / Android) with the photo + caption.
|
||||
* Falls back to text/url-only share if the image bytes cannot be loaded.
|
||||
*
|
||||
* Critical: do not `await` network/canvas work before the first `navigator.share`
|
||||
* when a File is already in hand — iOS Safari drops the user gesture and fails
|
||||
* with "Load failed".
|
||||
*/
|
||||
export async function shareViaNativeSheet(options: {
|
||||
imageUrl?: string | null;
|
||||
file?: File | null;
|
||||
imgEl?: HTMLImageElement | null;
|
||||
title?: string;
|
||||
text?: string;
|
||||
}): Promise<SharePhotoWallResult> {
|
||||
const title = options.title || "EventBuzz";
|
||||
const text = options.text || `Check out my photo from ${title}!`;
|
||||
const imageUrl = options.imageUrl || undefined;
|
||||
|
||||
if (typeof navigator.share !== "function") {
|
||||
throw new Error("Sharing is not supported on this device");
|
||||
}
|
||||
|
||||
const readyFile = options.file && options.file.size > 0 ? options.file : null;
|
||||
|
||||
const tryShareFiles = async (file: File): Promise<SharePhotoWallResult | "skip"> => {
|
||||
const payloadWithFiles: ShareData = { title, text, files: [file] };
|
||||
try {
|
||||
const canFiles =
|
||||
typeof navigator.canShare !== "function" || navigator.canShare(payloadWithFiles);
|
||||
if (!canFiles) return "skip";
|
||||
await navigator.share(payloadWithFiles);
|
||||
return "shared";
|
||||
} catch (err) {
|
||||
const name = err instanceof DOMException ? err.name : "";
|
||||
if (name === "AbortError") return "cancelled";
|
||||
return "skip";
|
||||
}
|
||||
};
|
||||
|
||||
// 1) Prefer already-cached bytes — no await before share (keeps iOS user gesture).
|
||||
if (readyFile) {
|
||||
const result = await tryShareFiles(readyFile);
|
||||
if (result !== "skip") return result;
|
||||
}
|
||||
|
||||
// 2) Text/url-only share still before any fetch (gesture-safe fallback).
|
||||
const payloadTextOnly: ShareData = imageUrl
|
||||
? { title, text, url: imageUrl }
|
||||
: { title, text };
|
||||
|
||||
try {
|
||||
await navigator.share(payloadTextOnly);
|
||||
return "shared";
|
||||
} catch (err) {
|
||||
const name = err instanceof DOMException ? err.name : "";
|
||||
if (name === "AbortError") return "cancelled";
|
||||
// Continue — try loading bytes for a second attempt (gesture may already be gone).
|
||||
}
|
||||
|
||||
// 3) Last resort: fetch / canvas, then share (may only work on Android).
|
||||
if (!readyFile) {
|
||||
const file = await resolveShareFile({
|
||||
file: null,
|
||||
imageUrl,
|
||||
imgEl: options.imgEl,
|
||||
});
|
||||
if (file) {
|
||||
const result = await tryShareFiles(file);
|
||||
if (result !== "skip") return result;
|
||||
}
|
||||
}
|
||||
|
||||
throw new Error("Could not share this photo. Try Download, then share from your photos.");
|
||||
}
|
||||
|
||||
/**
|
||||
* Share to a specific platform (desktop / non–share-sheet path).
|
||||
* Does NOT use navigator.share so each button can target a different app.
|
||||
*/
|
||||
export async function sharePhotoWallImage(options: {
|
||||
platform: PhotoWallSharePlatform;
|
||||
imageUrl: string;
|
||||
file?: File | null;
|
||||
title?: string;
|
||||
text?: string;
|
||||
}): Promise<SharePhotoWallResult> {
|
||||
|
|
@ -158,7 +333,7 @@ export async function sharePhotoWallImage(options: {
|
|||
const imageUrl = options.imageUrl;
|
||||
const platform = options.platform;
|
||||
|
||||
const intent = platformShareUrl(platform, imageUrl, text);
|
||||
const intent = imageUrl ? platformShareUrl(platform, imageUrl, text) : null;
|
||||
if (intent) {
|
||||
// Copy caption so users can paste if the app only receives the URL.
|
||||
await copyText(`${text}\n${imageUrl}`);
|
||||
|
|
@ -166,7 +341,13 @@ export async function sharePhotoWallImage(options: {
|
|||
return "shared";
|
||||
}
|
||||
|
||||
// Instagram / Snapchat: save file locally, copy caption, open the app.
|
||||
const file = await fetchImageFile(imageUrl, "photo-wall.jpg");
|
||||
// Instagram / Snapchat (or missing public URL): save file locally, copy caption, open the app.
|
||||
const file =
|
||||
options.file && options.file.size > 0
|
||||
? options.file
|
||||
: imageUrl
|
||||
? await fetchImageFile(imageUrl, "photo-wall.jpg")
|
||||
: null;
|
||||
if (!file) throw new Error("Could not load image for sharing");
|
||||
return saveImageAndOpenApp(platform, file, text);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,664 @@
|
|||
import {
|
||||
useCallback,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
type ReactNode,
|
||||
} from "react";
|
||||
import {
|
||||
Download,
|
||||
Facebook,
|
||||
Instagram,
|
||||
Linkedin,
|
||||
Loader2,
|
||||
Share2,
|
||||
} from "lucide-react";
|
||||
import { useParams } from "react-router-dom";
|
||||
import { AuthShell } from "../components/auth/AuthShell";
|
||||
import { PhotoWallCropModal } from "../components/PhotoWallCropModal";
|
||||
import { PhotoWallEventHeader } from "../components/PhotoWallEventHeader";
|
||||
import {
|
||||
getAiAvatarMeta,
|
||||
getVisitorLoginPreview,
|
||||
uploadAiAvatarPhoto,
|
||||
type AiAvatarMeta,
|
||||
type AiAvatarPublicTemplate,
|
||||
type PhotoWallSharePlatforms,
|
||||
} from "../lib/api";
|
||||
import { getAttendeeParticipant, getAttendeeToken } from "../lib/attendeeAuth";
|
||||
import { getImageNaturalSize, ratiosMatch } from "../lib/cropImage";
|
||||
import {
|
||||
canUseNativeShareSheet,
|
||||
downloadImageFromUrl,
|
||||
ensureShareableImageFile,
|
||||
fetchImageFile,
|
||||
sharePhotoWallImage,
|
||||
shareViaNativeSheet,
|
||||
} from "../lib/photoWallShare";
|
||||
import {
|
||||
EB_VISITOR_CONTEXT_KEY,
|
||||
setPostAuthReturnPath,
|
||||
type EbVisitorContext,
|
||||
} from "../types/visitorContext";
|
||||
|
||||
function WhiteCard({ children }: { children: ReactNode }) {
|
||||
return (
|
||||
<div className="flex w-full max-w-[354px] flex-col items-center">
|
||||
<div className="w-full rounded-xl shadow-[0_8px_12px_6px_rgba(0,0,0,0.15),0_4px_4px_rgba(0,0,0,0.3)]">
|
||||
<div className="rounded-xl bg-white px-6 py-6">{children}</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
type CropSession = {
|
||||
imageSrc: string;
|
||||
sourceFileName: string;
|
||||
};
|
||||
|
||||
type DoneState = {
|
||||
previewUrl: string | null;
|
||||
fileUrl: string | null;
|
||||
};
|
||||
|
||||
const SHARE_LABELS: Array<{ key: keyof PhotoWallSharePlatforms; label: string }> = [
|
||||
{ key: "linkedin", label: "LinkedIn" },
|
||||
{ key: "facebook", label: "Facebook" },
|
||||
{ key: "instagram", label: "Instagram" },
|
||||
{ key: "snapchat", label: "Snapchat" },
|
||||
{ key: "x", label: "X" },
|
||||
{ key: "whatsapp", label: "WhatsApp" },
|
||||
];
|
||||
|
||||
/** Rotating copy while WaveSpeed runs — keeps visitors engaged; not a real status feed. */
|
||||
const GENERATING_PIPELINE_MESSAGES = [
|
||||
"Uploading your selfie…",
|
||||
"Detecting your face…",
|
||||
"Aligning features with the avatar…",
|
||||
"Blending skin tones…",
|
||||
"Matching lighting and shadows…",
|
||||
"Applying AI head swap…",
|
||||
"Refining facial details…",
|
||||
"Smoothing edges…",
|
||||
"Polishing the final look…",
|
||||
"Almost there — finishing your avatar…",
|
||||
];
|
||||
|
||||
function ShareIcon({ platform }: { platform: keyof PhotoWallSharePlatforms }) {
|
||||
if (platform === "linkedin") return <Linkedin className="h-4 w-4" />;
|
||||
if (platform === "facebook") return <Facebook className="h-4 w-4" />;
|
||||
if (platform === "instagram") return <Instagram className="h-4 w-4" />;
|
||||
return <Share2 className="h-4 w-4" />;
|
||||
}
|
||||
|
||||
export function AiAvatarPage() {
|
||||
const { uploadToken } = useParams<{ uploadToken: string }>();
|
||||
const fileRef = useRef<HTMLInputElement>(null);
|
||||
const resultImgRef = useRef<HTMLImageElement>(null);
|
||||
const [meta, setMeta] = useState<AiAvatarMeta | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [err, setErr] = useState<string | null>(null);
|
||||
const [selectedAvatar, setSelectedAvatar] = useState<AiAvatarPublicTemplate | null>(null);
|
||||
const [file, setFile] = useState<File | null>(null);
|
||||
const [previewUrl, setPreviewUrl] = useState<string | null>(null);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [pipelineMsgIndex, setPipelineMsgIndex] = useState(0);
|
||||
const [picking, setPicking] = useState(false);
|
||||
const [done, setDone] = useState<DoneState | null>(null);
|
||||
const [actionMsg, setActionMsg] = useState<string | null>(null);
|
||||
const [cropSession, setCropSession] = useState<CropSession | null>(null);
|
||||
const [shareFile, setShareFile] = useState<File | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!submitting) {
|
||||
setPipelineMsgIndex(0);
|
||||
return;
|
||||
}
|
||||
const id = window.setInterval(() => {
|
||||
setPipelineMsgIndex((i) => Math.min(i + 1, GENERATING_PIPELINE_MESSAGES.length - 1));
|
||||
}, 2800);
|
||||
return () => window.clearInterval(id);
|
||||
}, [submitting]);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
if (!uploadToken) return;
|
||||
setLoading(true);
|
||||
setErr(null);
|
||||
try {
|
||||
const data = await getAiAvatarMeta(uploadToken);
|
||||
setMeta(data);
|
||||
} catch (e) {
|
||||
setMeta(null);
|
||||
setErr(e instanceof Error ? e.message : "AI Avatar not available");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [uploadToken]);
|
||||
|
||||
useEffect(() => {
|
||||
void load();
|
||||
}, [load]);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (previewUrl) URL.revokeObjectURL(previewUrl);
|
||||
};
|
||||
}, [previewUrl]);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (cropSession?.imageSrc) URL.revokeObjectURL(cropSession.imageSrc);
|
||||
};
|
||||
}, [cropSession]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!done?.fileUrl) return;
|
||||
let cancelled = false;
|
||||
void fetchImageFile(done.fileUrl, "ai-avatar.jpg")
|
||||
.then((f) => {
|
||||
if (!cancelled) setShareFile(f);
|
||||
})
|
||||
.catch(() => {
|
||||
/* keep local */
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [done?.fileUrl]);
|
||||
|
||||
const loggedIn = Boolean(getAttendeeToken() && getAttendeeParticipant());
|
||||
const enabledSharePlatforms = useMemo(
|
||||
() => SHARE_LABELS.filter((item) => Boolean(meta?.sharePlatforms?.[item.key])),
|
||||
[meta?.sharePlatforms],
|
||||
);
|
||||
const useNativeShare = useMemo(() => canUseNativeShareSheet(), []);
|
||||
const showShare = enabledSharePlatforms.length > 0;
|
||||
const allowDownload = meta?.allowDownload !== false;
|
||||
const shareCaption = `Check out my AI avatar from ${meta?.eventName || "the event"}!`;
|
||||
|
||||
const clearSelectedPhoto = useCallback(() => {
|
||||
setFile(null);
|
||||
setPreviewUrl((prev) => {
|
||||
if (prev) URL.revokeObjectURL(prev);
|
||||
return null;
|
||||
});
|
||||
if (fileRef.current) fileRef.current.value = "";
|
||||
}, []);
|
||||
|
||||
const applySelectedFile = useCallback((next: File) => {
|
||||
setFile(next);
|
||||
setPreviewUrl((prev) => {
|
||||
if (prev) URL.revokeObjectURL(prev);
|
||||
return URL.createObjectURL(next);
|
||||
});
|
||||
}, []);
|
||||
|
||||
// Square selfie crop for head swap (avatar photoSlot, else 1:1).
|
||||
const cropTarget = useMemo(() => {
|
||||
const slot = selectedAvatar?.photoSlot;
|
||||
if (slot?.w && slot?.h) {
|
||||
return { w: Math.max(1, slot.w), h: Math.max(1, slot.h) };
|
||||
}
|
||||
return { w: 1080, h: 1080 };
|
||||
}, [selectedAvatar]);
|
||||
|
||||
const handleFileChange = async (selected: File | null) => {
|
||||
if (!selected) {
|
||||
clearSelectedPhoto();
|
||||
return;
|
||||
}
|
||||
if (!selected.type.startsWith("image/")) {
|
||||
setErr("Please choose an image file");
|
||||
return;
|
||||
}
|
||||
setErr(null);
|
||||
setPicking(true);
|
||||
try {
|
||||
if (!cropTarget) {
|
||||
applySelectedFile(selected);
|
||||
return;
|
||||
}
|
||||
const natural = await getImageNaturalSize(selected);
|
||||
if (ratiosMatch(natural.width, natural.height, cropTarget.w, cropTarget.h)) {
|
||||
applySelectedFile(selected);
|
||||
return;
|
||||
}
|
||||
const src = URL.createObjectURL(selected);
|
||||
setCropSession({ imageSrc: src, sourceFileName: selected.name || "selfie.jpg" });
|
||||
} catch (e) {
|
||||
setErr(e instanceof Error ? e.message : "Could not process image");
|
||||
} finally {
|
||||
setPicking(false);
|
||||
if (fileRef.current) fileRef.current.value = "";
|
||||
}
|
||||
};
|
||||
|
||||
const onSubmit = async () => {
|
||||
if (!uploadToken || !file || !meta || !selectedAvatar) return;
|
||||
setSubmitting(true);
|
||||
setErr(null);
|
||||
setActionMsg(null);
|
||||
try {
|
||||
const result = await uploadAiAvatarPhoto(uploadToken, file, selectedAvatar._id);
|
||||
setShareFile(null);
|
||||
setDone({
|
||||
previewUrl: result.previewUrl,
|
||||
fileUrl: result.fileUrl,
|
||||
});
|
||||
clearSelectedPhoto();
|
||||
if (result.fileUrl) {
|
||||
void fetchImageFile(result.fileUrl, "ai-avatar.jpg")
|
||||
.then((f) => setShareFile(f))
|
||||
.catch(() => {
|
||||
void ensureShareableImageFile(file, "ai-avatar.jpg").then(setShareFile);
|
||||
});
|
||||
}
|
||||
} catch (e2) {
|
||||
setErr(e2 instanceof Error ? e2.message : "Could not create avatar");
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const resetForNew = () => {
|
||||
setDone(null);
|
||||
setShareFile(null);
|
||||
setActionMsg(null);
|
||||
setErr(null);
|
||||
setSelectedAvatar(null);
|
||||
clearSelectedPhoto();
|
||||
};
|
||||
|
||||
const onDownload = async () => {
|
||||
setActionMsg(null);
|
||||
try {
|
||||
if (shareFile) {
|
||||
const objectUrl = URL.createObjectURL(shareFile);
|
||||
const a = document.createElement("a");
|
||||
a.href = objectUrl;
|
||||
a.download = shareFile.name || "ai-avatar.jpg";
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
a.remove();
|
||||
URL.revokeObjectURL(objectUrl);
|
||||
setActionMsg("Download started");
|
||||
return;
|
||||
}
|
||||
const src = done?.fileUrl || done?.previewUrl;
|
||||
if (!src) return;
|
||||
await downloadImageFromUrl(src, "ai-avatar.jpg");
|
||||
setActionMsg("Download started");
|
||||
} catch (e) {
|
||||
setActionMsg(e instanceof Error ? e.message : "Download failed");
|
||||
}
|
||||
};
|
||||
|
||||
const onNativeShare = async () => {
|
||||
const src = done?.fileUrl || done?.previewUrl;
|
||||
setActionMsg(null);
|
||||
try {
|
||||
const result = await shareViaNativeSheet({
|
||||
file: shareFile,
|
||||
imageUrl: src,
|
||||
imgEl: resultImgRef.current,
|
||||
title: meta?.eventName || "AI Avatar",
|
||||
text: shareCaption,
|
||||
});
|
||||
if (result === "shared") setActionMsg("Shared");
|
||||
else setActionMsg(null);
|
||||
} catch (e) {
|
||||
const msg = e instanceof Error ? e.message : "Share failed";
|
||||
setActionMsg(
|
||||
/load failed|failed to fetch/i.test(msg)
|
||||
? "Could not share this photo. Try Download, then share from your photos."
|
||||
: msg,
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const onShare = async (platform: keyof PhotoWallSharePlatforms) => {
|
||||
const src = done?.fileUrl || done?.previewUrl;
|
||||
if (!src && !shareFile) return;
|
||||
setActionMsg(null);
|
||||
try {
|
||||
const result = await sharePhotoWallImage({
|
||||
platform,
|
||||
imageUrl: src || "",
|
||||
file: shareFile,
|
||||
title: meta?.eventName || "AI Avatar",
|
||||
text: shareCaption,
|
||||
});
|
||||
if (result === "shared") {
|
||||
setActionMsg(
|
||||
platform === "whatsapp" || platform === "facebook" || platform === "x" || platform === "linkedin"
|
||||
? "Opening app with your photo link"
|
||||
: "Shared",
|
||||
);
|
||||
} else if (result === "cancelled") {
|
||||
setActionMsg(null);
|
||||
} else {
|
||||
setActionMsg("Photo saved and caption copied — paste / attach it in the app");
|
||||
}
|
||||
} catch (e) {
|
||||
setActionMsg(e instanceof Error ? e.message : "Share failed");
|
||||
}
|
||||
};
|
||||
|
||||
const goLogin = async () => {
|
||||
if (!meta?.eventId) return;
|
||||
try {
|
||||
const preview = await getVisitorLoginPreview(meta.eventId);
|
||||
const ctx: EbVisitorContext = {
|
||||
type: "event",
|
||||
eventId: meta.eventId,
|
||||
event: preview,
|
||||
fetchedAt: new Date().toISOString(),
|
||||
};
|
||||
localStorage.setItem(EB_VISITOR_CONTEXT_KEY, JSON.stringify(ctx));
|
||||
setPostAuthReturnPath(`/ai-avatar/${uploadToken}`);
|
||||
window.location.assign(`/login?type=event&id=${encodeURIComponent(meta.eventId)}`);
|
||||
} catch {
|
||||
setErr("Could not open login");
|
||||
}
|
||||
};
|
||||
|
||||
if (!uploadToken) {
|
||||
return (
|
||||
<AuthShell showLogo={false}>
|
||||
<WhiteCard>
|
||||
<p className="text-center text-sm text-red-600">Missing link</p>
|
||||
</WhiteCard>
|
||||
</AuthShell>
|
||||
);
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<AuthShell showLogo={false}>
|
||||
<WhiteCard>
|
||||
<p className="text-center text-sm text-[#666]">Loading…</p>
|
||||
</WhiteCard>
|
||||
</AuthShell>
|
||||
);
|
||||
}
|
||||
|
||||
if (err && !meta) {
|
||||
return (
|
||||
<AuthShell showLogo={false}>
|
||||
<WhiteCard>
|
||||
<p className="text-center text-sm text-red-600">{err}</p>
|
||||
</WhiteCard>
|
||||
</AuthShell>
|
||||
);
|
||||
}
|
||||
|
||||
if (submitting) {
|
||||
const step = pipelineMsgIndex + 1;
|
||||
const total = GENERATING_PIPELINE_MESSAGES.length;
|
||||
const progressPct = Math.round((step / total) * 100);
|
||||
return (
|
||||
<AuthShell showLogo={false}>
|
||||
<WhiteCard>
|
||||
{meta?.event ? (
|
||||
<div className="mb-4">
|
||||
<PhotoWallEventHeader
|
||||
event={meta.event}
|
||||
display={meta.eventHeader || null}
|
||||
eyebrow="AI Avatar"
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
<div className="flex flex-col items-center py-4 text-center">
|
||||
<div className="mb-5 flex h-16 w-16 items-center justify-center rounded-full bg-[#fdecea]">
|
||||
<Loader2 className="h-8 w-8 animate-spin text-[#D32F2F]" aria-hidden />
|
||||
</div>
|
||||
<h1 className="mb-2 text-[22px] font-semibold text-[#1a1a1a]">Creating your avatar</h1>
|
||||
<p
|
||||
key={pipelineMsgIndex}
|
||||
className="mb-5 min-h-[2.5rem] text-sm text-[#666] transition-opacity duration-300"
|
||||
aria-live="polite"
|
||||
>
|
||||
{GENERATING_PIPELINE_MESSAGES[pipelineMsgIndex]}
|
||||
</p>
|
||||
<div className="mb-2 h-1.5 w-full overflow-hidden rounded-full bg-[#eee]">
|
||||
<div
|
||||
className="h-full rounded-full bg-[#D32F2F] transition-[width] duration-700 ease-out"
|
||||
style={{ width: `${progressPct}%` }}
|
||||
/>
|
||||
</div>
|
||||
<p className="text-xs text-[#999]">This can take up to a minute — hang tight</p>
|
||||
</div>
|
||||
</WhiteCard>
|
||||
</AuthShell>
|
||||
);
|
||||
}
|
||||
|
||||
if (done) {
|
||||
return (
|
||||
<AuthShell showLogo={false}>
|
||||
<WhiteCard>
|
||||
{meta?.event ? (
|
||||
<div className="mb-4">
|
||||
<PhotoWallEventHeader
|
||||
event={meta.event}
|
||||
display={meta.eventHeader || null}
|
||||
eyebrow="AI Avatar"
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
<h1 className="mb-2 text-center text-[22px] font-semibold text-[#1a1a1a]">Your avatar</h1>
|
||||
<p className="mb-4 text-center text-sm text-[#666]">
|
||||
Here’s your AI avatar. You can download or share it.
|
||||
</p>
|
||||
{(done.fileUrl || done.previewUrl) ? (
|
||||
<div className="mb-4 overflow-hidden rounded-lg border border-[#e5e5e5] bg-[#f8f8f8]">
|
||||
<img
|
||||
ref={resultImgRef}
|
||||
crossOrigin="anonymous"
|
||||
src={done.previewUrl || done.fileUrl || undefined}
|
||||
alt="Your AI avatar"
|
||||
className="mx-auto max-h-[360px] w-full object-contain"
|
||||
onError={(e) => {
|
||||
const img = e.currentTarget;
|
||||
if (img.crossOrigin) {
|
||||
img.crossOrigin = null;
|
||||
img.src = done.previewUrl || done.fileUrl || img.src;
|
||||
return;
|
||||
}
|
||||
if (done.fileUrl && img.src !== done.fileUrl) {
|
||||
img.src = done.fileUrl;
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
<div className="flex flex-col gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={resetForNew}
|
||||
className="inline-flex h-11 w-full items-center justify-center rounded-lg bg-[#D32F2F] px-4 text-sm font-semibold text-white hover:bg-[#b71c1c]"
|
||||
>
|
||||
Create another
|
||||
</button>
|
||||
{allowDownload ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void onDownload()}
|
||||
className="inline-flex h-11 w-full items-center justify-center gap-2 rounded-lg border border-[#ddd] bg-white px-4 text-sm font-semibold text-[#333] hover:bg-[#fafafa]"
|
||||
>
|
||||
<Download className="h-4 w-4" />
|
||||
Download photo
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
{showShare ? (
|
||||
<div className="mt-4">
|
||||
<p className="mb-2 text-center text-xs font-semibold uppercase tracking-wide text-[#888]">
|
||||
Share
|
||||
</p>
|
||||
{useNativeShare ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void onNativeShare()}
|
||||
className="inline-flex h-11 w-full items-center justify-center gap-2 rounded-lg border border-[#ddd] bg-white px-4 text-sm font-semibold text-[#333] hover:bg-[#fafafa]"
|
||||
>
|
||||
<Share2 className="h-4 w-4" />
|
||||
Share
|
||||
</button>
|
||||
) : (
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
{enabledSharePlatforms.map((item) => (
|
||||
<button
|
||||
key={item.key}
|
||||
type="button"
|
||||
onClick={() => void onShare(item.key)}
|
||||
className="inline-flex h-10 items-center justify-center gap-2 rounded-lg border border-[#ddd] bg-white px-3 text-xs font-semibold text-[#333] hover:bg-[#fafafa]"
|
||||
>
|
||||
<ShareIcon platform={item.key} />
|
||||
{item.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : null}
|
||||
{actionMsg ? <p className="mt-3 text-center text-xs text-[#666]">{actionMsg}</p> : null}
|
||||
</WhiteCard>
|
||||
</AuthShell>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<AuthShell showLogo={false}>
|
||||
<WhiteCard>
|
||||
{meta?.event ? (
|
||||
<div className="mb-4">
|
||||
<PhotoWallEventHeader
|
||||
event={meta.event}
|
||||
display={meta.eventHeader || null}
|
||||
eyebrow="AI Avatar"
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{meta?.accessMode === "login" && !loggedIn ? (
|
||||
<div className="space-y-3">
|
||||
<h1 className="mb-1 text-center text-[22px] font-semibold text-[#1a1a1a]">AI Avatar</h1>
|
||||
<p className="text-center text-sm text-[#666]">Sign in to create your AI avatar.</p>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void goLogin()}
|
||||
className="inline-flex h-11 w-full items-center justify-center rounded-lg bg-[#D32F2F] px-4 text-sm font-semibold text-white hover:bg-[#b71c1c]"
|
||||
>
|
||||
Sign in to continue
|
||||
</button>
|
||||
</div>
|
||||
) : !selectedAvatar ? (
|
||||
<div className="space-y-4">
|
||||
<h1 className="mb-1 text-center text-[22px] font-semibold text-[#1a1a1a]">Choose an avatar</h1>
|
||||
<p className="text-center text-sm text-[#666]">Pick an avatar, then upload your selfie.</p>
|
||||
{!meta?.avatars?.length ? (
|
||||
<p className="text-center text-sm text-[#666]">No avatars available yet.</p>
|
||||
) : (
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
{meta.avatars.map((avatar) => (
|
||||
<button
|
||||
key={avatar._id}
|
||||
type="button"
|
||||
onClick={() => setSelectedAvatar(avatar)}
|
||||
className="overflow-hidden rounded-lg border border-[#e5e5e5] bg-[#f8f8f8] text-left hover:border-[#D32F2F]"
|
||||
>
|
||||
<div className="aspect-square w-full bg-white">
|
||||
{avatar.imageUrl ? (
|
||||
<img
|
||||
src={avatar.imageUrl}
|
||||
alt={avatar.name}
|
||||
className="h-full w-full object-contain"
|
||||
/>
|
||||
) : (
|
||||
<div className="flex h-full items-center justify-center text-xs text-[#999]">
|
||||
No preview
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<p className="truncate px-2 py-2 text-center text-xs font-semibold text-[#333]">
|
||||
{avatar.name}
|
||||
</p>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{err ? <p className="text-center text-sm text-red-600">{err}</p> : null}
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setSelectedAvatar(null);
|
||||
clearSelectedPhoto();
|
||||
setErr(null);
|
||||
}}
|
||||
className="text-sm font-semibold text-[#D32F2F]"
|
||||
>
|
||||
← Change avatar
|
||||
</button>
|
||||
<h1 className="mb-1 text-center text-[22px] font-semibold text-[#1a1a1a]">Upload selfie</h1>
|
||||
<p className="text-center text-sm text-[#666]">
|
||||
Avatar: <span className="font-semibold text-[#333]">{selectedAvatar.name}</span>
|
||||
</p>
|
||||
|
||||
<input
|
||||
ref={fileRef}
|
||||
type="file"
|
||||
accept="image/*"
|
||||
capture="user"
|
||||
className="hidden"
|
||||
onChange={(e) => void handleFileChange(e.target.files?.[0] || null)}
|
||||
/>
|
||||
{previewUrl ? (
|
||||
<div className="overflow-hidden rounded-lg border border-[#e5e5e5] bg-[#f8f8f8]">
|
||||
<img src={previewUrl} alt="Your selfie" className="mx-auto max-h-[240px] w-full object-contain" />
|
||||
</div>
|
||||
) : null}
|
||||
<button
|
||||
type="button"
|
||||
disabled={picking}
|
||||
onClick={() => fileRef.current?.click()}
|
||||
className="inline-flex h-11 w-full items-center justify-center rounded-lg border border-[#ddd] bg-white px-4 text-sm font-semibold text-[#333] hover:bg-[#fafafa] disabled:opacity-55"
|
||||
>
|
||||
{picking ? "Processing…" : file ? "Change selfie" : "Choose selfie"}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
disabled={!file || submitting}
|
||||
onClick={() => void onSubmit()}
|
||||
className="inline-flex h-11 w-full items-center justify-center rounded-lg bg-[#D32F2F] px-4 text-sm font-semibold text-white hover:bg-[#b71c1c] disabled:opacity-55"
|
||||
>
|
||||
{submitting ? "Creating avatar…" : "Create avatar"}
|
||||
</button>
|
||||
{err ? <p className="text-center text-sm text-red-600">{err}</p> : null}
|
||||
</div>
|
||||
)}
|
||||
</WhiteCard>
|
||||
|
||||
{cropSession && cropTarget ? (
|
||||
<PhotoWallCropModal
|
||||
imageSrc={cropSession.imageSrc}
|
||||
sourceFileName={cropSession.sourceFileName}
|
||||
aspect={cropTarget.w / cropTarget.h}
|
||||
onCancel={() => {
|
||||
URL.revokeObjectURL(cropSession.imageSrc);
|
||||
setCropSession(null);
|
||||
}}
|
||||
onConfirm={(cropped) => {
|
||||
URL.revokeObjectURL(cropSession.imageSrc);
|
||||
setCropSession(null);
|
||||
applySelectedFile(cropped);
|
||||
}}
|
||||
/>
|
||||
) : null}
|
||||
</AuthShell>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,472 @@
|
|||
import {
|
||||
useCallback,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
type ReactNode,
|
||||
} from "react";
|
||||
import {
|
||||
Download,
|
||||
Facebook,
|
||||
Instagram,
|
||||
Linkedin,
|
||||
Search,
|
||||
Share2,
|
||||
} from "lucide-react";
|
||||
import { useParams } from "react-router-dom";
|
||||
import { AuthShell } from "../components/auth/AuthShell";
|
||||
import { PhotoWallEventHeader } from "../components/PhotoWallEventHeader";
|
||||
import {
|
||||
getPhotoSearchMeta,
|
||||
getVisitorLoginPreview,
|
||||
searchPhotoSearchMatches,
|
||||
type PhotoSearchMatch,
|
||||
type PhotoSearchMeta,
|
||||
type PhotoWallSharePlatforms,
|
||||
} from "../lib/api";
|
||||
import { getAttendeeParticipant, getAttendeeToken } from "../lib/attendeeAuth";
|
||||
import {
|
||||
canUseNativeShareSheet,
|
||||
downloadImageFromUrl,
|
||||
fetchImageFile,
|
||||
sharePhotoWallImage,
|
||||
shareViaNativeSheet,
|
||||
} from "../lib/photoWallShare";
|
||||
import {
|
||||
EB_VISITOR_CONTEXT_KEY,
|
||||
setPostAuthReturnPath,
|
||||
type EbVisitorContext,
|
||||
} from "../types/visitorContext";
|
||||
|
||||
function WhiteCard({ children, wide }: { children: ReactNode; wide?: boolean }) {
|
||||
return (
|
||||
<div className={`flex w-full ${wide ? "max-w-[480px]" : "max-w-[354px]"} flex-col items-center`}>
|
||||
<div className="w-full rounded-xl shadow-[0_8px_12px_6px_rgba(0,0,0,0.15),0_4px_4px_rgba(0,0,0,0.3)]">
|
||||
<div className="rounded-xl bg-white px-6 py-6">{children}</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const SHARE_LABELS: Array<{ key: keyof PhotoWallSharePlatforms; label: string }> = [
|
||||
{ key: "linkedin", label: "LinkedIn" },
|
||||
{ key: "facebook", label: "Facebook" },
|
||||
{ key: "instagram", label: "Instagram" },
|
||||
{ key: "snapchat", label: "Snapchat" },
|
||||
{ key: "x", label: "X" },
|
||||
{ key: "whatsapp", label: "WhatsApp" },
|
||||
];
|
||||
|
||||
function ShareIcon({ platform }: { platform: keyof PhotoWallSharePlatforms }) {
|
||||
if (platform === "linkedin") return <Linkedin className="h-4 w-4" />;
|
||||
if (platform === "facebook") return <Facebook className="h-4 w-4" />;
|
||||
if (platform === "instagram") return <Instagram className="h-4 w-4" />;
|
||||
return <Share2 className="h-4 w-4" />;
|
||||
}
|
||||
|
||||
function MatchCard({
|
||||
match,
|
||||
eventName,
|
||||
allowDownload,
|
||||
showShare,
|
||||
useNativeShare,
|
||||
enabledSharePlatforms,
|
||||
}: {
|
||||
match: PhotoSearchMatch;
|
||||
eventName: string;
|
||||
allowDownload: boolean;
|
||||
showShare: boolean;
|
||||
useNativeShare: boolean;
|
||||
enabledSharePlatforms: Array<{ key: keyof PhotoWallSharePlatforms; label: string }>;
|
||||
}) {
|
||||
const imgRef = useRef<HTMLImageElement>(null);
|
||||
const [shareFile, setShareFile] = useState<File | null>(null);
|
||||
const [actionMsg, setActionMsg] = useState<string | null>(null);
|
||||
const caption = `Check out my photo from ${eventName}!`;
|
||||
/** Grid preview uses CDN thumb (`url`) only — never the API file proxy. */
|
||||
const previewSrc = match.url || undefined;
|
||||
const downloadSrc = match.fileUrl || match.url || undefined;
|
||||
|
||||
useEffect(() => {
|
||||
if (!match.fileUrl) return;
|
||||
let cancelled = false;
|
||||
void fetchImageFile(match.fileUrl, match.originalFileName || "event-photo.jpg")
|
||||
.then((f) => {
|
||||
if (!cancelled) setShareFile(f);
|
||||
})
|
||||
.catch(() => {
|
||||
/* ignore — share can still try URL / canvas */
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [match.fileUrl, match.originalFileName]);
|
||||
|
||||
const onDownload = async () => {
|
||||
setActionMsg(null);
|
||||
try {
|
||||
if (shareFile) {
|
||||
const objectUrl = URL.createObjectURL(shareFile);
|
||||
const a = document.createElement("a");
|
||||
a.href = objectUrl;
|
||||
a.download = shareFile.name || "event-photo.jpg";
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
a.remove();
|
||||
URL.revokeObjectURL(objectUrl);
|
||||
setActionMsg("Download started");
|
||||
return;
|
||||
}
|
||||
if (!downloadSrc) return;
|
||||
await downloadImageFromUrl(downloadSrc, match.originalFileName || "event-photo.jpg");
|
||||
setActionMsg("Download started");
|
||||
} catch (e) {
|
||||
setActionMsg(e instanceof Error ? e.message : "Download failed");
|
||||
}
|
||||
};
|
||||
|
||||
const onNativeShare = async () => {
|
||||
setActionMsg(null);
|
||||
try {
|
||||
const result = await shareViaNativeSheet({
|
||||
file: shareFile,
|
||||
imageUrl: downloadSrc,
|
||||
imgEl: imgRef.current,
|
||||
title: eventName,
|
||||
text: caption,
|
||||
});
|
||||
if (result === "shared") setActionMsg("Shared");
|
||||
else setActionMsg(null);
|
||||
} catch (e) {
|
||||
const msg = e instanceof Error ? e.message : "Share failed";
|
||||
setActionMsg(
|
||||
/load failed|failed to fetch/i.test(msg)
|
||||
? "Could not share this photo. Try Download, then share from your photos."
|
||||
: msg,
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const onShare = async (platform: keyof PhotoWallSharePlatforms) => {
|
||||
if (!downloadSrc && !shareFile) return;
|
||||
setActionMsg(null);
|
||||
try {
|
||||
const result = await sharePhotoWallImage({
|
||||
platform,
|
||||
imageUrl: downloadSrc || "",
|
||||
file: shareFile,
|
||||
title: eventName,
|
||||
text: caption,
|
||||
});
|
||||
if (result === "shared") {
|
||||
setActionMsg(
|
||||
platform === "whatsapp" || platform === "facebook" || platform === "x" || platform === "linkedin"
|
||||
? "Opening app with your photo link"
|
||||
: "Shared",
|
||||
);
|
||||
} else if (result === "cancelled") {
|
||||
setActionMsg(null);
|
||||
} else {
|
||||
setActionMsg("Photo saved and caption copied — paste / attach it in the app");
|
||||
}
|
||||
} catch (e) {
|
||||
setActionMsg(e instanceof Error ? e.message : "Share failed");
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="overflow-hidden rounded-lg border border-[#e5e5e5] bg-[#f8f8f8]">
|
||||
<img
|
||||
ref={imgRef}
|
||||
crossOrigin="anonymous"
|
||||
src={previewSrc || undefined}
|
||||
alt={match.originalFileName || "Matched photo"}
|
||||
className="mx-auto max-h-[220px] w-full object-contain bg-white"
|
||||
onError={(e) => {
|
||||
const img = e.currentTarget;
|
||||
// Retry without CORS for CDN; do not switch to API file proxy for display.
|
||||
if (img.crossOrigin) {
|
||||
img.crossOrigin = null;
|
||||
if (previewSrc) img.src = previewSrc;
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<div className="space-y-2 p-3">
|
||||
{allowDownload ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void onDownload()}
|
||||
className="inline-flex h-9 w-full items-center justify-center gap-2 rounded-lg border border-[#ddd] bg-white px-3 text-xs font-semibold text-[#333] hover:bg-[#fafafa]"
|
||||
>
|
||||
<Download className="h-3.5 w-3.5" />
|
||||
Download
|
||||
</button>
|
||||
) : null}
|
||||
{showShare ? (
|
||||
useNativeShare ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void onNativeShare()}
|
||||
className="inline-flex h-9 w-full items-center justify-center gap-2 rounded-lg border border-[#ddd] bg-white px-3 text-xs font-semibold text-[#333] hover:bg-[#fafafa]"
|
||||
>
|
||||
<Share2 className="h-3.5 w-3.5" />
|
||||
Share
|
||||
</button>
|
||||
) : (
|
||||
<div className="grid grid-cols-2 gap-1.5">
|
||||
{enabledSharePlatforms.map((item) => (
|
||||
<button
|
||||
key={item.key}
|
||||
type="button"
|
||||
onClick={() => void onShare(item.key)}
|
||||
className="inline-flex h-8 items-center justify-center gap-1 rounded-lg border border-[#ddd] bg-white px-2 text-[11px] font-semibold text-[#333] hover:bg-[#fafafa]"
|
||||
>
|
||||
<ShareIcon platform={item.key} />
|
||||
{item.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
) : null}
|
||||
{actionMsg ? <p className="text-center text-[11px] text-[#666]">{actionMsg}</p> : null}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function PhotoSearchPage() {
|
||||
const { searchToken } = useParams<{ searchToken: string }>();
|
||||
const fileRef = useRef<HTMLInputElement>(null);
|
||||
const [meta, setMeta] = useState<PhotoSearchMeta | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [err, setErr] = useState<string | null>(null);
|
||||
const [file, setFile] = useState<File | null>(null);
|
||||
const [previewUrl, setPreviewUrl] = useState<string | null>(null);
|
||||
const [searching, setSearching] = useState(false);
|
||||
const [matches, setMatches] = useState<PhotoSearchMatch[] | null>(null);
|
||||
const [searched, setSearched] = useState(false);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
if (!searchToken) return;
|
||||
setLoading(true);
|
||||
setErr(null);
|
||||
try {
|
||||
const data = await getPhotoSearchMeta(searchToken);
|
||||
setMeta(data);
|
||||
} catch (e) {
|
||||
setMeta(null);
|
||||
setErr(e instanceof Error ? e.message : "Photo search not available");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [searchToken]);
|
||||
|
||||
useEffect(() => {
|
||||
void load();
|
||||
}, [load]);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (previewUrl) URL.revokeObjectURL(previewUrl);
|
||||
};
|
||||
}, [previewUrl]);
|
||||
|
||||
const loggedIn = Boolean(getAttendeeToken() && getAttendeeParticipant());
|
||||
const enabledSharePlatforms = useMemo(
|
||||
() => SHARE_LABELS.filter((item) => Boolean(meta?.sharePlatforms?.[item.key])),
|
||||
[meta?.sharePlatforms],
|
||||
);
|
||||
const useNativeShare = useMemo(() => canUseNativeShareSheet(), []);
|
||||
const showShare = enabledSharePlatforms.length > 0;
|
||||
const allowDownload = meta?.allowDownload !== false;
|
||||
|
||||
const clearFile = () => {
|
||||
setFile(null);
|
||||
setPreviewUrl((prev) => {
|
||||
if (prev) URL.revokeObjectURL(prev);
|
||||
return null;
|
||||
});
|
||||
if (fileRef.current) fileRef.current.value = "";
|
||||
};
|
||||
|
||||
const onPick = (selected: File | null) => {
|
||||
if (!selected) {
|
||||
clearFile();
|
||||
return;
|
||||
}
|
||||
if (!selected.type.startsWith("image/")) {
|
||||
setErr("Please choose an image file");
|
||||
return;
|
||||
}
|
||||
setErr(null);
|
||||
setMatches(null);
|
||||
setSearched(false);
|
||||
setFile(selected);
|
||||
setPreviewUrl((prev) => {
|
||||
if (prev) URL.revokeObjectURL(prev);
|
||||
return URL.createObjectURL(selected);
|
||||
});
|
||||
};
|
||||
|
||||
const onSearch = async () => {
|
||||
if (!searchToken || !file || !meta) return;
|
||||
setSearching(true);
|
||||
setErr(null);
|
||||
try {
|
||||
const result = await searchPhotoSearchMatches(searchToken, file);
|
||||
setMatches(result.matches);
|
||||
setSearched(true);
|
||||
} catch (e) {
|
||||
setMatches(null);
|
||||
setSearched(false);
|
||||
setErr(e instanceof Error ? e.message : "Search failed");
|
||||
} finally {
|
||||
setSearching(false);
|
||||
}
|
||||
};
|
||||
|
||||
const goLogin = async () => {
|
||||
if (!meta?.eventId) return;
|
||||
try {
|
||||
const preview = await getVisitorLoginPreview(meta.eventId);
|
||||
const ctx: EbVisitorContext = {
|
||||
type: "event",
|
||||
eventId: meta.eventId,
|
||||
event: preview,
|
||||
fetchedAt: new Date().toISOString(),
|
||||
};
|
||||
localStorage.setItem(EB_VISITOR_CONTEXT_KEY, JSON.stringify(ctx));
|
||||
setPostAuthReturnPath(`/photo-search/${searchToken}`);
|
||||
window.location.assign(`/login?type=event&id=${encodeURIComponent(meta.eventId)}`);
|
||||
} catch {
|
||||
setErr("Could not open login");
|
||||
}
|
||||
};
|
||||
|
||||
if (!searchToken) {
|
||||
return (
|
||||
<AuthShell showLogo={false}>
|
||||
<WhiteCard>
|
||||
<p className="text-center text-sm text-red-600">Missing search link</p>
|
||||
</WhiteCard>
|
||||
</AuthShell>
|
||||
);
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<AuthShell showLogo={false}>
|
||||
<WhiteCard>
|
||||
<p className="text-center text-sm text-[#666]">Loading…</p>
|
||||
</WhiteCard>
|
||||
</AuthShell>
|
||||
);
|
||||
}
|
||||
|
||||
if (err && !meta) {
|
||||
return (
|
||||
<AuthShell showLogo={false}>
|
||||
<WhiteCard>
|
||||
<p className="text-center text-sm text-red-600">{err}</p>
|
||||
</WhiteCard>
|
||||
</AuthShell>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<AuthShell showLogo={false}>
|
||||
<WhiteCard wide={Boolean(matches && matches.length > 0)}>
|
||||
{meta?.event ? (
|
||||
<div className="mb-4">
|
||||
<PhotoWallEventHeader
|
||||
event={meta.event}
|
||||
display={meta.eventHeader || null}
|
||||
eyebrow="Photo Search"
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<h1 className="mb-1 text-center text-[22px] font-semibold text-[#1a1a1a]">Find Photos</h1>
|
||||
<p className="mb-5 text-center text-sm text-[#666]">
|
||||
Upload a selfie to find photos of you from {meta?.eventName || "the event"}.
|
||||
</p>
|
||||
|
||||
{meta?.accessMode === "login" && !loggedIn ? (
|
||||
<div className="space-y-3">
|
||||
<p className="text-center text-sm text-[#666]">Sign in to search for your photos.</p>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void goLogin()}
|
||||
className="inline-flex h-11 w-full items-center justify-center rounded-lg bg-[#D32F2F] px-4 text-sm font-semibold text-white hover:bg-[#b71c1c]"
|
||||
>
|
||||
Sign in to continue
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<input
|
||||
ref={fileRef}
|
||||
type="file"
|
||||
accept="image/*"
|
||||
capture="user"
|
||||
className="hidden"
|
||||
onChange={(e) => onPick(e.target.files?.[0] || null)}
|
||||
/>
|
||||
{previewUrl ? (
|
||||
<div className="mb-3 overflow-hidden rounded-lg border border-[#e5e5e5] bg-[#f8f8f8]">
|
||||
<img src={previewUrl} alt="Your selfie" className="mx-auto max-h-[240px] w-full object-contain" />
|
||||
</div>
|
||||
) : null}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => fileRef.current?.click()}
|
||||
className="inline-flex h-11 w-full items-center justify-center gap-2 rounded-lg border border-[#ddd] bg-white px-4 text-sm font-semibold text-[#333] hover:bg-[#fafafa]"
|
||||
>
|
||||
{file ? "Change photo" : "Choose photo"}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
disabled={!file || searching}
|
||||
onClick={() => void onSearch()}
|
||||
className="inline-flex h-11 w-full items-center justify-center gap-2 rounded-lg bg-[#D32F2F] px-4 text-sm font-semibold text-white hover:bg-[#b71c1c] disabled:opacity-55"
|
||||
>
|
||||
<Search className="h-4 w-4" />
|
||||
{searching ? "Searching…" : "Find photos"}
|
||||
</button>
|
||||
|
||||
{err ? <p className="text-center text-sm text-red-600">{err}</p> : null}
|
||||
|
||||
{searched ? (
|
||||
<div className="space-y-3 pt-2">
|
||||
<p className="text-center text-sm font-semibold text-[#333]">
|
||||
{matches && matches.length > 0
|
||||
? `${matches.length} match${matches.length === 1 ? "" : "es"}`
|
||||
: "No matches found"}
|
||||
</p>
|
||||
{matches && matches.length > 0 ? (
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
{matches.map((m) => (
|
||||
<MatchCard
|
||||
key={m._id}
|
||||
match={m}
|
||||
eventName={meta?.eventName || "the event"}
|
||||
allowDownload={allowDownload}
|
||||
showShare={showShare}
|
||||
useNativeShare={useNativeShare}
|
||||
enabledSharePlatforms={enabledSharePlatforms}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
)}
|
||||
</WhiteCard>
|
||||
</AuthShell>
|
||||
);
|
||||
}
|
||||
|
|
@ -29,7 +29,14 @@ import {
|
|||
} from "../lib/api";
|
||||
import { getAttendeeParticipant, getAttendeeToken } from "../lib/attendeeAuth";
|
||||
import { getImageNaturalSize, ratiosMatch } from "../lib/cropImage";
|
||||
import { downloadImageFromUrl, sharePhotoWallImage } from "../lib/photoWallShare";
|
||||
import {
|
||||
canUseNativeShareSheet,
|
||||
downloadImageFromUrl,
|
||||
fetchImageFile,
|
||||
ensureShareableImageFile,
|
||||
sharePhotoWallImage,
|
||||
shareViaNativeSheet,
|
||||
} from "../lib/photoWallShare";
|
||||
import {
|
||||
EB_VISITOR_CONTEXT_KEY,
|
||||
setPostAuthReturnPath,
|
||||
|
|
@ -82,6 +89,7 @@ function ShareIcon({ platform }: { platform: keyof PhotoWallSharePlatforms }) {
|
|||
export function PhotoWallUploadPage() {
|
||||
const { uploadToken } = useParams<{ uploadToken: string }>();
|
||||
const fileRef = useRef<HTMLInputElement>(null);
|
||||
const resultImgRef = useRef<HTMLImageElement>(null);
|
||||
const [meta, setMeta] = useState<PhotoWallUploadMeta | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [err, setErr] = useState<string | null>(null);
|
||||
|
|
@ -94,6 +102,8 @@ export function PhotoWallUploadPage() {
|
|||
const [done, setDone] = useState<UploadDoneState | null>(null);
|
||||
const [actionMsg, setActionMsg] = useState<string | null>(null);
|
||||
const [cropSession, setCropSession] = useState<CropSession | null>(null);
|
||||
/** Cached bytes for share/download — avoids CORS/network fail when opening the OS sheet. */
|
||||
const [shareFile, setShareFile] = useState<File | null>(null);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
if (!uploadToken) return;
|
||||
|
|
@ -136,6 +146,7 @@ export function PhotoWallUploadPage() {
|
|||
() => SHARE_LABELS.filter((item) => Boolean(meta?.sharePlatforms?.[item.key])),
|
||||
[meta?.sharePlatforms],
|
||||
);
|
||||
const useNativeShare = useMemo(() => canUseNativeShareSheet(), []);
|
||||
|
||||
const clearSelectedPhoto = useCallback(() => {
|
||||
setFile(null);
|
||||
|
|
@ -146,8 +157,24 @@ export function PhotoWallUploadPage() {
|
|||
if (fileRef.current) fileRef.current.value = "";
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!done?.fileUrl) return;
|
||||
let cancelled = false;
|
||||
void fetchImageFile(done.fileUrl, "photo-wall.jpg")
|
||||
.then((f) => {
|
||||
if (!cancelled) setShareFile(f);
|
||||
})
|
||||
.catch(() => {
|
||||
/* keep any local shareFile from upload */
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [done?.fileUrl]);
|
||||
|
||||
const resetForNewUpload = useCallback(() => {
|
||||
setDone(null);
|
||||
setShareFile(null);
|
||||
setActionMsg(null);
|
||||
setErr(null);
|
||||
clearSelectedPhoto();
|
||||
|
|
@ -206,9 +233,12 @@ export function PhotoWallUploadPage() {
|
|||
setErr(null);
|
||||
setActionMsg(null);
|
||||
try {
|
||||
const localFile = file;
|
||||
const result = await uploadPhotoWallSelfie(uploadToken, file, values, {
|
||||
displayName: meta.askUploaderName ? uploaderName.trim() : undefined,
|
||||
});
|
||||
// Keep local bytes immediately so Share works even if the API file proxy fails (CORS/network).
|
||||
setShareFile(localFile);
|
||||
setDone({
|
||||
awaitingApproval: result.awaitingApproval,
|
||||
previewUrl: result.previewUrl,
|
||||
|
|
@ -216,6 +246,14 @@ export function PhotoWallUploadPage() {
|
|||
framed: result.framed,
|
||||
});
|
||||
clearSelectedPhoto();
|
||||
// Normalize HEIC/etc. in the background so iOS accepts the file in the OS share sheet.
|
||||
void ensureShareableImageFile(localFile, "photo-wall.jpg").then((shareable) => {
|
||||
setShareFile((prev) => {
|
||||
// Don't clobber a framed file already prefetched from the API.
|
||||
if (prev && prev !== localFile) return prev;
|
||||
return shareable;
|
||||
});
|
||||
});
|
||||
} catch (e2) {
|
||||
setErr(e2 instanceof Error ? e2.message : "Upload failed");
|
||||
} finally {
|
||||
|
|
@ -224,10 +262,22 @@ export function PhotoWallUploadPage() {
|
|||
};
|
||||
|
||||
const onDownload = async () => {
|
||||
const src = done?.fileUrl || done?.previewUrl;
|
||||
if (!src) return;
|
||||
setActionMsg(null);
|
||||
try {
|
||||
if (shareFile) {
|
||||
const objectUrl = URL.createObjectURL(shareFile);
|
||||
const a = document.createElement("a");
|
||||
a.href = objectUrl;
|
||||
a.download = shareFile.name || "photo-wall.jpg";
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
a.remove();
|
||||
URL.revokeObjectURL(objectUrl);
|
||||
setActionMsg("Download started");
|
||||
return;
|
||||
}
|
||||
const src = done?.fileUrl || done?.previewUrl;
|
||||
if (!src) return;
|
||||
await downloadImageFromUrl(src, "photo-wall.jpg");
|
||||
setActionMsg("Download started");
|
||||
} catch (e) {
|
||||
|
|
@ -235,16 +285,43 @@ export function PhotoWallUploadPage() {
|
|||
}
|
||||
};
|
||||
|
||||
const shareCaption = `Check out my photo from ${meta?.eventName || "the event"}!`;
|
||||
|
||||
const onNativeShare = async () => {
|
||||
const src = done?.fileUrl || done?.previewUrl;
|
||||
setActionMsg(null);
|
||||
try {
|
||||
// Pass cached File first so share runs without a prior network await (iOS gesture).
|
||||
const result = await shareViaNativeSheet({
|
||||
file: shareFile,
|
||||
imageUrl: src,
|
||||
imgEl: resultImgRef.current,
|
||||
title: meta?.eventName || "Photo wall",
|
||||
text: shareCaption,
|
||||
});
|
||||
if (result === "shared") setActionMsg("Shared");
|
||||
else setActionMsg(null);
|
||||
} catch (e) {
|
||||
const msg = e instanceof Error ? e.message : "Share failed";
|
||||
setActionMsg(
|
||||
/load failed|failed to fetch/i.test(msg)
|
||||
? "Could not share this photo. Try Download, then share from your photos."
|
||||
: msg,
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const onShare = async (platform: keyof PhotoWallSharePlatforms) => {
|
||||
const src = done?.fileUrl || done?.previewUrl;
|
||||
if (!src) return;
|
||||
if (!src && !shareFile) return;
|
||||
setActionMsg(null);
|
||||
try {
|
||||
const result = await sharePhotoWallImage({
|
||||
platform,
|
||||
imageUrl: src,
|
||||
imageUrl: src || "",
|
||||
file: shareFile,
|
||||
title: meta?.eventName || "Photo wall",
|
||||
text: `Check out my photo from ${meta?.eventName || "the event"}!`,
|
||||
text: shareCaption,
|
||||
});
|
||||
if (result === "shared") {
|
||||
setActionMsg(
|
||||
|
|
@ -334,13 +411,21 @@ export function PhotoWallUploadPage() {
|
|||
{(done.fileUrl || done.previewUrl) ? (
|
||||
<div className="mb-4 overflow-hidden rounded-lg border border-[#e5e5e5] bg-[#f8f8f8]">
|
||||
<img
|
||||
src={done.fileUrl || done.previewUrl || undefined}
|
||||
ref={resultImgRef}
|
||||
crossOrigin="anonymous"
|
||||
src={done.previewUrl || done.fileUrl || undefined}
|
||||
alt={done.framed ? "Your framed photo" : "Uploaded photo"}
|
||||
className="mx-auto max-h-[360px] w-full object-contain"
|
||||
onError={(e) => {
|
||||
const img = e.currentTarget;
|
||||
if (done.previewUrl && img.src !== done.previewUrl) {
|
||||
img.src = done.previewUrl;
|
||||
// Retry without CORS mode if CDN blocks anonymous reads.
|
||||
if (img.crossOrigin) {
|
||||
img.crossOrigin = null;
|
||||
img.src = done.previewUrl || done.fileUrl || img.src;
|
||||
return;
|
||||
}
|
||||
if (done.fileUrl && img.src !== done.fileUrl) {
|
||||
img.src = done.fileUrl;
|
||||
}
|
||||
}}
|
||||
/>
|
||||
|
|
@ -371,19 +456,30 @@ export function PhotoWallUploadPage() {
|
|||
<p className="mb-2 text-center text-xs font-semibold uppercase tracking-wide text-[#888]">
|
||||
Share
|
||||
</p>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
{enabledSharePlatforms.map((item) => (
|
||||
<button
|
||||
key={item.key}
|
||||
type="button"
|
||||
onClick={() => void onShare(item.key)}
|
||||
className="inline-flex h-10 items-center justify-center gap-2 rounded-lg border border-[#ddd] bg-white px-3 text-xs font-semibold text-[#333] hover:bg-[#fafafa]"
|
||||
>
|
||||
<ShareIcon platform={item.key} />
|
||||
{item.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
{useNativeShare ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void onNativeShare()}
|
||||
className="inline-flex h-11 w-full items-center justify-center gap-2 rounded-lg border border-[#ddd] bg-white px-4 text-sm font-semibold text-[#333] hover:bg-[#fafafa]"
|
||||
>
|
||||
<Share2 className="h-4 w-4" />
|
||||
Share
|
||||
</button>
|
||||
) : (
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
{enabledSharePlatforms.map((item) => (
|
||||
<button
|
||||
key={item.key}
|
||||
type="button"
|
||||
onClick={() => void onShare(item.key)}
|
||||
className="inline-flex h-10 items-center justify-center gap-2 rounded-lg border border-[#ddd] bg-white px-3 text-xs font-semibold text-[#333] hover:bg-[#fafafa]"
|
||||
>
|
||||
<ShareIcon platform={item.key} />
|
||||
{item.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : null}
|
||||
{actionMsg ? <p className="mt-3 text-center text-xs text-[#666]">{actionMsg}</p> : null}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,604 @@
|
|||
import {
|
||||
useCallback,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
type ReactNode,
|
||||
} from "react";
|
||||
import {
|
||||
Download,
|
||||
Facebook,
|
||||
Instagram,
|
||||
Linkedin,
|
||||
Share2,
|
||||
} from "lucide-react";
|
||||
import { useParams } from "react-router-dom";
|
||||
import { AuthShell } from "../components/auth/AuthShell";
|
||||
import { PhotoWallCropModal } from "../components/PhotoWallCropModal";
|
||||
import { PhotoWallEventHeader } from "../components/PhotoWallEventHeader";
|
||||
import {
|
||||
getSocialShareMeta,
|
||||
getVisitorLoginPreview,
|
||||
uploadSocialSharePhoto,
|
||||
type PhotoWallSharePlatforms,
|
||||
type SocialShareMeta,
|
||||
type SocialSharePublicFrame,
|
||||
} from "../lib/api";
|
||||
import { getAttendeeParticipant, getAttendeeToken } from "../lib/attendeeAuth";
|
||||
import { getImageNaturalSize, ratiosMatch } from "../lib/cropImage";
|
||||
import {
|
||||
canUseNativeShareSheet,
|
||||
downloadImageFromUrl,
|
||||
ensureShareableImageFile,
|
||||
fetchImageFile,
|
||||
sharePhotoWallImage,
|
||||
shareViaNativeSheet,
|
||||
} from "../lib/photoWallShare";
|
||||
import {
|
||||
EB_VISITOR_CONTEXT_KEY,
|
||||
setPostAuthReturnPath,
|
||||
type EbVisitorContext,
|
||||
} from "../types/visitorContext";
|
||||
|
||||
function WhiteCard({ children }: { children: ReactNode }) {
|
||||
return (
|
||||
<div className="flex w-full max-w-[354px] flex-col items-center">
|
||||
<div className="w-full rounded-xl shadow-[0_8px_12px_6px_rgba(0,0,0,0.15),0_4px_4px_rgba(0,0,0,0.3)]">
|
||||
<div className="rounded-xl bg-white px-6 py-6">{children}</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
type CropSession = {
|
||||
imageSrc: string;
|
||||
sourceFileName: string;
|
||||
};
|
||||
|
||||
type DoneState = {
|
||||
previewUrl: string | null;
|
||||
fileUrl: string | null;
|
||||
};
|
||||
|
||||
const SHARE_LABELS: Array<{ key: keyof PhotoWallSharePlatforms; label: string }> = [
|
||||
{ key: "linkedin", label: "LinkedIn" },
|
||||
{ key: "facebook", label: "Facebook" },
|
||||
{ key: "instagram", label: "Instagram" },
|
||||
{ key: "snapchat", label: "Snapchat" },
|
||||
{ key: "x", label: "X" },
|
||||
{ key: "whatsapp", label: "WhatsApp" },
|
||||
];
|
||||
|
||||
function ShareIcon({ platform }: { platform: keyof PhotoWallSharePlatforms }) {
|
||||
if (platform === "linkedin") return <Linkedin className="h-4 w-4" />;
|
||||
if (platform === "facebook") return <Facebook className="h-4 w-4" />;
|
||||
if (platform === "instagram") return <Instagram className="h-4 w-4" />;
|
||||
return <Share2 className="h-4 w-4" />;
|
||||
}
|
||||
|
||||
export function SocialSharePage() {
|
||||
const { uploadToken } = useParams<{ uploadToken: string }>();
|
||||
const fileRef = useRef<HTMLInputElement>(null);
|
||||
const resultImgRef = useRef<HTMLImageElement>(null);
|
||||
const [meta, setMeta] = useState<SocialShareMeta | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [err, setErr] = useState<string | null>(null);
|
||||
const [selectedFrame, setSelectedFrame] = useState<SocialSharePublicFrame | null>(null);
|
||||
const [file, setFile] = useState<File | null>(null);
|
||||
const [previewUrl, setPreviewUrl] = useState<string | null>(null);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [picking, setPicking] = useState(false);
|
||||
const [done, setDone] = useState<DoneState | null>(null);
|
||||
const [actionMsg, setActionMsg] = useState<string | null>(null);
|
||||
const [cropSession, setCropSession] = useState<CropSession | null>(null);
|
||||
const [shareFile, setShareFile] = useState<File | null>(null);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
if (!uploadToken) return;
|
||||
setLoading(true);
|
||||
setErr(null);
|
||||
try {
|
||||
const data = await getSocialShareMeta(uploadToken);
|
||||
setMeta(data);
|
||||
} catch (e) {
|
||||
setMeta(null);
|
||||
setErr(e instanceof Error ? e.message : "Social share not available");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [uploadToken]);
|
||||
|
||||
useEffect(() => {
|
||||
void load();
|
||||
}, [load]);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (previewUrl) URL.revokeObjectURL(previewUrl);
|
||||
};
|
||||
}, [previewUrl]);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (cropSession?.imageSrc) URL.revokeObjectURL(cropSession.imageSrc);
|
||||
};
|
||||
}, [cropSession]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!done?.fileUrl) return;
|
||||
let cancelled = false;
|
||||
void fetchImageFile(done.fileUrl, "social-share.jpg")
|
||||
.then((f) => {
|
||||
if (!cancelled) setShareFile(f);
|
||||
})
|
||||
.catch(() => {
|
||||
/* keep local */
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [done?.fileUrl]);
|
||||
|
||||
const loggedIn = Boolean(getAttendeeToken() && getAttendeeParticipant());
|
||||
const enabledSharePlatforms = useMemo(
|
||||
() => SHARE_LABELS.filter((item) => Boolean(meta?.sharePlatforms?.[item.key])),
|
||||
[meta?.sharePlatforms],
|
||||
);
|
||||
const useNativeShare = useMemo(() => canUseNativeShareSheet(), []);
|
||||
const showShare = enabledSharePlatforms.length > 0;
|
||||
const allowDownload = meta?.allowDownload !== false;
|
||||
const shareCaption = `Check out my photo from ${meta?.eventName || "the event"}!`;
|
||||
|
||||
const clearSelectedPhoto = useCallback(() => {
|
||||
setFile(null);
|
||||
setPreviewUrl((prev) => {
|
||||
if (prev) URL.revokeObjectURL(prev);
|
||||
return null;
|
||||
});
|
||||
if (fileRef.current) fileRef.current.value = "";
|
||||
}, []);
|
||||
|
||||
const applySelectedFile = useCallback((next: File) => {
|
||||
setFile(next);
|
||||
setPreviewUrl((prev) => {
|
||||
if (prev) URL.revokeObjectURL(prev);
|
||||
return URL.createObjectURL(next);
|
||||
});
|
||||
}, []);
|
||||
|
||||
const cropTarget = useMemo(() => {
|
||||
const slot = selectedFrame?.photoSlot;
|
||||
if (slot?.w && slot?.h) {
|
||||
return { w: Math.max(1, slot.w), h: Math.max(1, slot.h), fromFrame: true as const };
|
||||
}
|
||||
if (meta?.requireUploadCrop) {
|
||||
return {
|
||||
w: Math.max(1, Number(meta.maxPhotoWidth) || 1080),
|
||||
h: Math.max(1, Number(meta.maxPhotoHeight) || 1080),
|
||||
fromFrame: false as const,
|
||||
};
|
||||
}
|
||||
return null;
|
||||
}, [selectedFrame, meta?.requireUploadCrop, meta?.maxPhotoWidth, meta?.maxPhotoHeight]);
|
||||
|
||||
const handleFileChange = async (selected: File | null) => {
|
||||
if (!selected) {
|
||||
clearSelectedPhoto();
|
||||
return;
|
||||
}
|
||||
if (!selected.type.startsWith("image/")) {
|
||||
setErr("Please choose an image file");
|
||||
return;
|
||||
}
|
||||
setErr(null);
|
||||
setPicking(true);
|
||||
try {
|
||||
// Prefer the selected frame's photo-slot aspect (e.g. square slot → crop to 1:1).
|
||||
// Fall back to Settings → require crop + max W/H when no slot is defined.
|
||||
if (!cropTarget) {
|
||||
applySelectedFile(selected);
|
||||
return;
|
||||
}
|
||||
const natural = await getImageNaturalSize(selected);
|
||||
if (ratiosMatch(natural.width, natural.height, cropTarget.w, cropTarget.h)) {
|
||||
applySelectedFile(selected);
|
||||
return;
|
||||
}
|
||||
const src = URL.createObjectURL(selected);
|
||||
setCropSession({ imageSrc: src, sourceFileName: selected.name || "photo.jpg" });
|
||||
} catch (e) {
|
||||
setErr(e instanceof Error ? e.message : "Could not process image");
|
||||
} finally {
|
||||
setPicking(false);
|
||||
if (fileRef.current) fileRef.current.value = "";
|
||||
}
|
||||
};
|
||||
|
||||
const onSubmit = async () => {
|
||||
if (!uploadToken || !file || !meta || !selectedFrame) return;
|
||||
setSubmitting(true);
|
||||
setErr(null);
|
||||
setActionMsg(null);
|
||||
try {
|
||||
const result = await uploadSocialSharePhoto(uploadToken, file, selectedFrame._id);
|
||||
setShareFile(null);
|
||||
setDone({
|
||||
previewUrl: result.previewUrl,
|
||||
fileUrl: result.fileUrl,
|
||||
});
|
||||
clearSelectedPhoto();
|
||||
if (result.fileUrl) {
|
||||
void fetchImageFile(result.fileUrl, "social-share.jpg")
|
||||
.then((f) => setShareFile(f))
|
||||
.catch(() => {
|
||||
void ensureShareableImageFile(file, "social-share.jpg").then(setShareFile);
|
||||
});
|
||||
}
|
||||
} catch (e2) {
|
||||
setErr(e2 instanceof Error ? e2.message : "Upload failed");
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const resetForNew = () => {
|
||||
setDone(null);
|
||||
setShareFile(null);
|
||||
setActionMsg(null);
|
||||
setErr(null);
|
||||
setSelectedFrame(null);
|
||||
clearSelectedPhoto();
|
||||
};
|
||||
|
||||
const onDownload = async () => {
|
||||
setActionMsg(null);
|
||||
try {
|
||||
if (shareFile) {
|
||||
const objectUrl = URL.createObjectURL(shareFile);
|
||||
const a = document.createElement("a");
|
||||
a.href = objectUrl;
|
||||
a.download = shareFile.name || "social-share.jpg";
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
a.remove();
|
||||
URL.revokeObjectURL(objectUrl);
|
||||
setActionMsg("Download started");
|
||||
return;
|
||||
}
|
||||
const src = done?.fileUrl || done?.previewUrl;
|
||||
if (!src) return;
|
||||
await downloadImageFromUrl(src, "social-share.jpg");
|
||||
setActionMsg("Download started");
|
||||
} catch (e) {
|
||||
setActionMsg(e instanceof Error ? e.message : "Download failed");
|
||||
}
|
||||
};
|
||||
|
||||
const onNativeShare = async () => {
|
||||
const src = done?.fileUrl || done?.previewUrl;
|
||||
setActionMsg(null);
|
||||
try {
|
||||
const result = await shareViaNativeSheet({
|
||||
file: shareFile,
|
||||
imageUrl: src,
|
||||
imgEl: resultImgRef.current,
|
||||
title: meta?.eventName || "Social Share",
|
||||
text: shareCaption,
|
||||
});
|
||||
if (result === "shared") setActionMsg("Shared");
|
||||
else setActionMsg(null);
|
||||
} catch (e) {
|
||||
const msg = e instanceof Error ? e.message : "Share failed";
|
||||
setActionMsg(
|
||||
/load failed|failed to fetch/i.test(msg)
|
||||
? "Could not share this photo. Try Download, then share from your photos."
|
||||
: msg,
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const onShare = async (platform: keyof PhotoWallSharePlatforms) => {
|
||||
const src = done?.fileUrl || done?.previewUrl;
|
||||
if (!src && !shareFile) return;
|
||||
setActionMsg(null);
|
||||
try {
|
||||
const result = await sharePhotoWallImage({
|
||||
platform,
|
||||
imageUrl: src || "",
|
||||
file: shareFile,
|
||||
title: meta?.eventName || "Social Share",
|
||||
text: shareCaption,
|
||||
});
|
||||
if (result === "shared") {
|
||||
setActionMsg(
|
||||
platform === "whatsapp" || platform === "facebook" || platform === "x" || platform === "linkedin"
|
||||
? "Opening app with your photo link"
|
||||
: "Shared",
|
||||
);
|
||||
} else if (result === "cancelled") {
|
||||
setActionMsg(null);
|
||||
} else {
|
||||
setActionMsg("Photo saved and caption copied — paste / attach it in the app");
|
||||
}
|
||||
} catch (e) {
|
||||
setActionMsg(e instanceof Error ? e.message : "Share failed");
|
||||
}
|
||||
};
|
||||
|
||||
const goLogin = async () => {
|
||||
if (!meta?.eventId) return;
|
||||
try {
|
||||
const preview = await getVisitorLoginPreview(meta.eventId);
|
||||
const ctx: EbVisitorContext = {
|
||||
type: "event",
|
||||
eventId: meta.eventId,
|
||||
event: preview,
|
||||
fetchedAt: new Date().toISOString(),
|
||||
};
|
||||
localStorage.setItem(EB_VISITOR_CONTEXT_KEY, JSON.stringify(ctx));
|
||||
setPostAuthReturnPath(`/social-share/${uploadToken}`);
|
||||
window.location.assign(`/login?type=event&id=${encodeURIComponent(meta.eventId)}`);
|
||||
} catch {
|
||||
setErr("Could not open login");
|
||||
}
|
||||
};
|
||||
|
||||
if (!uploadToken) {
|
||||
return (
|
||||
<AuthShell showLogo={false}>
|
||||
<WhiteCard>
|
||||
<p className="text-center text-sm text-red-600">Missing link</p>
|
||||
</WhiteCard>
|
||||
</AuthShell>
|
||||
);
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<AuthShell showLogo={false}>
|
||||
<WhiteCard>
|
||||
<p className="text-center text-sm text-[#666]">Loading…</p>
|
||||
</WhiteCard>
|
||||
</AuthShell>
|
||||
);
|
||||
}
|
||||
|
||||
if (err && !meta) {
|
||||
return (
|
||||
<AuthShell showLogo={false}>
|
||||
<WhiteCard>
|
||||
<p className="text-center text-sm text-red-600">{err}</p>
|
||||
</WhiteCard>
|
||||
</AuthShell>
|
||||
);
|
||||
}
|
||||
|
||||
if (done) {
|
||||
return (
|
||||
<AuthShell showLogo={false}>
|
||||
<WhiteCard>
|
||||
{meta?.event ? (
|
||||
<div className="mb-4">
|
||||
<PhotoWallEventHeader
|
||||
event={meta.event}
|
||||
display={meta.eventHeader || null}
|
||||
eyebrow="Social Share"
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
<h1 className="mb-2 text-center text-[22px] font-semibold text-[#1a1a1a]">Your photo</h1>
|
||||
<p className="mb-4 text-center text-sm text-[#666]">
|
||||
Here’s your framed photo. You can download or share it.
|
||||
</p>
|
||||
{(done.fileUrl || done.previewUrl) ? (
|
||||
<div className="mb-4 overflow-hidden rounded-lg border border-[#e5e5e5] bg-[#f8f8f8]">
|
||||
<img
|
||||
ref={resultImgRef}
|
||||
crossOrigin="anonymous"
|
||||
src={done.previewUrl || done.fileUrl || undefined}
|
||||
alt="Your framed photo"
|
||||
className="mx-auto max-h-[360px] w-full object-contain"
|
||||
onError={(e) => {
|
||||
const img = e.currentTarget;
|
||||
if (img.crossOrigin) {
|
||||
img.crossOrigin = null;
|
||||
img.src = done.previewUrl || done.fileUrl || img.src;
|
||||
return;
|
||||
}
|
||||
if (done.fileUrl && img.src !== done.fileUrl) {
|
||||
img.src = done.fileUrl;
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
<div className="flex flex-col gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={resetForNew}
|
||||
className="inline-flex h-11 w-full items-center justify-center rounded-lg bg-[#D32F2F] px-4 text-sm font-semibold text-white hover:bg-[#b71c1c]"
|
||||
>
|
||||
Create another
|
||||
</button>
|
||||
{allowDownload ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void onDownload()}
|
||||
className="inline-flex h-11 w-full items-center justify-center gap-2 rounded-lg border border-[#ddd] bg-white px-4 text-sm font-semibold text-[#333] hover:bg-[#fafafa]"
|
||||
>
|
||||
<Download className="h-4 w-4" />
|
||||
Download photo
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
{showShare ? (
|
||||
<div className="mt-4">
|
||||
<p className="mb-2 text-center text-xs font-semibold uppercase tracking-wide text-[#888]">
|
||||
Share
|
||||
</p>
|
||||
{useNativeShare ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void onNativeShare()}
|
||||
className="inline-flex h-11 w-full items-center justify-center gap-2 rounded-lg border border-[#ddd] bg-white px-4 text-sm font-semibold text-[#333] hover:bg-[#fafafa]"
|
||||
>
|
||||
<Share2 className="h-4 w-4" />
|
||||
Share
|
||||
</button>
|
||||
) : (
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
{enabledSharePlatforms.map((item) => (
|
||||
<button
|
||||
key={item.key}
|
||||
type="button"
|
||||
onClick={() => void onShare(item.key)}
|
||||
className="inline-flex h-10 items-center justify-center gap-2 rounded-lg border border-[#ddd] bg-white px-3 text-xs font-semibold text-[#333] hover:bg-[#fafafa]"
|
||||
>
|
||||
<ShareIcon platform={item.key} />
|
||||
{item.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : null}
|
||||
{actionMsg ? <p className="mt-3 text-center text-xs text-[#666]">{actionMsg}</p> : null}
|
||||
</WhiteCard>
|
||||
</AuthShell>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<AuthShell showLogo={false}>
|
||||
<WhiteCard>
|
||||
{meta?.event ? (
|
||||
<div className="mb-4">
|
||||
<PhotoWallEventHeader
|
||||
event={meta.event}
|
||||
display={meta.eventHeader || null}
|
||||
eyebrow="Social Share"
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{meta?.accessMode === "login" && !loggedIn ? (
|
||||
<div className="space-y-3">
|
||||
<h1 className="mb-1 text-center text-[22px] font-semibold text-[#1a1a1a]">Social Share</h1>
|
||||
<p className="text-center text-sm text-[#666]">Sign in to create and share your photo.</p>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void goLogin()}
|
||||
className="inline-flex h-11 w-full items-center justify-center rounded-lg bg-[#D32F2F] px-4 text-sm font-semibold text-white hover:bg-[#b71c1c]"
|
||||
>
|
||||
Sign in to continue
|
||||
</button>
|
||||
</div>
|
||||
) : !selectedFrame ? (
|
||||
<div className="space-y-4">
|
||||
<h1 className="mb-1 text-center text-[22px] font-semibold text-[#1a1a1a]">Choose a frame</h1>
|
||||
<p className="text-center text-sm text-[#666]">Pick a frame, then upload your photo.</p>
|
||||
{!meta?.frames?.length ? (
|
||||
<p className="text-center text-sm text-[#666]">No frames available yet.</p>
|
||||
) : (
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
{meta.frames.map((frame) => (
|
||||
<button
|
||||
key={frame._id}
|
||||
type="button"
|
||||
onClick={() => setSelectedFrame(frame)}
|
||||
className="overflow-hidden rounded-lg border border-[#e5e5e5] bg-[#f8f8f8] text-left hover:border-[#D32F2F]"
|
||||
>
|
||||
<div className="aspect-square w-full bg-white">
|
||||
{frame.frameImageUrl ? (
|
||||
<img
|
||||
src={frame.frameImageUrl}
|
||||
alt={frame.name}
|
||||
className="h-full w-full object-contain"
|
||||
/>
|
||||
) : (
|
||||
<div className="flex h-full items-center justify-center text-xs text-[#999]">
|
||||
No preview
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<p className="truncate px-2 py-2 text-center text-xs font-semibold text-[#333]">
|
||||
{frame.name}
|
||||
</p>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{err ? <p className="text-center text-sm text-red-600">{err}</p> : null}
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setSelectedFrame(null);
|
||||
clearSelectedPhoto();
|
||||
setErr(null);
|
||||
}}
|
||||
className="text-sm font-semibold text-[#D32F2F]"
|
||||
>
|
||||
← Change frame
|
||||
</button>
|
||||
<h1 className="mb-1 text-center text-[22px] font-semibold text-[#1a1a1a]">Upload photo</h1>
|
||||
<p className="text-center text-sm text-[#666]">
|
||||
Frame: <span className="font-semibold text-[#333]">{selectedFrame.name}</span>
|
||||
</p>
|
||||
|
||||
<input
|
||||
ref={fileRef}
|
||||
type="file"
|
||||
accept="image/*"
|
||||
capture="user"
|
||||
className="hidden"
|
||||
onChange={(e) => void handleFileChange(e.target.files?.[0] || null)}
|
||||
/>
|
||||
{previewUrl ? (
|
||||
<div className="overflow-hidden rounded-lg border border-[#e5e5e5] bg-[#f8f8f8]">
|
||||
<img src={previewUrl} alt="Your photo" className="mx-auto max-h-[240px] w-full object-contain" />
|
||||
</div>
|
||||
) : null}
|
||||
<button
|
||||
type="button"
|
||||
disabled={picking}
|
||||
onClick={() => fileRef.current?.click()}
|
||||
className="inline-flex h-11 w-full items-center justify-center rounded-lg border border-[#ddd] bg-white px-4 text-sm font-semibold text-[#333] hover:bg-[#fafafa] disabled:opacity-55"
|
||||
>
|
||||
{picking ? "Processing…" : file ? "Change photo" : "Choose photo"}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
disabled={!file || submitting}
|
||||
onClick={() => void onSubmit()}
|
||||
className="inline-flex h-11 w-full items-center justify-center rounded-lg bg-[#D32F2F] px-4 text-sm font-semibold text-white hover:bg-[#b71c1c] disabled:opacity-55"
|
||||
>
|
||||
{submitting ? "Creating…" : "Create photo"}
|
||||
</button>
|
||||
{err ? <p className="text-center text-sm text-red-600">{err}</p> : null}
|
||||
</div>
|
||||
)}
|
||||
</WhiteCard>
|
||||
|
||||
{cropSession && cropTarget ? (
|
||||
<PhotoWallCropModal
|
||||
imageSrc={cropSession.imageSrc}
|
||||
sourceFileName={cropSession.sourceFileName}
|
||||
aspect={cropTarget.w / cropTarget.h}
|
||||
onCancel={() => {
|
||||
URL.revokeObjectURL(cropSession.imageSrc);
|
||||
setCropSession(null);
|
||||
}}
|
||||
onConfirm={(cropped) => {
|
||||
URL.revokeObjectURL(cropSession.imageSrc);
|
||||
setCropSession(null);
|
||||
applySelectedFile(cropped);
|
||||
}}
|
||||
/>
|
||||
) : null}
|
||||
</AuthShell>
|
||||
);
|
||||
}
|
||||
Loading…
Reference in New Issue