fix(recipes): overflow des préférences + régimes liés aux ingrédients
- Fix overflow horizontal sur /parametres/preferences : <fieldset> a un min-width: min-content par défaut du navigateur, ce qui empêchait la grille de l'IngredientPicker de wrapper (page entière poussée à ~3100px de large). Reset min-width: 0 sur .disliked-ingredients-field. - Recatégorise les laits/crèmes végétaux (coco, amande, avoine) de PRODUITS_LAITIERS_OEUFS vers LIQUIDES_BOISSONS — ce ne sont pas des produits laitiers. - Nouveau modèle IngredientDiet (many-to-many ingrédient <-> régime) : quels régimes (Végétarien, Végan, Pescétarien) chaque ingrédient respecte. Omnivore volontairement absent (trivial) et Sans gluten aussi (déjà dérivable de l'allergène Gluten existant). - reference-seed-data.ts : chaque groupe de catégorie porte un defaultDiets, avec dietNames en override pour les exceptions (fromages, viandes, poissons, sauces à base d'œuf/poisson...). - IngredientView.diets exposé par /reference/ingredients et /recipes, affiché via DietBadges dans IngredientPicker et IngredientRow. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
parent
ea86a4a7f1
commit
5890c62462
10 changed files with 330 additions and 59 deletions
|
|
@ -0,0 +1,14 @@
|
|||
-- CreateTable
|
||||
CREATE TABLE "ingredient_diet" (
|
||||
"ingredient_id" INTEGER NOT NULL,
|
||||
"diet_id" INTEGER NOT NULL,
|
||||
|
||||
CONSTRAINT "ingredient_diet_pkey" PRIMARY KEY ("ingredient_id","diet_id")
|
||||
);
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "ingredient_diet" ADD CONSTRAINT "ingredient_diet_ingredient_id_fkey" FOREIGN KEY ("ingredient_id") REFERENCES "ingredients"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "ingredient_diet" ADD CONSTRAINT "ingredient_diet_diet_id_fkey" FOREIGN KEY ("diet_id") REFERENCES "diet"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
|
|
@ -44,6 +44,7 @@ model Diet {
|
|||
|
||||
users UserProfile[]
|
||||
recipes RecipeDiet[]
|
||||
ingredients IngredientDiet[]
|
||||
|
||||
@@map("diet")
|
||||
}
|
||||
|
|
@ -336,10 +337,35 @@ model Ingredient {
|
|||
allergies IngredientAllergy[]
|
||||
/// Profiles that personally dislike this ingredient — see {@link UserProfileDislikedIngredient}.
|
||||
dislikedBy UserProfileDislikedIngredient[]
|
||||
/// Diet regimes this ingredient is compatible with — see {@link IngredientDiet}.
|
||||
diets IngredientDiet[]
|
||||
|
||||
@@map("ingredients")
|
||||
}
|
||||
|
||||
/// Explicit join table for the ingredients <-> diet regime association —
|
||||
/// which regimes (Végétarien, Végan, Pescétarien…) this ingredient is safe
|
||||
/// for, so the picker (apps/web's `IngredientPicker`/`IngredientRow`) can
|
||||
/// flag e.g. an ingredient as vegan without the user having to open its
|
||||
/// packaging. Seeded by category in `reference-seed-data.ts` (most
|
||||
/// ingredients in a category share the same compatible regimes, with
|
||||
/// per-item overrides for exceptions — meat cuts, dairy, seafood…), same as
|
||||
/// `IngredientAllergy`. Deliberately omits `Omnivore` (every ingredient is
|
||||
/// trivially compatible — storing it would be pure noise) and `Sans gluten`
|
||||
/// (already fully derivable from whether `IngredientAllergy` links this
|
||||
/// ingredient to the `Gluten` allergen — a second, hand-maintained source
|
||||
/// for the same fact would only risk drifting out of sync with it).
|
||||
model IngredientDiet {
|
||||
ingredientId Int @map("ingredient_id")
|
||||
dietId Int @map("diet_id")
|
||||
|
||||
ingredient Ingredient @relation(fields: [ingredientId], references: [id], onDelete: Cascade)
|
||||
diet Diet @relation(fields: [dietId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@id([ingredientId, dietId])
|
||||
@@map("ingredient_diet")
|
||||
}
|
||||
|
||||
/// Explicit join table for the ingredients <-> allergy association — not in
|
||||
/// the original spec doc, added so the recipe catalog can surface which
|
||||
/// allergens an ingredient (and by extension a recipe) carries. Same shape
|
||||
|
|
|
|||
|
|
@ -32,6 +32,17 @@ interface IngredientSeed {
|
|||
name: string;
|
||||
icon?: string;
|
||||
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).
|
||||
*/
|
||||
dietNames?: string[];
|
||||
}
|
||||
|
||||
// A broad pantry list — the goal is to cover the large majority of what a
|
||||
|
|
@ -52,9 +63,21 @@ interface IngredientSeed {
|
|||
// 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[] }> = [
|
||||
//
|
||||
// `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.
|
||||
const INGREDIENT_GROUPS: Array<{
|
||||
category: IngredientCategory;
|
||||
defaultDiets: string[];
|
||||
items: IngredientSeed[];
|
||||
}> = [
|
||||
{
|
||||
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"] },
|
||||
|
|
@ -87,6 +110,7 @@ const INGREDIENT_GROUPS: Array<{ category: IngredientCategory; items: Ingredient
|
|||
},
|
||||
{
|
||||
category: "LEGUMINEUSES",
|
||||
defaultDiets: ["Végétarien", "Végan", "Pescétarien"],
|
||||
items: [
|
||||
{ name: "Lentilles vertes", icon: "🫘", allergenNames: [] },
|
||||
{ name: "Lentilles corail", icon: "🫘", allergenNames: [] },
|
||||
|
|
@ -101,6 +125,7 @@ const INGREDIENT_GROUPS: Array<{ category: IngredientCategory; items: Ingredient
|
|||
},
|
||||
{
|
||||
category: "VIANDES_VOLAILLES",
|
||||
defaultDiets: [],
|
||||
items: [
|
||||
{ name: "Poulet", icon: "🍗", allergenNames: [] },
|
||||
{ name: "Dinde", icon: "🍗", allergenNames: [] },
|
||||
|
|
@ -126,6 +151,7 @@ const INGREDIENT_GROUPS: Array<{ category: IngredientCategory; items: Ingredient
|
|||
},
|
||||
{
|
||||
category: "POISSONS_FRUITS_DE_MER",
|
||||
defaultDiets: ["Pescétarien"],
|
||||
items: [
|
||||
{ name: "Saumon", icon: "🐟", allergenNames: ["Poissons"] },
|
||||
{ name: "Thon", icon: "🐟", allergenNames: ["Poissons"] },
|
||||
|
|
@ -172,6 +198,11 @@ const INGREDIENT_GROUPS: Array<{ category: IngredientCategory; items: Ingredient
|
|||
},
|
||||
{
|
||||
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"] },
|
||||
|
|
@ -189,14 +220,11 @@ const INGREDIENT_GROUPS: Array<{ category: IngredientCategory; items: Ingredient
|
|||
{ name: "Fromage blanc", icon: "🥣", allergenNames: ["Lait"] },
|
||||
{ name: "Mascarpone", icon: "🧀", allergenNames: ["Lait"] },
|
||||
{ name: "Yaourt", icon: "🥣", allergenNames: ["Lait"] },
|
||||
{ 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"] },
|
||||
],
|
||||
},
|
||||
{
|
||||
category: "LEGUMES",
|
||||
defaultDiets: ["Végétarien", "Végan", "Pescétarien"],
|
||||
items: [
|
||||
{ name: "Tomate", icon: "🍅", allergenNames: [] },
|
||||
{ name: "Oignon", icon: "🧅", allergenNames: [] },
|
||||
|
|
@ -240,6 +268,7 @@ const INGREDIENT_GROUPS: Array<{ category: IngredientCategory; items: Ingredient
|
|||
},
|
||||
{
|
||||
category: "FRUITS",
|
||||
defaultDiets: ["Végétarien", "Végan", "Pescétarien"],
|
||||
items: [
|
||||
{ name: "Citron", icon: "🍋", allergenNames: [] },
|
||||
{ name: "Citron vert", icon: "🍋", allergenNames: [] },
|
||||
|
|
@ -273,6 +302,7 @@ const INGREDIENT_GROUPS: Array<{ category: IngredientCategory; items: Ingredient
|
|||
},
|
||||
{
|
||||
category: "FRUITS_SECS_OLEAGINEUX",
|
||||
defaultDiets: ["Végétarien", "Végan", "Pescétarien"],
|
||||
items: [
|
||||
{ name: "Cacahuètes", icon: "🥜", allergenNames: ["Arachides"] },
|
||||
{ name: "Amandes", icon: "🌰", allergenNames: ["Fruits à coque"] },
|
||||
|
|
@ -293,6 +323,7 @@ const INGREDIENT_GROUPS: Array<{ category: IngredientCategory; items: Ingredient
|
|||
},
|
||||
{
|
||||
category: "CONDIMENTS_SAUCES",
|
||||
defaultDiets: ["Végétarien", "Végan", "Pescétarien"],
|
||||
items: [
|
||||
{ name: "Sel", icon: "🧂", allergenNames: [] },
|
||||
{ name: "Sucre", icon: "🍬", allergenNames: [] },
|
||||
|
|
@ -307,20 +338,35 @@ const INGREDIENT_GROUPS: Array<{ category: IngredientCategory; items: Ingredient
|
|||
{ name: "Sauce soja", icon: "🍶", allergenNames: ["Soja"] },
|
||||
{ name: "Tofu", icon: "🧊", allergenNames: ["Soja"] },
|
||||
{ name: "Moutarde", icon: "🟡", allergenNames: ["Moutarde"] },
|
||||
{ name: "Mayonnaise", icon: "🫙", allergenNames: ["Œufs"] },
|
||||
{
|
||||
name: "Mayonnaise",
|
||||
icon: "🫙",
|
||||
allergenNames: ["Œufs"],
|
||||
dietNames: ["Végétarien", "Pescétarien"],
|
||||
},
|
||||
{ name: "Ketchup", icon: "🫙", allergenNames: [] },
|
||||
{ name: "Miel", 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", icon: "🫙", allergenNames: ["Poissons"] },
|
||||
{ name: "Sauce nuoc-mâm", icon: "🫙", allergenNames: ["Poissons"] },
|
||||
{
|
||||
name: "Sauce Worcestershire",
|
||||
icon: "🫙",
|
||||
allergenNames: ["Poissons"],
|
||||
dietNames: ["Pescétarien"],
|
||||
},
|
||||
{
|
||||
name: "Sauce nuoc-mâm",
|
||||
icon: "🫙",
|
||||
allergenNames: ["Poissons"],
|
||||
dietNames: ["Pescétarien"],
|
||||
},
|
||||
{ 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"] },
|
||||
{ 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: [] },
|
||||
|
|
@ -338,34 +384,81 @@ const INGREDIENT_GROUPS: Array<{ category: IngredientCategory; items: Ingredient
|
|||
{ name: "Moutarde de Dijon", icon: "🟡", allergenNames: ["Moutarde"] },
|
||||
{ name: "Moutarde à l'ancienne", icon: "🟡", allergenNames: ["Moutarde"] },
|
||||
{ name: "Sauce barbecue", icon: "🫙", allergenNames: [] },
|
||||
{ name: "Sauce tartare", icon: "🫙", allergenNames: ["Œufs"] },
|
||||
{ name: "Sauce cocktail", icon: "🫙", allergenNames: ["Œufs"] },
|
||||
{ name: "Sauce béarnaise", icon: "🫙", allergenNames: ["Œufs", "Lait"] },
|
||||
{ name: "Sauce hollandaise", icon: "🫙", allergenNames: ["Œufs", "Lait"] },
|
||||
{ name: "Sauce béchamel", icon: "🫙", allergenNames: ["Lait", "Gluten"] },
|
||||
{
|
||||
name: "Sauce tartare",
|
||||
icon: "🫙",
|
||||
allergenNames: ["Œufs"],
|
||||
dietNames: ["Végétarien", "Pescétarien"],
|
||||
},
|
||||
{
|
||||
name: "Sauce cocktail",
|
||||
icon: "🫙",
|
||||
allergenNames: ["Œufs"],
|
||||
dietNames: ["Végétarien", "Pescétarien"],
|
||||
},
|
||||
{
|
||||
name: "Sauce béarnaise",
|
||||
icon: "🫙",
|
||||
allergenNames: ["Œufs", "Lait"],
|
||||
dietNames: ["Végétarien", "Pescétarien"],
|
||||
},
|
||||
{
|
||||
name: "Sauce hollandaise",
|
||||
icon: "🫙",
|
||||
allergenNames: ["Œufs", "Lait"],
|
||||
dietNames: ["Végétarien", "Pescétarien"],
|
||||
},
|
||||
{
|
||||
name: "Sauce béchamel",
|
||||
icon: "🫙",
|
||||
allergenNames: ["Lait", "Gluten"],
|
||||
dietNames: ["Végétarien", "Pescétarien"],
|
||||
},
|
||||
{ name: "Sauce teriyaki", icon: "🫙", allergenNames: ["Soja"] },
|
||||
{ name: "Sauce ponzu", icon: "🫙", allergenNames: ["Soja", "Poissons"] },
|
||||
{
|
||||
name: "Sauce ponzu",
|
||||
icon: "🫙",
|
||||
allergenNames: ["Soja", "Poissons"],
|
||||
dietNames: ["Pescétarien"],
|
||||
},
|
||||
{ name: "Chimichurri", icon: "🌿", allergenNames: [] },
|
||||
{
|
||||
name: "Pesto rouge (tomates séchées)",
|
||||
icon: "🫙",
|
||||
allergenNames: ["Lait", "Fruits à coque"],
|
||||
dietNames: ["Végétarien", "Pescétarien"],
|
||||
},
|
||||
{ name: "Tomates séchées", icon: "🍅", allergenNames: [] },
|
||||
{ name: "Fond de veau", icon: "🫙", allergenNames: [] },
|
||||
{ name: "Fond de volaille", icon: "🫙", allergenNames: [] },
|
||||
{ name: "Bouillon cube bœuf", icon: "🫙", allergenNames: ["Céleri"] },
|
||||
{ name: "Bouillon cube poisson", icon: "🫙", allergenNames: ["Poissons", "Céleri"] },
|
||||
{ name: "Fond de veau", icon: "🫙", allergenNames: [], dietNames: [] },
|
||||
{ name: "Fond de volaille", icon: "🫙", allergenNames: [], dietNames: [] },
|
||||
{ name: "Bouillon cube bœuf", icon: "🫙", allergenNames: ["Céleri"], dietNames: [] },
|
||||
{
|
||||
name: "Bouillon cube poisson",
|
||||
icon: "🫙",
|
||||
allergenNames: ["Poissons", "Céleri"],
|
||||
dietNames: ["Pescétarien"],
|
||||
},
|
||||
{ name: "Bouillon de légumes", icon: "🫙", allergenNames: ["Céleri"] },
|
||||
{ name: "Bouillon de volaille", icon: "🫙", allergenNames: ["Céleri"] },
|
||||
{ name: "Bouillon de bœuf", icon: "🫙", allergenNames: ["Céleri"] },
|
||||
{ name: "Bouillon de volaille", icon: "🫙", allergenNames: ["Céleri"], dietNames: [] },
|
||||
{ name: "Bouillon de bœuf", icon: "🫙", allergenNames: ["Céleri"], dietNames: [] },
|
||||
{ name: "Court-bouillon", icon: "🫙", allergenNames: [] },
|
||||
{ name: "Dashi (bouillon japonais)", icon: "🫙", allergenNames: ["Poissons"] },
|
||||
{ name: "Bisque de crustacés", icon: "🫙", allergenNames: ["Crustacés"] },
|
||||
{
|
||||
name: "Dashi (bouillon japonais)",
|
||||
icon: "🫙",
|
||||
allergenNames: ["Poissons"],
|
||||
dietNames: ["Pescétarien"],
|
||||
},
|
||||
{
|
||||
name: "Bisque de crustacés",
|
||||
icon: "🫙",
|
||||
allergenNames: ["Crustacés"],
|
||||
dietNames: ["Pescétarien"],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
category: "EPICES_HERBES",
|
||||
defaultDiets: ["Végétarien", "Végan", "Pescétarien"],
|
||||
items: [
|
||||
{ name: "Basilic", icon: "🌿", allergenNames: [] },
|
||||
{ name: "Persil", icon: "🌿", allergenNames: [] },
|
||||
|
|
@ -418,21 +511,36 @@ const INGREDIENT_GROUPS: Array<{ category: IngredientCategory; items: Ingredient
|
|||
},
|
||||
{
|
||||
category: "SUCRE_PATISSERIE",
|
||||
defaultDiets: ["Végétarien", "Végan", "Pescétarien"],
|
||||
items: [
|
||||
{ name: "Sucre roux", icon: "🍬", allergenNames: [] },
|
||||
{ name: "Sucre glace", icon: "🍬", allergenNames: [] },
|
||||
{ name: "Cassonade", icon: "🍬", allergenNames: [] },
|
||||
{ name: "Chocolat noir", icon: "🍫", allergenNames: [] },
|
||||
{ name: "Chocolat au lait", icon: "🍫", allergenNames: ["Lait"] },
|
||||
{ name: "Chocolat blanc", icon: "🍫", allergenNames: ["Lait"] },
|
||||
{
|
||||
name: "Chocolat au lait",
|
||||
icon: "🍫",
|
||||
allergenNames: ["Lait"],
|
||||
dietNames: ["Végétarien", "Pescétarien"],
|
||||
},
|
||||
{
|
||||
name: "Chocolat blanc",
|
||||
icon: "🍫",
|
||||
allergenNames: ["Lait"],
|
||||
dietNames: ["Végétarien", "Pescétarien"],
|
||||
},
|
||||
{ name: "Pépites de chocolat", icon: "🍫", allergenNames: [] },
|
||||
{ name: "Cacao en poudre", icon: "🍫", allergenNames: [] },
|
||||
{ name: "Gélatine", 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"] },
|
||||
|
|
@ -440,15 +548,40 @@ const INGREDIENT_GROUPS: Array<{ category: IngredientCategory; items: Ingredient
|
|||
{ name: "Lasagnes (feuilles)", icon: "🍝", allergenNames: ["Gluten"] },
|
||||
{ name: "Gnocchi", icon: "🍝", allergenNames: ["Gluten"] },
|
||||
{ name: "Riz arborio", icon: "🍚", allergenNames: [] },
|
||||
{ name: "Burrata", icon: "🧀", allergenNames: ["Lait"] },
|
||||
{ name: "Ricotta", icon: "🧀", allergenNames: ["Lait"] },
|
||||
{ name: "Pecorino", icon: "🧀", allergenNames: ["Lait"] },
|
||||
{ name: "Gorgonzola", icon: "🧀", allergenNames: ["Lait"] },
|
||||
{ name: "Prosciutto", icon: "🍖", allergenNames: [] },
|
||||
{ name: "Pancetta", icon: "🥓", allergenNames: [] },
|
||||
{ name: "Mortadelle", icon: "🍖", allergenNames: [] },
|
||||
{ name: "Salami", icon: "🍖", allergenNames: [] },
|
||||
{ name: "Pesto", icon: "🫙", allergenNames: ["Lait", "Fruits à coque"] },
|
||||
{
|
||||
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"] },
|
||||
|
|
@ -456,8 +589,14 @@ const INGREDIENT_GROUPS: Array<{ category: IngredientCategory; items: Ingredient
|
|||
},
|
||||
{
|
||||
category: "CUISINE_ASIATIQUE",
|
||||
defaultDiets: ["Végétarien", "Végan", "Pescétarien"],
|
||||
items: [
|
||||
{ name: "Sauce huître", icon: "🫙", allergenNames: ["Mollusques"] },
|
||||
{
|
||||
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: [] },
|
||||
|
|
@ -496,14 +635,25 @@ const INGREDIENT_GROUPS: Array<{ category: IngredientCategory; items: Ingredient
|
|||
{ name: "Sucre de palme", icon: "🍬", allergenNames: [] },
|
||||
{ name: "Pousses de bambou", allergenNames: [] },
|
||||
{ name: "Châtaignes d'eau", allergenNames: [] },
|
||||
{ name: "Poisson séché", icon: "🐟", allergenNames: ["Poissons"] },
|
||||
{ name: "Pâte de crevettes", icon: "🫙", allergenNames: ["Crustacés"] },
|
||||
{
|
||||
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"] },
|
||||
|
|
@ -513,11 +663,17 @@ const INGREDIENT_GROUPS: Array<{ category: IngredientCategory; items: Ingredient
|
|||
{ name: "Piment poblano", icon: "🌶️", allergenNames: [] },
|
||||
{ name: "Piment habanero", icon: "🌶️", allergenNames: [] },
|
||||
{ name: "Masa harina", icon: "🌽", allergenNames: [] },
|
||||
{ name: "Cheddar", icon: "🧀", allergenNames: ["Lait"] },
|
||||
{
|
||||
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"] },
|
||||
|
|
@ -530,15 +686,31 @@ const INGREDIENT_GROUPS: Array<{ category: IngredientCategory; items: Ingredient
|
|||
// 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"] },
|
||||
{ name: "Pain brioché", icon: "🍞", allergenNames: ["Gluten", "Lait", "Œufs"] },
|
||||
{
|
||||
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"] },
|
||||
{
|
||||
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"] },
|
||||
|
|
@ -550,6 +722,7 @@ const INGREDIENT_GROUPS: Array<{ category: IngredientCategory; items: Ingredient
|
|||
},
|
||||
{
|
||||
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: [] },
|
||||
|
|
@ -557,6 +730,7 @@ const INGREDIENT_GROUPS: Array<{ category: IngredientCategory; items: Ingredient
|
|||
},
|
||||
{
|
||||
category: "LIQUIDES_BOISSONS",
|
||||
defaultDiets: ["Végétarien", "Végan", "Pescétarien"],
|
||||
items: [
|
||||
{ name: "Eau", icon: "💧", allergenNames: [] },
|
||||
{ name: "Eau gazeuse", icon: "💧", allergenNames: [] },
|
||||
|
|
@ -581,13 +755,27 @@ const INGREDIENT_GROUPS: Array<{ category: IngredientCategory; items: Ingredient
|
|||
{ name: "Whisky", icon: "🥃", allergenNames: [] },
|
||||
{ name: "Vodka", icon: "🥃", allergenNames: [] },
|
||||
{ name: "Sirop de sucre de canne", icon: "🫙", allergenNames: [] },
|
||||
{ name: "Fumet de poisson", icon: "🫙", allergenNames: ["Poissons"] },
|
||||
{
|
||||
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<IngredientSeed & { category: IngredientCategory }> =
|
||||
INGREDIENT_GROUPS.flatMap(({ category, items }) => items.map((item) => ({ ...item, category })));
|
||||
const INGREDIENTS: Array<IngredientSeed & { category: IngredientCategory; dietNames: string[] }> =
|
||||
INGREDIENT_GROUPS.flatMap(({ category, defaultDiets, items }) =>
|
||||
items.map((item) => ({ ...item, category, dietNames: item.dietNames ?? defaultDiets })),
|
||||
);
|
||||
|
||||
/**
|
||||
* Populates the `Diet`/`Category`/`Allergy` reference tables. Idempotent
|
||||
|
|
@ -675,4 +863,23 @@ export async function seedReferenceData(prisma: PrismaClient): Promise<void> {
|
|||
if (links.length > 0) {
|
||||
await prisma.ingredientAllergy.createMany({ data: links, skipDuplicates: true });
|
||||
}
|
||||
|
||||
// Same bulk-insert approach as the allergy links above, resolved against
|
||||
// `dietNames` (item override, falling back to its group's `defaultDiets`
|
||||
// in the `INGREDIENTS` flatten step) instead of `allergenNames`.
|
||||
const diets = await prisma.diet.findMany();
|
||||
const dietIdByName = new Map(diets.map((d) => [d.name, d.id]));
|
||||
|
||||
const dietLinks: Array<{ ingredientId: number; dietId: number }> = [];
|
||||
for (const { name, dietNames } of INGREDIENTS) {
|
||||
const ingredientId = ingredientIdByName.get(name);
|
||||
if (ingredientId === undefined) continue;
|
||||
for (const dietName of dietNames) {
|
||||
const dietId = dietIdByName.get(dietName);
|
||||
if (dietId !== undefined) dietLinks.push({ ingredientId, dietId });
|
||||
}
|
||||
}
|
||||
if (dietLinks.length > 0) {
|
||||
await prisma.ingredientDiet.createMany({ data: dietLinks, skipDuplicates: true });
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -19,7 +19,10 @@ function recipeInclude(viewerId: number) {
|
|||
ingredients: {
|
||||
include: {
|
||||
ingredient: {
|
||||
include: { allergies: { include: { allergy: { include: { category: true } } } } },
|
||||
include: {
|
||||
allergies: { include: { allergy: { include: { category: true } } } },
|
||||
diets: { include: { diet: true } },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
|
|
@ -30,10 +33,10 @@ function recipeInclude(viewerId: number) {
|
|||
}
|
||||
|
||||
type RecipeWithDetails = Prisma.RecipeGetPayload<{ include: ReturnType<typeof recipeInclude> }>;
|
||||
type IngredientWithAllergies = RecipeWithDetails["ingredients"][number]["ingredient"];
|
||||
type IngredientWithDetails = RecipeWithDetails["ingredients"][number]["ingredient"];
|
||||
|
||||
/** Shapes a Prisma `Ingredient` (with its `allergies` relation included) into the public {@link IngredientView} — same aplattening as `reference.service.ts`'s `getIngredients`. */
|
||||
function toIngredientView(ingredient: IngredientWithAllergies): IngredientView {
|
||||
/** Shapes a Prisma `Ingredient` (with its `allergies`/`diets` relations included) into the public {@link IngredientView} — same aplattening as `reference.service.ts`'s `getIngredients`. */
|
||||
function toIngredientView(ingredient: IngredientWithDetails): IngredientView {
|
||||
return {
|
||||
id: ingredient.id,
|
||||
name: ingredient.name,
|
||||
|
|
@ -44,6 +47,7 @@ function toIngredientView(ingredient: IngredientWithAllergies): IngredientView {
|
|||
name: allergy.category.name,
|
||||
kind: allergy.category.kind,
|
||||
})),
|
||||
diets: ingredient.diets.map(({ diet }) => ({ id: diet.id, name: diet.name })),
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -26,14 +26,16 @@ export async function getAllergies(): Promise<AllergyView[]> {
|
|||
|
||||
/**
|
||||
* All reference ingredients, alphabetically, each resolved to its allergens
|
||||
* (see `IngredientAllergy` in schema.prisma) — same aplattening approach as
|
||||
* {@link getAllergies}. Ingredients with no linked allergen come back with
|
||||
* `allergens: []`.
|
||||
* (see `IngredientAllergy` in schema.prisma) and compatible diet regimes
|
||||
* (see `IngredientDiet`) — same aplattening approach as {@link getAllergies}.
|
||||
* Ingredients with no linked allergen/diet come back with `allergens: []`/
|
||||
* `diets: []`.
|
||||
*/
|
||||
export async function getIngredients(): Promise<IngredientView[]> {
|
||||
const ingredients = await prisma.ingredient.findMany({
|
||||
include: {
|
||||
allergies: { include: { allergy: { include: { category: true } } } },
|
||||
diets: { include: { diet: true } },
|
||||
},
|
||||
orderBy: { name: "asc" },
|
||||
});
|
||||
|
|
@ -47,5 +49,6 @@ export async function getIngredients(): Promise<IngredientView[]> {
|
|||
name: allergy.category.name,
|
||||
kind: allergy.category.kind,
|
||||
})),
|
||||
diets: ingredient.diets.map(({ diet }) => ({ id: diet.id, name: diet.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", "category", "allergens"]);
|
||||
expect(res.body[0]).to.have.keys(["id", "name", "icon", "category", "allergens", "diets"]);
|
||||
});
|
||||
|
||||
it("resolves each ingredient's linked allergens, empty for one with none", async () => {
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ import {
|
|||
import { useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
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). */
|
||||
|
|
@ -100,6 +101,7 @@ export function IngredientPicker({
|
|||
</span>
|
||||
<span className="ingredient-picker__card-name">{ingredient.name}</span>
|
||||
<AllergenBadges allergens={ingredient.allergens} />
|
||||
<DietBadges diets={ingredient.diets} />
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import type { IngredientView } from "@batch-cooking/shared";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { AllergenBadges } from "./AllergenBadges";
|
||||
import { DietBadges } from "./DietBadges";
|
||||
import "./recipes.scss";
|
||||
|
||||
/** 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. */
|
||||
|
|
@ -45,6 +46,7 @@ export function IngredientRow({
|
|||
aria-label={t("recipes.form.unitLabel")}
|
||||
/>
|
||||
<AllergenBadges allergens={ingredient.allergens} />
|
||||
<DietBadges diets={ingredient.diets} />
|
||||
<button
|
||||
type="button"
|
||||
className="ingredient-row__remove"
|
||||
|
|
|
|||
|
|
@ -889,6 +889,12 @@
|
|||
// ingredient picker but without quantity/unit — see
|
||||
// `features/profile/DislikedIngredientsField.tsx`.
|
||||
.disliked-ingredients-field {
|
||||
// `<fieldset>` carries a browser-default `min-width: min-content` that
|
||||
// ordinary block elements don't — it refuses to shrink for its content,
|
||||
// so `.ingredient-picker__grid` below (a `repeat(auto-fill, …)` grid)
|
||||
// never gets narrow enough to wrap and blows out the whole page's width
|
||||
// instead. Reset it back to the normal shrinkable behavior.
|
||||
min-width: 0;
|
||||
border: none;
|
||||
padding: 0;
|
||||
margin: var(--space-md) 0 0;
|
||||
|
|
|
|||
|
|
@ -72,9 +72,15 @@ export type IngredientCategory = (typeof INGREDIENT_CATEGORIES)[number];
|
|||
*
|
||||
* `allergens` is resolved server-side from the `IngredientAllergy` join
|
||||
* table — empty for an ingredient that carries none of the 14 EU-regulated
|
||||
* allergens. Used by the recipe catalog (`apps/web`'s recipe form and detail
|
||||
* page) to pick ingredients and to surface which allergens a recipe
|
||||
* contains, aggregated across its ingredients.
|
||||
* allergens. `diets` is resolved from `IngredientDiet` the same way — the
|
||||
* regimes this ingredient is compatible with (e.g. `Végétarien`, `Végan`),
|
||||
* so the picker can flag it without the user opening its packaging. Omits
|
||||
* `Omnivore` (every ingredient qualifies, so it's never stored) and
|
||||
* `Sans gluten` (already derivable from whether `allergens` contains
|
||||
* `Gluten` — see `IngredientDiet` in schema.prisma). Used by the recipe
|
||||
* catalog (`apps/web`'s recipe form and detail page) to pick ingredients and
|
||||
* to surface which allergens/regimes a recipe contains, aggregated across
|
||||
* its ingredients.
|
||||
*/
|
||||
export interface IngredientView {
|
||||
id: number;
|
||||
|
|
@ -82,4 +88,5 @@ export interface IngredientView {
|
|||
icon: string | null;
|
||||
category: IngredientCategory;
|
||||
allergens: AllergyView[];
|
||||
diets: DietView[];
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue