Web: page /foyer réelle (édition foyer/profil) (step 5/6)

- 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.
This commit is contained in:
Nicolas 2026-08-16 23:38:48 +02:00
parent 7bc81f51fb
commit 5d88e28810
4 changed files with 260 additions and 6 deletions

View file

@ -14,6 +14,14 @@ 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>;
/**
* 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<void>;
} }
/** React context carrying {@link AuthContextValue} — always accessed through {@link useAuth}, never directly. */ /** React context carrying {@link AuthContextValue} — always accessed through {@link useAuth}, never directly. */
@ -51,8 +59,12 @@ export function AuthProvider({ children }: { children: ReactNode }) {
setUser(null); setUser(null);
}, []); }, []);
const refreshUser = useCallback(async () => {
setUser(await apiClient.me());
}, []);
return ( return (
<AuthContext.Provider value={{ user, isLoading, signup, login, logout }}> <AuthContext.Provider value={{ user, isLoading, signup, login, logout, refreshUser }}>
{children} {children}
</AuthContext.Provider> </AuthContext.Provider>
); );

View file

@ -78,12 +78,13 @@
}, },
"household": { "household": {
"title": "Foyer & profil", "title": "Foyer & profil",
"comingSoon": "Cette section arrive bientôt.",
"form": { "form": {
"nameLabel": "Nom du foyer", "nameLabel": "Nom du foyer",
"dietLabel": "Régime alimentaire", "dietLabel": "Régime alimentaire",
"dietNone": "Aucun régime particulier", "dietNone": "Aucun régime particulier",
"allergiesLabel": "Allergies & intolérances" "allergiesLabel": "Allergies & intolérances",
"save": "Enregistrer",
"saved": "Enregistré ✓"
} }
} }
} }

View file

@ -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;
}

View file

@ -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 { 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() { export function HouseholdPage() {
const { t } = useTranslation(); const { t } = useTranslation();
return <ComingSoonPage title={t("household.title")} description={t("household.comingSoon")} />; 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 [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);
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 (
<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>
<form className="household-page__section" onSubmit={handleSaveHouseName} noValidate>
<HouseNameField value={houseName} onChange={setHouseName} error={houseNameErrors.name} />
{houseSaveState === "error" && <p className="field-error">{houseSaveError}</p>}
<button type="submit" disabled={houseSaveState === "saving"}>
{t("household.form.save")}
</button>
{houseSaveState === "saved" && (
<span className="household-page__saved">{t("household.form.saved")}</span>
)}
</form>
<form className="household-page__section" onSubmit={handleSaveDiet} noValidate>
<DietSelect diets={diets} value={dietId} onChange={setDietId} />
{dietSaveState === "error" && <p className="field-error">{dietSaveError}</p>}
<button type="submit" disabled={dietSaveState === "saving"}>
{t("household.form.save")}
</button>
{dietSaveState === "saved" && (
<span className="household-page__saved">{t("household.form.saved")}</span>
)}
</form>
<form className="household-page__section" onSubmit={handleSaveAllergies} noValidate>
<AllergySelect allergies={allergies} value={allergyIds} onChange={setAllergyIds} />
{allergySaveState === "error" && <p className="field-error">{allergySaveError}</p>}
<button type="submit" disabled={allergySaveState === "saving"}>
{t("household.form.save")}
</button>
{allergySaveState === "saved" && (
<span className="household-page__saved">{t("household.form.saved")}</span>
)}
</form>
</div>
);
} }