# Booking 1: class timetable

> A weekly class timetable people book from: classes grouped by day, places left written out, one button that does the right thing (book, join the waiting list, cancel, leave), a kind filter, and changes undone with the reason if they fail.

A shadcn/ui block from the [@ballmac](https://ui.ballmac.com/?utm_source=shadcn.exchange) registry, listed on [shadcn.exchange](https://shadcn.exchange/r/ballmac/booking-1).

- Page, with a live preview: https://shadcn.exchange/r/ballmac/booking-1
- Screenshot: https://shadcn.exchange/shot/ballmac/booking-1/light.png
- Registry item: https://ui.ballmac.com/r/new-york-v4/booking-1.json
- Categories: blocks, booking
- Listed: 2026-10-28, source last changed 2026-10-28

## Install

```bash
npx shadcn@latest add https://shadcn.exchange/r/ballmac/booking-1.json
```

Run it in a project that has shadcn/ui set up. It adds the files below and installs what they depend on.

## Dependencies

- npm packages: `lucide-react`
- Registry items: `utils`, `https://ui.ballmac.com/r/button.json`, `https://ui.ballmac.com/r/skeleton.json`, `https://ui.ballmac.com/r/state-view.json`

## Files

### `registry/ballmac/components/blocks/booking-1/booking-1.tsx`

```tsx
// Ballmac UI: Booking 1. https://ui.ballmac.com/blocks/booking-1
"use client"

import * as React from "react"
import { CalendarCheck, Clock, Loader2, UserRound } from "lucide-react"

import { Button } from "@/components/ballmac/button"
import { Skeleton } from "@/components/ballmac/skeleton"
import { frameProps, StateView, type StateProps } from "@/components/ballmac/state-view"
import { cn } from "@/lib/utils"

type Booking1Class = {
  id: string
  /** For example "Vinyasa flow". */
  title: string
  /** The kind of class, used by the filter: "Yoga", "Strength", "Cycling". */
  kind: string
  instructor: string
  /** Which day it is on, as the heading should read: "Mon 27 Oct". */
  day: string
  /** Start time, such as "07:00". */
  time: string
  /** For example "45 min". */
  length: string
  capacity: number
  /** Places already taken, not counting you. */
  taken: number
  /** The person using the page has a place. */
  booked?: boolean
  /** The person using the page is on the waiting list. */
  waitlisted?: boolean
}

type Booking1Props = Omit<React.ComponentProps<"section">, "title"> &
  StateProps & {
    classes?: Booking1Class[]
    title?: string
    /** Called to book, join the waiting list, or cancel. Reject and the change is undone with the message. */
    onChange?: (cls: Booking1Class, action: "book" | "waitlist" | "cancel" | "leave") => void | Promise<void>
  }

const sample: Booking1Class[] = [
  { id: "c1", title: "Vinyasa flow", kind: "Yoga", instructor: "Mira Patel", day: "Mon 27 Oct", time: "07:00", length: "45 min", capacity: 16, taken: 11 },
  { id: "c2", title: "Strength basics", kind: "Strength", instructor: "Luca Ferri", day: "Mon 27 Oct", time: "12:15", length: "40 min", capacity: 12, taken: 12 },
  { id: "c3", title: "Spin 45", kind: "Cycling", instructor: "Dana Cole", day: "Mon 27 Oct", time: "18:30", length: "45 min", capacity: 20, taken: 14, booked: true },
  { id: "c4", title: "Yin and stretch", kind: "Yoga", instructor: "Mira Patel", day: "Tue 28 Oct", time: "19:00", length: "60 min", capacity: 16, taken: 15 },
  { id: "c5", title: "Strength basics", kind: "Strength", instructor: "Luca Ferri", day: "Tue 28 Oct", time: "07:30", length: "40 min", capacity: 12, taken: 12, waitlisted: true },
  { id: "c6", title: "Spin 45", kind: "Cycling", instructor: "Dana Cole", day: "Wed 29 Oct", time: "06:45", length: "45 min", capacity: 20, taken: 6 },
]

function Skel() {
  return (
    <div aria-hidden="true" className="grid gap-6">
      {[0, 1].map((d) => (
        <div key={d} className="grid gap-3">
          <Skeleton className="h-6 w-32" />
          <div className="grid gap-3 sm:grid-cols-2">
            {[0, 1, 2].map((i) => (
              <Skeleton key={i} className="h-28 rounded-xl" />
            ))}
          </div>
        </div>
      ))}
    </div>
  )
}

/**
 * A weekly class timetable people can book from: classes grouped by day, places left written out ("3 places left", "Full"), and one button that does the
 * right thing for each class: Book, Join the waiting list, or Cancel. Changes show at once and are undone with the reason if they fail.
 */
function Booking1({ classes: classesProp = sample, title = "Timetable", onChange, state = "ready", onRetry, emptyState, errorState, className, ...props }: Booking1Props) {
  const id = React.useId()
  const [classes, setClasses] = React.useState<Booking1Class[]>(classesProp)
  React.useEffect(() => setClasses(classesProp), [classesProp])
  const [kind, setKind] = React.useState("all")
  const [busy, setBusy] = React.useState<string | null>(null)
  const [note, setNote] = React.useState<{ id: string; ok: boolean; text: string } | null>(null)
  const view = state === "ready" && classes.length === 0 ? "empty" : state
  const kinds = [...new Set(classes.map((c) => c.kind))]
  const shown = classes.filter((c) => kind === "all" || c.kind === kind)
  const days = [...new Set(shown.map((c) => c.day))]
  const mine = classes.filter((c) => c.booked).length
  const left = (c: Booking1Class) => Math.max(0, c.capacity - c.taken - (c.booked ? 1 : 0))

  async function act(c: Booking1Class, action: "book" | "waitlist" | "cancel" | "leave") {
    setBusy(c.id)
    setNote(null)
    const next: Booking1Class = { ...c, booked: action === "book" ? true : action === "cancel" ? false : c.booked, waitlisted: action === "waitlist" ? true : action === "leave" ? false : c.waitlisted }
    setClasses((l) => l.map((x) => (x.id === c.id ? next : x)))
    try {
      await onChange?.(c, action)
      setNote({ id: c.id, ok: true, text: action === "book" ? `You are booked into ${c.title}, ${c.day} at ${c.time}.` : action === "waitlist" ? `You are on the waiting list for ${c.title}. We will email you if a place opens.` : action === "cancel" ? `Your place in ${c.title} is cancelled.` : `You left the waiting list for ${c.title}.` })
    } catch (e) {
      setClasses((l) => l.map((x) => (x.id === c.id ? c : x)))
      setNote({ id: c.id, ok: false, text: e instanceof Error ? e.message : "That did not go through, so nothing changed." })
    } finally {
      setBusy(null)
    }
  }

  return (
    <section data-slot="booking-1" data-state={state} aria-labelledby={`${id}-title`} className={cn("mx-auto w-full max-w-4xl px-4 py-10 sm:px-6", className)} {...frameProps(props)}>
      <header className="mb-8">
        <h1 id={`${id}-title`} className="text-3xl font-semibold tracking-[-0.035em] text-balance">
          {title}
        </h1>
        <p aria-live="polite" className="text-muted-foreground mt-2">{mine === 0 ? "You have no classes booked this week." : `You are booked into ${mine} ${mine === 1 ? "class" : "classes"} this week.`}</p>
      </header>
      <StateView state={view} onRetry={onRetry} label="Loading the timetable" loading={<Skel />} empty={{ title: "No classes this week", description: "The timetable for next week is added on Fridays.", ...emptyState }} error={{ title: "The timetable could not be loaded", description: "Nothing was booked or cancelled. Check your connection and try again.", ...errorState }}>
        <div role="group" aria-label="Filter by kind of class" className="mb-6 flex flex-wrap gap-2">
          {["all", ...kinds].map((k) => (
            <button key={k} type="button" aria-pressed={kind === k} onClick={() => setKind(k)} className={cn("focus-visible:ring-ring/50 rounded-full border px-3.5 py-1.5 text-sm font-medium outline-none transition-colors focus-visible:ring-[3px] motion-reduce:transition-none", kind === k ? "bg-foreground text-background border-foreground" : "hover:bg-accent")}>
              {k === "all" ? "All classes" : k}
            </button>
          ))}
        </div>
        <div className="grid gap-8">
          {days.map((d) => (
            <section key={d} aria-label={d}>
              <h2 className="mb-3 text-lg font-semibold tracking-tight">{d}</h2>
              <ul className="grid gap-3 sm:grid-cols-2">
                {shown.filter((c) => c.day === d).sort((a, b) => a.time.localeCompare(b.time)).map((c) => {
                  const spots = left(c)
                  const full = spots === 0 && !c.booked
                  return (
                    <li key={c.id} data-booked={c.booked || undefined} className={cn("bg-card flex flex-col gap-3 rounded-xl border p-4", c.booked && "border-foreground")}>
                      <div className="flex items-start justify-between gap-3">
                        <div className="min-w-0">
                          <p className="font-medium">{c.title}</p>
                          <p className="text-muted-foreground mt-0.5 flex items-center gap-1.5 text-xs"><UserRound aria-hidden="true" className="size-3.5" /> {c.instructor}</p>
                        </div>
                        <p className="flex shrink-0 items-center gap-1.5 text-sm font-medium tabular-nums"><Clock aria-hidden="true" className="text-muted-foreground size-3.5" /> {c.time} <span className="text-muted-foreground text-xs font-normal">· {c.length}</span></p>
                      </div>
                      <p className={cn("text-xs tabular-nums", full ? "text-foreground font-medium" : spots <= 3 ? "text-foreground font-medium" : "text-muted-foreground")}>
                        {c.booked ? "You are booked" : c.waitlisted ? "You are on the waiting list" : full ? "Full" : spots <= 3 ? `Only ${spots} ${spots === 1 ? "place" : "places"} left` : `${spots} places left`}
                      </p>
                      <div className="mt-auto flex flex-wrap items-center gap-2">
                        {c.booked ? (
                          <Button size="sm" variant="outline" disabled={busy === c.id} onClick={() => void act(c, "cancel")} aria-label={`Cancel your place in ${c.title}, ${c.day} at ${c.time}`}>
                            {busy === c.id && <Loader2 aria-hidden="true" className="animate-spin" />} Cancel booking
                          </Button>
                        ) : c.waitlisted ? (
                          <Button size="sm" variant="outline" disabled={busy === c.id} onClick={() => void act(c, "leave")} aria-label={`Leave the waiting list for ${c.title}, ${c.day} at ${c.time}`}>
                            Leave waiting list
                          </Button>
                        ) : full ? (
                          <Button size="sm" variant="outline" disabled={busy === c.id} onClick={() => void act(c, "waitlist")} aria-label={`Join the waiting list for ${c.title}, ${c.day} at ${c.time}`}>
                            Join waiting list
                          </Button>
                        ) : (
                          <Button size="sm" disabled={busy === c.id} onClick={() => void act(c, "book")} aria-label={`Book ${c.title}, ${c.day} at ${c.time}`}>
                            {busy === c.id ? <Loader2 aria-hidden="true" className="animate-spin" /> : <CalendarCheck aria-hidden="true" />} Book
                          </Button>
                        )}
                      </div>
                      {note?.id === c.id && <p role={note.ok ? "status" : "alert"} className={cn("text-sm font-medium", !note.ok && "text-destructive")}>{note.text}</p>}
                    </li>
                  )
                })}
              </ul>
            </section>
          ))}
        </div>
      </StateView>
    </section>
  )
}

export { Booking1, type Booking1Class, type Booking1Props }
```
