Consomme le socle backend de #42/#44 (`GET /reference/tours`, `GET/POST /profile/tours`). Deux tours : orientation générale (auto sur `/`) et correction des métadonnées de recette (auto à l'ouverture du premier popover de correction). - Dépendance `driver.js` (~5 ko, MIT, zéro dépendance). - `features/tour/tour-engine.ts` : wrapper vanilla autour de `driver.js` — gère ce que la lib ne fait pas seule : la navigation React Router entre étapes situées sur des routes différentes, et un `onFinish` unique (`"completed"` seulement si on atteint la fin, `"skipped"` pour la croix / Échap / clic sur l'overlay). - `features/tour/TourContext.tsx` : `TourProvider` (charge scénarios + progression, keyed sur `user?.id` comme `ThemeContext`) et `useTour()` (`startTour(key)` / `isTourCompleted(key)` / `tours` / `isReady`). `isReady` est **calculé au render** contre l'id utilisateur chargé — pas stocké — pour qu'un changement de `user` le repasse à `false` immédiatement, avant qu'un effet enfant (l'auto-déclenchement) ne tourne sur des données de complétion périmées. - `main.tsx` : `<TourProvider>` sous `ThemeProvider`. - Attributs `data-tour-*` : liens de nav (`AppLayout`), grille / bouton `+` du 1er créneau / bouton Cuisiner (`PlanningPage`), page liste de courses (`ShoppingListPage`), description d'étape + section technique du popover (`StepDescription` / `TechStepCorrectionPopover`). - Déclenchement auto : tour `general` sur `/` (`PlanningPage`, ref-gardé), tour `techStepMetadata` à `activeCorrection` non-null (`StepDescription`). - Section « Revoir un tutoriel » dans `/parametres/preferences-utilisateur` — un `map` sur `tours`, un bouton « Revoir » par scénario. - `tour.scss` re-skinne le popover `driver.js` sur les tokens du thème. - i18n `tour.*` (chrome uniquement : boutons du popover, section « Revoir » ; le contenu des étapes vient de la base). Tests Cypress (`tour.feature`, 5 scénarios) : auto-démarrage + élément surligné pour un nouveau venu ; « Terminé » → complétion enregistrée `skipped:false` ; croix → `skipped:true` ; un tour déjà vu ne se relance pas ; « Revoir » le relance depuis les paramètres. Vérifié 5/5 en local. `pnpm build` + `pnpm lint` verts. Refs #42 Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
416 lines
15 KiB
TypeScript
416 lines
15 KiB
TypeScript
import { DateTime, formatDateOnly, getWeekStart, toDateOnly } from "@batch-cooking/date-tools";
|
||
import {
|
||
ErrorCode,
|
||
MEALS,
|
||
type Meal,
|
||
type PlanningItemView,
|
||
type PlanningView,
|
||
WEEK_DAYS,
|
||
type WeekDay,
|
||
} from "@batch-cooking/shared";
|
||
import {
|
||
type CollisionDetection,
|
||
closestCenter,
|
||
DndContext,
|
||
type DragEndEvent,
|
||
KeyboardSensor,
|
||
MouseSensor,
|
||
pointerWithin,
|
||
TouchSensor,
|
||
useDroppable,
|
||
useSensor,
|
||
useSensors,
|
||
} from "@dnd-kit/core";
|
||
import { useEffect, useRef, useState } from "react";
|
||
import { useTranslation } from "react-i18next";
|
||
import { useNavigate } from "react-router-dom";
|
||
import { ApiError, apiClient } from "../../api/client";
|
||
import { useAuth } from "../../features/auth/AuthContext";
|
||
import { LeftoverAssignDialog } from "../../features/planning/LeftoverAssignDialog";
|
||
import { LeftoversBasket } from "../../features/planning/LeftoversBasket";
|
||
import type { LeftoverDragData } from "../../features/planning/leftovers-basket";
|
||
import { type PlanningSlot, RecipePickerDialog } from "../../features/planning/RecipePickerDialog";
|
||
import { WeekNavigator } from "../../features/planning/WeekNavigator";
|
||
import { useTour } from "../../features/tour/TourContext";
|
||
import { errorMessageService } from "../../services/error-message.service";
|
||
import "./planning-page.scss";
|
||
|
||
/** Drop-target payload each planning cell registers (`useDroppable`'s `data`) — the slot a dragged leftover lands on. */
|
||
interface CellDropData {
|
||
date: string;
|
||
weekDay: WeekDay;
|
||
meal: Meal;
|
||
}
|
||
|
||
/**
|
||
* Drop onto whichever cell the pointer is actually over; fall back to the
|
||
* nearest cell centre only when the pointer isn't inside any (e.g. the
|
||
* keyboard sensor, which has no pointer). Cells are packed edge to edge, so
|
||
* a rect-overlap strategy would pick a neighbour whenever the small drag
|
||
* handle's translated box crossed a border.
|
||
*/
|
||
const dropOnCellUnderPointer: CollisionDetection = (args) => {
|
||
const underPointer = pointerWithin(args);
|
||
return underPointer.length > 0 ? underPointer : closestCenter(args);
|
||
};
|
||
|
||
/** Load state for the `GET /planning` call — a discriminated union so a stale/impossible combination (e.g. "loading" with data) can't be represented. */
|
||
type PlanningState =
|
||
| { status: "loading" }
|
||
| { status: "loaded"; planning: PlanningView | null }
|
||
| { status: "error" };
|
||
|
||
/** Meals that close out a "moment of the day" group (Matin/Midi/Après-midi/Soir) — see `.band-end` in planning-page.scss for the resulting border treatment. */
|
||
const BAND_END_MEALS: ReadonlySet<Meal> = new Set(["collation", "dejeuner", "gouter"]);
|
||
|
||
/**
|
||
* Landing page for an authenticated visitor — the household's planning for
|
||
* a selectable week, laid out as a grid (days × meals). Behind
|
||
* {@link RequireAuth} (via `AppLayout`), so this only renders once a
|
||
* session is confirmed.
|
||
*
|
||
* `null` from the API is a normal, common state (no planning for that week
|
||
* yet) — unlike the previous single-day table view this replaces, it isn't
|
||
* rendered as a separate "empty" message: the grid itself, with every cell
|
||
* showing just its "+" button, already communicates that. The "+" itself
|
||
* isn't wired to anything yet (no recipe catalog to search — see the
|
||
* planning page's plan/PR description) — a future task.
|
||
*/
|
||
export function PlanningPage() {
|
||
const { t } = useTranslation();
|
||
const navigate = useNavigate();
|
||
const [weekStart, setWeekStart] = useState<DateTime>(() => getWeekStart(DateTime.utc()));
|
||
const [state, setState] = useState<PlanningState>({ status: "loading" });
|
||
// The slot a `RecipePickerDialog` is currently open for — `null` means
|
||
// closed. Mounting the dialog only while this is set (rather than an
|
||
// always-mounted `isOpen` toggle) resets its internal filter/search
|
||
// state for free on every open, same convention as `WeekNavigator`'s own
|
||
// `CalendarPopover` (features/planning/WeekNavigator.tsx).
|
||
const [openSlot, setOpenSlot] = useState<PlanningSlot | null>(null);
|
||
// A leftover card has been dropped on a slot and we're asking how many of
|
||
// its pool to serve there — `null` means no such step is open.
|
||
const [pendingAssign, setPendingAssign] = useState<(LeftoverDragData & CellDropData) | null>(
|
||
null,
|
||
);
|
||
|
||
// Guided tour #1 (issue #42): a signed-in visitor who's never seen the
|
||
// general tour gets it on first arrival at "/". Ref-guarded so it fires
|
||
// once per mount, never on a week-navigation re-render; `startTour`
|
||
// additionally no-ops if it's already completed or already running.
|
||
const { user } = useAuth();
|
||
const { isReady: isTourReady, isTourCompleted, startTour } = useTour();
|
||
const autoTourStarted = useRef(false);
|
||
useEffect(() => {
|
||
if (!autoTourStarted.current && isTourReady && user && !isTourCompleted("general")) {
|
||
autoTourStarted.current = true;
|
||
startTour("general");
|
||
}
|
||
}, [isTourReady, user, isTourCompleted, startTour]);
|
||
|
||
// Mouse + touch rather than the unified PointerSensor: same coverage, but
|
||
// driven by plain mouse/touch events, which Cypress can script reliably
|
||
// (see `planning-leftovers.ts`). A small activation distance so a plain
|
||
// click on a basket line's "Jeter" button doesn't start a drag; the
|
||
// keyboard sensor keeps the whole flow reachable without a pointer.
|
||
const sensors = useSensors(
|
||
useSensor(MouseSensor, { activationConstraint: { distance: 5 } }),
|
||
useSensor(TouchSensor, { activationConstraint: { delay: 150, tolerance: 5 } }),
|
||
useSensor(KeyboardSensor),
|
||
);
|
||
|
||
useEffect(() => {
|
||
let cancelled = false;
|
||
setState({ status: "loading" });
|
||
|
||
apiClient
|
||
.getPlanningForWeek(formatDateOnly(weekStart))
|
||
.then((planning) => {
|
||
if (!cancelled) setState({ status: "loaded", planning });
|
||
})
|
||
.catch(() => {
|
||
if (!cancelled) setState({ status: "error" });
|
||
});
|
||
|
||
return () => {
|
||
cancelled = true;
|
||
};
|
||
}, [weekStart]);
|
||
|
||
/**
|
||
* Applies `updater` to the currently loaded planning's items, patching
|
||
* local state without a full `GET /planning` refetch — same philosophy
|
||
* as `RecipesPage`'s `handleFavoriteToggled`/`handleDeleted`. Builds a
|
||
* placeholder parent `Planning` if none existed yet (the week's very
|
||
* first add, see `addPlanningItem`'s `findOrCreatePlanningForWeek`) —
|
||
* its `id`/`startDate`/`finishDate` are never read anywhere on this page
|
||
* (only `planning.items` is), so a placeholder id is harmless; the next
|
||
* week-navigation away and back re-fetches the real row anyway.
|
||
*/
|
||
function patchPlanningItems(updater: (items: PlanningItemView[]) => PlanningItemView[]) {
|
||
setState((prev) => {
|
||
if (prev.status !== "loaded") return prev;
|
||
const planning = prev.planning ?? {
|
||
id: -1,
|
||
startDate: weekStart.toISO() ?? "",
|
||
finishDate: weekStart.plus({ days: 6 }).toISO() ?? "",
|
||
items: [],
|
||
};
|
||
return { status: "loaded", planning: { ...planning, items: updater(planning.items) } };
|
||
});
|
||
}
|
||
|
||
function handleAdded(item: PlanningItemView) {
|
||
patchPlanningItems((items) => [...items, item]);
|
||
}
|
||
|
||
/** `PATCH .../discard-leftover` returns the updated item — swap it in by id so the basket recomputes. */
|
||
function handleLeftoverDiscarded(updated: PlanningItemView) {
|
||
patchPlanningItems((items) => items.map((i) => (i.id === updated.id ? updated : i)));
|
||
}
|
||
|
||
/** A leftover card was dropped on a cell — open the "how many portions?" step for that (source, slot) pair. */
|
||
function handleDragEnd(event: DragEndEvent) {
|
||
const drag = event.active.data.current as LeftoverDragData | undefined;
|
||
const cell = event.over?.data.current as CellDropData | undefined;
|
||
if (!drag || !cell) return;
|
||
setPendingAssign({ ...drag, ...cell });
|
||
}
|
||
|
||
/** Confirms the assign step — `POST /planning/items/leftover`, then patches the new "leftovers" item into local state. Returns an error label to show inline, or `null` on success. */
|
||
async function confirmAssign(portions: number): Promise<string | null> {
|
||
if (!pendingAssign) return null;
|
||
try {
|
||
const item = await apiClient.assignLeftover({
|
||
date: pendingAssign.date,
|
||
weekDay: pendingAssign.weekDay,
|
||
meal: pendingAssign.meal,
|
||
sourceItemId: pendingAssign.sourceItemId,
|
||
portions,
|
||
});
|
||
patchPlanningItems((items) => [...items, item]);
|
||
setPendingAssign(null);
|
||
return null;
|
||
} catch (err) {
|
||
return errorMessageService.getLabel(
|
||
err instanceof ApiError ? err.code : ErrorCode.INTERNAL_ERROR,
|
||
);
|
||
}
|
||
}
|
||
|
||
/** Optimistic removal (same rollback-on-failure idea as `FavoriteStarButton`) — the API call already happened by the time this needs to roll back, `removePlanningItem` having already rejected. */
|
||
async function handleRemove(item: PlanningItemView) {
|
||
patchPlanningItems((items) => items.filter((i) => i.id !== item.id));
|
||
try {
|
||
await apiClient.removePlanningItem(item.id);
|
||
} catch {
|
||
patchPlanningItems((items) => [...items, item]);
|
||
}
|
||
}
|
||
|
||
// Enabled only once we know the week has at least one planned recipe —
|
||
// "cuisiner" an empty week would just land on the page's own empty state.
|
||
const hasPlannedRecipes = state.status === "loaded" && (state.planning?.items.length ?? 0) > 0;
|
||
|
||
return (
|
||
<DndContext
|
||
sensors={sensors}
|
||
collisionDetection={dropOnCellUnderPointer}
|
||
onDragEnd={handleDragEnd}
|
||
>
|
||
<div className="planning-page">
|
||
<div className="planning-page__header">
|
||
<h1>{t("planning.title")}</h1>
|
||
<WeekNavigator weekStart={weekStart} onChangeWeek={setWeekStart} />
|
||
{state.status === "loaded" && (
|
||
<LeftoversBasket
|
||
planning={state.planning}
|
||
onLeftoverDiscarded={handleLeftoverDiscarded}
|
||
/>
|
||
)}
|
||
<button
|
||
type="button"
|
||
className="planning-page__cook-btn"
|
||
data-tour="planning-cook-button"
|
||
disabled={!hasPlannedRecipes}
|
||
onClick={() => navigate(`/cuisiner?date=${formatDateOnly(weekStart)}`)}
|
||
>
|
||
{t("planning.startCooking")}
|
||
</button>
|
||
</div>
|
||
|
||
{state.status === "loading" && (
|
||
<p className="planning-page__status">{t("planning.loading")}</p>
|
||
)}
|
||
|
||
{state.status === "error" && (
|
||
<p className="planning-page__status planning-page__status--error">
|
||
{t("common.loadError")}
|
||
</p>
|
||
)}
|
||
|
||
{state.status === "loaded" && (
|
||
<PlanningGrid
|
||
weekStart={weekStart}
|
||
planning={state.planning}
|
||
onAddSlot={setOpenSlot}
|
||
onRemoveItem={handleRemove}
|
||
/>
|
||
)}
|
||
|
||
{openSlot && (
|
||
<RecipePickerDialog
|
||
slot={openSlot}
|
||
onClose={() => setOpenSlot(null)}
|
||
onAdded={handleAdded}
|
||
/>
|
||
)}
|
||
|
||
{pendingAssign && (
|
||
<LeftoverAssignDialog
|
||
recipeName={pendingAssign.recipeName}
|
||
available={pendingAssign.available}
|
||
onCancel={() => setPendingAssign(null)}
|
||
onConfirm={confirmAssign}
|
||
/>
|
||
)}
|
||
</div>
|
||
</DndContext>
|
||
);
|
||
}
|
||
|
||
/** The week grid itself — 7 day columns × 5 meal rows. */
|
||
function PlanningGrid({
|
||
weekStart,
|
||
planning,
|
||
onAddSlot,
|
||
onRemoveItem,
|
||
}: {
|
||
weekStart: DateTime;
|
||
planning: PlanningView | null;
|
||
onAddSlot: (slot: PlanningSlot) => void;
|
||
onRemoveItem: (item: PlanningItemView) => void;
|
||
}) {
|
||
const { t } = useTranslation();
|
||
const today = toDateOnly(DateTime.utc());
|
||
const days = WEEK_DAYS.map((weekDay, i) => ({ weekDay, date: weekStart.plus({ days: i }) }));
|
||
const items = planning?.items ?? [];
|
||
|
||
return (
|
||
<div className="planning-grid-wrapper">
|
||
<table className="planning-grid" data-tour="planning-grid">
|
||
<thead>
|
||
<tr>
|
||
<th />
|
||
{days.map(({ weekDay, date }) => (
|
||
<th key={weekDay} className={date.hasSame(today, "day") ? "today" : undefined}>
|
||
<span className="day-name">{t(`common.days.${weekDay}`)}</span>
|
||
<span className="day-date">{date.day}</span>
|
||
</th>
|
||
))}
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
{MEALS.map((meal, mealIndex) => (
|
||
<tr key={meal} className={BAND_END_MEALS.has(meal) ? "band-end" : undefined}>
|
||
<th>{t(`planning.meals.${meal}`)}</th>
|
||
{days.map(({ weekDay, date }, dayIndex) => (
|
||
<MealCell
|
||
key={weekDay}
|
||
isToday={date.hasSame(today, "day")}
|
||
date={formatDateOnly(date)}
|
||
weekDay={weekDay}
|
||
meal={meal}
|
||
items={items.filter((item) => item.weekDay === weekDay && item.meal === meal)}
|
||
onAdd={() => onAddSlot({ date: formatDateOnly(date), weekDay, meal })}
|
||
onRemove={onRemoveItem}
|
||
// Only the very first cell's "+" carries the tour anchor —
|
||
// the guided tour just needs one to point at.
|
||
isTourAddTarget={mealIndex === 0 && dayIndex === 0}
|
||
/>
|
||
))}
|
||
</tr>
|
||
))}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
/** One (day, meal) cell: the recipes already planned for it (as pills, each showing its portion count) plus the "+" to add another. Also a `@dnd-kit` drop target for a leftover card. */
|
||
function MealCell({
|
||
isToday,
|
||
date,
|
||
weekDay,
|
||
meal,
|
||
items,
|
||
onAdd,
|
||
onRemove,
|
||
isTourAddTarget = false,
|
||
}: {
|
||
isToday: boolean;
|
||
date: string;
|
||
weekDay: WeekDay;
|
||
meal: Meal;
|
||
items: PlanningItemView[];
|
||
onAdd: () => void;
|
||
onRemove: (item: PlanningItemView) => void;
|
||
isTourAddTarget?: boolean;
|
||
}) {
|
||
const { t } = useTranslation();
|
||
const { setNodeRef, isOver } = useDroppable({
|
||
id: `cell-${weekDay}-${meal}`,
|
||
data: { date, weekDay, meal } satisfies CellDropData,
|
||
});
|
||
|
||
const className = ["meal-cell", isToday ? "today" : "", isOver ? "meal-cell--drop-over" : ""]
|
||
.filter(Boolean)
|
||
.join(" ");
|
||
|
||
return (
|
||
<td ref={setNodeRef} className={className}>
|
||
<div className="meal-cell__content">
|
||
{items.length > 0 && (
|
||
<div className="meal-cell__recipes">
|
||
{items.map((item) => (
|
||
<span
|
||
key={item.id}
|
||
className={
|
||
item.sourceItem !== null ? "recipe-chip recipe-chip--leftover" : "recipe-chip"
|
||
}
|
||
>
|
||
<span className="recipe-chip__name">
|
||
{item.sourceItem !== null && (
|
||
<span
|
||
className="recipe-chip__leftover-badge"
|
||
title={t("planning.leftovers.chipBadge")}
|
||
>
|
||
♻️
|
||
</span>
|
||
)}
|
||
{item.recipe.name} · ×{item.portions}
|
||
</span>
|
||
<button
|
||
type="button"
|
||
className="recipe-chip__remove"
|
||
title={t("planning.grid.removeRecipe")}
|
||
onClick={() => onRemove(item)}
|
||
>
|
||
✕
|
||
</button>
|
||
</span>
|
||
))}
|
||
</div>
|
||
)}
|
||
<button
|
||
type="button"
|
||
className="add-recipe-btn"
|
||
title={t("planning.grid.addRecipe")}
|
||
onClick={onAdd}
|
||
data-tour={isTourAddTarget ? "planning-add-recipe" : undefined}
|
||
>
|
||
+
|
||
</button>
|
||
</div>
|
||
</td>
|
||
);
|
||
}
|