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,10 +292,43 @@ 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
/// `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 {
id Int @id @default(autoincrement())
name String @unique
icon String?
category IngredientCategory @default(EPICERIE_DIVERS)
alternateRecipeId Int? @map("alternate_recipe")
alternateRecipe Recipe? @relation("IngredientAlternateRecipe", fields: [alternateRecipeId], references: [id], onDelete: SetNull)

View file

@ -1,4 +1,4 @@
import type { AllergenKind, PrismaClient } from "@prisma/client";
import type { AllergenKind, IngredientCategory, PrismaClient } from "@prisma/client";
// Short, optional-to-pick regime list — `UserProfile.dietId` stays
// nullable, this is not meant to be exhaustive.
@ -28,6 +28,12 @@ const ALLERGENS: Array<{ name: string; kind: AllergenKind }> = [
{ name: "Mollusques", kind: "ALLERGY" },
];
interface IngredientSeed {
name: string;
icon?: string;
allergenNames: string[];
}
// A broad pantry list — the goal is to cover the large majority of what a
// home cook reaches for (viandes, poissons, légumes, fruits, féculents,
// condiments, épices...), not just enough to exercise the recipe catalog in
@ -39,8 +45,17 @@ const ALLERGENS: Array<{ name: string; kind: AllergenKind }> = [
// covered by at least one ingredient here. `icon` is left unset (rather than
// forcing a misleading emoji) for the handful of items with no good match in
// the standard emoji set (e.g. `Radis`, `Asperge`).
const INGREDIENTS: Array<{ name: string; icon?: string; allergenNames: string[] }> = [
// --- Céréales, farines & féculents ---------------------------------------
//
// Grouped by `category` (mirrors `IngredientCategory` in schema.prisma) so
// the picker UI (`apps/web`'s `IngredientPicker`) can offer category
// chips/browsing, not just free-text search — with 400+ ingredients, search
// alone isn't enough to actually *find* something. Each group's key is the
// single source of truth for that mapping; `INGREDIENTS` below just flattens
// it back to one array for the seeding loop.
const INGREDIENT_GROUPS: Array<{ category: IngredientCategory; items: IngredientSeed[] }> = [
{
category: "CEREALES_FECULENTS",
items: [
{ name: "Farine de blé", icon: "🌾", allergenNames: ["Gluten"] },
{ name: "Farine complète", icon: "🌾", allergenNames: ["Gluten"] },
{ name: "Farine de maïs", icon: "🌽", allergenNames: [] },
@ -68,8 +83,11 @@ const INGREDIENTS: Array<{ name: string; icon?: string; allergenNames: string[]
{ name: "Levure boulangère", icon: "🫙", allergenNames: [] },
{ name: "Levure chimique", icon: "🫙", allergenNames: [] },
{ name: "Maïzena", icon: "🫙", allergenNames: [] },
// --- Légumineuses ---------------------------------------------------------
],
},
{
category: "LEGUMINEUSES",
items: [
{ name: "Lentilles vertes", icon: "🫘", allergenNames: [] },
{ name: "Lentilles corail", icon: "🫘", allergenNames: [] },
{ name: "Pois chiches", icon: "🫘", allergenNames: [] },
@ -79,8 +97,11 @@ const INGREDIENTS: Array<{ name: string; icon?: string; allergenNames: string[]
{ name: "Pois cassés", icon: "🫘", allergenNames: [] },
{ name: "Fèves", icon: "🫘", allergenNames: [] },
{ name: "Edamame", icon: "🫛", allergenNames: ["Soja"] },
// --- Viandes & volailles ---------------------------------------------------
],
},
{
category: "VIANDES_VOLAILLES",
items: [
{ name: "Poulet", icon: "🍗", allergenNames: [] },
{ name: "Dinde", icon: "🍗", allergenNames: [] },
{ name: "Canard", icon: "🦆", allergenNames: [] },
@ -101,8 +122,11 @@ const INGREDIENTS: Array<{ name: string; icon?: string; allergenNames: string[]
{ name: "Saucisse", icon: "🌭", allergenNames: [] },
{ name: "Chorizo", icon: "🌭", allergenNames: [] },
{ name: "Merguez", icon: "🌭", allergenNames: [] },
// --- Poissons & fruits de mer -----------------------------------------------
],
},
{
category: "POISSONS_FRUITS_DE_MER",
items: [
{ name: "Saumon", icon: "🐟", allergenNames: ["Poissons"] },
{ name: "Thon", icon: "🐟", allergenNames: ["Poissons"] },
{ name: "Cabillaud", icon: "🐟", allergenNames: ["Poissons"] },
@ -144,8 +168,11 @@ const INGREDIENTS: Array<{ name: string; icon?: string; allergenNames: string[]
{ name: "Poulpe", icon: "🐙", allergenNames: ["Mollusques"] },
{ name: "Palourdes", icon: "🦪", allergenNames: ["Mollusques"] },
{ name: "Bulots", icon: "🐚", allergenNames: ["Mollusques"] },
// --- Produits laitiers & œufs -----------------------------------------------
],
},
{
category: "PRODUITS_LAITIERS_OEUFS",
items: [
{ name: "Œuf", icon: "🥚", allergenNames: ["Œufs"] },
{ name: "Lait", icon: "🥛", allergenNames: ["Lait"] },
{ name: "Beurre", icon: "🧈", allergenNames: ["Lait"] },
@ -166,8 +193,11 @@ const INGREDIENTS: Array<{ name: string; icon?: string; allergenNames: string[]
{ name: "Crème de coco", icon: "🥥", allergenNames: [] },
{ name: "Lait d'amande", icon: "🥛", allergenNames: ["Fruits à coque"] },
{ name: "Lait d'avoine", icon: "🥛", allergenNames: ["Gluten"] },
// --- Légumes -----------------------------------------------------------------
],
},
{
category: "LEGUMES",
items: [
{ name: "Tomate", icon: "🍅", allergenNames: [] },
{ name: "Oignon", icon: "🧅", allergenNames: [] },
{ name: "Échalote", icon: "🧅", allergenNames: [] },
@ -206,8 +236,11 @@ const INGREDIENTS: Array<{ name: string; icon?: string; allergenNames: string[]
{ name: "Butternut", icon: "🎃", allergenNames: [] },
{ name: "Asperge", allergenNames: [] },
{ name: "Avocat", icon: "🥑", allergenNames: [] },
// --- Fruits ------------------------------------------------------------------
],
},
{
category: "FRUITS",
items: [
{ name: "Citron", icon: "🍋", allergenNames: [] },
{ name: "Citron vert", icon: "🍋", allergenNames: [] },
{ name: "Pomme", icon: "🍎", allergenNames: [] },
@ -236,8 +269,11 @@ const INGREDIENTS: Array<{ name: string; icon?: string; allergenNames: string[]
{ name: "Grenade", allergenNames: [] },
{ name: "Rhubarbe", allergenNames: [] },
{ name: "Coing", allergenNames: [] },
// --- Fruits secs & oléagineux ------------------------------------------------
],
},
{
category: "FRUITS_SECS_OLEAGINEUX",
items: [
{ name: "Cacahuètes", icon: "🥜", allergenNames: ["Arachides"] },
{ name: "Amandes", icon: "🌰", allergenNames: ["Fruits à coque"] },
{ name: "Noix", icon: "🌰", allergenNames: ["Fruits à coque"] },
@ -253,8 +289,11 @@ const INGREDIENTS: Array<{ name: string; icon?: string; allergenNames: string[]
{ name: "Raisins secs", icon: "🍇", allergenNames: ["Sulfites"] },
{ name: "Pruneaux", allergenNames: ["Sulfites"] },
{ name: "Abricots secs", icon: "🍑", allergenNames: ["Sulfites"] },
// --- Condiments, sauces & huiles ----------------------------------------------
],
},
{
category: "CONDIMENTS_SAUCES",
items: [
{ name: "Sel", icon: "🧂", allergenNames: [] },
{ name: "Sucre", icon: "🍬", allergenNames: [] },
{ name: "Huile d'olive", icon: "🫒", allergenNames: [] },
@ -307,7 +346,11 @@ const INGREDIENTS: Array<{ name: string; icon?: string; allergenNames: string[]
{ name: "Sauce teriyaki", icon: "🫙", allergenNames: ["Soja"] },
{ name: "Sauce ponzu", icon: "🫙", allergenNames: ["Soja", "Poissons"] },
{ name: "Chimichurri", icon: "🌿", allergenNames: [] },
{ name: "Pesto rouge (tomates séchées)", icon: "🫙", allergenNames: ["Lait", "Fruits à coque"] },
{
name: "Pesto rouge (tomates séchées)",
icon: "🫙",
allergenNames: ["Lait", "Fruits à coque"],
},
{ name: "Tomates séchées", icon: "🍅", allergenNames: [] },
{ name: "Fond de veau", icon: "🫙", allergenNames: [] },
{ name: "Fond de volaille", icon: "🫙", allergenNames: [] },
@ -319,8 +362,11 @@ const INGREDIENTS: Array<{ name: string; icon?: string; allergenNames: string[]
{ name: "Court-bouillon", icon: "🫙", allergenNames: [] },
{ name: "Dashi (bouillon japonais)", icon: "🫙", allergenNames: ["Poissons"] },
{ name: "Bisque de crustacés", icon: "🫙", allergenNames: ["Crustacés"] },
// --- Épices & herbes -----------------------------------------------------------
],
},
{
category: "EPICES_HERBES",
items: [
{ name: "Basilic", icon: "🌿", allergenNames: [] },
{ name: "Persil", icon: "🌿", allergenNames: [] },
{ name: "Thym", icon: "🌿", allergenNames: [] },
@ -368,8 +414,11 @@ const INGREDIENTS: Array<{ name: string; icon?: string; allergenNames: string[]
{ name: "Sel aux herbes", icon: "🧂", allergenNames: [] },
{ name: "Sel de céleri", icon: "🧂", allergenNames: ["Céleri"] },
{ name: "Fleur de sel", icon: "🧂", allergenNames: [] },
// --- Sucre & pâtisserie ---------------------------------------------------------
],
},
{
category: "SUCRE_PATISSERIE",
items: [
{ name: "Sucre roux", icon: "🍬", allergenNames: [] },
{ name: "Sucre glace", icon: "🍬", allergenNames: [] },
{ name: "Cassonade", icon: "🍬", allergenNames: [] },
@ -380,8 +429,11 @@ const INGREDIENTS: Array<{ name: string; icon?: string; allergenNames: string[]
{ name: "Cacao en poudre", icon: "🍫", allergenNames: [] },
{ name: "Gélatine", icon: "🫙", allergenNames: [] },
{ name: "Extrait de vanille", icon: "🌿", allergenNames: [] },
// --- Cuisine italienne ---------------------------------------------------------
],
},
{
category: "CUISINE_ITALIENNE",
items: [
{ name: "Spaghetti", icon: "🍝", allergenNames: ["Gluten"] },
{ name: "Penne", icon: "🍝", allergenNames: ["Gluten"] },
{ name: "Tagliatelles", icon: "🍝", allergenNames: ["Gluten"] },
@ -400,8 +452,11 @@ const INGREDIENTS: Array<{ name: string; icon?: string; allergenNames: string[]
{ name: "Tomates cerises", icon: "🍅", allergenNames: [] },
{ name: "Focaccia", icon: "🍞", allergenNames: ["Gluten"] },
{ name: "Ciabatta", icon: "🍞", allergenNames: ["Gluten"] },
// --- Cuisine asiatique (chinoise, japonaise, thaïe, coréenne, indienne...) ------
],
},
{
category: "CUISINE_ASIATIQUE",
items: [
{ name: "Sauce huître", icon: "🫙", allergenNames: ["Mollusques"] },
{ name: "Sauce hoisin", icon: "🫙", allergenNames: ["Soja"] },
{ name: "Sauce sriracha", icon: "🫙", allergenNames: [] },
@ -445,8 +500,11 @@ const INGREDIENTS: Array<{ name: string; icon?: string; allergenNames: string[]
{ name: "Pâte de crevettes", icon: "🫙", allergenNames: ["Crustacés"] },
{ name: "Pâte de curry rouge (thaï)", icon: "🍛", allergenNames: [] },
{ name: "Pâte de curry vert (thaï)", icon: "🍛", allergenNames: [] },
// --- Cuisine mexicaine -----------------------------------------------------------
],
},
{
category: "CUISINE_MEXICAINE",
items: [
{ name: "Tortilla de maïs", icon: "🌮", allergenNames: [] },
{ name: "Tortilla de blé", icon: "🌮", allergenNames: ["Gluten"] },
{ name: "Haricots pinto", icon: "🫘", allergenNames: [] },
@ -456,17 +514,23 @@ const INGREDIENTS: Array<{ name: string; icon?: string; allergenNames: string[]
{ name: "Piment habanero", icon: "🌶️", allergenNames: [] },
{ name: "Masa harina", icon: "🌽", allergenNames: [] },
{ name: "Cheddar", icon: "🧀", allergenNames: ["Lait"] },
// --- Maghreb & Moyen-Orient --------------------------------------------------------
],
},
{
category: "MAGHREB_MOYEN_ORIENT",
items: [
{ name: "Ras el hanout", icon: "🌿", allergenNames: [] },
{ name: "Za'atar", icon: "🌿", allergenNames: ["Graines de sésame"] },
{ name: "Tahini", icon: "🫙", allergenNames: ["Graines de sésame"] },
// --- Pains & sandwichs ---------------------------------------------------------------
],
},
{
category: "PAINS_SANDWICHS",
// `Pain`/`Pain de mie`/`Pain complet`/`Baguette`/`Pain de seigle` are
// seeded further up (Céréales, farines & féculents) — this section covers
// the shapes specifically reached for to build a sandwich, plus a few
// international flatbreads not tied to one cuisine section above.
// seeded under `CEREALES_FECULENTS` above — this category covers the
// shapes specifically reached for to build a sandwich, plus a few
// international flatbreads not tied to one cuisine category above.
items: [
{ name: "Pain à burger", icon: "🍔", allergenNames: ["Gluten", "Lait", "Œufs"] },
{ name: "Pain brioché", icon: "🍞", allergenNames: ["Gluten", "Lait", "Œufs"] },
{ name: "Pain à hot-dog", icon: "🌭", allergenNames: ["Gluten"] },
@ -482,12 +546,18 @@ const INGREDIENTS: Array<{ name: string; icon?: string; allergenNames: string[]
{ name: "Pain sans gluten", icon: "🍞", allergenNames: [] },
{ name: "Biscotte", icon: "🍞", allergenNames: ["Gluten"] },
{ name: "Croûtons", icon: "🍞", allergenNames: ["Gluten"] },
// --- Épicerie divers ---------------------------------------------------------------
],
},
{
category: "EPICERIE_DIVERS",
items: [
{ name: "Bicarbonate de soude", icon: "🫙", allergenNames: [] },
{ name: "Fécule de pomme de terre", icon: "🫙", allergenNames: [] },
// --- Liquides & boissons de cuisine -------------------------------------------
],
},
{
category: "LIQUIDES_BOISSONS",
items: [
{ name: "Eau", icon: "💧", allergenNames: [] },
{ name: "Eau gazeuse", icon: "💧", allergenNames: [] },
{ name: "Eau de fleur d'oranger", icon: "💧", allergenNames: [] },
@ -512,8 +582,13 @@ const INGREDIENTS: Array<{ name: string; icon?: string; allergenNames: string[]
{ name: "Vodka", icon: "🥃", allergenNames: [] },
{ name: "Sirop de sucre de canne", icon: "🫙", allergenNames: [] },
{ name: "Fumet de poisson", icon: "🫙", allergenNames: ["Poissons"] },
],
},
];
const INGREDIENTS: Array<IngredientSeed & { category: IngredientCategory }> =
INGREDIENT_GROUPS.flatMap(({ category, items }) => items.map((item) => ({ ...item, category })));
/**
* Populates the `Diet`/`Category`/`Allergy` reference tables. Idempotent
* (safe to call against a database that already has this data upserts by
@ -549,29 +624,29 @@ export async function seedReferenceData(prisma: PrismaClient): Promise<void> {
// hundred entries long, and `seedReferenceData` re-runs on every single
// test's `resetDatabase()` — a per-row round trip made the whole suite
// measurably slower). Bulk-create whatever's missing in one query, then
// reconcile `icon` only for the rows where it actually changed — on a
// freshly-truncated table (the common test-suite case) that's zero
// updates, on a real re-deploy it's however many icons were edited in
// code since the last deploy, never the full list.
// reconcile `icon`/`category` only for the rows where either actually
// changed — on a freshly-truncated table (the common test-suite case)
// that's zero updates, on a real re-deploy it's however many rows were
// edited in code since the last deploy, never the full list.
const existingIngredients = await prisma.ingredient.findMany({
where: { name: { in: INGREDIENTS.map((i) => i.name) } },
select: { id: true, name: true, icon: true },
select: { id: true, name: true, icon: true, category: true },
});
const existingByName = new Map(existingIngredients.map((i) => [i.name, i]));
const missingIngredients = INGREDIENTS.filter((i) => !existingByName.has(i.name));
if (missingIngredients.length > 0) {
await prisma.ingredient.createMany({
data: missingIngredients.map(({ name, icon }) => ({ name, icon })),
data: missingIngredients.map(({ name, icon, category }) => ({ name, icon, category })),
});
}
const changedIcons = INGREDIENTS.filter((i) => {
const changed = INGREDIENTS.filter((i) => {
const existing = existingByName.get(i.name);
return existing && existing.icon !== (i.icon ?? null);
return existing && (existing.icon !== (i.icon ?? null) || existing.category !== i.category);
});
for (const { name, icon } of changedIcons) {
await prisma.ingredient.update({ where: { name }, data: { icon } });
for (const { name, icon, category } of changed) {
await prisma.ingredient.update({ where: { name }, data: { icon, category } });
}
// Re-resolve every ingredient's id (existing + just-created) and every

View file

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

View file

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

View file

@ -54,7 +54,7 @@ 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", "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 () => {

View file

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

View file

@ -140,11 +140,17 @@
flex-shrink: 0;
display: flex;
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);
margin-bottom: var(--space-md);
&__tab {
appearance: none;
flex-shrink: 0;
display: flex;
align-items: center;
gap: 0.4rem;
@ -661,62 +667,109 @@
}
}
// --- Ingredient autocomplete (recipe form + disliked-ingredients field) -----
.ingredient-autocomplete {
position: relative;
// --- Ingredient picker (recipe form + disliked-ingredients field) ----------
// Always-visible category chips + search + card grid replaces the earlier
// 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 {
input {
width: 100%;
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);
background: var(--color-surface-alt);
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 {
&__categories {
display: flex;
align-items: center;
gap: var(--space-sm);
width: 100%;
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);
text-align: left;
border: none;
border-radius: var(--radius-base);
background: none;
color: var(--color-text);
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 {
background: var(--color-surface-alt);
border-color: var(--color-primary);
color: var(--color-text);
}
&.active {
border-color: var(--color-primary);
background: var(--color-primary);
color: #fff;
}
}
&__name {
margin-right: auto;
&__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
// Capacitor later, see the root README) collapse it into a horizontal
// 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) {
.app-layout {
flex-direction: column;
@ -326,15 +337,75 @@
padding: 0;
}
// Nothing to collapse into on a horizontal bar there's no rail.
&__collapse-toggle {
display: none;
}
&__nav {
flex: 1 1 auto;
min-width: 0;
flex-direction: row;
overflow-x: auto;
a {
flex-shrink: 0;
}
}
&__settings,
&__footer {
position: relative;
flex: none;
padding-top: 0;
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"
},
"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é",
"unitLabel": "Unité",
"unitPlaceholder": "g, ml, unité…",

View file

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

View file

@ -32,6 +32,37 @@ export interface AllergyView {
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`
* reference data (`Ingredient`, seeded via `apps/api/src/db/
@ -49,5 +80,6 @@ export interface IngredientView {
id: number;
name: string;
icon: string | null;
category: IngredientCategory;
allergens: AllergyView[];
}