import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useNavigate, useParams } from "react-router-dom"; import { closePublicPresentation, getPublicPresentationSessionActivities, getPublicQuizPollLeaderboard, getPublicQuizPollPresentation, getPublicQuizPollQuestionStats, publishPublicPresentation, setPublicLiveQuestion, type PublicPresentationSessionActivity, type PublicQuizPollLeaderboardEntry, type PublicQuizPollPresentation, type PublicQuizPollQuestionStats, } from "../lib/api"; import { isFirebaseClientConfigured } from "../lib/firebaseClient"; import { filterVisibleQuizPolls, settingsDisplayMode, subscribeSessionQuizPollDetail, subscribeSessionQuizPolls, type RtdbQuizPollListItem, } from "../lib/sessionQuizPollsRtdb"; import { subscribeSessionPresentation } from "../lib/sessionPresentationRtdb"; import { PresentFooterNav } from "../components/present/PresentFooterNav"; import { PresentLeaderboard } from "../components/present/PresentLeaderboard"; import { PresentRespondersList } from "../components/present/PresentRespondersList"; import { PresentSessionMenu } from "../components/present/PresentSessionMenu"; import { 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" | "leaderboard"; const STATS_POLL_MS = 2500; function applyPresentationSettings( data: PublicQuizPollPresentation, setters: { setLiveQuestionId: (v: string | null) => void; setShowCorrectAnswer: (v: boolean) => void; setShowLeaderboard: (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.setShowLeaderboard(data.settings.showLeaderboard === 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 [leaderboard, setLeaderboard] = useState([]); const [loading, setLoading] = useState(true); const [statsLoading, setStatsLoading] = useState(false); const [leaderboardLoading, setLeaderboardLoading] = 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 [showLeaderboard, setShowLeaderboard] = 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 lastQuestionsFocusAtRef = useRef(null); const isQuizMaster = presentation?.settings.displayMode === "quiz_master"; const isQuiz = presentation?.type.toLowerCase() === "quiz"; const questionCount = presentation?.questions.length ?? 0; const questionSafe = Math.min(questionIndex, Math.max(0, questionCount - 1)); const currentQuestion = presentation?.questions[questionSafe]; const liveQuestionIndex = useMemo(() => { if (!presentation || !liveQuestionId) return -1; 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) { setErr("Invalid presentation link."); setLoading(false); return; } let cancelled = false; setLoading(true); setErr(null); getPublicQuizPollPresentation(t) .then((data) => { if (cancelled) return; setPresentation(data); setQuestionIndex(0); setBrowsing(false); applyPresentationSettings(data, { setLiveQuestionId, setShowCorrectAnswer, setShowLeaderboard, setIsLive, }); }) .catch((e) => { if (!cancelled) { setErr(e instanceof Error ? e.message : "Could not load presentation."); setPresentation(null); } }) .finally(() => { if (!cancelled) setLoading(false); }); return () => { cancelled = true; }; }, [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( presentation.sessionId, presentation._id, (detail) => { const live = detail?.settings.liveQuestionId != null && String(detail.settings.liveQuestionId).trim() !== "" ? String(detail.settings.liveQuestionId).trim() : null; setLiveQuestionId(live); setShowCorrectAnswer(detail?.settings.showCorrectAnswer === true); setShowLeaderboard(detail?.settings.showLeaderboard === 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(() => { const sessionId = presentation?.sessionId?.trim(); const t = token?.trim(); if (!sessionId || !t || !isFirebaseClientConfigured()) return; let primed = false; lastQuestionsFocusAtRef.current = null; const unsub = subscribeSessionPresentation(sessionId, (state) => { // Ignore the first snapshot so opening a quiz/poll present link is not bounced away. if (!primed) { primed = true; lastQuestionsFocusAtRef.current = state.updatedAt; return; } if (state.focus !== "questions" || !state.updatedAt) return; if (lastQuestionsFocusAtRef.current === state.updatedAt) return; lastQuestionsFocusAtRef.current = state.updatedAt; navigate(questionsPresentPath(sessionId, t), { replace: true }); }); return () => unsub(); }, [presentation?.sessionId, token, navigate]); useEffect(() => { if (!isQuizMaster || liveQuestionIndex < 0) return; const live = liveQuestionId ?? null; if (live !== prevLiveIdRef.current) { prevLiveIdRef.current = live; setBrowsing(false); setQuestionIndex(liveQuestionIndex); } else if (!browsing) { setQuestionIndex(liveQuestionIndex); } }, [isQuizMaster, liveQuestionId, liveQuestionIndex, browsing]); const displayOptions = useMemo(() => { if (!currentQuestion) return []; return mergeDisplayOptions(currentQuestion, stats); }, [currentQuestion, stats]); const statsKind = stats?.kind ?? currentQuestion?.type ?? "single"; const revealCorrect = isQuizMaster && liveQuestionIndex >= 0 && questionSafe === liveQuestionIndex && showCorrectAnswer; const loadStats = useCallback(async () => { const t = token?.trim(); if (!t || !presentation) return; setStatsLoading(true); try { const data = await getPublicQuizPollQuestionStats(t, questionSafe); setStats(data); setErr(null); } catch (e) { setStats(null); setErr(e instanceof Error ? e.message : "Could not load stats."); } finally { setStatsLoading(false); } }, [token, presentation, questionSafe]); const loadLeaderboard = useCallback(async () => { const t = token?.trim(); if (!t || !presentation || String(presentation.type).toLowerCase() !== "quiz") return; setLeaderboardLoading(true); try { const data = await getPublicQuizPollLeaderboard(t); setLeaderboard(data.entries ?? []); } catch { /* keep last good leaderboard while presenting */ } finally { setLeaderboardLoading(false); } }, [token, presentation]); useEffect(() => { if (!presentation) return; void loadStats(); const id = window.setInterval(() => void loadStats(), STATS_POLL_MS); return () => window.clearInterval(id); }, [presentation, loadStats]); useEffect(() => { if (!presentation || String(presentation.type).toLowerCase() !== "quiz") { setLeaderboard([]); return; } void loadLeaderboard(); const id = window.setInterval(() => void loadLeaderboard(), STATS_POLL_MS); return () => window.clearInterval(id); }, [presentation, loadLeaderboard]); useEffect(() => { if (!isQuiz && tab === "leaderboard") setTab("responses"); }, [isQuiz, tab]); useEffect(() => { if (!isQuiz) return; if (showLeaderboard) { setTab("leaderboard"); return; } setTab((prev) => (prev === "leaderboard" ? "responses" : prev)); }, [isQuiz, showLeaderboard]); const applyControlResult = (data: PublicQuizPollPresentation) => { setPresentation(data); applyPresentationSettings(data, { setLiveQuestionId, setShowCorrectAnswer, setShowLeaderboard, 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)); }; const goNext = () => { setBrowsing(true); setQuestionIndex((i) => Math.min(questionCount - 1, i + 1)); }; 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 (
Loading presentation…
); } if (err && !presentation) { return (

{err}

); } if (!presentation || !currentQuestion) { return (
Presentation not found.
); } const t = token?.trim() || ""; return (
{ 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}

{currentQuestion.question}

{statsLoading && !stats ? (

Updating…

) : null}
{isQuiz ? ( ) : null}
{tab === "responses" ? ( ) : tab === "participants" ? ( ) : ( )}
= questionCount - 1} />
); }