feat(recipes): remplace les emoji par des icônes SVG épurées

Deux usages d'emoji supprimés, remplacés par un vrai jeu d'icônes SVG
(style ligne, cohérent avec layouts/nav-icons.tsx) :

- Catégories/sous-catégories du picker (auparavant préfixées d'un
  emoji dans les libellés) : nouvelles icônes CategoryIcon/
  SubcategoryIcon, une par catégorie/sous-catégorie.
- Icône par ingrédient (auparavant un emoji différent pour chacun des
  437 ingrédients) : remplacé par un petit vocabulaire de ~22
  pictogrammes génériques ("un légume", "une bouteille", "un
  fromage"...) réutilisés selon la nature de l'ingrédient plutôt
  qu'un dessin par ingrédient (irréaliste à la main pour 437 items).

- Schéma : nouvel enum IngredientIcon (22 valeurs), colonne
  Ingredient.icon passe de String? (texte libre) à IngredientIcon
  (non nullable, toujours une valeur générique pertinente désormais).
- reference-seed-data.ts : chaque groupe porte un defaultIcon (calqué
  sur sa sous-catégorie), avec override par ingrédient pour les
  exceptions (ex. les fromages dans "produits laitiers", les jus/cafés
  dans "assaisonnements").
- apps/web/src/features/recipes/ingredient-icons.tsx (nouveau) :
  22 icônes SVG + CategoryIcon/SubcategoryIcon (réutilisent le même
  vocabulaire pour représenter chaque catégorie/sous-catégorie).
- packages/shared : IngredientView.icon devient IngredientIcon (union
  de 22 valeurs) au lieu de string | null.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Nicolas 2026-08-18 12:30:25 +02:00
parent 9775cbe38f
commit 78e0f89503
10 changed files with 964 additions and 440 deletions

View file

@ -0,0 +1,6 @@
-- CreateEnum
CREATE TYPE "IngredientIcon" AS ENUM ('VEGETABLE', 'FRUIT', 'HERB', 'MEAT', 'POULTRY', 'FISH', 'SHELLFISH', 'GRAIN', 'LEGUME', 'NUT_SEED', 'BREAD', 'DOUGH', 'MILK', 'CHEESE', 'EGG', 'SPROUT', 'SPICE', 'JAR', 'BOTTLE', 'DRINK', 'STOCK_POT', 'SUGAR');
-- AlterTable
ALTER TABLE "ingredients" DROP COLUMN "icon",
ADD COLUMN "icon" "IngredientIcon" NOT NULL DEFAULT 'JAR';

View file

@ -384,10 +384,48 @@ enum IngredientSubcategory {
SUCRES
}
/// Generic pictogram *type* for an ingredient — not in the original spec
/// doc. Started as a free-text emoji column (one character per ingredient,
/// 437 different ones), which the product decision recorded in chat
/// rejected as unprofessional/inconsistent. Rather than 437 hand-drawn SVG
/// icons (unrealistic), ingredients share a small vocabulary of ~20
/// generic shapes grouped by *what kind of thing* they are — a vegetable,
/// a bottle of oil, a wedge of cheese — regardless of which specific
/// ingredient. `apps/web`'s `features/recipes/ingredient-icons.tsx` maps
/// each value to its actual SVG (matching the app's hand-drawn line-icon
/// style, never emoji — see that file for the full reasoning and the
/// exact `reference-seed-data.ts` assignment per ingredient).
/// `@default(JAR)` — same NOT-NULL-migration-safety-net reasoning as
/// `IngredientCategory`'s default, never the intended value for a real row.
enum IngredientIcon {
VEGETABLE
FRUIT
HERB
MEAT
POULTRY
FISH
SHELLFISH
GRAIN
LEGUME
NUT_SEED
BREAD
DOUGH
MILK
CHEESE
EGG
SPROUT
SPICE
JAR
BOTTLE
DRINK
STOCK_POT
SUGAR
}
model Ingredient {
id Int @id @default(autoincrement())
name String @unique
icon String?
icon IngredientIcon @default(JAR)
category IngredientCategory @default(EPICERIE_SECHE)
subcategory IngredientSubcategory @default(AUTRES)
alternateRecipeId Int? @map("alternate_recipe")

File diff suppressed because it is too large Load diff

View file

@ -1,5 +1,6 @@
import type { IngredientView } from "@batch-cooking/shared";
import { useTranslation } from "react-i18next";
import { IngredientPicker } from "../recipes/IngredientPicker";
// Reuses `IngredientPicker` verbatim (built for the recipe form's
// ingredient picker, features/recipes/) rather than a second search+browse
// field — same reference ingredient list, same category/search browsing,
@ -9,7 +10,7 @@ import { useTranslation } from "react-i18next";
// recipes.scss — colocated there since it's the same "reference ingredient
// picker" visual family, even though this field lives in the profile
// feature).
import { IngredientPicker } from "../recipes/IngredientPicker";
import { IngredientTypeIcon } from "../recipes/ingredient-icons";
import "../recipes/recipes.scss";
import "./profile-forms.scss";
@ -50,7 +51,9 @@ export function DislikedIngredientsField({
<ul className="disliked-ingredients-field__chips">
{selected.map((ingredient) => (
<li key={ingredient.id} className="disliked-ingredients-field__chip">
<span aria-hidden="true">{ingredient.icon}</span>
<span aria-hidden="true">
<IngredientTypeIcon icon={ingredient.icon} />
</span>
{ingredient.name}
<button
type="button"

View file

@ -10,6 +10,7 @@ import { useTranslation } from "react-i18next";
import { AllergenIcon, DietPreferencesIcon } from "../../layouts/nav-icons";
import { AllergenBadges } from "./AllergenBadges";
import { DietBadges } from "./DietBadges";
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). */
@ -126,6 +127,7 @@ export function IngredientPicker({
className={`ingredient-picker__category${category === c ? " active" : ""}`}
onClick={() => selectCategory(c)}
>
<CategoryIcon category={c} />
{t(`recipes.form.category.${c}`)}
</button>
))}
@ -147,6 +149,7 @@ export function IngredientPicker({
className={`ingredient-picker__subcategory${subcategory === s ? " active" : ""}`}
onClick={() => setSubcategory(s)}
>
<SubcategoryIcon subcategory={s} />
{t(`recipes.form.subcategory.${s}`)}
</button>
))}
@ -165,7 +168,7 @@ export function IngredientPicker({
onClick={() => handleSelect(ingredient)}
>
<span className="ingredient-picker__card-icon" aria-hidden="true">
{ingredient.icon}
<IngredientTypeIcon icon={ingredient.icon} />
</span>
<span className="ingredient-picker__card-name">{ingredient.name}</span>
{showAllergens && <AllergenBadges allergens={ingredient.allergens} />}

View file

@ -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 { IngredientTypeIcon } from "./ingredient-icons";
import "./recipes.scss";
/** One selected ingredient line in the recipe form — the ingredient itself (picked via `IngredientPicker`) plus its quantity/unit for this recipe. Quantity/unit are kept as raw strings while editing (not parsed to a number until submit) so an in-progress/invalid value doesn't fight the input. */
@ -25,7 +26,7 @@ export function IngredientRow({
return (
<li className="ingredient-row">
<span className="ingredient-row__icon" aria-hidden="true">
{ingredient.icon}
<IngredientTypeIcon icon={ingredient.icon} />
</span>
<span className="ingredient-row__name">{ingredient.name}</span>
<input

View file

@ -0,0 +1,360 @@
import type {
IngredientCategory,
IngredientIcon as IngredientIconType,
IngredientSubcategory,
} from "@batch-cooking/shared";
import type { ReactNode } from "react";
// Generic pictograms for ingredients — one per `IngredientIcon` value
// (schema.prisma), reused across the category chips, subcategory chips and
// ingredient cards in `IngredientPicker`/`IngredientRow`. Replaces the
// earlier per-ingredient emoji (437 different characters, one per
// ingredient) with a small, hand-drawn vocabulary grouped by *what kind of
// thing* an ingredient is (a vegetable, a bottle of oil, a wedge of
// cheese…) rather than a literal picture of each one — 437 unique,
// professional icons isn't a realistic hand-drawn set, but ~20 generic
// shapes reused across them is. Same 24×24, stroke-only house style as
// `layouts/nav-icons.tsx` (kept separate from that file since these are
// ingredient-domain, not app-nav).
function Icon({ children }: { children: ReactNode }) {
return (
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true"
>
{children}
</svg>
);
}
/** Carrot — root vegetables, leafy greens, and produce generally (`PRODUITS_FRAIS`/`LEGUMES`). */
export function VegetableIcon() {
return (
<Icon>
<path d="M12 3c1.5 1 2 2.5 1.3 4M15 2c1 1.2 1 2.6 0 4" />
<path d="M13.3 7c2.8 0 4.7 2.9 4.1 5.6L15.8 20a2 2 0 0 1-3.9.2L9.2 12.6C8.3 9.9 10.3 7 13.3 7z" />
</Icon>
);
}
/** Apple — `PRODUITS_FRAIS`/`FRUITS`. */
export function FruitIcon() {
return (
<Icon>
<path d="M12 8c-.5-1.3-.2-3 1-4" />
<path d="M12 8c-4 0-6.5 3-6.5 7a6.5 6.5 0 0 0 13 0C18.5 11 16 8 12 8z" />
</Icon>
);
}
/** A leaf sprig — `PRODUITS_FRAIS`/`HERBES_FRAICHES`. */
export function HerbIcon() {
return (
<Icon>
<path d="M12 21V7" />
<path d="M12 8c0-3 2-5 5-5 0 3-2 5-5 5z" />
<path d="M12 13c0-3-2-5-5-5 0 3 2 5 5 5z" />
<path d="M12 18c0-2.5 1.7-4 4-4 0 2.5-1.7 4-4 4z" />
</Icon>
);
}
/** A cut of meat — `BOUCHERIE_POISSONNERIE`/`VIANDES`. */
export function MeatIcon() {
return (
<Icon>
<path d="M8 16 17 7a3 3 0 1 1 4 4l-9 9a5 5 0 0 1-8-2 5 5 0 0 1 4-4z" />
<circle cx="9" cy="15" r="1.2" fill="currentColor" stroke="none" />
</Icon>
);
}
/** Drumstick — `BOUCHERIE_POISSONNERIE`/`VOLAILLES`. */
export function PoultryIcon() {
return (
<Icon>
<path d="M13 4c2.5 0 4.5 2 4.5 4.5 0 2.8-2.3 4.8-4 6.4l-3.2 3.2a2.3 2.3 0 0 1-3.3-3.3l3.2-3.2C11.7 10 13.7 7.7 13 4z" />
<path d="M6.5 21 5 22.5" />
<path d="M9 18.5 7.5 20" />
</Icon>
);
}
/** Fish — `BOUCHERIE_POISSONNERIE`/`POISSONS`. */
export function FishIcon() {
return (
<Icon>
<path d="M3 12c3-4 8-6 13-4 2.5 1 5 3 5 4s-2.5 3-5 4c-5 2-10 0-13-4z" />
<path d="M16 9v6" />
<circle cx="6.5" cy="11" r=".6" fill="currentColor" stroke="none" />
</Icon>
);
}
/** Shrimp — `BOUCHERIE_POISSONNERIE`/`CRUSTACES_FRUITS_DE_MER`. */
export function ShellfishIcon() {
return (
<Icon>
<path d="M6 6c6-2 12 1 13 7-4 2-9 2-12-1C5 10 4.5 8 6 6z" />
<path d="M6 6 4 4M7 8 5 7M8 11l-2.2.6" />
</Icon>
);
}
/** Wheat ear — grains, pasta, rice, flour (`EPICERIE_SECHE`/`FECULENTS`, and flours under `AIDES_CULINAIRES`/`BASES`). */
export function GrainIcon() {
return (
<Icon>
<path d="M12 22V6" />
<path d="M12 7c-1-1-2.5-1-3.5 0 1 1 2.5 1 3.5 0zM12 7c1-1 2.5-1 3.5 0-1 1-2.5 1-3.5 0z" />
<path d="M12 11c-1-1-2.5-1-3.5 0 1 1 2.5 1 3.5 0zM12 11c1-1 2.5-1 3.5 0-1 1-2.5 1-3.5 0z" />
<path d="M12 15c-1-1-2.5-1-3.5 0 1 1 2.5 1 3.5 0zM12 15c1-1 2.5-1 3.5 0-1 1-2.5 1-3.5 0z" />
<path d="M12 6c0-1.5.8-2.8 2-3.5" />
</Icon>
);
}
/** Bean pod — `EPICERIE_SECHE`/`LEGUMINEUSES`. */
export function LegumeIcon() {
return (
<Icon>
<path d="M5 13c0-5 3.5-9 9-9 1 3-1 5-1 5s3 0 5 3c0 5-4 9-9 9-3 0-4-4-4-8z" />
<circle cx="9.5" cy="14.5" r="1" fill="currentColor" stroke="none" />
<circle cx="13" cy="11" r="1" fill="currentColor" stroke="none" />
</Icon>
);
}
/** Acorn — nuts, seeds, dried fruit (`EPICERIE_SECHE`/`GRAINES_FRUITS_SECS`). */
export function NutSeedIcon() {
return (
<Icon>
<path d="M8 9c0-3 1.8-5 4-5s4 2 4 5" />
<path d="M8 9c-1.3.5-2 1.7-2 3 0 4 2.7 8 6 8s6-4 6-8c0-1.3-.7-2.5-2-3z" />
</Icon>
);
}
/** A loaf, scored on top — `BOULANGERIE`/`PAINS`. */
export function BreadIcon() {
return (
<Icon>
<path d="M4 12c0-4.4 3.6-8 8-8s8 3.6 8 8v4a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2z" />
<path d="M9 8l1.5 3M14 7.5 15.5 11" />
</Icon>
);
}
/** Rolling pin — raw, uncooked pastry (`BOULANGERIE`/`PATES_A_CUIRE`). */
export function DoughIcon() {
return (
<Icon>
<rect x="6" y="10" width="12" height="4" rx="2" />
<circle cx="4" cy="12" r="2" />
<circle cx="20" cy="12" r="2" />
</Icon>
);
}
/** A glass of milk — `CREMERIE_FROMAGE`/`PRODUITS_LAITIERS` (non-cheese items). */
export function MilkIcon() {
return (
<Icon>
<path d="M9 2h6l1 4-1 2v11a2 2 0 0 1-2 2h-2a2 2 0 0 1-2-2V8L8 6z" />
<path d="M8 12h8" />
</Icon>
);
}
/** A wedge of cheese with holes — `CREMERIE_FROMAGE`/`PRODUITS_LAITIERS` (cheese items). */
export function CheeseIcon() {
return (
<Icon>
<path d="M3 18 20 8l1 3-3 9H4z" />
<circle cx="10" cy="15" r="1" fill="currentColor" stroke="none" />
<circle cx="15" cy="13" r="1" fill="currentColor" stroke="none" />
<circle cx="14" cy="17" r="1" fill="currentColor" stroke="none" />
</Icon>
);
}
/** An egg — `CREMERIE_FROMAGE`/`OEUFS`. */
export function EggIcon() {
return (
<Icon>
<path d="M12 3C8 8 6 12.5 6 15.5a6 6 0 0 0 12 0C18 12.5 16 8 12 3z" />
</Icon>
);
}
/** A seedling — plant-based dairy/meat alternatives (`CREMERIE_FROMAGE`/`ALTERNATIVES`). */
export function SproutIcon() {
return (
<Icon>
<path d="M12 21v-9" />
<path d="M12 12C7 12 5 9 5 5c5 0 7 2 7 7z" />
<path d="M12 12c4 0 6-2.5 6-6-4 0-6 1.5-6 6z" />
</Icon>
);
}
/** A shaker — dried spices/herbs (`CONDIMENTS_EPICES`/`EPICES`). */
export function SpiceIcon() {
return (
<Icon>
<rect x="8" y="8" width="8" height="13" rx="3" />
<path d="M10 8V5a2 2 0 0 1 4 0v3" />
<path d="M11 12h.01M13 12h.01M10.5 15h.01M13.5 15h.01M12 18h.01" />
</Icon>
);
}
/** A condiment jar — sauces, pickles, tinned/preserved goods (`CONDIMENTS_EPICES`/`SAUCES` and the `EPICERIE_SECHE`/`AUTRES` catch-all). */
export function JarIcon() {
return (
<Icon>
<rect x="6" y="9" width="12" height="12" rx="2" />
<path d="M9 9V6a1 1 0 0 1 1-1h4a1 1 0 0 1 1 1v3" />
<path d="M6 13h12" />
</Icon>
);
}
/** A bottle — oils, vinegars (`CONDIMENTS_EPICES`/`ASSAISONNEMENTS`, the pourable subset). */
export function BottleIcon() {
return (
<Icon>
<path d="M10 2h4v3.5l1.5 2V20a2 2 0 0 1-2 2h-3a2 2 0 0 1-2-2V7.5L10 5.5z" />
<path d="M9.5 11h5" />
</Icon>
);
}
/** A glass — juices, coffee/tea, cooking alcohols, water (`CONDIMENTS_EPICES`/`ASSAISONNEMENTS`'s drinkable subset). */
export function DrinkIcon() {
return (
<Icon>
<path d="M6 3h12l-1.5 16.5A2 2 0 0 1 14.5 21h-5a2 2 0 0 1-2-1.5z" />
<path d="M7 9h10" />
</Icon>
);
}
/** A stockpot — broths, stocks, water bases (`AIDES_CULINAIRES`/`BASES`'s liquid-base subset). */
export function StockPotIcon() {
return (
<Icon>
<rect x="4" y="10" width="16" height="10" rx="2" />
<path d="M2 12h2M20 12h2" />
<path d="M9 10V7a3 3 0 0 1 6 0v3" />
</Icon>
);
}
/** A sugar cube — `AIDES_CULINAIRES`/`SUCRES`. */
export function SugarIcon() {
return (
<Icon>
<path d="M4 8 12 4l8 4-8 4z" />
<path d="M4 8v8l8 4 8-4V8" />
<path d="M12 12v8" />
</Icon>
);
}
/** Every icon component, keyed by {@link IngredientIconType} — the single lookup `IngredientPicker`/`IngredientRow`/category chips render through. */
export const INGREDIENT_ICON_COMPONENTS: Record<IngredientIconType, () => JSX.Element> = {
VEGETABLE: VegetableIcon,
FRUIT: FruitIcon,
HERB: HerbIcon,
MEAT: MeatIcon,
POULTRY: PoultryIcon,
FISH: FishIcon,
SHELLFISH: ShellfishIcon,
GRAIN: GrainIcon,
LEGUME: LegumeIcon,
NUT_SEED: NutSeedIcon,
BREAD: BreadIcon,
DOUGH: DoughIcon,
MILK: MilkIcon,
CHEESE: CheeseIcon,
EGG: EggIcon,
SPROUT: SproutIcon,
SPICE: SpiceIcon,
JAR: JarIcon,
BOTTLE: BottleIcon,
DRINK: DrinkIcon,
STOCK_POT: StockPotIcon,
SUGAR: SugarIcon,
};
/** Renders the generic pictogram for one {@link IngredientIconType} — the actual component `IngredientPicker`'s cards and `IngredientRow` use, rather than importing {@link INGREDIENT_ICON_COMPONENTS} directly everywhere. */
export function IngredientTypeIcon({ icon }: { icon: IngredientIconType }) {
const Component = INGREDIENT_ICON_COMPONENTS[icon];
return <Component />;
}
/**
* One representative {@link IngredientIconType} per {@link IngredientCategory}
* aisle, for `IngredientPicker`'s top-level category chips a category
* mixes several icon types (e.g. "Aides culinaires" has flour, stock, and
* sugar items), so this is a deliberate, illustrative pick rather than a
* derived fact.
*/
export const CATEGORY_ICON: Record<IngredientCategory, IngredientIconType> = {
PRODUITS_FRAIS: "VEGETABLE",
BOUCHERIE_POISSONNERIE: "MEAT",
EPICERIE_SECHE: "GRAIN",
BOULANGERIE: "BREAD",
CREMERIE_FROMAGE: "CHEESE",
CONDIMENTS_EPICES: "SPICE",
AIDES_CULINAIRES: "STOCK_POT",
};
/** Renders {@link CATEGORY_ICON}'s pictogram for one category — used by the category chip row. */
export function CategoryIcon({ category }: { category: IngredientCategory }) {
return <IngredientTypeIcon icon={CATEGORY_ICON[category]} />;
}
/**
* One representative {@link IngredientIconType} per {@link IngredientSubcategory}
* rack, for `IngredientPicker`'s second-tier subcategory chips. Most map
* 1:1 onto their subcategory's dominant shape; a couple of heterogeneous
* subcategories (`ASSAISONNEMENTS` mixes oils with juices and coffee,
* `BASES` mixes flour with stock and canned tomato) get one illustrative
* pick rather than a derived fact, same reasoning as {@link CATEGORY_ICON}.
*/
export const SUBCATEGORY_ICON: Record<IngredientSubcategory, IngredientIconType> = {
LEGUMES: "VEGETABLE",
FRUITS: "FRUIT",
HERBES_FRAICHES: "HERB",
VIANDES: "MEAT",
VOLAILLES: "POULTRY",
POISSONS: "FISH",
CRUSTACES_FRUITS_DE_MER: "SHELLFISH",
FECULENTS: "GRAIN",
LEGUMINEUSES: "LEGUME",
GRAINES_FRUITS_SECS: "NUT_SEED",
AUTRES: "JAR",
PAINS: "BREAD",
PATES_A_CUIRE: "DOUGH",
PRODUITS_LAITIERS: "MILK",
OEUFS: "EGG",
ALTERNATIVES: "SPROUT",
EPICES: "SPICE",
SAUCES: "JAR",
ASSAISONNEMENTS: "BOTTLE",
BASES: "STOCK_POT",
EPAISSISSANTS: "JAR",
SUCRES: "SUGAR",
};
/** Renders {@link SUBCATEGORY_ICON}'s pictogram for one subcategory — used by the subcategory chip row. */
export function SubcategoryIcon({ subcategory }: { subcategory: IngredientSubcategory }) {
return <IngredientTypeIcon icon={SUBCATEGORY_ICON[subcategory]} />;
}

View file

@ -637,6 +637,17 @@
background: var(--color-surface);
border-radius: var(--radius-base);
&__icon {
display: flex;
flex-shrink: 0;
color: var(--color-text-muted);
svg {
width: 1.25rem;
height: 1.25rem;
}
}
&__name {
font-weight: 600;
margin-right: auto;
@ -751,6 +762,9 @@
}
&__category {
display: flex;
align-items: center;
gap: var(--space-xs);
flex-shrink: 0;
appearance: none;
padding: var(--space-xs) var(--space-sm);
@ -763,6 +777,12 @@
background: var(--color-surface-alt);
color: var(--color-text-muted);
svg {
flex-shrink: 0;
width: 1rem;
height: 1rem;
}
&:hover {
border-color: var(--color-primary);
color: var(--color-text);
@ -788,6 +808,9 @@
}
&__subcategory {
display: flex;
align-items: center;
gap: 4px;
flex-shrink: 0;
appearance: none;
padding: 2px var(--space-sm);
@ -800,6 +823,12 @@
background: transparent;
color: var(--color-text-muted);
svg {
flex-shrink: 0;
width: 0.85rem;
height: 0.85rem;
}
&:hover {
border-color: var(--color-tag);
color: var(--color-text);
@ -841,8 +870,14 @@
}
&__card-icon {
font-size: 1.5rem;
line-height: 1;
display: flex;
align-items: center;
justify-content: center;
svg {
width: 1.75rem;
height: 1.75rem;
}
}
&__card-name {
@ -1007,6 +1042,12 @@
background: var(--color-surface-alt);
border-radius: var(--radius-pill);
svg {
width: 1rem;
height: 1rem;
flex-shrink: 0;
}
button {
display: flex;
align-items: center;

View file

@ -172,13 +172,13 @@
"allSubcategories": "Tout",
"noIngredientFound": "Aucun ingrédient trouvé.",
"category": {
"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"
"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",

View file

@ -109,6 +109,43 @@ export const INGREDIENT_CATEGORY_SUBCATEGORIES: Record<
AIDES_CULINAIRES: ["BASES", "EPAISSISSANTS", "SUCRES"],
};
/**
* Generic pictogram *type* for an ingredient mirrors `IngredientIcon` in
* schema.prisma, declared by hand for the same reason as
* {@link AllergenKind}. Was a free-text emoji per ingredient (437 distinct
* characters); replaced with this small, shared vocabulary of ~20 generic
* shapes ("a vegetable", "a bottle", "a wedge of cheese") so
* `apps/web`'s `features/recipes/ingredient-icons.tsx` can render a real
* SVG line icon per ingredient instead see that file for the actual
* pictograms and the full reasoning.
*/
export const INGREDIENT_ICONS = [
"VEGETABLE",
"FRUIT",
"HERB",
"MEAT",
"POULTRY",
"FISH",
"SHELLFISH",
"GRAIN",
"LEGUME",
"NUT_SEED",
"BREAD",
"DOUGH",
"MILK",
"CHEESE",
"EGG",
"SPROUT",
"SPICE",
"JAR",
"BOTTLE",
"DRINK",
"STOCK_POT",
"SUGAR",
] as const;
/** Inferred TS type for one {@link INGREDIENT_ICONS} member. */
export type IngredientIcon = (typeof INGREDIENT_ICONS)[number];
/**
* A selectable ingredient, as returned by `GET /reference/ingredients`
* reference data (`Ingredient`, seeded via `apps/api/src/db/
@ -131,7 +168,7 @@ export const INGREDIENT_CATEGORY_SUBCATEGORIES: Record<
export interface IngredientView {
id: number;
name: string;
icon: string | null;
icon: IngredientIcon;
category: IngredientCategory;
subcategory: IngredientSubcategory;
allergens: AllergyView[];