# Opencals booking site playbook

How to build a production booking website (or add booking to an existing app) on the Opencals connector. The booking engine lives in Opencals. This app is the customer-facing frontend: catalogue, slot picker, cart, checkout, confirmation and a customer account area. For exact endpoints, bodies and response fields, use the **"Opencals Storefront API"** knowledge file. This file covers what to build and in what order.

## Ground rules

- **Never hard-code services, prices, durations, staff, locations or opening hours.** They come from the API and are managed by the merchant in the Opencals dashboard (app.opencals.com). Copy about the business (hero text, about, FAQs, contact) can live in the app.
- **Never build availability, pricing, discount or tax logic.** Render what the API returns. The cart's totals are the truth.
- **All API calls go through the Opencals connector from server-side code.** The browser talks to your own server functions, never to Opencals directly. Build one thin server function per operation (e.g. `get-services`, `get-slots`, `book-slot`, `checkout-start`), forward the `X-Cart-Id` and customer `Authorization` headers the browser sends, and return the API's status and body unchanged.
- **Times are UTC in the API.** Display them in the store timezone (from public settings), and send back exactly what you received.
- **Show real errors.** Surface the API's `message` and field `errors[]`. Never collapse them into "Something went wrong".
- **Watch the rate limit.** It's app-wide (about 120 requests/minute across all visitors). Cache catalogue data, use availability ranges for calendars, and never prefetch slots for every service.

## Step 1: understand the business, then choose the booking shape

Ask, or infer from the brief:

1. **What is being booked?**
   - *People's time* (stylist, therapist, clinician, coach, tutor). Staff-based booking: customers pick a service, optionally a person, then a time.
   - *Things* (courts, rooms, studios, lanes, bays, equipment). Resource booking: each unit is its own bookable product, often shown as a grid.
   - *Both* (a padel club with court rentals and coached sessions). Show both paths.
2. **One-to-one or group?** Groups (classes, workshops) have `maxAttendees > 1`. Show "X spots left" and a party-size picker.
3. **Fixed or variable length?** If `allowCustomDuration` is set, show a length picker (30/60/90 min) before the time.
4. **Where?** Several locations mean a location picker first. Online locations show a meeting link after booking. `delivery` locations (a mobile service) need the customer's address at checkout.
5. **Extras?** Add-ons (products' `addOns`) can be picked after the time is chosen.

Match the page flow to the booking shape first, and the visual style second. Restyling is cheap; changing the flow isn't.

| Business | Recommended flow |
|---|---|
| Salon, barber, spa | Services list → service → (staff: "Anyone" or a person) → date → time → extras → checkout |
| Clinic, therapist | Department/collection → service → practitioner → date → time → intake questions → checkout. Keep the tone calm and clinical |
| Fitness classes, workshops | Timetable by day (group products) → class → party size → checkout |
| Courts, rooms, studios | Sport/room type toggle → date → units × time grid → length → checkout |
| Coaches, tutors | Coach profiles → service → date → time → checkout |

## Step 2: pages and components

Build these. They cover most booking sites.

1. **Home**: hero, a short intro, featured services (from the API; the first 3–6 active products or one collection), location/contact block, and a clear "Book now" call to action.
2. **Services** (`/services`): the catalogue from `GET /storefront/products?status=active`, grouped by `GET /storefront/product-collections?isVisible=true` when collections exist. The card shows image, title, duration (formatted from seconds), and price, as "from £X" when variants differ. Products with several `variants` show the variants as options.
3. **Booking page** (`/book/:slug`): product detail by slug, then the flow for its shape (below).
4. **Cart / checkout** (`/checkout`): summary, promo code, customer form, questions, payment.
5. **Confirmation** (`/booking/confirmed`): order number (`order.name`), date/time in local time, location or online link, "Add to calendar", and a link to the account area.
6. **Account** (`/account`): sign in (6-digit email code), upcoming and past appointments, appointment detail with reschedule/cancel, profile, marketing preferences.
7. **Staff / team** (optional): from `GET /storefront/staff-members`. Show first name, last name and image only.
8. **Locations** (optional, multi-location): from `GET /storefront/locations`.

Reusable components: service card, variant selector, staff selector (with "Anyone available"), date picker (days enabled from availability ranges), time-slot grid (grouped into morning/afternoon/evening), party-size stepper, add-on selector, cart summary with countdown, checkout form driven by settings, payment step, appointment card.

## Step 3: the booking flow in detail

### Staff-based (salon, clinic, coaching)
1. Load the product by slug. If `variants.length > 1`, the customer picks a variant first. Every later call uses the **variant `id`**.
2. If the product has several `locations`, pick a location (or skip if there's one).
3. Staff: offer "Anyone available" plus the variant's `staffMembers`.
4. Date picker: call `current-availability-ranges` once for the variant (plus `locationId`/`staffMemberId` when chosen, and `timezone`) and enable only the days covered by a range. Open on the first available day (`nearest-availability`).
5. Time slots: `current-availabilities?date=…&timezone=…` for the chosen day. Display `fromTime` converted to local time. For "Anyone", a slot is bookable if `staffMemberIds` isn't empty. Don't send `staffMemberId` in the booking unless the customer picked someone.
6. On slot click: create the cart if needed, then `POST /storefront/appointments` with the slot, `cartId`, `numberOfAttendees` and any add-ons. Then go to checkout.
7. If booking fails with a 4xx, the slot was probably just taken. Refresh the slots, show "That time was just booked, please choose another", and keep the selections.

### Group classes
- The slot list doubles as a timetable. Show `maxAttendees - attendees` spots left, grey out full slots, and cap the party size at the remaining spots. Send `numberOfAttendees`.
- Optional guests: if `allowGuests`, let the customer add friends' emails (up to `maxGuests`). Guests get copies of the emails and don't use up spots.

### Resource grid (courts, rooms)
- Rows are units: the variants of the "Court" product, or the products in a collection. Columns are times on the selected date at the base duration.
- Fetch `current-availabilities` per unit for the chosen date (cache each result; retry on 429 with backoff). A cell is free if that unit has a slot starting at that time.
- On cell click: if the product allows custom duration, show the lengths (base × n up to `maxDuration`). Re-query that unit with `duration=<seconds>` to confirm the block fits, then book the returned slot.
- Each court is one product. Never model the 60-min and 90-min options as separate products; they wouldn't block each other.

### Variable length
Show the length picker before the date/time. Pass `duration` (seconds) to both availability calls, and show the price for each length as `ceil(length / base) × price`, clearly marked as an estimate. The cart total is authoritative.

## Step 4: cart and checkout

- Persist `cart.id` in `localStorage`. Send it as `X-Cart-Id` to your server functions, which forward it.
- Summary: each item's service, staff, local date/time, add-ons, and the line `discountedTotal`; then `subtotal`, discounts (`appliedDiscounts`), `totalTax` and `total`, formatted in `paymentCurrencyCode`.
- **Countdown**: "Your time is held for 9:32". Compute it from `expiresAt`, recompute on tab focus, call `/cart/extend` while the customer is typing, and on expiry start a new cart and send them back to pick a time.
- **Promo code**: a small "Have a code?" field. Show the mapped error message on failure. Show "Code SUMMER10 applied −£5" on success, with a remove button.
- **Customer form** from `checkoutSettings`: email and last name are always required; first name if `customerFirstNameRequired`; phone, company, VAT and billing address according to their `hidden` / `optional` / `required` values. Marketing checkboxes appear only for channels not set to `dont_show`, and are unticked by default. Show a delivery address only when a cart item is at a `delivery` location.
- A signed-in customer gets the form pre-filled from the profile and sent as `{ "kind": "existing", "customerId" }`. A guest sends `{ "kind": "new", … }`. **`kind` is always required.**
- **Questions**: fetch the cart's checkout questions. If there are any, show them as a step, and send each answer with its question text.
- **Payment**: list the providers from `/payment/providers`. Start checkout with the chosen one and branch on the returned `provider`:
  - `stripe`: Stripe Payment Element in the page using the returned `publishableKey`, `stripeAccountId` and `clientSecret`; confirm, then submit with the PaymentIntent id. Install `@stripe/stripe-js` and `@stripe/react-stripe-js`. **No Stripe secret or Stripe connector is needed**, because payments go to the merchant's Stripe account connected in Opencals.
  - `cash` / `bank_transfer`: "Pay at the venue" or "Pay by bank transfer" copy, then submit immediately.
  - `no_payment_required`: the button reads "Confirm booking", then submit immediately.
- Submit with `appointmentsSettings: { markAsScheduled: true }`. On success, clear the cart id, store `auth` tokens if returned (this signs the customer in), and go to the confirmation page.

## Step 5: customer account

- Sign in with a 6-digit email code (`request-login-code` → `verify-login-code`) as the main path. Password sign-in is optional. Handle magic links at a `/link/:token` route with `resolve-link`, then redirect to `redirectPath`.
- Keep tokens for the session, and send the access token to your server functions, which forward it as `Authorization: Bearer …`. On 401, refresh once, then sign out.
- **Appointments**: two tabs, Upcoming (`status=scheduled`, `orderBy=from`, `order=ASC`) and Past (`completed`, `canceled`). Each card shows service, staff, local date/time, location, and status badge.
- **Appointment detail**: reschedule and cancel buttons only if the product allows them and the start is more than `rescheduleGap` / `cancelGap` away; otherwise explain the notice period. Rescheduling reuses the slot picker with `excludeAppointmentId`. Cancelling asks for confirmation first.
- **Profile**: first name, last name, phone. **Preferences**: marketing channels with `checkout_and_account`. **Receipts**: from order invoices.

## Step 6: design

- Take the look from the brief or inspiration: colour palette, typography, photography style, density. Put colours and fonts in theme tokens (CSS variables / Tailwind theme) so they're easy to change.
- Match the tone to the business: warm and editorial for salons, dark and bold for barbers, calm and trustworthy for clinics, energetic and technical for sports venues.
- The slot picker is the core of the product. Give it large tap targets, a clear selected state, a disabled state for full or past slots, a sticky "Continue" on mobile, and local-time labels with the timezone shown when the visitor's timezone differs from the store's.
- Use skeleton loaders for the catalogue and slots. Show a friendly empty state ("No times left on this day — next available: Thu 16 Oct").
- Format prices with `Intl.NumberFormat` in the store currency and durations from seconds ("1 h 30 min"). Use the store's 12H/24H `timeFormat`.
- Build mobile first. Most bookings happen on phones.
- Accessibility: slot buttons with `aria-pressed`, labelled form fields, visible focus, and error messages tied to their fields.
- White-label: the site can carry only the merchant's brand. There's no need to show Opencals branding.

## Step 7: before calling it done

Walk through this with the connected store:

- [ ] Services load from the API; nothing is hard-coded.
- [ ] Days without availability are disabled; slots show in local time.
- [ ] A test booking completes end to end (use a Stripe test-mode store, or cash / no-payment).
- [ ] Refreshing the checkout page keeps the cart; letting it expire recovers cleanly.
- [ ] An invalid promo code shows a clear message; a valid one changes the total.
- [ ] Required checkout fields follow the store's settings.
- [ ] Sign in with the email code works; the new booking shows under Upcoming.
- [ ] Reschedule (including keeping the same day) and cancel work, and are hidden inside the notice window.
- [ ] Errors from the API are shown to the user, not swallowed.
- [ ] No page makes more than a handful of API calls on load; catalogue data is cached.

## Troubleshooting

- **A service is missing or has no times**: the product is inactive, has no schedule/availability, isn't offered at the chosen location, or is outside the advance-booking window. The merchant fixes this in the Opencals dashboard; it isn't a code bug.
- **Everything returns 404 `Cannot GET`**: the connector isn't sending `X-Api-Version: 1`. Fix the connector configuration.
- **401 "Origin not allowed"**: the API key has an allowed-origins list. Use a storefront key without origin restrictions for the connector.
- **Checkout 400**: usually a missing `kind` on `customer`, or a field the store requires. Show `errors[]`.
- **Bursts of 429**: too many parallel availability calls. Cache, use ranges, and back off.
