import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { Link, useNavigate, useSearchParams } from "react-router-dom"; import { HeaderMobile } from "../components/HeaderMobile"; import { MobileShell } from "../components/MobileShell"; import { getVisitorLoginPreview, getVisitorQuizPollMyResponse, getVisitorQuizPollResultsSummary, postVisitorQuizPollResponse, type VisitorPollResultsSummary, type VisitorQuizPollResponseItem, } from "../lib/api"; import { isFirebaseClientConfigured } from "../lib/firebaseClient"; import type { RtdbQuizPollDetail, RtdbQuizPollQuestion } from "../lib/sessionQuizPollsRtdb"; import { isQuizPollVisibleForAudience, settingsDisplayMode, subscribeSessionQuizPollDetail, } from "../lib/sessionQuizPollsRtdb"; import { useAutoNavigateToLiveQuizPoll } from "../hooks/useAutoNavigateToLiveQuizPoll"; import { getAttendeeToken } from "../lib/attendeeAuth"; import { notifyRewardFromApiData } from "../lib/rewardCelebration"; import type { LoginPreviewResponse } from "../types/visitorContext"; import { readStoredVisitorContext, visitorEventUrl } from "../types/visitorContext"; function typeHeading(type: string): string { const t = type.toLowerCase(); if (t === "poll") return "Poll"; if (t === "survey") return "Survey"; return "Quiz"; } function formatCountdown(totalSeconds: number): string { const s = Math.max(0, totalSeconds); const m = Math.floor(s / 60); const r = s % 60; return `${m}:${r.toString().padStart(2, "0")}`; } function formatUserAnswerLine(q: RtdbQuizPollQuestion, raw: unknown): string { if (q.type === "text") return typeof raw === "string" && raw.trim() ? raw.trim() : "—"; if (q.type === "rating") return raw != null && String(raw).trim() !== "" ? String(raw) : "—"; if (q.type === "multiple") { const ids = Array.isArray(raw) ? raw.map(String) : []; const labels = q.options.filter((o) => ids.includes(o._id)).map((o) => o.text); return labels.length ? labels.join(", ") : "—"; } const id = typeof raw === "string" ? raw : ""; return q.options.find((o) => o._id === id)?.text || "—"; } /** null = not auto-graded (text/rating) */ function userAnswerCorrectQuiz(q: RtdbQuizPollQuestion, raw: unknown): boolean | null { if (q.type === "text" || q.type === "rating") return null; if (q.type === "single") { const id = typeof raw === "string" ? raw : ""; const opt = q.options.find((o) => o._id === id); if (!opt) return false; return opt.isCorrect; } if (q.type === "multiple") { const correctIds = new Set(q.options.filter((o) => o.isCorrect).map((o) => o._id)); const sel = new Set(Array.isArray(raw) ? raw.map(String) : []); if (correctIds.size === 0) return false; return correctIds.size === sel.size && [...correctIds].every((id) => sel.has(id)); } return null; } function correctAnswerLine(q: RtdbQuizPollQuestion): string | null { if (q.type === "text" || q.type === "rating") return null; const correct = q.options.filter((o) => o.isCorrect).map((o) => o.text); if (!correct.length) return null; return correct.join(", "); } function QuestionMedia({ url, alt }: { url?: string; alt: string }) { const src = url?.trim(); if (!src) return null; return ( {alt} ); } function buildQuizResponses( quiz: RtdbQuizPollDetail, answers: Record, ): { items: VisitorQuizPollResponseItem[]; totalScore: number } { const isQuizType = quiz.type.toLowerCase() === "quiz"; let totalScore = 0; const items = quiz.questions.map((q, questionIndex) => { const raw = answers[q._id]; let answer: unknown; if (q.type === "multiple") { answer = Array.isArray(raw) ? raw : raw !== undefined ? [raw as string] : []; } else if (q.type === "text") { answer = typeof raw === "string" ? raw : ""; } else { answer = raw ?? null; } if (!isQuizType) { return { questionIndex, answer }; } let isCorrect = false; let pointsEarned = 0; const pts = q.points ?? 1; if (q.type === "single" || q.type === "multiple") { const correctIds = new Set(q.options.filter((o) => o.isCorrect).map((o) => o._id)); if (q.type === "single") { isCorrect = typeof raw === "string" && correctIds.has(raw); } else { const sel = new Set(Array.isArray(raw) ? raw : []); isCorrect = correctIds.size > 0 && correctIds.size === sel.size && [...correctIds].every((id) => sel.has(id)); } pointsEarned = isCorrect ? pts : 0; } totalScore += pointsEarned; return { questionIndex, answer, isCorrect, pointsEarned }; }); return { items, totalScore }; } function buildSingleQuestionResponse( quiz: RtdbQuizPollDetail, q: RtdbQuizPollQuestion, questionIndex: number, answers: Record, timeSpentSec: number, ): { items: VisitorQuizPollResponseItem[]; totalScore: number } { const isQuizType = quiz.type.toLowerCase() === "quiz"; const raw = answers[q._id]; let answer: unknown; if (q.type === "multiple") { answer = Array.isArray(raw) ? raw : raw !== undefined ? [raw as string] : []; } else if (q.type === "text") { answer = typeof raw === "string" ? raw : ""; } else { answer = raw ?? null; } const timeSpent = Math.max(0, Math.floor(timeSpentSec)); if (!isQuizType) { return { items: [{ questionIndex, answer, timeSpent }], totalScore: 0 }; } let isCorrect = false; let pointsEarned = 0; const pts = q.points ?? 1; if (q.type === "single" || q.type === "multiple") { const correctIds = new Set(q.options.filter((o) => o.isCorrect).map((o) => o._id)); if (q.type === "single") { isCorrect = typeof raw === "string" && correctIds.has(raw); } else { const sel = new Set(Array.isArray(raw) ? raw : []); isCorrect = correctIds.size > 0 && correctIds.size === sel.size && [...correctIds].every((id) => sel.has(id)); } pointsEarned = isCorrect ? pts : 0; } return { items: [{ questionIndex, answer, isCorrect, pointsEarned, timeSpent }], totalScore: pointsEarned, }; } function elapsedQuestionSec(startedAt: number, limitSec: number): number { if (!startedAt || startedAt <= 0) return 0; const raw = Math.max(0, Math.round((Date.now() - startedAt) / 1000)); return limitSec > 0 ? Math.min(raw, limitSec) : raw; } export function QuizPollTakePage() { const [searchParams] = useSearchParams(); const navigate = useNavigate(); const eventId = searchParams.get("event_id")?.trim() || ""; const sessionId = searchParams.get("session_id")?.trim() || ""; const quizPollId = searchParams.get("quiz_poll_id")?.trim() || ""; const ctx = readStoredVisitorContext(); useAutoNavigateToLiveQuizPoll({ eventId, sessionId, currentQuizPollId: quizPollId || null, enabled: Boolean(eventId && sessionId && quizPollId), }); const [quiz, setQuiz] = useState(null); const [loading, setLoading] = useState(true); const [err, setErr] = useState(null); const [eventInfo, setEventInfo] = useState(null); const sessionStartedAtRef = useRef(0); const questionStartedAtRef = useRef(0); const sessionKeyRef = useRef(null); const autoSubmitDoneRef = useRef(false); const submitOnceRef = useRef(false); const sessionBackHref = eventId && sessionId ? `/session?${new URLSearchParams({ event_id: eventId, session_id: sessionId }).toString()}` : eventId ? visitorEventUrl(eventId) : "/"; useEffect(() => { if (!eventId) { setEventInfo(null); return; } if (ctx?.eventId === eventId && ctx.event) { setEventInfo(ctx.event); return; } let cancelled = false; getVisitorLoginPreview(eventId) .then((ev) => { if (!cancelled) setEventInfo(ev); }) .catch(() => { if (!cancelled) setEventInfo(null); }); return () => { cancelled = true; }; }, [eventId, ctx?.eventId]); useEffect(() => { if (!eventId || !sessionId || !quizPollId) { setErr("Invalid quiz link."); setQuiz(null); setLoading(false); return; } if (!isFirebaseClientConfigured()) { setErr("Firebase is not configured. Set VITE_FIREBASE_API_KEY and VITE_FIREBASE_DATABASE_URL."); setQuiz(null); setLoading(false); return; } setLoading(true); setErr(null); const unsub = subscribeSessionQuizPollDetail( sessionId, quizPollId, (detail) => { setQuiz(detail); setLoading(false); if (!detail) setErr("This quiz was removed or is unavailable."); }, (e) => { setErr(e.message); setQuiz(null); setLoading(false); }, ); return () => unsub(); }, [eventId, sessionId, quizPollId]); const [answers, setAnswers] = useState>({}); const [submitted, setSubmitted] = useState(false); const [submitting, setSubmitting] = useState(false); const [submitError, setSubmitError] = useState(null); const [scoreSubmitted, setScoreSubmitted] = useState(null); const [secondsLeft, setSecondsLeft] = useState(null); const [pollResults, setPollResults] = useState(null); const [pollResultsLoading, setPollResultsLoading] = useState(false); const [pollResultsErr, setPollResultsErr] = useState(null); const [questionStep, setQuestionStep] = useState(0); const [answeredQuestionIndices, setAnsweredQuestionIndices] = useState([]); const [myResponseChecked, setMyResponseChecked] = useState(false); const [questionTimerEpoch, setQuestionTimerEpoch] = useState(0); const mainScrollRef = useRef(null); const liveQuestionIdRef = useRef(null); const isQuizMasterMode = Boolean(quiz && settingsDisplayMode(quiz.settings) === "quiz_master"); const liveQuestionId = quiz?.settings.liveQuestionId != null && String(quiz.settings.liveQuestionId).trim() !== "" ? String(quiz.settings.liveQuestionId).trim() : null; const liveQuestionIndex = quiz && liveQuestionId ? quiz.questions.findIndex((q) => String(q._id) === liveQuestionId) : -1; const liveQuestion = liveQuestionIndex >= 0 && quiz ? quiz.questions[liveQuestionIndex]! : null; const liveQuestionAlreadyAnswered = isQuizMasterMode && liveQuestionIndex >= 0 && answeredQuestionIndices.includes(liveQuestionIndex); useEffect(() => { setAnswers({}); setSubmitted(false); setSubmitError(null); setScoreSubmitted(null); setSecondsLeft(null); setPollResults(null); setPollResultsErr(null); setPollResultsLoading(false); setQuestionStep(0); setAnsweredQuestionIndices([]); setMyResponseChecked(false); sessionKeyRef.current = null; liveQuestionIdRef.current = null; questionStartedAtRef.current = 0; autoSubmitDoneRef.current = false; submitOnceRef.current = false; }, [quizPollId, quiz?._id]); useEffect(() => { if (!quiz?.questions?.length) return; setQuestionStep((s) => Math.min(s, Math.max(0, quiz.questions.length - 1))); }, [quiz?.questions?.length, quiz?._id]); useEffect(() => { if (isQuizMasterMode) return; mainScrollRef.current?.scrollTo({ top: 0, behavior: "smooth" }); questionStartedAtRef.current = Date.now(); autoSubmitDoneRef.current = false; }, [questionStep, isQuizMasterMode]); useEffect(() => { if (!submitted || !quiz || quiz.settings.showResults === false) return; const t = quiz.type.toLowerCase(); if (t !== "poll" && t !== "survey") return; if (!eventId || !quizPollId) return; let cancelled = false; setPollResultsLoading(true); setPollResultsErr(null); getVisitorQuizPollResultsSummary(eventId, quizPollId) .then((data) => { if (!cancelled) { setPollResults(data); setPollResultsErr(null); } }) .catch((e) => { if (!cancelled) { setPollResultsErr(e instanceof Error ? e.message : "Could not load results."); setPollResults(null); } }) .finally(() => { if (!cancelled) setPollResultsLoading(false); }); return () => { cancelled = true; }; }, [submitted, quiz, eventId, quizPollId]); useEffect(() => { if (!quiz || !eventId || !quizPollId || loading) return; if (!getAttendeeToken()) { setMyResponseChecked(true); return; } let cancelled = false; setMyResponseChecked(false); getVisitorQuizPollMyResponse(eventId, quizPollId) .then((data) => { if (cancelled) return; const indices = data.answeredQuestionIndices ?? []; setAnsweredQuestionIndices(indices); const mode = settingsDisplayMode(quiz.settings); if (mode !== "quiz_master") { const allowRetry = quiz.settings.allowMultipleAttempts === true; const qCount = quiz.questions.length; const answeredAll = qCount > 0 && indices.length >= qCount && [...new Set(indices)].length >= qCount; if (!allowRetry && (answeredAll || data.hasResponse)) { setSubmitted(true); if (quiz.type.toLowerCase() === "quiz" && typeof data.totalScore === "number") { setScoreSubmitted(data.totalScore); } } } }) .catch(() => { /* ignore — user can still attempt submit */ }) .finally(() => { if (!cancelled) setMyResponseChecked(true); }); return () => { cancelled = true; }; }, [quiz, eventId, quizPollId, loading]); useEffect(() => { if (loading || err || !quiz) return; if (isQuizPollVisibleForAudience(quiz.settings, new Date())) return; const t = window.setTimeout(() => { navigate(sessionBackHref, { replace: true }); }, 2000); return () => clearTimeout(t); }, [loading, err, quiz, navigate, sessionBackHref]); useEffect(() => { if (liveQuestionIdRef.current === liveQuestionId) return; liveQuestionIdRef.current = liveQuestionId; setAnswers({}); setSubmitError(null); submitOnceRef.current = false; autoSubmitDoneRef.current = false; questionStartedAtRef.current = Date.now(); setQuestionTimerEpoch((n) => n + 1); mainScrollRef.current?.scrollTo({ top: 0, behavior: "smooth" }); }, [isQuizMasterMode, liveQuestionId]); const totalQuestions = quiz?.questions.length ?? 0; const stepSafe = Math.min(questionStep, Math.max(0, totalQuestions - 1)); const multiQuestion = !isQuizMasterMode && totalQuestions > 1; const currentQuestion = isQuizMasterMode ? liveQuestion : quiz?.questions[stepSafe]; const timeRestrictedOn = Boolean( quiz?.settings.timeRestricted === true || String(quiz?.settings.timeRestricted ?? "").toLowerCase() === "true", ); const perQuestionTimer = Boolean( quiz && settingsDisplayMode(quiz.settings) === "quiz_master" && timeRestrictedOn, ); const timerActive = Boolean( quiz && timeRestrictedOn && (perQuestionTimer ? typeof currentQuestion?.timeLimit === "number" && currentQuestion.timeLimit > 0 : typeof quiz.settings.timeLimit === "number" && quiz.settings.timeLimit > 0), ); const timeLimitSec = timerActive ? perQuestionTimer ? Math.max(1, currentQuestion?.timeLimit ?? 0) : Math.max(1, quiz!.settings.timeLimit ?? 0) : 0; useEffect(() => { if (!quiz || submitted) return; const key = `${quiz._id}-${quizPollId}`; if (sessionKeyRef.current !== key) { sessionKeyRef.current = key; const now = Date.now(); sessionStartedAtRef.current = now; if (settingsDisplayMode(quiz.settings) !== "quiz_master") { questionStartedAtRef.current = now; } autoSubmitDoneRef.current = false; } }, [quiz, quizPollId, submitted]); const runSubmit = useCallback(async () => { if (!quiz || !eventId || !quizPollId || submitted || submitOnceRef.current) return; if (isQuizMasterMode && (!liveQuestion || liveQuestionIndex < 0)) return; if (isQuizMasterMode && liveQuestionAlreadyAnswered) return; submitOnceRef.current = true; setSubmitting(true); setSubmitError(null); if (!getAttendeeToken()) { setSubmitError("Sign in to submit your response."); submitOnceRef.current = false; setSubmitting(false); return; } const elapsedSec = elapsedQuestionSec( questionStartedAtRef.current || Date.now(), perQuestionTimer && isQuizMasterMode ? timeLimitSec : 0, ); try { if (isQuizMasterMode && liveQuestion) { const { items, totalScore } = buildSingleQuestionResponse( quiz, liveQuestion, liveQuestionIndex, answers, elapsedSec, ); const submitResult = await postVisitorQuizPollResponse(eventId, quizPollId, { responses: items, totalScore: quiz.type.toLowerCase() === "quiz" ? totalScore : undefined, timeSpent: elapsedSec, }); notifyRewardFromApiData(submitResult); setAnsweredQuestionIndices((prev) => prev.includes(liveQuestionIndex) ? prev : [...prev, liveQuestionIndex], ); return; } const { items, totalScore } = buildQuizResponses(quiz, answers); const submitResult = await postVisitorQuizPollResponse(eventId, quizPollId, { responses: items, totalScore: quiz.type.toLowerCase() === "quiz" ? totalScore : undefined, timeSpent: elapsedSec, }); notifyRewardFromApiData(submitResult); setSubmitted(true); if (quiz.type.toLowerCase() === "quiz" && quiz.settings.showResults !== false) { setScoreSubmitted(totalScore); } else { setScoreSubmitted(null); } } catch (e) { const msg = e instanceof Error ? e.message : "Could not submit."; if ( isQuizMasterMode && (msg.includes("already submitted an answer for this question") || msg.includes("Response already recorded")) ) { setAnsweredQuestionIndices((prev) => liveQuestionIndex >= 0 && !prev.includes(liveQuestionIndex) ? [...prev, liveQuestionIndex] : prev, ); setSubmitError(null); } else if ( msg.includes("already submitted a response") || msg.includes("You have already submitted") ) { setSubmitted(true); setSubmitError(null); } else { setSubmitError(msg); submitOnceRef.current = false; } } finally { setSubmitting(false); } }, [ quiz, eventId, quizPollId, answers, submitted, isQuizMasterMode, liveQuestion, liveQuestionIndex, liveQuestionAlreadyAnswered, perQuestionTimer, timeLimitSec, ]); useEffect(() => { if (!quiz || submitted || !timerActive) { setSecondsLeft(null); return; } if (isQuizMasterMode && (liveQuestionAlreadyAnswered || !liveQuestion)) { setSecondsLeft(null); return; } const startedAt = perQuestionTimer ? questionStartedAtRef.current : sessionStartedAtRef.current; if (perQuestionTimer && startedAt <= 0) { setSecondsLeft(null); return; } const tick = () => { if (perQuestionTimer && questionStartedAtRef.current <= 0) return; const tickStartedAt = perQuestionTimer ? questionStartedAtRef.current : sessionStartedAtRef.current; const tickDeadline = tickStartedAt + timeLimitSec * 1000; const left = Math.max(0, Math.ceil((tickDeadline - Date.now()) / 1000)); setSecondsLeft(left); if (left <= 0 && !autoSubmitDoneRef.current) { if (perQuestionTimer && !isQuizMasterMode && stepSafe < totalQuestions - 1) { setQuestionStep((s) => Math.min(totalQuestions - 1, s + 1)); return; } autoSubmitDoneRef.current = true; void runSubmit(); } }; tick(); const id = window.setInterval(tick, 1000); return () => window.clearInterval(id); }, [ quiz?._id, quizPollId, submitted, timerActive, timeLimitSec, runSubmit, quiz, perQuestionTimer, stepSafe, totalQuestions, isQuizMasterMode, liveQuestionId, liveQuestion, liveQuestionAlreadyAnswered, questionTimerEpoch, ]); const headerLogo = useMemo(() => { if (ctx?.eventId === eventId && ctx.event?.logoUrl?.trim()) return ctx.event.logoUrl.trim(); return eventInfo?.logoUrl?.trim(); }, [ctx, eventId, eventInfo?.logoUrl]); const setSingle = (q: RtdbQuizPollQuestion, optionId: string) => { setAnswers((prev) => ({ ...prev, [q._id]: optionId })); }; const toggleMulti = (q: RtdbQuizPollQuestion, optionId: string) => { setAnswers((prev) => { const cur = prev[q._id]; const list = Array.isArray(cur) ? [...cur] : []; const i = list.indexOf(optionId); if (i >= 0) list.splice(i, 1); else list.push(optionId); return { ...prev, [q._id]: list }; }); }; const setText = (q: RtdbQuizPollQuestion, text: string) => { setAnswers((prev) => ({ ...prev, [q._id]: text })); }; const signedIn = Boolean(getAttendeeToken()); if (!eventId || !sessionId || !quizPollId) { return (

Invalid quiz link.

Home
); } if (loading) { return (

Loading…

); } if (err || !quiz) { return (

{err ?? "Quiz not found."}

Back to session
); } const quizVisible = isQuizPollVisibleForAudience(quiz.settings, new Date()); if (!quizVisible) { return (

Returning to session…

); } if (submitted && !quiz) { return (

Thank you

Your response was submitted.

Back to session
); } if (submitted && quiz) { const showRes = quiz.settings.showResults !== false; const isQuiz = quiz.type.toLowerCase() === "quiz"; const isPollOrSurvey = quiz.type.toLowerCase() === "poll" || quiz.type.toLowerCase() === "survey"; return (

Thank you

Your response was submitted.

{showRes && isQuiz && typeof scoreSubmitted === "number" ? (

Your score: {scoreSubmitted}

) : null}
{showRes && isQuiz ? (

Results

Questions with correct answers

    {quiz.questions.map((q, qi) => { const raw = answers[q._id]; const graded = userAnswerCorrectQuiz(q, raw); const corr = correctAnswerLine(q); return (
  • {qi + 1}. {q.question}

    Your answer

    {formatUserAnswerLine(q, raw)} {graded === true ? " ✓" : graded === false ? " ✗" : ""}

    {corr ? ( <>

    Correct answer

    {corr}

    ) : q.type === "text" ? (

    Open-ended — no single correct answer.

    ) : q.type === "rating" ? (

    Rating scale — no correct answer key.

    ) : null}
  • ); })}
) : null} {showRes && isPollOrSurvey ? (

Results

Share of responses per option

{pollResultsLoading ? (

Loading results…

) : null} {pollResultsErr ? (

{pollResultsErr}

) : null} {pollResults && !pollResultsLoading ? (

Based on {pollResults.totalSubmissions} submission {pollResults.totalSubmissions === 1 ? "" : "s"}

) : null} {pollResults && !pollResultsLoading ? (
    {pollResults.questions.map((pq) => (
  • {pq.question}

    {pq.kind === "text" ? (

    {(pq.answeredCount ?? 0) === 0 ? "No text answers yet." : `${pq.answeredCount} of ${pq.totalResponses} answered (${pq.percentAnswered ?? 0}%)`}

    ) : (
    {(pq.options || []).map((opt) => (
    {opt.text || "—"} {opt.percent}%
    ))}
    )}
  • ))}
) : null}
) : null} Back to session
); } if (isQuizMasterMode && signedIn && !myResponseChecked) { return (

Loading…

); } const submitLabel = submitting ? "Submitting…" : `Submit ${typeHeading(quiz.type)}`; if (isQuizMasterMode && liveQuestionAlreadyAnswered) { return (

Answer submitted

You have already submitted this question. Please wait for the host to show the next one.

Back to session
); } if (isQuizMasterMode && !liveQuestion) { return (

{quiz.title}

The host will start the next question soon.

Back to session
); } const footerBtnClass = "rounded-lg p-3 text-center font-semibold shadow-sm disabled:opacity-50 disabled:hover:bg-inherit"; const footerSecondaryClass = `${footerBtnClass} flex-1 border border-zinc-300 bg-zinc-100 text-gray-700`; return (

{typeHeading(quiz.type)}

{quiz.title}

{isQuizMasterMode && liveQuestion ? (

Question {liveQuestionIndex + 1} of {totalQuestions}

) : multiQuestion ? (

Question {stepSafe + 1} of {totalQuestions}

) : null}
{timerActive && secondsLeft != null ? (
{perQuestionTimer && isQuizMasterMode ? "Time left for this question" : "Time left"} {formatCountdown(secondsLeft)}
) : null}
    {(isQuizMasterMode && liveQuestion ? [liveQuestion] : multiQuestion ? [quiz.questions[stepSafe]!] : quiz.questions ).map((q, offset) => { const qi = isQuizMasterMode ? liveQuestionIndex : multiQuestion ? stepSafe : offset; return (
  • {qi + 1}. {q.question}

    {q.type === "multiple" ? (
    {q.options.map((opt) => { const selected = Array.isArray(answers[q._id]) && answers[q._id].includes(opt._id); return ( ); })}
    ) : q.type === "text" ? (