2026-03-29 00:32:24 -04:00

437 lines
14 KiB
TypeScript

"use client";
import { useEffect, useState, useCallback } from "react";
import { useRouter } from "next/navigation";
import { Button } from "@/components/ui/button";
import {
Card,
CardContent,
CardDescription,
CardFooter,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
import { Progress } from "@/components/ui/progress";
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
import { Badge } from "@/components/ui/badge";
import { Label } from "@/components/ui/label";
interface QuizOption {
value: string;
label: string;
}
interface QuizQuestion {
id: string;
type: string;
paragraphText: string;
question: string;
options: QuizOption[];
correctAnswer: string;
explanation: string;
}
interface AnswerFeedback {
correct: boolean;
correctAnswer: string;
explanation: string;
score?: number;
passed?: boolean;
completed: boolean;
}
interface AnswerRecord {
questionId: string;
correct: boolean;
selectedAnswer: string;
correctAnswer: string;
explanation: string;
}
type QuizPhase = "loading" | "ready" | "active" | "feedback" | "results";
const TYPE_LABELS: Record<string, string> = {
"person-vs-function": "Person vs. Function",
"materiality-disclaimer": "Materiality Disclaimer",
"qv-counting": "QV Fact Counting",
"spac-exception": "SPAC Exception",
};
export default function QuizPage() {
const router = useRouter();
const [phase, setPhase] = useState<QuizPhase>("loading");
const [questions, setQuestions] = useState<QuizQuestion[]>([]);
const [quizSessionId, setQuizSessionId] = useState<string>("");
const [currentIndex, setCurrentIndex] = useState(0);
const [selectedAnswer, setSelectedAnswer] = useState<string>("");
const [feedback, setFeedback] = useState<AnswerFeedback | null>(null);
const [answers, setAnswers] = useState<AnswerRecord[]>([]);
const [finalScore, setFinalScore] = useState<number>(0);
const [finalPassed, setFinalPassed] = useState<boolean>(false);
const [submitting, setSubmitting] = useState(false);
useEffect(() => {
async function checkStatus() {
const res = await fetch("/api/quiz");
if (!res.ok) {
router.push("/login");
return;
}
const data = await res.json();
if (data.hasPassedQuiz) {
router.push("/label");
return;
}
setPhase("ready");
}
checkStatus();
}, [router]);
const startQuiz = useCallback(async () => {
const res = await fetch("/api/quiz", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ action: "start" }),
});
if (!res.ok) return;
const data = await res.json();
setQuizSessionId(data.quizSessionId);
setQuestions(data.questions);
setCurrentIndex(0);
setSelectedAnswer("");
setFeedback(null);
setAnswers([]);
setPhase("active");
}, []);
const submitAnswer = useCallback(async () => {
if (!selectedAnswer || submitting) return;
setSubmitting(true);
const question = questions[currentIndex];
const res = await fetch("/api/quiz", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
action: "answer",
quizSessionId,
questionId: question.id,
answer: selectedAnswer,
}),
});
if (!res.ok) {
setSubmitting(false);
return;
}
const data: AnswerFeedback = await res.json();
setFeedback(data);
setAnswers((prev) => [
...prev,
{
questionId: question.id,
correct: data.correct,
selectedAnswer,
correctAnswer: data.correctAnswer,
explanation: data.explanation,
},
]);
if (data.completed) {
setFinalScore(data.score!);
setFinalPassed(data.passed!);
}
setPhase("feedback");
setSubmitting(false);
}, [selectedAnswer, submitting, questions, currentIndex, quizSessionId]);
const nextQuestion = useCallback(() => {
if (feedback?.completed) {
setPhase("results");
return;
}
setCurrentIndex((prev) => prev + 1);
setSelectedAnswer("");
setFeedback(null);
setPhase("active");
}, [feedback]);
if (phase === "loading") {
return (
<div className="flex min-h-screen items-center justify-center">
<p className="text-muted-foreground">Checking quiz status...</p>
</div>
);
}
if (phase === "ready") {
return (
<div className="flex min-h-screen items-center justify-center p-4">
<Card className="w-full max-w-2xl">
<CardHeader>
<CardTitle className="text-2xl">Labeling Quiz</CardTitle>
<CardDescription>
Before you begin labeling, you must pass a short quiz testing your
knowledge of the labeling codebook. You need at least 7 out of 8
correct answers to pass.
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="space-y-2 text-sm text-muted-foreground">
<p>The quiz covers four question types:</p>
<ul className="list-disc pl-6 space-y-1">
<li>
<span className="font-medium text-foreground">
Person vs. Function
</span>{" "}
&mdash; Distinguishing management role descriptions from risk
management process descriptions
</li>
<li>
<span className="font-medium text-foreground">
Materiality Disclaimers
</span>{" "}
&mdash; Identifying materiality assessments vs. cross-references
</li>
<li>
<span className="font-medium text-foreground">
QV Fact Counting
</span>{" "}
&mdash; Determining specificity levels based on verifiable facts
</li>
<li>
<span className="font-medium text-foreground">
SPAC Exceptions
</span>{" "}
&mdash; Recognizing shell company / SPAC disclosures
</li>
</ul>
</div>
</CardContent>
<CardFooter>
<Button onClick={startQuiz} className="w-full" size="lg">
Start Quiz
</Button>
</CardFooter>
</Card>
</div>
);
}
if (phase === "active" || phase === "feedback") {
const question = questions[currentIndex];
const progress = ((currentIndex + (phase === "feedback" ? 1 : 0)) / questions.length) * 100;
return (
<div className="flex min-h-screen items-center justify-center p-4">
<Card className="w-full max-w-2xl">
<CardHeader>
<div className="flex items-center justify-between">
<CardTitle className="text-lg">
Question {currentIndex + 1} of {questions.length}
</CardTitle>
<Badge variant="outline">
{TYPE_LABELS[question.type] ?? question.type}
</Badge>
</div>
<Progress value={progress} className="mt-2" />
</CardHeader>
<CardContent className="space-y-6">
<div className="rounded-md border bg-muted/50 p-4">
<p className="text-sm leading-relaxed font-serif">
{question.paragraphText}
</p>
</div>
<p className="font-medium">{question.question}</p>
<RadioGroup
value={selectedAnswer}
onValueChange={phase === "active" ? setSelectedAnswer : undefined}
disabled={phase === "feedback"}
className="space-y-3"
>
{question.options.map((option) => {
let optionClass = "";
if (phase === "feedback" && feedback) {
if (option.value === feedback.correctAnswer) {
optionClass =
"border-green-500 bg-green-50 dark:bg-green-950/30";
} else if (
option.value === selectedAnswer &&
!feedback.correct
) {
optionClass = "border-red-500 bg-red-50 dark:bg-red-950/30";
}
}
return (
<div
key={option.value}
className={`flex items-center space-x-3 rounded-md border p-3 transition-colors ${optionClass}`}
>
<RadioGroupItem
value={option.value}
id={`option-${option.value}`}
/>
<Label
htmlFor={`option-${option.value}`}
className="flex-1 cursor-pointer"
>
{option.label}
</Label>
</div>
);
})}
</RadioGroup>
{phase === "feedback" && feedback && (
<Alert variant={feedback.correct ? "default" : "destructive"}>
<AlertTitle>
{feedback.correct ? "Correct!" : "Incorrect"}
</AlertTitle>
<AlertDescription className="mt-2 text-sm leading-relaxed">
{feedback.explanation}
</AlertDescription>
</Alert>
)}
</CardContent>
<CardFooter>
{phase === "active" && (
<Button
onClick={submitAnswer}
disabled={!selectedAnswer || submitting}
className="w-full"
>
{submitting ? "Submitting..." : "Submit Answer"}
</Button>
)}
{phase === "feedback" && (
<Button onClick={nextQuestion} className="w-full">
{feedback?.completed ? "See Results" : "Next Question"}
</Button>
)}
</CardFooter>
</Card>
</div>
);
}
if (phase === "results") {
const incorrectAnswers = answers.filter((a) => !a.correct);
return (
<div className="flex min-h-screen items-center justify-center p-4">
<Card className="w-full max-w-2xl">
<CardHeader>
<CardTitle className="text-2xl">Quiz Results</CardTitle>
<CardDescription>
You scored{" "}
<span className="font-bold text-foreground">
{finalScore}/{questions.length}
</span>{" "}
&mdash;{" "}
<span
className={
finalPassed
? "font-bold text-green-600 dark:text-green-400"
: "font-bold text-red-600 dark:text-red-400"
}
>
{finalPassed ? "Passed!" : "Failed"}
</span>
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
{finalPassed ? (
<Alert>
<AlertTitle>Great work!</AlertTitle>
<AlertDescription>
You demonstrated strong knowledge of the labeling codebook.
You may now proceed to labeling.
</AlertDescription>
</Alert>
) : (
<>
<Alert variant="destructive">
<AlertTitle>Not quite there</AlertTitle>
<AlertDescription>
You need at least 7 out of 8 correct to pass. Review the
explanations below and try again.
</AlertDescription>
</Alert>
{incorrectAnswers.length > 0 && (
<div className="space-y-3">
<p className="font-medium text-sm">
Questions you got wrong:
</p>
{incorrectAnswers.map((a) => {
const question = questions.find(
(q) => q.id === a.questionId,
);
if (!question) return null;
return (
<div
key={a.questionId}
className="rounded-md border p-3 space-y-2"
>
<div className="flex items-center gap-2">
<Badge variant="outline">
{TYPE_LABELS[question.type] ?? question.type}
</Badge>
</div>
<p className="text-sm text-muted-foreground font-serif">
{question.paragraphText.slice(0, 150)}
{question.paragraphText.length > 150 ? "..." : ""}
</p>
<p className="text-sm">
<span className="text-red-600 dark:text-red-400">
Your answer: {a.selectedAnswer}
</span>{" "}
&mdash;{" "}
<span className="text-green-600 dark:text-green-400">
Correct: {a.correctAnswer}
</span>
</p>
<p className="text-xs text-muted-foreground">
{a.explanation}
</p>
</div>
);
})}
</div>
)}
</>
)}
</CardContent>
<CardFooter>
{finalPassed ? (
<Button
onClick={() => router.push("/label")}
className="w-full"
size="lg"
>
Continue to Labeling
</Button>
) : (
<Button onClick={startQuiz} className="w-full" size="lg">
Review &amp; Retry
</Button>
)}
</CardFooter>
</Card>
</div>
);
}
return null;
}