import { type CreateRecipeInput, type DietView, ErrorCode, type IngredientView, MEALS, type Meal, type RecipeVisibility, type UnitView, WEEK_DAYS, type WeekDay, createRecipeSchema, } from "@batch-cooking/shared"; import { type FormEvent, useEffect, useState } from "react"; import { useTranslation } from "react-i18next"; import { useNavigate, useParams, useSearchParams } from "react-router-dom"; import { ApiError, apiClient } from "../api/client"; import { DietTagSelect } from "../features/recipes/DietTagSelect"; import { IngredientPicker } from "../features/recipes/IngredientPicker"; import { IngredientRow } from "../features/recipes/IngredientRow"; import { type StepDraft, StepListEditor } from "../features/recipes/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). Same list as `RecipeFormPage`. */ const VISIBILITY_OPTIONS: RecipeVisibility[] = ["PERSONAL", "HOUSE", "PUBLIC"]; /** A resolved ingredient line — identical shape to `RecipeFormPage`'s own `IngredientLine`. */ 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, or discard it, before this recipe can be saved. */ interface UnresolvedIngredientLine { key: string; rawText: string; quantity: string; } type LoadState = "loading" | "loaded" | "error"; /** * Reads and validates `?planningDate=&planningWeekDay=&planningMeal=` off * this page's own URL — `null` unless all three are present and well-formed * (a closed-set match against `WEEK_DAYS`/`MEALS`, same validation * `addPlanningItemSchema` enforces server-side), so a hand-typed or stale * URL just falls back to this page's normal "land on the recipe" behavior * rather than throwing. See this component's own doc comment. */ function parsePlanningSlot( searchParams: URLSearchParams, ): { date: string; weekDay: WeekDay; meal: Meal } | null { const date = searchParams.get("planningDate"); const weekDay = searchParams.get("planningWeekDay"); const meal = searchParams.get("planningMeal"); if ( date === null || !/^\d{4}-\d{2}-\d{2}$/.test(date) || weekDay === null || !(WEEK_DAYS as readonly string[]).includes(weekDay) || meal === null || !(MEALS as readonly string[]).includes(meal) ) { return null; } return { date, weekDay: weekDay as WeekDay, meal: meal as Meal }; } /** * Review screen for finalizing an import — routed at * `/recettes/importer/:sourceKey/:externalId` (reached from * `SourceItemPreviewPanel`'s "Importer cette recette" button). Pre-filled * from `GET /sources/:sourceKey/preview/:externalId` (the same draft the * preview panel already showed), 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. * * `?planningDate=&planningWeekDay=&planningMeal=` are set only when this * page was reached from `RecipePickerDialog`'s "Sources" tab (via * `SourceItemPreviewPanel`'s import link, see its own `planningSlot` prop) * — picking a not-yet-imported item there hands off to this full review * screen instead of the dialog's own small "how many portions?" step, * since an unresolved-ingredient review doesn't fit in that step. When * present and well-formed, a successful import also adds the freshly * created recipe straight to that planning slot (`POST /planning/items`, * using this form's own `portions` field) before landing back on the * planning page, instead of the recipe's own detail page. */ export function ImportRecipePage() { const { t } = useTranslation(); const navigate = useNavigate(); const { sourceKey, externalId } = useParams<{ sourceKey: string; externalId: string }>(); const [searchParams] = useSearchParams(); const planningSlot = parsePlanningSlot(searchParams); 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(() => { if (sourceKey === undefined || externalId === undefined) { setLoadState("error"); return; } 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); } function discardUnresolvedIngredient(key: string) { setUnresolvedIngredients((lines) => lines.filter((line) => line.key !== key)); setResolvingKey((current) => (current === key ? null : current)); } 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) && unresolvedIngredients.length === 0 && steps.length > 0 && steps.every((step) => step.description.trim().length > 0); async function handleSubmit(e: FormEvent) { e.preventDefault(); setFormError(null); if (sourceKey === undefined || externalId === undefined) return; const payload: CreateRecipeInput = { name: name.trim(), description: description.trim() || null, picture: picture.trim() || null, portions: Number(portions), visibility, dietIds, ingredients: ingredientLines.map((line) => ({ 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 { await apiClient.addPlanningItem({ date: planningSlot.date, weekDay: planningSlot.weekDay, meal: planningSlot.meal, recipeId: saved.id, portions: Number(portions), }); navigate("/"); return; } catch { // The recipe itself was already imported successfully — only the // planning add failed. Land on the new 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. navigate(`/recettes/${saved.id}`); return; } } 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("recipes.sources.import.loadError")}

); } const selectedIds = ingredientLines.map((line) => line.ingredient.id); return (

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

{planningSlot && (

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

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