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:
parent
acab18ac4a
commit
ea86a4a7f1
15 changed files with 954 additions and 621 deletions
|
|
@ -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';
|
||||
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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 () => {
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
109
apps/web/src/features/recipes/IngredientPicker.tsx
Normal file
109
apps/web/src/features/recipes/IngredientPicker.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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é…",
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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[];
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue