fix(recipes): retire l'import manuel, le planning importe seul

Correction de comportement sur la gestion des recettes de sources
externes — l'implémentation précédente avait dérivé d'une lecture
erronée du besoin :

- Plus aucun bouton d'import nulle part. Parcourir une source
  (RecipesPage, hors planning) ne fait plus jamais que prévisualiser
  — RecipeDetailPanel n'affiche plus de lien "Importer cette
  recette", seulement un bouton icône discret vers la page d'origine
  quand la recette en a une (nouveau .recipe-detail-panel__source-link,
  même emplacement que l'étoile favori).
- Une recette externe n'est importée dans la base qu'au moment où
  quelqu'un l'ajoute effectivement à son planning — jamais avant.
  RecipePickerDialog.handleSelectDraftItem est désormais le seul
  endroit de toute l'appli qui importe quoi que ce soit : cliquer sur
  un item pas encore importé y déclenche une tentative d'import
  transparente (POST /sources/.../import puis POST /planning/items),
  sans écran intermédiaire, dès que rien ne manque
  (tryBuildCompleteImport, nouveau apps/web/src/features/recipes/
  recipe-import-draft.ts). Seul un ingrédient non résolu (ou une
  erreur réseau) fait encore basculer vers l'écran de revue existant
  (ImportRecipePage), pré-rempli, pour compléter ce qui manque.
- RecipeSourcesPanel gagne onSelectDraftItem (remplace planningSlot,
  qui n'a plus de raison d'être puisqu'il n'y a plus de lien d'import
  à qui le transmettre) : quand ce callback est fourni
  (RecipePickerDialog uniquement), un item pas encore importé n'est
  plus prévisualisé sur place, il est remonté tel quel à l'appelant.

Tests :
- planning.feature : le scénario existant retire l'étape "je clique
  le lien Importer cette recette" (redirection désormais automatique
  puisque le draft de test a un ingrédient non résolu) ; nouveau
  scénario pour le chemin transparent (draft entièrement résolu,
  aucun écran de revue).
- recipe-sources.feature : le scénario qui important depuis /recettes
  (hors planning) est supprimé — cette capacité n'existe plus hors
  planning. Le scénario de deep-link vérifie maintenant l'absence du
  bouton d'import et la présence du lien discret.
- pnpm exec tsc -b --force (web) — propre.
- pnpm exec biome check — propre.
- pnpm --filter web build — propre.
- Cypress non exécutable localement sur cette machine (crash GPU
  Electron connu) — scénarios vérifiés par relecture attentive
  contre le markup/les clés i18n réels ; CI (GitHub Actions) fera
  foi à l'exécution.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Nicolas 2026-08-20 21:14:10 +02:00
parent 4381e63045
commit 73ae8169a1
12 changed files with 376 additions and 185 deletions

View file

@ -15,7 +15,7 @@ Feature: Adding a recipe to the planning
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 reflects whatever's been added so far
Scenario: Adds a not-yet-imported source item to a planning slot, importing it on the way Scenario: Adds a not-yet-imported source item to a planning slot, landing on the review screen 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 +23,6 @@ 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"
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"
@ -34,3 +33,15 @@ Feature: Adding a recipe to the planning
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 URL should be the home page
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: Adds a fully-resolved not-yet-imported item to a planning slot 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"
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 URL should be the home page

View file

@ -49,6 +49,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 +101,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.

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

@ -1,14 +1,17 @@
import { import {
type BrowsableSourceItemView,
type DietView, type DietView,
ErrorCode, ErrorCode,
type IngredientView, type IngredientView,
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";
@ -23,6 +26,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,13 +59,12 @@ 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: picking an
* version of this dialog see `ImportRecipePage`'s `planningSlot`, the * already-imported item behaves exactly like picking a regular recipe,
* review/import flow that made including them here worthwhile): picking * and picking one that isn't imported yet is what actually imports it
* an already-imported item behaves exactly like picking a regular recipe, * nowhere else in the app does (see `handleSelectDraftItem`) since a
* and picking one that isn't imported yet hands off to that review * source item only ever becomes a real, saved `Recipe` as a side effect of
* screen, which adds the freshly-created recipe straight to this slot * someone adding it to their planning.
* 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
@ -71,10 +74,14 @@ export interface PlanningSlot {
* Selecting a row doesn't navigate anywhere (unlike `RecipesPage`'s own * Selecting a row doesn't navigate anywhere (unlike `RecipesPage`'s own
* use of `RecipeTable`) it switches this same dialog to a small * use of `RecipeTable`) it switches this same dialog to a small
* "how many portions?" confirmation step, then calls `POST * "how many portions?" confirmation step, then calls `POST
* /planning/items` on submit. The one exception is picking a not-yet- * /planning/items` on submit. Picking a not-yet-imported source item is
* imported source item, which does navigate away entirely (to * handled differently still (`handleSelectDraftItem`): when the draft has
* `/recettes/importer/...`) that flow has its own portions field * everything a real recipe needs, it's imported and added to this slot
* already, on the review screen itself. * transparently no extra screen, same as picking anything else. Only
* when something's actually missing (an ingredient the automatic matcher
* couldn't resolve, say) does this navigate away entirely, to the review
* screen (`/recettes/importer/...`), which has its own portions field
* already.
*/ */
export function RecipePickerDialog({ export function RecipePickerDialog({
slot, slot,
@ -86,6 +93,7 @@ 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);
@ -106,6 +114,12 @@ export function RecipePickerDialog({
// recipe was deleted between the browse fetch and the click), surfaced // recipe was deleted between the browse fetch and the click), surfaced
// the same way any other catalog load error is on this dialog. // the same way any other catalog load error is on this dialog.
const [sourceSelectError, setSourceSelectError] = useState(false); const [sourceSelectError, setSourceSelectError] = useState(false);
// True while `handleSelectDraftItem` below is resolving a not-yet-
// imported item's transparent-import attempt — replaces the source tab's
// whole panel with a status message for that brief window (fetch the
// draft, maybe import it, maybe add it to the slot) rather than leaving
// the browse list clickable mid-flight.
const [isAddingDraft, setIsAddingDraft] = useState(false);
// The recipe picked in step 1 — `null` while still browsing, set once a // The recipe picked in step 1 — `null` while still browsing, set once a
// row is clicked to switch this dialog into its confirmation step. // row is clicked to switch this dialog into its confirmation step.
@ -180,6 +194,74 @@ export function RecipePickerDialog({
.catch(() => setSourceSelectError(true)); .catch(() => setSourceSelectError(true));
} }
/** Navigates away to the full review/creation screen, pre-filled from this exact item and carrying `slot` along so a successful import there adds straight to it — the fallback `handleSelectDraftItem` below takes whenever a transparent import isn't possible. */
function goToReviewScreen(sourceKey: string, externalId: string) {
navigate(
`/recettes/importer/${sourceKey}/${encodeURIComponent(externalId)}` +
`?planningDate=${slot.date}&planningWeekDay=${slot.weekDay}&planningMeal=${slot.meal}`,
);
}
/**
* Picking 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 falls back to the full review screen
* (`goToReviewScreen`) instead, since only a person can supply what's
* actually missing.
*/
async function handleSelectDraftItem(item: BrowsableSourceItemView) {
if (activeSourceKey === null) return;
const sourceKey = activeSourceKey;
setSourceSelectError(false);
setIsAddingDraft(true);
let payload: ReturnType<typeof tryBuildCompleteImport>;
try {
payload = tryBuildCompleteImport(
await apiClient.previewSourceItem(sourceKey, item.externalId),
);
} catch {
goToReviewScreen(sourceKey, item.externalId);
return;
}
if (!payload) {
setIsAddingDraft(false);
goToReviewScreen(sourceKey, item.externalId);
return;
}
let saved: RecipeView;
try {
saved = await apiClient.importSourceItem(sourceKey, item.externalId, payload);
} catch {
setIsAddingDraft(false);
goToReviewScreen(sourceKey, item.externalId);
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 through the review form (same fallback
// `ImportRecipePage`'s own submit takes for the identical failure).
navigate(`/recettes/${saved.id}`);
}
}
async function handleConfirm() { async function handleConfirm() {
if (!selectedRecipe) return; if (!selectedRecipe) return;
const parsedPortions = Number(portions); const parsedPortions = Number(portions);
@ -319,12 +401,16 @@ export function RecipePickerDialog({
{t("common.loadError")} {t("common.loadError")}
</p> </p>
)} )}
<RecipeSourcesPanel {isAddingDraft ? (
key={activeSourceKey} <p className="recipes-page__status">{t("planning.picker.addingDraft")}</p>
sourceKey={activeSourceKey} ) : (
planningSlot={slot} <RecipeSourcesPanel
onSelectImportedRecipe={handleSelectImportedRecipe} key={activeSourceKey}
/> sourceKey={activeSourceKey}
onSelectImportedRecipe={handleSelectImportedRecipe}
onSelectDraftItem={handleSelectDraftItem}
/>
)}
</> </>
) : ( ) : (
<> <>

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,11 @@ export function RecipeDetailPanel({
dislikedIngredientIds = [], dislikedIngredientIds = [],
onFavoriteToggled, onFavoriteToggled,
onDeleted, onDeleted,
planningSlot,
}: { }: {
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;
/**
* Set only when this panel is rendered from `RecipePickerDialog` (adding a
* 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 +97,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 +122,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>

View file

@ -1,4 +1,4 @@
import type { BrowsableSourceItemView, Meal, WeekDay } from "@batch-cooking/shared"; import type { BrowsableSourceItemView } 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";
@ -40,8 +40,9 @@ type BrowseState =
* The right-hand preview reuses `RecipeDetailPanel` itself (its * The right-hand preview reuses `RecipeDetailPanel` itself (its
* `"loaded-draft"` state) rather than a separate component viewing a * `"loaded-draft"` state) rather than a separate component viewing a
* not-yet-imported item is meant to look and feel exactly like viewing any * not-yet-imported item is meant to look and feel exactly like viewing any
* other recipe, differing only in which actions are offered (there's an * other recipe, minus the actions that don't apply to something that isn't
* "Importer" button where Modifier/Supprimer would be). * saved yet. This is `RecipesPage`'s own mode: browsing/reading only,
* nothing here ever imports anything (see `onSelectDraftItem` below).
* *
* Selecting an already-imported item navigates straight to its real * Selecting an already-imported item navigates straight to its real
* recipe (`/recettes/:id`, leaving this tab) `onSelectImportedRecipe` * recipe (`/recettes/:id`, leaving this tab) `onSelectImportedRecipe`
@ -58,20 +59,29 @@ type BrowseState =
* (`/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: it never previews a not-yet-
* no URL of its own to keep in sync. * imported item inline at all (see `onSelectDraftItem`).
*/ */
export function RecipeSourcesPanel({ export function RecipeSourcesPanel({
sourceKey, sourceKey,
onSelectImportedRecipe, onSelectImportedRecipe,
planningSlot, onSelectDraftItem,
initialSelection, initialSelection,
onItemSelected, onItemSelected,
}: { }: {
sourceKey: string; sourceKey: string;
onSelectImportedRecipe: (recipeId: number) => void; onSelectImportedRecipe: (recipeId: number) => void;
/** Forwarded as-is to `RecipeDetailPanel` — see its own doc comment. Only ever set by `RecipePickerDialog`. */ /**
planningSlot?: { date: string; weekDay: WeekDay; meal: Meal }; * Set only by `RecipePickerDialog` picking a not-yet-imported item
* while adding to a planning slot isn't something to preview inline
* here at all (there's no "import" button on that preview to lead
* anywhere any more see `RecipeDetailPanel`'s own doc comment). When
* set, a not-yet-imported row's click hands the item straight to this
* instead of previewing it, and the caller takes it from there
* (`RecipePickerDialog.handleSelectDraftItem`: import transparently when
* nothing's missing, otherwise hand off to the review screen).
*/
onSelectDraftItem?: (item: BrowsableSourceItemView) => void;
initialSelection?: SourceItemSelection; initialSelection?: SourceItemSelection;
onItemSelected?: (item: SourceItemSelection | null) => void; onItemSelected?: (item: SourceItemSelection | null) => void;
}) { }) {
@ -168,6 +178,10 @@ export function RecipeSourcesPanel({
onSelectImportedRecipe(item.recipeId); onSelectImportedRecipe(item.recipeId);
return; return;
} }
if (onSelectDraftItem) {
onSelectDraftItem(item);
return;
}
const selection = { sourceKey, externalId: item.externalId }; const selection = { sourceKey, externalId: item.externalId };
setSelectedExternalId(item.externalId); setSelectedExternalId(item.externalId);
setPreviewState({ status: "loading" }); setPreviewState({ status: "loading" });
@ -221,7 +235,7 @@ export function RecipeSourcesPanel({
</div> </div>
)} )}
<RecipeDetailPanel state={previewState} planningSlot={planningSlot} /> <RecipeDetailPanel state={previewState} />
</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

@ -674,6 +674,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

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,8 @@
"portionsLabel": "Nombre de portions", "portionsLabel": "Nombre de portions",
"backButton": "Retour", "backButton": "Retour",
"confirmButton": "Ajouter au planning", "confirmButton": "Ajouter au planning",
"adding": "Ajout…" "adding": "Ajout…",
"addingDraft": "Ajout au planning…"
} }
}, },
"recipes": { "recipes": {
@ -177,8 +178,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

@ -72,33 +72,38 @@ function parsePlanningSlot(
/** /**
* Review screen for finalizing an import routed at * Review screen for finalizing an import routed at
* `/recettes/importer/:sourceKey/:externalId` (reached from * `/recettes/importer/:sourceKey/:externalId`. Reached only one way now:
* `RecipeDetailPanel`'s "Importer cette recette" button, shown for its * `RecipePickerDialog.handleSelectDraftItem`'s fallback, when picking a
* `"loaded-draft"` state). Pre-filled * not-yet-imported source item to add to a planning slot turns out to need
* from `GET /sources/:sourceKey/preview/:externalId` (the same draft the * a person's input (an ingredient line the automatic matching
* preview panel already showed), structurally the same form as * (`ingredient-matcher.ts`) couldn't resolve, say) a draft with nothing
* `RecipeFormPage` same sub-components (`IngredientRow`, * missing imports transparently from that dialog instead, without ever
* reaching this screen (`tryBuildCompleteImport`). There is no other way
* in any more: browsing a source outside of adding-to-planning
* (`RecipesPage`/`RecipeSourcesPanel`) only ever previews, on purpose a
* source item isn't imported into the household's own catalog until
* someone actually plans it.
*
* Pre-filled from `GET /sources/:sourceKey/preview/:externalId` (the same
* draft the transparent-import attempt already fetched), structurally the
* same form as `RecipeFormPage` same sub-components (`IngredientRow`,
* `IngredientPicker`, `StepListEditor`, `DietTagSelect`), same * `IngredientPicker`, `StepListEditor`, `DietTagSelect`), same
* `CreateRecipeInput` submit shape plus one thing a manual creation * `CreateRecipeInput` submit shape plus one thing a manual creation
* never has to handle: ingredient lines the automatic matching * never has to handle: ingredient lines the automatic matching couldn't
* (`ingredient-matcher.ts`) couldn't resolve. Those render as their own * resolve. Those render as their own "à compléter" list, each needing a
* "à compléter" list, each needing a real ingredient picked (or the line * real ingredient picked (or the line discarded) before the form can
* discarded) before the form can submit never silently drops/guesses one, * submit never silently drops/guesses one, per the product decision this
* per the product decision this stage was built against (no invalid * stage was built against (no invalid recipe is ever persisted).
* recipe is ever persisted).
* *
* Submits to `POST /sources/:sourceKey/import/:externalId` * Submits to `POST /sources/:sourceKey/import/:externalId`
* (`apiClient.importSourceItem`) instead of `POST /recipes` the only * (`apiClient.importSourceItem`) instead of `POST /recipes` the only
* other difference from `RecipeFormPage`'s own submit. * other difference from `RecipeFormPage`'s own submit.
* *
* `?planningDate=&planningWeekDay=&planningMeal=` are set only when this * `?planningDate=&planningWeekDay=&planningMeal=` carry the planning slot
* page was reached from `RecipePickerDialog`'s "Sources" tab (via * `RecipePickerDialog` was adding to along as query params (always present
* `RecipeDetailPanel`'s import link, see its own `planningSlot` prop) * in practice, given the only entry point above still parsed
* picking a not-yet-imported item there hands off to this full review * defensively, see `parsePlanningSlot`). A successful import adds the
* screen instead of the dialog's own small "how many portions?" step, * freshly created recipe straight to that slot (`POST /planning/items`,
* 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 * using this form's own `portions` field) before landing back on the
* planning page, instead of the recipe's own detail page. * planning page, instead of the recipe's own detail page.
*/ */