feat: implement QOL features (Stats, Search, Dram of the Day)

This commit is contained in:
2025-12-18 12:34:51 +01:00
parent 7d395392d1
commit 35c2443473
6 changed files with 235 additions and 11 deletions

View File

@@ -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>
<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 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} />

View File

@@ -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;

View 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>
);
}

View 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>
);
}

View File

@@ -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);

View File

@@ -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())
);