/** * 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(path: string, init?: RequestInit): Promise { 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; /** Year of the lecture / conversation, when known. */ target_year?: number | null; 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(`/quizzes/for?path=${encodeURIComponent(path)}`), list: () => call("/quizzes"), get: (id: string) => call(`/quizzes/${id}`), /** Opens or resumes your attempt and returns the current question. */ start: (id: string) => call(`/quizzes/${id}/start`, { method: "POST" }), /** One option, or several for a multi question. Final once sent. */ answer: (id: string, questionId: string, optionIds: string[]) => call(`/quizzes/${id}/answer`, { method: "POST", body: JSON.stringify({ question_id: questionId, option_ids: optionIds }), }), result: (id: string) => call(`/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"; }