231 lines
8.9 KiB
TypeScript
231 lines
8.9 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
|
|
slug?: 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 [error, setError] = useState<string | null>(null)
|
|
const [searchQuery, setSearchQuery] = useState("")
|
|
const [stateFilter, setStateFilter] = useState<string>("")
|
|
const [sortBy, setSortBy] = useState<SortOption>("name")
|
|
|
|
useEffect(() => {
|
|
async function fetchVenues() {
|
|
try {
|
|
setError(null)
|
|
// 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)
|
|
setError("Failed to load venues. Please try again.")
|
|
} 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 (error) {
|
|
return (
|
|
<div className="container py-10 text-center">
|
|
<MapPin className="h-12 w-12 mx-auto mb-4 text-muted-foreground" />
|
|
<h1 className="text-2xl font-bold mb-2">Unable to Load Venues</h1>
|
|
<p className="text-muted-foreground mb-4">{error}</p>
|
|
<Button onClick={() => window.location.reload()}>Try Again</Button>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
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.slug}`}>
|
|
<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>
|
|
)
|
|
}
|