From d1ee78fc27c2dd9ace0090b074f3a55f2d386fc3 Mon Sep 17 00:00:00 2001 From: root Date: Tue, 31 Mar 2026 20:41:29 -0500 Subject: [PATCH] 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. --- frontend/src/components/Onboarding.tsx | 225 +++++++++++++++++++++++++ frontend/src/pages/Dashboard.tsx | 11 ++ 2 files changed, 236 insertions(+) create mode 100644 frontend/src/components/Onboarding.tsx diff --git a/frontend/src/components/Onboarding.tsx b/frontend/src/components/Onboarding.tsx new file mode 100644 index 0000000..36fbff0 --- /dev/null +++ b/frontend/src/components/Onboarding.tsx @@ -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 ( +
+ {/* Backdrop */} +
+ + {/* Card */} +
+
+ {/* Skip */} + {!isLastStep && ( + + )} + + {/* Step content with transition */} +
+ {/* Icon */} +
+ +
+ + {/* Title */} +

+ {step.title} +

+ + {/* Description */} +

+ {step.description} +

+ + {/* Details list */} + {step.details && ( +
    + {step.details.map((item) => ( +
  • + + {item} +
  • + ))} +
+ )} + + {/* CTA buttons on last step */} + {step.buttons && ( +
+ {step.buttons.map((btn) => ( + + ))} +
+ )} +
+ + {/* Navigation */} + {!isLastStep && ( + + )} + + {/* Progress dots */} +
+ {steps.map((_, i) => ( +
+
+
+
+ ) +} diff --git a/frontend/src/pages/Dashboard.tsx b/frontend/src/pages/Dashboard.tsx index a65bcee..6b9cc12 100644 --- a/frontend/src/pages/Dashboard.tsx +++ b/frontend/src/pages/Dashboard.tsx @@ -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 (
+ {showOnboarding && } + {/* Welcome */}