Merge pull request #51 from kyuno053/feat/unify-source-detail-view

fix(recipes): retire l'import manuel, le planning importe seul
This commit is contained in:
kyuno053 2026-08-21 00:09:30 +02:00 committed by GitHub
commit f7d7664397
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
17 changed files with 1094 additions and 699 deletions

View file

@ -13,9 +13,18 @@ Feature: Adding a recipe to the planning
And the sources reference list has options And the sources reference list has options
And the household has enabled TheMealDB And the household has enabled TheMealDB
And browsing TheMealDB returns some items And browsing TheMealDB returns some items
And the planning request reflects whatever's been added so far And the planning request returns nothing
Scenario: Adds a not-yet-imported source item to a planning slot, importing it on the way Scenario: Selecting a source item only previews it, the footer's Confirmer is what acts on it
Given previewing TheMealDB item "9999" is available
When I visit "/"
And I click the add button for the first empty planning slot
And I click the button "TheMealDB"
And I click the source item "Fish Pie"
Then the recipe detail panel heading should be "Fish Pie"
And the URL should be the home page
Scenario: Confirming a not-yet-imported source item lands on the embedded review form since an ingredient needs resolving
Given previewing TheMealDB item "9999" is available Given previewing TheMealDB item "9999" is available
And importing the previewed item will succeed and return id 99 And importing the previewed item will succeed and return id 99
And adding the imported recipe to the planning will succeed And adding the imported recipe to the planning will succeed
@ -23,7 +32,7 @@ Feature: Adding a recipe to the planning
And I click the add button for the first empty planning slot And I click the add button for the first empty planning slot
And I click the button "TheMealDB" And I click the button "TheMealDB"
And I click the source item "Fish Pie" And I click the source item "Fish Pie"
And I click the link "Importer cette recette" And I click the button "Confirmer"
Then I should see "Cette recette sera automatiquement ajoutée à votre planning une fois importée." Then I should see "Cette recette sera automatiquement ajoutée à votre planning une fois importée."
When I choose an ingredient for the unresolved line "some mystery paste" When I choose an ingredient for the unresolved line "some mystery paste"
@ -32,5 +41,19 @@ Feature: Adding a recipe to the planning
And I fill in the last ingredient's quantity with "1" and unit "unité" And I fill in the last ingredient's quantity with "1" and unit "unité"
And I click the button "Importer" And I click the button "Importer"
Then the planning add request should have included recipe 99, weekDay "lundi", meal "petit-dejeuner", and portions 4 Then the planning add request should have included recipe 99, weekDay "lundi", meal "petit-dejeuner", and portions 4
And the URL should be the home page And the recipe picker dialog should be closed
And the recipe "Fish Pie" should appear in the first planning slot with 4 portions And the recipe "Fish Pie" should appear in the first planning slot with 4 portions
Scenario: Confirming a fully-resolved not-yet-imported item adds it to the planning transparently, with no review screen at all
Given previewing TheMealDB item "7777" is fully resolved as "Ratatouille"
And importing item "7777" will succeed and return id 100
And adding recipe 100 to the planning will succeed
When I visit "/"
And I click the add button for the first empty planning slot
And I click the button "TheMealDB"
And I click the source item "Ratatouille"
And I click the button "Confirmer"
Then the import request for "7777" should have included the name "Ratatouille"
And the planning add request should have included recipe 100, weekDay "lundi", meal "petit-dejeuner", and portions 4
And the recipe picker dialog should be closed
And the recipe "Ratatouille" should appear in the first planning slot with 4 portions

View file

@ -13,13 +13,6 @@ import { Given, Then, When } from "@badeball/cypress-cucumber-preprocessor";
// its own comment for the full reasoning), so most of what's below mirrors // its own comment for the full reasoning), so most of what's below mirrors
// recipe-sources.ts's fixtures rather than importing them. // recipe-sources.ts's fixtures rather than importing them.
// Flips once, from `false` to `true`, as the single scenario in this file
// actually performs the planning-add — module-level `let` rather than
// something reset per-scenario, since there's only ever the one here (see
// household-settings.ts for the same pattern used across several scenarios
// instead).
let fishPiePlanned = false;
Given("the recipe catalog contains nothing", () => { Given("the recipe catalog contains nothing", () => {
cy.intercept("GET", /\/recipes\?/, { statusCode: 200, body: [] }); cy.intercept("GET", /\/recipes\?/, { statusCode: 200, body: [] });
}); });
@ -49,6 +42,18 @@ Given("browsing TheMealDB returns some items", () => {
alreadyImported: false, alreadyImported: false,
recipeId: null, recipeId: null,
}, },
// Draft's own preview ("previewing TheMealDB item ... is fully
// resolved") has nothing left for a person to fix — unlike "Fish
// Pie" above, exercises the transparent-import path instead of the
// review screen.
{
externalId: "7777",
title: "Ratatouille",
picture: null,
url: "https://www.themealdb.com/meal/7777",
alreadyImported: false,
recipeId: null,
},
], ],
nextCursor: null, nextCursor: null,
}, },
@ -89,6 +94,76 @@ Given("previewing TheMealDB item {string} is available", (externalId: string) =>
}); });
}); });
// Unlike "previewing TheMealDB item ... is available" above, every
// ingredient line here already resolved to a real ingredient/unit/quantity
// — `tryBuildCompleteImport` (RecipePickerDialog.tsx) accepts a draft
// shaped exactly like this one as-is, no review screen needed.
Given(
"previewing TheMealDB item {string} is fully resolved as {string}",
(externalId: string, name: string) => {
cy.intercept("GET", `**/sources/theMealDb/preview/${externalId}`, {
statusCode: 200,
body: {
sourceKey: "theMealDb",
externalId,
name,
description: null,
picture: null,
portions: 4,
sourceUrl: `https://www.themealdb.com/meal/${externalId}`,
ingredients: [
{
rawText: "1 onion",
quantity: 1,
ingredient: {
id: 1,
key: "onion",
icon: "VEGETABLE",
category: "freshProduce",
subcategory: "vegetables",
reproducible: false,
allergens: [],
diets: [],
},
unit: { id: 1, key: "piece", type: "COUNT", toBaseFactor: 1 },
},
],
steps: [{ description: "Cuire à la poêle.", picture: null, techSteps: [] }],
},
});
},
);
Given(
"importing item {string} will succeed and return id {int}",
(externalId: string, id: number) => {
cy.intercept("POST", `**/sources/theMealDb/import/${externalId}`, {
statusCode: 201,
body: { id },
}).as("importItem");
},
);
Given("adding recipe {int} to the planning will succeed", (recipeId: number) => {
cy.intercept("POST", "**/planning/items", {
statusCode: 201,
body: {
id: 2,
weekDay: "lundi",
meal: "petit-dejeuner",
portions: 4,
recipe: { id: recipeId, name: "Ratatouille" },
},
}).as("addPlanningItem");
});
Then(
"the import request for {string} should have included the name {string}",
(_externalId: string, name: string) => {
cy.wait("@importItem").its("request.body.name").should("eq", name);
},
);
// Covers every reference catalog both `RecipePickerDialog` (ingredients/ // Covers every reference catalog both `RecipePickerDialog` (ingredients/
// diets, for its own filters) and `ImportRecipePage` (ingredients/diets/ // diets, for its own filters) and `ImportRecipePage` (ingredients/diets/
// units, for the review form) fetch — same endpoints, one fixture for both. // units, for the review form) fetch — same endpoints, one fixture for both.
@ -134,50 +209,26 @@ Given("importing the previewed item will succeed and return id {int}", (id: numb
}); });
Given("adding the imported recipe to the planning will succeed", () => { Given("adding the imported recipe to the planning will succeed", () => {
cy.intercept("POST", "**/planning/items", (req) => { cy.intercept("POST", "**/planning/items", {
fishPiePlanned = true; statusCode: 201,
req.reply({ body: {
statusCode: 201, id: 1,
body: { weekDay: "lundi",
id: 1, meal: "petit-dejeuner",
weekDay: "lundi", portions: 4,
meal: "petit-dejeuner", recipe: { id: 99, name: "Fish Pie" },
portions: 4, },
recipe: { id: 99, name: "Fish Pie" },
},
});
}).as("addPlanningItem"); }).as("addPlanningItem");
}); });
// Stateful — landing back on "/" after the import journey remounts // Every scenario here confirms/imports without ever leaving "/" (the
// `PlanningPage` from scratch (a real cross-route navigation, not a // footer's "Confirmer" patches the grid locally via `PlanningPage`'s own
// same-component state update: see `ImportRecipePage`'s `navigate("/")`), // `onAdded` — `RecipePickerDialog`'s doc comment — rather than navigating
// so only a fresh `GET /planning?date=` that reflects the just-added item // away and back), so unlike a real cross-route remount, this fixture never
// makes it show up there — nothing client-side survives that remount to // needs to reflect what's been added: the grid picks it up from local
// patch it in locally the way `PlanningPage`'s own `patchPlanningItems` // state, not a fresh fetch.
// does for an add made without leaving the page. Then("the recipe picker dialog should be closed", () => {
Given("the planning request reflects whatever's been added so far", () => { cy.get(".dialog-panel").should("not.exist");
cy.intercept("GET", /\/planning\?/, (req) => {
req.reply({
statusCode: 200,
body: fishPiePlanned
? {
id: 1,
startDate: "2026-08-17T00:00:00.000Z",
finishDate: "2026-08-23T00:00:00.000Z",
items: [
{
id: 1,
weekDay: "lundi",
meal: "petit-dejeuner",
portions: 4,
recipe: { id: 99, name: "Fish Pie" },
},
],
}
: null,
});
});
}); });
// The very first "+" in DOM order is Lundi's Petit-déjeuner cell (`MEALS`'s // The very first "+" in DOM order is Lundi's Petit-déjeuner cell (`MEALS`'s

View file

@ -45,7 +45,7 @@ Feature: Browsing external recipe sources
And the recipe detail panel heading should be "Fish Pie" And the recipe detail panel heading should be "Fish Pie"
And I should see the highlighted technique "Cuire" And I should see the highlighted technique "Cuire"
Scenario: Deep-links straight to a not-yet-imported item's own page Scenario: Deep-links straight to a not-yet-imported item's own page, with no import affordance at all
Given the recipe catalog contains nothing Given the recipe catalog contains nothing
And the sources reference list has options And the sources reference list has options
And the household has enabled TheMealDB And the household has enabled TheMealDB
@ -53,31 +53,5 @@ Feature: Browsing external recipe sources
And previewing TheMealDB item "9999" is available And previewing TheMealDB item "9999" is available
When I visit "/recettes/sources/theMealDb/9999" When I visit "/recettes/sources/theMealDb/9999"
Then the recipe detail panel heading should be "Fish Pie" Then the recipe detail panel heading should be "Fish Pie"
And I should see "Importer cette recette" And I should not see "Importer cette recette"
And I should see a discreet link to the item's original page
Scenario: Reviews an import, resolving an unrecognized ingredient before confirming
Given the recipe catalog contains nothing
And the sources reference list has options
And the household has enabled TheMealDB
And browsing TheMealDB returns some items
And previewing TheMealDB item "9999" is available
And the ingredient and diet catalog is available for import
And importing the previewed item will succeed and return id 99
When I visit "/recettes"
And I click the button "TheMealDB"
And I click the source item "Fish Pie"
And I click the link "Importer cette recette"
Then the "recipe-name" field should have the value "Fish Pie"
And the recipe should include the ingredient "Oignon"
When I choose an ingredient for the unresolved line "some mystery paste"
And I select the ingredient "Sel" from the picker
Then the unresolved ingredients section should no longer be shown
And there should be 2 ingredient rows
When I select unit "unité" for the first ingredient
And I fill in the last ingredient's quantity with "1" and unit "unité"
Then the "Importer" button should not be disabled
When I click the button "Importer"
Then the import request should have included ingredient 2 with quantity 1 and unitId 1
And the URL should include "/recettes/99"

View file

@ -135,66 +135,9 @@ Then("the source item {string} should be marked as already imported", (title: st
cy.contains("tr", title).find(".source-item-table__imported-badge").should("be.visible"); cy.contains("tr", title).find(".source-item-table__imported-badge").should("be.visible");
}); });
// ImportRecipePage (the review screen) loads its own ingredient/diet/unit // Browsing a source (outside of adding-to-planning, `RecipePickerDialog`'s
// catalogs the same way RecipeFormPage does — "onion" matches the resolved // own scenarios in planning.ts) never imports anything — the only action
// line in "previewing TheMealDB item ... is available" above, "salt" is // this preview offers is a discreet way out to the item's own page.
// what "some mystery paste" (unresolved in that same fixture) gets Then("I should see a discreet link to the item's original page", () => {
// corrected to in the review-and-import scenario. cy.get(".recipe-detail-panel__source-link").should("be.visible");
Given("the ingredient and diet catalog is available for import", () => {
cy.intercept("GET", "**/reference/ingredients", {
statusCode: 200,
body: [
{
id: 1,
key: "onion",
icon: "VEGETABLE",
category: "freshProduce",
subcategory: "vegetables",
allergens: [],
diets: [],
},
{
id: 2,
key: "salt",
icon: "SPICE",
category: "condimentsAndSpices",
subcategory: "spices",
allergens: [],
diets: [],
},
],
});
cy.intercept("GET", "**/reference/diets", {
statusCode: 200,
body: [{ id: 1, key: "omnivore" }],
});
cy.intercept("GET", "**/reference/units", {
statusCode: 200,
body: [{ id: 1, key: "piece", type: "COUNT", toBaseFactor: 1 }],
});
}); });
Given("importing the previewed item will succeed and return id {int}", (id: number) => {
cy.intercept("POST", "**/sources/theMealDb/import/9999", { statusCode: 201, body: { id } }).as(
"importRecipe",
);
});
When("I choose an ingredient for the unresolved line {string}", (rawText: string) => {
cy.contains(".import-recipe__unresolved-row", rawText)
.contains("button", "Choisir un ingrédient")
.click();
});
Then("the unresolved ingredients section should no longer be shown", () => {
cy.get(".import-recipe__unresolved").should("not.exist");
});
Then(
"the import request should have included ingredient {int} with quantity {int} and unitId {int}",
(ingredientId: number, quantity: number, unitId: number) => {
cy.wait("@importRecipe")
.its("request.body.ingredients")
.should("include.deep.members", [{ ingredientId, quantity, unitId }]);
},
);

View file

@ -22,11 +22,14 @@ export function Dialog({
title, title,
children, children,
className, className,
footer,
}: { }: {
onClose: () => void; onClose: () => void;
title?: string; title?: string;
children: ReactNode; children: ReactNode;
className?: string; className?: string;
/** Optional action bar pinned below the scrollable body (`.dialog-panel__footer`) — outside `.dialog-panel__body`'s own scroll, same idea as `title`'s header. Omit for a plain dialog with no persistent footer actions. */
footer?: ReactNode;
}) { }) {
const dialogRef = useRef<HTMLDialogElement>(null); const dialogRef = useRef<HTMLDialogElement>(null);
@ -99,6 +102,7 @@ export function Dialog({
</div> </div>
)} )}
<div className="dialog-panel__body">{children}</div> <div className="dialog-panel__body">{children}</div>
{footer && <div className="dialog-panel__footer">{footer}</div>}
</dialog> </dialog>
); );
} }

View file

@ -61,3 +61,13 @@
overflow-y: auto; overflow-y: auto;
padding: var(--space-lg); padding: var(--space-lg);
} }
.dialog-panel__footer {
flex-shrink: 0;
display: flex;
align-items: center;
justify-content: flex-end;
gap: var(--space-sm);
padding: var(--space-md) var(--space-lg);
border-top: 1px solid var(--color-border);
}

View file

@ -5,17 +5,21 @@ import {
type Meal, type Meal,
type PlanningItemView, type PlanningItemView,
type RecipeSummaryView, type RecipeSummaryView,
type RecipeView,
type WeekDay, type WeekDay,
} from "@batch-cooking/shared"; } from "@batch-cooking/shared";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { useNavigate } from "react-router-dom";
import { ApiError, apiClient } from "../../api/client"; import { ApiError, apiClient } from "../../api/client";
import { CheckboxOption } from "../../components/ui/Checkbox"; import { CheckboxOption } from "../../components/ui/Checkbox";
import { Dialog } from "../../components/ui/Dialog"; import { Dialog } from "../../components/ui/Dialog";
import { errorMessageService } from "../../services/error-message.service"; import { errorMessageService } from "../../services/error-message.service";
import { DietTagSelect } from "../recipes/DietTagSelect"; import { DietTagSelect } from "../recipes/DietTagSelect";
import { IngredientPicker } from "../recipes/IngredientPicker"; import { IngredientPicker } from "../recipes/IngredientPicker";
import { RecipeSourcesPanel } from "../recipes/RecipeSourcesPanel"; import { RecipeDetailPanel, type RecipeDetailState } from "../recipes/RecipeDetailPanel";
import { RecipeImportForm } from "../recipes/RecipeImportForm";
import { RecipeSourcesPanel, type SourceItemSelection } from "../recipes/RecipeSourcesPanel";
import { RecipeTable } from "../recipes/RecipeTable"; import { RecipeTable } from "../recipes/RecipeTable";
import { import {
RecipeTabs, RecipeTabs,
@ -23,6 +27,7 @@ import {
isSourceTab, isSourceTab,
parseSourceTabValue, parseSourceTabValue,
} from "../recipes/RecipeTabs"; } from "../recipes/RecipeTabs";
import { tryBuildCompleteImport } from "../recipes/recipe-import-draft";
import { useEnabledSources } from "../recipes/useEnabledSources"; import { useEnabledSources } from "../recipes/useEnabledSources";
import "./recipe-picker-dialog.scss"; import "./recipe-picker-dialog.scss";
@ -55,26 +60,36 @@ export interface PlanningSlot {
* (ingredients / regime / "convient à tout le foyer" toggle, all wired to * (ingredients / regime / "convient à tout le foyer" toggle, all wired to
* `GET /recipes`'s corresponding query params) since browsing here is * `GET /recipes`'s corresponding query params) since browsing here is
* about finding something to cook, not just looking something up. Each * about finding something to cook, not just looking something up. Each
* household-enabled source's own tab is included too (unlike an earlier * household-enabled source's own tab is included too.
* version of this dialog see `ImportRecipePage`'s `planningSlot`, the
* review/import flow that made including them here worthwhile): picking
* an already-imported item behaves exactly like picking a regular recipe,
* and picking one that isn't imported yet hands off to that review
* screen, which adds the freshly-created recipe straight to this slot
* once it's saved.
* *
* Mounted only while open (see `PlanningPage`, same conditional-mount * Mounted only while open (see `PlanningPage`, same conditional-mount
* convention as its own `CalendarPopover`) every piece of local state * convention as its own `CalendarPopover`) every piece of local state
* below resets for free the next time it's reopened, no manual reset * below resets for free the next time it's reopened, no manual reset
* needed. * needed.
* *
* Selecting a row doesn't navigate anywhere (unlike `RecipesPage`'s own * Clicking a row only ever *selects* it same "preview before you commit"
* use of `RecipeTable`) it switches this same dialog to a small * shape for every kind of row: a regular tab's own master-detail pair
* "how many portions?" confirmation step, then calls `POST * (`RecipeTable` + a `RecipeDetailPanel` fetched here, mirroring
* /planning/items` on submit. The one exception is picking a not-yet- * `RecipesPage`'s own layout) fetches and previews a real recipe; a
* imported source item, which does navigate away entirely (to * source tab's `RecipeSourcesPanel` already previews either kind of row it
* `/recettes/importer/...`) that flow has its own portions field * has (already-imported or not) inline, on its own. Nothing about a click
* already, on the review screen itself. * commits to anything by itself the pinned footer's "Confirmer" button
* (`handleFooterConfirm`) is what acts on whichever preview is currently
* pending (`previewedRecipe`/`previewedDraft`, mutually exclusive):
* - A real recipe (regular tab, or an already-imported source item) moves
* to the small "how many portions?" step (`selectedRecipe`), same as
* before this dialog grew a footer.
* - A not-yet-imported source item is what actually imports one nowhere
* else in the app does (see `confirmDraftSelection`) since a source
* item only ever becomes a real, saved `Recipe` as a side effect of
* someone adding it to their planning. When the draft has everything a
* real recipe needs, it's imported and added to this slot transparently
* no extra screen. Only when something's actually missing (an
* ingredient the automatic matcher couldn't resolve, say) does this
* switch to a third step instead, embedding the full review form
* (`RecipeImportForm`) right in this same dialog rather than navigating
* away to `ImportRecipePage` and losing the picker's own context (search
* term, filters, which slot this even was).
*/ */
export function RecipePickerDialog({ export function RecipePickerDialog({
slot, slot,
@ -86,9 +101,18 @@ export function RecipePickerDialog({
onAdded: (item: PlanningItemView) => void; onAdded: (item: PlanningItemView) => void;
}) { }) {
const { t } = useTranslation(); const { t } = useTranslation();
const navigate = useNavigate();
const [activeTab, setActiveTab] = useState<RecipesPageTab>("favoris"); const [activeTab, setActiveTab] = useState<RecipesPageTab>("favoris");
const activeSourceKey = parseSourceTabValue(activeTab); const activeSourceKey = parseSourceTabValue(activeTab);
/** Switching tabs drops whatever was previewed/pending on the one just left — a stale "Confirmer" target from a different tab would be confusing at best. */
function handleTabChange(tab: RecipesPageTab) {
setActiveTab(tab);
setPreviewedRecipe(null);
setPreviewedDraft(null);
setRegularPreviewState({ status: "empty" });
}
const enabledSources = useEnabledSources(); const enabledSources = useEnabledSources();
const [search, setSearch] = useState(""); const [search, setSearch] = useState("");
const [debouncedSearch, setDebouncedSearch] = useState(""); const [debouncedSearch, setDebouncedSearch] = useState("");
@ -101,14 +125,37 @@ export function RecipePickerDialog({
const [ingredientsCatalog, setIngredientsCatalog] = useState<IngredientView[]>([]); const [ingredientsCatalog, setIngredientsCatalog] = useState<IngredientView[]>([]);
const [dietsCatalog, setDietsCatalog] = useState<DietView[]>([]); const [dietsCatalog, setDietsCatalog] = useState<DietView[]>([]);
const [listState, setListState] = useState<ListState>({ status: "loading" }); const [listState, setListState] = useState<ListState>({ status: "loading" });
// Set when picking an already-imported source item fails to resolve to a // The regular tabs' own master-detail pair — `RecipeTable` on the left,
// real recipe (see `handleSelectImportedRecipe`) — a rare race (the // this on the right, fetched on row click (mirrors `RecipesPage`'s
// recipe was deleted between the browse fetch and the click), surfaced // identical layout). Source tabs don't use this at all: `RecipeSourcesPanel`
// the same way any other catalog load error is on this dialog. // previews its own rows internally.
const [sourceSelectError, setSourceSelectError] = useState(false); const [regularPreviewState, setRegularPreviewState] = useState<RecipeDetailState>({
status: "empty",
});
// Which real recipe is the pending selection — from either the regular
// tabs' own preview above, or a source tab's already-imported row
// (`RecipeSourcesPanel`'s `onSelectImportedRecipe`, which already
// previewed it internally). Mutually exclusive with `previewedDraft`
// below; the footer's "Confirmer" (`handleFooterConfirm`) acts on
// whichever one is set.
const [previewedRecipe, setPreviewedRecipe] = useState<RecipeView | null>(null);
// Which not-yet-imported source item is the pending selection —
// `RecipeSourcesPanel`'s `onDraftSelected`, fired the moment such a row
// is clicked (it previews itself internally; this is just "which one").
const [previewedDraft, setPreviewedDraft] = useState<SourceItemSelection | null>(null);
// True while `confirmDraftSelection` below is resolving the footer's
// "Confirmer" for a pending draft (fetch it, maybe import it, maybe add
// it to the slot) — disables the footer for that brief window rather
// than allowing a second click mid-flight.
const [isConfirmingDraft, setIsConfirmingDraft] = useState(false);
// Set by `confirmDraftSelection`'s fallback when the pending draft needs
// a person's input before it can be imported — switches this whole
// dialog to its third step (see the top-level `if` below), embedding
// `RecipeImportForm` instead of showing it inline here.
const [reviewDraftItem, setReviewDraftItem] = useState<SourceItemSelection | null>(null);
// The recipe picked in step 1 — `null` while still browsing, set once a // The recipe the footer's "Confirmer" moved to this small step for —
// row is clicked to switch this dialog into its confirmation step. // `null` while still browsing.
const [selectedRecipe, setSelectedRecipe] = useState<RecipeSummaryView | null>(null); const [selectedRecipe, setSelectedRecipe] = useState<RecipeSummaryView | null>(null);
const [portions, setPortions] = useState("1"); const [portions, setPortions] = useState("1");
const [isSubmitting, setIsSubmitting] = useState(false); const [isSubmitting, setIsSubmitting] = useState(false);
@ -168,16 +215,106 @@ export function RecipePickerDialog({
selectedIngredientIds.includes(ingredient.id), selectedIngredientIds.includes(ingredient.id),
); );
/** Picking an already-imported source item (one of the source tabs' `RecipeSourcesPanel`) — resolved to its full recipe, then treated exactly like picking that same recipe from one of the regular tabs, moving straight to the confirm-portions step below. */ /** A regular tab's own row click — fetches the full recipe and previews it in this dialog's own master-detail pair, exactly like `RecipesPage` does. */
function handleSelectImportedRecipe(recipeId: number) { function handleSelectRegularRecipe(id: number) {
setSourceSelectError(false); setPreviewedDraft(null);
setRegularPreviewState({ status: "loading" });
apiClient apiClient
.getRecipe(recipeId) .getRecipe(id)
.then((recipe) => { .then((recipe) => {
setSelectedRecipe(recipe); setRegularPreviewState({ status: "loaded", recipe });
setPortions(String(recipe.portions)); setPreviewedRecipe(recipe);
}) })
.catch(() => setSourceSelectError(true)); .catch(() => setRegularPreviewState({ status: "error" }));
}
/** A source tab's already-imported row — `RecipeSourcesPanel` already fetched and is previewing it itself; this just records it as the pending selection. */
function handleSelectImportedRecipe(recipe: RecipeView) {
setPreviewedDraft(null);
setPreviewedRecipe(recipe);
}
/** A source tab's not-yet-imported row — `RecipeSourcesPanel` previews it itself; this just records it as the pending selection. */
function handleDraftSelected(selection: SourceItemSelection) {
setPreviewedRecipe(null);
setPreviewedDraft(selection);
}
/**
* The footer's "Confirmer" acts on whichever preview is currently
* pending. A real recipe moves to the small portions step below; a
* not-yet-imported draft runs {@link confirmDraftSelection}.
*/
function handleFooterConfirm() {
if (previewedRecipe) {
setSelectedRecipe(previewedRecipe);
setPortions(String(previewedRecipe.portions));
return;
}
if (previewedDraft) {
void confirmDraftSelection(previewedDraft);
}
}
/**
* Confirming a not-yet-imported source item the one action in the
* whole app that actually imports one (see this component's own doc
* comment). Fetches its full draft, and when {@link tryBuildCompleteImport}
* finds nothing missing, imports it and adds it to `slot` transparently:
* no extra screen, same end result as picking any other recipe. Anything
* short of that an unresolved ingredient, a network hiccup on any of
* these three calls switches to the embedded review-form step instead
* (`setReviewDraftItem`), since only a person can supply what's actually
* missing.
*/
async function confirmDraftSelection(selection: SourceItemSelection) {
const { sourceKey, externalId } = selection;
setIsConfirmingDraft(true);
function needsReview() {
setIsConfirmingDraft(false);
setReviewDraftItem({ sourceKey, externalId });
}
let payload: ReturnType<typeof tryBuildCompleteImport>;
try {
payload = tryBuildCompleteImport(await apiClient.previewSourceItem(sourceKey, externalId));
} catch {
needsReview();
return;
}
if (!payload) {
needsReview();
return;
}
let saved: RecipeView;
try {
saved = await apiClient.importSourceItem(sourceKey, externalId, payload);
} catch {
needsReview();
return;
}
try {
const planningItem = await apiClient.addPlanningItem({
date: slot.date,
weekDay: slot.weekDay,
meal: slot.meal,
recipeId: saved.id,
portions: payload.portions,
});
onAdded(planningItem);
onClose();
} catch {
// The recipe itself is already saved at this point — only adding it
// to this slot failed. Land on its own page rather than retrying the
// whole import (same fallback `RecipeImportForm`'s own submit takes
// for the identical failure — see this dialog's `onImported` handler
// below).
setIsConfirmingDraft(false);
navigate(`/recettes/${saved.id}`);
}
} }
async function handleConfirm() { async function handleConfirm() {
@ -204,6 +341,34 @@ export function RecipePickerDialog({
} }
} }
if (reviewDraftItem) {
return (
<Dialog
onClose={onClose}
title={t("recipes.sources.import.title")}
className="recipe-picker-dialog"
>
<RecipeImportForm
sourceKey={reviewDraftItem.sourceKey}
externalId={reviewDraftItem.externalId}
planningSlot={slot}
onImported={({ recipe, planningItem }) => {
if (planningItem) {
onAdded(planningItem);
onClose();
} else {
// The recipe itself is saved at this point — only adding it
// to this slot failed. Same fallback as the transparent-
// import path above: land on its own page instead of
// retrying.
navigate(`/recettes/${recipe.id}`);
}
}}
/>
</Dialog>
);
}
if (selectedRecipe) { if (selectedRecipe) {
return ( return (
<Dialog <Dialog
@ -239,8 +404,29 @@ export function RecipePickerDialog({
); );
} }
const canConfirm = previewedRecipe !== null || previewedDraft !== null;
return ( return (
<Dialog onClose={onClose} title={t("planning.picker.title")} className="recipe-picker-dialog"> <Dialog
onClose={onClose}
title={t("planning.picker.title")}
className="recipe-picker-dialog"
footer={
<>
<button type="button" onClick={onClose}>
{t("planning.picker.footerClose")}
</button>
<button
type="button"
className="recipe-picker-confirm__confirm"
onClick={handleFooterConfirm}
disabled={!canConfirm || isConfirmingDraft}
>
{isConfirmingDraft ? t("planning.picker.adding") : t("planning.picker.footerConfirm")}
</button>
</>
}
>
{activeSourceKey === null && ( {activeSourceKey === null && (
<div className="recipe-picker__filters"> <div className="recipe-picker__filters">
<input <input
@ -308,24 +494,17 @@ export function RecipePickerDialog({
<RecipeTabs <RecipeTabs
active={activeTab} active={activeTab}
onChange={setActiveTab} onChange={handleTabChange}
sources={enabledSources.status === "loaded" ? enabledSources.sources : []} sources={enabledSources.status === "loaded" ? enabledSources.sources : []}
/> />
{activeSourceKey !== null ? ( {activeSourceKey !== null ? (
<> <RecipeSourcesPanel
{sourceSelectError && ( key={activeSourceKey}
<p className="recipes-page__status recipes-page__status--error"> sourceKey={activeSourceKey}
{t("common.loadError")} onSelectImportedRecipe={handleSelectImportedRecipe}
</p> onDraftSelected={handleDraftSelected}
)} />
<RecipeSourcesPanel
key={activeSourceKey}
sourceKey={activeSourceKey}
planningSlot={slot}
onSelectImportedRecipe={handleSelectImportedRecipe}
/>
</>
) : ( ) : (
<> <>
{listState.status === "loading" && ( {listState.status === "loading" && (
@ -340,18 +519,14 @@ export function RecipePickerDialog({
<p className="recipes-page__status">{t("planning.picker.empty")}</p> <p className="recipes-page__status">{t("planning.picker.empty")}</p>
)} )}
{listState.status === "loaded" && listState.recipes.length > 0 && ( {listState.status === "loaded" && listState.recipes.length > 0 && (
<RecipeTable <div className="recipes-page__catalog">
recipes={listState.recipes} <RecipeTable
selectedId={null} recipes={listState.recipes}
onSelect={(id) => { selectedId={previewedRecipe?.id ?? null}
const recipe = listState.recipes.find((r) => r.id === id) ?? null; onSelect={handleSelectRegularRecipe}
setSelectedRecipe(recipe); />
// Pre-fill from the recipe's own written yield rather than <RecipeDetailPanel state={regularPreviewState} showActions={false} />
// always starting at 1 — still freely editable below, this </div>
// is just a better starting point (see `Recipe.portions`).
if (recipe) setPortions(String(recipe.portions));
}}
/>
)} )}
</> </>
)} )}

View file

@ -5,7 +5,56 @@
// RecipeTabs import it themselves). // RecipeTabs import it themselves).
.recipe-picker-dialog { .recipe-picker-dialog {
max-width: 56rem; width: 95vw;
max-width: 85rem;
// Fixed, not just capped `.dialog-panel`'s own `max-height` (dialog.scss)
// only bounds how tall a shrink-to-fit dialog can grow, which is right
// for every other dialog's small form but leaves this one's browsing
// step at the mercy of how much content happens to be on screen (a short
// personal-recipe tab vs. a ~25-item source browse). Pinning both
// `height`/`max-height` to the same `80vh` keeps the browsing area a
// consistent, generous size regardless of tab/content, on top of the
// `.dialog-panel__body` fix below that makes that area actually use the
// space instead of overflowing it.
height: 80vh;
max-height: 80vh;
// `.dialog-panel__body` (dialog.scss) is a plain block-flow scroll
// container by default fine for every other dialog's small form, but
// this one's browsing step embeds the same components `/recettes` uses
// (`RecipeTable`'s `.recipe-table-wrap`, `RecipeSourcesPanel`'s
// `.recipes-page__catalog`), both of which size themselves with
// `flex: 1; min-height: 0` and need a `display: flex` ancestor for that
// to mean anything on the real page that ancestor is `.recipes-page`
// itself (see its own doc comment for the identical fix that page
// needed once); this dialog never renders that wrapper, so without this
// the catalog/table just grew to its full content height instead of
// being clipped and independently scrollable within the dialog's own
// bounds harmless for the handful of rows a personal recipe tab
// usually has, but a source tab's ~25-item browse list made it obvious:
// everything past the dialog's fixed height rendered, technically, just
// never inside the visible/scrollable area. Scoped to this dialog only
// every other `Dialog` caller keeps the plain block layout.
.dialog-panel__body {
display: flex;
flex-direction: column;
}
.recipe-table-wrap {
flex: 1;
min-height: 0;
}
// `.recipes-page__catalog`'s own column split (recipes.scss) sizes the
// detail pane with `minmax(35vw, 38vw)` a fraction of the *viewport*,
// which tracked `/recettes`' own width there (that grid spans nearly the
// whole page) but has nothing to do with this dialog's width, now a
// fixed 92vw/75rem of its own. Overridden here as a fraction of the
// dialog itself instead, so the two panes stay proportionate to each
// other regardless of viewport size.
.recipes-page__catalog {
grid-template-columns: minmax(0, 3fr) minmax(0, 2fr);
}
} }
.recipe-picker__filters { .recipe-picker__filters {
@ -111,6 +160,8 @@
border: none; border: none;
border-radius: var(--radius-base); border-radius: var(--radius-base);
padding: var(--space-sm) var(--space-md); padding: var(--space-sm) var(--space-md);
font-family: var(--font-body);
font-size: var(--font-size-sm);
font-weight: 600; font-weight: 600;
cursor: pointer; cursor: pointer;
@ -123,3 +174,27 @@
cursor: not-allowed; cursor: not-allowed;
} }
} }
// The dialog-level footer (`Dialog`'s `footer` prop, used by this dialog's
// main browsing step) "Confirmer" reuses `.recipe-picker-confirm__confirm`
// above (same primary-button look as the portions step's own "Ajouter au
// planning"), "Fermer" needs its own secondary style since a bare
// `<button>` here would render with the browser's own default sizing/
// font instead of matching it same "bordered, no fill" secondary look
// as `.recipe-detail-panel__delete-confirm`'s cancel button.
.dialog-panel__footer button:not(.recipe-picker-confirm__confirm) {
padding: var(--space-sm) var(--space-md);
font-family: var(--font-body);
font-size: var(--font-size-sm);
font-weight: 600;
cursor: pointer;
border-radius: var(--radius-base);
border: 1px solid var(--color-border);
background: var(--color-surface);
color: var(--color-text);
&:hover {
border-color: var(--color-primary);
color: var(--color-primary);
}
}

View file

@ -1,14 +1,9 @@
import { import { ErrorCode, type RecipeImportDraftView, type RecipeView } from "@batch-cooking/shared";
ErrorCode,
type Meal,
type RecipeImportDraftView,
type RecipeView,
type WeekDay,
} from "@batch-cooking/shared";
import { useState } from "react"; import { useState } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { Link } from "react-router-dom"; import { Link } from "react-router-dom";
import { ApiError, apiClient } from "../../api/client"; import { ApiError, apiClient } from "../../api/client";
import { SourceLinkIcon } from "../../layouts/nav-icons";
import { errorMessageService } from "../../services/error-message.service"; import { errorMessageService } from "../../services/error-message.service";
import { AllergenBadges } from "./AllergenBadges"; import { AllergenBadges } from "./AllergenBadges";
import { FavoriteStarButton } from "./FavoriteStarButton"; import { FavoriteStarButton } from "./FavoriteStarButton";
@ -20,11 +15,14 @@ import "./recipes.scss";
* is distinct from `"not-found"` (a selected id that turned out invalid/ * is distinct from `"not-found"` (a selected id that turned out invalid/
* inaccessible), each with its own message. `"loaded-draft"` is the one * inaccessible), each with its own message. `"loaded-draft"` is the one
* variant that isn't a real, saved `Recipe`: a not-yet-imported source * variant that isn't a real, saved `Recipe`: a not-yet-imported source
* item's preview (`RecipeSourcesPanel`'s "Sources" tab) rendered through * item's read-only preview (`RecipeSourcesPanel`'s source tabs) rendered
* this exact same component so viewing one looks and behaves like viewing * through this exact same component so viewing one looks like viewing any
* any other recipe ("comme si c'était importé"), differing only in which * other recipe, minus every action that doesn't make sense on something
* actions make sense (there's nothing to favorite/edit/delete yet, but * that isn't saved yet (favorite/edit/delete nor a manual "import"
* there is something to *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 = export type RecipeDetailState =
| { status: "empty" } | { status: "empty" }
@ -50,20 +48,14 @@ export function RecipeDetailPanel({
dislikedIngredientIds = [], dislikedIngredientIds = [],
onFavoriteToggled, onFavoriteToggled,
onDeleted, onDeleted,
planningSlot, showActions = true,
}: { }: {
state: RecipeDetailState; state: RecipeDetailState;
dislikedIngredientIds?: number[]; dislikedIngredientIds?: number[];
onFavoriteToggled?: (recipeId: number, isFavorite: boolean) => void; onFavoriteToggled?: (recipeId: number, isFavorite: boolean) => void;
onDeleted?: (recipeId: number) => void; onDeleted?: (recipeId: number) => void;
/** /** Default `true`. `false` hides the favorite star and Modifier/Supprimer buttons on the `"loaded"` branch — for a caller previewing someone's pick before deciding what happens next (`RecipePickerDialog`'s browsing step), not viewing one's own catalog. */
* Set only when this panel is rendered from `RecipePickerDialog` (adding a showActions?: boolean;
* recipe to one planning slot) carried along on a `"loaded-draft"`
* item's "Importer cette recette" link as query params, so `ImportRecipePage`
* knows to add the freshly-created recipe to this exact slot once the
* import succeeds. See `ImportRecipePage`'s own `planningSlot`.
*/
planningSlot?: { date: string; weekDay: WeekDay; meal: Meal };
}) { }) {
const { t } = useTranslation(); const { t } = useTranslation();
@ -108,6 +100,18 @@ export function RecipeDetailPanel({
<div className="recipe-detail-panel__photo" aria-hidden="true"> <div className="recipe-detail-panel__photo" aria-hidden="true">
{draft.picture ? <img src={draft.picture} alt="" /> : "🍽️"} {draft.picture ? <img src={draft.picture} alt="" /> : "🍽️"}
</div> </div>
{draft.sourceUrl && (
<a
href={draft.sourceUrl}
target="_blank"
rel="noreferrer"
className="recipe-detail-panel__source-link"
title={t("recipes.sources.detail.viewSource")}
aria-label={t("recipes.sources.detail.viewSource")}
>
<SourceLinkIcon aria-hidden="true" />
</a>
)}
</div> </div>
<div className="recipe-detail-panel__title-row"> <div className="recipe-detail-panel__title-row">
@ -121,28 +125,6 @@ export function RecipeDetailPanel({
</div> </div>
</div> </div>
<div className="recipe-detail-panel__actions">
<Link
to={{
pathname: `/recettes/importer/${draft.sourceKey}/${encodeURIComponent(draft.externalId)}`,
search: planningSlot
? `?planningDate=${planningSlot.date}&planningWeekDay=${planningSlot.weekDay}&planningMeal=${planningSlot.meal}`
: undefined,
}}
className="recipes-page__new-button"
>
{t("recipes.sources.detail.importButton")}
</Link>
<a
href={draft.sourceUrl}
target="_blank"
rel="noreferrer"
className="recipes-page__new-button"
>
{t("recipes.sources.detail.viewSource")}
</a>
</div>
{draft.description && ( {draft.description && (
<section className="recipe-detail-panel__section recipe-detail-panel__section--description"> <section className="recipe-detail-panel__section recipe-detail-panel__section--description">
<p className="recipe-detail-panel__description">{draft.description}</p> <p className="recipe-detail-panel__description">{draft.description}</p>
@ -175,11 +157,13 @@ export function RecipeDetailPanel({
<div className="recipe-detail-panel__photo" aria-hidden="true"> <div className="recipe-detail-panel__photo" aria-hidden="true">
{recipe.picture ? <img src={recipe.picture} alt="" /> : "🍽️"} {recipe.picture ? <img src={recipe.picture} alt="" /> : "🍽️"}
</div> </div>
<FavoriteStarButton {showActions && (
recipeId={recipe.id} <FavoriteStarButton
isFavorite={recipe.isFavorite} recipeId={recipe.id}
onToggled={(isFavorite) => onFavoriteToggled?.(recipe.id, isFavorite)} isFavorite={recipe.isFavorite}
/> onToggled={(isFavorite) => onFavoriteToggled?.(recipe.id, isFavorite)}
/>
)}
</div> </div>
<div className="recipe-detail-panel__title-row"> <div className="recipe-detail-panel__title-row">
@ -203,12 +187,14 @@ export function RecipeDetailPanel({
</div> </div>
</div> </div>
<div className="recipe-detail-panel__actions"> {showActions && (
<Link to={`/recettes/${recipe.id}/modifier`} className="recipes-page__new-button"> <div className="recipe-detail-panel__actions">
{t("recipes.editButton")} <Link to={`/recettes/${recipe.id}/modifier`} className="recipes-page__new-button">
</Link> {t("recipes.editButton")}
<DeleteRecipeButton recipeId={recipe.id} onDeleted={() => onDeleted?.(recipe.id)} /> </Link>
</div> <DeleteRecipeButton recipeId={recipe.id} onDeleted={() => onDeleted?.(recipe.id)} />
</div>
)}
{recipe.description && ( {recipe.description && (
<section className="recipe-detail-panel__section recipe-detail-panel__section--description"> <section className="recipe-detail-panel__section recipe-detail-panel__section--description">

View file

@ -0,0 +1,437 @@
import {
type CreateRecipeInput,
type DietView,
ErrorCode,
type IngredientView,
type Meal,
type PlanningItemView,
type RecipeView,
type RecipeVisibility,
type UnitView,
type WeekDay,
createRecipeSchema,
} 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 "./DietTagSelect";
import { IngredientPicker } from "./IngredientPicker";
import { IngredientRow } from "./IngredientRow";
import { type StepDraft, StepListEditor } from "./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`. */
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";
/**
* 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<LoadState>("loading");
const [ingredientsCatalog, setIngredientsCatalog] = useState<IngredientView[]>([]);
const [dietsCatalog, setDietsCatalog] = useState<DietView[]>([]);
const [unitsCatalog, setUnitsCatalog] = useState<UnitView[]>([]);
const [name, setName] = useState("");
const [description, setDescription] = useState("");
const [picture, setPicture] = useState("");
const [portions, setPortions] = useState("4");
const [visibility, setVisibility] = useState<RecipeVisibility>("PERSONAL");
const [dietIds, setDietIds] = useState<number[]>([]);
const [ingredientLines, setIngredientLines] = useState<IngredientLine[]>([]);
const [unresolvedIngredients, setUnresolvedIngredients] = useState<UnresolvedIngredientLine[]>(
[],
);
// 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<string | null>(null);
const [steps, setSteps] = useState<StepDraft[]>([]);
const [formError, setFormError] = useState<string | null>(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<Pick<IngredientLine, "quantity" | "unitId">>,
) {
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);
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 {
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 (
<div className="recipe-form">
<p className="recipes-page__status">{t("recipes.loading")}</p>
</div>
);
}
if (loadState === "error") {
return (
<div className="recipe-form">
<p className="recipes-page__status recipes-page__status--error">
{t("recipes.sources.import.loadError")}
</p>
</div>
);
}
const selectedIds = ingredientLines.map((line) => line.ingredient.id);
return (
<form className="recipe-form" onSubmit={handleSubmit} noValidate>
{planningSlot && (
<p className="source-item-preview__hint">{t("recipes.sources.import.planningHint")}</p>
)}
<label htmlFor="recipe-name">{t("recipes.form.nameLabel")}</label>
<input id="recipe-name" value={name} onChange={(e) => setName(e.target.value)} />
<label htmlFor="recipe-description">{t("recipes.form.descriptionLabel")}</label>
<textarea
id="recipe-description"
value={description}
onChange={(e) => setDescription(e.target.value)}
rows={3}
/>
<label htmlFor="recipe-picture">{t("recipes.form.pictureLabel")}</label>
<input
id="recipe-picture"
type="url"
value={picture}
onChange={(e) => setPicture(e.target.value)}
placeholder="https://…"
/>
<label htmlFor="recipe-portions">{t("recipes.form.portionsLabel")}</label>
<input
id="recipe-portions"
type="number"
min="1"
step="1"
value={portions}
onChange={(e) => setPortions(e.target.value)}
/>
<label htmlFor="recipe-visibility">{t("recipes.form.visibilityLabel")}</label>
<select
id="recipe-visibility"
value={visibility}
onChange={(e) => setVisibility(e.target.value as RecipeVisibility)}
>
{VISIBILITY_OPTIONS.map((option) => (
<option key={option} value={option}>
{t(`recipes.form.visibility.${option}`)}
</option>
))}
</select>
<DietTagSelect diets={dietsCatalog} value={dietIds} onChange={setDietIds} />
<section className="recipe-form__section">
<h2>{t("recipes.ingredientsTitle")}</h2>
<ul className="recipe-form__ingredient-list">
{ingredientLines.map((line) => (
<IngredientRow
key={line.key}
ingredient={line.ingredient}
quantity={line.quantity}
unitId={line.unitId}
unitsCatalog={unitsCatalog}
onQuantityChange={(quantity) => updateIngredientLine(line.key, { quantity })}
onUnitChange={(unitId) => updateIngredientLine(line.key, { unitId })}
onRemove={() => removeIngredientLine(line.key)}
/>
))}
</ul>
{unresolvedIngredients.length > 0 && (
<section className="import-recipe__unresolved">
<h3>{t("recipes.sources.import.unresolvedTitle")}</h3>
<p className="source-item-preview__hint">
{t("recipes.sources.import.unresolvedHint")}
</p>
<ul className="import-recipe__unresolved-list">
{unresolvedIngredients.map((line) => (
<li key={line.key}>
<div className="import-recipe__unresolved-row">
<span>{line.rawText}</span>
<button
type="button"
onClick={() =>
setResolvingKey((current) => (current === line.key ? null : line.key))
}
>
{t("recipes.sources.import.resolveButton")}
</button>
<button type="button" onClick={() => discardUnresolvedIngredient(line.key)}>
{t("recipes.sources.import.discardButton")}
</button>
</div>
{resolvingKey === line.key && (
<IngredientPicker
ingredients={ingredientsCatalog}
excludeIds={selectedIds}
onSelect={(ingredient) => resolveIngredient(line.key, ingredient)}
/>
)}
</li>
))}
</ul>
</section>
)}
<IngredientPicker
ingredients={ingredientsCatalog}
excludeIds={selectedIds}
onSelect={addIngredient}
/>
</section>
<section className="recipe-form__section">
<h2>{t("recipes.stepsTitle")}</h2>
<StepListEditor steps={steps} onChange={setSteps} />
</section>
{formError && <p className="form-error">{formError}</p>}
<div className="recipe-form__actions">
<button type="submit" disabled={isSubmitting || !canSubmit}>
{isSubmitting
? t("recipes.sources.import.submitting")
: t("recipes.sources.import.submit")}
</button>
</div>
</form>
);
}

View file

@ -1,4 +1,4 @@
import type { BrowsableSourceItemView, Meal, WeekDay } from "@batch-cooking/shared"; import type { BrowsableSourceItemView, RecipeView } from "@batch-cooking/shared";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { apiClient } from "../../api/client"; import { apiClient } from "../../api/client";
@ -37,41 +37,37 @@ type BrowseState =
* `RecipePickerDialog`/`CalendarPopover` elsewhere simpler than this * `RecipePickerDialog`/`CalendarPopover` elsewhere simpler than this
* component reacting to its own `sourceKey` prop changing mid-lifetime. * component reacting to its own `sourceKey` prop changing mid-lifetime.
* *
* The right-hand preview reuses `RecipeDetailPanel` itself (its * The right-hand preview reuses `RecipeDetailPanel` itself for a
* `"loaded-draft"` state) rather than a separate component viewing a * not-yet-imported item, its `"loaded-draft"` state; for an already-
* not-yet-imported item is meant to look and feel exactly like viewing any * imported one, this panel fetches the real thing (`GET /recipes/:id`)
* other recipe, differing only in which actions are offered (there's an * and shows it through the exact same `"loaded"` state `RecipesPage` uses,
* "Importer" button where Modifier/Supprimer would be). * `showActions={false}` since editing/deleting isn't a click that belongs
* * on a browsing/preview screen. Either way, selecting a row only ever
* Selecting an already-imported item navigates straight to its real * previews here nothing about clicking one imports, saves, or navigates
* recipe (`/recettes/:id`, leaving this tab) `onSelectImportedRecipe` * by itself; what a selection *means* is entirely up to the caller
* hands back the id instead of this panel navigating anywhere itself, since * (`onSelectImportedRecipe`/`onDraftSelected` below just report which one
* what "viewing" an already-imported item means depends on the caller: * is currently previewed).
* `RecipesPage` navigates to the recipe's detail page (switching its own
* active tab first see its own doc comment), while `RecipePickerDialog`
* instead treats it exactly like picking that recipe from one of the
* regular tabs moving to its own confirm-portions step, no navigation
* at all.
* *
* `initialSelection`/`onItemSelected` are how `RecipesPage` keeps a * `initialSelection`/`onItemSelected` are how `RecipesPage` keeps a
* not-yet-imported item's preview addressable by URL * not-yet-imported item's preview addressable by URL
* (`/recettes/sources/:sourceKey/:externalId`) without this panel needing * (`/recettes/sources/:sourceKey/:externalId`) without this panel needing
* to know anything about routing itself it reports selection changes * to know anything about routing itself it reports selection changes
* upward, and re-previews on mount/prop-change if handed one back. * upward, and re-previews on mount/prop-change if handed one back.
* `RecipePickerDialog` leaves both unset: previewing inside that modal has * `RecipePickerDialog` leaves both unset: previewing inside that dialog
* no URL of its own to keep in sync. * has no URL of its own to keep in sync.
*/ */
export function RecipeSourcesPanel({ export function RecipeSourcesPanel({
sourceKey, sourceKey,
onSelectImportedRecipe, onSelectImportedRecipe,
planningSlot, onDraftSelected,
initialSelection, initialSelection,
onItemSelected, onItemSelected,
}: { }: {
sourceKey: string; sourceKey: string;
onSelectImportedRecipe: (recipeId: number) => void; /** Fired once an already-imported row's own fetch resolves — the full recipe, already what this panel is itself previewing, handed up so the caller (`RecipePickerDialog`) knows a real recipe is now the pending selection without fetching it again itself. */
/** Forwarded as-is to `RecipeDetailPanel` — see its own doc comment. Only ever set by `RecipePickerDialog`. */ onSelectImportedRecipe: (recipe: RecipeView) => void;
planningSlot?: { date: string; weekDay: WeekDay; meal: Meal }; /** Fired the moment a not-yet-imported row is clicked (before its own preview fetch even resolves) — same "which one is pending" role as `onSelectImportedRecipe`, just for a draft instead of a real recipe. Only `RecipePickerDialog` sets this; `RecipesPage` has nothing to do with "pending" since browsing there is never building up to a confirm step. */
onDraftSelected?: (selection: SourceItemSelection) => void;
initialSelection?: SourceItemSelection; initialSelection?: SourceItemSelection;
onItemSelected?: (item: SourceItemSelection | null) => void; onItemSelected?: (item: SourceItemSelection | null) => void;
}) { }) {
@ -165,7 +161,15 @@ export function RecipeSourcesPanel({
function handleSelectItem(item: BrowsableSourceItemView) { function handleSelectItem(item: BrowsableSourceItemView) {
if (item.alreadyImported && item.recipeId !== null) { if (item.alreadyImported && item.recipeId !== null) {
onSelectImportedRecipe(item.recipeId); setSelectedExternalId(item.externalId);
setPreviewState({ status: "loading" });
apiClient
.getRecipe(item.recipeId)
.then((recipe) => {
setPreviewState({ status: "loaded", recipe });
onSelectImportedRecipe(recipe);
})
.catch(() => setPreviewState({ status: "error" }));
return; return;
} }
const selection = { sourceKey, externalId: item.externalId }; const selection = { sourceKey, externalId: item.externalId };
@ -176,6 +180,7 @@ export function RecipeSourcesPanel({
// not a fresh one to make (see that effect's own doc comment). // not a fresh one to make (see that effect's own doc comment).
setPreviewedItem(selection); setPreviewedItem(selection);
onItemSelected?.(selection); onItemSelected?.(selection);
onDraftSelected?.(selection);
apiClient apiClient
.previewSourceItem(sourceKey, item.externalId) .previewSourceItem(sourceKey, item.externalId)
.then((draft) => setPreviewState({ status: "loaded-draft", draft })) .then((draft) => setPreviewState({ status: "loaded-draft", draft }))
@ -221,7 +226,7 @@ export function RecipeSourcesPanel({
</div> </div>
)} )}
<RecipeDetailPanel state={previewState} planningSlot={planningSlot} /> <RecipeDetailPanel state={previewState} showActions={false} />
</div> </div>
</> </>
); );

View file

@ -0,0 +1,60 @@
import {
type CreateRecipeInput,
type RecipeImportDraftView,
createRecipeSchema,
} from "@batch-cooking/shared";
/**
* Attempts to turn `draft` straight into a submittable {@link CreateRecipeInput}
* no form, no person involved for the planning picker's transparent
* import path (`RecipePickerDialog`'s `handleSelectDraftItem`): adding a
* not-yet-imported source item to a planning slot should just work,
* silently, whenever nothing about it actually needs a human's judgment
* call. Returns `null` the moment anything does an ingredient line the
* automatic matcher (`ingredient-matcher.ts`) couldn't resolve to a real
* ingredient/unit/quantity, or a missing portions count so the caller can
* fall back to the full review screen (`ImportRecipePage`), pre-filled from
* this exact same draft, for a person to fill in what's missing.
*
* Default `visibility`/`dietIds` mirror what a person would otherwise leave
* untouched on that same form (`PERSONAL`, no diet tags) nothing here is
* a guess about data the draft doesn't have an opinion on.
* `createRecipeSchema.safeParse` is still the actual authority on whether
* the result is submittable (a positive-portions check, string lengths,
* etc.) the checks above it exist only for what a schema alone can't
* catch: `ingredient`/`unit` being resolved references, not just present
* values.
*/
export function tryBuildCompleteImport(draft: RecipeImportDraftView): CreateRecipeInput | null {
if (draft.portions === null) return null;
if (draft.ingredients.length === 0) return null;
if (
draft.ingredients.some(
(line) => line.ingredient === null || line.unit === null || line.quantity === null,
)
) {
return null;
}
const candidate: CreateRecipeInput = {
name: draft.name,
description: draft.description,
picture: draft.picture,
portions: draft.portions,
visibility: "PERSONAL",
dietIds: [],
ingredients: draft.ingredients.map((line) => ({
// Every line is fully resolved by this point — guarded above.
ingredientId: (line.ingredient as NonNullable<typeof line.ingredient>).id,
quantity: line.quantity as number,
unitId: (line.unit as NonNullable<typeof line.unit>).id,
})),
steps: draft.steps.map((step) => ({
description: step.description,
picture: step.picture,
})),
};
const result = createRecipeSchema.safeParse(candidate);
return result.success ? result.data : null;
}

View file

@ -430,11 +430,9 @@
&__photo { &__photo {
height: 9rem; height: 9rem;
width: 100%; width: 100%;
background: linear-gradient( background: linear-gradient(160deg,
160deg, color-mix(in srgb, var(--color-primary) 22%, var(--color-surface-alt)),
color-mix(in srgb, var(--color-primary) 22%, var(--color-surface-alt)), var(--color-surface-alt));
var(--color-surface-alt)
);
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
@ -541,7 +539,7 @@
flex-shrink: 0; flex-shrink: 0;
padding: var(--space-md); padding: var(--space-md);
& + & { &+& {
border-top: 1px solid var(--color-border); border-top: 1px solid var(--color-border);
} }
@ -674,6 +672,42 @@
} }
} }
// --- External source link (draft preview header) ----------------------------
// Same overlay slot/sizing as `.favorite-star-button` above a draft
// preview never has both (nothing to favorite yet), so the two never
// compete for the corner. Deliberately muted/small ("discret" per the
// product decision this button follows): a way out to the original page,
// not a call to action the way the buttons it replaced were.
.recipe-detail-panel__source-link {
position: absolute;
top: var(--space-sm);
right: var(--space-sm);
width: 2.2rem;
height: 2.2rem;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
background: color-mix(in srgb, var(--color-surface) 82%, transparent);
box-shadow: var(--shadow-sm);
color: var(--color-text-muted);
transition:
transform 0.12s ease,
color 0.12s ease;
svg {
width: 1.1rem;
height: 1.1rem;
}
&:hover,
&:focus-visible {
color: var(--color-primary);
transform: scale(1.08);
outline: none;
}
}
// --- Recipe form (create/edit) ---------------------------------------------- // --- Recipe form (create/edit) ----------------------------------------------
// Per-field validation and whole-form error messages same small rules as // Per-field validation and whole-form error messages same small rules as
// auth-form.scss/profile-forms.scss, redeclared here rather than shared // auth-form.scss/profile-forms.scss, redeclared here rather than shared
@ -693,7 +727,6 @@
.recipe-form { .recipe-form {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
max-width: 40rem;
gap: var(--space-xs); gap: var(--space-xs);
label { label {
@ -1328,4 +1361,4 @@
} }
} }
} }
} }

View file

@ -26,4 +26,5 @@ export {
Star as FavoriteIcon, Star as FavoriteIcon,
Globe as PublicIcon, Globe as PublicIcon,
Rss as SourcesIcon, Rss as SourcesIcon,
ExternalLink as SourceLinkIcon,
} from "lucide-react"; } from "lucide-react";

View file

@ -142,7 +142,9 @@
"portionsLabel": "Nombre de portions", "portionsLabel": "Nombre de portions",
"backButton": "Retour", "backButton": "Retour",
"confirmButton": "Ajouter au planning", "confirmButton": "Ajouter au planning",
"adding": "Ajout…" "adding": "Ajout…",
"footerClose": "Fermer",
"footerConfirm": "Confirmer"
} }
}, },
"recipes": { "recipes": {
@ -177,8 +179,7 @@
"loading": "Chargement…", "loading": "Chargement…",
"loadError": "Impossible de charger cette source pour le moment.", "loadError": "Impossible de charger cette source pour le moment.",
"detail": { "detail": {
"viewSource": "Voir sur le site d'origine", "viewSource": "Voir sur le site d'origine"
"importButton": "Importer cette recette"
}, },
"import": { "import": {
"title": "Revoir l'import", "title": "Revoir l'import",

View file

@ -1,47 +1,8 @@
import { import { MEALS, type Meal, WEEK_DAYS, type WeekDay } from "@batch-cooking/shared";
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 { useTranslation } from "react-i18next";
import { useNavigate, useParams, useSearchParams } from "react-router-dom"; import { useNavigate, useParams, useSearchParams } from "react-router-dom";
import { ApiError, apiClient } from "../api/client"; import { RecipeImportForm } from "../features/recipes/RecipeImportForm";
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 "../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 * Reads and validates `?planningDate=&planningWeekDay=&planningMeal=` off
@ -71,36 +32,20 @@ function parsePlanningSlot(
} }
/** /**
* Review screen for finalizing an import routed at * Standalone route wrapper around `RecipeImportForm`, at
* `/recettes/importer/:sourceKey/:externalId` (reached from * `/recettes/importer/:sourceKey/:externalId` a directly-linkable
* `RecipeDetailPanel`'s "Importer cette recette" button, shown for its * fallback (a stale bookmark, a reload mid-flow) rather than the normal way
* `"loaded-draft"` state). Pre-filled * this form is reached now: `RecipePickerDialog` embeds `RecipeImportForm`
* from `GET /sources/:sourceKey/preview/:externalId` (the same draft the * directly as one of its own steps, without ever navigating here, when
* preview panel already showed), structurally the same form as * picking a not-yet-imported source item turns out to need a person's
* `RecipeFormPage` same sub-components (`IngredientRow`, * input (see that dialog's own doc comment). Landing on a real page instead
* `IngredientPicker`, `StepListEditor`, `DietTagSelect`), same * of a dialog step just changes where a successful import goes afterwards
* `CreateRecipeInput` submit shape plus one thing a manual creation * this page's own `handleImported` navigates, the dialog's own handler
* never has to handle: ingredient lines the automatic matching * closes itself instead.
* (`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` * `?planningDate=&planningWeekDay=&planningMeal=` would only realistically
* (`apiClient.importSourceItem`) instead of `POST /recipes` the only * be set by a stale URL at this point (the dialog's own path never
* other difference from `RecipeFormPage`'s own submit. * navigates), still parsed the same defensive way as before.
*
* `?planningDate=&planningWeekDay=&planningMeal=` are set only when this
* page was reached from `RecipePickerDialog`'s "Sources" tab (via
* `RecipeDetailPanel`'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() { export function ImportRecipePage() {
const { t } = useTranslation(); const { t } = useTranslation();
@ -109,218 +54,7 @@ export function ImportRecipePage() {
const [searchParams] = useSearchParams(); const [searchParams] = useSearchParams();
const planningSlot = parsePlanningSlot(searchParams); const planningSlot = parsePlanningSlot(searchParams);
const [loadState, setLoadState] = useState<LoadState>("loading"); if (sourceKey === undefined || externalId === undefined) {
const [ingredientsCatalog, setIngredientsCatalog] = useState<IngredientView[]>([]);
const [dietsCatalog, setDietsCatalog] = useState<DietView[]>([]);
const [unitsCatalog, setUnitsCatalog] = useState<UnitView[]>([]);
const [name, setName] = useState("");
const [description, setDescription] = useState("");
const [picture, setPicture] = useState("");
const [portions, setPortions] = useState("4");
const [visibility, setVisibility] = useState<RecipeVisibility>("PERSONAL");
const [dietIds, setDietIds] = useState<number[]>([]);
const [ingredientLines, setIngredientLines] = useState<IngredientLine[]>([]);
const [unresolvedIngredients, setUnresolvedIngredients] = useState<UnresolvedIngredientLine[]>(
[],
);
// 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<string | null>(null);
const [steps, setSteps] = useState<StepDraft[]>([]);
const [formError, setFormError] = useState<string | null>(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<Pick<IngredientLine, "quantity" | "unitId">>,
) {
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 (
<div className="recipe-form">
<p className="recipes-page__status">{t("recipes.loading")}</p>
</div>
);
}
if (loadState === "error") {
return ( return (
<div className="recipe-form"> <div className="recipe-form">
<p className="recipes-page__status recipes-page__status--error"> <p className="recipes-page__status recipes-page__status--error">
@ -330,134 +64,17 @@ export function ImportRecipePage() {
); );
} }
const selectedIds = ingredientLines.map((line) => line.ingredient.id);
return ( return (
<form className="recipe-form" onSubmit={handleSubmit} noValidate> <div>
<h1>{t("recipes.sources.import.title")}</h1> <h1>{t("recipes.sources.import.title")}</h1>
{planningSlot && ( <RecipeImportForm
<p className="source-item-preview__hint">{t("recipes.sources.import.planningHint")}</p> sourceKey={sourceKey}
)} externalId={externalId}
planningSlot={planningSlot ?? undefined}
<label htmlFor="recipe-name">{t("recipes.form.nameLabel")}</label> onImported={({ recipe, planningItem }) => {
<input id="recipe-name" value={name} onChange={(e) => setName(e.target.value)} /> navigate(planningItem ? "/" : `/recettes/${recipe.id}`);
}}
<label htmlFor="recipe-description">{t("recipes.form.descriptionLabel")}</label>
<textarea
id="recipe-description"
value={description}
onChange={(e) => setDescription(e.target.value)}
rows={3}
/> />
</div>
<label htmlFor="recipe-picture">{t("recipes.form.pictureLabel")}</label>
<input
id="recipe-picture"
type="url"
value={picture}
onChange={(e) => setPicture(e.target.value)}
placeholder="https://…"
/>
<label htmlFor="recipe-portions">{t("recipes.form.portionsLabel")}</label>
<input
id="recipe-portions"
type="number"
min="1"
step="1"
value={portions}
onChange={(e) => setPortions(e.target.value)}
/>
<label htmlFor="recipe-visibility">{t("recipes.form.visibilityLabel")}</label>
<select
id="recipe-visibility"
value={visibility}
onChange={(e) => setVisibility(e.target.value as RecipeVisibility)}
>
{VISIBILITY_OPTIONS.map((option) => (
<option key={option} value={option}>
{t(`recipes.form.visibility.${option}`)}
</option>
))}
</select>
<DietTagSelect diets={dietsCatalog} value={dietIds} onChange={setDietIds} />
<section className="recipe-form__section">
<h2>{t("recipes.ingredientsTitle")}</h2>
<ul className="recipe-form__ingredient-list">
{ingredientLines.map((line) => (
<IngredientRow
key={line.key}
ingredient={line.ingredient}
quantity={line.quantity}
unitId={line.unitId}
unitsCatalog={unitsCatalog}
onQuantityChange={(quantity) => updateIngredientLine(line.key, { quantity })}
onUnitChange={(unitId) => updateIngredientLine(line.key, { unitId })}
onRemove={() => removeIngredientLine(line.key)}
/>
))}
</ul>
{unresolvedIngredients.length > 0 && (
<section className="import-recipe__unresolved">
<h3>{t("recipes.sources.import.unresolvedTitle")}</h3>
<p className="source-item-preview__hint">
{t("recipes.sources.import.unresolvedHint")}
</p>
<ul className="import-recipe__unresolved-list">
{unresolvedIngredients.map((line) => (
<li key={line.key}>
<div className="import-recipe__unresolved-row">
<span>{line.rawText}</span>
<button
type="button"
onClick={() =>
setResolvingKey((current) => (current === line.key ? null : line.key))
}
>
{t("recipes.sources.import.resolveButton")}
</button>
<button type="button" onClick={() => discardUnresolvedIngredient(line.key)}>
{t("recipes.sources.import.discardButton")}
</button>
</div>
{resolvingKey === line.key && (
<IngredientPicker
ingredients={ingredientsCatalog}
excludeIds={selectedIds}
onSelect={(ingredient) => resolveIngredient(line.key, ingredient)}
/>
)}
</li>
))}
</ul>
</section>
)}
<IngredientPicker
ingredients={ingredientsCatalog}
excludeIds={selectedIds}
onSelect={addIngredient}
/>
</section>
<section className="recipe-form__section">
<h2>{t("recipes.stepsTitle")}</h2>
<StepListEditor steps={steps} onChange={setSteps} />
</section>
{formError && <p className="form-error">{formError}</p>}
<div className="recipe-form__actions">
<button type="submit" disabled={isSubmitting || !canSubmit}>
{isSubmitting
? t("recipes.sources.import.submitting")
: t("recipes.sources.import.submit")}
</button>
</div>
</form>
); );
} }

View file

@ -217,9 +217,9 @@ export function RecipesPage() {
: "/recettes", : "/recettes",
) )
} }
onSelectImportedRecipe={(recipeId) => { onSelectImportedRecipe={(recipe) => {
setActiveTab("favoris"); setActiveTab("favoris");
navigate(`/recettes/${recipeId}`); navigate(`/recettes/${recipe.id}`);
}} }}
/> />
) : ( ) : (