@handit.ai/onboarding
Version:
Interactive onboarding components and service for AI agents
1,353 lines (1,172 loc) • 57.7 kB
JavaScript
import React, { useCallback, useEffect, useRef, useState } from 'react';
import {
Box,
Button,
Card,
Dialog,
DialogContent,
FormControl,
FormControlLabel,
Radio,
RadioGroup,
Stack,
TextField,
Typography,
IconButton,
Tooltip,
} from '@mui/material';
import { X, Copy } from '@phosphor-icons/react';
import onboardingService from '../services/onboardingService';
import { OnboardingAssistant, OnboardingMenu, useInvisibleMouse, useOnboardingBanners } from './index';
const OnboardingOrchestrator = ({
autoStart = false,
triggerOnMount = true,
userState = {},
enableAutomaticStart = true,
isLoadingAutomaticStart = false,
onComplete = () => {},
onSkip = () => {},
updateOnboardingProgress = () => {},
config = null,
}) => {
// Core state
const [isActive, setIsActive] = useState(false);
const [menuOpen, setMenuOpen] = useState(false);
const [currentStep, setCurrentStep] = useState(null);
const [tourInfo, setTourInfo] = useState(null);
const [formData, setFormData] = useState({});
// Component states
const [assistantVisible, setAssistantVisible] = useState(false);
const [chatIsOpen, setChatIsOpen] = useState(false); // Track chat state
const banners = useOnboardingBanners();
const mouse = useInvisibleMouse();
// Ref to track if we've already started onboarding for a new user
const hasStartedNewUserOnboarding = useRef(false);
// Trigger highlighting when mouse targets a menu item
const highlightMenuItem = (menuTitle) => {
window.dispatchEvent(
new CustomEvent('onboardingMouseTarget', {
detail: { menuTitle },
})
);
};
// Remove highlighting when mouse leaves a menu item
const unhighlightMenuItem = () => {
window.dispatchEvent(new CustomEvent('onboardingMouseLeave'));
};
// Tour completion handler
const handleTourComplete = useCallback(() => {
setIsActive(false);
setMenuOpen(false);
setCurrentStep(null);
setTourInfo(null);
setAssistantVisible(false);
// Remove any highlighting
unhighlightMenuItem();
// Hide mouse and banners
mouse.hideMouse();
banners.hideAllBanners();
// Clear global onboarding flag and localStorage
window.__onboardingActive = false;
localStorage.removeItem('onboardingState');
window.dispatchEvent(
new CustomEvent('onboardingStateChange', {
detail: { active: false },
})
);
onComplete();
}, [mouse, banners, onComplete]);
// Helper function to handle tour completion with next tour checking
const handleTourEndWithNextTourCheck = useCallback(
(forceNextTour = false) => {
// Check if there are more steps in current tour
if (onboardingService.getCurrentStep() && !forceNextTour) {
// Still have steps, don't complete
return;
}
// No more steps in current tour, check if there's a next tour
const currentTourId = tourInfo?.tourId;
let nextTourId = null;
// Define the tour progression order
const tourOrder = onboardingService.getTourOrder();
nextTourId = tourOrder[currentTourId];
window.dispatchEvent(
new CustomEvent('onboarding:change-tour', {
detail: { tourId: nextTourId },
})
);
if (nextTourId) {
// There's a next tour, advance to it
onboardingService.transitionTour();
const nextStep = onboardingService.startTour(nextTourId);
if (nextStep) {
setCurrentStep(nextStep);
const tourInfo = onboardingService.getCurrentTourInfo();
setTourInfo(tourInfo);
// Show assistant if tour settings specify it
if (tourInfo?.settings?.showAssistant) {
setAssistantVisible(true);
}
// Update user's onboarding progress in database
updateOnboardingProgress(nextTourId);
// Update localStorage with new tour state
const onboardingState = {
isActive: true,
tourId: nextTourId,
currentStepId: nextStep.id,
assistantVisible: tourInfo?.settings?.showAssistant || false,
};
localStorage.setItem('onboardingState', JSON.stringify(onboardingState));
}
} else {
// No more tours, complete all tours
handleTourComplete();
}
},
[tourInfo, handleTourComplete, updateOnboardingProgress]
);
// Tour skip handler
const handleTourSkip = useCallback(() => {
setIsActive(false);
setMenuOpen(false);
setCurrentStep(null);
setTourInfo(null);
setAssistantVisible(false);
// Remove any highlighting
unhighlightMenuItem();
// Hide mouse and banners
mouse.hideMouse();
banners.hideAllBanners();
// Clear global onboarding flag and localStorage
window.__onboardingActive = false;
localStorage.removeItem('onboardingState');
window.dispatchEvent(
new CustomEvent('onboardingStateChange', {
detail: { active: false },
})
);
onSkip();
}, [mouse, banners, onSkip]);
// Start onboarding flow
const startOnboarding = useCallback((tourId = onboardingService.getInitialTourId()) => {
const step = onboardingService.startTour(tourId);
if (step) {
setCurrentStep(step);
const tourInfo = onboardingService.getCurrentTourInfo();
setTourInfo(tourInfo);
setIsActive(true);
// Show assistant if tour settings specify it
if (tourInfo?.settings?.showAssistant) {
setAssistantVisible(true);
}
// Update user's onboarding progress in database
updateOnboardingProgress(tourId);
// Persist onboarding state to localStorage
const onboardingState = {
isActive: true,
tourId: tourId,
currentStepId: step.id,
assistantVisible: tourInfo?.settings?.showAssistant || false,
};
localStorage.setItem('onboardingState', JSON.stringify(onboardingState));
// Set global onboarding flag for layout components
window.__onboardingActive = true;
window.dispatchEvent(
new CustomEvent('onboardingStateChange', {
detail: { active: true },
})
);
}
}, []);
// Persist state when step changes
useEffect(() => {
if (isActive && currentStep) {
const onboardingState = {
isActive: true,
tourId: tourInfo?.tourId,
currentStepId: currentStep.id,
assistantVisible: assistantVisible,
};
localStorage.setItem('onboardingState', JSON.stringify(onboardingState));
}
}, [isActive, currentStep, tourInfo, assistantVisible]);
// Restore onboarding state on page load
useEffect(() => {
const savedState = localStorage.getItem('onboardingState');
if (savedState && !isActive) {
try {
const state = JSON.parse(savedState);
if (state.isActive) {
// Add a small delay to ensure the page is fully loaded
setTimeout(() => {
// Restore the tour from the saved step
const step = onboardingService.startTour(state.tourId);
if (step) {
// Navigate to the correct step
let currentStepInService = step;
while (currentStepInService && currentStepInService.id !== state.currentStepId) {
onboardingService.nextStep();
currentStepInService = onboardingService.getCurrentStep();
if (!currentStepInService || currentStepInService.id === step.id) break;
}
const restoredStep = onboardingService.getCurrentStep();
if (restoredStep) {
setCurrentStep(restoredStep);
setTourInfo(onboardingService.getCurrentTourInfo());
setIsActive(true);
setAssistantVisible(state.assistantVisible);
// Set global flag
window.__onboardingActive = true;
window.dispatchEvent(
new CustomEvent('onboardingStateChange', {
detail: { active: true },
})
);
}
}
}, 500); // Small delay to ensure DOM is ready
}
} catch (error) {
console.error('Error restoring onboarding state:', error);
localStorage.removeItem('onboardingState');
}
}
}, []);
// Also check for state restoration when the component updates
useEffect(() => {
if (!isActive) {
const savedState = localStorage.getItem('onboardingState');
if (savedState) {
try {
const state = JSON.parse(savedState);
if (state.isActive && !isActive) {
// Trigger restoration
setTimeout(() => {
const step = onboardingService.getCurrentStep();
if (!step) {
// Service needs to be reinitialized
const restoredStep = onboardingService.startTour(state.tourId);
if (restoredStep) {
// Navigate to correct step
let currentStepInService = restoredStep;
while (currentStepInService && currentStepInService.id !== state.currentStepId) {
onboardingService.nextStep();
currentStepInService = onboardingService.getCurrentStep();
if (!currentStepInService) break;
}
const finalStep = onboardingService.getCurrentStep();
if (finalStep) {
setCurrentStep(finalStep);
setTourInfo(onboardingService.getCurrentTourInfo());
setIsActive(true);
setAssistantVisible(state.assistantVisible);
window.__onboardingActive = true;
window.dispatchEvent(
new CustomEvent('onboardingStateChange', {
detail: { active: true },
})
);
}
}
}
}, 100);
}
} catch (error) {
console.error('Error in state restoration check:', error);
}
}
}
}, [isActive]);
// Listen for chat open/close events to hide/show onboarding elements
useEffect(() => {
const handleChatOpened = () => {
setChatIsOpen(true);
// Hide all banners when chat opens
banners.hideAllBanners();
};
const handleChatClosed = () => {
setChatIsOpen(false);
// Banners will be restored automatically by the onboarding flow
// Check if we should advance to next step when chat closes
if (currentStep?.id === 'open-evaluators-chat') {
// Advance to next step
onboardingService.nextStep();
const nextStep = onboardingService.getCurrentStep();
if (nextStep) {
// Update state and localStorage for the next step
setCurrentStep(nextStep);
setTourInfo(onboardingService.getCurrentTourInfo());
const onboardingState = {
isActive: true,
tourId: tourInfo?.tourId,
currentStepId: nextStep.id,
assistantVisible: assistantVisible,
};
localStorage.setItem('onboardingState', JSON.stringify(onboardingState));
} else {
// No more steps, complete tour
handleTourEndWithNextTourCheck();
}
}
};
const handleEvaluatorsDetected = () => {
// Check if onboarding is currently active
if (isActive && currentStep) {
// Small delay to show the message, then auto-close chat and advance
setTimeout(() => {
// Tell the chat component to close itself
window.dispatchEvent(new CustomEvent('onboarding:close-chat'));
}, 2000); // 2 second delay to let user see the confirmation
}
};
window.addEventListener('onboarding:chat-opened', handleChatOpened);
window.addEventListener('onboarding:chat-closed', handleChatClosed);
window.addEventListener('onboarding:evaluators-detected', handleEvaluatorsDetected);
return () => {
window.removeEventListener('onboarding:chat-opened', handleChatOpened);
window.removeEventListener('onboarding:chat-closed', handleChatClosed);
window.removeEventListener('onboarding:evaluators-detected', handleEvaluatorsDetected);
};
}, [banners, currentStep, tourInfo, assistantVisible, handleTourEndWithNextTourCheck, isActive]);
// Global flag to force navigation open only when assistant is visible
useEffect(() => {
if (typeof window !== 'undefined') {
const showAssistant = assistantVisible || (currentStep && isActive);
window.__onboardingActive = showAssistant;
// Trigger a custom event so layout can listen for changes
window.dispatchEvent(
new CustomEvent('onboardingStateChange', {
detail: { active: showAssistant },
})
);
}
return () => {
if (typeof window !== 'undefined') {
window.__onboardingActive = false;
window.dispatchEvent(
new CustomEvent('onboardingStateChange', {
detail: { active: false },
})
);
}
};
}, [assistantVisible, currentStep, isActive]);
// Handle connection success event
const handleConnectionSuccess = useCallback(
(event) => {
console.log('handleConnectionSuccess', event);
console.log('currentStep', currentStep);
// Check if we should advance: either on the test-connection-button step or on docs page during onboarding
const shouldAdvance = (
currentStep?.id === 'test-connection-button' ||
(window.location.pathname === '/docs' && currentStep && event.detail?.success)
) && event.detail?.success;
if (shouldAdvance) {
// Remove highlighting
console.log('unhighlightMenuItem');
unhighlightMenuItem();
// Hide instruction banners
banners.hideAllBanners();
// Advance to next step
onboardingService.nextStep();
const nextStep = onboardingService.getCurrentStep();
if (nextStep) {
// Update state and localStorage for the next step
setCurrentStep(nextStep);
setTourInfo(onboardingService.getCurrentTourInfo());
const onboardingState = {
isActive: true,
tourId: tourInfo?.tourId,
currentStepId: nextStep.id,
assistantVisible: assistantVisible,
};
localStorage.setItem('onboardingState', JSON.stringify(onboardingState));
} else {
// No more steps, complete tour
handleTourEndWithNextTourCheck();
}
}
},
[currentStep, tourInfo, assistantVisible, banners, handleTourEndWithNextTourCheck]
);
// Handle loading state changes to prevent banner re-renders
const handleLoadingStateChange = useCallback((event) => {
// Don't re-render banners during loading states
if (event.detail?.loading) {
// Prevent banner updates during loading
return;
}
}, []);
// Handle step change events
const handleStepChanged = useCallback((event) => {
const { step } = event.detail;
if (step) {
setCurrentStep(step);
setTourInfo(onboardingService.getCurrentTourInfo());
// Update localStorage with new step
const onboardingState = {
isActive: true,
tourId: tourInfo?.id || onboardingService.getCurrentTourInfo()?.id,
currentStepId: step.id,
assistantVisible: assistantVisible,
};
localStorage.setItem('onboardingState', JSON.stringify(onboardingState));
}
}, [tourInfo, assistantVisible]);
// Initialize service
useEffect(() => {
// Create a new service instance with the provided config
const serviceInstance = new onboardingService.constructor(null, config);
serviceInstance.init(userState);
// Replace the global service instance
Object.assign(onboardingService, serviceInstance);
// Listen for tour events
onboardingService.on('tourCompleted', handleTourEndWithNextTourCheck);
onboardingService.on('tourSkipped', handleTourSkip);
// Listen for onboarding menu trigger from sidebar
const handleOpenOnboardingMenu = () => {
setIsActive(true);
setMenuOpen(true);
};
window.addEventListener('openOnboardingMenu', handleOpenOnboardingMenu);
window.addEventListener('onboarding:connection-success', handleConnectionSuccess);
window.addEventListener('onboarding:loading-state-change', handleLoadingStateChange);
window.addEventListener('onboarding:step-changed', handleStepChanged);
// Check if user is new (onboardingCurrentTour is null) and start onboarding immediately
// Only start if we haven't already started onboarding for this new user
// AND automatic start is enabled (passed from parent to determine business logic)
if (userState.onboardingCurrentTour === null && !hasStartedNewUserOnboarding.current && enableAutomaticStart && !isLoadingAutomaticStart) {
hasStartedNewUserOnboarding.current = true;
startOnboarding(onboardingService.getInitialTourId());
new CustomEvent('onboarding:start-tour', {
detail: { tourId: onboardingService.getInitialTourId() },
})
return;
}
// Auto-trigger if enabled
if (triggerOnMount) {
const suggestedTour = onboardingService.checkTriggers();
if (suggestedTour && autoStart) {
startOnboarding();
return;
}
}
// Direct start if autoStart is true (bypass triggers entirely)
if (autoStart) {
startOnboarding();
}
return () => {
// Cleanup listeners
window.removeEventListener('openOnboardingMenu', handleOpenOnboardingMenu);
window.removeEventListener('onboarding:connection-success', handleConnectionSuccess);
window.removeEventListener('onboarding:loading-state-change', handleLoadingStateChange);
window.removeEventListener('onboarding:step-changed', handleStepChanged);
};
}, [userState, config, autoStart, triggerOnMount, enableAutomaticStart, isLoadingAutomaticStart, handleConnectionSuccess, handleLoadingStateChange, handleStepChanged]);
// Navigation functions
const handleNext = useCallback(() => {
// Clear any existing banners and highlighting before advancing
banners.hideAllBanners();
unhighlightMenuItem();
onboardingService.nextStep();
setCurrentStep(onboardingService.getCurrentStep());
setTourInfo(onboardingService.getCurrentTourInfo());
// Use the helper function to handle tour completion with next tour checking
handleTourEndWithNextTourCheck();
}, [banners, handleTourEndWithNextTourCheck]);
const handlePrevious = useCallback(() => {
// Clear any existing banners and highlighting before going back
banners.hideAllBanners();
unhighlightMenuItem();
onboardingService.previousStep();
setCurrentStep(onboardingService.getCurrentStep());
setTourInfo(onboardingService.getCurrentTourInfo());
}, [banners]);
const handleSkip = useCallback(() => {
onboardingService.skipTour('user_skip');
handleTourSkip();
}, [handleTourSkip]);
const handleFinish = useCallback(() => {
//onboardingService.completeTour('tour_complete');
handleTourEndWithNextTourCheck(true);
}, [handleTourEndWithNextTourCheck]);
// Form handling
const handleFormSubmit = useCallback(
(stepId, data) => {
const nextStep = onboardingService.submitForm(stepId, data);
setCurrentStep(nextStep);
setTourInfo(onboardingService.getCurrentTourInfo());
setFormData({ ...formData, ...data });
},
[formData]
);
// Execute cursor guidance when a step changes
const executeCursorGuidance = useCallback((step) => {
if (!step.cursorGuidance?.enabled) return;
const guidance = step.cursorGuidance;
let currentStepIndex = 0;
const executeGuidanceStep = () => {
if (currentStepIndex >= guidance.steps.length) {
return;
}
const guidanceStep = guidance.steps[currentStepIndex];
// Find the target element
const targetElement = findTargetElement(guidanceStep.target, guidanceStep.targetText);
if (!targetElement) {
console.warn('Target element not found for guidance step:', guidanceStep);
return;
}
// Remove highlighting from previous target
if (currentStepIndex > 0) {
unhighlightMenuItem();
}
// Execute smooth cursor animation to target (from current position)
const mousePosition = mouse.animateToElement(guidanceStep.target, {
duration: 2000,
onComplete: () => {
// Highlight the target menu item when mouse reaches it
const menuTitle = targetElement.getAttribute('data-nav-item');
if (menuTitle) {
highlightMenuItem(menuTitle);
}
},
});
if (mousePosition) {
// Show instruction banner after animation completes
if (guidanceStep.instruction) {
setTimeout(() => {
const rect = targetElement.getBoundingClientRect();
const position = calculateBannerPosition(rect, guidanceStep.instruction.position);
banners.showBanner({
title: guidanceStep.instruction.title,
message: guidanceStep.instruction.description,
position,
variant: 'info',
autoHide: guidanceStep.instruction.actions ? false : true,
autoHideDelay: guidanceStep.instruction.actions ? 0 : 12000,
showCloseButton: false,
actions: guidanceStep.instruction.actions?.map((action) => ({
text: action.text,
type: action.type,
onClick: () => {
// Hide current banner immediately when any action is clicked
banners.hideAllBanners();
console.log('action', action);
if (action.action === 'nextStep') {
// Handle special case for closing connect dialog
if (currentStep?.id === 'close-connect-dialog') {
// Dispatch event to close connect dialog
window.dispatchEvent(new CustomEvent('onboarding:close-connect-dialog'));
// Small delay to allow dialog to close before advancing
setTimeout(() => {
onboardingService.nextStep();
setCurrentStep(onboardingService.getCurrentStep());
setTourInfo(onboardingService.getCurrentTourInfo());
if (!onboardingService.getCurrentStep()) {
updateOnboardingProgress(action.nextTourId);
handleTourEndWithNextTourCheck();
}
}, 500);
} else {
// Directly advance step without causing re-renders
onboardingService.nextStep();
setCurrentStep(onboardingService.getCurrentStep());
setTourInfo(onboardingService.getCurrentTourInfo());
if (!onboardingService.getCurrentStep()) {
updateOnboardingProgress(action.nextTourId);
handleTourEndWithNextTourCheck();
}
}
} else if (action.action === 'skipTour') {
onboardingService.skipTour('user_skip');
handleTourSkip();
} else if (action.action === 'nextTour') {
// Use transition method to avoid emitting completion event
onboardingService.transitionTour();
const nextStep = onboardingService.startTour(action.nextTourId);
console.log('nextStep', nextStep);
console.log('action.nextTourId', action);
if (nextStep) {
setCurrentStep(nextStep);
const tourInfo = onboardingService.getCurrentTourInfo();
setTourInfo(tourInfo);
console.log('action.nextTourId', action.nextTourId);
window.dispatchEvent(
new CustomEvent('onboarding:change-tour', {
detail: { tourId: action.nextTourId },
})
);
// Show assistant if tour settings specify it
if (tourInfo?.settings?.showAssistant) {
setAssistantVisible(true);
}
// Update localStorage with new tour state
const onboardingState = {
isActive: true,
tourId: action.nextTourId,
currentStepId: nextStep.id,
assistantVisible: tourInfo?.settings?.showAssistant || false,
};
localStorage.setItem('onboardingState', JSON.stringify(onboardingState));
}
} else if (action.action === 'finishTour') {
onboardingService.completeTour('tour_complete');
handleTourEndWithNextTourCheck();
}
},
})),
});
}, 1800); // Reduced delay for faster appearance
}
// Move to next step after delay
setTimeout(() => {
currentStepIndex++;
executeGuidanceStep();
}, guidance.steps[currentStepIndex].delay || 100);
}
};
// Start guidance execution after initial delay
setTimeout(executeGuidanceStep, guidance.delay || 500);
}, []);
// Helper function to find target elements
const findTargetElement = (selector, targetText) => {
if (targetText) {
// Find element containing specific text
const elements = document.querySelectorAll(selector);
for (let element of elements) {
if (element.textContent?.includes(targetText)) {
return element;
}
}
}
const element = document.querySelector(selector);
return element;
};
// Helper function to calculate banner position
const calculateBannerPosition = (elementRect, position) => {
const offset = 20;
switch (position) {
case 'right':
return { top: elementRect.top, left: elementRect.right + offset };
case 'left':
return { top: elementRect.top, left: elementRect.left - 300 - offset };
case 'bottom':
return { top: elementRect.bottom + offset, left: elementRect.left };
case 'top':
return { top: elementRect.top - 120 - offset, left: elementRect.left };
default:
return { top: elementRect.top, left: elementRect.right + offset };
}
};
// Execute cursor guidance when step changes
useEffect(() => {
if (currentStep && currentStep.type === 'cursor-only') {
// Handle waitForElement if specified
if (currentStep.waitForElement) {
const checkForElement = () => {
const element = document.querySelector(currentStep.waitForElement.target);
if (element) {
// Element found, proceed with the step
// Handle scrollIntoView if specified
if (currentStep.scrollIntoView) {
const targetElement = document.querySelector(currentStep.scrollIntoView.target);
if (targetElement) {
targetElement.scrollIntoView({
behavior: currentStep.scrollIntoView.behavior || 'smooth',
block: currentStep.scrollIntoView.block || 'center',
inline: currentStep.scrollIntoView.inline || 'nearest',
});
}
}
// Mouse will be shown during animation - no need to show immediately
executeCursorGuidance(currentStep);
} else {
// Element not found, check again after interval
setTimeout(checkForElement, currentStep.waitForElement.checkInterval || 1000);
}
};
// Start checking for element with timeout
const timeoutId = setTimeout(() => {
console.warn('waitForElement timeout reached for:', currentStep.waitForElement.target);
}, currentStep.waitForElement.timeout || 10000);
checkForElement();
return () => clearTimeout(timeoutId);
} else {
// Handle scrollIntoView if specified
if (currentStep.scrollIntoView) {
const targetElement = document.querySelector(currentStep.scrollIntoView.target);
if (targetElement) {
targetElement.scrollIntoView({
behavior: currentStep.scrollIntoView.behavior || 'smooth',
block: currentStep.scrollIntoView.block || 'center',
inline: currentStep.scrollIntoView.inline || 'nearest',
});
}
}
// Mouse will be shown during animation - no need to show immediately
executeCursorGuidance(currentStep);
}
}
}, [currentStep, executeCursorGuidance]);
// Set up click listeners for advanceOnClick targets
useEffect(() => {
if (currentStep && currentStep.advanceOnClick) {
const handleTargetClick = (event) => {
const target = event.target.closest(currentStep.advanceOnClick.target);
if (target) {
// Remove highlighting when user clicks (mouse will move to next target)
unhighlightMenuItem();
// Hide instruction banners when user clicks menu item (but keep center banners)
banners.hideAllBanners();
// Add delay if specified
const delay = currentStep.advanceDelay || 0;
setTimeout(() => {
// Advance to next step BEFORE navigation happens
onboardingService.nextStep();
const nextStep = onboardingService.getCurrentStep();
if (nextStep) {
// Update state and localStorage for the next step
setCurrentStep(nextStep);
setTourInfo(onboardingService.getCurrentTourInfo());
const onboardingState = {
isActive: true,
tourId: tourInfo?.tourId,
currentStepId: nextStep.id,
assistantVisible: assistantVisible,
};
localStorage.setItem('onboardingState', JSON.stringify(onboardingState));
} else {
// No more steps, complete tour
handleTourEndWithNextTourCheck();
}
}, delay);
}
};
// Add click listener to document
document.addEventListener('click', handleTargetClick);
// Cleanup
return () => {
document.removeEventListener('click', handleTargetClick);
};
}
}, [currentStep, banners, tourInfo, assistantVisible, handleTourEndWithNextTourCheck]);
// Set up change listeners for advanceOnChange targets
useEffect(() => {
if (currentStep && currentStep.advanceOnChange) {
const handleTargetChange = (event) => {
const target = event.target.closest(currentStep.advanceOnChange.target);
// Also check if the event target itself matches or if it's a child of the target
const directTarget = document.querySelector(currentStep.advanceOnChange.target);
const isWithinTarget = directTarget && (directTarget.contains(event.target) || event.target === directTarget);
if (target || isWithinTarget) {
// Remove highlighting when user makes selection
unhighlightMenuItem();
// Hide instruction banners
banners.hideAllBanners();
// Add delay if specified
const delay = currentStep.advanceDelay || 1000; // Default 1s delay for form changes
setTimeout(() => {
// Advance to next step
onboardingService.nextStep();
const nextStep = onboardingService.getCurrentStep();
if (nextStep) {
// Update state and localStorage for the next step
setCurrentStep(nextStep);
setTourInfo(onboardingService.getCurrentTourInfo());
const onboardingState = {
isActive: true,
tourId: tourInfo?.tourId,
currentStepId: nextStep.id,
assistantVisible: assistantVisible,
};
localStorage.setItem('onboardingState', JSON.stringify(onboardingState));
} else {
// No more steps, complete tour
handleTourEndWithNextTourCheck();
}
}, delay);
}
};
// Also try with input event for better MUI compatibility
const handleTargetInput = (event) => {
const target = event.target.closest(currentStep.advanceOnChange.target);
// Also check if the event target itself matches or if it's a child of the target
const directTarget = document.querySelector(currentStep.advanceOnChange.target);
const isWithinTarget = directTarget && (directTarget.contains(event.target) || event.target === directTarget);
if (target || isWithinTarget) {
// Remove highlighting when user makes selection
unhighlightMenuItem();
// Hide instruction banners
banners.hideAllBanners();
// Add delay if specified
const delay = currentStep.advanceDelay || 1000; // Default 1s delay for form changes
setTimeout(() => {
// Advance to next step
onboardingService.nextStep();
const nextStep = onboardingService.getCurrentStep();
if (nextStep) {
// Update state and localStorage for the next step
setCurrentStep(nextStep);
setTourInfo(onboardingService.getCurrentTourInfo());
const onboardingState = {
isActive: true,
tourId: tourInfo?.tourId,
currentStepId: nextStep.id,
assistantVisible: assistantVisible,
};
localStorage.setItem('onboardingState', JSON.stringify(onboardingState));
} else {
// No more steps, complete tour
handleTourEndWithNextTourCheck();
}
}, delay);
}
};
// MUI Select specific event handler
const handleMuiSelectChange = (event) => {
const target = event.target.closest(currentStep.advanceOnChange.target);
const directTarget = document.querySelector(currentStep.advanceOnChange.target);
const isWithinTarget = directTarget && (directTarget.contains(event.target) || event.target === directTarget);
if (target || isWithinTarget) {
// Remove highlighting when user makes selection
unhighlightMenuItem();
// Hide instruction banners
banners.hideAllBanners();
// Add delay if specified
const delay = currentStep.advanceDelay || 1000; // Default 1s delay for form changes
setTimeout(() => {
// Advance to next step
onboardingService.nextStep();
const nextStep = onboardingService.getCurrentStep();
if (nextStep) {
// Update state and localStorage for the next step
setCurrentStep(nextStep);
setTourInfo(onboardingService.getCurrentTourInfo());
const onboardingState = {
isActive: true,
tourId: tourInfo?.tourId,
currentStepId: nextStep.id,
assistantVisible: assistantVisible,
};
localStorage.setItem('onboardingState', JSON.stringify(onboardingState));
} else {
// No more steps, complete tour
handleTourEndWithNextTourCheck();
}
}, delay);
}
};
// Add multiple event listeners for better compatibility
document.addEventListener('change', handleTargetChange);
document.addEventListener('input', handleTargetInput);
// Also listen for click events on MUI Select options
document.addEventListener('click', handleMuiSelectChange);
// Cleanup
return () => {
document.removeEventListener('change', handleTargetChange);
document.removeEventListener('input', handleTargetInput);
document.removeEventListener('click', handleMuiSelectChange);
};
}
}, [currentStep, banners, tourInfo, assistantVisible, handleTourEndWithNextTourCheck]);
// Set up focus listeners for advanceOnFocus targets
useEffect(() => {
if (currentStep && currentStep.advanceOnFocus) {
const handleTargetFocus = (event) => {
const target = event.target.closest(currentStep.advanceOnFocus.target);
if (target) {
// Remove highlighting when user focuses input
unhighlightMenuItem();
// Hide instruction banners
banners.hideAllBanners();
// Add delay if specified
const delay = currentStep.advanceDelay || 500; // Default 0.5s delay for focus
setTimeout(() => {
// Advance to next step
onboardingService.nextStep();
const nextStep = onboardingService.getCurrentStep();
if (nextStep) {
// Update state and localStorage for the next step
setCurrentStep(nextStep);
setTourInfo(onboardingService.getCurrentTourInfo());
const onboardingState = {
isActive: true,
tourId: tourInfo?.tourId,
currentStepId: nextStep.id,
assistantVisible: assistantVisible,
};
localStorage.setItem('onboardingState', JSON.stringify(onboardingState));
} else {
// No more steps, complete tour
handleTourEndWithNextTourCheck();
}
}, delay);
}
};
// Add focus listener to document (with capture to catch events on all elements)
document.addEventListener('focus', handleTargetFocus, true);
// Cleanup
return () => {
document.removeEventListener('focus', handleTargetFocus, true);
};
}
}, [currentStep, banners, tourInfo, assistantVisible, handleTourEndWithNextTourCheck]);
// Handle banner-type steps
const [lastBannerStepId, setLastBannerStepId] = useState(null);
useEffect(() => {
if (currentStep && currentStep.type === 'banner' && currentStep.id !== lastBannerStepId) {
const position = calculateBannerPositionForPlacement(currentStep.placement);
banners.showBanner({
title: currentStep.content.heading,
message: currentStep.content.description,
position,
variant: currentStep.content.variant || 'info',
autoHide: currentStep.content.autoHide !== false,
autoHideDelay: currentStep.content.autoHideDelay || 10000,
showCloseButton: currentStep.content.showCloseButton !== false,
actions: currentStep.actions?.map((action) => ({
text: action.text,
type: action.type,
onClick: () => {
// Hide current banner immediately when any action is clicked
banners.hideAllBanners();
console.log('action', action);
if (action.action === 'nextStep') {
// Handle special case for closing connect dialog
if (currentStep?.id === 'close-connect-dialog') {
// Dispatch event to close connect dialog
window.dispatchEvent(new CustomEvent('onboarding:close-connect-dialog'));
// Small delay to allow dialog to close before advancing
setTimeout(() => {
onboardingService.nextStep();
setCurrentStep(onboardingService.getCurrentStep());
setTourInfo(onboardingService.getCurrentTourInfo());
if (!onboardingService.getCurrentStep()) {
handleTourEndWithNextTourCheck();
}
}, 500);
} else {
// Directly advance step without causing re-renders
onboardingService.nextStep();
setCurrentStep(onboardingService.getCurrentStep());
setTourInfo(onboardingService.getCurrentTourInfo());
if (!onboardingService.getCurrentStep()) {
handleTourEndWithNextTourCheck();
}
}
} else if (action.action === 'skipTour') {
onboardingService.skipTour('user_skip');
handleTourSkip();
} else if (action.action === 'nextTour') {
// Use transition method to avoid emitting completion event
onboardingService.transitionTour();
const nextStep = onboardingService.startTour(action.nextTourId);
window.dispatchEvent(
new CustomEvent('onboarding:change-tour', {
detail: { tourId: action.nextTourId },
})
);
if (nextStep) {
setCurrentStep(nextStep);
const tourInfo = onboardingService.getCurrentTourInfo();
setTourInfo(tourInfo);
// Show assistant if tour settings specify it
if (tourInfo?.settings?.showAssistant) {
setAssistantVisible(true);
}
// Update localStorage with new tour state
const onboardingState = {
isActive: true,
tourId: action.nextTourId,
currentStepId: nextStep.id,
assistantVisible: tourInfo?.settings?.showAssistant || false,
};
localStorage.setItem('onboardingState', JSON.stringify(onboardingState));
}
} else if (action.action === 'finishTour') {
onboardingService.completeTour('tour_complete');
handleTourEndWithNextTourCheck();
} else if (action.action === 'openChat') {
// Open chat with specified message
window.dispatchEvent(new CustomEvent('openOnboardingChat', {
detail: { mode: 'assistant', message: action.chatMessage || 'How can I help you?' }
}));
// Show additional banner if specified
if (action.showAdditionalBanner) {
const additionalPosition = calculateBannerPositionForPlacement(action.showAdditionalBanner.placement);
// Small delay to avoid banner collision with chat opening
setTimeout(() => {
banners.showBanner({
title: action.showAdditionalBanner.content.heading,
message: action.showAdditionalBanner.content.description,
position: additionalPosition,
variant: action.showAdditionalBanner.content.variant || 'info',
autoHide: action.showAdditionalBanner.content.autoHide !== false,
autoHideDelay: action.showAdditionalBanner.content.autoHideDelay || 10000,
showCloseButton: action.showAdditionalBanner.content.showCloseButton !== false,
icon: action.showAdditionalBanner.content.icon,
});
}, 1000); // Delay to let chat open first
}
} else if (action.action === 'apiCall') {
// Make API call
const makeApiCall = async () => {
try {
const token = localStorage.getItem('custom-auth-token');
const headers = {
'Content-Type': 'application/json',
};
if (token) {
headers['Authorization'] = `Bearer ${token}`;
}
const response = await fetch(`${process.env.NEXT_PUBLIC_API_URL}/${action.endpoint}`, {
method: action.method || 'GET',
headers,
body: action.method === 'POST' ? JSON.stringify({}) : undefined
});
if (response.ok) {
// API call successful, advance to next step
onboardingService.nextStep();
setCurrentStep(onboardingService.getCurrentStep());
setTourInfo(onboardingService.getCurrentTourInfo());
if (!onboardingService.getCurrentStep()) {
handleTourEndWithNextTourCheck();
}
} else {
console.error('API call failed:', response.status);
}
} catch (error) {
console.error('Error making API call:', error);
}
};
makeApiCall();
}
},
})),
icon: currentStep.content.icon,
});
setLastBannerStepId(currentStep.id);
}
}, [currentStep]);
// Handle navigation-type steps
useEffect(() => {
if (currentStep && currentStep.type === 'navigation') {
const navigation = currentStep.navigation;
if (navigation?.url) {
// Navigate to the specified URL
window.location.href = navigation.url;
// Auto-advance after navigation if specified
if (currentStep.autoAdvance && currentStep.duration) {
setTimeout(() => {
onboardingService.nextStep();
setCurrentStep(onboardingService.getCurrentStep());
setTourInfo(onboardingService.getCurrentTourInfo());
if (!onboardingService.getCurrentStep()) {
handleTourEndWithNextTourCheck();
}
}, currentStep.duration);
}
}
}
}, [currentStep]);
// Helper function to calculate banner position from placement
const calculateBannerPositionForPlacement = (placement) => {
switch (placement) {
case 'top-center':
return { top: 20, left: '50%', transform: 'translateX(-50%)' };
case 'top-left':
return { top: 20, left: 20 };
case 'top-right':
return { top: 20, right: 20 };
case 'bottom-center':
return { bottom: 20, left: '50%', transform: 'translateX(-50%)' };
case 'bottom-left':
return { bottom: 20, left: 20 };
case 'bottom-right':
return { bottom: 20, right: 20 };
case 'center':
return { top: '50%', left: '50%', transform: 'translate(-50%, -50%)' };
default:
return { top: 20, left: '50%', transform: 'translateX(-50%)' };
}
};
// Render current step content
const renderStepContent = () => {
if (!currentStep) return null;
switch (currentStep.type) {
case 'fullscreen-modal':
return <FullscreenModal step={currentStep} onNext={handleNext} onSkip={handleSkip} />;
case 'modal':
return (
<StepModal
step={currentStep}
onNext={handleNext}
onPrevious={handlePrevious}
onSkip={handleSkip}
onFormSubmit={handleFormSubmit}
/>
);
case 'banner':
// Banner is handled through banners system, no separate component needed
return null;
case 'cursor-only':
// Cursor guidance is handled in useEffect, no visual component needed
return null;
case 'tooltip':
return <StepTooltip step={currentStep} onNext={handleNext} onPrevious={handlePrevious} />;
default:
return null;
}
};
if (!isActive) {
return null;
}
return (
<>
{/* Main Menu */}
<OnboardingMenu
open={menuOpen}
onClose={() => {
setMenuOpen(false);
setIsActive(false);
}}
onStartTour={(tourId) => {
const step = onboardingService.startTour(tourId);
window.dispatchEvent(
new CustomEvent('onboarding:start-tour', {
detail: { tourId: tourId },
})
);
if (step) {
setCurrentStep(step);
const tourInfo = onboardingService.getCurrentTourInfo();
setTourInfo(tourInfo);
setMenuOpen(false);
// Show assistant if tour settings specify it
if (tourInfo?.settings?.showAssistant) {
setAssistantVisible(true);
}
// Update user's onboarding progress in database
updateOnboardingProgress(tourId);
}
}}
userOnboardingCurrentTour={userState.onboardingCurrentTour}
userCompletedTours={userState.completedTours || []}
/>
{/* Assistant - Always show during onboarding but hide when chat is open */}
{(assistantVisible || (currentStep && isActive)) && !chatIsOpen && (
<OnboardingAssistant
visible={true}
currentStep={tourInfo ? tourInfo.currentStep - 1 : 0}
totalSteps={tourInfo ? tourInfo.totalSteps : 0}
stepTitle={currentStep?.content?.heading || currentStep?.title || 'Onboarding Step'}
position="bottom-center"
onNext={handleNext}
onPrevious={handlePrevious}
onFinish={handleFinish}
/>
)}
{/* Step Content */}
{renderStepContent()}
{/* Banner Container - banners are hidden via hideAllBanners() when chat opens */}
<banne