# Public booking sites

**Path:** `/b/<slug>` on the runtime · **Postgres schema:** `sites` (migration slot `2026102411xxxx`) · **Services:** Supabase (tables, Storage), the checkout API (Stripe, built separately)

Each business gets its own public site: its theme, its title and tagline, a large side picture, its offerings with prices, and the booking flow. There is no demo chrome and no business switcher; the page looks like the business's site.

## Demo moment (under 60 seconds)

1. Open **[/b/spin-mitte](https://hackathon.fund-bench.com/b/spin-mitte)**. Red Spin Mitte site with the bikes photo. Click **Book a bike**, pick Monday, the 18:00 class, a free bike on the floor plan, then **Hold Bike n for 15 min**. The countdown starts; the staff calendar (`/demos/capacity-engine/internal.html`) shows the bike as held.
2. Enter a name and email, then **Pay 18 €**. With the checkout API live, the browser goes to Checkout and comes back to `/b/spin-mitte/booking/<id>`, which shows "Confirming your payment…" and then "You're booked". Until the API exists, the page shows **Payment step not connected yet** and keeps the hold.
3. Open **[/b/padel-ost?edit=1](https://hackathon.fund-bench.com/b/padel-ost?edit=1)**. The owner panel opens on the left. Click a library picture: the side picture changes at once. Click **Use on the side**: it is saved for every visitor. Edit the tagline and click **Save title and tagline**. **Upload your own picture** puts the business's own photo into the grid.
4. Optional: **[/b/nordwerk-office](https://hackathon.fund-bench.com/b/nordwerk-office)**: the project office presents its services and rates; **Request hours** shows how many hours are still open that week and sends a request.

## Run

```sh
# Runtime (Worker): pages at /b/<slug>, API at /api/sites
cd runtime && npm install && npx cf dev
open http://localhost:5173/b/spin-mitte

# Static, no runtime: seed data, holds in this browser, checkout "not connected"
python3 -m http.server 3000          # from the repository root
open "http://localhost:3000/demos/booking-site/?site=spin-mitte"

# Tests: path parsing, validation, picture order, schedule text, checkout hand-off (node:test)
cd demos/booking-site && npm test
```

`cf dev` runs without Supabase keys, so local runs use the seed fallback (reads work, owner saves answer `503` and stay a preview). The deployed runtime has the keys.

## Pages

| URL | What |
|---|---|
| `/b/<slug>` | The site. Interval businesses (Spin Mitte, Padel Ost): day → only free slots → floor plan → hold with countdown → name and email → **Pay**. Quantity businesses (Nordwerk): services, rates and an hours request. |
| `/b/<slug>/booking/<id>` | Confirmation after Checkout: booking details and status, polled every 2 s (5 s after 90 s) until `paid`, `failed`, `expired` or `cancelled`. |
| `/b/<slug>?edit=1` | Owner preview panel: title, tagline, side picture (library, own uploads, upload). Sends `x-demo-owner: 1`. |
| `/demos/booking-site/?site=<slug>[&booking=<id>]` | Same pages on a static server, where `/b/…` does not exist. |

The runtime serves `index.html` for everything under `/b/`, so every asset and fetch in the page uses an absolute path. Module imports are relative to the module, which is safe.

## Payment hand-off (contract with the checkout API)

`POST /api/checkout/sessions`

```json
{ "business": "spin-mitte", "claim_id": "<capacity claim>", "offering": "spin-45", "start": "2026-10-26T17:00:00.000Z", "resource": "bike-7",
  "customer": { "name": "Ada Lovelace", "email": "ada@example.com" },
  "return_url": "https://hackathon.fund-bench.com/b/spin-mitte/booking/{BOOKING_ID}",
  "cancel_url": "https://hackathon.fund-bench.com/b/spin-mitte?checkout=cancelled" }
```

→ `{ booking_id, url, provider: "stripe" | "simulated" }`; the page redirects to `url` (http or https only). The checkout replaces `{BOOKING_ID}` in `return_url`. The amount is not sent: the checkout prices the offering from the business config.

`GET /api/checkout/bookings/:id` → `{ booking: { id, business, status, amount, currency, platform_fee, provider, start, resource, customer_name } }`. The platform fee is not shown to customers.

- **Not connected:** the runtime's generic `404 not_found "No route for …"`, or a 404/405/501 without JSON (static server), shows **Payment step not connected yet**. The hold stays; nothing is faked.
- **Other errors** (for example `409` hold expired) show the API's message, and **Try again** stays available.
- **Cancelled checkout:** `cancel_url` brings the customer back; the hold and the name and email come back from `sessionStorage`, with "Payment cancelled. Nothing was charged."

## Runtime API ([`runtime/src/routes/sites.ts`](../../runtime/src/routes/sites.ts))

| Route | Purpose |
|---|---|
| `GET /api/sites/:slug` | `{ site, side_media, business, source }`: the `sites.business_sites` row by `path_slug` (published only), its side picture, and the business config from `core.businesses` (seed file fallback) |
| `GET /api/sites/:slug/media` | `{ business, media, source }`: library pictures (`business_slug` null) and this business's uploads, newest first |
| `POST /api/sites/:slug/media` | Owner only. Multipart `file` (JPEG, PNG or WebP, at most 5 MB, type checked against the first bytes), optional `alt`, `width`, `height`. Uploads to Storage `site-media/<business>/<id>.<ext>` with the secret key, inserts the `sites.media` row, removes the object again if the insert fails. At most 24 uploads per business. |
| `PUT /api/sites/:slug` | Owner only. `{ title?, tagline?, side_media_id? }`; unknown fields are refused; the picture must be a library picture or the business's own. |

Owner only means the header `x-demo-owner: 1`, otherwise `403 owner_only`. Without Supabase, reads serve [`lib/seed.js`](lib/seed.js) (a copy of the migration's seed rows) and writes return `503 storage_unavailable`. Validation lives in [`lib/site.js`](lib/site.js), shared by the page, the runtime and the tests.

## Files

| Path | What |
|---|---|
| [`index.html`](index.html), [`site.css`](site.css) | Page frame and styles. Business theme through the shared CSS variables; the owner panel uses the platform tokens. |
| [`app.js`](app.js) | Reads the slug, loads the site, applies the theme, side picture, photo credit and favicon, mounts the view |
| [`booking.js`](booking.js) | Interval booking flow (capacity data layer from `demos/capacity-engine/data.js`) |
| [`office.js`](office.js) | Project office page and hours request |
| [`confirmation.js`](confirmation.js) | Booking status page |
| [`owner.js`](owner.js) | Owner preview: text, picture picker, upload with resize to 1800 px |
| [`api.js`](api.js), [`dom.js`](dom.js) | Network calls with fallbacks; escaping and dates in the business time zone |
| [`lib/site.js`](lib/site.js), [`lib/seed.js`](lib/seed.js) | Pure rules and seed copy (with `.d.ts` for the runtime) |

Reused from the capacity engine: `business-theme.js` (theme → design system), `engine.js` (time zone helpers) and `data.js` (availability, holds, release; falls back to the local engine when the API fails).

## Simulated or not yet real

- **Payment** belongs to the checkout API. This page never marks anything paid by itself.
- **Nordwerk's hours request** shows a confirmation only: nothing is stored, allocated or emailed. Free hours shown next to the form are real (pool utilisation, read only). It does not call `POST /api/capacity/allocations`, so the capacity demo's scripted over-allocation moment stays intact.
- **Owner preview** has no sign-in. Anyone who sends `x-demo-owner: 1` can edit a site or upload pictures, capped at 24 uploads per business.
- **"You pay by invoice"** and **"reply within one working day"** on the office page are sample business copy.
- Library photos are from Unsplash, credited in the footer while shown. Own uploads carry no credit.

## Fallbacks

| Situation | Behaviour |
|---|---|
| Runtime without Supabase | Seed site, pictures and configs; availability from the local engine; owner saves say "Not saved" and stay a preview in the tab |
| No runtime (static server) | Same seed data from the page itself; holds in `localStorage` |
| Capacity API fails mid-session | `data.js` switches to the local engine; the footer says bookings stay in this browser |
| Checkout API missing | "Payment step not connected yet", hold kept |
