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>
199 lines
8.1 KiB
TypeScript
199 lines
8.1 KiB
TypeScript
import {
|
|
INGREDIENT_CATEGORIES,
|
|
INGREDIENT_CATEGORY_SUBCATEGORIES,
|
|
type IngredientCategory,
|
|
type IngredientSubcategory,
|
|
type IngredientView,
|
|
} from "@batch-cooking/shared";
|
|
import { useState } from "react";
|
|
import { useTranslation } from "react-i18next";
|
|
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";
|
|
|
|
/** "No filter at this level" — a UI-only pseudo-value, never sent to/received from the API (see {@link INGREDIENT_CATEGORIES}/{@link INGREDIENT_CATEGORY_SUBCATEGORIES} for the real, closed sets). */
|
|
const ALL = "ALL" as const;
|
|
|
|
/**
|
|
* Browsable ingredient picker — a two-level category/subcategory drill-down
|
|
* plus search plus 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, and a single flat list of
|
|
* 7 aisles wouldn't either (some — "Condiments & épices" — are 100+ items
|
|
* deep) — so picking a category reveals a second row of subcategory chips
|
|
* scoped to it (reset whenever the category changes, since a subcategory
|
|
* from the previous one wouldn't apply). Search still narrows within (or
|
|
* across) whatever's selected 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.
|
|
*
|
|
* The settings menu trailing the search input shows/hides the allergen/diet
|
|
* badge rows on every card — a display preference local to this picker (not
|
|
* persisted), for whoever finds two rows of badges per card too noisy while
|
|
* just browsing/searching by name. A labeled checkbox menu rather than two
|
|
* bare icon-only toggle buttons — those turned out too ambiguous on their
|
|
* own (unclear what each icon meant without a label attached).
|
|
*/
|
|
export function IngredientPicker({
|
|
ingredients,
|
|
excludeIds,
|
|
onSelect,
|
|
}: {
|
|
ingredients: IngredientView[];
|
|
excludeIds: number[];
|
|
onSelect: (ingredient: IngredientView) => void;
|
|
}) {
|
|
const { t } = useTranslation();
|
|
const [category, setCategory] = useState<IngredientCategory | typeof ALL>(ALL);
|
|
const [subcategory, setSubcategory] = useState<IngredientSubcategory | typeof ALL>(ALL);
|
|
const [query, setQuery] = useState("");
|
|
// Purely a display preference for this picker's own card grid — doesn't
|
|
// touch which ingredients `visible` includes, only whether their
|
|
// allergen/diet badges render. Defaults to shown (the previous, only
|
|
// behavior); collapsing them is an opt-in for whoever finds two rows of
|
|
// 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) {
|
|
setCategory(next);
|
|
setSubcategory(ALL);
|
|
}
|
|
|
|
const normalizedQuery = query.trim().toLowerCase();
|
|
const visible = ingredients.filter((ingredient) => {
|
|
if (excludeIds.includes(ingredient.id)) return false;
|
|
if (category !== ALL) {
|
|
if (ingredient.category !== category) return false;
|
|
if (subcategory !== ALL && ingredient.subcategory !== subcategory) return false;
|
|
}
|
|
// Matches against the *displayed* (translated) label, not the raw slug
|
|
// key — searching "œuf" should find "Œuf" the way it always has, not
|
|
// require typing its key.
|
|
if (normalizedQuery.length > 0) {
|
|
const label = t(`catalog.ingredients.${ingredient.key}`).toLowerCase();
|
|
if (!label.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 className="ingredient-picker__display-options">
|
|
<button
|
|
type="button"
|
|
className={`ingredient-picker__display-toggle${isDisplayMenuOpen ? " active" : ""}`}
|
|
onClick={() => setIsDisplayMenuOpen((v) => !v)}
|
|
aria-expanded={isDisplayMenuOpen}
|
|
title={t("recipes.form.displayOptions")}
|
|
>
|
|
<SettingsIcon aria-hidden="true" />
|
|
</button>
|
|
{isDisplayMenuOpen && (
|
|
<div className="ingredient-picker__display-menu">
|
|
<CheckboxOption checked={showDiets} onChange={setShowDiets}>
|
|
{t("recipes.form.showDietsLabel")}
|
|
</CheckboxOption>
|
|
<CheckboxOption checked={showAllergens} onChange={setShowAllergens}>
|
|
{t("recipes.form.showAllergensLabel")}
|
|
</CheckboxOption>
|
|
<CheckboxOption checked={showReproducible} onChange={setShowReproducible}>
|
|
{t("recipes.form.showReproducibleLabel")}
|
|
</CheckboxOption>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
<div className="ingredient-picker__categories">
|
|
<button
|
|
type="button"
|
|
className={`ingredient-picker__category${category === ALL ? " active" : ""}`}
|
|
onClick={() => selectCategory(ALL)}
|
|
>
|
|
{t("recipes.form.allCategories")}
|
|
</button>
|
|
{INGREDIENT_CATEGORIES.map((c) => (
|
|
<button
|
|
key={c}
|
|
type="button"
|
|
className={`ingredient-picker__category${category === c ? " active" : ""}`}
|
|
onClick={() => selectCategory(c)}
|
|
>
|
|
<CategoryIcon category={c} />
|
|
{t(`recipes.form.category.${c}`)}
|
|
</button>
|
|
))}
|
|
</div>
|
|
|
|
{category !== ALL && (
|
|
<div className="ingredient-picker__subcategories">
|
|
<button
|
|
type="button"
|
|
className={`ingredient-picker__subcategory${subcategory === ALL ? " active" : ""}`}
|
|
onClick={() => setSubcategory(ALL)}
|
|
>
|
|
{t("recipes.form.allSubcategories")}
|
|
</button>
|
|
{INGREDIENT_CATEGORY_SUBCATEGORIES[category].map((s) => (
|
|
<button
|
|
key={s}
|
|
type="button"
|
|
className={`ingredient-picker__subcategory${subcategory === s ? " active" : ""}`}
|
|
onClick={() => setSubcategory(s)}
|
|
>
|
|
<SubcategoryIcon subcategory={s} />
|
|
{t(`recipes.form.subcategory.${s}`)}
|
|
</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">
|
|
<IngredientTypeIcon icon={ingredient.icon} />
|
|
</span>
|
|
<span className="ingredient-picker__card-name">
|
|
{t(`catalog.ingredients.${ingredient.key}`)}
|
|
</span>
|
|
{showAllergens && <AllergenBadges allergens={ingredient.allergens} />}
|
|
{showDiets && <DietBadges diets={ingredient.diets} />}
|
|
{showReproducible && <ReproducibleBadge reproducible={ingredient.reproducible} />}
|
|
</button>
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|