Merge pull request #29 from kyuno053/feat/ingredient-reproducible-flag

feat(recipes): flag les ingrédients faisables maison + suggestion de recherche
This commit is contained in:
kyuno053 2026-08-19 21:01:53 +02:00 committed by GitHub
commit 3fd55c4f85
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
24 changed files with 1733 additions and 2060 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

@ -0,0 +1,294 @@
-- Replaces every Diet/Category(allergen)/Ingredient `key` with a
-- directly-authored English camelCase uid (no more French label +
-- separate catalog-en-keys.ts lookup table — see reference-seed-data.ts's
-- module doc comment). Auto-generated once by
-- scripts/gen-camel-uid-migration.ts — do not re-run, do not hand-edit.
-- Same shape as 20260818193000_catalog_keys_to_english/migration.sql.
UPDATE "diet" SET "key" = 'glutenFree' WHERE "key" = 'gluten_free';
UPDATE "category" SET "key" = 'treeNuts' WHERE "key" = 'tree_nuts';
UPDATE "category" SET "key" = 'sesameSeeds' WHERE "key" = 'sesame_seeds';
-- "sesame_seeds" is also an Ingredient key (the ingredient "Graines de
-- sésame" carries an allergen of the same name on itself) — hand-added,
-- the generator's first pass missed this dual-table case (see its
-- updated `if`/`if`/`if` — not `if`/`else if` — comment for why).
UPDATE "ingredients" SET "key" = 'sesameSeeds' WHERE "key" = 'sesame_seeds';
UPDATE "ingredients" SET "key" = 'bellPepper' WHERE "key" = 'bell_pepper';
UPDATE "ingredients" SET "key" = 'whiteCabbage' WHERE "key" = 'white_cabbage';
UPDATE "ingredients" SET "key" = 'redCabbage' WHERE "key" = 'red_cabbage';
UPDATE "ingredients" SET "key" = 'brusselsSprouts' WHERE "key" = 'brussels_sprouts';
UPDATE "ingredients" SET "key" = 'swissChard' WHERE "key" = 'swiss_chard';
UPDATE "ingredients" SET "key" = 'greenBean' WHERE "key" = 'green_bean';
UPDATE "ingredients" SET "key" = 'butternutSquash' WHERE "key" = 'butternut_squash';
UPDATE "ingredients" SET "key" = 'sweetPotato' WHERE "key" = 'sweet_potato';
UPDATE "ingredients" SET "key" = 'cherryTomato' WHERE "key" = 'cherry_tomato';
UPDATE "ingredients" SET "key" = 'bokChoy' WHERE "key" = 'bok_choy';
UPDATE "ingredients" SET "key" = 'soybeanSprouts' WHERE "key" = 'soybean_sprouts';
UPDATE "ingredients" SET "key" = 'freshGreenChili' WHERE "key" = 'fresh_green_chili';
UPDATE "ingredients" SET "key" = 'napaCabbage' WHERE "key" = 'napa_cabbage';
UPDATE "ingredients" SET "key" = 'springOnion' WHERE "key" = 'spring_onion';
UPDATE "ingredients" SET "key" = 'redKuriSquash' WHERE "key" = 'red_kuri_squash';
UPDATE "ingredients" SET "key" = 'lambsLettuce' WHERE "key" = 'lambs_lettuce';
UPDATE "ingredients" SET "key" = 'bayLeaf' WHERE "key" = 'bay_leaf';
UPDATE "ingredients" SET "key" = 'freshCilantro' WHERE "key" = 'fresh_cilantro';
UPDATE "ingredients" SET "key" = 'kaffirLime' WHERE "key" = 'kaffir_lime';
UPDATE "ingredients" SET "key" = 'groundBeef' WHERE "key" = 'ground_beef';
UPDATE "ingredients" SET "key" = 'beefSteak' WHERE "key" = 'beef_steak';
UPDATE "ingredients" SET "key" = 'beefRoast' WHERE "key" = 'beef_roast';
UPDATE "ingredients" SET "key" = 'vealCutlet' WHERE "key" = 'veal_cutlet';
UPDATE "ingredients" SET "key" = 'porkTenderloin' WHERE "key" = 'pork_tenderloin';
UPDATE "ingredients" SET "key" = 'porkChop' WHERE "key" = 'pork_chop';
UPDATE "ingredients" SET "key" = 'legOfLamb' WHERE "key" = 'leg_of_lamb';
UPDATE "ingredients" SET "key" = 'baconLardons' WHERE "key" = 'bacon_lardons';
UPDATE "ingredients" SET "key" = 'curedHam' WHERE "key" = 'cured_ham';
UPDATE "ingredients" SET "key" = 'whitePudding' WHERE "key" = 'white_pudding';
UPDATE "ingredients" SET "key" = 'blackPudding' WHERE "key" = 'black_pudding';
UPDATE "ingredients" SET "key" = 'dryCuredSausage' WHERE "key" = 'dry_cured_sausage';
UPDATE "ingredients" SET "key" = 'bayonneHam' WHERE "key" = 'bayonne_ham';
UPDATE "ingredients" SET "key" = 'rosetteSausage' WHERE "key" = 'rosette_sausage';
UPDATE "ingredients" SET "key" = 'vealLiver' WHERE "key" = 'veal_liver';
UPDATE "ingredients" SET "key" = 'vealKidneys' WHERE "key" = 'veal_kidneys';
UPDATE "ingredients" SET "key" = 'vealBrain' WHERE "key" = 'veal_brain';
UPDATE "ingredients" SET "key" = 'vealSweetbread' WHERE "key" = 'veal_sweetbread';
UPDATE "ingredients" SET "key" = 'beefTongue' WHERE "key" = 'beef_tongue';
UPDATE "ingredients" SET "key" = 'roeDeer' WHERE "key" = 'roe_deer';
UPDATE "ingredients" SET "key" = 'wildBoar' WHERE "key" = 'wild_boar';
UPDATE "ingredients" SET "key" = 'horseMeat' WHERE "key" = 'horse_meat';
UPDATE "ingredients" SET "key" = 'beefHeart' WHERE "key" = 'beef_heart';
UPDATE "ingredients" SET "key" = 'foieGras' WHERE "key" = 'foie_gras';
UPDATE "ingredients" SET "key" = 'beefMuzzle' WHERE "key" = 'beef_muzzle';
UPDATE "ingredients" SET "key" = 'grisonsDriedBeef' WHERE "key" = 'grisons_dried_beef';
UPDATE "ingredients" SET "key" = 'duckBreast' WHERE "key" = 'duck_breast';
UPDATE "ingredients" SET "key" = 'guineaFowl' WHERE "key" = 'guinea_fowl';
UPDATE "ingredients" SET "key" = 'poultryLiver' WHERE "key" = 'poultry_liver';
UPDATE "ingredients" SET "key" = 'seaBass' WHERE "key" = 'sea_bass';
UPDATE "ingredients" SET "key" = 'seaBream' WHERE "key" = 'sea_bream';
UPDATE "ingredients" SET "key" = 'redMullet' WHERE "key" = 'red_mullet';
UPDATE "ingredients" SET "key" = 'smokedSalmon' WHERE "key" = 'smoked_salmon';
UPDATE "ingredients" SET "key" = 'driedFish' WHERE "key" = 'dried_fish';
UPDATE "ingredients" SET "key" = 'saltCod' WHERE "key" = 'salt_cod';
UPDATE "ingredients" SET "key" = 'lemonSole' WHERE "key" = 'lemon_sole';
UPDATE "ingredients" SET "key" = 'spinyLobster' WHERE "key" = 'spiny_lobster';
UPDATE "ingredients" SET "key" = 'spiderCrab' WHERE "key" = 'spider_crab';
UPDATE "ingredients" SET "key" = 'greyShrimp' WHERE "key" = 'grey_shrimp';
UPDATE "ingredients" SET "key" = 'wholeWheatPasta' WHERE "key" = 'whole_wheat_pasta';
UPDATE "ingredients" SET "key" = 'basmatiRice' WHERE "key" = 'basmati_rice';
UPDATE "ingredients" SET "key" = 'brownRice' WHERE "key" = 'brown_rice';
UPDATE "ingredients" SET "key" = 'lasagnaSheets' WHERE "key" = 'lasagna_sheets';
UPDATE "ingredients" SET "key" = 'arborioRice' WHERE "key" = 'arborio_rice';
UPDATE "ingredients" SET "key" = 'riceNoodles' WHERE "key" = 'rice_noodles';
UPDATE "ingredients" SET "key" = 'udonNoodles' WHERE "key" = 'udon_noodles';
UPDATE "ingredients" SET "key" = 'sobaNoodles' WHERE "key" = 'soba_noodles';
UPDATE "ingredients" SET "key" = 'chineseNoodles' WHERE "key" = 'chinese_noodles';
UPDATE "ingredients" SET "key" = 'riceVermicelli' WHERE "key" = 'rice_vermicelli';
UPDATE "ingredients" SET "key" = 'soyVermicelli' WHERE "key" = 'soy_vermicelli';
UPDATE "ingredients" SET "key" = 'stickyRice' WHERE "key" = 'sticky_rice';
UPDATE "ingredients" SET "key" = 'sushiRice' WHERE "key" = 'sushi_rice';
UPDATE "ingredients" SET "key" = 'jasmineRice' WHERE "key" = 'jasmine_rice';
UPDATE "ingredients" SET "key" = 'greenLentils' WHERE "key" = 'green_lentils';
UPDATE "ingredients" SET "key" = 'redLentils' WHERE "key" = 'red_lentils';
UPDATE "ingredients" SET "key" = 'whiteBeans' WHERE "key" = 'white_beans';
UPDATE "ingredients" SET "key" = 'kidneyBeans' WHERE "key" = 'kidney_beans';
UPDATE "ingredients" SET "key" = 'blackBeans' WHERE "key" = 'black_beans';
UPDATE "ingredients" SET "key" = 'splitPeas' WHERE "key" = 'split_peas';
UPDATE "ingredients" SET "key" = 'favaBeans' WHERE "key" = 'fava_beans';
UPDATE "ingredients" SET "key" = 'pintoBeans' WHERE "key" = 'pinto_beans';
UPDATE "ingredients" SET "key" = 'flageoletBeans' WHERE "key" = 'flageolet_beans';
UPDATE "ingredients" SET "key" = 'goldenLentils' WHERE "key" = 'golden_lentils';
UPDATE "ingredients" SET "key" = 'peanutsShelled' WHERE "key" = 'peanuts_shelled';
UPDATE "ingredients" SET "key" = 'almondPowder' WHERE "key" = 'almond_powder';
UPDATE "ingredients" SET "key" = 'pineNuts' WHERE "key" = 'pine_nuts';
UPDATE "ingredients" SET "key" = 'sunflowerSeeds' WHERE "key" = 'sunflower_seeds';
UPDATE "ingredients" SET "key" = 'pumpkinSeeds' WHERE "key" = 'pumpkin_seeds';
UPDATE "ingredients" SET "key" = 'shreddedCoconut' WHERE "key" = 'shredded_coconut';
UPDATE "ingredients" SET "key" = 'driedApricots' WHERE "key" = 'dried_apricots';
UPDATE "ingredients" SET "key" = 'blackMushrooms' WHERE "key" = 'black_mushrooms';
UPDATE "ingredients" SET "key" = 'noriSeaweed' WHERE "key" = 'nori_seaweed';
UPDATE "ingredients" SET "key" = 'wakameSeaweed' WHERE "key" = 'wakame_seaweed';
UPDATE "ingredients" SET "key" = 'kombuSeaweed' WHERE "key" = 'kombu_seaweed';
UPDATE "ingredients" SET "key" = 'bambooShoots' WHERE "key" = 'bamboo_shoots';
UPDATE "ingredients" SET "key" = 'waterChestnuts' WHERE "key" = 'water_chestnuts';
UPDATE "ingredients" SET "key" = 'sandwichBread' WHERE "key" = 'sandwich_bread';
UPDATE "ingredients" SET "key" = 'wholeWheatBread' WHERE "key" = 'whole_wheat_bread';
UPDATE "ingredients" SET "key" = 'ryeBread' WHERE "key" = 'rye_bread';
UPDATE "ingredients" SET "key" = 'burgerBun' WHERE "key" = 'burger_bun';
UPDATE "ingredients" SET "key" = 'briocheBun' WHERE "key" = 'brioche_bun';
UPDATE "ingredients" SET "key" = 'hotDogBun' WHERE "key" = 'hot_dog_bun';
UPDATE "ingredients" SET "key" = 'pitaBread' WHERE "key" = 'pita_bread';
UPDATE "ingredients" SET "key" = 'wrapBread' WHERE "key" = 'wrap_bread';
UPDATE "ingredients" SET "key" = 'vienneseBread' WHERE "key" = 'viennese_bread';
UPDATE "ingredients" SET "key" = 'countryBread' WHERE "key" = 'country_bread';
UPDATE "ingredients" SET "key" = 'multigrainBread' WHERE "key" = 'multigrain_bread';
UPDATE "ingredients" SET "key" = 'breadRoll' WHERE "key" = 'bread_roll';
UPDATE "ingredients" SET "key" = 'swedishBread' WHERE "key" = 'swedish_bread';
UPDATE "ingredients" SET "key" = 'glutenFreeBread' WHERE "key" = 'gluten_free_bread';
UPDATE "ingredients" SET "key" = 'cornTortilla' WHERE "key" = 'corn_tortilla';
UPDATE "ingredients" SET "key" = 'wheatTortilla' WHERE "key" = 'wheat_tortilla';
UPDATE "ingredients" SET "key" = 'puffPastry' WHERE "key" = 'puff_pastry';
UPDATE "ingredients" SET "key" = 'shortcrustPastry' WHERE "key" = 'shortcrust_pastry';
UPDATE "ingredients" SET "key" = 'pizzaDough' WHERE "key" = 'pizza_dough';
UPDATE "ingredients" SET "key" = 'sweetShortcrustPastry' WHERE "key" = 'sweet_shortcrust_pastry';
UPDATE "ingredients" SET "key" = 'cremeFraiche' WHERE "key" = 'creme_fraiche';
UPDATE "ingredients" SET "key" = 'liquidCream' WHERE "key" = 'liquid_cream';
UPDATE "ingredients" SET "key" = 'goatCheese' WHERE "key" = 'goat_cheese';
UPDATE "ingredients" SET "key" = 'fromageBlanc' WHERE "key" = 'fromage_blanc';
UPDATE "ingredients" SET "key" = 'saintNectaire' WHERE "key" = 'saint_nectaire';
UPDATE "ingredients" SET "key" = 'blueCheese' WHERE "key" = 'blue_cheese';
UPDATE "ingredients" SET "key" = 'pontLeveque' WHERE "key" = 'pont_leveque';
UPDATE "ingredients" SET "key" = 'racletteCheese' WHERE "key" = 'raclette_cheese';
UPDATE "ingredients" SET "key" = 'fourmeDAmbert' WHERE "key" = 'fourme_d_ambert';
UPDATE "ingredients" SET "key" = 'ossauIraty' WHERE "key" = 'ossau_iraty';
UPDATE "ingredients" SET "key" = 'saintMarcellin' WHERE "key" = 'saint_marcellin';
UPDATE "ingredients" SET "key" = 'crottinDeChavignol' WHERE "key" = 'crottin_de_chavignol';
UPDATE "ingredients" SET "key" = 'abondanceCheese' WHERE "key" = 'abondance_cheese';
UPDATE "ingredients" SET "key" = 'carreDeLEst' WHERE "key" = 'carre_de_l_est';
UPDATE "ingredients" SET "key" = 'montDor' WHERE "key" = 'mont_dor';
UPDATE "ingredients" SET "key" = 'greekYogurt' WHERE "key" = 'greek_yogurt';
UPDATE "ingredients" SET "key" = 'coconutMilk' WHERE "key" = 'coconut_milk';
UPDATE "ingredients" SET "key" = 'coconutCream' WHERE "key" = 'coconut_cream';
UPDATE "ingredients" SET "key" = 'almondMilk' WHERE "key" = 'almond_milk';
UPDATE "ingredients" SET "key" = 'oatMilk' WHERE "key" = 'oat_milk';
UPDATE "ingredients" SET "key" = 'silkenTofu' WHERE "key" = 'silken_tofu';
UPDATE "ingredients" SET "key" = 'herbesDeProvence' WHERE "key" = 'herbes_de_provence';
UPDATE "ingredients" SET "key" = 'blackPepper' WHERE "key" = 'black_pepper';
UPDATE "ingredients" SET "key" = 'espelettePepper' WHERE "key" = 'espelette_pepper';
UPDATE "ingredients" SET "key" = 'cayennePepper' WHERE "key" = 'cayenne_pepper';
UPDATE "ingredients" SET "key" = 'curryPowder' WHERE "key" = 'curry_powder';
UPDATE "ingredients" SET "key" = 'vanillaBean' WHERE "key" = 'vanilla_bean';
UPDATE "ingredients" SET "key" = 'whitePepper' WHERE "key" = 'white_pepper';
UPDATE "ingredients" SET "key" = 'pinkPepper' WHERE "key" = 'pink_pepper';
UPDATE "ingredients" SET "key" = 'sichuanPepper' WHERE "key" = 'sichuan_pepper';
UPDATE "ingredients" SET "key" = 'smokedPaprika' WHERE "key" = 'smoked_paprika';
UPDATE "ingredients" SET "key" = 'birdEyeChili' WHERE "key" = 'bird_eye_chili';
UPDATE "ingredients" SET "key" = 'juniperBerries' WHERE "key" = 'juniper_berries';
UPDATE "ingredients" SET "key" = 'starAnise' WHERE "key" = 'star_anise';
UPDATE "ingredients" SET "key" = 'greenAnise' WHERE "key" = 'green_anise';
UPDATE "ingredients" SET "key" = 'fennelSeeds' WHERE "key" = 'fennel_seeds';
UPDATE "ingredients" SET "key" = 'colomboPowder' WHERE "key" = 'colombo_powder';
UPDATE "ingredients" SET "key" = 'herbSalt' WHERE "key" = 'herb_salt';
UPDATE "ingredients" SET "key" = 'celerySalt' WHERE "key" = 'celery_salt';
UPDATE "ingredients" SET "key" = 'fleurDeSel' WHERE "key" = 'fleur_de_sel';
UPDATE "ingredients" SET "key" = 'fiveSpice' WHERE "key" = 'five_spice';
UPDATE "ingredients" SET "key" = 'garamMasala' WHERE "key" = 'garam_masala';
UPDATE "ingredients" SET "key" = 'corianderSeeds' WHERE "key" = 'coriander_seeds';
UPDATE "ingredients" SET "key" = 'poblanoPepper' WHERE "key" = 'poblano_pepper';
UPDATE "ingredients" SET "key" = 'rasElHanout' WHERE "key" = 'ras_el_hanout';
UPDATE "ingredients" SET "key" = 'soySauce' WHERE "key" = 'soy_sauce';
UPDATE "ingredients" SET "key" = 'worcestershireSauce' WHERE "key" = 'worcestershire_sauce';
UPDATE "ingredients" SET "key" = 'fishSauce' WHERE "key" = 'fish_sauce';
UPDATE "ingredients" SET "key" = 'curryPaste' WHERE "key" = 'curry_paste';
UPDATE "ingredients" SET "key" = 'peanutButter' WHERE "key" = 'peanut_butter';
UPDATE "ingredients" SET "key" = 'dijonMustard' WHERE "key" = 'dijon_mustard';
UPDATE "ingredients" SET "key" = 'wholegrainMustard' WHERE "key" = 'wholegrain_mustard';
UPDATE "ingredients" SET "key" = 'barbecueSauce' WHERE "key" = 'barbecue_sauce';
UPDATE "ingredients" SET "key" = 'tartarSauce' WHERE "key" = 'tartar_sauce';
UPDATE "ingredients" SET "key" = 'cocktailSauce' WHERE "key" = 'cocktail_sauce';
UPDATE "ingredients" SET "key" = 'bearnaiseSauce' WHERE "key" = 'bearnaise_sauce';
UPDATE "ingredients" SET "key" = 'hollandaiseSauce' WHERE "key" = 'hollandaise_sauce';
UPDATE "ingredients" SET "key" = 'bechamelSauce' WHERE "key" = 'bechamel_sauce';
UPDATE "ingredients" SET "key" = 'teriyakiSauce' WHERE "key" = 'teriyaki_sauce';
UPDATE "ingredients" SET "key" = 'ponzuSauce' WHERE "key" = 'ponzu_sauce';
UPDATE "ingredients" SET "key" = 'redPesto' WHERE "key" = 'red_pesto';
UPDATE "ingredients" SET "key" = 'oysterSauce' WHERE "key" = 'oyster_sauce';
UPDATE "ingredients" SET "key" = 'hoisinSauce' WHERE "key" = 'hoisin_sauce';
UPDATE "ingredients" SET "key" = 'sweetChiliSauce' WHERE "key" = 'sweet_chili_sauce';
UPDATE "ingredients" SET "key" = 'shrimpPaste' WHERE "key" = 'shrimp_paste';
UPDATE "ingredients" SET "key" = 'redCurryPaste' WHERE "key" = 'red_curry_paste';
UPDATE "ingredients" SET "key" = 'greenCurryPaste' WHERE "key" = 'green_curry_paste';
UPDATE "ingredients" SET "key" = 'oliveOil' WHERE "key" = 'olive_oil';
UPDATE "ingredients" SET "key" = 'sunflowerOil' WHERE "key" = 'sunflower_oil';
UPDATE "ingredients" SET "key" = 'rapeseedOil' WHERE "key" = 'rapeseed_oil';
UPDATE "ingredients" SET "key" = 'coconutOil' WHERE "key" = 'coconut_oil';
UPDATE "ingredients" SET "key" = 'sesameOil' WHERE "key" = 'sesame_oil';
UPDATE "ingredients" SET "key" = 'ciderVinegar' WHERE "key" = 'cider_vinegar';
UPDATE "ingredients" SET "key" = 'whiteVinegar' WHERE "key" = 'white_vinegar';
UPDATE "ingredients" SET "key" = 'balsamicVinegar' WHERE "key" = 'balsamic_vinegar';
UPDATE "ingredients" SET "key" = 'blackOlives' WHERE "key" = 'black_olives';
UPDATE "ingredients" SET "key" = 'greenOlives' WHERE "key" = 'green_olives';
UPDATE "ingredients" SET "key" = 'whiteWine' WHERE "key" = 'white_wine';
UPDATE "ingredients" SET "key" = 'redWine' WHERE "key" = 'red_wine';
UPDATE "ingredients" SET "key" = 'roseWine' WHERE "key" = 'rose_wine';
UPDATE "ingredients" SET "key" = 'redWineVinegar' WHERE "key" = 'red_wine_vinegar';
UPDATE "ingredients" SET "key" = 'whiteWineVinegar' WHERE "key" = 'white_wine_vinegar';
UPDATE "ingredients" SET "key" = 'sherryVinegar' WHERE "key" = 'sherry_vinegar';
UPDATE "ingredients" SET "key" = 'walnutOil' WHERE "key" = 'walnut_oil';
UPDATE "ingredients" SET "key" = 'hazelnutOil' WHERE "key" = 'hazelnut_oil';
UPDATE "ingredients" SET "key" = 'peanutOil' WHERE "key" = 'peanut_oil';
UPDATE "ingredients" SET "key" = 'chiliOil' WHERE "key" = 'chili_oil';
UPDATE "ingredients" SET "key" = 'riceVinegar' WHERE "key" = 'rice_vinegar';
UPDATE "ingredients" SET "key" = 'cornOil' WHERE "key" = 'corn_oil';
UPDATE "ingredients" SET "key" = 'grapeseedOil' WHERE "key" = 'grapeseed_oil';
UPDATE "ingredients" SET "key" = 'soybeanOil' WHERE "key" = 'soybean_oil';
UPDATE "ingredients" SET "key" = 'palmOil' WHERE "key" = 'palm_oil';
UPDATE "ingredients" SET "key" = 'lemonJuice' WHERE "key" = 'lemon_juice';
UPDATE "ingredients" SET "key" = 'limeJuice' WHERE "key" = 'lime_juice';
UPDATE "ingredients" SET "key" = 'orangeJuice' WHERE "key" = 'orange_juice';
UPDATE "ingredients" SET "key" = 'appleJuice' WHERE "key" = 'apple_juice';
UPDATE "ingredients" SET "key" = 'grapeJuice' WHERE "key" = 'grape_juice';
UPDATE "ingredients" SET "key" = 'tomatoJuice' WHERE "key" = 'tomato_juice';
UPDATE "ingredients" SET "key" = 'cranberryJuice' WHERE "key" = 'cranberry_juice';
UPDATE "ingredients" SET "key" = 'portWine' WHERE "key" = 'port_wine';
UPDATE "ingredients" SET "key" = 'vinJaune' WHERE "key" = 'vin_jaune';
UPDATE "ingredients" SET "key" = 'wheatFlour' WHERE "key" = 'wheat_flour';
UPDATE "ingredients" SET "key" = 'wholeWheatFlour' WHERE "key" = 'whole_wheat_flour';
UPDATE "ingredients" SET "key" = 'cornFlour' WHERE "key" = 'corn_flour';
UPDATE "ingredients" SET "key" = 'buckwheatFlour' WHERE "key" = 'buckwheat_flour';
UPDATE "ingredients" SET "key" = 'riceFlour' WHERE "key" = 'rice_flour';
UPDATE "ingredients" SET "key" = 'vegetableStockCube' WHERE "key" = 'vegetable_stock_cube';
UPDATE "ingredients" SET "key" = 'chickenStockCube' WHERE "key" = 'chicken_stock_cube';
UPDATE "ingredients" SET "key" = 'tomatoPaste' WHERE "key" = 'tomato_paste';
UPDATE "ingredients" SET "key" = 'tomatoCoulis' WHERE "key" = 'tomato_coulis';
UPDATE "ingredients" SET "key" = 'cannedPeeledTomatoes' WHERE "key" = 'canned_peeled_tomatoes';
UPDATE "ingredients" SET "key" = 'sunDriedTomatoes' WHERE "key" = 'sun_dried_tomatoes';
UPDATE "ingredients" SET "key" = 'vealStock' WHERE "key" = 'veal_stock';
UPDATE "ingredients" SET "key" = 'chickenStock' WHERE "key" = 'chicken_stock';
UPDATE "ingredients" SET "key" = 'beefStockCube' WHERE "key" = 'beef_stock_cube';
UPDATE "ingredients" SET "key" = 'fishStockCube' WHERE "key" = 'fish_stock_cube';
UPDATE "ingredients" SET "key" = 'vegetableBroth' WHERE "key" = 'vegetable_broth';
UPDATE "ingredients" SET "key" = 'chickenBroth' WHERE "key" = 'chicken_broth';
UPDATE "ingredients" SET "key" = 'beefBroth' WHERE "key" = 'beef_broth';
UPDATE "ingredients" SET "key" = 'courtBouillon' WHERE "key" = 'court_bouillon';
UPDATE "ingredients" SET "key" = 'shellfishBisque' WHERE "key" = 'shellfish_bisque';
UPDATE "ingredients" SET "key" = 'tapiocaFlour' WHERE "key" = 'tapioca_flour';
UPDATE "ingredients" SET "key" = 'masaHarina' WHERE "key" = 'masa_harina';
UPDATE "ingredients" SET "key" = 'sparklingWater' WHERE "key" = 'sparkling_water';
UPDATE "ingredients" SET "key" = 'orangeBlossomWater' WHERE "key" = 'orange_blossom_water';
UPDATE "ingredients" SET "key" = 'roseWater' WHERE "key" = 'rose_water';
UPDATE "ingredients" SET "key" = 'fishFumet' WHERE "key" = 'fish_fumet';
UPDATE "ingredients" SET "key" = 'bakersYeast' WHERE "key" = 'bakers_yeast';
UPDATE "ingredients" SET "key" = 'bakingPowder' WHERE "key" = 'baking_powder';
UPDATE "ingredients" SET "key" = 'lupinFlour' WHERE "key" = 'lupin_flour';
UPDATE "ingredients" SET "key" = 'bakingSoda' WHERE "key" = 'baking_soda';
UPDATE "ingredients" SET "key" = 'potatoStarch' WHERE "key" = 'potato_starch';
UPDATE "ingredients" SET "key" = 'mapleSyrup' WHERE "key" = 'maple_syrup';
UPDATE "ingredients" SET "key" = 'brownSugar' WHERE "key" = 'brown_sugar';
UPDATE "ingredients" SET "key" = 'powderedSugar' WHERE "key" = 'powdered_sugar';
UPDATE "ingredients" SET "key" = 'demeraraSugar' WHERE "key" = 'demerara_sugar';
UPDATE "ingredients" SET "key" = 'darkChocolate' WHERE "key" = 'dark_chocolate';
UPDATE "ingredients" SET "key" = 'milkChocolate' WHERE "key" = 'milk_chocolate';
UPDATE "ingredients" SET "key" = 'whiteChocolate' WHERE "key" = 'white_chocolate';
UPDATE "ingredients" SET "key" = 'chocolateChips' WHERE "key" = 'chocolate_chips';
UPDATE "ingredients" SET "key" = 'cocoaPowder' WHERE "key" = 'cocoa_powder';
UPDATE "ingredients" SET "key" = 'vanillaExtract' WHERE "key" = 'vanilla_extract';
UPDATE "ingredients" SET "key" = 'palmSugar' WHERE "key" = 'palm_sugar';
UPDATE "ingredients" SET "key" = 'caneSyrup' WHERE "key" = 'cane_syrup';
-- IngredientCategory/IngredientSubcategory enum rename — same
-- add-with-default-then-swap approach as
-- 20260818113250_ingredient_taxonomy_rework/migration.sql: the old and
-- new enums share no values, so a direct cast isn't possible. Existing
-- rows land on the placeholder default; seedReferenceData() (runs on
-- every container start, see apps/api/Dockerfile) corrects every row's
-- real category/subcategory immediately after.
CREATE TYPE "IngredientCategory_new" AS ENUM ('freshProduce', 'meatAndSeafood', 'dryGoods', 'bakery', 'dairyAndCheese', 'condimentsAndSpices', 'cookingEssentials');
CREATE TYPE "IngredientSubcategory_new" AS ENUM ('vegetables', 'fruits', 'freshHerbs', 'meats', 'poultry', 'fish', 'shellfish', 'starches', 'legumes', 'nutsAndSeeds', 'other', 'breads', 'rawDough', 'dairy', 'eggs', 'plantBasedAlternatives', 'spices', 'sauces', 'seasonings', 'bases', 'thickeners', 'sugars');
ALTER TABLE "ingredients" ADD COLUMN "category_new" "IngredientCategory_new" NOT NULL DEFAULT 'dryGoods';
ALTER TABLE "ingredients" ADD COLUMN "subcategory_new" "IngredientSubcategory_new" NOT NULL DEFAULT 'other';
ALTER TABLE "ingredients" DROP COLUMN "category";
ALTER TABLE "ingredients" DROP COLUMN "subcategory";
ALTER TABLE "ingredients" RENAME COLUMN "category_new" TO "category";
ALTER TABLE "ingredients" RENAME COLUMN "subcategory_new" TO "subcategory";
DROP TYPE "IngredientCategory";
DROP TYPE "IngredientSubcategory";
ALTER TYPE "IngredientCategory_new" RENAME TO "IngredientCategory";
ALTER TYPE "IngredientSubcategory_new" RENAME TO "IngredientSubcategory";

View file

@ -37,11 +37,11 @@ model House {
/// `key` is `@unique` — not in the original spec doc, added so the seed /// `key` is `@unique` — not in the original spec doc, added so the seed
/// script (prisma/seed.ts) can `upsert` by key and stay idempotent/safe to /// script (prisma/seed.ts) can `upsert` by key and stay idempotent/safe to
/// re-run, and so two reference rows can never silently duplicate the same /// re-run, and so two reference rows can never silently duplicate the same
/// regime. A stable slug (e.g. `"vegetarien"`), not the display label — /// regime. A stable English camelCase uid (e.g. `"vegetarian"`), not the
/// the label itself lives in `apps/web`'s `locales/fr/translation.json` /// display label — the label itself lives in `apps/web`'s
/// under `catalog.diets.<key>` (see `reference-seed-data.ts`'s `DIETS` and /// `locales/fr/translation.json` under `catalog.diets.<key>` (see
/// `utils/slugify.ts`), so it can be edited/translated without ever /// `reference-seed-data.ts`'s `DIETS`), so it can be edited/translated
/// touching this column or the rows that reference it by id. /// without ever touching this column or the rows that reference it by id.
model Diet { model Diet {
id Int @id @default(autoincrement()) id Int @id @default(autoincrement())
key String @unique key String @unique
@ -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")
} }
@ -315,30 +313,28 @@ model RecipeDiet {
/// `reference-seed-data.ts`'s `INGREDIENT_GROUPS` keys exactly — that file /// `reference-seed-data.ts`'s `INGREDIENT_GROUPS` keys exactly — that file
/// is the single source of truth for which ingredient belongs to which /// is the single source of truth for which ingredient belongs to which
/// (category, subcategory) pair, these enums just give it type-safe /// (category, subcategory) pair, these enums just give it type-safe
/// columns to live in. `@default(EPICERIE_SECHE)` exists only so this /// columns to live in. `@default(dryGoods)` exists only so this
/// column can be added `NOT NULL` to a table that may already have rows — /// 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 /// 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. /// run, this default is never the intended value for a real ingredient.
enum IngredientCategory { enum IngredientCategory {
/// 🥦 Légumes, fruits, herbes fraîches. /// 🥦 Vegetables, fruits, fresh herbs.
PRODUITS_FRAIS freshProduce
/// 🥩 Viandes, volailles, poissons, crustacés & fruits de mer. /// 🥩 Meats, poultry, fish, shellfish & seafood.
BOUCHERIE_POISSONNERIE meatAndSeafood
/// 🥫 Féculents, légumineuses, graines & fruits secs, et le reste des /// 🥫 Starches, legumes, nuts & seeds, and the rest of the dry/tinned
/// produits secs/en conserve qui ne rentre dans aucune autre case /// goods that don't fit any other bucket (dried seaweed, dried
/// (algues séchées, champignons séchés…). /// mushrooms…).
EPICERIE_SECHE dryGoods
/// 🍞 Pains et pâtes à cuire (crues, à enfourner). /// 🍞 Breads and raw dough (uncooked, ready to bake).
BOULANGERIE bakery
/// 🧈 Produits laitiers, œufs, alternatives végétales (laits végétaux, /// 🧈 Dairy, eggs, plant-based alternatives (plant milks, tofu…).
/// tofu…). dairyAndCheese
CREMERIE_FROMAGE /// 🧂 Spices, sauces, seasonings (oils, vinegars, cooking alcohols…).
/// 🧂 Épices, sauces, assaisonnements (huiles, vinaigres, alcools de condimentsAndSpices
/// cuisine…). /// 🍳 Prep bases (flours, stocks, water), thickeners (yeasts, starches,
CONDIMENTS_EPICES /// gelatin), sugars.
/// 🍳 Bases de préparation (farines, bouillons, eau), épaississants cookingEssentials
/// (levures, fécules, gélatine), sucres.
AIDES_CULINAIRES
} }
/// Finer-grained rack within one {@link IngredientCategory} aisle — see /// Finer-grained rack within one {@link IngredientCategory} aisle — see
@ -347,50 +343,50 @@ enum IngredientCategory {
/// `reference-seed-data.ts`'s `INGREDIENT_GROUPS`, not enforced at the /// `reference-seed-data.ts`'s `INGREDIENT_GROUPS`, not enforced at the
/// database level — Postgres enums can't express that relationship, same /// database level — Postgres enums can't express that relationship, same
/// tradeoff already accepted for `IngredientCategory` itself). /// tradeoff already accepted for `IngredientCategory` itself).
/// `@default(AUTRES)` — same NOT-NULL-migration-safety-net reasoning as /// `@default(other)` — same NOT-NULL-migration-safety-net reasoning as
/// `IngredientCategory`'s default, never the intended value for a real row. /// `IngredientCategory`'s default, never the intended value for a real row.
enum IngredientSubcategory { enum IngredientSubcategory {
// --- Produits frais ------------------------------------------------------ // --- freshProduce ----------------------------------------------------------
LEGUMES vegetables
FRUITS fruits
HERBES_FRAICHES freshHerbs
// --- Boucherie & poissonnerie --------------------------------------------- // --- meatAndSeafood ----------------------------------------------------------
VIANDES meats
VOLAILLES poultry
POISSONS fish
CRUSTACES_FRUITS_DE_MER shellfish
// --- Épicerie sèche -------------------------------------------------------- // --- dryGoods ----------------------------------------------------------------
FECULENTS starches
LEGUMINEUSES legumes
GRAINES_FRUITS_SECS nutsAndSeeds
/// Catch-all for dried/tinned pantry items that don't fit the three /// Catch-all for dried/tinned pantry items that don't fit the three
/// subcategories above — dried seaweed, dried mushrooms, tinned bamboo /// subcategories above — dried seaweed, dried mushrooms, tinned bamboo
/// shoots/water chestnuts… /// shoots/water chestnuts…
AUTRES other
// --- Boulangerie ------------------------------------------------------- // --- bakery --------------------------------------------------------------
PAINS breads
/// Raw, uncooked doughs meant to be baked (puff pastry, shortcrust…) — /// Raw, uncooked doughs meant to be baked (puff pastry, shortcrust…) —
/// distinct from `PAINS` (already-baked bread). /// distinct from `breads` (already-baked bread).
PATES_A_CUIRE rawDough
// --- Crémerie & fromage -------------------------------------------------- // --- dairyAndCheese ------------------------------------------------------
PRODUITS_LAITIERS dairy
OEUFS eggs
/// Plant-based dairy/meat substitutes — coconut/almond/oat "milk", tofu. /// Plant-based dairy/meat substitutes — coconut/almond/oat "milk", tofu.
ALTERNATIVES plantBasedAlternatives
// --- Condiments & épices ------------------------------------------------- // --- condimentsAndSpices ---------------------------------------------------
EPICES spices
SAUCES sauces
/// Oils, vinegars, citrus juices, cooking alcohols/wines — liquids that /// Oils, vinegars, citrus juices, cooking alcohols/wines — liquids that
/// season rather than form the base of a dish. /// season rather than form the base of a dish.
ASSAISONNEMENTS seasonings
// --- Aides culinaires ---------------------------------------------------- // --- cookingEssentials -----------------------------------------------------
/// Flours, stocks/broths, canned tomato bases, water — the literal base /// Flours, stocks/broths, canned tomato bases, water — the literal base
/// a recipe is built on. /// a recipe is built on.
BASES bases
/// Leavening/gelling/thickening agents — yeast, baking soda, cornstarch, /// Leavening/gelling/thickening agents — yeast, baking soda, cornstarch,
/// gelatin. /// gelatin.
EPAISSISSANTS thickeners
SUCRES sugars
} }
/// Generic pictogram *type* for an ingredient — not in the original spec /// Generic pictogram *type* for an ingredient — not in the original spec
@ -435,11 +431,20 @@ 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(dryGoods)
subcategory IngredientSubcategory @default(AUTRES) subcategory IngredientSubcategory @default(other)
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}.

View file

@ -1,49 +0,0 @@
/**
* One-off generator, run by hand whenever the catalog's reference data
* changes (a new ingredient/diet/allergen added to
* `db/reference-seed-data.ts`, or an English key corrected in
* `catalog-en-keys.ts`): regenerates
* `apps/web/src/locales/fr/translation.json`'s
* `catalog.{diets,allergens,ingredients}` sections (English key -> French
* label), merged in without touching the rest of the file.
*
* Doesn't touch the database a brand new diet/allergen/ingredient is
* created fresh by `seedReferenceData`'s normal create path (see
* `reference-seed-data.ts`), no backfill needed. Renaming an *existing*
* item's English key in `catalog-en-keys.ts` does need a one-off migration
* (`UPDATE ... SET key = ...`, keyed by the *old* key value) written by
* hand for that occasion see
* `prisma/migrations/20260818193000_catalog_keys_to_english/` for the shape
* one looks like.
*
* Never imported by the app itself a dev-time tool, run via
* `tsx scripts/generate-catalog-i18n.ts`.
*/
import { readFileSync, writeFileSync } from "node:fs";
import { fileURLToPath } from "node:url";
import { getEnglishKey } from "../src/db/catalog-en-keys.js";
import { ALLERGENS, DIETS, INGREDIENT_GROUPS } from "../src/db/reference-seed-data.js";
const here = fileURLToPath(new URL(".", import.meta.url));
function toKeyLabelMap(labels: string[]): Record<string, string> {
const map: Record<string, string> = {};
for (const label of labels) {
map[getEnglishKey(label)] = label;
}
return map;
}
const diets = toKeyLabelMap(DIETS);
const allergens = toKeyLabelMap(ALLERGENS.map((a) => a.name));
const ingredients = toKeyLabelMap(INGREDIENT_GROUPS.flatMap((g) => g.items.map((i) => i.name)));
console.log(
`diets: ${Object.keys(diets).length}, allergens: ${Object.keys(allergens).length}, ingredients: ${Object.keys(ingredients).length}`,
);
const localePath = `${here}../../web/src/locales/fr/translation.json`;
const locale = JSON.parse(readFileSync(localePath, "utf8"));
locale.catalog = { diets, allergens, ingredients };
writeFileSync(localePath, `${JSON.stringify(locale, null, 2)}\n`);
console.log(`wrote ${localePath}`);

View file

@ -1,46 +0,0 @@
import { getEnglishKey } from "../src/db/catalog-en-keys.js";
/**
* One-off validation, run by hand: checks that `catalog-en-keys.ts` has an
* entry for every diet/allergen/ingredient currently in
* `reference-seed-data.ts`, and that the resulting English keys are unique
* within each table. Not part of the app or the seed itself just a
* pre-flight check while building/editing the dictionary by hand.
*/
import { ALLERGENS, DIETS, INGREDIENT_GROUPS } from "../src/db/reference-seed-data.js";
function check(label: string, names: string[]) {
const keys = new Map<string, string>();
const missing: string[] = [];
const duplicates: string[] = [];
for (const name of names) {
let key: string;
try {
key = getEnglishKey(name);
} catch {
missing.push(name);
continue;
}
const existing = keys.get(key);
if (existing !== undefined && existing !== name) {
duplicates.push(`"${existing}" and "${name}" both map to "${key}"`);
}
keys.set(key, name);
}
console.log(`${label}: ${names.length} names, ${keys.size} unique keys`);
if (missing.length > 0) {
console.log(` MISSING (${missing.length}):`, missing);
}
if (duplicates.length > 0) {
console.log(` DUPLICATES (${duplicates.length}):`, duplicates);
}
}
check("Diets", DIETS);
check(
"Allergens",
ALLERGENS.map((a) => a.name),
);
check(
"Ingredients",
INGREDIENT_GROUPS.flatMap((g) => g.items.map((i) => i.name)),
);

View file

@ -1,666 +0,0 @@
/**
* English key for every catalog reference label `Diet`/`Category`
* (allergens)/`Ingredient` rows must carry a stable, storage-safe `key`
* that is itself English, independent of whatever language the *seed's*
* authoring label (`DIETS`/`ALLERGENS`/`INGREDIENT_GROUPS` in
* `reference-seed-data.ts`, currently French) happens to be in a French
* `key` would tie the identifier to the one language it's meant to be
* decoupled from (see `utils/slugify.ts`'s doc comment and `apps/web`'s
* `locales/fr/translation.json` `catalog.*` namespace, which resolves the
* *display* label from this same key).
*
* Hand-assigned (not machine-translated) an English label is chosen once
* and never changes, exactly like the key it produces (via {@link
* slugify}). Keyed by the exact French authoring label so
* `reference-seed-data.ts` and `scripts/generate-catalog-i18n.ts` can look
* a row's key up by the same string they already have in hand.
*
* `getEnglishKey` throws on a missing entry rather than falling back to
* slugifying the French label a silently-French key defeats the point,
* so a newly-added diet/allergen/ingredient must get an entry here before
* it can seed.
*/
import { slugify } from "../utils/slugify.js";
const DIET_KEYS: Record<string, string> = {
Omnivore: "omnivore",
Végétarien: "vegetarian",
Végan: "vegan",
Pescétarien: "pescatarian",
"Sans gluten": "gluten_free",
};
const ALLERGEN_KEYS: Record<string, string> = {
Gluten: "gluten",
Crustacés: "crustaceans",
Œufs: "eggs",
Poissons: "fish",
Arachides: "peanuts",
Soja: "soy",
Lait: "milk",
"Fruits à coque": "tree_nuts",
Céleri: "celery",
Moutarde: "mustard",
"Graines de sésame": "sesame_seeds",
Sulfites: "sulfites",
Lupin: "lupin",
Mollusques: "molluscs",
};
// Grouped by (category, subcategory), mirroring `INGREDIENT_GROUPS` in
// reference-seed-data.ts, purely so a translator can find/check an entry
// against its source group — this is one flat lookup table at runtime.
const INGREDIENT_KEYS: Record<string, string> = {
// --- Produits frais / Légumes ---------------------------------------
Tomate: "tomato",
Oignon: "onion",
Échalote: "shallot",
Ail: "garlic",
Carotte: "carrot",
Courgette: "zucchini",
Concombre: "cucumber",
Cornichons: "gherkins",
Poivron: "bell_pepper",
Champignon: "mushroom",
Cèpes: "porcini",
Aubergine: "eggplant",
Brocoli: "broccoli",
"Chou-fleur": "cauliflower",
"Chou blanc": "white_cabbage",
"Chou rouge": "red_cabbage",
"Chou de Bruxelles": "brussels_sprouts",
Épinard: "spinach",
Blette: "swiss_chard",
Salade: "lettuce",
Roquette: "arugula",
Cresson: "watercress",
Poireau: "leek",
Radis: "radish",
Betterave: "beetroot",
Navet: "turnip",
Panais: "parsnip",
"Haricot vert": "green_bean",
"Petit pois": "pea",
Maïs: "corn",
Artichaut: "artichoke",
Fenouil: "fennel",
Endive: "endive",
Potiron: "pumpkin",
Butternut: "butternut_squash",
Asperge: "asparagus",
Avocat: "avocado",
"Pomme de terre": "potato",
"Patate douce": "sweet_potato",
"Tomates cerises": "cherry_tomato",
"Pak-choï": "bok_choy",
"Germes de soja": "soybean_sprouts",
Shiitake: "shiitake",
Daikon: "daikon",
"Piment vert frais": "fresh_green_chili",
Cardon: "cardoon",
"Chicorée rouge": "radicchio",
"Chou romanesco": "romanesco",
"Chou-rave": "kohlrabi",
"Chou chinois": "napa_cabbage",
"Céleri-rave": "celeriac",
Gombo: "okra",
"Oignon nouveau": "spring_onion",
Potimarron: "red_kuri_squash",
Rutabaga: "rutabaga",
Salicorne: "samphire",
Salsifis: "salsify",
Mâche: "lambs_lettuce",
Scarole: "escarole",
// --- Produits frais / Fruits -----------------------------------------
Citron: "lemon",
"Citron vert": "lime",
Pomme: "apple",
Poire: "pear",
Banane: "banana",
Orange: "orange",
Clémentine: "clementine",
Pamplemousse: "grapefruit",
Fraise: "strawberry",
Framboise: "raspberry",
Myrtille: "blueberry",
Mûre: "blackberry",
Cerise: "cherry",
Abricot: "apricot",
Pêche: "peach",
Prune: "plum",
Raisin: "grape",
Melon: "melon",
Pastèque: "watermelon",
Ananas: "pineapple",
Mangue: "mango",
Kiwi: "kiwi",
Figue: "fig",
Datte: "date",
Litchi: "lychee",
Grenade: "pomegranate",
Rhubarbe: "rhubarb",
Coing: "quince",
Cassis: "blackcurrant",
Canneberge: "cranberry",
Groseille: "redcurrant",
Kaki: "persimmon",
Nectarine: "nectarine",
Tamarin: "tamarind",
// --- Produits frais / Herbes fraîches ---------------------------------
Basilic: "basil",
Persil: "parsley",
Thym: "thyme",
Romarin: "rosemary",
Laurier: "bay_leaf",
Ciboulette: "chives",
"Coriandre fraîche": "fresh_cilantro",
Menthe: "mint",
Origan: "oregano",
Aneth: "dill",
Estragon: "tarragon",
Sarriette: "savory",
Marjolaine: "marjoram",
Sauge: "sage",
Cerfeuil: "chervil",
Gingembre: "ginger",
Citronnelle: "lemongrass",
Combava: "kaffir_lime",
// --- Boucherie & poissonnerie / Viandes -------------------------------
Lapin: "rabbit",
"Bœuf haché": "ground_beef",
"Steak de bœuf": "beef_steak",
"Rôti de bœuf": "beef_roast",
"Escalope de veau": "veal_cutlet",
"Filet mignon de porc": "pork_tenderloin",
"Côte de porc": "pork_chop",
Agneau: "lamb",
"Gigot d'agneau": "leg_of_lamb",
Lardons: "bacon_lardons",
Bacon: "bacon",
"Jambon blanc": "ham",
"Jambon cru": "cured_ham",
Saucisse: "sausage",
Chorizo: "chorizo",
Merguez: "merguez",
Prosciutto: "prosciutto",
Pancetta: "pancetta",
Mortadelle: "mortadella",
Salami: "salami",
Andouille: "andouille",
Andouillette: "andouillette",
"Boudin blanc": "white_pudding",
"Boudin noir": "black_pudding",
Cervelas: "cervelat",
Rillettes: "rillettes",
"Saucisson sec": "dry_cured_sausage",
"Jambon de Bayonne": "bayonne_ham",
Coppa: "coppa",
"Rosette (saucisson)": "rosette_sausage",
"Foie de veau": "veal_liver",
"Rognons de veau": "veal_kidneys",
"Cervelle de veau": "veal_brain",
"Ris de veau": "veal_sweetbread",
"Langue de bœuf": "beef_tongue",
Tripes: "tripe",
Cerf: "venison",
Chevreuil: "roe_deer",
Sanglier: "wild_boar",
Cheval: "horse_meat",
"Cœur de bœuf": "beef_heart",
"Foie gras": "foie_gras",
"Museau de bœuf": "beef_muzzle",
"Viande des Grisons": "grisons_dried_beef",
// --- Boucherie & poissonnerie / Volailles -----------------------------
Poulet: "chicken",
Dinde: "turkey",
Canard: "duck",
"Magret de canard": "duck_breast",
Caille: "quail",
Pintade: "guinea_fowl",
Oie: "goose",
"Foie de volaille": "poultry_liver",
Chapon: "capon",
Pigeon: "pigeon",
Faisan: "pheasant",
// --- Boucherie & poissonnerie / Poissons -------------------------------
Saumon: "salmon",
Thon: "tuna",
Cabillaud: "cod",
Truite: "trout",
Sardine: "sardine",
Anchois: "anchovy",
Merlan: "whiting",
Surimi: "surimi",
"Bar (loup de mer)": "sea_bass",
Dorade: "sea_bream",
Sole: "sole",
Turbot: "turbot",
Merlu: "hake",
Colin: "pollock",
"Lieu noir": "saithe",
Églefin: "haddock",
Maquereau: "mackerel",
Hareng: "herring",
Rouget: "red_mullet",
Raie: "skate",
Lotte: "monkfish",
Flétan: "halibut",
Espadon: "swordfish",
Carpe: "carp",
Brochet: "pike",
Perche: "perch",
Tilapia: "tilapia",
Panga: "pangasius",
"Saumon fumé": "smoked_salmon",
"Poisson séché": "dried_fish",
Anguille: "eel",
"Carrelet (ou plie)": "plaice",
Morue: "salt_cod",
Limande: "lemon_sole",
Rascasse: "scorpionfish",
// --- Boucherie & poissonnerie / Crustacés & fruits de mer -------------
Crevettes: "shrimp",
Langoustines: "langoustine",
Homard: "lobster",
Crabe: "crab",
Langouste: "spiny_lobster",
Moules: "mussels",
Huîtres: "oysters",
"Saint-Jacques": "scallops",
Calamar: "squid",
Poulpe: "octopus",
Palourdes: "clams",
Bulots: "whelks",
"Araignée de mer": "spider_crab",
Bigorneau: "periwinkle",
Écrevisse: "crayfish",
"Crevette grise": "grey_shrimp",
Coque: "cockle",
Escargot: "snail",
Seiche: "cuttlefish",
// --- Épicerie sèche / Féculents ----------------------------------------
Semoule: "semolina",
Couscous: "couscous",
Boulgour: "bulgur",
Polenta: "polenta",
Quinoa: "quinoa",
Pâtes: "pasta",
"Pâtes complètes": "whole_wheat_pasta",
Riz: "rice",
"Riz basmati": "basmati_rice",
"Riz complet": "brown_rice",
"Flocons d'avoine": "oats",
Spaghetti: "spaghetti",
Penne: "penne",
Tagliatelles: "tagliatelle",
"Lasagnes (feuilles)": "lasagna_sheets",
Gnocchi: "gnocchi",
"Riz arborio": "arborio_rice",
"Nouilles de riz": "rice_noodles",
"Nouilles udon": "udon_noodles",
"Nouilles soba": "soba_noodles",
"Nouilles chinoises": "chinese_noodles",
"Vermicelles de riz": "rice_vermicelli",
"Vermicelles de soja": "soy_vermicelli",
"Riz gluant": "sticky_rice",
"Riz à sushi": "sushi_rice",
"Riz jasmin": "jasmine_rice",
// --- Épicerie sèche / Légumineuses --------------------------------------
"Lentilles vertes": "green_lentils",
"Lentilles corail": "red_lentils",
"Pois chiches": "chickpeas",
"Haricots blancs": "white_beans",
"Haricots rouges": "kidney_beans",
"Haricots noirs": "black_beans",
"Pois cassés": "split_peas",
Fèves: "fava_beans",
Edamame: "edamame",
"Haricots pinto": "pinto_beans",
"Haricots flageolets": "flageolet_beans",
"Lentilles blondes": "golden_lentils",
// --- Épicerie sèche / Graines & fruits secs ----------------------------
Cacahuètes: "peanuts_shelled",
Amandes: "almonds",
Noix: "walnuts",
Noisettes: "hazelnuts",
"Noix de cajou": "cashews",
Pistaches: "pistachios",
"Noix de pécan": "pecans",
"Poudre d'amande": "almond_powder",
"Pignons de pin": "pine_nuts",
"Graines de tournesol": "sunflower_seeds",
"Graines de courge": "pumpkin_seeds",
"Noix de coco râpée": "shredded_coconut",
"Raisins secs": "raisins",
Pruneaux: "prunes",
"Abricots secs": "dried_apricots",
// --- Épicerie sèche / Autres --------------------------------------------
"Champignons noirs": "black_mushrooms",
"Algue nori": "nori_seaweed",
"Algue wakamé": "wakame_seaweed",
"Algue kombu": "kombu_seaweed",
"Pousses de bambou": "bamboo_shoots",
"Châtaignes d'eau": "water_chestnuts",
// --- Boulangerie / Pains -------------------------------------------------
Pain: "bread",
"Pain de mie": "sandwich_bread",
"Pain complet": "whole_wheat_bread",
Baguette: "baguette",
"Pain de seigle": "rye_bread",
Chapelure: "breadcrumbs",
"Pain à burger": "burger_bun",
"Pain brioché": "brioche_bun",
"Pain à hot-dog": "hot_dog_bun",
"Pain pita": "pita_bread",
"Pain bagel": "bagel",
Naan: "naan",
"Pain wrap": "wrap_bread",
"Pain viennois": "viennese_bread",
"Pain de campagne": "country_bread",
"Pain aux céréales": "multigrain_bread",
"Petit pain": "bread_roll",
"Pain suédois": "swedish_bread",
"Pain sans gluten": "gluten_free_bread",
Biscotte: "rusk",
Croûtons: "croutons",
Focaccia: "focaccia",
Ciabatta: "ciabatta",
"Tortilla de maïs": "corn_tortilla",
"Tortilla de blé": "wheat_tortilla",
Gressin: "breadstick",
// --- Boulangerie / Pâtes à cuire -----------------------------------------
"Pâte feuilletée": "puff_pastry",
"Pâte brisée": "shortcrust_pastry",
"Pâte à pizza": "pizza_dough",
"Pâte à tarte sablée": "sweet_shortcrust_pastry",
// --- Crémerie & fromage / Produits laitiers ------------------------------
Lait: "milk",
Beurre: "butter",
"Crème fraîche": "creme_fraiche",
"Crème liquide": "liquid_cream",
Fromage: "cheese",
Emmental: "emmental",
Gruyère: "gruyere",
Parmesan: "parmesan",
Mozzarella: "mozzarella",
"Chèvre (fromage)": "goat_cheese",
Feta: "feta",
Comté: "comte",
"Fromage blanc": "fromage_blanc",
Mascarpone: "mascarpone",
Yaourt: "yogurt",
Burrata: "burrata",
Ricotta: "ricotta",
Pecorino: "pecorino",
Gorgonzola: "gorgonzola",
Cheddar: "cheddar",
Brie: "brie",
Camembert: "camembert",
Roquefort: "roquefort",
Munster: "munster",
Reblochon: "reblochon",
Cantal: "cantal",
Beaufort: "beaufort",
"Saint-Nectaire": "saint_nectaire",
"Bleu (fromage)": "blue_cheese",
Cancoillotte: "cancoillotte",
Tomme: "tomme",
Époisses: "epoisses",
Chaource: "chaource",
Livarot: "livarot",
"Pont-l'Évêque": "pont_leveque",
Morbier: "morbier",
"Raclette (fromage)": "raclette_cheese",
"Fourme d'Ambert": "fourme_d_ambert",
Salers: "salers",
"Ossau-Iraty": "ossau_iraty",
Vacherin: "vacherin",
"Saint-Marcellin": "saint_marcellin",
Neufchâtel: "neufchatel",
"Crottin de Chavignol": "crottin_de_chavignol",
Abondance: "abondance_cheese",
"Carré de l'Est": "carre_de_l_est",
Edam: "edam",
Gouda: "gouda",
Mimolette: "mimolette",
Maroilles: "maroilles",
"Mont d'or": "mont_dor",
Kéfir: "kefir",
"Yaourt à la grecque": "greek_yogurt",
// --- Crémerie & fromage / Œufs -------------------------------------------
Œuf: "egg",
// --- Crémerie & fromage / Alternatives ------------------------------------
"Lait de coco": "coconut_milk",
"Crème de coco": "coconut_cream",
"Lait d'amande": "almond_milk",
"Lait d'avoine": "oat_milk",
Tofu: "tofu",
"Tofu soyeux": "silken_tofu",
// --- Condiments & épices / Épices -----------------------------------------
"Herbes de Provence": "herbes_de_provence",
"Poivre noir": "black_pepper",
Paprika: "paprika",
"Piment d'Espelette": "espelette_pepper",
"Piment de Cayenne": "cayenne_pepper",
Cumin: "cumin",
"Curry (poudre)": "curry_powder",
Curcuma: "turmeric",
Cannelle: "cinnamon",
Muscade: "nutmeg",
Safran: "saffron",
"Clou de girofle": "clove",
"Vanille (gousse)": "vanilla_bean",
"Poivre blanc": "white_pepper",
"Poivre rose": "pink_pepper",
"Poivre du Sichuan": "sichuan_pepper",
"Paprika fumé": "smoked_paprika",
"Piment oiseau": "bird_eye_chili",
"Baies de genièvre": "juniper_berries",
"Anis étoilé (badiane)": "star_anise",
"Anis vert": "green_anise",
"Graines de fenouil": "fennel_seeds",
Sumac: "sumac",
Nigelle: "nigella",
"Quatre épices": "allspice",
"Colombo (poudre)": "colombo_powder",
Baharat: "baharat",
Raifort: "horseradish",
"Sel aux herbes": "herb_salt",
"Sel de céleri": "celery_salt",
"Fleur de sel": "fleur_de_sel",
Sel: "salt",
"Cinq épices": "five_spice",
"Garam masala": "garam_masala",
"Graines de coriandre": "coriander_seeds",
Cardamome: "cardamom",
Fenugrec: "fenugreek",
"Piment jalapeño": "jalapeno",
"Piment chipotle": "chipotle",
"Piment poblano": "poblano_pepper",
"Piment habanero": "habanero",
"Ras el hanout": "ras_el_hanout",
"Za'atar": "zaatar",
// --- Condiments & épices / Sauces -------------------------------------------
"Sauce soja": "soy_sauce",
Moutarde: "mustard",
Mayonnaise: "mayonnaise",
Ketchup: "ketchup",
Tabasco: "tabasco",
"Sauce Worcestershire": "worcestershire_sauce",
"Sauce nuoc-mâm": "fish_sauce",
Wasabi: "wasabi",
Harissa: "harissa",
"Pâte de curry": "curry_paste",
"Beurre de cacahuète": "peanut_butter",
"Moutarde de Dijon": "dijon_mustard",
"Moutarde à l'ancienne": "wholegrain_mustard",
"Sauce barbecue": "barbecue_sauce",
"Sauce tartare": "tartar_sauce",
"Sauce cocktail": "cocktail_sauce",
"Sauce béarnaise": "bearnaise_sauce",
"Sauce hollandaise": "hollandaise_sauce",
"Sauce béchamel": "bechamel_sauce",
"Sauce teriyaki": "teriyaki_sauce",
"Sauce ponzu": "ponzu_sauce",
Chimichurri: "chimichurri",
"Pesto rouge (tomates séchées)": "red_pesto",
Pesto: "pesto",
"Sauce huître": "oyster_sauce",
"Sauce hoisin": "hoisin_sauce",
"Sauce sriracha": "sriracha",
"Sauce sweet chili": "sweet_chili_sauce",
Miso: "miso",
"Pâte de crevettes": "shrimp_paste",
"Pâte de curry rouge (thaï)": "red_curry_paste",
"Pâte de curry vert (thaï)": "green_curry_paste",
Tahini: "tahini",
Aïoli: "aioli",
"Sauce vinaigrette": "vinaigrette",
Houmous: "hummus",
// --- Condiments & épices / Assaisonnements -----------------------------------
"Huile d'olive": "olive_oil",
"Huile de tournesol": "sunflower_oil",
"Huile de colza": "rapeseed_oil",
"Huile de coco": "coconut_oil",
"Huile de sésame": "sesame_oil",
"Vinaigre de cidre": "cider_vinegar",
"Vinaigre blanc": "white_vinegar",
"Vinaigre balsamique": "balsamic_vinegar",
Câpres: "capers",
Olives: "olives",
"Olives noires": "black_olives",
"Olives vertes": "green_olives",
"Vin blanc (cuisine)": "white_wine",
"Vin rouge (cuisine)": "red_wine",
"Vin rosé (cuisine)": "rose_wine",
"Vinaigre de vin rouge": "red_wine_vinegar",
"Vinaigre de vin blanc": "white_wine_vinegar",
"Vinaigre de xérès": "sherry_vinegar",
"Huile de noix": "walnut_oil",
"Huile de noisette": "hazelnut_oil",
"Huile d'arachide": "peanut_oil",
"Huile pimentée": "chili_oil",
"Huile de maïs": "corn_oil",
"Huile de pépins de raisin": "grapeseed_oil",
"Huile de soja": "soybean_oil",
"Huile de palme": "palm_oil",
"Vinaigre de riz": "rice_vinegar",
Mirin: "mirin",
"Saké (cuisine)": "sake",
"Jus de citron": "lemon_juice",
"Jus de citron vert": "lime_juice",
"Jus d'orange": "orange_juice",
"Jus de pomme": "apple_juice",
"Jus de raisin": "grape_juice",
"Jus de tomate": "tomato_juice",
"Jus de cranberry": "cranberry_juice",
Café: "coffee",
Thé: "tea",
"Bière (cuisine)": "beer",
"Cidre (cuisine)": "cider",
"Champagne / vin pétillant (cuisine)": "champagne",
"Porto (cuisine)": "port_wine",
"Vin jaune (cuisine)": "vin_jaune",
Cognac: "cognac",
Rhum: "rum",
Whisky: "whisky",
Vodka: "vodka",
// --- Aides culinaires / Bases -------------------------------------------
"Farine de blé": "wheat_flour",
"Farine complète": "whole_wheat_flour",
"Farine de maïs": "corn_flour",
"Farine de sarrasin": "buckwheat_flour",
"Farine de riz": "rice_flour",
"Bouillon cube légumes": "vegetable_stock_cube",
"Bouillon cube volaille": "chicken_stock_cube",
"Concentré de tomate": "tomato_paste",
"Coulis de tomate": "tomato_coulis",
"Tomates pelées (conserve)": "canned_peeled_tomatoes",
"Tomates séchées": "sun_dried_tomatoes",
"Fond de veau": "veal_stock",
"Fond de volaille": "chicken_stock",
"Bouillon cube bœuf": "beef_stock_cube",
"Bouillon cube poisson": "fish_stock_cube",
"Bouillon de légumes": "vegetable_broth",
"Bouillon de volaille": "chicken_broth",
"Bouillon de bœuf": "beef_broth",
"Court-bouillon": "court_bouillon",
"Dashi (bouillon japonais)": "dashi",
"Bisque de crustacés": "shellfish_bisque",
"Farine de tapioca": "tapioca_flour",
"Masa harina": "masa_harina",
Eau: "water",
"Eau gazeuse": "sparkling_water",
"Eau de fleur d'oranger": "orange_blossom_water",
"Eau de rose": "rose_water",
"Fumet de poisson": "fish_fumet",
// --- Aides culinaires / Épaississants -------------------------------------
"Levure boulangère": "bakers_yeast",
"Levure chimique": "baking_powder",
Maïzena: "cornstarch",
"Farine de lupin": "lupin_flour",
Gélatine: "gelatin",
"Bicarbonate de soude": "baking_soda",
"Fécule de pomme de terre": "potato_starch",
// --- Aides culinaires / Sucres ---------------------------------------------
Sucre: "sugar",
Miel: "honey",
"Sirop d'érable": "maple_syrup",
"Sucre roux": "brown_sugar",
"Sucre glace": "powdered_sugar",
Cassonade: "demerara_sugar",
"Chocolat noir": "dark_chocolate",
"Chocolat au lait": "milk_chocolate",
"Chocolat blanc": "white_chocolate",
"Pépites de chocolat": "chocolate_chips",
"Cacao en poudre": "cocoa_powder",
"Extrait de vanille": "vanilla_extract",
"Sucre de palme": "palm_sugar",
"Sirop de sucre de canne": "cane_syrup",
};
const ALL_KEYS: Record<string, string> = {
...DIET_KEYS,
...ALLERGEN_KEYS,
...INGREDIENT_KEYS,
};
/**
* Resolves a seed-time French authoring label to its English `key`
* throws if it's missing an entry above rather than silently falling back
* to a French slug, since a new diet/allergen/ingredient needs a
* deliberately-chosen English key before it can seed at all.
* `slugify` still runs over the result so a stray character/casing slip in
* the table above can't produce a key that doesn't match the
* `[a-z0-9_]`-only shape every other key has.
*/
export function getEnglishKey(frenchLabel: string): string {
const english = ALL_KEYS[frenchLabel];
if (english === undefined) {
throw new Error(
`No English key registered for "${frenchLabel}" — add one to catalog-en-keys.ts`,
);
}
return slugify(english);
}

File diff suppressed because it is too large Load diff

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

@ -1,26 +0,0 @@
/**
* Deterministic slug for a French reference-data label used as the
* stable, storage-safe `key` for `Diet`/`Category`/`Ingredient` rows (see
* `db/reference-seed-data.ts`), decoupled from the display label so the
* label itself can live in `apps/web`'s `locales/fr/translation.json`
* (`catalog.*` namespace) instead of the database. A row's `key` is derived
* from its seed-time French name once and then never changes renaming the
* *label* later (a translation fix, a rewording) never touches the key, the
* FK-referencing rows, or any code that looks a row up by key.
*
* Handles the two French ligatures NFD decomposition doesn't touch (`œ`,
* `æ` aren't accented letters, they're distinct glyphs) explicitly, then
* strips every other accent via NFD decomposition + Unicode "Mark" removal
* (`\p{M}`, every combining diacritic NFD can produce), then collapses
* whatever isn't `[a-z0-9]` into single underscores.
*/
export function slugify(label: string): string {
return label
.toLowerCase()
.replace(/œ/g, "oe")
.replace(/æ/g, "ae")
.normalize("NFD")
.replace(/\p{M}/gu, "")
.replace(/[^a-z0-9]+/g, "_")
.replace(/^_+|_+$/g, "");
}

View file

@ -3,7 +3,6 @@ import { faker } from "@faker-js/faker";
import { expect } from "chai"; import { expect } from "chai";
import request from "supertest"; import request from "supertest";
import { createApp } from "../src/app.js"; import { createApp } from "../src/app.js";
import { getEnglishKey } from "../src/db/catalog-en-keys.js";
import { prisma } from "../src/db/prisma.js"; import { prisma } from "../src/db/prisma.js";
import { resetDatabase } from "../test-support/reset-db.js"; import { resetDatabase } from "../test-support/reset-db.js";
@ -41,7 +40,7 @@ describe("Profile", () => {
const agent = request.agent(app); const agent = request.agent(app);
await agent.post("/auth/signup").send(buildSignupPayload()); await agent.post("/auth/signup").send(buildSignupPayload());
const diet = await prisma.diet.findFirstOrThrow({ const diet = await prisma.diet.findFirstOrThrow({
where: { key: getEnglishKey("Végétarien") }, where: { key: "vegetarian" },
}); });
const res = await agent.patch("/profile/diet").send({ dietId: diet.id }); const res = await agent.patch("/profile/diet").send({ dietId: diet.id });
@ -53,7 +52,7 @@ describe("Profile", () => {
it("clears the regime when dietId is null", async () => { it("clears the regime when dietId is null", async () => {
const agent = request.agent(app); const agent = request.agent(app);
await agent.post("/auth/signup").send(buildSignupPayload()); await agent.post("/auth/signup").send(buildSignupPayload());
const diet = await prisma.diet.findFirstOrThrow({ where: { key: getEnglishKey("Végan") } }); const diet = await prisma.diet.findFirstOrThrow({ where: { key: "vegan" } });
await agent.patch("/profile/diet").send({ dietId: diet.id }); await agent.patch("/profile/diet").send({ dietId: diet.id });
const res = await agent.patch("/profile/diet").send({ dietId: null }); const res = await agent.patch("/profile/diet").send({ dietId: null });
@ -86,8 +85,8 @@ describe("Profile", () => {
const agent = request.agent(app); const agent = request.agent(app);
await agent.post("/auth/signup").send(buildSignupPayload()); await agent.post("/auth/signup").send(buildSignupPayload());
const allergies = await prisma.allergy.findMany({ include: { category: true } }); const allergies = await prisma.allergy.findMany({ include: { category: true } });
const peanuts = allergies.find((a) => a.category.key === getEnglishKey("Arachides")); const peanuts = allergies.find((a) => a.category.key === "peanuts");
const gluten = allergies.find((a) => a.category.key === getEnglishKey("Gluten")); const gluten = allergies.find((a) => a.category.key === "gluten");
if (!peanuts || !gluten) throw new Error("expected seeded allergens missing"); if (!peanuts || !gluten) throw new Error("expected seeded allergens missing");
const initial = await agent.get("/profile/allergies"); const initial = await agent.get("/profile/allergies");
@ -107,8 +106,8 @@ describe("Profile", () => {
const agent = request.agent(app); const agent = request.agent(app);
await agent.post("/auth/signup").send(buildSignupPayload()); await agent.post("/auth/signup").send(buildSignupPayload());
const allergies = await prisma.allergy.findMany({ include: { category: true } }); const allergies = await prisma.allergy.findMany({ include: { category: true } });
const peanuts = allergies.find((a) => a.category.key === getEnglishKey("Arachides")); const peanuts = allergies.find((a) => a.category.key === "peanuts");
const gluten = allergies.find((a) => a.category.key === getEnglishKey("Gluten")); const gluten = allergies.find((a) => a.category.key === "gluten");
if (!peanuts || !gluten) throw new Error("expected seeded allergens missing"); if (!peanuts || !gluten) throw new Error("expected seeded allergens missing");
await agent.patch("/profile/allergies").send({ allergyIds: [peanuts.id] }); await agent.patch("/profile/allergies").send({ allergyIds: [peanuts.id] });
@ -144,10 +143,10 @@ describe("Profile", () => {
const agent = request.agent(app); const agent = request.agent(app);
await agent.post("/auth/signup").send(buildSignupPayload()); await agent.post("/auth/signup").send(buildSignupPayload());
const tomate = await prisma.ingredient.findFirstOrThrow({ const tomate = await prisma.ingredient.findFirstOrThrow({
where: { key: getEnglishKey("Tomate") }, where: { key: "tomato" },
}); });
const oignon = await prisma.ingredient.findFirstOrThrow({ const oignon = await prisma.ingredient.findFirstOrThrow({
where: { key: getEnglishKey("Oignon") }, where: { key: "onion" },
}); });
const initial = await agent.get("/profile/disliked-ingredients"); const initial = await agent.get("/profile/disliked-ingredients");
@ -167,10 +166,10 @@ describe("Profile", () => {
const agent = request.agent(app); const agent = request.agent(app);
await agent.post("/auth/signup").send(buildSignupPayload()); await agent.post("/auth/signup").send(buildSignupPayload());
const tomate = await prisma.ingredient.findFirstOrThrow({ const tomate = await prisma.ingredient.findFirstOrThrow({
where: { key: getEnglishKey("Tomate") }, where: { key: "tomato" },
}); });
const oignon = await prisma.ingredient.findFirstOrThrow({ const oignon = await prisma.ingredient.findFirstOrThrow({
where: { key: getEnglishKey("Oignon") }, where: { key: "onion" },
}); });
await agent await agent

View file

@ -4,7 +4,6 @@ import { faker } from "@faker-js/faker";
import { expect } from "chai"; import { expect } from "chai";
import request from "supertest"; import request from "supertest";
import { createApp } from "../src/app.js"; import { createApp } from "../src/app.js";
import { getEnglishKey } from "../src/db/catalog-en-keys.js";
import { prisma } from "../src/db/prisma.js"; import { prisma } from "../src/db/prisma.js";
import { resetDatabase } from "../test-support/reset-db.js"; import { resetDatabase } from "../test-support/reset-db.js";
@ -20,11 +19,9 @@ function buildSignupPayload(): SignupInput {
}; };
} }
/** Resolves a reference ingredient's id by its `reference-seed-data.ts` French name (slugified to match its `key`). */ /** Resolves a reference ingredient's id by its `reference-seed-data.ts` uid (also its DB `key`). */
async function ingredientId(name: string): Promise<number> { async function ingredientId(key: string): Promise<number> {
const ingredient = await prisma.ingredient.findFirstOrThrow({ const ingredient = await prisma.ingredient.findFirstOrThrow({ where: { key } });
where: { key: getEnglishKey(name) },
});
return ingredient.id; return ingredient.id;
} }
@ -164,10 +161,10 @@ describe("Recipes", () => {
describe("POST /recipes", () => { describe("POST /recipes", () => {
it("creates a recipe with its ingredients, ordered steps and diet tags", async () => { it("creates a recipe with its ingredients, ordered steps and diet tags", async () => {
const { agent } = await signup(); const { agent } = await signup();
const tomate = await ingredientId("Tomate"); const tomate = await ingredientId("tomato");
const oeuf = await ingredientId("Œuf"); const oeuf = await ingredientId("egg");
const vegetarien = await prisma.diet.findFirstOrThrow({ const vegetarien = await prisma.diet.findFirstOrThrow({
where: { key: getEnglishKey("Végétarien") }, where: { key: "vegetarian" },
}); });
const res = await agent.post("/recipes").send({ const res = await agent.post("/recipes").send({
@ -188,18 +185,14 @@ describe("Recipes", () => {
res.body.steps.map((s: { description: string; order: number }) => s.order), res.body.steps.map((s: { description: string; order: number }) => s.order),
).to.deep.equal([0, 1]); ).to.deep.equal([0, 1]);
// Allergens aggregated across ingredients — "Œuf" carries "Œufs". // Allergens aggregated across ingredients — "Œuf" carries "Œufs".
expect(res.body.allergens.map((a: { key: string }) => a.key)).to.include( expect(res.body.allergens.map((a: { key: string }) => a.key)).to.include("eggs");
getEnglishKey("Œufs"), expect(res.body.diets.map((d: { key: string }) => d.key)).to.deep.equal(["vegetarian"]);
);
expect(res.body.diets.map((d: { key: string }) => d.key)).to.deep.equal([
getEnglishKey("Végétarien"),
]);
}); });
it("defaults to PERSONAL visibility, and stamps the author's current household", async () => { it("defaults to PERSONAL visibility, and stamps the author's current household", async () => {
const { agent } = await signup(); const { agent } = await signup();
const houseRes = await agent.post("/house").send({ name: "Chez moi" }); const houseRes = await agent.post("/house").send({ name: "Chez moi" });
const tomate = await ingredientId("Tomate"); const tomate = await ingredientId("tomato");
const res = await agent.post("/recipes").send({ const res = await agent.post("/recipes").send({
name: "Test", name: "Test",
@ -239,7 +232,7 @@ describe("Recipes", () => {
it("rejects an unknown dietId with 404 DIET_NOT_FOUND", async () => { it("rejects an unknown dietId with 404 DIET_NOT_FOUND", async () => {
const { agent } = await signup(); const { agent } = await signup();
const tomate = await ingredientId("Tomate"); const tomate = await ingredientId("tomato");
const res = await agent.post("/recipes").send({ const res = await agent.post("/recipes").send({
name: "Test", name: "Test",
@ -276,7 +269,7 @@ describe("Recipes", () => {
it("returns the full recipe detail", async () => { it("returns the full recipe detail", async () => {
const { agent } = await signup(); const { agent } = await signup();
const tomate = await ingredientId("Tomate"); const tomate = await ingredientId("tomato");
const created = await agent.post("/recipes").send({ const created = await agent.post("/recipes").send({
name: "Salade", name: "Salade",
dietIds: [], dietIds: [],
@ -288,7 +281,7 @@ describe("Recipes", () => {
expect(res.status).to.equal(200); expect(res.status).to.equal(200);
expect(res.body.name).to.equal("Salade"); expect(res.body.name).to.equal("Salade");
expect(res.body.ingredients[0].ingredient.key).to.equal(getEnglishKey("Tomate")); expect(res.body.ingredients[0].ingredient.key).to.equal("tomato");
expect(res.body.isFavorite).to.equal(false); expect(res.body.isFavorite).to.equal(false);
}); });
@ -352,8 +345,8 @@ describe("Recipes", () => {
describe("PATCH /recipes/:id", () => { describe("PATCH /recipes/:id", () => {
it("replaces the recipe's whole content", async () => { it("replaces the recipe's whole content", async () => {
const { agent } = await signup(); const { agent } = await signup();
const tomate = await ingredientId("Tomate"); const tomate = await ingredientId("tomato");
const oignon = await ingredientId("Oignon"); const oignon = await ingredientId("onion");
const created = await agent.post("/recipes").send({ const created = await agent.post("/recipes").send({
name: "Salade", name: "Salade",
dietIds: [], dietIds: [],
@ -373,13 +366,13 @@ describe("Recipes", () => {
expect(res.body.name).to.equal("Salade composée"); expect(res.body.name).to.equal("Salade composée");
expect(res.body.visibility).to.equal("PUBLIC"); expect(res.body.visibility).to.equal("PUBLIC");
expect(res.body.ingredients).to.have.length(1); expect(res.body.ingredients).to.have.length(1);
expect(res.body.ingredients[0].ingredient.key).to.equal(getEnglishKey("Oignon")); expect(res.body.ingredients[0].ingredient.key).to.equal("onion");
expect(res.body.steps).to.have.length(2); expect(res.body.steps).to.have.length(2);
}); });
it("returns 404 RECIPE_NOT_FOUND for an unknown id", async () => { it("returns 404 RECIPE_NOT_FOUND for an unknown id", async () => {
const { agent } = await signup(); const { agent } = await signup();
const tomate = await ingredientId("Tomate"); const tomate = await ingredientId("tomato");
const res = await agent.patch("/recipes/999999").send({ const res = await agent.patch("/recipes/999999").send({
name: "Test", name: "Test",
@ -394,7 +387,7 @@ describe("Recipes", () => {
it("rejects an unknown dietId with 404 DIET_NOT_FOUND", async () => { it("rejects an unknown dietId with 404 DIET_NOT_FOUND", async () => {
const { agent } = await signup(); const { agent } = await signup();
const tomate = await ingredientId("Tomate"); const tomate = await ingredientId("tomato");
const created = await agent.post("/recipes").send({ const created = await agent.post("/recipes").send({
name: "Salade", name: "Salade",
dietIds: [], dietIds: [],
@ -416,7 +409,7 @@ describe("Recipes", () => {
it("rejects an edit from anyone other than the recipe's author with 403 NOT_RECIPE_AUTHOR", async () => { it("rejects an edit from anyone other than the recipe's author with 403 NOT_RECIPE_AUTHOR", async () => {
const { agent, profileId } = await signup(); const { agent, profileId } = await signup();
const { agent: otherAgent } = await signup(); const { agent: otherAgent } = await signup();
const tomate = await ingredientId("Tomate"); const tomate = await ingredientId("tomato");
const recipe = await prisma.recipe.create({ const recipe = await prisma.recipe.create({
data: { name: "Publique", authorId: profileId, visibility: "PUBLIC" }, data: { name: "Publique", authorId: profileId, visibility: "PUBLIC" },
}); });

View file

@ -1,7 +1,6 @@
import { expect } from "chai"; import { expect } from "chai";
import request from "supertest"; import request from "supertest";
import { createApp } from "../src/app.js"; import { createApp } from "../src/app.js";
import { getEnglishKey } from "../src/db/catalog-en-keys.js";
import { prisma } from "../src/db/prisma.js"; import { prisma } from "../src/db/prisma.js";
import { resetDatabase } from "../test-support/reset-db.js"; import { resetDatabase } from "../test-support/reset-db.js";
@ -22,7 +21,7 @@ describe("Reference data", () => {
expect(res.status).to.equal(200); expect(res.status).to.equal(200);
expect(res.body).to.have.length(5); expect(res.body).to.have.length(5);
expect(res.body.map((d: { key: string }) => d.key)).to.include(getEnglishKey("Végétarien")); expect(res.body.map((d: { key: string }) => d.key)).to.include("vegetarian");
expect(res.body[0]).to.have.keys(["id", "key"]); expect(res.body[0]).to.have.keys(["id", "key"]);
}); });
}); });
@ -33,18 +32,17 @@ describe("Reference data", () => {
expect(res.status).to.equal(200); expect(res.status).to.equal(200);
expect(res.body).to.have.length(14); expect(res.body).to.have.length(14);
expect(res.body.map((a: { key: string }) => a.key)).to.include(getEnglishKey("Arachides")); expect(res.body.map((a: { key: string }) => a.key)).to.include("peanuts");
expect(res.body[0]).to.have.keys(["id", "key", "kind"]); expect(res.body[0]).to.have.keys(["id", "key", "kind"]);
}); });
it("classifies Gluten and Sulfites as intolerances, the rest as allergies", async () => { it("classifies Gluten and Sulfites as intolerances, the rest as allergies", async () => {
const res = await request(app).get("/reference/allergies"); const res = await request(app).get("/reference/allergies");
const byKey = (name: string) => const byKey = (key: string) => res.body.find((a: { key: string }) => a.key === key);
res.body.find((a: { key: string }) => a.key === getEnglishKey(name)); expect(byKey("gluten").kind).to.equal("INTOLERANCE");
expect(byKey("Gluten").kind).to.equal("INTOLERANCE"); expect(byKey("sulfites").kind).to.equal("INTOLERANCE");
expect(byKey("Sulfites").kind).to.equal("INTOLERANCE"); expect(byKey("peanuts").kind).to.equal("ALLERGY");
expect(byKey("Arachides").kind).to.equal("ALLERGY");
expect(res.body.filter((a: { kind: string }) => a.kind === "INTOLERANCE")).to.have.length(2); expect(res.body.filter((a: { kind: string }) => a.kind === "INTOLERANCE")).to.have.length(2);
}); });
}); });
@ -55,13 +53,14 @@ describe("Reference data", () => {
expect(res.status).to.equal(200); expect(res.status).to.equal(200);
expect(res.body.length).to.be.greaterThan(0); expect(res.body.length).to.be.greaterThan(0);
expect(res.body.map((i: { key: string }) => i.key)).to.include(getEnglishKey("Tomate")); expect(res.body.map((i: { key: string }) => i.key)).to.include("tomato");
expect(res.body[0]).to.have.keys([ expect(res.body[0]).to.have.keys([
"id", "id",
"key", "key",
"icon", "icon",
"category", "category",
"subcategory", "subcategory",
"reproducible",
"allergens", "allergens",
"diets", "diets",
]); ]);
@ -70,12 +69,9 @@ describe("Reference data", () => {
it("resolves each ingredient's linked allergens, empty for one with none", async () => { it("resolves each ingredient's linked allergens, empty for one with none", async () => {
const res = await request(app).get("/reference/ingredients"); const res = await request(app).get("/reference/ingredients");
const byKey = (name: string) => const byKey = (key: string) => res.body.find((i: { key: string }) => i.key === key);
res.body.find((i: { key: string }) => i.key === getEnglishKey(name)); expect(byKey("egg").allergens.map((a: { key: string }) => a.key)).to.include("eggs");
expect(byKey("Œuf").allergens.map((a: { key: string }) => a.key)).to.include( expect(byKey("tomato").allergens).to.deep.equal([]);
getEnglishKey("Œufs"),
);
expect(byKey("Tomate").allergens).to.deep.equal([]);
}); });
}); });
}); });

View file

@ -4,8 +4,8 @@ const tomato = {
id: 1, id: 1,
key: "tomato", key: "tomato",
icon: "VEGETABLE", icon: "VEGETABLE",
category: "PRODUITS_FRAIS", category: "freshProduce",
subcategory: "LEGUMES", subcategory: "vegetables",
allergens: [], allergens: [],
diets: [{ id: 2, key: "vegetarian" }], diets: [{ id: 2, key: "vegetarian" }],
}; };
@ -13,8 +13,8 @@ const egg = {
id: 2, id: 2,
key: "egg", key: "egg",
icon: "EGG", icon: "EGG",
category: "CREMERIE_FROMAGE", category: "dairyAndCheese",
subcategory: "OEUFS", subcategory: "eggs",
allergens: [{ id: 1, key: "eggs", kind: "ALLERGY" }], allergens: [{ id: 1, key: "eggs", kind: "ALLERGY" }],
diets: [], diets: [],
}; };
@ -22,8 +22,8 @@ const carrot = {
id: 3, id: 3,
key: "carrot", key: "carrot",
icon: "VEGETABLE", icon: "VEGETABLE",
category: "PRODUITS_FRAIS", category: "freshProduce",
subcategory: "LEGUMES", subcategory: "vegetables",
allergens: [], allergens: [],
diets: [{ id: 2, key: "vegetarian" }], diets: [{ id: 2, key: "vegetarian" }],
}; };

View file

@ -52,8 +52,8 @@ const omeletteDetail = {
id: 10, id: 10,
key: "egg", key: "egg",
icon: "EGG", icon: "EGG",
category: "CREMERIE_FROMAGE", category: "dairyAndCheese",
subcategory: "OEUFS", subcategory: "eggs",
allergens: [oeufs], allergens: [oeufs],
diets: [], diets: [],
}, },

View file

@ -23,8 +23,8 @@ const omeletteDetail = {
id: 10, id: 10,
key: "egg", key: "egg",
icon: "EGG", icon: "EGG",
category: "CREMERIE_FROMAGE", category: "dairyAndCheese",
subcategory: "OEUFS", subcategory: "eggs",
allergens: [oeufs], allergens: [oeufs],
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

@ -48,7 +48,7 @@ function FilledIcon({ children }: { children: ReactNode }) {
); );
} }
/** Carrot (foodiconpack.com, CC BY 4.0 — see the credits page) — root vegetables, leafy greens, and produce generally (`PRODUITS_FRAIS`/`LEGUMES`). */ /** Carrot (foodiconpack.com, CC BY 4.0 — see the credits page) — root vegetables, leafy greens, and produce generally (`freshProduce`/`vegetables`). */
export function VegetableIcon() { export function VegetableIcon() {
return ( return (
<FilledIcon> <FilledIcon>
@ -57,7 +57,7 @@ export function VegetableIcon() {
); );
} }
/** Apple (foodiconpack.com, CC BY 4.0) — `PRODUITS_FRAIS`/`FRUITS`. */ /** Apple (foodiconpack.com, CC BY 4.0) — `freshProduce`/`fruits`. */
export function FruitIcon() { export function FruitIcon() {
return ( return (
<FilledIcon> <FilledIcon>
@ -69,7 +69,7 @@ export function FruitIcon() {
); );
} }
/** Basil sprig (foodiconpack.com, CC BY 4.0) — `PRODUITS_FRAIS`/`HERBES_FRAICHES`. */ /** Basil sprig (foodiconpack.com, CC BY 4.0) — `freshProduce`/`freshHerbs`. */
export function HerbIcon() { export function HerbIcon() {
return ( return (
<FilledIcon> <FilledIcon>
@ -78,7 +78,7 @@ export function HerbIcon() {
); );
} }
/** Cut of beef (foodiconpack.com, CC BY 4.0) — `BOUCHERIE_POISSONNERIE`/`VIANDES`. */ /** Cut of beef (foodiconpack.com, CC BY 4.0) — `meatAndSeafood`/`meats`. */
export function MeatIcon() { export function MeatIcon() {
return ( return (
<FilledIcon> <FilledIcon>
@ -90,7 +90,7 @@ export function MeatIcon() {
); );
} }
/** Chicken (foodiconpack.com, CC BY 4.0) — `BOUCHERIE_POISSONNERIE`/`VOLAILLES`. */ /** Chicken (foodiconpack.com, CC BY 4.0) — `meatAndSeafood`/`poultry`. */
export function PoultryIcon() { export function PoultryIcon() {
return ( return (
<FilledIcon> <FilledIcon>
@ -101,7 +101,7 @@ export function PoultryIcon() {
); );
} }
/** Salmon (foodiconpack.com, CC BY 4.0) — `BOUCHERIE_POISSONNERIE`/`POISSONS`. */ /** Salmon (foodiconpack.com, CC BY 4.0) — `meatAndSeafood`/`fish`. */
export function FishIcon() { export function FishIcon() {
return ( return (
<FilledIcon> <FilledIcon>
@ -113,7 +113,7 @@ export function FishIcon() {
); );
} }
/** Shrimp (foodiconpack.com, CC BY 4.0) — `BOUCHERIE_POISSONNERIE`/`CRUSTACES_FRUITS_DE_MER`. */ /** Shrimp (foodiconpack.com, CC BY 4.0) — `meatAndSeafood`/`shellfish`. */
export function ShellfishIcon() { export function ShellfishIcon() {
return ( return (
<FilledIcon> <FilledIcon>
@ -123,7 +123,7 @@ export function ShellfishIcon() {
); );
} }
/** Bowl of rice (foodiconpack.com, CC BY 4.0) — grains, pasta, rice, flour (`EPICERIE_SECHE`/`FECULENTS`, and flours under `AIDES_CULINAIRES`/`BASES`). */ /** Bowl of rice (foodiconpack.com, CC BY 4.0) — grains, pasta, rice, flour (`dryGoods`/`starches`, and flours under `cookingEssentials`/`bases`). */
export function GrainIcon() { export function GrainIcon() {
return ( return (
<FilledIcon> <FilledIcon>
@ -142,7 +142,7 @@ export function GrainIcon() {
); );
} }
/** Chickpeas (foodiconpack.com, CC BY 4.0) — `EPICERIE_SECHE`/`LEGUMINEUSES`. */ /** Chickpeas (foodiconpack.com, CC BY 4.0) — `dryGoods`/`legumes`. */
export function LegumeIcon() { export function LegumeIcon() {
return ( return (
<FilledIcon> <FilledIcon>
@ -158,7 +158,7 @@ export function LegumeIcon() {
); );
} }
/** Almonds (foodiconpack.com, CC BY 4.0) — nuts, seeds, dried fruit (`EPICERIE_SECHE`/`GRAINES_FRUITS_SECS`). */ /** Almonds (foodiconpack.com, CC BY 4.0) — nuts, seeds, dried fruit (`dryGoods`/`nutsAndSeeds`). */
export function NutSeedIcon() { export function NutSeedIcon() {
return ( return (
<FilledIcon> <FilledIcon>
@ -174,7 +174,7 @@ export function NutSeedIcon() {
); );
} }
/** A loaf, scored on top — `BOULANGERIE`/`PAINS`. */ /** A loaf, scored on top — `bakery`/`breads`. */
export function BreadIcon() { export function BreadIcon() {
return ( return (
<Icon> <Icon>
@ -184,7 +184,7 @@ export function BreadIcon() {
); );
} }
/** Rolling pin — raw, uncooked pastry (`BOULANGERIE`/`PATES_A_CUIRE`). */ /** Rolling pin — raw, uncooked pastry (`bakery`/`rawDough`). */
export function DoughIcon() { export function DoughIcon() {
return ( return (
<Icon> <Icon>
@ -195,7 +195,7 @@ export function DoughIcon() {
); );
} }
/** Milk carton (foodiconpack.com, CC BY 4.0) — `CREMERIE_FROMAGE`/`PRODUITS_LAITIERS` (non-cheese items). */ /** Milk carton (foodiconpack.com, CC BY 4.0) — `dairyAndCheese`/`dairy` (non-cheese items). */
export function MilkIcon() { export function MilkIcon() {
return ( return (
<FilledIcon> <FilledIcon>
@ -206,7 +206,7 @@ export function MilkIcon() {
); );
} }
/** Wedge of cheddar (foodiconpack.com, CC BY 4.0) — `CREMERIE_FROMAGE`/`PRODUITS_LAITIERS` (cheese items). */ /** Wedge of cheddar (foodiconpack.com, CC BY 4.0) — `dairyAndCheese`/`dairy` (cheese items). */
export function CheeseIcon() { export function CheeseIcon() {
return ( return (
<FilledIcon> <FilledIcon>
@ -225,7 +225,7 @@ export function CheeseIcon() {
); );
} }
/** Eggs (foodiconpack.com, CC BY 4.0) — `CREMERIE_FROMAGE`/`OEUFS`. */ /** Eggs (foodiconpack.com, CC BY 4.0) — `dairyAndCheese`/`eggs`. */
export function EggIcon() { export function EggIcon() {
return ( return (
<FilledIcon> <FilledIcon>
@ -238,7 +238,7 @@ export function EggIcon() {
); );
} }
/** A seedling — plant-based dairy/meat alternatives (`CREMERIE_FROMAGE`/`ALTERNATIVES`). */ /** A seedling — plant-based dairy/meat alternatives (`dairyAndCheese`/`plantBasedAlternatives`). */
export function SproutIcon() { export function SproutIcon() {
return ( return (
<Icon> <Icon>
@ -249,7 +249,7 @@ export function SproutIcon() {
); );
} }
/** Cinnamon sticks (foodiconpack.com, CC BY 4.0) — dried spices/herbs (`CONDIMENTS_EPICES`/`EPICES`). */ /** Cinnamon sticks (foodiconpack.com, CC BY 4.0) — dried spices/herbs (`condimentsAndSpices`/`spices`). */
export function SpiceIcon() { export function SpiceIcon() {
return ( return (
<FilledIcon> <FilledIcon>
@ -262,7 +262,7 @@ export function SpiceIcon() {
); );
} }
/** Honey jar (foodiconpack.com, CC BY 4.0) — sauces, pickles, tinned/preserved goods (`CONDIMENTS_EPICES`/`SAUCES` and the `EPICERIE_SECHE`/`AUTRES` catch-all). */ /** Honey jar (foodiconpack.com, CC BY 4.0) — sauces, pickles, tinned/preserved goods (`condimentsAndSpices`/`sauces` and the `dryGoods`/`other` catch-all). */
export function JarIcon() { export function JarIcon() {
return ( return (
<FilledIcon> <FilledIcon>
@ -272,7 +272,7 @@ export function JarIcon() {
); );
} }
/** Oil bottle (foodiconpack.com, CC BY 4.0) — oils, vinegars (`CONDIMENTS_EPICES`/`ASSAISONNEMENTS`, the pourable subset). */ /** Oil bottle (foodiconpack.com, CC BY 4.0) — oils, vinegars (`condimentsAndSpices`/`seasonings`, the pourable subset). */
export function BottleIcon() { export function BottleIcon() {
return ( return (
<FilledIcon> <FilledIcon>
@ -283,7 +283,7 @@ export function BottleIcon() {
); );
} }
/** Glass (foodiconpack.com, CC BY 4.0) — juices, coffee/tea, cooking alcohols, water (`CONDIMENTS_EPICES`/`ASSAISONNEMENTS`'s drinkable subset). */ /** Glass (foodiconpack.com, CC BY 4.0) — juices, coffee/tea, cooking alcohols, water (`condimentsAndSpices`/`seasonings`'s drinkable subset). */
export function DrinkIcon() { export function DrinkIcon() {
return ( return (
<FilledIcon> <FilledIcon>
@ -295,7 +295,7 @@ export function DrinkIcon() {
); );
} }
/** Stockpot (foodiconpack.com, CC BY 4.0) — broths, stocks, water bases (`AIDES_CULINAIRES`/`BASES`'s liquid-base subset). */ /** Stockpot (foodiconpack.com, CC BY 4.0) — broths, stocks, water bases (`cookingEssentials`/`bases`'s liquid-base subset). */
export function StockPotIcon() { export function StockPotIcon() {
return ( return (
<FilledIcon> <FilledIcon>
@ -304,7 +304,7 @@ export function StockPotIcon() {
); );
} }
/** Sugar (foodiconpack.com, CC BY 4.0) — `AIDES_CULINAIRES`/`SUCRES`. */ /** Sugar (foodiconpack.com, CC BY 4.0) — `cookingEssentials`/`sugars`. */
export function SugarIcon() { export function SugarIcon() {
return ( return (
<FilledIcon> <FilledIcon>
@ -355,13 +355,13 @@ export function IngredientTypeIcon({ icon }: { icon: IngredientIconType }) {
* derived fact. * derived fact.
*/ */
export const CATEGORY_ICON: Record<IngredientCategory, IngredientIconType> = { export const CATEGORY_ICON: Record<IngredientCategory, IngredientIconType> = {
PRODUITS_FRAIS: "VEGETABLE", freshProduce: "VEGETABLE",
BOUCHERIE_POISSONNERIE: "MEAT", meatAndSeafood: "MEAT",
EPICERIE_SECHE: "GRAIN", dryGoods: "GRAIN",
BOULANGERIE: "BREAD", bakery: "BREAD",
CREMERIE_FROMAGE: "CHEESE", dairyAndCheese: "CHEESE",
CONDIMENTS_EPICES: "SPICE", condimentsAndSpices: "SPICE",
AIDES_CULINAIRES: "STOCK_POT", cookingEssentials: "STOCK_POT",
}; };
/** Renders {@link CATEGORY_ICON}'s pictogram for one category — used by the category chip row. */ /** Renders {@link CATEGORY_ICON}'s pictogram for one category — used by the category chip row. */
@ -373,33 +373,33 @@ export function CategoryIcon({ category }: { category: IngredientCategory }) {
* One representative {@link IngredientIconType} per {@link IngredientSubcategory} * One representative {@link IngredientIconType} per {@link IngredientSubcategory}
* rack, for `IngredientPicker`'s second-tier subcategory chips. Most map * rack, for `IngredientPicker`'s second-tier subcategory chips. Most map
* 1:1 onto their subcategory's dominant shape; a couple of heterogeneous * 1:1 onto their subcategory's dominant shape; a couple of heterogeneous
* subcategories (`ASSAISONNEMENTS` mixes oils with juices and coffee, * subcategories (`seasonings` mixes oils with juices and coffee,
* `BASES` mixes flour with stock and canned tomato) get one illustrative * `bases` mixes flour with stock and canned tomato) get one illustrative
* pick rather than a derived fact, same reasoning as {@link CATEGORY_ICON}. * pick rather than a derived fact, same reasoning as {@link CATEGORY_ICON}.
*/ */
export const SUBCATEGORY_ICON: Record<IngredientSubcategory, IngredientIconType> = { export const SUBCATEGORY_ICON: Record<IngredientSubcategory, IngredientIconType> = {
LEGUMES: "VEGETABLE", vegetables: "VEGETABLE",
FRUITS: "FRUIT", fruits: "FRUIT",
HERBES_FRAICHES: "HERB", freshHerbs: "HERB",
VIANDES: "MEAT", meats: "MEAT",
VOLAILLES: "POULTRY", poultry: "POULTRY",
POISSONS: "FISH", fish: "FISH",
CRUSTACES_FRUITS_DE_MER: "SHELLFISH", shellfish: "SHELLFISH",
FECULENTS: "GRAIN", starches: "GRAIN",
LEGUMINEUSES: "LEGUME", legumes: "LEGUME",
GRAINES_FRUITS_SECS: "NUT_SEED", nutsAndSeeds: "NUT_SEED",
AUTRES: "JAR", other: "JAR",
PAINS: "BREAD", breads: "BREAD",
PATES_A_CUIRE: "DOUGH", rawDough: "DOUGH",
PRODUITS_LAITIERS: "MILK", dairy: "MILK",
OEUFS: "EGG", eggs: "EGG",
ALTERNATIVES: "SPROUT", plantBasedAlternatives: "SPROUT",
EPICES: "SPICE", spices: "SPICE",
SAUCES: "JAR", sauces: "JAR",
ASSAISONNEMENTS: "BOTTLE", seasonings: "BOTTLE",
BASES: "STOCK_POT", bases: "STOCK_POT",
EPAISSISSANTS: "JAR", thickeners: "JAR",
SUCRES: "SUGAR", sugars: "SUGAR",
}; };
/** Renders {@link SUBCATEGORY_ICON}'s pictogram for one subcategory — used by the subcategory chip row. */ /** Renders {@link SUBCATEGORY_ICON}'s pictogram for one subcategory — used by the subcategory chip row. */

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,41 +185,44 @@
"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é.",
"category": { "category": {
"PRODUITS_FRAIS": "Produits frais", "freshProduce": "Produits frais",
"BOUCHERIE_POISSONNERIE": "Boucherie & poissonnerie", "meatAndSeafood": "Boucherie & poissonnerie",
"EPICERIE_SECHE": "Épicerie sèche", "dryGoods": "Épicerie sèche",
"BOULANGERIE": "Boulangerie", "bakery": "Boulangerie",
"CREMERIE_FROMAGE": "Crémerie & fromage", "dairyAndCheese": "Crémerie & fromage",
"CONDIMENTS_EPICES": "Condiments & épices", "condimentsAndSpices": "Condiments & épices",
"AIDES_CULINAIRES": "Aides culinaires" "cookingEssentials": "Aides culinaires"
}, },
"subcategory": { "subcategory": {
"LEGUMES": "Légumes", "vegetables": "Légumes",
"FRUITS": "Fruits", "fruits": "Fruits",
"HERBES_FRAICHES": "Herbes fraîches", "freshHerbs": "Herbes fraîches",
"VIANDES": "Viandes", "meats": "Viandes",
"VOLAILLES": "Volailles", "poultry": "Volailles",
"POISSONS": "Poissons", "fish": "Poissons",
"CRUSTACES_FRUITS_DE_MER": "Crustacés & fruits de mer", "shellfish": "Crustacés & fruits de mer",
"FECULENTS": "Féculents", "starches": "Féculents",
"LEGUMINEUSES": "Légumineuses", "legumes": "Légumineuses",
"GRAINES_FRUITS_SECS": "Graines & fruits secs", "nutsAndSeeds": "Graines & fruits secs",
"AUTRES": "Autres", "other": "Autres",
"PAINS": "Pains", "breads": "Pains",
"PATES_A_CUIRE": "Pâtes à cuire", "rawDough": "Pâtes à cuire",
"PRODUITS_LAITIERS": "Produits laitiers", "dairy": "Produits laitiers",
"OEUFS": "Œufs", "eggs": "Œufs",
"ALTERNATIVES": "Alternatives végétales", "plantBasedAlternatives": "Alternatives végétales",
"EPICES": "Épices", "spices": "Épices",
"SAUCES": "Sauces", "sauces": "Sauces",
"ASSAISONNEMENTS": "Assaisonnements", "seasonings": "Assaisonnements",
"BASES": "Bases", "bases": "Bases",
"EPAISSISSANTS": "Épaississants", "thickeners": "Épaississants",
"SUCRES": "Sucres" "sugars": "Sucres"
}, },
"quantityLabel": "Quantité", "quantityLabel": "Quantité",
"unitLabel": "Unité", "unitLabel": "Unité",
@ -319,7 +322,7 @@
"vegetarian": "Végétarien", "vegetarian": "Végétarien",
"vegan": "Végan", "vegan": "Végan",
"pescatarian": "Pescétarien", "pescatarian": "Pescétarien",
"gluten_free": "Sans gluten" "glutenFree": "Sans gluten"
}, },
"allergens": { "allergens": {
"gluten": "Gluten", "gluten": "Gluten",
@ -329,10 +332,10 @@
"peanuts": "Arachides", "peanuts": "Arachides",
"soy": "Soja", "soy": "Soja",
"milk": "Lait", "milk": "Lait",
"tree_nuts": "Fruits à coque", "treeNuts": "Fruits à coque",
"celery": "Céleri", "celery": "Céleri",
"mustard": "Moutarde", "mustard": "Moutarde",
"sesame_seeds": "Graines de sésame", "sesameSeeds": "Graines de sésame",
"sulfites": "Sulfites", "sulfites": "Sulfites",
"lupin": "Lupin", "lupin": "Lupin",
"molluscs": "Mollusques" "molluscs": "Mollusques"
@ -346,17 +349,17 @@
"zucchini": "Courgette", "zucchini": "Courgette",
"cucumber": "Concombre", "cucumber": "Concombre",
"gherkins": "Cornichons", "gherkins": "Cornichons",
"bell_pepper": "Poivron", "bellPepper": "Poivron",
"mushroom": "Champignon", "mushroom": "Champignon",
"porcini": "Cèpes", "porcini": "Cèpes",
"eggplant": "Aubergine", "eggplant": "Aubergine",
"broccoli": "Brocoli", "broccoli": "Brocoli",
"cauliflower": "Chou-fleur", "cauliflower": "Chou-fleur",
"white_cabbage": "Chou blanc", "whiteCabbage": "Chou blanc",
"red_cabbage": "Chou rouge", "redCabbage": "Chou rouge",
"brussels_sprouts": "Chou de Bruxelles", "brusselsSprouts": "Chou de Bruxelles",
"spinach": "Épinard", "spinach": "Épinard",
"swiss_chard": "Blette", "swissChard": "Blette",
"lettuce": "Salade", "lettuce": "Salade",
"arugula": "Roquette", "arugula": "Roquette",
"watercress": "Cresson", "watercress": "Cresson",
@ -366,37 +369,37 @@
"beetroot": "Betterave", "beetroot": "Betterave",
"turnip": "Navet", "turnip": "Navet",
"parsnip": "Panais", "parsnip": "Panais",
"green_bean": "Haricot vert", "greenBean": "Haricot vert",
"pea": "Petit pois", "pea": "Petit pois",
"corn": "Maïs", "corn": "Maïs",
"artichoke": "Artichaut", "artichoke": "Artichaut",
"fennel": "Fenouil", "fennel": "Fenouil",
"endive": "Endive", "endive": "Endive",
"pumpkin": "Potiron", "pumpkin": "Potiron",
"butternut_squash": "Butternut", "butternutSquash": "Butternut",
"asparagus": "Asperge", "asparagus": "Asperge",
"avocado": "Avocat", "avocado": "Avocat",
"potato": "Pomme de terre", "potato": "Pomme de terre",
"sweet_potato": "Patate douce", "sweetPotato": "Patate douce",
"cherry_tomato": "Tomates cerises", "cherryTomato": "Tomates cerises",
"bok_choy": "Pak-choï", "bokChoy": "Pak-choï",
"soybean_sprouts": "Germes de soja", "soybeanSprouts": "Germes de soja",
"shiitake": "Shiitake", "shiitake": "Shiitake",
"daikon": "Daikon", "daikon": "Daikon",
"fresh_green_chili": "Piment vert frais", "freshGreenChili": "Piment vert frais",
"cardoon": "Cardon", "cardoon": "Cardon",
"radicchio": "Chicorée rouge", "radicchio": "Chicorée rouge",
"romanesco": "Chou romanesco", "romanesco": "Chou romanesco",
"kohlrabi": "Chou-rave", "kohlrabi": "Chou-rave",
"napa_cabbage": "Chou chinois", "napaCabbage": "Chou chinois",
"celeriac": "Céleri-rave", "celeriac": "Céleri-rave",
"okra": "Gombo", "okra": "Gombo",
"spring_onion": "Oignon nouveau", "springOnion": "Oignon nouveau",
"red_kuri_squash": "Potimarron", "redKuriSquash": "Potimarron",
"rutabaga": "Rutabaga", "rutabaga": "Rutabaga",
"samphire": "Salicorne", "samphire": "Salicorne",
"salsify": "Salsifis", "salsify": "Salsifis",
"lambs_lettuce": "Mâche", "lambsLettuce": "Mâche",
"escarole": "Scarole", "escarole": "Scarole",
"lemon": "Citron", "lemon": "Citron",
"lime": "Citron vert", "lime": "Citron vert",
@ -436,9 +439,9 @@
"parsley": "Persil", "parsley": "Persil",
"thyme": "Thym", "thyme": "Thym",
"rosemary": "Romarin", "rosemary": "Romarin",
"bay_leaf": "Laurier", "bayLeaf": "Laurier",
"chives": "Ciboulette", "chives": "Ciboulette",
"fresh_cilantro": "Coriandre fraîche", "freshCilantro": "Coriandre fraîche",
"mint": "Menthe", "mint": "Menthe",
"oregano": "Origan", "oregano": "Origan",
"dill": "Aneth", "dill": "Aneth",
@ -449,20 +452,20 @@
"chervil": "Cerfeuil", "chervil": "Cerfeuil",
"ginger": "Gingembre", "ginger": "Gingembre",
"lemongrass": "Citronnelle", "lemongrass": "Citronnelle",
"kaffir_lime": "Combava", "kaffirLime": "Combava",
"rabbit": "Lapin", "rabbit": "Lapin",
"ground_beef": "Bœuf haché", "groundBeef": "Bœuf haché",
"beef_steak": "Steak de bœuf", "beefSteak": "Steak de bœuf",
"beef_roast": "Rôti de bœuf", "beefRoast": "Rôti de bœuf",
"veal_cutlet": "Escalope de veau", "vealCutlet": "Escalope de veau",
"pork_tenderloin": "Filet mignon de porc", "porkTenderloin": "Filet mignon de porc",
"pork_chop": "Côte de porc", "porkChop": "Côte de porc",
"lamb": "Agneau", "lamb": "Agneau",
"leg_of_lamb": "Gigot d'agneau", "legOfLamb": "Gigot d'agneau",
"bacon_lardons": "Lardons", "baconLardons": "Lardons",
"bacon": "Bacon", "bacon": "Bacon",
"ham": "Jambon blanc", "ham": "Jambon blanc",
"cured_ham": "Jambon cru", "curedHam": "Jambon cru",
"sausage": "Saucisse", "sausage": "Saucisse",
"chorizo": "Chorizo", "chorizo": "Chorizo",
"merguez": "Merguez", "merguez": "Merguez",
@ -472,36 +475,36 @@
"salami": "Salami", "salami": "Salami",
"andouille": "Andouille", "andouille": "Andouille",
"andouillette": "Andouillette", "andouillette": "Andouillette",
"white_pudding": "Boudin blanc", "whitePudding": "Boudin blanc",
"black_pudding": "Boudin noir", "blackPudding": "Boudin noir",
"cervelat": "Cervelas", "cervelat": "Cervelas",
"rillettes": "Rillettes", "rillettes": "Rillettes",
"dry_cured_sausage": "Saucisson sec", "dryCuredSausage": "Saucisson sec",
"bayonne_ham": "Jambon de Bayonne", "bayonneHam": "Jambon de Bayonne",
"coppa": "Coppa", "coppa": "Coppa",
"rosette_sausage": "Rosette (saucisson)", "rosetteSausage": "Rosette (saucisson)",
"veal_liver": "Foie de veau", "vealLiver": "Foie de veau",
"veal_kidneys": "Rognons de veau", "vealKidneys": "Rognons de veau",
"veal_brain": "Cervelle de veau", "vealBrain": "Cervelle de veau",
"veal_sweetbread": "Ris de veau", "vealSweetbread": "Ris de veau",
"beef_tongue": "Langue de bœuf", "beefTongue": "Langue de bœuf",
"tripe": "Tripes", "tripe": "Tripes",
"venison": "Cerf", "venison": "Cerf",
"roe_deer": "Chevreuil", "roeDeer": "Chevreuil",
"wild_boar": "Sanglier", "wildBoar": "Sanglier",
"horse_meat": "Cheval", "horseMeat": "Cheval",
"beef_heart": "Cœur de bœuf", "beefHeart": "Cœur de bœuf",
"foie_gras": "Foie gras", "foieGras": "Foie gras",
"beef_muzzle": "Museau de bœuf", "beefMuzzle": "Museau de bœuf",
"grisons_dried_beef": "Viande des Grisons", "grisonsDriedBeef": "Viande des Grisons",
"chicken": "Poulet", "chicken": "Poulet",
"turkey": "Dinde", "turkey": "Dinde",
"duck": "Canard", "duck": "Canard",
"duck_breast": "Magret de canard", "duckBreast": "Magret de canard",
"quail": "Caille", "quail": "Caille",
"guinea_fowl": "Pintade", "guineaFowl": "Pintade",
"goose": "Oie", "goose": "Oie",
"poultry_liver": "Foie de volaille", "poultryLiver": "Foie de volaille",
"capon": "Chapon", "capon": "Chapon",
"pigeon": "Pigeon", "pigeon": "Pigeon",
"pheasant": "Faisan", "pheasant": "Faisan",
@ -513,8 +516,8 @@
"anchovy": "Anchois", "anchovy": "Anchois",
"whiting": "Merlan", "whiting": "Merlan",
"surimi": "Surimi", "surimi": "Surimi",
"sea_bass": "Bar (loup de mer)", "seaBass": "Bar (loup de mer)",
"sea_bream": "Dorade", "seaBream": "Dorade",
"sole": "Sole", "sole": "Sole",
"turbot": "Turbot", "turbot": "Turbot",
"hake": "Merlu", "hake": "Merlu",
@ -523,7 +526,7 @@
"haddock": "Églefin", "haddock": "Églefin",
"mackerel": "Maquereau", "mackerel": "Maquereau",
"herring": "Hareng", "herring": "Hareng",
"red_mullet": "Rouget", "redMullet": "Rouget",
"skate": "Raie", "skate": "Raie",
"monkfish": "Lotte", "monkfish": "Lotte",
"halibut": "Flétan", "halibut": "Flétan",
@ -533,18 +536,18 @@
"perch": "Perche", "perch": "Perche",
"tilapia": "Tilapia", "tilapia": "Tilapia",
"pangasius": "Panga", "pangasius": "Panga",
"smoked_salmon": "Saumon fumé", "smokedSalmon": "Saumon fumé",
"dried_fish": "Poisson séché", "driedFish": "Poisson séché",
"eel": "Anguille", "eel": "Anguille",
"plaice": "Carrelet (ou plie)", "plaice": "Carrelet (ou plie)",
"salt_cod": "Morue", "saltCod": "Morue",
"lemon_sole": "Limande", "lemonSole": "Limande",
"scorpionfish": "Rascasse", "scorpionfish": "Rascasse",
"shrimp": "Crevettes", "shrimp": "Crevettes",
"langoustine": "Langoustines", "langoustine": "Langoustines",
"lobster": "Homard", "lobster": "Homard",
"crab": "Crabe", "crab": "Crabe",
"spiny_lobster": "Langouste", "spinyLobster": "Langouste",
"mussels": "Moules", "mussels": "Moules",
"oysters": "Huîtres", "oysters": "Huîtres",
"scallops": "Saint-Jacques", "scallops": "Saint-Jacques",
@ -552,10 +555,10 @@
"octopus": "Poulpe", "octopus": "Poulpe",
"clams": "Palourdes", "clams": "Palourdes",
"whelks": "Bulots", "whelks": "Bulots",
"spider_crab": "Araignée de mer", "spiderCrab": "Araignée de mer",
"periwinkle": "Bigorneau", "periwinkle": "Bigorneau",
"crayfish": "Écrevisse", "crayfish": "Écrevisse",
"grey_shrimp": "Crevette grise", "greyShrimp": "Crevette grise",
"cockle": "Coque", "cockle": "Coque",
"snail": "Escargot", "snail": "Escargot",
"cuttlefish": "Seiche", "cuttlefish": "Seiche",
@ -565,103 +568,103 @@
"polenta": "Polenta", "polenta": "Polenta",
"quinoa": "Quinoa", "quinoa": "Quinoa",
"pasta": "Pâtes", "pasta": "Pâtes",
"whole_wheat_pasta": "Pâtes complètes", "wholeWheatPasta": "Pâtes complètes",
"rice": "Riz", "rice": "Riz",
"basmati_rice": "Riz basmati", "basmatiRice": "Riz basmati",
"brown_rice": "Riz complet", "brownRice": "Riz complet",
"oats": "Flocons d'avoine", "oats": "Flocons d'avoine",
"spaghetti": "Spaghetti", "spaghetti": "Spaghetti",
"penne": "Penne", "penne": "Penne",
"tagliatelle": "Tagliatelles", "tagliatelle": "Tagliatelles",
"lasagna_sheets": "Lasagnes (feuilles)", "lasagnaSheets": "Lasagnes (feuilles)",
"gnocchi": "Gnocchi", "gnocchi": "Gnocchi",
"arborio_rice": "Riz arborio", "arborioRice": "Riz arborio",
"rice_noodles": "Nouilles de riz", "riceNoodles": "Nouilles de riz",
"udon_noodles": "Nouilles udon", "udonNoodles": "Nouilles udon",
"soba_noodles": "Nouilles soba", "sobaNoodles": "Nouilles soba",
"chinese_noodles": "Nouilles chinoises", "chineseNoodles": "Nouilles chinoises",
"rice_vermicelli": "Vermicelles de riz", "riceVermicelli": "Vermicelles de riz",
"soy_vermicelli": "Vermicelles de soja", "soyVermicelli": "Vermicelles de soja",
"sticky_rice": "Riz gluant", "stickyRice": "Riz gluant",
"sushi_rice": "Riz à sushi", "sushiRice": "Riz à sushi",
"jasmine_rice": "Riz jasmin", "jasmineRice": "Riz jasmin",
"green_lentils": "Lentilles vertes", "greenLentils": "Lentilles vertes",
"red_lentils": "Lentilles corail", "redLentils": "Lentilles corail",
"chickpeas": "Pois chiches", "chickpeas": "Pois chiches",
"white_beans": "Haricots blancs", "whiteBeans": "Haricots blancs",
"kidney_beans": "Haricots rouges", "kidneyBeans": "Haricots rouges",
"black_beans": "Haricots noirs", "blackBeans": "Haricots noirs",
"split_peas": "Pois cassés", "splitPeas": "Pois cassés",
"fava_beans": "Fèves", "favaBeans": "Fèves",
"edamame": "Edamame", "edamame": "Edamame",
"pinto_beans": "Haricots pinto", "pintoBeans": "Haricots pinto",
"flageolet_beans": "Haricots flageolets", "flageoletBeans": "Haricots flageolets",
"golden_lentils": "Lentilles blondes", "goldenLentils": "Lentilles blondes",
"peanuts_shelled": "Cacahuètes", "peanutsShelled": "Cacahuètes",
"almonds": "Amandes", "almonds": "Amandes",
"walnuts": "Noix", "walnuts": "Noix",
"hazelnuts": "Noisettes", "hazelnuts": "Noisettes",
"cashews": "Noix de cajou", "cashews": "Noix de cajou",
"pistachios": "Pistaches", "pistachios": "Pistaches",
"pecans": "Noix de pécan", "pecans": "Noix de pécan",
"almond_powder": "Poudre d'amande", "almondPowder": "Poudre d'amande",
"pine_nuts": "Pignons de pin", "pineNuts": "Pignons de pin",
"sunflower_seeds": "Graines de tournesol", "sunflowerSeeds": "Graines de tournesol",
"pumpkin_seeds": "Graines de courge", "pumpkinSeeds": "Graines de courge",
"shredded_coconut": "Noix de coco râpée", "shreddedCoconut": "Noix de coco râpée",
"raisins": "Raisins secs", "raisins": "Raisins secs",
"prunes": "Pruneaux", "prunes": "Pruneaux",
"dried_apricots": "Abricots secs", "driedApricots": "Abricots secs",
"sesame_seeds": "Graines de sésame", "sesameSeeds": "Graines de sésame",
"black_mushrooms": "Champignons noirs", "blackMushrooms": "Champignons noirs",
"nori_seaweed": "Algue nori", "noriSeaweed": "Algue nori",
"wakame_seaweed": "Algue wakamé", "wakameSeaweed": "Algue wakamé",
"kombu_seaweed": "Algue kombu", "kombuSeaweed": "Algue kombu",
"bamboo_shoots": "Pousses de bambou", "bambooShoots": "Pousses de bambou",
"water_chestnuts": "Châtaignes d'eau", "waterChestnuts": "Châtaignes d'eau",
"bread": "Pain", "bread": "Pain",
"sandwich_bread": "Pain de mie", "sandwichBread": "Pain de mie",
"whole_wheat_bread": "Pain complet", "wholeWheatBread": "Pain complet",
"baguette": "Baguette", "baguette": "Baguette",
"rye_bread": "Pain de seigle", "ryeBread": "Pain de seigle",
"breadcrumbs": "Chapelure", "breadcrumbs": "Chapelure",
"burger_bun": "Pain à burger", "burgerBun": "Pain à burger",
"brioche_bun": "Pain brioché", "briocheBun": "Pain brioché",
"hot_dog_bun": "Pain à hot-dog", "hotDogBun": "Pain à hot-dog",
"pita_bread": "Pain pita", "pitaBread": "Pain pita",
"bagel": "Pain bagel", "bagel": "Pain bagel",
"naan": "Naan", "naan": "Naan",
"wrap_bread": "Pain wrap", "wrapBread": "Pain wrap",
"viennese_bread": "Pain viennois", "vienneseBread": "Pain viennois",
"country_bread": "Pain de campagne", "countryBread": "Pain de campagne",
"multigrain_bread": "Pain aux céréales", "multigrainBread": "Pain aux céréales",
"bread_roll": "Petit pain", "breadRoll": "Petit pain",
"swedish_bread": "Pain suédois", "swedishBread": "Pain suédois",
"gluten_free_bread": "Pain sans gluten", "glutenFreeBread": "Pain sans gluten",
"rusk": "Biscotte", "rusk": "Biscotte",
"croutons": "Croûtons", "croutons": "Croûtons",
"focaccia": "Focaccia", "focaccia": "Focaccia",
"ciabatta": "Ciabatta", "ciabatta": "Ciabatta",
"corn_tortilla": "Tortilla de maïs", "cornTortilla": "Tortilla de maïs",
"wheat_tortilla": "Tortilla de blé", "wheatTortilla": "Tortilla de blé",
"breadstick": "Gressin", "breadstick": "Gressin",
"puff_pastry": "Pâte feuilletée", "puffPastry": "Pâte feuilletée",
"shortcrust_pastry": "Pâte brisée", "shortcrustPastry": "Pâte brisée",
"pizza_dough": "Pâte à pizza", "pizzaDough": "Pâte à pizza",
"sweet_shortcrust_pastry": "Pâte à tarte sablée", "sweetShortcrustPastry": "Pâte à tarte sablée",
"milk": "Lait", "milk": "Lait",
"butter": "Beurre", "butter": "Beurre",
"creme_fraiche": "Crème fraîche", "cremeFraiche": "Crème fraîche",
"liquid_cream": "Crème liquide", "liquidCream": "Crème liquide",
"cheese": "Fromage", "cheese": "Fromage",
"emmental": "Emmental", "emmental": "Emmental",
"gruyere": "Gruyère", "gruyere": "Gruyère",
"parmesan": "Parmesan", "parmesan": "Parmesan",
"mozzarella": "Mozzarella", "mozzarella": "Mozzarella",
"goat_cheese": "Chèvre (fromage)", "goatCheese": "Chèvre (fromage)",
"feta": "Feta", "feta": "Feta",
"comte": "Comté", "comte": "Comté",
"fromage_blanc": "Fromage blanc", "fromageBlanc": "Fromage blanc",
"mascarpone": "Mascarpone", "mascarpone": "Mascarpone",
"yogurt": "Yaourt", "yogurt": "Yaourt",
"burrata": "Burrata", "burrata": "Burrata",
@ -676,214 +679,214 @@
"reblochon": "Reblochon", "reblochon": "Reblochon",
"cantal": "Cantal", "cantal": "Cantal",
"beaufort": "Beaufort", "beaufort": "Beaufort",
"saint_nectaire": "Saint-Nectaire", "saintNectaire": "Saint-Nectaire",
"blue_cheese": "Bleu (fromage)", "blueCheese": "Bleu (fromage)",
"cancoillotte": "Cancoillotte", "cancoillotte": "Cancoillotte",
"tomme": "Tomme", "tomme": "Tomme",
"epoisses": "Époisses", "epoisses": "Époisses",
"chaource": "Chaource", "chaource": "Chaource",
"livarot": "Livarot", "livarot": "Livarot",
"pont_leveque": "Pont-l'Évêque", "pontLeveque": "Pont-l'Évêque",
"morbier": "Morbier", "morbier": "Morbier",
"raclette_cheese": "Raclette (fromage)", "racletteCheese": "Raclette (fromage)",
"fourme_d_ambert": "Fourme d'Ambert", "fourmeDAmbert": "Fourme d'Ambert",
"salers": "Salers", "salers": "Salers",
"ossau_iraty": "Ossau-Iraty", "ossauIraty": "Ossau-Iraty",
"vacherin": "Vacherin", "vacherin": "Vacherin",
"saint_marcellin": "Saint-Marcellin", "saintMarcellin": "Saint-Marcellin",
"neufchatel": "Neufchâtel", "neufchatel": "Neufchâtel",
"crottin_de_chavignol": "Crottin de Chavignol", "crottinDeChavignol": "Crottin de Chavignol",
"abondance_cheese": "Abondance", "abondanceCheese": "Abondance",
"carre_de_l_est": "Carré de l'Est", "carreDeLEst": "Carré de l'Est",
"edam": "Edam", "edam": "Edam",
"gouda": "Gouda", "gouda": "Gouda",
"mimolette": "Mimolette", "mimolette": "Mimolette",
"maroilles": "Maroilles", "maroilles": "Maroilles",
"mont_dor": "Mont d'or", "montDor": "Mont d'or",
"kefir": "Kéfir", "kefir": "Kéfir",
"greek_yogurt": "Yaourt à la grecque", "greekYogurt": "Yaourt à la grecque",
"egg": "Œuf", "egg": "Œuf",
"coconut_milk": "Lait de coco", "coconutMilk": "Lait de coco",
"coconut_cream": "Crème de coco", "coconutCream": "Crème de coco",
"almond_milk": "Lait d'amande", "almondMilk": "Lait d'amande",
"oat_milk": "Lait d'avoine", "oatMilk": "Lait d'avoine",
"tofu": "Tofu", "tofu": "Tofu",
"silken_tofu": "Tofu soyeux", "silkenTofu": "Tofu soyeux",
"herbes_de_provence": "Herbes de Provence", "herbesDeProvence": "Herbes de Provence",
"black_pepper": "Poivre noir", "blackPepper": "Poivre noir",
"paprika": "Paprika", "paprika": "Paprika",
"espelette_pepper": "Piment d'Espelette", "espelettePepper": "Piment d'Espelette",
"cayenne_pepper": "Piment de Cayenne", "cayennePepper": "Piment de Cayenne",
"cumin": "Cumin", "cumin": "Cumin",
"curry_powder": "Curry (poudre)", "curryPowder": "Curry (poudre)",
"turmeric": "Curcuma", "turmeric": "Curcuma",
"cinnamon": "Cannelle", "cinnamon": "Cannelle",
"nutmeg": "Muscade", "nutmeg": "Muscade",
"saffron": "Safran", "saffron": "Safran",
"clove": "Clou de girofle", "clove": "Clou de girofle",
"vanilla_bean": "Vanille (gousse)", "vanillaBean": "Vanille (gousse)",
"white_pepper": "Poivre blanc", "whitePepper": "Poivre blanc",
"pink_pepper": "Poivre rose", "pinkPepper": "Poivre rose",
"sichuan_pepper": "Poivre du Sichuan", "sichuanPepper": "Poivre du Sichuan",
"smoked_paprika": "Paprika fumé", "smokedPaprika": "Paprika fumé",
"bird_eye_chili": "Piment oiseau", "birdEyeChili": "Piment oiseau",
"juniper_berries": "Baies de genièvre", "juniperBerries": "Baies de genièvre",
"star_anise": "Anis étoilé (badiane)", "starAnise": "Anis étoilé (badiane)",
"green_anise": "Anis vert", "greenAnise": "Anis vert",
"fennel_seeds": "Graines de fenouil", "fennelSeeds": "Graines de fenouil",
"sumac": "Sumac", "sumac": "Sumac",
"nigella": "Nigelle", "nigella": "Nigelle",
"allspice": "Quatre épices", "allspice": "Quatre épices",
"colombo_powder": "Colombo (poudre)", "colomboPowder": "Colombo (poudre)",
"baharat": "Baharat", "baharat": "Baharat",
"horseradish": "Raifort", "horseradish": "Raifort",
"herb_salt": "Sel aux herbes", "herbSalt": "Sel aux herbes",
"celery_salt": "Sel de céleri", "celerySalt": "Sel de céleri",
"fleur_de_sel": "Fleur de sel", "fleurDeSel": "Fleur de sel",
"salt": "Sel", "salt": "Sel",
"five_spice": "Cinq épices", "fiveSpice": "Cinq épices",
"garam_masala": "Garam masala", "garamMasala": "Garam masala",
"coriander_seeds": "Graines de coriandre", "corianderSeeds": "Graines de coriandre",
"cardamom": "Cardamome", "cardamom": "Cardamome",
"fenugreek": "Fenugrec", "fenugreek": "Fenugrec",
"jalapeno": "Piment jalapeño", "jalapeno": "Piment jalapeño",
"chipotle": "Piment chipotle", "chipotle": "Piment chipotle",
"poblano_pepper": "Piment poblano", "poblanoPepper": "Piment poblano",
"habanero": "Piment habanero", "habanero": "Piment habanero",
"ras_el_hanout": "Ras el hanout", "rasElHanout": "Ras el hanout",
"zaatar": "Za'atar", "zaatar": "Za'atar",
"soy_sauce": "Sauce soja", "soySauce": "Sauce soja",
"mustard": "Moutarde", "mustard": "Moutarde",
"mayonnaise": "Mayonnaise", "mayonnaise": "Mayonnaise",
"ketchup": "Ketchup", "ketchup": "Ketchup",
"tabasco": "Tabasco", "tabasco": "Tabasco",
"worcestershire_sauce": "Sauce Worcestershire", "worcestershireSauce": "Sauce Worcestershire",
"fish_sauce": "Sauce nuoc-mâm", "fishSauce": "Sauce nuoc-mâm",
"wasabi": "Wasabi", "wasabi": "Wasabi",
"harissa": "Harissa", "harissa": "Harissa",
"curry_paste": "Pâte de curry", "curryPaste": "Pâte de curry",
"peanut_butter": "Beurre de cacahuète", "peanutButter": "Beurre de cacahuète",
"dijon_mustard": "Moutarde de Dijon", "dijonMustard": "Moutarde de Dijon",
"wholegrain_mustard": "Moutarde à l'ancienne", "wholegrainMustard": "Moutarde à l'ancienne",
"barbecue_sauce": "Sauce barbecue", "barbecueSauce": "Sauce barbecue",
"tartar_sauce": "Sauce tartare", "tartarSauce": "Sauce tartare",
"cocktail_sauce": "Sauce cocktail", "cocktailSauce": "Sauce cocktail",
"bearnaise_sauce": "Sauce béarnaise", "bearnaiseSauce": "Sauce béarnaise",
"hollandaise_sauce": "Sauce hollandaise", "hollandaiseSauce": "Sauce hollandaise",
"bechamel_sauce": "Sauce béchamel", "bechamelSauce": "Sauce béchamel",
"teriyaki_sauce": "Sauce teriyaki", "teriyakiSauce": "Sauce teriyaki",
"ponzu_sauce": "Sauce ponzu", "ponzuSauce": "Sauce ponzu",
"chimichurri": "Chimichurri", "chimichurri": "Chimichurri",
"red_pesto": "Pesto rouge (tomates séchées)", "redPesto": "Pesto rouge (tomates séchées)",
"pesto": "Pesto", "pesto": "Pesto",
"oyster_sauce": "Sauce huître", "oysterSauce": "Sauce huître",
"hoisin_sauce": "Sauce hoisin", "hoisinSauce": "Sauce hoisin",
"sriracha": "Sauce sriracha", "sriracha": "Sauce sriracha",
"sweet_chili_sauce": "Sauce sweet chili", "sweetChiliSauce": "Sauce sweet chili",
"miso": "Miso", "miso": "Miso",
"shrimp_paste": "Pâte de crevettes", "shrimpPaste": "Pâte de crevettes",
"red_curry_paste": "Pâte de curry rouge (thaï)", "redCurryPaste": "Pâte de curry rouge (thaï)",
"green_curry_paste": "Pâte de curry vert (thaï)", "greenCurryPaste": "Pâte de curry vert (thaï)",
"tahini": "Tahini", "tahini": "Tahini",
"aioli": "Aïoli", "aioli": "Aïoli",
"vinaigrette": "Sauce vinaigrette", "vinaigrette": "Sauce vinaigrette",
"hummus": "Houmous", "hummus": "Houmous",
"olive_oil": "Huile d'olive", "oliveOil": "Huile d'olive",
"sunflower_oil": "Huile de tournesol", "sunflowerOil": "Huile de tournesol",
"rapeseed_oil": "Huile de colza", "rapeseedOil": "Huile de colza",
"coconut_oil": "Huile de coco", "coconutOil": "Huile de coco",
"sesame_oil": "Huile de sésame", "sesameOil": "Huile de sésame",
"cider_vinegar": "Vinaigre de cidre", "ciderVinegar": "Vinaigre de cidre",
"white_vinegar": "Vinaigre blanc", "whiteVinegar": "Vinaigre blanc",
"balsamic_vinegar": "Vinaigre balsamique", "balsamicVinegar": "Vinaigre balsamique",
"capers": "Câpres", "capers": "Câpres",
"olives": "Olives", "olives": "Olives",
"black_olives": "Olives noires", "blackOlives": "Olives noires",
"green_olives": "Olives vertes", "greenOlives": "Olives vertes",
"white_wine": "Vin blanc (cuisine)", "whiteWine": "Vin blanc (cuisine)",
"red_wine": "Vin rouge (cuisine)", "redWine": "Vin rouge (cuisine)",
"rose_wine": "Vin rosé (cuisine)", "roseWine": "Vin rosé (cuisine)",
"red_wine_vinegar": "Vinaigre de vin rouge", "redWineVinegar": "Vinaigre de vin rouge",
"white_wine_vinegar": "Vinaigre de vin blanc", "whiteWineVinegar": "Vinaigre de vin blanc",
"sherry_vinegar": "Vinaigre de xérès", "sherryVinegar": "Vinaigre de xérès",
"walnut_oil": "Huile de noix", "walnutOil": "Huile de noix",
"hazelnut_oil": "Huile de noisette", "hazelnutOil": "Huile de noisette",
"peanut_oil": "Huile d'arachide", "peanutOil": "Huile d'arachide",
"chili_oil": "Huile pimentée", "chiliOil": "Huile pimentée",
"rice_vinegar": "Vinaigre de riz", "riceVinegar": "Vinaigre de riz",
"corn_oil": "Huile de maïs", "cornOil": "Huile de maïs",
"grapeseed_oil": "Huile de pépins de raisin", "grapeseedOil": "Huile de pépins de raisin",
"soybean_oil": "Huile de soja", "soybeanOil": "Huile de soja",
"palm_oil": "Huile de palme", "palmOil": "Huile de palme",
"mirin": "Mirin", "mirin": "Mirin",
"sake": "Saké (cuisine)", "sake": "Saké (cuisine)",
"lemon_juice": "Jus de citron", "lemonJuice": "Jus de citron",
"lime_juice": "Jus de citron vert", "limeJuice": "Jus de citron vert",
"orange_juice": "Jus d'orange", "orangeJuice": "Jus d'orange",
"apple_juice": "Jus de pomme", "appleJuice": "Jus de pomme",
"grape_juice": "Jus de raisin", "grapeJuice": "Jus de raisin",
"tomato_juice": "Jus de tomate", "tomatoJuice": "Jus de tomate",
"cranberry_juice": "Jus de cranberry", "cranberryJuice": "Jus de cranberry",
"coffee": "Café", "coffee": "Café",
"tea": "Thé", "tea": "Thé",
"beer": "Bière (cuisine)", "beer": "Bière (cuisine)",
"cider": "Cidre (cuisine)", "cider": "Cidre (cuisine)",
"champagne": "Champagne / vin pétillant (cuisine)", "champagne": "Champagne / vin pétillant (cuisine)",
"port_wine": "Porto (cuisine)", "portWine": "Porto (cuisine)",
"vin_jaune": "Vin jaune (cuisine)", "vinJaune": "Vin jaune (cuisine)",
"cognac": "Cognac", "cognac": "Cognac",
"rum": "Rhum", "rum": "Rhum",
"whisky": "Whisky", "whisky": "Whisky",
"vodka": "Vodka", "vodka": "Vodka",
"wheat_flour": "Farine de blé", "wheatFlour": "Farine de blé",
"whole_wheat_flour": "Farine complète", "wholeWheatFlour": "Farine complète",
"corn_flour": "Farine de maïs", "cornFlour": "Farine de maïs",
"buckwheat_flour": "Farine de sarrasin", "buckwheatFlour": "Farine de sarrasin",
"rice_flour": "Farine de riz", "riceFlour": "Farine de riz",
"vegetable_stock_cube": "Bouillon cube légumes", "vegetableStockCube": "Bouillon cube légumes",
"chicken_stock_cube": "Bouillon cube volaille", "chickenStockCube": "Bouillon cube volaille",
"tomato_paste": "Concentré de tomate", "tomatoPaste": "Concentré de tomate",
"tomato_coulis": "Coulis de tomate", "tomatoCoulis": "Coulis de tomate",
"canned_peeled_tomatoes": "Tomates pelées (conserve)", "cannedPeeledTomatoes": "Tomates pelées (conserve)",
"sun_dried_tomatoes": "Tomates séchées", "sunDriedTomatoes": "Tomates séchées",
"veal_stock": "Fond de veau", "vealStock": "Fond de veau",
"chicken_stock": "Fond de volaille", "chickenStock": "Fond de volaille",
"beef_stock_cube": "Bouillon cube bœuf", "beefStockCube": "Bouillon cube bœuf",
"fish_stock_cube": "Bouillon cube poisson", "fishStockCube": "Bouillon cube poisson",
"vegetable_broth": "Bouillon de légumes", "vegetableBroth": "Bouillon de légumes",
"chicken_broth": "Bouillon de volaille", "chickenBroth": "Bouillon de volaille",
"beef_broth": "Bouillon de bœuf", "beefBroth": "Bouillon de bœuf",
"court_bouillon": "Court-bouillon", "courtBouillon": "Court-bouillon",
"dashi": "Dashi (bouillon japonais)", "dashi": "Dashi (bouillon japonais)",
"shellfish_bisque": "Bisque de crustacés", "shellfishBisque": "Bisque de crustacés",
"tapioca_flour": "Farine de tapioca", "tapiocaFlour": "Farine de tapioca",
"masa_harina": "Masa harina", "masaHarina": "Masa harina",
"water": "Eau", "water": "Eau",
"sparkling_water": "Eau gazeuse", "sparklingWater": "Eau gazeuse",
"orange_blossom_water": "Eau de fleur d'oranger", "orangeBlossomWater": "Eau de fleur d'oranger",
"rose_water": "Eau de rose", "roseWater": "Eau de rose",
"fish_fumet": "Fumet de poisson", "fishFumet": "Fumet de poisson",
"bakers_yeast": "Levure boulangère", "bakersYeast": "Levure boulangère",
"baking_powder": "Levure chimique", "bakingPowder": "Levure chimique",
"cornstarch": "Maïzena", "cornstarch": "Maïzena",
"lupin_flour": "Farine de lupin", "lupinFlour": "Farine de lupin",
"gelatin": "Gélatine", "gelatin": "Gélatine",
"baking_soda": "Bicarbonate de soude", "bakingSoda": "Bicarbonate de soude",
"potato_starch": "Fécule de pomme de terre", "potatoStarch": "Fécule de pomme de terre",
"sugar": "Sucre", "sugar": "Sucre",
"honey": "Miel", "honey": "Miel",
"maple_syrup": "Sirop d'érable", "mapleSyrup": "Sirop d'érable",
"brown_sugar": "Sucre roux", "brownSugar": "Sucre roux",
"powdered_sugar": "Sucre glace", "powderedSugar": "Sucre glace",
"demerara_sugar": "Cassonade", "demeraraSugar": "Cassonade",
"dark_chocolate": "Chocolat noir", "darkChocolate": "Chocolat noir",
"milk_chocolate": "Chocolat au lait", "milkChocolate": "Chocolat au lait",
"white_chocolate": "Chocolat blanc", "whiteChocolate": "Chocolat blanc",
"chocolate_chips": "Pépites de chocolat", "chocolateChips": "Pépites de chocolat",
"cocoa_powder": "Cacao en poudre", "cocoaPowder": "Cacao en poudre",
"vanilla_extract": "Extrait de vanille", "vanillaExtract": "Extrait de vanille",
"palm_sugar": "Sucre de palme", "palmSugar": "Sucre de palme",
"cane_syrup": "Sirop de sucre de canne" "caneSyrup": "Sirop de sucre de canne"
} }
} }
} }

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

@ -2,12 +2,13 @@
* A dietary regime, as returned by `GET /reference/diets` reference data * A dietary regime, as returned by `GET /reference/diets` reference data
* (`Diet`, seeded via `apps/api/prisma/seed.ts`), not user-specific. * (`Diet`, seeded via `apps/api/prisma/seed.ts`), not user-specific.
* *
* `key` is a stable slug (e.g. `"vegetarien"`), not a display label it * `key` is a stable, English camelCase uid (e.g. `"vegetarian"`), not a
* never changes once seeded, unlike the label it stands in for. Callers * display label it never changes once seeded, unlike the label it stands
* resolve the label themselves via i18n (`t(\`catalog.diets.${key}\`)`, * in for. Callers resolve the label themselves via i18n
* `apps/web`'s `locales/fr/translation.json`), the same way * (`t(\`catalog.diets.${key}\`)`, `apps/web`'s `locales/fr/translation.json`),
* `IngredientCategory`/`IngredientSubcategory` enum values already do (see * the same way `IngredientCategory`/`IngredientSubcategory` enum values
* `recipes.form.category.*`/`recipes.form.subcategory.*` in that file). * already do (see `recipes.form.category.*`/`recipes.form.subcategory.*` in
* that file).
*/ */
export interface DietView { export interface DietView {
id: number; id: number;
@ -29,11 +30,12 @@ export type AllergenKind = "ALLERGY" | "INTOLERANCE";
* table itself carries no key of its own (see `schema.prisma`), so this * table itself carries no key of its own (see `schema.prisma`), so this
* flattens that split away: callers just get `{id, key}` and never need to * flattens that split away: callers just get `{id, key}` and never need to
* know a `Category` exists underneath. Like {@link DietView.key}, it's a * know a `Category` exists underneath. Like {@link DietView.key}, it's a
* stable slug (e.g. `"gluten"`), not a display label resolved via * stable English camelCase uid (e.g. `"gluten"`), not a display label
* `t(\`catalog.allergens.${key}\`)`. `kind` groups allergens into two * resolved via `t(\`catalog.allergens.${key}\`)`. `kind` groups allergens
* separate lists client-side (`AllergySelect`, `apps/web`) rather than one * into two separate lists client-side (`AllergySelect`, `apps/web`) rather
* flat "allergies & intolérances" list a single `PATCH /profile/allergies` * than one flat "allergies & intolerances" list a single
* call still covers both, this is a display grouping only. * `PATCH /profile/allergies` call still covers both, this is a display
* grouping only.
*/ */
export interface AllergyView { export interface AllergyView {
id: number; id: number;
@ -51,13 +53,13 @@ export interface AllergyView {
* rack within each aisle. * rack within each aisle.
*/ */
export const INGREDIENT_CATEGORIES = [ export const INGREDIENT_CATEGORIES = [
"PRODUITS_FRAIS", "freshProduce",
"BOUCHERIE_POISSONNERIE", "meatAndSeafood",
"EPICERIE_SECHE", "dryGoods",
"BOULANGERIE", "bakery",
"CREMERIE_FROMAGE", "dairyAndCheese",
"CONDIMENTS_EPICES", "condimentsAndSpices",
"AIDES_CULINAIRES", "cookingEssentials",
] as const; ] as const;
/** Inferred TS type for one {@link INGREDIENT_CATEGORIES} member. */ /** Inferred TS type for one {@link INGREDIENT_CATEGORIES} member. */
export type IngredientCategory = (typeof INGREDIENT_CATEGORIES)[number]; export type IngredientCategory = (typeof INGREDIENT_CATEGORIES)[number];
@ -69,28 +71,28 @@ export type IngredientCategory = (typeof INGREDIENT_CATEGORIES)[number];
* which category, and in what display order. * which category, and in what display order.
*/ */
export const INGREDIENT_SUBCATEGORIES = [ export const INGREDIENT_SUBCATEGORIES = [
"LEGUMES", "vegetables",
"FRUITS", "fruits",
"HERBES_FRAICHES", "freshHerbs",
"VIANDES", "meats",
"VOLAILLES", "poultry",
"POISSONS", "fish",
"CRUSTACES_FRUITS_DE_MER", "shellfish",
"FECULENTS", "starches",
"LEGUMINEUSES", "legumes",
"GRAINES_FRUITS_SECS", "nutsAndSeeds",
"AUTRES", "other",
"PAINS", "breads",
"PATES_A_CUIRE", "rawDough",
"PRODUITS_LAITIERS", "dairy",
"OEUFS", "eggs",
"ALTERNATIVES", "plantBasedAlternatives",
"EPICES", "spices",
"SAUCES", "sauces",
"ASSAISONNEMENTS", "seasonings",
"BASES", "bases",
"EPAISSISSANTS", "thickeners",
"SUCRES", "sugars",
] as const; ] as const;
/** Inferred TS type for one {@link INGREDIENT_SUBCATEGORIES} member. */ /** Inferred TS type for one {@link INGREDIENT_SUBCATEGORIES} member. */
export type IngredientSubcategory = (typeof INGREDIENT_SUBCATEGORIES)[number]; export type IngredientSubcategory = (typeof INGREDIENT_SUBCATEGORIES)[number];
@ -109,13 +111,13 @@ export const INGREDIENT_CATEGORY_SUBCATEGORIES: Record<
IngredientCategory, IngredientCategory,
readonly IngredientSubcategory[] readonly IngredientSubcategory[]
> = { > = {
PRODUITS_FRAIS: ["LEGUMES", "FRUITS", "HERBES_FRAICHES"], freshProduce: ["vegetables", "fruits", "freshHerbs"],
BOUCHERIE_POISSONNERIE: ["VIANDES", "VOLAILLES", "POISSONS", "CRUSTACES_FRUITS_DE_MER"], meatAndSeafood: ["meats", "poultry", "fish", "shellfish"],
EPICERIE_SECHE: ["FECULENTS", "LEGUMINEUSES", "GRAINES_FRUITS_SECS", "AUTRES"], dryGoods: ["starches", "legumes", "nutsAndSeeds", "other"],
BOULANGERIE: ["PAINS", "PATES_A_CUIRE"], bakery: ["breads", "rawDough"],
CREMERIE_FROMAGE: ["PRODUITS_LAITIERS", "OEUFS", "ALTERNATIVES"], dairyAndCheese: ["dairy", "eggs", "plantBasedAlternatives"],
CONDIMENTS_EPICES: ["EPICES", "SAUCES", "ASSAISONNEMENTS"], condimentsAndSpices: ["spices", "sauces", "seasonings"],
AIDES_CULINAIRES: ["BASES", "EPAISSISSANTS", "SUCRES"], cookingEssentials: ["bases", "thickeners", "sugars"],
}; };
/** /**
@ -165,17 +167,18 @@ export type IngredientIcon = (typeof INGREDIENT_ICONS)[number];
* `allergens` is resolved server-side from the `IngredientAllergy` join * `allergens` is resolved server-side from the `IngredientAllergy` join
* table empty for an ingredient that carries none of the 14 EU-regulated * table empty for an ingredient that carries none of the 14 EU-regulated
* allergens. `diets` is resolved from `IngredientDiet` the same way the * allergens. `diets` is resolved from `IngredientDiet` the same way the
* regimes this ingredient is compatible with (e.g. `Végétarien`, `gan`), * regimes this ingredient is compatible with (e.g. `vegetarian`, `vegan`),
* so the picker can flag it without the user opening its packaging. Omits * so the picker can flag it without the user opening its packaging. Omits
* `Omnivore` (every ingredient qualifies, so it's never stored) and * `omnivore` (every ingredient qualifies, so it's never stored) and
* `Sans gluten` (already derivable from whether `allergens` contains * `glutenFree` (already derivable from whether `allergens` contains
* `Gluten` see `IngredientDiet` in schema.prisma). Used by the recipe * `gluten` see `IngredientDiet` in schema.prisma). Used by the recipe
* catalog (`apps/web`'s recipe form and detail page) to pick ingredients and * catalog (`apps/web`'s recipe form and detail page) to pick ingredients and
* to surface which allergens/regimes a recipe contains, aggregated across * to surface which allergens/regimes a recipe contains, aggregated across
* its ingredients. * its ingredients.
* *
* `key` is a stable slug (e.g. `"tomate"`), not a display label like * `key` is a stable English camelCase uid (e.g. `"tomato"`), not a display
* {@link DietView.key}, resolved via `t(\`catalog.ingredients.${key}\`)`. * label like {@link DietView.key}, resolved via
* `t(\`catalog.ingredients.${key}\`)`.
*/ */
export interface IngredientView { export interface IngredientView {
id: number; id: number;
@ -183,6 +186,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[];
} }