Merge branch 'main' of http://192.168.1.200:3000/Stratawiz/EventBuzz_Website
* '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:
commit
418f85b402
|
|
@ -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.
|
||||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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": {
|
||||
|
|
|
|||
10
src/App.tsx
10
src/App.tsx
|
|
@ -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 />} />
|
||||
|
|
|
|||
|
|
@ -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(" ")}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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}`}
|
||||
|
|
|
|||
|
|
@ -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]);
|
||||
}
|
||||
436
src/lib/api.ts
436
src/lib/api.ts
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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)));
|
||||
},
|
||||
);
|
||||
}
|
||||
|
|
@ -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[] {
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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 }>();
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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'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'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'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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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);
|
||||
|
|
|
|||
Loading…
Reference in New Issue