← Files WixARCHIVED FILE
skills/wix-headless-templates/bookings/rest/booking.ts
11.9 KB · Oct 8, 2026 · 12:02 UTC
// Availability, the calendar reads (sessions, offered days, course seats), add-ons, settings, the
// booking form, and the createBooking → Cart V2 → checkout-or-place sequence over REST — the twin of
// app/wix/bookings/booking.ts. Same exports, same DTOs; every body comes from booking-core (the SAME
// file the SDK transport uses, deployed flat next to this one), so this file is only the transport:
// literal paths, one fetch per step. Failures are loud where a visitor acts (booking): a taken slot,
// a refused cart, a checkout that won't start all throw — surface the message. Reads that only
// inform (settings, sessions, offered days, add-ons) fall back to an empty default.
// All calls run with the visitor token: the booking and its cart are the token's (see ./client).
// docs: https://dev.wix.com/docs/api-reference/business-solutions/bookings/time-slots/time-slots-v2/list-availability-time-slots.md
// docs: https://dev.wix.com/docs/api-reference/business-solutions/bookings/time-slots/time-slots-v2/list-event-time-slots.md
// docs: https://dev.wix.com/docs/api-reference/business-management/calendar/events-v3/query-events.md
// docs: https://dev.wix.com/docs/api-reference/business-solutions/bookings/services/services-v2/list-add-on-groups-by-service-id.md
// docs: https://dev.wix.com/docs/api-reference/business-solutions/bookings/bookings/bookings-writer-v2/create-booking.md
// docs: https://dev.wix.com/docs/api-reference/business-solutions/e-commerce/purchase-flow/cart-v2/create-cart.md
// docs: https://dev.wix.com/docs/api-reference/business-management/headless/redirects/create-redirect-session.md
// docs: https://dev.wix.com/docs/api-reference/crm/forms/form-schemas/get-form-summary.md
import { wixRequest } from "./client.js";
import { DEFAULT_BOOKINGS_SETTINGS, toBookingsSettings } from "./services-core.js";
import {
FALLBACK_FIELDS,
addOnGroupsRequest,
appointmentSlotsRequest,
bookingCartRequest,
bookingIdOf,
bookingOptions,
bookingRequest,
cartIdOf,
checkoutRedirectRequest,
checkoutRequired,
classSlotsRequest,
clampNextAvailable,
confirmedResult,
contactOf,
courseAvailabilityOf,
defaultTimeZone,
masterEventsRequest,
nextAvailableRequest,
nextEventsCursor,
nextSlotsCursor,
offeredDaysByScheduleId,
resolveDisplayTimeZone,
sessionsRequest,
toAddOnGroups,
toFormFields,
toLocalDateString,
toSession,
toSlotsPage,
type BookingOptions,
type NextAvailableOptions,
type Raw,
type SlotsWindow,
} from "./booking-core.js";
import type { AddOnGroup, BookingFormField, BookingResult, BookingsSettings, CourseAvailability, ServiceDetail, Session, Slot, SlotsPage, Weekday } from "./types.js";
export { resolveDisplayTimeZone, toLocalDateString };
// The two time-slot endpoints. APPOINTMENT pages on `cursorPagingMetadata`, CLASS on `pagingMetadata`.
// POST /service-availability/v2/time-slots { serviceId, fromLocalDate, toLocalDate, timeZone?, bookable, cursorPaging, includeResourceTypeIds, resourceTypes? }
// POST /service-availability/v2/time-slots/event { serviceIds, fromLocalDate, toLocalDate, timeZone?, includeNonBookable, cursorPaging, eventFilter? }
const listSlots = (type: string, body: Raw): Promise<Raw> =>
wixRequest<Raw>(type === "CLASS" ? "/service-availability/v2/time-slots/event" : "/service-availability/v2/time-slots", { body });
/**
* Every slot for a service in [start of `from`'s day, +days) — APPOINTMENT and CLASS use different
* endpoints; this branches and follows the cursor until the week is complete. Same-time slots across
* staff are merged; full class sessions come back with `bookable: false`. `timeZone` undefined → the
* business zone (the response says which zone the local times are in). COURSE has no slots ([]).
*/
export async function fetchSlots(service: Pick<ServiceDetail, "id" | "type">, window: SlotsWindow = {}): Promise<SlotsPage> {
if (service.type === "COURSE") return { slots: [], timeZone: window.timeZone ?? null };
const raws: Raw[] = [];
let timeZone: string | null = null;
let cursor: string | undefined;
do {
const body = service.type === "CLASS" ? classSlotsRequest(service.id, { ...window, cursor }) : appointmentSlotsRequest(service.id, { ...window, cursor });
const res = await listSlots(service.type, body);
raws.push(...((res?.timeSlots ?? []) as Raw[]));
timeZone ??= res?.timeZone ?? null;
cursor = nextSlotsCursor(res) ?? undefined;
} while (cursor);
return toSlotsPage(raws, timeZone);
}
/**
* The next few bookable times (default 3, clamped 1..6) scanning from today to the end of the month
* six months out — what an empty week points at. Not meaningful for VARIED pricing, add-ons, or a
* course (`showsNextAvailability`); those return []. Same two endpoints as fetchSlots.
*/
export async function fetchNextAvailableSlots(service: Pick<ServiceDetail, "id" | "type">, options: NextAvailableOptions = {}): Promise<SlotsPage> {
if (service.type === "COURSE") return { slots: [], timeZone: null };
const res = await listSlots(service.type, nextAvailableRequest(service, options));
const page = toSlotsPage((res?.timeSlots ?? []) as Raw[], res?.timeZone);
return { ...page, slots: page.slots.slice(0, clampNextAvailable(options.limit)) };
}
/**
* Which zone the site shows times in and whether visitors may switch. Never rejects: Wix's default
* (business zone) on failure. The path is the one the @wix/bookings SDK resolves for
* `bookingsSettings.getBookingsSettings()` (no public reference page yet).
* GET /_api/bookings-settings/v2/settings → { bookingsSettings: { displayTimeZone: { basedOn, customerCanChange } } }
*/
export async function fetchBookingsSettings(): Promise<BookingsSettings> {
try {
return toBookingsSettings(await wixRequest<Raw>("/_api/bookings-settings/v2/settings", { method: "GET" }));
} catch {
return DEFAULT_BOOKINGS_SETTINGS;
}
}
/**
* The service's add-on groups with their add-ons (prices formatted). Non-fatal (empty on failure).
* POST /bookings/v2/services/add-on-groups/list-add-on-groups-by-service-id { serviceId } → { addOnGroupsDetails }
*/
export async function fetchAddOnGroups(serviceId: string): Promise<AddOnGroup[]> {
try {
return toAddOnGroups(await wixRequest<Raw>("/bookings/v2/services/add-on-groups/list-add-on-groups-by-service-id", { body: addOnGroupsRequest(serviceId) }));
} catch {
return [];
}
}
// The Calendar Events query: sessions (INSTANCE events of one schedule) and recurring MASTER events.
// POST /calendar/v3/events/query { query: { filter, cursorPaging }, fromLocalDate, timeZone?, recurrenceType? } → { events, pagingMetadata }
const queryEvents = (body: Raw): Promise<Raw> => wixRequest<Raw>("/calendar/v3/events/query", { body });
/**
* Upcoming sessions of a class or course schedule, oldest first, 7 per page; `nextCursor` fetches the
* next page. Informational (booking goes through a Slot or the course itself). Non-fatal.
*/
export async function fetchSessions(scheduleId: string, options: { limit?: number; cursor?: string; timeZone?: string } = {}): Promise<{ sessions: Session[]; nextCursor: string | null }> {
try {
const res = await queryEvents(sessionsRequest(scheduleId, options));
return { sessions: ((res?.events ?? []) as Raw[]).map(toSession), nextCursor: nextEventsCursor(res) };
} catch {
return { sessions: [], nextCursor: null };
}
}
async function queryMasterEvents(scheduleIds: string[], timeZone?: string): Promise<Raw[]> {
if (!scheduleIds.length) return [];
try {
return ((await queryEvents(masterEventsRequest(scheduleIds, timeZone)))?.events ?? []) as Raw[];
} catch {
return [];
}
}
/** The weekdays each class/course schedule meets on — ONE batched call for a whole listing page. Non-fatal ({}). */
export async function fetchOfferedDays(scheduleIds: string[], timeZone?: string): Promise<Record<string, Weekday[]>> {
return offeredDaysByScheduleId(await queryMasterEvents(scheduleIds, timeZone));
}
/** A COURSE's seats (total, left, full) from its recurring sessions; null once ended or without sessions. Non-fatal. */
export async function fetchCourseAvailability(service: Pick<ServiceDetail, "scheduleId" | "course">, timeZone?: string): Promise<CourseAvailability | null> {
if (!service.scheduleId) return null;
return courseAvailabilityOf(await queryMasterEvents([service.scheduleId], timeZone), service.course?.ended ?? true);
}
/**
* The service's booking-form fields, flat and render-ready (values keyed by `target`). ALWAYS a
* non-empty list — contact basics when the schema is missing/unusable — so the form renders
* unconditionally. GET /form-schema-service/v4/forms/{formId}/summary (labels, types) and
* GET /form-schema-service/v4/forms/{formId} (which fields are required — the summary doesn't say).
*/
export async function fetchBookingForm(formId: string | null): Promise<BookingFormField[]> {
if (!formId) return FALLBACK_FIELDS;
try {
const id = encodeURIComponent(formId);
const [res, form] = await Promise.all([
wixRequest<Raw>(`/form-schema-service/v4/forms/${id}/summary`, { method: "GET" }),
wixRequest<Raw>(`/form-schema-service/v4/forms/${id}`, { method: "GET" }).catch(() => null),
]);
return toFormFields(res?.formSummary, form?.form);
} catch {
return FALLBACK_FIELDS;
}
}
/**
* Book a slot (or, for a COURSE, the whole course — pass `slot: null`): createBooking → createCart
* (holds the seat, carries the contact and location) → calculateCart → hosted checkout (paid) or
* placeOrder (free / pay-in-person, decided from the calculated cart). Call from the browser.
* `formValues` is the object your inputs wrote, keyed by field `target` — passed as the
* formSubmission DIRECTLY. Throws with the refusal (slot taken, invalid form) — surface it, don't
* swallow it. `origin` is the site's real https origin as registered on the OAuth app's allowed
* domains (browser: window.location.origin).
* POST /bookings/v2/bookings { booking, participantNotification, sendSmsReminder, formSubmission }
* POST /ecom/v2/carts { catalogItems: [{ quantity: 1, catalogReference: { catalogItemId: <bookingId>, appId } }], cart: { source, businessInfo?, customerInfo? } }
* POST /ecom/v2/carts/{cartId}/calculate {}
* POST /headless/v1/redirect-session { ecomCheckout: { checkoutId: <cartId> }, callbacks: { postFlowUrl } }
* POST /ecom/v2/carts/{cartId}/place-order {}
*/
export async function bookService(
service: ServiceDetail,
slot: Slot | null,
formValues: Record<string, unknown>,
{
staffId,
timeZone = defaultTimeZone(),
participants,
depositSelected,
origin = typeof window !== "undefined" ? window.location.origin : "",
}: Omit<BookingOptions, "idKey"> & { origin?: string } = {},
): Promise<BookingResult> {
const created = await wixRequest<Raw>("/bookings/v2/bookings", {
body: { booking: bookingRequest(service, slot, { staffId, timeZone, participants, depositSelected, idKey: "id" }), ...bookingOptions(formValues) },
});
const bookingId = bookingIdOf(created);
if (!bookingId) throw new Error("The booking couldn't be created — the slot may have just been taken.");
const newCart = await wixRequest<Raw>("/ecom/v2/carts", {
body: bookingCartRequest([bookingId], contactOf(created), slot?.location?.type === "BUSINESS" ? slot.location.id : null),
});
const cartId = cartIdOf(newCart);
if (!cartId) throw new Error("The booking couldn't be reserved — please try again.");
const calc = await wixRequest<Raw>(`/ecom/v2/carts/${cartId}/calculate`, { body: {} });
if (checkoutRequired(service, calc)) {
const session = await wixRequest<Raw>("/headless/v1/redirect-session", { body: checkoutRedirectRequest(cartId, origin) });
const url = session?.redirectSession?.fullUrl;
if (!url) throw new Error("Checkout couldn't start — please try again.");
return { kind: "redirect", url };
}
const order = await wixRequest<Raw>(`/ecom/v2/carts/${cartId}/place-order`, { body: {} });
return confirmedResult(bookingId, order);
}
SHA-256: 5c77400322c6b86aa913af042d704b6b614fa2733a756cb267778fd323a7fd8e