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>
129 lines
5 KiB
TypeScript
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;
|
|
}
|