Changes
This commit is contained in:
parent
ecae5a7eb2
commit
229e3cdcda
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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": {
|
||||||
|
|
|
||||||
|
|
@ -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 />} />
|
||||||
|
|
|
||||||
|
|
@ -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(" ")}
|
||||||
/>
|
/>
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,113 @@
|
||||||
|
import { useCallback, useState } from "react";
|
||||||
|
import Cropper, { type Area } from "react-easy-crop";
|
||||||
|
import { getCroppedImageFile } from "../lib/cropImage";
|
||||||
|
|
||||||
|
type PhotoWallCropModalProps = {
|
||||||
|
imageSrc: string;
|
||||||
|
aspect: number;
|
||||||
|
sourceFileName?: string;
|
||||||
|
onCancel: () => void;
|
||||||
|
onConfirm: (file: File) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function PhotoWallCropModal({
|
||||||
|
imageSrc,
|
||||||
|
aspect,
|
||||||
|
sourceFileName,
|
||||||
|
onCancel,
|
||||||
|
onConfirm,
|
||||||
|
}: PhotoWallCropModalProps) {
|
||||||
|
const [crop, setCrop] = useState({ x: 0, y: 0 });
|
||||||
|
const [zoom, setZoom] = useState(1);
|
||||||
|
const [croppedAreaPixels, setCroppedAreaPixels] = useState<Area | null>(null);
|
||||||
|
const [working, setWorking] = useState(false);
|
||||||
|
const [err, setErr] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const onCropComplete = useCallback((_area: Area, pixels: Area) => {
|
||||||
|
setCroppedAreaPixels(pixels);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const confirm = async () => {
|
||||||
|
if (!croppedAreaPixels) return;
|
||||||
|
setWorking(true);
|
||||||
|
setErr(null);
|
||||||
|
try {
|
||||||
|
const file = await getCroppedImageFile(
|
||||||
|
imageSrc,
|
||||||
|
croppedAreaPixels,
|
||||||
|
sourceFileName || "photo.jpg",
|
||||||
|
"image/jpeg",
|
||||||
|
);
|
||||||
|
onConfirm(file);
|
||||||
|
} catch (e) {
|
||||||
|
setErr(e instanceof Error ? e.message : "Could not crop photo");
|
||||||
|
} finally {
|
||||||
|
setWorking(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="fixed inset-0 z-50 flex items-end justify-center bg-black/55 p-4 sm:items-center">
|
||||||
|
<div className="flex w-full max-w-[420px] flex-col overflow-hidden rounded-2xl bg-white shadow-xl">
|
||||||
|
<div className="border-b border-[#f2f2f7] px-4 py-3">
|
||||||
|
<p className="font-inter text-base font-semibold text-[#24282f]">Crop your photo</p>
|
||||||
|
<p className="mt-0.5 font-inter text-xs text-[rgba(36,40,47,0.65)]">
|
||||||
|
Adjust to match the wall photo ratio before uploading.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="relative h-[280px] w-full bg-[#1a1d24] sm:h-[320px]">
|
||||||
|
<Cropper
|
||||||
|
image={imageSrc}
|
||||||
|
crop={crop}
|
||||||
|
zoom={zoom}
|
||||||
|
aspect={aspect}
|
||||||
|
onCropChange={setCrop}
|
||||||
|
onZoomChange={setZoom}
|
||||||
|
onCropComplete={onCropComplete}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-3 px-4 py-4">
|
||||||
|
<label className="flex flex-col gap-1.5">
|
||||||
|
<span className="font-inter text-xs font-semibold text-[#24282f]">Zoom</span>
|
||||||
|
<input
|
||||||
|
type="range"
|
||||||
|
min={1}
|
||||||
|
max={3}
|
||||||
|
step={0.05}
|
||||||
|
value={zoom}
|
||||||
|
onChange={(e) => setZoom(Number(e.target.value))}
|
||||||
|
className="w-full accent-[#ef8d1d]"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
{err ? (
|
||||||
|
<p className="text-center text-sm text-red-600" role="alert">
|
||||||
|
{err}
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onCancel}
|
||||||
|
disabled={working}
|
||||||
|
className="flex-1 rounded-full border border-[rgba(116,112,112,0.3)] bg-white py-3 text-xs font-semibold uppercase tracking-wide text-[#24282f] disabled:opacity-60"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => void confirm()}
|
||||||
|
disabled={working || !croppedAreaPixels}
|
||||||
|
className="flex-1 rounded-full border border-[rgba(214,200,200,0.6)] bg-[#ef8d1d] py-3 text-xs font-semibold uppercase tracking-wide text-white shadow-[0_4px_4px_rgba(0,0,0,0.15)] disabled:opacity-60"
|
||||||
|
>
|
||||||
|
{working ? "Cropping…" : "Use photo"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,55 @@
|
||||||
|
import type { PublicQuizPollLeaderboardEntry } from "../../lib/api";
|
||||||
|
|
||||||
|
function formatTimeTaken(sec: number): string {
|
||||||
|
if (sec <= 0) return "—";
|
||||||
|
if (sec < 60) return `${sec}s`;
|
||||||
|
const m = Math.floor(sec / 60);
|
||||||
|
const s = sec % 60;
|
||||||
|
return s > 0 ? `${m}m ${s}s` : `${m}m`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function PresentLeaderboard({
|
||||||
|
entries,
|
||||||
|
loading,
|
||||||
|
}: {
|
||||||
|
entries: PublicQuizPollLeaderboardEntry[];
|
||||||
|
loading?: boolean;
|
||||||
|
}) {
|
||||||
|
if (loading && entries.length === 0) {
|
||||||
|
return <p className="py-8 text-center text-sm text-[rgba(36,40,47,0.55)]">Loading leaderboard…</p>;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (entries.length === 0) {
|
||||||
|
return (
|
||||||
|
<p className="py-8 text-center text-sm text-[rgba(36,40,47,0.55)]">No quiz responses yet.</p>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="overflow-hidden rounded-lg border border-[#e5e5ea] bg-white shadow-sm">
|
||||||
|
<div className="grid grid-cols-[48px_minmax(0,1fr)_88px_72px] gap-2 border-b border-[#e5e5ea] bg-[#f2f2f7] px-3 py-2 text-[11px] font-semibold uppercase tracking-wide text-[rgba(36,40,47,0.55)] sm:grid-cols-[56px_minmax(0,1fr)_110px_88px] sm:px-4">
|
||||||
|
<span>Rank</span>
|
||||||
|
<span>Name</span>
|
||||||
|
<span className="text-right">Correct</span>
|
||||||
|
<span className="text-right">Time</span>
|
||||||
|
</div>
|
||||||
|
<ul className="divide-y divide-[#e5e5ea]">
|
||||||
|
{entries.map((row) => (
|
||||||
|
<li
|
||||||
|
key={`${row.participantId || row.displayName}-${row.rank}`}
|
||||||
|
className="grid grid-cols-[48px_minmax(0,1fr)_88px_72px] items-center gap-2 px-3 py-2.5 sm:grid-cols-[56px_minmax(0,1fr)_110px_88px] sm:px-4"
|
||||||
|
>
|
||||||
|
<span className="text-sm font-bold tabular-nums text-[rgba(36,40,47,0.45)]">#{row.rank}</span>
|
||||||
|
<span className="truncate text-sm font-semibold text-[#24282f] sm:text-base">{row.displayName}</span>
|
||||||
|
<span className="text-right text-sm font-semibold tabular-nums text-[#24282f]">
|
||||||
|
{row.correctCount}/{row.totalQuestions}
|
||||||
|
</span>
|
||||||
|
<span className="text-right text-sm font-semibold tabular-nums text-[#1e72ef]">
|
||||||
|
{formatTimeTaken(row.timeTakenSec)}
|
||||||
|
</span>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -2,16 +2,22 @@ import type { PublicQuizPollResponder } from "../../lib/api";
|
||||||
|
|
||||||
function formatTimeTaken(sec: number): string {
|
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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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]);
|
||||||
|
}
|
||||||
194
src/lib/api.ts
194
src/lib/api.ts
|
|
@ -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;
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,64 @@
|
||||||
|
import type { Area } from "react-easy-crop";
|
||||||
|
|
||||||
|
export function loadImage(src: string): Promise<HTMLImageElement> {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const img = new Image();
|
||||||
|
img.addEventListener("load", () => resolve(img));
|
||||||
|
img.addEventListener("error", () => reject(new Error("Failed to load image")));
|
||||||
|
img.src = src;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getImageNaturalSize(file: File): Promise<{ width: number; height: number }> {
|
||||||
|
const url = URL.createObjectURL(file);
|
||||||
|
try {
|
||||||
|
const img = await loadImage(url);
|
||||||
|
return { width: img.naturalWidth, height: img.naturalHeight };
|
||||||
|
} finally {
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ratiosMatch(
|
||||||
|
imageWidth: number,
|
||||||
|
imageHeight: number,
|
||||||
|
targetWidth: number,
|
||||||
|
targetHeight: number,
|
||||||
|
tolerance = 0.02,
|
||||||
|
): boolean {
|
||||||
|
if (!imageWidth || !imageHeight || !targetWidth || !targetHeight) return true;
|
||||||
|
const imageRatio = imageWidth / imageHeight;
|
||||||
|
const targetRatio = targetWidth / targetHeight;
|
||||||
|
return Math.abs(imageRatio - targetRatio) / targetRatio <= tolerance;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getCroppedImageFile(
|
||||||
|
imageSrc: string,
|
||||||
|
crop: Area,
|
||||||
|
fileName = "photo.jpg",
|
||||||
|
mimeType = "image/jpeg",
|
||||||
|
): Promise<File> {
|
||||||
|
const image = await loadImage(imageSrc);
|
||||||
|
const canvas = document.createElement("canvas");
|
||||||
|
const ctx = canvas.getContext("2d");
|
||||||
|
if (!ctx) throw new Error("Canvas not supported");
|
||||||
|
|
||||||
|
const w = Math.max(1, Math.round(crop.width));
|
||||||
|
const h = Math.max(1, Math.round(crop.height));
|
||||||
|
canvas.width = w;
|
||||||
|
canvas.height = h;
|
||||||
|
|
||||||
|
ctx.drawImage(image, crop.x, crop.y, crop.width, crop.height, 0, 0, w, h);
|
||||||
|
|
||||||
|
const blob = await new Promise<Blob>((resolve, reject) => {
|
||||||
|
canvas.toBlob(
|
||||||
|
(b) => (b ? resolve(b) : reject(new Error("Failed to crop image"))),
|
||||||
|
mimeType,
|
||||||
|
0.92,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
const base = fileName.replace(/\.[^.]+$/, "") || "photo";
|
||||||
|
const ext = mimeType === "image/png" ? "png" : "jpg";
|
||||||
|
return new File([blob], `${base}.${ext}`, { type: mimeType });
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,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);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,42 @@
|
||||||
|
import { ref, onValue, type Unsubscribe } from "firebase/database";
|
||||||
|
import { getRtdb } from "./firebaseClient";
|
||||||
|
|
||||||
|
export type SessionPresentFocus = "questions" | null;
|
||||||
|
|
||||||
|
export type SessionPresentationState = {
|
||||||
|
focus: SessionPresentFocus;
|
||||||
|
updatedAt: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
function readPresentation(raw: unknown): SessionPresentationState {
|
||||||
|
if (!raw || typeof raw !== "object" || Array.isArray(raw)) {
|
||||||
|
return { focus: null, updatedAt: null };
|
||||||
|
}
|
||||||
|
const o = raw as Record<string, unknown>;
|
||||||
|
const focus = o.focus === "questions" ? "questions" : null;
|
||||||
|
const updatedAt = o.updatedAt != null ? String(o.updatedAt) : null;
|
||||||
|
return { focus, updatedAt };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Subscribe to `sessions/{sessionId}/presentation` present-screen focus. */
|
||||||
|
export function subscribeSessionPresentation(
|
||||||
|
sessionId: string,
|
||||||
|
onData: (state: SessionPresentationState) => void,
|
||||||
|
onError?: (e: Error) => void,
|
||||||
|
): Unsubscribe {
|
||||||
|
const db = getRtdb();
|
||||||
|
const r = ref(db, `sessions/${sessionId}/presentation`);
|
||||||
|
return onValue(
|
||||||
|
r,
|
||||||
|
(snap) => {
|
||||||
|
try {
|
||||||
|
onData(readPresentation(snap.val()));
|
||||||
|
} catch (e) {
|
||||||
|
onError?.(e instanceof Error ? e : new Error(String(e)));
|
||||||
|
}
|
||||||
|
},
|
||||||
|
(err) => {
|
||||||
|
onError?.(err instanceof Error ? err : new Error(String(err)));
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -18,6 +18,8 @@ export type RtdbQuizPollSettings = {
|
||||||
liveQuestionId?: string | null;
|
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[] {
|
||||||
|
|
|
||||||
|
|
@ -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 }>();
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -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'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'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'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>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,113 @@
|
||||||
|
import { useCallback, useEffect, useState } from "react";
|
||||||
|
import { useParams } from "react-router-dom";
|
||||||
|
import { getPhotoWallPresent, type PhotoWallPresentData } from "../lib/api";
|
||||||
|
|
||||||
|
const POLL_MS = 4000;
|
||||||
|
|
||||||
|
export function PhotoWallPresentPage() {
|
||||||
|
const { presentToken } = useParams<{ presentToken: string }>();
|
||||||
|
const [data, setData] = useState<PhotoWallPresentData | null>(null);
|
||||||
|
const [err, setErr] = useState<string | null>(null);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
|
||||||
|
const refresh = useCallback(async () => {
|
||||||
|
if (!presentToken) return;
|
||||||
|
try {
|
||||||
|
const next = await getPhotoWallPresent(presentToken);
|
||||||
|
setData(next);
|
||||||
|
setErr(null);
|
||||||
|
} catch (e) {
|
||||||
|
setErr(e instanceof Error ? e.message : "Photo wall not available");
|
||||||
|
setData(null);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, [presentToken]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
void refresh();
|
||||||
|
const id = window.setInterval(() => void refresh(), POLL_MS);
|
||||||
|
return () => window.clearInterval(id);
|
||||||
|
}, [refresh]);
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div className="eb-gradient-auth flex min-h-dvh items-center justify-center text-white/80">
|
||||||
|
Loading wall…
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!data) {
|
||||||
|
return (
|
||||||
|
<div className="eb-gradient-auth flex min-h-dvh flex-col items-center justify-center gap-2 px-6 text-center text-white">
|
||||||
|
<p className="text-lg font-semibold">Photo wall unavailable</p>
|
||||||
|
<p className="text-sm text-white/80">{err}</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const cols = Math.max(1, Math.min(12, data.photosPerRow || 4));
|
||||||
|
const maxW = data.maxPhotoWidth || 320;
|
||||||
|
const maxH = data.maxPhotoHeight || 320;
|
||||||
|
const gap = data.gapPx ?? 8;
|
||||||
|
const showNames = Boolean(data.showUploaderNameOnWall);
|
||||||
|
const frameEnabled = Boolean(data.frameEnabled);
|
||||||
|
const frameW = data.frameCanvasWidth || 1080;
|
||||||
|
const frameH = data.frameCanvasHeight || 1080;
|
||||||
|
const aspectRatio = frameEnabled ? `${frameW} / ${frameH}` : `${maxW} / ${maxH}`;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="eb-gradient-auth min-h-dvh text-white">
|
||||||
|
<div className="px-4 pb-3 pt-4 text-center">
|
||||||
|
<p className="text-xs uppercase tracking-[0.2em] text-white/70">Photo Wall</p>
|
||||||
|
<h1 className="font-inter text-xl font-semibold sm:text-2xl">{data.eventName}</h1>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{data.photos.length === 0 ? (
|
||||||
|
<div className="flex min-h-[60dvh] items-center justify-center px-6 text-center text-white/80">
|
||||||
|
Waiting for photos…
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div
|
||||||
|
className="mx-auto w-full max-w-[100vw] px-3 pb-6"
|
||||||
|
style={{
|
||||||
|
display: "grid",
|
||||||
|
gridTemplateColumns: `repeat(${cols}, minmax(0, 1fr))`,
|
||||||
|
gap: `${gap}px`,
|
||||||
|
justifyItems: "center",
|
||||||
|
alignItems: "start",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{data.photos.map((p) => (
|
||||||
|
<div
|
||||||
|
key={p._id}
|
||||||
|
className="flex w-full flex-col items-center"
|
||||||
|
style={{ maxWidth: maxW }}
|
||||||
|
>
|
||||||
|
<img
|
||||||
|
src={p.url}
|
||||||
|
alt=""
|
||||||
|
className="w-full rounded-md object-cover shadow-lg"
|
||||||
|
style={{
|
||||||
|
maxWidth: maxW,
|
||||||
|
maxHeight: maxH,
|
||||||
|
aspectRatio,
|
||||||
|
objectFit: "cover",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
{showNames && p.displayName ? (
|
||||||
|
<p
|
||||||
|
className="mt-1.5 w-full truncate text-center font-inter text-xs font-medium text-white/90 sm:text-sm"
|
||||||
|
title={p.displayName}
|
||||||
|
>
|
||||||
|
{p.displayName}
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -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) {
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue