Web: pages Compte/Préférences/Foyer, client API et déplacement des menus (step 6/8)

- pages/settings/AccountSettingsPage: identité + suppression de compte
  (confirmation en deux temps, mot de passe requis)
- pages/settings/PreferencesPage: régime + allergies/intolérances,
  sorti de HouseholdPage (attribut du profil, pas du foyer)
- pages/settings/HouseholdSettingsPage: sans foyer → créer/rejoindre ;
  avec foyer → renommer (hot-save), code d'invitation, membres,
  retirer un membre / supprimer le foyer (admin) ou le quitter
- HouseholdPage.tsx/.scss supprimés (contenu réparti ci-dessus)
- apiClient: createHouse/joinHouse/leaveHouse/deleteHouse/
  removeHouseMember/deleteAccount
- AuthContext: deleteAccount()
- i18n: namespaces account/preferences réorganisés, household réduit
  au foyer, common.saving/saved factorisées
This commit is contained in:
Nicolas 2026-08-17 10:45:58 +02:00
parent 8e9457297d
commit 7abe030bb9
14 changed files with 984 additions and 327 deletions

View file

@ -3,7 +3,6 @@ import { RedirectIfAuthenticated } from "./features/auth/RedirectIfAuthenticated
import { RequireAuth } from "./features/auth/RequireAuth";
import { AppLayout } from "./layouts/AppLayout";
import { HomePage } from "./pages/HomePage";
import { HouseholdPage } from "./pages/HouseholdPage";
import { LoginPage } from "./pages/LoginPage";
import { RecipesPage } from "./pages/RecipesPage";
import { ShoppingListPage } from "./pages/ShoppingListPage";
@ -11,6 +10,9 @@ import { SignupPage } from "./pages/SignupPage";
import { OnboardingAllergensPage } from "./pages/onboarding/OnboardingAllergensPage";
import { OnboardingDietPage } from "./pages/onboarding/OnboardingDietPage";
import { OnboardingHouseholdPage } from "./pages/onboarding/OnboardingHouseholdPage";
import { AccountSettingsPage } from "./pages/settings/AccountSettingsPage";
import { HouseholdSettingsPage } from "./pages/settings/HouseholdSettingsPage";
import { PreferencesPage } from "./pages/settings/PreferencesPage";
/**
* Top-level route table. Every authenticated section is nested under one
@ -20,11 +22,18 @@ import { OnboardingHouseholdPage } from "./pages/onboarding/OnboardingHouseholdP
* {@link RedirectIfAuthenticated}). Anything else falls back to `/`, which
* itself redirects to `/login` if needed.
*
* `/onboarding/*` (household/regime/allergens) is also `RequireAuth`-gated
* reached right after signup, once a session already exists but
* deliberately its own top-level route group, *not* nested under
* `AppLayout`: a focused, distraction-free wizard with no sidebar, same
* full-page-card language as `/login`/`/signup` (see `onboarding.scss`).
* `/parametres/*` (compte/préférences/foyer) are the settings pages,
* reachable from the sidebar's bottom "Paramètres" menu and the account
* menu (see `AppLayout`) nested under `AppLayout` like every other
* authenticated section. `/foyer` is the old, pre-split combined page's
* path; it now just redirects to `/parametres/foyer` so an existing
* bookmark/link keeps working.
*
* `/onboarding/*` (regime/foyer/allergens, in that order) is also
* `RequireAuth`-gated reached right after signup, once a session already
* exists but deliberately its own top-level route group, *not* nested
* under `AppLayout`: a focused, distraction-free wizard with no sidebar,
* same full-page-card language as `/login`/`/signup` (see `onboarding.scss`).
*/
export function App() {
return (
@ -39,16 +48,11 @@ export function App() {
<Route path="/" element={<HomePage />} />
<Route path="/recettes" element={<RecipesPage />} />
<Route path="/liste-de-courses" element={<ShoppingListPage />} />
<Route path="/foyer" element={<HouseholdPage />} />
<Route path="/parametres/compte" element={<AccountSettingsPage />} />
<Route path="/parametres/preferences" element={<PreferencesPage />} />
<Route path="/parametres/foyer" element={<HouseholdSettingsPage />} />
<Route path="/foyer" element={<Navigate to="/parametres/foyer" replace />} />
</Route>
<Route
path="/onboarding/foyer"
element={
<RequireAuth>
<OnboardingHouseholdPage />
</RequireAuth>
}
/>
<Route
path="/onboarding/regime"
element={
@ -57,6 +61,14 @@ export function App() {
</RequireAuth>
}
/>
<Route
path="/onboarding/foyer"
element={
<RequireAuth>
<OnboardingHouseholdPage />
</RequireAuth>
}
/>
<Route
path="/onboarding/allergenes"
element={

View file

@ -98,6 +98,11 @@ export class ApiClient {
return this.request("/auth/me");
}
/** Permanently deletes the current profile, after re-verifying its password — rejects with `INVALID_CREDENTIALS` if it's wrong. */
public deleteAccount(password: string): Promise<void> {
return this.request("/auth/me", { method: "DELETE", body: JSON.stringify({ password }) });
}
/** 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");
@ -113,7 +118,7 @@ export class ApiClient {
return this.request("/reference/allergies");
}
/** Fetches the current user's household. */
/** 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");
}
@ -123,6 +128,31 @@ export class ApiClient {
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" });
}
/** 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 }) });

View file

@ -14,11 +14,13 @@ interface AuthContextValue {
login: (input: LoginInput) => Promise<void>;
/** Ends the session and clears `user`. */
logout: () => Promise<void>;
/** Permanently deletes the current account and clears `user`. Throws `ApiError` (e.g. wrong password) on failure. */
deleteAccount: (password: string) => Promise<void>;
/**
* Re-fetches the current profile and updates `user`. Needed after
* anything that changes profile fields `user` carries (e.g. `dietId`)
* outside of `signup`/`login` `PATCH /profile/diet` (see
* `HouseholdPage.tsx`) updates the database directly via `apiClient`,
* `PreferencesPage.tsx`) updates the database directly via `apiClient`,
* which doesn't touch this context on its own.
*/
refreshUser: () => Promise<void>;
@ -59,12 +61,19 @@ export function AuthProvider({ children }: { children: ReactNode }) {
setUser(null);
}, []);
const deleteAccount = useCallback(async (password: string) => {
await apiClient.deleteAccount(password);
setUser(null);
}, []);
const refreshUser = useCallback(async () => {
setUser(await apiClient.me());
}, []);
return (
<AuthContext.Provider value={{ user, isLoading, signup, login, logout, refreshUser }}>
<AuthContext.Provider
value={{ user, isLoading, signup, login, logout, deleteAccount, refreshUser }}
>
{children}
</AuthContext.Provider>
);

View file

@ -12,8 +12,8 @@ interface AllergySelectProps {
* Multi-select (checkbox grid, not a native `<select multiple>` far more
* discoverable/tappable, especially on the mobile viewport this app is
* eventually embedded into via Capacitor) for a group of allergens. Used
* both by the signup wizard's allergens step and the `/foyer` settings
* page, and rendered *twice* by each once for allergies, once for
* both by the signup wizard's allergens step and the `/parametres/preferences`
* settings page, and rendered *twice* by each once for allergies, once for
* intolerances (`AllergyView.kind` groups them; callers filter and pass
* two separate lists rather than this component knowing about the split).
* An empty `value` is a normal, valid state (no declared allergies, or

View file

@ -10,10 +10,10 @@ interface DietSelectProps {
/**
* Dropdown picker for a dietary regime used both by the signup wizard's
* regime step and the `/foyer` settings page. Always includes a "none"
* option (mapped to `null`, not just an empty label) since this step of the
* profile journey is skippable a profile with no regime is a normal,
* valid state, not an incomplete one.
* regime step and the `/parametres/preferences` settings page. Always
* includes a "none" option (mapped to `null`, not just an empty label)
* since this step of the profile journey is skippable a profile with no
* regime is a normal, valid state, not an incomplete one.
*
* Receives `diets` as a prop rather than fetching them itself: the caller
* (a page) owns loading state/errors for the reference list, this stays a
@ -24,13 +24,13 @@ export function DietSelect({ diets, value, onChange }: DietSelectProps) {
return (
<>
<label htmlFor="diet">{t("household.form.dietLabel")}</label>
<label htmlFor="diet">{t("preferences.form.dietLabel")}</label>
<select
id="diet"
value={value ?? ""}
onChange={(e) => onChange(e.target.value === "" ? null : Number(e.target.value))}
>
<option value="">{t("household.form.dietNone")}</option>
<option value="">{t("preferences.form.dietNone")}</option>
{diets.map((diet) => (
<option key={diet.id} value={diet.id}>
{diet.name}

View file

@ -9,7 +9,7 @@ interface HouseNameFieldProps {
/**
* Labeled text input for the household's name used both by the signup
* wizard's household step and the `/foyer` settings page (see
* wizard's household step and the `/parametres/foyer` settings page (see
* `profile-forms.scss` for the shared styling both consume). Controlled
* component: the caller owns the value and persists it (`ApiClient.
* renameHouse`) on submit, not this component.

View file

@ -1,10 +1,11 @@
// =============================================================================
// Styles shared by the profile-journey field components (HouseNameField,
// DietSelect, AllergySelect) used both by the signup wizard's steps
// (pages/onboarding/) and the `/foyer` settings page (HouseholdPage). Field
// styling only (label/input/select/checkbox) the surrounding page/card
// layout belongs to each consuming page's own .scss, same split as
// features/auth/auth-form.scss vs. LoginPage/SignupPage.
// (pages/onboarding/) and the settings pages (PreferencesPage,
// HouseholdSettingsPage, under pages/settings/). Field styling only
// (label/input/select/checkbox) the surrounding page/card layout belongs
// to each consuming page's own .scss, same split as features/auth/
// auth-form.scss vs. LoginPage/SignupPage.
// =============================================================================
// No `@use` of the theme partial needed every token below is a runtime

View file

@ -1,13 +1,20 @@
{
"common": {
"saving": "Enregistrement…",
"saved": "Enregistré ✓"
},
"errors": {
"VALIDATION_ERROR": "Erreur de validation",
"EMAIL_ALREADY_IN_USE": "Cet email est déjà utilisé",
"INVALID_CREDENTIALS": "Email ou mot de passe incorrect",
"NOT_AUTHENTICATED": "Vous devez être connecté",
"ALREADY_HAS_HOUSE": "Vous appartenez déjà à un foyer",
"NOT_HOUSE_ADMIN": "Seul l'administrateur du foyer peut faire ça",
"NOT_FOUND": "Ressource introuvable",
"HOUSE_NOT_FOUND": "Votre profil n'a pas de foyer",
"DIET_NOT_FOUND": "Ce régime alimentaire n'existe pas",
"ALLERGY_NOT_FOUND": "Un des allergènes sélectionnés n'existe pas",
"INVITE_CODE_NOT_FOUND": "Ce code d'invitation ne correspond à aucun foyer",
"INTERNAL_ERROR": "Une erreur est survenue, réessayez plus tard"
},
"auth": {
@ -37,12 +44,15 @@
"continue": "Continuer",
"finish": "Terminer",
"loading": "Chargement…",
"household": {
"title": "Comment s'appelle votre foyer ?"
},
"diet": {
"title": "Un régime alimentaire particulier ?"
},
"household": {
"title": "Rejoignez ou créez un foyer",
"subtitle": "Optionnel — vous pourrez le faire plus tard depuis les paramètres.",
"skip": "Passer cette étape",
"alreadyHasHouse": "Vous faites déjà partie du foyer « {{name}} »."
},
"allergens": {
"title": "Des allergies ou intolérances ?"
}
@ -51,8 +61,18 @@
"nav": {
"planning": "Planning",
"recipes": "Recettes",
"shoppingList": "Liste de courses",
"household": "Foyer & profil"
"shoppingList": "Liste de courses"
},
"settings": {
"toggle": "Paramètres",
"nav": {
"account": "Compte",
"preferences": "Préférences",
"household": "Foyer"
}
},
"accountMenu": {
"myAccount": "Mon compte"
},
"greeting": "Bonjour {{firstName}} 👋",
"logout": "Se déconnecter"
@ -76,16 +96,58 @@
"title": "Liste de courses",
"comingSoon": "Cette section arrive bientôt."
},
"household": {
"title": "Foyer & profil",
"account": {
"title": "Compte",
"identity": {
"firstNameLabel": "Prénom",
"lastNameLabel": "Nom",
"emailLabel": "Email"
},
"dangerZone": {
"title": "Zone dangereuse",
"description": "Supprimer votre compte est définitif et irréversible.",
"deleteButton": "Supprimer mon compte",
"passwordLabel": "Confirmez avec votre mot de passe",
"confirmButton": "Confirmer la suppression",
"cancelButton": "Annuler"
}
},
"preferences": {
"title": "Préférences alimentaires",
"form": {
"nameLabel": "Nom du foyer",
"dietLabel": "Régime alimentaire",
"dietNone": "Aucun régime particulier",
"allergiesLabel": "Allergies",
"intolerancesLabel": "Intolérances",
"saving": "Enregistrement…",
"saved": "Enregistré ✓"
"intolerancesLabel": "Intolérances"
}
},
"household": {
"title": "Foyer",
"form": {
"nameLabel": "Nom du foyer"
},
"noHouse": {
"intro": "Vous n'appartenez à aucun foyer pour le moment.",
"createTitle": "Créer un foyer",
"createButton": "Créer",
"joinTitle": "Rejoindre un foyer",
"joinLabel": "Code d'invitation",
"joinButton": "Rejoindre"
},
"inviteCodeLabel": "Code d'invitation",
"copyButton": "Copier",
"copied": "Copié ✓",
"membersTitle": "Membres",
"adminBadge": "Admin",
"youSuffix": " (vous)",
"removeButton": "Retirer",
"leaveButton": "Quitter le foyer",
"dangerZone": {
"title": "Zone dangereuse",
"description": "Supprimer le foyer le supprime pour tous ses membres, ainsi que son planning.",
"deleteButton": "Supprimer le foyer",
"confirmButton": "Confirmer la suppression",
"cancelButton": "Annuler"
}
}
}

View file

@ -1,46 +0,0 @@
// =============================================================================
// Styles specific to HouseholdPage colocated next to HouseholdPage.tsx
// since nothing else uses these classes. Field/label/input styling itself
// comes from features/profile/profile-forms.scss (shared with the
// onboarding wizard); this file only covers this page's own layout.
// =============================================================================
.household-page {
&__status {
color: var(--color-text-muted);
font-size: var(--font-size-md);
}
&__status--error {
color: var(--color-error);
}
}
// Each of the three settings (household name, regime, allergies +
// intolerances) is its own independently-autosaved section a card per
// section, same surface treatment used elsewhere (see .planning-table in
// HomePage.scss), so each reads as a distinct, self-contained unit rather
// than one long form. No buttons here (hot saving see HouseholdPage.tsx).
.household-page__section {
max-width: 32rem;
margin-top: var(--space-lg);
padding: var(--space-lg);
background: var(--color-surface);
border-radius: var(--radius-md);
box-shadow: var(--shadow-sm);
}
.household-page__saving,
.household-page__saved {
margin: var(--space-sm) 0 0;
font-size: var(--font-size-sm);
font-weight: 600;
}
.household-page__saving {
color: var(--color-text-muted);
}
.household-page__saved {
color: var(--color-success);
}

View file

@ -1,239 +0,0 @@
import {
type AllergyView,
type DietView,
ErrorCode,
renameHouseSchema,
} 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 { HouseNameField } from "../features/profile/HouseNameField";
import { fieldErrorsFrom } from "../lib/zod-errors";
import { errorMessageService } from "../services/error-message.service";
import "./HouseholdPage.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 household name field (typing) — long enough that saves don't fire on every keystroke. */
const HOUSE_NAME_DEBOUNCE_MS = 600;
/** Debounce for the allergen checkboxes — coalesces a quick burst of several toggles into one request. */
const ALLERGIES_DEBOUNCE_MS = 500;
/**
* Household & profile settings routed at `/foyer`. The always-available
* counterpart to the signup wizard (`pages/onboarding/`): same concerns
* (household name, dietary regime, allergies, intolerances), same shared
* field components, but editable at any time rather than run once.
*
* Hot saving (retour fonctionnel) no "Enregistrer" buttons; each section
* autosaves shortly after the user stops changing it. Saves are triggered
* from the field's own `onChange` handler, *not* a generic `useEffect`
* watching the value: an effect keyed on the value would also fire the
* moment the initial `GET` calls populate that same state, with no clean
* way to tell "just loaded" apart from "user edited" routing every save
* through an explicit handler sidesteps that entirely, since the initial
* load never goes through these handlers.
*/
export function HouseholdPage() {
const { t } = useTranslation();
const { refreshUser } = useAuth();
const [isLoading, setIsLoading] = useState(true);
const [loadError, setLoadError] = useState(false);
const [houseName, setHouseName] = useState("");
const [houseNameErrors, setHouseNameErrors] = useState<Record<string, string>>({});
const [houseSaveState, setHouseSaveState] = useState<SaveState>("idle");
const [houseSaveError, setHouseSaveError] = useState<string | null>(null);
const houseNameTimeout = useRef<number | undefined>(undefined);
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);
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.getCurrentHouse(),
apiClient.getDiets(),
apiClient.getAllergies(),
apiClient.getAllergyIds(),
apiClient.me(),
])
.then(([house, dietsResult, allergiesResult, allergyIdsResult, profile]) => {
if (cancelled) return;
setHouseName(house?.name ?? "");
setDiets(dietsResult);
setAllergies(allergiesResult);
setAllergyIds(allergyIdsResult);
setDietId(profile.dietId);
})
.catch(() => {
if (!cancelled) setLoadError(true);
})
.finally(() => {
if (!cancelled) setIsLoading(false);
});
return () => {
cancelled = true;
};
}, []);
// Pending debounced saves must not fire after unmount (e.g. the user
// navigates away mid-debounce).
useEffect(() => {
return () => {
window.clearTimeout(houseNameTimeout.current);
window.clearTimeout(allergiesTimeout.current);
};
}, []);
function handleHouseNameChange(name: string) {
setHouseName(name);
window.clearTimeout(houseNameTimeout.current);
const result = renameHouseSchema.safeParse({ name });
if (!result.success) {
setHouseNameErrors(fieldErrorsFrom(result.error));
setHouseSaveState("idle");
return;
}
setHouseNameErrors({});
setHouseSaveState("saving");
houseNameTimeout.current = window.setTimeout(async () => {
try {
await apiClient.renameHouse(result.data.name);
setHouseSaveState("saved");
} catch (err) {
const code = err instanceof ApiError ? err.code : ErrorCode.INTERNAL_ERROR;
setHouseSaveError(errorMessageService.getLabel(code));
setHouseSaveState("error");
}
}, HOUSE_NAME_DEBOUNCE_MS);
}
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 the sidebar/anywhere else 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);
}
if (isLoading) {
return (
<div className="household-page">
<h1>{t("household.title")}</h1>
<p className="household-page__status">{t("onboarding.loading")}</p>
</div>
);
}
if (loadError) {
return (
<div className="household-page">
<h1>{t("household.title")}</h1>
<p className="household-page__status household-page__status--error">{t("home.error")}</p>
</div>
);
}
return (
<div className="household-page">
<h1>{t("household.title")}</h1>
<div className="household-page__section">
<HouseNameField
value={houseName}
onChange={handleHouseNameChange}
error={houseNameErrors.name}
/>
<SaveStatus state={houseSaveState} error={houseSaveError} t={t} />
</div>
<div className="household-page__section">
<DietSelect diets={diets} value={dietId} onChange={handleDietChange} />
<SaveStatus state={dietSaveState} error={dietSaveError} t={t} />
</div>
<div className="household-page__section">
<AllergySelect
legend={t("household.form.allergiesLabel")}
allergies={allergies.filter((allergy) => allergy.kind === "ALLERGY")}
value={allergyIds}
onChange={handleAllergyIdsChange}
/>
<AllergySelect
legend={t("household.form.intolerancesLabel")}
allergies={allergies.filter((allergy) => allergy.kind === "INTOLERANCE")}
value={allergyIds}
onChange={handleAllergyIdsChange}
/>
<SaveStatus state={allergySaveState} error={allergySaveError} t={t} />
</div>
</div>
);
}
/** Inline "saving…"/"saved ✓"/error feedback shared by every autosaved section — `idle` renders nothing. */
function SaveStatus({
state,
error,
t,
}: {
state: SaveState;
error: string | null;
t: (key: string) => string;
}) {
if (state === "saving") {
return <p className="household-page__saving">{t("household.form.saving")}</p>;
}
if (state === "saved") {
return <p className="household-page__saved">{t("household.form.saved")}</p>;
}
if (state === "error") {
return <p className="field-error">{error}</p>;
}
return null;
}

View file

@ -0,0 +1,106 @@
import { ErrorCode } from "@batch-cooking/shared";
import { type FormEvent, useState } from "react";
import { useTranslation } from "react-i18next";
import { useNavigate } from "react-router-dom";
import { ApiError } from "../../api/client";
import { useAuth } from "../../features/auth/AuthContext";
import { errorMessageService } from "../../services/error-message.service";
import "./settings-pages.scss";
/**
* Account settings routed at `/parametres/compte`. Read-only identity
* (editing name/email isn't a requested feature yet) plus a "danger zone"
* to permanently delete the account, gated behind re-entering the current
* password (same idea as `login`'s check, see `auth.service.ts`'s
* `deleteAccount`).
*/
export function AccountSettingsPage() {
const { t } = useTranslation();
const { user, deleteAccount } = useAuth();
const navigate = useNavigate();
const [isConfirming, setIsConfirming] = useState(false);
const [password, setPassword] = useState("");
const [error, setError] = useState<string | null>(null);
const [isSubmitting, setIsSubmitting] = useState(false);
async function handleDelete(e: FormEvent) {
e.preventDefault();
setError(null);
setIsSubmitting(true);
try {
await deleteAccount(password);
navigate("/login");
} catch (err) {
const code = err instanceof ApiError ? err.code : ErrorCode.INTERNAL_ERROR;
setError(errorMessageService.getLabel(code));
} finally {
setIsSubmitting(false);
}
}
function cancelDelete() {
setIsConfirming(false);
setPassword("");
setError(null);
}
return (
<div className="settings-page">
<h1>{t("account.title")}</h1>
<div className="settings-page__section">
<p>
{t("account.identity.firstNameLabel")}: {user?.firstName}
</p>
<p>
{t("account.identity.lastNameLabel")}: {user?.lastName}
</p>
<p>
{t("account.identity.emailLabel")}: {user?.email}
</p>
</div>
<div className="settings-page__section settings-page__danger-zone">
<h2 className="settings-page__section-title">{t("account.dangerZone.title")}</h2>
<p className="settings-page__hint">{t("account.dangerZone.description")}</p>
{!isConfirming ? (
<div className="settings-page__actions">
<button
type="button"
className="settings-page__danger-button"
onClick={() => setIsConfirming(true)}
>
{t("account.dangerZone.deleteButton")}
</button>
</div>
) : (
<form onSubmit={handleDelete} noValidate>
<label htmlFor="deleteAccountPassword">{t("account.dangerZone.passwordLabel")}</label>
<input
id="deleteAccountPassword"
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
autoComplete="current-password"
/>
{error && <p className="field-error">{error}</p>}
<div className="settings-page__actions">
<button
type="submit"
className="settings-page__danger-button"
disabled={isSubmitting}
>
{t("account.dangerZone.confirmButton")}
</button>
<button type="button" onClick={cancelDelete} disabled={isSubmitting}>
{t("account.dangerZone.cancelButton")}
</button>
</div>
</form>
)}
</div>
</div>
);
}

View file

@ -0,0 +1,386 @@
import { ErrorCode, type HouseView, renameHouseSchema } from "@batch-cooking/shared";
import { type FormEvent, useEffect, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { ApiError, apiClient } from "../../api/client";
import { useAuth } from "../../features/auth/AuthContext";
import { HouseNameField } from "../../features/profile/HouseNameField";
import { fieldErrorsFrom } from "../../lib/zod-errors";
import { errorMessageService } from "../../services/error-message.service";
import "./settings-pages.scss";
/** Status of the household name's own autosave — see `PreferencesPage` for the same hot-saving pattern. */
type SaveState = "idle" | "saving" | "saved" | "error";
/** Debounce for the household name field (typing) — long enough that saves don't fire on every keystroke. */
const HOUSE_NAME_DEBOUNCE_MS = 600;
/**
* Household settings routed at `/parametres/foyer`. Split out of what
* used to be `HouseholdPage` (regime/allergies moved to `PreferencesPage`,
* a personal-profile concern rather than a household one). Two very
* different layouts depending on whether the profile currently belongs to
* a household:
*
* - **No household**: create one, or join an existing one by invite code.
* - **Has a household**: rename it (hot-save, same pattern as before),
* share its invite code, see its members, and either manage it (admin:
* remove a member, delete the household) or leave it (non-admin).
*
* Reloads `getCurrentHouse()` after every mutation (create/join/leave/
* delete/remove) rather than optimistically patching local state these
* are infrequent, deliberate actions, not a hot-saved field, so the extra
* round trip isn't worth the risk of the two ever drifting apart.
*/
export function HouseholdSettingsPage() {
const { t } = useTranslation();
const { user } = useAuth();
const [isLoading, setIsLoading] = useState(true);
const [loadError, setLoadError] = useState(false);
const [house, setHouse] = useState<HouseView | null>(null);
useEffect(() => {
loadHouse();
}, []);
function loadHouse() {
setIsLoading(true);
setLoadError(false);
return apiClient
.getCurrentHouse()
.then((result) => setHouse(result))
.catch(() => setLoadError(true))
.finally(() => setIsLoading(false));
}
if (isLoading) {
return (
<div className="settings-page">
<h1>{t("household.title")}</h1>
<p className="settings-page__status">{t("onboarding.loading")}</p>
</div>
);
}
if (loadError) {
return (
<div className="settings-page">
<h1>{t("household.title")}</h1>
<p className="settings-page__status settings-page__status--error">{t("home.error")}</p>
</div>
);
}
return (
<div className="settings-page">
<h1>{t("household.title")}</h1>
{house === null ? (
<NoHousehold onChanged={loadHouse} />
) : (
<HasHousehold house={house} currentUserId={user?.id ?? null} onChanged={loadHouse} />
)}
</div>
);
}
/** Create-or-join forms shown when the profile doesn't belong to a household yet. */
function NoHousehold({ onChanged }: { onChanged: () => void }) {
const { t } = useTranslation();
const [name, setName] = useState("");
const [nameErrors, setNameErrors] = useState<Record<string, string>>({});
const [createError, setCreateError] = useState<string | null>(null);
const [isCreating, setIsCreating] = useState(false);
const [inviteCode, setInviteCode] = useState("");
const [joinError, setJoinError] = useState<string | null>(null);
const [isJoining, setIsJoining] = useState(false);
async function handleCreate(e: FormEvent) {
e.preventDefault();
setCreateError(null);
const result = renameHouseSchema.safeParse({ name });
if (!result.success) {
setNameErrors(fieldErrorsFrom(result.error));
return;
}
setNameErrors({});
setIsCreating(true);
try {
await apiClient.createHouse(result.data.name);
onChanged();
} catch (err) {
const code = err instanceof ApiError ? err.code : ErrorCode.INTERNAL_ERROR;
setCreateError(errorMessageService.getLabel(code));
} finally {
setIsCreating(false);
}
}
async function handleJoin(e: FormEvent) {
e.preventDefault();
setJoinError(null);
setIsJoining(true);
try {
await apiClient.joinHouse(inviteCode.trim());
onChanged();
} catch (err) {
const code = err instanceof ApiError ? err.code : ErrorCode.INTERNAL_ERROR;
setJoinError(errorMessageService.getLabel(code));
} finally {
setIsJoining(false);
}
}
return (
<>
<p className="settings-page__hint">{t("household.noHouse.intro")}</p>
<form className="settings-page__section" onSubmit={handleCreate} noValidate>
<h2 className="settings-page__section-title">{t("household.noHouse.createTitle")}</h2>
<HouseNameField value={name} onChange={setName} error={nameErrors.name} />
{createError && <p className="form-error">{createError}</p>}
<div className="settings-page__actions">
<button type="submit" disabled={isCreating}>
{t("household.noHouse.createButton")}
</button>
</div>
</form>
<form className="settings-page__section" onSubmit={handleJoin} noValidate>
<h2 className="settings-page__section-title">{t("household.noHouse.joinTitle")}</h2>
<label htmlFor="inviteCode">{t("household.noHouse.joinLabel")}</label>
<input
id="inviteCode"
value={inviteCode}
onChange={(e) => setInviteCode(e.target.value.toUpperCase())}
autoComplete="off"
/>
{joinError && <p className="form-error">{joinError}</p>}
<div className="settings-page__actions">
<button type="submit" disabled={isJoining || inviteCode.trim().length === 0}>
{t("household.noHouse.joinButton")}
</button>
</div>
</form>
</>
);
}
/** Household details/management shown once the profile belongs to one. */
function HasHousehold({
house,
currentUserId,
onChanged,
}: {
house: HouseView;
currentUserId: number | null;
onChanged: () => void;
}) {
const { t } = useTranslation();
const isAdmin = currentUserId !== null && currentUserId === house.adminId;
const [name, setName] = useState(house.name);
const [nameErrors, setNameErrors] = useState<Record<string, string>>({});
const [saveState, setSaveState] = useState<SaveState>("idle");
const [saveError, setSaveError] = useState<string | null>(null);
const nameTimeout = useRef<number | undefined>(undefined);
useEffect(() => {
setName(house.name);
}, [house.name]);
useEffect(() => {
return () => window.clearTimeout(nameTimeout.current);
}, []);
function handleNameChange(newName: string) {
setName(newName);
window.clearTimeout(nameTimeout.current);
const result = renameHouseSchema.safeParse({ name: newName });
if (!result.success) {
setNameErrors(fieldErrorsFrom(result.error));
setSaveState("idle");
return;
}
setNameErrors({});
setSaveState("saving");
nameTimeout.current = window.setTimeout(async () => {
try {
await apiClient.renameHouse(result.data.name);
setSaveState("saved");
} catch (err) {
const code = err instanceof ApiError ? err.code : ErrorCode.INTERNAL_ERROR;
setSaveError(errorMessageService.getLabel(code));
setSaveState("error");
}
}, HOUSE_NAME_DEBOUNCE_MS);
}
return (
<>
<div className="settings-page__section">
<HouseNameField value={name} onChange={handleNameChange} error={nameErrors.name} />
{saveState === "saving" && <p className="settings-page__saving">{t("common.saving")}</p>}
{saveState === "saved" && <p className="settings-page__saved">{t("common.saved")}</p>}
{saveState === "error" && <p className="field-error">{saveError}</p>}
<div className="settings-page__invite-section">
<p className="settings-page__hint">{t("household.inviteCodeLabel")}</p>
<InviteCode code={house.inviteCode} />
</div>
</div>
<div className="settings-page__section">
<h2 className="settings-page__section-title">{t("household.membersTitle")}</h2>
<ul className="settings-page__members">
{house.members.map((member) => (
<li key={member.id} className="settings-page__member">
<span>
{member.firstName} {member.lastName}
{member.id === currentUserId && t("household.youSuffix")}
{member.id === house.adminId && (
<span className="settings-page__member-badge">{t("household.adminBadge")}</span>
)}
</span>
{isAdmin && member.id !== currentUserId && (
<RemoveMemberButton memberId={member.id} onChanged={onChanged} />
)}
</li>
))}
</ul>
</div>
{isAdmin ? (
<DeleteHouseholdSection onChanged={onChanged} />
) : (
<LeaveHouseholdSection onChanged={onChanged} />
)}
</>
);
}
/** Read-only invite code display with a one-click clipboard copy. */
function InviteCode({ code }: { code: string }) {
const { t } = useTranslation();
const [copied, setCopied] = useState(false);
async function handleCopy() {
await navigator.clipboard.writeText(code);
setCopied(true);
window.setTimeout(() => setCopied(false), 2000);
}
return (
<div className="settings-page__actions">
<span className="settings-page__invite-code">{code}</span>
<button type="button" onClick={handleCopy}>
{copied ? t("household.copied") : t("household.copyButton")}
</button>
</div>
);
}
/** Admin-only button removing one specific member from the household. */
function RemoveMemberButton({
memberId,
onChanged,
}: {
memberId: number;
onChanged: () => void;
}) {
const { t } = useTranslation();
const [isRemoving, setIsRemoving] = useState(false);
async function handleRemove() {
setIsRemoving(true);
try {
await apiClient.removeHouseMember(memberId);
onChanged();
} finally {
setIsRemoving(false);
}
}
return (
<button type="button" onClick={handleRemove} disabled={isRemoving}>
{t("household.removeButton")}
</button>
);
}
/** Admin-only danger zone: delete the household outright, with an inline two-step confirmation. */
function DeleteHouseholdSection({ onChanged }: { onChanged: () => void }) {
const { t } = useTranslation();
const [isConfirming, setIsConfirming] = useState(false);
const [isDeleting, setIsDeleting] = useState(false);
async function handleDelete() {
setIsDeleting(true);
try {
await apiClient.deleteHouse();
onChanged();
} finally {
setIsDeleting(false);
}
}
return (
<div className="settings-page__section settings-page__danger-zone">
<h2 className="settings-page__section-title">{t("household.dangerZone.title")}</h2>
<p className="settings-page__hint">{t("household.dangerZone.description")}</p>
<div className="settings-page__actions">
{!isConfirming ? (
<button
type="button"
className="settings-page__danger-button"
onClick={() => setIsConfirming(true)}
>
{t("household.dangerZone.deleteButton")}
</button>
) : (
<>
<button
type="button"
className="settings-page__danger-button"
onClick={handleDelete}
disabled={isDeleting}
>
{t("household.dangerZone.confirmButton")}
</button>
<button type="button" onClick={() => setIsConfirming(false)} disabled={isDeleting}>
{t("household.dangerZone.cancelButton")}
</button>
</>
)}
</div>
</div>
);
}
/** Non-admin members' way out: leave the household (immediate — no confirmation step, unlike deleting it entirely, since it only affects the leaving member). */
function LeaveHouseholdSection({ onChanged }: { onChanged: () => void }) {
const { t } = useTranslation();
const [isLeaving, setIsLeaving] = useState(false);
async function handleLeave() {
setIsLeaving(true);
try {
await apiClient.leaveHouse();
onChanged();
} finally {
setIsLeaving(false);
}
}
return (
<div className="settings-page__section">
<div className="settings-page__actions">
<button type="button" onClick={handleLeave} disabled={isLeaving}>
{t("household.leaveButton")}
</button>
</div>
</div>
);
}

View file

@ -0,0 +1,185 @@
import { type AllergyView, type DietView, ErrorCode } 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 { 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);
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.me(),
])
.then(([dietsResult, allergiesResult, allergyIdsResult, profile]) => {
if (cancelled) return;
setDiets(dietsResult);
setAllergies(allergiesResult);
setAllergyIds(allergyIdsResult);
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);
};
}, []);
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);
}
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("home.error")}</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>
);
}
/** 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;
}

View file

@ -0,0 +1,151 @@
// =============================================================================
// Styles shared by the three settings pages (AccountSettingsPage,
// PreferencesPage, HouseholdSettingsPage) colocated under pages/settings/
// since nothing outside that folder uses these classes. Field/label/input
// styling itself still comes from features/profile/profile-forms.scss
// (shared with the onboarding wizard); this file only covers page layout
// direct continuation of what used to be HouseholdPage.scss before the
// household/regime/allergies page was split in three.
// =============================================================================
.settings-page {
&__status {
color: var(--color-text-muted);
font-size: var(--font-size-md);
}
&__status--error {
color: var(--color-error);
}
}
// Each setting lives in its own card, same surface treatment used
// elsewhere (see .planning-table in HomePage.scss) reads as a distinct,
// self-contained unit rather than one long form.
.settings-page__section {
max-width: 32rem;
margin-top: var(--space-lg);
padding: var(--space-lg);
background: var(--color-surface);
border-radius: var(--radius-md);
box-shadow: var(--shadow-sm);
}
.settings-page__section-title {
margin: 0 0 var(--space-sm);
font-size: var(--font-size-md);
}
.settings-page__saving,
.settings-page__saved {
margin: var(--space-sm) 0 0;
font-size: var(--font-size-sm);
font-weight: 600;
}
.settings-page__saving {
color: var(--color-text-muted);
}
.settings-page__saved {
color: var(--color-success);
}
.settings-page__actions {
display: flex;
gap: var(--space-sm);
margin-top: var(--space-sm);
}
.settings-page button {
padding: 0.5rem var(--space-md);
font-family: var(--font-body);
font-size: var(--font-size-sm);
font-weight: 600;
cursor: pointer;
border-radius: var(--radius-base);
border: 1px solid var(--color-border);
background: var(--color-surface);
color: var(--color-text);
&:hover:not(:disabled) {
background: var(--color-surface-alt);
}
&:disabled {
opacity: 0.6;
cursor: not-allowed;
}
}
.settings-page__hint {
color: var(--color-text-muted);
font-size: var(--font-size-sm);
}
.settings-page__invite-section {
margin-top: var(--space-md);
}
// Household member list.
.settings-page__members {
list-style: none;
margin: var(--space-sm) 0 0;
padding: 0;
display: flex;
flex-direction: column;
gap: var(--space-xs);
}
.settings-page__member {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--space-sm);
padding: var(--space-xs) 0;
}
.settings-page__member-badge {
margin-left: var(--space-xs);
padding: 0.1rem 0.4rem;
font-size: var(--font-size-xs);
font-weight: 600;
color: var(--color-primary);
background: var(--color-surface-alt);
border-radius: var(--radius-base);
}
// Invite code meant to be read/copied, so it's set in a monospace font
// and never wraps mid-code.
.settings-page__invite-code {
font-family: monospace;
font-size: var(--font-size-md);
letter-spacing: 0.08em;
}
// Destructive actions (delete household/account, remove a member) get a
// visually distinct, consistent "danger" treatment wherever they appear.
.settings-page__danger-zone {
margin-top: var(--space-lg);
border-color: var(--color-error);
}
.settings-page__danger-button {
color: #fff;
background: var(--color-error);
border-color: var(--color-error);
&:hover {
opacity: 0.9;
}
}
button.settings-page__link-button {
padding: 0;
font: inherit;
color: var(--color-primary);
background: none;
border: none;
cursor: pointer;
text-decoration: underline;
}