MyKindlebox/mykindlebox/client/src/pages/Shows.jsx

449 lines
17 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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