batchCooking/apps/web/src/features/recipes/RecipeDetailPanel.tsx
Nicolas 16f593d5d0
Some checks failed
CI / lint (push) Successful in 1m46s
CI / build (push) Successful in 1m55s
CI / e2e (push) Successful in 8m37s
CI / intent-service-test (push) Successful in 17m3s
CI / test (push) Failing after 3h14m19s
feat(recipes): permet d'ajouter des ingredients hors-catalogue
Quand le catalogue seede ne couvre pas un ingredient, l'utilisateur pouvait
etre bloque (creation manuelle) ou perdre silencieusement la ligne (import).
Une ligne de recette accepte desormais `placeholderName` (texte libre) au
lieu de `ingredientId` : l'API cree une ligne `Ingredient` `isPlaceholder`
(cle `placeholder:<uuid>`, `displayName`, `createdById`) dans la transaction
de la recette, et emet `ingredient.placeholder_created`. Ces lignes sont
exclues de `GET /reference/ingredients` et de `ingredient-matcher`.

Front : bouton "Ajouter << ... >>" dans l'etat vide de `IngredientPicker`
(formulaire + import), badge "a completer" sur la ligne, helper
`ingredientLabel` applique partout ou un libelle d'ingredient est rendu.

Admin : `/admin/catalog/*` (+ page `apps/admin-web`) liste les placeholders
regroupes par nom normalise, "marquer traite" (`reviewedAt`) et purge des
orphelins. La promotion en vraie entree catalogue reste manuelle.

Migration `ingredient_placeholder` ecrite a la main (Postgres indisponible).
Suites Mocha DB-backed ecrites, non executees en session ; test pur
`normalizePlaceholderName` + Cypress admin-web/web verts.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-28 23:36:42 +02:00

293 lines
11 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 "./badges/AllergenBadges";
import { FavoriteStarButton } from "./badges/FavoriteStarButton";
import { ingredientLabel } from "./ingredients/ingredient-label";
import { StepDescription } from "./steps/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,
showActions = true,
}: {
state: RecipeDetailState;
dislikedIngredientIds?: number[];
onFavoriteToggled?: (recipeId: number, isFavorite: boolean) => void;
onDeleted?: (recipeId: number) => void;
/** Default `true`. `false` hides the favorite star and Modifier/Supprimer buttons on the `"loaded"` branch — for a caller previewing someone's pick before deciding what happens next (`RecipePickerDialog`'s browsing step), not viewing one's own catalog. */
showActions?: boolean;
}) {
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) => (
// biome-ignore lint/suspicious/noArrayIndexKey: DraftRecipeStepView (packages/shared) has no id — it's an unsaved source preview, nothing to key on yet — and this list is a static read-only render, never reordered/edited here.
<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>
{showActions && (
<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">
🚫 {ingredientLabel(ingredient, t)}
</li>
))}
</ul>
)}
</div>
</div>
{showActions && (
<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>
{/* Discoverability hint for the highlight/correction feature below
— nothing about the steps list itself otherwise signals that a
highlighted technique or a plain-text selection is interactive.
Tied to `showActions`, same reasoning as `StepDescription`'s own
`editable` prop right below. */}
{showActions && (
<p className="recipe-detail-panel__tech-step-hint">
{t("recipes.techStepCorrection.discoverabilityHint")}
</p>
)}
<ol className="recipe-detail-panel__steps">
{recipe.steps.map((step) => (
<li key={step.id}>
{step.picture && <img src={step.picture} alt="" />}
{/* Tied to `showActions` (not unconditionally on): that flag
already distinguishes a full recipe view from a lightweight
preview (`RecipePickerDialog`'s browsing step, `showActions={false}`)
— offering technique corrections in a quick "pick a recipe
for planning" preview would be more distracting than
useful there, even though the API itself allows it for any
viewer who can see the recipe. */}
<StepDescription
description={step.description}
techSteps={step.techSteps}
editable={showActions}
recipeId={recipe.id}
stepId={step.id}
/>
</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>
);
}