Merge pull request #45 from kyuno053/feat/browse-sources-backend

feat(recipes): parcourir et prévisualiser les sources externes (étape 1/4)
This commit is contained in:
kyuno053 2026-08-20 18:45:45 +02:00 committed by GitHub
commit b383abdfdf
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
38 changed files with 2819 additions and 244 deletions

View file

@ -10,6 +10,7 @@ import { preferencesRouter } from "./modules/preferences/preferences.routes.js";
import { profileRouter } from "./modules/profile/profile.routes.js"; import { profileRouter } from "./modules/profile/profile.routes.js";
import { recipeRouter } from "./modules/recipe/recipe.routes.js"; import { recipeRouter } from "./modules/recipe/recipe.routes.js";
import { referenceRouter } from "./modules/reference/reference.routes.js"; import { referenceRouter } from "./modules/reference/reference.routes.js";
import { sourcesRouter } from "./modules/sources/sources.routes.js";
/** /**
* Builds the API's `ExpressServer`: standard middleware, routes, and the * Builds the API's `ExpressServer`: standard middleware, routes, and the
@ -34,6 +35,7 @@ export function createServer(): ExpressServer {
server.mountRouter("/profile", profileRouter); server.mountRouter("/profile", profileRouter);
server.mountRouter("/recipes", recipeRouter); server.mountRouter("/recipes", recipeRouter);
server.mountRouter("/reference", referenceRouter); server.mountRouter("/reference", referenceRouter);
server.mountRouter("/sources", sourcesRouter);
// Serves the built frontend (production Docker image only — see // Serves the built frontend (production Docker image only — see
// FRONTEND_DIST_DIR's doc comment in config/env.ts). Must come after // FRONTEND_DIST_DIR's doc comment in config/env.ts). Must come after

View file

@ -41,28 +41,33 @@ export async function syncRecipeSources(prisma: PrismaClient): Promise<void> {
/** /**
* Which of `externalIds` already have a `Recipe` imported from the source * Which of `externalIds` already have a `Recipe` imported from the source
* registered under `sourceKey` the DB-touching counterpart to * registered under `sourceKey`, mapped to that `Recipe`'s id the
* `markAlreadyImported` (recipe-source-adapter.ts), which stays pure and * DB-touching counterpart to `markAlreadyImported` (recipe-source-adapter.ts),
* takes this set as a plain argument rather than querying itself. Returns * which stays pure and takes a plain `ReadonlySet<string>` (this map's
* an empty set (not an error) for a `sourceKey` with no matching `Source` * `.keys()`) rather than querying itself. The id (not just membership) is
* row nothing can have been imported from a source we don't even have a * what `sources.service.ts`'s browse endpoint needs to link an
* catalog entry for. * already-imported item straight to its real `Recipe`, instead of a
* caller having to look it up again. Returns an empty map (not an error)
* for a `sourceKey` with no matching `Source` row nothing can have been
* imported from a source we don't even have a catalog entry for.
*/ */
export async function findImportedExternalIds( export async function findImportedRecipeIds(
prisma: PrismaClient, prisma: PrismaClient,
sourceKey: string, sourceKey: string,
externalIds: string[], externalIds: string[],
): Promise<Set<string>> { ): Promise<Map<string, number>> {
if (externalIds.length === 0) return new Set(); if (externalIds.length === 0) return new Map();
const source = await prisma.source.findUnique({ where: { key: sourceKey } }); const source = await prisma.source.findUnique({ where: { key: sourceKey } });
if (!source) return new Set(); if (!source) return new Map();
const imported = await prisma.recipe.findMany({ const imported = await prisma.recipe.findMany({
where: { sourceId: source.id, externalId: { in: externalIds } }, where: { sourceId: source.id, externalId: { in: externalIds } },
select: { externalId: true }, select: { id: true, externalId: true },
}); });
return new Set( return new Map(
imported.flatMap((recipe) => (recipe.externalId !== null ? [recipe.externalId] : [])), imported.flatMap((recipe) =>
recipe.externalId !== null ? [[recipe.externalId, recipe.id]] : [],
),
); );
} }

View file

@ -146,6 +146,7 @@ export interface ParsedRecipe {
* name: "Some Recipe Site", * name: "Some Recipe Site",
* official: false, * official: false,
* iconUrl: "https://somerecipesite.example/favicon.svg", * iconUrl: "https://somerecipesite.example/favicon.svg",
* locale: "fr",
* async list(params) { ... }, * async list(params) { ... },
* async fetchDetail(externalId) { ... }, * async fetchDetail(externalId) { ... },
* parse(raw) { ... }, * parse(raw) { ... },
@ -171,6 +172,16 @@ export interface RecipeSourceAdapter<TRawDetail = unknown> {
official: boolean; official: boolean;
/** URL of the source's own logo/favicon, for `SourceSelect` (apps/web) to display next to its name — `null` if the source has none worth showing. Synced to `Source.iconUrl` the same way as `name`/`official`. */ /** URL of the source's own logo/favicon, for `SourceSelect` (apps/web) to display next to its name — `null` if the source has none worth showing. Synced to `Source.iconUrl` the same way as `name`/`official`. */
iconUrl: string | null; iconUrl: string | null;
/**
* Language of the text this source produces (`ParsedRecipe.description`/
* `steps[].description`/`ingredients[].name`) e.g. `"en"` for
* TheMealDB. Not a user preference: the language the source's own
* content is actually written in, regardless of who's browsing it.
* Determines which `TechStepMapping`/ingredient-label locale
* `translateRecipe` (`recipe-translation.ts`) resolves this source's
* recipes against when previewing/importing one.
*/
locale: string;
list(params: RecipeSourceListParams): Promise<RecipeSourceListResult>; list(params: RecipeSourceListParams): Promise<RecipeSourceListResult>;
fetchDetail(externalId: string): Promise<TRawDetail>; fetchDetail(externalId: string): Promise<TRawDetail>;
parse(raw: TRawDetail): ParsedRecipe; parse(raw: TRawDetail): ParsedRecipe;

View file

@ -364,11 +364,46 @@ export async function createRecipe(
input: CreateRecipeInput, input: CreateRecipeInput,
authorId: number, authorId: number,
authorHouseId: number | null, authorHouseId: number | null,
): Promise<RecipeView> {
return createRecipeInternal(input, authorId, authorHouseId, null);
}
/**
* Finalizes an import from an external source same validation/creation
* path as {@link createRecipe} (by the time this is called, `input` has
* already been reviewed and every ingredient resolved to a real catalog
* id, same as a manual creation see `sources.service.ts`'s
* `importSourceItem`, the only caller), plus stamping `sourceId`/
* `externalId` and matching techniques against `locale` (the source's own
* e.g. `"en"` for TheMealDB) instead of the hardcoded French default,
* since the step text is still in whatever language the source wrote it
* in.
*
* @throws {HttpError} `404 INGREDIENT_NOT_FOUND` if any `ingredientId` doesn't match a reference ingredient.
* @throws {HttpError} `404 UNIT_NOT_FOUND` if any `unitId` doesn't match a reference unit.
* @throws {HttpError} `404 DIET_NOT_FOUND` if any `dietId` doesn't match a reference diet.
*/
export async function createImportedRecipe(
input: CreateRecipeInput,
authorId: number,
authorHouseId: number | null,
source: { sourceId: number; externalId: string; locale: string },
): Promise<RecipeView> {
return createRecipeInternal(input, authorId, authorHouseId, source);
}
async function createRecipeInternal(
input: CreateRecipeInput,
authorId: number,
authorHouseId: number | null,
source: { sourceId: number; externalId: string; locale: string } | null,
): Promise<RecipeView> { ): Promise<RecipeView> {
await assertIngredientsExist(input.ingredients.map((i) => i.ingredientId)); await assertIngredientsExist(input.ingredients.map((i) => i.ingredientId));
await assertUnitsExist(input.ingredients.map((i) => i.unitId)); await assertUnitsExist(input.ingredients.map((i) => i.unitId));
await assertDietsExist(input.dietIds); await assertDietsExist(input.dietIds);
const techStepMappings = await loadTechStepMappingRules(DEFAULT_TECH_STEP_LOCALE); const techStepMappings = await loadTechStepMappingRules(
source?.locale ?? DEFAULT_TECH_STEP_LOCALE,
);
const created = await prisma.recipe.create({ const created = await prisma.recipe.create({
data: { data: {
@ -379,6 +414,8 @@ export async function createRecipe(
authorId, authorId,
authorHouseId, authorHouseId,
visibility: input.visibility, visibility: input.visibility,
sourceId: source?.sourceId ?? null,
externalId: source?.externalId ?? null,
ingredients: { ingredients: {
create: input.ingredients.map((ingredient) => ({ create: input.ingredients.map((ingredient) => ({
ingredientId: ingredient.ingredientId, ingredientId: ingredient.ingredientId,

View file

@ -0,0 +1,71 @@
import { HttpError } from "@batch-cooking/error-tools";
import { wrapAsyncHandler } from "@batch-cooking/express-tools";
import { ErrorCode, browseSourceSchema, createRecipeSchema } from "@batch-cooking/shared";
import { Router } from "express";
import { type AuthLocals, requireAuth } from "../../middlewares/require-auth.js";
import { browseSource, importSourceItem, previewSourceItem } from "./sources.service.js";
/**
* Router mounted at `/sources` in app.ts browsing/previewing a
* household's *enabled* external recipe sources (see `sources.service.ts`).
* Every route requires a session, same posture as `/recipes`/`/house`: this
* is app content scoped to the viewer's household, not signup-time
* reference data (contrast `/reference/sources`, which just lists what
* exists, public, no auth needed).
*/
export const sourcesRouter = Router();
/** Route params are typed `string | undefined` by Express even for a segment that always matches when the route does — this just satisfies TS, the branch is unreachable in practice. */
function requireParam(value: string | undefined): string {
if (value === undefined) {
throw new HttpError(400, ErrorCode.VALIDATION_ERROR, "Missing route parameter");
}
return value;
}
sourcesRouter.get(
"/:sourceKey/browse",
requireAuth,
wrapAsyncHandler<unknown, AuthLocals>(async (req, res) => {
const input = browseSourceSchema.parse(req.query);
const { houseId } = res.locals.userProfile;
res.status(200).json(await browseSource(requireParam(req.params.sourceKey), houseId, input));
}),
);
sourcesRouter.get(
"/:sourceKey/preview/:externalId",
requireAuth,
wrapAsyncHandler<unknown, AuthLocals>(async (req, res) => {
const { houseId } = res.locals.userProfile;
res
.status(200)
.json(
await previewSourceItem(
requireParam(req.params.sourceKey),
requireParam(req.params.externalId),
houseId,
),
);
}),
);
sourcesRouter.post(
"/:sourceKey/import/:externalId",
requireAuth,
wrapAsyncHandler<unknown, AuthLocals>(async (req, res) => {
const input = createRecipeSchema.parse(req.body);
const { id: authorId, houseId } = res.locals.userProfile;
res
.status(201)
.json(
await importSourceItem(
requireParam(req.params.sourceKey),
requireParam(req.params.externalId),
input,
authorId,
houseId,
),
);
}),
);

View file

@ -0,0 +1,240 @@
import { HttpError } from "@batch-cooking/error-tools";
import {
type BrowsableSourceItemView,
type CreateRecipeInput,
type DraftRecipeIngredientView,
type DraftRecipeStepView,
ErrorCode,
type RecipeImportDraftView,
type RecipeView,
} from "@batch-cooking/shared";
import { prisma } from "../../db/prisma.js";
import { findImportedRecipeIds } from "../../db/recipe-source-sync.js";
import {
type IngredientMatchEntry,
type UnitMatchEntry,
loadIngredientCatalog,
loadUnitCatalog,
} from "../../lib/ingredient-matcher.js";
import { type RecipeSourceAdapter, markAlreadyImported } from "../../lib/recipe-source-adapter.js";
import { RecipeSourceError } from "../../lib/recipe-source-errors.js";
import { getRecipeSource } from "../../lib/recipe-source-registry.js";
import { translateRecipeIngredients } from "../../lib/recipe-translation.js";
import { loadTechStepMappingRules, matchTechStepSpans } from "../../lib/tech-step-matcher.js";
import { getHouseSourceIds } from "../house/house.service.js";
import { createImportedRecipe } from "../recipe/recipe.service.js";
import { getIngredients, getUnits } from "../reference/reference.service.js";
/**
* Browsing, previewing, and importing a household's *enabled* external
* recipe sources (`HouseSource`) the "onglet Sources" feature (see the
* project plan). Browsing lists what a source offers
* (`RecipeSourceAdapter.list()`); previewing fully translates one item
* (`translateRecipeIngredients`, `matchTechStepSpans` same building
* blocks `recipe.service.ts` uses at real save time) without persisting
* it; importing (`importSourceItem`) is the only function here that
* actually saves by the time it's called, the caller (the review screen)
* has already resolved every ingredient to a real catalog id, same as a
* manual `POST /recipes`.
*/
/**
* `sourceKey` must both exist as a `Source` (household-enabled, via
* `HouseSource`) *and* still be a registered adapter (`recipe-source-registry.ts`)
* the two can drift apart (a `Source` row outlives its adapter being
* unregistered, exactly what `jsonLdRecipe` was cleaned up from see
* `sources/index.ts`), so both are checked. Either failure looks like "this
* source doesn't exist" to the caller (404 `SOURCE_NOT_FOUND`), same
* "don't distinguish not-found from not-visible" posture `recipe.service.ts`
* takes for a recipe the viewer can't see.
*
* @throws {HttpError} `404 HOUSE_NOT_FOUND` if the profile has no household yet.
* @throws {HttpError} `404 SOURCE_NOT_FOUND` if `sourceKey` doesn't match a source enabled for this household.
*/
async function assertSourceEnabled(
houseId: number | null,
sourceKey: string,
): Promise<{ adapter: RecipeSourceAdapter; sourceId: number }> {
const enabledSourceIds = await getHouseSourceIds(houseId);
const source = await prisma.source.findUnique({ where: { key: sourceKey } });
if (!source || !enabledSourceIds.includes(source.id)) {
throw new HttpError(
404,
ErrorCode.SOURCE_NOT_FOUND,
`Source "${sourceKey}" is not enabled for this household`,
);
}
const adapter = getRecipeSource(sourceKey);
if (!adapter) {
throw new HttpError(
404,
ErrorCode.SOURCE_NOT_FOUND,
`Source "${sourceKey}" has no registered adapter`,
);
}
return { adapter, sourceId: source.id };
}
/**
* One page of `sourceKey`'s own catalog, each item flagged with whether
* it's already been imported (and, if so, its real `Recipe` id see
* `findImportedRecipeIds`).
*
* @throws {HttpError} `404 HOUSE_NOT_FOUND` if the profile has no household yet.
* @throws {HttpError} `404 SOURCE_NOT_FOUND` if `sourceKey` doesn't match a source enabled for this household.
*/
export async function browseSource(
sourceKey: string,
houseId: number | null,
params: { query?: string; cursor?: string },
): Promise<{ items: BrowsableSourceItemView[]; nextCursor: string | null }> {
const { adapter } = await assertSourceEnabled(houseId, sourceKey);
const result = await adapter.list({ query: params.query, cursor: params.cursor });
const importedRecipeIds = await findImportedRecipeIds(
prisma,
sourceKey,
result.items.map((item) => item.externalId),
);
const marked = markAlreadyImported(result.items, new Set(importedRecipeIds.keys()));
return {
items: marked.map((item) => ({
externalId: item.externalId,
title: item.title,
picture: item.picture,
url: item.url,
alreadyImported: item.alreadyImported,
recipeId: importedRecipeIds.get(item.externalId) ?? null,
})),
nextCursor: result.nextCursor,
};
}
/**
* Fully translates one source item into an unsaved {@link RecipeImportDraftView}
* fetches + parses it (`fetchDetail`/`parse`), then resolves its
* ingredients/units (`translateRecipeIngredients`) and detects each step's
* techniques with their exact matched span (`matchTechStepSpans`, the same
* function `recipe.service.ts` uses at real save time see its doc
* comment), all against `adapter.locale`'s catalogs. Ingredient/unit
* matching itself only has English data today (see `ingredient-matcher.ts`);
* a non-English-locale source simply gets `ingredient`/`unit: null` on
* every line, the same graceful "no matching-language data" degradation
* `translateRecipe` already has.
*
* @throws {HttpError} `404 HOUSE_NOT_FOUND` if the profile has no household yet.
* @throws {HttpError} `404 SOURCE_NOT_FOUND` if `sourceKey` doesn't match a source enabled for this household.
* @throws {HttpError} `404 RECIPE_NOT_FOUND` if `externalId` couldn't be fetched or parsed into a usable recipe (a `RecipeSourceError` `recipe-source-errors.ts` from the adapter).
*/
export async function previewSourceItem(
sourceKey: string,
externalId: string,
houseId: number | null,
): Promise<RecipeImportDraftView> {
const { adapter } = await assertSourceEnabled(houseId, sourceKey);
let parsed: ReturnType<typeof adapter.parse>;
try {
const raw = await adapter.fetchDetail(externalId);
parsed = adapter.parse(raw);
} catch (err) {
if (err instanceof RecipeSourceError) {
throw new HttpError(404, ErrorCode.RECIPE_NOT_FOUND, err.message);
}
throw err;
}
const [techStepMappings, ingredientCatalog, unitCatalog, techStepsByKey] = await Promise.all([
loadTechStepMappingRules(adapter.locale),
adapter.locale === "en" ? loadIngredientCatalog() : Promise.resolve<IngredientMatchEntry[]>([]),
adapter.locale === "en" ? loadUnitCatalog() : Promise.resolve<UnitMatchEntry[]>([]),
prisma.techStep.findMany({ select: { id: true, key: true } }),
]);
const techStepById = new Map(techStepsByKey.map((techStep) => [techStep.id, techStep]));
const translatedIngredients = translateRecipeIngredients(
parsed.ingredients,
ingredientCatalog,
unitCatalog,
);
const [ingredientViews, unitViews] = await Promise.all([getIngredients(), getUnits()]);
const ingredientById = new Map(ingredientViews.map((view) => [view.id, view]));
const unitById = new Map(unitViews.map((view) => [view.id, view]));
const ingredients: DraftRecipeIngredientView[] = translatedIngredients.map((ingredient) => ({
rawText: ingredient.rawText,
quantity: ingredient.quantity,
ingredient:
ingredient.ingredientId !== null
? (ingredientById.get(ingredient.ingredientId) ?? null)
: null,
unit: ingredient.unitId !== null ? (unitById.get(ingredient.unitId) ?? null) : null,
}));
const steps: DraftRecipeStepView[] = parsed.steps.map((step) => ({
description: step.description,
picture: step.picture,
techSteps: matchTechStepSpans(step.description, techStepMappings).flatMap((match) => {
const techStep = techStepById.get(match.techStepId);
return techStep ? [{ techStep, start: match.start, end: match.end }] : [];
}),
}));
return {
sourceKey,
externalId,
name: parsed.name,
description: parsed.description,
picture: parsed.picture,
portions: parsed.portions,
sourceUrl: parsed.sourceUrl,
ingredients,
steps,
};
}
/**
* Finalizes an import the review screen (pre-filled from
* {@link previewSourceItem}'s draft, unresolved ingredients fixed up by
* the user via the normal `IngredientPicker`) submits `input` as a
* regular {@link CreateRecipeInput}, exactly like a manually-authored
* recipe. This just adds two things `createRecipe` itself can't:
* confirming `externalId` isn't already imported (the DB's own
* `@@unique([sourceId, externalId])` would reject a second attempt too,
* but as a raw constraint violation checking first gives a clean,
* expected error instead), and stamping `sourceId`/`externalId` plus
* matching techniques against the source's own locale
* (`createImportedRecipe`, `recipe.service.ts`).
*
* @throws {HttpError} `404 HOUSE_NOT_FOUND` if the profile has no household yet.
* @throws {HttpError} `404 SOURCE_NOT_FOUND` if `sourceKey` doesn't match a source enabled for this household.
* @throws {HttpError} `409 RECIPE_ALREADY_IMPORTED` if `externalId` was already imported from this source.
* @throws {HttpError} `404 INGREDIENT_NOT_FOUND` if any `ingredientId` doesn't match a reference ingredient.
* @throws {HttpError} `404 UNIT_NOT_FOUND` if any `unitId` doesn't match a reference unit.
* @throws {HttpError} `404 DIET_NOT_FOUND` if any `dietId` doesn't match a reference diet.
*/
export async function importSourceItem(
sourceKey: string,
externalId: string,
input: CreateRecipeInput,
authorId: number,
authorHouseId: number | null,
): Promise<RecipeView> {
const { adapter, sourceId } = await assertSourceEnabled(authorHouseId, sourceKey);
const alreadyImported = await findImportedRecipeIds(prisma, sourceKey, [externalId]);
if (alreadyImported.has(externalId)) {
throw new HttpError(
409,
ErrorCode.RECIPE_ALREADY_IMPORTED,
`"${externalId}" from source "${sourceKey}" is already imported`,
);
}
return createImportedRecipe(input, authorId, authorHouseId, {
sourceId,
externalId,
locale: adapter.locale,
});
}

View file

@ -170,6 +170,13 @@ export const jsonLdRecipeAdapter: RecipeSourceAdapter<{ html: string; url: strin
name: "Import générique (JSON-LD)", name: "Import générique (JSON-LD)",
official: false, official: false,
iconUrl: null, iconUrl: null,
// Genuinely varies per scraped site (this adapter has no fixed content
// language of its own) — "fr" as a placeholder since it's never actually
// consulted: this adapter isn't registered into the source registry (see
// sources/index.ts), so nothing calls translateRecipe against it today.
// A concrete per-site adapter built on top of this one would declare its
// own real locale.
locale: "fr",
async list() { async list() {
return { items: [], nextCursor: null }; return { items: [], nextCursor: null };

View file

@ -76,6 +76,10 @@ export const theMealDbAdapter: RecipeSourceAdapter<TheMealDbMeal> = {
name: "TheMealDB", name: "TheMealDB",
official: true, official: true,
iconUrl: "https://www.themealdb.com/images/logo.svg", iconUrl: "https://www.themealdb.com/images/logo.svg",
// TheMealDB's content (names, ingredients, instructions) is English —
// determines which locale translateRecipe (recipe-translation.ts)
// resolves this source's recipes against when previewing/importing one.
locale: "en",
async list(params: RecipeSourceListParams): Promise<RecipeSourceListResult> { async list(params: RecipeSourceListParams): Promise<RecipeSourceListResult> {
const query = params.query ?? ""; const query = params.query ?? "";

View file

@ -28,6 +28,7 @@ function buildFakeAdapter(key: string, name: string): RecipeSourceAdapter {
name, name,
official: false, official: false,
iconUrl: null, iconUrl: null,
locale: "fr",
async list() { async list() {
return { items: [], nextCursor: null }; return { items: [], nextCursor: null };
}, },

View file

@ -4,7 +4,7 @@ import { expect } from "chai";
import request from "supertest"; import request from "supertest";
import { createApp } from "../src/app.js"; import { createApp } from "../src/app.js";
import { prisma } from "../src/db/prisma.js"; import { prisma } from "../src/db/prisma.js";
import { findImportedExternalIds, syncRecipeSources } from "../src/db/recipe-source-sync.js"; import { findImportedRecipeIds, syncRecipeSources } from "../src/db/recipe-source-sync.js";
import type { RecipeSourceAdapter } from "../src/lib/recipe-source-adapter.js"; import type { RecipeSourceAdapter } from "../src/lib/recipe-source-adapter.js";
import { clearRecipeSources, registerRecipeSource } from "../src/lib/recipe-source-registry.js"; import { clearRecipeSources, registerRecipeSource } from "../src/lib/recipe-source-registry.js";
import { resetDatabase } from "../test-support/reset-db.js"; import { resetDatabase } from "../test-support/reset-db.js";
@ -28,6 +28,7 @@ function buildFakeAdapter(key: string, name: string): RecipeSourceAdapter {
name, name,
official: false, official: false,
iconUrl: null, iconUrl: null,
locale: "fr",
async list() { async list() {
return { items: [], nextCursor: null }; return { items: [], nextCursor: null };
}, },
@ -108,22 +109,22 @@ describe("recipe-source-sync", () => {
}); });
}); });
describe("findImportedExternalIds", () => { describe("findImportedRecipeIds", () => {
it("returns an empty set for a sourceKey with no matching Source row", async () => { it("returns an empty map for a sourceKey with no matching Source row", async () => {
expect(await findImportedExternalIds(prisma, "unknown", ["1", "2"])).to.deep.equal(new Set()); expect(await findImportedRecipeIds(prisma, "unknown", ["1", "2"])).to.deep.equal(new Map());
}); });
it("returns an empty set for an empty externalIds list", async () => { it("returns an empty map for an empty externalIds list", async () => {
expect(await findImportedExternalIds(prisma, "fakeSource", [])).to.deep.equal(new Set()); expect(await findImportedRecipeIds(prisma, "fakeSource", [])).to.deep.equal(new Map());
}); });
it("returns exactly the externalIds already imported from that source", async () => { it("returns exactly the externalIds already imported from that source, mapped to their Recipe id", async () => {
const { profileId } = await signup(); const { profileId } = await signup();
registerRecipeSource(buildFakeAdapter("fakeSource", "Fake Source")); registerRecipeSource(buildFakeAdapter("fakeSource", "Fake Source"));
await syncRecipeSources(prisma); await syncRecipeSources(prisma);
const source = await prisma.source.findUniqueOrThrow({ where: { key: "fakeSource" } }); const source = await prisma.source.findUniqueOrThrow({ where: { key: "fakeSource" } });
await prisma.recipe.create({ const imported = await prisma.recipe.create({
data: { data: {
name: "Tarte", name: "Tarte",
authorId: profileId, authorId: profileId,
@ -135,8 +136,8 @@ describe("recipe-source-sync", () => {
// Manually-authored, not tied to any source — shouldn't ever show up as "imported". // Manually-authored, not tied to any source — shouldn't ever show up as "imported".
await prisma.recipe.create({ data: { name: "Salade", authorId: profileId, portions: 2 } }); await prisma.recipe.create({ data: { name: "Salade", authorId: profileId, portions: 2 } });
const result = await findImportedExternalIds(prisma, "fakeSource", ["1", "2", "3"]); const result = await findImportedRecipeIds(prisma, "fakeSource", ["1", "2", "3"]);
expect(result).to.deep.equal(new Set(["1"])); expect(result).to.deep.equal(new Map([["1", imported.id]]));
}); });
it("scopes matches to the given source — the same externalId from a different source doesn't count", async () => { it("scopes matches to the given source — the same externalId from a different source doesn't count", async () => {
@ -156,7 +157,7 @@ describe("recipe-source-sync", () => {
}, },
}); });
expect(await findImportedExternalIds(prisma, "fakeSource", ["1"])).to.deep.equal(new Set()); expect(await findImportedRecipeIds(prisma, "fakeSource", ["1"])).to.deep.equal(new Map());
}); });
}); });

View file

@ -60,6 +60,7 @@ function buildFakeAdapter(key = "fakeSource"): RecipeSourceAdapter<FakeRawRecipe
name: "Fake Source", name: "Fake Source",
official: false, official: false,
iconUrl: null, iconUrl: null,
locale: "fr",
async list(params: RecipeSourceListParams): Promise<RecipeSourceListResult> { async list(params: RecipeSourceListParams): Promise<RecipeSourceListResult> {
const start = params.cursor ? Number(params.cursor) : 0; const start = params.cursor ? Number(params.cursor) : 0;
const page = FAKE_CATALOG.slice(start, start + PAGE_SIZE); const page = FAKE_CATALOG.slice(start, start + PAGE_SIZE);

View file

@ -17,6 +17,7 @@ function buildFakeAdapter(key: string, name: string): RecipeSourceAdapter {
name, name,
official: false, official: false,
iconUrl: null, iconUrl: null,
locale: "fr",
async list() { async list() {
return { items: [], nextCursor: null }; return { items: [], nextCursor: null };
}, },

View file

@ -20,6 +20,7 @@ function buildFakeAdapter(
name, name,
official, official,
iconUrl, iconUrl,
locale: "fr",
async list() { async list() {
return { items: [], nextCursor: null }; return { items: [], nextCursor: null };
}, },

View file

@ -0,0 +1,385 @@
import type { SignupInput } from "@batch-cooking/shared";
import { ErrorCode } from "@batch-cooking/shared";
import { faker } from "@faker-js/faker";
import { expect } from "chai";
import request from "supertest";
import { createApp } from "../src/app.js";
import { prisma } from "../src/db/prisma.js";
import { syncRecipeSources } from "../src/db/recipe-source-sync.js";
import type {
ParsedRecipe,
RecipeSourceAdapter,
RecipeSourceListParams,
RecipeSourceListResult,
} from "../src/lib/recipe-source-adapter.js";
import { RecipeSourceFetchError } from "../src/lib/recipe-source-errors.js";
import { clearRecipeSources, registerRecipeSource } from "../src/lib/recipe-source-registry.js";
import { resetDatabase } from "../test-support/reset-db.js";
/** See `recipe.test.ts` — generated rather than hardcoded, no test fixture looks like a real person's data. */
function buildSignupPayload(): SignupInput {
const firstName = faker.person.firstName();
const lastName = faker.person.lastName();
return {
firstName,
lastName,
email: faker.internet.email({ firstName, lastName }).toLowerCase(),
password: faker.internet.password({ length: 16 }),
};
}
/**
* A minimal, real English-content fake adapter `parse()` deliberately
* mixes one ingredient that resolves against the real seeded catalog
* ("onion") with one that doesn't ("mystery paste"), and a step whose
* text matches a real seeded English tech-step mapping ("chop") same
* "exercise the real catalog, not a mock of it" approach the ingredient/
* tech-step matcher tests already use.
*/
function buildFakeAdapter(key = "fakeSource"): RecipeSourceAdapter<{ externalId: string }> {
return {
key,
name: "Fake Source",
official: true,
iconUrl: null,
locale: "en",
async list(_params: RecipeSourceListParams): Promise<RecipeSourceListResult> {
return {
items: [
{ externalId: "1", title: "Onion soup", picture: null, url: "https://fake.test/1" },
{ externalId: "2", title: "Mystery stew", picture: null, url: "https://fake.test/2" },
],
nextCursor: null,
};
},
async fetchDetail(externalId: string): Promise<{ externalId: string }> {
if (externalId === "missing") {
throw new RecipeSourceFetchError(key, `No item found for id "${externalId}"`);
}
return { externalId };
},
parse(raw: { externalId: string }): ParsedRecipe {
return {
name: `Fake recipe ${raw.externalId}`,
description: null,
picture: null,
portions: 4,
sourceUrl: `https://fake.test/${raw.externalId}`,
ingredients: [
{ rawText: "1 onion", quantity: null, unit: null, name: "onion" },
// No leading number and no recognizable unit word — exercises
// quantity/unit staying null alongside the ingredient itself not
// resolving, not just the ingredient.
{ rawText: "some mystery paste", quantity: null, unit: null, name: "mystery paste" },
],
steps: [{ description: "Chop the onions finely", picture: null }],
};
},
};
}
/** Resolves a reference ingredient's id by its `reference-seed-data.ts` uid (also its DB `key`) — same reasoning as `recipe.test.ts`'s own helper. */
async function ingredientId(key: string): Promise<number> {
const ingredient = await prisma.ingredient.findFirstOrThrow({ where: { key } });
return ingredient.id;
}
/** Resolves a reference unit's id by its `reference-seed-data.ts` uid (also its DB `key`) — same reasoning as {@link ingredientId}. */
async function unitId(key: string): Promise<number> {
const unit = await prisma.unit.findFirstOrThrow({ where: { key } });
return unit.id;
}
describe("Sources", () => {
const app = createApp();
/** Signs up a fresh profile, creates a household for it, and returns the session `agent` alongside the household id. */
async function signupWithHouse(): Promise<{
agent: ReturnType<typeof request.agent>;
houseId: number;
}> {
const agent = request.agent(app);
await agent.post("/auth/signup").send(buildSignupPayload());
const houseRes = await agent.post("/house").send({ name: "Chez moi" });
return { agent, houseId: houseRes.body.id };
}
beforeEach(async () => {
await resetDatabase();
});
afterEach(() => {
clearRecipeSources();
});
after(async () => {
await prisma.$disconnect();
});
describe("GET /sources/:sourceKey/browse", () => {
it("rejects requests without a session cookie with 401 NOT_AUTHENTICATED", async () => {
const res = await request(app).get("/sources/fakeSource/browse");
expect(res.status).to.equal(401);
expect(res.body.code).to.equal(ErrorCode.NOT_AUTHENTICATED);
});
it("rejects a profile with no household with 404 HOUSE_NOT_FOUND", async () => {
const agent = request.agent(app);
await agent.post("/auth/signup").send(buildSignupPayload());
const res = await agent.get("/sources/fakeSource/browse");
expect(res.status).to.equal(404);
expect(res.body.code).to.equal(ErrorCode.HOUSE_NOT_FOUND);
});
it("rejects an unknown sourceKey with 404 SOURCE_NOT_FOUND", async () => {
const { agent } = await signupWithHouse();
const res = await agent.get("/sources/unknown/browse");
expect(res.status).to.equal(404);
expect(res.body.code).to.equal(ErrorCode.SOURCE_NOT_FOUND);
});
it("rejects a real source the household hasn't enabled with 404 SOURCE_NOT_FOUND", async () => {
const { agent } = await signupWithHouse();
registerRecipeSource(buildFakeAdapter());
await syncRecipeSources(prisma);
const res = await agent.get("/sources/fakeSource/browse");
expect(res.status).to.equal(404);
expect(res.body.code).to.equal(ErrorCode.SOURCE_NOT_FOUND);
});
it("returns each item flagged with alreadyImported/recipeId once the source is enabled", async () => {
const { agent, houseId } = await signupWithHouse();
registerRecipeSource(buildFakeAdapter());
await syncRecipeSources(prisma);
const source = await prisma.source.findUniqueOrThrow({ where: { key: "fakeSource" } });
await agent.patch("/house/current/sources").send({ sourceIds: [source.id] });
const importedRecipe = await prisma.recipe.create({
data: {
name: "Already imported",
authorId: (await prisma.userProfile.findFirstOrThrow({ where: { houseId } })).id,
portions: 4,
sourceId: source.id,
externalId: "1",
},
});
const res = await agent.get("/sources/fakeSource/browse");
expect(res.status).to.equal(200);
expect(res.body.nextCursor).to.equal(null);
expect(res.body.items).to.deep.equal([
{
externalId: "1",
title: "Onion soup",
picture: null,
url: "https://fake.test/1",
alreadyImported: true,
recipeId: importedRecipe.id,
},
{
externalId: "2",
title: "Mystery stew",
picture: null,
url: "https://fake.test/2",
alreadyImported: false,
recipeId: null,
},
]);
});
});
describe("GET /sources/:sourceKey/preview/:externalId", () => {
async function enableFakeSource(): Promise<{
agent: ReturnType<typeof request.agent>;
}> {
const { agent } = await signupWithHouse();
registerRecipeSource(buildFakeAdapter());
await syncRecipeSources(prisma);
const source = await prisma.source.findUniqueOrThrow({ where: { key: "fakeSource" } });
await agent.patch("/house/current/sources").send({ sourceIds: [source.id] });
return { agent };
}
it("rejects a source the household hasn't enabled with 404 SOURCE_NOT_FOUND", async () => {
const { agent } = await signupWithHouse();
registerRecipeSource(buildFakeAdapter());
await syncRecipeSources(prisma);
const res = await agent.get("/sources/fakeSource/preview/1");
expect(res.status).to.equal(404);
expect(res.body.code).to.equal(ErrorCode.SOURCE_NOT_FOUND);
});
it("translates the item against the real catalog: resolves what it can, leaves the rest null", async () => {
const { agent } = await enableFakeSource();
const onion = await prisma.ingredient.findFirstOrThrow({ where: { key: "onion" } });
const chop = await prisma.techStep.findFirstOrThrow({ where: { key: "chop" } });
const res = await agent.get("/sources/fakeSource/preview/1");
expect(res.status).to.equal(200);
expect(res.body).to.deep.include({
sourceKey: "fakeSource",
externalId: "1",
name: "Fake recipe 1",
description: null,
picture: null,
portions: 4,
sourceUrl: "https://fake.test/1",
});
const [resolved, unresolved] = res.body.ingredients;
expect(resolved.rawText).to.equal("1 onion");
expect(resolved.ingredient).to.deep.include({ id: onion.id, key: "onion" });
expect(unresolved.rawText).to.equal("some mystery paste");
expect(unresolved.ingredient).to.equal(null);
expect(unresolved.unit).to.equal(null);
expect(unresolved.quantity).to.equal(null);
expect(res.body.steps).to.have.length(1);
const [step] = res.body.steps;
expect(step.description).to.equal("Chop the onions finely");
expect(step.techSteps).to.have.length(1);
expect(step.techSteps[0].techStep).to.deep.equal({ id: chop.id, key: "chop" });
expect(
step.description.slice(step.techSteps[0].start, step.techSteps[0].end).toLowerCase(),
).to.equal("chop");
});
it("returns 404 RECIPE_NOT_FOUND when the adapter can't fetch the item", async () => {
const { agent } = await enableFakeSource();
const res = await agent.get("/sources/fakeSource/preview/missing");
expect(res.status).to.equal(404);
expect(res.body.code).to.equal(ErrorCode.RECIPE_NOT_FOUND);
});
});
describe("POST /sources/:sourceKey/import/:externalId", () => {
async function enableFakeSource(): Promise<{
agent: ReturnType<typeof request.agent>;
sourceId: number;
}> {
const { agent } = await signupWithHouse();
registerRecipeSource(buildFakeAdapter());
await syncRecipeSources(prisma);
const source = await prisma.source.findUniqueOrThrow({ where: { key: "fakeSource" } });
await agent.patch("/house/current/sources").send({ sourceIds: [source.id] });
return { agent, sourceId: source.id };
}
/** A fully-resolved payload, as the review screen would submit it — every ingredient already has a real ingredientId/unitId, same shape `POST /recipes` accepts. */
async function buildImportPayload() {
return {
name: "Fake recipe 1 (revue)",
portions: 4,
dietIds: [],
ingredients: [
{ ingredientId: await ingredientId("onion"), quantity: 1, unitId: await unitId("piece") },
],
steps: [{ description: "Chop the onions finely" }],
};
}
it("rejects requests without a session cookie with 401 NOT_AUTHENTICATED", async () => {
const res = await request(app)
.post("/sources/fakeSource/import/1")
.send(await buildImportPayload());
expect(res.status).to.equal(401);
expect(res.body.code).to.equal(ErrorCode.NOT_AUTHENTICATED);
});
it("rejects a source the household hasn't enabled with 404 SOURCE_NOT_FOUND", async () => {
const { agent } = await signupWithHouse();
registerRecipeSource(buildFakeAdapter());
await syncRecipeSources(prisma);
const res = await agent.post("/sources/fakeSource/import/1").send(await buildImportPayload());
expect(res.status).to.equal(404);
expect(res.body.code).to.equal(ErrorCode.SOURCE_NOT_FOUND);
});
it("creates the recipe with sourceId/externalId set, matching techniques against the source's own locale", async () => {
const { agent, sourceId } = await enableFakeSource();
const chop = await prisma.techStep.findFirstOrThrow({ where: { key: "chop" } });
const res = await agent.post("/sources/fakeSource/import/1").send(await buildImportPayload());
expect(res.status).to.equal(201);
const created = await prisma.recipe.findUniqueOrThrow({ where: { id: res.body.id } });
expect(created.sourceId).to.equal(sourceId);
expect(created.externalId).to.equal("1");
// The step text is English ("Chop the onions finely") — this only
// matches "chop" if the fake adapter's own locale ("en") was used
// for tech-step matching, not the hardcoded French default (which
// would find nothing in English text — see recipe-translation.test.ts's
// "locales are separate rule sets" test for the same point made the
// other way around).
const step = await prisma.step.findFirstOrThrow({ where: { recipeId: created.id } });
const stepTechSteps = await prisma.stepTechStep.findMany({ where: { stepId: step.id } });
expect(stepTechSteps.map((s) => s.techStepId)).to.deep.equal([chop.id]);
});
it("rejects a second import of the same item with 409 RECIPE_ALREADY_IMPORTED", async () => {
const { agent } = await enableFakeSource();
const first = await agent
.post("/sources/fakeSource/import/1")
.send(await buildImportPayload());
expect(first.status).to.equal(201);
const second = await agent
.post("/sources/fakeSource/import/1")
.send(await buildImportPayload());
expect(second.status).to.equal(409);
expect(second.body.code).to.equal(ErrorCode.RECIPE_ALREADY_IMPORTED);
});
it("rejects an unknown ingredientId with 404 INGREDIENT_NOT_FOUND, same as a manual creation", async () => {
const { agent } = await enableFakeSource();
const payload = await buildImportPayload();
payload.ingredients[0].ingredientId = 999_999;
const res = await agent.post("/sources/fakeSource/import/1").send(payload);
expect(res.status).to.equal(404);
expect(res.body.code).to.equal(ErrorCode.INGREDIENT_NOT_FOUND);
});
it("rejects a second household's import of the same item too — the item's identity is global, not per-household", async () => {
// Registers/syncs the adapter once — enableFakeSource() itself does
// this too, and registerRecipeSource() throws on a duplicate key, so
// calling it twice in one test (once per household) isn't an option.
registerRecipeSource(buildFakeAdapter());
await syncRecipeSources(prisma);
const source = await prisma.source.findUniqueOrThrow({ where: { key: "fakeSource" } });
const { agent: firstAgent } = await signupWithHouse();
await firstAgent.patch("/house/current/sources").send({ sourceIds: [source.id] });
const firstImport = await firstAgent
.post("/sources/fakeSource/import/1")
.send(await buildImportPayload());
expect(firstImport.status).to.equal(201);
const { agent: secondAgent } = await signupWithHouse();
await secondAgent.patch("/house/current/sources").send({ sourceIds: [source.id] });
const secondImport = await secondAgent
.post("/sources/fakeSource/import/1")
.send(await buildImportPayload());
expect(secondImport.status).to.equal(409);
expect(secondImport.body.code).to.equal(ErrorCode.RECIPE_ALREADY_IMPORTED);
});
});
});

View file

@ -0,0 +1,36 @@
Feature: Adding a recipe to the planning
As a signed-in user
I want to add a recipe to a planning slot even when I haven't imported it yet
So that browsing an external source and planning it is a single trip
Background:
Given I am signed in as "Alice" "Martin"
And my household id is 1
And today is frozen at "2026-08-17T09:00:00.000Z"
And the household request returns no household
And the recipe catalog contains nothing
And the ingredient and diet catalog is available for import
And the sources reference list has options
And the household has enabled TheMealDB
And browsing TheMealDB returns some items
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
Given previewing TheMealDB item "9999" is available
And importing the previewed item will succeed and return id 99
And adding the imported recipe 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 "Sources"
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."
When I choose an ingredient for the unresolved line "some mystery paste"
And I select the ingredient "Sel" from the picker
And I select unit "unité" for the first ingredient
And I fill in the last ingredient's quantity with "1" and unit "unité"
And I click the button "Importer"
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 "Fish Pie" should appear in the first planning slot with 4 portions

View file

@ -0,0 +1,219 @@
import { Given, Then, When } from "@badeball/cypress-cucumber-preprocessor";
// Mocks the API via cy.intercept — this job doesn't run a live backend (see
// .github/workflows/ci.yml); apps/api's own Mocha suite covers real API
// behavior against a real database (see test/sources.test.ts,
// test/planning.test.ts).
//
// planning.feature's journey crosses both `RecipePickerDialog` (browsing an
// external source from a planning slot) and the import review screen
// (`ImportRecipePage`) it hands off to — same "each spec's own
// self-contained fixtures" precedent recipe-sources.ts already sets (the
// Cucumber preprocessor's step lookup isn't global across cypress/e2e/, see
// its own comment for the full reasoning), so most of what's below mirrors
// 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", () => {
cy.intercept("GET", /\/recipes\?/, { statusCode: 200, body: [] });
});
Given("the household has enabled TheMealDB", () => {
cy.intercept("GET", "**/house/current/sources", { statusCode: 200, body: [1] });
});
Given("browsing TheMealDB returns some items", () => {
cy.intercept("GET", "**/sources/theMealDb/browse*", {
statusCode: 200,
body: {
items: [
{
externalId: "52795",
title: "Chicken Handi",
picture: null,
url: "https://www.themealdb.com/meal/52795",
alreadyImported: true,
recipeId: 2,
},
{
externalId: "9999",
title: "Fish Pie",
picture: null,
url: "https://www.themealdb.com/meal/9999",
alreadyImported: false,
recipeId: null,
},
],
nextCursor: null,
},
});
});
Given("previewing TheMealDB item {string} is available", (externalId: string) => {
cy.intercept("GET", `**/sources/theMealDb/preview/${externalId}`, {
statusCode: 200,
body: {
sourceKey: "theMealDb",
externalId,
name: "Fish Pie",
description: null,
picture: null,
portions: 4,
sourceUrl: "https://www.themealdb.com/meal/9999",
ingredients: [
{
rawText: "1 onion",
quantity: 1,
ingredient: {
id: 1,
key: "onion",
icon: "VEGETABLE",
category: "freshProduce",
subcategory: "vegetables",
reproducible: false,
allergens: [],
diets: [],
},
unit: null,
},
{ rawText: "some mystery paste", quantity: null, ingredient: null, unit: null },
],
steps: [{ description: "Cuire à la poêle.", picture: null, techSteps: [] }],
},
});
});
// Covers every reference catalog both `RecipePickerDialog` (ingredients/
// diets, for its own filters) and `ImportRecipePage` (ingredients/diets/
// units, for the review form) fetch — same endpoints, one fixture for both.
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");
});
Given("adding the imported recipe to the planning will succeed", () => {
cy.intercept("POST", "**/planning/items", (req) => {
fishPiePlanned = true;
req.reply({
statusCode: 201,
body: {
id: 1,
weekDay: "lundi",
meal: "petit-dejeuner",
portions: 4,
recipe: { id: 99, name: "Fish Pie" },
},
});
}).as("addPlanningItem");
});
// Stateful — landing back on "/" after the import journey remounts
// `PlanningPage` from scratch (a real cross-route navigation, not a
// same-component state update: see `ImportRecipePage`'s `navigate("/")`),
// so only a fresh `GET /planning?date=` that reflects the just-added item
// makes it show up there — nothing client-side survives that remount to
// patch it in locally the way `PlanningPage`'s own `patchPlanningItems`
// does for an add made without leaving the page.
Given("the planning request reflects whatever's been added so far", () => {
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
// first entry × `WEEK_DAYS`'s first entry, see `PlanningGrid`) — the exact
// slot this feature's fixtures above (weekDay "lundi", meal
// "petit-dejeuner") are written against.
When("I click the add button for the first empty planning slot", () => {
cy.get(".add-recipe-btn").first().click();
});
When("I click the source item {string}", (title: string) => {
cy.contains(".recipe-table__name", title).click();
});
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 planning add request should have included recipe {int}, weekDay {string}, meal {string}, and portions {int}",
(recipeId: number, weekDay: string, meal: string, portions: number) => {
cy.wait("@addPlanningItem")
.its("request.body")
.should("deep.include", { recipeId, weekDay, meal, portions });
},
);
Then(
"the recipe {string} should appear in the first planning slot with {int} portions",
(name: string, portions: number) => {
cy.get(".planning-grid tbody tr")
.first()
.within(() => {
cy.contains(".recipe-chip", `${name} · ×${portions}`).should("be.visible");
});
},
);

View file

@ -57,66 +57,6 @@ When("I visit the new recipe form without a secure random UUID", () => {
}); });
}); });
When("I search the ingredient picker for {string}", (text: string) => {
cy.get("input[placeholder='Rechercher un ingrédient…']").type(text);
});
When("I select the ingredient {string} from the picker", (name: string) => {
cy.contains(".ingredient-picker__card", name).click();
});
Then("the ingredient {string} should no longer be in the picker", (name: string) => {
cy.contains(".ingredient-picker__card", name).should("not.exist");
});
Then("the ingredient {string} should be visible in the picker", (name: string) => {
cy.contains(".ingredient-picker__card", name).should("be.visible");
});
Then("the recipe should include the ingredient {string}", (name: string) => {
cy.contains(".ingredient-row__name", name).should("be.visible");
});
Then("there should be {int} ingredient rows", (count: number) => {
cy.get(".ingredient-row").should("have.length", count);
});
When("I remove the ingredient {string} from the recipe", (name: string) => {
cy.contains(".ingredient-row", name).find("button[title='Retirer cet ingrédient']").click();
});
When(
"I fill in the ingredient's quantity with {string} and unit {string}",
(quantity: string, unit: string) => {
cy.get(".ingredient-row .ingredient-row__quantity").type(quantity);
cy.get(".ingredient-row .ingredient-row__unit").select(unit);
},
);
Then("the ingredient's quantity should be {string}", (quantity: string) => {
cy.get(".ingredient-row .ingredient-row__quantity").should("have.value", quantity);
});
When(
"I fill in the last ingredient's quantity with {string} and unit {string}",
(quantity: string, unit: string) => {
cy.get(".ingredient-row .ingredient-row__quantity").last().type(quantity);
cy.get(".ingredient-row .ingredient-row__unit").last().select(unit);
},
);
When("I add a step", () => {
cy.contains("button", "Ajouter une étape").click();
});
When("I fill in the step description with {string}", (text: string) => {
cy.get(".step-list-editor__item textarea").type(text);
});
Then("there should be {int} step editor items", (count: number) => {
cy.get(".step-list-editor__item").should("have.length", count);
});
Then( Then(
"the recipe creation request should have included name {string}, portions {int}, and ingredient {int} with quantity {int} and unitId {int}", "the recipe creation request should have included name {string}, portions {int}, and ingredient {int} with quantity {int} and unitId {int}",
(name: string, portions: number, ingredientId: number, quantity: number, unitId: number) => { (name: string, portions: number, ingredientId: number, quantity: number, unitId: number) => {

View file

@ -0,0 +1,73 @@
Feature: Browsing external recipe sources
As a signed-in user
I want to browse the recipes available from my household's enabled sources
So that I can find new recipes to import, or jump straight to ones I already have
Background:
Given I am signed in as "Alice" "Martin"
And my household id is 1
And the disliked ingredients list is empty
And the planning request returns nothing
Scenario: Prompts to enable a source when the household hasn't enabled any
Given the recipe catalog contains nothing
And the sources reference list has options
And the household's enabled sources are empty
When I visit "/recettes"
And I click the button "Sources"
Then I should see "Aucune source n'est activée"
Scenario: Browses an enabled source, distinguishing already-imported items from new ones
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 recipe 2's detail is available
When I visit "/recettes"
And I click the button "Sources"
Then I should see the source item "Chicken Handi"
And I should see the source item "Fish Pie"
And the source item "Chicken Handi" should be marked as already imported
When I click the source item "Chicken Handi"
Then the URL should include "/recettes/2"
And the recipe detail panel heading should be "Omelette"
Scenario: Previews a not-yet-imported item, highlighting its detected techniques
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
When I visit "/recettes"
And I click the button "Sources"
And I click the source item "Fish Pie"
Then the recipe detail panel heading should be "Fish Pie"
And I should see the highlighted technique "Cuire"
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 "Sources"
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

@ -0,0 +1,200 @@
import { Given, Then, When } from "@badeball/cypress-cucumber-preprocessor";
// Mocks the API via cy.intercept — this job doesn't run a live backend (see
// .github/workflows/ci.yml); apps/api's own Mocha suite covers real API
// behavior against a real database (see test/sources.test.ts).
//
// "the sources reference list has options"/"the household's enabled
// sources are empty" resolve from cypress/support/step_definitions/ (the
// preprocessor's step-lookup is *not* global across cypress/e2e/ — only a
// feature's own same-named file/directory plus that shared folder are
// searched, see its error message when a step isn't found). recipes.ts
// sits directly in cypress/e2e/ (not that shared folder), so its own
// "the disliked ingredients list is empty"/"the recipe catalog
// contains"/"recipe 2's detail is available" are scoped to recipes.feature
// only — this file redeclares its own minimal equivalents rather than
// relocating shared infra, the same "each spec's own self-contained
// fixtures" precedent recipes.cy.ts already sets alongside recipes.ts.
Given("the disliked ingredients list is empty", () => {
cy.intercept("GET", "**/profile/disliked-ingredients", { statusCode: 200, body: [] });
});
Given("the recipe catalog contains nothing", () => {
cy.intercept("GET", /\/recipes\?/, { statusCode: 200, body: [] });
});
Given("recipe 2's detail is available", () => {
cy.intercept("GET", "**/recipes/2", {
statusCode: 200,
body: {
id: 2,
name: "Omelette",
description: null,
picture: null,
portions: 2,
authorId: 1,
visibility: "PERSONAL",
allergens: [],
diets: [],
isFavorite: false,
ingredients: [],
steps: [
{
id: 1,
description: "Cuire à la poêle.",
picture: null,
order: 1,
techSteps: [{ techStep: { id: 1, key: "cook" }, start: 0, end: 5 }],
},
],
},
});
});
Given("the household has enabled TheMealDB", () => {
cy.intercept("GET", "**/house/current/sources", { statusCode: 200, body: [1] });
});
Given("browsing TheMealDB returns some items", () => {
cy.intercept("GET", "**/sources/theMealDb/browse*", {
statusCode: 200,
body: {
items: [
{
externalId: "52795",
title: "Chicken Handi",
picture: null,
url: "https://www.themealdb.com/meal/52795",
alreadyImported: true,
recipeId: 2,
},
{
externalId: "9999",
title: "Fish Pie",
picture: null,
url: "https://www.themealdb.com/meal/9999",
alreadyImported: false,
recipeId: null,
},
],
nextCursor: null,
},
});
});
Given("previewing TheMealDB item {string} is available", (externalId: string) => {
cy.intercept("GET", `**/sources/theMealDb/preview/${externalId}`, {
statusCode: 200,
body: {
sourceKey: "theMealDb",
externalId,
name: "Fish Pie",
description: null,
picture: null,
portions: 4,
sourceUrl: "https://www.themealdb.com/meal/9999",
ingredients: [
{
rawText: "1 onion",
quantity: 1,
ingredient: {
id: 1,
key: "onion",
icon: "VEGETABLE",
category: "freshProduce",
subcategory: "vegetables",
reproducible: false,
allergens: [],
diets: [],
},
unit: null,
},
{ rawText: "some mystery paste", quantity: null, ingredient: null, unit: null },
],
steps: [
{
description: "Cuire à la poêle.",
picture: null,
techSteps: [{ techStep: { id: 1, key: "cook" }, start: 0, end: 5 }],
},
],
},
});
});
Then("I should see the source item {string}", (title: string) => {
cy.contains(".recipe-table__name", title).should("be.visible");
});
When("I click the source item {string}", (title: string) => {
cy.contains(".recipe-table__name", title).click();
});
Then("the source item {string} should be marked as already imported", (title: string) => {
cy.contains("tr", title).find(".source-item-table__imported-badge").should("be.visible");
});
// ImportRecipePage (the review screen) loads its own ingredient/diet/unit
// catalogs the same way RecipeFormPage does — "onion" matches the resolved
// line in "previewing TheMealDB item ... is available" above, "salt" is
// what "some mystery paste" (unresolved in that same fixture) gets
// corrected to in the review-and-import scenario.
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

@ -136,9 +136,6 @@ describe("Recipe catalog", () => {
cy.get(".recipe-tabs__tab.active").should("contain.text", "Perso"); cy.get(".recipe-tabs__tab.active").should("contain.text", "Perso");
cy.contains(".recipe-table__name", "Omelette").should("be.visible"); cy.contains(".recipe-table__name", "Omelette").should("be.visible");
cy.contains(".recipe-table__name", "Ratatouille").should("not.exist"); cy.contains(".recipe-table__name", "Ratatouille").should("not.exist");
// The disabled "Sources (bientôt)" placeholder never becomes active.
cy.contains(".recipe-tabs__tab", "Sources (bientôt)").should("be.disabled");
}); });
it("searches within the active tab, debounced", () => { it("searches within the active tab, debounced", () => {

View file

@ -80,10 +80,6 @@ Then("the recipe {string} should not be marked as favorite", (name: string) => {
cy.contains(".recipe-table__name", name).find(".recipe-table__fav-mark").should("not.exist"); cy.contains(".recipe-table__name", name).find(".recipe-table__fav-mark").should("not.exist");
}); });
Then("the recipe detail panel heading should be {string}", (text: string) => {
cy.get(".recipe-detail-panel").contains("h2", text).should("be.visible");
});
When("I click the favorite star", () => { When("I click the favorite star", () => {
cy.get(".favorite-star-button").click(); cy.get(".favorite-star-button").click();
}); });
@ -111,19 +107,3 @@ Then("the delete request should have been made", () => {
Then("the URL should match the recipes list", () => { Then("the URL should match the recipes list", () => {
cy.url().should("match", /\/recettes\/?$/); cy.url().should("match", /\/recettes\/?$/);
}); });
Then("I should see the highlighted technique {string}", (text: string) => {
// The steps section sits below the panel's header/photo/description, off
// the fold of `.app-content`'s own scroll (see layout.cy.ts) — a bare
// `.should("be.visible")` doesn't auto-scroll, same fix as
// household-settings.feature's sources-section scenario.
cy.contains(".step-tech-step", text).scrollIntoView().should("be.visible");
});
When("I focus the highlighted technique {string}", (text: string) => {
cy.contains(".step-tech-step", text).focus();
});
Then("the tooltip should show {string}", (label: string) => {
cy.get(".tooltip__bubble").contains(label).should("be.visible");
});

View file

@ -133,6 +133,100 @@ When("I scroll to the section {string}", (legend: string) => {
cy.contains("legend", legend).scrollIntoView(); cy.contains("legend", legend).scrollIntoView();
}); });
// `.recipe-detail-panel` is used by both a saved recipe's real detail
// (RecipeDetailPanel) and an unsaved source item's read-only preview
// (SourceItemPreviewPanel) — recipes.feature and recipe-sources.feature
// both need this.
Then("the recipe detail panel heading should be {string}", (text: string) => {
cy.get(".recipe-detail-panel").contains("h2", text).should("be.visible");
});
// `.step-tech-step`/`.tooltip__bubble` come from StepDescription/Tooltip
// (components/ui/), rendered by both of those same two panels — same
// reasoning as the detail-panel-heading step above.
Then("I should see the highlighted technique {string}", (text: string) => {
// The steps section can sit below the panel's header/photo/description,
// off the fold of `.app-content`'s own scroll (see layout.cy.ts) — a
// bare `.should("be.visible")` doesn't auto-scroll.
cy.contains(".step-tech-step", text).scrollIntoView().should("be.visible");
});
When("I focus the highlighted technique {string}", (text: string) => {
cy.contains(".step-tech-step", text).focus();
});
Then("the tooltip should show {string}", (label: string) => {
cy.get(".tooltip__bubble").contains(label).should("be.visible");
});
// `IngredientPicker`/`IngredientRow`/`StepListEditor` (features/recipes/)
// back both RecipeFormPage and ImportRecipePage — recipe-form.feature and
// import-recipe.feature both need these.
When("I search the ingredient picker for {string}", (text: string) => {
cy.get("input[placeholder='Rechercher un ingrédient…']").type(text);
});
When("I select the ingredient {string} from the picker", (name: string) => {
cy.contains(".ingredient-picker__card", name).click();
});
Then("the ingredient {string} should no longer be in the picker", (name: string) => {
cy.contains(".ingredient-picker__card", name).should("not.exist");
});
Then("the ingredient {string} should be visible in the picker", (name: string) => {
cy.contains(".ingredient-picker__card", name).should("be.visible");
});
Then("the recipe should include the ingredient {string}", (name: string) => {
cy.contains(".ingredient-row__name", name).should("be.visible");
});
Then("there should be {int} ingredient rows", (count: number) => {
cy.get(".ingredient-row").should("have.length", count);
});
When("I remove the ingredient {string} from the recipe", (name: string) => {
cy.contains(".ingredient-row", name).find("button[title='Retirer cet ingrédient']").click();
});
When(
"I fill in the ingredient's quantity with {string} and unit {string}",
(quantity: string, unit: string) => {
cy.get(".ingredient-row .ingredient-row__quantity").type(quantity);
cy.get(".ingredient-row .ingredient-row__unit").select(unit);
},
);
Then("the ingredient's quantity should be {string}", (quantity: string) => {
cy.get(".ingredient-row .ingredient-row__quantity").should("have.value", quantity);
});
When("I select unit {string} for the first ingredient", (unit: string) => {
cy.get(".ingredient-row .ingredient-row__unit").first().select(unit);
});
When(
"I fill in the last ingredient's quantity with {string} and unit {string}",
(quantity: string, unit: string) => {
cy.get(".ingredient-row .ingredient-row__quantity").last().type(quantity);
cy.get(".ingredient-row .ingredient-row__unit").last().select(unit);
},
);
When("I add a step", () => {
cy.contains("button", "Ajouter une étape").click();
});
When("I fill in the step description with {string}", (text: string) => {
cy.get(".step-list-editor__item textarea").type(text);
});
Then("there should be {int} step editor items", (count: number) => {
cy.get(".step-list-editor__item").should("have.length", count);
});
Then("the checkbox {string} should be checked", (label: string) => { Then("the checkbox {string} should be checked", (label: string) => {
cy.contains("label", label).find("input[type=checkbox]").should("be.checked"); cy.contains("label", label).find("input[type=checkbox]").should("be.checked");
}); });

View file

@ -2,6 +2,7 @@ import { Navigate, Route, Routes } from "react-router-dom";
import { RedirectIfAuthenticated } from "./features/auth/RedirectIfAuthenticated"; import { RedirectIfAuthenticated } from "./features/auth/RedirectIfAuthenticated";
import { RequireAuth } from "./features/auth/RequireAuth"; import { RequireAuth } from "./features/auth/RequireAuth";
import { AppLayout } from "./layouts/AppLayout"; import { AppLayout } from "./layouts/AppLayout";
import { ImportRecipePage } from "./pages/ImportRecipePage";
import { LoginPage } from "./pages/LoginPage"; import { LoginPage } from "./pages/LoginPage";
import { PlanningPage } from "./pages/PlanningPage"; import { PlanningPage } from "./pages/PlanningPage";
import { RecipeFormPage } from "./pages/RecipeFormPage"; import { RecipeFormPage } from "./pages/RecipeFormPage";
@ -59,6 +60,7 @@ export function App() {
(see RecipesPage.tsx). */} (see RecipesPage.tsx). */}
<Route path="/recettes" element={<RecipesPage />} /> <Route path="/recettes" element={<RecipesPage />} />
<Route path="/recettes/nouvelle" element={<RecipeFormPage />} /> <Route path="/recettes/nouvelle" element={<RecipeFormPage />} />
<Route path="/recettes/importer/:sourceKey/:externalId" element={<ImportRecipePage />} />
<Route path="/recettes/:id" element={<RecipesPage />} /> <Route path="/recettes/:id" element={<RecipesPage />} />
<Route path="/recettes/:id/modifier" element={<RecipeFormPage />} /> <Route path="/recettes/:id/modifier" element={<RecipeFormPage />} />
<Route path="/liste-de-courses" element={<ShoppingListPage />} /> <Route path="/liste-de-courses" element={<ShoppingListPage />} />

View file

@ -2,6 +2,7 @@ import {
type AddPlanningItemInput, type AddPlanningItemInput,
type AllergyView, type AllergyView,
type ApiErrorResponse, type ApiErrorResponse,
type BrowsableSourceItemView,
type CreateRecipeInput, type CreateRecipeInput,
type DietView, type DietView,
ErrorCode, ErrorCode,
@ -11,6 +12,7 @@ import {
type PlanningItemView, type PlanningItemView,
type PlanningView, type PlanningView,
type PreferencesView, type PreferencesView,
type RecipeImportDraftView,
type RecipeSummaryView, type RecipeSummaryView,
type RecipeTab, type RecipeTab,
type RecipeView, type RecipeView,
@ -173,6 +175,35 @@ export class ApiClient {
return this.request("/reference/sources"); return this.request("/reference/sources");
} }
/** One page of `sourceKey`'s own catalog (recipe catalog's "Sources" tab), each item flagged with whether it's already been imported. Rejects with `SOURCE_NOT_FOUND` unless the viewer's household has this source enabled (`/parametres/foyer`). */
public browseSource(
sourceKey: string,
params: { query?: string; cursor?: string } = {},
): Promise<{ items: BrowsableSourceItemView[]; nextCursor: string | null }> {
const search = new URLSearchParams();
if (params.query) search.set("query", params.query);
if (params.cursor) search.set("cursor", params.cursor);
const queryString = search.toString();
return this.request(`/sources/${sourceKey}/browse${queryString ? `?${queryString}` : ""}`);
}
/** Fully translates one not-yet-saved source item (ingredients/units/techniques resolved where possible) — nothing is persisted. Rejects with `RECIPE_NOT_FOUND` if the source couldn't fetch/parse it. */
public previewSourceItem(sourceKey: string, externalId: string): Promise<RecipeImportDraftView> {
return this.request(`/sources/${sourceKey}/preview/${encodeURIComponent(externalId)}`);
}
/** Finalizes an import — `input` is a fully-resolved `CreateRecipeInput`, exactly like a manual `createRecipe()` call (the review screen, `ImportRecipePage`, is what makes sure of that before calling this). Rejects with `RECIPE_ALREADY_IMPORTED` if this item was imported since the preview was fetched. */
public importSourceItem(
sourceKey: string,
externalId: string,
input: CreateRecipeInput,
): Promise<RecipeView> {
return this.request(`/sources/${sourceKey}/import/${encodeURIComponent(externalId)}`, {
method: "POST",
body: JSON.stringify(input),
});
}
/** /**
* One catalog tab (favoris/perso/foyer/publique see `RecipeTab`), * One catalog tab (favoris/perso/foyer/publique see `RecipeTab`),
* optionally narrowed further `search` (name substring), * optionally narrowed further `search` (name substring),

View file

@ -5,7 +5,6 @@ import {
type Meal, type Meal,
type PlanningItemView, type PlanningItemView,
type RecipeSummaryView, type RecipeSummaryView,
type RecipeTab,
type WeekDay, type WeekDay,
} from "@batch-cooking/shared"; } from "@batch-cooking/shared";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
@ -16,8 +15,9 @@ 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 { RecipeTable } from "../recipes/RecipeTable"; import { RecipeTable } from "../recipes/RecipeTable";
import { RecipeTabs } from "../recipes/RecipeTabs"; import { RecipeTabs, type RecipesPageTab } from "../recipes/RecipeTabs";
import "./recipe-picker-dialog.scss"; import "./recipe-picker-dialog.scss";
/** Debounce for the search field — same value as `RecipesPage`'s. */ /** Debounce for the search field — same value as `RecipesPage`'s. */
@ -48,7 +48,13 @@ export interface PlanningSlot {
* with three extra filters layered on top of the plain name search * with three extra filters layered on top of the plain name search
* (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. * about finding something to cook, not just looking something up. The
* "Sources" tab is included too (unlike an earlier version of this dialog
* see `ImportRecipePage`'s `planningSlot`, the review/import flow that
* made including it 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
@ -58,7 +64,10 @@ 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. * /planning/items` on submit. The one exception is picking a not-yet-
* imported source item, which does navigate away entirely (to
* `/recettes/importer/...`) that flow has its own portions field
* already, on the review screen itself.
*/ */
export function RecipePickerDialog({ export function RecipePickerDialog({
slot, slot,
@ -71,7 +80,7 @@ export function RecipePickerDialog({
}) { }) {
const { t } = useTranslation(); const { t } = useTranslation();
const [activeTab, setActiveTab] = useState<RecipeTab>("favoris"); const [activeTab, setActiveTab] = useState<RecipesPageTab>("favoris");
const [search, setSearch] = useState(""); const [search, setSearch] = useState("");
const [debouncedSearch, setDebouncedSearch] = useState(""); const [debouncedSearch, setDebouncedSearch] = useState("");
const [selectedIngredientIds, setSelectedIngredientIds] = useState<number[]>([]); const [selectedIngredientIds, setSelectedIngredientIds] = useState<number[]>([]);
@ -83,6 +92,11 @@ 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
// real recipe (see `handleSelectImportedRecipe`) — a rare race (the
// recipe was deleted between the browse fetch and the click), surfaced
// the same way any other catalog load error is on this dialog.
const [sourceSelectError, setSourceSelectError] = 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.
@ -114,6 +128,9 @@ export function RecipePickerDialog({
}, []); }, []);
useEffect(() => { useEffect(() => {
// The "sources" tab doesn't query the recipe table at all — same guard
// as `RecipesPage`'s own identical effect.
if (activeTab === "sources") return;
let cancelled = false; let cancelled = false;
setListState({ status: "loading" }); setListState({ status: "loading" });
@ -140,6 +157,18 @@ export function RecipePickerDialog({
selectedIngredientIds.includes(ingredient.id), selectedIngredientIds.includes(ingredient.id),
); );
/** Picking an already-imported source item (`RecipeSourcesPanel`'s "sources" tab) — 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. */
function handleSelectImportedRecipe(recipeId: number) {
setSourceSelectError(false);
apiClient
.getRecipe(recipeId)
.then((recipe) => {
setSelectedRecipe(recipe);
setPortions(String(recipe.portions));
})
.catch(() => setSourceSelectError(true));
}
async function handleConfirm() { async function handleConfirm() {
if (!selectedRecipe) return; if (!selectedRecipe) return;
const parsedPortions = Number(portions); const parsedPortions = Number(portions);
@ -201,6 +230,7 @@ export function RecipePickerDialog({
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">
{activeTab !== "sources" && (
<div className="recipe-picker__filters"> <div className="recipe-picker__filters">
<input <input
type="search" type="search"
@ -244,12 +274,18 @@ export function RecipePickerDialog({
<IngredientPicker <IngredientPicker
ingredients={ingredientsCatalog} ingredients={ingredientsCatalog}
excludeIds={selectedIngredientIds} excludeIds={selectedIngredientIds}
onSelect={(ingredient) => setSelectedIngredientIds((ids) => [...ids, ingredient.id])} onSelect={(ingredient) =>
setSelectedIngredientIds((ids) => [...ids, ingredient.id])
}
/> />
)} )}
</div> </div>
<DietTagSelect diets={dietsCatalog} value={selectedDietIds} onChange={setSelectedDietIds} /> <DietTagSelect
diets={dietsCatalog}
value={selectedDietIds}
onChange={setSelectedDietIds}
/>
{hasHousehold && ( {hasHousehold && (
<CheckboxOption checked={suitableForHousehold} onChange={setSuitableForHousehold}> <CheckboxOption checked={suitableForHousehold} onChange={setSuitableForHousehold}>
@ -257,14 +293,31 @@ export function RecipePickerDialog({
</CheckboxOption> </CheckboxOption>
)} )}
</div> </div>
)}
<RecipeTabs active={activeTab} onChange={setActiveTab} /> <RecipeTabs active={activeTab} onChange={setActiveTab} />
{activeTab === "sources" ? (
<>
{sourceSelectError && (
<p className="recipes-page__status recipes-page__status--error">
{t("common.loadError")}
</p>
)}
<RecipeSourcesPanel
planningSlot={slot}
onSelectImportedRecipe={handleSelectImportedRecipe}
/>
</>
) : (
<>
{listState.status === "loading" && ( {listState.status === "loading" && (
<p className="recipes-page__status">{t("planning.picker.loading")}</p> <p className="recipes-page__status">{t("planning.picker.loading")}</p>
)} )}
{listState.status === "error" && ( {listState.status === "error" && (
<p className="recipes-page__status recipes-page__status--error">{t("common.loadError")}</p> <p className="recipes-page__status recipes-page__status--error">
{t("common.loadError")}
</p>
)} )}
{listState.status === "loaded" && listState.recipes.length === 0 && ( {listState.status === "loaded" && listState.recipes.length === 0 && (
<p className="recipes-page__status">{t("planning.picker.empty")}</p> <p className="recipes-page__status">{t("planning.picker.empty")}</p>
@ -277,12 +330,14 @@ export function RecipePickerDialog({
const recipe = listState.recipes.find((r) => r.id === id) ?? null; const recipe = listState.recipes.find((r) => r.id === id) ?? null;
setSelectedRecipe(recipe); setSelectedRecipe(recipe);
// Pre-fill from the recipe's own written yield rather than // Pre-fill from the recipe's own written yield rather than
// always starting at 1 — still freely editable below, this is // always starting at 1 — still freely editable below, this
// just a better starting point (see `Recipe.portions`). // is just a better starting point (see `Recipe.portions`).
if (recipe) setPortions(String(recipe.portions)); if (recipe) setPortions(String(recipe.portions));
}} }}
/> />
)} )}
</>
)}
</Dialog> </Dialog>
); );
} }

View file

@ -0,0 +1,216 @@
import type { BrowsableSourceItemView, Meal, SourceView, WeekDay } from "@batch-cooking/shared";
import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { Link } from "react-router-dom";
import { apiClient } from "../../api/client";
import { SourceItemPreviewPanel, type SourceItemPreviewState } from "./SourceItemPreviewPanel";
import { SourceItemTable } from "./SourceItemTable";
import "./recipes.scss";
/** Debounce for the search field — same idea/value as `RecipesPage`'s own search. */
const SEARCH_DEBOUNCE_MS = 300;
type EnabledSourcesState =
| { status: "loading" }
| { status: "loaded"; sources: SourceView[] }
| { status: "error" };
type BrowseState =
| { status: "loading" }
| { status: "loaded"; items: BrowsableSourceItemView[]; nextCursor: string | null }
| { status: "error" };
/**
* "Sources" tab content of the recipe catalog (`RecipesPage`) a
* self-contained master-detail pair of its own (source selector + browsable
* list on the left, `SourceItemPreviewPanel` on the right), independent of
* `RecipeTable`/`RecipeDetailPanel`: it browses a source's *live* catalog
* (`GET /sources/:sourceKey/browse`), not the saved `Recipe` table, so it
* doesn't share their `RecipeTab`-based fetching at all.
*
* Selecting an already-imported item navigates straight to its real
* recipe (`/recettes/:id`, leaving this tab) selecting one that isn't
* imported yet shows a read-only preview here instead. Turning that
* preview into an actual saved recipe (reviewing/fixing unresolved
* ingredients first) is a later stage of the same plan, not built here.
*
* `onSelectImportedRecipe` hands back the id instead of this panel
* navigating anywhere itself what "viewing" an already-imported item
* means depends on the caller: `RecipesPage` switches its own active tab
* away from `"sources"` (its `RecipeDetailPanel`/`RecipeTable` only render
* outside that tab, so without switching first the URL would change but
* this panel would keep rendering over it) and navigates to the recipe's
* detail page, 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.
*/
export function RecipeSourcesPanel({
onSelectImportedRecipe,
planningSlot,
}: {
onSelectImportedRecipe: (recipeId: number) => void;
/** Forwarded as-is to `SourceItemPreviewPanel` — see its own doc comment. Only ever set by `RecipePickerDialog`. */
planningSlot?: { date: string; weekDay: WeekDay; meal: Meal };
}) {
const { t } = useTranslation();
const [enabledSources, setEnabledSources] = useState<EnabledSourcesState>({ status: "loading" });
const [selectedSourceKey, setSelectedSourceKey] = useState<string | null>(null);
const [search, setSearch] = useState("");
const [debouncedSearch, setDebouncedSearch] = useState("");
const [browseState, setBrowseState] = useState<BrowseState>({ status: "loading" });
const [selectedExternalId, setSelectedExternalId] = useState<string | null>(null);
const [previewState, setPreviewState] = useState<SourceItemPreviewState>({ status: "empty" });
// Loaded once — which sources exist, crossed with which the household
// has enabled (`/parametres/foyer`). Defaults the selector to the first
// enabled one, if any.
useEffect(() => {
let cancelled = false;
Promise.all([apiClient.getSources(), apiClient.getHouseSourceIds()])
.then(([sources, enabledIds]) => {
if (cancelled) return;
const enabled = sources.filter((source) => enabledIds.includes(source.id));
setEnabledSources({ status: "loaded", sources: enabled });
setSelectedSourceKey((current) => current ?? enabled[0]?.key ?? null);
})
.catch(() => {
if (!cancelled) setEnabledSources({ status: "error" });
});
return () => {
cancelled = true;
};
}, []);
useEffect(() => {
const timeout = window.setTimeout(() => setDebouncedSearch(search), SEARCH_DEBOUNCE_MS);
return () => window.clearTimeout(timeout);
}, [search]);
useEffect(() => {
if (selectedSourceKey === null) return;
let cancelled = false;
setBrowseState({ status: "loading" });
setSelectedExternalId(null);
setPreviewState({ status: "empty" });
apiClient
.browseSource(selectedSourceKey, { query: debouncedSearch.trim() || undefined })
.then(({ items, nextCursor }) => {
if (!cancelled) setBrowseState({ status: "loaded", items, nextCursor });
})
.catch(() => {
if (!cancelled) setBrowseState({ status: "error" });
});
return () => {
cancelled = true;
};
}, [selectedSourceKey, debouncedSearch]);
function handleLoadMore() {
if (selectedSourceKey === null || browseState.status !== "loaded" || !browseState.nextCursor) {
return;
}
const cursor = browseState.nextCursor;
apiClient
.browseSource(selectedSourceKey, { query: debouncedSearch.trim() || undefined, cursor })
.then(({ items, nextCursor }) => {
setBrowseState((prev) =>
prev.status === "loaded"
? { status: "loaded", items: [...prev.items, ...items], nextCursor }
: prev,
);
})
.catch(() => setBrowseState({ status: "error" }));
}
function handleSelectItem(item: BrowsableSourceItemView) {
if (item.alreadyImported && item.recipeId !== null) {
onSelectImportedRecipe(item.recipeId);
return;
}
if (selectedSourceKey === null) return;
setSelectedExternalId(item.externalId);
setPreviewState({ status: "loading" });
apiClient
.previewSourceItem(selectedSourceKey, item.externalId)
.then((draft) => setPreviewState({ status: "loaded", draft }))
.catch(() => setPreviewState({ status: "error" }));
}
if (enabledSources.status === "loading") {
return <p className="recipes-page__status">{t("recipes.loading")}</p>;
}
if (enabledSources.status === "error") {
return (
<p className="recipes-page__status recipes-page__status--error">{t("common.loadError")}</p>
);
}
if (enabledSources.sources.length === 0) {
return (
<p className="recipes-page__status">
{t("recipes.sources.noneEnabled")}{" "}
<Link to="/parametres/foyer">{t("recipes.sources.noneEnabledLink")}</Link>
</p>
);
}
return (
<>
<div className="recipes-page__header recipes-page__header--sources">
{enabledSources.sources.length > 1 && (
<select
className="source-sources-select"
aria-label={t("recipes.sources.sourceLabel")}
value={selectedSourceKey ?? ""}
onChange={(e) => setSelectedSourceKey(e.target.value)}
>
{enabledSources.sources.map((source) => (
<option key={source.key} value={source.key}>
{source.name}
</option>
))}
</select>
)}
<input
type="search"
className="recipes-page__search"
placeholder={t("recipes.sources.searchPlaceholder")}
value={search}
onChange={(e) => setSearch(e.target.value)}
/>
</div>
<div className="recipes-page__catalog">
{browseState.status === "loading" && (
<p className="recipes-page__status">{t("recipes.sources.loading")}</p>
)}
{browseState.status === "error" && (
<p className="recipes-page__status recipes-page__status--error">
{t("recipes.sources.loadError")}
</p>
)}
{browseState.status === "loaded" && browseState.items.length === 0 && (
<p className="recipes-page__status">{t("recipes.sources.empty")}</p>
)}
{browseState.status === "loaded" && browseState.items.length > 0 && (
<div className="source-items-column">
<SourceItemTable
items={browseState.items}
selectedExternalId={selectedExternalId}
onSelect={handleSelectItem}
/>
{browseState.nextCursor && (
<button type="button" className="source-items-load-more" onClick={handleLoadMore}>
{t("recipes.sources.loadMore")}
</button>
)}
</div>
)}
<SourceItemPreviewPanel state={previewState} planningSlot={planningSlot} />
</div>
</>
);
}

View file

@ -1,37 +1,61 @@
import type { RecipeTab } from "@batch-cooking/shared"; import type { RecipeTab } from "@batch-cooking/shared";
import type { LucideIcon } from "lucide-react"; import type { LucideIcon } from "lucide-react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { AccountIcon, FavoriteIcon, HouseholdIcon, PublicIcon } from "../../layouts/nav-icons"; import {
AccountIcon,
FavoriteIcon,
HouseholdIcon,
PublicIcon,
SourcesIcon,
} from "../../layouts/nav-icons";
import "./recipes.scss"; import "./recipes.scss";
/** Every functional tab, in display order, with its icon — reuses `AccountIcon`/`HouseholdIcon` from the sidebar's own icon set (see nav-icons.tsx) rather than a second "person"/"house" glyph. */ /**
const TABS: Array<{ value: RecipeTab; Icon: LucideIcon }> = [ * A tab of the recipe catalog either a real {@link RecipeTab} (`GET
* /recipes?tab=`, `recipe.service.ts`'s `listRecipes`) or `"sources"`, a
* web-only mode that doesn't query the recipe table at all: it browses a
* household-enabled external source's own catalog live
* (`GET /sources/:sourceKey/browse`, `RecipeSourcesPanel`) instead of
* listing saved `Recipe` rows. Kept out of the shared `RecipeTab` type on
* purpose the API has no `tab=sources` to validate.
*/
export type RecipesPageTab = RecipeTab | "sources";
/** Every possible tab, in display order, with its icon — reuses `AccountIcon`/`HouseholdIcon` from the sidebar's own icon set (see nav-icons.tsx) rather than a second "person"/"house" glyph. */
const ALL_TABS: Array<{ value: RecipesPageTab; Icon: LucideIcon }> = [
{ value: "favoris", Icon: FavoriteIcon }, { value: "favoris", Icon: FavoriteIcon },
{ value: "perso", Icon: AccountIcon }, { value: "perso", Icon: AccountIcon },
{ value: "foyer", Icon: HouseholdIcon }, { value: "foyer", Icon: HouseholdIcon },
{ value: "publique", Icon: PublicIcon }, { value: "publique", Icon: PublicIcon },
{ value: "sources", Icon: SourcesIcon },
]; ];
/** /**
* Catalog tab bar Favoris / Perso / Foyer / Publique, plus a disabled * Catalog tab bar Favoris / Perso / Foyer / Publique / Sources by
* placeholder for external sources (not built yet, see the plan's "hors * default (`/recettes`, `RecipesPage`). `tabs` narrows which of those
* scope" note) so the eventual nav slot is visible without being * show `RecipePickerDialog` (picking a recipe for a planning slot)
* functional. No "toutes" tab: every recipe a viewer can see falls under * passes just the four real ones: browsing external sources mid-dialog,
* exactly one of perso/foyer/publique (its own visibility) see * without the review/import flow, doesn't make sense there yet (its
* `onChange` narrows the result back to `RecipeTab` itself, safe exactly
* because `tabs` guarantees `"sources"` is never clickable there). No
* "toutes" tab among the real ones: every recipe a viewer can see falls
* under exactly one of perso/foyer/publique (its own visibility) see
* `recipe.service.ts`'s `listRecipes`. * `recipe.service.ts`'s `listRecipes`.
*/ */
export function RecipeTabs({ export function RecipeTabs({
active, active,
onChange, onChange,
tabs = ALL_TABS.map((tab) => tab.value),
}: { }: {
active: RecipeTab; active: RecipesPageTab;
onChange: (tab: RecipeTab) => void; onChange: (tab: RecipesPageTab) => void;
tabs?: readonly RecipesPageTab[];
}) { }) {
const { t } = useTranslation(); const { t } = useTranslation();
return ( return (
<div className="recipe-tabs"> <div className="recipe-tabs">
{TABS.map(({ value, Icon }) => ( {ALL_TABS.filter(({ value }) => tabs.includes(value)).map(({ value, Icon }) => (
<button <button
key={value} key={value}
type="button" type="button"
@ -42,14 +66,6 @@ export function RecipeTabs({
{t(`recipes.tabs.${value}`)} {t(`recipes.tabs.${value}`)}
</button> </button>
))} ))}
<button
type="button"
className="recipe-tabs__tab placeholder"
disabled
title={t("recipes.tabs.sourcesSoonHint")}
>
{t("recipes.tabs.sourcesSoon")}
</button>
</div> </div>
); );
} }

View file

@ -0,0 +1,154 @@
import type { Meal, RecipeImportDraftView, WeekDay } from "@batch-cooking/shared";
import { useTranslation } from "react-i18next";
import { Link } from "react-router-dom";
import { StepDescription } from "./StepDescription";
import "./recipes.scss";
/** State {@link SourceItemPreviewPanel} renders — mirrors `RecipeDetailState`'s shape (`RecipeDetailPanel`), one status short (no "not-found": an invalid `externalId` surfaces as `"error"`, there's no separate "id was well-formed but nothing matched it" case here). */
export type SourceItemPreviewState =
| { status: "empty" }
| { status: "loading" }
| { status: "loaded"; draft: RecipeImportDraftView }
| { status: "error" };
/**
* Right-hand panel of the catalog's "Sources" tab (`RecipeSourcesPanel`)
* a read-only preview of a not-yet-imported item: nothing here can be
* edited or saved yet (no favorite/edit/delete actions, unlike
* `RecipeDetailPanel`) turning this into an actual import with a review
* step for unresolved ingredients is a later stage of the same plan.
* Reuses `StepDescription` so a step's detected techniques are already
* highlighted here too, exactly like a saved recipe's detail.
*/
export function SourceItemPreviewPanel({
state,
planningSlot,
}: {
state: SourceItemPreviewState;
/**
* Set only when this panel is rendered from `RecipePickerDialog` (adding a
* recipe to one planning slot) rather than the standalone `/recettes`
* catalog carried along on the "Importer cette recette" link as query
* params so `ImportRecipePage` knows to add the freshly-created recipe to
* this exact slot once the import succeeds, instead of landing on the
* recipe's own detail page. See `ImportRecipePage`'s `planningSlot`.
*/
planningSlot?: { date: string; weekDay: WeekDay; meal: Meal };
}) {
const { t } = useTranslation();
if (state.status === "empty") {
return (
<aside className="recipe-detail-panel">
<p className="recipe-detail-panel__status">{t("recipes.sources.detail.empty")}</p>
</aside>
);
}
if (state.status === "loading") {
return (
<aside className="recipe-detail-panel">
<p className="recipe-detail-panel__status">{t("recipes.sources.detail.loading")}</p>
</aside>
);
}
if (state.status === "error") {
return (
<aside className="recipe-detail-panel">
<p className="recipe-detail-panel__status recipe-detail-panel__status--error">
{t("recipes.sources.detail.loadError")}
</p>
</aside>
);
}
const { draft } = state;
const hasUnresolvedIngredient = draft.ingredients.some(
(ingredient) => ingredient.ingredient === null,
);
return (
<aside className="recipe-detail-panel">
<div className="recipe-detail-panel__header">
<div className="recipe-detail-panel__photo" aria-hidden="true">
{draft.picture ? <img src={draft.picture} alt="" /> : "🍽️"}
</div>
</div>
<div className="recipe-detail-panel__title-row">
<div className="recipe-detail-panel__title-main">
<h2>{draft.name}</h2>
{draft.portions !== null && (
<p className="recipe-detail-panel__portions">
{t("recipes.detail.portions", { count: draft.portions })}
</p>
)}
</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 && (
<section className="recipe-detail-panel__section recipe-detail-panel__section--description">
<p className="recipe-detail-panel__description">{draft.description}</p>
</section>
)}
<section className="recipe-detail-panel__section">
<h3>{t("recipes.sources.detail.ingredientsCount", { count: draft.ingredients.length })}</h3>
{hasUnresolvedIngredient && (
<p className="source-item-preview__hint">
{t("recipes.sources.detail.unresolvedIngredientsHint")}
</p>
)}
<ul className="source-item-preview__ingredients">
{draft.ingredients.map((ingredient, index) => (
// Draft lines have no id of their own (nothing is saved yet) —
// `rawText` alone could collide (a source repeating the same
// line), so it's paired with its position; this list is fully
// regenerated from `draft` on every render, never reordered in
// place, so that's safe here (same reasoning as
// StepDescription.tsx's segment keys).
<li
key={`${index}-${ingredient.rawText}`}
className={ingredient.ingredient === null ? "is-unresolved" : undefined}
>
{ingredient.rawText}
</li>
))}
</ul>
</section>
<section className="recipe-detail-panel__section">
<h3>{t("recipes.sources.detail.stepsCount", { count: draft.steps.length })}</h3>
<ol className="recipe-detail-panel__steps">
{draft.steps.map((step, index) => (
<li key={`${index}-${step.description}`}>
{step.picture && <img src={step.picture} alt="" />}
<StepDescription description={step.description} techSteps={step.techSteps} />
</li>
))}
</ol>
</section>
</aside>
);
}

View file

@ -0,0 +1,67 @@
import type { BrowsableSourceItemView } from "@batch-cooking/shared";
import { useTranslation } from "react-i18next";
import "./recipes.scss";
/**
* List of one source's browsable items (`RecipeSourcesPanel`) same
* "photo + name, click/Enter to select" row shape as `RecipeTable`, plus
* an "already imported" badge in place of allergen/regime columns (a
* source item has neither, it's not resolved against our catalogs until
* previewed).
*/
export function SourceItemTable({
items,
selectedExternalId,
onSelect,
}: {
items: BrowsableSourceItemView[];
selectedExternalId: string | null;
onSelect: (item: BrowsableSourceItemView) => void;
}) {
const { t } = useTranslation();
return (
<div className="recipe-table-wrap">
<table className="recipe-table">
<thead>
<tr>
<th />
<th>{t("recipes.table.name")}</th>
<th />
</tr>
</thead>
<tbody>
{items.map((item) => (
<tr
key={item.externalId}
className={item.externalId === selectedExternalId ? "selected" : undefined}
onClick={() => onSelect(item)}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
onSelect(item);
}
}}
tabIndex={0}
aria-current={item.externalId === selectedExternalId ? "true" : undefined}
>
<td>
<span className="recipe-table__photo" aria-hidden="true">
{item.picture ? <img src={item.picture} alt="" /> : "🍽️"}
</span>
</td>
<td className="recipe-table__name">{item.title}</td>
<td>
{item.alreadyImported && (
<span className="source-item-table__imported-badge">
{t("recipes.sources.alreadyImported")}
</span>
)}
</td>
</tr>
))}
</tbody>
</table>
</div>
);
}

View file

@ -334,6 +334,91 @@
} }
} }
// --- Sources tab (RecipeSourcesPanel) ---------------------------------------
// Reuses .recipes-page__header/__search/__catalog and .recipe-table(-wrap)
// as-is (see RecipeSourcesPanel.tsx/SourceItemTable.tsx) only what's
// actually new to this tab gets its own rules here.
.recipes-page__header--sources {
// The source <select> only renders when the household has more than one
// enabled source (see RecipeSourcesPanel) this just keeps it visually
// grouped with the search field when it does.
gap: var(--space-sm);
}
.source-sources-select {
flex: none;
padding: 0.5rem var(--space-md);
font-family: var(--font-body);
font-size: var(--font-size-sm);
border: 1px solid var(--color-border);
border-radius: var(--radius-base);
background: var(--color-surface);
color: var(--color-text);
}
.source-item-table__imported-badge {
padding: 0.1rem 0.5rem;
font-size: var(--font-size-xs);
font-weight: 600;
color: var(--color-text-muted);
background: var(--color-surface-alt);
border-radius: var(--radius-pill);
white-space: nowrap;
}
.source-items-column {
height: 100%;
min-height: 0;
display: flex;
flex-direction: column;
gap: var(--space-sm);
}
.source-items-load-more {
flex-shrink: 0;
align-self: center;
padding: 0.4rem var(--space-lg);
font-family: var(--font-body);
font-size: var(--font-size-sm);
color: var(--color-primary);
background: none;
border: 1px solid var(--color-border);
border-radius: var(--radius-pill);
cursor: pointer;
&:hover {
border-color: var(--color-primary);
}
}
.source-item-preview__hint {
margin: 0 0 var(--space-sm);
padding: var(--space-xs) var(--space-sm);
font-size: var(--font-size-sm);
color: var(--color-warning);
background: color-mix(in srgb, var(--color-warning) 12%, var(--color-surface));
border-radius: var(--radius-base);
}
.source-item-preview__ingredients {
margin: 0;
padding: 0;
list-style: none;
display: flex;
flex-direction: column;
gap: 0.3rem;
li {
font-size: var(--font-size-sm);
}
.is-unresolved {
color: var(--color-text-muted);
font-style: italic;
}
}
// --- Recipe detail panel ----------------------------------------------------- // --- Recipe detail panel -----------------------------------------------------
// 100% of the grid row's height (see `.recipes-page__catalog` above): the // 100% of the grid row's height (see `.recipes-page__catalog` above): the
// panel itself never scrolls, only its content does past that height // panel itself never scrolls, only its content does past that height
@ -695,6 +780,59 @@
} }
} }
// --- Unresolved-ingredient review (ImportRecipePage) ------------------------
// Amber-tinted callout, same "warning" language as .source-item-preview__hint
// each line needs a person to pick the right ingredient (or drop it)
// before the form can submit at all (see ImportRecipePage.tsx's canSubmit).
.import-recipe__unresolved {
margin: var(--space-sm) 0;
padding: var(--space-sm) var(--space-md);
background: color-mix(in srgb, var(--color-warning) 8%, var(--color-surface));
border: 1px solid color-mix(in srgb, var(--color-warning) 30%, var(--color-border));
border-radius: var(--radius-md);
h3 {
margin: 0 0 var(--space-xs);
font-size: var(--font-size-base);
}
}
.import-recipe__unresolved-list {
list-style: none;
margin: var(--space-sm) 0 0;
padding: 0;
display: flex;
flex-direction: column;
gap: var(--space-sm);
}
.import-recipe__unresolved-row {
display: flex;
align-items: center;
gap: var(--space-sm);
flex-wrap: wrap;
span {
flex: 1 1 auto;
font-size: var(--font-size-sm);
}
button {
flex: none;
padding: 0.3rem var(--space-sm);
font-family: var(--font-body);
font-size: var(--font-size-xs);
background: var(--color-surface);
border: 1px solid var(--color-border);
border-radius: var(--radius-base);
cursor: pointer;
&:hover {
border-color: var(--color-primary);
}
}
}
// --- Diet tag multi-select (recipe form) ------------------------------------- // --- Diet tag multi-select (recipe form) -------------------------------------
// Checkbox-grid, same visual language as AllergySelect (profile-forms.scss) // Checkbox-grid, same visual language as AllergySelect (profile-forms.scss)
// global.scss's `label:has(> input[type="checkbox"])` rule already // global.scss's `label:has(> input[type="checkbox"])` rule already

View file

@ -25,4 +25,5 @@ export {
ChevronLeft as ChevronLeftIcon, ChevronLeft as ChevronLeftIcon,
Star as FavoriteIcon, Star as FavoriteIcon,
Globe as PublicIcon, Globe as PublicIcon,
Rss as SourcesIcon,
} from "lucide-react"; } from "lucide-react";

View file

@ -19,6 +19,7 @@
"INVITE_CODE_NOT_FOUND": "Ce code d'invitation ne correspond à aucun foyer", "INVITE_CODE_NOT_FOUND": "Ce code d'invitation ne correspond à aucun foyer",
"RECIPE_NOT_FOUND": "Cette recette n'existe pas", "RECIPE_NOT_FOUND": "Cette recette n'existe pas",
"RECIPE_IN_USE": "Cette recette est encore utilisée dans un planning", "RECIPE_IN_USE": "Cette recette est encore utilisée dans un planning",
"RECIPE_ALREADY_IMPORTED": "Cette recette a déjà été importée",
"INGREDIENT_NOT_FOUND": "Un des ingrédients sélectionnés n'existe pas", "INGREDIENT_NOT_FOUND": "Un des ingrédients sélectionnés n'existe pas",
"UNIT_NOT_FOUND": "Une des unités sélectionnées n'existe pas", "UNIT_NOT_FOUND": "Une des unités sélectionnées n'existe pas",
"SOURCE_NOT_FOUND": "Une des sources sélectionnées n'existe pas", "SOURCE_NOT_FOUND": "Une des sources sélectionnées n'existe pas",
@ -162,14 +163,48 @@
"perso": "Perso", "perso": "Perso",
"foyer": "Foyer", "foyer": "Foyer",
"publique": "Publique", "publique": "Publique",
"sourcesSoon": "Sources (bientôt)", "sources": "Sources"
"sourcesSoonHint": "Un onglet par source externe, une fois l'import de recettes construit"
}, },
"table": { "table": {
"name": "Nom", "name": "Nom",
"allergens": "Allergènes / intolérances", "allergens": "Allergènes / intolérances",
"diets": "Régime associé" "diets": "Régime associé"
}, },
"sources": {
"sourceLabel": "Source",
"noneEnabled": "Aucune source n'est activée pour votre foyer.",
"noneEnabledLink": "Activez-en une dans les paramètres du foyer",
"searchPlaceholder": "Rechercher…",
"empty": "Aucune recette trouvée.",
"loadMore": "Voir plus",
"alreadyImported": "Déjà importée",
"loading": "Chargement…",
"loadError": "Impossible de charger cette source pour le moment.",
"detail": {
"empty": "Sélectionnez une recette dans la liste pour voir son aperçu ici.",
"loading": "Chargement de l'aperçu…",
"loadError": "Impossible de charger l'aperçu de cette recette.",
"viewSource": "Voir sur le site d'origine",
"importButton": "Importer cette recette",
"ingredientsCount_one": "{{count}} ingrédient",
"ingredientsCount_other": "{{count}} ingrédients",
"unresolvedIngredientsHint": "Certains ingrédients n'ont pas été reconnus automatiquement — ils pourront être corrigés à l'import.",
"stepsCount_one": "{{count}} étape",
"stepsCount_other": "{{count}} étapes"
},
"import": {
"title": "Revoir l'import",
"planningHint": "Cette recette sera automatiquement ajoutée à votre planning une fois importée.",
"loadError": "Impossible de charger cette recette pour le moment.",
"unresolvedTitle": "Ingrédients à compléter",
"unresolvedHint": "Ces lignes n'ont pas été reconnues automatiquement — choisissez le bon ingrédient, ou retirez-les.",
"resolveButton": "Choisir un ingrédient",
"discardButton": "Retirer cette ligne",
"submit": "Importer",
"submitting": "Import en cours…",
"genericError": "Le formulaire contient des erreurs"
}
},
"detail": { "detail": {
"empty": "Sélectionnez une recette dans le tableau pour voir son détail ici.", "empty": "Sélectionnez une recette dans le tableau pour voir son détail ici.",
"favorite": "Ajouter aux favoris", "favorite": "Ajouter aux favoris",

View file

@ -0,0 +1,462 @@
import {
type CreateRecipeInput,
type DietView,
ErrorCode,
type IngredientView,
MEALS,
type Meal,
type RecipeVisibility,
type UnitView,
WEEK_DAYS,
type WeekDay,
createRecipeSchema,
} from "@batch-cooking/shared";
import { type FormEvent, useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { useNavigate, useParams, useSearchParams } from "react-router-dom";
import { ApiError, apiClient } from "../api/client";
import { DietTagSelect } from "../features/recipes/DietTagSelect";
import { IngredientPicker } from "../features/recipes/IngredientPicker";
import { IngredientRow } from "../features/recipes/IngredientRow";
import { type StepDraft, StepListEditor } from "../features/recipes/StepListEditor";
import "../features/recipes/recipes.scss";
import { makeClientKey } from "../lib/client-key";
import { errorMessageService } from "../services/error-message.service";
/** In display order — mirrors `RecipeVisibility` (schema.prisma/shared types). Same list as `RecipeFormPage`. */
const VISIBILITY_OPTIONS: RecipeVisibility[] = ["PERSONAL", "HOUSE", "PUBLIC"];
/** A resolved ingredient line — identical shape to `RecipeFormPage`'s own `IngredientLine`. */
interface IngredientLine {
key: string;
ingredient: IngredientView;
quantity: string;
unitId: number | null;
}
/** One draft line that didn't resolve to a real `Ingredient` on preview (`DraftRecipeIngredientView.ingredient === null`) — still needs a person to pick the right one, or discard it, before this recipe can be saved. */
interface UnresolvedIngredientLine {
key: string;
rawText: string;
quantity: string;
}
type LoadState = "loading" | "loaded" | "error";
/**
* Reads and validates `?planningDate=&planningWeekDay=&planningMeal=` off
* this page's own URL `null` unless all three are present and well-formed
* (a closed-set match against `WEEK_DAYS`/`MEALS`, same validation
* `addPlanningItemSchema` enforces server-side), so a hand-typed or stale
* URL just falls back to this page's normal "land on the recipe" behavior
* rather than throwing. See this component's own doc comment.
*/
function parsePlanningSlot(
searchParams: URLSearchParams,
): { date: string; weekDay: WeekDay; meal: Meal } | null {
const date = searchParams.get("planningDate");
const weekDay = searchParams.get("planningWeekDay");
const meal = searchParams.get("planningMeal");
if (
date === null ||
!/^\d{4}-\d{2}-\d{2}$/.test(date) ||
weekDay === null ||
!(WEEK_DAYS as readonly string[]).includes(weekDay) ||
meal === null ||
!(MEALS as readonly string[]).includes(meal)
) {
return null;
}
return { date, weekDay: weekDay as WeekDay, meal: meal as Meal };
}
/**
* Review screen for finalizing an import routed at
* `/recettes/importer/:sourceKey/:externalId` (reached from
* `SourceItemPreviewPanel`'s "Importer cette recette" button). Pre-filled
* from `GET /sources/:sourceKey/preview/:externalId` (the same draft the
* preview panel already showed), structurally the same form as
* `RecipeFormPage` same sub-components (`IngredientRow`,
* `IngredientPicker`, `StepListEditor`, `DietTagSelect`), same
* `CreateRecipeInput` submit shape plus one thing a manual creation
* never has to handle: ingredient lines the automatic matching
* (`ingredient-matcher.ts`) couldn't resolve. Those render as their own
* "à compléter" list, each needing a real ingredient picked (or the line
* discarded) before the form can submit never silently drops/guesses one,
* per the product decision this stage was built against (no invalid
* recipe is ever persisted).
*
* Submits to `POST /sources/:sourceKey/import/:externalId`
* (`apiClient.importSourceItem`) instead of `POST /recipes` the only
* other difference from `RecipeFormPage`'s own submit.
*
* `?planningDate=&planningWeekDay=&planningMeal=` are set only when this
* page was reached from `RecipePickerDialog`'s "Sources" tab (via
* `SourceItemPreviewPanel`'s import link, see its own `planningSlot` prop)
* picking a not-yet-imported item there hands off to this full review
* screen instead of the dialog's own small "how many portions?" step,
* since an unresolved-ingredient review doesn't fit in that step. When
* present and well-formed, a successful import also adds the freshly
* created recipe straight to that planning slot (`POST /planning/items`,
* using this form's own `portions` field) before landing back on the
* planning page, instead of the recipe's own detail page.
*/
export function ImportRecipePage() {
const { t } = useTranslation();
const navigate = useNavigate();
const { sourceKey, externalId } = useParams<{ sourceKey: string; externalId: string }>();
const [searchParams] = useSearchParams();
const planningSlot = parsePlanningSlot(searchParams);
const [loadState, setLoadState] = useState<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(() => {
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 (
<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>
<h1>{t("recipes.sources.import.title")}</h1>
{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,11 +1,12 @@
import { ErrorCode, type RecipeSummaryView, type RecipeTab } from "@batch-cooking/shared"; import { ErrorCode, type RecipeSummaryView } 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 { Link, useNavigate, useParams, useSearchParams } from "react-router-dom"; import { Link, useNavigate, useParams, useSearchParams } from "react-router-dom";
import { ApiError, apiClient } from "../api/client"; import { ApiError, apiClient } from "../api/client";
import { RecipeDetailPanel, type RecipeDetailState } from "../features/recipes/RecipeDetailPanel"; import { RecipeDetailPanel, type RecipeDetailState } from "../features/recipes/RecipeDetailPanel";
import { RecipeSourcesPanel } from "../features/recipes/RecipeSourcesPanel";
import { RecipeTable } from "../features/recipes/RecipeTable"; import { RecipeTable } from "../features/recipes/RecipeTable";
import { RecipeTabs } from "../features/recipes/RecipeTabs"; import { RecipeTabs, type RecipesPageTab } from "../features/recipes/RecipeTabs";
import "../features/recipes/recipes.scss"; import "../features/recipes/recipes.scss";
/** Debounce for the search field — avoids firing a request on every keystroke, same idea as the household name's autosave (`HouseholdSettingsPage`). */ /** Debounce for the search field — avoids firing a request on every keystroke, same idea as the household name's autosave (`HouseholdSettingsPage`). */
@ -37,7 +38,7 @@ export function RecipesPage() {
// filter view would). // filter view would).
const [searchParams] = useSearchParams(); const [searchParams] = useSearchParams();
const [activeTab, setActiveTab] = useState<RecipeTab>("favoris"); const [activeTab, setActiveTab] = useState<RecipesPageTab>("favoris");
const [search, setSearch] = useState(() => searchParams.get("search") ?? ""); const [search, setSearch] = useState(() => searchParams.get("search") ?? "");
// Seeded from the same initial value as `search` — otherwise the first // Seeded from the same initial value as `search` — otherwise the first
// fetch below would fire with an empty term (the debounce effect hasn't // fetch below would fire with an empty term (the debounce effect hasn't
@ -53,6 +54,10 @@ export function RecipesPage() {
}, [search]); }, [search]);
useEffect(() => { useEffect(() => {
// The "sources" tab doesn't query the recipe table at all — it browses
// a source's own live catalog instead (see `RecipeSourcesPanel`, which
// owns its own fetching entirely).
if (activeTab === "sources") return;
let cancelled = false; let cancelled = false;
setListState({ status: "loading" }); setListState({ status: "loading" });
@ -137,6 +142,7 @@ export function RecipesPage() {
<div className="recipes-page"> <div className="recipes-page">
<div className="recipes-page__header"> <div className="recipes-page__header">
<h1>{t("recipes.title")}</h1> <h1>{t("recipes.title")}</h1>
{activeTab !== "sources" && (
<input <input
type="search" type="search"
className="recipes-page__search" className="recipes-page__search"
@ -144,6 +150,7 @@ export function RecipesPage() {
value={search} value={search}
onChange={(e) => setSearch(e.target.value)} onChange={(e) => setSearch(e.target.value)}
/> />
)}
<Link to="/recettes/nouvelle" className="recipes-page__new-button"> <Link to="/recettes/nouvelle" className="recipes-page__new-button">
{t("recipes.newButton")} {t("recipes.newButton")}
</Link> </Link>
@ -151,6 +158,14 @@ export function RecipesPage() {
<RecipeTabs active={activeTab} onChange={setActiveTab} /> <RecipeTabs active={activeTab} onChange={setActiveTab} />
{activeTab === "sources" ? (
<RecipeSourcesPanel
onSelectImportedRecipe={(recipeId) => {
setActiveTab("favoris");
navigate(`/recettes/${recipeId}`);
}}
/>
) : (
<div className="recipes-page__catalog"> <div className="recipes-page__catalog">
{listState.status === "loading" && ( {listState.status === "loading" && (
<p className="recipes-page__status">{t("recipes.loading")}</p> <p className="recipes-page__status">{t("recipes.loading")}</p>
@ -178,6 +193,7 @@ export function RecipesPage() {
onDeleted={handleDeleted} onDeleted={handleDeleted}
/> />
</div> </div>
)}
</div> </div>
); );
} }

View file

@ -38,6 +38,8 @@ export enum ErrorCode {
ALREADY_HAS_HOUSE = 4020, ALREADY_HAS_HOUSE = 4020,
/** `DELETE /recipes/:id` attempted on a recipe still referenced by at least one `PlanningItem`. */ /** `DELETE /recipes/:id` attempted on a recipe still referenced by at least one `PlanningItem`. */
RECIPE_IN_USE = 4021, RECIPE_IN_USE = 4021,
/** `POST /sources/:sourceKey/import/:externalId` attempted on an item already imported (a `Recipe` already exists for that `sourceId`/`externalId` pair). */
RECIPE_ALREADY_IMPORTED = 4022,
/** A household action reserved to its admin (delete the household, remove a member) attempted by a non-admin member. */ /** A household action reserved to its admin (delete the household, remove a member) attempted by a non-admin member. */
NOT_HOUSE_ADMIN = 4030, NOT_HOUSE_ADMIN = 4030,
/** `PATCH /recipes/:id` or `DELETE /recipes/:id` attempted by someone other than the recipe's author — visibility controls reading, not writing. */ /** `PATCH /recipes/:id` or `DELETE /recipes/:id` attempted by someone other than the recipe's author — visibility controls reading, not writing. */

View file

@ -12,10 +12,12 @@ export * from "./schemas/planning.js";
export * from "./schemas/preferences.js"; export * from "./schemas/preferences.js";
export * from "./schemas/profile.js"; export * from "./schemas/profile.js";
export * from "./schemas/recipe.js"; export * from "./schemas/recipe.js";
export * from "./schemas/sources.js";
export * from "./tools/assert-is-never.js"; export * from "./tools/assert-is-never.js";
export * from "./types/household.js"; export * from "./types/household.js";
export * from "./types/planning.js"; export * from "./types/planning.js";
export * from "./types/preferences.js"; export * from "./types/preferences.js";
export * from "./types/recipe.js"; export * from "./types/recipe.js";
export * from "./types/reference.js"; export * from "./types/reference.js";
export * from "./types/sources.js";
export * from "./types/user-profile.js"; export * from "./types/user-profile.js";

View file

@ -0,0 +1,9 @@
import { z } from "zod";
/** Payload accepted by `GET /sources/:sourceKey/browse`'s query params — `query` free-text searches the source's own catalog (support varies by adapter, see `RecipeSourceListParams`), `cursor` continues a previous page (`RecipeSourceListResult.nextCursor`), omitted starts from the first page. */
export const browseSourceSchema = z.object({
query: z.string().trim().min(1).optional(),
cursor: z.string().trim().min(1).optional(),
});
/** Inferred TS type for {@link browseSourceSchema}'s validated output. */
export type BrowseSourceInput = z.infer<typeof browseSourceSchema>;

View file

@ -0,0 +1,63 @@
import type { StepTechStepView } from "./recipe.js";
import type { IngredientView, UnitView } from "./reference.js";
/**
* One item from a source's own catalog (`RecipeSourceAdapter.list()`),
* browsable regardless of whether it's already been imported
* `GET /sources/:sourceKey/browse`. Mirrors `BrowsableRecipeItem`
* (apps/api's `recipe-source-adapter.ts`), plus `recipeId`: the already-
* imported `Recipe`'s id when `alreadyImported` is true, so a caller (the
* browse UI) can navigate straight to it without a second lookup `null`
* otherwise.
*/
export interface BrowsableSourceItemView {
externalId: string;
title: string;
picture: string | null;
url: string;
alreadyImported: boolean;
recipeId: number | null;
}
/**
* One ingredient line of an unsaved import draft (`RecipeImportDraftView`)
* same spirit as `RecipeIngredientView`, but `ingredient`/`unit` can be
* `null` (nothing in the catalog matched see `ingredient-matcher.ts`) and
* `quantity` can be missing entirely, since this hasn't been reviewed/fixed
* up by a person yet.
*/
export interface DraftRecipeIngredientView {
/** Exactly what the source wrote for this line — kept even once ingredient/unit resolve, so a review screen can show what the match was made from. */
rawText: string;
quantity: number | null;
ingredient: IngredientView | null;
unit: UnitView | null;
}
/** One step of an unsaved import draft — `techSteps` is detected the same way a real save computes it (`matchTechStepSpans`), just not persisted yet. */
export interface DraftRecipeStepView {
description: string;
picture: string | null;
techSteps: StepTechStepView[];
}
/**
* An unsaved preview of one source item, fully translated (ingredients/
* units/techniques resolved against our catalogs where possible)
* `GET /sources/:sourceKey/preview/:externalId`. What a future import
* review screen pre-fills its form from. Deliberately distinct from
* `RecipeView`: nothing here has an id (nothing is saved), and ingredient/
* unit resolution can be incomplete nothing about this type assumes it's
* ready to persist as-is.
*/
export interface RecipeImportDraftView {
sourceKey: string;
externalId: string;
name: string;
description: string | null;
picture: string | null;
portions: number | null;
sourceUrl: string;
ingredients: DraftRecipeIngredientView[];
steps: DraftRecipeStepView[];
}