feat(web): moteur de tutoriel guidé driver.js (#42) #60

Merged
kyuno merged 1 commit from feat/tour-engine into main 2026-09-06 06:53:24 +02:00
17 changed files with 607 additions and 12 deletions

View file

@ -0,0 +1,39 @@
Feature: Guided tour
A signed-in user who has never seen the "general" tour gets it on their
first arrival at "/". Finishing it (or dismissing it) records it as seen
so it never auto-starts again — but it can always be replayed from the
settings.
Background:
Given I am signed in as "Alice" "Martin"
And the planning request returns nothing
Scenario: The general tour auto-starts for a first-time visitor
Given the guided tour has not been seen yet
When I visit "/"
Then the tour popover shows "Votre semaine, ici"
And the sidebar "Planning" link is highlighted by the tour
Scenario: Finishing the tour records it as completed
Given the guided tour has not been seen yet, with a single step
When I visit "/"
And I click the tour's next button
Then the tour completion was recorded as finished
Scenario: Dismissing the tour records it as skipped
Given the guided tour has not been seen yet
When I visit "/"
And I dismiss the tour
Then the tour completion was recorded as skipped
Scenario: A tour already seen does not auto-start again
Given the guided tour has already been seen
When I visit "/"
Then no tour popover is shown
Scenario: A seen tour can be replayed from the settings
Given the guided tour has already been seen
When I visit "/parametres/preferences-utilisateur"
And I click "Revoir" next to "Tour d'orientation"
Then the tour popover shows "Votre semaine, ici"

View file

@ -0,0 +1,79 @@
import { Given, Then, When } from "@badeball/cypress-cucumber-preprocessor";
// Guided-tour steps (issue #42). Mocks the API via `cy.intercept`, like the
// rest of the suite. `I am signed in as` / `the planning request returns
// nothing` / `I visit` come from the shared common.steps.ts.
const GENERAL_STEPS = [
{ order: 0, route: "/", targetKey: "nav-planning", title: "Votre semaine, ici", content: "..." },
{ order: 1, route: null, targetKey: "nav-recipes", title: "Vos recettes", content: "..." },
{
order: 2,
route: null,
targetKey: "nav-shopping-list",
title: "La liste de courses",
content: "...",
},
];
function interceptTours(steps: typeof GENERAL_STEPS) {
cy.intercept("GET", "**/reference/tours", {
statusCode: 200,
body: [{ key: "general", name: "Tour d'orientation", steps }],
});
}
Given("the guided tour has not been seen yet", () => {
interceptTours(GENERAL_STEPS);
cy.intercept("GET", "**/profile/tours", { statusCode: 200, body: { completedTourKeys: [] } });
cy.intercept("POST", "**/profile/tours/general/complete", { statusCode: 204 }).as("complete");
});
Given("the guided tour has not been seen yet, with a single step", () => {
interceptTours([GENERAL_STEPS[0]]);
cy.intercept("GET", "**/profile/tours", { statusCode: 200, body: { completedTourKeys: [] } });
cy.intercept("POST", "**/profile/tours/general/complete", { statusCode: 204 }).as("complete");
});
Given("the guided tour has already been seen", () => {
interceptTours(GENERAL_STEPS);
cy.intercept("GET", "**/profile/tours", {
statusCode: 200,
body: { completedTourKeys: ["general"] },
});
cy.intercept("POST", "**/profile/tours/general/complete", { statusCode: 204 }).as("complete");
});
Then("the tour popover shows {string}", (title: string) => {
cy.get(".driver-popover.tour-popover .driver-popover-title").should("contain.text", title);
});
Then("the sidebar {string} link is highlighted by the tour", (label: string) => {
cy.contains(".app-sidebar__nav a", label).should("have.class", "driver-active-element");
});
When("I click the tour's next button", () => {
cy.get(".driver-popover.tour-popover .driver-popover-next-btn").click();
});
When("I dismiss the tour", () => {
cy.get(".driver-popover.tour-popover .driver-popover-close-btn").click();
});
Then("the tour completion was recorded as finished", () => {
cy.wait("@complete").its("request.body").should("deep.equal", { skipped: false });
});
Then("the tour completion was recorded as skipped", () => {
cy.wait("@complete").its("request.body").should("deep.equal", { skipped: true });
});
Then("no tour popover is shown", () => {
// Give the auto-trigger effect a chance to (not) fire.
cy.wait(300);
cy.get(".driver-popover").should("not.exist");
});
When("I click {string} next to {string}", (button: string, tourName: string) => {
cy.contains(".tour-replay-list__item", tourName).contains("button", button).click();
});

View file

@ -22,6 +22,7 @@
"@capacitor/preferences": "^8.0.1", "@capacitor/preferences": "^8.0.1",
"@dnd-kit/core": "^6.3.1", "@dnd-kit/core": "^6.3.1",
"@dnd-kit/utilities": "^3.2.2", "@dnd-kit/utilities": "^3.2.2",
"driver.js": "^1.8.0",
"i18next": "^26.3.6", "i18next": "^26.3.6",
"lucide-react": "^1.32.0", "lucide-react": "^1.32.0",
"react": "^18.3.1", "react": "^18.3.1",

View file

@ -28,6 +28,7 @@ import {
type SubmitTechStepCorrectionResult, type SubmitTechStepCorrectionResult,
type TechStepView, type TechStepView,
type ThemePreference, type ThemePreference,
type TourScenarioView,
type UnitView, type UnitView,
type UpdateRecipeInput, type UpdateRecipeInput,
type UtensilView, type UtensilView,
@ -477,6 +478,27 @@ export class ApiClient {
public updatePreferences(theme: ThemePreference): Promise<PreferencesView> { public updatePreferences(theme: ThemePreference): Promise<PreferencesView> {
return this._request("/preferences", { method: "PATCH", body: JSON.stringify({ theme }) }); return this._request("/preferences", { method: "PATCH", body: JSON.stringify({ theme }) });
} }
/** Every enabled guided-tour scenario with its steps — public reference data (issue #42/#44). */
public getTours(): Promise<TourScenarioView[]> {
return this._request("/reference/tours");
}
/** The `key`s of the tour scenarios the current user has already seen (completed or skipped). */
public async getCompletedTourKeys(): Promise<string[]> {
const { completedTourKeys } = await this._request<{ completedTourKeys: string[] }>(
"/profile/tours",
);
return completedTourKeys;
}
/** Marks a tour scenario as seen — `skipped` distinguishes a dismissal from a full run (analytics only; both stop the auto-trigger). Idempotent. */
public completeTour(tourKey: string, skipped: boolean): Promise<void> {
return this._request(`/profile/tours/${encodeURIComponent(tourKey)}/complete`, {
method: "POST",
body: JSON.stringify({ skipped }),
});
}
} }
/** Single shared instance — this client is stateless, no need for one per caller. */ /** Single shared instance — this client is stateless, no need for one per caller. */

View file

@ -6,6 +6,7 @@ import type {
import { Fragment, useEffect, useRef, useState } from "react"; import { Fragment, useEffect, useRef, useState } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { Tooltip } from "../../../components/ui/Tooltip"; import { Tooltip } from "../../../components/ui/Tooltip";
import { useTour } from "../../tour/TourContext";
import { splitDescriptionSegments } from "./highlight-tech-steps"; import { splitDescriptionSegments } from "./highlight-tech-steps";
import { TechStepCorrectionPopover } from "./TechStepCorrectionPopover"; import { TechStepCorrectionPopover } from "./TechStepCorrectionPopover";
import { temperatureLabel } from "./temperature-label"; import { temperatureLabel } from "./temperature-label";
@ -119,6 +120,16 @@ export function StepDescription({
previousTechStepId: number | null; previousTechStepId: number | null;
} | null>(null); } | null>(null);
// Guided tour #2 (issue #42): the first time a viewer actually opens a
// correction (editable mode only), walk them through the metadata editor.
// `startTour` no-ops if it's already been seen or is disabled.
const { startTour, isTourCompleted } = useTour();
useEffect(() => {
if (editable && activeCorrection && !isTourCompleted("techStepMetadata")) {
startTour("techStepMetadata");
}
}, [editable, activeCorrection, isTourCompleted, startTour]);
// Routes the *next* text selection to the open `TechStepCorrectionPopover` // Routes the *next* text selection to the open `TechStepCorrectionPopover`
// (as an ingredient/utensil/temperature mention span) instead of opening // (as an ingredient/utensil/temperature mention span) instead of opening
// a brand-new correction — set when that popover calls `onRequestSpan`, cleared once // a brand-new correction — set when that popover calls `onRequestSpan`, cleared once
@ -192,7 +203,11 @@ export function StepDescription({
return ( return (
<> <>
<p ref={containerRef} onMouseUp={handleMouseUp}> <p
ref={containerRef}
onMouseUp={handleMouseUp}
data-tour={editable ? "techstep-description" : undefined}
>
{segments.map((segment, index) => { {segments.map((segment, index) => {
const start = offset; const start = offset;
offset += segment.text.length; offset += segment.text.length;

View file

@ -631,7 +631,10 @@ export function TechStepCorrectionPopover({
<p>{t("recipes.loading")}</p> <p>{t("recipes.loading")}</p>
) : ( ) : (
<> <>
<section className="tech-step-correction-popover__section tech-step-correction-popover__technique-section"> <section
className="tech-step-correction-popover__section tech-step-correction-popover__technique-section"
data-tour="techstep-technique-picker"
>
<SectionHeader <SectionHeader
icon={<TagIcon />} icon={<TagIcon />}
title={t("recipes.techStepCorrection.techniqueSection")} title={t("recipes.techStepCorrection.techniqueSection")}

View file

@ -0,0 +1,129 @@
import type { TourScenarioView } from "@batch-cooking/shared";
import { createContext, type ReactNode, useCallback, useContext, useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { useLocation, useNavigate } from "react-router-dom";
import { apiClient } from "../../api/client";
import { useAuth } from "../auth/AuthContext";
import { runTour } from "./tour-engine";
/** Shape of the tour state/actions exposed via {@link useTour}. */
interface TourContextValue {
/** Every enabled scenario (`GET /reference/tours`), or `[]` until loaded. */
tours: TourScenarioView[];
/** True once both the scenario list and (when signed in) the "already seen" set have loaded — auto-triggers should wait for this. */
isReady: boolean;
/** Whether the signed-in user has already seen this scenario (completed or skipped). Always `false` for a logged-out visitor. */
isTourCompleted: (tourKey: string) => boolean;
/**
* Starts a scenario by key. No-op if it's unknown or already running.
* Marks it seen server-side when it ends (a full run vs. a dismissal emit
* different analytics but both stop the auto-trigger).
*/
startTour: (tourKey: string) => void;
}
const TourContext = createContext<TourContextValue | null>(null);
/**
* Loads the guided-tour scenarios and the signed-in user's progress, and
* drives `driver.js` through a scenario on request (issue #42). Must sit
* inside `AuthProvider` (reads `useAuth`) and a router (`useNavigate` /
* `useLocation`) — see `main.tsx`. Same "effect keyed on `user?.id`, no
* `localStorage`" shape as `ThemeContext`.
*
* The tour engine itself (`tour-engine.ts`) is plain TS with no React —
* this context is the thin React seam the issue #42 design calls for.
*/
export function TourProvider({ children }: { children: ReactNode }) {
const { user } = useAuth();
const { t } = useTranslation();
const navigate = useNavigate();
const location = useLocation();
const [tours, setTours] = useState<TourScenarioView[]>([]);
const [completedKeys, setCompletedKeys] = useState<Set<string>>(new Set());
// The user id the current `tours`/`completedKeys` were loaded for
// (`null` = loaded logged-out; `undefined` = nothing loaded yet).
const [loadedForUserId, setLoadedForUserId] = useState<number | null | undefined>(undefined);
const [runningKey, setRunningKey] = useState<string | null>(null);
const currentUserId = user?.id ?? null;
// Computed in render, not stored — so the instant `user` changes,
// `isReady` reads `false` *before* any effect (including a child's
// auto-trigger) runs against stale, other-user completion data.
const isReady = loadedForUserId === currentUserId;
// biome-ignore lint/correctness/useExhaustiveDependencies: keyed on the id on purpose, same as ThemeContext.
useEffect(() => {
let cancelled = false;
const scenarios = apiClient.getTours().catch((): TourScenarioView[] => []);
const progress = user
? apiClient.getCompletedTourKeys().catch((): string[] => [])
: Promise.resolve<string[]>([]);
void Promise.all([scenarios, progress]).then(([loadedTours, keys]) => {
if (cancelled) return;
setTours(loadedTours);
setCompletedKeys(new Set(keys));
setLoadedForUserId(currentUserId);
});
return () => {
cancelled = true;
};
}, [user?.id]);
const isTourCompleted = useCallback(
(tourKey: string) => completedKeys.has(tourKey),
[completedKeys],
);
const startTour = useCallback(
(tourKey: string) => {
if (runningKey) return;
const scenario = tours.find((tour) => tour.key === tourKey);
if (!scenario) return;
setRunningKey(tourKey);
runTour({
scenario,
navigate: (path) => navigate(path),
getPathname: () => location.pathname,
labels: {
next: t("tour.controls.next"),
prev: t("tour.controls.prev"),
done: t("tour.controls.done"),
},
onFinish: (outcome) => {
setRunningKey(null);
// Optimistically mark it seen so an auto-trigger doesn't re-fire
// before the request resolves; the POST is idempotent anyway.
setCompletedKeys((prev) => new Set(prev).add(tourKey));
if (user) {
apiClient.completeTour(tourKey, outcome === "skipped").catch(() => {
// A failed "mark seen" isn't worth surfacing — worst case the
// tour offers itself again next session.
});
}
},
});
},
[runningKey, tours, navigate, location.pathname, user, t],
);
return (
<TourContext.Provider value={{ tours, isReady, isTourCompleted, startTour }}>
{children}
</TourContext.Provider>
);
}
/** Reads the current tour state/actions. Must be called from within a {@link TourProvider}. */
export function useTour(): TourContextValue {
const ctx = useContext(TourContext);
if (!ctx) {
throw new Error("useTour must be used within a TourProvider");
}
return ctx;
}

View file

@ -0,0 +1,112 @@
import type { TourScenarioView } from "@batch-cooking/shared";
import { type Config, type DriveStep, driver } from "driver.js";
import "driver.js/dist/driver.css";
import "./tour.scss";
/** How a tour run ended — a completion and a deliberate skip both count as "seen" server-side, but emit different analytics. */
export type TourOutcome = "completed" | "skipped";
interface RunTourOptions {
scenario: TourScenarioView;
/** React Router navigate — used to move to a step's `route` before highlighting its target. */
navigate: (path: string) => void;
/** Current `location.pathname`, read lazily so a mid-tour route change is seen. */
getPathname: () => string;
/** Localized popover button labels (the popover *content* is DB-driven free text, the buttons are chrome). */
labels: { next: string; prev: string; done: string };
/** Called exactly once when the run ends (last step done, skipped, or dismissed). */
onFinish: (outcome: TourOutcome) => void;
}
/** CSS selector for the element a step points at — the `data-tour` attribute the frontend puts on it. */
function selectorFor(targetKey: string): string {
return `[data-tour="${targetKey}"]`;
}
/**
* Runs one {@link TourScenarioView} through `driver.js`. `driver.js` handles
* the spotlight/popover and waits (config `waitForElement`) for a target to
* mount; this wrapper adds the two things it doesn't do itself:
*
* - **cross-route steps** — before advancing to a step whose `route` differs
* from the current path, `navigate()` there; `driver.js` then waits for
* the target to appear on the new page.
* - **a single, definitive outcome** — `onFinish` fires exactly once,
* `"completed"` only when the user reaches the end, `"skipped"` for the ✕,
* Escape, or an overlay click.
*
* Deliberately framework-agnostic (no React) — called from `TourContext`'s
* `startTour`, same "imperative library behind a hook" split the issue #42
* design calls for.
*/
export function runTour({
scenario,
navigate,
getPathname,
labels,
onFinish,
}: RunTourOptions): void {
if (scenario.steps.length === 0) {
onFinish("completed");
return;
}
let finished = false;
const finish = (outcome: TourOutcome) => {
if (finished) return;
finished = true;
onFinish(outcome);
};
const steps: DriveStep[] = scenario.steps.map((step) => ({
element: selectorFor(step.targetKey),
popover: { title: step.title, description: step.content },
}));
const config: Config = {
steps,
showProgress: scenario.steps.length > 1,
// Give a freshly-navigated route time to mount the target before
// driver.js gives up on it.
waitForElement: 4000,
smoothScroll: true,
allowClose: true,
popoverClass: "tour-popover",
nextBtnText: labels.next,
prevBtnText: labels.prev,
doneBtnText: labels.done,
onNextClick: (_element, _step, { driver: driverObj, index }) => {
if (driverObj.isLastStep()) {
driverObj.destroy();
finish("completed");
return;
}
const nextStep = scenario.steps[(index ?? 0) + 1];
if (nextStep?.route && nextStep.route !== getPathname()) {
navigate(nextStep.route);
}
driverObj.moveNext();
},
onPrevClick: (_element, _step, { driver: driverObj, index }) => {
const prevStep = scenario.steps[(index ?? 0) - 1];
if (prevStep?.route && prevStep.route !== getPathname()) {
navigate(prevStep.route);
}
driverObj.movePrevious();
},
onCloseClick: (_element, _step, { driver: driverObj }) => {
driverObj.destroy();
finish("skipped");
},
// Reached on Escape / overlay click (and as the tail of `destroy()` from
// the handlers above, where `finished` is already set so this is a no-op).
onDestroyed: () => finish("skipped"),
};
const driverObj = driver(config);
const firstRoute = scenario.steps[0]?.route;
if (firstRoute && firstRoute !== getPathname()) {
navigate(firstRoute);
}
driverObj.drive();
}

View file

@ -0,0 +1,73 @@
// Re-skins driver.js's popover to the app's design tokens (its own
// `driver.css`, imported alongside this in `tour-engine.ts`, ships a
// generic light theme). Scoped to `.tour-popover`, the `popoverClass` the
// engine passes — driver.js's overlay/stage keep their defaults. Tokens
// resolve per-theme globally (`styles/_theme.scss`), so no light/dark
// blocks here.
.driver-popover.tour-popover {
background: var(--color-surface);
color: var(--color-text);
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
box-shadow: var(--shadow-md);
font-family: var(--font-body);
max-width: 22rem;
.driver-popover-title {
font-family: var(--font-display);
font-size: var(--font-size-md);
color: var(--color-text);
}
.driver-popover-description {
font-size: var(--font-size-sm);
color: var(--color-text-muted);
}
.driver-popover-progress-text {
font-size: var(--font-size-xs);
color: var(--color-text-muted);
}
// driver.js draws the popover arrow with `border-*-color` on a
// zero-size element — retint the side that actually shows.
.driver-popover-arrow-side-left.driver-popover-arrow {
border-left-color: var(--color-surface);
}
.driver-popover-arrow-side-right.driver-popover-arrow {
border-right-color: var(--color-surface);
}
.driver-popover-arrow-side-top.driver-popover-arrow {
border-top-color: var(--color-surface);
}
.driver-popover-arrow-side-bottom.driver-popover-arrow {
border-bottom-color: var(--color-surface);
}
.driver-popover-footer button {
font-family: var(--font-body);
font-size: var(--font-size-sm);
font-weight: 600;
text-shadow: none;
border-radius: var(--radius-base);
border: 1px solid var(--color-border);
background: var(--color-surface);
color: var(--color-text);
&:hover {
background: var(--color-surface-alt);
}
}
.driver-popover-next-btn {
background: var(--color-primary);
border-color: var(--color-primary);
color: #fff;
&:hover {
background: var(--color-primary-hover);
border-color: var(--color-primary-hover);
}
}
}

View file

@ -25,9 +25,14 @@ const SIDEBAR_COLLAPSED_KEY = "batchcooking:sidebarCollapsed";
* one locale key, no other file to touch. * one locale key, no other file to touch.
*/ */
const NAV_ITEMS = [ const NAV_ITEMS = [
{ to: "/", key: "planning", Icon: PlanningIcon }, { to: "/", key: "planning", Icon: PlanningIcon, tourTarget: "nav-planning" },
{ to: "/recettes", key: "recipes", Icon: RecipesIcon }, { to: "/recettes", key: "recipes", Icon: RecipesIcon, tourTarget: "nav-recipes" },
{ to: "/liste-de-courses", key: "shoppingList", Icon: ShoppingListIcon }, {
to: "/liste-de-courses",
key: "shoppingList",
Icon: ShoppingListIcon,
tourTarget: "nav-shopping-list",
},
] as const; ] as const;
/** /**
@ -92,7 +97,7 @@ export function AppLayout() {
</div> </div>
<nav className="app-sidebar__nav"> <nav className="app-sidebar__nav">
{NAV_ITEMS.map(({ to, key, Icon }) => ( {NAV_ITEMS.map(({ to, key, Icon, tourTarget }) => (
<NavLink <NavLink
key={to} key={to}
to={to} to={to}
@ -103,6 +108,7 @@ export function AppLayout() {
end={to === "/"} end={to === "/"}
className={({ isActive }) => (isActive ? "active" : undefined)} className={({ isActive }) => (isActive ? "active" : undefined)}
title={t(`layout.nav.${key}`)} title={t(`layout.nav.${key}`)}
data-tour={tourTarget}
> >
<Icon aria-hidden="true" /> <Icon aria-hidden="true" />
<span className="label">{t(`layout.nav.${key}`)}</span> <span className="label">{t(`layout.nav.${key}`)}</span>

View file

@ -1360,5 +1360,16 @@
"title": "Prêt à arrêter d'improviser le dîner ?", "title": "Prêt à arrêter d'improviser le dîner ?",
"button": "Créer un compte" "button": "Créer un compte"
} }
},
"tour": {
"controls": {
"next": "Suivant",
"prev": "Précédent",
"done": "Terminé"
},
"replay": {
"title": "Revoir un tutoriel",
"button": "Revoir"
}
} }
} }

View file

@ -6,6 +6,7 @@ import { App } from "./App";
import { setTokenStore } from "./api/client"; import { setTokenStore } from "./api/client";
import { AuthProvider } from "./features/auth/AuthContext"; import { AuthProvider } from "./features/auth/AuthContext";
import { ThemeProvider } from "./features/theme/ThemeContext"; import { ThemeProvider } from "./features/theme/ThemeContext";
import { TourProvider } from "./features/tour/TourContext";
// Side-effect import: initializes the i18next instance before anything // Side-effect import: initializes the i18next instance before anything
// renders (react-i18next reads it via context under the hood). See i18n/i18n.ts. // renders (react-i18next reads it via context under the hood). See i18n/i18n.ts.
import "./i18n/i18n"; import "./i18n/i18n";
@ -46,7 +47,9 @@ async function bootstrap() {
<Router> <Router>
<AuthProvider> <AuthProvider>
<ThemeProvider> <ThemeProvider>
<App /> <TourProvider>
<App />
</TourProvider>
</ThemeProvider> </ThemeProvider>
</AuthProvider> </AuthProvider>
</Router> </Router>

View file

@ -21,15 +21,17 @@ import {
useSensor, useSensor,
useSensors, useSensors,
} from "@dnd-kit/core"; } from "@dnd-kit/core";
import { useEffect, useState } from "react"; import { useEffect, useRef, useState } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { useNavigate } from "react-router-dom"; import { useNavigate } from "react-router-dom";
import { ApiError, apiClient } from "../../api/client"; import { ApiError, apiClient } from "../../api/client";
import { useAuth } from "../../features/auth/AuthContext";
import { LeftoverAssignDialog } from "../../features/planning/LeftoverAssignDialog"; import { LeftoverAssignDialog } from "../../features/planning/LeftoverAssignDialog";
import { LeftoversBasket } from "../../features/planning/LeftoversBasket"; import { LeftoversBasket } from "../../features/planning/LeftoversBasket";
import type { LeftoverDragData } from "../../features/planning/leftovers-basket"; import type { LeftoverDragData } from "../../features/planning/leftovers-basket";
import { type PlanningSlot, RecipePickerDialog } from "../../features/planning/RecipePickerDialog"; import { type PlanningSlot, RecipePickerDialog } from "../../features/planning/RecipePickerDialog";
import { WeekNavigator } from "../../features/planning/WeekNavigator"; import { WeekNavigator } from "../../features/planning/WeekNavigator";
import { useTour } from "../../features/tour/TourContext";
import { errorMessageService } from "../../services/error-message.service"; import { errorMessageService } from "../../services/error-message.service";
import "./planning-page.scss"; import "./planning-page.scss";
@ -91,6 +93,20 @@ export function PlanningPage() {
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 // Mouse + touch rather than the unified PointerSensor: same coverage, but
// driven by plain mouse/touch events, which Cypress can script reliably // driven by plain mouse/touch events, which Cypress can script reliably
// (see `planning-leftovers.ts`). A small activation distance so a plain // (see `planning-leftovers.ts`). A small activation distance so a plain
@ -214,6 +230,7 @@ export function PlanningPage() {
<button <button
type="button" type="button"
className="planning-page__cook-btn" className="planning-page__cook-btn"
data-tour="planning-cook-button"
disabled={!hasPlannedRecipes} disabled={!hasPlannedRecipes}
onClick={() => navigate(`/cuisiner?date=${formatDateOnly(weekStart)}`)} onClick={() => navigate(`/cuisiner?date=${formatDateOnly(weekStart)}`)}
> >
@ -280,7 +297,7 @@ function PlanningGrid({
return ( return (
<div className="planning-grid-wrapper"> <div className="planning-grid-wrapper">
<table className="planning-grid"> <table className="planning-grid" data-tour="planning-grid">
<thead> <thead>
<tr> <tr>
<th /> <th />
@ -293,10 +310,10 @@ function PlanningGrid({
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{MEALS.map((meal) => ( {MEALS.map((meal, mealIndex) => (
<tr key={meal} className={BAND_END_MEALS.has(meal) ? "band-end" : undefined}> <tr key={meal} className={BAND_END_MEALS.has(meal) ? "band-end" : undefined}>
<th>{t(`planning.meals.${meal}`)}</th> <th>{t(`planning.meals.${meal}`)}</th>
{days.map(({ weekDay, date }) => ( {days.map(({ weekDay, date }, dayIndex) => (
<MealCell <MealCell
key={weekDay} key={weekDay}
isToday={date.hasSame(today, "day")} isToday={date.hasSame(today, "day")}
@ -306,6 +323,9 @@ function PlanningGrid({
items={items.filter((item) => item.weekDay === weekDay && item.meal === meal)} items={items.filter((item) => item.weekDay === weekDay && item.meal === meal)}
onAdd={() => onAddSlot({ date: formatDateOnly(date), weekDay, meal })} onAdd={() => onAddSlot({ date: formatDateOnly(date), weekDay, meal })}
onRemove={onRemoveItem} 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> </tr>
@ -325,6 +345,7 @@ function MealCell({
items, items,
onAdd, onAdd,
onRemove, onRemove,
isTourAddTarget = false,
}: { }: {
isToday: boolean; isToday: boolean;
date: string; date: string;
@ -333,6 +354,7 @@ function MealCell({
items: PlanningItemView[]; items: PlanningItemView[];
onAdd: () => void; onAdd: () => void;
onRemove: (item: PlanningItemView) => void; onRemove: (item: PlanningItemView) => void;
isTourAddTarget?: boolean;
}) { }) {
const { t } = useTranslation(); const { t } = useTranslation();
const { setNodeRef, isOver } = useDroppable({ const { setNodeRef, isOver } = useDroppable({
@ -384,6 +406,7 @@ function MealCell({
className="add-recipe-btn" className="add-recipe-btn"
title={t("planning.grid.addRecipe")} title={t("planning.grid.addRecipe")}
onClick={onAdd} onClick={onAdd}
data-tour={isTourAddTarget ? "planning-add-recipe" : undefined}
> >
+ +
</button> </button>

View file

@ -4,6 +4,7 @@ import { useTranslation } from "react-i18next";
import { ApiError } from "../../api/client"; import { ApiError } from "../../api/client";
import { RadioOption } from "../../components/ui/Radio"; import { RadioOption } from "../../components/ui/Radio";
import { useTheme } from "../../features/theme/ThemeContext"; import { useTheme } from "../../features/theme/ThemeContext";
import { useTour } from "../../features/tour/TourContext";
import { errorMessageService } from "../../services/error-message.service"; import { errorMessageService } from "../../services/error-message.service";
import "./settings-pages.scss"; import "./settings-pages.scss";
@ -65,6 +66,39 @@ export function UserPreferencesPage() {
{saveState === "saved" && <p className="settings-page__saved">{t("common.saved")}</p>} {saveState === "saved" && <p className="settings-page__saved">{t("common.saved")}</p>}
{saveState === "error" && <p className="field-error">{saveError}</p>} {saveState === "error" && <p className="field-error">{saveError}</p>}
</div> </div>
<TourReplaySection />
</div>
);
}
/**
* "Revoir un tutoriel" — one row per known guided-tour scenario, each with
* a button that replays it on demand regardless of whether it's already
* been seen (issue #42). A plain `map` over the registry, so a 3rd tour
* added later shows up here with no change.
*/
function TourReplaySection() {
const { t } = useTranslation();
const { tours, startTour } = useTour();
if (tours.length === 0) {
return null;
}
return (
<div className="settings-page__section">
<h2>{t("tour.replay.title")}</h2>
<ul className="tour-replay-list">
{tours.map((tour) => (
<li key={tour.key} className="tour-replay-list__item">
<span>{tour.name}</span>
<button type="button" onClick={() => startTour(tour.key)}>
{t("tour.replay.button")}
</button>
</li>
))}
</ul>
</div> </div>
); );
} }

View file

@ -175,3 +175,40 @@ button.settings-page__link-button {
font-size: var(--font-size-sm); font-size: var(--font-size-sm);
} }
} }
// "Revoir un tutoriel" (UserPreferencesPage) — one row per guided-tour
// scenario, name on the left, a replay button on the right.
.tour-replay-list {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: var(--space-sm);
&__item {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--space-md);
font-size: var(--font-size-sm);
button {
flex-shrink: 0;
padding: var(--space-xs) var(--space-md);
border-radius: var(--radius-base);
border: 1px solid var(--color-border);
background: var(--color-surface);
color: var(--color-text);
font-family: var(--font-body);
font-size: var(--font-size-sm);
font-weight: 600;
cursor: pointer;
&:hover {
border-color: var(--color-primary);
color: var(--color-primary);
}
}
}
}

View file

@ -52,7 +52,7 @@ export function ShoppingListPage() {
}, [weekStart]); }, [weekStart]);
return ( return (
<div className="shopping-list-page"> <div className="shopping-list-page" data-tour="shopping-list-summary">
<div className="shopping-list-page__header"> <div className="shopping-list-page__header">
<h1>{t("shoppingList.title")}</h1> <h1>{t("shoppingList.title")}</h1>
<WeekNavigator weekStart={weekStart} onChangeWeek={setWeekStart} /> <WeekNavigator weekStart={weekStart} onChangeWeek={setWeekStart} />

View file

@ -117,6 +117,9 @@ importers:
'@dnd-kit/utilities': '@dnd-kit/utilities':
specifier: ^3.2.2 specifier: ^3.2.2
version: 3.2.2(react@18.3.1) version: 3.2.2(react@18.3.1)
driver.js:
specifier: ^1.8.0
version: 1.8.0
i18next: i18next:
specifier: ^26.3.6 specifier: ^26.3.6
version: 26.3.6(typescript@5.9.3) version: 26.3.6(typescript@5.9.3)
@ -2121,6 +2124,9 @@ packages:
resolution: {integrity: sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow==, tarball: https://registry.npmjs.org/dotenv/-/dotenv-16.6.1.tgz} resolution: {integrity: sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow==, tarball: https://registry.npmjs.org/dotenv/-/dotenv-16.6.1.tgz}
engines: {node: '>=12'} engines: {node: '>=12'}
driver.js@1.8.0:
resolution: {integrity: sha512-+8/IO7h1v14IzWh2GP60N7T3PFZweXwdn5e5POuxRSBoCYUojsBxzqawPeXh3YZIibRy7EehYNEyxe7slwwtdg==, tarball: https://registry.npmjs.org/driver.js/-/driver.js-1.8.0.tgz}
dunder-proto@1.0.1: dunder-proto@1.0.1:
resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==, tarball: https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz} resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==, tarball: https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz}
engines: {node: '>= 0.4'} engines: {node: '>= 0.4'}
@ -6109,6 +6115,8 @@ snapshots:
dotenv@16.6.1: {} dotenv@16.6.1: {}
driver.js@1.8.0: {}
dunder-proto@1.0.1: dunder-proto@1.0.1:
dependencies: dependencies:
call-bind-apply-helpers: 1.0.2 call-bind-apply-helpers: 1.0.2