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:
parent
7bc81f51fb
commit
5d88e28810
4 changed files with 260 additions and 6 deletions
|
|
@ -14,6 +14,14 @@ interface AuthContextValue {
|
|||
login: (input: LoginInput) => Promise<void>;
|
||||
/** Ends the session and clears `user`. */
|
||||
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. */
|
||||
|
|
@ -51,8 +59,12 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
|||
setUser(null);
|
||||
}, []);
|
||||
|
||||
const refreshUser = useCallback(async () => {
|
||||
setUser(await apiClient.me());
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<AuthContext.Provider value={{ user, isLoading, signup, login, logout }}>
|
||||
<AuthContext.Provider value={{ user, isLoading, signup, login, logout, refreshUser }}>
|
||||
{children}
|
||||
</AuthContext.Provider>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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é ✓"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
59
apps/web/src/pages/HouseholdPage.scss
Normal file
59
apps/web/src/pages/HouseholdPage.scss
Normal 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;
|
||||
}
|
||||
|
|
@ -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 <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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue