Skip to content

Commit d695e89

Browse files
committed
feat: add animated 5-step onboarding tour with Framer Motion and completion tracking (#808)
1 parent 948d163 commit d695e89

5 files changed

Lines changed: 429 additions & 0 deletions

File tree

components/OnboardingTour.tsx

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
'use client';
2+
3+
import { TourOverlay } from '@/components/TourOverlay';
4+
import { useTour } from '@/hooks/useTour';
5+
6+
export function OnboardingTour() {
7+
const {
8+
isOpen,
9+
currentStep,
10+
currentStepData,
11+
next,
12+
previous,
13+
skip,
14+
complete,
15+
totalSteps,
16+
} = useTour();
17+
18+
if (!currentStepData || !isOpen) {
19+
return null;
20+
}
21+
22+
return (
23+
<TourOverlay
24+
isOpen={isOpen}
25+
targetSelector={currentStepData.targetSelector}
26+
title={currentStepData.title}
27+
description={currentStepData.description}
28+
step={currentStep + 1}
29+
totalSteps={totalSteps}
30+
onNext={next}
31+
onPrevious={previous}
32+
onSkip={skip}
33+
onComplete={complete}
34+
/>
35+
);
36+
}

components/TourOverlay.tsx

Lines changed: 125 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,125 @@
1+
'use client';
2+
3+
import { motion, AnimatePresence } from 'framer-motion';
4+
import { useEffect, useState } from 'react';
5+
import { createPortal } from 'react-dom';
6+
import { TourTooltip } from '@/components/TourTooltip';
7+
8+
interface TourOverlayProps {
9+
isOpen: boolean;
10+
targetSelector: string;
11+
title: string;
12+
description: string;
13+
step: number;
14+
totalSteps: number;
15+
onNext: () => void;
16+
onPrevious: () => void;
17+
onSkip: () => void;
18+
onComplete: () => void;
19+
}
20+
21+
export function TourOverlay({
22+
isOpen,
23+
targetSelector,
24+
title,
25+
description,
26+
step,
27+
totalSteps,
28+
onNext,
29+
onPrevious,
30+
onSkip,
31+
onComplete,
32+
}: TourOverlayProps) {
33+
const [targetRect, setTargetRect] = useState<DOMRect | null>(null);
34+
const [mounted, setMounted] = useState(false);
35+
36+
useEffect(() => {
37+
setMounted(true);
38+
}, []);
39+
40+
useEffect(() => {
41+
if (!isOpen) {
42+
setTargetRect(null);
43+
return;
44+
}
45+
46+
const element = document.querySelector(targetSelector);
47+
if (!element) {
48+
console.warn(`Tour target not found: ${targetSelector}`);
49+
return;
50+
}
51+
52+
const rect = element.getBoundingClientRect();
53+
setTargetRect(rect);
54+
55+
const handleResize = () => {
56+
const newRect = element.getBoundingClientRect();
57+
setTargetRect(newRect);
58+
};
59+
60+
window.addEventListener('resize', handleResize);
61+
window.addEventListener('scroll', handleResize);
62+
63+
return () => {
64+
window.removeEventListener('resize', handleResize);
65+
window.removeEventListener('scroll', handleResize);
66+
};
67+
}, [isOpen, targetSelector]);
68+
69+
if (!mounted || !isOpen) return null;
70+
71+
return createPortal(
72+
<AnimatePresence>
73+
{isOpen && (
74+
<>
75+
{/* Backdrop */}
76+
<motion.div
77+
key="tour-backdrop"
78+
initial={{ opacity: 0 }}
79+
animate={{ opacity: 1 }}
80+
exit={{ opacity: 0 }}
81+
transition={{ duration: 0.3 }}
82+
className="fixed inset-0 bg-black/50 z-40 pointer-events-auto"
83+
onClick={onSkip}
84+
/>
85+
86+
{/* Spotlight */}
87+
{targetRect && (
88+
<motion.div
89+
key="tour-spotlight"
90+
initial={{ opacity: 0 }}
91+
animate={{ opacity: 1 }}
92+
exit={{ opacity: 0 }}
93+
transition={{ duration: 0.3 }}
94+
className="fixed z-40 pointer-events-none"
95+
style={{
96+
top: targetRect.top - 8,
97+
left: targetRect.left - 8,
98+
width: targetRect.width + 16,
99+
height: targetRect.height + 16,
100+
boxShadow: '0 0 0 9999px rgba(0, 0, 0, 0.5)',
101+
borderRadius: 'var(--radius, 8px)',
102+
}}
103+
/>
104+
)}
105+
106+
{/* Tooltip */}
107+
<div className="fixed z-50 pointer-events-none">
108+
<TourTooltip
109+
title={title}
110+
description={description}
111+
step={step}
112+
totalSteps={totalSteps}
113+
targetRect={targetRect}
114+
onNext={onNext}
115+
onPrevious={onPrevious}
116+
onSkip={onSkip}
117+
onComplete={onComplete}
118+
/>
119+
</div>
120+
</>
121+
)}
122+
</AnimatePresence>,
123+
document.body
124+
);
125+
}

components/TourTooltip.tsx

Lines changed: 141 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,141 @@
1+
'use client';
2+
3+
import { motion, AnimatePresence } from 'framer-motion';
4+
import { ChevronRight, ChevronLeft, X } from 'lucide-react';
5+
import { Button } from '@/components/ui/button';
6+
import { useEffect, useState } from 'react';
7+
8+
interface TourTooltipProps {
9+
title: string;
10+
description: string;
11+
step: number;
12+
totalSteps: number;
13+
targetRect: DOMRect | null;
14+
onNext: () => void;
15+
onPrevious: () => void;
16+
onSkip: () => void;
17+
onComplete: () => void;
18+
}
19+
20+
export function TourTooltip({
21+
title,
22+
description,
23+
step,
24+
totalSteps,
25+
targetRect,
26+
onNext,
27+
onPrevious,
28+
onSkip,
29+
onComplete,
30+
}: TourTooltipProps) {
31+
const [position, setPosition] = useState({ top: 0, left: 0 });
32+
33+
useEffect(() => {
34+
if (!targetRect) return;
35+
36+
const padding = 16;
37+
const tooltipHeight = 200;
38+
const tooltipWidth = 300;
39+
40+
let top = targetRect.bottom + padding;
41+
let left = targetRect.left + targetRect.width / 2 - tooltipWidth / 2;
42+
43+
// Adjust if tooltip goes off-screen
44+
if (left < padding) {
45+
left = padding;
46+
} else if (left + tooltipWidth > window.innerWidth - padding) {
47+
left = window.innerWidth - tooltipWidth - padding;
48+
}
49+
50+
if (top + tooltipHeight > window.innerHeight) {
51+
top = targetRect.top - tooltipHeight - padding;
52+
}
53+
54+
setPosition({ top, left });
55+
}, [targetRect]);
56+
57+
if (!targetRect) return null;
58+
59+
return (
60+
<AnimatePresence>
61+
<motion.div
62+
key={`tooltip-${step}`}
63+
initial={{ opacity: 0, scale: 0.9, y: -10 }}
64+
animate={{ opacity: 1, scale: 1, y: 0 }}
65+
exit={{ opacity: 0, scale: 0.9, y: -10 }}
66+
transition={{ duration: 0.2 }}
67+
className="fixed z-50 bg-background border border-border rounded-lg shadow-lg p-4 w-80 pointer-events-auto"
68+
style={{
69+
top: `${position.top}px`,
70+
left: `${position.left}px`,
71+
}}
72+
>
73+
{/* Header */}
74+
<div className="flex items-start justify-between mb-3">
75+
<div>
76+
<h3 className="text-sm font-bold text-foreground">{title}</h3>
77+
<p className="text-xs text-muted-foreground mt-1">{description}</p>
78+
</div>
79+
<button
80+
onClick={onSkip}
81+
className="ml-2 p-1 hover:bg-secondary rounded transition-colors flex-shrink-0"
82+
aria-label="Close tour"
83+
>
84+
<X size={14} className="text-muted-foreground" />
85+
</button>
86+
</div>
87+
88+
{/* Step Counter */}
89+
<div className="mb-4">
90+
<div className="flex items-center gap-2 mb-2">
91+
<div className="text-xs font-semibold text-muted-foreground">
92+
{step} of {totalSteps}
93+
</div>
94+
</div>
95+
<div className="w-full h-1 bg-secondary rounded-full overflow-hidden">
96+
<motion.div
97+
className="h-full bg-primary"
98+
initial={{ width: 0 }}
99+
animate={{ width: `${(step / totalSteps) * 100}%` }}
100+
transition={{ duration: 0.3 }}
101+
/>
102+
</div>
103+
</div>
104+
105+
{/* Actions */}
106+
<div className="flex gap-2">
107+
{step > 1 && (
108+
<Button
109+
size="sm"
110+
variant="ghost"
111+
onClick={onPrevious}
112+
className="gap-1 flex-1"
113+
>
114+
<ChevronLeft size={14} />
115+
Previous
116+
</Button>
117+
)}
118+
119+
<Button
120+
size="sm"
121+
variant={step === totalSteps ? 'default' : 'ghost'}
122+
onClick={step === totalSteps ? onComplete : onNext}
123+
className="gap-1 flex-1"
124+
>
125+
{step === totalSteps ? 'Done' : 'Next'}
126+
{step < totalSteps && <ChevronRight size={14} />}
127+
</Button>
128+
129+
<Button
130+
size="sm"
131+
variant="ghost"
132+
onClick={onSkip}
133+
className="flex-1"
134+
>
135+
Skip
136+
</Button>
137+
</div>
138+
</motion.div>
139+
</AnimatePresence>
140+
);
141+
}

hooks/useTour.ts

Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,94 @@
1+
'use client';
2+
3+
import { useEffect, useState, useCallback } from 'react';
4+
import { TOUR_STEPS } from '@/lib/tour-steps';
5+
6+
interface TourState {
7+
isOpen: boolean;
8+
currentStep: number;
9+
isCompleted: boolean;
10+
}
11+
12+
export function useTour() {
13+
const [state, setState] = useState<TourState>({
14+
isOpen: false,
15+
currentStep: 0,
16+
isCompleted: false,
17+
});
18+
19+
// Load tour state from localStorage on mount
20+
useEffect(() => {
21+
const saved = localStorage.getItem('tour_completed');
22+
if (saved === 'true') {
23+
setState((prev) => ({ ...prev, isCompleted: true, isOpen: false }));
24+
} else {
25+
// Start tour on first visit
26+
setState((prev) => ({ ...prev, isOpen: true, currentStep: 0 }));
27+
}
28+
}, []);
29+
30+
const goToStep = useCallback((step: number) => {
31+
if (step >= 0 && step < TOUR_STEPS.length) {
32+
setState((prev) => ({ ...prev, currentStep: step }));
33+
}
34+
}, []);
35+
36+
const next = useCallback(() => {
37+
setState((prev) => {
38+
if (prev.currentStep < TOUR_STEPS.length - 1) {
39+
return { ...prev, currentStep: prev.currentStep + 1 };
40+
}
41+
return prev;
42+
});
43+
}, []);
44+
45+
const previous = useCallback(() => {
46+
setState((prev) => {
47+
if (prev.currentStep > 0) {
48+
return { ...prev, currentStep: prev.currentStep - 1 };
49+
}
50+
return prev;
51+
});
52+
}, []);
53+
54+
const skip = useCallback(() => {
55+
setState((prev) => ({ ...prev, isOpen: false }));
56+
localStorage.setItem('tour_completed', 'true');
57+
}, []);
58+
59+
const complete = useCallback(() => {
60+
setState((prev) => ({
61+
...prev,
62+
isOpen: false,
63+
isCompleted: true,
64+
}));
65+
localStorage.setItem('tour_completed', 'true');
66+
67+
// Track completion in analytics
68+
if (typeof window !== 'undefined' && window.gtag) {
69+
window.gtag('event', 'tour_completed', {
70+
event_category: 'engagement',
71+
event_label: 'onboarding_tour',
72+
});
73+
}
74+
}, []);
75+
76+
const resume = useCallback(() => {
77+
setState((prev) => ({ ...prev, isOpen: true, isCompleted: false }));
78+
localStorage.setItem('tour_completed', 'false');
79+
}, []);
80+
81+
const currentStepData = TOUR_STEPS[state.currentStep];
82+
83+
return {
84+
...state,
85+
currentStepData,
86+
goToStep,
87+
next,
88+
previous,
89+
skip,
90+
complete,
91+
resume,
92+
totalSteps: TOUR_STEPS.length,
93+
};
94+
}

0 commit comments

Comments
 (0)