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 { RequireAuth } from "./features/auth/RequireAuth";
|
||||||
import { AppLayout } from "./layouts/AppLayout";
|
import { AppLayout } from "./layouts/AppLayout";
|
||||||
import { HomePage } from "./pages/HomePage";
|
import { HomePage } from "./pages/HomePage";
|
||||||
import { HouseholdPage } from "./pages/HouseholdPage";
|
|
||||||
import { LoginPage } from "./pages/LoginPage";
|
import { LoginPage } from "./pages/LoginPage";
|
||||||
import { RecipesPage } from "./pages/RecipesPage";
|
import { RecipesPage } from "./pages/RecipesPage";
|
||||||
import { ShoppingListPage } from "./pages/ShoppingListPage";
|
import { ShoppingListPage } from "./pages/ShoppingListPage";
|
||||||
|
|
@ -11,6 +10,9 @@ import { SignupPage } from "./pages/SignupPage";
|
||||||
import { OnboardingAllergensPage } from "./pages/onboarding/OnboardingAllergensPage";
|
import { OnboardingAllergensPage } from "./pages/onboarding/OnboardingAllergensPage";
|
||||||
import { OnboardingDietPage } from "./pages/onboarding/OnboardingDietPage";
|
import { OnboardingDietPage } from "./pages/onboarding/OnboardingDietPage";
|
||||||
import { OnboardingHouseholdPage } from "./pages/onboarding/OnboardingHouseholdPage";
|
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
|
* 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
|
* {@link RedirectIfAuthenticated}). Anything else falls back to `/`, which
|
||||||
* itself redirects to `/login` if needed.
|
* itself redirects to `/login` if needed.
|
||||||
*
|
*
|
||||||
* `/onboarding/*` (household/regime/allergens) is also `RequireAuth`-gated
|
* `/parametres/*` (compte/préférences/foyer) are the settings pages,
|
||||||
* — reached right after signup, once a session already exists — but
|
* reachable from the sidebar's bottom "Paramètres" menu and the account
|
||||||
* deliberately its own top-level route group, *not* nested under
|
* menu (see `AppLayout`) — nested under `AppLayout` like every other
|
||||||
* `AppLayout`: a focused, distraction-free wizard with no sidebar, same
|
* authenticated section. `/foyer` is the old, pre-split combined page's
|
||||||
* full-page-card language as `/login`/`/signup` (see `onboarding.scss`).
|
* 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() {
|
export function App() {
|
||||||
return (
|
return (
|
||||||
|
|
@ -39,16 +48,11 @@ export function App() {
|
||||||
<Route path="/" element={<HomePage />} />
|
<Route path="/" element={<HomePage />} />
|
||||||
<Route path="/recettes" element={<RecipesPage />} />
|
<Route path="/recettes" element={<RecipesPage />} />
|
||||||
<Route path="/liste-de-courses" element={<ShoppingListPage />} />
|
<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>
|
||||||
<Route
|
|
||||||
path="/onboarding/foyer"
|
|
||||||
element={
|
|
||||||
<RequireAuth>
|
|
||||||
<OnboardingHouseholdPage />
|
|
||||||
</RequireAuth>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
<Route
|
<Route
|
||||||
path="/onboarding/regime"
|
path="/onboarding/regime"
|
||||||
element={
|
element={
|
||||||
|
|
@ -57,6 +61,14 @@ export function App() {
|
||||||
</RequireAuth>
|
</RequireAuth>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
<Route
|
||||||
|
path="/onboarding/foyer"
|
||||||
|
element={
|
||||||
|
<RequireAuth>
|
||||||
|
<OnboardingHouseholdPage />
|
||||||
|
</RequireAuth>
|
||||||
|
}
|
||||||
|
/>
|
||||||
<Route
|
<Route
|
||||||
path="/onboarding/allergenes"
|
path="/onboarding/allergenes"
|
||||||
element={
|
element={
|
||||||
|
|
|
||||||
|
|
@ -98,6 +98,11 @@ export class ApiClient {
|
||||||
return this.request("/auth/me");
|
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. */
|
/** Fetches the current user's household's planning for today, or `null` if there isn't one yet. */
|
||||||
public getCurrentPlanning(): Promise<PlanningView | null> {
|
public getCurrentPlanning(): Promise<PlanningView | null> {
|
||||||
return this.request("/planning/current");
|
return this.request("/planning/current");
|
||||||
|
|
@ -113,7 +118,7 @@ export class ApiClient {
|
||||||
return this.request("/reference/allergies");
|
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> {
|
public getCurrentHouse(): Promise<HouseView | null> {
|
||||||
return this.request("/house/current");
|
return this.request("/house/current");
|
||||||
}
|
}
|
||||||
|
|
@ -123,6 +128,31 @@ export class ApiClient {
|
||||||
return this.request("/house/current", { method: "PATCH", body: JSON.stringify({ name }) });
|
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. */
|
/** Sets (or clears, with `null`) the current user's dietary regime. */
|
||||||
public updateDiet(dietId: number | null): Promise<SafeUserProfile> {
|
public updateDiet(dietId: number | null): Promise<SafeUserProfile> {
|
||||||
return this.request("/profile/diet", { method: "PATCH", body: JSON.stringify({ dietId }) });
|
return this.request("/profile/diet", { method: "PATCH", body: JSON.stringify({ dietId }) });
|
||||||
|
|
|
||||||
|
|
@ -14,11 +14,13 @@ interface AuthContextValue {
|
||||||
login: (input: LoginInput) => Promise<void>;
|
login: (input: LoginInput) => Promise<void>;
|
||||||
/** Ends the session and clears `user`. */
|
/** Ends the session and clears `user`. */
|
||||||
logout: () => Promise<void>;
|
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
|
* Re-fetches the current profile and updates `user`. Needed after
|
||||||
* anything that changes profile fields `user` carries (e.g. `dietId`)
|
* anything that changes profile fields `user` carries (e.g. `dietId`)
|
||||||
* outside of `signup`/`login` — `PATCH /profile/diet` (see
|
* 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.
|
* which doesn't touch this context on its own.
|
||||||
*/
|
*/
|
||||||
refreshUser: () => Promise<void>;
|
refreshUser: () => Promise<void>;
|
||||||
|
|
@ -59,12 +61,19 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
||||||
setUser(null);
|
setUser(null);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
const deleteAccount = useCallback(async (password: string) => {
|
||||||
|
await apiClient.deleteAccount(password);
|
||||||
|
setUser(null);
|
||||||
|
}, []);
|
||||||
|
|
||||||
const refreshUser = useCallback(async () => {
|
const refreshUser = useCallback(async () => {
|
||||||
setUser(await apiClient.me());
|
setUser(await apiClient.me());
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AuthContext.Provider value={{ user, isLoading, signup, login, logout, refreshUser }}>
|
<AuthContext.Provider
|
||||||
|
value={{ user, isLoading, signup, login, logout, deleteAccount, refreshUser }}
|
||||||
|
>
|
||||||
{children}
|
{children}
|
||||||
</AuthContext.Provider>
|
</AuthContext.Provider>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -12,8 +12,8 @@ interface AllergySelectProps {
|
||||||
* Multi-select (checkbox grid, not a native `<select multiple>` — far more
|
* Multi-select (checkbox grid, not a native `<select multiple>` — far more
|
||||||
* discoverable/tappable, especially on the mobile viewport this app is
|
* discoverable/tappable, especially on the mobile viewport this app is
|
||||||
* eventually embedded into via Capacitor) for a group of allergens. Used
|
* eventually embedded into via Capacitor) for a group of allergens. Used
|
||||||
* both by the signup wizard's allergens step and the `/foyer` settings
|
* both by the signup wizard's allergens step and the `/parametres/preferences`
|
||||||
* page, and rendered *twice* by each — once for allergies, once for
|
* settings page, and rendered *twice* by each — once for allergies, once for
|
||||||
* intolerances (`AllergyView.kind` groups them; callers filter and pass
|
* intolerances (`AllergyView.kind` groups them; callers filter and pass
|
||||||
* two separate lists rather than this component knowing about the split).
|
* two separate lists rather than this component knowing about the split).
|
||||||
* An empty `value` is a normal, valid state (no declared allergies, or
|
* 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
|
* Dropdown picker for a dietary regime — used both by the signup wizard's
|
||||||
* regime step and the `/foyer` settings page. Always includes a "none"
|
* regime step and the `/parametres/preferences` settings page. Always
|
||||||
* option (mapped to `null`, not just an empty label) since this step of the
|
* includes a "none" option (mapped to `null`, not just an empty label)
|
||||||
* profile journey is skippable — a profile with no regime is a normal,
|
* since this step of the profile journey is skippable — a profile with no
|
||||||
* valid state, not an incomplete one.
|
* regime is a normal, valid state, not an incomplete one.
|
||||||
*
|
*
|
||||||
* Receives `diets` as a prop rather than fetching them itself: the caller
|
* 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
|
* (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 (
|
return (
|
||||||
<>
|
<>
|
||||||
<label htmlFor="diet">{t("household.form.dietLabel")}</label>
|
<label htmlFor="diet">{t("preferences.form.dietLabel")}</label>
|
||||||
<select
|
<select
|
||||||
id="diet"
|
id="diet"
|
||||||
value={value ?? ""}
|
value={value ?? ""}
|
||||||
onChange={(e) => onChange(e.target.value === "" ? null : Number(e.target.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) => (
|
{diets.map((diet) => (
|
||||||
<option key={diet.id} value={diet.id}>
|
<option key={diet.id} value={diet.id}>
|
||||||
{diet.name}
|
{diet.name}
|
||||||
|
|
|
||||||
|
|
@ -9,7 +9,7 @@ interface HouseNameFieldProps {
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Labeled text input for the household's name — used both by the signup
|
* 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
|
* `profile-forms.scss` for the shared styling both consume). Controlled
|
||||||
* component: the caller owns the value and persists it (`ApiClient.
|
* component: the caller owns the value and persists it (`ApiClient.
|
||||||
* renameHouse`) on submit, not this component.
|
* renameHouse`) on submit, not this component.
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,11 @@
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
// Styles shared by the profile-journey field components (HouseNameField,
|
// Styles shared by the profile-journey field components (HouseNameField,
|
||||||
// DietSelect, AllergySelect) — used both by the signup wizard's steps
|
// DietSelect, AllergySelect) — used both by the signup wizard's steps
|
||||||
// (pages/onboarding/) and the `/foyer` settings page (HouseholdPage). Field
|
// (pages/onboarding/) and the settings pages (PreferencesPage,
|
||||||
// styling only (label/input/select/checkbox) — the surrounding page/card
|
// HouseholdSettingsPage, under pages/settings/). Field styling only
|
||||||
// layout belongs to each consuming page's own .scss, same split as
|
// (label/input/select/checkbox) — the surrounding page/card layout belongs
|
||||||
// features/auth/auth-form.scss vs. LoginPage/SignupPage.
|
// 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
|
// No `@use` of the theme partial needed — every token below is a runtime
|
||||||
|
|
|
||||||
|
|
@ -1,13 +1,20 @@
|
||||||
{
|
{
|
||||||
|
"common": {
|
||||||
|
"saving": "Enregistrement…",
|
||||||
|
"saved": "Enregistré ✓"
|
||||||
|
},
|
||||||
"errors": {
|
"errors": {
|
||||||
"VALIDATION_ERROR": "Erreur de validation",
|
"VALIDATION_ERROR": "Erreur de validation",
|
||||||
"EMAIL_ALREADY_IN_USE": "Cet email est déjà utilisé",
|
"EMAIL_ALREADY_IN_USE": "Cet email est déjà utilisé",
|
||||||
"INVALID_CREDENTIALS": "Email ou mot de passe incorrect",
|
"INVALID_CREDENTIALS": "Email ou mot de passe incorrect",
|
||||||
"NOT_AUTHENTICATED": "Vous devez être connecté",
|
"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",
|
"NOT_FOUND": "Ressource introuvable",
|
||||||
"HOUSE_NOT_FOUND": "Votre profil n'a pas de foyer",
|
"HOUSE_NOT_FOUND": "Votre profil n'a pas de foyer",
|
||||||
"DIET_NOT_FOUND": "Ce régime alimentaire n'existe pas",
|
"DIET_NOT_FOUND": "Ce régime alimentaire n'existe pas",
|
||||||
"ALLERGY_NOT_FOUND": "Un des allergènes sélectionnés 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"
|
"INTERNAL_ERROR": "Une erreur est survenue, réessayez plus tard"
|
||||||
},
|
},
|
||||||
"auth": {
|
"auth": {
|
||||||
|
|
@ -37,12 +44,15 @@
|
||||||
"continue": "Continuer",
|
"continue": "Continuer",
|
||||||
"finish": "Terminer",
|
"finish": "Terminer",
|
||||||
"loading": "Chargement…",
|
"loading": "Chargement…",
|
||||||
"household": {
|
|
||||||
"title": "Comment s'appelle votre foyer ?"
|
|
||||||
},
|
|
||||||
"diet": {
|
"diet": {
|
||||||
"title": "Un régime alimentaire particulier ?"
|
"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": {
|
"allergens": {
|
||||||
"title": "Des allergies ou intolérances ?"
|
"title": "Des allergies ou intolérances ?"
|
||||||
}
|
}
|
||||||
|
|
@ -51,8 +61,18 @@
|
||||||
"nav": {
|
"nav": {
|
||||||
"planning": "Planning",
|
"planning": "Planning",
|
||||||
"recipes": "Recettes",
|
"recipes": "Recettes",
|
||||||
"shoppingList": "Liste de courses",
|
"shoppingList": "Liste de courses"
|
||||||
"household": "Foyer & profil"
|
},
|
||||||
|
"settings": {
|
||||||
|
"toggle": "Paramètres",
|
||||||
|
"nav": {
|
||||||
|
"account": "Compte",
|
||||||
|
"preferences": "Préférences",
|
||||||
|
"household": "Foyer"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"accountMenu": {
|
||||||
|
"myAccount": "Mon compte"
|
||||||
},
|
},
|
||||||
"greeting": "Bonjour {{firstName}} 👋",
|
"greeting": "Bonjour {{firstName}} 👋",
|
||||||
"logout": "Se déconnecter"
|
"logout": "Se déconnecter"
|
||||||
|
|
@ -76,16 +96,58 @@
|
||||||
"title": "Liste de courses",
|
"title": "Liste de courses",
|
||||||
"comingSoon": "Cette section arrive bientôt."
|
"comingSoon": "Cette section arrive bientôt."
|
||||||
},
|
},
|
||||||
"household": {
|
"account": {
|
||||||
"title": "Foyer & profil",
|
"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": {
|
"form": {
|
||||||
"nameLabel": "Nom du foyer",
|
|
||||||
"dietLabel": "Régime alimentaire",
|
"dietLabel": "Régime alimentaire",
|
||||||
"dietNone": "Aucun régime particulier",
|
"dietNone": "Aucun régime particulier",
|
||||||
"allergiesLabel": "Allergies",
|
"allergiesLabel": "Allergies",
|
||||||
"intolerancesLabel": "Intolérances",
|
"intolerancesLabel": "Intolérances"
|
||||||
"saving": "Enregistrement…",
|
}
|
||||||
"saved": "Enregistré ✓"
|
},
|
||||||
|
"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