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[]
|
||||
favoritedBy RecipeFavorite[]
|
||||
diets RecipeDiet[]
|
||||
/// Ingredients for which this recipe is offered as a make-it-yourself alternative.
|
||||
alternateFor Ingredient[] @relation("IngredientAlternateRecipe")
|
||||
|
||||
@@map("recipe")
|
||||
}
|
||||
|
|
@ -437,9 +435,18 @@ model Ingredient {
|
|||
icon IngredientIcon @default(JAR)
|
||||
category IngredientCategory @default(EPICERIE_SECHE)
|
||||
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[]
|
||||
allergies IngredientAllergy[]
|
||||
/// Profiles that personally dislike this ingredient — see {@link UserProfileDislikedIngredient}.
|
||||
|
|
|
|||
|
|
@ -64,6 +64,18 @@ interface IngredientSeed {
|
|||
* why).
|
||||
*/
|
||||
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
|
||||
|
|
@ -425,14 +437,15 @@ export const INGREDIENT_GROUPS: Array<{
|
|||
defaultDiets: ["Végétarien", "Végan", "Pescétarien"],
|
||||
defaultIcon: "BREAD",
|
||||
items: [
|
||||
{ name: "Pain", allergenNames: ["Gluten"] },
|
||||
{ name: "Pain de mie", allergenNames: ["Gluten"] },
|
||||
{ name: "Pain", reproducible: true, allergenNames: ["Gluten"] },
|
||||
{ name: "Pain de mie", reproducible: true, allergenNames: ["Gluten"] },
|
||||
{ name: "Pain complet", allergenNames: ["Gluten"] },
|
||||
{ name: "Baguette", allergenNames: ["Gluten"] },
|
||||
{ name: "Pain de seigle", allergenNames: ["Gluten"] },
|
||||
{ name: "Chapelure", allergenNames: ["Gluten"] },
|
||||
{ name: "Chapelure", reproducible: true, allergenNames: ["Gluten"] },
|
||||
{
|
||||
name: "Pain à burger",
|
||||
reproducible: true,
|
||||
allergenNames: ["Gluten", "Lait", "Œufs"],
|
||||
dietNames: ["Végétarien", "Pescétarien"],
|
||||
},
|
||||
|
|
@ -441,10 +454,10 @@ export const INGREDIENT_GROUPS: Array<{
|
|||
allergenNames: ["Gluten", "Lait", "Œufs"],
|
||||
dietNames: ["Végétarien", "Pescétarien"],
|
||||
},
|
||||
{ name: "Pain à hot-dog", allergenNames: ["Gluten"] },
|
||||
{ name: "Pain pita", allergenNames: ["Gluten"] },
|
||||
{ name: "Pain bagel", allergenNames: ["Gluten"] },
|
||||
{ name: "Naan", allergenNames: ["Gluten"] },
|
||||
{ name: "Pain à hot-dog", reproducible: true, allergenNames: ["Gluten"] },
|
||||
{ name: "Pain pita", reproducible: true, allergenNames: ["Gluten"] },
|
||||
{ name: "Pain bagel", reproducible: true, allergenNames: ["Gluten"] },
|
||||
{ name: "Naan", reproducible: true, allergenNames: ["Gluten"] },
|
||||
{ name: "Pain wrap", allergenNames: ["Gluten"] },
|
||||
{
|
||||
name: "Pain viennois",
|
||||
|
|
@ -457,9 +470,9 @@ export const INGREDIENT_GROUPS: Array<{
|
|||
{ name: "Pain suédois", allergenNames: ["Gluten"] },
|
||||
{ name: "Pain sans gluten", allergenNames: [] },
|
||||
{ name: "Biscotte", allergenNames: ["Gluten"] },
|
||||
{ name: "Croûtons", allergenNames: ["Gluten"] },
|
||||
{ name: "Focaccia", allergenNames: ["Gluten"] },
|
||||
{ name: "Ciabatta", allergenNames: ["Gluten"] },
|
||||
{ name: "Croûtons", reproducible: true, allergenNames: ["Gluten"] },
|
||||
{ name: "Focaccia", reproducible: true, allergenNames: ["Gluten"] },
|
||||
{ name: "Ciabatta", reproducible: true, allergenNames: ["Gluten"] },
|
||||
{ name: "Tortilla de maïs", allergenNames: [] },
|
||||
{ name: "Tortilla de blé", allergenNames: ["Gluten"] },
|
||||
],
|
||||
|
|
@ -472,17 +485,20 @@ export const INGREDIENT_GROUPS: Array<{
|
|||
items: [
|
||||
{
|
||||
name: "Pâte feuilletée",
|
||||
reproducible: true,
|
||||
allergenNames: ["Gluten", "Lait"],
|
||||
dietNames: ["Végétarien", "Pescétarien"],
|
||||
},
|
||||
{
|
||||
name: "Pâte brisée",
|
||||
reproducible: true,
|
||||
allergenNames: ["Gluten", "Lait"],
|
||||
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",
|
||||
reproducible: true,
|
||||
allergenNames: ["Gluten", "Lait"],
|
||||
dietNames: ["Végétarien", "Pescétarien"],
|
||||
},
|
||||
|
|
@ -604,10 +620,11 @@ export const INGREDIENT_GROUPS: Array<{
|
|||
{ name: "Moutarde", allergenNames: ["Moutarde"] },
|
||||
{
|
||||
name: "Mayonnaise",
|
||||
reproducible: true,
|
||||
allergenNames: ["Œufs"],
|
||||
dietNames: ["Végétarien", "Pescétarien"],
|
||||
},
|
||||
{ name: "Ketchup", allergenNames: [] },
|
||||
{ name: "Ketchup", reproducible: true, allergenNames: [] },
|
||||
{ name: "Tabasco", allergenNames: [] },
|
||||
{
|
||||
name: "Sauce Worcestershire",
|
||||
|
|
@ -625,7 +642,7 @@ export const INGREDIENT_GROUPS: Array<{
|
|||
{ name: "Beurre de cacahuète", allergenNames: ["Arachides"] },
|
||||
{ name: "Moutarde de Dijon", allergenNames: ["Moutarde"] },
|
||||
{ name: "Moutarde à l'ancienne", allergenNames: ["Moutarde"] },
|
||||
{ name: "Sauce barbecue", allergenNames: [] },
|
||||
{ name: "Sauce barbecue", reproducible: true, allergenNames: [] },
|
||||
{
|
||||
name: "Sauce tartare",
|
||||
allergenNames: ["Œufs"],
|
||||
|
|
@ -648,6 +665,7 @@ export const INGREDIENT_GROUPS: Array<{
|
|||
},
|
||||
{
|
||||
name: "Sauce béchamel",
|
||||
reproducible: true,
|
||||
allergenNames: ["Lait", "Gluten"],
|
||||
dietNames: ["Végétarien", "Pescétarien"],
|
||||
},
|
||||
|
|
@ -665,6 +683,7 @@ export const INGREDIENT_GROUPS: Array<{
|
|||
},
|
||||
{
|
||||
name: "Pesto",
|
||||
reproducible: true,
|
||||
allergenNames: ["Lait", "Fruits à coque"],
|
||||
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 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: "Tomates pelées (conserve)", 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",
|
||||
icon: "STOCK_POT",
|
||||
|
|
@ -778,14 +809,26 @@ export const INGREDIENT_GROUPS: Array<{
|
|||
allergenNames: ["Poissons", "Céleri"],
|
||||
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",
|
||||
icon: "STOCK_POT",
|
||||
reproducible: true,
|
||||
allergenNames: ["Céleri"],
|
||||
dietNames: [],
|
||||
},
|
||||
{
|
||||
name: "Bouillon de bœuf",
|
||||
icon: "STOCK_POT",
|
||||
reproducible: true,
|
||||
allergenNames: ["Céleri"],
|
||||
dietNames: [],
|
||||
},
|
||||
{ name: "Bouillon de bœuf", icon: "STOCK_POT", allergenNames: ["Céleri"], dietNames: [] },
|
||||
{ name: "Court-bouillon", icon: "STOCK_POT", allergenNames: [] },
|
||||
{
|
||||
name: "Dashi (bouillon japonais)",
|
||||
|
|
@ -808,6 +851,7 @@ export const INGREDIENT_GROUPS: Array<{
|
|||
{
|
||||
name: "Fumet de poisson",
|
||||
icon: "STOCK_POT",
|
||||
reproducible: true,
|
||||
allergenNames: ["Poissons"],
|
||||
dietNames: ["Pescétarien"],
|
||||
},
|
||||
|
|
@ -864,11 +908,12 @@ export const INGREDIENT_GROUPS: Array<{
|
|||
];
|
||||
|
||||
const INGREDIENTS: Array<
|
||||
Omit<IngredientSeed, "icon" | "dietNames"> & {
|
||||
Omit<IngredientSeed, "icon" | "dietNames" | "reproducible"> & {
|
||||
category: IngredientCategory;
|
||||
subcategory: IngredientSubcategory;
|
||||
icon: IngredientIcon;
|
||||
dietNames: string[];
|
||||
reproducible: boolean;
|
||||
}
|
||||
> = INGREDIENT_GROUPS.flatMap(({ category, subcategory, defaultDiets, defaultIcon, items }) =>
|
||||
items.map((item) => ({
|
||||
|
|
@ -877,6 +922,7 @@ const INGREDIENTS: Array<
|
|||
subcategory,
|
||||
icon: item.icon ?? defaultIcon,
|
||||
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 existingIngredients = await prisma.ingredient.findMany({
|
||||
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 missingIngredients = INGREDIENTS.filter((i) => !existingByKey.has(getEnglishKey(i.name)));
|
||||
if (missingIngredients.length > 0) {
|
||||
await prisma.ingredient.createMany({
|
||||
data: missingIngredients.map(({ name, icon, category, subcategory }) => ({
|
||||
data: missingIngredients.map(({ name, icon, category, subcategory, reproducible }) => ({
|
||||
key: getEnglishKey(name),
|
||||
icon,
|
||||
category,
|
||||
subcategory,
|
||||
reproducible,
|
||||
})),
|
||||
});
|
||||
}
|
||||
|
|
@ -956,13 +1010,14 @@ export async function seedReferenceData(prisma: PrismaClient): Promise<void> {
|
|||
existing &&
|
||||
(existing.icon !== i.icon ||
|
||||
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({
|
||||
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,
|
||||
category: ingredient.category,
|
||||
subcategory: ingredient.subcategory,
|
||||
reproducible: ingredient.reproducible,
|
||||
allergens: ingredient.allergies.map(({ allergy }) => ({
|
||||
id: allergy.id,
|
||||
key: allergy.category.key,
|
||||
|
|
|
|||
|
|
@ -53,6 +53,7 @@ export async function getIngredients(): Promise<IngredientView[]> {
|
|||
icon: ingredient.icon,
|
||||
category: ingredient.category,
|
||||
subcategory: ingredient.subcategory,
|
||||
reproducible: ingredient.reproducible,
|
||||
allergens: ingredient.allergies.map(({ allergy }) => ({
|
||||
id: allergy.id,
|
||||
key: allergy.category.key,
|
||||
|
|
|
|||
|
|
@ -62,6 +62,7 @@ describe("Reference data", () => {
|
|||
"icon",
|
||||
"category",
|
||||
"subcategory",
|
||||
"reproducible",
|
||||
"allergens",
|
||||
"diets",
|
||||
]);
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ import { CheckboxOption } from "../../components/ui/Checkbox";
|
|||
import { SettingsIcon } from "../../layouts/nav-icons";
|
||||
import { AllergenBadges } from "./AllergenBadges";
|
||||
import { DietBadges } from "./DietBadges";
|
||||
import { ReproducibleBadge } from "./ReproducibleBadge";
|
||||
import { CategoryIcon, IngredientTypeIcon, SubcategoryIcon } from "./ingredient-icons";
|
||||
import "./recipes.scss";
|
||||
|
||||
|
|
@ -60,6 +61,7 @@ export function IngredientPicker({
|
|||
// badges per card too noisy while just browsing/searching by name.
|
||||
const [showAllergens, setShowAllergens] = useState(true);
|
||||
const [showDiets, setShowDiets] = useState(true);
|
||||
const [showReproducible, setShowReproducible] = useState(true);
|
||||
const [isDisplayMenuOpen, setIsDisplayMenuOpen] = useState(false);
|
||||
|
||||
function selectCategory(next: IngredientCategory | typeof ALL) {
|
||||
|
|
@ -116,6 +118,9 @@ export function IngredientPicker({
|
|||
<CheckboxOption checked={showAllergens} onChange={setShowAllergens}>
|
||||
{t("recipes.form.showAllergensLabel")}
|
||||
</CheckboxOption>
|
||||
<CheckboxOption checked={showReproducible} onChange={setShowReproducible}>
|
||||
{t("recipes.form.showReproducibleLabel")}
|
||||
</CheckboxOption>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
|
@ -184,6 +189,7 @@ export function IngredientPicker({
|
|||
</span>
|
||||
{showAllergens && <AllergenBadges allergens={ingredient.allergens} />}
|
||||
{showDiets && <DietBadges diets={ingredient.diets} />}
|
||||
{showReproducible && <ReproducibleBadge reproducible={ingredient.reproducible} />}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ import type { IngredientView } from "@batch-cooking/shared";
|
|||
import { useTranslation } from "react-i18next";
|
||||
import { AllergenBadges } from "./AllergenBadges";
|
||||
import { DietBadges } from "./DietBadges";
|
||||
import { ReproducibleBadge } from "./ReproducibleBadge";
|
||||
import { IngredientTypeIcon } from "./ingredient-icons";
|
||||
import "./recipes.scss";
|
||||
|
||||
|
|
@ -48,6 +49,10 @@ export function IngredientRow({
|
|||
/>
|
||||
<AllergenBadges allergens={ingredient.allergens} />
|
||||
<DietBadges diets={ingredient.diets} />
|
||||
<ReproducibleBadge
|
||||
reproducible={ingredient.reproducible}
|
||||
searchLabel={t(`catalog.ingredients.${ingredient.key}`)}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
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);
|
||||
}
|
||||
|
||||
// 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 -------------------------------------------------------------
|
||||
// `.app-content` (AppLayout.scss) already stretches to the full viewport
|
||||
// height — same reasoning as `planning-page.scss`. `.recipes-page` fills
|
||||
|
|
|
|||
|
|
@ -185,6 +185,9 @@
|
|||
"displayOptions": "Options d'affichage",
|
||||
"showDietsLabel": "Régimes alimentaires",
|
||||
"showAllergensLabel": "Allergènes",
|
||||
"showReproducibleLabel": "Faisable maison",
|
||||
"reproducibleBadge": "Faisable maison",
|
||||
"reproducibleBadgeHint": "Chercher une recette pour {{name}}",
|
||||
"allCategories": "Tout",
|
||||
"allSubcategories": "Tout",
|
||||
"noIngredientFound": "Aucun ingrédient trouvé.",
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import { ErrorCode, type RecipeSummaryView, type RecipeTab } from "@batch-cooking/shared";
|
||||
import { useEffect, useState } from "react";
|
||||
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 { RecipeDetailPanel, type RecipeDetailState } from "../features/recipes/RecipeDetailPanel";
|
||||
import { RecipeTable } from "../features/recipes/RecipeTable";
|
||||
|
|
@ -30,10 +30,19 @@ export function RecipesPage() {
|
|||
const navigate = useNavigate();
|
||||
const { id } = useParams<{ id: string }>();
|
||||
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 [search, setSearch] = useState("");
|
||||
const [debouncedSearch, setDebouncedSearch] = useState("");
|
||||
const [search, setSearch] = useState(() => searchParams.get("search") ?? "");
|
||||
// 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 [detailState, setDetailState] = useState<RecipeDetailState>({ status: "empty" });
|
||||
const [dislikedIngredientIds, setDislikedIngredientIds] = useState<number[]>([]);
|
||||
|
|
|
|||
|
|
@ -183,6 +183,8 @@ export interface IngredientView {
|
|||
icon: IngredientIcon;
|
||||
category: IngredientCategory;
|
||||
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[];
|
||||
diets: DietView[];
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue