fediversion/frontend/app/venues/page.tsx
fullsizemalt 3aaf35d43b
Some checks failed
Deploy Fediversion / deploy (push) Failing after 1s
refactor(api): standardize venues endpoint
- Backend: /api/venues returns PaginatedResponse envelope
- Frontend: Updated VenuesPage, AdminVenuesPage, VerticalVenuesPage to consume envelope
2025-12-30 20:35:59 -08:00

233 lines
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/`)
const venuesEnvelope = await venuesRes.json()
const venuesData: Venue[] = venuesEnvelope.data || []
// Fetch show counts for each venue (batch approach)
const showsRes = await fetch(`${getApiUrl()}/shows/?limit=1000`)
const showsEnvelope = await showsRes.json()
const showsData = showsEnvelope.data || []
// 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>
)
}