← Files WixARCHIVED FILE

skills/wix-headless-templates/rentals/project/src/wix/rentals/rentals.ts

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

↓ Download file

See the change to this file →

// Rentals over the SDK — the only file that touches raw Bookings entities on this transport: the
// rental catalog (Services V2 filtered to the Rentals app), start availability, hourly end options,
// the daily walk's day list, the price preview, the booking form, and the
// createBooking → Cart V2 → checkout-or-place sequence. Every body is built in ./rentals-core
// (shared with the REST twin in templates/rentals/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 (renting) and silent-with-a-default where they only inform (form, preview).
// docs: https://dev.wix.com/docs/api-reference/business-solutions/bookings/services/services-v2/query-services.md
// 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-availability-time-slot-end-options.md
// docs: https://dev.wix.com/docs/api-reference/business-solutions/bookings/pricing/pricing-api/preview-price.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
import { availabilityTimeSlots, bookings as bookingsModule, pricing as pricingModule, 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 { imgSrc } from "../media";
import {
  FALLBACK_FIELDS,
  RENTALS_APP_ID,
  RENTALS_FORM_ID,
  RENTALS_PAGE_SIZE,
  bookingIdOf,
  bookingOptions,
  bookingRequest,
  cartIdOf,
  cartRequest,
  checkoutRedirectRequest,
  checkoutRequired,
  confirmedResult,
  contactOf,
  dailyEndOptions,
  defaultTimeZone,
  endOptionsRequest,
  friendlyError,
  isRental,
  nextSlotsCursor,
  previewRequest,
  startsRequest,
  toDetail,
  toFormFields,
  toHourlyEndOptions,
  toQuote,
  toStartOptions,
  toSummary,
  type Raw,
  type StartWindow,
} from "./rentals-core";
import type { EndOption, RentalDetail, RentalFormField, RentalQuote, RentalResult, RentalSummary, StartOption } from "./types";

export { RENTALS_APP_ID, RENTALS_FORM_ID, RENTALS_PAGE_SIZE, dailyEndOptions, defaultTimeZone };

const services = wixModule(servicesModule);
const slots = wixModule(availabilityTimeSlots);
const bookings = wixModule(bookingsModule);
const pricing = wixModule(pricingModule);
const cart = wixModule({ createCart, calculateCart, placeOrder });
const redirects = wixModule(redirectsModule);

export interface RentalsPage {
  items: RentalSummary[];
  /** Another page follows — pass `offset + items.length`. */
  hasMore: boolean;
}

/**
 * One page of rentals: Bookings services carrying the Rentals app id (the filter that keeps haircuts
 * out of a rooms catalog on a site that has both), not hidden, with a duration range.
 */
export async function fetchRentals({ limit = RENTALS_PAGE_SIZE, offset = 0 }: { limit?: number; offset?: number } = {}): Promise<RentalsPage> {
  const res = await services.queryServices().eq("appId", RENTALS_APP_ID).eq("hidden", false).limit(limit).skip(offset).find();
  return { items: ((res.items ?? []) as Raw[]).filter(isRental).map((r) => toSummary(r, imgSrc)), hasMore: res.hasNext() };
}

/** One rental by its URL slug (mainSlug.name); null when not found or not a rental. */
export async function fetchRentalBySlug(slug: string): Promise<RentalDetail | null> {
  const res = await services.queryServices().eq("mainSlug.name", slug).eq("appId", RENTALS_APP_ID).eq("hidden", false).limit(1).find();
  const raw = res.items?.[0] as Raw | undefined;
  return raw && isRental(raw) ? toDetail(raw, imgSrc) : null;
}

export interface StartsPage {
  options: StartOption[];
  /** The zone the server computed in — the one requested, or the business zone when none was sent. */
  timeZone: string | null;
}

/**
 * The starts a customer can pick in [start of `from`'s day, +days): times for an hourly rental, one
 * per available day for a daily one; follows the cursor until the window is complete. Each start
 * carries the resource it books.
 */
export async function fetchStarts(rental: Pick<RentalDetail, "id" | "unit" | "resourceTypeId">, window: StartWindow = {}): Promise<StartsPage> {
  const raws: Raw[] = [];
  let timeZone: string | null = null;
  let cursor: string | undefined;
  do {
    const res: Raw = await slots.listAvailabilityTimeSlots(startsRequest(rental, { ...window, cursor }) as any);
    raws.push(...((res.timeSlots ?? []) as Raw[]));
    timeZone ??= res.timeZone ?? null;
    cursor = nextSlotsCursor(res) ?? undefined;
  } while (cursor);
  return { options: toStartOptions(raws, rental.unit), timeZone };
}

/**
 * The lengths a customer can pick for a chosen start. HOURLY: the server's end options (capped by the
 * rental's maximum and by the resource's next booking). DAILY: the consecutive available days from the
 * start, walked here from the starts already fetched (`availableDayKeys`).
 */
export async function fetchEndOptions(rental: Pick<RentalDetail, "id" | "unit" | "minUnits" | "maxUnits">, start: StartOption, { timeZone, availableDayKeys = [] }: { timeZone?: string | null; availableDayKeys?: string[] } = {}): Promise<EndOption[]> {
  if (rental.unit === "DAY") return dailyEndOptions(start, availableDayKeys, rental);
  const res: Raw = await slots.listAvailabilityTimeSlotEndOptions(rental.id, endOptionsRequest(start, timeZone, "_id") as any);
  return toHourlyEndOptions(res, start);
}

/** The server's price for a start and length; falls back to rate × length when the preview fails. */
export async function fetchQuote(rental: Pick<RentalDetail, "id" | "rateAmount" | "currency" | "unit">, start: StartOption, end: EndOption, timeZone: string = defaultTimeZone()): Promise<RentalQuote> {
  try {
    const res: Raw = await pricing.previewPrice(previewRequest(rental, start, end.endLocal, timeZone) as any);
    return toQuote(res, rental, end.units);
  } catch {
    return toQuote(null, rental, end.units);
  }
}

/** The booking form's fields (the Rentals default form on a seeded site); contact basics when unreadable. */
export async function fetchRentalForm(formId: string | null): Promise<RentalFormField[]> {
  const id = encodeURIComponent(formId ?? RENTALS_FORM_ID);
  try {
    const json = async (path: string): Promise<Raw | null> => {
      const r = await wixFetch(path);
      return r.ok ? ((await r.json()) as Raw) : null;
    };
    const [summary, form] = await Promise.all([json(`/form-schema-service/v4/forms/${id}/summary`), json(`/form-schema-service/v4/forms/${id}`).catch(() => null)]);
    return summary ? toFormFields(summary.formSummary, form?.form) : FALLBACK_FIELDS;
  } catch {
    return FALLBACK_FIELDS;
  }
}

/**
 * Rent: createBooking (the start's resource, the customer's end) → createCart (holds it, carries the
 * contact and location, the Rentals app id on the line) → calculateCart → hosted checkout (paid) or
 * placeOrder (free / pay-in-person). Call from the browser. Throws with a friendly message on refusal.
 */
export async function rentResource(rental: RentalDetail, start: StartOption, end: EndOption, formValues: Record<string, unknown>, timeZone: string = defaultTimeZone()): Promise<RentalResult> {
  let created: Raw;
  try {
    created = await bookings.createBooking(bookingRequest(rental, start, end.endLocal, timeZone, "_id") as any, bookingOptions(formValues) as any);
  } catch (e) {
    throw new Error(friendlyError(e instanceof Error ? e.message : String(e)));
  }
  const bookingId = bookingIdOf(created);
  if (!bookingId) throw new Error("The rental couldn't be booked — the time may have just been taken.");

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

  const calc: Raw = await cart.calculateCart(cartId);
  if (checkoutRequired(rental, 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: 8bb0760f702d6131e7b05cc8dcf5ede0946aaa722c6777866e7ec8d7bbb77650