diff --git a/nginx.example.conf b/nginx.example.conf
new file mode 100644
index 0000000..3cfa76b
--- /dev/null
+++ b/nginx.example.conf
@@ -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)
diff --git a/src/App.tsx b/src/App.tsx
index 3608360..cbf32d3 100644
--- a/src/App.tsx
+++ b/src/App.tsx
@@ -17,6 +17,7 @@ import { RewardsPage } from "./pages/RewardsPage";
import { SingleEventPage } from "./pages/SingleEventPage";
import { QuizPollTakePage } from "./pages/QuizPollTakePage";
import { QuizPollPresentPage } from "./pages/QuizPollPresentPage";
+import { QuestionsPresentPage } from "./pages/QuestionsPresentPage";
import { CodeEntryPage } from "./pages/CodeEntryPage";
import { PrivacyPolicyPage } from "./pages/PrivacyPolicyPage";
import { TermsOfServicePage } from "./pages/TermsOfServicePage";
@@ -39,6 +40,7 @@ export default function App() {
} />
} />
} />
+ } />
} />
} />
} />
diff --git a/src/components/AgendaRowTrackV2.tsx b/src/components/AgendaRowTrackV2.tsx
index d2edf95..06e2bf9 100644
--- a/src/components/AgendaRowTrackV2.tsx
+++ b/src/components/AgendaRowTrackV2.tsx
@@ -22,6 +22,8 @@ export type AgendaRowTrackV2Data = {
showJoin?: boolean;
/** When set with `showJoin`, Join navigates here instead of a no-op button. */
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. */
bookmark?: AgendaRowTrackV2Bookmark;
/** Shown before clock time (e.g. full date on “My Sessions”). */
@@ -32,6 +34,9 @@ export type AgendaRowTrackV2Data = {
export function AgendaRowTrackV2({ row }: { row: AgendaRowTrackV2Data }) {
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 (
@@ -132,24 +137,24 @@ export function AgendaRowTrackV2({ row }: { row: AgendaRowTrackV2Data }) {
) : null}
- {row.showJoin &&
- (row.joinHref ? (
-
- Join
-
-
- ) : (
-
- ))}
+ {row.showJoin ? (
+
+ {row.joinHref ? (
+
+ Join
+
+
+ ) : (
+
+ )}
+ {row.joinLive ? (
+
+ ) : null}
+
+ ) : null}
);
diff --git a/src/components/EventLandingAgenda.tsx b/src/components/EventLandingAgenda.tsx
index efda27a..8443747 100644
--- a/src/components/EventLandingAgenda.tsx
+++ b/src/components/EventLandingAgenda.tsx
@@ -5,8 +5,10 @@ import {
formatDateLong,
formatDurationMinutes,
formatTimeShort,
+ isSessionLiveNow,
sessionTypeDisplay,
} from "../lib/sessionDisplay";
+import { useNow } from "../hooks/useNow";
import type { AgendaRowTrackV2Bookmark, AgendaRowTrackV2Data } from "./AgendaRowTrackV2";
import { AgendaRowTrackV2 } from "./AgendaRowTrackV2";
@@ -103,10 +105,12 @@ function sessionToAgendaRowV2(
s: VisitorSessionPreview,
eventId: string,
bookmark: AgendaRowTrackV2Bookmark,
+ nowMs: number,
): AgendaRowTrackV2Data {
const names = (s.speakers || []).map(landingSpeakerName);
if (s.moderator) names.push(`${landingSpeakerName(s.moderator)} (Moderator)`);
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 {
time: formatTimeShort(s.startTime),
duration: formatDurationMinutes(s.startTime, s.endTime) || "—",
@@ -115,9 +119,10 @@ function sessionToAgendaRowV2(
description:
s.description?.trim() || "Agenda details will appear here when available.",
speakerNames: names.length ? names : ["—"],
- status: s.status === "ongoing" ? { kind: "live" } : null,
+ status: liveNow ? { kind: "live" } : null,
showJoin: true,
joinHref,
+ joinLive: liveNow,
bookmark,
};
}
@@ -126,8 +131,9 @@ function sessionToMySessionsRowV2(
s: VisitorSessionPreview,
eventId: string,
bookmark: AgendaRowTrackV2Bookmark,
+ nowMs: number,
): AgendaRowTrackV2Data {
- const base = sessionToAgendaRowV2(s, eventId, bookmark);
+ const base = sessionToAgendaRowV2(s, eventId, bookmark, nowMs);
const hall = s.track?.hall?.trim();
const dateLbl = formatDateLong(s.startTime);
return {
@@ -154,6 +160,8 @@ export function LandingAgendaCards({
bookmarkedIds,
toggleBookmark,
}: LandingAgendaCardsProps) {
+ const nowMs = useNow();
+
return (
{days.map((day) => {
@@ -216,7 +224,7 @@ export function LandingAgendaCards({
row={sessionToAgendaRowV2(s, eventId, {
isBookmarked: bookmarkedIds.has(s._id),
onToggle: () => void toggleBookmark(s._id),
- })}
+ }, nowMs)}
/>
))}
@@ -244,6 +252,8 @@ export function LandingMySessionsList({
bookmarkedIds: Set;
toggleBookmark: (sessionId: string) => void | Promise;
}) {
+ const nowMs = useNow();
+
return (
@@ -255,7 +265,7 @@ export function LandingMySessionsList({
row={sessionToMySessionsRowV2(s, eventId, {
isBookmarked: bookmarkedIds.has(s._id),
onToggle: () => void toggleBookmark(s._id),
- })}
+ }, nowMs)}
/>
))}
diff --git a/src/components/present/PresentSessionMenu.tsx b/src/components/present/PresentSessionMenu.tsx
new file mode 100644
index 0000000..ceae229
--- /dev/null
+++ b/src/components/present/PresentSessionMenu.tsx
@@ -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
(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 (
+
+
+
+ {open ? (
+
+ ) : null}
+
+ );
+}
diff --git a/src/components/present/presentSessionMenuHelpers.ts b/src/components/present/presentSessionMenuHelpers.ts
new file mode 100644
index 0000000..f157d61
--- /dev/null
+++ b/src/components/present/presentSessionMenuHelpers.ts
@@ -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)}`;
+}
diff --git a/src/hooks/useNow.ts b/src/hooks/useNow.ts
new file mode 100644
index 0000000..5af88b0
--- /dev/null
+++ b/src/hooks/useNow.ts
@@ -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;
+}
diff --git a/src/index.css b/src/index.css
index d797a12..47b3bee 100644
--- a/src/index.css
+++ b/src/index.css
@@ -104,3 +104,26 @@ body {
opacity: 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);
+ }
+}
diff --git a/src/lib/api.ts b/src/lib/api.ts
index bcd5041..440083b 100644
--- a/src/lib/api.ts
+++ b/src/lib/api.ts
@@ -210,7 +210,10 @@ export type VisitorSessionPreviewSpeaker = {
lastName?: string;
title?: string;
photo?: string;
+ /** Faculty profile role (directory). Prefer sessionRole for this session. */
speakerRole?: string;
+ /** Per-session key-person role assigned on the session. */
+ sessionRole?: string;
/** Shown when present (private profiles still get bio / expertise / photo / title). */
bio?: string;
expertise?: string[];
@@ -786,7 +789,9 @@ export type PublicQuizPollPresentation = {
type: string;
settings: {
displayMode?: string;
+ isLive?: boolean;
liveQuestionId?: string | null;
+ showCorrectAnswer?: boolean;
showResults?: boolean;
};
questions: PublicQuizPollPresentationQuestion[];
@@ -825,6 +830,34 @@ export async function getPublicQuizPollPresentation(token: string): Promise {
+ 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(
token: string,
questionIndex: number,
@@ -837,3 +870,36 @@ export async function getPublicQuizPollQuestionStats(
if (!body || typeof body !== "object" || !("data" in body)) throw new Error("Invalid response");
return (body as { data: PublicQuizPollQuestionStats }).data;
}
+
+async function postPublicPresentationControl(
+ token: string,
+ pathSuffix: string,
+ payload?: Record,
+): Promise {
+ 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 {
+ return postPublicPresentationControl(token, "/publish");
+}
+
+export async function closePublicPresentation(token: string): Promise {
+ return postPublicPresentationControl(token, "/close");
+}
+
+export async function setPublicLiveQuestion(
+ token: string,
+ questionId: string | null,
+): Promise {
+ return postPublicPresentationControl(token, "/live-question", { questionId });
+}
diff --git a/src/lib/sessionDisplay.ts b/src/lib/sessionDisplay.ts
index f51ef92..81942ac 100644
--- a/src/lib/sessionDisplay.ts
+++ b/src/lib/sessionDisplay.ts
@@ -45,3 +45,16 @@ export function formatEventScheduleRange(startIso: string | undefined, endIso: s
}
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;
+}
diff --git a/src/lib/sessionQuestionsRtdb.ts b/src/lib/sessionQuestionsRtdb.ts
index c72b79e..36f0868 100644
--- a/src/lib/sessionQuestionsRtdb.ts
+++ b/src/lib/sessionQuestionsRtdb.ts
@@ -23,35 +23,34 @@ function num(v: unknown): number {
export function rtdbSnapshotToQuestions(val: unknown): RtdbQuestionItem[] {
if (!val || typeof val !== "object" || Array.isArray(val)) return [];
const data = val as Record>;
- return Object.entries(data)
- .map(([key, raw]) => {
- if (!raw || typeof raw !== "object") return null;
- const v = raw as Record;
- 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";
- const audienceListed = v.audienceListed !== false;
- if (status === "archived" || !audienceListed) return null;
- return {
- _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: Boolean(v.shortlisted),
- audienceListed,
- } satisfies RtdbQuestionItem;
- })
- .filter((x): x is RtdbQuestionItem => x != null)
- .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;
+ const items: RtdbQuestionItem[] = [];
+ for (const [key, raw] of Object.entries(data)) {
+ if (!raw || typeof raw !== "object") continue;
+ const v = raw as Record;
+ 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";
+ const audienceListed = v.audienceListed !== false;
+ if (status === "archived" || !audienceListed) 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: Boolean(v.shortlisted),
+ audienceListed,
});
+ }
+ 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 `sessions/{sessionId}/questions`. */
@@ -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>;
+ const items: RtdbQuestionItem[] = [];
+ for (const [key, raw] of Object.entries(data)) {
+ if (!raw || typeof raw !== "object") continue;
+ const v = raw as Record;
+ 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)));
+ },
+ );
+}
diff --git a/src/pages/CodeEntryPage.tsx b/src/pages/CodeEntryPage.tsx
index 5faed0f..9919a9a 100644
--- a/src/pages/CodeEntryPage.tsx
+++ b/src/pages/CodeEntryPage.tsx
@@ -4,7 +4,7 @@ import { AuthShell } from "../components/auth/AuthShell";
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() {
const { code: codeParam } = useParams<{ code: string }>();
diff --git a/src/pages/QuestionsPresentPage.tsx b/src/pages/QuestionsPresentPage.tsx
new file mode 100644
index 0000000..f30c578
--- /dev/null
+++ b/src/pages/QuestionsPresentPage.tsx
@@ -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([]);
+ const [loading, setLoading] = useState(true);
+ const [err, setErr] = useState(null);
+ const [sessionActivities, setSessionActivities] = useState([]);
+ const [sessionRtdbItems, setSessionRtdbItems] = useState([]);
+
+ const prevOnAirIdsRef = useRef | null>(null);
+ const tokenByIdRef = useRef