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")} + )} + +
+ ); }