449 lines
17 KiB
JavaScript
449 lines
17 KiB
JavaScript
import { useEffect, useMemo, useRef, useState } from 'react'
|
||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||
import { getArbaShows, refreshArbaShows } from '../api'
|
||
import Card from '../components/Card'
|
||
import Button from '../components/Button'
|
||
import PageHeader from '../components/PageHeader'
|
||
import EmptyState from '../components/EmptyState'
|
||
import AddToEventModal from '../components/AddToEventModal'
|
||
import { Input } from '../components/FormControls'
|
||
|
||
const PAGE_SIZE = 50
|
||
|
||
// The five buckets lib/arba.ts normalizes to. 'OTHER' is intentionally omitted
|
||
// from the filter — it's a small catch-all for rows whose show-information
|
||
// string didn't match the expected pattern, and they still show when no
|
||
// show-type filter is active.
|
||
const SHOW_TYPE_OPTIONS = [
|
||
{ value: 'ALL BREED OPEN', label: 'All Breed — Open' },
|
||
{ value: 'ALL BREED YOUTH', label: 'All Breed — Youth' },
|
||
{ value: 'SPECIALTY OPEN', label: 'Specialty — Open' },
|
||
{ value: 'SPECIALTY YOUTH', label: 'Specialty — Youth' },
|
||
{ value: 'FAIR SHOW', label: 'Fair Show' },
|
||
]
|
||
|
||
function todayIso() {
|
||
return new Date().toISOString().slice(0, 10)
|
||
}
|
||
|
||
function formatTimestamp(iso) {
|
||
if (!iso) return 'never'
|
||
const d = new Date(iso)
|
||
if (Number.isNaN(d.getTime())) return 'unknown'
|
||
return d.toLocaleString(undefined, {
|
||
month: 'short',
|
||
day: 'numeric',
|
||
hour: 'numeric',
|
||
minute: '2-digit',
|
||
})
|
||
}
|
||
|
||
function ChevronIcon() {
|
||
return (
|
||
<svg viewBox="0 0 20 20" className="h-3.5 w-3.5" fill="none" stroke="currentColor" strokeWidth="1.5">
|
||
<path d="m6 8 4 4 4-4" strokeLinecap="round" strokeLinejoin="round" />
|
||
</svg>
|
||
)
|
||
}
|
||
|
||
// Compact checkbox dropdown used for State / District / Show Type. Closes on
|
||
// outside click. `options` is [{ value, label }]; `selected` is an array of
|
||
// values.
|
||
function MultiSelect({ label, options, selected, onChange }) {
|
||
const [open, setOpen] = useState(false)
|
||
const ref = useRef(null)
|
||
|
||
useEffect(() => {
|
||
if (!open) return undefined
|
||
function onClick(e) {
|
||
if (ref.current && !ref.current.contains(e.target)) setOpen(false)
|
||
}
|
||
document.addEventListener('mousedown', onClick)
|
||
return () => document.removeEventListener('mousedown', onClick)
|
||
}, [open])
|
||
|
||
function toggle(value) {
|
||
if (selected.includes(value)) onChange(selected.filter((v) => v !== value))
|
||
else onChange([...selected, value])
|
||
}
|
||
|
||
return (
|
||
<div className="relative" ref={ref}>
|
||
<button
|
||
type="button"
|
||
onClick={() => setOpen((v) => !v)}
|
||
className="flex min-h-[44px] md:min-h-[32px] items-center gap-2 rounded-md border border-border bg-surface-raised px-3 py-1.5 font-body text-sm text-text-primary"
|
||
>
|
||
<span>{label}</span>
|
||
{selected.length > 0 && (
|
||
<span className="rounded-full bg-accent px-1.5 text-xs text-surface">{selected.length}</span>
|
||
)}
|
||
<ChevronIcon />
|
||
</button>
|
||
{open && (
|
||
<div className="absolute z-10 mt-1 max-h-72 w-56 overflow-y-auto rounded-md border border-border bg-surface-raised p-2 shadow-lg">
|
||
{selected.length > 0 && (
|
||
<button
|
||
type="button"
|
||
onClick={() => onChange([])}
|
||
className="mb-1 w-full rounded px-2 py-1 text-left font-body text-xs text-accent hover:bg-surface-sunken"
|
||
>
|
||
Clear {label}
|
||
</button>
|
||
)}
|
||
{options.map((option) => (
|
||
<label
|
||
key={option.value}
|
||
className="flex min-h-[40px] cursor-pointer items-center gap-2 rounded px-2 py-1 font-body md:min-h-0 text-sm text-text-primary hover:bg-surface-sunken"
|
||
>
|
||
<input
|
||
type="checkbox"
|
||
checked={selected.includes(option.value)}
|
||
onChange={() => toggle(option.value)}
|
||
className="accent-accent"
|
||
/>
|
||
<span>{option.label || '—'}</span>
|
||
</label>
|
||
))}
|
||
</div>
|
||
)}
|
||
</div>
|
||
)
|
||
}
|
||
|
||
function MapPinIcon() {
|
||
return (
|
||
<svg viewBox="0 0 20 20" className="inline h-4 w-4 align-text-bottom" fill="currentColor">
|
||
<path d="M10 2a5 5 0 0 0-5 5c0 3.5 5 11 5 11s5-7.5 5-11a5 5 0 0 0-5-5Zm0 7a2 2 0 1 1 0-4 2 2 0 0 1 0 4Z" />
|
||
</svg>
|
||
)
|
||
}
|
||
|
||
// Phone/tablet layout for one show: the same fields as a table row, stacked,
|
||
// so the list never scrolls sideways.
|
||
function ShowListCard({ show: s, onAdd }) {
|
||
const place = [s.city, s.state].filter(Boolean).join(', ')
|
||
return (
|
||
<Card className="flex flex-col gap-2 p-4 font-body text-sm">
|
||
<div className="flex items-start justify-between gap-3">
|
||
<div className="min-w-0">
|
||
<p className="font-medium text-text-primary">{s.club_name}</p>
|
||
<p className="text-xs text-text-secondary">
|
||
{s.show_type_full || s.show_type}
|
||
{s.special_breed ? ` · ${s.special_breed}` : ''}
|
||
</p>
|
||
</div>
|
||
<p className="shrink-0 whitespace-nowrap text-text-primary">{s.raw_date || s.date || '—'}</p>
|
||
</div>
|
||
<p className="text-text-secondary">
|
||
{place || '—'}
|
||
{s.district ? ` · District ${s.district}` : ''}
|
||
</p>
|
||
{s.secretary || s.email ? (
|
||
<p className="text-text-secondary">
|
||
{s.secretary}
|
||
{s.secretary && s.email ? ' · ' : ''}
|
||
{s.email ? (
|
||
<a href={`mailto:${s.email}`} className="break-all text-accent hover:underline">
|
||
{s.email}
|
||
</a>
|
||
) : null}
|
||
</p>
|
||
) : null}
|
||
<div className="flex flex-wrap items-center gap-x-4">
|
||
{s.maps_url ? (
|
||
<a
|
||
href={s.maps_url}
|
||
target="_blank"
|
||
rel="noreferrer"
|
||
className="inline-flex min-h-[44px] items-center gap-1 text-accent hover:underline"
|
||
>
|
||
<MapPinIcon /> Map
|
||
</a>
|
||
) : null}
|
||
{s.catalog_url ? (
|
||
<a
|
||
href={s.catalog_url}
|
||
target="_blank"
|
||
rel="noreferrer"
|
||
className="inline-flex min-h-[44px] items-center text-accent hover:underline"
|
||
>
|
||
Catalog
|
||
</a>
|
||
) : null}
|
||
<Button variant="secondary" size="sm" onClick={onAdd} className="ml-auto">
|
||
Add to Event
|
||
</Button>
|
||
</div>
|
||
</Card>
|
||
)
|
||
}
|
||
|
||
function Shows() {
|
||
const queryClient = useQueryClient()
|
||
const { data, isLoading, isError, error } = useQuery({
|
||
queryKey: ['arba-shows'],
|
||
queryFn: getArbaShows,
|
||
})
|
||
|
||
const refresh = useMutation({
|
||
mutationFn: refreshArbaShows,
|
||
onSuccess: (result) => queryClient.setQueryData(['arba-shows'], result),
|
||
})
|
||
|
||
const shows = useMemo(() => data?.shows ?? [], [data])
|
||
|
||
// Filter state
|
||
const [states, setStates] = useState([])
|
||
const [districts, setDistricts] = useState([])
|
||
const [showTypes, setShowTypes] = useState([])
|
||
const [dateFrom, setDateFrom] = useState(todayIso)
|
||
const [dateTo, setDateTo] = useState('')
|
||
const [keyword, setKeyword] = useState('')
|
||
const [page, setPage] = useState(1)
|
||
const [addingShow, setAddingShow] = useState(null)
|
||
|
||
// Options derived from the loaded data.
|
||
const stateOptions = useMemo(() => {
|
||
const set = [...new Set(shows.map((s) => s.state).filter(Boolean))].sort()
|
||
return set.map((v) => ({ value: v, label: v }))
|
||
}, [shows])
|
||
|
||
const districtOptions = useMemo(() => {
|
||
const set = [...new Set(shows.map((s) => s.district ?? ''))].sort((a, b) => {
|
||
if (a === '') return 1
|
||
if (b === '') return -1
|
||
return Number(a) - Number(b)
|
||
})
|
||
return set.map((v) => ({ value: v, label: v === '' ? '— (none)' : v }))
|
||
}, [shows])
|
||
|
||
const filtered = useMemo(() => {
|
||
const kw = keyword.trim().toLowerCase()
|
||
return shows.filter((s) => {
|
||
if (states.length && !states.includes(s.state)) return false
|
||
if (districts.length && !districts.includes(s.district ?? '')) return false
|
||
if (showTypes.length && !showTypes.includes(s.show_type)) return false
|
||
if (dateFrom && (!s.date || s.date < dateFrom)) return false
|
||
if (dateTo && (!s.date || s.date > dateTo)) return false
|
||
if (kw) {
|
||
const haystack = `${s.club_name} ${s.city} ${s.secretary}`.toLowerCase()
|
||
if (!haystack.includes(kw)) return false
|
||
}
|
||
return true
|
||
})
|
||
}, [shows, states, districts, showTypes, dateFrom, dateTo, keyword])
|
||
|
||
// Snap back to page 1 whenever the filtered set changes size/content.
|
||
const totalPages = Math.max(1, Math.ceil(filtered.length / PAGE_SIZE))
|
||
const currentPage = Math.min(page, totalPages)
|
||
const pageRows = filtered.slice((currentPage - 1) * PAGE_SIZE, currentPage * PAGE_SIZE)
|
||
|
||
function resetFilters() {
|
||
setStates([])
|
||
setDistricts([])
|
||
setShowTypes([])
|
||
setDateFrom(todayIso())
|
||
setDateTo('')
|
||
setKeyword('')
|
||
setPage(1)
|
||
}
|
||
|
||
const hasActiveFilters =
|
||
states.length || districts.length || showTypes.length || dateTo || keyword || dateFrom !== todayIso()
|
||
|
||
return (
|
||
<div className="flex flex-col gap-6">
|
||
<PageHeader
|
||
title="Shows"
|
||
subtitle="Upcoming ARBA-sanctioned shows"
|
||
action={
|
||
<div className="flex items-center gap-3">
|
||
<span className="hidden font-body text-xs text-text-secondary sm:inline">
|
||
Updated {formatTimestamp(data?.fetched_at)}
|
||
{data?.stale ? ' (stale)' : ''}
|
||
</span>
|
||
<Button to="/shows/events" variant="secondary">My Events</Button>
|
||
<Button
|
||
variant="secondary"
|
||
onClick={() => refresh.mutate()}
|
||
disabled={refresh.isPending}
|
||
>
|
||
{refresh.isPending ? 'Refreshing…' : 'Refresh Data'}
|
||
</Button>
|
||
</div>
|
||
}
|
||
/>
|
||
|
||
{/* Filter bar */}
|
||
<div className="flex flex-wrap items-end gap-3">
|
||
<MultiSelect label="State" options={stateOptions} selected={states} onChange={(v) => { setStates(v); setPage(1) }} />
|
||
<MultiSelect label="District" options={districtOptions} selected={districts} onChange={(v) => { setDistricts(v); setPage(1) }} />
|
||
<MultiSelect label="Show Type" options={SHOW_TYPE_OPTIONS} selected={showTypes} onChange={(v) => { setShowTypes(v); setPage(1) }} />
|
||
|
||
<label className="flex flex-col gap-1 font-body text-xs text-text-secondary">
|
||
From
|
||
<Input
|
||
size="sm"
|
||
type="date"
|
||
value={dateFrom}
|
||
onChange={(e) => { setDateFrom(e.target.value); setPage(1) }}
|
||
className=""
|
||
/>
|
||
</label>
|
||
<label className="flex flex-col gap-1 font-body text-xs text-text-secondary">
|
||
To
|
||
<Input
|
||
size="sm"
|
||
type="date"
|
||
value={dateTo}
|
||
onChange={(e) => { setDateTo(e.target.value); setPage(1) }}
|
||
className=""
|
||
/>
|
||
</label>
|
||
|
||
<Input
|
||
size="sm"
|
||
type="text"
|
||
value={keyword}
|
||
onChange={(e) => { setKeyword(e.target.value); setPage(1) }}
|
||
placeholder="Club, city, or secretary"
|
||
aria-label="Search by club, city, or secretary"
|
||
className="w-56"
|
||
/>
|
||
|
||
{hasActiveFilters ? (
|
||
<button
|
||
type="button"
|
||
onClick={resetFilters}
|
||
className="min-h-[44px] md:min-h-[32px] rounded-md px-3 py-1.5 font-body text-sm text-accent hover:bg-surface-sunken"
|
||
>
|
||
Reset Filters
|
||
</button>
|
||
) : null}
|
||
</div>
|
||
|
||
{refresh.isError ? (
|
||
<p className="font-body text-sm text-danger">
|
||
Refresh failed: {refresh.error.message}
|
||
</p>
|
||
) : null}
|
||
|
||
{isError ? (
|
||
<Card>
|
||
<EmptyState
|
||
title="Couldn’t load shows"
|
||
description={error?.message || 'The ARBA listing is unavailable right now. Try refreshing.'}
|
||
action={<Button variant="primary" onClick={() => refresh.mutate()}>Try Again</Button>}
|
||
/>
|
||
</Card>
|
||
) : isLoading ? (
|
||
<Card className="p-8">
|
||
<p className="font-body text-sm text-text-secondary">Loading shows…</p>
|
||
</Card>
|
||
) : filtered.length === 0 ? (
|
||
<Card>
|
||
<EmptyState
|
||
title="No matching shows"
|
||
description="Try widening your date range or clearing filters."
|
||
action={hasActiveFilters ? <Button variant="secondary" onClick={resetFilters}>Reset Filters</Button> : null}
|
||
/>
|
||
</Card>
|
||
) : (
|
||
<>
|
||
<p className="font-body text-sm text-text-secondary">
|
||
{filtered.length} show{filtered.length === 1 ? '' : 's'}
|
||
</p>
|
||
<div className="grid grid-cols-1 gap-3 md:grid-cols-2 xl:hidden">
|
||
{pageRows.map((s, i) => (
|
||
<ShowListCard key={s.sanction_number || i} show={s} onAdd={() => setAddingShow(s)} />
|
||
))}
|
||
</div>
|
||
{/* The full table needs ~900px, which only fits beside the sidebar on wide screens. */}
|
||
<Card className="hidden overflow-x-auto xl:block">
|
||
<table className="w-full min-w-[900px] font-body text-sm">
|
||
<thead>
|
||
<tr className="border-b border-border text-left text-text-secondary">
|
||
<th className="px-3 py-2 font-medium">Date</th>
|
||
<th className="px-3 py-2 font-medium">State</th>
|
||
<th className="px-3 py-2 font-medium">Dist.</th>
|
||
<th className="px-3 py-2 font-medium">City</th>
|
||
<th className="px-3 py-2 font-medium">Club / Show</th>
|
||
<th className="px-3 py-2 font-medium">Specialty</th>
|
||
<th className="px-3 py-2 font-medium">Secretary</th>
|
||
<th className="px-3 py-2 font-medium">Links</th>
|
||
<th className="px-3 py-2 font-medium"></th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
{pageRows.map((s, i) => (
|
||
<tr key={s.sanction_number || i} className={i % 2 === 1 ? 'bg-surface-sunken' : ''}>
|
||
<td className="whitespace-nowrap px-3 py-2 text-text-primary">{s.raw_date || s.date || '—'}</td>
|
||
<td className="px-3 py-2 text-text-primary">{s.state}</td>
|
||
<td className="px-3 py-2 text-text-primary">{s.district || '—'}</td>
|
||
<td className="px-3 py-2 text-text-primary">{s.city}</td>
|
||
<td className="px-3 py-2 text-text-primary">
|
||
<div className="font-medium">{s.club_name}</div>
|
||
<div className="text-xs text-text-secondary">{s.show_type_full || s.show_type}</div>
|
||
</td>
|
||
<td className="px-3 py-2 text-text-primary">{s.special_breed || '—'}</td>
|
||
<td className="px-3 py-2 text-text-primary">
|
||
<div>{s.secretary || '—'}</div>
|
||
{s.email ? (
|
||
<a href={`mailto:${s.email}`} className="text-xs text-accent hover:underline">
|
||
{s.email}
|
||
</a>
|
||
) : null}
|
||
</td>
|
||
<td className="whitespace-nowrap px-3 py-2">
|
||
<div className="flex items-center gap-2">
|
||
{s.maps_url ? (
|
||
<a href={s.maps_url} target="_blank" rel="noreferrer" title="Map" className="text-accent hover:underline">
|
||
<MapPinIcon />
|
||
</a>
|
||
) : null}
|
||
{s.catalog_url ? (
|
||
<a href={s.catalog_url} target="_blank" rel="noreferrer" className="text-xs text-accent hover:underline">
|
||
Catalog
|
||
</a>
|
||
) : null}
|
||
</div>
|
||
</td>
|
||
<td className="whitespace-nowrap px-3 py-2 text-right">
|
||
<Button variant="secondary" size="sm" onClick={() => setAddingShow(s)}>
|
||
Add to Event
|
||
</Button>
|
||
</td>
|
||
</tr>
|
||
))}
|
||
</tbody>
|
||
</table>
|
||
</Card>
|
||
|
||
{totalPages > 1 ? (
|
||
<div className="flex items-center justify-between font-body text-sm text-text-secondary">
|
||
<span>
|
||
Page {currentPage} of {totalPages}
|
||
</span>
|
||
<div className="flex gap-2">
|
||
<Button variant="secondary" size="sm" disabled={currentPage <= 1} onClick={() => setPage(currentPage - 1)}>
|
||
Previous
|
||
</Button>
|
||
<Button variant="secondary" size="sm" disabled={currentPage >= totalPages} onClick={() => setPage(currentPage + 1)}>
|
||
Next
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
) : null}
|
||
</>
|
||
)}
|
||
|
||
{addingShow ? (
|
||
<AddToEventModal show={addingShow} onClose={() => setAddingShow(null)} />
|
||
) : null}
|
||
</div>
|
||
)
|
||
}
|
||
|
||
export default Shows
|