"use client"; /** * Create or edit a quiz. * * Questions are multiple choice: 2–5 options, with exactly one correct — or, * for a "multiple answers" question, one or more correct. Prompts, * options, explanations and the description all take the same formatting * markers as the rest of the panel (bold, italic, underline, strike, links). * * Once anyone has taken a quiz its questions are frozen — their answers point * at those exact questions and options — so the editor shows them read-only * and only the title, description and target stay editable. */ import { useState } from "react"; import { ArrowDown, ArrowUp, Plus, Trash2 } from "lucide-react"; import { toast } from "sonner"; import { quizzes, AdminError, type Quiz, type QuizDraft, type QuizQuestion, } from "@/lib/api"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { RichTextarea } from "@/components/ui/rich-textarea"; import { RichText } from "@/lib/rich-text"; import { cn } from "@/lib/utils"; const MAX_OPTIONS = 5; const MIN_OPTIONS = 2; const blankQuestion = (): QuizQuestion => ({ prompt: "", explanation: "", multi: false, options: [ { body: "", is_correct: true }, { body: "", is_correct: false }, ], }); /** Mirrors the BFF's rules, so problems show before a round trip. */ function problemWith(q: QuizQuestion): string | null { if (!q.prompt.trim()) return "needs a prompt"; if (q.options.length < MIN_OPTIONS || q.options.length > MAX_OPTIONS) return `needs ${MIN_OPTIONS}–${MAX_OPTIONS} options`; if (q.options.some((o) => !o.body.trim())) return "has an empty option"; const correct = q.options.filter((o) => o.is_correct).length; if (q.multi && correct === 0) return "needs at least one correct option"; if (!q.multi && correct !== 1) return "needs exactly one correct option"; return null; } export function QuizEditor({ initial, onSaved, readOnly = false, }: { initial: Quiz | null; onSaved: (q: Quiz) => void; /** For staff who may view quizzes but not change them. */ readOnly?: boolean; }) { const taken = (initial?.attempt_count ?? 0) > 0; // Questions render frozen either way; only `taken` explains why. const locked = taken || readOnly; const [title, setTitle] = useState(initial?.title ?? ""); const [description, setDescription] = useState(initial?.description ?? ""); const [targetUrl, setTargetUrl] = useState(initial?.target_path ?? ""); const [targetTitle, setTargetTitle] = useState(initial?.target_title ?? ""); const [questions, setQuestions] = useState( initial?.questions?.length ? initial.questions : [blankQuestion()], ); const [saving, setSaving] = useState(false); const editQ = (i: number, patch: Partial) => setQuestions((qs) => qs.map((q, j) => (j === i ? { ...q, ...patch } : q))); const editOption = (qi: number, oi: number, body: string) => editQ(qi, { options: questions[qi].options.map((o, j) => j === oi ? { ...o, body } : o, ), }); // Single answer: picking one clears the rest. Multiple answers: toggles. const markCorrect = (qi: number, oi: number) => editQ(qi, { options: questions[qi].options.map((o, j) => questions[qi].multi ? j === oi ? { ...o, is_correct: !o.is_correct } : o : { ...o, is_correct: j === oi }, ), }); // Back to a single answer keeps only the first correct option. const setMulti = (qi: number, multi: boolean) => { const opts = questions[qi].options; const first = opts.findIndex((o) => o.is_correct); editQ(qi, { multi, options: multi ? opts : opts.map((o, j) => ({ ...o, is_correct: j === Math.max(first, 0) })), }); }; const removeOption = (qi: number, oi: number) => { const opts = questions[qi].options.filter((_, j) => j !== oi); // Removing the last correct option would leave none marked; promote the // first. if (!opts.some((o) => o.is_correct) && opts[0]) opts[0] = { ...opts[0], is_correct: true }; editQ(qi, { options: opts }); }; const move = (i: number, by: number) => setQuestions((qs) => { const j = i + by; if (j < 0 || j >= qs.length) return qs; const next = [...qs]; [next[i], next[j]] = [next[j], next[i]]; return next; }); const save = async () => { if (!title.trim()) return toast.error("Give the quiz a title"); if (!locked) { const bad = questions.map(problemWith).findIndex(Boolean); if (bad >= 0) return toast.error( `Question ${bad + 1} ${problemWith(questions[bad])}`, ); } const draft: QuizDraft = { title: title.trim(), description, target_url: targetUrl.trim(), target_title: targetUrl.trim() ? targetTitle.trim() : "", ...(locked ? {} : { questions }), }; setSaving(true); try { const saved = initial ? await quizzes.update(initial.id, draft) : await quizzes.create(draft); toast.success( initial ? "Quiz saved" : "Quiz created — publish it when it's ready", ); onSaved(saved); } catch (err) { toast.error( err instanceof AdminError ? err.message : "Could not save the quiz", ); } finally { setSaving(false); } }; return (
setTitle(e.target.value)} placeholder="Quiz title" maxLength={200} />
setTargetUrl(e.target.value)} placeholder="Link to a chapter, lecture or conversation" />

Paste the page's link, e.g. /books/bg/2 or /transcriptions/720720sb-london. Leave empty for a stand-alone quiz.

{targetUrl.trim() && (
setTargetTitle(e.target.value)} placeholder="Content title (filled in automatically)" />

Override only if the automatic title reads badly.

)}
{taken && !readOnly && (

{initial?.attempt_count}{" "} {initial?.attempt_count === 1 ? "person has" : "people have"} taken this quiz, so its questions can no longer change. You can still edit the title, introduction and link.

)}
    {questions.map((q, qi) => (
  1. Question {qi + 1}

    {!locked && (
    )}
    {locked ? (

    {q.multi && (

    Multiple answers

    )}
      {q.options.map((o, oi) => (
    • ))}
    ) : (
    editQ(qi, { prompt: v })} rows={2} maxLength={2000} placeholder="Question" />
    {q.multi ? "Options — tick every correct one" : "Options — select the correct one"} {q.options.map((o, oi) => (
    markCorrect(qi, oi)} className="mt-3 accent-primary" aria-label={`Option ${oi + 1} is correct`} /> editOption(qi, oi, v)} rows={1} maxLength={500} placeholder={`Option ${oi + 1}`} className="flex-1" />
    ))} {q.options.length < MAX_OPTIONS && ( )}
    editQ(qi, { explanation: v })} rows={2} maxLength={2000} placeholder="Explanation shown with the answer (optional)" />
    )}
  2. ))}
{!locked ? ( ) : ( )} {!readOnly && ( )}
); }