quiz seo image and authguard
This commit is contained in:
+23
-299
@@ -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
|
* Link previews name the quiz. The quiz's card is the BFF's one public quiz
|
||||||
* result.
|
* endpoint — title, description and what it's attached to, never questions —
|
||||||
*
|
* so a shared link unfurls even though taking the quiz needs an account.
|
||||||
* 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.
|
|
||||||
*/
|
*/
|
||||||
|
export async function generateMetadata({ params }: Props): Promise<Metadata> {
|
||||||
import { useCallback, useEffect, useState } from "react";
|
const { id } = await params;
|
||||||
import Link from "next/link";
|
const card = await bff<QuizCard>(`/quizzes/${encodeURIComponent(id)}`, {
|
||||||
import { useParams } from "next/navigation";
|
revalidate: 300,
|
||||||
import {
|
}).catch(() => null);
|
||||||
ArrowLeft,
|
if (!card) return { title: "Quiz" };
|
||||||
ArrowRight,
|
return {
|
||||||
Check,
|
title: card.title,
|
||||||
ClipboardCheck,
|
description: `A quiz on Prabhupāda Says — ${card.question_count} question${card.question_count === 1 ? "" : "s"}.`,
|
||||||
Loader2,
|
...ogForQuiz(card),
|
||||||
} 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) => {
|
export default async function QuizPage({ params }: Props) {
|
||||||
if (!play.question || submitting || selected.length === 0) return;
|
const { id } = await params;
|
||||||
setSubmitting(true);
|
return <QuizPlayer id={id} />;
|
||||||
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,309 @@
|
|||||||
|
"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 { 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<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(() => {
|
||||||
|
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 (
|
||||||
|
<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>
|
||||||
|
);
|
||||||
|
}
|
||||||
+9
-142
@@ -1,147 +1,14 @@
|
|||||||
"use client";
|
import type { Metadata } from "next";
|
||||||
|
import { ogForQuizzes } from "@/lib/og";
|
||||||
|
import { QuizzesList } from "./quizzes-list";
|
||||||
|
|
||||||
/**
|
export const metadata: Metadata = {
|
||||||
* /quizzes — every published quiz, with where you are in each.
|
title: "Quizzes",
|
||||||
*
|
description:
|
||||||
* Reached from the Quizzes card on the search page. Quizzes are deliberately
|
"Test your understanding of Śrīla Prabhupāda's books, lectures and conversations.",
|
||||||
* not in search results: they're something to do, not something to look up.
|
...ogForQuizzes(),
|
||||||
*/
|
|
||||||
|
|
||||||
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() {
|
export default function QuizzesPage() {
|
||||||
const { user } = useAuth();
|
return <QuizzesList />;
|
||||||
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>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,150 @@
|
|||||||
|
"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.
|
||||||
|
* Signed in only: visitors get the sign-in modal, and home if they dismiss it.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useEffect, useMemo, useState } from "react";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { ClipboardCheck } from "lucide-react";
|
||||||
|
import { useSignedInPage } from "@/components/auth/use-signed-in-page";
|
||||||
|
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 function QuizzesList() {
|
||||||
|
const { user } = useSignedInPage();
|
||||||
|
const userId = user?.id;
|
||||||
|
const [list, setList] = useState<QuizCard[] | null>(null);
|
||||||
|
const [filter, setFilter] = useState<Filter>("all");
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!userId) return;
|
||||||
|
let cancelled = false;
|
||||||
|
quiz
|
||||||
|
.list()
|
||||||
|
.then((l) => !cancelled && setList(l))
|
||||||
|
.catch(() => !cancelled && setList([]));
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}, [userId]);
|
||||||
|
|
||||||
|
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>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!user ? null : 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* useSignedInPage — for a page that only makes sense signed in.
|
||||||
|
*
|
||||||
|
* Signed-out visitors get the sign-in modal over the page; dismissing it
|
||||||
|
* takes them home. This is the same gate /notifications, /chats and /u/me
|
||||||
|
* each wire up by hand. Returns the user (null until signed in) so the page
|
||||||
|
* can hold its content back until then.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useEffect } from "react";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
import { useAuth } from "@/components/auth/auth-provider";
|
||||||
|
import { useAuthGate } from "@/components/auth/auth-gate";
|
||||||
|
|
||||||
|
export function useSignedInPage() {
|
||||||
|
const { user, loading } = useAuth();
|
||||||
|
const { requireAuth } = useAuthGate();
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (loading || user) return;
|
||||||
|
let cancelled = false;
|
||||||
|
(async () => {
|
||||||
|
const ok = await requireAuth();
|
||||||
|
if (!ok && !cancelled) router.replace("/");
|
||||||
|
})();
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}, [loading, user, requireAuth, router]);
|
||||||
|
|
||||||
|
return { user, loading };
|
||||||
|
}
|
||||||
@@ -5,6 +5,7 @@
|
|||||||
*
|
*
|
||||||
* Asks the BFF whether this page has a published quiz and renders nothing if
|
* 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.
|
* 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.
|
* Its label tracks progress: Take quiz → Continue quiz → View results.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
@@ -24,10 +25,12 @@ export function QuizButton({
|
|||||||
className?: string;
|
className?: string;
|
||||||
}) {
|
}) {
|
||||||
const { user } = useAuth();
|
const { user } = useAuth();
|
||||||
|
const userId = user?.id;
|
||||||
const [card, setCard] = useState<QuizCard | null>(null);
|
const [card, setCard] = useState<QuizCard | null>(null);
|
||||||
|
|
||||||
// Re-asked when the user changes, so progress appears right after sign-in.
|
// Re-asked when the user changes, so progress appears right after sign-in.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
if (!userId) return;
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
quiz
|
quiz
|
||||||
.forPath(path)
|
.forPath(path)
|
||||||
@@ -40,9 +43,10 @@ export function QuizButton({
|
|||||||
return () => {
|
return () => {
|
||||||
cancelled = true;
|
cancelled = true;
|
||||||
};
|
};
|
||||||
}, [path, user?.id]);
|
}, [path, userId]);
|
||||||
|
|
||||||
if (!card) return null;
|
// 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 done = card.my.state === "completed";
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -6,7 +6,8 @@
|
|||||||
* - "tile": a card that leads the search page's "Start here" row.
|
* - "tile": a card that leads the search page's "Start here" row.
|
||||||
* - "banner": a full-width row for the homepage, under sādhana / vāṇī.
|
* - "banner": a full-width row for the homepage, under sādhana / vāṇī.
|
||||||
*
|
*
|
||||||
* Renders nothing until there is at least one published quiz; with
|
* Signed-in readers only, like quizzes themselves. Renders nothing until
|
||||||
|
* there is at least one published quiz; with
|
||||||
* `pendingOnly` it also hides once every quiz has been finished, so the
|
* `pendingOnly` it also hides once every quiz has been finished, so the
|
||||||
* homepage only nudges when there's something left to do.
|
* homepage only nudges when there's something left to do.
|
||||||
*/
|
*/
|
||||||
@@ -28,9 +29,11 @@ export function QuizzesCTA({
|
|||||||
className?: string;
|
className?: string;
|
||||||
}) {
|
}) {
|
||||||
const { user } = useAuth();
|
const { user } = useAuth();
|
||||||
|
const userId = user?.id;
|
||||||
const [cards, setCards] = useState<QuizCard[] | null>(null);
|
const [cards, setCards] = useState<QuizCard[] | null>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
if (!userId) return;
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
quiz
|
quiz
|
||||||
.list()
|
.list()
|
||||||
@@ -44,9 +47,10 @@ export function QuizzesCTA({
|
|||||||
cancelled = true;
|
cancelled = true;
|
||||||
};
|
};
|
||||||
// Refetch on sign-in/out: progress is per user.
|
// Refetch on sign-in/out: progress is per user.
|
||||||
}, [user?.id]);
|
}, [userId]);
|
||||||
|
|
||||||
if (!cards?.length) return null;
|
// Cards left over from before sign-out are ignored rather than cleared.
|
||||||
|
if (!user || !cards?.length) return null;
|
||||||
|
|
||||||
if (pendingOnly && cards.every((c) => c.my.state === "completed")) {
|
if (pendingOnly && cards.every((c) => c.my.state === "completed")) {
|
||||||
return null;
|
return null;
|
||||||
|
|||||||
@@ -318,6 +318,68 @@ export function ogForLettersByLocation(location: string, count: number) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Quizzes
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
const QUIZ_DESCRIPTION =
|
||||||
|
"Test your understanding of Śrīla Prabhupāda's books, lectures and conversations.";
|
||||||
|
|
||||||
|
const QUIZ_KIND_LABEL: Record<string, string> = {
|
||||||
|
chapter: "Book chapter",
|
||||||
|
lecture: "Lecture",
|
||||||
|
conversation: "Conversation",
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Admin text carries light formatting markers (*bold*, _italic_, links…).
|
||||||
|
* A preview card wants the words only.
|
||||||
|
*/
|
||||||
|
function stripRichMarkers(text: string): string {
|
||||||
|
return text
|
||||||
|
.replace(/\[([^\]\n]+)\]\((https?:\/\/[^\s)]+)\)/g, "$1")
|
||||||
|
.replace(/__([^_\n]+?)__/g, "$1")
|
||||||
|
.replace(/\*([^*\n]+?)\*/g, "$1")
|
||||||
|
.replace(/~([^~\n]+?)~/g, "$1")
|
||||||
|
.replace(/(^|\W)_([^_\n]+?)_(?=\W|$)/g, "$1$2")
|
||||||
|
.replace(/\s+/g, " ")
|
||||||
|
.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** OG metadata for a single quiz. */
|
||||||
|
export function ogForQuiz(quiz: {
|
||||||
|
title: string;
|
||||||
|
description?: string;
|
||||||
|
target_kind: string;
|
||||||
|
target_title?: string;
|
||||||
|
question_count: number;
|
||||||
|
}) {
|
||||||
|
const kind = QUIZ_KIND_LABEL[quiz.target_kind];
|
||||||
|
const count = `${quiz.question_count} question${quiz.question_count === 1 ? "" : "s"}`;
|
||||||
|
const detail = [
|
||||||
|
"Quiz",
|
||||||
|
kind && quiz.target_title ? `${kind}: ${quiz.target_title}` : null,
|
||||||
|
count,
|
||||||
|
]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(" · ");
|
||||||
|
const about = stripRichMarkers(quiz.description ?? "");
|
||||||
|
return buildOgMeta({
|
||||||
|
title: quiz.title,
|
||||||
|
detail,
|
||||||
|
quote: about || QUIZ_DESCRIPTION,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** OG metadata for the /quizzes index. */
|
||||||
|
export function ogForQuizzes() {
|
||||||
|
return buildOgMeta({
|
||||||
|
title: "Quizzes",
|
||||||
|
detail: "Prabhupāda Says",
|
||||||
|
quote: QUIZ_DESCRIPTION,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// hl= query param → OG quote extraction
|
// hl= query param → OG quote extraction
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|||||||
Reference in New Issue
Block a user