batchCooking/apps/web/src/features/recipes/RecipeDetailPanel.tsx
Nicolas 73ae8169a1 fix(recipes): retire l'import manuel, le planning importe seul
Correction de comportement sur la gestion des recettes de sources
externes — l'implémentation précédente avait dérivé d'une lecture
erronée du besoin :

- Plus aucun bouton d'import nulle part. Parcourir une source
  (RecipesPage, hors planning) ne fait plus jamais que prévisualiser
  — RecipeDetailPanel n'affiche plus de lien "Importer cette
  recette", seulement un bouton icône discret vers la page d'origine
  quand la recette en a une (nouveau .recipe-detail-panel__source-link,
  même emplacement que l'étoile favori).
- Une recette externe n'est importée dans la base qu'au moment où
  quelqu'un l'ajoute effectivement à son planning — jamais avant.
  RecipePickerDialog.handleSelectDraftItem est désormais le seul
  endroit de toute l'appli qui importe quoi que ce soit : cliquer sur
  un item pas encore importé y déclenche une tentative d'import
  transparente (POST /sources/.../import puis POST /planning/items),
  sans écran intermédiaire, dès que rien ne manque
  (tryBuildCompleteImport, nouveau apps/web/src/features/recipes/
  recipe-import-draft.ts). Seul un ingrédient non résolu (ou une
  erreur réseau) fait encore basculer vers l'écran de revue existant
  (ImportRecipePage), pré-rempli, pour compléter ce qui manque.
- RecipeSourcesPanel gagne onSelectDraftItem (remplace planningSlot,
  qui n'a plus de raison d'être puisqu'il n'y a plus de lien d'import
  à qui le transmettre) : quand ce callback est fourni
  (RecipePickerDialog uniquement), un item pas encore importé n'est
  plus prévisualisé sur place, il est remonté tel quel à l'appelant.

Tests :
- planning.feature : le scénario existant retire l'étape "je clique
  le lien Importer cette recette" (redirection désormais automatique
  puisque le draft de test a un ingrédient non résolu) ; nouveau
  scénario pour le chemin transparent (draft entièrement résolu,
  aucun écran de revue).
- recipe-sources.feature : le scénario qui important depuis /recettes
  (hors planning) est supprimé — cette capacité n'existe plus hors
  planning. Le scénario de deep-link vérifie maintenant l'absence du
  bouton d'import et la présence du lien discret.
- pnpm exec tsc -b --force (web) — propre.
- pnpm exec biome check — propre.
- pnpm --filter web build — propre.
- Cypress non exécutable localement sur cette machine (crash GPU
  Electron connu) — scénarios vérifiés par relecture attentive
  contre le markup/les clés i18n réels ; CI (GitHub Actions) fera
  foi à l'exécution.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-20 21:14:10 +02:00

267 lines
9.4 KiB
TypeScript

import { ErrorCode, type RecipeImportDraftView, type RecipeView } from "@batch-cooking/shared";
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { Link } from "react-router-dom";
import { ApiError, apiClient } from "../../api/client";
import { SourceLinkIcon } from "../../layouts/nav-icons";
import { errorMessageService } from "../../services/error-message.service";
import { AllergenBadges } from "./AllergenBadges";
import { FavoriteStarButton } from "./FavoriteStarButton";
import { StepDescription } from "./StepDescription";
import "./recipes.scss";
/**
* State {@link RecipeDetailPanel} renders — `"empty"` (no row selected yet)
* is distinct from `"not-found"` (a selected id that turned out invalid/
* inaccessible), each with its own message. `"loaded-draft"` is the one
* variant that isn't a real, saved `Recipe`: a not-yet-imported source
* item's read-only preview (`RecipeSourcesPanel`'s source tabs) — rendered
* through this exact same component so viewing one looks like viewing any
* other recipe, minus every action that doesn't make sense on something
* that isn't saved yet (favorite/edit/delete — nor a manual "import"
* button: a source item only ever gets saved as a side effect of adding it
* to a planning slot, see `RecipePickerDialog`'s `handleSelectDraftItem`,
* never from this preview). The one action this state does offer is a
* discreet link to the item's original page, if it has one.
*/
export type RecipeDetailState =
| { status: "empty" }
| { status: "loading" }
| { status: "loaded"; recipe: RecipeView }
| { status: "loaded-draft"; draft: RecipeImportDraftView }
| { status: "not-found" }
| { status: "error" };
/**
* Right-hand panel of the catalog's master-detail layout (`RecipesPage`) —
* header (photo + favorite star), name + allergen/disliked-ingredient
* badges, description, ordered steps. `dislikedIngredientIds` is the
* *viewer's* personal taste-preference list (`GET
* /profile/disliked-ingredients`) — crossed here against this recipe's own
* ingredients to surface just the ones relevant to it, not the viewer's
* whole list. `onFavoriteToggled`/`onDeleted` are optional — only the
* `"loaded"` (real recipe) branch ever calls them; callers that only ever
* pass `"loaded-draft"`/other states (`RecipeSourcesPanel`) can omit them.
*/
export function RecipeDetailPanel({
state,
dislikedIngredientIds = [],
onFavoriteToggled,
onDeleted,
}: {
state: RecipeDetailState;
dislikedIngredientIds?: number[];
onFavoriteToggled?: (recipeId: number, isFavorite: boolean) => void;
onDeleted?: (recipeId: number) => void;
}) {
const { t } = useTranslation();
if (state.status === "empty") {
return (
<aside className="recipe-detail-panel">
<p className="recipe-detail-panel__status">{t("recipes.detail.empty")}</p>
</aside>
);
}
if (state.status === "loading") {
return (
<aside className="recipe-detail-panel">
<p className="recipe-detail-panel__status">{t("recipes.loading")}</p>
</aside>
);
}
if (state.status === "not-found") {
return (
<aside className="recipe-detail-panel">
<p className="recipe-detail-panel__status recipe-detail-panel__status--error">
{t("recipes.notFound")}
</p>
</aside>
);
}
if (state.status === "error") {
return (
<aside className="recipe-detail-panel">
<p className="recipe-detail-panel__status recipe-detail-panel__status--error">
{t("common.loadError")}
</p>
</aside>
);
}
if (state.status === "loaded-draft") {
const { draft } = state;
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>
{draft.sourceUrl && (
<a
href={draft.sourceUrl}
target="_blank"
rel="noreferrer"
className="recipe-detail-panel__source-link"
title={t("recipes.sources.detail.viewSource")}
aria-label={t("recipes.sources.detail.viewSource")}
>
<SourceLinkIcon aria-hidden="true" />
</a>
)}
</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>
{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.stepsTitle")}</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>
);
}
const { recipe } = state;
const dislikedIngredients = recipe.ingredients
.map((line) => line.ingredient)
.filter((ingredient) => dislikedIngredientIds.includes(ingredient.id));
return (
<aside className="recipe-detail-panel">
<div className="recipe-detail-panel__header">
<div className="recipe-detail-panel__photo" aria-hidden="true">
{recipe.picture ? <img src={recipe.picture} alt="" /> : "🍽️"}
</div>
<FavoriteStarButton
recipeId={recipe.id}
isFavorite={recipe.isFavorite}
onToggled={(isFavorite) => onFavoriteToggled?.(recipe.id, isFavorite)}
/>
</div>
<div className="recipe-detail-panel__title-row">
<div className="recipe-detail-panel__title-main">
<h2>{recipe.name}</h2>
<p className="recipe-detail-panel__portions">
{t("recipes.detail.portions", { count: recipe.portions })}
</p>
</div>
<div className="recipe-detail-panel__title-badges">
<AllergenBadges allergens={recipe.allergens} />
{dislikedIngredients.length > 0 && (
<ul className="disliked-badges">
{dislikedIngredients.map((ingredient) => (
<li key={ingredient.id} className="disliked-badge">
🚫 {t(`catalog.ingredients.${ingredient.key}`)}
</li>
))}
</ul>
)}
</div>
</div>
<div className="recipe-detail-panel__actions">
<Link to={`/recettes/${recipe.id}/modifier`} className="recipes-page__new-button">
{t("recipes.editButton")}
</Link>
<DeleteRecipeButton recipeId={recipe.id} onDeleted={() => onDeleted?.(recipe.id)} />
</div>
{recipe.description && (
<section className="recipe-detail-panel__section recipe-detail-panel__section--description">
<p className="recipe-detail-panel__description">{recipe.description}</p>
</section>
)}
<section className="recipe-detail-panel__section">
<h3>{t("recipes.stepsTitle")}</h3>
<ol className="recipe-detail-panel__steps">
{recipe.steps.map((step) => (
<li key={step.id}>
{step.picture && <img src={step.picture} alt="" />}
<StepDescription description={step.description} techSteps={step.techSteps} />
</li>
))}
</ol>
</section>
</aside>
);
}
/** Delete action with an inline two-step confirmation, same pattern as `HouseholdSettingsPage`'s danger zone. */
function DeleteRecipeButton({
recipeId,
onDeleted,
}: {
recipeId: number;
onDeleted: () => void;
}) {
const { t } = useTranslation();
const [isConfirming, setIsConfirming] = useState(false);
const [isDeleting, setIsDeleting] = useState(false);
const [error, setError] = useState<string | null>(null);
async function handleDelete() {
setIsDeleting(true);
setError(null);
try {
await apiClient.deleteRecipe(recipeId);
onDeleted();
} catch (err) {
const code = err instanceof ApiError ? err.code : ErrorCode.INTERNAL_ERROR;
setError(errorMessageService.getLabel(code));
setIsDeleting(false);
}
}
if (!isConfirming) {
return (
<button
type="button"
className="recipe-detail-panel__danger-button"
onClick={() => setIsConfirming(true)}
>
{t("recipes.deleteButton")}
</button>
);
}
return (
<span className="recipe-detail-panel__delete-confirm">
<button
type="button"
className="recipe-detail-panel__danger-button"
onClick={handleDelete}
disabled={isDeleting}
>
{t("recipes.confirmDeleteButton")}
</button>
<button type="button" onClick={() => setIsConfirming(false)} disabled={isDeleting}>
{t("recipes.cancelDeleteButton")}
</button>
{error && <p className="field-error">{error}</p>}
</span>
);
}