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 &&