From 1d9bb6d11248924607a530c7728fabaf4c8c42b7 Mon Sep 17 00:00:00 2001 From: Nicolas Date: Tue, 18 Aug 2026 19:44:30 +0200 Subject: [PATCH] =?UTF-8?q?feat(web,api):=20zone=20dangereuse=20rouge,=20p?= =?UTF-8?q?r=C3=A9f=C3=A9rences=20=C3=A9largies,=20onglet=20favoris=20par?= =?UTF-8?q?=20d=C3=A9faut,=20e2e=20recettes,=20catalogue=20en=20uid+i18n?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Zone dangereuse (compte) : le bouton "Supprimer mon compte" est rouge. - Pages préférences/paramétrage : contenu centré et élargi (32rem -> 56rem) au lieu de coller à gauche sur un écran large. - Page recettes : l'onglet "Favoris" est sélectionné par défaut. - Ajout de apps/web/cypress/e2e/recipes.cy.ts (onglets, recherche, sélection master-detail, favori, suppression, lien nouvelle recette). - Catalogue de référence (ingrédients/régimes/allergènes) : la colonne `name` (le libellé français, utilisé comme clé unique) devient `key`, un slug stable et opaque au sens produit (ex. "vegetarien", "boeuf_hache"). Le libellé lui-même déménage entièrement côté client, dans apps/web/src/locales/fr/translation.json sous le namespace `catalog.*`, résolu via `t(\`catalog.ingredients.${key}\`)` etc. — même schéma que IngredientCategory/IngredientSubcategory. Migration Prisma (rename + backfill des ~456 lignes déjà seedées), seed/service/tests API et composants web mis à jour en conséquence. - apps/api/src/utils/slugify.ts + scripts/generate-catalog-i18n.ts (regénère le fichier de traduction depuis reference-seed-data.ts). - 102 tests Mocha + 32 scénarios Cucumber passent contre la base migrée. Note : cypress run plante dans cet environnement (le processus GPU Chromium/Electron crash même headless, indépendamment des flags) — les recipes.cy.ts n'ont pas pu être exécutés ici ; vérifiés par lecture du code source des composants visés et par un passage manuel dans le navigateur de prévisualisation. Co-Authored-By: Claude Sonnet 5 --- .gitignore | 4 +- .../step-definitions/profile.steps.ts | 16 +- .../features/step-definitions/recipe.steps.ts | 15 +- .../step-definitions/reference.steps.ts | 10 +- .../migration.sql | 480 ++++++++++++++++++ apps/api/prisma/schema.prisma | 32 +- apps/api/scripts/generate-catalog-i18n.ts | 99 ++++ apps/api/src/db/reference-seed-data.ts | 70 ++- apps/api/src/modules/recipe/recipe.service.ts | 10 +- .../modules/reference/reference.service.ts | 44 +- apps/api/src/utils/slugify.ts | 26 + apps/api/test/profile.test.ts | 21 +- apps/api/test/recipe.test.ts | 15 +- apps/api/test/reference.test.ts | 33 +- apps/web/cypress/e2e/recipes.cy.ts | 237 +++++++++ .../src/features/profile/AllergySelect.tsx | 5 +- apps/web/src/features/profile/DietSelect.tsx | 2 +- .../profile/DislikedIngredientsField.tsx | 2 +- .../src/features/recipes/AllergenBadges.tsx | 4 +- apps/web/src/features/recipes/DietBadges.tsx | 4 +- .../src/features/recipes/DietTagSelect.tsx | 2 +- .../src/features/recipes/IngredientPicker.tsx | 12 +- .../src/features/recipes/IngredientRow.tsx | 2 +- .../features/recipes/RecipeDetailPanel.tsx | 2 +- apps/web/src/locales/fr/translation.json | 464 +++++++++++++++++ apps/web/src/pages/RecipesPage.tsx | 2 +- .../src/pages/settings/settings-pages.scss | 22 +- packages/shared/src/types/reference.ts | 26 +- 28 files changed, 1533 insertions(+), 128 deletions(-) create mode 100644 apps/api/prisma/migrations/20260818190000_catalog_labels_to_keys/migration.sql create mode 100644 apps/api/scripts/generate-catalog-i18n.ts create mode 100644 apps/api/src/utils/slugify.ts create mode 100644 apps/web/cypress/e2e/recipes.cy.ts diff --git a/.gitignore b/.gitignore index a49f9c9..b873d1e 100644 --- a/.gitignore +++ b/.gitignore @@ -149,4 +149,6 @@ Projet batch cooking.pdf tmp-mockups/ # IA -.claude/ \ No newline at end of file +.claude/ +# Scratch output of apps/api/scripts/generate-catalog-i18n.ts — regenerate on demand. +apps/api/scripts/backfill.sql diff --git a/apps/api/features/step-definitions/profile.steps.ts b/apps/api/features/step-definitions/profile.steps.ts index f9e432f..b5783e8 100644 --- a/apps/api/features/step-definitions/profile.steps.ts +++ b/apps/api/features/step-definitions/profile.steps.ts @@ -1,6 +1,7 @@ import assert from "node:assert/strict"; import { Then, When } from "@cucumber/cucumber"; import { prisma } from "../../src/db/prisma.js"; +import { slugify } from "../../src/utils/slugify.js"; import type { CustomWorld } from "../support/world.js"; /** Splits a comma-separated list of names from a `.feature` string into trimmed, non-empty parts. */ @@ -11,25 +12,32 @@ function splitNames(names: string): string[] { .filter(Boolean); } -/** Resolves allergen names (Category.name) to their Allergy id — see reference.service.ts for why the name lives on Category, not Allergy. */ +/** + * Resolves allergen names (as written in a `.feature` file, e.g. + * "Arachides") to their Allergy id — scenarios still name allergens by their + * French label for readability, so this slugifies before matching against + * `Category.key` (see reference.service.ts for why the key lives on + * Category, not Allergy). + */ async function allergyIdsFor(names: string[]): Promise { const allergies = await prisma.allergy.findMany({ include: { category: true } }); return names.map((name) => { - const match = allergies.find((allergy) => allergy.category.name === name); + const key = slugify(name); + const match = allergies.find((allergy) => allergy.category.key === key); if (!match) throw new Error(`No seeded allergen named "${name}"`); return match.id; }); } When("I set my regime to {string}", async function (this: CustomWorld, dietName: string) { - const diet = await prisma.diet.findFirstOrThrow({ where: { name: dietName } }); + const diet = await prisma.diet.findFirstOrThrow({ where: { key: slugify(dietName) } }); this.response = await this.agent.patch("/profile/diet").send({ dietId: diet.id }); }); Then( "my profile's regime should be {string}", async function (this: CustomWorld, dietName: string) { - const diet = await prisma.diet.findFirstOrThrow({ where: { name: dietName } }); + const diet = await prisma.diet.findFirstOrThrow({ where: { key: slugify(dietName) } }); assert.equal(this.response.body.dietId, diet.id); }, ); diff --git a/apps/api/features/step-definitions/recipe.steps.ts b/apps/api/features/step-definitions/recipe.steps.ts index 020f99f..d61c078 100644 --- a/apps/api/features/step-definitions/recipe.steps.ts +++ b/apps/api/features/step-definitions/recipe.steps.ts @@ -1,12 +1,18 @@ import assert from "node:assert/strict"; import { Given, Then, When } from "@cucumber/cucumber"; import { prisma } from "../../src/db/prisma.js"; +import { slugify } from "../../src/utils/slugify.js"; import { TEST_REFERENCE_DATE } from "../../test-support/reference-date.js"; import type { CustomWorld } from "../support/world.js"; -/** Resolves a reference ingredient by its seeded name — every scenario below names an ingredient by its `reference-seed-data.ts` name, never a raw id. */ +/** + * Resolves a reference ingredient by its seeded French name — every + * scenario below names an ingredient by its `reference-seed-data.ts` name, + * never a raw id or its slug `key` directly, so this slugifies before + * matching. + */ async function findIngredientId(name: string): Promise { - const ingredient = await prisma.ingredient.findFirstOrThrow({ where: { name } }); + const ingredient = await prisma.ingredient.findFirstOrThrow({ where: { key: slugify(name) } }); return ingredient.id; } @@ -55,10 +61,11 @@ Then( "the created recipe should have ingredient {string} and step {string}", function (this: CustomWorld, ingredientName: string, step: string) { const body = this.response.body as { - ingredients: Array<{ ingredient: { name: string } }>; + ingredients: Array<{ ingredient: { key: string } }>; steps: Array<{ description: string }>; }; - assert.ok(body.ingredients.some((line) => line.ingredient.name === ingredientName)); + const expectedKey = slugify(ingredientName); + assert.ok(body.ingredients.some((line) => line.ingredient.key === expectedKey)); assert.ok(body.steps.some((s) => s.description === step)); }, ); diff --git a/apps/api/features/step-definitions/reference.steps.ts b/apps/api/features/step-definitions/reference.steps.ts index e4e7c1b..f5d9b58 100644 --- a/apps/api/features/step-definitions/reference.steps.ts +++ b/apps/api/features/step-definitions/reference.steps.ts @@ -1,11 +1,17 @@ import assert from "node:assert/strict"; import { Then } from "@cucumber/cucumber"; +import { slugify } from "../../src/utils/slugify.js"; import type { CustomWorld } from "../support/world.js"; +// The feature file still names an item by its French label, for +// readability — the response itself carries only the slug `key` (see +// reference.service.ts), so this slugifies the expected label before +// comparing. Then( "the reference list response should include {string}", function (this: CustomWorld, name: string) { - const names = (this.response.body as Array<{ name: string }>).map((item) => item.name); - assert.ok(names.includes(name), `expected ${JSON.stringify(names)} to include "${name}"`); + const keys = (this.response.body as Array<{ key: string }>).map((item) => item.key); + const expectedKey = slugify(name); + assert.ok(keys.includes(expectedKey), `expected ${JSON.stringify(keys)} to include "${expectedKey}"`); }, ); diff --git a/apps/api/prisma/migrations/20260818190000_catalog_labels_to_keys/migration.sql b/apps/api/prisma/migrations/20260818190000_catalog_labels_to_keys/migration.sql new file mode 100644 index 0000000..af2b0b3 --- /dev/null +++ b/apps/api/prisma/migrations/20260818190000_catalog_labels_to_keys/migration.sql @@ -0,0 +1,480 @@ +-- Renames the reference-data label column to a stable slug key on Diet, +-- Category (allergens), and Ingredient — see `utils/slugify.ts` and +-- `scripts/generate-catalog-i18n.ts`. The display label these columns used +-- to hold moves to `apps/web`'s `locales/fr/translation.json` (`catalog.*` +-- namespace) instead. + +ALTER TABLE "diet" RENAME COLUMN "name" TO "key"; +ALTER INDEX "diet_name_key" RENAME TO "diet_key_key"; + +ALTER TABLE "category" RENAME COLUMN "name" TO "key"; +ALTER INDEX "category_name_key" RENAME TO "category_key_key"; + +ALTER TABLE "ingredients" RENAME COLUMN "name" TO "key"; +ALTER INDEX "ingredients_name_key" RENAME TO "ingredients_key_key"; + +-- Auto-generated by scripts/generate-catalog-i18n.ts — do not hand-edit. +-- Backfills the "key" column (just renamed from "name" by this migration's +-- preceding statement, so it still holds the old French label) to its slug +-- value, for every row already seeded in a database this migration runs +-- against. A fresh database has none of these rows yet (the seed script +-- inserts by "key" from the start), so this is a no-op there. + +UPDATE "diet" SET "key" = 'omnivore' WHERE "key" = 'Omnivore'; +UPDATE "diet" SET "key" = 'vegetarien' WHERE "key" = 'Végétarien'; +UPDATE "diet" SET "key" = 'vegan' WHERE "key" = 'Végan'; +UPDATE "diet" SET "key" = 'pescetarien' WHERE "key" = 'Pescétarien'; +UPDATE "diet" SET "key" = 'sans_gluten' WHERE "key" = 'Sans gluten'; + +UPDATE "category" SET "key" = 'gluten' WHERE "key" = 'Gluten'; +UPDATE "category" SET "key" = 'crustaces' WHERE "key" = 'Crustacés'; +UPDATE "category" SET "key" = 'oeufs' WHERE "key" = 'Œufs'; +UPDATE "category" SET "key" = 'poissons' WHERE "key" = 'Poissons'; +UPDATE "category" SET "key" = 'arachides' WHERE "key" = 'Arachides'; +UPDATE "category" SET "key" = 'soja' WHERE "key" = 'Soja'; +UPDATE "category" SET "key" = 'lait' WHERE "key" = 'Lait'; +UPDATE "category" SET "key" = 'fruits_a_coque' WHERE "key" = 'Fruits à coque'; +UPDATE "category" SET "key" = 'celeri' WHERE "key" = 'Céleri'; +UPDATE "category" SET "key" = 'moutarde' WHERE "key" = 'Moutarde'; +UPDATE "category" SET "key" = 'graines_de_sesame' WHERE "key" = 'Graines de sésame'; +UPDATE "category" SET "key" = 'sulfites' WHERE "key" = 'Sulfites'; +UPDATE "category" SET "key" = 'lupin' WHERE "key" = 'Lupin'; +UPDATE "category" SET "key" = 'mollusques' WHERE "key" = 'Mollusques'; + +UPDATE "ingredients" SET "key" = 'tomate' WHERE "key" = 'Tomate'; +UPDATE "ingredients" SET "key" = 'oignon' WHERE "key" = 'Oignon'; +UPDATE "ingredients" SET "key" = 'echalote' WHERE "key" = 'Échalote'; +UPDATE "ingredients" SET "key" = 'ail' WHERE "key" = 'Ail'; +UPDATE "ingredients" SET "key" = 'carotte' WHERE "key" = 'Carotte'; +UPDATE "ingredients" SET "key" = 'courgette' WHERE "key" = 'Courgette'; +UPDATE "ingredients" SET "key" = 'concombre' WHERE "key" = 'Concombre'; +UPDATE "ingredients" SET "key" = 'cornichons' WHERE "key" = 'Cornichons'; +UPDATE "ingredients" SET "key" = 'poivron' WHERE "key" = 'Poivron'; +UPDATE "ingredients" SET "key" = 'champignon' WHERE "key" = 'Champignon'; +UPDATE "ingredients" SET "key" = 'cepes' WHERE "key" = 'Cèpes'; +UPDATE "ingredients" SET "key" = 'aubergine' WHERE "key" = 'Aubergine'; +UPDATE "ingredients" SET "key" = 'brocoli' WHERE "key" = 'Brocoli'; +UPDATE "ingredients" SET "key" = 'chou_fleur' WHERE "key" = 'Chou-fleur'; +UPDATE "ingredients" SET "key" = 'chou_blanc' WHERE "key" = 'Chou blanc'; +UPDATE "ingredients" SET "key" = 'chou_rouge' WHERE "key" = 'Chou rouge'; +UPDATE "ingredients" SET "key" = 'chou_de_bruxelles' WHERE "key" = 'Chou de Bruxelles'; +UPDATE "ingredients" SET "key" = 'epinard' WHERE "key" = 'Épinard'; +UPDATE "ingredients" SET "key" = 'blette' WHERE "key" = 'Blette'; +UPDATE "ingredients" SET "key" = 'salade' WHERE "key" = 'Salade'; +UPDATE "ingredients" SET "key" = 'roquette' WHERE "key" = 'Roquette'; +UPDATE "ingredients" SET "key" = 'cresson' WHERE "key" = 'Cresson'; +UPDATE "ingredients" SET "key" = 'poireau' WHERE "key" = 'Poireau'; +UPDATE "ingredients" SET "key" = 'celeri' WHERE "key" = 'Céleri'; +UPDATE "ingredients" SET "key" = 'radis' WHERE "key" = 'Radis'; +UPDATE "ingredients" SET "key" = 'betterave' WHERE "key" = 'Betterave'; +UPDATE "ingredients" SET "key" = 'navet' WHERE "key" = 'Navet'; +UPDATE "ingredients" SET "key" = 'panais' WHERE "key" = 'Panais'; +UPDATE "ingredients" SET "key" = 'haricot_vert' WHERE "key" = 'Haricot vert'; +UPDATE "ingredients" SET "key" = 'petit_pois' WHERE "key" = 'Petit pois'; +UPDATE "ingredients" SET "key" = 'mais' WHERE "key" = 'Maïs'; +UPDATE "ingredients" SET "key" = 'artichaut' WHERE "key" = 'Artichaut'; +UPDATE "ingredients" SET "key" = 'fenouil' WHERE "key" = 'Fenouil'; +UPDATE "ingredients" SET "key" = 'endive' WHERE "key" = 'Endive'; +UPDATE "ingredients" SET "key" = 'potiron' WHERE "key" = 'Potiron'; +UPDATE "ingredients" SET "key" = 'butternut' WHERE "key" = 'Butternut'; +UPDATE "ingredients" SET "key" = 'asperge' WHERE "key" = 'Asperge'; +UPDATE "ingredients" SET "key" = 'avocat' WHERE "key" = 'Avocat'; +UPDATE "ingredients" SET "key" = 'pomme_de_terre' WHERE "key" = 'Pomme de terre'; +UPDATE "ingredients" SET "key" = 'patate_douce' WHERE "key" = 'Patate douce'; +UPDATE "ingredients" SET "key" = 'tomates_cerises' WHERE "key" = 'Tomates cerises'; +UPDATE "ingredients" SET "key" = 'pak_choi' WHERE "key" = 'Pak-choï'; +UPDATE "ingredients" SET "key" = 'germes_de_soja' WHERE "key" = 'Germes de soja'; +UPDATE "ingredients" SET "key" = 'shiitake' WHERE "key" = 'Shiitake'; +UPDATE "ingredients" SET "key" = 'daikon' WHERE "key" = 'Daikon'; +UPDATE "ingredients" SET "key" = 'piment_vert_frais' WHERE "key" = 'Piment vert frais'; +UPDATE "ingredients" SET "key" = 'citron' WHERE "key" = 'Citron'; +UPDATE "ingredients" SET "key" = 'citron_vert' WHERE "key" = 'Citron vert'; +UPDATE "ingredients" SET "key" = 'pomme' WHERE "key" = 'Pomme'; +UPDATE "ingredients" SET "key" = 'poire' WHERE "key" = 'Poire'; +UPDATE "ingredients" SET "key" = 'banane' WHERE "key" = 'Banane'; +UPDATE "ingredients" SET "key" = 'orange' WHERE "key" = 'Orange'; +UPDATE "ingredients" SET "key" = 'clementine' WHERE "key" = 'Clémentine'; +UPDATE "ingredients" SET "key" = 'pamplemousse' WHERE "key" = 'Pamplemousse'; +UPDATE "ingredients" SET "key" = 'fraise' WHERE "key" = 'Fraise'; +UPDATE "ingredients" SET "key" = 'framboise' WHERE "key" = 'Framboise'; +UPDATE "ingredients" SET "key" = 'myrtille' WHERE "key" = 'Myrtille'; +UPDATE "ingredients" SET "key" = 'mure' WHERE "key" = 'Mûre'; +UPDATE "ingredients" SET "key" = 'cerise' WHERE "key" = 'Cerise'; +UPDATE "ingredients" SET "key" = 'abricot' WHERE "key" = 'Abricot'; +UPDATE "ingredients" SET "key" = 'peche' WHERE "key" = 'Pêche'; +UPDATE "ingredients" SET "key" = 'prune' WHERE "key" = 'Prune'; +UPDATE "ingredients" SET "key" = 'raisin' WHERE "key" = 'Raisin'; +UPDATE "ingredients" SET "key" = 'melon' WHERE "key" = 'Melon'; +UPDATE "ingredients" SET "key" = 'pasteque' WHERE "key" = 'Pastèque'; +UPDATE "ingredients" SET "key" = 'ananas' WHERE "key" = 'Ananas'; +UPDATE "ingredients" SET "key" = 'mangue' WHERE "key" = 'Mangue'; +UPDATE "ingredients" SET "key" = 'kiwi' WHERE "key" = 'Kiwi'; +UPDATE "ingredients" SET "key" = 'figue' WHERE "key" = 'Figue'; +UPDATE "ingredients" SET "key" = 'datte' WHERE "key" = 'Datte'; +UPDATE "ingredients" SET "key" = 'litchi' WHERE "key" = 'Litchi'; +UPDATE "ingredients" SET "key" = 'grenade' WHERE "key" = 'Grenade'; +UPDATE "ingredients" SET "key" = 'rhubarbe' WHERE "key" = 'Rhubarbe'; +UPDATE "ingredients" SET "key" = 'coing' WHERE "key" = 'Coing'; +UPDATE "ingredients" SET "key" = 'basilic' WHERE "key" = 'Basilic'; +UPDATE "ingredients" SET "key" = 'persil' WHERE "key" = 'Persil'; +UPDATE "ingredients" SET "key" = 'thym' WHERE "key" = 'Thym'; +UPDATE "ingredients" SET "key" = 'romarin' WHERE "key" = 'Romarin'; +UPDATE "ingredients" SET "key" = 'laurier' WHERE "key" = 'Laurier'; +UPDATE "ingredients" SET "key" = 'ciboulette' WHERE "key" = 'Ciboulette'; +UPDATE "ingredients" SET "key" = 'coriandre_fraiche' WHERE "key" = 'Coriandre fraîche'; +UPDATE "ingredients" SET "key" = 'menthe' WHERE "key" = 'Menthe'; +UPDATE "ingredients" SET "key" = 'origan' WHERE "key" = 'Origan'; +UPDATE "ingredients" SET "key" = 'aneth' WHERE "key" = 'Aneth'; +UPDATE "ingredients" SET "key" = 'estragon' WHERE "key" = 'Estragon'; +UPDATE "ingredients" SET "key" = 'sarriette' WHERE "key" = 'Sarriette'; +UPDATE "ingredients" SET "key" = 'marjolaine' WHERE "key" = 'Marjolaine'; +UPDATE "ingredients" SET "key" = 'sauge' WHERE "key" = 'Sauge'; +UPDATE "ingredients" SET "key" = 'cerfeuil' WHERE "key" = 'Cerfeuil'; +UPDATE "ingredients" SET "key" = 'gingembre' WHERE "key" = 'Gingembre'; +UPDATE "ingredients" SET "key" = 'citronnelle' WHERE "key" = 'Citronnelle'; +UPDATE "ingredients" SET "key" = 'combava' WHERE "key" = 'Combava'; +UPDATE "ingredients" SET "key" = 'lapin' WHERE "key" = 'Lapin'; +UPDATE "ingredients" SET "key" = 'boeuf_hache' WHERE "key" = 'Bœuf haché'; +UPDATE "ingredients" SET "key" = 'steak_de_boeuf' WHERE "key" = 'Steak de bœuf'; +UPDATE "ingredients" SET "key" = 'roti_de_boeuf' WHERE "key" = 'Rôti de bœuf'; +UPDATE "ingredients" SET "key" = 'escalope_de_veau' WHERE "key" = 'Escalope de veau'; +UPDATE "ingredients" SET "key" = 'filet_mignon_de_porc' WHERE "key" = 'Filet mignon de porc'; +UPDATE "ingredients" SET "key" = 'cote_de_porc' WHERE "key" = 'Côte de porc'; +UPDATE "ingredients" SET "key" = 'agneau' WHERE "key" = 'Agneau'; +UPDATE "ingredients" SET "key" = 'gigot_d_agneau' WHERE "key" = 'Gigot d''agneau'; +UPDATE "ingredients" SET "key" = 'lardons' WHERE "key" = 'Lardons'; +UPDATE "ingredients" SET "key" = 'bacon' WHERE "key" = 'Bacon'; +UPDATE "ingredients" SET "key" = 'jambon_blanc' WHERE "key" = 'Jambon blanc'; +UPDATE "ingredients" SET "key" = 'jambon_cru' WHERE "key" = 'Jambon cru'; +UPDATE "ingredients" SET "key" = 'saucisse' WHERE "key" = 'Saucisse'; +UPDATE "ingredients" SET "key" = 'chorizo' WHERE "key" = 'Chorizo'; +UPDATE "ingredients" SET "key" = 'merguez' WHERE "key" = 'Merguez'; +UPDATE "ingredients" SET "key" = 'prosciutto' WHERE "key" = 'Prosciutto'; +UPDATE "ingredients" SET "key" = 'pancetta' WHERE "key" = 'Pancetta'; +UPDATE "ingredients" SET "key" = 'mortadelle' WHERE "key" = 'Mortadelle'; +UPDATE "ingredients" SET "key" = 'salami' WHERE "key" = 'Salami'; +UPDATE "ingredients" SET "key" = 'poulet' WHERE "key" = 'Poulet'; +UPDATE "ingredients" SET "key" = 'dinde' WHERE "key" = 'Dinde'; +UPDATE "ingredients" SET "key" = 'canard' WHERE "key" = 'Canard'; +UPDATE "ingredients" SET "key" = 'magret_de_canard' WHERE "key" = 'Magret de canard'; +UPDATE "ingredients" SET "key" = 'saumon' WHERE "key" = 'Saumon'; +UPDATE "ingredients" SET "key" = 'thon' WHERE "key" = 'Thon'; +UPDATE "ingredients" SET "key" = 'cabillaud' WHERE "key" = 'Cabillaud'; +UPDATE "ingredients" SET "key" = 'truite' WHERE "key" = 'Truite'; +UPDATE "ingredients" SET "key" = 'sardine' WHERE "key" = 'Sardine'; +UPDATE "ingredients" SET "key" = 'anchois' WHERE "key" = 'Anchois'; +UPDATE "ingredients" SET "key" = 'merlan' WHERE "key" = 'Merlan'; +UPDATE "ingredients" SET "key" = 'surimi' WHERE "key" = 'Surimi'; +UPDATE "ingredients" SET "key" = 'bar_loup_de_mer' WHERE "key" = 'Bar (loup de mer)'; +UPDATE "ingredients" SET "key" = 'dorade' WHERE "key" = 'Dorade'; +UPDATE "ingredients" SET "key" = 'sole' WHERE "key" = 'Sole'; +UPDATE "ingredients" SET "key" = 'turbot' WHERE "key" = 'Turbot'; +UPDATE "ingredients" SET "key" = 'merlu' WHERE "key" = 'Merlu'; +UPDATE "ingredients" SET "key" = 'colin' WHERE "key" = 'Colin'; +UPDATE "ingredients" SET "key" = 'lieu_noir' WHERE "key" = 'Lieu noir'; +UPDATE "ingredients" SET "key" = 'eglefin' WHERE "key" = 'Églefin'; +UPDATE "ingredients" SET "key" = 'maquereau' WHERE "key" = 'Maquereau'; +UPDATE "ingredients" SET "key" = 'hareng' WHERE "key" = 'Hareng'; +UPDATE "ingredients" SET "key" = 'rouget' WHERE "key" = 'Rouget'; +UPDATE "ingredients" SET "key" = 'raie' WHERE "key" = 'Raie'; +UPDATE "ingredients" SET "key" = 'lotte' WHERE "key" = 'Lotte'; +UPDATE "ingredients" SET "key" = 'fletan' WHERE "key" = 'Flétan'; +UPDATE "ingredients" SET "key" = 'espadon' WHERE "key" = 'Espadon'; +UPDATE "ingredients" SET "key" = 'carpe' WHERE "key" = 'Carpe'; +UPDATE "ingredients" SET "key" = 'brochet' WHERE "key" = 'Brochet'; +UPDATE "ingredients" SET "key" = 'perche' WHERE "key" = 'Perche'; +UPDATE "ingredients" SET "key" = 'tilapia' WHERE "key" = 'Tilapia'; +UPDATE "ingredients" SET "key" = 'panga' WHERE "key" = 'Panga'; +UPDATE "ingredients" SET "key" = 'saumon_fume' WHERE "key" = 'Saumon fumé'; +UPDATE "ingredients" SET "key" = 'poisson_seche' WHERE "key" = 'Poisson séché'; +UPDATE "ingredients" SET "key" = 'crevettes' WHERE "key" = 'Crevettes'; +UPDATE "ingredients" SET "key" = 'langoustines' WHERE "key" = 'Langoustines'; +UPDATE "ingredients" SET "key" = 'homard' WHERE "key" = 'Homard'; +UPDATE "ingredients" SET "key" = 'crabe' WHERE "key" = 'Crabe'; +UPDATE "ingredients" SET "key" = 'langouste' WHERE "key" = 'Langouste'; +UPDATE "ingredients" SET "key" = 'moules' WHERE "key" = 'Moules'; +UPDATE "ingredients" SET "key" = 'huitres' WHERE "key" = 'Huîtres'; +UPDATE "ingredients" SET "key" = 'saint_jacques' WHERE "key" = 'Saint-Jacques'; +UPDATE "ingredients" SET "key" = 'calamar' WHERE "key" = 'Calamar'; +UPDATE "ingredients" SET "key" = 'poulpe' WHERE "key" = 'Poulpe'; +UPDATE "ingredients" SET "key" = 'palourdes' WHERE "key" = 'Palourdes'; +UPDATE "ingredients" SET "key" = 'bulots' WHERE "key" = 'Bulots'; +UPDATE "ingredients" SET "key" = 'semoule' WHERE "key" = 'Semoule'; +UPDATE "ingredients" SET "key" = 'couscous' WHERE "key" = 'Couscous'; +UPDATE "ingredients" SET "key" = 'boulgour' WHERE "key" = 'Boulgour'; +UPDATE "ingredients" SET "key" = 'polenta' WHERE "key" = 'Polenta'; +UPDATE "ingredients" SET "key" = 'quinoa' WHERE "key" = 'Quinoa'; +UPDATE "ingredients" SET "key" = 'pates' WHERE "key" = 'Pâtes'; +UPDATE "ingredients" SET "key" = 'pates_completes' WHERE "key" = 'Pâtes complètes'; +UPDATE "ingredients" SET "key" = 'riz' WHERE "key" = 'Riz'; +UPDATE "ingredients" SET "key" = 'riz_basmati' WHERE "key" = 'Riz basmati'; +UPDATE "ingredients" SET "key" = 'riz_complet' WHERE "key" = 'Riz complet'; +UPDATE "ingredients" SET "key" = 'flocons_d_avoine' WHERE "key" = 'Flocons d''avoine'; +UPDATE "ingredients" SET "key" = 'spaghetti' WHERE "key" = 'Spaghetti'; +UPDATE "ingredients" SET "key" = 'penne' WHERE "key" = 'Penne'; +UPDATE "ingredients" SET "key" = 'tagliatelles' WHERE "key" = 'Tagliatelles'; +UPDATE "ingredients" SET "key" = 'lasagnes_feuilles' WHERE "key" = 'Lasagnes (feuilles)'; +UPDATE "ingredients" SET "key" = 'gnocchi' WHERE "key" = 'Gnocchi'; +UPDATE "ingredients" SET "key" = 'riz_arborio' WHERE "key" = 'Riz arborio'; +UPDATE "ingredients" SET "key" = 'nouilles_de_riz' WHERE "key" = 'Nouilles de riz'; +UPDATE "ingredients" SET "key" = 'nouilles_udon' WHERE "key" = 'Nouilles udon'; +UPDATE "ingredients" SET "key" = 'nouilles_soba' WHERE "key" = 'Nouilles soba'; +UPDATE "ingredients" SET "key" = 'nouilles_chinoises' WHERE "key" = 'Nouilles chinoises'; +UPDATE "ingredients" SET "key" = 'vermicelles_de_riz' WHERE "key" = 'Vermicelles de riz'; +UPDATE "ingredients" SET "key" = 'vermicelles_de_soja' WHERE "key" = 'Vermicelles de soja'; +UPDATE "ingredients" SET "key" = 'riz_gluant' WHERE "key" = 'Riz gluant'; +UPDATE "ingredients" SET "key" = 'riz_a_sushi' WHERE "key" = 'Riz à sushi'; +UPDATE "ingredients" SET "key" = 'riz_jasmin' WHERE "key" = 'Riz jasmin'; +UPDATE "ingredients" SET "key" = 'lentilles_vertes' WHERE "key" = 'Lentilles vertes'; +UPDATE "ingredients" SET "key" = 'lentilles_corail' WHERE "key" = 'Lentilles corail'; +UPDATE "ingredients" SET "key" = 'pois_chiches' WHERE "key" = 'Pois chiches'; +UPDATE "ingredients" SET "key" = 'haricots_blancs' WHERE "key" = 'Haricots blancs'; +UPDATE "ingredients" SET "key" = 'haricots_rouges' WHERE "key" = 'Haricots rouges'; +UPDATE "ingredients" SET "key" = 'haricots_noirs' WHERE "key" = 'Haricots noirs'; +UPDATE "ingredients" SET "key" = 'pois_casses' WHERE "key" = 'Pois cassés'; +UPDATE "ingredients" SET "key" = 'feves' WHERE "key" = 'Fèves'; +UPDATE "ingredients" SET "key" = 'edamame' WHERE "key" = 'Edamame'; +UPDATE "ingredients" SET "key" = 'haricots_pinto' WHERE "key" = 'Haricots pinto'; +UPDATE "ingredients" SET "key" = 'cacahuetes' WHERE "key" = 'Cacahuètes'; +UPDATE "ingredients" SET "key" = 'amandes' WHERE "key" = 'Amandes'; +UPDATE "ingredients" SET "key" = 'noix' WHERE "key" = 'Noix'; +UPDATE "ingredients" SET "key" = 'noisettes' WHERE "key" = 'Noisettes'; +UPDATE "ingredients" SET "key" = 'noix_de_cajou' WHERE "key" = 'Noix de cajou'; +UPDATE "ingredients" SET "key" = 'pistaches' WHERE "key" = 'Pistaches'; +UPDATE "ingredients" SET "key" = 'noix_de_pecan' WHERE "key" = 'Noix de pécan'; +UPDATE "ingredients" SET "key" = 'poudre_d_amande' WHERE "key" = 'Poudre d''amande'; +UPDATE "ingredients" SET "key" = 'pignons_de_pin' WHERE "key" = 'Pignons de pin'; +UPDATE "ingredients" SET "key" = 'graines_de_tournesol' WHERE "key" = 'Graines de tournesol'; +UPDATE "ingredients" SET "key" = 'graines_de_courge' WHERE "key" = 'Graines de courge'; +UPDATE "ingredients" SET "key" = 'noix_de_coco_rapee' WHERE "key" = 'Noix de coco râpée'; +UPDATE "ingredients" SET "key" = 'raisins_secs' WHERE "key" = 'Raisins secs'; +UPDATE "ingredients" SET "key" = 'pruneaux' WHERE "key" = 'Pruneaux'; +UPDATE "ingredients" SET "key" = 'abricots_secs' WHERE "key" = 'Abricots secs'; +UPDATE "ingredients" SET "key" = 'graines_de_sesame' WHERE "key" = 'Graines de sésame'; +UPDATE "ingredients" SET "key" = 'champignons_noirs' WHERE "key" = 'Champignons noirs'; +UPDATE "ingredients" SET "key" = 'algue_nori' WHERE "key" = 'Algue nori'; +UPDATE "ingredients" SET "key" = 'algue_wakame' WHERE "key" = 'Algue wakamé'; +UPDATE "ingredients" SET "key" = 'algue_kombu' WHERE "key" = 'Algue kombu'; +UPDATE "ingredients" SET "key" = 'pousses_de_bambou' WHERE "key" = 'Pousses de bambou'; +UPDATE "ingredients" SET "key" = 'chataignes_d_eau' WHERE "key" = 'Châtaignes d''eau'; +UPDATE "ingredients" SET "key" = 'pain' WHERE "key" = 'Pain'; +UPDATE "ingredients" SET "key" = 'pain_de_mie' WHERE "key" = 'Pain de mie'; +UPDATE "ingredients" SET "key" = 'pain_complet' WHERE "key" = 'Pain complet'; +UPDATE "ingredients" SET "key" = 'baguette' WHERE "key" = 'Baguette'; +UPDATE "ingredients" SET "key" = 'pain_de_seigle' WHERE "key" = 'Pain de seigle'; +UPDATE "ingredients" SET "key" = 'chapelure' WHERE "key" = 'Chapelure'; +UPDATE "ingredients" SET "key" = 'pain_a_burger' WHERE "key" = 'Pain à burger'; +UPDATE "ingredients" SET "key" = 'pain_brioche' WHERE "key" = 'Pain brioché'; +UPDATE "ingredients" SET "key" = 'pain_a_hot_dog' WHERE "key" = 'Pain à hot-dog'; +UPDATE "ingredients" SET "key" = 'pain_pita' WHERE "key" = 'Pain pita'; +UPDATE "ingredients" SET "key" = 'pain_bagel' WHERE "key" = 'Pain bagel'; +UPDATE "ingredients" SET "key" = 'naan' WHERE "key" = 'Naan'; +UPDATE "ingredients" SET "key" = 'pain_wrap' WHERE "key" = 'Pain wrap'; +UPDATE "ingredients" SET "key" = 'pain_viennois' WHERE "key" = 'Pain viennois'; +UPDATE "ingredients" SET "key" = 'pain_de_campagne' WHERE "key" = 'Pain de campagne'; +UPDATE "ingredients" SET "key" = 'pain_aux_cereales' WHERE "key" = 'Pain aux céréales'; +UPDATE "ingredients" SET "key" = 'petit_pain' WHERE "key" = 'Petit pain'; +UPDATE "ingredients" SET "key" = 'pain_suedois' WHERE "key" = 'Pain suédois'; +UPDATE "ingredients" SET "key" = 'pain_sans_gluten' WHERE "key" = 'Pain sans gluten'; +UPDATE "ingredients" SET "key" = 'biscotte' WHERE "key" = 'Biscotte'; +UPDATE "ingredients" SET "key" = 'croutons' WHERE "key" = 'Croûtons'; +UPDATE "ingredients" SET "key" = 'focaccia' WHERE "key" = 'Focaccia'; +UPDATE "ingredients" SET "key" = 'ciabatta' WHERE "key" = 'Ciabatta'; +UPDATE "ingredients" SET "key" = 'tortilla_de_mais' WHERE "key" = 'Tortilla de maïs'; +UPDATE "ingredients" SET "key" = 'tortilla_de_ble' WHERE "key" = 'Tortilla de blé'; +UPDATE "ingredients" SET "key" = 'pate_feuilletee' WHERE "key" = 'Pâte feuilletée'; +UPDATE "ingredients" SET "key" = 'pate_brisee' WHERE "key" = 'Pâte brisée'; +UPDATE "ingredients" SET "key" = 'pate_a_pizza' WHERE "key" = 'Pâte à pizza'; +UPDATE "ingredients" SET "key" = 'pate_a_tarte_sablee' WHERE "key" = 'Pâte à tarte sablée'; +UPDATE "ingredients" SET "key" = 'lait' WHERE "key" = 'Lait'; +UPDATE "ingredients" SET "key" = 'beurre' WHERE "key" = 'Beurre'; +UPDATE "ingredients" SET "key" = 'creme_fraiche' WHERE "key" = 'Crème fraîche'; +UPDATE "ingredients" SET "key" = 'creme_liquide' WHERE "key" = 'Crème liquide'; +UPDATE "ingredients" SET "key" = 'fromage' WHERE "key" = 'Fromage'; +UPDATE "ingredients" SET "key" = 'emmental' WHERE "key" = 'Emmental'; +UPDATE "ingredients" SET "key" = 'gruyere' WHERE "key" = 'Gruyère'; +UPDATE "ingredients" SET "key" = 'parmesan' WHERE "key" = 'Parmesan'; +UPDATE "ingredients" SET "key" = 'mozzarella' WHERE "key" = 'Mozzarella'; +UPDATE "ingredients" SET "key" = 'chevre_fromage' WHERE "key" = 'Chèvre (fromage)'; +UPDATE "ingredients" SET "key" = 'feta' WHERE "key" = 'Feta'; +UPDATE "ingredients" SET "key" = 'comte' WHERE "key" = 'Comté'; +UPDATE "ingredients" SET "key" = 'fromage_blanc' WHERE "key" = 'Fromage blanc'; +UPDATE "ingredients" SET "key" = 'mascarpone' WHERE "key" = 'Mascarpone'; +UPDATE "ingredients" SET "key" = 'yaourt' WHERE "key" = 'Yaourt'; +UPDATE "ingredients" SET "key" = 'burrata' WHERE "key" = 'Burrata'; +UPDATE "ingredients" SET "key" = 'ricotta' WHERE "key" = 'Ricotta'; +UPDATE "ingredients" SET "key" = 'pecorino' WHERE "key" = 'Pecorino'; +UPDATE "ingredients" SET "key" = 'gorgonzola' WHERE "key" = 'Gorgonzola'; +UPDATE "ingredients" SET "key" = 'cheddar' WHERE "key" = 'Cheddar'; +UPDATE "ingredients" SET "key" = 'oeuf' WHERE "key" = 'Œuf'; +UPDATE "ingredients" SET "key" = 'lait_de_coco' WHERE "key" = 'Lait de coco'; +UPDATE "ingredients" SET "key" = 'creme_de_coco' WHERE "key" = 'Crème de coco'; +UPDATE "ingredients" SET "key" = 'lait_d_amande' WHERE "key" = 'Lait d''amande'; +UPDATE "ingredients" SET "key" = 'lait_d_avoine' WHERE "key" = 'Lait d''avoine'; +UPDATE "ingredients" SET "key" = 'tofu' WHERE "key" = 'Tofu'; +UPDATE "ingredients" SET "key" = 'tofu_soyeux' WHERE "key" = 'Tofu soyeux'; +UPDATE "ingredients" SET "key" = 'herbes_de_provence' WHERE "key" = 'Herbes de Provence'; +UPDATE "ingredients" SET "key" = 'poivre_noir' WHERE "key" = 'Poivre noir'; +UPDATE "ingredients" SET "key" = 'paprika' WHERE "key" = 'Paprika'; +UPDATE "ingredients" SET "key" = 'piment_d_espelette' WHERE "key" = 'Piment d''Espelette'; +UPDATE "ingredients" SET "key" = 'piment_de_cayenne' WHERE "key" = 'Piment de Cayenne'; +UPDATE "ingredients" SET "key" = 'cumin' WHERE "key" = 'Cumin'; +UPDATE "ingredients" SET "key" = 'curry_poudre' WHERE "key" = 'Curry (poudre)'; +UPDATE "ingredients" SET "key" = 'curcuma' WHERE "key" = 'Curcuma'; +UPDATE "ingredients" SET "key" = 'cannelle' WHERE "key" = 'Cannelle'; +UPDATE "ingredients" SET "key" = 'muscade' WHERE "key" = 'Muscade'; +UPDATE "ingredients" SET "key" = 'safran' WHERE "key" = 'Safran'; +UPDATE "ingredients" SET "key" = 'clou_de_girofle' WHERE "key" = 'Clou de girofle'; +UPDATE "ingredients" SET "key" = 'vanille_gousse' WHERE "key" = 'Vanille (gousse)'; +UPDATE "ingredients" SET "key" = 'poivre_blanc' WHERE "key" = 'Poivre blanc'; +UPDATE "ingredients" SET "key" = 'poivre_rose' WHERE "key" = 'Poivre rose'; +UPDATE "ingredients" SET "key" = 'poivre_du_sichuan' WHERE "key" = 'Poivre du Sichuan'; +UPDATE "ingredients" SET "key" = 'paprika_fume' WHERE "key" = 'Paprika fumé'; +UPDATE "ingredients" SET "key" = 'piment_oiseau' WHERE "key" = 'Piment oiseau'; +UPDATE "ingredients" SET "key" = 'baies_de_genievre' WHERE "key" = 'Baies de genièvre'; +UPDATE "ingredients" SET "key" = 'anis_etoile_badiane' WHERE "key" = 'Anis étoilé (badiane)'; +UPDATE "ingredients" SET "key" = 'anis_vert' WHERE "key" = 'Anis vert'; +UPDATE "ingredients" SET "key" = 'graines_de_fenouil' WHERE "key" = 'Graines de fenouil'; +UPDATE "ingredients" SET "key" = 'sumac' WHERE "key" = 'Sumac'; +UPDATE "ingredients" SET "key" = 'nigelle' WHERE "key" = 'Nigelle'; +UPDATE "ingredients" SET "key" = 'quatre_epices' WHERE "key" = 'Quatre épices'; +UPDATE "ingredients" SET "key" = 'colombo_poudre' WHERE "key" = 'Colombo (poudre)'; +UPDATE "ingredients" SET "key" = 'baharat' WHERE "key" = 'Baharat'; +UPDATE "ingredients" SET "key" = 'raifort' WHERE "key" = 'Raifort'; +UPDATE "ingredients" SET "key" = 'sel_aux_herbes' WHERE "key" = 'Sel aux herbes'; +UPDATE "ingredients" SET "key" = 'sel_de_celeri' WHERE "key" = 'Sel de céleri'; +UPDATE "ingredients" SET "key" = 'fleur_de_sel' WHERE "key" = 'Fleur de sel'; +UPDATE "ingredients" SET "key" = 'sel' WHERE "key" = 'Sel'; +UPDATE "ingredients" SET "key" = 'cinq_epices' WHERE "key" = 'Cinq épices'; +UPDATE "ingredients" SET "key" = 'garam_masala' WHERE "key" = 'Garam masala'; +UPDATE "ingredients" SET "key" = 'graines_de_coriandre' WHERE "key" = 'Graines de coriandre'; +UPDATE "ingredients" SET "key" = 'cardamome' WHERE "key" = 'Cardamome'; +UPDATE "ingredients" SET "key" = 'fenugrec' WHERE "key" = 'Fenugrec'; +UPDATE "ingredients" SET "key" = 'piment_jalapeno' WHERE "key" = 'Piment jalapeño'; +UPDATE "ingredients" SET "key" = 'piment_chipotle' WHERE "key" = 'Piment chipotle'; +UPDATE "ingredients" SET "key" = 'piment_poblano' WHERE "key" = 'Piment poblano'; +UPDATE "ingredients" SET "key" = 'piment_habanero' WHERE "key" = 'Piment habanero'; +UPDATE "ingredients" SET "key" = 'ras_el_hanout' WHERE "key" = 'Ras el hanout'; +UPDATE "ingredients" SET "key" = 'za_atar' WHERE "key" = 'Za''atar'; +UPDATE "ingredients" SET "key" = 'sauce_soja' WHERE "key" = 'Sauce soja'; +UPDATE "ingredients" SET "key" = 'moutarde' WHERE "key" = 'Moutarde'; +UPDATE "ingredients" SET "key" = 'mayonnaise' WHERE "key" = 'Mayonnaise'; +UPDATE "ingredients" SET "key" = 'ketchup' WHERE "key" = 'Ketchup'; +UPDATE "ingredients" SET "key" = 'tabasco' WHERE "key" = 'Tabasco'; +UPDATE "ingredients" SET "key" = 'sauce_worcestershire' WHERE "key" = 'Sauce Worcestershire'; +UPDATE "ingredients" SET "key" = 'sauce_nuoc_mam' WHERE "key" = 'Sauce nuoc-mâm'; +UPDATE "ingredients" SET "key" = 'wasabi' WHERE "key" = 'Wasabi'; +UPDATE "ingredients" SET "key" = 'harissa' WHERE "key" = 'Harissa'; +UPDATE "ingredients" SET "key" = 'pate_de_curry' WHERE "key" = 'Pâte de curry'; +UPDATE "ingredients" SET "key" = 'beurre_de_cacahuete' WHERE "key" = 'Beurre de cacahuète'; +UPDATE "ingredients" SET "key" = 'moutarde_de_dijon' WHERE "key" = 'Moutarde de Dijon'; +UPDATE "ingredients" SET "key" = 'moutarde_a_l_ancienne' WHERE "key" = 'Moutarde à l''ancienne'; +UPDATE "ingredients" SET "key" = 'sauce_barbecue' WHERE "key" = 'Sauce barbecue'; +UPDATE "ingredients" SET "key" = 'sauce_tartare' WHERE "key" = 'Sauce tartare'; +UPDATE "ingredients" SET "key" = 'sauce_cocktail' WHERE "key" = 'Sauce cocktail'; +UPDATE "ingredients" SET "key" = 'sauce_bearnaise' WHERE "key" = 'Sauce béarnaise'; +UPDATE "ingredients" SET "key" = 'sauce_hollandaise' WHERE "key" = 'Sauce hollandaise'; +UPDATE "ingredients" SET "key" = 'sauce_bechamel' WHERE "key" = 'Sauce béchamel'; +UPDATE "ingredients" SET "key" = 'sauce_teriyaki' WHERE "key" = 'Sauce teriyaki'; +UPDATE "ingredients" SET "key" = 'sauce_ponzu' WHERE "key" = 'Sauce ponzu'; +UPDATE "ingredients" SET "key" = 'chimichurri' WHERE "key" = 'Chimichurri'; +UPDATE "ingredients" SET "key" = 'pesto_rouge_tomates_sechees' WHERE "key" = 'Pesto rouge (tomates séchées)'; +UPDATE "ingredients" SET "key" = 'pesto' WHERE "key" = 'Pesto'; +UPDATE "ingredients" SET "key" = 'sauce_huitre' WHERE "key" = 'Sauce huître'; +UPDATE "ingredients" SET "key" = 'sauce_hoisin' WHERE "key" = 'Sauce hoisin'; +UPDATE "ingredients" SET "key" = 'sauce_sriracha' WHERE "key" = 'Sauce sriracha'; +UPDATE "ingredients" SET "key" = 'sauce_sweet_chili' WHERE "key" = 'Sauce sweet chili'; +UPDATE "ingredients" SET "key" = 'miso' WHERE "key" = 'Miso'; +UPDATE "ingredients" SET "key" = 'pate_de_crevettes' WHERE "key" = 'Pâte de crevettes'; +UPDATE "ingredients" SET "key" = 'pate_de_curry_rouge_thai' WHERE "key" = 'Pâte de curry rouge (thaï)'; +UPDATE "ingredients" SET "key" = 'pate_de_curry_vert_thai' WHERE "key" = 'Pâte de curry vert (thaï)'; +UPDATE "ingredients" SET "key" = 'tahini' WHERE "key" = 'Tahini'; +UPDATE "ingredients" SET "key" = 'huile_d_olive' WHERE "key" = 'Huile d''olive'; +UPDATE "ingredients" SET "key" = 'huile_de_tournesol' WHERE "key" = 'Huile de tournesol'; +UPDATE "ingredients" SET "key" = 'huile_de_colza' WHERE "key" = 'Huile de colza'; +UPDATE "ingredients" SET "key" = 'huile_de_coco' WHERE "key" = 'Huile de coco'; +UPDATE "ingredients" SET "key" = 'huile_de_sesame' WHERE "key" = 'Huile de sésame'; +UPDATE "ingredients" SET "key" = 'vinaigre_de_cidre' WHERE "key" = 'Vinaigre de cidre'; +UPDATE "ingredients" SET "key" = 'vinaigre_blanc' WHERE "key" = 'Vinaigre blanc'; +UPDATE "ingredients" SET "key" = 'vinaigre_balsamique' WHERE "key" = 'Vinaigre balsamique'; +UPDATE "ingredients" SET "key" = 'capres' WHERE "key" = 'Câpres'; +UPDATE "ingredients" SET "key" = 'olives' WHERE "key" = 'Olives'; +UPDATE "ingredients" SET "key" = 'vin_blanc_cuisine' WHERE "key" = 'Vin blanc (cuisine)'; +UPDATE "ingredients" SET "key" = 'vin_rouge_cuisine' WHERE "key" = 'Vin rouge (cuisine)'; +UPDATE "ingredients" SET "key" = 'vinaigre_de_vin_rouge' WHERE "key" = 'Vinaigre de vin rouge'; +UPDATE "ingredients" SET "key" = 'vinaigre_de_vin_blanc' WHERE "key" = 'Vinaigre de vin blanc'; +UPDATE "ingredients" SET "key" = 'vinaigre_de_xeres' WHERE "key" = 'Vinaigre de xérès'; +UPDATE "ingredients" SET "key" = 'huile_de_noix' WHERE "key" = 'Huile de noix'; +UPDATE "ingredients" SET "key" = 'huile_de_noisette' WHERE "key" = 'Huile de noisette'; +UPDATE "ingredients" SET "key" = 'huile_d_arachide' WHERE "key" = 'Huile d''arachide'; +UPDATE "ingredients" SET "key" = 'huile_pimentee' WHERE "key" = 'Huile pimentée'; +UPDATE "ingredients" SET "key" = 'vinaigre_de_riz' WHERE "key" = 'Vinaigre de riz'; +UPDATE "ingredients" SET "key" = 'mirin' WHERE "key" = 'Mirin'; +UPDATE "ingredients" SET "key" = 'sake_cuisine' WHERE "key" = 'Saké (cuisine)'; +UPDATE "ingredients" SET "key" = 'jus_de_citron' WHERE "key" = 'Jus de citron'; +UPDATE "ingredients" SET "key" = 'jus_de_citron_vert' WHERE "key" = 'Jus de citron vert'; +UPDATE "ingredients" SET "key" = 'jus_d_orange' WHERE "key" = 'Jus d''orange'; +UPDATE "ingredients" SET "key" = 'jus_de_pomme' WHERE "key" = 'Jus de pomme'; +UPDATE "ingredients" SET "key" = 'jus_de_raisin' WHERE "key" = 'Jus de raisin'; +UPDATE "ingredients" SET "key" = 'jus_de_tomate' WHERE "key" = 'Jus de tomate'; +UPDATE "ingredients" SET "key" = 'jus_de_cranberry' WHERE "key" = 'Jus de cranberry'; +UPDATE "ingredients" SET "key" = 'cafe' WHERE "key" = 'Café'; +UPDATE "ingredients" SET "key" = 'the' WHERE "key" = 'Thé'; +UPDATE "ingredients" SET "key" = 'biere_cuisine' WHERE "key" = 'Bière (cuisine)'; +UPDATE "ingredients" SET "key" = 'cidre_cuisine' WHERE "key" = 'Cidre (cuisine)'; +UPDATE "ingredients" SET "key" = 'champagne_vin_petillant_cuisine' WHERE "key" = 'Champagne / vin pétillant (cuisine)'; +UPDATE "ingredients" SET "key" = 'porto_cuisine' WHERE "key" = 'Porto (cuisine)'; +UPDATE "ingredients" SET "key" = 'vin_jaune_cuisine' WHERE "key" = 'Vin jaune (cuisine)'; +UPDATE "ingredients" SET "key" = 'cognac' WHERE "key" = 'Cognac'; +UPDATE "ingredients" SET "key" = 'rhum' WHERE "key" = 'Rhum'; +UPDATE "ingredients" SET "key" = 'whisky' WHERE "key" = 'Whisky'; +UPDATE "ingredients" SET "key" = 'vodka' WHERE "key" = 'Vodka'; +UPDATE "ingredients" SET "key" = 'farine_de_ble' WHERE "key" = 'Farine de blé'; +UPDATE "ingredients" SET "key" = 'farine_complete' WHERE "key" = 'Farine complète'; +UPDATE "ingredients" SET "key" = 'farine_de_mais' WHERE "key" = 'Farine de maïs'; +UPDATE "ingredients" SET "key" = 'farine_de_sarrasin' WHERE "key" = 'Farine de sarrasin'; +UPDATE "ingredients" SET "key" = 'farine_de_riz' WHERE "key" = 'Farine de riz'; +UPDATE "ingredients" SET "key" = 'bouillon_cube_legumes' WHERE "key" = 'Bouillon cube légumes'; +UPDATE "ingredients" SET "key" = 'bouillon_cube_volaille' WHERE "key" = 'Bouillon cube volaille'; +UPDATE "ingredients" SET "key" = 'concentre_de_tomate' WHERE "key" = 'Concentré de tomate'; +UPDATE "ingredients" SET "key" = 'coulis_de_tomate' WHERE "key" = 'Coulis de tomate'; +UPDATE "ingredients" SET "key" = 'tomates_pelees_conserve' WHERE "key" = 'Tomates pelées (conserve)'; +UPDATE "ingredients" SET "key" = 'tomates_sechees' WHERE "key" = 'Tomates séchées'; +UPDATE "ingredients" SET "key" = 'fond_de_veau' WHERE "key" = 'Fond de veau'; +UPDATE "ingredients" SET "key" = 'fond_de_volaille' WHERE "key" = 'Fond de volaille'; +UPDATE "ingredients" SET "key" = 'bouillon_cube_boeuf' WHERE "key" = 'Bouillon cube bœuf'; +UPDATE "ingredients" SET "key" = 'bouillon_cube_poisson' WHERE "key" = 'Bouillon cube poisson'; +UPDATE "ingredients" SET "key" = 'bouillon_de_legumes' WHERE "key" = 'Bouillon de légumes'; +UPDATE "ingredients" SET "key" = 'bouillon_de_volaille' WHERE "key" = 'Bouillon de volaille'; +UPDATE "ingredients" SET "key" = 'bouillon_de_boeuf' WHERE "key" = 'Bouillon de bœuf'; +UPDATE "ingredients" SET "key" = 'court_bouillon' WHERE "key" = 'Court-bouillon'; +UPDATE "ingredients" SET "key" = 'dashi_bouillon_japonais' WHERE "key" = 'Dashi (bouillon japonais)'; +UPDATE "ingredients" SET "key" = 'bisque_de_crustaces' WHERE "key" = 'Bisque de crustacés'; +UPDATE "ingredients" SET "key" = 'farine_de_tapioca' WHERE "key" = 'Farine de tapioca'; +UPDATE "ingredients" SET "key" = 'masa_harina' WHERE "key" = 'Masa harina'; +UPDATE "ingredients" SET "key" = 'eau' WHERE "key" = 'Eau'; +UPDATE "ingredients" SET "key" = 'eau_gazeuse' WHERE "key" = 'Eau gazeuse'; +UPDATE "ingredients" SET "key" = 'eau_de_fleur_d_oranger' WHERE "key" = 'Eau de fleur d''oranger'; +UPDATE "ingredients" SET "key" = 'eau_de_rose' WHERE "key" = 'Eau de rose'; +UPDATE "ingredients" SET "key" = 'fumet_de_poisson' WHERE "key" = 'Fumet de poisson'; +UPDATE "ingredients" SET "key" = 'levure_boulangere' WHERE "key" = 'Levure boulangère'; +UPDATE "ingredients" SET "key" = 'levure_chimique' WHERE "key" = 'Levure chimique'; +UPDATE "ingredients" SET "key" = 'maizena' WHERE "key" = 'Maïzena'; +UPDATE "ingredients" SET "key" = 'farine_de_lupin' WHERE "key" = 'Farine de lupin'; +UPDATE "ingredients" SET "key" = 'gelatine' WHERE "key" = 'Gélatine'; +UPDATE "ingredients" SET "key" = 'bicarbonate_de_soude' WHERE "key" = 'Bicarbonate de soude'; +UPDATE "ingredients" SET "key" = 'fecule_de_pomme_de_terre' WHERE "key" = 'Fécule de pomme de terre'; +UPDATE "ingredients" SET "key" = 'sucre' WHERE "key" = 'Sucre'; +UPDATE "ingredients" SET "key" = 'miel' WHERE "key" = 'Miel'; +UPDATE "ingredients" SET "key" = 'sirop_d_erable' WHERE "key" = 'Sirop d''érable'; +UPDATE "ingredients" SET "key" = 'sucre_roux' WHERE "key" = 'Sucre roux'; +UPDATE "ingredients" SET "key" = 'sucre_glace' WHERE "key" = 'Sucre glace'; +UPDATE "ingredients" SET "key" = 'cassonade' WHERE "key" = 'Cassonade'; +UPDATE "ingredients" SET "key" = 'chocolat_noir' WHERE "key" = 'Chocolat noir'; +UPDATE "ingredients" SET "key" = 'chocolat_au_lait' WHERE "key" = 'Chocolat au lait'; +UPDATE "ingredients" SET "key" = 'chocolat_blanc' WHERE "key" = 'Chocolat blanc'; +UPDATE "ingredients" SET "key" = 'pepites_de_chocolat' WHERE "key" = 'Pépites de chocolat'; +UPDATE "ingredients" SET "key" = 'cacao_en_poudre' WHERE "key" = 'Cacao en poudre'; +UPDATE "ingredients" SET "key" = 'extrait_de_vanille' WHERE "key" = 'Extrait de vanille'; +UPDATE "ingredients" SET "key" = 'sucre_de_palme' WHERE "key" = 'Sucre de palme'; +UPDATE "ingredients" SET "key" = 'sirop_de_sucre_de_canne' WHERE "key" = 'Sirop de sucre de canne'; diff --git a/apps/api/prisma/schema.prisma b/apps/api/prisma/schema.prisma index c680e77..6b29be4 100644 --- a/apps/api/prisma/schema.prisma +++ b/apps/api/prisma/schema.prisma @@ -34,13 +34,17 @@ model House { @@map("house") } -/// `name` is `@unique` — not in the original spec doc, added so the seed -/// script (prisma/seed.ts) can `upsert` by name and stay idempotent/safe to +/// `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 /// re-run, and so two reference rows can never silently duplicate the same -/// regime. +/// regime. A stable slug (e.g. `"vegetarien"`), not the display label — +/// the label itself lives in `apps/web`'s `locales/fr/translation.json` +/// under `catalog.diets.` (see `reference-seed-data.ts`'s `DIETS` and +/// `utils/slugify.ts`), so it can be edited/translated without ever +/// touching this column or the rows that reference it by id. model Diet { - id Int @id @default(autoincrement()) - name String @unique + id Int @id @default(autoincrement()) + key String @unique users UserProfile[] recipes RecipeDiet[] @@ -59,11 +63,13 @@ enum AllergenKind { } /// Enumeration-style table, meant to grow over time (e.g. allergy nuances). -/// `name` is `@unique` for the same reason as `Diet.name` above. `kind` is -/// also not in the original spec doc — see {@link AllergenKind}. +/// `key` is `@unique` for the same reason as `Diet.key` above — a stable +/// slug (`catalog.allergens.` in `apps/web`'s locale file), not the +/// display label. `kind` is also not in the original spec doc — see +/// {@link AllergenKind}. model Category { id Int @id @default(autoincrement()) - name String @unique + key String @unique kind AllergenKind @default(ALLERGY) allergies Allergy[] @@ -288,9 +294,11 @@ model RecipeDiet { @@map("recipe_diet") } -/// `name` is `@unique` — not in the original spec doc, added so the seed -/// script (reference-seed-data.ts) can `upsert` by name and stay -/// idempotent/safe to re-run, same reason as `Diet.name`/`Category.name`. +/// `key` is `@unique` — not in the original spec doc, added so the seed +/// script (reference-seed-data.ts) can `upsert` by key and stay +/// idempotent/safe to re-run, same reason as `Diet.key`/`Category.key`. A +/// stable slug (`catalog.ingredients.` in `apps/web`'s locale file), +/// not the display label. /// Ingredients are reference data (like Diet/Allergy): seeded, never /// created/edited/deleted through the API. /// Not in the original spec doc — supermarket-aisle grouping ("rayons") so @@ -424,7 +432,7 @@ enum IngredientIcon { model Ingredient { id Int @id @default(autoincrement()) - name String @unique + key String @unique icon IngredientIcon @default(JAR) category IngredientCategory @default(EPICERIE_SECHE) subcategory IngredientSubcategory @default(AUTRES) diff --git a/apps/api/scripts/generate-catalog-i18n.ts b/apps/api/scripts/generate-catalog-i18n.ts new file mode 100644 index 0000000..fc58a05 --- /dev/null +++ b/apps/api/scripts/generate-catalog-i18n.ts @@ -0,0 +1,99 @@ +/** + * 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`): derives every row's slug `key` from its + * French name (see `slugify.ts`), fails loudly on any collision, and + * regenerates `apps/web/src/locales/fr/translation.json`'s + * `catalog.{diets,allergens,ingredients}` sections (key -> French label), + * merged in without touching the rest of the file. + * + * Also (re-)writes `backfill.sql` alongside itself — the `UPDATE ... SET + * key = ...` statements a migration adding a brand new item needs to carry + * forward, in case that ever happens again; the one for this refactor's own + * migration (`prisma/migrations/20260818190000_catalog_labels_to_keys/`) + * was generated once and copied in by hand, and `backfill.sql` itself is + * gitignored scratch output, not the source of truth. + * + * 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 { ALLERGENS, DIETS, INGREDIENT_GROUPS } from "../src/db/reference-seed-data.js"; +import { slugify } from "../src/utils/slugify.js"; + +const here = fileURLToPath(new URL(".", import.meta.url)); + +function toKeyLabelMap(labels: string[]): Record { + const map: Record = {}; + const seenKeys = new Map(); + for (const label of labels) { + const key = slugify(label); + const clashingLabel = seenKeys.get(key); + if (clashingLabel !== undefined && clashingLabel !== label) { + throw new Error(`Slug collision: "${clashingLabel}" and "${label}" both slugify to "${key}"`); + } + seenKeys.set(key, label); + map[key] = label; + } + return map; +} + +const dietLabels = DIETS; +const allergenLabels = ALLERGENS.map((a) => a.name); +const ingredientLabels = INGREDIENT_GROUPS.flatMap((g) => g.items.map((i) => i.name)); + +const diets = toKeyLabelMap(dietLabels); +const allergens = toKeyLabelMap(allergenLabels); +const ingredients = toKeyLabelMap(ingredientLabels); + +console.log( + `diets: ${Object.keys(diets).length}, allergens: ${Object.keys(allergens).length}, ingredients: ${Object.keys(ingredients).length}`, +); + +// --- Merge into the fr locale file ----------------------------------------- +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}`); + +// --- Emit the migration's backfill SQL -------------------------------------- +function escapeSql(value: string): string { + return value.replace(/'/g, "''"); +} + +// The migration this feeds renames each table's "name" column to "key" +// *before* running this backfill — so by the time these UPDATEs run, the +// "key" column still holds the old French label value (just under its new +// column name), which is exactly what the WHERE clause below matches on. +const dietUpdates = Object.entries(diets) + .map(([key, label]) => `UPDATE "diet" SET "key" = '${escapeSql(key)}' WHERE "key" = '${escapeSql(label)}';`) + .join("\n"); +const categoryUpdates = Object.entries(allergens) + .map( + ([key, label]) => `UPDATE "category" SET "key" = '${escapeSql(key)}' WHERE "key" = '${escapeSql(label)}';`, + ) + .join("\n"); +const ingredientUpdates = Object.entries(ingredients) + .map( + ([key, label]) => + `UPDATE "ingredients" SET "key" = '${escapeSql(key)}' WHERE "key" = '${escapeSql(label)}';`, + ) + .join("\n"); + +const sql = `-- Auto-generated by scripts/generate-catalog-i18n.ts — do not hand-edit. +-- Backfills the "key" column (just renamed from "name" by this migration's +-- preceding statement, so it still holds the old French label) to its slug +-- value, for every row already seeded in a database this migration runs +-- against. A fresh database has none of these rows yet (the seed script +-- inserts by "key" from the start), so this is a no-op there. + +${dietUpdates} + +${categoryUpdates} + +${ingredientUpdates} +`; +writeFileSync(here + "backfill.sql", sql); +console.log(`wrote ${here}backfill.sql`); diff --git a/apps/api/src/db/reference-seed-data.ts b/apps/api/src/db/reference-seed-data.ts index d9de8d5..52e76fa 100644 --- a/apps/api/src/db/reference-seed-data.ts +++ b/apps/api/src/db/reference-seed-data.ts @@ -5,10 +5,14 @@ import type { IngredientSubcategory, PrismaClient, } from "@prisma/client"; +import { slugify } from "../utils/slugify.js"; // Short, optional-to-pick regime list — `UserProfile.dietId` stays -// nullable, this is not meant to be exhaustive. -const DIETS = ["Omnivore", "Végétarien", "Végan", "Pescétarien", "Sans gluten"]; +// nullable, this is not meant to be exhaustive. Exported for +// `apps/api/scripts/generate-catalog-i18n.ts` (one-off, regenerates +// `apps/web`'s `locales/fr/translation.json` `catalog.diets` section from +// this same list). +export const DIETS = ["Omnivore", "Végétarien", "Végan", "Pescétarien", "Sans gluten"]; // The 14 allergens EU Regulation 1169/2011 (Annex II) requires food // businesses to declare — a standard, defensible reference list rather than @@ -17,7 +21,7 @@ const DIETS = ["Omnivore", "Végétarien", "Végan", "Pescétarien", "Sans glute // sensitivity) per the product decision discussed in chat: only Gluten and // Sulfites are commonly-recognized intolerances among the 14; the rest are // true allergens. -const ALLERGENS: Array<{ name: string; kind: AllergenKind }> = [ +export const ALLERGENS: Array<{ name: string; kind: AllergenKind }> = [ { name: "Gluten", kind: "INTOLERANCE" }, { name: "Crustacés", kind: "ALLERGY" }, { name: "Œufs", kind: "ALLERGY" }, @@ -92,7 +96,7 @@ interface IngredientSeed { // repeating the same values on hundreds of items; only a group's // exceptions (a fish-based stock inside "bases", a cheese inside "produits // laitiers"…) need a per-item override. -const INGREDIENT_GROUPS: Array<{ +export const INGREDIENT_GROUPS: Array<{ category: IngredientCategory; subcategory: IngredientSubcategory; defaultDiets: string[]; @@ -879,27 +883,36 @@ const INGREDIENTS: Array< /** * Populates the `Diet`/`Category`/`Allergy` reference tables. Idempotent * (safe to call against a database that already has this data — upserts by - * `name`, both `@unique`) — used both by `prisma/seed.ts` (the CLI entry + * `key`, all `@unique`) — used both by `prisma/seed.ts` (the CLI entry * point, `prisma db seed`) and by `test-support/reset-db.ts` (so every * test starts from the same realistic reference data the real app seeds, * not an empty table). + * + * Every `name` below (`DIETS`, `ALLERGENS`, `INGREDIENT_GROUPS`) is an + * *authoring* label, never written to the database or seen by a client — + * {@link slugify} derives each row's real, stable `key` from it once, up + * front. The database only ever stores that slug; the French label itself + * lives in `apps/web`'s `locales/fr/translation.json` (`catalog.*` + * namespace, kept in sync by `scripts/generate-catalog-i18n.ts`). */ export async function seedReferenceData(prisma: PrismaClient): Promise { for (const name of DIETS) { - await prisma.diet.upsert({ where: { name }, update: {}, create: { name } }); + const key = slugify(name); + await prisma.diet.upsert({ where: { key }, update: {}, create: { key } }); } - // `Allergy` itself carries no `name` — it's the selectable instance of a - // named `Category` (see schema.prisma) — so seeding an allergen means one - // Category (upserted by name) plus exactly one Allergy row under it, + // `Allergy` itself carries no `key` — it's the selectable instance of a + // keyed `Category` (see schema.prisma) — so seeding an allergen means one + // Category (upserted by key) plus exactly one Allergy row under it, // created only the first time. `update: { kind }` (not `{}`) — a reseed // must correct `kind` on an already-existing category if the // classification above ever changes, not just skip it. for (const { name, kind } of ALLERGENS) { + const key = slugify(name); const category = await prisma.category.upsert({ - where: { name }, + where: { key }, update: { kind }, - create: { name, kind }, + create: { key, kind }, }); const existing = await prisma.allergy.findFirst({ where: { categoryId: category.id } }); if (!existing) { @@ -916,17 +929,18 @@ export async function seedReferenceData(prisma: PrismaClient): Promise { // test-suite case) that's zero updates, on a real re-deploy it's however // many rows were edited in code since the last deploy, never the full // list. + const ingredientKeys = INGREDIENTS.map((i) => slugify(i.name)); const existingIngredients = await prisma.ingredient.findMany({ - where: { name: { in: INGREDIENTS.map((i) => i.name) } }, - select: { id: true, name: true, icon: true, category: true, subcategory: true }, + where: { key: { in: ingredientKeys } }, + select: { id: true, key: true, icon: true, category: true, subcategory: true }, }); - const existingByName = new Map(existingIngredients.map((i) => [i.name, i])); + const existingByKey = new Map(existingIngredients.map((i) => [i.key, i])); - const missingIngredients = INGREDIENTS.filter((i) => !existingByName.has(i.name)); + const missingIngredients = INGREDIENTS.filter((i) => !existingByKey.has(slugify(i.name))); if (missingIngredients.length > 0) { await prisma.ingredient.createMany({ data: missingIngredients.map(({ name, icon, category, subcategory }) => ({ - name, + key: slugify(name), icon, category, subcategory, @@ -935,7 +949,7 @@ export async function seedReferenceData(prisma: PrismaClient): Promise { } const changed = INGREDIENTS.filter((i) => { - const existing = existingByName.get(i.name); + const existing = existingByKey.get(slugify(i.name)); return ( existing && (existing.icon !== i.icon || @@ -944,29 +958,29 @@ export async function seedReferenceData(prisma: PrismaClient): Promise { ); }); for (const { name, icon, category, subcategory } of changed) { - await prisma.ingredient.update({ where: { name }, data: { icon, category, subcategory } }); + await prisma.ingredient.update({ where: { key: slugify(name) }, data: { icon, category, subcategory } }); } // Re-resolve every ingredient's id (existing + just-created) and every - // allergy's id (by its category name) once, then link them in a single + // allergy's id (by its category key) once, then link them in a single // bulk insert — same "re-derived every time, not upserted per link" // reasoning as before for `IngredientAllergy` (it has no natural per-row // identity to upsert against), just batched instead of looped. const allIngredients = await prisma.ingredient.findMany({ - where: { name: { in: INGREDIENTS.map((i) => i.name) } }, - select: { id: true, name: true }, + where: { key: { in: ingredientKeys } }, + select: { id: true, key: true }, }); - const ingredientIdByName = new Map(allIngredients.map((i) => [i.name, i.id])); + const ingredientIdByKey = new Map(allIngredients.map((i) => [i.key, i.id])); const allergies = await prisma.allergy.findMany({ include: { category: true } }); - const allergyIdByCategoryName = new Map(allergies.map((a) => [a.category.name, a.id])); + const allergyIdByCategoryKey = new Map(allergies.map((a) => [a.category.key, a.id])); const links: Array<{ ingredientId: number; allergyId: number }> = []; for (const { name, allergenNames } of INGREDIENTS) { - const ingredientId = ingredientIdByName.get(name); + const ingredientId = ingredientIdByKey.get(slugify(name)); if (ingredientId === undefined) continue; for (const allergenName of allergenNames) { - const allergyId = allergyIdByCategoryName.get(allergenName); + const allergyId = allergyIdByCategoryKey.get(slugify(allergenName)); if (allergyId !== undefined) links.push({ ingredientId, allergyId }); } } @@ -978,14 +992,14 @@ export async function seedReferenceData(prisma: PrismaClient): Promise { // `dietNames` (item override, falling back to its group's `defaultDiets` // in the `INGREDIENTS` flatten step) instead of `allergenNames`. const diets = await prisma.diet.findMany(); - const dietIdByName = new Map(diets.map((d) => [d.name, d.id])); + const dietIdByKey = new Map(diets.map((d) => [d.key, d.id])); const dietLinks: Array<{ ingredientId: number; dietId: number }> = []; for (const { name, dietNames } of INGREDIENTS) { - const ingredientId = ingredientIdByName.get(name); + const ingredientId = ingredientIdByKey.get(slugify(name)); if (ingredientId === undefined) continue; for (const dietName of dietNames) { - const dietId = dietIdByName.get(dietName); + const dietId = dietIdByKey.get(slugify(dietName)); if (dietId !== undefined) dietLinks.push({ ingredientId, dietId }); } } diff --git a/apps/api/src/modules/recipe/recipe.service.ts b/apps/api/src/modules/recipe/recipe.service.ts index 0e80a1f..b79f4e3 100644 --- a/apps/api/src/modules/recipe/recipe.service.ts +++ b/apps/api/src/modules/recipe/recipe.service.ts @@ -39,21 +39,21 @@ type IngredientWithDetails = RecipeWithDetails["ingredients"][number]["ingredien function toIngredientView(ingredient: IngredientWithDetails): IngredientView { return { id: ingredient.id, - name: ingredient.name, + key: ingredient.key, icon: ingredient.icon, category: ingredient.category, subcategory: ingredient.subcategory, allergens: ingredient.allergies.map(({ allergy }) => ({ id: allergy.id, - name: allergy.category.name, + key: allergy.category.key, kind: allergy.category.kind, })), - diets: ingredient.diets.map(({ diet }) => ({ id: diet.id, name: diet.name })), + diets: ingredient.diets.map(({ diet }) => ({ id: diet.id, key: diet.key })), }; } -function toDietView(diet: { id: number; name: string }): DietView { - return { id: diet.id, name: diet.name }; +function toDietView(diet: { id: number; key: string }): DietView { + return { id: diet.id, key: diet.key }; } /** Deduplicates allergens (by id) across every ingredient of a recipe, for the aggregated "contains" badge — see {@link RecipeSummaryView.allergens}. */ diff --git a/apps/api/src/modules/reference/reference.service.ts b/apps/api/src/modules/reference/reference.service.ts index 832f770..247d4d2 100644 --- a/apps/api/src/modules/reference/reference.service.ts +++ b/apps/api/src/modules/reference/reference.service.ts @@ -1,35 +1,43 @@ import type { AllergyView, DietView, IngredientView } from "@batch-cooking/shared"; import { prisma } from "../../db/prisma.js"; -/** All reference dietary regimes, alphabetically — small, static list (see prisma/seed.ts). */ +/** + * All reference dietary regimes, ordered by `key` — small, static list (see + * prisma/seed.ts). `key` is a stable slug, not the display label (see + * {@link DietView}), so this is an alphabetical-by-slug order rather than a + * true French alphabetical one — close enough for a 5-item list, and the + * server has no other order to offer now that the label itself only exists + * client-side (`apps/web`'s `locales/fr/translation.json`). + */ export async function getDiets(): Promise { - return prisma.diet.findMany({ orderBy: { name: "asc" } }); + return prisma.diet.findMany({ orderBy: { key: "asc" } }); } /** - * All reference allergens, alphabetically. `Allergy` carries no `name` of - * its own — it's the selectable instance of a named `Category` (see - * schema.prisma) — so this resolves each allergen's display name from its - * category and flattens the split away for callers. + * All reference allergens, ordered by key (see {@link getDiets} for why key, + * not label). `Allergy` carries no `key` of its own — it's the selectable + * instance of a keyed `Category` (see schema.prisma) — so this resolves + * each allergen's key from its category and flattens the split away for + * callers. */ export async function getAllergies(): Promise { const allergies = await prisma.allergy.findMany({ - include: { category: { select: { name: true, kind: true } } }, - orderBy: { category: { name: "asc" } }, + include: { category: { select: { key: true, kind: true } } }, + orderBy: { category: { key: "asc" } }, }); return allergies.map((allergy) => ({ id: allergy.id, - name: allergy.category.name, + key: allergy.category.key, kind: allergy.category.kind, })); } /** - * All reference ingredients, alphabetically, each resolved to its allergens - * (see `IngredientAllergy` in schema.prisma) and compatible diet regimes - * (see `IngredientDiet`) — same aplattening approach as {@link getAllergies}. - * Ingredients with no linked allergen/diet come back with `allergens: []`/ - * `diets: []`. + * All reference ingredients, ordered by key (see {@link getDiets} for why), + * each resolved to its allergens (see `IngredientAllergy` in schema.prisma) + * and compatible diet regimes (see `IngredientDiet`) — same flattening + * approach as {@link getAllergies}. Ingredients with no linked + * allergen/diet come back with `allergens: []`/`diets: []`. */ export async function getIngredients(): Promise { const ingredients = await prisma.ingredient.findMany({ @@ -37,19 +45,19 @@ export async function getIngredients(): Promise { allergies: { include: { allergy: { include: { category: true } } } }, diets: { include: { diet: true } }, }, - orderBy: { name: "asc" }, + orderBy: { key: "asc" }, }); return ingredients.map((ingredient) => ({ id: ingredient.id, - name: ingredient.name, + key: ingredient.key, icon: ingredient.icon, category: ingredient.category, subcategory: ingredient.subcategory, allergens: ingredient.allergies.map(({ allergy }) => ({ id: allergy.id, - name: allergy.category.name, + key: allergy.category.key, kind: allergy.category.kind, })), - diets: ingredient.diets.map(({ diet }) => ({ id: diet.id, name: diet.name })), + diets: ingredient.diets.map(({ diet }) => ({ id: diet.id, key: diet.key })), })); } diff --git a/apps/api/src/utils/slugify.ts b/apps/api/src/utils/slugify.ts new file mode 100644 index 0000000..9649aed --- /dev/null +++ b/apps/api/src/utils/slugify.ts @@ -0,0 +1,26 @@ +/** + * 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, ""); +} diff --git a/apps/api/test/profile.test.ts b/apps/api/test/profile.test.ts index b2247ef..4ea42b3 100644 --- a/apps/api/test/profile.test.ts +++ b/apps/api/test/profile.test.ts @@ -4,6 +4,7 @@ import { expect } from "chai"; import request from "supertest"; import { createApp } from "../src/app.js"; import { prisma } from "../src/db/prisma.js"; +import { slugify } from "../src/utils/slugify.js"; import { resetDatabase } from "../test-support/reset-db.js"; function buildSignupPayload(): SignupInput { @@ -39,7 +40,7 @@ describe("Profile", () => { it("sets the profile's regime to a valid, seeded diet", async () => { const agent = request.agent(app); await agent.post("/auth/signup").send(buildSignupPayload()); - const diet = await prisma.diet.findFirstOrThrow({ where: { name: "Végétarien" } }); + const diet = await prisma.diet.findFirstOrThrow({ where: { key: slugify("Végétarien") } }); const res = await agent.patch("/profile/diet").send({ dietId: diet.id }); @@ -50,7 +51,7 @@ describe("Profile", () => { it("clears the regime when dietId is null", async () => { const agent = request.agent(app); await agent.post("/auth/signup").send(buildSignupPayload()); - const diet = await prisma.diet.findFirstOrThrow({ where: { name: "Végan" } }); + const diet = await prisma.diet.findFirstOrThrow({ where: { key: slugify("Végan") } }); await agent.patch("/profile/diet").send({ dietId: diet.id }); const res = await agent.patch("/profile/diet").send({ dietId: null }); @@ -83,8 +84,8 @@ describe("Profile", () => { const agent = request.agent(app); await agent.post("/auth/signup").send(buildSignupPayload()); const allergies = await prisma.allergy.findMany({ include: { category: true } }); - const peanuts = allergies.find((a) => a.category.name === "Arachides"); - const gluten = allergies.find((a) => a.category.name === "Gluten"); + const peanuts = allergies.find((a) => a.category.key === slugify("Arachides")); + const gluten = allergies.find((a) => a.category.key === slugify("Gluten")); if (!peanuts || !gluten) throw new Error("expected seeded allergens missing"); const initial = await agent.get("/profile/allergies"); @@ -104,8 +105,8 @@ describe("Profile", () => { const agent = request.agent(app); await agent.post("/auth/signup").send(buildSignupPayload()); const allergies = await prisma.allergy.findMany({ include: { category: true } }); - const peanuts = allergies.find((a) => a.category.name === "Arachides"); - const gluten = allergies.find((a) => a.category.name === "Gluten"); + const peanuts = allergies.find((a) => a.category.key === slugify("Arachides")); + const gluten = allergies.find((a) => a.category.key === slugify("Gluten")); if (!peanuts || !gluten) throw new Error("expected seeded allergens missing"); await agent.patch("/profile/allergies").send({ allergyIds: [peanuts.id] }); @@ -140,8 +141,8 @@ describe("Profile", () => { it("starts empty, then reflects a saved selection", async () => { const agent = request.agent(app); await agent.post("/auth/signup").send(buildSignupPayload()); - const tomate = await prisma.ingredient.findFirstOrThrow({ where: { name: "Tomate" } }); - const oignon = await prisma.ingredient.findFirstOrThrow({ where: { name: "Oignon" } }); + const tomate = await prisma.ingredient.findFirstOrThrow({ where: { key: slugify("Tomate") } }); + const oignon = await prisma.ingredient.findFirstOrThrow({ where: { key: slugify("Oignon") } }); const initial = await agent.get("/profile/disliked-ingredients"); expect(initial.body).to.deep.equal([]); @@ -159,8 +160,8 @@ describe("Profile", () => { it("replaces (not merges) the previous selection", async () => { const agent = request.agent(app); await agent.post("/auth/signup").send(buildSignupPayload()); - const tomate = await prisma.ingredient.findFirstOrThrow({ where: { name: "Tomate" } }); - const oignon = await prisma.ingredient.findFirstOrThrow({ where: { name: "Oignon" } }); + const tomate = await prisma.ingredient.findFirstOrThrow({ where: { key: slugify("Tomate") } }); + const oignon = await prisma.ingredient.findFirstOrThrow({ where: { key: slugify("Oignon") } }); await agent .patch("/profile/disliked-ingredients") diff --git a/apps/api/test/recipe.test.ts b/apps/api/test/recipe.test.ts index 0af5e82..a00ef65 100644 --- a/apps/api/test/recipe.test.ts +++ b/apps/api/test/recipe.test.ts @@ -5,6 +5,7 @@ import { expect } from "chai"; import request from "supertest"; import { createApp } from "../src/app.js"; import { prisma } from "../src/db/prisma.js"; +import { slugify } from "../src/utils/slugify.js"; import { resetDatabase } from "../test-support/reset-db.js"; /** See `auth.test.ts` — generated rather than hardcoded, no test fixture looks like a real person's data. */ @@ -19,9 +20,9 @@ function buildSignupPayload(): SignupInput { }; } -/** Resolves a reference ingredient's id by its `reference-seed-data.ts` name. */ +/** Resolves a reference ingredient's id by its `reference-seed-data.ts` French name (slugified to match its `key`). */ async function ingredientId(name: string): Promise { - const ingredient = await prisma.ingredient.findFirstOrThrow({ where: { name } }); + const ingredient = await prisma.ingredient.findFirstOrThrow({ where: { key: slugify(name) } }); return ingredient.id; } @@ -163,7 +164,7 @@ describe("Recipes", () => { const { agent } = await signup(); const tomate = await ingredientId("Tomate"); const oeuf = await ingredientId("Œuf"); - const vegetarien = await prisma.diet.findFirstOrThrow({ where: { name: "Végétarien" } }); + const vegetarien = await prisma.diet.findFirstOrThrow({ where: { key: "vegetarien" } }); const res = await agent.post("/recipes").send({ name: "Omelette provençale", @@ -183,8 +184,8 @@ describe("Recipes", () => { res.body.steps.map((s: { description: string; order: number }) => s.order), ).to.deep.equal([0, 1]); // Allergens aggregated across ingredients — "Œuf" carries "Œufs". - expect(res.body.allergens.map((a: { name: string }) => a.name)).to.include("Œufs"); - expect(res.body.diets.map((d: { name: string }) => d.name)).to.deep.equal(["Végétarien"]); + expect(res.body.allergens.map((a: { key: string }) => a.key)).to.include("oeufs"); + expect(res.body.diets.map((d: { key: string }) => d.key)).to.deep.equal(["vegetarien"]); }); it("defaults to PERSONAL visibility, and stamps the author's current household", async () => { @@ -279,7 +280,7 @@ describe("Recipes", () => { expect(res.status).to.equal(200); expect(res.body.name).to.equal("Salade"); - expect(res.body.ingredients[0].ingredient.name).to.equal("Tomate"); + expect(res.body.ingredients[0].ingredient.key).to.equal("tomate"); expect(res.body.isFavorite).to.equal(false); }); @@ -364,7 +365,7 @@ describe("Recipes", () => { expect(res.body.name).to.equal("Salade composée"); expect(res.body.visibility).to.equal("PUBLIC"); expect(res.body.ingredients).to.have.length(1); - expect(res.body.ingredients[0].ingredient.name).to.equal("Oignon"); + expect(res.body.ingredients[0].ingredient.key).to.equal("oignon"); expect(res.body.steps).to.have.length(2); }); diff --git a/apps/api/test/reference.test.ts b/apps/api/test/reference.test.ts index 47e4e80..64a0a80 100644 --- a/apps/api/test/reference.test.ts +++ b/apps/api/test/reference.test.ts @@ -3,6 +3,7 @@ import request from "supertest"; import { createApp } from "../src/app.js"; import { prisma } from "../src/db/prisma.js"; import { resetDatabase } from "../test-support/reset-db.js"; +import { slugify } from "../src/utils/slugify.js"; describe("Reference data", () => { const app = createApp(); @@ -21,28 +22,29 @@ describe("Reference data", () => { expect(res.status).to.equal(200); expect(res.body).to.have.length(5); - expect(res.body.map((d: { name: string }) => d.name)).to.include("Végétarien"); - expect(res.body[0]).to.have.keys(["id", "name"]); + expect(res.body.map((d: { key: string }) => d.key)).to.include(slugify("Végétarien")); + expect(res.body[0]).to.have.keys(["id", "key"]); }); }); describe("GET /reference/allergies", () => { - it("returns the seeded allergens with their name resolved, no session required", async () => { + it("returns the seeded allergens with their key resolved, no session required", async () => { const res = await request(app).get("/reference/allergies"); expect(res.status).to.equal(200); expect(res.body).to.have.length(14); - expect(res.body.map((a: { name: string }) => a.name)).to.include("Arachides"); - expect(res.body[0]).to.have.keys(["id", "name", "kind"]); + expect(res.body.map((a: { key: string }) => a.key)).to.include(slugify("Arachides")); + expect(res.body[0]).to.have.keys(["id", "key", "kind"]); }); it("classifies Gluten and Sulfites as intolerances, the rest as allergies", async () => { const res = await request(app).get("/reference/allergies"); - const byName = (name: string) => res.body.find((a: { name: string }) => a.name === name); - expect(byName("Gluten").kind).to.equal("INTOLERANCE"); - expect(byName("Sulfites").kind).to.equal("INTOLERANCE"); - expect(byName("Arachides").kind).to.equal("ALLERGY"); + const byKey = (name: string) => + res.body.find((a: { key: string }) => a.key === slugify(name)); + expect(byKey("Gluten").kind).to.equal("INTOLERANCE"); + expect(byKey("Sulfites").kind).to.equal("INTOLERANCE"); + expect(byKey("Arachides").kind).to.equal("ALLERGY"); expect(res.body.filter((a: { kind: string }) => a.kind === "INTOLERANCE")).to.have.length(2); }); }); @@ -53,10 +55,10 @@ describe("Reference data", () => { expect(res.status).to.equal(200); expect(res.body.length).to.be.greaterThan(0); - expect(res.body.map((i: { name: string }) => i.name)).to.include("Tomate"); + expect(res.body.map((i: { key: string }) => i.key)).to.include(slugify("Tomate")); expect(res.body[0]).to.have.keys([ "id", - "name", + "key", "icon", "category", "subcategory", @@ -68,9 +70,12 @@ describe("Reference data", () => { it("resolves each ingredient's linked allergens, empty for one with none", async () => { const res = await request(app).get("/reference/ingredients"); - const byName = (name: string) => res.body.find((i: { name: string }) => i.name === name); - expect(byName("Œuf").allergens.map((a: { name: string }) => a.name)).to.include("Œufs"); - expect(byName("Tomate").allergens).to.deep.equal([]); + const byKey = (name: string) => + res.body.find((i: { key: string }) => i.key === slugify(name)); + expect(byKey("Œuf").allergens.map((a: { key: string }) => a.key)).to.include( + slugify("Œufs"), + ); + expect(byKey("Tomate").allergens).to.deep.equal([]); }); }); }); diff --git a/apps/web/cypress/e2e/recipes.cy.ts b/apps/web/cypress/e2e/recipes.cy.ts new file mode 100644 index 0000000..96556e6 --- /dev/null +++ b/apps/web/cypress/e2e/recipes.cy.ts @@ -0,0 +1,237 @@ +// Mocks the API via cy.intercept — see auth.cy.ts for the rationale (no +// live backend in this CI job; apps/api's own Mocha/Cucumber suites cover +// real API behavior against a real database). + +const authenticatedProfile = { + id: 1, + firstName: "Alice", + lastName: "Martin", + email: "alice@example.com", + tokenVersion: 0, + houseId: 1, + dietId: null, +}; + +const vegetarien = { id: 1, name: "Végétarien" }; +const gluten = { id: 1, name: "Gluten", kind: "INTOLERANCE" }; +const oeufs = { id: 2, name: "Œufs", kind: "ALLERGY" }; + +const ratatouille = { + id: 1, + name: "Ratatouille", + description: null, + picture: null, + authorId: 1, + visibility: "PERSONAL", + allergens: [], + diets: [vegetarien], + isFavorite: true, +}; + +const omelette = { + id: 2, + name: "Omelette", + description: null, + picture: null, + authorId: 1, + visibility: "PERSONAL", + allergens: [oeufs], + diets: [], + isFavorite: false, +}; + +const omeletteDetail = { + ...omelette, + description: "Une omelette toute simple.", + ingredients: [ + { + ingredient: { + id: 10, + name: "Œuf", + icon: "EGG", + category: "CREMERIE_FROMAGE", + subcategory: "OEUFS", + allergens: [oeufs], + diets: [], + }, + quantity: 3, + unit: "unité", + }, + ], + steps: [ + { id: 1, description: "Battre les œufs.", picture: null, order: 1 }, + { id: 2, description: "Cuire à la poêle.", picture: null, order: 2 }, + ], +}; + +function interceptAuth() { + cy.intercept("GET", "**/auth/me", { statusCode: 200, body: authenticatedProfile }); + cy.intercept("GET", "**/profile/disliked-ingredients", { statusCode: 200, body: [] }); +} + +describe("Recipe catalog", () => { + beforeEach(() => { + interceptAuth(); + }); + + it("defaults to the Favoris tab and lists its recipes", () => { + cy.intercept("GET", /\/recipes\?/, (req) => { + expect(req.url).to.include("tab=favoris"); + req.reply({ statusCode: 200, body: [ratatouille] }); + }).as("listRecipes"); + + cy.visit("/recettes"); + cy.wait("@listRecipes"); + + cy.contains("h1", "Recettes").should("be.visible"); + cy.get(".recipe-tabs__tab.active").should("contain.text", "Favoris"); + cy.contains(".recipe-table__name", "Ratatouille").should("be.visible"); + // The favorited row carries the ★ fav-mark. + cy.contains(".recipe-table__name", "Ratatouille") + .find(".recipe-table__fav-mark") + .should("exist"); + cy.contains(".recipe-table__name", "Ratatouille") + .parents("tr") + .find(".diet-badge") + .should("contain.text", "Végétarien"); + }); + + it("shows the empty state when a tab has no recipes", () => { + cy.intercept("GET", /\/recipes\?/, { statusCode: 200, body: [] }); + + cy.visit("/recettes"); + + cy.contains("Aucune recette pour le moment.").should("be.visible"); + }); + + it("shows an error state when the catalog fails to load", () => { + cy.intercept("GET", /\/recipes\?/, { statusCode: 500, body: { code: 5000, message: "boom" } }); + + cy.visit("/recettes"); + + cy.contains(".recipes-page__status--error", "Impossible de charger").should("be.visible"); + }); + + it("switches tabs, re-fetching each one's own recipes", () => { + cy.intercept("GET", /\/recipes\?/, (req) => { + const tab = new URL(req.url).searchParams.get("tab"); + const body = tab === "perso" ? [omelette] : [ratatouille]; + req.reply({ statusCode: 200, body }); + }).as("listRecipes"); + + cy.visit("/recettes"); + cy.wait("@listRecipes"); + cy.contains(".recipe-table__name", "Ratatouille").should("be.visible"); + + // Not asserting the specific request URL here — React StrictMode (see + // main.tsx) double-invokes mount/update effects in dev, so this can + // legitimately fire twice; the rendered result converges either way + // (same reasoning as planning-page.cy.ts's week-navigation tests). + cy.contains(".recipe-tabs__tab", "Perso").click(); + cy.get(".recipe-tabs__tab.active").should("contain.text", "Perso"); + cy.contains(".recipe-table__name", "Omelette").should("be.visible"); + cy.contains(".recipe-table__name", "Ratatouille").should("not.exist"); + + // The disabled "Sources (bientôt)" placeholder never becomes active. + cy.contains(".recipe-tabs__tab", "Sources (bientôt)").should("be.disabled"); + }); + + it("searches within the active tab, debounced", () => { + cy.intercept("GET", /\/recipes\?/, (req) => { + const search = new URL(req.url).searchParams.get("search"); + req.reply({ statusCode: 200, body: search ? [omelette] : [ratatouille, omelette] }); + }).as("listRecipes"); + + cy.visit("/recettes"); + cy.wait("@listRecipes"); + cy.contains(".recipe-table__name", "Ratatouille").should("be.visible"); + + // Same "assert the rendered result, not the request count/URL" reasoning + // as the tab-switch test above — the 300ms debounce plus StrictMode's + // double-invoked effects make the exact number/order of requests an + // implementation detail, not something worth pinning down here. + cy.get(".recipes-page__search").type("Omel"); + cy.contains(".recipe-table__name", "Omelette").should("be.visible"); + cy.contains(".recipe-table__name", "Ratatouille").should("not.exist"); + }); + + it("opens a recipe's detail alongside the table when its row is selected", () => { + cy.intercept("GET", /\/recipes\?/, { statusCode: 200, body: [ratatouille, omelette] }); + cy.intercept("GET", "**/recipes/2", { statusCode: 200, body: omeletteDetail }).as("getRecipe"); + + cy.visit("/recettes"); + cy.contains(".recipe-table__name", "Omelette").click(); + cy.wait("@getRecipe"); + + cy.url().should("include", "/recettes/2"); + // The table stays mounted (master-detail, not a page navigation) — + // both rows are still visible next to the detail panel. + cy.contains(".recipe-table__name", "Ratatouille").should("be.visible"); + cy.get("tr.selected .recipe-table__name").should("contain.text", "Omelette"); + + cy.get(".recipe-detail-panel").within(() => { + cy.contains("h2", "Omelette").should("be.visible"); + cy.contains("Une omelette toute simple.").should("be.visible"); + cy.contains("Battre les œufs.").should("be.visible"); + cy.contains("Cuire à la poêle.").should("be.visible"); + }); + }); + + it("shows a not-found message for a selected id the API rejects", () => { + cy.intercept("GET", /\/recipes\?/, { statusCode: 200, body: [] }); + cy.intercept("GET", "**/recipes/999", { + statusCode: 404, + body: { code: 4041, message: "not found" }, + }); + + cy.visit("/recettes/999"); + + cy.contains(".recipe-detail-panel", "Cette recette n'existe pas.").should("be.visible"); + }); + + it("toggles a recipe's favorite from the detail panel and reflects it in the table", () => { + cy.intercept("GET", /\/recipes\?/, { statusCode: 200, body: [omelette] }); + cy.intercept("GET", "**/recipes/2", { statusCode: 200, body: omeletteDetail }); + cy.intercept("POST", "**/recipes/2/favorite", { statusCode: 204 }).as("favorite"); + + cy.visit("/recettes/2"); + + cy.contains(".recipe-table__name", "Omelette") + .find(".recipe-table__fav-mark") + .should("not.exist"); + + cy.get(".favorite-star-button").click(); + cy.wait("@favorite"); + + cy.get(".favorite-star-button").should("have.class", "is-favorite"); + cy.contains(".recipe-table__name", "Omelette") + .find(".recipe-table__fav-mark") + .should("exist"); + }); + + it("deletes a recipe after a two-step confirmation, then clears the selection", () => { + cy.intercept("GET", /\/recipes\?/, { statusCode: 200, body: [omelette] }); + cy.intercept("GET", "**/recipes/2", { statusCode: 200, body: omeletteDetail }); + cy.intercept("DELETE", "**/recipes/2", { statusCode: 204 }).as("deleteRecipe"); + + cy.visit("/recettes/2"); + cy.contains(".recipe-detail-panel", "Omelette").should("be.visible"); + + cy.contains(".recipe-detail-panel__danger-button", "Supprimer").click(); + cy.contains(".recipe-detail-panel__danger-button", "Confirmer la suppression").click(); + cy.wait("@deleteRecipe"); + + cy.url().should("match", /\/recettes\/?$/); + cy.contains(".recipe-table__name", "Omelette").should("not.exist"); + cy.contains("Sélectionnez une recette dans le tableau").should("be.visible"); + }); + + it("links the new-recipe button to the recipe form", () => { + cy.intercept("GET", /\/recipes\?/, { statusCode: 200, body: [] }); + + cy.visit("/recettes"); + + cy.contains(".recipes-page__new-button", "Nouvelle recette") + .should("have.attr", "href", "/recettes/nouvelle"); + }); +}); diff --git a/apps/web/src/features/profile/AllergySelect.tsx b/apps/web/src/features/profile/AllergySelect.tsx index 1782279..c886162 100644 --- a/apps/web/src/features/profile/AllergySelect.tsx +++ b/apps/web/src/features/profile/AllergySelect.tsx @@ -1,4 +1,5 @@ import type { AllergyView } from "@batch-cooking/shared"; +import { useTranslation } from "react-i18next"; import { CheckboxOption } from "../../components/ui/Checkbox"; import "./profile-forms.scss"; @@ -29,6 +30,8 @@ interface AllergySelectProps { * rationale as `DietSelect`. */ export function AllergySelect({ legend, allergies, value, onChange }: AllergySelectProps) { + const { t } = useTranslation(); + function toggle(id: number) { onChange(value.includes(id) ? value.filter((existing) => existing !== id) : [...value, id]); } @@ -45,7 +48,7 @@ export function AllergySelect({ legend, allergies, value, onChange }: AllergySel onChange={() => toggle(allergy.id)} className="allergy-select__option" > - {allergy.name} + {t(`catalog.allergens.${allergy.key}`)} ); })} diff --git a/apps/web/src/features/profile/DietSelect.tsx b/apps/web/src/features/profile/DietSelect.tsx index b1211a7..6d9c969 100644 --- a/apps/web/src/features/profile/DietSelect.tsx +++ b/apps/web/src/features/profile/DietSelect.tsx @@ -33,7 +33,7 @@ export function DietSelect({ diets, value, onChange }: DietSelectProps) { {diets.map((diet) => ( ))} diff --git a/apps/web/src/features/profile/DislikedIngredientsField.tsx b/apps/web/src/features/profile/DislikedIngredientsField.tsx index 6c9556f..4139855 100644 --- a/apps/web/src/features/profile/DislikedIngredientsField.tsx +++ b/apps/web/src/features/profile/DislikedIngredientsField.tsx @@ -54,7 +54,7 @@ export function DislikedIngredientsField({ - {ingredient.name} + {t(`catalog.ingredients.${ingredient.key}`)} diff --git a/apps/web/src/features/recipes/IngredientRow.tsx b/apps/web/src/features/recipes/IngredientRow.tsx index 9982658..7cb4a7e 100644 --- a/apps/web/src/features/recipes/IngredientRow.tsx +++ b/apps/web/src/features/recipes/IngredientRow.tsx @@ -28,7 +28,7 @@ export function IngredientRow({ - {ingredient.name} + {t(`catalog.ingredients.${ingredient.key}`)} {dislikedIngredients.map((ingredient) => (
  • - 🚫 {ingredient.name} + 🚫 {t(`catalog.ingredients.${ingredient.key}`)}
  • ))} diff --git a/apps/web/src/locales/fr/translation.json b/apps/web/src/locales/fr/translation.json index 60a11bb..1a181b0 100644 --- a/apps/web/src/locales/fr/translation.json +++ b/apps/web/src/locales/fr/translation.json @@ -297,5 +297,469 @@ "confirmButton": "Confirmer la suppression", "cancelButton": "Annuler" } + }, + "catalog": { + "diets": { + "omnivore": "Omnivore", + "vegetarien": "Végétarien", + "vegan": "Végan", + "pescetarien": "Pescétarien", + "sans_gluten": "Sans gluten" + }, + "allergens": { + "gluten": "Gluten", + "crustaces": "Crustacés", + "oeufs": "Œufs", + "poissons": "Poissons", + "arachides": "Arachides", + "soja": "Soja", + "lait": "Lait", + "fruits_a_coque": "Fruits à coque", + "celeri": "Céleri", + "moutarde": "Moutarde", + "graines_de_sesame": "Graines de sésame", + "sulfites": "Sulfites", + "lupin": "Lupin", + "mollusques": "Mollusques" + }, + "ingredients": { + "tomate": "Tomate", + "oignon": "Oignon", + "echalote": "Échalote", + "ail": "Ail", + "carotte": "Carotte", + "courgette": "Courgette", + "concombre": "Concombre", + "cornichons": "Cornichons", + "poivron": "Poivron", + "champignon": "Champignon", + "cepes": "Cèpes", + "aubergine": "Aubergine", + "brocoli": "Brocoli", + "chou_fleur": "Chou-fleur", + "chou_blanc": "Chou blanc", + "chou_rouge": "Chou rouge", + "chou_de_bruxelles": "Chou de Bruxelles", + "epinard": "Épinard", + "blette": "Blette", + "salade": "Salade", + "roquette": "Roquette", + "cresson": "Cresson", + "poireau": "Poireau", + "celeri": "Céleri", + "radis": "Radis", + "betterave": "Betterave", + "navet": "Navet", + "panais": "Panais", + "haricot_vert": "Haricot vert", + "petit_pois": "Petit pois", + "mais": "Maïs", + "artichaut": "Artichaut", + "fenouil": "Fenouil", + "endive": "Endive", + "potiron": "Potiron", + "butternut": "Butternut", + "asperge": "Asperge", + "avocat": "Avocat", + "pomme_de_terre": "Pomme de terre", + "patate_douce": "Patate douce", + "tomates_cerises": "Tomates cerises", + "pak_choi": "Pak-choï", + "germes_de_soja": "Germes de soja", + "shiitake": "Shiitake", + "daikon": "Daikon", + "piment_vert_frais": "Piment vert frais", + "citron": "Citron", + "citron_vert": "Citron vert", + "pomme": "Pomme", + "poire": "Poire", + "banane": "Banane", + "orange": "Orange", + "clementine": "Clémentine", + "pamplemousse": "Pamplemousse", + "fraise": "Fraise", + "framboise": "Framboise", + "myrtille": "Myrtille", + "mure": "Mûre", + "cerise": "Cerise", + "abricot": "Abricot", + "peche": "Pêche", + "prune": "Prune", + "raisin": "Raisin", + "melon": "Melon", + "pasteque": "Pastèque", + "ananas": "Ananas", + "mangue": "Mangue", + "kiwi": "Kiwi", + "figue": "Figue", + "datte": "Datte", + "litchi": "Litchi", + "grenade": "Grenade", + "rhubarbe": "Rhubarbe", + "coing": "Coing", + "basilic": "Basilic", + "persil": "Persil", + "thym": "Thym", + "romarin": "Romarin", + "laurier": "Laurier", + "ciboulette": "Ciboulette", + "coriandre_fraiche": "Coriandre fraîche", + "menthe": "Menthe", + "origan": "Origan", + "aneth": "Aneth", + "estragon": "Estragon", + "sarriette": "Sarriette", + "marjolaine": "Marjolaine", + "sauge": "Sauge", + "cerfeuil": "Cerfeuil", + "gingembre": "Gingembre", + "citronnelle": "Citronnelle", + "combava": "Combava", + "lapin": "Lapin", + "boeuf_hache": "Bœuf haché", + "steak_de_boeuf": "Steak de bœuf", + "roti_de_boeuf": "Rôti de bœuf", + "escalope_de_veau": "Escalope de veau", + "filet_mignon_de_porc": "Filet mignon de porc", + "cote_de_porc": "Côte de porc", + "agneau": "Agneau", + "gigot_d_agneau": "Gigot d'agneau", + "lardons": "Lardons", + "bacon": "Bacon", + "jambon_blanc": "Jambon blanc", + "jambon_cru": "Jambon cru", + "saucisse": "Saucisse", + "chorizo": "Chorizo", + "merguez": "Merguez", + "prosciutto": "Prosciutto", + "pancetta": "Pancetta", + "mortadelle": "Mortadelle", + "salami": "Salami", + "poulet": "Poulet", + "dinde": "Dinde", + "canard": "Canard", + "magret_de_canard": "Magret de canard", + "saumon": "Saumon", + "thon": "Thon", + "cabillaud": "Cabillaud", + "truite": "Truite", + "sardine": "Sardine", + "anchois": "Anchois", + "merlan": "Merlan", + "surimi": "Surimi", + "bar_loup_de_mer": "Bar (loup de mer)", + "dorade": "Dorade", + "sole": "Sole", + "turbot": "Turbot", + "merlu": "Merlu", + "colin": "Colin", + "lieu_noir": "Lieu noir", + "eglefin": "Églefin", + "maquereau": "Maquereau", + "hareng": "Hareng", + "rouget": "Rouget", + "raie": "Raie", + "lotte": "Lotte", + "fletan": "Flétan", + "espadon": "Espadon", + "carpe": "Carpe", + "brochet": "Brochet", + "perche": "Perche", + "tilapia": "Tilapia", + "panga": "Panga", + "saumon_fume": "Saumon fumé", + "poisson_seche": "Poisson séché", + "crevettes": "Crevettes", + "langoustines": "Langoustines", + "homard": "Homard", + "crabe": "Crabe", + "langouste": "Langouste", + "moules": "Moules", + "huitres": "Huîtres", + "saint_jacques": "Saint-Jacques", + "calamar": "Calamar", + "poulpe": "Poulpe", + "palourdes": "Palourdes", + "bulots": "Bulots", + "semoule": "Semoule", + "couscous": "Couscous", + "boulgour": "Boulgour", + "polenta": "Polenta", + "quinoa": "Quinoa", + "pates": "Pâtes", + "pates_completes": "Pâtes complètes", + "riz": "Riz", + "riz_basmati": "Riz basmati", + "riz_complet": "Riz complet", + "flocons_d_avoine": "Flocons d'avoine", + "spaghetti": "Spaghetti", + "penne": "Penne", + "tagliatelles": "Tagliatelles", + "lasagnes_feuilles": "Lasagnes (feuilles)", + "gnocchi": "Gnocchi", + "riz_arborio": "Riz arborio", + "nouilles_de_riz": "Nouilles de riz", + "nouilles_udon": "Nouilles udon", + "nouilles_soba": "Nouilles soba", + "nouilles_chinoises": "Nouilles chinoises", + "vermicelles_de_riz": "Vermicelles de riz", + "vermicelles_de_soja": "Vermicelles de soja", + "riz_gluant": "Riz gluant", + "riz_a_sushi": "Riz à sushi", + "riz_jasmin": "Riz jasmin", + "lentilles_vertes": "Lentilles vertes", + "lentilles_corail": "Lentilles corail", + "pois_chiches": "Pois chiches", + "haricots_blancs": "Haricots blancs", + "haricots_rouges": "Haricots rouges", + "haricots_noirs": "Haricots noirs", + "pois_casses": "Pois cassés", + "feves": "Fèves", + "edamame": "Edamame", + "haricots_pinto": "Haricots pinto", + "cacahuetes": "Cacahuètes", + "amandes": "Amandes", + "noix": "Noix", + "noisettes": "Noisettes", + "noix_de_cajou": "Noix de cajou", + "pistaches": "Pistaches", + "noix_de_pecan": "Noix de pécan", + "poudre_d_amande": "Poudre d'amande", + "pignons_de_pin": "Pignons de pin", + "graines_de_tournesol": "Graines de tournesol", + "graines_de_courge": "Graines de courge", + "noix_de_coco_rapee": "Noix de coco râpée", + "raisins_secs": "Raisins secs", + "pruneaux": "Pruneaux", + "abricots_secs": "Abricots secs", + "graines_de_sesame": "Graines de sésame", + "champignons_noirs": "Champignons noirs", + "algue_nori": "Algue nori", + "algue_wakame": "Algue wakamé", + "algue_kombu": "Algue kombu", + "pousses_de_bambou": "Pousses de bambou", + "chataignes_d_eau": "Châtaignes d'eau", + "pain": "Pain", + "pain_de_mie": "Pain de mie", + "pain_complet": "Pain complet", + "baguette": "Baguette", + "pain_de_seigle": "Pain de seigle", + "chapelure": "Chapelure", + "pain_a_burger": "Pain à burger", + "pain_brioche": "Pain brioché", + "pain_a_hot_dog": "Pain à hot-dog", + "pain_pita": "Pain pita", + "pain_bagel": "Pain bagel", + "naan": "Naan", + "pain_wrap": "Pain wrap", + "pain_viennois": "Pain viennois", + "pain_de_campagne": "Pain de campagne", + "pain_aux_cereales": "Pain aux céréales", + "petit_pain": "Petit pain", + "pain_suedois": "Pain suédois", + "pain_sans_gluten": "Pain sans gluten", + "biscotte": "Biscotte", + "croutons": "Croûtons", + "focaccia": "Focaccia", + "ciabatta": "Ciabatta", + "tortilla_de_mais": "Tortilla de maïs", + "tortilla_de_ble": "Tortilla de blé", + "pate_feuilletee": "Pâte feuilletée", + "pate_brisee": "Pâte brisée", + "pate_a_pizza": "Pâte à pizza", + "pate_a_tarte_sablee": "Pâte à tarte sablée", + "lait": "Lait", + "beurre": "Beurre", + "creme_fraiche": "Crème fraîche", + "creme_liquide": "Crème liquide", + "fromage": "Fromage", + "emmental": "Emmental", + "gruyere": "Gruyère", + "parmesan": "Parmesan", + "mozzarella": "Mozzarella", + "chevre_fromage": "Chèvre (fromage)", + "feta": "Feta", + "comte": "Comté", + "fromage_blanc": "Fromage blanc", + "mascarpone": "Mascarpone", + "yaourt": "Yaourt", + "burrata": "Burrata", + "ricotta": "Ricotta", + "pecorino": "Pecorino", + "gorgonzola": "Gorgonzola", + "cheddar": "Cheddar", + "oeuf": "Œuf", + "lait_de_coco": "Lait de coco", + "creme_de_coco": "Crème de coco", + "lait_d_amande": "Lait d'amande", + "lait_d_avoine": "Lait d'avoine", + "tofu": "Tofu", + "tofu_soyeux": "Tofu soyeux", + "herbes_de_provence": "Herbes de Provence", + "poivre_noir": "Poivre noir", + "paprika": "Paprika", + "piment_d_espelette": "Piment d'Espelette", + "piment_de_cayenne": "Piment de Cayenne", + "cumin": "Cumin", + "curry_poudre": "Curry (poudre)", + "curcuma": "Curcuma", + "cannelle": "Cannelle", + "muscade": "Muscade", + "safran": "Safran", + "clou_de_girofle": "Clou de girofle", + "vanille_gousse": "Vanille (gousse)", + "poivre_blanc": "Poivre blanc", + "poivre_rose": "Poivre rose", + "poivre_du_sichuan": "Poivre du Sichuan", + "paprika_fume": "Paprika fumé", + "piment_oiseau": "Piment oiseau", + "baies_de_genievre": "Baies de genièvre", + "anis_etoile_badiane": "Anis étoilé (badiane)", + "anis_vert": "Anis vert", + "graines_de_fenouil": "Graines de fenouil", + "sumac": "Sumac", + "nigelle": "Nigelle", + "quatre_epices": "Quatre épices", + "colombo_poudre": "Colombo (poudre)", + "baharat": "Baharat", + "raifort": "Raifort", + "sel_aux_herbes": "Sel aux herbes", + "sel_de_celeri": "Sel de céleri", + "fleur_de_sel": "Fleur de sel", + "sel": "Sel", + "cinq_epices": "Cinq épices", + "garam_masala": "Garam masala", + "graines_de_coriandre": "Graines de coriandre", + "cardamome": "Cardamome", + "fenugrec": "Fenugrec", + "piment_jalapeno": "Piment jalapeño", + "piment_chipotle": "Piment chipotle", + "piment_poblano": "Piment poblano", + "piment_habanero": "Piment habanero", + "ras_el_hanout": "Ras el hanout", + "za_atar": "Za'atar", + "sauce_soja": "Sauce soja", + "moutarde": "Moutarde", + "mayonnaise": "Mayonnaise", + "ketchup": "Ketchup", + "tabasco": "Tabasco", + "sauce_worcestershire": "Sauce Worcestershire", + "sauce_nuoc_mam": "Sauce nuoc-mâm", + "wasabi": "Wasabi", + "harissa": "Harissa", + "pate_de_curry": "Pâte de curry", + "beurre_de_cacahuete": "Beurre de cacahuète", + "moutarde_de_dijon": "Moutarde de Dijon", + "moutarde_a_l_ancienne": "Moutarde à l'ancienne", + "sauce_barbecue": "Sauce barbecue", + "sauce_tartare": "Sauce tartare", + "sauce_cocktail": "Sauce cocktail", + "sauce_bearnaise": "Sauce béarnaise", + "sauce_hollandaise": "Sauce hollandaise", + "sauce_bechamel": "Sauce béchamel", + "sauce_teriyaki": "Sauce teriyaki", + "sauce_ponzu": "Sauce ponzu", + "chimichurri": "Chimichurri", + "pesto_rouge_tomates_sechees": "Pesto rouge (tomates séchées)", + "pesto": "Pesto", + "sauce_huitre": "Sauce huître", + "sauce_hoisin": "Sauce hoisin", + "sauce_sriracha": "Sauce sriracha", + "sauce_sweet_chili": "Sauce sweet chili", + "miso": "Miso", + "pate_de_crevettes": "Pâte de crevettes", + "pate_de_curry_rouge_thai": "Pâte de curry rouge (thaï)", + "pate_de_curry_vert_thai": "Pâte de curry vert (thaï)", + "tahini": "Tahini", + "huile_d_olive": "Huile d'olive", + "huile_de_tournesol": "Huile de tournesol", + "huile_de_colza": "Huile de colza", + "huile_de_coco": "Huile de coco", + "huile_de_sesame": "Huile de sésame", + "vinaigre_de_cidre": "Vinaigre de cidre", + "vinaigre_blanc": "Vinaigre blanc", + "vinaigre_balsamique": "Vinaigre balsamique", + "capres": "Câpres", + "olives": "Olives", + "vin_blanc_cuisine": "Vin blanc (cuisine)", + "vin_rouge_cuisine": "Vin rouge (cuisine)", + "vinaigre_de_vin_rouge": "Vinaigre de vin rouge", + "vinaigre_de_vin_blanc": "Vinaigre de vin blanc", + "vinaigre_de_xeres": "Vinaigre de xérès", + "huile_de_noix": "Huile de noix", + "huile_de_noisette": "Huile de noisette", + "huile_d_arachide": "Huile d'arachide", + "huile_pimentee": "Huile pimentée", + "vinaigre_de_riz": "Vinaigre de riz", + "mirin": "Mirin", + "sake_cuisine": "Saké (cuisine)", + "jus_de_citron": "Jus de citron", + "jus_de_citron_vert": "Jus de citron vert", + "jus_d_orange": "Jus d'orange", + "jus_de_pomme": "Jus de pomme", + "jus_de_raisin": "Jus de raisin", + "jus_de_tomate": "Jus de tomate", + "jus_de_cranberry": "Jus de cranberry", + "cafe": "Café", + "the": "Thé", + "biere_cuisine": "Bière (cuisine)", + "cidre_cuisine": "Cidre (cuisine)", + "champagne_vin_petillant_cuisine": "Champagne / vin pétillant (cuisine)", + "porto_cuisine": "Porto (cuisine)", + "vin_jaune_cuisine": "Vin jaune (cuisine)", + "cognac": "Cognac", + "rhum": "Rhum", + "whisky": "Whisky", + "vodka": "Vodka", + "farine_de_ble": "Farine de blé", + "farine_complete": "Farine complète", + "farine_de_mais": "Farine de maïs", + "farine_de_sarrasin": "Farine de sarrasin", + "farine_de_riz": "Farine de riz", + "bouillon_cube_legumes": "Bouillon cube légumes", + "bouillon_cube_volaille": "Bouillon cube volaille", + "concentre_de_tomate": "Concentré de tomate", + "coulis_de_tomate": "Coulis de tomate", + "tomates_pelees_conserve": "Tomates pelées (conserve)", + "tomates_sechees": "Tomates séchées", + "fond_de_veau": "Fond de veau", + "fond_de_volaille": "Fond de volaille", + "bouillon_cube_boeuf": "Bouillon cube bœuf", + "bouillon_cube_poisson": "Bouillon cube poisson", + "bouillon_de_legumes": "Bouillon de légumes", + "bouillon_de_volaille": "Bouillon de volaille", + "bouillon_de_boeuf": "Bouillon de bœuf", + "court_bouillon": "Court-bouillon", + "dashi_bouillon_japonais": "Dashi (bouillon japonais)", + "bisque_de_crustaces": "Bisque de crustacés", + "farine_de_tapioca": "Farine de tapioca", + "masa_harina": "Masa harina", + "eau": "Eau", + "eau_gazeuse": "Eau gazeuse", + "eau_de_fleur_d_oranger": "Eau de fleur d'oranger", + "eau_de_rose": "Eau de rose", + "fumet_de_poisson": "Fumet de poisson", + "levure_boulangere": "Levure boulangère", + "levure_chimique": "Levure chimique", + "maizena": "Maïzena", + "farine_de_lupin": "Farine de lupin", + "gelatine": "Gélatine", + "bicarbonate_de_soude": "Bicarbonate de soude", + "fecule_de_pomme_de_terre": "Fécule de pomme de terre", + "sucre": "Sucre", + "miel": "Miel", + "sirop_d_erable": "Sirop d'érable", + "sucre_roux": "Sucre roux", + "sucre_glace": "Sucre glace", + "cassonade": "Cassonade", + "chocolat_noir": "Chocolat noir", + "chocolat_au_lait": "Chocolat au lait", + "chocolat_blanc": "Chocolat blanc", + "pepites_de_chocolat": "Pépites de chocolat", + "cacao_en_poudre": "Cacao en poudre", + "extrait_de_vanille": "Extrait de vanille", + "sucre_de_palme": "Sucre de palme", + "sirop_de_sucre_de_canne": "Sirop de sucre de canne" + } } } diff --git a/apps/web/src/pages/RecipesPage.tsx b/apps/web/src/pages/RecipesPage.tsx index 98f931f..0b9ffe3 100644 --- a/apps/web/src/pages/RecipesPage.tsx +++ b/apps/web/src/pages/RecipesPage.tsx @@ -31,7 +31,7 @@ export function RecipesPage() { const { id } = useParams<{ id: string }>(); const selectedId = id !== undefined ? Number(id) : null; - const [activeTab, setActiveTab] = useState("publique"); + const [activeTab, setActiveTab] = useState("favoris"); const [search, setSearch] = useState(""); const [debouncedSearch, setDebouncedSearch] = useState(""); const [listState, setListState] = useState({ status: "loading" }); diff --git a/apps/web/src/pages/settings/settings-pages.scss b/apps/web/src/pages/settings/settings-pages.scss index 0dee2b4..4ba5a24 100644 --- a/apps/web/src/pages/settings/settings-pages.scss +++ b/apps/web/src/pages/settings/settings-pages.scss @@ -8,7 +8,15 @@ // household/regime/allergies page was split in three. // ============================================================================= +// Centered column, not pinned to the content area's left edge — on a wide +// desktop viewport that left plenty of unused space beside these forms. +// Wide enough to give grids (the allergy checkboxes, the ingredient +// picker's category chips) real room, still well short of `.app-content`'s +// full width so line lengths/inputs don't stretch edge-to-edge. .settings-page { + max-width: 56rem; + margin: 0 auto; + &__status { color: var(--color-text-muted); font-size: var(--font-size-md); @@ -21,9 +29,10 @@ // Each setting lives in its own card, same surface treatment used // elsewhere (see .planning-table in HomePage.scss) — reads as a distinct, -// self-contained unit rather than one long form. +// self-contained unit rather than one long form. Width comes from +// `.settings-page` above, not its own cap, now that it's the one +// establishing the centered column every section sits in. .settings-page__section { - max-width: 32rem; margin-top: var(--space-lg); padding: var(--space-lg); background: var(--color-surface); @@ -131,12 +140,17 @@ background: color-mix(in srgb, var(--color-error) 8%, var(--color-surface)); } -.settings-page__danger-button { +// `button.` prefix (not just `.settings-page__danger-button`) to outrank +// the generic `.settings-page button` rule above — a class selector alone +// has lower specificity than "class + element", so without it the generic +// gray fill/border was winning and this never actually rendered red (same +// fix as `button.settings-page__link-button` below, same reason). +button.settings-page__danger-button { color: #fff; background: var(--color-error); border-color: var(--color-error); - &:hover { + &:hover:not(:disabled) { opacity: 0.9; } } diff --git a/packages/shared/src/types/reference.ts b/packages/shared/src/types/reference.ts index 49c86f8..5a004c7 100644 --- a/packages/shared/src/types/reference.ts +++ b/packages/shared/src/types/reference.ts @@ -1,10 +1,17 @@ /** * A dietary regime, as returned by `GET /reference/diets` — reference data * (`Diet`, seeded via `apps/api/prisma/seed.ts`), not user-specific. + * + * `key` is a stable slug (e.g. `"vegetarien"`), not a display label — it + * never changes once seeded, unlike the label it stands in for. Callers + * resolve the label themselves via i18n (`t(\`catalog.diets.${key}\`)`, + * `apps/web`'s `locales/fr/translation.json`), the same way + * `IngredientCategory`/`IngredientSubcategory` enum values already do (see + * `recipes.form.category.*`/`recipes.form.subcategory.*` in that file). */ export interface DietView { id: number; - name: string; + key: string; } /** @@ -18,17 +25,19 @@ export type AllergenKind = "ALLERGY" | "INTOLERANCE"; /** * A selectable allergen, as returned by `GET /reference/allergies`. * - * `name` is resolved server-side from the parent `Category` — the `Allergy` - * table itself carries no name of its own (see `schema.prisma`), so this - * flattens that split away: callers just get `{id, name}` and never need to - * know a `Category` exists underneath. `kind` groups allergens into two + * `key` is resolved server-side from the parent `Category` — the `Allergy` + * 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 + * know a `Category` exists underneath. Like {@link DietView.key}, it's a + * stable slug (e.g. `"gluten"`), not a display label — resolved via + * `t(\`catalog.allergens.${key}\`)`. `kind` groups allergens into two * separate lists client-side (`AllergySelect`, `apps/web`) rather than one * flat "allergies & intolérances" list — a single `PATCH /profile/allergies` * call still covers both, this is a display grouping only. */ export interface AllergyView { id: number; - name: string; + key: string; kind: AllergenKind; } @@ -164,10 +173,13 @@ export type IngredientIcon = (typeof INGREDIENT_ICONS)[number]; * catalog (`apps/web`'s recipe form and detail page) to pick ingredients and * to surface which allergens/regimes a recipe contains, aggregated across * its ingredients. + * + * `key` is a stable slug (e.g. `"tomate"`), not a display label — like + * {@link DietView.key}, resolved via `t(\`catalog.ingredients.${key}\`)`. */ export interface IngredientView { id: number; - name: string; + key: string; icon: IngredientIcon; category: IngredientCategory; subcategory: IngredientSubcategory;