feat(recipes): catégories d'ingrédients + nouveau sélecteur

- Prisma: enum IngredientCategory (18 valeurs) + Ingredient.category,
  migration appliquée
- reference-seed-data.ts restructuré en 18 groupes de catégories
- packages/shared: INGREDIENT_CATEGORIES + IngredientView.category
- API: category exposé par /reference/ingredients et /recipes
- Web: nouveau IngredientPicker (chips catégories + recherche + grille
  de cartes) remplaçant IngredientAutocomplete, branché dans le
  formulaire de recette et le champ aliments-pas-aimés
- i18n: clés recipes.form.category.* et libellés du picker
- Fix test reference.test.ts pour la nouvelle forme d'IngredientView

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Nicolas 2026-08-18 10:24:04 +02:00
parent acab18ac4a
commit ea86a4a7f1
15 changed files with 954 additions and 621 deletions

View file

@ -0,0 +1,6 @@
-- CreateEnum
CREATE TYPE "IngredientCategory" AS ENUM ('CEREALES_FECULENTS', 'LEGUMINEUSES', 'VIANDES_VOLAILLES', 'POISSONS_FRUITS_DE_MER', 'PRODUITS_LAITIERS_OEUFS', 'LEGUMES', 'FRUITS', 'FRUITS_SECS_OLEAGINEUX', 'CONDIMENTS_SAUCES', 'EPICES_HERBES', 'SUCRE_PATISSERIE', 'CUISINE_ITALIENNE', 'CUISINE_ASIATIQUE', 'CUISINE_MEXICAINE', 'MAGHREB_MOYEN_ORIENT', 'PAINS_SANDWICHS', 'EPICERIE_DIVERS', 'LIQUIDES_BOISSONS');
-- AlterTable
ALTER TABLE "ingredients" ADD COLUMN "category" "IngredientCategory" NOT NULL DEFAULT 'EPICERIE_DIVERS';

View file

@ -292,11 +292,44 @@ model RecipeDiet {
/// idempotent/safe to re-run, same reason as `Diet.name`/`Category.name`. /// idempotent/safe to re-run, same reason as `Diet.name`/`Category.name`.
/// Ingredients are reference data (like Diet/Allergy): seeded, never /// Ingredients are reference data (like Diet/Allergy): seeded, never
/// created/edited/deleted through the API. /// created/edited/deleted through the API.
/// Not in the original spec doc — coarse grouping (viandes, légumes,
/// épices...) so the ingredient picker (apps/web) can offer category
/// browsing, not just free-text search: with 400+ reference ingredients,
/// search alone doesn't scale to actually *finding* one. Mirrors
/// `reference-seed-data.ts`'s `INGREDIENT_GROUPS` keys exactly — that file
/// is the single source of truth for which ingredient belongs to which
/// category, this enum just gives it a type-safe column to live in.
/// `@default(EPICERIE_DIVERS)` exists only so this column can be added
/// `NOT NULL` to a table that may already have rows — the seed script
/// corrects every row's real category on the very next run, this default
/// is never the intended value for a real ingredient.
enum IngredientCategory {
CEREALES_FECULENTS
LEGUMINEUSES
VIANDES_VOLAILLES
POISSONS_FRUITS_DE_MER
PRODUITS_LAITIERS_OEUFS
LEGUMES
FRUITS
FRUITS_SECS_OLEAGINEUX
CONDIMENTS_SAUCES
EPICES_HERBES
SUCRE_PATISSERIE
CUISINE_ITALIENNE
CUISINE_ASIATIQUE
CUISINE_MEXICAINE
MAGHREB_MOYEN_ORIENT
PAINS_SANDWICHS
EPICERIE_DIVERS
LIQUIDES_BOISSONS
}
model Ingredient { model Ingredient {
id Int @id @default(autoincrement()) id Int @id @default(autoincrement())
name String @unique name String @unique
icon String? icon String?
alternateRecipeId Int? @map("alternate_recipe") category IngredientCategory @default(EPICERIE_DIVERS)
alternateRecipeId Int? @map("alternate_recipe")
alternateRecipe Recipe? @relation("IngredientAlternateRecipe", fields: [alternateRecipeId], references: [id], onDelete: SetNull) alternateRecipe Recipe? @relation("IngredientAlternateRecipe", fields: [alternateRecipeId], references: [id], onDelete: SetNull)
recipes RecipeIngredient[] recipes RecipeIngredient[]

File diff suppressed because it is too large Load diff

View file

@ -38,6 +38,7 @@ function toIngredientView(ingredient: IngredientWithAllergies): IngredientView {
id: ingredient.id, id: ingredient.id,
name: ingredient.name, name: ingredient.name,
icon: ingredient.icon, icon: ingredient.icon,
category: ingredient.category,
allergens: ingredient.allergies.map(({ allergy }) => ({ allergens: ingredient.allergies.map(({ allergy }) => ({
id: allergy.id, id: allergy.id,
name: allergy.category.name, name: allergy.category.name,

View file

@ -41,6 +41,7 @@ export async function getIngredients(): Promise<IngredientView[]> {
id: ingredient.id, id: ingredient.id,
name: ingredient.name, name: ingredient.name,
icon: ingredient.icon, icon: ingredient.icon,
category: ingredient.category,
allergens: ingredient.allergies.map(({ allergy }) => ({ allergens: ingredient.allergies.map(({ allergy }) => ({
id: allergy.id, id: allergy.id,
name: allergy.category.name, name: allergy.category.name,

View file

@ -54,7 +54,7 @@ describe("Reference data", () => {
expect(res.status).to.equal(200); expect(res.status).to.equal(200);
expect(res.body.length).to.be.greaterThan(0); expect(res.body.length).to.be.greaterThan(0);
expect(res.body.map((i: { name: string }) => i.name)).to.include("Tomate"); expect(res.body.map((i: { name: string }) => i.name)).to.include("Tomate");
expect(res.body[0]).to.have.keys(["id", "name", "icon", "allergens"]); expect(res.body[0]).to.have.keys(["id", "name", "icon", "category", "allergens"]);
}); });
it("resolves each ingredient's linked allergens, empty for one with none", async () => { it("resolves each ingredient's linked allergens, empty for one with none", async () => {

View file

@ -1,14 +1,15 @@
import type { IngredientView } from "@batch-cooking/shared"; import type { IngredientView } from "@batch-cooking/shared";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
// Reuses `IngredientAutocomplete` verbatim (built for the recipe form's // Reuses `IngredientPicker` verbatim (built for the recipe form's
// ingredient picker, features/recipes/) rather than a second search field — // ingredient picker, features/recipes/) rather than a second search+browse
// same reference ingredient list, same "type to filter" behavior, just // field — same reference ingredient list, same category/search browsing,
// without the recipe form's quantity/unit per line. Its own recipes.scss // just without the recipe form's quantity/unit per line. Its own
// import already covers `.ingredient-autocomplete`; this file's explicit // recipes.scss import already covers `.ingredient-picker`; this file's
// import below only adds `.disliked-ingredients-field__*` (see recipes.scss // explicit import below only adds `.disliked-ingredients-field__*` (see
// — colocated there since it's the same "reference ingredient picker" // recipes.scss — colocated there since it's the same "reference ingredient
// visual family, even though this field lives in the profile feature). // picker" visual family, even though this field lives in the profile
import { IngredientAutocomplete } from "../recipes/IngredientAutocomplete"; // feature).
import { IngredientPicker } from "../recipes/IngredientPicker";
import "../recipes/recipes.scss"; import "../recipes/recipes.scss";
import "./profile-forms.scss"; import "./profile-forms.scss";
@ -62,7 +63,7 @@ export function DislikedIngredientsField({
))} ))}
</ul> </ul>
)} )}
<IngredientAutocomplete ingredients={ingredients} excludeIds={value} onSelect={add} /> <IngredientPicker ingredients={ingredients} excludeIds={value} onSelect={add} />
</fieldset> </fieldset>
); );
} }

View file

@ -1,71 +0,0 @@
import type { IngredientView } from "@batch-cooking/shared";
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { AllergenBadges } from "./AllergenBadges";
import "./recipes.scss";
/** Caps how many matches are shown at once — the reference list is small, but an unbounded dropdown would still be unwieldy for a broad query like a single letter. */
const MAX_SUGGESTIONS = 8;
/**
* Text field over the static reference ingredient list (`GET
* /reference/ingredients`) receives the full list as a prop rather than
* fetching it itself, same rationale as `AllergySelect`/`DietSelect`, and
* filters it client-side as the user types: it's small, non-administrable
* reference data, no dedicated search endpoint needed. `excludeIds` (the
* ingredients already on the recipe) keeps the same one from being added
* twice.
*/
export function IngredientAutocomplete({
ingredients,
excludeIds,
onSelect,
}: {
ingredients: IngredientView[];
excludeIds: number[];
onSelect: (ingredient: IngredientView) => void;
}) {
const { t } = useTranslation();
const [query, setQuery] = useState("");
const normalizedQuery = query.trim().toLowerCase();
const suggestions =
normalizedQuery.length === 0
? []
: ingredients
.filter(
(ingredient) =>
!excludeIds.includes(ingredient.id) &&
ingredient.name.toLowerCase().includes(normalizedQuery),
)
.slice(0, MAX_SUGGESTIONS);
function handleSelect(ingredient: IngredientView) {
onSelect(ingredient);
setQuery("");
}
return (
<div className="ingredient-autocomplete">
<input
type="text"
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder={t("recipes.form.addIngredientPlaceholder")}
/>
{suggestions.length > 0 && (
<ul className="ingredient-autocomplete__suggestions">
{suggestions.map((ingredient) => (
<li key={ingredient.id}>
<button type="button" onClick={() => handleSelect(ingredient)}>
<span aria-hidden="true">{ingredient.icon}</span>
<span className="ingredient-autocomplete__name">{ingredient.name}</span>
<AllergenBadges allergens={ingredient.allergens} />
</button>
</li>
))}
</ul>
)}
</div>
);
}

View file

@ -0,0 +1,109 @@
import {
INGREDIENT_CATEGORIES,
type IngredientCategory,
type IngredientView,
} from "@batch-cooking/shared";
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { AllergenBadges } from "./AllergenBadges";
import "./recipes.scss";
/** "No category filter" — a UI-only pseudo-category, never sent to/received from the API (see {@link INGREDIENT_CATEGORIES} for the real, closed set). */
const ALL_CATEGORIES = "ALL" as const;
/**
* Browsable ingredient picker category chips + search + a card grid,
* replacing the earlier `IngredientAutocomplete` (a plain type-to-filter
* dropdown). With 400+ reference ingredients, search alone doesn't scale to
* actually *finding* one category browsing is the main fix; search still
* narrows within (or across) categories for when the name is already known.
*
* Receives `ingredients` as a prop rather than fetching them itself same
* rationale as `AllergySelect`/`DietSelect`. `excludeIds` (already-selected
* ingredients a recipe's ingredient list, or a profile's disliked list)
* keeps the same one from being added twice.
*/
export function IngredientPicker({
ingredients,
excludeIds,
onSelect,
}: {
ingredients: IngredientView[];
excludeIds: number[];
onSelect: (ingredient: IngredientView) => void;
}) {
const { t } = useTranslation();
const [category, setCategory] = useState<IngredientCategory | typeof ALL_CATEGORIES>(
ALL_CATEGORIES,
);
const [query, setQuery] = useState("");
const normalizedQuery = query.trim().toLowerCase();
const visible = ingredients.filter((ingredient) => {
if (excludeIds.includes(ingredient.id)) return false;
if (category !== ALL_CATEGORIES && ingredient.category !== category) return false;
if (normalizedQuery.length > 0 && !ingredient.name.toLowerCase().includes(normalizedQuery)) {
return false;
}
return true;
});
function handleSelect(ingredient: IngredientView) {
onSelect(ingredient);
setQuery("");
}
return (
<div className="ingredient-picker">
<div className="ingredient-picker__search">
<input
type="text"
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder={t("recipes.form.searchIngredientPlaceholder")}
/>
</div>
<div className="ingredient-picker__categories">
<button
type="button"
className={`ingredient-picker__category${category === ALL_CATEGORIES ? " active" : ""}`}
onClick={() => setCategory(ALL_CATEGORIES)}
>
{t("recipes.form.allCategories")}
</button>
{INGREDIENT_CATEGORIES.map((c) => (
<button
key={c}
type="button"
className={`ingredient-picker__category${category === c ? " active" : ""}`}
onClick={() => setCategory(c)}
>
{t(`recipes.form.category.${c}`)}
</button>
))}
</div>
{visible.length === 0 ? (
<p className="ingredient-picker__empty">{t("recipes.form.noIngredientFound")}</p>
) : (
<div className="ingredient-picker__grid">
{visible.map((ingredient) => (
<button
key={ingredient.id}
type="button"
className="ingredient-picker__card"
onClick={() => handleSelect(ingredient)}
>
<span className="ingredient-picker__card-icon" aria-hidden="true">
{ingredient.icon}
</span>
<span className="ingredient-picker__card-name">{ingredient.name}</span>
<AllergenBadges allergens={ingredient.allergens} />
</button>
))}
</div>
)}
</div>
);
}

View file

@ -3,7 +3,7 @@ import { useTranslation } from "react-i18next";
import { AllergenBadges } from "./AllergenBadges"; import { AllergenBadges } from "./AllergenBadges";
import "./recipes.scss"; import "./recipes.scss";
/** One selected ingredient line in the recipe form — the ingredient itself (picked via `IngredientAutocomplete`) plus its quantity/unit for this recipe. Quantity/unit are kept as raw strings while editing (not parsed to a number until submit) so an in-progress/invalid value doesn't fight the input. */ /** One selected ingredient line in the recipe form — the ingredient itself (picked via `IngredientPicker`) plus its quantity/unit for this recipe. Quantity/unit are kept as raw strings while editing (not parsed to a number until submit) so an in-progress/invalid value doesn't fight the input. */
export function IngredientRow({ export function IngredientRow({
ingredient, ingredient,
quantity, quantity,

View file

@ -140,11 +140,17 @@
flex-shrink: 0; flex-shrink: 0;
display: flex; display: flex;
gap: var(--space-xs); gap: var(--space-xs);
// Never lets a tab overflow the page (which would force the whole body
// to scroll horizontally, see global.scss's rule against that) — scrolls
// within itself instead once the tabs (including the disabled "Sources"
// placeholder) don't all fit, same pattern as the sidebar's own nav.
overflow-x: auto;
border-bottom: 1px solid var(--color-border); border-bottom: 1px solid var(--color-border);
margin-bottom: var(--space-md); margin-bottom: var(--space-md);
&__tab { &__tab {
appearance: none; appearance: none;
flex-shrink: 0;
display: flex; display: flex;
align-items: center; align-items: center;
gap: 0.4rem; gap: 0.4rem;
@ -661,62 +667,109 @@
} }
} }
// --- Ingredient autocomplete (recipe form + disliked-ingredients field) ----- // --- Ingredient picker (recipe form + disliked-ingredients field) ----------
.ingredient-autocomplete { // Always-visible category chips + search + card grid replaces the earlier
position: relative; // type-to-filter dropdown (`.ingredient-autocomplete`, now gone), which
// stopped scaling once the reference list passed a couple hundred items.
// The category row uses the same "scroll, don't crush" pattern as the app
// sidebar nav (see AppLayout.scss's mobile breakpoint): chips never shrink
// below a tappable size, the row scrolls horizontally instead.
.ingredient-picker {
display: flex;
flex-direction: column;
gap: var(--space-sm);
padding: var(--space-sm);
background: var(--color-surface);
border: 1px solid var(--color-border);
border-radius: var(--radius-base);
> input { &__search {
width: 100%; input {
max-width: 24rem;
padding: var(--space-sm);
font-family: var(--font-body);
font-size: var(--font-size-base);
border: 1px solid var(--color-border);
border-radius: var(--radius-base);
background: var(--color-surface);
color: var(--color-text);
}
&__suggestions {
position: absolute;
z-index: 1;
top: 100%;
left: 0;
width: 100%;
max-width: 24rem;
max-height: 16rem;
overflow-y: auto;
margin: var(--space-xs) 0 0;
padding: var(--space-xs);
list-style: none;
background: var(--color-surface);
border: 1px solid var(--color-border);
border-radius: var(--radius-base);
box-shadow: var(--shadow-md);
button {
display: flex;
align-items: center;
gap: var(--space-sm);
width: 100%; width: 100%;
padding: var(--space-xs) var(--space-sm); padding: var(--space-sm);
font-family: var(--font-body); font-family: var(--font-body);
font-size: var(--font-size-sm); font-size: var(--font-size-base);
text-align: left; border: 1px solid var(--color-border);
border: none;
border-radius: var(--radius-base); border-radius: var(--radius-base);
background: none; background: var(--color-surface-alt);
color: var(--color-text); color: var(--color-text);
cursor: pointer;
&:hover {
background: var(--color-surface-alt);
}
} }
} }
&__name { &__categories {
margin-right: auto; display: flex;
gap: var(--space-xs);
overflow-x: auto;
padding-bottom: var(--space-xs);
}
&__category {
flex-shrink: 0;
appearance: none;
padding: var(--space-xs) var(--space-sm);
font-family: var(--font-body);
font-size: var(--font-size-sm);
white-space: nowrap;
cursor: pointer;
border: 1px solid var(--color-border);
border-radius: var(--radius-pill);
background: var(--color-surface-alt);
color: var(--color-text-muted);
&:hover {
border-color: var(--color-primary);
color: var(--color-text);
}
&.active {
border-color: var(--color-primary);
background: var(--color-primary);
color: #fff;
}
}
&__grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(7rem, 1fr));
gap: var(--space-xs);
max-height: 18rem;
overflow-y: auto;
padding: 2px;
}
&__card {
display: flex;
flex-direction: column;
align-items: center;
gap: var(--space-xs);
padding: var(--space-sm);
text-align: center;
cursor: pointer;
border: 1px solid var(--color-border);
border-radius: var(--radius-base);
background: var(--color-surface-alt);
color: var(--color-text);
&:hover {
border-color: var(--color-primary);
background: var(--color-surface);
}
}
&__card-icon {
font-size: 1.5rem;
line-height: 1;
}
&__card-name {
font-size: var(--font-size-sm);
}
&__empty {
padding: var(--space-md);
font-size: var(--font-size-sm);
color: var(--color-text-muted);
text-align: center;
} }
} }

View file

@ -305,6 +305,17 @@
// viewport (relevant early given this app is meant to be embedded via // viewport (relevant early given this app is meant to be embedded via
// Capacitor later, see the root README) collapse it into a horizontal // Capacitor later, see the root README) collapse it into a horizontal
// top bar instead of a side rail. // top bar instead of a side rail.
//
// The primary nav (Planning/Recettes/Liste de courses) must stay fully
// legible and tappable at any width it's the app's main navigation, not
// optional chrome. Without `&__nav { min-width: 0 }` + `a { flex-shrink: 0
// }` below, `&__settings`/`&__footer`'s own natural (non-shrinking) width
// silently crushed it down to ~16px unlabeled slivers on a narrow phone
// (measured on a 375px viewport) invisible labels, no real tap target.
// The fix: `__settings`/`__footer` collapse to icon-only instead (same
// look as the desktop rail's `.collapsed` state), freeing width for the
// nav, which falls back to horizontal scroll (`overflow-x: auto`) rather
// than shrinking if it still doesn't fit.
@media (max-width: 640px) { @media (max-width: 640px) {
.app-layout { .app-layout {
flex-direction: column; flex-direction: column;
@ -326,15 +337,75 @@
padding: 0; padding: 0;
} }
// Nothing to collapse into on a horizontal bar there's no rail.
&__collapse-toggle {
display: none;
}
&__nav { &__nav {
flex: 1 1 auto;
min-width: 0;
flex-direction: row; flex-direction: row;
overflow-x: auto; overflow-x: auto;
a {
flex-shrink: 0;
}
} }
&__settings, &__settings,
&__footer { &__footer {
position: relative;
flex: none;
padding-top: 0; padding-top: 0;
border-top: none; border-top: none;
} }
&__settings-toggle,
&__account-toggle {
padding-left: var(--space-xs);
padding-right: var(--space-xs);
}
&__settings-toggle-left .label,
&__account-toggle .label,
&__settings-toggle .chevron {
display: none;
}
// Both reveals become floating panels anchored under their icon-only
// toggle on the desktop rail one is inline (settings, stacks fine in
// a column) and the other already floats (account); neither can stay
// in normal flow on this horizontal bar without breaking the row.
// Selector order matters: `&__settings-nav` also carries the plain
// `&__nav` class (for the link styling), so this must come after it to
// win on `flex-direction`/`overflow-x`.
&__settings-nav {
position: absolute;
top: calc(100% + var(--space-xs));
right: 0;
flex-direction: column;
width: 14rem;
padding: var(--space-xs);
overflow-x: visible;
background: var(--color-surface);
border: 1px solid var(--color-border);
border-radius: var(--radius-base);
box-shadow: var(--shadow-md);
z-index: 10;
}
// Desktop opens this *upward* (`bottom: calc(100% + ...)`, see the base
// rule above) because the footer sits at the bottom of a tall rail
// on this horizontal top bar the footer is near `y: 0`, so "upward"
// pushed the menu entirely off-screen above the viewport. Flip it to
// open downward here instead.
&__account-menu {
top: calc(100% + var(--space-xs));
bottom: auto;
left: auto;
right: 0;
width: 12rem;
}
} }
} }

View file

@ -165,7 +165,29 @@
"PUBLIC": "Tout le monde" "PUBLIC": "Tout le monde"
}, },
"dietsLabel": "Régime(s) associé(s)", "dietsLabel": "Régime(s) associé(s)",
"addIngredientPlaceholder": "Ajouter un ingrédient…", "searchIngredientPlaceholder": "Rechercher un ingrédient…",
"allCategories": "Tout",
"noIngredientFound": "Aucun ingrédient trouvé.",
"category": {
"CEREALES_FECULENTS": "Céréales & féculents",
"LEGUMINEUSES": "Légumineuses",
"VIANDES_VOLAILLES": "Viandes & volailles",
"POISSONS_FRUITS_DE_MER": "Poissons & fruits de mer",
"PRODUITS_LAITIERS_OEUFS": "Produits laitiers & œufs",
"LEGUMES": "Légumes",
"FRUITS": "Fruits",
"FRUITS_SECS_OLEAGINEUX": "Fruits secs & oléagineux",
"CONDIMENTS_SAUCES": "Condiments & sauces",
"EPICES_HERBES": "Épices & herbes",
"SUCRE_PATISSERIE": "Sucre & pâtisserie",
"CUISINE_ITALIENNE": "Cuisine italienne",
"CUISINE_ASIATIQUE": "Cuisine asiatique",
"CUISINE_MEXICAINE": "Cuisine mexicaine",
"MAGHREB_MOYEN_ORIENT": "Maghreb & Moyen-Orient",
"PAINS_SANDWICHS": "Pains & sandwichs",
"EPICERIE_DIVERS": "Épicerie & divers",
"LIQUIDES_BOISSONS": "Liquides & boissons"
},
"quantityLabel": "Quantité", "quantityLabel": "Quantité",
"unitLabel": "Unité", "unitLabel": "Unité",
"unitPlaceholder": "g, ml, unité…", "unitPlaceholder": "g, ml, unité…",

View file

@ -11,7 +11,7 @@ import { useTranslation } from "react-i18next";
import { useNavigate, useParams } from "react-router-dom"; import { useNavigate, useParams } from "react-router-dom";
import { ApiError, apiClient } from "../api/client"; import { ApiError, apiClient } from "../api/client";
import { DietTagSelect } from "../features/recipes/DietTagSelect"; import { DietTagSelect } from "../features/recipes/DietTagSelect";
import { IngredientAutocomplete } from "../features/recipes/IngredientAutocomplete"; import { IngredientPicker } from "../features/recipes/IngredientPicker";
import { IngredientRow } from "../features/recipes/IngredientRow"; import { IngredientRow } from "../features/recipes/IngredientRow";
import { type StepDraft, StepListEditor } from "../features/recipes/StepListEditor"; import { type StepDraft, StepListEditor } from "../features/recipes/StepListEditor";
import "../features/recipes/recipes.scss"; import "../features/recipes/recipes.scss";
@ -251,7 +251,7 @@ export function RecipeFormPage() {
/> />
))} ))}
</ul> </ul>
<IngredientAutocomplete <IngredientPicker
ingredients={ingredientsCatalog} ingredients={ingredientsCatalog}
excludeIds={selectedIds} excludeIds={selectedIds}
onSelect={addIngredient} onSelect={addIngredient}

View file

@ -32,6 +32,37 @@ export interface AllergyView {
kind: AllergenKind; kind: AllergenKind;
} }
/**
* Coarse ingredient grouping (viandes, légumes, épices...) mirrors
* `IngredientCategory` in schema.prisma, declared by hand for the same
* reason as {@link AllergenKind}. Lets the ingredient picker (`apps/web`'s
* `IngredientPicker`) offer category browsing, not just free-text search:
* with 400+ reference ingredients, search alone doesn't scale to actually
* *finding* one.
*/
export const INGREDIENT_CATEGORIES = [
"CEREALES_FECULENTS",
"LEGUMINEUSES",
"VIANDES_VOLAILLES",
"POISSONS_FRUITS_DE_MER",
"PRODUITS_LAITIERS_OEUFS",
"LEGUMES",
"FRUITS",
"FRUITS_SECS_OLEAGINEUX",
"CONDIMENTS_SAUCES",
"EPICES_HERBES",
"SUCRE_PATISSERIE",
"CUISINE_ITALIENNE",
"CUISINE_ASIATIQUE",
"CUISINE_MEXICAINE",
"MAGHREB_MOYEN_ORIENT",
"PAINS_SANDWICHS",
"EPICERIE_DIVERS",
"LIQUIDES_BOISSONS",
] as const;
/** Inferred TS type for one {@link INGREDIENT_CATEGORIES} member. */
export type IngredientCategory = (typeof INGREDIENT_CATEGORIES)[number];
/** /**
* A selectable ingredient, as returned by `GET /reference/ingredients` * A selectable ingredient, as returned by `GET /reference/ingredients`
* reference data (`Ingredient`, seeded via `apps/api/src/db/ * reference data (`Ingredient`, seeded via `apps/api/src/db/
@ -49,5 +80,6 @@ export interface IngredientView {
id: number; id: number;
name: string; name: string;
icon: string | null; icon: string | null;
category: IngredientCategory;
allergens: AllergyView[]; allergens: AllergyView[];
} }