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 widget
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-markdownLoad the page
useBookingPagereturns 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 whenpageErroris 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.Pick a day
shadcn's
Calendar(react-day-picker) hands you aDateat local midnight, which is exactly whatuseAvailableSlotswants. 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" />datelives 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
useAvailableSlotsis 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 itsrefetch()after a failed booking. A smallSlotPickercomponent 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.Ask the host's questions
Each question has a
question_type.QuestionFieldmaps them to shadcn inputs:textareatoTextarea,selecttoSelectwith the question'soptions,checkboxtoCheckboxwith the answer"Yes"or"No",phoneto atelinput, the rest toInput.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 unknownquestionIdmakes the API drop every answer for that booking, so never send ids from another page.Book, and recover from a taken slot
createfromuseCreateBookingposts the booking. Pass the slot'sstartandendunchanged. 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, callsrefetchSlots(), 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.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.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;
BookingWidgetis the client boundary.
The finished files
'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>
);
}'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
nameandemailfrom your user. - Several pages. List them with
useClndr().bookingPages.list()(filter onis_activeandvisibility) and pass the chosen slug in. - Keep guest data on your server. Swap
useCreateBookingfor a server action that books with your secret key; the rest of the widget stays as it is.