{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "booking-1",
  "title": "Booking 1: class timetable",
  "author": "Ballmac <https://ui.ballmac.com>",
  "description": "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.",
  "dependencies": [
    "lucide-react"
  ],
  "registryDependencies": [
    "utils",
    "https://ui.ballmac.com/r/button.json",
    "https://ui.ballmac.com/r/skeleton.json",
    "https://ui.ballmac.com/r/state-view.json"
  ],
  "files": [
    {
      "path": "registry/ballmac/components/blocks/booking-1/booking-1.tsx",
      "content": "// Ballmac UI: Booking 1. https://ui.ballmac.com/blocks/booking-1\n\"use client\"\n\nimport * as React from \"react\"\nimport { CalendarCheck, Clock, Loader2, UserRound } from \"lucide-react\"\n\nimport { Button } from \"@/components/ballmac/button\"\nimport { Skeleton } from \"@/components/ballmac/skeleton\"\nimport { frameProps, StateView, type StateProps } from \"@/components/ballmac/state-view\"\nimport { cn } from \"@/lib/utils\"\n\ntype Booking1Class = {\n  id: string\n  /** For example \"Vinyasa flow\". */\n  title: string\n  /** The kind of class, used by the filter: \"Yoga\", \"Strength\", \"Cycling\". */\n  kind: string\n  instructor: string\n  /** Which day it is on, as the heading should read: \"Mon 27 Oct\". */\n  day: string\n  /** Start time, such as \"07:00\". */\n  time: string\n  /** For example \"45 min\". */\n  length: string\n  capacity: number\n  /** Places already taken, not counting you. */\n  taken: number\n  /** The person using the page has a place. */\n  booked?: boolean\n  /** The person using the page is on the waiting list. */\n  waitlisted?: boolean\n}\n\ntype Booking1Props = Omit<React.ComponentProps<\"section\">, \"title\"> &\n  StateProps & {\n    classes?: Booking1Class[]\n    title?: string\n    /** Called to book, join the waiting list, or cancel. Reject and the change is undone with the message. */\n    onChange?: (cls: Booking1Class, action: \"book\" | \"waitlist\" | \"cancel\" | \"leave\") => void | Promise<void>\n  }\n\nconst sample: Booking1Class[] = [\n  { id: \"c1\", title: \"Vinyasa flow\", kind: \"Yoga\", instructor: \"Mira Patel\", day: \"Mon 27 Oct\", time: \"07:00\", length: \"45 min\", capacity: 16, taken: 11 },\n  { id: \"c2\", title: \"Strength basics\", kind: \"Strength\", instructor: \"Luca Ferri\", day: \"Mon 27 Oct\", time: \"12:15\", length: \"40 min\", capacity: 12, taken: 12 },\n  { 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 },\n  { 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 },\n  { 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 },\n  { id: \"c6\", title: \"Spin 45\", kind: \"Cycling\", instructor: \"Dana Cole\", day: \"Wed 29 Oct\", time: \"06:45\", length: \"45 min\", capacity: 20, taken: 6 },\n]\n\nfunction Skel() {\n  return (\n    <div aria-hidden=\"true\" className=\"grid gap-6\">\n      {[0, 1].map((d) => (\n        <div key={d} className=\"grid gap-3\">\n          <Skeleton className=\"h-6 w-32\" />\n          <div className=\"grid gap-3 sm:grid-cols-2\">\n            {[0, 1, 2].map((i) => (\n              <Skeleton key={i} className=\"h-28 rounded-xl\" />\n            ))}\n          </div>\n        </div>\n      ))}\n    </div>\n  )\n}\n\n/**\n * 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\n * 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.\n */\nfunction Booking1({ classes: classesProp = sample, title = \"Timetable\", onChange, state = \"ready\", onRetry, emptyState, errorState, className, ...props }: Booking1Props) {\n  const id = React.useId()\n  const [classes, setClasses] = React.useState<Booking1Class[]>(classesProp)\n  React.useEffect(() => setClasses(classesProp), [classesProp])\n  const [kind, setKind] = React.useState(\"all\")\n  const [busy, setBusy] = React.useState<string | null>(null)\n  const [note, setNote] = React.useState<{ id: string; ok: boolean; text: string } | null>(null)\n  const view = state === \"ready\" && classes.length === 0 ? \"empty\" : state\n  const kinds = [...new Set(classes.map((c) => c.kind))]\n  const shown = classes.filter((c) => kind === \"all\" || c.kind === kind)\n  const days = [...new Set(shown.map((c) => c.day))]\n  const mine = classes.filter((c) => c.booked).length\n  const left = (c: Booking1Class) => Math.max(0, c.capacity - c.taken - (c.booked ? 1 : 0))\n\n  async function act(c: Booking1Class, action: \"book\" | \"waitlist\" | \"cancel\" | \"leave\") {\n    setBusy(c.id)\n    setNote(null)\n    const next: Booking1Class = { ...c, booked: action === \"book\" ? true : action === \"cancel\" ? false : c.booked, waitlisted: action === \"waitlist\" ? true : action === \"leave\" ? false : c.waitlisted }\n    setClasses((l) => l.map((x) => (x.id === c.id ? next : x)))\n    try {\n      await onChange?.(c, action)\n      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}.` })\n    } catch (e) {\n      setClasses((l) => l.map((x) => (x.id === c.id ? c : x)))\n      setNote({ id: c.id, ok: false, text: e instanceof Error ? e.message : \"That did not go through, so nothing changed.\" })\n    } finally {\n      setBusy(null)\n    }\n  }\n\n  return (\n    <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)}>\n      <header className=\"mb-8\">\n        <h1 id={`${id}-title`} className=\"text-3xl font-semibold tracking-[-0.035em] text-balance\">\n          {title}\n        </h1>\n        <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>\n      </header>\n      <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 }}>\n        <div role=\"group\" aria-label=\"Filter by kind of class\" className=\"mb-6 flex flex-wrap gap-2\">\n          {[\"all\", ...kinds].map((k) => (\n            <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\")}>\n              {k === \"all\" ? \"All classes\" : k}\n            </button>\n          ))}\n        </div>\n        <div className=\"grid gap-8\">\n          {days.map((d) => (\n            <section key={d} aria-label={d}>\n              <h2 className=\"mb-3 text-lg font-semibold tracking-tight\">{d}</h2>\n              <ul className=\"grid gap-3 sm:grid-cols-2\">\n                {shown.filter((c) => c.day === d).sort((a, b) => a.time.localeCompare(b.time)).map((c) => {\n                  const spots = left(c)\n                  const full = spots === 0 && !c.booked\n                  return (\n                    <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\")}>\n                      <div className=\"flex items-start justify-between gap-3\">\n                        <div className=\"min-w-0\">\n                          <p className=\"font-medium\">{c.title}</p>\n                          <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>\n                        </div>\n                        <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>\n                      </div>\n                      <p className={cn(\"text-xs tabular-nums\", full ? \"text-foreground font-medium\" : spots <= 3 ? \"text-foreground font-medium\" : \"text-muted-foreground\")}>\n                        {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`}\n                      </p>\n                      <div className=\"mt-auto flex flex-wrap items-center gap-2\">\n                        {c.booked ? (\n                          <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}`}>\n                            {busy === c.id && <Loader2 aria-hidden=\"true\" className=\"animate-spin\" />} Cancel booking\n                          </Button>\n                        ) : c.waitlisted ? (\n                          <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}`}>\n                            Leave waiting list\n                          </Button>\n                        ) : full ? (\n                          <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}`}>\n                            Join waiting list\n                          </Button>\n                        ) : (\n                          <Button size=\"sm\" disabled={busy === c.id} onClick={() => void act(c, \"book\")} aria-label={`Book ${c.title}, ${c.day} at ${c.time}`}>\n                            {busy === c.id ? <Loader2 aria-hidden=\"true\" className=\"animate-spin\" /> : <CalendarCheck aria-hidden=\"true\" />} Book\n                          </Button>\n                        )}\n                      </div>\n                      {note?.id === c.id && <p role={note.ok ? \"status\" : \"alert\"} className={cn(\"text-sm font-medium\", !note.ok && \"text-destructive\")}>{note.text}</p>}\n                    </li>\n                  )\n                })}\n              </ul>\n            </section>\n          ))}\n        </div>\n      </StateView>\n    </section>\n  )\n}\n\nexport { Booking1, type Booking1Class, type Booking1Props }\n",
      "type": "registry:component",
      "target": "@components/ballmac/blocks/booking-1/booking-1.tsx"
    }
  ],
  "meta": {
    "schema": 1,
    "tier": "free",
    "tags": [
      "booking",
      "classes",
      "timetable",
      "fitness",
      "studio",
      "waitlist",
      "schedule",
      "wellness"
    ],
    "version": "1.0.0",
    "updated": "2026-10-24",
    "tests": true,
    "ai": {
      "summary": "A class timetable. <Booking1 classes={[{ id, title, kind, instructor, day: 'Mon 27 Oct', time: '07:00', length: '45 min', capacity, taken, booked?, waitlisted? }]} onChange={(class, 'book' | 'waitlist' | 'cancel' | 'leave') => Promise} />. Places left = capacity minus taken (minus you if booked); 3 or fewer says 'Only 2 places left', zero says 'Full' and offers the waiting list. Changes show at once and are undone if onChange rejects.",
      "whenToUse": [
        "A studio, gym or workshop timetable with limited places",
        "Any scheduled session where people can book, queue or cancel"
      ],
      "whenNotToUse": [
        "A day schedule for staff and patients; use clinic-1",
        "A calendar of events with tickets; use event-1"
      ],
      "composesWith": [
        "clinic-1",
        "calendar-1",
        "event-1",
        "profile-1"
      ],
      "a11y": [
        {
          "keys": "Days",
          "action": "Each day is a labelled region with a heading; classes are list items in time order"
        },
        {
          "keys": "Places left",
          "action": "Written as words ('Only 1 place left', 'Full'), never colour alone"
        },
        {
          "keys": "Buttons",
          "action": "Named for the class, day and time ('Book Spin 45, Wed 29 Oct at 06:45'); the result is a polite announcement and a failure is an alert"
        },
        {
          "keys": "Filter",
          "action": "Toggle buttons with aria-pressed"
        }
      ],
      "customization": [
        "classes and the filter kinds come from the data",
        "onChange handles all four actions",
        "The week summary updates as you book"
      ]
    },
    "states": [
      {
        "id": "loading",
        "title": "Loading",
        "example": "booking-1-loading",
        "trigger": "state=\"loading\"",
        "note": "Skeleton day headings and cards under the real heading, announced as loading."
      },
      {
        "id": "empty",
        "title": "No classes this week",
        "example": "booking-1-empty",
        "trigger": "state=\"empty\"",
        "note": "Says no classes this week; emptyState sets the copy."
      },
      {
        "id": "error",
        "title": "Could not load",
        "example": "booking-1-error",
        "trigger": "state=\"error\"",
        "note": "An alert saying nothing was booked or cancelled, with Try again (onRetry). A booking that fails is undone and says why."
      }
    ],
    "examples": [
      "booking-1-demo",
      "booking-1-loading",
      "booking-1-empty",
      "booking-1-error"
    ],
    "url": "https://ui.ballmac.com/components/booking-1"
  },
  "categories": [
    "blocks",
    "booking"
  ],
  "type": "registry:block"
}