feat: Add onboarding tutorial and improved empty states
Onboarding Tutorial: - 5-step walkthrough for new users - Welcome, Scan, Taste, Sessions, Ready steps - Skippable, stores completion in localStorage - Beautiful full-screen overlay with animations Empty States: - SessionList: Visual empty state with icon and description - BuddyList: Visual empty state with icon and description - Reusable EmptyState component ready for more usage Layout: Added OnboardingTutorial and CookieBanner
This commit is contained in:
@@ -11,6 +11,7 @@ import MainContentWrapper from "@/components/MainContentWrapper";
|
|||||||
import AuthListener from "@/components/AuthListener";
|
import AuthListener from "@/components/AuthListener";
|
||||||
import SyncHandler from "@/components/SyncHandler";
|
import SyncHandler from "@/components/SyncHandler";
|
||||||
import CookieBanner from "@/components/CookieBanner";
|
import CookieBanner from "@/components/CookieBanner";
|
||||||
|
import OnboardingTutorial from "@/components/OnboardingTutorial";
|
||||||
|
|
||||||
const inter = Inter({ subsets: ["latin"], variable: '--font-inter' });
|
const inter = Inter({ subsets: ["latin"], variable: '--font-inter' });
|
||||||
|
|
||||||
@@ -58,6 +59,7 @@ export default function RootLayout({
|
|||||||
{children}
|
{children}
|
||||||
</MainContentWrapper>
|
</MainContentWrapper>
|
||||||
<CookieBanner />
|
<CookieBanner />
|
||||||
|
<OnboardingTutorial />
|
||||||
</SessionProvider>
|
</SessionProvider>
|
||||||
</I18nProvider>
|
</I18nProvider>
|
||||||
</body>
|
</body>
|
||||||
|
|||||||
@@ -135,8 +135,14 @@ export default function BuddyList() {
|
|||||||
<Loader2 size={24} className="animate-spin" />
|
<Loader2 size={24} className="animate-spin" />
|
||||||
</div>
|
</div>
|
||||||
) : buddies.length === 0 ? (
|
) : buddies.length === 0 ? (
|
||||||
<div className="text-center py-8 text-zinc-600 text-xs font-bold">
|
<div className="text-center py-8">
|
||||||
{t('buddy.noBuddies')}
|
<div className="w-14 h-14 mx-auto rounded-2xl bg-zinc-800/50 flex items-center justify-center mb-4">
|
||||||
|
<Users size={24} className="text-zinc-500" />
|
||||||
|
</div>
|
||||||
|
<p className="text-sm font-bold text-zinc-400 mb-1">Keine Buddies</p>
|
||||||
|
<p className="text-xs text-zinc-600 max-w-[200px] mx-auto">
|
||||||
|
Füge Freunde hinzu um deren Empfehlungen zu sehen
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3 max-h-[400px] overflow-y-auto pr-2 scrollbar-none">
|
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3 max-h-[400px] overflow-y-auto pr-2 scrollbar-none">
|
||||||
|
|||||||
159
src/components/OnboardingTutorial.tsx
Normal file
159
src/components/OnboardingTutorial.tsx
Normal file
@@ -0,0 +1,159 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState, useEffect } from 'react';
|
||||||
|
import { motion, AnimatePresence } from 'framer-motion';
|
||||||
|
import { Scan, GlassWater, Users, Settings, ArrowRight, X, Sparkles } from 'lucide-react';
|
||||||
|
|
||||||
|
const ONBOARDING_KEY = 'dramlog_onboarding_complete';
|
||||||
|
|
||||||
|
interface OnboardingStep {
|
||||||
|
id: string;
|
||||||
|
icon: React.ReactNode;
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const STEPS: OnboardingStep[] = [
|
||||||
|
{
|
||||||
|
id: 'welcome',
|
||||||
|
icon: <Sparkles size={32} className="text-orange-500" />,
|
||||||
|
title: 'Willkommen bei DramLog!',
|
||||||
|
description: 'Dein persönliches Whisky-Tagebuch. Scanne, bewerte und entdecke neue Drams.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'scan',
|
||||||
|
icon: <Scan size={32} className="text-orange-500" />,
|
||||||
|
title: 'Scanne deine Flaschen',
|
||||||
|
description: 'Fotografiere das Etikett einer Flasche – die KI erkennt automatisch alle Details.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'taste',
|
||||||
|
icon: <GlassWater size={32} className="text-orange-500" />,
|
||||||
|
title: 'Bewerte deine Drams',
|
||||||
|
description: 'Füge Tasting-Notizen hinzu und behalte den Überblick über deine Lieblings-Whiskys.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'session',
|
||||||
|
icon: <Users size={32} className="text-orange-500" />,
|
||||||
|
title: 'Tasting-Sessions',
|
||||||
|
description: 'Organisiere Verkostungen mit Freunden und vergleicht eure Bewertungen.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'ready',
|
||||||
|
icon: <Settings size={32} className="text-orange-500" />,
|
||||||
|
title: 'Bereit zum Start!',
|
||||||
|
description: 'Scanne jetzt deine erste Flasche mit dem orangefarbenen Button unten.',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
export default function OnboardingTutorial() {
|
||||||
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
|
const [currentStep, setCurrentStep] = useState(0);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
// Check if onboarding was completed
|
||||||
|
const completed = localStorage.getItem(ONBOARDING_KEY);
|
||||||
|
if (!completed) {
|
||||||
|
// Small delay to not overwhelm on first load
|
||||||
|
const timer = setTimeout(() => setIsOpen(true), 1000);
|
||||||
|
return () => clearTimeout(timer);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleNext = () => {
|
||||||
|
if (currentStep < STEPS.length - 1) {
|
||||||
|
setCurrentStep(prev => prev + 1);
|
||||||
|
} else {
|
||||||
|
handleComplete();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleComplete = () => {
|
||||||
|
localStorage.setItem(ONBOARDING_KEY, 'true');
|
||||||
|
setIsOpen(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSkip = () => {
|
||||||
|
localStorage.setItem(ONBOARDING_KEY, 'true');
|
||||||
|
setIsOpen(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!isOpen) return null;
|
||||||
|
|
||||||
|
const step = STEPS[currentStep];
|
||||||
|
const isLastStep = currentStep === STEPS.length - 1;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AnimatePresence>
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0 }}
|
||||||
|
animate={{ opacity: 1 }}
|
||||||
|
exit={{ opacity: 0 }}
|
||||||
|
className="fixed inset-0 z-[200] bg-black/90 backdrop-blur-sm flex items-center justify-center p-6"
|
||||||
|
>
|
||||||
|
{/* Close button */}
|
||||||
|
<button
|
||||||
|
onClick={handleSkip}
|
||||||
|
className="absolute top-6 right-6 p-2 text-zinc-500 hover:text-white transition-colors"
|
||||||
|
>
|
||||||
|
<X size={24} />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<motion.div
|
||||||
|
key={step.id}
|
||||||
|
initial={{ opacity: 0, y: 20 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
exit={{ opacity: 0, y: -20 }}
|
||||||
|
className="max-w-sm w-full"
|
||||||
|
>
|
||||||
|
{/* Icon */}
|
||||||
|
<div className="w-20 h-20 mx-auto rounded-3xl bg-zinc-900 border border-zinc-800 flex items-center justify-center mb-8">
|
||||||
|
{step.icon}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Content */}
|
||||||
|
<h2 className="text-2xl font-bold text-white text-center mb-3">
|
||||||
|
{step.title}
|
||||||
|
</h2>
|
||||||
|
<p className="text-zinc-400 text-center text-sm leading-relaxed mb-8">
|
||||||
|
{step.description}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{/* Progress dots */}
|
||||||
|
<div className="flex justify-center gap-2 mb-8">
|
||||||
|
{STEPS.map((_, index) => (
|
||||||
|
<div
|
||||||
|
key={index}
|
||||||
|
className={`w-2 h-2 rounded-full transition-all ${index === currentStep
|
||||||
|
? 'w-6 bg-orange-500'
|
||||||
|
: index < currentStep
|
||||||
|
? 'bg-orange-500/50'
|
||||||
|
: 'bg-zinc-700'
|
||||||
|
}`}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Buttons */}
|
||||||
|
<div className="flex gap-3">
|
||||||
|
{!isLastStep && (
|
||||||
|
<button
|
||||||
|
onClick={handleSkip}
|
||||||
|
className="flex-1 py-3 px-4 text-sm font-bold text-zinc-500 hover:text-white transition-colors"
|
||||||
|
>
|
||||||
|
Überspringen
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
onClick={handleNext}
|
||||||
|
className="flex-1 py-3 px-4 bg-orange-600 hover:bg-orange-500 text-white font-bold text-sm rounded-xl flex items-center justify-center gap-2 transition-colors"
|
||||||
|
>
|
||||||
|
{isLastStep ? 'Los geht\'s!' : 'Weiter'}
|
||||||
|
<ArrowRight size={16} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
</motion.div>
|
||||||
|
</AnimatePresence>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -182,8 +182,14 @@ export default function SessionList() {
|
|||||||
<Loader2 size={24} className="animate-spin" />
|
<Loader2 size={24} className="animate-spin" />
|
||||||
</div>
|
</div>
|
||||||
) : sessions.length === 0 ? (
|
) : sessions.length === 0 ? (
|
||||||
<div className="text-center py-8 text-zinc-600 text-xs font-bold">
|
<div className="text-center py-8">
|
||||||
{t('session.noSessions')}
|
<div className="w-14 h-14 mx-auto rounded-2xl bg-zinc-800/50 flex items-center justify-center mb-4">
|
||||||
|
<Calendar size={24} className="text-zinc-500" />
|
||||||
|
</div>
|
||||||
|
<p className="text-sm font-bold text-zinc-400 mb-1">Keine Sessions</p>
|
||||||
|
<p className="text-xs text-zinc-600 max-w-[200px] mx-auto">
|
||||||
|
Erstelle eine Tasting-Session um mehrere Whiskys zu vergleichen
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user