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>(new Map()); + const pendingSwitchIdRef = useRef(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(); + 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 ( +
+

Invalid session.

+
+ ); + } + + if (loading) { + return ( +
+ Loading questions… +
+ ); + } + + if (err) { + return ( +
+

{err}

+
+ ); + } + + return ( +
+
+
+ { + if (item.type === "questions") return; + const nextToken = item.presentationToken?.trim(); + if (nextToken) { + navigate(`/quiz-present/${encodeURIComponent(nextToken)}`, { replace: true }); + } + }} + /> +
+

Questions

+

Shortlisted

+

+ {questions.length} question{questions.length === 1 ? "" : "s"} +

+
+
+ + {questions.length === 0 ? ( +
+

No shortlisted questions

+

+ Shortlist questions from Activities to show them here. +

+
+ ) : ( +
    + {questions.map((q, index) => ( +
  • +
    + + Q{index + 1} + + {q.status === "answered" ? ( + + Answered + + ) : null} + {q.up}↑ +
    +

    {q.text}

    +

    {q.author}

    +
  • + ))} +
+ )} +
+
+ ); +} diff --git a/src/pages/QuizPollPresentPage.tsx b/src/pages/QuizPollPresentPage.tsx index b6d4ac7..f8426f0 100644 --- a/src/pages/QuizPollPresentPage.tsx +++ b/src/pages/QuizPollPresentPage.tsx @@ -1,38 +1,89 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react"; -import { useParams } from "react-router-dom"; +import { useNavigate, useParams } from "react-router-dom"; import { + closePublicPresentation, + getPublicPresentationSessionActivities, getPublicQuizPollPresentation, getPublicQuizPollQuestionStats, + publishPublicPresentation, + setPublicLiveQuestion, + type PublicPresentationSessionActivity, type PublicQuizPollPresentation, type PublicQuizPollQuestionStats, } from "../lib/api"; 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 { PresentRespondersList } from "../components/present/PresentRespondersList"; -import { PresentStatsBars } from "../components/present/PresentStatsBars"; +import { PresentSessionMenu } from "../components/present/PresentSessionMenu"; import { - mergeDisplayOptions, -} from "../components/present/presentStatsMerge"; + questionsPresentPath, + 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"; 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() { const { token } = useParams<{ token: string }>(); + const navigate = useNavigate(); const [presentation, setPresentation] = useState(null); const [stats, setStats] = useState(null); const [loading, setLoading] = useState(true); const [statsLoading, setStatsLoading] = useState(false); const [err, setErr] = useState(null); + const [controlErr, setControlErr] = useState(null); + const [controlBusy, setControlBusy] = useState(false); const [tab, setTab] = useState("responses"); const [questionIndex, setQuestionIndex] = useState(0); const [browsing, setBrowsing] = useState(false); const [liveQuestionId, setLiveQuestionId] = useState(null); const [showCorrectAnswer, setShowCorrectAnswer] = useState(false); + const [isLive, setIsLive] = useState(false); + const [sessionActivities, setSessionActivities] = useState([]); + const [sessionRtdbItems, setSessionRtdbItems] = useState([]); const prevLiveIdRef = useRef(null); + const prevOnAirIdsRef = useRef | null>(null); + const tokenByIdRef = useRef>(new Map()); + const pendingSwitchIdRef = useRef(null); const isQuizMaster = presentation?.settings.displayMode === "quiz_master"; const isQuiz = presentation?.type.toLowerCase() === "quiz"; @@ -45,6 +96,9 @@ export function QuizPollPresentPage() { return presentation.questions.findIndex((q) => q._id === liveQuestionId); }, [presentation, liveQuestionId]); + const currentQuestionIsLive = + isQuizMaster && Boolean(currentQuestion?._id) && currentQuestion?._id === liveQuestionId; + useEffect(() => { const t = token?.trim(); if (!t) { @@ -57,11 +111,11 @@ export function QuizPollPresentPage() { setErr(null); getPublicQuizPollPresentation(t) .then((data) => { - if (!cancelled) { - setPresentation(data); - setQuestionIndex(0); - setBrowsing(false); - } + if (cancelled) return; + setPresentation(data); + setQuestionIndex(0); + setBrowsing(false); + applyPresentationSettings(data, { setLiveQuestionId, setShowCorrectAnswer, setIsLive }); }) .catch((e) => { if (!cancelled) { @@ -77,6 +131,40 @@ export function QuizPollPresentPage() { }; }, [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(); + 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(() => { if (!presentation?.sessionId || !presentation._id || !isFirebaseClientConfigured()) return; const unsub = subscribeSessionQuizPollDetail( @@ -89,12 +177,51 @@ export function QuizPollPresentPage() { : null; setLiveQuestionId(live); setShowCorrectAnswer(detail?.settings.showCorrectAnswer === true); + if (detail) { + setIsLive(detail.isLive === true || detail.settings.isLive === true); + } }, () => {}, ); return () => unsub(); }, [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(() => { if (!isQuizMaster || liveQuestionIndex < 0) return; const live = liveQuestionId ?? null; @@ -139,6 +266,24 @@ export function QuizPollPresentPage() { return () => window.clearInterval(id); }, [presentation, loadStats]); + const applyControlResult = (data: PublicQuizPollPresentation) => { + setPresentation(data); + applyPresentationSettings(data, { setLiveQuestionId, setShowCorrectAnswer, setIsLive }); + }; + + const runControl = async (action: () => Promise) => { + 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 = () => { setBrowsing(true); setQuestionIndex((i) => Math.max(0, i - 1)); @@ -152,6 +297,47 @@ export function QuizPollPresentPage() { const viewingOffLive = 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) { return (
@@ -176,19 +362,99 @@ export function QuizPollPresentPage() { ); } + const t = token?.trim() || ""; + return (
- {viewingOffLive ? ( -

- Viewing Q{questionSafe + 1} — live: Q{liveQuestionIndex + 1} -

- ) : isQuizMaster && liveQuestionIndex >= 0 ? ( -

- Live question -

- ) : null} +
+
+ { + 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 ? ( + + Live · Question {liveQuestionIndex + 1} + + ) : ( + + Offline · No question live + + ) + ) : isLive ? ( + + Live + + ) : ( + + Offline + + )} + {viewingOffLive ? ( + + Viewing Q{questionSafe + 1} — live: Q{liveQuestionIndex + 1} + + ) : null} +
+ +
+ {isQuizMaster ? ( + currentQuestionIsLive ? ( + + ) : ( + + ) + ) : isLive ? ( + + ) : ( + + )} + {controlErr ?

{controlErr}

: null} +
+
+

Question {questionSafe + 1} of {questionCount}

@@ -223,16 +489,14 @@ export function QuizPollPresentPage() {
{tab === "responses" ? ( - <> - - + ) : ( - Rate this event + Rate this session @@ -652,7 +652,11 @@ export function SingleEventPage() { {activeTabId === "key-persons" ? (
    {(session.speakers || []).map((s) => ( - + ))} {session.moderator ? (