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:
Nicolas 2026-08-19 19:09:39 +02:00
parent a8eea46c58
commit 978fe71a11
13 changed files with 216 additions and 40 deletions

View file

@ -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;

View file

@ -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")
} }
@ -432,20 +430,29 @@ enum IngredientIcon {
} }
model Ingredient { model Ingredient {
id Int @id @default(autoincrement()) id Int @id @default(autoincrement())
key String @unique key String @unique
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}.
dislikedBy UserProfileDislikedIngredient[] dislikedBy UserProfileDislikedIngredient[]
/// Diet regimes this ingredient is compatible with — see {@link IngredientDiet}. /// Diet regimes this ingredient is compatible with — see {@link IngredientDiet}.
diets IngredientDiet[] diets IngredientDiet[]
@@map("ingredients") @@map("ingredients")
} }

View file

@ -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 },
}); });
} }

View file

@ -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,

View file

@ -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,

View file

@ -62,6 +62,7 @@ describe("Reference data", () => {
"icon", "icon",
"category", "category",
"subcategory", "subcategory",
"reproducible",
"allergens", "allergens",
"diets", "diets",
]); ]);

View file

@ -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>

View file

@ -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"

View 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>;
}

View file

@ -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

View file

@ -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é.",

View file

@ -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[]>([]);

View file

@ -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[];
} }