feat(recipes): refonte de la catégorisation des ingrédients façon rayons de supermarché
Remplace les 18 catégories plates (mélangeant rayons génériques et cuisines d'origine — un ingrédient pouvait finir dans "légumes" ou "cuisine italienne" selon l'angle choisi) par une hiérarchie à 2 niveaux sur le modèle d'un supermarché français : - 🥦 Produits frais (légumes, fruits, herbes fraîches) - 🥩 Boucherie & poissonnerie (viandes, volailles, poissons, crustacés & fruits de mer) - 🥫 Épicerie sèche (féculents, légumineuses, graines & fruits secs, autres produits secs/conserves) - 🍞 Boulangerie (pains, pâtes à cuire — 4 nouveaux ingrédients : pâte feuilletée/brisée/à pizza/à tarte sablée) - 🧈 Crémerie & fromage (produits laitiers, œufs, alternatives végétales) - 🧂 Condiments & épices (épices, sauces, assaisonnements) - 🍳 Aides culinaires (bases, épaississants, sucres) - Schéma : nouvel enum IngredientCategory (7 valeurs) + nouvel enum IngredientSubcategory (22 valeurs) + colonne Ingredient.subcategory. Migration appliquée via reset (données de référence, aucune perte réelle) car les anciennes valeurs d'enum n'existent plus dans les nouvelles. - reference-seed-data.ts entièrement réorganisé par (catégorie, sous- catégorie), tous les 437 ingrédients recatégorisés un par un. - packages/shared : INGREDIENT_CATEGORIES (7), INGREDIENT_SUBCATEGORIES (22) et INGREDIENT_CATEGORY_SUBCATEGORIES (mapping catégorie → sous-catégories, pour piloter le picker) remplacent l'ancienne liste plate. - IngredientPicker : drill-down à 2 niveaux — la ligne de sous- catégories (couleur --color-tag, visuellement subordonnée) apparaît une fois une catégorie choisie, et se réinitialise au changement de catégorie. - i18n : nouvelles clés recipes.form.category.* (7) et recipes.form.subcategory.* (22). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
parent
11b03b0e56
commit
9775cbe38f
10 changed files with 875 additions and 579 deletions
|
|
@ -0,0 +1,18 @@
|
|||
-- CreateEnum
|
||||
CREATE TYPE "IngredientSubcategory" AS ENUM ('LEGUMES', 'FRUITS', 'HERBES_FRAICHES', 'VIANDES', 'VOLAILLES', 'POISSONS', 'CRUSTACES_FRUITS_DE_MER', 'FECULENTS', 'LEGUMINEUSES', 'GRAINES_FRUITS_SECS', 'AUTRES', 'PAINS', 'PATES_A_CUIRE', 'PRODUITS_LAITIERS', 'OEUFS', 'ALTERNATIVES', 'EPICES', 'SAUCES', 'ASSAISONNEMENTS', 'BASES', 'EPAISSISSANTS', 'SUCRES');
|
||||
|
||||
-- AlterEnum
|
||||
BEGIN;
|
||||
CREATE TYPE "IngredientCategory_new" AS ENUM ('PRODUITS_FRAIS', 'BOUCHERIE_POISSONNERIE', 'EPICERIE_SECHE', 'BOULANGERIE', 'CREMERIE_FROMAGE', 'CONDIMENTS_EPICES', 'AIDES_CULINAIRES');
|
||||
ALTER TABLE "ingredients" ALTER COLUMN "category" DROP DEFAULT;
|
||||
ALTER TABLE "ingredients" ALTER COLUMN "category" TYPE "IngredientCategory_new" USING ("category"::text::"IngredientCategory_new");
|
||||
ALTER TYPE "IngredientCategory" RENAME TO "IngredientCategory_old";
|
||||
ALTER TYPE "IngredientCategory_new" RENAME TO "IngredientCategory";
|
||||
DROP TYPE "IngredientCategory_old";
|
||||
ALTER TABLE "ingredients" ALTER COLUMN "category" SET DEFAULT 'EPICERIE_SECHE';
|
||||
COMMIT;
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "ingredients" ADD COLUMN "subcategory" "IngredientSubcategory" NOT NULL DEFAULT 'AUTRES',
|
||||
ALTER COLUMN "category" SET DEFAULT 'EPICERIE_SECHE';
|
||||
|
||||
|
|
@ -293,43 +293,103 @@ model RecipeDiet {
|
|||
/// idempotent/safe to re-run, same reason as `Diet.name`/`Category.name`.
|
||||
/// Ingredients are reference data (like Diet/Allergy): seeded, never
|
||||
/// created/edited/deleted through the API.
|
||||
/// Not in the original spec doc — coarse grouping (viandes, légumes,
|
||||
/// épices...) so the ingredient picker (apps/web) can offer category
|
||||
/// browsing, not just free-text search: with 400+ reference ingredients,
|
||||
/// search alone doesn't scale to actually *finding* one. Mirrors
|
||||
/// Not in the original spec doc — supermarket-aisle grouping ("rayons") so
|
||||
/// the ingredient picker (apps/web) can offer category browsing, not just
|
||||
/// free-text search: with 400+ reference ingredients, search alone doesn't
|
||||
/// scale to actually *finding* one. Reworked from an earlier, less
|
||||
/// intuitive scheme (cuisine-of-origin categories mixed in with aisle-style
|
||||
/// ones, e.g. a "cuisine italienne" bucket sitting next to "légumes" —
|
||||
/// meant an ingredient's category depended on which one you thought of
|
||||
/// first) into how a French grocery store is actually laid out: 7 aisles,
|
||||
/// each with a couple of {@link IngredientSubcategory} racks for finer
|
||||
/// browsing once "Épicerie sèche" alone would be 100+ items deep. Mirrors
|
||||
/// `reference-seed-data.ts`'s `INGREDIENT_GROUPS` keys exactly — that file
|
||||
/// is the single source of truth for which ingredient belongs to which
|
||||
/// category, this enum just gives it a type-safe column to live in.
|
||||
/// `@default(EPICERIE_DIVERS)` exists only so this column can be added
|
||||
/// `NOT NULL` to a table that may already have rows — the seed script
|
||||
/// corrects every row's real category on the very next run, this default
|
||||
/// is never the intended value for a real ingredient.
|
||||
/// (category, subcategory) pair, these enums just give it type-safe
|
||||
/// columns to live in. `@default(EPICERIE_SECHE)` exists only so this
|
||||
/// column can be added `NOT NULL` to a table that may already have rows —
|
||||
/// the seed script corrects every row's real category on the very next
|
||||
/// run, this default is never the intended value for a real ingredient.
|
||||
enum IngredientCategory {
|
||||
CEREALES_FECULENTS
|
||||
LEGUMINEUSES
|
||||
VIANDES_VOLAILLES
|
||||
POISSONS_FRUITS_DE_MER
|
||||
PRODUITS_LAITIERS_OEUFS
|
||||
/// 🥦 Légumes, fruits, herbes fraîches.
|
||||
PRODUITS_FRAIS
|
||||
/// 🥩 Viandes, volailles, poissons, crustacés & fruits de mer.
|
||||
BOUCHERIE_POISSONNERIE
|
||||
/// 🥫 Féculents, légumineuses, graines & fruits secs, et le reste des
|
||||
/// produits secs/en conserve qui ne rentre dans aucune autre case
|
||||
/// (algues séchées, champignons séchés…).
|
||||
EPICERIE_SECHE
|
||||
/// 🍞 Pains et pâtes à cuire (crues, à enfourner).
|
||||
BOULANGERIE
|
||||
/// 🧈 Produits laitiers, œufs, alternatives végétales (laits végétaux,
|
||||
/// tofu…).
|
||||
CREMERIE_FROMAGE
|
||||
/// 🧂 Épices, sauces, assaisonnements (huiles, vinaigres, alcools de
|
||||
/// cuisine…).
|
||||
CONDIMENTS_EPICES
|
||||
/// 🍳 Bases de préparation (farines, bouillons, eau), épaississants
|
||||
/// (levures, fécules, gélatine), sucres.
|
||||
AIDES_CULINAIRES
|
||||
}
|
||||
|
||||
/// Finer-grained rack within one {@link IngredientCategory} aisle — see
|
||||
/// that enum's doc comment for why this two-level scheme replaced a flat
|
||||
/// list. Each value belongs to exactly one category by construction (see
|
||||
/// `reference-seed-data.ts`'s `INGREDIENT_GROUPS`, not enforced at the
|
||||
/// database level — Postgres enums can't express that relationship, same
|
||||
/// tradeoff already accepted for `IngredientCategory` itself).
|
||||
/// `@default(AUTRES)` — same NOT-NULL-migration-safety-net reasoning as
|
||||
/// `IngredientCategory`'s default, never the intended value for a real row.
|
||||
enum IngredientSubcategory {
|
||||
// --- Produits frais ------------------------------------------------------
|
||||
LEGUMES
|
||||
FRUITS
|
||||
FRUITS_SECS_OLEAGINEUX
|
||||
CONDIMENTS_SAUCES
|
||||
EPICES_HERBES
|
||||
SUCRE_PATISSERIE
|
||||
CUISINE_ITALIENNE
|
||||
CUISINE_ASIATIQUE
|
||||
CUISINE_MEXICAINE
|
||||
MAGHREB_MOYEN_ORIENT
|
||||
PAINS_SANDWICHS
|
||||
EPICERIE_DIVERS
|
||||
LIQUIDES_BOISSONS
|
||||
HERBES_FRAICHES
|
||||
// --- Boucherie & poissonnerie ---------------------------------------------
|
||||
VIANDES
|
||||
VOLAILLES
|
||||
POISSONS
|
||||
CRUSTACES_FRUITS_DE_MER
|
||||
// --- Épicerie sèche --------------------------------------------------------
|
||||
FECULENTS
|
||||
LEGUMINEUSES
|
||||
GRAINES_FRUITS_SECS
|
||||
/// Catch-all for dried/tinned pantry items that don't fit the three
|
||||
/// subcategories above — dried seaweed, dried mushrooms, tinned bamboo
|
||||
/// shoots/water chestnuts…
|
||||
AUTRES
|
||||
// --- Boulangerie -------------------------------------------------------
|
||||
PAINS
|
||||
/// Raw, uncooked doughs meant to be baked (puff pastry, shortcrust…) —
|
||||
/// distinct from `PAINS` (already-baked bread).
|
||||
PATES_A_CUIRE
|
||||
// --- Crémerie & fromage --------------------------------------------------
|
||||
PRODUITS_LAITIERS
|
||||
OEUFS
|
||||
/// Plant-based dairy/meat substitutes — coconut/almond/oat "milk", tofu.
|
||||
ALTERNATIVES
|
||||
// --- Condiments & épices -------------------------------------------------
|
||||
EPICES
|
||||
SAUCES
|
||||
/// Oils, vinegars, citrus juices, cooking alcohols/wines — liquids that
|
||||
/// season rather than form the base of a dish.
|
||||
ASSAISONNEMENTS
|
||||
// --- Aides culinaires ----------------------------------------------------
|
||||
/// Flours, stocks/broths, canned tomato bases, water — the literal base
|
||||
/// a recipe is built on.
|
||||
BASES
|
||||
/// Leavening/gelling/thickening agents — yeast, baking soda, cornstarch,
|
||||
/// gelatin.
|
||||
EPAISSISSANTS
|
||||
SUCRES
|
||||
}
|
||||
|
||||
model Ingredient {
|
||||
id Int @id @default(autoincrement())
|
||||
name String @unique
|
||||
icon String?
|
||||
category IngredientCategory @default(EPICERIE_DIVERS)
|
||||
category IngredientCategory @default(EPICERIE_SECHE)
|
||||
subcategory IngredientSubcategory @default(AUTRES)
|
||||
alternateRecipeId Int? @map("alternate_recipe")
|
||||
|
||||
alternateRecipe Recipe? @relation("IngredientAlternateRecipe", fields: [alternateRecipeId], references: [id], onDelete: SetNull)
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -42,6 +42,7 @@ function toIngredientView(ingredient: IngredientWithDetails): IngredientView {
|
|||
name: ingredient.name,
|
||||
icon: ingredient.icon,
|
||||
category: ingredient.category,
|
||||
subcategory: ingredient.subcategory,
|
||||
allergens: ingredient.allergies.map(({ allergy }) => ({
|
||||
id: allergy.id,
|
||||
name: allergy.category.name,
|
||||
|
|
|
|||
|
|
@ -44,6 +44,7 @@ export async function getIngredients(): Promise<IngredientView[]> {
|
|||
name: ingredient.name,
|
||||
icon: ingredient.icon,
|
||||
category: ingredient.category,
|
||||
subcategory: ingredient.subcategory,
|
||||
allergens: ingredient.allergies.map(({ allergy }) => ({
|
||||
id: allergy.id,
|
||||
name: allergy.category.name,
|
||||
|
|
|
|||
|
|
@ -54,7 +54,15 @@ describe("Reference data", () => {
|
|||
expect(res.status).to.equal(200);
|
||||
expect(res.body.length).to.be.greaterThan(0);
|
||||
expect(res.body.map((i: { name: string }) => i.name)).to.include("Tomate");
|
||||
expect(res.body[0]).to.have.keys(["id", "name", "icon", "category", "allergens", "diets"]);
|
||||
expect(res.body[0]).to.have.keys([
|
||||
"id",
|
||||
"name",
|
||||
"icon",
|
||||
"category",
|
||||
"subcategory",
|
||||
"allergens",
|
||||
"diets",
|
||||
]);
|
||||
});
|
||||
|
||||
it("resolves each ingredient's linked allergens, empty for one with none", async () => {
|
||||
|
|
|
|||
|
|
@ -1,6 +1,8 @@
|
|||
import {
|
||||
INGREDIENT_CATEGORIES,
|
||||
INGREDIENT_CATEGORY_SUBCATEGORIES,
|
||||
type IngredientCategory,
|
||||
type IngredientSubcategory,
|
||||
type IngredientView,
|
||||
} from "@batch-cooking/shared";
|
||||
import { useState } from "react";
|
||||
|
|
@ -10,15 +12,19 @@ import { AllergenBadges } from "./AllergenBadges";
|
|||
import { DietBadges } from "./DietBadges";
|
||||
import "./recipes.scss";
|
||||
|
||||
/** "No category filter" — a UI-only pseudo-category, never sent to/received from the API (see {@link INGREDIENT_CATEGORIES} for the real, closed set). */
|
||||
const ALL_CATEGORIES = "ALL" as const;
|
||||
/** "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 — category chips + search + a card grid,
|
||||
* replacing the earlier `IngredientAutocomplete` (a plain type-to-filter
|
||||
* dropdown). With 400+ reference ingredients, search alone doesn't scale to
|
||||
* actually *finding* one — category browsing is the main fix; search still
|
||||
* narrows within (or across) categories for when the name is already known.
|
||||
* 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
|
||||
|
|
@ -40,9 +46,8 @@ export function IngredientPicker({
|
|||
onSelect: (ingredient: IngredientView) => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const [category, setCategory] = useState<IngredientCategory | typeof ALL_CATEGORIES>(
|
||||
ALL_CATEGORIES,
|
||||
);
|
||||
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
|
||||
|
|
@ -52,10 +57,18 @@ export function IngredientPicker({
|
|||
const [showAllergens, setShowAllergens] = useState(true);
|
||||
const [showDiets, setShowDiets] = useState(true);
|
||||
|
||||
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_CATEGORIES && ingredient.category !== category) return false;
|
||||
if (category !== ALL) {
|
||||
if (ingredient.category !== category) return false;
|
||||
if (subcategory !== ALL && ingredient.subcategory !== subcategory) return false;
|
||||
}
|
||||
if (normalizedQuery.length > 0 && !ingredient.name.toLowerCase().includes(normalizedQuery)) {
|
||||
return false;
|
||||
}
|
||||
|
|
@ -101,8 +114,8 @@ export function IngredientPicker({
|
|||
<div className="ingredient-picker__categories">
|
||||
<button
|
||||
type="button"
|
||||
className={`ingredient-picker__category${category === ALL_CATEGORIES ? " active" : ""}`}
|
||||
onClick={() => setCategory(ALL_CATEGORIES)}
|
||||
className={`ingredient-picker__category${category === ALL ? " active" : ""}`}
|
||||
onClick={() => selectCategory(ALL)}
|
||||
>
|
||||
{t("recipes.form.allCategories")}
|
||||
</button>
|
||||
|
|
@ -111,13 +124,35 @@ export function IngredientPicker({
|
|||
key={c}
|
||||
type="button"
|
||||
className={`ingredient-picker__category${category === c ? " active" : ""}`}
|
||||
onClick={() => setCategory(c)}
|
||||
onClick={() => selectCategory(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)}
|
||||
>
|
||||
{t(`recipes.form.subcategory.${s}`)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{visible.length === 0 ? (
|
||||
<p className="ingredient-picker__empty">{t("recipes.form.noIngredientFound")}</p>
|
||||
) : (
|
||||
|
|
|
|||
|
|
@ -775,6 +775,43 @@
|
|||
}
|
||||
}
|
||||
|
||||
// Second-tier drill-down, shown once a main category is picked — same
|
||||
// "scroll, don't crush" row as `&__categories` above, but visually
|
||||
// subordinate: smaller, and using `--color-tag` (not `--color-primary`)
|
||||
// for its active state so the two levels stay legible at a glance.
|
||||
&__subcategories {
|
||||
display: flex;
|
||||
gap: var(--space-xs);
|
||||
overflow-x: auto;
|
||||
padding-bottom: var(--space-xs);
|
||||
margin-top: calc(var(--space-xs) * -1);
|
||||
}
|
||||
|
||||
&__subcategory {
|
||||
flex-shrink: 0;
|
||||
appearance: none;
|
||||
padding: 2px var(--space-sm);
|
||||
font-family: var(--font-body);
|
||||
font-size: var(--font-size-xs);
|
||||
white-space: nowrap;
|
||||
cursor: pointer;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-pill);
|
||||
background: transparent;
|
||||
color: var(--color-text-muted);
|
||||
|
||||
&:hover {
|
||||
border-color: var(--color-tag);
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
&.active {
|
||||
border-color: var(--color-tag);
|
||||
background: var(--color-tag);
|
||||
color: var(--color-tag-ink);
|
||||
}
|
||||
}
|
||||
|
||||
&__grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(7rem, 1fr));
|
||||
|
|
|
|||
|
|
@ -169,26 +169,40 @@
|
|||
"toggleDiets": "Afficher/masquer les régimes alimentaires",
|
||||
"toggleAllergens": "Afficher/masquer les allergènes",
|
||||
"allCategories": "Tout",
|
||||
"allSubcategories": "Tout",
|
||||
"noIngredientFound": "Aucun ingrédient trouvé.",
|
||||
"category": {
|
||||
"CEREALES_FECULENTS": "Céréales & féculents",
|
||||
"LEGUMINEUSES": "Légumineuses",
|
||||
"VIANDES_VOLAILLES": "Viandes & volailles",
|
||||
"POISSONS_FRUITS_DE_MER": "Poissons & fruits de mer",
|
||||
"PRODUITS_LAITIERS_OEUFS": "Produits laitiers & œufs",
|
||||
"PRODUITS_FRAIS": "🥦 Produits frais",
|
||||
"BOUCHERIE_POISSONNERIE": "🥩 Boucherie & poissonnerie",
|
||||
"EPICERIE_SECHE": "🥫 Épicerie sèche",
|
||||
"BOULANGERIE": "🍞 Boulangerie",
|
||||
"CREMERIE_FROMAGE": "🧈 Crémerie & fromage",
|
||||
"CONDIMENTS_EPICES": "🧂 Condiments & épices",
|
||||
"AIDES_CULINAIRES": "🍳 Aides culinaires"
|
||||
},
|
||||
"subcategory": {
|
||||
"LEGUMES": "Légumes",
|
||||
"FRUITS": "Fruits",
|
||||
"FRUITS_SECS_OLEAGINEUX": "Fruits secs & oléagineux",
|
||||
"CONDIMENTS_SAUCES": "Condiments & sauces",
|
||||
"EPICES_HERBES": "Épices & herbes",
|
||||
"SUCRE_PATISSERIE": "Sucre & pâtisserie",
|
||||
"CUISINE_ITALIENNE": "Cuisine italienne",
|
||||
"CUISINE_ASIATIQUE": "Cuisine asiatique",
|
||||
"CUISINE_MEXICAINE": "Cuisine mexicaine",
|
||||
"MAGHREB_MOYEN_ORIENT": "Maghreb & Moyen-Orient",
|
||||
"PAINS_SANDWICHS": "Pains & sandwichs",
|
||||
"EPICERIE_DIVERS": "Épicerie & divers",
|
||||
"LIQUIDES_BOISSONS": "Liquides & boissons"
|
||||
"HERBES_FRAICHES": "Herbes fraîches",
|
||||
"VIANDES": "Viandes",
|
||||
"VOLAILLES": "Volailles",
|
||||
"POISSONS": "Poissons",
|
||||
"CRUSTACES_FRUITS_DE_MER": "Crustacés & fruits de mer",
|
||||
"FECULENTS": "Féculents",
|
||||
"LEGUMINEUSES": "Légumineuses",
|
||||
"GRAINES_FRUITS_SECS": "Graines & fruits secs",
|
||||
"AUTRES": "Autres",
|
||||
"PAINS": "Pains",
|
||||
"PATES_A_CUIRE": "Pâtes à cuire",
|
||||
"PRODUITS_LAITIERS": "Produits laitiers",
|
||||
"OEUFS": "Œufs",
|
||||
"ALTERNATIVES": "Alternatives végétales",
|
||||
"EPICES": "Épices",
|
||||
"SAUCES": "Sauces",
|
||||
"ASSAISONNEMENTS": "Assaisonnements",
|
||||
"BASES": "Bases",
|
||||
"EPAISSISSANTS": "Épaississants",
|
||||
"SUCRES": "Sucres"
|
||||
},
|
||||
"quantityLabel": "Quantité",
|
||||
"unitLabel": "Unité",
|
||||
|
|
|
|||
|
|
@ -33,36 +33,82 @@ export interface AllergyView {
|
|||
}
|
||||
|
||||
/**
|
||||
* Coarse ingredient grouping (viandes, légumes, épices...) — mirrors
|
||||
* `IngredientCategory` in schema.prisma, declared by hand for the same
|
||||
* reason as {@link AllergenKind}. Lets the ingredient picker (`apps/web`'s
|
||||
* Supermarket-aisle grouping ("rayons") — mirrors `IngredientCategory` in
|
||||
* schema.prisma, declared by hand for the same reason as
|
||||
* {@link AllergenKind}. Lets the ingredient picker (`apps/web`'s
|
||||
* `IngredientPicker`) offer category browsing, not just free-text search:
|
||||
* with 400+ reference ingredients, search alone doesn't scale to actually
|
||||
* *finding* one.
|
||||
* *finding* one. See {@link INGREDIENT_SUBCATEGORIES} for the finer-grained
|
||||
* rack within each aisle.
|
||||
*/
|
||||
export const INGREDIENT_CATEGORIES = [
|
||||
"CEREALES_FECULENTS",
|
||||
"LEGUMINEUSES",
|
||||
"VIANDES_VOLAILLES",
|
||||
"POISSONS_FRUITS_DE_MER",
|
||||
"PRODUITS_LAITIERS_OEUFS",
|
||||
"LEGUMES",
|
||||
"FRUITS",
|
||||
"FRUITS_SECS_OLEAGINEUX",
|
||||
"CONDIMENTS_SAUCES",
|
||||
"EPICES_HERBES",
|
||||
"SUCRE_PATISSERIE",
|
||||
"CUISINE_ITALIENNE",
|
||||
"CUISINE_ASIATIQUE",
|
||||
"CUISINE_MEXICAINE",
|
||||
"MAGHREB_MOYEN_ORIENT",
|
||||
"PAINS_SANDWICHS",
|
||||
"EPICERIE_DIVERS",
|
||||
"LIQUIDES_BOISSONS",
|
||||
"PRODUITS_FRAIS",
|
||||
"BOUCHERIE_POISSONNERIE",
|
||||
"EPICERIE_SECHE",
|
||||
"BOULANGERIE",
|
||||
"CREMERIE_FROMAGE",
|
||||
"CONDIMENTS_EPICES",
|
||||
"AIDES_CULINAIRES",
|
||||
] as const;
|
||||
/** Inferred TS type for one {@link INGREDIENT_CATEGORIES} member. */
|
||||
export type IngredientCategory = (typeof INGREDIENT_CATEGORIES)[number];
|
||||
|
||||
/**
|
||||
* Finer-grained rack within one {@link IngredientCategory} aisle — mirrors
|
||||
* `IngredientSubcategory` in schema.prisma. See
|
||||
* {@link INGREDIENT_CATEGORY_SUBCATEGORIES} for which of these belongs to
|
||||
* which category, and in what display order.
|
||||
*/
|
||||
export const INGREDIENT_SUBCATEGORIES = [
|
||||
"LEGUMES",
|
||||
"FRUITS",
|
||||
"HERBES_FRAICHES",
|
||||
"VIANDES",
|
||||
"VOLAILLES",
|
||||
"POISSONS",
|
||||
"CRUSTACES_FRUITS_DE_MER",
|
||||
"FECULENTS",
|
||||
"LEGUMINEUSES",
|
||||
"GRAINES_FRUITS_SECS",
|
||||
"AUTRES",
|
||||
"PAINS",
|
||||
"PATES_A_CUIRE",
|
||||
"PRODUITS_LAITIERS",
|
||||
"OEUFS",
|
||||
"ALTERNATIVES",
|
||||
"EPICES",
|
||||
"SAUCES",
|
||||
"ASSAISONNEMENTS",
|
||||
"BASES",
|
||||
"EPAISSISSANTS",
|
||||
"SUCRES",
|
||||
] as const;
|
||||
/** Inferred TS type for one {@link INGREDIENT_SUBCATEGORIES} member. */
|
||||
export type IngredientSubcategory = (typeof INGREDIENT_SUBCATEGORIES)[number];
|
||||
|
||||
/**
|
||||
* Which {@link INGREDIENT_SUBCATEGORIES} belong to which
|
||||
* {@link INGREDIENT_CATEGORIES} aisle, in the order the picker should list
|
||||
* them — the two-level drill-down `IngredientPicker` (`apps/web`) needs
|
||||
* this to know which subcategory chips to offer once a category is picked.
|
||||
* Mirrors `apps/api/src/db/reference-seed-data.ts`'s `INGREDIENT_GROUPS`,
|
||||
* which is the actual source of truth for which *ingredient* belongs to
|
||||
* which pair — this is just the category→subcategory shape of that data,
|
||||
* duplicated here since the seed file isn't reachable from `apps/web`.
|
||||
*/
|
||||
export const INGREDIENT_CATEGORY_SUBCATEGORIES: Record<
|
||||
IngredientCategory,
|
||||
readonly IngredientSubcategory[]
|
||||
> = {
|
||||
PRODUITS_FRAIS: ["LEGUMES", "FRUITS", "HERBES_FRAICHES"],
|
||||
BOUCHERIE_POISSONNERIE: ["VIANDES", "VOLAILLES", "POISSONS", "CRUSTACES_FRUITS_DE_MER"],
|
||||
EPICERIE_SECHE: ["FECULENTS", "LEGUMINEUSES", "GRAINES_FRUITS_SECS", "AUTRES"],
|
||||
BOULANGERIE: ["PAINS", "PATES_A_CUIRE"],
|
||||
CREMERIE_FROMAGE: ["PRODUITS_LAITIERS", "OEUFS", "ALTERNATIVES"],
|
||||
CONDIMENTS_EPICES: ["EPICES", "SAUCES", "ASSAISONNEMENTS"],
|
||||
AIDES_CULINAIRES: ["BASES", "EPAISSISSANTS", "SUCRES"],
|
||||
};
|
||||
|
||||
/**
|
||||
* A selectable ingredient, as returned by `GET /reference/ingredients` —
|
||||
* reference data (`Ingredient`, seeded via `apps/api/src/db/
|
||||
|
|
@ -87,6 +133,7 @@ export interface IngredientView {
|
|||
name: string;
|
||||
icon: string | null;
|
||||
category: IngredientCategory;
|
||||
subcategory: IngredientSubcategory;
|
||||
allergens: AllergyView[];
|
||||
diets: DietView[];
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue