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:
parent
1d03effc77
commit
e512c33ffc
6 changed files with 242 additions and 1 deletions
|
|
@ -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. */
|
||||
|
|
|
|||
46
apps/web/src/features/profile/AllergySelect.tsx
Normal file
46
apps/web/src/features/profile/AllergySelect.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
42
apps/web/src/features/profile/DietSelect.tsx
Normal file
42
apps/web/src/features/profile/DietSelect.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
32
apps/web/src/features/profile/HouseNameField.tsx
Normal file
32
apps/web/src/features/profile/HouseNameField.tsx
Normal 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>}
|
||||
</>
|
||||
);
|
||||
}
|
||||
74
apps/web/src/features/profile/profile-forms.scss
Normal file
74
apps/web/src/features/profile/profile-forms.scss
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue