From 5d88e28810ea6e47b98e2619784e7e02f19cd34b Mon Sep 17 00:00:00 2001 From: Nicolas Date: Sun, 16 Aug 2026 23:38:48 +0200 Subject: [PATCH] =?UTF-8?q?Web:=20page=20/foyer=20r=C3=A9elle=20(=C3=A9dit?= =?UTF-8?q?ion=20foyer/profil)=20(step=205/6)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - HouseholdPage remplace le stub ComingSoonPage : 3 sections indépendamment sauvegardées (nom du foyer, régime, allergènes/ intolérances), mêmes composants partagés que le wizard d'inscription. Chaque section a son propre bouton "Enregistrer" (3 ressources API distinctes, pas de raison qu'une modification attende les autres). - AuthContext : ajout de refreshUser() — re-fetch GET /auth/me et met à jour `user`. - Bug trouvé et corrigé en testant l'aller-retour SPA dans le navigateur (sidebar → Recettes → Foyer, sans rechargement complet) : la valeur du régime revenait à l'ancienne après sauvegarde. Cause : la page initialisait dietId depuis useAuth().user.dietId, un instantané jamais rafraîchi après une modification faite directement via apiClient (qui ne touche pas AuthContext). Fix : la page fetch son propre profil frais (apiClient.me()) au montage plutôt que de dépendre du contexte, et appelle refreshUser() après une sauvegarde réussie du régime pour que le reste de l'app reste cohérent aussi. - household.comingSoon (clé i18n) supprimée, plus utilisée. Vérifié dans le navigateur : préremplissage, sauvegarde par section, persistance après rechargement ET après navigation SPA aller-retour ; nettoyage du compte de test. --- apps/web/src/features/auth/AuthContext.tsx | 14 +- apps/web/src/locales/fr/translation.json | 5 +- apps/web/src/pages/HouseholdPage.scss | 59 +++++++ apps/web/src/pages/HouseholdPage.tsx | 188 ++++++++++++++++++++- 4 files changed, 260 insertions(+), 6 deletions(-) create mode 100644 apps/web/src/pages/HouseholdPage.scss diff --git a/apps/web/src/features/auth/AuthContext.tsx b/apps/web/src/features/auth/AuthContext.tsx index a63bf00..cca04ac 100644 --- a/apps/web/src/features/auth/AuthContext.tsx +++ b/apps/web/src/features/auth/AuthContext.tsx @@ -14,6 +14,14 @@ interface AuthContextValue { login: (input: LoginInput) => Promise; /** Ends the session and clears `user`. */ logout: () => Promise; + /** + * 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`, + * which doesn't touch this context on its own. + */ + refreshUser: () => Promise; } /** React context carrying {@link AuthContextValue} — always accessed through {@link useAuth}, never directly. */ @@ -51,8 +59,12 @@ export function AuthProvider({ children }: { children: ReactNode }) { setUser(null); }, []); + const refreshUser = useCallback(async () => { + setUser(await apiClient.me()); + }, []); + return ( - + {children} ); diff --git a/apps/web/src/locales/fr/translation.json b/apps/web/src/locales/fr/translation.json index 0bd77e8..fc5dea8 100644 --- a/apps/web/src/locales/fr/translation.json +++ b/apps/web/src/locales/fr/translation.json @@ -78,12 +78,13 @@ }, "household": { "title": "Foyer & profil", - "comingSoon": "Cette section arrive bientôt.", "form": { "nameLabel": "Nom du foyer", "dietLabel": "Régime alimentaire", "dietNone": "Aucun régime particulier", - "allergiesLabel": "Allergies & intolérances" + "allergiesLabel": "Allergies & intolérances", + "save": "Enregistrer", + "saved": "Enregistré ✓" } } } diff --git a/apps/web/src/pages/HouseholdPage.scss b/apps/web/src/pages/HouseholdPage.scss new file mode 100644 index 0000000..7052b0c --- /dev/null +++ b/apps/web/src/pages/HouseholdPage.scss @@ -0,0 +1,59 @@ +// ============================================================================= +// 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, allergens) is its +// own independently-saved 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. +.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); + + button { + margin-top: var(--space-md); + padding: 0.5rem var(--space-lg); + font-family: var(--font-body); + font-size: var(--font-size-base); + font-weight: 600; + cursor: pointer; + border-radius: var(--radius-base); + border: none; + background: var(--color-primary); + color: #fff; + + &:hover:not(:disabled) { + background: var(--color-primary-hover); + } + + &:disabled { + opacity: 0.6; + cursor: not-allowed; + } + } +} + +.household-page__saved { + margin-left: var(--space-sm); + color: var(--color-success); + font-size: var(--font-size-sm); + font-weight: 600; +} diff --git a/apps/web/src/pages/HouseholdPage.tsx b/apps/web/src/pages/HouseholdPage.tsx index 8502f37..6aaecfc 100644 --- a/apps/web/src/pages/HouseholdPage.tsx +++ b/apps/web/src/pages/HouseholdPage.tsx @@ -1,8 +1,190 @@ +import { + type AllergyView, + type DietView, + ErrorCode, + renameHouseSchema, +} from "@batch-cooking/shared"; +import { type FormEvent, useEffect, useState } from "react"; import { useTranslation } from "react-i18next"; -import { ComingSoonPage } from "./ComingSoonPage"; +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"; -/** Household & profile section — routed at `/foyer`. No backend yet beyond auth, stub for now. */ +/** Status of one section's own save action — sections save independently, each with its own feedback. */ +type SaveState = "idle" | "saving" | "saved" | "error"; + +/** + * Household & profile settings — routed at `/foyer`. The always-available + * counterpart to the signup wizard (`pages/onboarding/`): same three + * concerns (household name, dietary regime, allergens/intolerances), same + * shared field components, but editable at any time rather than run once. + * Each section saves independently (three separate resources server-side — + * `PATCH /house/current`, `/profile/diet`, `/profile/allergies` — so there's + * no reason a change to one has to wait on the others). + */ export function HouseholdPage() { const { t } = useTranslation(); - return ; + const { refreshUser } = useAuth(); + + const [isLoading, setIsLoading] = useState(true); + const [loadError, setLoadError] = useState(false); + + const [houseName, setHouseName] = useState(""); + const [houseNameErrors, setHouseNameErrors] = useState>({}); + const [houseSaveState, setHouseSaveState] = useState("idle"); + const [houseSaveError, setHouseSaveError] = useState(null); + + const [diets, setDiets] = useState([]); + const [dietId, setDietId] = useState(null); + const [dietSaveState, setDietSaveState] = useState("idle"); + const [dietSaveError, setDietSaveError] = useState(null); + + const [allergies, setAllergies] = useState([]); + const [allergyIds, setAllergyIds] = useState([]); + const [allergySaveState, setAllergySaveState] = useState("idle"); + const [allergySaveError, setAllergySaveError] = useState(null); + + 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; + }; + }, []); + + async function handleSaveHouseName(e: FormEvent) { + e.preventDefault(); + const result = renameHouseSchema.safeParse({ name: houseName }); + if (!result.success) { + setHouseNameErrors(fieldErrorsFrom(result.error)); + return; + } + setHouseNameErrors({}); + setHouseSaveState("saving"); + 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"); + } + } + + async function handleSaveDiet(e: FormEvent) { + e.preventDefault(); + setDietSaveState("saving"); + try { + await apiClient.updateDiet(dietId); + // 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"); + } + } + + async function handleSaveAllergies(e: FormEvent) { + e.preventDefault(); + setAllergySaveState("saving"); + try { + await apiClient.updateAllergyIds(allergyIds); + setAllergySaveState("saved"); + } catch (err) { + const code = err instanceof ApiError ? err.code : ErrorCode.INTERNAL_ERROR; + setAllergySaveError(errorMessageService.getLabel(code)); + setAllergySaveState("error"); + } + } + + if (isLoading) { + return ( +
+

{t("household.title")}

+

{t("onboarding.loading")}

+
+ ); + } + + if (loadError) { + return ( +
+

{t("household.title")}

+

{t("home.error")}

+
+ ); + } + + return ( +
+

{t("household.title")}

+ +
+ + {houseSaveState === "error" &&

{houseSaveError}

} + + {houseSaveState === "saved" && ( + {t("household.form.saved")} + )} + + +
+ + {dietSaveState === "error" &&

{dietSaveError}

} + + {dietSaveState === "saved" && ( + {t("household.form.saved")} + )} + + +
+ + {allergySaveState === "error" &&

{allergySaveError}

} + + {allergySaveState === "saved" && ( + {t("household.form.saved")} + )} + +
+ ); }