Skip to content
All examples

Booking

Pick a day and a free time, leave your details and get a confirmation. Past days, weekends and fully booked days can't be chosen.

Add this example to a project that has prfct installed. The command writes the files below and installs the components they use.

$ pnpm dlx shadcn@latest add https://www.prfct.dev/r/booking.json
components/booking/booking.tsx
"use client"

import * as React from "react"
import {
  ArrowLeftIcon,
  CalendarCheckIcon,
  CalendarIcon,
  ClockIcon,
  GlobeIcon,
  VideoIcon,
  type LucideIcon,
} from "lucide-react"

import host from "./aurora.svg"
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"
import { Button } from "@/components/ui/button"
import { Calendar } from "@/components/ui/calendar"
import {
  Field,
  FieldError,
  FieldGroup,
  FieldLabel,
} from "@/components/ui/field"
import { Input } from "@/components/ui/input"
import { Textarea } from "@/components/ui/textarea"

// A fixed "today" keeps the example the same on every visit.
const today = new Date(2026, 8, 23)
const firstFreeDay = new Date(2026, 8, 24)
const fullyBooked = [new Date(2026, 8, 25), new Date(2026, 9, 2)]

const DURATION = 30
/** The hours a call can start at: every half hour, with a break for lunch. */
const starts = [9, 9.5, 10, 10.5, 11, 11.5, 13, 13.5, 14, 14.5, 15, 15.5]

const dayFormat = new Intl.DateTimeFormat("en-US", {
  weekday: "long",
  month: "long",
  day: "numeric",
})
const timeFormat = new Intl.DateTimeFormat("en-US", {
  hour: "numeric",
  minute: "2-digit",
})

const EMAIL = /^\S+@\S+\.\S+$/

type Guest = { name: string; email: string }

/**
 * The free slots of a day. A real calendar asks its server; here every third
 * slot is taken, shifted by the date so that days differ.
 */
function freeSlots(day: Date) {
  return starts
    .filter((_, index) => (index + day.getDate()) % 3 !== 0)
    .map(
      (hour) =>
        new Date(day.getFullYear(), day.getMonth(), day.getDate(), 0, hour * 60)
    )
}

const endOf = (slot: Date) => new Date(slot.getTime() + DURATION * 60_000)

function Detail({
  icon: Icon,
  children,
}: {
  icon: LucideIcon
  children: React.ReactNode
}) {
  return (
    <li className="flex items-start gap-2.5">
      <Icon className="mt-0.5 size-4 shrink-0" aria-hidden="true" />
      {children}
    </li>
  )
}

/** What is being booked, and once a slot is chosen, when. */
function Summary({ slot }: { slot?: Date }) {
  return (
    <div className="flex flex-col gap-5">
      <div className="flex items-center gap-3">
        <Avatar>
          <AvatarImage src={host.src} alt="" />
          <AvatarFallback>MC</AvatarFallback>
        </Avatar>
        <span className="text-sm text-muted-foreground">Maya Chen</span>
      </div>
      <div className="flex flex-col gap-2">
        <h2 className="text-heading-lg">Intro call</h2>
        <p className="text-sm text-muted-foreground">
          A walkthrough of Meridian for your team. Bring your questions.
        </p>
      </div>
      <ul className="flex flex-col gap-2.5 text-sm text-muted-foreground">
        {slot && (
          <Detail icon={CalendarIcon}>
            <span className="font-medium text-foreground first-letter:uppercase">
              {dayFormat.format(slot)},{" "}
              {timeFormat.formatRange(slot, endOf(slot))}
            </span>
          </Detail>
        )}
        <Detail icon={ClockIcon}>{DURATION} minutes</Detail>
        <Detail icon={VideoIcon}>Video call, link sent by email</Detail>
        <Detail icon={GlobeIcon}>Central European Time</Detail>
      </ul>
    </div>
  )
}

function TimeSlots({
  day,
  onPick,
}: {
  day: Date
  onPick: (slot: Date) => void
}) {
  return (
    <section aria-labelledby="booking-day" className="flex flex-col gap-3">
      {/* Announced when the day changes: the times below belong to it. */}
      <h3
        id="booking-day"
        aria-live="polite"
        // As tall as the calendar's caption, so the two headings line up.
        className="h-9 text-sm leading-9 font-medium first-letter:uppercase"
      >
        {dayFormat.format(day)}
      </h3>
      <div className="grid grid-cols-2 gap-2 @sm:grid-cols-3 @3xl:grid-cols-1">
        {freeSlots(day).map((slot) => (
          <Button
            key={slot.getTime()}
            variant="outline"
            onClick={() => onPick(slot)}
          >
            {timeFormat.format(slot)}
          </Button>
        ))}
      </div>
    </section>
  )
}

/** A labelled text input that shows its error, wired for screen readers. */
function TextField({
  name,
  label,
  error,
  ...props
}: React.ComponentProps<typeof Input> & {
  name: keyof Guest
  label: string
  error?: string
}) {
  const id = `booking-${name}`
  return (
    <Field data-invalid={error ? true : undefined}>
      <FieldLabel htmlFor={id}>{label}</FieldLabel>
      <Input
        id={id}
        name={name}
        aria-invalid={error ? true : undefined}
        aria-describedby={error ? `${id}-error` : undefined}
        {...props}
      />
      {error && <FieldError id={`${id}-error`}>{error}</FieldError>}
    </Field>
  )
}

function DetailsForm({
  onBack,
  onConfirm,
}: {
  onBack: () => void
  onConfirm: (guest: Guest) => void
}) {
  const [errors, setErrors] = React.useState<Partial<Guest>>({})
  const [pending, setPending] = React.useState(false)

  async function submit(event: React.FormEvent<HTMLFormElement>) {
    event.preventDefault()
    const form = event.currentTarget
    const data = new FormData(form)
    const guest: Guest = {
      name: String(data.get("name")).trim(),
      email: String(data.get("email")).trim(),
    }

    const problems: Partial<Guest> = {}
    if (!guest.name) problems.name = "Enter your name."
    if (!EMAIL.test(guest.email)) {
      problems.email = "Enter an email address like ada@example.com."
    }
    setErrors(problems)
    const [first] = Object.keys(problems)
    if (first) {
      // Send people to the first field that needs fixing.
      form.querySelector<HTMLInputElement>(`[name="${first}"]`)?.focus()
      return
    }

    setPending(true)
    // Stands in for the request that books the slot.
    await new Promise((resolve) => setTimeout(resolve, 900))
    onConfirm(guest)
  }

  return (
    <form noValidate onSubmit={submit} className="flex flex-col gap-6">
      <h3 className="text-heading-sm">Your details</h3>
      <FieldGroup>
        <TextField
          name="name"
          label="Name"
          autoComplete="name"
          autoFocus
          error={errors.name}
        />
        <TextField
          name="email"
          label="Email"
          type="email"
          autoComplete="email"
          placeholder="ada@example.com"
          error={errors.email}
        />
        <Field>
          <FieldLabel htmlFor="booking-notes">
            Notes
            <span className="font-normal text-muted-foreground">
              (optional)
            </span>
          </FieldLabel>
          <Textarea
            id="booking-notes"
            name="notes"
            placeholder="Anything we should prepare?"
          />
        </Field>
      </FieldGroup>
      <div className="flex flex-wrap justify-between gap-2">
        {/* Off while the booking is sent: going back wouldn't cancel it. */}
        <Button
          type="button"
          variant="ghost"
          disabled={pending}
          onClick={onBack}
        >
          <ArrowLeftIcon data-icon="inline-start" />
          Change time
        </Button>
        <Button type="submit" loading={pending}>
          Confirm booking
        </Button>
      </div>
    </form>
  )
}

function Confirmation({
  guest,
  onReset,
}: {
  guest: Guest
  onReset: () => void
}) {
  const heading = React.useRef<HTMLHeadingElement>(null)
  // The form that had focus is gone; the heading takes it, so screen readers
  // announce the result and keyboard users continue from here.
  React.useEffect(() => heading.current?.focus(), [])

  return (
    <div className="flex flex-col items-start gap-6">
      <span className="flex size-11 items-center justify-center rounded-xl border bg-card text-muted-foreground shadow-raised">
        <CalendarCheckIcon className="size-5" aria-hidden="true" />
      </span>
      <div className="flex flex-col gap-2">
        <h3
          ref={heading}
          tabIndex={-1}
          className="text-heading-lg outline-none"
        >
          You’re booked
        </h3>
        <p className="text-sm text-muted-foreground">
          An invitation with the video link is on its way to{" "}
          <span className="font-medium wrap-anywhere text-foreground">
            {guest.email}
          </span>
          .
        </p>
      </div>
      <Button variant="outline" onClick={onReset}>
        Book another time
      </Button>
    </div>
  )
}

export default function BookingExample() {
  const [day, setDay] = React.useState(firstFreeDay)
  const [slot, setSlot] = React.useState<Date>()
  const [guest, setGuest] = React.useState<Guest>()

  function reset() {
    setSlot(undefined)
    setGuest(undefined)
  }

  return (
    <div className="@container">
      <div className="mx-auto grid max-w-4xl overflow-hidden rounded-xl border bg-background @2xl:grid-cols-[17rem_minmax(0,1fr)]">
        <div className="border-b p-4 @md:p-6 @2xl:border-r @2xl:border-b-0">
          <Summary slot={slot} />
        </div>
        <div className="p-4 @md:p-6">
          {guest ? (
            <Confirmation guest={guest} onReset={reset} />
          ) : slot ? (
            <DetailsForm
              onBack={() => setSlot(undefined)}
              onConfirm={setGuest}
            />
          ) : (
            <div className="grid gap-6 @3xl:grid-cols-[auto_minmax(0,1fr)]">
              <Calendar
                mode="single"
                required
                today={today}
                defaultMonth={day}
                selected={day}
                onSelect={setDay}
                // Past days, weekends and fully booked days can't be chosen.
                disabled={[
                  { before: today },
                  { dayOfWeek: [0, 6] },
                  ...fullyBooked,
                ]}
                className="mx-auto p-0 @3xl:mx-0"
              />
              <TimeSlots day={day} onPick={setSlot} />
            </div>
          )}
        </div>
      </div>
    </div>
  )
}
components/booking/aurora.svg
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 80 80">
  <rect width="80" height="80" fill="#e4eaff"/>
  <circle cx="40" cy="31" r="14" fill="#8ea6ff"/>
  <path d="M12 80c0-15.5 12.5-26 28-26s28 10.5 28 26z" fill="#8ea6ff"/>
</svg>
app/booking/page.tsx
import type { Metadata } from "next"

import Booking from "@/components/booking/booking"

export const metadata: Metadata = {
  title: "Booking",
  description: "Scheduling a call: a calendar with unavailable days, free time slots, a short form and a confirmation.",
}

export default function BookingPage() {
  return (
    <main className="mx-auto min-h-dvh w-full max-w-[90rem] p-4 sm:p-8">
      <Booking />
    </main>
  )
}