quiz seo image and authguard
This commit is contained in:
+24
-300
@@ -1,306 +1,30 @@
|
||||
"use client";
|
||||
import type { Metadata } from "next";
|
||||
import { bff } from "@/lib/bff";
|
||||
import { ogForQuiz } from "@/lib/og";
|
||||
import type { QuizCard } from "@/lib/quiz";
|
||||
import { QuizPlayer } from "./quiz-player";
|
||||
|
||||
type Props = { params: Promise<{ id: string }> };
|
||||
|
||||
/**
|
||||
* 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.
|
||||
* Link previews name the quiz. The quiz's card is the BFF's one public quiz
|
||||
* endpoint — title, description and what it's attached to, never questions —
|
||||
* so a shared link unfurls even though taking the quiz needs an account.
|
||||
*/
|
||||
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import Link from "next/link";
|
||||
import { useParams } from "next/navigation";
|
||||
import {
|
||||
ArrowLeft,
|
||||
ArrowRight,
|
||||
Check,
|
||||
ClipboardCheck,
|
||||
Loader2,
|
||||
} from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import { useAuth } from "@/components/auth/auth-provider";
|
||||
import { useAuthGate } from "@/components/auth/auth-gate";
|
||||
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 { 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 default function QuizPage() {
|
||||
const { id } = useParams<{ id: string }>();
|
||||
const { user } = useAuth();
|
||||
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 });
|
||||
export async function generateMetadata({ params }: Props): Promise<Metadata> {
|
||||
const { id } = await params;
|
||||
const card = await bff<QuizCard>(`/quizzes/${encodeURIComponent(id)}`, {
|
||||
revalidate: 300,
|
||||
}).catch(() => null);
|
||||
if (!card) return { title: "Quiz" };
|
||||
return {
|
||||
title: card.title,
|
||||
description: `A quiz on Prabhupāda Says — ${card.question_count} question${card.question_count === 1 ? "" : "s"}.`,
|
||||
...ogForQuiz(card),
|
||||
};
|
||||
}
|
||||
|
||||
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(() => {
|
||||
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, user?.id, 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'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>
|
||||
)}
|
||||
</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'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>
|
||||
);
|
||||
export default async function QuizPage({ params }: Props) {
|
||||
const { id } = await params;
|
||||
return <QuizPlayer id={id} />;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user