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>
267 lines
9.4 KiB
TypeScript
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>
|
|
);
|
|
}
|