batchCooking/apps/web/src/features/recipes/SourceItemPreviewPanel.tsx
Nicolas e4c8d910d4 feat(recipes): écran de revue et finalisation de l'import (étape 3/4)
Backend :
- `createRecipe` refactorisé en fine enveloppe autour d'un nouvel
  helper interne `createRecipeInternal`, paramétré par une source
  d'import optionnelle ; nouvelle fonction exportée
  `createImportedRecipe` qui réutilise toute la validation
  ingrédients/unités/diets et le matching des tech steps, sans
  dupliquer cette logique.
- La locale de l'adaptateur source est propagée jusqu'au chargement
  des `TechStepMapping`, pour que le texte anglais (TheMealDB, etc.)
  soit matché contre le bon jeu de règles au lieu du défaut français.
- Nouvel endpoint `POST /sources/:sourceKey/import/:externalId` —
  valide le payload via `createRecipeSchema` (même schéma qu'une
  création manuelle) et persiste une vraie `Recipe` liée à la source
  (`sourceId`/`externalId`).
- Nouveau code d'erreur `RECIPE_ALREADY_IMPORTED` (4022) quand
  l'item a déjà été importé pour ce foyer.

Frontend :
- `ImportRecipePage` (nouvelle page, `/recettes/importer/:sourceKey/:externalId`) —
  pré-remplit le formulaire depuis `previewSourceItem`, en miroir de
  `RecipeFormPage` (mêmes sous-composants : `IngredientRow`,
  `IngredientPicker`, `StepListEditor`, `DietTagSelect`). Ajoute une
  section dédiée aux lignes d'ingrédients non résolues automatiquement :
  l'utilisateur choisit un ingrédient réel via l'`IngredientPicker`
  existant ou retire la ligne — aucune recette invalide n'est jamais
  soumise, le bouton d'import reste désactivé tant qu'il en reste.
- `SourceItemPreviewPanel` gagne un lien « Importer cette recette »
  vers cet écran.

Tests :
- Mocha (`apps/api/test/sources.test.ts`) : 6 nouveaux tests sur
  `POST /sources/:sourceKey/import/:externalId` (payload valide,
  ingrédient/unité inconnus, déjà importé, deux foyers distincts,
  locale de la source respectée pour les tech steps). 282 tests
  passent au total, aucune régression.
- Cypress : nouveau scénario Gherkin bout-en-bout dans
  `recipe-sources.feature` (parcourir → prévisualiser → importer →
  résoudre un ingrédient non reconnu → confirmer → atterrir sur la
  recette sauvegardée). Steps d'édition d'ingrédients/étapes
  génériques déplacés de `recipe-form.ts` vers
  `cypress/support/step_definitions/common.steps.ts`, réutilisables
  par ce nouveau scénario.

Suite : étape 4 (ajouter au planning déclenche l'import si nécessaire).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-20 16:56:06 +02:00

135 lines
5.1 KiB
TypeScript

import type { RecipeImportDraftView } from "@batch-cooking/shared";
import { useTranslation } from "react-i18next";
import { Link } from "react-router-dom";
import { StepDescription } from "./StepDescription";
import "./recipes.scss";
/** State {@link SourceItemPreviewPanel} renders — mirrors `RecipeDetailState`'s shape (`RecipeDetailPanel`), one status short (no "not-found": an invalid `externalId` surfaces as `"error"`, there's no separate "id was well-formed but nothing matched it" case here). */
export type SourceItemPreviewState =
| { status: "empty" }
| { status: "loading" }
| { status: "loaded"; draft: RecipeImportDraftView }
| { status: "error" };
/**
* Right-hand panel of the catalog's "Sources" tab (`RecipeSourcesPanel`) —
* a read-only preview of a not-yet-imported item: nothing here can be
* edited or saved yet (no favorite/edit/delete actions, unlike
* `RecipeDetailPanel`) — turning this into an actual import with a review
* step for unresolved ingredients is a later stage of the same plan.
* Reuses `StepDescription` so a step's detected techniques are already
* highlighted here too, exactly like a saved recipe's detail.
*/
export function SourceItemPreviewPanel({ state }: { state: SourceItemPreviewState }) {
const { t } = useTranslation();
if (state.status === "empty") {
return (
<aside className="recipe-detail-panel">
<p className="recipe-detail-panel__status">{t("recipes.sources.detail.empty")}</p>
</aside>
);
}
if (state.status === "loading") {
return (
<aside className="recipe-detail-panel">
<p className="recipe-detail-panel__status">{t("recipes.sources.detail.loading")}</p>
</aside>
);
}
if (state.status === "error") {
return (
<aside className="recipe-detail-panel">
<p className="recipe-detail-panel__status recipe-detail-panel__status--error">
{t("recipes.sources.detail.loadError")}
</p>
</aside>
);
}
const { draft } = state;
const hasUnresolvedIngredient = draft.ingredients.some(
(ingredient) => ingredient.ingredient === null,
);
return (
<aside className="recipe-detail-panel">
<div className="recipe-detail-panel__header">
<div className="recipe-detail-panel__photo" aria-hidden="true">
{draft.picture ? <img src={draft.picture} alt="" /> : "🍽️"}
</div>
</div>
<div className="recipe-detail-panel__title-row">
<div className="recipe-detail-panel__title-main">
<h2>{draft.name}</h2>
{draft.portions !== null && (
<p className="recipe-detail-panel__portions">
{t("recipes.detail.portions", { count: draft.portions })}
</p>
)}
</div>
</div>
<div className="recipe-detail-panel__actions">
<Link
to={`/recettes/importer/${draft.sourceKey}/${encodeURIComponent(draft.externalId)}`}
className="recipes-page__new-button"
>
{t("recipes.sources.detail.importButton")}
</Link>
<a
href={draft.sourceUrl}
target="_blank"
rel="noreferrer"
className="recipes-page__new-button"
>
{t("recipes.sources.detail.viewSource")}
</a>
</div>
{draft.description && (
<section className="recipe-detail-panel__section recipe-detail-panel__section--description">
<p className="recipe-detail-panel__description">{draft.description}</p>
</section>
)}
<section className="recipe-detail-panel__section">
<h3>{t("recipes.sources.detail.ingredientsCount", { count: draft.ingredients.length })}</h3>
{hasUnresolvedIngredient && (
<p className="source-item-preview__hint">
{t("recipes.sources.detail.unresolvedIngredientsHint")}
</p>
)}
<ul className="source-item-preview__ingredients">
{draft.ingredients.map((ingredient, index) => (
// Draft lines have no id of their own (nothing is saved yet) —
// `rawText` alone could collide (a source repeating the same
// line), so it's paired with its position; this list is fully
// regenerated from `draft` on every render, never reordered in
// place, so that's safe here (same reasoning as
// StepDescription.tsx's segment keys).
<li
key={`${index}-${ingredient.rawText}`}
className={ingredient.ingredient === null ? "is-unresolved" : undefined}
>
{ingredient.rawText}
</li>
))}
</ul>
</section>
<section className="recipe-detail-panel__section">
<h3>{t("recipes.sources.detail.stepsCount", { count: draft.steps.length })}</h3>
<ol className="recipe-detail-panel__steps">
{draft.steps.map((step, index) => (
<li key={`${index}-${step.description}`}>
{step.picture && <img src={step.picture} alt="" />}
<StepDescription description={step.description} techSteps={step.techSteps} />
</li>
))}
</ol>
</section>
</aside>
);
}