1145 lines
45 KiB
TypeScript
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>
|
|
);
|
|
}
|