feat: on-demand topic tests with shared question bank
Adds a /topic-test route where learners can take a 5-question test on any eligible topic at any time. Questions come from a shared, admin-curated question_bank — no LLM calls on the user path. Points feed the existing leaderboard with a 10pt/topic/week cap (per ISO week) so the bank can't be farmed, and repeats from a thin bank yield 0 points. - New PB collections: question_bank, on_demand_attempts (+ migrations and setup-pb-collections.mjs entries). - db.js: un-deprecates getQuizBank/setQuizBank against question_bank so the existing admin TestManager panel becomes functional again; adds saveOnDemandAttempt, getOnDemandPointsThisWeek, getUserSeenQuestionIds, getAllOnDemandAttempts, isoWeekKey. - testService.js: getEligibleTopicsForOnDemand, startOnDemandTest (unseen- first draw), finishOnDemandTest (cap enforcement + leaderboard upsert). - New TopicTest page, route, and nav entry; weekly test flow untouched. - Leaderboard folds on-demand 100%s into perfect-score count and merges on-demand attempts into the recent activity feed. - Spec: docs/on-demand-tests-spec.md with ADRs and extension points. - Tests: 5 new vitest cases covering eligibility, draw fallback, scoring, partial cap, and exhausted cap (85/85 total). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -111,10 +111,11 @@ const Leaderboard = () => {
|
||||
|
||||
useEffect(() => {
|
||||
const load = async () => {
|
||||
const [leaderboardData, allUsers, allResults] = await Promise.all([
|
||||
const [leaderboardData, allUsers, allResults, allOnDemand] = await Promise.all([
|
||||
db.getLeaderboard(),
|
||||
db.getTeamMembers(),
|
||||
db.getAllTestResults(),
|
||||
db.getAllOnDemandAttempts(),
|
||||
]);
|
||||
|
||||
const nonAdmins = allUsers.filter(u => u.role !== 'admin');
|
||||
@@ -128,6 +129,16 @@ const Leaderboard = () => {
|
||||
resultsByUser[r.user_id][r.week_number] = r.percentage;
|
||||
}
|
||||
|
||||
// Count on-demand perfect scores per user (heatmap stays weekly-only since
|
||||
// on-demand attempts have no week_number — they aren't curriculum slots).
|
||||
const onDemandPerfectByUser = {};
|
||||
for (const r of allOnDemand) {
|
||||
if (!nonAdminIds.has(r.user_id)) continue;
|
||||
if (r.percentage === 100) {
|
||||
onDemandPerfectByUser[r.user_id] = (onDemandPerfectByUser[r.user_id] || 0) + 1;
|
||||
}
|
||||
}
|
||||
|
||||
// Build enriched user objects
|
||||
const enriched = nonAdmins.map(user => {
|
||||
const lb = leaderboardData.find(e => e.user_id === user.id) || {};
|
||||
@@ -140,7 +151,8 @@ const Leaderboard = () => {
|
||||
});
|
||||
|
||||
const streak = computeStreak(heatmap);
|
||||
const perfectScores = heatmap.filter(s => s === 100).length;
|
||||
const perfectScores = heatmap.filter(s => s === 100).length
|
||||
+ (onDemandPerfectByUser[user.id] || 0);
|
||||
|
||||
return {
|
||||
id: user.id,
|
||||
@@ -160,20 +172,36 @@ const Leaderboard = () => {
|
||||
a.name.localeCompare(b.name)
|
||||
);
|
||||
|
||||
// Recent activity feed: newest results across all non-admin users
|
||||
// Recent activity feed: merge weekly results and on-demand attempts,
|
||||
// newest first, capped at 10.
|
||||
const nameMap = Object.fromEntries(allUsers.map(u => [u.id, u.name]));
|
||||
const recentFeed = allResults
|
||||
const weeklyItems = allResults
|
||||
.filter(r => nonAdminIds.has(r.user_id))
|
||||
.slice(0, 10)
|
||||
.map(r => ({
|
||||
key: `${r.user_id}-${r.week_number}`,
|
||||
kind: 'weekly',
|
||||
key: `w-${r.user_id}-${r.week_number}`,
|
||||
name: nameMap[r.user_id] || 'Unknown',
|
||||
week: r.week_number,
|
||||
label: `week-${r.week_number}`,
|
||||
score: r.score,
|
||||
total: r.total,
|
||||
percentage: r.percentage,
|
||||
created: r.created,
|
||||
}));
|
||||
const onDemandItems = allOnDemand
|
||||
.filter(r => nonAdminIds.has(r.user_id))
|
||||
.map(r => ({
|
||||
kind: 'on_demand',
|
||||
key: `od-${r.id}`,
|
||||
name: nameMap[r.user_id] || 'Unknown',
|
||||
label: r.topic_label || 'topic',
|
||||
score: r.score,
|
||||
total: r.total,
|
||||
percentage: r.percentage,
|
||||
created: r.created,
|
||||
}));
|
||||
const recentFeed = [...weeklyItems, ...onDemandItems]
|
||||
.sort((a, b) => new Date(b.created) - new Date(a.created))
|
||||
.slice(0, 10);
|
||||
|
||||
setUsers(enriched);
|
||||
setFeed(recentFeed);
|
||||
@@ -390,8 +418,8 @@ const Leaderboard = () => {
|
||||
>
|
||||
<span className="text-teal/40 flex-shrink-0 select-none">›</span>
|
||||
<span className="text-fg font-medium flex-shrink-0">{item.name}</span>
|
||||
<span className="text-fg-subtle">completed</span>
|
||||
<span className="text-teal font-medium">week-{item.week}</span>
|
||||
<span className="text-fg-subtle">{item.kind === 'on_demand' ? 'tested' : 'completed'}</span>
|
||||
<span className="text-teal font-medium">{item.label}</span>
|
||||
<span className="text-fg-subtle">
|
||||
[{item.score}/{item.total}
|
||||
{item.percentage === 100 && (
|
||||
|
||||
469
src/pages/TopicTest.jsx
Normal file
469
src/pages/TopicTest.jsx
Normal file
@@ -0,0 +1,469 @@
|
||||
import { useState, useEffect, useRef, useCallback } from 'react';
|
||||
import {
|
||||
Loader, AlertCircle, Trophy, ArrowRight, ArrowLeft,
|
||||
Clock, CheckCircle, XCircle, BookOpen,
|
||||
} from 'lucide-react';
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
import { Link } from 'react-router-dom';
|
||||
import Card from '../components/ui/Card';
|
||||
import Button from '../components/ui/Button';
|
||||
import Tag from '../components/ui/Tag';
|
||||
import { useApp } from '../store/AppContext';
|
||||
import {
|
||||
getEligibleTopicsForOnDemand,
|
||||
startOnDemandTest,
|
||||
finishOnDemandTest,
|
||||
ON_DEMAND_TEST_SIZE,
|
||||
WEEKLY_POINT_CAP_PER_TOPIC,
|
||||
} from '../lib/testService';
|
||||
|
||||
const TIMER_SECONDS = 300;
|
||||
|
||||
function formatTime(sec) {
|
||||
const m = Math.floor(sec / 60);
|
||||
const s = sec % 60;
|
||||
return `${m}:${s.toString().padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
const TopicTest = () => {
|
||||
const { state } = useApp();
|
||||
const { currentUser } = state;
|
||||
|
||||
const [phase, setPhase] = useState('picker'); // picker | loading | quiz | results
|
||||
const [topics, setTopics] = useState([]);
|
||||
const [topicsLoading, setTopicsLoading] = useState(true);
|
||||
const [pickedTopic, setPickedTopic] = useState(null);
|
||||
const [quiz, setQuiz] = useState(null);
|
||||
const [answers, setAnswers] = useState({});
|
||||
const [currentQ, setCurrentQ] = useState(0);
|
||||
const [showFeedback, setShowFeedback] = useState(false);
|
||||
const [timeLeft, setTimeLeft] = useState(TIMER_SECONDS);
|
||||
const [result, setResult] = useState(null);
|
||||
const [error, setError] = useState(null);
|
||||
const timerRef = useRef(null);
|
||||
|
||||
// ── Load eligible topics for the picker ──
|
||||
useEffect(() => {
|
||||
if (phase !== 'picker' || !currentUser) return;
|
||||
let cancelled = false;
|
||||
(async () => {
|
||||
setTopicsLoading(true);
|
||||
try {
|
||||
const eligible = await getEligibleTopicsForOnDemand();
|
||||
if (!cancelled) setTopics(eligible);
|
||||
} catch (e) {
|
||||
if (!cancelled) setError(e.message);
|
||||
} finally {
|
||||
if (!cancelled) setTopicsLoading(false);
|
||||
}
|
||||
})();
|
||||
return () => { cancelled = true; };
|
||||
}, [phase, currentUser]);
|
||||
|
||||
// ── Timer ──
|
||||
const finishRef = useRef(null);
|
||||
useEffect(() => {
|
||||
if (phase === 'quiz') {
|
||||
timerRef.current = setInterval(() => {
|
||||
setTimeLeft(prev => {
|
||||
if (prev <= 1) {
|
||||
clearInterval(timerRef.current);
|
||||
finishRef.current?.();
|
||||
return 0;
|
||||
}
|
||||
return prev - 1;
|
||||
});
|
||||
}, 1000);
|
||||
}
|
||||
return () => clearInterval(timerRef.current);
|
||||
}, [phase]);
|
||||
|
||||
// ── Start a test on the picked topic ──
|
||||
const startTest = async (topic) => {
|
||||
setPickedTopic(topic);
|
||||
setPhase('loading');
|
||||
setError(null);
|
||||
try {
|
||||
const q = await startOnDemandTest(currentUser.id, topic.id);
|
||||
if (!q?.questions?.length) throw new Error('Could not pull questions from the bank for this topic.');
|
||||
setQuiz(q);
|
||||
setAnswers({});
|
||||
setCurrentQ(0);
|
||||
setShowFeedback(false);
|
||||
setTimeLeft(TIMER_SECONDS);
|
||||
setPhase('quiz');
|
||||
} catch (e) {
|
||||
setError(e.message);
|
||||
setPhase('picker');
|
||||
}
|
||||
};
|
||||
|
||||
// ── Pick an answer ──
|
||||
const selectAnswer = (questionId, optionIndex) => {
|
||||
if (showFeedback) return;
|
||||
setAnswers(prev => ({ ...prev, [questionId]: optionIndex }));
|
||||
setShowFeedback(true);
|
||||
};
|
||||
|
||||
const nextQuestion = () => {
|
||||
setShowFeedback(false);
|
||||
if (currentQ < quiz.questions.length - 1) {
|
||||
setCurrentQ(p => p + 1);
|
||||
} else {
|
||||
finishTest();
|
||||
}
|
||||
};
|
||||
|
||||
const finishTest = useCallback(async () => {
|
||||
clearInterval(timerRef.current);
|
||||
if (!quiz || !pickedTopic) return;
|
||||
try {
|
||||
const r = await finishOnDemandTest(
|
||||
currentUser.id,
|
||||
pickedTopic.id,
|
||||
pickedTopic.label,
|
||||
quiz,
|
||||
answers,
|
||||
TIMER_SECONDS - timeLeft,
|
||||
);
|
||||
setResult(r);
|
||||
setPhase('results');
|
||||
} catch (e) {
|
||||
setError(e.message);
|
||||
setPhase('picker');
|
||||
}
|
||||
}, [quiz, pickedTopic, answers, timeLeft, currentUser]);
|
||||
|
||||
useEffect(() => { finishRef.current = finishTest; }, [finishTest]);
|
||||
|
||||
const resetToPicker = () => {
|
||||
setQuiz(null);
|
||||
setResult(null);
|
||||
setPickedTopic(null);
|
||||
setAnswers({});
|
||||
setCurrentQ(0);
|
||||
setShowFeedback(false);
|
||||
setError(null);
|
||||
setPhase('picker');
|
||||
};
|
||||
|
||||
// ─── Picker ────────────────────────────────────────────────
|
||||
if (phase === 'picker') {
|
||||
return (
|
||||
<div className="p-4 md:p-8 max-w-3xl mx-auto pb-24 md:pb-8">
|
||||
<div className="mb-8">
|
||||
<h1 className="text-3xl md:text-4xl font-bold text-teal mb-2">Topic Test</h1>
|
||||
<p className="text-fg-muted">
|
||||
Pick any topic and take a {ON_DEMAND_TEST_SIZE}-question test on demand.
|
||||
You can earn up to {WEEKLY_POINT_CAP_PER_TOPIC} points per topic per week.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<Card className="border border-red-200 bg-red-50 text-red-900 p-4 mb-6 text-sm">
|
||||
<div className="flex items-start gap-2">
|
||||
<AlertCircle size={16} className="flex-shrink-0 mt-0.5" />
|
||||
<p>{error}</p>
|
||||
</div>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{topicsLoading ? (
|
||||
<div className="text-center py-20">
|
||||
<Loader size={36} className="mx-auto text-teal animate-spin mb-3" />
|
||||
<p className="text-fg-muted text-sm">Loading available topics…</p>
|
||||
</div>
|
||||
) : topics.length === 0 ? (
|
||||
<Card className="border border-dashed border-bg-warm text-center py-16 text-fg-muted">
|
||||
<BookOpen size={48} className="mx-auto mb-4 text-teal/30" />
|
||||
<p className="font-medium text-fg mb-1">No topics available yet.</p>
|
||||
<p className="text-sm">An admin needs to add at least {ON_DEMAND_TEST_SIZE} questions to a topic's bank before you can test on it.</p>
|
||||
</Card>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{topics.map(topic => (
|
||||
<Card key={topic.id} className="border border-bg-warm flex items-center justify-between gap-4">
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 mb-1 flex-wrap">
|
||||
<h3 className="font-semibold truncate">{topic.label}</h3>
|
||||
{topic.theme && <Tag variant="accent" className="text-xs">{topic.theme}</Tag>}
|
||||
<Tag variant="success" className="text-xs">{topic.bankSize} questions</Tag>
|
||||
</div>
|
||||
{topic.description && (
|
||||
<p className="text-sm text-fg-muted truncate">{topic.description}</p>
|
||||
)}
|
||||
</div>
|
||||
<Button onClick={() => startTest(topic)} className="flex-shrink-0">
|
||||
Start <ArrowRight size={16} className="ml-1.5" />
|
||||
</Button>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Loading ───────────────────────────────────────────────
|
||||
if (phase === 'loading') {
|
||||
return (
|
||||
<div className="p-4 md:p-8 max-w-2xl mx-auto text-center py-20">
|
||||
<Loader size={48} className="mx-auto text-teal animate-spin mb-4" />
|
||||
<p className="font-medium text-lg">Drawing questions from the bank…</p>
|
||||
<p className="text-sm text-fg-muted mt-2">No AI call — these are shared, admin-curated questions.</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Results ───────────────────────────────────────────────
|
||||
if (phase === 'results' && result) {
|
||||
const isPerfect = result.percentage === 100;
|
||||
const isGood = result.percentage >= 70;
|
||||
const hasRepeats = result.breakdown.some(b => !b.isUnseen);
|
||||
|
||||
return (
|
||||
<div className="p-4 md:p-8 max-w-3xl mx-auto pb-24 md:pb-8">
|
||||
<motion.div
|
||||
initial={{ scale: 0.8, opacity: 0 }}
|
||||
animate={{ scale: 1, opacity: 1 }}
|
||||
className="text-center mb-10"
|
||||
>
|
||||
<div className={`inline-flex items-center justify-center w-28 h-28 rounded-full mb-6 ${
|
||||
isPerfect ? 'bg-teal text-white' : isGood ? 'bg-teal/10 text-teal' : 'bg-orange-100 text-orange-600'
|
||||
}`}>
|
||||
<span className="text-4xl font-bold">{result.percentage}%</span>
|
||||
</div>
|
||||
<h1 className="text-3xl font-bold mb-2">
|
||||
{isPerfect ? 'Perfect score!' : isGood ? 'Well done!' : 'Keep learning!'}
|
||||
</h1>
|
||||
<p className="text-fg-muted">
|
||||
{pickedTopic?.label} · {result.score}/{result.total} in {formatTime(result.timeUsed)}.
|
||||
{result.pointsEarned > 0 && (
|
||||
<span className="text-teal font-semibold"> +{result.pointsEarned} pts</span>
|
||||
)}
|
||||
</p>
|
||||
</motion.div>
|
||||
|
||||
{/* Capped / repeats notice */}
|
||||
{(result.capped || hasRepeats) && (
|
||||
<Card className="border border-orange-200 bg-orange-50 text-orange-900 mb-6 text-sm">
|
||||
<div className="flex items-start gap-2">
|
||||
<AlertCircle size={16} className="flex-shrink-0 mt-0.5" />
|
||||
<div>
|
||||
{result.capped && (
|
||||
<p className="font-medium">
|
||||
Weekly cap reached: you've already earned the maximum {WEEKLY_POINT_CAP_PER_TOPIC} pts for this topic this week.
|
||||
{result.rawPoints > result.pointsEarned && ` Practice points earned: ${result.rawPoints - result.pointsEarned} (not counted).`}
|
||||
</p>
|
||||
)}
|
||||
{hasRepeats && !result.capped && (
|
||||
<p>
|
||||
Some questions were repeats — you've seen them before. Repeats are practice only and don't add to your point total.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
<div className="grid grid-cols-3 gap-4 mb-8">
|
||||
<Card className="border border-bg-warm text-center py-4">
|
||||
<CheckCircle size={24} className="mx-auto text-teal mb-1" />
|
||||
<div className="text-2xl font-bold text-teal">{result.score}</div>
|
||||
<div className="text-xs text-fg-muted">Correct</div>
|
||||
</Card>
|
||||
<Card className="border border-bg-warm text-center py-4">
|
||||
<XCircle size={24} className="mx-auto text-red-400 mb-1" />
|
||||
<div className="text-2xl font-bold text-red-500">{result.total - result.score}</div>
|
||||
<div className="text-xs text-fg-muted">Incorrect</div>
|
||||
</Card>
|
||||
<Card className="border border-bg-warm text-center py-4">
|
||||
<Trophy size={24} className="mx-auto text-fg-muted mb-1" />
|
||||
<div className="text-2xl font-bold">{result.pointsEarned}</div>
|
||||
<div className="text-xs text-fg-muted">Points</div>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between mb-4 flex-wrap gap-2">
|
||||
<h2 className="text-xl font-bold">Question Review</h2>
|
||||
<div className="flex gap-2">
|
||||
<Button variant="outline" onClick={resetToPicker}>
|
||||
<ArrowLeft size={16} className="mr-1.5" /> Another topic
|
||||
</Button>
|
||||
<Link to="/leaderboard">
|
||||
<Button variant="outline"><Trophy size={16} className="mr-2" /> Leaderboard</Button>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
{result.breakdown.map((item, i) => (
|
||||
<Card key={i} className={`border ${item.correct ? 'border-teal/30' : 'border-red-200'}`}>
|
||||
<div className="flex items-start gap-3 mb-3">
|
||||
<div className={`flex-shrink-0 w-7 h-7 rounded-full flex items-center justify-center text-sm font-bold ${
|
||||
item.correct ? 'bg-teal/10 text-teal' : 'bg-red-50 text-red-500'
|
||||
}`}>
|
||||
{i + 1}
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<p className="font-medium">{item.question}</p>
|
||||
<div className="flex items-center gap-2 mt-0.5">
|
||||
<p className="text-xs text-fg-muted">{item.topicLabel}</p>
|
||||
{!item.isUnseen && (
|
||||
<Tag variant="dark" className="text-[10px]">Repeat (no points)</Tag>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-2 ml-10 mb-3">
|
||||
{item.options.map((opt, oi) => {
|
||||
const isCorrect = oi === item.correctIndex;
|
||||
const wasSelected = oi === item.selected;
|
||||
return (
|
||||
<div
|
||||
key={oi}
|
||||
className={`px-3 py-2 rounded-[var(--r-sm)] text-sm border ${
|
||||
isCorrect
|
||||
? 'border-teal bg-teal/5 text-teal font-medium'
|
||||
: wasSelected
|
||||
? 'border-red-300 bg-red-50 text-red-700'
|
||||
: 'border-bg-warm text-fg-muted'
|
||||
}`}
|
||||
>
|
||||
{isCorrect && <CheckCircle size={14} className="inline mr-1 -mt-0.5" />}
|
||||
{wasSelected && !isCorrect && <XCircle size={14} className="inline mr-1 -mt-0.5" />}
|
||||
{opt}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<p className="text-sm text-fg-muted ml-10 italic">{item.explanation}</p>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Active Quiz ───────────────────────────────────────────
|
||||
if (phase === 'quiz' && quiz) {
|
||||
const q = quiz.questions[currentQ];
|
||||
if (!q) {
|
||||
return (
|
||||
<div className="p-4 md:p-8 max-w-2xl mx-auto text-center py-20">
|
||||
<p className="text-red-500 font-medium">Something went wrong loading this question.</p>
|
||||
<Button className="mt-4" onClick={resetToPicker}>Back</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const selectedAnswer = answers[q.id];
|
||||
const isCorrect = selectedAnswer === q.correctIndex;
|
||||
const progress = ((currentQ + (showFeedback ? 1 : 0)) / quiz.questions.length) * 100;
|
||||
const timerDanger = timeLeft < 60;
|
||||
|
||||
return (
|
||||
<div className="p-4 md:p-8 max-w-2xl mx-auto pb-24 md:pb-8">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<span className="font-mono text-sm text-fg-muted">
|
||||
Question {currentQ + 1} of {quiz.questions.length}
|
||||
</span>
|
||||
<span className={`font-mono text-sm font-medium flex items-center gap-1.5 ${
|
||||
timerDanger ? 'text-red-500 animate-pulse' : 'text-fg-muted'
|
||||
}`}>
|
||||
<Clock size={14} /> {formatTime(timeLeft)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="h-1.5 bg-bg-warm rounded-full mb-8 overflow-hidden">
|
||||
<motion.div
|
||||
className="h-full bg-teal rounded-full"
|
||||
initial={false}
|
||||
animate={{ width: `${progress}%` }}
|
||||
transition={{ duration: 0.3 }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<AnimatePresence mode="wait">
|
||||
<motion.div
|
||||
key={q.id}
|
||||
initial={{ opacity: 0, x: 30 }}
|
||||
animate={{ opacity: 1, x: 0 }}
|
||||
exit={{ opacity: 0, x: -30 }}
|
||||
transition={{ duration: 0.25 }}
|
||||
>
|
||||
<Card className="border border-bg-warm mb-6">
|
||||
<div className="flex items-center gap-2 mb-4 flex-wrap">
|
||||
<Tag variant="accent" className="text-xs">{pickedTopic?.label}</Tag>
|
||||
{!q.isUnseen && <Tag variant="dark" className="text-[10px]">Practice (no points)</Tag>}
|
||||
</div>
|
||||
<h2 className="text-xl font-bold leading-snug">{q.question}</h2>
|
||||
</Card>
|
||||
|
||||
<div className="space-y-3">
|
||||
{q.options.map((option, oi) => {
|
||||
let optionClass = 'border-bg-warm bg-paper hover:border-teal/50 hover:bg-teal/5 cursor-pointer';
|
||||
if (showFeedback) {
|
||||
if (oi === q.correctIndex) {
|
||||
optionClass = 'border-teal bg-teal/10 text-teal';
|
||||
} else if (oi === selectedAnswer && !isCorrect) {
|
||||
optionClass = 'border-red-300 bg-red-50 text-red-700';
|
||||
} else {
|
||||
optionClass = 'border-bg-warm text-fg-muted opacity-50';
|
||||
}
|
||||
} else if (selectedAnswer === oi) {
|
||||
optionClass = 'border-teal bg-teal/5';
|
||||
}
|
||||
return (
|
||||
<button
|
||||
key={oi}
|
||||
onClick={() => selectAnswer(q.id, oi)}
|
||||
disabled={showFeedback}
|
||||
className={`w-full text-left p-4 rounded-[var(--r-sm)] border transition-all flex items-center gap-3 ${optionClass}`}
|
||||
>
|
||||
<span className="flex-shrink-0 w-8 h-8 rounded-full border border-current flex items-center justify-center font-mono font-bold text-sm">
|
||||
{String.fromCharCode(65 + oi)}
|
||||
</span>
|
||||
<span className="text-sm font-medium">{option.replace(/^[A-D]\)\s*/, '')}</span>
|
||||
{showFeedback && oi === q.correctIndex && <CheckCircle size={18} className="ml-auto text-teal" />}
|
||||
{showFeedback && oi === selectedAnswer && !isCorrect && oi !== q.correctIndex && <XCircle size={18} className="ml-auto text-red-400" />}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{showFeedback && (
|
||||
<motion.div initial={{ opacity: 0, y: 10 }} animate={{ opacity: 1, y: 0 }} className="mt-6">
|
||||
<Card className={`border ${isCorrect ? 'border-teal/30 bg-teal/5' : 'border-red-200 bg-red-50'}`}>
|
||||
<div className="flex items-start gap-3">
|
||||
{isCorrect
|
||||
? <CheckCircle size={20} className="text-teal flex-shrink-0 mt-0.5" />
|
||||
: <XCircle size={20} className="text-red-500 flex-shrink-0 mt-0.5" />}
|
||||
<div>
|
||||
<p className={`font-bold text-sm ${isCorrect ? 'text-teal' : 'text-red-700'}`}>
|
||||
{isCorrect ? 'Correct!' : 'Incorrect'}
|
||||
</p>
|
||||
<p className="text-sm mt-1">{q.explanation}</p>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
<div className="flex justify-end mt-4">
|
||||
<Button onClick={nextQuestion}>
|
||||
{currentQ < quiz.questions.length - 1
|
||||
? <>Next Question <ArrowRight size={18} className="ml-2" /></>
|
||||
: <>Finish Test <Trophy size={18} className="ml-2" /></>}
|
||||
</Button>
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
</motion.div>
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
export default TopicTest;
|
||||
Reference in New Issue
Block a user