batchCooking/apps/web/src/features/tour/TourContext.tsx
Nicolas 38fcf03417
All checks were successful
CI / lint (push) Successful in 3m55s
CI / build (push) Successful in 4m53s
CI / intent-service-test (push) Successful in 5m52s
CI / e2e (push) Successful in 10m30s
CI / test (push) Successful in 18m43s
feat(web): moteur de tutoriel guidé driver.js (#42)
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>
2026-09-06 06:17:06 +02:00

129 lines
5 KiB
TypeScript

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;
}