// components/QuizBlock.jsx
import { useState, useEffect, useRef, useCallback } from "react";
import { z } from "zod";
import { Button } from "@/components/ui/button";
import { Skeleton } from "@/components/ui/skeleton";
import {
ChevronLeft, ChevronRight,
Circle, CheckCircle2,
Square, CheckSquare2,
Clock, AlertTriangle, Info, ArrowRight,
} from "lucide-react";
import { useClientNotifications } from "@/contexts/ClientNotificationContext";
import { useDateFormat } from "@/hooks/useDateFormat";
// Zod schema: every question must have a non-empty answer before submitting.
function buildAnswerSchema(questions = []) {
const shape = {};
for (const q of questions) {
const key = String(q.question_id);
shape[key] = q.type === "multi_select"
? z.array(z.unknown()).min(1, "This question requires at least one selection.")
: z.union([z.string(), z.number()]).refine((v) => v !== "" && v != null, "This question requires an answer.");
}
return z.object(shape);
}
function QuizSkeleton() {
return (
);
}
function formatTime(secs) {
if (secs === null || secs === undefined) return null;
const m = Math.floor(secs / 60).toString().padStart(2, "0");
const s = (secs % 60).toString().padStart(2, "0");
return `${m}:${s}`;
}
function isQuestionAnswered(answer) {
return Array.isArray(answer) ? answer.length > 0 : answer !== undefined;
}
/**
* Props:
* quiz — assessment data including active_session, time_limit_minutes, etc.
* loading — true while fetch is in-flight
* onStart — async () => { session_id, expires_at, remaining_seconds, draft_answers }
* onDraft — (answers) => void — called every 25s
* onRefreshSession — async () => { expires_at, remaining_seconds }
* onSubmit — async (answers, sessionId?) => result|null
* onRetake — () => void
* onActiveChange — (isActive: boolean) => void — fires when session starts/ends
* label — "Quiz" or "Assessment"
*/
const QuizBlock = ({ quiz, loading = false, onStart, onDraft, onRefreshSession, onSubmit, onRetake, onActiveChange, onNextContent, nextLabel, label = "Quiz" }) => {
const { fmtDateTime } = useDateFormat();
const questions = quiz?.questions ?? [];
const total = questions.length;
// ── Core stage state ──────────────────────────────────────────────────────
const [stage, setStage] = useState("intro"); // 'intro' | 'taking' | 'review' | 'result'
const [currentIndex, setCurrentIndex] = useState(0);
const [answers, setAnswers] = useState({});
const [submitting, setSubmitting] = useState(false);
const [starting, setStarting] = useState(false);
const [result, setResult] = useState(null);
const [submitError, setSubmitError] = useState(null);
// ── Timer state ───────────────────────────────────────────────────────────
const [remainingSeconds, setRemainingSeconds] = useState(null);
const [timeExpired, setTimeExpired] = useState(false);
// ── Notifications ─────────────────────────────────────────────────────────
const { notifications, fetchNotifications, accelerate, decelerate } = useClientNotifications();
const [assessmentUpdatedAlert, setAssessmentUpdatedAlert] = useState(false);
const seenNotifRef = useRef(new Set());
// ── Session refs ──────────────────────────────────────────────────────────
const sessionRef = useRef({ sessionId: null, expiresAt: null });
const answersRef = useRef({});
const timerFiredRef = useRef(false);
useEffect(() => { answersRef.current = answers; }, [answers]);
// Notify parent when an active session starts or ends
useEffect(() => {
onActiveChange?.(stage === "taking" || stage === "review");
}, [stage]); // eslint-disable-line react-hooks/exhaustive-deps
// Always deactivate when component unmounts (user navigated away mid-session)
useEffect(() => () => { onActiveChange?.(false); }, []); // eslint-disable-line react-hooks/exhaustive-deps
useEffect(() => {
setStage("intro");
setResult(null);
setRemainingSeconds(null);
setTimeExpired(false);
sessionRef.current = { sessionId: null, expiresAt: null };
timerFiredRef.current = false;
const draft = quiz?.active_session?.draft_answers ?? {};
const hasDraft = Object.keys(draft).length > 0;
setAnswers(hasDraft ? draft : {});
answersRef.current = hasDraft ? draft : {};
if (hasDraft) {
const qs = quiz?.questions ?? [];
const firstUnanswered = qs.findIndex(q => !isQuestionAnswered(draft[q.question_id]));
setCurrentIndex(firstUnanswered > -1 ? firstUnanswered : 0);
} else {
setCurrentIndex(0);
}
}, [quiz?.quiz_id]);
useEffect(() => {
if (stage !== "taking") { decelerate(); return; }
accelerate();
fetchNotifications();
return () => decelerate();
}, [stage]); // eslint-disable-line react-hooks/exhaustive-deps
useEffect(() => {
if (stage !== "taking") return;
notifications.forEach((n) => {
if (n.type === "assessment" && n.title === "Assessment Updated" && !seenNotifRef.current.has(n.notification_id)) {
seenNotifRef.current.add(n.notification_id);
setAssessmentUpdatedAlert(true);
if (onRefreshSession) {
onRefreshSession().then(updated => {
if (updated?.remaining_seconds != null) {
setRemainingSeconds(updated.remaining_seconds);
sessionRef.current.expiresAt = updated.expires_at ? new Date(updated.expires_at) : null;
}
});
}
}
});
}, [notifications, stage]); // eslint-disable-line react-hooks/exhaustive-deps
useEffect(() => {
if (stage !== "taking" || !onDraft) return;
onDraft(answersRef.current);
const id = setInterval(() => onDraft(answersRef.current), 25_000);
return () => clearInterval(id);
}, [stage, onDraft]); // eslint-disable-line react-hooks/exhaustive-deps
// Save immediately on every answer change — ensures no answer is lost if the tab closes
// before the 25s interval fires. Guards on stage and non-empty answers to skip resets.
useEffect(() => {
if (stage !== "taking" || !onDraft || Object.keys(answers).length === 0) return;
onDraft(answers);
}, [answers]); // eslint-disable-line react-hooks/exhaustive-deps
useEffect(() => {
if (stage !== "taking" || !sessionRef.current.expiresAt) return;
timerFiredRef.current = false;
const tick = () => {
const secs = Math.max(0, Math.ceil((sessionRef.current.expiresAt.getTime() - Date.now()) / 1000));
setRemainingSeconds(secs);
if (secs <= 0 && !timerFiredRef.current) {
timerFiredRef.current = true;
setTimeExpired(true);
clearInterval(id);
}
};
tick();
const id = setInterval(tick, 1000);
return () => clearInterval(id);
}, [stage]);
useEffect(() => {
if (!timeExpired || submitting) return;
const autoSubmit = async () => {
setSubmitting(true);
const res = await onSubmit?.(answersRef.current, sessionRef.current.sessionId);
setSubmitting(false);
if (res) { setResult(res); setStage("result"); }
};
autoSubmit();
}, [timeExpired]); // eslint-disable-line react-hooks/exhaustive-deps
// ── Handlers ─────────────────────────────────────────────────────────────
const handleStart = useCallback(async () => {
const isAssessment = label === "Assessment";
const hasTimeLimit = isAssessment && (quiz?.time_limit_minutes ?? 0) > 0;
if (isAssessment && onStart) {
setStarting(true);
const session = await onStart();
setStarting(false);
if (!session) return;
sessionRef.current = {
sessionId: session.session_id,
expiresAt: session.expires_at ? new Date(session.expires_at) : null,
};
if (hasTimeLimit && session.remaining_seconds != null) setRemainingSeconds(session.remaining_seconds);
if (session.draft_answers && Object.keys(session.draft_answers).length > 0) {
setAnswers(session.draft_answers);
answersRef.current = session.draft_answers;
const qs = quiz?.questions ?? [];
const firstUnanswered = qs.findIndex(q => !isQuestionAnswered(session.draft_answers[q.question_id]));
setCurrentIndex(firstUnanswered > -1 ? firstUnanswered : 0);
}
}
setStage("taking");
}, [label, quiz?.time_limit_minutes, onStart]);
const handleRetake = () => {
setCurrentIndex(0);
setAnswers({});
setResult(null);
setRemainingSeconds(null);
setTimeExpired(false);
sessionRef.current = { sessionId: null, expiresAt: null };
timerFiredRef.current = false;
setStage("intro");
onRetake?.();
};
const handleSubmit = useCallback(async () => {
// Zod: all questions must be answered before submission is allowed
const schema = buildAnswerSchema(questions);
const stringifiedAnswers = Object.fromEntries(
Object.entries(answers).map(([k, v]) => [String(k), v])
);
const parsed = schema.safeParse(stringifiedAnswers);
if (!parsed.success) {
const unanswered = questions.filter(
(q) => !isQuestionAnswered(answers[q.question_id])
).length;
setSubmitError(`Answer all questions before submitting — ${unanswered} still unanswered.`);
return;
}
setSubmitError(null);
setSubmitting(true);
const res = await onSubmit?.(answers, sessionRef.current.sessionId);
setSubmitting(false);
if (res) { setResult(res); setStage("result"); }
}, [answers, questions, onSubmit]);
const handleOptionClick = (optionId) => {
const question = questions[currentIndex];
const isMulti = question.type === "multi_select";
const multiLimit = isMulti ? (question.correct_count ?? null) : null;
setAnswers((prev) => {
if (!isMulti) return { ...prev, [question.question_id]: optionId };
const current = prev[question.question_id] ?? [];
if (!current.includes(optionId) && multiLimit !== null && current.length >= multiLimit) return prev;
const next = current.includes(optionId)
? current.filter((id) => id !== optionId)
: [...current, optionId];
return { ...prev, [question.question_id]: next };
});
};
const handleGoToReview = () => {
setStage("review");
};
const handleNext = () => {
if (isLast) { handleGoToReview(); return; }
setCurrentIndex((i) => Math.min(i + 1, total - 1));
};
const handlePrev = () => {
if (isFirst) {
if (label !== "Assessment") setStage("intro");
return;
}
setCurrentIndex((i) => Math.max(i - 1, 0));
};
// ── Guards ────────────────────────────────────────────────────────────────
if (!quiz && !loading) {
return (
No {label.toLowerCase()} available yet.
);
}
if (loading) return ;
if (!total) {
return (
This {label.toLowerCase()} doesn't have any questions yet.
);
}
// ── Derived values ────────────────────────────────────────────────────────
const isAssessment = label === "Assessment";
const hasTimeLimit = isAssessment && (quiz?.time_limit_minutes ?? 0) > 0;
const activeSession = quiz?.active_session ?? null;
// ── ASSESSMENT INTRO ──────────────────────────────────────────────────────
if (stage === "intro" && isAssessment) {
const attempts = quiz.attempt_count ?? 0;
const attemptsRemaining = quiz.attempts_remaining ?? null;
const cooldownUntil = quiz.cooldown_until ? new Date(quiz.cooldown_until) : null;
const canAttempt = quiz.can_attempt ?? true;
const attemptsDisplay = attemptsRemaining !== null && quiz.max_attempts
? `${attemptsRemaining} / ${quiz.max_attempts}`
: attemptsRemaining !== null
? `${attemptsRemaining}`
: attempts > 0 ? `${attempts}` : "—";
return (
{/* Header */}
{label}
{quiz.title || label}
{quiz.description && (
{quiz.description}
)}
{quiz.is_required && !quiz.description && (
Required to complete this course.
)}
{/* Stats grid */}
{hasTimeLimit ? `${quiz.time_limit_minutes} min` : "—"}
Time limit
{quiz.passing_score}%
To pass
{attemptsDisplay}
Attempts left
{/* Status banners */}
{quiz.has_passed && (
You've already passed this assessment
Best score: {quiz.best_attempt?.score}%
{quiz.best_attempt?.passing_score != null && (
· passing score {quiz.best_attempt.passing_score}%
)}
)}
{!canAttempt && cooldownUntil && (
You're on cooldown
You can retake this assessment after {fmtDateTime(cooldownUntil)}
)}
{!canAttempt && !cooldownUntil && attemptsRemaining === 0 && (
No attempts remaining
{quiz.window_reset_at
? `You can try again after ${fmtDateTime(quiz.window_reset_at)}`
: "You've used all available attempts for this assessment"}
)}
{activeSession && canAttempt && (
Session in progress
You have an unfinished attempt.{" "}
{activeSession.remaining_seconds != null
? `${formatTime(activeSession.remaining_seconds)} remaining — resume before time runs out.`
: "Resume where you left off."}
)}
{/* Before you begin */}
Before you begin
{hasTimeLimit && (
The timer starts the moment you begin and{" "}
cannot be paused . Your assessment auto-submits when time runs out.
)}
You can skip questions and return to them later. All questions must be answered before you can submit.
Your progress is saved automatically — you can safely resume if you lose connection.
Review all answers on the summary screen before final submission.
{starting
? "Starting…"
: activeSession
? "Resume Assessment"
: attempts > 0
? "Retake Assessment"
: "Begin assessment"}
);
}
// ── QUIZ INTRO ────────────────────────────────────────────────────────────
if (stage === "intro") {
const attempts = quiz.attempt_count ?? 0;
const attemptsRemaining = quiz.attempts_remaining ?? null;
const cooldownUntil = quiz.cooldown_until ? new Date(quiz.cooldown_until) : null;
const canAttempt = quiz.can_attempt ?? true;
const hasActiveSession = !!quiz.active_session;
return (
{quiz.title || label}
{quiz.is_required && (
Required to complete this unit
)}
{quiz.has_passed && !hasActiveSession && (
You've already passed this quiz
Best score: {quiz.best_attempt?.score}%
)}
{hasActiveSession && (
You have an unfinished session
You left this quiz before completing it. Resume to continue from where you left off.
)}
{total}
Question{total === 1 ? "" : "s"}
{quiz.passing_score}%
To pass
{starting ? "Starting…" : hasActiveSession ? "Resume Quiz" : attempts > 0 ? "Retake Quiz" : "Start Quiz"}
);
}
// ── RESULT ────────────────────────────────────────────────────────────────
if (stage === "result" && result) {
return (
{result.passed ? "You passed!" : "You did not pass"}
{result.score}%
{result.earned_points} / {result.total_points} points · passing score {result.passing_score}%
{result.attempt_number && (
Attempt #{result.attempt_number}
)}
{result.passed && onNextContent && nextLabel && (
)}
{!result.passed && (
Retake {label}
)}
);
}
// ── ASSESSMENT REVIEW ─────────────────────────────────────────────────────
if (stage === "review" && isAssessment) {
const answeredCount = questions.filter(q => isQuestionAnswered(answers[q.question_id])).length;
const unansweredCount = total - answeredCount;
return (
Final Step
Review your answers
Once you submit, your answers are final and your score is calculated.
{/* Stats row */}
0 ? "text-amber-600 dark:text-amber-400" : "text-muted-foreground"}`}>
{unansweredCount}
Unanswered
{remainingSeconds !== null ? formatTime(remainingSeconds) : "—"}
Time left
{/* Unanswered warning */}
{unansweredCount > 0 && (
{unansweredCount} question{unansweredCount !== 1 ? "s are" : " is"} still unanswered. {" "}
You can still submit. Tap a highlighted number below to jump back.
)}
{/* All questions grid */}
All questions
{questions.map((q, i) => {
const answered = isQuestionAnswered(answers[q.question_id]);
return (
{ setCurrentIndex(i); setStage("taking"); }}
className={`aspect-square rounded-lg text-sm font-medium transition-colors
${answered
? "bg-primary text-background hover:opacity-80"
: "border-2 border-amber-400 text-amber-600 dark:text-amber-400 hover:bg-amber-50 dark:hover:bg-amber-500/10"
}`}
>
{i + 1}
);
})}
Answered
Unanswered
setStage("taking")} disabled={submitting}>
Back to assessment
{submitting ? "Submitting…" : "Submit assessment"}
{submitError && (
{submitError}
)}
);
}
// ── QUIZ REVIEW ───────────────────────────────────────────────────────────
if (stage === "review") {
return (
{quiz.title || label}
{/* Question pills */}
{questions.map((q, i) => {
const answered = isQuestionAnswered(answers[q.question_id]);
return (
{ setCurrentIndex(i); setStage("taking"); }}
className={`size-9 rounded-full text-sm font-semibold transition-colors
${answered
? "bg-primary text-background hover:opacity-80"
: "bg-muted text-muted-foreground border border-border hover:bg-muted/80"
}`}
>
{i + 1}
);
})}
Review your answers
Check your responses before submitting. Tap any question to go back and change your answer.
{questions.map((q, i) => {
const ans = answers[q.question_id];
const answered = isQuestionAnswered(ans);
const isMultiQ = q.type === "multi_select";
let answerLabel = null;
if (answered) {
if (isMultiQ && Array.isArray(ans)) {
answerLabel = ans
.map(id => {
const idx = (q.options ?? []).findIndex(o => o.option_id === id);
return idx >= 0 ? String.fromCharCode(65 + idx) : null;
})
.filter(Boolean)
.join(", ");
} else {
const idx = (q.options ?? []).findIndex(o => o.option_id === ans);
if (idx >= 0) {
answerLabel = `${String.fromCharCode(65 + idx)}. ${q.options[idx].text}`;
}
}
}
return (
{ setCurrentIndex(i); setStage("taking"); }}
className="w-full flex items-center gap-4 rounded-xl border bg-card p-4 text-left hover:bg-muted/30 transition-colors"
>
{i + 1}
{q.question}
{answerLabel && (
{answerLabel}
)}
{answered ? "Answered" : "Not answered"}
);
})}
setStage("taking")} disabled={submitting}>
Back to questions
{submitting ? "Submitting…" : "Submit quiz"}
{submitError && (
{submitError}
)}
);
}
// ── QUESTION STEPPER (shared derived values) ──────────────────────────────
const question = questions[currentIndex];
const isFirst = currentIndex === 0;
const isLast = currentIndex === total - 1;
const isMulti = question.type === "multi_select";
const selected = answers[question.question_id];
const progress = Math.round(((currentIndex + 1) / total) * 100);
const multiLimit = isMulti ? (question.correct_count ?? null) : null;
const selectedCount = isMulti ? (selected ?? []).length : 0;
const limitReached = multiLimit !== null && selectedCount >= multiLimit;
const isCurrentAnswered = isQuestionAnswered(selected);
const unansweredCount = questions.filter(q => !isQuestionAnswered(answers[q.question_id])).length;
const timerColor = remainingSeconds !== null
? remainingSeconds <= 60
? "text-red-500 dark:text-red-400"
: remainingSeconds <= 300
? "text-amber-500 dark:text-amber-400"
: "text-muted-foreground"
: null;
// ── ASSESSMENT TAKING (2-column layout) ───────────────────────────────────
if (isAssessment) {
const answeredCount = questions.filter(q => isQuestionAnswered(answers[q.question_id])).length;
const optionRows = (question.options ?? []).map((option, i) => {
const letter = String.fromCharCode(65 + i);
const isSelected = isMulti
? (selected ?? []).includes(option.option_id)
: selected === option.option_id;
const isDisabled = (isMulti && limitReached && !isSelected) || (timeExpired && submitting);
const Icon = isMulti
? (isSelected ? CheckSquare2 : Square)
: (isSelected ? CheckCircle2 : Circle);
return (
handleOptionClick(option.option_id)}
disabled={isDisabled}
className={`flex w-full items-center gap-3 rounded-lg border px-3 py-3 text-left text-sm transition-colors sm:text-base
${isSelected ? "border-primary bg-primary/5" : "border-border"}
${isDisabled ? "opacity-40 cursor-not-allowed" : "hover:bg-muted-foreground/5"}`}
>
{letter}
{option.text}
);
});
return (
{/* Assessment updated alert */}
{assessmentUpdatedAlert && (
Assessment Updated
Your administrator has made changes to this assessment. Your current session and saved answers are unaffected.
setAssessmentUpdatedAlert(false)}
className="text-blue-400 hover:text-blue-600 shrink-0 text-xs leading-none mt-0.5"
>✕
)}
{/* ── Right: sidebar — order-first on mobile so timer/progress sit above the question ── */}
{/* Timer */}
{remainingSeconds !== null && (
Time Remaining
{timeExpired ? "00:00" : formatTime(remainingSeconds)}
)}
{/* Progress panel */}
Progress
{answeredCount} / {total} answered
{/* Number grid — scrollable after ~30 questions (3 rows on mobile, 4 on desktop) */}
{questions.map((q, i) => {
const ans = answers[q.question_id];
const answered = isQuestionAnswered(ans);
const isCurrent = i === currentIndex;
return (
setCurrentIndex(i)}
disabled={timeExpired && submitting}
className={`aspect-square rounded text-xs font-medium transition-colors
${isCurrent
? "border-2 border-foreground bg-background text-foreground"
: answered
? "bg-primary text-background hover:opacity-80"
: "border border-border bg-background text-muted-foreground hover:bg-muted"
}`}
>
{i + 1}
);
})}
{/* Legend — desktop only */}
Answered
Not answered
Current
{/* Auto-save indicator */}
All answers saved automatically
{/* Review button */}
0}
>
Review & submit
{unansweredCount > 0 && (
Answer all {total} questions first — {unansweredCount} remaining.
)}
{/* ── Left: question — order-last on mobile so it appears below the compact sidebar ── */}
{/* Question header */}
Question {currentIndex + 1} of {total}
{isMulti ? "Multiple select" : "Multiple choice"}
{/* Question card */}
{currentIndex + 1}. {question.question}
{isMulti
? multiLimit
? `Select ${multiLimit} answers`
: "Select all that apply"
: "Select one answer"}
{isMulti && multiLimit !== null && (
{limitReached
? `${selectedCount} / ${multiLimit} selected — limit reached`
: `${selectedCount} / ${multiLimit} selected`}
)}
{optionRows}
{timeExpired && submitting && (
Time's up — submitting your answers…
)}
{/* Navigation */}
Previous
0)}>
{submitting ? "Submitting…" : isLast ? "Review & submit" : "Next"}
{!submitting && }
);
}
// ── QUIZ TAKING (1-column with pills) ─────────────────────────────────────
return (
{/* Header: title + progress */}
{quiz.title &&
{quiz.title} }
Question {currentIndex + 1} of {total}
{progress}%
{/* Question number pills — all freely clickable; skipped questions show as muted */}
{questions.map((q, i) => {
const ans = answers[q.question_id];
const answered = isQuestionAnswered(ans);
const isCurrent = i === currentIndex;
return (
setCurrentIndex(i)}
disabled={timeExpired && submitting}
className={`size-9 rounded-full text-sm font-semibold transition-colors
${isCurrent
? "border-2 border-primary bg-background text-foreground"
: answered
? "bg-primary text-background hover:opacity-80"
: "bg-muted text-muted-foreground border border-border hover:bg-muted/60"
}`}
>
{i + 1}
);
})}
{timeExpired && submitting && (
Time's up — submitting your answers…
)}
{/* Question card */}
{currentIndex + 1}. {question.question}
{isMulti && multiLimit !== null && (
{limitReached
? `${selectedCount} / ${multiLimit} selected — limit reached`
: `Select ${multiLimit} answer${multiLimit !== 1 ? "s" : ""} · ${selectedCount} / ${multiLimit} selected`}
)}
{(question.options ?? []).map((option, i) => {
const letter = String.fromCharCode(65 + i);
const isSelected = isMulti
? (selected ?? []).includes(option.option_id)
: selected === option.option_id;
const isDisabled = (isMulti && limitReached && !isSelected) || (timeExpired && submitting);
const Icon = isMulti
? (isSelected ? CheckSquare2 : Square)
: (isSelected ? CheckCircle2 : Circle);
return (
handleOptionClick(option.option_id)}
disabled={isDisabled}
className={`flex w-full items-center gap-3 rounded-lg border px-3 py-2.5 text-left text-sm transition-colors sm:text-base
${isSelected ? "border-primary bg-primary/5" : "border-border"}
${isDisabled ? "opacity-40 cursor-not-allowed" : "hover:bg-muted-foreground/5"}`}
>
{letter}
{option.text}
);
})}
{/* Navigation */}
Previous
0)}>
{submitting ? "Submitting…" : isLast ? "Review answers" : "Next"}
{!submitting && }
{isLast && unansweredCount > 0 && (
Answer all {total} questions first — {unansweredCount} remaining.
)}
);
};
export default QuizBlock;