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>
238 lines
8.4 KiB
TypeScript
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;
|
|
}
|