Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions apps/apollo-vertex/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
44 changes: 44 additions & 0 deletions apps/apollo-vertex/registry.json
Original file line number Diff line number Diff line change
Expand Up @@ -1065,6 +1065,50 @@
}
]
},
{
"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"
},
{
"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"
}
]
},
{
"name": "navigation-menu",
"type": "registry:ui",
Expand Down
39 changes: 39 additions & 0 deletions apps/apollo-vertex/registry/onboarding-tour-joyride/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
// Components
export type {
OnboardingTourJoyridePopoverCardProps,
OnboardingTourJoyridePopoverData,
OnboardingTourJoyridePopoverProps,
} from "./onboarding-tour-joyride-popover";
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,
markTourCompleted,
resetTourState,
} from "./tour-persistence";
Original file line number Diff line number Diff line change
@@ -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 (
<div
data-slot="onboarding-tour-joyride-popover"
{...rootProps}
className={cn("relative", className)}
>
{/* Gradient glow background */}
<div
className="absolute inset-0 rounded-xl pointer-events-none blur-xl"
style={{
background:
"linear-gradient(112.44deg, rgba(108, 90, 239, 0.2) 31.16%, rgba(18, 203, 123, 0.1) 106.82%)",
}}
/>

<div
className="relative w-full max-w-[360px] bg-card rounded-xl border border-border shadow-lg p-5"
role="dialog"
aria-modal="true"
aria-labelledby={titleId}
>
{/* Progress bars */}
<div className="flex gap-1.5 mb-5">
{Array.from({ length: totalSteps }, (_, i) => `step-${i}`).map(
(key, index) => (
<div
key={key}
className={cn(
"h-1 flex-1 rounded-full transition-colors",
index <= currentStep ? "bg-primary" : "bg-muted",
)}
/>
),
)}
</div>

{/* Title */}
<h3
id={titleId}
className="text-lg font-semibold text-foreground mb-2 leading-snug"
>
{title}
</h3>

{/* Body */}
<div className="text-sm text-muted-foreground mb-4 leading-relaxed">
{body}
</div>

{/* Tip section */}
{tip && (
<div className="flex items-start gap-2.5 mb-4">
<Lightbulb
className="w-4 h-4 text-muted-foreground mt-1 shrink-0"
strokeWidth={1.5}
/>
<p className="text-sm text-muted-foreground leading-relaxed">
{tip}
</p>
</div>
)}

{/* Navigation footer */}
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
{showBack && (
<Button
{...(backButtonProps ?? { onClick: onBack })}
variant="outline"
size="icon-lg"
aria-label="Go back"
>
<ArrowLeft />
</Button>
)}
<Button
{...(primaryButtonProps ?? { onClick: onNext })}
className="px-8"
autoFocus
>
{isLastStep ? "Done" : (nextLabel ?? "Next")}
</Button>
</div>

{!isLastStep && (
<Button
{...(skipButtonProps ?? { onClick: onSkip })}
variant="link"
className="text-muted-foreground"
>
{"Skip tour"}
</Button>
)}
</div>
</div>
</div>
);
}

type OnboardingTourJoyridePopoverProps = TooltipRenderProps;

function readPopoverData(
value: unknown,
): Partial<OnboardingTourJoyridePopoverData> {
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<string, unknown>;
const result: Partial<OnboardingTourJoyridePopoverData> = {};
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 (
<OnboardingTourJoyridePopoverCard
{...tooltipProps}
title={data.title ?? ""}
body={data.body ?? null}
tip={data.tip}
currentStep={data.currentStep ?? 0}
totalSteps={data.totalSteps ?? 1}
showBack={data.showBack ?? false}
nextLabel={data.nextLabel}
isLastStep={isLastStep}
backButtonProps={backProps}
primaryButtonProps={primaryProps}
skipButtonProps={skipProps}
/>
);
}

export { OnboardingTourJoyridePopover, OnboardingTourJoyridePopoverCard };
export type {
OnboardingTourJoyridePopoverProps,
OnboardingTourJoyridePopoverCardProps,
OnboardingTourJoyridePopoverData,
};
Loading
Loading