elmeg-demo/frontend/app/venues/page.tsx
fullsizemalt ee311c0bc4 feat: Complete venues overhaul
- Fix ratings API to support venue_id and tour_id
- Add migration for new rating columns
- Venues list: search, state filter, sort by name/shows
- Venues detail: show list with dates, venue stats, error handling
- Remove broken EntityRating/SocialWrapper from venue pages
2025-12-21 17:51:05 -08:00

216 lines
8.2 KiB
TypeScript

"use client"
import { useEffect, useState, useMemo } from "react"
import { getApiUrl } from "@/lib/api-config"
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
import { Input } from "@/components/ui/input"
import { Button } from "@/components/ui/button"
import Link from "next/link"
import { MapPin, Search, Calendar, ArrowUpDown } from "lucide-react"
interface Venue {
id: number
name: string
city: string
state: string
country: string
show_count?: number
}
type SortOption = "name" | "city" | "shows"
export default function VenuesPage() {
const [venues, setVenues] = useState<Venue[]>([])
const [loading, setLoading] = useState(true)
const [searchQuery, setSearchQuery] = useState("")
const [stateFilter, setStateFilter] = useState<string>("")
const [sortBy, setSortBy] = useState<SortOption>("name")
useEffect(() => {
async function fetchVenues() {
try {
// Fetch venues
const venuesRes = await fetch(`${getApiUrl()}/venues/?limit=500`)
const venuesData: Venue[] = await venuesRes.json()
// Fetch show counts for each venue (batch approach)
const showsRes = await fetch(`${getApiUrl()}/shows/?limit=1000`)
const showsData = await showsRes.json()
// Count shows per venue
const showCounts: Record<number, number> = {}
showsData.forEach((show: any) => {
if (show.venue_id) {
showCounts[show.venue_id] = (showCounts[show.venue_id] || 0) + 1
}
})
// Merge counts into venues
const venuesWithCounts = venuesData.map(v => ({
...v,
show_count: showCounts[v.id] || 0
}))
setVenues(venuesWithCounts)
} catch (error) {
console.error("Failed to fetch venues:", error)
} finally {
setLoading(false)
}
}
fetchVenues()
}, [])
// Get unique states for filter dropdown
const uniqueStates = useMemo(() => {
const states = [...new Set(venues.map(v => v.state).filter(Boolean))]
return states.sort()
}, [venues])
// Filter and sort venues
const filteredVenues = useMemo(() => {
let result = venues
// Search filter
if (searchQuery) {
const query = searchQuery.toLowerCase()
result = result.filter(v =>
v.name.toLowerCase().includes(query) ||
v.city.toLowerCase().includes(query)
)
}
// State filter
if (stateFilter) {
result = result.filter(v => v.state === stateFilter)
}
// Sort
switch (sortBy) {
case "name":
result = [...result].sort((a, b) => a.name.localeCompare(b.name))
break
case "city":
result = [...result].sort((a, b) => a.city.localeCompare(b.city))
break
case "shows":
result = [...result].sort((a, b) => (b.show_count || 0) - (a.show_count || 0))
break
}
return result
}, [venues, searchQuery, stateFilter, sortBy])
if (loading) {
return (
<div className="container py-10">
<div className="animate-pulse space-y-4">
<div className="h-8 bg-muted rounded w-48" />
<div className="h-12 bg-muted rounded" />
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
{[...Array(9)].map((_, i) => (
<div key={i} className="h-32 bg-muted rounded" />
))}
</div>
</div>
</div>
)
}
return (
<div className="container py-10 space-y-6">
{/* Header */}
<div className="flex flex-col gap-2">
<h1 className="text-3xl font-bold tracking-tight">Venues</h1>
<p className="text-muted-foreground">
{venues.length} venues where the magic happens
</p>
</div>
{/* Search & Filters */}
<div className="flex flex-col sm:flex-row gap-4">
<div className="relative flex-1">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Input
placeholder="Search venues or cities..."
className="pl-10"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
/>
</div>
<select
className="h-10 px-3 rounded-md border bg-background text-sm"
value={stateFilter}
onChange={(e) => setStateFilter(e.target.value)}
>
<option value="">All States</option>
{uniqueStates.map(state => (
<option key={state} value={state}>{state}</option>
))}
</select>
<div className="flex gap-2">
<Button
variant={sortBy === "name" ? "default" : "outline"}
size="sm"
onClick={() => setSortBy("name")}
>
<ArrowUpDown className="h-3 w-3 mr-1" />
Name
</Button>
<Button
variant={sortBy === "shows" ? "default" : "outline"}
size="sm"
onClick={() => setSortBy("shows")}
>
<Calendar className="h-3 w-3 mr-1" />
Shows
</Button>
</div>
</div>
{/* Results count */}
{(searchQuery || stateFilter) && (
<p className="text-sm text-muted-foreground">
Showing {filteredVenues.length} of {venues.length} venues
</p>
)}
{/* Venue Grid */}
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
{filteredVenues.map((venue) => (
<Link key={venue.id} href={`/venues/${venue.id}`}>
<Card className="h-full hover:bg-accent/50 transition-colors cursor-pointer group">
<CardHeader className="pb-2">
<CardTitle className="flex items-center gap-2 text-lg group-hover:text-primary transition-colors">
<MapPin className="h-5 w-5 text-green-500 flex-shrink-0" />
<span className="truncate">{venue.name}</span>
</CardTitle>
</CardHeader>
<CardContent>
<div className="flex items-center justify-between">
<p className="text-muted-foreground text-sm">
{venue.city}{venue.state ? `, ${venue.state}` : ""}
</p>
{(venue.show_count || 0) > 0 && (
<span className="text-xs bg-primary/10 text-primary px-2 py-1 rounded-full font-medium">
{venue.show_count} {venue.show_count === 1 ? "show" : "shows"}
</span>
)}
</div>
</CardContent>
</Card>
</Link>
))}
</div>
{filteredVenues.length === 0 && (
<div className="text-center py-12 text-muted-foreground">
<MapPin className="h-12 w-12 mx-auto mb-4 opacity-50" />
<p>No venues found matching your search.</p>
</div>
)}
</div>
)
}