511 lines
18 KiB
TypeScript
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>
|
|
);
|
|
}
|