# shadcn booking widget

Build a custom clndr.pro booking widget with shadcn/ui and the @clndr-pro/react hooks — a calendar, times grouped in the guest's timezone, the host's questions, and handling for slots someone else just booked.

`BookingInline` is one fixed flow: a date input, a list of times, a form. This tutorial builds the version you'd design yourself, with shadcn/ui: a month calendar beside the open times, times grouped under the guest's own dates, every question type drawn with the right shadcn input, skeletons while things load, and a recovery path when someone books the slot first.

It uses the [hooks](/react/hooks) for data and nothing else from the SDK, so every pixel is yours to change. The finished files are at the [end of the page](#the-finished-files).

**Build a custom shadcn booking widget**

```text
Build a custom clndr.pro booking widget in this project with shadcn/ui, following https://docs.clndr.pro/tutorials/shadcn-booking-widget.md (read it first, and https://docs.clndr.pro/react/hooks.md for the hook details).

- Use the hooks from @clndr-pro/react 0.2.1+ (useBookingPage, useAvailableSlots, useCreateBooking) inside 'use client' files. If there's no ClndrProvider yet, set one up as in https://docs.clndr.pro/nextjs.md.
- Add whichever of these shadcn components the project doesn't have: calendar button card input label textarea skeleton alert select checkbox. Install react-markdown for the page description.
- Match this project's existing styling and components where they differ from stock shadcn.
- Build the Date for useAvailableSlots from local parts (a date picker's Date is fine; never new Date('YYYY-MM-DD')). Show times in the guest's timezone, grouped by the guest's local date.
- Render every question type (text, email, phone, textarea, select, checkbox) and check required answers before submitting.
- When a booking fails because the slot was taken (HTTP 409), call refetch() from useAvailableSlots and tell the guest.
- Ask me for the booking page slug and where the widget should go. Run the type checker and the build when you're done.
```

## Before you start

You need a Next.js app with shadcn/ui and a [`ClndrProvider`](/nextjs#the-browser-side) holding your publishable key, and `@clndr-pro/react` 0.2.1 or later: earlier versions ask for the wrong day for many timezones. Add the shadcn components this widget uses, and `react-markdown` for the booking page's description:

```bash
npx shadcn@latest add calendar button card input label textarea skeleton alert select checkbox
npm install react-markdown
```

**Load the page**

`useBookingPage` returns the page, its host and its questions. Until the page arrives there's no data and no error; show skeletons then, and an error only when `pageError` is set.

```tsx
const { data: page, error: pageError } = useBookingPage(slug);

if (!page) {
  return pageError ? (
    <Alert variant="destructive">
      <AlertTitle>This booking page isn't available</AlertTitle>
      <AlertDescription>{pageError.message}</AlertDescription>
    </Alert>
  ) : (
    <Card>{/* skeletons */}</Card>
  );
}
```

The description is Markdown written by the host, so render it with `react-markdown`. The arbitrary-variant classes (`[&_ul]:list-disc`) style the generated lists and links without the typography plugin.

**Pick a day**

shadcn's `Calendar` (react-day-picker) hands you a `Date` at local midnight, which is exactly what `useAvailableSlots` wants. Disable days before today and after the page's booking window:

```tsx
const today = startOfToday();
const lastDay = bookingPage.max_days_ahead ? addDays(today, bookingPage.max_days_ahead) : undefined;

<Calendar
  mode="single"
  selected={date}
  onSelect={(day) => {
    setDate(day);
    setSlot(null);
    setNotice(null);
  }}
  disabled={lastDay ? [{ before: today }, { after: lastDay }] : { before: today }}
  className="rounded-md border"
/>
```

`date` lives in state. The hook compares the day's timestamp, so it fetches again only when the guest picks a different day.

**Show the open times**

`useAvailableSlots` is called once, at the top of the widget, so the list survives while the guest fills in the form ("Change" goes straight back to it) and the submit handler can call its `refetch()` after a failed booking. A small `SlotPicker` component only draws what it returns:

```tsx
const { slots, status: slotsStatus, error: slotsError, refetch: refetchSlots } = useAvailableSlots(slug, date ?? null);

<SlotPicker slots={slots} status={slotsStatus} error={slotsError} onPick={setSlot} />
```

The API returns the **host's** day, as UTC timestamps. Format them with the guest's locale and timezone, and group by the guest's date: when host and guest are hours apart, a host's Tuesday can start on the guest's Monday evening.

```tsx
const days = useMemo(() => {
  const byDay = new Map<string, TimeSlot[]>();
  for (const slot of slots) {
    const day = dayFormat.format(new Date(slot.start));
    byDay.set(day, [...(byDay.get(day) ?? []), slot]);
  }
  return [...byDay];
}, [slots]);
```

Show the skeleton for anything but `status === 'success'`, which also covers the moment between picking a day and the request starting. An empty list means the host has no free time that day, so say so.

**Ask the host's questions**

Each question has a `question_type`. `QuestionField` maps them to shadcn inputs: `textarea` to `Textarea`, `select` to `Select` with the question's `options`, `checkbox` to `Checkbox` with the answer `"Yes"` or `"No"`, `phone` to a `tel` input, the rest to `Input`.

The API doesn't enforce `is_required`, so the widget checks before submitting:

```tsx
function isAnswered(question: BookingQuestion, value: string | undefined) {
  if (question.question_type === 'checkbox') return value === 'Yes';
  return !!value?.trim();
}

const missing = questions.filter((q) => q.is_required && !isAnswered(q, answers[q.id]));
```

Send only answered questions, as `{ questionId, answer }` pairs. An unknown `questionId` makes the API drop every answer for that booking, so never send ids from another page.

**Book, and recover from a taken slot**

`create` from `useCreateBooking` posts the booking. Pass the slot's `start` and `end` unchanged. If two guests pick the same time, the second gets an HTTP 409: with a publishable key the message is usually "This time slot is not available. Fetch the slots again and pick another." (the check also matches the message text). The widget clears the picked slot, calls `refetchSlots()`, and tells the guest:

```tsx
try {
  await create({
    bookingPageId: bookingPage.id,
    guestName: name,
    guestEmail: email,
    startTime: slot.start,
    endTime: slot.end,
    responses: Object.entries(answers)
      .filter(([, answer]) => answer.trim() !== '')
      .map(([questionId, answer]) => ({ questionId, answer })),
  });
} catch (err) {
  if (isSlotTaken(err)) {
    setSlot(null);
    refetchSlots();
    setNotice('Someone booked that time a moment ago. Pick another one.');
  }
}
```

Other errors (the guest's email used its 5 booking attempts this clock hour, the page was switched off) land on the hook's `error`, and the form shows the API's message.

**Say what happened**

The booking that comes back has `status: 'confirmed'` on *direct* pages and `'pending'` on *approval* pages. Those need different words: a pending guest has not got a meeting yet, and clndr.pro won't email them until the host approves.

```tsx
<CardTitle>{booking.status === 'pending' ? 'Request sent' : "You're booked"}</CardTitle>
```

Direct pages with Meet links turned on return the link straight away in `google_meet_link`.

**Put it on a page**

```tsx title="app/book/page.tsx"
import { BookingWidget } from '@/components/booking/booking-widget';

export const metadata = { title: 'Book a call' };

export default function BookPage() {
  return (
    <main className="mx-auto max-w-4xl px-4 py-12">
      <BookingWidget slug="intro-call" />
    </main>
  );
}
```

The page stays a Server Component; `BookingWidget` is the client boundary.

## The finished files

```tsx title="components/booking/booking-widget.tsx"
'use client';

import { useMemo, useState, type FormEvent } from 'react';
import Markdown from 'react-markdown';
import {
  ClndrError,
  useAvailableSlots,
  useBookingPage,
  useCreateBooking,
  type BookingQuestion,
  type TimeSlot,
} from '@clndr-pro/react';
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert';
import { Button } from '@/components/ui/button';
import { Calendar } from '@/components/ui/calendar';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Skeleton } from '@/components/ui/skeleton';
import { QuestionField } from './question-field';

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

function startOfToday() {
  const now = new Date();
  return new Date(now.getFullYear(), now.getMonth(), now.getDate());
}

function addDays(date: Date, days: number) {
  return new Date(date.getFullYear(), date.getMonth(), date.getDate() + days);
}

/** Someone else booked the slot between fetching and submitting. */
function isSlotTaken(err: unknown) {
  return err instanceof ClndrError && (err.status === 409 || /no longer available|not available/i.test(err.message));
}

function isAnswered(question: BookingQuestion, value: string | undefined) {
  if (question.question_type === 'checkbox') return value === 'Yes';
  return !!value?.trim();
}

export function BookingWidget({ slug }: { slug: string }) {
  const { data: page, error: pageError } = useBookingPage(slug);
  const { create, data: booking, error: createError, isLoading: submitting } = useCreateBooking();

  const [date, setDate] = useState<Date | undefined>();
  // Called here, not inside SlotPicker, so the list survives while the guest
  // fills in the form and submit() can refetch it after a taken slot.
  const { slots, status: slotsStatus, error: slotsError, refetch: refetchSlots } = useAvailableSlots(slug, date ?? null);
  const [slot, setSlot] = useState<TimeSlot | null>(null);
  const [name, setName] = useState('');
  const [email, setEmail] = useState('');
  const [answers, setAnswers] = useState<Record<string, string>>({});
  const [notice, setNotice] = useState<string | null>(null);

  // No page and no error yet: still loading.
  if (!page) {
    return pageError ? (
      <Alert variant="destructive">
        <AlertTitle>This booking page isn't available</AlertTitle>
        <AlertDescription>{pageError.message}</AlertDescription>
      </Alert>
    ) : (
      <Card>
        <CardHeader className="gap-3">
          <Skeleton className="h-6 w-48" />
          <Skeleton className="h-4 w-32" />
        </CardHeader>
        <CardContent>
          <Skeleton className="h-72 w-full" />
        </CardContent>
      </Card>
    );
  }

  const { bookingPage, userProfile, questions } = page;
  const host = userProfile.full_name ?? userProfile.username;

  if (booking) {
    const start = new Date(booking.start_time);
    return (
      <Card>
        <CardHeader>
          <CardTitle>{booking.status === 'pending' ? 'Request sent' : "You're booked"}</CardTitle>
          <CardDescription>
            {bookingPage.title} with {host}, {dayFormat.format(start)} at {timeFormat.format(start)}.
          </CardDescription>
        </CardHeader>
        <CardContent className="text-sm text-muted-foreground">
          {booking.status === 'pending'
            ? `${host} confirms requests by hand. You'll get an email at ${booking.guest_email} once it's approved.`
            : `A confirmation is on its way to ${booking.guest_email}.`}
          {booking.google_meet_link ? (
            <p className="mt-3">
              <a className="font-medium text-foreground underline underline-offset-4" href={booking.google_meet_link}>
                Join with Google Meet
              </a>
            </p>
          ) : null}
        </CardContent>
      </Card>
    );
  }

  const today = startOfToday();
  const lastDay = bookingPage.max_days_ahead ? addDays(today, bookingPage.max_days_ahead) : undefined;

  async function submit(e: FormEvent) {
    e.preventDefault();
    if (!slot) return;
    const missing = questions.filter((q) => q.is_required && !isAnswered(q, answers[q.id]));
    if (missing.length > 0) {
      setNotice(`Please answer: ${missing.map((q) => q.question_text).join(', ')}`);
      return;
    }
    setNotice(null);
    try {
      await create({
        bookingPageId: bookingPage.id,
        guestName: name,
        guestEmail: email,
        startTime: slot.start,
        endTime: slot.end,
        responses: Object.entries(answers)
          .filter(([, answer]) => answer.trim() !== '')
          .map(([questionId, answer]) => ({ questionId, answer })),
      });
    } catch (err) {
      if (isSlotTaken(err)) {
        setSlot(null);
        refetchSlots();
        setNotice('Someone booked that time a moment ago. Pick another one.');
      }
      // Anything else is on `createError` and shown below the form.
    }
  }

  return (
    <Card>
      <CardHeader>
        <CardTitle className="text-xl">{bookingPage.title}</CardTitle>
        <CardDescription>
          {host} · {bookingPage.duration_minutes} min
        </CardDescription>
        {bookingPage.description ? (
          <div className="mt-2 space-y-2 text-sm text-muted-foreground [&_a]:underline [&_ol]:list-decimal [&_ol]:pl-5 [&_strong]:text-foreground [&_ul]:list-disc [&_ul]:pl-5">
            <Markdown>{bookingPage.description}</Markdown>
          </div>
        ) : null}
      </CardHeader>

      <CardContent className="grid gap-6 md:grid-cols-[auto_1fr]">
        <Calendar
          mode="single"
          selected={date}
          onSelect={(day) => {
            setDate(day);
            setSlot(null);
            setNotice(null);
          }}
          disabled={lastDay ? [{ before: today }, { after: lastDay }] : { before: today }}
          className="rounded-md border"
        />

        <div className="min-w-0">
          {notice ? (
            <Alert className="mb-4">
              <AlertDescription>{notice}</AlertDescription>
            </Alert>
          ) : null}

          {!date ? (
            <p className="text-sm text-muted-foreground">Pick a day to see open times.</p>
          ) : !slot ? (
            <SlotPicker slots={slots} status={slotsStatus} error={slotsError} onPick={setSlot} />
          ) : (
            <form onSubmit={submit} className="grid gap-4">
              <div className="flex items-center justify-between gap-2 rounded-md border px-3 py-2 text-sm">
                <span>
                  {dayFormat.format(new Date(slot.start))}, {timeFormat.format(new Date(slot.start))}
                </span>
                <Button type="button" variant="ghost" size="sm" onClick={() => setSlot(null)}>
                  Change
                </Button>
              </div>

              <div className="grid gap-2">
                <Label htmlFor="guest-name">Name</Label>
                <Input id="guest-name" required autoComplete="name" value={name} onChange={(e) => setName(e.target.value)} />
              </div>
              <div className="grid gap-2">
                <Label htmlFor="guest-email">Email</Label>
                <Input
                  id="guest-email"
                  type="email"
                  required
                  autoComplete="email"
                  value={email}
                  onChange={(e) => setEmail(e.target.value)}
                />
              </div>

              {questions.map((question) => (
                <QuestionField
                  key={question.id}
                  question={question}
                  value={answers[question.id] ?? ''}
                  onChange={(value) => setAnswers((a) => ({ ...a, [question.id]: value }))}
                />
              ))}

              {createError && !isSlotTaken(createError) ? (
                <Alert variant="destructive">
                  <AlertTitle>Couldn't book that</AlertTitle>
                  <AlertDescription>{createError.message}</AlertDescription>
                </Alert>
              ) : null}

              <Button type="submit" disabled={submitting}>
                {submitting ? 'Booking…' : bookingPage.booking_type === 'approval' ? 'Request this time' : 'Book'}
              </Button>
            </form>
          )}
        </div>
      </CardContent>
    </Card>
  );
}

/**
 * Open times for one day, grouped by the guest's own calendar date. The day
 * the API returns is the host's day, so near midnight some slots can fall on
 * the guest's previous or next date.
 */
function SlotPicker({
  slots,
  status,
  error,
  onPick,
}: {
  slots: TimeSlot[];
  status: 'idle' | 'loading' | 'success' | 'error';
  error: Error | null;
  onPick: (slot: TimeSlot) => void;
}) {
  const timeZone = useMemo(() => Intl.DateTimeFormat().resolvedOptions().timeZone, []);

  const days = useMemo(() => {
    const byDay = new Map<string, TimeSlot[]>();
    for (const slot of slots) {
      const day = dayFormat.format(new Date(slot.start));
      byDay.set(day, [...(byDay.get(day) ?? []), slot]);
    }
    return [...byDay];
  }, [slots]);

  if (status === 'error') {
    return (
      <Alert variant="destructive">
        <AlertTitle>Couldn't load times</AlertTitle>
        <AlertDescription>{error?.message}</AlertDescription>
      </Alert>
    );
  }

  if (status !== 'success') {
    return (
      <div className="grid grid-cols-3 gap-2">
        {Array.from({ length: 9 }, (_, i) => (
          <Skeleton key={i} className="h-9" />
        ))}
      </div>
    );
  }

  if (days.length === 0) {
    return <p className="text-sm text-muted-foreground">No open times that day. Try another one.</p>;
  }

  return (
    <div className="grid gap-4">
      {days.map(([day, daySlots]) => (
        <section key={day} className="grid gap-2">
          <h3 className="text-sm font-medium">{day}</h3>
          <div className="grid grid-cols-3 gap-2 sm:grid-cols-4">
            {daySlots.map((slot) => (
              <Button key={slot.start} variant="outline" onClick={() => onPick(slot)}>
                {timeFormat.format(new Date(slot.start))}
              </Button>
            ))}
          </div>
        </section>
      ))}
      <p className="text-xs text-muted-foreground">Times are in {timeZone.replace(/_/g, ' ')}.</p>
    </div>
  );
}
```

```tsx title="components/booking/question-field.tsx"
'use client';

import type { BookingQuestion } from '@clndr-pro/react';
import { Checkbox } from '@/components/ui/checkbox';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { Textarea } from '@/components/ui/textarea';

interface QuestionFieldProps {
  question: BookingQuestion;
  value: string;
  onChange: (value: string) => void;
}

/** One of the host's questions, rendered with the right shadcn input for its type. */
export function QuestionField({ question, value, onChange }: QuestionFieldProps) {
  const id = `question-${question.id}`;
  const label = (
    <Label htmlFor={id}>
      {question.question_text}
      {question.is_required ? <span className="text-muted-foreground"> *</span> : null}
    </Label>
  );

  switch (question.question_type) {
    case 'textarea':
      return (
        <div className="grid gap-2">
          {label}
          <Textarea id={id} value={value} onChange={(e) => onChange(e.target.value)} rows={4} />
        </div>
      );

    case 'select':
      return (
        <div className="grid gap-2">
          {label}
          <Select value={value} onValueChange={onChange}>
            <SelectTrigger id={id} className="w-full">
              <SelectValue placeholder="Choose one" />
            </SelectTrigger>
            <SelectContent>
              {(question.options ?? []).map((option) => (
                <SelectItem key={option} value={option}>
                  {option}
                </SelectItem>
              ))}
            </SelectContent>
          </Select>
        </div>
      );

    case 'checkbox':
      return (
        <div className="flex items-center gap-3">
          <Checkbox
            id={id}
            checked={value === 'Yes'}
            onCheckedChange={(checked) => onChange(checked === true ? 'Yes' : 'No')}
          />
          {label}
        </div>
      );

    default:
      return (
        <div className="grid gap-2">
          {label}
          <Input
            id={id}
            type={question.question_type === 'email' ? 'email' : question.question_type === 'phone' ? 'tel' : 'text'}
            autoComplete={question.question_type === 'phone' ? 'tel' : undefined}
            value={value}
            onChange={(e) => onChange(e.target.value)}
          />
        </div>
      );
  }
}
```

## Where to take it

* **Your copy.** Every string is in the component: the button says "Request this time" on approval pages and "Book" otherwise.
* **Prefill.** If guests are signed in to your site, initialise `name` and `email` from your user.
* **Several pages.** List them with `useClndr().bookingPages.list()` (filter on `is_active` and `visibility`) and pass the chosen slug in.
* **Keep guest data on your server.** Swap `useCreateBooking` for a [server action](/tutorials/server-actions) that books with your secret key; the rest of the widget stays as it is.
