* 'main' of http://192.168.1.200:3000/Stratawiz/EventBuzz_Website:
  Add attendee photo search, social share, and AI avatar flows.
  Changes

# Conflicts:
#	src/types/visitorContext.ts
This commit is contained in:
Amey Bhagwat 2026-08-14 13:18:32 +05:30
commit 418f85b402
31 changed files with 4312 additions and 517 deletions

189
FUNCTIONALITY_BRIEF.md Normal file
View File

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

20
package-lock.json generated
View File

@ -13,6 +13,7 @@
"lucide-react": "0.487.0",
"react": "18.3.1",
"react-dom": "18.3.1",
"react-easy-crop": "^6.2.3",
"react-router-dom": "^6.28.0"
},
"devDependencies": {
@ -3082,6 +3083,12 @@
"dev": true,
"license": "MIT"
},
"node_modules/normalize-wheel": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/normalize-wheel/-/normalize-wheel-1.0.1.tgz",
"integrity": "sha512-1OnlAPZ3zgrk8B91HyRj+eVv+kS5u+Z0SCsak6Xil/kmgEia50ga7zfkumayonZrImffAxPU/5WcyGhzetHNPA==",
"license": "BSD-3-Clause"
},
"node_modules/picocolors": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
@ -3180,6 +3187,19 @@
"react": "^18.3.1"
}
},
"node_modules/react-easy-crop": {
"version": "6.2.3",
"resolved": "https://registry.npmjs.org/react-easy-crop/-/react-easy-crop-6.2.3.tgz",
"integrity": "sha512-ebimG3OGlzizjxEZ77Cj9CVLcg5vDZ6QVz8ud1rx4WmsCDWHIROEhgMi0GpJ/jKAuwHrH0M+QZUK4hyvxbYhOA==",
"license": "MIT",
"dependencies": {
"normalize-wheel": "^1.0.1"
},
"peerDependencies": {
"react": ">=16.4.0",
"react-dom": ">=16.4.0"
}
},
"node_modules/react-refresh": {
"version": "0.17.0",
"resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz",

View File

@ -14,6 +14,7 @@
"lucide-react": "0.487.0",
"react": "18.3.1",
"react-dom": "18.3.1",
"react-easy-crop": "^6.2.3",
"react-router-dom": "^6.28.0"
},
"devDependencies": {

View File

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

View File

@ -20,7 +20,7 @@ export function EventBuzzLogo({
alt={alt}
width={256}
height={48}
className={["block h-auto min-h-[36px] w-auto max-w-full shrink-0 object-contain", className]
className={["block w-auto max-w-full shrink-0 object-contain", className]
.filter(Boolean)
.join(" ")}
/>

View File

@ -0,0 +1,113 @@
import { useCallback, useState } from "react";
import Cropper, { type Area } from "react-easy-crop";
import { getCroppedImageFile } from "../lib/cropImage";
type PhotoWallCropModalProps = {
imageSrc: string;
aspect: number;
sourceFileName?: string;
onCancel: () => void;
onConfirm: (file: File) => void;
};
export function PhotoWallCropModal({
imageSrc,
aspect,
sourceFileName,
onCancel,
onConfirm,
}: PhotoWallCropModalProps) {
const [crop, setCrop] = useState({ x: 0, y: 0 });
const [zoom, setZoom] = useState(1);
const [croppedAreaPixels, setCroppedAreaPixels] = useState<Area | null>(null);
const [working, setWorking] = useState(false);
const [err, setErr] = useState<string | null>(null);
const onCropComplete = useCallback((_area: Area, pixels: Area) => {
setCroppedAreaPixels(pixels);
}, []);
const confirm = async () => {
if (!croppedAreaPixels) return;
setWorking(true);
setErr(null);
try {
const file = await getCroppedImageFile(
imageSrc,
croppedAreaPixels,
sourceFileName || "photo.jpg",
"image/jpeg",
);
onConfirm(file);
} catch (e) {
setErr(e instanceof Error ? e.message : "Could not crop photo");
} finally {
setWorking(false);
}
};
return (
<div className="fixed inset-0 z-50 flex items-end justify-center bg-black/55 p-4 sm:items-center">
<div className="flex w-full max-w-[420px] flex-col overflow-hidden rounded-2xl bg-white shadow-xl">
<div className="border-b border-[#f2f2f7] px-4 py-3">
<p className="font-inter text-base font-semibold text-[#24282f]">Crop your photo</p>
<p className="mt-0.5 font-inter text-xs text-[rgba(36,40,47,0.65)]">
Adjust to match the wall photo ratio before uploading.
</p>
</div>
<div className="relative h-[280px] w-full bg-[#1a1d24] sm:h-[320px]">
<Cropper
image={imageSrc}
crop={crop}
zoom={zoom}
aspect={aspect}
onCropChange={setCrop}
onZoomChange={setZoom}
onCropComplete={onCropComplete}
/>
</div>
<div className="flex flex-col gap-3 px-4 py-4">
<label className="flex flex-col gap-1.5">
<span className="font-inter text-xs font-semibold text-[#24282f]">Zoom</span>
<input
type="range"
min={1}
max={3}
step={0.05}
value={zoom}
onChange={(e) => setZoom(Number(e.target.value))}
className="w-full accent-[#ef8d1d]"
/>
</label>
{err ? (
<p className="text-center text-sm text-red-600" role="alert">
{err}
</p>
) : null}
<div className="flex gap-2">
<button
type="button"
onClick={onCancel}
disabled={working}
className="flex-1 rounded-full border border-[rgba(116,112,112,0.3)] bg-white py-3 text-xs font-semibold uppercase tracking-wide text-[#24282f] disabled:opacity-60"
>
Cancel
</button>
<button
type="button"
onClick={() => void confirm()}
disabled={working || !croppedAreaPixels}
className="flex-1 rounded-full border border-[rgba(214,200,200,0.6)] bg-[#ef8d1d] py-3 text-xs font-semibold uppercase tracking-wide text-white shadow-[0_4px_4px_rgba(0,0,0,0.15)] disabled:opacity-60"
>
{working ? "Cropping…" : "Use photo"}
</button>
</div>
</div>
</div>
</div>
);
}

View File

@ -0,0 +1,154 @@
import type { ReactNode } from "react";
import { Calendar, MapPin } from "lucide-react";
import type { PhotoWallEventHeaderDisplay, PhotoWallPublicEvent } from "../lib/api";
function resolveDisplay(display?: PhotoWallEventHeaderDisplay | null) {
return {
showLogo: display?.showLogo !== false,
showBanner: display?.showBanner === true,
showTitle: display?.showTitle !== false,
showDateRange: display?.showDateRange !== false,
showEventType: display?.showEventType !== false,
showLocationType: display?.showLocationType !== false,
showVenue: display?.showVenue !== false,
};
}
const EVENT_TYPE_LABEL: Record<string, string> = {
conference: "Conference",
workshop: "Workshop",
summit: "Summit",
cme: "CME",
webinar: "Webinar",
};
function formatEventType(t?: string): string {
if (!t) return "";
return EVENT_TYPE_LABEL[t] || t.replace(/^\w/, (c) => c.toUpperCase());
}
function formatLocationKind(locationType?: string): string {
if (locationType === "virtual") return "Virtual";
if (locationType === "hybrid") return "Hybrid";
return "In person";
}
function formatEventDateRange(ev: PhotoWallPublicEvent): string {
const { startDate, endDate } = ev;
if (!startDate) return "";
const s = new Date(startDate);
const e = endDate ? new Date(endDate) : s;
if (Number.isNaN(s.getTime())) return "";
const sameDay = !Number.isNaN(e.getTime()) && s.toDateString() === e.toDateString();
const dOpt: Intl.DateTimeFormatOptions = { month: "short", day: "numeric", year: "numeric" };
if (sameDay) return s.toLocaleDateString(undefined, { weekday: "short", ...dOpt });
return `${s.toLocaleDateString(undefined, dOpt)} – ${e.toLocaleDateString(undefined, dOpt)}`;
}
export function PhotoWallEventHeader({
event,
display,
/** Small label above the event title. Default "Photo Wall"; pass null/"" to hide. */
eyebrow = "Photo Wall",
}: {
event: PhotoWallPublicEvent;
display?: PhotoWallEventHeaderDisplay | null;
eyebrow?: string | null;
}) {
const d = resolveDisplay(display);
const eyebrowText = eyebrow == null ? "" : String(eyebrow).trim();
const typeLabel = formatEventType(event.eventType);
const dateLine = formatEventDateRange(event);
const kind = formatLocationKind(event.locationType);
const venue = event.location?.trim() || "";
const showLoc =
d.showVenue &&
!!venue &&
(event.locationType === "physical" || event.locationType === "hybrid" || !event.locationType);
const showBanner = d.showBanner && !!event.imageUrl;
const showLogo = d.showLogo && !!event.logoUrl;
const showTitle = d.showTitle && !!event.name;
const showSubtitle = d.showTitle && !!event.subtitle?.trim();
const metaItems: ReactNode[] = [];
if (d.showDateRange && dateLine) {
metaItems.push(
<span key="date" className="inline-flex items-center gap-1.5">
<Calendar className="size-3.5 shrink-0" strokeWidth={1.75} />
{dateLine}
</span>,
);
}
if (d.showEventType && typeLabel) metaItems.push(<span key="type">{typeLabel}</span>);
if (d.showLocationType && kind) metaItems.push(<span key="kind">{kind}</span>);
if (showLoc) {
metaItems.push(
<span key="loc" className="inline-flex items-center gap-1.5">
<MapPin className="size-3.5 shrink-0" strokeWidth={1.75} />
{venue}
</span>,
);
}
if (
!showBanner &&
!showLogo &&
!showTitle &&
!showSubtitle &&
metaItems.length === 0 &&
!eyebrowText
) {
return null;
}
return (
<header className="flex w-full flex-col items-center gap-3 text-center">
{showLogo ? (
<div className="flex h-[103px] w-full items-center justify-center rounded-xl border border-[#f2f2f7] px-4">
<img
src={event.logoUrl!}
alt={event.name}
className="max-h-[72px] w-full max-w-[240px] object-contain object-center"
/>
</div>
) : null}
{showBanner ? (
<div className="w-full overflow-hidden rounded-xl">
<img src={event.imageUrl!} alt="" className="max-h-28 w-full object-cover" />
</div>
) : null}
{showTitle || showSubtitle || metaItems.length > 0 ? (
<div className="flex w-full flex-col items-center gap-2">
{eyebrowText ? (
<p className="font-inter text-base font-semibold text-[#24282f]">{eyebrowText}</p>
) : null}
{showTitle ? (
<h1 className="font-inter text-[28px] font-bold leading-tight text-[#24282f] sm:text-[32px]">
{event.name}
</h1>
) : null}
{showSubtitle ? (
<p className="font-inter text-sm font-medium text-[#24282f]/80">{event.subtitle}</p>
) : null}
{metaItems.length > 0 ? (
<div className="flex flex-wrap items-center justify-center gap-x-2 gap-y-1 text-xs text-[#24282f]">
{metaItems.map((item, index) => (
<span key={index} className="inline-flex items-center gap-1.5 font-inter">
{index > 0 ? (
<span aria-hidden="true" className="text-[rgba(36,40,47,0.4)]">
·
</span>
) : null}
{item}
</span>
))}
</div>
) : null}
</div>
) : eyebrowText ? (
<p className="font-inter text-base font-semibold text-[#24282f]">{eyebrowText}</p>
) : null}
</header>
);
}

View File

@ -1,6 +1,6 @@
import type { ReactNode } from "react";
import { EventBuzzLogo } from "../EventBuzzLogo";
import { EventBuzzMark } from "../EventBuzzMark";
import { LegalFooterLinks } from "../marketing/LegalFooterLinks";
type AuthShellProps = {
children: ReactNode;
@ -8,9 +8,16 @@ type AuthShellProps = {
mainClassName?: string;
/** Show EventBuzz white logo pinned to the top of the auth shell */
showLogo?: boolean;
/** Classes for the footer EventBuzz mark (default h-5) */
footerLogoClassName?: string;
};
export function AuthShell({ children, mainClassName = "", showLogo = true }: AuthShellProps) {
export function AuthShell({
children,
mainClassName = "",
showLogo = true,
footerLogoClassName = "h-5 max-h-5 min-h-0 w-auto",
}: AuthShellProps) {
return (
<div className="eb-gradient-auth relative flex min-h-dvh w-full flex-1 flex-col">
{showLogo ? (
@ -23,8 +30,17 @@ export function AuthShell({ children, mainClassName = "", showLogo = true }: Aut
>
{children}
</div>
<footer className="mx-auto mt-auto w-full max-w-[402px] shrink-0 px-6 py-3 text-sm font-light">
<LegalFooterLinks variant="light" />
<footer className="mx-auto mt-auto flex w-full max-w-[402px] shrink-0 items-center justify-center gap-2 px-6 py-4">
<span className="text-xs font-medium tracking-wide text-white/75">Powered by</span>
<a
href="https://eventbuzz.live"
target="_blank"
rel="noopener noreferrer"
className="rounded-sm outline-none focus-visible:ring-2 focus-visible:ring-white/40"
aria-label="EventBuzz — opens in a new tab"
>
<EventBuzzLogo variant="white" className={footerLogoClassName} />
</a>
</footer>
</div>
);

View File

@ -0,0 +1,55 @@
import type { PublicQuizPollLeaderboardEntry } from "../../lib/api";
function formatTimeTaken(sec: number): string {
if (sec <= 0) return "—";
if (sec < 60) return `${sec}s`;
const m = Math.floor(sec / 60);
const s = sec % 60;
return s > 0 ? `${m}m ${s}s` : `${m}m`;
}
export function PresentLeaderboard({
entries,
loading,
}: {
entries: PublicQuizPollLeaderboardEntry[];
loading?: boolean;
}) {
if (loading && entries.length === 0) {
return <p className="py-8 text-center text-sm text-[rgba(36,40,47,0.55)]">Loading leaderboard…</p>;
}
if (entries.length === 0) {
return (
<p className="py-8 text-center text-sm text-[rgba(36,40,47,0.55)]">No quiz responses yet.</p>
);
}
return (
<div className="overflow-hidden rounded-lg border border-[#e5e5ea] bg-white shadow-sm">
<div className="grid grid-cols-[48px_minmax(0,1fr)_88px_72px] gap-2 border-b border-[#e5e5ea] bg-[#f2f2f7] px-3 py-2 text-[11px] font-semibold uppercase tracking-wide text-[rgba(36,40,47,0.55)] sm:grid-cols-[56px_minmax(0,1fr)_110px_88px] sm:px-4">
<span>Rank</span>
<span>Name</span>
<span className="text-right">Correct</span>
<span className="text-right">Time</span>
</div>
<ul className="divide-y divide-[#e5e5ea]">
{entries.map((row) => (
<li
key={`${row.participantId || row.displayName}-${row.rank}`}
className="grid grid-cols-[48px_minmax(0,1fr)_88px_72px] items-center gap-2 px-3 py-2.5 sm:grid-cols-[56px_minmax(0,1fr)_110px_88px] sm:px-4"
>
<span className="text-sm font-bold tabular-nums text-[rgba(36,40,47,0.45)]">#{row.rank}</span>
<span className="truncate text-sm font-semibold text-[#24282f] sm:text-base">{row.displayName}</span>
<span className="text-right text-sm font-semibold tabular-nums text-[#24282f]">
{row.correctCount}/{row.totalQuestions}
</span>
<span className="text-right text-sm font-semibold tabular-nums text-[#1e72ef]">
{formatTimeTaken(row.timeTakenSec)}
</span>
</li>
))}
</ul>
</div>
);
}

View File

@ -2,16 +2,22 @@ import type { PublicQuizPollResponder } from "../../lib/api";
function formatTimeTaken(sec: number): string {
if (sec <= 0) return "—";
if (sec < 60) return `${sec} sec`;
if (sec < 60) return `${sec}s`;
const m = Math.floor(sec / 60);
const s = sec % 60;
return s > 0 ? `${m} min ${s} sec` : `${m} min`;
return s > 0 ? `${m}m ${s}s` : `${m}m`;
}
function resultLabel(isCorrect: boolean | null, showCorrectness: boolean): string {
if (!showCorrectness) return "—";
if (isCorrect === true) return "Correct";
if (isCorrect === false) return "Incorrect";
return "—";
}
export function PresentRespondersList({
responders,
isQuiz,
showAnswerText,
showCorrectness,
}: {
responders: PublicQuizPollResponder[];
@ -25,43 +31,44 @@ export function PresentRespondersList({
);
}
const reveal = Boolean(isQuiz && showCorrectness);
return (
<ul className="flex flex-col gap-2">
{responders.map((r, i) => (
<li
key={`${r.participantId}-${r.answeredAt}-${i}`}
className="flex items-center gap-3 rounded-lg border border-[#e5e5ea] bg-white px-4 py-3 shadow-sm"
>
<span className="w-8 shrink-0 text-center text-sm font-bold tabular-nums text-[rgba(36,40,47,0.4)]">
{i + 1}
</span>
<div className="min-w-0 flex-1">
<p className="truncate text-base font-semibold text-[#24282f]">{r.displayName}</p>
{showAnswerText && r.answerText ? (
<p className="mt-0.5 truncate text-sm text-[rgba(36,40,47,0.65)]">{r.answerText}</p>
) : null}
<p className="mt-1 text-sm text-[rgba(36,40,47,0.65)]">
<span className="font-medium text-[#24282f]">Time taken:</span>{" "}
<span className="font-semibold tabular-nums text-[#1e72ef]">{formatTimeTaken(r.responseTimeSec)}</span>
</p>
</div>
{isQuiz && showCorrectness ? (
<div className="shrink-0 text-right">
<div className="overflow-hidden rounded-lg border border-[#e5e5ea] bg-white shadow-sm">
<div className="grid grid-cols-[48px_minmax(0,1fr)_96px_72px] gap-2 border-b border-[#e5e5ea] bg-[#f2f2f7] px-3 py-2 text-[11px] font-semibold uppercase tracking-wide text-[rgba(36,40,47,0.55)] sm:grid-cols-[56px_minmax(0,1fr)_110px_88px] sm:px-4">
<span>Rank</span>
<span>Name</span>
<span className="text-right">Result</span>
<span className="text-right">Time</span>
</div>
<ul className="divide-y divide-[#e5e5ea]">
{responders.map((r, i) => {
const label = resultLabel(r.isCorrect, reveal);
return (
<li
key={`${r.participantId}-${r.answeredAt}-${i}`}
className="grid grid-cols-[48px_minmax(0,1fr)_96px_72px] items-center gap-2 px-3 py-2.5 sm:grid-cols-[56px_minmax(0,1fr)_110px_88px] sm:px-4"
>
<span className="text-sm font-bold tabular-nums text-[rgba(36,40,47,0.45)]">#{i + 1}</span>
<span className="truncate text-sm font-semibold text-[#24282f] sm:text-base">{r.displayName}</span>
<span
className={`inline-flex rounded-full px-2.5 py-1 text-xs font-bold ${
r.isCorrect === true
? "bg-emerald-100 text-emerald-800"
: r.isCorrect === false
? "bg-red-100 text-red-700"
: "bg-slate-100 text-slate-600"
className={`text-right text-sm font-semibold ${
label === "Correct"
? "text-emerald-700"
: label === "Incorrect"
? "text-red-600"
: "text-[rgba(36,40,47,0.45)]"
}`}
>
{r.isCorrect === true ? "Correct" : r.isCorrect === false ? "Incorrect" : "—"}
{label}
</span>
</div>
) : null}
</li>
))}
</ul>
<span className="text-right text-sm font-semibold tabular-nums text-[#1e72ef]">
{formatTimeTaken(r.responseTimeSec)}
</span>
</li>
);
})}
</ul>
</div>
);
}

View File

@ -37,7 +37,7 @@ export function PresentStatsBars({
}
return (
<div className="flex flex-col gap-4">
<div className="flex flex-col gap-3">
{options.map((opt) => {
const highlighted = revealCorrect && opt.isCorrect === true;
const barColor = highlighted ? "bg-emerald-500" : "bg-[#1e72ef]";
@ -45,7 +45,7 @@ export function PresentStatsBars({
return (
<div key={opt.optionId} className={`rounded-lg border bg-white p-4 shadow-sm ${borderColor}`}>
<div className="mb-3 flex flex-wrap items-center justify-between gap-3">
<div className="mb-2 flex flex-wrap items-center justify-between gap-2">
<span className="min-w-0 flex-1 text-base font-medium leading-snug text-[#24282f]">
{opt.text || "—"}
{highlighted ? (
@ -54,16 +54,16 @@ export function PresentStatsBars({
</span>
) : null}
</span>
<div className="flex shrink-0 items-center gap-2 rounded-full bg-[#f2f2f7] px-3 py-1.5">
<span className="text-xl font-bold tabular-nums text-[#24282f]">{opt.count}</span>
<span className="text-xs font-semibold uppercase tracking-wide text-[rgba(36,40,47,0.55)]">
{opt.count === 1 ? "response" : "responses"}
</span>
<div className="flex shrink-0 items-center gap-2">
<span className="text-sm font-semibold tabular-nums text-[#1e72ef]">{opt.percent}%</span>
<div className="flex items-center gap-1.5 rounded-full bg-[#f2f2f7] px-2.5 py-0.5">
<span className="text-base font-bold tabular-nums leading-none text-[#24282f]">{opt.count}</span>
<span className="text-[10px] font-semibold uppercase tracking-wide text-[rgba(36,40,47,0.55)]">
{opt.count === 1 ? "response" : "responses"}
</span>
</div>
</div>
</div>
<div className="mb-1 flex justify-end">
<span className="text-sm font-semibold tabular-nums text-[#1e72ef]">{opt.percent}%</span>
</div>
<div className="h-3 overflow-hidden rounded-full bg-[#e5e5ea]">
<div
className={`h-full rounded-full transition-[width] duration-500 ease-out ${barColor}`}

View File

@ -0,0 +1,65 @@
import { useEffect, useRef } from "react";
import { useNavigate } from "react-router-dom";
import {
firstQuizMasterOrManualVisibleId,
onAirManualOrQuizMasterIds,
quizPollTakePath,
} from "../lib/sessionQuizPollsRtdb";
import { useSessionQuizPollsRtdb } from "./useSessionQuizPollsRtdb";
/**
* Auto-open / switch visitors to a quiz_master or manual activity when it becomes
* live in RTDB — same edge-detect idea as the presenter screen.
*/
export function useAutoNavigateToLiveQuizPoll(options: {
eventId: string;
sessionId: string;
/** When set (take page), stay if still live unless another activity newly goes live. */
currentQuizPollId?: string | null;
enabled?: boolean;
}) {
const { eventId, sessionId, currentQuizPollId = null, enabled = true } = options;
const navigate = useNavigate();
const { items, loading } = useSessionQuizPollsRtdb(sessionId);
const prevOnAirRef = useRef<Set<string> | null>(null);
useEffect(() => {
prevOnAirRef.current = null;
}, [sessionId]);
useEffect(() => {
const eid = eventId.trim();
const sid = sessionId.trim();
if (!enabled || !eid || !sid || loading) return;
const onAir = onAirManualOrQuizMasterIds(items);
const onAirSet = new Set(onAir);
const prev = prevOnAirRef.current;
const current = currentQuizPollId?.trim() || null;
if (prev == null) {
prevOnAirRef.current = onAirSet;
const target = firstQuizMasterOrManualVisibleId(items);
if (!target) return;
if (current && onAirSet.has(current)) return;
navigate(quizPollTakePath(eid, sid, target), { replace: true });
return;
}
const newlyEntered = onAir.filter((id) => !prev.has(id));
prevOnAirRef.current = onAirSet;
const switchTo = newlyEntered.find((id) => id !== current) ?? newlyEntered[0];
if (switchTo && switchTo !== current) {
navigate(quizPollTakePath(eid, sid, switchTo), { replace: true });
return;
}
if (current && !onAirSet.has(current)) {
const fallback = firstQuizMasterOrManualVisibleId(items);
if (fallback && fallback !== current) {
navigate(quizPollTakePath(eid, sid, fallback), { replace: true });
}
}
}, [enabled, eventId, sessionId, loading, items, currentQuizPollId, navigate]);
}

View File

@ -895,6 +895,7 @@ export type PublicQuizPollPresentation = {
isLive?: boolean;
liveQuestionId?: string | null;
showCorrectAnswer?: boolean;
showLeaderboard?: boolean;
showResults?: boolean;
};
questions: PublicQuizPollPresentationQuestion[];
@ -974,6 +975,31 @@ export async function getPublicQuizPollQuestionStats(
return (body as { data: PublicQuizPollQuestionStats }).data;
}
export type PublicQuizPollLeaderboardEntry = {
rank: number;
participantId: string;
displayName: string;
correctCount: number;
totalQuestions: number;
timeTakenSec: number;
totalScore: number;
};
export type PublicQuizPollLeaderboard = {
totalQuestions: number;
entries: PublicQuizPollLeaderboardEntry[];
};
export async function getPublicQuizPollLeaderboard(token: string): Promise<PublicQuizPollLeaderboard> {
const base = getBaseUrl();
const url = `${base}/api/public/quiz-polls/present/${encodeURIComponent(token)}/leaderboard`;
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: PublicQuizPollLeaderboard }).data;
}
async function postPublicPresentationControl(
token: string,
pathSuffix: string,
@ -1006,3 +1032,413 @@ export async function setPublicLiveQuestion(
): Promise<PublicQuizPollPresentation> {
return postPublicPresentationControl(token, "/live-question", { questionId });
}
export type PhotoWallPublicFormField = {
label?: string;
name?: string;
type?: string;
required?: boolean;
options?: string[];
placeholder?: string;
mapsTo?: string;
};
export type PhotoWallEventHeaderDisplay = {
showLogo?: boolean;
showBanner?: boolean;
showTitle?: boolean;
showDateRange?: boolean;
showEventType?: boolean;
showLocationType?: boolean;
showVenue?: boolean;
};
export type PhotoWallPublicEvent = {
_id?: string;
name: string;
subtitle?: string;
eventType?: string;
startDate?: string | null;
endDate?: string | null;
location?: string;
locationType?: string;
logoUrl?: string | null;
imageUrl?: string | null;
};
export type PhotoWallSharePlatforms = {
linkedin?: boolean;
facebook?: boolean;
instagram?: boolean;
snapchat?: boolean;
x?: boolean;
whatsapp?: boolean;
};
export type PhotoWallUploadMeta = {
eventId: string;
eventName: string;
event?: PhotoWallPublicEvent | null;
eventHeader?: PhotoWallEventHeaderDisplay | null;
accessMode: "open" | "login";
collectDetails: boolean;
createParticipant: boolean;
moderationMode: "auto" | "approval";
showUploaderNameOnWall?: boolean;
requireUploadCrop?: boolean;
askUploaderName?: boolean;
maxPhotoWidth?: number;
maxPhotoHeight?: number;
frameEnabled?: boolean;
allowFramedDownload?: boolean;
sharePlatforms?: PhotoWallSharePlatforms;
form: {
_id: string;
title?: string;
description?: string;
type?: string;
fields: PhotoWallPublicFormField[];
settings?: { thankYouMessage?: string; isActive?: boolean };
} | null;
};
export type PhotoWallPresentData = {
eventId: string;
eventName: string;
photosPerRow: number;
maxPhotoWidth: number;
maxPhotoHeight: number;
gapPx: number;
showUploaderNameOnWall?: boolean;
frameEnabled?: boolean;
frameCanvasWidth?: number;
frameCanvasHeight?: number;
photos: Array<{ _id: string; url: string; createdAt?: string; displayName?: string | null }>;
};
export async function getPhotoWallUploadMeta(uploadToken: string): Promise<PhotoWallUploadMeta> {
const base = getBaseUrl();
const url = `${base}/api/public/photo-wall/upload/${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: PhotoWallUploadMeta }).data;
}
export async function uploadPhotoWallSelfie(
uploadToken: string,
file: File,
formData?: Record<string, unknown>,
options?: { displayName?: string },
): Promise<{
awaitingApproval: boolean;
previewUrl: string | null;
/** API proxy URL — use for download/share fetch (CORS-safe). */
fileUrl: string | null;
framed: boolean;
photoId: string | null;
photoUrl: string | null;
framedUrl: string | null;
}> {
const base = getBaseUrl();
const url = `${base}/api/public/photo-wall/upload/${encodeURIComponent(uploadToken)}`;
const fd = new FormData();
fd.append("file", file, file.name);
if (formData && Object.keys(formData).length) {
fd.append("formData", JSON.stringify(formData));
}
if (options?.displayName?.trim()) {
fd.append("displayName", options.displayName.trim());
}
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: {
awaitingApproval?: boolean;
previewUrl?: string;
fileUrl?: string;
framed?: boolean;
photo?: { _id?: string; url?: string; framedUrl?: string | null };
};
}
).data;
const photoUrl = data?.photo?.url || null;
const framedUrl = data?.photo?.framedUrl || null;
const photoId = data?.photo?._id ? String(data.photo._id) : null;
const fileUrlRelative = data?.fileUrl || null;
const fileUrl = fileUrlRelative
? fileUrlRelative.startsWith("http")
? fileUrlRelative
: `${base}${fileUrlRelative.startsWith("/") ? "" : "/"}${fileUrlRelative}`
: data?.previewUrl || framedUrl || photoUrl || null;
return {
awaitingApproval: Boolean(data?.awaitingApproval),
previewUrl: data?.previewUrl || framedUrl || photoUrl,
fileUrl,
framed: Boolean(data?.framed || framedUrl),
photoId,
photoUrl,
framedUrl,
};
}
export async function getPhotoWallPresent(presentToken: string): Promise<PhotoWallPresentData> {
const base = getBaseUrl();
const url = `${base}/api/public/photo-wall/present/${encodeURIComponent(presentToken)}`;
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: 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,
};
}

64
src/lib/cropImage.ts Normal file
View File

@ -0,0 +1,64 @@
import type { Area } from "react-easy-crop";
export function loadImage(src: string): Promise<HTMLImageElement> {
return new Promise((resolve, reject) => {
const img = new Image();
img.addEventListener("load", () => resolve(img));
img.addEventListener("error", () => reject(new Error("Failed to load image")));
img.src = src;
});
}
export async function getImageNaturalSize(file: File): Promise<{ width: number; height: number }> {
const url = URL.createObjectURL(file);
try {
const img = await loadImage(url);
return { width: img.naturalWidth, height: img.naturalHeight };
} finally {
URL.revokeObjectURL(url);
}
}
export function ratiosMatch(
imageWidth: number,
imageHeight: number,
targetWidth: number,
targetHeight: number,
tolerance = 0.02,
): boolean {
if (!imageWidth || !imageHeight || !targetWidth || !targetHeight) return true;
const imageRatio = imageWidth / imageHeight;
const targetRatio = targetWidth / targetHeight;
return Math.abs(imageRatio - targetRatio) / targetRatio <= tolerance;
}
export async function getCroppedImageFile(
imageSrc: string,
crop: Area,
fileName = "photo.jpg",
mimeType = "image/jpeg",
): Promise<File> {
const image = await loadImage(imageSrc);
const canvas = document.createElement("canvas");
const ctx = canvas.getContext("2d");
if (!ctx) throw new Error("Canvas not supported");
const w = Math.max(1, Math.round(crop.width));
const h = Math.max(1, Math.round(crop.height));
canvas.width = w;
canvas.height = h;
ctx.drawImage(image, crop.x, crop.y, crop.width, crop.height, 0, 0, w, h);
const blob = await new Promise<Blob>((resolve, reject) => {
canvas.toBlob(
(b) => (b ? resolve(b) : reject(new Error("Failed to crop image"))),
mimeType,
0.92,
);
});
const base = fileName.replace(/\.[^.]+$/, "") || "photo";
const ext = mimeType === "image/png" ? "png" : "jpg";
return new File([blob], `${base}.${ext}`, { type: mimeType });
}

353
src/lib/photoWallShare.ts Normal file
View File

@ -0,0 +1,353 @@
/** Download + social share helpers for photo-wall visitor success screen.
*
* On phones: prefer the OS share sheet (`navigator.share`) so the user picks the app.
* On desktop: platform buttons open that app / web intent. Instagram / Snapchat cannot
* receive an image via web intent, so we save the photo, copy the caption, then open the app.
*/
export type PhotoWallSharePlatform =
| "linkedin"
| "facebook"
| "instagram"
| "snapchat"
| "x"
| "whatsapp";
export type SharePhotoWallResult = "shared" | "downloaded" | "cancelled";
const PLATFORM_APP_HOME: Record<PhotoWallSharePlatform, string> = {
linkedin: "https://www.linkedin.com/feed/",
facebook: "https://www.facebook.com/",
instagram: "instagram://app",
snapchat: "snapchat://",
x: "https://x.com/compose/post",
whatsapp: "https://wa.me/",
};
export function isMobileUa(): boolean {
if (typeof navigator === "undefined") return false;
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;
}
/** True when we should show one Share button that opens the OS share sheet. */
export function canUseNativeShareSheet(): boolean {
if (typeof navigator === "undefined") return false;
if (!isMobileUa()) return false;
return typeof navigator.share === "function";
}
function friendlyFetchError(err: unknown): Error {
const msg = err instanceof Error ? err.message : String(err || "");
// Safari often surfaces network/CORS failures as "Load failed" / "Failed to fetch"
if (/load failed|failed to fetch|networkerror|access control/i.test(msg)) {
return new Error("Could not load photo for sharing. Try Download, then share from your photos.");
}
return err instanceof Error ? err : new Error("Could not load image for sharing");
}
/** Convert HEIC / unknown types to JPEG so iOS `canShare({ files })` accepts them. */
export async function ensureShareableImageFile(file: File, filename = "photo-wall.jpg"): Promise<File> {
const type = (file.type || "").toLowerCase();
if (type === "image/jpeg" || type === "image/png" || type === "image/webp") {
return file;
}
try {
const bitmap = await createImageBitmap(file);
const canvas = document.createElement("canvas");
canvas.width = bitmap.width;
canvas.height = bitmap.height;
const ctx = canvas.getContext("2d");
if (!ctx) return file;
ctx.drawImage(bitmap, 0, 0);
bitmap.close?.();
const blob = await new Promise<Blob | null>((resolve) =>
canvas.toBlob((b) => resolve(b), "image/jpeg", 0.92),
);
if (!blob?.size) return file;
return new File([blob], filename.replace(/\.\w+$/, "") + ".jpg", { type: "image/jpeg" });
} catch {
return file;
}
}
export async function fetchImageFile(url: string, filename = "photo-wall.jpg"): Promise<File> {
let res: Response;
try {
res = await fetch(url, { mode: "cors", credentials: "omit", cache: "no-store" });
} catch (err) {
throw friendlyFetchError(err);
}
if (!res.ok) throw new Error("Could not load image for sharing");
const blob = await res.blob();
if (!blob.size) throw new Error("Could not load image for sharing");
const type = blob.type && blob.type.startsWith("image/") ? blob.type : "image/jpeg";
const ext = type.includes("png") ? "png" : type.includes("webp") ? "webp" : "jpg";
const name = filename.replace(/\.\w+$/, "") + `.${ext}`;
return new File([blob], name, { type });
}
/** Read pixels from an already-displayed <img> (must be CORS-clean / same-origin). */
export async function fileFromHtmlImage(
img: HTMLImageElement,
filename = "photo-wall.jpg",
): Promise<File | null> {
if (!img?.naturalWidth || !img.naturalHeight) return null;
try {
const canvas = document.createElement("canvas");
canvas.width = img.naturalWidth;
canvas.height = img.naturalHeight;
const ctx = canvas.getContext("2d");
if (!ctx) return null;
ctx.drawImage(img, 0, 0);
const blob = await new Promise<Blob | null>((resolve) =>
canvas.toBlob((b) => resolve(b), "image/jpeg", 0.92),
);
if (!blob?.size) return null;
return new File([blob], filename.replace(/\.\w+$/, "") + ".jpg", { type: "image/jpeg" });
} catch {
return null;
}
}
export async function downloadImageFromUrl(url: string, filename = "photo-wall.jpg") {
const file = await fetchImageFile(url, filename);
const objectUrl = URL.createObjectURL(file);
const a = document.createElement("a");
a.href = objectUrl;
a.download = file.name;
document.body.appendChild(a);
a.click();
a.remove();
URL.revokeObjectURL(objectUrl);
}
async function copyText(text: string): Promise<boolean> {
try {
if (navigator.clipboard?.writeText) {
await navigator.clipboard.writeText(text);
return true;
}
} catch {
/* fall through */
}
try {
const el = document.createElement("textarea");
el.value = text;
el.setAttribute("readonly", "");
el.style.position = "fixed";
el.style.left = "-9999px";
document.body.appendChild(el);
el.select();
const ok = document.execCommand("copy");
el.remove();
return ok;
} catch {
return false;
}
}
function openUrl(url: string) {
// Prefer same-tab navigation on mobile so deep links / app handoff work more reliably.
if (isMobileUa()) {
window.location.assign(url);
return;
}
window.open(url, "_blank", "noopener,noreferrer");
}
function platformShareUrl(
platform: PhotoWallSharePlatform,
imageUrl: string,
text: string,
): string | null {
const encodedText = encodeURIComponent(text);
const encodedUrl = encodeURIComponent(imageUrl);
const textWithUrl = encodeURIComponent(`${text}\n${imageUrl}`);
switch (platform) {
case "whatsapp":
// wa.me opens the WhatsApp app on phones and WhatsApp Web on desktop
return `https://wa.me/?text=${textWithUrl}`;
case "facebook":
return `https://www.facebook.com/sharer/sharer.php?u=${encodedUrl}`;
case "x":
return `https://twitter.com/intent/tweet?text=${encodedText}&url=${encodedUrl}`;
case "linkedin":
return `https://www.linkedin.com/sharing/share-offsite/?url=${encodedUrl}`;
case "instagram":
case "snapchat":
return null;
default:
return null;
}
}
async function saveImageAndOpenApp(
platform: PhotoWallSharePlatform,
file: File,
text: string,
): Promise<SharePhotoWallResult> {
const objectUrl = URL.createObjectURL(file);
try {
const a = document.createElement("a");
a.href = objectUrl;
a.download = file.name;
document.body.appendChild(a);
a.click();
a.remove();
} finally {
URL.revokeObjectURL(objectUrl);
}
await copyText(text);
// Prefer native schemes on mobile; fall back to https homes.
if (platform === "instagram") {
openUrl(isMobileUa() ? "instagram://app" : "https://www.instagram.com/");
} else if (platform === "snapchat") {
openUrl(isMobileUa() ? "snapchat://" : "https://www.snapchat.com/");
} else {
openUrl(PLATFORM_APP_HOME[platform]);
}
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;
}
/**
* Open the OS share sheet (iOS / Android) with the photo + caption.
* Falls back to text/url-only share if the image bytes cannot be loaded.
*
* Critical: do not `await` network/canvas work before the first `navigator.share`
* when a File is already in hand — iOS Safari drops the user gesture and fails
* with "Load failed".
*/
export async function shareViaNativeSheet(options: {
imageUrl?: string | null;
file?: File | null;
imgEl?: HTMLImageElement | null;
title?: string;
text?: string;
}): Promise<SharePhotoWallResult> {
const title = options.title || "EventBuzz";
const text = options.text || `Check out my photo from ${title}!`;
const imageUrl = options.imageUrl || undefined;
if (typeof navigator.share !== "function") {
throw new Error("Sharing is not supported on this device");
}
const readyFile = options.file && options.file.size > 0 ? options.file : null;
const tryShareFiles = async (file: File): Promise<SharePhotoWallResult | "skip"> => {
const payloadWithFiles: ShareData = { title, text, files: [file] };
try {
const canFiles =
typeof navigator.canShare !== "function" || navigator.canShare(payloadWithFiles);
if (!canFiles) return "skip";
await navigator.share(payloadWithFiles);
return "shared";
} catch (err) {
const name = err instanceof DOMException ? err.name : "";
if (name === "AbortError") return "cancelled";
return "skip";
}
};
// 1) Prefer already-cached bytes — no await before share (keeps iOS user gesture).
if (readyFile) {
const result = await tryShareFiles(readyFile);
if (result !== "skip") return result;
}
// 2) Text/url-only share still before any fetch (gesture-safe fallback).
const payloadTextOnly: ShareData = imageUrl
? { title, text, url: imageUrl }
: { title, text };
try {
await navigator.share(payloadTextOnly);
return "shared";
} catch (err) {
const name = err instanceof DOMException ? err.name : "";
if (name === "AbortError") return "cancelled";
// Continue — try loading bytes for a second attempt (gesture may already be gone).
}
// 3) Last resort: fetch / canvas, then share (may only work on Android).
if (!readyFile) {
const file = await resolveShareFile({
file: null,
imageUrl,
imgEl: options.imgEl,
});
if (file) {
const result = await tryShareFiles(file);
if (result !== "skip") return result;
}
}
throw new Error("Could not share this photo. Try Download, then share from your photos.");
}
/**
* Share to a specific platform (desktop / non–share-sheet path).
* Does NOT use navigator.share so each button can target a different app.
*/
export async function sharePhotoWallImage(options: {
platform: PhotoWallSharePlatform;
imageUrl: string;
file?: File | null;
title?: string;
text?: string;
}): Promise<SharePhotoWallResult> {
const title = options.title || "EventBuzz";
const text = options.text || `Check out my photo from ${title}!`;
const imageUrl = options.imageUrl;
const platform = options.platform;
const intent = imageUrl ? platformShareUrl(platform, imageUrl, text) : null;
if (intent) {
// Copy caption so users can paste if the app only receives the URL.
await copyText(`${text}\n${imageUrl}`);
openUrl(intent);
return "shared";
}
// Instagram / Snapchat (or missing public URL): save file locally, copy caption, open the app.
const file =
options.file && options.file.size > 0
? options.file
: imageUrl
? await fetchImageFile(imageUrl, "photo-wall.jpg")
: null;
if (!file) throw new Error("Could not load image for sharing");
return saveImageAndOpenApp(platform, file, text);
}

View File

@ -0,0 +1,42 @@
import { ref, onValue, type Unsubscribe } from "firebase/database";
import { getRtdb } from "./firebaseClient";
export type SessionPresentFocus = "questions" | null;
export type SessionPresentationState = {
focus: SessionPresentFocus;
updatedAt: string | null;
};
function readPresentation(raw: unknown): SessionPresentationState {
if (!raw || typeof raw !== "object" || Array.isArray(raw)) {
return { focus: null, updatedAt: null };
}
const o = raw as Record<string, unknown>;
const focus = o.focus === "questions" ? "questions" : null;
const updatedAt = o.updatedAt != null ? String(o.updatedAt) : null;
return { focus, updatedAt };
}
/** Subscribe to `sessions/{sessionId}/presentation` present-screen focus. */
export function subscribeSessionPresentation(
sessionId: string,
onData: (state: SessionPresentationState) => void,
onError?: (e: Error) => void,
): Unsubscribe {
const db = getRtdb();
const r = ref(db, `sessions/${sessionId}/presentation`);
return onValue(
r,
(snap) => {
try {
onData(readPresentation(snap.val()));
} catch (e) {
onError?.(e instanceof Error ? e : new Error(String(e)));
}
},
(err) => {
onError?.(err instanceof Error ? err : new Error(String(err)));
},
);
}

View File

@ -18,6 +18,8 @@ export type RtdbQuizPollSettings = {
liveQuestionId?: string | null;
/** quiz_master: projector shows correct answer highlight */
showCorrectAnswer?: boolean;
/** quiz_master: projector switches to Leaderboard tab */
showLeaderboard?: boolean;
};
/** Normalize status from RTDB; legacy nodes without status are treated as active. */
@ -49,6 +51,24 @@ export function firstQuizMasterOrManualVisibleId(
return null;
}
/** Visible quiz_master / manual activity ids (audience on-air set). */
export function onAirManualOrQuizMasterIds(visibleQuizPolls: RtdbQuizPollListItem[]): string[] {
return visibleQuizPolls
.filter((item) => {
const mode = settingsDisplayMode(item.settings);
return mode === "quiz_master" || mode === "manual";
})
.map((item) => item._id);
}
export function quizPollTakePath(eventId: string, sessionId: string, quizPollId: string): string {
return `/quiz-poll?${new URLSearchParams({
event_id: eventId,
session_id: sessionId,
quiz_poll_id: quizPollId,
}).toString()}`;
}
/**
* Session list & take-page visibility (aligned with dashboard display modes).
*
@ -140,7 +160,21 @@ function readSettings(raw: Record<string, unknown>): RtdbQuizPollSettings {
? String(o.liveQuestionId).trim()
: null;
const showCorrectAnswer = o.showCorrectAnswer === true || o.showCorrectAnswer === "true";
return { isLive, startTime, endTime, displayMode, status, timeRestricted, timeLimit, showResults, allowMultipleAttempts, liveQuestionId, showCorrectAnswer };
const showLeaderboard = o.showLeaderboard === true || o.showLeaderboard === "true";
return {
isLive,
startTime,
endTime,
displayMode,
status,
timeRestricted,
timeLimit,
showResults,
allowMultipleAttempts,
liveQuestionId,
showCorrectAnswer,
showLeaderboard,
};
}
function mapOptions(raw: unknown): RtdbQuizPollOption[] {

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

@ -0,0 +1,664 @@
import {
useCallback,
useEffect,
useMemo,
useRef,
useState,
type ReactNode,
} from "react";
import {
Download,
Facebook,
Instagram,
Linkedin,
Loader2,
Share2,
} from "lucide-react";
import { useParams } from "react-router-dom";
import { AuthShell } from "../components/auth/AuthShell";
import { PhotoWallCropModal } from "../components/PhotoWallCropModal";
import { PhotoWallEventHeader } from "../components/PhotoWallEventHeader";
import {
getAiAvatarMeta,
getVisitorLoginPreview,
uploadAiAvatarPhoto,
type AiAvatarMeta,
type AiAvatarPublicTemplate,
type PhotoWallSharePlatforms,
} from "../lib/api";
import { getAttendeeParticipant, getAttendeeToken } from "../lib/attendeeAuth";
import { getImageNaturalSize, ratiosMatch } from "../lib/cropImage";
import {
canUseNativeShareSheet,
downloadImageFromUrl,
ensureShareableImageFile,
fetchImageFile,
sharePhotoWallImage,
shareViaNativeSheet,
} from "../lib/photoWallShare";
import {
EB_VISITOR_CONTEXT_KEY,
setPostAuthReturnPath,
type EbVisitorContext,
} from "../types/visitorContext";
function WhiteCard({ children }: { children: ReactNode }) {
return (
<div className="flex w-full max-w-[354px] flex-col items-center">
<div className="w-full rounded-xl shadow-[0_8px_12px_6px_rgba(0,0,0,0.15),0_4px_4px_rgba(0,0,0,0.3)]">
<div className="rounded-xl bg-white px-6 py-6">{children}</div>
</div>
</div>
);
}
type CropSession = {
imageSrc: string;
sourceFileName: string;
};
type DoneState = {
previewUrl: string | null;
fileUrl: string | null;
};
const SHARE_LABELS: Array<{ key: keyof PhotoWallSharePlatforms; label: string }> = [
{ key: "linkedin", label: "LinkedIn" },
{ key: "facebook", label: "Facebook" },
{ key: "instagram", label: "Instagram" },
{ key: "snapchat", label: "Snapchat" },
{ key: "x", label: "X" },
{ key: "whatsapp", label: "WhatsApp" },
];
/** Rotating copy while WaveSpeed runs — keeps visitors engaged; not a real status feed. */
const GENERATING_PIPELINE_MESSAGES = [
"Uploading your selfie…",
"Detecting your face…",
"Aligning features with the avatar…",
"Blending skin tones…",
"Matching lighting and shadows…",
"Applying AI head swap…",
"Refining facial details…",
"Smoothing edges…",
"Polishing the final look…",
"Almost there — finishing your avatar…",
];
function ShareIcon({ platform }: { platform: keyof PhotoWallSharePlatforms }) {
if (platform === "linkedin") return <Linkedin className="h-4 w-4" />;
if (platform === "facebook") return <Facebook className="h-4 w-4" />;
if (platform === "instagram") return <Instagram className="h-4 w-4" />;
return <Share2 className="h-4 w-4" />;
}
export function AiAvatarPage() {
const { uploadToken } = useParams<{ uploadToken: string }>();
const fileRef = useRef<HTMLInputElement>(null);
const resultImgRef = useRef<HTMLImageElement>(null);
const [meta, setMeta] = useState<AiAvatarMeta | null>(null);
const [loading, setLoading] = useState(true);
const [err, setErr] = useState<string | null>(null);
const [selectedAvatar, setSelectedAvatar] = useState<AiAvatarPublicTemplate | null>(null);
const [file, setFile] = useState<File | null>(null);
const [previewUrl, setPreviewUrl] = useState<string | null>(null);
const [submitting, setSubmitting] = useState(false);
const [pipelineMsgIndex, setPipelineMsgIndex] = useState(0);
const [picking, setPicking] = useState(false);
const [done, setDone] = useState<DoneState | null>(null);
const [actionMsg, setActionMsg] = useState<string | null>(null);
const [cropSession, setCropSession] = useState<CropSession | null>(null);
const [shareFile, setShareFile] = useState<File | null>(null);
useEffect(() => {
if (!submitting) {
setPipelineMsgIndex(0);
return;
}
const id = window.setInterval(() => {
setPipelineMsgIndex((i) => Math.min(i + 1, GENERATING_PIPELINE_MESSAGES.length - 1));
}, 2800);
return () => window.clearInterval(id);
}, [submitting]);
const load = useCallback(async () => {
if (!uploadToken) return;
setLoading(true);
setErr(null);
try {
const data = await getAiAvatarMeta(uploadToken);
setMeta(data);
} catch (e) {
setMeta(null);
setErr(e instanceof Error ? e.message : "AI Avatar not available");
} finally {
setLoading(false);
}
}, [uploadToken]);
useEffect(() => {
void load();
}, [load]);
useEffect(() => {
return () => {
if (previewUrl) URL.revokeObjectURL(previewUrl);
};
}, [previewUrl]);
useEffect(() => {
return () => {
if (cropSession?.imageSrc) URL.revokeObjectURL(cropSession.imageSrc);
};
}, [cropSession]);
useEffect(() => {
if (!done?.fileUrl) return;
let cancelled = false;
void fetchImageFile(done.fileUrl, "ai-avatar.jpg")
.then((f) => {
if (!cancelled) setShareFile(f);
})
.catch(() => {
/* keep local */
});
return () => {
cancelled = true;
};
}, [done?.fileUrl]);
const loggedIn = Boolean(getAttendeeToken() && getAttendeeParticipant());
const enabledSharePlatforms = useMemo(
() => SHARE_LABELS.filter((item) => Boolean(meta?.sharePlatforms?.[item.key])),
[meta?.sharePlatforms],
);
const useNativeShare = useMemo(() => canUseNativeShareSheet(), []);
const showShare = enabledSharePlatforms.length > 0;
const allowDownload = meta?.allowDownload !== false;
const shareCaption = `Check out my AI avatar from ${meta?.eventName || "the event"}!`;
const clearSelectedPhoto = useCallback(() => {
setFile(null);
setPreviewUrl((prev) => {
if (prev) URL.revokeObjectURL(prev);
return null;
});
if (fileRef.current) fileRef.current.value = "";
}, []);
const applySelectedFile = useCallback((next: File) => {
setFile(next);
setPreviewUrl((prev) => {
if (prev) URL.revokeObjectURL(prev);
return URL.createObjectURL(next);
});
}, []);
// Square selfie crop for head swap (avatar photoSlot, else 1:1).
const cropTarget = useMemo(() => {
const slot = selectedAvatar?.photoSlot;
if (slot?.w && slot?.h) {
return { w: Math.max(1, slot.w), h: Math.max(1, slot.h) };
}
return { w: 1080, h: 1080 };
}, [selectedAvatar]);
const handleFileChange = async (selected: File | null) => {
if (!selected) {
clearSelectedPhoto();
return;
}
if (!selected.type.startsWith("image/")) {
setErr("Please choose an image file");
return;
}
setErr(null);
setPicking(true);
try {
if (!cropTarget) {
applySelectedFile(selected);
return;
}
const natural = await getImageNaturalSize(selected);
if (ratiosMatch(natural.width, natural.height, cropTarget.w, cropTarget.h)) {
applySelectedFile(selected);
return;
}
const src = URL.createObjectURL(selected);
setCropSession({ imageSrc: src, sourceFileName: selected.name || "selfie.jpg" });
} catch (e) {
setErr(e instanceof Error ? e.message : "Could not process image");
} finally {
setPicking(false);
if (fileRef.current) fileRef.current.value = "";
}
};
const onSubmit = async () => {
if (!uploadToken || !file || !meta || !selectedAvatar) return;
setSubmitting(true);
setErr(null);
setActionMsg(null);
try {
const result = await uploadAiAvatarPhoto(uploadToken, file, selectedAvatar._id);
setShareFile(null);
setDone({
previewUrl: result.previewUrl,
fileUrl: result.fileUrl,
});
clearSelectedPhoto();
if (result.fileUrl) {
void fetchImageFile(result.fileUrl, "ai-avatar.jpg")
.then((f) => setShareFile(f))
.catch(() => {
void ensureShareableImageFile(file, "ai-avatar.jpg").then(setShareFile);
});
}
} catch (e2) {
setErr(e2 instanceof Error ? e2.message : "Could not create avatar");
} finally {
setSubmitting(false);
}
};
const resetForNew = () => {
setDone(null);
setShareFile(null);
setActionMsg(null);
setErr(null);
setSelectedAvatar(null);
clearSelectedPhoto();
};
const onDownload = async () => {
setActionMsg(null);
try {
if (shareFile) {
const objectUrl = URL.createObjectURL(shareFile);
const a = document.createElement("a");
a.href = objectUrl;
a.download = shareFile.name || "ai-avatar.jpg";
document.body.appendChild(a);
a.click();
a.remove();
URL.revokeObjectURL(objectUrl);
setActionMsg("Download started");
return;
}
const src = done?.fileUrl || done?.previewUrl;
if (!src) return;
await downloadImageFromUrl(src, "ai-avatar.jpg");
setActionMsg("Download started");
} catch (e) {
setActionMsg(e instanceof Error ? e.message : "Download failed");
}
};
const onNativeShare = async () => {
const src = done?.fileUrl || done?.previewUrl;
setActionMsg(null);
try {
const result = await shareViaNativeSheet({
file: shareFile,
imageUrl: src,
imgEl: resultImgRef.current,
title: meta?.eventName || "AI Avatar",
text: shareCaption,
});
if (result === "shared") setActionMsg("Shared");
else setActionMsg(null);
} catch (e) {
const msg = e instanceof Error ? e.message : "Share failed";
setActionMsg(
/load failed|failed to fetch/i.test(msg)
? "Could not share this photo. Try Download, then share from your photos."
: msg,
);
}
};
const onShare = async (platform: keyof PhotoWallSharePlatforms) => {
const src = done?.fileUrl || done?.previewUrl;
if (!src && !shareFile) return;
setActionMsg(null);
try {
const result = await sharePhotoWallImage({
platform,
imageUrl: src || "",
file: shareFile,
title: meta?.eventName || "AI Avatar",
text: shareCaption,
});
if (result === "shared") {
setActionMsg(
platform === "whatsapp" || platform === "facebook" || platform === "x" || platform === "linkedin"
? "Opening app with your photo link"
: "Shared",
);
} else if (result === "cancelled") {
setActionMsg(null);
} else {
setActionMsg("Photo saved and caption copied — paste / attach it in the app");
}
} catch (e) {
setActionMsg(e instanceof Error ? e.message : "Share failed");
}
};
const goLogin = async () => {
if (!meta?.eventId) return;
try {
const preview = await getVisitorLoginPreview(meta.eventId);
const ctx: EbVisitorContext = {
type: "event",
eventId: meta.eventId,
event: preview,
fetchedAt: new Date().toISOString(),
};
localStorage.setItem(EB_VISITOR_CONTEXT_KEY, JSON.stringify(ctx));
setPostAuthReturnPath(`/ai-avatar/${uploadToken}`);
window.location.assign(`/login?type=event&id=${encodeURIComponent(meta.eventId)}`);
} catch {
setErr("Could not open login");
}
};
if (!uploadToken) {
return (
<AuthShell showLogo={false}>
<WhiteCard>
<p className="text-center text-sm text-red-600">Missing link</p>
</WhiteCard>
</AuthShell>
);
}
if (loading) {
return (
<AuthShell showLogo={false}>
<WhiteCard>
<p className="text-center text-sm text-[#666]">Loading…</p>
</WhiteCard>
</AuthShell>
);
}
if (err && !meta) {
return (
<AuthShell showLogo={false}>
<WhiteCard>
<p className="text-center text-sm text-red-600">{err}</p>
</WhiteCard>
</AuthShell>
);
}
if (submitting) {
const step = pipelineMsgIndex + 1;
const total = GENERATING_PIPELINE_MESSAGES.length;
const progressPct = Math.round((step / total) * 100);
return (
<AuthShell showLogo={false}>
<WhiteCard>
{meta?.event ? (
<div className="mb-4">
<PhotoWallEventHeader
event={meta.event}
display={meta.eventHeader || null}
eyebrow="AI Avatar"
/>
</div>
) : null}
<div className="flex flex-col items-center py-4 text-center">
<div className="mb-5 flex h-16 w-16 items-center justify-center rounded-full bg-[#fdecea]">
<Loader2 className="h-8 w-8 animate-spin text-[#D32F2F]" aria-hidden />
</div>
<h1 className="mb-2 text-[22px] font-semibold text-[#1a1a1a]">Creating your avatar</h1>
<p
key={pipelineMsgIndex}
className="mb-5 min-h-[2.5rem] text-sm text-[#666] transition-opacity duration-300"
aria-live="polite"
>
{GENERATING_PIPELINE_MESSAGES[pipelineMsgIndex]}
</p>
<div className="mb-2 h-1.5 w-full overflow-hidden rounded-full bg-[#eee]">
<div
className="h-full rounded-full bg-[#D32F2F] transition-[width] duration-700 ease-out"
style={{ width: `${progressPct}%` }}
/>
</div>
<p className="text-xs text-[#999]">This can take up to a minute — hang tight</p>
</div>
</WhiteCard>
</AuthShell>
);
}
if (done) {
return (
<AuthShell showLogo={false}>
<WhiteCard>
{meta?.event ? (
<div className="mb-4">
<PhotoWallEventHeader
event={meta.event}
display={meta.eventHeader || null}
eyebrow="AI Avatar"
/>
</div>
) : null}
<h1 className="mb-2 text-center text-[22px] font-semibold text-[#1a1a1a]">Your avatar</h1>
<p className="mb-4 text-center text-sm text-[#666]">
Here’s your AI avatar. You can download or share it.
</p>
{(done.fileUrl || done.previewUrl) ? (
<div className="mb-4 overflow-hidden rounded-lg border border-[#e5e5e5] bg-[#f8f8f8]">
<img
ref={resultImgRef}
crossOrigin="anonymous"
src={done.previewUrl || done.fileUrl || undefined}
alt="Your AI avatar"
className="mx-auto max-h-[360px] w-full object-contain"
onError={(e) => {
const img = e.currentTarget;
if (img.crossOrigin) {
img.crossOrigin = null;
img.src = done.previewUrl || done.fileUrl || img.src;
return;
}
if (done.fileUrl && img.src !== done.fileUrl) {
img.src = done.fileUrl;
}
}}
/>
</div>
) : null}
<div className="flex flex-col gap-2">
<button
type="button"
onClick={resetForNew}
className="inline-flex h-11 w-full items-center justify-center rounded-lg bg-[#D32F2F] px-4 text-sm font-semibold text-white hover:bg-[#b71c1c]"
>
Create another
</button>
{allowDownload ? (
<button
type="button"
onClick={() => void onDownload()}
className="inline-flex h-11 w-full items-center justify-center gap-2 rounded-lg border border-[#ddd] bg-white px-4 text-sm font-semibold text-[#333] hover:bg-[#fafafa]"
>
<Download className="h-4 w-4" />
Download photo
</button>
) : null}
</div>
{showShare ? (
<div className="mt-4">
<p className="mb-2 text-center text-xs font-semibold uppercase tracking-wide text-[#888]">
Share
</p>
{useNativeShare ? (
<button
type="button"
onClick={() => void onNativeShare()}
className="inline-flex h-11 w-full items-center justify-center gap-2 rounded-lg border border-[#ddd] bg-white px-4 text-sm font-semibold text-[#333] hover:bg-[#fafafa]"
>
<Share2 className="h-4 w-4" />
Share
</button>
) : (
<div className="grid grid-cols-2 gap-2">
{enabledSharePlatforms.map((item) => (
<button
key={item.key}
type="button"
onClick={() => void onShare(item.key)}
className="inline-flex h-10 items-center justify-center gap-2 rounded-lg border border-[#ddd] bg-white px-3 text-xs font-semibold text-[#333] hover:bg-[#fafafa]"
>
<ShareIcon platform={item.key} />
{item.label}
</button>
))}
</div>
)}
</div>
) : null}
{actionMsg ? <p className="mt-3 text-center text-xs text-[#666]">{actionMsg}</p> : null}
</WhiteCard>
</AuthShell>
);
}
return (
<AuthShell showLogo={false}>
<WhiteCard>
{meta?.event ? (
<div className="mb-4">
<PhotoWallEventHeader
event={meta.event}
display={meta.eventHeader || null}
eyebrow="AI Avatar"
/>
</div>
) : null}
{meta?.accessMode === "login" && !loggedIn ? (
<div className="space-y-3">
<h1 className="mb-1 text-center text-[22px] font-semibold text-[#1a1a1a]">AI Avatar</h1>
<p className="text-center text-sm text-[#666]">Sign in to create your AI avatar.</p>
<button
type="button"
onClick={() => void goLogin()}
className="inline-flex h-11 w-full items-center justify-center rounded-lg bg-[#D32F2F] px-4 text-sm font-semibold text-white hover:bg-[#b71c1c]"
>
Sign in to continue
</button>
</div>
) : !selectedAvatar ? (
<div className="space-y-4">
<h1 className="mb-1 text-center text-[22px] font-semibold text-[#1a1a1a]">Choose an avatar</h1>
<p className="text-center text-sm text-[#666]">Pick an avatar, then upload your selfie.</p>
{!meta?.avatars?.length ? (
<p className="text-center text-sm text-[#666]">No avatars available yet.</p>
) : (
<div className="grid grid-cols-2 gap-3">
{meta.avatars.map((avatar) => (
<button
key={avatar._id}
type="button"
onClick={() => setSelectedAvatar(avatar)}
className="overflow-hidden rounded-lg border border-[#e5e5e5] bg-[#f8f8f8] text-left hover:border-[#D32F2F]"
>
<div className="aspect-square w-full bg-white">
{avatar.imageUrl ? (
<img
src={avatar.imageUrl}
alt={avatar.name}
className="h-full w-full object-contain"
/>
) : (
<div className="flex h-full items-center justify-center text-xs text-[#999]">
No preview
</div>
)}
</div>
<p className="truncate px-2 py-2 text-center text-xs font-semibold text-[#333]">
{avatar.name}
</p>
</button>
))}
</div>
)}
{err ? <p className="text-center text-sm text-red-600">{err}</p> : null}
</div>
) : (
<div className="space-y-4">
<button
type="button"
onClick={() => {
setSelectedAvatar(null);
clearSelectedPhoto();
setErr(null);
}}
className="text-sm font-semibold text-[#D32F2F]"
>
← Change avatar
</button>
<h1 className="mb-1 text-center text-[22px] font-semibold text-[#1a1a1a]">Upload selfie</h1>
<p className="text-center text-sm text-[#666]">
Avatar: <span className="font-semibold text-[#333]">{selectedAvatar.name}</span>
</p>
<input
ref={fileRef}
type="file"
accept="image/*"
capture="user"
className="hidden"
onChange={(e) => void handleFileChange(e.target.files?.[0] || null)}
/>
{previewUrl ? (
<div className="overflow-hidden rounded-lg border border-[#e5e5e5] bg-[#f8f8f8]">
<img src={previewUrl} alt="Your selfie" className="mx-auto max-h-[240px] w-full object-contain" />
</div>
) : null}
<button
type="button"
disabled={picking}
onClick={() => fileRef.current?.click()}
className="inline-flex h-11 w-full items-center justify-center rounded-lg border border-[#ddd] bg-white px-4 text-sm font-semibold text-[#333] hover:bg-[#fafafa] disabled:opacity-55"
>
{picking ? "Processing…" : file ? "Change selfie" : "Choose selfie"}
</button>
<button
type="button"
disabled={!file || submitting}
onClick={() => void onSubmit()}
className="inline-flex h-11 w-full items-center justify-center rounded-lg bg-[#D32F2F] px-4 text-sm font-semibold text-white hover:bg-[#b71c1c] disabled:opacity-55"
>
{submitting ? "Creating avatar…" : "Create avatar"}
</button>
{err ? <p className="text-center text-sm text-red-600">{err}</p> : null}
</div>
)}
</WhiteCard>
{cropSession && cropTarget ? (
<PhotoWallCropModal
imageSrc={cropSession.imageSrc}
sourceFileName={cropSession.sourceFileName}
aspect={cropTarget.w / cropTarget.h}
onCancel={() => {
URL.revokeObjectURL(cropSession.imageSrc);
setCropSession(null);
}}
onConfirm={(cropped) => {
URL.revokeObjectURL(cropSession.imageSrc);
setCropSession(null);
applySelectedFile(cropped);
}}
/>
) : null}
</AuthShell>
);
}

View File

@ -4,7 +4,7 @@ import { AuthShell } from "../components/auth/AuthShell";
import { normalizeJoinCodeInput, resolveJoinCodeAndNavigate } from "../lib/joinCodeNavigation";
/**
* Handles https://join.eventbuzz.live/code/{code} — same resolve flow as the homepage join strip.
* Handles https://join.eventbuzz.live/code/{code} — same resolve flow as the homepage join form.
*/
export function CodeEntryPage() {
const { code: codeParam } = useParams<{ code: string }>();

View File

@ -3,6 +3,7 @@ import { Link, useSearchParams } from "react-router-dom";
import { HeaderMobile } from "../components/HeaderMobile";
import { MobileShell } from "../components/MobileShell";
import { DynamicFeedbackForm } from "../components/DynamicFeedbackForm";
import { useAutoNavigateToLiveQuizPoll } from "../hooks/useAutoNavigateToLiveQuizPoll";
import { useEventRegistrationSettings } from "../hooks/useEventRegistrationSettings";
import {
getVisitorFeedbackFormSchema,
@ -21,6 +22,12 @@ export function FeedbackPage() {
const eventId = urlEventId || ctx?.eventId || "";
const sessionId = urlSessionId || ctx?.sessionId || "";
useAutoNavigateToLiveQuizPoll({
eventId,
sessionId,
enabled: Boolean(eventId && sessionId),
});
const [eventSnapshot, setEventSnapshot] = useState(
ctx?.eventId === eventId && ctx.event ? ctx.event : null,
);

View File

@ -1,418 +1,71 @@
import { useCallback, useState, type ReactNode } from "react";
import {
BarChart3,
CalendarClock,
ClipboardList,
Facebook,
Gamepad2,
Mail,
MessageCircle,
MessageCircleQuestion,
Network,
Phone,
Youtube,
} from "lucide-react";
import { Link } from "react-router-dom";
import aboutBanner from "../assets/about-banner.png";
import { JoinCodeTopStrip } from "../components/marketing/JoinCodeTopStrip";
import { MarketingFooter } from "../components/marketing/MarketingFooter";
import { MarketingHeader } from "../components/marketing/MarketingHeader";
import { MarketingHeroSection } from "../components/marketing/MarketingHeroSection";
import {
MARKETING_HEADER_SCROLL_MARGIN,
scrollToId,
shellX,
} from "../components/marketing/marketingLayout";
import { PORTAL_LOGIN_URL, PORTAL_REGISTER_URL } from "../constants/portalUrls";
import { Ticket } from "lucide-react";
import { AuthShell } from "../components/auth/AuthShell";
import { useJoinEventCode } from "../hooks/useJoinEventCode";
const featureItems = [
{
title: "Live Q&A",
body: "Engage your audience with real-time questions and answers during sessions.",
Icon: MessageCircleQuestion,
},
{
title: "Quizzes",
body: "Make learning fun with interactive quizzes.",
Icon: ClipboardList,
},
{
title: "Gamification",
body: "Boosts event engagement by turning participation into a rewarding experience.",
Icon: Gamepad2,
},
{
title: "Interactive Polls",
body: "Capture instant feedback with live polls and see results in real-time.",
Icon: BarChart3,
},
{
title: "Event Agenda",
body: "Keep attendees informed with detailed session schedules and speaker info.",
Icon: CalendarClock,
},
{
title: "Multi-Track Events",
body: "Manage complex events with multiple tracks and concurrent sessions.",
Icon: Network,
},
] as const;
/** Marketing homepage (default route `/`) */
/** Join entry page (default route `/`) — enter event or session code. */
export function Home() {
const [captcha, setCaptcha] = useState(() => randomCaptcha());
const [captchaInput, setCaptchaInput] = useState("");
const reloadCaptcha = useCallback(() => {
setCaptcha(randomCaptcha());
setCaptchaInput("");
}, []);
function handleContactSubmit(e: React.FormEvent) {
e.preventDefault();
reloadCaptcha();
}
const { code, setCode, submitting, joinError, handleJoin } = useJoinEventCode();
return (
<div className="min-h-dvh w-full bg-white text-[#24282f]" data-name="Home">
<MarketingHeader />
<AuthShell>
<div className="flex w-full max-w-[354px] flex-col items-center">
<div className="w-full overflow-hidden rounded-2xl bg-white shadow-[0_8px_12px_6px_rgba(0,0,0,0.15),0_4px_4px_rgba(0,0,0,0.3)]">
<div className="h-1.5 w-full bg-gradient-to-r from-[#34A7F2] via-[#1D66EF] to-[#ef8d1d]" aria-hidden />
<JoinCodeTopStrip />
<MarketingHeroSection />
{/* Features */}
<section
id="features"
className={`${MARKETING_HEADER_SCROLL_MARGIN} mx-auto w-full max-w-[1728px] py-10 lg:py-20 ${shellX}`}
data-name="Banner_02_Mobile"
>
<div className="space-y-9 lg:space-y-12">
<div className="mx-auto max-w-4xl space-y-4 text-center lg:mx-0 lg:max-w-none lg:text-left">
<h2 className="text-[clamp(1.5rem,4vw,3.5rem)] font-light italic leading-tight tracking-wide lg:leading-[68px]">
Everything you need to create{" "}
<span className="font-bold not-italic">engaging</span>
{" and "}
<span className="font-bold not-italic">interactive</span> events
</h2>
<p className="text-lg font-light leading-snug tracking-wide text-[#24282f] lg:max-w-[804px] lg:text-[22px] lg:leading-[27px]">
Our platform enables real-time interaction through live Q&A sessions, instant polls,
engaging quizzes, and much more.
</p>
</div>
<ul className="grid grid-cols-1 gap-4 md:grid-cols-2 md:gap-6 lg:gap-8">
{featureItems.map(({ title, body, Icon }) => (
<li
key={title}
className="flex gap-4 rounded-xl border border-[#1d66ef] bg-white p-4 shadow-sm lg:gap-6 lg:rounded-3xl lg:px-8 lg:py-6"
<form onSubmit={handleJoin} className="flex flex-col gap-6 px-6 pb-8 pt-7">
<div className="flex flex-col items-center gap-3 text-center">
<div
className="flex size-12 items-center justify-center rounded-full"
style={{
background: "linear-gradient(135deg, rgba(52,167,242,0.12) 0%, rgba(29,102,239,0.16) 100%)",
}}
aria-hidden
>
<div className="flex size-12 shrink-0 items-center justify-center rounded-lg bg-[#f2f2f7] lg:size-[88px] lg:rounded-2xl lg:p-6">
<Icon className="size-6 text-[#1d66ef] lg:size-14" strokeWidth={1.5} />
</div>
<div className="min-w-0 space-y-1 lg:space-y-6">
<h3 className="font-inter text-base font-semibold tracking-wide lg:text-2xl">
{title}
</h3>
<p className="text-sm font-light leading-snug tracking-wide lg:text-[22px] lg:leading-[27px]">
{body}
</p>
</div>
</li>
))}
</ul>
</div>
</section>
{/* About + stats */}
<section
id="about"
className={`${MARKETING_HEADER_SCROLL_MARGIN} bg-[#f2f2f7] py-10 lg:py-20`}
data-name="Banner_03_Mobile"
>
<div className={`mx-auto w-full max-w-[1728px] space-y-9 lg:space-y-12 ${shellX}`}>
<div className="mx-auto max-w-4xl space-y-4 lg:mx-0 lg:max-w-4xl">
<h2 className="text-[clamp(1.5rem,4vw,3rem)] font-light italic leading-tight tracking-wide">
About event<span className="font-bold not-italic">buzz</span>
</h2>
<p className="text-lg font-light leading-snug tracking-wide lg:text-[22px] lg:leading-[27px]">
EventBuzz is a comprehensive event engagement platform designed to transform how
organizers connect with their audience. Whether you&apos;re hosting a conference,
workshop, training session, or corporate event, EventBuzz provides the tools you need
to create memorable experiences.
</p>
<p className="text-base font-medium leading-snug tracking-wide lg:text-lg">
Our platform enables real-time interaction through live Q&A sessions, instant polls,
engaging quizzes, and much more.
</p>
</div>
<div
className="relative mx-auto max-w-[1439px] overflow-hidden rounded-[40px] sm:rounded-[60px]"
data-name="About_Banner"
>
<div className="relative aspect-[16/10] min-h-[280px] w-full lg:aspect-[1439/494] lg:min-h-[400px]">
<img
src={aboutBanner}
alt=""
className="absolute inset-0 size-full object-cover object-center"
/>
<div className="absolute inset-0 bg-black/55" />
<Ticket className="size-6 text-[#1D66EF]" strokeWidth={1.75} />
</div>
<div className="space-y-1.5">
<h1 className="font-inter text-[22px] font-bold leading-tight text-[#24282f]">
Join with your code
</h1>
<p className="font-inter text-sm leading-relaxed text-[rgba(36,40,47,0.72)]">
Enter the code provided by the event organiser — you’ll usually find it in your
invite, on the event screen, or on a poster at the venue.
</p>
</div>
</div>
<div className="absolute inset-0 flex flex-col justify-end gap-4 p-6 pb-8 lg:flex-row lg:items-end lg:justify-center lg:gap-6 lg:px-12 lg:pb-16">
{[
{ n: "1000+", l: "Events Hosted" },
{ n: "50K+", l: "Attendees" },
{ n: "96%", l: "Satisfaction" },
].map((row) => (
<div
key={row.l}
className="flex items-center gap-4 rounded-2xl bg-white/20 px-6 py-3 text-[#f1f1f1] shadow-[0_6px_10px_rgba(0,0,0,0.15),0_2px_3px_rgba(0,0,0,0.3)] lg:flex-col lg:items-center lg:gap-2 lg:px-10 lg:py-4 lg:text-center"
>
<span className="text-3xl font-light italic tracking-wide sm:text-[40px] lg:text-[60px] lg:leading-[84px]">
{row.n}
</span>
<span className="text-base font-semibold italic tracking-wide lg:text-[22px] lg:leading-[27px]">
{row.l}
</span>
</div>
))}
</div>
</div>
</div>
</section>
{/* Contact intro (dark) */}
<section className="bg-[#24282f] px-4 py-8 text-white sm:px-6 lg:py-20" data-name="Contact Us">
<div className={`mx-auto w-full max-w-[1728px] space-y-6 lg:space-y-9 ${shellX}`}>
<h2 className="text-[clamp(1.75rem,4vw,4.75rem)] font-light italic leading-tight tracking-wide lg:leading-[84px]">
Contact Us
</h2>
<p className="max-w-4xl text-lg font-light leading-snug tracking-wide text-white/95 lg:max-w-[1067px] lg:text-[22px] lg:leading-[27px]">
Have questions? We&apos;d love to hear from you. Send us a message!
</p>
</div>
</section>
{/* Form + Get in touch */}
<section
id="contact"
className={`${MARKETING_HEADER_SCROLL_MARGIN} mx-auto w-full max-w-[1728px] py-10 lg:py-12 ${shellX}`}
>
<div className="flex flex-col gap-10 lg:flex-row lg:items-start lg:gap-6 xl:gap-8">
<form
onSubmit={handleContactSubmit}
className="min-w-0 flex-1 space-y-6 lg:max-w-[704px] lg:space-y-6"
>
<Field label="Your Name" htmlFor="m-name" desktopLarge>
<label className="flex flex-col gap-2">
<span className="font-inter text-sm font-semibold text-[#24282f]">Code</span>
<input
id="m-name"
name="name"
defaultValue="John Smith"
className="h-[51px] w-full rounded-lg border border-[rgba(116,112,112,0.3)] bg-[rgba(157,157,172,0.07)] px-4 font-inter text-sm text-[#24282f] lg:h-[59px] lg:rounded-xl lg:p-4"
id="join-event-code"
type="text"
value={code}
onChange={(e) => setCode(e.target.value.toUpperCase())}
placeholder="e.g. ABC123"
maxLength={12}
autoComplete="off"
autoCapitalize="characters"
spellCheck={false}
className="h-[52px] w-full rounded-xl border border-[rgba(116,112,112,0.3)] bg-[rgba(157,157,172,0.07)] px-4 text-center font-inter text-base font-semibold uppercase tracking-[0.18em] text-[#24282f] outline-none transition-shadow focus:border-[#1D66EF]/55 focus:bg-white focus:shadow-[0_0_0_3px_rgba(29,102,239,0.12)] placeholder:font-normal placeholder:tracking-normal placeholder:text-[rgba(36,40,47,0.45)]"
/>
</Field>
<Field label="Your Email" htmlFor="m-email" desktopLarge>
<input
id="m-email"
name="email"
type="email"
defaultValue="john@email.com"
className="h-[51px] w-full rounded-lg border border-[rgba(116,112,112,0.3)] bg-[rgba(157,157,172,0.07)] px-4 font-inter text-sm text-[#24282f] lg:h-[59px] lg:rounded-xl lg:p-4"
/>
</Field>
<Field label="Message" htmlFor="m-msg" desktopLarge>
<textarea
id="m-msg"
name="message"
rows={4}
placeholder="Tell us about your event needs..."
className="w-full rounded-lg border border-[rgba(116,112,112,0.3)] bg-[rgba(157,157,172,0.07)] px-4 py-3 font-inter text-sm text-[#24282f] placeholder:text-[#24282f]/50 lg:rounded-xl lg:p-4"
/>
</Field>
<div className="space-y-2">
<p className="font-inter text-base font-semibold lg:text-2xl">Captcha Code</p>
<CaptchaRow
captcha={captcha}
value={captchaInput}
onChange={setCaptchaInput}
onReload={reloadCaptcha}
/>
</div>
</label>
{joinError ? (
<p className="-mt-2 text-center text-sm text-red-600" role="alert">
{joinError}
</p>
) : null}
<button
type="submit"
className="w-full rounded-full border border-[rgba(214,200,200,0.6)] bg-[#ef8d1d] py-3 text-xs font-semibold uppercase tracking-wide text-white shadow-[0_4px_4px_rgba(0,0,0,0.15)] lg:w-auto lg:px-8 lg:py-4 lg:text-lg"
disabled={submitting || !code.trim()}
className="w-full rounded-full border border-[rgba(214,200,200,0.6)] bg-[#ef8d1d] py-3.5 text-xs font-semibold uppercase tracking-wide text-white shadow-[0_4px_4px_rgba(0,0,0,0.15)] transition-opacity hover:opacity-95 disabled:opacity-60"
>
Send Message
{submitting ? "Joining…" : "Join"}
</button>
</form>
<div className="w-full shrink-0 space-y-8 rounded-3xl bg-[#f2f2f7] p-4 lg:max-w-[464px] lg:self-stretch lg:p-10">
<div>
<h3 className="font-inter text-base font-semibold lg:text-[22px]">Get in Touch</h3>
<p className="mt-2 text-sm leading-normal lg:text-lg">
Have questions? We&apos;d love to hear from you. Send us a message!
</p>
</div>
<div className="flex gap-4">
<div className="flex size-14 shrink-0 items-center justify-center rounded-xl bg-[#ef8d1d] text-white">
<Phone className="size-6" strokeWidth={1.75} />
</div>
<div>
<p className="font-inter text-base font-semibold lg:text-2xl">Phone</p>
<a href="tel:+919923121245" className="text-sm hover:underline lg:text-lg">
+91 99231 21245
</a>
</div>
</div>
<div className="flex gap-4">
<div className="flex size-14 shrink-0 items-center justify-center rounded-xl bg-[#ef8d1d] text-white">
<Mail className="size-6" strokeWidth={1.75} />
</div>
<div>
<p className="font-inter text-base font-semibold lg:text-2xl">Email</p>
<a href="mailto:hello@eventbuzz.live" className="text-sm hover:underline lg:text-lg">
hello@eventbuzz.live
</a>
</div>
</div>
<div>
<p className="font-inter text-base font-semibold lg:text-[22px]">Follow Us</p>
<div className="mt-4 flex gap-4">
<a
href="https://facebook.com"
target="_blank"
rel="noreferrer"
className="flex size-10 items-center justify-center rounded-full bg-white text-[#24282f] shadow-sm hover:opacity-90 lg:size-14"
aria-label="Facebook"
>
<Facebook className="size-5 lg:size-7" strokeWidth={1.5} />
</a>
<a
href="https://youtube.com"
target="_blank"
rel="noreferrer"
className="flex size-10 items-center justify-center rounded-full bg-white text-[#24282f] shadow-sm hover:opacity-90 lg:size-14"
aria-label="YouTube"
>
<Youtube className="size-5 lg:size-7" strokeWidth={1.5} />
</a>
<a
href="https://wa.me/"
target="_blank"
rel="noreferrer"
className="flex size-10 items-center justify-center rounded-full bg-[#25D366] text-white shadow-sm hover:opacity-90 lg:size-14"
aria-label="WhatsApp"
>
<MessageCircle className="size-5 lg:size-7" strokeWidth={1.75} />
</a>
</div>
</div>
</div>
</div>
</section>
{/* Bottom CTA */}
<section
className="eb-gradient-auth px-4 py-8 text-white sm:px-6 lg:py-20"
data-name="Trial_Mobile"
>
<div
className={`mx-auto flex w-full max-w-[1728px] flex-col gap-8 lg:items-center lg:text-center ${shellX}`}
>
<div className="space-y-4 lg:max-w-4xl lg:space-y-4">
<h2 className="text-[clamp(1.5rem,4vw,2.75rem)] font-light italic leading-tight tracking-wide lg:text-[45px] lg:leading-[84px]">
Ready to Engage Your Audience?
</h2>
<p className="text-lg font-light leading-snug tracking-wide lg:text-[22px] lg:leading-[27px]">
Get started with EventBuzz today and transform your events into interactive
experiences.
</p>
</div>
<div className="flex flex-col gap-3 sm:flex-row sm:flex-wrap lg:justify-center">
<a
href={PORTAL_REGISTER_URL}
className="rounded-full border border-[rgba(214,200,200,0.6)] bg-white px-[18px] py-3 text-center text-xs font-semibold uppercase tracking-wide text-[#24282f] shadow-[0_4px_4px_rgba(0,0,0,0.15)] lg:px-8 lg:py-4 lg:text-lg"
>
Start free trial
</a>
<button
type="button"
onClick={() => scrollToId("contact")}
className="rounded-full border border-white/60 px-[18px] py-3 text-xs font-semibold uppercase tracking-wide text-white shadow-[0_4px_4px_rgba(0,0,0,0.15)] lg:px-8 lg:py-4 lg:text-lg"
>
Contact us
</button>
</div>
</div>
</section>
<MarketingFooter />
</div>
);
}
function Field({
label,
htmlFor,
children,
desktopLarge,
}: {
label: string;
htmlFor: string;
children: ReactNode;
desktopLarge?: boolean;
}) {
return (
<div className="space-y-2">
<label
htmlFor={htmlFor}
className={`font-inter font-semibold ${desktopLarge ? "text-base lg:text-2xl" : "text-base"}`}
>
{label}
</label>
{children}
</div>
);
}
function randomCaptcha() {
const chars = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789";
return Array.from({ length: 4 }, () => chars[Math.floor(Math.random() * chars.length)]).join(
" ",
);
}
function CaptchaRow({
captcha,
value,
onChange,
onReload,
}: {
captcha: string;
value: string;
onChange: (v: string) => void;
onReload: () => void;
}) {
return (
<div className="grid grid-cols-1 gap-2 lg:grid-cols-2 lg:gap-6">
<div className="flex min-h-[52px] items-center justify-between gap-3 rounded-lg border border-[rgba(116,112,112,0.3)] bg-[rgba(157,157,172,0.07)] px-4 lg:min-h-[60px] lg:rounded-xl lg:px-4">
<span className="font-mono text-lg font-semibold tracking-[0.35em] text-[#24282f]">
{captcha}
</span>
<button
type="button"
onClick={onReload}
className="shrink-0 rounded-full bg-[#24282f] px-6 py-2 text-xs font-semibold uppercase tracking-wide text-white lg:text-base"
>
Reload
</button>
</div>
<input
value={value}
onChange={(e) => onChange(e.target.value)}
placeholder="Enter Captcha"
className="h-[51px] w-full rounded-lg border border-[rgba(116,112,112,0.3)] bg-[rgba(157,157,172,0.07)] px-4 font-inter text-sm text-[#24282f] lg:h-[59px] lg:self-end lg:rounded-xl lg:p-4"
aria-label="Enter captcha"
/>
</div>
</AuthShell>
);
}

View File

@ -0,0 +1,472 @@
import {
useCallback,
useEffect,
useMemo,
useRef,
useState,
type ReactNode,
} from "react";
import {
Download,
Facebook,
Instagram,
Linkedin,
Search,
Share2,
} from "lucide-react";
import { useParams } from "react-router-dom";
import { AuthShell } from "../components/auth/AuthShell";
import { PhotoWallEventHeader } from "../components/PhotoWallEventHeader";
import {
getPhotoSearchMeta,
getVisitorLoginPreview,
searchPhotoSearchMatches,
type PhotoSearchMatch,
type PhotoSearchMeta,
type PhotoWallSharePlatforms,
} from "../lib/api";
import { getAttendeeParticipant, getAttendeeToken } from "../lib/attendeeAuth";
import {
canUseNativeShareSheet,
downloadImageFromUrl,
fetchImageFile,
sharePhotoWallImage,
shareViaNativeSheet,
} from "../lib/photoWallShare";
import {
EB_VISITOR_CONTEXT_KEY,
setPostAuthReturnPath,
type EbVisitorContext,
} from "../types/visitorContext";
function WhiteCard({ children, wide }: { children: ReactNode; wide?: boolean }) {
return (
<div className={`flex w-full ${wide ? "max-w-[480px]" : "max-w-[354px]"} flex-col items-center`}>
<div className="w-full rounded-xl shadow-[0_8px_12px_6px_rgba(0,0,0,0.15),0_4px_4px_rgba(0,0,0,0.3)]">
<div className="rounded-xl bg-white px-6 py-6">{children}</div>
</div>
</div>
);
}
const SHARE_LABELS: Array<{ key: keyof PhotoWallSharePlatforms; label: string }> = [
{ key: "linkedin", label: "LinkedIn" },
{ key: "facebook", label: "Facebook" },
{ key: "instagram", label: "Instagram" },
{ key: "snapchat", label: "Snapchat" },
{ key: "x", label: "X" },
{ key: "whatsapp", label: "WhatsApp" },
];
function ShareIcon({ platform }: { platform: keyof PhotoWallSharePlatforms }) {
if (platform === "linkedin") return <Linkedin className="h-4 w-4" />;
if (platform === "facebook") return <Facebook className="h-4 w-4" />;
if (platform === "instagram") return <Instagram className="h-4 w-4" />;
return <Share2 className="h-4 w-4" />;
}
function MatchCard({
match,
eventName,
allowDownload,
showShare,
useNativeShare,
enabledSharePlatforms,
}: {
match: PhotoSearchMatch;
eventName: string;
allowDownload: boolean;
showShare: boolean;
useNativeShare: boolean;
enabledSharePlatforms: Array<{ key: keyof PhotoWallSharePlatforms; label: string }>;
}) {
const imgRef = useRef<HTMLImageElement>(null);
const [shareFile, setShareFile] = useState<File | null>(null);
const [actionMsg, setActionMsg] = useState<string | null>(null);
const caption = `Check out my photo from ${eventName}!`;
/** Grid preview uses CDN thumb (`url`) only — never the API file proxy. */
const previewSrc = match.url || undefined;
const downloadSrc = match.fileUrl || match.url || undefined;
useEffect(() => {
if (!match.fileUrl) return;
let cancelled = false;
void fetchImageFile(match.fileUrl, match.originalFileName || "event-photo.jpg")
.then((f) => {
if (!cancelled) setShareFile(f);
})
.catch(() => {
/* ignore — share can still try URL / canvas */
});
return () => {
cancelled = true;
};
}, [match.fileUrl, match.originalFileName]);
const onDownload = async () => {
setActionMsg(null);
try {
if (shareFile) {
const objectUrl = URL.createObjectURL(shareFile);
const a = document.createElement("a");
a.href = objectUrl;
a.download = shareFile.name || "event-photo.jpg";
document.body.appendChild(a);
a.click();
a.remove();
URL.revokeObjectURL(objectUrl);
setActionMsg("Download started");
return;
}
if (!downloadSrc) return;
await downloadImageFromUrl(downloadSrc, match.originalFileName || "event-photo.jpg");
setActionMsg("Download started");
} catch (e) {
setActionMsg(e instanceof Error ? e.message : "Download failed");
}
};
const onNativeShare = async () => {
setActionMsg(null);
try {
const result = await shareViaNativeSheet({
file: shareFile,
imageUrl: downloadSrc,
imgEl: imgRef.current,
title: eventName,
text: caption,
});
if (result === "shared") setActionMsg("Shared");
else setActionMsg(null);
} catch (e) {
const msg = e instanceof Error ? e.message : "Share failed";
setActionMsg(
/load failed|failed to fetch/i.test(msg)
? "Could not share this photo. Try Download, then share from your photos."
: msg,
);
}
};
const onShare = async (platform: keyof PhotoWallSharePlatforms) => {
if (!downloadSrc && !shareFile) return;
setActionMsg(null);
try {
const result = await sharePhotoWallImage({
platform,
imageUrl: downloadSrc || "",
file: shareFile,
title: eventName,
text: caption,
});
if (result === "shared") {
setActionMsg(
platform === "whatsapp" || platform === "facebook" || platform === "x" || platform === "linkedin"
? "Opening app with your photo link"
: "Shared",
);
} else if (result === "cancelled") {
setActionMsg(null);
} else {
setActionMsg("Photo saved and caption copied — paste / attach it in the app");
}
} catch (e) {
setActionMsg(e instanceof Error ? e.message : "Share failed");
}
};
return (
<div className="overflow-hidden rounded-lg border border-[#e5e5e5] bg-[#f8f8f8]">
<img
ref={imgRef}
crossOrigin="anonymous"
src={previewSrc || undefined}
alt={match.originalFileName || "Matched photo"}
className="mx-auto max-h-[220px] w-full object-contain bg-white"
onError={(e) => {
const img = e.currentTarget;
// Retry without CORS for CDN; do not switch to API file proxy for display.
if (img.crossOrigin) {
img.crossOrigin = null;
if (previewSrc) img.src = previewSrc;
}
}}
/>
<div className="space-y-2 p-3">
{allowDownload ? (
<button
type="button"
onClick={() => void onDownload()}
className="inline-flex h-9 w-full items-center justify-center gap-2 rounded-lg border border-[#ddd] bg-white px-3 text-xs font-semibold text-[#333] hover:bg-[#fafafa]"
>
<Download className="h-3.5 w-3.5" />
Download
</button>
) : null}
{showShare ? (
useNativeShare ? (
<button
type="button"
onClick={() => void onNativeShare()}
className="inline-flex h-9 w-full items-center justify-center gap-2 rounded-lg border border-[#ddd] bg-white px-3 text-xs font-semibold text-[#333] hover:bg-[#fafafa]"
>
<Share2 className="h-3.5 w-3.5" />
Share
</button>
) : (
<div className="grid grid-cols-2 gap-1.5">
{enabledSharePlatforms.map((item) => (
<button
key={item.key}
type="button"
onClick={() => void onShare(item.key)}
className="inline-flex h-8 items-center justify-center gap-1 rounded-lg border border-[#ddd] bg-white px-2 text-[11px] font-semibold text-[#333] hover:bg-[#fafafa]"
>
<ShareIcon platform={item.key} />
{item.label}
</button>
))}
</div>
)
) : null}
{actionMsg ? <p className="text-center text-[11px] text-[#666]">{actionMsg}</p> : null}
</div>
</div>
);
}
export function PhotoSearchPage() {
const { searchToken } = useParams<{ searchToken: string }>();
const fileRef = useRef<HTMLInputElement>(null);
const [meta, setMeta] = useState<PhotoSearchMeta | null>(null);
const [loading, setLoading] = useState(true);
const [err, setErr] = useState<string | null>(null);
const [file, setFile] = useState<File | null>(null);
const [previewUrl, setPreviewUrl] = useState<string | null>(null);
const [searching, setSearching] = useState(false);
const [matches, setMatches] = useState<PhotoSearchMatch[] | null>(null);
const [searched, setSearched] = useState(false);
const load = useCallback(async () => {
if (!searchToken) return;
setLoading(true);
setErr(null);
try {
const data = await getPhotoSearchMeta(searchToken);
setMeta(data);
} catch (e) {
setMeta(null);
setErr(e instanceof Error ? e.message : "Photo search not available");
} finally {
setLoading(false);
}
}, [searchToken]);
useEffect(() => {
void load();
}, [load]);
useEffect(() => {
return () => {
if (previewUrl) URL.revokeObjectURL(previewUrl);
};
}, [previewUrl]);
const loggedIn = Boolean(getAttendeeToken() && getAttendeeParticipant());
const enabledSharePlatforms = useMemo(
() => SHARE_LABELS.filter((item) => Boolean(meta?.sharePlatforms?.[item.key])),
[meta?.sharePlatforms],
);
const useNativeShare = useMemo(() => canUseNativeShareSheet(), []);
const showShare = enabledSharePlatforms.length > 0;
const allowDownload = meta?.allowDownload !== false;
const clearFile = () => {
setFile(null);
setPreviewUrl((prev) => {
if (prev) URL.revokeObjectURL(prev);
return null;
});
if (fileRef.current) fileRef.current.value = "";
};
const onPick = (selected: File | null) => {
if (!selected) {
clearFile();
return;
}
if (!selected.type.startsWith("image/")) {
setErr("Please choose an image file");
return;
}
setErr(null);
setMatches(null);
setSearched(false);
setFile(selected);
setPreviewUrl((prev) => {
if (prev) URL.revokeObjectURL(prev);
return URL.createObjectURL(selected);
});
};
const onSearch = async () => {
if (!searchToken || !file || !meta) return;
setSearching(true);
setErr(null);
try {
const result = await searchPhotoSearchMatches(searchToken, file);
setMatches(result.matches);
setSearched(true);
} catch (e) {
setMatches(null);
setSearched(false);
setErr(e instanceof Error ? e.message : "Search failed");
} finally {
setSearching(false);
}
};
const goLogin = async () => {
if (!meta?.eventId) return;
try {
const preview = await getVisitorLoginPreview(meta.eventId);
const ctx: EbVisitorContext = {
type: "event",
eventId: meta.eventId,
event: preview,
fetchedAt: new Date().toISOString(),
};
localStorage.setItem(EB_VISITOR_CONTEXT_KEY, JSON.stringify(ctx));
setPostAuthReturnPath(`/photo-search/${searchToken}`);
window.location.assign(`/login?type=event&id=${encodeURIComponent(meta.eventId)}`);
} catch {
setErr("Could not open login");
}
};
if (!searchToken) {
return (
<AuthShell showLogo={false}>
<WhiteCard>
<p className="text-center text-sm text-red-600">Missing search link</p>
</WhiteCard>
</AuthShell>
);
}
if (loading) {
return (
<AuthShell showLogo={false}>
<WhiteCard>
<p className="text-center text-sm text-[#666]">Loading…</p>
</WhiteCard>
</AuthShell>
);
}
if (err && !meta) {
return (
<AuthShell showLogo={false}>
<WhiteCard>
<p className="text-center text-sm text-red-600">{err}</p>
</WhiteCard>
</AuthShell>
);
}
return (
<AuthShell showLogo={false}>
<WhiteCard wide={Boolean(matches && matches.length > 0)}>
{meta?.event ? (
<div className="mb-4">
<PhotoWallEventHeader
event={meta.event}
display={meta.eventHeader || null}
eyebrow="Photo Search"
/>
</div>
) : null}
<h1 className="mb-1 text-center text-[22px] font-semibold text-[#1a1a1a]">Find Photos</h1>
<p className="mb-5 text-center text-sm text-[#666]">
Upload a selfie to find photos of you from {meta?.eventName || "the event"}.
</p>
{meta?.accessMode === "login" && !loggedIn ? (
<div className="space-y-3">
<p className="text-center text-sm text-[#666]">Sign in to search for your photos.</p>
<button
type="button"
onClick={() => void goLogin()}
className="inline-flex h-11 w-full items-center justify-center rounded-lg bg-[#D32F2F] px-4 text-sm font-semibold text-white hover:bg-[#b71c1c]"
>
Sign in to continue
</button>
</div>
) : (
<div className="space-y-4">
<div>
<input
ref={fileRef}
type="file"
accept="image/*"
capture="user"
className="hidden"
onChange={(e) => onPick(e.target.files?.[0] || null)}
/>
{previewUrl ? (
<div className="mb-3 overflow-hidden rounded-lg border border-[#e5e5e5] bg-[#f8f8f8]">
<img src={previewUrl} alt="Your selfie" className="mx-auto max-h-[240px] w-full object-contain" />
</div>
) : null}
<button
type="button"
onClick={() => fileRef.current?.click()}
className="inline-flex h-11 w-full items-center justify-center gap-2 rounded-lg border border-[#ddd] bg-white px-4 text-sm font-semibold text-[#333] hover:bg-[#fafafa]"
>
{file ? "Change photo" : "Choose photo"}
</button>
</div>
<button
type="button"
disabled={!file || searching}
onClick={() => void onSearch()}
className="inline-flex h-11 w-full items-center justify-center gap-2 rounded-lg bg-[#D32F2F] px-4 text-sm font-semibold text-white hover:bg-[#b71c1c] disabled:opacity-55"
>
<Search className="h-4 w-4" />
{searching ? "Searching…" : "Find photos"}
</button>
{err ? <p className="text-center text-sm text-red-600">{err}</p> : null}
{searched ? (
<div className="space-y-3 pt-2">
<p className="text-center text-sm font-semibold text-[#333]">
{matches && matches.length > 0
? `${matches.length} match${matches.length === 1 ? "" : "es"}`
: "No matches found"}
</p>
{matches && matches.length > 0 ? (
<div className="grid grid-cols-2 gap-3">
{matches.map((m) => (
<MatchCard
key={m._id}
match={m}
eventName={meta?.eventName || "the event"}
allowDownload={allowDownload}
showShare={showShare}
useNativeShare={useNativeShare}
enabledSharePlatforms={enabledSharePlatforms}
/>
))}
</div>
) : null}
</div>
) : null}
</div>
)}
</WhiteCard>
</AuthShell>
);
}

View File

@ -0,0 +1,113 @@
import { useCallback, useEffect, useState } from "react";
import { useParams } from "react-router-dom";
import { getPhotoWallPresent, type PhotoWallPresentData } from "../lib/api";
const POLL_MS = 4000;
export function PhotoWallPresentPage() {
const { presentToken } = useParams<{ presentToken: string }>();
const [data, setData] = useState<PhotoWallPresentData | null>(null);
const [err, setErr] = useState<string | null>(null);
const [loading, setLoading] = useState(true);
const refresh = useCallback(async () => {
if (!presentToken) return;
try {
const next = await getPhotoWallPresent(presentToken);
setData(next);
setErr(null);
} catch (e) {
setErr(e instanceof Error ? e.message : "Photo wall not available");
setData(null);
} finally {
setLoading(false);
}
}, [presentToken]);
useEffect(() => {
void refresh();
const id = window.setInterval(() => void refresh(), POLL_MS);
return () => window.clearInterval(id);
}, [refresh]);
if (loading) {
return (
<div className="eb-gradient-auth flex min-h-dvh items-center justify-center text-white/80">
Loading wall…
</div>
);
}
if (!data) {
return (
<div className="eb-gradient-auth flex min-h-dvh flex-col items-center justify-center gap-2 px-6 text-center text-white">
<p className="text-lg font-semibold">Photo wall unavailable</p>
<p className="text-sm text-white/80">{err}</p>
</div>
);
}
const cols = Math.max(1, Math.min(12, data.photosPerRow || 4));
const maxW = data.maxPhotoWidth || 320;
const maxH = data.maxPhotoHeight || 320;
const gap = data.gapPx ?? 8;
const showNames = Boolean(data.showUploaderNameOnWall);
const frameEnabled = Boolean(data.frameEnabled);
const frameW = data.frameCanvasWidth || 1080;
const frameH = data.frameCanvasHeight || 1080;
const aspectRatio = frameEnabled ? `${frameW} / ${frameH}` : `${maxW} / ${maxH}`;
return (
<div className="eb-gradient-auth min-h-dvh text-white">
<div className="px-4 pb-3 pt-4 text-center">
<p className="text-xs uppercase tracking-[0.2em] text-white/70">Photo Wall</p>
<h1 className="font-inter text-xl font-semibold sm:text-2xl">{data.eventName}</h1>
</div>
{data.photos.length === 0 ? (
<div className="flex min-h-[60dvh] items-center justify-center px-6 text-center text-white/80">
Waiting for photos…
</div>
) : (
<div
className="mx-auto w-full max-w-[100vw] px-3 pb-6"
style={{
display: "grid",
gridTemplateColumns: `repeat(${cols}, minmax(0, 1fr))`,
gap: `${gap}px`,
justifyItems: "center",
alignItems: "start",
}}
>
{data.photos.map((p) => (
<div
key={p._id}
className="flex w-full flex-col items-center"
style={{ maxWidth: maxW }}
>
<img
src={p.url}
alt=""
className="w-full rounded-md object-cover shadow-lg"
style={{
maxWidth: maxW,
maxHeight: maxH,
aspectRatio,
objectFit: "cover",
}}
/>
{showNames && p.displayName ? (
<p
className="mt-1.5 w-full truncate text-center font-inter text-xs font-medium text-white/90 sm:text-sm"
title={p.displayName}
>
{p.displayName}
</p>
) : null}
</div>
))}
</div>
)}
</div>
);
}

View File

@ -0,0 +1,634 @@
import {
useCallback,
useEffect,
useMemo,
useRef,
useState,
type FormEvent,
type ReactNode,
} from "react";
import {
Download,
Facebook,
ImagePlus,
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 {
getPhotoWallUploadMeta,
getVisitorLoginPreview,
uploadPhotoWallSelfie,
type PhotoWallSharePlatforms,
type PhotoWallPublicFormField,
type PhotoWallUploadMeta,
} from "../lib/api";
import { getAttendeeParticipant, getAttendeeToken } from "../lib/attendeeAuth";
import { getImageNaturalSize, ratiosMatch } from "../lib/cropImage";
import {
canUseNativeShareSheet,
downloadImageFromUrl,
fetchImageFile,
ensureShareableImageFile,
sharePhotoWallImage,
shareViaNativeSheet,
} from "../lib/photoWallShare";
import {
EB_VISITOR_CONTEXT_KEY,
setPostAuthReturnPath,
type EbVisitorContext,
} from "../types/visitorContext";
function fieldKey(f: PhotoWallPublicFormField): string {
return String(f.name || f.label || "").trim();
}
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 UploadDoneState = {
awaitingApproval: boolean;
previewUrl: string | null;
/** CORS-safe API URL for download/share */
fileUrl: string | null;
framed: boolean;
};
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 PhotoWallUploadPage() {
const { uploadToken } = useParams<{ uploadToken: string }>();
const fileRef = useRef<HTMLInputElement>(null);
const resultImgRef = useRef<HTMLImageElement>(null);
const [meta, setMeta] = useState<PhotoWallUploadMeta | null>(null);
const [loading, setLoading] = useState(true);
const [err, setErr] = useState<string | null>(null);
const [file, setFile] = useState<File | null>(null);
const [previewUrl, setPreviewUrl] = useState<string | null>(null);
const [values, setValues] = useState<Record<string, unknown>>({});
const [uploaderName, setUploaderName] = useState("");
const [submitting, setSubmitting] = useState(false);
const [picking, setPicking] = useState(false);
const [done, setDone] = useState<UploadDoneState | null>(null);
const [actionMsg, setActionMsg] = useState<string | null>(null);
const [cropSession, setCropSession] = useState<CropSession | null>(null);
/** Cached bytes for share/download — avoids CORS/network fail when opening the OS sheet. */
const [shareFile, setShareFile] = useState<File | null>(null);
const load = useCallback(async () => {
if (!uploadToken) return;
setLoading(true);
setErr(null);
try {
const data = await getPhotoWallUploadMeta(uploadToken);
setMeta(data);
} catch (e) {
setMeta(null);
setErr(e instanceof Error ? e.message : "Photo wall 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]);
const fields = useMemo(
() => (meta?.form?.fields || []).filter((f) => f.label?.trim() && f.type !== "section"),
[meta],
);
const loggedIn = Boolean(getAttendeeToken() && getAttendeeParticipant());
const enabledSharePlatforms = useMemo(
() => SHARE_LABELS.filter((item) => Boolean(meta?.sharePlatforms?.[item.key])),
[meta?.sharePlatforms],
);
const useNativeShare = useMemo(() => canUseNativeShareSheet(), []);
const clearSelectedPhoto = useCallback(() => {
setFile(null);
setPreviewUrl((prev) => {
if (prev) URL.revokeObjectURL(prev);
return null;
});
if (fileRef.current) fileRef.current.value = "";
}, []);
useEffect(() => {
if (!done?.fileUrl) return;
let cancelled = false;
void fetchImageFile(done.fileUrl, "photo-wall.jpg")
.then((f) => {
if (!cancelled) setShareFile(f);
})
.catch(() => {
/* keep any local shareFile from upload */
});
return () => {
cancelled = true;
};
}, [done?.fileUrl]);
const resetForNewUpload = useCallback(() => {
setDone(null);
setShareFile(null);
setActionMsg(null);
setErr(null);
clearSelectedPhoto();
}, [clearSelectedPhoto]);
const applySelectedFile = useCallback((next: File) => {
setFile(next);
setPreviewUrl((prev) => {
if (prev) URL.revokeObjectURL(prev);
return URL.createObjectURL(next);
});
}, []);
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 {
const requireCrop = Boolean(meta?.requireUploadCrop);
if (!requireCrop) {
applySelectedFile(selected);
return;
}
const maxW = Math.max(1, Number(meta?.maxPhotoWidth) || 1080);
const maxH = Math.max(1, Number(meta?.maxPhotoHeight) || 1080);
const natural = await getImageNaturalSize(selected);
if (ratiosMatch(natural.width, natural.height, maxW, maxH)) {
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 (e: FormEvent) => {
e.preventDefault();
if (!uploadToken || !file || !meta) return;
if (meta.askUploaderName && !uploaderName.trim()) {
setErr("Please enter your name");
return;
}
setSubmitting(true);
setErr(null);
setActionMsg(null);
try {
const localFile = file;
const result = await uploadPhotoWallSelfie(uploadToken, file, values, {
displayName: meta.askUploaderName ? uploaderName.trim() : undefined,
});
// Keep local bytes immediately so Share works even if the API file proxy fails (CORS/network).
setShareFile(localFile);
setDone({
awaitingApproval: result.awaitingApproval,
previewUrl: result.previewUrl,
fileUrl: result.fileUrl,
framed: result.framed,
});
clearSelectedPhoto();
// Normalize HEIC/etc. in the background so iOS accepts the file in the OS share sheet.
void ensureShareableImageFile(localFile, "photo-wall.jpg").then((shareable) => {
setShareFile((prev) => {
// Don't clobber a framed file already prefetched from the API.
if (prev && prev !== localFile) return prev;
return shareable;
});
});
} catch (e2) {
setErr(e2 instanceof Error ? e2.message : "Upload failed");
} finally {
setSubmitting(false);
}
};
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 || "photo-wall.jpg";
document.body.appendChild(a);
a.click();
a.remove();
URL.revokeObjectURL(objectUrl);
setActionMsg("Download started");
return;
}
const src = done?.fileUrl || done?.previewUrl;
if (!src) return;
await downloadImageFromUrl(src, "photo-wall.jpg");
setActionMsg("Download started");
} catch (e) {
setActionMsg(e instanceof Error ? e.message : "Download failed");
}
};
const shareCaption = `Check out my photo from ${meta?.eventName || "the event"}!`;
const onNativeShare = async () => {
const src = done?.fileUrl || done?.previewUrl;
setActionMsg(null);
try {
// Pass cached File first so share runs without a prior network await (iOS gesture).
const result = await shareViaNativeSheet({
file: shareFile,
imageUrl: src,
imgEl: resultImgRef.current,
title: meta?.eventName || "Photo wall",
text: shareCaption,
});
if (result === "shared") setActionMsg("Shared");
else setActionMsg(null);
} catch (e) {
const msg = e instanceof Error ? e.message : "Share failed";
setActionMsg(
/load failed|failed to fetch/i.test(msg)
? "Could not share this photo. Try Download, then share from your photos."
: msg,
);
}
};
const onShare = async (platform: keyof PhotoWallSharePlatforms) => {
const src = done?.fileUrl || done?.previewUrl;
if (!src && !shareFile) return;
setActionMsg(null);
try {
const result = await sharePhotoWallImage({
platform,
imageUrl: src || "",
file: shareFile,
title: meta?.eventName || "Photo wall",
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(`/photo-wall/${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 upload 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) {
const canDownload = Boolean(meta?.allowFramedDownload && (done.fileUrl || done.previewUrl));
const showShare = Boolean((done.fileUrl || done.previewUrl) && enabledSharePlatforms.length > 0);
return (
<AuthShell showLogo={false}>
<WhiteCard>
{meta?.event ? (
<div className="mb-4">
<PhotoWallEventHeader event={meta.event} display={meta.eventHeader || null} />
</div>
) : null}
<h1 className="mb-2 text-center text-[22px] font-semibold text-[#1a1a1a]">
{done.framed ? "Your framed photo" : "Photo uploaded"}
</h1>
<p className="mb-4 text-center text-sm text-[#666]">
{done.awaitingApproval
? "Thanks! Your photo is waiting for approval."
: done.framed
? "Here’s your generated photo. You can edit, download, or share it."
: "Thanks! Your photo was added to the wall."}
</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={done.framed ? "Your framed photo" : "Uploaded photo"}
className="mx-auto max-h-[360px] w-full object-contain"
onError={(e) => {
const img = e.currentTarget;
// Retry without CORS mode if CDN blocks anonymous reads.
if (img.crossOrigin) {
img.crossOrigin = null;
img.src = done.previewUrl || done.fileUrl || img.src;
return;
}
if (done.fileUrl && img.src !== done.fileUrl) {
img.src = done.fileUrl;
}
}}
/>
</div>
) : null}
<div className="flex flex-col gap-2">
<button
type="button"
onClick={resetForNewUpload}
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]"
>
<ImagePlus className="h-4 w-4" />
Edit / Upload new photo
</button>
{canDownload ? (
<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} />
</div>
) : null}
<h1 className="mb-1 text-center text-[22px] font-semibold text-[#1a1a1a]">Upload your photo</h1>
<p className="mb-5 text-center text-sm text-[#666]">
{meta?.form?.description || "Add a selfie to the event photo wall."}
</p>
{meta?.accessMode === "login" && !loggedIn ? (
<div className="space-y-3">
<p className="text-center text-sm text-[#666]">Sign in to upload your photo for this event.</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>
) : (
<form onSubmit={(e) => void onSubmit(e)} className="space-y-4">
<div>
<input
ref={fileRef}
type="file"
accept="image/*"
capture="user"
className="hidden"
onChange={(e) => void handleFileChange(e.target.files?.[0] || null)}
/>
<button
type="button"
disabled={picking}
onClick={() => fileRef.current?.click()}
className="flex w-full flex-col items-center justify-center gap-2 rounded-lg border-2 border-dashed border-[#ccc] bg-[#fafafa] px-4 py-8 text-sm text-[#666] hover:border-[#D32F2F] hover:bg-[#fff5f5]"
>
{previewUrl ? (
<img src={previewUrl} alt="Preview" className="max-h-48 rounded-md object-contain" />
) : (
<>
<ImagePlus className="h-8 w-8 text-[#D32F2F]" />
<span>{picking ? "Preparing…" : "Tap to take or choose a photo"}</span>
</>
)}
</button>
{file ? (
<p className="mt-2 truncate text-center text-xs text-[#888]">{file.name}</p>
) : null}
</div>
{meta?.askUploaderName ? (
<label className="block text-sm">
<span className="mb-1 block font-medium text-[#333]">
Your name <span className="text-red-500">*</span>
</span>
<input
type="text"
value={uploaderName}
onChange={(e) => setUploaderName(e.target.value)}
className="h-10 w-full rounded-lg border border-[#ddd] px-3 text-sm outline-none focus:border-[#D32F2F]"
placeholder="Name shown on the wall"
required
/>
</label>
) : null}
{fields.map((f) => {
const key = fieldKey(f);
if (!key) return null;
const required = Boolean(f.required);
if (f.type === "textarea") {
return (
<label key={key} className="block text-sm">
<span className="mb-1 block font-medium text-[#333]">
{f.label}
{required ? <span className="text-red-500"> *</span> : null}
</span>
<textarea
value={String(values[key] ?? "")}
onChange={(e) => setValues((v) => ({ ...v, [key]: e.target.value }))}
required={required}
rows={3}
className="w-full rounded-lg border border-[#ddd] px-3 py-2 text-sm outline-none focus:border-[#D32F2F]"
/>
</label>
);
}
return (
<label key={key} className="block text-sm">
<span className="mb-1 block font-medium text-[#333]">
{f.label}
{required ? <span className="text-red-500"> *</span> : null}
</span>
<input
type={f.type === "email" ? "email" : f.type === "tel" ? "tel" : "text"}
value={String(values[key] ?? "")}
onChange={(e) => setValues((v) => ({ ...v, [key]: e.target.value }))}
required={required}
className="h-10 w-full rounded-lg border border-[#ddd] px-3 text-sm outline-none focus:border-[#D32F2F]"
/>
</label>
);
})}
{err ? <p className="text-center text-sm text-red-600">{err}</p> : null}
<button
type="submit"
disabled={!file || submitting || picking}
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-50"
>
{submitting ? "Uploading…" : "Upload"}
</button>
</form>
)}
</WhiteCard>
{cropSession && meta ? (
<PhotoWallCropModal
imageSrc={cropSession.imageSrc}
sourceFileName={cropSession.sourceFileName}
aspect={
Math.max(1, Number(meta.maxPhotoWidth) || 1080) /
Math.max(1, Number(meta.maxPhotoHeight) || 1080)
}
onCancel={() => {
URL.revokeObjectURL(cropSession.imageSrc);
setCropSession(null);
}}
onConfirm={(cropped) => {
URL.revokeObjectURL(cropSession.imageSrc);
setCropSession(null);
applySelectedFile(cropped);
}}
/>
) : null}
</AuthShell>
);
}

View File

@ -2,6 +2,7 @@ import { useEffect, useState } from "react";
import { ChevronRight } from "lucide-react";
import { Link, useSearchParams } from "react-router-dom";
import { getVisitorLoginPreview, getVisitorSessionPreview } from "../lib/api";
import { useAutoNavigateToLiveQuizPoll } from "../hooks/useAutoNavigateToLiveQuizPoll";
import { useSessionQuestionsRtdb } from "../hooks/useSessionQuestionsRtdb";
import { AudienceQuestionCard } from "../components/AudienceQuestionCard";
import { AskQuestionModal } from "../components/AskQuestionModal";
@ -37,6 +38,12 @@ export function Questions() {
eventId || null,
);
useAutoNavigateToLiveQuizPoll({
eventId,
sessionId,
enabled: Boolean(eventId && sessionId),
});
const list = rtdbItems.map((r) => ({
_id: r._id,
author: r.author,

View File

@ -3,11 +3,13 @@ import { useNavigate, useParams } from "react-router-dom";
import {
closePublicPresentation,
getPublicPresentationSessionActivities,
getPublicQuizPollLeaderboard,
getPublicQuizPollPresentation,
getPublicQuizPollQuestionStats,
publishPublicPresentation,
setPublicLiveQuestion,
type PublicPresentationSessionActivity,
type PublicQuizPollLeaderboardEntry,
type PublicQuizPollPresentation,
type PublicQuizPollQuestionStats,
} from "../lib/api";
@ -19,7 +21,9 @@ import {
subscribeSessionQuizPolls,
type RtdbQuizPollListItem,
} from "../lib/sessionQuizPollsRtdb";
import { subscribeSessionPresentation } from "../lib/sessionPresentationRtdb";
import { PresentFooterNav } from "../components/present/PresentFooterNav";
import { PresentLeaderboard } from "../components/present/PresentLeaderboard";
import { PresentRespondersList } from "../components/present/PresentRespondersList";
import { PresentSessionMenu } from "../components/present/PresentSessionMenu";
import {
@ -40,7 +44,7 @@ function onAirIdsFromSessionList(items: RtdbQuizPollListItem[]): string[] {
.map((x) => x._id);
}
type TabId = "responses" | "participants";
type TabId = "responses" | "participants" | "leaderboard";
const STATS_POLL_MS = 2500;
@ -49,6 +53,7 @@ function applyPresentationSettings(
setters: {
setLiveQuestionId: (v: string | null) => void;
setShowCorrectAnswer: (v: boolean) => void;
setShowLeaderboard: (v: boolean) => void;
setIsLive: (v: boolean) => void;
},
) {
@ -58,6 +63,7 @@ function applyPresentationSettings(
: null;
setters.setLiveQuestionId(live);
setters.setShowCorrectAnswer(data.settings.showCorrectAnswer === true);
setters.setShowLeaderboard(data.settings.showLeaderboard === true);
setters.setIsLive(data.settings.isLive === true);
}
@ -66,8 +72,10 @@ export function QuizPollPresentPage() {
const navigate = useNavigate();
const [presentation, setPresentation] = useState<PublicQuizPollPresentation | null>(null);
const [stats, setStats] = useState<PublicQuizPollQuestionStats | null>(null);
const [leaderboard, setLeaderboard] = useState<PublicQuizPollLeaderboardEntry[]>([]);
const [loading, setLoading] = useState(true);
const [statsLoading, setStatsLoading] = useState(false);
const [leaderboardLoading, setLeaderboardLoading] = useState(false);
const [err, setErr] = useState<string | null>(null);
const [controlErr, setControlErr] = useState<string | null>(null);
const [controlBusy, setControlBusy] = useState(false);
@ -76,6 +84,7 @@ export function QuizPollPresentPage() {
const [browsing, setBrowsing] = useState(false);
const [liveQuestionId, setLiveQuestionId] = useState<string | null>(null);
const [showCorrectAnswer, setShowCorrectAnswer] = useState(false);
const [showLeaderboard, setShowLeaderboard] = useState(false);
const [isLive, setIsLive] = useState(false);
const [sessionActivities, setSessionActivities] = useState<PublicPresentationSessionActivity[]>([]);
const [sessionRtdbItems, setSessionRtdbItems] = useState<RtdbQuizPollListItem[]>([]);
@ -84,6 +93,7 @@ export function QuizPollPresentPage() {
const prevOnAirIdsRef = useRef<Set<string> | null>(null);
const tokenByIdRef = useRef<Map<string, string>>(new Map());
const pendingSwitchIdRef = useRef<string | null>(null);
const lastQuestionsFocusAtRef = useRef<string | null>(null);
const isQuizMaster = presentation?.settings.displayMode === "quiz_master";
const isQuiz = presentation?.type.toLowerCase() === "quiz";
@ -115,7 +125,12 @@ export function QuizPollPresentPage() {
setPresentation(data);
setQuestionIndex(0);
setBrowsing(false);
applyPresentationSettings(data, { setLiveQuestionId, setShowCorrectAnswer, setIsLive });
applyPresentationSettings(data, {
setLiveQuestionId,
setShowCorrectAnswer,
setShowLeaderboard,
setIsLive,
});
})
.catch((e) => {
if (!cancelled) {
@ -177,6 +192,7 @@ export function QuizPollPresentPage() {
: null;
setLiveQuestionId(live);
setShowCorrectAnswer(detail?.settings.showCorrectAnswer === true);
setShowLeaderboard(detail?.settings.showLeaderboard === true);
if (detail) {
setIsLive(detail.isLive === true || detail.settings.isLive === true);
}
@ -222,6 +238,27 @@ export function QuizPollPresentPage() {
return () => unsub();
}, [presentation?.sessionId, presentation?._id, token, navigate]);
useEffect(() => {
const sessionId = presentation?.sessionId?.trim();
const t = token?.trim();
if (!sessionId || !t || !isFirebaseClientConfigured()) return;
let primed = false;
lastQuestionsFocusAtRef.current = null;
const unsub = subscribeSessionPresentation(sessionId, (state) => {
// Ignore the first snapshot so opening a quiz/poll present link is not bounced away.
if (!primed) {
primed = true;
lastQuestionsFocusAtRef.current = state.updatedAt;
return;
}
if (state.focus !== "questions" || !state.updatedAt) return;
if (lastQuestionsFocusAtRef.current === state.updatedAt) return;
lastQuestionsFocusAtRef.current = state.updatedAt;
navigate(questionsPresentPath(sessionId, t), { replace: true });
});
return () => unsub();
}, [presentation?.sessionId, token, navigate]);
useEffect(() => {
if (!isQuizMaster || liveQuestionIndex < 0) return;
const live = liveQuestionId ?? null;
@ -259,6 +296,20 @@ export function QuizPollPresentPage() {
}
}, [token, presentation, questionSafe]);
const loadLeaderboard = useCallback(async () => {
const t = token?.trim();
if (!t || !presentation || String(presentation.type).toLowerCase() !== "quiz") return;
setLeaderboardLoading(true);
try {
const data = await getPublicQuizPollLeaderboard(t);
setLeaderboard(data.entries ?? []);
} catch {
/* keep last good leaderboard while presenting */
} finally {
setLeaderboardLoading(false);
}
}, [token, presentation]);
useEffect(() => {
if (!presentation) return;
void loadStats();
@ -266,9 +317,37 @@ export function QuizPollPresentPage() {
return () => window.clearInterval(id);
}, [presentation, loadStats]);
useEffect(() => {
if (!presentation || String(presentation.type).toLowerCase() !== "quiz") {
setLeaderboard([]);
return;
}
void loadLeaderboard();
const id = window.setInterval(() => void loadLeaderboard(), STATS_POLL_MS);
return () => window.clearInterval(id);
}, [presentation, loadLeaderboard]);
useEffect(() => {
if (!isQuiz && tab === "leaderboard") setTab("responses");
}, [isQuiz, tab]);
useEffect(() => {
if (!isQuiz) return;
if (showLeaderboard) {
setTab("leaderboard");
return;
}
setTab((prev) => (prev === "leaderboard" ? "responses" : prev));
}, [isQuiz, showLeaderboard]);
const applyControlResult = (data: PublicQuizPollPresentation) => {
setPresentation(data);
applyPresentationSettings(data, { setLiveQuestionId, setShowCorrectAnswer, setIsLive });
applyPresentationSettings(data, {
setLiveQuestionId,
setShowCorrectAnswer,
setShowLeaderboard,
setIsLive,
});
};
const runControl = async (action: () => Promise<PublicQuizPollPresentation>) => {
@ -366,9 +445,9 @@ export function QuizPollPresentPage() {
return (
<div className="flex min-h-dvh flex-col bg-[#f2f2f7] pb-20 text-[#24282f]">
<main className="mx-auto flex w-full max-w-4xl flex-1 flex-col px-4 py-8 sm:px-6">
<div className="mb-6 rounded-xl border border-[#e5e5ea] bg-white p-5 shadow-sm">
<div className="mb-3 flex flex-wrap items-start justify-between gap-3">
<main className="mx-auto flex w-full max-w-4xl flex-1 flex-col gap-3 px-4 py-3 sm:px-6">
<div className="rounded-xl border border-[#e5e5ea] bg-white px-5 py-3 shadow-sm">
<div className="mb-2 flex flex-wrap items-start justify-between gap-3">
<div className="flex min-w-0 flex-wrap items-center gap-2">
<PresentSessionMenu
items={menuItems}
@ -458,15 +537,13 @@ export function QuizPollPresentPage() {
<p className="text-sm font-medium uppercase tracking-wide text-[#1e72ef]">
Question {questionSafe + 1} of {questionCount}
</p>
<h1 className="mt-2 text-2xl font-bold leading-snug text-[#24282f] sm:text-3xl">{currentQuestion.question}</h1>
<h1 className="mt-1 text-base font-semibold leading-snug text-[#24282f] sm:text-lg">{currentQuestion.question}</h1>
{statsLoading && !stats ? (
<p className="mt-2 text-xs text-[rgba(36,40,47,0.45)]">Updating…</p>
) : stats ? (
<p className="mt-2 text-sm text-[rgba(36,40,47,0.65)]">{stats.totalResponses} responses</p>
) : null}
</div>
<div className="mb-4 flex gap-2 rounded-lg border border-[#e5e5ea] bg-white p-1 shadow-sm">
<div className="flex gap-2 rounded-lg border border-[#e5e5ea] bg-white p-1 shadow-sm">
<button
type="button"
onClick={() => setTab("responses")}
@ -474,7 +551,7 @@ export function QuizPollPresentPage() {
tab === "responses" ? "bg-[#1e72ef] text-white" : "text-[rgba(36,40,47,0.65)] hover:text-[#24282f]"
}`}
>
Responses
Responses{stats ? ` (${stats.totalResponses})` : ""}
</button>
<button
type="button"
@ -485,6 +562,17 @@ export function QuizPollPresentPage() {
>
Participants
</button>
{isQuiz ? (
<button
type="button"
onClick={() => setTab("leaderboard")}
className={`flex-1 rounded-md px-3 py-2 text-sm font-semibold transition ${
tab === "leaderboard" ? "bg-[#1e72ef] text-white" : "text-[rgba(36,40,47,0.65)] hover:text-[#24282f]"
}`}
>
Leaderboard{leaderboard.length > 0 ? ` (${leaderboard.length})` : ""}
</button>
) : null}
</div>
<div className="flex flex-1 flex-col">
@ -497,13 +585,15 @@ export function QuizPollPresentPage() {
totalResponses={stats?.totalResponses ?? 0}
revealCorrect={revealCorrect}
/>
) : (
) : tab === "participants" ? (
<PresentRespondersList
responders={stats?.responders ?? []}
isQuiz={isQuiz}
showAnswerText={currentQuestion.type === "text"}
showCorrectness={revealCorrect}
showCorrectness={isQuiz}
/>
) : (
<PresentLeaderboard entries={leaderboard} loading={leaderboardLoading} />
)}
</div>
</main>

View File

@ -13,12 +13,11 @@ import {
import { isFirebaseClientConfigured } from "../lib/firebaseClient";
import type { RtdbQuizPollDetail, RtdbQuizPollQuestion } from "../lib/sessionQuizPollsRtdb";
import {
firstQuizMasterOrManualVisibleId,
isQuizPollVisibleForAudience,
settingsDisplayMode,
subscribeSessionQuizPollDetail,
} from "../lib/sessionQuizPollsRtdb";
import { useSessionQuizPollsRtdb } from "../hooks/useSessionQuizPollsRtdb";
import { useAutoNavigateToLiveQuizPoll } from "../hooks/useAutoNavigateToLiveQuizPoll";
import { getAttendeeToken } from "../lib/attendeeAuth";
import { notifyRewardFromApiData } from "../lib/rewardCelebration";
import type { LoginPreviewResponse } from "../types/visitorContext";
@ -186,10 +185,14 @@ export function QuizPollTakePage() {
const sessionId = searchParams.get("session_id")?.trim() || "";
const quizPollId = searchParams.get("quiz_poll_id")?.trim() || "";
const { items: sessionLiveQuizPolls, loading: sessionQuizPollListLoading } =
useSessionQuizPollsRtdb(sessionId);
const ctx = readStoredVisitorContext();
useAutoNavigateToLiveQuizPoll({
eventId,
sessionId,
currentQuizPollId: quizPollId || null,
enabled: Boolean(eventId && sessionId && quizPollId),
});
const [quiz, setQuiz] = useState<RtdbQuizPollDetail | null>(null);
const [loading, setLoading] = useState(true);
const [err, setErr] = useState<string | null>(null);
@ -263,28 +266,6 @@ export function QuizPollTakePage() {
return () => unsub();
}, [eventId, sessionId, quizPollId]);
useEffect(() => {
if (!eventId || !sessionId || !quizPollId) return;
if (sessionQuizPollListLoading) return;
const target = firstQuizMasterOrManualVisibleId(sessionLiveQuizPolls);
if (!target || target === quizPollId) return;
navigate(
`/quiz-poll?${new URLSearchParams({
event_id: eventId,
session_id: sessionId,
quiz_poll_id: target,
}).toString()}`,
{ replace: true },
);
}, [
eventId,
sessionId,
quizPollId,
sessionQuizPollListLoading,
sessionLiveQuizPolls,
navigate,
]);
const [answers, setAnswers] = useState<Record<string, string | string[]>>({});
const [submitted, setSubmitted] = useState(false);
const [submitting, setSubmitting] = useState(false);

View File

@ -3,6 +3,7 @@ import { Link, useSearchParams } from "react-router-dom";
import { HeaderMobile } from "../components/HeaderMobile";
import { MobileShell } from "../components/MobileShell";
import { DynamicFeedbackForm } from "../components/DynamicFeedbackForm";
import { useAutoNavigateToLiveQuizPoll } from "../hooks/useAutoNavigateToLiveQuizPoll";
import { useEventRegistrationSettings } from "../hooks/useEventRegistrationSettings";
import {
getVisitorFeedbackFormSchema,
@ -21,6 +22,12 @@ export function RatingsPage() {
const eventId = urlEventId || ctx?.eventId || "";
const sessionId = urlSessionId || ctx?.sessionId || "";
useAutoNavigateToLiveQuizPoll({
eventId,
sessionId,
enabled: Boolean(eventId && sessionId),
});
const [eventSnapshot, setEventSnapshot] = useState(
ctx?.eventId === eventId && ctx.event ? ctx.event : null,
);

View File

@ -1,6 +1,6 @@
import { useEffect, useMemo, useState } from "react";
import { Bookmark, Calendar, ChevronDown, ChevronRight, Clock, ExternalLink, MapPin, MessageCircle, Star } from "lucide-react";
import { Link, useNavigate, useSearchParams } from "react-router-dom";
import { Link, useSearchParams } from "react-router-dom";
import { HeaderMobile } from "../components/HeaderMobile";
import {
groupSessionsIntoAgendaDays,
@ -28,8 +28,7 @@ import type { LoginPreviewResponse } from "../types/visitorContext";
import { readStoredVisitorContext } from "../types/visitorContext";
import { useSessionBookmarks } from "../hooks/useSessionBookmarks";
import { useEventRegistrationSettings } from "../hooks/useEventRegistrationSettings";
import { useSessionQuizPollsRtdb } from "../hooks/useSessionQuizPollsRtdb";
import { firstQuizMasterOrManualVisibleId } from "../lib/sessionQuizPollsRtdb";
import { useAutoNavigateToLiveQuizPoll } from "../hooks/useAutoNavigateToLiveQuizPoll";
type SessionTabId = "key-persons" | "my-sessions" | "agenda";
@ -230,7 +229,6 @@ function headerEventNameForEvent(
export function SingleEventPage() {
const [searchParams] = useSearchParams();
const navigate = useNavigate();
const eventId = searchParams.get("event_id")?.trim() || "";
const sessionId = searchParams.get("session_id")?.trim() || "";
const ctx = readStoredVisitorContext();
@ -250,28 +248,11 @@ export function SingleEventPage() {
const reg = useEventRegistrationSettings(eventInfo);
const { items: sessionLiveQuizPolls, loading: rtdbQuizPollsLoading } =
useSessionQuizPollsRtdb(sessionId);
useEffect(() => {
if (!eventId || !sessionId || loading || err || !session) return;
if (rtdbQuizPollsLoading) return;
const target = firstQuizMasterOrManualVisibleId(sessionLiveQuizPolls);
if (!target) return;
navigate(
`/quiz-poll?${new URLSearchParams({ event_id: eventId, session_id: sessionId, quiz_poll_id: target }).toString()}`,
{ replace: true },
);
}, [
useAutoNavigateToLiveQuizPoll({
eventId,
sessionId,
loading,
err,
session,
rtdbQuizPollsLoading,
sessionLiveQuizPolls,
navigate,
]);
enabled: Boolean(eventId && sessionId && !loading && !err && session),
});
useEffect(() => {
if (!eventId || !sessionId) {

View File

@ -0,0 +1,604 @@
import {
useCallback,
useEffect,
useMemo,
useRef,
useState,
type ReactNode,
} from "react";
import {
Download,
Facebook,
Instagram,
Linkedin,
Share2,
} from "lucide-react";
import { useParams } from "react-router-dom";
import { AuthShell } from "../components/auth/AuthShell";
import { PhotoWallCropModal } from "../components/PhotoWallCropModal";
import { PhotoWallEventHeader } from "../components/PhotoWallEventHeader";
import {
getSocialShareMeta,
getVisitorLoginPreview,
uploadSocialSharePhoto,
type PhotoWallSharePlatforms,
type SocialShareMeta,
type SocialSharePublicFrame,
} from "../lib/api";
import { getAttendeeParticipant, getAttendeeToken } from "../lib/attendeeAuth";
import { getImageNaturalSize, ratiosMatch } from "../lib/cropImage";
import {
canUseNativeShareSheet,
downloadImageFromUrl,
ensureShareableImageFile,
fetchImageFile,
sharePhotoWallImage,
shareViaNativeSheet,
} from "../lib/photoWallShare";
import {
EB_VISITOR_CONTEXT_KEY,
setPostAuthReturnPath,
type EbVisitorContext,
} from "../types/visitorContext";
function WhiteCard({ children }: { children: ReactNode }) {
return (
<div className="flex w-full max-w-[354px] flex-col items-center">
<div className="w-full rounded-xl shadow-[0_8px_12px_6px_rgba(0,0,0,0.15),0_4px_4px_rgba(0,0,0,0.3)]">
<div className="rounded-xl bg-white px-6 py-6">{children}</div>
</div>
</div>
);
}
type CropSession = {
imageSrc: string;
sourceFileName: string;
};
type DoneState = {
previewUrl: string | null;
fileUrl: string | null;
};
const SHARE_LABELS: Array<{ key: keyof PhotoWallSharePlatforms; label: string }> = [
{ key: "linkedin", label: "LinkedIn" },
{ key: "facebook", label: "Facebook" },
{ key: "instagram", label: "Instagram" },
{ key: "snapchat", label: "Snapchat" },
{ key: "x", label: "X" },
{ key: "whatsapp", label: "WhatsApp" },
];
function ShareIcon({ platform }: { platform: keyof PhotoWallSharePlatforms }) {
if (platform === "linkedin") return <Linkedin className="h-4 w-4" />;
if (platform === "facebook") return <Facebook className="h-4 w-4" />;
if (platform === "instagram") return <Instagram className="h-4 w-4" />;
return <Share2 className="h-4 w-4" />;
}
export function SocialSharePage() {
const { uploadToken } = useParams<{ uploadToken: string }>();
const fileRef = useRef<HTMLInputElement>(null);
const resultImgRef = useRef<HTMLImageElement>(null);
const [meta, setMeta] = useState<SocialShareMeta | null>(null);
const [loading, setLoading] = useState(true);
const [err, setErr] = useState<string | null>(null);
const [selectedFrame, setSelectedFrame] = useState<SocialSharePublicFrame | null>(null);
const [file, setFile] = useState<File | null>(null);
const [previewUrl, setPreviewUrl] = useState<string | null>(null);
const [submitting, setSubmitting] = useState(false);
const [picking, setPicking] = useState(false);
const [done, setDone] = useState<DoneState | null>(null);
const [actionMsg, setActionMsg] = useState<string | null>(null);
const [cropSession, setCropSession] = useState<CropSession | null>(null);
const [shareFile, setShareFile] = useState<File | null>(null);
const load = useCallback(async () => {
if (!uploadToken) return;
setLoading(true);
setErr(null);
try {
const data = await getSocialShareMeta(uploadToken);
setMeta(data);
} catch (e) {
setMeta(null);
setErr(e instanceof Error ? e.message : "Social share not available");
} finally {
setLoading(false);
}
}, [uploadToken]);
useEffect(() => {
void load();
}, [load]);
useEffect(() => {
return () => {
if (previewUrl) URL.revokeObjectURL(previewUrl);
};
}, [previewUrl]);
useEffect(() => {
return () => {
if (cropSession?.imageSrc) URL.revokeObjectURL(cropSession.imageSrc);
};
}, [cropSession]);
useEffect(() => {
if (!done?.fileUrl) return;
let cancelled = false;
void fetchImageFile(done.fileUrl, "social-share.jpg")
.then((f) => {
if (!cancelled) setShareFile(f);
})
.catch(() => {
/* keep local */
});
return () => {
cancelled = true;
};
}, [done?.fileUrl]);
const loggedIn = Boolean(getAttendeeToken() && getAttendeeParticipant());
const enabledSharePlatforms = useMemo(
() => SHARE_LABELS.filter((item) => Boolean(meta?.sharePlatforms?.[item.key])),
[meta?.sharePlatforms],
);
const useNativeShare = useMemo(() => canUseNativeShareSheet(), []);
const showShare = enabledSharePlatforms.length > 0;
const allowDownload = meta?.allowDownload !== false;
const shareCaption = `Check out my photo from ${meta?.eventName || "the event"}!`;
const clearSelectedPhoto = useCallback(() => {
setFile(null);
setPreviewUrl((prev) => {
if (prev) URL.revokeObjectURL(prev);
return null;
});
if (fileRef.current) fileRef.current.value = "";
}, []);
const applySelectedFile = useCallback((next: File) => {
setFile(next);
setPreviewUrl((prev) => {
if (prev) URL.revokeObjectURL(prev);
return URL.createObjectURL(next);
});
}, []);
const cropTarget = useMemo(() => {
const slot = selectedFrame?.photoSlot;
if (slot?.w && slot?.h) {
return { w: Math.max(1, slot.w), h: Math.max(1, slot.h), fromFrame: true as const };
}
if (meta?.requireUploadCrop) {
return {
w: Math.max(1, Number(meta.maxPhotoWidth) || 1080),
h: Math.max(1, Number(meta.maxPhotoHeight) || 1080),
fromFrame: false as const,
};
}
return null;
}, [selectedFrame, meta?.requireUploadCrop, meta?.maxPhotoWidth, meta?.maxPhotoHeight]);
const handleFileChange = async (selected: File | null) => {
if (!selected) {
clearSelectedPhoto();
return;
}
if (!selected.type.startsWith("image/")) {
setErr("Please choose an image file");
return;
}
setErr(null);
setPicking(true);
try {
// Prefer the selected frame's photo-slot aspect (e.g. square slot → crop to 1:1).
// Fall back to Settings → require crop + max W/H when no slot is defined.
if (!cropTarget) {
applySelectedFile(selected);
return;
}
const natural = await getImageNaturalSize(selected);
if (ratiosMatch(natural.width, natural.height, cropTarget.w, cropTarget.h)) {
applySelectedFile(selected);
return;
}
const src = URL.createObjectURL(selected);
setCropSession({ imageSrc: src, sourceFileName: selected.name || "photo.jpg" });
} catch (e) {
setErr(e instanceof Error ? e.message : "Could not process image");
} finally {
setPicking(false);
if (fileRef.current) fileRef.current.value = "";
}
};
const onSubmit = async () => {
if (!uploadToken || !file || !meta || !selectedFrame) return;
setSubmitting(true);
setErr(null);
setActionMsg(null);
try {
const result = await uploadSocialSharePhoto(uploadToken, file, selectedFrame._id);
setShareFile(null);
setDone({
previewUrl: result.previewUrl,
fileUrl: result.fileUrl,
});
clearSelectedPhoto();
if (result.fileUrl) {
void fetchImageFile(result.fileUrl, "social-share.jpg")
.then((f) => setShareFile(f))
.catch(() => {
void ensureShareableImageFile(file, "social-share.jpg").then(setShareFile);
});
}
} catch (e2) {
setErr(e2 instanceof Error ? e2.message : "Upload failed");
} finally {
setSubmitting(false);
}
};
const resetForNew = () => {
setDone(null);
setShareFile(null);
setActionMsg(null);
setErr(null);
setSelectedFrame(null);
clearSelectedPhoto();
};
const onDownload = async () => {
setActionMsg(null);
try {
if (shareFile) {
const objectUrl = URL.createObjectURL(shareFile);
const a = document.createElement("a");
a.href = objectUrl;
a.download = shareFile.name || "social-share.jpg";
document.body.appendChild(a);
a.click();
a.remove();
URL.revokeObjectURL(objectUrl);
setActionMsg("Download started");
return;
}
const src = done?.fileUrl || done?.previewUrl;
if (!src) return;
await downloadImageFromUrl(src, "social-share.jpg");
setActionMsg("Download started");
} catch (e) {
setActionMsg(e instanceof Error ? e.message : "Download failed");
}
};
const onNativeShare = async () => {
const src = done?.fileUrl || done?.previewUrl;
setActionMsg(null);
try {
const result = await shareViaNativeSheet({
file: shareFile,
imageUrl: src,
imgEl: resultImgRef.current,
title: meta?.eventName || "Social Share",
text: shareCaption,
});
if (result === "shared") setActionMsg("Shared");
else setActionMsg(null);
} catch (e) {
const msg = e instanceof Error ? e.message : "Share failed";
setActionMsg(
/load failed|failed to fetch/i.test(msg)
? "Could not share this photo. Try Download, then share from your photos."
: msg,
);
}
};
const onShare = async (platform: keyof PhotoWallSharePlatforms) => {
const src = done?.fileUrl || done?.previewUrl;
if (!src && !shareFile) return;
setActionMsg(null);
try {
const result = await sharePhotoWallImage({
platform,
imageUrl: src || "",
file: shareFile,
title: meta?.eventName || "Social Share",
text: shareCaption,
});
if (result === "shared") {
setActionMsg(
platform === "whatsapp" || platform === "facebook" || platform === "x" || platform === "linkedin"
? "Opening app with your photo link"
: "Shared",
);
} else if (result === "cancelled") {
setActionMsg(null);
} else {
setActionMsg("Photo saved and caption copied — paste / attach it in the app");
}
} catch (e) {
setActionMsg(e instanceof Error ? e.message : "Share failed");
}
};
const goLogin = async () => {
if (!meta?.eventId) return;
try {
const preview = await getVisitorLoginPreview(meta.eventId);
const ctx: EbVisitorContext = {
type: "event",
eventId: meta.eventId,
event: preview,
fetchedAt: new Date().toISOString(),
};
localStorage.setItem(EB_VISITOR_CONTEXT_KEY, JSON.stringify(ctx));
setPostAuthReturnPath(`/social-share/${uploadToken}`);
window.location.assign(`/login?type=event&id=${encodeURIComponent(meta.eventId)}`);
} catch {
setErr("Could not open login");
}
};
if (!uploadToken) {
return (
<AuthShell showLogo={false}>
<WhiteCard>
<p className="text-center text-sm text-red-600">Missing link</p>
</WhiteCard>
</AuthShell>
);
}
if (loading) {
return (
<AuthShell showLogo={false}>
<WhiteCard>
<p className="text-center text-sm text-[#666]">Loading…</p>
</WhiteCard>
</AuthShell>
);
}
if (err && !meta) {
return (
<AuthShell showLogo={false}>
<WhiteCard>
<p className="text-center text-sm text-red-600">{err}</p>
</WhiteCard>
</AuthShell>
);
}
if (done) {
return (
<AuthShell showLogo={false}>
<WhiteCard>
{meta?.event ? (
<div className="mb-4">
<PhotoWallEventHeader
event={meta.event}
display={meta.eventHeader || null}
eyebrow="Social Share"
/>
</div>
) : null}
<h1 className="mb-2 text-center text-[22px] font-semibold text-[#1a1a1a]">Your photo</h1>
<p className="mb-4 text-center text-sm text-[#666]">
Here’s your framed photo. You can download or share it.
</p>
{(done.fileUrl || done.previewUrl) ? (
<div className="mb-4 overflow-hidden rounded-lg border border-[#e5e5e5] bg-[#f8f8f8]">
<img
ref={resultImgRef}
crossOrigin="anonymous"
src={done.previewUrl || done.fileUrl || undefined}
alt="Your framed photo"
className="mx-auto max-h-[360px] w-full object-contain"
onError={(e) => {
const img = e.currentTarget;
if (img.crossOrigin) {
img.crossOrigin = null;
img.src = done.previewUrl || done.fileUrl || img.src;
return;
}
if (done.fileUrl && img.src !== done.fileUrl) {
img.src = done.fileUrl;
}
}}
/>
</div>
) : null}
<div className="flex flex-col gap-2">
<button
type="button"
onClick={resetForNew}
className="inline-flex h-11 w-full items-center justify-center rounded-lg bg-[#D32F2F] px-4 text-sm font-semibold text-white hover:bg-[#b71c1c]"
>
Create another
</button>
{allowDownload ? (
<button
type="button"
onClick={() => void onDownload()}
className="inline-flex h-11 w-full items-center justify-center gap-2 rounded-lg border border-[#ddd] bg-white px-4 text-sm font-semibold text-[#333] hover:bg-[#fafafa]"
>
<Download className="h-4 w-4" />
Download photo
</button>
) : null}
</div>
{showShare ? (
<div className="mt-4">
<p className="mb-2 text-center text-xs font-semibold uppercase tracking-wide text-[#888]">
Share
</p>
{useNativeShare ? (
<button
type="button"
onClick={() => void onNativeShare()}
className="inline-flex h-11 w-full items-center justify-center gap-2 rounded-lg border border-[#ddd] bg-white px-4 text-sm font-semibold text-[#333] hover:bg-[#fafafa]"
>
<Share2 className="h-4 w-4" />
Share
</button>
) : (
<div className="grid grid-cols-2 gap-2">
{enabledSharePlatforms.map((item) => (
<button
key={item.key}
type="button"
onClick={() => void onShare(item.key)}
className="inline-flex h-10 items-center justify-center gap-2 rounded-lg border border-[#ddd] bg-white px-3 text-xs font-semibold text-[#333] hover:bg-[#fafafa]"
>
<ShareIcon platform={item.key} />
{item.label}
</button>
))}
</div>
)}
</div>
) : null}
{actionMsg ? <p className="mt-3 text-center text-xs text-[#666]">{actionMsg}</p> : null}
</WhiteCard>
</AuthShell>
);
}
return (
<AuthShell showLogo={false}>
<WhiteCard>
{meta?.event ? (
<div className="mb-4">
<PhotoWallEventHeader
event={meta.event}
display={meta.eventHeader || null}
eyebrow="Social Share"
/>
</div>
) : null}
{meta?.accessMode === "login" && !loggedIn ? (
<div className="space-y-3">
<h1 className="mb-1 text-center text-[22px] font-semibold text-[#1a1a1a]">Social Share</h1>
<p className="text-center text-sm text-[#666]">Sign in to create and share your photo.</p>
<button
type="button"
onClick={() => void goLogin()}
className="inline-flex h-11 w-full items-center justify-center rounded-lg bg-[#D32F2F] px-4 text-sm font-semibold text-white hover:bg-[#b71c1c]"
>
Sign in to continue
</button>
</div>
) : !selectedFrame ? (
<div className="space-y-4">
<h1 className="mb-1 text-center text-[22px] font-semibold text-[#1a1a1a]">Choose a frame</h1>
<p className="text-center text-sm text-[#666]">Pick a frame, then upload your photo.</p>
{!meta?.frames?.length ? (
<p className="text-center text-sm text-[#666]">No frames available yet.</p>
) : (
<div className="grid grid-cols-2 gap-3">
{meta.frames.map((frame) => (
<button
key={frame._id}
type="button"
onClick={() => setSelectedFrame(frame)}
className="overflow-hidden rounded-lg border border-[#e5e5e5] bg-[#f8f8f8] text-left hover:border-[#D32F2F]"
>
<div className="aspect-square w-full bg-white">
{frame.frameImageUrl ? (
<img
src={frame.frameImageUrl}
alt={frame.name}
className="h-full w-full object-contain"
/>
) : (
<div className="flex h-full items-center justify-center text-xs text-[#999]">
No preview
</div>
)}
</div>
<p className="truncate px-2 py-2 text-center text-xs font-semibold text-[#333]">
{frame.name}
</p>
</button>
))}
</div>
)}
{err ? <p className="text-center text-sm text-red-600">{err}</p> : null}
</div>
) : (
<div className="space-y-4">
<button
type="button"
onClick={() => {
setSelectedFrame(null);
clearSelectedPhoto();
setErr(null);
}}
className="text-sm font-semibold text-[#D32F2F]"
>
← Change frame
</button>
<h1 className="mb-1 text-center text-[22px] font-semibold text-[#1a1a1a]">Upload photo</h1>
<p className="text-center text-sm text-[#666]">
Frame: <span className="font-semibold text-[#333]">{selectedFrame.name}</span>
</p>
<input
ref={fileRef}
type="file"
accept="image/*"
capture="user"
className="hidden"
onChange={(e) => void handleFileChange(e.target.files?.[0] || null)}
/>
{previewUrl ? (
<div className="overflow-hidden rounded-lg border border-[#e5e5e5] bg-[#f8f8f8]">
<img src={previewUrl} alt="Your photo" className="mx-auto max-h-[240px] w-full object-contain" />
</div>
) : null}
<button
type="button"
disabled={picking}
onClick={() => fileRef.current?.click()}
className="inline-flex h-11 w-full items-center justify-center rounded-lg border border-[#ddd] bg-white px-4 text-sm font-semibold text-[#333] hover:bg-[#fafafa] disabled:opacity-55"
>
{picking ? "Processing…" : file ? "Change photo" : "Choose photo"}
</button>
<button
type="button"
disabled={!file || submitting}
onClick={() => void onSubmit()}
className="inline-flex h-11 w-full items-center justify-center rounded-lg bg-[#D32F2F] px-4 text-sm font-semibold text-white hover:bg-[#b71c1c] disabled:opacity-55"
>
{submitting ? "Creating…" : "Create photo"}
</button>
{err ? <p className="text-center text-sm text-red-600">{err}</p> : null}
</div>
)}
</WhiteCard>
{cropSession && cropTarget ? (
<PhotoWallCropModal
imageSrc={cropSession.imageSrc}
sourceFileName={cropSession.sourceFileName}
aspect={cropTarget.w / cropTarget.h}
onCancel={() => {
URL.revokeObjectURL(cropSession.imageSrc);
setCropSession(null);
}}
onConfirm={(cropped) => {
URL.revokeObjectURL(cropSession.imageSrc);
setCropSession(null);
applySelectedFile(cropped);
}}
/>
) : null}
</AuthShell>
);
}

View File

@ -132,11 +132,34 @@ export type VisitorJoinContext = {
trackId?: string;
};
/** Optional return path after visitor auth (e.g. photo-wall upload). */
export const EB_POST_AUTH_RETURN_KEY = "eb_post_auth_return";
export function setPostAuthReturnPath(path: string) {
try {
if (path && path.startsWith("/")) sessionStorage.setItem(EB_POST_AUTH_RETURN_KEY, path);
} catch {
/* ignore */
}
}
export function consumePostAuthReturnPath(): string | null {
try {
const path = sessionStorage.getItem(EB_POST_AUTH_RETURN_KEY);
if (path) sessionStorage.removeItem(EB_POST_AUTH_RETURN_KEY);
return path && path.startsWith("/") ? path : null;
} catch {
return null;
}
}
/**
* After login / profile completion: `/event|session|track?event_id=&session_id=&track_id=`
* Honors an optional return path set via `setPostAuthReturnPath`.
*/
export function buildPostAuthUrl(ctx: VisitorJoinContext | EbVisitorContext | null): string {
console.log("buildPostAuthUrl", ctx);
const ret = consumePostAuthReturnPath();
if (ret) return ret;
if (!ctx) return "/event";
const params = new URLSearchParams();
params.set("event_id", ctx.eventId);