diff --git a/skills/wix-headless/references/astro/bookings/COMPONENTS.md b/skills/wix-headless/references/astro/bookings/COMPONENTS.md index 5f23cd2d..fd78c830 100644 --- a/skills/wix-headless/references/astro/bookings/COMPONENTS.md +++ b/skills/wix-headless/references/astro/bookings/COMPONENTS.md @@ -13,13 +13,57 @@ Read `references/shared/IMPLEMENTER.md` + `references/shared/STYLING.md` first. | File | Purpose | |------|---------| | `src/components/ServiceCard.astro` | Static card for the services listing grid | -| `src/components/AvailabilityCalendar.tsx` | React island — date picker + slot grid | -| `src/components/BookingForm.tsx` | React island — booking details form + submission | +| `src/components/VariantSelector.tsx` | React island — option picker for VARIED-rate services (step 1) | +| `src/components/AvailabilityCalendar.tsx` | React island — date picker + slot grid (step 2) | +| `src/components/BookingForm.tsx` | React island — booking details form + submission (step 3) | Do NOT write any `.css` files — `components-bookings.css` is pre-copied and must not be modified. --- +## VariantSelector.tsx + +A `client:only="react"` island shown **only for `VARIED`-rate services**, as step 1 of the booking flow (before the calendar). Fetches the service's option and per-variant prices, renders them as a button grid, calls `onVariantSelected(variant)` on pick. + +**Props:** +```typescript +interface Props { + serviceId: string; + serviceName: string; + onVariantSelected: (variant: SelectedVariant) => void; +} + +export interface SelectedVariant { + optionId: string; + optionType: "CUSTOM" | "STAFF_MEMBER" | "DURATION"; + custom?: string; // CUSTOM — the choice label, e.g. "Student" + staffMemberId?: string; // STAFF_MEMBER — resource ID + durationMinutes?: number; // DURATION + label: string; // display label shown in the form header + price: { value: string; currency: string }; +} +``` + +**SDK wiring:** +```typescript +import { serviceOptionsAndVariants } from "@wix/bookings"; +// getServiceOptionsAndVariantsByServiceId returns { serviceVariants: { ... } } +// Note the key is "serviceVariants" (not "serviceOptionsAndVariants") for this endpoint. +const result = await wixClient.serviceOptionsAndVariants.getServiceOptionsAndVariantsByServiceId(serviceId); +const sv = (result as any).serviceVariants; // TS doesn't know this key — cast required +const option = sv?.options?.values?.[0]; // only 1 option per service +// variants: sv?.variants?.values[] each has .choices[0] + .price +``` + +**Option types + choice field:** +- `CUSTOM` → `option.customData.name` (label), `choice.custom` (value like "Adult") +- `DURATION` → `option.durationData.name` (label), `choice.duration.minutes` / `choice.duration.name` +- `STAFF_MEMBER` → `choice.staffMemberId` (resource ID). Name resolution requires a separate staff-members query with elevation — pass a `staffId→name` map as a prop from SSR or show a generic label. + +Shows a `"Booking options are not yet available"` message when no variants are configured (rather than returning `null`, which would silently empty the booking section). `ServiceBookingFlow` stays gated at this step — the calendar never appears. Note this condition in `errors` on return: the merchant must configure variants in the dashboard before the service is bookable. + +--- + ## ServiceCard.astro A static Astro component. No client-side interactivity — just props in, HTML out. diff --git a/skills/wix-headless/references/astro/templates/bookings/BookingForm.tsx b/skills/wix-headless/references/astro/templates/bookings/BookingForm.tsx index bb56e40b..543b5265 100644 --- a/skills/wix-headless/references/astro/templates/bookings/BookingForm.tsx +++ b/skills/wix-headless/references/astro/templates/bookings/BookingForm.tsx @@ -2,6 +2,7 @@ import { useState } from "react"; import { createClient, OAuthStrategy } from "@wix/sdk"; import { bookings } from "@wix/bookings"; import type { SelectedSlot } from "./AvailabilityCalendar"; +import type { SelectedVariant } from "./VariantSelector"; import { WIX_CLIENT_ID } from "astro:env/client"; // browser client ID, NOT import.meta.env // BookingForm.tsx — client:only="react" island. Renders after a slot is picked. @@ -15,6 +16,7 @@ interface Props { serviceName: string; serviceType: "APPOINTMENT" | "CLASS"; slot: SelectedSlot; + selectedVariant?: SelectedVariant; // present only for VARIED-rate services onSuccess: (bookingId: string, startDate: string) => void; onCancel: () => void; } @@ -31,7 +33,7 @@ const slotDisplay = (slot: SelectedSlot) => }) : ""; -export default function BookingForm({ serviceName, serviceType, slot, onSuccess, onCancel }: Props) { +export default function BookingForm({ serviceName, serviceType, slot, selectedVariant, onSuccess, onCancel }: Props) { const [firstName, setFirstName] = useState(""); const [lastName, setLastName] = useState(""); const [email, setEmail] = useState(""); @@ -78,8 +80,37 @@ export default function BookingForm({ serviceName, serviceType, slot, onSuccess, } setSubmitting(true); try { + // For VARIED services the API requires participantsChoices (not totalParticipants). + // The choice shape mirrors the service-options-and-variants API: + // CUSTOM → { optionId, custom: "Student" } + // DURATION → { optionId, duration: { minutes: 30 } } + // STAFF_MEMBER → { optionId, staffMemberId: "" } + // Verify the exact participantsChoices.serviceChoices nesting against the live + // API before shipping — the SDK types expose it as `as any` here intentionally. + const participantsChoices = selectedVariant + ? { + serviceChoices: [ + { + numberOfParticipants: participants, + choices: [ + { + optionId: selectedVariant.optionId, + ...(selectedVariant.custom !== undefined + ? { custom: selectedVariant.custom } + : selectedVariant.staffMemberId + ? { staffMemberId: selectedVariant.staffMemberId } + : { duration: { minutes: selectedVariant.durationMinutes ?? 0 } }), + }, + ], + }, + ], + } + : undefined; + const result = await wixClient.bookings.createBooking({ - totalParticipants: participants, // party size + ...(participantsChoices + ? { participantsChoices } + : { totalParticipants: participants }), contactDetails: contactDetails(), selectedPaymentOption: "OFFLINE" as const, bookedEntity: { slot: buildSlot() }, @@ -148,8 +179,19 @@ export default function BookingForm({ serviceName, serviceType, slot, onSuccess,

{isWaitlist ? "Join the waitlist" : "Reserve your spot"}

- {serviceName} · {slotDisplay(slot)}{slot.instructorName ? ` · with ${slot.instructorName}` : ""} + {serviceName} + {selectedVariant && ` · ${selectedVariant.label}`} + {" · "}{slotDisplay(slot)} + {slot.instructorName ? ` · with ${slot.instructorName}` : ""}

+ {selectedVariant && ( +

+ {new Intl.NumberFormat("en-US", { + style: "currency", + currency: selectedVariant.price.currency ?? "USD", + }).format(Number(selectedVariant.price.value))} +

+ )} {isWaitlist &&

This session is full — join the waitlist and we'll notify you if a spot opens.

}
diff --git a/skills/wix-headless/references/astro/templates/bookings/ServiceBookingFlow.tsx b/skills/wix-headless/references/astro/templates/bookings/ServiceBookingFlow.tsx index 69547f73..fc838088 100644 --- a/skills/wix-headless/references/astro/templates/bookings/ServiceBookingFlow.tsx +++ b/skills/wix-headless/references/astro/templates/bookings/ServiceBookingFlow.tsx @@ -1,21 +1,27 @@ import { useState } from "react"; import AvailabilityCalendar from "./AvailabilityCalendar"; import type { SelectedSlot } from "./AvailabilityCalendar"; +import VariantSelector from "./VariantSelector"; +import type { SelectedVariant } from "./VariantSelector"; import BookingForm from "./BookingForm"; -// ServiceBookingFlow.tsx — client:only="react" coordinator island. Holds the -// selected-slot state shared between AvailabilityCalendar and BookingForm, -// transitions between them, and redirects to the confirmation page on success. -// serviceType MUST be threaded through so the calendar picks the right time-slots -// API and the form builds the right createBooking shape. +// ServiceBookingFlow.tsx — client:only="react" coordinator island. +// Three-step flow for VARIED-rate services: +// 1. VariantSelector — pick an option (e.g. "Adult / Student") and see its price +// 2. AvailabilityCalendar — pick a time slot +// 3. BookingForm — contact details + confirm +// For FIXED / NO_FEE / CUSTOM services step 1 is skipped. +// serviceType MUST be threaded through so the calendar and form pick the right APIs. interface Props { serviceId: string; serviceName: string; serviceType: "APPOINTMENT" | "CLASS"; + rateType?: string; // "FIXED" | "NO_FEE" | "VARIED" | "CUSTOM"; absent → treated as FIXED } -export default function ServiceBookingFlow({ serviceId, serviceName, serviceType }: Props) { +export default function ServiceBookingFlow({ serviceId, serviceName, serviceType, rateType }: Props) { + const [selectedVariant, setSelectedVariant] = useState(null); const [selectedSlot, setSelectedSlot] = useState(null); const handleSuccess = (bookingId: string, startDate?: string) => { @@ -26,6 +32,21 @@ export default function ServiceBookingFlow({ serviceId, serviceName, serviceType window.location.href = `/booking-confirmation?${params.toString()}`; }; + // Step 1 — VARIED only: pick a variant before picking a slot. + // VariantSelector returns null when no variants are configured, which means + // onVariantSelected is never called — the calendar never appears. Guard for that + // by rendering null (booking unavailable until variants are set up in the dashboard). + if (rateType === "VARIED" && !selectedVariant) { + return ( + + ); + } + + // Step 2 — pick a time slot. if (!selectedSlot) { return ( setSelectedSlot(null)} /> diff --git a/skills/wix-headless/references/astro/templates/bookings/VariantSelector.tsx b/skills/wix-headless/references/astro/templates/bookings/VariantSelector.tsx new file mode 100644 index 00000000..2c2da903 --- /dev/null +++ b/skills/wix-headless/references/astro/templates/bookings/VariantSelector.tsx @@ -0,0 +1,141 @@ +import { useEffect, useState } from "react"; +import { createClient, OAuthStrategy } from "@wix/sdk"; +import { serviceOptionsAndVariants } from "@wix/bookings"; +import { WIX_CLIENT_ID } from "astro:env/client"; + +// VariantSelector.tsx — client:only="react" island shown as step 1 of the booking +// flow only for VARIED-rate services. Fetches the service's single option +// (CUSTOM / DURATION / STAFF_MEMBER) and its variants with per-variant prices, +// then lets the user pick one before proceeding to the availability calendar. + +export interface SelectedVariant { + optionId: string; + optionType: "CUSTOM" | "STAFF_MEMBER" | "DURATION"; + // Exactly one choice field is set, matching the option type: + custom?: string; // CUSTOM — the choice label string, e.g. "Student" + staffMemberId?: string; // STAFF_MEMBER — resource ID + durationMinutes?: number; // DURATION + label: string; // display label shown in UI and booking form header + price: { value: string; currency: string }; +} + +interface Props { + serviceId: string; + serviceName: string; + onVariantSelected: (variant: SelectedVariant) => void; +} + +const wixClient = createClient({ + modules: { serviceOptionsAndVariants }, + auth: OAuthStrategy({ clientId: WIX_CLIENT_ID }), +}); + +const fmt = (p: { value: string; currency: string }) => + new Intl.NumberFormat("en-US", { style: "currency", currency: p.currency ?? "USD" }).format( + Number(p.value), + ); + +export default function VariantSelector({ serviceId, serviceName, onVariantSelected }: Props) { + const [status, setStatus] = useState<"loading" | "ready" | "error" | "none">("loading"); + const [variants, setVariants] = useState([]); + const [optionLabel, setOptionLabel] = useState("Option"); + + useEffect(() => { + void (async () => { + try { + // getServiceOptionsAndVariantsByServiceId returns { serviceVariants: { ... } } + // (note: "serviceVariants" key, not "serviceOptionsAndVariants" — different from + // the get-by-object-ID response, which uses "serviceOptionsAndVariants"). + const result = await wixClient.serviceOptionsAndVariants.getServiceOptionsAndVariantsByServiceId(serviceId); + const sv = (result as any).serviceVariants; + const option = sv?.options?.values?.[0]; + + // No option defined → not truly a VARIED service or not yet configured. + // Return null so ServiceBookingFlow falls through to the calendar. + if (!option) { setStatus("none"); return; } + + const optionId = option.id as string; + const optionType = (option.type ?? "CUSTOM") as SelectedVariant["optionType"]; + + setOptionLabel( + optionType === "CUSTOM" ? (option.customData?.name ?? "Option") : + optionType === "DURATION" ? (option.durationData?.name ?? "Duration") : + /* STAFF_MEMBER */ "Staff Member", + ); + + const parsed: SelectedVariant[] = (sv?.variants?.values ?? []).flatMap((v: any) => { + const choice = v.choices?.[0] ?? {}; + const price: { value: string; currency: string } = v.price ?? { value: "0", currency: "USD" }; + + if (optionType === "CUSTOM") { + if (!choice.custom) return []; // skip malformed choice + return [{ + optionId, optionType, + custom: choice.custom, + label: choice.custom, + price, + }]; + } + if (optionType === "DURATION") { + const mins: number | undefined = choice.duration?.minutes; + if (mins == null) return []; // skip DURATION variant without minutes — unbookable + const name: string = choice.duration?.name ?? `${mins} min`; + return [{ optionId, optionType, durationMinutes: mins, label: name, price }]; + } + // STAFF_MEMBER — staffMemberId is a resource ID. Resolving the display name + // requires a separate /bookings/v1/staff-members/query call with elevation + // (staff data isn't public). For a real implementation, fetch staff members + // SSR-side and pass a staffId→name map as a prop. Here we fall back to "Staff". + if (!choice.staffMemberId) return []; // skip malformed choice + return [{ + optionId, optionType, + staffMemberId: choice.staffMemberId, + label: "Staff", + price, + }]; + }); + + setVariants(parsed); + setStatus(parsed.length > 0 ? "ready" : "none"); + } catch (err) { + console.error("[variants] fetch failed:", err); + setStatus("error"); + } + })(); + }, [serviceId]); + + if (status === "loading") { + return

Loading pricing options…

; + } + if (status === "error") { + return

Could not load pricing options — please try again.

; + } + // "none" → VARIED service exists but no variants are configured yet. + // Show a message rather than null — returning null silently empties the booking section. + if (status === "none") { + return ( +

+ Booking options are not yet available — check back soon or contact us to book. +

+ ); + } + + return ( +
+

Select {optionLabel}

+
+ {variants.map((v, i) => ( + + ))} +
+
+ ); +} diff --git a/skills/wix-headless/references/astro/templates/bookings/components-bookings.css b/skills/wix-headless/references/astro/templates/bookings/components-bookings.css index 729a3993..1ad0a01e 100644 --- a/skills/wix-headless/references/astro/templates/bookings/components-bookings.css +++ b/skills/wix-headless/references/astro/templates/bookings/components-bookings.css @@ -386,6 +386,67 @@ body[data-navigating="true"] .service-grid { margin-top: var(--spacing-xs); } +/* ── Variant selector (VARIED-rate services) ────────────────────────────────── */ +.variant-selector { + display: flex; + flex-direction: column; + gap: var(--spacing-md); +} + +.variant-selector-label { + font-family: var(--font-display); + font-size: 1rem; + font-weight: 500; + color: var(--color-ink); + margin: 0; +} + +.variant-options { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(160px, 1fr)); + gap: var(--spacing-sm); +} + +.variant-option { + display: flex; + flex-direction: column; + align-items: flex-start; + gap: var(--spacing-xs); + padding: var(--spacing-md); + border: 1px solid var(--color-rule); + border-radius: var(--radius-md); + background: var(--color-paper, var(--color-cream)); + cursor: pointer; + text-align: left; + transition: background-color 150ms ease, border-color 150ms ease, transform 150ms ease; +} +.variant-option:hover { + border-color: var(--color-ink); + background: var(--color-paper-warm, var(--color-cream)); + transform: translateY(-2px); +} + +.variant-option-label { + font-family: var(--font-display); + font-size: 0.9375rem; + color: var(--color-ink); + font-weight: 500; +} + +.variant-option-price { + font-family: var(--font-body); + font-size: 0.875rem; + color: var(--color-ink-soft, var(--color-mute)); +} + +.booking-variant-price { + font-family: var(--font-display); + font-size: 1.125rem; + font-weight: 600; + color: var(--color-ink); + margin: 0; +} + /* ── Manage booking page ─────────────────────────────────────────────────────── */ .manage-booking { display: flex; diff --git a/skills/wix-headless/references/astro/templates/bookings/services/[slug].astro b/skills/wix-headless/references/astro/templates/bookings/services/[slug].astro index 57869e16..31efc8fb 100644 --- a/skills/wix-headless/references/astro/templates/bookings/services/[slug].astro +++ b/skills/wix-headless/references/astro/templates/bookings/services/[slug].astro @@ -26,12 +26,15 @@ try { if (!service) return Astro.redirect("/services", 302); const formatPrice = (p: any): string | undefined => { - if (!p || p.rateType === "NO_FEE") return undefined; + if (!p) return undefined; + if (p.rateType === "NO_FEE") return "Free"; if (p.rateType === "FIXED" && p.fixed?.price) { const { value, currency } = p.fixed.price; if (!value) return undefined; return new Intl.NumberFormat("en-US", { style: "currency", currency: currency ?? "USD" }).format(Number(value)); } + if (p.rateType === "VARIED") return "Varies"; + if (p.rateType === "CUSTOM") return p.custom?.description ?? "Contact us"; return undefined; }; const price = formatPrice(service.payment); @@ -74,6 +77,7 @@ const seoTags = service.seoData?.tags ?? []; serviceId={service._id} serviceName={serviceName} serviceType={service.type ?? "APPOINTMENT"} + rateType={service.payment?.rateType ?? "FIXED"} client:only="react" /> diff --git a/skills/wix-headless/references/astro/templates/bookings/services/index.astro b/skills/wix-headless/references/astro/templates/bookings/services/index.astro index 6f97b5d3..91f00261 100644 --- a/skills/wix-headless/references/astro/templates/bookings/services/index.astro +++ b/skills/wix-headless/references/astro/templates/bookings/services/index.astro @@ -22,12 +22,15 @@ try { const getSlug = (s: any): string => s.mainSlug?.name ?? s.supportedSlugs?.[0]?.name ?? s._id; const formatPrice = (s: any): string | undefined => { const p = s.payment; - if (!p || p.rateType === "NO_FEE") return undefined; + if (!p) return undefined; + if (p.rateType === "NO_FEE") return "Free"; if (p.rateType === "FIXED" && p.fixed?.price) { const { value, currency } = p.fixed.price; if (!value) return undefined; return new Intl.NumberFormat("en-US", { style: "currency", currency: currency ?? "USD" }).format(Number(value)); } + if (p.rateType === "VARIED") return "Varies"; + if (p.rateType === "CUSTOM") return p.custom?.description ?? "Contact us"; return undefined; }; const getDuration = (s: any): number => s.schedule?.availabilityConstraints?.sessionDurations?.[0] ?? 60; diff --git a/skills/wix-headless/references/bookings/INSTRUCTIONS.md b/skills/wix-headless/references/bookings/INSTRUCTIONS.md index f3eb11fe..f6a0198c 100644 --- a/skills/wix-headless/references/bookings/INSTRUCTIONS.md +++ b/skills/wix-headless/references/bookings/INSTRUCTIONS.md @@ -12,7 +12,7 @@ Extends `references/shared/IMPLEMENTER.md`. Read that file first for phase routi | Scope | Phase | Reference | |-------|-------|-----------| | `seed` | Seed (service creation via Wix Bookings REST API) | `./SERVICES_DATA.md` | -| `components` | Components: `ServiceCard.astro`, `AvailabilityCalendar.tsx`, `BookingForm.tsx`, `ServiceBookingFlow.tsx`, `ManageBooking.tsx` (`SeoTags.astro` is pre-copied — see Templates) | `../astro/bookings/COMPONENTS.md` | +| `components` | Components: `ServiceCard.astro`, `VariantSelector.tsx`, `AvailabilityCalendar.tsx`, `BookingForm.tsx`, `ServiceBookingFlow.tsx`, `ManageBooking.tsx` (`SeoTags.astro` is pre-copied — see Templates) | `../astro/bookings/COMPONENTS.md` | | `pages` | Pages: `/services` listing, `/services/[slug]` detail, `/booking-confirmation`, `/manage-booking` | `../astro/bookings/SERVICES_PAGES.md` | ## Files this vertical creates / contributes @@ -35,9 +35,10 @@ Canonical templates live at `/references/astro/templates/bookings/`. Components (`components` scope — `.tsx`/`.astro`, no CSS): - `…/templates/bookings/ServiceCard.astro` +- `…/templates/bookings/VariantSelector.tsx` — step 1 for VARIED-rate services; fetches service options & variants, renders choice+price buttons, exports `SelectedVariant` type - `…/templates/bookings/AvailabilityCalendar.tsx` — branches on `serviceType` (APPOINTMENT → `availabilityTimeSlots`, CLASS → `eventTimeSlots`); capacity + instructor + full→waitlist -- `…/templates/bookings/BookingForm.tsx` — `createBooking` → `/api/confirm-booking`; party size; waitlist on full -- `…/templates/bookings/ServiceBookingFlow.tsx` — coordinator (threads `serviceType`) +- `…/templates/bookings/BookingForm.tsx` — `createBooking` → `/api/confirm-booking`; party size; waitlist on full; `participantsChoices` for VARIED +- `…/templates/bookings/ServiceBookingFlow.tsx` — coordinator (threads `serviceType` + `rateType`; inserts VariantSelector step for VARIED) - `…/templates/bookings/ManageBooking.tsx` — cancel via anonymous token (used by `manage-booking.astro`) Pages (`pages` scope): @@ -77,6 +78,11 @@ If `global.css` ships a partial rule for any class above, flag it in your return | Use Services V1 API endpoint (`/bookings/v1/catalog/services`) | Services V2 is at `POST https://www.wixapis.com/_api/bookings/v2/services`. V1 has a different (nested `info.*`) payload shape. | | Nest service fields under `info` (`info.name`, `info.description`) | V2 uses flat fields: `name`, `description`, `tagLine` at the top level of the `service` object. | | Use `payment.fixed.price.amount` | V2 uses `payment.fixed.price.value` (a string like `"75.00"`). `amount` is the V1 field name. | +| Show no price badge for `NO_FEE` services | `NO_FEE` is explicitly free — display `"Free"`, not nothing. Returning `undefined` for `NO_FEE` leaves the price row blank, which looks like missing data. | +| Show no price badge for `VARIED` or `CUSTOM` services | `VARIED` → display `"Varies"` (the exact variant prices require a separate `serviceOptionsAndVariants` query — safe to skip on a listing page). `CUSTOM` → display `service.payment.custom.description` (e.g. `"Donation"`, `"Contact us for pricing"`). Falling through to `undefined` silently drops the price row for both. | +| Skip `VariantSelector` for VARIED services | For VARIED-rate services the booking flow has 3 steps: `VariantSelector` → `AvailabilityCalendar` → `BookingForm`. `ServiceBookingFlow` gates step 2 on `rateType === "VARIED" && !selectedVariant`. If `rateType` is not threaded from `[slug].astro`, the variant step is silently skipped and `createBooking` is called with `totalParticipants` instead of `participantsChoices` — the API may reject it or produce a price mismatch. Always pass `rateType={service.payment?.rateType}` to `ServiceBookingFlow`. | +| Pass `totalParticipants` for VARIED services | VARIED services require `participantsChoices` (not `totalParticipants`) in `createBooking`. Use `participantsChoices.serviceChoices[0].choices[0]` with `optionId` and the appropriate choice field (`custom`, `staffMemberId`, or `duration.minutes`). Sending `totalParticipants` for a VARIED service may silently use the default variant price instead of the chosen one, or return a 400. | +| Read variants from `result.serviceOptionsAndVariants` when fetching by service ID | `getServiceOptionsAndVariantsByServiceId` returns `{ serviceVariants: { ... } }` — note `serviceVariants`, NOT `serviceOptionsAndVariants`. The `serviceOptionsAndVariants` key is used by the get-by-object-ID endpoint. Using the wrong key returns `undefined` and the component falls through to `status: "none"`, showing a "not yet available" message instead of options. Also: TypeScript doesn't know this key — cast with `(result as any).serviceVariants`. | | Omit `defaultCapacity` when creating a service | Required in V2. Set to `1` for APPOINTMENT; use participant count for CLASS. | | Omit `onlineBooking` when creating a service | Required in V2. At minimum `{ "enabled": true }`. | | Omit `sessionDurations` for an APPOINTMENT service | Required for APPOINTMENT: `schedule.availabilityConstraints.sessionDurations: []`. Do NOT specify for CLASS. | diff --git a/skills/wix-headless/references/bookings/SERVICES_DATA.md b/skills/wix-headless/references/bookings/SERVICES_DATA.md index 6d7b6ecf..cd679a6d 100644 --- a/skills/wix-headless/references/bookings/SERVICES_DATA.md +++ b/skills/wix-headless/references/bookings/SERVICES_DATA.md @@ -171,6 +171,45 @@ curl -sS -X POST \ > **Slug:** Extract from `service.mainSlug.name`. If absent, derive from the service name: lowercase, replace spaces and non-alphanumeric chars with hyphens, deduplicate hyphens. +### Pricing type payloads + +Four `rateType` values are supported. Choose based on `brand` + `intent`; default to `FIXED`. All four examples below replace the `"payment"` block in the service create payload. + +**NO_FEE** — service is free; customer pays nothing. Still requires at least one `payment.options` flag set to `true`. +```json +"payment": { + "rateType": "NO_FEE", + "options": { "online": false, "inPerson": true, "deposit": false, "pricingPlan": false } +} +``` + +**FIXED** — single price for all bookings (the default seed choice). +```json +"payment": { + "rateType": "FIXED", + "fixed": { "price": { "value": "75.00", "currency": "USD" } }, + "options": { "online": true, "inPerson": false, "deposit": false, "pricingPlan": false } +} +``` + +**CUSTOM** — price is a free-text label (e.g. "Donation", "Contact us for pricing"). **Must have `inPerson: true`** — the API rejects CUSTOM with `online`-only options. +```json +"payment": { + "rateType": "CUSTOM", + "custom": { "description": "Contact us for pricing" }, + "options": { "online": false, "inPerson": true, "deposit": false, "pricingPlan": false } +} +``` + +**VARIED** — price depends on which service variant the customer picks (e.g. adult vs child, 30 min vs 60 min). **Requires `varied.defaultPrice`** — the API rejects VARIED without it (`"Payment of type VARIED must include payment.rate.varied.defaultPrice"`). Set it to a reasonable mid-range fallback; the per-variant prices in Step 4c override it for each choice. The front-end displays "Varies" on the listing page and shows the real variant prices after the customer selects one in the booking flow. +```json +"payment": { + "rateType": "VARIED", + "varied": { "defaultPrice": { "value": "65.00", "currency": "USD" } }, + "options": { "online": true, "inPerson": false, "deposit": false, "pricingPlan": false } +} +``` + ### Service creation guidelines - **Count**: Create exactly `intent.bookings.serviceCount` services (default 3 when not specified). @@ -184,6 +223,60 @@ curl -sS -X POST \ - **Currency**: Set `"USD"` unless the brand's locale implies otherwise — **but note the site's business currency wins**: Services V2 silently stores the site-locale currency (e.g. a EUR-locale site stores `EUR` even when you send `USD`). This is not an error; the page templates format from the service's returned `currency`, so display stays correct. Don't fight it. - **Fire all service creates as a parallel batch** — they are independent calls. +--- + +## Step 4c — Define service variants (VARIED services only) + +Skip unless a service was created with `rateType: "VARIED"`. This step defines the options and their per-variant prices. + +**Endpoint:** `POST https://www.wixapis.com/bookings/v2/services/{serviceId}/service-options-and-variants` + +**Use `CUSTOM` option type for seeding.** The `CUSTOM` type is the simplest: you define named choices (e.g. "Adult", "Student") and the API stores them verbatim. The front-end reads them back as `choice.custom` (a string). `DURATION` and `STAFF_MEMBER` option types have a different read-back shape (`choice.duration.minutes`, `choice.staffMemberId`) and are better configured via the Wix Dashboard (Catalog → Services → [service] → Pricing) where the UI enforces the correct structure. + +**Request body must wrap everything in a `"serviceOptionsAndVariants"` key.** Sending `{ "options": [...] }` at the top level returns 200 with an empty body and silently stores nothing — the most common mistake with this endpoint. + +**`optionId` in variant choices must be the server-assigned ID** returned in the create response, not the option name string. Run two sequential calls: first POST with only `options` to get the IDs, then PUT/re-POST with `variants` referencing those IDs. + +**Step 4c-i** — create the option, capture the assigned ID: +```bash +VARIANTS_RESP=$(curl -sS -X POST \ + -H "Authorization: Bearer $TOKEN" -H "wix-site-id: " -H "Content-Type: application/json" \ + -d '{ + "serviceOptionsAndVariants": { + "options": [ + { "name": "Age Group", "optionType": "CUSTOM", + "values": [ { "id": "adult", "value": "Adult" }, { "id": "student", "value": "Student" } ] } + ] + } + }' \ + "https://www.wixapis.com/bookings/v2/services//service-options-and-variants") + +OPTION_ID=$(echo "$VARIANTS_RESP" | node -e "const d=require('fs').readFileSync('/dev/stdin','utf8'); console.log(JSON.parse(d).serviceOptionsAndVariants?.options?.[0]?.id ?? '')") +``` + +**Step 4c-ii** — add variants with the server-assigned `$OPTION_ID`: +```bash +curl -sS -X PUT \ + -H "Authorization: Bearer $TOKEN" -H "wix-site-id: " -H "Content-Type: application/json" \ + -d "{ + \"serviceOptionsAndVariants\": { + \"options\": [ + { \"id\": \"$OPTION_ID\", \"name\": \"Age Group\", \"optionType\": \"CUSTOM\", + \"values\": [ { \"id\": \"adult\", \"value\": \"Adult\" }, { \"id\": \"student\", \"value\": \"Student\" } ] } + ], + \"variants\": [ + { \"choices\": [ { \"optionId\": \"$OPTION_ID\", \"custom\": \"Adult\" } ], + \"price\": { \"value\": \"70.00\", \"currency\": \"USD\" } }, + { \"choices\": [ { \"optionId\": \"$OPTION_ID\", \"custom\": \"Student\" } ], + \"price\": { \"value\": \"45.00\", \"currency\": \"USD\" } } + ] + } + }" \ + "https://www.wixapis.com/bookings/v2/services//service-options-and-variants" +``` + +> If the brand intent is unclear or variants are complex, skip this step and add to `notes`: `"VARIED service created — define variants from the Bookings dashboard (Catalog → Services → [service] → Pricing)."` The front-end safely shows "Varies" on the listing page when no variants are found. + ### Required fields summary (V2) | Field | Required | Notes | @@ -259,6 +352,7 @@ Verify by fetching slots the way the front-end does (`eventTimeSlots.listEventTi "name": "", "type": "APPOINTMENT", "durationMinutes": 60, + "rateType": "FIXED", "price": "75.00", "currency": "USD" } @@ -286,7 +380,10 @@ Verify by fetching slots the way the front-end does (`eventTimeSlots.listEventTi | 403 on service create | Re-mint token and retry once. If still 403, Bookings app was not installed — return `status: "error"`. | | 400 `"defaultCapacity is required"` | Add `"defaultCapacity": 1` to the payload (required in V2, not obvious from V1 docs). | | 400 `"onlineBooking is required"` | Add `"onlineBooking": { "enabled": true }` — required in V2. | -| 400 on `payment.options` | At least one of `online` or `inPerson` must be `true`. Set `"inPerson": true` as fallback. | +| 400 on `payment.options` | At least one of `online` or `inPerson` must be `true`. Set `"inPerson": true` as fallback. This applies even for `NO_FEE` services — free doesn't exempt the options requirement. | +| 400 on CUSTOM with `online: true` only | `CUSTOM` rate type **requires** `inPerson: true`. The API rejects a CUSTOM service where only `online` is enabled. Always set `"inPerson": true` for CUSTOM (can combine with `online: true`). | +| 400 `"Payment of type VARIED must include payment.rate.varied.defaultPrice"` | The VARIED payment block requires `"varied": { "defaultPrice": { "value": "...", "currency": "USD" } }`. Omitting it causes a 400. Set a reasonable mid-range fallback; per-variant prices from Step 4c override it per choice. | +| Step 4c POST returns 200 with empty body, GET also returns empty | The request body was sent without the `"serviceOptionsAndVariants"` wrapper — e.g. `{ "options": [...] }` at the top level. The API silently accepts it but stores nothing. Always wrap: `{ "serviceOptionsAndVariants": { "options": [...], "variants": [...] } }`. | | 400 `"sessionDurations is required"` | Add `"schedule": { "availabilityConstraints": { "sessionDurations": [60] } }` for APPOINTMENT types. | | 400 `MISSING_APPOINTMENT_RESOURCES` on service create | An APPOINTMENT service's `staffMemberIds` must be **non-empty**. Pass the created staff `resourceId`s (Step 3), or — when `hasStaff` is false — the default **Business Owner** `resourceId` (query `/bookings/v1/staff-members/query` with `{"query":{}}`). An empty `[]` always 400s here. | | 400 enum error on `locations.type` | Use `"BUSINESS"`, not `"OWNER_BUSINESS"`. The services endpoint accepts `UNKNOWN_LOCATION_TYPE`, `CUSTOM`, `BUSINESS`, `CUSTOMER`. `OWNER_BUSINESS` is valid on `createBooking.bookedEntity.slot.location.locationType` only. | diff --git a/skills/wix-headless/references/custom/bookings/WIRING.md b/skills/wix-headless/references/custom/bookings/WIRING.md index 660718d1..1c4f72ae 100644 --- a/skills/wix-headless/references/custom/bookings/WIRING.md +++ b/skills/wix-headless/references/custom/bookings/WIRING.md @@ -28,9 +28,12 @@ You wire the **bookings capability** (services list + availability + book) into const { items } = await wix.services.queryServices().limit(100).find(); const visible = items.filter((s) => !s.hidden); // Bind into the existing markup: name s.name, tagline s.tagLine, slug s.mainSlug?.name, - // duration s.schedule?.availabilityConstraints?.sessionDurations?.[0] (APPOINTMENT), - // price s.payment?.fixed?.price ({ value, currency } — value is a string; format from - // the returned currency, the site business locale wins over what was seeded). + // duration s.schedule?.availabilityConstraints?.sessionDurations?.[0] (APPOINTMENT). + // Price display — branch on rateType: + // NO_FEE → "Free" + // FIXED → format s.payment.fixed.price.value (string) + currency + // VARIED → "Varies" (actual variants require a separate serviceOptionsAndVariants query) + // CUSTOM → s.payment.custom.description (e.g. "Donation", "Contact us for pricing") ```