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:
225
frontend/src/components/Onboarding.tsx
Normal file
225
frontend/src/components/Onboarding.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import { ListMusic, Heart, Sparkles, Compass, Loader2, Music } from 'lucide-reac
|
||||
import { useAuth } from '../lib/auth'
|
||||
import { getPlaylists, getRecommendationHistory, getSavedRecommendations, generateRecommendations, type RecommendationItem, type PlaylistResponse } from '../lib/api'
|
||||
import RecommendationCard from '../components/RecommendationCard'
|
||||
import Onboarding from '../components/Onboarding'
|
||||
import { toggleSaveRecommendation } from '../lib/api'
|
||||
|
||||
export default function Dashboard() {
|
||||
@@ -15,6 +16,14 @@ export default function Dashboard() {
|
||||
const [query, setQuery] = useState('')
|
||||
const [discovering, setDiscovering] = useState(false)
|
||||
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(() => {
|
||||
const load = async () => {
|
||||
@@ -70,6 +79,8 @@ export default function Dashboard() {
|
||||
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
{showOnboarding && <Onboarding onComplete={handleOnboardingComplete} />}
|
||||
|
||||
{/* Welcome */}
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold text-charcoal">
|
||||
|
||||
Reference in New Issue
Block a user