diff --git a/src/app/admin/page.tsx b/src/app/admin/page.tsx index 007a15b..c559b81 100644 --- a/src/app/admin/page.tsx +++ b/src/app/admin/page.tsx @@ -129,6 +129,18 @@ export default async function AdminPage() { > All Bottles + + All Splits + + + All Tastings + (null); + const [filterStatus, setFilterStatus] = useState<'all' | 'active' | 'closed'>('all'); + + // Get unique hosts for filter + const hosts = useMemo(() => { + const hostMap = new Map(); + 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 ( +
+ {/* Filters */} +
+
+ + 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" + /> +
+ +
+ + + +
+
+ + {/* Results */} +
+ Showing {filteredSplits.length} of {splits.length} splits +
+ + {/* Splits Grid */} +
+ {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 ( +
+ {/* Image */} +
+ {split.bottle?.image_url ? ( + {split.bottle.name + ) : ( +
+ +
+ )} + + {/* Status Badge */} + + {split.is_active ? : } + {split.is_active ? 'Active' : 'Closed'} + + + {/* Participants Badge */} + + + {split.participantCount} + +
+ + {/* Info */} +
+

+ {split.bottle?.name || 'Unknown Bottle'} +

+

+ {split.bottle?.distillery || 'Unknown Distillery'} +

+ + {/* Progress Bar */} +
+
+ {split.totalReserved}cl reserved + {remaining}cl left +
+
+
+
+
+ + {/* Details */} +
+ + + {split.host.display_name || split.host.username} + + + {new Date(split.created_at).toLocaleDateString('de-DE')} + +
+ + {/* Price & Link */} +
+ + {split.price_bottle.toFixed(2)}€ + + + + {split.public_slug} + +
+
+
+ ); + })} +
+ + {/* Empty State */} + {filteredSplits.length === 0 && ( +
+
+ +
+

No Splits Found

+

No bottle splits match your filters.

+
+ )} +
+ ); +} diff --git a/src/app/admin/splits/page.tsx b/src/app/admin/splits/page.tsx new file mode 100644 index 0000000..620292c --- /dev/null +++ b/src/app/admin/splits/page.tsx @@ -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 ( +
+
+ {/* Header */} +
+ + + +
+

All Bottle Splits

+

+ View all bottle splits from all users +

+
+
+ + {error && ( +
+ Error loading splits: {error.message} +
+ )} + + {/* Stats Cards */} +
+
+
+ + Total Splits +
+
{stats.totalSplits}
+
+
+
+ + Active +
+
{stats.activeSplits}
+
+
+
+ + Hosts +
+
{stats.totalHosts}
+
+
+
+ + Participants +
+
{stats.totalParticipants}
+
+
+
+ + Total Volume +
+
{stats.totalVolume}cl
+
+
+ + {/* Splits List */} + +
+
+ ); +} diff --git a/src/app/admin/tastings/AdminTastingsList.tsx b/src/app/admin/tastings/AdminTastingsList.tsx new file mode 100644 index 0000000..d8d278a --- /dev/null +++ b/src/app/admin/tastings/AdminTastingsList.tsx @@ -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(null); + const [filterRating, setFilterRating] = useState(null); + + // Get unique users for filter + const users = useMemo(() => { + const userMap = new Map(); + 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 ( +
+ {[1, 2, 3, 4, 5].map(star => ( + + ))} +
+ ); + }; + + return ( +
+ {/* Filters */} +
+
+ + 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" + /> +
+ +
+ + + +
+
+ + {/* Results */} +
+ Showing {filteredTastings.length} of {tastings.length} tastings +
+ + {/* Tastings List */} +
+ {filteredTastings.map(tasting => { + const hasNotes = tasting.notes || tasting.nose || tasting.palate || tasting.finish; + + return ( +
+
+ {/* Bottle Image */} +
+ {tasting.bottle?.image_url ? ( + {tasting.bottle.name + ) : ( +
+ +
+ )} +
+ + {/* Content */} +
+
+
+

+ {tasting.bottle?.name || 'Unknown Bottle'} +

+

+ {tasting.bottle?.distillery || 'Unknown Distillery'} +

+
+
+ {tasting.rating > 0 ? renderStars(tasting.rating) : ( + No rating + )} +
+
+ + {/* Notes Preview */} + {hasNotes && ( +
+ {tasting.nose && ( +

+ Nose: {tasting.nose.slice(0, 80)}... +

+ )} + {tasting.notes && ( +

+ {tasting.notes.slice(0, 150)}... +

+ )} +
+ )} + + {/* Meta */} +
+ + + {tasting.user.display_name || tasting.user.username} + + + {new Date(tasting.created_at).toLocaleDateString('de-DE', { + day: '2-digit', + month: '2-digit', + year: 'numeric', + hour: '2-digit', + minute: '2-digit', + })} + + {hasNotes && ( + + + Has notes + + )} +
+
+
+
+ ); + })} +
+ + {/* Empty State */} + {filteredTastings.length === 0 && ( +
+
+ +
+

No Tastings Found

+

No tastings match your filters.

+
+ )} +
+ ); +} diff --git a/src/app/admin/tastings/page.tsx b/src/app/admin/tastings/page.tsx new file mode 100644 index 0000000..199dd35 --- /dev/null +++ b/src/app/admin/tastings/page.tsx @@ -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 ( +
+
+ {/* Header */} +
+ + + +
+

All Tastings

+

+ View all tasting notes from all users +

+
+
+ + {error && ( +
+ Error loading tastings: {error.message} +
+ )} + + {/* Stats Cards */} +
+
+
+ + Total Tastings +
+
{stats.totalTastings}
+
+
+
+ + Users +
+
{stats.totalUsers}
+
+
+
+ + Avg Rating +
+
+ {stats.avgRating > 0 ? stats.avgRating.toFixed(1) : 'N/A'} +
+
+
+
+ + With Notes +
+
{stats.withNotes}
+
+
+
+ + Today +
+
{stats.todayCount}
+
+
+ + {/* Tastings List */} + +
+
+ ); +}