"use client"; import { createContext, useCallback, useContext, useEffect, useRef, useState } from "react"; import { createRoot, Root } from "react-dom/client"; import Shepherd from "shepherd.js"; import "shepherd.js/dist/css/shepherd.css"; import { OnboardingCard } from "../components/OnboardingCard"; import { DEFAULT_ONBOARDING_LABELS, OnboardingContextValue, OnboardingProviderProps, OnboardingStepConfig, } from "../interfaces"; import "../styles/onboarding.css"; const OnboardingContext = createContext(null); export function OnboardingProvider({ children, tours = [], tourPaths: _tourPaths = {}, labels = DEFAULT_ONBOARDING_LABELS, renderCard, zIndex: _zIndex = 9999, }: OnboardingProviderProps) { const [isTourActive, setIsTourActive] = useState(false); const [activeTourId, setActiveTourId] = useState(null); const [currentStepIndex, setCurrentStepIndex] = useState(0); const [totalSteps, setTotalSteps] = useState(0); const tourRef = useRef | null>(null); const rootsRef = useRef>(new Map()); const cleanupRoots = useCallback(() => { rootsRef.current.forEach((root) => { try { root.unmount(); } catch (_e) { // Root may already be unmounted } }); rootsRef.current.clear(); }, []); const closeTour = useCallback(() => { if (tourRef.current) { tourRef.current.cancel(); tourRef.current = null; } cleanupRoots(); setIsTourActive(false); setActiveTourId(null); setCurrentStepIndex(0); setTotalSteps(0); }, [cleanupRoots]); const nextStep = useCallback(() => { if (tourRef.current) { tourRef.current.next(); } }, []); const previousStep = useCallback(() => { if (tourRef.current) { tourRef.current.back(); } }, []); const goToStep = useCallback((index: number) => { if (tourRef.current) { tourRef.current.show(index); } }, []); const startTour = useCallback( (tourId: string, steps?: OnboardingStepConfig[]) => { if (typeof window === "undefined") return; // Close any existing tour if (tourRef.current) { closeTour(); } // Get steps from provided steps or find tour by ID let tourSteps = steps; if (!tourSteps) { const tour = tours.find((t) => t.id === tourId); tourSteps = tour?.steps; } if (!tourSteps || tourSteps.length === 0) { console.warn(`No steps found for tour: ${tourId}`); return; } setTotalSteps(tourSteps.length); setActiveTourId(tourId); const tour = new Shepherd.Tour({ useModalOverlay: true, defaultStepOptions: { classes: "shepherd-theme-custom", scrollTo: { behavior: "smooth", block: "center" }, cancelIcon: { enabled: false }, }, }); // Set up tour event listeners tour.on("show", () => setIsTourActive(true)); tour.on("cancel", () => { cleanupRoots(); setIsTourActive(false); setActiveTourId(null); setCurrentStepIndex(0); setTotalSteps(0); tourRef.current = null; }); tour.on("complete", () => { cleanupRoots(); setIsTourActive(false); setActiveTourId(null); setCurrentStepIndex(0); setTotalSteps(0); tourRef.current = null; }); tourSteps.forEach((stepConfig, index) => { const stepId = stepConfig.id || `step-${index}`; tour.addStep({ id: stepId, attachTo: stepConfig.selector ? { element: stepConfig.selector, on: stepConfig.side || "bottom" } : undefined, arrow: true, // Use text callback to return DOM element with React content text: () => { const container = document.createElement("div"); const root = createRoot(container); rootsRef.current.set(stepId, root); const cardProps = { step: stepConfig, currentIndex: index, totalSteps: tourSteps!.length, labels, onNext: () => tour.next(), onPrevious: () => tour.back(), onClose: () => tour.cancel(), onSkip: () => tour.cancel(), isFirst: index === 0, isLast: index === tourSteps!.length - 1, }; root.render(renderCard ? renderCard(cardProps) : ); return container; }, buttons: [], // Empty - our card handles navigation beforeShowPromise: stepConfig.showDelay ? () => new Promise((resolve) => setTimeout(resolve, stepConfig.showDelay)) : undefined, canClickTarget: stepConfig.canClickTarget ?? false, modalOverlayOpeningPadding: 8, modalOverlayOpeningRadius: 8, when: { show: () => { setCurrentStepIndex(index); stepConfig.onShow?.(); }, hide: () => { stepConfig.onHide?.(); }, }, }); }); tourRef.current = tour; tour.start(); }, [tours, labels, renderCard, closeTour, cleanupRoots], ); // Cleanup on unmount useEffect(() => { return () => { if (tourRef.current) { tourRef.current.cancel(); } cleanupRoots(); }; }, [cleanupRoots]); const value: OnboardingContextValue = { startTour, closeTour, nextStep, previousStep, goToStep, isTourActive, activeTourId, currentStepIndex, totalSteps, }; return {children}; } export function useOnboarding(): OnboardingContextValue { const context = useContext(OnboardingContext); if (!context) { throw new Error("useOnboarding must be used within an OnboardingProvider"); } return context; }