diff --git a/apps/apollo-vertex/registry.json b/apps/apollo-vertex/registry.json
index 70cf4bb67..c0c5d0cf8 100644
--- a/apps/apollo-vertex/registry.json
+++ b/apps/apollo-vertex/registry.json
@@ -2093,6 +2093,16 @@
"path": "registry/onboarding-tour-joyride/onboarding-tour-joyride-popover.tsx",
"type": "registry:ui",
"target": "components/ui/onboarding-tour-joyride/onboarding-tour-joyride-popover.tsx"
+ },
+ {
+ "path": "registry/onboarding-tour-joyride/onboarding-tour-joyride-welcome-modal.tsx",
+ "type": "registry:ui",
+ "target": "components/ui/onboarding-tour-joyride/onboarding-tour-joyride-welcome-modal.tsx"
+ },
+ {
+ "path": "registry/onboarding-tour-joyride/onboarding-tour-joyride-provider.tsx",
+ "type": "registry:ui",
+ "target": "components/ui/onboarding-tour-joyride/onboarding-tour-joyride-provider.tsx"
}
]
},
diff --git a/apps/apollo-vertex/registry/onboarding-tour-joyride/index.ts b/apps/apollo-vertex/registry/onboarding-tour-joyride/index.ts
index a4cb5ed22..42e279b61 100644
--- a/apps/apollo-vertex/registry/onboarding-tour-joyride/index.ts
+++ b/apps/apollo-vertex/registry/onboarding-tour-joyride/index.ts
@@ -8,11 +8,29 @@ export {
OnboardingTourJoyridePopover,
OnboardingTourJoyridePopoverCard,
} from "./onboarding-tour-joyride-popover";
+// Context and hooks
+export type {
+ OnboardingTourContextValue,
+ OnboardingTourProviderProps,
+} from "./onboarding-tour-joyride-provider";
+export {
+ OnboardingTourProvider,
+ useOnboardingTour,
+} from "./onboarding-tour-joyride-provider";
// Types
export type {
TourDefinition,
TourStep,
} from "./onboarding-tour-joyride-types";
+// Welcome modal
+export type {
+ OnboardingTourJoyrideWelcomeModalCardProps,
+ OnboardingTourJoyrideWelcomeModalProps,
+} from "./onboarding-tour-joyride-welcome-modal";
+export {
+ OnboardingTourJoyrideWelcomeModal,
+ OnboardingTourJoyrideWelcomeModalCard,
+} from "./onboarding-tour-joyride-welcome-modal";
// Persistence
export {
isTourCompleted,
diff --git a/apps/apollo-vertex/registry/onboarding-tour-joyride/onboarding-tour-joyride-popover.tsx b/apps/apollo-vertex/registry/onboarding-tour-joyride/onboarding-tour-joyride-popover.tsx
index a9ce0b86f..1dbae1e2f 100644
--- a/apps/apollo-vertex/registry/onboarding-tour-joyride/onboarding-tour-joyride-popover.tsx
+++ b/apps/apollo-vertex/registry/onboarding-tour-joyride/onboarding-tour-joyride-popover.tsx
@@ -6,6 +6,7 @@ import { useId } from "react";
import type { TooltipRenderProps } from "react-joyride";
import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils";
+import { AiGlow } from "@/registry/ai-glow/ai-glow";
interface OnboardingTourJoyridePopoverData {
/** Step title */
@@ -86,14 +87,7 @@ function OnboardingTourJoyridePopoverCard({
{...rootProps}
className={cn("relative", className)}
>
- {/* Gradient glow background */}
-
+
{
+ /** Start a tour by its ID */
+ startTour: (tourId: string) => void;
+ /** Set a condition value for gating steps */
+ setCondition: (key: TCondition, value: boolean) => void;
+}
+
+const OnboardingTourContext = createContext
(
+ null,
+);
+
+interface OnboardingTourProviderProps {
+ children: ReactNode;
+ /** Tour definitions available to this provider */
+ tours: TourDefinition[];
+ /** Initial conditions */
+ initialConditions?: Partial>;
+}
+
+interface JoyrideStepWithData extends Step {
+ data: OnboardingTourJoyridePopoverData & { stepId: string };
+}
+
+function isDarkMode(): boolean {
+ if (typeof document === "undefined") return false;
+ return document.documentElement.classList.contains("dark");
+}
+
+function OnboardingTourProvider({
+ children,
+ tours,
+ initialConditions,
+}: OnboardingTourProviderProps) {
+ const [activeTour, setActiveTour] =
+ useState | null>(null);
+ const [stepIndex, setStepIndex] = useState(0);
+ const [isRunning, setIsRunning] = useState(false);
+ const [pendingAdvance, setPendingAdvance] = useState(false);
+ const [conditions, setConditions] = useState>(() => {
+ const result: Record = {};
+ if (!initialConditions) return result;
+ for (const [k, v] of Object.entries(initialConditions)) {
+ if (typeof v === "boolean") result[k] = v;
+ }
+ return result;
+ });
+
+ const currentStep: TourStep | null = activeTour
+ ? (activeTour.steps[stepIndex] ?? null)
+ : null;
+ const isModalStep = currentStep?.type === "modal";
+
+ // Build Joyride's step list from the active tour. Modal-type steps are
+ // placeholders so indexes align with activeTour.steps — Joyride runs are
+ // paused while we render the modal ourselves.
+ const joyrideSteps: JoyrideStepWithData[] = activeTour
+ ? buildJoyrideSteps(activeTour)
+ : [];
+
+ // Fire onEnter for the active step
+ useEffect(() => {
+ if (!isRunning || !currentStep) return;
+ currentStep.onEnter?.();
+ }, [isRunning, currentStep]);
+
+ // Resume advance when a pending waitFor condition is satisfied
+ useEffect(() => {
+ if (!pendingAdvance || !activeTour) return;
+ const nextStep = activeTour.steps[stepIndex + 1];
+ if (!nextStep) return;
+ if (nextStep.waitFor && !conditions[nextStep.waitFor as string]) return;
+ setPendingAdvance(false);
+ setStepIndex(stepIndex + 1);
+ }, [pendingAdvance, conditions, activeTour, stepIndex]);
+
+ function stopTour(markCompleted: boolean) {
+ if (activeTour && markCompleted) {
+ markTourCompleted(activeTour.id);
+ }
+ setIsRunning(false);
+ setActiveTour(null);
+ setStepIndex(0);
+ setPendingAdvance(false);
+ }
+
+ function advance() {
+ if (!activeTour) return;
+ const isLast = stepIndex >= activeTour.steps.length - 1;
+ if (isLast) {
+ stopTour(true);
+ return;
+ }
+ const nextStep = activeTour.steps[stepIndex + 1];
+ if (nextStep?.waitFor && !conditions[nextStep.waitFor as string]) {
+ setPendingAdvance(true);
+ return;
+ }
+ setStepIndex(stepIndex + 1);
+ }
+
+ function goBack() {
+ setStepIndex((prev) => Math.max(0, prev - 1));
+ }
+
+ function handleJoyrideEvent(data: EventData, _controls: Controls) {
+ const { action, status, type } = data;
+
+ // Tour ended — respect skip/finish
+ if (status === STATUS.FINISHED || status === STATUS.SKIPPED) {
+ stopTour(true);
+ return;
+ }
+
+ if (action === ACTIONS.CLOSE || action === ACTIONS.SKIP) {
+ stopTour(true);
+ return;
+ }
+
+ if (type === EVENTS.STEP_AFTER || type === EVENTS.TARGET_NOT_FOUND) {
+ if (action === ACTIONS.PREV) {
+ goBack();
+ } else if (action === ACTIONS.NEXT) {
+ advance();
+ }
+ }
+ }
+
+ function startTour(tourId: string) {
+ if (isTourCompleted(tourId)) return;
+ const tour = tours.find((t) => t.id === tourId);
+ if (!tour) return;
+ setActiveTour(tour);
+ setStepIndex(0);
+ setPendingAdvance(false);
+ setIsRunning(true);
+ }
+
+ function setCondition(key: string, value: boolean) {
+ setConditions((prev) => ({ ...prev, [key]: value }));
+ }
+
+ // The context stores the widest (string) variant; consumers narrow via
+ // `useOnboardingTour()` which re-types the value.
+ const value: OnboardingTourContextValue = {
+ startTour,
+ setCondition,
+ };
+
+ // Resolve a safe step index for Joyride: never a modal step.
+ const joyrideStepIndex = isModalStep ? 0 : stepIndex;
+ // Joyride should not run while a modal step is active, or when no tour.
+ const joyrideRun = isRunning && !isModalStep && activeTour !== null;
+
+ const [dark, setDark] = useState(false);
+ useEffect(() => {
+ setDark(isDarkMode());
+ const observer = new MutationObserver(() => setDark(isDarkMode()));
+ observer.observe(document.documentElement, {
+ attributes: true,
+ attributeFilter: ["class"],
+ });
+ return () => observer.disconnect();
+ }, []);
+
+ return (
+
+ {children}
+ {activeTour && (
+ null}
+ onEvent={handleJoyrideEvent}
+ options={{
+ primaryColor: "var(--color-primary)",
+ overlayColor: dark
+ ? "rgba(0, 0, 0, 0.8)"
+ : "rgba(255, 255, 255, 0.8)",
+ spotlightPadding: 8,
+ spotlightRadius: 16,
+ offset: 12,
+ dismissKeyAction: "close",
+ overlayClickAction: false,
+ disableFocusTrap: false,
+ zIndex: 9999,
+ }}
+ />
+ )}
+ {currentStep && isModalStep && (
+ stopTour(true)}
+ />
+ )}
+
+ );
+}
+
+function buildJoyrideSteps(
+ tour: TourDefinition,
+): JoyrideStepWithData[] {
+ const popoverSteps = tour.steps.filter((s) => s.type !== "modal");
+
+ return tour.steps.map((step) => {
+ if (step.type === "modal") {
+ // Placeholder — Joyride won't render this because `run` is false
+ // while we show the modal ourselves. Target body so Joyride doesn't
+ // complain about a missing selector.
+ return {
+ target: "body",
+ content: "",
+ placement: "center" as const,
+ data: {
+ stepId: step.id,
+ title: step.title,
+ body: step.body,
+ tip: step.tip,
+ currentStep: 0,
+ totalSteps: popoverSteps.length || 1,
+ showBack: false,
+ nextLabel: step.nextLabel,
+ },
+ };
+ }
+
+ const popoverIndex = popoverSteps.findIndex((s) => s.id === step.id);
+ const totalSteps = popoverSteps.length || 1;
+ // Find the previous step's type to decide whether to show "Back"
+ const absoluteIndex = tour.steps.findIndex((s) => s.id === step.id);
+ const prevStep = tour.steps[absoluteIndex - 1];
+ const showBack = absoluteIndex > 0 && prevStep?.type !== "modal";
+
+ return {
+ target: step.selector ?? "body",
+ content: "",
+ title: "",
+ placement: step.placement ?? "bottom",
+ skipBeacon: true,
+ data: {
+ stepId: step.id,
+ title: step.title,
+ body: step.body,
+ tip: step.tip,
+ currentStep: popoverIndex,
+ totalSteps,
+ showBack,
+ nextLabel: step.nextLabel,
+ },
+ };
+ });
+}
+
+function useOnboardingTour<
+ TCondition extends string = string,
+>(): OnboardingTourContextValue {
+ const context = useContext(OnboardingTourContext);
+ if (!context) {
+ throw new Error(
+ "useOnboardingTour must be used within an OnboardingTourProvider",
+ );
+ }
+ // Generic context pattern — base context stores string, consumer narrows via generic
+ return context as OnboardingTourContextValue;
+}
+
+export { OnboardingTourProvider, useOnboardingTour };
+export type { OnboardingTourContextValue, OnboardingTourProviderProps };
diff --git a/apps/apollo-vertex/registry/onboarding-tour-joyride/onboarding-tour-joyride-welcome-modal.tsx b/apps/apollo-vertex/registry/onboarding-tour-joyride/onboarding-tour-joyride-welcome-modal.tsx
new file mode 100644
index 000000000..18ddf770a
--- /dev/null
+++ b/apps/apollo-vertex/registry/onboarding-tour-joyride/onboarding-tour-joyride-welcome-modal.tsx
@@ -0,0 +1,209 @@
+"use client";
+
+import * as DialogPrimitive from "@radix-ui/react-dialog";
+import { X } from "lucide-react";
+import { type ReactNode, useId } from "react";
+import { Button } from "@/components/ui/button";
+import { cn } from "@/lib/utils";
+
+interface OnboardingTourJoyrideWelcomeModalCardProps {
+ /** Modal title */
+ title: string;
+ /** Primary body content */
+ body: ReactNode;
+ /** Secondary description paragraph */
+ description?: string;
+ /** Image URL to display above the content. */
+ image?: string;
+ /** Custom label for the CTA button */
+ nextLabel?: string;
+ /** CTA button handler */
+ onNext?: () => void;
+ /** Close/dismiss handler */
+ onClose?: () => void;
+ /** Optional className for the outer card */
+ className?: string;
+}
+
+/**
+ * The visual modal card — renderable standalone for previews or inside the
+ * Radix Dialog wrapper for runtime use.
+ */
+function OnboardingTourJoyrideWelcomeModalCard({
+ title,
+ body,
+ description,
+ image,
+ nextLabel = "Let's go",
+ onNext,
+ onClose,
+ className,
+}: OnboardingTourJoyrideWelcomeModalCardProps) {
+ const titleId = useId();
+ const descriptionId = useId();
+
+ return (
+
+ {/* Close button */}
+
+
+ {/* Image */}
+ {image && (
+
+ {/* biome-ignore lint/performance/noImgElement: registry component must work without Next.js */}
+

+
+ )}
+
+ {/* Content */}
+
+
+ {title}
+
+
+ {body}
+
+ {description && (
+
+ {description}
+
+ )}
+
+
+
+ );
+}
+
+interface OnboardingTourJoyrideWelcomeModalProps {
+ /** Whether the modal is open */
+ open: boolean;
+ /** Modal title */
+ title: string;
+ /** Primary body content */
+ body: ReactNode;
+ /** Secondary description paragraph */
+ description?: string;
+ /** Image URL to display above the content. */
+ image?: string;
+ /** Custom label for the CTA button */
+ nextLabel?: string;
+ /** CTA button handler */
+ onNext: () => void;
+ /** Close/dismiss handler */
+ onClose: () => void;
+}
+
+function OnboardingTourJoyrideWelcomeModal({
+ open,
+ title,
+ body,
+ description,
+ image,
+ nextLabel = "Let's go",
+ onNext,
+ onClose,
+}: OnboardingTourJoyrideWelcomeModalProps) {
+ return (
+ {
+ if (!next) onClose();
+ }}
+ >
+
+
+
+
+ {/* Close button */}
+
+
+
+
+ {/* Image */}
+ {image && (
+
+ {/* biome-ignore lint/performance/noImgElement: registry component must work without Next.js */}
+

+
+ )}
+
+ {/* Content */}
+
+
+ {title}
+
+
+
+ {body}
+
+
+ {description && (
+
+ {description}
+
+ )}
+
+
+
+
+
+
+ );
+}
+
+export {
+ OnboardingTourJoyrideWelcomeModal,
+ OnboardingTourJoyrideWelcomeModalCard,
+};
+export type {
+ OnboardingTourJoyrideWelcomeModalProps,
+ OnboardingTourJoyrideWelcomeModalCardProps,
+};