diff --git a/apps/api/prisma/migrations/20260818113250_ingredient_taxonomy_rework/migration.sql b/apps/api/prisma/migrations/20260818113250_ingredient_taxonomy_rework/migration.sql new file mode 100644 index 0000000..025b473 --- /dev/null +++ b/apps/api/prisma/migrations/20260818113250_ingredient_taxonomy_rework/migration.sql @@ -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'; + diff --git a/apps/api/prisma/schema.prisma b/apps/api/prisma/schema.prisma index ff60451..c43981f 100644 --- a/apps/api/prisma/schema.prisma +++ b/apps/api/prisma/schema.prisma @@ -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[] diff --git a/apps/api/src/db/reference-seed-data.ts b/apps/api/src/db/reference-seed-data.ts index c1046e6..8d2f83d 100644 --- a/apps/api/src/db/reference-seed-data.ts +++ b/apps/api/src/db/reference-seed-data.ts @@ -1,4 +1,9 @@ -import type { AllergenKind, IngredientCategory, PrismaClient } from "@prisma/client"; +import type { + AllergenKind, + IngredientCategory, + IngredientSubcategory, + PrismaClient, +} from "@prisma/client"; // Short, optional-to-pick regime list — `UserProfile.dietId` stays // nullable, this is not meant to be exhaustive. @@ -34,13 +39,14 @@ interface IngredientSeed { allergenNames: string[]; /** * Diet regimes this ingredient is compatible with, overriding its group's - * `defaultDiets` below — only needed for the exceptions within a category - * (a dairy cheese inside an otherwise-vegan cuisine group, a meat-based - * stock inside an otherwise-plant condiments group…). References `DIETS` - * by name, same as `allergenNames` references `ALLERGENS`. Deliberately - * never includes `"Omnivore"` (trivial, every ingredient qualifies) or - * `"Sans gluten"` (derived from `allergenNames` instead — see - * `IngredientDiet` in schema.prisma for why). + * `defaultDiets` below — only needed for the exceptions within a + * subcategory (a fish-based stock inside an otherwise-vegan "bases" + * group, a butter-based dough inside an otherwise-vegan "pâtes à + * cuire"…). References `DIETS` by name, same as `allergenNames` + * references `ALLERGENS`. Deliberately never includes `"Omnivore"` + * (trivial, every ingredient qualifies) or `"Sans gluten"` (derived from + * `allergenNames` instead — see `IngredientDiet` in schema.prisma for + * why). */ dietNames?: string[]; } @@ -55,175 +61,40 @@ interface IngredientSeed { // `ALLERGENS` above by name — every one of the 14 EU-regulated allergens is // 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`). +// the standard emoji set (e.g. `Radis`, `Asperge`, or a raw pastry dough — +// there's no "puff pastry" emoji). // -// 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. +// Grouped by (`category`, `subcategory`) — mirrors `IngredientCategory`/ +// `IngredientSubcategory` in schema.prisma, a supermarket-aisle taxonomy +// ("rayons") reworked from an earlier, less intuitive scheme that mixed +// cuisine-of-origin buckets ("cuisine italienne") in with aisle-style ones +// ("légumes") — an ingredient's category used to depend on which angle you +// thought of first. The picker UI (`apps/web`'s `IngredientPicker`) uses +// this to offer category browsing, not just free-text search — with 400+ +// ingredients, search alone doesn't scale to actually *finding* something, +// and a single flat list of 7 aisles alone wouldn't either (some aisles +// would be 100+ items deep). 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. // // `defaultDiets` is the regime compatibility every item in the group shares -// unless it sets its own `dietNames` — most categories are diet-homogeneous -// (a vegetable is always vegan, a cut of meat never is), so this avoids -// repeating the same three diet names on hundreds of items; only the -// category's exceptions (a cheese inside "cuisine italienne", a fish sauce -// inside "cuisine asiatique"…) need a per-item override. +// unless it sets its own `dietNames` — most groups are diet-homogeneous (a +// vegetable is always vegan, a cut of meat never is), so this avoids +// repeating the same diet names on hundreds of items; only a group's +// exceptions (a fish-based stock inside "bases", a butter-based dough +// inside "pâtes à cuire"…) need a per-item override. const INGREDIENT_GROUPS: Array<{ category: IngredientCategory; + subcategory: IngredientSubcategory; defaultDiets: string[]; items: IngredientSeed[]; }> = [ + // ========================================================================= + // 🥦 Produits frais + // ========================================================================= { - category: "CEREALES_FECULENTS", - defaultDiets: ["Végétarien", "Végan", "Pescétarien"], - items: [ - { name: "Farine de blé", icon: "🌾", allergenNames: ["Gluten"] }, - { name: "Farine complète", icon: "🌾", allergenNames: ["Gluten"] }, - { name: "Farine de maïs", icon: "🌽", allergenNames: [] }, - { name: "Farine de sarrasin", icon: "🌾", allergenNames: [] }, - { name: "Farine de riz", icon: "🍚", allergenNames: [] }, - { name: "Semoule", icon: "🌾", allergenNames: ["Gluten"] }, - { name: "Couscous", icon: "🌾", allergenNames: ["Gluten"] }, - { name: "Boulgour", icon: "🌾", allergenNames: ["Gluten"] }, - { name: "Polenta", icon: "🌽", allergenNames: [] }, - { name: "Quinoa", icon: "🌾", allergenNames: [] }, - { name: "Pâtes", icon: "🍝", allergenNames: ["Gluten"] }, - { name: "Pâtes complètes", icon: "🍝", allergenNames: ["Gluten"] }, - { name: "Riz", icon: "🍚", allergenNames: [] }, - { name: "Riz basmati", icon: "🍚", allergenNames: [] }, - { name: "Riz complet", icon: "🍚", allergenNames: [] }, - { name: "Flocons d'avoine", icon: "🌾", allergenNames: ["Gluten"] }, - { name: "Pomme de terre", icon: "🥔", allergenNames: [] }, - { name: "Patate douce", icon: "🍠", allergenNames: [] }, - { name: "Pain", icon: "🍞", allergenNames: ["Gluten"] }, - { name: "Pain de mie", icon: "🍞", allergenNames: ["Gluten"] }, - { name: "Pain complet", icon: "🍞", allergenNames: ["Gluten"] }, - { name: "Baguette", icon: "🥖", allergenNames: ["Gluten"] }, - { name: "Pain de seigle", icon: "🍞", allergenNames: ["Gluten"] }, - { name: "Chapelure", icon: "🍞", allergenNames: ["Gluten"] }, - { name: "Levure boulangère", icon: "🫙", allergenNames: [] }, - { name: "Levure chimique", icon: "🫙", allergenNames: [] }, - { name: "Maïzena", icon: "🫙", allergenNames: [] }, - ], - }, - { - category: "LEGUMINEUSES", - defaultDiets: ["Végétarien", "Végan", "Pescétarien"], - items: [ - { name: "Lentilles vertes", icon: "🫘", allergenNames: [] }, - { name: "Lentilles corail", icon: "🫘", allergenNames: [] }, - { name: "Pois chiches", icon: "🫘", allergenNames: [] }, - { name: "Haricots blancs", icon: "🫘", allergenNames: [] }, - { name: "Haricots rouges", icon: "🫘", allergenNames: [] }, - { name: "Haricots noirs", icon: "🫘", allergenNames: [] }, - { name: "Pois cassés", icon: "🫘", allergenNames: [] }, - { name: "Fèves", icon: "🫘", allergenNames: [] }, - { name: "Edamame", icon: "🫛", allergenNames: ["Soja"] }, - ], - }, - { - category: "VIANDES_VOLAILLES", - defaultDiets: [], - items: [ - { name: "Poulet", icon: "🍗", allergenNames: [] }, - { name: "Dinde", icon: "🍗", allergenNames: [] }, - { name: "Canard", icon: "🦆", allergenNames: [] }, - { name: "Magret de canard", icon: "🦆", allergenNames: [] }, - { name: "Lapin", icon: "🐇", allergenNames: [] }, - { name: "Bœuf haché", icon: "🥩", allergenNames: [] }, - { name: "Steak de bœuf", icon: "🥩", allergenNames: [] }, - { name: "Rôti de bœuf", icon: "🥩", allergenNames: [] }, - { name: "Escalope de veau", icon: "🥩", allergenNames: [] }, - { name: "Filet mignon de porc", icon: "🥩", allergenNames: [] }, - { name: "Côte de porc", icon: "🥩", allergenNames: [] }, - { name: "Agneau", icon: "🍖", allergenNames: [] }, - { name: "Gigot d'agneau", icon: "🍖", allergenNames: [] }, - { name: "Lardons", icon: "🥓", allergenNames: [] }, - { name: "Bacon", icon: "🥓", allergenNames: [] }, - { name: "Jambon blanc", icon: "🍖", allergenNames: [] }, - { name: "Jambon cru", icon: "🍖", allergenNames: [] }, - { name: "Saucisse", icon: "🌭", allergenNames: [] }, - { name: "Chorizo", icon: "🌭", allergenNames: [] }, - { name: "Merguez", icon: "🌭", allergenNames: [] }, - ], - }, - { - category: "POISSONS_FRUITS_DE_MER", - defaultDiets: ["Pescétarien"], - items: [ - { name: "Saumon", icon: "🐟", allergenNames: ["Poissons"] }, - { name: "Thon", icon: "🐟", allergenNames: ["Poissons"] }, - { name: "Cabillaud", icon: "🐟", allergenNames: ["Poissons"] }, - { name: "Truite", icon: "🐟", allergenNames: ["Poissons"] }, - { name: "Sardine", icon: "🐟", allergenNames: ["Poissons"] }, - { name: "Anchois", icon: "🐟", allergenNames: ["Poissons"] }, - { name: "Merlan", icon: "🐟", allergenNames: ["Poissons"] }, - { name: "Surimi", icon: "🐟", allergenNames: ["Poissons"] }, - { name: "Bar (loup de mer)", icon: "🐟", allergenNames: ["Poissons"] }, - { name: "Dorade", icon: "🐟", allergenNames: ["Poissons"] }, - { name: "Sole", icon: "🐟", allergenNames: ["Poissons"] }, - { name: "Turbot", icon: "🐟", allergenNames: ["Poissons"] }, - { name: "Merlu", icon: "🐟", allergenNames: ["Poissons"] }, - { name: "Colin", icon: "🐟", allergenNames: ["Poissons"] }, - { name: "Lieu noir", icon: "🐟", allergenNames: ["Poissons"] }, - { name: "Églefin", icon: "🐟", allergenNames: ["Poissons"] }, - { name: "Maquereau", icon: "🐟", allergenNames: ["Poissons"] }, - { name: "Hareng", icon: "🐟", allergenNames: ["Poissons"] }, - { name: "Rouget", icon: "🐟", allergenNames: ["Poissons"] }, - { name: "Raie", icon: "🐟", allergenNames: ["Poissons"] }, - { name: "Lotte", icon: "🐟", allergenNames: ["Poissons"] }, - { name: "Flétan", icon: "🐟", allergenNames: ["Poissons"] }, - { name: "Espadon", icon: "🐟", allergenNames: ["Poissons"] }, - { name: "Carpe", icon: "🐟", allergenNames: ["Poissons"] }, - { name: "Brochet", icon: "🐟", allergenNames: ["Poissons"] }, - { name: "Perche", icon: "🐟", allergenNames: ["Poissons"] }, - { name: "Tilapia", icon: "🐟", allergenNames: ["Poissons"] }, - { name: "Panga", icon: "🐟", allergenNames: ["Poissons"] }, - { name: "Saumon fumé", icon: "🐟", allergenNames: ["Poissons"] }, - { name: "Crevettes", icon: "🍤", allergenNames: ["Crustacés"] }, - { name: "Langoustines", icon: "🍤", allergenNames: ["Crustacés"] }, - { name: "Homard", icon: "🦞", allergenNames: ["Crustacés"] }, - { name: "Crabe", icon: "🦀", allergenNames: ["Crustacés"] }, - { name: "Langouste", icon: "🦞", allergenNames: ["Crustacés"] }, - { name: "Moules", icon: "🦪", allergenNames: ["Mollusques"] }, - { name: "Huîtres", icon: "🦪", allergenNames: ["Mollusques"] }, - { name: "Saint-Jacques", icon: "🦪", allergenNames: ["Mollusques"] }, - { name: "Calamar", icon: "🦑", allergenNames: ["Mollusques"] }, - { name: "Poulpe", icon: "🐙", allergenNames: ["Mollusques"] }, - { name: "Palourdes", icon: "🦪", allergenNames: ["Mollusques"] }, - { name: "Bulots", icon: "🐚", allergenNames: ["Mollusques"] }, - ], - }, - { - category: "PRODUITS_LAITIERS_OEUFS", - // Real dairy/egg products only — plant-based "milks" (coconut, almond, - // oat…) live under `LIQUIDES_BOISSONS` instead: they're not dairy, and - // grouping them here purely because the French name says "lait" was a - // categorization bug (they're vegan; nothing in this group is). - defaultDiets: ["Végétarien", "Pescétarien"], - items: [ - { name: "Œuf", icon: "🥚", allergenNames: ["Œufs"] }, - { name: "Lait", icon: "🥛", allergenNames: ["Lait"] }, - { name: "Beurre", icon: "🧈", allergenNames: ["Lait"] }, - { name: "Crème fraîche", icon: "🥛", allergenNames: ["Lait"] }, - { name: "Crème liquide", icon: "🥛", allergenNames: ["Lait"] }, - { name: "Fromage", icon: "🧀", allergenNames: ["Lait"] }, - { name: "Emmental", icon: "🧀", allergenNames: ["Lait"] }, - { name: "Gruyère", icon: "🧀", allergenNames: ["Lait"] }, - { name: "Parmesan", icon: "🧀", allergenNames: ["Lait"] }, - { name: "Mozzarella", icon: "🧀", allergenNames: ["Lait"] }, - { name: "Chèvre (fromage)", icon: "🧀", allergenNames: ["Lait"] }, - { name: "Feta", icon: "🧀", allergenNames: ["Lait"] }, - { name: "Comté", icon: "🧀", allergenNames: ["Lait"] }, - { name: "Fromage blanc", icon: "🥣", allergenNames: ["Lait"] }, - { name: "Mascarpone", icon: "🧀", allergenNames: ["Lait"] }, - { name: "Yaourt", icon: "🥣", allergenNames: ["Lait"] }, - ], - }, - { - category: "LEGUMES", + category: "PRODUITS_FRAIS", + subcategory: "LEGUMES", defaultDiets: ["Végétarien", "Végan", "Pescétarien"], items: [ { name: "Tomate", icon: "🍅", allergenNames: [] }, @@ -264,10 +135,19 @@ const INGREDIENT_GROUPS: Array<{ { name: "Butternut", icon: "🎃", allergenNames: [] }, { name: "Asperge", allergenNames: [] }, { name: "Avocat", icon: "🥑", allergenNames: [] }, + { name: "Pomme de terre", icon: "🥔", allergenNames: [] }, + { name: "Patate douce", icon: "🍠", allergenNames: [] }, + { name: "Tomates cerises", icon: "🍅", allergenNames: [] }, + { name: "Pak-choï", icon: "🥬", allergenNames: [] }, + { name: "Germes de soja", icon: "🌱", allergenNames: ["Soja"] }, + { name: "Shiitake", icon: "🍄", allergenNames: [] }, + { name: "Daikon", allergenNames: [] }, + { name: "Piment vert frais", icon: "🌶️", allergenNames: [] }, ], }, { - category: "FRUITS", + category: "PRODUITS_FRAIS", + subcategory: "FRUITS", defaultDiets: ["Végétarien", "Végan", "Pescétarien"], items: [ { name: "Citron", icon: "🍋", allergenNames: [] }, @@ -301,7 +181,183 @@ const INGREDIENT_GROUPS: Array<{ ], }, { - category: "FRUITS_SECS_OLEAGINEUX", + category: "PRODUITS_FRAIS", + subcategory: "HERBES_FRAICHES", + defaultDiets: ["Végétarien", "Végan", "Pescétarien"], + items: [ + { name: "Basilic", icon: "🌿", allergenNames: [] }, + { name: "Persil", icon: "🌿", allergenNames: [] }, + { name: "Thym", icon: "🌿", allergenNames: [] }, + { name: "Romarin", icon: "🌿", allergenNames: [] }, + { name: "Laurier", icon: "🌿", allergenNames: [] }, + { name: "Ciboulette", icon: "🌿", allergenNames: [] }, + { name: "Coriandre fraîche", icon: "🌿", allergenNames: [] }, + { name: "Menthe", icon: "🌿", allergenNames: [] }, + { name: "Origan", icon: "🌿", allergenNames: [] }, + { name: "Aneth", icon: "🌿", allergenNames: [] }, + { name: "Estragon", icon: "🌿", allergenNames: [] }, + { name: "Sarriette", icon: "🌿", allergenNames: [] }, + { name: "Marjolaine", icon: "🌿", allergenNames: [] }, + { name: "Sauge", icon: "🌿", allergenNames: [] }, + { name: "Cerfeuil", icon: "🌿", allergenNames: [] }, + { name: "Gingembre", icon: "🫚", allergenNames: [] }, + { name: "Citronnelle", icon: "🌿", allergenNames: [] }, + { name: "Combava", icon: "🌿", allergenNames: [] }, + ], + }, + // ========================================================================= + // 🥩 Boucherie & poissonnerie + // ========================================================================= + { + category: "BOUCHERIE_POISSONNERIE", + subcategory: "VIANDES", + defaultDiets: [], + items: [ + { name: "Lapin", icon: "🐇", allergenNames: [] }, + { name: "Bœuf haché", icon: "🥩", allergenNames: [] }, + { name: "Steak de bœuf", icon: "🥩", allergenNames: [] }, + { name: "Rôti de bœuf", icon: "🥩", allergenNames: [] }, + { name: "Escalope de veau", icon: "🥩", allergenNames: [] }, + { name: "Filet mignon de porc", icon: "🥩", allergenNames: [] }, + { name: "Côte de porc", icon: "🥩", allergenNames: [] }, + { name: "Agneau", icon: "🍖", allergenNames: [] }, + { name: "Gigot d'agneau", icon: "🍖", allergenNames: [] }, + { name: "Lardons", icon: "🥓", allergenNames: [] }, + { name: "Bacon", icon: "🥓", allergenNames: [] }, + { name: "Jambon blanc", icon: "🍖", allergenNames: [] }, + { name: "Jambon cru", icon: "🍖", allergenNames: [] }, + { name: "Saucisse", icon: "🌭", allergenNames: [] }, + { name: "Chorizo", icon: "🌭", allergenNames: [] }, + { name: "Merguez", icon: "🌭", allergenNames: [] }, + { name: "Prosciutto", icon: "🍖", allergenNames: [] }, + { name: "Pancetta", icon: "🥓", allergenNames: [] }, + { name: "Mortadelle", icon: "🍖", allergenNames: [] }, + { name: "Salami", icon: "🍖", allergenNames: [] }, + ], + }, + { + category: "BOUCHERIE_POISSONNERIE", + subcategory: "VOLAILLES", + defaultDiets: [], + items: [ + { name: "Poulet", icon: "🍗", allergenNames: [] }, + { name: "Dinde", icon: "🍗", allergenNames: [] }, + { name: "Canard", icon: "🦆", allergenNames: [] }, + { name: "Magret de canard", icon: "🦆", allergenNames: [] }, + ], + }, + { + category: "BOUCHERIE_POISSONNERIE", + subcategory: "POISSONS", + defaultDiets: ["Pescétarien"], + items: [ + { name: "Saumon", icon: "🐟", allergenNames: ["Poissons"] }, + { name: "Thon", icon: "🐟", allergenNames: ["Poissons"] }, + { name: "Cabillaud", icon: "🐟", allergenNames: ["Poissons"] }, + { name: "Truite", icon: "🐟", allergenNames: ["Poissons"] }, + { name: "Sardine", icon: "🐟", allergenNames: ["Poissons"] }, + { name: "Anchois", icon: "🐟", allergenNames: ["Poissons"] }, + { name: "Merlan", icon: "🐟", allergenNames: ["Poissons"] }, + { name: "Surimi", icon: "🐟", allergenNames: ["Poissons"] }, + { name: "Bar (loup de mer)", icon: "🐟", allergenNames: ["Poissons"] }, + { name: "Dorade", icon: "🐟", allergenNames: ["Poissons"] }, + { name: "Sole", icon: "🐟", allergenNames: ["Poissons"] }, + { name: "Turbot", icon: "🐟", allergenNames: ["Poissons"] }, + { name: "Merlu", icon: "🐟", allergenNames: ["Poissons"] }, + { name: "Colin", icon: "🐟", allergenNames: ["Poissons"] }, + { name: "Lieu noir", icon: "🐟", allergenNames: ["Poissons"] }, + { name: "Églefin", icon: "🐟", allergenNames: ["Poissons"] }, + { name: "Maquereau", icon: "🐟", allergenNames: ["Poissons"] }, + { name: "Hareng", icon: "🐟", allergenNames: ["Poissons"] }, + { name: "Rouget", icon: "🐟", allergenNames: ["Poissons"] }, + { name: "Raie", icon: "🐟", allergenNames: ["Poissons"] }, + { name: "Lotte", icon: "🐟", allergenNames: ["Poissons"] }, + { name: "Flétan", icon: "🐟", allergenNames: ["Poissons"] }, + { name: "Espadon", icon: "🐟", allergenNames: ["Poissons"] }, + { name: "Carpe", icon: "🐟", allergenNames: ["Poissons"] }, + { name: "Brochet", icon: "🐟", allergenNames: ["Poissons"] }, + { name: "Perche", icon: "🐟", allergenNames: ["Poissons"] }, + { name: "Tilapia", icon: "🐟", allergenNames: ["Poissons"] }, + { name: "Panga", icon: "🐟", allergenNames: ["Poissons"] }, + { name: "Saumon fumé", icon: "🐟", allergenNames: ["Poissons"] }, + { name: "Poisson séché", icon: "🐟", allergenNames: ["Poissons"] }, + ], + }, + { + category: "BOUCHERIE_POISSONNERIE", + subcategory: "CRUSTACES_FRUITS_DE_MER", + defaultDiets: ["Pescétarien"], + items: [ + { name: "Crevettes", icon: "🍤", allergenNames: ["Crustacés"] }, + { name: "Langoustines", icon: "🍤", allergenNames: ["Crustacés"] }, + { name: "Homard", icon: "🦞", allergenNames: ["Crustacés"] }, + { name: "Crabe", icon: "🦀", allergenNames: ["Crustacés"] }, + { name: "Langouste", icon: "🦞", allergenNames: ["Crustacés"] }, + { name: "Moules", icon: "🦪", allergenNames: ["Mollusques"] }, + { name: "Huîtres", icon: "🦪", allergenNames: ["Mollusques"] }, + { name: "Saint-Jacques", icon: "🦪", allergenNames: ["Mollusques"] }, + { name: "Calamar", icon: "🦑", allergenNames: ["Mollusques"] }, + { name: "Poulpe", icon: "🐙", allergenNames: ["Mollusques"] }, + { name: "Palourdes", icon: "🦪", allergenNames: ["Mollusques"] }, + { name: "Bulots", icon: "🐚", allergenNames: ["Mollusques"] }, + ], + }, + // ========================================================================= + // 🥫 Épicerie sèche + // ========================================================================= + { + category: "EPICERIE_SECHE", + subcategory: "FECULENTS", + defaultDiets: ["Végétarien", "Végan", "Pescétarien"], + items: [ + { name: "Semoule", icon: "🌾", allergenNames: ["Gluten"] }, + { name: "Couscous", icon: "🌾", allergenNames: ["Gluten"] }, + { name: "Boulgour", icon: "🌾", allergenNames: ["Gluten"] }, + { name: "Polenta", icon: "🌽", allergenNames: [] }, + { name: "Quinoa", icon: "🌾", allergenNames: [] }, + { name: "Pâtes", icon: "🍝", allergenNames: ["Gluten"] }, + { name: "Pâtes complètes", icon: "🍝", allergenNames: ["Gluten"] }, + { name: "Riz", icon: "🍚", allergenNames: [] }, + { name: "Riz basmati", icon: "🍚", allergenNames: [] }, + { name: "Riz complet", icon: "🍚", allergenNames: [] }, + { name: "Flocons d'avoine", icon: "🌾", allergenNames: ["Gluten"] }, + { name: "Spaghetti", icon: "🍝", allergenNames: ["Gluten"] }, + { name: "Penne", icon: "🍝", allergenNames: ["Gluten"] }, + { name: "Tagliatelles", icon: "🍝", allergenNames: ["Gluten"] }, + { name: "Lasagnes (feuilles)", icon: "🍝", allergenNames: ["Gluten"] }, + { name: "Gnocchi", icon: "🍝", allergenNames: ["Gluten"] }, + { name: "Riz arborio", icon: "🍚", allergenNames: [] }, + { name: "Nouilles de riz", icon: "🍜", allergenNames: [] }, + { name: "Nouilles udon", icon: "🍜", allergenNames: ["Gluten"] }, + { name: "Nouilles soba", icon: "🍜", allergenNames: ["Gluten"] }, + { name: "Nouilles chinoises", icon: "🍜", allergenNames: ["Gluten"] }, + { name: "Vermicelles de riz", icon: "🍜", allergenNames: [] }, + { name: "Vermicelles de soja", icon: "🍜", allergenNames: [] }, + { name: "Riz gluant", icon: "🍚", allergenNames: [] }, + { name: "Riz à sushi", icon: "🍚", allergenNames: [] }, + { name: "Riz jasmin", icon: "🍚", allergenNames: [] }, + ], + }, + { + category: "EPICERIE_SECHE", + subcategory: "LEGUMINEUSES", + defaultDiets: ["Végétarien", "Végan", "Pescétarien"], + items: [ + { name: "Lentilles vertes", icon: "🫘", allergenNames: [] }, + { name: "Lentilles corail", icon: "🫘", allergenNames: [] }, + { name: "Pois chiches", icon: "🫘", allergenNames: [] }, + { name: "Haricots blancs", icon: "🫘", allergenNames: [] }, + { name: "Haricots rouges", icon: "🫘", allergenNames: [] }, + { name: "Haricots noirs", icon: "🫘", allergenNames: [] }, + { name: "Pois cassés", icon: "🫘", allergenNames: [] }, + { name: "Fèves", icon: "🫘", allergenNames: [] }, + { name: "Edamame", icon: "🫛", allergenNames: ["Soja"] }, + { name: "Haricots pinto", icon: "🫘", allergenNames: [] }, + ], + }, + { + category: "EPICERIE_SECHE", + subcategory: "GRAINES_FRUITS_SECS", defaultDiets: ["Végétarien", "Végan", "Pescétarien"], items: [ { name: "Cacahuètes", icon: "🥜", allergenNames: ["Arachides"] }, @@ -319,24 +375,203 @@ const INGREDIENT_GROUPS: Array<{ { name: "Raisins secs", icon: "🍇", allergenNames: ["Sulfites"] }, { name: "Pruneaux", allergenNames: ["Sulfites"] }, { name: "Abricots secs", icon: "🍑", allergenNames: ["Sulfites"] }, + { name: "Graines de sésame", icon: "🌱", allergenNames: ["Graines de sésame"] }, ], }, { - category: "CONDIMENTS_SAUCES", + category: "EPICERIE_SECHE", + subcategory: "AUTRES", defaultDiets: ["Végétarien", "Végan", "Pescétarien"], items: [ - { name: "Sel", icon: "🧂", allergenNames: [] }, - { name: "Sucre", icon: "🍬", allergenNames: [] }, - { name: "Huile d'olive", icon: "🫒", allergenNames: [] }, - { name: "Huile de tournesol", icon: "🧴", allergenNames: [] }, - { name: "Huile de colza", icon: "🧴", allergenNames: [] }, - { name: "Huile de coco", icon: "🥥", allergenNames: [] }, - { name: "Huile de sésame", icon: "🧴", allergenNames: ["Graines de sésame"] }, - { name: "Vinaigre de cidre", icon: "🧴", allergenNames: [] }, - { name: "Vinaigre blanc", icon: "🧴", allergenNames: [] }, - { name: "Vinaigre balsamique", icon: "🧴", allergenNames: ["Sulfites"] }, - { name: "Sauce soja", icon: "🍶", allergenNames: ["Soja"] }, + { name: "Champignons noirs", icon: "🍄", allergenNames: [] }, + { name: "Algue nori", icon: "🌿", allergenNames: [] }, + { name: "Algue wakamé", icon: "🌿", allergenNames: [] }, + { name: "Algue kombu", icon: "🌿", allergenNames: [] }, + { name: "Pousses de bambou", allergenNames: [] }, + { name: "Châtaignes d'eau", allergenNames: [] }, + ], + }, + // ========================================================================= + // 🍞 Boulangerie + // ========================================================================= + { + category: "BOULANGERIE", + subcategory: "PAINS", + defaultDiets: ["Végétarien", "Végan", "Pescétarien"], + items: [ + { name: "Pain", icon: "🍞", allergenNames: ["Gluten"] }, + { name: "Pain de mie", icon: "🍞", allergenNames: ["Gluten"] }, + { name: "Pain complet", icon: "🍞", allergenNames: ["Gluten"] }, + { name: "Baguette", icon: "🥖", allergenNames: ["Gluten"] }, + { name: "Pain de seigle", icon: "🍞", allergenNames: ["Gluten"] }, + { name: "Chapelure", icon: "🍞", allergenNames: ["Gluten"] }, + { + name: "Pain à burger", + icon: "🍔", + allergenNames: ["Gluten", "Lait", "Œufs"], + dietNames: ["Végétarien", "Pescétarien"], + }, + { + name: "Pain brioché", + icon: "🍞", + allergenNames: ["Gluten", "Lait", "Œufs"], + dietNames: ["Végétarien", "Pescétarien"], + }, + { name: "Pain à hot-dog", icon: "🌭", allergenNames: ["Gluten"] }, + { name: "Pain pita", icon: "🫓", allergenNames: ["Gluten"] }, + { name: "Pain bagel", icon: "🥯", allergenNames: ["Gluten"] }, + { name: "Naan", icon: "🫓", allergenNames: ["Gluten"] }, + { name: "Pain wrap", icon: "🫓", allergenNames: ["Gluten"] }, + { + name: "Pain viennois", + icon: "🍞", + allergenNames: ["Gluten", "Lait"], + dietNames: ["Végétarien", "Pescétarien"], + }, + { name: "Pain de campagne", icon: "🍞", allergenNames: ["Gluten"] }, + { name: "Pain aux céréales", icon: "🍞", allergenNames: ["Gluten"] }, + { name: "Petit pain", icon: "🍞", allergenNames: ["Gluten"] }, + { name: "Pain suédois", icon: "🍞", allergenNames: ["Gluten"] }, + { name: "Pain sans gluten", icon: "🍞", allergenNames: [] }, + { name: "Biscotte", icon: "🍞", allergenNames: ["Gluten"] }, + { name: "Croûtons", icon: "🍞", allergenNames: ["Gluten"] }, + { name: "Focaccia", icon: "🍞", allergenNames: ["Gluten"] }, + { name: "Ciabatta", icon: "🍞", allergenNames: ["Gluten"] }, + { name: "Tortilla de maïs", icon: "🌮", allergenNames: [] }, + { name: "Tortilla de blé", icon: "🌮", allergenNames: ["Gluten"] }, + ], + }, + { + category: "BOULANGERIE", + subcategory: "PATES_A_CUIRE", + defaultDiets: ["Végétarien", "Végan", "Pescétarien"], + items: [ + { + name: "Pâte feuilletée", + allergenNames: ["Gluten", "Lait"], + dietNames: ["Végétarien", "Pescétarien"], + }, + { + name: "Pâte brisée", + allergenNames: ["Gluten", "Lait"], + dietNames: ["Végétarien", "Pescétarien"], + }, + { name: "Pâte à pizza", allergenNames: ["Gluten"] }, + { + name: "Pâte à tarte sablée", + allergenNames: ["Gluten", "Lait"], + dietNames: ["Végétarien", "Pescétarien"], + }, + ], + }, + // ========================================================================= + // 🧈 Crémerie & fromage + // ========================================================================= + { + category: "CREMERIE_FROMAGE", + subcategory: "PRODUITS_LAITIERS", + defaultDiets: ["Végétarien", "Pescétarien"], + items: [ + { name: "Lait", icon: "🥛", allergenNames: ["Lait"] }, + { name: "Beurre", icon: "🧈", allergenNames: ["Lait"] }, + { name: "Crème fraîche", icon: "🥛", allergenNames: ["Lait"] }, + { name: "Crème liquide", icon: "🥛", allergenNames: ["Lait"] }, + { name: "Fromage", icon: "🧀", allergenNames: ["Lait"] }, + { name: "Emmental", icon: "🧀", allergenNames: ["Lait"] }, + { name: "Gruyère", icon: "🧀", allergenNames: ["Lait"] }, + { name: "Parmesan", icon: "🧀", allergenNames: ["Lait"] }, + { name: "Mozzarella", icon: "🧀", allergenNames: ["Lait"] }, + { name: "Chèvre (fromage)", icon: "🧀", allergenNames: ["Lait"] }, + { name: "Feta", icon: "🧀", allergenNames: ["Lait"] }, + { name: "Comté", icon: "🧀", allergenNames: ["Lait"] }, + { name: "Fromage blanc", icon: "🥣", allergenNames: ["Lait"] }, + { name: "Mascarpone", icon: "🧀", allergenNames: ["Lait"] }, + { name: "Yaourt", icon: "🥣", allergenNames: ["Lait"] }, + { name: "Burrata", icon: "🧀", allergenNames: ["Lait"] }, + { name: "Ricotta", icon: "🧀", allergenNames: ["Lait"] }, + { name: "Pecorino", icon: "🧀", allergenNames: ["Lait"] }, + { name: "Gorgonzola", icon: "🧀", allergenNames: ["Lait"] }, + { name: "Cheddar", icon: "🧀", allergenNames: ["Lait"] }, + ], + }, + { + category: "CREMERIE_FROMAGE", + subcategory: "OEUFS", + defaultDiets: ["Végétarien", "Pescétarien"], + items: [{ name: "Œuf", icon: "🥚", allergenNames: ["Œufs"] }], + }, + { + category: "CREMERIE_FROMAGE", + subcategory: "ALTERNATIVES", + defaultDiets: ["Végétarien", "Végan", "Pescétarien"], + items: [ + { name: "Lait de coco", icon: "🥥", allergenNames: [] }, + { name: "Crème de coco", icon: "🥥", allergenNames: [] }, + { name: "Lait d'amande", icon: "🥛", allergenNames: ["Fruits à coque"] }, + { name: "Lait d'avoine", icon: "🥛", allergenNames: ["Gluten"] }, { name: "Tofu", icon: "🧊", allergenNames: ["Soja"] }, + { name: "Tofu soyeux", icon: "🧊", allergenNames: ["Soja"] }, + ], + }, + // ========================================================================= + // 🧂 Condiments & épices + // ========================================================================= + { + category: "CONDIMENTS_EPICES", + subcategory: "EPICES", + defaultDiets: ["Végétarien", "Végan", "Pescétarien"], + items: [ + { name: "Herbes de Provence", icon: "🌿", allergenNames: [] }, + { name: "Poivre noir", allergenNames: [] }, + { name: "Paprika", icon: "🌶️", allergenNames: [] }, + { name: "Piment d'Espelette", icon: "🌶️", allergenNames: [] }, + { name: "Piment de Cayenne", icon: "🌶️", allergenNames: [] }, + { name: "Cumin", icon: "🌿", allergenNames: [] }, + { name: "Curry (poudre)", icon: "🌿", allergenNames: [] }, + { name: "Curcuma", icon: "🌿", allergenNames: [] }, + { name: "Cannelle", icon: "🌿", allergenNames: [] }, + { name: "Muscade", icon: "🌿", allergenNames: [] }, + { name: "Safran", icon: "🌿", allergenNames: [] }, + { name: "Clou de girofle", icon: "🌿", allergenNames: [] }, + { name: "Vanille (gousse)", icon: "🌿", allergenNames: [] }, + { name: "Poivre blanc", allergenNames: [] }, + { name: "Poivre rose", allergenNames: [] }, + { name: "Poivre du Sichuan", allergenNames: [] }, + { name: "Paprika fumé", icon: "🌶️", allergenNames: [] }, + { name: "Piment oiseau", icon: "🌶️", allergenNames: [] }, + { name: "Baies de genièvre", allergenNames: [] }, + { name: "Anis étoilé (badiane)", icon: "🌿", allergenNames: [] }, + { name: "Anis vert", icon: "🌿", allergenNames: [] }, + { name: "Graines de fenouil", icon: "🌿", allergenNames: [] }, + { name: "Sumac", allergenNames: [] }, + { name: "Nigelle", allergenNames: [] }, + { name: "Quatre épices", icon: "🌿", allergenNames: [] }, + { name: "Colombo (poudre)", icon: "🌿", allergenNames: [] }, + { name: "Baharat", icon: "🌿", allergenNames: [] }, + { name: "Raifort", allergenNames: [] }, + { name: "Sel aux herbes", icon: "🧂", allergenNames: [] }, + { name: "Sel de céleri", icon: "🧂", allergenNames: ["Céleri"] }, + { name: "Fleur de sel", icon: "🧂", allergenNames: [] }, + { name: "Sel", icon: "🧂", allergenNames: [] }, + { name: "Cinq épices", icon: "🌿", allergenNames: [] }, + { name: "Garam masala", icon: "🌿", allergenNames: [] }, + { name: "Graines de coriandre", icon: "🌿", allergenNames: [] }, + { name: "Cardamome", icon: "🌿", allergenNames: [] }, + { name: "Fenugrec", icon: "🌿", allergenNames: [] }, + { name: "Piment jalapeño", icon: "🌶️", allergenNames: [] }, + { name: "Piment chipotle", icon: "🌶️", allergenNames: [] }, + { name: "Piment poblano", icon: "🌶️", allergenNames: [] }, + { name: "Piment habanero", icon: "🌶️", allergenNames: [] }, + { name: "Ras el hanout", icon: "🌿", allergenNames: [] }, + { name: "Za'atar", icon: "🌿", allergenNames: ["Graines de sésame"] }, + ], + }, + { + category: "CONDIMENTS_EPICES", + subcategory: "SAUCES", + defaultDiets: ["Végétarien", "Végan", "Pescétarien"], + items: [ + { name: "Sauce soja", icon: "🍶", allergenNames: ["Soja"] }, { name: "Moutarde", icon: "🟡", allergenNames: ["Moutarde"] }, { name: "Mayonnaise", @@ -345,10 +580,6 @@ const INGREDIENT_GROUPS: Array<{ dietNames: ["Végétarien", "Pescétarien"], }, { name: "Ketchup", icon: "🫙", allergenNames: [] }, - { name: "Miel", icon: "🍯", allergenNames: [], dietNames: ["Végétarien", "Pescétarien"] }, - { name: "Sirop d'érable", icon: "🍁", allergenNames: [] }, - { name: "Câpres", allergenNames: [] }, - { name: "Olives", icon: "🫒", allergenNames: [] }, { name: "Tabasco", icon: "🌶️", allergenNames: [] }, { name: "Sauce Worcestershire", @@ -365,22 +596,7 @@ const INGREDIENT_GROUPS: Array<{ { name: "Wasabi", allergenNames: [] }, { name: "Harissa", icon: "🌶️", allergenNames: [] }, { name: "Pâte de curry", icon: "🍛", allergenNames: [] }, - { name: "Bouillon cube légumes", icon: "🫙", allergenNames: ["Céleri"] }, - { name: "Bouillon cube volaille", icon: "🫙", allergenNames: ["Céleri"], dietNames: [] }, - { name: "Concentré de tomate", icon: "🍅", allergenNames: [] }, - { name: "Coulis de tomate", icon: "🍅", allergenNames: [] }, - { name: "Tomates pelées (conserve)", icon: "🍅", allergenNames: [] }, - { name: "Vin blanc (cuisine)", icon: "🍷", allergenNames: ["Sulfites"] }, - { name: "Vin rouge (cuisine)", icon: "🍷", allergenNames: ["Sulfites"] }, - { name: "Vinaigre de vin rouge", icon: "🧴", allergenNames: ["Sulfites"] }, - { name: "Vinaigre de vin blanc", icon: "🧴", allergenNames: ["Sulfites"] }, - { name: "Vinaigre de xérès", icon: "🧴", allergenNames: ["Sulfites"] }, - { name: "Huile de noix", icon: "🧴", allergenNames: ["Fruits à coque"] }, - { name: "Huile de noisette", icon: "🧴", allergenNames: ["Fruits à coque"] }, - { name: "Huile d'arachide", icon: "🧴", allergenNames: ["Arachides"] }, - { name: "Huile pimentée", icon: "🌶️", allergenNames: [] }, { name: "Beurre de cacahuète", icon: "🥜", allergenNames: ["Arachides"] }, - { name: "Farine de lupin", icon: "🌱", allergenNames: ["Lupin"] }, { name: "Moutarde de Dijon", icon: "🟡", allergenNames: ["Moutarde"] }, { name: "Moutarde à l'ancienne", icon: "🟡", allergenNames: ["Moutarde"] }, { name: "Sauce barbecue", icon: "🫙", allergenNames: [] }, @@ -428,6 +644,98 @@ const INGREDIENT_GROUPS: Array<{ allergenNames: ["Lait", "Fruits à coque"], dietNames: ["Végétarien", "Pescétarien"], }, + { + name: "Pesto", + icon: "🫙", + allergenNames: ["Lait", "Fruits à coque"], + dietNames: ["Végétarien", "Pescétarien"], + }, + { + name: "Sauce huître", + icon: "🫙", + allergenNames: ["Mollusques"], + dietNames: ["Pescétarien"], + }, + { name: "Sauce hoisin", icon: "🫙", allergenNames: ["Soja"] }, + { name: "Sauce sriracha", icon: "🫙", allergenNames: [] }, + { name: "Sauce sweet chili", icon: "🫙", allergenNames: [] }, + { name: "Miso", icon: "🫙", allergenNames: ["Soja"] }, + { + name: "Pâte de crevettes", + icon: "🫙", + allergenNames: ["Crustacés"], + dietNames: ["Pescétarien"], + }, + { name: "Pâte de curry rouge (thaï)", icon: "🍛", allergenNames: [] }, + { name: "Pâte de curry vert (thaï)", icon: "🍛", allergenNames: [] }, + { name: "Tahini", icon: "🫙", allergenNames: ["Graines de sésame"] }, + ], + }, + { + category: "CONDIMENTS_EPICES", + subcategory: "ASSAISONNEMENTS", + defaultDiets: ["Végétarien", "Végan", "Pescétarien"], + items: [ + { name: "Huile d'olive", icon: "🫒", allergenNames: [] }, + { name: "Huile de tournesol", icon: "🧴", allergenNames: [] }, + { name: "Huile de colza", icon: "🧴", allergenNames: [] }, + { name: "Huile de coco", icon: "🥥", allergenNames: [] }, + { name: "Huile de sésame", icon: "🧴", allergenNames: ["Graines de sésame"] }, + { name: "Vinaigre de cidre", icon: "🧴", allergenNames: [] }, + { name: "Vinaigre blanc", icon: "🧴", allergenNames: [] }, + { name: "Vinaigre balsamique", icon: "🧴", allergenNames: ["Sulfites"] }, + { name: "Câpres", allergenNames: [] }, + { name: "Olives", icon: "🫒", allergenNames: [] }, + { name: "Vin blanc (cuisine)", icon: "🍷", allergenNames: ["Sulfites"] }, + { name: "Vin rouge (cuisine)", icon: "🍷", allergenNames: ["Sulfites"] }, + { name: "Vinaigre de vin rouge", icon: "🧴", allergenNames: ["Sulfites"] }, + { name: "Vinaigre de vin blanc", icon: "🧴", allergenNames: ["Sulfites"] }, + { name: "Vinaigre de xérès", icon: "🧴", allergenNames: ["Sulfites"] }, + { name: "Huile de noix", icon: "🧴", allergenNames: ["Fruits à coque"] }, + { name: "Huile de noisette", icon: "🧴", allergenNames: ["Fruits à coque"] }, + { name: "Huile d'arachide", icon: "🧴", allergenNames: ["Arachides"] }, + { name: "Huile pimentée", icon: "🌶️", allergenNames: [] }, + { name: "Vinaigre de riz", icon: "🧴", allergenNames: [] }, + { name: "Mirin", icon: "🍶", allergenNames: [] }, + { name: "Saké (cuisine)", icon: "🍶", allergenNames: [] }, + { name: "Jus de citron", icon: "🧃", allergenNames: [] }, + { name: "Jus de citron vert", icon: "🧃", allergenNames: [] }, + { name: "Jus d'orange", icon: "🧃", allergenNames: [] }, + { name: "Jus de pomme", icon: "🧃", allergenNames: [] }, + { name: "Jus de raisin", icon: "🧃", allergenNames: [] }, + { name: "Jus de tomate", icon: "🧃", allergenNames: [] }, + { name: "Jus de cranberry", icon: "🧃", allergenNames: [] }, + { name: "Café", icon: "☕", allergenNames: [] }, + { name: "Thé", icon: "🍵", allergenNames: [] }, + { name: "Bière (cuisine)", icon: "🍺", allergenNames: ["Gluten"] }, + { name: "Cidre (cuisine)", allergenNames: ["Sulfites"] }, + { name: "Champagne / vin pétillant (cuisine)", icon: "🥂", allergenNames: ["Sulfites"] }, + { name: "Porto (cuisine)", icon: "🍷", allergenNames: ["Sulfites"] }, + { name: "Vin jaune (cuisine)", icon: "🍷", allergenNames: ["Sulfites"] }, + { name: "Cognac", icon: "🥃", allergenNames: [] }, + { name: "Rhum", icon: "🥃", allergenNames: [] }, + { name: "Whisky", icon: "🥃", allergenNames: [] }, + { name: "Vodka", icon: "🥃", allergenNames: [] }, + ], + }, + // ========================================================================= + // 🍳 Aides culinaires + // ========================================================================= + { + category: "AIDES_CULINAIRES", + subcategory: "BASES", + defaultDiets: ["Végétarien", "Végan", "Pescétarien"], + items: [ + { name: "Farine de blé", icon: "🌾", allergenNames: ["Gluten"] }, + { name: "Farine complète", icon: "🌾", allergenNames: ["Gluten"] }, + { name: "Farine de maïs", icon: "🌽", allergenNames: [] }, + { name: "Farine de sarrasin", icon: "🌾", allergenNames: [] }, + { name: "Farine de riz", icon: "🍚", allergenNames: [] }, + { name: "Bouillon cube légumes", icon: "🫙", allergenNames: ["Céleri"] }, + { name: "Bouillon cube volaille", icon: "🫙", allergenNames: ["Céleri"], dietNames: [] }, + { name: "Concentré de tomate", icon: "🍅", allergenNames: [] }, + { name: "Coulis de tomate", icon: "🍅", allergenNames: [] }, + { name: "Tomates pelées (conserve)", icon: "🍅", allergenNames: [] }, { name: "Tomates séchées", icon: "🍅", allergenNames: [] }, { name: "Fond de veau", icon: "🫙", allergenNames: [], dietNames: [] }, { name: "Fond de volaille", icon: "🫙", allergenNames: [], dietNames: [] }, @@ -454,65 +762,45 @@ const INGREDIENT_GROUPS: Array<{ allergenNames: ["Crustacés"], dietNames: ["Pescétarien"], }, + { name: "Farine de tapioca", icon: "🌾", allergenNames: [] }, + { name: "Masa harina", icon: "🌽", allergenNames: [] }, + { name: "Eau", icon: "💧", allergenNames: [] }, + { name: "Eau gazeuse", icon: "💧", allergenNames: [] }, + { name: "Eau de fleur d'oranger", icon: "💧", allergenNames: [] }, + { name: "Eau de rose", icon: "💧", allergenNames: [] }, + { + name: "Fumet de poisson", + icon: "🫙", + allergenNames: ["Poissons"], + dietNames: ["Pescétarien"], + }, ], }, { - category: "EPICES_HERBES", + category: "AIDES_CULINAIRES", + subcategory: "EPAISSISSANTS", defaultDiets: ["Végétarien", "Végan", "Pescétarien"], items: [ - { name: "Basilic", icon: "🌿", allergenNames: [] }, - { name: "Persil", icon: "🌿", allergenNames: [] }, - { name: "Thym", icon: "🌿", allergenNames: [] }, - { name: "Romarin", icon: "🌿", allergenNames: [] }, - { name: "Laurier", icon: "🌿", allergenNames: [] }, - { name: "Ciboulette", icon: "🌿", allergenNames: [] }, - { name: "Coriandre fraîche", icon: "🌿", allergenNames: [] }, - { name: "Menthe", icon: "🌿", allergenNames: [] }, - { name: "Origan", icon: "🌿", allergenNames: [] }, - { name: "Aneth", icon: "🌿", allergenNames: [] }, - { name: "Estragon", icon: "🌿", allergenNames: [] }, - { name: "Herbes de Provence", icon: "🌿", allergenNames: [] }, - { name: "Poivre noir", allergenNames: [] }, - { name: "Paprika", icon: "🌶️", allergenNames: [] }, - { name: "Piment d'Espelette", icon: "🌶️", allergenNames: [] }, - { name: "Piment de Cayenne", icon: "🌶️", allergenNames: [] }, - { name: "Cumin", icon: "🌿", allergenNames: [] }, - { name: "Curry (poudre)", icon: "🌿", allergenNames: [] }, - { name: "Curcuma", icon: "🌿", allergenNames: [] }, - { name: "Cannelle", icon: "🌿", allergenNames: [] }, - { name: "Gingembre", icon: "🫚", allergenNames: [] }, - { name: "Muscade", icon: "🌿", allergenNames: [] }, - { name: "Safran", icon: "🌿", allergenNames: [] }, - { name: "Clou de girofle", icon: "🌿", allergenNames: [] }, - { name: "Vanille (gousse)", icon: "🌿", allergenNames: [] }, - { name: "Poivre blanc", allergenNames: [] }, - { name: "Poivre rose", allergenNames: [] }, - { name: "Poivre du Sichuan", allergenNames: [] }, - { name: "Paprika fumé", icon: "🌶️", allergenNames: [] }, - { name: "Piment oiseau", icon: "🌶️", allergenNames: [] }, - { name: "Baies de genièvre", allergenNames: [] }, - { name: "Anis étoilé (badiane)", icon: "🌿", allergenNames: [] }, - { name: "Anis vert", icon: "🌿", allergenNames: [] }, - { name: "Graines de fenouil", icon: "🌿", allergenNames: [] }, - { name: "Sarriette", icon: "🌿", allergenNames: [] }, - { name: "Marjolaine", icon: "🌿", allergenNames: [] }, - { name: "Sauge", icon: "🌿", allergenNames: [] }, - { name: "Cerfeuil", icon: "🌿", allergenNames: [] }, - { name: "Sumac", allergenNames: [] }, - { name: "Nigelle", allergenNames: [] }, - { name: "Quatre épices", icon: "🌿", allergenNames: [] }, - { name: "Colombo (poudre)", icon: "🌿", allergenNames: [] }, - { name: "Baharat", icon: "🌿", allergenNames: [] }, - { name: "Raifort", allergenNames: [] }, - { name: "Sel aux herbes", icon: "🧂", allergenNames: [] }, - { name: "Sel de céleri", icon: "🧂", allergenNames: ["Céleri"] }, - { name: "Fleur de sel", icon: "🧂", allergenNames: [] }, + { name: "Levure boulangère", icon: "🫙", allergenNames: [] }, + { name: "Levure chimique", icon: "🫙", allergenNames: [] }, + { name: "Maïzena", icon: "🫙", allergenNames: [] }, + { name: "Farine de lupin", icon: "🌱", allergenNames: ["Lupin"] }, + // Animal collagen (bones/skin, usually pork or beef) — not + // vegetarian/vegan, and not reliably fish-derived either, so no + // pescetarian flag. + { name: "Gélatine", icon: "🫙", allergenNames: [], dietNames: [] }, + { name: "Bicarbonate de soude", icon: "🫙", allergenNames: [] }, + { name: "Fécule de pomme de terre", icon: "🫙", allergenNames: [] }, ], }, { - category: "SUCRE_PATISSERIE", + category: "AIDES_CULINAIRES", + subcategory: "SUCRES", defaultDiets: ["Végétarien", "Végan", "Pescétarien"], items: [ + { name: "Sucre", icon: "🍬", allergenNames: [] }, + { name: "Miel", icon: "🍯", allergenNames: [], dietNames: ["Végétarien", "Pescétarien"] }, + { name: "Sirop d'érable", icon: "🍁", allergenNames: [] }, { name: "Sucre roux", icon: "🍬", allergenNames: [] }, { name: "Sucre glace", icon: "🍬", allergenNames: [] }, { name: "Cassonade", icon: "🍬", allergenNames: [] }, @@ -531,251 +819,27 @@ const INGREDIENT_GROUPS: Array<{ }, { name: "Pépites de chocolat", icon: "🍫", allergenNames: [] }, { name: "Cacao en poudre", icon: "🍫", allergenNames: [] }, - // Animal collagen (bones/skin, usually pork or beef) — not - // vegetarian/vegan, and not reliably fish-derived either, so no - // pescetarian flag. - { name: "Gélatine", icon: "🫙", allergenNames: [], dietNames: [] }, { name: "Extrait de vanille", icon: "🌿", allergenNames: [] }, - ], - }, - { - category: "CUISINE_ITALIENNE", - defaultDiets: ["Végétarien", "Végan", "Pescétarien"], - items: [ - { name: "Spaghetti", icon: "🍝", allergenNames: ["Gluten"] }, - { name: "Penne", icon: "🍝", allergenNames: ["Gluten"] }, - { name: "Tagliatelles", icon: "🍝", allergenNames: ["Gluten"] }, - { name: "Lasagnes (feuilles)", icon: "🍝", allergenNames: ["Gluten"] }, - { name: "Gnocchi", icon: "🍝", allergenNames: ["Gluten"] }, - { name: "Riz arborio", icon: "🍚", allergenNames: [] }, - { - name: "Burrata", - icon: "🧀", - allergenNames: ["Lait"], - dietNames: ["Végétarien", "Pescétarien"], - }, - { - name: "Ricotta", - icon: "🧀", - allergenNames: ["Lait"], - dietNames: ["Végétarien", "Pescétarien"], - }, - { - name: "Pecorino", - icon: "🧀", - allergenNames: ["Lait"], - dietNames: ["Végétarien", "Pescétarien"], - }, - { - name: "Gorgonzola", - icon: "🧀", - allergenNames: ["Lait"], - dietNames: ["Végétarien", "Pescétarien"], - }, - { name: "Prosciutto", icon: "🍖", allergenNames: [], dietNames: [] }, - { name: "Pancetta", icon: "🥓", allergenNames: [], dietNames: [] }, - { name: "Mortadelle", icon: "🍖", allergenNames: [], dietNames: [] }, - { name: "Salami", icon: "🍖", allergenNames: [], dietNames: [] }, - { - name: "Pesto", - icon: "🫙", - allergenNames: ["Lait", "Fruits à coque"], - dietNames: ["Végétarien", "Pescétarien"], - }, - { name: "Tomates cerises", icon: "🍅", allergenNames: [] }, - { name: "Focaccia", icon: "🍞", allergenNames: ["Gluten"] }, - { name: "Ciabatta", icon: "🍞", allergenNames: ["Gluten"] }, - ], - }, - { - category: "CUISINE_ASIATIQUE", - defaultDiets: ["Végétarien", "Végan", "Pescétarien"], - items: [ - { - name: "Sauce huître", - icon: "🫙", - allergenNames: ["Mollusques"], - dietNames: ["Pescétarien"], - }, - { name: "Sauce hoisin", icon: "🫙", allergenNames: ["Soja"] }, - { name: "Sauce sriracha", icon: "🫙", allergenNames: [] }, - { name: "Sauce sweet chili", icon: "🫙", allergenNames: [] }, - { name: "Vinaigre de riz", icon: "🧴", allergenNames: [] }, - { name: "Mirin", icon: "🍶", allergenNames: [] }, - { name: "Saké (cuisine)", icon: "🍶", allergenNames: [] }, - { name: "Miso", icon: "🫙", allergenNames: ["Soja"] }, - { name: "Tofu soyeux", icon: "🧊", allergenNames: ["Soja"] }, - { name: "Graines de sésame", icon: "🌱", allergenNames: ["Graines de sésame"] }, - { name: "Nouilles de riz", icon: "🍜", allergenNames: [] }, - { name: "Nouilles udon", icon: "🍜", allergenNames: ["Gluten"] }, - { name: "Nouilles soba", icon: "🍜", allergenNames: ["Gluten"] }, - { name: "Nouilles chinoises", icon: "🍜", allergenNames: ["Gluten"] }, - { name: "Vermicelles de riz", icon: "🍜", allergenNames: [] }, - { name: "Vermicelles de soja", icon: "🍜", allergenNames: [] }, - { name: "Riz gluant", icon: "🍚", allergenNames: [] }, - { name: "Riz à sushi", icon: "🍚", allergenNames: [] }, - { name: "Riz jasmin", icon: "🍚", allergenNames: [] }, - { name: "Citronnelle", icon: "🌿", allergenNames: [] }, - { name: "Combava", icon: "🌿", allergenNames: [] }, - { name: "Pak-choï", icon: "🥬", allergenNames: [] }, - { name: "Germes de soja", icon: "🌱", allergenNames: ["Soja"] }, - { name: "Shiitake", icon: "🍄", allergenNames: [] }, - { name: "Champignons noirs", icon: "🍄", allergenNames: [] }, - { name: "Daikon", allergenNames: [] }, - { name: "Algue nori", icon: "🌿", allergenNames: [] }, - { name: "Algue wakamé", icon: "🌿", allergenNames: [] }, - { name: "Algue kombu", icon: "🌿", allergenNames: [] }, - { name: "Cinq épices", icon: "🌿", allergenNames: [] }, - { name: "Garam masala", icon: "🌿", allergenNames: [] }, - { name: "Graines de coriandre", icon: "🌿", allergenNames: [] }, - { name: "Cardamome", icon: "🌿", allergenNames: [] }, - { name: "Fenugrec", icon: "🌿", allergenNames: [] }, - { name: "Piment vert frais", icon: "🌶️", allergenNames: [] }, - { name: "Farine de tapioca", icon: "🌾", allergenNames: [] }, { name: "Sucre de palme", icon: "🍬", allergenNames: [] }, - { name: "Pousses de bambou", allergenNames: [] }, - { name: "Châtaignes d'eau", allergenNames: [] }, - { - name: "Poisson séché", - icon: "🐟", - allergenNames: ["Poissons"], - dietNames: ["Pescétarien"], - }, - { - name: "Pâte de crevettes", - icon: "🫙", - allergenNames: ["Crustacés"], - dietNames: ["Pescétarien"], - }, - { name: "Pâte de curry rouge (thaï)", icon: "🍛", allergenNames: [] }, - { name: "Pâte de curry vert (thaï)", icon: "🍛", allergenNames: [] }, - ], - }, - { - category: "CUISINE_MEXICAINE", - defaultDiets: ["Végétarien", "Végan", "Pescétarien"], - items: [ - { name: "Tortilla de maïs", icon: "🌮", allergenNames: [] }, - { name: "Tortilla de blé", icon: "🌮", allergenNames: ["Gluten"] }, - { name: "Haricots pinto", icon: "🫘", allergenNames: [] }, - { name: "Piment jalapeño", icon: "🌶️", allergenNames: [] }, - { name: "Piment chipotle", icon: "🌶️", allergenNames: [] }, - { name: "Piment poblano", icon: "🌶️", allergenNames: [] }, - { name: "Piment habanero", icon: "🌶️", allergenNames: [] }, - { name: "Masa harina", icon: "🌽", allergenNames: [] }, - { - name: "Cheddar", - icon: "🧀", - allergenNames: ["Lait"], - dietNames: ["Végétarien", "Pescétarien"], - }, - ], - }, - { - category: "MAGHREB_MOYEN_ORIENT", - defaultDiets: ["Végétarien", "Végan", "Pescétarien"], - items: [ - { name: "Ras el hanout", icon: "🌿", allergenNames: [] }, - { name: "Za'atar", icon: "🌿", allergenNames: ["Graines de sésame"] }, - { name: "Tahini", icon: "🫙", allergenNames: ["Graines de sésame"] }, - ], - }, - { - category: "PAINS_SANDWICHS", - // `Pain`/`Pain de mie`/`Pain complet`/`Baguette`/`Pain de seigle` are - // 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. - defaultDiets: ["Végétarien", "Végan", "Pescétarien"], - items: [ - { - name: "Pain à burger", - icon: "🍔", - allergenNames: ["Gluten", "Lait", "Œufs"], - dietNames: ["Végétarien", "Pescétarien"], - }, - { - name: "Pain brioché", - icon: "🍞", - allergenNames: ["Gluten", "Lait", "Œufs"], - dietNames: ["Végétarien", "Pescétarien"], - }, - { name: "Pain à hot-dog", icon: "🌭", allergenNames: ["Gluten"] }, - { name: "Pain pita", icon: "🫓", allergenNames: ["Gluten"] }, - { name: "Pain bagel", icon: "🥯", allergenNames: ["Gluten"] }, - { name: "Naan", icon: "🫓", allergenNames: ["Gluten"] }, - { name: "Pain wrap", icon: "🫓", allergenNames: ["Gluten"] }, - { - name: "Pain viennois", - icon: "🍞", - allergenNames: ["Gluten", "Lait"], - dietNames: ["Végétarien", "Pescétarien"], - }, - { name: "Pain de campagne", icon: "🍞", allergenNames: ["Gluten"] }, - { name: "Pain aux céréales", icon: "🍞", allergenNames: ["Gluten"] }, - { name: "Petit pain", icon: "🍞", allergenNames: ["Gluten"] }, - { name: "Pain suédois", icon: "🍞", allergenNames: ["Gluten"] }, - { name: "Pain sans gluten", icon: "🍞", allergenNames: [] }, - { name: "Biscotte", icon: "🍞", allergenNames: ["Gluten"] }, - { name: "Croûtons", icon: "🍞", allergenNames: ["Gluten"] }, - ], - }, - { - category: "EPICERIE_DIVERS", - defaultDiets: ["Végétarien", "Végan", "Pescétarien"], - items: [ - { name: "Bicarbonate de soude", icon: "🫙", allergenNames: [] }, - { name: "Fécule de pomme de terre", icon: "🫙", allergenNames: [] }, - ], - }, - { - category: "LIQUIDES_BOISSONS", - defaultDiets: ["Végétarien", "Végan", "Pescétarien"], - items: [ - { name: "Eau", icon: "💧", allergenNames: [] }, - { name: "Eau gazeuse", icon: "💧", allergenNames: [] }, - { name: "Eau de fleur d'oranger", icon: "💧", allergenNames: [] }, - { name: "Eau de rose", icon: "💧", allergenNames: [] }, - { name: "Jus de citron", icon: "🧃", allergenNames: [] }, - { name: "Jus de citron vert", icon: "🧃", allergenNames: [] }, - { name: "Jus d'orange", icon: "🧃", allergenNames: [] }, - { name: "Jus de pomme", icon: "🧃", allergenNames: [] }, - { name: "Jus de raisin", icon: "🧃", allergenNames: [] }, - { name: "Jus de tomate", icon: "🧃", allergenNames: [] }, - { name: "Jus de cranberry", icon: "🧃", allergenNames: [] }, - { name: "Café", icon: "☕", allergenNames: [] }, - { name: "Thé", icon: "🍵", allergenNames: [] }, - { name: "Bière (cuisine)", icon: "🍺", allergenNames: ["Gluten"] }, - { name: "Cidre (cuisine)", allergenNames: ["Sulfites"] }, - { name: "Champagne / vin pétillant (cuisine)", icon: "🥂", allergenNames: ["Sulfites"] }, - { name: "Porto (cuisine)", icon: "🍷", allergenNames: ["Sulfites"] }, - { name: "Vin jaune (cuisine)", icon: "🍷", allergenNames: ["Sulfites"] }, - { name: "Cognac", icon: "🥃", allergenNames: [] }, - { name: "Rhum", icon: "🥃", allergenNames: [] }, - { name: "Whisky", icon: "🥃", allergenNames: [] }, - { name: "Vodka", icon: "🥃", allergenNames: [] }, { name: "Sirop de sucre de canne", icon: "🫙", allergenNames: [] }, - { - name: "Fumet de poisson", - icon: "🫙", - allergenNames: ["Poissons"], - dietNames: ["Pescétarien"], - }, - // Plant-based milk/cream substitutes — moved here from - // `PRODUITS_LAITIERS_OEUFS` (they're not dairy; see that group's - // comment) alongside the other drinkable/pourable liquids. - { name: "Lait de coco", icon: "🥥", allergenNames: [] }, - { name: "Crème de coco", icon: "🥥", allergenNames: [] }, - { name: "Lait d'amande", icon: "🥛", allergenNames: ["Fruits à coque"] }, - { name: "Lait d'avoine", icon: "🥛", allergenNames: ["Gluten"] }, ], }, ]; -const INGREDIENTS: Array = - INGREDIENT_GROUPS.flatMap(({ category, defaultDiets, items }) => - items.map((item) => ({ ...item, category, dietNames: item.dietNames ?? defaultDiets })), - ); +const INGREDIENTS: Array< + IngredientSeed & { + category: IngredientCategory; + subcategory: IngredientSubcategory; + dietNames: string[]; + } +> = INGREDIENT_GROUPS.flatMap(({ category, subcategory, defaultDiets, items }) => + items.map((item) => ({ + ...item, + category, + subcategory, + dietNames: item.dietNames ?? defaultDiets, + })), +); /** * Populates the `Diet`/`Category`/`Allergy` reference tables. Idempotent @@ -812,29 +876,40 @@ export async function seedReferenceData(prisma: PrismaClient): Promise { // 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`/`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. + // reconcile `icon`/`category`/`subcategory` only for the rows where any + // of them 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, category: true }, + select: { id: true, name: true, icon: true, category: true, subcategory: 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, category }) => ({ name, icon, category })), + data: missingIngredients.map(({ name, icon, category, subcategory }) => ({ + name, + icon, + category, + subcategory, + })), }); } const changed = INGREDIENTS.filter((i) => { const existing = existingByName.get(i.name); - return existing && (existing.icon !== (i.icon ?? null) || existing.category !== i.category); + return ( + existing && + (existing.icon !== (i.icon ?? null) || + existing.category !== i.category || + existing.subcategory !== i.subcategory) + ); }); - for (const { name, icon, category } of changed) { - await prisma.ingredient.update({ where: { name }, data: { icon, category } }); + for (const { name, icon, category, subcategory } of changed) { + await prisma.ingredient.update({ where: { name }, data: { icon, category, subcategory } }); } // Re-resolve every ingredient's id (existing + just-created) and every diff --git a/apps/api/src/modules/recipe/recipe.service.ts b/apps/api/src/modules/recipe/recipe.service.ts index 08cf70a..0e80a1f 100644 --- a/apps/api/src/modules/recipe/recipe.service.ts +++ b/apps/api/src/modules/recipe/recipe.service.ts @@ -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, diff --git a/apps/api/src/modules/reference/reference.service.ts b/apps/api/src/modules/reference/reference.service.ts index 98ec9e7..832f770 100644 --- a/apps/api/src/modules/reference/reference.service.ts +++ b/apps/api/src/modules/reference/reference.service.ts @@ -44,6 +44,7 @@ export async function getIngredients(): Promise { name: ingredient.name, icon: ingredient.icon, category: ingredient.category, + subcategory: ingredient.subcategory, allergens: ingredient.allergies.map(({ allergy }) => ({ id: allergy.id, name: allergy.category.name, diff --git a/apps/api/test/reference.test.ts b/apps/api/test/reference.test.ts index 542e5d5..47e4e80 100644 --- a/apps/api/test/reference.test.ts +++ b/apps/api/test/reference.test.ts @@ -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 () => { diff --git a/apps/web/src/features/recipes/IngredientPicker.tsx b/apps/web/src/features/recipes/IngredientPicker.tsx index 6b24c22..eb8b676 100644 --- a/apps/web/src/features/recipes/IngredientPicker.tsx +++ b/apps/web/src/features/recipes/IngredientPicker.tsx @@ -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( - ALL_CATEGORIES, - ); + const [category, setCategory] = useState(ALL); + const [subcategory, setSubcategory] = useState(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({
@@ -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}`)} ))}
+ {category !== ALL && ( +
+ + {INGREDIENT_CATEGORY_SUBCATEGORIES[category].map((s) => ( + + ))} +
+ )} + {visible.length === 0 ? (

{t("recipes.form.noIngredientFound")}

) : ( diff --git a/apps/web/src/features/recipes/recipes.scss b/apps/web/src/features/recipes/recipes.scss index e073e73..89c131f 100644 --- a/apps/web/src/features/recipes/recipes.scss +++ b/apps/web/src/features/recipes/recipes.scss @@ -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)); diff --git a/apps/web/src/locales/fr/translation.json b/apps/web/src/locales/fr/translation.json index ff3637a..16b07c5 100644 --- a/apps/web/src/locales/fr/translation.json +++ b/apps/web/src/locales/fr/translation.json @@ -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é", diff --git a/packages/shared/src/types/reference.ts b/packages/shared/src/types/reference.ts index e455f42..b41f768 100644 --- a/packages/shared/src/types/reference.ts +++ b/packages/shared/src/types/reference.ts @@ -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 category→subcategory 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[]; }