diff --git a/apps/web/cypress/e2e/tour.feature b/apps/web/cypress/e2e/tour.feature new file mode 100644 index 0000000..4fd1142 --- /dev/null +++ b/apps/web/cypress/e2e/tour.feature @@ -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" diff --git a/apps/web/cypress/e2e/tour.ts b/apps/web/cypress/e2e/tour.ts new file mode 100644 index 0000000..14cc472 --- /dev/null +++ b/apps/web/cypress/e2e/tour.ts @@ -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(); +}); diff --git a/apps/web/package.json b/apps/web/package.json index 5891828..d84e5a4 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -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", diff --git a/apps/web/src/api/client.ts b/apps/web/src/api/client.ts index d2bdb65..6126374 100644 --- a/apps/web/src/api/client.ts +++ b/apps/web/src/api/client.ts @@ -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 { 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 { + 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 { + 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 { + 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. */ diff --git a/apps/web/src/features/recipes/steps/StepDescription.tsx b/apps/web/src/features/recipes/steps/StepDescription.tsx index 05577b0..5fb0e4a 100644 --- a/apps/web/src/features/recipes/steps/StepDescription.tsx +++ b/apps/web/src/features/recipes/steps/StepDescription.tsx @@ -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 ( <> -

+

{segments.map((segment, index) => { const start = offset; offset += segment.text.length; diff --git a/apps/web/src/features/recipes/steps/TechStepCorrectionPopover.tsx b/apps/web/src/features/recipes/steps/TechStepCorrectionPopover.tsx index 06be3b9..c0d07f9 100644 --- a/apps/web/src/features/recipes/steps/TechStepCorrectionPopover.tsx +++ b/apps/web/src/features/recipes/steps/TechStepCorrectionPopover.tsx @@ -631,7 +631,10 @@ export function TechStepCorrectionPopover({

{t("recipes.loading")}

) : ( <> -
+
} title={t("recipes.techStepCorrection.techniqueSection")} diff --git a/apps/web/src/features/tour/TourContext.tsx b/apps/web/src/features/tour/TourContext.tsx new file mode 100644 index 0000000..06d6ba8 --- /dev/null +++ b/apps/web/src/features/tour/TourContext.tsx @@ -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(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([]); + const [completedKeys, setCompletedKeys] = useState>(new Set()); + // The user id the current `tours`/`completedKeys` were loaded for + // (`null` = loaded logged-out; `undefined` = nothing loaded yet). + const [loadedForUserId, setLoadedForUserId] = useState(undefined); + const [runningKey, setRunningKey] = useState(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([]); + + 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 ( + + {children} + + ); +} + +/** 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; +} diff --git a/apps/web/src/features/tour/tour-engine.ts b/apps/web/src/features/tour/tour-engine.ts new file mode 100644 index 0000000..ac7b40d --- /dev/null +++ b/apps/web/src/features/tour/tour-engine.ts @@ -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(); +} diff --git a/apps/web/src/features/tour/tour.scss b/apps/web/src/features/tour/tour.scss new file mode 100644 index 0000000..0cb88e7 --- /dev/null +++ b/apps/web/src/features/tour/tour.scss @@ -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); + } + } +} diff --git a/apps/web/src/layouts/AppLayout.tsx b/apps/web/src/layouts/AppLayout.tsx index e2b0a1e..029b612 100644 --- a/apps/web/src/layouts/AppLayout.tsx +++ b/apps/web/src/layouts/AppLayout.tsx @@ -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() {