437 lines
14 KiB
TypeScript
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",
|
|
"specificity": "Specificity Level",
|
|
"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>{" "}
|
|
— Distinguishing management role descriptions from risk
|
|
management process descriptions
|
|
</li>
|
|
<li>
|
|
<span className="font-medium text-foreground">
|
|
Materiality Disclaimers
|
|
</span>{" "}
|
|
— Identifying materiality assessments vs. cross-references
|
|
</li>
|
|
<li>
|
|
<span className="font-medium text-foreground">
|
|
QV Fact Counting
|
|
</span>{" "}
|
|
— Determining specificity levels based on verifiable facts
|
|
</li>
|
|
<li>
|
|
<span className="font-medium text-foreground">
|
|
SPAC Exceptions
|
|
</span>{" "}
|
|
— 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>{" "}
|
|
—{" "}
|
|
<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>{" "}
|
|
—{" "}
|
|
<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 & Retry
|
|
</Button>
|
|
)}
|
|
</CardFooter>
|
|
</Card>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return null;
|
|
}
|