EventBuzz_Website/src/pages/QuizPollTakePage.tsx

1145 lines
45 KiB
TypeScript

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 (
<img
src={src}
alt={alt}
className="mt-3 max-h-52 w-full rounded-md border border-[#e5e5ea] object-contain"
loading="lazy"
/>
);
}
function buildQuizResponses(
quiz: RtdbQuizPollDetail,
answers: Record<string, string | string[]>,
): { 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<string, string | string[]>,
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<RtdbQuizPollDetail | null>(null);
const [loading, setLoading] = useState(true);
const [err, setErr] = useState<string | null>(null);
const [eventInfo, setEventInfo] = useState<LoginPreviewResponse | null>(null);
const sessionStartedAtRef = useRef<number>(0);
const questionStartedAtRef = useRef<number>(0);
const sessionKeyRef = useRef<string | null>(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<Record<string, string | string[]>>({});
const [submitted, setSubmitted] = useState(false);
const [submitting, setSubmitting] = useState(false);
const [submitError, setSubmitError] = useState<string | null>(null);
const [scoreSubmitted, setScoreSubmitted] = useState<number | null>(null);
const [secondsLeft, setSecondsLeft] = useState<number | null>(null);
const [pollResults, setPollResults] = useState<VisitorPollResultsSummary | null>(null);
const [pollResultsLoading, setPollResultsLoading] = useState(false);
const [pollResultsErr, setPollResultsErr] = useState<string | null>(null);
const [questionStep, setQuestionStep] = useState(0);
const [answeredQuestionIndices, setAnsweredQuestionIndices] = useState<number[]>([]);
const [myResponseChecked, setMyResponseChecked] = useState(false);
const [questionTimerEpoch, setQuestionTimerEpoch] = useState(0);
const mainScrollRef = useRef<HTMLElement | null>(null);
const liveQuestionIdRef = useRef<string | null>(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 (
<MobileShell className="relative">
<HeaderMobile eventId={null} />
<main className="flex flex-1 flex-col items-center justify-center px-4 py-10">
<div className="mx-auto w-full max-w-[370px] rounded-lg card-bg p-4 text-center shadow-sm">
<p className="text-center text-sm">Invalid quiz link.</p>
<Link to="/" className="mt-4 text-sm font-semibold text-[var(--theme-button-color)] underline">
Home
</Link>
</div>
</main>
</MobileShell>
);
}
if (loading) {
return (
<MobileShell className="relative">
<HeaderMobile logoUrl={headerLogo} eventId={eventId} />
<main className="flex flex-1 items-center justify-center px-4 py-10">
<div className="mx-auto w-full max-w-[370px] rounded-lg card-bg p-4 text-center shadow-sm">
<p className="text-sm">Loading…</p>
</div>
</main>
</MobileShell>
);
}
if (err || !quiz) {
return (
<MobileShell className="relative">
<HeaderMobile logoUrl={headerLogo} eventId={eventId} />
<main className="flex flex-1 flex-col items-center justify-center px-4 py-10">
<div className="mx-auto w-full max-w-[370px] rounded-lg card-bg p-4 text-center shadow-sm">
<p className="text-center text-sm text-red-600">{err ?? "Quiz not found."}</p>
<Link to={sessionBackHref} className="mt-4 text-sm font-semibold text-[var(--theme-button-color)] underline">
Back to session
</Link>
</div>
</main>
</MobileShell>
);
}
const quizVisible = isQuizPollVisibleForAudience(quiz.settings, new Date());
if (!quizVisible) {
return (
<MobileShell className="relative">
<HeaderMobile logoUrl={headerLogo} eventId={eventId} />
<main className="flex flex-1 flex-col items-center justify-center px-4 py-10">
<div className="mx-auto w-full max-w-[370px] rounded-lg card-bg p-4 text-center shadow-sm">
<p className="text-center text-sm text-[var(--theme-card-text-color)]/80">Returning to session…</p>
</div>
</main>
</MobileShell>
);
}
if (submitted && !quiz) {
return (
<MobileShell className="relative">
<HeaderMobile logoUrl={headerLogo} eventId={eventId} />
<main className="flex flex-1 flex-col items-center justify-center px-4 py-10">
<div className="mx-auto w-full max-w-[370px] rounded-lg card-bg p-4 text-center shadow-sm">
<p className="font-inter text-lg font-semibold">Thank you</p>
<p className="mt-2 text-center text-sm text-[var(--theme-card-text-color)]/80">Your response was submitted.</p>
<Link
to={sessionBackHref}
className="mt-6 text-sm font-semibold text[var(--theme-button-color)] underline"
>
Back to session
</Link>
</div>
</main>
</MobileShell>
);
}
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 (
<MobileShell className="relative h-dvh min-h-0 flex-col">
<div className="flex shrink-0 flex-col">
<HeaderMobile logoUrl={headerLogo} eventId={eventId} />
</div>
<main className="min-h-0 flex-1 overflow-y-auto overscroll-contain px-4 pb-8 pt-6">
<div className="mx-auto w-full max-w-[370px]">
<div className="rounded-lg card-bg p-5 text-center shadow-md">
<p className="font-inter text-lg font-semibold text-[var(--theme-card-text-color)]]">Thank you</p>
<p className="mt-1 text-sm text-[var(--theme-card-text-color)]/80">Your response was submitted.</p>
{showRes && isQuiz && typeof scoreSubmitted === "number" ? (
<p className="mt-3 text-sm font-semibold text-[var(--theme-button-color)]">Your score: {scoreSubmitted}</p>
) : null}
</div>
{showRes && isQuiz ? (
<div className="mt-6">
<p className="text-[10px] font-semibold uppercase tracking-wide text-[var(--theme-button-color)]">Results</p>
<p className="mt-0.5 text-xs text-[var(--theme-card-text-color)]/80">Questions with correct answers</p>
<ul className="mt-4 flex flex-col gap-4">
{quiz.questions.map((q, qi) => {
const raw = answers[q._id];
const graded = userAnswerCorrectQuiz(q, raw);
const corr = correctAnswerLine(q);
return (
<li
key={q._id}
className="rounded-lg border border-[var(--theme-card-text-color)]/15 card-bg p-4 shadow-sm"
>
<p className="text-sm font-semibold text-[var(--theme-card-text-color)]">
<span className="text-[var(--theme-card-text-color)]">{qi + 1}. </span>
{q.question}
</p>
<p className="mt-2 text-xs font-medium uppercase tracking-wide text-[var(--theme-card-text-color)]/80">
Your answer
</p>
<p
className={`text-sm font-medium ${
graded === true
? "text-green-700"
: graded === false
? "text-red-600"
: "text-[var(--theme-card-text-color)]"
}`}
>
{formatUserAnswerLine(q, raw)}
{graded === true ? " ✓" : graded === false ? " ✗" : ""}
</p>
{corr ? (
<>
<p className="mt-2 text-[10px] font-medium uppercase tracking-wide text-[var(--theme-card-text-color)]/80">
Correct answer
</p>
<p className="text-sm font-semibold text-green-800">{corr}</p>
</>
) : q.type === "text" ? (
<p className="mt-2 text-xs text-[var(--theme-card-text-color)]/60">
Open-ended — no single correct answer.
</p>
) : q.type === "rating" ? (
<p className="mt-2 text-xs text-[var(--theme-card-text-color)]/60">
Rating scale — no correct answer key.
</p>
) : null}
</li>
);
})}
</ul>
</div>
) : null}
{showRes && isPollOrSurvey ? (
<div className="mt-6">
<p className="text-[10px] font-semibold uppercase tracking-wide text-[var(--theme-button-color)]">Results</p>
<p className="mt-0.5 text-xs text-[var(--theme-card-text-color)]">Share of responses per option</p>
{pollResultsLoading ? (
<p className="mt-6 text-center text-sm text-[var(--theme-card-text-color)]/80">Loading results…</p>
) : null}
{pollResultsErr ? (
<p className="mt-4 text-center text-sm text-red-600">{pollResultsErr}</p>
) : null}
{pollResults && !pollResultsLoading ? (
<p className="mt-3 text-xs text-[var(--theme-card-text-color)]/60">
Based on {pollResults.totalSubmissions} submission
{pollResults.totalSubmissions === 1 ? "" : "s"}
</p>
) : null}
{pollResults && !pollResultsLoading ? (
<ul className="mt-4 flex flex-col gap-5">
{pollResults.questions.map((pq) => (
<li
key={`${pq.questionIndex}-${pq.questionId}`}
className="rounded-lg border border-[var(--theme-card-text-color)]/15 card-bg p-4 shadow-sm"
>
<p className="text-sm font-semibold text-[var(--theme-card-text-color)]">{pq.question}</p>
{pq.kind === "text" ? (
<p className="mt-2 text-sm text-[var(--theme-card-text-color)]/80">
{(pq.answeredCount ?? 0) === 0
? "No text answers yet."
: `${pq.answeredCount} of ${pq.totalResponses} answered (${pq.percentAnswered ?? 0}%)`}
</p>
) : (
<div className="mt-3 flex flex-col gap-3">
{(pq.options || []).map((opt) => (
<div key={opt.optionId}>
<div className="mb-1 flex justify-between gap-2 text-xs text-[var(--theme-card-text-color)]">
<span className="min-w-0 flex-1 leading-snug">{opt.text || "—"}</span>
<span className="shrink-0 tabular-nums font-semibold text-[var(--theme-button-color)]">
{opt.percent}%
</span>
</div>
<div className="h-2 overflow-hidden rounded-full bg-[var(--theme-button-color)]/10">
<div
className="h-full rounded-full bg-[var(--theme-button-color)]"
style={{ width: `${Math.min(100, Math.max(0, opt.percent))}%` }}
/>
</div>
</div>
))}
</div>
)}
</li>
))}
</ul>
) : null}
</div>
) : null}
<Link
to={sessionBackHref}
className="mt-8 block text-center text-sm font-semibold text-[var(--theme-text-color)] underline"
>
Back to session
</Link>
</div>
</main>
</MobileShell>
);
}
if (isQuizMasterMode && signedIn && !myResponseChecked) {
return (
<MobileShell className="relative h-dvh min-h-0 flex-col">
<div className="flex shrink-0 flex-col">
<HeaderMobile logoUrl={headerLogo} eventId={eventId} />
</div>
<main className="flex flex-1 items-center justify-center px-4 py-10">
<div className="mx-auto w-full max-w-[370px] rounded-lg card-bg p-4 text-center shadow-sm">
<p className="text-sm">Loading…</p>
</div>
</main>
</MobileShell>
);
}
const submitLabel = submitting ? "Submitting…" : `Submit ${typeHeading(quiz.type)}`;
if (isQuizMasterMode && liveQuestionAlreadyAnswered) {
return (
<MobileShell className="relative h-dvh min-h-0 flex-col">
<div className="flex shrink-0 flex-col">
<HeaderMobile logoUrl={headerLogo} eventId={eventId} />
</div>
<main className="flex flex-1 flex-col items-center justify-center px-4 py-10">
<div className="mx-auto w-full max-w-[370px] rounded-lg border text-center shadow-sm">
<div className="card-bg rounded-lg p-4">
<p className="font-inter text-lg font-semibold">Answer submitted</p>
<p className="mt-2 text-sm text-[var(--theme-card-text-color)]/80">
You have already submitted this question. Please wait for the host to show the next one.
</p>
</div>
</div>
<Link to={sessionBackHref} className="mt-6 text-sm font-semibold text-[var(--theme-button-color)] underline">
Back to session
</Link>
</main>
</MobileShell>
);
}
if (isQuizMasterMode && !liveQuestion) {
return (
<MobileShell className="relative h-dvh min-h-0 flex-col">
<div className="flex shrink-0 flex-col">
<HeaderMobile logoUrl={headerLogo} eventId={eventId} />
</div>
<main className="flex flex-1 flex-col items-center justify-center px-4 py-10">
<div className="mx-auto w-full max-w-[370px] rounded-lg card-bg p-5 text-center shadow-sm">
<p className="font-inter text-lg font-semibold">{quiz.title}</p>
<p className="mt-2 text-sm text-[var(--theme-card-text-color)]/80">
The host will start the next question soon.
</p>
</div>
<Link to={sessionBackHref} className="mt-6 text-sm font-semibold text-[var(--theme-button-color)] underline">
Back to session
</Link>
</main>
</MobileShell>
);
}
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 (
<MobileShell className="relative h-dvh min-h-0 flex-col">
<div className="flex shrink-0 flex-col">
<HeaderMobile logoUrl={headerLogo} eventId={eventId} />
</div>
<main
ref={mainScrollRef}
className="relative z-0 min-h-0 flex-1 overflow-y-auto overscroll-contain px-4 pt-6"
>
<div className="mx-auto w-full max-w-[370px] pb-4">
<div className="flex flex-col gap-1 card-bg p-4 rounded-lg shadow-sm">
<p className="text-[10px] font-semibold uppercase tracking-wide text-[var(--theme-button-color)]">{typeHeading(quiz.type)}</p>
<h1 className="mt-1 font-inte text-xl font-semibold">{quiz.title}</h1>
{isQuizMasterMode && liveQuestion ? (
<p className="mt-2 text-xs font-medium tabular-nums text-[var(--theme-card-text-color)]/80">
Question {liveQuestionIndex + 1} of {totalQuestions}
</p>
) : multiQuestion ? (
<p className="mt-2 text-xs font-medium tabular-nums text-[var(--theme-card-text-color)]/80">
Question {stepSafe + 1} of {totalQuestions}
</p>
) : null}
</div>
{timerActive && secondsLeft != null ? (
<div
className={`mt-4 flex items-center justify-between rounded-lg border px-3 py-2.5 ${
secondsLeft <= 30 ? "border-amber-400 bg-amber-50" : "border-zinc-300 bg-zinc-100"
}`}
>
<span className="text-xs font-medium text-[var(--theme-card-text-color)]">
{perQuestionTimer && isQuizMasterMode ? "Time left for this question" : "Time left"}
</span>
<span
className={`font-mono text-lg font-bold tabular-nums ${
secondsLeft <= 30 ? "text-amber-800" : "text-[var(--theme-card-text-color)]"
}`}
>
{formatCountdown(secondsLeft)}
</span>
</div>
) : null}
<ul className="mt-6 flex flex-col gap-6">
{(isQuizMasterMode && liveQuestion
? [liveQuestion]
: multiQuestion
? [quiz.questions[stepSafe]!]
: quiz.questions
).map((q, offset) => {
const qi = isQuizMasterMode ? liveQuestionIndex : multiQuestion ? stepSafe : offset;
return (
<li key={q._id} className="rounded-lg card-bg p-4 shadow-sm">
<p className="text-sm font-semibold text-[var(--theme-card-text-color)]">
<span className="text-[var(--theme-card-text-color)]">{qi + 1}. </span>
{q.question}
</p>
<QuestionMedia url={q.imageUrl} alt="" />
{q.type === "multiple" ? (
<div className="mt-3 flex flex-col gap-2">
{q.options.map((opt) => {
const selected = Array.isArray(answers[q._id]) && answers[q._id].includes(opt._id);
return (
<label
key={opt._id}
className={`flex cursor-pointer items-start gap-2 rounded-md border px-3 py-2 text-sm ${
selected ? "border-[var(--theme-button-color)] bg-[var(--theme-card-color)]/10" : "border-[var(--theme-card-text-color)]/5 bg-[var(--theme-card-text-color)]/8 text-[var(--theme-card-text-color)]"
}`}
>
<input
type="checkbox"
checked={selected}
onChange={() => toggleMulti(q, opt._id)}
className="mt-0.5"
/>
<span className="flex min-w-0 flex-1 flex-col gap-2">
<span>{opt.text}</span>
<QuestionMedia url={opt.imageUrl} alt="" />
</span>
</label>
);
})}
</div>
) : q.type === "text" ? (
<textarea
value={typeof answers[q._id] === "string" ? answers[q._id] : ""}
onChange={(e) => setText(q, e.target.value)}
rows={3}
className="mt-3 w-full rounded-md border border-[var(--theme-card-text-color)]/10 bg-[var(--theme-card-color)]/40 px-3 py-2 text-sm text-[var(--theme-card-text-color)] placeholder:text-[var(--theme-card-text-color)]/60 transition-all duration-300 ease-in-out focus:border-[var(--theme-button-color)] focus:outline-none focus:ring-2 focus:ring-[var(--theme-button-color)]/30 disabled:opacity-60"
placeholder="Your answer"
/>
) : q.type === "rating" ? (
<div className="mt-3 flex flex-wrap gap-2">
{[1, 2, 3, 4, 5].map((n) => (
<button
key={n}
type="button"
onClick={() => setSingle(q, String(n))}
className={`size-10 rounded-full border text-sm font-semibold ${
answers[q._id] === String(n)
? "border-[var(--theme-button-color)] bg-[var(--theme-button-color] text-white"
: "border-[var(--theme-card-text-color)] text-[var(--theme-card-text-color)]"
}`}
>
{n}
</button>
))}
</div>
) : (
<div className="mt-3 flex flex-col gap-2">
{q.options.map((opt) => (
<label
key={opt._id}
className={`flex cursor-pointer items-start gap-2 rounded-md border px-3 py-2 text-sm transition-all duration-300 ease ${
answers[q._id] === opt._id ? "border-[var(--theme-button-color)] bg-[var(--theme-button-color)]/10" : "border-[var(--theme-card-text-color)]/5 bg-[var(--theme-card-text-color)]/8 text-[var(--theme-card-text-color)]"
}`}
>
<input
type="radio"
name={`q-${q._id}`}
checked={answers[q._id] === opt._id}
onChange={() => setSingle(q, opt._id)}
className="mt-0.5 opacity-0 invisible w-[1px] h-[1px]"
/>
<span className="flex min-w-0 flex-1 flex-col gap-0">
<span>{opt.text}</span>
<QuestionMedia url={opt.imageUrl} alt="" />
</span>
</label>
))}
</div>
)}
</li>
);
})}
</ul>
{!signedIn ? (
<p className="mt-8 text-center text-xs text-[var(--theme-card-text-color)]/80">
{multiQuestion ? (
<>
Use <span className="font-semibold">Next</span> or{" "}
<span className="font-semibold">Submit</span> below after{" "}
<Link className="font-semibold theme-btn-link" to={visitorEventUrl(eventId)}>
signing in
</Link>
.
</>
) : (
<>
Tap <span className="font-semibold">Submit</span> below after{" "}
<Link className="font-semibold theme-btn-link" to={visitorEventUrl(eventId)}>
signing in
</Link>
.
</>
)}
</p>
) : null}
{submitError ? <p className="mt-4 text-center text-sm text-red-600">{submitError}</p> : null}
<p className="mt-6 text-center text-md">
<Link className="text-[var(--theme-text-color)] underline" to={sessionBackHref}>
Back to session
</Link>
</p>
</div>
</main>
<footer
className="z-40 shrink-0 border-t border-[var(--theme-card-color)]/25 card-bg shadow-md"
style={{ paddingBottom: "max(12px, env(safe-area-inset-bottom))", paddingTop: 12, paddingLeft: 16, paddingRight: 16 }}
>
<div className="mx-auto flex w-full max-w-[370px] gap-2">
{multiQuestion && !isQuizMasterMode ? (
<>
<button
type="button"
disabled={stepSafe <= 0}
onClick={() => setQuestionStep((s) => Math.max(0, s - 1))}
className={footerSecondaryClass}
>
Previous
</button>
{stepSafe >= totalQuestions - 1 ? (
<button
type="button"
disabled={submitting}
onClick={() => void runSubmit()}
className={`${footerBtnClass} flex-1 theme-btn-bg text-white disabled:hover:bg-[var(--theme-button-color)]/50`}
>
{submitLabel}
</button>
) : (
<button
type="button"
onClick={() => setQuestionStep((s) => Math.min(totalQuestions - 1, s + 1))}
className={`${footerBtnClass} flex-1 theme-btn-bg text-white disabled:hover:bg-[var(--theme-button-color)]/50`}
>
Next
</button>
)}
</>
) : (
<button
type="button"
disabled={submitting}
onClick={() => void runSubmit()}
className="w-full rounded-lg theme-btn-bg px-3 py-3 text-center text-sm font-semibold text-white shadow-sm disabled:opacity-60 disabled:hover:bg-[var(--theme-button-color)]/50"
>
{submitLabel}
</button>
)}
</div>
</footer>
</MobileShell>
);
}