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 { QuestionsPresentPage } from "./pages/QuestionsPresentPage";
|
||||||
import { PhotoWallUploadPage } from "./pages/PhotoWallUploadPage";
|
import { PhotoWallUploadPage } from "./pages/PhotoWallUploadPage";
|
||||||
import { PhotoWallPresentPage } from "./pages/PhotoWallPresentPage";
|
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 { CodeEntryPage } from "./pages/CodeEntryPage";
|
||||||
import { PrivacyPolicyPage } from "./pages/PrivacyPolicyPage";
|
import { PrivacyPolicyPage } from "./pages/PrivacyPolicyPage";
|
||||||
import { TermsOfServicePage } from "./pages/TermsOfServicePage";
|
import { TermsOfServicePage } from "./pages/TermsOfServicePage";
|
||||||
|
|
@ -45,6 +48,9 @@ export default function App() {
|
||||||
<Route path="/questions-present/:sessionId" element={<QuestionsPresentPage />} />
|
<Route path="/questions-present/:sessionId" element={<QuestionsPresentPage />} />
|
||||||
<Route path="/photo-wall/:uploadToken" element={<PhotoWallUploadPage />} />
|
<Route path="/photo-wall/:uploadToken" element={<PhotoWallUploadPage />} />
|
||||||
<Route path="/photo-wall-present/:presentToken" element={<PhotoWallPresentPage />} />
|
<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="/questions" element={<Questions />} />
|
||||||
<Route path="/ask" element={<Navigate to="/questions" replace />} />
|
<Route path="/ask" element={<Navigate to="/questions" replace />} />
|
||||||
<Route path="/feedback" element={<FeedbackPage />} />
|
<Route path="/feedback" element={<FeedbackPage />} />
|
||||||
|
|
|
||||||
|
|
@ -48,11 +48,15 @@ function formatEventDateRange(ev: PhotoWallPublicEvent): string {
|
||||||
export function PhotoWallEventHeader({
|
export function PhotoWallEventHeader({
|
||||||
event,
|
event,
|
||||||
display,
|
display,
|
||||||
|
/** Small label above the event title. Default "Photo Wall"; pass null/"" to hide. */
|
||||||
|
eyebrow = "Photo Wall",
|
||||||
}: {
|
}: {
|
||||||
event: PhotoWallPublicEvent;
|
event: PhotoWallPublicEvent;
|
||||||
display?: PhotoWallEventHeaderDisplay | null;
|
display?: PhotoWallEventHeaderDisplay | null;
|
||||||
|
eyebrow?: string | null;
|
||||||
}) {
|
}) {
|
||||||
const d = resolveDisplay(display);
|
const d = resolveDisplay(display);
|
||||||
|
const eyebrowText = eyebrow == null ? "" : String(eyebrow).trim();
|
||||||
const typeLabel = formatEventType(event.eventType);
|
const typeLabel = formatEventType(event.eventType);
|
||||||
const dateLine = formatEventDateRange(event);
|
const dateLine = formatEventDateRange(event);
|
||||||
const kind = formatLocationKind(event.locationType);
|
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;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -109,7 +120,9 @@ export function PhotoWallEventHeader({
|
||||||
) : null}
|
) : null}
|
||||||
{showTitle || showSubtitle || metaItems.length > 0 ? (
|
{showTitle || showSubtitle || metaItems.length > 0 ? (
|
||||||
<div className="flex w-full flex-col items-center gap-2">
|
<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 ? (
|
{showTitle ? (
|
||||||
<h1 className="font-inter text-[28px] font-bold leading-tight text-[#24282f] sm:text-[32px]">
|
<h1 className="font-inter text-[28px] font-bold leading-tight text-[#24282f] sm:text-[32px]">
|
||||||
{event.name}
|
{event.name}
|
||||||
|
|
@ -133,9 +146,9 @@ export function PhotoWallEventHeader({
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : eyebrowText ? (
|
||||||
<p className="font-inter text-base font-semibold text-[#24282f]">Photo Wall</p>
|
<p className="font-inter text-base font-semibold text-[#24282f]">{eyebrowText}</p>
|
||||||
)}
|
) : null}
|
||||||
</header>
|
</header>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
248
src/lib/api.ts
248
src/lib/api.ts
|
|
@ -1074,9 +1074,7 @@ export async function uploadPhotoWallSelfie(
|
||||||
? fileUrlRelative.startsWith("http")
|
? fileUrlRelative.startsWith("http")
|
||||||
? fileUrlRelative
|
? fileUrlRelative
|
||||||
: `${base}${fileUrlRelative.startsWith("/") ? "" : "/"}${fileUrlRelative}`
|
: `${base}${fileUrlRelative.startsWith("/") ? "" : "/"}${fileUrlRelative}`
|
||||||
: photoId
|
: data?.previewUrl || framedUrl || photoUrl || null;
|
||||||
? `${base}/api/public/photo-wall/upload/${encodeURIComponent(uploadToken)}/photos/${encodeURIComponent(photoId)}/file`
|
|
||||||
: null;
|
|
||||||
return {
|
return {
|
||||||
awaitingApproval: Boolean(data?.awaitingApproval),
|
awaitingApproval: Boolean(data?.awaitingApproval),
|
||||||
previewUrl: data?.previewUrl || framedUrl || photoUrl,
|
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");
|
if (!body || typeof body !== "object" || !("data" in body)) throw new Error("Invalid response");
|
||||||
return (body as { data: PhotoWallPresentData }).data;
|
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.
|
/** 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.
|
* On phones: prefer the OS share sheet (`navigator.share`) so the user picks the app.
|
||||||
* Instagram / Snapchat cannot receive an image via web intent, so we save the photo,
|
* On desktop: platform buttons open that app / web intent. Instagram / Snapchat cannot
|
||||||
* copy the caption, then open the app.
|
* receive an image via web intent, so we save the photo, copy the caption, then open the app.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
export type PhotoWallSharePlatform =
|
export type PhotoWallSharePlatform =
|
||||||
|
|
@ -24,21 +24,96 @@ const PLATFORM_APP_HOME: Record<PhotoWallSharePlatform, string> = {
|
||||||
whatsapp: "https://wa.me/",
|
whatsapp: "https://wa.me/",
|
||||||
};
|
};
|
||||||
|
|
||||||
function isMobileUa(): boolean {
|
export function isMobileUa(): boolean {
|
||||||
if (typeof navigator === "undefined") return false;
|
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> {
|
/** True when we should show one Share button that opens the OS share sheet. */
|
||||||
const res = await fetch(url, { mode: "cors" });
|
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");
|
if (!res.ok) throw new Error("Could not load image for sharing");
|
||||||
const blob = await res.blob();
|
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 type = blob.type && blob.type.startsWith("image/") ? blob.type : "image/jpeg";
|
||||||
const ext = type.includes("png") ? "png" : type.includes("webp") ? "webp" : "jpg";
|
const ext = type.includes("png") ? "png" : type.includes("webp") ? "webp" : "jpg";
|
||||||
const name = filename.replace(/\.\w+$/, "") + `.${ext}`;
|
const name = filename.replace(/\.\w+$/, "") + `.${ext}`;
|
||||||
return new File([blob], name, { type });
|
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") {
|
export async function downloadImageFromUrl(url: string, filename = "photo-wall.jpg") {
|
||||||
const file = await fetchImageFile(url, filename);
|
const file = await fetchImageFile(url, filename);
|
||||||
const objectUrl = URL.createObjectURL(file);
|
const objectUrl = URL.createObjectURL(file);
|
||||||
|
|
@ -143,13 +218,113 @@ async function saveImageAndOpenApp(
|
||||||
return "downloaded";
|
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
|
* Open the OS share sheet (iOS / Android) with the photo + caption.
|
||||||
* iOS system sheet and ignores which button was tapped).
|
* 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: {
|
export async function sharePhotoWallImage(options: {
|
||||||
platform: PhotoWallSharePlatform;
|
platform: PhotoWallSharePlatform;
|
||||||
imageUrl: string;
|
imageUrl: string;
|
||||||
|
file?: File | null;
|
||||||
title?: string;
|
title?: string;
|
||||||
text?: string;
|
text?: string;
|
||||||
}): Promise<SharePhotoWallResult> {
|
}): Promise<SharePhotoWallResult> {
|
||||||
|
|
@ -158,7 +333,7 @@ export async function sharePhotoWallImage(options: {
|
||||||
const imageUrl = options.imageUrl;
|
const imageUrl = options.imageUrl;
|
||||||
const platform = options.platform;
|
const platform = options.platform;
|
||||||
|
|
||||||
const intent = platformShareUrl(platform, imageUrl, text);
|
const intent = imageUrl ? platformShareUrl(platform, imageUrl, text) : null;
|
||||||
if (intent) {
|
if (intent) {
|
||||||
// Copy caption so users can paste if the app only receives the URL.
|
// Copy caption so users can paste if the app only receives the URL.
|
||||||
await copyText(`${text}\n${imageUrl}`);
|
await copyText(`${text}\n${imageUrl}`);
|
||||||
|
|
@ -166,7 +341,13 @@ export async function sharePhotoWallImage(options: {
|
||||||
return "shared";
|
return "shared";
|
||||||
}
|
}
|
||||||
|
|
||||||
// Instagram / Snapchat: save file locally, copy caption, open the app.
|
// Instagram / Snapchat (or missing public URL): save file locally, copy caption, open the app.
|
||||||
const file = await fetchImageFile(imageUrl, "photo-wall.jpg");
|
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);
|
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";
|
} from "../lib/api";
|
||||||
import { getAttendeeParticipant, getAttendeeToken } from "../lib/attendeeAuth";
|
import { getAttendeeParticipant, getAttendeeToken } from "../lib/attendeeAuth";
|
||||||
import { getImageNaturalSize, ratiosMatch } from "../lib/cropImage";
|
import { getImageNaturalSize, ratiosMatch } from "../lib/cropImage";
|
||||||
import { downloadImageFromUrl, sharePhotoWallImage } from "../lib/photoWallShare";
|
import {
|
||||||
|
canUseNativeShareSheet,
|
||||||
|
downloadImageFromUrl,
|
||||||
|
fetchImageFile,
|
||||||
|
ensureShareableImageFile,
|
||||||
|
sharePhotoWallImage,
|
||||||
|
shareViaNativeSheet,
|
||||||
|
} from "../lib/photoWallShare";
|
||||||
import {
|
import {
|
||||||
EB_VISITOR_CONTEXT_KEY,
|
EB_VISITOR_CONTEXT_KEY,
|
||||||
setPostAuthReturnPath,
|
setPostAuthReturnPath,
|
||||||
|
|
@ -82,6 +89,7 @@ function ShareIcon({ platform }: { platform: keyof PhotoWallSharePlatforms }) {
|
||||||
export function PhotoWallUploadPage() {
|
export function PhotoWallUploadPage() {
|
||||||
const { uploadToken } = useParams<{ uploadToken: string }>();
|
const { uploadToken } = useParams<{ uploadToken: string }>();
|
||||||
const fileRef = useRef<HTMLInputElement>(null);
|
const fileRef = useRef<HTMLInputElement>(null);
|
||||||
|
const resultImgRef = useRef<HTMLImageElement>(null);
|
||||||
const [meta, setMeta] = useState<PhotoWallUploadMeta | null>(null);
|
const [meta, setMeta] = useState<PhotoWallUploadMeta | null>(null);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [err, setErr] = useState<string | null>(null);
|
const [err, setErr] = useState<string | null>(null);
|
||||||
|
|
@ -94,6 +102,8 @@ export function PhotoWallUploadPage() {
|
||||||
const [done, setDone] = useState<UploadDoneState | null>(null);
|
const [done, setDone] = useState<UploadDoneState | null>(null);
|
||||||
const [actionMsg, setActionMsg] = useState<string | null>(null);
|
const [actionMsg, setActionMsg] = useState<string | null>(null);
|
||||||
const [cropSession, setCropSession] = useState<CropSession | 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 () => {
|
const load = useCallback(async () => {
|
||||||
if (!uploadToken) return;
|
if (!uploadToken) return;
|
||||||
|
|
@ -136,6 +146,7 @@ export function PhotoWallUploadPage() {
|
||||||
() => SHARE_LABELS.filter((item) => Boolean(meta?.sharePlatforms?.[item.key])),
|
() => SHARE_LABELS.filter((item) => Boolean(meta?.sharePlatforms?.[item.key])),
|
||||||
[meta?.sharePlatforms],
|
[meta?.sharePlatforms],
|
||||||
);
|
);
|
||||||
|
const useNativeShare = useMemo(() => canUseNativeShareSheet(), []);
|
||||||
|
|
||||||
const clearSelectedPhoto = useCallback(() => {
|
const clearSelectedPhoto = useCallback(() => {
|
||||||
setFile(null);
|
setFile(null);
|
||||||
|
|
@ -146,8 +157,24 @@ export function PhotoWallUploadPage() {
|
||||||
if (fileRef.current) fileRef.current.value = "";
|
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(() => {
|
const resetForNewUpload = useCallback(() => {
|
||||||
setDone(null);
|
setDone(null);
|
||||||
|
setShareFile(null);
|
||||||
setActionMsg(null);
|
setActionMsg(null);
|
||||||
setErr(null);
|
setErr(null);
|
||||||
clearSelectedPhoto();
|
clearSelectedPhoto();
|
||||||
|
|
@ -206,9 +233,12 @@ export function PhotoWallUploadPage() {
|
||||||
setErr(null);
|
setErr(null);
|
||||||
setActionMsg(null);
|
setActionMsg(null);
|
||||||
try {
|
try {
|
||||||
|
const localFile = file;
|
||||||
const result = await uploadPhotoWallSelfie(uploadToken, file, values, {
|
const result = await uploadPhotoWallSelfie(uploadToken, file, values, {
|
||||||
displayName: meta.askUploaderName ? uploaderName.trim() : undefined,
|
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({
|
setDone({
|
||||||
awaitingApproval: result.awaitingApproval,
|
awaitingApproval: result.awaitingApproval,
|
||||||
previewUrl: result.previewUrl,
|
previewUrl: result.previewUrl,
|
||||||
|
|
@ -216,6 +246,14 @@ export function PhotoWallUploadPage() {
|
||||||
framed: result.framed,
|
framed: result.framed,
|
||||||
});
|
});
|
||||||
clearSelectedPhoto();
|
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) {
|
} catch (e2) {
|
||||||
setErr(e2 instanceof Error ? e2.message : "Upload failed");
|
setErr(e2 instanceof Error ? e2.message : "Upload failed");
|
||||||
} finally {
|
} finally {
|
||||||
|
|
@ -224,10 +262,22 @@ export function PhotoWallUploadPage() {
|
||||||
};
|
};
|
||||||
|
|
||||||
const onDownload = async () => {
|
const onDownload = async () => {
|
||||||
const src = done?.fileUrl || done?.previewUrl;
|
|
||||||
if (!src) return;
|
|
||||||
setActionMsg(null);
|
setActionMsg(null);
|
||||||
try {
|
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");
|
await downloadImageFromUrl(src, "photo-wall.jpg");
|
||||||
setActionMsg("Download started");
|
setActionMsg("Download started");
|
||||||
} catch (e) {
|
} 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 onShare = async (platform: keyof PhotoWallSharePlatforms) => {
|
||||||
const src = done?.fileUrl || done?.previewUrl;
|
const src = done?.fileUrl || done?.previewUrl;
|
||||||
if (!src) return;
|
if (!src && !shareFile) return;
|
||||||
setActionMsg(null);
|
setActionMsg(null);
|
||||||
try {
|
try {
|
||||||
const result = await sharePhotoWallImage({
|
const result = await sharePhotoWallImage({
|
||||||
platform,
|
platform,
|
||||||
imageUrl: src,
|
imageUrl: src || "",
|
||||||
|
file: shareFile,
|
||||||
title: meta?.eventName || "Photo wall",
|
title: meta?.eventName || "Photo wall",
|
||||||
text: `Check out my photo from ${meta?.eventName || "the event"}!`,
|
text: shareCaption,
|
||||||
});
|
});
|
||||||
if (result === "shared") {
|
if (result === "shared") {
|
||||||
setActionMsg(
|
setActionMsg(
|
||||||
|
|
@ -334,13 +411,21 @@ export function PhotoWallUploadPage() {
|
||||||
{(done.fileUrl || done.previewUrl) ? (
|
{(done.fileUrl || done.previewUrl) ? (
|
||||||
<div className="mb-4 overflow-hidden rounded-lg border border-[#e5e5e5] bg-[#f8f8f8]">
|
<div className="mb-4 overflow-hidden rounded-lg border border-[#e5e5e5] bg-[#f8f8f8]">
|
||||||
<img
|
<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"}
|
alt={done.framed ? "Your framed photo" : "Uploaded photo"}
|
||||||
className="mx-auto max-h-[360px] w-full object-contain"
|
className="mx-auto max-h-[360px] w-full object-contain"
|
||||||
onError={(e) => {
|
onError={(e) => {
|
||||||
const img = e.currentTarget;
|
const img = e.currentTarget;
|
||||||
if (done.previewUrl && img.src !== done.previewUrl) {
|
// Retry without CORS mode if CDN blocks anonymous reads.
|
||||||
img.src = done.previewUrl;
|
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]">
|
<p className="mb-2 text-center text-xs font-semibold uppercase tracking-wide text-[#888]">
|
||||||
Share
|
Share
|
||||||
</p>
|
</p>
|
||||||
<div className="grid grid-cols-2 gap-2">
|
{useNativeShare ? (
|
||||||
{enabledSharePlatforms.map((item) => (
|
<button
|
||||||
<button
|
type="button"
|
||||||
key={item.key}
|
onClick={() => void onNativeShare()}
|
||||||
type="button"
|
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]"
|
||||||
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]"
|
<Share2 className="h-4 w-4" />
|
||||||
>
|
Share
|
||||||
<ShareIcon platform={item.key} />
|
</button>
|
||||||
{item.label}
|
) : (
|
||||||
</button>
|
<div className="grid grid-cols-2 gap-2">
|
||||||
))}
|
{enabledSharePlatforms.map((item) => (
|
||||||
</div>
|
<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>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
{actionMsg ? <p className="mt-3 text-center text-xs text-[#666]">{actionMsg}</p> : 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