124 lines
3.4 KiB
TypeScript
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";
|
|
}
|