Skip to content
Open
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
34 changes: 34 additions & 0 deletions apps/apollo-vertex/registry.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
21 changes: 21 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,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";
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,
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
import type { ReactNode } from "react";

interface TourStep<TCondition extends string = string> {
/** 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<TCondition extends string = string> {
/** Unique tour identifier */
id: string;
/** Tour steps in order */
steps: TourStep<TCondition>[];
}

export type { TourStep, TourDefinition };
Original file line number Diff line number Diff line change
@@ -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 };
3 changes: 3 additions & 0 deletions apps/apollo-vertex/tsconfig.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"],
Expand Down
Loading
Loading