@doany-ai/calendar
v0.1.0
Published
Booking the business's services, on a Doany site: calendar hooks and default components (service list, date and time picker, booking flow, class sessions, booking status)
Readme
@doany-ai/calendar
Booking the business's services on a Doany site: the services a visitor can book, a date and time picker, the booking flow, class sessions with seats, and the page a booking's link opens. Hooks read and book through the SDK client the site gives <DoanyProvider>; the components are defaults the site restyles through the theme variables, adjusts through props (every word through labels), or replaces while keeping the hooks.
The business sets its services up in its Catalog (type, options with duration and price, how clients book, cancellation rules, questions). The site only reads them and books. Nothing here creates services.
import { ServiceList, BookingFlow, SessionList, BookingStatus } from "@doany-ai/calendar";
import { Link, useParams } from "react-router-dom";
// /book — what can be booked; each card leads to its booking page
<ServiceList hrefFor={(s) => `/book/${s.id}`} renderLink={({ href, className, children }) => <Link to={href} className={className}>{children}</Link>} />
// /book/:serviceSettingId — pick a time, say who, done
const { serviceSettingId } = useParams();
<BookingFlow serviceSettingId={serviceSettingId} />
// /classes — upcoming sessions with seats; Register opens the form in a dialog
<SessionList />
// /bookings/:registrationId — the page a booking's link opens; change the time or cancel within the policy
const { registrationId } = useParams();
<BookingStatus registrationId={registrationId} />Keep AuthProvider in src/App.jsx: BookingFlow and SessionList read the signed-in account to prefill the form. List the pages in doany/features.jsonc under booking.
Records
What the hooks return, as the platform gives them (the fields this package reads; src/types.ts has them all).
- BookableService (
services.list/useBookableServices):id(whatslotsandbooktake),item_name,booking_mode(appointment|class),online_booking_enabled(false: the business takes this service's bookings itself; the site shows no booking entry),variants[](id,name,duration_minutes,price_amount,currency,is_default),payment_requirement(none|full_online),location_type(business_address|customer_address|online) +address,phone_required,booking_fields[](the questions:key,type—text,textarea,email,phone,url,number,date,select,multiselect,checkbox—label,required,options),allow_customer_cancel,cancellation_cutoff_minutes,allow_customer_reschedule,reschedule_cutoff_minutes,cancellation_policy_text,booking_horizon_days,seat_capacity. - Slot (
useSlots):start_at,end_at,slot_token(good for five minutes; holds nothing),token_expires_at. - Booking (
useSessions): an appointment or a class session:id,version,kind,status,item_name,variant_name,start_at,end_at,timezone,price_amount,currency,payment_requirement,location_type,address,booking_fields_snapshot,allowed_actions(create_registrationwhile seats are open; on the registration's own read of an appointment,reschedulewhile its visitor may move it); a session addscapacity,remaining_seats,allowed_variants[](the tickets). - Registration (
useRegistration, in every booking result): one person's place:id,booking_id,version,status(pending_payment|confirmed|canceled|expired),order_id(when paid online),hold_expires_at,allowed_actions(cancelwhen the policy allows). - BookingResult (
useBook,useRegister):{ booking, registration, access_token, order_access_token }. The SDK keeps the tokens for the browser session, so the calls that follow need none.
Times are ISO instants; the components show them in the visitor's timezone (timezone prop, default the browser's). Prices are in the currency's smallest unit (12000 = $120.00).
Components
ServiceList
The bookable services as cards (name, price, length, a Book button), in the catalog's name order (the catalog is read with sort: "name"), with "Load more" while there are more.
| prop | |
| --- | --- |
| params | { booking_mode?: "appointment" \| "class", limit? }. A menu is the whole list: it reads pages of at least 50 whatever limit says, so a business with up to 50 services sees no "Load more" |
| hrefFor | (service) => string: each card's link |
| renderLink | ({ href, className, children }) => node: render links with the site's router |
| onSelect | (service) => void: instead of a link |
| renderItem | (service) => node: replaces the card |
| empty | node shown when nothing can be booked |
| footer | node inside each card, under its text |
| variant | "grid" (default): tiles. "menu": the services grouped by their catalog category, with a category bar and a search box, each a row — a service menu |
| showDetails | read each service's description, category and image from the catalog (doany.catalog.list({ type: "service" })); default true. A catalog that cannot be read leaves the cards without them |
| locale, labels, className | |
ServiceCard
One service: service, href / onSelect, renderLink, footer, locale, labels, className.
BookingFlow
Book an appointment: the variant (when the service has several), a day and a time, who it is for, then the confirmation.
| prop | |
| --- | --- |
| serviceSettingId | required: the service (BookableService.id) |
| catalogItemVariantId | skip the variant choice |
| timezone | the timezone times are shown in; default the browser's |
| address | for a service at the customer's address |
| layout | "split" (default): the service's summary (image, description, length, price, place, chosen time, timezone) in a column beside the steps. "stacked": one column, summary on top |
| order | "when-first" (default): day and time, then the details. "who-first": the details first, then the time; the chosen time books at once |
| image, showDetails | the summary's image (the catalog item's by default) and whether to read the catalog's description and image at all (default true) |
| renderPayment | (result) => node: for a service paid online, what to render while the payment is due — the site's checkout for result.registration.order_id (@doany-ai/commerce's OrderCheckout) |
| onBooked | (result) => void |
| children | (result) => node: under the confirmation, links onward |
| locale, labels, className | |
A slot taken by someone else meanwhile (SLOT_UNAVAILABLE) sends the visitor back to pick another; the times are re-read.
On a preview address, the flow shows a notice before the booking action. Visitors can choose a time, fill in their details and run local validation; the form keeps their input and never creates a booking, calls onBooked, renders payment, or shows a booking confirmation. This also applies to class registration (SessionList, SessionCalendar, RegisterDialog), prepaid services and package funding. Use the published website for a real booking. The backend determines preview status through calendar.context(); if that read fails, viewing and filling remain available while the final action waits for Try again. A backend INVALID_STATE with details.reason="preview_only" keeps the input and shows the same notice.
DateTimePicker
The calendar and the day's times, with the timezone under it. For a site that draws its own flow around it. Today is picked to begin with and its times shown; a day without times hands over to the next, for two weeks at most and never past the horizon, until the visitor picks a day.
locale formats the calendar's month, weekday headings and day numbers as well as the chosen day and times. Weekday headings keep the locale's short names when they fit a four-character abbreviation; longer names use its narrow form when the resulting seven headings stay distinct, with the full weekday name kept for screen readers. Dates use the Gregorian calendar to match the picker's grid and month navigation, including locales that default to another calendar. Pass labels.previousMonth / labels.nextMonth for its navigation buttons' accessible names. BookingFlow passes these through; SessionCalendar uses the same calendar localization.
| prop | |
| --- | --- |
| service, variant | required |
| timezone, onTimezoneChange | |
| value, onChange | the chosen Slot |
| onConfirm, confirmLabel | the button that appears beside the chosen slot (selectTime label by default) |
| reschedule | { bookingId, accessToken? }: the times an existing appointment can move to, instead of a new booking's |
| layout | "wide" (default): the times beside the calendar. "narrow": under it |
| locale, labels, className | |
AttendeeForm
Name, email, phone and the service's questions. fields (the service's booking_fields), phoneRequired, defaultValues, onSubmit(attendee), submitLabel, busy, disabled (disable the final action while preserving the inputs), error, secondaryAction, locale, labels, className. The flow adds the timezone before booking. A field that may be left blank is marked Optional at the end of its label row; required fields carry nothing. The form checks what the platform will check and says so under the field once it was left or the form submitted, in place of the browser's own bubbles: a name that is not blank, an email or a phone (the phone when phoneRequired), the email an address, every required question answered, and an email, phone, url, number or date question given an answer of its kind: an email address; a phone typed like the contact phone and sent in E.164; a web address (example.com or a full URL); a number, sent as a number; a date, sent as YYYY-MM-DD. A text question is not held to any of these. The phone is typed as a local number behind a country, shown as its flag and calling code: the chip opens a searchable list of every country (the United States first, then by name; a name, a code or a calling code finds one), the United States to start with; a number typed with its own + or 00 moves the flag to that country and keeps the rest; the trunk prefix is dropped where the country dials one — and is sent in E.164. These fields are @doany-ai/app-core/form's, shared with the inquiry form. A site changes those messages through labels (nameRequired, contactRequired, emailFormat, phoneRequired, phoneFormat, answerRequired, choiceRequired, urlFormat, numberFormat, dateFormat, optional, country, searchCountry, noCountry); it does not wrap the form to check them again.
SessionCalendar
A month of class sessions: the days with sessions are marked on the calendar; the chosen day's sessions, with their seats, are listed beside it, each with Register (the same dialog as SessionList).
| prop | |
| --- | --- |
| serviceSettingId | only this class |
| timezone, renderPayment, onRegistered, locale, labels, className | as in SessionList |
PackageList, PackageCard
The packages of uses a visitor can buy (doany.catalog.list({ type: "plan", purchasable: true })), as pricing cards: name, price, description, and the way to buy — which the site provides, because buying is @doany-ai/commerce's job:
<PackageList
highlight={(item) => item.name === "10-Class Pack"}
renderBuy={(item, variant) => <BuyButton items={[{ catalog_item_variant_id: variant.id, quantity: 1 }]} successPath={(id) => `/orders/${id}`} />}
/>| prop | |
| --- | --- |
| renderBuy | (item, variant) => node: the buying control. Left out, a plain Buy button calls onBuy |
| onBuy | (item, variant) => void |
| highlight | (item) => boolean: the card to mark out |
| footerFor | (item) => node: under each description |
| category, limit, renderItem, empty, locale, labels, className | |
SessionList
Upcoming class sessions grouped by day, each with its seats and a Register button that opens the form in a dialog.
| prop | |
| --- | --- |
| params | { service_setting_id?, from?, to?, limit? } |
| timezone | default the browser's |
| renderPayment | as in BookingFlow |
| onRegistered | (result) => void |
| empty | node when there are no sessions |
| locale, labels, className | |
BookingStatus
The page a booking's link opens (/bookings/<registration id>#t=<token>): status, what, when, where, and Change time and Cancel within the policy. Change time shows while the platform lists reschedule on the booking: the picker over the booking's own free times, then the booking moves and the page reads it again.
| prop | |
| --- | --- |
| registrationId | required |
| accessToken | default: the link's #t=, else this browser session's |
| timezone | default the browser's |
| allowCancel | default true |
| allowReschedule | default true |
| children | (status, detail) => node |
| locale, labels, className | |
Hooks
| hook | returns |
| useServiceDetails() | query of the catalog's service items, plus byItem: a Map from catalog item id to { description, category, image_url, … } |
| usePackages({ limit?, category? }) | query of the packages on sale, plus packages |
| --- | --- |
| useCalendarContext() | query of { preview }: whether the backend identifies this website address as a preview; custom booking UIs should show the notice and stop before mutation when true |
| useBookableServices({ booking_mode?, limit? }) | react-query infinite query + services (every page so far) + timezone (the business's) |
| useBookableService(id) | query of one BookableService |
| useSlots(serviceSettingId, { catalogItemVariantId, date: "YYYY-MM-DD", timezone }) | infinite query + slots |
| useBook() | mutation: mutate({ service_setting_id, catalog_item_variant_id, start_at, slot_token, attendee: { name, timezone, email?, phone?, answers? }, address? }) → BookingResult |
| useSessions({ service_setting_id?, from?, to?, limit? }) | infinite query + sessions + timezone |
| useSession(bookingId) | query of one Booking |
| useRegister() | mutation: mutate({ bookingId, attendee, catalog_item_variant_id?, expected: { booking_version, payment_requirement, item_version?, variant_version? } }) → BookingResult |
| useRegistration(registrationId, { accessToken? }) | query of { registration, booking }; polls while pending_payment |
| useCancelRegistration() | mutation: mutate({ registrationId, registration_version, booking_version, reason?, accessToken? }) |
| useRescheduleSlots(bookingId, { date: "YYYY-MM-DD", timezone, accessToken? }) | infinite query + slots: the times this appointment can move to |
| useReschedule() | mutation: mutate({ bookingId, registrationId, registration_version, booking_version, start_at, slot_token, reason?, accessToken? }) → the moved Booking; reads the registration again |
| readBookingLinkToken(registrationId) | the token in the page's link, for a page that calls the SDK itself |
Helpers: formatDuration(minutes), formatTime(iso, timezone, locale), formatDay, formatDateTime, formatTimezone, localDate(date, timezone), browserTimezone(), servicePriceLabel(service, locale), variantPriceLabel(variant, locale), defaultVariant(service).
Labels
Every word the components show is in labels (Partial<CalendarLabels>; the rest keep their defaults). A site in another language passes its words once to the outer component; it passes them on.
free, fromPrice(price), duration(minutes), book, askForQuote, noServices, loadError, tryAgain, loadMore, loadingMore, notAvailable, selectDateTime, timezone, noSlots, previousMonth, nextMonth, options, yourDetails, name, nameRequired, email, phone, phoneFormat, optional, contactRequired, emailFormat, phoneRequired, country, answerRequired, choiceRequired, urlFormat, confirmBooking, booking, back, slotTaken, bookingFailed, previewOnly, previewChecked, rateLimited, detailsRejected, bookedTitle, bookedBody(when), payToConfirmTitle, payToConfirmBody(until), payNow, seatsLeft(n), full, register, registered, noSessions, spotsOf(left, total), onlyLeft(n), thisWeek(n), previousWeek, nextWeek, noSessionsThisWeek, selectTime, searchServices, allServices, noMatches, classSeats(n), buy, noPackages, pickDay, noSessionsOnDay, statusConfirmed, statusPendingPayment, statusCanceled, statusExpired, cancelBooking, cancelConfirm, keepBooking, canceling, cancelFailed, cancelProcessing, changeTime, moveHere, keepTime, rescheduling, rescheduled, rescheduleFailed, bookingNotFound, bookingCheckFailed, cancellationPolicy, location, online, atYourAddress, joinMeeting, yourAddress, addressRequired.
Failure wording: the site's label (loadError, bookingFailed, cancelFailed, rescheduleFailed) when set, else the platform's own message (with or without an error code), else the default. Known failures keep their specific labels (slotTaken, rateLimited, detailsRejected for VALIDATION_ERROR); an expired slot quote uses an explicit slotTaken, then bookingFailed, then the platform's message. Translating the message does not change recovery: expired quotes still reload the times, and changed service details still reload the service.
Customizing
Stop at the first level that does what was asked:
- Theme: the variables in the site's
src/index.css(--primary,--radius,--font-heading, …) style every component here. - Props:
className,labels,renderLink,renderItem,empty,renderPayment,children. - Hooks: build the part yourself on
useBookableServices,useSlots,useBook,useSessions,useRegister,useRegistration,useCancelRegistration,useRescheduleSlots,useReschedule.
The package ships no CSS: the site's Tailwind build scans node_modules/@doany-ai/calendar/dist (the general and service scaffolds do).
Develop
npm install
npm run typecheck # includes tests/sdk-contract.types.ts: the real SDK client satisfies CalendarClient
npm test
npm run build