import { type CreateRecipeInput, createRecipeSchema, type DietView, ErrorCode, type IngredientView, type RecipeVisibility, type UnitView, } from "@batch-cooking/shared"; import { type FormEvent, useEffect, useState } from "react"; import { useTranslation } from "react-i18next"; import { useNavigate, useParams } from "react-router-dom"; import { ApiError, apiClient } from "../../api/client"; import { DietTagSelect } from "../../features/recipes/badges/DietTagSelect"; import { IngredientPicker } from "../../features/recipes/ingredients/IngredientPicker"; import { IngredientRow } from "../../features/recipes/ingredients/IngredientRow"; import { isUnsavedPlaceholder, makePlaceholderIngredientView, } from "../../features/recipes/ingredients/placeholder-ingredient"; import { type StepDraft, StepListEditor } from "../../features/recipes/steps/StepListEditor"; import "../../features/recipes/recipes.scss"; import { makeClientKey } from "../../lib/client-key"; import { errorMessageService } from "../../services/error-message.service"; /** In display order — mirrors `RecipeVisibility` (schema.prisma/shared types). */ const VISIBILITY_OPTIONS: RecipeVisibility[] = ["PERSONAL", "HOUSE", "PUBLIC"]; /** * One selected ingredient line — `key` is a client-only stable identity, * same reasoning as `StepDraft`. `unitId` is `null` until the user picks * one (no default — unlike `portions`, there's no single "usually right" * unit across every ingredient); `canSubmit` gates on every line having one * set before allowing save. * * `ingredient` is a real `IngredientView` for a catalog pick or an * edit-loaded placeholder, or a synthetic one (see * `makePlaceholderIngredientView`) for a brand-new free-text placeholder * the user added because the catalog fell short — the latter submits as * `placeholderName`, not `ingredientId` (see {@link isUnsavedPlaceholder}). */ interface IngredientLine { key: string; ingredient: IngredientView; quantity: string; unitId: number | null; } /** Load state for the reference ingredient list (+ the existing recipe, when editing) this form needs before it can render. */ type LoadState = "loading" | "loaded" | "error"; /** * Create/edit form for one recipe — routed at `/recettes/nouvelle` and * `/recettes/:id/modifier`. Same component for both: edit mode is just * "there's an `:id` param", which also drives preloading the existing * recipe's fields. Saving always sends the recipe's *whole* content (name, * ingredients, steps) — there's no partial-field save here, matching the * API's `PATCH /recipes/:id` contract (see `recipe.service.ts`). */ export function RecipeFormPage() { const { t } = useTranslation(); const navigate = useNavigate(); const { id } = useParams<{ id: string }>(); const recipeId = id !== undefined ? Number(id) : null; const isEditing = recipeId !== null; const [loadState, setLoadState] = useState("loading"); const [ingredientsCatalog, setIngredientsCatalog] = useState([]); const [dietsCatalog, setDietsCatalog] = useState([]); const [unitsCatalog, setUnitsCatalog] = useState([]); const [name, setName] = useState(""); const [description, setDescription] = useState(""); const [picture, setPicture] = useState(""); // Pre-filled with a sensible default (same posture as `visibility` // defaulting to `PERSONAL`) rather than starting empty — this is a // required field, but the user shouldn't have to type a value just to // get past the gate if 4 is already right for their recipe. const [portions, setPortions] = useState("4"); const [visibility, setVisibility] = useState("PERSONAL"); const [dietIds, setDietIds] = useState([]); const [ingredientLines, setIngredientLines] = useState([]); const [steps, setSteps] = useState([]); const [formError, setFormError] = useState(null); const [isSubmitting, setIsSubmitting] = useState(false); useEffect(() => { let cancelled = false; setLoadState("loading"); Promise.all([ apiClient.getIngredients(), apiClient.getDiets(), apiClient.getUnits(), recipeId !== null ? apiClient.getRecipe(recipeId) : Promise.resolve(null), ]) .then(([ingredients, diets, units, recipe]) => { if (cancelled) return; setIngredientsCatalog(ingredients); setDietsCatalog(diets); setUnitsCatalog(units); if (recipe) { setName(recipe.name); setDescription(recipe.description ?? ""); setPicture(recipe.picture ?? ""); setPortions(String(recipe.portions)); setVisibility(recipe.visibility); setDietIds(recipe.diets.map((diet) => diet.id)); setIngredientLines( recipe.ingredients.map((line) => ({ key: makeClientKey(), ingredient: line.ingredient, quantity: String(line.quantity), unitId: line.unit.id, })), ); setSteps( recipe.steps.map((step) => ({ key: makeClientKey(), description: step.description, picture: step.picture ?? "", })), ); } setLoadState("loaded"); }) .catch(() => { if (!cancelled) setLoadState("error"); }); return () => { cancelled = true; }; }, [recipeId]); function addIngredient(ingredient: IngredientView) { setIngredientLines((lines) => [ ...lines, { key: makeClientKey(), ingredient, quantity: "", unitId: null }, ]); } /** Adds a free-text placeholder line — the escape hatch when nothing in the catalog matches (see `IngredientPicker`'s `onAddPlaceholder`). */ function addPlaceholderIngredient(name: string) { setIngredientLines((lines) => [ ...lines, { key: makeClientKey(), ingredient: makePlaceholderIngredientView(name), quantity: "", unitId: null, }, ]); } function updateIngredientLine( key: string, patch: Partial>, ) { setIngredientLines((lines) => lines.map((line) => (line.key === key ? { ...line, ...patch } : line)), ); } function removeIngredientLine(key: string) { setIngredientLines((lines) => lines.filter((line) => line.key !== key)); } // Surfaced next to the submit button when it's the reason `canSubmit` is // false — same "don't leave the button silently disabled" reasoning as // `RecipeImportForm` (issue #53), just less likely to bite here since a // manually-added line starts with no unit by design, right where the // person is already looking. const hasIngredientMissingUnit = ingredientLines.some((line) => line.unitId === null); // Gates the submit button — the schema (checked again on submit, see // `handleSubmit`) is the source of truth, this is just instant feedback // that doesn't need a round trip through zod on every keystroke. const canSubmit = name.trim().length > 0 && Number.isInteger(Number(portions)) && Number(portions) > 0 && ingredientLines.length > 0 && ingredientLines.every((line) => Number(line.quantity) > 0 && line.unitId !== null) && steps.length > 0 && steps.every((step) => step.description.trim().length > 0); async function handleSubmit(e: FormEvent) { e.preventDefault(); setFormError(null); const payload: CreateRecipeInput = { name: name.trim(), description: description.trim() || null, picture: picture.trim() || null, portions: Number(portions), visibility, dietIds, ingredients: ingredientLines.map((line) => ({ // A just-added free-text line has no catalog id yet — ask the API to // create the placeholder row via `placeholderName`. An edit-loaded // placeholder already has a real id and goes through `ingredientId` // like any other line (so re-saving never duplicates it). ...(isUnsavedPlaceholder(line.ingredient) ? { placeholderName: line.ingredient.displayName ?? "" } : { ingredientId: line.ingredient.id }), quantity: Number(line.quantity), // `canSubmit` already requires every line to have a unit picked // before the button is enabled — `?? 0` is just to satisfy the // type here; if it's ever reached with no unit set, the schema's // `positive()` check rejects it the same way an invalid quantity // already does. unitId: line.unitId ?? 0, })), steps: steps.map((step) => ({ description: step.description.trim(), picture: step.picture.trim() || null, })), }; const result = createRecipeSchema.safeParse(payload); if (!result.success) { setFormError(result.error.issues[0]?.message ?? t("recipes.form.genericError")); return; } setIsSubmitting(true); try { const saved = recipeId !== null ? await apiClient.updateRecipe(recipeId, result.data) : await apiClient.createRecipe(result.data); void navigate(`/recettes/${saved.id}`); } catch (err) { const code = err instanceof ApiError ? err.code : ErrorCode.INTERNAL_ERROR; setFormError(errorMessageService.getLabel(code)); } finally { setIsSubmitting(false); } } if (loadState === "loading") { return (

{t("recipes.loading")}

); } if (loadState === "error") { return (

{t("common.loadError")}

); } // Placeholder lines carry no real catalog id (a brand-new one is id 0, an // edit-loaded one isn't in the browsable catalog anyway), so they never // belong in the picker's "already picked, hide it" set. const selectedIds = ingredientLines .filter((line) => !line.ingredient.isPlaceholder) .map((line) => line.ingredient.id); return (

{isEditing ? t("recipes.form.editTitle") : t("recipes.form.newTitle")}

setName(e.target.value)} />