feat(web): moteur de tutoriel guidé driver.js (#42) #60
17 changed files with 607 additions and 12 deletions
39
apps/web/cypress/e2e/tour.feature
Normal file
39
apps/web/cypress/e2e/tour.feature
Normal 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"
|
||||
79
apps/web/cypress/e2e/tour.ts
Normal file
79
apps/web/cypress/e2e/tour.ts
Normal 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();
|
||||
});
|
||||
|
|
@ -22,6 +22,7 @@
|
|||
"@capacitor/preferences": "^8.0.1",
|
||||
"@dnd-kit/core": "^6.3.1",
|
||||
"@dnd-kit/utilities": "^3.2.2",
|
||||
"driver.js": "^1.8.0",
|
||||
"i18next": "^26.3.6",
|
||||
"lucide-react": "^1.32.0",
|
||||
"react": "^18.3.1",
|
||||
|
|
|
|||
|
|
@ -28,6 +28,7 @@ import {
|
|||
type SubmitTechStepCorrectionResult,
|
||||
type TechStepView,
|
||||
type ThemePreference,
|
||||
type TourScenarioView,
|
||||
type UnitView,
|
||||
type UpdateRecipeInput,
|
||||
type UtensilView,
|
||||
|
|
@ -477,6 +478,27 @@ export class ApiClient {
|
|||
public updatePreferences(theme: ThemePreference): Promise<PreferencesView> {
|
||||
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. */
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ import type {
|
|||
import { Fragment, useEffect, useRef, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Tooltip } from "../../../components/ui/Tooltip";
|
||||
import { useTour } from "../../tour/TourContext";
|
||||
import { splitDescriptionSegments } from "./highlight-tech-steps";
|
||||
import { TechStepCorrectionPopover } from "./TechStepCorrectionPopover";
|
||||
import { temperatureLabel } from "./temperature-label";
|
||||
|
|
@ -119,6 +120,16 @@ export function StepDescription({
|
|||
previousTechStepId: number | 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`
|
||||
// (as an ingredient/utensil/temperature mention span) instead of opening
|
||||
// a brand-new correction — set when that popover calls `onRequestSpan`, cleared once
|
||||
|
|
@ -192,7 +203,11 @@ export function StepDescription({
|
|||
|
||||
return (
|
||||
<>
|
||||
<p ref={containerRef} onMouseUp={handleMouseUp}>
|
||||
<p
|
||||
ref={containerRef}
|
||||
onMouseUp={handleMouseUp}
|
||||
data-tour={editable ? "techstep-description" : undefined}
|
||||
>
|
||||
{segments.map((segment, index) => {
|
||||
const start = offset;
|
||||
offset += segment.text.length;
|
||||
|
|
|
|||
|
|
@ -631,7 +631,10 @@ export function TechStepCorrectionPopover({
|
|||
<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
|
||||
icon={<TagIcon />}
|
||||
title={t("recipes.techStepCorrection.techniqueSection")}
|
||||
|
|
|
|||
129
apps/web/src/features/tour/TourContext.tsx
Normal file
129
apps/web/src/features/tour/TourContext.tsx
Normal 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;
|
||||
}
|
||||
112
apps/web/src/features/tour/tour-engine.ts
Normal file
112
apps/web/src/features/tour/tour-engine.ts
Normal 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();
|
||||
}
|
||||
73
apps/web/src/features/tour/tour.scss
Normal file
73
apps/web/src/features/tour/tour.scss
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -25,9 +25,14 @@ const SIDEBAR_COLLAPSED_KEY = "batchcooking:sidebarCollapsed";
|
|||
* one locale key, no other file to touch.
|
||||
*/
|
||||
const NAV_ITEMS = [
|
||||
{ to: "/", key: "planning", Icon: PlanningIcon },
|
||||
{ to: "/recettes", key: "recipes", Icon: RecipesIcon },
|
||||
{ to: "/liste-de-courses", key: "shoppingList", Icon: ShoppingListIcon },
|
||||
{ to: "/", key: "planning", Icon: PlanningIcon, tourTarget: "nav-planning" },
|
||||
{ to: "/recettes", key: "recipes", Icon: RecipesIcon, tourTarget: "nav-recipes" },
|
||||
{
|
||||
to: "/liste-de-courses",
|
||||
key: "shoppingList",
|
||||
Icon: ShoppingListIcon,
|
||||
tourTarget: "nav-shopping-list",
|
||||
},
|
||||
] as const;
|
||||
|
||||
/**
|
||||
|
|
@ -92,7 +97,7 @@ export function AppLayout() {
|
|||
</div>
|
||||
|
||||
<nav className="app-sidebar__nav">
|
||||
{NAV_ITEMS.map(({ to, key, Icon }) => (
|
||||
{NAV_ITEMS.map(({ to, key, Icon, tourTarget }) => (
|
||||
<NavLink
|
||||
key={to}
|
||||
to={to}
|
||||
|
|
@ -103,6 +108,7 @@ export function AppLayout() {
|
|||
end={to === "/"}
|
||||
className={({ isActive }) => (isActive ? "active" : undefined)}
|
||||
title={t(`layout.nav.${key}`)}
|
||||
data-tour={tourTarget}
|
||||
>
|
||||
<Icon aria-hidden="true" />
|
||||
<span className="label">{t(`layout.nav.${key}`)}</span>
|
||||
|
|
|
|||
|
|
@ -1360,5 +1360,16 @@
|
|||
"title": "Prêt à arrêter d'improviser le dîner ?",
|
||||
"button": "Créer un compte"
|
||||
}
|
||||
},
|
||||
"tour": {
|
||||
"controls": {
|
||||
"next": "Suivant",
|
||||
"prev": "Précédent",
|
||||
"done": "Terminé"
|
||||
},
|
||||
"replay": {
|
||||
"title": "Revoir un tutoriel",
|
||||
"button": "Revoir"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ import { App } from "./App";
|
|||
import { setTokenStore } from "./api/client";
|
||||
import { AuthProvider } from "./features/auth/AuthContext";
|
||||
import { ThemeProvider } from "./features/theme/ThemeContext";
|
||||
import { TourProvider } from "./features/tour/TourContext";
|
||||
// Side-effect import: initializes the i18next instance before anything
|
||||
// renders (react-i18next reads it via context under the hood). See i18n/i18n.ts.
|
||||
import "./i18n/i18n";
|
||||
|
|
@ -46,7 +47,9 @@ async function bootstrap() {
|
|||
<Router>
|
||||
<AuthProvider>
|
||||
<ThemeProvider>
|
||||
<App />
|
||||
<TourProvider>
|
||||
<App />
|
||||
</TourProvider>
|
||||
</ThemeProvider>
|
||||
</AuthProvider>
|
||||
</Router>
|
||||
|
|
|
|||
|
|
@ -21,15 +21,17 @@ import {
|
|||
useSensor,
|
||||
useSensors,
|
||||
} from "@dnd-kit/core";
|
||||
import { useEffect, useState } from "react";
|
||||
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";
|
||||
|
||||
|
|
@ -91,6 +93,20 @@ export function PlanningPage() {
|
|||
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
|
||||
|
|
@ -214,6 +230,7 @@ export function PlanningPage() {
|
|||
<button
|
||||
type="button"
|
||||
className="planning-page__cook-btn"
|
||||
data-tour="planning-cook-button"
|
||||
disabled={!hasPlannedRecipes}
|
||||
onClick={() => navigate(`/cuisiner?date=${formatDateOnly(weekStart)}`)}
|
||||
>
|
||||
|
|
@ -280,7 +297,7 @@ function PlanningGrid({
|
|||
|
||||
return (
|
||||
<div className="planning-grid-wrapper">
|
||||
<table className="planning-grid">
|
||||
<table className="planning-grid" data-tour="planning-grid">
|
||||
<thead>
|
||||
<tr>
|
||||
<th />
|
||||
|
|
@ -293,10 +310,10 @@ function PlanningGrid({
|
|||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{MEALS.map((meal) => (
|
||||
{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 }) => (
|
||||
{days.map(({ weekDay, date }, dayIndex) => (
|
||||
<MealCell
|
||||
key={weekDay}
|
||||
isToday={date.hasSame(today, "day")}
|
||||
|
|
@ -306,6 +323,9 @@ function PlanningGrid({
|
|||
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>
|
||||
|
|
@ -325,6 +345,7 @@ function MealCell({
|
|||
items,
|
||||
onAdd,
|
||||
onRemove,
|
||||
isTourAddTarget = false,
|
||||
}: {
|
||||
isToday: boolean;
|
||||
date: string;
|
||||
|
|
@ -333,6 +354,7 @@ function MealCell({
|
|||
items: PlanningItemView[];
|
||||
onAdd: () => void;
|
||||
onRemove: (item: PlanningItemView) => void;
|
||||
isTourAddTarget?: boolean;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const { setNodeRef, isOver } = useDroppable({
|
||||
|
|
@ -384,6 +406,7 @@ function MealCell({
|
|||
className="add-recipe-btn"
|
||||
title={t("planning.grid.addRecipe")}
|
||||
onClick={onAdd}
|
||||
data-tour={isTourAddTarget ? "planning-add-recipe" : undefined}
|
||||
>
|
||||
+
|
||||
</button>
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import { useTranslation } from "react-i18next";
|
|||
import { ApiError } from "../../api/client";
|
||||
import { RadioOption } from "../../components/ui/Radio";
|
||||
import { useTheme } from "../../features/theme/ThemeContext";
|
||||
import { useTour } from "../../features/tour/TourContext";
|
||||
import { errorMessageService } from "../../services/error-message.service";
|
||||
import "./settings-pages.scss";
|
||||
|
||||
|
|
@ -65,6 +66,39 @@ export function UserPreferencesPage() {
|
|||
{saveState === "saved" && <p className="settings-page__saved">{t("common.saved")}</p>}
|
||||
{saveState === "error" && <p className="field-error">{saveError}</p>}
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -175,3 +175,40 @@ button.settings-page__link-button {
|
|||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -52,7 +52,7 @@ export function ShoppingListPage() {
|
|||
}, [weekStart]);
|
||||
|
||||
return (
|
||||
<div className="shopping-list-page">
|
||||
<div className="shopping-list-page" data-tour="shopping-list-summary">
|
||||
<div className="shopping-list-page__header">
|
||||
<h1>{t("shoppingList.title")}</h1>
|
||||
<WeekNavigator weekStart={weekStart} onChangeWeek={setWeekStart} />
|
||||
|
|
|
|||
|
|
@ -117,6 +117,9 @@ importers:
|
|||
'@dnd-kit/utilities':
|
||||
specifier: ^3.2.2
|
||||
version: 3.2.2(react@18.3.1)
|
||||
driver.js:
|
||||
specifier: ^1.8.0
|
||||
version: 1.8.0
|
||||
i18next:
|
||||
specifier: ^26.3.6
|
||||
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}
|
||||
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:
|
||||
resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==, tarball: https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz}
|
||||
engines: {node: '>= 0.4'}
|
||||
|
|
@ -6109,6 +6115,8 @@ snapshots:
|
|||
|
||||
dotenv@16.6.1: {}
|
||||
|
||||
driver.js@1.8.0: {}
|
||||
|
||||
dunder-proto@1.0.1:
|
||||
dependencies:
|
||||
call-bind-apply-helpers: 1.0.2
|
||||
|
|
|
|||
Loading…
Reference in a new issue