feat: quiz
This commit is contained in:
@@ -5,7 +5,8 @@
|
||||
* library.
|
||||
*
|
||||
* Two sections, top to bottom:
|
||||
* 1. Start here — three primary entry points (Books, Letters, Lectures)
|
||||
* 1. Start here — a Quizzes tile, then three primary entry points
|
||||
* (Books, Letters, Lectures), all one width
|
||||
* rendered as full-width feature tiles with longer copy.
|
||||
* 2. Explore — a six-card asymmetric grid for the remaining sections.
|
||||
*
|
||||
@@ -26,6 +27,7 @@ import {
|
||||
PenLine,
|
||||
Users,
|
||||
} from "lucide-react";
|
||||
import { QuizzesCTA } from "@/components/quiz/quizzes-cta";
|
||||
|
||||
type Feature = {
|
||||
href: string;
|
||||
@@ -112,7 +114,11 @@ export function KnowledgebaseIndex() {
|
||||
<div>
|
||||
<section className="py-4">
|
||||
<SectionHeading eyebrow="Start here" title="Three doors in" />
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4 sm:gap-5">
|
||||
{/* One grid so all four share a width. Quizzes lead: something to
|
||||
do, ahead of the three ways in to read. Absent until one is
|
||||
published, leaving the original three across. */}
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-none lg:grid-flow-col lg:auto-cols-fr gap-4 sm:gap-5">
|
||||
<QuizzesCTA variant="tile" />
|
||||
{FEATURED.map((f) => (
|
||||
<FeaturedTile key={f.href} feature={f} />
|
||||
))}
|
||||
@@ -145,7 +151,7 @@ function FeaturedTile({ feature }: { feature: Feature }) {
|
||||
<ArrowUpRight className="size-4 text-muted-foreground/60 group-hover:text-foreground transition-colors" />
|
||||
</div>
|
||||
|
||||
<h3 className="mt-5 font-[family-name:var(--font-title)] font-semibold text-2xl sm:text-[1.6rem] leading-tight text-foreground">
|
||||
<h3 className="mt-5 font-[family-name:var(--font-title)] font-semibold text-2xl sm:text-[1.6rem] lg:text-[1.35rem] leading-tight text-foreground">
|
||||
{feature.label}
|
||||
</h3>
|
||||
|
||||
|
||||
@@ -28,6 +28,7 @@ import { useRouter, usePathname } from "next/navigation";
|
||||
import { Check, Link2 } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { SaveOfflineButton } from "@/components/layout/save-offline-button";
|
||||
import { QuizButton } from "@/components/quiz/quiz-button";
|
||||
import type { OfflineDescriptor } from "@/lib/offline";
|
||||
|
||||
const SEARCH_RETURN_KEY = "spsays_from_search";
|
||||
@@ -71,6 +72,7 @@ export function PageHeader({
|
||||
bare = false,
|
||||
sticky = false,
|
||||
offline,
|
||||
quiz = true,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
/** Drop the bordered chrome — useful for pages that want flush title text. */
|
||||
@@ -89,6 +91,13 @@ export function PageHeader({
|
||||
* visible gap.
|
||||
*/
|
||||
sticky?: boolean;
|
||||
/**
|
||||
* Show this page's quiz button (if it has a published quiz) under the
|
||||
* title. On by default for content pages; a page that places the button
|
||||
* itself — the transcript, beside its audio player — turns this off so it
|
||||
* never appears twice.
|
||||
*/
|
||||
quiz?: boolean;
|
||||
className?: string;
|
||||
}) {
|
||||
const router = useRouter();
|
||||
@@ -216,6 +225,9 @@ export function PageHeader({
|
||||
|
||||
{/* Page-level controls, anchored at the header's bottom. */}
|
||||
<div className="mt-3 flex flex-wrap items-center gap-2">
|
||||
{/* First, so it reads as belonging to the title above it. Renders
|
||||
nothing on pages without a published quiz. */}
|
||||
{quiz && isContentPath(pathname) ? <QuizButton path={pathname} /> : null}
|
||||
<button
|
||||
type="button"
|
||||
onClick={copyPageLink}
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
"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.
|
||||
* 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,
|
||||
className,
|
||||
}: {
|
||||
/** The content page's own path, e.g. /books/bg/2. */
|
||||
path: string;
|
||||
className?: string;
|
||||
}) {
|
||||
const { user } = useAuth();
|
||||
const [card, setCard] = useState<QuizCard | null>(null);
|
||||
|
||||
// Re-asked when the user changes, so progress appears right after sign-in.
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
quiz
|
||||
.forPath(path)
|
||||
.then((c) => {
|
||||
if (!cancelled) setCard(c);
|
||||
})
|
||||
.catch(() => {
|
||||
/* no quiz button is the right failure */
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [path, user?.id]);
|
||||
|
||||
if (!card) return null;
|
||||
|
||||
const done = card.my.state === "completed";
|
||||
return (
|
||||
<Link
|
||||
href={`/quizzes/${card.id}`}
|
||||
title={card.title}
|
||||
className={cn(
|
||||
"inline-flex items-center gap-1.5 rounded-md border px-2.5 py-1 text-xs font-medium transition-colors",
|
||||
done
|
||||
? "border-border text-muted-foreground hover:bg-muted hover:text-foreground"
|
||||
: "border-primary/40 bg-primary/10 text-primary hover:bg-primary/15",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<ClipboardCheck className="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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* Your finished attempt: the score, then every question with your answer,
|
||||
* the correct one, its explanation, and how long you took.
|
||||
*
|
||||
* Correct options are filled solid so they read at a glance; a wrong pick is
|
||||
* outlined in red. "Your answer" marks whatever you chose either way.
|
||||
*/
|
||||
|
||||
import Link from "next/link";
|
||||
import { Check, Clock, X } from "lucide-react";
|
||||
import { RichText } from "@/lib/rich-text";
|
||||
import type { QuizResult } from "@/lib/quiz";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export function fmtMs(ms?: number | null): string {
|
||||
if (ms == null) return "—";
|
||||
if (ms < 60_000) return `${(ms / 1000).toFixed(1)}s`;
|
||||
return `${Math.floor(ms / 60_000)}m ${Math.round((ms % 60_000) / 1000)}s`;
|
||||
}
|
||||
|
||||
export function QuizResultView({ result }: { result: QuizResult }) {
|
||||
const pct = result.total
|
||||
? Math.round((result.score / result.total) * 100)
|
||||
: 0;
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<section className="rounded-2xl border border-border bg-card p-5 text-center">
|
||||
<p className="text-xs font-medium uppercase tracking-wide text-primary">
|
||||
Your result
|
||||
</p>
|
||||
<p className="mt-2 text-4xl font-semibold tabular-nums text-foreground">
|
||||
{result.score}
|
||||
<span className="text-2xl text-muted-foreground">
|
||||
/{result.total}
|
||||
</span>
|
||||
</p>
|
||||
<p className="mt-1 text-sm text-muted-foreground">
|
||||
{pct}% correct · {fmtMs(result.total_ms)} in total
|
||||
</p>
|
||||
{result.quiz.target_path && (
|
||||
<Link
|
||||
href={result.quiz.target_path}
|
||||
className="mt-3 inline-block text-sm font-medium text-primary hover:underline"
|
||||
>
|
||||
Back to {result.quiz.target_title || "the reading"}
|
||||
</Link>
|
||||
)}
|
||||
</section>
|
||||
|
||||
<ol className="space-y-4">
|
||||
{result.items.map((it, i) => (
|
||||
<li key={it.id} className="rounded-xl border border-border p-4">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<p className="text-sm font-medium text-foreground">
|
||||
<span className="text-muted-foreground">{i + 1}. </span>
|
||||
<RichText text={it.prompt} />
|
||||
</p>
|
||||
<span
|
||||
className={cn(
|
||||
"inline-flex shrink-0 items-center gap-1 text-xs",
|
||||
it.is_correct ? "text-primary" : "text-destructive",
|
||||
)}
|
||||
>
|
||||
{it.is_correct ? (
|
||||
<Check className="size-3.5" />
|
||||
) : (
|
||||
<X className="size-3.5" />
|
||||
)}
|
||||
{it.is_correct ? "Correct" : "Incorrect"}
|
||||
</span>
|
||||
</div>
|
||||
{it.multi && (
|
||||
<p className="mt-1 text-xs text-muted-foreground">
|
||||
Select all that apply
|
||||
</p>
|
||||
)}
|
||||
<ul className="mt-3 space-y-1.5">
|
||||
{it.options.map((o) => {
|
||||
const chosen = it.chosen_option_ids.includes(o.id);
|
||||
return (
|
||||
<li
|
||||
key={o.id}
|
||||
className={cn(
|
||||
"flex items-start gap-2 rounded-lg border px-3 py-2 text-sm",
|
||||
o.is_correct
|
||||
? "border-primary bg-primary font-medium text-primary-foreground dark:text-white"
|
||||
: chosen
|
||||
? "border-destructive/50 bg-destructive/10 text-foreground"
|
||||
: "border-border text-muted-foreground",
|
||||
)}
|
||||
>
|
||||
{o.is_correct ? (
|
||||
<Check className="mt-0.5 size-3.5 shrink-0" strokeWidth={3} />
|
||||
) : chosen ? (
|
||||
<X className="mt-0.5 size-3.5 shrink-0 text-destructive" />
|
||||
) : (
|
||||
<span className="size-3.5 shrink-0" />
|
||||
)}
|
||||
<span className="min-w-0 flex-1">
|
||||
<RichText text={o.body} />
|
||||
</span>
|
||||
{chosen && (
|
||||
<span
|
||||
className={cn(
|
||||
"shrink-0 text-xs font-normal",
|
||||
o.is_correct ? "opacity-85" : "text-muted-foreground",
|
||||
)}
|
||||
>
|
||||
Your answer
|
||||
</span>
|
||||
)}
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
{it.explanation && (
|
||||
<p className="mt-3 rounded-lg bg-muted/40 px-3 py-2 text-sm text-muted-foreground">
|
||||
<RichText text={it.explanation} />
|
||||
</p>
|
||||
)}
|
||||
<p className="mt-2 flex items-center gap-1 text-xs text-muted-foreground">
|
||||
<Clock className="size-3" /> {fmtMs(it.response_ms)}
|
||||
</p>
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* QuizzesCTA — the way into /quizzes, in two shapes:
|
||||
*
|
||||
* - "tile": a card that leads the search page's "Start here" row.
|
||||
* - "banner": a full-width row for the homepage, under sādhana / vāṇī.
|
||||
*
|
||||
* Renders nothing until there is at least one published quiz; with
|
||||
* `pendingOnly` it also hides once every quiz has been finished, so the
|
||||
* homepage only nudges when there's something left to do.
|
||||
*/
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import Link from "next/link";
|
||||
import { ArrowUpRight, ChevronRight, ClipboardCheck } from "lucide-react";
|
||||
import { useAuth } from "@/components/auth/auth-provider";
|
||||
import { quiz, type QuizCard } from "@/lib/quiz";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export function QuizzesCTA({
|
||||
variant = "banner",
|
||||
pendingOnly = false,
|
||||
className,
|
||||
}: {
|
||||
variant?: "banner" | "tile";
|
||||
pendingOnly?: boolean;
|
||||
className?: string;
|
||||
}) {
|
||||
const { user } = useAuth();
|
||||
const [cards, setCards] = useState<QuizCard[] | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
quiz
|
||||
.list()
|
||||
.then((c) => {
|
||||
if (!cancelled) setCards(c);
|
||||
})
|
||||
.catch(() => {
|
||||
/* CTA simply doesn't render */
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
// Refetch on sign-in/out: progress is per user.
|
||||
}, [user?.id]);
|
||||
|
||||
if (!cards?.length) return null;
|
||||
|
||||
if (pendingOnly && cards.every((c) => c.my.state === "completed")) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const tagline = "Test your understanding of chapters, lectures and more";
|
||||
|
||||
if (variant === "tile") {
|
||||
// Matches the search page's FeaturedTile so it sits in that grid as an
|
||||
// equal: same padding, icon chip, title scale and corner arrow.
|
||||
return (
|
||||
<Link
|
||||
href="/quizzes"
|
||||
className={cn(
|
||||
"group relative flex flex-col h-full rounded-2xl bg-card ring-1 ring-foreground/10 hover:ring-foreground/25 hover:shadow-md transition-all p-6",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex items-center justify-center size-11 rounded-xl bg-primary/12 text-primary">
|
||||
<ClipboardCheck className="size-5" />
|
||||
</div>
|
||||
<ArrowUpRight className="size-4 text-muted-foreground/60 group-hover:text-foreground transition-colors" />
|
||||
</div>
|
||||
<h3 className="mt-5 font-[family-name:var(--font-title)] font-semibold text-2xl sm:text-[1.6rem] lg:text-[1.35rem] leading-tight text-foreground">
|
||||
Quizzes
|
||||
</h3>
|
||||
<p className="mt-2 text-sm text-muted-foreground leading-relaxed flex-1">
|
||||
{tagline}
|
||||
</p>
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Link
|
||||
href="/quizzes"
|
||||
className={cn(
|
||||
"group flex items-center gap-3.5 rounded-2xl border border-border bg-card p-4 text-left transition-colors hover:border-primary/40 hover:bg-muted/40",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<span className="inline-flex size-12 shrink-0 items-center justify-center rounded-full bg-primary/10 text-primary">
|
||||
<ClipboardCheck className="size-6" />
|
||||
</span>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="text-base font-semibold leading-snug text-foreground">
|
||||
Quizzes
|
||||
</p>
|
||||
<p className="truncate text-sm leading-snug text-muted-foreground">
|
||||
{tagline}
|
||||
</p>
|
||||
</div>
|
||||
<ChevronRight className="size-5 shrink-0 text-muted-foreground transition-transform group-hover:translate-x-0.5 group-hover:text-foreground" />
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
@@ -8,13 +8,23 @@
|
||||
*/
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import Image from "next/image";
|
||||
import Link from "next/link";
|
||||
import { ChevronRight } from "lucide-react";
|
||||
import { useAuth } from "@/components/auth/auth-provider";
|
||||
import { sadhna, type SadhnaDashboard } from "@/lib/sadhna";
|
||||
import { StreakFlame } from "@/components/sadhna/streak-flame";
|
||||
|
||||
export function SadhnaStreakCTA() {
|
||||
export function SadhnaStreakCTA({
|
||||
compact = false,
|
||||
}: {
|
||||
/**
|
||||
* Icon-only tile for the homepage row beside Vāṇī of the Day. The streak
|
||||
* and today's status move into the accessible label and tooltip, and the
|
||||
* tile tints while a streak is running.
|
||||
*/
|
||||
compact?: boolean;
|
||||
} = {}) {
|
||||
const { user, loading } = useAuth();
|
||||
const authed = !loading && !!user;
|
||||
const [dash, setDash] = useState<SadhnaDashboard | null>(null);
|
||||
@@ -55,6 +65,35 @@ export function SadhnaStreakCTA() {
|
||||
? "One offering done — finish today"
|
||||
: "Chant and post a quote to complete today";
|
||||
|
||||
if (compact) {
|
||||
const active = streak > 0;
|
||||
return (
|
||||
<Link
|
||||
href="/sadhna-streak"
|
||||
aria-label={`${title}. ${subtitle}`}
|
||||
title={`${title} — ${subtitle}`}
|
||||
className={
|
||||
// An explicit square matching the homepage row's h-24. Deriving the
|
||||
// width from height (aspect-square + h-full) collapses to the icon in
|
||||
// Safari, so both sides are fixed.
|
||||
"group p-2 flex flex-col size-24 shrink-0 items-center justify-center rounded-2xl border transition-colors " +
|
||||
(active
|
||||
? "border-primary/30 bg-primary/10 hover:border-primary/50"
|
||||
: "border-border bg-card hover:border-primary/40 hover:bg-muted/40")
|
||||
}
|
||||
>
|
||||
<Image
|
||||
src="/icons/chanting-beads.svg"
|
||||
alt=""
|
||||
width={44}
|
||||
height={44}
|
||||
className="size-12 transition-transform group-hover:scale-105"
|
||||
unoptimized
|
||||
/>
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Link
|
||||
href="/sadhna-streak"
|
||||
|
||||
@@ -12,6 +12,7 @@ import { useEffect, useState } from "react";
|
||||
import Link from "next/link";
|
||||
import { ChevronRight } from "lucide-react";
|
||||
import { getVaniOfTheDay, type Vani } from "@/lib/vani";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
/** Quote-marks glyph (svgrepo). Inherits color via `currentColor`. */
|
||||
function QuotesIcon({ className }: { className?: string }) {
|
||||
@@ -28,7 +29,7 @@ function QuotesIcon({ className }: { className?: string }) {
|
||||
);
|
||||
}
|
||||
|
||||
export function VaniOfTheDayCTA() {
|
||||
export function VaniOfTheDayCTA({ className }: { className?: string } = {}) {
|
||||
const [vani, setVani] = useState<Vani | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -57,11 +58,11 @@ export function VaniOfTheDayCTA() {
|
||||
return (
|
||||
<Link
|
||||
href={vani.href}
|
||||
className="group flex w-full max-w-md items-center gap-3.5 rounded-2xl border border-border bg-card p-4 text-left transition-colors hover:border-primary/40 hover:bg-muted/40"
|
||||
className={cn(
|
||||
"group flex w-full max-w-md items-center gap-3.5 rounded-2xl border border-border bg-card p-4 text-left transition-colors hover:border-primary/40 hover:bg-muted/40",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<span className="inline-flex size-12 shrink-0 items-center justify-center rounded-full bg-primary/10 text-primary">
|
||||
<QuotesIcon className="size-6" />
|
||||
</span>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="text-xs font-medium text-primary">Vani of the day</p>
|
||||
<p className="mt-0.5 truncate text-base font-semibold text-foreground leading-snug">
|
||||
|
||||
Reference in New Issue
Block a user