import { type CreateRecipeInput, createRecipeSchema, type DietView, ErrorCode, type IngredientView, type Meal, type PlanningItemView, type RecipeView, type RecipeVisibility, type UnitView, type WeekDay, } from "@batch-cooking/shared"; import { type FormEvent, useEffect, useState } from "react"; import { useTranslation } from "react-i18next"; import { ApiError, apiClient } from "../../../api/client"; import { makeClientKey } from "../../../lib/client-key"; import { errorMessageService } from "../../../services/error-message.service"; import { DietTagSelect } from "../badges/DietTagSelect"; import { IngredientPicker } from "../ingredients/IngredientPicker"; import { IngredientRow } from "../ingredients/IngredientRow"; import { isUnsavedPlaceholder, makePlaceholderIngredientView, } from "../ingredients/placeholder-ingredient"; import { type StepDraft, StepListEditor } from "../steps/StepListEditor"; import "../recipes.scss"; /** In display order — mirrors `RecipeVisibility` (schema.prisma/shared types). Same list as `RecipeFormPage`. */ const VISIBILITY_OPTIONS: RecipeVisibility[] = ["PERSONAL", "HOUSE", "PUBLIC"]; /** A resolved ingredient line — identical shape to `RecipeFormPage`'s own `IngredientLine` (`ingredient` may be a synthetic placeholder view, see there). */ interface IngredientLine { key: string; ingredient: IngredientView; quantity: string; unitId: number | null; } /** One draft line that didn't resolve to a real `Ingredient` on preview (`DraftRecipeIngredientView.ingredient === null`) — still needs a person to pick the right one, keep it as a free-text placeholder, or discard it, before this recipe can be saved. */ interface UnresolvedIngredientLine { key: string; rawText: string; quantity: string; } type LoadState = "loading" | "loaded" | "error"; /** * The review/creation form for finalizing a source item's import — the * form itself, extracted out of `ImportRecipePage` so `RecipePickerDialog` * can embed it directly as a step of its own (the normal way this is * reached now: `handleSelectDraftItem`'s fallback when a draft has * something `tryBuildCompleteImport` couldn't resolve on its own) instead * of navigating to a separate page and losing the picker's context. * `ImportRecipePage` still wraps this as a standalone, directly-linkable * route — a safety net (a stale bookmark, a reload mid-flow), not the * primary path any more. * * Pre-filled from `GET /sources/:sourceKey/preview/:externalId`, * structurally the same form as `RecipeFormPage` — same sub-components * (`IngredientRow`, `IngredientPicker`, `StepListEditor`, `DietTagSelect`), * same `CreateRecipeInput` submit shape — plus one thing a manual creation * never has to handle: ingredient lines the automatic matching * (`ingredient-matcher.ts`) couldn't resolve. Those render as their own "à * compléter" list, each needing a real ingredient picked (or the line * discarded) before the form can submit — never silently drops/guesses * one, per the product decision this stage was built against (no invalid * recipe is ever persisted). * * Submits to `POST /sources/:sourceKey/import/:externalId` * (`apiClient.importSourceItem`) instead of `POST /recipes` — the only * other difference from `RecipeFormPage`'s own submit. When `planningSlot` * is given, a successful import also adds the freshly created recipe * straight to that slot (`POST /planning/items`, using this form's own * `portions` field) before calling `onImported` — `planningItem` on that * result is `null` either when there's no slot to add to, or when the * recipe saved fine but that add itself failed (the caller decides what to * do about that rather than this component guessing — see * `ImportRecipePage`/`RecipePickerDialog`'s own handling). */ export function RecipeImportForm({ sourceKey, externalId, planningSlot, onImported, }: { sourceKey: string; externalId: string; planningSlot?: { date: string; weekDay: WeekDay; meal: Meal }; onImported: (result: { recipe: RecipeView; planningItem: PlanningItemView | null }) => void; }) { const { t } = useTranslation(); 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(""); const [portions, setPortions] = useState("4"); const [visibility, setVisibility] = useState("PERSONAL"); const [dietIds, setDietIds] = useState([]); const [ingredientLines, setIngredientLines] = useState([]); const [unresolvedIngredients, setUnresolvedIngredients] = useState( [], ); // Which unresolved line's picker is currently open — at most one at a // time (IngredientPicker is a whole browsable grid, not a compact // popover; showing one per unresolved line at once would be unwieldy). const [resolvingKey, setResolvingKey] = useState(null); 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(), apiClient.previewSourceItem(sourceKey, externalId), ]) .then(([ingredients, diets, units, draft]) => { if (cancelled) return; setIngredientsCatalog(ingredients); setDietsCatalog(diets); setUnitsCatalog(units); setName(draft.name); setDescription(draft.description ?? ""); setPicture(draft.picture ?? ""); setPortions(draft.portions !== null ? String(draft.portions) : "4"); const resolved: IngredientLine[] = []; const unresolved: UnresolvedIngredientLine[] = []; for (const line of draft.ingredients) { if (line.ingredient !== null) { resolved.push({ key: makeClientKey(), ingredient: line.ingredient, quantity: line.quantity !== null ? String(line.quantity) : "", unitId: line.unit?.id ?? null, }); } else { unresolved.push({ key: makeClientKey(), rawText: line.rawText, quantity: line.quantity !== null ? String(line.quantity) : "", }); } } setIngredientLines(resolved); setUnresolvedIngredients(unresolved); setSteps( draft.steps.map((step) => ({ key: makeClientKey(), description: step.description, picture: step.picture ?? "", })), ); setLoadState("loaded"); }) .catch(() => { if (!cancelled) setLoadState("error"); }); return () => { cancelled = true; }; }, [sourceKey, externalId]); function addIngredient(ingredient: IngredientView) { setIngredientLines((lines) => [ ...lines, { key: makeClientKey(), ingredient, 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)); } /** Promotes an unresolved line into a real ingredient line, carrying its quantity over — its unit still needs picking, same as a freshly-added ingredient. */ function resolveIngredient(unresolvedKey: string, ingredient: IngredientView) { setUnresolvedIngredients((lines) => { const line = lines.find((l) => l.key === unresolvedKey); if (line) { setIngredientLines((resolved) => [ ...resolved, { key: makeClientKey(), ingredient, quantity: line.quantity, unitId: null }, ]); } return lines.filter((l) => l.key !== unresolvedKey); }); setResolvingKey(null); } /** Keeps an unresolved line as a free-text placeholder — the catalog had nothing matching, but the ingredient shouldn't be silently dropped (see `Ingredient.isPlaceholder` in schema.prisma). Its unit still needs picking, same as a resolved line. */ function keepUnresolvedAsPlaceholder(unresolvedKey: string) { setUnresolvedIngredients((lines) => { const line = lines.find((l) => l.key === unresolvedKey); if (line) { setIngredientLines((resolved) => [ ...resolved, { key: makeClientKey(), ingredient: makePlaceholderIngredientView(line.rawText.trim()), quantity: line.quantity, unitId: null, }, ]); } return lines.filter((l) => l.key !== unresolvedKey); }); setResolvingKey((current) => (current === unresolvedKey ? null : current)); } function discardUnresolvedIngredient(key: string) { setUnresolvedIngredients((lines) => lines.filter((line) => line.key !== key)); setResolvingKey((current) => (current === key ? null : current)); } // Surfaced next to the submit button (see the `unitMissingHint` per-row // hint in `IngredientRow` for the same thing at the line level) — without // this, a pre-filled import whose auto-matched ingredients are otherwise // complete could leave `canSubmit` false with nothing on the page saying // why (see issue #53). const hasIngredientMissingUnit = ingredientLines.some((line) => line.unitId === null); // Two raw source lines can independently resolve to the same catalog // ingredient (e.g. "Egg Yolks" and "Eggs" both matching "Egg") — // `RecipeIngredient`'s primary key is `(recipeId, ingredientId)`, one row // per ingredient (schema.prisma), so submitting both would otherwise fail // (`createRecipeSchema` now rejects it, see its own doc comment). Blocked // here too, with each duplicate row highlighted, rather than letting the // user find out only after clicking "Importer". const duplicateIngredientIds = (() => { const seen = new Set(); const duplicates = new Set(); for (const line of ingredientLines) { // Placeholder lines have no catalog id (all share the sentinel 0) and // each becomes its own fresh row server-side — two of them never // collide on `RecipeIngredient`'s `(recipeId, ingredientId)` key. if (line.ingredient.isPlaceholder) continue; if (seen.has(line.ingredient.id)) duplicates.add(line.ingredient.id); seen.add(line.ingredient.id); } return duplicates; })(); const hasDuplicateIngredient = duplicateIngredientIds.size > 0; 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) && !hasDuplicateIngredient && unresolvedIngredients.length === 0 && 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) => ({ // Free-text lines (nothing in the catalog matched) submit as // `placeholderName` so the API creates the placeholder row — same // branch as RecipeFormPage's identical submit. ...(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 — // same "?? 0, the schema rejects it if ever reached" reasoning as // RecipeFormPage's identical submit. 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.sources.import.genericError")); return; } setIsSubmitting(true); try { const saved = await apiClient.importSourceItem(sourceKey, externalId, result.data); if (planningSlot) { try { const planningItem = await apiClient.addPlanningItem({ date: planningSlot.date, weekDay: planningSlot.weekDay, meal: planningSlot.meal, recipeId: saved.id, portions: Number(portions), }); onImported({ recipe: saved, planningItem }); return; } catch { // The recipe itself was already imported successfully — only the // planning add failed. The caller decides what to do with a // `null` planningItem (land on the recipe's own page rather than // stranding the user on a form that already submitted; it can // still be added to that slot afterwards via the normal // "déjà importée" picker path). onImported({ recipe: saved, planningItem: null }); return; } } onImported({ recipe: saved, planningItem: null }); } 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("recipes.sources.import.loadError")}

); } const selectedIds = ingredientLines .filter((line) => !line.ingredient.isPlaceholder) .map((line) => line.ingredient.id); return (
{planningSlot && (

{t("recipes.sources.import.planningHint")}

)} setName(e.target.value)} />