feat(recipes): flag les ingrédients faisables maison + suggestion de recherche
Remplace la FK morte `Ingredient.alternateRecipeId` (jamais branchée nulle part — confirmé par exploration : zéro usage en dehors de schema.prisma) par un flag booléen `reproducible`, plus simple : pas de liaison recette↔ingrédient en base, juste une info "ça vaut le coup d'être fait maison" plus un raccourci de recherche. - Migration : drop `alternate_recipe` (colonne + FK), ajoute `reproducible BOOLEAN NOT NULL DEFAULT false` sur `ingredients`. - `reference-seed-data.ts` : `IngredientSeed` gagne `reproducible?`, threadé dans le flatten + la réconciliation `seedReferenceData`. Premier lot de 27 ingrédients marqués (pains, pâtes à cuire, sauces de base, bouillons/fonds) — même logique que la curation Ciqual : un lot solide plutôt qu'exhaustif sur les 546 ingrédients. - `IngredientView` (shared) + les deux endroits qui la construisent (`reference.service.ts`, `recipe.service.ts`) gagnent `reproducible`. - `ReproducibleBadge` (nouveau) : pastille "Faisable maison" — simple dans `IngredientPicker` (avec son propre toggle d'affichage), lien cliquable dans `IngredientRow` vers `/recettes?search=<nom>` ouvert dans un nouvel onglet (pour ne jamais perdre le formulaire de recette en cours — pas de persistance de brouillon dans `RecipeFormPage`). - `RecipesPage` lit `?search=` au montage pour permettre ce deep-link. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
parent
a8eea46c58
commit
978fe71a11
13 changed files with 216 additions and 40 deletions
|
|
@ -0,0 +1,9 @@
|
||||||
|
-- Replaces the never-wired-up `Ingredient.alternateRecipeId` FK (zero
|
||||||
|
-- usage anywhere outside schema.prisma — confirmed by repo-wide grep)
|
||||||
|
-- with a plain boolean flag: whether this ingredient is reasonably
|
||||||
|
-- makeable at home. Product decision: no ingredient↔recipe linking in
|
||||||
|
-- the database — the recipe form only nudges the author toward the
|
||||||
|
-- recipe catalog's own search, pre-filled with the ingredient's name.
|
||||||
|
ALTER TABLE "ingredients" DROP CONSTRAINT "ingredients_alternate_recipe_fkey";
|
||||||
|
ALTER TABLE "ingredients" DROP COLUMN "alternate_recipe";
|
||||||
|
ALTER TABLE "ingredients" ADD COLUMN "reproducible" BOOLEAN NOT NULL DEFAULT false;
|
||||||
|
|
@ -259,8 +259,6 @@ model Recipe {
|
||||||
planningItems PlanningItem[]
|
planningItems PlanningItem[]
|
||||||
favoritedBy RecipeFavorite[]
|
favoritedBy RecipeFavorite[]
|
||||||
diets RecipeDiet[]
|
diets RecipeDiet[]
|
||||||
/// Ingredients for which this recipe is offered as a make-it-yourself alternative.
|
|
||||||
alternateFor Ingredient[] @relation("IngredientAlternateRecipe")
|
|
||||||
|
|
||||||
@@map("recipe")
|
@@map("recipe")
|
||||||
}
|
}
|
||||||
|
|
@ -437,9 +435,18 @@ model Ingredient {
|
||||||
icon IngredientIcon @default(JAR)
|
icon IngredientIcon @default(JAR)
|
||||||
category IngredientCategory @default(EPICERIE_SECHE)
|
category IngredientCategory @default(EPICERIE_SECHE)
|
||||||
subcategory IngredientSubcategory @default(AUTRES)
|
subcategory IngredientSubcategory @default(AUTRES)
|
||||||
alternateRecipeId Int? @map("alternate_recipe")
|
/// Whether this ingredient is reasonably makeable at home (a burger bun,
|
||||||
|
/// a béchamel) rather than something you'd only ever buy (a raw
|
||||||
|
/// vegetable, a specific cut of meat) — surfaced in the recipe form as a
|
||||||
|
/// badge/link nudging the author to go check the recipe catalog for a
|
||||||
|
/// "make it yourself" recipe (see `apps/web`'s `IngredientRow`/
|
||||||
|
/// `IngredientPicker`). Deliberately just a flag, not a link to a
|
||||||
|
/// specific recipe — replaces an earlier, never-wired-up
|
||||||
|
/// `alternateRecipeId` FK (product decision discussed in chat: no
|
||||||
|
/// ingredient↔recipe linking in the database, the UI only pre-fills the
|
||||||
|
/// catalog's own search with this ingredient's name).
|
||||||
|
reproducible Boolean @default(false)
|
||||||
|
|
||||||
alternateRecipe Recipe? @relation("IngredientAlternateRecipe", fields: [alternateRecipeId], references: [id], onDelete: SetNull)
|
|
||||||
recipes RecipeIngredient[]
|
recipes RecipeIngredient[]
|
||||||
allergies IngredientAllergy[]
|
allergies IngredientAllergy[]
|
||||||
/// Profiles that personally dislike this ingredient — see {@link UserProfileDislikedIngredient}.
|
/// Profiles that personally dislike this ingredient — see {@link UserProfileDislikedIngredient}.
|
||||||
|
|
|
||||||
|
|
@ -64,6 +64,18 @@ interface IngredientSeed {
|
||||||
* why).
|
* why).
|
||||||
*/
|
*/
|
||||||
dietNames?: string[];
|
dietNames?: string[];
|
||||||
|
/**
|
||||||
|
* Whether this ingredient is reasonably makeable at home (a burger bun, a
|
||||||
|
* béchamel) rather than something you'd only ever buy (a raw vegetable, a
|
||||||
|
* specific cut of meat) — see `Ingredient.reproducible` in schema.prisma.
|
||||||
|
* Omitted (falsy) by default; only set `true` on the curated subset this
|
||||||
|
* is actually true for. Never group-level (unlike `defaultDiets`/
|
||||||
|
* `defaultIcon`) — even a homogeneous-looking group like "Pains" mixes
|
||||||
|
* genuinely home-bakeable items (`Pain`, `Naan`) with ones nobody
|
||||||
|
* realistically bakes from scratch (`Pain de seigle`, `Biscotte`), so this
|
||||||
|
* needs a per-item judgment call, not a group default.
|
||||||
|
*/
|
||||||
|
reproducible?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 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
|
||||||
|
|
@ -425,14 +437,15 @@ export const INGREDIENT_GROUPS: Array<{
|
||||||
defaultDiets: ["Végétarien", "Végan", "Pescétarien"],
|
defaultDiets: ["Végétarien", "Végan", "Pescétarien"],
|
||||||
defaultIcon: "BREAD",
|
defaultIcon: "BREAD",
|
||||||
items: [
|
items: [
|
||||||
{ name: "Pain", allergenNames: ["Gluten"] },
|
{ name: "Pain", reproducible: true, allergenNames: ["Gluten"] },
|
||||||
{ name: "Pain de mie", allergenNames: ["Gluten"] },
|
{ name: "Pain de mie", reproducible: true, allergenNames: ["Gluten"] },
|
||||||
{ name: "Pain complet", allergenNames: ["Gluten"] },
|
{ name: "Pain complet", allergenNames: ["Gluten"] },
|
||||||
{ name: "Baguette", allergenNames: ["Gluten"] },
|
{ name: "Baguette", allergenNames: ["Gluten"] },
|
||||||
{ name: "Pain de seigle", allergenNames: ["Gluten"] },
|
{ name: "Pain de seigle", allergenNames: ["Gluten"] },
|
||||||
{ name: "Chapelure", allergenNames: ["Gluten"] },
|
{ name: "Chapelure", reproducible: true, allergenNames: ["Gluten"] },
|
||||||
{
|
{
|
||||||
name: "Pain à burger",
|
name: "Pain à burger",
|
||||||
|
reproducible: true,
|
||||||
allergenNames: ["Gluten", "Lait", "Œufs"],
|
allergenNames: ["Gluten", "Lait", "Œufs"],
|
||||||
dietNames: ["Végétarien", "Pescétarien"],
|
dietNames: ["Végétarien", "Pescétarien"],
|
||||||
},
|
},
|
||||||
|
|
@ -441,10 +454,10 @@ export const INGREDIENT_GROUPS: Array<{
|
||||||
allergenNames: ["Gluten", "Lait", "Œufs"],
|
allergenNames: ["Gluten", "Lait", "Œufs"],
|
||||||
dietNames: ["Végétarien", "Pescétarien"],
|
dietNames: ["Végétarien", "Pescétarien"],
|
||||||
},
|
},
|
||||||
{ name: "Pain à hot-dog", allergenNames: ["Gluten"] },
|
{ name: "Pain à hot-dog", reproducible: true, allergenNames: ["Gluten"] },
|
||||||
{ name: "Pain pita", allergenNames: ["Gluten"] },
|
{ name: "Pain pita", reproducible: true, allergenNames: ["Gluten"] },
|
||||||
{ name: "Pain bagel", allergenNames: ["Gluten"] },
|
{ name: "Pain bagel", reproducible: true, allergenNames: ["Gluten"] },
|
||||||
{ name: "Naan", allergenNames: ["Gluten"] },
|
{ name: "Naan", reproducible: true, allergenNames: ["Gluten"] },
|
||||||
{ name: "Pain wrap", allergenNames: ["Gluten"] },
|
{ name: "Pain wrap", allergenNames: ["Gluten"] },
|
||||||
{
|
{
|
||||||
name: "Pain viennois",
|
name: "Pain viennois",
|
||||||
|
|
@ -457,9 +470,9 @@ export const INGREDIENT_GROUPS: Array<{
|
||||||
{ name: "Pain suédois", allergenNames: ["Gluten"] },
|
{ name: "Pain suédois", allergenNames: ["Gluten"] },
|
||||||
{ name: "Pain sans gluten", allergenNames: [] },
|
{ name: "Pain sans gluten", allergenNames: [] },
|
||||||
{ name: "Biscotte", allergenNames: ["Gluten"] },
|
{ name: "Biscotte", allergenNames: ["Gluten"] },
|
||||||
{ name: "Croûtons", allergenNames: ["Gluten"] },
|
{ name: "Croûtons", reproducible: true, allergenNames: ["Gluten"] },
|
||||||
{ name: "Focaccia", allergenNames: ["Gluten"] },
|
{ name: "Focaccia", reproducible: true, allergenNames: ["Gluten"] },
|
||||||
{ name: "Ciabatta", allergenNames: ["Gluten"] },
|
{ name: "Ciabatta", reproducible: true, allergenNames: ["Gluten"] },
|
||||||
{ name: "Tortilla de maïs", allergenNames: [] },
|
{ name: "Tortilla de maïs", allergenNames: [] },
|
||||||
{ name: "Tortilla de blé", allergenNames: ["Gluten"] },
|
{ name: "Tortilla de blé", allergenNames: ["Gluten"] },
|
||||||
],
|
],
|
||||||
|
|
@ -472,17 +485,20 @@ export const INGREDIENT_GROUPS: Array<{
|
||||||
items: [
|
items: [
|
||||||
{
|
{
|
||||||
name: "Pâte feuilletée",
|
name: "Pâte feuilletée",
|
||||||
|
reproducible: true,
|
||||||
allergenNames: ["Gluten", "Lait"],
|
allergenNames: ["Gluten", "Lait"],
|
||||||
dietNames: ["Végétarien", "Pescétarien"],
|
dietNames: ["Végétarien", "Pescétarien"],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "Pâte brisée",
|
name: "Pâte brisée",
|
||||||
|
reproducible: true,
|
||||||
allergenNames: ["Gluten", "Lait"],
|
allergenNames: ["Gluten", "Lait"],
|
||||||
dietNames: ["Végétarien", "Pescétarien"],
|
dietNames: ["Végétarien", "Pescétarien"],
|
||||||
},
|
},
|
||||||
{ name: "Pâte à pizza", allergenNames: ["Gluten"] },
|
{ name: "Pâte à pizza", reproducible: true, allergenNames: ["Gluten"] },
|
||||||
{
|
{
|
||||||
name: "Pâte à tarte sablée",
|
name: "Pâte à tarte sablée",
|
||||||
|
reproducible: true,
|
||||||
allergenNames: ["Gluten", "Lait"],
|
allergenNames: ["Gluten", "Lait"],
|
||||||
dietNames: ["Végétarien", "Pescétarien"],
|
dietNames: ["Végétarien", "Pescétarien"],
|
||||||
},
|
},
|
||||||
|
|
@ -604,10 +620,11 @@ export const INGREDIENT_GROUPS: Array<{
|
||||||
{ name: "Moutarde", allergenNames: ["Moutarde"] },
|
{ name: "Moutarde", allergenNames: ["Moutarde"] },
|
||||||
{
|
{
|
||||||
name: "Mayonnaise",
|
name: "Mayonnaise",
|
||||||
|
reproducible: true,
|
||||||
allergenNames: ["Œufs"],
|
allergenNames: ["Œufs"],
|
||||||
dietNames: ["Végétarien", "Pescétarien"],
|
dietNames: ["Végétarien", "Pescétarien"],
|
||||||
},
|
},
|
||||||
{ name: "Ketchup", allergenNames: [] },
|
{ name: "Ketchup", reproducible: true, allergenNames: [] },
|
||||||
{ name: "Tabasco", allergenNames: [] },
|
{ name: "Tabasco", allergenNames: [] },
|
||||||
{
|
{
|
||||||
name: "Sauce Worcestershire",
|
name: "Sauce Worcestershire",
|
||||||
|
|
@ -625,7 +642,7 @@ export const INGREDIENT_GROUPS: Array<{
|
||||||
{ name: "Beurre de cacahuète", allergenNames: ["Arachides"] },
|
{ name: "Beurre de cacahuète", allergenNames: ["Arachides"] },
|
||||||
{ name: "Moutarde de Dijon", allergenNames: ["Moutarde"] },
|
{ name: "Moutarde de Dijon", allergenNames: ["Moutarde"] },
|
||||||
{ name: "Moutarde à l'ancienne", allergenNames: ["Moutarde"] },
|
{ name: "Moutarde à l'ancienne", allergenNames: ["Moutarde"] },
|
||||||
{ name: "Sauce barbecue", allergenNames: [] },
|
{ name: "Sauce barbecue", reproducible: true, allergenNames: [] },
|
||||||
{
|
{
|
||||||
name: "Sauce tartare",
|
name: "Sauce tartare",
|
||||||
allergenNames: ["Œufs"],
|
allergenNames: ["Œufs"],
|
||||||
|
|
@ -648,6 +665,7 @@ export const INGREDIENT_GROUPS: Array<{
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "Sauce béchamel",
|
name: "Sauce béchamel",
|
||||||
|
reproducible: true,
|
||||||
allergenNames: ["Lait", "Gluten"],
|
allergenNames: ["Lait", "Gluten"],
|
||||||
dietNames: ["Végétarien", "Pescétarien"],
|
dietNames: ["Végétarien", "Pescétarien"],
|
||||||
},
|
},
|
||||||
|
|
@ -665,6 +683,7 @@ export const INGREDIENT_GROUPS: Array<{
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "Pesto",
|
name: "Pesto",
|
||||||
|
reproducible: true,
|
||||||
allergenNames: ["Lait", "Fruits à coque"],
|
allergenNames: ["Lait", "Fruits à coque"],
|
||||||
dietNames: ["Végétarien", "Pescétarien"],
|
dietNames: ["Végétarien", "Pescétarien"],
|
||||||
},
|
},
|
||||||
|
|
@ -684,7 +703,7 @@ export const INGREDIENT_GROUPS: Array<{
|
||||||
},
|
},
|
||||||
{ name: "Pâte de curry rouge (thaï)", allergenNames: [] },
|
{ name: "Pâte de curry rouge (thaï)", allergenNames: [] },
|
||||||
{ name: "Pâte de curry vert (thaï)", allergenNames: [] },
|
{ name: "Pâte de curry vert (thaï)", allergenNames: [] },
|
||||||
{ name: "Tahini", allergenNames: ["Graines de sésame"] },
|
{ name: "Tahini", reproducible: true, allergenNames: ["Graines de sésame"] },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|
@ -764,8 +783,20 @@ export const INGREDIENT_GROUPS: Array<{
|
||||||
{ name: "Coulis de tomate", icon: "JAR", allergenNames: [] },
|
{ name: "Coulis de tomate", icon: "JAR", allergenNames: [] },
|
||||||
{ name: "Tomates pelées (conserve)", icon: "JAR", allergenNames: [] },
|
{ name: "Tomates pelées (conserve)", icon: "JAR", allergenNames: [] },
|
||||||
{ name: "Tomates séchées", icon: "JAR", allergenNames: [] },
|
{ name: "Tomates séchées", icon: "JAR", allergenNames: [] },
|
||||||
{ name: "Fond de veau", icon: "STOCK_POT", allergenNames: [], dietNames: [] },
|
{
|
||||||
{ name: "Fond de volaille", icon: "STOCK_POT", allergenNames: [], dietNames: [] },
|
name: "Fond de veau",
|
||||||
|
icon: "STOCK_POT",
|
||||||
|
reproducible: true,
|
||||||
|
allergenNames: [],
|
||||||
|
dietNames: [],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Fond de volaille",
|
||||||
|
icon: "STOCK_POT",
|
||||||
|
reproducible: true,
|
||||||
|
allergenNames: [],
|
||||||
|
dietNames: [],
|
||||||
|
},
|
||||||
{
|
{
|
||||||
name: "Bouillon cube bœuf",
|
name: "Bouillon cube bœuf",
|
||||||
icon: "STOCK_POT",
|
icon: "STOCK_POT",
|
||||||
|
|
@ -778,14 +809,26 @@ export const INGREDIENT_GROUPS: Array<{
|
||||||
allergenNames: ["Poissons", "Céleri"],
|
allergenNames: ["Poissons", "Céleri"],
|
||||||
dietNames: ["Pescétarien"],
|
dietNames: ["Pescétarien"],
|
||||||
},
|
},
|
||||||
{ name: "Bouillon de légumes", icon: "STOCK_POT", allergenNames: ["Céleri"] },
|
{
|
||||||
|
name: "Bouillon de légumes",
|
||||||
|
icon: "STOCK_POT",
|
||||||
|
reproducible: true,
|
||||||
|
allergenNames: ["Céleri"],
|
||||||
|
},
|
||||||
{
|
{
|
||||||
name: "Bouillon de volaille",
|
name: "Bouillon de volaille",
|
||||||
icon: "STOCK_POT",
|
icon: "STOCK_POT",
|
||||||
|
reproducible: true,
|
||||||
|
allergenNames: ["Céleri"],
|
||||||
|
dietNames: [],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Bouillon de bœuf",
|
||||||
|
icon: "STOCK_POT",
|
||||||
|
reproducible: true,
|
||||||
allergenNames: ["Céleri"],
|
allergenNames: ["Céleri"],
|
||||||
dietNames: [],
|
dietNames: [],
|
||||||
},
|
},
|
||||||
{ name: "Bouillon de bœuf", icon: "STOCK_POT", allergenNames: ["Céleri"], dietNames: [] },
|
|
||||||
{ name: "Court-bouillon", icon: "STOCK_POT", allergenNames: [] },
|
{ name: "Court-bouillon", icon: "STOCK_POT", allergenNames: [] },
|
||||||
{
|
{
|
||||||
name: "Dashi (bouillon japonais)",
|
name: "Dashi (bouillon japonais)",
|
||||||
|
|
@ -808,6 +851,7 @@ export const INGREDIENT_GROUPS: Array<{
|
||||||
{
|
{
|
||||||
name: "Fumet de poisson",
|
name: "Fumet de poisson",
|
||||||
icon: "STOCK_POT",
|
icon: "STOCK_POT",
|
||||||
|
reproducible: true,
|
||||||
allergenNames: ["Poissons"],
|
allergenNames: ["Poissons"],
|
||||||
dietNames: ["Pescétarien"],
|
dietNames: ["Pescétarien"],
|
||||||
},
|
},
|
||||||
|
|
@ -864,11 +908,12 @@ export const INGREDIENT_GROUPS: Array<{
|
||||||
];
|
];
|
||||||
|
|
||||||
const INGREDIENTS: Array<
|
const INGREDIENTS: Array<
|
||||||
Omit<IngredientSeed, "icon" | "dietNames"> & {
|
Omit<IngredientSeed, "icon" | "dietNames" | "reproducible"> & {
|
||||||
category: IngredientCategory;
|
category: IngredientCategory;
|
||||||
subcategory: IngredientSubcategory;
|
subcategory: IngredientSubcategory;
|
||||||
icon: IngredientIcon;
|
icon: IngredientIcon;
|
||||||
dietNames: string[];
|
dietNames: string[];
|
||||||
|
reproducible: boolean;
|
||||||
}
|
}
|
||||||
> = INGREDIENT_GROUPS.flatMap(({ category, subcategory, defaultDiets, defaultIcon, items }) =>
|
> = INGREDIENT_GROUPS.flatMap(({ category, subcategory, defaultDiets, defaultIcon, items }) =>
|
||||||
items.map((item) => ({
|
items.map((item) => ({
|
||||||
|
|
@ -877,6 +922,7 @@ const INGREDIENTS: Array<
|
||||||
subcategory,
|
subcategory,
|
||||||
icon: item.icon ?? defaultIcon,
|
icon: item.icon ?? defaultIcon,
|
||||||
dietNames: item.dietNames ?? defaultDiets,
|
dietNames: item.dietNames ?? defaultDiets,
|
||||||
|
reproducible: item.reproducible ?? false,
|
||||||
})),
|
})),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
@ -934,18 +980,26 @@ export async function seedReferenceData(prisma: PrismaClient): Promise<void> {
|
||||||
const ingredientKeys = INGREDIENTS.map((i) => getEnglishKey(i.name));
|
const ingredientKeys = INGREDIENTS.map((i) => getEnglishKey(i.name));
|
||||||
const existingIngredients = await prisma.ingredient.findMany({
|
const existingIngredients = await prisma.ingredient.findMany({
|
||||||
where: { key: { in: ingredientKeys } },
|
where: { key: { in: ingredientKeys } },
|
||||||
select: { id: true, key: true, icon: true, category: true, subcategory: true },
|
select: {
|
||||||
|
id: true,
|
||||||
|
key: true,
|
||||||
|
icon: true,
|
||||||
|
category: true,
|
||||||
|
subcategory: true,
|
||||||
|
reproducible: true,
|
||||||
|
},
|
||||||
});
|
});
|
||||||
const existingByKey = new Map(existingIngredients.map((i) => [i.key, i]));
|
const existingByKey = new Map(existingIngredients.map((i) => [i.key, i]));
|
||||||
|
|
||||||
const missingIngredients = INGREDIENTS.filter((i) => !existingByKey.has(getEnglishKey(i.name)));
|
const missingIngredients = INGREDIENTS.filter((i) => !existingByKey.has(getEnglishKey(i.name)));
|
||||||
if (missingIngredients.length > 0) {
|
if (missingIngredients.length > 0) {
|
||||||
await prisma.ingredient.createMany({
|
await prisma.ingredient.createMany({
|
||||||
data: missingIngredients.map(({ name, icon, category, subcategory }) => ({
|
data: missingIngredients.map(({ name, icon, category, subcategory, reproducible }) => ({
|
||||||
key: getEnglishKey(name),
|
key: getEnglishKey(name),
|
||||||
icon,
|
icon,
|
||||||
category,
|
category,
|
||||||
subcategory,
|
subcategory,
|
||||||
|
reproducible,
|
||||||
})),
|
})),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
@ -956,13 +1010,14 @@ export async function seedReferenceData(prisma: PrismaClient): Promise<void> {
|
||||||
existing &&
|
existing &&
|
||||||
(existing.icon !== i.icon ||
|
(existing.icon !== i.icon ||
|
||||||
existing.category !== i.category ||
|
existing.category !== i.category ||
|
||||||
existing.subcategory !== i.subcategory)
|
existing.subcategory !== i.subcategory ||
|
||||||
|
existing.reproducible !== i.reproducible)
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
for (const { name, icon, category, subcategory } of changed) {
|
for (const { name, icon, category, subcategory, reproducible } of changed) {
|
||||||
await prisma.ingredient.update({
|
await prisma.ingredient.update({
|
||||||
where: { key: getEnglishKey(name) },
|
where: { key: getEnglishKey(name) },
|
||||||
data: { icon, category, subcategory },
|
data: { icon, category, subcategory, reproducible },
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -43,6 +43,7 @@ function toIngredientView(ingredient: IngredientWithDetails): IngredientView {
|
||||||
icon: ingredient.icon,
|
icon: ingredient.icon,
|
||||||
category: ingredient.category,
|
category: ingredient.category,
|
||||||
subcategory: ingredient.subcategory,
|
subcategory: ingredient.subcategory,
|
||||||
|
reproducible: ingredient.reproducible,
|
||||||
allergens: ingredient.allergies.map(({ allergy }) => ({
|
allergens: ingredient.allergies.map(({ allergy }) => ({
|
||||||
id: allergy.id,
|
id: allergy.id,
|
||||||
key: allergy.category.key,
|
key: allergy.category.key,
|
||||||
|
|
|
||||||
|
|
@ -53,6 +53,7 @@ export async function getIngredients(): Promise<IngredientView[]> {
|
||||||
icon: ingredient.icon,
|
icon: ingredient.icon,
|
||||||
category: ingredient.category,
|
category: ingredient.category,
|
||||||
subcategory: ingredient.subcategory,
|
subcategory: ingredient.subcategory,
|
||||||
|
reproducible: ingredient.reproducible,
|
||||||
allergens: ingredient.allergies.map(({ allergy }) => ({
|
allergens: ingredient.allergies.map(({ allergy }) => ({
|
||||||
id: allergy.id,
|
id: allergy.id,
|
||||||
key: allergy.category.key,
|
key: allergy.category.key,
|
||||||
|
|
|
||||||
|
|
@ -62,6 +62,7 @@ describe("Reference data", () => {
|
||||||
"icon",
|
"icon",
|
||||||
"category",
|
"category",
|
||||||
"subcategory",
|
"subcategory",
|
||||||
|
"reproducible",
|
||||||
"allergens",
|
"allergens",
|
||||||
"diets",
|
"diets",
|
||||||
]);
|
]);
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,7 @@ import { CheckboxOption } from "../../components/ui/Checkbox";
|
||||||
import { SettingsIcon } from "../../layouts/nav-icons";
|
import { SettingsIcon } from "../../layouts/nav-icons";
|
||||||
import { AllergenBadges } from "./AllergenBadges";
|
import { AllergenBadges } from "./AllergenBadges";
|
||||||
import { DietBadges } from "./DietBadges";
|
import { DietBadges } from "./DietBadges";
|
||||||
|
import { ReproducibleBadge } from "./ReproducibleBadge";
|
||||||
import { CategoryIcon, IngredientTypeIcon, SubcategoryIcon } from "./ingredient-icons";
|
import { CategoryIcon, IngredientTypeIcon, SubcategoryIcon } from "./ingredient-icons";
|
||||||
import "./recipes.scss";
|
import "./recipes.scss";
|
||||||
|
|
||||||
|
|
@ -60,6 +61,7 @@ export function IngredientPicker({
|
||||||
// badges per card too noisy while just browsing/searching by name.
|
// badges per card too noisy while just browsing/searching by name.
|
||||||
const [showAllergens, setShowAllergens] = useState(true);
|
const [showAllergens, setShowAllergens] = useState(true);
|
||||||
const [showDiets, setShowDiets] = useState(true);
|
const [showDiets, setShowDiets] = useState(true);
|
||||||
|
const [showReproducible, setShowReproducible] = useState(true);
|
||||||
const [isDisplayMenuOpen, setIsDisplayMenuOpen] = useState(false);
|
const [isDisplayMenuOpen, setIsDisplayMenuOpen] = useState(false);
|
||||||
|
|
||||||
function selectCategory(next: IngredientCategory | typeof ALL) {
|
function selectCategory(next: IngredientCategory | typeof ALL) {
|
||||||
|
|
@ -116,6 +118,9 @@ export function IngredientPicker({
|
||||||
<CheckboxOption checked={showAllergens} onChange={setShowAllergens}>
|
<CheckboxOption checked={showAllergens} onChange={setShowAllergens}>
|
||||||
{t("recipes.form.showAllergensLabel")}
|
{t("recipes.form.showAllergensLabel")}
|
||||||
</CheckboxOption>
|
</CheckboxOption>
|
||||||
|
<CheckboxOption checked={showReproducible} onChange={setShowReproducible}>
|
||||||
|
{t("recipes.form.showReproducibleLabel")}
|
||||||
|
</CheckboxOption>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -184,6 +189,7 @@ export function IngredientPicker({
|
||||||
</span>
|
</span>
|
||||||
{showAllergens && <AllergenBadges allergens={ingredient.allergens} />}
|
{showAllergens && <AllergenBadges allergens={ingredient.allergens} />}
|
||||||
{showDiets && <DietBadges diets={ingredient.diets} />}
|
{showDiets && <DietBadges diets={ingredient.diets} />}
|
||||||
|
{showReproducible && <ReproducibleBadge reproducible={ingredient.reproducible} />}
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@ import type { IngredientView } from "@batch-cooking/shared";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { AllergenBadges } from "./AllergenBadges";
|
import { AllergenBadges } from "./AllergenBadges";
|
||||||
import { DietBadges } from "./DietBadges";
|
import { DietBadges } from "./DietBadges";
|
||||||
|
import { ReproducibleBadge } from "./ReproducibleBadge";
|
||||||
import { IngredientTypeIcon } from "./ingredient-icons";
|
import { IngredientTypeIcon } from "./ingredient-icons";
|
||||||
import "./recipes.scss";
|
import "./recipes.scss";
|
||||||
|
|
||||||
|
|
@ -48,6 +49,10 @@ export function IngredientRow({
|
||||||
/>
|
/>
|
||||||
<AllergenBadges allergens={ingredient.allergens} />
|
<AllergenBadges allergens={ingredient.allergens} />
|
||||||
<DietBadges diets={ingredient.diets} />
|
<DietBadges diets={ingredient.diets} />
|
||||||
|
<ReproducibleBadge
|
||||||
|
reproducible={ingredient.reproducible}
|
||||||
|
searchLabel={t(`catalog.ingredients.${ingredient.key}`)}
|
||||||
|
/>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="ingredient-row__remove"
|
className="ingredient-row__remove"
|
||||||
|
|
|
||||||
49
apps/web/src/features/recipes/ReproducibleBadge.tsx
Normal file
49
apps/web/src/features/recipes/ReproducibleBadge.tsx
Normal file
|
|
@ -0,0 +1,49 @@
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import "./recipes.scss";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Single "faisable maison" pill for an ingredient flagged
|
||||||
|
* `IngredientView.reproducible` — renders nothing otherwise, same "mount
|
||||||
|
* unconditionally" convention as `AllergenBadges`/`DietBadges`, just for
|
||||||
|
* one boolean rather than a list.
|
||||||
|
*
|
||||||
|
* Two shapes depending on where it's used:
|
||||||
|
* - `IngredientPicker`'s card grid: plain, non-interactive pill (no
|
||||||
|
* `searchLabel`).
|
||||||
|
* - `IngredientRow` (an ingredient already added to the recipe being
|
||||||
|
* built): pass `searchLabel` (its translated display name) to render it
|
||||||
|
* as a link opening the recipe catalog's own search, pre-filled with
|
||||||
|
* that name, in a new tab — a plain `<a>`, not a router `<Link>`, so the
|
||||||
|
* in-progress recipe form (no draft persistence, see `RecipeFormPage`)
|
||||||
|
* is never at risk of being navigated away from.
|
||||||
|
*/
|
||||||
|
export function ReproducibleBadge({
|
||||||
|
reproducible,
|
||||||
|
searchLabel,
|
||||||
|
}: {
|
||||||
|
reproducible: boolean;
|
||||||
|
searchLabel?: string;
|
||||||
|
}) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
if (!reproducible) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const label = t("recipes.form.reproducibleBadge");
|
||||||
|
|
||||||
|
if (searchLabel) {
|
||||||
|
return (
|
||||||
|
<a
|
||||||
|
className="reproducible-badge reproducible-badge--link"
|
||||||
|
href={`/recettes?search=${encodeURIComponent(searchLabel)}`}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
title={t("recipes.form.reproducibleBadgeHint", { name: searchLabel })}
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</a>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return <span className="reproducible-badge">{label}</span>;
|
||||||
|
}
|
||||||
|
|
@ -55,6 +55,34 @@
|
||||||
border: 1px dashed var(--color-border);
|
border: 1px dashed var(--color-border);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// A fourth, distinct pill language for `ReproducibleBadge` — neither a
|
||||||
|
// warning (allergen) nor a classification (diet) nor a negative (disliked):
|
||||||
|
// a positive nudge ("this could be homemade"), so it borrows
|
||||||
|
// --color-primary (the same tinted-fill treatment as `.is-selected` in
|
||||||
|
// global.scss) rather than any of the three above. `margin-top` matches its
|
||||||
|
// siblings' `.allergen-badges`/`.diet-badges` list wrappers even though this
|
||||||
|
// is a lone element, not a list, so it lines up the same way when it's the
|
||||||
|
// only badge present.
|
||||||
|
.reproducible-badge {
|
||||||
|
display: inline-block;
|
||||||
|
margin: var(--space-sm) 0 0;
|
||||||
|
padding: 0.15rem 0.6rem;
|
||||||
|
font-size: var(--font-size-xs);
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--color-primary);
|
||||||
|
background: color-mix(in srgb, var(--color-primary) 12%, var(--color-surface));
|
||||||
|
border-radius: var(--radius-pill);
|
||||||
|
}
|
||||||
|
|
||||||
|
.reproducible-badge--link {
|
||||||
|
text-decoration: none;
|
||||||
|
cursor: pointer;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: color-mix(in srgb, var(--color-primary) 22%, var(--color-surface));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// --- Catalog page -------------------------------------------------------------
|
// --- Catalog page -------------------------------------------------------------
|
||||||
// `.app-content` (AppLayout.scss) already stretches to the full viewport
|
// `.app-content` (AppLayout.scss) already stretches to the full viewport
|
||||||
// height — same reasoning as `planning-page.scss`. `.recipes-page` fills
|
// height — same reasoning as `planning-page.scss`. `.recipes-page` fills
|
||||||
|
|
|
||||||
|
|
@ -185,6 +185,9 @@
|
||||||
"displayOptions": "Options d'affichage",
|
"displayOptions": "Options d'affichage",
|
||||||
"showDietsLabel": "Régimes alimentaires",
|
"showDietsLabel": "Régimes alimentaires",
|
||||||
"showAllergensLabel": "Allergènes",
|
"showAllergensLabel": "Allergènes",
|
||||||
|
"showReproducibleLabel": "Faisable maison",
|
||||||
|
"reproducibleBadge": "Faisable maison",
|
||||||
|
"reproducibleBadgeHint": "Chercher une recette pour {{name}}",
|
||||||
"allCategories": "Tout",
|
"allCategories": "Tout",
|
||||||
"allSubcategories": "Tout",
|
"allSubcategories": "Tout",
|
||||||
"noIngredientFound": "Aucun ingrédient trouvé.",
|
"noIngredientFound": "Aucun ingrédient trouvé.",
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
import { ErrorCode, type RecipeSummaryView, type RecipeTab } from "@batch-cooking/shared";
|
import { ErrorCode, type RecipeSummaryView, type RecipeTab } from "@batch-cooking/shared";
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { Link, useNavigate, useParams } from "react-router-dom";
|
import { Link, useNavigate, useParams, useSearchParams } from "react-router-dom";
|
||||||
import { ApiError, apiClient } from "../api/client";
|
import { ApiError, apiClient } from "../api/client";
|
||||||
import { RecipeDetailPanel, type RecipeDetailState } from "../features/recipes/RecipeDetailPanel";
|
import { RecipeDetailPanel, type RecipeDetailState } from "../features/recipes/RecipeDetailPanel";
|
||||||
import { RecipeTable } from "../features/recipes/RecipeTable";
|
import { RecipeTable } from "../features/recipes/RecipeTable";
|
||||||
|
|
@ -30,10 +30,19 @@ export function RecipesPage() {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const { id } = useParams<{ id: string }>();
|
const { id } = useParams<{ id: string }>();
|
||||||
const selectedId = id !== undefined ? Number(id) : null;
|
const selectedId = id !== undefined ? Number(id) : null;
|
||||||
|
// `?search=` lets another page (the recipe form's "faisable maison"
|
||||||
|
// badge, see `ReproducibleBadge`) deep-link straight into a pre-filled
|
||||||
|
// search — read once on mount, not kept in sync on every keystroke
|
||||||
|
// afterwards (this page doesn't own the URL the way e.g. a shareable
|
||||||
|
// filter view would).
|
||||||
|
const [searchParams] = useSearchParams();
|
||||||
|
|
||||||
const [activeTab, setActiveTab] = useState<RecipeTab>("favoris");
|
const [activeTab, setActiveTab] = useState<RecipeTab>("favoris");
|
||||||
const [search, setSearch] = useState("");
|
const [search, setSearch] = useState(() => searchParams.get("search") ?? "");
|
||||||
const [debouncedSearch, setDebouncedSearch] = useState("");
|
// Seeded from the same initial value as `search` — otherwise the first
|
||||||
|
// fetch below would fire with an empty term (the debounce effect hasn't
|
||||||
|
// run yet), then a second one 300ms later once it catches up.
|
||||||
|
const [debouncedSearch, setDebouncedSearch] = useState(() => searchParams.get("search") ?? "");
|
||||||
const [listState, setListState] = useState<RecipeListState>({ status: "loading" });
|
const [listState, setListState] = useState<RecipeListState>({ status: "loading" });
|
||||||
const [detailState, setDetailState] = useState<RecipeDetailState>({ status: "empty" });
|
const [detailState, setDetailState] = useState<RecipeDetailState>({ status: "empty" });
|
||||||
const [dislikedIngredientIds, setDislikedIngredientIds] = useState<number[]>([]);
|
const [dislikedIngredientIds, setDislikedIngredientIds] = useState<number[]>([]);
|
||||||
|
|
|
||||||
|
|
@ -183,6 +183,8 @@ export interface IngredientView {
|
||||||
icon: IngredientIcon;
|
icon: IngredientIcon;
|
||||||
category: IngredientCategory;
|
category: IngredientCategory;
|
||||||
subcategory: IngredientSubcategory;
|
subcategory: IngredientSubcategory;
|
||||||
|
/** Whether this ingredient is reasonably makeable at home (a burger bun, a béchamel) rather than something you'd only ever buy — see `Ingredient.reproducible` in schema.prisma. Surfaced as a badge/link in the recipe form nudging toward the recipe catalog's own search, not a link to a specific recipe. */
|
||||||
|
reproducible: boolean;
|
||||||
allergens: AllergyView[];
|
allergens: AllergyView[];
|
||||||
diets: DietView[];
|
diets: DietView[];
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue