14 "js/wix/booking-flow-store.js": "// Declarations of booking-flow-store.ts: what this file exports, their arguments and what they return.\n// import { type AddOnSelection, type BookingDay } from \"./booking-core.js\";\n// import type { AddOnGroup, BookingFormField, BookingResult, CourseAvailability, CtaState, ServiceDetail, Session, Slot, Weekday } from \"./bookings-types.js\";\n// export type { BookingDay };\n// export interface BookingFlowState {\n// days: BookingDay[] | null;\n// windowStart: Date;\n// nextAvailable: Slot[] | null;\n// staffId: string | undefined;\n// selectedSlot: Slot | null;\n// timeZone: string | null;\n// timeZoneLabel: string;\n// displayTimeZone: \"BUSINESS\" | \"CUSTOMER\";\n// customerCanChangeTimeZone: boolean;\n// course: CourseAvailability | null;\n// offeredDays: Weekday[];\n// sessions: Session[] | null;\n// hasMoreSessions: boolean;\n// participants: number;\n// maxParticipants: number;\n// payDeposit: boolean;\n// addOnGroups: AddOnGroup[];\n// addOns: AddOnSelection;\n// addOnsTotal: string;\n// addOnsMinutes: number;\n// formFields: BookingFormField[];\n// values: Record<string, string>;\n// ctaState: CtaState;\n// canBook: boolean;\n// booking: boolean;\n// confirmed: BookingResult | null;\n// error: string | null;\n// }\n// export interface BookingFlowStore {\n// getState(): BookingFlowState;\n// subscribe(listener: (state: BookingFlowState) => void): () => void;\n// start(): void;\n// stop(): void;\n// nextWeek(): void;\n// prevWeek(): void;\n// jumpTo(dayKey: string): void;\n// setStaffId(id: string | undefined): void;\n// setSelectedSlot(slot: Slot | null): void;\n// setDisplayTimeZone(zone: \"BUSINESS\" | \"CUSTOMER\"): void;\n// setParticipants(n: number): void;\n// setPayDeposit(payDeposit: boolean): void;\n// toggleAddOn(id: string): void;\n// setAddOnQuantity(id: string, quantity: number): void;\n// canSelectMore(groupId: string): boolean;\n// loadMoreSessions(): Promise<void>;\n// setValue(target: string, value: string): void;\n// book(): Promise<BookingResult>;\n// }\n// export declare function createBookingFlowStore(service: ServiceDetail): BookingFlowStore;\n// The whole booking state machine for one service as a framework-free store — the logic behind\n// useBookingFlow, usable from React (useBookingFlow wraps it), from a static page's booking surface,\n// from Vue/Svelte, or as the specification for a port: the display time zone (the site's setting),\n// the availability window (day-grouped slots, week paging, optional staff filter, next availability),\n// a course's seats and dates, a class's upcoming sessions, participants, deposit choice, add-on\n// selection, the schema-driven form, the CTA state, and book(). All correctness (slot scheduleId vs\n// eventId vs a course's schedule, ANY_RESOURCE, the slot's location, formSubmission, payment option,\n// checkout-or-place) lives in the data layer; this store orchestrates. A late response from a\n// superseded window is dropped. One store per booking surface: createBookingFlowStore(service).\nimport { bookService, fetchAddOnGroups, fetchBookingForm, fetchBookingsSettings, fetchCourseAvailability, fetchNextAvailableSlots, fetchOfferedDays, fetchSessions, fetchSlots, resolveDisplayTimeZone, } from \"./booking.js\";\nimport { ctaStateOf } from \"./services-core.js\";\nimport { addOnsDuration, addOnsTotal, canSelectMore, groupSlotsByDay, maxParticipants, showsNextAvailability, startOfDay, timeZoneLabel, withAddOnQuantity, } from \"./booking-core.js\";\nconst WINDOW_DAYS = 7;\nconst RESOURCE_PARAM = \"resource\";\nconst readResourceParam = () => {\n if (typeof window === \"undefined\")\n return undefined;\n try {\n return new URL(window.location.href).searchParams.get(RESOURCE_PARAM) || undefined;\n }\n catch {\n return undefined;\n }\n};\nexport function createBookingFlowStore(service) {\n const isCourse = service.type === \"COURSE\";\n let windowStart = startOfDay(new Date());\n let staffId = readResourceParam();\n let slots = isCourse ? [] : null;\n let nextAvailable = showsNextAvailability(service) ? null : [];\n let selectedSlot = null;\n let settings = { displayTimeZone: \"BUSINESS\", customerCanChange: false };\n let requestZone; // what the requests send; undefined = the business zone\n let timeZone = null; // what the response says the times are in\n let course = null;\n let offeredDays = service.offeredDays;\n let sessions = service.type === \"APPOINTMENT\" ? [] : null;\n let sessionsCursor = null;\n let participants = 1;\n let payDeposit = !!service.deposit;\n let addOnGroups = [];\n let addOns = {};\n let formFields = [];\n let values = {};\n let booking = false;\n let confirmed = null;\n let error = null;\n let started = false;\n let generation = 0;\n const listeners = new Set();\n let snapshot = null;\n const emit = () => {\n snapshot = null;\n for (const fn of listeners)\n fn();\n };\n const ctaState = () => ctaStateOf(service, course?.full ?? false);\n const formComplete = () => formFields.every((f) => !f.required || (values[f.target] ?? \"\").trim().length > 0);\n const zoneForBooking = () => timeZone ?? requestZone ?? undefined;\n function getState() {\n if (snapshot)\n return snapshot;\n const cta = ctaState();\n const target = isCourse ? !service.course?.ended && cta !== \"viewCourse\" && !!service.scheduleId : !!selectedSlot?.bookable;\n snapshot = {\n days: slots === null ? null : groupSlotsByDay(slots),\n windowStart,\n nextAvailable,\n staffId,\n selectedSlot,\n timeZone,\n timeZoneLabel: timeZoneLabel(timeZone, selectedSlot ? new Date(selectedSlot.startLocal) : windowStart),\n displayTimeZone: settings.displayTimeZone,\n customerCanChangeTimeZone: settings.customerCanChange,\n course,\n offeredDays,\n sessions,\n hasMoreSessions: !!sessionsCursor,\n participants,\n maxParticipants: maxParticipants(service, selectedSlot),\n payDeposit,\n addOnGroups,\n addOns,\n addOnsTotal: addOnsTotal(addOnGroups, addOns),\n addOnsMinutes: addOnsDuration(addOnGroups, addOns),\n formFields,\n values,\n ctaState: cta,\n canBook: target && formComplete() && !booking,\n booking,\n confirmed,\n error,\n };\n return snapshot;\n }\n // The window, the staff filter, or the zone changed: clear the picker and load the slots for the new selection.\n function loadSlots() {\n if (!started || isCourse)\n return;\n const id = ++generation;\n slots = null;\n selectedSlot = null;\n emit();\n fetchSlots(service, { from: windowStart, days: WINDOW_DAYS, staffId, timeZone: requestZone })\n .then((page) => {\n if (!started || generation !== id)\n return; // superseded — drop it\n slots = page.slots;\n timeZone = page.timeZone ?? requestZone ?? null;\n emit();\n })\n .catch((e) => {\n if (!started || generation !== id)\n return;\n slots = [];\n error = e instanceof Error ? e.message : String(e);\n emit();\n });\n }\n // Next availability follows the staff filter and the zone, not the window.\n function loadNextAvailable() {\n if (!started || !showsNextAvailability(service))\n return;\n const id = generation;\n nextAvailable = null;\n fetchNextAvailableSlots(service, { staffId, timeZone: requestZone })\n .then((page) => {\n if (!started || generation !== id)\n return;\n nextAvailable = page.slots;\n emit();\n })\n .catch(() => {\n if (!started || generation !== id)\n return;\n nextAvailable = [];\n emit();\n });\n }\n // Sessions: a class always, a course while it still runs, never an appointment.\n function loadSessions() {\n const scheduleId = service.scheduleId;\n if (!started || !scheduleId || service.type === \"APPOINTMENT\" || (isCourse && service.course?.ended)) {\n sessions = sessions ?? [];\n return;\n }\n const id = generation;\n fetchSessions(scheduleId, { timeZone: requestZone }).then((page) => {\n if (!started || generation !== id)\n return;\n sessions = page.sessions;\n sessionsCursor = page.nextCursor;\n emit();\n });\n }\n // The recurring sessions: the weekdays a class/course meets, and a course's seats.\n function loadSchedule() {\n if (!started || service.type === \"APPOINTMENT\" || !service.scheduleId)\n return;\n const id = generation;\n const scheduleId = service.scheduleId;\n fetchOfferedDays([scheduleId], requestZone).then((days) => {\n if (!started || generation !== id)\n return;\n offeredDays = days[scheduleId] ?? offeredDays;\n emit();\n });\n if (!isCourse)\n return;\n fetchCourseAvailability(service, requestZone).then((c) => {\n if (!started || generation !== id)\n return;\n course = c;\n timeZone = timeZone ?? requestZone ?? null;\n emit();\n });\n }\n function loadForZone() {\n loadSlots();\n loadNextAvailable();\n loadSessions();\n loadSchedule();\n emit();\n }\n return {\n getState,\n subscribe(fn) {\n const listener = () => { try {\n fn(getState());\n }\n catch (e) {\n console.error(\"booking-flow-store listener failed\", e);\n } };\n listeners.add(listener);\n return () => listeners.delete(listener);\n },\n start() {\n if (started)\n return;\n started = true;\n // The zone policy first: everything time-related is requested in the zone the owner chose.\n fetchBookingsSettings().then((s) => {\n if (!started)\n return;\n settings = s;\n requestZone = resolveDisplayTimeZone(s);\n loadForZone();\n });\n fetchBookingForm(service.formId).then((f) => { if (started) {\n formFields = f;\n emit();\n } });\n if (service.hasAddOns)\n fetchAddOnGroups(service.id).then((g) => { if (started) {\n addOnGroups = g;\n emit();\n } });\n },\n stop() {\n started = false;\n generation++;\n },\n nextWeek() {\n windowStart = new Date(windowStart.getFullYear(), windowStart.getMonth(), windowStart.getDate() + WINDOW_DAYS);\n loadSlots();\n },\n prevWeek() {\n const today = startOfDay(new Date());\n const prev = new Date(windowStart.getFullYear(), windowStart.getMonth(), windowStart.getDate() - WINDOW_DAYS);\n windowStart = prev < today ? today : prev;\n loadSlots();\n },\n jumpTo(dayKey) {\n const day = new Date(`${dayKey}T12:00:00`);\n if (Number.isNaN(day.getTime()))\n return;\n const today = startOfDay(new Date());\n const target = startOfDay(day);\n windowStart = target < today ? today : target;\n loadSlots();\n },\n setStaffId(id) {\n if (id === staffId)\n return;\n staffId = id;\n loadSlots();\n loadNextAvailable();\n },\n setSelectedSlot(slot) {\n if (slot && !slot.bookable)\n return;\n selectedSlot = slot;\n participants = Math.min(participants, maxParticipants(service, slot));\n emit();\n },\n setDisplayTimeZone(zone) {\n if (!settings.customerCanChange || zone === settings.displayTimeZone)\n return;\n settings = { ...settings, displayTimeZone: zone };\n requestZone = resolveDisplayTimeZone(settings);\n timeZone = null;\n generation++;\n loadForZone();\n },\n setParticipants(n) {\n participants = Math.max(1, Math.min(Math.floor(n) || 1, maxParticipants(service, selectedSlot)));\n emit();\n },\n setPayDeposit(v) {\n if (!service.deposit)\n return;\n payDeposit = service.deposit.fullUpfrontAllowed ? v : true;\n emit();\n },\n toggleAddOn(id) {\n addOns = withAddOnQuantity(addOnGroups, addOns, id, addOns[id] > 0 ? 0 : 1);\n emit();\n },\n setAddOnQuantity(id, quantity) {\n addOns = withAddOnQuantity(addOnGroups, addOns, id, quantity);\n emit();\n },\n canSelectMore(groupId) {\n const group = addOnGroups.find((g) => g.id === groupId);\n return group ? canSelectMore(group, addOns) : false;\n },\n async loadMoreSessions() {\n if (!started || !sessionsCursor || !service.scheduleId)\n return;\n const id = generation;\n const cursor = sessionsCursor;\n sessionsCursor = null;\n emit();\n const page = await fetchSessions(service.scheduleId, { cursor, timeZone: requestZone });\n if (!started || generation !== id)\n return;\n sessions = [...(sessions ?? []), ...page.sessions];\n sessionsCursor = page.nextCursor;\n emit();\n },\n setValue(target, value) {\n values = { ...values, [target]: value };\n emit();\n },\n async book() {\n if (!isCourse && !selectedSlot)\n throw new Error(\"Pick a time first.\");\n if (isCourse && ctaState() === \"viewCourse\")\n throw new Error(\"This course can't be booked anymore.\");\n booking = true;\n error = null;\n emit();\n try {\n const result = await bookService(service, isCourse ? null : selectedSlot, values, {\n staffId,\n timeZone: zoneForBooking(),\n participants,\n depositSelected: service.deposit?.fullUpfrontAllowed ? payDeposit : undefined,\n });\n if (result.kind === \"redirect\") {\n window.location.href = result.url;\n }\n else {\n confirmed = result;\n }\n return result;\n }\n catch (e) {\n error = e instanceof Error ? e.message : String(e);\n throw e;\n }\n finally {\n booking = false;\n emit();\n }\n },\n };\n}\n",
15 "js/wix/booking.js": "// Declarations of booking.ts: what this file exports, their arguments and what they return.\n// import { resolveDisplayTimeZone, toLocalDateString, type BookingOptions, type NextAvailableOptions, type SlotsWindow } from \"./booking-core.js\";\n// import type { AddOnGroup, BookingFormField, BookingResult, BookingsSettings, CourseAvailability, ServiceDetail, Session, Slot, SlotsPage, Weekday } from \"./bookings-types.js\";\n// export { resolveDisplayTimeZone, toLocalDateString };\n// export declare function fetchSlots(service: Pick<ServiceDetail, \"id\" | \"type\">, window?: SlotsWindow): Promise<SlotsPage>;\n// export declare function fetchNextAvailableSlots(service: Pick<ServiceDetail, \"id\" | \"type\">, options?: NextAvailableOptions): Promise<SlotsPage>;\n// export declare function fetchBookingsSettings(): Promise<BookingsSettings>;\n// export declare function fetchAddOnGroups(serviceId: string): Promise<AddOnGroup[]>;\n// export declare function fetchSessions(scheduleId: string, options?: {\n// limit?: number;\n// cursor?: string;\n// timeZone?: string;\n// }): Promise<{\n// sessions: Session[];\n// nextCursor: string | null;\n// }>;\n// export declare function fetchOfferedDays(scheduleIds: string[], timeZone?: string): Promise<Record<string, Weekday[]>>;\n// export declare function fetchCourseAvailability(service: Pick<ServiceDetail, \"scheduleId\" | \"course\">, timeZone?: string): Promise<CourseAvailability | null>;\n// export declare function fetchBookingForm(formId: string | null): Promise<BookingFormField[]>;\n// export declare function bookService(service: ServiceDetail, slot: Slot | null, formValues: Record<string, unknown>, { staffId, timeZone, participants, depositSelected, origin, }?: Omit<BookingOptions, \"idKey\"> & {\n// origin?: string;\n// }): Promise<BookingResult>;\n// Availability, the calendar reads (sessions, offered days, course seats), add-ons, settings, the\n// booking form, and the createBooking → Cart V2 → checkout-or-place sequence over REST — the twin of\n// app/wix/bookings/booking.ts. Same exports, same DTOs; every body comes from booking-core (the SAME\n// file the SDK transport uses, deployed flat next to this one), so this file is only the transport:\n// literal paths, one fetch per step. Failures are loud where a visitor acts (booking): a taken slot,\n// a refused cart, a checkout that won't start all throw — surface the message. Reads that only\n// inform (settings, sessions, offered days, add-ons) fall back to an empty default.\n// All calls run with the visitor token: the booking and its cart are the token's (see ./client).\n// docs: https://dev.wix.com/docs/api-reference/business-solutions/bookings/time-slots/time-slots-v2/list-availability-time-slots.md\n// docs: https://dev.wix.com/docs/api-reference/business-solutions/bookings/time-slots/time-slots-v2/list-event-time-slots.md\n// docs: https://dev.wix.com/docs/api-reference/business-management/calendar/events-v3/query-events.md\n// docs: https://dev.wix.com/docs/api-reference/business-solutions/bookings/services/services-v2/list-add-on-groups-by-service-id.md\n// docs: https://dev.wix.com/docs/api-reference/business-solutions/bookings/bookings/bookings-writer-v2/create-booking.md\n// docs: https://dev.wix.com/docs/api-reference/business-solutions/e-commerce/purchase-flow/cart-v2/create-cart.md\n// docs: https://dev.wix.com/docs/api-reference/business-management/headless/redirects/create-redirect-session.md\n// docs: https://dev.wix.com/docs/api-reference/crm/forms/form-schemas/get-form-summary.md\nimport { wixRequest } from \"./client.js\";\nimport { DEFAULT_BOOKINGS_SETTINGS, toBookingsSettings } from \"./services-core.js\";\nimport { 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, } from \"./booking-core.js\";\nexport { resolveDisplayTimeZone, toLocalDateString };\n// The two time-slot endpoints. APPOINTMENT pages on `cursorPagingMetadata`, CLASS on `pagingMetadata`.\n// POST /service-availability/v2/time-slots { serviceId, fromLocalDate, toLocalDate, timeZone?, bookable, cursorPaging, includeResourceTypeIds, resourceTypes? }\n// POST /service-availability/v2/time-slots/event { serviceIds, fromLocalDate, toLocalDate, timeZone?, includeNonBookable, cursorPaging, eventFilter? }\nconst listSlots = (type, body) => wixRequest(type === \"CLASS\" ? \"/service-availability/v2/time-slots/event\" : \"/service-availability/v2/time-slots\", { body });\n/**\n * Every slot for a service in [start of `from`'s day, +days) — APPOINTMENT and CLASS use different\n * endpoints; this branches and follows the cursor until the week is complete. Same-time slots across\n * staff are merged; full class sessions come back with `bookable: false`. `timeZone` undefined → the\n * business zone (the response says which zone the local times are in). COURSE has no slots ([]).\n */\nexport async function fetchSlots(service, window = {}) {\n if (service.type === \"COURSE\")\n return { slots: [], timeZone: window.timeZone ?? null };\n const raws = [];\n let timeZone = null;\n let cursor;\n do {\n const body = service.type === \"CLASS\" ? classSlotsRequest(service.id, { ...window, cursor }) : appointmentSlotsRequest(service.id, { ...window, cursor });\n const res = await listSlots(service.type, body);\n raws.push(...(res?.timeSlots ?? []));\n timeZone ??= res?.timeZone ?? null;\n cursor = nextSlotsCursor(res) ?? undefined;\n } while (cursor);\n return toSlotsPage(raws, timeZone);\n}\n/**\n * The next few bookable times (default 3, clamped 1..6) scanning from today to the end of the month\n * six months out — what an empty week points at. Not meaningful for VARIED pricing, add-ons, or a\n * course (`showsNextAvailability`); those return []. Same two endpoints as fetchSlots.\n */\nexport async function fetchNextAvailableSlots(service, options = {}) {\n if (service.type === \"COURSE\")\n return { slots: [], timeZone: null };\n const res = await listSlots(service.type, nextAvailableRequest(service, options));\n const page = toSlotsPage((res?.timeSlots ?? []), res?.timeZone);\n return { ...page, slots: page.slots.slice(0, clampNextAvailable(options.limit)) };\n}\n/**\n * Which zone the site shows times in and whether visitors may switch. Never rejects: Wix's default\n * (business zone) on failure. The path is the one the @wix/bookings SDK resolves for\n * `bookingsSettings.getBookingsSettings()` (no public reference page yet).\n * GET /_api/bookings-settings/v2/settings → { bookingsSettings: { displayTimeZone: { basedOn, customerCanChange } } }\n */\nexport async function fetchBookingsSettings() {\n try {\n return toBookingsSettings(await wixRequest(\"/_api/bookings-settings/v2/settings\", { method: \"GET\" }));\n }\n catch {\n return DEFAULT_BOOKINGS_SETTINGS;\n }\n}\n/**\n * The service's add-on groups with their add-ons (prices formatted). Non-fatal (empty on failure).\n * POST /bookings/v2/services/add-on-groups/list-add-on-groups-by-service-id { serviceId } → { addOnGroupsDetails }\n */\nexport async function fetchAddOnGroups(serviceId) {\n try {\n return toAddOnGroups(await wixRequest(\"/bookings/v2/services/add-on-groups/list-add-on-groups-by-service-id\", { body: addOnGroupsRequest(serviceId) }));\n }\n catch {\n return [];\n }\n}\n// The Calendar Events query: sessions (INSTANCE events of one schedule) and recurring MASTER events.\n// POST /calendar/v3/events/query { query: { filter, cursorPaging }, fromLocalDate, timeZone?, recurrenceType? } → { events, pagingMetadata }\nconst queryEvents = (body) => wixRequest(\"/calendar/v3/events/query\", { body });\n/**\n * Upcoming sessions of a class or course schedule, oldest first, 7 per page; `nextCursor` fetches the\n * next page. Informational (booking goes through a Slot or the course itself). Non-fatal.\n */\nexport async function fetchSessions(scheduleId, options = {}) {\n try {\n const res = await queryEvents(sessionsRequest(scheduleId, options));\n return { sessions: (res?.events ?? []).map(toSession), nextCursor: nextEventsCursor(res) };\n }\n catch {\n return { sessions: [], nextCursor: null };\n }\n}\nasync function queryMasterEvents(scheduleIds, timeZone) {\n if (!scheduleIds.length)\n return [];\n try {\n return ((await queryEvents(masterEventsRequest(scheduleIds, timeZone)))?.events ?? []);\n }\n catch {\n return [];\n }\n}\n/** The weekdays each class/course schedule meets on — ONE batched call for a whole listing page. Non-fatal ({}). */\nexport async function fetchOfferedDays(scheduleIds, timeZone) {\n return offeredDaysByScheduleId(await queryMasterEvents(scheduleIds, timeZone));\n}\n/** A COURSE's seats (total, left, full) from its recurring sessions; null once ended or without sessions. Non-fatal. */\nexport async function fetchCourseAvailability(service, timeZone) {\n if (!service.scheduleId)\n return null;\n return courseAvailabilityOf(await queryMasterEvents([service.scheduleId], timeZone), service.course?.ended ?? true);\n}\n/**\n * The service's booking-form fields, flat and render-ready (values keyed by `target`). ALWAYS a\n * non-empty list — contact basics when the schema is missing/unusable — so the form renders\n * unconditionally. GET /form-schema-service/v4/forms/{formId}/summary (labels, types) and\n * GET /form-schema-service/v4/forms/{formId} (which fields are required — the summary doesn't say).\n */\nexport async function fetchBookingForm(formId) {\n if (!formId)\n return FALLBACK_FIELDS;\n try {\n const id = encodeURIComponent(formId);\n const [res, form] = await Promise.all([\n wixRequest(`/form-schema-service/v4/forms/${id}/summary`, { method: \"GET\" }),\n wixRequest(`/form-schema-service/v4/forms/${id}`, { method: \"GET\" }).catch(() => null),\n ]);\n return toFormFields(res?.formSummary, form?.form);\n }\n catch {\n return FALLBACK_FIELDS;\n }\n}\n/**\n * Book a slot (or, for a COURSE, the whole course — pass `slot: null`): createBooking → createCart\n * (holds the seat, carries the contact and location) → calculateCart → hosted checkout (paid) or\n * placeOrder (free / pay-in-person, decided from the calculated cart). Call from the browser.\n * `formValues` is the object your inputs wrote, keyed by field `target` — passed as the\n * formSubmission DIRECTLY. Throws with the refusal (slot taken, invalid form) — surface it, don't\n * swallow it. `origin` is the site's real https origin as registered on the OAuth app's allowed\n * domains (browser: window.location.origin).\n * POST /bookings/v2/bookings { booking, participantNotification, sendSmsReminder, formSubmission }\n * POST /ecom/v2/carts { catalogItems: [{ quantity: 1, catalogReference: { catalogItemId: <bookingId>, appId } }], cart: { source, businessInfo?, customerInfo? } }\n * POST /ecom/v2/carts/{cartId}/calculate {}\n * POST /headless/v1/redirect-session { ecomCheckout: { checkoutId: <cartId> }, callbacks: { postFlowUrl } }\n * POST /ecom/v2/carts/{cartId}/place-order {}\n */\nexport async function bookService(service, slot, formValues, { staffId, timeZone = defaultTimeZone(), participants, depositSelected, origin = typeof window !== \"undefined\" ? window.location.origin : \"\", } = {}) {\n const created = await wixRequest(\"/bookings/v2/bookings\", {\n body: { booking: bookingRequest(service, slot, { staffId, timeZone, participants, depositSelected, idKey: \"id\" }), ...bookingOptions(formValues) },\n });\n const bookingId = bookingIdOf(created);\n if (!bookingId)\n throw new Error(\"The booking couldn't be created — the slot may have just been taken.\");\n const newCart = await wixRequest(\"/ecom/v2/carts\", {\n body: bookingCartRequest([bookingId], contactOf(created), slot?.location?.type === \"BUSINESS\" ? slot.location.id : null),\n });\n const cartId = cartIdOf(newCart);\n if (!cartId)\n throw new Error(\"The booking couldn't be reserved — please try again.\");\n const calc = await wixRequest(`/ecom/v2/carts/${cartId}/calculate`, { body: {} });\n if (checkoutRequired(service, calc)) {\n const session = await wixRequest(\"/headless/v1/redirect-session\", { body: checkoutRedirectRequest(cartId, origin) });\n const url = session?.redirectSession?.fullUrl;\n if (!url)\n throw new Error(\"Checkout couldn't start — please try again.\");\n return { kind: \"redirect\", url };\n }\n const order = await wixRequest(`/ecom/v2/carts/${cartId}/place-order`, { body: {} });\n return confirmedResult(bookingId, order);\n}\n",
16 "js/wix/bookings-types.js": "// Declarations of bookings-types.ts: what this file exports, their arguments and what they return.\n// /** The three Wix Bookings service types. Anything else never becomes a DTO. */\n// export type ServiceType = \"APPOINTMENT\" | \"CLASS\" | \"COURSE\";\n// /** How the service is priced (Services V2 `payment.rateType`). */\n// export type RateType = \"FIXED\" | \"VARIED\" | \"CUSTOM\" | \"NO_FEE\" | \"SUBSCRIPTION\";\n// /** What the one CTA does: book now, send a request the owner approves, or (a full/ended course) only view. */\n// export type CtaState = \"book\" | \"requestToBook\" | \"viewCourse\";\n// export type Weekday = \"MONDAY\" | \"TUESDAY\" | \"WEDNESDAY\" | \"THURSDAY\" | \"FRIDAY\" | \"SATURDAY\" | \"SUNDAY\";\n// /** Where a service (or one slot) takes place. BUSINESS locations have an id; CUSTOM/CUSTOMER don't. */\n// export interface ServiceLocation {\n// id: string | null;\n// name: string;\n// type: \"BUSINESS\" | \"CUSTOM\" | \"CUSTOMER\";\n// }\n// /** A staff member as the service lists them; `id` is the resource GUID used for filtering and booking. */\n// export interface StaffSummary {\n// id: string;\n// name: string;\n// /** Resolved https URL (\"\" when the staff member has no photo). */\n// imageUrl: string;\n// }\n// /** Deposit terms when the service takes one; null otherwise. */\n// export interface Deposit {\n// /** Formatted deposit amount (\"\" when the deposit is a percentage — the server resolves it at booking). */\n// amount: string;\n// /** True when the visitor may choose to pay the full price upfront instead. */\n// fullUpfrontAllowed: boolean;\n// }\n// /** A service as a listing/grid tile needs it. */\n// export interface ServiceSummary {\n// id: string;\n// slug: string;\n// name: string;\n// tagLine: string;\n// type: ServiceType;\n// rateType: RateType;\n// /**\n// * Display-ready price: FIXED → \"€75\"; VARIED → the lowest price (render \"From €30\", see `priceFrom`);\n// * CUSTOM → the owner's text (\"Ask for a quote\"); NO_FEE → \"Free\"; \"\" when nothing can be shown.\n// */\n// price: string;\n// /** True for VARIED pricing — `price` is the minimum, prefix it (\"From €30\"). */\n// priceFrom: boolean;\n// /** The pre-discount price when a discount applies to a FIXED price (\"\" otherwise) — render struck through. */\n// basePrice: string;\n// /** The discount's name when one applies (\"\" otherwise). */\n// discountName: string;\n// /** True when a discount applies but the amount is only known at checkout (VARIED, add-ons). */\n// calculatedAtCheckout: boolean;\n// /** True when the service can be paid with a pricing plan / membership. */\n// hasPricingPlans: boolean;\n// /** NO_FEE (or a FIXED price of 0): books without a checkout. */\n// free: boolean;\n// /** Session length in minutes (appointments; classes and courses may have none). */\n// durationMinutes: number | null;\n// /** \"1 hr 30 min\" / \"45 min\" (\"\" when unknown). */\n// durationLabel: string;\n// /** Resolved https URL (\"\" when the service has no image). */\n// imageUrl: string;\n// categoryId: string | null;\n// categoryName: string;\n// /** The service's schedule id — classes/courses hang their sessions on it; a COURSE books this whole schedule. */\n// scheduleId: string | null;\n// staff: StaffSummary[];\n// locations: ServiceLocation[];\n// /** Sessions carry a video-conference link. */\n// conferencing: boolean;\n// /** The service offers add-ons (next-availability is not shown for these; the add-ons load on the booking page). */\n// hasAddOns: boolean;\n// /** The owner approves each booking by hand: the CTA is \"Request to book\", and success is a sent request. */\n// requiresManualApproval: boolean;\n// onlineBookingEnabled: boolean;\n// /** Late/after-start booking policy already forbids booking (courses: the course started or ended). */\n// tooLateToBook: boolean;\n// /** CTA state from the policy alone; a course's fullness is folded in by the booking flow (`ctaState` there). */\n// ctaState: CtaState;\n// /** Seats per session (classes/courses) — 1 for appointments; null when unknown. */\n// defaultCapacity: number | null;\n// /** Policy cap on participants per booking; null when the policy sets none. */\n// maxParticipantsPerBooking: number | null;\n// deposit: Deposit | null;\n// /** Weekly days a class/course meets (from its recurring sessions) — filled by the listing store, [] until then. */\n// offeredDays: Weekday[];\n// }\n// /** The fixed span of a COURSE (its sessions' first start and last end). */\n// export interface CourseSpan {\n// /** ISO instants; null when the course has no sessions yet. */\n// startDate: string | null;\n// endDate: string | null;\n// /** True once the last session is over (or none exists) — the course can't be booked. */\n// ended: boolean;\n// }\n// /** A service as the booking page needs it. */\n// export interface ServiceDetail extends ServiceSummary {\n// /** Description HTML/text as stored. */\n// description: string;\n// /** The @wix/forms booking-form id (null → the contact-basics fallback form). */\n// formId: string | null;\n// /** Derived payment option the booking must send (\"ONLINE\" | \"OFFLINE\"). */\n// paymentOption: \"ONLINE\" | \"OFFLINE\";\n// /** True → checkout is always required regardless of price (cancellation-fee policy). */\n// cancellationFeeEnabled: boolean;\n// /** COURSE only; null for appointments and classes. */\n// course: CourseSpan | null;\n// }\n// export interface BookingCategory {\n// id: string;\n// name: string;\n// }\n// /** A business location that hosts at least one bookable service (a listing filter option). */\n// export interface LocationOption {\n// id: string;\n// name: string;\n// }\n// /** Bookings settings the visitor-facing surfaces need. */\n// export interface BookingsSettings {\n// /** Which zone times are shown in by default: the business's (the Wix default) or the visitor's. */\n// displayTimeZone: \"BUSINESS\" | \"CUSTOMER\";\n// /** The owner lets visitors switch between the two. */\n// customerCanChange: boolean;\n// }\n// /** One time slot, appointment or class session. */\n// export interface Slot {\n// /** Identity: start|end|location — two staff at the same time are ONE slot; React keys go here. */\n// key: string;\n// /** Local wall-clock strings \"YYYY-MM-DDThh:mm:ss\" (no Z) in the response's `timeZone`. */\n// startLocal: string;\n// endLocal: string;\n// /** \"YYYY-MM-DD\" — for grouping slots by day. */\n// dayKey: string;\n// /** Display label, e.g. \"9:00 AM\". */\n// label: string;\n// /** False for a full class session or a policy-blocked time — render disabled (\"Full\"). */\n// bookable: boolean;\n// /** Seats: total and left (classes); null for appointments. */\n// totalCapacity: number | null;\n// remainingCapacity: number | null;\n// /** Waitlist seats left when the class has a waitlist and the session is full; null otherwise. */\n// waitlistCapacity: number | null;\n// /** APPOINTMENT slots carry their own scheduleId; CLASS slots carry an eventId (+ title) instead. */\n// scheduleId: string | null;\n// eventId: string | null;\n// eventTitle: string;\n// location: ServiceLocation | null;\n// /** Staff able to take this slot (may be empty — ANY_RESOURCE still books). */\n// staff: {\n// id: string;\n// name: string;\n// }[];\n// }\n// /** A window of slots plus the IANA zone their local times are expressed in. */\n// export interface SlotsPage {\n// slots: Slot[];\n// /** The zone the server computed in — the one requested, or the business zone when none was sent. */\n// timeZone: string | null;\n// }\n// /** One upcoming session of a class or course (informational — booking goes through a Slot / the course). */\n// export interface Session {\n// id: string;\n// title: string;\n// startLocal: string;\n// endLocal: string;\n// dayKey: string;\n// /** \"Mon, Sep 7 · 9:00 AM\" style label pieces: the day and the start time. */\n// dayLabel: string;\n// label: string;\n// durationMinutes: number;\n// staff: {\n// id: string;\n// name: string;\n// }[];\n// totalCapacity: number | null;\n// spotsLeft: number | null;\n// isFullyBooked: boolean;\n// isCancelled: boolean;\n// }\n// /** Seats on a COURSE, from its recurring sessions; null once the course has ended or has no sessions. */\n// export interface CourseAvailability {\n// totalCapacity: number | null;\n// spotsLeft: number | null;\n// full: boolean;\n// }\n// /** An optional extra the visitor can add to a booking. */\n// export interface AddOn {\n// id: string;\n// name: string;\n// /** Formatted price (\"\" when the add-on is free or the currency is unknown). */\n// price: string;\n// /** The same amount as a number (0 when none) with its currency — for totals; never render these. */\n// priceAmount: number;\n// currency: string;\n// /** Extra minutes the add-on adds to the session; null for quantity-based add-ons. */\n// durationMinutes: number | null;\n// /** Max units of this add-on per booking; null when it is a one-off (0/1). */\n// maxQuantity: number | null;\n// }\n// export interface AddOnGroup {\n// id: string;\n// name: string;\n// /** The owner's question for this group (\"Add a scalp massage?\"). */\n// prompt: string;\n// /** Max distinct add-ons the visitor may pick from this group; null = unlimited. */\n// maxSelectable: number | null;\n// addOns: AddOn[];\n// }\n// /** A booking-form field (flat, from the form summary + schema; values are keyed by `target`). */\n// export interface BookingFormField {\n// target: string;\n// label: string;\n// type: \"STRING\" | \"EMAIL\" | \"PHONE\" | \"NUMBER\" | \"URL\";\n// options?: string[];\n// /** The form marks it required — the only fields that gate `canBook`; the rest may stay empty. */\n// required: boolean;\n// }\n// /** The outcome of book(): either the browser is being redirected, or it's done. */\n// export type BookingResult = {\n// kind: \"redirect\";\n// url: string;\n// } | {\n// kind: \"confirmed\";\n// bookingId: string;\n// orderId: string | null;\n// };\n// Bookings DTOs — the serializable shapes every hook, component, and page consumes.\n// Plain JSON: safe as Astro island props or across server/client boundaries. Images are\n// resolved https URLs; every displayable price is a ready formatted string (\"\" when the\n// site sent no currency — the surface omits it, never guesses one).\nexport {};\n",
17 "js/wix/services-core.js": "// Declarations of services-core.ts: what this file exports, their arguments and what they return.\n// import type { BookingCategory, BookingsSettings, CourseSpan, CtaState, Deposit, LocationOption, RateType, ServiceDetail, ServiceLocation, ServiceSummary, StaffSummary } from \"./bookings-types.js\";\n// export type Raw = Record<string, any>;\n// export type ImgSrc = (value: any, width: number, height: number) => string;\n// export declare const BOOKINGS_APP_ID = \"13d21c63-b5ec-5912-8397-c3a5ddb27a97\";\n// export declare const STAFF_RESOURCE_TYPE_ID = \"1cd44cf8-756f-41c3-bd90-3e2ffcaf1155\";\n// export declare const SERVICES_CONDITIONAL_FIELDS: readonly [\"STAFF_MEMBER_DETAILS\", \"DISCOUNT_INFO_DETAILS\"];\n// export declare const SERVICES_PAGE_SIZE = 20;\n// export declare const OTHER_LOCATIONS_ID = \"OTHER_LOCATIONS\";\n// export declare const rawId: (raw: Raw | undefined | null) => string;\n// export interface ServicesFilterOptions {\n// slug?: string;\n// categoryId?: string | null;\n// locationId?: string | null;\n// }\n// export declare function servicesFilter({ slug, categoryId, locationId }?: ServicesFilterOptions): Raw;\n// export declare const CATEGORIES_QUERY: Raw;\n// export declare const LOCATIONS_QUERY: Raw;\n// export declare function hasMorePage(meta: Raw | undefined | null, offset: number, count: number, limit: number): boolean;\n// export declare function mediaValue(image: unknown): unknown;\n// export declare function formatPrice(value: string | number | undefined | null, currency: string | undefined | null, locale?: string): string;\n// export declare const formatMoney: (money: Raw | undefined | null, locale?: string) => string;\n// export declare function durationLabel(minutes: number | null | undefined, locale?: string): string;\n// export declare const hasAddOns: (raw: Raw) => boolean;\n// export interface ResolvedPrice {\n// rateType: RateType;\n// price: string;\n// priceFrom: boolean;\n// basePrice: string;\n// discountName: string;\n// calculatedAtCheckout: boolean;\n// hasPricingPlans: boolean;\n// free: boolean;\n// }\n// export declare function resolvePrice(raw: Raw): ResolvedPrice;\n// export declare function depositOf(raw: Raw): Deposit | null;\n// export declare function toLocation(raw: Raw): ServiceLocation;\n// export declare function toLocationOptions(res: Raw | null | undefined): {\n// locations: LocationOption[];\n// hasOtherLocations: boolean;\n// };\n// export declare function toStaff(raw: Raw, imgSrc: ImgSrc): StaffSummary[];\n// export declare function toIso(value: unknown): string | null;\n// export declare function isTooLateToBook(raw: Raw, now?: number): boolean;\n// export declare function ctaStateOf(service: Pick<ServiceSummary, \"type\" | \"requiresManualApproval\" | \"tooLateToBook\">, courseFull?: boolean): CtaState;\n// export declare function courseSpanOf(raw: Raw, now?: number): CourseSpan | null;\n// export declare const isKnownType: (raw: Raw) => boolean;\n// export declare function toSummary(raw: Raw, imgSrc: ImgSrc): ServiceSummary;\n// export declare function toDetail(raw: Raw, imgSrc: ImgSrc): ServiceDetail;\n// export declare function toCategory(raw: Raw): BookingCategory;\n// export declare function toCategories(raws: Raw[]): BookingCategory[];\n// export declare function toBookingsSettings(raw: Raw | null | undefined): BookingsSettings;\n// export declare const DEFAULT_BOOKINGS_SETTINGS: BookingsSettings;\n/** The Wix Bookings app id — the cart's catalogReference.appId and the services filter. */\nexport const BOOKINGS_APP_ID = \"13d21c63-b5ec-5912-8397-c3a5ddb27a97\";\n/** Staff-member resource type id (ANY_RESOURCE fallback + staff filtering). */\nexport const STAFF_RESOURCE_TYPE_ID = \"1cd44cf8-756f-41c3-bd90-3e2ffcaf1155\";\n/**\n * Requested on every service read: without STAFF_MEMBER_DETAILS `staffMemberDetails` is absent (staff\n * = []); without DISCOUNT_INFO_DETAILS `payment.discountInfo` is absent (no discount is ever shown).\n */\nexport const SERVICES_CONDITIONAL_FIELDS = [\"STAFF_MEMBER_DETAILS\", \"DISCOUNT_INFO_DETAILS\"];\n/** Listing page size — Wix's own list pages 20 at a time. */\nexport const SERVICES_PAGE_SIZE = 20;\n/**\n * Reserved location-filter id for \"held somewhere else\" (custom or customer locations) — the same\n * literal Wix's service list accepts in `?location=`, never a real location GUID.\n */\nexport const OTHER_LOCATIONS_ID = \"OTHER_LOCATIONS\";\nconst SERVICE_TYPES = [\"APPOINTMENT\", \"CLASS\", \"COURSE\"];\nconst RATE_TYPES = [\"FIXED\", \"VARIED\", \"CUSTOM\", \"NO_FEE\", \"SUBSCRIPTION\"];\nexport const rawId = (raw) => raw?._id ?? raw?.id ?? \"\";\n/**\n * The filter every services read shares, in REST query-language form: this app's services, never\n * hidden ones (filtered by the SERVER — a page is never under-filled by hidden services), optionally\n * one slug (`mainSlug.name`), one category (`category.id`), one business location\n * (`locations.business.id`) or the \"other locations\" bucket (`locations.type` CUSTOM/CUSTOMER).\n * The SDK transport applies the same rules with its query builder.\n */\nexport function servicesFilter({ slug, categoryId, locationId } = {}) {\n return {\n appId: BOOKINGS_APP_ID,\n hidden: false,\n ...(slug ? { \"mainSlug.name\": slug } : {}),\n ...(categoryId ? { \"category.id\": categoryId } : {}),\n ...(locationId === OTHER_LOCATIONS_ID\n ? { \"locations.type\": { $hasSome: [\"CUSTOM\", \"CUSTOMER\"] } }\n : locationId\n ? { \"locations.business.id\": locationId }\n : {}),\n };\n}\n/** The Query Categories body: only categories that hold at least one bookable service come back. */\nexport const CATEGORIES_QUERY = { filter: { services: {} } };\n/** The Query Locations body: only business locations that host a visible service of this app. */\nexport const LOCATIONS_QUERY = { filter: { services: { appId: BOOKINGS_APP_ID, hidden: false } } };\n/**\n * Whether an offset page has a successor: from the response's total when the server counted, else\n * from a full page (a short page is the last one).\n */\nexport function hasMorePage(meta, offset, count, limit) {\n const total = meta?.total;\n if (typeof total === \"number\" && !meta?.tooManyToCount)\n return offset + count < total;\n return count >= limit;\n}\n/**\n * A media value the image helpers can resolve. The SDK hands over a `wix:image://v1/<id>/<name>#…`\n * string (or an https URL); REST hands over the image OBJECT with a bare file id — rebuilt into the\n * same `wix:image://` form the SDK produces, so imgSrc scales both identically.\n */\nexport function mediaValue(image) {\n if (!image || typeof image === \"string\")\n return image;\n const o = image;\n const url = o.url ?? \"\";\n if (/^(https?:|wix:image:)/.test(url))\n return url;\n if (o.id)\n return `wix:image://v1/${o.id}/${encodeURIComponent(o.filename ?? \"\")}#originWidth=${o.width ?? 0}&originHeight=${o.height ?? 0}`;\n return url;\n}\n/**\n * A Money value → display string, the way Wix's own bookings surfaces format it: whole amounts\n * without decimals (\"€75\"), fractional ones with two (\"€49.99\"); \"\" when the value or the currency\n * is missing — never a guessed currency (a USD default mislabels every non-USD site).\n */\nexport function formatPrice(value, currency, locale) {\n if (value == null || value === \"\" || !currency)\n return \"\";\n const amount = Number(value);\n if (!Number.isFinite(amount))\n return \"\";\n const digits = amount % 1 === 0 ? 0 : 2;\n try {\n return new Intl.NumberFormat(locale, { style: \"currency\", currency, currencyDisplay: \"symbol\", minimumFractionDigits: digits, maximumFractionDigits: digits }).format(amount);\n }\n catch {\n return `${amount} ${currency}`;\n }\n}\n/** Money as the API sends it ({ value, currency }) → display string (\"\" when incomplete). */\nexport const formatMoney = (money, locale) => formatPrice(money?.value, money?.currency, locale);\n/** \"1 hr 30 min\" / \"45 min\" / \"2 hr\" through Intl unit formatting (\"\" when unknown). */\nexport function durationLabel(minutes, locale) {\n if (minutes == null || !Number.isFinite(minutes) || minutes <= 0)\n return \"\";\n try {\n const unit = (u, n) => new Intl.NumberFormat(locale, { style: \"unit\", unit: u, unitDisplay: \"short\" }).format(n);\n if (minutes < 60)\n return unit(\"minute\", minutes);\n const hours = Math.floor(minutes / 60);\n const rest = minutes % 60;\n return rest === 0 ? unit(\"hour\", hours) : `${unit(\"hour\", hours)} ${unit(\"minute\", rest)}`;\n }\n catch {\n return `${minutes} min`;\n }\n}\n/** A service offers add-ons when any of its groups holds add-on ids. */\nexport const hasAddOns = (raw) => (raw.addOnGroups ?? []).some((g) => (g.addOnIds ?? []).length > 0);\n/**\n * The price a tile shows, by `payment.rateType` — Wix's own rule: FIXED → the price; VARIED → the\n * minimum with a \"From\" flag; CUSTOM → the owner's free text; NO_FEE → \"Free\" (Wix shows nothing;\n * a headless brief expects the word). A discount (from DISCOUNT_INFO_DETAILS) yields a concrete\n * discounted price only for a single FIXED price with no add-ons; otherwise it is \"calculated at\n * checkout\". A FIXED price of 0 counts as free too — it books without a checkout.\n */\nexport function resolvePrice(raw) {\n const payment = raw.payment ?? {};\n const rateType = (RATE_TYPES.includes(payment.rateType) ? payment.rateType : \"NO_FEE\");\n const hasPricingPlans = (payment.pricingPlanIds ?? []).length > 0;\n let price = \"\";\n let priceFrom = false;\n let free = false;\n switch (rateType) {\n case \"FIXED\":\n price = formatMoney(payment.fixed?.price);\n free = Number(payment.fixed?.price?.value ?? 0) === 0;\n if (free)\n price = \"Free\";\n break;\n case \"VARIED\":\n price = formatMoney(payment.varied?.minPrice ?? payment.varied?.defaultPrice);\n priceFrom = !!price;\n break;\n case \"CUSTOM\":\n price = payment.custom?.description ?? \"\";\n break;\n case \"NO_FEE\":\n price = \"Free\";\n free = true;\n break;\n default:\n break;\n }\n const discountName = payment.discountInfo?.discountName ?? \"\";\n const after = discountName && rateType === \"FIXED\" && !free && !hasAddOns(raw) ? formatMoney(payment.discountInfo?.priceAfterDiscount) : \"\";\n return {\n rateType,\n price: after || price,\n priceFrom,\n basePrice: after ? price : \"\",\n discountName,\n calculatedAtCheckout: !!discountName && !after,\n hasPricingPlans,\n free,\n };\n}\n/**\n * Deposit terms from the service's payment: `options.deposit` says a deposit is taken; the amount is\n * `fixed|varied.deposit` (a percentage deposit stores no amount — the server resolves it, so the\n * amount stays \"\"); `fullUpfrontPaymentAllowed` lets the visitor pay everything now instead.\n */\nexport function depositOf(raw) {\n const payment = raw.payment ?? {};\n const rate = payment.fixed ?? payment.varied ?? {};\n const value = rate.deposit?.value ?? rate.depositDetails?.amount?.value;\n const takesDeposit = payment.options?.deposit === true || (!!value && Number(value) > 0);\n if (!takesDeposit)\n return null;\n return {\n amount: formatMoney(rate.deposit ?? rate.depositDetails?.amount),\n fullUpfrontAllowed: rate.fullUpfrontPaymentAllowed === true,\n };\n}\n/** A service `locations[]` entry → DTO (BUSINESS carries the location's id and name). */\nexport function toLocation(raw) {\n const type = ([\"BUSINESS\", \"CUSTOM\", \"CUSTOMER\"].includes(raw.type) ? raw.type : \"CUSTOM\");\n return {\n id: type === \"BUSINESS\" ? rawId(raw.business) || rawId(raw) || null : null,\n name: raw.business?.name ?? raw.custom?.address?.formattedAddress ?? (type === \"CUSTOMER\" ? \"Your location\" : \"\"),\n type,\n };\n}\n/** A Query Locations response → the listing's location options (sorted by name) and the \"other locations\" flag. */\nexport function toLocationOptions(res) {\n const locations = (res?.businessLocations?.locations ?? [])\n .map((l) => ({ id: rawId(l.business) || rawId(l), name: l.business?.name ?? \"\" }))\n .filter((l) => l.id)\n .sort((a, b) => a.name.localeCompare(b.name));\n return { locations, hasOtherLocations: res?.customLocations?.exists === true || res?.customerLocations?.exists === true };\n}\n/** Staff from `staffMemberDetails` (needs STAFF_MEMBER_DETAILS), photo through the injected imgSrc. */\nexport function toStaff(raw, imgSrc) {\n return (raw.staffMemberDetails?.staffMembers ?? [])\n .map((m) => ({ id: m.staffMemberId ?? \"\", name: m.name ?? \"\", imageUrl: imgSrc(mediaValue(m.mainMedia?.image), 160, 160) }))\n .filter((m) => m.id);\n}\n/** An API date (Date on the SDK, ISO string on REST) → ISO string, or null. */\nexport function toIso(value) {\n if (!value)\n return null;\n const d = value instanceof Date ? value : new Date(String(value));\n return Number.isNaN(d.getTime()) ? null : d.toISOString();\n}\nconst isMissingOrPast = (iso, now) => !iso || new Date(iso).getTime() <= now;\n/**\n * Whether policy already forbids booking (Wix's rule): the late-booking limit before the first\n * session has passed, or the first session started and booking after start is off, or a course's\n * last session ended. Only meaningful for services with a fixed first session (classes, courses).\n */\nexport function isTooLateToBook(raw, now = Date.now()) {\n const policy = raw.bookingPolicy ?? {};\n const afterStart = policy.bookAfterStartPolicy?.enabled === true;\n const first = toIso(raw.schedule?.firstSessionStart);\n const last = toIso(raw.schedule?.lastSessionEnd);\n let tooLate = false;\n if (!afterStart && first && policy.limitLateBookingPolicy?.enabled) {\n const latest = new Date(first).getTime() - (policy.limitLateBookingPolicy.latestBookingInMinutes ?? 0) * 60_000;\n tooLate = now > latest;\n }\n const courseEnded = raw.type === \"COURSE\" && isMissingOrPast(last, now);\n const started = !!first && isMissingOrPast(first, now);\n return courseEnded || (started && !afterStart) || tooLate;\n}\n/**\n * The CTA state: a course that is full or too late to book is only viewable; manual approval turns\n * \"Book\" into \"Request to book\"; otherwise book. `courseFull` comes from the course's sessions\n * (the booking flow supplies it; the listing passes false).\n */\nexport function ctaStateOf(service, courseFull = false) {\n if (service.type === \"COURSE\" && (courseFull || service.tooLateToBook))\n return \"viewCourse\";\n if (service.requiresManualApproval)\n return \"requestToBook\";\n return \"book\";\n}\n/** A COURSE's span from its schedule; null for other types. */\nexport function courseSpanOf(raw, now = Date.now()) {\n if (raw.type !== \"COURSE\")\n return null;\n const endDate = toIso(raw.schedule?.lastSessionEnd);\n return { startDate: toIso(raw.schedule?.firstSessionStart), endDate, ended: isMissingOrPast(endDate, now) };\n}\n/** True for the three known types; an unknown type never becomes a DTO (Wix's own list would not render it either). */\nexport const isKnownType = (raw) => SERVICE_TYPES.includes(raw.type);\nexport function toSummary(raw, imgSrc) {\n const priced = resolvePrice(raw);\n const durationMinutes = raw.schedule?.availabilityConstraints?.sessionDurations?.[0] ?? null;\n const type = (isKnownType(raw) ? raw.type : \"APPOINTMENT\");\n const requiresManualApproval = raw.onlineBooking?.requireManualApproval === true;\n const tooLateToBook = isTooLateToBook(raw);\n return {\n id: rawId(raw),\n slug: raw.mainSlug?.name ?? raw.supportedSlugs?.[0]?.name ?? \"\",\n name: raw.name ?? \"\",\n tagLine: raw.tagLine ?? \"\",\n type,\n ...priced,\n durationMinutes,\n durationLabel: durationLabel(durationMinutes),\n imageUrl: imgSrc(mediaValue(raw.media?.mainMedia?.image), 800, 800),\n categoryId: rawId(raw.category) || null,\n categoryName: raw.category?.name ?? \"\",\n scheduleId: rawId(raw.schedule) || null,\n staff: toStaff(raw, imgSrc),\n locations: (raw.locations ?? []).map(toLocation),\n conferencing: raw.conferencing?.enabled === true,\n hasAddOns: hasAddOns(raw),\n requiresManualApproval,\n onlineBookingEnabled: raw.onlineBooking?.enabled !== false,\n tooLateToBook,\n ctaState: ctaStateOf({ type, requiresManualApproval, tooLateToBook }),\n defaultCapacity: raw.defaultCapacity ?? null,\n maxParticipantsPerBooking: raw.bookingPolicy?.participantsPolicy?.maxParticipantsPerBooking ?? null,\n deposit: depositOf(raw),\n offeredDays: [],\n };\n}\nexport function toDetail(raw, imgSrc) {\n const summary = toSummary(raw, imgSrc);\n const online = raw.payment?.options?.online === true;\n const inPerson = raw.payment?.options?.inPerson === true;\n // Derive — never hardcode \"ONLINE\": a free/pay-in-person service booked ONLINE gets rejected by the\n // cart with INSUFFICIENT_INVENTORY. Online-only → ONLINE; in-person-only → OFFLINE; both/neither → ONLINE.\n const paymentOption = !online && inPerson ? \"OFFLINE\" : \"ONLINE\";\n return {\n ...summary,\n description: raw.description ?? \"\",\n formId: rawId(raw.form) || null,\n paymentOption,\n cancellationFeeEnabled: raw.bookingPolicy?.cancellationFeePolicy?.enabled === true,\n course: courseSpanOf(raw),\n };\n}\nexport function toCategory(raw) {\n return { id: rawId(raw), name: raw.name ?? \"\" };\n}\n/** Categories in the owner's order (`sortOrder`), ids present. */\nexport function toCategories(raws) {\n return [...raws].sort((a, b) => (a.sortOrder ?? 0) - (b.sortOrder ?? 0)).map(toCategory).filter((c) => c.id);\n}\n/** Bookings settings (the SDK returns the entity, REST wraps it in `bookingsSettings`); the Wix default is the business zone. */\nexport function toBookingsSettings(raw) {\n const settings = raw?.bookingsSettings ?? raw ?? {};\n return {\n displayTimeZone: settings.displayTimeZone?.basedOn === \"CUSTOMER_TIME_ZONE\" ? \"CUSTOMER\" : \"BUSINESS\",\n customerCanChange: settings.displayTimeZone?.customerCanChange === true,\n };\n}\n/** The settings when they can't be read — Wix's own fallback: business zone, no switching. */\nexport const DEFAULT_BOOKINGS_SETTINGS = { displayTimeZone: \"BUSINESS\", customerCanChange: false };\n",
18 "js/wix/services-store.js": "// Declarations of services-store.ts: what this file exports, their arguments and what they return.\n// import { OTHER_LOCATIONS_ID } from \"./services.js\";\n// import type { BookingCategory, LocationOption, ServiceSummary } from \"./bookings-types.js\";\n// export { OTHER_LOCATIONS_ID };\n// export interface ServicesStoreOptions {\n// initialServices?: ServiceSummary[];\n// initialHasMore?: boolean;\n// initialCategories?: BookingCategory[];\n// initialLocations?: {\n// locations: LocationOption[];\n// hasOtherLocations: boolean;\n// };\n// initialCategoryId?: string | null;\n// initialLocationId?: string | null;\n// pageSize?: number;\n// syncUrl?: boolean;\n// }\n// export interface ServicesState {\n// services: ServiceSummary[] | null;\n// categories: BookingCategory[];\n// locations: LocationOption[];\n// hasOtherLocations: boolean;\n// activeCategoryId: string | null;\n// activeLocationId: string | null;\n// hasMore: boolean;\n// loadingMore: boolean;\n// error: string | null;\n// }\n// export interface ServicesStore {\n// getState(): ServicesState;\n// subscribe(listener: (state: ServicesState) => void): () => void;\n// start(): void;\n// stop(): void;\n// setActiveCategoryId(id: string | null): void;\n// setActiveLocationId(id: string | null): void;\n// loadMore(): Promise<void>;\n// }\n// export declare function createServicesStore({ initialServices, initialHasMore, initialCategories, initialLocations, initialCategoryId, initialLocationId, pageSize, syncUrl, }?: ServicesStoreOptions): ServicesStore;\n// The services listing as a framework-free store — the logic behind useServices, usable from React\n// (useServices wraps it with useSyncExternalStore), from a static page or Vue/Svelte (subscribe and\n// render), or as the specification for a port. State, actions, subscribe/getState, emit after every\n// change. Category and location filters are applied by Wix (a fresh first page per selection, as its\n// own service list does), pages are 20 long with `hasMore`/`loadMore()`, and the selection lives in\n// the URL (`?category=`, `?location=`) so a filtered list is a link. After every page ONE batched\n// calendar call fills `offeredDays` on the classes and courses of that page.\n// SSR-friendly: seed with `initialServices` (+ `initialHasMore`, `initialCategories`, `initialLocations`)\n// and nothing is fetched for the first paint; without them `start()` loads everything.\n// One store per mounted listing: createServicesStore(), not a singleton.\nimport { fetchOfferedDays } from \"./booking.js\";\nimport { OTHER_LOCATIONS_ID, SERVICES_PAGE_SIZE, fetchBookingCategories, fetchLocations, fetchServices } from \"./services.js\";\nexport { OTHER_LOCATIONS_ID };\nconst CATEGORY_PARAM = \"category\";\nconst LOCATION_PARAM = \"location\";\nconst readParam = (name) => {\n if (typeof window === \"undefined\")\n return null;\n try {\n return new URL(window.location.href).searchParams.get(name) || null;\n }\n catch {\n return null;\n }\n};\nconst writeParams = (values) => {\n if (typeof window === \"undefined\" || typeof history === \"undefined\")\n return;\n try {\n const url = new URL(window.location.href);\n for (const [k, v] of Object.entries(values))\n v ? url.searchParams.set(k, v) : url.searchParams.delete(k);\n history.replaceState(history.state, \"\", url.toString());\n }\n catch {\n /* an unusual URL — the selection still applies, it just isn't shareable */\n }\n};\nexport function createServicesStore({ initialServices, initialHasMore = false, initialCategories, initialLocations, initialCategoryId = null, initialLocationId = null, pageSize = SERVICES_PAGE_SIZE, syncUrl = true, } = {}) {\n let services = initialServices ?? null;\n let categories = initialCategories ?? [];\n let locations = initialLocations?.locations ?? [];\n let hasOtherLocations = initialLocations?.hasOtherLocations ?? false;\n let activeCategoryId = initialCategoryId;\n let activeLocationId = initialLocationId;\n let hasMore = initialHasMore;\n let loadingMore = false;\n let error = null;\n let started = false;\n let generation = 0;\n const listeners = new Set();\n let snapshot = null;\n const emit = () => {\n snapshot = null;\n for (const fn of listeners)\n fn();\n };\n function getState() {\n if (snapshot)\n return snapshot;\n snapshot = { services, categories, locations, hasOtherLocations, activeCategoryId, activeLocationId, hasMore, loadingMore, error };\n return snapshot;\n }\n // One batched MASTER-events call per page: the weekdays every class/course on it meets (appointments have none).\n function fillOfferedDays(items, id) {\n const scheduleIds = [...new Set(items.filter((s) => s.type !== \"APPOINTMENT\").map((s) => s.scheduleId).filter((x) => !!x))];\n if (!scheduleIds.length)\n return;\n fetchOfferedDays(scheduleIds).then((days) => {\n if (!started || generation !== id || !services)\n return;\n services = services.map((s) => (s.scheduleId && days[s.scheduleId] ? { ...s, offeredDays: days[s.scheduleId] } : s));\n emit();\n });\n }\n // The selection changed: a fresh first page from Wix (the filter is applied there, never to a page in hand).\n function loadFirstPage() {\n if (!started)\n return;\n const id = ++generation;\n services = null;\n hasMore = false;\n loadingMore = false; // an in-flight loadMore belongs to the old selection\n error = null;\n emit();\n fetchServices({ limit: pageSize, offset: 0, categoryId: activeCategoryId, locationId: activeLocationId })\n .then((page) => {\n if (!started || generation !== id)\n return; // superseded — drop it\n services = page.items;\n hasMore = page.hasMore;\n emit();\n fillOfferedDays(page.items, id);\n })\n .catch((e) => {\n if (!started || generation !== id)\n return;\n services = [];\n error = e instanceof Error ? e.message : String(e);\n emit();\n });\n }\n return {\n getState,\n subscribe(fn) {\n const listener = () => { try {\n fn(getState());\n }\n catch (e) {\n console.error(\"services-store listener failed\", e);\n } };\n listeners.add(listener);\n return () => listeners.delete(listener);\n },\n start() {\n if (started)\n return;\n started = true;\n // The URL is the source of truth for the selection; a seeded page fetched with a different one is stale.\n const urlCategory = syncUrl ? readParam(CATEGORY_PARAM) : null;\n const urlLocation = syncUrl ? readParam(LOCATION_PARAM) : null;\n const selectionChanged = syncUrl && ((urlCategory ?? null) !== activeCategoryId || (urlLocation ?? null) !== activeLocationId);\n if (selectionChanged) {\n activeCategoryId = urlCategory;\n activeLocationId = urlLocation;\n }\n if (!initialServices || selectionChanged)\n loadFirstPage();\n else\n fillOfferedDays(initialServices, generation);\n if (!initialCategories)\n fetchBookingCategories().then((c) => { if (started) {\n categories = c;\n emit();\n } });\n if (!initialLocations)\n fetchLocations().then((l) => { if (started) {\n locations = l.locations;\n hasOtherLocations = l.hasOtherLocations;\n emit();\n } });\n },\n stop() {\n started = false;\n generation++;\n },\n setActiveCategoryId(id) {\n if (id === activeCategoryId)\n return;\n activeCategoryId = id;\n if (syncUrl)\n writeParams({ [CATEGORY_PARAM]: id });\n loadFirstPage();\n },\n setActiveLocationId(id) {\n if (id === activeLocationId)\n return;\n activeLocationId = id;\n if (syncUrl)\n writeParams({ [LOCATION_PARAM]: id });\n loadFirstPage();\n },\n async loadMore() {\n if (!started || loadingMore || !hasMore || !services)\n return;\n const id = generation;\n loadingMore = true;\n emit();\n try {\n const page = await fetchServices({ limit: pageSize, offset: services.length, categoryId: activeCategoryId, locationId: activeLocationId });\n if (!started || generation !== id)\n return;\n services = [...services, ...page.items];\n hasMore = page.hasMore;\n fillOfferedDays(page.items, id);\n }\n catch (e) {\n if (!started || generation !== id)\n return;\n error = e instanceof Error ? e.message : String(e);\n }\n finally {\n if (generation === id)\n loadingMore = false;\n emit();\n }\n },\n };\n}\n",
19 "js/wix/services.js": "// Declarations of services.ts: what this file exports, their arguments and what they return.\n// import { BOOKINGS_APP_ID, OTHER_LOCATIONS_ID, SERVICES_PAGE_SIZE, STAFF_RESOURCE_TYPE_ID, type ServicesFilterOptions } from \"./services-core.js\";\n// import type { BookingCategory, LocationOption, ServiceDetail, ServiceSummary } from \"./bookings-types.js\";\n// export { BOOKINGS_APP_ID, OTHER_LOCATIONS_ID, SERVICES_PAGE_SIZE, STAFF_RESOURCE_TYPE_ID };\n// export interface FetchServicesOptions extends ServicesFilterOptions {\n// limit?: number;\n// offset?: number;\n// }\n// export interface ServicesPage {\n// items: ServiceSummary[];\n// hasMore: boolean;\n// }\n// export declare function fetchServices({ limit, offset, categoryId, locationId }?: FetchServicesOptions): Promise<ServicesPage>;\n// export declare function fetchServiceBySlug(slug: string): Promise<ServiceDetail | null>;\n// export declare function fetchBookingCategories(): Promise<BookingCategory[]>;\n// export declare function fetchLocations(): Promise<{\n// locations: LocationOption[];\n// hasOtherLocations: boolean;\n// }>;\n// Service reads over REST — the twin of app/wix/bookings/services.ts. Same exports, same DTOs; the\n// rules and mappers come from services-core (the SAME file the SDK transport uses, deployed flat\n// next to this one by deploy.mjs --stack static), so this file is only the transport: one fetch with\n// a literal body per function. Every call here is safe from a browser with a visitor token.\n// Porting: keep the paths, keep the bodies, port the core once.\n// docs: https://dev.wix.com/docs/api-reference/business-solutions/bookings/services/services-v2/query-services.md\n// docs: https://dev.wix.com/docs/api-reference/business-solutions/bookings/services/services-v2/query-categories.md\n// docs: https://dev.wix.com/docs/api-reference/business-solutions/bookings/services/services-v2/query-locations.md\nimport { wixRequest } from \"./client.js\";\nimport { imgSrc } from \"./media.js\";\nimport { BOOKINGS_APP_ID, CATEGORIES_QUERY, LOCATIONS_QUERY, OTHER_LOCATIONS_ID, SERVICES_CONDITIONAL_FIELDS, SERVICES_PAGE_SIZE, STAFF_RESOURCE_TYPE_ID, hasMorePage, isKnownType, servicesFilter, toCategories, toDetail, toLocationOptions, toSummary, } from \"./services-core.js\";\nexport { BOOKINGS_APP_ID, OTHER_LOCATIONS_ID, SERVICES_PAGE_SIZE, STAFF_RESOURCE_TYPE_ID };\n// POST /bookings/v2/services/query { query: { filter, paging: { limit, offset } }, conditionalFields }\nasync function queryServices(filter, limit, offset) {\n return wixRequest(\"/bookings/v2/services/query\", {\n body: { query: { filter: servicesFilter(filter), paging: { limit, offset } }, conditionalFields: SERVICES_CONDITIONAL_FIELDS },\n });\n}\n/**\n * One page of bookable services (this app's, never hidden — filtered by the server), optionally one\n * category and/or one business location (or OTHER_LOCATIONS_ID for custom/customer locations),\n * mapped to grid-ready DTOs. Wix pages 20 at a time; `hasMore` says whether to ask for the next.\n */\nexport async function fetchServices({ limit = SERVICES_PAGE_SIZE, offset = 0, categoryId, locationId } = {}) {\n const res = await queryServices({ categoryId, locationId }, limit, offset);\n const raws = (res?.services ?? []).filter(isKnownType);\n return { items: raws.map((s) => toSummary(s, imgSrc)), hasMore: hasMorePage(res?.pagingMetadata, offset, (res?.services ?? []).length, limit) };\n}\n/** One service by its URL slug (mainSlug.name) — a query with the slug in the filter. Null when not found. */\nexport async function fetchServiceBySlug(slug) {\n const raw = ((await queryServices({ slug }, 1, 0))?.services ?? [])[0];\n return raw && isKnownType(raw) ? toDetail(raw, imgSrc) : null;\n}\n/**\n * Service categories for a filter bar — only categories that hold a bookable service, in the owner's\n * order. Non-fatal (empty array on failure).\n * POST /bookings/v2/services/categories/query { filter: { services: {} } }\n */\nexport async function fetchBookingCategories() {\n try {\n const res = await wixRequest(\"/bookings/v2/services/categories/query\", { body: CATEGORIES_QUERY });\n return toCategories((res?.categories ?? []));\n }\n catch {\n return [];\n }\n}\n/**\n * Business locations that host a visible service (a listing filter, sorted by name) plus whether any\n * service is held at a custom/customer location (the OTHER_LOCATIONS_ID bucket). Non-fatal.\n * POST /bookings/v2/services/locations/query { filter: { services: { appId, hidden: false } } }\n */\nexport async function fetchLocations() {\n try {\n const res = await wixRequest(\"/bookings/v2/services/locations/query\", { body: LOCATIONS_QUERY });\n return toLocationOptions(res);\n }\n catch {\n return { locations: [], hasOtherLocations: false };\n }\n}\n"