611 lines
23 KiB
TypeScript
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>
|
|
);
|
|
}
|