feat: implement QOL features (Stats, Search, Dram of the Day)
This commit is contained in:
@@ -7,6 +7,8 @@ import BottleGrid from "@/components/BottleGrid";
|
||||
import AuthForm from "@/components/AuthForm";
|
||||
import BuddyList from "@/components/BuddyList";
|
||||
import SessionList from "@/components/SessionList";
|
||||
import StatsDashboard from "@/components/StatsDashboard";
|
||||
import DramOfTheDay from "@/components/DramOfTheDay";
|
||||
|
||||
export default function Home() {
|
||||
const supabase = createClientComponentClient();
|
||||
@@ -61,7 +63,8 @@ export default function Home() {
|
||||
.select(`
|
||||
*,
|
||||
tastings (
|
||||
created_at
|
||||
created_at,
|
||||
rating
|
||||
)
|
||||
`)
|
||||
.order('created_at', { ascending: false });
|
||||
@@ -122,14 +125,21 @@ export default function Home() {
|
||||
<h1 className="text-4xl font-black text-zinc-900 dark:text-white tracking-tighter">
|
||||
WHISKY<span className="text-amber-600">VAULT</span>
|
||||
</h1>
|
||||
<div className="flex items-center gap-4">
|
||||
<DramOfTheDay bottles={bottles} />
|
||||
<button
|
||||
onClick={handleLogout}
|
||||
className="text-sm font-medium text-zinc-500 hover:text-zinc-800 dark:hover:text-zinc-300 transition-colors"
|
||||
>
|
||||
Abmelden
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div className="w-full">
|
||||
<StatsDashboard bottles={bottles} />
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-8 w-full max-w-5xl">
|
||||
<div className="flex flex-col gap-8">
|
||||
<CameraCapture onSaveComplete={fetchCollection} />
|
||||
|
||||
@@ -148,10 +148,19 @@ export default function BottleGrid({ bottles }: BottleGridProps) {
|
||||
}, [bottles]);
|
||||
|
||||
const filteredBottles = useMemo(() => {
|
||||
let result = bottles.filter((bottle) => {
|
||||
const result = bottles.filter((bottle) => {
|
||||
const searchLower = searchQuery.toLowerCase();
|
||||
const tastingNotesMatch = bottle.tastings?.some((t: any) =>
|
||||
(t.nose_notes?.toLowerCase().includes(searchLower)) ||
|
||||
(t.palate_notes?.toLowerCase().includes(searchLower)) ||
|
||||
(t.finish_notes?.toLowerCase().includes(searchLower))
|
||||
);
|
||||
|
||||
const matchesSearch =
|
||||
bottle.name?.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||
bottle.distillery?.toLowerCase().includes(searchQuery.toLowerCase());
|
||||
bottle.name.toLowerCase().includes(searchLower) ||
|
||||
bottle.distillery?.toLowerCase().includes(searchLower) ||
|
||||
bottle.category?.toLowerCase().includes(searchLower) ||
|
||||
tastingNotesMatch;
|
||||
|
||||
const matchesCategory = !selectedCategory || bottle.category === selectedCategory;
|
||||
const matchesDistillery = !selectedDistillery || bottle.distillery === selectedDistillery;
|
||||
|
||||
101
src/components/DramOfTheDay.tsx
Normal file
101
src/components/DramOfTheDay.tsx
Normal file
@@ -0,0 +1,101 @@
|
||||
'use client';
|
||||
|
||||
import React, { useState } from 'react';
|
||||
import { Sparkles, GlassWater, Dices, X } from 'lucide-react';
|
||||
import Link from 'next/link';
|
||||
|
||||
interface Bottle {
|
||||
id: string;
|
||||
name: string;
|
||||
distillery?: string;
|
||||
status: 'sealed' | 'open' | 'sampled' | 'empty';
|
||||
}
|
||||
|
||||
interface DramOfTheDayProps {
|
||||
bottles: Bottle[];
|
||||
}
|
||||
|
||||
export default function DramOfTheDay({ bottles }: DramOfTheDayProps) {
|
||||
const [suggestion, setSuggestion] = useState<Bottle | null>(null);
|
||||
const [isRolling, setIsRolling] = useState(false);
|
||||
|
||||
const suggestDram = () => {
|
||||
setIsRolling(true);
|
||||
const openBottles = bottles.filter(b => b.status === 'open' || b.status === 'sampled');
|
||||
|
||||
if (openBottles.length === 0) {
|
||||
alert('Keine offenen Flaschen gefunden! Vielleicht Zeit für ein neues Tasting? 🥃');
|
||||
setIsRolling(false);
|
||||
return;
|
||||
}
|
||||
|
||||
// Simulate a "roll" for 800ms
|
||||
setTimeout(() => {
|
||||
const randomBottle = openBottles[Math.floor(Math.random() * openBottles.length)];
|
||||
setSuggestion(randomBottle);
|
||||
setIsRolling(false);
|
||||
}, 800);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="relative">
|
||||
<button
|
||||
onClick={suggestDram}
|
||||
disabled={isRolling}
|
||||
className="flex items-center gap-2 px-6 py-3 bg-amber-600 hover:bg-amber-700 text-white rounded-2xl font-black uppercase tracking-widest text-xs transition-all shadow-lg shadow-amber-600/20 active:scale-95 disabled:opacity-50"
|
||||
>
|
||||
{isRolling ? (
|
||||
<Dices size={18} className="animate-spin" />
|
||||
) : (
|
||||
<Sparkles size={18} />
|
||||
)}
|
||||
Dram of the Day
|
||||
</button>
|
||||
|
||||
{suggestion && (
|
||||
<div className="fixed inset-0 z-[100] flex items-center justify-center p-6 bg-black/60 backdrop-blur-sm animate-in fade-in duration-300">
|
||||
<div className="bg-white dark:bg-zinc-900 w-full max-w-sm rounded-[40px] p-8 shadow-2xl border border-amber-500/20 relative animate-in zoom-in-95 duration-300">
|
||||
<button
|
||||
onClick={() => setSuggestion(null)}
|
||||
className="absolute top-6 right-6 text-zinc-400 hover:text-zinc-600 dark:hover:text-zinc-200"
|
||||
>
|
||||
<X size={24} />
|
||||
</button>
|
||||
|
||||
<div className="flex flex-col items-center text-center space-y-6">
|
||||
<div className="w-20 h-20 bg-amber-100 dark:bg-amber-900/30 rounded-3xl flex items-center justify-center text-amber-600">
|
||||
<GlassWater size={40} strokeWidth={2.5} />
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<h3 className="text-[10px] font-black uppercase tracking-[0.2em] text-amber-600">Dein heutiger Dram</h3>
|
||||
<h2 className="text-2xl font-black text-zinc-900 dark:text-white leading-tight">
|
||||
{suggestion.name}
|
||||
</h2>
|
||||
{suggestion.distillery && (
|
||||
<p className="text-zinc-500 font-bold italic">{suggestion.distillery}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="w-full pt-4">
|
||||
<Link
|
||||
href={`/bottles/${suggestion.id}`}
|
||||
onClick={() => setSuggestion(null)}
|
||||
className="block w-full py-4 bg-zinc-900 dark:bg-zinc-100 text-white dark:text-zinc-900 rounded-2xl font-black uppercase tracking-widest text-xs hover:bg-amber-600 dark:hover:bg-amber-600 hover:text-white transition-all shadow-xl"
|
||||
>
|
||||
Flasche anschauen
|
||||
</Link>
|
||||
<button
|
||||
onClick={suggestDram}
|
||||
className="w-full mt-3 py-2 text-zinc-400 hover:text-amber-600 text-[10px] font-black uppercase tracking-widest transition-colors"
|
||||
>
|
||||
Nicht heute, noch mal würfeln
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
99
src/components/StatsDashboard.tsx
Normal file
99
src/components/StatsDashboard.tsx
Normal file
@@ -0,0 +1,99 @@
|
||||
'use client';
|
||||
|
||||
import React, { useMemo } from 'react';
|
||||
import { TrendingUp, CreditCard, Star, Home, BarChart3 } from 'lucide-react';
|
||||
|
||||
interface Bottle {
|
||||
id: string;
|
||||
purchase_price?: number | null;
|
||||
status: 'sealed' | 'open' | 'sampled' | 'empty';
|
||||
distillery?: string;
|
||||
tastings?: { rating: number }[];
|
||||
}
|
||||
|
||||
interface StatsDashboardProps {
|
||||
bottles: Bottle[];
|
||||
}
|
||||
|
||||
export default function StatsDashboard({ bottles }: StatsDashboardProps) {
|
||||
const stats = useMemo(() => {
|
||||
const activeBottles = bottles.filter(b => b.status !== 'empty');
|
||||
const totalValue = bottles.reduce((sum, b) => sum + (Number(b.purchase_price) || 0), 0);
|
||||
|
||||
const ratings = bottles.flatMap(b => b.tastings?.map(t => t.rating) || []);
|
||||
const avgRating = ratings.length > 0
|
||||
? Math.round(ratings.reduce((sum, r) => sum + r, 0) / ratings.length)
|
||||
: 0;
|
||||
|
||||
const distilleries = bottles
|
||||
.filter(b => b.distillery)
|
||||
.reduce((acc, b) => {
|
||||
acc[b.distillery!] = (acc[b.distillery!] || 0) + 1;
|
||||
return acc;
|
||||
}, {} as Record<string, number>);
|
||||
|
||||
const topDistillery = Object.entries(distilleries).sort((a, b) => b[1] - a[1])[0]?.[0] || 'N/A';
|
||||
|
||||
return {
|
||||
totalValue,
|
||||
activeCount: activeBottles.length,
|
||||
avgRating,
|
||||
topDistillery,
|
||||
totalCount: bottles.length
|
||||
};
|
||||
}, [bottles]);
|
||||
|
||||
const statItems = [
|
||||
{
|
||||
label: 'Gesamtwert',
|
||||
value: stats.totalValue.toLocaleString('de-DE', { style: 'currency', currency: 'EUR' }),
|
||||
icon: CreditCard,
|
||||
color: 'text-green-600',
|
||||
bg: 'bg-green-50 dark:bg-green-900/20'
|
||||
},
|
||||
{
|
||||
label: 'In der Bar',
|
||||
value: stats.activeCount,
|
||||
icon: Home,
|
||||
color: 'text-blue-600',
|
||||
bg: 'bg-blue-50 dark:bg-blue-900/20'
|
||||
},
|
||||
{
|
||||
label: 'Ø Bewertung',
|
||||
value: `${stats.avgRating}/100`,
|
||||
icon: Star,
|
||||
color: 'text-amber-600',
|
||||
bg: 'bg-amber-50 dark:bg-amber-900/20'
|
||||
},
|
||||
{
|
||||
label: 'Top Brennerei',
|
||||
value: stats.topDistillery,
|
||||
icon: BarChart3,
|
||||
color: 'text-purple-600',
|
||||
bg: 'bg-purple-50 dark:bg-purple-900/20'
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="w-full grid grid-cols-2 md:grid-cols-4 gap-4 mb-8 h-fit animate-in fade-in slide-in-from-top-4 duration-500">
|
||||
{statItems.map((item, idx) => {
|
||||
const Icon = item.icon;
|
||||
return (
|
||||
<div
|
||||
key={idx}
|
||||
className="bg-white dark:bg-zinc-900 p-4 rounded-3xl border border-zinc-200 dark:border-zinc-800 shadow-sm flex flex-col gap-1 relative overflow-hidden group hover:border-amber-500/30 transition-all"
|
||||
>
|
||||
<div className={`p-2 rounded-xl w-fit ${item.bg} mb-1 flex items-center justify-center`}>
|
||||
<Icon size={16} className={item.color} />
|
||||
</div>
|
||||
<div className="text-[10px] font-black uppercase text-zinc-400 tracking-widest">{item.label}</div>
|
||||
<div className="text-lg font-black text-zinc-900 dark:text-white truncate">
|
||||
{item.value}
|
||||
</div>
|
||||
<TrendingUp size={12} className="absolute top-4 right-4 text-zinc-100 dark:text-zinc-800 group-hover:text-amber-500/20 transition-colors" />
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -13,7 +13,11 @@ export async function updateBottleStatus(bottleId: string, status: 'sealed' | 'o
|
||||
|
||||
const { error } = await supabase
|
||||
.from('bottles')
|
||||
.update({ status, updated_at: new Date().toISOString() })
|
||||
.update({
|
||||
status,
|
||||
updated_at: new Date().toISOString(),
|
||||
finished_at: status === 'empty' ? new Date().toISOString() : null
|
||||
})
|
||||
.eq('id', bottleId)
|
||||
.eq('user_id', session.user.id);
|
||||
|
||||
|
||||
@@ -43,6 +43,7 @@ CREATE TABLE IF NOT EXISTS bottles (
|
||||
purchase_price DECIMAL(10, 2),
|
||||
is_whisky BOOLEAN DEFAULT true,
|
||||
confidence INTEGER DEFAULT 100,
|
||||
finished_at TIMESTAMP WITH TIME ZONE,
|
||||
created_at TIMESTAMP WITH TIME ZONE DEFAULT timezone('Europe/Berlin'::text, now()),
|
||||
updated_at TIMESTAMP WITH TIME ZONE DEFAULT timezone('Europe/Berlin'::text, now())
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user