diff --git a/apps/apollo-vertex/package.json b/apps/apollo-vertex/package.json
index 48bd7a379..5e8bb284b 100644
--- a/apps/apollo-vertex/package.json
+++ b/apps/apollo-vertex/package.json
@@ -87,6 +87,7 @@
"react-dom": "19.2.3",
"react-hook-form": "^7.66.1",
"react-i18next": "^16.5.4",
+ "react-joyride": "^3.0.2",
"react-markdown": "^10.1.0",
"react-resizable-panels": "^4.7.3",
"recharts": "2.15.4",
diff --git a/apps/apollo-vertex/registry.json b/apps/apollo-vertex/registry.json
index 18e83cabb..a4f1ef381 100644
--- a/apps/apollo-vertex/registry.json
+++ b/apps/apollo-vertex/registry.json
@@ -1065,6 +1065,40 @@
}
]
},
+ {
+ "name": "onboarding-tour-joyride",
+ "type": "registry:ui",
+ "title": "Onboarding Tour (Joyride)",
+ "description": "Guided onboarding tour built on React Joyride with an Apollo Vertex–styled custom tooltip, welcome modal, and conditional step gating.",
+ "dependencies": [
+ "react-joyride",
+ "lucide-react",
+ "@radix-ui/react-dialog"
+ ],
+ "registryDependencies": ["button"],
+ "files": [
+ {
+ "path": "registry/onboarding-tour-joyride/index.ts",
+ "type": "registry:ui",
+ "target": "components/ui/onboarding-tour-joyride/index.ts"
+ },
+ {
+ "path": "registry/onboarding-tour-joyride/onboarding-tour-joyride-types.ts",
+ "type": "registry:ui",
+ "target": "components/ui/onboarding-tour-joyride/onboarding-tour-joyride-types.ts"
+ },
+ {
+ "path": "registry/onboarding-tour-joyride/tour-persistence.ts",
+ "type": "registry:ui",
+ "target": "components/ui/onboarding-tour-joyride/tour-persistence.ts"
+ },
+ {
+ "path": "registry/onboarding-tour-joyride/onboarding-tour-joyride-popover.tsx",
+ "type": "registry:ui",
+ "target": "components/ui/onboarding-tour-joyride/onboarding-tour-joyride-popover.tsx"
+ }
+ ]
+ },
{
"name": "navigation-menu",
"type": "registry:ui",
diff --git a/apps/apollo-vertex/registry/onboarding-tour-joyride/index.ts b/apps/apollo-vertex/registry/onboarding-tour-joyride/index.ts
new file mode 100644
index 000000000..a4cb5ed22
--- /dev/null
+++ b/apps/apollo-vertex/registry/onboarding-tour-joyride/index.ts
@@ -0,0 +1,21 @@
+// Components
+export type {
+ OnboardingTourJoyridePopoverCardProps,
+ OnboardingTourJoyridePopoverData,
+ OnboardingTourJoyridePopoverProps,
+} from "./onboarding-tour-joyride-popover";
+export {
+ OnboardingTourJoyridePopover,
+ OnboardingTourJoyridePopoverCard,
+} from "./onboarding-tour-joyride-popover";
+// Types
+export type {
+ TourDefinition,
+ TourStep,
+} from "./onboarding-tour-joyride-types";
+// Persistence
+export {
+ isTourCompleted,
+ markTourCompleted,
+ resetTourState,
+} from "./tour-persistence";
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
new file mode 100644
index 000000000..a9ce0b86f
--- /dev/null
+++ b/apps/apollo-vertex/registry/onboarding-tour-joyride/onboarding-tour-joyride-popover.tsx
@@ -0,0 +1,239 @@
+"use client";
+
+import { ArrowLeft, Lightbulb } from "lucide-react";
+import type { ComponentProps, ReactNode } from "react";
+import { useId } from "react";
+import type { TooltipRenderProps } from "react-joyride";
+import { Button } from "@/components/ui/button";
+import { cn } from "@/lib/utils";
+
+interface OnboardingTourJoyridePopoverData {
+ /** Step title */
+ title: string;
+ /** Step body content */
+ body: ReactNode;
+ /** Optional tip/note section */
+ tip?: string;
+ /** Current step index within popover-type steps (0-based) */
+ currentStep: number;
+ /** Total popover-type steps */
+ totalSteps: number;
+ /** Whether to show back button */
+ showBack?: boolean;
+ /** Custom label for the Next button */
+ nextLabel?: string;
+}
+
+interface OnboardingTourJoyridePopoverCardProps extends ComponentProps<"div"> {
+ /** Step title */
+ title: string;
+ /** Step body content */
+ body: ReactNode;
+ /** Optional tip/note section */
+ tip?: string;
+ /** Current popover step index (0-based) */
+ currentStep: number;
+ /** Total popover steps */
+ totalSteps: number;
+ /** Whether to show the back button */
+ showBack?: boolean;
+ /** Back button handler (omit to hide, or use showBack=false) */
+ onBack?: () => void;
+ /** Primary (next/done) button handler */
+ onNext?: () => void;
+ /** Skip tour handler */
+ onSkip?: () => void;
+ /** Whether this is the last step (changes "Next" to "Done") */
+ isLastStep?: boolean;
+ /** Custom label for the next button */
+ nextLabel?: string;
+ /** Props to spread on back button (from Joyride) */
+ backButtonProps?: ComponentProps<"button">;
+ /** Props to spread on primary button (from Joyride) */
+ primaryButtonProps?: ComponentProps<"button">;
+ /** Props to spread on skip button (from Joyride) */
+ skipButtonProps?: ComponentProps<"button">;
+}
+
+/**
+ * The visual popover card — renderable standalone or as a Joyride tooltip.
+ * Accepts either plain `onBack/onNext/onSkip` handlers OR Joyride's
+ * `backButtonProps/primaryButtonProps/skipButtonProps` spreads.
+ */
+function OnboardingTourJoyridePopoverCard({
+ title,
+ body,
+ tip,
+ currentStep,
+ totalSteps,
+ showBack = false,
+ onBack,
+ onNext,
+ onSkip,
+ isLastStep = false,
+ nextLabel,
+ backButtonProps,
+ primaryButtonProps,
+ skipButtonProps,
+ className,
+ ...rootProps
+}: OnboardingTourJoyridePopoverCardProps) {
+ const titleId = useId();
+
+ return (
+
+ {/* Gradient glow background */}
+
+
+
+ {/* Progress bars */}
+
+ {Array.from({ length: totalSteps }, (_, i) => `step-${i}`).map(
+ (key, index) => (
+
+ ),
+ )}
+
+
+ {/* Title */}
+
+ {title}
+
+
+ {/* Body */}
+
+ {body}
+
+
+ {/* Tip section */}
+ {tip && (
+
+ )}
+
+ {/* Navigation footer */}
+
+
+ {showBack && (
+
+ )}
+
+
+
+ {!isLastStep && (
+
+ )}
+
+
+
+ );
+}
+
+type OnboardingTourJoyridePopoverProps = TooltipRenderProps;
+
+function readPopoverData(
+ value: unknown,
+): Partial {
+ if (value === null || typeof value !== "object") return {};
+ // oxlint-disable-next-line typescript-eslint(no-unsafe-type-assertion) -- narrowed to `object`; cast to index by string key
+ const v = value as Record;
+ const result: Partial = {};
+ if (typeof v.title === "string") result.title = v.title;
+ if (typeof v.tip === "string") result.tip = v.tip;
+ if (typeof v.nextLabel === "string") result.nextLabel = v.nextLabel;
+ if (typeof v.currentStep === "number") result.currentStep = v.currentStep;
+ if (typeof v.totalSteps === "number") result.totalSteps = v.totalSteps;
+ if (typeof v.showBack === "boolean") result.showBack = v.showBack;
+ // oxlint-disable-next-line typescript-eslint(no-unsafe-type-assertion) -- ReactNode is a broad union; we trust the provider to stash valid nodes
+ if ("body" in v) result.body = v.body as ReactNode;
+ return result;
+}
+
+/**
+ * Joyride `tooltipComponent` — receives TooltipRenderProps and renders the
+ * Apollo Vertex–styled popover card. Custom data travels on `step.data`.
+ */
+function OnboardingTourJoyridePopover({
+ backProps,
+ primaryProps,
+ skipProps,
+ tooltipProps,
+ isLastStep,
+ step,
+}: OnboardingTourJoyridePopoverProps) {
+ const data = readPopoverData(step.data);
+
+ return (
+
+ );
+}
+
+export { OnboardingTourJoyridePopover, OnboardingTourJoyridePopoverCard };
+export type {
+ OnboardingTourJoyridePopoverProps,
+ OnboardingTourJoyridePopoverCardProps,
+ OnboardingTourJoyridePopoverData,
+};
diff --git a/apps/apollo-vertex/registry/onboarding-tour-joyride/onboarding-tour-joyride-types.ts b/apps/apollo-vertex/registry/onboarding-tour-joyride/onboarding-tour-joyride-types.ts
new file mode 100644
index 000000000..f64fde72e
--- /dev/null
+++ b/apps/apollo-vertex/registry/onboarding-tour-joyride/onboarding-tour-joyride-types.ts
@@ -0,0 +1,35 @@
+import type { ReactNode } from "react";
+
+interface TourStep {
+ /** Unique step identifier */
+ id: string;
+ /** CSS selector for the target element. Omit for centered/modal steps. */
+ selector?: string;
+ /** Step title */
+ title: string;
+ /** Step body content */
+ body: ReactNode;
+ /** Optional tip/note to display */
+ tip?: string;
+ /** Popover placement relative to target */
+ placement?: "top" | "bottom" | "left" | "right";
+ /** Condition key that must be true before this step can be reached */
+ waitFor?: TCondition;
+ /** Custom label for the Next button */
+ nextLabel?: string;
+ /** Callback when this step becomes active */
+ onEnter?: () => void;
+ /** Step rendering type. 'modal' renders a welcome modal. Default: 'popover' */
+ type?: "popover" | "modal";
+ /** Image URL for modal-type steps */
+ image?: string;
+}
+
+interface TourDefinition {
+ /** Unique tour identifier */
+ id: string;
+ /** Tour steps in order */
+ steps: TourStep[];
+}
+
+export type { TourStep, TourDefinition };
diff --git a/apps/apollo-vertex/registry/onboarding-tour-joyride/tour-persistence.ts b/apps/apollo-vertex/registry/onboarding-tour-joyride/tour-persistence.ts
new file mode 100644
index 000000000..45806a5e3
--- /dev/null
+++ b/apps/apollo-vertex/registry/onboarding-tour-joyride/tour-persistence.ts
@@ -0,0 +1,32 @@
+const TOUR_STORAGE_KEY = "onboarding-tour-completed";
+
+function loadCompletedTours(): string[] {
+ try {
+ const data = localStorage.getItem(TOUR_STORAGE_KEY);
+ if (!data) return [];
+ const parsed: unknown = JSON.parse(data);
+ if (!Array.isArray(parsed)) return [];
+ return parsed.filter((item): item is string => typeof item === "string");
+ } catch {
+ return [];
+ }
+}
+
+function isTourCompleted(tourId: string): boolean {
+ return loadCompletedTours().includes(tourId);
+}
+
+function markTourCompleted(tourId: string) {
+ const completed = loadCompletedTours();
+ if (!completed.includes(tourId)) {
+ completed.push(tourId);
+ localStorage.setItem(TOUR_STORAGE_KEY, JSON.stringify(completed));
+ }
+}
+
+function resetTourState(tourId: string) {
+ const completed = loadCompletedTours().filter((id) => id !== tourId);
+ localStorage.setItem(TOUR_STORAGE_KEY, JSON.stringify(completed));
+}
+
+export { isTourCompleted, markTourCompleted, resetTourState };
diff --git a/apps/apollo-vertex/tsconfig.json b/apps/apollo-vertex/tsconfig.json
index 5f046dc02..9be0b6630 100644
--- a/apps/apollo-vertex/tsconfig.json
+++ b/apps/apollo-vertex/tsconfig.json
@@ -65,6 +65,9 @@
"@/components/ui/navigation-menu": [
"./registry/navigation-menu/navigation-menu"
],
+ "@/components/ui/onboarding-tour-joyride": [
+ "./registry/onboarding-tour-joyride/index"
+ ],
"@/components/ui/page-header": ["./registry/page-header/page-header"],
"@/components/ui/pagination": ["./registry/pagination/pagination"],
"@/components/ui/popover": ["./registry/popover/popover"],
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 9897021e2..4f2d3342c 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -348,6 +348,9 @@ importers:
react-i18next:
specifier: ^16.5.4
version: 16.5.4(i18next@25.8.1(typescript@5.9.3))(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(typescript@5.9.3)
+ react-joyride:
+ specifier: ^3.0.2
+ version: 3.0.2(@types/react@19.2.8)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
react-markdown:
specifier: ^10.1.0
version: 10.1.0(@types/react@19.2.8)(react@19.2.3)
@@ -2888,6 +2891,9 @@ packages:
resolution: {integrity: sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
+ '@fastify/deepmerge@3.2.1':
+ resolution: {integrity: sha512-N5Oqvltoa2r9z1tbx4xjky0oRR60v+T47Ic4J1ukoVQcptLOrIdRnCSdTGmOmajZuHVKlTnfcmrjyqsGEW1ztA==}
+
'@floating-ui/core@1.7.3':
resolution: {integrity: sha512-sGnvb5dmrJaKEZ+LDIpguvdX3bDlEllmv4/ClQ9awcmCZrlx5jQyyMWFM5kBI+EyNOCDDiKk8il0zeuX3Zlg/w==}
@@ -3010,6 +3016,17 @@ packages:
typescript:
optional: true
+ '@gilbarbara/deep-equal@0.4.1':
+ resolution: {integrity: sha512-QF2BGeQjsa59T59XvFdR3is5jrl28Eg0J6giXAC5919bcqvR8XP4B+07tpbs6Y6/IQd4FBncaL2WVXIBgSxt4w==}
+
+ '@gilbarbara/hooks@0.11.0':
+ resolution: {integrity: sha512-CIVazdxqFRplUfm9wZL3/0X1TURJekhPMWGFdWzEmyJrGPiotX2yxA1KiB8N7VnhawIaMtb2Apnda4Y6DRwi2Q==}
+ peerDependencies:
+ react: 16.8 - 19
+
+ '@gilbarbara/types@0.2.2':
+ resolution: {integrity: sha512-QuQDBRRcm1Q8AbSac2W1YElurOhprj3Iko/o+P1fJxUWS4rOGKMVli98OXS7uo4z+cKAif6a+L9bcZFSyauQpQ==}
+
'@gwhitney/detect-indent@7.0.1':
resolution: {integrity: sha512-7bQW+gkKa2kKZPeJf6+c6gFK9ARxQfn+FKy9ScTBppyKRWH2KzsmweXUoklqeEiHiNVWaeP5csIdsNq6w7QhzA==}
engines: {node: '>=12.20'}
@@ -9587,6 +9604,9 @@ packages:
resolution: {integrity: sha512-qP1vozQRI+BMOPcjFzrjXuQvdak2pHNUMZoeG2eRbiSqyvbEf/wQtEOTOX1guk6E3t36RkaqiSt8A/6YElNxLQ==}
engines: {node: '>=12'}
+ is-lite@2.0.0:
+ resolution: {integrity: sha512-70f2BMIQlbSUXVKaZUd9a9fJH3IH1PDckV0m4BIIO4LjnNYvOh4Ng7vXIXEwpA0KDZknRq+7fHwGTu0jIdx28g==}
+
is-map@2.0.3:
resolution: {integrity: sha512-1Qed0/Hr2m+YqxnM09CjA2d/i6YZNfF6R2oRAOj36eUdS6qIV/huPJNSEpKbupewFs+ZsJlxsjjPbc0/afW6Lw==}
engines: {node: '>= 0.4'}
@@ -11960,6 +11980,12 @@ packages:
typescript:
optional: true
+ react-innertext@1.1.5:
+ resolution: {integrity: sha512-PWAqdqhxhHIv80dT9znP2KvS+hfkbRovFp4zFYHFFlOoQLRiawIic81gKb3U1wEyJZgMwgs3JoLtwryASRWP3Q==}
+ peerDependencies:
+ '@types/react': '>=0.0.0 <=99'
+ react: '>=0.0.0 <=99'
+
react-is@16.13.1:
resolution: {integrity: sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==}
@@ -11972,6 +11998,12 @@ packages:
react-is@19.2.3:
resolution: {integrity: sha512-qJNJfu81ByyabuG7hPFEbXqNcWSU3+eVus+KJs+0ncpGfMyYdvSmxiJxbWR65lYi1I+/0HBcliO029gc4F+PnA==}
+ react-joyride@3.0.2:
+ resolution: {integrity: sha512-Tm+zXo/O8rFOUkN1yH+t38HqLvOCB8p5JTqgQD3IlLyZGCXzJEnCXtyB/BQIUC7X07kEaw0BqtKjWFzF4fyDVw==}
+ peerDependencies:
+ react: 16.8 - 19
+ react-dom: 16.8 - 19
+
react-live@4.1.8:
resolution: {integrity: sha512-B2SgNqwPuS2ekqj4lcxi5TibEcjWkdVyYykBEUBshPAPDQ527x2zPEZg560n8egNtAjUpwXFQm7pcXV65aAYmg==}
engines: {node: '>= 0.12.0', npm: '>= 2.0.0'}
@@ -12490,6 +12522,12 @@ packages:
scroll-into-view-if-needed@3.1.0:
resolution: {integrity: sha512-49oNpRjWRvnU8NyGVmUaYG4jtTkNonFZI86MmGRDqBphEK2EXT9gdEUoQPZhuBM8yWHxCWbobltqYO5M4XrUvQ==}
+ scroll@3.0.1:
+ resolution: {integrity: sha512-pz7y517OVls1maEzlirKO5nPYle9AXsFzTMNJrRGmT951mzpIBy7sNHOg5o/0MQd/NqliCiWnAi0kZneMPFLcg==}
+
+ scrollparent@2.1.0:
+ resolution: {integrity: sha512-bnnvJL28/Rtz/kz2+4wpBjHzWoEzXhVg/TE8BeVGJHUqE8THNIRnDxDWMktwM+qahvlRdvlLdsQfYe+cuqfZeA==}
+
semantic-release-monorepo@8.0.2:
resolution: {integrity: sha512-TQC6KKIA0ATjii1OT0ZmQqcVzBJoaetJaJBC8FmKkg1IbDR4wBsuX6gl6UHDdijRDl8YyXqahj2hkJNyV6m9Jg==}
peerDependencies:
@@ -15839,6 +15877,8 @@ snapshots:
'@eslint/core': 0.17.0
levn: 0.4.1
+ '@fastify/deepmerge@3.2.1': {}
+
'@floating-ui/core@1.7.3':
dependencies:
'@floating-ui/utils': 0.2.10
@@ -16031,6 +16071,17 @@ snapshots:
optionalDependencies:
typescript: 5.9.3
+ '@gilbarbara/deep-equal@0.4.1': {}
+
+ '@gilbarbara/hooks@0.11.0(react@19.2.3)':
+ dependencies:
+ '@gilbarbara/deep-equal': 0.4.1
+ react: 19.2.3
+
+ '@gilbarbara/types@0.2.2':
+ dependencies:
+ type-fest: 4.41.0
+
'@gwhitney/detect-indent@7.0.1': {}
'@headlessui/react@2.2.9(react-dom@19.2.3(react@19.2.3))(react@19.2.3)':
@@ -23716,6 +23767,8 @@ snapshots:
is-interactive@2.0.0: {}
+ is-lite@2.0.0: {}
+
is-map@2.0.3: {}
is-negative-zero@2.0.3: {}
@@ -26426,6 +26479,11 @@ snapshots:
optionalDependencies:
typescript: 5.9.3
+ react-innertext@1.1.5(@types/react@19.2.8)(react@19.2.3):
+ dependencies:
+ '@types/react': 19.2.8
+ react: 19.2.3
+
react-is@16.13.1: {}
react-is@17.0.2: {}
@@ -26434,6 +26492,23 @@ snapshots:
react-is@19.2.3: {}
+ react-joyride@3.0.2(@types/react@19.2.8)(react-dom@19.2.3(react@19.2.3))(react@19.2.3):
+ dependencies:
+ '@fastify/deepmerge': 3.2.1
+ '@floating-ui/react-dom': 2.1.8(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
+ '@gilbarbara/deep-equal': 0.4.1
+ '@gilbarbara/hooks': 0.11.0(react@19.2.3)
+ '@gilbarbara/types': 0.2.2
+ is-lite: 2.0.0
+ react: 19.2.3
+ react-dom: 19.2.3(react@19.2.3)
+ react-innertext: 1.1.5(@types/react@19.2.8)(react@19.2.3)
+ scroll: 3.0.1
+ scrollparent: 2.1.0
+ use-sync-external-store: 1.6.0(react@19.2.3)
+ transitivePeerDependencies:
+ - '@types/react'
+
react-live@4.1.8(react-dom@19.2.3(react@19.2.3))(react@19.2.3):
dependencies:
prism-react-renderer: 2.4.1(react@19.2.3)
@@ -27208,6 +27283,10 @@ snapshots:
dependencies:
compute-scroll-into-view: 3.1.1
+ scroll@3.0.1: {}
+
+ scrollparent@2.1.0: {}
+
semantic-release-monorepo@8.0.2(semantic-release@25.0.3(typescript@5.9.3)):
dependencies:
debug: 4.4.3