# The Site API

For pages on a business's Latchbell website. Write {{slug}} and {{booking_url}} as they are: Latchbell fills them in. Upload with upload_site_pages; a site hosted elsewhere uses chat_embed.script_embed instead.

The page's script talks to its own address only. Every call is a Latchbell customer tool with the business fixed by the
address: the same tools AI assistants use, with the same checks. Prices and times always come from here; never type them
into the HTML.

Load the client once: `<script src="/latchbell.js"></script>`. It defines `window.Latchbell`. Every call returns a Promise;
a failure rejects with an `Error` whose `message` is written for the customer: show it.

| Client | Call | Returns |
|---|---|---|
| `Latchbell.business()` | `business_info` | `business` (the name), `business_type`, `hours`, `service_area`, `service_zips`, `booking` (`enabled`, `mode`: `book`, `request` or `off`, `reason`), `accepting_requests` |
| `Latchbell.catalog()` | `catalog` | one object: the catalog's `categories`, `items`, `optionGroups`, `fulfillment` at the top level, plus `next` (which calls finish each way), `team` (first names a customer may ask for), `request_only`, `time_zone` (raw `fetch('/api/v1/catalog')` has them under `.catalog`) |
| `Latchbell.quote(selections, fulfillment)` | `quote` | `lines` (each with `choices`, `unit_cents`, `line_cents`, `adjustments`), `estimate_cents`, `total_cents` (null when partial), `pricing` (estimate/from/partial), `duration_minutes`, `catalog_revision`, `delivery_fee_cents`, `tax_cents` |
| `Latchbell.times(selections, 'YYYY-MM-DD', {timeOfDay, who, onlyWho, repeat})` | `find_times` | `request_only`, `reason`, `time_zone`, `estimate` (the same as `quote()`, with the repeat's price), `slots[]` (`start` to pass back, `when` the label to show, e.g. "Tue, Oct 20, 12:00 PM", and `seats_left` for a table) |
| `Latchbell.book({selections, estimate, start, customer_name, customer_phone, customer_email, address, zip, note, who, only_who, repeat, visits, request_key})` | `book` | `booking` (`booking_id`, `status`, `summary`, `when`, `price_cents`, `with`, `party`), `reply_token`, `conversation_url`, `next` |
| `Latchbell.request({…the same without start, repeat and visits, preferred_date, preferred_time})` | `request_booking` | the same, with `booking.status: requested` and no time |
| `Latchbell.cancel(bookingId, replyToken)` | `cancel_booking` | `booking`, with `status: cancelled` |
| `Latchbell.move(bookingId, replyToken, start)` | `reschedule_booking` | `booking` at its new time |
| `Latchbell.checkIn(bookingId, replyToken)` | `check_in` | `booking`, from 30 minutes before it starts |
| `Latchbell.call('booking_status', {booking_id, reply_token})` | `booking_status` | `booking` |
| `Latchbell.products()` | `products` | older view of the pickup and delivery items (`products`, `variants`), `accepting_orders`; prefer `catalog()` |
| `Latchbell.estimateOrder(lines)` | `estimate_order` | `items`, `estimate_cents`, `tax_cents`, `total_cents`, `pricing`, `catalog_revision` |
| `Latchbell.requestOrder({lines, estimate, customer_name, fulfillment, preferred_time, note})` | `request_order` | `order`, `conversation_url` |
| `Latchbell.payOrder({lines, estimate, customer_name, fulfillment, preferred_time, note})` | `checkout_order` | takes the customer to Stripe's payment page |
| `Latchbell.message(name, text)` | `contact_business` | `conversation_url` |
| `Latchbell.chat(name, text)` | `contact_business` / `follow_up_business` + `inquiry_status` | `{ replies, ai_answers, waiting, messages, conversation_url }`: the business's new messages after yours (each with `ai: true` when its AI agent wrote it) |
| `Latchbell.chatHistory()` | `inquiry_status` | this tab's conversation so far (`messages`, `request_status`, `ai_answers`, `answering`, `conversation_url`), or `null`; no `waiting` |
| `Latchbell.money(cents)` | | `"$22"`, `"$12.50"`, `"Price to confirm"` for null |

- `selections` (quote, times, book, request) and `lines` (orders) are the same shape:
  `[{ item_id, quantity, options: { <group id>: [<choice id>, …] }, fields: { <field id>: "answer" } }]`. Offer each item's
  own option groups in the order it lists them (only its `only` choices when set), respect each group's `min`/`max` and
  choices' `excludes`, one item per `group`, adults and kids apart. A choice with `sets` replaces the item's price (show it
  as the price), one with `times` multiplies what's before it (show "×1.5"), others add. Answers in `fields` are text,
  even a number. Never add up a total on the page: only `quote()` knows the order, the minimum and the repeat discount.
- Without the client: `fetch('/api/v1/<tool>', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(args) })`
  for any call; reads may be GET with query parameters. Don't send `business`: the address decides it.

## Rules the server enforces (build the page to match)

- **Estimate, then confirm.** `book`, `request` and `requestOrder` need the `estimate` the customer saw: Latchbell refuses if
  the catalog or the price changed since. Show the itemized estimate and total on a review step; call book/order only from
  the customer's click on a clearly labelled button ("Book Tue 2:00 PM · $58", "Send order request · $49").
- **A name, and a phone or an email.** Every booking flow asks for both, or the server refuses it: a name, and a mobile
  number (10 digits) or an email. One of the two is enough, so don't require both. Orders and chats need only a name.
- **An address for work at the customer's place.** An `onsite` item needs `address` and a 5-digit `zip` from
  `business().service_zips`. Check the ZIP before showing times, and say so kindly when the business doesn't serve it.
  Don't ask for an address otherwise, unless the item has a field for it.
- **One attempt, one request key.** The client makes one per call. If you retry after a network error, make one with
  `Latchbell.key()` and pass the same `request_key` each time, so the customer isn't booked twice. A key belongs to one set
  of details: when they change something, use a new one.
- **`request_only`** (from `catalog()` or `times()`): times aren't promised (a duration or price is missing, the team
  confirms everything, or the hours aren't set), and `reason` says why. Then the flow ends in `request()` with a preferred day
  and time, and the button says "Send request". Never show times you didn't get from `times()`.
- **`booking.mode` `off`** (from `business()`): nothing can be booked here, not even by request: leave the booking flow
  out and let the chat and the Call button do the work.
- `pricing: from` or `partial`: say "from" or "plus items the business prices", never a firm total.
- Booking takes no payment unless the item says `payment: upfront`: otherwise say "Pay at your visit". With `upfront`, label
  the button "Pay $58 and book": `book()` takes the customer to Stripe's own page, the time is held for them while they
  pay, and they come back to their conversation link. Never ask for card details on the page. Orders: see "Things to take
  or have delivered".
- After success, show what happened and a link to `conversation_url` ("Your booking and messages") that the customer
  should keep. Only `booking.status` `confirmed` means booked, and `requested` means sent for the team to confirm; for
  any other status show `next`, and never say it's booked. That link lets them see, move, cancel and check in, so most
  pages stop here. To offer those on the done screen, use `booking.booking_id` and `reply_token` from the answer, in memory
  only: `reply_token` is private, so never show it, put it in an address or store it anywhere.

## Flows to build

Read the catalog once on load (`catalog()`), then build the flow for each way its items are had (`next` lists them). A
business may need several: a salon's services and its retail shelf; a restaurant's pickup, delivery and tables.

**Booking a time** (items with `appointment`, `onsite` or `reservation`). One flow for all of them, whatever the business:

1. On load, `catalog()`, and `business()` for `booking.mode` and `service_zips`. Category chips; each item shows name, price
   (`money`, "from" when `pricing` is from), duration if known, and a short description. Kids' items in their own tab when
   `audience` is child.
2. Choosing an item opens its option groups in the item's order (radio for pick-one, checkboxes for up to N, the first
   choice of a required group already picked) and its required `fields`, which `quote()` needs before it can answer. Keep a
   sticky summary with `quote()` refreshed on each change (debounced), in `aria-live`, with each line's `adjustments`
   (a minimum, a repeat discount).
3. When `team` has more than one name, offer "Anyone" first, then each name as `who`: a wish someone else may take. `onlyWho`
   keeps it with them, with fewer times.
4. When the item has `repeats` (`{ "weekly": 15 }`), offer them ("Weekly, 15% off"), and pass `repeat` to `times()` and to
   `book()`, with `visits` for how many, the first included (up to 10, as far ahead as the business takes bookings).
5. Date (next 14 days as buttons) → `times()` → time buttons grouped morning/afternoon/evening, each showing its `when` as it
   comes, or the request form when `request_only`. An item with `arrivalWindow` shows a window ("Arrives Tue, Oct 20, 8:00 –
   10:00 AM"): call it the time they arrive, never an exact start.
6. Review: items, total, time, name, phone or email, the address and ZIP for `onsite`, an optional note → the book button.
7. Done screen with `when` and `conversation_url`.

```javascript
// Real ids and starts from the live answers: never type these into a page.
const selections = [{ item_id: item.id, options: { [group.id]: [choice.id] } }];
const estimate = await Latchbell.quote(selections);            // show estimate.lines and its total
const open = await Latchbell.times(selections, date, { repeat });
if (open.request_only) { /* the request form: say open.reason */ }
const r = await Latchbell.book({ selections, estimate: open.estimate, start: slot.start, repeat, customer_name, customer_phone, customer_email, address, zip, note });
if (r.booking.status === 'confirmed') { /* show r.booking.when and r.conversation_url */ }
```

Pass `times()`'s `estimate` to `book()`: with a repeat it's the only one with the repeat's price. `request()` takes no repeat, so
send it `quote()`'s estimate, and put the repeat they wanted in `note`. On failure keep what they entered, show the error and
let them choose again from fresh times; don't retry a booking by yourself or claim it worked.

**A table, a class, a court** (items with `reservation`): the same flow, where a place is held for a party. `quantity` is the
party: ask for it first (1 to the item's `maxQuantity`), and send `[{ item_id, quantity: party }]`. With one such item, select it
for them: there's no list to show. Show `seats_left` when it's low. `request_only` here usually means the business hasn't said
how many it seats. A confirmation says the party (`booking.party`) and `when`.

**At the customer's place** (items with `onsite`): the same flow, with the address and ZIP. The price is often built from options
(rooms, size, the kind of job), so show `quote()` changing as they choose, and ask for the ZIP early, so nobody picks a time
for an address the business doesn't serve.

**Things to take or have delivered** (items with `pickup`/`delivery`): cards with photo (`/photos/<photo>`), price (the
size's when a `sets` group exists), option pickers per group (radio for pick-one, checkboxes for up to N), quantity; a
basket with `estimateOrder(lines, fulfillment)` (delivery includes its fee); pickup or delivery as the catalog offers
(delivery asks for the address and ZIP, and says the minimum); name, preferred time, note, and each item's `fields` →
`requestOrder`.

**Product photos come from the catalog, as the page loads.** Each card's picture is `'/photos/' + it.photo` from the
`catalog()` it was drawn from, and a card without `it.photo` simply has none (no stand-in). Never write a list of item photos
into the page's script: the owner adds and changes photos in the app, and only `it.photo` follows. Latchbell won't publish a
page whose script lists the catalog's photos, or whose order flow shows an item without the photo the catalog has for it.

**Paying for a basket:** every basket handles both cases, and the page's script decides each time it loads, never you when you
build it, so a basket made today starts taking payment the day the store finishes with Stripe. `products()` and `catalog()` say
`payments.online`. When it's true and the estimate's `pricing` is
`estimate` (every price fixed), the basket ends in a **Pay now** button: `Latchbell.payOrder(d)` takes the same details as
`requestOrder` and sends the customer to Stripe's own payment page in the store's name, and they come back to their conversation
link. The money goes straight to the store. Show the total first: `total_cents` includes sales tax, so when `tax_cents` isn't 0 show it
on its own line. Show any error it returns; in Latchbell's check it answers
without leaving the page, so show the confirmation then. Otherwise use `requestOrder` and say "The shop confirms your order and
price". Never show Pay now unless both are true, and never ask for card details on the page.

**Chat with the business:** the bell (`<div data-lb-bell="{{slug}}"></div>`) by default; `data-lb-open-chat` on a button opens it. For a chat in the page, `<section id="chat" data-lb-chat data-title="…"></section>` (with
`/latchbell.js` on the page) becomes a ChatGPT-style chat answered by the AI agent; color it with
`--lb-chat-accent`, `--lb-chat-bg`, `--lb-chat-ink`. Only if the owner wants something it can't do, build one as below:
a message list and one input. Restore `chatHistory()` before
enabling Send, so a slow history response cannot append old messages after a new one. `null` means no conversation yet.
Ask the name once, or send "Website visitor"; once `chatHistory()` returns a conversation, hide the name field and
don't require it (later messages continue the same conversation). Set `maxlength="500"` and validate trimmed messages as 1–500 characters;
both the initial message and follow-ups have that limit.

A typical `chatHistory()` response is below; `chat()`'s `messages` / `replies` contain the same message objects:

```json
{
  "request_status": "replied",
  "ai_answers": true,
  "answering": false,
  "messages": [
    { "id": "msg_example_customer", "sender": "customer", "from": "You", "text": "Do you take walk-ins?", "at": 1792594800000 },
    { "id": "msg_example_reply", "sender": "pro", "from": "Example Studio · AI agent", "ai": true, "text": "Yes, during our opening hours.", "at": 1792594801000 }
  ]
}
```

- **Use `mine` or `sender` for identity:** every message carries `mine` (true for the visitor's own); `sender === 'customer'` is the visitor; `sender === 'pro'` is the business. `from` is only
  a display label (a visitor's is `"You"`), never a role. Do not test `from || role || sender` or guess from names.
- A message with `system: true` is a neutral Latchbell note, not a person or an AI answer. Otherwise, label a business
  message "AI agent" only when its own `ai` is true; other business messages use the business's name. Render all
  message text as plain text with `textContent`, in the returned order. Use each stable `id` to avoid duplicates when
  refreshing; do not turn an optimistic visitor bubble into a second copy when its saved message arrives.
- On send, disable Send, show one pending/typing indicator, and call `Latchbell.chat(name, text)`. It waits up to about a
  minute for the AI agent when one is answering. Render its returned messages/replies using the same rules above.
  Only clear the input after success. On failure, retain the customer's text, show the error beside the composer, remove
  the pending indicator and re-enable Send. Always release the busy state, including after a rejected request.
  A failed history load must also restore the controls and show a recoverable error, not leave the chat loading forever.
- `chat()` returns `waiting` and `conversation_url`. When waiting, say the question is waiting for the team and show that
  private conversation link if returned. `chatHistory()` instead returns `request_status` (`waiting_for_response`,
  `replied` or `closed`), `answering` and the same `conversation_url`; it has **no** `waiting`. After a reload, derive waiting from
  `request_status === 'waiting_for_response'`, and show a typing state only while `ai_answers && answering`. Never invent
  or reconstruct a private link from the inquiry ID.
- Maintain one status element: update or remove it as the state changes, rather than appending another waiting note on
  every send or refresh. While the chat is open and waiting, refresh `chatHistory()` every few seconds to display later
  team/AI replies; stop when closed, and reconcile by message ID. Never promise an instant answer; say what happened.

**By request** (`request`): show the items and options with prices, then `Latchbell.message(name, text)` with what they chose and
when it suits them; say the business will reply.

Keep every flow keyboard- and phone-friendly: real buttons, labels, 44 px targets, 16 px inputs, focus moved to each new
step's heading, errors next to the button that failed.

## Test hooks (required)

Latchbell runs every draft in a real browser on a phone and a computer, using these attributes, and won't publish a page
whose flows don't work. Its writes are dry runs: nothing is booked or sent. Mark each flow and its controls:

- `data-lb-flow="order"` on the box for pickup/delivery ordering, `"book"` for anything with `appointment` or `onsite`,
  `"reserve"` for anything with `reservation` (one box per flow the catalog offers).
- Inside it, on the real controls:
  - `data-lb="add"`: each item's add or select button (the check clicks the first). Pre-select the first choice of every
    required (`min` 1) option group, so adding works without more clicks. A `reserve` box with one item needs none.
  - `data-lb="date"` (a date input, value `YYYY-MM-DD`), `data-lb="party"` (party size), `data-lb="find"` (the button that
    loads times, if there is one), `data-lb="time"` (each time button).
  - `data-lb="name"`, `"address"`, `"zip"`, `"phone"`, `"email"`, `"note"`: the inputs, when the flow has them. A `book` or
    `reserve` box must show `phone` or `email` once a time is chosen, or the check fails.
  - `data-lb="submit"`: the button that books, orders or reserves.
  - `data-lb="done"`: the confirmation, present only after it succeeded.
- Keep hooked controls visible and uncovered (nothing sticky on top of them) at 390 px and 1440 px.
