This commit is contained in:
Ravi Gulhane 2026-08-13 10:37:39 +05:30
parent ecae5a7eb2
commit 229e3cdcda
27 changed files with 1845 additions and 516 deletions

20
package-lock.json generated
View File

@ -13,6 +13,7 @@
"lucide-react": "0.487.0", "lucide-react": "0.487.0",
"react": "18.3.1", "react": "18.3.1",
"react-dom": "18.3.1", "react-dom": "18.3.1",
"react-easy-crop": "^6.2.3",
"react-router-dom": "^6.28.0" "react-router-dom": "^6.28.0"
}, },
"devDependencies": { "devDependencies": {
@ -3082,6 +3083,12 @@
"dev": true, "dev": true,
"license": "MIT" "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": { "node_modules/picocolors": {
"version": "1.1.1", "version": "1.1.1",
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
@ -3180,6 +3187,19 @@
"react": "^18.3.1" "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": { "node_modules/react-refresh": {
"version": "0.17.0", "version": "0.17.0",
"resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz", "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz",

View File

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

View File

@ -18,6 +18,8 @@ import { SingleEventPage } from "./pages/SingleEventPage";
import { QuizPollTakePage } from "./pages/QuizPollTakePage"; import { QuizPollTakePage } from "./pages/QuizPollTakePage";
import { QuizPollPresentPage } from "./pages/QuizPollPresentPage"; import { QuizPollPresentPage } from "./pages/QuizPollPresentPage";
import { QuestionsPresentPage } from "./pages/QuestionsPresentPage"; import { QuestionsPresentPage } from "./pages/QuestionsPresentPage";
import { PhotoWallUploadPage } from "./pages/PhotoWallUploadPage";
import { PhotoWallPresentPage } from "./pages/PhotoWallPresentPage";
import { CodeEntryPage } from "./pages/CodeEntryPage"; import { CodeEntryPage } from "./pages/CodeEntryPage";
import { PrivacyPolicyPage } from "./pages/PrivacyPolicyPage"; import { PrivacyPolicyPage } from "./pages/PrivacyPolicyPage";
import { TermsOfServicePage } from "./pages/TermsOfServicePage"; import { TermsOfServicePage } from "./pages/TermsOfServicePage";
@ -41,6 +43,8 @@ export default function App() {
<Route path="/quiz-poll" element={<QuizPollTakePage />} /> <Route path="/quiz-poll" element={<QuizPollTakePage />} />
<Route path="/quiz-present/:token" element={<QuizPollPresentPage />} /> <Route path="/quiz-present/:token" element={<QuizPollPresentPage />} />
<Route path="/questions-present/:sessionId" element={<QuestionsPresentPage />} /> <Route path="/questions-present/:sessionId" element={<QuestionsPresentPage />} />
<Route path="/photo-wall/:uploadToken" element={<PhotoWallUploadPage />} />
<Route path="/photo-wall-present/:presentToken" element={<PhotoWallPresentPage />} />
<Route path="/questions" element={<Questions />} /> <Route path="/questions" element={<Questions />} />
<Route path="/ask" element={<Navigate to="/questions" replace />} /> <Route path="/ask" element={<Navigate to="/questions" replace />} />
<Route path="/feedback" element={<FeedbackPage />} /> <Route path="/feedback" element={<FeedbackPage />} />

View File

@ -20,7 +20,7 @@ export function EventBuzzLogo({
alt={alt} alt={alt}
width={256} width={256}
height={48} 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) .filter(Boolean)
.join(" ")} .join(" ")}
/> />

View File

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

View File

@ -0,0 +1,141 @@
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,
}: {
event: PhotoWallPublicEvent;
display?: PhotoWallEventHeaderDisplay | null;
}) {
const d = resolveDisplay(display);
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) {
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">
<p className="font-inter text-base font-semibold text-[#24282f]">Photo Wall</p>
{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>
) : (
<p className="font-inter text-base font-semibold text-[#24282f]">Photo Wall</p>
)}
</header>
);
}

View File

@ -1,6 +1,6 @@
import type { ReactNode } from "react"; import type { ReactNode } from "react";
import { EventBuzzLogo } from "../EventBuzzLogo";
import { EventBuzzMark } from "../EventBuzzMark"; import { EventBuzzMark } from "../EventBuzzMark";
import { LegalFooterLinks } from "../marketing/LegalFooterLinks";
type AuthShellProps = { type AuthShellProps = {
children: ReactNode; children: ReactNode;
@ -8,9 +8,16 @@ type AuthShellProps = {
mainClassName?: string; mainClassName?: string;
/** Show EventBuzz white logo pinned to the top of the auth shell */ /** Show EventBuzz white logo pinned to the top of the auth shell */
showLogo?: boolean; 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 ( return (
<div className="eb-gradient-auth relative flex min-h-dvh w-full flex-1 flex-col"> <div className="eb-gradient-auth relative flex min-h-dvh w-full flex-1 flex-col">
{showLogo ? ( {showLogo ? (
@ -23,8 +30,17 @@ export function AuthShell({ children, mainClassName = "", showLogo = true }: Aut
> >
{children} {children}
</div> </div>
<footer className="mx-auto mt-auto w-full max-w-[402px] shrink-0 px-6 py-3 text-sm font-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">
<LegalFooterLinks variant="light" /> <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> </footer>
</div> </div>
); );

View File

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

View File

@ -2,16 +2,22 @@ import type { PublicQuizPollResponder } from "../../lib/api";
function formatTimeTaken(sec: number): string { function formatTimeTaken(sec: number): string {
if (sec <= 0) return "—"; if (sec <= 0) return "—";
if (sec < 60) return `${sec} sec`; if (sec < 60) return `${sec}s`;
const m = Math.floor(sec / 60); const m = Math.floor(sec / 60);
const s = 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({ export function PresentRespondersList({
responders, responders,
isQuiz, isQuiz,
showAnswerText,
showCorrectness, showCorrectness,
}: { }: {
responders: PublicQuizPollResponder[]; responders: PublicQuizPollResponder[];
@ -25,43 +31,44 @@ export function PresentRespondersList({
); );
} }
const reveal = Boolean(isQuiz && showCorrectness);
return (
<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 ( return (
<ul className="flex flex-col gap-2">
{responders.map((r, i) => (
<li <li
key={`${r.participantId}-${r.answeredAt}-${i}`} 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" 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="w-8 shrink-0 text-center text-sm font-bold tabular-nums text-[rgba(36,40,47,0.4)]"> <span className="text-sm font-bold tabular-nums text-[rgba(36,40,47,0.45)]">#{i + 1}</span>
{i + 1} <span className="truncate text-sm font-semibold text-[#24282f] sm:text-base">{r.displayName}</span>
</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">
<span <span
className={`inline-flex rounded-full px-2.5 py-1 text-xs font-bold ${ className={`text-right text-sm font-semibold ${
r.isCorrect === true label === "Correct"
? "bg-emerald-100 text-emerald-800" ? "text-emerald-700"
: r.isCorrect === false : label === "Incorrect"
? "bg-red-100 text-red-700" ? "text-red-600"
: "bg-slate-100 text-slate-600" : "text-[rgba(36,40,47,0.45)]"
}`} }`}
> >
{r.isCorrect === true ? "Correct" : r.isCorrect === false ? "Incorrect" : "—"} {label}
</span>
<span className="text-right text-sm font-semibold tabular-nums text-[#1e72ef]">
{formatTimeTaken(r.responseTimeSec)}
</span> </span>
</div>
) : null}
</li> </li>
))} );
})}
</ul> </ul>
</div>
); );
} }

View File

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

View File

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

View File

@ -792,6 +792,7 @@ export type PublicQuizPollPresentation = {
isLive?: boolean; isLive?: boolean;
liveQuestionId?: string | null; liveQuestionId?: string | null;
showCorrectAnswer?: boolean; showCorrectAnswer?: boolean;
showLeaderboard?: boolean;
showResults?: boolean; showResults?: boolean;
}; };
questions: PublicQuizPollPresentationQuestion[]; questions: PublicQuizPollPresentationQuestion[];
@ -871,6 +872,31 @@ export async function getPublicQuizPollQuestionStats(
return (body as { data: PublicQuizPollQuestionStats }).data; 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( async function postPublicPresentationControl(
token: string, token: string,
pathSuffix: string, pathSuffix: string,
@ -903,3 +929,171 @@ export async function setPublicLiveQuestion(
): Promise<PublicQuizPollPresentation> { ): Promise<PublicQuizPollPresentation> {
return postPublicPresentationControl(token, "/live-question", { questionId }); 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}`
: photoId
? `${base}/api/public/photo-wall/upload/${encodeURIComponent(uploadToken)}/photos/${encodeURIComponent(photoId)}/file`
: 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;
}

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

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

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

@ -0,0 +1,172 @@
/** Download + social share helpers for photo-wall visitor success screen.
*
* Platform buttons open that app (or its web share intent) — not the iOS system share sheet.
* Instagram / Snapchat cannot receive an image via web intent, so we save the photo,
* copy the caption, then open the app.
*/
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/",
};
function isMobileUa(): boolean {
if (typeof navigator === "undefined") return false;
return /iPhone|iPad|iPod|Android/i.test(navigator.userAgent);
}
async function fetchImageFile(url: string, filename = "photo-wall.jpg"): Promise<File> {
const res = await fetch(url, { mode: "cors" });
if (!res.ok) throw new Error("Could not load image for sharing");
const blob = await res.blob();
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 });
}
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";
}
/**
* Share to a specific platform. Does NOT use navigator.share (that always opens the
* iOS system sheet and ignores which button was tapped).
*/
export async function sharePhotoWallImage(options: {
platform: PhotoWallSharePlatform;
imageUrl: string;
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 = platformShareUrl(platform, imageUrl, text);
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: save file locally, copy caption, open the app.
const file = await fetchImageFile(imageUrl, "photo-wall.jpg");
return saveImageAndOpenApp(platform, file, text);
}

View File

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

View File

@ -18,6 +18,8 @@ export type RtdbQuizPollSettings = {
liveQuestionId?: string | null; liveQuestionId?: string | null;
/** quiz_master: projector shows correct answer highlight */ /** quiz_master: projector shows correct answer highlight */
showCorrectAnswer?: boolean; showCorrectAnswer?: boolean;
/** quiz_master: projector switches to Leaderboard tab */
showLeaderboard?: boolean;
}; };
/** Normalize status from RTDB; legacy nodes without status are treated as active. */ /** Normalize status from RTDB; legacy nodes without status are treated as active. */
@ -49,6 +51,24 @@ export function firstQuizMasterOrManualVisibleId(
return null; 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). * 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() ? String(o.liveQuestionId).trim()
: null; : null;
const showCorrectAnswer = o.showCorrectAnswer === true || o.showCorrectAnswer === "true"; 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[] { function mapOptions(raw: unknown): RtdbQuizPollOption[] {

View File

@ -4,7 +4,7 @@ import { AuthShell } from "../components/auth/AuthShell";
import { normalizeJoinCodeInput, resolveJoinCodeAndNavigate } from "../lib/joinCodeNavigation"; 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() { export function CodeEntryPage() {
const { code: codeParam } = useParams<{ code: string }>(); const { code: codeParam } = useParams<{ code: string }>();

View File

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

View File

@ -1,418 +1,71 @@
import { useCallback, useState, type ReactNode } from "react"; import { Ticket } from "lucide-react";
import { import { AuthShell } from "../components/auth/AuthShell";
BarChart3, import { useJoinEventCode } from "../hooks/useJoinEventCode";
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";
const featureItems = [ /** Join entry page (default route `/`) — enter event or session code. */
{
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 `/`) */
export function Home() { export function Home() {
const [captcha, setCaptcha] = useState(() => randomCaptcha()); const { code, setCode, submitting, joinError, handleJoin } = useJoinEventCode();
const [captchaInput, setCaptchaInput] = useState("");
const reloadCaptcha = useCallback(() => {
setCaptcha(randomCaptcha());
setCaptchaInput("");
}, []);
function handleContactSubmit(e: React.FormEvent) {
e.preventDefault();
reloadCaptcha();
}
return ( return (
<div className="min-h-dvh w-full bg-white text-[#24282f]" data-name="Home"> <AuthShell>
<MarketingHeader /> <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 /> <form onSubmit={handleJoin} className="flex flex-col gap-6 px-6 pb-8 pt-7">
<MarketingHeroSection /> <div className="flex flex-col items-center gap-3 text-center">
{/* 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"
>
<div className="flex size-12 shrink-0 items-center justify-center rounded-lg bg-[#f2f2f7] lg:size-[88px] lg:rounded-2xl lg:p-6">
<Icon className="size-6 text-[#1d66ef] lg:size-14" strokeWidth={1.5} />
</div>
<div className="min-w-0 space-y-1 lg:space-y-6">
<h3 className="font-inter text-base font-semibold tracking-wide lg:text-2xl">
{title}
</h3>
<p className="text-sm font-light leading-snug tracking-wide lg:text-[22px] lg:leading-[27px]">
{body}
</p>
</div>
</li>
))}
</ul>
</div>
</section>
{/* About + stats */}
<section
id="about"
className={`${MARKETING_HEADER_SCROLL_MARGIN} bg-[#f2f2f7] py-10 lg:py-20`}
data-name="Banner_03_Mobile"
>
<div className={`mx-auto w-full max-w-[1728px] space-y-9 lg:space-y-12 ${shellX}`}>
<div className="mx-auto max-w-4xl space-y-4 lg:mx-0 lg:max-w-4xl">
<h2 className="text-[clamp(1.5rem,4vw,3rem)] font-light italic leading-tight tracking-wide">
About event<span className="font-bold not-italic">buzz</span>
</h2>
<p className="text-lg font-light leading-snug tracking-wide lg:text-[22px] lg:leading-[27px]">
EventBuzz is a comprehensive event engagement platform designed to transform how
organizers connect with their audience. Whether you&apos;re hosting a conference,
workshop, training session, or corporate event, EventBuzz provides the tools you need
to create memorable experiences.
</p>
<p className="text-base font-medium leading-snug tracking-wide lg:text-lg">
Our platform enables real-time interaction through live Q&A sessions, instant polls,
engaging quizzes, and much more.
</p>
</div>
<div <div
className="relative mx-auto max-w-[1439px] overflow-hidden rounded-[40px] sm:rounded-[60px]" className="flex size-12 items-center justify-center rounded-full"
data-name="About_Banner" style={{
background: "linear-gradient(135deg, rgba(52,167,242,0.12) 0%, rgba(29,102,239,0.16) 100%)",
}}
aria-hidden
> >
<div className="relative aspect-[16/10] min-h-[280px] w-full lg:aspect-[1439/494] lg:min-h-[400px]"> <Ticket className="size-6 text-[#1D66EF]" strokeWidth={1.75} />
<img
src={aboutBanner}
alt=""
className="absolute inset-0 size-full object-cover object-center"
/>
<div className="absolute inset-0 bg-black/55" />
</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"> <div className="space-y-1.5">
{[ <h1 className="font-inter text-[22px] font-bold leading-tight text-[#24282f]">
{ n: "1000+", l: "Events Hosted" }, Join with your code
{ n: "50K+", l: "Attendees" }, </h1>
{ n: "96%", l: "Satisfaction" }, <p className="font-inter text-sm leading-relaxed text-[rgba(36,40,47,0.72)]">
].map((row) => ( Enter the code provided by the event organiser — you’ll usually find it in your
<div invite, on the event screen, or on a poster at the venue.
key={row.l}
className="flex items-center gap-4 rounded-2xl bg-white/20 px-6 py-3 text-[#f1f1f1] shadow-[0_6px_10px_rgba(0,0,0,0.15),0_2px_3px_rgba(0,0,0,0.3)] lg:flex-col lg:items-center lg:gap-2 lg:px-10 lg:py-4 lg:text-center"
>
<span className="text-3xl font-light italic tracking-wide sm:text-[40px] lg:text-[60px] lg:leading-[84px]">
{row.n}
</span>
<span className="text-base font-semibold italic tracking-wide lg:text-[22px] lg:leading-[27px]">
{row.l}
</span>
</div>
))}
</div>
</div>
</div>
</section>
{/* Contact intro (dark) */}
<section className="bg-[#24282f] px-4 py-8 text-white sm:px-6 lg:py-20" data-name="Contact Us">
<div className={`mx-auto w-full max-w-[1728px] space-y-6 lg:space-y-9 ${shellX}`}>
<h2 className="text-[clamp(1.75rem,4vw,4.75rem)] font-light italic leading-tight tracking-wide lg:leading-[84px]">
Contact Us
</h2>
<p className="max-w-4xl text-lg font-light leading-snug tracking-wide text-white/95 lg:max-w-[1067px] lg:text-[22px] lg:leading-[27px]">
Have questions? We&apos;d love to hear from you. Send us a message!
</p> </p>
</div> </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>
<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"
/>
</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> </div>
<label className="flex flex-col gap-2">
<span className="font-inter text-sm font-semibold text-[#24282f]">Code</span>
<input
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)]"
/>
</label>
{joinError ? (
<p className="-mt-2 text-center text-sm text-red-600" role="alert">
{joinError}
</p>
) : null}
<button <button
type="submit" 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> </button>
</form> </form>
<div className="w-full shrink-0 space-y-8 rounded-3xl bg-[#f2f2f7] p-4 lg:max-w-[464px] lg:self-stretch lg:p-10">
<div>
<h3 className="font-inter text-base font-semibold lg:text-[22px]">Get in Touch</h3>
<p className="mt-2 text-sm leading-normal lg:text-lg">
Have questions? We&apos;d love to hear from you. Send us a message!
</p>
</div>
<div className="flex gap-4">
<div className="flex size-14 shrink-0 items-center justify-center rounded-xl bg-[#ef8d1d] text-white">
<Phone className="size-6" strokeWidth={1.75} />
</div>
<div>
<p className="font-inter text-base font-semibold lg:text-2xl">Phone</p>
<a href="tel:+919923121245" className="text-sm hover:underline lg:text-lg">
+91 99231 21245
</a>
</div> </div>
</div> </div>
<div className="flex gap-4"> </AuthShell>
<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>
); );
} }

View File

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

View File

@ -0,0 +1,538 @@
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 { downloadImageFromUrl, sharePhotoWallImage } 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 [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);
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 clearSelectedPhoto = useCallback(() => {
setFile(null);
setPreviewUrl((prev) => {
if (prev) URL.revokeObjectURL(prev);
return null;
});
if (fileRef.current) fileRef.current.value = "";
}, []);
const resetForNewUpload = useCallback(() => {
setDone(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 result = await uploadPhotoWallSelfie(uploadToken, file, values, {
displayName: meta.askUploaderName ? uploaderName.trim() : undefined,
});
setDone({
awaitingApproval: result.awaitingApproval,
previewUrl: result.previewUrl,
fileUrl: result.fileUrl,
framed: result.framed,
});
clearSelectedPhoto();
} catch (e2) {
setErr(e2 instanceof Error ? e2.message : "Upload failed");
} finally {
setSubmitting(false);
}
};
const onDownload = async () => {
const src = done?.fileUrl || done?.previewUrl;
if (!src) return;
setActionMsg(null);
try {
await downloadImageFromUrl(src, "photo-wall.jpg");
setActionMsg("Download started");
} catch (e) {
setActionMsg(e instanceof Error ? e.message : "Download failed");
}
};
const onShare = async (platform: keyof PhotoWallSharePlatforms) => {
const src = done?.fileUrl || done?.previewUrl;
if (!src) return;
setActionMsg(null);
try {
const result = await sharePhotoWallImage({
platform,
imageUrl: src,
title: meta?.eventName || "Photo wall",
text: `Check out my photo from ${meta?.eventName || "the event"}!`,
});
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
src={done.fileUrl || done.previewUrl || undefined}
alt={done.framed ? "Your framed photo" : "Uploaded photo"}
className="mx-auto max-h-[360px] w-full object-contain"
onError={(e) => {
const img = e.currentTarget;
if (done.previewUrl && img.src !== done.previewUrl) {
img.src = done.previewUrl;
}
}}
/>
</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>
<div className="grid grid-cols-2 gap-2">
{enabledSharePlatforms.map((item) => (
<button
key={item.key}
type="button"
onClick={() => void onShare(item.key)}
className="inline-flex h-10 items-center justify-center gap-2 rounded-lg border border-[#ddd] bg-white px-3 text-xs font-semibold text-[#333] hover:bg-[#fafafa]"
>
<ShareIcon platform={item.key} />
{item.label}
</button>
))}
</div>
</div>
) : null}
{actionMsg ? <p className="mt-3 text-center text-xs text-[#666]">{actionMsg}</p> : null}
</WhiteCard>
</AuthShell>
);
}
return (
<AuthShell showLogo={false}>
<WhiteCard>
{meta?.event ? (
<div className="mb-4">
<PhotoWallEventHeader event={meta.event} display={meta.eventHeader || null} />
</div>
) : null}
<h1 className="mb-1 text-center text-[22px] font-semibold text-[#1a1a1a]">Upload your photo</h1>
<p className="mb-5 text-center text-sm text-[#666]">
{meta?.form?.description || "Add a selfie to the event photo wall."}
</p>
{meta?.accessMode === "login" && !loggedIn ? (
<div className="space-y-3">
<p className="text-center text-sm text-[#666]">Sign in to upload your photo for this event.</p>
<button
type="button"
onClick={() => void goLogin()}
className="inline-flex h-11 w-full items-center justify-center rounded-lg bg-[#D32F2F] px-4 text-sm font-semibold text-white hover:bg-[#b71c1c]"
>
Sign in to continue
</button>
</div>
) : (
<form onSubmit={(e) => void onSubmit(e)} className="space-y-4">
<div>
<input
ref={fileRef}
type="file"
accept="image/*"
capture="user"
className="hidden"
onChange={(e) => void handleFileChange(e.target.files?.[0] || null)}
/>
<button
type="button"
disabled={picking}
onClick={() => fileRef.current?.click()}
className="flex w-full flex-col items-center justify-center gap-2 rounded-lg border-2 border-dashed border-[#ccc] bg-[#fafafa] px-4 py-8 text-sm text-[#666] hover:border-[#D32F2F] hover:bg-[#fff5f5]"
>
{previewUrl ? (
<img src={previewUrl} alt="Preview" className="max-h-48 rounded-md object-contain" />
) : (
<>
<ImagePlus className="h-8 w-8 text-[#D32F2F]" />
<span>{picking ? "Preparing…" : "Tap to take or choose a photo"}</span>
</>
)}
</button>
{file ? (
<p className="mt-2 truncate text-center text-xs text-[#888]">{file.name}</p>
) : null}
</div>
{meta?.askUploaderName ? (
<label className="block text-sm">
<span className="mb-1 block font-medium text-[#333]">
Your name <span className="text-red-500">*</span>
</span>
<input
type="text"
value={uploaderName}
onChange={(e) => setUploaderName(e.target.value)}
className="h-10 w-full rounded-lg border border-[#ddd] px-3 text-sm outline-none focus:border-[#D32F2F]"
placeholder="Name shown on the wall"
required
/>
</label>
) : null}
{fields.map((f) => {
const key = fieldKey(f);
if (!key) return null;
const required = Boolean(f.required);
if (f.type === "textarea") {
return (
<label key={key} className="block text-sm">
<span className="mb-1 block font-medium text-[#333]">
{f.label}
{required ? <span className="text-red-500"> *</span> : null}
</span>
<textarea
value={String(values[key] ?? "")}
onChange={(e) => setValues((v) => ({ ...v, [key]: e.target.value }))}
required={required}
rows={3}
className="w-full rounded-lg border border-[#ddd] px-3 py-2 text-sm outline-none focus:border-[#D32F2F]"
/>
</label>
);
}
return (
<label key={key} className="block text-sm">
<span className="mb-1 block font-medium text-[#333]">
{f.label}
{required ? <span className="text-red-500"> *</span> : null}
</span>
<input
type={f.type === "email" ? "email" : f.type === "tel" ? "tel" : "text"}
value={String(values[key] ?? "")}
onChange={(e) => setValues((v) => ({ ...v, [key]: e.target.value }))}
required={required}
className="h-10 w-full rounded-lg border border-[#ddd] px-3 text-sm outline-none focus:border-[#D32F2F]"
/>
</label>
);
})}
{err ? <p className="text-center text-sm text-red-600">{err}</p> : null}
<button
type="submit"
disabled={!file || submitting || picking}
className="inline-flex h-11 w-full items-center justify-center rounded-lg bg-[#D32F2F] px-4 text-sm font-semibold text-white hover:bg-[#b71c1c] disabled:opacity-50"
>
{submitting ? "Uploading…" : "Upload"}
</button>
</form>
)}
</WhiteCard>
{cropSession && meta ? (
<PhotoWallCropModal
imageSrc={cropSession.imageSrc}
sourceFileName={cropSession.sourceFileName}
aspect={
Math.max(1, Number(meta.maxPhotoWidth) || 1080) /
Math.max(1, Number(meta.maxPhotoHeight) || 1080)
}
onCancel={() => {
URL.revokeObjectURL(cropSession.imageSrc);
setCropSession(null);
}}
onConfirm={(cropped) => {
URL.revokeObjectURL(cropSession.imageSrc);
setCropSession(null);
applySelectedFile(cropped);
}}
/>
) : null}
</AuthShell>
);
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -94,10 +94,34 @@ export type VisitorJoinContext = {
trackId?: string; 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=` * 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 { export function buildPostAuthUrl(ctx: VisitorJoinContext | EbVisitorContext | null): string {
const ret = consumePostAuthReturnPath();
if (ret) return ret;
if (!ctx) return "/event"; if (!ctx) return "/event";
const params = new URLSearchParams(); const params = new URLSearchParams();
params.set("event_id", ctx.eventId); params.set("event_id", ctx.eventId);