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