Public Booking Form
A basic public booking form component.
A basic public booking form component.
Live Demo
Installation
Follow the instructions to prepare your app. This component requires a few additional dependencies:
lucide-react(icons)@base-ui/react(radio group)react-day-picker(calendar)
Run the following command to install the dependencies:
npm install lucide-react @base-ui/react react-day-pickerAdd the base styles for React Day Picker to your app styles:
@import "react-day-picker/style.css" layer(components);Since this component only uses public API endpoints, the fetchAccessToken prop is not required on SavvyCalProvider.
Create a new file called public-booking-form.tsx in your app's components directory:
import { Radio } from "@base-ui/react/radio";import { RadioGroup } from "@base-ui/react/radio-group";import { getEarliestPublicServiceSlot, type Slot,} from "@savvycal/appointments-core";import { useCreatePublicAppointment, usePublicServiceSlots,} from "@savvycal/appointments-react-query";import { useSavvyCalFetchClient } from "@savvycal/appointments-react-query";import { endOfMonth, formatDate, fromUnixTime, getBrowserTimeZone, getTimeZoneDisplayName, isSameDay, startOfMonth, toISODate,} from "@savvycal/appointments-utils";import clsx from "clsx";import { ChevronDownIcon, ChevronLeftIcon, ChevronRightIcon, GlobeIcon,} from "lucide-react";import React, { useEffect, useMemo, useState } from "react";import { DayPicker, getDefaultClassNames } from "react-day-picker";interface ClientData { /** The client's email address. */ email: string; /** The client's first name. */ first_name: string; /** The client's last name. */ last_name: string; /** The client's locale (e.g., "en"). Defaults to "en". */ locale?: string; /** The client's phone number. */ phone?: string; /** A unique reference ID for the client in your system. Defaults to the email. */ reference_id?: string;}interface PublicBookingFormProps { /** Data about the client booking the appointment. */ clientData: ClientData; /** The ID of the service to book. */ serviceId: string;}const PublicBookingForm = ({ clientData, serviceId,}: PublicBookingFormProps) => { const [timeZone] = useState<string>(getBrowserTimeZone()); const client = useSavvyCalFetchClient(); const [month, setMonth] = useState<Date>(); const [selectedDay, setSelectedDay] = useState<Date>(); const [selectedSlot, setSelectedSlot] = useState<Slot>(); const [initError, setInitError] = useState<string>(); useEffect(() => { async function jumpToEarliestDate() { try { const { data } = await getEarliestPublicServiceSlot(client, { service_id: serviceId, }); setInitError(undefined); if (data?.data) { setMonth(startOfMonth(data.data.start_at)); setSelectedDay(fromUnixTime(data.data.start_at_ts)); } else { // No available slots, default to current month setMonth(startOfMonth(new Date())); } } catch { setInitError("Failed to load available times. Please try again."); setMonth(startOfMonth(new Date())); } } if (!month && !selectedDay) { jumpToEarliestDate(); } }, [client, month, selectedDay, serviceId]); const visibleRange = useMemo(() => { if (!month) return { from: "", until: "" }; return { from: toISODate(month, timeZone), until: toISODate(endOfMonth(month), timeZone), }; }, [month, timeZone]); const { data, error: slotsError, refetch, } = usePublicServiceSlots( serviceId, { ...visibleRange, time_zone: timeZone }, { enabled: !!month, }, ); const [submitError, setSubmitError] = useState<string>(); const { isPending: isCreating, mutate: createPublicAppointment } = useCreatePublicAppointment({ onError: () => { setSubmitError("Failed to book appointment. Please try again."); }, onSuccess: () => { setSelectedSlot(undefined); setSubmitError(undefined); refetch(); }, }); const dateHasSlots = (date: Date) => { if (!data) return false; return data.data.some((slot) => { const slotDate = fromUnixTime(slot.start_at_ts); return isSameDay(slotDate, date, timeZone); }); }; const slotsOnSelectedDay = useMemo(() => { if (!data || !selectedDay) return []; return data.data.filter((slot) => { const slotDate = fromUnixTime(slot.start_at_ts); return isSameDay(slotDate, selectedDay, timeZone); }); }, [data, selectedDay, timeZone]); const handleSubmit = (e: React.FormEvent) => { e.preventDefault(); if (!selectedSlot) return; createPublicAppointment({ body: { client_data: { email: clientData.email, first_name: clientData.first_name, last_name: clientData.last_name, locale: clientData.locale ?? "en", phone: clientData.phone ?? "", reference_id: clientData.reference_id ?? clientData.email, time_zone: timeZone, }, end_at: selectedSlot.end_at, service_id: serviceId, start_at: selectedSlot.start_at, time_zone: timeZone, }, }); }; const error = initError || (slotsError ? "Failed to load available times." : undefined); return ( <div className="@container [--nav-height:--spacing(11)]"> {error && ( <div className="mb-6 rounded-md bg-red-50 px-4 py-3 text-sm text-red-700"> {error} </div> )} <div className="flex flex-col items-center gap-8 @xl:flex-row @xl:items-start"> <div> <Calendar disabled={(date) => !dateHasSlots(date)} month={month} onMonthChange={(month) => { setMonth(month); setSelectedDay(undefined); setSelectedSlot(undefined); }} onSelect={(day) => { setSelectedDay(day); if ( day && selectedSlot && !isSameDay(new Date(selectedSlot.start_at), day, timeZone) ) { setSelectedSlot(undefined); } }} selectedDay={selectedDay} /> <div className="mt-6 flex items-center justify-center gap-2 text-sm"> <GlobeIcon className="size-4 text-zinc-500" /> <span className="text-zinc-500"> {getTimeZoneDisplayName(timeZone)} </span> </div> </div> {selectedDay && slotsOnSelectedDay && ( <div className="w-full max-w-sm grow @xl:w-auto @xl:max-w-none"> <form onSubmit={handleSubmit}> <div className="flex h-(--nav-height) items-center justify-center"> <h2 className="text-center font-semibold"> {formatDate(selectedDay, { day: "numeric", month: "long", weekday: "long", })} </h2> </div> <RadioGroup className="mt-3 grid gap-2 @2xl:grid-cols-2 @3xl:grid-cols-3" onValueChange={(value) => setSelectedSlot( slotsOnSelectedDay.find( (slot) => slot.start_at === String(value), ), ) } value={selectedSlot?.start_at} > {slotsOnSelectedDay.map((slot) => { const slotStartAt = fromUnixTime(slot.start_at_ts); return ( <label className={clsx( "flex cursor-pointer justify-center rounded-md px-6 py-2.5 text-base text-zinc-900", "ring-1 ring-zinc-900/20 ring-inset hover:bg-zinc-900/5", "has-data-checked:ring-2 has-data-checked:ring-zinc-900", "has-focus-visible:ring-3 has-focus-visible:ring-zinc-900/25", )} key={slot.start_at} > <Radio.Root className="sr-only" value={slot.start_at}> <Radio.Indicator /> </Radio.Root> {formatDate(slotStartAt, { timeStyle: "short" })} </label> ); })} </RadioGroup> <div className="mt-6"> <button className={clsx( "w-full cursor-pointer rounded-md bg-zinc-900 px-6 py-3 text-white", "hover:not-disabled:bg-zinc-800 active:not-disabled:brightness-90", "focus:outline-none focus-visible:ring-3 focus-visible:ring-zinc-900/25", "disabled:cursor-not-allowed disabled:opacity-50", )} disabled={!selectedSlot || isCreating} type="submit" > {isCreating ? "Submitting..." : "Book appointment"} </button> {submitError && ( <p className="mt-2 text-center text-sm text-red-600"> {submitError} </p> )} </div> </form> </div> )} </div> </div> );};const Calendar = ({ disabled, month, onMonthChange, onSelect, selectedDay,}: { disabled: (date: Date) => boolean; month: Date | undefined; onMonthChange: (month: Date) => void; onSelect: (day: Date | undefined) => void; selectedDay: Date | undefined;}) => { const defaultClassNames = getDefaultClassNames(); return ( <DayPicker animate classNames={{ button_next: clsx( defaultClassNames.button_next, "rounded-md hover:bg-zinc-100 active:bg-zinc-200", "size-(--rdp-day_button-width)", ), button_previous: clsx( defaultClassNames.button_previous, "rounded-md hover:bg-zinc-100 active:bg-zinc-200", "size-(--rdp-day_button-width)", ), caption_label: clsx( defaultClassNames.caption_label, "text-base font-semibold text-zinc-900", ), chevron: clsx("text-zinc-900"), day: clsx(defaultClassNames.day, "group"), day_button: clsx( defaultClassNames.day_button, "rounded-md text-zinc-900", "group-[:not([data-disabled])]:bg-zinc-200 group-[:not([data-disabled])]:font-medium", "group-[:not([data-selected])]:text-zinc-800", "group-[[data-selected]]:bg-zinc-900 group-[[data-selected]]:text-white", ), disabled: clsx( defaultClassNames.disabled, "text-zinc-600 line-through", ), month_caption: clsx( defaultClassNames.month_caption, "flex items-center justify-center", ), month_grid: clsx(defaultClassNames.month_grid), nav: clsx(defaultClassNames.nav, "w-full flex justify-between"), root: clsx( defaultClassNames.root, "[--rdp-nav-height:var(--nav-height)]", ), selected: "", today: clsx(defaultClassNames.today, "text-zinc-900"), week: "grid grid-cols-7 gap-0.5", weekday: clsx(defaultClassNames.weekday, "py-3 text-zinc-900"), weekdays: "grid grid-cols-7 gap-0.5", weeks: "flex flex-col gap-0.5", }} components={{ Chevron: ({ className, orientation, ...props }) => { if (orientation === "left") { return ( <ChevronLeftIcon className={clsx("size-5", className)} {...props} /> ); } if (orientation === "right") { return ( <ChevronRightIcon className={clsx("size-5", className)} {...props} /> ); } return ( <ChevronDownIcon className={clsx("size-5", className)} {...props} /> ); }, }} disabled={disabled} formatters={{ formatWeekdayName: (weekday) => weekday.toLocaleString("default", { weekday: "short" }), }} mode="single" month={month} onMonthChange={onMonthChange} onSelect={onSelect} selected={selectedDay} /> );};export { PublicBookingForm };