{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "salon-1",
  "title": "Salon 1: appointment booking",
  "author": "Ballmac <https://ui.ballmac.com>",
  "description": "A booking page for a salon or clinic: choose services (time and price add up), a stylist or anyone, then a day and a time that stylist is free, a live summary, and a confirmation that says exactly what was booked.",
  "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/salon-1/salon-1.tsx",
      "content": "// Ballmac UI: Salon 1. https://ui.ballmac.com/blocks/salon-1\n\"use client\"\n\nimport * as React from \"react\"\nimport { CircleCheck, Clock, Loader2 } 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 Salon1Service = { id: string; name: string; /** How long it takes, in minutes. */ minutes: number; price: number }\ntype Salon1Stylist = { id: string; name: string; specialty?: string }\ntype Salon1Day = {\n  id: string\n  /** For example \"Sat 25 Oct\". */\n  label: string\n  /** The free start times that day, and who is free at each. */\n  slots: { time: string; stylists: string[] }[]\n}\n\ntype Salon1Request = { services: string[]; stylist: string; day: string; time: string }\n\ntype Salon1Props = Omit<React.ComponentProps<\"section\">, \"title\"> &\n  StateProps & {\n    services?: Salon1Service[]\n    stylists?: Salon1Stylist[]\n    days?: Salon1Day[]\n    title?: string\n    /** ISO currency code. */\n    currency?: string\n    /** Called with the choices. Reject and the page keeps them with the message. */\n    onBook?: (request: Salon1Request) => void | Promise<void>\n  }\n\nconst services: Salon1Service[] = [\n  { id: \"cut\", name: \"Haircut and finish\", minutes: 45, price: 52 },\n  { id: \"colour\", name: \"Full colour\", minutes: 120, price: 110 },\n  { id: \"highlights\", name: \"Half-head highlights\", minutes: 90, price: 95 },\n  { id: \"treat\", name: \"Conditioning treatment\", minutes: 20, price: 24 },\n  { id: \"beard\", name: \"Beard trim\", minutes: 20, price: 18 },\n]\nconst stylists: Salon1Stylist[] = [\n  { id: \"mira\", name: \"Mira\", specialty: \"Colour\" },\n  { id: \"luca\", name: \"Luca\", specialty: \"Cuts\" },\n  { id: \"dana\", name: \"Dana\", specialty: \"Everything\" },\n]\nconst days: Salon1Day[] = [\n  { id: \"sat\", label: \"Sat 25 Oct\", slots: [{ time: \"09:00\", stylists: [\"mira\", \"luca\"] }, { time: \"10:30\", stylists: [\"luca\", \"dana\"] }, { time: \"13:00\", stylists: [\"mira\", \"dana\"] }, { time: \"15:30\", stylists: [\"dana\"] }] },\n  { id: \"mon\", label: \"Mon 27 Oct\", slots: [{ time: \"10:00\", stylists: [\"luca\"] }, { time: \"12:00\", stylists: [\"mira\", \"luca\", \"dana\"] }, { time: \"16:00\", stylists: [\"mira\"] }] },\n  { id: \"tue\", label: \"Tue 28 Oct\", slots: [] },\n]\n\nfunction Skel() {\n  return (\n    <div aria-hidden=\"true\" className=\"grid gap-6 lg:grid-cols-[minmax(0,1fr)_18rem]\">\n      <div className=\"grid gap-4\">\n        <Skeleton className=\"h-48 rounded-xl\" />\n        <Skeleton className=\"h-28 rounded-xl\" />\n        <Skeleton className=\"h-40 rounded-xl\" />\n      </div>\n      <Skeleton className=\"h-72 rounded-xl\" />\n    </div>\n  )\n}\n\n/**\n * A salon booking page in one view: choose services (the total time and price add up), choose a stylist or anyone, choose a day and a time that stylist is free.\n * Times only show when they work, a day with nothing free says so, and the summary says exactly what is booked before the button is pressed.\n */\nfunction Salon1({ services: svc = services, stylists: sty = stylists, days: dayList = days, title = \"Book an appointment\", currency = \"USD\", onBook, state = \"ready\", onRetry, emptyState, errorState, className, ...props }: Salon1Props) {\n  const id = React.useId()\n  const [chosen, setChosen] = React.useState<string[]>([])\n  const [stylist, setStylist] = React.useState(\"any\")\n  const [day, setDay] = React.useState<string | null>(null)\n  const [time, setTime] = React.useState<string | null>(null)\n  const [tried, setTried] = React.useState(false)\n  const [busy, setBusy] = React.useState(false)\n  const [result, setResult] = React.useState<{ ok: boolean; text: string } | null>(null)\n  const view = state === \"ready\" && svc.length === 0 ? \"empty\" : state\n  const money = (n: number) => new Intl.NumberFormat(\"en-US\", { style: \"currency\", currency }).format(n)\n  const picked = svc.filter((s) => chosen.includes(s.id))\n  const minutes = picked.reduce((n, s) => n + s.minutes, 0)\n  const total = picked.reduce((n, s) => n + s.price, 0)\n  const d = dayList.find((x) => x.id === day)\n  const slots = (d?.slots ?? []).filter((s) => stylist === \"any\" || s.stylists.includes(stylist))\n  const hm = (m: number) => (m >= 60 ? `${Math.floor(m / 60)} h${m % 60 ? ` ${m % 60} min` : \"\"}` : `${m} min`)\n  const who = stylist === \"any\" ? \"Anyone free\" : sty.find((s) => s.id === stylist)?.name\n\n  const choose = <T,>(set: (v: T) => void, v: T) => { setResult(null); set(v) }\n  async function book() {\n    setTried(true)\n    if (!picked.length || !day || !time) return\n    setBusy(true)\n    setResult(null)\n    try {\n      await onBook?.({ services: chosen, stylist, day, time })\n      setResult({ ok: true, text: `Booked: ${d?.label} at ${time}, ${hm(minutes)}, ${money(total)}. We have sent a confirmation.` })\n    } catch (e) {\n      setResult({ ok: false, text: e instanceof Error ? e.message : \"The booking was not made. Your choices are still here.\" })\n    } finally {\n      setBusy(false)\n    }\n  }\n\n  return (\n    <section data-slot=\"salon-1\" data-state={state} aria-labelledby={`${id}-title`} className={cn(\"mx-auto w-full max-w-5xl 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      </header>\n      <StateView state={view} onRetry={onRetry} label=\"Loading the booking page\" loading={<Skel />} empty={{ title: \"Booking is closed\", description: \"There is nothing to book at the moment. Please check back soon.\", ...emptyState }} error={{ title: \"The booking page could not be loaded\", description: \"Nothing was booked. Check your connection and try again.\", ...errorState }}>\n        <div className=\"grid gap-8 lg:grid-cols-[minmax(0,1fr)_19rem]\">\n          <div className=\"grid min-w-0 gap-8\">\n            <fieldset>\n              <legend className=\"mb-3 text-lg font-semibold tracking-tight\">1. Choose your services</legend>\n              <ul className=\"grid gap-2.5 sm:grid-cols-2\">\n                {svc.map((s) => (\n                  <li key={s.id}>\n                    <label className=\"relative block cursor-pointer\">\n                      <input type=\"checkbox\" checked={chosen.includes(s.id)} onChange={(e) => choose(setChosen, e.target.checked ? [...chosen, s.id] : chosen.filter((x) => x !== s.id))} className=\"peer sr-only\" />\n                      <span className=\"bg-card peer-checked:border-foreground peer-checked:bg-accent/40 peer-focus-visible:ring-ring/50 flex items-center justify-between gap-3 rounded-xl border p-3.5 transition-colors peer-focus-visible:ring-[3px] motion-reduce:transition-none\">\n                        <span>\n                          <span className=\"block text-sm font-medium\">{s.name}</span>\n                          <span className=\"text-muted-foreground flex items-center gap-1.5 text-xs\"><Clock aria-hidden=\"true\" className=\"size-3\" />{hm(s.minutes)}</span>\n                        </span>\n                        <span className=\"flex items-center gap-2 text-sm font-medium tabular-nums\">{money(s.price)}<CircleCheck aria-hidden=\"true\" className={cn(\"size-5\", chosen.includes(s.id) ? \"opacity-100\" : \"opacity-20\")} /></span>\n                      </span>\n                    </label>\n                  </li>\n                ))}\n              </ul>\n              {tried && !picked.length && <p role=\"alert\" className=\"text-destructive mt-2 text-sm font-medium\">Choose at least one service.</p>}\n            </fieldset>\n\n            <fieldset>\n              <legend className=\"mb-3 text-lg font-semibold tracking-tight\">2. Choose a stylist</legend>\n              <div className=\"flex flex-wrap gap-2.5\">\n                {[{ id: \"any\", name: \"Anyone free\", specialty: \"The earliest time\" }, ...sty].map((s) => (\n                  <label key={s.id} className=\"relative cursor-pointer\">\n                    <input type=\"radio\" name={`${id}-stylist`} value={s.id} checked={stylist === s.id} onChange={() => { choose(setStylist, s.id); setTime(null) }} className=\"peer sr-only\" />\n                    <span className=\"bg-card peer-checked:border-foreground peer-checked:bg-accent/40 peer-focus-visible:ring-ring/50 block rounded-xl border px-4 py-2.5 text-sm transition-colors peer-focus-visible:ring-[3px] motion-reduce:transition-none\">\n                      <span className=\"block font-medium\">{s.name}</span>\n                      {s.specialty && <span className=\"text-muted-foreground block text-xs\">{s.specialty}</span>}\n                    </span>\n                  </label>\n                ))}\n              </div>\n            </fieldset>\n\n            <fieldset>\n              <legend className=\"mb-3 text-lg font-semibold tracking-tight\">3. Choose a day and a time</legend>\n              <div role=\"group\" aria-label=\"Day\" className=\"flex flex-wrap gap-2\">\n                {dayList.map((x) => (\n                  <button key={x.id} type=\"button\" aria-pressed={day === x.id} onClick={() => { choose(setDay, x.id); setTime(null) }} 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\", day === x.id ? \"bg-foreground text-background border-foreground\" : \"hover:bg-accent\")}>{x.label}</button>\n                ))}\n              </div>\n              <div className=\"mt-4\" aria-live=\"polite\">\n                {!d ? (\n                  <p className=\"text-muted-foreground text-sm\">Choose a day to see the times.</p>\n                ) : slots.length ? (\n                  <div role=\"group\" aria-label={`Times on ${d.label}`} className=\"flex flex-wrap gap-2\">\n                    {slots.map((s) => (\n                      <button key={s.time} type=\"button\" aria-pressed={time === s.time} onClick={() => choose(setTime, s.time)} className={cn(\"focus-visible:ring-ring/50 h-10 min-w-20 rounded-lg border px-3 text-sm font-medium tabular-nums outline-none transition-colors focus-visible:ring-[3px] motion-reduce:transition-none\", time === s.time ? \"bg-foreground text-background border-foreground\" : \"hover:bg-accent\")}>{s.time}</button>\n                    ))}\n                  </div>\n                ) : (\n                  <p className=\"text-muted-foreground text-sm\">{stylist === \"any\" ? `Nothing is free on ${d.label}.` : `${who} has nothing free on ${d.label}.`} Try another day{stylist === \"any\" ? \"\" : \" or choose anyone free\"}.</p>\n                )}\n                {tried && d && !time && <p role=\"alert\" className=\"text-destructive mt-2 text-sm font-medium\">Choose a time.</p>}\n                {tried && !d && <p role=\"alert\" className=\"text-destructive mt-2 text-sm font-medium\">Choose a day and a time.</p>}\n              </div>\n            </fieldset>\n          </div>\n\n          <aside aria-labelledby={`${id}-sum`} className=\"lg:sticky lg:top-6 lg:self-start\">\n            <div className=\"bg-card grid gap-4 rounded-2xl border p-5\">\n              <h2 id={`${id}-sum`} className=\"text-lg font-semibold tracking-tight\">Your appointment</h2>\n              {picked.length ? (\n                <ul className=\"grid gap-1.5 text-sm\">\n                  {picked.map((s) => (\n                    <li key={s.id} className=\"flex justify-between gap-3\"><span>{s.name}</span><span className=\"text-muted-foreground tabular-nums\">{money(s.price)}</span></li>\n                  ))}\n                </ul>\n              ) : (\n                <p className=\"text-muted-foreground text-sm\">No services chosen yet.</p>\n              )}\n              <dl className=\"grid gap-1.5 border-t pt-3 text-sm\">\n                <div className=\"flex justify-between gap-3\"><dt className=\"text-muted-foreground\">Stylist</dt><dd className=\"font-medium\">{who}</dd></div>\n                <div className=\"flex justify-between gap-3\"><dt className=\"text-muted-foreground\">When</dt><dd className=\"font-medium tabular-nums\">{d && time ? `${d.label}, ${time}` : \"Not chosen\"}</dd></div>\n                <div className=\"flex justify-between gap-3\"><dt className=\"text-muted-foreground\">Time needed</dt><dd className=\"font-medium tabular-nums\">{minutes ? hm(minutes) : \"–\"}</dd></div>\n                <div className=\"flex justify-between gap-3 border-t pt-2 text-base\"><dt className=\"font-semibold\">Total</dt><dd className=\"font-semibold tabular-nums\">{money(total)}</dd></div>\n              </dl>\n              <Button size=\"lg\" disabled={busy || result?.ok === true} onClick={() => void book()}>\n                {busy && <Loader2 aria-hidden=\"true\" className=\"animate-spin\" />} Book appointment\n              </Button>\n              <p aria-live=\"polite\" role={result && !result.ok ? \"alert\" : undefined} className={cn(\"text-sm\", result && !result.ok ? \"text-destructive font-medium\" : \"text-muted-foreground\")}>{result?.text}</p>\n            </div>\n          </aside>\n        </div>\n      </StateView>\n    </section>\n  )\n}\n\nexport { Salon1, type Salon1Day, type Salon1Props, type Salon1Request, type Salon1Service, type Salon1Stylist }\n",
      "type": "registry:component",
      "target": "@components/ballmac/blocks/salon-1/salon-1.tsx"
    }
  ],
  "meta": {
    "schema": 1,
    "tier": "free",
    "tags": [
      "booking",
      "appointments",
      "salon",
      "scheduling",
      "services",
      "clinic",
      "beauty",
      "customer"
    ],
    "version": "1.0.0",
    "updated": "2026-10-24",
    "tests": true,
    "ai": {
      "summary": "Appointment booking. <Salon1 services={[{ id, name, minutes, price }]} stylists={[{ id, name, specialty? }]} days={[{ id, label, slots: [{ time, stylists: ['id'] }] }]} currency='USD' onBook={({ services, stylist, day, time }) => Promise} />. Times show only when the chosen stylist is free; changing stylist or day clears the time. A failed booking keeps the choices.",
      "whenToUse": [
        "A customer-facing booking page",
        "Any choose-what, choose-who, choose-when flow"
      ],
      "whenNotToUse": [
        "A staff calendar; use calendar-1",
        "Class or event tickets; use billing-1"
      ],
      "composesWith": [
        "floorplan-1",
        "rentals-1",
        "booking-1"
      ],
      "a11y": [
        {
          "keys": "Services",
          "action": "Checkboxes in a fieldset, each with time and price in its label"
        },
        {
          "keys": "Stylist",
          "action": "A radio group; Anyone free is the default"
        },
        {
          "keys": "Times",
          "action": "Pressed-state buttons in a labelled group; an empty day says why"
        },
        {
          "keys": "Result",
          "action": "Confirmation and errors are announced; missing choices are alerts"
        }
      ],
      "customization": [
        "services, stylists, days, currency",
        "onBook"
      ]
    },
    "states": [
      {
        "id": "loading",
        "title": "Loading",
        "example": "salon-1-loading",
        "trigger": "state=\"loading\"",
        "note": "Skeleton form and summary under the real heading, announced as loading."
      },
      {
        "id": "empty",
        "title": "Booking is closed",
        "example": "salon-1-empty",
        "trigger": "state=\"empty\"",
        "note": "Says booking is closed; emptyState sets the copy."
      },
      {
        "id": "error",
        "title": "Could not load",
        "example": "salon-1-error",
        "trigger": "state=\"error\"",
        "note": "An alert saying nothing was booked, with Try again (onRetry). A booking that fails keeps every choice and says why."
      }
    ],
    "examples": [
      "salon-1-demo",
      "salon-1-quiet",
      "salon-1-loading",
      "salon-1-empty",
      "salon-1-error"
    ],
    "url": "https://ui.ballmac.com/components/salon-1"
  },
  "categories": [
    "blocks",
    "salon"
  ],
  "type": "registry:block"
}