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 ( ); } if (state.status === "loading") { return ( ); } if (state.status === "not-found") { return ( ); } if (state.status === "error") { return ( ); } if (state.status === "loaded-draft") { const { draft } = state; return ( ); } const { recipe } = state; const dislikedIngredients = recipe.ingredients .map((line) => line.ingredient) .filter((ingredient) => dislikedIngredientIds.includes(ingredient.id)); return ( ); } /** 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(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 ( ); } return ( {error &&

{error}

}
); }