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 && ( +
+ +

+ {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