Merge pull request 'feat(recipes): fusionne l'édition de la technique et des métadonnées dans le correcteur' (#6) from feat/tech-step-correction-merged-editor into main
Some checks failed
CI / lint (push) Successful in 1m38s
CI / test (push) Failing after 2s
CI / intent-service-test (push) Failing after 5s
CI / build (push) Successful in 51s
CI / e2e (push) Failing after 5m57s

Reviewed-on: #6
This commit is contained in:
kyuno 2026-08-27 17:41:01 +02:00
commit d58491e6f9
6 changed files with 209 additions and 136 deletions

View file

@ -90,36 +90,65 @@ describe("TechStepCorrectionPopover", () => {
); );
}); });
it("shows the selected text and every technique option once loaded", () => { it("shows the selected text, the technique catalog (searchable) and the metadata sections all together", () => {
cy.mount(<Harness />); cy.mount(<Harness />);
cy.wait("@getTechSteps"); cy.wait("@getTechSteps");
cy.contains(".tech-step-correction-popover__selection", "Cuire").should("be.visible"); cy.contains(".tech-step-correction-popover__selection", "Cuire").should("be.visible");
cy.get(".tech-step-correction-popover__list button").should("have.length", 2); // Merged editor (see TechStepCorrectionPopover's own doc comment) — no
// separate "pick, then metadata reveals itself" step, both render at
// once, and the technique catalog goes through the same searchable
// `CatalogSearchPicker` as the ingredient/utensil sub-flows (a plain
// unfiltered list of the real ~74-entry catalog isn't browsable).
cy.get(
".tech-step-correction-popover__technique-section .catalog-search-picker__list button",
).should("have.length", 2);
cy.contains("h4", "Ingrédients").should("be.visible");
cy.contains("h4", "Ustensiles").should("be.visible");
cy.contains("button", "Valider").should("be.visible");
}); });
it("offers a 'no technique here' option only when correcting an existing match", () => { it("offers a 'no technique here' option, and marks the current pick, only when correcting an existing match", () => {
cy.mount(<Harness previousTechStepId={null} />); cy.mount(<Harness previousTechStepId={null} />);
cy.wait("@getTechSteps"); cy.wait("@getTechSteps");
cy.get(".tech-step-correction-popover__remove").should("not.exist"); cy.get(".tech-step-correction-popover__remove").should("not.exist");
cy.contains(".tech-step-correction-popover__chosen-technique", "Aucune technique sélectionnée");
cy.mount(<Harness previousTechStepId={cook.id} />); cy.mount(<Harness previousTechStepId={cook.id} />);
cy.wait("@getTechSteps"); cy.wait("@getTechSteps");
cy.get(".tech-step-correction-popover__remove").should("exist"); cy.get(".tech-step-correction-popover__remove").should("exist");
cy.contains(".tech-step-correction-popover__chosen-technique", "Cuire");
cy.contains(".catalog-search-picker__list button", "Cuire").should(
"have.class",
"catalog-search-picker__item--selected",
);
}); });
it("selecting a technique reveals the Ingrédients/Ustensiles sections instead of submitting immediately", () => { it("picking a technique from the catalog selects it without submitting immediately", () => {
cy.mount(<Harness />); cy.mount(<Harness />);
cy.wait("@getTechSteps"); cy.wait("@getTechSteps");
cy.contains(".tech-step-correction-popover__list button", "Mijoter").click(); cy.contains(
".tech-step-correction-popover__technique-section .catalog-search-picker__list button",
"Mijoter",
).click();
cy.get(".tech-step-correction-popover__list").should("not.exist"); cy.contains(".tech-step-correction-popover__chosen-technique", "Mijoter");
cy.contains("h4", "Ingrédients").should("be.visible");
cy.contains("h4", "Ustensiles").should("be.visible");
cy.contains("button", "Valider").should("be.visible"); cy.contains("button", "Valider").should("be.visible");
}); });
it("Valider stays disabled until a technique is actually picked", () => {
cy.mount(<Harness />);
cy.wait("@getTechSteps");
cy.contains("button", "Valider").should("be.disabled");
cy.contains(
".tech-step-correction-popover__technique-section .catalog-search-picker__list button",
"Mijoter",
).click();
cy.contains("button", "Valider").should("not.be.disabled");
});
it("submits the selected technique (no metadata touched) with ingredients/utensils omitted from the request", () => { it("submits the selected technique (no metadata touched) with ingredients/utensils omitted from the request", () => {
cy.intercept("POST", "**/recipes/2/steps/2/corrections", { cy.intercept("POST", "**/recipes/2/steps/2/corrections", {
statusCode: 201, statusCode: 201,
@ -136,7 +165,10 @@ describe("TechStepCorrectionPopover", () => {
cy.mount(<Harness onSubmitted={onSubmitted} />); cy.mount(<Harness onSubmitted={onSubmitted} />);
cy.wait("@getTechSteps"); cy.wait("@getTechSteps");
cy.contains(".tech-step-correction-popover__list button", "Mijoter").click(); cy.contains(
".tech-step-correction-popover__technique-section .catalog-search-picker__list button",
"Mijoter",
).click();
cy.contains("button", "Valider").click(); cy.contains("button", "Valider").click();
cy.wait("@submitCorrection").its("request.body").should("deep.equal", { cy.wait("@submitCorrection").its("request.body").should("deep.equal", {
@ -162,11 +194,14 @@ describe("TechStepCorrectionPopover", () => {
}).as("submitCorrection"); }).as("submitCorrection");
cy.mount(<Harness />); cy.mount(<Harness />);
cy.wait("@getTechSteps"); cy.wait("@getTechSteps");
cy.contains(".tech-step-correction-popover__list button", "Mijoter").click();
cy.contains("button", "+ Ajouter un ingrédient").click();
cy.wait(["@getIngredients", "@getUnits", "@getUtensils"]); cy.wait(["@getIngredients", "@getUnits", "@getUtensils"]);
cy.contains(
".tech-step-correction-popover__technique-section .catalog-search-picker__list button",
"Mijoter",
).click();
cy.contains("button", "+ Ajouter un ingrédient").click();
cy.contains(".catalog-search-picker button", "Beurre").click(); cy.contains(".catalog-search-picker button", "Beurre").click();
cy.get('input[type="number"]').type("50"); cy.get('input[type="number"]').type("50");
cy.get("select").select(String(gram.id)); cy.get("select").select(String(gram.id));
@ -199,9 +234,11 @@ describe("TechStepCorrectionPopover", () => {
existingUtensils={[{ utensil: pan, start: 14, end: 23, source: "auto" }]} existingUtensils={[{ utensil: pan, start: 14, end: 23, source: "auto" }]}
/>, />,
); );
// An existing match starts pre-selected on itself (see
// TechStepCorrectionPopover's own doc comment) — the metadata sections,
// pre-seeded from `existingIngredients`/`existingUtensils`, are visible
// immediately, no need to re-pick "Cuire" from a list first.
cy.wait("@getTechSteps"); cy.wait("@getTechSteps");
cy.contains(".tech-step-correction-popover__list button", "Mijoter").click();
cy.wait(["@getIngredients", "@getUnits", "@getUtensils"]); cy.wait(["@getIngredients", "@getUnits", "@getUtensils"]);
cy.contains(".tech-step-correction-popover__chip", "50 g Beurre").find("button").click(); cy.contains(".tech-step-correction-popover__chip", "50 g Beurre").find("button").click();
@ -217,7 +254,10 @@ describe("TechStepCorrectionPopover", () => {
cy.mount(<Harness onClose={onClose} />); cy.mount(<Harness onClose={onClose} />);
cy.wait("@getTechSteps"); cy.wait("@getTechSteps");
cy.contains(".tech-step-correction-popover__list button", "Cuire").click(); cy.contains(
".tech-step-correction-popover__technique-section .catalog-search-picker__list button",
"Cuire",
).click();
cy.contains("button", "Valider").click(); cy.contains("button", "Valider").click();
cy.wait("@submitCorrection"); cy.wait("@submitCorrection");

View file

@ -110,13 +110,20 @@ Then("I should see the technique correction options", () => {
cy.get(".tech-step-correction-popover").should("be.visible"); cy.get(".tech-step-correction-popover").should("be.visible");
}); });
// Picking a technique only *selects* it now — it takes a separate // Picking a technique only *selects* it — it takes a separate "Valider"
// "Valider" click to actually submit (room was made for attaching // click to actually submit (room was made for attaching ingredient/utensil
// ingredient/utensil metadata first, see `TechStepCorrectionPopover.tsx`'s // metadata alongside it, see `TechStepCorrectionPopover.tsx`'s own doc
// own doc comment) — folded into this one step since nothing in this // comment on its merged editor) — folded into this one step since nothing
// scenario cares about that intermediate state on its own. // in this scenario cares about that intermediate state on its own. The
// technique catalog is picked via the same searchable `CatalogSearchPicker`
// the ingredient/utensil sub-flows use, scoped to
// `__technique-section` since that same search-and-pick component is
// reused inside this popover for more than just techniques.
When("I choose {string} as the correct technique", (label: string) => { When("I choose {string} as the correct technique", (label: string) => {
cy.contains(".tech-step-correction-popover__list button", label).click(); cy.contains(
".tech-step-correction-popover__technique-section .catalog-search-picker__list button",
label,
).click();
cy.contains(".tech-step-correction-popover__confirm-button", "Valider").click(); cy.contains(".tech-step-correction-popover__confirm-button", "Valider").click();
}); });

View file

@ -729,40 +729,38 @@
margin: 0 0 var(--space-sm); margin: 0 0 var(--space-sm);
} }
&__list { // Technique picker + Ingrédients/Ustensiles render together as one
display: flex; // screen now (see `TechStepCorrectionPopover.tsx`'s own doc comment) —
flex-wrap: wrap; // this section just needs its own small header row, the actual picker
gap: var(--space-xs); // is `.catalog-search-picker` (below), reused as-is from the ingredient/
list-style: none; // utensil sub-flows.
margin: 0 0 var(--space-sm); &__technique-section {
padding: 0; h4 {
margin: 0 0 var(--space-xs);
button {
padding: 0.3rem 0.6rem;
font-size: var(--font-size-sm); font-size: var(--font-size-sm);
color: var(--color-text); color: var(--color-text-muted);
background: var(--color-surface);
border: 1px solid var(--color-border);
border-radius: var(--radius-pill);
cursor: pointer;
&:hover:not(:disabled) {
background: color-mix(in srgb, var(--color-primary) 14%, transparent);
border-color: var(--color-primary);
}
&:disabled {
opacity: 0.5;
cursor: not-allowed;
}
} }
}
// Nested (rather than a sibling `&__remove` block) so its border-color &__technique-header {
// wins over the plain `button` rule above by class-count specificity, display: flex;
// no `!important` needed. align-items: baseline;
.tech-step-correction-popover__remove { justify-content: space-between;
color: var(--color-error); gap: var(--space-sm);
border-color: var(--color-error); }
&__remove {
padding: 0.2rem 0.5rem;
font-size: var(--font-size-sm);
color: var(--color-error);
background: none;
border: 1px solid var(--color-error);
border-radius: var(--radius-pill);
cursor: pointer;
&:disabled {
opacity: 0.5;
cursor: not-allowed;
} }
} }
@ -808,36 +806,27 @@
gap: var(--space-sm); gap: var(--space-sm);
} }
// "Aucune technique sélectionnée."/"Technique retenue : X" no button
// here anymore (re-picking happens directly through the search picker
// right below, see `TechStepCorrectionPopover.tsx`'s doc comment on the
// merged editor), just a small status line.
&__chosen-technique { &__chosen-technique {
display: flex; margin: 0 0 var(--space-xs);
align-items: center;
justify-content: space-between;
gap: var(--space-sm);
margin: 0;
font-weight: 600; font-weight: 600;
} }
&__change {
background: none;
border: none;
color: var(--color-primary);
text-decoration: underline;
cursor: pointer;
padding: 0;
font-size: var(--font-size-sm);
font-weight: 400;
}
&__metadata-section { &__metadata-section {
margin-top: var(--space-sm);
h4 { h4 {
margin: 0 0 var(--space-xs); margin: 0 0 var(--space-xs);
font-size: var(--font-size-sm); font-size: var(--font-size-sm);
color: var(--color-text-muted); color: var(--color-text-muted);
} }
// "+ Ajouter…" button deliberately the same plain-text-link styling // "+ Ajouter…" button deliberately a plain text-link style, not
// as `&__change` above, not another pill button (`&__list button`) // another pill button (`.catalog-search-picker__list button`) this
// this is a secondary action inside an already-open popover, not a // is a secondary action inside an already-open popover, not a
// top-level choice competing with the chips above it. // top-level choice competing with the chips above it.
> button { > button {
background: none; background: none;
@ -925,7 +914,10 @@
list-style: none; list-style: none;
margin: 0; margin: 0;
padding: 0; padding: 0;
max-height: 8rem; // Raised from the original 8rem this component is now also the
// technique picker (~74 entries, see this file's own doc comment),
// where 8rem left only a couple of rows visible before scrolling.
max-height: 14rem;
overflow-y: auto; overflow-y: auto;
button { button {
@ -941,6 +933,18 @@
background: color-mix(in srgb, var(--color-primary) 14%, transparent); background: color-mix(in srgb, var(--color-primary) 14%, transparent);
border-color: var(--color-primary); border-color: var(--color-primary);
} }
// The technique picker's current pick (`selectedId` prop) — stays
// visually marked even while filtered/scrolled past, so re-opening
// this popover's picker doesn't read as "nothing chosen yet" when
// something already is. Unused by the ingredient/utensil sub-flows
// (they never pass `selectedId` each pick there just appends a
// fresh mention, nothing to mark as "current").
&.catalog-search-picker__item--selected {
background: color-mix(in srgb, var(--color-primary) 20%, transparent);
border-color: var(--color-primary);
font-weight: 600;
}
} }
} }
} }

View file

@ -3,25 +3,30 @@ import { useState } from "react";
/** /**
* Small search-and-pick list a lighter alternative to `IngredientPicker.tsx` * Small search-and-pick list a lighter alternative to `IngredientPicker.tsx`
* (category/subcategory grid + allergen/diet toggles) for a context that * (category/subcategory grid + allergen/diet toggles) for a context that
* doesn't have room for that: `TechStepCorrectionPopover.tsx`'s "attach an * doesn't have room for that: `TechStepCorrectionPopover.tsx`'s ingredient/
* ingredient/utensil to this correction" flow, embedded in a small popover * utensil/**technique** pickers, all embedded in a small popover rather than
* rather than a full recipe form. Reused for both an ingredient and a * a full recipe form. Reused for all three an ingredient, a utensil, and a
* utensil are both "search a short reference list by translated label, pick * technique are all "search a reference list by translated label, pick one"
* one" from this component's point of view, the only difference is which * from this component's point of view, the only difference is which
* `items`/labels the caller passes in. * `items`/labels the caller passes in. The technique catalog in particular
* (~74 entries) is exactly the case a plain unfiltered list stops being
* readable at the original motivation for adding search here at all.
* *
* Deliberately just `{ id, label }` in, `id` out no `IngredientView`/ * Deliberately just `{ id, label }` in, `id` out no `IngredientView`/
* `UtensilView` dependency here, so this stays reusable for any future * `UtensilView`/`TechStepView` dependency here, so this stays reusable for
* "search this small reference catalog" need without growing a new prop * any future "search this small reference catalog" need without growing a
* per catalog shape. * new prop per catalog shape.
*/ */
export function CatalogSearchPicker({ export function CatalogSearchPicker({
items, items,
selectedId,
onSelect, onSelect,
placeholder, placeholder,
emptyLabel, emptyLabel,
}: { }: {
items: { id: number; label: string }[]; items: { id: number; label: string }[];
/** The currently-picked item, if any — marked with a distinct modifier class so it stays visible at a glance while browsing/filtering a longer list (e.g. `TechStepCorrectionPopover`'s ~74-entry technique catalog), not just implied by whatever's selected elsewhere on screen. Omit for a picker with no notion of a "current" pick (the ingredient/utensil span sub-flows — each `onSelect` there just appends a brand-new mention, nothing to mark as already chosen). */
selectedId?: number;
onSelect: (id: number) => void; onSelect: (id: number) => void;
placeholder: string; placeholder: string;
emptyLabel: string; emptyLabel: string;
@ -48,7 +53,13 @@ export function CatalogSearchPicker({
<ul className="catalog-search-picker__list"> <ul className="catalog-search-picker__list">
{visible.map((item) => ( {visible.map((item) => (
<li key={item.id}> <li key={item.id}>
<button type="button" onClick={() => onSelect(item.id)}> <button
type="button"
className={
item.id === selectedId ? "catalog-search-picker__item--selected" : undefined
}
onClick={() => onSelect(item.id)}
>
{item.label} {item.label}
</button> </button>
</li> </li>

View file

@ -61,12 +61,17 @@ function toPendingUtensil(view: StepTechStepUtensilView): PendingUtensil {
* first the description stays fully visible and selectable the whole * first the description stays fully visible and selectable the whole
* time, nothing overlays it. * time, nothing overlays it.
* *
* **Removing** a match (`submit(null)`) stays a single immediate action * **One merged editor, not a wizard**: picking a technique
* nothing to attach when removing. **Picking/relabeling** a technique used * (`CatalogSearchPicker`, searchable the reference catalog is ~74
* to submit immediately too; it no longer does, so there's room to attach * entries, an unfiltered flat list wasn't browsable) and editing its
* metadata before committing clicking a technique now only *selects* it * Ingrédients/Ustensiles metadata render together on the same screen,
* (`selectedTechStepId`), revealing the Ingrédients/Ustensiles sections and * always there's no separate "pick, then a metadata step reveals
* a final "Valider" button that submits everything together. * itself" sequence to go through, and no dead end where metadata is
* technically attachable but not visible until some other action happens
* first. A single "Valider" submits everything at once; disabled until a
* technique is actually selected (there's nothing to attach metadata to
* otherwise). **Removing** a match (`submit(null)`) stays its own
* immediate action next to the picker nothing to attach when removing.
* *
* The two metadata sections are pre-seeded from `existingIngredients`/ * The two metadata sections are pre-seeded from `existingIngredients`/
* `existingUtensils` (whatever's already attached to this occurrence, auto- * `existingUtensils` (whatever's already attached to this occurrence, auto-
@ -118,7 +123,14 @@ export function TechStepCorrectionPopover({
const { t } = useTranslation(); const { t } = useTranslation();
const popoverRef = useRef<HTMLDivElement>(null); const popoverRef = useRef<HTMLDivElement>(null);
const [techSteps, setTechSteps] = useState<TechStepView[] | null>(null); const [techSteps, setTechSteps] = useState<TechStepView[] | null>(null);
const [selectedTechStepId, setSelectedTechStepId] = useState<number | null>(null); // Opening this popover on an *already-detected* match (`previousTechStepId
// !== null`, i.e. the user clicked an existing highlight rather than
// selecting fresh text) starts pre-selected on that same technique, its
// name shown next to the picker right away — since the picker and the
// metadata sections render together regardless (see this component's own
// doc comment), this just saves re-picking the technique that's already
// correct before its metadata becomes editable.
const [selectedTechStepId, setSelectedTechStepId] = useState<number | null>(previousTechStepId);
const [catalogs, setCatalogs] = useState<{ const [catalogs, setCatalogs] = useState<{
ingredients: IngredientView[]; ingredients: IngredientView[];
units: UnitView[]; units: UnitView[];
@ -173,12 +185,12 @@ export function TechStepCorrectionPopover({
}; };
}, []); }, []);
// Only fetched once a technique is actually selected — the Ingrédients/ // Fetched unconditionally on mount — the Ingrédients/Ustensiles sections
// Ustensiles sections (the only things that need these) don't render // render alongside the technique picker from the start (see this
// before then, so a popover only ever used to relabel/remove a technique // component's own doc comment on the merged editor), so there's no later
// never pays for these three extra requests. // point to defer this to anymore.
useEffect(() => { useEffect(() => {
if (selectedTechStepId === null || catalogs !== null) return; if (catalogs !== null) return;
let cancelled = false; let cancelled = false;
Promise.all([apiClient.getIngredients(), apiClient.getUnits(), apiClient.getUtensils()]) Promise.all([apiClient.getIngredients(), apiClient.getUnits(), apiClient.getUtensils()])
.then(([ingredients, units, utensils]) => { .then(([ingredients, units, utensils]) => {
@ -190,7 +202,7 @@ export function TechStepCorrectionPopover({
return () => { return () => {
cancelled = true; cancelled = true;
}; };
}, [selectedTechStepId, catalogs]); }, [catalogs]);
// Consumes a span `StepDescription` just resolved on this popover's // Consumes a span `StepDescription` just resolved on this popover's
// behalf (see `resolvedMetadataSpan`'s own doc comment above) — opens the // behalf (see `resolvedMetadataSpan`'s own doc comment above) — opens the
@ -391,47 +403,42 @@ export function TechStepCorrectionPopover({
</div> </div>
) : techSteps === null ? ( ) : techSteps === null ? (
<p>{t("recipes.loading")}</p> <p>{t("recipes.loading")}</p>
) : selectedTechStepId === null ? (
<ul className="tech-step-correction-popover__list">
{previousTechStepId !== null && (
<li>
<button
type="button"
disabled={isSubmitting}
onClick={removeMatch}
className="tech-step-correction-popover__remove"
>
{t("recipes.techStepCorrection.removeMatch")}
</button>
</li>
)}
{techSteps.map((techStep) => (
<li key={techStep.id}>
<button
type="button"
disabled={isSubmitting}
onClick={() => setSelectedTechStepId(techStep.id)}
>
{t(`catalog.techSteps.${techStep.key}`)}
</button>
</li>
))}
</ul>
) : ( ) : (
<div className="tech-step-correction-popover__confirm"> <div className="tech-step-correction-popover__confirm">
<p className="tech-step-correction-popover__chosen-technique"> <section className="tech-step-correction-popover__technique-section">
{t( <div className="tech-step-correction-popover__technique-header">
`catalog.techSteps.${techSteps.find((ts) => ts.id === selectedTechStepId)?.key ?? ""}`, <h4>{t("recipes.techStepCorrection.techniqueSection")}</h4>
)} {previousTechStepId !== null && (
<button <button
type="button" type="button"
className="tech-step-correction-popover__change" disabled={isSubmitting}
onClick={() => setSelectedTechStepId(null)} onClick={removeMatch}
disabled={isSubmitting} className="tech-step-correction-popover__remove"
> >
{t("recipes.techStepCorrection.changeTechnique")} {t("recipes.techStepCorrection.removeMatch")}
</button> </button>
</p> )}
</div>
<p className="tech-step-correction-popover__chosen-technique">
{selectedTechStepId !== null
? t("recipes.techStepCorrection.currentTechnique", {
technique: t(
`catalog.techSteps.${techSteps.find((ts) => ts.id === selectedTechStepId)?.key ?? ""}`,
),
})
: t("recipes.techStepCorrection.noTechniqueSelected")}
</p>
<CatalogSearchPicker
items={techSteps.map((techStep) => ({
id: techStep.id,
label: t(`catalog.techSteps.${techStep.key}`),
}))}
selectedId={selectedTechStepId ?? undefined}
onSelect={setSelectedTechStepId}
placeholder={t("recipes.techStepCorrection.searchTechniquePlaceholder")}
emptyLabel={t("recipes.techStepCorrection.noTechniqueFound")}
/>
</section>
<section className="tech-step-correction-popover__metadata-section"> <section className="tech-step-correction-popover__metadata-section">
<h4>{t("recipes.techStepCorrection.ingredientsSection")}</h4> <h4>{t("recipes.techStepCorrection.ingredientsSection")}</h4>
@ -494,7 +501,7 @@ export function TechStepCorrectionPopover({
type="button" type="button"
className="tech-step-correction-popover__confirm-button" className="tech-step-correction-popover__confirm-button"
onClick={confirm} onClick={confirm}
disabled={isSubmitting} disabled={isSubmitting || selectedTechStepId === null}
> >
{t("recipes.techStepCorrection.confirm")} {t("recipes.techStepCorrection.confirm")}
</button> </button>

View file

@ -170,8 +170,12 @@
"cancel": "Annuler", "cancel": "Annuler",
"manualTooltip": "{{technique}} (correction manuelle)", "manualTooltip": "{{technique}} (correction manuelle)",
"discoverabilityHint": "💡 Sélectionnez du texte, ou cliquez sur une technique surlignée, pour la corriger.", "discoverabilityHint": "💡 Sélectionnez du texte, ou cliquez sur une technique surlignée, pour la corriger.",
"changeTechnique": "Changer",
"confirm": "Valider", "confirm": "Valider",
"techniqueSection": "Technique",
"currentTechnique": "Technique retenue : {{technique}}",
"noTechniqueSelected": "Aucune technique sélectionnée.",
"searchTechniquePlaceholder": "Rechercher une technique…",
"noTechniqueFound": "Aucune technique trouvée.",
"ingredientsSection": "Ingrédients", "ingredientsSection": "Ingrédients",
"utensilsSection": "Ustensiles", "utensilsSection": "Ustensiles",
"addIngredient": "+ Ajouter un ingrédient", "addIngredient": "+ Ajouter un ingrédient",