"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({ kind: "loading" }); const [selected, setSelected] = useState([]); 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 (
All quizzes {stage.kind === "loading" && (

Loading…

)} {stage.kind === "missing" && (

This quiz isn't available.

)} {(stage.kind === "intro" || stage.kind === "playing" || stage.kind === "result") && (

{stage.card.title}

{stage.card.target_path && ( {stage.card.target_title || stage.card.target_path} )} {/* Shares the quiz itself; the link unfurls with its own OG card. */}
)} {stage.kind === "intro" && (
{stage.card.description && (
)}

{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.

{stage.card.my.state === "in_progress" && (

You've answered {stage.card.my.answered} of{" "} {stage.card.my.total}.

)}
)} {stage.kind === "playing" && stage.play.question && (
Question {stage.play.answered + 1} of {stage.play.total}

{stage.play.question.multi ? "Select all that apply" : "Select one answer"}

    {stage.play.question.options.map((o) => { const on = selected.includes(o.id); const multi = stage.play.question?.multi; return (
  • ); })}
)} {stage.kind === "result" && }
); }