Examples
Whole screens for municipal services, built only from rapidUI components. Copy the source into your app.
Book a facility
Find a sports hall or meeting room, pick a time and confirm the booking.
Leisure and culture
Book a municipal facility
Sports halls, meeting rooms and pitches for associations and residents. Choose a facility, a date and one or more free times.
Facility type
9 facilities · availability for Mon 5 Oct
Norrby Sports Hall
Storgatan 14 · 450 kr per hour
Date
TimesMon 5 Oct
Already booked
- 08:00–12:00Norrby IF floorball
- 12:00–13:00School PE lessons
- 14:00–15:00Basketball club Hoops
- 16:00–17:00Norrby IF floorball
- Times
- None selected
- Hours
- 0 × 450 kr
- Total
- 0 kr
Select one or more free times to book.
View source components/examples/facility-booking.tsx
'use client'
import { format } from 'date-fns'
import {
AccessibilityIcon,
CalendarDaysIcon,
CheckIcon,
CircleCheckIcon,
ClockIcon,
DumbbellIcon,
LockIcon,
MapPinIcon,
PresentationIcon,
SearchIcon,
SearchXIcon,
TreesIcon,
UsersIcon,
XCircleIcon,
XIcon,
} from 'lucide-react'
import * as React from 'react'
import { Alert, AlertAction, AlertDescription, AlertTitle } from '@/components/ui/alert'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import { Calendar } from '@/components/ui/calendar'
import { Card, CardContent, CardFooter, CardTitle } from '@/components/ui/card'
import {
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import { Empty, EmptyContent, EmptyDescription, EmptyHeader, EmptyMedia, EmptyTitle } from '@/components/ui/empty'
import { Field, FieldDescription, FieldError, FieldGroup, FieldLabel } from '@/components/ui/field'
import { Input } from '@/components/ui/input'
import { InputGroup, InputGroupAddon, InputGroupInput } from '@/components/ui/input-group'
import { Label } from '@/components/ui/label'
import { NumberInput } from '@/components/ui/number-input'
import { Select, SelectContent, SelectGroup, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
import { Separator } from '@/components/ui/separator'
import { Switch } from '@/components/ui/switch'
import { ToggleGroup, ToggleGroupItem } from '@/components/ui/toggle-group'
import { cn } from '@/lib/utils'
type FacilityType = 'sports-hall' | 'meeting-room' | 'outdoor-pitch'
type Facility = {
id: string
name: string
type: FacilityType
district: string
address: string
capacity: number
equipment: string[]
pricePerHour: number
accessible: boolean
/** Share of the day's times that are already taken, 0–1 */
busy: number
reasons: string[]
}
type Booking = {
reference: string
facilityId: string
dateKey: string
hours: number[]
organisation: string
}
type Slot = {
hour: number
booked: boolean
reason?: string
}
const TYPES: Record<FacilityType, { label: string; plural: string; icon: typeof DumbbellIcon; tone: string }> = {
'sports-hall': {
label: 'Sports hall',
plural: 'Sports halls',
icon: DumbbellIcon,
tone: 'bg-chart-1/15 text-chart-1',
},
'meeting-room': {
label: 'Meeting room',
plural: 'Meeting rooms',
icon: PresentationIcon,
tone: 'bg-chart-2/15 text-chart-2',
},
'outdoor-pitch': {
label: 'Outdoor pitch',
plural: 'Outdoor pitches',
icon: TreesIcon,
tone: 'bg-chart-3/15 text-chart-3',
},
}
const DISTRICTS = {
all: 'All districts',
centrum: 'Centrum',
norrby: 'Norrby',
soder: 'Söder',
vaster: 'Väster',
oster: 'Öster',
}
const CAPACITIES = {
any: 'Any size',
'20': '20 or more',
'50': '50 or more',
'100': '100 or more',
'200': '200 or more',
}
const PURPOSES = {
training: 'Training',
match: 'Match or competition',
meeting: 'Meeting or annual meeting',
event: 'Public event',
course: 'Course or study circle',
}
const FACILITIES: Facility[] = [
{
id: 'norrby-hall',
name: 'Norrby Sports Hall',
type: 'sports-hall',
district: 'norrby',
address: 'Storgatan 14',
capacity: 200,
equipment: ['Floorball goals', 'Basketball', 'Changing rooms'],
pricePerHour: 450,
accessible: true,
busy: 0.45,
reasons: ['Norrby IF floorball', 'Norrby IF floorball', 'School PE lessons', 'Basketball club Hoops'],
},
{
id: 'kungsgatan-room',
name: 'Kungsgatan Community Room',
type: 'meeting-room',
district: 'centrum',
address: 'Kungsgatan 3',
capacity: 40,
equipment: ['Projector', 'Kitchenette', 'Hearing loop'],
pricePerHour: 180,
accessible: true,
busy: 0.35,
reasons: ['Pensioners’ association', 'Kungsgatan choir', 'Tenant association board'],
},
{
id: 'sodra-ip',
name: 'Södra IP Artificial Pitch',
type: 'outdoor-pitch',
district: 'soder',
address: 'Idrottsvägen 8',
capacity: 300,
equipment: ['Floodlights', '11-a-side goals', 'Changing rooms'],
pricePerHour: 600,
accessible: false,
busy: 0.6,
reasons: ['Södra FF youth training', 'Södra FF senior team', 'Pitch maintenance'],
},
{
id: 'ekorren-room',
name: 'Library Room Ekorren',
type: 'meeting-room',
district: 'centrum',
address: 'Bibliotekstorget 1',
capacity: 12,
equipment: ['Screen', 'Whiteboard'],
pricePerHour: 120,
accessible: true,
busy: 0.3,
reasons: ['Language café', 'Library book club', 'Homework help'],
},
{
id: 'vastra-gym',
name: 'Västra School Gym',
type: 'sports-hall',
district: 'vaster',
address: 'Skolgatan 22',
capacity: 60,
equipment: ['Wall bars', 'Badminton', 'Changing rooms'],
pricePerHour: 250,
accessible: false,
busy: 0.4,
reasons: ['Västra badminton club', 'Senior gymnastics', 'Karate club Ippon'],
},
{
id: 'strandparken',
name: 'Strandparken Grass Pitch',
type: 'outdoor-pitch',
district: 'vaster',
address: 'Strandvägen 5',
capacity: 150,
equipment: ['7-a-side goals', 'Accessible toilet'],
pricePerHour: 300,
accessible: true,
busy: 0.25,
reasons: ['Västra BK kids’ football', 'Grass cutting'],
},
{
id: 'ostra-hall',
name: 'Östra Assembly Hall',
type: 'meeting-room',
district: 'oster',
address: 'Parkvägen 11',
capacity: 120,
equipment: ['Stage', 'Sound system', 'Hearing loop'],
pricePerHour: 350,
accessible: true,
busy: 0.5,
reasons: ['Theatre group rehearsal', 'Municipal council information evening', 'Folk dance society'],
},
{
id: 'arena-oster',
name: 'Arena Öster',
type: 'sports-hall',
district: 'oster',
address: 'Arenavägen 2',
capacity: 500,
equipment: ['Handball', 'Spectator stands', 'Café'],
pricePerHour: 900,
accessible: true,
busy: 0.85,
reasons: ['Öster HK handball', 'Öster HK handball', 'Regional futsal series', 'Floor sealing'],
},
{
id: 'bjorkvagen-pitch',
name: 'Björkvägen Gravel Pitch',
type: 'outdoor-pitch',
district: 'norrby',
address: 'Björkvägen 40',
capacity: 100,
equipment: ['7-a-side goals'],
pricePerHour: 150,
accessible: false,
busy: 0.2,
reasons: ['Norrby IF youth football'],
},
]
const FIRST_BOOKABLE = new Date(2026, 9, 1)
const DEFAULT_DATE = new Date(2026, 9, 5)
const HOURS = Array.from({ length: 14 }, (_, index) => 8 + index)
const kronor = new Intl.NumberFormat('sv-SE', { style: 'currency', currency: 'SEK', maximumFractionDigits: 0 })
function dateKey(date: Date) {
return format(date, 'yyyy-MM-dd')
}
function hourLabel(hour: number) {
return `${String(hour).padStart(2, '0')}:00`
}
function rangeLabel(hours: number[]) {
const sorted = [...hours].sort((a, b) => a - b)
const ranges: string[] = []
let start = sorted[0]
for (let index = 0; index < sorted.length; index++) {
const hour = sorted[index]
if (sorted[index + 1] !== hour + 1) {
ranges.push(`${hourLabel(start)}–${hourLabel(hour + 1)}`)
start = sorted[index + 1]
}
}
return ranges.join(', ')
}
/** Deterministic mock availability, so the example looks the same on every render */
function getSlots(facility: Facility, date: Date, bookings: Booking[]): Slot[] {
const seed = facility.id.length * 31 + date.getDate() * 13 + date.getMonth() * 7 + date.getDay() * 3
const own = bookings.filter((booking) => booking.facilityId === facility.id && booking.dateKey === dateKey(date))
const weekday = date.getDay() >= 1 && date.getDay() <= 5
return HOURS.map((hour) => {
const mine = own.find((booking) => booking.hours.includes(hour))
if (mine) return { hour, booked: true, reason: `Your booking ${mine.reference}` }
if (facility.id === 'vastra-gym' && weekday && hour < 16) return { hour, booked: true, reason: 'School hours' }
const blockSeed = seed + Math.floor(hour / 2)
const chance = (((blockSeed * 9301 + 49297) % 233280) / 233280 + ((hour * 0.137) % 0.3)) % 1
if (chance < facility.busy) {
return { hour, booked: true, reason: facility.reasons[blockSeed % facility.reasons.length] }
}
return { hour, booked: false }
})
}
function groupBooked(slots: Slot[]) {
const groups: { hours: number[]; reason: string }[] = []
for (const slot of slots) {
if (!slot.booked || !slot.reason) continue
const last = groups.at(-1)
if (last && last.reason === slot.reason && last.hours.at(-1) === slot.hour - 1) last.hours.push(slot.hour)
else groups.push({ hours: [slot.hour], reason: slot.reason })
}
return groups
}
function AvailabilityBadge({ free }: { free: number }) {
if (free === 0) {
return (
<Badge variant="destructive">
<XCircleIcon data-icon="inline-start" aria-hidden />
Fully booked
</Badge>
)
}
if (free <= 4) {
return (
<Badge variant="outline">
<ClockIcon data-icon="inline-start" aria-hidden />
{free} {free === 1 ? 'time' : 'times'} left
</Badge>
)
}
return (
<Badge className="bg-primary/10 text-primary">
<CircleCheckIcon data-icon="inline-start" aria-hidden />
{free} times free
</Badge>
)
}
function FacilityCard({
facility,
free,
selected,
onSelect,
}: {
facility: Facility
free: number
selected: boolean
onSelect: () => void
}) {
const type = TYPES[facility.type]
const TypeIcon = type.icon
return (
<Card className={cn('pt-0', selected && 'ring-2 ring-primary')}>
<div className={cn('relative flex h-24 items-center justify-center', type.tone)}>
<TypeIcon className="size-8" aria-hidden />
<Badge variant="secondary" className="absolute top-2 left-2">
{type.label}
</Badge>
{facility.accessible && (
<Badge variant="secondary" className="absolute top-2 right-2">
<AccessibilityIcon data-icon="inline-start" aria-hidden />
Accessible
</Badge>
)}
</div>
<CardContent className="flex flex-1 flex-col gap-2">
<div className="flex items-start justify-between gap-2">
<CardTitle as="h4">{facility.name}</CardTitle>
<AvailabilityBadge free={free} />
</div>
<div className="flex flex-col gap-1 text-muted-foreground">
<span className="flex items-center gap-1.5">
<MapPinIcon className="size-4 shrink-0" aria-hidden />
{facility.address}, {DISTRICTS[facility.district as keyof typeof DISTRICTS]}
</span>
<span className="flex items-center gap-1.5">
<UsersIcon className="size-4 shrink-0" aria-hidden />
Up to {facility.capacity} people
</span>
</div>
<ul className="flex flex-wrap gap-1" aria-label="Equipment">
{facility.equipment.map((item) => (
<li key={item}>
<Badge variant="outline">{item}</Badge>
</li>
))}
</ul>
</CardContent>
<CardFooter className="flex items-center justify-between gap-2 border-t py-3">
<p>
<span className="font-medium">{kronor.format(facility.pricePerHour)}</span>
<span className="text-muted-foreground"> / hour</span>
</p>
<Button
variant={selected ? 'secondary' : 'outline'}
aria-pressed={selected}
aria-label={`${selected ? 'Selected' : 'Choose'}: ${facility.name}`}
onClick={onSelect}
>
{selected && <CheckIcon data-icon="inline-start" aria-hidden />}
{selected ? 'Selected' : 'Choose'}
</Button>
</CardFooter>
</Card>
)
}
function ConfirmDialog({
open,
onOpenChange,
facility,
date,
hours,
total,
onConfirm,
}: {
open: boolean
onOpenChange: (open: boolean) => void
facility: Facility
date: Date
hours: number[]
total: number
onConfirm: (organisation: string) => void
}) {
const id = React.useId()
const [organisation, setOrganisation] = React.useState('')
const [purpose, setPurpose] = React.useState<string | null>(null)
const [participants, setParticipants] = React.useState(10)
const [submitted, setSubmitted] = React.useState(false)
React.useEffect(() => {
if (open) setSubmitted(false)
}, [open])
const errors = {
organisation: organisation.trim() ? undefined : 'Enter the association or your name.',
purpose: purpose ? undefined : 'Choose what the booking is for.',
participants:
participants < 1
? 'At least 1 participant.'
: participants > facility.capacity
? `${facility.name} takes at most ${facility.capacity} people.`
: undefined,
}
const show = (key: keyof typeof errors) => (submitted ? errors[key] : undefined)
function handleSubmit(event: React.FormEvent<HTMLFormElement>) {
event.preventDefault()
setSubmitted(true)
if (errors.organisation || errors.purpose || errors.participants) return
onConfirm(organisation.trim())
setOrganisation('')
setPurpose(null)
setParticipants(10)
}
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-md">
<form onSubmit={handleSubmit} noValidate className="grid gap-4">
<DialogHeader>
<DialogTitle>Confirm booking</DialogTitle>
<DialogDescription>
{facility.name}, {format(date, 'EEEE d MMMM yyyy')}, {rangeLabel(hours)}.
</DialogDescription>
</DialogHeader>
<FieldGroup className="gap-4">
<Field data-invalid={!!show('organisation')}>
<FieldLabel htmlFor={`${id}-org`}>Organisation or name</FieldLabel>
<Input
id={`${id}-org`}
value={organisation}
onChange={(event) => setOrganisation(event.target.value)}
placeholder="e.g. Norrby IF"
autoComplete="organization"
aria-invalid={!!show('organisation')}
aria-describedby={show('organisation') ? `${id}-org-error` : undefined}
/>
<FieldError id={`${id}-org-error`}>{show('organisation')}</FieldError>
</Field>
<Field data-invalid={!!show('purpose')}>
<FieldLabel id={`${id}-purpose-label`}>Purpose</FieldLabel>
<Select items={PURPOSES} value={purpose} onValueChange={(value) => setPurpose(value)}>
<SelectTrigger
className="w-full"
aria-labelledby={`${id}-purpose-label`}
aria-invalid={!!show('purpose')}
aria-describedby={show('purpose') ? `${id}-purpose-error` : undefined}
>
<SelectValue placeholder="Choose a purpose" />
</SelectTrigger>
<SelectContent>
<SelectGroup>
{Object.entries(PURPOSES).map(([value, label]) => (
<SelectItem key={value} value={value}>
{label}
</SelectItem>
))}
</SelectGroup>
</SelectContent>
</Select>
<FieldError id={`${id}-purpose-error`}>{show('purpose')}</FieldError>
</Field>
<Field data-invalid={!!show('participants')}>
<FieldLabel id={`${id}-count-label`} htmlFor={`${id}-count`}>
Number of participants
</FieldLabel>
<div className="w-36">
<NumberInput
id={`${id}-count`}
aria-labelledby={`${id}-count-label`}
aria-describedby={`${id}-count-hint`}
value={participants}
onChange={setParticipants}
min={0}
max={facility.capacity + 50}
/>
</div>
<FieldDescription id={`${id}-count-hint`}>Maximum {facility.capacity} for this facility.</FieldDescription>
<FieldError>{show('participants')}</FieldError>
</Field>
</FieldGroup>
<div className="flex items-center justify-between rounded-lg bg-muted px-3 py-2">
<span className="text-muted-foreground">Total to pay</span>
<span className="font-medium">{kronor.format(total)}</span>
</div>
<DialogFooter>
<DialogClose render={<Button variant="outline" type="button" />}>Cancel</DialogClose>
<Button type="submit">Confirm booking</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
)
}
function BookingPanel({
facility,
date,
onDateChange,
bookings,
onBook,
onClear,
headingRef,
}: {
facility: Facility | undefined
date: Date
onDateChange: (date: Date) => void
bookings: Booking[]
onBook: (booking: Omit<Booking, 'reference'>) => Booking
onClear: () => void
headingRef: React.RefObject<HTMLHeadingElement | null>
}) {
const id = React.useId()
const [selectedHours, setSelectedHours] = React.useState<string[]>([])
const [dialogOpen, setDialogOpen] = React.useState(false)
const [confirmed, setConfirmed] = React.useState<Booking | null>(null)
// A new facility or date starts a fresh selection
const selectionKey = `${facility?.id}-${dateKey(date)}`
const [lastKey, setLastKey] = React.useState(selectionKey)
if (lastKey !== selectionKey) {
setLastKey(selectionKey)
setSelectedHours([])
}
if (!facility) {
return (
<section aria-labelledby={`${id}-heading`} className="rounded-xl border border-dashed">
<h4 id={`${id}-heading`} ref={headingRef} tabIndex={-1} className="sr-only">
Your booking
</h4>
<Empty>
<EmptyHeader>
<EmptyMedia variant="icon">
<CalendarDaysIcon aria-hidden />
</EmptyMedia>
<EmptyTitle>No facility chosen</EmptyTitle>
<EmptyDescription>Choose a facility to see free times and prices.</EmptyDescription>
</EmptyHeader>
</Empty>
</section>
)
}
const slots = getSlots(facility, date, bookings)
const bookedGroups = groupBooked(slots)
const hours = selectedHours.map(Number).sort((a, b) => a - b)
const total = hours.length * facility.pricePerHour
return (
<section aria-labelledby={`${id}-heading`} className="flex flex-col gap-4 rounded-xl p-4 ring-1 ring-border">
<div className="flex items-start justify-between gap-2">
<div className="flex flex-col gap-0.5">
<h4
id={`${id}-heading`}
ref={headingRef}
tabIndex={-1}
className="font-heading text-base font-medium outline-none"
>
{facility.name}
</h4>
<p className="text-muted-foreground">
{facility.address} · {kronor.format(facility.pricePerHour)} per hour
</p>
</div>
<Button variant="ghost" size="icon-sm" aria-label="Close booking panel" onClick={onClear}>
<XIcon />
</Button>
</div>
{confirmed && (
<Alert>
<CircleCheckIcon aria-hidden />
<AlertTitle>Booking {confirmed.reference} confirmed</AlertTitle>
<AlertDescription>
{confirmed.organisation}, {format(new Date(`${confirmed.dateKey}T00:00`), 'd MMM')} {rangeLabel(confirmed.hours)}.
A confirmation and invoice are sent to your e-mail.
</AlertDescription>
<AlertAction>
<Button variant="ghost" size="icon-sm" aria-label="Dismiss confirmation" onClick={() => setConfirmed(null)}>
<XIcon />
</Button>
</AlertAction>
</Alert>
)}
<div className="flex flex-col gap-2">
<span className="font-medium" id={`${id}-date-label`}>
Date
</span>
<Calendar
mode="single"
required
selected={date}
onSelect={(next) => next && onDateChange(next)}
defaultMonth={date}
disabled={{ before: FIRST_BOOKABLE }}
startMonth={new Date(2026, 9)}
endMonth={new Date(2027, 2)}
aria-labelledby={`${id}-date-label`}
className="self-center rounded-lg border [--cell-size:--spacing(9)]"
/>
</div>
<div className="flex flex-col gap-2">
<div className="flex items-baseline justify-between gap-2">
<span className="font-medium" id={`${id}-times-label`}>
Times
</span>
<span className="text-muted-foreground">{format(date, 'EEE d MMM')}</span>
</div>
<ToggleGroup
multiple
variant="outline"
value={selectedHours}
onValueChange={setSelectedHours}
aria-labelledby={`${id}-times-label`}
className="grid w-full grid-cols-2"
>
{slots.map((slot) => {
const pressed = selectedHours.includes(String(slot.hour))
const label = `${hourLabel(slot.hour)}–${hourLabel(slot.hour + 1)}`
return (
<ToggleGroupItem
key={slot.hour}
value={String(slot.hour)}
disabled={slot.booked}
aria-label={slot.booked ? `${label}, booked: ${slot.reason}` : `${label}, free`}
className="w-full justify-between"
>
<span className="tabular-nums">{label}</span>
{slot.booked ? (
<LockIcon aria-hidden />
) : pressed ? (
<CheckIcon aria-hidden />
) : (
<span className="text-xs text-muted-foreground">Free</span>
)}
</ToggleGroupItem>
)
})}
</ToggleGroup>
{bookedGroups.length > 0 && (
<div className="flex flex-col gap-1 rounded-lg bg-muted px-3 py-2">
<span className="font-medium">Already booked</span>
<ul className="flex flex-col gap-0.5 text-muted-foreground">
{bookedGroups.map((group) => (
<li key={`${group.hours[0]}-${group.reason}`} className="flex gap-2">
<span className="shrink-0 tabular-nums">{rangeLabel(group.hours)}</span>
<span>{group.reason}</span>
</li>
))}
</ul>
</div>
)}
</div>
<Separator />
<dl className="grid grid-cols-[auto_1fr] gap-x-4 gap-y-1">
<dt className="text-muted-foreground">Times</dt>
<dd className="text-right">{hours.length ? rangeLabel(hours) : 'None selected'}</dd>
<dt className="text-muted-foreground">Hours</dt>
<dd className="text-right tabular-nums">
{hours.length} × {kronor.format(facility.pricePerHour)}
</dd>
<dt className="font-medium">Total</dt>
<dd className="text-right font-medium tabular-nums" aria-live="polite">
{kronor.format(total)}
</dd>
</dl>
<Button disabled={hours.length === 0} onClick={() => setDialogOpen(true)}>
Book
</Button>
<p className="-mt-2 text-muted-foreground">
{hours.length === 0 ? 'Select one or more free times to book.' : 'Free cancellation up to 48 hours before.'}
</p>
<ConfirmDialog
open={dialogOpen}
onOpenChange={setDialogOpen}
facility={facility}
date={date}
hours={hours}
total={total}
onConfirm={(organisation) => {
const booking = onBook({ facilityId: facility.id, dateKey: dateKey(date), hours, organisation })
setConfirmed(booking)
setSelectedHours([])
setDialogOpen(false)
}}
/>
</section>
)
}
export default function FacilityBookingExample() {
const id = React.useId()
const [type, setType] = React.useState<string>('all')
const [district, setDistrict] = React.useState('all')
const [capacity, setCapacity] = React.useState('any')
const [accessibleOnly, setAccessibleOnly] = React.useState(false)
const [query, setQuery] = React.useState('')
const [selectedId, setSelectedId] = React.useState<string | undefined>('norrby-hall')
const [date, setDate] = React.useState(DEFAULT_DATE)
const [bookings, setBookings] = React.useState<Booking[]>([])
const panelRef = React.useRef<HTMLDivElement>(null)
const headingRef = React.useRef<HTMLHeadingElement>(null)
const results = FACILITIES.filter((facility) => {
const text = `${facility.name} ${facility.address} ${facility.equipment.join(' ')}`.toLowerCase()
return (
(type === 'all' || facility.type === type) &&
(district === 'all' || facility.district === district) &&
(capacity === 'any' || facility.capacity >= Number(capacity)) &&
(!accessibleOnly || facility.accessible) &&
text.includes(query.trim().toLowerCase())
)
})
const filtersActive = type !== 'all' || district !== 'all' || capacity !== 'any' || accessibleOnly || query !== ''
const selected = FACILITIES.find((facility) => facility.id === selectedId)
function resetFilters() {
setType('all')
setDistrict('all')
setCapacity('any')
setAccessibleOnly(false)
setQuery('')
}
function selectFacility(facilityId: string) {
setSelectedId(facilityId)
// On narrow screens the panel sits below the results, so bring it into view and move focus to it
requestAnimationFrame(() => {
const panel = panelRef.current
if (panel && panel.getBoundingClientRect().top > window.innerHeight * 0.6) {
panel.scrollIntoView({ behavior: 'smooth', block: 'start' })
}
headingRef.current?.focus({ preventScroll: true })
})
}
function addBooking(booking: Omit<Booking, 'reference'>) {
const created = { ...booking, reference: `2026-0${4812 + bookings.length * 37}` }
setBookings((current) => [...current, created])
return created
}
return (
<div className="@container flex min-h-[720px] flex-col gap-6 bg-background p-4 text-sm @3xl:p-6">
<header className="flex flex-col gap-4 @3xl:flex-row @3xl:items-end @3xl:justify-between">
<div className="flex flex-col gap-1">
<p className="text-muted-foreground">Leisure and culture</p>
<h3 className="font-heading text-xl font-semibold tracking-tight">Book a municipal facility</h3>
<p className="max-w-prose text-muted-foreground">
Sports halls, meeting rooms and pitches for associations and residents. Choose a facility, a date and one or more
free times.
</p>
</div>
{bookings.length > 0 && (
<Badge variant="secondary" className="self-start @3xl:self-end">
<CheckIcon data-icon="inline-start" aria-hidden />
{bookings.length} {bookings.length === 1 ? 'booking' : 'bookings'} this session
</Badge>
)}
</header>
<section aria-label="Filters" className="flex flex-col gap-4 rounded-xl bg-muted/50 p-4 ring-1 ring-border">
<div className="flex flex-col gap-2">
<span id={`${id}-type-label`} className="font-medium">
Facility type
</span>
<ToggleGroup
variant="outline"
value={[type]}
onValueChange={(value) => value[0] && setType(value[0])}
aria-labelledby={`${id}-type-label`}
className="flex-wrap"
>
<ToggleGroupItem value="all" className="bg-background">
All
</ToggleGroupItem>
{(Object.keys(TYPES) as FacilityType[]).map((key) => {
const Icon = TYPES[key].icon
return (
<ToggleGroupItem key={key} value={key} className="bg-background">
<Icon data-icon="inline-start" aria-hidden />
{TYPES[key].plural}
</ToggleGroupItem>
)
})}
</ToggleGroup>
</div>
<div className="grid gap-4 @xl:grid-cols-2 @5xl:grid-cols-[minmax(0,1.5fr)_minmax(0,1fr)_minmax(0,1fr)_auto] @5xl:items-end">
<Field>
<FieldLabel htmlFor={`${id}-search`}>Search</FieldLabel>
<InputGroup className="bg-background">
<InputGroupAddon>
<SearchIcon aria-hidden />
</InputGroupAddon>
<InputGroupInput
id={`${id}-search`}
type="search"
placeholder="Name, address or equipment"
value={query}
onChange={(event) => setQuery(event.target.value)}
/>
</InputGroup>
</Field>
<Field>
<FieldLabel id={`${id}-district-label`}>District</FieldLabel>
<Select items={DISTRICTS} value={district} onValueChange={(value) => setDistrict(value ?? 'all')}>
<SelectTrigger className="w-full bg-background" aria-labelledby={`${id}-district-label`}>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectGroup>
{Object.entries(DISTRICTS).map(([value, label]) => (
<SelectItem key={value} value={value}>
{label}
</SelectItem>
))}
</SelectGroup>
</SelectContent>
</Select>
</Field>
<Field>
<FieldLabel id={`${id}-capacity-label`}>Capacity</FieldLabel>
<Select items={CAPACITIES} value={capacity} onValueChange={(value) => setCapacity(value ?? 'any')}>
<SelectTrigger className="w-full bg-background" aria-labelledby={`${id}-capacity-label`}>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectGroup>
{Object.entries(CAPACITIES).map(([value, label]) => (
<SelectItem key={value} value={value}>
{label}
</SelectItem>
))}
</SelectGroup>
</SelectContent>
</Select>
</Field>
<div className="flex h-10 items-center gap-2">
<Switch id={`${id}-accessible`} checked={accessibleOnly} onCheckedChange={setAccessibleOnly} />
<Label htmlFor={`${id}-accessible`}>Accessible entrance</Label>
</div>
</div>
</section>
<div className="grid flex-1 items-start gap-6 @4xl:grid-cols-[minmax(0,1fr)_340px]">
<section aria-labelledby={`${id}-results`} className="flex flex-col gap-4">
<div className="flex items-center justify-between gap-2">
<h4 id={`${id}-results`} className="font-medium" aria-live="polite">
{results.length} {results.length === 1 ? 'facility' : 'facilities'}
<span className="font-normal text-muted-foreground"> · availability for {format(date, 'EEE d MMM')}</span>
</h4>
{filtersActive && (
<Button variant="ghost" onClick={resetFilters}>
Clear filters
</Button>
)}
</div>
{results.length === 0 ? (
<Empty className="border">
<EmptyHeader>
<EmptyMedia variant="icon">
<SearchXIcon aria-hidden />
</EmptyMedia>
<EmptyTitle>No facilities match</EmptyTitle>
<EmptyDescription>
Try another district, a smaller capacity or fewer filters. Some older buildings have no accessible entrance
yet.
</EmptyDescription>
</EmptyHeader>
<EmptyContent>
<Button variant="outline" onClick={resetFilters}>
Clear filters
</Button>
</EmptyContent>
</Empty>
) : (
<ul className="grid gap-4 @xl:grid-cols-2">
{results.map((facility) => (
<li key={facility.id} className="flex *:flex-1">
<FacilityCard
facility={facility}
free={getSlots(facility, date, bookings).filter((slot) => !slot.booked).length}
selected={facility.id === selectedId}
onSelect={() => selectFacility(facility.id)}
/>
</li>
))}
</ul>
)}
</section>
<div ref={panelRef} className="scroll-mt-4 @4xl:sticky @4xl:top-4">
<BookingPanel
facility={selected}
date={date}
onDateChange={setDate}
bookings={bookings}
onBook={addBooking}
onClear={() => setSelectedId(undefined)}
headingRef={headingRef}
/>
</div>
</div>
</div>
)
}