public booking flow: /book browse → magic-link signup → confirm

This commit is contained in:
2026-05-02 08:46:12 -04:00
parent 415813470a
commit 4c70fe2f39
12 changed files with 1116 additions and 22 deletions

View File

@@ -0,0 +1,93 @@
# 2026-05-02 — Admin Create-Booking Page
> Companion to `Initial.md`. Predecessor: `2026-05-02-auth-and-admin-shell.md`. Closes Step 5b of `Initial.md` §9.
## Milestone
Admin can now take a booking from the browser, end-to-end. The CLI (`scripts/book-on-behalf.ts`) is no longer the only way to create a booking — there's a `/admin/bookings/new` page that wraps the same loader + `createHold` + email pipeline behind a server-rendered form. The practice can now use TouchBase to take phone bookings without leaving the browser.
## What landed
| Path | Role |
|---|---|
| `src/app/admin/bookings/new/page.tsx` | Server-rendered create-booking page. Form (service + date + customer email) → server action redirects to same URL with query params → page renders slot grid. Each slot is its own form whose server action calls the booking pipeline. |
| `src/app/admin/bookings/page.tsx` | Added "New booking" link in the header |
No new dependencies. No schema changes.
## What's verified end-to-end
In the dev-server smoke test (curl + Mailpit, server running on :3000):
- `GET /admin/bookings/new` (signed in) → 200, form renders
- `GET /admin/bookings/new?serviceId=…&customerEmail=alex@example.com&date=2026-05-05` → 33 slots rendered (10:00 EDT through 18:00 EDT, every 15 min, for a 60-min service)
- `GET /admin/bookings/new?…&customerEmail=ghost@example.com&…` → "No customer registered for ghost@example.com" inline error
- `GET /admin/bookings/new?…&date=2026-05-04` (Monday, not in working hours) → "No available slots on this date"
Plus: 64/64 tests, lint clean, typecheck clean.
The actual `bookSlotAction` (server action that creates the booking) wasn't exercised via curl because Next.js server actions require a signed POST that's annoying to replay; all of its building blocks (`createHold`, `confirmHold`, `sendBookingConfirmation`, the loader, `findSlots`) are unit-tested individually plus end-to-end via the existing CLI smoke test.
## Decisions ratified
| Decision | Resolution |
|---|---|
| Form architecture | Multi-step server-rendered. No client state. Form 1 = filter (service+date+customer); slot grid renders on submit; each slot is its own server-action form. Reason: simplest possible thing that works; preserves admin's filter state across page loads via query params. |
| Customer selection | Plain email input (no autocomplete/search-as-you-type yet). Reason: keep client JS out for v1; admin already knows customer emails. Revisit if it gets clunky. |
| Slot picker UX | 46 column grid of buttons, each labeled with local time only (e.g. "10:00 AM"). Date is fixed by the filter above. |
| Greedy assignment | First eligible therapist + first eligible room from the slot's candidate lists. Same as the CLI. Reason: more sophisticated load-balancing waits until we have data on whether it matters. |
| Error surfacing | Errors round-trip through the URL via `error=...` query param. Reason: server actions in Next.js can't return errors directly to the same page without client state; redirect-with-error is the simplest pattern. |
| Date input minimum | `min=` set to today (in practice TZ via `Intl.DateTimeFormat` with `en-CA` locale to get YYYY-MM-DD). |
## Gotchas hit
### `searchParams` is async in Next.js 16
The `searchParams` page prop is now `Promise<…>` in Next 15+. Must `await searchParams` before reading. Easy to miss — TS will catch it but only if the type annotation is correct.
## Open questions still unresolved
1. Customer-visible brand name
2. Currency
3. Stripe account ownership
4. **NEW**: Customer search-as-you-type vs. plain email field — admin UX call. Plain email works; autocomplete is nicer. Defer until requested.
## Roadmap status (`Initial.md` §9)
1. Spike — done 2026-04-30
2. Schema + seed — done 2026-05-01
3. Availability algorithm — done 2026-05-01
4. First end-to-end story (admin booking + email) — done 2026-05-01
5. Public self-booking → Stripe → reminders
- 5a Auth.js + admin shell — done 2026-05-02
- **5b Admin "create booking" page — done 2026-05-02 (this session)**
- 5c Public booking page (CUSTOMER role, magic-link signup, slot search + checkout)
- 5d Stripe deposit flow + webhook
- 5e Email reminders (pg-boss scheduled jobs)
## Recommended next step
**5c — public booking page**. Most of the slot-picker UI carries over from `/admin/bookings/new`; the differences are:
- **CUSTOMER auth flow**: magic-link signup if email isn't registered. Auth.js's `signIn("nodemailer", { email })` already creates a User row via the adapter; we just need to also create a `Customer` row + redirect into a booking flow.
- **Public-facing styling**: likely a polished landing page with service grid → "Book" → date+slot picker → confirm.
- **Customer can't pick therapist or room directly** — algorithm assigns greedy.
- **Hold semantics matter more**: customer pays deposit during the hold window; if they bail, hold expires.
That last point pulls Stripe (5d) closer in dependency: deposit-required services need the payment flow before the booking can become CONFIRMED. We may want to do 5c in two phases:
- **5c.1**: public booking with no payment (CONFIRMED on click). Fast to ship; lets the practice test the customer-side UX before Stripe.
- **5c.2**: add the deposit payment flow (Stripe).
Either approach is fine. Recommend 5c.1 first (~35 days), then 5d (~35 days, gates 5c.2).
## How to resume
```bash
cd /Users/noise/Documents/code/touchbase
docker-compose up -d postgres mailpit
pnpm db:seed
pnpm dev
# Sign in at http://localhost:3000/login as admin@touchbase.local
# Click magic link in http://localhost:8025
# /admin/bookings → "New booking" → fill form → pick slot → done
```