feat(web,api): zone dangereuse rouge, préférences élargies, onglet favoris par défaut, e2e recettes, catalogue en uid+i18n
- 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 <noreply@anthropic.com>
This commit is contained in:
parent
2c474481cc
commit
1d9bb6d112
28 changed files with 1533 additions and 128 deletions
4
.gitignore
vendored
4
.gitignore
vendored
|
|
@ -149,4 +149,6 @@ Projet batch cooking.pdf
|
|||
tmp-mockups/
|
||||
|
||||
# IA
|
||||
.claude/
|
||||
.claude/
|
||||
# Scratch output of apps/api/scripts/generate-catalog-i18n.ts — regenerate on demand.
|
||||
apps/api/scripts/backfill.sql
|
||||
|
|
|
|||
|
|
@ -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<number[]> {
|
||||
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);
|
||||
},
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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<number> {
|
||||
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));
|
||||
},
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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}"`);
|
||||
},
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
@ -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.<key>` (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.<key>` 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.<key>` 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)
|
||||
|
|
|
|||
99
apps/api/scripts/generate-catalog-i18n.ts
Normal file
99
apps/api/scripts/generate-catalog-i18n.ts
Normal file
|
|
@ -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<string, string> {
|
||||
const map: Record<string, string> = {};
|
||||
const seenKeys = new Map<string, string>();
|
||||
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`);
|
||||
|
|
@ -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<void> {
|
||||
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<void> {
|
|||
// 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<void> {
|
|||
}
|
||||
|
||||
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<void> {
|
|||
);
|
||||
});
|
||||
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<void> {
|
|||
// `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 });
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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}. */
|
||||
|
|
|
|||
|
|
@ -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<DietView[]> {
|
||||
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<AllergyView[]> {
|
||||
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<IngredientView[]> {
|
||||
const ingredients = await prisma.ingredient.findMany({
|
||||
|
|
@ -37,19 +45,19 @@ export async function getIngredients(): Promise<IngredientView[]> {
|
|||
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 })),
|
||||
}));
|
||||
}
|
||||
|
|
|
|||
26
apps/api/src/utils/slugify.ts
Normal file
26
apps/api/src/utils/slugify.ts
Normal file
|
|
@ -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, "");
|
||||
}
|
||||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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<number> {
|
||||
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);
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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([]);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
237
apps/web/cypress/e2e/recipes.cy.ts
Normal file
237
apps/web/cypress/e2e/recipes.cy.ts
Normal file
|
|
@ -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");
|
||||
});
|
||||
});
|
||||
|
|
@ -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}`)}
|
||||
</CheckboxOption>
|
||||
);
|
||||
})}
|
||||
|
|
|
|||
|
|
@ -33,7 +33,7 @@ export function DietSelect({ diets, value, onChange }: DietSelectProps) {
|
|||
<option value="">{t("preferences.form.dietNone")}</option>
|
||||
{diets.map((diet) => (
|
||||
<option key={diet.id} value={diet.id}>
|
||||
{diet.name}
|
||||
{t(`catalog.diets.${diet.key}`)}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
|
|
|
|||
|
|
@ -54,7 +54,7 @@ export function DislikedIngredientsField({
|
|||
<span aria-hidden="true">
|
||||
<IngredientTypeIcon icon={ingredient.icon} />
|
||||
</span>
|
||||
{ingredient.name}
|
||||
{t(`catalog.ingredients.${ingredient.key}`)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => remove(ingredient.id)}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import type { AllergyView } from "@batch-cooking/shared";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import "./recipes.scss";
|
||||
|
||||
/**
|
||||
|
|
@ -10,6 +11,7 @@ import "./recipes.scss";
|
|||
* mount it unconditionally.
|
||||
*/
|
||||
export function AllergenBadges({ allergens }: { allergens: AllergyView[] }) {
|
||||
const { t } = useTranslation();
|
||||
if (allergens.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
|
@ -17,7 +19,7 @@ export function AllergenBadges({ allergens }: { allergens: AllergyView[] }) {
|
|||
<ul className="allergen-badges">
|
||||
{allergens.map((allergen) => (
|
||||
<li key={allergen.id} className="allergen-badge">
|
||||
{allergen.name}
|
||||
{t(`catalog.allergens.${allergen.key}`)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import type { DietView } from "@batch-cooking/shared";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import "./recipes.scss";
|
||||
|
||||
/**
|
||||
|
|
@ -10,6 +11,7 @@ import "./recipes.scss";
|
|||
* Renders nothing for an empty list, same convention as `AllergenBadges`.
|
||||
*/
|
||||
export function DietBadges({ diets }: { diets: DietView[] }) {
|
||||
const { t } = useTranslation();
|
||||
if (diets.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
|
@ -17,7 +19,7 @@ export function DietBadges({ diets }: { diets: DietView[] }) {
|
|||
<ul className="diet-badges">
|
||||
{diets.map((diet) => (
|
||||
<li key={diet.id} className="diet-badge">
|
||||
{diet.name}
|
||||
{t(`catalog.diets.${diet.key}`)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
|
|
|
|||
|
|
@ -31,7 +31,7 @@ export function DietTagSelect({
|
|||
const checked = value.includes(diet.id);
|
||||
return (
|
||||
<CheckboxOption key={diet.id} checked={checked} onChange={() => toggle(diet.id)}>
|
||||
{diet.name}
|
||||
{t(`catalog.diets.${diet.key}`)}
|
||||
</CheckboxOption>
|
||||
);
|
||||
})}
|
||||
|
|
|
|||
|
|
@ -74,8 +74,12 @@ export function IngredientPicker({
|
|||
if (ingredient.category !== category) return false;
|
||||
if (subcategory !== ALL && ingredient.subcategory !== subcategory) return false;
|
||||
}
|
||||
if (normalizedQuery.length > 0 && !ingredient.name.toLowerCase().includes(normalizedQuery)) {
|
||||
return false;
|
||||
// Matches against the *displayed* (translated) label, not the raw slug
|
||||
// key — searching "œuf" should find "Œuf" the way it always has, not
|
||||
// require typing its key.
|
||||
if (normalizedQuery.length > 0) {
|
||||
const label = t(`catalog.ingredients.${ingredient.key}`).toLowerCase();
|
||||
if (!label.includes(normalizedQuery)) return false;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
|
|
@ -175,7 +179,9 @@ export function IngredientPicker({
|
|||
<span className="ingredient-picker__card-icon" aria-hidden="true">
|
||||
<IngredientTypeIcon icon={ingredient.icon} />
|
||||
</span>
|
||||
<span className="ingredient-picker__card-name">{ingredient.name}</span>
|
||||
<span className="ingredient-picker__card-name">
|
||||
{t(`catalog.ingredients.${ingredient.key}`)}
|
||||
</span>
|
||||
{showAllergens && <AllergenBadges allergens={ingredient.allergens} />}
|
||||
{showDiets && <DietBadges diets={ingredient.diets} />}
|
||||
</button>
|
||||
|
|
|
|||
|
|
@ -28,7 +28,7 @@ export function IngredientRow({
|
|||
<span className="ingredient-row__icon" aria-hidden="true">
|
||||
<IngredientTypeIcon icon={ingredient.icon} />
|
||||
</span>
|
||||
<span className="ingredient-row__name">{ingredient.name}</span>
|
||||
<span className="ingredient-row__name">{t(`catalog.ingredients.${ingredient.key}`)}</span>
|
||||
<input
|
||||
type="number"
|
||||
min="0"
|
||||
|
|
|
|||
|
|
@ -97,7 +97,7 @@ export function RecipeDetailPanel({
|
|||
<ul className="disliked-badges">
|
||||
{dislikedIngredients.map((ingredient) => (
|
||||
<li key={ingredient.id} className="disliked-badge">
|
||||
🚫 {ingredient.name}
|
||||
🚫 {t(`catalog.ingredients.${ingredient.key}`)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -31,7 +31,7 @@ export function RecipesPage() {
|
|||
const { id } = useParams<{ id: string }>();
|
||||
const selectedId = id !== undefined ? Number(id) : null;
|
||||
|
||||
const [activeTab, setActiveTab] = useState<RecipeTab>("publique");
|
||||
const [activeTab, setActiveTab] = useState<RecipeTab>("favoris");
|
||||
const [search, setSearch] = useState("");
|
||||
const [debouncedSearch, setDebouncedSearch] = useState("");
|
||||
const [listState, setListState] = useState<RecipeListState>({ status: "loading" });
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
Loading…
Reference in a new issue