// 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 */}

{total}

Questions

{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.
); } // ── 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

); } // ── 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 && (
Up next {nextLabel}
)} {!result.passed && (
)}
); } // ── 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 */}

{answeredCount}

Answered

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 ( ); })}
Answered Unanswered
{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 ( ); })}

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 ( ); })}
{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 ( ); }); return (
{/* Assessment updated alert */} {assessmentUpdatedAlert && (

Assessment Updated

Your administrator has made changes to this assessment. Your current session and saved answers are unaffected.

)}
{/* ── 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 ( ); })}
{/* Legend — desktop only */}
Answered Not answered Current
{/* Auto-save indicator */}
All answers saved automatically
{/* Review button */} {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 */}
); } // ── 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 ( ); })}
{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 ( ); })}
{/* Navigation */}
{isLast && unansweredCount > 0 && (

Answer all {total} questions first — {unansweredCount} remaining.

)}
); }; export default QuizBlock;