batchCooking/apps/web/src/pages/settings/PreferencesPage.tsx
Nicolas acab18ac4a feat(recipes): catalogue v2 - visibilité, favoris, régimes et catalogue d'ingrédients exhaustif
Recipe catalog v2:
- Recipe gagne visibility (PERSONAL/HOUSE/PUBLIC), authorId, authorHouseId
- Favoris par utilisateur (RecipeFavorite), régimes associés (RecipeDiet)
- Aliments "pas aimés" par utilisateur (UserProfileDislikedIngredient),
  distinct des allergies médicales
- API: GET /recipes?tab=favoris|perso|foyer|publique avec contrôle d'accès
  complet, POST/DELETE /recipes/:id/favorite, édition/suppression réservées
  à l'auteur (403 NOT_RECIPE_AUTHOR), GET/PATCH /profile/disliked-ingredients
- Frontend: vue maître-détail (onglets + tableau + panneau détail),
  formulaire enrichi (visibilité, régimes), section préférences pour les
  aliments pas aimés

Catalogue d'ingrédients de référence:
- Extension du seed de 39 à ~430 ingrédients (viandes, poissons/fruits de
  mer, légumes, fruits, féculents, condiments/sauces, épices/herbes, pains
  à sandwich, cuisines italienne/asiatique/mexicaine/maghrébine, liquides
  et boissons de cuisine, bouillons/fonds)
- Chaque ingrédient lié à ses allergènes UE (IngredientAllergy) — les 14
  allergènes réglementaires restent tous couverts
- Seeding optimisé en requêtes groupées (createMany/diff ciblé) plutôt
  qu'un upsert par ligne, pour garder resetDatabase() rapide en test

Tests: 102 tests Mocha + 32 scénarios BDD, tous verts.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-18 09:29:11 +02:00

238 lines
8.4 KiB
TypeScript

import {
type AllergyView,
type DietView,
ErrorCode,
type IngredientView,
} from "@batch-cooking/shared";
import { useEffect, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { ApiError, apiClient } from "../../api/client";
import { useAuth } from "../../features/auth/AuthContext";
import { AllergySelect } from "../../features/profile/AllergySelect";
import { DietSelect } from "../../features/profile/DietSelect";
import { DislikedIngredientsField } from "../../features/profile/DislikedIngredientsField";
import { errorMessageService } from "../../services/error-message.service";
import "./settings-pages.scss";
/** Status of one section's own autosave — sections save independently, each with its own feedback. */
type SaveState = "idle" | "saving" | "saved" | "error";
/** Debounce for the allergen checkboxes — coalesces a quick burst of several toggles into one request. */
const ALLERGIES_DEBOUNCE_MS = 500;
/**
* Dietary preferences — regime and allergies/intolerances — routed at
* `/parametres/preferences`. The always-available counterpart to the
* onboarding wizard's regime/allergens steps (`pages/onboarding/`): same
* concerns, same shared field components, but editable at any time rather
* than run once. Split out of what used to be `HouseholdPage` — these are
* personal profile attributes (`UserProfile.dietId`/allergies), not
* household ones, hence their own page distinct from `HouseholdSettingsPage`.
*
* Hot saving (no "Enregistrer" button) — see `HouseholdSettingsPage` for the
* same pattern and rationale, shared verbatim.
*/
export function PreferencesPage() {
const { t } = useTranslation();
const { refreshUser } = useAuth();
const [isLoading, setIsLoading] = useState(true);
const [loadError, setLoadError] = useState(false);
const [diets, setDiets] = useState<DietView[]>([]);
const [dietId, setDietId] = useState<number | null>(null);
const [dietSaveState, setDietSaveState] = useState<SaveState>("idle");
const [dietSaveError, setDietSaveError] = useState<string | null>(null);
const [allergies, setAllergies] = useState<AllergyView[]>([]);
const [allergyIds, setAllergyIds] = useState<number[]>([]);
const [allergySaveState, setAllergySaveState] = useState<SaveState>("idle");
const [allergySaveError, setAllergySaveError] = useState<string | null>(null);
const allergiesTimeout = useRef<number | undefined>(undefined);
const [ingredients, setIngredients] = useState<IngredientView[]>([]);
const [dislikedIngredientIds, setDislikedIngredientIds] = useState<number[]>([]);
const [dislikedSaveState, setDislikedSaveState] = useState<SaveState>("idle");
const [dislikedSaveError, setDislikedSaveError] = useState<string | null>(null);
const dislikedTimeout = useRef<number | undefined>(undefined);
useEffect(() => {
let cancelled = false;
// `apiClient.me()` here (not `useAuth().user.dietId`) — this page can be
// revisited many times over a session without a full reload, and
// AuthContext's `user` only refreshes on app load or after an explicit
// `refreshUser()` call; relying on it directly would show a stale
// `dietId` after navigating away and back post-save.
Promise.all([
apiClient.getDiets(),
apiClient.getAllergies(),
apiClient.getAllergyIds(),
apiClient.getIngredients(),
apiClient.getDislikedIngredientIds(),
apiClient.me(),
])
.then(
([
dietsResult,
allergiesResult,
allergyIdsResult,
ingredientsResult,
dislikedResult,
profile,
]) => {
if (cancelled) return;
setDiets(dietsResult);
setAllergies(allergiesResult);
setAllergyIds(allergyIdsResult);
setIngredients(ingredientsResult);
setDislikedIngredientIds(dislikedResult);
setDietId(profile.dietId);
},
)
.catch(() => {
if (!cancelled) setLoadError(true);
})
.finally(() => {
if (!cancelled) setIsLoading(false);
});
return () => {
cancelled = true;
};
}, []);
// A pending debounced save must not fire after unmount (e.g. the user
// navigates away mid-debounce).
useEffect(() => {
return () => {
window.clearTimeout(allergiesTimeout.current);
window.clearTimeout(dislikedTimeout.current);
};
}, []);
async function handleDietChange(newDietId: number | null) {
setDietId(newDietId);
setDietSaveState("saving");
try {
await apiClient.updateDiet(newDietId);
// Keeps AuthContext's `user.dietId` in sync — nothing else reads it
// today, but anything that might in the future shouldn't have to
// know this page exists to stay correct.
await refreshUser();
setDietSaveState("saved");
} catch (err) {
const code = err instanceof ApiError ? err.code : ErrorCode.INTERNAL_ERROR;
setDietSaveError(errorMessageService.getLabel(code));
setDietSaveState("error");
}
}
function handleAllergyIdsChange(newAllergyIds: number[]) {
setAllergyIds(newAllergyIds);
window.clearTimeout(allergiesTimeout.current);
setAllergySaveState("saving");
allergiesTimeout.current = window.setTimeout(async () => {
try {
await apiClient.updateAllergyIds(newAllergyIds);
setAllergySaveState("saved");
} catch (err) {
const code = err instanceof ApiError ? err.code : ErrorCode.INTERNAL_ERROR;
setAllergySaveError(errorMessageService.getLabel(code));
setAllergySaveState("error");
}
}, ALLERGIES_DEBOUNCE_MS);
}
function handleDislikedIngredientIdsChange(newIds: number[]) {
setDislikedIngredientIds(newIds);
window.clearTimeout(dislikedTimeout.current);
setDislikedSaveState("saving");
dislikedTimeout.current = window.setTimeout(async () => {
try {
await apiClient.updateDislikedIngredientIds(newIds);
setDislikedSaveState("saved");
} catch (err) {
const code = err instanceof ApiError ? err.code : ErrorCode.INTERNAL_ERROR;
setDislikedSaveError(errorMessageService.getLabel(code));
setDislikedSaveState("error");
}
}, ALLERGIES_DEBOUNCE_MS);
}
if (isLoading) {
return (
<div className="settings-page">
<h1>{t("preferences.title")}</h1>
<p className="settings-page__status">{t("onboarding.loading")}</p>
</div>
);
}
if (loadError) {
return (
<div className="settings-page">
<h1>{t("preferences.title")}</h1>
<p className="settings-page__status settings-page__status--error">
{t("common.loadError")}
</p>
</div>
);
}
return (
<div className="settings-page">
<h1>{t("preferences.title")}</h1>
<div className="settings-page__section">
<DietSelect diets={diets} value={dietId} onChange={handleDietChange} />
<SaveStatus state={dietSaveState} error={dietSaveError} t={t} />
</div>
<div className="settings-page__section">
<AllergySelect
legend={t("preferences.form.allergiesLabel")}
allergies={allergies.filter((allergy) => allergy.kind === "ALLERGY")}
value={allergyIds}
onChange={handleAllergyIdsChange}
/>
<AllergySelect
legend={t("preferences.form.intolerancesLabel")}
allergies={allergies.filter((allergy) => allergy.kind === "INTOLERANCE")}
value={allergyIds}
onChange={handleAllergyIdsChange}
/>
<SaveStatus state={allergySaveState} error={allergySaveError} t={t} />
</div>
<div className="settings-page__section">
<DislikedIngredientsField
ingredients={ingredients}
value={dislikedIngredientIds}
onChange={handleDislikedIngredientIdsChange}
/>
<SaveStatus state={dislikedSaveState} error={dislikedSaveError} t={t} />
</div>
</div>
);
}
/** Inline "saving…"/"saved ✓"/error feedback shared by every autosaved section on this page — `idle` renders nothing. */
function SaveStatus({
state,
error,
t,
}: {
state: SaveState;
error: string | null;
t: (key: string) => string;
}) {
if (state === "saving") {
return <p className="settings-page__saving">{t("common.saving")}</p>;
}
if (state === "saved") {
return <p className="settings-page__saved">{t("common.saved")}</p>;
}
if (state === "error") {
return <p className="field-error">{error}</p>;
}
return null;
}