98 lines
3.1 KiB
TypeScript
98 lines
3.1 KiB
TypeScript
"use client";
|
|
|
|
/**
|
|
* The quiz button on a content page.
|
|
*
|
|
* Asks the BFF whether this page has a published quiz and renders nothing if
|
|
* not, so it can sit on every content page without leaving an empty slot.
|
|
* Quizzes are for signed-in readers only, so signed out it never asks.
|
|
* Its label tracks progress: Take quiz → Continue quiz → View results.
|
|
*/
|
|
|
|
import { useEffect, useState } from "react";
|
|
import Link from "next/link";
|
|
import { ClipboardCheck } from "lucide-react";
|
|
import { useAuth } from "@/components/auth/auth-provider";
|
|
import { quiz, quizActionLabel, type QuizCard } from "@/lib/quiz";
|
|
import { cn } from "@/lib/utils";
|
|
|
|
export function QuizButton({
|
|
path,
|
|
size = "sm",
|
|
className,
|
|
}: {
|
|
/** The content page's own path, e.g. /books/bg/2. */
|
|
path: string;
|
|
/**
|
|
* "sm" for the page header's controls row; "lg" to sit beside the lecture
|
|
* page's Play Audio button as an equal — same height, padding, type and
|
|
* colours.
|
|
*/
|
|
size?: "sm" | "lg";
|
|
className?: string;
|
|
}) {
|
|
const { user } = useAuth();
|
|
const userId = user?.id;
|
|
const [card, setCard] = useState<QuizCard | null>(null);
|
|
|
|
// Re-asked when the user changes, so progress appears right after sign-in.
|
|
useEffect(() => {
|
|
if (!userId) return;
|
|
let cancelled = false;
|
|
quiz
|
|
.forPath(path)
|
|
.then((c) => {
|
|
if (!cancelled) setCard(c);
|
|
})
|
|
.catch(() => {
|
|
/* no quiz button is the right failure */
|
|
});
|
|
return () => {
|
|
cancelled = true;
|
|
};
|
|
}, [path, userId]);
|
|
|
|
// A card left over from before sign-out is ignored rather than cleared.
|
|
if (!card || !user) return null;
|
|
|
|
const done = card.my.state === "completed";
|
|
const lg = size === "lg";
|
|
return (
|
|
<Link
|
|
href={`/quizzes/${card.id}`}
|
|
title={card.title}
|
|
className={cn(
|
|
"inline-flex items-center border transition-colors",
|
|
lg
|
|
? // Mirrors PlayAudioButton's default variant. That has no border,
|
|
// so 1px less vertical padding keeps the two exactly as tall.
|
|
"gap-2 rounded-lg px-5 py-[9px] text-sm font-semibold transition-all"
|
|
: "gap-1.5 rounded-md px-2.5 py-1 text-xs font-medium",
|
|
done
|
|
? "border-border text-muted-foreground hover:bg-muted hover:text-foreground"
|
|
: // Take / Continue: solid, so a pending quiz stands out.
|
|
cn(
|
|
"border-primary bg-primary text-primary-foreground shadow-sm hover:bg-primary/90",
|
|
// Header pill keeps white text in dark mode; beside Play Audio
|
|
// it takes Play Audio's own foreground so the pair match.
|
|
lg ? "hover:shadow-md" : "dark:text-white",
|
|
),
|
|
className,
|
|
)}
|
|
>
|
|
<ClipboardCheck className={lg ? "size-4" : "size-3.5"} />
|
|
{quizActionLabel(card.my.state)}
|
|
{card.my.state === "in_progress" && (
|
|
<span className="tabular-nums opacity-70">
|
|
{card.my.answered}/{card.my.total}
|
|
</span>
|
|
)}
|
|
{done && card.my.score !== undefined && (
|
|
<span className="tabular-nums opacity-70">
|
|
{card.my.score}/{card.my.total}
|
|
</span>
|
|
)}
|
|
</Link>
|
|
);
|
|
}
|