From 0e2e0d7300fa75e358ba0fd106432ef6bc0a45d4 Mon Sep 17 00:00:00 2001 From: gosangam Date: Thu, 15 Oct 2026 21:09:37 +0530 Subject: [PATCH] quiz seo image and authguard --- app/(app)/quizzes/[id]/page.tsx | 324 ++----------------------- app/(app)/quizzes/[id]/quiz-player.tsx | 309 +++++++++++++++++++++++ app/(app)/quizzes/page.tsx | 151 +----------- app/(app)/quizzes/quizzes-list.tsx | 150 ++++++++++++ components/auth/use-signed-in-page.ts | 35 +++ components/quiz/quiz-button.tsx | 8 +- components/quiz/quizzes-cta.tsx | 10 +- lib/og.ts | 62 +++++ 8 files changed, 602 insertions(+), 447 deletions(-) create mode 100644 app/(app)/quizzes/[id]/quiz-player.tsx create mode 100644 app/(app)/quizzes/quizzes-list.tsx create mode 100644 components/auth/use-signed-in-page.ts diff --git a/app/(app)/quizzes/[id]/page.tsx b/app/(app)/quizzes/[id]/page.tsx index 11e19a89..dcebe450 100644 --- a/app/(app)/quizzes/[id]/page.tsx +++ b/app/(app)/quizzes/[id]/page.tsx @@ -1,306 +1,30 @@ -"use client"; +import type { Metadata } from "next"; +import { bff } from "@/lib/bff"; +import { ogForQuiz } from "@/lib/og"; +import type { QuizCard } from "@/lib/quiz"; +import { QuizPlayer } from "./quiz-player"; + +type Props = { params: Promise<{ id: string }> }; /** - * Taking a quiz: an introduction, then one question at a time, then your - * result. - * - * Each question is fetched from the BFF as you reach it, and the BFF starts - * its clock at that moment — so response times are measured server-side and - * reloading the page doesn't restart them. Correct answers are only revealed - * on the results screen, never while you're still answering. - * - * Tapping an option only selects it; the answer is sent, and becomes final, - * when you press Submit. Multi questions ("select all that apply") take - * several selections. + * Link previews name the quiz. The quiz's card is the BFF's one public quiz + * endpoint — title, description and what it's attached to, never questions — + * so a shared link unfurls even though taking the quiz needs an account. */ - -import { useCallback, useEffect, useState } from "react"; -import Link from "next/link"; -import { useParams } from "next/navigation"; -import { - ArrowLeft, - ArrowRight, - Check, - ClipboardCheck, - Loader2, -} from "lucide-react"; -import { toast } from "sonner"; -import { useAuth } from "@/components/auth/auth-provider"; -import { useAuthGate } from "@/components/auth/auth-gate"; -import { RichText } from "@/lib/rich-text"; -import { - quiz as quizApi, - quizActionLabel, - QuizError, - type QuizCard, - type QuizPlay, - type QuizResult, -} from "@/lib/quiz"; -import { QuizResultView } from "@/components/quiz/quiz-result-view"; -import { cn } from "@/lib/utils"; - -type Stage = - | { kind: "loading" } - | { kind: "intro"; card: QuizCard } - | { kind: "playing"; card: QuizCard; play: QuizPlay } - | { kind: "result"; card: QuizCard; result: QuizResult } - | { kind: "missing" }; - -export default function QuizPage() { - const { id } = useParams<{ id: string }>(); - const { user } = useAuth(); - const { requireAuth } = useAuthGate(); - const [stage, setStage] = useState({ kind: "loading" }); - const [selected, setSelected] = useState([]); - const [submitting, setSubmitting] = useState(false); - - // Every new question starts with nothing selected. - const goTo = (card: QuizCard, play: QuizPlay) => { - setSelected([]); - setStage({ kind: "playing", card, play }); +export async function generateMetadata({ params }: Props): Promise { + const { id } = await params; + const card = await bff(`/quizzes/${encodeURIComponent(id)}`, { + revalidate: 300, + }).catch(() => null); + if (!card) return { title: "Quiz" }; + return { + title: card.title, + description: `A quiz on Prabhupāda Says — ${card.question_count} question${card.question_count === 1 ? "" : "s"}.`, + ...ogForQuiz(card), }; +} - const toggle = (play: QuizPlay, optionId: string) => { - if (submitting) return; - setSelected((cur) => - play.question?.multi - ? cur.includes(optionId) - ? cur.filter((x) => x !== optionId) - : [...cur, optionId] - : [optionId], - ); - }; - - const showResult = useCallback( - async (card: QuizCard) => { - const result = await quizApi.result(id); - setStage({ kind: "result", card, result }); - }, - [id], - ); - - useEffect(() => { - let cancelled = false; - quizApi - .get(id) - .then(async (card) => { - if (cancelled) return; - if (card.my.state === "completed") await showResult(card); - else setStage({ kind: "intro", card }); - }) - .catch(() => !cancelled && setStage({ kind: "missing" })); - return () => { - cancelled = true; - }; - // user?.id: progress appears once you sign in. - }, [id, user?.id, showResult]); - - const begin = async (card: QuizCard) => { - if (!(await requireAuth())) return; - try { - const play = await quizApi.start(id); - if (play.done) await showResult(card); - else goTo(card, play); - } catch (err) { - toast.error( - err instanceof QuizError ? err.message : "Could not start the quiz", - ); - } - }; - - const submit = async (card: QuizCard, play: QuizPlay) => { - if (!play.question || submitting || selected.length === 0) return; - setSubmitting(true); - try { - const next = await quizApi.answer(id, play.question.id, selected); - if (next.done) await showResult(card); - else goTo(card, next); - } catch (err) { - // Most likely the question was already answered in another tab: pick up - // wherever the attempt actually is rather than leaving this one stuck. - toast.error( - err instanceof QuizError ? err.message : "Could not save that answer", - ); - try { - const resumed = await quizApi.start(id); - if (resumed.done) await showResult(card); - else goTo(card, resumed); - } catch { - /* the toast already said something went wrong */ - } - } finally { - setSubmitting(false); - } - }; - - return ( -
- - All quizzes - - - {stage.kind === "loading" && ( -

Loading…

- )} - - {stage.kind === "missing" && ( -

- This quiz isn't available. -

- )} - - {(stage.kind === "intro" || - stage.kind === "playing" || - stage.kind === "result") && ( -
-

- {stage.card.title} -

- {stage.card.target_path && ( - - {stage.card.target_title || stage.card.target_path} - - )} -
- )} - - {stage.kind === "intro" && ( -
- {stage.card.description && ( -
- -
- )} -

- {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. -

- {stage.card.my.state === "in_progress" && ( -

- You've answered {stage.card.my.answered} of{" "} - {stage.card.my.total}. -

- )} - -
- )} - - {stage.kind === "playing" && stage.play.question && ( -
-
-
- - Question {stage.play.answered + 1} of {stage.play.total} - -
-
-
-
-
- -
-

- -

-

- {stage.play.question.multi - ? "Select all that apply" - : "Select one answer"} -

-
- -
    - {stage.play.question.options.map((o) => { - const on = selected.includes(o.id); - const multi = stage.play.question?.multi; - return ( -
  • - -
  • - ); - })} -
- -
- -
-
- )} - - {stage.kind === "result" && } -
- ); +export default async function QuizPage({ params }: Props) { + const { id } = await params; + return ; } diff --git a/app/(app)/quizzes/[id]/quiz-player.tsx b/app/(app)/quizzes/[id]/quiz-player.tsx new file mode 100644 index 00000000..7bf71f9c --- /dev/null +++ b/app/(app)/quizzes/[id]/quiz-player.tsx @@ -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({ kind: "loading" }); + const [selected, setSelected] = useState([]); + 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 ( +
+ + All quizzes + + + {stage.kind === "loading" && ( +

Loading…

+ )} + + {stage.kind === "missing" && ( +

+ This quiz isn't available. +

+ )} + + {(stage.kind === "intro" || + stage.kind === "playing" || + stage.kind === "result") && ( +
+

+ {stage.card.title} +

+ {stage.card.target_path && ( + + {stage.card.target_title || stage.card.target_path} + + )} +
+ )} + + {stage.kind === "intro" && ( +
+ {stage.card.description && ( +
+ +
+ )} +

+ {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. +

+ {stage.card.my.state === "in_progress" && ( +

+ You've answered {stage.card.my.answered} of{" "} + {stage.card.my.total}. +

+ )} + +
+ )} + + {stage.kind === "playing" && stage.play.question && ( +
+
+
+ + Question {stage.play.answered + 1} of {stage.play.total} + +
+
+
+
+
+ +
+

+ +

+

+ {stage.play.question.multi + ? "Select all that apply" + : "Select one answer"} +

+
+ +
    + {stage.play.question.options.map((o) => { + const on = selected.includes(o.id); + const multi = stage.play.question?.multi; + return ( +
  • + +
  • + ); + })} +
+ +
+ +
+
+ )} + + {stage.kind === "result" && } +
+ ); +} diff --git a/app/(app)/quizzes/page.tsx b/app/(app)/quizzes/page.tsx index 89919a4c..e894215f 100644 --- a/app/(app)/quizzes/page.tsx +++ b/app/(app)/quizzes/page.tsx @@ -1,147 +1,14 @@ -"use client"; +import type { Metadata } from "next"; +import { ogForQuizzes } from "@/lib/og"; +import { QuizzesList } from "./quizzes-list"; -/** - * /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 = { - chapter: "Book chapter", - lecture: "Lecture", - conversation: "Conversation", - standalone: "Quiz", +export const metadata: Metadata = { + title: "Quizzes", + description: + "Test your understanding of Śrīla Prabhupāda's books, lectures and conversations.", + ...ogForQuizzes(), }; -type Filter = "all" | "todo" | "done"; - export default function QuizzesPage() { - const { user } = useAuth(); - const [list, setList] = useState(null); - const [filter, setFilter] = useState("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 ( -
-
-

- Quizzes -

-

- Test what you've read and heard. Each quiz can be taken once, and - only you can see your results. -

-
- - {user && ( -
- {( - [ - ["all", "All"], - ["todo", "To do"], - ["done", "Finished"], - ] as const - ).map(([k, label]) => ( - - ))} -
- )} - - {list === null ? ( -

Loading…

- ) : shown.length === 0 ? ( -

- {list.length === 0 - ? "No quizzes yet — check back soon." - : "Nothing here."} -

- ) : ( -
    - {shown.map((q) => { - const done = q.my.state === "completed"; - return ( -
  • - -

    - {KIND[q.target_kind]} -

    -

    - {q.title} -

    - {q.target_title && ( -

    - {q.target_title} -

    - )} -
    - - {q.question_count}{" "} - {q.question_count === 1 ? "question" : "questions"} - - - - {quizActionLabel(q.my.state)} - {done && - q.my.score !== undefined && - ` · ${q.my.score}/${q.my.total}`} - -
    - -
  • - ); - })} -
- )} -
- ); + return ; } diff --git a/app/(app)/quizzes/quizzes-list.tsx b/app/(app)/quizzes/quizzes-list.tsx new file mode 100644 index 00000000..f5a6055b --- /dev/null +++ b/app/(app)/quizzes/quizzes-list.tsx @@ -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 = { + 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(null); + const [filter, setFilter] = useState("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 ( +
+
+

+ Quizzes +

+

+ Test what you've read and heard. Each quiz can be taken once, and + only you can see your results. +

+
+ + {user && ( +
+ {( + [ + ["all", "All"], + ["todo", "To do"], + ["done", "Finished"], + ] as const + ).map(([k, label]) => ( + + ))} +
+ )} + + {!user ? null : list === null ? ( +

Loading…

+ ) : shown.length === 0 ? ( +

+ {list.length === 0 + ? "No quizzes yet — check back soon." + : "Nothing here."} +

+ ) : ( +
    + {shown.map((q) => { + const done = q.my.state === "completed"; + return ( +
  • + +

    + {KIND[q.target_kind]} +

    +

    + {q.title} +

    + {q.target_title && ( +

    + {q.target_title} +

    + )} +
    + + {q.question_count}{" "} + {q.question_count === 1 ? "question" : "questions"} + + + + {quizActionLabel(q.my.state)} + {done && + q.my.score !== undefined && + ` · ${q.my.score}/${q.my.total}`} + +
    + +
  • + ); + })} +
+ )} +
+ ); +} diff --git a/components/auth/use-signed-in-page.ts b/components/auth/use-signed-in-page.ts new file mode 100644 index 00000000..0f0db64a --- /dev/null +++ b/components/auth/use-signed-in-page.ts @@ -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 }; +} diff --git a/components/quiz/quiz-button.tsx b/components/quiz/quiz-button.tsx index 78bbdacf..c9f84e9b 100644 --- a/components/quiz/quiz-button.tsx +++ b/components/quiz/quiz-button.tsx @@ -5,6 +5,7 @@ * * Asks the BFF whether this page has a published quiz and renders nothing if * not, so it can sit on every content page without leaving an empty slot. + * Quizzes are for signed-in readers only, so signed out it never asks. * Its label tracks progress: Take quiz → Continue quiz → View results. */ @@ -24,10 +25,12 @@ export function QuizButton({ className?: string; }) { const { user } = useAuth(); + const userId = user?.id; const [card, setCard] = useState(null); // Re-asked when the user changes, so progress appears right after sign-in. useEffect(() => { + if (!userId) return; let cancelled = false; quiz .forPath(path) @@ -40,9 +43,10 @@ export function QuizButton({ return () => { 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"; return ( diff --git a/components/quiz/quizzes-cta.tsx b/components/quiz/quizzes-cta.tsx index e14ccec4..125f3dc2 100644 --- a/components/quiz/quizzes-cta.tsx +++ b/components/quiz/quizzes-cta.tsx @@ -6,7 +6,8 @@ * - "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 + * 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 * homepage only nudges when there's something left to do. */ @@ -28,9 +29,11 @@ export function QuizzesCTA({ className?: string; }) { const { user } = useAuth(); + const userId = user?.id; const [cards, setCards] = useState(null); useEffect(() => { + if (!userId) return; let cancelled = false; quiz .list() @@ -44,9 +47,10 @@ export function QuizzesCTA({ cancelled = true; }; // 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")) { return null; diff --git a/lib/og.ts b/lib/og.ts index 2f93374e..63b408a4 100644 --- a/lib/og.ts +++ b/lib/og.ts @@ -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 = { + 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 // ---------------------------------------------------------------------------