Files
2026-10-18 13:02:30 +05:30

124 lines
3.4 KiB
TypeScript

/**
* 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;
/** 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<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";
}