From a52570936d08153c10b870008ab3a6f9eb4c5b5f Mon Sep 17 00:00:00 2001 From: Peter Vachon Date: Mon, 11 May 2026 08:55:21 -0400 Subject: [PATCH] =?UTF-8?q?feat(apollo-vertex):=20add=20Joyride=20onboardi?= =?UTF-8?q?ng=20tour=20=E2=80=94=20popover=20card=20and=20types?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds the presentational layer of the onboarding-tour-joyride registry component: step type definitions, tour persistence hook, and the popover card that Joyride renders as its tooltipComponent. The popover card is split into two components: OnboardingTourJoyridePopoverCard (pure visual, usable standalone for static previews) and OnboardingTourJoyridePopover (adapter that receives Joyride's TooltipRenderProps and feeds them into the card). This keeps the presentational component free of Joyride's internal prop types. Co-Authored-By: Colin Reilly --- apps/apollo-vertex/package.json | 1 + apps/apollo-vertex/registry.json | 34 +++ .../registry/onboarding-tour-joyride/index.ts | 21 ++ .../onboarding-tour-joyride-popover.tsx | 239 ++++++++++++++++++ .../onboarding-tour-joyride-types.ts | 35 +++ .../tour-persistence.ts | 32 +++ apps/apollo-vertex/tsconfig.json | 3 + pnpm-lock.yaml | 79 ++++++ 8 files changed, 444 insertions(+) create mode 100644 apps/apollo-vertex/registry/onboarding-tour-joyride/index.ts create mode 100644 apps/apollo-vertex/registry/onboarding-tour-joyride/onboarding-tour-joyride-popover.tsx create mode 100644 apps/apollo-vertex/registry/onboarding-tour-joyride/onboarding-tour-joyride-types.ts create mode 100644 apps/apollo-vertex/registry/onboarding-tour-joyride/tour-persistence.ts 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