Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
48 changes: 46 additions & 2 deletions skills/wix-headless/references/astro/bookings/COMPONENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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;
}
Expand All @@ -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("");
Expand Down Expand Up @@ -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: "<resourceId>" }
// 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() },
Expand Down Expand Up @@ -148,8 +179,19 @@ export default function BookingForm({ serviceName, serviceType, slot, onSuccess,
<div className="booking-form-header">
<h3 className="booking-form-title">{isWaitlist ? "Join the waitlist" : "Reserve your spot"}</h3>
<p className="booking-form-subtitle">
{serviceName} · {slotDisplay(slot)}{slot.instructorName ? ` · with ${slot.instructorName}` : ""}
{serviceName}
{selectedVariant && ` · ${selectedVariant.label}`}
{" · "}{slotDisplay(slot)}
{slot.instructorName ? ` · with ${slot.instructorName}` : ""}
</p>
{selectedVariant && (
<p className="booking-variant-price">
{new Intl.NumberFormat("en-US", {
style: "currency",
currency: selectedVariant.price.currency ?? "USD",
}).format(Number(selectedVariant.price.value))}
</p>
)}
{isWaitlist && <p className="booking-form-note">This session is full — join the waitlist and we'll notify you if a spot opens.</p>}
</div>

Expand Down
Original file line number Diff line number Diff line change
@@ -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<SelectedVariant | null>(null);
const [selectedSlot, setSelectedSlot] = useState<SelectedSlot | null>(null);

const handleSuccess = (bookingId: string, startDate?: string) => {
Expand All @@ -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 (
<VariantSelector
serviceId={serviceId}
serviceName={serviceName}
onVariantSelected={setSelectedVariant}
/>
);
}

// Step 2 — pick a time slot.
if (!selectedSlot) {
return (
<AvailabilityCalendar
Expand All @@ -37,12 +58,14 @@ export default function ServiceBookingFlow({ serviceId, serviceName, serviceType
);
}

// Step 3 — contact details + confirm.
return (
<BookingForm
serviceId={serviceId}
serviceName={serviceName}
serviceType={serviceType}
slot={selectedSlot}
selectedVariant={selectedVariant ?? undefined}
onSuccess={handleSuccess}
onCancel={() => setSelectedSlot(null)}
/>
Expand Down
Original file line number Diff line number Diff line change
@@ -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<SelectedVariant[]>([]);
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 <p className="availability-loading">Loading pricing options…</p>;
}
if (status === "error") {
return <p className="availability-error">Could not load pricing options — please try again.</p>;
}
// "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 (
<p className="availability-empty">
Booking options are not yet available — check back soon or contact us to book.
</p>
);
}

return (
<div className="variant-selector">
<p className="variant-selector-label">Select {optionLabel}</p>
<div className="variant-options" role="group" aria-label={`Select ${optionLabel} for ${serviceName}`}>
{variants.map((v, i) => (
<button
key={i}
type="button"
className="variant-option"
onClick={() => onVariantSelected(v)}
>
<span className="variant-option-label">{v.label}</span>
<span className="variant-option-price">{fmt(v.price)}</span>
</button>
))}
</div>
</div>
);
}
Loading
Loading