Study/src/components/quizzes/QuizViewer.tsx
2026-07-11 13:48:02 -07:00

511 lines
18 KiB
TypeScript

"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<string[]>([]);
const [currentIndex, setCurrentIndex] = useState(0);
const [answers, setAnswers] = useState<Record<string, string[]>>({});
const [submittedAnswers, setSubmittedAnswers] = useState<string[]>([]);
const [shuffledOptions, setShuffledOptions] = useState<Record<string, Option[]>>({});
const [loading, setLoading] = useState(true);
const [resultsData, setResultsData] = useState<any | null>(null);
const [toastMessage, setToastMessage] = useState<string | null>(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<string, Option[]> = {};
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<string, string[]> = {};
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<string, string[]>
) {
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 <div>Loading quiz...</div>;
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 (
<QuizResults
quiz={quiz}
attempt={resultsData}
onRetake={(missedIds: string[]) => {
// Restart this component with retakeIds
setOrder(missedIds);
setCurrentIndex(0);
setAnswers({});
setSubmittedAnswers([]);
setResultsData(null);
// Re-shuffle options for the new attempt
const newShuffledOpts: Record<string, Option[]> = {};
for (const q of quiz.questions) {
newShuffledOpts[q.id] = [...q.options].sort(() => Math.random() - 0.5);
}
setShuffledOptions(newShuffledOpts);
}}
onClose={onFinished}
/>
);
}
if (!currentQ) {
return <div>Question not found.</div>;
}
return (
<div className="flex flex-col items-center relative">
{/* Toast Notification */}
{toastMessage && (
<div className="absolute top-4 left-1/2 -translate-x-1/2 z-30 animate-fade-in pointer-events-none">
<div className="bg-bg-surface-alt/95 backdrop-blur shadow-[var(--shadow-card)] text-text-heading text-sm font-semibold px-6 py-2.5 rounded-full whitespace-nowrap">
{toastMessage}
</div>
</div>
)}
{/* Main Unified Card */}
<div className="mb-8 mt-4 w-full max-w-4xl overflow-hidden rounded-[2rem] border border-border-light bg-bg-surface shadow-[var(--shadow-card-hover)] md:mt-8">
{/* Progress Header */}
<div className="flex items-center gap-4 border-b border-border-light bg-bg-surface-alt/55 px-5 py-4 md:px-8">
<div className="text-sm font-mono text-text-secondary whitespace-nowrap tracking-wider">
Q {currentIndex + 1} / {order.length}
</div>
<div className="flex-1 h-2 bg-bg-surface rounded-full overflow-hidden border border-border-light/50">
<div
className="h-full bg-primary transition-all duration-300"
style={{ width: `${(currentIndex / order.length) * 100}%` }}
/>
</div>
<div className="bg-badge-bg/50 px-3 py-1 rounded-full text-sm font-mono text-primary whitespace-nowrap">
Score: {parseFloat(runningScore.toFixed(2))} / {maxPossibleSoFar}
</div>
</div>
{/* Question Area */}
<div className="px-5 pb-6 pt-6 md:px-10 md:pb-10 md:pt-8">
{/* Tags */}
<div className="flex items-center justify-between mb-8">
<span className="inline-flex px-3 py-1 rounded-md bg-primary/10 text-primary text-xs font-bold uppercase tracking-wider">
{currentQ.category}
</span>
<span className="inline-flex px-3 py-1 rounded-md bg-amber-500/10 text-amber-600 text-xs font-bold uppercase tracking-wider">
{currentQ.type === "MULTIPLE_CHOICE" ? "Multiple Choice" : "Select All That Apply"}
</span>
</div>
{/* Prompt */}
<div className="markdown-content editorial-title mb-8 text-2xl leading-relaxed text-text-heading md:text-3xl">
<ReactMarkdown remarkPlugins={[remarkGfm]}>
{currentQ.prompt}
</ReactMarkdown>
</div>
{/* Options */}
<div className="space-y-4">
{(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 = (
<div className="text-success flex items-center gap-1.5 text-sm font-bold">
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2.5} d="M5 13l4 4L19 7" />
</svg>
Correct Answer
</div>
);
} else {
stateClass = "border-border-light";
}
} else if (isSelected) {
stateClass = "border-primary bg-primary/5";
}
return (
<div
key={opt.id}
onClick={() => 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}`}
>
<div className="flex-shrink-0">
<div className={`w-5 h-5 rounded ${currentQ.type === 'SATA' ? 'border' : 'border rounded-full'} flex items-center justify-center transition-colors ${
isSelected && isSubmitted && opt.isCorrect ? 'border-success bg-success' :
isSelected && isSubmitted && !opt.isCorrect ? 'border-error bg-error' :
isSelected && !isSubmitted ? 'border-primary bg-primary' :
'border-border bg-bg-surface'
}`}>
{isSelected && (
<svg className="w-3.5 h-3.5 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
{isSubmitted && !opt.isCorrect ? (
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={3} d="M6 18L18 6M6 6l12 12" />
) : (
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={3} d="M5 13l4 4L19 7" />
)}
</svg>
)}
</div>
</div>
<div className="flex-1 text-text-body font-medium leading-snug">
{opt.text}
</div>
{rightIcon && (
<div className="flex-shrink-0 pl-2">
{rightIcon}
</div>
)}
</div>
);
})}
</div>
{/* 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 = (
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2.5} d="M6 18L18 6M6 6l12 12" />
</svg>
);
if (qScore === 1) {
badgeText = "Correct";
bannerBg = "bg-success/10 border-success/20 text-success";
icon = (
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2.5} d="M5 13l4 4L19 7" />
</svg>
);
} else if (qScore > 0) {
badgeText = "Partially Correct";
bannerBg = "bg-amber-500/10 border-amber-500/20 text-amber-600";
icon = (
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
</svg>
);
}
return (
<div className="mt-8 flex flex-col gap-4 animate-slide-up">
<div className={`flex items-center gap-3 px-5 py-4 rounded-xl border ${bannerBg} font-bold text-lg tracking-wide shadow-sm`}>
{icon}
{badgeText}
</div>
<div className="bg-bg-callout border border-primary/20 rounded-xl p-5 shadow-sm">
<h4 className="text-sm font-bold text-text-heading uppercase tracking-wider mb-2">Rationale</h4>
<div className="markdown-content text-sm text-text-body">
<ReactMarkdown remarkPlugins={[remarkGfm]}>
{currentQ.rationale}
</ReactMarkdown>
</div>
</div>
</div>
);
})()}
{/* Action Button */}
<div className="sticky bottom-3 z-10 mt-10 flex rounded-2xl bg-bg-surface/90 p-1 backdrop-blur md:static md:bg-transparent md:p-0">
{!isSubmitted ? (
<button
onClick={handleSubmit}
disabled={currentSelections.length === 0}
className="w-full py-3.5 rounded-xl bg-primary hover:bg-primary-hover text-white font-bold text-lg tracking-wide disabled:opacity-50 disabled:bg-primary/50 disabled:hover:bg-primary/50 disabled:cursor-not-allowed transition-all duration-200 shadow-sm"
>
Submit Answer
</button>
) : currentIndex + 1 < order.length ? (
<button
onClick={handleNext}
className="w-full py-3.5 rounded-xl bg-primary hover:bg-primary-hover text-white font-bold text-lg tracking-wide transition-all duration-200 cursor-pointer shadow-sm"
>
Next Question
</button>
) : (
<button
onClick={handleFinish}
className="w-full py-3.5 rounded-xl bg-success hover:bg-success/90 text-white font-bold text-lg tracking-wide transition-all duration-200 cursor-pointer shadow-sm"
>
Finish Quiz
</button>
)}
</div>
</div>
</div>
</div>
);
}