feat(recipes): refonte de la catégorisation des ingrédients façon rayons de supermarché

Remplace les 18 catégories plates (mélangeant rayons génériques et
cuisines d'origine — un ingrédient pouvait finir dans "légumes" ou
"cuisine italienne" selon l'angle choisi) par une hiérarchie à 2 niveaux
sur le modèle d'un supermarché français :

- 🥦 Produits frais (légumes, fruits, herbes fraîches)
- 🥩 Boucherie & poissonnerie (viandes, volailles, poissons,
  crustacés & fruits de mer)
- 🥫 Épicerie sèche (féculents, légumineuses, graines & fruits secs,
  autres produits secs/conserves)
- 🍞 Boulangerie (pains, pâtes à cuire — 4 nouveaux ingrédients :
  pâte feuilletée/brisée/à pizza/à tarte sablée)
- 🧈 Crémerie & fromage (produits laitiers, œufs, alternatives
  végétales)
- 🧂 Condiments & épices (épices, sauces, assaisonnements)
- 🍳 Aides culinaires (bases, épaississants, sucres)

- Schéma : nouvel enum IngredientCategory (7 valeurs) + nouvel enum
  IngredientSubcategory (22 valeurs) + colonne Ingredient.subcategory.
  Migration appliquée via reset (données de référence, aucune perte
  réelle) car les anciennes valeurs d'enum n'existent plus dans les
  nouvelles.
- reference-seed-data.ts entièrement réorganisé par (catégorie, sous-
  catégorie), tous les 437 ingrédients recatégorisés un par un.
- packages/shared : INGREDIENT_CATEGORIES (7), INGREDIENT_SUBCATEGORIES
  (22) et INGREDIENT_CATEGORY_SUBCATEGORIES (mapping catégorie →
  sous-catégories, pour piloter le picker) remplacent l'ancienne liste
  plate.
- IngredientPicker : drill-down à 2 niveaux — la ligne de sous-
  catégories (couleur --color-tag, visuellement subordonnée) apparaît
  une fois une catégorie choisie, et se réinitialise au changement de
  catégorie.
- i18n : nouvelles clés recipes.form.category.* (7) et
  recipes.form.subcategory.* (22).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Nicolas 2026-08-18 11:47:47 +02:00
parent 11b03b0e56
commit 9775cbe38f
10 changed files with 875 additions and 579 deletions

View file

@ -0,0 +1,18 @@
-- CreateEnum
CREATE TYPE "IngredientSubcategory" AS ENUM ('LEGUMES', 'FRUITS', 'HERBES_FRAICHES', 'VIANDES', 'VOLAILLES', 'POISSONS', 'CRUSTACES_FRUITS_DE_MER', 'FECULENTS', 'LEGUMINEUSES', 'GRAINES_FRUITS_SECS', 'AUTRES', 'PAINS', 'PATES_A_CUIRE', 'PRODUITS_LAITIERS', 'OEUFS', 'ALTERNATIVES', 'EPICES', 'SAUCES', 'ASSAISONNEMENTS', 'BASES', 'EPAISSISSANTS', 'SUCRES');
-- AlterEnum
BEGIN;
CREATE TYPE "IngredientCategory_new" AS ENUM ('PRODUITS_FRAIS', 'BOUCHERIE_POISSONNERIE', 'EPICERIE_SECHE', 'BOULANGERIE', 'CREMERIE_FROMAGE', 'CONDIMENTS_EPICES', 'AIDES_CULINAIRES');
ALTER TABLE "ingredients" ALTER COLUMN "category" DROP DEFAULT;
ALTER TABLE "ingredients" ALTER COLUMN "category" TYPE "IngredientCategory_new" USING ("category"::text::"IngredientCategory_new");
ALTER TYPE "IngredientCategory" RENAME TO "IngredientCategory_old";
ALTER TYPE "IngredientCategory_new" RENAME TO "IngredientCategory";
DROP TYPE "IngredientCategory_old";
ALTER TABLE "ingredients" ALTER COLUMN "category" SET DEFAULT 'EPICERIE_SECHE';
COMMIT;
-- AlterTable
ALTER TABLE "ingredients" ADD COLUMN "subcategory" "IngredientSubcategory" NOT NULL DEFAULT 'AUTRES',
ALTER COLUMN "category" SET DEFAULT 'EPICERIE_SECHE';

View file

@ -293,44 +293,104 @@ model RecipeDiet {
/// idempotent/safe to re-run, same reason as `Diet.name`/`Category.name`.
/// Ingredients are reference data (like Diet/Allergy): seeded, never
/// 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
/// Not in the original spec doc — supermarket-aisle grouping ("rayons") 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. Reworked from an earlier, less
/// intuitive scheme (cuisine-of-origin categories mixed in with aisle-style
/// ones, e.g. a "cuisine italienne" bucket sitting next to "légumes" —
/// meant an ingredient's category depended on which one you thought of
/// first) into how a French grocery store is actually laid out: 7 aisles,
/// each with a couple of {@link IngredientSubcategory} racks for finer
/// browsing once "Épicerie sèche" alone would be 100+ items deep. 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.
/// (category, subcategory) pair, these enums just give it type-safe
/// columns to live in. `@default(EPICERIE_SECHE)` 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
/// 🥦 Légumes, fruits, herbes fraîches.
PRODUITS_FRAIS
/// 🥩 Viandes, volailles, poissons, crustacés & fruits de mer.
BOUCHERIE_POISSONNERIE
/// 🥫 Féculents, légumineuses, graines & fruits secs, et le reste des
/// produits secs/en conserve qui ne rentre dans aucune autre case
/// (algues séchées, champignons séchés…).
EPICERIE_SECHE
/// 🍞 Pains et pâtes à cuire (crues, à enfourner).
BOULANGERIE
/// 🧈 Produits laitiers, œufs, alternatives végétales (laits végétaux,
/// tofu…).
CREMERIE_FROMAGE
/// 🧂 Épices, sauces, assaisonnements (huiles, vinaigres, alcools de
/// cuisine…).
CONDIMENTS_EPICES
/// 🍳 Bases de préparation (farines, bouillons, eau), épaississants
/// (levures, fécules, gélatine), sucres.
AIDES_CULINAIRES
}
/// Finer-grained rack within one {@link IngredientCategory} aisle — see
/// that enum's doc comment for why this two-level scheme replaced a flat
/// list. Each value belongs to exactly one category by construction (see
/// `reference-seed-data.ts`'s `INGREDIENT_GROUPS`, not enforced at the
/// database level — Postgres enums can't express that relationship, same
/// tradeoff already accepted for `IngredientCategory` itself).
/// `@default(AUTRES)` — same NOT-NULL-migration-safety-net reasoning as
/// `IngredientCategory`'s default, never the intended value for a real row.
enum IngredientSubcategory {
// --- Produits frais ------------------------------------------------------
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
HERBES_FRAICHES
// --- Boucherie & poissonnerie ---------------------------------------------
VIANDES
VOLAILLES
POISSONS
CRUSTACES_FRUITS_DE_MER
// --- Épicerie sèche --------------------------------------------------------
FECULENTS
LEGUMINEUSES
GRAINES_FRUITS_SECS
/// Catch-all for dried/tinned pantry items that don't fit the three
/// subcategories above — dried seaweed, dried mushrooms, tinned bamboo
/// shoots/water chestnuts…
AUTRES
// --- Boulangerie -------------------------------------------------------
PAINS
/// Raw, uncooked doughs meant to be baked (puff pastry, shortcrust…) —
/// distinct from `PAINS` (already-baked bread).
PATES_A_CUIRE
// --- Crémerie & fromage --------------------------------------------------
PRODUITS_LAITIERS
OEUFS
/// Plant-based dairy/meat substitutes — coconut/almond/oat "milk", tofu.
ALTERNATIVES
// --- Condiments & épices -------------------------------------------------
EPICES
SAUCES
/// Oils, vinegars, citrus juices, cooking alcohols/wines — liquids that
/// season rather than form the base of a dish.
ASSAISONNEMENTS
// --- Aides culinaires ----------------------------------------------------
/// Flours, stocks/broths, canned tomato bases, water — the literal base
/// a recipe is built on.
BASES
/// Leavening/gelling/thickening agents — yeast, baking soda, cornstarch,
/// gelatin.
EPAISSISSANTS
SUCRES
}
model Ingredient {
id Int @id @default(autoincrement())
name String @unique
id Int @id @default(autoincrement())
name String @unique
icon String?
category IngredientCategory @default(EPICERIE_DIVERS)
alternateRecipeId Int? @map("alternate_recipe")
category IngredientCategory @default(EPICERIE_SECHE)
subcategory IngredientSubcategory @default(AUTRES)
alternateRecipeId Int? @map("alternate_recipe")
alternateRecipe Recipe? @relation("IngredientAlternateRecipe", fields: [alternateRecipeId], references: [id], onDelete: SetNull)
recipes RecipeIngredient[]

File diff suppressed because it is too large Load diff

View file

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

View file

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

View file

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

View file

@ -1,6 +1,8 @@
import {
INGREDIENT_CATEGORIES,
INGREDIENT_CATEGORY_SUBCATEGORIES,
type IngredientCategory,
type IngredientSubcategory,
type IngredientView,
} from "@batch-cooking/shared";
import { useState } from "react";
@ -10,15 +12,19 @@ import { AllergenBadges } from "./AllergenBadges";
import { DietBadges } from "./DietBadges";
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;
/** "No filter at this level" — a UI-only pseudo-value, never sent to/received from the API (see {@link INGREDIENT_CATEGORIES}/{@link INGREDIENT_CATEGORY_SUBCATEGORIES} for the real, closed sets). */
const ALL = "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.
* Browsable ingredient picker a two-level category/subcategory drill-down
* plus search plus 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, and a single flat list of
* 7 aisles wouldn't either (some "Condiments & épices" are 100+ items
* deep) so picking a category reveals a second row of subcategory chips
* scoped to it (reset whenever the category changes, since a subcategory
* from the previous one wouldn't apply). Search still narrows within (or
* across) whatever's selected 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
@ -40,9 +46,8 @@ export function IngredientPicker({
onSelect: (ingredient: IngredientView) => void;
}) {
const { t } = useTranslation();
const [category, setCategory] = useState<IngredientCategory | typeof ALL_CATEGORIES>(
ALL_CATEGORIES,
);
const [category, setCategory] = useState<IngredientCategory | typeof ALL>(ALL);
const [subcategory, setSubcategory] = useState<IngredientSubcategory | typeof ALL>(ALL);
const [query, setQuery] = useState("");
// Purely a display preference for this picker's own card grid — doesn't
// touch which ingredients `visible` includes, only whether their
@ -52,10 +57,18 @@ export function IngredientPicker({
const [showAllergens, setShowAllergens] = useState(true);
const [showDiets, setShowDiets] = useState(true);
function selectCategory(next: IngredientCategory | typeof ALL) {
setCategory(next);
setSubcategory(ALL);
}
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 (category !== ALL) {
if (ingredient.category !== category) return false;
if (subcategory !== ALL && ingredient.subcategory !== subcategory) return false;
}
if (normalizedQuery.length > 0 && !ingredient.name.toLowerCase().includes(normalizedQuery)) {
return false;
}
@ -101,8 +114,8 @@ export function IngredientPicker({
<div className="ingredient-picker__categories">
<button
type="button"
className={`ingredient-picker__category${category === ALL_CATEGORIES ? " active" : ""}`}
onClick={() => setCategory(ALL_CATEGORIES)}
className={`ingredient-picker__category${category === ALL ? " active" : ""}`}
onClick={() => selectCategory(ALL)}
>
{t("recipes.form.allCategories")}
</button>
@ -111,13 +124,35 @@ export function IngredientPicker({
key={c}
type="button"
className={`ingredient-picker__category${category === c ? " active" : ""}`}
onClick={() => setCategory(c)}
onClick={() => selectCategory(c)}
>
{t(`recipes.form.category.${c}`)}
</button>
))}
</div>
{category !== ALL && (
<div className="ingredient-picker__subcategories">
<button
type="button"
className={`ingredient-picker__subcategory${subcategory === ALL ? " active" : ""}`}
onClick={() => setSubcategory(ALL)}
>
{t("recipes.form.allSubcategories")}
</button>
{INGREDIENT_CATEGORY_SUBCATEGORIES[category].map((s) => (
<button
key={s}
type="button"
className={`ingredient-picker__subcategory${subcategory === s ? " active" : ""}`}
onClick={() => setSubcategory(s)}
>
{t(`recipes.form.subcategory.${s}`)}
</button>
))}
</div>
)}
{visible.length === 0 ? (
<p className="ingredient-picker__empty">{t("recipes.form.noIngredientFound")}</p>
) : (

View file

@ -775,6 +775,43 @@
}
}
// Second-tier drill-down, shown once a main category is picked same
// "scroll, don't crush" row as `&__categories` above, but visually
// subordinate: smaller, and using `--color-tag` (not `--color-primary`)
// for its active state so the two levels stay legible at a glance.
&__subcategories {
display: flex;
gap: var(--space-xs);
overflow-x: auto;
padding-bottom: var(--space-xs);
margin-top: calc(var(--space-xs) * -1);
}
&__subcategory {
flex-shrink: 0;
appearance: none;
padding: 2px var(--space-sm);
font-family: var(--font-body);
font-size: var(--font-size-xs);
white-space: nowrap;
cursor: pointer;
border: 1px solid var(--color-border);
border-radius: var(--radius-pill);
background: transparent;
color: var(--color-text-muted);
&:hover {
border-color: var(--color-tag);
color: var(--color-text);
}
&.active {
border-color: var(--color-tag);
background: var(--color-tag);
color: var(--color-tag-ink);
}
}
&__grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(7rem, 1fr));

View file

@ -169,26 +169,40 @@
"toggleDiets": "Afficher/masquer les régimes alimentaires",
"toggleAllergens": "Afficher/masquer les allergènes",
"allCategories": "Tout",
"allSubcategories": "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",
"PRODUITS_FRAIS": "🥦 Produits frais",
"BOUCHERIE_POISSONNERIE": "🥩 Boucherie & poissonnerie",
"EPICERIE_SECHE": "🥫 Épicerie sèche",
"BOULANGERIE": "🍞 Boulangerie",
"CREMERIE_FROMAGE": "🧈 Crémerie & fromage",
"CONDIMENTS_EPICES": "🧂 Condiments & épices",
"AIDES_CULINAIRES": "🍳 Aides culinaires"
},
"subcategory": {
"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"
"HERBES_FRAICHES": "Herbes fraîches",
"VIANDES": "Viandes",
"VOLAILLES": "Volailles",
"POISSONS": "Poissons",
"CRUSTACES_FRUITS_DE_MER": "Crustacés & fruits de mer",
"FECULENTS": "Féculents",
"LEGUMINEUSES": "Légumineuses",
"GRAINES_FRUITS_SECS": "Graines & fruits secs",
"AUTRES": "Autres",
"PAINS": "Pains",
"PATES_A_CUIRE": "Pâtes à cuire",
"PRODUITS_LAITIERS": "Produits laitiers",
"OEUFS": "Œufs",
"ALTERNATIVES": "Alternatives végétales",
"EPICES": "Épices",
"SAUCES": "Sauces",
"ASSAISONNEMENTS": "Assaisonnements",
"BASES": "Bases",
"EPAISSISSANTS": "Épaississants",
"SUCRES": "Sucres"
},
"quantityLabel": "Quantité",
"unitLabel": "Unité",

View file

@ -33,36 +33,82 @@ export interface AllergyView {
}
/**
* 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
* Supermarket-aisle grouping ("rayons") 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.
* *finding* one. See {@link INGREDIENT_SUBCATEGORIES} for the finer-grained
* rack within each aisle.
*/
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",
"PRODUITS_FRAIS",
"BOUCHERIE_POISSONNERIE",
"EPICERIE_SECHE",
"BOULANGERIE",
"CREMERIE_FROMAGE",
"CONDIMENTS_EPICES",
"AIDES_CULINAIRES",
] as const;
/** Inferred TS type for one {@link INGREDIENT_CATEGORIES} member. */
export type IngredientCategory = (typeof INGREDIENT_CATEGORIES)[number];
/**
* Finer-grained rack within one {@link IngredientCategory} aisle mirrors
* `IngredientSubcategory` in schema.prisma. See
* {@link INGREDIENT_CATEGORY_SUBCATEGORIES} for which of these belongs to
* which category, and in what display order.
*/
export const INGREDIENT_SUBCATEGORIES = [
"LEGUMES",
"FRUITS",
"HERBES_FRAICHES",
"VIANDES",
"VOLAILLES",
"POISSONS",
"CRUSTACES_FRUITS_DE_MER",
"FECULENTS",
"LEGUMINEUSES",
"GRAINES_FRUITS_SECS",
"AUTRES",
"PAINS",
"PATES_A_CUIRE",
"PRODUITS_LAITIERS",
"OEUFS",
"ALTERNATIVES",
"EPICES",
"SAUCES",
"ASSAISONNEMENTS",
"BASES",
"EPAISSISSANTS",
"SUCRES",
] as const;
/** Inferred TS type for one {@link INGREDIENT_SUBCATEGORIES} member. */
export type IngredientSubcategory = (typeof INGREDIENT_SUBCATEGORIES)[number];
/**
* Which {@link INGREDIENT_SUBCATEGORIES} belong to which
* {@link INGREDIENT_CATEGORIES} aisle, in the order the picker should list
* them the two-level drill-down `IngredientPicker` (`apps/web`) needs
* this to know which subcategory chips to offer once a category is picked.
* Mirrors `apps/api/src/db/reference-seed-data.ts`'s `INGREDIENT_GROUPS`,
* which is the actual source of truth for which *ingredient* belongs to
* which pair this is just the categorysubcategory shape of that data,
* duplicated here since the seed file isn't reachable from `apps/web`.
*/
export const INGREDIENT_CATEGORY_SUBCATEGORIES: Record<
IngredientCategory,
readonly IngredientSubcategory[]
> = {
PRODUITS_FRAIS: ["LEGUMES", "FRUITS", "HERBES_FRAICHES"],
BOUCHERIE_POISSONNERIE: ["VIANDES", "VOLAILLES", "POISSONS", "CRUSTACES_FRUITS_DE_MER"],
EPICERIE_SECHE: ["FECULENTS", "LEGUMINEUSES", "GRAINES_FRUITS_SECS", "AUTRES"],
BOULANGERIE: ["PAINS", "PATES_A_CUIRE"],
CREMERIE_FROMAGE: ["PRODUITS_LAITIERS", "OEUFS", "ALTERNATIVES"],
CONDIMENTS_EPICES: ["EPICES", "SAUCES", "ASSAISONNEMENTS"],
AIDES_CULINAIRES: ["BASES", "EPAISSISSANTS", "SUCRES"],
};
/**
* A selectable ingredient, as returned by `GET /reference/ingredients`
* reference data (`Ingredient`, seeded via `apps/api/src/db/
@ -87,6 +133,7 @@ export interface IngredientView {
name: string;
icon: string | null;
category: IngredientCategory;
subcategory: IngredientSubcategory;
allergens: AllergyView[];
diets: DietView[];
}