-
Notifications
You must be signed in to change notification settings - Fork 436
feat(web): add minimal onboarding infrastructure with tour registry #3338
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Conversation
Add a scalable onboarding system for guided tours: - Core library (lib/onboarding): - Tour registry pattern for modular tour registration - isNewUserAtom for controlling new user onboarding - seenToursAtom for tracking completed tours - Type definitions for steps and tours with lifecycle hooks - Components (components/Onboarding): - OnboardingProvider wrapping NextStep library - OnboardingCard with progress bar and navigation - useOnboardingTour hook for triggering tours - Example tour for evaluation results page Uses @agentaai/nextstepjs (our fork with selector retry)
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
…hydration issues - Updated OnboardingProvider to use useState and useEffect for managing tour state, improving hydration handling. - Added caching mechanism in TourRegistry to prevent unnecessary re-renders during tour registration and unregistration. - Introduced InternalTour type for better type safety in onboarding components.
- Enhanced OnboardingCard to allow users to drag the card for better accessibility. - Added drag state management using useState and useRef hooks. - Implemented mouse event handlers for drag functionality, ensuring the card remains within viewport boundaries. - Updated card rendering to reflect drag position and added a drag handle for user interaction.
… and interactions - Changed tour step titles to better reflect content: "View Tabs" to "Aggregated Results" and "Explore Test Scenarios" to "Detailed Results". - Enhanced content descriptions for clarity on tab functionalities. - Adjusted selectors for the Overview and Scenarios tabs to improve targeting. - Added functionality to automatically click the Scenarios tab when the corresponding step is shown.
…ponent - Introduced a cardTransition prop with a duration of 0.2 seconds to enhance the visual experience during onboarding steps.
… component - Store a reference to the current step's cleanup handler to ensure it is properly removed when leaving the step. - Prevent accumulation of stale handlers by deleting the cleanup handler on exit.
- Removed unnecessary imports from EvalResultsOnboarding and useOnboardingTour files to streamline the codebase. - Improved code readability by eliminating unused variables and dependencies.
- Consolidated import statements in the useOnboardingTour hook to improve code clarity and maintainability.
| resetSeenToursAtom, | ||
| activeTourIdAtom, | ||
| currentStepStateAtom, | ||
| } from "./atoms" |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
let's use reducer pattern.
something like:
import type {OnboardingStep} from "./types"
// ─────────────────────────────────────────────────────────────
// State
// ─────────────────────────────────────────────────────────────
export type TourStatus = "idle" | "active" | "completing"
export interface TourState {
status: TourStatus
activeTourId: string | null
currentStepIndex: number
totalSteps: number
currentStep: OnboardingStep | null
/** Pending effects to execute after state transition */
pendingEffects: TourEffect[]
}
export const initialTourState: TourState = {
status: "idle",
activeTourId: null,
currentStepIndex: 0,
totalSteps: 0,
currentStep: null,
pendingEffects: [],
}
// ─────────────────────────────────────────────────────────────
// Actions (explicit, dispatchable intentions)
// ─────────────────────────────────────────────────────────────
export type TourAction =
| { type: "START_TOUR"; tourId: string; steps: OnboardingStep[] }
| { type: "NEXT_STEP" }
| { type: "PREV_STEP" }
| { type: "SKIP_TOUR" }
| { type: "COMPLETE_TOUR" }
| { type: "CLEAR_EFFECTS" }
// ─────────────────────────────────────────────────────────────
// Effects (side effects to run AFTER state updates)
// ─────────────────────────────────────────────────────────────
export type TourEffect =
| { type: "RUN_ON_ENTER"; step: OnboardingStep }
| { type: "RUN_ON_EXIT"; step: OnboardingStep }
| { type: "RUN_ON_CLEANUP"; steps: OnboardingStep[] }
| { type: "MARK_TOUR_SEEN"; tourId: string }
| { type: "SYNC_LIBRARY"; action: "start" | "next" | "prev" | "stop"; tourId?: string }
// ─────────────────────────────────────────────────────────────
// Reducer (pure function - no side effects)
// ─────────────────────────────────────────────────────────────
export function tourReducer(state: TourState, action: TourAction): TourState {
switch (action.type) {
case "START_TOUR": {
if (state.status === "active") {
// Already running a tour - reject
console.warn(`[Tour] Cannot start "${action.tourId}" - tour already active`)
return state
}
const firstStep = action.steps[0] ?? null
return {
status: "active",
activeTourId: action.tourId,
currentStepIndex: 0,
totalSteps: action.steps.length,
currentStep: firstStep,
pendingEffects: [
{ type: "SYNC_LIBRARY", action: "start", tourId: action.tourId },
...(firstStep ? [{ type: "RUN_ON_ENTER" as const, step: firstStep }] : []),
],
}
}
case "NEXT_STEP": {
if (state.status !== "active" || !state.activeTourId) return state
const nextIndex = state.currentStepIndex + 1
const steps = getStepsFromRegistry(state.activeTourId)
// Check if this was the last step
if (nextIndex >= state.totalSteps) {
return {
...state,
status: "completing",
pendingEffects: [
...(state.currentStep ? [{ type: "RUN_ON_EXIT" as const, step: state.currentStep }] : []),
{ type: "RUN_ON_CLEANUP", steps },
{ type: "MARK_TOUR_SEEN", tourId: state.activeTourId },
{ type: "SYNC_LIBRARY", action: "stop" },
],
}
}
const nextStep = steps[nextIndex] ?? null
return {
...state,
currentStepIndex: nextIndex,
currentStep: nextStep,
pendingEffects: [
...(state.currentStep ? [{ type: "RUN_ON_EXIT" as const, step: state.currentStep }] : []),
{ type: "SYNC_LIBRARY", action: "next" },
...(nextStep ? [{ type: "RUN_ON_ENTER" as const, step: nextStep }] : []),
],
}
}
case "PREV_STEP": {
if (state.status !== "active" || state.currentStepIndex === 0) return state
const prevIndex = state.currentStepIndex - 1
const steps = getStepsFromRegistry(state.activeTourId!)
const prevStep = steps[prevIndex] ?? null
return {
...state,
currentStepIndex: prevIndex,
currentStep: prevStep,
pendingEffects: [
...(state.currentStep ? [{ type: "RUN_ON_EXIT" as const, step: state.currentStep }] : []),
{ type: "SYNC_LIBRARY", action: "prev" },
...(prevStep ? [{ type: "RUN_ON_ENTER" as const, step: prevStep }] : []),
],
}
}
case "SKIP_TOUR": {
if (state.status !== "active" || !state.activeTourId) return state
const steps = getStepsFromRegistry(state.activeTourId)
return {
...initialTourState,
pendingEffects: [
...(state.currentStep ? [{ type: "RUN_ON_EXIT" as const, step: state.currentStep }] : []),
{ type: "RUN_ON_CLEANUP", steps },
{ type: "MARK_TOUR_SEEN", tourId: state.activeTourId },
{ type: "SYNC_LIBRARY", action: "stop" },
],
}
}
case "COMPLETE_TOUR": {
// Final cleanup after completing effects
return initialTourState
}
case "CLEAR_EFFECTS": {
return { ...state, pendingEffects: [] }
}
default:
return state
}
}
// Helper to get steps (would import from registry)
function getStepsFromRegistry(tourId: string): OnboardingStep[] {
// Import tourRegistry and get steps
const { tourRegistry } = require("./registry")
return tourRegistry.get(tourId)?.steps ?? []
}
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
and a hook to execute pending effects from the tour reducer, separating WHAT should happen (reducer) from HOW it happens (this new hook)
| if (!canAutoStart) return | ||
|
|
||
| // Small delay to ensure page is rendered | ||
| const timer = setTimeout(() => { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
let's not have arbitrary timeouts. reducer pattern can help us clean these up
|
|
||
| // Update current step state and run lifecycle hooks | ||
| useEffect(() => { | ||
| if (!step) return |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
we can offload logic from this component to the reducer
Summary
@agentaai/nextstepjs(our fork with selector retry mechanism for async elements)Architecture
How to Use
Test Plan
isNewUserAtomis set totrueCleanShot.2026-01-03.at.23.08.56.mp4