feat(recipes): catégories d'ingrédients + nouveau sélecteur
- Prisma: enum IngredientCategory (18 valeurs) + Ingredient.category, migration appliquée - reference-seed-data.ts restructuré en 18 groupes de catégories - packages/shared: INGREDIENT_CATEGORIES + IngredientView.category - API: category exposé par /reference/ingredients et /recipes - Web: nouveau IngredientPicker (chips catégories + recherche + grille de cartes) remplaçant IngredientAutocomplete, branché dans le formulaire de recette et le champ aliments-pas-aimés - i18n: clés recipes.form.category.* et libellés du picker - Fix test reference.test.ts pour la nouvelle forme d'IngredientView Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
parent
acab18ac4a
commit
ea86a4a7f1
15 changed files with 954 additions and 621 deletions
|
|
@ -0,0 +1,6 @@
|
||||||
|
-- CreateEnum
|
||||||
|
CREATE TYPE "IngredientCategory" AS ENUM ('CEREALES_FECULENTS', 'LEGUMINEUSES', 'VIANDES_VOLAILLES', 'POISSONS_FRUITS_DE_MER', 'PRODUITS_LAITIERS_OEUFS', 'LEGUMES', 'FRUITS', 'FRUITS_SECS_OLEAGINEUX', 'CONDIMENTS_SAUCES', 'EPICES_HERBES', 'SUCRE_PATISSERIE', 'CUISINE_ITALIENNE', 'CUISINE_ASIATIQUE', 'CUISINE_MEXICAINE', 'MAGHREB_MOYEN_ORIENT', 'PAINS_SANDWICHS', 'EPICERIE_DIVERS', 'LIQUIDES_BOISSONS');
|
||||||
|
|
||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "ingredients" ADD COLUMN "category" "IngredientCategory" NOT NULL DEFAULT 'EPICERIE_DIVERS';
|
||||||
|
|
||||||
|
|
@ -292,10 +292,43 @@ model RecipeDiet {
|
||||||
/// idempotent/safe to re-run, same reason as `Diet.name`/`Category.name`.
|
/// idempotent/safe to re-run, same reason as `Diet.name`/`Category.name`.
|
||||||
/// Ingredients are reference data (like Diet/Allergy): seeded, never
|
/// Ingredients are reference data (like Diet/Allergy): seeded, never
|
||||||
/// created/edited/deleted through the API.
|
/// created/edited/deleted through the API.
|
||||||
|
/// Not in the original spec doc — coarse grouping (viandes, légumes,
|
||||||
|
/// épices...) so the ingredient picker (apps/web) can offer category
|
||||||
|
/// browsing, not just free-text search: with 400+ reference ingredients,
|
||||||
|
/// search alone doesn't scale to actually *finding* one. Mirrors
|
||||||
|
/// `reference-seed-data.ts`'s `INGREDIENT_GROUPS` keys exactly — that file
|
||||||
|
/// is the single source of truth for which ingredient belongs to which
|
||||||
|
/// category, this enum just gives it a type-safe column to live in.
|
||||||
|
/// `@default(EPICERIE_DIVERS)` exists only so this column can be added
|
||||||
|
/// `NOT NULL` to a table that may already have rows — the seed script
|
||||||
|
/// corrects every row's real category on the very next run, this default
|
||||||
|
/// is never the intended value for a real ingredient.
|
||||||
|
enum IngredientCategory {
|
||||||
|
CEREALES_FECULENTS
|
||||||
|
LEGUMINEUSES
|
||||||
|
VIANDES_VOLAILLES
|
||||||
|
POISSONS_FRUITS_DE_MER
|
||||||
|
PRODUITS_LAITIERS_OEUFS
|
||||||
|
LEGUMES
|
||||||
|
FRUITS
|
||||||
|
FRUITS_SECS_OLEAGINEUX
|
||||||
|
CONDIMENTS_SAUCES
|
||||||
|
EPICES_HERBES
|
||||||
|
SUCRE_PATISSERIE
|
||||||
|
CUISINE_ITALIENNE
|
||||||
|
CUISINE_ASIATIQUE
|
||||||
|
CUISINE_MEXICAINE
|
||||||
|
MAGHREB_MOYEN_ORIENT
|
||||||
|
PAINS_SANDWICHS
|
||||||
|
EPICERIE_DIVERS
|
||||||
|
LIQUIDES_BOISSONS
|
||||||
|
}
|
||||||
|
|
||||||
model Ingredient {
|
model Ingredient {
|
||||||
id Int @id @default(autoincrement())
|
id Int @id @default(autoincrement())
|
||||||
name String @unique
|
name String @unique
|
||||||
icon String?
|
icon String?
|
||||||
|
category IngredientCategory @default(EPICERIE_DIVERS)
|
||||||
alternateRecipeId Int? @map("alternate_recipe")
|
alternateRecipeId Int? @map("alternate_recipe")
|
||||||
|
|
||||||
alternateRecipe Recipe? @relation("IngredientAlternateRecipe", fields: [alternateRecipeId], references: [id], onDelete: SetNull)
|
alternateRecipe Recipe? @relation("IngredientAlternateRecipe", fields: [alternateRecipeId], references: [id], onDelete: SetNull)
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import type { AllergenKind, PrismaClient } from "@prisma/client";
|
import type { AllergenKind, IngredientCategory, PrismaClient } from "@prisma/client";
|
||||||
|
|
||||||
// Short, optional-to-pick regime list — `UserProfile.dietId` stays
|
// Short, optional-to-pick regime list — `UserProfile.dietId` stays
|
||||||
// nullable, this is not meant to be exhaustive.
|
// nullable, this is not meant to be exhaustive.
|
||||||
|
|
@ -28,6 +28,12 @@ const ALLERGENS: Array<{ name: string; kind: AllergenKind }> = [
|
||||||
{ name: "Mollusques", kind: "ALLERGY" },
|
{ name: "Mollusques", kind: "ALLERGY" },
|
||||||
];
|
];
|
||||||
|
|
||||||
|
interface IngredientSeed {
|
||||||
|
name: string;
|
||||||
|
icon?: string;
|
||||||
|
allergenNames: string[];
|
||||||
|
}
|
||||||
|
|
||||||
// A broad pantry list — the goal is to cover the large majority of what a
|
// A broad pantry list — the goal is to cover the large majority of what a
|
||||||
// home cook reaches for (viandes, poissons, légumes, fruits, féculents,
|
// home cook reaches for (viandes, poissons, légumes, fruits, féculents,
|
||||||
// condiments, épices...), not just enough to exercise the recipe catalog in
|
// condiments, épices...), not just enough to exercise the recipe catalog in
|
||||||
|
|
@ -39,8 +45,17 @@ const ALLERGENS: Array<{ name: string; kind: AllergenKind }> = [
|
||||||
// covered by at least one ingredient here. `icon` is left unset (rather than
|
// 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
|
// 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`).
|
||||||
const INGREDIENTS: Array<{ name: string; icon?: string; allergenNames: string[] }> = [
|
//
|
||||||
// --- Céréales, farines & féculents ---------------------------------------
|
// Grouped by `category` (mirrors `IngredientCategory` in schema.prisma) so
|
||||||
|
// the picker UI (`apps/web`'s `IngredientPicker`) can offer category
|
||||||
|
// chips/browsing, not just free-text search — with 400+ ingredients, search
|
||||||
|
// alone isn't enough to actually *find* something. Each group's key is the
|
||||||
|
// single source of truth for that mapping; `INGREDIENTS` below just flattens
|
||||||
|
// it back to one array for the seeding loop.
|
||||||
|
const INGREDIENT_GROUPS: Array<{ category: IngredientCategory; items: IngredientSeed[] }> = [
|
||||||
|
{
|
||||||
|
category: "CEREALES_FECULENTS",
|
||||||
|
items: [
|
||||||
{ name: "Farine de blé", icon: "🌾", allergenNames: ["Gluten"] },
|
{ name: "Farine de blé", icon: "🌾", allergenNames: ["Gluten"] },
|
||||||
{ name: "Farine complète", icon: "🌾", allergenNames: ["Gluten"] },
|
{ name: "Farine complète", icon: "🌾", allergenNames: ["Gluten"] },
|
||||||
{ name: "Farine de maïs", icon: "🌽", allergenNames: [] },
|
{ name: "Farine de maïs", icon: "🌽", allergenNames: [] },
|
||||||
|
|
@ -68,8 +83,11 @@ const INGREDIENTS: Array<{ name: string; icon?: string; allergenNames: string[]
|
||||||
{ name: "Levure boulangère", icon: "🫙", allergenNames: [] },
|
{ name: "Levure boulangère", icon: "🫙", allergenNames: [] },
|
||||||
{ name: "Levure chimique", icon: "🫙", allergenNames: [] },
|
{ name: "Levure chimique", icon: "🫙", allergenNames: [] },
|
||||||
{ name: "Maïzena", icon: "🫙", allergenNames: [] },
|
{ name: "Maïzena", icon: "🫙", allergenNames: [] },
|
||||||
|
],
|
||||||
// --- Légumineuses ---------------------------------------------------------
|
},
|
||||||
|
{
|
||||||
|
category: "LEGUMINEUSES",
|
||||||
|
items: [
|
||||||
{ name: "Lentilles vertes", icon: "🫘", allergenNames: [] },
|
{ name: "Lentilles vertes", icon: "🫘", allergenNames: [] },
|
||||||
{ name: "Lentilles corail", icon: "🫘", allergenNames: [] },
|
{ name: "Lentilles corail", icon: "🫘", allergenNames: [] },
|
||||||
{ name: "Pois chiches", icon: "🫘", allergenNames: [] },
|
{ name: "Pois chiches", icon: "🫘", allergenNames: [] },
|
||||||
|
|
@ -79,8 +97,11 @@ const INGREDIENTS: Array<{ name: string; icon?: string; allergenNames: string[]
|
||||||
{ name: "Pois cassés", icon: "🫘", allergenNames: [] },
|
{ name: "Pois cassés", icon: "🫘", allergenNames: [] },
|
||||||
{ name: "Fèves", icon: "🫘", allergenNames: [] },
|
{ name: "Fèves", icon: "🫘", allergenNames: [] },
|
||||||
{ name: "Edamame", icon: "🫛", allergenNames: ["Soja"] },
|
{ name: "Edamame", icon: "🫛", allergenNames: ["Soja"] },
|
||||||
|
],
|
||||||
// --- Viandes & volailles ---------------------------------------------------
|
},
|
||||||
|
{
|
||||||
|
category: "VIANDES_VOLAILLES",
|
||||||
|
items: [
|
||||||
{ name: "Poulet", icon: "🍗", allergenNames: [] },
|
{ name: "Poulet", icon: "🍗", allergenNames: [] },
|
||||||
{ name: "Dinde", icon: "🍗", allergenNames: [] },
|
{ name: "Dinde", icon: "🍗", allergenNames: [] },
|
||||||
{ name: "Canard", icon: "🦆", allergenNames: [] },
|
{ name: "Canard", icon: "🦆", allergenNames: [] },
|
||||||
|
|
@ -101,8 +122,11 @@ const INGREDIENTS: Array<{ name: string; icon?: string; allergenNames: string[]
|
||||||
{ name: "Saucisse", icon: "🌭", allergenNames: [] },
|
{ name: "Saucisse", icon: "🌭", allergenNames: [] },
|
||||||
{ name: "Chorizo", icon: "🌭", allergenNames: [] },
|
{ name: "Chorizo", icon: "🌭", allergenNames: [] },
|
||||||
{ name: "Merguez", icon: "🌭", allergenNames: [] },
|
{ name: "Merguez", icon: "🌭", allergenNames: [] },
|
||||||
|
],
|
||||||
// --- Poissons & fruits de mer -----------------------------------------------
|
},
|
||||||
|
{
|
||||||
|
category: "POISSONS_FRUITS_DE_MER",
|
||||||
|
items: [
|
||||||
{ name: "Saumon", icon: "🐟", allergenNames: ["Poissons"] },
|
{ name: "Saumon", icon: "🐟", allergenNames: ["Poissons"] },
|
||||||
{ name: "Thon", icon: "🐟", allergenNames: ["Poissons"] },
|
{ name: "Thon", icon: "🐟", allergenNames: ["Poissons"] },
|
||||||
{ name: "Cabillaud", icon: "🐟", allergenNames: ["Poissons"] },
|
{ name: "Cabillaud", icon: "🐟", allergenNames: ["Poissons"] },
|
||||||
|
|
@ -144,8 +168,11 @@ const INGREDIENTS: Array<{ name: string; icon?: string; allergenNames: string[]
|
||||||
{ name: "Poulpe", icon: "🐙", allergenNames: ["Mollusques"] },
|
{ name: "Poulpe", icon: "🐙", allergenNames: ["Mollusques"] },
|
||||||
{ name: "Palourdes", icon: "🦪", allergenNames: ["Mollusques"] },
|
{ name: "Palourdes", icon: "🦪", allergenNames: ["Mollusques"] },
|
||||||
{ name: "Bulots", icon: "🐚", allergenNames: ["Mollusques"] },
|
{ name: "Bulots", icon: "🐚", allergenNames: ["Mollusques"] },
|
||||||
|
],
|
||||||
// --- Produits laitiers & œufs -----------------------------------------------
|
},
|
||||||
|
{
|
||||||
|
category: "PRODUITS_LAITIERS_OEUFS",
|
||||||
|
items: [
|
||||||
{ name: "Œuf", icon: "🥚", allergenNames: ["Œufs"] },
|
{ name: "Œuf", icon: "🥚", allergenNames: ["Œufs"] },
|
||||||
{ name: "Lait", icon: "🥛", allergenNames: ["Lait"] },
|
{ name: "Lait", icon: "🥛", allergenNames: ["Lait"] },
|
||||||
{ name: "Beurre", icon: "🧈", allergenNames: ["Lait"] },
|
{ name: "Beurre", icon: "🧈", allergenNames: ["Lait"] },
|
||||||
|
|
@ -166,8 +193,11 @@ const INGREDIENTS: Array<{ name: string; icon?: string; allergenNames: string[]
|
||||||
{ name: "Crème de coco", icon: "🥥", allergenNames: [] },
|
{ name: "Crème de coco", icon: "🥥", allergenNames: [] },
|
||||||
{ name: "Lait d'amande", icon: "🥛", allergenNames: ["Fruits à coque"] },
|
{ name: "Lait d'amande", icon: "🥛", allergenNames: ["Fruits à coque"] },
|
||||||
{ name: "Lait d'avoine", icon: "🥛", allergenNames: ["Gluten"] },
|
{ name: "Lait d'avoine", icon: "🥛", allergenNames: ["Gluten"] },
|
||||||
|
],
|
||||||
// --- Légumes -----------------------------------------------------------------
|
},
|
||||||
|
{
|
||||||
|
category: "LEGUMES",
|
||||||
|
items: [
|
||||||
{ name: "Tomate", icon: "🍅", allergenNames: [] },
|
{ name: "Tomate", icon: "🍅", allergenNames: [] },
|
||||||
{ name: "Oignon", icon: "🧅", allergenNames: [] },
|
{ name: "Oignon", icon: "🧅", allergenNames: [] },
|
||||||
{ name: "Échalote", icon: "🧅", allergenNames: [] },
|
{ name: "Échalote", icon: "🧅", allergenNames: [] },
|
||||||
|
|
@ -206,8 +236,11 @@ const INGREDIENTS: Array<{ name: string; icon?: string; allergenNames: string[]
|
||||||
{ name: "Butternut", icon: "🎃", allergenNames: [] },
|
{ name: "Butternut", icon: "🎃", allergenNames: [] },
|
||||||
{ name: "Asperge", allergenNames: [] },
|
{ name: "Asperge", allergenNames: [] },
|
||||||
{ name: "Avocat", icon: "🥑", allergenNames: [] },
|
{ name: "Avocat", icon: "🥑", allergenNames: [] },
|
||||||
|
],
|
||||||
// --- Fruits ------------------------------------------------------------------
|
},
|
||||||
|
{
|
||||||
|
category: "FRUITS",
|
||||||
|
items: [
|
||||||
{ name: "Citron", icon: "🍋", allergenNames: [] },
|
{ name: "Citron", icon: "🍋", allergenNames: [] },
|
||||||
{ name: "Citron vert", icon: "🍋", allergenNames: [] },
|
{ name: "Citron vert", icon: "🍋", allergenNames: [] },
|
||||||
{ name: "Pomme", icon: "🍎", allergenNames: [] },
|
{ name: "Pomme", icon: "🍎", allergenNames: [] },
|
||||||
|
|
@ -236,8 +269,11 @@ const INGREDIENTS: Array<{ name: string; icon?: string; allergenNames: string[]
|
||||||
{ name: "Grenade", allergenNames: [] },
|
{ name: "Grenade", allergenNames: [] },
|
||||||
{ name: "Rhubarbe", allergenNames: [] },
|
{ name: "Rhubarbe", allergenNames: [] },
|
||||||
{ name: "Coing", allergenNames: [] },
|
{ name: "Coing", allergenNames: [] },
|
||||||
|
],
|
||||||
// --- Fruits secs & oléagineux ------------------------------------------------
|
},
|
||||||
|
{
|
||||||
|
category: "FRUITS_SECS_OLEAGINEUX",
|
||||||
|
items: [
|
||||||
{ name: "Cacahuètes", icon: "🥜", allergenNames: ["Arachides"] },
|
{ name: "Cacahuètes", icon: "🥜", allergenNames: ["Arachides"] },
|
||||||
{ name: "Amandes", icon: "🌰", allergenNames: ["Fruits à coque"] },
|
{ name: "Amandes", icon: "🌰", allergenNames: ["Fruits à coque"] },
|
||||||
{ name: "Noix", icon: "🌰", allergenNames: ["Fruits à coque"] },
|
{ name: "Noix", icon: "🌰", allergenNames: ["Fruits à coque"] },
|
||||||
|
|
@ -253,8 +289,11 @@ const INGREDIENTS: Array<{ name: string; icon?: string; allergenNames: string[]
|
||||||
{ name: "Raisins secs", icon: "🍇", allergenNames: ["Sulfites"] },
|
{ name: "Raisins secs", icon: "🍇", allergenNames: ["Sulfites"] },
|
||||||
{ name: "Pruneaux", allergenNames: ["Sulfites"] },
|
{ name: "Pruneaux", allergenNames: ["Sulfites"] },
|
||||||
{ name: "Abricots secs", icon: "🍑", allergenNames: ["Sulfites"] },
|
{ name: "Abricots secs", icon: "🍑", allergenNames: ["Sulfites"] },
|
||||||
|
],
|
||||||
// --- Condiments, sauces & huiles ----------------------------------------------
|
},
|
||||||
|
{
|
||||||
|
category: "CONDIMENTS_SAUCES",
|
||||||
|
items: [
|
||||||
{ name: "Sel", icon: "🧂", allergenNames: [] },
|
{ name: "Sel", icon: "🧂", allergenNames: [] },
|
||||||
{ name: "Sucre", icon: "🍬", allergenNames: [] },
|
{ name: "Sucre", icon: "🍬", allergenNames: [] },
|
||||||
{ name: "Huile d'olive", icon: "🫒", allergenNames: [] },
|
{ name: "Huile d'olive", icon: "🫒", allergenNames: [] },
|
||||||
|
|
@ -307,7 +346,11 @@ const INGREDIENTS: Array<{ name: string; icon?: string; allergenNames: string[]
|
||||||
{ name: "Sauce teriyaki", icon: "🫙", allergenNames: ["Soja"] },
|
{ name: "Sauce teriyaki", icon: "🫙", allergenNames: ["Soja"] },
|
||||||
{ name: "Sauce ponzu", icon: "🫙", allergenNames: ["Soja", "Poissons"] },
|
{ name: "Sauce ponzu", icon: "🫙", allergenNames: ["Soja", "Poissons"] },
|
||||||
{ name: "Chimichurri", icon: "🌿", allergenNames: [] },
|
{ name: "Chimichurri", icon: "🌿", allergenNames: [] },
|
||||||
{ name: "Pesto rouge (tomates séchées)", icon: "🫙", allergenNames: ["Lait", "Fruits à coque"] },
|
{
|
||||||
|
name: "Pesto rouge (tomates séchées)",
|
||||||
|
icon: "🫙",
|
||||||
|
allergenNames: ["Lait", "Fruits à coque"],
|
||||||
|
},
|
||||||
{ name: "Tomates séchées", icon: "🍅", allergenNames: [] },
|
{ name: "Tomates séchées", icon: "🍅", allergenNames: [] },
|
||||||
{ name: "Fond de veau", icon: "🫙", allergenNames: [] },
|
{ name: "Fond de veau", icon: "🫙", allergenNames: [] },
|
||||||
{ name: "Fond de volaille", icon: "🫙", allergenNames: [] },
|
{ name: "Fond de volaille", icon: "🫙", allergenNames: [] },
|
||||||
|
|
@ -319,8 +362,11 @@ const INGREDIENTS: Array<{ name: string; icon?: string; allergenNames: string[]
|
||||||
{ name: "Court-bouillon", icon: "🫙", allergenNames: [] },
|
{ name: "Court-bouillon", icon: "🫙", allergenNames: [] },
|
||||||
{ name: "Dashi (bouillon japonais)", icon: "🫙", allergenNames: ["Poissons"] },
|
{ name: "Dashi (bouillon japonais)", icon: "🫙", allergenNames: ["Poissons"] },
|
||||||
{ name: "Bisque de crustacés", icon: "🫙", allergenNames: ["Crustacés"] },
|
{ name: "Bisque de crustacés", icon: "🫙", allergenNames: ["Crustacés"] },
|
||||||
|
],
|
||||||
// --- Épices & herbes -----------------------------------------------------------
|
},
|
||||||
|
{
|
||||||
|
category: "EPICES_HERBES",
|
||||||
|
items: [
|
||||||
{ name: "Basilic", icon: "🌿", allergenNames: [] },
|
{ name: "Basilic", icon: "🌿", allergenNames: [] },
|
||||||
{ name: "Persil", icon: "🌿", allergenNames: [] },
|
{ name: "Persil", icon: "🌿", allergenNames: [] },
|
||||||
{ name: "Thym", icon: "🌿", allergenNames: [] },
|
{ name: "Thym", icon: "🌿", allergenNames: [] },
|
||||||
|
|
@ -368,8 +414,11 @@ const INGREDIENTS: Array<{ name: string; icon?: string; allergenNames: string[]
|
||||||
{ name: "Sel aux herbes", icon: "🧂", allergenNames: [] },
|
{ name: "Sel aux herbes", icon: "🧂", allergenNames: [] },
|
||||||
{ name: "Sel de céleri", icon: "🧂", allergenNames: ["Céleri"] },
|
{ name: "Sel de céleri", icon: "🧂", allergenNames: ["Céleri"] },
|
||||||
{ name: "Fleur de sel", icon: "🧂", allergenNames: [] },
|
{ name: "Fleur de sel", icon: "🧂", allergenNames: [] },
|
||||||
|
],
|
||||||
// --- Sucre & pâtisserie ---------------------------------------------------------
|
},
|
||||||
|
{
|
||||||
|
category: "SUCRE_PATISSERIE",
|
||||||
|
items: [
|
||||||
{ name: "Sucre roux", icon: "🍬", allergenNames: [] },
|
{ name: "Sucre roux", icon: "🍬", allergenNames: [] },
|
||||||
{ name: "Sucre glace", icon: "🍬", allergenNames: [] },
|
{ name: "Sucre glace", icon: "🍬", allergenNames: [] },
|
||||||
{ name: "Cassonade", icon: "🍬", allergenNames: [] },
|
{ name: "Cassonade", icon: "🍬", allergenNames: [] },
|
||||||
|
|
@ -380,8 +429,11 @@ const INGREDIENTS: Array<{ name: string; icon?: string; allergenNames: string[]
|
||||||
{ name: "Cacao en poudre", icon: "🍫", allergenNames: [] },
|
{ name: "Cacao en poudre", icon: "🍫", allergenNames: [] },
|
||||||
{ name: "Gélatine", icon: "🫙", allergenNames: [] },
|
{ name: "Gélatine", icon: "🫙", allergenNames: [] },
|
||||||
{ name: "Extrait de vanille", icon: "🌿", allergenNames: [] },
|
{ name: "Extrait de vanille", icon: "🌿", allergenNames: [] },
|
||||||
|
],
|
||||||
// --- Cuisine italienne ---------------------------------------------------------
|
},
|
||||||
|
{
|
||||||
|
category: "CUISINE_ITALIENNE",
|
||||||
|
items: [
|
||||||
{ name: "Spaghetti", icon: "🍝", allergenNames: ["Gluten"] },
|
{ name: "Spaghetti", icon: "🍝", allergenNames: ["Gluten"] },
|
||||||
{ name: "Penne", icon: "🍝", allergenNames: ["Gluten"] },
|
{ name: "Penne", icon: "🍝", allergenNames: ["Gluten"] },
|
||||||
{ name: "Tagliatelles", icon: "🍝", allergenNames: ["Gluten"] },
|
{ name: "Tagliatelles", icon: "🍝", allergenNames: ["Gluten"] },
|
||||||
|
|
@ -400,8 +452,11 @@ const INGREDIENTS: Array<{ name: string; icon?: string; allergenNames: string[]
|
||||||
{ name: "Tomates cerises", icon: "🍅", allergenNames: [] },
|
{ name: "Tomates cerises", icon: "🍅", allergenNames: [] },
|
||||||
{ name: "Focaccia", icon: "🍞", allergenNames: ["Gluten"] },
|
{ name: "Focaccia", icon: "🍞", allergenNames: ["Gluten"] },
|
||||||
{ name: "Ciabatta", icon: "🍞", allergenNames: ["Gluten"] },
|
{ name: "Ciabatta", icon: "🍞", allergenNames: ["Gluten"] },
|
||||||
|
],
|
||||||
// --- Cuisine asiatique (chinoise, japonaise, thaïe, coréenne, indienne...) ------
|
},
|
||||||
|
{
|
||||||
|
category: "CUISINE_ASIATIQUE",
|
||||||
|
items: [
|
||||||
{ name: "Sauce huître", icon: "🫙", allergenNames: ["Mollusques"] },
|
{ name: "Sauce huître", icon: "🫙", allergenNames: ["Mollusques"] },
|
||||||
{ name: "Sauce hoisin", icon: "🫙", allergenNames: ["Soja"] },
|
{ name: "Sauce hoisin", icon: "🫙", allergenNames: ["Soja"] },
|
||||||
{ name: "Sauce sriracha", icon: "🫙", allergenNames: [] },
|
{ name: "Sauce sriracha", icon: "🫙", allergenNames: [] },
|
||||||
|
|
@ -445,8 +500,11 @@ const INGREDIENTS: Array<{ name: string; icon?: string; allergenNames: string[]
|
||||||
{ name: "Pâte de crevettes", icon: "🫙", allergenNames: ["Crustacés"] },
|
{ name: "Pâte de crevettes", icon: "🫙", allergenNames: ["Crustacés"] },
|
||||||
{ name: "Pâte de curry rouge (thaï)", icon: "🍛", allergenNames: [] },
|
{ name: "Pâte de curry rouge (thaï)", icon: "🍛", allergenNames: [] },
|
||||||
{ name: "Pâte de curry vert (thaï)", icon: "🍛", allergenNames: [] },
|
{ name: "Pâte de curry vert (thaï)", icon: "🍛", allergenNames: [] },
|
||||||
|
],
|
||||||
// --- Cuisine mexicaine -----------------------------------------------------------
|
},
|
||||||
|
{
|
||||||
|
category: "CUISINE_MEXICAINE",
|
||||||
|
items: [
|
||||||
{ name: "Tortilla de maïs", icon: "🌮", allergenNames: [] },
|
{ name: "Tortilla de maïs", icon: "🌮", allergenNames: [] },
|
||||||
{ name: "Tortilla de blé", icon: "🌮", allergenNames: ["Gluten"] },
|
{ name: "Tortilla de blé", icon: "🌮", allergenNames: ["Gluten"] },
|
||||||
{ name: "Haricots pinto", icon: "🫘", allergenNames: [] },
|
{ name: "Haricots pinto", icon: "🫘", allergenNames: [] },
|
||||||
|
|
@ -456,17 +514,23 @@ const INGREDIENTS: Array<{ name: string; icon?: string; allergenNames: string[]
|
||||||
{ name: "Piment habanero", icon: "🌶️", allergenNames: [] },
|
{ name: "Piment habanero", icon: "🌶️", allergenNames: [] },
|
||||||
{ name: "Masa harina", icon: "🌽", allergenNames: [] },
|
{ name: "Masa harina", icon: "🌽", allergenNames: [] },
|
||||||
{ name: "Cheddar", icon: "🧀", allergenNames: ["Lait"] },
|
{ name: "Cheddar", icon: "🧀", allergenNames: ["Lait"] },
|
||||||
|
],
|
||||||
// --- Maghreb & Moyen-Orient --------------------------------------------------------
|
},
|
||||||
|
{
|
||||||
|
category: "MAGHREB_MOYEN_ORIENT",
|
||||||
|
items: [
|
||||||
{ name: "Ras el hanout", icon: "🌿", allergenNames: [] },
|
{ name: "Ras el hanout", icon: "🌿", allergenNames: [] },
|
||||||
{ name: "Za'atar", icon: "🌿", allergenNames: ["Graines de sésame"] },
|
{ name: "Za'atar", icon: "🌿", allergenNames: ["Graines de sésame"] },
|
||||||
{ name: "Tahini", icon: "🫙", allergenNames: ["Graines de sésame"] },
|
{ name: "Tahini", icon: "🫙", allergenNames: ["Graines de sésame"] },
|
||||||
|
],
|
||||||
// --- Pains & sandwichs ---------------------------------------------------------------
|
},
|
||||||
|
{
|
||||||
|
category: "PAINS_SANDWICHS",
|
||||||
// `Pain`/`Pain de mie`/`Pain complet`/`Baguette`/`Pain de seigle` are
|
// `Pain`/`Pain de mie`/`Pain complet`/`Baguette`/`Pain de seigle` are
|
||||||
// seeded further up (Céréales, farines & féculents) — this section covers
|
// seeded under `CEREALES_FECULENTS` above — this category covers the
|
||||||
// the shapes specifically reached for to build a sandwich, plus a few
|
// shapes specifically reached for to build a sandwich, plus a few
|
||||||
// international flatbreads not tied to one cuisine section above.
|
// international flatbreads not tied to one cuisine category above.
|
||||||
|
items: [
|
||||||
{ name: "Pain à burger", icon: "🍔", allergenNames: ["Gluten", "Lait", "Œufs"] },
|
{ name: "Pain à burger", icon: "🍔", allergenNames: ["Gluten", "Lait", "Œufs"] },
|
||||||
{ name: "Pain brioché", icon: "🍞", allergenNames: ["Gluten", "Lait", "Œufs"] },
|
{ name: "Pain brioché", icon: "🍞", allergenNames: ["Gluten", "Lait", "Œufs"] },
|
||||||
{ name: "Pain à hot-dog", icon: "🌭", allergenNames: ["Gluten"] },
|
{ name: "Pain à hot-dog", icon: "🌭", allergenNames: ["Gluten"] },
|
||||||
|
|
@ -482,12 +546,18 @@ const INGREDIENTS: Array<{ name: string; icon?: string; allergenNames: string[]
|
||||||
{ name: "Pain sans gluten", icon: "🍞", allergenNames: [] },
|
{ name: "Pain sans gluten", icon: "🍞", allergenNames: [] },
|
||||||
{ name: "Biscotte", icon: "🍞", allergenNames: ["Gluten"] },
|
{ name: "Biscotte", icon: "🍞", allergenNames: ["Gluten"] },
|
||||||
{ name: "Croûtons", icon: "🍞", allergenNames: ["Gluten"] },
|
{ name: "Croûtons", icon: "🍞", allergenNames: ["Gluten"] },
|
||||||
|
],
|
||||||
// --- Épicerie divers ---------------------------------------------------------------
|
},
|
||||||
|
{
|
||||||
|
category: "EPICERIE_DIVERS",
|
||||||
|
items: [
|
||||||
{ name: "Bicarbonate de soude", icon: "🫙", allergenNames: [] },
|
{ name: "Bicarbonate de soude", icon: "🫙", allergenNames: [] },
|
||||||
{ name: "Fécule de pomme de terre", icon: "🫙", allergenNames: [] },
|
{ name: "Fécule de pomme de terre", icon: "🫙", allergenNames: [] },
|
||||||
|
],
|
||||||
// --- Liquides & boissons de cuisine -------------------------------------------
|
},
|
||||||
|
{
|
||||||
|
category: "LIQUIDES_BOISSONS",
|
||||||
|
items: [
|
||||||
{ name: "Eau", icon: "💧", allergenNames: [] },
|
{ name: "Eau", icon: "💧", allergenNames: [] },
|
||||||
{ name: "Eau gazeuse", icon: "💧", allergenNames: [] },
|
{ name: "Eau gazeuse", icon: "💧", allergenNames: [] },
|
||||||
{ name: "Eau de fleur d'oranger", icon: "💧", allergenNames: [] },
|
{ name: "Eau de fleur d'oranger", icon: "💧", allergenNames: [] },
|
||||||
|
|
@ -512,8 +582,13 @@ const INGREDIENTS: Array<{ name: string; icon?: string; allergenNames: string[]
|
||||||
{ name: "Vodka", icon: "🥃", allergenNames: [] },
|
{ name: "Vodka", icon: "🥃", allergenNames: [] },
|
||||||
{ name: "Sirop de sucre de canne", icon: "🫙", allergenNames: [] },
|
{ name: "Sirop de sucre de canne", icon: "🫙", allergenNames: [] },
|
||||||
{ name: "Fumet de poisson", icon: "🫙", allergenNames: ["Poissons"] },
|
{ name: "Fumet de poisson", icon: "🫙", allergenNames: ["Poissons"] },
|
||||||
|
],
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
|
const INGREDIENTS: Array<IngredientSeed & { category: IngredientCategory }> =
|
||||||
|
INGREDIENT_GROUPS.flatMap(({ category, items }) => items.map((item) => ({ ...item, category })));
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Populates the `Diet`/`Category`/`Allergy` reference tables. Idempotent
|
* Populates the `Diet`/`Category`/`Allergy` reference tables. Idempotent
|
||||||
* (safe to call against a database that already has this data — upserts by
|
* (safe to call against a database that already has this data — upserts by
|
||||||
|
|
@ -549,29 +624,29 @@ export async function seedReferenceData(prisma: PrismaClient): Promise<void> {
|
||||||
// hundred entries long, and `seedReferenceData` re-runs on every single
|
// hundred entries long, and `seedReferenceData` re-runs on every single
|
||||||
// test's `resetDatabase()` — a per-row round trip made the whole suite
|
// test's `resetDatabase()` — a per-row round trip made the whole suite
|
||||||
// measurably slower). Bulk-create whatever's missing in one query, then
|
// measurably slower). Bulk-create whatever's missing in one query, then
|
||||||
// reconcile `icon` only for the rows where it actually changed — on a
|
// reconcile `icon`/`category` only for the rows where either actually
|
||||||
// freshly-truncated table (the common test-suite case) that's zero
|
// changed — on a freshly-truncated table (the common test-suite case)
|
||||||
// updates, on a real re-deploy it's however many icons were edited in
|
// that's zero updates, on a real re-deploy it's however many rows were
|
||||||
// code since the last deploy, never the full list.
|
// edited in code since the last deploy, never the full list.
|
||||||
const existingIngredients = await prisma.ingredient.findMany({
|
const existingIngredients = await prisma.ingredient.findMany({
|
||||||
where: { name: { in: INGREDIENTS.map((i) => i.name) } },
|
where: { name: { in: INGREDIENTS.map((i) => i.name) } },
|
||||||
select: { id: true, name: true, icon: true },
|
select: { id: true, name: true, icon: true, category: true },
|
||||||
});
|
});
|
||||||
const existingByName = new Map(existingIngredients.map((i) => [i.name, i]));
|
const existingByName = new Map(existingIngredients.map((i) => [i.name, i]));
|
||||||
|
|
||||||
const missingIngredients = INGREDIENTS.filter((i) => !existingByName.has(i.name));
|
const missingIngredients = INGREDIENTS.filter((i) => !existingByName.has(i.name));
|
||||||
if (missingIngredients.length > 0) {
|
if (missingIngredients.length > 0) {
|
||||||
await prisma.ingredient.createMany({
|
await prisma.ingredient.createMany({
|
||||||
data: missingIngredients.map(({ name, icon }) => ({ name, icon })),
|
data: missingIngredients.map(({ name, icon, category }) => ({ name, icon, category })),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const changedIcons = INGREDIENTS.filter((i) => {
|
const changed = INGREDIENTS.filter((i) => {
|
||||||
const existing = existingByName.get(i.name);
|
const existing = existingByName.get(i.name);
|
||||||
return existing && existing.icon !== (i.icon ?? null);
|
return existing && (existing.icon !== (i.icon ?? null) || existing.category !== i.category);
|
||||||
});
|
});
|
||||||
for (const { name, icon } of changedIcons) {
|
for (const { name, icon, category } of changed) {
|
||||||
await prisma.ingredient.update({ where: { name }, data: { icon } });
|
await prisma.ingredient.update({ where: { name }, data: { icon, category } });
|
||||||
}
|
}
|
||||||
|
|
||||||
// Re-resolve every ingredient's id (existing + just-created) and every
|
// Re-resolve every ingredient's id (existing + just-created) and every
|
||||||
|
|
|
||||||
|
|
@ -38,6 +38,7 @@ function toIngredientView(ingredient: IngredientWithAllergies): IngredientView {
|
||||||
id: ingredient.id,
|
id: ingredient.id,
|
||||||
name: ingredient.name,
|
name: ingredient.name,
|
||||||
icon: ingredient.icon,
|
icon: ingredient.icon,
|
||||||
|
category: ingredient.category,
|
||||||
allergens: ingredient.allergies.map(({ allergy }) => ({
|
allergens: ingredient.allergies.map(({ allergy }) => ({
|
||||||
id: allergy.id,
|
id: allergy.id,
|
||||||
name: allergy.category.name,
|
name: allergy.category.name,
|
||||||
|
|
|
||||||
|
|
@ -41,6 +41,7 @@ export async function getIngredients(): Promise<IngredientView[]> {
|
||||||
id: ingredient.id,
|
id: ingredient.id,
|
||||||
name: ingredient.name,
|
name: ingredient.name,
|
||||||
icon: ingredient.icon,
|
icon: ingredient.icon,
|
||||||
|
category: ingredient.category,
|
||||||
allergens: ingredient.allergies.map(({ allergy }) => ({
|
allergens: ingredient.allergies.map(({ allergy }) => ({
|
||||||
id: allergy.id,
|
id: allergy.id,
|
||||||
name: allergy.category.name,
|
name: allergy.category.name,
|
||||||
|
|
|
||||||
|
|
@ -54,7 +54,7 @@ describe("Reference data", () => {
|
||||||
expect(res.status).to.equal(200);
|
expect(res.status).to.equal(200);
|
||||||
expect(res.body.length).to.be.greaterThan(0);
|
expect(res.body.length).to.be.greaterThan(0);
|
||||||
expect(res.body.map((i: { name: string }) => i.name)).to.include("Tomate");
|
expect(res.body.map((i: { name: string }) => i.name)).to.include("Tomate");
|
||||||
expect(res.body[0]).to.have.keys(["id", "name", "icon", "allergens"]);
|
expect(res.body[0]).to.have.keys(["id", "name", "icon", "category", "allergens"]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("resolves each ingredient's linked allergens, empty for one with none", async () => {
|
it("resolves each ingredient's linked allergens, empty for one with none", async () => {
|
||||||
|
|
|
||||||
|
|
@ -1,14 +1,15 @@
|
||||||
import type { IngredientView } from "@batch-cooking/shared";
|
import type { IngredientView } from "@batch-cooking/shared";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
// Reuses `IngredientAutocomplete` verbatim (built for the recipe form's
|
// Reuses `IngredientPicker` verbatim (built for the recipe form's
|
||||||
// ingredient picker, features/recipes/) rather than a second search field —
|
// ingredient picker, features/recipes/) rather than a second search+browse
|
||||||
// same reference ingredient list, same "type to filter" behavior, just
|
// field — same reference ingredient list, same category/search browsing,
|
||||||
// without the recipe form's quantity/unit per line. Its own recipes.scss
|
// just without the recipe form's quantity/unit per line. Its own
|
||||||
// import already covers `.ingredient-autocomplete`; this file's explicit
|
// recipes.scss import already covers `.ingredient-picker`; this file's
|
||||||
// import below only adds `.disliked-ingredients-field__*` (see recipes.scss
|
// explicit import below only adds `.disliked-ingredients-field__*` (see
|
||||||
// — colocated there since it's the same "reference ingredient picker"
|
// recipes.scss — colocated there since it's the same "reference ingredient
|
||||||
// visual family, even though this field lives in the profile feature).
|
// picker" visual family, even though this field lives in the profile
|
||||||
import { IngredientAutocomplete } from "../recipes/IngredientAutocomplete";
|
// feature).
|
||||||
|
import { IngredientPicker } from "../recipes/IngredientPicker";
|
||||||
import "../recipes/recipes.scss";
|
import "../recipes/recipes.scss";
|
||||||
import "./profile-forms.scss";
|
import "./profile-forms.scss";
|
||||||
|
|
||||||
|
|
@ -62,7 +63,7 @@ export function DislikedIngredientsField({
|
||||||
))}
|
))}
|
||||||
</ul>
|
</ul>
|
||||||
)}
|
)}
|
||||||
<IngredientAutocomplete ingredients={ingredients} excludeIds={value} onSelect={add} />
|
<IngredientPicker ingredients={ingredients} excludeIds={value} onSelect={add} />
|
||||||
</fieldset>
|
</fieldset>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,71 +0,0 @@
|
||||||
import type { IngredientView } from "@batch-cooking/shared";
|
|
||||||
import { useState } from "react";
|
|
||||||
import { useTranslation } from "react-i18next";
|
|
||||||
import { AllergenBadges } from "./AllergenBadges";
|
|
||||||
import "./recipes.scss";
|
|
||||||
|
|
||||||
/** Caps how many matches are shown at once — the reference list is small, but an unbounded dropdown would still be unwieldy for a broad query like a single letter. */
|
|
||||||
const MAX_SUGGESTIONS = 8;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Text field over the static reference ingredient list (`GET
|
|
||||||
* /reference/ingredients`) — receives the full list as a prop rather than
|
|
||||||
* fetching it itself, same rationale as `AllergySelect`/`DietSelect`, and
|
|
||||||
* filters it client-side as the user types: it's small, non-administrable
|
|
||||||
* reference data, no dedicated search endpoint needed. `excludeIds` (the
|
|
||||||
* ingredients already on the recipe) keeps the same one from being added
|
|
||||||
* twice.
|
|
||||||
*/
|
|
||||||
export function IngredientAutocomplete({
|
|
||||||
ingredients,
|
|
||||||
excludeIds,
|
|
||||||
onSelect,
|
|
||||||
}: {
|
|
||||||
ingredients: IngredientView[];
|
|
||||||
excludeIds: number[];
|
|
||||||
onSelect: (ingredient: IngredientView) => void;
|
|
||||||
}) {
|
|
||||||
const { t } = useTranslation();
|
|
||||||
const [query, setQuery] = useState("");
|
|
||||||
|
|
||||||
const normalizedQuery = query.trim().toLowerCase();
|
|
||||||
const suggestions =
|
|
||||||
normalizedQuery.length === 0
|
|
||||||
? []
|
|
||||||
: ingredients
|
|
||||||
.filter(
|
|
||||||
(ingredient) =>
|
|
||||||
!excludeIds.includes(ingredient.id) &&
|
|
||||||
ingredient.name.toLowerCase().includes(normalizedQuery),
|
|
||||||
)
|
|
||||||
.slice(0, MAX_SUGGESTIONS);
|
|
||||||
|
|
||||||
function handleSelect(ingredient: IngredientView) {
|
|
||||||
onSelect(ingredient);
|
|
||||||
setQuery("");
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="ingredient-autocomplete">
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
value={query}
|
|
||||||
onChange={(e) => setQuery(e.target.value)}
|
|
||||||
placeholder={t("recipes.form.addIngredientPlaceholder")}
|
|
||||||
/>
|
|
||||||
{suggestions.length > 0 && (
|
|
||||||
<ul className="ingredient-autocomplete__suggestions">
|
|
||||||
{suggestions.map((ingredient) => (
|
|
||||||
<li key={ingredient.id}>
|
|
||||||
<button type="button" onClick={() => handleSelect(ingredient)}>
|
|
||||||
<span aria-hidden="true">{ingredient.icon}</span>
|
|
||||||
<span className="ingredient-autocomplete__name">{ingredient.name}</span>
|
|
||||||
<AllergenBadges allergens={ingredient.allergens} />
|
|
||||||
</button>
|
|
||||||
</li>
|
|
||||||
))}
|
|
||||||
</ul>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
109
apps/web/src/features/recipes/IngredientPicker.tsx
Normal file
109
apps/web/src/features/recipes/IngredientPicker.tsx
Normal file
|
|
@ -0,0 +1,109 @@
|
||||||
|
import {
|
||||||
|
INGREDIENT_CATEGORIES,
|
||||||
|
type IngredientCategory,
|
||||||
|
type IngredientView,
|
||||||
|
} from "@batch-cooking/shared";
|
||||||
|
import { useState } from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { AllergenBadges } from "./AllergenBadges";
|
||||||
|
import "./recipes.scss";
|
||||||
|
|
||||||
|
/** "No category filter" — a UI-only pseudo-category, never sent to/received from the API (see {@link INGREDIENT_CATEGORIES} for the real, closed set). */
|
||||||
|
const ALL_CATEGORIES = "ALL" as const;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Browsable ingredient picker — category chips + search + a card grid,
|
||||||
|
* replacing the earlier `IngredientAutocomplete` (a plain type-to-filter
|
||||||
|
* dropdown). With 400+ reference ingredients, search alone doesn't scale to
|
||||||
|
* actually *finding* one — category browsing is the main fix; search still
|
||||||
|
* narrows within (or across) categories for when the name is already known.
|
||||||
|
*
|
||||||
|
* Receives `ingredients` as a prop rather than fetching them itself — same
|
||||||
|
* rationale as `AllergySelect`/`DietSelect`. `excludeIds` (already-selected
|
||||||
|
* ingredients — a recipe's ingredient list, or a profile's disliked list)
|
||||||
|
* keeps the same one from being added twice.
|
||||||
|
*/
|
||||||
|
export function IngredientPicker({
|
||||||
|
ingredients,
|
||||||
|
excludeIds,
|
||||||
|
onSelect,
|
||||||
|
}: {
|
||||||
|
ingredients: IngredientView[];
|
||||||
|
excludeIds: number[];
|
||||||
|
onSelect: (ingredient: IngredientView) => void;
|
||||||
|
}) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const [category, setCategory] = useState<IngredientCategory | typeof ALL_CATEGORIES>(
|
||||||
|
ALL_CATEGORIES,
|
||||||
|
);
|
||||||
|
const [query, setQuery] = useState("");
|
||||||
|
|
||||||
|
const normalizedQuery = query.trim().toLowerCase();
|
||||||
|
const visible = ingredients.filter((ingredient) => {
|
||||||
|
if (excludeIds.includes(ingredient.id)) return false;
|
||||||
|
if (category !== ALL_CATEGORIES && ingredient.category !== category) return false;
|
||||||
|
if (normalizedQuery.length > 0 && !ingredient.name.toLowerCase().includes(normalizedQuery)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
|
||||||
|
function handleSelect(ingredient: IngredientView) {
|
||||||
|
onSelect(ingredient);
|
||||||
|
setQuery("");
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="ingredient-picker">
|
||||||
|
<div className="ingredient-picker__search">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={query}
|
||||||
|
onChange={(e) => setQuery(e.target.value)}
|
||||||
|
placeholder={t("recipes.form.searchIngredientPlaceholder")}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="ingredient-picker__categories">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={`ingredient-picker__category${category === ALL_CATEGORIES ? " active" : ""}`}
|
||||||
|
onClick={() => setCategory(ALL_CATEGORIES)}
|
||||||
|
>
|
||||||
|
{t("recipes.form.allCategories")}
|
||||||
|
</button>
|
||||||
|
{INGREDIENT_CATEGORIES.map((c) => (
|
||||||
|
<button
|
||||||
|
key={c}
|
||||||
|
type="button"
|
||||||
|
className={`ingredient-picker__category${category === c ? " active" : ""}`}
|
||||||
|
onClick={() => setCategory(c)}
|
||||||
|
>
|
||||||
|
{t(`recipes.form.category.${c}`)}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{visible.length === 0 ? (
|
||||||
|
<p className="ingredient-picker__empty">{t("recipes.form.noIngredientFound")}</p>
|
||||||
|
) : (
|
||||||
|
<div className="ingredient-picker__grid">
|
||||||
|
{visible.map((ingredient) => (
|
||||||
|
<button
|
||||||
|
key={ingredient.id}
|
||||||
|
type="button"
|
||||||
|
className="ingredient-picker__card"
|
||||||
|
onClick={() => handleSelect(ingredient)}
|
||||||
|
>
|
||||||
|
<span className="ingredient-picker__card-icon" aria-hidden="true">
|
||||||
|
{ingredient.icon}
|
||||||
|
</span>
|
||||||
|
<span className="ingredient-picker__card-name">{ingredient.name}</span>
|
||||||
|
<AllergenBadges allergens={ingredient.allergens} />
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -3,7 +3,7 @@ import { useTranslation } from "react-i18next";
|
||||||
import { AllergenBadges } from "./AllergenBadges";
|
import { AllergenBadges } from "./AllergenBadges";
|
||||||
import "./recipes.scss";
|
import "./recipes.scss";
|
||||||
|
|
||||||
/** One selected ingredient line in the recipe form — the ingredient itself (picked via `IngredientAutocomplete`) plus its quantity/unit for this recipe. Quantity/unit are kept as raw strings while editing (not parsed to a number until submit) so an in-progress/invalid value doesn't fight the input. */
|
/** One selected ingredient line in the recipe form — the ingredient itself (picked via `IngredientPicker`) plus its quantity/unit for this recipe. Quantity/unit are kept as raw strings while editing (not parsed to a number until submit) so an in-progress/invalid value doesn't fight the input. */
|
||||||
export function IngredientRow({
|
export function IngredientRow({
|
||||||
ingredient,
|
ingredient,
|
||||||
quantity,
|
quantity,
|
||||||
|
|
|
||||||
|
|
@ -140,11 +140,17 @@
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: var(--space-xs);
|
gap: var(--space-xs);
|
||||||
|
// Never lets a tab overflow the page (which would force the whole body
|
||||||
|
// to scroll horizontally, see global.scss's rule against that) — scrolls
|
||||||
|
// within itself instead once the tabs (including the disabled "Sources"
|
||||||
|
// placeholder) don't all fit, same pattern as the sidebar's own nav.
|
||||||
|
overflow-x: auto;
|
||||||
border-bottom: 1px solid var(--color-border);
|
border-bottom: 1px solid var(--color-border);
|
||||||
margin-bottom: var(--space-md);
|
margin-bottom: var(--space-md);
|
||||||
|
|
||||||
&__tab {
|
&__tab {
|
||||||
appearance: none;
|
appearance: none;
|
||||||
|
flex-shrink: 0;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 0.4rem;
|
gap: 0.4rem;
|
||||||
|
|
@ -661,62 +667,109 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Ingredient autocomplete (recipe form + disliked-ingredients field) -----
|
// --- Ingredient picker (recipe form + disliked-ingredients field) ----------
|
||||||
.ingredient-autocomplete {
|
// Always-visible category chips + search + card grid — replaces the earlier
|
||||||
position: relative;
|
// type-to-filter dropdown (`.ingredient-autocomplete`, now gone), which
|
||||||
|
// stopped scaling once the reference list passed a couple hundred items.
|
||||||
|
// The category row uses the same "scroll, don't crush" pattern as the app
|
||||||
|
// sidebar nav (see AppLayout.scss's mobile breakpoint): chips never shrink
|
||||||
|
// below a tappable size, the row scrolls horizontally instead.
|
||||||
|
.ingredient-picker {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--space-sm);
|
||||||
|
padding: var(--space-sm);
|
||||||
|
background: var(--color-surface);
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
border-radius: var(--radius-base);
|
||||||
|
|
||||||
> input {
|
&__search {
|
||||||
|
input {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
max-width: 24rem;
|
|
||||||
padding: var(--space-sm);
|
padding: var(--space-sm);
|
||||||
font-family: var(--font-body);
|
font-family: var(--font-body);
|
||||||
font-size: var(--font-size-base);
|
font-size: var(--font-size-base);
|
||||||
border: 1px solid var(--color-border);
|
border: 1px solid var(--color-border);
|
||||||
border-radius: var(--radius-base);
|
border-radius: var(--radius-base);
|
||||||
background: var(--color-surface);
|
background: var(--color-surface-alt);
|
||||||
color: var(--color-text);
|
color: var(--color-text);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
&__suggestions {
|
&__categories {
|
||||||
position: absolute;
|
|
||||||
z-index: 1;
|
|
||||||
top: 100%;
|
|
||||||
left: 0;
|
|
||||||
width: 100%;
|
|
||||||
max-width: 24rem;
|
|
||||||
max-height: 16rem;
|
|
||||||
overflow-y: auto;
|
|
||||||
margin: var(--space-xs) 0 0;
|
|
||||||
padding: var(--space-xs);
|
|
||||||
list-style: none;
|
|
||||||
background: var(--color-surface);
|
|
||||||
border: 1px solid var(--color-border);
|
|
||||||
border-radius: var(--radius-base);
|
|
||||||
box-shadow: var(--shadow-md);
|
|
||||||
|
|
||||||
button {
|
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
gap: var(--space-xs);
|
||||||
gap: var(--space-sm);
|
overflow-x: auto;
|
||||||
width: 100%;
|
padding-bottom: var(--space-xs);
|
||||||
|
}
|
||||||
|
|
||||||
|
&__category {
|
||||||
|
flex-shrink: 0;
|
||||||
|
appearance: none;
|
||||||
padding: var(--space-xs) var(--space-sm);
|
padding: var(--space-xs) var(--space-sm);
|
||||||
font-family: var(--font-body);
|
font-family: var(--font-body);
|
||||||
font-size: var(--font-size-sm);
|
font-size: var(--font-size-sm);
|
||||||
text-align: left;
|
white-space: nowrap;
|
||||||
border: none;
|
|
||||||
border-radius: var(--radius-base);
|
|
||||||
background: none;
|
|
||||||
color: var(--color-text);
|
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
border-radius: var(--radius-pill);
|
||||||
|
background: var(--color-surface-alt);
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
|
||||||
&:hover {
|
&:hover {
|
||||||
background: var(--color-surface-alt);
|
border-color: var(--color-primary);
|
||||||
|
color: var(--color-text);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
&.active {
|
||||||
|
border-color: var(--color-primary);
|
||||||
|
background: var(--color-primary);
|
||||||
|
color: #fff;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
&__name {
|
&__grid {
|
||||||
margin-right: auto;
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(7rem, 1fr));
|
||||||
|
gap: var(--space-xs);
|
||||||
|
max-height: 18rem;
|
||||||
|
overflow-y: auto;
|
||||||
|
padding: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__card {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--space-xs);
|
||||||
|
padding: var(--space-sm);
|
||||||
|
text-align: center;
|
||||||
|
cursor: pointer;
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
border-radius: var(--radius-base);
|
||||||
|
background: var(--color-surface-alt);
|
||||||
|
color: var(--color-text);
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
border-color: var(--color-primary);
|
||||||
|
background: var(--color-surface);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&__card-icon {
|
||||||
|
font-size: 1.5rem;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__card-name {
|
||||||
|
font-size: var(--font-size-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
&__empty {
|
||||||
|
padding: var(--space-md);
|
||||||
|
font-size: var(--font-size-sm);
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
text-align: center;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -305,6 +305,17 @@
|
||||||
// viewport (relevant early given this app is meant to be embedded via
|
// viewport (relevant early given this app is meant to be embedded via
|
||||||
// Capacitor later, see the root README) — collapse it into a horizontal
|
// Capacitor later, see the root README) — collapse it into a horizontal
|
||||||
// top bar instead of a side rail.
|
// top bar instead of a side rail.
|
||||||
|
//
|
||||||
|
// The primary nav (Planning/Recettes/Liste de courses) must stay fully
|
||||||
|
// legible and tappable at any width — it's the app's main navigation, not
|
||||||
|
// optional chrome. Without `&__nav { min-width: 0 }` + `a { flex-shrink: 0
|
||||||
|
// }` below, `&__settings`/`&__footer`'s own natural (non-shrinking) width
|
||||||
|
// silently crushed it down to ~16px unlabeled slivers on a narrow phone
|
||||||
|
// (measured on a 375px viewport) — invisible labels, no real tap target.
|
||||||
|
// The fix: `__settings`/`__footer` collapse to icon-only instead (same
|
||||||
|
// look as the desktop rail's `.collapsed` state), freeing width for the
|
||||||
|
// nav, which falls back to horizontal scroll (`overflow-x: auto`) rather
|
||||||
|
// than shrinking if it still doesn't fit.
|
||||||
@media (max-width: 640px) {
|
@media (max-width: 640px) {
|
||||||
.app-layout {
|
.app-layout {
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
|
|
@ -326,15 +337,75 @@
|
||||||
padding: 0;
|
padding: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Nothing to collapse into on a horizontal bar — there's no rail.
|
||||||
|
&__collapse-toggle {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
&__nav {
|
&__nav {
|
||||||
|
flex: 1 1 auto;
|
||||||
|
min-width: 0;
|
||||||
flex-direction: row;
|
flex-direction: row;
|
||||||
overflow-x: auto;
|
overflow-x: auto;
|
||||||
|
|
||||||
|
a {
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
&__settings,
|
&__settings,
|
||||||
&__footer {
|
&__footer {
|
||||||
|
position: relative;
|
||||||
|
flex: none;
|
||||||
padding-top: 0;
|
padding-top: 0;
|
||||||
border-top: none;
|
border-top: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
&__settings-toggle,
|
||||||
|
&__account-toggle {
|
||||||
|
padding-left: var(--space-xs);
|
||||||
|
padding-right: var(--space-xs);
|
||||||
|
}
|
||||||
|
|
||||||
|
&__settings-toggle-left .label,
|
||||||
|
&__account-toggle .label,
|
||||||
|
&__settings-toggle .chevron {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Both reveals become floating panels anchored under their icon-only
|
||||||
|
// toggle — on the desktop rail one is inline (settings, stacks fine in
|
||||||
|
// a column) and the other already floats (account); neither can stay
|
||||||
|
// in normal flow on this horizontal bar without breaking the row.
|
||||||
|
// Selector order matters: `&__settings-nav` also carries the plain
|
||||||
|
// `&__nav` class (for the link styling), so this must come after it to
|
||||||
|
// win on `flex-direction`/`overflow-x`.
|
||||||
|
&__settings-nav {
|
||||||
|
position: absolute;
|
||||||
|
top: calc(100% + var(--space-xs));
|
||||||
|
right: 0;
|
||||||
|
flex-direction: column;
|
||||||
|
width: 14rem;
|
||||||
|
padding: var(--space-xs);
|
||||||
|
overflow-x: visible;
|
||||||
|
background: var(--color-surface);
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
border-radius: var(--radius-base);
|
||||||
|
box-shadow: var(--shadow-md);
|
||||||
|
z-index: 10;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Desktop opens this *upward* (`bottom: calc(100% + ...)`, see the base
|
||||||
|
// rule above) because the footer sits at the bottom of a tall rail —
|
||||||
|
// on this horizontal top bar the footer is near `y: 0`, so "upward"
|
||||||
|
// pushed the menu entirely off-screen above the viewport. Flip it to
|
||||||
|
// open downward here instead.
|
||||||
|
&__account-menu {
|
||||||
|
top: calc(100% + var(--space-xs));
|
||||||
|
bottom: auto;
|
||||||
|
left: auto;
|
||||||
|
right: 0;
|
||||||
|
width: 12rem;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -165,7 +165,29 @@
|
||||||
"PUBLIC": "Tout le monde"
|
"PUBLIC": "Tout le monde"
|
||||||
},
|
},
|
||||||
"dietsLabel": "Régime(s) associé(s)",
|
"dietsLabel": "Régime(s) associé(s)",
|
||||||
"addIngredientPlaceholder": "Ajouter un ingrédient…",
|
"searchIngredientPlaceholder": "Rechercher un ingrédient…",
|
||||||
|
"allCategories": "Tout",
|
||||||
|
"noIngredientFound": "Aucun ingrédient trouvé.",
|
||||||
|
"category": {
|
||||||
|
"CEREALES_FECULENTS": "Céréales & féculents",
|
||||||
|
"LEGUMINEUSES": "Légumineuses",
|
||||||
|
"VIANDES_VOLAILLES": "Viandes & volailles",
|
||||||
|
"POISSONS_FRUITS_DE_MER": "Poissons & fruits de mer",
|
||||||
|
"PRODUITS_LAITIERS_OEUFS": "Produits laitiers & œufs",
|
||||||
|
"LEGUMES": "Légumes",
|
||||||
|
"FRUITS": "Fruits",
|
||||||
|
"FRUITS_SECS_OLEAGINEUX": "Fruits secs & oléagineux",
|
||||||
|
"CONDIMENTS_SAUCES": "Condiments & sauces",
|
||||||
|
"EPICES_HERBES": "Épices & herbes",
|
||||||
|
"SUCRE_PATISSERIE": "Sucre & pâtisserie",
|
||||||
|
"CUISINE_ITALIENNE": "Cuisine italienne",
|
||||||
|
"CUISINE_ASIATIQUE": "Cuisine asiatique",
|
||||||
|
"CUISINE_MEXICAINE": "Cuisine mexicaine",
|
||||||
|
"MAGHREB_MOYEN_ORIENT": "Maghreb & Moyen-Orient",
|
||||||
|
"PAINS_SANDWICHS": "Pains & sandwichs",
|
||||||
|
"EPICERIE_DIVERS": "Épicerie & divers",
|
||||||
|
"LIQUIDES_BOISSONS": "Liquides & boissons"
|
||||||
|
},
|
||||||
"quantityLabel": "Quantité",
|
"quantityLabel": "Quantité",
|
||||||
"unitLabel": "Unité",
|
"unitLabel": "Unité",
|
||||||
"unitPlaceholder": "g, ml, unité…",
|
"unitPlaceholder": "g, ml, unité…",
|
||||||
|
|
|
||||||
|
|
@ -11,7 +11,7 @@ import { useTranslation } from "react-i18next";
|
||||||
import { useNavigate, useParams } from "react-router-dom";
|
import { useNavigate, useParams } from "react-router-dom";
|
||||||
import { ApiError, apiClient } from "../api/client";
|
import { ApiError, apiClient } from "../api/client";
|
||||||
import { DietTagSelect } from "../features/recipes/DietTagSelect";
|
import { DietTagSelect } from "../features/recipes/DietTagSelect";
|
||||||
import { IngredientAutocomplete } from "../features/recipes/IngredientAutocomplete";
|
import { IngredientPicker } from "../features/recipes/IngredientPicker";
|
||||||
import { IngredientRow } from "../features/recipes/IngredientRow";
|
import { IngredientRow } from "../features/recipes/IngredientRow";
|
||||||
import { type StepDraft, StepListEditor } from "../features/recipes/StepListEditor";
|
import { type StepDraft, StepListEditor } from "../features/recipes/StepListEditor";
|
||||||
import "../features/recipes/recipes.scss";
|
import "../features/recipes/recipes.scss";
|
||||||
|
|
@ -251,7 +251,7 @@ export function RecipeFormPage() {
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</ul>
|
</ul>
|
||||||
<IngredientAutocomplete
|
<IngredientPicker
|
||||||
ingredients={ingredientsCatalog}
|
ingredients={ingredientsCatalog}
|
||||||
excludeIds={selectedIds}
|
excludeIds={selectedIds}
|
||||||
onSelect={addIngredient}
|
onSelect={addIngredient}
|
||||||
|
|
|
||||||
|
|
@ -32,6 +32,37 @@ export interface AllergyView {
|
||||||
kind: AllergenKind;
|
kind: AllergenKind;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Coarse ingredient grouping (viandes, légumes, épices...) — mirrors
|
||||||
|
* `IngredientCategory` in schema.prisma, declared by hand for the same
|
||||||
|
* reason as {@link AllergenKind}. Lets the ingredient picker (`apps/web`'s
|
||||||
|
* `IngredientPicker`) offer category browsing, not just free-text search:
|
||||||
|
* with 400+ reference ingredients, search alone doesn't scale to actually
|
||||||
|
* *finding* one.
|
||||||
|
*/
|
||||||
|
export const INGREDIENT_CATEGORIES = [
|
||||||
|
"CEREALES_FECULENTS",
|
||||||
|
"LEGUMINEUSES",
|
||||||
|
"VIANDES_VOLAILLES",
|
||||||
|
"POISSONS_FRUITS_DE_MER",
|
||||||
|
"PRODUITS_LAITIERS_OEUFS",
|
||||||
|
"LEGUMES",
|
||||||
|
"FRUITS",
|
||||||
|
"FRUITS_SECS_OLEAGINEUX",
|
||||||
|
"CONDIMENTS_SAUCES",
|
||||||
|
"EPICES_HERBES",
|
||||||
|
"SUCRE_PATISSERIE",
|
||||||
|
"CUISINE_ITALIENNE",
|
||||||
|
"CUISINE_ASIATIQUE",
|
||||||
|
"CUISINE_MEXICAINE",
|
||||||
|
"MAGHREB_MOYEN_ORIENT",
|
||||||
|
"PAINS_SANDWICHS",
|
||||||
|
"EPICERIE_DIVERS",
|
||||||
|
"LIQUIDES_BOISSONS",
|
||||||
|
] as const;
|
||||||
|
/** Inferred TS type for one {@link INGREDIENT_CATEGORIES} member. */
|
||||||
|
export type IngredientCategory = (typeof INGREDIENT_CATEGORIES)[number];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A selectable ingredient, as returned by `GET /reference/ingredients` —
|
* A selectable ingredient, as returned by `GET /reference/ingredients` —
|
||||||
* reference data (`Ingredient`, seeded via `apps/api/src/db/
|
* reference data (`Ingredient`, seeded via `apps/api/src/db/
|
||||||
|
|
@ -49,5 +80,6 @@ export interface IngredientView {
|
||||||
id: number;
|
id: number;
|
||||||
name: string;
|
name: string;
|
||||||
icon: string | null;
|
icon: string | null;
|
||||||
|
category: IngredientCategory;
|
||||||
allergens: AllergyView[];
|
allergens: AllergyView[];
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue