← Files WixARCHIVED FILE

skills/wix-headless-templates/bookings/project/src/wix/bookings/booking.ts

11.4 KB · Oct 8, 2026 · 12:02 UTC

↓ Download file

See the change to this file →

// 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 the SDK. The
// payload shapes are exact and easy to get subtly wrong — they are built in ./booking-core (shared
// with the REST twin in templates/bookings/rest/); this file is the transport only. Copy as-is;
// extend by calling these exports, never by editing them. Failures are loud where a visitor acts
// (booking), silent-with-a-default where they only inform (settings, sessions, offered days).
// 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-solutions/e-commerce/purchase-flow/cart-v2/calculate-cart.md
// docs: https://dev.wix.com/docs/api-reference/business-solutions/e-commerce/purchase-flow/cart-v2/place-order.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 { availabilityTimeSlots, eventTimeSlots, bookings as bookingsModule, bookingsSettings as settingsModule, services as servicesModule } from "@wix/bookings";
import { createCart, calculateCart, placeOrder } from "@wix/auto_sdk_ecom_cart-v-2";
import { redirects as redirectsModule } from "@wix/redirects";
import { wixFetch, wixModule } from "../sdk";
import { DEFAULT_BOOKINGS_SETTINGS, toBookingsSettings } from "./services-core";
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";
import type { AddOnGroup, BookingFormField, BookingResult, BookingsSettings, CourseAvailability, ServiceDetail, Session, Slot, SlotsPage, Weekday } from "./types";

export { resolveDisplayTimeZone, toLocalDateString };

const apptSlots = wixModule(availabilityTimeSlots);
const classSlots = wixModule(eventTimeSlots);
const bookings = wixModule(bookingsModule);
const settings = wixModule(settingsModule);
const services = wixModule(servicesModule);
const cart = wixModule({ createCart, calculateCart, placeOrder });
const redirects = wixModule(redirectsModule);

/** A POST to a Wix REST endpoint whose SDK module isn't bundled (the Calendar events module); throws on a non-2xx. */
async function postJson(path: string, body: Raw): Promise<Raw> {
  const r = await wixFetch(path, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(body) });
  if (!r.ok) throw new Error(`${path} failed (${r.status}).`);
  return (await r.json()) as Raw;
}

/**
 * Every slot for a service in [start of `from`'s day, +days) — APPOINTMENT and CLASS use different
 * APIs; 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 res: Raw =
      service.type === "CLASS"
        ? await classSlots.listEventTimeSlots(classSlotsRequest(service.id, { ...window, cursor }) as any)
        : await apptSlots.listAvailabilityTimeSlots(appointmentSlotsRequest(service.id, { ...window, cursor }) as any);
    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 [].
 */
export async function fetchNextAvailableSlots(service: Pick<ServiceDetail, "id" | "type">, options: NextAvailableOptions = {}): Promise<SlotsPage> {
  if (service.type === "COURSE") return { slots: [], timeZone: null };
  const body = nextAvailableRequest(service, options);
  const res: Raw = service.type === "CLASS" ? await classSlots.listEventTimeSlots(body as any) : await apptSlots.listAvailabilityTimeSlots(body as any);
  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. */
export async function fetchBookingsSettings(): Promise<BookingsSettings> {
  try {
    return toBookingsSettings((await settings.getBookingsSettings()) as Raw);
  } catch {
    return DEFAULT_BOOKINGS_SETTINGS;
  }
}

/** The service's add-on groups with their add-ons (prices formatted). Non-fatal (empty on failure). */
export async function fetchAddOnGroups(serviceId: string): Promise<AddOnGroup[]> {
  try {
    // The SDK takes the id positionally; REST sends addOnGroupsRequest(serviceId) as the body.
    const res: Raw = await services.listAddOnGroupsByServiceId(addOnGroupsRequest(serviceId).serviceId);
    return toAddOnGroups(res);
  } catch {
    return [];
  }
}

/**
 * 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 postJson("/calendar/v3/events/query", 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 postJson("/calendar/v3/events/query", 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 are keyed by `target`).
 * ALWAYS returns a non-empty list — contact basics when the schema is missing/unusable —
 * so the form can render unconditionally.
 */
export async function fetchBookingForm(formId: string | null): Promise<BookingFormField[]> {
  if (!formId) return FALLBACK_FIELDS;
  try {
    // Over wixFetch, not the @wix/forms `forms` module: that generated module is 15 MB and would
    // ride into the booking island's client chunk (11 MB per visitor). The summary has labels and
    // types; only the full schema says which fields are required.
    const id = encodeURIComponent(formId);
    const json = async (path: string): Promise<Raw | null> => {
      const r = await wixFetch(path);
      return r.ok ? ((await r.json()) as Raw) : null;
    };
    const [res, form] = await Promise.all([
      json(`/form-schema-service/v4/forms/${id}/summary`),
      json(`/form-schema-service/v4/forms/${id}`).catch(() => null),
    ]);
    if (!res) return FALLBACK_FIELDS;
    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 a friendly message on refusal (slot taken, invalid form) —
 * surface it, don't swallow it.
 */
export async function bookService(
  service: ServiceDetail,
  slot: Slot | null,
  formValues: Record<string, unknown>,
  { staffId, timeZone = defaultTimeZone(), participants, depositSelected }: Omit<BookingOptions, "idKey"> = {},
): Promise<BookingResult> {
  const created: Raw = await bookings.createBooking(
    bookingRequest(service, slot, { staffId, timeZone, participants, depositSelected, idKey: "_id" }) as any,
    bookingOptions(formValues) as any,
  );
  const bookingId = bookingIdOf(created);
  if (!bookingId) throw new Error("The booking couldn't be created — the slot may have just been taken.");

  const newCart: Raw = await cart.createCart(bookingCartRequest([bookingId], contactOf(created), slot?.location?.type === "BUSINESS" ? slot.location.id : null) as any);
  const cartId = cartIdOf(newCart);
  if (!cartId) throw new Error("The booking couldn't be reserved — please try again.");

  const calc: Raw = await cart.calculateCart(cartId);
  if (checkoutRequired(service, calc)) {
    const origin = typeof window !== "undefined" ? window.location.origin : "";
    const session: Raw = await redirects.createRedirectSession(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: Raw = await cart.placeOrder(cartId);
  return confirmedResult(bookingId, order);
}

SHA-256: a5967909e4521a4be528761b5451c0f215822606d5ac3651a5fb550e58741181