mirror of
https://github.com/rgrgogu/new_starr.git
synced 2026-09-27 00:12:54 +08:00
perform test #1
test to courses Signed-off-by: Kenneth Obsequio <k80308392@gmail.com>
This commit is contained in:
@@ -1,12 +1,14 @@
|
||||
// components/QuizBlock.jsx
|
||||
import { useState, useEffect } from "react";
|
||||
import { useState, useEffect, useRef, useCallback } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import {
|
||||
ChevronLeft, ChevronRight,
|
||||
Circle, CheckCircle2,
|
||||
Square, CheckSquare2,
|
||||
Clock, AlertTriangle, Info,
|
||||
} from "lucide-react";
|
||||
import { useClientNotifications } from "@/contexts/ClientNotificationContext";
|
||||
|
||||
function QuizSkeleton() {
|
||||
return (
|
||||
@@ -24,30 +26,225 @@ function QuizSkeleton() {
|
||||
);
|
||||
}
|
||||
|
||||
function formatTime(secs) {
|
||||
if (secs === null || secs === undefined) return null;
|
||||
const m = Math.floor(secs / 60).toString().padStart(2, "0");
|
||||
const s = (secs % 60).toString().padStart(2, "0");
|
||||
return `${m}:${s}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Props:
|
||||
* quiz — { quiz_id, title, is_required, passing_score, max_questions, attempt_count, has_passed, best_attempt, questions: [...] }
|
||||
* quiz — assessment data including active_session, time_limit_minutes, etc.
|
||||
* loading — true while fetch is in-flight
|
||||
* onSubmit — (answers) => Promise<result|null>
|
||||
* label — noun used in copy ("Quiz" or "Assessment"), default "Quiz"
|
||||
* onStart — async () => { session_id, expires_at, remaining_seconds, draft_answers } — only for timed assessments
|
||||
* onDraft — (answers) => void — called every 25s to UPSERT draft + last_heartbeat_at
|
||||
* onRefreshSession — async () => { expires_at, remaining_seconds } — called when assessment_updated fires
|
||||
* onSubmit — async (answers, sessionId?) => result|null
|
||||
* onRetake — () => void — refetch so attempt stats refresh
|
||||
* label — "Quiz" or "Assessment"
|
||||
*/
|
||||
const QuizBlock = ({ quiz, loading = false, onSubmit, onRetake, label = "Quiz" }) => {
|
||||
const QuizBlock = ({ quiz, loading = false, onStart, onDraft, onRefreshSession, onSubmit, onRetake, label = "Quiz" }) => {
|
||||
const questions = quiz?.questions ?? [];
|
||||
const total = questions.length;
|
||||
|
||||
const [stage, setStage] = useState("intro"); // 'intro' | 'taking' | 'result'
|
||||
// ── Core stage state ──────────────────────────────────────────────────────
|
||||
const [stage, setStage] = useState("intro"); // 'intro' | 'taking' | 'result'
|
||||
const [currentIndex, setCurrentIndex] = useState(0);
|
||||
const [answers, setAnswers] = useState({});
|
||||
const [answers, setAnswers] = useState({});
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [result, setResult] = useState(null);
|
||||
const [starting, setStarting] = useState(false);
|
||||
const [result, setResult] = useState(null);
|
||||
|
||||
// ── Timer state ───────────────────────────────────────────────────────────
|
||||
const [remainingSeconds, setRemainingSeconds] = useState(null); // null = no active countdown
|
||||
const [timeExpired, setTimeExpired] = useState(false);
|
||||
|
||||
// ── Notifications (assessment_updated alert) ──────────────────────────────
|
||||
const { notifications, fetchNotifications, accelerate, decelerate } = useClientNotifications();
|
||||
const [assessmentUpdatedAlert, setAssessmentUpdatedAlert] = useState(false);
|
||||
const seenNotifRef = useRef(new Set()); // tracks notification_ids already shown
|
||||
|
||||
// ── Session refs (stable across renders, no stale-closure issues) ─────────
|
||||
const sessionRef = useRef({ sessionId: null, expiresAt: null });
|
||||
const answersRef = useRef({});
|
||||
const timerFiredRef = useRef(false);
|
||||
|
||||
// Keep answersRef in sync with state so the timer's auto-submit closure reads fresh data
|
||||
useEffect(() => { answersRef.current = answers; }, [answers]);
|
||||
|
||||
// Reset all state when the quiz/assessment changes
|
||||
useEffect(() => {
|
||||
setStage("intro");
|
||||
setCurrentIndex(0);
|
||||
setAnswers({});
|
||||
setResult(null);
|
||||
setRemainingSeconds(null);
|
||||
setTimeExpired(false);
|
||||
sessionRef.current = { sessionId: null, expiresAt: null };
|
||||
timerFiredRef.current = false;
|
||||
// Pre-load any saved draft so the student can see their progress on the intro screen
|
||||
const draft = quiz?.active_session?.draft_answers ?? {};
|
||||
setAnswers(Object.keys(draft).length > 0 ? draft : {});
|
||||
answersRef.current = draft;
|
||||
}, [quiz?.quiz_id]);
|
||||
|
||||
// ── Speed up notification polling while mid-assessment ───────────────────
|
||||
useEffect(() => {
|
||||
if (stage !== "taking") { decelerate(); return; }
|
||||
accelerate();
|
||||
fetchNotifications(); // immediate refresh when stage starts
|
||||
return () => decelerate();
|
||||
}, [stage]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
// ── Watch for assessment_updated notifications ────────────────────────────
|
||||
useEffect(() => {
|
||||
if (stage !== "taking") return;
|
||||
notifications.forEach((n) => {
|
||||
if (n.type === "assessment" && n.title === "Assessment Updated" && !seenNotifRef.current.has(n.notification_id)) {
|
||||
seenNotifRef.current.add(n.notification_id);
|
||||
setAssessmentUpdatedAlert(true);
|
||||
// Refresh the session timer — admin may have changed time_limit_minutes
|
||||
if (onRefreshSession) {
|
||||
onRefreshSession().then(updated => {
|
||||
if (updated?.remaining_seconds != null) {
|
||||
setRemainingSeconds(updated.remaining_seconds);
|
||||
sessionRef.current.expiresAt = updated.expires_at
|
||||
? new Date(updated.expires_at)
|
||||
: null;
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
}, [notifications, stage]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
// ── Draft auto-save: immediate on enter, then every 25s ──────────────────
|
||||
// The immediate call sets last_heartbeat_at right away so the crash-resume
|
||||
// freeze logic always has a reference point even if the tab closes within seconds.
|
||||
useEffect(() => {
|
||||
if (stage !== "taking" || !onDraft) return;
|
||||
onDraft(answersRef.current); // immediate — seeds last_heartbeat_at now
|
||||
const id = setInterval(() => onDraft(answersRef.current), 25_000);
|
||||
return () => clearInterval(id);
|
||||
}, [stage, onDraft]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
// ── Timer tick (only active while stage === 'taking' and expiresAt is set) ─
|
||||
useEffect(() => {
|
||||
if (stage !== "taking" || !sessionRef.current.expiresAt) return;
|
||||
|
||||
timerFiredRef.current = false;
|
||||
|
||||
const tick = () => {
|
||||
const secs = Math.max(0, Math.ceil((sessionRef.current.expiresAt.getTime() - Date.now()) / 1000));
|
||||
setRemainingSeconds(secs);
|
||||
|
||||
if (secs <= 0 && !timerFiredRef.current) {
|
||||
timerFiredRef.current = true;
|
||||
setTimeExpired(true);
|
||||
clearInterval(id);
|
||||
}
|
||||
};
|
||||
|
||||
tick(); // immediate first tick so UI shows correct time right away
|
||||
const id = setInterval(tick, 1000);
|
||||
return () => clearInterval(id);
|
||||
}, [stage]); // intentionally only stage — expiresAt is a ref
|
||||
|
||||
// ── Auto-submit when time runs out ────────────────────────────────────────
|
||||
useEffect(() => {
|
||||
if (!timeExpired || submitting) return;
|
||||
|
||||
const autoSubmit = async () => {
|
||||
setSubmitting(true);
|
||||
const res = await onSubmit?.(answersRef.current, sessionRef.current.sessionId);
|
||||
setSubmitting(false);
|
||||
if (res) {
|
||||
setResult(res);
|
||||
setStage("result");
|
||||
}
|
||||
};
|
||||
|
||||
autoSubmit();
|
||||
}, [timeExpired]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
// ── Handlers ─────────────────────────────────────────────────────────────
|
||||
const handleStart = useCallback(async (resuming = false) => {
|
||||
const isAssessment = label === "Assessment";
|
||||
const hasTimeLimit = isAssessment && (quiz?.time_limit_minutes ?? 0) > 0;
|
||||
|
||||
// Always create/resume a session for assessments (timed or not) so admin
|
||||
// can see in-progress records and draft answers are preserved on crash.
|
||||
if (isAssessment && onStart) {
|
||||
setStarting(true);
|
||||
const session = await onStart();
|
||||
setStarting(false);
|
||||
if (!session) return; // onStart already toasted the error
|
||||
|
||||
sessionRef.current = {
|
||||
sessionId: session.session_id,
|
||||
expiresAt: session.expires_at ? new Date(session.expires_at) : null,
|
||||
};
|
||||
if (hasTimeLimit && session.remaining_seconds != null) {
|
||||
setRemainingSeconds(session.remaining_seconds);
|
||||
}
|
||||
// Restore draft answers saved before the crash/close
|
||||
if (session.draft_answers && Object.keys(session.draft_answers).length > 0) {
|
||||
setAnswers(session.draft_answers);
|
||||
answersRef.current = session.draft_answers;
|
||||
}
|
||||
}
|
||||
|
||||
setStage("taking");
|
||||
}, [label, quiz?.time_limit_minutes, onStart]);
|
||||
|
||||
const handleRetake = () => {
|
||||
setCurrentIndex(0);
|
||||
setAnswers({});
|
||||
setResult(null);
|
||||
setRemainingSeconds(null);
|
||||
setTimeExpired(false);
|
||||
sessionRef.current = { sessionId: null, expiresAt: null };
|
||||
timerFiredRef.current = false;
|
||||
setStage("intro");
|
||||
onRetake?.();
|
||||
};
|
||||
|
||||
const handleOptionClick = (optionId) => {
|
||||
const question = questions[currentIndex];
|
||||
const isMulti = question.type === "multi_select";
|
||||
const multiLimit = isMulti ? (question.correct_count ?? null) : null;
|
||||
|
||||
setAnswers((prev) => {
|
||||
if (!isMulti) return { ...prev, [question.question_id]: optionId };
|
||||
const current = prev[question.question_id] ?? [];
|
||||
if (!current.includes(optionId) && multiLimit !== null && current.length >= multiLimit) return prev;
|
||||
const next = current.includes(optionId)
|
||||
? current.filter((id) => id !== optionId)
|
||||
: [...current, optionId];
|
||||
return { ...prev, [question.question_id]: next };
|
||||
});
|
||||
};
|
||||
|
||||
const handleNext = async () => {
|
||||
if (isLast) {
|
||||
setSubmitting(true);
|
||||
const res = await onSubmit?.(answers, sessionRef.current.sessionId);
|
||||
setSubmitting(false);
|
||||
if (res) {
|
||||
setResult(res);
|
||||
setStage("result");
|
||||
}
|
||||
return;
|
||||
}
|
||||
setCurrentIndex((i) => Math.min(i + 1, total - 1));
|
||||
};
|
||||
|
||||
const handlePrev = () => {
|
||||
if (isFirst) { setStage("intro"); return; }
|
||||
setCurrentIndex((i) => Math.max(i - 1, 0));
|
||||
};
|
||||
|
||||
// ── Empty / loading guards ────────────────────────────────────────────────
|
||||
if (!quiz && !loading) {
|
||||
return (
|
||||
<div className="h-full w-full flex items-center justify-center text-muted-foreground py-20">
|
||||
@@ -66,20 +263,17 @@ const QuizBlock = ({ quiz, loading = false, onSubmit, onRetake, label = "Quiz" }
|
||||
);
|
||||
}
|
||||
|
||||
const handleRetake = () => {
|
||||
setCurrentIndex(0);
|
||||
setAnswers({});
|
||||
setResult(null);
|
||||
setStage("intro");
|
||||
onRetake?.(); // refetch so attempts_remaining/cooldown_until reflect the submission that just happened
|
||||
};
|
||||
// ── Derived values ────────────────────────────────────────────────────────
|
||||
const isAssessment = label === "Assessment";
|
||||
const hasTimeLimit = isAssessment && (quiz?.time_limit_minutes ?? 0) > 0;
|
||||
const activeSession = isAssessment ? (quiz?.active_session ?? null) : null; // pre-existing in_progress from server
|
||||
|
||||
// ── Intro screen ─────────────────────────────────────────────────────────
|
||||
if (stage === "intro") {
|
||||
const attempts = quiz.attempt_count ?? 0;
|
||||
const attemptsRemaining = quiz.attempts_remaining ?? null; // null = backend hasn't sent this field yet
|
||||
const cooldownUntil = quiz.cooldown_until ? new Date(quiz.cooldown_until) : null;
|
||||
const canAttempt = quiz.can_attempt ?? true; // default open if field is absent, for back-compat
|
||||
const attempts = quiz.attempt_count ?? 0;
|
||||
const attemptsRemaining = quiz.attempts_remaining ?? null;
|
||||
const cooldownUntil = quiz.cooldown_until ? new Date(quiz.cooldown_until) : null;
|
||||
const canAttempt = quiz.can_attempt ?? true;
|
||||
|
||||
return (
|
||||
<div className="max-w-2xl mx-auto">
|
||||
@@ -91,6 +285,17 @@ const QuizBlock = ({ quiz, loading = false, onSubmit, onRetake, label = "Quiz" }
|
||||
Required to complete this {label === "Assessment" ? "course" : "unit"}
|
||||
</span>
|
||||
)}
|
||||
{label === "Assessment" && quiz.max_attempts && quiz.cooldown_hours && (
|
||||
<span className="inline-block rounded-full bg-muted px-2.5 py-0.5 text-xs text-muted-foreground">
|
||||
{quiz.max_attempts} failed attempt{quiz.max_attempts !== 1 ? "s" : ""} → {quiz.cooldown_hours}h cooldown
|
||||
</span>
|
||||
)}
|
||||
{hasTimeLimit && (
|
||||
<span className="inline-flex items-center gap-1 rounded-full bg-blue-500/10 px-2.5 py-0.5 text-xs font-medium text-blue-600 dark:text-blue-400">
|
||||
<Clock className="size-3" />
|
||||
{quiz.time_limit_minutes} minute time limit
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{quiz.has_passed && (
|
||||
@@ -130,20 +335,39 @@ const QuizBlock = ({ quiz, loading = false, onSubmit, onRetake, label = "Quiz" }
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Resume banner — shown when the server reports an active in_progress session */}
|
||||
{activeSession && canAttempt && (
|
||||
<div className="rounded-lg border border-blue-500/30 bg-blue-500/5 px-4 py-3 text-left space-y-1">
|
||||
<p className="text-sm font-medium text-blue-700 dark:text-blue-400 flex items-center gap-1.5">
|
||||
<Clock className="size-4" /> Session in progress
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
You have an unfinished attempt.{" "}
|
||||
{activeSession.remaining_seconds != null
|
||||
? `${formatTime(activeSession.remaining_seconds)} remaining — resume before time runs out.`
|
||||
: "Resume where you left off."}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex items-center justify-center gap-6 sm:gap-10">
|
||||
<div className="space-y-0.5">
|
||||
<p className="text-2xl font-bold sm:text-3xl">{total}</p>
|
||||
<p className="text-xs text-muted-foreground sm:text-sm">Question{total === 1 ? "" : "s"}</p>
|
||||
</div>
|
||||
<div className="h-10 w-px bg-border" />
|
||||
<div className="space-y-0.5">
|
||||
<p className="text-2xl font-bold sm:text-3xl">
|
||||
{attemptsRemaining !== null ? attemptsRemaining : attempts}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground sm:text-sm">
|
||||
{attemptsRemaining !== null ? "Attempts Left" : `Attempt${attempts === 1 ? "" : "s"}`}
|
||||
</p>
|
||||
</div>
|
||||
{(attemptsRemaining !== null || attempts > 0) && (
|
||||
<>
|
||||
<div className="h-10 w-px bg-border" />
|
||||
<div className="space-y-0.5">
|
||||
<p className="text-2xl font-bold sm:text-3xl">
|
||||
{attemptsRemaining !== null ? attemptsRemaining : attempts}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground sm:text-sm">
|
||||
{attemptsRemaining !== null ? "Attempts Left" : `Attempt${attempts === 1 ? "" : "s"}`}
|
||||
</p>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
<div className="h-10 w-px bg-border" />
|
||||
<div className="space-y-0.5">
|
||||
<p className="text-2xl font-bold sm:text-3xl">{quiz.passing_score}%</p>
|
||||
@@ -151,8 +375,19 @@ const QuizBlock = ({ quiz, loading = false, onSubmit, onRetake, label = "Quiz" }
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Button size="lg" className="w-full sm:w-auto" onClick={() => setStage("taking")} disabled={!canAttempt}>
|
||||
{attempts > 0 ? `Retake ${label}` : `Start ${label}`}
|
||||
<Button
|
||||
size="lg"
|
||||
className="w-full sm:w-auto"
|
||||
onClick={() => handleStart(!!activeSession)}
|
||||
disabled={!canAttempt || starting}
|
||||
>
|
||||
{starting
|
||||
? "Starting…"
|
||||
: activeSession
|
||||
? "Resume Assessment"
|
||||
: attempts > 0
|
||||
? `Retake ${label}`
|
||||
: `Start ${label}`}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -186,47 +421,57 @@ const QuizBlock = ({ quiz, loading = false, onSubmit, onRetake, label = "Quiz" }
|
||||
}
|
||||
|
||||
// ── Question stepper ─────────────────────────────────────────────────────
|
||||
const question = questions[currentIndex];
|
||||
const isFirst = currentIndex === 0;
|
||||
const isLast = currentIndex === total - 1;
|
||||
const isMulti = question.type === "multi_select";
|
||||
const selected = answers[question.question_id];
|
||||
const progress = Math.round(((currentIndex + 1) / total) * 100);
|
||||
const question = questions[currentIndex];
|
||||
const isFirst = currentIndex === 0;
|
||||
const isLast = currentIndex === total - 1;
|
||||
const isMulti = question.type === "multi_select";
|
||||
const selected = answers[question.question_id];
|
||||
const progress = Math.round(((currentIndex + 1) / total) * 100);
|
||||
|
||||
const handleOptionClick = (optionId) => {
|
||||
setAnswers((prev) => {
|
||||
if (!isMulti) return { ...prev, [question.question_id]: optionId };
|
||||
const current = prev[question.question_id] ?? [];
|
||||
const next = current.includes(optionId)
|
||||
? current.filter((id) => id !== optionId)
|
||||
: [...current, optionId];
|
||||
return { ...prev, [question.question_id]: next };
|
||||
});
|
||||
};
|
||||
const multiLimit = isMulti ? (question.correct_count ?? null) : null;
|
||||
const selectedCount = isMulti ? (selected ?? []).length : 0;
|
||||
const limitReached = multiLimit !== null && selectedCount >= multiLimit;
|
||||
|
||||
const handleNext = async () => {
|
||||
if (isLast) {
|
||||
setSubmitting(true);
|
||||
const res = await onSubmit?.(answers);
|
||||
setSubmitting(false);
|
||||
if (res) {
|
||||
setResult(res);
|
||||
setStage("result");
|
||||
}
|
||||
return;
|
||||
}
|
||||
setCurrentIndex((i) => Math.min(i + 1, total - 1));
|
||||
};
|
||||
|
||||
const handlePrev = () => {
|
||||
if (isFirst) { setStage("intro"); return; }
|
||||
setCurrentIndex((i) => Math.max(i - 1, 0));
|
||||
};
|
||||
// Timer color: red < 60s, amber < 5min, default otherwise
|
||||
const timerColor = remainingSeconds !== null
|
||||
? remainingSeconds <= 60
|
||||
? "text-red-500 dark:text-red-400"
|
||||
: remainingSeconds <= 300
|
||||
? "text-amber-500 dark:text-amber-400"
|
||||
: "text-muted-foreground"
|
||||
: null;
|
||||
|
||||
return (
|
||||
<div className="max-w-2xl mx-auto space-y-5">
|
||||
{/* ── Assessment updated alert banner ── */}
|
||||
{assessmentUpdatedAlert && (
|
||||
<div className="flex items-start gap-3 rounded-lg border border-blue-500/30 bg-blue-500/5 px-4 py-3">
|
||||
<Info className="h-4 w-4 text-blue-500 shrink-0 mt-0.5" />
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm font-medium text-blue-700 dark:text-blue-400">Assessment Updated</p>
|
||||
<p className="text-xs text-blue-600/80 dark:text-blue-300/70 mt-0.5">
|
||||
Your administrator has made changes to this assessment. Your current session and saved answers are unaffected — continue as normal.
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setAssessmentUpdatedAlert(false)}
|
||||
className="text-blue-400 hover:text-blue-600 shrink-0 text-xs leading-none mt-0.5"
|
||||
>✕</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-2">
|
||||
{quiz.title && <h2 className="text-lg font-semibold sm:text-xl">{quiz.title}</h2>}
|
||||
<div className="flex items-center justify-between">
|
||||
{quiz.title && <h2 className="text-lg font-semibold sm:text-xl truncate">{quiz.title}</h2>}
|
||||
{remainingSeconds !== null && (
|
||||
<span className={`flex items-center gap-1 font-mono text-sm font-bold tabular-nums shrink-0 ml-3 ${timerColor}`}>
|
||||
{timeExpired && submitting
|
||||
? <><AlertTriangle className="size-3.5" /> Time's up</>
|
||||
: <><Clock className="size-3.5" />{formatTime(remainingSeconds)}</>
|
||||
}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center justify-between text-xs text-muted-foreground sm:text-sm">
|
||||
<span>Question {currentIndex + 1} of {total}</span>
|
||||
<span>{progress}%</span>
|
||||
@@ -236,16 +481,32 @@ const QuizBlock = ({ quiz, loading = false, onSubmit, onRetake, label = "Quiz" }
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{timeExpired && submitting && (
|
||||
<div className="rounded-lg border border-amber-500/30 bg-amber-500/5 px-4 py-2 text-xs text-amber-700 dark:text-amber-400 text-center">
|
||||
Time's up — submitting your answers…
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="rounded-xl border bg-card p-4 space-y-4 sm:p-6">
|
||||
<p className="font-bold text-base leading-relaxed sm:text-lg">
|
||||
{currentIndex + 1}. {question.question}
|
||||
</p>
|
||||
<div className="space-y-1">
|
||||
<p className="font-bold text-base leading-relaxed sm:text-lg">
|
||||
{currentIndex + 1}. {question.question}
|
||||
</p>
|
||||
{isMulti && multiLimit !== null && (
|
||||
<p className={`text-xs font-medium ${limitReached ? "text-amber-600 dark:text-amber-400" : "text-blue-600 dark:text-blue-400"}`}>
|
||||
{limitReached
|
||||
? `${selectedCount} / ${multiLimit} selected — limit reached`
|
||||
: `Select ${multiLimit} answer${multiLimit !== 1 ? "s" : ""} · ${selectedCount} / ${multiLimit} selected`}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
{(question.options ?? []).map((option, i) => {
|
||||
const letter = String.fromCharCode(65 + i);
|
||||
const isSelected = isMulti
|
||||
? (selected ?? []).includes(option.option_id)
|
||||
: selected === option.option_id;
|
||||
const isDisabled = (isMulti && limitReached && !isSelected) || (timeExpired && submitting);
|
||||
const Icon = isMulti
|
||||
? (isSelected ? CheckSquare2 : Square)
|
||||
: (isSelected ? CheckCircle2 : Circle);
|
||||
@@ -255,8 +516,10 @@ const QuizBlock = ({ quiz, loading = false, onSubmit, onRetake, label = "Quiz" }
|
||||
key={option.option_id}
|
||||
type="button"
|
||||
onClick={() => handleOptionClick(option.option_id)}
|
||||
className={`flex w-full items-center gap-3 rounded-lg border px-3 py-2.5 text-left text-sm transition-colors sm:text-base ${isSelected ? "border-primary bg-primary/5" : "border-border hover:bg-muted-foreground/5"
|
||||
}`}
|
||||
disabled={isDisabled}
|
||||
className={`flex w-full items-center gap-3 rounded-lg border px-3 py-2.5 text-left text-sm transition-colors sm:text-base
|
||||
${isSelected ? "border-primary bg-primary/5" : "border-border"}
|
||||
${isDisabled ? "opacity-40 cursor-not-allowed" : "hover:bg-muted-foreground/5"}`}
|
||||
>
|
||||
<Icon className={`size-4 shrink-0 ${isSelected ? "text-primary" : "text-muted-foreground"}`} />
|
||||
<span className="font-medium text-muted-foreground">{letter}.</span>
|
||||
@@ -273,7 +536,7 @@ const QuizBlock = ({ quiz, loading = false, onSubmit, onRetake, label = "Quiz" }
|
||||
Previous
|
||||
</Button>
|
||||
<Button onClick={handleNext} disabled={submitting}>
|
||||
{submitting ? "Submitting..." : isLast ? "Submit" : "Next"}
|
||||
{submitting ? "Submitting…" : isLast ? "Submit" : "Next"}
|
||||
{!isLast && !submitting && <ChevronRight className="size-4" />}
|
||||
</Button>
|
||||
</div>
|
||||
@@ -281,4 +544,4 @@ const QuizBlock = ({ quiz, loading = false, onSubmit, onRetake, label = "Quiz" }
|
||||
);
|
||||
};
|
||||
|
||||
export default QuizBlock;
|
||||
export default QuizBlock;
|
||||
|
||||
Reference in New Issue
Block a user