Files
spsays-training/app/(app)/quizzes/[id]/quiz-player.tsx
T
2026-10-16 03:25:08 +05:30

315 lines
11 KiB
TypeScript

"use client";
/**
* Taking a quiz: an introduction, then one question at a time, then your
* result.
*
* Each question is fetched from the BFF as you reach it, and the BFF starts
* its clock at that moment — so response times are measured server-side and
* reloading the page doesn't restart them. Correct answers are only revealed
* on the results screen, never while you're still answering.
*
* Tapping an option only selects it; the answer is sent, and becomes final,
* when you press Submit. Multi questions ("select all that apply") take
* several selections.
*
* Signed in only: visitors get the sign-in modal over the page, and home if
* they dismiss it. Nothing about the quiz loads until they're signed in.
*/
import { useCallback, useEffect, useState } from "react";
import Link from "next/link";
import {
ArrowLeft,
ArrowRight,
Check,
ClipboardCheck,
Loader2,
} from "lucide-react";
import { toast } from "sonner";
import { useAuthGate } from "@/components/auth/auth-gate";
import { useSignedInPage } from "@/components/auth/use-signed-in-page";
import { RichText } from "@/lib/rich-text";
import {
quiz as quizApi,
quizActionLabel,
QuizError,
type QuizCard,
type QuizPlay,
type QuizResult,
} from "@/lib/quiz";
import { QuizResultView } from "@/components/quiz/quiz-result-view";
import { CopyPageLinkButton } from "@/components/layout/copy-page-link-button";
import { cn } from "@/lib/utils";
type Stage =
| { kind: "loading" }
| { kind: "intro"; card: QuizCard }
| { kind: "playing"; card: QuizCard; play: QuizPlay }
| { kind: "result"; card: QuizCard; result: QuizResult }
| { kind: "missing" };
export function QuizPlayer({ id }: { id: string }) {
const { user } = useSignedInPage();
const userId = user?.id;
const { requireAuth } = useAuthGate();
const [stage, setStage] = useState<Stage>({ kind: "loading" });
const [selected, setSelected] = useState<string[]>([]);
const [submitting, setSubmitting] = useState(false);
// Every new question starts with nothing selected.
const goTo = (card: QuizCard, play: QuizPlay) => {
setSelected([]);
setStage({ kind: "playing", card, play });
};
const toggle = (play: QuizPlay, optionId: string) => {
if (submitting) return;
setSelected((cur) =>
play.question?.multi
? cur.includes(optionId)
? cur.filter((x) => x !== optionId)
: [...cur, optionId]
: [optionId],
);
};
const showResult = useCallback(
async (card: QuizCard) => {
const result = await quizApi.result(id);
setStage({ kind: "result", card, result });
},
[id],
);
useEffect(() => {
if (!userId) return;
let cancelled = false;
quizApi
.get(id)
.then(async (card) => {
if (cancelled) return;
if (card.my.state === "completed") await showResult(card);
else setStage({ kind: "intro", card });
})
.catch(() => !cancelled && setStage({ kind: "missing" }));
return () => {
cancelled = true;
};
// user?.id: progress appears once you sign in.
}, [id, userId, showResult]);
const begin = async (card: QuizCard) => {
if (!(await requireAuth())) return;
try {
const play = await quizApi.start(id);
if (play.done) await showResult(card);
else goTo(card, play);
} catch (err) {
toast.error(
err instanceof QuizError ? err.message : "Could not start the quiz",
);
}
};
const submit = async (card: QuizCard, play: QuizPlay) => {
if (!play.question || submitting || selected.length === 0) return;
setSubmitting(true);
try {
const next = await quizApi.answer(id, play.question.id, selected);
if (next.done) await showResult(card);
else goTo(card, next);
} catch (err) {
// Most likely the question was already answered in another tab: pick up
// wherever the attempt actually is rather than leaving this one stuck.
toast.error(
err instanceof QuizError ? err.message : "Could not save that answer",
);
try {
const resumed = await quizApi.start(id);
if (resumed.done) await showResult(card);
else goTo(card, resumed);
} catch {
/* the toast already said something went wrong */
}
} finally {
setSubmitting(false);
}
};
return (
<main className="mx-auto w-full max-w-2xl flex-1 px-4 py-6 sm:px-6 sm:py-10">
<Link
href="/quizzes"
className="mb-4 inline-flex items-center gap-1 text-sm text-muted-foreground hover:text-foreground"
>
<ArrowLeft className="size-4" /> All quizzes
</Link>
{stage.kind === "loading" && (
<p className="text-sm text-muted-foreground">Loading…</p>
)}
{stage.kind === "missing" && (
<p className="text-sm text-muted-foreground">
This quiz isn&apos;t available.
</p>
)}
{(stage.kind === "intro" ||
stage.kind === "playing" ||
stage.kind === "result") && (
<header className="mb-6">
<h1 className="font-[family-name:var(--font-title)] text-2xl font-semibold text-foreground">
{stage.card.title}
</h1>
{stage.card.target_path && (
<Link
href={stage.card.target_path}
className="mt-1 inline-block text-sm text-primary hover:underline"
>
{stage.card.target_title || stage.card.target_path}
</Link>
)}
{/* Shares the quiz itself; the link unfurls with its own OG card. */}
<div className="mt-3">
<CopyPageLinkButton label="Copy quiz link" />
</div>
</header>
)}
{stage.kind === "intro" && (
<section className="space-y-5 rounded-2xl border border-border bg-card p-5">
{stage.card.description && (
<div className="text-sm leading-relaxed text-foreground">
<RichText text={stage.card.description} />
</div>
)}
<p className="text-sm text-muted-foreground">
{stage.card.question_count}{" "}
{stage.card.question_count === 1 ? "question" : "questions"}, one
attempt. Each answer is final once submitted, and your time on each
question is recorded.
</p>
{stage.card.my.state === "in_progress" && (
<p className="text-sm text-muted-foreground">
You&apos;ve answered {stage.card.my.answered} of{" "}
{stage.card.my.total}.
</p>
)}
<button
type="button"
onClick={() => void begin(stage.card)}
className="inline-flex items-center gap-2 rounded-lg bg-primary px-4 py-2 text-sm font-medium text-primary-foreground hover:bg-primary/90 dark:text-white"
>
<ClipboardCheck className="size-4" />
{user
? quizActionLabel(stage.card.my.state)
: "Sign in to take the quiz"}
</button>
</section>
)}
{stage.kind === "playing" && stage.play.question && (
<section className="space-y-5">
<div>
<div className="mb-1.5 flex justify-between text-xs text-muted-foreground">
<span>
Question {stage.play.answered + 1} of {stage.play.total}
</span>
</div>
<div className="h-1.5 overflow-hidden rounded-full bg-muted">
<div
className="h-full rounded-full bg-primary transition-all"
style={{
width: `${(stage.play.answered / stage.play.total) * 100}%`,
}}
/>
</div>
</div>
<div>
<p className="text-lg leading-relaxed text-foreground">
<RichText text={stage.play.question.prompt} />
</p>
<p className="mt-1 text-xs text-muted-foreground">
{stage.play.question.multi
? "Select all that apply"
: "Select one answer"}
</p>
</div>
<ul
className="space-y-2"
role={stage.play.question.multi ? "group" : "radiogroup"}
>
{stage.play.question.options.map((o) => {
const on = selected.includes(o.id);
const multi = stage.play.question?.multi;
return (
<li key={o.id}>
<button
type="button"
role={multi ? "checkbox" : "radio"}
aria-checked={on}
disabled={submitting}
onClick={() => toggle(stage.play, o.id)}
className={cn(
"flex w-full items-center gap-3 rounded-xl border px-4 py-3 text-left text-sm transition-colors",
on
? "border-primary bg-primary/10"
: "border-border bg-card hover:border-primary/40 hover:bg-muted/40",
"disabled:cursor-default",
)}
>
{/* Radio dot for one answer, checkbox for several. */}
<span
aria-hidden
className={cn(
"flex size-4 shrink-0 items-center justify-center border transition-colors",
multi ? "rounded" : "rounded-full",
on
? "border-primary bg-primary text-primary-foreground"
: "border-muted-foreground/40",
)}
>
{on &&
(multi ? (
<Check className="size-3" strokeWidth={3} />
) : (
<span className="size-1.5 rounded-full bg-primary-foreground" />
))}
</span>
<span className="min-w-0 flex-1">
<RichText text={o.body} />
</span>
</button>
</li>
);
})}
</ul>
<div className="flex justify-end">
<button
type="button"
disabled={selected.length === 0 || submitting}
onClick={() => void submit(stage.card, stage.play)}
className="inline-flex items-center gap-2 rounded-lg bg-primary px-5 py-2 text-sm font-medium text-primary-foreground hover:bg-primary/90 disabled:opacity-50 dark:text-white"
>
{submitting && <Loader2 className="size-4 animate-spin" />}
{stage.play.answered + 1 === stage.play.total
? "Submit and finish"
: "Next question"}
{!submitting && stage.play.answered + 1 < stage.play.total && (
<ArrowRight className="size-4" />
)}
</button>
</div>
</section>
)}
{stage.kind === "result" && <QuizResultView result={stage.result} />}
</main>
);
}