94 lines
5.6 KiB
Markdown
94 lines
5.6 KiB
Markdown
# 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 | 4–6 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 (~3–5 days), then 5d (~3–5 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
|
||
```
|