From 917b8b5860fad4618463c3983710de14d71333bd Mon Sep 17 00:00:00 2001 From: adimara Date: Mon, 8 Jun 2026 11:31:06 +0300 Subject: [PATCH 1/4] feat(bookings): support all 4 pricing rate types in seed and display MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add NO_FEE, CUSTOM, and VARIED alongside FIXED in the bookings seeding guide and page templates. Previously, only FIXED was documented in the seed payloads, and formatPrice silently returned undefined for all other rate types — showing a blank price on free, varied, and custom services. - SERVICES_DATA.md: add seed payload examples for all 4 rateTypes, document the CUSTOM constraint (inPerson: true required), add Step 4c for VARIED service-options-and-variants follow-up, add rateType to return contract, add failure modes for CUSTOM/NO_FEE payment options - index.astro + [slug].astro: fix formatPrice — NO_FEE → "Free", VARIED → "Varies", CUSTOM → payment.custom.description - custom/bookings/WIRING.md: update price comment to cover all 4 types - INSTRUCTIONS.md: add failure mode rows for NO_FEE/VARIED/CUSTOM display Co-Authored-By: Claude Sonnet 4.6 --- .../templates/bookings/services/[slug].astro | 5 +- .../templates/bookings/services/index.astro | 5 +- .../references/bookings/INSTRUCTIONS.md | 2 + .../references/bookings/SERVICES_DATA.md | 82 ++++++++++++++++++- .../references/custom/bookings/WIRING.md | 9 +- 5 files changed, 97 insertions(+), 6 deletions(-) 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..abf2fd3a 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); 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..5dd62c77 100644 --- a/skills/wix-headless/references/bookings/INSTRUCTIONS.md +++ b/skills/wix-headless/references/bookings/INSTRUCTIONS.md @@ -77,6 +77,8 @@ 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. | | 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..761593ed 100644 --- a/skills/wix-headless/references/bookings/SERVICES_DATA.md +++ b/skills/wix-headless/references/bookings/SERVICES_DATA.md @@ -171,6 +171,44 @@ 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. 30 min vs 60 min, adult vs child, staff member). Creating the service with `VARIED` is step 1; you must also define variants via the **Service Options and Variants API** (`POST /bookings/v2/services/{serviceId}/service-options-and-variants`) — see Step 4c below. The front-end displays "Varies" until variants are queried. +```json +"payment": { + "rateType": "VARIED", + "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 +222,46 @@ 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 (e.g. "Duration", "Age Group") and their per-variant prices. + +**Endpoint:** `POST https://www.wixapis.com/bookings/v2/services/{serviceId}/service-options-and-variants` + +Example — two duration variants (30 min / 60 min): +```bash +curl -sS -X POST \ + -H "Authorization: Bearer $TOKEN" -H "wix-site-id: " -H "Content-Type: application/json" \ + -d '{ + "serviceOptionsAndVariants": { + "options": [ + { + "name": "Duration", + "values": [ + { "id": "opt-30", "caption": "30 min" }, + { "id": "opt-60", "caption": "60 min" } + ] + } + ], + "variants": [ + { + "choices": [ { "optionId": "Duration", "value": "opt-30" } ], + "price": { "value": "40.00", "currency": "USD" } + }, + { + "choices": [ { "optionId": "Duration", "value": "opt-60" } ], + "price": { "value": "70.00", "currency": "USD" } + } + ] + } + }' \ + "https://www.wixapis.com/bookings/v2/services//service-options-and-variants" +``` + +> If variants are complex or the brand intent is unclear, 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" when no variant query is made. + ### Required fields summary (V2) | Field | Required | Notes | @@ -259,6 +337,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 +365,8 @@ 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 `"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") ``` From e12b8afa9f8234785d615a694fb6e85da62dee23 Mon Sep 17 00:00:00 2001 From: adimara Date: Mon, 8 Jun 2026 12:04:08 +0300 Subject: [PATCH 2/4] feat(bookings): add variant selection step for VARIED-rate services For services with rateType VARIED, the booking flow now has a proper three-step UX: pick an option (e.g. Student / Adult), pick a time slot, then fill in contact details. - VariantSelector.tsx (new): fetches service options & variants via getServiceOptionsAndVariantsByServiceId, renders each choice as a card button with its price. Handles CUSTOM, DURATION, and STAFF_MEMBER option types. Exports SelectedVariant type used by the full flow. - ServiceBookingFlow.tsx: add rateType prop; gate VariantSelector as step 1 when rateType === "VARIED"; thread selectedVariant to BookingForm. - BookingForm.tsx: accept optional selectedVariant; send participantsChoices (not totalParticipants) to createBooking when a variant is selected; show selected variant label + price in the form header. - [slug].astro: pass rateType={service.payment?.rateType} to ServiceBookingFlow. - components-bookings.css: add .variant-selector / .variant-option / .variant-option-price / .booking-variant-price rules. - COMPONENTS.md + INSTRUCTIONS.md: document VariantSelector, the three-step flow, STAFF_MEMBER name-resolution caveat, and new failure modes. Co-Authored-By: Claude Sonnet 4.6 --- .../references/astro/bookings/COMPONENTS.md | 48 ++++++- .../astro/templates/bookings/BookingForm.tsx | 48 ++++++- .../templates/bookings/ServiceBookingFlow.tsx | 35 ++++- .../templates/bookings/VariantSelector.tsx | 131 ++++++++++++++++++ .../bookings/components-bookings.css | 61 ++++++++ .../templates/bookings/services/[slug].astro | 1 + .../references/bookings/INSTRUCTIONS.md | 10 +- 7 files changed, 320 insertions(+), 14 deletions(-) create mode 100644 skills/wix-headless/references/astro/templates/bookings/VariantSelector.tsx diff --git a/skills/wix-headless/references/astro/bookings/COMPONENTS.md b/skills/wix-headless/references/astro/bookings/COMPONENTS.md index 5f23cd2d..4b9f1a80 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.serviceVariants; +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. + +Returns `null` when no variants are configured — `ServiceBookingFlow` stays at the VariantSelector step in that case (the calendar never appears). This surfaces as "no options available" which means the merchant hasn't set up variants yet; note it in `errors` on return. + +--- + ## 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..6243abd4 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 } }), + }, + ], + }, + ], + } + : 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..88527fc4 --- /dev/null +++ b/skills/wix-headless/references/astro/templates/bookings/VariantSelector.tsx @@ -0,0 +1,131 @@ +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 ?? []).map((v: any) => { + const choice = v.choices?.[0] ?? {}; + const price: { value: string; currency: string } = v.price ?? { value: "0", currency: "USD" }; + + if (optionType === "CUSTOM") { + return { + optionId, optionType, + custom: choice.custom, + label: choice.custom ?? "", + price, + }; + } + if (optionType === "DURATION") { + const mins: number | undefined = choice.duration?.minutes; + const name: string = choice.duration?.name ?? (mins != null ? `${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". + 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" → no variants configured yet; ServiceBookingFlow should skip this step + if (status === "none") return null; + + 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 abf2fd3a..31efc8fb 100644 --- a/skills/wix-headless/references/astro/templates/bookings/services/[slug].astro +++ b/skills/wix-headless/references/astro/templates/bookings/services/[slug].astro @@ -77,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/bookings/INSTRUCTIONS.md b/skills/wix-headless/references/bookings/INSTRUCTIONS.md index 5dd62c77..fa6ec873 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): @@ -79,6 +80,9 @@ If `global.css` ships a partial rule for any class above, flag it in your return | 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"` with no options shown. | | 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. | From 2429cfe4b598dba9614786c0f206d64c0261796b Mon Sep 17 00:00:00 2001 From: adimara Date: Mon, 8 Jun 2026 12:14:32 +0300 Subject: [PATCH 3/4] =?UTF-8?q?fix(bookings):=20address=20PR=20review=20?= =?UTF-8?q?=E2=80=94=20seed=20payload,=20null=20state,=20type=20safety,=20?= =?UTF-8?q?docs?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - SERVICES_DATA.md: replace incorrect DURATION seed example with CUSTOM (DURATION's read-back shape doesn't match what a generic option payload produces; CUSTOM round-trips cleanly). Document that optionId in variant choices must be the server-assigned ID, not the option name string. - VariantSelector.tsx: show a user-facing message when status==="none" instead of returning null (which silently emptied the booking section). Switch .map → .flatMap and skip malformed choices (CUSTOM with no value, DURATION with no minutes, STAFF_MEMBER with no staffMemberId) so bad data can't produce unbookable variants. - BookingForm.tsx: guard durationMinutes fallback to 0 so the API never receives { duration: { minutes: undefined } } for a DURATION choice. - COMPONENTS.md: add `as any` cast to SDK wiring example (TS doesn't know the serviceVariants key); update none-state description to match new behavior. - INSTRUCTIONS.md: note the as-any cast requirement alongside the serviceVariants key gotcha. Co-Authored-By: Claude Sonnet 4.6 --- .../references/astro/bookings/COMPONENTS.md | 4 +-- .../astro/templates/bookings/BookingForm.tsx | 2 +- .../templates/bookings/VariantSelector.tsx | 30 ++++++++++++------- .../references/bookings/INSTRUCTIONS.md | 2 +- .../references/bookings/SERVICES_DATA.md | 25 +++++++++------- 5 files changed, 39 insertions(+), 24 deletions(-) diff --git a/skills/wix-headless/references/astro/bookings/COMPONENTS.md b/skills/wix-headless/references/astro/bookings/COMPONENTS.md index 4b9f1a80..fd78c830 100644 --- a/skills/wix-headless/references/astro/bookings/COMPONENTS.md +++ b/skills/wix-headless/references/astro/bookings/COMPONENTS.md @@ -50,7 +50,7 @@ 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.serviceVariants; +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 ``` @@ -60,7 +60,7 @@ const option = sv?.options?.values?.[0]; // only 1 option per service - `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. -Returns `null` when no variants are configured — `ServiceBookingFlow` stays at the VariantSelector step in that case (the calendar never appears). This surfaces as "no options available" which means the merchant hasn't set up variants yet; note it in `errors` on return. +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. --- diff --git a/skills/wix-headless/references/astro/templates/bookings/BookingForm.tsx b/skills/wix-headless/references/astro/templates/bookings/BookingForm.tsx index 6243abd4..543b5265 100644 --- a/skills/wix-headless/references/astro/templates/bookings/BookingForm.tsx +++ b/skills/wix-headless/references/astro/templates/bookings/BookingForm.tsx @@ -99,7 +99,7 @@ export default function BookingForm({ serviceName, serviceType, slot, selectedVa ? { custom: selectedVariant.custom } : selectedVariant.staffMemberId ? { staffMemberId: selectedVariant.staffMemberId } - : { duration: { minutes: selectedVariant.durationMinutes } }), + : { duration: { minutes: selectedVariant.durationMinutes ?? 0 } }), }, ], }, diff --git a/skills/wix-headless/references/astro/templates/bookings/VariantSelector.tsx b/skills/wix-headless/references/astro/templates/bookings/VariantSelector.tsx index 88527fc4..2c2da903 100644 --- a/skills/wix-headless/references/astro/templates/bookings/VariantSelector.tsx +++ b/skills/wix-headless/references/astro/templates/bookings/VariantSelector.tsx @@ -63,33 +63,36 @@ export default function VariantSelector({ serviceId, serviceName, onVariantSelec /* STAFF_MEMBER */ "Staff Member", ); - const parsed: SelectedVariant[] = (sv?.variants?.values ?? []).map((v: any) => { + 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") { - return { + if (!choice.custom) return []; // skip malformed choice + return [{ optionId, optionType, custom: choice.custom, - label: choice.custom ?? "", + label: choice.custom, price, - }; + }]; } if (optionType === "DURATION") { const mins: number | undefined = choice.duration?.minutes; - const name: string = choice.duration?.name ?? (mins != null ? `${mins} min` : "?"); - return { optionId, optionType, durationMinutes: mins, label: name, price }; + 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". - return { + if (!choice.staffMemberId) return []; // skip malformed choice + return [{ optionId, optionType, staffMemberId: choice.staffMemberId, label: "Staff", price, - }; + }]; }); setVariants(parsed); @@ -107,8 +110,15 @@ export default function VariantSelector({ serviceId, serviceName, onVariantSelec if (status === "error") { return

Could not load pricing options — please try again.

; } - // "none" → no variants configured yet; ServiceBookingFlow should skip this step - if (status === "none") return null; + // "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 (
diff --git a/skills/wix-headless/references/bookings/INSTRUCTIONS.md b/skills/wix-headless/references/bookings/INSTRUCTIONS.md index fa6ec873..f6a0198c 100644 --- a/skills/wix-headless/references/bookings/INSTRUCTIONS.md +++ b/skills/wix-headless/references/bookings/INSTRUCTIONS.md @@ -82,7 +82,7 @@ If `global.css` ships a partial rule for any class above, flag it in your return | 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"` with no options shown. | +| 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 761593ed..ac2f3f53 100644 --- a/skills/wix-headless/references/bookings/SERVICES_DATA.md +++ b/skills/wix-headless/references/bookings/SERVICES_DATA.md @@ -226,11 +226,13 @@ Four `rateType` values are supported. Choose based on `brand` + `intent`; defaul ## Step 4c — Define service variants (VARIED services only) -Skip unless a service was created with `rateType: "VARIED"`. This step defines the options (e.g. "Duration", "Age Group") and their per-variant prices. +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` -Example — two duration variants (30 min / 60 min): +**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. + +Example — two CUSTOM variants ("Adult" / "Student"): ```bash curl -sS -X POST \ -H "Authorization: Bearer $TOKEN" -H "wix-site-id: " -H "Content-Type: application/json" \ @@ -238,21 +240,22 @@ curl -sS -X POST \ "serviceOptionsAndVariants": { "options": [ { - "name": "Duration", + "name": "Age Group", + "optionType": "CUSTOM", "values": [ - { "id": "opt-30", "caption": "30 min" }, - { "id": "opt-60", "caption": "60 min" } + { "id": "adult", "value": "Adult" }, + { "id": "student", "value": "Student" } ] } ], "variants": [ { - "choices": [ { "optionId": "Duration", "value": "opt-30" } ], - "price": { "value": "40.00", "currency": "USD" } + "choices": [ { "optionId": "", "custom": "Adult" } ], + "price": { "value": "70.00", "currency": "USD" } }, { - "choices": [ { "optionId": "Duration", "value": "opt-60" } ], - "price": { "value": "70.00", "currency": "USD" } + "choices": [ { "optionId": "", "custom": "Student" } ], + "price": { "value": "45.00", "currency": "USD" } } ] } @@ -260,7 +263,9 @@ curl -sS -X POST \ "https://www.wixapis.com/bookings/v2/services//service-options-and-variants" ``` -> If variants are complex or the brand intent is unclear, 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" when no variant query is made. +> **Important:** `optionId` in each variant's `choices` must be the **server-assigned ID** returned in the create response (`serviceOptionsAndVariants.options[].id`), not the option name. The call above is shown as a two-step operation in practice: (1) POST with the `options` block only to get the assigned IDs, then (2) PATCH or re-POST with `variants` referencing those IDs. Alternatively, skip this step entirely and configure variants in the dashboard. + +> 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" and a "not yet configured" message when no variants are found. ### Required fields summary (V2) From de890d6c7cd4116c86e36ecfd8ecd14e0faaf4d8 Mon Sep 17 00:00:00 2001 From: adimara Date: Mon, 8 Jun 2026 14:27:46 +0300 Subject: [PATCH 4/4] fix(bookings): correct VARIED seed payload and Step 4c body structure MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two live-test failures: - VARIED service creation 400: add required `varied.defaultPrice` to the VARIED payment block. The API rejects VARIED without it; `fixed.price` is also wrong. Document the correct shape and add a failure mode entry. - Step 4c silent empty response: the agent sent `{ "options": [...] }` at the top level; the API accepts it but stores nothing. Rewrite Step 4c as a two-step sequential curl (create options → capture server-assigned option ID → PUT with variants referencing that ID). Add prominent warning about the required `serviceOptionsAndVariants` wrapper and a failure mode. Co-Authored-By: Claude Sonnet 4.6 --- .../references/bookings/SERVICES_DATA.md | 60 +++++++++++-------- 1 file changed, 36 insertions(+), 24 deletions(-) diff --git a/skills/wix-headless/references/bookings/SERVICES_DATA.md b/skills/wix-headless/references/bookings/SERVICES_DATA.md index ac2f3f53..cd679a6d 100644 --- a/skills/wix-headless/references/bookings/SERVICES_DATA.md +++ b/skills/wix-headless/references/bookings/SERVICES_DATA.md @@ -201,10 +201,11 @@ Four `rateType` values are supported. Choose based on `brand` + `intent`; defaul } ``` -**VARIED** — price depends on which service variant the customer picks (e.g. 30 min vs 60 min, adult vs child, staff member). Creating the service with `VARIED` is step 1; you must also define variants via the **Service Options and Variants API** (`POST /bookings/v2/services/{serviceId}/service-options-and-variants`) — see Step 4c below. The front-end displays "Varies" until variants are queried. +**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 } } ``` @@ -232,40 +233,49 @@ Skip unless a service was created with `rateType: "VARIED"`. This step defines t **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. -Example — two CUSTOM variants ("Adult" / "Student"): +**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 -curl -sS -X POST \ +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" } - ] - } - ], - "variants": [ - { - "choices": [ { "optionId": "", "custom": "Adult" } ], - "price": { "value": "70.00", "currency": "USD" } - }, - { - "choices": [ { "optionId": "", "custom": "Student" } ], - "price": { "value": "45.00", "currency": "USD" } - } + { "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" + "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 ?? '')") ``` -> **Important:** `optionId` in each variant's `choices` must be the **server-assigned ID** returned in the create response (`serviceOptionsAndVariants.options[].id`), not the option name. The call above is shown as a two-step operation in practice: (1) POST with the `options` block only to get the assigned IDs, then (2) PATCH or re-POST with `variants` referencing those IDs. Alternatively, skip this step entirely and configure variants in the dashboard. +**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" and a "not yet configured" message when no variants are found. +> 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) @@ -372,6 +382,8 @@ Verify by fetching slots the way the front-end does (`eventTimeSlots.listEventTi | 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. 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. |