"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); // 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; } // 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]); // Persist progress as you go function saveProgress( idx: number, ans: Record ) { if (retakeIds || isShared) return; // Don't persist retake partial state or shared mode into the main progress row 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 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); }} onClose={onFinished} /> ); } if (!currentQ) { return
Question not found.
; } return (
{/* Running Score Header */}
Progress: {currentIndex + 1} / {order.length}
Score so far: {parseFloat(runningScore.toFixed(2))} / {maxPossibleSoFar}
{/* Question Card */}
{/* Header (Type & Category) */}
{currentQ.type === "MULTIPLE_CHOICE" ? "Multiple Choice" : "Select All That Apply"} {currentQ.category}
{currentQ.prompt}
{(shuffledOptions[currentQId] || currentQ.options).map((opt) => { const isSelected = currentSelections.includes(opt.id); let stateClass = "border-border hover:border-primary/50 cursor-pointer"; let icon = null; if (isSubmitted) { stateClass = "cursor-default opacity-80"; if (opt.isCorrect) { stateClass = "border-success bg-success-bg/30 text-text-heading"; icon = ( ); } else if (isSelected && !opt.isCorrect) { // Selected incorrectly stateClass = "border-error bg-error-bg/30 text-text-heading"; icon = ( ); } else { // Not selected, not correct stateClass = "border-border-light bg-bg-surface-alt/50"; } } else if (isSelected) { stateClass = "border-primary bg-primary/5 ring-1 ring-primary"; } return (
toggleOption(opt.id)} className={`flex items-start gap-4 p-4 rounded-xl border-2 transition-all duration-200 ${stateClass}`} >
{isSelected && ( )}
{opt.text}
{icon && (
{icon}
)}
); })}
{/* 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 (
{/* Result Banner */}
{icon} {badgeText}
{/* Rationale Box */}

Rationale

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