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:
ghaddaditw
2025-06-08 07:17:57 +00:00
parent b23344a78d
commit e3b52dc009
3 changed files with 523 additions and 0 deletions
@@ -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'
}
];
+24
View File
@@ -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;