# Opencals booking site playbook (Base44)

How to build a production booking website (or add booking to an existing app) on Opencals inside Base44. 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"** skill. This file covers how to connect and what to build, in what order.

## Step 0: the `opencals` backend function (build this first)

Every Opencals call goes through **one** backend function named `opencals`. The API reference calls it "the connector": wherever it says the connector adds a header, this function does it.

- The store's Storefront API key is saved in the app's Secrets as `OPENCALS_API_KEY` (it starts with `sfk_`). Read it inside the function. Never put it in frontend code, entities or logs.
- Input: `{ method, path, query?, body?, cartId?, accessToken? }`.
- Allow only paths that start with `/storefront/`. Reject anything else with 400.
- Call `https://api.opencals.com` + `path` (+ `query` as a query string) with these headers:
  - `X-Api-Key: <OPENCALS_API_KEY>`
  - `X-Api-Version: 1` (without it every route returns 404 `Cannot GET …`)
  - `Content-Type: application/json` when there is a body
  - `X-Cart-Id: <cartId>` when `cartId` is given
  - `Authorization: Bearer <accessToken>` when `accessToken` is given
- Return `{ status, data }` with the API's HTTP status and parsed JSON body unchanged (`data` is null for 204). Don't throw on 4xx; the UI needs the API's `message` and `errors[]`.
- The frontend calls it with `base44.functions.invoke('opencals', { ... })`. Keep the cart id and customer tokens in the browser (`localStorage` / session) and pass them in on every call that needs them.
- Test it first: `GET /storefront/stores/public-settings` must return 200 with the store name and currency. 404 `Cannot GET` means the version header is missing; 401 `Origin not allowed` means the key has an allowed-origins list and needs replacing with a key that has none.

This app doesn't need Base44 entities for services, bookings or customers. Opencals stores all of that; use entities only for content that belongs to the site itself.

## 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` backend function.** The browser never calls Opencals directly and never sees the key. Pass `cartId` and the customer's `accessToken` into the function; it forwards them as headers and returns 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.** Treat it as 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`. Pass it as `cartId` to the `opencals` function, which forwards it as `X-Cart-Id`.
- 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 pass the access token as `accessToken` to the `opencals` function, which forwards 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 `opencals` function isn't sending `X-Api-Version: 1`.
- **401 "Origin not allowed"**: the API key has an allowed-origins list. Save a storefront key without origin restrictions as `OPENCALS_API_KEY`.
- **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.
