- ApiClient: getDiets/getAllergies (référence), getCurrentHouse/ renameHouse, updateDiet, getAllergyIds/updateAllergyIds. - features/profile/: HouseNameField, DietSelect (toujours une option "aucun régime" -> null, étape skippable), AllergySelect (checkboxes en grille + fieldset/legend, pas un <select multiple> — plus tapable/accessible, notamment sur mobile). Tous "dumb"/contrôlés : reçoivent leurs données (diets/allergies) en props plutôt que de les fetcher eux-mêmes — le fetch/état de chargement reste à la page appelante. - profile-forms.scss partagé par les trois (même split que features/auth/auth-form.scss vs Login/SignupPage : styles de champs ici, layout de page dans chaque page consommatrice). Pas encore utilisés (aucune page ne les importe) — le wizard d'inscription (étape suivante) et la page /foyer les cablent.
146 lines
5.3 KiB
TypeScript
146 lines
5.3 KiB
TypeScript
import {
|
|
type AllergyView,
|
|
type ApiErrorResponse,
|
|
type DietView,
|
|
ErrorCode,
|
|
type HouseView,
|
|
type LoginInput,
|
|
type PlanningView,
|
|
type SafeUserProfile,
|
|
type SignupInput,
|
|
} from "@batch-cooking/shared";
|
|
|
|
/** Base URL of the API, configurable via `VITE_API_URL` (see `.env.example`). */
|
|
const API_BASE_URL: string = import.meta.env.VITE_API_URL ?? "http://localhost:3000";
|
|
|
|
/**
|
|
* Thrown by {@link ApiClient} whenever the API responds with a non-2xx
|
|
* status. Carries the same {@link ErrorCode} the API returned, so callers
|
|
* can branch on `error.code` (and UI code can look up its label via
|
|
* `ErrorMessageService.getLabel(error.code)`) instead of parsing text.
|
|
*/
|
|
export class ApiError extends Error {
|
|
/** HTTP status code of the failed response. */
|
|
public readonly status: number;
|
|
/** Machine-readable error code — see {@link ErrorCode}. */
|
|
public readonly code: ErrorCode;
|
|
/** Per-field validation messages, present only when `code` is `VALIDATION_ERROR`. */
|
|
public readonly fieldErrors?: Record<string, string[] | undefined>;
|
|
|
|
public constructor(status: number, body: ApiErrorResponse) {
|
|
super(body.message);
|
|
this.name = "ApiError";
|
|
this.status = status;
|
|
this.code = body.code;
|
|
this.fieldErrors = body.details;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Thin fetch wrapper around the auth endpoints. A class (rather than plain
|
|
* functions) so it reads as a cohesive service and stays easy to extend
|
|
* (e.g. swapping the transport, adding request interceptors) without
|
|
* touching every call site. Used as a single shared instance (`apiClient`,
|
|
* exported below) — it's stateless, so there's no reason for more than one.
|
|
*/
|
|
export class ApiClient {
|
|
/**
|
|
* Performs a JSON request against the API and returns the parsed body.
|
|
*
|
|
* @throws {ApiError} if the response status is not in the 2xx range.
|
|
*/
|
|
private async request<TResponseBody>(
|
|
path: string,
|
|
options: RequestInit = {},
|
|
): Promise<TResponseBody> {
|
|
const response = await fetch(`${API_BASE_URL}${path}`, {
|
|
...options,
|
|
// Required for the httpOnly session cookie to be sent/received — the
|
|
// API and the web app run on different origins.
|
|
credentials: "include",
|
|
headers: { "Content-Type": "application/json", ...options.headers },
|
|
});
|
|
|
|
if (!response.ok) {
|
|
const body = (await response.json().catch(() => null)) as ApiErrorResponse | null;
|
|
// Fallback for a response that couldn't even be parsed as JSON — no
|
|
// hardcoded string, always the real enum member.
|
|
throw new ApiError(
|
|
response.status,
|
|
body ?? { code: ErrorCode.INTERNAL_ERROR, message: "Something went wrong" },
|
|
);
|
|
}
|
|
|
|
// 204 No Content (e.g. logout) has no body to parse.
|
|
if (response.status === 204) {
|
|
return undefined as TResponseBody;
|
|
}
|
|
return response.json() as Promise<TResponseBody>;
|
|
}
|
|
|
|
/** Creates a profile (+ household) and starts a session. */
|
|
public signup(input: SignupInput): Promise<SafeUserProfile> {
|
|
return this.request("/auth/signup", { method: "POST", body: JSON.stringify(input) });
|
|
}
|
|
|
|
/** Verifies credentials and starts a session. */
|
|
public login(input: LoginInput): Promise<SafeUserProfile> {
|
|
return this.request("/auth/login", { method: "POST", body: JSON.stringify(input) });
|
|
}
|
|
|
|
/** Ends the current session. */
|
|
public logout(): Promise<void> {
|
|
return this.request("/auth/logout", { method: "POST" });
|
|
}
|
|
|
|
/** Fetches the currently authenticated profile — rejects with `NOT_AUTHENTICATED` if there's no session. */
|
|
public me(): Promise<SafeUserProfile> {
|
|
return this.request("/auth/me");
|
|
}
|
|
|
|
/** Fetches the current user's household's planning for today, or `null` if there isn't one yet. */
|
|
public getCurrentPlanning(): Promise<PlanningView | null> {
|
|
return this.request("/planning/current");
|
|
}
|
|
|
|
/** Reference list of dietary regimes to pick from (signup wizard, `/foyer`). Public — no session required. */
|
|
public getDiets(): Promise<DietView[]> {
|
|
return this.request("/reference/diets");
|
|
}
|
|
|
|
/** Reference list of selectable allergens (signup wizard, `/foyer`). Public — no session required. */
|
|
public getAllergies(): Promise<AllergyView[]> {
|
|
return this.request("/reference/allergies");
|
|
}
|
|
|
|
/** Fetches the current user's household. */
|
|
public getCurrentHouse(): Promise<HouseView | null> {
|
|
return this.request("/house/current");
|
|
}
|
|
|
|
/** Renames the current user's household. */
|
|
public renameHouse(name: string): Promise<HouseView> {
|
|
return this.request("/house/current", { method: "PATCH", body: JSON.stringify({ name }) });
|
|
}
|
|
|
|
/** Sets (or clears, with `null`) the current user's dietary regime. */
|
|
public updateDiet(dietId: number | null): Promise<SafeUserProfile> {
|
|
return this.request("/profile/diet", { method: "PATCH", body: JSON.stringify({ dietId }) });
|
|
}
|
|
|
|
/** Fetches the current user's selected allergen ids. */
|
|
public getAllergyIds(): Promise<number[]> {
|
|
return this.request("/profile/allergies");
|
|
}
|
|
|
|
/** Replaces the current user's full allergen selection (not a merge — send the complete list). */
|
|
public updateAllergyIds(allergyIds: number[]): Promise<number[]> {
|
|
return this.request("/profile/allergies", {
|
|
method: "PATCH",
|
|
body: JSON.stringify({ allergyIds }),
|
|
});
|
|
}
|
|
}
|
|
|
|
/** Single shared instance — this client is stateless, no need for one per caller. */
|
|
export const apiClient = new ApiClient();
|