feat: quiz
This commit is contained in:
+121
@@ -0,0 +1,121 @@
|
||||
/**
|
||||
* 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;
|
||||
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";
|
||||
}
|
||||
Reference in New Issue
Block a user