- Create /admin/bottles page with comprehensive bottle overview - Show stats: total bottles, total users, avg rating, top distilleries - AdminBottlesList with search, filter by user/category, sorting - Display bottle images, ratings, user info, and dates - Add 'All Bottles' link to admin dashboard
162 lines
7.0 KiB
TypeScript
162 lines
7.0 KiB
TypeScript
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, Search, Filter } from 'lucide-react';
|
|
import AdminBottlesList from './AdminBottlesList';
|
|
|
|
export default async function AdminBottlesPage() {
|
|
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 bottles from all users with user info
|
|
const { data: bottlesRaw, error } = await supabase
|
|
.from('bottles')
|
|
.select(`
|
|
id,
|
|
name,
|
|
distillery,
|
|
image_url,
|
|
abv,
|
|
age,
|
|
category,
|
|
status,
|
|
created_at,
|
|
user_id,
|
|
tastings (
|
|
id,
|
|
rating
|
|
)
|
|
`)
|
|
.order('created_at', { ascending: false })
|
|
.limit(500);
|
|
|
|
// Get unique user IDs
|
|
const userIds = Array.from(new Set(bottlesRaw?.map(b => b.user_id) || []));
|
|
|
|
// Fetch profiles for these users
|
|
const { data: profiles } = userIds.length > 0
|
|
? await supabase.from('profiles').select('id, username, display_name').in('id', userIds)
|
|
: { data: [] };
|
|
|
|
// Combine bottles with user info
|
|
const bottles = bottlesRaw?.map(bottle => ({
|
|
...bottle,
|
|
user: profiles?.find(p => p.id === bottle.user_id) || { username: 'Unknown', display_name: null }
|
|
})) || [];
|
|
|
|
// Calculate stats
|
|
const stats = {
|
|
totalBottles: bottles.length,
|
|
totalUsers: userIds.length,
|
|
avgRating: bottles.reduce((sum, b) => {
|
|
const ratings = b.tastings?.map((t: any) => t.rating).filter((r: number) => r > 0) || [];
|
|
const avg = ratings.length > 0 ? ratings.reduce((a: number, b: number) => a + b, 0) / ratings.length : 0;
|
|
return sum + avg;
|
|
}, 0) / bottles.filter(b => b.tastings && b.tastings.length > 0).length || 0,
|
|
topDistilleries: Object.entries(
|
|
bottles.reduce((acc: Record<string, number>, b) => {
|
|
const d = b.distillery || 'Unknown';
|
|
acc[d] = (acc[d] || 0) + 1;
|
|
return acc;
|
|
}, {})
|
|
).sort((a, b) => b[1] - a[1]).slice(0, 5),
|
|
};
|
|
|
|
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 Bottles</h1>
|
|
<p className="text-sm text-zinc-500">
|
|
View all scanned bottles 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 bottles: {error.message}
|
|
</div>
|
|
)}
|
|
|
|
{/* Stats Cards */}
|
|
<div className="grid grid-cols-2 md:grid-cols-4 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">
|
|
<Wine size={18} className="text-orange-500" />
|
|
<span className="text-xs font-bold text-zinc-500 uppercase">Total Bottles</span>
|
|
</div>
|
|
<div className="text-2xl font-black text-white">{stats.totalBottles}</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">Total 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">
|
|
<Calendar size={18} className="text-green-500" />
|
|
<span className="text-xs font-bold text-zinc-500 uppercase">Top Distillery</span>
|
|
</div>
|
|
<div className="text-lg font-black text-white truncate">
|
|
{stats.topDistilleries[0]?.[0] || 'N/A'}
|
|
</div>
|
|
{stats.topDistilleries[0] && (
|
|
<div className="text-xs text-zinc-500">{stats.topDistilleries[0][1]} bottles</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Top Distilleries */}
|
|
<div className="bg-zinc-900 rounded-2xl p-4 border border-zinc-800 mb-8">
|
|
<h3 className="text-sm font-bold text-zinc-400 uppercase mb-3">Top 5 Distilleries</h3>
|
|
<div className="flex flex-wrap gap-2">
|
|
{stats.topDistilleries.map(([name, count]) => (
|
|
<span
|
|
key={name}
|
|
className="px-3 py-1.5 bg-zinc-800 rounded-lg text-sm text-zinc-300"
|
|
>
|
|
{name} <span className="text-orange-500 font-bold">({count})</span>
|
|
</span>
|
|
))}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Bottles List - Client Component for search/filter */}
|
|
<AdminBottlesList bottles={bottles} />
|
|
</div>
|
|
</main>
|
|
);
|
|
}
|