Setting the file. One moment.
Booking · Wix Headless Templates · wix/skills · Skills Docs
ContentsBack to the top of the page This file
Number 16.157
Position 157 of 828
Type TypeScript
Size 12 KB
Lines 227 bookings/rest/ booking.ts
TypeScript · 227 lines · 12 KB
12
// docs: https://dev.wix.com/docs/api-reference/business-solutions/bookings/services/services-v2/list-add-on-groups-by-service-id.md
13 // docs: https://dev.wix.com/docs/api-reference/business-solutions/bookings/bookings/bookings-writer-v2/create-booking.md
14 // docs: https://dev.wix.com/docs/api-reference/business-solutions/e-commerce/purchase-flow/cart-v2/create-cart.md
15 // docs: https://dev.wix.com/docs/api-reference/business-management/headless/redirects/create-redirect-session.md
16 // docs: https://dev.wix.com/docs/api-reference/crm/forms/form-schemas/get-form-summary.md
17 import { wixRequest } from "../../shared/rest/client.js" ;
18 import { DEFAULT_BOOKINGS_SETTINGS, toBookingsSettings } from "../app/wix/bookings/services-core.js" ;
19 import {
20 FALLBACK_FIELDS,
21 addOnGroupsRequest,
22 appointmentSlotsRequest,
23 bookingCartRequest,
24 bookingIdOf,
25 bookingOptions,
26 bookingRequest,
27 cartIdOf,
28 checkoutRedirectRequest,
29 checkoutRequired,
30 classSlotsRequest,
31 clampNextAvailable,
32 confirmedResult,
33 contactOf,
34 courseAvailabilityOf,
35 defaultTimeZone,
36 masterEventsRequest,
37 nextAvailableRequest,
38 nextEventsCursor,
39 nextSlotsCursor,
40 offeredDaysByScheduleId,
41 resolveDisplayTimeZone,
42 sessionsRequest,
43 toAddOnGroups,
44 toFormFields,
45 toLocalDateString,
46 toSession,
47 toSlotsPage,
48 type BookingOptions,
49 type NextAvailableOptions,
50 type Raw,
51 type SlotsWindow,
52 } from "../app/wix/bookings/booking-core.js" ;
53 import type { AddOnGroup, BookingFormField, BookingResult, BookingsSettings, CourseAvailability, ServiceDetail, Session, Slot, SlotsPage, Weekday } from "../app/wix/bookings/types.js" ;
54
55 export { resolveDisplayTimeZone, toLocalDateString };
56
57 // The two time-slot endpoints. APPOINTMENT pages on `cursorPagingMetadata`, CLASS on `pagingMetadata`.
58 // POST /service-availability/v2/time-slots { serviceId, fromLocalDate, toLocalDate, timeZone?, bookable, cursorPaging, includeResourceTypeIds, resourceTypes? }
59 // POST /service-availability/v2/time-slots/event { serviceIds, fromLocalDate, toLocalDate, timeZone?, includeNonBookable, cursorPaging, eventFilter? }
60 const listSlots = ( type : string , body : Raw ) : Promise < Raw > =>
61 wixRequest < Raw >(type === "CLASS" ? "/service-availability/v2/time-slots/event" : "/service-availability/v2/time-slots" , { body });
62
63 /**
64 * Every slot for a service in [start of `from`'s day, +days) — APPOINTMENT and CLASS use different
65 * endpoints; this branches and follows the cursor until the week is complete. Same-time slots across
66 * staff are merged; full class sessions come back with `bookable: false`. `timeZone` undefined → the
67 * business zone (the response says which zone the local times are in). COURSE has no slots ([]).
68 */
69 export async function fetchSlots ( service : Pick < ServiceDetail , "id" | "type" >, window : SlotsWindow = {}) : Promise < SlotsPage > {
70 if (service.type === "COURSE" ) return { slots: [], timeZone: window.timeZone ?? null };
71 const raws : Raw [] = [];
72 let timeZone : string | null = null ;
73 let cursor : string | undefined ;
74 do {
75 const body = service.type === "CLASS" ? classSlotsRequest (service.id, { ... window, cursor }) : appointmentSlotsRequest (service.id, { ... window, cursor });
76 const res = await listSlots (service.type, body);
77 raws. push ( ... ((res?.timeSlots ?? []) as Raw []));
78 timeZone ??= res?.timeZone ?? null ;
79 cursor = nextSlotsCursor (res) ?? undefined ;
80 } while (cursor);
81 return toSlotsPage (raws, timeZone);
82 }
83
84 /**
85 * The next few bookable times (default 3, clamped 1..6) scanning from today to the end of the month
86 * six months out — what an empty week points at. Not meaningful for VARIED pricing, add-ons, or a
87 * course (`showsNextAvailability`); those return []. Same two endpoints as fetchSlots.
88 */
89 export async function fetchNextAvailableSlots ( service : Pick < ServiceDetail , "id" | "type" >, options : NextAvailableOptions = {}) : Promise < SlotsPage > {
90 if (service.type === "COURSE" ) return { slots: [], timeZone: null };
91 const res = await listSlots (service.type, nextAvailableRequest (service, options));
92 const page = toSlotsPage ((res?.timeSlots ?? []) as Raw [], res?.timeZone);
93 return { ... page, slots: page.slots. slice ( 0 , clampNextAvailable (options.limit)) };
94 }
95
96 /**
97 * Which zone the site shows times in and whether visitors may switch. Never rejects: Wix's default
98 * (business zone) on failure. The path is the one the @wix/bookings SDK resolves for
99 * `bookingsSettings.getBookingsSettings()` (no public reference page yet).
100 * GET /_api/bookings-settings/v2/settings → { bookingsSettings: { displayTimeZone: { basedOn, customerCanChange } } }
101 */
102 export async function fetchBookingsSettings () : Promise < BookingsSettings > {
103 try {
104 return toBookingsSettings ( await wixRequest < Raw >( "/_api/bookings-settings/v2/settings" , { method: "GET" }));
105 } catch {
106 return DEFAULT_BOOKINGS_SETTINGS ;
107 }
108 }
109
110 /**
111 * The service's add-on groups with their add-ons (prices formatted). Non-fatal (empty on failure).
112 * POST /bookings/v2/services/add-on-groups/list-add-on-groups-by-service-id { serviceId } → { addOnGroupsDetails }
113 */
114 export async function fetchAddOnGroups ( serviceId : string ) : Promise < AddOnGroup []> {
115 try {
116 return toAddOnGroups ( await wixRequest < Raw >( "/bookings/v2/services/add-on-groups/list-add-on-groups-by-service-id" , { body: addOnGroupsRequest (serviceId) }));
117 } catch {
118 return [];
119 }
120 }
121
122 // The Calendar Events query: sessions (INSTANCE events of one schedule) and recurring MASTER events.
123 // POST /calendar/v3/events/query { query: { filter, cursorPaging }, fromLocalDate, timeZone?, recurrenceType? } → { events, pagingMetadata }
124 const queryEvents = ( body : Raw ) : Promise < Raw > => wixRequest < Raw >( "/calendar/v3/events/query" , { body });
125
126 /**
127 * Upcoming sessions of a class or course schedule, oldest first, 7 per page; `nextCursor` fetches the
128 * next page. Informational (booking goes through a Slot or the course itself). Non-fatal.
129 */
130 export async function fetchSessions ( scheduleId : string , options : { limit ?: number ; cursor ?: string ; timeZone ?: string } = {}) : Promise <{ sessions : Session []; nextCursor : string | null }> {
131 try {
132 const res = await queryEvents ( sessionsRequest (scheduleId, options));
133 return { sessions: ((res?.events ?? []) as Raw []). map (toSession), nextCursor: nextEventsCursor (res) };
134 } catch {
135 return { sessions: [], nextCursor: null };
136 }
137 }
138
139 async function queryMasterEvents ( scheduleIds : string [], timeZone ?: string ) : Promise < Raw []> {
140 if ( ! scheduleIds. length ) return [];
141 try {
142 return (( await queryEvents ( masterEventsRequest (scheduleIds, timeZone)))?.events ?? []) as Raw [];
143 } catch {
144 return [];
145 }
146 }
147
148 /** The weekdays each class/course schedule meets on — ONE batched call for a whole listing page. Non-fatal ({}). */
149 export async function fetchOfferedDays ( scheduleIds : string [], timeZone ?: string ) : Promise < Record < string , Weekday []>> {
150 return offeredDaysByScheduleId ( await queryMasterEvents (scheduleIds, timeZone));
151 }
152
153 /** A COURSE's seats (total, left, full) from its recurring sessions; null once ended or without sessions. Non-fatal. */
154 export async function fetchCourseAvailability ( service : Pick < ServiceDetail , "scheduleId" | "course" >, timeZone ?: string ) : Promise < CourseAvailability | null > {
155 if ( ! service.scheduleId) return null ;
156 return courseAvailabilityOf ( await queryMasterEvents ([service.scheduleId], timeZone), service.course?.ended ?? true );
157 }
158
159 /**
160 * The service's booking-form fields, flat and render-ready (values keyed by `target`). ALWAYS a
161 * non-empty list — contact basics when the schema is missing/unusable — so the form renders
162 * unconditionally. GET /form-schema-service/v4/forms/{formId}/summary (labels, types) and
163 * GET /form-schema-service/v4/forms/{formId} (which fields are required — the summary doesn't say).
164 */
165 export async function fetchBookingForm ( formId : string | null ) : Promise < BookingFormField []> {
166 if ( ! formId) return FALLBACK_FIELDS ;
167 try {
168 const id = encodeURIComponent (formId);
169 const [ res , form ] = await Promise . all ([
170 wixRequest < Raw >( `/form-schema-service/v4/forms/${ id }/summary` , { method: "GET" }),
171 wixRequest < Raw >( `/form-schema-service/v4/forms/${ id }` , { method: "GET" }). catch (() => null ),
172 ]);
173 return toFormFields (res?.formSummary, form?.form);
174 } catch {
175 return FALLBACK_FIELDS ;
176 }
177 }
178
179 /**
180 * Book a slot (or, for a COURSE, the whole course — pass `slot: null`): createBooking → createCart
181 * (holds the seat, carries the contact and location) → calculateCart → hosted checkout (paid) or
182 * placeOrder (free / pay-in-person, decided from the calculated cart). Call from the browser.
183 * `formValues` is the object your inputs wrote, keyed by field `target` — passed as the
184 * formSubmission DIRECTLY. Throws with the refusal (slot taken, invalid form) — surface it, don't
185 * swallow it. `origin` is the site's real https origin as registered on the OAuth app's allowed
186 * domains (browser: window.location.origin).
187 * POST /bookings/v2/bookings { booking, participantNotification, sendSmsReminder, formSubmission }
188 * POST /ecom/v2/carts { catalogItems: [{ quantity: 1, catalogReference: { catalogItemId: <bookingId>, appId } }], cart: { source, businessInfo?, customerInfo? } }
189 * POST /ecom/v2/carts/{cartId}/calculate {}
190 * POST /headless/v1/redirect-session { ecomCheckout: { checkoutId: <cartId> }, callbacks: { postFlowUrl } }
191 * POST /ecom/v2/carts/{cartId}/place-order {}
192 */
193 export async function bookService (
194 service : ServiceDetail ,
195 slot : Slot | null ,
196 formValues : Record < string , unknown >,
197 {
198 staffId ,
199 timeZone = defaultTimeZone (),
200 participants ,
201 depositSelected ,
202 origin = typeof window !== "undefined" ? window.location.origin : "" ,
203 } : Omit < BookingOptions , "idKey" > & { origin ?: string } = {},
204 ) : Promise < BookingResult > {
205 const created = await wixRequest < Raw >( "/bookings/v2/bookings" , {
206 body: { booking: bookingRequest (service, slot, { staffId, timeZone, participants, depositSelected, idKey: "id" }), ... bookingOptions (formValues) },
207 });
208 const bookingId = bookingIdOf (created);
209 if ( ! bookingId) throw new Error ( "The booking couldn't be created — the slot may have just been taken." );
210
211 const newCart = await wixRequest < Raw >( "/ecom/v2/carts" , {
212 body: bookingCartRequest ([bookingId], contactOf (created), slot?.location?.type === "BUSINESS" ? slot.location.id : null ),
213 });
214 const cartId = cartIdOf (newCart);
215 if ( ! cartId) throw new Error ( "The booking couldn't be reserved — please try again." );
216
217 const calc = await wixRequest < Raw >( `/ecom/v2/carts/${ cartId }/calculate` , { body: {} });
218 if ( checkoutRequired (service, calc)) {
219 const session = await wixRequest < Raw >( "/headless/v1/redirect-session" , { body: checkoutRedirectRequest (cartId, origin) });
220 const url = session?.redirectSession?.fullUrl;
221 if ( ! url) throw new Error ( "Checkout couldn't start — please try again." );
222 return { kind: "redirect" , url };
223 }
224
225 const order = await wixRequest < Raw >( `/ecom/v2/carts/${ cartId }/place-order` , { body: {} });
226 return confirmedResult (bookingId, order);
227 }