diff --git a/apps/ui/src/components/shared/index.ts b/apps/ui/src/components/shared/index.ts index 2497d409..5060ebd1 100644 --- a/apps/ui/src/components/shared/index.ts +++ b/apps/ui/src/components/shared/index.ts @@ -5,3 +5,17 @@ export { type UseModelOverrideOptions, type UseModelOverrideResult, } from './use-model-override'; + +// Onboarding Wizard Components +export { + OnboardingWizard, + useOnboardingWizard, + ONBOARDING_STORAGE_PREFIX, + ONBOARDING_TARGET_ATTRIBUTE, + ONBOARDING_ANALYTICS, + type OnboardingStep, + type OnboardingState, + type OnboardingWizardProps, + type UseOnboardingWizardOptions, + type UseOnboardingWizardResult, +} from './onboarding'; diff --git a/apps/ui/src/components/shared/onboarding/constants.ts b/apps/ui/src/components/shared/onboarding/constants.ts new file mode 100644 index 00000000..d001634e --- /dev/null +++ b/apps/ui/src/components/shared/onboarding/constants.ts @@ -0,0 +1,55 @@ +/** + * Shared Onboarding Wizard Constants + * + * Layout, positioning, and timing constants for the onboarding wizard. + */ + +/** Storage key prefix for onboarding state */ +export const ONBOARDING_STORAGE_PREFIX = 'automaker:onboarding'; + +/** Padding around spotlight highlight elements (px) */ +export const SPOTLIGHT_PADDING = 8; + +/** Padding between target element and tooltip (px) */ +export const TOOLTIP_OFFSET = 16; + +/** Vertical offset from top of target to tooltip (px) */ +export const TOOLTIP_TOP_OFFSET = 40; + +/** Maximum tooltip width (px) */ +export const TOOLTIP_MAX_WIDTH = 400; + +/** Minimum safe margin from viewport edges (px) */ +export const VIEWPORT_SAFE_MARGIN = 16; + +/** Threshold for placing tooltip to the right of target (30% of viewport) */ +export const TOOLTIP_POSITION_RIGHT_THRESHOLD = 0.3; + +/** Threshold for placing tooltip to the left of target (70% of viewport) */ +export const TOOLTIP_POSITION_LEFT_THRESHOLD = 0.7; + +/** Threshold from bottom of viewport to trigger alternate positioning (px) */ +export const BOTTOM_THRESHOLD = 450; + +/** Debounce delay for resize handler (ms) */ +export const RESIZE_DEBOUNCE_MS = 100; + +/** Animation duration for step transitions (ms) */ +export const STEP_TRANSITION_DURATION = 200; + +/** ID for the wizard description element (for aria-describedby) */ +export const WIZARD_DESCRIPTION_ID = 'onboarding-wizard-description'; + +/** ID for the wizard title element (for aria-labelledby) */ +export const WIZARD_TITLE_ID = 'onboarding-wizard-title'; + +/** Data attribute name for targeting elements */ +export const ONBOARDING_TARGET_ATTRIBUTE = 'data-onboarding-target'; + +/** Analytics event names for onboarding tracking */ +export const ONBOARDING_ANALYTICS = { + STARTED: 'onboarding_started', + COMPLETED: 'onboarding_completed', + SKIPPED: 'onboarding_skipped', + STEP_VIEWED: 'onboarding_step_viewed', +} as const; diff --git a/apps/ui/src/components/shared/onboarding/index.ts b/apps/ui/src/components/shared/onboarding/index.ts new file mode 100644 index 00000000..26b117c3 --- /dev/null +++ b/apps/ui/src/components/shared/onboarding/index.ts @@ -0,0 +1,21 @@ +/** + * Shared Onboarding Components + * + * Generic onboarding wizard infrastructure for building + * interactive tutorials across different views. + */ + +export { OnboardingWizard } from './onboarding-wizard'; +export { useOnboardingWizard } from './use-onboarding-wizard'; +export type { + OnboardingStep, + OnboardingState, + OnboardingWizardProps, + UseOnboardingWizardOptions, + UseOnboardingWizardResult, +} from './types'; +export { + ONBOARDING_STORAGE_PREFIX, + ONBOARDING_TARGET_ATTRIBUTE, + ONBOARDING_ANALYTICS, +} from './constants'; diff --git a/apps/ui/src/components/shared/onboarding/onboarding-wizard.tsx b/apps/ui/src/components/shared/onboarding/onboarding-wizard.tsx new file mode 100644 index 00000000..70bbcb9b --- /dev/null +++ b/apps/ui/src/components/shared/onboarding/onboarding-wizard.tsx @@ -0,0 +1,545 @@ +/** + * Generic Onboarding Wizard Component + * + * A multi-step wizard overlay that guides users through features + * with visual highlighting (spotlight effect) on target elements. + * + * Features: + * - Spotlight overlay targeting elements via data-onboarding-target + * - Responsive tooltip positioning (left/right/bottom) + * - Step navigation (keyboard & mouse) + * - Configurable children slot for view-specific content + * - Completion celebration animation + * - Full accessibility (ARIA, focus management) + */ + +import { useEffect, useRef, useCallback, useState } from 'react'; +import { createPortal } from 'react-dom'; +import { X, ChevronLeft, ChevronRight, CheckCircle2, PartyPopper, Sparkles } from 'lucide-react'; +import { Button } from '@/components/ui/button'; +import { cn } from '@/lib/utils'; +import { + SPOTLIGHT_PADDING, + TOOLTIP_OFFSET, + TOOLTIP_TOP_OFFSET, + TOOLTIP_MAX_WIDTH, + VIEWPORT_SAFE_MARGIN, + TOOLTIP_POSITION_RIGHT_THRESHOLD, + TOOLTIP_POSITION_LEFT_THRESHOLD, + BOTTOM_THRESHOLD, + RESIZE_DEBOUNCE_MS, + STEP_TRANSITION_DURATION, + WIZARD_DESCRIPTION_ID, + WIZARD_TITLE_ID, + ONBOARDING_TARGET_ATTRIBUTE, +} from './constants'; +import type { OnboardingWizardProps, OnboardingStep } from './types'; + +interface HighlightRect { + top: number; + left: number; + right: number; + bottom: number; + width: number; + height: number; +} + +export function OnboardingWizard({ + isVisible, + currentStep, + currentStepData, + totalSteps, + onNext, + onPrevious, + onSkip, + onComplete, + steps, + children, +}: OnboardingWizardProps) { + const [highlightRect, setHighlightRect] = useState(null); + const [tooltipPosition, setTooltipPosition] = useState<'left' | 'right' | 'bottom'>('bottom'); + const [isAnimating, setIsAnimating] = useState(false); + const [showCompletionCelebration, setShowCompletionCelebration] = useState(false); + + // Refs for focus management + const dialogRef = useRef(null); + const nextButtonRef = useRef(null); + + // Detect if user is on a touch device + const [isTouchDevice, setIsTouchDevice] = useState(false); + + useEffect(() => { + setIsTouchDevice('ontouchstart' in window || navigator.maxTouchPoints > 0); + }, []); + + // Lock scroll when wizard is visible + useEffect(() => { + if (!isVisible) return; + + const originalOverflow = document.body.style.overflow; + document.body.style.overflow = 'hidden'; + + return () => { + document.body.style.overflow = originalOverflow; + }; + }, [isVisible]); + + // Focus management - move focus to dialog when opened + useEffect(() => { + if (!isVisible) return; + + const timer = setTimeout(() => { + nextButtonRef.current?.focus(); + }, STEP_TRANSITION_DURATION); + + return () => clearTimeout(timer); + }, [isVisible]); + + // Animate step transitions + useEffect(() => { + if (!isVisible) return; + + setIsAnimating(true); + const timer = setTimeout(() => { + setIsAnimating(false); + }, STEP_TRANSITION_DURATION); + + return () => clearTimeout(timer); + }, [currentStep, isVisible]); + + // Find and highlight the target element + useEffect(() => { + if (!isVisible || !currentStepData) { + setHighlightRect(null); + return; + } + + const updateHighlight = () => { + // Find target element by data-onboarding-target attribute + const targetEl = document.querySelector( + `[${ONBOARDING_TARGET_ATTRIBUTE}="${currentStepData.targetId}"]` + ); + + if (targetEl) { + const rect = targetEl.getBoundingClientRect(); + setHighlightRect({ + top: rect.top, + left: rect.left, + right: rect.right, + bottom: rect.bottom, + width: rect.width, + height: rect.height, + }); + + // Determine tooltip position based on target position and available space + const viewportWidth = window.innerWidth; + const viewportHeight = window.innerHeight; + const targetCenter = rect.left + rect.width / 2; + const tooltipWidth = Math.min(TOOLTIP_MAX_WIDTH, viewportWidth - VIEWPORT_SAFE_MARGIN * 2); + + const spaceAtBottom = viewportHeight - rect.bottom - TOOLTIP_OFFSET; + const spaceAtRight = viewportWidth - rect.right - TOOLTIP_OFFSET; + const spaceAtLeft = rect.left - TOOLTIP_OFFSET; + + // For leftmost targets, prefer right position + if ( + targetCenter < viewportWidth * TOOLTIP_POSITION_RIGHT_THRESHOLD && + spaceAtRight >= tooltipWidth + ) { + setTooltipPosition('right'); + } + // For rightmost targets, prefer left position + else if ( + targetCenter > viewportWidth * TOOLTIP_POSITION_LEFT_THRESHOLD && + spaceAtLeft >= tooltipWidth + ) { + setTooltipPosition('left'); + } + // For middle targets, check if bottom position would work + else if (spaceAtBottom >= BOTTOM_THRESHOLD) { + setTooltipPosition('bottom'); + } + // Fallback logic + else if (spaceAtRight > spaceAtLeft && spaceAtRight >= tooltipWidth * 0.6) { + setTooltipPosition('right'); + } else if (spaceAtLeft >= tooltipWidth * 0.6) { + setTooltipPosition('left'); + } else { + setTooltipPosition('bottom'); + } + } + }; + + updateHighlight(); + + // Debounced resize handler + let resizeTimeout: ReturnType; + const handleResize = () => { + clearTimeout(resizeTimeout); + resizeTimeout = setTimeout(updateHighlight, RESIZE_DEBOUNCE_MS); + }; + + window.addEventListener('resize', handleResize); + return () => { + window.removeEventListener('resize', handleResize); + clearTimeout(resizeTimeout); + }; + }, [isVisible, currentStepData]); + + // Keyboard navigation + useEffect(() => { + if (!isVisible) return; + + const handleKeyDown = (e: KeyboardEvent) => { + if (e.key === 'Escape') { + onSkip(); + } else if (e.key === 'ArrowRight' || e.key === 'Enter') { + if (currentStep < totalSteps - 1) { + onNext(); + } else { + handleComplete(); + } + } else if (e.key === 'ArrowLeft') { + onPrevious(); + } + }; + + window.addEventListener('keydown', handleKeyDown); + return () => window.removeEventListener('keydown', handleKeyDown); + }, [isVisible, currentStep, totalSteps, onNext, onPrevious, onSkip]); + + // Calculate tooltip styles based on position and highlight rect + const getTooltipStyles = useCallback((): React.CSSProperties => { + if (!highlightRect) return {}; + + const viewportHeight = window.innerHeight; + const viewportWidth = window.innerWidth; + const tooltipWidth = Math.min(TOOLTIP_MAX_WIDTH, viewportWidth - VIEWPORT_SAFE_MARGIN * 2); + + switch (tooltipPosition) { + case 'right': { + const topPos = Math.max(VIEWPORT_SAFE_MARGIN, highlightRect.top + TOOLTIP_TOP_OFFSET); + const availableHeight = viewportHeight - topPos - VIEWPORT_SAFE_MARGIN; + return { + position: 'fixed', + top: topPos, + left: highlightRect.right + TOOLTIP_OFFSET, + width: tooltipWidth, + maxWidth: `calc(100vw - ${highlightRect.right + TOOLTIP_OFFSET * 2}px)`, + maxHeight: Math.max(200, availableHeight), + }; + } + case 'left': { + const topPos = Math.max(VIEWPORT_SAFE_MARGIN, highlightRect.top + TOOLTIP_TOP_OFFSET); + const availableHeight = viewportHeight - topPos - VIEWPORT_SAFE_MARGIN; + return { + position: 'fixed', + top: topPos, + right: viewportWidth - highlightRect.left + TOOLTIP_OFFSET, + width: tooltipWidth, + maxWidth: `calc(${highlightRect.left - TOOLTIP_OFFSET * 2}px)`, + maxHeight: Math.max(200, availableHeight), + }; + } + case 'bottom': + default: { + const idealTop = highlightRect.bottom + TOOLTIP_OFFSET; + const availableHeight = viewportHeight - idealTop - VIEWPORT_SAFE_MARGIN; + + const minTop = 100; + const topPos = + availableHeight < 250 + ? Math.max( + minTop, + viewportHeight - Math.max(300, availableHeight) - VIEWPORT_SAFE_MARGIN + ) + : idealTop; + + const idealLeft = highlightRect.left + highlightRect.width / 2 - tooltipWidth / 2; + const leftPos = Math.max( + VIEWPORT_SAFE_MARGIN, + Math.min(idealLeft, viewportWidth - tooltipWidth - VIEWPORT_SAFE_MARGIN) + ); + + return { + position: 'fixed', + top: topPos, + left: leftPos, + width: tooltipWidth, + maxHeight: Math.max(200, viewportHeight - topPos - VIEWPORT_SAFE_MARGIN), + }; + } + } + }, [highlightRect, tooltipPosition]); + + // Handle completion with celebration + const handleComplete = useCallback(() => { + setShowCompletionCelebration(true); + setTimeout(() => { + setShowCompletionCelebration(false); + onComplete(); + }, 1200); + }, [onComplete]); + + // Handle step indicator click for direct navigation + const handleStepClick = useCallback( + (stepIndex: number) => { + if (stepIndex === currentStep) return; + + if (stepIndex > currentStep) { + for (let i = currentStep; i < stepIndex; i++) { + onNext(); + } + } else { + for (let i = currentStep; i > stepIndex; i--) { + onPrevious(); + } + } + }, + [currentStep, onNext, onPrevious] + ); + + if (!isVisible || !currentStepData) return null; + + const StepIcon = currentStepData.icon || Sparkles; + const isLastStep = currentStep === totalSteps - 1; + const isFirstStep = currentStep === 0; + + const content = ( +
+ {/* Completion celebration overlay */} + {showCompletionCelebration && ( +
+
+ +

You're all set!

+
+
+ )} + + {/* Dark overlay with cutout for highlighted element */} + + + {/* Highlight border around the target element */} + {highlightRect && ( +
+ )} + + {/* Skip button - top right */} + + + {/* Tooltip card with step content */} +
+ {/* Header */} +
+
+ +
+
+

+ {currentStepData.title} +

+
+ + Step {currentStep + 1} of {totalSteps} + + {/* Step indicators - clickable for navigation */} + +
+
+
+ + {/* Description */} +

+ {currentStepData.description} +

+ + {/* Tip box */} + {currentStepData.tip && ( +
+

+ Tip: + {currentStepData.tip} +

+
+ )} + + {/* Custom content slot (e.g., Quick Start section) */} + {children} + + {/* Navigation buttons */} +
+ + + +
+ + {/* Keyboard hints - hidden on touch devices */} + {!isTouchDevice && ( + + )} +
+
+ ); + + // Render in a portal to ensure it's above everything + return createPortal(content, document.body); +} diff --git a/apps/ui/src/components/shared/onboarding/types.ts b/apps/ui/src/components/shared/onboarding/types.ts new file mode 100644 index 00000000..ce934778 --- /dev/null +++ b/apps/ui/src/components/shared/onboarding/types.ts @@ -0,0 +1,109 @@ +/** + * Shared Onboarding Wizard Types + * + * Generic types for building onboarding wizards across different views. + */ + +import type { ComponentType } from 'react'; + +/** + * Represents a single step in the onboarding wizard + */ +export interface OnboardingStep { + /** Unique identifier for this step */ + id: string; + /** Target element ID - matches data-onboarding-target attribute */ + targetId: string; + /** Step title displayed in the wizard */ + title: string; + /** Main description explaining this step */ + description: string; + /** Optional tip shown in a highlighted box */ + tip?: string; + /** Optional icon component for visual identification */ + icon?: ComponentType<{ className?: string }>; +} + +/** + * Persisted onboarding state structure + */ +export interface OnboardingState { + /** Whether the wizard has been completed */ + completed: boolean; + /** ISO timestamp when completed */ + completedAt?: string; + /** Whether the wizard has been skipped */ + skipped: boolean; + /** ISO timestamp when skipped */ + skippedAt?: string; +} + +/** + * Options for the useOnboardingWizard hook + */ +export interface UseOnboardingWizardOptions { + /** Unique storage key for localStorage persistence */ + storageKey: string; + /** Array of wizard steps to display */ + steps: OnboardingStep[]; + /** Optional callback when wizard is completed */ + onComplete?: () => void; + /** Optional callback when wizard is skipped */ + onSkip?: () => void; +} + +/** + * Return type for the useOnboardingWizard hook + */ +export interface UseOnboardingWizardResult { + /** Whether the wizard is currently visible */ + isVisible: boolean; + /** Current step index (0-based) */ + currentStep: number; + /** Current step data or null if not available */ + currentStepData: OnboardingStep | null; + /** Total number of steps */ + totalSteps: number; + /** Navigate to the next step */ + goToNextStep: () => void; + /** Navigate to the previous step */ + goToPreviousStep: () => void; + /** Navigate to a specific step by index */ + goToStep: (step: number) => void; + /** Start/show the wizard from the beginning */ + startWizard: () => void; + /** Complete the wizard and hide it */ + completeWizard: () => void; + /** Skip the wizard and hide it */ + skipWizard: () => void; + /** Whether the wizard has been completed */ + isCompleted: boolean; + /** Whether the wizard has been skipped */ + isSkipped: boolean; +} + +/** + * Props for the OnboardingWizard component + */ +export interface OnboardingWizardProps { + /** Whether the wizard is visible */ + isVisible: boolean; + /** Current step index */ + currentStep: number; + /** Current step data */ + currentStepData: OnboardingStep | null; + /** Total number of steps */ + totalSteps: number; + /** Handler for next step navigation */ + onNext: () => void; + /** Handler for previous step navigation */ + onPrevious: () => void; + /** Handler for skipping the wizard */ + onSkip: () => void; + /** Handler for completing the wizard */ + onComplete: () => void; + /** Array of all steps (for step indicator navigation) */ + steps: OnboardingStep[]; + /** Optional content to render before navigation buttons (e.g., Quick Start) */ + children?: React.ReactNode; +} diff --git a/apps/ui/src/components/shared/onboarding/use-onboarding-wizard.ts b/apps/ui/src/components/shared/onboarding/use-onboarding-wizard.ts new file mode 100644 index 00000000..a545f091 --- /dev/null +++ b/apps/ui/src/components/shared/onboarding/use-onboarding-wizard.ts @@ -0,0 +1,216 @@ +/** + * Generic Onboarding Wizard Hook + * + * Manages the state and logic for interactive onboarding wizards. + * Can be used to create onboarding experiences for any view. + * + * Features: + * - Persists completion status to localStorage + * - Step navigation (next, previous, jump to step) + * - Analytics tracking hooks + * - No auto-show logic - wizard only shows via startWizard() + */ + +import { useState, useCallback, useEffect, useMemo } from 'react'; +import { createLogger } from '@automaker/utils/logger'; +import { getItem, setItem } from '@/lib/storage'; +import { ONBOARDING_STORAGE_PREFIX, ONBOARDING_ANALYTICS } from './constants'; +import type { + OnboardingState, + OnboardingStep, + UseOnboardingWizardOptions, + UseOnboardingWizardResult, +} from './types'; + +const logger = createLogger('OnboardingWizard'); + +/** Default state for new wizards */ +const DEFAULT_ONBOARDING_STATE: OnboardingState = { + completed: false, + skipped: false, +}; + +/** + * Load onboarding state from localStorage + */ +function loadOnboardingState(storageKey: string): OnboardingState { + try { + const fullKey = `${ONBOARDING_STORAGE_PREFIX}:${storageKey}`; + const stored = getItem(fullKey); + if (stored) { + return JSON.parse(stored) as OnboardingState; + } + } catch (error) { + logger.error('Failed to load onboarding state:', error); + } + return { ...DEFAULT_ONBOARDING_STATE }; +} + +/** + * Save onboarding state to localStorage + */ +function saveOnboardingState(storageKey: string, state: OnboardingState): void { + try { + const fullKey = `${ONBOARDING_STORAGE_PREFIX}:${storageKey}`; + setItem(fullKey, JSON.stringify(state)); + } catch (error) { + logger.error('Failed to save onboarding state:', error); + } +} + +/** + * Track analytics event (placeholder - integrate with actual analytics service) + */ +function trackAnalytics(event: string, data?: Record): void { + logger.debug(`[Analytics] ${event}`, data); +} + +/** + * Generic hook for managing onboarding wizard state. + * + * @example + * ```tsx + * const wizard = useOnboardingWizard({ + * storageKey: 'my-view-onboarding', + * steps: MY_WIZARD_STEPS, + * onComplete: () => console.log('Done!'), + * }); + * + * // Start the wizard when user clicks help button + * + * + * // Render the wizard + * + * ``` + */ +export function useOnboardingWizard({ + storageKey, + steps, + onComplete, + onSkip, +}: UseOnboardingWizardOptions): UseOnboardingWizardResult { + const [currentStep, setCurrentStep] = useState(0); + const [isWizardVisible, setIsWizardVisible] = useState(false); + const [onboardingState, setOnboardingState] = useState(DEFAULT_ONBOARDING_STATE); + + // Load persisted state on mount + useEffect(() => { + const state = loadOnboardingState(storageKey); + setOnboardingState(state); + }, [storageKey]); + + // Update persisted state helper + const updateState = useCallback( + (updates: Partial) => { + setOnboardingState((prev) => { + const newState = { ...prev, ...updates }; + saveOnboardingState(storageKey, newState); + return newState; + }); + }, + [storageKey] + ); + + // Current step data + const currentStepData = useMemo(() => steps[currentStep] || null, [steps, currentStep]); + const totalSteps = steps.length; + + // Navigation handlers + const goToNextStep = useCallback(() => { + if (currentStep < totalSteps - 1) { + const nextStep = currentStep + 1; + setCurrentStep(nextStep); + trackAnalytics(ONBOARDING_ANALYTICS.STEP_VIEWED, { + storageKey, + step: nextStep, + stepId: steps[nextStep]?.id, + }); + } + }, [currentStep, totalSteps, storageKey, steps]); + + const goToPreviousStep = useCallback(() => { + if (currentStep > 0) { + setCurrentStep(currentStep - 1); + } + }, [currentStep]); + + const goToStep = useCallback( + (step: number) => { + if (step >= 0 && step < totalSteps) { + setCurrentStep(step); + trackAnalytics(ONBOARDING_ANALYTICS.STEP_VIEWED, { + storageKey, + step, + stepId: steps[step]?.id, + }); + } + }, + [totalSteps, storageKey, steps] + ); + + // Wizard lifecycle handlers + const startWizard = useCallback(() => { + setCurrentStep(0); + setIsWizardVisible(true); + trackAnalytics(ONBOARDING_ANALYTICS.STARTED, { storageKey }); + }, [storageKey]); + + const completeWizard = useCallback(() => { + setIsWizardVisible(false); + setCurrentStep(0); + updateState({ + completed: true, + completedAt: new Date().toISOString(), + }); + trackAnalytics(ONBOARDING_ANALYTICS.COMPLETED, { storageKey }); + onComplete?.(); + }, [storageKey, updateState, onComplete]); + + const skipWizard = useCallback(() => { + setIsWizardVisible(false); + setCurrentStep(0); + updateState({ + skipped: true, + skippedAt: new Date().toISOString(), + }); + trackAnalytics(ONBOARDING_ANALYTICS.SKIPPED, { + storageKey, + skippedAtStep: currentStep, + }); + onSkip?.(); + }, [storageKey, currentStep, updateState, onSkip]); + + return { + // Visibility + isVisible: isWizardVisible, + + // Steps + currentStep, + currentStepData, + totalSteps, + + // Navigation + goToNextStep, + goToPreviousStep, + goToStep, + + // Actions + startWizard, + completeWizard, + skipWizard, + + // State + isCompleted: onboardingState.completed, + isSkipped: onboardingState.skipped, + }; +} diff --git a/apps/ui/src/components/views/board-view.tsx b/apps/ui/src/components/views/board-view.tsx index 05431445..741ef507 100644 --- a/apps/ui/src/components/views/board-view.tsx +++ b/apps/ui/src/components/views/board-view.tsx @@ -1032,17 +1032,9 @@ export function BoardView() { currentProject, }); - // Use onboarding wizard hook - check if board is empty (no non-sample features) - const nonSampleFeatureCount = useMemo( - () => hookFeatures.filter((f) => !isSampleFeature(f)).length, - [hookFeatures] - ); + // Use onboarding wizard hook - triggered manually via help button const onboarding = useBoardOnboarding({ projectPath: currentProject?.path || null, - isEmpty: nonSampleFeatureCount === 0 && !isLoading, - totalFeatureCount: hookFeatures.length, - // Don't show wizard when spec generation is happening (for new projects) - isSpecDialogOpen: isCreatingSpec, }); // Handler for Quick Start - create sample features @@ -1292,8 +1284,7 @@ export function BoardView() { onShowBoardBackground={() => setShowBoardBackgroundModal(true)} onShowCompletedModal={() => setShowCompletedModal(true)} completedCount={completedFeatures.length} - onShowTour={onboarding.retriggerWizard} - canShowTour={onboarding.canRetrigger} + onStartTour={onboarding.startWizard} /> {/* Worktree Panel - conditionally rendered based on visibility setting */} @@ -1667,6 +1658,7 @@ export function BoardView() { hasSampleData={onboarding.hasSampleData} onClearSampleData={handleClearSampleData} isQuickStartLoading={isQuickStartLoading} + steps={onboarding.steps} />
); diff --git a/apps/ui/src/components/views/board-view/board-controls.tsx b/apps/ui/src/components/views/board-view/board-controls.tsx index dacb38b6..9bc66f9a 100644 --- a/apps/ui/src/components/views/board-view/board-controls.tsx +++ b/apps/ui/src/components/views/board-view/board-controls.tsx @@ -7,10 +7,8 @@ interface BoardControlsProps { onShowBoardBackground: () => void; onShowCompletedModal: () => void; completedCount: number; - /** Callback to show the onboarding wizard tour */ - onShowTour?: () => void; - /** Whether the tour can be shown (wizard was previously completed/skipped) */ - canShowTour?: boolean; + /** Callback to start the onboarding wizard tour */ + onStartTour?: () => void; } export function BoardControls({ @@ -18,22 +16,21 @@ export function BoardControls({ onShowBoardBackground, onShowCompletedModal, completedCount, - onShowTour, - canShowTour = false, + onStartTour, }: BoardControlsProps) { if (!isMounted) return null; return (
- {/* Board Tour Button - only show if tour can be retriggered */} - {canShowTour && onShowTour && ( + {/* Board Tour Button - always visible when handler is provided */} + {onStartTour && (
diff --git a/apps/ui/src/components/views/board-view/components/board-onboarding-wizard.tsx b/apps/ui/src/components/views/board-view/components/board-onboarding-wizard.tsx index 141399c3..6ac32098 100644 --- a/apps/ui/src/components/views/board-view/components/board-onboarding-wizard.tsx +++ b/apps/ui/src/components/views/board-view/components/board-onboarding-wizard.tsx @@ -1,80 +1,19 @@ /** * Board Onboarding Wizard Component * - * A multi-step wizard overlay that guides new users through the Kanban board - * workflow with visual highlighting (spotlight effect) on each column. - * - * Features: - * - Spotlight/overlay effect to focus attention on each column - * - Step navigation (Next, Previous, Skip) - * - Quick Start button to generate sample cards - * - Responsive design for mobile, tablet, and desktop - * - Keyboard navigation support + * Board-specific wrapper around the shared OnboardingWizard component. + * Adds Quick Start functionality to generate sample tasks. */ -import { useEffect, useRef, useCallback, useState } from 'react'; -import { createPortal } from 'react-dom'; -import { - X, - ChevronLeft, - ChevronRight, - Sparkles, - PlayCircle, - Lightbulb, - CheckCircle2, - Trash2, - Loader2, - PartyPopper, - Settings2, -} from 'lucide-react'; +import { Sparkles, CheckCircle2, Trash2, Loader2 } from 'lucide-react'; import { Button } from '@/components/ui/button'; import { cn } from '@/lib/utils'; -import { WIZARD_STEPS, type WizardStep } from '../hooks/use-board-onboarding'; - -// ============================================================================ -// CONSTANTS -// ============================================================================ - -/** Threshold for placing tooltip to the right of column (30% of viewport) */ -const TOOLTIP_POSITION_RIGHT_THRESHOLD = 0.3; - -/** Threshold for placing tooltip to the left of column (70% of viewport) */ -const TOOLTIP_POSITION_LEFT_THRESHOLD = 0.7; - -/** Padding around tooltip and highlight elements (px) */ -const SPOTLIGHT_PADDING = 8; - -/** Padding between column and tooltip (px) */ -const TOOLTIP_OFFSET = 16; - -/** Vertical offset from top of column to tooltip (px) */ -const TOOLTIP_TOP_OFFSET = 40; - -/** Maximum tooltip width (px) */ -const TOOLTIP_MAX_WIDTH = 400; - -/** Minimum safe margin from viewport edges (px) */ -const VIEWPORT_SAFE_MARGIN = 16; - -/** Threshold from bottom of viewport to trigger alternate positioning (px) */ -const BOTTOM_THRESHOLD = 450; - -/** Debounce delay for resize handler (ms) */ -const RESIZE_DEBOUNCE_MS = 100; - -/** Animation duration for step transitions (ms) */ -const STEP_TRANSITION_DURATION = 200; - -/** ID for the wizard description element (for aria-describedby) */ -const WIZARD_DESCRIPTION_ID = 'wizard-step-description'; - -/** ID for the wizard title element (for aria-labelledby) */ -const WIZARD_TITLE_ID = 'wizard-step-title'; +import { OnboardingWizard, type OnboardingStep } from '@/components/shared/onboarding'; interface BoardOnboardingWizardProps { isVisible: boolean; currentStep: number; - currentStepData: WizardStep | null; + currentStepData: OnboardingStep | null; totalSteps: number; onNext: () => void; onPrevious: () => void; @@ -84,16 +23,76 @@ interface BoardOnboardingWizardProps { hasSampleData: boolean; onClearSampleData: () => void; isQuickStartLoading?: boolean; + steps: OnboardingStep[]; } -// Icons for each column/step -const STEP_ICONS: Record> = { - backlog: PlayCircle, - in_progress: Sparkles, - waiting_approval: Lightbulb, - verified: CheckCircle2, - custom_columns: Settings2, -}; +/** + * Quick Start section component - only shown on first step + */ +function QuickStartSection({ + onQuickStart, + hasSampleData, + onClearSampleData, + isQuickStartLoading = false, +}: { + onQuickStart: () => void; + hasSampleData: boolean; + onClearSampleData: () => void; + isQuickStartLoading?: boolean; +}) { + return ( +
+

+

+

+ Want to see the board in action? We can add some sample tasks to demonstrate the workflow. +

+
+ + {hasSampleData && ( + + )} +
+
+ ); +} export function BoardOnboardingWizard({ isVisible, @@ -108,571 +107,31 @@ export function BoardOnboardingWizard({ hasSampleData, onClearSampleData, isQuickStartLoading = false, + steps, }: BoardOnboardingWizardProps) { - // Store rect as simple object to avoid DOMRect type issues - const [highlightRect, setHighlightRect] = useState<{ - top: number; - left: number; - right: number; - bottom: number; - width: number; - height: number; - } | null>(null); - const [tooltipPosition, setTooltipPosition] = useState<'left' | 'right' | 'bottom'>('bottom'); - const [isAnimating, setIsAnimating] = useState(false); - const [showCompletionCelebration, setShowCompletionCelebration] = useState(false); - - // Refs for focus management - const dialogRef = useRef(null); - const nextButtonRef = useRef(null); - - // Detect if user is on a touch device - const [isTouchDevice, setIsTouchDevice] = useState(false); - - useEffect(() => { - setIsTouchDevice('ontouchstart' in window || navigator.maxTouchPoints > 0); - }, []); - - // Lock scroll when wizard is visible - useEffect(() => { - if (!isVisible) return; - - // Prevent body scroll while wizard is open - const originalOverflow = document.body.style.overflow; - document.body.style.overflow = 'hidden'; - - return () => { - document.body.style.overflow = originalOverflow; - }; - }, [isVisible]); - - // Focus management - move focus to dialog when opened - useEffect(() => { - if (!isVisible) return; - - // Focus the next button when wizard opens for keyboard accessibility - const timer = setTimeout(() => { - nextButtonRef.current?.focus(); - }, STEP_TRANSITION_DURATION); - - return () => clearTimeout(timer); - }, [isVisible]); - - // Animate step transitions - useEffect(() => { - if (!isVisible) return; - - setIsAnimating(true); - const timer = setTimeout(() => { - setIsAnimating(false); - }, STEP_TRANSITION_DURATION); - - return () => clearTimeout(timer); - }, [currentStep, isVisible]); - - // Find and highlight the current column - useEffect(() => { - if (!isVisible || !currentStepData) { - setHighlightRect(null); - return; - } - - // Helper to update highlight rect and tooltip position - const updateHighlight = () => { - const columnEl = document.querySelector( - `[data-testid="kanban-column-${currentStepData.columnId}"]` - ); - - if (columnEl) { - const rect = columnEl.getBoundingClientRect(); - setHighlightRect({ - top: rect.top, - left: rect.left, - right: rect.right, - bottom: rect.bottom, - width: rect.width, - height: rect.height, - }); - - // Determine tooltip position based on column position and available space - const viewportWidth = window.innerWidth; - const viewportHeight = window.innerHeight; - const columnCenter = rect.left + rect.width / 2; - const tooltipWidth = Math.min(TOOLTIP_MAX_WIDTH, viewportWidth - VIEWPORT_SAFE_MARGIN * 2); - - // Check if there's enough space at the bottom - const spaceAtBottom = viewportHeight - rect.bottom - TOOLTIP_OFFSET; - const spaceAtRight = viewportWidth - rect.right - TOOLTIP_OFFSET; - const spaceAtLeft = rect.left - TOOLTIP_OFFSET; - - // For leftmost columns, prefer right position - if ( - columnCenter < viewportWidth * TOOLTIP_POSITION_RIGHT_THRESHOLD && - spaceAtRight >= tooltipWidth - ) { - setTooltipPosition('right'); - } - // For rightmost columns, prefer left position - else if ( - columnCenter > viewportWidth * TOOLTIP_POSITION_LEFT_THRESHOLD && - spaceAtLeft >= tooltipWidth - ) { - setTooltipPosition('left'); - } - // For middle columns, check if bottom position would work - else if (spaceAtBottom >= BOTTOM_THRESHOLD) { - setTooltipPosition('bottom'); - } - // If bottom doesn't have enough space, try left or right based on which has more space - else if (spaceAtRight > spaceAtLeft && spaceAtRight >= tooltipWidth * 0.6) { - setTooltipPosition('right'); - } else if (spaceAtLeft >= tooltipWidth * 0.6) { - setTooltipPosition('left'); - } - // Fallback to bottom with scrollable content - else { - setTooltipPosition('bottom'); - } - } - }; - - // Initial update - updateHighlight(); - - // Debounced resize handler for performance - let resizeTimeout: ReturnType; - const handleResize = () => { - clearTimeout(resizeTimeout); - resizeTimeout = setTimeout(updateHighlight, RESIZE_DEBOUNCE_MS); - }; - - window.addEventListener('resize', handleResize); - return () => { - window.removeEventListener('resize', handleResize); - clearTimeout(resizeTimeout); - }; - }, [isVisible, currentStepData]); - - // Keyboard navigation - useEffect(() => { - if (!isVisible) return; - - const handleKeyDown = (e: KeyboardEvent) => { - if (e.key === 'Escape') { - onSkip(); - } else if (e.key === 'ArrowRight' || e.key === 'Enter') { - if (currentStep < totalSteps - 1) { - onNext(); - } else { - onComplete(); - } - } else if (e.key === 'ArrowLeft') { - onPrevious(); - } - }; - - window.addEventListener('keydown', handleKeyDown); - return () => window.removeEventListener('keydown', handleKeyDown); - }, [isVisible, currentStep, totalSteps, onNext, onPrevious, onSkip, onComplete]); - - // Calculate tooltip styles based on position and highlight rect - const getTooltipStyles = useCallback((): React.CSSProperties => { - if (!highlightRect) return {}; - - const viewportHeight = window.innerHeight; - const viewportWidth = window.innerWidth; - const tooltipWidth = Math.min(TOOLTIP_MAX_WIDTH, viewportWidth - VIEWPORT_SAFE_MARGIN * 2); - - switch (tooltipPosition) { - case 'right': { - const topPos = Math.max(VIEWPORT_SAFE_MARGIN, highlightRect.top + TOOLTIP_TOP_OFFSET); - const availableHeight = viewportHeight - topPos - VIEWPORT_SAFE_MARGIN; - return { - position: 'fixed', - top: topPos, - left: highlightRect.right + TOOLTIP_OFFSET, - width: tooltipWidth, - maxWidth: `calc(100vw - ${highlightRect.right + TOOLTIP_OFFSET * 2}px)`, - maxHeight: Math.max(200, availableHeight), - }; - } - case 'left': { - const topPos = Math.max(VIEWPORT_SAFE_MARGIN, highlightRect.top + TOOLTIP_TOP_OFFSET); - const availableHeight = viewportHeight - topPos - VIEWPORT_SAFE_MARGIN; - return { - position: 'fixed', - top: topPos, - right: viewportWidth - highlightRect.left + TOOLTIP_OFFSET, - width: tooltipWidth, - maxWidth: `calc(${highlightRect.left - TOOLTIP_OFFSET * 2}px)`, - maxHeight: Math.max(200, availableHeight), - }; - } - case 'bottom': - default: { - // Calculate available space at bottom - const idealTop = highlightRect.bottom + TOOLTIP_OFFSET; - const availableHeight = viewportHeight - idealTop - VIEWPORT_SAFE_MARGIN; - - // If not enough space, position higher but ensure tooltip stays below header - const minTop = 100; // Minimum distance from top of viewport - const topPos = - availableHeight < 250 - ? Math.max( - minTop, - viewportHeight - Math.max(300, availableHeight) - VIEWPORT_SAFE_MARGIN - ) - : idealTop; - - // Center tooltip under column but keep within viewport bounds - const idealLeft = highlightRect.left + highlightRect.width / 2 - tooltipWidth / 2; - const leftPos = Math.max( - VIEWPORT_SAFE_MARGIN, - Math.min(idealLeft, viewportWidth - tooltipWidth - VIEWPORT_SAFE_MARGIN) - ); - - return { - position: 'fixed', - top: topPos, - left: leftPos, - width: tooltipWidth, - maxHeight: Math.max(200, viewportHeight - topPos - VIEWPORT_SAFE_MARGIN), - }; - } - } - }, [highlightRect, tooltipPosition]); - - // Handle completion with celebration - const handleComplete = useCallback(() => { - setShowCompletionCelebration(true); - // Show celebration briefly before completing - setTimeout(() => { - setShowCompletionCelebration(false); - onComplete(); - }, 1200); - }, [onComplete]); - - // Handle step indicator click for direct navigation - const handleStepClick = useCallback( - (stepIndex: number) => { - if (stepIndex === currentStep) return; - - // Use onNext/onPrevious to properly track analytics - if (stepIndex > currentStep) { - for (let i = currentStep; i < stepIndex; i++) { - onNext(); - } - } else { - for (let i = currentStep; i > stepIndex; i--) { - onPrevious(); - } - } - }, - [currentStep, onNext, onPrevious] - ); - - if (!isVisible || !currentStepData) return null; - - const StepIcon = STEP_ICONS[currentStepData.id] || Sparkles; - const isLastStep = currentStep === totalSteps - 1; const isFirstStep = currentStep === 0; - const content = ( -
- {/* Completion celebration overlay */} - {showCompletionCelebration && ( -
-
- -

You're all set!

-
-
- )} - - {/* Dark overlay with cutout for highlighted column */} - - - {/* Highlight border around the column */} - {highlightRect && ( -
)} - - {/* Skip button - top right with accessible touch target */} - - - {/* Tooltip/Card with step content */} -
- {/* Header */} -
-
- -
-
-

- {currentStepData.title} -

-
- - Step {currentStep + 1} of {totalSteps} - - {/* Step indicators - clickable for navigation */} - -
-
-
- - {/* Description */} -

- {currentStepData.description} -

- - {/* Tip box */} - {currentStepData.tip && ( -
-

- Tip: - {currentStepData.tip} -

-
- )} - - {/* Quick Start section - only on first step */} - {isFirstStep && ( -
-

-

-

- Want to see the board in action? We can add some sample tasks to demonstrate the - workflow. -

-
- - {hasSampleData && ( - - )} -
-
- )} - - {/* Navigation buttons */} -
- - - -
- - {/* Keyboard hints - hidden on touch devices for cleaner mobile UX */} - {!isTouchDevice && ( - - )} -
-
+ ); - - // Render in a portal to ensure it's above everything - return createPortal(content, document.body); } diff --git a/apps/ui/src/components/views/board-view/components/kanban-column.tsx b/apps/ui/src/components/views/board-view/components/kanban-column.tsx index 4a1b62dd..0a9db2d1 100644 --- a/apps/ui/src/components/views/board-view/components/kanban-column.tsx +++ b/apps/ui/src/components/views/board-view/components/kanban-column.tsx @@ -50,6 +50,7 @@ export const KanbanColumn = memo(function KanbanColumn({ )} style={widthStyle} data-testid={`kanban-column-${id}`} + data-onboarding-target={id} > {/* Background layer with opacity */}
): void { logger.debug(`[Analytics] ${event}`, data); - // TODO: Integrate with actual analytics service (e.g., PostHog, Amplitude) - // Example: posthog.capture(event, data); } +// ============================================================================ +// HOOK +// ============================================================================ + export interface UseBoardOnboardingOptions { projectPath: string | null; - isEmpty: boolean; // Whether the board has no features - totalFeatureCount: number; // Total number of features in the board - /** Whether the spec generation dialog is currently open (prevents wizard from showing) */ - isSpecDialogOpen?: boolean; } export interface UseBoardOnboardingResult { - // Wizard visibility + // From shared wizard hook isWizardVisible: boolean; - shouldShowWizard: boolean; - - // Current step currentStep: number; - currentStepData: WizardStep | null; + currentStepData: OnboardingStep | null; totalSteps: number; - - // Navigation goToNextStep: () => void; goToPreviousStep: () => void; goToStep: (step: number) => void; - - // Actions startWizard: () => void; completeWizard: () => void; skipWizard: () => void; - dismissWizard: () => void; + isCompleted: boolean; + isSkipped: boolean; - // Quick Start / Sample Data + // Board-specific hasSampleData: boolean; setHasSampleData: (has: boolean) => void; markQuickStartUsed: () => void; - // Re-trigger - canRetrigger: boolean; - retriggerWizard: () => void; - - // State - isCompleted: boolean; - isSkipped: boolean; + // Steps data for component + steps: OnboardingStep[]; } export function useBoardOnboarding({ projectPath, - isEmpty, - totalFeatureCount, - isSpecDialogOpen = false, }: UseBoardOnboardingOptions): UseBoardOnboardingResult { - // Local state - const [currentStep, setCurrentStep] = useState(0); - const [isWizardActive, setIsWizardActive] = useState(false); - const [onboardingState, setOnboardingState] = useState(DEFAULT_ONBOARDING_STATE); + // Board-specific state for sample data + const [boardData, setBoardData] = useState(DEFAULT_BOARD_DATA); - // Load persisted state when project changes + // Create storage key from project path + const storageKey = projectPath ? `board:${sanitizeProjectPath(projectPath)}` : 'board:default'; + + // Use the shared onboarding wizard hook + const wizard = useOnboardingWizard({ + storageKey, + steps: BOARD_WIZARD_STEPS, + }); + + // Load board-specific data when project changes useEffect(() => { if (!projectPath) { - setOnboardingState(DEFAULT_ONBOARDING_STATE); + setBoardData(DEFAULT_BOARD_DATA); return; } - const state = loadOnboardingState(projectPath); - setOnboardingState(state); + const data = loadBoardData(projectPath); + setBoardData(data); + }, [projectPath]); - // Auto-show wizard for empty boards that haven't seen it - // Don't re-trigger if board became empty after having features (edge case) - // Don't show if spec dialog is open (for new projects) - if ( - isEmpty && - !state.hasEverSeenWizard && - !state.completed && - !state.skipped && - !isSpecDialogOpen - ) { - // Small delay to let the board render first - const timer = setTimeout(() => { - setIsWizardActive(true); - trackAnalytics(ONBOARDING_ANALYTICS.STARTED, { projectPath }); - }, WIZARD_AUTO_SHOW_DELAY_MS); - return () => clearTimeout(timer); - } - }, [projectPath, isEmpty, isSpecDialogOpen]); - - // Update persisted state helper - const updateState = useCallback( - (updates: Partial) => { + // Update board data helper + const updateBoardData = useCallback( + (updates: Partial) => { if (!projectPath) return; - setOnboardingState((prev) => { - const newState = { ...prev, ...updates }; - saveOnboardingState(projectPath, newState); - return newState; + setBoardData((prev) => { + const newData = { ...prev, ...updates }; + saveBoardData(projectPath, newData); + return newData; }); }, [projectPath] ); - // Determine if wizard should be visible - // Don't show if: - // - No project selected - // - Already completed or skipped - // - Board has features and user has seen wizard before (became empty after deletion) - const shouldShowWizard = useMemo(() => { - if (!projectPath) return false; - if (onboardingState.completed || onboardingState.skipped) return false; - if (!isEmpty && onboardingState.hasEverSeenWizard) return false; - return isEmpty && !onboardingState.hasEverSeenWizard; - }, [projectPath, isEmpty, onboardingState]); - - // Current step data - const currentStepData = WIZARD_STEPS[currentStep] || null; - const totalSteps = WIZARD_STEPS.length; - - // Navigation handlers - const goToNextStep = useCallback(() => { - if (currentStep < totalSteps - 1) { - const nextStep = currentStep + 1; - setCurrentStep(nextStep); - trackAnalytics(ONBOARDING_ANALYTICS.STEP_VIEWED, { - step: nextStep, - stepId: WIZARD_STEPS[nextStep]?.id, - projectPath, - }); - } - }, [currentStep, totalSteps, projectPath]); - - const goToPreviousStep = useCallback(() => { - if (currentStep > 0) { - setCurrentStep(currentStep - 1); - } - }, [currentStep]); - - const goToStep = useCallback( - (step: number) => { - if (step >= 0 && step < totalSteps) { - setCurrentStep(step); - trackAnalytics(ONBOARDING_ANALYTICS.STEP_VIEWED, { - step, - stepId: WIZARD_STEPS[step]?.id, - projectPath, - }); - } - }, - [totalSteps, projectPath] - ); - - // Wizard lifecycle handlers - const startWizard = useCallback(() => { - setCurrentStep(0); - setIsWizardActive(true); - updateState({ hasEverSeenWizard: true }); - trackAnalytics(ONBOARDING_ANALYTICS.STARTED, { projectPath }); - }, [projectPath, updateState]); - - const completeWizard = useCallback(() => { - setIsWizardActive(false); - setCurrentStep(0); - updateState({ - completed: true, - completedAt: new Date().toISOString(), - hasEverSeenWizard: true, - }); - trackAnalytics(ONBOARDING_ANALYTICS.COMPLETED, { - projectPath, - quickStartUsed: onboardingState.quickStartUsed, - totalFeatureCount, - }); - }, [projectPath, updateState, onboardingState.quickStartUsed, totalFeatureCount]); - - const skipWizard = useCallback(() => { - setIsWizardActive(false); - setCurrentStep(0); - updateState({ - skipped: true, - skippedAt: new Date().toISOString(), - hasEverSeenWizard: true, - }); - trackAnalytics(ONBOARDING_ANALYTICS.SKIPPED, { - projectPath, - skippedAtStep: currentStep, - }); - }, [projectPath, currentStep, updateState]); - - const dismissWizard = useCallback(() => { - // Same as skip but doesn't mark as "skipped" - just closes the wizard - setIsWizardActive(false); - updateState({ hasEverSeenWizard: true }); - }, [updateState]); - - // Quick Start / Sample Data + // Sample data handlers const setHasSampleData = useCallback( (has: boolean) => { - updateState({ hasSampleData: has }); + updateBoardData({ hasSampleData: has }); if (!has) { - trackAnalytics(ONBOARDING_ANALYTICS.SAMPLE_DATA_CLEARED, { projectPath }); + trackAnalytics(BOARD_ONBOARDING_ANALYTICS.SAMPLE_DATA_CLEARED, { projectPath }); } }, - [projectPath, updateState] + [projectPath, updateBoardData] ); const markQuickStartUsed = useCallback(() => { - updateState({ quickStartUsed: true, hasSampleData: true }); - trackAnalytics(ONBOARDING_ANALYTICS.QUICK_START_USED, { projectPath }); - }, [projectPath, updateState]); - - // Re-trigger wizard - memoized for stable reference - const canRetrigger = useMemo( - () => onboardingState.completed || onboardingState.skipped, - [onboardingState.completed, onboardingState.skipped] - ); - - const retriggerWizard = useCallback(() => { - setCurrentStep(0); - setIsWizardActive(true); - // Don't reset completion status, just show wizard again - trackAnalytics(ONBOARDING_ANALYTICS.RETRIGGERED, { projectPath }); - }, [projectPath]); + updateBoardData({ quickStartUsed: true, hasSampleData: true }); + trackAnalytics(BOARD_ONBOARDING_ANALYTICS.QUICK_START_USED, { projectPath }); + }, [projectPath, updateBoardData]); return { - // Visibility - isWizardVisible: isWizardActive, - shouldShowWizard, + // Spread shared wizard state and actions + isWizardVisible: wizard.isVisible, + currentStep: wizard.currentStep, + currentStepData: wizard.currentStepData, + totalSteps: wizard.totalSteps, + goToNextStep: wizard.goToNextStep, + goToPreviousStep: wizard.goToPreviousStep, + goToStep: wizard.goToStep, + startWizard: wizard.startWizard, + completeWizard: wizard.completeWizard, + skipWizard: wizard.skipWizard, + isCompleted: wizard.isCompleted, + isSkipped: wizard.isSkipped, - // Steps - currentStep, - currentStepData, - totalSteps, - - // Navigation - goToNextStep, - goToPreviousStep, - goToStep, - - // Actions - startWizard, - completeWizard, - skipWizard, - dismissWizard, - - // Sample Data - hasSampleData: onboardingState.hasSampleData, + // Board-specific + hasSampleData: boardData.hasSampleData, setHasSampleData, markQuickStartUsed, - // Re-trigger - canRetrigger, - retriggerWizard, - - // State - isCompleted: onboardingState.completed, - isSkipped: onboardingState.skipped, + // Steps data + steps: BOARD_WIZARD_STEPS, }; } diff --git a/apps/ui/src/components/views/board-view/kanban-board.tsx b/apps/ui/src/components/views/board-view/kanban-board.tsx index 4601a70c..ca055d53 100644 --- a/apps/ui/src/components/views/board-view/kanban-board.tsx +++ b/apps/ui/src/components/views/board-view/kanban-board.tsx @@ -173,6 +173,7 @@ export function KanbanBoard({ onClick={onOpenPipelineSettings} title="Pipeline Settings" data-testid="pipeline-settings-button" + data-onboarding-target="pipeline-settings" >