Some checks failed
Automated Container Build / build-and-push (push) Failing after 7s
151 lines
6.1 KiB
TypeScript
151 lines
6.1 KiB
TypeScript
"use client";
|
|
|
|
import ReactMarkdown from "react-markdown";
|
|
import remarkGfm from "remark-gfm";
|
|
import { CategoryBreakdown } from "./CategoryBreakdown";
|
|
import { scoreQuestion } from "@/lib/scoring";
|
|
|
|
interface QuizResultsProps {
|
|
quiz: any; // QuizData
|
|
attempt: any; // QuizAttempt
|
|
onRetake: (missedIds: string[]) => void;
|
|
onClose: () => void;
|
|
}
|
|
|
|
export function QuizResults({ quiz, attempt, onRetake, onClose }: QuizResultsProps) {
|
|
let answers: Record<string, string[]> = {};
|
|
try {
|
|
answers = JSON.parse(attempt.answersJson);
|
|
} catch {}
|
|
|
|
// Determine non-full-credit questions for review list
|
|
const reviewItems: any[] = [];
|
|
const missedIds: string[] = [];
|
|
|
|
const answeredIds = Object.keys(answers);
|
|
const questionsToReview = quiz.questions.filter((q: any) => answeredIds.includes(q.id));
|
|
|
|
questionsToReview.forEach((q: any) => {
|
|
const formattedQ = {
|
|
id: q.id,
|
|
type: q.type,
|
|
options: q.options,
|
|
};
|
|
const score = scoreQuestion(formattedQ, answers[q.id] || []);
|
|
|
|
// If not full credit (1.0), add to review and missed arrays
|
|
if (score < 1) {
|
|
missedIds.push(q.id);
|
|
reviewItems.push({
|
|
question: q,
|
|
score,
|
|
selections: answers[q.id] || []
|
|
});
|
|
}
|
|
});
|
|
|
|
const percentage = attempt.maxScore > 0 ? (attempt.score / attempt.maxScore) * 100 : 0;
|
|
|
|
return (
|
|
<div className="max-w-3xl mx-auto space-y-8 animate-fade-in pb-12">
|
|
|
|
{/* Top Banner */}
|
|
<div className="bg-bg-surface rounded-2xl border border-border-light shadow-[var(--shadow-card)] p-8 text-center relative overflow-hidden">
|
|
{percentage >= 80 && (
|
|
<div className="absolute top-0 left-0 w-full h-2 bg-success"></div>
|
|
)}
|
|
{percentage < 80 && percentage >= 60 && (
|
|
<div className="absolute top-0 left-0 w-full h-2 bg-primary"></div>
|
|
)}
|
|
{percentage < 60 && (
|
|
<div className="absolute top-0 left-0 w-full h-2 bg-error"></div>
|
|
)}
|
|
|
|
<h2 className="text-2xl font-bold text-text-heading mb-2">Quiz Complete</h2>
|
|
{attempt.isPartialRetake && (
|
|
<p className="text-sm text-text-secondary mb-4">(Partial Retake)</p>
|
|
)}
|
|
|
|
<div className="flex justify-center items-end gap-2 mb-6">
|
|
<span className="text-5xl font-black text-text-heading">{Math.round(percentage)}%</span>
|
|
<span className="text-lg text-text-muted mb-1 font-medium">({attempt.score.toFixed(2)} / {attempt.maxScore})</span>
|
|
</div>
|
|
|
|
<div className="flex justify-center gap-4">
|
|
<button
|
|
onClick={onClose}
|
|
className="px-6 py-2.5 rounded-lg border border-border text-text-heading font-medium hover:bg-bg-surface-alt transition-all duration-200 cursor-pointer"
|
|
>
|
|
Back to Quizzes
|
|
</button>
|
|
|
|
{missedIds.length > 0 && (
|
|
<button
|
|
onClick={() => onRetake(missedIds)}
|
|
className="px-6 py-2.5 rounded-lg bg-primary text-white font-medium hover:bg-primary-hover transition-all duration-200 cursor-pointer"
|
|
>
|
|
Retake Missed ({missedIds.length})
|
|
</button>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Category Breakdown */}
|
|
<div className="bg-bg-surface rounded-2xl border border-border-light shadow-[var(--shadow-card)] p-6 md:p-8">
|
|
<h3 className="text-lg font-bold text-text-heading mb-6">Category Breakdown</h3>
|
|
<CategoryBreakdown quiz={quiz} attempt={attempt} answeredIds={answeredIds} answers={answers} />
|
|
</div>
|
|
|
|
{/* Review List */}
|
|
{reviewItems.length > 0 && (
|
|
<div className="space-y-6">
|
|
<h3 className="text-xl font-bold text-text-heading px-2">Areas for Review</h3>
|
|
|
|
{reviewItems.map((item, idx) => {
|
|
const q = item.question;
|
|
const sels = item.selections;
|
|
return (
|
|
<div key={q.id} className="bg-bg-surface rounded-2xl border border-border-light shadow-sm overflow-hidden">
|
|
<div className="bg-error-bg/30 border-b border-error/20 px-6 py-3 flex items-center justify-between">
|
|
<span className="text-sm font-medium text-error">Score: {item.score.toFixed(2)} / 1</span>
|
|
<span className="text-xs font-bold uppercase tracking-wider text-text-muted">{q.category}</span>
|
|
</div>
|
|
<div className="p-6">
|
|
<div className="markdown-content text-text-heading mb-6 font-medium">
|
|
<ReactMarkdown remarkPlugins={[remarkGfm]}>{q.prompt}</ReactMarkdown>
|
|
</div>
|
|
|
|
<div className="space-y-2 mb-6">
|
|
{q.options.map((opt: any) => {
|
|
const isSelected = sels.includes(opt.id);
|
|
let style = "text-text-body";
|
|
if (opt.isCorrect) style = "text-success font-medium bg-success-bg/50 border-success/30";
|
|
else if (isSelected && !opt.isCorrect) style = "text-error font-medium line-through bg-error-bg/50 border-error/30";
|
|
else style = "border-transparent";
|
|
|
|
return (
|
|
<div key={opt.id} className={`flex items-start gap-3 p-3 rounded-lg border ${style}`}>
|
|
<div className="pt-0.5">
|
|
{opt.isCorrect ? "✓" : (isSelected ? "✗" : "•")}
|
|
</div>
|
|
<div>{opt.text}</div>
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
|
|
<div className="bg-bg-callout border border-primary/20 rounded-xl p-5">
|
|
<h4 className="text-xs font-bold text-text-heading uppercase tracking-wider mb-2">Rationale</h4>
|
|
<div className="markdown-content text-sm text-text-body">
|
|
<ReactMarkdown remarkPlugins={[remarkGfm]}>{q.rationale}</ReactMarkdown>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|