"use client"; import { useState, useEffect } from "react"; import ReactMarkdown from "react-markdown"; import remarkGfm from "remark-gfm"; import { scoreQuestion } from "@/lib/scoring"; import { QuizResults } from "./QuizResults"; interface Option { id: string; text: string; isCorrect: boolean; } interface Question { id: string; type: string; prompt: string; rationale: string; category: string; options: Option[]; } interface QuizViewerProps { quiz: { id: string; name: string; questions: Question[]; progress: Array<{ mode: string; currentIndex: number; orderJson: string; answersJson: string | null; }>; }; retakeIds: string[] | null; isShared?: boolean; onFinished?: () => void; } export function QuizViewer({ quiz, retakeIds, isShared = false, onFinished }: QuizViewerProps) { const [order, setOrder] = useState([]); const [currentIndex, setCurrentIndex] = useState(0); const [answers, setAnswers] = useState>({}); const [submittedAnswers, setSubmittedAnswers] = useState([]); const [shuffledOptions, setShuffledOptions] = useState>({}); const [loading, setLoading] = useState(true); const [resultsData, setResultsData] = useState(null); const [toastMessage, setToastMessage] = useState(null); // Auto-hide toast useEffect(() => { if (toastMessage) { const timer = setTimeout(() => setToastMessage(null), 3000); return () => clearTimeout(timer); } }, [toastMessage]); // Initialize session useEffect(() => { // Generate shuffled options once per session mount const newShuffledOpts: Record = {}; for (const q of quiz.questions) { newShuffledOpts[q.id] = [...q.options].sort(() => Math.random() - 0.5); } setShuffledOptions(newShuffledOpts); // If retaking specific questions if (retakeIds && retakeIds.length > 0) { setOrder(retakeIds); setCurrentIndex(0); setAnswers({}); setSubmittedAnswers([]); setLoading(false); return; } // Shared links: load from localStorage if (isShared) { try { const saved = localStorage.getItem(`quiz_progress_${quiz.id}`); if (saved) { const parsed = JSON.parse(saved); setOrder(parsed.order || quiz.questions.map(q => q.id)); setCurrentIndex(parsed.currentIndex || 0); setAnswers(parsed.answers || {}); setSubmittedAnswers(Object.keys(parsed.answers || {})); setToastMessage("Session restored"); setLoading(false); return; } } catch (e) { // Fallback to defaults } } // Otherwise, normal sequential logic checking for progress const prog = quiz.progress?.find((p) => p.mode === "SEQUENTIAL"); if (prog) { let savedOrder: string[] = []; try { savedOrder = JSON.parse(prog.orderJson); } catch { savedOrder = quiz.questions.map((q) => q.id); } let savedAnswers: Record = {}; if (prog.answersJson) { try { savedAnswers = JSON.parse(prog.answersJson); } catch {} } setOrder(savedOrder); setCurrentIndex(Math.min(prog.currentIndex, savedOrder.length - 1)); setAnswers(savedAnswers); setSubmittedAnswers(Object.keys(savedAnswers)); } else { const freshOrder = quiz.questions.map((q) => q.id).sort(() => Math.random() - 0.5); setOrder(freshOrder); setCurrentIndex(0); setAnswers({}); setSubmittedAnswers([]); } setLoading(false); }, [quiz, retakeIds, isShared]); // Persist progress as you go function saveProgress( idx: number, ans: Record ) { if (retakeIds) return; // Don't persist retake partial state if (isShared) { localStorage.setItem(`quiz_progress_${quiz.id}`, JSON.stringify({ currentIndex: idx, order: order, answers: ans, })); return; } fetch("/api/progress", { method: "PATCH", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ contentType: "QUIZ", contentId: quiz.id, mode: "SEQUENTIAL", currentIndex: idx, orderJson: JSON.stringify(order), answersJson: JSON.stringify(ans), }), }).catch(() => {}); } if (loading) return
Loading quiz...
; const currentQId = order[currentIndex]; const currentQ = quiz.questions.find((q) => q.id === currentQId); const isSubmitted = currentQId ? submittedAnswers.includes(currentQId) : false; const currentSelections = currentQId ? answers[currentQId] || [] : []; // Compute running score let runningScore = 0; let maxPossibleSoFar = 0; submittedAnswers.forEach((qId) => { const q = quiz.questions.find((x) => x.id === qId); if (!q) return; maxPossibleSoFar += 1; // 1 point per question const formattedQ = { id: q.id, type: q.type as "MULTIPLE_CHOICE" | "SATA", options: q.options, }; runningScore += scoreQuestion(formattedQ, answers[qId] || []); }); function toggleOption(optId: string) { if (isSubmitted || !currentQId || !currentQ) return; if (currentQ.type === "MULTIPLE_CHOICE") { setAnswers({ ...answers, [currentQId]: [optId] }); } else { const prev = answers[currentQId] || []; const updated = prev.includes(optId) ? prev.filter((id) => id !== optId) : [...prev, optId]; setAnswers({ ...answers, [currentQId]: updated }); } } function handleSubmit() { if (isSubmitted || !currentQId) return; const newSubmitted = [...submittedAnswers, currentQId]; setSubmittedAnswers(newSubmitted); saveProgress(currentIndex, answers); } function handleNext() { if (currentIndex + 1 < order.length) { const nextIdx = currentIndex + 1; setCurrentIndex(nextIdx); saveProgress(nextIdx, answers); } } async function handleFinish() { if (isShared) { localStorage.removeItem(`quiz_progress_${quiz.id}`); } // If shared, grade it locally and show results, don't ping backend if (isShared) { // Simulate an attempt object let runningScore = 0; let maxScore = quiz.questions.length; // Actually, depends on retakeIds, but shared doesn't support retakeIds typically if (retakeIds) maxScore = retakeIds.length; const scoredItems = (retakeIds || quiz.questions.map(q => q.id)).map(qId => { const q = quiz.questions.find((x) => x.id === qId); if (!q) return 0; const formattedQ = { id: q.id, type: q.type as "MULTIPLE_CHOICE" | "SATA", options: q.options, }; return scoreQuestion(formattedQ, answers[qId] || []); }); runningScore = scoredItems.reduce((acc, curr) => acc + curr, 0); const attempt = { id: "shared-attempt", score: runningScore, maxScore: maxScore, answersJson: JSON.stringify(answers), isPartialRetake: false, completedAt: new Date().toISOString(), }; setResultsData(attempt); return; } // Send to backend const isPartialRetake = !!retakeIds; try { const res = await fetch(`/api/quizzes/${quiz.id}/attempt`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ answersJson: JSON.stringify(answers), isPartialRetake, }), }); if (res.ok) { const attempt = await res.json(); // Clear progress if (!isPartialRetake) { await fetch("/api/progress", { method: "DELETE", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ contentType: "QUIZ", contentId: quiz.id, mode: "SEQUENTIAL", }), }); } setResultsData(attempt); } } catch (e) { console.error(e); } } if (resultsData) { return ( { // Restart this component with retakeIds setOrder(missedIds); setCurrentIndex(0); setAnswers({}); setSubmittedAnswers([]); setResultsData(null); // Re-shuffle options for the new attempt const newShuffledOpts: Record = {}; for (const q of quiz.questions) { newShuffledOpts[q.id] = [...q.options].sort(() => Math.random() - 0.5); } setShuffledOptions(newShuffledOpts); }} onClose={onFinished} /> ); } if (!currentQ) { return
Question not found.
; } return (
{/* Toast Notification */} {toastMessage && (
{toastMessage}
)} {/* Main Unified Card */}
{/* Progress Header */}
Q {currentIndex + 1} / {order.length}
Score: {parseFloat(runningScore.toFixed(2))} / {maxPossibleSoFar}
{/* Question Area */}
{/* Tags */}
{currentQ.category} {currentQ.type === "MULTIPLE_CHOICE" ? "Multiple Choice" : "Select All That Apply"}
{/* Prompt */}
{currentQ.prompt}
{/* Options */}
{(shuffledOptions[currentQId] || currentQ.options).map((opt) => { const isSelected = currentSelections.includes(opt.id); let stateClass = "border-border-light hover:border-border cursor-pointer"; let rightIcon = null; if (isSubmitted) { stateClass = "cursor-default opacity-80"; if (isSelected && opt.isCorrect) { stateClass = "border-success bg-success-bg text-text-heading"; } else if (isSelected && !opt.isCorrect) { stateClass = "border-error bg-error-bg text-text-heading"; } else if (!isSelected && opt.isCorrect) { stateClass = "border-success/50 bg-success/5 text-text-heading"; rightIcon = (
Correct Answer
); } else { stateClass = "border-border-light"; } } else if (isSelected) { stateClass = "border-primary bg-primary/5"; } return (
toggleOption(opt.id)} className={`flex min-h-14 items-center gap-4 rounded-2xl border px-4 py-4 transition-all duration-200 md:px-5 ${stateClass}`} >
{isSelected && ( {isSubmitted && !opt.isCorrect ? ( ) : ( )} )}
{opt.text}
{rightIcon && (
{rightIcon}
)}
); })}
{/* Rationale shown after submission */} {isSubmitted && (() => { const formattedQ = { id: currentQ.id, type: currentQ.type as "MULTIPLE_CHOICE" | "SATA", options: currentQ.options, }; const qScore = scoreQuestion(formattedQ, currentSelections); let badgeText = "Incorrect"; let bannerBg = "bg-error/10 border-error/20 text-error"; let icon = ( ); if (qScore === 1) { badgeText = "Correct"; bannerBg = "bg-success/10 border-success/20 text-success"; icon = ( ); } else if (qScore > 0) { badgeText = "Partially Correct"; bannerBg = "bg-amber-500/10 border-amber-500/20 text-amber-600"; icon = ( ); } return (
{icon} {badgeText}

Rationale

{currentQ.rationale}
); })()} {/* Action Button */}
{!isSubmitted ? ( ) : currentIndex + 1 < order.length ? ( ) : ( )}
); }