{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "rentals-1",
  "title": "Rentals 1: rental booking",
  "author": "Ballmac <https://ui.ballmac.com>",
  "description": "A rental booking page: choose an item, the days and extras, see price, refundable deposit and pay-today, a clash with days already out named by date, and a reservation 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/rentals-1/rentals-1.tsx",
      "content": "// Ballmac UI: Rentals 1. https://ui.ballmac.com/blocks/rentals-1\n\"use client\"\n\nimport * as React from \"react\"\nimport { CalendarDays, Loader2, ShieldCheck } 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 Rentals1Item = {\n  id: string\n  name: string\n  detail: string\n  /** Price for one day. */\n  perDay: number\n  /** Refundable deposit, taken with the booking. */\n  deposit: number\n  /** Dates the item is already out, as \"YYYY-MM-DD\" days. */\n  booked?: string[]\n}\ntype Rentals1Extra = { id: string; name: string; perDay: number }\ntype Rentals1Request = { item: string; from: string; to: string; extras: string[] }\n\ntype Rentals1Props = Omit<React.ComponentProps<\"section\">, \"title\"> &\n  StateProps & {\n    items?: Rentals1Item[]\n    extras?: Rentals1Extra[]\n    title?: string\n    currency?: string\n    /** Longest rental in days. */\n    maxDays?: number\n    /** The earliest day that can be chosen, as \"YYYY-MM-DD\". Defaults to today. */\n    today?: string\n    /** Called with the request. Reject and the choices stay with the message. */\n    onReserve?: (request: Rentals1Request) => void | Promise<void>\n  }\n\nconst items: Rentals1Item[] = [\n  { id: \"kayak\", name: \"Two-person sea kayak\", detail: \"Paddles, spray decks and life jackets included.\", perDay: 60, deposit: 100, booked: [\"2026-11-03\", \"2026-11-04\", \"2026-11-05\"] },\n  { id: \"sup\", name: \"Inflatable paddle board\", detail: \"Pump, leash and a dry bag.\", perDay: 35, deposit: 60 },\n  { id: \"tent\", name: \"Four-person tent\", detail: \"Pitched in five minutes. Footprint included.\", perDay: 28, deposit: 80, booked: [\"2026-11-01\"] },\n]\nconst extras: Rentals1Extra[] = [\n  { id: \"ins\", name: \"Damage cover\", perDay: 6 },\n  { id: \"roof\", name: \"Roof rack straps\", perDay: 3 },\n]\n\nconst DAY = 86400000\nconst iso = (d: Date) => d.toISOString().slice(0, 10)\nconst at = (s: string) => new Date(`${s}T00:00:00Z`).getTime()\n/** Whole days from the first day to the last day, counting both ends. Zero for an invalid or backwards range. */\nfunction days(from: string, to: string) {\n  if (!from || !to) return 0\n  const n = Math.round((at(to) - at(from)) / DAY) + 1\n  return n > 0 ? n : 0\n}\n/** The first booked day inside a range, or null when the whole range is free. */\nfunction firstClash(from: string, to: string, booked: string[] = []) {\n  return days(from, to) ? [...booked].sort().find((b) => b >= from && b <= to) ?? null : null\n}\n\nfunction Skel() {\n  return (\n    <div aria-hidden=\"true\" className=\"grid gap-6 lg:grid-cols-[minmax(0,1fr)_19rem]\">\n      <div className=\"grid gap-3\">{[0, 1, 2].map((i) => <Skeleton key={i} className=\"h-24 rounded-xl\" />)}</div>\n      <Skeleton className=\"h-72 rounded-xl\" />\n    </div>\n  )\n}\n\n/**\n * A rental booking page: pick the item, pick the days, and the price, deposit and any clash with days it is already out are worked out as you go.\n * A clash names the first day that is taken, a range that runs backwards says so, and the summary is exact before the button.\n */\nfunction Rentals1({ items: list = items, extras: ex = extras, title = \"Rent gear for the weekend\", currency = \"USD\", maxDays = 14, today, onReserve, state = \"ready\", onRetry, emptyState, errorState, className, ...props }: Rentals1Props) {\n  const id = React.useId()\n  // Today is read after mount, so a page prerendered ahead of time never freezes the date.\n  const [min, setMin] = React.useState(today ?? \"\")\n  React.useEffect(() => { if (!today) setMin(iso(new Date())) }, [today])\n  const [itemId, setItemId] = React.useState(list[0]?.id ?? \"\")\n  const [from, setFrom] = React.useState(\"\")\n  const [to, setTo] = React.useState(\"\")\n  const [chosen, setChosen] = React.useState<string[]>([])\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\" && list.length === 0 ? \"empty\" : state\n  const money = (n: number) => new Intl.NumberFormat(\"en-US\", { style: \"currency\", currency }).format(n)\n  const item = list.find((x) => x.id === itemId)\n  const n = days(from, to)\n  const clash = item ? firstClash(from, to, item.booked) : null\n  const perDay = (item?.perDay ?? 0) + ex.filter((e) => chosen.includes(e.id)).reduce((s, e) => s + e.perDay, 0)\n  const rental = perDay * n\n  const long = n > maxDays\n  const dateLabel = (s: string) => new Date(`${s}T00:00:00Z`).toLocaleDateString(\"en-US\", { day: \"numeric\", month: \"short\", timeZone: \"UTC\" })\n\n  const problem = !from || !to ? \"Choose the first and last day.\" : n === 0 ? \"The last day is before the first day.\" : long ? `The longest rental is ${maxDays} days.` : clash ? `${item?.name} is already out on ${dateLabel(clash)}. Choose other days or another item.` : null\n  const reset = () => setResult(null)\n\n  async function reserve() {\n    setTried(true)\n    if (problem || !item) return\n    setBusy(true)\n    setResult(null)\n    try {\n      await onReserve?.({ item: item.id, from, to, extras: chosen })\n      setResult({ ok: true, text: `Reserved: ${item.name}, ${dateLabel(from)} to ${dateLabel(to)} (${n} ${n === 1 ? \"day\" : \"days\"}). ${money(rental)} plus a ${money(item.deposit)} deposit you get back.` })\n    } catch (e) {\n      setResult({ ok: false, text: e instanceof Error ? e.message : \"The reservation was not made. Your choices are still here.\" })\n    } finally {\n      setBusy(false)\n    }\n  }\n\n  const dateInput = \"bg-background focus-visible:ring-ring/50 h-11 w-full rounded-lg border px-3 text-sm outline-none focus-visible:ring-[3px] aria-[invalid=true]:border-destructive\"\n\n  return (\n    <section data-slot=\"rentals-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      <h1 id={`${id}-title`} className=\"mb-8 text-3xl font-semibold tracking-[-0.035em] text-balance\">{title}</h1>\n      <StateView state={view} onRetry={onRetry} label=\"Loading the rentals\" loading={<Skel />} empty={{ title: \"Nothing to rent right now\", description: \"All our gear is out. Check back soon.\", ...emptyState }} error={{ title: \"The rentals could not be loaded\", description: \"Nothing was reserved. 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 what to rent</legend>\n              <ul className=\"grid gap-2.5\">\n                {list.map((x) => (\n                  <li key={x.id}>\n                    <label className=\"relative block cursor-pointer\">\n                      <input type=\"radio\" name={`${id}-item`} checked={itemId === x.id} onChange={() => { reset(); setItemId(x.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-start justify-between gap-4 rounded-xl border p-4 transition-colors peer-focus-visible:ring-[3px] motion-reduce:transition-none\">\n                        <span className=\"min-w-0\">\n                          <span className=\"block text-sm font-medium\">{x.name}</span>\n                          <span className=\"text-muted-foreground block text-sm\">{x.detail}</span>\n                          {x.booked?.length ? <span className=\"text-muted-foreground mt-1 block text-xs\">Out on {x.booked.length} {x.booked.length === 1 ? \"day\" : \"days\"} soon</span> : null}\n                        </span>\n                        <span className=\"shrink-0 text-end text-sm font-semibold tabular-nums\">{money(x.perDay)}<span className=\"text-muted-foreground block text-xs font-normal\">a day</span></span>\n                      </span>\n                    </label>\n                  </li>\n                ))}\n              </ul>\n            </fieldset>\n\n            <fieldset>\n              <legend className=\"mb-3 text-lg font-semibold tracking-tight\">2. Choose the days</legend>\n              <div className=\"grid gap-3 sm:grid-cols-2\">\n                <div>\n                  <label htmlFor={`${id}-from`} className=\"mb-1.5 block text-sm font-medium\">First day</label>\n                  <input id={`${id}-from`} type=\"date\" min={min || undefined} value={from} onChange={(e) => { reset(); setFrom(e.target.value); if (!to || e.target.value > to) setTo(\"\") }} aria-invalid={tried && !from} className={dateInput} />\n                </div>\n                <div>\n                  <label htmlFor={`${id}-to`} className=\"mb-1.5 block text-sm font-medium\">Last day</label>\n                  <input id={`${id}-to`} type=\"date\" min={from || min || undefined} value={to} onChange={(e) => { reset(); setTo(e.target.value) }} aria-invalid={tried && !!problem} aria-describedby={`${id}-problem`} className={dateInput} />\n                </div>\n              </div>\n              <p id={`${id}-problem`} aria-live=\"polite\" role={tried && problem ? \"alert\" : undefined} className={cn(\"mt-2 min-h-5 text-sm\", tried && problem ? \"text-destructive font-medium\" : clash || long ? \"text-destructive\" : \"text-muted-foreground\")}>\n                {tried && problem ? problem : clash || long || n === 0 && from && to ? problem : n ? `${n} ${n === 1 ? \"day\" : \"days\"} · ${dateLabel(from)} to ${dateLabel(to)}` : \"\"}\n              </p>\n            </fieldset>\n\n            {ex.length > 0 && (\n              <fieldset>\n                <legend className=\"mb-3 text-lg font-semibold tracking-tight\">3. Add extras</legend>\n                <ul className=\"grid gap-2 sm:grid-cols-2\">\n                  {ex.map((e) => (\n                    <li key={e.id}>\n                      <label className=\"bg-card flex items-center justify-between gap-3 rounded-xl border p-3.5 text-sm\">\n                        <span className=\"flex items-center gap-2.5\"><input type=\"checkbox\" checked={chosen.includes(e.id)} onChange={(ev) => { reset(); setChosen(ev.target.checked ? [...chosen, e.id] : chosen.filter((x) => x !== e.id)) }} className=\"size-4\" />{e.name}</span>\n                        <span className=\"text-muted-foreground tabular-nums\">+{money(e.perDay)} a day</span>\n                      </label>\n                    </li>\n                  ))}\n                </ul>\n              </fieldset>\n            )}\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 rental</h2>\n              <p className=\"text-sm font-medium\">{item?.name}</p>\n              <dl className=\"grid gap-1.5 text-sm\">\n                <div className=\"flex justify-between gap-3\"><dt className=\"text-muted-foreground\">Price a day</dt><dd className=\"tabular-nums\">{money(perDay)}</dd></div>\n                <div className=\"flex justify-between gap-3\"><dt className=\"text-muted-foreground\">Days</dt><dd className=\"tabular-nums\">{n || \"–\"}</dd></div>\n                <div className=\"flex justify-between gap-3 border-t pt-2\"><dt className=\"font-medium\">Rental</dt><dd className=\"font-semibold tabular-nums\">{n && !problem ? money(rental) : \"–\"}</dd></div>\n                <div className=\"flex justify-between gap-3\"><dt className=\"text-muted-foreground\">Deposit, returned</dt><dd className=\"tabular-nums\">{item ? money(item.deposit) : \"–\"}</dd></div>\n                <div className=\"flex justify-between gap-3 border-t pt-2 text-base\"><dt className=\"font-semibold\">Pay today</dt><dd className=\"font-semibold tabular-nums\">{n && !problem && item ? money(rental + item.deposit) : \"–\"}</dd></div>\n              </dl>\n              <Button size=\"lg\" disabled={busy || result?.ok === true} onClick={() => void reserve()}>\n                {busy ? <Loader2 aria-hidden=\"true\" className=\"animate-spin\" /> : <CalendarDays aria-hidden=\"true\" />} Reserve\n              </Button>\n              <p className=\"text-muted-foreground flex items-start gap-2 text-xs\"><ShieldCheck aria-hidden=\"true\" className=\"mt-0.5 size-3.5 shrink-0\" />The deposit comes back when the gear does.</p>\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 { Rentals1, days, firstClash, type Rentals1Extra, type Rentals1Item, type Rentals1Props, type Rentals1Request }\n",
      "type": "registry:component",
      "target": "@components/ballmac/blocks/rentals-1/rentals-1.tsx"
    }
  ],
  "meta": {
    "schema": 1,
    "tier": "free",
    "tags": [
      "rental",
      "booking",
      "reservation",
      "dates",
      "equipment",
      "deposit",
      "hire",
      "marketplace"
    ],
    "version": "1.0.0",
    "updated": "2026-10-24",
    "tests": true,
    "ai": {
      "summary": "Rental booking. <Rentals1 items={[{ id, name, detail, perDay, deposit, booked?: ['YYYY-MM-DD'] }]} extras={[{ id, name, perDay }]} maxDays={14} today='YYYY-MM-DD' currency='USD' onReserve={({ item, from, to, extras }) => Promise} />. Both ends of the range count as days. A range that includes a booked day is refused and names it.",
      "whenToUse": [
        "Equipment, vehicle or space hire",
        "Any date-range booking with a deposit"
      ],
      "whenNotToUse": [
        "A single appointment slot; use salon-1",
        "A hotel with rooms and guests; use booking-1"
      ],
      "composesWith": [
        "salon-1",
        "booking-1",
        "calendar-1"
      ],
      "a11y": [
        {
          "keys": "Item",
          "action": "A radio group; each option has its name, detail and daily price"
        },
        {
          "keys": "Dates",
          "action": "Native date fields with labels; problems are written and announced"
        },
        {
          "keys": "Summary",
          "action": "A description list; Pay today is only shown when the dates work"
        },
        {
          "keys": "Result",
          "action": "Confirmation and errors are announced"
        }
      ],
      "customization": [
        "items, extras, maxDays, today, currency",
        "onReserve"
      ]
    },
    "states": [
      {
        "id": "loading",
        "title": "Loading",
        "example": "rentals-1-loading",
        "trigger": "state=\"loading\"",
        "note": "Skeleton list and summary under the real heading, announced as loading."
      },
      {
        "id": "empty",
        "title": "Nothing to rent",
        "example": "rentals-1-empty",
        "trigger": "state=\"empty\"",
        "note": "Says there is nothing to rent; emptyState sets the copy."
      },
      {
        "id": "error",
        "title": "Could not load",
        "example": "rentals-1-error",
        "trigger": "state=\"error\"",
        "note": "An alert saying nothing was reserved, with Try again (onRetry). A reservation that fails keeps the choices and says why."
      }
    ],
    "examples": [
      "rentals-1-demo",
      "rentals-1-long",
      "rentals-1-loading",
      "rentals-1-empty",
      "rentals-1-error"
    ],
    "url": "https://ui.ballmac.com/components/rentals-1"
  },
  "categories": [
    "blocks",
    "rentals"
  ],
  "type": "registry:block"
}