Improve event landing agenda and presentation flows.

Update session display, present menus, and related attendee app pages.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Ravi Gulhane 2026-08-06 13:05:01 +05:30
parent 228b81630e
commit ecae5a7eb2
15 changed files with 1007 additions and 82 deletions

55
nginx.example.conf Normal file
View File

@ -0,0 +1,55 @@
# nginx example for join.eventbuzz.live (participant SPA from `eventbuzz/`)
#
# Deploy: cd eventbuzz && npm run build → serve `dist` here.
# API CORS must allow https://join.eventbuzz.live; backend EVENTBUZZ_PUBLIC_URL
# must be https://join.eventbuzz.live (join codes, magic links, quiz-present).
#
# Dashboard stays on console.eventbuzz.live; microsites on web.eventbuzz.live;
# marketing apex eventbuzz.live can redirect legacy participant paths (below).
server {
listen 443 ssl http2;
server_name join.eventbuzz.live;
# ssl_certificate ...
# ssl_certificate_key ...
root /var/www/eventbuzz-join/dist;
index index.html;
location /assets/ {
try_files $uri =404;
expires 7d;
add_header Cache-Control "public, immutable";
}
# SPA fallback — /code/:code, /auth/callback, /quiz-present/..., event/session routes
location / {
try_files $uri $uri/ /index.html;
}
}
# Optional: keep old printed QRs / emails working after host move.
# Place on the marketing apex (eventbuzz.live) vhost — not on join.
#
# server {
# listen 443 ssl http2;
# server_name eventbuzz.live;
# # ... marketing site ...
#
# location ^~ /code/ {
# return 301 https://join.eventbuzz.live$request_uri;
# }
# location ^~ /auth/callback {
# return 301 https://join.eventbuzz.live$request_uri;
# }
# location ^~ /quiz-present/ {
# return 301 https://join.eventbuzz.live$request_uri;
# }
# }
# Local development
# - API: http://localhost:3001/api
# - Participant SPA: cd eventbuzz && npm run dev (port 5174)
# - Dashboard: cd frontend && npm run dev
# - Backend .env: EVENTBUZZ_PUBLIC_URL=http://localhost:5174 (or https://join.eventbuzz.live in prod)

View File

@ -17,6 +17,7 @@ import { RewardsPage } from "./pages/RewardsPage";
import { SingleEventPage } from "./pages/SingleEventPage"; 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 { 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";
@ -39,6 +40,7 @@ export default function App() {
<Route path="/session" element={<SingleEventPage />} /> <Route path="/session" element={<SingleEventPage />} />
<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" element={<Questions />} /> <Route path="/questions" element={<Questions />} />
<Route path="/ask" element={<Navigate to="/questions" replace />} /> <Route path="/ask" element={<Navigate to="/questions" replace />} />
<Route path="/feedback" element={<FeedbackPage />} /> <Route path="/feedback" element={<FeedbackPage />} />

View File

@ -22,6 +22,8 @@ export type AgendaRowTrackV2Data = {
showJoin?: boolean; showJoin?: boolean;
/** When set with `showJoin`, Join navigates here instead of a no-op button. */ /** When set with `showJoin`, Join navigates here instead of a no-op button. */
joinHref?: string; joinHref?: string;
/** Session is in progress — highlight Join and show a blinking live dot. */
joinLive?: boolean;
/** When set, bookmark control is shown and toggles this session on the device. */ /** When set, bookmark control is shown and toggles this session on the device. */
bookmark?: AgendaRowTrackV2Bookmark; bookmark?: AgendaRowTrackV2Bookmark;
/** Shown before clock time (e.g. full date on “My Sessions”). */ /** Shown before clock time (e.g. full date on “My Sessions”). */
@ -32,6 +34,9 @@ export type AgendaRowTrackV2Data = {
export function AgendaRowTrackV2({ row }: { row: AgendaRowTrackV2Data }) { export function AgendaRowTrackV2({ row }: { row: AgendaRowTrackV2Data }) {
const [detailsOpen, setDetailsOpen] = useState(false); const [detailsOpen, setDetailsOpen] = useState(false);
const joinBtnClass = row.joinLive
? "inline-flex h-8 w-[84px] items-center justify-between rounded-full bg-[#e67e00] pl-[18px] pr-2 text-xs font-semibold uppercase text-white shadow-[0_0_0_2px_rgba(239,141,29,0.5),0_4px_14px_rgba(239,141,29,0.45)] ring-2 ring-[#ffb347]/40"
: "inline-flex h-8 w-[84px] items-center justify-between rounded-full bg-[#ef8d1d] pl-[18px] pr-2 text-xs font-semibold uppercase text-white shadow-[0_4px_4px_rgba(0,0,0,0.15)]";
return ( return (
<div className="relative w-full pb-4"> <div className="relative w-full pb-4">
@ -132,24 +137,24 @@ export function AgendaRowTrackV2({ row }: { row: AgendaRowTrackV2Data }) {
</div> </div>
</div> </div>
) : null} ) : null}
{row.showJoin && {row.showJoin ? (
(row.joinHref ? ( <div className="mt-1 flex items-center gap-2">
<Link {row.joinHref ? (
to={row.joinHref} <Link to={row.joinHref} className={joinBtnClass}>
className="mt-1 inline-flex h-8 w-[84px] items-center justify-between rounded-full bg-[#ef8d1d] pl-[18px] pr-2 text-xs font-semibold uppercase text-white shadow-[0_4px_4px_rgba(0,0,0,0.15)]"
>
Join Join
<ChevronRight className="size-3" strokeWidth={2.5} /> <ChevronRight className="size-3" strokeWidth={2.5} />
</Link> </Link>
) : ( ) : (
<button <button type="button" className={joinBtnClass}>
type="button"
className="mt-1 inline-flex h-8 w-[84px] items-center justify-between rounded-full bg-[#ef8d1d] pl-[18px] pr-2 text-xs font-semibold uppercase text-white shadow-[0_4px_4px_rgba(0,0,0,0.15)]"
>
Join Join
<ChevronRight className="size-3" strokeWidth={2.5} /> <ChevronRight className="size-3" strokeWidth={2.5} />
</button> </button>
))} )}
{row.joinLive ? (
<span className="eb-live-dot" aria-hidden />
) : null}
</div>
) : null}
</div> </div>
</div> </div>
); );

View File

@ -5,8 +5,10 @@ import {
formatDateLong, formatDateLong,
formatDurationMinutes, formatDurationMinutes,
formatTimeShort, formatTimeShort,
isSessionLiveNow,
sessionTypeDisplay, sessionTypeDisplay,
} from "../lib/sessionDisplay"; } from "../lib/sessionDisplay";
import { useNow } from "../hooks/useNow";
import type { AgendaRowTrackV2Bookmark, AgendaRowTrackV2Data } from "./AgendaRowTrackV2"; import type { AgendaRowTrackV2Bookmark, AgendaRowTrackV2Data } from "./AgendaRowTrackV2";
import { AgendaRowTrackV2 } from "./AgendaRowTrackV2"; import { AgendaRowTrackV2 } from "./AgendaRowTrackV2";
@ -103,10 +105,12 @@ function sessionToAgendaRowV2(
s: VisitorSessionPreview, s: VisitorSessionPreview,
eventId: string, eventId: string,
bookmark: AgendaRowTrackV2Bookmark, bookmark: AgendaRowTrackV2Bookmark,
nowMs: number,
): AgendaRowTrackV2Data { ): AgendaRowTrackV2Data {
const names = (s.speakers || []).map(landingSpeakerName); const names = (s.speakers || []).map(landingSpeakerName);
if (s.moderator) names.push(`${landingSpeakerName(s.moderator)} (Moderator)`); if (s.moderator) names.push(`${landingSpeakerName(s.moderator)} (Moderator)`);
const joinHref = `/session?${new URLSearchParams({ event_id: eventId, session_id: s._id }).toString()}`; const joinHref = `/session?${new URLSearchParams({ event_id: eventId, session_id: s._id }).toString()}`;
const liveNow = isSessionLiveNow(s.startTime, s.endTime, nowMs) || s.status === "ongoing";
return { return {
time: formatTimeShort(s.startTime), time: formatTimeShort(s.startTime),
duration: formatDurationMinutes(s.startTime, s.endTime) || "—", duration: formatDurationMinutes(s.startTime, s.endTime) || "—",
@ -115,9 +119,10 @@ function sessionToAgendaRowV2(
description: description:
s.description?.trim() || "Agenda details will appear here when available.", s.description?.trim() || "Agenda details will appear here when available.",
speakerNames: names.length ? names : ["—"], speakerNames: names.length ? names : ["—"],
status: s.status === "ongoing" ? { kind: "live" } : null, status: liveNow ? { kind: "live" } : null,
showJoin: true, showJoin: true,
joinHref, joinHref,
joinLive: liveNow,
bookmark, bookmark,
}; };
} }
@ -126,8 +131,9 @@ function sessionToMySessionsRowV2(
s: VisitorSessionPreview, s: VisitorSessionPreview,
eventId: string, eventId: string,
bookmark: AgendaRowTrackV2Bookmark, bookmark: AgendaRowTrackV2Bookmark,
nowMs: number,
): AgendaRowTrackV2Data { ): AgendaRowTrackV2Data {
const base = sessionToAgendaRowV2(s, eventId, bookmark); const base = sessionToAgendaRowV2(s, eventId, bookmark, nowMs);
const hall = s.track?.hall?.trim(); const hall = s.track?.hall?.trim();
const dateLbl = formatDateLong(s.startTime); const dateLbl = formatDateLong(s.startTime);
return { return {
@ -154,6 +160,8 @@ export function LandingAgendaCards({
bookmarkedIds, bookmarkedIds,
toggleBookmark, toggleBookmark,
}: LandingAgendaCardsProps) { }: LandingAgendaCardsProps) {
const nowMs = useNow();
return ( return (
<div className="mt-3 flex w-full max-w-none flex-col gap-2"> <div className="mt-3 flex w-full max-w-none flex-col gap-2">
{days.map((day) => { {days.map((day) => {
@ -216,7 +224,7 @@ export function LandingAgendaCards({
row={sessionToAgendaRowV2(s, eventId, { row={sessionToAgendaRowV2(s, eventId, {
isBookmarked: bookmarkedIds.has(s._id), isBookmarked: bookmarkedIds.has(s._id),
onToggle: () => void toggleBookmark(s._id), onToggle: () => void toggleBookmark(s._id),
})} }, nowMs)}
/> />
))} ))}
</div> </div>
@ -244,6 +252,8 @@ export function LandingMySessionsList({
bookmarkedIds: Set<string>; bookmarkedIds: Set<string>;
toggleBookmark: (sessionId: string) => void | Promise<void>; toggleBookmark: (sessionId: string) => void | Promise<void>;
}) { }) {
const nowMs = useNow();
return ( return (
<div className="mt-3 w-full max-w-none"> <div className="mt-3 w-full max-w-none">
<div className="relative w-full"> <div className="relative w-full">
@ -255,7 +265,7 @@ export function LandingMySessionsList({
row={sessionToMySessionsRowV2(s, eventId, { row={sessionToMySessionsRowV2(s, eventId, {
isBookmarked: bookmarkedIds.has(s._id), isBookmarked: bookmarkedIds.has(s._id),
onToggle: () => void toggleBookmark(s._id), onToggle: () => void toggleBookmark(s._id),
})} }, nowMs)}
/> />
))} ))}
</div> </div>

View File

@ -0,0 +1,142 @@
import { useEffect, useId, useRef, useState } from "react";
export type PresentSessionMenuItem = {
_id: string;
title: string;
type: string;
presentationToken: string;
isOnAir: boolean;
};
function groupLabel(type: string): string {
const t = type.trim().toLowerCase();
if (t === "quiz") return "Quizzes";
if (t === "survey") return "Surveys";
if (t === "questions") return "Questions";
return "Polls";
}
function groupOrder(type: string): number {
const t = type.trim().toLowerCase();
if (t === "quiz") return 0;
if (t === "poll") return 1;
if (t === "survey") return 2;
if (t === "questions") return 3;
return 4;
}
export function PresentSessionMenu({
items,
currentId,
onSelect,
}: {
items: PresentSessionMenuItem[];
currentId: string;
onSelect: (item: PresentSessionMenuItem) => void;
}) {
const [open, setOpen] = useState(false);
const rootRef = useRef<HTMLDivElement>(null);
const menuId = useId();
useEffect(() => {
if (!open) return;
const onDoc = (e: MouseEvent) => {
if (!rootRef.current?.contains(e.target as Node)) setOpen(false);
};
const onKey = (e: KeyboardEvent) => {
if (e.key === "Escape") setOpen(false);
};
document.addEventListener("mousedown", onDoc);
document.addEventListener("keydown", onKey);
return () => {
document.removeEventListener("mousedown", onDoc);
document.removeEventListener("keydown", onKey);
};
}, [open]);
const groups = items
.slice()
.sort((a, b) => {
const g = groupOrder(a.type) - groupOrder(b.type);
if (g !== 0) return g;
return a.title.localeCompare(b.title, undefined, { sensitivity: "base" });
})
.reduce<{ label: string; items: PresentSessionMenuItem[] }[]>((acc, item) => {
const label = groupLabel(item.type);
const last = acc[acc.length - 1];
if (last && last.label === label) last.items.push(item);
else acc.push({ label, items: [item] });
return acc;
}, []);
return (
<div ref={rootRef} className="relative">
<button
type="button"
aria-label="Session activities"
aria-expanded={open}
aria-controls={menuId}
onClick={() => setOpen((v) => !v)}
className="flex h-11 w-11 items-center justify-center rounded-lg border border-[#e5e5ea] bg-white text-[#24282f] shadow-sm transition hover:bg-[#f2f2f7]"
>
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" aria-hidden="true">
<path
d="M4 7h16M4 12h16M4 17h16"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
/>
</svg>
</button>
{open ? (
<div
id={menuId}
role="menu"
className="absolute left-0 top-full z-50 mt-2 max-h-[70vh] w-[min(22rem,calc(100vw-2rem))] overflow-y-auto rounded-xl border border-[#e5e5ea] bg-white py-2 shadow-lg"
>
{groups.length === 0 ? (
<p className="px-4 py-3 text-sm text-[rgba(36,40,47,0.55)]">No activities</p>
) : (
groups.map((group) => (
<div key={group.label} className="py-1">
<p className="px-4 py-1.5 text-xs font-bold uppercase tracking-wide text-[rgba(36,40,47,0.45)]">
{group.label}
</p>
{group.items.map((item) => {
const isCurrent = item._id === currentId;
return (
<button
key={item._id}
type="button"
role="menuitem"
onClick={() => {
setOpen(false);
if (!isCurrent) onSelect(item);
}}
className={`flex w-full items-center gap-3 px-4 py-3 text-left text-base transition hover:bg-[#f2f2f7] ${
isCurrent ? "bg-[#eef5ff]" : ""
}`}
>
<span className="min-w-0 flex-1 truncate font-semibold text-[#24282f]">
{item.title || "Untitled"}
</span>
{item.isOnAir ? (
<span className="flex-shrink-0 rounded-full bg-emerald-100 px-2 py-0.5 text-[10px] font-bold uppercase tracking-wide text-emerald-800">
Live
</span>
) : null}
{isCurrent ? (
<span className="flex-shrink-0 text-xs font-semibold text-[#1e72ef]">Current</span>
) : null}
</button>
);
})}
</div>
))
)}
</div>
) : null}
</div>
);
}

View File

@ -0,0 +1,27 @@
import type { PresentSessionMenuItem } from "./PresentSessionMenu";
export const PRESENT_QUESTIONS_MENU_ID = "__session_questions__";
export function withQuestionsMenuItem(
items: PresentSessionMenuItem[],
sessionId: string | null | undefined,
): PresentSessionMenuItem[] {
if (!sessionId?.trim()) return items;
return [
...items,
{
_id: PRESENT_QUESTIONS_MENU_ID,
title: "Questions",
type: "questions",
presentationToken: "",
isOnAir: false,
},
];
}
export function questionsPresentPath(sessionId: string, fromToken?: string | null): string {
const base = `/questions-present/${encodeURIComponent(sessionId)}`;
const t = fromToken?.trim();
if (!t) return base;
return `${base}?token=${encodeURIComponent(t)}`;
}

11
src/hooks/useNow.ts Normal file
View File

@ -0,0 +1,11 @@
import { useEffect, useState } from "react";
/** Re-render on an interval so time-sensitive UI (e.g. live sessions) stays current. */
export function useNow(intervalMs = 30000): number {
const [now, setNow] = useState(() => Date.now());
useEffect(() => {
const id = window.setInterval(() => setNow(Date.now()), intervalMs);
return () => window.clearInterval(id);
}, [intervalMs]);
return now;
}

View File

@ -104,3 +104,26 @@ body {
opacity: 1; opacity: 1;
transform: translate(-50%, 0) scale(1); transform: translate(-50%, 0) scale(1);
} }
/* Live session indicator beside Join on event agenda */
.eb-live-dot {
width: 8px;
height: 8px;
flex-shrink: 0;
border-radius: 9999px;
background-color: #14ae5c;
box-shadow: 0 0 0 2px rgba(20, 174, 92, 0.25);
animation: eb-live-blink 1.2s ease-in-out infinite;
}
@keyframes eb-live-blink {
0%,
100% {
opacity: 1;
transform: scale(1);
}
50% {
opacity: 0.35;
transform: scale(0.85);
}
}

View File

@ -210,7 +210,10 @@ export type VisitorSessionPreviewSpeaker = {
lastName?: string; lastName?: string;
title?: string; title?: string;
photo?: string; photo?: string;
/** Faculty profile role (directory). Prefer sessionRole for this session. */
speakerRole?: string; speakerRole?: string;
/** Per-session key-person role assigned on the session. */
sessionRole?: string;
/** Shown when present (private profiles still get bio / expertise / photo / title). */ /** Shown when present (private profiles still get bio / expertise / photo / title). */
bio?: string; bio?: string;
expertise?: string[]; expertise?: string[];
@ -786,7 +789,9 @@ export type PublicQuizPollPresentation = {
type: string; type: string;
settings: { settings: {
displayMode?: string; displayMode?: string;
isLive?: boolean;
liveQuestionId?: string | null; liveQuestionId?: string | null;
showCorrectAnswer?: boolean;
showResults?: boolean; showResults?: boolean;
}; };
questions: PublicQuizPollPresentationQuestion[]; questions: PublicQuizPollPresentationQuestion[];
@ -825,6 +830,34 @@ export async function getPublicQuizPollPresentation(token: string): Promise<Publ
return (body as { data: PublicQuizPollPresentation }).data; return (body as { data: PublicQuizPollPresentation }).data;
} }
export type PublicPresentationSessionActivity = {
_id: string;
title: string;
type: string;
presentationToken: string;
presentationUrl: string;
isLive: boolean;
displayMode?: string;
liveQuestionId?: string | null;
};
export type PublicPresentationSessionActivities = {
sessionId: string | null;
activities: PublicPresentationSessionActivity[];
};
export async function getPublicPresentationSessionActivities(
token: string,
): Promise<PublicPresentationSessionActivities> {
const base = getBaseUrl();
const url = `${base}/api/public/quiz-polls/present/${encodeURIComponent(token)}/session-activities`;
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: PublicPresentationSessionActivities }).data;
}
export async function getPublicQuizPollQuestionStats( export async function getPublicQuizPollQuestionStats(
token: string, token: string,
questionIndex: number, questionIndex: number,
@ -837,3 +870,36 @@ export async function getPublicQuizPollQuestionStats(
if (!body || typeof body !== "object" || !("data" in body)) throw new Error("Invalid response"); if (!body || typeof body !== "object" || !("data" in body)) throw new Error("Invalid response");
return (body as { data: PublicQuizPollQuestionStats }).data; return (body as { data: PublicQuizPollQuestionStats }).data;
} }
async function postPublicPresentationControl(
token: string,
pathSuffix: string,
payload?: Record<string, unknown>,
): Promise<PublicQuizPollPresentation> {
const base = getBaseUrl();
const url = `${base}/api/public/quiz-polls/present/${encodeURIComponent(token)}${pathSuffix}`;
const res = await fetch(url, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: payload != null ? JSON.stringify(payload) : undefined,
});
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: PublicQuizPollPresentation }).data;
}
export async function publishPublicPresentation(token: string): Promise<PublicQuizPollPresentation> {
return postPublicPresentationControl(token, "/publish");
}
export async function closePublicPresentation(token: string): Promise<PublicQuizPollPresentation> {
return postPublicPresentationControl(token, "/close");
}
export async function setPublicLiveQuestion(
token: string,
questionId: string | null,
): Promise<PublicQuizPollPresentation> {
return postPublicPresentationControl(token, "/live-question", { questionId });
}

View File

@ -45,3 +45,16 @@ export function formatEventScheduleRange(startIso: string | undefined, endIso: s
} }
return `${s.toLocaleDateString(undefined, opts)} – ${e.toLocaleDateString(undefined, opts)}`; return `${s.toLocaleDateString(undefined, opts)} – ${e.toLocaleDateString(undefined, opts)}`;
} }
/** True when `now` falls within [start, end] (inclusive). */
export function isSessionLiveNow(
startIso: string | undefined,
endIso: string | undefined,
nowMs: number = Date.now(),
): boolean {
if (!startIso || !endIso) return false;
const start = new Date(startIso).getTime();
const end = new Date(endIso).getTime();
if (Number.isNaN(start) || Number.isNaN(end) || end <= start) return false;
return nowMs >= start && nowMs <= end;
}

View File

@ -23,17 +23,17 @@ function num(v: unknown): number {
export function rtdbSnapshotToQuestions(val: unknown): RtdbQuestionItem[] { export function rtdbSnapshotToQuestions(val: unknown): RtdbQuestionItem[] {
if (!val || typeof val !== "object" || Array.isArray(val)) return []; if (!val || typeof val !== "object" || Array.isArray(val)) return [];
const data = val as Record<string, Record<string, unknown>>; const data = val as Record<string, Record<string, unknown>>;
return Object.entries(data) const items: RtdbQuestionItem[] = [];
.map(([key, raw]) => { for (const [key, raw] of Object.entries(data)) {
if (!raw || typeof raw !== "object") return null; if (!raw || typeof raw !== "object") continue;
const v = raw as Record<string, unknown>; const v = raw as Record<string, unknown>;
const fromField = const fromField =
v._id != null && String(v._id).trim() !== "" ? String(v._id).trim() : ""; v._id != null && String(v._id).trim() !== "" ? String(v._id).trim() : "";
const id = fromField || key; const id = fromField || key;
const status = v.status != null ? String(v.status) : "pending"; const status = v.status != null ? String(v.status) : "pending";
const audienceListed = v.audienceListed !== false; const audienceListed = v.audienceListed !== false;
if (status === "archived" || !audienceListed) return null; if (status === "archived" || !audienceListed) continue;
return { items.push({
_id: id, _id: id,
author: String(v.authorName ?? "Anonymous"), author: String(v.authorName ?? "Anonymous"),
text: String(v.question ?? ""), text: String(v.question ?? ""),
@ -43,10 +43,9 @@ export function rtdbSnapshotToQuestions(val: unknown): RtdbQuestionItem[] {
status, status,
shortlisted: Boolean(v.shortlisted), shortlisted: Boolean(v.shortlisted),
audienceListed, audienceListed,
} satisfies RtdbQuestionItem; });
}) }
.filter((x): x is RtdbQuestionItem => x != null) return items.sort((a, b) => {
.sort((a, b) => {
if (b.up !== a.up) return b.up - a.up; if (b.up !== a.up) return b.up - a.up;
const ta = a.createdAt ? Date.parse(a.createdAt) : 0; const ta = a.createdAt ? Date.parse(a.createdAt) : 0;
const tb = b.createdAt ? Date.parse(b.createdAt) : 0; const tb = b.createdAt ? Date.parse(b.createdAt) : 0;
@ -76,3 +75,61 @@ export function subscribeSessionQuestions(
}, },
); );
} }
/**
* Presenter view: shortlisted questions only (includes audience-hidden; excludes archived).
*/
export function rtdbSnapshotToShortlistedQuestions(val: unknown): RtdbQuestionItem[] {
if (!val || typeof val !== "object" || Array.isArray(val)) return [];
const data = val as Record<string, Record<string, unknown>>;
const items: RtdbQuestionItem[] = [];
for (const [key, raw] of Object.entries(data)) {
if (!raw || typeof raw !== "object") continue;
const v = raw as Record<string, unknown>;
const fromField =
v._id != null && String(v._id).trim() !== "" ? String(v._id).trim() : "";
const id = fromField || key;
const status = v.status != null ? String(v.status) : "pending";
if (status === "archived" || !Boolean(v.shortlisted)) continue;
items.push({
_id: id,
author: String(v.authorName ?? "Anonymous"),
text: String(v.question ?? ""),
up: num(v.upvotes),
down: num(v.downvotes),
createdAt: v.createdAt != null ? String(v.createdAt) : undefined,
status,
shortlisted: true,
audienceListed: v.audienceListed !== false,
});
}
return items.sort((a, b) => {
if (b.up !== a.up) return b.up - a.up;
const ta = a.createdAt ? Date.parse(a.createdAt) : 0;
const tb = b.createdAt ? Date.parse(b.createdAt) : 0;
return tb - ta;
});
}
/** Subscribe to shortlisted session questions for presenter mode. */
export function subscribeSessionShortlistedQuestions(
sessionId: string,
onData: (items: RtdbQuestionItem[]) => void,
onError?: (e: Error) => void,
): Unsubscribe {
const db = getRtdb();
const r = ref(db, `sessions/${sessionId}/questions`);
return onValue(
r,
(snap) => {
try {
onData(rtdbSnapshotToShortlistedQuestions(snap.val()));
} catch (e) {
onError?.(e instanceof Error ? e : new Error(String(e)));
}
},
(err) => {
onError?.(err instanceof Error ? err : new Error(String(err)));
},
);
}

View File

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

View File

@ -0,0 +1,246 @@
import { useEffect, useMemo, useRef, useState } from "react";
import { useNavigate, useParams, useSearchParams } from "react-router-dom";
import {
getPublicPresentationSessionActivities,
type PublicPresentationSessionActivity,
} from "../lib/api";
import { isFirebaseClientConfigured } from "../lib/firebaseClient";
import {
filterVisibleQuizPolls,
settingsDisplayMode,
subscribeSessionQuizPolls,
type RtdbQuizPollListItem,
} from "../lib/sessionQuizPollsRtdb";
import {
subscribeSessionShortlistedQuestions,
type RtdbQuestionItem,
} from "../lib/sessionQuestionsRtdb";
import { PresentSessionMenu } from "../components/present/PresentSessionMenu";
import {
PRESENT_QUESTIONS_MENU_ID,
withQuestionsMenuItem,
} from "../components/present/presentSessionMenuHelpers";
function isManualOrQuizMasterOnAir(item: RtdbQuizPollListItem): boolean {
const mode = settingsDisplayMode(item.settings);
return mode === "manual" || mode === "quiz_master";
}
function onAirIdsFromSessionList(items: RtdbQuizPollListItem[]): string[] {
return filterVisibleQuizPolls(items)
.filter(isManualOrQuizMasterOnAir)
.map((x) => x._id);
}
export function QuestionsPresentPage() {
const { sessionId: sessionIdParam } = useParams<{ sessionId: string }>();
const [searchParams] = useSearchParams();
const navigate = useNavigate();
const fromToken = searchParams.get("token")?.trim() || "";
const sessionId = sessionIdParam?.trim() || "";
const [questions, setQuestions] = useState<RtdbQuestionItem[]>([]);
const [loading, setLoading] = useState(true);
const [err, setErr] = useState<string | null>(null);
const [sessionActivities, setSessionActivities] = useState<PublicPresentationSessionActivity[]>([]);
const [sessionRtdbItems, setSessionRtdbItems] = useState<RtdbQuizPollListItem[]>([]);
const prevOnAirIdsRef = useRef<Set<string> | null>(null);
const tokenByIdRef = useRef<Map<string, string>>(new Map());
const pendingSwitchIdRef = useRef<string | null>(null);
useEffect(() => {
if (!sessionId) {
setErr("Invalid session.");
setLoading(false);
return;
}
if (!isFirebaseClientConfigured()) {
setErr("Firebase is not configured.");
setLoading(false);
return;
}
setLoading(true);
setErr(null);
const unsub = subscribeSessionShortlistedQuestions(
sessionId,
(items) => {
setQuestions(items);
setLoading(false);
},
(e) => {
setErr(e.message);
setQuestions([]);
setLoading(false);
},
);
return () => unsub();
}, [sessionId]);
useEffect(() => {
if (!fromToken) {
setSessionActivities([]);
tokenByIdRef.current = new Map();
return;
}
let cancelled = false;
getPublicPresentationSessionActivities(fromToken)
.then((data) => {
if (cancelled) return;
const activities = data.activities ?? [];
setSessionActivities(activities);
const map = new Map<string, string>();
for (const a of activities) {
if (a._id && a.presentationToken) map.set(a._id, a.presentationToken);
}
tokenByIdRef.current = map;
const pending = pendingSwitchIdRef.current;
if (pending) {
const nextToken = map.get(pending);
if (nextToken) {
pendingSwitchIdRef.current = null;
navigate(`/quiz-present/${encodeURIComponent(nextToken)}`, { replace: true });
}
}
})
.catch(() => {
if (!cancelled) {
setSessionActivities([]);
tokenByIdRef.current = new Map();
}
});
return () => {
cancelled = true;
};
}, [fromToken, navigate]);
useEffect(() => {
if (!sessionId || !isFirebaseClientConfigured()) {
setSessionRtdbItems([]);
prevOnAirIdsRef.current = null;
return;
}
prevOnAirIdsRef.current = null;
const unsub = subscribeSessionQuizPolls(
sessionId,
(items) => {
setSessionRtdbItems(items);
const onAir = onAirIdsFromSessionList(items);
const prev = prevOnAirIdsRef.current;
if (prev == null) {
prevOnAirIdsRef.current = new Set(onAir);
return;
}
const newlyEntered = onAir.filter((id) => !prev.has(id));
prevOnAirIdsRef.current = new Set(onAir);
const switchTo = newlyEntered[0];
if (!switchTo) return;
const nextToken = tokenByIdRef.current.get(switchTo);
if (!nextToken) {
pendingSwitchIdRef.current = switchTo;
return;
}
pendingSwitchIdRef.current = null;
navigate(`/quiz-present/${encodeURIComponent(nextToken)}`, { replace: true });
},
() => {},
);
return () => unsub();
}, [sessionId, navigate]);
const onAirIdSet = useMemo(() => new Set(onAirIdsFromSessionList(sessionRtdbItems)), [sessionRtdbItems]);
const menuItems = useMemo(() => {
const base = sessionActivities.map((a) => ({
_id: a._id,
title: a.title,
type: a.type,
presentationToken: a.presentationToken,
isOnAir: onAirIdSet.has(a._id),
}));
return withQuestionsMenuItem(base, sessionId);
}, [sessionActivities, sessionId, onAirIdSet]);
if (!sessionId) {
return (
<div className="flex min-h-dvh items-center justify-center bg-white px-4 text-center">
<p className="text-red-600">Invalid session.</p>
</div>
);
}
if (loading) {
return (
<div className="flex min-h-dvh items-center justify-center bg-white text-[rgba(36,40,47,0.65)]">
Loading questions…
</div>
);
}
if (err) {
return (
<div className="flex min-h-dvh items-center justify-center bg-white px-4 text-center">
<p className="text-red-600">{err}</p>
</div>
);
}
return (
<div className="flex min-h-dvh flex-col bg-[#f2f2f7] text-[#24282f]">
<main className="mx-auto flex w-full max-w-4xl flex-1 flex-col px-4 py-8 sm:px-6">
<div className="mb-6 flex flex-wrap items-start gap-3">
<PresentSessionMenu
items={menuItems}
currentId={PRESENT_QUESTIONS_MENU_ID}
onSelect={(item) => {
if (item.type === "questions") return;
const nextToken = item.presentationToken?.trim();
if (nextToken) {
navigate(`/quiz-present/${encodeURIComponent(nextToken)}`, { replace: true });
}
}}
/>
<div className="min-w-0 flex-1">
<p className="text-sm font-medium uppercase tracking-wide text-[#1e72ef]">Questions</p>
<h1 className="mt-1 text-2xl font-bold text-[#24282f] sm:text-3xl">Shortlisted</h1>
<p className="mt-1 text-sm text-[rgba(36,40,47,0.55)]">
{questions.length} question{questions.length === 1 ? "" : "s"}
</p>
</div>
</div>
{questions.length === 0 ? (
<div className="rounded-xl border border-[#e5e5ea] bg-white px-6 py-16 text-center shadow-sm">
<p className="text-lg font-semibold text-[#24282f]">No shortlisted questions</p>
<p className="mt-2 text-sm text-[rgba(36,40,47,0.55)]">
Shortlist questions from Activities to show them here.
</p>
</div>
) : (
<ul className="flex flex-col gap-4">
{questions.map((q, index) => (
<li
key={q._id}
className="rounded-xl border border-[#e5e5ea] bg-white p-5 shadow-sm sm:p-7"
>
<div className="mb-3 flex flex-wrap items-center gap-2">
<span className="rounded-full bg-[#eef5ff] px-3 py-1 text-xs font-bold uppercase tracking-wide text-[#1e72ef]">
Q{index + 1}
</span>
{q.status === "answered" ? (
<span className="rounded-full bg-emerald-100 px-3 py-1 text-xs font-bold uppercase tracking-wide text-emerald-800">
Answered
</span>
) : null}
<span className="text-sm text-[rgba(36,40,47,0.45)]">{q.up}↑</span>
</div>
<p className="text-xl font-semibold leading-snug text-[#24282f] sm:text-2xl">{q.text}</p>
<p className="mt-3 text-sm font-medium text-[rgba(36,40,47,0.55)]">{q.author}</p>
</li>
))}
</ul>
)}
</main>
</div>
);
}

View File

@ -1,38 +1,89 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useParams } from "react-router-dom"; import { useNavigate, useParams } from "react-router-dom";
import { import {
closePublicPresentation,
getPublicPresentationSessionActivities,
getPublicQuizPollPresentation, getPublicQuizPollPresentation,
getPublicQuizPollQuestionStats, getPublicQuizPollQuestionStats,
publishPublicPresentation,
setPublicLiveQuestion,
type PublicPresentationSessionActivity,
type PublicQuizPollPresentation, type PublicQuizPollPresentation,
type PublicQuizPollQuestionStats, type PublicQuizPollQuestionStats,
} from "../lib/api"; } from "../lib/api";
import { isFirebaseClientConfigured } from "../lib/firebaseClient"; import { isFirebaseClientConfigured } from "../lib/firebaseClient";
import { subscribeSessionQuizPollDetail } from "../lib/sessionQuizPollsRtdb"; import {
filterVisibleQuizPolls,
settingsDisplayMode,
subscribeSessionQuizPollDetail,
subscribeSessionQuizPolls,
type RtdbQuizPollListItem,
} from "../lib/sessionQuizPollsRtdb";
import { PresentFooterNav } from "../components/present/PresentFooterNav"; import { PresentFooterNav } from "../components/present/PresentFooterNav";
import { PresentRespondersList } from "../components/present/PresentRespondersList"; import { PresentRespondersList } from "../components/present/PresentRespondersList";
import { PresentStatsBars } from "../components/present/PresentStatsBars"; import { PresentSessionMenu } from "../components/present/PresentSessionMenu";
import { import {
mergeDisplayOptions, questionsPresentPath,
} from "../components/present/presentStatsMerge"; withQuestionsMenuItem,
} from "../components/present/presentSessionMenuHelpers";
import { PresentStatsBars } from "../components/present/PresentStatsBars";
import { mergeDisplayOptions } from "../components/present/presentStatsMerge";
function isManualOrQuizMasterOnAir(item: RtdbQuizPollListItem): boolean {
const mode = settingsDisplayMode(item.settings);
return mode === "manual" || mode === "quiz_master";
}
function onAirIdsFromSessionList(items: RtdbQuizPollListItem[]): string[] {
return filterVisibleQuizPolls(items)
.filter(isManualOrQuizMasterOnAir)
.map((x) => x._id);
}
type TabId = "responses" | "participants"; type TabId = "responses" | "participants";
const STATS_POLL_MS = 2500; const STATS_POLL_MS = 2500;
function applyPresentationSettings(
data: PublicQuizPollPresentation,
setters: {
setLiveQuestionId: (v: string | null) => void;
setShowCorrectAnswer: (v: boolean) => void;
setIsLive: (v: boolean) => void;
},
) {
const live =
data.settings.liveQuestionId != null && String(data.settings.liveQuestionId).trim() !== ""
? String(data.settings.liveQuestionId).trim()
: null;
setters.setLiveQuestionId(live);
setters.setShowCorrectAnswer(data.settings.showCorrectAnswer === true);
setters.setIsLive(data.settings.isLive === true);
}
export function QuizPollPresentPage() { export function QuizPollPresentPage() {
const { token } = useParams<{ token: string }>(); const { token } = useParams<{ token: string }>();
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 [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [statsLoading, setStatsLoading] = useState(false); const [statsLoading, setStatsLoading] = useState(false);
const [err, setErr] = useState<string | null>(null); const [err, setErr] = useState<string | null>(null);
const [controlErr, setControlErr] = useState<string | null>(null);
const [controlBusy, setControlBusy] = useState(false);
const [tab, setTab] = useState<TabId>("responses"); const [tab, setTab] = useState<TabId>("responses");
const [questionIndex, setQuestionIndex] = useState(0); const [questionIndex, setQuestionIndex] = useState(0);
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 [isLive, setIsLive] = useState(false);
const [sessionActivities, setSessionActivities] = useState<PublicPresentationSessionActivity[]>([]);
const [sessionRtdbItems, setSessionRtdbItems] = useState<RtdbQuizPollListItem[]>([]);
const prevLiveIdRef = useRef<string | null>(null); const prevLiveIdRef = useRef<string | null>(null);
const prevOnAirIdsRef = useRef<Set<string> | null>(null);
const tokenByIdRef = useRef<Map<string, string>>(new Map());
const pendingSwitchIdRef = 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";
@ -45,6 +96,9 @@ export function QuizPollPresentPage() {
return presentation.questions.findIndex((q) => q._id === liveQuestionId); return presentation.questions.findIndex((q) => q._id === liveQuestionId);
}, [presentation, liveQuestionId]); }, [presentation, liveQuestionId]);
const currentQuestionIsLive =
isQuizMaster && Boolean(currentQuestion?._id) && currentQuestion?._id === liveQuestionId;
useEffect(() => { useEffect(() => {
const t = token?.trim(); const t = token?.trim();
if (!t) { if (!t) {
@ -57,11 +111,11 @@ export function QuizPollPresentPage() {
setErr(null); setErr(null);
getPublicQuizPollPresentation(t) getPublicQuizPollPresentation(t)
.then((data) => { .then((data) => {
if (!cancelled) { if (cancelled) return;
setPresentation(data); setPresentation(data);
setQuestionIndex(0); setQuestionIndex(0);
setBrowsing(false); setBrowsing(false);
} applyPresentationSettings(data, { setLiveQuestionId, setShowCorrectAnswer, setIsLive });
}) })
.catch((e) => { .catch((e) => {
if (!cancelled) { if (!cancelled) {
@ -77,6 +131,40 @@ export function QuizPollPresentPage() {
}; };
}, [token]); }, [token]);
useEffect(() => {
const t = token?.trim();
if (!t || !presentation) return;
let cancelled = false;
getPublicPresentationSessionActivities(t)
.then((data) => {
if (cancelled) return;
const activities = data.activities ?? [];
setSessionActivities(activities);
const map = new Map<string, string>();
for (const a of activities) {
if (a._id && a.presentationToken) map.set(a._id, a.presentationToken);
}
tokenByIdRef.current = map;
const pending = pendingSwitchIdRef.current;
if (pending && pending !== presentation._id) {
const nextToken = map.get(pending);
if (nextToken && nextToken !== t) {
pendingSwitchIdRef.current = null;
navigate(`/quiz-present/${encodeURIComponent(nextToken)}`, { replace: true });
}
}
})
.catch(() => {
if (!cancelled) {
setSessionActivities([]);
tokenByIdRef.current = new Map();
}
});
return () => {
cancelled = true;
};
}, [token, presentation?._id, presentation?.sessionId, navigate]);
useEffect(() => { useEffect(() => {
if (!presentation?.sessionId || !presentation._id || !isFirebaseClientConfigured()) return; if (!presentation?.sessionId || !presentation._id || !isFirebaseClientConfigured()) return;
const unsub = subscribeSessionQuizPollDetail( const unsub = subscribeSessionQuizPollDetail(
@ -89,12 +177,51 @@ export function QuizPollPresentPage() {
: null; : null;
setLiveQuestionId(live); setLiveQuestionId(live);
setShowCorrectAnswer(detail?.settings.showCorrectAnswer === true); setShowCorrectAnswer(detail?.settings.showCorrectAnswer === true);
if (detail) {
setIsLive(detail.isLive === true || detail.settings.isLive === true);
}
}, },
() => {}, () => {},
); );
return () => unsub(); return () => unsub();
}, [presentation?.sessionId, presentation?._id]); }, [presentation?.sessionId, presentation?._id]);
useEffect(() => {
const sessionId = presentation?.sessionId?.trim();
if (!sessionId || !presentation?._id || !isFirebaseClientConfigured()) {
setSessionRtdbItems([]);
prevOnAirIdsRef.current = null;
return;
}
prevOnAirIdsRef.current = null;
const currentId = presentation._id;
const unsub = subscribeSessionQuizPolls(
sessionId,
(items) => {
setSessionRtdbItems(items);
const onAir = onAirIdsFromSessionList(items);
const prev = prevOnAirIdsRef.current;
if (prev == null) {
prevOnAirIdsRef.current = new Set(onAir);
return;
}
const newlyEntered = onAir.filter((id) => !prev.has(id));
prevOnAirIdsRef.current = new Set(onAir);
const switchTo = newlyEntered.find((id) => id !== currentId);
if (!switchTo) return;
const nextToken = tokenByIdRef.current.get(switchTo);
if (!nextToken || nextToken === token?.trim()) {
pendingSwitchIdRef.current = switchTo;
return;
}
pendingSwitchIdRef.current = null;
navigate(`/quiz-present/${encodeURIComponent(nextToken)}`, { replace: true });
},
() => {},
);
return () => unsub();
}, [presentation?.sessionId, presentation?._id, token, navigate]);
useEffect(() => { useEffect(() => {
if (!isQuizMaster || liveQuestionIndex < 0) return; if (!isQuizMaster || liveQuestionIndex < 0) return;
const live = liveQuestionId ?? null; const live = liveQuestionId ?? null;
@ -139,6 +266,24 @@ export function QuizPollPresentPage() {
return () => window.clearInterval(id); return () => window.clearInterval(id);
}, [presentation, loadStats]); }, [presentation, loadStats]);
const applyControlResult = (data: PublicQuizPollPresentation) => {
setPresentation(data);
applyPresentationSettings(data, { setLiveQuestionId, setShowCorrectAnswer, setIsLive });
};
const runControl = async (action: () => Promise<PublicQuizPollPresentation>) => {
setControlBusy(true);
setControlErr(null);
try {
const data = await action();
applyControlResult(data);
} catch (e) {
setControlErr(e instanceof Error ? e.message : "Could not update live status.");
} finally {
setControlBusy(false);
}
};
const goPrev = () => { const goPrev = () => {
setBrowsing(true); setBrowsing(true);
setQuestionIndex((i) => Math.max(0, i - 1)); setQuestionIndex((i) => Math.max(0, i - 1));
@ -152,6 +297,47 @@ export function QuizPollPresentPage() {
const viewingOffLive = const viewingOffLive =
isQuizMaster && liveQuestionIndex >= 0 && questionSafe !== liveQuestionIndex; isQuizMaster && liveQuestionIndex >= 0 && questionSafe !== liveQuestionIndex;
const onAirIdSet = useMemo(() => new Set(onAirIdsFromSessionList(sessionRtdbItems)), [sessionRtdbItems]);
const menuItems = useMemo(() => {
if (!presentation) return [];
const base =
sessionActivities.length > 0
? sessionActivities
: [
{
_id: presentation._id,
title: presentation.title,
type: presentation.type,
presentationToken: token?.trim() || "",
presentationUrl: "",
isLive,
displayMode: presentation.settings.displayMode,
liveQuestionId,
} satisfies PublicPresentationSessionActivity,
];
const items = base
.filter((a) => a.presentationToken)
.map((a) => ({
_id: a._id,
title: a.title,
type: a.type,
presentationToken: a.presentationToken,
isOnAir:
onAirIdSet.has(a._id) ||
(a._id === presentation._id && isLive && (isQuizMaster ? Boolean(liveQuestionId) : true)),
}));
return withQuestionsMenuItem(items, presentation.sessionId);
}, [
presentation,
sessionActivities,
token,
isLive,
liveQuestionId,
isQuizMaster,
onAirIdSet,
]);
if (loading) { if (loading) {
return ( return (
<div className="flex min-h-dvh items-center justify-center bg-white text-[rgba(36,40,47,0.65)]"> <div className="flex min-h-dvh items-center justify-center bg-white text-[rgba(36,40,47,0.65)]">
@ -176,19 +362,99 @@ export function QuizPollPresentPage() {
); );
} }
const t = token?.trim() || "";
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 px-4 py-8 sm:px-6">
<div className="mb-6 rounded-xl border border-[#e5e5ea] bg-white p-5 shadow-sm"> <div className="mb-6 rounded-xl border border-[#e5e5ea] bg-white p-5 shadow-sm">
<div className="mb-3 flex flex-wrap items-start justify-between gap-3">
<div className="flex min-w-0 flex-wrap items-center gap-2">
<PresentSessionMenu
items={menuItems}
currentId={presentation._id}
onSelect={(item) => {
if (item.type === "questions") {
const sid = presentation.sessionId?.trim();
if (sid) navigate(questionsPresentPath(sid, t), { replace: true });
return;
}
const nextToken = item.presentationToken?.trim();
if (nextToken && nextToken !== t) {
navigate(`/quiz-present/${encodeURIComponent(nextToken)}`, { replace: true });
}
}}
/>
{isQuizMaster ? (
liveQuestionIndex >= 0 ? (
<span className="rounded-full bg-emerald-100 px-3 py-1 text-xs font-bold uppercase tracking-wide text-emerald-800">
Live · Question {liveQuestionIndex + 1}
</span>
) : (
<span className="rounded-full bg-[#f2f2f7] px-3 py-1 text-xs font-bold uppercase tracking-wide text-[rgba(36,40,47,0.55)]">
Offline · No question live
</span>
)
) : isLive ? (
<span className="rounded-full bg-emerald-100 px-3 py-1 text-xs font-bold uppercase tracking-wide text-emerald-800">
Live
</span>
) : (
<span className="rounded-full bg-[#f2f2f7] px-3 py-1 text-xs font-bold uppercase tracking-wide text-[rgba(36,40,47,0.55)]">
Offline
</span>
)}
{viewingOffLive ? ( {viewingOffLive ? (
<p className="mb-3 text-center text-xs font-medium uppercase tracking-wide text-amber-700"> <span className="rounded-full bg-amber-100 px-3 py-1 text-xs font-medium uppercase tracking-wide text-amber-800">
Viewing Q{questionSafe + 1} — live: Q{liveQuestionIndex + 1} Viewing Q{questionSafe + 1} — live: Q{liveQuestionIndex + 1}
</p> </span>
) : isQuizMaster && liveQuestionIndex >= 0 ? (
<p className="mb-3 text-center text-xs font-medium uppercase tracking-wide text-emerald-700">
Live question
</p>
) : null} ) : null}
</div>
<div className="flex flex-shrink-0 flex-col items-end gap-1">
{isQuizMaster ? (
currentQuestionIsLive ? (
<button
type="button"
disabled={controlBusy || !t}
onClick={() => void runControl(() => setPublicLiveQuestion(t, null))}
className="rounded-lg border border-[#e5e5ea] bg-white px-4 py-2 text-sm font-semibold text-[#24282f] shadow-sm transition hover:bg-[#f2f2f7] disabled:opacity-50"
>
{controlBusy ? "Updating…" : "Make offline"}
</button>
) : (
<button
type="button"
disabled={controlBusy || !t || !currentQuestion._id}
onClick={() => void runControl(() => setPublicLiveQuestion(t, currentQuestion._id))}
className="rounded-lg bg-[#16a34a] px-4 py-2 text-sm font-semibold text-white shadow-sm transition hover:bg-[#15803d] disabled:opacity-50"
>
{controlBusy ? "Updating…" : "Make this question live"}
</button>
)
) : isLive ? (
<button
type="button"
disabled={controlBusy || !t}
onClick={() => void runControl(() => closePublicPresentation(t))}
className="rounded-lg border border-[#e5e5ea] bg-white px-4 py-2 text-sm font-semibold text-[#24282f] shadow-sm transition hover:bg-[#f2f2f7] disabled:opacity-50"
>
{controlBusy ? "Updating…" : "Make offline"}
</button>
) : (
<button
type="button"
disabled={controlBusy || !t}
onClick={() => void runControl(() => publishPublicPresentation(t))}
className="rounded-lg bg-[#16a34a] px-4 py-2 text-sm font-semibold text-white shadow-sm transition hover:bg-[#15803d] disabled:opacity-50"
>
{controlBusy ? "Updating…" : "Go live"}
</button>
)}
{controlErr ? <p className="max-w-[220px] text-right text-sm text-red-600">{controlErr}</p> : null}
</div>
</div>
<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>
@ -223,7 +489,6 @@ export function QuizPollPresentPage() {
<div className="flex flex-1 flex-col"> <div className="flex flex-1 flex-col">
{tab === "responses" ? ( {tab === "responses" ? (
<>
<PresentStatsBars <PresentStatsBars
kind={statsKind} kind={statsKind}
options={displayOptions} options={displayOptions}
@ -232,7 +497,6 @@ export function QuizPollPresentPage() {
totalResponses={stats?.totalResponses ?? 0} totalResponses={stats?.totalResponses ?? 0}
revealCorrect={revealCorrect} revealCorrect={revealCorrect}
/> />
</>
) : ( ) : (
<PresentRespondersList <PresentRespondersList
responders={stats?.responders ?? []} responders={stats?.responders ?? []}

View File

@ -621,7 +621,7 @@ export function SingleEventPage() {
> >
<span className="flex min-w-0 items-center gap-2.5"> <span className="flex min-w-0 items-center gap-2.5">
<Star className="size-[18px] shrink-0 text-[#1e72ef]" strokeWidth={1.75} /> <Star className="size-[18px] shrink-0 text-[#1e72ef]" strokeWidth={1.75} />
<span className="truncate">Rate this event</span> <span className="truncate">Rate this session</span>
</span> </span>
<ChevronRight className="size-4 shrink-0 text-[#c7c7cc]" strokeWidth={2} aria-hidden /> <ChevronRight className="size-4 shrink-0 text-[#c7c7cc]" strokeWidth={2} aria-hidden />
</Link> </Link>
@ -652,7 +652,11 @@ export function SingleEventPage() {
{activeTabId === "key-persons" ? ( {activeTabId === "key-persons" ? (
<ul className="flex flex-col gap-3"> <ul className="flex flex-col gap-3">
{(session.speakers || []).map((s) => ( {(session.speakers || []).map((s) => (
<KeyPersonRow key={s._id} person={s} tag={s.speakerRole?.trim() || "Speaker"} /> <KeyPersonRow
key={s._id}
person={s}
tag={s.sessionRole?.trim() || s.speakerRole?.trim() || "Speaker"}
/>
))} ))}
{session.moderator ? ( {session.moderator ? (
<KeyPersonRow <KeyPersonRow