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:
parent
8e9457297d
commit
7abe030bb9
14 changed files with 984 additions and 327 deletions
|
|
@ -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={
|
||||
|
|
|
|||
|
|
@ -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 }) });
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
106
apps/web/src/pages/settings/AccountSettingsPage.tsx
Normal file
106
apps/web/src/pages/settings/AccountSettingsPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
386
apps/web/src/pages/settings/HouseholdSettingsPage.tsx
Normal file
386
apps/web/src/pages/settings/HouseholdSettingsPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
185
apps/web/src/pages/settings/PreferencesPage.tsx
Normal file
185
apps/web/src/pages/settings/PreferencesPage.tsx
Normal 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;
|
||||
}
|
||||
151
apps/web/src/pages/settings/settings-pages.scss
Normal file
151
apps/web/src/pages/settings/settings-pages.scss
Normal 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;
|
||||
}
|
||||
Loading…
Reference in a new issue