shadcn booking widget

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 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.

Build a custom shadcn booking widgetPaste into Claude Code

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 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:

npx shadcn@latest add calendar button card input label textarea skeleton alert select checkbox
npm install react-markdown
  1. 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.

    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.

  2. 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:

    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.

  3. 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:

    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.

    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.

  4. 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:

    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.

  5. 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:

    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.

  6. 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.

    <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.

  7. Put it on a page

    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

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>
  );
}
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 that books with your secret key; the rest of the widget stays as it is.