Add first-time user onboarding walkthrough

Multi-step modal overlay that guides new users through key features:
Welcome, Import Music, Discover, Features, and Get Started. Shows
once on first login via localStorage flag, with animated step
transitions, progress dots, and navigation to import/discover pages.
This commit is contained in:
root
2026-03-31 20:41:29 -05:00
parent cb6de2f43e
commit d1ee78fc27
2 changed files with 236 additions and 0 deletions

View File

@@ -0,0 +1,225 @@
import { useState, useEffect, useCallback } from 'react'
import { useNavigate } from 'react-router-dom'
import { Disc3, ListMusic, Compass, Sparkles, Rocket } from 'lucide-react'
interface OnboardingProps {
onComplete: () => void
}
interface Step {
icon: typeof Disc3
title: string
description: string
details?: string[]
buttons?: { label: string; path: string; primary?: boolean }[]
}
const steps: Step[] = [
{
icon: Disc3,
title: 'Welcome to Vynl!',
description:
'Your AI-powered music discovery companion. Let\u2019s show you around.',
},
{
icon: ListMusic,
title: 'Start by importing your music',
description:
'Paste a YouTube Music playlist URL, import from Last.fm, or just type in songs you love.',
details: ['YouTube Music playlists', 'Last.fm library sync', 'Manual song & artist entry'],
},
{
icon: Compass,
title: 'Discover new music',
description:
'Choose a discovery mode, set your mood, and let AI find songs you\u2019ll love. Every recommendation links to YouTube Music.',
details: [
'Sonic Twin \u2013 your musical doppelg\u00e4nger',
'Era Bridge \u2013 cross decades',
'Deep Cuts \u2013 hidden gems',
'Rising \u2013 emerging artists',
'Surprise Me \u2013 random delight',
],
},
{
icon: Sparkles,
title: 'Explore more',
description: 'Vynl is packed with ways to dig deeper into music.',
details: [
'Crate Digger \u2013 swipe through discoveries',
'Rabbit Hole \u2013 follow connected songs',
'Playlist Generator \u2013 AI-built playlists',
'Artist Deep Dive \u2013 click any artist name',
],
},
{
icon: Rocket,
title: 'You\u2019re all set!',
description:
'Import a playlist or just type what you\u2019re in the mood for on the Discover page.',
buttons: [
{ label: 'Import Music', path: '/playlists' },
{ label: 'Start Discovering', path: '/discover', primary: true },
],
},
]
export default function Onboarding({ onComplete }: OnboardingProps) {
const navigate = useNavigate()
const [currentStep, setCurrentStep] = useState(0)
const [direction, setDirection] = useState<'next' | 'prev'>('next')
const [animating, setAnimating] = useState(false)
const [visible, setVisible] = useState(false)
const isLastStep = currentStep === steps.length - 1
// Fade in on mount
useEffect(() => {
requestAnimationFrame(() => setVisible(true))
document.body.classList.add('overflow-hidden')
return () => document.body.classList.remove('overflow-hidden')
}, [])
const goTo = useCallback(
(next: number, dir: 'next' | 'prev') => {
if (animating) return
setDirection(dir)
setAnimating(true)
setTimeout(() => {
setCurrentStep(next)
setAnimating(false)
}, 200)
},
[animating],
)
const handleNext = () => {
if (isLastStep) {
onComplete()
return
}
goTo(currentStep + 1, 'next')
}
const handleCTA = (path: string) => {
onComplete()
navigate(path)
}
const step = steps[currentStep]
const Icon = step.icon
const translateClass = animating
? direction === 'next'
? 'opacity-0 translate-x-8'
: 'opacity-0 -translate-x-8'
: 'opacity-100 translate-x-0'
return (
<div
className={`fixed inset-0 z-[100] flex items-center justify-center p-4 transition-opacity duration-300 ${
visible ? 'opacity-100' : 'opacity-0'
}`}
>
{/* Backdrop */}
<div className="absolute inset-0 bg-black/60" onClick={onComplete} />
{/* Card */}
<div className="relative w-full max-w-lg bg-white rounded-2xl shadow-2xl overflow-hidden">
<div className="px-8 pt-10 pb-8 flex flex-col items-center text-center">
{/* Skip */}
{!isLastStep && (
<button
onClick={onComplete}
className="absolute top-4 right-5 text-sm text-charcoal-muted hover:text-charcoal transition-colors cursor-pointer bg-transparent border-none"
>
Skip
</button>
)}
{/* Step content with transition */}
<div
className={`flex flex-col items-center transition-all duration-200 ease-in-out ${translateClass}`}
>
{/* Icon */}
<div className="w-20 h-20 rounded-full bg-purple-50 flex items-center justify-center mb-6">
<Icon className="w-10 h-10 text-purple" strokeWidth={1.8} />
</div>
{/* Title */}
<h2 className="text-2xl font-bold text-charcoal mb-3">
{step.title}
</h2>
{/* Description */}
<p className="text-charcoal-muted leading-relaxed max-w-sm">
{step.description}
</p>
{/* Details list */}
{step.details && (
<ul className="mt-5 space-y-2 text-left w-full max-w-xs">
{step.details.map((item) => (
<li
key={item}
className="flex items-start gap-2 text-sm text-charcoal-muted"
>
<span className="mt-1 w-1.5 h-1.5 rounded-full bg-purple flex-shrink-0" />
{item}
</li>
))}
</ul>
)}
{/* CTA buttons on last step */}
{step.buttons && (
<div className="flex gap-3 mt-8 w-full max-w-xs">
{step.buttons.map((btn) => (
<button
key={btn.label}
onClick={() => handleCTA(btn.path)}
className={`flex-1 py-3 px-4 rounded-xl font-semibold text-sm transition-colors cursor-pointer border-none ${
btn.primary
? 'bg-purple text-white hover:bg-purple-dark'
: 'bg-purple-50 text-purple hover:bg-purple-100'
}`}
>
{btn.label}
</button>
))}
</div>
)}
</div>
{/* Navigation */}
{!isLastStep && (
<button
onClick={handleNext}
className="mt-8 w-full max-w-xs py-3 bg-purple text-white font-semibold rounded-xl hover:bg-purple-dark transition-colors cursor-pointer border-none text-sm"
>
{currentStep === 0 ? "Let's Go" : 'Next'}
</button>
)}
{/* Progress dots */}
<div className="flex items-center gap-2 mt-6">
{steps.map((_, i) => (
<button
key={i}
onClick={() => {
if (i !== currentStep) goTo(i, i > currentStep ? 'next' : 'prev')
}}
className={`rounded-full transition-all duration-300 cursor-pointer border-none p-0 ${
i === currentStep
? 'w-6 h-2 bg-purple'
: 'w-2 h-2 bg-purple-200 hover:bg-purple-300'
}`}
aria-label={`Go to step ${i + 1}`}
/>
))}
</div>
</div>
</div>
</div>
)
}

View File

@@ -4,6 +4,7 @@ import { ListMusic, Heart, Sparkles, Compass, Loader2, Music } from 'lucide-reac
import { useAuth } from '../lib/auth' import { useAuth } from '../lib/auth'
import { getPlaylists, getRecommendationHistory, getSavedRecommendations, generateRecommendations, type RecommendationItem, type PlaylistResponse } from '../lib/api' import { getPlaylists, getRecommendationHistory, getSavedRecommendations, generateRecommendations, type RecommendationItem, type PlaylistResponse } from '../lib/api'
import RecommendationCard from '../components/RecommendationCard' import RecommendationCard from '../components/RecommendationCard'
import Onboarding from '../components/Onboarding'
import { toggleSaveRecommendation } from '../lib/api' import { toggleSaveRecommendation } from '../lib/api'
export default function Dashboard() { export default function Dashboard() {
@@ -15,6 +16,14 @@ export default function Dashboard() {
const [query, setQuery] = useState('') const [query, setQuery] = useState('')
const [discovering, setDiscovering] = useState(false) const [discovering, setDiscovering] = useState(false)
const [loading, setLoading] = useState(true) const [loading, setLoading] = useState(true)
const [showOnboarding, setShowOnboarding] = useState(
!localStorage.getItem('vynl_onboarding_done')
)
const handleOnboardingComplete = () => {
localStorage.setItem('vynl_onboarding_done', 'true')
setShowOnboarding(false)
}
useEffect(() => { useEffect(() => {
const load = async () => { const load = async () => {
@@ -70,6 +79,8 @@ export default function Dashboard() {
return ( return (
<div className="space-y-8"> <div className="space-y-8">
{showOnboarding && <Onboarding onComplete={handleOnboardingComplete} />}
{/* Welcome */} {/* Welcome */}
<div> <div>
<h1 className="text-3xl font-bold text-charcoal"> <h1 className="text-3xl font-bold text-charcoal">