Guide new users with an interactive tour highlighting key features
Implements an onboarding tour using React, TypeScript, and CSS to guide users. Replit-Commit-Author: Agent Replit-Commit-Session-Id: c5f0c281-8dd8-4846-b452-4a07bcd21062 Replit-Commit-Screenshot-Url: https://storage.googleapis.com/screenshot-production-us-central1/9777c70b-fc38-4831-8d6b-78dfffe041b0/44c8f610-b797-4c56-a2e5-1122aff07267.jpg
This commit is contained in:
@@ -0,0 +1,239 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { X, ChevronLeft, ChevronRight, Check } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
|
||||
export interface TourStep {
|
||||
id: string;
|
||||
target: string;
|
||||
title: string;
|
||||
content: string;
|
||||
position?: 'top' | 'bottom' | 'left' | 'right';
|
||||
action?: 'click' | 'hover' | 'none';
|
||||
optional?: boolean;
|
||||
}
|
||||
|
||||
interface OnboardingTourProps {
|
||||
isActive: boolean;
|
||||
onComplete: () => void;
|
||||
onSkip: () => void;
|
||||
steps: TourStep[];
|
||||
}
|
||||
|
||||
export const OnboardingTour: React.FC<OnboardingTourProps> = ({
|
||||
isActive,
|
||||
onComplete,
|
||||
onSkip,
|
||||
steps
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const [currentStepIndex, setCurrentStepIndex] = useState(0);
|
||||
const [highlightedElement, setHighlightedElement] = useState<HTMLElement | null>(null);
|
||||
const [tooltipPosition, setTooltipPosition] = useState({ x: 0, y: 0 });
|
||||
|
||||
const currentStep = steps[currentStepIndex];
|
||||
const isLastStep = currentStepIndex === steps.length - 1;
|
||||
const isFirstStep = currentStepIndex === 0;
|
||||
|
||||
useEffect(() => {
|
||||
if (!isActive || !currentStep) return;
|
||||
|
||||
const targetElement = document.querySelector(currentStep.target) as HTMLElement;
|
||||
if (targetElement) {
|
||||
setHighlightedElement(targetElement);
|
||||
|
||||
// Calculate tooltip position
|
||||
const rect = targetElement.getBoundingClientRect();
|
||||
const position = currentStep.position || 'bottom';
|
||||
|
||||
let x = rect.left + rect.width / 2;
|
||||
let y = rect.bottom + 10;
|
||||
|
||||
switch (position) {
|
||||
case 'top':
|
||||
y = rect.top - 10;
|
||||
break;
|
||||
case 'left':
|
||||
x = rect.left - 10;
|
||||
y = rect.top + rect.height / 2;
|
||||
break;
|
||||
case 'right':
|
||||
x = rect.right + 10;
|
||||
y = rect.top + rect.height / 2;
|
||||
break;
|
||||
case 'bottom':
|
||||
default:
|
||||
y = rect.bottom + 10;
|
||||
break;
|
||||
}
|
||||
|
||||
setTooltipPosition({ x, y });
|
||||
|
||||
// Scroll element into view
|
||||
targetElement.scrollIntoView({
|
||||
behavior: 'smooth',
|
||||
block: 'center',
|
||||
inline: 'center'
|
||||
});
|
||||
|
||||
// Add highlight class
|
||||
targetElement.classList.add('tour-highlight');
|
||||
|
||||
// Add pulsing animation for interactive elements
|
||||
if (currentStep.action && currentStep.action !== 'none') {
|
||||
targetElement.classList.add('tour-pulse');
|
||||
}
|
||||
}
|
||||
|
||||
return () => {
|
||||
if (targetElement) {
|
||||
targetElement.classList.remove('tour-highlight', 'tour-pulse');
|
||||
}
|
||||
};
|
||||
}, [currentStep, isActive]);
|
||||
|
||||
const handleNext = () => {
|
||||
if (isLastStep) {
|
||||
onComplete();
|
||||
} else {
|
||||
setCurrentStepIndex(prev => prev + 1);
|
||||
}
|
||||
};
|
||||
|
||||
const handlePrevious = () => {
|
||||
if (!isFirstStep) {
|
||||
setCurrentStepIndex(prev => prev - 1);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSkip = () => {
|
||||
onSkip();
|
||||
};
|
||||
|
||||
const handleStepClick = (index: number) => {
|
||||
setCurrentStepIndex(index);
|
||||
};
|
||||
|
||||
if (!isActive || !currentStep) return null;
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Overlay */}
|
||||
<div className="fixed inset-0 bg-black bg-opacity-50 z-40 pointer-events-none" />
|
||||
|
||||
{/* Highlight spotlight */}
|
||||
{highlightedElement && (
|
||||
<div
|
||||
className="fixed z-50 pointer-events-none"
|
||||
style={{
|
||||
left: highlightedElement.getBoundingClientRect().left - 4,
|
||||
top: highlightedElement.getBoundingClientRect().top - 4,
|
||||
width: highlightedElement.getBoundingClientRect().width + 8,
|
||||
height: highlightedElement.getBoundingClientRect().height + 8,
|
||||
boxShadow: '0 0 0 4px rgba(59, 130, 246, 0.5), 0 0 0 9999px rgba(0, 0, 0, 0.5)',
|
||||
borderRadius: '8px',
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Tooltip */}
|
||||
<Card
|
||||
className="fixed z-50 max-w-sm bg-white dark:bg-gray-800 shadow-lg border"
|
||||
style={{
|
||||
left: Math.max(16, Math.min(tooltipPosition.x - 150, window.innerWidth - 316)),
|
||||
top: Math.max(16, Math.min(tooltipPosition.y, window.innerHeight - 200)),
|
||||
}}
|
||||
>
|
||||
<CardHeader className="pb-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<CardTitle className="text-sm font-medium">
|
||||
{t(currentStep.title)}
|
||||
</CardTitle>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={handleSkip}
|
||||
className="h-6 w-6 p-0"
|
||||
>
|
||||
<X className="h-3 w-3" />
|
||||
</Button>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Badge variant="secondary" className="text-xs">
|
||||
{currentStepIndex + 1} / {steps.length}
|
||||
</Badge>
|
||||
{currentStep.optional && (
|
||||
<Badge variant="outline" className="text-xs">
|
||||
{t('onboarding.optional')}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent className="pt-0">
|
||||
<p className="text-sm text-gray-600 dark:text-gray-300 mb-4">
|
||||
{t(currentStep.content)}
|
||||
</p>
|
||||
|
||||
{currentStep.action && currentStep.action !== 'none' && (
|
||||
<div className="mb-4 p-2 bg-blue-50 dark:bg-blue-900/20 rounded-md">
|
||||
<p className="text-xs text-blue-700 dark:text-blue-300">
|
||||
{currentStep.action === 'click' && t('onboarding.clickInstruction')}
|
||||
{currentStep.action === 'hover' && t('onboarding.hoverInstruction')}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handlePrevious}
|
||||
disabled={isFirstStep}
|
||||
className="flex items-center gap-1"
|
||||
>
|
||||
<ChevronLeft className="h-3 w-3" />
|
||||
{t('onboarding.previous')}
|
||||
</Button>
|
||||
|
||||
<div className="flex gap-1">
|
||||
{steps.map((_, index) => (
|
||||
<button
|
||||
key={index}
|
||||
onClick={() => handleStepClick(index)}
|
||||
className={`w-2 h-2 rounded-full transition-colors ${
|
||||
index === currentStepIndex
|
||||
? 'bg-blue-500'
|
||||
: index < currentStepIndex
|
||||
? 'bg-green-500'
|
||||
: 'bg-gray-300 dark:bg-gray-600'
|
||||
}`}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={handleNext}
|
||||
className="flex items-center gap-1"
|
||||
>
|
||||
{isLastStep ? (
|
||||
<>
|
||||
<Check className="h-3 w-3" />
|
||||
{t('onboarding.finish')}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{t('onboarding.next')}
|
||||
<ChevronRight className="h-3 w-3" />
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,260 @@
|
||||
import React, { createContext, useContext, useState, useEffect } from 'react';
|
||||
import { OnboardingTour, TourStep } from './OnboardingTour';
|
||||
import { useAuth } from '@/hooks/useAuth';
|
||||
|
||||
interface TourManagerContextType {
|
||||
startTour: (tourId: string, steps: TourStep[]) => void;
|
||||
skipTour: () => void;
|
||||
completeTour: () => void;
|
||||
isActive: boolean;
|
||||
currentTour: string | null;
|
||||
}
|
||||
|
||||
const TourManagerContext = createContext<TourManagerContextType | undefined>(undefined);
|
||||
|
||||
export const useTourManager = () => {
|
||||
const context = useContext(TourManagerContext);
|
||||
if (!context) {
|
||||
throw new Error('useTourManager must be used within a TourManagerProvider');
|
||||
}
|
||||
return context;
|
||||
};
|
||||
|
||||
interface TourManagerProviderProps {
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
export const TourManagerProvider: React.FC<TourManagerProviderProps> = ({ children }) => {
|
||||
const { user } = useAuth();
|
||||
const [isActive, setIsActive] = useState(false);
|
||||
const [currentTour, setCurrentTour] = useState<string | null>(null);
|
||||
const [currentSteps, setCurrentSteps] = useState<TourStep[]>([]);
|
||||
|
||||
// Check if user should see onboarding tour
|
||||
useEffect(() => {
|
||||
if (user && !user.hasCompletedOnboarding) {
|
||||
// Auto-start main tour for new users
|
||||
const mainTourSteps = getMainTourSteps();
|
||||
startTour('main', mainTourSteps);
|
||||
}
|
||||
}, [user]);
|
||||
|
||||
const startTour = (tourId: string, steps: TourStep[]) => {
|
||||
setCurrentTour(tourId);
|
||||
setCurrentSteps(steps);
|
||||
setIsActive(true);
|
||||
};
|
||||
|
||||
const skipTour = () => {
|
||||
setIsActive(false);
|
||||
setCurrentTour(null);
|
||||
setCurrentSteps([]);
|
||||
|
||||
// Mark onboarding as completed if it's the main tour
|
||||
if (currentTour === 'main' && user) {
|
||||
markOnboardingComplete();
|
||||
}
|
||||
};
|
||||
|
||||
const completeTour = () => {
|
||||
setIsActive(false);
|
||||
setCurrentTour(null);
|
||||
setCurrentSteps([]);
|
||||
|
||||
// Mark onboarding as completed if it's the main tour
|
||||
if (currentTour === 'main' && user) {
|
||||
markOnboardingComplete();
|
||||
}
|
||||
};
|
||||
|
||||
const markOnboardingComplete = async () => {
|
||||
try {
|
||||
const response = await fetch('/api/auth/complete-onboarding', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
credentials: 'include',
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
console.error('Failed to mark onboarding as complete');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error completing onboarding:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const value = {
|
||||
startTour,
|
||||
skipTour,
|
||||
completeTour,
|
||||
isActive,
|
||||
currentTour,
|
||||
};
|
||||
|
||||
return (
|
||||
<TourManagerContext.Provider value={value}>
|
||||
{children}
|
||||
<OnboardingTour
|
||||
isActive={isActive}
|
||||
onComplete={completeTour}
|
||||
onSkip={skipTour}
|
||||
steps={currentSteps}
|
||||
/>
|
||||
</TourManagerContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
// Define tour steps for different sections
|
||||
export const getMainTourSteps = (): TourStep[] => [
|
||||
{
|
||||
id: 'welcome',
|
||||
target: '[data-tour="sidebar"]',
|
||||
title: 'onboarding.welcome.title',
|
||||
content: 'onboarding.welcome.content',
|
||||
position: 'right'
|
||||
},
|
||||
{
|
||||
id: 'navigation',
|
||||
target: '[data-tour="sidebar-nav"]',
|
||||
title: 'onboarding.navigation.title',
|
||||
content: 'onboarding.navigation.content',
|
||||
position: 'right',
|
||||
action: 'hover'
|
||||
},
|
||||
{
|
||||
id: 'dashboard',
|
||||
target: '[data-tour="dashboard-link"]',
|
||||
title: 'onboarding.dashboard.title',
|
||||
content: 'onboarding.dashboard.content',
|
||||
position: 'right',
|
||||
action: 'click'
|
||||
},
|
||||
{
|
||||
id: 'tasks',
|
||||
target: '[data-tour="tasks-section"]',
|
||||
title: 'onboarding.tasks.title',
|
||||
content: 'onboarding.tasks.content',
|
||||
position: 'bottom'
|
||||
},
|
||||
{
|
||||
id: 'create-task',
|
||||
target: '[data-tour="create-task-btn"]',
|
||||
title: 'onboarding.createTask.title',
|
||||
content: 'onboarding.createTask.content',
|
||||
position: 'bottom',
|
||||
action: 'click',
|
||||
optional: true
|
||||
},
|
||||
{
|
||||
id: 'finances',
|
||||
target: '[data-tour="finances-link"]',
|
||||
title: 'onboarding.finances.title',
|
||||
content: 'onboarding.finances.content',
|
||||
position: 'right'
|
||||
},
|
||||
{
|
||||
id: 'voice-control',
|
||||
target: '[data-tour="voice-button"]',
|
||||
title: 'onboarding.voice.title',
|
||||
content: 'onboarding.voice.content',
|
||||
position: 'top',
|
||||
optional: true
|
||||
},
|
||||
{
|
||||
id: 'ai-assistant',
|
||||
target: '[data-tour="ai-link"]',
|
||||
title: 'onboarding.ai.title',
|
||||
content: 'onboarding.ai.content',
|
||||
position: 'right'
|
||||
},
|
||||
{
|
||||
id: 'profile',
|
||||
target: '[data-tour="profile-menu"]',
|
||||
title: 'onboarding.profile.title',
|
||||
content: 'onboarding.profile.content',
|
||||
position: 'left'
|
||||
},
|
||||
{
|
||||
id: 'settings',
|
||||
target: '[data-tour="settings-link"]',
|
||||
title: 'onboarding.settings.title',
|
||||
content: 'onboarding.settings.content',
|
||||
position: 'left',
|
||||
optional: true
|
||||
}
|
||||
];
|
||||
|
||||
export const getTasksTourSteps = (): TourStep[] => [
|
||||
{
|
||||
id: 'task-list',
|
||||
target: '[data-tour="task-list"]',
|
||||
title: 'onboarding.taskList.title',
|
||||
content: 'onboarding.taskList.content',
|
||||
position: 'top'
|
||||
},
|
||||
{
|
||||
id: 'task-filters',
|
||||
target: '[data-tour="task-filters"]',
|
||||
title: 'onboarding.taskFilters.title',
|
||||
content: 'onboarding.taskFilters.content',
|
||||
position: 'bottom'
|
||||
},
|
||||
{
|
||||
id: 'task-create',
|
||||
target: '[data-tour="task-create-form"]',
|
||||
title: 'onboarding.taskCreate.title',
|
||||
content: 'onboarding.taskCreate.content',
|
||||
position: 'left'
|
||||
}
|
||||
];
|
||||
|
||||
export const getFinancesTourSteps = (): TourStep[] => [
|
||||
{
|
||||
id: 'financial-overview',
|
||||
target: '[data-tour="financial-overview"]',
|
||||
title: 'onboarding.financialOverview.title',
|
||||
content: 'onboarding.financialOverview.content',
|
||||
position: 'top'
|
||||
},
|
||||
{
|
||||
id: 'add-record',
|
||||
target: '[data-tour="add-record-btn"]',
|
||||
title: 'onboarding.addRecord.title',
|
||||
content: 'onboarding.addRecord.content',
|
||||
position: 'bottom',
|
||||
action: 'click'
|
||||
},
|
||||
{
|
||||
id: 'budget-planner',
|
||||
target: '[data-tour="budget-planner"]',
|
||||
title: 'onboarding.budgetPlanner.title',
|
||||
content: 'onboarding.budgetPlanner.content',
|
||||
position: 'left'
|
||||
}
|
||||
];
|
||||
|
||||
export const getVoiceTourSteps = (): TourStep[] => [
|
||||
{
|
||||
id: 'voice-recorder',
|
||||
target: '[data-tour="voice-recorder"]',
|
||||
title: 'onboarding.voiceRecorder.title',
|
||||
content: 'onboarding.voiceRecorder.content',
|
||||
position: 'top'
|
||||
},
|
||||
{
|
||||
id: 'voice-commands',
|
||||
target: '[data-tour="voice-commands"]',
|
||||
title: 'onboarding.voiceCommands.title',
|
||||
content: 'onboarding.voiceCommands.content',
|
||||
position: 'bottom'
|
||||
},
|
||||
{
|
||||
id: 'voice-shortcuts',
|
||||
target: '[data-tour="voice-shortcuts"]',
|
||||
title: 'onboarding.voiceShortcuts.title',
|
||||
content: 'onboarding.voiceShortcuts.content',
|
||||
position: 'left'
|
||||
}
|
||||
];
|
||||
@@ -110,6 +110,30 @@
|
||||
}
|
||||
}
|
||||
|
||||
/* Onboarding Tour Styles */
|
||||
.tour-highlight {
|
||||
position: relative;
|
||||
z-index: 60;
|
||||
box-shadow: 0 0 0 4px rgba(59, 130, 246, 0.5);
|
||||
border-radius: 8px;
|
||||
transition: all 0.3s ease-in-out;
|
||||
}
|
||||
|
||||
.tour-pulse {
|
||||
animation: tour-pulse 2s cubic-bezier(0.4, 0, 0.6, 1) infinite;
|
||||
}
|
||||
|
||||
@keyframes tour-pulse {
|
||||
0%, 100% {
|
||||
transform: scale(1);
|
||||
box-shadow: 0 0 0 4px rgba(59, 130, 246, 0.5);
|
||||
}
|
||||
50% {
|
||||
transform: scale(1.02);
|
||||
box-shadow: 0 0 0 8px rgba(59, 130, 246, 0.3);
|
||||
}
|
||||
}
|
||||
|
||||
/* Custom scrollbars */
|
||||
::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
|
||||
Reference in New Issue
Block a user