rapidUI

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

  • Sports hallAccessible

    Norrby Sports Hall

    7 times free
    Storgatan 14, NorrbyUp to 200 people
    • Floorball goals
    • Basketball
    • Changing rooms

    450 kr / hour

  • Meeting roomAccessible

    Kungsgatan Community Room

    10 times free
    Kungsgatan 3, CentrumUp to 40 people
    • Projector
    • Kitchenette
    • Hearing loop

    180 kr / hour

  • Outdoor pitch

    Södra IP Artificial Pitch

    12 times free
    Idrottsvägen 8, SöderUp to 300 people
    • Floodlights
    • 11-a-side goals
    • Changing rooms

    600 kr / hour

  • Meeting roomAccessible

    Library Room Ekorren

    14 times free
    Bibliotekstorget 1, CentrumUp to 12 people
    • Screen
    • Whiteboard

    120 kr / hour

  • Sports hall

    Västra School Gym

    Fully booked
    Skolgatan 22, VästerUp to 60 people
    • Wall bars
    • Badminton
    • Changing rooms

    250 kr / hour

  • Outdoor pitchAccessible

    Strandparken Grass Pitch

    14 times free
    Strandvägen 5, VästerUp to 150 people
    • 7-a-side goals
    • Accessible toilet

    300 kr / hour

  • Meeting roomAccessible

    Östra Assembly Hall

    1 time left
    Parkvägen 11, ÖsterUp to 120 people
    • Stage
    • Sound system
    • Hearing loop

    350 kr / hour

  • Sports hallAccessible

    Arena Öster

    Fully booked
    Arenavägen 2, ÖsterUp to 500 people
    • Handball
    • Spectator stands
    • Café

    900 kr / hour

  • Outdoor pitch

    Björkvägen Gravel Pitch

    14 times free
    Björkvägen 40, NorrbyUp to 100 people
    • 7-a-side goals

    150 kr / hour

Norrby Sports Hall

Storgatan 14 · 450 kr per hour

Date
October 2026
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>
	)
}