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 */
|
/* Custom scrollbars */
|
||||||
::-webkit-scrollbar {
|
::-webkit-scrollbar {
|
||||||
width: 6px;
|
width: 6px;
|
||||||
|
|||||||
Reference in New Issue
Block a user