feat: quiz
This commit is contained in:
@@ -9,6 +9,7 @@ import {
|
|||||||
} from "@/lib/transcriptions";
|
} from "@/lib/transcriptions";
|
||||||
import { buildOgMeta, ogForTranscription, extractQuoteFromHl } from "@/lib/og";
|
import { buildOgMeta, ogForTranscription, extractQuoteFromHl } from "@/lib/og";
|
||||||
import { PageHeader } from "@/components/layout/page-header";
|
import { PageHeader } from "@/components/layout/page-header";
|
||||||
|
import { QuizButton } from "@/components/quiz/quiz-button";
|
||||||
import { Card, CardContent } from "@/components/ui/card";
|
import { Card, CardContent } from "@/components/ui/card";
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
import { buttonVariants } from "@/components/ui/button";
|
import { buttonVariants } from "@/components/ui/button";
|
||||||
@@ -95,6 +96,9 @@ export default async function TranscriptionPage({
|
|||||||
/>
|
/>
|
||||||
<PageHeader
|
<PageHeader
|
||||||
sticky
|
sticky
|
||||||
|
// With audio, the quiz button sits beside the play control below
|
||||||
|
// instead; without it, the header shows it as on any other page.
|
||||||
|
quiz={!t.audioUrl}
|
||||||
offline={{
|
offline={{
|
||||||
type: "transcript",
|
type: "transcript",
|
||||||
title: t.name,
|
title: t.name,
|
||||||
@@ -168,7 +172,8 @@ export default async function TranscriptionPage({
|
|||||||
|
|
||||||
{/* Play Audio button */}
|
{/* Play Audio button */}
|
||||||
{t.audioUrl && (
|
{t.audioUrl && (
|
||||||
<div className="w-full md:w-[25%] flex justify-end">
|
<div className="w-full md:w-auto flex flex-wrap items-center justify-end gap-2">
|
||||||
|
<QuizButton path={`/transcriptions/${t.slug}`} className="py-2" />
|
||||||
<PlayAudioButton
|
<PlayAudioButton
|
||||||
audioUrl={t.audioUrl}
|
audioUrl={t.audioUrl}
|
||||||
title={t.name}
|
title={t.name}
|
||||||
|
|||||||
+23
-6
@@ -6,6 +6,7 @@ import { useAuth } from "@/components/auth/auth-provider";
|
|||||||
import { SadhnaStreakCTA } from "@/components/sadhna/streak-cta";
|
import { SadhnaStreakCTA } from "@/components/sadhna/streak-cta";
|
||||||
import { VaniOfTheDayCTA } from "@/components/vani/vani-of-the-day-cta";
|
import { VaniOfTheDayCTA } from "@/components/vani/vani-of-the-day-cta";
|
||||||
import { HomeFeed } from "@/components/feed/home-feed";
|
import { HomeFeed } from "@/components/feed/home-feed";
|
||||||
|
import { QuizzesCTA } from "@/components/quiz/quizzes-cta";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* / — the Home (Feed) gate.
|
* / — the Home (Feed) gate.
|
||||||
@@ -33,9 +34,17 @@ export default function HomePage() {
|
|||||||
// mobile this max-width is wider than the viewport, so it's full-bleed
|
// mobile this max-width is wider than the viewport, so it's full-bleed
|
||||||
// as before.
|
// as before.
|
||||||
<main className="flex-1 max-w-lg w-full mx-auto px-4 sm:px-6 py-6">
|
<main className="flex-1 max-w-lg w-full mx-auto px-4 sm:px-6 py-6">
|
||||||
<div className="mb-6 flex flex-col gap-3">
|
<div className="mb-6">
|
||||||
<SadhnaStreakCTA />
|
<div className="flex h-24 gap-3">
|
||||||
<VaniOfTheDayCTA />
|
{/* A compact sādhana tile beside a wider Vāṇī of the Day: the
|
||||||
|
streak is a daily habit you already know how to reach, so it
|
||||||
|
needs only a recognisable mark; the vāṇī changes daily and
|
||||||
|
earns the room to say what it is. */}
|
||||||
|
<SadhnaStreakCTA compact />
|
||||||
|
<VaniOfTheDayCTA className="h-full min-w-0 max-w-none flex-1" />
|
||||||
|
</div>
|
||||||
|
{/* Only while something is left to take. */}
|
||||||
|
<QuizzesCTA pendingOnly className="mt-3" />
|
||||||
</div>
|
</div>
|
||||||
<HomeFeed />
|
<HomeFeed />
|
||||||
</main>
|
</main>
|
||||||
@@ -88,9 +97,17 @@ export default function HomePage() {
|
|||||||
</p>
|
</p>
|
||||||
|
|
||||||
{/* Daily sādhana streak + Vāṇī of the day — entry points for visitors. */}
|
{/* Daily sādhana streak + Vāṇī of the day — entry points for visitors. */}
|
||||||
<div className="mt-6 w-full max-w-md flex flex-col gap-3">
|
<div className="mt-6 w-full max-w-md">
|
||||||
<SadhnaStreakCTA />
|
<div className="flex h-24 gap-3">
|
||||||
<VaniOfTheDayCTA />
|
{/* A compact sādhana tile beside a wider Vāṇī of the Day: the
|
||||||
|
streak is a daily habit you already know how to reach, so it
|
||||||
|
needs only a recognisable mark; the vāṇī changes daily and
|
||||||
|
earns the room to say what it is. */}
|
||||||
|
<SadhnaStreakCTA compact />
|
||||||
|
<VaniOfTheDayCTA className="h-full min-w-0 max-w-none flex-1" />
|
||||||
|
</div>
|
||||||
|
{/* Only while something is left to take. */}
|
||||||
|
<QuizzesCTA pendingOnly className="mt-3" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
|
|||||||
@@ -0,0 +1,306 @@
|
|||||||
|
"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.
|
||||||
|
*/
|
||||||
|
|
||||||
|
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 });
|
||||||
|
};
|
||||||
|
|
||||||
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,147 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* /quizzes — every published quiz, with where you are in each.
|
||||||
|
*
|
||||||
|
* Reached from the Quizzes card on the search page. Quizzes are deliberately
|
||||||
|
* not in search results: they're something to do, not something to look up.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useEffect, useMemo, 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";
|
||||||
|
|
||||||
|
const KIND: Record<QuizCard["target_kind"], string> = {
|
||||||
|
chapter: "Book chapter",
|
||||||
|
lecture: "Lecture",
|
||||||
|
conversation: "Conversation",
|
||||||
|
standalone: "Quiz",
|
||||||
|
};
|
||||||
|
|
||||||
|
type Filter = "all" | "todo" | "done";
|
||||||
|
|
||||||
|
export default function QuizzesPage() {
|
||||||
|
const { user } = useAuth();
|
||||||
|
const [list, setList] = useState<QuizCard[] | null>(null);
|
||||||
|
const [filter, setFilter] = useState<Filter>("all");
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false;
|
||||||
|
quiz
|
||||||
|
.list()
|
||||||
|
.then((l) => !cancelled && setList(l))
|
||||||
|
.catch(() => !cancelled && setList([]));
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}, [user?.id]);
|
||||||
|
|
||||||
|
const shown = useMemo(
|
||||||
|
() =>
|
||||||
|
(list ?? []).filter((q) =>
|
||||||
|
filter === "all"
|
||||||
|
? true
|
||||||
|
: filter === "done"
|
||||||
|
? q.my.state === "completed"
|
||||||
|
: q.my.state !== "completed",
|
||||||
|
),
|
||||||
|
[list, filter],
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<main className="mx-auto w-full max-w-3xl flex-1 px-4 py-6 sm:px-6 sm:py-10">
|
||||||
|
<header className="mb-6">
|
||||||
|
<h1 className="font-[family-name:var(--font-title)] text-2xl font-semibold text-foreground sm:text-3xl">
|
||||||
|
Quizzes
|
||||||
|
</h1>
|
||||||
|
<p className="mt-1 text-sm text-muted-foreground">
|
||||||
|
Test what you've read and heard. Each quiz can be taken once, and
|
||||||
|
only you can see your results.
|
||||||
|
</p>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
{user && (
|
||||||
|
<div className="mb-4 flex gap-1">
|
||||||
|
{(
|
||||||
|
[
|
||||||
|
["all", "All"],
|
||||||
|
["todo", "To do"],
|
||||||
|
["done", "Finished"],
|
||||||
|
] as const
|
||||||
|
).map(([k, label]) => (
|
||||||
|
<button
|
||||||
|
key={k}
|
||||||
|
type="button"
|
||||||
|
onClick={() => setFilter(k)}
|
||||||
|
className={cn(
|
||||||
|
"rounded-full px-3 py-1 text-sm transition-colors",
|
||||||
|
filter === k
|
||||||
|
? "bg-primary text-primary-foreground dark:text-white"
|
||||||
|
: "text-muted-foreground hover:bg-muted hover:text-foreground",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{list === null ? (
|
||||||
|
<p className="text-sm text-muted-foreground">Loading…</p>
|
||||||
|
) : shown.length === 0 ? (
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
{list.length === 0
|
||||||
|
? "No quizzes yet — check back soon."
|
||||||
|
: "Nothing here."}
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<ul className="grid gap-3 sm:grid-cols-2">
|
||||||
|
{shown.map((q) => {
|
||||||
|
const done = q.my.state === "completed";
|
||||||
|
return (
|
||||||
|
<li key={q.id}>
|
||||||
|
<Link
|
||||||
|
href={`/quizzes/${q.id}`}
|
||||||
|
className="flex h-full flex-col rounded-2xl border border-border bg-card p-4 transition-colors hover:border-primary/40 hover:bg-muted/30"
|
||||||
|
>
|
||||||
|
<p className="text-xs font-medium text-primary">
|
||||||
|
{KIND[q.target_kind]}
|
||||||
|
</p>
|
||||||
|
<p className="mt-1 font-semibold leading-snug text-foreground">
|
||||||
|
{q.title}
|
||||||
|
</p>
|
||||||
|
{q.target_title && (
|
||||||
|
<p className="mt-0.5 truncate text-sm text-muted-foreground">
|
||||||
|
{q.target_title}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
<div className="mt-auto flex items-center justify-between gap-2 pt-4 text-xs">
|
||||||
|
<span className="text-muted-foreground">
|
||||||
|
{q.question_count}{" "}
|
||||||
|
{q.question_count === 1 ? "question" : "questions"}
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
"inline-flex items-center gap-1 font-medium",
|
||||||
|
done ? "text-muted-foreground" : "text-primary",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<ClipboardCheck className="size-3.5" />
|
||||||
|
{quizActionLabel(q.my.state)}
|
||||||
|
{done &&
|
||||||
|
q.my.score !== undefined &&
|
||||||
|
` · ${q.my.score}/${q.my.total}`}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</Link>
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -5,7 +5,8 @@
|
|||||||
* library.
|
* library.
|
||||||
*
|
*
|
||||||
* Two sections, top to bottom:
|
* 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.
|
* rendered as full-width feature tiles with longer copy.
|
||||||
* 2. Explore — a six-card asymmetric grid for the remaining sections.
|
* 2. Explore — a six-card asymmetric grid for the remaining sections.
|
||||||
*
|
*
|
||||||
@@ -26,6 +27,7 @@ import {
|
|||||||
PenLine,
|
PenLine,
|
||||||
Users,
|
Users,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
|
import { QuizzesCTA } from "@/components/quiz/quizzes-cta";
|
||||||
|
|
||||||
type Feature = {
|
type Feature = {
|
||||||
href: string;
|
href: string;
|
||||||
@@ -112,7 +114,11 @@ export function KnowledgebaseIndex() {
|
|||||||
<div>
|
<div>
|
||||||
<section className="py-4">
|
<section className="py-4">
|
||||||
<SectionHeading eyebrow="Start here" title="Three doors in" />
|
<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) => (
|
{FEATURED.map((f) => (
|
||||||
<FeaturedTile key={f.href} feature={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" />
|
<ArrowUpRight className="size-4 text-muted-foreground/60 group-hover:text-foreground transition-colors" />
|
||||||
</div>
|
</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}
|
{feature.label}
|
||||||
</h3>
|
</h3>
|
||||||
|
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ import { useRouter, usePathname } from "next/navigation";
|
|||||||
import { Check, Link2 } from "lucide-react";
|
import { Check, Link2 } from "lucide-react";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { SaveOfflineButton } from "@/components/layout/save-offline-button";
|
import { SaveOfflineButton } from "@/components/layout/save-offline-button";
|
||||||
|
import { QuizButton } from "@/components/quiz/quiz-button";
|
||||||
import type { OfflineDescriptor } from "@/lib/offline";
|
import type { OfflineDescriptor } from "@/lib/offline";
|
||||||
|
|
||||||
const SEARCH_RETURN_KEY = "spsays_from_search";
|
const SEARCH_RETURN_KEY = "spsays_from_search";
|
||||||
@@ -71,6 +72,7 @@ export function PageHeader({
|
|||||||
bare = false,
|
bare = false,
|
||||||
sticky = false,
|
sticky = false,
|
||||||
offline,
|
offline,
|
||||||
|
quiz = true,
|
||||||
}: {
|
}: {
|
||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
/** Drop the bordered chrome — useful for pages that want flush title text. */
|
/** Drop the bordered chrome — useful for pages that want flush title text. */
|
||||||
@@ -89,6 +91,13 @@ export function PageHeader({
|
|||||||
* visible gap.
|
* visible gap.
|
||||||
*/
|
*/
|
||||||
sticky?: boolean;
|
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;
|
className?: string;
|
||||||
}) {
|
}) {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
@@ -216,6 +225,9 @@ export function PageHeader({
|
|||||||
|
|
||||||
{/* Page-level controls, anchored at the header's bottom. */}
|
{/* Page-level controls, anchored at the header's bottom. */}
|
||||||
<div className="mt-3 flex flex-wrap items-center gap-2">
|
<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
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={copyPageLink}
|
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 { useEffect, useState } from "react";
|
||||||
|
import Image from "next/image";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { ChevronRight } from "lucide-react";
|
import { ChevronRight } from "lucide-react";
|
||||||
import { useAuth } from "@/components/auth/auth-provider";
|
import { useAuth } from "@/components/auth/auth-provider";
|
||||||
import { sadhna, type SadhnaDashboard } from "@/lib/sadhna";
|
import { sadhna, type SadhnaDashboard } from "@/lib/sadhna";
|
||||||
import { StreakFlame } from "@/components/sadhna/streak-flame";
|
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 { user, loading } = useAuth();
|
||||||
const authed = !loading && !!user;
|
const authed = !loading && !!user;
|
||||||
const [dash, setDash] = useState<SadhnaDashboard | null>(null);
|
const [dash, setDash] = useState<SadhnaDashboard | null>(null);
|
||||||
@@ -55,6 +65,35 @@ export function SadhnaStreakCTA() {
|
|||||||
? "One offering done — finish today"
|
? "One offering done — finish today"
|
||||||
: "Chant and post a quote to complete 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 (
|
return (
|
||||||
<Link
|
<Link
|
||||||
href="/sadhna-streak"
|
href="/sadhna-streak"
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import { useEffect, useState } from "react";
|
|||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { ChevronRight } from "lucide-react";
|
import { ChevronRight } from "lucide-react";
|
||||||
import { getVaniOfTheDay, type Vani } from "@/lib/vani";
|
import { getVaniOfTheDay, type Vani } from "@/lib/vani";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
/** Quote-marks glyph (svgrepo). Inherits color via `currentColor`. */
|
/** Quote-marks glyph (svgrepo). Inherits color via `currentColor`. */
|
||||||
function QuotesIcon({ className }: { className?: string }) {
|
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);
|
const [vani, setVani] = useState<Vani | null>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -57,11 +58,11 @@ export function VaniOfTheDayCTA() {
|
|||||||
return (
|
return (
|
||||||
<Link
|
<Link
|
||||||
href={vani.href}
|
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">
|
<div className="min-w-0 flex-1">
|
||||||
<p className="text-xs font-medium text-primary">Vani of the day</p>
|
<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">
|
<p className="mt-0.5 truncate text-base font-semibold text-foreground leading-snug">
|
||||||
|
|||||||
+121
@@ -0,0 +1,121 @@
|
|||||||
|
/**
|
||||||
|
* Browser-side client for quizzes.
|
||||||
|
*
|
||||||
|
* Correct answers never come down while an attempt is in progress — the BFF
|
||||||
|
* withholds them — so nothing here can reveal one early. Results are only
|
||||||
|
* ever the caller's own: the BFF reads the user from the session.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const BFF_BASE = process.env.NEXT_PUBLIC_BFF_URL || "http://localhost:8080";
|
||||||
|
|
||||||
|
export class QuizError extends Error {
|
||||||
|
constructor(
|
||||||
|
public status: number,
|
||||||
|
message: string,
|
||||||
|
) {
|
||||||
|
super(message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function call<T>(path: string, init?: RequestInit): Promise<T> {
|
||||||
|
const res = await fetch(`${BFF_BASE}/api/v1${path}`, {
|
||||||
|
credentials: "include",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
...init,
|
||||||
|
});
|
||||||
|
let json: { data?: T; error?: { message?: string } } | null = null;
|
||||||
|
try {
|
||||||
|
json = await res.json();
|
||||||
|
} catch {
|
||||||
|
/* empty body */
|
||||||
|
}
|
||||||
|
if (!res.ok) {
|
||||||
|
throw new QuizError(
|
||||||
|
res.status,
|
||||||
|
json?.error?.message || "Quiz request failed",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
// `data` can legitimately be null (no quiz on this page), so test for the
|
||||||
|
// key rather than falling back with `??`, which would return the envelope.
|
||||||
|
return (json && "data" in json ? json.data : json) as T;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type QuizState = "not_started" | "in_progress" | "completed";
|
||||||
|
|
||||||
|
export type QuizOption = {
|
||||||
|
id: string;
|
||||||
|
ord: number;
|
||||||
|
body: string;
|
||||||
|
is_correct?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type QuizQuestion = {
|
||||||
|
id: string;
|
||||||
|
ord: number;
|
||||||
|
prompt: string;
|
||||||
|
explanation?: string;
|
||||||
|
/** "Select all that apply": several options may be correct. */
|
||||||
|
multi: boolean;
|
||||||
|
options: QuizOption[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export type QuizCard = {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
|
target_kind: "chapter" | "lecture" | "conversation" | "standalone";
|
||||||
|
target_path?: string | null;
|
||||||
|
target_title: string;
|
||||||
|
question_count: number;
|
||||||
|
my: { state: QuizState; answered: number; total: number; score?: number };
|
||||||
|
};
|
||||||
|
|
||||||
|
export type QuizPlay = {
|
||||||
|
quiz_id: string;
|
||||||
|
title: string;
|
||||||
|
answered: number;
|
||||||
|
total: number;
|
||||||
|
done: boolean;
|
||||||
|
question?: QuizQuestion;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type QuizResult = {
|
||||||
|
quiz: QuizCard;
|
||||||
|
score: number;
|
||||||
|
total: number;
|
||||||
|
started_at: string;
|
||||||
|
completed_at?: string;
|
||||||
|
total_ms: number;
|
||||||
|
items: (QuizQuestion & {
|
||||||
|
chosen_option_ids: string[];
|
||||||
|
is_correct: boolean;
|
||||||
|
response_ms?: number;
|
||||||
|
})[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export const quiz = {
|
||||||
|
/** The quiz on a content page, or null when it has none. */
|
||||||
|
forPath: (path: string) =>
|
||||||
|
call<QuizCard | null>(`/quizzes/for?path=${encodeURIComponent(path)}`),
|
||||||
|
list: () => call<QuizCard[]>("/quizzes"),
|
||||||
|
get: (id: string) => call<QuizCard>(`/quizzes/${id}`),
|
||||||
|
/** Opens or resumes your attempt and returns the current question. */
|
||||||
|
start: (id: string) =>
|
||||||
|
call<QuizPlay>(`/quizzes/${id}/start`, { method: "POST" }),
|
||||||
|
/** One option, or several for a multi question. Final once sent. */
|
||||||
|
answer: (id: string, questionId: string, optionIds: string[]) =>
|
||||||
|
call<QuizPlay>(`/quizzes/${id}/answer`, {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify({ question_id: questionId, option_ids: optionIds }),
|
||||||
|
}),
|
||||||
|
result: (id: string) => call<QuizResult>(`/quizzes/${id}/result`),
|
||||||
|
};
|
||||||
|
|
||||||
|
/** The button's wording follows where you are in the quiz. */
|
||||||
|
export function quizActionLabel(state: QuizState): string {
|
||||||
|
return state === "completed"
|
||||||
|
? "View results"
|
||||||
|
: state === "in_progress"
|
||||||
|
? "Continue quiz"
|
||||||
|
: "Take quiz";
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user