feat: Add admin pages for splits and tastings
- Create /admin/splits page showing all bottle splits - Stats: total splits, active, hosts, participants, volume - Filter by host, status (active/closed) - Progress bars showing reservation status - Create /admin/tastings page showing all tasting notes - Stats: total tastings, users, avg rating, with notes, today - Filter by user, rating - Notes preview with star ratings - Add navigation links to admin dashboard
This commit is contained in:
225
src/app/admin/splits/AdminSplitsList.tsx
Normal file
225
src/app/admin/splits/AdminSplitsList.tsx
Normal file
@@ -0,0 +1,225 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useMemo } from 'react';
|
||||
import { Search, User, Calendar, Share2, Users, Check, X, ExternalLink } from 'lucide-react';
|
||||
import Image from 'next/image';
|
||||
import Link from 'next/link';
|
||||
|
||||
interface Split {
|
||||
id: string;
|
||||
public_slug: string;
|
||||
host_id: string;
|
||||
total_volume: number;
|
||||
host_share: number;
|
||||
price_bottle: number;
|
||||
is_active: boolean;
|
||||
created_at: string;
|
||||
host: { username: string; display_name: string | null };
|
||||
bottle: { id: string; name: string; distillery: string | null; image_url: string | null } | null;
|
||||
participantCount: number;
|
||||
totalReserved: number;
|
||||
}
|
||||
|
||||
interface AdminSplitsListProps {
|
||||
splits: Split[];
|
||||
}
|
||||
|
||||
export default function AdminSplitsList({ splits }: AdminSplitsListProps) {
|
||||
const [search, setSearch] = useState('');
|
||||
const [filterHost, setFilterHost] = useState<string | null>(null);
|
||||
const [filterStatus, setFilterStatus] = useState<'all' | 'active' | 'closed'>('all');
|
||||
|
||||
// Get unique hosts for filter
|
||||
const hosts = useMemo(() => {
|
||||
const hostMap = new Map<string, string>();
|
||||
splits.forEach(s => {
|
||||
hostMap.set(s.host_id, s.host.display_name || s.host.username);
|
||||
});
|
||||
return Array.from(hostMap.entries()).sort((a, b) => a[1].localeCompare(b[1]));
|
||||
}, [splits]);
|
||||
|
||||
// Filter splits
|
||||
const filteredSplits = useMemo(() => {
|
||||
let result = splits;
|
||||
|
||||
if (search) {
|
||||
const searchLower = search.toLowerCase();
|
||||
result = result.filter(s =>
|
||||
s.bottle?.name?.toLowerCase().includes(searchLower) ||
|
||||
s.bottle?.distillery?.toLowerCase().includes(searchLower) ||
|
||||
s.host.username.toLowerCase().includes(searchLower) ||
|
||||
s.public_slug.toLowerCase().includes(searchLower)
|
||||
);
|
||||
}
|
||||
|
||||
if (filterHost) {
|
||||
result = result.filter(s => s.host_id === filterHost);
|
||||
}
|
||||
|
||||
if (filterStatus === 'active') {
|
||||
result = result.filter(s => s.is_active);
|
||||
} else if (filterStatus === 'closed') {
|
||||
result = result.filter(s => !s.is_active);
|
||||
}
|
||||
|
||||
return result;
|
||||
}, [splits, search, filterHost, filterStatus]);
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* Filters */}
|
||||
<div className="flex flex-col lg:flex-row gap-4">
|
||||
<div className="flex-1 relative">
|
||||
<Search size={18} className="absolute left-4 top-1/2 -translate-y-1/2 text-zinc-500" />
|
||||
<input
|
||||
type="text"
|
||||
value={search}
|
||||
onChange={e => setSearch(e.target.value)}
|
||||
placeholder="Search bottles, hosts, or slugs..."
|
||||
className="w-full pl-12 pr-4 py-3 bg-zinc-900 border border-zinc-800 rounded-xl text-white placeholder-zinc-600 focus:outline-none focus:border-orange-600"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2">
|
||||
<select
|
||||
value={filterHost || ''}
|
||||
onChange={e => setFilterHost(e.target.value || null)}
|
||||
className="px-4 py-3 bg-zinc-900 border border-zinc-800 rounded-xl text-zinc-300 focus:outline-none"
|
||||
>
|
||||
<option value="">All Hosts</option>
|
||||
{hosts.map(([id, name]) => (
|
||||
<option key={id} value={id}>{name}</option>
|
||||
))}
|
||||
</select>
|
||||
|
||||
<select
|
||||
value={filterStatus}
|
||||
onChange={e => setFilterStatus(e.target.value as any)}
|
||||
className="px-4 py-3 bg-zinc-900 border border-zinc-800 rounded-xl text-zinc-300 focus:outline-none"
|
||||
>
|
||||
<option value="all">All Status</option>
|
||||
<option value="active">Active</option>
|
||||
<option value="closed">Closed</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Results */}
|
||||
<div className="text-sm text-zinc-500">
|
||||
Showing {filteredSplits.length} of {splits.length} splits
|
||||
</div>
|
||||
|
||||
{/* Splits Grid */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{filteredSplits.map(split => {
|
||||
const available = split.total_volume - split.host_share;
|
||||
const remaining = available - split.totalReserved;
|
||||
const fillPercent = Math.min(100, (split.totalReserved / available) * 100);
|
||||
|
||||
return (
|
||||
<div
|
||||
key={split.id}
|
||||
className={`bg-zinc-900 rounded-2xl border overflow-hidden transition-colors ${split.is_active
|
||||
? 'border-zinc-800 hover:border-zinc-700'
|
||||
: 'border-zinc-800/50 opacity-60'
|
||||
}`}
|
||||
>
|
||||
{/* Image */}
|
||||
<div className="aspect-[16/9] relative bg-zinc-800">
|
||||
{split.bottle?.image_url ? (
|
||||
<Image
|
||||
src={split.bottle.image_url}
|
||||
alt={split.bottle.name || 'Bottle'}
|
||||
fill
|
||||
className="object-cover"
|
||||
sizes="(max-width: 768px) 100vw, (max-width: 1200px) 50vw, 33vw"
|
||||
/>
|
||||
) : (
|
||||
<div className="absolute inset-0 flex items-center justify-center">
|
||||
<Share2 size={48} className="text-zinc-700" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Status Badge */}
|
||||
<span className={`absolute top-2 left-2 px-2 py-1 text-[10px] font-bold rounded-lg flex items-center gap-1 ${split.is_active
|
||||
? 'bg-green-600/90 text-white'
|
||||
: 'bg-zinc-700/90 text-zinc-300'
|
||||
}`}>
|
||||
{split.is_active ? <Check size={10} /> : <X size={10} />}
|
||||
{split.is_active ? 'Active' : 'Closed'}
|
||||
</span>
|
||||
|
||||
{/* Participants Badge */}
|
||||
<span className="absolute top-2 right-2 px-2 py-1 bg-black/60 backdrop-blur-sm text-xs font-bold text-white rounded-lg flex items-center gap-1">
|
||||
<Users size={12} />
|
||||
{split.participantCount}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Info */}
|
||||
<div className="p-4">
|
||||
<h3 className="font-bold text-white truncate mb-1">
|
||||
{split.bottle?.name || 'Unknown Bottle'}
|
||||
</h3>
|
||||
<p className="text-sm text-zinc-500 truncate mb-2">
|
||||
{split.bottle?.distillery || 'Unknown Distillery'}
|
||||
</p>
|
||||
|
||||
{/* Progress Bar */}
|
||||
<div className="mb-3">
|
||||
<div className="flex justify-between text-xs text-zinc-500 mb-1">
|
||||
<span>{split.totalReserved}cl reserved</span>
|
||||
<span>{remaining}cl left</span>
|
||||
</div>
|
||||
<div className="h-2 bg-zinc-800 rounded-full overflow-hidden">
|
||||
<div
|
||||
className="h-full bg-gradient-to-r from-orange-500 to-orange-600 transition-all"
|
||||
style={{ width: `${fillPercent}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Details */}
|
||||
<div className="flex items-center justify-between text-xs mb-3">
|
||||
<span className="flex items-center gap-1 text-zinc-400">
|
||||
<User size={12} />
|
||||
{split.host.display_name || split.host.username}
|
||||
</span>
|
||||
<span className="text-zinc-600">
|
||||
{new Date(split.created_at).toLocaleDateString('de-DE')}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Price & Link */}
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm font-bold text-orange-500">
|
||||
{split.price_bottle.toFixed(2)}€
|
||||
</span>
|
||||
<Link
|
||||
href={`/splits/${split.public_slug}`}
|
||||
target="_blank"
|
||||
className="flex items-center gap-1 text-xs text-zinc-400 hover:text-white transition-colors"
|
||||
>
|
||||
<ExternalLink size={12} />
|
||||
{split.public_slug}
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Empty State */}
|
||||
{filteredSplits.length === 0 && (
|
||||
<div className="text-center py-12">
|
||||
<div className="w-16 h-16 mx-auto rounded-2xl bg-zinc-900 flex items-center justify-center mb-4">
|
||||
<Share2 size={32} className="text-zinc-600" />
|
||||
</div>
|
||||
<p className="text-lg font-bold text-white mb-2">No Splits Found</p>
|
||||
<p className="text-sm text-zinc-500">No bottle splits match your filters.</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user