Web: composants partagés foyer/régime/allergènes (step 3/6)

- ApiClient: getDiets/getAllergies (référence), getCurrentHouse/
  renameHouse, updateDiet, getAllergyIds/updateAllergyIds.
- features/profile/: HouseNameField, DietSelect (toujours une option
  "aucun régime" -> null, étape skippable), AllergySelect (checkboxes
  en grille + fieldset/legend, pas un <select multiple> — plus
  tapable/accessible, notamment sur mobile). Tous "dumb"/contrôlés :
  reçoivent leurs données (diets/allergies) en props plutôt que de les
  fetcher eux-mêmes — le fetch/état de chargement reste à la page
  appelante.
- profile-forms.scss partagé par les trois (même split que
  features/auth/auth-form.scss vs Login/SignupPage : styles de champs
  ici, layout de page dans chaque page consommatrice).

Pas encore utilisés (aucune page ne les importe) — le wizard
d'inscription (étape suivante) et la page /foyer les cablent.
This commit is contained in:
Nicolas 2026-08-16 23:22:19 +02:00
parent 1d03effc77
commit e512c33ffc
6 changed files with 242 additions and 1 deletions

View file

@ -1,6 +1,9 @@
import {
type AllergyView,
type ApiErrorResponse,
type DietView,
ErrorCode,
type HouseView,
type LoginInput,
type PlanningView,
type SafeUserProfile,
@ -99,6 +102,44 @@ export class ApiClient {
public getCurrentPlanning(): Promise<PlanningView | null> {
return this.request("/planning/current");
}
/** Reference list of dietary regimes to pick from (signup wizard, `/foyer`). Public — no session required. */
public getDiets(): Promise<DietView[]> {
return this.request("/reference/diets");
}
/** Reference list of selectable allergens (signup wizard, `/foyer`). Public — no session required. */
public getAllergies(): Promise<AllergyView[]> {
return this.request("/reference/allergies");
}
/** Fetches the current user's household. */
public getCurrentHouse(): Promise<HouseView | null> {
return this.request("/house/current");
}
/** Renames the current user's household. */
public renameHouse(name: string): Promise<HouseView> {
return this.request("/house/current", { method: "PATCH", body: JSON.stringify({ name }) });
}
/** Sets (or clears, with `null`) the current user's dietary regime. */
public updateDiet(dietId: number | null): Promise<SafeUserProfile> {
return this.request("/profile/diet", { method: "PATCH", body: JSON.stringify({ dietId }) });
}
/** Fetches the current user's selected allergen ids. */
public getAllergyIds(): Promise<number[]> {
return this.request("/profile/allergies");
}
/** Replaces the current user's full allergen selection (not a merge — send the complete list). */
public updateAllergyIds(allergyIds: number[]): Promise<number[]> {
return this.request("/profile/allergies", {
method: "PATCH",
body: JSON.stringify({ allergyIds }),
});
}
}
/** Single shared instance — this client is stateless, no need for one per caller. */

View file

@ -0,0 +1,46 @@
import type { AllergyView } from "@batch-cooking/shared";
import { useTranslation } from "react-i18next";
import "./profile-forms.scss";
interface AllergySelectProps {
allergies: AllergyView[];
value: number[];
onChange: (allergyIds: number[]) => void;
}
/**
* Multi-select (checkbox grid, not a native `<select multiple>` far more
* discoverable/tappable, especially on the mobile viewport this app is
* eventually embedded into via Capacitor) for allergens/intolerances. Used
* both by the signup wizard's allergens step and the `/foyer` settings
* page. An empty `value` is a normal, valid state (no declared allergies,
* or this skippable step was skipped), not an incomplete one.
*
* A `<fieldset>`/`<legend>` (not a bare `<label>`, which only associates
* with a single control) the correct semantic label for a group of
* checkboxes. Receives `allergies` as a prop rather than fetching them
* itself same rationale as `DietSelect`.
*/
export function AllergySelect({ allergies, value, onChange }: AllergySelectProps) {
const { t } = useTranslation();
function toggle(id: number) {
onChange(value.includes(id) ? value.filter((existing) => existing !== id) : [...value, id]);
}
return (
<fieldset className="allergy-select">
<legend>{t("household.form.allergiesLabel")}</legend>
{allergies.map((allergy) => (
<label key={allergy.id} className="allergy-select__option">
<input
type="checkbox"
checked={value.includes(allergy.id)}
onChange={() => toggle(allergy.id)}
/>
{allergy.name}
</label>
))}
</fieldset>
);
}

View file

@ -0,0 +1,42 @@
import type { DietView } from "@batch-cooking/shared";
import { useTranslation } from "react-i18next";
import "./profile-forms.scss";
interface DietSelectProps {
diets: DietView[];
value: number | null;
onChange: (dietId: number | null) => void;
}
/**
* Dropdown picker for a dietary regime used both by the signup wizard's
* regime step and the `/foyer` settings page. Always includes a "none"
* option (mapped to `null`, not just an empty label) since this step of the
* profile journey is skippable a profile with no regime is a normal,
* valid state, not an incomplete one.
*
* 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
* plain, easy-to-test presentational component.
*/
export function DietSelect({ diets, value, onChange }: DietSelectProps) {
const { t } = useTranslation();
return (
<>
<label htmlFor="diet">{t("household.form.dietLabel")}</label>
<select
id="diet"
value={value ?? ""}
onChange={(e) => onChange(e.target.value === "" ? null : Number(e.target.value))}
>
<option value="">{t("household.form.dietNone")}</option>
{diets.map((diet) => (
<option key={diet.id} value={diet.id}>
{diet.name}
</option>
))}
</select>
</>
);
}

View file

@ -0,0 +1,32 @@
import { useTranslation } from "react-i18next";
import "./profile-forms.scss";
interface HouseNameFieldProps {
value: string;
onChange: (name: string) => void;
error?: string;
}
/**
* Labeled text input for the household's name used both by the signup
* wizard's household step and the `/foyer` settings page (see
* `profile-forms.scss` for the shared styling both consume). Controlled
* component: the caller owns the value and persists it (`ApiClient.
* renameHouse`) on submit, not this component.
*/
export function HouseNameField({ value, onChange, error }: HouseNameFieldProps) {
const { t } = useTranslation();
return (
<>
<label htmlFor="houseName">{t("household.form.nameLabel")}</label>
<input
id="houseName"
value={value}
onChange={(e) => onChange(e.target.value)}
autoComplete="off"
/>
{error && <p className="field-error">{error}</p>}
</>
);
}

View file

@ -0,0 +1,74 @@
// =============================================================================
// Styles shared by the profile-journey field components (HouseNameField,
// DietSelect, AllergySelect) used both by the signup wizard's steps
// (pages/onboarding/) and the `/foyer` settings page (HouseholdPage). Field
// styling only (label/input/select/checkbox) the surrounding page/card
// layout belongs 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
// CSS custom property, see global.scss.
label {
display: block;
font-size: var(--font-size-sm);
font-weight: 600;
margin-top: var(--space-sm);
}
input,
select {
width: 100%;
padding: var(--space-sm);
font-family: var(--font-body);
font-size: var(--font-size-base);
color: var(--color-text);
background: var(--color-background);
border: 1px solid var(--color-border);
border-radius: var(--radius-base);
}
.field-error {
color: var(--color-error);
font-size: var(--font-size-xs);
margin: 0;
}
// Checkbox grid for allergens/intolerances a grid of tappable rows reads
// better than a native multi-select listbox, especially on the narrow
// viewport this app is eventually embedded into via Capacitor (see
// AllergySelect.tsx).
.allergy-select {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(10rem, 1fr));
gap: var(--space-xs) var(--space-md);
// Reset the browser's default fieldset chrome (border/padding) — the
// grid above is styling enough, this shouldn't look like a boxed panel.
margin: var(--space-sm) 0 0;
padding: 0;
border: none;
legend {
grid-column: 1 / -1;
padding: 0;
font-size: var(--font-size-sm);
font-weight: 600;
}
&__option {
display: flex;
align-items: center;
gap: var(--space-xs);
// Overrides the block/margin-top label rule above this label wraps
// an inline checkbox + text pair, not a field caption above an input.
margin-top: 0;
font-weight: 400;
font-size: var(--font-size-base);
cursor: pointer;
input[type="checkbox"] {
width: auto;
}
}
}

View file

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