Compare commits

...

80 Commits

Author SHA1 Message Date
Flea Flicker 17872e7b27 docs: add AGENTS.md and CONTRIBUTING.md (GRO-2381)
CI / Test (pull_request) Successful in 20s
CI / Lint & Typecheck (pull_request) Successful in 28s
CI / Build & Push Docker Image (pull_request) Failing after 1m3s
Point at the authoritative groombook/org skills (sdlc, coding-standards,
safety) and document the phase-by-phase PR flow + uat->main merge-gate
policy summary. Whitelist reminder for main branch protection: engineer
team only.

cc @cpfarhood

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-06-12 16:27:26 +00:00
Flea Flicker 661bd4f902 Promote uat → main (PROD): GRO-2358 logout on no-access screen (#74)
CI / Test (push) Successful in 18s
CI / Lint & Typecheck (push) Successful in 26s
CI / Build & Push Docker Image (push) Successful in 15s
Promote uat → main (PROD): GRO-2358 — restore logout on 'Portal access not configured' screen.

Squashed from uat-to-main/GRO-2358 (0d24fe0).

Cherry-pick of validated uat squash bfe3ccf.

Pre-merge gates green: CI (Lint+Typecheck 30s, Test 23s, Docker Build 11s); CTO Gitea review APPROVED (comment 13465); QA GRO-2362 done; UAT GRO-2363 4/4 PASS on git.farh.net/groombook/web:2026.06.11-bfe3ccf; Security GRO-2364 cleared.

Head branch uat-to-main/GRO-2358 retained for Flea's post-deploy verification.

Refs GRO-2358, GRO-2362, GRO-2363, GRO-2364, GRO-2367.

Co-authored-by: Flea Flicker <22+gb_flea@noreply.git.farh.net>
Co-committed-by: Flea Flicker <22+gb_flea@noreply.git.farh.net>
2026-06-11 15:43:32 +00:00
Flea Flicker fe565861b9 Promote uat → main (PROD): GRO-2319 portal StatusBadge palette (#71)
CI / Test (push) Successful in 20s
CI / Lint & Typecheck (push) Successful in 25s
CI / Build & Push Docker Image (push) Successful in 20s
Co-authored-by: Flea Flicker <22+gb_flea@noreply.git.farh.net>
Co-committed-by: Flea Flicker <22+gb_flea@noreply.git.farh.net>
2026-06-10 08:57:46 +00:00
Scrubs McBarkley 7ef270312c Merge pull request 'Promote uat → main (PROD): GRO-2160 route nav export + offline polish' (#68) from flea/uat-to-main-gro-2160 into main
CI / Test (push) Successful in 19s
CI / Lint & Typecheck (push) Successful in 27s
CI / Build & Push Docker Image (push) Successful in 15s
2026-06-09 05:27:25 +00:00
Flea Flicker f58a0e569b Promote dev → uat: GRO-2160 route nav export + offline polish (#67)
CI / Test (push) Successful in 20s
CI / Lint & Typecheck (push) Successful in 26s
CI / Build & Push Docker Image (push) Successful in 10s
CI / Test (pull_request) Successful in 21s
CI / Lint & Typecheck (pull_request) Successful in 28s
CI / Build & Push Docker Image (pull_request) Successful in 14s
2026-06-09 04:40:32 +00:00
Scrubs McBarkley 2a401a4584 Merge pull request 'Promote uat → main (PROD): GRO-2159 drag-to-reorder + re-optimize + GRO-2236 price/duration display fix'
CI / Test (push) Successful in 18s
CI / Lint & Typecheck (push) Successful in 25s
CI / Build & Push Docker Image (push) Successful in 12s
CEO prod merge. All gates cleared: CTO Phase 4 review #4573 approved, CI green on e93017b, QA PASS (GRO-2281, GRO-2256), post-deploy UAT regression PASS (GRO-2283, GRO-2276), security PASS (GRO-2284). Merging GRO-2159 + GRO-2236 to main.
2026-06-09 04:00:43 +00:00
Flea Flicker e93017b279 Promote dev → uat: GRO-2159 drag-to-reorder + re-optimize (#64)
CI / Test (push) Successful in 35s
CI / Lint & Typecheck (push) Successful in 44s
CI / Build & Push Docker Image (push) Successful in 11s
CI / Test (pull_request) Successful in 21s
CI / Lint & Typecheck (pull_request) Successful in 27s
CI / Build & Push Docker Image (pull_request) Successful in 12s
2026-06-09 03:10:17 +00:00
Scrubs McBarkley 27c59113e2 Merge pull request 'Promote uat → main (PROD): GRO-2158 route planner page' (#62) from flea/uat-to-main-gro-2158 into main
CI / Test (push) Successful in 22s
CI / Lint & Typecheck (push) Successful in 27s
CI / Build & Push Docker Image (push) Failing after 9s
Promote uat → main (PROD): GRO-2158 route planner page

Merged by CEO (gb_scrubs) — board-gated prod merge.
Validated SHA: 980615b (GRO-2272/2273/2274 all PASS).
CTO review: APPROVED (review #4567 by gb_dogfather).

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-06-09 02:37:35 +00:00
Flea Flicker db11e5f2bd Merge pull request 'Promote dev → uat: GRO-2236 portal Book New service cards price + duration' (#58) from flea/dev-to-uat-gro-2236 into uat
CI / Test (push) Successful in 24s
CI / Lint & Typecheck (push) Successful in 30s
CI / Build & Push Docker Image (push) Successful in 48s
2026-06-09 02:13:08 +00:00
Flea Flicker 980615b8e6 Promote dev → uat: GRO-2158 route planner page (#61)
CI / Test (push) Successful in 18s
CI / Lint & Typecheck (push) Successful in 27s
CI / Build & Push Docker Image (push) Successful in 14s
CI / Test (pull_request) Successful in 20s
CI / Lint & Typecheck (pull_request) Successful in 30s
CI / Build & Push Docker Image (pull_request) Successful in 41s
2026-06-09 02:00:55 +00:00
Flea Flicker 95c688764b Merge pull request 'uat→main (PROD): GRO-2234 portal Book New fix + validated batch' (#59) from flea/uat-to-main-gro-2234-web into main
CI / Test (push) Successful in 21s
CI / Lint & Typecheck (push) Successful in 27s
CI / Build & Push Docker Image (push) Successful in 16s
2026-06-09 00:37:47 +00:00
The Dogfather f549101962 fix(GRO-2236): portal Book New service cards show price + duration (#57)
CI / Test (pull_request) Successful in 20s
CI / Lint & Typecheck (pull_request) Successful in 28s
CI / Build & Push Docker Image (pull_request) Successful in 14s
Co-authored-by: The Dogfather <20+gb_dogfather@noreply.git.farh.net>
Co-committed-by: The Dogfather <20+gb_dogfather@noreply.git.farh.net>
2026-06-08 23:32:19 +00:00
Flea Flicker 62dc85b560 Promote dev → uat: GRO-2211/2218/2207 + GRO-2234 portal Book New (cumulative) (#56)
CI / Lint & Typecheck (push) Successful in 28s
CI / Test (push) Successful in 28s
CI / Build & Push Docker Image (push) Successful in 41s
CI / Test (pull_request) Successful in 21s
CI / Lint & Typecheck (pull_request) Successful in 27s
CI / Build & Push Docker Image (pull_request) Successful in 47s
2026-06-08 19:58:43 +00:00
Flea Flicker 5bb8fbcb7d Merge pull request 'Promote uat → main (atomic): GRO-2105/2094/2099/2089/2180/2213 portal bundle' (#48) from uat into main
CI / Test (push) Successful in 19s
CI / Lint & Typecheck (push) Successful in 27s
CI / Build & Push Docker Image (push) Successful in 40s
2026-06-08 19:29:49 +00:00
Flea Flicker bc21d6de09 Promote dev → uat: GRO-2213 portal booking preferredTime HH:MM:SS fix (#52)
CI / Test (push) Successful in 21s
CI / Test (pull_request) Successful in 22s
CI / Lint & Typecheck (push) Successful in 26s
CI / Lint & Typecheck (pull_request) Successful in 28s
CI / Build & Push Docker Image (push) Successful in 25s
CI / Build & Push Docker Image (pull_request) Successful in 20s
2026-06-08 17:36:16 +00:00
Flea Flicker 32ef3bca4d Merge pull request 'Promote dev → uat: GRO-2180 portal Appointments ISO startTime fix' (#50) from dev into uat
CI / Lint & Typecheck (pull_request) Failing after 10m47s
CI / Test (push) Failing after 10m51s
CI / Lint & Typecheck (push) Failing after 10m52s
CI / Build & Push Docker Image (push) Has been skipped
CI / Test (pull_request) Failing after 15m38s
CI / Build & Push Docker Image (pull_request) Has been skipped
Merge PR #50: fix(GRO-2180) portal Appointments ISO startTime (dev → uat)

QA-approved (gb_lint); PR CI green after transient runner re-run.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-08 10:28:50 +00:00
Flea Flicker 2b494c01f8 fix(GRO-2180): portal Appointments handles ISO startTime shape (#49)
CI / Test (push) Successful in 17s
CI / Lint & Typecheck (push) Successful in 22s
CI / Build & Push Docker Image (push) Successful in 14s
CI / Test (pull_request) Successful in 20s
CI / Lint & Typecheck (pull_request) Successful in 26s
CI / Build & Push Docker Image (pull_request) Successful in 10s
Self-merge to dev after green CI (run #115). Phase 1 CI-only gate.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-06-08 08:26:27 +00:00
Flea Flicker 3397767a01 fix(GRO-2180): normalize portal appointments API shape so /appointments loads
CI / Test (pull_request) Successful in 21s
CI / Lint & Typecheck (pull_request) Successful in 28s
CI / Build & Push Docker Image (pull_request) Successful in 46s
The /api/portal/appointments endpoint returns ISO startTime/endTime plus
nested pet/service/staff objects, but the portal client Appointment type
expected flat date/time/petName fields. isUpcoming() read appt.date/appt.time
(both undefined), so parseTimeTo24Hour(undefined) threw a TypeError; the
useEffect try/catch set the error state and the success-path-only Book New
button became unreachable.

- Add normalizeAppointment() at the fetch boundary mapping the API shape to the
  flat Appointment shape (derives display date/time from startTime, duration
  from the start/end delta), tolerant of the legacy flat shape.
- Prefer absolute startTime in isUpcoming(); fall back to date/time.
- Harden parseTimeTo24Hour against blank/undefined input (no NaN).
- Add Appointment.startTime/endTime to the type.
- Tests: normalizeAppointment + isUpcoming(startTime) + parseTimeTo24Hour safety.
- Update UAT_PLAYBOOK.md §5.12.2 and new §5.12d regression cases.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-06-08 04:18:35 +00:00
Flea Flicker 47c29ecbc2 Promote to UAT: GRO-2105 BookingFlow/RescheduleFlow availability fix (#47)
CI / Test (push) Successful in 17s
CI / Lint & Typecheck (push) Successful in 23s
CI / Build & Push Docker Image (push) Successful in 19s
CI / Test (pull_request) Failing after 10m34s
CI / Lint & Typecheck (pull_request) Failing after 10m34s
CI / Build & Push Docker Image (pull_request) Has been skipped
2026-06-02 19:17:03 +00:00
Flea Flicker f0c58c193c fix(GRO-2105): include serviceId in BookingFlow/RescheduleFlow availability call (#46)
CI / Test (push) Successful in 22s
CI / Lint & Typecheck (push) Successful in 28s
CI / Test (pull_request) Successful in 25s
CI / Lint & Typecheck (pull_request) Successful in 27s
CI / Build & Push Docker Image (push) Successful in 11s
CI / Build & Push Docker Image (pull_request) Successful in 12s
2026-06-02 19:06:15 +00:00
The Dogfather de7386e47a Promote to UAT: GRO-2094 React bootstrap error instrumentation (#45)
CI / Test (push) Successful in 23s
CI / Lint & Typecheck (push) Successful in 30s
CI / Build & Push Docker Image (push) Successful in 13s
Co-authored-by: The Dogfather <20+gb_dogfather@noreply.git.farh.net>
Co-committed-by: The Dogfather <20+gb_dogfather@noreply.git.farh.net>
2026-06-02 18:42:25 +00:00
The Dogfather 4600dcf950 Merge pull request 'fix(GRO-2094): instrument bootstrap with global error + ErrorBoundary' (#43) from fix/gro-2094-react-blank-mount into dev
CI / Test (push) Successful in 21s
CI / Lint & Typecheck (push) Successful in 32s
CI / Build & Push Docker Image (push) Successful in 15s
CI / Test (pull_request) Successful in 22s
CI / Lint & Typecheck (pull_request) Successful in 28s
CI / Build & Push Docker Image (pull_request) Successful in 14s
2026-06-02 18:32:21 +00:00
Flea Flicker 7daa9c480a fix(GRO-2094): instrument bootstrap with global error + ErrorBoundary
CI / Test (pull_request) Successful in 20s
CI / Lint & Typecheck (pull_request) Successful in 26s
CI / Build & Push Docker Image (pull_request) Successful in 48s
The bundle at /login was executing but the React tree never painted —
no console errors, no fallback UI, just an empty <div id='root'>.
Add three layers of defense so a future failure of this shape is
captured instead of being silently swallowed:

  1. window 'error' and 'unhandledrejection' listeners in main.tsx,
     printing structured context to console.error so Playwright
     sees the failure in the console log even if React unmounts
     the tree.

  2. A top-level <ErrorBoundary> in main.tsx that renders the
     actual exception (name, message, stack) inside the DOM
     instead of leaving <div id='root'> empty. The boundary also
     logs to console.error via componentDidCatch.

  3. New tests for the ErrorBoundary (renders children, surfaces
     thrown errors visibly) and two new UAT_PLAYBOOK test cases
     (TC-WEB-5.1.6 / 5.1.7) that explicitly assert the
     'never-blank-root' invariant on UAT.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-06-02 17:48:29 +00:00
Scrubs McBarkley 746fad635f Merge pull request 'fix(GRO-2099): show loading state during CustomerPortal SSO bridge bootstrap' (#44) from flea/gro-2099-fix-authed-portal-nav into dev
CI / Test (push) Successful in 22s
CI / Lint & Typecheck (push) Successful in 25s
CI / Build & Push Docker Image (push) Successful in 14s
2026-06-02 16:38:03 +00:00
Flea Flicker f1cf58dc56 fix(GRO-2099): show loading state during CustomerPortal SSO bridge bootstrap
CI / Test (pull_request) Successful in 24s
CI / Lint & Typecheck (pull_request) Successful in 29s
CI / Build & Push Docker Image (pull_request) Successful in 45s
Root cause: `Dashboard.tsx:194` runs its own `!sessionId && !isImpersonating &&
!getDevUser()` auth guard, redirecting to `/login` if `sessionId` is null. For
SSO customers, the CustomerPortal's useEffect has to call `/api/auth/get-session`
and then `/api/portal/session-from-auth` to populate `portalSessionId`. During
that bootstrap window (typically 100-300ms), `sessionId` is null and the guard
fires — redirecting the user to `/login` and breaking the post-sign-in flow.
App.tsx additionally returned `null` at `/login` for authenticated users
(`showCustomerPortal` is false at `/login`), leaving a blank React root even
if the redirect target was /login itself.

Fix:
- `CustomerPortal.tsx`: show a 'Loading…' state (`role=status`) while
  `!initComplete`. The portal chrome and its child sections only mount once
  the bootstrap has resolved, so child auth guards don't fire prematurely.
- `App.tsx`: at `/login` with a valid session, redirect to `/` so the
  customer lands on the portal instead of seeing a blank page.
- `App.tsx`: only return `LoginPage` when at `/login` — other portal
  routes defer the auth check to `CustomerPortal` (the customer SSO bridge
  resolves `portalSessionId` on mount).
- `UAT_PLAYBOOK.md`: add §5.27 with 8 cases covering the bug, the loading
  state, the /login auto-redirect, the unauth fallback, and the groomer /
  impersonation non-regressions.
- `src/__tests__/portal.test.tsx`: add a regression test that asserts the
  loading state is shown during the bridge and the portal nav is NOT in the
  DOM mid-bootstrap.

Reproduction (Shedward, run b4ae0155; reproduced locally on UAT image
`2026.06.01-ec29f71`):
1. From `about:blank`, complete customer SSO as `uat-customer`.
2. `browser_navigate` to `/portal`.
3. Pre-fix: redirected to `/login` with blank React root.
4. Post-fix: URL stays at `/portal`, dashboard renders with customer name.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-06-02 16:35:16 +00:00
Flea Flicker 903ce2d675 fix(GRO-2089): correct Authentik customer credential source in UAT_PLAYBOOK §5.25 (#42)
CI / Test (push) Successful in 24s
CI / Lint & Typecheck (push) Successful in 29s
CI / Build & Push Docker Image (push) Successful in 15s
fix(GRO-2089): correct Authentik customer credential source in UAT_PLAYBOOK §5.25
2026-06-02 14:48:03 +00:00
Scrubs McBarkley fdff0977ad Merge pull request 'Promote uat → main: GRO-2012 RescheduleFlow portalSessionId fallback' (#40) from uat into main
CI / Test (push) Successful in 23s
CI / Lint & Typecheck (push) Successful in 29s
CI / Build & Push Docker Image (push) Successful in 16s
Promote uat → main: GRO-2012 RescheduleFlow portalSessionId fallback

Gate checks:
- UAT: GRO-2023 done (CTO verified, ec29f719)
- Security: GRO-2032 Barkley PASS
- UAT_PLAYBOOK.md: TC-WEB-5.26 present

Fix: CustomerPortal.tsx:329 sessionId={session?.id ?? portalSessionId}
Fix commit: f29f1828c8

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-06-01 19:10:08 +00:00
The Dogfather ec29f71974 Merge pull request 'Promote to UAT: GRO-2012 RescheduleFlow portalSessionId fallback' (#39) from dev into uat
CI / Test (push) Successful in 21s
CI / Lint & Typecheck (push) Successful in 30s
CI / Build & Push Docker Image (push) Successful in 10s
CI / Test (pull_request) Successful in 21s
CI / Lint & Typecheck (pull_request) Successful in 28s
CI / Build & Push Docker Image (pull_request) Successful in 13s
2026-06-01 17:46:35 +00:00
Flea Flicker f29f1828c8 fix(GRO-2012): pass portalSessionId to RescheduleFlow for SSO bridge customers (#38)
CI / Test (push) Successful in 22s
CI / Lint & Typecheck (push) Successful in 28s
CI / Build & Push Docker Image (push) Successful in 14s
CI / Test (pull_request) Successful in 20s
CI / Lint & Typecheck (pull_request) Successful in 26s
CI / Build & Push Docker Image (pull_request) Successful in 11s
fix(GRO-2012): pass portalSessionId to RescheduleFlow for SSO bridge customers (closes #38)

- src/portal/CustomerPortal.tsx:329 - use portalSessionId fallback for RescheduleFlow
- src/__tests__/portal.test.tsx - new regression test
- UAT_PLAYBOOK.md §5.26 - new test cases

cc @cpfarhood

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-06-01 17:28:43 +00:00
The Dogfather bd2a0d9516 Merge pull request 'Promote dev -> uat: GRO-2011 login-blank fix (+ GRO-1867)' (#37) from dev into uat
CI / Test (push) Successful in 19s
CI / Lint & Typecheck (push) Successful in 23s
CI / Build & Push Docker Image (push) Successful in 10s
2026-06-01 16:38:14 +00:00
Lint Roller 3d7b247562 fix(GRO-2011): /login renders blank — always fetch setup/status for unauth users (#36)
CI / Test (push) Successful in 21s
CI / Lint & Typecheck (push) Successful in 27s
CI / Build & Push Docker Image (push) Successful in 12s
CI / Test (pull_request) Successful in 20s
CI / Lint & Typecheck (pull_request) Successful in 26s
CI / Build & Push Docker Image (pull_request) Successful in 11s
Co-authored-by: Lint Roller <23+gb_lint@noreply.git.farh.net>
Co-committed-by: Lint Roller <23+gb_lint@noreply.git.farh.net>
2026-06-01 16:36:44 +00:00
Flea Flicker 198053fa31 feat(GRO-1867): bridge Better Auth session to CustomerPortal (#34)
CI / Test (push) Successful in 25s
CI / Lint & Typecheck (push) Successful in 31s
CI / Build & Push Docker Image (push) Successful in 14s
2026-06-01 15:47:41 +00:00
The Dogfather 0e5e9d1f16 Merge pull request 'chore: promote dev → uat (GRO-1829 SW fix)' (#32) from dev into uat
CI / Test (push) Successful in 13s
CI / Lint & Typecheck (push) Successful in 23s
CI / Build & Push Docker Image (push) Successful in 15s
Merge: promote dev → uat (GRO-1829 SW fix)
2026-05-27 02:27:32 +00:00
Scrubs McBarkley 228a3d746c Merge pull request 'fix(GRO-1829): add skipWaiting/clientsClaim to VitePWA workbox config' (#31) from gro-1829-swpwa-fix into dev
CI / Lint & Typecheck (push) Successful in 17s
CI / Test (push) Successful in 17s
CI / Build & Push Docker Image (push) Successful in 31s
CI / Test (pull_request) Successful in 14s
CI / Lint & Typecheck (pull_request) Successful in 17s
CI / Build & Push Docker Image (pull_request) Successful in 15s
2026-05-27 02:22:25 +00:00
Flea Flicker ad9a178c89 fix: add skipWaiting/clientsClaim to VitePWA workbox config
CI / Test (pull_request) Successful in 15s
CI / Lint & Typecheck (pull_request) Successful in 17s
CI / Build & Push Docker Image (pull_request) Successful in 33s
Root cause: SW remained in waiting phase after redeploy, serving stale
precached assets. Without skipWaiting/clientsClaim the old SW persisted
and controlled the page even after a new SW was installed.

Fixes blank-page regression where React never mounted on login.
2026-05-27 02:22:01 +00:00
The Dogfather 9a3b5d88c8 Merge pull request 'fix(GRO-1822): add role check before /admin redirect — customers access portal' (#30) from fix/gro-1822-role-based-redirect into dev
CI / Test (push) Successful in 20s
CI / Lint & Typecheck (push) Successful in 28s
CI / Build & Push Docker Image (push) Successful in 34s
2026-05-27 01:01:59 +00:00
Flea Flicker 4e487db6f1 fix(GRO-1822): add role check before /admin redirect — customers access portal
CI / Test (pull_request) Failing after 14s
CI / Lint & Typecheck (pull_request) Failing after 17s
CI / Build & Push Docker Image (pull_request) Has been skipped
App.tsx lines 389-393 redirected ALL authenticated users to /admin,
breaking customer portal access after SSO login.

Now checks `session.user.role === "staff"` before redirecting.
Customers (role !== "staff") can access the portal at /.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-05-27 01:01:28 +00:00
The Dogfather 3b4d0f15f6 Merge pull request 'chore: promote dev → uat (GRO-1795 StatusBadge)' (#28) from dev into uat
CI / Lint & Typecheck (push) Successful in 17s
CI / Test (push) Successful in 13s
CI / Build & Push Docker Image (push) Successful in 34s
Merge PR #28: promote dev → uat (GRO-1795 StatusBadge)
2026-05-26 13:23:52 +00:00
The Dogfather 736535a24c Merge pull request 'feat(portal): add StatusBadge to appointment cards (GRO-1795)' (#26) from feature/gro-1165e-booking-status-badge into dev
CI / Test (push) Successful in 15s
CI / Lint & Typecheck (push) Successful in 17s
CI / Lint & Typecheck (pull_request) Successful in 18s
CI / Test (pull_request) Successful in 19s
CI / Build & Push Docker Image (push) Successful in 53s
CI / Build & Push Docker Image (pull_request) Successful in 47s
Merge PR #26: feat(portal): add StatusBadge to appointment cards (GRO-1795)
2026-05-26 13:23:30 +00:00
The Dogfather 87939e5413 Merge pull request 'chore: promote dev → uat (GRO-1794 booking analytics)' (#27) from dev into uat
CI / Test (push) Successful in 19s
CI / Lint & Typecheck (push) Successful in 22s
CI / Build & Push Docker Image (push) Successful in 12s
Merge dev → uat: GRO-1794 booking funnel analytics events
2026-05-26 13:16:39 +00:00
The Dogfather 33a1b3ed7a Merge pull request 'feat(GRO-1794): booking funnel analytics events' (#24) from feature/gro-1165d-booking-analytics into dev
CI / Test (push) Successful in 23s
CI / Lint & Typecheck (push) Successful in 30s
CI / Test (pull_request) Successful in 15s
CI / Lint & Typecheck (pull_request) Successful in 16s
CI / Build & Push Docker Image (push) Successful in 32s
CI / Build & Push Docker Image (pull_request) Successful in 52s
Merge PR #24: feat(GRO-1794): booking funnel analytics events

CTO-approved. QA passed. All CI green.
2026-05-26 13:16:06 +00:00
Flea Flicker 65686c8563 fix(GRO-1795): restore fireEvent and waitFor imports
CI / Test (pull_request) Successful in 15s
CI / Lint & Typecheck (pull_request) Successful in 17s
CI / Build & Push Docker Image (pull_request) Successful in 35s
QA regression: PR #26 removed fireEvent and waitFor from the
@testing-library/react import, breaking 21 test cases and typecheck.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-05-26 13:12:59 +00:00
Flea Flicker 112c61ab1c fix: add non-null assertion on listener.mock.calls[0] (TS strict mode)
CI / Lint & Typecheck (pull_request) Successful in 16s
CI / Test (pull_request) Successful in 2m26s
CI / Build & Push Docker Image (pull_request) Successful in 59s
Lines 28 and 40 access mock.calls[0] which is possibly undefined under
strict TypeScript. Adding ! to satisfy TS2532.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-05-26 13:06:07 +00:00
Flea Flicker 106d31a95e feat(portal): add StatusBadge to appointment cards
CI / Test (pull_request) Failing after 13s
CI / Lint & Typecheck (pull_request) Failing after 16s
CI / Build & Push Docker Image (pull_request) Has been skipped
Add a StatusBadge component that renders human-readable labels
(Confirmed, Pending, Waitlisted, etc.) with semantic color classes
for appointment cards in the portal. Replaces raw status strings.

- Added STATUS_LABELS map for human-readable status labels
- Updated STATUS_COLORS to use accessible amber/blue tones
- Exported StatusBadge for testing
- Added unit tests for all 7 badge states plus fallback
- Updated UAT_PLAYBOOK.md §5.12c with status badge test cases

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-26 13:04:02 +00:00
The Dogfather 4e3a038bf3 Merge pull request 'Promote dev → uat (GRO-1793: dynamic time slots)' (#25) from dev into uat
CI / Test (push) Successful in 14s
CI / Lint & Typecheck (push) Successful in 16s
CI / Build & Push Docker Image (push) Failing after 6s
Promote dev → uat: GRO-1793 dynamic portal time slots (#25)
2026-05-26 13:02:16 +00:00
Flea Flicker 7e5a851d9c fix: add missing vi import and fix getByText exact match assertions
CI / Test (pull_request) Successful in 13s
CI / Lint & Typecheck (pull_request) Failing after 20s
CI / Build & Push Docker Image (pull_request) Has been skipped
- analytics.test.ts: add vi to vitest import (was used at lines 24, 37, 66)
- BookingError.test.tsx: use regex matchers so phone/email assertions
  match partial text in combined <p> element

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-05-26 12:57:43 +00:00
The Dogfather 88ba9915c6 Merge pull request 'GRO-1793: Dynamic portal time slots (replace hardcoded)' (#23) from feature/gro-1165c-dynamic-timeslots into dev
CI / Test (push) Successful in 14s
CI / Lint & Typecheck (push) Successful in 18s
CI / Build & Push Docker Image (push) Successful in 9s
CI / Test (pull_request) Successful in 18s
CI / Lint & Typecheck (pull_request) Successful in 2m33s
CI / Build & Push Docker Image (pull_request) Successful in 8s
GRO-1793: Dynamic portal time slots (replace hardcoded) (#23)

Replaces hardcoded time slot arrays in portal BookingFlow and RescheduleFlow with API-fetched dynamic availability.
2026-05-26 12:56:25 +00:00
Flea Flicker 26cdd69a49 fix(GRO-1793): remove unused act import and add aria-label to date inputs
CI / Test (pull_request) Successful in 14s
CI / Lint & Typecheck (pull_request) Successful in 23s
CI / Build & Push Docker Image (pull_request) Successful in 34s
QA review pointed out:
- Lint error: 'act' imported but never used in test file
- 6 test failures: date input lacked accessible label

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-26 12:48:55 +00:00
Flea Flicker 3bccb1ac01 docs(UAT_PLAYBOOK.md): add §5.24 booking funnel analytics test cases
CI / Test (pull_request) Failing after 23s
CI / Lint & Typecheck (pull_request) Failing after 26s
CI / Build & Push Docker Image (pull_request) Has been skipped
Gro-1794 required UAT test cases for the booking funnel analytics events.
Covers all 6 events × both flows (public/portal), plus PII audit and
no-op-safety checks.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-05-26 12:40:49 +00:00
Flea Flicker 2e99ed520f feat(GRO-1794): add booking funnel analytics events
CI / Lint & Typecheck (pull_request) Failing after 15s
CI / Test (pull_request) Failing after 18s
CI / Build & Push Docker Image (pull_request) Has been skipped
- New analytics utility (src/lib/analytics.ts) with ANALYTICS_EVENTS constants
  and fireAnalyticsEvent() – thin wrapper over window.dispatchEvent, no-op safe
  Built for Plausible/GTM integration later.

- Public booking wizard (Book.tsx): fires step-transition events at each step
  (service → time → contact → submit) plus booking_confirmed on the dedicated
  confirmation page.

- Portal BookingFlow (Appointments.tsx): fires equivalent events for the
  portal booking flow. booking_confirmed fires via useEffect when the inline
  success state is shown.

- BookingErrorPage: fires booking_error on mount (no PII in payload).

Events include step name and flow type (public/portal) but contain no PII:
no names, emails, phone numbers, or pet names in any payload.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-05-26 12:38:58 +00:00
Barcode Betty a873369a9b GRO-1793: Update UAT_PLAYBOOK.md §5.12b — new dynamic time slots tests
CI / Test (pull_request) Failing after 22s
CI / Lint & Typecheck (pull_request) Failing after 28s
CI / Build & Push Docker Image (pull_request) Has been skipped
Added TC-WEB-5.12.5 through TC-WEB-5.12.11 covering BookingFlow and
RescheduleFlow dynamic slot fetching, loading state, error state, and
empty state scenarios.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-05-26 12:25:54 +00:00
Barcode Betty d78c859c2b Replace hardcoded time slots with dynamic API availability
CI / Lint & Typecheck (pull_request) Failing after 15s
CI / Test (pull_request) Failing after 17s
CI / Build & Push Docker Image (pull_request) Has been skipped
Both BookingFlow and RescheduleFlow in Appointments.tsx now fetch
from /api/book/availability when a date is selected, matching the
public booking wizard behavior. Loading and error states shown.

- Removed hardcoded availableTimes arrays from both flows
- Added useEffect that fetches availability on date change
- Shows "Checking availability…" while loading
- Shows error message on fetch failure
- Shows "No available slots" when API returns empty

Added tests for RescheduleFlow dynamic slot fetching covering:
loading, fetched slots, error, empty, API params, and re-fetch on
date change.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-05-26 12:23:29 +00:00
Barcode Betty 344a32e3e4 feat(GRO-1792): add recovery paths to booking error and cancellation pages
CI / Test (pull_request) Failing after 18s
CI / Lint & Typecheck (pull_request) Successful in 24s
CI / Build & Push Docker Image (pull_request) Has been skipped
- Add "Start a new booking" button to BookingError linking to /admin/book
- Add "Book again" button to BookingCancelled linking to /admin/book
- Add business contact info section to BookingError (from BUSINESS_CONTACT_INFO constant)
- Replace hardcoded colors with CSS variables (--color-error, --color-cancelled, etc.)
- Add page-level string constants to eliminate hardcoded strings
- Add unit tests for both pages (9 tests passing)

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-05-26 12:00:55 +00:00
Scrubs McBarkley 2aad7cb6a0 Merge pull request 'promote: uat → main (GRO-1757 SSO auto-provision fix)' (#21) from uat into main
CI / Test (push) Successful in 13s
CI / Lint & Typecheck (push) Successful in 21s
CI / Build & Push Docker Image (push) Successful in 13s
2026-05-26 02:16:28 +00:00
Lint Roller 8349ea00de Merge pull request 'promote: dev → uat (GRO-1757 SSO auto-provision fix)' (#19) from dev into uat
CI / Test (push) Successful in 13s
CI / Lint & Typecheck (push) Successful in 33s
CI / Build & Push Docker Image (push) Successful in 14s
CI / Test (pull_request) Successful in 19s
CI / Lint & Typecheck (pull_request) Successful in 24s
CI / Build & Push Docker Image (pull_request) Successful in 15s
promote: dev → uat (GRO-1757 SSO auto-provision fix)
2026-05-25 23:48:10 +00:00
Scrubs McBarkley b630b40c92 fix(GRO-1757): add SSO + OOBE test cases to groombook-web UAT_PLAYBOOK (#18)
CI / Test (push) Successful in 22s
CI / Lint & Typecheck (push) Successful in 28s
CI / Build & Push Docker Image (push) Successful in 9s
CI / Test (pull_request) Successful in 20s
CI / Lint & Typecheck (pull_request) Successful in 29s
CI / Build & Push Docker Image (pull_request) Successful in 17s
2026-05-25 23:39:46 +00:00
Flea Flicker db892409ef fix(GRO-1633): add buildx network=host and provenance:false to web CI (#17)
CI / Test (push) Successful in 17s
CI / Lint & Typecheck (push) Successful in 19s
CI / Build & Push Docker Image (push) Successful in 9s
2026-05-24 22:08:59 +00:00
Chris Farhood 0c41640f59 Add .mcp.json
CI / Test (push) Successful in 20s
CI / Lint & Typecheck (push) Successful in 27s
CI / Build & Push Docker Image (push) Successful in 4m1s
2026-05-24 18:15:24 +00:00
The Dogfather c83214cf42 Merge pull request 'fix(GRO-1414): update pet size value from x-large to xlarge' (#12) from fix/gro-1414-pet-size-enum into dev
CI / Test (push) Successful in 13s
CI / Lint & Typecheck (push) Successful in 21s
CI / Build & Push Docker Image (push) Failing after 2m20s
fix(GRO-1414): update pet size value from x-large to xlarge (#12)
2026-05-23 18:31:05 +00:00
The Dogfather 0306c7fbd9 Merge pull request 'chore(GRO-1592): promote dev→uat SSO session cookie fix' (#16) from promote-uat-gro1592 into uat
CI / Test (push) Successful in 12s
CI / Lint & Typecheck (push) Successful in 18s
CI / Build & Push Docker Image (push) Failing after 39s
2026-05-23 14:13:43 +00:00
Chris Farhood 93da2f1dd8 chore: promote dev→uat for GRO-1592 SSO session cookie fix
CI / Lint & Typecheck (pull_request) Successful in 17s
CI / Test (pull_request) Successful in 18s
CI / Build & Push Docker Image (pull_request) Failing after 41s
- Fixed frontend auth client baseURL fallback to use window.location.origin
- Added UAT test coverage (TC-AUTH-5.3.4)

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-05-23 14:13:12 +00:00
The Dogfather 80101fc37c Merge pull request 'fix(GRO-1592): fallback auth baseURL to window.location.origin' (#15) from fix/gro-1592-sso-session-cookie into dev
CI / Test (push) Successful in 14s
CI / Lint & Typecheck (push) Successful in 16s
CI / Build & Push Docker Image (push) Failing after 39s
2026-05-23 14:13:01 +00:00
The Dogfather 62cbfe4e43 Merge pull request 'promote: dev → uat (GRO-1173 buffer rules + GRO-1470 pet save persistence)' (#14) from dev into uat
CI / Test (push) Successful in 14s
CI / Lint & Typecheck (push) Successful in 19s
CI / Build & Push Docker Image (push) Successful in 9s
promote: dev → uat (GRO-1173 buffer rules + GRO-1470 pet save persistence) (#14)

Merged-By: The Dogfather (CTO)
Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-05-21 19:46:41 +00:00
The Dogfather f62c0b112d Merge pull request 'feat(GRO-1173): admin UI buffer rules, service default buffer, pet size/coat' (#13) from flea-flicker/pet-profile-editor into dev
CI / Test (push) Successful in 15s
CI / Lint & Typecheck (push) Successful in 17s
CI / Build & Push Docker Image (push) Successful in 10s
CI / Test (pull_request) Successful in 15s
CI / Lint & Typecheck (pull_request) Successful in 17s
CI / Build & Push Docker Image (pull_request) Successful in 10s
feat(GRO-1173): admin UI buffer rules, service default buffer, pet size/coat (#13)

Merged-By: The Dogfather (CTO)
Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-05-21 19:46:01 +00:00
Flea Flicker 034f4ab295 docs(GRO-1470): add UAT test cases for pet profile API persistence
CI / Test (pull_request) Successful in 13s
CI / Lint & Typecheck (pull_request) Successful in 17s
CI / Build & Push Docker Image (pull_request) Successful in 20s
Add §5.23 covering:
- API persistence (page reload verification)
- Save error state (form stays open on failure)
- Saving indicator (spinner while in-flight)

Updated UAT_PLAYBOOK.md §5.23

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-21 19:32:35 +00:00
Flea Flicker f958dbdb4f Persist pet profile changes via PATCH /api/portal/pets/{petId}
CI / Test (pull_request) Successful in 15s
CI / Lint & Typecheck (pull_request) Successful in 18s
CI / Build & Push Docker Image (pull_request) Successful in 34s
- handlePetSave is now async; calls PATCH before updating local state
- API response used as source of truth for local state update
- Error state shown on API failure; edit form NOT cleared on failure
- Loading/saving indicator in PetForm while API call in flight

Refs: GRO-1470
Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-05-21 19:17:12 +00:00
Flea Flicker 837b5f6d8a docs(GRO-1173): add UAT test cases for buffer rules, service default buffer, pet size/coat
CI / Test (pull_request) Successful in 16s
CI / Lint & Typecheck (pull_request) Successful in 17s
CI / Build & Push Docker Image (pull_request) Successful in 17s
- §5.7: add TC-WEB-5.7.5 through TC-WEB-5.7.7 for pet size/coat in admin UI
- §5.8.1 (new): add Buffer Rules Management test cases
  - TC-WEB-5.8.2 through TC-WEB-5.8.7: create/edit/delete buffer rules, size/coat filtering, empty state
- §5.9: add TC-WEB-5.9.4 and TC-WEB-5.9.5 for service default buffer minutes

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-05-21 14:52:42 +00:00
Flea Flicker 889e1e26ae feat(GRO-1173): add sizeCategory and coatType dropdowns to admin pet form
CI / Test (pull_request) Successful in 16s
CI / Lint & Typecheck (pull_request) Successful in 20s
CI / Build & Push Docker Image (pull_request) Successful in 33s
- PetForm interface: add sizeCategory and coatType fields
- EMPTY_PET: initialise new fields as empty strings
- openEditPet: pre-populate from pet.petSizeCategory and pet.coatType
- submitPet body: include petSizeCategory and coatType in POST/PATCH
- Pet form UI: add Size Category and Coat Type dropdowns after Breed field
  - Size: Small / Medium / Large / X-Large (maps to enum values)
  - Coat: Smooth / Double / Curly / Wire / Long / Hairless (maps to CoatType union)
  - Both optional — blank "Not set" option matches API optional semantics

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-05-21 14:44:55 +00:00
Flea Flicker ef6d9d5ab5 fix(CI): use Gitea registry for Docker push
CI / Test (pull_request) Successful in 15s
CI / Lint & Typecheck (pull_request) Successful in 17s
CI / Build & Push Docker Image (pull_request) Failing after 37s
Switch from ghcr.io (GitHub Container Registry) to git.farh.net
Gitea Container Registry. The Gitea Actions runner does not have
access to GitHub's GITHUB_TOKEN for ghcr.io authentication.

Based on fix/ci-registry-auth branch pattern (a582bd0).

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-05-21 12:29:29 +00:00
Flea Flicker 5fec0c938a Fix: render error state in BufferRulesSection (fixes lint error)
CI / Test (pull_request) Successful in 14s
CI / Lint & Typecheck (pull_request) Successful in 18s
CI / Build & Push Docker Image (pull_request) Failing after 8s
The 'error' useState was declared but never read — only setError was called.
Now renders the error message as a red text node when the fetch fails.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-21 11:38:03 +00:00
Flea Flicker 1820f82cfb docs(GRO-1173): add UAT test cases for buffer rules, service buffer, and pet size/coat
CI / Test (pull_request) Successful in 14s
CI / Lint & Typecheck (pull_request) Failing after 17s
CI / Build & Push Docker Image (pull_request) Has been skipped
Sections 5.20, 5.21, and 5.22 added to UAT_PLAYBOOK.md:
- TC-WEB-5.20.x: Buffer rules CRUD, validation, empty state (14 cases)
- TC-WEB-5.21.x: Service default buffer field in table and form (7 cases)
- TC-WEB-5.22.x: Pet size category and coat type in portal PetForm (7 cases)

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-05-21 11:16:34 +00:00
Flea Flicker ec17f1e885 feat(GRO-1173): apply buffer rules UI changes to extracted groombook/web repo
CI / Test (pull_request) Successful in 15s
CI / Lint & Typecheck (pull_request) Failing after 18s
CI / Build & Push Docker Image (pull_request) Has been skipped
This commit ports the GRO-1173 admin UI changes from the app monorepo
into the extracted groombook/web repo, using the correct source paths
(src/ instead of apps/web/src/):

- New BufferRulesSection component (full CRUD UI for /api/buffer-rules)
- Default Buffer (minutes) field added to service create/edit form
- Size Category and Coat Type dropdowns added to PetForm (portal)
- @groombook/types Service interface extended with defaultBufferMinutes
- BufferRulesSection embedded in Settings page

The PetForm already had coatType — this commit adds petSizeCategory
and renders both fields with proper dropdown selectors.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-05-21 11:12:45 +00:00
Flea Flicker f1bb7c4fa6 fix(GRO-1414): update pet size value from x-large to xlarge
CI / Test (pull_request) Successful in 14s
CI / Lint & Typecheck (pull_request) Successful in 18s
CI / Build & Push Docker Image (pull_request) Successful in 35s
Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-05-21 06:58:56 +00:00
The Dogfather 56b11befe9 Merge pull request 'feat(GRO-1174): add pet size/coat dropdowns to booking wizard' (#8) from flea-flicker/pet-profile-editor into dev
CI / Test (push) Successful in 14s
CI / Lint & Typecheck (push) Successful in 17s
CI / Build & Push Docker Image (push) Successful in 37s
test merge
2026-05-21 00:43:10 +00:00
Chris Farhood f414d2589f fix(GRO-1366): add non-null assertion to removeButtons[0]
CI / Test (pull_request) Successful in 14s
CI / Lint & Typecheck (pull_request) Successful in 17s
CI / Build & Push Docker Image (pull_request) Failing after 8s
Fix TypeScript error on line 114: HTMLElement | undefined is not
assignable to Element. Added ! assertion since length guard already
excludes the empty-array case.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-05-20 14:22:02 +00:00
The Dogfather db6a2a1bbf Merge pull request 'promote: dev → uat (Renovate config, GRO-1081)' (#11) from dev into uat
promote: dev → uat (Renovate config, GRO-1081)

Merge PR #11: dev → uat promotion
Includes: chore: add Renovate config (GRO-1081)
2026-05-20 12:42:04 +00:00
The Dogfather 032a3796ba Merge pull request 'chore: promote dev to uat (CI Docker registry fix)' (#10) from dev into uat
chore: promote dev to uat (CI Docker registry fix) (#10)

Promotes GRO-1348 CI registry fix to UAT.
2026-05-20 11:17:21 +00:00
Chris Farhood d1f8d27d1c fix: remove unused X import from lucide-react
CI / Test (pull_request) Successful in 15s
CI / Lint & Typecheck (pull_request) Successful in 18s
CI / Build & Push Docker Image (pull_request) Failing after 7s
Resolves ESLint error: 'X' is defined but never used
GRO-1347

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-05-20 10:55:24 +00:00
the-dogfather-cto[bot] cac8fc947e chore(GRO-1289): promote dev to uat — add UAT_PLAYBOOK.md
chore(GRO-1289): promote dev to uat — add UAT_PLAYBOOK.md
2026-05-14 21:13:56 +00:00
the-dogfather-cto[bot] 592be1301c chore: promote dev to uat (#3)
chore: promote dev to uat
2026-05-11 13:19:33 +00:00
39 changed files with 4454 additions and 156 deletions
+3
View File
@@ -78,6 +78,8 @@ jobs:
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
with:
driver-opts: network=host
- name: Log in to Gitea Container Registry
uses: docker/login-action@v3
@@ -92,6 +94,7 @@ jobs:
context: .
file: Dockerfile
push: true
provenance: false
tags: |
git.farh.net/groombook/web:${{ steps.version.outputs.tag }}
${{ github.ref == 'refs/heads/main' && 'git.farh.net/groombook/web:latest' || '' }}
+11
View File
@@ -0,0 +1,11 @@
{
"mcpServers": {
"gitea": {
"type": "http",
"url": "https://git-mcp.farh.net/mcp",
"headers": {
"Authorization": "Bearer ${GITEA_TOKEN}"
}
}
}
}
+54
View File
@@ -0,0 +1,54 @@
# AGENTS.md
This repository (`groombook/web`) is part of the GroomBook application stack. The
authoritative process, quality bar, and safety rules live in the shared
[`groombook/org`](https://git.farh.net/groombook/org) skills repository. Read
those first; this file is only a pointer.
## Authoritative skills
- **SDLC (branching, PRs, phases, handoffs):**
[`groombook/org/skills/sdlc/SKILL.md`](https://git.farh.net/groombook/org/src/branch/main/skills/sdlc/SKILL.md)
- **Coding standards (priority ordering, PR discipline, tests, no-hardcoded-values, CalVer):**
[`groombook/org/skills/coding-standards/SKILL.md`](https://git.farh.net/groombook/org/src/branch/main/skills/coding-standards/SKILL.md)
- **Safety (no plaintext secrets, no direct `kubectl apply` to `groombook`, no self-merge, board approval for destructive actions):**
[`groombook/org/skills/safety/SKILL.md`](https://git.farh.net/groombook/org/src/branch/main/skills/safety/SKILL.md)
For human contributors and humans reviewing agent work, see
[`CONTRIBUTING.md`](./CONTRIBUTING.md) in this repo for the phase-by-phase PR
flow and the `uat→main` merge-gate policy summary.
## Non-negotiable operational rules
These mirror the org skills; they are restated here so any agent landing in
this repo sees them without a cross-repo fetch.
- **All changes go through a PR.** Never push directly to `dev`, `uat`, or `main`.
- **Branch strategy:** `feature/<name>``dev``uat``main`. Engineers
always target `dev` first.
- **No self-merge contract.** The engineer who opened a PR clicks merge only
after the named reviewer (CI / QA / UAT / Security / CTO per phase)
approves. Issue-thread QA / UAT / security approvals do **not** clear the
Gitea `required_approvals` gate on `uat→main` — only a Gitea **Approve**
click from a member of the `approvals_whitelist_username` does. On this
repo that whitelist is `["gb_flea", "gb_dogfather"]` (engineer team).
Board-level accounts cannot give the Approve click by policy.
- **Always include `cc @cpfarhood`** at the bottom of every PR body for
board visibility (not as a reviewer).
- **Secrets in code are forbidden.** Use Bitnami Sealed Secrets; never commit
plaintext. See the `safety` skill.
- **Production (`groombook` namespace) is Flux-managed.** Never
`kubectl apply` directly. Infrastructure changes go through PRs in
`groombook/infra`.
## Local development
See the repo's own README, package scripts, and CI workflow. The
authoritative pipeline (Gitea Actions, image build, deploy hooks) is the
shared `groombook/infra` overlay; do not reimplement it here.
## When uncertain
If a task conflicts with the org skills, **the org skills win**. Open an
issue in `groombook/org` to propose a change rather than encoding a local
exception.
+117
View File
@@ -0,0 +1,117 @@
# Contributing to `groombook/web`
Thanks for contributing. This document is the human-facing companion to
[`AGENTS.md`](./AGENTS.md) and the authoritative
[`groombook/org`](https://git.farh.net/groombook/org) skills. The org skills
govern; this file is a quick-reference for the human/agent PR flow in this
repo.
## Branch strategy
Three long-lived branches; one PR per promotion step.
| Branch | Environment | Who merges | Prerequisites for merge |
|---------|-------------|-----------|-------------------------|
| `dev` | Dev | Engineer | CI passes |
| `uat` | UAT | Engineer | QA code review approval |
| `main` | Production | Engineer | UAT validation + CTO Gitea Approve when the `uat→main` merge-gate policy applies (see below) |
Engineers always target `dev` first. Feature branches: `<agent-name>/<short-description>`.
## Phase-by-phase PR flow
### Phase 1 — Dev
1. Branch from `dev`: `git checkout -b <name>/<short-description> origin/dev`.
2. Write code + tests. Run unit tests, type check, and lint locally (or rely on CI).
3. Open a PR against `dev`:
```bash
tea pr create --base dev --title "..." --body "..."
```
Include `cc @cpfarhood` at the bottom of the body for board visibility.
4. CI must pass. CI green → engineer self-merges.
5. CI builds and deploys to Dev automatically.
### Phase 2 — UAT promotion
1. Open a PR from `dev` to `uat`.
2. CI must pass.
3. **QA (Lint Roller)** reviews and approves on the Gitea PR.
4. QA approved → engineer self-merges.
5. CI builds and deploys to UAT automatically.
### Phase 3 — UAT regression + Security review
1. **UAT (Shedward Scissorhands)** runs full regression against UAT — every
feature, old and new, no exceptions.
2. **Security (Barkley Trimsworth)** reviews the changes.
3. Failures in either gate bounce back to Phase 1.
### Phase 4 — Production promotion (`uat → main`)
This is the gate the org PR
[`groombook/org#13`](https://git.farh.net/groombook/org/pulls/13) defines.
The full rule is in
[`groombook/org/skills/sdlc/SKILL.md`](https://git.farh.net/groombook/org/src/branch/main/skills/sdlc/SKILL.md)
and
[`groombook/org/skills/coding-standards/SKILL.md`](https://git.farh.net/groombook/org/src/branch/main/skills/coding-standards/SKILL.md);
the summary is below.
**The CTO Gitea Approve click is NOT the default gate.** Once the four
pre-gates (QA, UAT deploy, UAT regression, security) are green, the engineer
self-merges.
**A CTO Gitea Approve click IS required** only for PRs in one of three
categories:
1. **Novel auth / session paths** — login, OIDC, OOBE, session middleware,
token issuance, password reset, MFA, new auth provider integrations.
Routine auth-gated UI (button styling, error messages, form layout) is
**not** in this category.
2. **Infra / prod-affecting merges** — deploys, infra manifests, secrets,
GitOps overlays, CI/CD, `main` branch protection, production
routing/ingress, prod state mutations. All Phase 5 infra overlay PRs in
`groombook/infra` require CTO Gitea Approve without exception.
3. **Risk-flagged merges** — `risk:cto-approve` label, or explicit CTO/CEO
sign-off request in the PR or issue thread.
The engineer opens the `uat→main` PR, classifies it against the three
categories above, and adds `cc @cpfarhood`. If the PR is in scope, the CTO
clicks Approve; once approved (and the four pre-gates are green), the
engineer merges.
### Phase 5 — Production deployment
A separate PR in `groombook/infra` bumps the overlay image tag for prod.
Handed to QA (Lint Roller) for review, then self-merged by the engineer.
## The four pre-gates (uat→main)
A `uat→main` PR is mergeable when **all four** are green:
1. **QA code review** — done on the dev→uat promotion PR.
2. **UAT deploy** — the UAT image built from the uat tip is live in UAT.
3. **UAT regression** — Shedward's full-feature UAT pass is green (no
pre-existing defects, no new defects).
4. **Security review** — Barkley's security code review is green.
Issue-thread QA / UAT / security approvals do **not** clear the Gitea
`required_approvals` gate. Only a Gitea **Approve** click from a member of
the `approvals_whitelist_username` for `main` clears it. In this repo that
whitelist is the engineer team (`gb_flea`, `gb_dogfather`).
## Style, tests, and quality bar
See
[`groombook/org/skills/coding-standards/SKILL.md`](https://git.farh.net/groombook/org/src/branch/main/skills/coding-standards/SKILL.md)
for the engineering priority ordering, test requirements, no-hardcoded-values
rules, CalVer versioning policy, and the `git.farh.net` container registry
policy.
## Safety
See
[`groombook/org/skills/safety/SKILL.md`](https://git.farh.net/groombook/org/src/branch/main/skills/safety/SKILL.md)
for the non-negotiable rules: no plaintext secrets, no `kubectl apply` to
`groombook`, no self-merge, no direct `tofu` runs, board approval for
destructive actions, escalation protocol.
+324 -1
View File
@@ -53,6 +53,9 @@ export const { signIn, signOut, useSession, changePassword } = authClient;
| TC-WEB-5.1.2 | OIDC redirect | Click OIDC login button | Redirected to OIDC provider, then back to app with session established |
| TC-WEB-5.1.3 | Logout | Click logout button | Session cleared, redirected to login page |
| TC-WEB-5.1.4 | Session indicator | After successful login | User info/initials visible in UI indicating active session |
| TC-WEB-5.1.5 | Unauthenticated `/login` renders the form (GRO-2011) | In a private/incognito window with no session cookie, navigate to UAT `/login` | React root mounts; the GroomBook sign-in card with the OIDC button is visible. Network tab shows `/api/auth/get-session` 200, `/api/setup/status` 200, and the login form is rendered (NOT a blank white viewport). |
| TC-WEB-5.1.6 | Swallowed render error surfaces in DOM (GRO-2094) | Trigger a render-time exception in the React tree (e.g. via temporary throw in a child component on a test build) and load `/login` in a clean context | Either the login form renders normally (happy path) OR the top-level `ErrorBoundary` testid `error-boundary` is visible with a populated `error-boundary-message` pre block showing the exception name/message/stack. **NEVER** a blank `<div id="root">` with no error indicator. Browser console must contain either zero render errors or a `[ErrorBoundary]` line plus the raw exception. |
| TC-WEB-5.1.7 | Global `error` and `unhandledrejection` listeners are wired (GRO-2094) | In a clean browser context, load `/login`, then trigger `setTimeout(() => { throw new Error("synthetic") }, 0)` from the console and `Promise.reject(new Error("synthetic-promise"))` | Browser console shows `[window.error]` and `[unhandledrejection]` log lines with the thrown values. Confirms global listeners are active in production. |
### 5.2 Authentication — VITE_API_URL Set
@@ -78,6 +81,26 @@ export const { signIn, signOut, useSession, changePassword } = authClient;
| TC-AUTH-5.4.1 | Session persists across page reload | Sign in, reload page | Session remains active |
| TC-AUTH-5.4.2 | Session clears on sign-out | Sign in, sign out | User is logged out, redirected to login |
### 5.4.1 SSO Login Journey (Authentik OIDC end-to-end)
| # | Scenario | Steps | Pass Criteria | Fail Criteria |
|---|----------|-------|---------------|---------------|
| TC-WEB-SSO-1 | Sign-in page shows SSO button | Navigate to app root URL | Sign-in page displayed with "Sign in with SSO" button visible | No SSO button, 403 before page loads |
| TC-WEB-SSO-2 | Click SSO redirects to Authentik | Click "Sign in with SSO" button | Browser redirected to Authentik login at auth.farh.net | No redirect, error shown, button does nothing |
| TC-WEB-SSO-3 | Valid OIDC credentials authenticate | At Authentik, enter valid credentials and authenticate | Redirected back to app with active session | Redirect loop, 403, session not established |
| TC-WEB-SSO-4 | Post-login dashboard accessible | After SSO flow completes, dashboard loads | Dashboard displays correctly with user identity shown | Blank page, 403, session not active |
| TC-WEB-SSO-5 | User identity displayed correctly | After SSO login, check header/nav | User name/email/initials shown in nav, role reflected in UI | No user indicator, wrong user shown |
### 5.4.2 OOBE Flow Post-Login
| # | Scenario | Steps | Pass Criteria | Fail Criteria |
|---|----------|-------|---------------|---------------|
| TC-WEB-OOBE-1 | Fresh DB shows setup wizard | On fresh DB (no super user), navigate to app | Setup wizard / OOBE screen displayed | Regular login page shown instead of setup |
| TC-WEB-OOBE-2 | Configure OIDC via setup | During OOBE, configure OIDC auth provider via /api/setup/auth-provider | OIDC configured successfully, no 403 | 403 during setup, config rejected |
| TC-WEB-OOBE-3 | Setup completes and redirects | Complete OOBE setup with business name | Redirected to app dashboard as super user, setup bypassed on reload | Setup errors, wrong redirect, setup reappears |
| TC-WEB-OOBE-4 | Admin panel accessible after setup | After completing OOBE, navigate to admin panel | Admin features accessible | 403 on admin panel, insufficient permissions |
| TC-WEB-OOBE-5 | SSO login during OOBE does not interfere | During fresh OOBE, attempt SSO login before completing setup | SSO login redirected appropriately, setup can still complete | Auto-provision creates staff prematurely, setup flow broken |
### 5.5 Dashboard
| # | Scenario | Steps | Expected |
@@ -104,6 +127,20 @@ export const { signIn, signOut, useSession, changePassword } = authClient;
| TC-WEB-5.7.2 | Add pet | Click "Add Pet", fill form, submit | Pet created and linked to client |
| TC-WEB-5.7.3 | Edit pet details | Click on pet, modify details, save | Pet updated successfully |
| TC-WEB-5.7.4 | Grooming history view | View pet profile | Past appointments/grooming sessions displayed |
| TC-WEB-5.7.5 | Add pet with size/coat | Create pet with Size Category and Coat Type filled | Size and coat type persisted, visible on pet profile |
| TC-WEB-5.7.6 | Edit pet size/coat | Edit existing pet, change size/coat dropdowns | Updated values saved to pet record |
| TC-WEB-5.7.7 | Size/coat optional | Create pet without selecting size or coat | Pet created successfully, fields remain unset |
### 5.8.1 Buffer Rules Management UI (GRO-1173)
| # | Scenario | Steps | Expected |
|---|----------|-------|----------|
| TC-WEB-5.8.2 | Buffer rules section visible | Navigate to Settings | "Buffer Rules" section shown with description |
| TC-WEB-5.8.3 | Create buffer rule | Click "+ Add Rule", select service and buffer minutes, submit | Rule appears in list, matches service/size/coat |
| TC-WEB-5.8.4 | Edit buffer minutes inline | Click Edit on a rule, change minutes, click Save | New buffer value reflected in list |
| TC-WEB-5.8.5 | Delete buffer rule | Click Delete, confirm | Rule removed from list |
| TC-WEB-5.8.6 | Create rule with size/coat | Create rule with Size Category or Coat Type specified | Rule shows size/coat tags in list |
| TC-WEB-5.8.7 | Empty state | Navigate to Settings with no rules | "No buffer rules configured yet" message shown |
### 5.8 Appointment Scheduling UI
@@ -122,6 +159,8 @@ export const { signIn, signOut, useSession, changePassword } = authClient;
| TC-WEB-5.9.1 | Service catalog loads | Navigate to Services | List of available services displayed |
| TC-WEB-5.9.2 | Create service | Click "New Service", fill form, submit | Service created successfully |
| TC-WEB-5.9.3 | Edit service | Click on service, modify details, save | Service updated successfully |
| TC-WEB-5.9.4 | Create service with default buffer | Create service with "Default buffer time" filled | Buffer shown in service list and form after save |
| TC-WEB-5.9.5 | Edit service buffer | Open existing service, change default buffer minutes | Updated value persisted after save |
### 5.10 Staff Management UI
@@ -143,10 +182,108 @@ export const { signIn, signOut, useSession, changePassword } = authClient;
| # | Scenario | Steps | Expected |
|---|----------|-------|----------|
| TC-WEB-5.12.1 | Client-facing view | Log in as client persona | Customer portal UI displayed |
| TC-WEB-5.12.2 | Appointment list | View client portal appointments | List of client's appointments visible |
| TC-WEB-5.12.2 | Appointment list | View client portal appointments | List of client's appointments visible — each card shows pet name, service, formatted date/time, and groomer (no "Failed to load appointments" error, no blank screen). "Book New" button is visible and clickable. See 5.12d. |
| TC-WEB-5.12.3 | Confirm appointment | Click confirm on pending appointment | Appointment status updated to confirmed |
| TC-WEB-5.12.4 | Cancel appointment | Click cancel on appointment | Appointment marked as cancelled |
#### 5.12b Dynamic Portal Time Slots (GRO-1793, GRO-2105)
| # | Scenario | Steps | Expected |
|---|----------|-------|----------|
| TC-WEB-5.12.5 | BookingFlow dynamic slots | Open Book New, select pet and service, pick a date | `GET /api/book/availability?serviceId=<selected>&date=<picked>`; "Checking availability…" shown while loading; slot list rendered |
| TC-WEB-5.12.6 | BookingFlow slots match wizard | Compare BookingFlow slot times with public booking wizard for same date | Same slots displayed |
| TC-WEB-5.12.7 | BookingFlow error state | Mock API failure on availability fetch (4xx/5xx OR a 200 with non-array body) | "Failed to load time slots" error shown and the page stays interactive (no white screen) |
| TC-WEB-5.12.8 | BookingFlow no slots | Select date with no availability | "No available slots on this date" shown |
| TC-WEB-5.12.9 | RescheduleFlow dynamic slots | Open reschedule, pick a new date | `GET /api/book/availability?serviceId=<appt.serviceId>&date=<picked>`; loading state shown; slot list rendered |
| TC-WEB-5.12.10 | RescheduleFlow error state | Mock API failure on availability fetch (4xx/5xx OR a 200 with non-array body) | "Failed to load time slots" error shown and the page stays interactive (no white screen) |
| TC-WEB-5.12.11 | RescheduleFlow no slots | Select date with no availability | "No available slots on this date" shown |
> **GRO-2105 regression note:** prior to the fix, both `BookingFlow` and
> `RescheduleFlow` called `/api/book/availability` with only `date=…`, so the
> API responded 400 `{error:"serviceId and date are required"}`. The React
> handler then `.map()`'d that error object, throwing `TypeError: ee.map is
> not a function` and wiping `<div id="root">`. The fix ensures both flows
> include `serviceId` in the query string and surface the API's error string
> (or "Failed to load time slots") instead of crashing.
#### 5.12c Waitlist/Booking Status Badges (GRO-1795)
| # | Scenario | Steps | Expected |
|---|----------|-------|----------|
| TC-WEB-5.12.12 | Confirmed badge | View appointment card with confirmed status | Green "Confirmed" badge displayed |
| TC-WEB-5.12.13 | Pending badge | View appointment card with pending status | Amber "Pending" badge displayed |
| TC-WEB-5.12.14 | Waitlisted badge | View appointment card with waitlisted status | Blue "Waitlisted" badge displayed |
| TC-WEB-5.12.15 | Badge uses CSS classes | Inspect badge element | Badge uses CSS variable-based classes (e.g., bg-green-100, text-amber-600), not hardcoded colors |
| TC-WEB-5.12.16 | Badge status from data | Compare badge label to appointment.status field | Badge label matches the API appointment status exactly |
| TC-WEB-5.12.17 | Unknown status fallback | Render badge with unknown status value | Badge renders with the raw status string as label and fallback CSS class |
#### 5.12f Live StatusBadge palette — no-show / pending / waitlisted (GRO-2319)
These cases exercise the full StatusBadge palette as it is now produced live by
the seeded UAT customer (`uat-customer@groombook.dev`), not just unit-rendered.
| # | Scenario | Steps | Expected |
|---|----------|-------|----------|
| TC-WEB-5.12.26 | No-show badge (item 1) | Sign in as `uat-customer@groombook.dev`, open `Appointments`**Past** tab, find the seeded `no_show` appointment | A styled yellow **"No-show"** badge renders (`bg-yellow-100 text-yellow-700`) — **not** a raw gray `no_show` label. The DB `no_show` (underscore) status is normalized to the `no-show` palette key. |
| TC-WEB-5.12.27 | Pending derivation (item 2) | On the **Upcoming** tab, find the seeded upcoming appointment whose `confirmationStatus` is `pending` (groomer-unconfirmed) | The card's top-row badge reads amber **"Pending"** (derived from `confirmationStatus`), even though the raw appointment status is `scheduled`. |
| TC-WEB-5.12.28 | Confirmed not overridden | On the **Upcoming** tab, find the seeded confirmed appointment (`confirmationStatus = confirmed`) | Badge still reads green **"Confirmed"** — the pending derivation does not override a confirmed appointment. |
| TC-WEB-5.12.29 | Waitlisted card (item 2) | On the **Upcoming** tab, find the seeded waitlist entry for the customer | A card renders with a blue **"Waitlisted"** badge, a **dashed muted border**, and the subtext _"You're on the waitlist — we'll let you know if a spot opens."_ The Confirm / Reschedule / Cancel / Notes actions are **not** shown for this entry (it is not a booked appointment). |
> **GRO-2319 note:** the DB `appointment_status` enum cannot represent `pending`
> or `waitlisted`, so those badges are derived in the portal: `pending` from an
> upcoming appointment's `confirmationStatus`, and `waitlisted` from active
> `waitlist_entries` surfaced by `GET /api/portal/appointments` as synthetic
> cards. The `no_show` → `no-show` key normalization fixes the cosmetic badge
> mismatch (item 1).
#### 5.12d Appointment API Shape Normalization (GRO-2180)
| # | Scenario | Steps | Expected |
|---|----------|-------|----------|
| TC-WEB-5.12.18 | Portal appointments load (regression) | Sign in as `uat-customer@groombook.dev`, open `Appointments` | List renders without the "Failed to load appointments. Please try again." error; "Book New" button is visible and clickable |
| TC-WEB-5.12.19 | Card fields populated from API | Inspect an appointment card | Pet name, service, formatted date (e.g. "Mon, Jun 1, 2026"), time (e.g. "10:00 AM"), and groomer name render — derived from the API's `startTime`/`endTime`/nested `pet`/`staff` objects |
| TC-WEB-5.12.20 | Upcoming vs Past split | View both tabs | Future, non-cancelled/non-completed appointments appear under "Upcoming"; past/completed/cancelled under "Past" (classification uses absolute `startTime`) |
| TC-WEB-5.12.21 | Reschedule from card | Expand an upcoming appointment, click Reschedule, pick a date | `GET /api/book/availability?serviceId=<appt.serviceId>&date=<picked>` fires with a non-empty `serviceId` (sourced from the API's nested `service.id`) |
> **GRO-2180 regression note:** `/api/portal/appointments` returns ISO
> `startTime`/`endTime` and nested `pet`/`service`/`staff` objects, but the portal
> client `Appointment` type expected flat `date`/`time`/`petName` fields.
> `isUpcoming()` read `appt.date`/`appt.time` (both `undefined`), so
> `parseTimeTo24Hour(undefined)` threw `TypeError`, the `useEffect` `try/catch`
> set the error state, and the "Book New" button (only rendered in the success
> path) became unreachable. The fix normalizes the API response into the flat
> `Appointment` shape at the fetch boundary (`normalizeAppointment`), prefers the
> absolute `startTime` in `isUpcoming`, and hardens `parseTimeTo24Hour` against
> blank/undefined input.
#### 5.12e Book New `preferredTime` Formatting (GRO-2211, GRO-2213)
| # | Scenario | Steps | Expected |
|---|----------|-------|----------|
| TC-WEB-5.12.22 | Slot buttons show formatted label | Sign in as `uat-customer@groombook.dev`, open `Appointments`, click "Book New", select a pet and service, pick a date with availability | Each time-slot button shows a human-readable label like `10:00 AM` (UTC), never a raw ISO timestamp (e.g. not `2026-06-09T10:00:00.000Z`) |
| TC-WEB-5.12.23 | Confirmation review shows formatted label | Continue the Book New wizard to the Review step | The "Date & Time" summary and the final confirmation both display the formatted slot label (e.g. `10:00 AM`), not a raw ISO string |
| TC-WEB-5.12.24 | Booking submit succeeds (regression) | Complete the Book New wizard and submit the request | Request succeeds with no `500` / `invalid input syntax for type time` error; the booking POST sends `preferredTime` as `HH:MM:SS` (e.g. `10:00:00`); the new appointment appears in the Upcoming list |
| TC-WEB-5.12.25 | Slow-wizard submit succeeds (GRO-2234) | Sign in as `uat-customer@groombook.dev`, open `Appointments`, click "Book New", then deliberately pace the wizard (pet → service → groomer → date/slot → review) so that **>2 minutes** elapse before clicking "Confirm Booking". | Submit returns success — **no** "Failed to book appointment. Please try again." error. In DevTools → Network, if the first `POST /api/portal/waitlist` returns `401`, a `POST /api/portal/session-from-auth` fires immediately after and the booking is retried once with the fresh `X-Impersonation-Session-Id`, then returns 201. The appointment appears in the Upcoming list. |
> **GRO-2234 note:** A deliberately-paced Book New wizard could outlive the
> portal impersonation session, so the final `POST /api/portal/waitlist` returned
> `401 {"error":"Unauthorized"}` ("Failed to book appointment"). The web fix adds
> a transparent one-shot re-mint: on a `401` from the waitlist submit,
> `BookingFlow` calls `POST /api/portal/session-from-auth` (the Better Auth
> cookie is still valid) and retries the submit once with the fresh session id.
> The companion API fix (groombook/api GRO-2234) adds bounded sliding expiration
> so active sessions rarely lapse in the first place.
> **GRO-2211/GRO-2213 note:** The Book New wizard previously rendered the raw
> UTC ISO slot string as the button/confirmation label and submitted that same
> ISO value as `preferredTime`, which the API rejected with
> `invalid input syntax for type time` (HTTP 500). The fix adds shared UTC
> helpers `formatSlotLabel(slot)` (display → `10:00 AM`) and `slotToTime(slot)`
> (payload → `HH:MM:SS`) in `src/portal/sections/Appointments.tsx`, so the
> displayed label and the submitted `preferredTime` both derive from the same
> canonical UTC ISO slot. (The sibling `RescheduleFlow` `startTime` raw-ISO issue
> on a different endpoint is tracked separately and is out of scope here.)
### 5.13 Reports UI
| # | Scenario | Steps | Expected |
@@ -217,6 +354,192 @@ export const { signIn, signOut, useSession, changePassword } = authClient;
| TC-WEB-5.19.8 | Form reset clears size/coat | Complete booking, click "Book another" | Size and coat fields reset to empty |
| TC-WEB-5.19.9 | New pet record has size/coat | Complete booking, view created pet in admin | Pet record shows selected size and coat type |
### 5.20 Buffer Rules Management — Admin UI (GRO-1173)
| # | Scenario | Steps | Expected |
|---|----------|-------|----------|
| TC-WEB-5.20.1 | Buffer rules section loads | Navigate to Settings page (admin) | "Buffer Rules" section visible with "+ Add Rule" button |
| TC-WEB-5.20.2 | Add rule — required fields only | Click "+ Add Rule", select a service, enter buffer minutes, submit | Rule created, appears in list below |
| TC-WEB-5.20.3 | Add rule — with size category | Add rule, select service + size category + buffer minutes | Rule created with size tag shown in list |
| TC-WEB-5.20.4 | Add rule — with coat type | Add rule, select service + coat type + buffer minutes | Rule created with coat tag shown in list |
| TC-WEB-5.20.5 | Add rule — with both size and coat | Add rule, select service + size + coat + buffer minutes | Rule created with both tags shown |
| TC-WEB-5.20.6 | Validation — missing service | Submit form without selecting service | Error: "Service and valid buffer minutes are required" |
| TC-WEB-5.20.7 | Validation — zero buffer | Submit form with 0 buffer minutes | Error: "Service and valid buffer minutes are required" |
| TC-WEB-5.20.8 | Edit rule inline | Click "Edit" on a rule, change buffer value, click "Save" | Rule updated in list |
| TC-WEB-5.20.9 | Cancel edit | Click "Edit", then "Cancel" | Original value unchanged |
| TC-WEB-5.20.10 | Delete rule — confirmation | Click "Delete" on a rule | Confirmation prompt appears |
| TC-WEB-5.20.11 | Confirm delete | On confirmation prompt, click "Confirm" | Rule removed from list |
| TC-WEB-5.20.12 | Cancel delete | On confirmation prompt, click "Cancel" | Rule remains in list |
| TC-WEB-5.20.13 | Empty state | No rules exist | Message: "No buffer rules configured yet." |
| TC-WEB-5.20.14 | Toggle form | Click "+ Add Rule", then "Cancel" | Form hidden, no rule created |
### 5.21 Service Default Buffer Minutes (GRO-1173)
| # | Scenario | Steps | Expected |
|---|----------|-------|----------|
| TC-WEB-5.21.1 | Default buffer shown in table | Navigate to Services page | "Default Buffer" column visible in services table |
| TC-WEB-5.21.2 | New service default is 0 | Click "+ Add Service" | Default Buffer field pre-filled with 0 |
| TC-WEB-5.21.3 | Create service with buffer | Fill service form, set Default Buffer = 10, submit | Service created with 10 min default buffer |
| TC-WEB-5.21.4 | Edit service — view buffer | Edit an existing service | Current default buffer value shown in form |
| TC-WEB-5.21.5 | Update buffer on existing service | Edit service, change Default Buffer to 15, save | Buffer updated, table shows 15 min |
| TC-WEB-5.21.6 | Buffer field — zero allowed | Set Default Buffer to 0, save | Service saved with 0 (no default buffer) |
| TC-WEB-5.21.7 | Buffer field — integer only | Enter non-integer value | Field restricts to integer values |
### 5.22 Pet Profile — Size Category & Coat Type (GRO-1173)
| # | Scenario | Steps | Expected |
|---|----------|-------|----------|
| TC-WEB-5.22.1 | Size category dropdown visible | Open Add Pet or Edit Pet form (portal) | "Size Category" dropdown visible with options: Small, Medium, Large, X-Large |
| TC-WEB-5.22.2 | Coat type dropdown visible | Open Add Pet or Edit Pet form | "Coat Type" dropdown visible with options: Smooth, Double, Curly, Wire, Long, Hairless |
| TC-WEB-5.22.3 | Size and coat both optional | Submit pet form without selecting size or coat | Pet saved successfully |
| TC-WEB-5.22.4 | Save pet with size category | Select "Large", fill required fields, save | Pet saved with size = "large" |
| TC-WEB-5.22.5 | Save pet with coat type | Select "Curly", fill required fields, save | Pet saved with coat = "curly" |
| TC-WEB-5.22.6 | Size and coat persisted | Save pet with size + coat, edit again | Both fields retain their selected values |
| TC-WEB-5.22.7 | Clear size | Select size, then clear back to default | Size cleared on save |
### 5.23 Pet Profile — API Persistence & Save UX (GRO-1470)
| # | Scenario | Steps | Expected |
|---|----------|-------|----------|
| TC-WEB-5.23.1 | Save pet — API persistence | Edit a pet, change a field (e.g. coat type), click Save, reload the page | Changed field retained after reload (proves PATCH round-trip to server) |
| TC-WEB-5.23.2 | Save pet — error state | Trigger an API save failure (e.g. network error) | Error message displayed; edit form stays open; no data cleared |
| TC-WEB-5.23.3 | Save pet — saving indicator | Click Save | Spinner/indicator shown while request is in flight; form controls disabled |
### 5.24 Booking Funnel Analytics Events (GRO-1794)
| # | Scenario | Steps | Expected |
|---|----------|-------|----------|
| TC-WEB-5.24.1 | booking_step_service — public | Select a service in the public booking wizard | `booking_step_service` CustomEvent fires with detail.step="service" and detail.flow="public" |
| TC-WEB-5.24.2 | booking_step_time — public | Select a time slot and click Continue | `booking_step_time` fires with detail.step="time" and detail.flow="public" |
| TC-WEB-5.24.3 | booking_step_contact — public | Fill in contact/pet form, click "Review booking" | `booking_step_contact` fires with detail.step="contact" and detail.flow="public" |
| TC-WEB-5.24.4 | booking_step_submit — public | Confirm and submit the booking | `booking_step_submit` fires with detail.step="submit" and detail.flow="public" |
| TC-WEB-5.24.5 | booking_confirmed — public | Navigate to /booking-confirmed | `booking_confirmed` fires once on mount with detail.step="confirmed" and detail.flow="public" |
| TC-WEB-5.24.6 | booking_error — public | Navigate to /booking-error | `booking_error` fires once on mount with detail.step="error" and detail.flow="public" |
| TC-WEB-5.24.7 | booking_step_service — portal | Select a pet in the portal BookingFlow | `booking_step_service` fires with detail.step="service" and detail.flow="portal" |
| TC-WEB-5.24.8 | booking_step_time — portal | Pick a date and time in portal BookingFlow | `booking_step_time` fires with detail.step="time" and detail.flow="portal" |
| TC-WEB-5.24.9 | booking_step_contact — portal | Proceed from groomer selection to review screen | `booking_step_contact` fires with detail.step="groomer" and detail.flow="portal" |
| TC-WEB-5.24.10 | booking_step_submit — portal | Submit booking in portal BookingFlow | `booking_step_submit` fires with detail.step="submit" and detail.flow="portal" |
| TC-WEB-5.24.11 | booking_confirmed — portal | Portal booking request succeeds | Inline success state is shown and `booking_confirmed` fires with detail.step="confirmed" and detail.flow="portal" |
| TC-WEB-5.24.12 | No PII in analytics payloads | Fire each event and inspect detail object | Payload contains only: step, flow, timestamp — no names, emails, phone numbers, or pet names |
| TC-WEB-5.24.13 | No-op safe | Trigger analytics with window.dispatchEvent blocked (e.g. CSP) | No error thrown; booking flow completes normally |
### 5.25 Customer Portal — Better Auth SSO Bridge (GRO-1867)
These cases cover the `CustomerPortal` initialisation path that bridges an Authentik / Better Auth session into a portal session via `POST /api/portal/session-from-auth`. The bridge runs after the URL-impersonation (`?sessionId=`) and dev-user paths have been ruled out.
**Pre-conditions:**
- UAT is configured with Authentik SSO. The seeded customer **Authentik** password lives in the `authentik-uat-users-credentials` Secret in the `groombook-uat` namespace (key `uat_customer_password`) — **NOT** in `seed-uat-passwords:customer-password` (that Secret holds the *Better Auth* email+password credential, a separate identity store; see GRO-2089). Pull the Authentik password at the start of every run:
```bash
CUSTOMER_AUTHENTIK=$(kubectl get secret authentik-uat-users-credentials -n groombook-uat \
-o jsonpath='{.data.uat_customer_password}' | base64 -d)
```
The Authentik user is provisioned by Terraform (`infra/terraform/users.tf`); the `lifecycle.ignore_changes = [password]` block means the password is set on initial creation and never auto-rotated, so the value held in the live Secret is the one Authentik itself has. If Authentik rejects it, the user was re-provisioned out-of-band via the Authentik admin UI and the Secret has drifted from the live identity — fix the Secret (or the admin-set password) and re-run.
- `POST /api/portal/session-from-auth` from [GRO-1866](https://paperclip.farhoodlabs.com/GRO/issues/GRO-1866) is deployed on UAT.
- Clear cookies and localStorage between cases unless otherwise noted.
| # | Scenario | Steps | Expected |
|---|----------|-------|----------|
| TC-WEB-5.25.1 | Authenticated customer reaches portal dashboard | 1. From clean state, navigate to UAT `/login`. 2. Click "Sign in with SSO" and complete Authentik flow with a seeded **customer** identity. 3. After callback, land on `/`. | Portal dashboard renders. No redirect to `/login`. No impersonation banner. Top-right greeting reads "Hi, &lt;FirstName&gt;". |
| TC-WEB-5.25.2 | Bridge call sequence | Repeat TC-WEB-5.25.1 with DevTools → Network open and the **All** tab filtered to `/api/`. | In order: `GET /api/auth/get-session` → 200. `POST /api/portal/session-from-auth` → 201 with body `{ sessionId, clientId, clientName }`. |
| TC-WEB-5.25.3 | Subsequent portal calls use the bridged session ID | After TC-WEB-5.25.1 succeeds, navigate to **Appointments**, **My Pets**, **Billing**, **Settings**. Inspect any `/api/portal/*` request in DevTools → Network. | Each portal API call carries an `X-Impersonation-Session-Id` header whose value equals the `sessionId` returned by `session-from-auth` (not a URL-param value). Each call returns 200 (or 404 for genuinely empty collections), never 401. |
| TC-WEB-5.25.4 | No impersonation chrome for the customer's own session | After TC-WEB-5.25.1, scan the portal UI. | No amber border around the page. No "STAFF VIEW" watermark. No "End Impersonation" button in the sidebar. The customer is themselves; only impersonation sessions started via `?sessionId=` show the banner. |
| TC-WEB-5.25.5 | 404 fallback for authenticated user with no client record | 1. Sign in via SSO with an Authentik account whose email is **not** present in `clients`. 2. Land on `/`. | `POST /api/portal/session-from-auth` returns 404. The portal renders a centred card titled **"Portal access not configured"** with the message about contacting the groomer and a **Sign out** button. No redirect loop, no portal chrome. |
| TC-WEB-5.25.6 | 404 fallback Sign-out escape hatch (GRO-2358) | From TC-WEB-5.25.5 click **Sign out**. | The shared `signOut()` from `lib/auth-client` fires (same handler as `AdminLayout`); browser navigates to `/login`; the Authentik session cookie is cleared. Reloading `/` no longer hits 404 (will show the login page). The handler always navigates to `/login` — even if the network call to `/api/auth/sign-out` fails — so a transient auth-server hiccup never leaves the user trapped on an authenticated screen. |
| TC-WEB-5.25.6b | 404 fallback Sign-out on deep-link (GRO-2358) | From TC-WEB-5.25.5, instead of staying on `/`, navigate directly to a portal sub-route (e.g. `/appointments`, `/pets`, `/billing`). The no-access card renders. Click **Sign out**. | The same shared `signOut()` handler fires and the browser navigates to `/login`. The no-access screen must surface an escape hatch on every authenticated route — not just `/` — so a stale or deep link into a portal the user has no access to can never trap them. |
| TC-WEB-5.25.7 | Bridge precedence — impersonation URL wins | 1. Sign in via SSO as a customer. 2. Open a new tab to `https://uat.groombook.dev/?sessionId=<a-valid-staff-impersonation-session-id>`. | The impersonation path runs; the amber banner appears for the impersonated client. The Better Auth bridge is **not** called on this load (`session-from-auth` absent in Network). |
| TC-WEB-5.25.8 | Bridge precedence — dev user wins | In dev mode (e.g. local) with `localStorage["dev-user"]` set to a client persona, navigate to `/`. | The dev-session path runs (`POST /api/portal/dev-session`). The Better Auth bridge is **not** called (`session-from-auth` absent in Network). Staff dev users still redirect to `/admin`. |
| TC-WEB-5.25.9 | Staff Better Auth session does not run the customer bridge | Sign in via SSO with a staff identity. Navigate to `/`. | `App.tsx` routing redirects to `/admin`. `POST /api/portal/session-from-auth` is **not** called. |
| TC-WEB-5.25.10 | Unauthenticated user is sent to login (no infinite loop) | Without signing in, navigate directly to `/`. | `App.tsx` renders the LoginPage. `CustomerPortal` does not render. No `session-from-auth` request is made. |
| TC-WEB-5.25.11 | Session persists across reload via Better Auth cookie | After TC-WEB-5.25.1 succeeds, reload the page. | Portal dashboard re-renders. A fresh `GET /api/auth/get-session` + `POST /api/portal/session-from-auth` pair runs and yields 200/201. Greeting still reads "Hi, &lt;FirstName&gt;". |
### 5.27 Customer Portal — Authenticated HTML-route cold mount (GRO-2099)
These cases guard against the regression where a customer who had just completed SSO sign-in was bounced back to `/login` (with a blank React root) when navigating directly to `/portal`, `/book`, `/schedule`, or even `/login` itself. Root cause: `Dashboard.tsx`'s `!sessionId && !isImpersonating && !getDevUser()` guard fired during the CustomerPortal's bootstrap — before the SSO bridge resolved `portalSessionId` — and redirected to `/login`. The fix: `CustomerPortal` now shows a loading state while the bootstrap is in flight, so the portal chrome and its `!sessionId` child guards do not mount prematurely. App.tsx additionally redirects an authenticated user at `/login` to `/` instead of rendering `null`.
**Pre-conditions:**
- TC-WEB-5.25.1 — TC-WEB-5.25.3 must pass on the build under test.
- Clear cookies and localStorage between cases.
| # | Scenario | Steps | Expected |
|---|----------|-------|----------|
| TC-WEB-5.27.1 | Authenticated customer lands on `/portal` after direct nav | 1. From clean state, complete TC-WEB-5.25.1 (SSO sign-in as a customer). 2. Land on `/`. 3. `browser_navigate` (full page load) directly to `/portal`. | Final URL stays at `/portal`. The React root is non-empty. The portal dashboard renders with the customer's name. No `Navigate to /login` fires. |
| TC-WEB-5.27.2 | Authenticated customer lands on `/book` and `/schedule` after direct nav | From TC-WEB-5.27.1, `browser_navigate` to `/book` then `/schedule` (one fresh navigation each). | Each final URL stays at the navigated path. The portal chrome is visible. The page does not redirect to `/login`. |
| TC-WEB-5.27.3 | Authenticated customer at `/login` is auto-redirected to `/` | From TC-WEB-5.27.1, `browser_navigate` to `/login`. | The browser ends at `/` (not at a blank `/login`). The portal dashboard renders. No blank React root at `/login`. |
| TC-WEB-5.27.4 | Loading state is visible during the bootstrap, no portal chrome flash | 1. With the UAT build under test, open DevTools → Network and throttle to Slow 3G. 2. Sign in via SSO. 3. Land on `/`. | A "Loading…" element (`role="status"`) is briefly visible. The portal nav (Home / Appointments / etc.) is NOT visible during the loading window. No `Navigate to /login` fires during the bootstrap. |
| TC-WEB-5.27.5 | SSO bridge still runs and yields 201 | From TC-WEB-5.27.4 (or TC-WEB-5.27.1), inspect Network. | The same `GET /api/auth/get-session` (200) → `POST /api/portal/session-from-auth` (201) sequence from TC-WEB-5.25.2 still runs. The customer name appears in the greeting. |
| TC-WEB-5.27.6 | Unauthenticated direct nav to `/portal` still ends at `/login` (no regression) | Clear cookies. `browser_navigate` to `/portal`. | The portal briefly shows the loading state, then `CustomerPortal`'s `!session && !portalSessionId` guard redirects to `/login`. The login form renders. No infinite loop. |
| TC-WEB-5.27.7 | Groomer SSO still works (no regression) | 1. From clean state, sign in via SSO as the groomer identity (uat-groomer). 2. Land on `/`. | `App.tsx`'s staff check redirects to `/admin`. The groomer nav renders. No `CustomerPortal` flash. No `/portal` redirect loop. |
| TC-WEB-5.27.8 | Impersonation session still works (no regression) | 1. With an active impersonation session, open `/?sessionId=<id>`. | The amber "STAFF VIEW" chrome renders. The portal loads. No `/login` redirect. |
### 5.26 Customer Portal — RescheduleFlow under SSO Bridge (GRO-2012)
These cases guard against the regression where an SSO-bridge customer (no `?sessionId=` URL param, no impersonation session) could trigger the RescheduleFlow and have `RescheduleFlow` receive `sessionId={null}`, which caused the internal `/api/book/availability` call to send `X-Impersonation-Session-Id: ` (empty) and return 401. The fix: `CustomerPortal` now passes `sessionId={session?.id ?? portalSessionId}` to `<RescheduleFlow>` (matching the fallback `renderSection()` already used).
**Pre-conditions:**
- TC-WEB-5.25.1 — TC-WEB-5.25.3 must pass on the build under test.
- The seeded customer used has at least one upcoming, non-cancelled appointment with `status` ∈ {`pending`, `confirmed`}.
| # | Scenario | Steps | Expected |
|---|----------|-------|----------|
| TC-WEB-5.26.1 | RescheduleFlow receives portalSessionId (no 401) | 1. Complete TC-WEB-5.25.1 (SSO sign-in as a customer). 2. From the dashboard, click **Reschedule** on the next-upcoming appointment. 3. In the RescheduleFlow modal, pick a future date. 4. Open DevTools → Network and filter to `/api/`. | The `GET /api/book/availability?date=<picked>` request includes an `X-Impersonation-Session-Id` header whose value equals the `sessionId` from `session-from-auth`. The request returns 200. The time-slot list populates. No 401. |
| TC-WEB-5.26.2 | RescheduleFlow submit succeeds | From TC-WEB-5.26.1, pick a time slot and confirm. | `POST /api/portal/appointments/<id>/reschedule` (or the equivalent) includes the same `X-Impersonation-Session-Id` value. Returns 200. The modal closes and the appointment card reflects the new time. |
| TC-WEB-5.26.3 | Impersonation flow reschedule is unchanged (no regression) | 1. With an active impersonation session (`?sessionId=<active>`), load `/`. 2. Click **Reschedule** on an appointment. 3. Pick a date. | `GET /api/book/availability` includes `X-Impersonation-Session-Id` equal to the impersonation `sessionId` (not `portalSessionId`). Returns 200. Behaves identically to the pre-fix build. |
| TC-WEB-5.26.4 | No `X-Impersonation-Session-Id` is empty / null | From TC-WEB-5.26.1, inspect every `/api/portal/*` and `/api/book/*` request. | No request has an empty or `null` `X-Impersonation-Session-Id` header. |
### 5.28 Route Planner Page (GRO-2158)
The admin Route Planner lives at `/admin/routes`. It shows a groomer's geocoded appointment stops for a chosen date on a `react-leaflet` / OpenStreetMap map (numbered pins + a connecting polyline), a stop-list panel, a travel-time/distance summary, a route status badge, and an **Optimize** button wired to `POST /api/routes/optimize`. Leaflet is loaded via a dynamic import so it ships as a separate code-split chunk. Groomers are auto-filtered to their own route (no groomer selector); managers/receptionists pick a groomer.
**Pre-conditions:**
- Sign in to `/admin` as a manager (e.g. uat-manager) and, separately, as a groomer (uat-groomer).
- At least one groomer has appointments on the test date whose clients have geocoded addresses.
| # | Scenario | Steps | Expected |
|---|----------|-------|----------|
| TC-WEB-5.28.1 | Page loads and is reachable from nav | 1. Sign in as a manager. 2. Click **Routes** in the admin nav. | URL is `/admin/routes`. The "Route Planner" heading, a Date picker, a Groomer selector, and an **Optimize** button render. No console errors. |
| TC-WEB-5.28.2 | Leaflet map is code-split | 1. Open DevTools → Network (JS filter). 2. Load `/admin/reports` first, confirm no `RouteMap` chunk loads. 3. Navigate to `/admin/routes`. | A separate `RouteMap-*.js` chunk (and `RouteMap-*.css`) is fetched only when the Routes page renders, not on other admin pages. |
| TC-WEB-5.28.3 | Map shows numbered pins + polyline | Select a groomer + date that has a built route with ≥2 geocoded stops. | The OSM map renders with one numbered pin per stop (1, 2, 3…) and a polyline connecting them in order. Tile attribution to OpenStreetMap is visible. |
| TC-WEB-5.28.4 | Stop-list panel cards | Inspect the panel beside the map. | Each stop card shows the stop number, client name, appointment time, address, and travel time from the previous stop (stop 1 shows "Start of route"). |
| TC-WEB-5.28.5 | Summary + status badge | Inspect the summary bar and badge. | Stops count, total travel time, and total distance (km) are shown. A status badge reads one of Draft / Optimized / In progress / Completed matching the route's status. |
| TC-WEB-5.28.6 | Optimize button | Click **Optimize**. | A `POST /api/routes/optimize` with `{ staffId, date }` fires. On success the map, stop order, summary, and status badge refresh. Any skipped (non-geocoded) clients surface as a warning. |
| TC-WEB-5.28.7 | Groomer role auto-filter | Sign in as a groomer and open `/admin/routes`. | No groomer selector is shown. The page loads the signed-in groomer's own route for the selected date. The groomer cannot view another groomer's route. |
| TC-WEB-5.28.8 | Empty / no-route state | Select a date with no appointments. | The map area and stop panel show a friendly empty state ("No stops…"). No crash; **Optimize** is still clickable. |
### 5.29 Route Planner — Drag-to-Reorder & Re-optimize (GRO-2159)
The stop-list panel is drag-sortable (`@dnd-kit`). Each stop card has a grab handle (⠿). Dropping a stop in a new position calls `PATCH /api/routes/:routeId/reorder` with `{ stopOrder: [routeStopId…] }` (full first-to-last order); the UI updates optimistically and rolls back on error. The server recomputes per-leg travel, buffers, totals and tight-schedule conflict flags, and the panel/map/summary adopt the response. A "tight schedule" warning is shown on any stop whose gap is shorter than its travel + buffer. After a manual reorder a hint with a **Re-optimize** button appears (re-runs `POST /api/routes/optimize`). Drag works via mouse (desktop), press-and-hold touch (mobile groomers), and keyboard (focus handle → Space → arrows → Space).
| Test Case | Description | Steps | Expected Result |
|-----------|-------------|-------|-----------------|
| TC-WEB-5.29.1 | Drag handle present | Open `/admin/routes` for a route with ≥2 stops. | Each stop card shows a grab handle (⠿) with an accessible label "Drag to reorder <client>". |
| TC-WEB-5.29.2 | Reorder persists | Drag a stop to a new position and drop it. | A `PATCH /api/routes/:routeId/reorder` fires with the new `stopOrder` (every stop id once, new order). Stop numbers, the map polyline order, and travel-from-previous labels refresh to match. |
| TC-WEB-5.29.3 | Optimistic update + rollback | Simulate a failing reorder (e.g. server returns an error / offline). | The list shows the new order immediately, then reverts to the prior order when the PATCH fails, and an error message is shown. No stuck/partial order. |
| TC-WEB-5.29.4 | Tight-schedule warning re-evaluated | Reorder so two stops are too close together. | The affected stop card shows "⚠ Tight schedule — travel + buffer may exceed the gap" (red border) after the server recomputes; warnings clear on a roomier order. |
| TC-WEB-5.29.5 | Re-optimize button | After a manual drag reorder, locate the hint banner. | A "Stops reordered manually…" hint with a **Re-optimize** button appears. Clicking it fires `POST /api/routes/optimize` and the hint clears once the optimized route loads. The hint is absent before any manual reorder. |
| TC-WEB-5.29.6 | Touch / mobile drag | On a touch device (or mobile emulation), press-and-hold a stop's handle (~200ms) then drag. | The stop lifts and can be dropped in a new position; page scroll is not hijacked by a quick swipe. Reorder persists as in 5.29.2. |
| TC-WEB-5.29.7 | Groomer reorders own route | Sign in as a groomer, reorder stops on the own route. | Reorder succeeds (groomer is authorized for their own route). |
### 5.30 Route Planner — Navigation Export & Offline (GRO-2160)
When a route has stops, an export panel offers **Open in Google Maps** and **Open in Apple Maps** buttons. Each fetches `GET /api/routes/:routeId/export/google-maps` (or `/apple-maps`) and opens the returned deep-link URL in the device's maps app (Google Maps `https://www.google.com/maps/dir/?...`, Apple Maps `maps://...`). The page detects the device OS (iOS / Android / desktop) and renders the most relevant button prominently (filled) with the other as a secondary outline button; on iOS Apple Maps leads, otherwise Google Maps leads. Offline support: the existing Workbox `NetworkFirst` rule caches `/api/routes/*` responses (24h TTL) so a previously-loaded route still renders without network; a `CacheFirst` rule (`osm-tiles`, 7-day TTL, 400 entries) caches OpenStreetMap tiles. On every route load and after each optimize/reorder, the page pre-warms the OSM tiles covering the route's bounding box (zooms 1214, capped at 80 tiles) so the map is viewable offline. The layout is responsive: below 768px the map/stop-list stack to one column, the map shrinks, and the export buttons go full-width.
| Test Case | Description | Steps | Expected Result |
|-----------|-------------|-------|-----------------|
| TC-WEB-5.30.1 | Export buttons render | Open `/admin/routes` for a route with ≥1 stop. | An export panel shows both **Open in Google Maps** and **Open in Apple Maps** buttons. Buttons are absent when there are no stops. |
| TC-WEB-5.30.2 | Google Maps deep link | Click **Open in Google Maps**. | A `GET /api/routes/:routeId/export/google-maps` fires and the returned `https://www.google.com/maps/dir/?...` URL opens (new tab / Google Maps app) with origin, destination, and waypoints in route order. |
| TC-WEB-5.30.3 | Apple Maps deep link | On iOS (or emulation), click **Open in Apple Maps**. | A `GET /api/routes/:routeId/export/apple-maps` fires and the returned `maps://...` URL opens Apple Maps with the route chained `+to:`. |
| TC-WEB-5.30.4 | Platform-aware prominence | Open the page on an iPhone (or iOS UA emulation) vs Android/desktop. | On iOS the **Apple Maps** button is the prominent (filled) one and Google Maps is the secondary (outline); on Android/desktop **Google Maps** is prominent and Apple Maps secondary. Both buttons are always available. |
| TC-WEB-5.30.5 | Export error handling | Trigger an export that errors (e.g. route exceeds the platform waypoint cap). | The pre-opened tab is closed and an inline error message is shown; no silent failure. |
| TC-WEB-5.30.6 | Offline route data | Load a route online, then in DevTools → Network set **Offline** and reload `/admin/routes` for the same groomer/date. | The route data still loads from the `api-cache` (NetworkFirst fallback); stops, summary, and badge render without network. |
| TC-WEB-5.30.7 | Offline map tiles | After viewing/optimizing a route online, go **Offline** and view the same route. | The OSM map tiles for the route area render from the `osm-tiles` CacheFirst cache (pre-warmed); the map is not blank in the route's vicinity. |
| TC-WEB-5.30.8 | Responsive mobile layout | Open the page at a phone width (≤768px, e.g. 390px). | Map and stop-list stack into a single column, the map height shrinks, and the export buttons span full width. No horizontal scroll; controls remain usable with a thumb. |
## 6. Pass/Fail Criteria
**Pass:**
+6
View File
@@ -13,14 +13,19 @@
"test:e2e": "playwright test -c e2e/playwright.config.ts"
},
"dependencies": {
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2",
"@groombook/types": "workspace:*",
"@stripe/react-stripe-js": "^6.1.0",
"@stripe/stripe-js": "^9.1.0",
"@tailwindcss/vite": "^4.2.2",
"better-auth": "^1.5.6",
"leaflet": "^1.9.4",
"lucide-react": "^0.577.0",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"react-leaflet": "^5.0.0",
"react-router-dom": "^7.1.2",
"recharts": "^3.8.0",
"tailwindcss": "^4.2.2"
@@ -30,6 +35,7 @@
"@testing-library/jest-dom": "^6.9.1",
"@testing-library/react": "^16.3.2",
"@testing-library/user-event": "^14.6.1",
"@types/leaflet": "^1.9.12",
"@types/node": "^25.6.0",
"@types/react": "^19.0.6",
"@types/react-dom": "^19.0.3",
+5
View File
@@ -34,6 +34,10 @@ export interface Pet {
breed: string | null;
weightKg: number | null;
dateOfBirth: string | null;
/** Portal-shaped serialization of weightKg (GET/PATCH /api/portal/pets). */
weight?: string | number | null;
/** Portal-shaped serialization of dateOfBirth (GET/PATCH /api/portal/pets). */
birthDate?: string | null;
healthAlerts: string | null;
groomingNotes: string | null;
cutStyle: string | null;
@@ -71,6 +75,7 @@ export interface Service {
basePriceCents: number;
durationMinutes: number;
active: boolean;
defaultBufferMinutes?: number;
createdAt: string;
updatedAt: string;
}
+138 -3
View File
@@ -8,6 +8,15 @@ importers:
.:
dependencies:
'@dnd-kit/core':
specifier: ^6.3.1
version: 6.3.1(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
'@dnd-kit/sortable':
specifier: ^10.0.0
version: 10.0.0(@dnd-kit/core@6.3.1(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(react@19.2.6)
'@dnd-kit/utilities':
specifier: ^3.2.2
version: 3.2.2(react@19.2.6)
'@groombook/types':
specifier: workspace:*
version: link:packages/types
@@ -23,6 +32,9 @@ importers:
better-auth:
specifier: ^1.5.6
version: 1.6.10(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(vitest@3.2.4(@types/node@25.6.2)(jiti@2.7.0)(jsdom@26.1.0)(lightningcss@1.32.0)(terser@5.47.1))
leaflet:
specifier: ^1.9.4
version: 1.9.4
lucide-react:
specifier: ^0.577.0
version: 0.577.0(react@19.2.6)
@@ -32,12 +44,15 @@ importers:
react-dom:
specifier: ^19.0.0
version: 19.2.6(react@19.2.6)
react-leaflet:
specifier: ^5.0.0
version: 5.0.0(leaflet@1.9.4)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
react-router-dom:
specifier: ^7.1.2
version: 7.15.0(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
recharts:
specifier: ^3.8.0
version: 3.8.1(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react-is@16.13.1)(react@19.2.6)(redux@5.0.1)
version: 3.8.1(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react-is@17.0.2)(react@19.2.6)(redux@5.0.1)
tailwindcss:
specifier: ^4.2.2
version: 4.3.0
@@ -54,6 +69,9 @@ importers:
'@testing-library/user-event':
specifier: ^14.6.1
version: 14.6.1(@testing-library/dom@10.4.1)
'@types/leaflet':
specifier: ^1.9.12
version: 1.9.21
'@types/node':
specifier: ^25.6.0
version: 25.6.2
@@ -739,6 +757,28 @@ packages:
resolution: {integrity: sha512-Vd/9EVDiu6PPJt9yAh6roZP6El1xHrdvIVGjyBsHR0RYwNHgL7FJPyIIW4fANJNG6FtyZfvlRPpFI4ZM/lubvw==}
engines: {node: '>=18'}
'@dnd-kit/accessibility@3.1.1':
resolution: {integrity: sha512-2P+YgaXF+gRsIihwwY1gCsQSYnu9Zyj2py8kY5fFvUM1qm2WA2u639R6YNVfU4GWr+ZM5mqEsfHZZLoRONbemw==}
peerDependencies:
react: '>=16.8.0'
'@dnd-kit/core@6.3.1':
resolution: {integrity: sha512-xkGBRQQab4RLwgXxoqETICr6S5JlogafbhNsidmrkVv2YRs5MLwpjoF2qpiGjQt8S9AoxtIV603s0GIUpY5eYQ==}
peerDependencies:
react: '>=16.8.0'
react-dom: '>=16.8.0'
'@dnd-kit/sortable@10.0.0':
resolution: {integrity: sha512-+xqhmIIzvAYMGfBYYnbKuNicfSsk4RksY2XdmJhT+HAC01nix6fHCztU68jooFiMUB01Ky3F0FyOvhG/BZrWkg==}
peerDependencies:
'@dnd-kit/core': ^6.3.0
react: '>=16.8.0'
'@dnd-kit/utilities@3.2.2':
resolution: {integrity: sha512-+MKAJEOfaBe5SmV6t34p80MMKhjvUz0vRrvVJbPT0WElzaOJ/1xs+D+KDv+tD/NE5ujfrChEcshd4fLn0wpiqg==}
peerDependencies:
react: '>=16.8.0'
'@esbuild/aix-ppc64@0.25.12':
resolution: {integrity: sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==}
engines: {node: '>=18'}
@@ -1005,6 +1045,13 @@ packages:
engines: {node: '>=18'}
hasBin: true
'@react-leaflet/core@3.0.0':
resolution: {integrity: sha512-3EWmekh4Nz+pGcr+xjf0KNyYfC3U2JjnkWsh0zcqaexYqmmB5ZhH37kz41JXGmKzpaMZCnPofBBm64i+YrEvGQ==}
peerDependencies:
leaflet: ^1.9.0
react: ^19.0.0
react-dom: ^19.0.0
'@reduxjs/toolkit@2.11.2':
resolution: {integrity: sha512-Kd6kAHTA6/nUpp8mySPqj3en3dm0tdMIgbttnQ1xFMVpufoj+ADi8pXLBsd4xzTRHQa7t/Jv8W5UnCuW4kuWMQ==}
peerDependencies:
@@ -1102,66 +1149,79 @@ packages:
resolution: {integrity: sha512-DV6fJoxEYWJOvaZIsok7KrYl0tPvga5OZ2yvKHNNYyk/2roMLqQAbGhr78EQ5YhHpnhLKJD3S1WFusAkmUuV5g==}
cpu: [arm]
os: [linux]
libc: [glibc]
'@rollup/rollup-linux-arm-musleabihf@4.60.3':
resolution: {integrity: sha512-mQKoJAzvuOs6F+TZybQO4GOTSMUu7v0WdxEk24krQ/uUxXoPTtHjuaUuPmFhtBcM4K0ons8nrE3JyhTuCFtT/w==}
cpu: [arm]
os: [linux]
libc: [musl]
'@rollup/rollup-linux-arm64-gnu@4.60.3':
resolution: {integrity: sha512-Whjj2qoiJ6+OOJMGptTYazaJvjOJm+iKHpXQM1P3LzGjt7Ff++Tp7nH4N8J/BUA7R9IHfDyx4DJIflifwnbmIA==}
cpu: [arm64]
os: [linux]
libc: [glibc]
'@rollup/rollup-linux-arm64-musl@4.60.3':
resolution: {integrity: sha512-4YTNHKqGng5+yiZt3mg77nmyuCfmNfX4fPmyUapBcIk+BdwSwmCWGXOUxhXbBEkFHtoN5boLj/5NON+u5QC9tg==}
cpu: [arm64]
os: [linux]
libc: [musl]
'@rollup/rollup-linux-loong64-gnu@4.60.3':
resolution: {integrity: sha512-SU3kNlhkpI4UqlUc2VXPGK9o886ZsSeGfMAX2ba2b8DKmMXq4AL7KUrkSWVbb7koVqx41Yczx6dx5PNargIrEA==}
cpu: [loong64]
os: [linux]
libc: [glibc]
'@rollup/rollup-linux-loong64-musl@4.60.3':
resolution: {integrity: sha512-6lDLl5h4TXpB1mTf2rQWnAk/LcXrx9vBfu/DT5TIPhvMhRWaZ5MxkIc8u4lJAmBo6klTe1ywXIUHFjylW505sg==}
cpu: [loong64]
os: [linux]
libc: [musl]
'@rollup/rollup-linux-ppc64-gnu@4.60.3':
resolution: {integrity: sha512-BMo8bOw8evlup/8G+cj5xWtPyp93xPdyoSN16Zy90Q2QZ0ZYRhCt6ZJSwbrRzG9HApFabjwj2p25TUPDWrhzqQ==}
cpu: [ppc64]
os: [linux]
libc: [glibc]
'@rollup/rollup-linux-ppc64-musl@4.60.3':
resolution: {integrity: sha512-E0L8X1dZN1/Rph+5VPF6Xj2G7JJvMACVXtamTJIDrVI44Y3K+G8gQaMEAavbqCGTa16InptiVrX6eM6pmJ+7qA==}
cpu: [ppc64]
os: [linux]
libc: [musl]
'@rollup/rollup-linux-riscv64-gnu@4.60.3':
resolution: {integrity: sha512-oZJ/WHaVfHUiRAtmTAeo3DcevNsVvH8mbvodjZy7D5QKvCefO371SiKRpxoDcCxB3PTRTLayWBkvmDQKTcX/sw==}
cpu: [riscv64]
os: [linux]
libc: [glibc]
'@rollup/rollup-linux-riscv64-musl@4.60.3':
resolution: {integrity: sha512-Dhbyh7j9FybM3YaTgaHmVALwA8AkUwTPccyCQ79TG9AJUsMQqgN1DDEZNr4+QUfwiWvLDumW5vdwzoeUF+TNxQ==}
cpu: [riscv64]
os: [linux]
libc: [musl]
'@rollup/rollup-linux-s390x-gnu@4.60.3':
resolution: {integrity: sha512-cJd1X5XhHHlltkaypz1UcWLA8AcoIi1aWhsvaWDskD1oz2eKCypnqvTQ8ykMNI0RSmm7NkTdSqSSD7zM0xa6Ig==}
cpu: [s390x]
os: [linux]
libc: [glibc]
'@rollup/rollup-linux-x64-gnu@4.60.3':
resolution: {integrity: sha512-DAZDBHQfG2oQuhY7mc6I3/qB4LU2fQCjRvxbDwd/Jdvb9fypP4IJ4qmtu6lNjes6B531AI8cg1aKC2di97bUxA==}
cpu: [x64]
os: [linux]
libc: [glibc]
'@rollup/rollup-linux-x64-musl@4.60.3':
resolution: {integrity: sha512-cRxsE8c13mZOh3vP+wLDxpQBRrOHDIGOWyDL93Sy0Ga8y515fBcC2pjUfFwUe5T7tqvTvWbCpg1URM/AXdWIXA==}
cpu: [x64]
os: [linux]
libc: [musl]
'@rollup/rollup-openbsd-x64@4.60.3':
resolution: {integrity: sha512-QaWcIgRxqEdQdhJqW4DJctsH6HCmo5vHxY0krHSX4jMtOqfzC+dqDGuHM87bu4H8JBeibWx7jFz+h6/4C8wA5Q==}
@@ -1248,24 +1308,28 @@ packages:
engines: {node: '>= 20'}
cpu: [arm64]
os: [linux]
libc: [glibc]
'@tailwindcss/oxide-linux-arm64-musl@4.3.0':
resolution: {integrity: sha512-Z6sukiQsngnWO+l39X4pPbiWT81IC+PLKF+PHxIlyZbGNb9MODfYlXEVlFvej5BOZInWX01kVyzeLvHsXhfczQ==}
engines: {node: '>= 20'}
cpu: [arm64]
os: [linux]
libc: [musl]
'@tailwindcss/oxide-linux-x64-gnu@4.3.0':
resolution: {integrity: sha512-DRNdQRpSGzRGfARVuVkxvM8Q12nh19l4BF/G7zGA1oe+9wcC6saFBHTISrpIcKzhiXtSrlSrluCfvMuledoCTQ==}
engines: {node: '>= 20'}
cpu: [x64]
os: [linux]
libc: [glibc]
'@tailwindcss/oxide-linux-x64-musl@4.3.0':
resolution: {integrity: sha512-Z0IADbDo8bh6I7h2IQMx601AdXBLfFpEdUotft86evd/8ZPflZe9COPO8Q1vw+pfLWIUo9zN/JGZvwuAJqduqg==}
engines: {node: '>= 20'}
cpu: [x64]
os: [linux]
libc: [musl]
'@tailwindcss/oxide-wasm32-wasi@4.3.0':
resolution: {integrity: sha512-HNZGOUxEmElksYR7S6sC5jTeNGpobAsy9u7Gu0AskJ8/20FR9GqebUyB+HBcU/ax6BHuiuJi+Oda4B+YX6H1yA==}
@@ -1387,9 +1451,15 @@ packages:
'@types/estree@1.0.9':
resolution: {integrity: sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==}
'@types/geojson@7946.0.16':
resolution: {integrity: sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg==}
'@types/json-schema@7.0.15':
resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==}
'@types/leaflet@1.9.21':
resolution: {integrity: sha512-TbAd9DaPGSnzp6QvtYngntMZgcRk+igFELwR2N99XZn7RXUdKgsXMR+28bUO0rPsWp8MIu/f47luLIQuSLYv/w==}
'@types/node@25.6.2':
resolution: {integrity: sha512-sokuT28dxf9JT5Kady1fsXOvI4HVpjZa95NKT5y9PNTIrs2AsobR4GFAA90ZG8M+nxVRLysCXsVj6eGC7Vbrlw==}
@@ -2462,6 +2532,9 @@ packages:
resolution: {integrity: sha512-nbD8lB9EB3wNdMhOCdx5Li8DxnLbvKByylRLcJ1h+4SkrowVeECAyZlyiKMThF7xFdRz0jSQ2MoJr+wXux2y0Q==}
engines: {node: '>=20.0.0'}
leaflet@1.9.4:
resolution: {integrity: sha512-nxS1ynzJOmOlHp+iL3FyWqK89GtNL8U8rvlMOsQdTTssxZwCXh8N2NB3GDQOL+YR3XnWyZAxwQixURb+FA74PA==}
leven@3.1.0:
resolution: {integrity: sha512-qsda+H8jTaUaN/x5vzW2rzc+8Rw4TAQ/4KjB46IwK5VH+IlVeeeje/EoZRpiXvIqjFgK84QffqPztGI3VBLG1A==}
engines: {node: '>=6'}
@@ -2505,24 +2578,28 @@ packages:
engines: {node: '>= 12.0.0'}
cpu: [arm64]
os: [linux]
libc: [glibc]
lightningcss-linux-arm64-musl@1.32.0:
resolution: {integrity: sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==}
engines: {node: '>= 12.0.0'}
cpu: [arm64]
os: [linux]
libc: [musl]
lightningcss-linux-x64-gnu@1.32.0:
resolution: {integrity: sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==}
engines: {node: '>= 12.0.0'}
cpu: [x64]
os: [linux]
libc: [glibc]
lightningcss-linux-x64-musl@1.32.0:
resolution: {integrity: sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==}
engines: {node: '>= 12.0.0'}
cpu: [x64]
os: [linux]
libc: [musl]
lightningcss-win32-arm64-msvc@1.32.0:
resolution: {integrity: sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==}
@@ -2764,6 +2841,13 @@ packages:
react-is@17.0.2:
resolution: {integrity: sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==}
react-leaflet@5.0.0:
resolution: {integrity: sha512-CWbTpr5vcHw5bt9i4zSlPEVQdTVcML390TjeDG0cK59z1ylexpqC6M1PJFjV8jD7CF+ACBFsLIDs6DRMoLEofw==}
peerDependencies:
leaflet: ^1.9.0
react: ^19.0.0
react-dom: ^19.0.0
react-redux@9.2.0:
resolution: {integrity: sha512-ROY9fvHhwOD9ySfrF0wmvu//bKCQ6AeZZq1nJNtbDC+kk5DuSuNX/n6YWYF/SYy7bSba4D4FSz8DJeKY/S/r+g==}
peerDependencies:
@@ -3123,6 +3207,9 @@ packages:
peerDependencies:
typescript: '>=4.8.4'
tslib@2.8.1:
resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==}
type-check@0.4.0:
resolution: {integrity: sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==}
engines: {node: '>= 0.8.0'}
@@ -4208,6 +4295,31 @@ snapshots:
'@csstools/css-tokenizer@3.0.4': {}
'@dnd-kit/accessibility@3.1.1(react@19.2.6)':
dependencies:
react: 19.2.6
tslib: 2.8.1
'@dnd-kit/core@6.3.1(react-dom@19.2.6(react@19.2.6))(react@19.2.6)':
dependencies:
'@dnd-kit/accessibility': 3.1.1(react@19.2.6)
'@dnd-kit/utilities': 3.2.2(react@19.2.6)
react: 19.2.6
react-dom: 19.2.6(react@19.2.6)
tslib: 2.8.1
'@dnd-kit/sortable@10.0.0(@dnd-kit/core@6.3.1(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(react@19.2.6)':
dependencies:
'@dnd-kit/core': 6.3.1(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
'@dnd-kit/utilities': 3.2.2(react@19.2.6)
react: 19.2.6
tslib: 2.8.1
'@dnd-kit/utilities@3.2.2(react@19.2.6)':
dependencies:
react: 19.2.6
tslib: 2.8.1
'@esbuild/aix-ppc64@0.25.12':
optional: true
@@ -4398,6 +4510,12 @@ snapshots:
dependencies:
playwright: 1.59.1
'@react-leaflet/core@3.0.0(leaflet@1.9.4)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)':
dependencies:
leaflet: 1.9.4
react: 19.2.6
react-dom: 19.2.6(react@19.2.6)
'@reduxjs/toolkit@2.11.2(react-redux@9.2.0(@types/react@19.2.14)(react@19.2.6)(redux@5.0.1))(react@19.2.6)':
dependencies:
'@standard-schema/spec': 1.1.0
@@ -4711,8 +4829,14 @@ snapshots:
'@types/estree@1.0.9': {}
'@types/geojson@7946.0.16': {}
'@types/json-schema@7.0.15': {}
'@types/leaflet@1.9.21':
dependencies:
'@types/geojson': 7946.0.16
'@types/node@25.6.2':
dependencies:
undici-types: 7.19.2
@@ -5884,6 +6008,8 @@ snapshots:
kysely@0.28.17: {}
leaflet@1.9.4: {}
leven@3.1.0: {}
levn@0.4.1:
@@ -6135,6 +6261,13 @@ snapshots:
react-is@17.0.2: {}
react-leaflet@5.0.0(leaflet@1.9.4)(react-dom@19.2.6(react@19.2.6))(react@19.2.6):
dependencies:
'@react-leaflet/core': 3.0.0(leaflet@1.9.4)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
leaflet: 1.9.4
react: 19.2.6
react-dom: 19.2.6(react@19.2.6)
react-redux@9.2.0(@types/react@19.2.14)(react@19.2.6)(redux@5.0.1):
dependencies:
'@types/use-sync-external-store': 0.0.6
@@ -6162,7 +6295,7 @@ snapshots:
react@19.2.6: {}
recharts@3.8.1(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react-is@16.13.1)(react@19.2.6)(redux@5.0.1):
recharts@3.8.1(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react-is@17.0.2)(react@19.2.6)(redux@5.0.1):
dependencies:
'@reduxjs/toolkit': 2.11.2(react-redux@9.2.0(@types/react@19.2.14)(react@19.2.6)(redux@5.0.1))(react@19.2.6)
clsx: 2.1.1
@@ -6172,7 +6305,7 @@ snapshots:
immer: 10.2.0
react: 19.2.6
react-dom: 19.2.6(react@19.2.6)
react-is: 16.13.1
react-is: 17.0.2
react-redux: 9.2.0(@types/react@19.2.14)(react@19.2.6)(redux@5.0.1)
reselect: 5.1.1
tiny-invariant: 1.3.3
@@ -6553,6 +6686,8 @@ snapshots:
dependencies:
typescript: 5.9.3
tslib@2.8.1: {}
type-check@0.4.0:
dependencies:
prelude-ls: 1.2.1
+28 -7
View File
@@ -8,6 +8,7 @@ import { StaffPage } from "./pages/Staff.js";
import { InvoicesPage } from "./pages/Invoices.js";
import { BookPage } from "./pages/Book.js";
import { ReportsPage } from "./pages/Reports.js";
import { RoutesPage } from "./pages/Routes.js";
import { GroupBookingPage } from "./pages/GroupBooking.js";
import { SettingsPage } from "./pages/Settings.js";
import { BookingConfirmedPage } from "./pages/BookingConfirmed.js";
@@ -175,6 +176,7 @@ const NAV_LINKS = [
{ to: "/admin/staff", label: "Staff" },
{ to: "/admin/invoices", label: "Invoices" },
{ to: "/admin/group-bookings", label: "Group Bookings" },
{ to: "/admin/routes", label: "Routes" },
{ to: "/admin/reports", label: "Reports" },
{ to: "/admin/settings", label: "Settings" },
{ to: "/", label: "Customer Portal" },
@@ -303,6 +305,7 @@ function AdminLayout() {
<Route path="/invoices" element={<InvoicesPage />} />
<Route path="/book" element={<BookPage />} />
<Route path="/group-bookings" element={<GroupBookingPage />} />
<Route path="/routes" element={<RoutesPage />} />
<Route path="/reports" element={<ReportsPage />} />
<Route path="/settings" element={<SettingsPage />} />
</Routes>
@@ -327,11 +330,16 @@ export function App() {
.catch(() => setAuthDisabled(false));
}, []);
// After session is confirmed, check if setup is needed
// After session is confirmed, check if setup is needed.
// Always run the setup/status fetch as soon as the auth state is known — even for
// unauthenticated users, so the `needsSetup` value is in place if they sign in
// mid-session. The unauth branch in the render below is handled before
// `needsSetup` is consulted, so this is safe and avoids a stuck-`null` state.
// See GRO-2011.
useEffect(() => {
if (authDisabled === null || sessionLoading) return;
// Skip if no authenticated session (will redirect to login or dev selector)
if (!authDisabled && !session) return;
// In dev mode, only fetch when a dev user has been selected — otherwise the
// user is mid-redirect to the dev login selector and we don't need setup state.
if (authDisabled && !getDevUser()) return;
fetch("/api/setup/status")
@@ -373,8 +381,12 @@ export function App() {
return <Navigate to="/login" replace />;
}
// Show login BEFORE checking needsSetup (needsSetup is never set for unauthenticated users)
if (!authDisabled && !session) {
// Show login BEFORE checking needsSetup (needsSetup is never set for unauthenticated users).
// At /login with a valid session, fall through so the staff redirect below can
// route staff to /admin and the final render can redirect customers to / (portal).
// Previously, an authenticated customer at /login would see a blank page because
// the final render returns null at /login (showCustomerPortal is false). See GRO-2099.
if (!authDisabled && !session && location.pathname === "/login") {
return <LoginPage />;
}
@@ -386,15 +398,24 @@ export function App() {
return <Navigate to="/setup" replace />;
}
// Redirect authenticated users to /admin (but preserve impersonation flow via ?sessionId=)
// Redirect staff to /admin; allow customers to access portal (preserve impersonation via ?sessionId=)
const searchParams = new URLSearchParams(location.search);
if (!authDisabled && session && !location.pathname.startsWith("/admin") && !searchParams.has("sessionId")) {
const isStaff = session?.user && (session.user as any).role === "staff";
if (!authDisabled && session && !location.pathname.startsWith("/admin") && !searchParams.has("sessionId") && isStaff) {
return <Navigate to="/admin" replace />;
}
// Don't render portal chrome at /login — DevLoginSelector is shown instead
const showCustomerPortal = !location.pathname.startsWith("/admin") && location.pathname !== "/login";
// At /login with a valid session, redirect to the portal root. Without this,
// the final render returns null at /login (showCustomerPortal is false) and
// the user sees a blank page after a successful sign-in. Staff are routed
// to /admin by the earlier staff check. See GRO-2099.
if (!authDisabled && session && location.pathname === "/login") {
return <Navigate to="/" replace />;
}
return (
<BrandingProvider>
{location.pathname.startsWith("/admin") ? (
+77
View File
@@ -0,0 +1,77 @@
import { Component } from "react";
import type { ErrorInfo, ReactNode } from "react";
interface ErrorBoundaryProps {
children: ReactNode;
}
interface ErrorBoundaryState {
error: Error | null;
}
/**
* Top-level ErrorBoundary — renders the error visibly so the actual exception
* appears in the DOM (and therefore in the Playwright snapshot) instead of
* React 18+ unmounting the entire tree to a blank `<div id="root">`.
*
* Background: GRO-2094. The bundle was executing but never painting, with
* the failure swallowed. Surfacing the error here is the first step; the
* real fix is in the underlying component that threw.
*/
export class ErrorBoundary extends Component<ErrorBoundaryProps, ErrorBoundaryState> {
state: ErrorBoundaryState = { error: null };
static getDerivedStateFromError(error: Error): ErrorBoundaryState {
return { error };
}
componentDidCatch(error: Error, info: ErrorInfo): void {
// Also surface to the console — this is what the test harness greps for.
// eslint-disable-next-line no-console
console.error("[ErrorBoundary] Uncaught render error:", error, info);
}
render() {
if (this.state.error) {
const err = this.state.error;
return (
<div
data-testid="error-boundary"
style={{
padding: "2rem",
fontFamily: "ui-monospace, SFMono-Regular, Menlo, monospace",
color: "#7f1d1d",
background: "#fef2f2",
minHeight: "100vh",
boxSizing: "border-box",
}}
>
<h1 style={{ fontSize: 18, margin: "0 0 0.5rem" }}>Something went wrong</h1>
<p style={{ margin: "0 0 1rem", color: "#991b1b" }}>
The app failed to render. The full error is shown below please share this
output when reporting the bug.
</p>
<pre
data-testid="error-boundary-message"
style={{
whiteSpace: "pre-wrap",
wordBreak: "break-word",
background: "#fff",
border: "1px solid #fecaca",
borderRadius: 6,
padding: "0.75rem 1rem",
margin: 0,
fontSize: 13,
lineHeight: 1.4,
}}
>
{err.name}: {err.message}
{"\n\n"}
{err.stack ?? "(no stack)"}
</pre>
</div>
);
}
return this.props.children;
}
}
+59
View File
@@ -121,6 +121,65 @@ describe("App navigation", () => {
});
});
describe("GRO-2011 — setup/status fetch for unauthenticated users", () => {
it("calls /api/setup/status for unauthenticated users so needsSetup is never stuck null", async () => {
const setupStatusCalls: string[] = [];
global.fetch = vi.fn((url: string) => {
if (url === "/api/dev/config") {
return Promise.resolve({
ok: true,
json: async () => ({ authDisabled: false }),
} as Response);
}
if (url === "/api/auth/get-session") {
// Better Auth returns 200 with null session for unauthenticated users.
return Promise.resolve({
ok: true,
json: async () => null,
} as unknown as Response);
}
if (url === "/api/setup/status") {
setupStatusCalls.push(url);
return Promise.resolve({
ok: true,
json: async () => ({ needsSetup: false }),
} as Response);
}
if (url === "/api/branding") {
return Promise.resolve({
ok: true,
json: async () => ({
businessName: "GroomBook",
primaryColor: "#4f8a6f",
accentColor: "#8b7355",
logoBase64: null,
logoMimeType: null,
}),
} as Response);
}
return Promise.resolve({ ok: true, json: async () => [] } as Response);
}) as unknown as typeof fetch;
render(
<MemoryRouter initialEntries={["/login"]}>
<App />
</MemoryRouter>
);
// The login page should be rendered for the unauthenticated user.
await screen.findByText("Sign in to continue");
// Crucially, /api/setup/status must be called even when the user is unauthenticated —
// otherwise `needsSetup` stays null and a later code path can short-circuit to a
// blank page (GRO-2011).
await waitFor(() => {
expect(setupStatusCalls.length).toBeGreaterThanOrEqual(1);
});
expect(setupStatusCalls[0]).toBe("/api/setup/status");
});
});
describe("Dev login selector", () => {
it("redirects to /login when auth is disabled and no user selected", async () => {
global.fetch = vi.fn((url: string) => {
+603 -2
View File
@@ -1,6 +1,6 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { render, screen, fireEvent, waitFor } from "@testing-library/react";
import { parseTimeTo24Hour, isUpcoming, CustomerNotesSection, ConfirmationSection } from "../portal/sections/Appointments.tsx";
import { parseTimeTo24Hour, isUpcoming, normalizeAppointment, normalizeService, formatServicePrice, CustomerNotesSection, ConfirmationSection, StatusBadge, normalizeStatusKey, deriveDisplayStatus, formatSlotLabel, slotToTime, BookingFlow } from "../portal/sections/Appointments.tsx";
const UPCOMING_APPT = {
id: "appt-1",
@@ -42,6 +42,84 @@ describe("parseTimeTo24Hour", () => {
expect(parseTimeTo24Hour("11:00 PM")).toBe("23:00:00");
expect(parseTimeTo24Hour("12:00 PM")).toBe("12:00:00");
});
it("does not throw on undefined/null/empty input (GRO-2180)", () => {
expect(() => parseTimeTo24Hour(undefined)).not.toThrow();
expect(() => parseTimeTo24Hour(null)).not.toThrow();
expect(parseTimeTo24Hour(undefined)).toBe("00:00:00");
expect(parseTimeTo24Hour("")).toBe("00:00:00");
});
});
// GRO-2180: `/api/portal/appointments` returns ISO `startTime`/`endTime` + nested
// pet/service/staff objects, not the flat date/time/petName shape the UI renders.
describe("normalizeAppointment (API startTime shape — GRO-2180)", () => {
const RAW_API_APPT = {
id: "a0000001-0000-0000-0000-000000000001",
startTime: "2026-06-01T10:00:00.000Z",
endTime: "2026-06-01T10:45:00.000Z",
status: "completed" as const,
confirmationStatus: "confirmed" as const,
customerNotes: "Please be gentle",
notes: null,
pet: { id: "c0000001-0000-0000-0000-000000000001", name: "UAT Pup Alpha", photo: null },
service: { id: "b0000001-0000-0000-0000-000000000001", name: "Full Groom" },
staff: { id: "00000000-0000-0000-0000-000000000004", name: "UAT Staff Groomer" },
};
it("maps nested pet/service/staff and ISO startTime without throwing", () => {
const appt = normalizeAppointment(RAW_API_APPT);
expect(appt.id).toBe("a0000001-0000-0000-0000-000000000001");
expect(appt.petId).toBe("c0000001-0000-0000-0000-000000000001");
expect(appt.serviceId).toBe("b0000001-0000-0000-0000-000000000001");
expect(appt.groomerId).toBe("00000000-0000-0000-0000-000000000004");
expect(appt.petName).toBe("UAT Pup Alpha");
expect(appt.serviceName).toBe("Full Groom");
expect(appt.groomerName).toBe("UAT Staff Groomer");
expect(appt.startTime).toBe("2026-06-01T10:00:00.000Z");
expect(appt.customerNotes).toBe("Please be gentle");
});
it("derives duration in minutes from start/end delta", () => {
expect(normalizeAppointment(RAW_API_APPT).duration).toBe(45);
});
it("produces a date/time pair that does not crash isUpcoming or formatDate", () => {
const appt = normalizeAppointment(RAW_API_APPT);
expect(typeof appt.date).toBe("string");
expect(typeof appt.time).toBe("string");
expect(() => isUpcoming(appt)).not.toThrow();
});
it("classifies a past completed appointment as not upcoming", () => {
expect(isUpcoming(normalizeAppointment(RAW_API_APPT))).toBe(false);
});
it("classifies a future scheduled appointment as upcoming via startTime", () => {
const future = normalizeAppointment({
...RAW_API_APPT,
startTime: "2099-01-01T10:00:00.000Z",
endTime: "2099-01-01T11:00:00.000Z",
status: "confirmed",
});
expect(isUpcoming(future)).toBe(true);
});
it("tolerates null nested objects without throwing", () => {
const appt = normalizeAppointment({
id: "a2",
startTime: "2099-01-01T10:00:00.000Z",
endTime: "2099-01-01T11:00:00.000Z",
status: "scheduled",
pet: null,
service: null,
staff: null,
});
expect(appt.petId).toBe("");
expect(appt.serviceId).toBe("");
expect(appt.groomerId).toBeNull();
expect(appt.petName).toBeUndefined();
});
});
describe("isUpcoming", () => {
@@ -379,4 +457,527 @@ describe("ConfirmationSection", () => {
expect(screen.getByText(/Confirmed!/i)).toBeInTheDocument();
});
});
});
});
describe("StatusBadge", () => {
it("renders Confirmed for confirmed status", () => {
render(<StatusBadge status="confirmed" />);
expect(screen.getByText("Confirmed")).toBeInTheDocument();
});
it("renders Pending for pending status", () => {
render(<StatusBadge status="pending" />);
expect(screen.getByText("Pending")).toBeInTheDocument();
});
it("renders Waitlisted for waitlisted status", () => {
render(<StatusBadge status="waitlisted" />);
expect(screen.getByText("Waitlisted")).toBeInTheDocument();
});
it("renders Completed for completed status", () => {
render(<StatusBadge status="completed" />);
expect(screen.getByText("Completed")).toBeInTheDocument();
});
it("renders Cancelled for cancelled status", () => {
render(<StatusBadge status="cancelled" />);
expect(screen.getByText("Cancelled")).toBeInTheDocument();
});
it("falls back to status string for unknown status", () => {
render(<StatusBadge status="custom-status" />);
expect(screen.getByText("custom-status")).toBeInTheDocument();
});
it("uses correct CSS class for confirmed status", () => {
render(<StatusBadge status="confirmed" />);
const badge = screen.getByText("Confirmed").closest('span');
expect(badge?.className).toContain("bg-green-100");
expect(badge?.className).toContain("text-green-700");
});
it("uses correct CSS class for waitlisted status", () => {
render(<StatusBadge status="waitlisted" />);
const badge = screen.getByText("Waitlisted").closest('span');
expect(badge?.className).toContain("bg-blue-100");
expect(badge?.className).toContain("text-blue-600");
});
it("uses correct CSS class for pending status", () => {
render(<StatusBadge status="pending" />);
const badge = screen.getByText("Pending").closest('span');
expect(badge?.className).toContain("bg-amber-100");
expect(badge?.className).toContain("text-amber-600");
});
it("uses fallback styling for unknown status", () => {
render(<StatusBadge status="unknown" />);
const badge = screen.getByText("unknown").closest('span');
expect(badge?.className).toContain("bg-stone-100");
expect(badge?.className).toContain("text-stone-600");
});
// GRO-2319 item 1: DB stores `no_show` (underscore) but the palette key is
// `no-show` (hyphen) — without normalization it rendered raw gray text.
it("renders the styled No-show badge for DB `no_show` status", () => {
render(<StatusBadge status="no_show" />);
const badge = screen.getByText("No-show").closest('span');
expect(badge?.className).toContain("bg-yellow-100");
expect(badge?.className).toContain("text-yellow-700");
});
});
describe("normalizeStatusKey (GRO-2319 item 1)", () => {
it("maps underscore status keys to the hyphen palette key", () => {
expect(normalizeStatusKey("no_show")).toBe("no-show");
});
it("leaves already-hyphenated / single-word keys unchanged", () => {
expect(normalizeStatusKey("no-show")).toBe("no-show");
expect(normalizeStatusKey("confirmed")).toBe("confirmed");
});
});
describe("deriveDisplayStatus (GRO-2319 item 2)", () => {
it("derives Pending for an upcoming, unconfirmed appointment", () => {
expect(
deriveDisplayStatus({ ...UPCOMING_APPT, status: "scheduled", confirmationStatus: "pending" }),
).toBe("pending");
});
it("keeps the raw status when the appointment is confirmed", () => {
expect(
deriveDisplayStatus({ ...UPCOMING_APPT, status: "confirmed", confirmationStatus: "confirmed" }),
).toBe("confirmed");
});
it("does not derive Pending for a past appointment", () => {
expect(
deriveDisplayStatus({ ...PAST_APPT, status: "completed", confirmationStatus: "pending" }),
).toBe("completed");
});
it("always shows Waitlisted for a waitlist-backed entry", () => {
expect(
deriveDisplayStatus({ ...UPCOMING_APPT, status: "waitlisted", confirmationStatus: undefined }),
).toBe("waitlisted");
});
});
describe("RescheduleFlow dynamic time slots", () => {
beforeEach(() => {
vi.clearAllMocks();
global.fetch = vi.fn();
});
const RESCHEDULE_APPT = {
id: "appt-r1",
petId: "pet-1",
petName: "Buddy",
groomerId: "groomer-1",
groomerName: "Sarah",
services: ["Bath & Brush"],
serviceId: "service-1",
addOns: [],
date: "2027-01-01",
time: "10:00 AM",
duration: 60,
price: 50,
status: "confirmed" as const,
notes: "",
customerNotes: "",
confirmationStatus: "confirmed" as const,
};
it("shows loading state while fetching availability", async () => {
vi.mocked(global.fetch).mockReturnValue(new Promise(() => {})); // Never resolves
const { RescheduleFlow } = await import("../portal/sections/Appointments.tsx");
render(<RescheduleFlow appointment={RESCHEDULE_APPT} onClose={() => {}} sessionId="test-session-id" />);
const dateInput = screen.getByLabelText(/date/i) || screen.getByRole("textbox", { name: /date/i });
fireEvent.change(dateInput, { target: { value: "2027-01-15" } });
await waitFor(() => {
expect(screen.getByText(/Checking availability/i)).toBeInTheDocument();
});
});
it("displays fetched time slots from API", async () => {
vi.mocked(global.fetch).mockResolvedValue({
ok: true,
json: async () => ["9:00 AM", "10:00 AM", "2:00 PM"],
} as Response);
const { RescheduleFlow } = await import("../portal/sections/Appointments.tsx");
render(<RescheduleFlow appointment={RESCHEDULE_APPT} onClose={() => {}} sessionId="test-session-id" />);
const dateInput = screen.getByLabelText(/date/i) || screen.getByRole("textbox", { name: /date/i });
fireEvent.change(dateInput, { target: { value: "2027-01-15" } });
await waitFor(() => {
expect(screen.getByText("9:00 AM")).toBeInTheDocument();
expect(screen.getByText("10:00 AM")).toBeInTheDocument();
expect(screen.getByText("2:00 PM")).toBeInTheDocument();
});
});
it("shows error state when availability fetch fails", async () => {
vi.mocked(global.fetch).mockRejectedValue(new Error("Network error"));
const { RescheduleFlow } = await import("../portal/sections/Appointments.tsx");
render(<RescheduleFlow appointment={RESCHEDULE_APPT} onClose={() => {}} sessionId="test-session-id" />);
const dateInput = screen.getByLabelText(/date/i) || screen.getByRole("textbox", { name: /date/i });
fireEvent.change(dateInput, { target: { value: "2027-01-15" } });
await waitFor(() => {
expect(screen.getByText(/Failed to load time slots/i)).toBeInTheDocument();
});
});
it("shows no slots message when API returns empty array", async () => {
vi.mocked(global.fetch).mockResolvedValue({
ok: true,
json: async () => [] as string[],
} as Response);
const { RescheduleFlow } = await import("../portal/sections/Appointments.tsx");
render(<RescheduleFlow appointment={RESCHEDULE_APPT} onClose={() => {}} sessionId="test-session-id" />);
const dateInput = screen.getByLabelText(/date/i) || screen.getByRole("textbox", { name: /date/i });
fireEvent.change(dateInput, { target: { value: "2027-01-15" } });
await waitFor(() => {
expect(screen.getByText(/No available slots on this date/i)).toBeInTheDocument();
});
});
it("calls /api/book/availability with the serviceId and selected date", async () => {
vi.mocked(global.fetch).mockResolvedValue({
ok: true,
json: async () => ["9:00 AM"] as string[],
} as Response);
const { RescheduleFlow } = await import("../portal/sections/Appointments.tsx");
render(<RescheduleFlow appointment={RESCHEDULE_APPT} onClose={() => {}} sessionId="test-session-id" />);
const dateInput = screen.getByLabelText(/date/i) || screen.getByRole("textbox", { name: /date/i });
fireEvent.change(dateInput, { target: { value: "2027-02-20" } });
await waitFor(() => {
expect(global.fetch).toHaveBeenCalledWith(
"/api/book/availability?serviceId=service-1&date=2027-02-20",
expect.objectContaining({
headers: expect.objectContaining({ "X-Impersonation-Session-Id": "test-session-id" }),
})
);
});
});
it("shows error message when API returns a 4xx error object instead of an array", async () => {
vi.mocked(global.fetch).mockResolvedValue({
ok: false,
status: 400,
json: async () => ({ error: "serviceId and date are required" }),
} as Response);
const { RescheduleFlow } = await import("../portal/sections/Appointments.tsx");
render(<RescheduleFlow appointment={RESCHEDULE_APPT} onClose={() => {}} sessionId="test-session-id" />);
const dateInput = screen.getByLabelText(/date/i) || screen.getByRole("textbox", { name: /date/i });
fireEvent.change(dateInput, { target: { value: "2027-02-20" } });
await waitFor(() => {
expect(screen.getByText(/serviceId and date are required/i)).toBeInTheDocument();
});
});
it("shows generic error when API returns 200 but body is not an array", async () => {
vi.mocked(global.fetch).mockResolvedValue({
ok: true,
json: async () => ({ error: "serviceId and date are required" }),
} as Response);
const { RescheduleFlow } = await import("../portal/sections/Appointments.tsx");
render(<RescheduleFlow appointment={RESCHEDULE_APPT} onClose={() => {}} sessionId="test-session-id" />);
const dateInput = screen.getByLabelText(/date/i) || screen.getByRole("textbox", { name: /date/i });
fireEvent.change(dateInput, { target: { value: "2027-02-20" } });
await waitFor(() => {
expect(screen.getByText(/Failed to load time slots/i)).toBeInTheDocument();
});
});
it("re-fetches slots when date changes", async () => {
vi.mocked(global.fetch)
.mockResolvedValueOnce({
ok: true,
json: async () => ["9:00 AM"] as string[],
} as Response)
.mockResolvedValueOnce({
ok: true,
json: async () => ["11:00 AM", "1:00 PM"] as string[],
} as Response);
const { RescheduleFlow } = await import("../portal/sections/Appointments.tsx");
render(<RescheduleFlow appointment={RESCHEDULE_APPT} onClose={() => {}} sessionId="test-session-id" />);
const dateInput = screen.getByLabelText(/date/i) || screen.getByRole("textbox", { name: /date/i });
fireEvent.change(dateInput, { target: { value: "2027-01-10" } });
await waitFor(() => expect(screen.getByText("9:00 AM")).toBeInTheDocument());
fireEvent.change(dateInput, { target: { value: "2027-01-15" } });
await waitFor(() => {
expect(screen.getByText("11:00 AM")).toBeInTheDocument();
expect(screen.getByText("1:00 PM")).toBeInTheDocument();
});
});
});
describe("slot helpers (GRO-2213)", () => {
it("formatSlotLabel formats a canonical UTC ISO slot to a UTC clock label", () => {
expect(formatSlotLabel("2026-06-09T10:00:00.000Z")).toBe("10:00 AM");
expect(formatSlotLabel("2026-06-09T14:30:00.000Z")).toBe("2:30 PM");
expect(formatSlotLabel("2026-06-09T09:00:00.000Z")).toBe("9:00 AM");
});
it("formatSlotLabel never echoes a raw ISO string", () => {
expect(formatSlotLabel("2026-06-09T10:00:00.000Z")).not.toMatch(/\d{4}-\d{2}-\d{2}T/);
});
it("formatSlotLabel passes through an already-formatted label unchanged", () => {
expect(formatSlotLabel("10:00 AM")).toBe("10:00 AM");
});
it("slotToTime extracts the UTC HH:MM:SS time component from an ISO slot", () => {
expect(slotToTime("2026-06-09T10:00:00.000Z")).toBe("10:00:00");
expect(slotToTime("2026-06-09T14:30:00.000Z")).toBe("14:30:00");
expect(slotToTime("2026-06-09T10:00:00.000Z")).toMatch(/^\d{2}:\d{2}:\d{2}$/);
});
it("slotToTime guards a value that is already HH:MM:SS", () => {
expect(slotToTime("10:00:00")).toBe("10:00:00");
});
it("slotToTime converts a 12-hour label fallback to HH:MM:SS", () => {
expect(slotToTime("9:00 AM")).toBe("09:00:00");
expect(slotToTime("2:30 PM")).toBe("14:30:00");
});
});
describe("BookingFlow Book New funnel (GRO-2213)", () => {
beforeEach(() => {
vi.clearAllMocks();
global.fetch = vi.fn();
});
function routedFetch(captured: { waitlistBody?: Record<string, unknown> }) {
return (input: RequestInfo | URL, init?: RequestInit) => {
const url = typeof input === "string" ? input : input.toString();
if (url.includes("/api/portal/pets")) {
return Promise.resolve({
ok: true,
json: async () => ({ pets: [{ id: "pet-1", name: "Buddy", breed: "Lab" }] }),
} as Response);
}
if (url.includes("/api/portal/services")) {
return Promise.resolve({
ok: true,
json: async () => ({
services: [{ id: "service-1", name: "Bath & Brush", isAddOn: false, duration: 60, price: 50 }],
}),
} as Response);
}
if (url.includes("/api/book/availability")) {
return Promise.resolve({
ok: true,
json: async () => ["2026-06-09T10:00:00.000Z", "2026-06-09T14:30:00.000Z"],
} as Response);
}
if (url.includes("/api/portal/waitlist")) {
captured.waitlistBody = JSON.parse((init?.body as string) ?? "{}");
return Promise.resolve({ ok: true, json: async () => ({}) } as Response);
}
return Promise.resolve({ ok: true, json: async () => ({}) } as Response);
};
}
it("renders formatted slot labels (not raw ISO) and submits preferredTime as HH:MM:SS", async () => {
const captured: { waitlistBody?: Record<string, unknown> } = {};
vi.mocked(global.fetch).mockImplementation(routedFetch(captured) as typeof fetch);
render(<BookingFlow onClose={() => {}} sessionId="test-session-id" />);
// Step 1 — pick pet (auto-advances to step 2)
await waitFor(() => expect(screen.getByText("Buddy")).toBeInTheDocument());
fireEvent.click(screen.getByText("Buddy"));
// Step 2 — pick service, then Next
await waitFor(() => expect(screen.getByText("Bath & Brush")).toBeInTheDocument());
fireEvent.click(screen.getByText("Bath & Brush"));
fireEvent.click(screen.getByRole("button", { name: /^Next$/ }));
// Step 3 — groomer, Next
await waitFor(() => expect(screen.getByText("First Available")).toBeInTheDocument());
fireEvent.click(screen.getByRole("button", { name: /^Next$/ }));
// Step 4 — date + slot
await waitFor(() => expect(screen.getByLabelText(/date/i)).toBeInTheDocument());
fireEvent.change(screen.getByLabelText(/date/i), { target: { value: "2026-06-09" } });
// Slot button shows the formatted UTC label, never the raw ISO
await waitFor(() => expect(screen.getByText("10:00 AM")).toBeInTheDocument());
expect(screen.queryByText(/2026-06-09T10:00:00/)).not.toBeInTheDocument();
fireEvent.click(screen.getByText("10:00 AM"));
fireEvent.click(screen.getByRole("button", { name: /^Next$/ }));
// Step 5 — review shows the formatted label
await waitFor(() => expect(screen.getByText(/Review & Confirm/i)).toBeInTheDocument());
expect(screen.getByText(/10:00 AM/)).toBeInTheDocument();
fireEvent.click(screen.getByRole("button", { name: /Confirm Booking/i }));
await waitFor(() => expect(captured.waitlistBody).toBeDefined());
const body = captured.waitlistBody ?? {};
expect(body.preferredTime).toMatch(/^\d{2}:\d{2}:\d{2}$/);
expect(body.preferredTime).toBe("10:00:00");
expect(body.preferredDate).toBe("2026-06-09");
});
it("re-mints the portal session and retries once when waitlist returns 401 (GRO-2234)", async () => {
const calls = { waitlist: 0, remint: 0 };
const waitlistHeaders: string[] = [];
const routed = (input: RequestInfo | URL, init?: RequestInit) => {
const url = typeof input === "string" ? input : input.toString();
if (url.includes("/api/portal/pets")) {
return Promise.resolve({
ok: true,
json: async () => ({ pets: [{ id: "pet-1", name: "Buddy", breed: "Lab" }] }),
} as Response);
}
if (url.includes("/api/portal/services")) {
return Promise.resolve({
ok: true,
json: async () => ({
services: [{ id: "service-1", name: "Bath & Brush", isAddOn: false, duration: 60, price: 50 }],
}),
} as Response);
}
if (url.includes("/api/book/availability")) {
return Promise.resolve({
ok: true,
json: async () => ["2026-06-09T10:00:00.000Z"],
} as Response);
}
if (url.includes("/api/portal/session-from-auth")) {
calls.remint += 1;
return Promise.resolve({
ok: true,
json: async () => ({ sessionId: "fresh-session-id", clientId: "c1", clientName: "Jane" }),
} as Response);
}
if (url.includes("/api/portal/waitlist")) {
calls.waitlist += 1;
const headers = (init?.headers ?? {}) as Record<string, string>;
waitlistHeaders.push(headers["X-Impersonation-Session-Id"] ?? "");
// First attempt: session lapsed → 401. Retry after re-mint: success.
if (calls.waitlist === 1) {
return Promise.resolve({ ok: false, status: 401, json: async () => ({ error: "Unauthorized" }) } as Response);
}
return Promise.resolve({ ok: true, status: 201, json: async () => ({}) } as Response);
}
return Promise.resolve({ ok: true, json: async () => ({}) } as Response);
};
global.fetch = vi.fn().mockImplementation(routed as typeof fetch);
render(<BookingFlow onClose={() => {}} sessionId="stale-session-id" />);
await waitFor(() => expect(screen.getByText("Buddy")).toBeInTheDocument());
fireEvent.click(screen.getByText("Buddy"));
await waitFor(() => expect(screen.getByText("Bath & Brush")).toBeInTheDocument());
fireEvent.click(screen.getByText("Bath & Brush"));
fireEvent.click(screen.getByRole("button", { name: /^Next$/ }));
await waitFor(() => expect(screen.getByText("First Available")).toBeInTheDocument());
fireEvent.click(screen.getByRole("button", { name: /^Next$/ }));
await waitFor(() => expect(screen.getByLabelText(/date/i)).toBeInTheDocument());
fireEvent.change(screen.getByLabelText(/date/i), { target: { value: "2026-06-09" } });
await waitFor(() => expect(screen.getByText("10:00 AM")).toBeInTheDocument());
fireEvent.click(screen.getByText("10:00 AM"));
fireEvent.click(screen.getByRole("button", { name: /^Next$/ }));
await waitFor(() => expect(screen.getByText(/Review & Confirm/i)).toBeInTheDocument());
fireEvent.click(screen.getByRole("button", { name: /Confirm Booking/i }));
// Re-mint happened exactly once, waitlist retried with the fresh id, and the
// booking succeeded (no error surfaced).
await waitFor(() => expect(calls.waitlist).toBe(2));
expect(calls.remint).toBe(1);
expect(waitlistHeaders).toEqual(["stale-session-id", "fresh-session-id"]);
expect(screen.queryByText(/Failed to book appointment/i)).not.toBeInTheDocument();
});
});
describe("normalizeService", () => {
it("maps API basePriceCents/durationMinutes to price (dollars)/duration", () => {
const svc = normalizeService({
id: "svc-1",
name: "Full Groom",
basePriceCents: 4500,
durationMinutes: 60,
});
expect(svc.price).toBe(45);
expect(svc.duration).toBe(60);
});
it("preserves an already-normalized payload (price/duration)", () => {
const svc = normalizeService({
id: "svc-2",
name: "Bath",
price: 30,
duration: 30,
});
expect(svc.price).toBe(30);
expect(svc.duration).toBe(30);
});
it("leaves price/duration undefined when both source shapes are absent", () => {
const svc = normalizeService({ id: "svc-3", name: "Mystery" });
expect(svc.price).toBeUndefined();
expect(svc.duration).toBeUndefined();
});
it("coerces null fields to undefined", () => {
const svc = normalizeService({
id: "svc-4",
name: "Nail Trim",
basePriceCents: null,
durationMinutes: null,
description: null,
});
expect(svc.price).toBeUndefined();
expect(svc.duration).toBeUndefined();
expect(svc.description).toBeUndefined();
});
});
describe("formatServicePrice", () => {
it("prefers an explicit priceRange string", () => {
expect(formatServicePrice({ priceRange: "$40$60", price: 45 })).toBe("$40$60");
});
it("formats integer dollars without trailing zeros", () => {
expect(formatServicePrice({ price: 45 })).toBe("$45");
});
it("formats fractional dollars to cents", () => {
expect(formatServicePrice({ price: 45.5 })).toBe("$45.50");
});
it("returns null when no price is available (never '$undefined')", () => {
expect(formatServicePrice({})).toBeNull();
expect(formatServicePrice({ price: undefined })).toBeNull();
});
});
+27
View File
@@ -0,0 +1,27 @@
import { describe, it, expect } from "vitest";
import { render, screen } from "@testing-library/react";
import { BookingCancelledPage } from "../pages/BookingCancelled.tsx";
describe("BookingCancelledPage", () => {
it("renders the cancelled heading", () => {
render(<BookingCancelledPage />);
expect(screen.getByRole("heading", { name: /Appointment Cancelled/i })).toBeInTheDocument();
});
it("renders the cancelled body text", () => {
render(<BookingCancelledPage />);
expect(screen.getByText(/Your appointment has been cancelled/i)).toBeInTheDocument();
});
it("has a Book again link pointing to /admin/book", () => {
render(<BookingCancelledPage />);
const link = screen.getByRole("link", { name: /Book again/i });
expect(link).toHaveAttribute("href", "/admin/book");
});
it("has a Back to Portal link pointing to /", () => {
render(<BookingCancelledPage />);
const link = screen.getByRole("link", { name: /Back to Portal/i });
expect(link).toHaveAttribute("href", "/");
});
});
+38
View File
@@ -0,0 +1,38 @@
import { describe, it, expect } from "vitest";
import { render, screen } from "@testing-library/react";
import { BookingErrorPage } from "../pages/BookingError.tsx";
import { BUSINESS_CONTACT_INFO } from "../lib/contact.ts";
describe("BookingErrorPage", () => {
it("renders the error heading", () => {
render(<BookingErrorPage />);
expect(screen.getByRole("heading", { name: /Link Invalid or Expired/i })).toBeInTheDocument();
});
it("renders the error body text", () => {
render(<BookingErrorPage />);
expect(screen.getByText(/This confirmation link is invalid/i)).toBeInTheDocument();
});
it("has a Start a new booking link pointing to /admin/book", () => {
render(<BookingErrorPage />);
const link = screen.getByRole("link", { name: /Start a new booking/i });
expect(link).toHaveAttribute("href", "/admin/book");
});
it("has a Back to Portal link pointing to /", () => {
render(<BookingErrorPage />);
const link = screen.getByRole("link", { name: /Back to Portal/i });
expect(link).toHaveAttribute("href", "/");
});
it("displays business contact phone", () => {
render(<BookingErrorPage />);
expect(screen.getByText(new RegExp(BUSINESS_CONTACT_INFO.phone.replace(/[()]/g, "\\$&")))).toBeInTheDocument();
});
it("displays business contact email", () => {
render(<BookingErrorPage />);
expect(screen.getByText(new RegExp(BUSINESS_CONTACT_INFO.email))).toBeInTheDocument();
});
});
+54
View File
@@ -0,0 +1,54 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { render, screen, cleanup } from "@testing-library/react";
import { ErrorBoundary } from "../ErrorBoundary";
function ThrowingChild(): never {
throw new Error("synthetic render-time failure for GRO-2094");
}
function GoodChild() {
return <div data-testid="good-child">ok</div>;
}
describe("ErrorBoundary (GRO-2094)", () => {
let errorSpy: ReturnType<typeof vi.spyOn>;
beforeEach(() => {
// React 18+ logs caught render errors to console.error via React's own
// instrumentation; suppress it so test output is clean but capture it
// for an assertion below.
errorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
});
afterEach(() => {
errorSpy.mockRestore();
cleanup();
});
it("renders children when nothing throws", () => {
render(
<ErrorBoundary>
<GoodChild />
</ErrorBoundary>
);
expect(screen.getByTestId("good-child")).toBeInTheDocument();
expect(screen.queryByTestId("error-boundary")).not.toBeInTheDocument();
});
it("renders the error visibly when a child throws during render", () => {
render(
<ErrorBoundary>
<ThrowingChild />
</ErrorBoundary>
);
const fallback = screen.getByTestId("error-boundary");
expect(fallback).toBeInTheDocument();
const message = screen.getByTestId("error-boundary-message");
// The actual exception is shown — no more silent blank root.
expect(message.textContent).toContain("synthetic render-time failure for GRO-2094");
// The boundary also calls console.error so it shows up in the Playwright
// console log even if the DOM-rendered fallback is somehow missed.
expect(errorSpy).toHaveBeenCalled();
});
});
+9 -1
View File
@@ -111,7 +111,7 @@ describe("PetForm", () => {
render(<PetForm pet={petWithAlert} onSave={onSave} onCancel={onCancel} />);
const removeButtons = screen.getAllByRole("button", { name: "" });
if (removeButtons.length === 0) return;
const removeButton = removeButtons[0];
const removeButton = removeButtons[0]!;
if (!removeButton) return;
fireEvent.click(removeButton);
expect(screen.queryByText("Allergic to chicken")).toBeNull();
@@ -154,4 +154,12 @@ describe("PetForm", () => {
expect(screen.getByText("Anxious")).toBeTruthy();
expect(screen.getByText("Good with kids")).toBeTruthy();
});
// ── Weight pre-fill from portal `weight` key (GRO-2207) ───────────────────────
it("pre-fills weight from the portal `weight` key when weightKg is absent", () => {
const portalPet: Pet = { ...BASE_PET, weightKg: null, weight: "12.50" };
render(<PetForm pet={portalPet} onSave={onSave} onCancel={onCancel} />);
expect(screen.getByDisplayValue(12.5)).toBeTruthy();
});
});
+80
View File
@@ -0,0 +1,80 @@
import { describe, it, expect } from "vitest";
import { render, screen } from "@testing-library/react";
import { BasicInfoTab, formatSizeCategory } from "../portal/sections/PetProfiles.js";
import type { Pet } from "@groombook/types";
// The portal endpoint (GET /api/portal/pets) serializes DB columns under
// portal-shaped keys: weightKg→weight, dateOfBirth→birthDate. The read view
// must surface those keys (GRO-2207), not the raw staff-side weightKg/dateOfBirth.
const PORTAL_PET: Pet = {
id: "pet-1",
clientId: "client-1",
name: "Pup Alpha",
species: "dog",
breed: "Poodle",
// Staff-shaped keys intentionally null — only the portal keys are populated,
// proving the read view reads `weight`/`birthDate`.
weightKg: null,
dateOfBirth: null,
weight: "12.50",
birthDate: "2022-03-10T00:00:00.000Z",
petSizeCategory: "extra_large",
healthAlerts: null,
groomingNotes: null,
cutStyle: null,
shampooPreference: null,
specialCareNotes: null,
customFields: {},
coatType: null,
preferredCuts: [],
medicalAlerts: [],
createdAt: "2024-01-01T00:00:00.000Z",
updatedAt: "2024-01-01T00:00:00.000Z",
};
describe("BasicInfoTab read view (GRO-2207)", () => {
it("renders Weight from the portal `weight` key", () => {
render(<BasicInfoTab pet={PORTAL_PET} readOnly />);
expect(screen.getByText("12.50 kg")).toBeInTheDocument();
});
it("renders Date of Birth from the portal `birthDate` key", () => {
render(<BasicInfoTab pet={PORTAL_PET} readOnly />);
expect(screen.getByText("March 10, 2022")).toBeInTheDocument();
});
it("renders Size Category formatted from petSizeCategory", () => {
render(<BasicInfoTab pet={PORTAL_PET} readOnly />);
expect(screen.getByText("Size Category")).toBeInTheDocument();
expect(screen.getByText("Extra Large")).toBeInTheDocument();
});
it("falls back to staff-shaped keys when portal keys are absent", () => {
const staffShaped: Pet = { ...PORTAL_PET, weight: null, birthDate: null, weightKg: 25, dateOfBirth: "2020-01-05T00:00:00.000Z" };
render(<BasicInfoTab pet={staffShaped} readOnly />);
expect(screen.getByText("25 kg")).toBeInTheDocument();
expect(screen.getByText("January 5, 2020")).toBeInTheDocument();
});
it("shows Unknown for missing weight/DoB and size", () => {
const empty: Pet = { ...PORTAL_PET, weight: null, birthDate: null, weightKg: null, dateOfBirth: null, petSizeCategory: null };
render(<BasicInfoTab pet={empty} readOnly />);
// Weight, Date of Birth and Size Category rows all read "Unknown".
expect(screen.getAllByText("Unknown").length).toBeGreaterThanOrEqual(3);
});
});
describe("formatSizeCategory", () => {
it("title-cases each underscore-separated segment", () => {
expect(formatSizeCategory("extra_large")).toBe("Extra Large");
expect(formatSizeCategory("small")).toBe("Small");
expect(formatSizeCategory("medium")).toBe("Medium");
expect(formatSizeCategory("large")).toBe("Large");
});
it("returns Unknown for null/undefined/empty", () => {
expect(formatSizeCategory(null)).toBe("Unknown");
expect(formatSizeCategory(undefined)).toBe("Unknown");
expect(formatSizeCategory("")).toBe("Unknown");
});
});
+211
View File
@@ -0,0 +1,211 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { render, screen, waitFor, fireEvent } from "@testing-library/react";
import { RoutesPage } from "../pages/Routes.tsx";
// Leaflet does not render in jsdom — replace the lazily-loaded map with a stub
// that just reports the stop count so we can assert it received the route data.
vi.mock("../components/RouteMap.js", () => ({
default: ({ stops }: { stops: unknown[] }) => (
<div data-testid="route-map">map:{stops.length}</div>
),
}));
const MANAGER = { id: "m1", name: "Manager", role: "manager", active: true };
const GROOMER = { id: "g1", name: "Sam Groomer", role: "groomer", active: true };
const ROUTE_RESPONSE = {
route: {
id: "r1",
staffId: "g1",
routeDate: "2026-06-09",
status: "optimized",
totalTravelMins: 95,
totalDistanceKm: "42.50",
},
stops: [
{
id: "s1",
appointmentId: "a1",
stopOrder: 1,
latitude: 51.5,
longitude: -0.1,
travelMinsFromPrev: null,
travelDistanceKmFromPrev: null,
bufferMins: 15,
appointmentStartTime: "2026-06-09T09:00:00.000Z",
appointmentEndTime: "2026-06-09T10:00:00.000Z",
appointmentStatus: "confirmed",
clientId: "c1",
clientName: "Alice",
clientAddress: "1 High St",
conflict: { hasConflict: false },
},
{
id: "s2",
appointmentId: "a2",
stopOrder: 2,
latitude: 51.52,
longitude: -0.12,
travelMinsFromPrev: 20,
travelDistanceKmFromPrev: "8.00",
bufferMins: 15,
appointmentStartTime: "2026-06-09T11:00:00.000Z",
appointmentEndTime: "2026-06-09T12:00:00.000Z",
appointmentStatus: "confirmed",
clientId: "c2",
clientName: "Bob",
clientAddress: "2 Low St",
conflict: { hasConflict: true },
},
],
hasConflicts: true,
conflictCount: 1,
};
function mockFetch(meRole: "manager" | "groomer") {
return vi.fn((url: string, opts?: RequestInit) => {
if (url === "/api/staff/me") {
return Promise.resolve({
ok: true,
json: () => Promise.resolve(meRole === "manager" ? MANAGER : GROOMER),
} as Response);
}
if (url === "/api/staff") {
return Promise.resolve({ ok: true, json: () => Promise.resolve([MANAGER, GROOMER]) } as Response);
}
if (url.startsWith("/api/routes/daily")) {
return Promise.resolve({ ok: true, json: () => Promise.resolve(ROUTE_RESPONSE) } as Response);
}
if (url === "/api/routes/optimize" && opts?.method === "POST") {
return Promise.resolve({ ok: true, json: () => Promise.resolve(ROUTE_RESPONSE) } as Response);
}
if (/^\/api\/routes\/[^/]+\/reorder$/.test(url) && opts?.method === "PATCH") {
return Promise.resolve({ ok: true, json: () => Promise.resolve(ROUTE_RESPONSE) } as Response);
}
if (/^\/api\/routes\/[^/]+\/export\/google-maps$/.test(url)) {
return Promise.resolve({
ok: true,
json: () => Promise.resolve({ platform: "google-maps", url: "https://www.google.com/maps/dir/?api=1", stopCount: 2, waypointCount: 0 }),
} as Response);
}
if (/^\/api\/routes\/[^/]+\/export\/apple-maps$/.test(url)) {
return Promise.resolve({
ok: true,
json: () => Promise.resolve({ platform: "apple-maps", url: "maps://?saddr=51.5,-0.1&daddr=51.52,-0.12", stopCount: 2, waypointCount: 0 }),
} as Response);
}
return Promise.resolve({ ok: true, json: () => Promise.resolve({}) } as Response);
});
}
beforeEach(() => {
vi.restoreAllMocks();
});
describe("RoutesPage", () => {
it("renders stop cards, summary, status badge and map for a manager", async () => {
global.fetch = mockFetch("manager") as unknown as typeof fetch;
render(<RoutesPage />);
await waitFor(() => expect(screen.getByText("Alice")).toBeInTheDocument());
expect(screen.getByText("Bob")).toBeInTheDocument();
expect(screen.getByText("1 High St")).toBeInTheDocument();
// Summary: travel time formatted, distance shown
expect(screen.getByText("1 h 35 min")).toBeInTheDocument();
expect(screen.getByText("42.50 km")).toBeInTheDocument();
// Status badge
expect(screen.getByText("Optimized")).toBeInTheDocument();
// First stop is start-of-route; second shows travel from previous
expect(screen.getByText("Start of route")).toBeInTheDocument();
expect(screen.getByText("20 min travel from previous")).toBeInTheDocument();
// Map received both stops (lazy chunk resolves asynchronously)
expect(await screen.findByTestId("route-map")).toHaveTextContent("map:2");
});
it("shows the groomer selector for managers", async () => {
global.fetch = mockFetch("manager") as unknown as typeof fetch;
render(<RoutesPage />);
await waitFor(() => expect(screen.getByText("Groomer")).toBeInTheDocument());
});
it("hides the groomer selector for groomer role (auto-filtered)", async () => {
global.fetch = mockFetch("groomer") as unknown as typeof fetch;
render(<RoutesPage />);
await waitFor(() => expect(screen.getByText("Alice")).toBeInTheDocument());
expect(screen.queryByText("Groomer")).not.toBeInTheDocument();
});
it("renders a drag handle for each stop (drag-to-reorder enabled)", async () => {
global.fetch = mockFetch("manager") as unknown as typeof fetch;
render(<RoutesPage />);
await waitFor(() => expect(screen.getByText("Alice")).toBeInTheDocument());
expect(screen.getByLabelText("Drag to reorder Alice")).toBeInTheDocument();
expect(screen.getByLabelText("Drag to reorder Bob")).toBeInTheDocument();
});
it("flags the tight-schedule conflict on the affected stop", async () => {
global.fetch = mockFetch("manager") as unknown as typeof fetch;
render(<RoutesPage />);
await waitFor(() => expect(screen.getByText("Bob")).toBeInTheDocument());
expect(
screen.getByText(/Tight schedule — travel \+ buffer may exceed the gap/)
).toBeInTheDocument();
});
it("does not show the re-optimize hint before any manual reorder", async () => {
global.fetch = mockFetch("manager") as unknown as typeof fetch;
render(<RoutesPage />);
await waitFor(() => expect(screen.getByText("Alice")).toBeInTheDocument());
expect(screen.queryByText("Re-optimize")).not.toBeInTheDocument();
});
it("calls the optimize endpoint when Optimize is clicked", async () => {
const fetchMock = mockFetch("manager");
global.fetch = fetchMock as unknown as typeof fetch;
render(<RoutesPage />);
await waitFor(() => expect(screen.getByText("Alice")).toBeInTheDocument());
fireEvent.click(screen.getByText("Optimize"));
await waitFor(() =>
expect(fetchMock).toHaveBeenCalledWith(
"/api/routes/optimize",
expect.objectContaining({ method: "POST" })
)
);
});
it("renders both navigation export buttons when the route has stops", async () => {
global.fetch = mockFetch("manager") as unknown as typeof fetch;
render(<RoutesPage />);
await waitFor(() => expect(screen.getByText("Alice")).toBeInTheDocument());
expect(screen.getByRole("button", { name: "Open in Google Maps" })).toBeInTheDocument();
expect(screen.getByRole("button", { name: "Open in Apple Maps" })).toBeInTheDocument();
});
it("fetches the export deep link and opens it when Open in Google Maps is clicked", async () => {
const fetchMock = mockFetch("manager");
global.fetch = fetchMock as unknown as typeof fetch;
const openSpy = vi
.spyOn(window, "open")
.mockReturnValue({ location: { href: "" }, close: vi.fn() } as unknown as Window);
render(<RoutesPage />);
await waitFor(() => expect(screen.getByText("Alice")).toBeInTheDocument());
fireEvent.click(screen.getByRole("button", { name: "Open in Google Maps" }));
await waitFor(() =>
expect(fetchMock).toHaveBeenCalledWith("/api/routes/r1/export/google-maps")
);
expect(openSpy).toHaveBeenCalled();
});
});
+83
View File
@@ -0,0 +1,83 @@
import { describe, it, expect, vi } from "vitest";
import { ANALYTICS_EVENTS, fireAnalyticsEvent } from "../lib/analytics";
describe("analytics", () => {
describe("ANALYTICS_EVENTS constants", () => {
it("exports all required event names", () => {
expect(ANALYTICS_EVENTS.BOOKING_STEP_SERVICE).toBe("booking_step_service");
expect(ANALYTICS_EVENTS.BOOKING_STEP_TIME).toBe("booking_step_time");
expect(ANALYTICS_EVENTS.BOOKING_STEP_CONTACT).toBe("booking_step_contact");
expect(ANALYTICS_EVENTS.BOOKING_STEP_SUBMIT).toBe("booking_step_submit");
expect(ANALYTICS_EVENTS.BOOKING_CONFIRMED).toBe("booking_confirmed");
expect(ANALYTICS_EVENTS.BOOKING_ERROR).toBe("booking_error");
});
it("has no duplicate event names", () => {
const values = Object.values(ANALYTICS_EVENTS);
const unique = new Set(values);
expect(unique.size).toBe(values.length);
});
});
describe("fireAnalyticsEvent", () => {
it("dispatches a CustomEvent with the correct event name", () => {
const listener = vi.fn();
window.addEventListener(ANALYTICS_EVENTS.BOOKING_STEP_SERVICE, listener);
fireAnalyticsEvent(ANALYTICS_EVENTS.BOOKING_STEP_SERVICE, { step: "service", flow: "public" });
expect(listener).toHaveBeenCalledTimes(1);
const event = listener.mock.calls[0]![0] as CustomEvent;
expect(event.type).toBe("booking_step_service");
expect(event.detail.step).toBe("service");
expect(event.detail.flow).toBe("public");
expect(event.detail.timestamp).toBeDefined();
window.removeEventListener(ANALYTICS_EVENTS.BOOKING_STEP_SERVICE, listener);
});
it("includes a timestamp in the event detail", () => {
const listener = vi.fn();
window.addEventListener(ANALYTICS_EVENTS.BOOKING_CONFIRMED, listener);
fireAnalyticsEvent(ANALYTICS_EVENTS.BOOKING_CONFIRMED, { step: "confirmed", flow: "public" });
const event = listener.mock.calls[0]![0] as CustomEvent;
expect(event.detail.timestamp).toBeTruthy();
expect(new Date(event.detail.timestamp as string)).toBeInstanceOf(Date);
window.removeEventListener(ANALYTICS_EVENTS.BOOKING_CONFIRMED, listener);
});
it("does not throw when called with no payload", () => {
expect(() => {
fireAnalyticsEvent(ANALYTICS_EVENTS.BOOKING_ERROR, {});
}).not.toThrow();
});
it("does not throw when window.dispatchEvent throws", () => {
const original = window.dispatchEvent;
window.dispatchEvent = () => {
throw new Error("analytics blocked");
};
expect(() => {
fireAnalyticsEvent(ANALYTICS_EVENTS.BOOKING_STEP_SUBMIT, { step: "submit", flow: "public" });
}).not.toThrow();
window.dispatchEvent = original;
});
it("fires events for all event types", () => {
const events = Object.values(ANALYTICS_EVENTS);
for (const eventName of events) {
const listener = vi.fn();
window.addEventListener(eventName, listener);
fireAnalyticsEvent(eventName as typeof events[number], { step: "test", flow: "public" });
expect(listener).toHaveBeenCalledTimes(1);
window.removeEventListener(eventName, listener);
}
});
it("does not include PII in payload", () => {
// Payload only contains step, flow, and timestamp — no names, emails, or phones
const payload = { step: "contact", flow: "public" };
const keys = Object.keys(payload);
const piish = ["name", "email", "phone", "clientName", "clientEmail", "clientPhone", "petName"];
const hasPII = piish.some((k) => keys.includes(k));
expect(hasPII).toBe(false);
});
});
});
+440
View File
@@ -5,6 +5,34 @@ import { ImpersonationBanner } from "../portal/ImpersonationBanner.js";
import { AuditLogViewer } from "../portal/AuditLogViewer.js";
import type { ImpersonationSession, ImpersonationAuditLog } from "@groombook/types";
// Spy on the RescheduleFlow so we can assert the sessionId prop it receives
// from CustomerPortal without rendering the full flow UI. The real module is
// still loaded via importActual; only RescheduleFlow is swapped.
const rescheduleFlowSpy = vi.hoisted(() =>
vi.fn((_props: { sessionId: string | null; appointment: { id: string } }) => null)
);
vi.mock("../portal/sections/Appointments.js", async () => {
const actual = await vi.importActual<typeof import("../portal/sections/Appointments.js")>(
"../portal/sections/Appointments.js"
);
return {
...actual,
RescheduleFlow: rescheduleFlowSpy,
};
});
// Spy on the canonical `signOut()` from the shared auth-client so we can
// assert the no-access screen's logout button uses the SAME handler as
// `AdminLayout`. We mock at the module boundary — the no-access screen is
// the one authenticated surface that renders without the portal chrome, so
// a regression here would trap the user. We do NOT use `importActual`
// because the real `createAuthClient()` requires a runtime `baseURL`
// (Better Auth) that the JSDOM test environment can't supply.
const signOutSpy = vi.hoisted(() => vi.fn(async () => undefined));
vi.mock("../lib/auth-client.js", () => ({
signOut: signOutSpy,
}));
const SESSION: ImpersonationSession = {
id: "sess-1",
staffId: "staff-1",
@@ -313,3 +341,415 @@ describe("CustomerPortal session loading", () => {
Object.defineProperty(window, "location", { value: originalLocation, writable: true });
});
});
// ─── CustomerPortal — Better Auth SSO bridge (GRO-1867) ────────────────────
describe("CustomerPortal SSO bridge", () => {
beforeEach(() => {
// Make sure no dev-user leaks across tests
window.localStorage.clear();
// Reset shared signOut() spy so per-test counts are deterministic
signOutSpy.mockClear();
});
const brandingResponse = {
ok: true,
json: async () => ({
businessName: "GroomBook",
primaryColor: "#4f8a6f",
accentColor: "#8b7355",
logoBase64: null,
logoMimeType: null,
}),
} as Response;
it("bridges Better Auth session via /api/portal/session-from-auth and uses returned sessionId", async () => {
global.fetch = vi.fn((input: RequestInfo, init?: RequestInit) => {
const url = typeof input === "string" ? input : input.toString();
if (url === "/api/branding") return Promise.resolve(brandingResponse);
if (url === "/api/auth/get-session") {
return Promise.resolve({
ok: true,
json: async () => ({ user: { email: "customer@example.com", role: "customer" } }),
} as Response);
}
if (url === "/api/portal/session-from-auth" && init?.method === "POST") {
return Promise.resolve({
ok: true,
status: 201,
json: async () => ({ sessionId: "sso-sess-1", clientId: "client-1", clientName: "Jane Doe" }),
} as Response);
}
// Subsequent portal API calls — surface them so we can assert the header
return Promise.resolve({ ok: true, json: async () => ({}) } as Response);
}) as unknown as typeof fetch;
const { CustomerPortal } = await import("../portal/CustomerPortal.js");
render(
<MemoryRouter initialEntries={["/"]}>
<CustomerPortal />
</MemoryRouter>
);
await waitFor(() => {
expect(global.fetch).toHaveBeenCalledWith("/api/auth/get-session", expect.objectContaining({ credentials: "include" }));
});
await waitFor(() => {
expect(global.fetch).toHaveBeenCalledWith(
"/api/portal/session-from-auth",
expect.objectContaining({ method: "POST", credentials: "include" })
);
});
// Client greeting reflects the bridged customer name (proof the response was consumed)
await waitFor(() => {
expect(screen.getByText(/Hi, Jane/)).toBeInTheDocument();
});
// The impersonation banner must NOT appear — this is the customer themselves
expect(screen.queryByRole("button", { name: /End Session/i })).not.toBeInTheDocument();
});
it("shows a friendly fallback when session-from-auth returns 404 (no client record)", async () => {
global.fetch = vi.fn((input: RequestInfo) => {
const url = typeof input === "string" ? input : input.toString();
if (url === "/api/branding") return Promise.resolve(brandingResponse);
if (url === "/api/auth/get-session") {
return Promise.resolve({
ok: true,
json: async () => ({ user: { email: "stranger@example.com", role: "customer" } }),
} as Response);
}
if (url === "/api/portal/session-from-auth") {
return Promise.resolve({
ok: false,
status: 404,
json: async () => ({ error: "No client record found for this user" }),
} as Response);
}
return Promise.resolve({ ok: true, json: async () => ({}) } as Response);
}) as unknown as typeof fetch;
const { CustomerPortal } = await import("../portal/CustomerPortal.js");
render(
<MemoryRouter initialEntries={["/"]}>
<CustomerPortal />
</MemoryRouter>
);
await waitFor(() => {
expect(screen.getByText(/Portal access not configured/i)).toBeInTheDocument();
});
expect(screen.getByText(/not linked to a customer record/i)).toBeInTheDocument();
// Sign-out escape hatch is present so the user is not stuck in a loop
expect(screen.getByRole("button", { name: /Sign out/i })).toBeInTheDocument();
});
it("calls the shared signOut() handler and navigates to /login from the no-access screen (GRO-2358)", async () => {
// Reset the spy so previous tests don't leak into this assertion.
signOutSpy.mockClear();
// JSDOM throws on window.location.href assignment by default; swap in a
// writable stub so the navigation is observable, then restore after.
const originalLocation = window.location;
Object.defineProperty(window, "location", {
value: { href: "" },
writable: true,
configurable: true,
});
global.fetch = vi.fn((input: RequestInfo) => {
const url = typeof input === "string" ? input : input.toString();
if (url === "/api/branding") return Promise.resolve(brandingResponse);
if (url === "/api/auth/get-session") {
return Promise.resolve({
ok: true,
json: async () => ({ user: { email: "stranger@example.com", role: "customer" } }),
} as Response);
}
if (url === "/api/portal/session-from-auth") {
return Promise.resolve({
ok: false,
status: 404,
json: async () => ({ error: "No client record found for this user" }),
} as Response);
}
return Promise.resolve({ ok: true, json: async () => ({}) } as Response);
}) as unknown as typeof fetch;
const { CustomerPortal } = await import("../portal/CustomerPortal.js");
render(
<MemoryRouter initialEntries={["/"]}>
<CustomerPortal />
</MemoryRouter>
);
await waitFor(() => {
expect(screen.getByText(/Portal access not configured/i)).toBeInTheDocument();
});
// Pre-condition: the shared signOut() must NOT have been called yet — the
// no-access screen is mounted because the bridge failed, not because the
// user clicked anything.
expect(signOutSpy).not.toHaveBeenCalled();
// Drive the click. The handler is the SAME `signOut()` exported from
// auth-client that AdminLayout uses, so verifying this call is enough to
// prove the no-access screen reaches the canonical sign-out surface.
const signOutButton = screen.getByRole("button", { name: /Sign out/i });
fireEvent.click(signOutButton);
await waitFor(() => {
expect(signOutSpy).toHaveBeenCalledTimes(1);
});
// The handler always navigates to /login — even if the network call to
// /api/auth/sign-out fails — so a transient auth-server hiccup never
// leaves the user trapped on an authenticated screen.
await waitFor(() => {
expect(window.location.href).toBe("/login");
});
Object.defineProperty(window, "location", { value: originalLocation, configurable: true });
});
it("reaches the same shared signOut() on a deep-link no-access screen (GRO-2358)", async () => {
// AC requires verifying the SAME logout handler is reachable from at
// least one other authenticated surface — here a deep link to a portal
// sub-route (e.g. /appointments) for a user with a Better Auth session
// but no client record. The no-access screen is the only authenticated
// surface without a route guard, so the handler must fire identically.
signOutSpy.mockClear();
const originalLocation = window.location;
Object.defineProperty(window, "location", {
value: { href: "" },
writable: true,
configurable: true,
});
global.fetch = vi.fn((input: RequestInfo) => {
const url = typeof input === "string" ? input : input.toString();
if (url === "/api/branding") return Promise.resolve(brandingResponse);
if (url === "/api/auth/get-session") {
return Promise.resolve({
ok: true,
json: async () => ({ user: { email: "stranger@example.com", role: "customer" } }),
} as Response);
}
if (url === "/api/portal/session-from-auth") {
return Promise.resolve({
ok: false,
status: 404,
json: async () => ({ error: "No client record found for this user" }),
} as Response);
}
return Promise.resolve({ ok: true, json: async () => ({}) } as Response);
}) as unknown as typeof fetch;
const { CustomerPortal } = await import("../portal/CustomerPortal.js");
render(
<MemoryRouter initialEntries={["/appointments"]}>
<CustomerPortal />
</MemoryRouter>
);
await waitFor(() => {
expect(screen.getByText(/Portal access not configured/i)).toBeInTheDocument();
});
const signOutButton = screen.getByRole("button", { name: /Sign out/i });
fireEvent.click(signOutButton);
await waitFor(() => {
expect(signOutSpy).toHaveBeenCalledTimes(1);
});
await waitFor(() => {
expect(window.location.href).toBe("/login");
});
Object.defineProperty(window, "location", { value: originalLocation, configurable: true });
});
it("does not call session-from-auth when there is no Better Auth session", async () => {
global.fetch = vi.fn((input: RequestInfo) => {
const url = typeof input === "string" ? input : input.toString();
if (url === "/api/branding") return Promise.resolve(brandingResponse);
if (url === "/api/auth/get-session") {
return Promise.resolve({
ok: true,
json: async () => null,
} as Response);
}
return Promise.resolve({ ok: true, json: async () => ({}) } as Response);
}) as unknown as typeof fetch;
const { CustomerPortal } = await import("../portal/CustomerPortal.js");
render(
<MemoryRouter initialEntries={["/"]}>
<CustomerPortal />
</MemoryRouter>
);
await waitFor(() => {
expect(global.fetch).toHaveBeenCalledWith("/api/auth/get-session", expect.objectContaining({ credentials: "include" }));
});
// Wait one tick to ensure no subsequent bridge call is queued
await new Promise((r) => setTimeout(r, 30));
const bridgeCalls = vi.mocked(global.fetch).mock.calls.filter(
([u]) => typeof u === "string" && u === "/api/portal/session-from-auth"
);
expect(bridgeCalls).toHaveLength(0);
});
it("skips the bridge for staff Better Auth sessions", async () => {
global.fetch = vi.fn((input: RequestInfo) => {
const url = typeof input === "string" ? input : input.toString();
if (url === "/api/branding") return Promise.resolve(brandingResponse);
if (url === "/api/auth/get-session") {
return Promise.resolve({
ok: true,
json: async () => ({ user: { email: "staff@example.com", role: "staff" } }),
} as Response);
}
return Promise.resolve({ ok: true, json: async () => ({}) } as Response);
}) as unknown as typeof fetch;
const { CustomerPortal } = await import("../portal/CustomerPortal.js");
render(
<MemoryRouter initialEntries={["/"]}>
<CustomerPortal />
</MemoryRouter>
);
await waitFor(() => {
expect(global.fetch).toHaveBeenCalledWith("/api/auth/get-session", expect.objectContaining({ credentials: "include" }));
});
await new Promise((r) => setTimeout(r, 30));
const bridgeCalls = vi.mocked(global.fetch).mock.calls.filter(
([u]) => typeof u === "string" && u === "/api/portal/session-from-auth"
);
expect(bridgeCalls).toHaveLength(0);
});
it("passes portalSessionId (not null) to RescheduleFlow for SSO bridge customers (GRO-2012)", async () => {
rescheduleFlowSpy.mockClear();
global.fetch = vi.fn((input: RequestInfo, init?: RequestInit) => {
const url = typeof input === "string" ? input : input.toString();
if (url === "/api/branding") return Promise.resolve(brandingResponse);
if (url === "/api/auth/get-session") {
return Promise.resolve({
ok: true,
json: async () => ({ user: { email: "customer@example.com", role: "customer" } }),
} as Response);
}
if (url === "/api/portal/session-from-auth" && init?.method === "POST") {
return Promise.resolve({
ok: true,
status: 201,
json: async () => ({ sessionId: "sso-sess-1", clientId: "client-1", clientName: "Jane Doe" }),
} as Response);
}
// Dashboard data — return an upcoming appointment so the Reschedule
// button is rendered on the dashboard card.
if (url === "/api/portal/appointments") {
return Promise.resolve({
ok: true,
json: async () => ({
appointments: [
{
id: "appt-1",
date: "2099-01-01",
time: "10:00",
petName: "Buddy",
serviceName: "Bath & Brush",
status: "confirmed",
},
],
}),
} as Response);
}
if (url === "/api/portal/pets") {
return Promise.resolve({ ok: true, json: async () => ({ pets: [] }) } as Response);
}
if (url === "/api/portal/invoices") {
return Promise.resolve({ ok: true, json: async () => ({ invoices: [] }) } as Response);
}
return Promise.resolve({ ok: true, json: async () => ({}) } as Response);
}) as unknown as typeof fetch;
const { CustomerPortal } = await import("../portal/CustomerPortal.js");
render(
<MemoryRouter initialEntries={["/"]}>
<CustomerPortal />
</MemoryRouter>
);
// Wait for the Reschedule button to appear on the dashboard card
const rescheduleBtn = await screen.findByRole("button", { name: /^Reschedule$/i });
fireEvent.click(rescheduleBtn);
// RescheduleFlow should have been invoked with the bridged portalSessionId,
// NOT null. Pre-fix, the call would be sessionId={null} for SSO customers.
await waitFor(() => {
expect(rescheduleFlowSpy).toHaveBeenCalled();
});
const lastProps = rescheduleFlowSpy.mock.lastCall?.[0];
expect(lastProps).toBeDefined();
expect(lastProps!.sessionId).toBe("sso-sess-1");
expect(lastProps!.appointment.id).toBe("appt-1");
});
// GRO-2099 regression: the portal chrome (and Dashboard's `!sessionId` guard)
// must NOT render before the SSO bridge resolves. A loading state must be
// shown instead. Previously, the Dashboard's redirect-to-/login guard fired
// mid-bootstrap, leaving the user with a blank page after sign-in.
it("renders a loading state during the SSO bridge (does not flash portal chrome)", async () => {
// Slow bridge: resolve get-session and session-from-auth after a tick so
// we can observe the loading state mid-bootstrap.
let resolveBridge!: (value: Response) => void;
const bridgePromise = new Promise<Response>((resolve) => {
resolveBridge = resolve;
});
global.fetch = vi.fn((input: RequestInfo, init?: RequestInit) => {
const url = typeof input === "string" ? input : input.toString();
if (url === "/api/branding") return Promise.resolve(brandingResponse);
if (url === "/api/auth/get-session") {
return Promise.resolve({
ok: true,
json: async () => ({ user: { email: "customer@example.com", role: "customer" } }),
} as Response);
}
if (url === "/api/portal/session-from-auth" && init?.method === "POST") {
return bridgePromise;
}
return Promise.resolve({ ok: true, json: async () => ({}) } as Response);
}) as unknown as typeof fetch;
const { CustomerPortal } = await import("../portal/CustomerPortal.js");
render(
<MemoryRouter initialEntries={["/"]}>
<CustomerPortal />
</MemoryRouter>
);
// Loading state is visible while the bridge is in flight. The portal nav
// (Home / Appointments / etc.) must NOT be present — its presence would
// indicate the chrome is rendering with a null session, which is the
// pre-GRO-2099 bug.
expect(await screen.findByRole("status")).toHaveTextContent(/Loading/i);
expect(screen.queryByText("Home")).not.toBeInTheDocument();
expect(screen.queryByText("Appointments")).not.toBeInTheDocument();
// Resolve the bridge and confirm the portal renders normally.
resolveBridge({
ok: true,
status: 201,
json: async () => ({ sessionId: "sso-sess-1", clientId: "client-1", clientName: "Jane Doe" }),
} as Response);
await waitFor(() => {
expect(screen.getByText(/Hi, Jane/)).toBeInTheDocument();
});
});
});
+282
View File
@@ -0,0 +1,282 @@
import { useEffect, useState } from "react";
import { Loader2 } from "lucide-react";
interface Service {
id: string;
name: string;
description?: string;
basePriceCents: number;
durationMinutes: number;
active: boolean;
}
interface BufferRule {
id: string;
serviceId: string;
serviceName: string;
sizeCategory?: string;
coatType?: string;
bufferMinutes: number;
createdAt: string;
updatedAt: string;
}
interface BufferRuleForm {
serviceId: string;
sizeCategory: string;
coatType: string;
bufferMinutes: string;
}
const EMPTY_FORM: BufferRuleForm = {
serviceId: "",
sizeCategory: "",
coatType: "",
bufferMinutes: "",
};
const SIZE_OPTIONS = ["", "small", "medium", "large", "xlarge"] as const;
const COAT_OPTIONS = ["", "smooth", "double", "wire", "curly", "long", "hairless"] as const;
export function BufferRulesSection() {
const [rules, setRules] = useState<BufferRule[]>([]);
const [services, setServices] = useState<Service[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [showForm, setShowForm] = useState(false);
const [form, setForm] = useState<BufferRuleForm>(EMPTY_FORM);
const [saving, setSaving] = useState(false);
const [formError, setFormError] = useState<string | null>(null);
const [deletingId, setDeletingId] = useState<string | null>(null);
const [confirmDeleteId, setConfirmDeleteId] = useState<string | null>(null);
const [editingId, setEditingId] = useState<string | null>(null);
const [editBuffer, setEditBuffer] = useState<string>("");
useEffect(() => {
Promise.all([
fetch("/api/buffer-rules").then(r => r.ok ? r.json() : []),
fetch("/api/services?includeInactive=true").then(r => r.ok ? r.json() : []),
]).then(([rulesData, servicesData]) => {
setRules(rulesData as BufferRule[]);
setServices(servicesData as Service[]);
}).catch(() => setError("Failed to load")).finally(() => setLoading(false));
}, []);
async function handleCreate(e: React.FormEvent) {
e.preventDefault();
const mins = parseInt(form.bufferMinutes);
if (!form.serviceId || isNaN(mins) || mins <= 0) {
setFormError("Service and valid buffer minutes are required.");
return;
}
setSaving(true);
setFormError(null);
try {
const body: Record<string, string | number> = {
serviceId: form.serviceId,
bufferMinutes: mins,
};
if (form.sizeCategory) body.sizeCategory = form.sizeCategory;
if (form.coatType) body.coatType = form.coatType;
const res = await fetch("/api/buffer-rules", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
});
if (!res.ok) {
const err = await res.json().catch(() => ({})) as { error?: string };
throw new Error(err.error ?? `HTTP ${res.status}`);
}
const newRule = await res.json() as BufferRule;
setRules(prev => [...prev, newRule]);
setShowForm(false);
setForm(EMPTY_FORM);
} catch (e: unknown) {
setFormError(e instanceof Error ? e.message : "Failed to create rule");
} finally {
setSaving(false);
}
}
async function handleDelete(id: string) {
setDeletingId(id);
try {
await fetch(`/api/buffer-rules/${id}`, { method: "DELETE" });
setRules(prev => prev.filter(r => r.id !== id));
} finally {
setDeletingId(null);
setConfirmDeleteId(null);
}
}
function startEdit(rule: BufferRule) {
setEditingId(rule.id);
setEditBuffer(String(rule.bufferMinutes));
}
async function saveEdit(rule: BufferRule) {
const mins = parseInt(editBuffer);
if (isNaN(mins) || mins <= 0) return;
setSaving(true);
try {
const res = await fetch(`/api/buffer-rules/${rule.id}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ bufferMinutes: mins }),
});
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const updated = await res.json() as BufferRule;
setRules(prev => prev.map(r => r.id === updated.id ? updated : r));
} catch {
// silent fail
} finally {
setSaving(false);
setEditingId(null);
setEditBuffer("");
}
}
if (loading) {
return (
<div className="flex items-center justify-center py-8">
<Loader2 size={20} className="animate-spin text-stone-400" />
</div>
);
}
if (error) {
return (
<div className="py-4 text-sm text-red-500">{error}</div>
);
}
return (
<div>
<div className="flex items-center justify-between mb-4">
<div>
<h2 className="text-base font-semibold text-stone-800">Buffer Rules</h2>
<p className="text-sm text-stone-500">Extra time rules per service / pet size / coat type</p>
</div>
<button
onClick={() => { setShowForm(!showForm); setFormError(null); }}
className="px-3 py-1.5 bg-(--color-primary) text-white text-sm rounded-lg hover:bg-(--color-primary-hover)"
>
{showForm ? "Cancel" : "+ Add Rule"}
</button>
</div>
{showForm && (
<form onSubmit={handleCreate} className="mb-6 p-4 bg-stone-50 rounded-xl border border-stone-200 space-y-3">
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
<div>
<label className="block text-xs font-medium text-stone-600 mb-1">Service *</label>
<select
value={form.serviceId}
onChange={e => setForm(f => ({ ...f, serviceId: e.target.value }))}
required
className="w-full border border-stone-200 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-(--color-accent)"
>
<option value="">Select service</option>
{services.map(s => (
<option key={s.id} value={s.id}>{s.name}</option>
))}
</select>
</div>
<div>
<label className="block text-xs font-medium text-stone-600 mb-1">Buffer (minutes) *</label>
<input
type="number"
min="1"
step="1"
value={form.bufferMinutes}
onChange={e => setForm(f => ({ ...f, bufferMinutes: e.target.value }))}
required
className="w-full border border-stone-200 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-(--color-accent)"
/>
</div>
<div>
<label className="block text-xs font-medium text-stone-600 mb-1">Size Category</label>
<select
value={form.sizeCategory}
onChange={e => setForm(f => ({ ...f, sizeCategory: e.target.value }))}
className="w-full border border-stone-200 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-(--color-accent)"
>
<option value="">Any</option>
{SIZE_OPTIONS.filter(s => s).map(s => (
<option key={s} value={s}>{s.charAt(0).toUpperCase() + s.slice(1)}</option>
))}
</select>
</div>
<div>
<label className="block text-xs font-medium text-stone-600 mb-1">Coat Type</label>
<select
value={form.coatType}
onChange={e => setForm(f => ({ ...f, coatType: e.target.value }))}
className="w-full border border-stone-200 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-(--color-accent)"
>
<option value="">Any</option>
{COAT_OPTIONS.filter(c => c).map(c => (
<option key={c} value={c}>{c.charAt(0).toUpperCase() + c.slice(1)}</option>
))}
</select>
</div>
</div>
{formError && <p className="text-sm text-red-500">{formError}</p>}
<button
type="submit"
disabled={saving}
className="px-4 py-2 bg-(--color-primary) text-white text-sm rounded-lg hover:bg-(--color-primary-hover) disabled:opacity-60"
>
{saving ? "Saving…" : "Create Rule"}
</button>
</form>
)}
{rules.length === 0 && !showForm ? (
<p className="text-sm text-stone-400 py-6 text-center">No buffer rules configured yet.</p>
) : (
<div className="space-y-2">
{rules.map(rule => (
<div key={rule.id} className="flex items-center gap-3 p-3 bg-white rounded-xl border border-stone-200">
<div className="flex-1 min-w-0">
<div className="text-sm font-medium text-stone-800 truncate">{rule.serviceName}</div>
<div className="text-xs text-stone-500 flex gap-2 flex-wrap">
{rule.sizeCategory && <span>Size: {rule.sizeCategory}</span>}
{rule.coatType && <span>Coat: {rule.coatType}</span>}
</div>
</div>
{editingId === rule.id ? (
<div className="flex items-center gap-2">
<input
type="number"
min="1"
value={editBuffer}
onChange={e => setEditBuffer(e.target.value)}
className="w-20 border border-stone-200 rounded px-2 py-1 text-sm"
/>
<span className="text-xs text-stone-500">min</span>
<button onClick={() => saveEdit(rule)} disabled={saving} className="text-xs text-green-600 font-medium">Save</button>
<button onClick={() => setEditingId(null)} className="text-xs text-stone-500">Cancel</button>
</div>
) : (
<>
<span className="text-sm font-medium text-stone-700">{rule.bufferMinutes} min</span>
<button onClick={() => startEdit(rule)} className="text-xs text-stone-500 hover:text-stone-700 px-2">Edit</button>
</>
)}
{confirmDeleteId === rule.id ? (
<div className="flex items-center gap-2">
<span className="text-xs text-red-500">Delete?</span>
<button onClick={() => handleDelete(rule.id)} disabled={deletingId === rule.id} className="text-xs text-red-600 font-medium">Confirm</button>
<button onClick={() => setConfirmDeleteId(null)} className="text-xs text-stone-500">Cancel</button>
</div>
) : (
<button onClick={() => setConfirmDeleteId(rule.id)} className="text-xs text-red-400 hover:text-red-600">Delete</button>
)}
</div>
))}
</div>
)}
</div>
);
}
+97
View File
@@ -0,0 +1,97 @@
import { useEffect } from "react";
import {
MapContainer,
TileLayer,
Marker,
Polyline,
Tooltip,
useMap,
} from "react-leaflet";
import L from "leaflet";
import "leaflet/dist/leaflet.css";
// This component is loaded via React.lazy from the route planner page so that
// Leaflet + react-leaflet land in a separate code-split chunk and never weigh
// down the main admin bundle.
export interface RouteMapStop {
id: string;
stopOrder: number;
latitude: number;
longitude: number;
clientName: string;
}
interface RouteMapProps {
stops: RouteMapStop[];
primaryColor: string;
}
/** A numbered teardrop pin rendered as an inline-SVG divIcon (no image assets). */
function numberedIcon(order: number, color: string): L.DivIcon {
return L.divIcon({
className: "route-stop-pin",
html: `<div style="position:relative;width:28px;height:40px">
<svg width="28" height="40" viewBox="0 0 28 40" xmlns="http://www.w3.org/2000/svg">
<path d="M14 0C6.27 0 0 6.27 0 14c0 9.5 14 26 14 26s14-16.5 14-26C28 6.27 21.73 0 14 0z" fill="${color}" stroke="#ffffff" stroke-width="1.5"/>
</svg>
<span style="position:absolute;top:5px;left:0;width:28px;text-align:center;color:#fff;font-size:13px;font-weight:700;font-family:system-ui,sans-serif">${order}</span>
</div>`,
iconSize: [28, 40],
iconAnchor: [14, 40],
tooltipAnchor: [0, -34],
});
}
/** Keeps the viewport framed around all stops whenever the route changes. */
function FitBounds({ stops }: { stops: RouteMapStop[] }) {
const map = useMap();
useEffect(() => {
if (stops.length === 0) return;
const latlngs = stops.map((s) => [s.latitude, s.longitude] as [number, number]);
if (latlngs.length === 1) {
map.setView(latlngs[0]!, 14);
} else {
map.fitBounds(L.latLngBounds(latlngs), { padding: [40, 40] });
}
}, [map, stops]);
return null;
}
export default function RouteMap({ stops, primaryColor }: RouteMapProps) {
// Fallback centre (London) only used briefly before FitBounds runs or when the
// route has no geocoded stops.
const center: [number, number] = stops[0]
? [stops[0].latitude, stops[0].longitude]
: [51.505, -0.09];
const line = stops.map((s) => [s.latitude, s.longitude] as [number, number]);
return (
<MapContainer
center={center}
zoom={12}
scrollWheelZoom
style={{ height: "100%", width: "100%", borderRadius: 8 }}
>
<TileLayer
attribution='&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors'
url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png"
/>
{line.length >= 2 && (
<Polyline positions={line} color={primaryColor} weight={4} opacity={0.7} />
)}
{stops.map((s) => (
<Marker
key={s.id}
position={[s.latitude, s.longitude]}
icon={numberedIcon(s.stopOrder, primaryColor)}
>
<Tooltip>
{s.stopOrder}. {s.clientName}
</Tooltip>
</Marker>
))}
<FitBounds stops={stops} />
</MapContainer>
);
}
+13
View File
@@ -8,6 +8,19 @@
--color-accent-dark: color-mix(in srgb, var(--color-accent) 78%, #000);
--color-accent-light: color-mix(in srgb, var(--color-accent) 18%, #fff);
--color-accent-lighter: color-mix(in srgb, var(--color-accent) 9%, #fff);
/* Semantic / booking page tokens */
--color-error: #dc2626;
--color-error-dark: #b91c1c;
--color-error-bg: #fef2f2;
--color-cancelled: #ea580c;
--color-cancelled-dark: #c2410c;
--color-cancelled-bg: #fff7ed;
--color-success: #16a34a;
--color-success-dark: #15803d;
--color-success-bg: #f0fdf4;
--color-text-secondary: #4b5563;
--color-surface: #fff;
}
*, *::before, *::after {
+40
View File
@@ -0,0 +1,40 @@
// Analytics event names — single source of truth
export const ANALYTICS_EVENTS = {
BOOKING_STEP_SERVICE: "booking_step_service",
BOOKING_STEP_TIME: "booking_step_time",
BOOKING_STEP_CONTACT: "booking_step_contact",
BOOKING_STEP_SUBMIT: "booking_step_submit",
BOOKING_CONFIRMED: "booking_confirmed",
BOOKING_ERROR: "booking_error",
} as const;
export type AnalyticsEventName = (typeof ANALYTICS_EVENTS)[keyof typeof ANALYTICS_EVENTS];
export type AnalyticsPayload = {
step?: string;
flow?: "public" | "portal";
[key: string]: string | undefined;
};
/**
* Fires a lightweight analytics event via window.dispatchEvent.
* No-op safe: failures are swallowed so analytics never breaks the booking flow.
* Designed for later Plausible/GTM integration.
*/
export function fireAnalyticsEvent(
eventName: AnalyticsEventName,
payload: AnalyticsPayload = {}
): void {
try {
window.dispatchEvent(
new CustomEvent(eventName, {
detail: {
...payload,
timestamp: new Date().toISOString(),
},
})
);
} catch {
// no-op: analytics must never break the booking flow
}
}
+7
View File
@@ -0,0 +1,7 @@
// Business contact information — update values to reflect actual business details.
// Used on error/cancellation pages to help customers reach the business.
export const BUSINESS_CONTACT_INFO = {
phone: "(555) 000-1234",
email: "hello@groombook.example.com",
address: "123 Main St, Anytown, USA",
} as const;
+37 -3
View File
@@ -2,9 +2,41 @@ import { StrictMode } from "react";
import { createRoot } from "react-dom/client";
import { BrowserRouter } from "react-router-dom";
import { App } from "./App.js";
import { ErrorBoundary } from "./ErrorBoundary.js";
import { installDevFetchInterceptor } from "./lib/devFetch.js";
import "./index.css";
// --------------------------------------------------------------------
// Global error capture (GRO-2094).
//
// Symptom: React root stays empty at /login — bundle parses, no console
// errors, no error boundary fallback. Some failure is being swallowed
// before it reaches React's commit phase. These listeners make sure any
// thrown error or unhandled promise rejection is at least visible in
// the console (and in the Playwright network/console log) instead of
// vanishing into the void.
// --------------------------------------------------------------------
function reportGlobalError(kind: string, payload: unknown): void {
// eslint-disable-next-line no-console
console.error(`[${kind}]`, payload);
}
window.addEventListener("error", (event) => {
reportGlobalError("window.error", {
message: event.message,
filename: event.filename,
lineno: event.lineno,
colno: event.colno,
error: event.error,
});
});
window.addEventListener("unhandledrejection", (event) => {
reportGlobalError("unhandledrejection", {
reason: event.reason,
});
});
installDevFetchInterceptor();
const root = document.getElementById("root");
@@ -12,8 +44,10 @@ if (!root) throw new Error("Root element not found");
createRoot(root).render(
<StrictMode>
<BrowserRouter>
<App />
</BrowserRouter>
<ErrorBoundary>
<BrowserRouter>
<App />
</BrowserRouter>
</ErrorBoundary>
</StrictMode>
);
+6 -1
View File
@@ -1,6 +1,7 @@
import { useEffect, useState } from "react";
import { useSearchParams } from "react-router-dom";
import type { Service } from "@groombook/types";
import { ANALYTICS_EVENTS, fireAnalyticsEvent } from "../lib/analytics";
// ─── Types ───────────────────────────────────────────────────────────────────
@@ -193,12 +194,14 @@ export function BookPage() {
setSelectedService(svc);
setForm((f) => ({ ...f, serviceId: svc.id }));
setStep(2);
fireAnalyticsEvent(ANALYTICS_EVENTS.BOOKING_STEP_SERVICE, { step: "service", flow: "public" });
}
function goToStep3() {
if (!selectedSlot) return;
setForm((f) => ({ ...f, startTime: selectedSlot }));
setStep(3);
fireAnalyticsEvent(ANALYTICS_EVENTS.BOOKING_STEP_TIME, { step: "time", flow: "public" });
}
function goToStep4() {
@@ -208,6 +211,7 @@ export function BookPage() {
}
setFormError(null);
setStep(4);
fireAnalyticsEvent(ANALYTICS_EVENTS.BOOKING_STEP_CONTACT, { step: "contact", flow: "public" });
}
async function submitBooking() {
@@ -236,6 +240,7 @@ export function BookPage() {
throw new Error(body.error ?? `HTTP ${res.status}`);
}
const data = (await res.json()) as BookingResult;
fireAnalyticsEvent(ANALYTICS_EVENTS.BOOKING_STEP_SUBMIT, { step: "submit", flow: "public" });
setResult(data);
setStep(5);
} catch (e: unknown) {
@@ -519,7 +524,7 @@ export function BookPage() {
<option value="small">Small (under 15 lbs)</option>
<option value="medium">Medium (1540 lbs)</option>
<option value="large">Large (4080 lbs)</option>
<option value="x-large">X-Large (over 80 lbs)</option>
<option value="xlarge">X-Large (over 80 lbs)</option>
</select>
</div>
<div>
+46 -22
View File
@@ -1,3 +1,10 @@
const STRINGS = {
heading: "Appointment Cancelled",
body: "Your appointment has been cancelled. If this was a mistake or you'd like to rebook, please contact us.",
bookAgain: "Book again",
backToPortal: "Back to Portal",
} as const;
export function BookingCancelledPage() {
return (
<div
@@ -7,12 +14,12 @@ export function BookingCancelledPage() {
alignItems: "center",
justifyContent: "center",
fontFamily: "system-ui, sans-serif",
background: "#fff7ed",
background: "var(--color-cancelled-bg)",
}}
>
<div
style={{
background: "#fff",
background: "var(--color-surface)",
borderRadius: 12,
padding: "2.5rem 3rem",
boxShadow: "0 4px 24px rgba(0,0,0,0.08)",
@@ -21,28 +28,45 @@ export function BookingCancelledPage() {
}}
>
<div style={{ fontSize: 56, marginBottom: "0.5rem" }}></div>
<h1 style={{ color: "#c2410c", fontSize: 24, margin: "0 0 0.5rem" }}>
Appointment Cancelled
<h1 style={{ color: "var(--color-cancelled-dark)", fontSize: 24, margin: "0 0 0.5rem" }}>
{STRINGS.heading}
</h1>
<p style={{ color: "#4b5563", margin: "0 0 1.5rem" }}>
Your appointment has been cancelled. If this was a mistake or you'd
like to rebook, please contact us.
<p style={{ color: "var(--color-text-secondary)", margin: "0 0 1.5rem" }}>
{STRINGS.body}
</p>
<a
href="/"
style={{
display: "inline-block",
padding: "0.6rem 1.5rem",
background: "#ea580c",
color: "#fff",
borderRadius: 6,
textDecoration: "none",
fontWeight: 600,
fontSize: 14,
}}
>
Back to Portal
</a>
<div style={{ display: "flex", flexDirection: "column", gap: "0.75rem", alignItems: "center" }}>
<a
href="/admin/book"
style={{
display: "inline-block",
padding: "0.6rem 1.5rem",
background: "var(--color-primary)",
color: "#fff",
borderRadius: 6,
textDecoration: "none",
fontWeight: 600,
fontSize: 14,
}}
>
{STRINGS.bookAgain}
</a>
<a
href="/"
style={{
display: "inline-block",
padding: "0.6rem 1.5rem",
background: "var(--color-cancelled)",
color: "#fff",
borderRadius: 6,
textDecoration: "none",
fontWeight: 600,
fontSize: 14,
}}
>
{STRINGS.backToPortal}
</a>
</div>
</div>
</div>
);
+7
View File
@@ -1,4 +1,11 @@
import { useEffect } from "react";
import { ANALYTICS_EVENTS, fireAnalyticsEvent } from "../lib/analytics";
export function BookingConfirmedPage() {
useEffect(() => {
fireAnalyticsEvent(ANALYTICS_EVENTS.BOOKING_CONFIRMED, { step: "confirmed", flow: "public" });
}, []);
return (
<div
style={{
+62 -22
View File
@@ -1,4 +1,20 @@
import { useEffect } from "react";
import { BUSINESS_CONTACT_INFO } from "../lib/contact";
import { ANALYTICS_EVENTS, fireAnalyticsEvent } from "../lib/analytics";
const STRINGS = {
heading: "Link Invalid or Expired",
body: "This confirmation link is invalid, has already been used, or your appointment has already passed. Please contact us if you need help.",
newBooking: "Start a new booking",
backToPortal: "Back to Portal",
contactLabel: "Need help?",
} as const;
export function BookingErrorPage() {
useEffect(() => {
fireAnalyticsEvent(ANALYTICS_EVENTS.BOOKING_ERROR, { step: "error", flow: "public" });
}, []);
return (
<div
style={{
@@ -7,12 +23,12 @@ export function BookingErrorPage() {
alignItems: "center",
justifyContent: "center",
fontFamily: "system-ui, sans-serif",
background: "#fef2f2",
background: "var(--color-error-bg)",
}}
>
<div
style={{
background: "#fff",
background: "var(--color-surface)",
borderRadius: 12,
padding: "2.5rem 3rem",
boxShadow: "0 4px 24px rgba(0,0,0,0.08)",
@@ -21,28 +37,52 @@ export function BookingErrorPage() {
}}
>
<div style={{ fontSize: 56, marginBottom: "0.5rem" }}></div>
<h1 style={{ color: "#b91c1c", fontSize: 24, margin: "0 0 0.5rem" }}>
Link Invalid or Expired
<h1 style={{ color: "var(--color-error-dark)", fontSize: 24, margin: "0 0 0.5rem" }}>
{STRINGS.heading}
</h1>
<p style={{ color: "#4b5563", margin: "0 0 1.5rem" }}>
This confirmation link is invalid, has already been used, or your
appointment has already passed. Please contact us if you need help.
<p style={{ color: "var(--color-text-secondary)", margin: "0 0 1.5rem" }}>
{STRINGS.body}
</p>
<a
href="/"
style={{
display: "inline-block",
padding: "0.6rem 1.5rem",
background: "#dc2626",
color: "#fff",
borderRadius: 6,
textDecoration: "none",
fontWeight: 600,
fontSize: 14,
}}
>
Back to Portal
</a>
<div style={{ display: "flex", flexDirection: "column", gap: "0.75rem", alignItems: "center" }}>
<a
href="/admin/book"
style={{
display: "inline-block",
padding: "0.6rem 1.5rem",
background: "var(--color-primary)",
color: "#fff",
borderRadius: 6,
textDecoration: "none",
fontWeight: 600,
fontSize: 14,
}}
>
{STRINGS.newBooking}
</a>
<a
href="/"
style={{
display: "inline-block",
padding: "0.6rem 1.5rem",
background: "var(--color-error)",
color: "#fff",
borderRadius: 6,
textDecoration: "none",
fontWeight: 600,
fontSize: 14,
}}
>
{STRINGS.backToPortal}
</a>
</div>
<div style={{ marginTop: "1.5rem", paddingTop: "1rem", borderTop: "1px solid #e5e7eb", fontSize: 13, color: "var(--color-text-secondary)" }}>
<p style={{ margin: "0 0 0.25rem", fontWeight: 600 }}>{STRINGS.contactLabel}</p>
<p style={{ margin: 0 }}>
{BUSINESS_CONTACT_INFO.phone} · {BUSINESS_CONTACT_INFO.email}
</p>
</div>
</div>
</div>
);
+35
View File
@@ -25,6 +25,8 @@ interface PetForm {
cutStyle: string;
shampooPreference: string;
specialCareNotes: string;
coatType: string;
sizeCategory: string;
}
interface VisitLogForm {
@@ -38,6 +40,7 @@ const EMPTY_CLIENT: ClientForm = { name: "", email: "", phone: "", address: "",
const EMPTY_PET: PetForm = {
name: "", species: "Dog", breed: "", weightStr: "", dob: "",
healthAlerts: "", groomingNotes: "", cutStyle: "", shampooPreference: "", specialCareNotes: "",
coatType: "", sizeCategory: "",
};
const EMPTY_VISIT_LOG: VisitLogForm = { cutStyle: "", productsUsed: "", notes: "", groomedAt: "" };
@@ -209,6 +212,8 @@ export function ClientsPage() {
cutStyle: p.cutStyle ?? "",
shampooPreference: p.shampooPreference ?? "",
specialCareNotes: p.specialCareNotes ?? "",
coatType: p.coatType ?? "",
sizeCategory: p.petSizeCategory ?? "",
});
setPetFormError(null);
setShowPetForm(true);
@@ -315,6 +320,8 @@ export function ClientsPage() {
cutStyle: petForm.cutStyle || undefined,
shampooPreference: petForm.shampooPreference || undefined,
specialCareNotes: petForm.specialCareNotes || undefined,
coatType: petForm.coatType || undefined,
petSizeCategory: petForm.sizeCategory || undefined,
};
const res = editingPet
? await fetch(`/api/pets/${editingPet.id}`, { method: "PATCH", headers: { "Content-Type": "application/json" }, body: JSON.stringify(body) })
@@ -690,6 +697,34 @@ export function ClientsPage() {
<Field label="Breed (optional)">
<input value={petForm.breed} onChange={(e) => setPetForm((f) => ({ ...f, breed: e.target.value }))} style={inputStyle} />
</Field>
<Field label="Size Category (optional)">
<select
value={petForm.sizeCategory}
onChange={(e) => setPetForm((f) => ({ ...f, sizeCategory: e.target.value }))}
style={inputStyle}
>
<option value="">Not set</option>
<option value="small">Small</option>
<option value="medium">Medium</option>
<option value="large">Large</option>
<option value="xlarge">X-Large</option>
</select>
</Field>
<Field label="Coat Type (optional)">
<select
value={petForm.coatType}
onChange={(e) => setPetForm((f) => ({ ...f, coatType: e.target.value }))}
style={inputStyle}
>
<option value="">Not set</option>
<option value="smooth">Smooth</option>
<option value="double">Double</option>
<option value="curly">Curly</option>
<option value="wire">Wire</option>
<option value="long">Long</option>
<option value="hairless">Hairless</option>
</select>
</Field>
<Field label="Weight kg (optional)">
<input type="number" step="0.1" min="0" value={petForm.weightStr} onChange={(e) => setPetForm((f) => ({ ...f, weightStr: e.target.value }))} style={inputStyle} />
</Field>
+781
View File
@@ -0,0 +1,781 @@
import { Suspense, lazy, useCallback, useEffect, useMemo, useState } from "react";
import {
DndContext,
KeyboardSensor,
PointerSensor,
TouchSensor,
closestCenter,
useSensor,
useSensors,
type DragEndEvent,
} from "@dnd-kit/core";
import {
SortableContext,
arrayMove,
sortableKeyboardCoordinates,
useSortable,
verticalListSortingStrategy,
} from "@dnd-kit/sortable";
import { CSS } from "@dnd-kit/utilities";
import { useBranding } from "../BrandingContext.js";
import type { RouteMapStop } from "../components/RouteMap.js";
// Leaflet is heavy and only needed on this page — load it as a separate chunk.
const RouteMap = lazy(() => import("../components/RouteMap.js"));
// ─── Types (mirror groombook/api /api/routes responses) ─────────────────────────
type RouteStatus = "draft" | "optimized" | "in_progress" | "completed";
interface RouteRow {
id: string;
staffId: string;
routeDate: string;
status: RouteStatus;
totalTravelMins: number | null;
totalDistanceKm: string | null;
}
interface ConflictFlags {
hasConflict: boolean;
}
interface RouteStop {
id: string;
appointmentId: string;
stopOrder: number;
latitude: number;
longitude: number;
travelMinsFromPrev: number | null;
travelDistanceKmFromPrev: string | null;
bufferMins: number;
appointmentStartTime: string;
appointmentEndTime: string;
appointmentStatus: string;
clientId: string;
clientName: string;
clientAddress: string | null;
conflict: ConflictFlags;
}
interface RouteResponse {
route: RouteRow;
stops: RouteStop[];
hasConflicts: boolean;
conflictCount: number;
warnings?: string[];
skipped?: Array<{ appointmentId: string; clientName: string; reason: string }>;
}
interface StaffMember {
id: string;
name: string;
role: "groomer" | "receptionist" | "manager";
active: boolean;
}
// ─── Helpers ────────────────────────────────────────────────────────────────────
function todayIso(): string {
return new Date().toISOString().slice(0, 10);
}
function fmtDuration(mins: number | null | undefined): string {
if (mins == null) return "—";
if (mins < 60) return `${mins} min`;
const h = Math.floor(mins / 60);
const m = mins % 60;
return m === 0 ? `${h} h` : `${h} h ${m} min`;
}
function fmtTime(iso: string): string {
const d = new Date(iso);
if (Number.isNaN(d.getTime())) return iso;
return d.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" });
}
// ─── Navigation export ────────────────────────────────────────────────────────
/** Navigation target platforms supported by the API export endpoints. */
type NavigationPlatform = "google-maps" | "apple-maps";
type DevicePlatform = "ios" | "android" | "other";
/**
* Best-effort mobile-OS detection so we can surface the most useful navigation
* app first. Apple Maps deep links (`maps://`) only resolve on iOS; everywhere
* else Google Maps is the safe default. iPadOS 13+ reports a desktop UA, so we
* also treat a touch-capable "MacIntel" device as iOS.
*/
function detectPlatform(): DevicePlatform {
if (typeof navigator === "undefined") return "other";
const ua = navigator.userAgent || "";
if (/iphone|ipad|ipod/i.test(ua)) return "ios";
if (navigator.platform === "MacIntel" && navigator.maxTouchPoints > 1) return "ios";
if (/android/i.test(ua)) return "android";
return "other";
}
// ─── Offline map-tile pre-warming ────────────────────────────────────────────
/** OSM tile zoom levels pre-fetched around a route so the map renders offline. */
const PREWARM_ZOOM_LEVELS = [12, 13, 14] as const;
/** Hard cap on tiles fetched per pre-warm pass — keeps us friendly to OSM. */
const MAX_PREWARM_TILES = 80;
/** Subdomains Leaflet's default OSM TileLayer rotates through (`{s}`). */
const TILE_SUBDOMAINS = ["a", "b", "c"] as const;
/** Web-Mercator longitude → tile X index at the given zoom. */
function lonToTileX(lon: number, z: number): number {
return Math.floor(((lon + 180) / 360) * 2 ** z);
}
/** Web-Mercator latitude → tile Y index at the given zoom. */
function latToTileY(lat: number, z: number): number {
const rad = (lat * Math.PI) / 180;
return Math.floor(
((1 - Math.log(Math.tan(rad) + 1 / Math.cos(rad)) / Math.PI) / 2) * 2 ** z
);
}
/**
* Warm the browser/service-worker cache with the OSM tiles covering the route's
* bounding box (plus a one-tile margin) across a few zoom levels. Tiles are
* fetched via `new Image()` so they hit the same URLs Leaflet later requests and
* land in the CacheFirst tile cache, making the map viewable offline. Bounded by
* MAX_PREWARM_TILES so a sprawling route never floods the network.
*/
function prewarmRouteTiles(
stops: Array<{ latitude: number; longitude: number }>
): void {
if (typeof window === "undefined" || stops.length === 0) return;
const lats = stops.map((s) => s.latitude);
const lons = stops.map((s) => s.longitude);
const minLat = Math.min(...lats);
const maxLat = Math.max(...lats);
const minLon = Math.min(...lons);
const maxLon = Math.max(...lons);
const urls: string[] = [];
for (const z of PREWARM_ZOOM_LEVELS) {
const x0 = lonToTileX(minLon, z) - 1;
const x1 = lonToTileX(maxLon, z) + 1;
// Tile Y grows as latitude decreases, so maxLat → smaller Y.
const y0 = latToTileY(maxLat, z) - 1;
const y1 = latToTileY(minLat, z) + 1;
for (let x = x0; x <= x1; x++) {
for (let y = y0; y <= y1; y++) {
if (x < 0 || y < 0 || x >= 2 ** z || y >= 2 ** z) continue;
const s = TILE_SUBDOMAINS[(x + y) % TILE_SUBDOMAINS.length];
urls.push(`https://${s}.tile.openstreetmap.org/${z}/${x}/${y}.png`);
}
}
}
for (const url of urls.slice(0, MAX_PREWARM_TILES)) {
const img = new Image();
img.src = url;
}
}
// ─── Responsive layout ────────────────────────────────────────────────────────
/** Tracks a `max-width` media query so the page can adapt to phone widths. */
function useIsMobile(maxWidthPx = 768): boolean {
const query = `(max-width: ${maxWidthPx}px)`;
const [isMobile, setIsMobile] = useState(
() => typeof window !== "undefined" && typeof window.matchMedia === "function"
? window.matchMedia(query).matches
: false
);
useEffect(() => {
if (typeof window === "undefined" || typeof window.matchMedia !== "function") return;
const mq = window.matchMedia(query);
const onChange = (e: MediaQueryListEvent) => setIsMobile(e.matches);
setIsMobile(mq.matches);
mq.addEventListener("change", onChange);
return () => mq.removeEventListener("change", onChange);
}, [query]);
return isMobile;
}
const STATUS_STYLES: Record<RouteStatus, { bg: string; fg: string; label: string }> = {
draft: { bg: "#f1f5f9", fg: "#475569", label: "Draft" },
optimized: { bg: "#ecfdf5", fg: "#047857", label: "Optimized" },
in_progress: { bg: "#eff6ff", fg: "#1d4ed8", label: "In progress" },
completed: { bg: "#f5f3ff", fg: "#6d28d9", label: "Completed" },
};
function StatusBadge({ status }: { status: RouteStatus }) {
const s = STATUS_STYLES[status] ?? STATUS_STYLES.draft;
return (
<span
style={{
background: s.bg,
color: s.fg,
borderRadius: 999,
padding: "0.2rem 0.7rem",
fontSize: 12,
fontWeight: 600,
textTransform: "none",
}}
>
{s.label}
</span>
);
}
const inputStyle: React.CSSProperties = {
padding: "0.4rem 0.6rem",
borderRadius: 6,
border: "1px solid #cbd5e1",
fontSize: 14,
};
/**
* A single draggable stop card. The drag handle (⠿) carries the dnd-kit
* listeners so the rest of the card stays scrollable/selectable; the handle is
* sized for touch and works with pointer, touch and keyboard sensors.
*/
function SortableStop({
stop,
primaryColor,
disabled,
}: {
stop: RouteStop;
primaryColor: string;
disabled: boolean;
}) {
const { attributes, listeners, setNodeRef, transform, transition, isDragging } =
useSortable({ id: stop.id, disabled });
return (
<div
ref={setNodeRef}
style={{
background: "#fff",
border: `1px solid ${stop.conflict?.hasConflict ? "#fca5a5" : "#e2e8f0"}`,
borderRadius: 8,
padding: "0.7rem 0.85rem",
display: "flex",
gap: 10,
transform: CSS.Transform.toString(transform),
transition,
opacity: isDragging ? 0.6 : 1,
boxShadow: isDragging ? "0 6px 16px rgba(0,0,0,0.18)" : "none",
touchAction: "none",
}}
>
<button
type="button"
aria-label={`Drag to reorder ${stop.clientName}`}
disabled={disabled}
{...attributes}
{...listeners}
style={{
flexShrink: 0,
alignSelf: "stretch",
width: 28,
border: "none",
background: "transparent",
color: "#94a3b8",
fontSize: 18,
lineHeight: 1,
cursor: disabled ? "not-allowed" : "grab",
touchAction: "none",
padding: 0,
}}
>
</button>
<div
style={{
flexShrink: 0,
width: 26,
height: 26,
borderRadius: "50%",
background: primaryColor,
color: "#fff",
fontSize: 13,
fontWeight: 700,
display: "flex",
alignItems: "center",
justifyContent: "center",
}}
>
{stop.stopOrder}
</div>
<div style={{ minWidth: 0, flex: 1 }}>
<div style={{ display: "flex", justifyContent: "space-between", gap: 8 }}>
<strong style={{ fontSize: 14, color: "#1a202c" }}>{stop.clientName}</strong>
<span style={{ fontSize: 13, color: "#4b5563", whiteSpace: "nowrap" }}>{fmtTime(stop.appointmentStartTime)}</span>
</div>
<div style={{ fontSize: 12, color: "#6b7280", marginTop: 2 }}>{stop.clientAddress || "No address on file"}</div>
<div style={{ fontSize: 12, color: "#6b7280", marginTop: 4 }}>
{stop.stopOrder === 1 || stop.travelMinsFromPrev == null
? "Start of route"
: `${fmtDuration(stop.travelMinsFromPrev)} travel from previous`}
</div>
{stop.conflict?.hasConflict && (
<div style={{ fontSize: 12, color: "#b91c1c", marginTop: 4, fontWeight: 600 }}>
Tight schedule travel + buffer may exceed the gap
</div>
)}
</div>
</div>
);
}
/**
* Navigation export controls. Fetches a platform deep-link from the API and opens
* it. The button matching the detected device OS is shown prominently (filled);
* the other is offered as a secondary outline button. On desktop both are
* secondary and Google Maps leads.
*/
function NavExportButtons({
routeId,
primaryColor,
fullWidth,
}: {
routeId: string;
primaryColor: string;
fullWidth: boolean;
}) {
const [busy, setBusy] = useState<NavigationPlatform | null>(null);
const [error, setError] = useState<string | null>(null);
const platform = useMemo(detectPlatform, []);
const openIn = useCallback(
async (target: NavigationPlatform) => {
setBusy(target);
setError(null);
// Pre-open a tab synchronously: mobile Safari/Chrome block window.open()
// calls that happen after an await (no longer in the user-gesture turn).
const win = window.open("", "_blank");
try {
const r = await fetch(`/api/routes/${encodeURIComponent(routeId)}/export/${target}`);
if (!r.ok) {
const body = await r.json().catch(() => ({}));
throw new Error(body.error || `Export failed (${r.status})`);
}
const { url } = (await r.json()) as { url: string };
if (win) win.location.href = url;
else window.location.href = url;
} catch (e) {
win?.close();
setError(e instanceof Error ? e.message : "Export failed");
} finally {
setBusy(null);
}
},
[routeId]
);
const baseBtn: React.CSSProperties = {
padding: "0.55rem 1rem",
borderRadius: 6,
fontWeight: 600,
fontSize: 14,
cursor: busy ? "wait" : "pointer",
flex: fullWidth ? "1 1 0" : "0 0 auto",
};
const primaryBtn: React.CSSProperties = {
...baseBtn,
border: "none",
background: primaryColor,
color: "#fff",
};
const secondaryBtn: React.CSSProperties = {
...baseBtn,
border: `1px solid ${primaryColor}`,
background: "#fff",
color: primaryColor,
};
const label = (p: NavigationPlatform) =>
busy === p
? "Opening…"
: p === "google-maps"
? "Open in Google Maps"
: "Open in Apple Maps";
const google = (
<button
key="google"
type="button"
onClick={() => openIn("google-maps")}
disabled={busy !== null}
style={platform === "ios" ? secondaryBtn : primaryBtn}
>
{label("google-maps")}
</button>
);
const apple = (
<button
key="apple"
type="button"
onClick={() => openIn("apple-maps")}
disabled={busy !== null}
style={platform === "ios" ? primaryBtn : secondaryBtn}
>
{label("apple-maps")}
</button>
);
// Prominent (filled) button first; secondary second.
const ordered = platform === "ios" ? [apple, google] : [google, apple];
return (
<div style={{ display: "flex", flexDirection: "column", gap: 6 }}>
<div style={{ display: "flex", gap: 8, flexWrap: "wrap", alignItems: "center" }}>
<span style={{ fontSize: 12, color: "#4b5563", fontWeight: 600, marginRight: 4 }}>
Navigate
</span>
{ordered}
</div>
{error && <div style={{ fontSize: 12, color: "#991b1b" }}>{error}</div>}
</div>
);
}
// ─── Page ───────────────────────────────────────────────────────────────────────
export function RoutesPage() {
const { branding } = useBranding();
const primaryColor = branding.primaryColor || "#4f8a6f";
const [me, setMe] = useState<StaffMember | null>(null);
const [meLoaded, setMeLoaded] = useState(false);
const [groomers, setGroomers] = useState<StaffMember[]>([]);
const [staffId, setStaffId] = useState<string>("");
const [date, setDate] = useState<string>(todayIso());
const [data, setData] = useState<RouteResponse | null>(null);
const [loading, setLoading] = useState(false);
const [optimizing, setOptimizing] = useState(false);
const [reordering, setReordering] = useState(false);
const [manuallyReordered, setManuallyReordered] = useState(false);
const [error, setError] = useState<string | null>(null);
const isGroomer = me?.role === "groomer";
const isMobile = useIsMobile();
// Resolve the current staff member; groomers are pinned to their own route.
useEffect(() => {
fetch("/api/staff/me")
.then((r) => (r.ok ? r.json() : null))
.then((row: StaffMember | null) => {
setMe(row);
if (row?.role === "groomer") setStaffId(row.id);
})
.catch(() => setMe(null))
.finally(() => setMeLoaded(true));
}, []);
// Managers / receptionists pick a groomer; groomers never see the selector.
useEffect(() => {
if (!meLoaded || isGroomer) return;
fetch("/api/staff")
.then((r) => (r.ok ? r.json() : []))
.then((rows: StaffMember[]) => {
const gs = rows.filter((s) => s.active && s.role === "groomer");
setGroomers(gs);
setStaffId((cur) => cur || gs[0]?.id || "");
})
.catch(() => setGroomers([]));
}, [meLoaded, isGroomer]);
const loadRoute = useCallback(async () => {
if (!staffId || !date) return;
setLoading(true);
setError(null);
try {
const r = await fetch(
`/api/routes/daily?staffId=${encodeURIComponent(staffId)}&date=${encodeURIComponent(date)}`
);
if (!r.ok) {
const body = await r.json().catch(() => ({}));
throw new Error(body.error || `Failed to load route (${r.status})`);
}
setData(await r.json());
setManuallyReordered(false);
} catch (e) {
setData(null);
setError(e instanceof Error ? e.message : "Failed to load route");
} finally {
setLoading(false);
}
}, [staffId, date]);
useEffect(() => {
void loadRoute();
}, [loadRoute]);
const optimize = useCallback(async () => {
if (!staffId || !date) return;
setOptimizing(true);
setError(null);
try {
const r = await fetch("/api/routes/optimize", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ staffId, date }),
});
if (!r.ok) {
const body = await r.json().catch(() => ({}));
throw new Error(body.error || `Optimization failed (${r.status})`);
}
setData(await r.json());
setManuallyReordered(false);
} catch (e) {
setError(e instanceof Error ? e.message : "Optimization failed");
} finally {
setOptimizing(false);
}
}, [staffId, date]);
// Drag-to-reorder: pointer for desktop, touch (press-and-hold) for mobile
// groomers, keyboard for accessibility. Touch uses a short delay so vertical
// scrolling of the stop list still works without triggering a drag.
const sensors = useSensors(
useSensor(PointerSensor, { activationConstraint: { distance: 5 } }),
useSensor(TouchSensor, { activationConstraint: { delay: 200, tolerance: 8 } }),
useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates })
);
// Persist a manually reordered stop list. Optimistic: the UI is updated
// immediately from the dropped order and rolled back if the PATCH fails.
const reorder = useCallback(
async (orderedIds: string[]) => {
const routeId = data?.route?.id;
if (!routeId) return;
const previous = data;
// Optimistic local update: renumber stopOrder to match the new order so
// the list and the map reflect the drop before the server responds.
const byId = new Map((data?.stops ?? []).map((s) => [s.id, s]));
const optimisticStops = orderedIds
.map((id, i) => {
const s = byId.get(id);
return s ? { ...s, stopOrder: i + 1 } : null;
})
.filter((s): s is RouteStop => s !== null);
setData((cur) => (cur ? { ...cur, stops: optimisticStops } : cur));
setManuallyReordered(true);
setReordering(true);
setError(null);
try {
const r = await fetch(`/api/routes/${encodeURIComponent(routeId)}/reorder`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ stopOrder: orderedIds }),
});
if (!r.ok) {
const body = await r.json().catch(() => ({}));
throw new Error(body.error || `Reorder failed (${r.status})`);
}
// Server recomputes travel legs, buffers and conflict flags — adopt its
// authoritative response over the optimistic guess.
setData(await r.json());
} catch (e) {
setData(previous); // rollback
setError(e instanceof Error ? e.message : "Reorder failed");
} finally {
setReordering(false);
}
},
[data]
);
const handleDragEnd = useCallback(
(event: DragEndEvent) => {
const { active, over } = event;
if (!over || active.id === over.id) return;
const ids = (data?.stops ?? []).map((s) => s.id);
const from = ids.indexOf(String(active.id));
const to = ids.indexOf(String(over.id));
if (from === -1 || to === -1) return;
void reorder(arrayMove(ids, from, to));
},
[data, reorder]
);
const mapStops: RouteMapStop[] = useMemo(
() =>
(data?.stops ?? []).map((s) => ({
id: s.id,
stopOrder: s.stopOrder,
latitude: s.latitude,
longitude: s.longitude,
clientName: s.clientName,
})),
[data]
);
// Pre-warm OSM map tiles for the route area whenever a route (re)loads or is
// re-optimized, so the map stays viewable offline. Runs after today's route is
// fetched on page load and after every optimize/reorder that yields new stops.
useEffect(() => {
if (mapStops.length > 0) prewarmRouteTiles(mapStops);
}, [mapStops]);
const stops = data?.stops ?? [];
const route = data?.route ?? null;
return (
<div style={{ padding: "1.25rem", maxWidth: 1280, margin: "0 auto" }}>
<div style={{ display: "flex", alignItems: "center", gap: 12, flexWrap: "wrap", marginBottom: "1rem" }}>
<h1 style={{ fontSize: 22, fontWeight: 700, color: "#1a202c", margin: 0 }}>Route Planner</h1>
{route && <StatusBadge status={route.status} />}
</div>
{/* Controls */}
<div style={{ display: "flex", gap: 12, flexWrap: "wrap", alignItems: "flex-end", marginBottom: "1rem" }}>
<label style={{ display: "flex", flexDirection: "column", gap: 4, fontSize: 12, color: "#4b5563", fontWeight: 600 }}>
Date
<input type="date" value={date} onChange={(e) => setDate(e.target.value)} style={inputStyle} />
</label>
{!isGroomer && (
<label style={{ display: "flex", flexDirection: "column", gap: 4, fontSize: 12, color: "#4b5563", fontWeight: 600 }}>
Groomer
<select
value={staffId}
onChange={(e) => setStaffId(e.target.value)}
style={{ ...inputStyle, minWidth: 180 }}
>
{groomers.length === 0 && <option value="">No groomers</option>}
{groomers.map((g) => (
<option key={g.id} value={g.id}>
{g.name}
</option>
))}
</select>
</label>
)}
<button
onClick={optimize}
disabled={optimizing || !staffId}
style={{
padding: "0.5rem 1.1rem",
borderRadius: 6,
border: "none",
background: primaryColor,
color: "#fff",
fontWeight: 600,
fontSize: 14,
cursor: optimizing || !staffId ? "wait" : "pointer",
opacity: optimizing || !staffId ? 0.7 : 1,
}}
>
{optimizing ? "Optimizing…" : "Optimize"}
</button>
</div>
{error && (
<div style={{ background: "#fef2f2", border: "1px solid #fecaca", borderRadius: 6, padding: "0.6rem 0.8rem", color: "#991b1b", fontSize: 13, marginBottom: "1rem" }}>
{error}
</div>
)}
{data?.warnings && data.warnings.length > 0 && (
<div style={{ background: "#fffbeb", border: "1px solid #fde68a", borderRadius: 6, padding: "0.6rem 0.8rem", color: "#92400e", fontSize: 13, marginBottom: "1rem" }}>
{data.warnings.map((w, i) => (
<div key={i}>{w}</div>
))}
</div>
)}
{/* Summary */}
<div style={{ display: "flex", gap: 24, flexWrap: "wrap", marginBottom: "1rem", padding: "0.8rem 1rem", background: "#fff", borderRadius: 8, border: "1px solid #e2e8f0" }}>
<Summary label="Stops" value={String(stops.length)} />
<Summary label="Total travel time" value={fmtDuration(route?.totalTravelMins)} />
<Summary label="Total distance" value={route?.totalDistanceKm != null ? `${route.totalDistanceKm} km` : "—"} />
</div>
{/* Navigation export — open the route in the device's maps app */}
{route && stops.length > 0 && (
<div style={{ marginBottom: "1rem", padding: "0.8rem 1rem", background: "#fff", borderRadius: 8, border: "1px solid #e2e8f0" }}>
<NavExportButtons routeId={route.id} primaryColor={primaryColor} fullWidth={isMobile} />
</div>
)}
<div style={{ display: "grid", gridTemplateColumns: isMobile ? "1fr" : "minmax(0, 1.5fr) minmax(280px, 1fr)", gap: 16, alignItems: "stretch" }}>
{/* Map */}
<div style={{ height: isMobile ? 340 : 540, background: "#e5e7eb", borderRadius: 8, overflow: "hidden", border: "1px solid #e2e8f0" }}>
{mapStops.length > 0 ? (
<Suspense fallback={<Centered>Loading map</Centered>}>
<RouteMap stops={mapStops} primaryColor={primaryColor} />
</Suspense>
) : (
<Centered>{loading ? "Loading route…" : "No stops to display. Click Optimize to build the route."}</Centered>
)}
</div>
{/* Stop list panel — drag-to-reorder */}
<div style={{ display: "flex", flexDirection: "column", gap: 10, maxHeight: isMobile ? "none" : 540, overflowY: isMobile ? "visible" : "auto" }}>
{stops.length === 0 && !loading && (
<div style={{ color: "#6b7280", fontSize: 14, padding: "1rem" }}>No stops for this day.</div>
)}
{stops.length > 0 && (
<>
{manuallyReordered && (
<div style={{ display: "flex", alignItems: "center", justifyContent: "space-between", gap: 8, background: "#fffbeb", border: "1px solid #fde68a", borderRadius: 8, padding: "0.55rem 0.7rem" }}>
<span style={{ fontSize: 12, color: "#92400e" }}>
Stops reordered manually. Re-optimize to recompute the best route.
</span>
<button
type="button"
onClick={optimize}
disabled={optimizing || reordering || !staffId}
style={{
flexShrink: 0,
padding: "0.35rem 0.7rem",
borderRadius: 6,
border: "none",
background: primaryColor,
color: "#fff",
fontSize: 13,
fontWeight: 600,
cursor: optimizing || reordering || !staffId ? "not-allowed" : "pointer",
opacity: optimizing || reordering || !staffId ? 0.6 : 1,
}}
>
{optimizing ? "Optimizing…" : "Re-optimize"}
</button>
</div>
)}
<DndContext
sensors={sensors}
collisionDetection={closestCenter}
onDragEnd={handleDragEnd}
>
<SortableContext items={stops.map((s) => s.id)} strategy={verticalListSortingStrategy}>
{stops.map((s) => (
<SortableStop key={s.id} stop={s} primaryColor={primaryColor} disabled={reordering} />
))}
</SortableContext>
</DndContext>
</>
)}
</div>
</div>
</div>
);
}
function Summary({ label, value }: { label: string; value: string }) {
return (
<div>
<div style={{ fontSize: 12, color: "#6b7280", fontWeight: 600 }}>{label}</div>
<div style={{ fontSize: 18, color: "#1a202c", fontWeight: 700 }}>{value}</div>
</div>
);
}
function Centered({ children }: { children: React.ReactNode }) {
return (
<div style={{ height: "100%", display: "flex", alignItems: "center", justifyContent: "center", color: "#6b7280", fontSize: 14, textAlign: "center", padding: "1rem" }}>
{children}
</div>
);
}
+19 -1
View File
@@ -6,6 +6,7 @@ interface ServiceForm {
description: string;
priceStr: string;
durationMinutes: number;
defaultBufferMinutes: number;
active: boolean;
}
@@ -14,6 +15,7 @@ const EMPTY_FORM: ServiceForm = {
description: "",
priceStr: "",
durationMinutes: 60,
defaultBufferMinutes: 0,
active: true,
};
@@ -55,6 +57,7 @@ export function ServicesPage() {
description: s.description ?? "",
priceStr: (s.basePriceCents / 100).toFixed(2),
durationMinutes: s.durationMinutes,
defaultBufferMinutes: s.defaultBufferMinutes ?? 0,
active: s.active,
});
setFormError(null);
@@ -76,6 +79,7 @@ export function ServicesPage() {
description: form.description || undefined,
basePriceCents: Math.round(price * 100),
durationMinutes: form.durationMinutes,
defaultBufferMinutes: form.defaultBufferMinutes,
active: form.active,
};
const res = editing
@@ -138,7 +142,7 @@ export function ServicesPage() {
<table style={{ width: "100%", borderCollapse: "collapse", fontSize: 14 }}>
<thead>
<tr style={{ background: "#f8fafc" }}>
{["Name", "Description", "Price", "Duration", "Status", ""].map((h) => (
{["Name", "Description", "Price", "Duration", "Default Buffer", "Status", ""].map((h) => (
<th key={h} style={{ textAlign: "left", padding: "0.55rem 0.75rem", borderBottom: "1px solid #e5e7eb", fontSize: 11, fontWeight: 600, color: "#6b7280", textTransform: "uppercase", letterSpacing: "0.04em" }}>
{h}
</th>
@@ -152,6 +156,7 @@ export function ServicesPage() {
<td style={tdStyle}>{s.description ?? "—"}</td>
<td style={tdStyle}>${(s.basePriceCents / 100).toFixed(2)}</td>
<td style={tdStyle}>{s.durationMinutes} min</td>
<td style={tdStyle}>{(s as Service & { defaultBufferMinutes?: number }).defaultBufferMinutes ?? 0} min</td>
<td style={tdStyle}>
<button
onClick={() => toggleActive(s)}
@@ -240,6 +245,19 @@ export function ServicesPage() {
style={inputStyle}
/>
</Field>
<Field label="Default Buffer (minutes)">
<input
type="number"
min="0"
step="1"
value={form.defaultBufferMinutes}
onChange={(e) => setForm((f) => ({ ...f, defaultBufferMinutes: parseInt(e.target.value) || 0 }))}
style={inputStyle}
/>
<p style={{ fontSize: 12, color: "#9ca3af", marginTop: "0.2rem" }}>
Default buffer time applied when no specific rule matches
</p>
</Field>
<Field label="Status">
<label style={{ display: "flex", alignItems: "center", gap: "0.5rem", cursor: "pointer" }}>
<input
+5
View File
@@ -1,5 +1,6 @@
import { useState, useEffect, useRef } from "react";
import { useBranding } from "../BrandingContext.js";
import { BufferRulesSection } from "../components/BufferRules.js";
interface AuthProviderConfig {
id: number;
@@ -533,6 +534,10 @@ issuerUrl: authForm.issuerUrl,
{saving ? "Saving..." : "Save Changes"}
</button>
{/* Buffer Rules Section */}
<hr style={{ margin: "2rem 0", border: "none", borderTop: "1px solid #e5e7eb" }} />
<BufferRulesSection />
{/* Auth Provider Section — super users only */}
{currentUser?.isSuperUser && (
<>
+127 -6
View File
@@ -15,6 +15,7 @@ import { ImpersonationBanner } from "./ImpersonationBanner.js";
import { AuditLogViewer } from "./AuditLogViewer.js";
import { useBranding } from "../BrandingContext.js";
import { getDevUser } from "../pages/DevLoginSelector.js";
import { signOut } from "../lib/auth-client.js";
import type { ImpersonationSession } from "@groombook/types";
import type { Appointment as PortalAppointment } from "./sections/Appointments.js";
@@ -43,6 +44,15 @@ export function CustomerPortal() {
// Track whether an impersonation session fetch from URL param is in-flight
// Dashboard will not redirect while this is true, allowing the session to load
const [isImpersonating, setIsImpersonating] = useState(false);
// Portal session ID for real SSO customers (GRO-1867). Populated by the
// Better Auth → /api/portal/session-from-auth bridge below. Carries the
// X-Impersonation-Session-Id header on subsequent portal API calls without
// triggering the impersonation banner (the customer is themselves).
const [portalSessionId, setPortalSessionId] = useState<string | null>(null);
// User-facing message when the SSO bridge cannot resolve a client record
// (e.g. authenticated user with no matching client row). Rendered in place
// of the portal chrome instead of bouncing back to /login.
const [authError, setAuthError] = useState<string | null>(null);
const { branding } = useBranding();
const [searchParams, setSearchParams] = useSearchParams();
@@ -98,10 +108,64 @@ export function CustomerPortal() {
}
})
.finally(() => setInitComplete(true));
} else {
// No valid session: staff dev users and unauthenticated users fall through here
setInitComplete(true);
return;
}
if (devUser && devUser.type === "staff") {
// Staff dev user — fall through; App.tsx redirects to /admin.
setInitComplete(true);
return;
}
// Real SSO customer (GRO-1867): bridge a Better Auth session into a portal
// session via POST /api/portal/session-from-auth. The returned session ID
// is used in the X-Impersonation-Session-Id header for portal API calls.
(async () => {
try {
const sessionResp = await fetch("/api/auth/get-session", { credentials: "include" });
if (!sessionResp.ok) {
setInitComplete(true);
return;
}
let sessionData: { user?: { email?: string; role?: string | null } } | null = null;
try {
sessionData = (await sessionResp.json()) as { user?: { email?: string; role?: string | null } } | null;
} catch {
// Better Auth returns an empty body when there is no session
}
if (!sessionData || !sessionData.user) {
setInitComplete(true);
return;
}
// Staff are routed to /admin by App.tsx; don't run the customer bridge.
if (sessionData.user.role === "staff") {
setInitComplete(true);
return;
}
const bridgeResp = await fetch("/api/portal/session-from-auth", {
method: "POST",
credentials: "include",
});
if (bridgeResp.ok) {
const data = await bridgeResp.json() as { sessionId: string; clientId: string; clientName: string };
setPortalSessionId(data.sessionId);
setClientName(data.clientName);
} else if (bridgeResp.status === 404) {
// Authenticated but no matching client row — show a friendly message
// instead of bouncing back to /login (which would loop indefinitely).
setAuthError(
"Your account is not linked to a customer record. Please contact your groomer to set up portal access."
);
}
// 401/other: fall through; App.tsx render guard will redirect to /login.
} catch {
// Network error — fall through; the render guard will redirect to /login.
} finally {
setInitComplete(true);
}
})();
}, []);
const handleEnd = useCallback(async () => {
@@ -130,6 +194,19 @@ export function CustomerPortal() {
}
}, [session]);
// Shared sign-out handler — wires the canonical Better Auth `signOut()` so
// every authenticated surface (no-access screen, portal chrome, etc.) uses
// the same implementation as `AdminLayout`. Failure to reach the server
// still leaves the SPA free to navigate to /login.
const handleSignOut = useCallback(async () => {
try {
await signOut();
} catch {
// Best-effort; navigate to /login regardless so the user is never trapped.
}
window.location.href = "/login";
}, []);
const logPageView = useCallback((page: string) => {
if (!session) return;
void fetch(`/api/impersonation/sessions/${session.id}/log`, {
@@ -157,7 +234,7 @@ export function CustomerPortal() {
const isReadOnly = session?.status === "active";
const renderSection = () => {
const sessionId = session?.id ?? null;
const sessionId = session?.id ?? portalSessionId;
switch (activeSection) {
case "dashboard":
return <Dashboard onNavigate={handleNavClick} readOnly={!!isReadOnly} sessionId={sessionId} clientName={clientName} onReschedule={handleReschedule} isImpersonating={isImpersonating} />;
@@ -178,12 +255,56 @@ export function CustomerPortal() {
const avatarInitials = (clientName.split(" ")[0] || "G").charAt(0).toUpperCase();
// Show a loading state while the SSO bridge is in progress. The portal chrome
// and its sections (e.g. Dashboard) assume a session is established and run
// their own auth guards — rendering them before the bridge resolves triggers
// a redirect to /login from `Dashboard.tsx`'s `!sessionId` check, breaking the
// post-sign-in flow. Once `initComplete` is true we know whether a session was
// established and can render the correct branch. See GRO-2099.
if (!initComplete) {
return (
<div
className="min-h-screen flex items-center justify-center bg-[#faf8f5]"
role="status"
aria-live="polite"
>
<div className="text-stone-500 text-sm">Loading</div>
</div>
);
}
// After init completes, redirect unauthenticated users to /login and staff to /admin.
// The portal chrome must NEVER be visible to users without a valid client session.
// For client dev users, we stay on the portal even if session is null — the dev-session
// response may not have id set immediately, or there may be timing issues with the
// session state. Dev users are verified via localStorage and the dev-session flow.
if (initComplete && !session) {
// SSO customers are recognised by portalSessionId (set by the Better Auth bridge).
if (!session && !portalSessionId) {
if (authError) {
// GRO-1867: graceful 404 fallback — authenticated user has no client row.
return (
<div
className="min-h-screen flex items-center justify-center bg-[#faf8f5] font-sans px-6"
role="alert"
aria-live="polite"
>
<div className="max-w-md w-full bg-white rounded-xl shadow-sm border border-stone-200 p-8 text-center">
<div className="w-12 h-12 rounded-full bg-amber-100 text-amber-700 flex items-center justify-center mx-auto mb-4">
<Shield size={22} />
</div>
<h1 className="text-lg font-semibold text-stone-800 mb-2">Portal access not configured</h1>
<p className="text-sm text-stone-600 mb-6">{authError}</p>
<button
onClick={() => { void handleSignOut(); }}
className="inline-flex items-center justify-center gap-2 px-4 py-2 rounded-lg text-sm font-medium text-stone-700 bg-stone-100 hover:bg-stone-200 transition-colors"
>
<LogOut size={14} />
Sign out
</button>
</div>
</div>
);
}
const devUser = getDevUser();
if (devUser && devUser.type === "staff") {
return <Navigate to="/admin" replace />;
@@ -230,7 +351,7 @@ export function CustomerPortal() {
<RescheduleFlow
appointment={rescheduleAppointment}
onClose={() => { setShowReschedule(false); setRescheduleAppointment(null); }}
sessionId={session?.id ?? null}
sessionId={session?.id ?? portalSessionId}
/>
)}
+432 -71
View File
@@ -1,11 +1,67 @@
import React, { useState, useEffect } from 'react';
import { Calendar, Clock, Plus, ChevronRight, ChevronDown, Loader2 } from 'lucide-react';
import { ANALYTICS_EVENTS, fireAnalyticsEvent } from '../../lib/analytics';
// ─── Availability fetch helper ───────────────────────────────────────────────
// Returns ISO startTime strings for the given service/date, or an error message.
// Validates HTTP status and that the body is actually an array — the API
// responds with `{error: "..."}` on 4xx, and we must not treat that as slots.
const AVAILABILITY_ERROR_MESSAGE = 'Failed to load time slots';
/**
* Re-mint an SSO-bridge portal session from the active Better Auth session.
* Defense-in-depth for GRO-2234: if a portal call returns 401 mid-flow (the
* impersonation session lapsed during a slow wizard), the customer's Better
* Auth cookie is still valid, so we can transparently obtain a fresh portal
* session id and retry once. Returns the new session id, or null if no Better
* Auth session is available (e.g. staff/dev impersonation paths).
*/
async function remintPortalSession(): Promise<string | null> {
try {
const res = await fetch('/api/portal/session-from-auth', {
method: 'POST',
credentials: 'include',
});
if (!res.ok) return null;
const data = (await res.json().catch(() => ({}))) as { sessionId?: string };
return data.sessionId ?? null;
} catch {
return null;
}
}
async function fetchAvailability(
params: { serviceId: string; date: string },
sessionId: string | null,
): Promise<{ times: string[]; error: string | null }> {
const url = `/api/book/availability?${new URLSearchParams(params).toString()}`;
const headers: Record<string, string> = {};
if (sessionId) headers['X-Impersonation-Session-Id'] = sessionId;
try {
const res = await fetch(url, { headers });
if (!res.ok) {
const body = (await res.json().catch(() => ({}))) as { error?: string };
return { times: [], error: body.error ?? `${AVAILABILITY_ERROR_MESSAGE} (HTTP ${res.status})` };
}
const data: unknown = await res.json();
if (!Array.isArray(data)) {
return { times: [], error: AVAILABILITY_ERROR_MESSAGE };
}
return { times: data as string[], error: null };
} catch {
return { times: [], error: AVAILABILITY_ERROR_MESSAGE };
}
}
export interface Appointment {
id: string;
petId: string;
serviceId: string;
groomerId: string | null;
// Absolute ISO instants as returned by `/api/portal/appointments`. `date`/`time`
// below are the locally-formatted display strings derived from `startTime`.
startTime?: string;
endTime?: string;
date: string;
time: string;
status: 'scheduled' | 'confirmed' | 'pending' | 'waitlisted' | 'completed' | 'cancelled' | 'no-show';
@@ -33,8 +89,8 @@ interface Service {
id: string;
name: string;
description?: string;
duration: number;
price: number;
duration?: number;
price?: number;
priceRange?: string;
isAddOn?: boolean;
}
@@ -61,35 +117,237 @@ export function formatDate(dateStr: string): string {
});
}
export function parseTimeTo24Hour(time: string): string {
const parts = time.split(' ');
export function parseTimeTo24Hour(time: string | null | undefined): string {
const parts = (time ?? '').split(' ');
const hoursMinutes = parts[0] ?? '';
const period = parts[1] ?? '';
const [hoursStr, minutesStr] = hoursMinutes.split(':');
const hours = parseInt(hoursStr ?? '0', 10);
const minutes = parseInt(minutesStr ?? '0', 10);
// `|| '0'` (not `?? '0'`) so empty strings from blank/undefined input
// fall back to 0 rather than parsing to NaN.
const hours = parseInt(hoursStr || '0', 10);
const minutes = parseInt(minutesStr || '0', 10);
let hours24 = hours;
if (period === 'PM' && hours !== 12) hours24 += 12;
if (period === 'AM' && hours === 12) hours24 = 0;
return `${hours24.toString().padStart(2, '0')}:${minutes.toString().padStart(2, '0')}:00`;
}
// A booking slot is the canonical UTC ISO instant returned by
// `/api/book/availability` (e.g. "2026-06-09T10:00:00.000Z" is the 10:00 UTC
// business slot — see api `src/lib/slots.ts`, which builds them with
// `setUTCHours`). Display label and submit payload both derive from the slot via
// these helpers so they never desync. Always format/extract in UTC: slots are
// generated as UTC business hours, so a browser-local conversion would mislabel
// the slot and diverge from the stored Postgres `time` column.
export function formatSlotLabel(slot: string): string {
const d = new Date(slot);
// Non-ISO input (e.g. an already-formatted "10:00 AM" label) — show as-is.
if (Number.isNaN(d.getTime())) return slot;
return new Intl.DateTimeFormat('en-US', {
hour: 'numeric',
minute: '2-digit',
hour12: true,
timeZone: 'UTC',
}).format(d);
}
// Extracts the UTC `HH:MM:SS` time component the api stores in the Postgres
// `time` column. The api inserts this verbatim, so a full ISO datetime here is
// an `invalid input syntax for type time` 500 (GRO-2211).
export function slotToTime(slot: string): string {
if (/^\d{2}:\d{2}:\d{2}$/.test(slot)) return slot; // already HH:MM:SS
const d = new Date(slot);
if (!Number.isNaN(d.getTime())) {
const hh = String(d.getUTCHours()).padStart(2, '0');
const mm = String(d.getUTCMinutes()).padStart(2, '0');
const ss = String(d.getUTCSeconds()).padStart(2, '0');
return `${hh}:${mm}:${ss}`;
}
// "10:00 AM"-style label fallback.
return parseTimeTo24Hour(slot);
}
export function isUpcoming(appt: Appointment): boolean {
const now = new Date();
const apptDate = new Date(`${appt.date}T${parseTimeTo24Hour(appt.time)}`);
// Prefer the absolute ISO `startTime` from the API; fall back to the
// locally-formatted date/time pair for already-normalized/legacy shapes.
const apptDate = appt.startTime
? new Date(appt.startTime)
: new Date(`${appt.date}T${parseTimeTo24Hour(appt.time)}`);
return apptDate > now && appt.status !== 'cancelled' && appt.status !== 'completed';
}
// ─── API → UI shape normalization ────────────────────────────────────────────
// `/api/portal/appointments` returns ISO `startTime`/`endTime` plus nested
// pet/service/staff objects, NOT the flat `date`/`time`/`petName` shape the
// portal UI renders. Every field below is optional so the legacy flat shape
// (used by tests/fixtures) is tolerated unchanged.
export interface RawApiAppointment {
id: string;
startTime?: string | null;
endTime?: string | null;
status: Appointment['status'];
confirmationStatus?: Appointment['confirmationStatus'];
customerNotes?: string | null;
notes?: string | null;
pet?: { id?: string | null; name?: string | null; photo?: string | null } | null;
service?: { id?: string | null; name?: string | null } | null;
staff?: { id?: string | null; name?: string | null } | null;
// Legacy / already-flat fields
petId?: string;
serviceId?: string;
groomerId?: string | null;
date?: string;
time?: string;
petName?: string;
serviceName?: string;
groomerName?: string;
duration?: number;
price?: number;
addOns?: string[];
}
function toLocalDateString(d: Date): string {
const year = d.getFullYear();
const month = String(d.getMonth() + 1).padStart(2, '0');
const day = String(d.getDate()).padStart(2, '0');
return `${year}-${month}-${day}`;
}
function toLocalTimeString(d: Date): string {
return d.toLocaleTimeString('en-US', { hour: 'numeric', minute: '2-digit', hour12: true });
}
// Maps a raw API appointment into the flat `Appointment` shape the portal
// renders. Derives display `date`/`time` from the absolute `startTime` and
// `duration` from the start/end delta. Tolerates the legacy flat shape.
export function normalizeAppointment(raw: RawApiAppointment): Appointment {
const start = raw.startTime ? new Date(raw.startTime) : null;
const end = raw.endTime ? new Date(raw.endTime) : null;
const derivedDuration =
start && end ? Math.round((end.getTime() - start.getTime()) / 60000) : undefined;
return {
id: raw.id,
petId: raw.pet?.id ?? raw.petId ?? '',
serviceId: raw.service?.id ?? raw.serviceId ?? '',
groomerId: raw.staff?.id ?? raw.groomerId ?? null,
startTime: raw.startTime ?? undefined,
endTime: raw.endTime ?? undefined,
date: start ? toLocalDateString(start) : raw.date ?? '',
time: start ? toLocalTimeString(start) : raw.time ?? '',
status: raw.status,
petName: raw.pet?.name ?? raw.petName,
serviceName: raw.service?.name ?? raw.serviceName,
groomerName: raw.staff?.name ?? raw.groomerName,
duration: raw.duration ?? derivedDuration,
price: raw.price,
notes: raw.notes ?? undefined,
customerNotes: raw.customerNotes ?? undefined,
addOns: raw.addOns,
confirmationStatus: raw.confirmationStatus,
};
}
// Raw service shape from `GET /api/portal/services`, which projects the
// canonical DB columns (`basePriceCents`, `durationMinutes`). Also tolerates an
// already-normalized payload so either shape renders correctly.
interface RawApiService {
id: string;
name: string;
description?: string | null;
basePriceCents?: number | null;
durationMinutes?: number | null;
price?: number | null;
duration?: number | null;
priceRange?: string | null;
isAddOn?: boolean | null;
}
// Normalizes a raw API service into the flat `Service` shape the cards render:
// price as dollars (from `basePriceCents`) and duration in minutes (from
// `durationMinutes`). Leaves fields undefined when genuinely absent so the card
// can hide them rather than print `$undefined` / empty `min`.
export function normalizeService(raw: RawApiService): Service {
const price =
raw.price ?? (typeof raw.basePriceCents === 'number' ? raw.basePriceCents / 100 : undefined);
const duration = raw.duration ?? raw.durationMinutes ?? undefined;
return {
id: raw.id,
name: raw.name,
description: raw.description ?? undefined,
duration: duration ?? undefined,
price: price ?? undefined,
priceRange: raw.priceRange ?? undefined,
isAddOn: raw.isAddOn ?? undefined,
};
}
// Renders a service price for display, preferring an explicit `priceRange`
// string, then a numeric dollar `price` (integers without trailing zeros, e.g.
// `$45`; fractional values to cents, e.g. `$45.50`). Returns null when neither
// is available so the caller can omit the price line entirely.
export function formatServicePrice(svc: Pick<Service, 'price' | 'priceRange'>): string | null {
if (svc.priceRange) return svc.priceRange;
if (typeof svc.price === 'number' && Number.isFinite(svc.price)) {
return `$${Number.isInteger(svc.price) ? svc.price : svc.price.toFixed(2)}`;
}
return null;
}
const STATUS_COLORS: Record<string, string> = {
confirmed: 'bg-green-100 text-green-700',
pending: 'bg-amber-100 text-amber-700',
waitlisted: 'bg-blue-100 text-blue-700',
pending: 'bg-amber-100 text-amber-600',
waitlisted: 'bg-blue-100 text-blue-600',
completed: 'bg-stone-100 text-stone-600',
cancelled: 'bg-red-100 text-red-600',
'no-show': 'bg-yellow-100 text-yellow-700',
scheduled: 'bg-blue-100 text-blue-700',
scheduled: 'bg-blue-100 text-blue-600',
};
const STATUS_LABELS: Record<string, string> = {
confirmed: 'Confirmed',
pending: 'Pending',
waitlisted: 'Waitlisted',
completed: 'Completed',
cancelled: 'Cancelled',
'no-show': 'No-show',
scheduled: 'Scheduled',
};
// The DB `appointment_status` enum stores `no_show` (underscore), but the badge
// palette is keyed on `no-show` (hyphen). Without normalization a no-show
// appointment renders as a raw gray `no_show` label instead of the styled
// "No-show" badge (GRO-2319 item 1). Map underscore status keys to the hyphen
// palette key so DB-sourced statuses resolve to their intended badge style.
export function normalizeStatusKey(status: string): string {
return status.replace(/_/g, '-');
}
export function StatusBadge({ status }: { status: string }) {
const key = normalizeStatusKey(status);
const label = STATUS_LABELS[key] ?? status;
const colorClass = STATUS_COLORS[key] ?? 'bg-stone-100 text-stone-600';
return (
<span className={`px-2 py-0.5 rounded-full text-xs font-medium ${colorClass}`}>
{label}
</span>
);
}
// Derives the badge state shown on an Upcoming/Past card from the appointment's
// raw status plus its confirmationStatus (GRO-2319 item 2, CMPO-approved):
// - a synthetic waitlist entry (status `waitlisted`) always shows Waitlisted
// - an upcoming appointment the groomer has not yet confirmed
// (`confirmationStatus === 'pending'`) shows Pending — semantically honest
// and reduces anxiety-driven follow-up messages
// - otherwise the raw status drives the badge
export function deriveDisplayStatus(appt: Appointment): string {
if (appt.status === 'waitlisted') return 'waitlisted';
if (isUpcoming(appt) && appt.confirmationStatus === 'pending') return 'pending';
return appt.status;
}
const CONFIRMATION_STATUS_COLORS: Record<string, string> = {
confirmed: 'bg-green-100 text-green-700',
pending: 'bg-amber-100 text-amber-700',
@@ -123,7 +381,8 @@ export const AppointmentsSection: React.FC<AppointmentsSectionProps> = ({ sessio
if (response.ok) {
const data = await response.json();
const fetchedAppointments: Appointment[] = data.appointments || data || [];
const rawAppointments: RawApiAppointment[] = data.appointments || data || [];
const fetchedAppointments: Appointment[] = rawAppointments.map(normalizeAppointment);
const upcoming = fetchedAppointments.filter((appt) => isUpcoming(appt));
const past = fetchedAppointments.filter((appt) => !isUpcoming(appt));
@@ -272,11 +531,24 @@ function AppointmentCard({
sessionId: string | null;
onReschedule: (appt: Appointment) => void;
}) {
// A waitlist-backed entry (GRO-2319 item 2, CMPO UX spec GRO-2328) is not a
// confirmed appointment: it gets a muted, dashed-border card and a subtext
// line so the customer can tell it apart from booked appointments, and the
// appointment-only actions (confirm / notes / reschedule / cancel) are hidden.
const isWaitlist = appt.status === 'waitlisted';
return (
<div className="bg-white rounded-xl border border-stone-200 shadow-sm overflow-hidden">
<div
className={
isWaitlist
? 'bg-stone-50/60 rounded-xl border border-dashed border-stone-300 shadow-sm overflow-hidden'
: 'bg-white rounded-xl border border-stone-200 shadow-sm overflow-hidden'
}
>
<button
onClick={onToggle}
className="w-full flex items-center gap-4 p-4 text-left hover:bg-stone-50"
className={`w-full flex items-center gap-4 p-4 text-left ${
isWaitlist ? 'hover:bg-stone-100/60' : 'hover:bg-stone-50'
}`}
>
<div className="w-10 h-10 rounded-lg bg-blue-100 flex items-center justify-center text-lg shrink-0">
{appt.petName?.charAt(0) || 'P'}
@@ -296,14 +568,13 @@ function AppointmentCard({
</span>
<span>with {appt.groomerName || 'First Available'}</span>
</div>
{isWaitlist && (
<p className="text-xs text-stone-400 mt-1">
You're on the waitlist — we'll let you know if a spot opens.
</p>
)}
</div>
<span
className={`px-2 py-0.5 rounded-full text-xs font-medium ${
STATUS_COLORS[appt.status] || ''
}`}
>
{appt.status}
</span>
<StatusBadge status={deriveDisplayStatus(appt)} />
{expanded ? (
<ChevronDown size={16} className="text-stone-400" />
) : (
@@ -337,11 +608,14 @@ function AppointmentCard({
{appt.notes}
</p>
)}
{isUpcoming(appt) && !readOnly && (
{!isWaitlist && isUpcoming(appt) && !readOnly && (
<CustomerNotesSection appointment={appt} sessionId={sessionId} />
)}
{isUpcoming(appt) && <ConfirmationSection appointment={appt} sessionId={sessionId} />}
{appt.status !== 'completed' &&
{!isWaitlist && isUpcoming(appt) && (
<ConfirmationSection appointment={appt} sessionId={sessionId} />
)}
{!isWaitlist &&
appt.status !== 'completed' &&
appt.status !== 'cancelled' &&
!readOnly && (
<div className="flex gap-2 mt-3">
@@ -573,16 +847,36 @@ export function RescheduleFlow({
const [submitting, setSubmitting] = useState(false);
const [error, setError] = useState<string | null>(null);
const [success, setSuccess] = useState(false);
const [slotsLoading, setSlotsLoading] = useState(false);
const [slotsError, setSlotsError] = useState<string | null>(null);
const [availableTimes, setAvailableTimes] = useState<string[]>([]);
const availableTimes = [
'9:00 AM',
'10:00 AM',
'11:00 AM',
'1:00 PM',
'2:00 PM',
'3:00 PM',
'4:00 PM',
];
useEffect(() => {
if (!selectedDate || !sessionId) {
setAvailableTimes([]);
setSlotsError(null);
return;
}
if (!appt.serviceId) {
setAvailableTimes([]);
setSlotsError('Failed to load time slots');
return;
}
let cancelled = false;
setSlotsLoading(true);
setSlotsError(null);
fetchAvailability({ serviceId: appt.serviceId, date: selectedDate }, sessionId).then(
({ times, error }) => {
if (cancelled) return;
setAvailableTimes(times);
setSlotsError(error);
setSlotsLoading(false);
},
);
return () => {
cancelled = true;
};
}, [selectedDate, sessionId, appt.serviceId]);
async function handleSubmit() {
if (!selectedDate || !selectedTime) return;
@@ -654,6 +948,7 @@ export function RescheduleFlow({
<h3 className="font-medium text-stone-800 mb-3">Pick a New Date & Time</h3>
<input
type="date"
aria-label="Select date"
value={selectedDate}
onChange={(e) => setSelectedDate(e.target.value)}
min={new Date().toISOString().split('T')[0]}
@@ -661,7 +956,12 @@ export function RescheduleFlow({
/>
{selectedDate && (
<div className="grid grid-cols-3 gap-2 mb-4">
{availableTimes.map((time) => (
{slotsLoading && <p className="col-span-3 text-sm text-stone-500 py-2">Checking availability</p>}
{!slotsLoading && slotsError && <p className="col-span-3 text-sm text-red-500 py-2">{slotsError}</p>}
{!slotsLoading && availableTimes.length === 0 && !slotsError && (
<p className="col-span-3 text-sm text-stone-500 py-2">No available slots on this date.</p>
)}
{!slotsLoading && availableTimes.map((time) => (
<button
key={time}
onClick={() => setSelectedTime(time)}
@@ -707,7 +1007,7 @@ interface BookingFlowProps {
sessionId: string | null;
}
function BookingFlow({ onClose, sessionId }: BookingFlowProps) {
export function BookingFlow({ onClose, sessionId }: BookingFlowProps) {
const [step, setStep] = useState(1);
const [pets, setPets] = useState<Pet[]>([]);
const [services, setServices] = useState<Service[]>([]);
@@ -720,19 +1020,45 @@ function BookingFlow({ onClose, sessionId }: BookingFlowProps) {
const [notes, setNotes] = useState('');
const [recurring, setRecurring] = useState('');
const [confirmed, setConfirmed] = useState(false);
useEffect(() => {
if (confirmed) {
fireAnalyticsEvent(ANALYTICS_EVENTS.BOOKING_CONFIRMED, { step: "confirmed", flow: "portal" });
}
}, [confirmed]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [submitting, setSubmitting] = useState(false);
const [slotsLoading, setSlotsLoading] = useState(false);
const [slotsError, setSlotsError] = useState<string | null>(null);
const [availableTimes, setAvailableTimes] = useState<string[]>([]);
const availableTimes = [
'9:00 AM',
'10:00 AM',
'11:00 AM',
'1:00 PM',
'2:00 PM',
'3:00 PM',
'4:00 PM',
];
useEffect(() => {
if (!selectedDate || !sessionId) {
setAvailableTimes([]);
setSlotsError(null);
return;
}
const serviceId = selectedServices[0]?.id;
if (!serviceId) {
setAvailableTimes([]);
setSlotsError('Failed to load time slots');
return;
}
let cancelled = false;
setSlotsLoading(true);
setSlotsError(null);
fetchAvailability({ serviceId, date: selectedDate }, sessionId).then(
({ times, error }) => {
if (cancelled) return;
setAvailableTimes(times);
setSlotsError(error);
setSlotsLoading(false);
},
);
return () => {
cancelled = true;
};
}, [selectedDate, sessionId, selectedServices]);
useEffect(() => {
const fetchData = async () => {
@@ -758,7 +1084,8 @@ function BookingFlow({ onClose, sessionId }: BookingFlowProps) {
if (servicesRes.ok) {
const servicesData = await servicesRes.json();
setServices(servicesData.services || servicesData || []);
const rawServices: RawApiService[] = servicesData.services || servicesData || [];
setServices(rawServices.map(normalizeService));
}
} catch {
setError('Failed to load data. Please try again.');
@@ -779,28 +1106,43 @@ function BookingFlow({ onClose, sessionId }: BookingFlowProps) {
setSubmitting(true);
setError(null);
try {
const response = await fetch('/api/portal/waitlist', {
const payload = JSON.stringify({
petId: selectedPet.id,
serviceId: selectedServices[0]?.id,
serviceIds: selectedServices.map((s) => s.id),
addOnIds: selectedAddOns.map((s) => s.id),
groomerId: selectedGroomer === 'first-available' ? null : selectedGroomer,
preferredDate: selectedDate,
preferredTime: slotToTime(selectedTime),
notes: notes || undefined,
recurring: recurring || undefined,
});
const submitWaitlist = (id: string) =>
fetch('/api/portal/waitlist', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-Impersonation-Session-Id': sessionId ?? '',
'X-Impersonation-Session-Id': id,
},
body: JSON.stringify({
petId: selectedPet.id,
serviceId: selectedServices[0]?.id,
serviceIds: selectedServices.map((s) => s.id),
addOnIds: selectedAddOns.map((s) => s.id),
groomerId: selectedGroomer === 'first-available' ? null : selectedGroomer,
preferredDate: selectedDate,
preferredTime: selectedTime,
notes: notes || undefined,
recurring: recurring || undefined,
}),
body: payload,
});
try {
let response = await submitWaitlist(sessionId);
// GRO-2234: a deliberately-paced wizard can outlive the portal session.
// The customer's Better Auth session is still valid, so transparently
// re-mint a fresh portal session and retry once before surfacing an error.
if (response.status === 401) {
const freshSessionId = await remintPortalSession();
if (freshSessionId) {
response = await submitWaitlist(freshSessionId);
}
}
if (response.ok) {
setConfirmed(true);
fireAnalyticsEvent(ANALYTICS_EVENTS.BOOKING_STEP_SUBMIT, { step: "submit", flow: "portal" });
setTimeout(() => {
window.location.reload();
}, 1500);
@@ -855,7 +1197,7 @@ function BookingFlow({ onClose, sessionId }: BookingFlowProps) {
Appointment Requested!
</h3>
<p className="text-sm text-stone-500 mb-4">
{selectedPet?.name} on {formatDate(selectedDate)} at {selectedTime}
{selectedPet?.name} on {formatDate(selectedDate)} at {formatSlotLabel(selectedTime)}
</p>
<button
onClick={onClose}
@@ -876,6 +1218,7 @@ function BookingFlow({ onClose, sessionId }: BookingFlowProps) {
onClick={() => {
setSelectedPet(pet);
setStep(2);
fireAnalyticsEvent(ANALYTICS_EVENTS.BOOKING_STEP_SERVICE, { step: "service", flow: "portal" });
}}
className={`w-full flex items-center gap-3 p-3 rounded-xl border text-left transition-colors ${
selectedPet?.id === pet.id
@@ -938,10 +1281,14 @@ function BookingFlow({ onClose, sessionId }: BookingFlowProps) {
)}
</div>
<div className="text-right shrink-0 ml-3">
<p className="text-sm font-medium text-stone-700">
{svc.priceRange || `$${svc.price}`}
</p>
<p className="text-xs text-stone-400">{svc.duration} min</p>
{formatServicePrice(svc) && (
<p className="text-sm font-medium text-stone-700">
{formatServicePrice(svc)}
</p>
)}
{typeof svc.duration === 'number' && (
<p className="text-xs text-stone-400">{svc.duration} min</p>
)}
</div>
</button>
))}
@@ -974,9 +1321,11 @@ function BookingFlow({ onClose, sessionId }: BookingFlowProps) {
<p className="text-xs text-stone-500">{svc.description}</p>
)}
</div>
<span className="text-stone-600 shrink-0 ml-3">
{svc.priceRange || `$${svc.price}`}
</span>
{formatServicePrice(svc) && (
<span className="text-stone-600 shrink-0 ml-3">
{formatServicePrice(svc)}
</span>
)}
</button>
))}
</div>
@@ -1034,7 +1383,10 @@ function BookingFlow({ onClose, sessionId }: BookingFlowProps) {
Back
</button>
<button
onClick={() => setStep(4)}
onClick={() => {
setStep(4);
fireAnalyticsEvent(ANALYTICS_EVENTS.BOOKING_STEP_CONTACT, { step: "groomer", flow: "portal" });
}}
className="flex-1 px-4 py-2 bg-blue-600 text-white rounded-lg text-sm font-medium"
>
Next
@@ -1048,6 +1400,7 @@ function BookingFlow({ onClose, sessionId }: BookingFlowProps) {
<h3 className="font-medium text-stone-800 mb-3">Pick Date & Time</h3>
<input
type="date"
aria-label="Select date"
value={selectedDate}
onChange={(e) => setSelectedDate(e.target.value)}
min={new Date().toISOString().split('T')[0]}
@@ -1055,7 +1408,12 @@ function BookingFlow({ onClose, sessionId }: BookingFlowProps) {
/>
{selectedDate && (
<div className="grid grid-cols-3 gap-2 mb-4">
{availableTimes.map((time) => (
{slotsLoading && <p className="col-span-3 text-sm text-stone-500 py-2">Checking availability</p>}
{!slotsLoading && slotsError && <p className="col-span-3 text-sm text-red-500 py-2">{slotsError}</p>}
{!slotsLoading && availableTimes.length === 0 && !slotsError && (
<p className="col-span-3 text-sm text-stone-500 py-2">No available slots on this date.</p>
)}
{!slotsLoading && availableTimes.map((time) => (
<button
key={time}
onClick={() => setSelectedTime(time)}
@@ -1065,7 +1423,7 @@ function BookingFlow({ onClose, sessionId }: BookingFlowProps) {
: 'border-stone-200 hover:border-stone-300'
}`}
>
{time}
{formatSlotLabel(time)}
</button>
))}
</div>
@@ -1093,7 +1451,10 @@ function BookingFlow({ onClose, sessionId }: BookingFlowProps) {
Back
</button>
<button
onClick={() => setStep(5)}
onClick={() => {
setStep(5);
fireAnalyticsEvent(ANALYTICS_EVENTS.BOOKING_STEP_TIME, { step: "time", flow: "portal" });
}}
disabled={!selectedDate || !selectedTime}
className="flex-1 px-4 py-2 bg-blue-600 text-white rounded-lg text-sm font-medium disabled:opacity-50"
>
@@ -1132,7 +1493,7 @@ function BookingFlow({ onClose, sessionId }: BookingFlowProps) {
<div className="flex justify-between">
<span className="text-stone-500">Date & Time</span>
<span className="font-medium">
{formatDate(selectedDate)} at {selectedTime}
{formatDate(selectedDate)} at {formatSlotLabel(selectedTime)}
</span>
</div>
{recurring && (
+35 -8
View File
@@ -1,26 +1,31 @@
import { useState } from "react";
import { X, Save, Plus, Star } from "lucide-react";
import { X, Save, Plus, Star, Loader2 } from "lucide-react";
import type { Pet, MedicalAlert, CoatType, AlertSeverity } from "@groombook/types";
const COAT_TYPES: CoatType[] = ["double", "wire", "curly", "smooth", "long", "hairless"];
const SEVERITY_OPTIONS: AlertSeverity[] = ["low", "medium", "high"];
const SIZE_OPTIONS = ["small", "medium", "large", "xlarge"] as const;
type SizeOption = typeof SIZE_OPTIONS[number];
interface Props {
pet?: Pet;
onSave: (pet: Pet) => void;
onSave: (pet: Pet) => void | Promise<void>;
onCancel: () => void;
saving?: boolean;
saveError?: string | null;
}
function newAlert(): Omit<MedicalAlert, "id"> {
return { type: "", description: "", severity: "low" };
}
export function PetForm({ pet, onSave, onCancel }: Props) {
export function PetForm({ pet, onSave, onCancel, saving, saveError }: Props) {
const [name, setName] = useState(pet?.name ?? "");
const [breed, setBreed] = useState(pet?.breed ?? "");
const [weight, setWeight] = useState(pet?.weightKg ?? 0);
const [weight, setWeight] = useState(Number(pet?.weight ?? pet?.weightKg ?? 0));
const [notes, setNotes] = useState(pet?.healthAlerts ?? "");
const [coatType, setCoatType] = useState<CoatType | "">((pet?.coatType as CoatType) ?? "");
const [petSizeCategory, setPetSizeCategory] = useState<SizeOption | "">(pet?.petSizeCategory as SizeOption ?? "");
const [preferredCuts, setPreferredCuts] = useState<string[]>(pet?.preferredCuts ?? []);
const [cutInput, setCutInput] = useState("");
const [alerts, setAlerts] = useState<Omit<MedicalAlert, "id">[]>(
@@ -81,6 +86,7 @@ export function PetForm({ pet, onSave, onCancel }: Props) {
weightKg: weight || null,
healthAlerts: notes,
coatType: coatType || null,
petSizeCategory: petSizeCategory || null,
preferredCuts,
medicalAlerts: alerts.map((a, i) => ({ ...a, id: pet.medicalAlerts?.[i]?.id ?? crypto.randomUUID() })),
};
@@ -159,6 +165,22 @@ export function PetForm({ pet, onSave, onCancel }: Props) {
</select>
</div>
{/* Size Category */}
<div>
<label htmlFor="size-category" className="block text-sm font-medium text-stone-600 mb-1">Size Category</label>
<select
id="size-category"
value={petSizeCategory}
onChange={e => setPetSizeCategory(e.target.value as SizeOption)}
className="w-full border border-stone-200 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-(--color-accent) bg-white"
>
<option value="">Select size</option>
{SIZE_OPTIONS.map(s => (
<option key={s} value={s}>{s.charAt(0).toUpperCase() + s.slice(1)}</option>
))}
</select>
</div>
{/* Temperament (read-only) */}
{(temperamentScore != null || temperamentFlags.length > 0) && (
<div className="bg-stone-50 rounded-xl p-4 space-y-2">
@@ -285,18 +307,23 @@ export function PetForm({ pet, onSave, onCancel }: Props) {
<button
type="button"
onClick={onCancel}
className="flex-1 px-4 py-2 border border-stone-200 rounded-lg text-sm text-stone-600 hover:bg-stone-50"
disabled={saving}
className="flex-1 px-4 py-2 border border-stone-200 rounded-lg text-sm text-stone-600 hover:bg-stone-50 disabled:opacity-50"
>
Cancel
</button>
<button
type="submit"
className="flex-1 flex items-center justify-center gap-1.5 px-4 py-2 bg-(--color-accent) text-white rounded-lg text-sm font-medium hover:bg-(--color-accent-hover)"
disabled={saving}
className="flex-1 flex items-center justify-center gap-1.5 px-4 py-2 bg-(--color-accent) text-white rounded-lg text-sm font-medium hover:bg-(--color-accent-hover) disabled:opacity-50"
>
<Save size={14} />
Save
{saving ? <Loader2 size={14} className="animate-spin" /> : <Save size={14} />}
{saving ? "Saving…" : "Save"}
</button>
</div>
{saveError && (
<p className="text-sm text-red-500 text-center">{saveError}</p>
)}
</form>
</div>
);
+37 -8
View File
@@ -83,9 +83,27 @@ export function PetProfiles({ sessionId, readOnly }: Props) {
const petHistory = appointments.appointments.filter(a => a.pet?.id === selectedPetId && new Date(a.startTime) <= new Date());
const editingPet = editingPetId ? pets.find(p => p.id === editingPetId) ?? null : null;
function handlePetSave(updatedPet: Pet) {
setPets(prev => prev.map(p => p.id === updatedPet.id ? updatedPet : p));
setEditingPetId(null);
const [saving, setSaving] = useState(false);
const [saveError, setSaveError] = useState<string | null>(null);
async function handlePetSave(updatedPet: Pet) {
setSaving(true);
setSaveError(null);
try {
const res = await fetch(`/api/portal/pets/${updatedPet.id}`, {
method: "PATCH",
headers: { "Content-Type": "application/json", ...buildHeaders(sessionId) },
body: JSON.stringify(updatedPet),
});
if (!res.ok) throw new Error("Failed to save pet");
const saved: Pet = await res.json();
setPets(prev => prev.map(p => p.id === saved.id ? saved : p));
setEditingPetId(null);
} catch (e) {
setSaveError(e instanceof Error ? e.message : "Failed to save pet");
} finally {
setSaving(false);
}
}
if (editingPet) {
@@ -94,6 +112,8 @@ export function PetProfiles({ sessionId, readOnly }: Props) {
pet={editingPet}
onSave={handlePetSave}
onCancel={() => setEditingPetId(null)}
saving={saving}
saveError={saveError}
/>
);
}
@@ -156,9 +176,9 @@ export function PetProfiles({ sessionId, readOnly }: Props) {
</div>
<div className="flex-1">
<h2 className="text-xl font-semibold text-stone-800">{selectedPet.name}</h2>
<p className="text-stone-500 text-sm">{selectedPet.breed ?? "Unknown breed"} · {selectedPet.weightKg ? `${selectedPet.weightKg} kg` : "Unknown weight"}</p>
<p className="text-stone-500 text-sm">{selectedPet.breed ?? "Unknown breed"} · {(() => { const w = selectedPet.weight ?? selectedPet.weightKg; return w != null && w !== "" ? `${w} kg` : "Unknown weight"; })()}</p>
<p className="text-stone-400 text-xs mt-0.5">
Born {selectedPet.dateOfBirth ? new Date(selectedPet.dateOfBirth).toLocaleDateString("en-US", { month: "long", day: "numeric", year: "numeric" }) : "Unknown"}
Born {(() => { const d = selectedPet.birthDate ?? selectedPet.dateOfBirth; return d ? new Date(d).toLocaleDateString("en-US", { month: "long", day: "numeric", year: "numeric" }) : "Unknown"; })()}
</p>
</div>
{!readOnly && (
@@ -202,6 +222,14 @@ export function PetProfiles({ sessionId, readOnly }: Props) {
);
}
export function formatSizeCategory(size?: string | null): string {
if (!size) return "Unknown";
return size
.split("_")
.map((part) => part.charAt(0).toUpperCase() + part.slice(1))
.join(" ");
}
function InfoRow({ label, value }: { label: string; value: React.ReactNode }) {
return (
<div className="flex flex-col sm:flex-row sm:items-center py-2.5 border-b border-stone-100 last:border-0">
@@ -224,7 +252,7 @@ function SeverityBadge({ severity }: { severity: "low" | "medium" | "high" }) {
);
}
function BasicInfoTab({ pet, readOnly }: { pet: Pet; readOnly: boolean }) {
export function BasicInfoTab({ pet, readOnly }: { pet: Pet; readOnly: boolean }) {
const score = pet.temperamentScore;
const flags = pet.temperamentFlags ?? [];
@@ -232,8 +260,9 @@ function BasicInfoTab({ pet, readOnly }: { pet: Pet; readOnly: boolean }) {
<div>
<InfoRow label="Name" value={pet.name} />
<InfoRow label="Breed" value={pet.breed || "Unknown"} />
<InfoRow label="Weight" value={pet.weightKg ? `${pet.weightKg} kg` : "Unknown"} />
<InfoRow label="Date of Birth" value={pet.dateOfBirth ? new Date(pet.dateOfBirth).toLocaleDateString("en-US", { month: "long", day: "numeric", year: "numeric" }) : "Unknown"} />
<InfoRow label="Weight" value={(() => { const w = pet.weight ?? pet.weightKg; return w != null && w !== "" ? `${w} kg` : "Unknown"; })()} />
<InfoRow label="Date of Birth" value={(() => { const d = pet.birthDate ?? pet.dateOfBirth; return d ? new Date(d).toLocaleDateString("en-US", { month: "long", day: "numeric", year: "numeric" }) : "Unknown"; })()} />
<InfoRow label="Size Category" value={formatSizeCategory(pet.petSizeCategory)} />
{/* Temperament (staff-set, read-only) */}
{(score != null || flags.length > 0) && (
+19
View File
@@ -39,6 +39,8 @@ export default defineConfig({
],
},
workbox: {
skipWaiting: true,
clientsClaim: true,
globPatterns: ["**/*.{js,css,html,ico,png,svg,woff2}"],
navigateFallbackDenylist: [
/^\/api\/auth\//,
@@ -55,6 +57,23 @@ export default defineConfig({
},
},
},
{
// OpenStreetMap raster tiles for the Route Planner map. CacheFirst so
// tiles pre-warmed for a route render offline during the day. Capped
// entries + 7-day TTL keep the cache bounded.
urlPattern: /^https:\/\/[abc]\.tile\.openstreetmap\.org\/.*\.png$/i,
handler: "CacheFirst",
options: {
cacheName: "osm-tiles",
expiration: {
maxEntries: 400,
maxAgeSeconds: 60 * 60 * 24 * 7, // 7 days
},
cacheableResponse: {
statuses: [0, 200],
},
},
},
],
},
}),