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>
293 lines
11 KiB
TypeScript
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>
|
|
);
|
|
}
|