EventBuzz_Website/src/pages/QuizPollPresentPage.tsx

611 lines
23 KiB
TypeScript

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<PublicQuizPollPresentation | null>(null);
const [stats, setStats] = useState<PublicQuizPollQuestionStats | null>(null);
const [leaderboard, setLeaderboard] = useState<PublicQuizPollLeaderboardEntry[]>([]);
const [loading, setLoading] = useState(true);
const [statsLoading, setStatsLoading] = useState(false);
const [leaderboardLoading, setLeaderboardLoading] = useState(false);
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 [questionIndex, setQuestionIndex] = useState(0);
const [browsing, setBrowsing] = useState(false);
const [liveQuestionId, setLiveQuestionId] = useState<string | null>(null);
const [showCorrectAnswer, setShowCorrectAnswer] = useState(false);
const [showLeaderboard, setShowLeaderboard] = useState(false);
const [isLive, setIsLive] = useState(false);
const [sessionActivities, setSessionActivities] = useState<PublicPresentationSessionActivity[]>([]);
const [sessionRtdbItems, setSessionRtdbItems] = useState<RtdbQuizPollListItem[]>([]);
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 lastQuestionsFocusAtRef = useRef<string | null>(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<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(() => {
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<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 = () => {
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 (
<div className="flex min-h-dvh items-center justify-center bg-white text-[rgba(36,40,47,0.65)]">
Loading presentation…
</div>
);
}
if (err && !presentation) {
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>
);
}
if (!presentation || !currentQuestion) {
return (
<div className="flex min-h-dvh items-center justify-center bg-white text-[rgba(36,40,47,0.65)]">
Presentation not found.
</div>
);
}
const t = token?.trim() || "";
return (
<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 gap-3 px-4 py-3 sm:px-6">
<div className="rounded-xl border border-[#e5e5ea] bg-white px-5 py-3 shadow-sm">
<div className="mb-2 flex flex-wrap items-start justify-between gap-3">
<div className="flex min-w-0 flex-wrap items-center gap-2">
<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 ? (
<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}
</span>
) : 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]">
Question {questionSafe + 1} of {questionCount}
</p>
<h1 className="mt-1 text-base font-semibold leading-snug text-[#24282f] sm:text-lg">{currentQuestion.question}</h1>
{statsLoading && !stats ? (
<p className="mt-2 text-xs text-[rgba(36,40,47,0.45)]">Updating…</p>
) : null}
</div>
<div className="flex gap-2 rounded-lg border border-[#e5e5ea] bg-white p-1 shadow-sm">
<button
type="button"
onClick={() => setTab("responses")}
className={`flex-1 rounded-md px-3 py-2 text-sm font-semibold transition ${
tab === "responses" ? "bg-[#1e72ef] text-white" : "text-[rgba(36,40,47,0.65)] hover:text-[#24282f]"
}`}
>
Responses{stats ? ` (${stats.totalResponses})` : ""}
</button>
<button
type="button"
onClick={() => setTab("participants")}
className={`flex-1 rounded-md px-3 py-2 text-sm font-semibold transition ${
tab === "participants" ? "bg-[#1e72ef] text-white" : "text-[rgba(36,40,47,0.65)] hover:text-[#24282f]"
}`}
>
Participants
</button>
{isQuiz ? (
<button
type="button"
onClick={() => setTab("leaderboard")}
className={`flex-1 rounded-md px-3 py-2 text-sm font-semibold transition ${
tab === "leaderboard" ? "bg-[#1e72ef] text-white" : "text-[rgba(36,40,47,0.65)] hover:text-[#24282f]"
}`}
>
Leaderboard{leaderboard.length > 0 ? ` (${leaderboard.length})` : ""}
</button>
) : null}
</div>
<div className="flex flex-1 flex-col">
{tab === "responses" ? (
<PresentStatsBars
kind={statsKind}
options={displayOptions}
answeredCount={stats?.answeredCount}
percentAnswered={stats?.percentAnswered}
totalResponses={stats?.totalResponses ?? 0}
revealCorrect={revealCorrect}
/>
) : tab === "participants" ? (
<PresentRespondersList
responders={stats?.responders ?? []}
isQuiz={isQuiz}
showAnswerText={currentQuestion.type === "text"}
showCorrectness={isQuiz}
/>
) : (
<PresentLeaderboard entries={leaderboard} loading={leaderboardLoading} />
)}
</div>
</main>
<PresentFooterNav
title={presentation.title}
onPrev={goPrev}
onNext={goNext}
prevDisabled={questionSafe <= 0}
nextDisabled={questionSafe >= questionCount - 1}
/>
</div>
);
}