Consomme le socle backend de #42/#44 (`GET /reference/tours`, `GET/POST /profile/tours`). Deux tours : orientation générale (auto sur `/`) et correction des métadonnées de recette (auto à l'ouverture du premier popover de correction). - Dépendance `driver.js` (~5 ko, MIT, zéro dépendance). - `features/tour/tour-engine.ts` : wrapper vanilla autour de `driver.js` — gère ce que la lib ne fait pas seule : la navigation React Router entre étapes situées sur des routes différentes, et un `onFinish` unique (`"completed"` seulement si on atteint la fin, `"skipped"` pour la croix / Échap / clic sur l'overlay). - `features/tour/TourContext.tsx` : `TourProvider` (charge scénarios + progression, keyed sur `user?.id` comme `ThemeContext`) et `useTour()` (`startTour(key)` / `isTourCompleted(key)` / `tours` / `isReady`). `isReady` est **calculé au render** contre l'id utilisateur chargé — pas stocké — pour qu'un changement de `user` le repasse à `false` immédiatement, avant qu'un effet enfant (l'auto-déclenchement) ne tourne sur des données de complétion périmées. - `main.tsx` : `<TourProvider>` sous `ThemeProvider`. - Attributs `data-tour-*` : liens de nav (`AppLayout`), grille / bouton `+` du 1er créneau / bouton Cuisiner (`PlanningPage`), page liste de courses (`ShoppingListPage`), description d'étape + section technique du popover (`StepDescription` / `TechStepCorrectionPopover`). - Déclenchement auto : tour `general` sur `/` (`PlanningPage`, ref-gardé), tour `techStepMetadata` à `activeCorrection` non-null (`StepDescription`). - Section « Revoir un tutoriel » dans `/parametres/preferences-utilisateur` — un `map` sur `tours`, un bouton « Revoir » par scénario. - `tour.scss` re-skinne le popover `driver.js` sur les tokens du thème. - i18n `tour.*` (chrome uniquement : boutons du popover, section « Revoir » ; le contenu des étapes vient de la base). Tests Cypress (`tour.feature`, 5 scénarios) : auto-démarrage + élément surligné pour un nouveau venu ; « Terminé » → complétion enregistrée `skipped:false` ; croix → `skipped:true` ; un tour déjà vu ne se relance pas ; « Revoir » le relance depuis les paramètres. Vérifié 5/5 en local. `pnpm build` + `pnpm lint` verts. Refs #42 Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
505 lines
22 KiB
TypeScript
505 lines
22 KiB
TypeScript
import {
|
|
type AddPlanningItemInput,
|
|
type AllergyView,
|
|
type ApiErrorResponse,
|
|
type AssignLeftoverInput,
|
|
type AuthResponse,
|
|
type BrowsableSourceItemView,
|
|
type CreateRecipeInput,
|
|
type DietView,
|
|
ErrorCode,
|
|
type HouseView,
|
|
type IngredientView,
|
|
type LoginInput,
|
|
type OptimizedCookingPlanView,
|
|
type PlanningItemView,
|
|
type PlanningView,
|
|
type PreferencesView,
|
|
type RecipeImportDraftView,
|
|
type RecipeSummaryView,
|
|
type RecipeTab,
|
|
type RecipeView,
|
|
type SafeUserProfile,
|
|
type ShoppingListView,
|
|
type SignupInput,
|
|
type SourceView,
|
|
type StepTechStepCorrectionView,
|
|
type SubmitTechStepCorrectionInput,
|
|
type SubmitTechStepCorrectionResult,
|
|
type TechStepView,
|
|
type ThemePreference,
|
|
type TourScenarioView,
|
|
type UnitView,
|
|
type UpdateRecipeInput,
|
|
type UtensilView,
|
|
} from "@batch-cooking/shared";
|
|
import { noopTokenStore, type TokenStore } from "./token-store";
|
|
|
|
/**
|
|
* Base URL of the API, configurable via `VITE_API_URL` (see `.env.example`).
|
|
* Defaults to `""` (same origin as the page) — correct for the production
|
|
* Docker image, where the API serves this very frontend build (see
|
|
* apps/api/Dockerfile), so a relative path already reaches it. Native dev
|
|
* (`pnpm dev:web`) overrides this via `VITE_API_URL=http://localhost:3000`
|
|
* in `apps/web/.env`, since the Vite dev server (5173) and the API (3000)
|
|
* are on different origins there.
|
|
*/
|
|
const API_BASE_URL: string = import.meta.env.VITE_API_URL ?? "";
|
|
|
|
/**
|
|
* The token store {@link ApiClient} consults on every request. Defaults to
|
|
* the browser no-op (the httpOnly cookie carries the session); the native
|
|
* shell replaces it via {@link setTokenStore} with a persistent,
|
|
* `@capacitor/preferences`-backed store. Module-level singleton, same shape
|
|
* as {@link API_BASE_URL} and the exported `apiClient`.
|
|
*/
|
|
let activeTokenStore: TokenStore = noopTokenStore;
|
|
|
|
/**
|
|
* Swaps in a non-browser {@link TokenStore}. Called once at startup by the
|
|
* Capacitor bootstrap (issue #39, later phase) when
|
|
* `Capacitor.isNativePlatform()` — never by the web build, which keeps the
|
|
* no-op and its cookie.
|
|
*/
|
|
export function setTokenStore(store: TokenStore): void {
|
|
activeTokenStore = store;
|
|
}
|
|
|
|
/**
|
|
* 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> {
|
|
try {
|
|
// `null` for the browser (no-op store — the cookie below is the
|
|
// session); a real JWT only in the native shell, where the cookie
|
|
// can't travel. See `token-store.ts`.
|
|
const token = await activeTokenStore.get();
|
|
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",
|
|
...(token ? { Authorization: `Bearer ${token}` } : {}),
|
|
...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 (await response.json()) as TResponseBody;
|
|
} catch (err) {
|
|
// Rethrown as-is — every caller already handles/surfaces API failures
|
|
// its own way (an `ApiError` catch, a `.catch()` chain — see
|
|
// `error-message.service.ts`), this is just the one place the
|
|
// fetch/`await` itself has to sit inside a try/catch per the repo's
|
|
// convention.
|
|
throw err;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Creates a profile and starts a session — no household yet, that's an
|
|
* optional step of the onboarding wizard. Persists the returned token via
|
|
* the active {@link TokenStore} (no-op in the browser, where the httpOnly
|
|
* cookie is what actually keeps the session).
|
|
*/
|
|
public async signup(input: SignupInput): Promise<SafeUserProfile> {
|
|
const { token, ...profile } = await this._request<AuthResponse>("/auth/signup", {
|
|
method: "POST",
|
|
body: JSON.stringify(input),
|
|
});
|
|
await activeTokenStore.set(token);
|
|
return profile;
|
|
}
|
|
|
|
/** Verifies credentials and starts a session — see {@link signup} re: the token. */
|
|
public async login(input: LoginInput): Promise<SafeUserProfile> {
|
|
const { token, ...profile } = await this._request<AuthResponse>("/auth/login", {
|
|
method: "POST",
|
|
body: JSON.stringify(input),
|
|
});
|
|
await activeTokenStore.set(token);
|
|
return profile;
|
|
}
|
|
|
|
/** Ends the current session, and drops any stored token. */
|
|
public async logout(): Promise<void> {
|
|
await this._request("/auth/logout", { method: "POST" });
|
|
await activeTokenStore.clear();
|
|
}
|
|
|
|
/** Fetches the currently authenticated profile — rejects with `NOT_AUTHENTICATED` if there's no session. */
|
|
public me(): Promise<SafeUserProfile> {
|
|
return this._request("/auth/me");
|
|
}
|
|
|
|
/** Permanently deletes the current profile, after re-verifying its password — rejects with `INVALID_CREDENTIALS` if it's wrong. Drops any stored token on success. */
|
|
public async deleteAccount(password: string): Promise<void> {
|
|
await this._request("/auth/me", { method: "DELETE", body: JSON.stringify({ password }) });
|
|
await activeTokenStore.clear();
|
|
}
|
|
|
|
/**
|
|
* Fetches the current user's household's planning covering `date`
|
|
* (`YYYY-MM-DD`, e.g. from `date-tools`'s `formatDateOnly`), or `null` if
|
|
* there isn't one for that week yet.
|
|
*/
|
|
public getPlanningForWeek(date: string): Promise<PlanningView | null> {
|
|
return this._request(`/planning?date=${date}`);
|
|
}
|
|
|
|
/**
|
|
* Adds a recipe to one (day, meal) slot of the household's planning for
|
|
* the week containing `input.date`, creating that week's planning on the
|
|
* fly if it doesn't exist yet — rejects with `HOUSE_NOT_FOUND` (no
|
|
* household) or `RECIPE_NOT_FOUND` (the recipe isn't visible to the
|
|
* caller).
|
|
*/
|
|
public addPlanningItem(input: AddPlanningItemInput): Promise<PlanningItemView> {
|
|
return this._request("/planning/items", { method: "POST", body: JSON.stringify(input) });
|
|
}
|
|
|
|
/** Removes one recipe from a planning slot — rejects with `PLANNING_ITEM_NOT_FOUND`. */
|
|
public removePlanningItem(id: number): Promise<void> {
|
|
return this._request(`/planning/items/${id}`, { method: "DELETE" });
|
|
}
|
|
|
|
/** Throws away planning item `id`'s whole remaining leftover pool (`discardedPortions += available`) — idempotent, returns the updated item. Rejects with `PLANNING_ITEM_NOT_FOUND`. */
|
|
public discardLeftover(id: number): Promise<PlanningItemView> {
|
|
return this._request(`/planning/items/${id}/discard-leftover`, { method: "PATCH" });
|
|
}
|
|
|
|
/** Serves `portions` of a source item's leftover pool on a slot, creating a "leftovers" planning item (same recipe as the source). Rejects with `LEFTOVER_QUANTITY_EXCEEDED` (over the pool) or `PLANNING_ITEM_NOT_FOUND`. */
|
|
public assignLeftover(input: AssignLeftoverInput): Promise<PlanningItemView> {
|
|
return this._request("/planning/items/leftover", {
|
|
method: "POST",
|
|
body: JSON.stringify(input),
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Fetches the current user's household's shopping list for the week
|
|
* covering `date` (`YYYY-MM-DD`, e.g. from `date-tools`'s
|
|
* `formatDateOnly`) — every ingredient across that week's planned
|
|
* recipes, summed. Unlike {@link getPlanningForWeek}, never resolves to
|
|
* `null`: no household or nothing planned that week both come back as a
|
|
* normal list with an empty `items` array.
|
|
*/
|
|
public getShoppingListForWeek(date: string): Promise<ShoppingListView> {
|
|
return this._request(`/shopping-list?date=${date}`);
|
|
}
|
|
|
|
/**
|
|
* Fetches the current user's household's optimized cooking plan for the
|
|
* week covering `date` (`YYYY-MM-DD`) — every recipe planned that week
|
|
* reorganized into ordered phases (shared prep pooled, passive cooks
|
|
* floated into the background). Like {@link getShoppingListForWeek} and
|
|
* unlike {@link getPlanningForWeek}, never resolves to `null`: no
|
|
* household or nothing planned both come back as a normal plan with
|
|
* empty `recipes`/`phases`.
|
|
*/
|
|
public getCookingPlanForWeek(date: string): Promise<OptimizedCookingPlanView> {
|
|
return this._request(`/cooking-session?date=${date}`);
|
|
}
|
|
|
|
/** 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");
|
|
}
|
|
|
|
/** Reference list of ingredients, each resolved to its allergens — static, non-administrable (recipe form's ingredient picker). Public — no session required. */
|
|
public getIngredients(): Promise<IngredientView[]> {
|
|
return this._request("/reference/ingredients");
|
|
}
|
|
|
|
/** Reference list of recipe ingredient units (g, kg, cuillère à soupe…) — static, non-administrable (recipe form's per-ingredient unit select). Public — no session required. */
|
|
public getUnits(): Promise<UnitView[]> {
|
|
return this._request("/reference/units");
|
|
}
|
|
|
|
/** Reference list of detected cooking techniques — static, non-administrable (`TechStepCorrectionPopover`'s technique picker). Public — no session required. */
|
|
public getTechSteps(): Promise<TechStepView[]> {
|
|
return this._request("/reference/tech-steps");
|
|
}
|
|
|
|
/** Reference list of cooking utensils — static, non-administrable (`TechStepCorrectionPopover`'s utensil picker, once a technique is selected). Public — no session required. */
|
|
public getUtensils(): Promise<UtensilView[]> {
|
|
return this._request("/reference/utensils");
|
|
}
|
|
|
|
/** Reference list of implemented recipe sources (onboarding wizard's source step, `/parametres/foyer`) — empty until a concrete source is registered. Public — no session required. */
|
|
public getSources(): Promise<SourceView[]> {
|
|
return this._request("/reference/sources");
|
|
}
|
|
|
|
/** One page of `sourceKey`'s own catalog (recipe catalog's "Sources" tab), each item flagged with whether it's already been imported. Rejects with `SOURCE_NOT_FOUND` unless the viewer's household has this source enabled (`/parametres/foyer`). */
|
|
public browseSource(
|
|
sourceKey: string,
|
|
params: { query?: string; cursor?: string } = {},
|
|
): Promise<{ items: BrowsableSourceItemView[]; nextCursor: string | null }> {
|
|
const search = new URLSearchParams();
|
|
if (params.query) search.set("query", params.query);
|
|
if (params.cursor) search.set("cursor", params.cursor);
|
|
const queryString = search.toString();
|
|
return this._request(`/sources/${sourceKey}/browse${queryString ? `?${queryString}` : ""}`);
|
|
}
|
|
|
|
/** Fully translates one not-yet-saved source item (ingredients/units/techniques resolved where possible) — nothing is persisted. Rejects with `RECIPE_NOT_FOUND` if the source couldn't fetch/parse it. */
|
|
public previewSourceItem(sourceKey: string, externalId: string): Promise<RecipeImportDraftView> {
|
|
return this._request(`/sources/${sourceKey}/preview/${encodeURIComponent(externalId)}`);
|
|
}
|
|
|
|
/** Finalizes an import — `input` is a fully-resolved `CreateRecipeInput`, exactly like a manual `createRecipe()` call (the review screen, `ImportRecipePage`, is what makes sure of that before calling this). Rejects with `RECIPE_ALREADY_IMPORTED` if this item was imported since the preview was fetched. */
|
|
public importSourceItem(
|
|
sourceKey: string,
|
|
externalId: string,
|
|
input: CreateRecipeInput,
|
|
): Promise<RecipeView> {
|
|
return this._request(`/sources/${sourceKey}/import/${encodeURIComponent(externalId)}`, {
|
|
method: "POST",
|
|
body: JSON.stringify(input),
|
|
});
|
|
}
|
|
|
|
/**
|
|
* One catalog tab (favoris/perso/foyer/publique — see `RecipeTab`),
|
|
* optionally narrowed further — `search` (name substring),
|
|
* `suitableForHousehold` (the planning recipe picker's "convient à tout
|
|
* le foyer" toggle), `ingredientIds`/`dietIds` (that same picker's
|
|
* ingredient/regime filters — a recipe must carry *every* id listed).
|
|
*/
|
|
public listRecipes(
|
|
tab: RecipeTab,
|
|
filters: {
|
|
search?: string;
|
|
suitableForHousehold?: boolean;
|
|
ingredientIds?: number[];
|
|
dietIds?: number[];
|
|
} = {},
|
|
): Promise<RecipeSummaryView[]> {
|
|
const params = new URLSearchParams({ tab });
|
|
if (filters.search) params.set("search", filters.search);
|
|
if (filters.suitableForHousehold) params.set("suitableForHousehold", "true");
|
|
for (const id of filters.ingredientIds ?? []) params.append("ingredientIds", String(id));
|
|
for (const id of filters.dietIds ?? []) params.append("dietIds", String(id));
|
|
return this._request(`/recipes?${params.toString()}`);
|
|
}
|
|
|
|
/** Fetches one recipe's full detail — rejects with `RECIPE_NOT_FOUND` if `id` doesn't match any recipe. */
|
|
public getRecipe(id: number): Promise<RecipeView> {
|
|
return this._request(`/recipes/${id}`);
|
|
}
|
|
|
|
/** Adds a recipe to the catalog — rejects with `INGREDIENT_NOT_FOUND` if any `ingredientId` doesn't match a reference ingredient. */
|
|
public createRecipe(input: CreateRecipeInput): Promise<RecipeView> {
|
|
return this._request("/recipes", { method: "POST", body: JSON.stringify(input) });
|
|
}
|
|
|
|
/** Replaces a recipe's full content (not a partial merge) — same rejections as {@link createRecipe}, plus `RECIPE_NOT_FOUND`. */
|
|
public updateRecipe(id: number, input: UpdateRecipeInput): Promise<RecipeView> {
|
|
return this._request(`/recipes/${id}`, { method: "PATCH", body: JSON.stringify(input) });
|
|
}
|
|
|
|
/** Removes a recipe from the catalog outright — rejects with `RECIPE_IN_USE` if it's still referenced by a planning item. */
|
|
public deleteRecipe(id: number): Promise<void> {
|
|
return this._request(`/recipes/${id}`, { method: "DELETE" });
|
|
}
|
|
|
|
/** Favorites a recipe for the current user — idempotent. */
|
|
public addFavoriteRecipe(id: number): Promise<void> {
|
|
return this._request(`/recipes/${id}/favorite`, { method: "POST" });
|
|
}
|
|
|
|
/** Unfavorites a recipe for the current user — idempotent. */
|
|
public removeFavoriteRecipe(id: number): Promise<void> {
|
|
return this._request(`/recipes/${id}/favorite`, { method: "DELETE" });
|
|
}
|
|
|
|
/** Submits a correction to one of `stepId`'s detected techniques — see `SubmitTechStepCorrectionInput`'s doc comment (`packages/shared`) for what `previousTechStepId`/`correctedTechStepId` each mean. Open to any viewer who can see the recipe, not just its author. The response's `techSteps` is the step's fresh, immediately up-to-date technique sequence — see `SubmitTechStepCorrectionResult`'s doc comment. */
|
|
public submitTechStepCorrection(
|
|
recipeId: number,
|
|
stepId: number,
|
|
input: SubmitTechStepCorrectionInput,
|
|
): Promise<SubmitTechStepCorrectionResult> {
|
|
return this._request(`/recipes/${recipeId}/steps/${stepId}/corrections`, {
|
|
method: "POST",
|
|
body: JSON.stringify(input),
|
|
});
|
|
}
|
|
|
|
/** Every correction submitted so far for `stepId`, most recent first. */
|
|
public getTechStepCorrections(
|
|
recipeId: number,
|
|
stepId: number,
|
|
): Promise<StepTechStepCorrectionView[]> {
|
|
return this._request(`/recipes/${recipeId}/steps/${stepId}/corrections`);
|
|
}
|
|
|
|
/** Fetches the current user's household (with its member list), or `null` if they don't have one yet. */
|
|
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 }) });
|
|
}
|
|
|
|
/** Creates a new household, with the caller as its admin — rejects with `ALREADY_HAS_HOUSE` if they already belong to one. */
|
|
public createHouse(name: string): Promise<HouseView> {
|
|
return this._request("/house", { method: "POST", body: JSON.stringify({ name }) });
|
|
}
|
|
|
|
/** Joins an existing household by invite code — rejects with `ALREADY_HAS_HOUSE`/`INVITE_CODE_NOT_FOUND`. */
|
|
public joinHouse(inviteCode: string): Promise<HouseView> {
|
|
return this._request("/house/join", { method: "POST", body: JSON.stringify({ inviteCode }) });
|
|
}
|
|
|
|
/** Removes the current user from their household — hands off adminship or deletes the household if they were its last member (see the API's `house.service.ts`). */
|
|
public leaveHouse(): Promise<void> {
|
|
return this._request("/house/leave", { method: "POST" });
|
|
}
|
|
|
|
/** Deletes the current user's household outright — every member loses it. Admin-only. */
|
|
public deleteHouse(): Promise<void> {
|
|
return this._request("/house/current", { method: "DELETE" });
|
|
}
|
|
|
|
/** Removes one specific member from the current user's household. Admin-only. */
|
|
public removeHouseMember(memberId: number): Promise<HouseView> {
|
|
return this._request(`/house/members/${memberId}`, { method: "DELETE" });
|
|
}
|
|
|
|
/** Fetches the current user's household's enabled recipe-source ids — which sources show up in its recipe tabs. Rejects with `HOUSE_NOT_FOUND` if they have no household yet. */
|
|
public getHouseSourceIds(): Promise<number[]> {
|
|
return this._request("/house/current/sources");
|
|
}
|
|
|
|
/** Replaces the current user's household's full enabled-source selection (not a merge — send the complete list; empty hides every external source). Rejects with `HOUSE_NOT_FOUND`/`SOURCE_NOT_FOUND`. */
|
|
public updateHouseSourceIds(sourceIds: number[]): Promise<number[]> {
|
|
return this._request("/house/current/sources", {
|
|
method: "PATCH",
|
|
body: JSON.stringify({ sourceIds }),
|
|
});
|
|
}
|
|
|
|
/** 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 }),
|
|
});
|
|
}
|
|
|
|
/** Fetches the current user's personally disliked ingredient ids — a taste preference, distinct from `getAllergyIds` (medical). */
|
|
public getDislikedIngredientIds(): Promise<number[]> {
|
|
return this._request("/profile/disliked-ingredients");
|
|
}
|
|
|
|
/** Replaces the current user's full disliked-ingredient selection (not a merge — send the complete list). */
|
|
public updateDislikedIngredientIds(dislikedIngredientIds: number[]): Promise<number[]> {
|
|
return this._request("/profile/disliked-ingredients", {
|
|
method: "PATCH",
|
|
body: JSON.stringify({ dislikedIngredientIds }),
|
|
});
|
|
}
|
|
|
|
/** Fetches the current user's personalization preferences — `theme` defaults to `"SYSTEM"` if never set. */
|
|
public getPreferences(): Promise<PreferencesView> {
|
|
return this._request("/preferences");
|
|
}
|
|
|
|
/** Sets the current user's theme preference. */
|
|
public updatePreferences(theme: ThemePreference): Promise<PreferencesView> {
|
|
return this._request("/preferences", { method: "PATCH", body: JSON.stringify({ theme }) });
|
|
}
|
|
|
|
/** Every enabled guided-tour scenario with its steps — public reference data (issue #42/#44). */
|
|
public getTours(): Promise<TourScenarioView[]> {
|
|
return this._request("/reference/tours");
|
|
}
|
|
|
|
/** The `key`s of the tour scenarios the current user has already seen (completed or skipped). */
|
|
public async getCompletedTourKeys(): Promise<string[]> {
|
|
const { completedTourKeys } = await this._request<{ completedTourKeys: string[] }>(
|
|
"/profile/tours",
|
|
);
|
|
return completedTourKeys;
|
|
}
|
|
|
|
/** Marks a tour scenario as seen — `skipped` distinguishes a dismissal from a full run (analytics only; both stop the auto-trigger). Idempotent. */
|
|
public completeTour(tourKey: string, skipped: boolean): Promise<void> {
|
|
return this._request(`/profile/tours/${encodeURIComponent(tourKey)}/complete`, {
|
|
method: "POST",
|
|
body: JSON.stringify({ skipped }),
|
|
});
|
|
}
|
|
}
|
|
|
|
/** Single shared instance — this client is stateless, no need for one per caller. */
|
|
export const apiClient = new ApiClient();
|