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:
@@ -129,6 +129,18 @@ export default async function AdminPage() {
|
|||||||
>
|
>
|
||||||
All Bottles
|
All Bottles
|
||||||
</Link>
|
</Link>
|
||||||
|
<Link
|
||||||
|
href="/admin/splits"
|
||||||
|
className="px-4 py-2 bg-purple-600 hover:bg-purple-700 text-white rounded-xl font-bold transition-colors"
|
||||||
|
>
|
||||||
|
All Splits
|
||||||
|
</Link>
|
||||||
|
<Link
|
||||||
|
href="/admin/tastings"
|
||||||
|
className="px-4 py-2 bg-pink-600 hover:bg-pink-700 text-white rounded-xl font-bold transition-colors"
|
||||||
|
>
|
||||||
|
All Tastings
|
||||||
|
</Link>
|
||||||
<Link
|
<Link
|
||||||
href="/"
|
href="/"
|
||||||
className="px-4 py-2 bg-zinc-900 dark:bg-zinc-100 text-white dark:text-zinc-900 rounded-xl font-bold hover:bg-zinc-800 transition-colors"
|
className="px-4 py-2 bg-zinc-900 dark:bg-zinc-100 text-white dark:text-zinc-900 rounded-xl font-bold hover:bg-zinc-800 transition-colors"
|
||||||
|
|||||||
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
139
src/app/admin/splits/page.tsx
Normal file
139
src/app/admin/splits/page.tsx
Normal file
@@ -0,0 +1,139 @@
|
|||||||
|
export const dynamic = 'force-dynamic';
|
||||||
|
|
||||||
|
import { createClient } from '@/lib/supabase/server';
|
||||||
|
import { redirect } from 'next/navigation';
|
||||||
|
import { checkIsAdmin } from '@/services/track-api-usage';
|
||||||
|
import Link from 'next/link';
|
||||||
|
import { ArrowLeft, Share2, User, Calendar, Users, DollarSign, Package } from 'lucide-react';
|
||||||
|
import AdminSplitsList from './AdminSplitsList';
|
||||||
|
|
||||||
|
export default async function AdminSplitsPage() {
|
||||||
|
const supabase = await createClient();
|
||||||
|
const { data: { user } } = await supabase.auth.getUser();
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
redirect('/');
|
||||||
|
}
|
||||||
|
|
||||||
|
const isAdmin = await checkIsAdmin(user.id);
|
||||||
|
if (!isAdmin) {
|
||||||
|
redirect('/');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fetch all splits from all users
|
||||||
|
const { data: splitsRaw, error } = await supabase
|
||||||
|
.from('bottle_splits')
|
||||||
|
.select(`
|
||||||
|
id,
|
||||||
|
public_slug,
|
||||||
|
bottle_id,
|
||||||
|
host_id,
|
||||||
|
total_volume,
|
||||||
|
host_share,
|
||||||
|
price_bottle,
|
||||||
|
is_active,
|
||||||
|
created_at,
|
||||||
|
bottles (id, name, distillery, image_url),
|
||||||
|
split_participants (id, amount_cl, status, user_id)
|
||||||
|
`)
|
||||||
|
.order('created_at', { ascending: false })
|
||||||
|
.limit(500);
|
||||||
|
|
||||||
|
// Get unique host IDs
|
||||||
|
const hostIds = Array.from(new Set(splitsRaw?.map(s => s.host_id) || []));
|
||||||
|
|
||||||
|
// Fetch profiles for hosts
|
||||||
|
const { data: profiles } = hostIds.length > 0
|
||||||
|
? await supabase.from('profiles').select('id, username, display_name').in('id', hostIds)
|
||||||
|
: { data: [] };
|
||||||
|
|
||||||
|
// Combine splits with host info
|
||||||
|
const splits = splitsRaw?.map(split => ({
|
||||||
|
...split,
|
||||||
|
host: profiles?.find(p => p.id === split.host_id) || { username: 'Unknown', display_name: null },
|
||||||
|
bottle: split.bottles as any,
|
||||||
|
participantCount: (split.split_participants as any[])?.length || 0,
|
||||||
|
totalReserved: (split.split_participants as any[])?.reduce((sum: number, p: any) =>
|
||||||
|
['APPROVED', 'PAID', 'SHIPPED', 'PENDING'].includes(p.status) ? sum + p.amount_cl : sum, 0
|
||||||
|
) || 0,
|
||||||
|
})) || [];
|
||||||
|
|
||||||
|
// Calculate stats
|
||||||
|
const stats = {
|
||||||
|
totalSplits: splits.length,
|
||||||
|
activeSplits: splits.filter(s => s.is_active).length,
|
||||||
|
totalHosts: hostIds.length,
|
||||||
|
totalParticipants: splits.reduce((sum, s) => sum + s.participantCount, 0),
|
||||||
|
totalVolume: splits.reduce((sum, s) => sum + s.total_volume, 0),
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<main className="min-h-screen bg-zinc-950 p-6 pb-24">
|
||||||
|
<div className="max-w-7xl mx-auto">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-center gap-4 mb-8">
|
||||||
|
<Link
|
||||||
|
href="/admin"
|
||||||
|
className="p-2 rounded-xl bg-zinc-900 border border-zinc-800 text-zinc-400 hover:text-white hover:border-zinc-700 transition-colors"
|
||||||
|
>
|
||||||
|
<ArrowLeft size={20} />
|
||||||
|
</Link>
|
||||||
|
<div className="flex-1">
|
||||||
|
<h1 className="text-2xl font-bold text-white">All Bottle Splits</h1>
|
||||||
|
<p className="text-sm text-zinc-500">
|
||||||
|
View all bottle splits from all users
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<div className="mb-6 p-4 bg-red-500/20 border border-red-500/50 rounded-xl text-red-400 text-sm">
|
||||||
|
Error loading splits: {error.message}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Stats Cards */}
|
||||||
|
<div className="grid grid-cols-2 md:grid-cols-5 gap-4 mb-8">
|
||||||
|
<div className="bg-zinc-900 rounded-2xl p-4 border border-zinc-800">
|
||||||
|
<div className="flex items-center gap-2 mb-2">
|
||||||
|
<Share2 size={18} className="text-purple-500" />
|
||||||
|
<span className="text-xs font-bold text-zinc-500 uppercase">Total Splits</span>
|
||||||
|
</div>
|
||||||
|
<div className="text-2xl font-black text-white">{stats.totalSplits}</div>
|
||||||
|
</div>
|
||||||
|
<div className="bg-zinc-900 rounded-2xl p-4 border border-zinc-800">
|
||||||
|
<div className="flex items-center gap-2 mb-2">
|
||||||
|
<Package size={18} className="text-green-500" />
|
||||||
|
<span className="text-xs font-bold text-zinc-500 uppercase">Active</span>
|
||||||
|
</div>
|
||||||
|
<div className="text-2xl font-black text-white">{stats.activeSplits}</div>
|
||||||
|
</div>
|
||||||
|
<div className="bg-zinc-900 rounded-2xl p-4 border border-zinc-800">
|
||||||
|
<div className="flex items-center gap-2 mb-2">
|
||||||
|
<User size={18} className="text-blue-500" />
|
||||||
|
<span className="text-xs font-bold text-zinc-500 uppercase">Hosts</span>
|
||||||
|
</div>
|
||||||
|
<div className="text-2xl font-black text-white">{stats.totalHosts}</div>
|
||||||
|
</div>
|
||||||
|
<div className="bg-zinc-900 rounded-2xl p-4 border border-zinc-800">
|
||||||
|
<div className="flex items-center gap-2 mb-2">
|
||||||
|
<Users size={18} className="text-orange-500" />
|
||||||
|
<span className="text-xs font-bold text-zinc-500 uppercase">Participants</span>
|
||||||
|
</div>
|
||||||
|
<div className="text-2xl font-black text-white">{stats.totalParticipants}</div>
|
||||||
|
</div>
|
||||||
|
<div className="bg-zinc-900 rounded-2xl p-4 border border-zinc-800">
|
||||||
|
<div className="flex items-center gap-2 mb-2">
|
||||||
|
<DollarSign size={18} className="text-yellow-500" />
|
||||||
|
<span className="text-xs font-bold text-zinc-500 uppercase">Total Volume</span>
|
||||||
|
</div>
|
||||||
|
<div className="text-2xl font-black text-white">{stats.totalVolume}cl</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Splits List */}
|
||||||
|
<AdminSplitsList splits={splits} />
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
231
src/app/admin/tastings/AdminTastingsList.tsx
Normal file
231
src/app/admin/tastings/AdminTastingsList.tsx
Normal file
@@ -0,0 +1,231 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState, useMemo } from 'react';
|
||||||
|
import { Search, User, Star, Wine, MessageSquare } from 'lucide-react';
|
||||||
|
import Image from 'next/image';
|
||||||
|
|
||||||
|
interface Tasting {
|
||||||
|
id: string;
|
||||||
|
bottle_id: string;
|
||||||
|
user_id: string;
|
||||||
|
rating: number;
|
||||||
|
nose: string | null;
|
||||||
|
palate: string | null;
|
||||||
|
finish: string | null;
|
||||||
|
notes: string | null;
|
||||||
|
created_at: string;
|
||||||
|
user: { username: string; display_name: string | null };
|
||||||
|
bottle: { id: string; name: string; distillery: string | null; image_url: string | null } | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface AdminTastingsListProps {
|
||||||
|
tastings: Tasting[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function AdminTastingsList({ tastings }: AdminTastingsListProps) {
|
||||||
|
const [search, setSearch] = useState('');
|
||||||
|
const [filterUser, setFilterUser] = useState<string | null>(null);
|
||||||
|
const [filterRating, setFilterRating] = useState<number | null>(null);
|
||||||
|
|
||||||
|
// Get unique users for filter
|
||||||
|
const users = useMemo(() => {
|
||||||
|
const userMap = new Map<string, string>();
|
||||||
|
tastings.forEach(t => {
|
||||||
|
userMap.set(t.user_id, t.user.display_name || t.user.username);
|
||||||
|
});
|
||||||
|
return Array.from(userMap.entries()).sort((a, b) => a[1].localeCompare(b[1]));
|
||||||
|
}, [tastings]);
|
||||||
|
|
||||||
|
// Filter tastings
|
||||||
|
const filteredTastings = useMemo(() => {
|
||||||
|
let result = tastings;
|
||||||
|
|
||||||
|
if (search) {
|
||||||
|
const searchLower = search.toLowerCase();
|
||||||
|
result = result.filter(t =>
|
||||||
|
t.bottle?.name?.toLowerCase().includes(searchLower) ||
|
||||||
|
t.bottle?.distillery?.toLowerCase().includes(searchLower) ||
|
||||||
|
t.user.username.toLowerCase().includes(searchLower) ||
|
||||||
|
t.notes?.toLowerCase().includes(searchLower) ||
|
||||||
|
t.nose?.toLowerCase().includes(searchLower) ||
|
||||||
|
t.palate?.toLowerCase().includes(searchLower) ||
|
||||||
|
t.finish?.toLowerCase().includes(searchLower)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (filterUser) {
|
||||||
|
result = result.filter(t => t.user_id === filterUser);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (filterRating !== null) {
|
||||||
|
result = result.filter(t => Math.floor(t.rating) === filterRating);
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}, [tastings, search, filterUser, filterRating]);
|
||||||
|
|
||||||
|
const renderStars = (rating: number) => {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-0.5">
|
||||||
|
{[1, 2, 3, 4, 5].map(star => (
|
||||||
|
<Star
|
||||||
|
key={star}
|
||||||
|
size={14}
|
||||||
|
className={star <= rating ? 'text-orange-500 fill-orange-500' : 'text-zinc-700'}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
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, users, or notes..."
|
||||||
|
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={filterUser || ''}
|
||||||
|
onChange={e => setFilterUser(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 Users</option>
|
||||||
|
{users.map(([id, name]) => (
|
||||||
|
<option key={id} value={id}>{name}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
|
||||||
|
<select
|
||||||
|
value={filterRating ?? ''}
|
||||||
|
onChange={e => setFilterRating(e.target.value ? parseInt(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 Ratings</option>
|
||||||
|
<option value="5">5 Stars</option>
|
||||||
|
<option value="4">4 Stars</option>
|
||||||
|
<option value="3">3 Stars</option>
|
||||||
|
<option value="2">2 Stars</option>
|
||||||
|
<option value="1">1 Star</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Results */}
|
||||||
|
<div className="text-sm text-zinc-500">
|
||||||
|
Showing {filteredTastings.length} of {tastings.length} tastings
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Tastings List */}
|
||||||
|
<div className="space-y-3">
|
||||||
|
{filteredTastings.map(tasting => {
|
||||||
|
const hasNotes = tasting.notes || tasting.nose || tasting.palate || tasting.finish;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={tasting.id}
|
||||||
|
className="bg-zinc-900 rounded-2xl border border-zinc-800 p-4 hover:border-zinc-700 transition-colors"
|
||||||
|
>
|
||||||
|
<div className="flex gap-4">
|
||||||
|
{/* Bottle Image */}
|
||||||
|
<div className="w-16 h-16 rounded-xl overflow-hidden bg-zinc-800 flex-shrink-0">
|
||||||
|
{tasting.bottle?.image_url ? (
|
||||||
|
<Image
|
||||||
|
src={tasting.bottle.image_url}
|
||||||
|
alt={tasting.bottle.name || 'Bottle'}
|
||||||
|
width={64}
|
||||||
|
height={64}
|
||||||
|
className="w-full h-full object-cover"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className="w-full h-full flex items-center justify-center">
|
||||||
|
<Wine size={24} className="text-zinc-700" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Content */}
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className="flex items-start justify-between gap-2 mb-1">
|
||||||
|
<div>
|
||||||
|
<h3 className="font-bold text-white truncate">
|
||||||
|
{tasting.bottle?.name || 'Unknown Bottle'}
|
||||||
|
</h3>
|
||||||
|
<p className="text-sm text-zinc-500 truncate">
|
||||||
|
{tasting.bottle?.distillery || 'Unknown Distillery'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex-shrink-0">
|
||||||
|
{tasting.rating > 0 ? renderStars(tasting.rating) : (
|
||||||
|
<span className="text-xs text-zinc-600">No rating</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Notes Preview */}
|
||||||
|
{hasNotes && (
|
||||||
|
<div className="mt-2 space-y-1">
|
||||||
|
{tasting.nose && (
|
||||||
|
<p className="text-xs text-zinc-400">
|
||||||
|
<span className="text-zinc-600">Nose:</span> {tasting.nose.slice(0, 80)}...
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{tasting.notes && (
|
||||||
|
<p className="text-xs text-zinc-400 line-clamp-2">
|
||||||
|
{tasting.notes.slice(0, 150)}...
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Meta */}
|
||||||
|
<div className="flex items-center gap-4 mt-2 text-xs text-zinc-600">
|
||||||
|
<span className="flex items-center gap-1">
|
||||||
|
<User size={12} />
|
||||||
|
{tasting.user.display_name || tasting.user.username}
|
||||||
|
</span>
|
||||||
|
<span>
|
||||||
|
{new Date(tasting.created_at).toLocaleDateString('de-DE', {
|
||||||
|
day: '2-digit',
|
||||||
|
month: '2-digit',
|
||||||
|
year: 'numeric',
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit',
|
||||||
|
})}
|
||||||
|
</span>
|
||||||
|
{hasNotes && (
|
||||||
|
<span className="flex items-center gap-1 text-green-500">
|
||||||
|
<MessageSquare size={12} />
|
||||||
|
Has notes
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Empty State */}
|
||||||
|
{filteredTastings.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">
|
||||||
|
<Star size={32} className="text-zinc-600" />
|
||||||
|
</div>
|
||||||
|
<p className="text-lg font-bold text-white mb-2">No Tastings Found</p>
|
||||||
|
<p className="text-sm text-zinc-500">No tastings match your filters.</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
142
src/app/admin/tastings/page.tsx
Normal file
142
src/app/admin/tastings/page.tsx
Normal file
@@ -0,0 +1,142 @@
|
|||||||
|
export const dynamic = 'force-dynamic';
|
||||||
|
|
||||||
|
import { createClient } from '@/lib/supabase/server';
|
||||||
|
import { redirect } from 'next/navigation';
|
||||||
|
import { checkIsAdmin } from '@/services/track-api-usage';
|
||||||
|
import Link from 'next/link';
|
||||||
|
import { ArrowLeft, Wine, User, Calendar, Star, MessageSquare, Sparkles } from 'lucide-react';
|
||||||
|
import AdminTastingsList from './AdminTastingsList';
|
||||||
|
|
||||||
|
export default async function AdminTastingsPage() {
|
||||||
|
const supabase = await createClient();
|
||||||
|
const { data: { user } } = await supabase.auth.getUser();
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
redirect('/');
|
||||||
|
}
|
||||||
|
|
||||||
|
const isAdmin = await checkIsAdmin(user.id);
|
||||||
|
if (!isAdmin) {
|
||||||
|
redirect('/');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fetch all tastings from all users
|
||||||
|
const { data: tastingsRaw, error } = await supabase
|
||||||
|
.from('tastings')
|
||||||
|
.select(`
|
||||||
|
id,
|
||||||
|
bottle_id,
|
||||||
|
user_id,
|
||||||
|
rating,
|
||||||
|
nose,
|
||||||
|
palate,
|
||||||
|
finish,
|
||||||
|
notes,
|
||||||
|
created_at,
|
||||||
|
bottles (id, name, distillery, image_url)
|
||||||
|
`)
|
||||||
|
.order('created_at', { ascending: false })
|
||||||
|
.limit(500);
|
||||||
|
|
||||||
|
// Get unique user IDs
|
||||||
|
const userIds = Array.from(new Set(tastingsRaw?.map(t => t.user_id) || []));
|
||||||
|
|
||||||
|
// Fetch profiles for users
|
||||||
|
const { data: profiles } = userIds.length > 0
|
||||||
|
? await supabase.from('profiles').select('id, username, display_name').in('id', userIds)
|
||||||
|
: { data: [] };
|
||||||
|
|
||||||
|
// Combine tastings with user info
|
||||||
|
const tastings = tastingsRaw?.map(tasting => ({
|
||||||
|
...tasting,
|
||||||
|
user: profiles?.find(p => p.id === tasting.user_id) || { username: 'Unknown', display_name: null },
|
||||||
|
bottle: tasting.bottles as any,
|
||||||
|
})) || [];
|
||||||
|
|
||||||
|
// Calculate stats
|
||||||
|
const stats = {
|
||||||
|
totalTastings: tastings.length,
|
||||||
|
totalUsers: userIds.length,
|
||||||
|
avgRating: tastings.length > 0
|
||||||
|
? tastings.reduce((sum, t) => sum + (t.rating || 0), 0) / tastings.filter(t => t.rating > 0).length
|
||||||
|
: 0,
|
||||||
|
withNotes: tastings.filter(t => t.notes || t.nose || t.palate || t.finish).length,
|
||||||
|
todayCount: tastings.filter(t => {
|
||||||
|
const today = new Date();
|
||||||
|
const tastingDate = new Date(t.created_at);
|
||||||
|
return tastingDate.toDateString() === today.toDateString();
|
||||||
|
}).length,
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<main className="min-h-screen bg-zinc-950 p-6 pb-24">
|
||||||
|
<div className="max-w-7xl mx-auto">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-center gap-4 mb-8">
|
||||||
|
<Link
|
||||||
|
href="/admin"
|
||||||
|
className="p-2 rounded-xl bg-zinc-900 border border-zinc-800 text-zinc-400 hover:text-white hover:border-zinc-700 transition-colors"
|
||||||
|
>
|
||||||
|
<ArrowLeft size={20} />
|
||||||
|
</Link>
|
||||||
|
<div className="flex-1">
|
||||||
|
<h1 className="text-2xl font-bold text-white">All Tastings</h1>
|
||||||
|
<p className="text-sm text-zinc-500">
|
||||||
|
View all tasting notes from all users
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<div className="mb-6 p-4 bg-red-500/20 border border-red-500/50 rounded-xl text-red-400 text-sm">
|
||||||
|
Error loading tastings: {error.message}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Stats Cards */}
|
||||||
|
<div className="grid grid-cols-2 md:grid-cols-5 gap-4 mb-8">
|
||||||
|
<div className="bg-zinc-900 rounded-2xl p-4 border border-zinc-800">
|
||||||
|
<div className="flex items-center gap-2 mb-2">
|
||||||
|
<Sparkles size={18} className="text-purple-500" />
|
||||||
|
<span className="text-xs font-bold text-zinc-500 uppercase">Total Tastings</span>
|
||||||
|
</div>
|
||||||
|
<div className="text-2xl font-black text-white">{stats.totalTastings}</div>
|
||||||
|
</div>
|
||||||
|
<div className="bg-zinc-900 rounded-2xl p-4 border border-zinc-800">
|
||||||
|
<div className="flex items-center gap-2 mb-2">
|
||||||
|
<User size={18} className="text-blue-500" />
|
||||||
|
<span className="text-xs font-bold text-zinc-500 uppercase">Users</span>
|
||||||
|
</div>
|
||||||
|
<div className="text-2xl font-black text-white">{stats.totalUsers}</div>
|
||||||
|
</div>
|
||||||
|
<div className="bg-zinc-900 rounded-2xl p-4 border border-zinc-800">
|
||||||
|
<div className="flex items-center gap-2 mb-2">
|
||||||
|
<Star size={18} className="text-yellow-500" />
|
||||||
|
<span className="text-xs font-bold text-zinc-500 uppercase">Avg Rating</span>
|
||||||
|
</div>
|
||||||
|
<div className="text-2xl font-black text-white">
|
||||||
|
{stats.avgRating > 0 ? stats.avgRating.toFixed(1) : 'N/A'}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="bg-zinc-900 rounded-2xl p-4 border border-zinc-800">
|
||||||
|
<div className="flex items-center gap-2 mb-2">
|
||||||
|
<MessageSquare size={18} className="text-green-500" />
|
||||||
|
<span className="text-xs font-bold text-zinc-500 uppercase">With Notes</span>
|
||||||
|
</div>
|
||||||
|
<div className="text-2xl font-black text-white">{stats.withNotes}</div>
|
||||||
|
</div>
|
||||||
|
<div className="bg-zinc-900 rounded-2xl p-4 border border-zinc-800">
|
||||||
|
<div className="flex items-center gap-2 mb-2">
|
||||||
|
<Calendar size={18} className="text-orange-500" />
|
||||||
|
<span className="text-xs font-bold text-zinc-500 uppercase">Today</span>
|
||||||
|
</div>
|
||||||
|
<div className="text-2xl font-black text-white">{stats.todayCount}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Tastings List */}
|
||||||
|
<AdminTastingsList tastings={tastings} />
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user