diff --git a/docs/progress/2026-05-02-booking-lifecycle.md b/docs/progress/2026-05-02-booking-lifecycle.md new file mode 100644 index 0000000..a553818 --- /dev/null +++ b/docs/progress/2026-05-02-booking-lifecycle.md @@ -0,0 +1,90 @@ +# 2026-05-02 — Booking Lifecycle (Phase C) + +> Companion to `BuildLog.md`. Predecessor: `2026-05-02-therapist-self-serve.md`. Closes Phase C of the post-payments UX plan. + +## Milestone + +The booking lifecycle has all the verbs the practice needs: create, view, **reschedule, mark complete, mark no-show**, cancel — for both customer and admin. Reschedule is atomic (cancel-old + create-new in one transaction; rollback on conflict so the customer never loses their old slot to a phantom new booking). Single "rescheduled" email replaces the cancel + new-confirmation pair. Booking detail pages exist for both customer and admin. + +## What landed + +| Path | Role | +|---|---| +| `src/lib/booking.ts` | +`rescheduleBooking()` (atomic, transactional, surfaces `BookingConflictError` if new slot collides — old booking stays intact). +`markComplete()` and +`markNoShow()` (CONFIRMED→target only, idempotent). | +| `src/lib/email.ts` | +`sendBookingRescheduled()` template (one email summarizing old → new). | +| `src/app/admin/bookings/[id]/page.tsx` | Detail page — all booking fields incl. cancel-audit, payment-status placeholder, room-released-at, action buttons (Mark complete / Mark no-show / Cancel). Mark-* buttons only appear on past CONFIRMED bookings. | +| `src/app/account/bookings/[id]/page.tsx` | Customer detail page — service / when / therapist / room / price + Reschedule + Cancel. Owner-or-admin authorization on the action. | +| `src/app/book/page.tsx` | Accepts `fromBookingId` param — shows "Rescheduling your X — pick a new time" banner; threads param through to slot links and the date picker form. | +| `src/app/book/confirm/page.tsx` | Detects `fromBookingId` and routes through `rescheduleBooking` + `sendBookingRescheduled` instead of `createHold` + `sendBookingConfirmation`. Page heading and copy adapt. Conflict redirects back to `/book` with `taken=1` and the `fromBookingId` preserved. | +| `src/app/account/bookings/page.tsx` | Per-row `Reschedule` link → `/book?serviceId=…&fromBookingId=…`. Booking row is now itself a link to the detail page. | +| `src/app/admin/bookings/page.tsx` | Booking row links to detail page. Therapist-name fallback for null. | +| `test/booking.test.ts` | +8 tests: reschedule happy path, atomic rollback on conflict, rejects rescheduling CANCELLED, can change therapist/room on reschedule, markComplete CONFIRMED→COMPLETED, markComplete idempotent, markComplete rejects HOLD, markNoShow CONFIRMED→NO_SHOW. | + +## What's verified + +- `pnpm test` — **79/79 green** (was 71; +8 new in this session) +- `pnpm lint` — clean +- `pnpm exec tsc --noEmit` — clean +- Live smoke test (curl + Mailpit, dev server): + - **Customer (alex@example.com)**: signed in via magic link → `/account/bookings` shows Reschedule + Cancel buttons → click Reschedule → `/book?fromBookingId=…` shows "Rescheduling your..." banner and slot links carry `fromBookingId` → click new slot → `/book/confirm` shows "Confirm reschedule" heading, "previous booking will be cancelled" copy, hidden `fromBookingId` field + - **Admin (admin@touchbase.local)**: `/admin/bookings/{id}` renders full detail with customer name, therapist link, room link, all fields, Cancel button (Mark complete / no-show buttons hidden because the booking is in the future — they appear only after the booking end time) + +## Decisions ratified + +| Decision | Resolution | +|---|---| +| Reschedule semantics | **Atomic** — cancel-old + create-new in one `db.$transaction`. If the new insert fails (conflict, FK, etc.), the entire transaction rolls back; old booking stays intact. Reason: customer should never lose their slot to a phantom new booking. | +| Reschedule email | **One** rescheduled email, not cancel + new-confirmation pair. Reason: cleaner customer experience; one notification with old-vs-new framing. | +| Mark complete / no-show timing | Buttons only render when `status === "CONFIRMED" && booking.endsAt < now`. Reason: marking a future booking complete is meaningless and almost always a misclick. | +| Mark complete / no-show idempotency | Re-marking returns `{ ok: true }` (no-op). Switching from one final state to another is rejected. Reason: prevents accidental status flips after the fact. | +| Cancel reason on reschedule | `"rescheduled"` literal in `Booking.cancelReason`. Reason: distinguishable from customer/admin cancellation in audit; could drive metrics later. | +| Authorization on customer detail page | Owner or admin can view; otherwise redirect to `/account/bookings`. Same on cancel action. | +| Authorization on admin detail page | Layout already requires `role=ADMIN`; actions re-check on the safe side. | +| `/account/bookings/[id]` reachable by admins | Yes — they may want to debug from a customer's POV. The cancel button still works. | +| Reschedule preserves service by default | `rescheduleBooking` defaults newServiceId/therapist/room to the old booking's. The customer flow always passes the same serviceId; admin could pass different ones if/when we build admin-side reschedule. | +| Mark-* helpers return discriminated unions | `{ ok: true }` or `{ ok: false; reason: string }`. Reason: same shape as `cancelBooking` so the UI can react without exceptions. | + +## Gotchas hit + +None — clean session. + +## Open questions + +1. Customer-visible brand name (still pending) +2. Currency +3. Stripe account ownership +4. **NEW**: should reschedule require explicit confirmation if the old booking was CONFIRMED with a deposit? Not relevant until 5d Stripe. +5. **NEW**: admin "reschedule on customer's behalf" UI flow — currently admins cancel and the customer rebooks. We have the `rescheduleBooking` helper; the admin UI for it is a future enhancement. + +## Roadmap status + +UX-completeness: + +- A — Admin CRUD: done 2026-05-02 +- B — Therapist self-serve: done 2026-05-02 +- **C — Booking lifecycle UX: done 2026-05-02 (this session)** +- D — PWA shell (per `BuildLog.md` §11) +- E — Polish — 404, empty states, mobile review, dark-mode glance + +## Recommended next step + +**Phase D — PWA shell**. Per `BuildLog.md` §11, this is the "Stage 1" mobile story we promised at bootstrap: + +- `manifest.webmanifest` with name, icons, `display: "standalone"`, theme color, start URL +- A minimal service worker (`next-pwa` or hand-rolled with Workbox) — **network-first for HTML, stale-while-revalidate for static assets, no caching of API/booking endpoints** +- iOS Safari quirks: separate `apple-touch-icon`, `apple-mobile-web-app-capable`, status bar style +- Test on a phone or in DevTools "Add to Home Screen" + +~half-day to a full day. After D, Phase E (~half-day): 404 page, empty-state polish, mobile-first audit, dark-mode glance. + +## How to resume + +```bash +cd /Users/noise/Documents/code/touchbase +docker-compose up -d postgres mailpit +pnpm db:seed +pnpm tsx scripts/book-on-behalf.ts alex@example.com "60-minute Swedish" 2026-05-05T10:00 +pnpm dev +# As alex: sign in → /account/bookings → click row to see detail → Reschedule → pick new time → confirm +# As admin: sign in → /admin/bookings → click row → view detail with action buttons +``` diff --git a/docs/progress/2026-05-02-pwa-and-polish.md b/docs/progress/2026-05-02-pwa-and-polish.md new file mode 100644 index 0000000..e97d5b5 --- /dev/null +++ b/docs/progress/2026-05-02-pwa-and-polish.md @@ -0,0 +1,122 @@ +# 2026-05-02 — PWA Shell + Polish (Phases D + E) + +> Companion to `BuildLog.md`. Predecessor: `2026-05-02-booking-lifecycle.md`. Closes Phases D and E of the post-payments UX plan — UX-completeness done. + +## Milestone + +Two small phases bundled because both came in well under estimate. The app is now installable as a PWA (Add to Home Screen on iOS / Android, manifest + service worker + icons), has friendly 404 / error pages instead of Next's default boxes, and the slot grid + admin tables stop crushing on narrow viewports. + +**This closes the UX-completeness pivot.** Backend is feature-complete for v1 minus payments + reminders. Next workstream is **5d Stripe**. + +## What landed + +### Phase D — PWA shell +| Path | Role | +|---|---| +| `public/icon.svg` | Simple TB monogram on dark rounded square — works as favicon and as PWA icon at any size | +| `public/icon-mask.svg` | Maskable variant — full-bleed background, content in safe area for Android adaptive icons | +| `src/app/manifest.ts` | Next.js typed Metadata Manifest. name + short_name + display=standalone + theme color + 2 icons (any + maskable) | +| `public/sw.js` | Hand-rolled service worker. Network-first for HTML, stale-while-revalidate for static assets, **never** caches API/auth/admin/account/therapist/book paths. Versioned cache name (`tb-html-v1`, `tb-static-v1`). | +| `src/app/layout.tsx` | Added `manifest`, `appleWebApp`, `icons` to metadata. Added `viewport.themeColor` (light + dark + viewport-fit=cover for iPhone notch). Inline SW registration script gated to `NODE_ENV === "production"` (avoids stale-chunk weirdness in dev). | + +### Phase E — Polish +| Path | Role | +|---|---| +| `src/app/not-found.tsx` | Friendly 404 — required because `notFound()` is called in several admin/account pages | +| `src/app/error.tsx` | Global error boundary client component with "Try again" + "Go home" buttons. Error digest displayed for debugging. | +| Slot grids in `/book` and `/admin/bookings/new` | Default to `grid-cols-3` instead of crushing to 1 col on mobile. Promotes to 4/6 cols at sm/md. | +| Admin tables (5 files) | `overflow-hidden` → `overflow-x-auto` so they scroll on narrow viewports instead of clipping | + +## What's verified + +- `pnpm test` — **79/79 green** +- `pnpm lint` — clean +- `pnpm exec tsc --noEmit` — clean +- Live PWA smoke (curl on dev server): + - `GET /sw.js` → 200, `application/javascript`, 3.3 KB + - `GET /manifest.webmanifest` → 200, `application/manifest+json`, well-formed JSON with TouchBase + display=standalone + theme color + 2 icon refs + - `GET /icon.svg` → 200, `image/svg+xml`, 361 B + - `GET /icon-mask.svg` → 200, 437 B + - HTML head includes: `` (with `viewport-fit=cover`), 2× `` (light + dark), ``, ``, ``, ``, `` +- 404 smoke: `GET /no-such-page` → renders the new not-found page with "404" heading + "We couldn't find that page" + "Back to home" link + +## Decisions ratified + +| Decision | Resolution | +|---|---| +| PWA library | **None** — hand-rolled `public/sw.js` (~80 LOC). Reason: zero dependency, full visibility into caching behavior, easy to reason about. `next-pwa` / `@serwist/next` are reasonable upgrades when we have specific needs (offline queueing, push notifications, etc.). | +| Icon format | **SVG** (single file, scaled by browser). Reason: ships in 360 bytes, looks crisp at any size, no asset pipeline. iOS Safari supports SVG `apple-touch-icon` from iOS 17+; on older iOS the icon falls back to the bookmark default — acceptable for v1. We can add a 180×180 PNG later if needed. | +| SW caching policy | Network-first for HTML; stale-while-revalidate for static; **never** cache `/api/`, `/admin/`, `/account/`, `/therapist/`, `/book*`. Reason: any cached page in those routes could show stale slot availability or stale booking state. Safer to require fresh data. | +| Versioned cache name | `tb-html-v1`, `tb-static-v1`. Bump on deploys that should invalidate. Reason: simple manual control vs. fingerprint-based invalidation. | +| SW registration timing | `window.addEventListener('load', ...)` — runs after the page is interactive so it doesn't compete with first paint. Production-only via inline script gated on `NODE_ENV`. | +| `mobile-web-app-capable` instead of `apple-mobile-web-app-capable` | Next.js's `appleWebApp.capable: true` emits the modern unprefixed form, which is the spec-current name. Both Apple and Android browsers respect it. | +| `dynamic = "force-dynamic"` everywhere | Already the default in our pages. SW reinforces this — stale HTML is OK on cold reload but never on subsequent navigation. | +| Error page is a Client Component | Required by Next.js's error boundary spec. Server logs already capture the underlying error; client-side `console.error` is a backup for browser inspection. | +| 404 + error page styling | Match the rest of the app — minimal, Tailwind utility classes, matches dark mode automatically. | +| Mobile slot grid breakpoint | `grid-cols-3 sm:grid-cols-4 md:grid-cols-6`. 3-across on phone is comfortable thumb territory; 4 on tablet; 6 on desktop. | +| Admin table overflow | `overflow-x-auto` rather than `overflow-hidden`. Tradeoff: rounded corners look slightly worse when content overflows, but the alternative (clip) is functionally broken on phone. | + +## Gotchas hit + +### `apple-mobile-web-app-capable` missing +Initial smoke didn't find this meta tag. Turned out Next.js (modern versions) emits the unprefixed `mobile-web-app-capable` instead — which is the current spec form. Both work. No fix needed. + +### Lint flagged `` instead of `` in error page +Started with `` to avoid importing `` for one element. ESLint rule (`@next/next/no-html-link-for-pages`) caught it. Switched to ``. + +### `'event' is defined but never used` in SW +Pure JS file linted alongside the rest. The unused param in the install handler triggered the unused-vars rule. Removed the param. + +## Open questions + +1. Customer-visible brand name (still pending) +2. Currency +3. Stripe account ownership — **needed for next phase** +4. **NEW**: should the SW also cache the home page for an "offline" experience? Currently network-first means it works offline ONLY if previously visited. Acceptable for v1. +5. **NEW**: PNG fallback for `apple-touch-icon` for iOS <17? Defer until someone reports the bookmark icon is missing. + +## Roadmap status + +UX-completeness: + +- A — Admin CRUD: done 2026-05-02 +- B — Therapist self-serve: done 2026-05-02 +- C — Booking lifecycle: done 2026-05-02 +- **D — PWA shell: done 2026-05-02 (this session, part 1)** +- **E — Polish: done 2026-05-02 (this session, part 2)** + +**UX is done.** Backend roadmap continues: + +- 5d — Stripe deposit flow + webhook +- 5e — Email reminders (pg-boss scheduled jobs) + +## Recommended next step + +**5d — Stripe deposit flow.** The biggest remaining backend chunk. Sequence per `BuildLog.md` notes: + +1. Stripe SDK + env config (test keys — user provides) +2. Render Stripe Elements on `/book/confirm` (this is the **first place we need a real Client Component for interactive UI** — past Client Components have been minimal) +3. `createPaymentIntent` for the deposit at `createHold` time; pass `client_secret` to the page +4. Webhook handler at `/api/stripe/webhook` (verifies signature, updates `Payment` row, transitions Booking from HOLD to CONFIRMED on `payment_intent.succeeded`) +5. Hold expiry job via pg-boss cancels HOLDs whose deposit didn't capture in time + +~3–5 days. After 5d, **5e reminders** is small — pg-boss schedule + reminder template + send job (~2–3 days). + +Before starting 5d we'll need: +- Stripe test secret key + publishable key +- Webhook signing secret (from Stripe CLI for local dev) + +## How to resume + +```bash +cd /Users/noise/Documents/code/touchbase +docker-compose up -d postgres mailpit +pnpm db:seed +pnpm tsx scripts/book-on-behalf.ts alex@example.com "60-minute Swedish" 2026-05-05T10:00 +pnpm dev +# Try the customer flow on a phone (or DevTools mobile preview): +# http://:3000 → tap a service → date → pick time → sign in → confirm +# Add to Home Screen — PWA icon + standalone display +# Try the admin/therapist flows on a phone — tables now scroll horizontally +# Hit a bad URL like /xyzzy → see the 404 page +``` diff --git a/docs/progress/2026-05-02-stripe-scaffold.md b/docs/progress/2026-05-02-stripe-scaffold.md new file mode 100644 index 0000000..66e008b --- /dev/null +++ b/docs/progress/2026-05-02-stripe-scaffold.md @@ -0,0 +1,120 @@ +# 2026-05-02 — Stripe Deposit Flow (5d, scaffolded) + +> Companion to `BuildLog.md`. Predecessor: `2026-05-02-pwa-and-polish.md`. Closes the no-keys-required portion of Phase 5d. + +## Milestone + +Full Stripe deposit-payment flow is scaffolded end-to-end. Code is wired and typed and the DB-side payment helpers are unit-tested. **Live testing requires real Stripe test keys** — see "How to finish" below. Without keys, the codebase still compiles and ships; the deposit branch in `/book/confirm` only activates when `stripeConfigured()` returns true, so non-deposit services and the existing flow continue to work unchanged. + +## What landed + +| Path | Role | +|---|---| +| `src/lib/stripe.ts` | Lazy server-side `Stripe` client + `stripeConfigured()` capability check. | +| `src/lib/payments.ts` | `createDepositIntentForBooking` (creates PI, persists id), `recordPaymentSucceeded` (HOLD→CONFIRMED + Payment row, idempotent on replay), `recordPaymentFailed` (HOLD→CANCELLED with reason), `confirmAfterPayment` (sends booking confirmation). | +| `src/app/api/stripe/webhook/route.ts` | POST endpoint at `/api/stripe/webhook`. Verifies signature against `STRIPE_WEBHOOK_SECRET`. Handles `payment_intent.succeeded` and `payment_intent.payment_failed`; acks unknown events so Stripe stops retrying. | +| `src/components/PaymentForm.tsx` | **First real client component** — Stripe Elements (PaymentElement) wrapped in `` provider. Cached `loadStripe` promise. Submit → `stripe.confirmPayment` → Stripe redirects to return URL. | +| `src/app/book/pay/[id]/page.tsx` | Server-renders the booking summary + Payment Element. Owner-only auth. Bounces to `/book/done` if booking is already CONFIRMED, or to `/account/bookings` if it's terminal. Shows a friendly "payments not configured" state when `STRIPE_*` env vars aren't set. | +| `src/app/book/confirm/page.tsx` | Confirm action now branches: if `service.depositCents > 0 && stripeConfigured()`, creates the PI and redirects to `/book/pay/[id]`. Otherwise current behavior (immediate CONFIRMED + email). | +| `src/app/book/done/page.tsx` | Adapts copy when booking is still HOLD (waiting for webhook): "Processing your payment". Also handles the CANCELLED case explicitly. | +| `test/payments.test.ts` | 7 tests for payment helpers — succeeded transitions HOLD→CONFIRMED + writes Payment row, idempotent on replay, doesn't resurrect CANCELLED, fails-on-unknown-PI; failed cancels HOLDs with reason, doesn't change CONFIRMED status, fails-on-unknown-PI. | +| `.env`, `.env.example` | Stripe placeholders: `STRIPE_SECRET_KEY`, `STRIPE_PUBLISHABLE_KEY`, `STRIPE_WEBHOOK_SECRET`. | + +## What's verified + +- `pnpm test` — **86/86 green** (was 79; +7 new payment tests) +- `pnpm lint` — clean +- `pnpm exec tsc --noEmit` — clean +- All deposit-flow code paths are typed against the real Stripe SDK (`stripe@22.1.0`, `@stripe/react-stripe-js@6.3.0`, `@stripe/stripe-js@9.4.0`). + +**Not verified (needs real keys)**: Stripe Elements rendering, `confirmPayment` round-trip, real webhook signature, end-to-end booking-with-deposit happy path, end-to-end payment failure. + +## Decisions ratified + +| Decision | Resolution | +|---|---| +| Booking lifecycle with deposit | HOLD → (Stripe Payment Element via `/book/pay/[id]`) → webhook fires `payment_intent.succeeded` → CONFIRMED + email. Asynchronous webhook is the source of truth. | +| Hold expiry safety | Existing `expireStaleHolds` job continues to run; if the customer abandons the payment page, the HOLD expires after 10 min and the slot frees up. | +| Source of truth for payment status | The webhook. The page redirect after `confirmPayment` is just UX — `/book/done` displays "Processing" if the booking is still HOLD when they land. | +| `client_secret` storage | NOT stored in our DB. Retrieved from Stripe via `paymentIntents.retrieve` when re-rendering `/book/pay`. Reason: client_secret is sensitive and Stripe recommends not persisting it. | +| Payment row uniqueness | `Payment.stripePaymentIntentId @unique` already in schema. Webhook handler `upsert`s on this — ensures idempotency on duplicate webhook deliveries. | +| Event acknowledgment | Always 200 for unknown events (Stripe will stop retrying). 400 for missing/invalid signature. 500 only for handler exceptions (so Stripe retries on transient errors). | +| Capability detection | `stripeConfigured()` checks all three env vars. Used by `/book/confirm` to decide whether to route to payment, and by `/book/pay` to render a friendly "not configured" state. Lets the app run without Stripe for non-deposit services. | +| Fallback when payments aren't configured | Services with deposit > 0 that get booked through a non-Stripe path proceed straight to CONFIRMED without payment. Reason: the deposit is an expectation between the practice and the customer; in dev or while Stripe is being set up, we don't want bookings to fail. | +| Failure semantics | `payment_intent.payment_failed` cancels the HOLD with `cancelReason="payment failed"`. The customer can re-try by booking again from scratch. We don't currently support a "try a different card" flow on the same booking. | +| Money math precision | All amounts in integer cents end-to-end. No `Number` rounding anywhere. | +| First Client Component | `src/components/PaymentForm.tsx`. Necessary because Stripe.js is browser-only. We previously kept all UI server-rendered; this is the first deliberate exception. | + +## Gotchas hit + +None this session — clean scaffold. + +## Open items / known gaps (without real keys) + +These are deliberately deferred until you provide keys: + +1. **Visual confirmation** that PaymentElement renders correctly with our theme. +2. **Real `payment_intent.succeeded` handling** end-to-end (Stripe CLI → webhook → DB → email). Code is correct; just needs to run. +3. **Signed webhook** verification — handler logic is correct but the signature constant-time comparison only fires with a real secret. +4. **Edge cases** like 3DS challenge flows, declined cards, post-redirect race when the user lands on /book/done before the webhook fires. + +## How to finish (when you provide keys) + +1. **Get Stripe test keys**: https://dashboard.stripe.com/test/apikeys → `sk_test_…` and `pk_test_…`. Drop both in `.env`. +2. **Run the Stripe CLI** for webhook forwarding: + ```bash + brew install stripe/stripe-cli/stripe # if needed + stripe login # one-time + stripe listen --forward-to localhost:3000/api/stripe/webhook + ``` + The CLI prints a `whsec_…` — paste into `.env` as `STRIPE_WEBHOOK_SECRET`. +3. **Restart `pnpm dev`** so the new env vars are picked up. +4. **Test the deposit flow** as a customer: + ```bash + pnpm db:seed + # /60-minute Swedish has $20 deposit per the seed + pnpm tsx scripts/book-on-behalf.ts alex@example.com "60-minute Swedish" 2026-05-05T10:00 + # OR via the public flow: + # /book → click 60-min Swedish → pick a date → pick a slot → /book/confirm → /book/pay/[id] + ``` + Use Stripe test card `4242 4242 4242 4242`, any future expiry, any CVC, any zip. +5. **Watch the Stripe CLI** output to see the webhook fire. Watch Mailpit (`http://localhost:8025`) for the confirmation email. +6. **Verify in DB**: `SELECT id, status, "paymentStatus", "stripePaymentIntentId" FROM "Booking" ORDER BY "createdAt" DESC LIMIT 1;` — should show `CONFIRMED` + `CAPTURED` + a `pi_…` id. +7. **Test failure**: use card `4000 0000 0000 9995` (declined) — booking should land in CANCELLED with `cancelReason="payment failed"`. + +## Roadmap status + +Backend roadmap: + +- 1–4 done +- 5a + 5b + 5c.1 + 5c.2 done +- UX phases A–E done +- **5d Stripe — scaffolded 2026-05-02 (this session). Awaits keys + live verification.** +- 5e Email reminders (pg-boss scheduled jobs) — next + +## Recommended next step + +**5e — email reminders** can be done in parallel with you setting up Stripe keys, since they're orthogonal: + +- Add pg-boss (we already chose it; just `pnpm add pg-boss`) +- Migration: pg-boss creates its own schema in our Postgres +- Worker process that runs reminder jobs on a schedule +- `sendBookingReminder` email template (24h-before) +- Trigger: when a booking is CONFIRMED, schedule its reminder; on cancel/reschedule, deschedule +- ~2–3 days + +After 5e, **v1 is feature-complete for soft launch** — modulo whatever real-Stripe-test-mode reveals in 5d verification. + +## How to resume + +```bash +cd /Users/noise/Documents/code/touchbase +docker-compose up -d postgres mailpit +pnpm db:seed +pnpm dev +# Without Stripe keys: deposit-required services (60-min Swedish, etc.) currently +# proceed to confirmed-without-payment because stripeConfigured() returns false. +# This means existing flows still work for development. +# +# With Stripe keys configured: see "How to finish" above. +``` diff --git a/package.json b/package.json index 980674e..528f90d 100644 --- a/package.json +++ b/package.json @@ -25,6 +25,8 @@ "@auth/prisma-adapter": "^2.11.2", "@prisma/adapter-pg": "^7.8.0", "@prisma/client": "^7.8.0", + "@stripe/react-stripe-js": "^6.3.0", + "@stripe/stripe-js": "^9.4.0", "date-fns": "^4.1.0", "date-fns-tz": "^3.2.0", "next": "16.2.4", @@ -33,6 +35,7 @@ "pg": "^8.20.0", "react": "19.2.4", "react-dom": "19.2.4", + "stripe": "^22.1.0", "zod": "^4.3.6" }, "devDependencies": { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index e109e71..1dc52a0 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -17,6 +17,12 @@ importers: '@prisma/client': specifier: ^7.8.0 version: 7.8.0(prisma@7.8.0(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(typescript@5.9.3))(typescript@5.9.3) + '@stripe/react-stripe-js': + specifier: ^6.3.0 + version: 6.3.0(@stripe/stripe-js@9.4.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@stripe/stripe-js': + specifier: ^9.4.0 + version: 9.4.0 date-fns: specifier: ^4.1.0 version: 4.1.0 @@ -41,6 +47,9 @@ importers: react-dom: specifier: 19.2.4 version: 19.2.4(react@19.2.4) + stripe: + specifier: ^22.1.0 + version: 22.1.0(@types/node@22.19.17) zod: specifier: ^4.3.6 version: 4.3.6 @@ -908,6 +917,17 @@ packages: '@standard-schema/spec@1.1.0': resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==} + '@stripe/react-stripe-js@6.3.0': + resolution: {integrity: sha512-N1FTRNCMKySElDz1lAsf/m6Oy5vcl6LRVXcW29t0Y3U3HYOAqCBlk6nuDsR2x7SAuaXkVCjnpCqrNbA/7l74jg==} + peerDependencies: + '@stripe/stripe-js': '>=9.3.1 <10.0.0' + react: '>=16.8.0 <20.0.0' + react-dom: '>=16.8.0 <20.0.0' + + '@stripe/stripe-js@9.4.0': + resolution: {integrity: sha512-zXG86DnoLU5nH2U18tX5ApTE3IAm+txoiPm4YFyEtRS0K5y7ZDH9M8e1Le/cZyI6wvW3BkJn2daZe2FqZOrSSg==} + engines: {node: '>=12.16'} + '@swc/helpers@0.5.15': resolution: {integrity: sha512-JQ5TuMi45Owi4/BIMAJBoSQoOJu12oOk/gADqlcUL9JEdHB8vyjUSsxqeNXnmXHjYKMi2WcYtezGEEhqUI/E2g==} @@ -2677,6 +2697,15 @@ packages: resolution: {integrity: sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==} engines: {node: '>=8'} + stripe@22.1.0: + resolution: {integrity: sha512-w/xHyJGxXWnLPbNHG13sz/fae0MrFGC80Oz7YbICQymbfpqfEcsoG+6yG+9BWb81PWc4rrkeSO4wmTcmefmbLw==} + engines: {node: '>=18'} + peerDependencies: + '@types/node': '>=18' + peerDependenciesMeta: + '@types/node': + optional: true + styled-jsx@5.1.6: resolution: {integrity: sha512-qSVyDTeMotdvQYoHWLNGwRFJHC+i+ZvdBRYosOFgC+Wg1vx4frN2/RG/NA7SYqqvKNLf39P2LSRA2pu6n0XYZA==} engines: {node: '>= 12.0.0'} @@ -3626,6 +3655,15 @@ snapshots: '@standard-schema/spec@1.1.0': {} + '@stripe/react-stripe-js@6.3.0(@stripe/stripe-js@9.4.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': + dependencies: + '@stripe/stripe-js': 9.4.0 + prop-types: 15.8.1 + react: 19.2.4 + react-dom: 19.2.4(react@19.2.4) + + '@stripe/stripe-js@9.4.0': {} + '@swc/helpers@0.5.15': dependencies: tslib: 2.8.1 @@ -5614,6 +5652,10 @@ snapshots: strip-json-comments@3.1.1: {} + stripe@22.1.0(@types/node@22.19.17): + optionalDependencies: + '@types/node': 22.19.17 + styled-jsx@5.1.6(@babel/core@7.29.0)(react@19.2.4): dependencies: client-only: 0.0.1 diff --git a/public/icon-mask.svg b/public/icon-mask.svg new file mode 100644 index 0000000..ef8db89 --- /dev/null +++ b/public/icon-mask.svg @@ -0,0 +1,7 @@ + + + + TB + diff --git a/public/icon.svg b/public/icon.svg new file mode 100644 index 0000000..f3a39e7 --- /dev/null +++ b/public/icon.svg @@ -0,0 +1,6 @@ + + + TB + diff --git a/public/sw.js b/public/sw.js new file mode 100644 index 0000000..cd9ac01 --- /dev/null +++ b/public/sw.js @@ -0,0 +1,106 @@ +// TouchBase service worker — minimal, hand-rolled. +// +// Strategy: +// - HTML pages (text/html): network-first → cache fallback for offline. +// - Static assets (/_next/static, /icon.svg, fonts): stale-while-revalidate. +// - API + auth + booking server actions: pass-through, NEVER cached. +// Stale appointment data is dangerous; better to fail than to lie. +// +// Cache name is versioned. Bump CACHE_VERSION on any deploy that should +// invalidate the cache for returning users. + +const CACHE_VERSION = "v1"; +const HTML_CACHE = `tb-html-${CACHE_VERSION}`; +const STATIC_CACHE = `tb-static-${CACHE_VERSION}`; +const ALL_CACHES = [HTML_CACHE, STATIC_CACHE]; + +self.addEventListener("install", () => { + // Activate the new SW immediately; users get the new caching rules on next nav. + self.skipWaiting(); +}); + +self.addEventListener("activate", (event) => { + event.waitUntil( + (async () => { + const names = await caches.keys(); + await Promise.all( + names + .filter((n) => n.startsWith("tb-") && !ALL_CACHES.includes(n)) + .map((n) => caches.delete(n)), + ); + await self.clients.claim(); + })(), + ); +}); + +self.addEventListener("fetch", (event) => { + const req = event.request; + + // Only handle GET. Server actions (POST), auth callbacks, etc. always pass through. + if (req.method !== "GET") return; + + const url = new URL(req.url); + + // Same-origin only — don't cache cross-origin (analytics, fonts CDN, etc.). + if (url.origin !== self.location.origin) return; + + // Never cache API, auth, or anything in the booking flow that could go stale. + // Stale slot data could let a customer "see" an already-taken slot. + if ( + url.pathname.startsWith("/api/") || + url.pathname.startsWith("/_next/data/") || + url.pathname.startsWith("/admin/") || + url.pathname.startsWith("/account/") || + url.pathname.startsWith("/therapist/") || + url.pathname.startsWith("/book") + ) { + return; // pass through + } + + // Static assets → stale-while-revalidate + if ( + url.pathname.startsWith("/_next/static/") || + url.pathname === "/icon.svg" || + url.pathname === "/icon-mask.svg" || + url.pathname === "/favicon.ico" || + url.pathname === "/manifest.webmanifest" + ) { + event.respondWith(staleWhileRevalidate(req, STATIC_CACHE)); + return; + } + + // HTML navigation → network-first → cache fallback + if (req.mode === "navigate" || req.headers.get("accept")?.includes("text/html")) { + event.respondWith(networkFirst(req, HTML_CACHE)); + return; + } +}); + +async function networkFirst(request, cacheName) { + const cache = await caches.open(cacheName); + try { + const fresh = await fetch(request); + if (fresh && fresh.ok) { + cache.put(request, fresh.clone()); + } + return fresh; + } catch (err) { + const cached = await cache.match(request); + if (cached) return cached; + throw err; + } +} + +async function staleWhileRevalidate(request, cacheName) { + const cache = await caches.open(cacheName); + const cached = await cache.match(request); + const fetchPromise = fetch(request) + .then((response) => { + if (response && response.ok) { + cache.put(request, response.clone()); + } + return response; + }) + .catch(() => undefined); + return cached ?? (await fetchPromise) ?? new Response("offline", { status: 503 }); +} diff --git a/src/app/account/bookings/[id]/page.tsx b/src/app/account/bookings/[id]/page.tsx new file mode 100644 index 0000000..9d91237 --- /dev/null +++ b/src/app/account/bookings/[id]/page.tsx @@ -0,0 +1,164 @@ +import Link from "next/link"; +import { notFound, redirect } from "next/navigation"; +import { auth } from "@/auth"; +import { db } from "@/lib/db"; +import { cancelBooking } from "@/lib/booking"; +import { sendBookingCancellation } from "@/lib/email"; +import { parseStringTrim } from "@/lib/forms"; + +export const metadata = { title: "Booking — TouchBase" }; +export const dynamic = "force-dynamic"; + +const TZ = process.env.APP_TZ ?? "America/Detroit"; + +type Params = Promise<{ id: string }>; + +function formatLocal(d: Date): string { + return new Intl.DateTimeFormat("en-US", { + timeZone: TZ, + weekday: "long", + month: "long", + day: "numeric", + hour: "numeric", + minute: "2-digit", + timeZoneName: "short", + }).format(d); +} + +async function actionCancel(formData: FormData): Promise { + "use server"; + const session = await auth(); + if (!session?.user) redirect("/login?callbackUrl=/account/bookings"); + const id = parseStringTrim(formData.get("bookingId")); + if (!id) return; + + const booking = await db.booking.findUnique({ + where: { id }, + select: { customerId: true }, + }); + if (!booking) return; + const isOwner = booking.customerId === session!.user.id; + const isAdmin = session!.user.role === "ADMIN"; + if (!isOwner && !isAdmin) redirect("/account/bookings"); + + const result = await cancelBooking(db, { + bookingId: id, + cancelledByUserId: session!.user.id, + reason: isOwner ? "customer cancellation" : "admin cancellation", + }); + if (result.kind === "cancelled" && !result.alreadyCancelled) { + await sendBookingCancellation({ db, bookingId: id }); + } + redirect("/account/bookings"); +} + +export default async function CustomerBookingDetailPage({ + params, +}: { + params: Params; +}) { + const { id } = await params; + const session = await auth(); + if (!session?.user) redirect(`/login?callbackUrl=/account/bookings/${id}`); + + const booking = await db.booking.findUnique({ + where: { id }, + include: { + service: { select: { name: true, durationMin: true, priceCents: true, depositCents: true } }, + therapist: { include: { user: { select: { name: true, email: true } } } }, + room: { select: { name: true } }, + customer: { select: { id: true, email: true } }, + }, + }); + if (!booking) notFound(); + + // Customers can only see their own; admins can see anyone's + const isOwner = booking.customer.id === session!.user.id; + const isAdmin = session!.user.role === "ADMIN"; + if (!isOwner && !isAdmin) redirect("/account/bookings"); + + const isActive = booking.status === "HOLD" || booking.status === "CONFIRMED"; + const isPast = booking.startsAt < new Date(); + const canModify = isActive && !isPast; + + return ( +
+ + ← All my bookings + + +
+

+ {booking.service.name} +

+ {booking.status} +
+ +
+
+ {formatLocal(booking.startsAt)} + {booking.service.durationMin} min + + {booking.therapist.user.name ?? booking.therapist.user.email} + + {booking.room.name} + + ${(booking.service.priceCents / 100).toFixed(2)} + +
+
+ + {canModify && ( +
+
+ + Reschedule + +
+ + +
+
+
+ )} +
+ ); +} + +function Field({ label, children }: { label: string; children: React.ReactNode }) { + return ( +
+
{label}
+
{children}
+
+ ); +} + +function statusPill(status: string): string { + const base = "rounded-full px-2 py-0.5 text-xs"; + switch (status) { + case "CONFIRMED": + return `${base} bg-green-100 text-green-800 dark:bg-green-900/40 dark:text-green-200`; + case "HOLD": + return `${base} bg-amber-100 text-amber-800 dark:bg-amber-900/40 dark:text-amber-200`; + case "COMPLETED": + return `${base} bg-blue-100 text-blue-800 dark:bg-blue-900/40 dark:text-blue-200`; + case "NO_SHOW": + return `${base} bg-amber-100 text-amber-800 dark:bg-amber-900/40 dark:text-amber-200`; + case "CANCELLED": + return `${base} bg-zinc-100 text-zinc-600 dark:bg-zinc-800 dark:text-zinc-400`; + default: + return `${base} bg-zinc-100 text-zinc-600`; + } +} diff --git a/src/app/account/bookings/page.tsx b/src/app/account/bookings/page.tsx index 46d1da9..f75fab3 100644 --- a/src/app/account/bookings/page.tsx +++ b/src/app/account/bookings/page.tsx @@ -1,3 +1,4 @@ +import Link from "next/link"; import { redirect } from "next/navigation"; import { auth } from "@/auth"; import { db } from "@/lib/db"; @@ -108,7 +109,7 @@ export default async function MyBookingsPage() { className="rounded-md border border-zinc-200 bg-white p-4 dark:border-zinc-800 dark:bg-zinc-950" >
-
+
{b.service.name}
{formatLocalLong(b.startsAt)} @@ -116,16 +117,24 @@ export default async function MyBookingsPage() {
{b.service.durationMin} min
-
-
- - -
+ Reschedule + +
+ + +
+
))} diff --git a/src/app/admin/bookings/[id]/page.tsx b/src/app/admin/bookings/[id]/page.tsx new file mode 100644 index 0000000..eace1b9 --- /dev/null +++ b/src/app/admin/bookings/[id]/page.tsx @@ -0,0 +1,251 @@ +import Link from "next/link"; +import { notFound, redirect } from "next/navigation"; +import { auth } from "@/auth"; +import { db } from "@/lib/db"; +import { + cancelBooking, + markComplete, + markNoShow, +} from "@/lib/booking"; +import { sendBookingCancellation } from "@/lib/email"; +import { parseStringTrim } from "@/lib/forms"; + +export const metadata = { title: "Booking — TouchBase" }; +export const dynamic = "force-dynamic"; + +const TZ = process.env.APP_TZ ?? "America/Detroit"; + +type Params = Promise<{ id: string }>; + +function formatLocal(d: Date): string { + return new Intl.DateTimeFormat("en-US", { + timeZone: TZ, + weekday: "short", + month: "short", + day: "numeric", + hour: "numeric", + minute: "2-digit", + timeZoneName: "short", + }).format(d); +} + +async function actionCancel(formData: FormData): Promise { + "use server"; + const session = await auth(); + if (!session?.user || session.user.role !== "ADMIN") { + redirect("/login"); + } + const id = parseStringTrim(formData.get("bookingId")); + if (!id) return; + const result = await cancelBooking(db, { + bookingId: id, + cancelledByUserId: session!.user.id, + reason: "admin cancellation", + }); + if (result.kind === "cancelled" && !result.alreadyCancelled) { + await sendBookingCancellation({ db, bookingId: id }); + } + redirect(`/admin/bookings/${id}`); +} + +async function actionMarkComplete(formData: FormData): Promise { + "use server"; + const session = await auth(); + if (!session?.user || session.user.role !== "ADMIN") redirect("/login"); + const id = parseStringTrim(formData.get("bookingId")); + if (!id) return; + await markComplete(db, id); + redirect(`/admin/bookings/${id}`); +} + +async function actionMarkNoShow(formData: FormData): Promise { + "use server"; + const session = await auth(); + if (!session?.user || session.user.role !== "ADMIN") redirect("/login"); + const id = parseStringTrim(formData.get("bookingId")); + if (!id) return; + await markNoShow(db, id); + redirect(`/admin/bookings/${id}`); +} + +export default async function AdminBookingDetailPage({ + params, +}: { + params: Params; +}) { + const { id } = await params; + const booking = await db.booking.findUnique({ + where: { id }, + include: { + customer: { select: { name: true, email: true, phone: true } }, + service: { select: { name: true, durationMin: true, bufferAfterMin: true } }, + therapist: { include: { user: { select: { name: true, email: true } } } }, + room: { select: { name: true, id: true } }, + }, + }); + if (!booking) notFound(); + + const isActive = booking.status === "HOLD" || booking.status === "CONFIRMED"; + const isPast = booking.endsAt < new Date(); + const canMarkComplete = booking.status === "CONFIRMED" && isPast; + const canMarkNoShow = booking.status === "CONFIRMED" && isPast; + const canCancel = isActive; + + return ( +
+ + ← All bookings + + +
+

+ {booking.service.name} +

+ + {booking.status} + +
+ +
+
+ + {formatLocal(booking.startsAt)} +
+ ends {formatLocal(booking.endsAt)} · room held until{" "} + {formatLocal(booking.roomReleasedAt)} +
+
+ +
+ {booking.customer.name ?? unnamed} +
+
{booking.customer.email}
+ {booking.customer.phone && ( +
{booking.customer.phone}
+ )} +
+ + + {booking.therapist.user.name ?? booking.therapist.user.email} + + + + + {booking.room.name} + + + + ${(booking.priceCents / 100).toFixed(2)} + + + ${(booking.depositCents / 100).toFixed(2)} +
+ Payment status: {booking.paymentStatus} +
+
+ {booking.status === "CANCELLED" && ( + <> + + {booking.cancelledAt ? formatLocal(booking.cancelledAt) : "—"} + + + {booking.cancelReason ?? "—"} + + + )} + {booking.notes && ( +
+ {booking.notes} +
+ )} +
+
+ + {(canMarkComplete || canMarkNoShow || canCancel) && ( +
+

+ Actions +

+
+ {canMarkComplete && ( +
+ + +
+ )} + {canMarkNoShow && ( +
+ + +
+ )} + {canCancel && ( +
+ + +
+ )} +
+ {!canMarkComplete && !canMarkNoShow && booking.status === "CONFIRMED" && ( +

+ Mark complete / no-show available after the booking end time. +

+ )} +
+ )} +
+ ); +} + +function Field({ label, children }: { label: string; children: React.ReactNode }) { + return ( +
+
{label}
+
{children}
+
+ ); +} + +function statusPill(status: string): string { + const base = "rounded-full px-2 py-0.5 text-xs"; + switch (status) { + case "CONFIRMED": + return `${base} bg-green-100 text-green-800 dark:bg-green-900/40 dark:text-green-200`; + case "HOLD": + return `${base} bg-amber-100 text-amber-800 dark:bg-amber-900/40 dark:text-amber-200`; + case "COMPLETED": + return `${base} bg-blue-100 text-blue-800 dark:bg-blue-900/40 dark:text-blue-200`; + case "NO_SHOW": + return `${base} bg-amber-100 text-amber-800 dark:bg-amber-900/40 dark:text-amber-200`; + case "CANCELLED": + return `${base} bg-zinc-100 text-zinc-600 dark:bg-zinc-800 dark:text-zinc-400`; + default: + return `${base} bg-zinc-100 text-zinc-600`; + } +} diff --git a/src/app/admin/bookings/new/page.tsx b/src/app/admin/bookings/new/page.tsx index aa524d1..91192d8 100644 --- a/src/app/admin/bookings/new/page.tsx +++ b/src/app/admin/bookings/new/page.tsx @@ -273,7 +273,7 @@ export default async function NewBookingPage({ No available slots on this date for the selected service.

) : ( -
+
{slots.map((slot) => (
diff --git a/src/app/admin/bookings/page.tsx b/src/app/admin/bookings/page.tsx index 231db0f..5ed6226 100644 --- a/src/app/admin/bookings/page.tsx +++ b/src/app/admin/bookings/page.tsx @@ -53,7 +53,7 @@ export default async function BookingsPage() { No upcoming bookings.

) : ( -
+
@@ -69,7 +69,9 @@ export default async function BookingsPage() { {bookings.map((b) => ( - +
- {formatLocal(b.startsAt)} + + {formatLocal(b.startsAt)} +
{b.customer.name ?? unnamed}
@@ -81,7 +83,9 @@ export default async function BookingsPage() { {b.service.durationMin}m
{b.therapist.user.name} + {b.therapist.user.name ?? unnamed} + {b.room.name} ) : ( -
+
diff --git a/src/app/admin/services/page.tsx b/src/app/admin/services/page.tsx index 11dcf03..09430df 100644 --- a/src/app/admin/services/page.tsx +++ b/src/app/admin/services/page.tsx @@ -27,7 +27,7 @@ export default async function ServicesPage() { No services yet.

) : ( -
+
diff --git a/src/app/admin/therapists/page.tsx b/src/app/admin/therapists/page.tsx index 98204c1..b485dd6 100644 --- a/src/app/admin/therapists/page.tsx +++ b/src/app/admin/therapists/page.tsx @@ -31,7 +31,7 @@ export default async function TherapistsPage() { No therapists yet.

) : ( -
+
diff --git a/src/app/api/stripe/webhook/route.ts b/src/app/api/stripe/webhook/route.ts new file mode 100644 index 0000000..dbf6731 --- /dev/null +++ b/src/app/api/stripe/webhook/route.ts @@ -0,0 +1,75 @@ +// Stripe webhook handler. +// +// Configure your endpoint in the Stripe Dashboard (or via `stripe listen` for +// local dev). The signing secret goes in STRIPE_WEBHOOK_SECRET. We verify +// every request and ignore unsigned/invalid ones. +// +// Idempotency: handlers in src/lib/payments.ts upsert Payment rows on +// stripePaymentIntentId @unique, so duplicate webhook deliveries are safe. + +import type { NextRequest } from "next/server"; +import { NextResponse } from "next/server"; +import type Stripe from "stripe"; +import { db } from "@/lib/db"; +import { stripe } from "@/lib/stripe"; +import { + confirmAfterPayment, + recordPaymentFailed, + recordPaymentSucceeded, +} from "@/lib/payments"; + +export const dynamic = "force-dynamic"; +export const runtime = "nodejs"; + +export async function POST(req: NextRequest): Promise { + const secret = process.env.STRIPE_WEBHOOK_SECRET; + if (!secret) { + return NextResponse.json( + { error: "STRIPE_WEBHOOK_SECRET is not configured" }, + { status: 500 }, + ); + } + + const sig = req.headers.get("stripe-signature"); + if (!sig) return NextResponse.json({ error: "missing signature" }, { status: 400 }); + + // Stripe needs the raw body to verify the signature. + const rawBody = await req.text(); + + let event: Stripe.Event; + try { + event = stripe().webhooks.constructEvent(rawBody, sig, secret); + } catch (err) { + const msg = err instanceof Error ? err.message : "invalid signature"; + return NextResponse.json({ error: msg }, { status: 400 }); + } + + try { + switch (event.type) { + case "payment_intent.succeeded": { + const pi = event.data.object as Stripe.PaymentIntent; + const result = await recordPaymentSucceeded(db, pi.id, pi.amount); + if (result.transitioned) { + await confirmAfterPayment(db, result.bookingId); + } + return NextResponse.json({ received: true, bookingId: result.bookingId }); + } + case "payment_intent.payment_failed": { + const pi = event.data.object as Stripe.PaymentIntent; + const result = await recordPaymentFailed(db, pi.id); + return NextResponse.json({ received: true, bookingId: result.bookingId }); + } + default: + // Acknowledge unknown events so Stripe stops retrying. Logged for debugging. + console.log(`[stripe webhook] unhandled event: ${event.type}`); + return NextResponse.json({ received: true, unhandled: event.type }); + } + } catch (err) { + // Returning 500 makes Stripe retry. That's the right behavior for transient errors. + console.error("[stripe webhook] handler threw:", err); + return NextResponse.json( + { error: err instanceof Error ? err.message : "handler error" }, + { status: 500 }, + ); + } +} diff --git a/src/app/book/confirm/page.tsx b/src/app/book/confirm/page.tsx index 920ced9..5f869b4 100644 --- a/src/app/book/confirm/page.tsx +++ b/src/app/book/confirm/page.tsx @@ -5,8 +5,18 @@ import { auth } from "@/auth"; import { db } from "@/lib/db"; import { findSlots } from "@/lib/availability"; import { loadAvailabilityState } from "@/lib/availability-loader"; -import { BookingConflictError, confirmHold, createHold } from "@/lib/booking"; -import { sendBookingConfirmation } from "@/lib/email"; +import { + BookingConflictError, + confirmHold, + createHold, + rescheduleBooking, +} from "@/lib/booking"; +import { + sendBookingConfirmation, + sendBookingRescheduled, +} from "@/lib/email"; +import { stripeConfigured } from "@/lib/stripe"; +import { createDepositIntentForBooking } from "@/lib/payments"; export const metadata = { title: "Confirm booking — TouchBase" }; export const dynamic = "force-dynamic"; @@ -17,6 +27,7 @@ type SearchParams = Promise<{ serviceId?: string; startsAtIso?: string; error?: string; + fromBookingId?: string; }>; function formatLocalLong(d: Date): string { @@ -36,10 +47,12 @@ async function confirmBookingAction(formData: FormData): Promise { const serviceId = String(formData.get("serviceId") ?? ""); const startsAtIso = String(formData.get("startsAtIso") ?? ""); const name = String(formData.get("name") ?? "").trim(); + const fromBookingId = String(formData.get("fromBookingId") ?? ""); const session = await auth(); if (!session?.user) { const next = new URLSearchParams({ serviceId, startsAtIso }); + if (fromBookingId) next.set("fromBookingId", fromBookingId); redirect(`/login?callbackUrl=${encodeURIComponent(`/book/confirm?${next}`)}`); } @@ -66,6 +79,7 @@ async function confirmBookingAction(formData: FormData): Promise { startsAtIso, error: "Could not compute availability — please try again.", }); + if (fromBookingId) params.set("fromBookingId", fromBookingId); redirect(`/book/confirm?${params}`); } @@ -84,27 +98,63 @@ async function confirmBookingAction(formData: FormData): Promise { date: startsAtIso.slice(0, 10), taken: "1", }); + if (fromBookingId) back.set("fromBookingId", fromBookingId); redirect(`/book?${back}`); } try { - const hold = await createHold(db, { - customerId: userId, - serviceId, - therapistId: slot.candidateTherapistIds[0], - roomId: slot.candidateRoomIds[0], - startsAt, - }); - await confirmHold(db, hold.id); - await sendBookingConfirmation({ db, bookingId: hold.id }); - redirect(`/book/done?bookingId=${hold.id}`); + if (fromBookingId) { + // Reschedule path: validate ownership, then atomic cancel-and-rebook. + const old = await db.booking.findUnique({ + where: { id: fromBookingId }, + select: { customerId: true }, + }); + if (!old || old.customerId !== userId) redirect("/account/bookings"); + + const result = await rescheduleBooking(db, { + oldBookingId: fromBookingId, + newStartsAt: startsAt, + newServiceId: serviceId, + newTherapistId: slot.candidateTherapistIds[0], + newRoomId: slot.candidateRoomIds[0], + cancelledByUserId: userId, + }); + await sendBookingRescheduled({ + db, + oldBookingId: result.oldBookingId, + newBookingId: result.newBookingId, + }); + redirect(`/book/done?bookingId=${result.newBookingId}`); + } else { + const hold = await createHold(db, { + customerId: userId, + serviceId, + therapistId: slot.candidateTherapistIds[0], + roomId: slot.candidateRoomIds[0], + startsAt, + }); + + // If the service has a deposit AND Stripe is configured, route to the + // payment page; the booking stays HOLD until the webhook fires. Otherwise + // confirm immediately (current behavior). + if (hold.depositCents > 0 && stripeConfigured()) { + await createDepositIntentForBooking(db, hold.id); + redirect(`/book/pay/${hold.id}`); + } + + await confirmHold(db, hold.id); + await sendBookingConfirmation({ db, bookingId: hold.id }); + redirect(`/book/done?bookingId=${hold.id}`); + } } catch (e) { if (e instanceof BookingConflictError) { const params = new URLSearchParams({ serviceId, date: startsAtIso.slice(0, 10), + taken: "1", }); - redirect(`/book?${params}&taken=1`); + if (fromBookingId) params.set("fromBookingId", fromBookingId); + redirect(`/book?${params}`); } throw e; } @@ -116,12 +166,13 @@ export default async function ConfirmPage({ searchParams: SearchParams; }) { const params = await searchParams; - const { serviceId, startsAtIso } = params; + const { serviceId, startsAtIso, fromBookingId } = params; if (!serviceId || !startsAtIso) redirect("/"); const session = await auth(); if (!session?.user) { const next = new URLSearchParams({ serviceId, startsAtIso }); + if (fromBookingId) next.set("fromBookingId", fromBookingId); redirect(`/login?callbackUrl=${encodeURIComponent(`/book/confirm?${next}`)}`); } @@ -155,10 +206,12 @@ export default async function ConfirmPage({

- Confirm your booking + {fromBookingId ? "Confirm reschedule" : "Confirm your booking"}

- Almost done — review and confirm. + {fromBookingId + ? "Your previous booking will be cancelled and replaced with this one." + : "Almost done — review and confirm."}

{params.error && ( @@ -201,6 +254,9 @@ export default async function ConfirmPage({ > + {fromBookingId && ( + + )} {needsName && (
@@ -229,7 +285,14 @@ export default async function ConfirmPage({ { + const back = new URLSearchParams({ + serviceId, + date: startsAtIso.slice(0, 10), + }); + if (fromBookingId) back.set("fromBookingId", fromBookingId); + return `/book?${back}`; + })()} className="mt-4 block text-center text-sm text-zinc-600 hover:text-zinc-900 dark:text-zinc-400 dark:hover:text-zinc-100" > ← Pick a different time diff --git a/src/app/book/done/page.tsx b/src/app/book/done/page.tsx index 9f89539..bdfa821 100644 --- a/src/app/book/done/page.tsx +++ b/src/app/book/done/page.tsx @@ -38,6 +38,12 @@ export default async function DonePage({ }); if (!booking) redirect("/"); + // Stripe redirects here right after `confirmPayment`, but the + // `payment_intent.succeeded` webhook may not have fired yet — the booking + // can still be HOLD for a few seconds. Show "processing" copy in that case. + const isProcessing = booking.status === "HOLD"; + const isCancelled = booking.status === "CANCELLED"; + return (
@@ -48,10 +54,18 @@ export default async function DonePage({

- You're booked + {isCancelled + ? "Booking cancelled" + : isProcessing + ? "Processing your payment" + : "You’re booked"}

- A confirmation email is on its way to {booking.customer.email}. + {isCancelled + ? "This booking is no longer active. If you didn’t cancel, please contact us." + : isProcessing + ? "Hold tight — we’re confirming your payment. This page will reflect the result once it clears (usually a few seconds)." + : `A confirmation email is on its way to ${booking.customer.email}.`}

diff --git a/src/app/book/page.tsx b/src/app/book/page.tsx index dc15171..39727e1 100644 --- a/src/app/book/page.tsx +++ b/src/app/book/page.tsx @@ -15,6 +15,8 @@ const TZ = process.env.APP_TZ ?? "America/Detroit"; type SearchParams = Promise<{ serviceId?: string; date?: string; + fromBookingId?: string; // set when rescheduling + taken?: string; // banner if a slot was taken between view and confirm }>; function formatLocalTime(d: Date): string { @@ -47,7 +49,10 @@ async function pickDateAction(formData: FormData): Promise { "use server"; const serviceId = String(formData.get("serviceId") ?? ""); const date = String(formData.get("date") ?? ""); - redirect(`/book?serviceId=${serviceId}&date=${date}`); + const fromBookingId = String(formData.get("fromBookingId") ?? ""); + const params = new URLSearchParams({ serviceId, date }); + if (fromBookingId) params.set("fromBookingId", fromBookingId); + redirect(`/book?${params}`); } export default async function BookPage({ @@ -58,11 +63,30 @@ export default async function BookPage({ const params = await searchParams; const session = await auth(); const serviceId = params.serviceId; + const fromBookingId = params.fromBookingId; if (!serviceId) redirect("/"); const service = await db.service.findUnique({ where: { id: serviceId } }); if (!service || !service.active) redirect("/"); + // If rescheduling, validate the customer owns the booking and it's reschedulable + let rescheduleNote: string | null = null; + if (fromBookingId) { + if (!session?.user) { + redirect(`/login?callbackUrl=/book?serviceId=${serviceId}&fromBookingId=${fromBookingId}`); + } + const old = await db.booking.findUnique({ + where: { id: fromBookingId }, + include: { service: { select: { name: true } } }, + }); + if (!old || old.customerId !== session!.user.id) redirect("/"); + if (old.status !== "HOLD" && old.status !== "CONFIRMED") { + // Old booking is no longer reschedulable; drop the param + redirect(`/book?serviceId=${serviceId}`); + } + rescheduleNote = `Rescheduling your ${old.service.name} — pick a new time below.`; + } + const todayISO = todayLocalISO(); const date = params.date ?? todayISO; @@ -132,11 +156,25 @@ export default async function BookPage({ )} + {rescheduleNote && ( +
+ {rescheduleNote} +
+ )} + {params.taken && ( +
+ That time was just taken — pick another. +
+ )} +
+ {fromBookingId && ( + + )} @@ -167,16 +205,17 @@ export default async function BookPage({ No available times on this date. Try another day.

) : ( -
+
{slots.map((slot) => { - const params = new URLSearchParams({ + const slotParams = new URLSearchParams({ serviceId, startsAtIso: slot.startsAt.toISOString(), }); + if (fromBookingId) slotParams.set("fromBookingId", fromBookingId); return ( {formatLocalTime(slot.startsAt)} diff --git a/src/app/book/pay/[id]/page.tsx b/src/app/book/pay/[id]/page.tsx new file mode 100644 index 0000000..c754f48 --- /dev/null +++ b/src/app/book/pay/[id]/page.tsx @@ -0,0 +1,119 @@ +import Link from "next/link"; +import { notFound, redirect } from "next/navigation"; +import { auth } from "@/auth"; +import { db } from "@/lib/db"; +import { stripeConfigured } from "@/lib/stripe"; +import { createDepositIntentForBooking } from "@/lib/payments"; +import { PaymentForm } from "@/components/PaymentForm"; + +export const metadata = { title: "Payment — TouchBase" }; +export const dynamic = "force-dynamic"; + +const TZ = process.env.APP_TZ ?? "America/Detroit"; + +type Params = Promise<{ id: string }>; + +function formatLocal(d: Date): string { + return new Intl.DateTimeFormat("en-US", { + timeZone: TZ, + weekday: "short", + month: "short", + day: "numeric", + hour: "numeric", + minute: "2-digit", + timeZoneName: "short", + }).format(d); +} + +export default async function PayPage({ params }: { params: Params }) { + const { id } = await params; + const session = await auth(); + if (!session?.user) redirect(`/login?callbackUrl=/book/pay/${id}`); + + const booking = await db.booking.findUnique({ + where: { id }, + include: { service: true, customer: { select: { id: true } } }, + }); + if (!booking) notFound(); + if (booking.customer.id !== session!.user.id) redirect("/account/bookings"); + + // If already CONFIRMED, payment is done — bounce to the success page. + if (booking.status === "CONFIRMED") { + redirect(`/book/done?bookingId=${id}`); + } + if (booking.status === "CANCELLED" || booking.status === "COMPLETED" || booking.status === "NO_SHOW") { + redirect("/account/bookings"); + } + if (booking.service.depositCents <= 0) { + redirect(`/book/done?bookingId=${id}`); + } + + if (!stripeConfigured()) { + return ( +
+

+ Payments not configured +

+

+ The practice hasn't finished setting up online payments yet. + Please contact them to confirm your booking. +

+ + Back to my bookings + +
+ ); + } + + const intent = await createDepositIntentForBooking(db, id); + const publishableKey = process.env.STRIPE_PUBLISHABLE_KEY!; + const appUrl = process.env.APP_URL ?? "http://localhost:3000"; + const returnUrl = `${appUrl}/book/done?bookingId=${id}`; + + return ( +
+
+ + TouchBase + +
+ +
+

+ Pay your deposit +

+

+ Confirm your card to lock in the booking. +

+ +
+
+
+
Service
+
{booking.service.name}
+
+
+
When
+
{formatLocal(booking.startsAt)}
+
+
+
Deposit
+
+ ${(booking.service.depositCents / 100).toFixed(2)} of ${(booking.service.priceCents / 100).toFixed(2)} +
+
+
+
+ +
+ +
+
+
+ ); +} diff --git a/src/app/error.tsx b/src/app/error.tsx new file mode 100644 index 0000000..64d2a50 --- /dev/null +++ b/src/app/error.tsx @@ -0,0 +1,46 @@ +"use client"; + +// Global error boundary. Required to be a client component by Next.js. +// Keep it minimal — server logs already capture the underlying error. + +import Link from "next/link"; +import { useEffect } from "react"; + +export default function GlobalError({ + error, + reset, +}: { + error: Error & { digest?: string }; + reset: () => void; +}) { + useEffect(() => { + console.error("[touchbase] page error:", error); + }, [error]); + + return ( +
+

Something went wrong

+

+ We hit an unexpected error. Try again, or head back to the start. +

+ {error.digest && ( +

ref: {error.digest}

+ )} +
+ + + Go home + +
+
+ ); +} diff --git a/src/app/layout.tsx b/src/app/layout.tsx index f2cf875..f23e3c4 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -1,4 +1,4 @@ -import type { Metadata } from "next"; +import type { Metadata, Viewport } from "next"; import { Geist, Geist_Mono } from "next/font/google"; import "./globals.css"; @@ -15,8 +15,39 @@ const geistMono = Geist_Mono({ export const metadata: Metadata = { title: "TouchBase", description: "Massage practice scheduling", + manifest: "/manifest.webmanifest", + appleWebApp: { + capable: true, + title: "TouchBase", + statusBarStyle: "black-translucent", + }, + icons: { + icon: "/icon.svg", + apple: "/icon.svg", + }, }; +export const viewport: Viewport = { + themeColor: [ + { media: "(prefers-color-scheme: light)", color: "#fafafa" }, + { media: "(prefers-color-scheme: dark)", color: "#18181b" }, + ], + width: "device-width", + initialScale: 1, + viewportFit: "cover", +}; + +// Run at the body bottom: registers the SW once the page is interactive. +const SW_REGISTRATION_SCRIPT = ` +if ('serviceWorker' in navigator) { + window.addEventListener('load', function() { + navigator.serviceWorker.register('/sw.js').catch(function(err) { + console.warn('[touchbase] sw register failed:', err); + }); + }); +} +`; + export default function RootLayout({ children, }: Readonly<{ @@ -27,7 +58,19 @@ export default function RootLayout({ lang="en" className={`${geistSans.variable} ${geistMono.variable} h-full antialiased`} > - {children} + + {children} +