Compare commits

...

62 Commits

Author SHA1 Message Date
Flea Flicker 1f7f96b00b fix(GRO-2373): add Sign out button to in-portal chrome sidebar
CI / Test (pull_request) Successful in 21s
CI / Lint & Typecheck (pull_request) Successful in 29s
CI / Build & Push Docker Image (pull_request) Successful in 45s
The customer portal chrome (Home, Appointments, My Pets, Report Cards,
Billing, Messages, Settings) had no visible sign-out control. Only the
OOBE and the no-access card exposed one. Users had to clear cookies or
use devtools to sign out.

The CMPO ruling for GRO-2355 required the logout control to be
reachable from the OOBE screen, the in-portal screen, and the
deleted-portal deep-link. GRO-2358 (P1) covered no-access + OOBE but
missed the in-portal chrome.

Fix: add a 'Sign out' button in the sidebar footer (next to 'End
Impersonation') wired to the existing handleSignOut(), which calls the
canonical signOut() from lib/auth-client — the SAME handler used by
OOBE, the no-access card, and AdminLayout's top-bar Logout.

Test: portal.test.tsx renders the CustomerPortal with a successful SSO
bridge, lands on the chrome, clicks the new portal-chrome-signout
button, and asserts the shared signOutSpy fires + window.location.href
navigates to /login.

UAT_PLAYBOOK.md: added TC-WEB-5.25.6f covering the new chrome
sign-out reachability.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-06-11 18:22:11 +00:00
Flea Flicker 7a8b59ab87 Merge pull request 'feat(GRO-2359): route Authentik new-SSO users into OOBE' (#75) from feature/2357-p2-sso-to-oobe-routing into dev
CI / Test (push) Successful in 21s
CI / Lint & Typecheck (push) Successful in 27s
CI / Build & Push Docker Image (push) Successful in 18s
GRO-2359 (web): feat(GRO-2359): route Authentik new-SSO users into OOBE (#75)
2026-06-11 16:34:32 +00:00
Flea Flicker 250c7a5ac9 feat(GRO-2359): route Authentik new-SSO users into OOBE (web)
CI / Test (pull_request) Successful in 25s
CI / Lint & Typecheck (pull_request) Successful in 28s
CI / Build & Push Docker Image (pull_request) Successful in 44s
The post-auth handler in CustomerPortal previously rendered the
"Portal access not configured" card when the SSO bridge returned 404
(no client row for the user's email). That trapped first-time SSO
users on a dead-end screen with no path to portal creation.

This change routes the 404 to a new OOBE component (src/portal/OOBE.tsx)
that drives portal creation:
  * Mounts inline inside CustomerPortal so the post-auth flow stays
    inside the portal render tree (no App-level router needed).
  * Also reachable as a direct deep-link via the new /onboarding route
    in App.tsx (for grooming admins or recovery flows).
  * Submits to a new POST /api/portal/clients-from-auth endpoint in
    groombook-api (companion commit) that creates a fresh client row
    bound to the Better Auth email. 409 means the email already has a
    portal record — the OOBE shows a portal-selection message.
  * Uses the canonical shared signOut() from lib/auth-client (GRO-2358
    invariant) for the Sign out button.
  * Full window.location.href reload on submit success to reset the
    bridge's cached state and land the user in their portal.

The no-access card itself is preserved for the deep-link deleted-portal
case (a customer whose portal was disabled/deleted), signalled via
?noAccess=deleted-portal on a portal sub-route. The OOBE handles the
first-time-creation case; the no-access card handles the "had a portal
but lost it" case.

Test coverage:
  * "routes to /onboarding when session-from-auth returns 404 (GRO-2359)"
    — proves the post-auth 404 mounts the OOBE inline, not the legacy
    no-access card.
  * 6 new OOBE tests: render from direct link, name prefill, form
    submission, 409 portal-selection, required-name validation, shared
    signOut(), redirect on no-session.
  * P1 no-access tests reworked to use ?noAccess=deleted-portal so the
    GRO-2358 signOut invariant is still verified on the only surviving
    path to the no-access card.

UAT_PLAYBOOK §5.25.5–6e rewritten to cover the OOBE flow (form submit,
409, deep-link mount, deleted-portal no-access card).

Paired with the api PR on feature/2357-p2-portal-clients-from-auth.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-06-11 16:30:03 +00:00
Flea Flicker dee7465190 fix(GRO-2358): wire signOut() at higher layer for no-access screen (#72)
CI / Test (push) Successful in 17s
CI / Lint & Typecheck (push) Successful in 23s
CI / Build & Push Docker Image (push) Successful in 40s
2026-06-11 14:24:46 +00:00
Flea Flicker 66bac2c6f8 feat(GRO-2319): live-render full StatusBadge palette in portal (#69)
CI / Test (push) Successful in 23s
CI / Lint & Typecheck (push) Successful in 26s
CI / Build & Push Docker Image (push) Successful in 13s
2026-06-09 10:41:07 +00:00
Flea Flicker 044eeaae61 feat(GRO-2160): route nav export buttons + offline map polish (#66)
CI / Test (push) Successful in 20s
CI / Lint & Typecheck (push) Successful in 25s
CI / Build & Push Docker Image (push) Successful in 11s
2026-06-09 04:31:24 +00:00
Flea Flicker 59a29a2d03 feat(GRO-2159): drag-to-reorder + re-optimize on route planner (#63)
CI / Test (push) Successful in 21s
CI / Lint & Typecheck (push) Successful in 28s
CI / Build & Push Docker Image (push) Successful in 11s
2026-06-09 02:57:49 +00:00
Flea Flicker c58e4e4b23 feat(GRO-2158): route planner page at /admin/routes (#60)
CI / Test (push) Successful in 24s
CI / Lint & Typecheck (push) Successful in 31s
CI / Build & Push Docker Image (push) Successful in 12s
2026-06-09 01:50:49 +00:00
The Dogfather 98c8a7bb83 fix(GRO-2236): portal Book New service cards show price + duration (#57)
CI / Test (push) Successful in 18s
CI / Lint & Typecheck (push) Successful in 28s
CI / Build & Push Docker Image (push) Successful in 14s
CI / Test (pull_request) Successful in 24s
CI / Lint & Typecheck (pull_request) Successful in 31s
CI / Build & Push Docker Image (pull_request) Successful in 10s
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:30:30 +00:00
Flea Flicker 1ceac35437 fix(GRO-2234): transparent re-mint on 401 for portal Book New submit (#55)
CI / Test (push) Successful in 23s
CI / Lint & Typecheck (push) Successful in 31s
CI / Build & Push Docker Image (push) Successful in 16s
2026-06-08 19:13:03 +00:00
Lint Roller 3d0c3c551b fix(portal): show Weight/DoB + Size Category in pet read view (GRO-2207) (#54)
CI / Test (push) Successful in 22s
CI / Test (pull_request) Successful in 18s
CI / Lint & Typecheck (pull_request) Successful in 34s
CI / Build & Push Docker Image (pull_request) Successful in 15s
CI / Lint & Typecheck (push) Failing after 12m56s
CI / Build & Push Docker Image (push) Has been skipped
2026-06-08 17:31:44 +00:00
Flea Flicker c7417dc9e3 docs(uat): add §5.12e Book New preferredTime test cases (GRO-2218) (#53)
CI / Test (push) Failing after 6s
CI / Lint & Typecheck (push) Successful in 21s
CI / Build & Push Docker Image (push) Has been skipped
CI / Test (pull_request) Successful in 18s
CI / Lint & Typecheck (pull_request) Successful in 27s
CI / Build & Push Docker Image (pull_request) Successful in 11s
2026-06-08 16:54:07 +00:00
Flea Flicker 0d52ddd9f0 fix(portal): send preferredTime as HH:MM:SS and format booking slot labels (GRO-2211) (#51)
CI / Test (push) Successful in 18s
CI / Lint & Typecheck (push) Successful in 25s
CI / Build & Push Docker Image (push) Successful in 12s
CI / Test (pull_request) Successful in 22s
CI / Lint & Typecheck (pull_request) Successful in 28s
CI / Build & Push Docker Image (pull_request) Successful in 12s
2026-06-08 16:41:14 +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 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 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
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
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
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 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 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
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 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
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 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
Flea Flicker 8ee58471b2 docs(UAT_PLAYBOOK): add TC-AUTH-5.3.4 — SSO cookie after Authentik callback
CI / Test (pull_request) Successful in 14s
CI / Lint & Typecheck (pull_request) Successful in 16s
CI / Build & Push Docker Image (pull_request) Failing after 38s
Documents the acceptance criteria for GRO-1592: after completing
Authentik SSO login without VITE_API_URL set, the
__Secure-better-auth.session_token cookie must be present in the
browser and sent with subsequent /api/* calls.

Updated: UAT_PLAYBOOK.md §5.3

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-05-23 14:02:16 +00:00
Flea Flicker 35d31a984d fix(GRO-1592): fallback auth baseURL to window.location.origin
CI / Test (pull_request) Successful in 18s
CI / Lint & Typecheck (pull_request) Successful in 19s
CI / Build & Push Docker Image (pull_request) Failing after 38s
When VITE_API_URL is not set (e.g. in Docker/container deployments
where the env var was never injected), fallback to
window.location.origin so the auth client uses relative URLs and
cookies are sent to the correct origin.

Previously the fallback was empty string "", which caused the auth
client to default to http://localhost:3000 — the nginx sub_filter
workaround only handles strings baked into the JS bundle at build
time, not runtime-constructed URLs.

Fixes: SSO session cookie not set in browser after Authentik callback

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-05-23 13:57:47 +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
Scrubs McBarkley f70dd96c65 Merge pull request 'feat: extract groombook/web from monorepo (GRO-903)' (#1) from dev into main
CI / Test (push) Successful in 14s
CI / Lint & Typecheck (push) Successful in 16s
CI / Build & Push Docker Image (push) Successful in 14s
feat: extract groombook/web from monorepo (GRO-903)

Bootstrap exception: dev → main

QA: Lint Roller (#2753)
CTO: The Dogfather (#2764)
CI: Lint & Typecheck ✓, Tests ✓, Docker Build ✓
UAT_PLAYBOOK.md: present
2026-05-20 15:26:27 +00:00
Chris Farhood 42f3e3211a fix(GRO-903): resolve CI/CD blockers on groombook/web PR #1
CI / Test (push) Successful in 15s
CI / Test (pull_request) Successful in 15s
CI / Lint & Typecheck (push) Successful in 17s
CI / Lint & Typecheck (pull_request) Successful in 17s
CI / Build & Push Docker Image (push) Successful in 10s
CI / Build & Push Docker Image (pull_request) Successful in 11s
- Move CI workflow from .github/workflows/ to .gitea/workflows/
- Add uat branch to CI triggers (push and pull_request)
- Fix Dockerfile HEALTHCHECK to use wget instead of curl

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-20 15:01:46 +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
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
40 changed files with 5041 additions and 162 deletions
@@ -2,9 +2,9 @@ name: CI
on:
push:
branches: [main, dev]
branches: [main, dev, uat]
pull_request:
branches: [main, dev]
branches: [main, dev, uat]
workflow_dispatch:
inputs:
ref:
@@ -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}"
}
}
}
}
+1 -1
View File
@@ -18,4 +18,4 @@ COPY nginx.conf /etc/nginx/conf.d/default.conf
COPY --from=builder /app/dist /usr/share/nginx/html
EXPOSE 80
HEALTHCHECK --interval=30s --timeout=5s --start-period=5s --retries=3 \
CMD curl -f http://localhost:80/ || exit 1
CMD wget --spider -q http://localhost:80/ || exit 1
+329 -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
@@ -69,6 +72,7 @@ export const { signIn, signOut, useSession, changePassword } = authClient;
| TC-AUTH-5.3.1 | Auth client falls back to window.location.origin | Do not set `VITE_API_URL`, load app | Auth client uses `window.location.origin` as base URL |
| TC-AUTH-5.3.2 | Sign-in on localhost | Load app without `VITE_API_URL` on localhost:3000 | Auth client uses `http://localhost:3000` as base URL |
| TC-AUTH-5.3.3 | Sign-in on dev environment | Load app without `VITE_API_URL` on `https://dev.groombook.dev` | Auth client uses `https://dev.groombook.dev` as base URL |
| TC-AUTH-5.3.4 | SSO cookie set after Authentik callback (GRO-1592) | Complete Authentik SSO login on UAT without `VITE_API_URL` set | `__Secure-better-auth.session_token` cookie is present in browser; subsequent `/api/*` calls include the cookie and return 200 |
### 5.4 Session Persistence
@@ -77,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 |
@@ -103,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
@@ -121,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
@@ -142,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 |
@@ -216,6 +354,196 @@ 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 from SSO bridge routes to OOBE (GRO-2359) | 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 post-auth handler mounts the **OOBE** (`src/portal/OOBE.tsx`) — a centred card titled **"Welcome — let's set up your portal"** with form fields for name (prefilled from the Better Auth session), phone, address, and notes. The legacy "Portal access not configured" card is **not** rendered on the new-user path. No redirect loop, no portal chrome. |
| TC-WEB-5.25.6 | OOBE form submission creates the portal (GRO-2359) | From TC-WEB-5.25.5, fill in the OOBE form and click **Create my portal**. | `POST /api/portal/clients-from-auth` is called with `{ name, phone, address, notes }`; the email is taken from the Better Auth session (the API binds the new client row to the SSO identity). The page reloads to `/`, the bridge re-runs, and the user lands in their portal dashboard. DevTools → Network shows `POST /api/portal/clients-from-auth` → 201 followed by `POST /api/portal/session-from-auth` → 201. |
| TC-WEB-5.25.6b | OOBE handles portal selection (409 from clients-from-auth) (GRO-2359) | 1. Sign in via SSO with an email that already exists in `clients` (e.g. a previously deleted-then-recreated account). 2. Land on OOBE. 3. Click **Create my portal**. | The API returns 409 "A customer record with this email already exists". The OOBE re-enables the submit button and shows the portal-selection message: "A customer record with this email already exists. Please contact your groomer to link your account." The shared signOut() button remains reachable so the user can exit if needed. |
| TC-WEB-5.25.6c | OOBE uses the shared signOut() handler (GRO-2358, GRO-2359) | From TC-WEB-5.25.5, click **Sign out** in the OOBE footer. | The same shared `signOut()` from `lib/auth-client` fires (same handler as `AdminLayout` and the no-access card); browser navigates to `/login`; the Authentik session cookie is cleared. 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.6d | OOBE is mountable from a direct deep-link (GRO-2359) | 1. Sign in via SSO as any customer. 2. In a new tab, navigate to `https://uat.groombook.dev/onboarding`. | The OOBE form mounts (the App.tsx `/onboarding` route resolves before the CustomerPortal `!sessionId` guards). The submit, signOut, and field-validation behaviour are identical to the post-auth mount. |
| TC-WEB-5.25.6e | Deleted-portal deep-link still reaches the no-access card (GRO-2358, GRO-2359) | 1. Sign in via SSO as a customer whose `clients` row was disabled/deleted by the groomer. 2. Land on a portal sub-route with `?noAccess=deleted-portal` (e.g. visit `https://uat.groombook.dev/appointments?noAccess=deleted-portal` directly). | The no-access card renders (the deep-link deleted-portal case — the OOBE is reserved for first-time creation). The shared signOut() from GRO-2358 is wired identically. This proves the no-access card is still reachable for non-new-user failure modes and the CMPO "no-trap" invariant holds across the auth boundary. |
| TC-WEB-5.25.6f | In-portal chrome sidebar exposes a Sign out button (GRO-2373) | 1. Complete TC-WEB-5.25.1 (SSO sign-in as a customer). 2. From the portal chrome, look at the sidebar footer (the section below the navigation links, where "Customer Portal v1.0" sits). 3. Locate the **Sign out** button (a stone-grey button above the version label, with a LogOut icon). 4. Click it. | A **Sign out** button is present in the sidebar footer (not buried in the Settings page, not hidden in a dropdown — it's visible on every portal sub-route, including Home, Appointments, My Pets, Report Cards, Billing, Messages, Settings). Clicking it fires the same shared `signOut()` from `lib/auth-client` (same handler as the OOBE footer, the no-access card, and `AdminLayout`'s top-bar "Logout"); `POST /api/auth/sign-out` → 200 `{"success":true}`; the browser navigates to `/login`; the Better Auth / Authentik session cookie is cleared. Proves the CMPO "no-trap" invariant (originally established in GRO-2355) holds on the third authenticated surface — the in-portal chrome — which the GRO-2358 P1 fix did not cover. |
| 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
+41 -8
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";
@@ -15,6 +16,7 @@ import { BookingCancelledPage } from "./pages/BookingCancelled.js";
import { BookingErrorPage } from "./pages/BookingError.js";
import { SetupWizard } from "./pages/SetupWizard.tsx";
import { CustomerPortal } from "./portal/CustomerPortal.js";
import { OOBE } from "./portal/OOBE.js";
import { DevLoginSelector, getDevUser } from "./pages/DevLoginSelector.js";
import { DevSessionIndicator } from "./components/DevSessionIndicator.js";
import { BrandingProvider, useBranding } from "./BrandingContext.js";
@@ -175,6 +177,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 +306,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 +331,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 +382,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,14 +399,29 @@ 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";
const showCustomerPortal = !location.pathname.startsWith("/admin") && location.pathname !== "/login" && location.pathname !== "/onboarding";
// GRO-2359: OOBE is mountable from a direct link (deep-link to /onboarding)
// and from the post-auth callback (CustomerPortal navigates here when the
// SSO bridge returns 404). Render the OOBE component standalone so it's
// outside the portal chrome (no `!sessionId` guards, no `!initComplete`
// loading states to fight — the OOBE handles its own auth resolution).
const showOOBE = location.pathname === "/onboarding";
// 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>
@@ -404,6 +432,11 @@ export function App() {
</Routes>
{authDisabled && <DevSessionIndicator />}
</>
) : showOOBE ? (
<>
<OOBE />
{authDisabled && <DevSessionIndicator />}
</>
) : showCustomerPortal ? (
<>
<CustomerPortal />
+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);
});
});
});
+816
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",
@@ -36,6 +64,22 @@ const AUDIT_LOGS: ImpersonationAuditLog[] = [
},
];
// ─── Shared test fixtures ───────────────────────────────────────────────────
// `brandingResponse` is the mock /api/branding payload used by every test
// in this file. Hoisted to module scope so the SSO bridge and the OOBE
// describe blocks can both reach it without redefining the same body.
const brandingResponse = {
ok: true,
json: async () => ({
businessName: "GroomBook",
primaryColor: "#4f8a6f",
accentColor: "#8b7355",
logoBase64: null,
logoMimeType: null,
}),
} as Response;
// ─── ImpersonationBanner ────────────────────────────────────────────────────
describe("ImpersonationBanner", () => {
@@ -313,3 +357,775 @@ 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();
});
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("routes to /onboarding when session-from-auth returns 404 (GRO-2359)", async () => {
// GRO-2359 replaces the P1 no-access fallback for the new-user path.
// The post-auth handler must now navigate to /onboarding so the OOBE
// component can drive portal creation. The no-access card itself is
// reserved for the deep-link deleted-portal case (see the next two
// tests, which exercise ?noAccess=deleted-portal).
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", name: "Stranger", 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;
// MemoryRouter is required for the React Router context used by
// useNavigate inside CustomerPortal. We pass `initialEntries=["/"]`
// and let the post-auth handler navigate the router to /onboarding.
const { CustomerPortal } = await import("../portal/CustomerPortal.js");
render(
<MemoryRouter initialEntries={["/"]}>
<CustomerPortal />
</MemoryRouter>
);
// The bridge 404 must NOT render the legacy no-access card. The OOBE
// form is the new-user surface.
await waitFor(() => {
expect(screen.getByText(/set up your portal/i)).toBeInTheDocument();
});
expect(screen.queryByText(/Portal access not configured/i)).not.toBeInTheDocument();
expect(screen.getByRole("button", { name: /Create my portal/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,
});
// GRO-2359: the post-auth bridge 404 now routes to /onboarding (OOBE)
// on the new-user path. The no-access card itself is reserved for the
// deep-link deleted-portal case, which is signalled via
// ?noAccess=deleted-portal. A server-side "client disabled" check
// (future GRO) is the natural trigger.
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);
}
// The bridge must NOT succeed (so portalSessionId stays null) and must
// NOT be 404 (which would route to /onboarding). A 500 models a
// server-side portal-disabled check; the no-access card is mounted
// because of the URL param, not because of the bridge.
if (url === "/api/portal/session-from-auth") {
return Promise.resolve({
ok: false,
status: 500,
json: async () => ({ error: "Portal disabled" }),
} 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={["/?noAccess=deleted-portal"]}>
<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 of the deleted-portal signal, 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
// whose portal was deleted. The no-access screen is the only
// authenticated surface without a route guard, so the handler must
// fire identically.
//
// GRO-2359: the bridge 404 now routes to /onboarding (OOBE) on the
// new-user path; ?noAccess=deleted-portal is the surviving trigger.
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);
}
// The bridge must NOT succeed (so portalSessionId stays null) and must
// NOT be 404 (which would route to /onboarding). A 500 models a
// server-side portal-disabled check; the no-access card is mounted
// because of the URL param, not because of the bridge.
if (url === "/api/portal/session-from-auth") {
return Promise.resolve({
ok: false,
status: 500,
json: async () => ({ error: "Portal disabled" }),
} 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?noAccess=deleted-portal"]}>
<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();
});
});
});
describe("OOBE portal-creation flow (GRO-2359)", () => {
beforeEach(() => {
window.localStorage.clear();
});
// The OOBE is mounted both from the post-auth callback (CustomerPortal
// navigates to /onboarding on bridge 404) and from a direct deep-link.
// This set of tests exercises the direct-link mount, the form submit, and
// the shared signOut() handler. The post-auth routing is covered by the
// "routes to /onboarding when session-from-auth returns 404" test above.
function setupOOBEAuthMock(opts: { role?: string } = {}) {
const role = opts.role ?? "customer";
return 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: "new-sso@example.com", name: "New SSO", role },
}),
} as Response);
}
return Promise.resolve({ ok: true, json: async () => ({}) } as Response);
}) as unknown as typeof fetch;
}
it("renders the OOBE form when navigated to /onboarding directly (GRO-2359)", async () => {
global.fetch = setupOOBEAuthMock();
const { OOBE } = await import("../portal/OOBE.js");
render(
<MemoryRouter initialEntries={["/onboarding"]}>
<OOBE />
</MemoryRouter>
);
await waitFor(() => {
expect(screen.getByRole("heading", { name: /set up your portal/i })).toBeInTheDocument();
});
// All three primary form fields are present.
expect(screen.getByLabelText(/your name/i)).toBeInTheDocument();
expect(screen.getByLabelText(/phone/i)).toBeInTheDocument();
expect(screen.getByLabelText(/address/i)).toBeInTheDocument();
// Submit and shared signOut are both present.
expect(screen.getByRole("button", { name: /Create my portal/i })).toBeInTheDocument();
expect(screen.getByRole("button", { name: /Sign out/i })).toBeInTheDocument();
});
it("prefills the name field from the Better Auth session (GRO-2359)", async () => {
global.fetch = setupOOBEAuthMock();
const { OOBE } = await import("../portal/OOBE.js");
render(
<MemoryRouter initialEntries={["/onboarding"]}>
<OOBE />
</MemoryRouter>
);
await waitFor(() => {
expect(screen.getByLabelText(/your name/i)).toHaveValue("New SSO");
});
});
it("calls POST /api/portal/clients-from-auth and navigates to / on success (GRO-2359)", async () => {
const fetchMock = 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: "new-sso@example.com", name: "New SSO", role: "customer" },
}),
} as Response);
}
if (url === "/api/portal/clients-from-auth" && init?.method === "POST") {
return Promise.resolve({
ok: true,
status: 201,
json: async () => ({
id: "new-client-id",
name: "New SSO",
email: "new-sso@example.com",
}),
} as Response);
}
return Promise.resolve({ ok: true, json: async () => ({}) } as Response);
}) as unknown as typeof fetch;
global.fetch = fetchMock;
const { OOBE } = await import("../portal/OOBE.js");
render(
<MemoryRouter initialEntries={["/onboarding"]}>
<OOBE />
</MemoryRouter>
);
await waitFor(() => {
expect(screen.getByLabelText(/your name/i)).toHaveValue("New SSO");
});
// Fill phone + address and submit.
fireEvent.change(screen.getByLabelText(/phone/i), {
target: { value: "555-1234" },
});
fireEvent.change(screen.getByLabelText(/address/i), {
target: { value: "1 Main St" },
});
fireEvent.click(screen.getByRole("button", { name: /Create my portal/i }));
// The endpoint must have been called with the form values, normalised
// (phone/address trimmed). We don't assert navigation here because the
// MemoryRouter would need a history prop to assert a URL change — the
// internal `navigate("/")` call is the contract.
await waitFor(() => {
const calls = vi.mocked(fetchMock).mock.calls;
const onboardCall = calls.find(([u]) =>
typeof u === "string" && (u as string).endsWith("/api/portal/clients-from-auth"),
);
expect(onboardCall).toBeDefined();
const body = JSON.parse(((onboardCall?.[1] as RequestInit | undefined)?.body as string) ?? "{}");
expect(body).toEqual({
name: "New SSO",
phone: "555-1234",
address: "1 Main St",
notes: null,
});
});
});
it("shows the portal-selection message when the API returns 409 (GRO-2359)", 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: "new-sso@example.com", name: "New SSO", role: "customer" },
}),
} as Response);
}
if (url === "/api/portal/clients-from-auth" && init?.method === "POST") {
return Promise.resolve({
ok: false,
status: 409,
json: async () => ({ error: "A customer record with this email already exists" }),
} as Response);
}
return Promise.resolve({ ok: true, json: async () => ({}) } as Response);
}) as unknown as typeof fetch;
const { OOBE } = await import("../portal/OOBE.js");
render(
<MemoryRouter initialEntries={["/onboarding"]}>
<OOBE />
</MemoryRouter>
);
await waitFor(() => {
expect(screen.getByLabelText(/your name/i)).toHaveValue("New SSO");
});
fireEvent.click(screen.getByRole("button", { name: /Create my portal/i }));
await waitFor(() => {
expect(screen.getByText(/already exists/i)).toBeInTheDocument();
});
// The submit button is re-enabled after the error so the user can retry.
expect(screen.getByRole("button", { name: /Create my portal/i })).not.toBeDisabled();
});
it("requires the name field before submitting (GRO-2359)", async () => {
// Use a session WITHOUT a name so the OOBE starts with an empty form.
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: "noname@example.com", role: "customer" } }),
} as Response);
}
return Promise.resolve({ ok: true, json: async () => ({}) } as Response);
}) as unknown as typeof fetch;
const { OOBE } = await import("../portal/OOBE.js");
render(
<MemoryRouter initialEntries={["/onboarding"]}>
<OOBE />
</MemoryRouter>
);
await waitFor(() => {
expect(screen.getByLabelText(/your name/i)).toHaveValue("");
});
fireEvent.click(screen.getByRole("button", { name: /Create my portal/i }));
// The name-required error is shown; no API call was made.
await waitFor(() => {
expect(screen.getByText(/tell us your name/i)).toBeInTheDocument();
});
});
it("uses the shared signOut() handler on the OOBE Sign out button (GRO-2359)", async () => {
signOutSpy.mockClear();
const originalLocation = window.location;
Object.defineProperty(window, "location", {
value: { href: "" },
writable: true,
configurable: true,
});
global.fetch = setupOOBEAuthMock();
const { OOBE } = await import("../portal/OOBE.js");
render(
<MemoryRouter initialEntries={["/onboarding"]}>
<OOBE />
</MemoryRouter>
);
await waitFor(() => {
expect(screen.getByRole("button", { name: /Sign out/i })).toBeInTheDocument();
});
fireEvent.click(screen.getByRole("button", { name: /Sign out/i }));
// Same canonical handler as AdminLayout and the no-access card, per
// GRO-2358 — never a raw fetch("/api/auth/sign-out").
await waitFor(() => {
expect(signOutSpy).toHaveBeenCalledTimes(1);
});
await waitFor(() => {
expect(window.location.href).toBe("/login");
});
Object.defineProperty(window, "location", { value: originalLocation, configurable: true });
});
it("redirects to /login when no Better Auth session is present (GRO-2359)", async () => {
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: false, status: 401, json: async () => ({}) } as Response);
}
return Promise.resolve({ ok: true, json: async () => ({}) } as Response);
}) as unknown as typeof fetch;
const { OOBE } = await import("../portal/OOBE.js");
render(
<MemoryRouter initialEntries={["/onboarding"]}>
<OOBE />
</MemoryRouter>
);
await waitFor(() => {
expect(window.location.href).toBe("/login");
});
Object.defineProperty(window, "location", { value: originalLocation, configurable: true });
});
it("reaches the shared signOut() handler from the in-portal chrome sidebar (GRO-2373)", async () => {
// Pre-GRO-2373, the customer portal chrome (Home, Appointments, My Pets,
// Report Cards, Billing, Messages, Settings) had no visible sign-out
// control — only the OOBE and the no-access card exposed one. This
// leaves users signed-in with no escape hatch. The fix lands a
// "Sign out" button in the sidebar footer that wires to the same
// canonical `signOut()` already used by OOBE / no-access / AdminLayout.
signOutSpy.mockClear();
const originalLocation = window.location;
Object.defineProperty(window, "location", {
value: { href: "" },
writable: true,
configurable: true,
});
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: "uat-customer@groombook.dev", role: "customer" } }),
} as Response);
}
if (url === "/api/portal/session-from-auth" && init?.method === "POST") {
return Promise.resolve({
ok: true,
status: 201,
json: async () => ({ sessionId: "chrome-sess-1", clientId: "client-1", clientName: "Jane Doe" }),
} 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>
);
// Land on the chrome (proof: customer greeting is rendered, no
// no-access card, no OOBE).
await waitFor(() => {
expect(screen.getByText(/Hi, Jane/)).toBeInTheDocument();
});
expect(screen.queryByText(/Portal access not configured/i)).not.toBeInTheDocument();
expect(screen.queryByText(/set up your portal/i)).not.toBeInTheDocument();
// The new chrome sign-out is scoped by data-testid so it doesn't
// collide with other surfaces that may also render "Sign out" labels
// (e.g. the impersonation banner uses "End Session").
const signOutButton = screen.getByTestId("portal-chrome-signout");
expect(signOutButton).toHaveTextContent(/Sign out/i);
fireEvent.click(signOutButton);
// Same canonical handler as OOBE / no-access / AdminLayout — never
// a raw fetch("/api/auth/sign-out") and never a navigate() without
// signOut() (the OOBE/no-access surface uses window.location.href
// for a hard reload so cached state is reset).
await waitFor(() => {
expect(signOutSpy).toHaveBeenCalledTimes(1);
});
await waitFor(() => {
expect(window.location.href).toBe("/login");
});
Object.defineProperty(window, "location", { value: originalLocation, configurable: true });
});
});
+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
}
}
+1 -1
View File
@@ -1,7 +1,7 @@
import { createAuthClient } from "better-auth/react";
export const authClient = createAuthClient({
baseURL: import.meta.env.VITE_API_URL ?? "",
baseURL: import.meta.env.VITE_API_URL || (typeof window !== "undefined" ? window.location.origin : ""),
});
export const { signIn, signOut, useSession, changePassword } = authClient;
+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 && (
<>
+175 -7
View File
@@ -13,8 +13,10 @@ import { Communication } from "./sections/Communication.js";
import { AccountSettings } from "./sections/AccountSettings.js";
import { ImpersonationBanner } from "./ImpersonationBanner.js";
import { AuditLogViewer } from "./AuditLogViewer.js";
import { OOBE } from "./OOBE.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 +45,22 @@ 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);
// GRO-2359 — the SSO bridge 404 (no client row for the user's email)
// routes the user into the OOBE. We mount the OOBE inline rather than
// navigating to /onboarding so the post-auth flow stays inside the
// CustomerPortal render tree (test-isolated, no App-level router needed
// for the integration to work). The /onboarding route in App.tsx is
// still the mount point for direct deep-links to the same component.
const [showOOBE, setShowOOBE] = useState(false);
const { branding } = useBranding();
const [searchParams, setSearchParams] = useSearchParams();
@@ -53,6 +71,18 @@ export function CustomerPortal() {
initDone.current = true;
const sessionId = searchParams.get("sessionId");
// GRO-2359: a deep-link to a portal sub-route with ?noAccess=deleted-portal
// is the only path that still shows the no-access card. The post-auth
// 404-from-bridge path now navigates to /onboarding (OOBE) so the new
// user can create a portal. The deleted-portal case is set explicitly
// (e.g. a groomer who disabled a client) and uses the same no-access
// UI with the shared signOut() — that was the GRO-2358 invariant.
const noAccess = searchParams.get("noAccess");
if (noAccess === "deleted-portal") {
setAuthError(
"Your portal access has been removed. Please contact your groomer if you think this is a mistake.",
);
}
if (sessionId) {
setIsImpersonating(true);
@@ -98,10 +128,66 @@ 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 — mount the OOBE
// (GRO-2359) so the user can create their portal record instead
// of landing on the no-access card. The no-access card itself is
// still reachable for the deleted-portal case (see GRO-2358) via
// the ?noAccess=deleted-portal deep-link, but is no longer in
// the new-user path.
setShowOOBE(true);
}
// 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 +216,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 +256,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 +277,65 @@ 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) {
// GRO-2359 — new-user path: mount the OOBE inline so the SSO bridge's
// 404 hands the user a portal-creation form instead of the no-access
// card. onCompleted triggers a full page reload to /, which re-runs
// the bridge (now with a matching client row) and lands the user in
// the portal. A full reload (not React Router navigate) is the
// safest reset of the bridge's cached state.
if (showOOBE) {
return <OOBE onCompleted={() => { window.location.href = "/"; }} />;
}
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 +382,7 @@ export function CustomerPortal() {
<RescheduleFlow
appointment={rescheduleAppointment}
onClose={() => { setShowReschedule(false); setRescheduleAppointment(null); }}
sessionId={session?.id ?? null}
sessionId={session?.id ?? portalSessionId}
/>
)}
@@ -299,7 +451,14 @@ export function CustomerPortal() {
})}
</div>
{/* Session controls (only shown during active impersonation) */}
{/* Session controls — Sign out is always reachable from the portal
chrome (GRO-2373). End Impersonation is staff-only and only
appears during an active impersonation session. Both share the
same LogOut icon for visual consistency, but route to distinct
handlers: handleSignOut calls the canonical Better Auth
`signOut()` (mirroring OOBE and the no-access card); handleEnd
tears down the staff impersonation session and returns to the
admin clients list. */}
<div className="border-t border-stone-100 p-4 space-y-2">
{session?.status === "active" && (
<button
@@ -310,6 +469,15 @@ export function CustomerPortal() {
End Impersonation
</button>
)}
<button
type="button"
onClick={() => { void handleSignOut(); }}
className="w-full flex items-center gap-2 px-3 py-2 rounded-lg text-xs font-medium text-stone-700 bg-stone-50 hover:bg-stone-100 transition-colors"
data-testid="portal-chrome-signout"
>
<LogOut size={14} />
Sign out
</button>
<div className="flex items-center gap-2 px-3 py-2 text-xs text-stone-400">
<Shield size={12} />
Customer Portal v1.0
+312
View File
@@ -0,0 +1,312 @@
import { useCallback, useEffect, useState } from "react";
import { LogOut, Shield, Sparkles } from "lucide-react";
import { signOut } from "../lib/auth-client.js";
/**
* OOBE (Out-of-Box Experience) for a first-time Authentik SSO user whose
* email does not match any existing `clients` row.
*
* The post-auth handler in `CustomerPortal.tsx` redirects to this component
* when `POST /api/portal/session-from-auth` returns 404. From here the new
* user can either:
* (a) Create a fresh customer record bound to their SSO email, or
* (b) Sign out (the no-access screen is no longer in the new-user path).
*
* After successful creation, the OOBE navigates to `/` so the portal's
* existing SSO bridge re-runs and lands the user in their portal with a
* real `X-Impersonation-Session-Id` header. No new client state is
* required — the bridge re-resolves the session and the rest of the
* portal is unchanged.
*
* GRO-2359 — root-cause fix for "new SSO user lands on Portal access not
* configured" (companion to GRO-2358, which restored logout on that screen).
*/
type OOBEFormState = {
name: string;
phone: string;
address: string;
notes: string;
};
type OOBEStatus = "loading" | "ready" | "submitting" | "error";
const EMPTY_FORM: OOBEFormState = {
name: "",
phone: "",
address: "",
notes: "",
};
type OOBEProps = {
/**
* Override the post-success destination. Defaults to `/` so the SSO bridge
* re-runs. Test suites pass a custom destination to keep assertions
* deterministic without a real portal session.
*/
onCompleted?: () => void;
};
export function OOBE({ onCompleted }: OOBEProps = {}) {
const [status, setStatus] = useState<OOBEStatus>("loading");
const [form, setForm] = useState<OOBEFormState>(EMPTY_FORM);
const [error, setError] = useState<string | null>(null);
const [sessionEmail, setSessionEmail] = useState<string | null>(null);
// Resolve the Better Auth session on mount. The OOBE is gated to
// authenticated users — if no session exists the API will reject the
// creation request, so we redirect to /login early. We prefill `name`
// from the Better Auth `user.name` if the SSO provider returned one.
//
// We use a full `window.location.href` redirect (not `navigate`) so the
// OOBE works the same way whether it's mounted from the post-auth
// callback (inside CustomerPortal's render tree) or from a direct
// deep-link (mounted by App.tsx). A full reload also resets any
// cached state in the parent component.
useEffect(() => {
let cancelled = false;
(async () => {
try {
const r = await fetch("/api/auth/get-session", { credentials: "include" });
if (!r.ok) {
if (!cancelled) window.location.href = "/login";
return;
}
const data = (await r.json().catch(() => null)) as
| { user?: { email?: string; name?: string; role?: string | null } }
| null;
if (cancelled) return;
if (!data?.user) {
window.location.href = "/login";
return;
}
if (data.user.role === "staff") {
window.location.href = "/admin";
return;
}
setSessionEmail(data.user.email ?? null);
setForm((prev) => ({ ...prev, name: data.user?.name ?? prev.name }));
setStatus("ready");
} catch {
if (!cancelled) window.location.href = "/login";
}
})();
return () => {
cancelled = true;
};
}, []);
const handleChange = useCallback(
(field: keyof OOBEFormState) => (e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) => {
const value = e.target.value;
setForm((prev) => ({ ...prev, [field]: value }));
},
[],
);
const handleSubmit = useCallback(
async (e: React.FormEvent) => {
e.preventDefault();
if (status === "submitting") return;
if (!form.name.trim()) {
setError("Please tell us your name so we can set up your portal.");
return;
}
setStatus("submitting");
setError(null);
try {
const r = await fetch("/api/portal/clients-from-auth", {
method: "POST",
credentials: "include",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
name: form.name.trim(),
phone: form.phone.trim() || null,
address: form.address.trim() || null,
notes: form.notes.trim() || null,
}),
});
if (r.ok) {
// Let the parent (or default) decide where to land. The default
// is the portal root, which re-runs the SSO bridge. A full
// `window.location.href` reload resets any cached state in the
// parent (the bridge reads from Better Auth cookies, so a fresh
// request picks up the new client row).
if (onCompleted) {
onCompleted();
} else {
window.location.href = "/";
}
return;
}
if (r.status === 409) {
setStatus("ready");
setError(
"A customer record with this email already exists. Please contact your groomer to link your account.",
);
return;
}
const body = (await r.json().catch(() => null)) as { error?: string } | null;
setStatus("ready");
setError(body?.error ?? "We couldn't set up your portal. Please try again.");
} catch {
setStatus("ready");
setError("Network error. Please check your connection and try again.");
}
},
[form, onCompleted, status],
);
const handleSignOut = useCallback(async () => {
try {
await signOut();
} catch {
// Best-effort; navigate to /login regardless so the user is never trapped.
}
window.location.href = "/login";
}, []);
if (status === "loading") {
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>
);
}
return (
<div
className="min-h-screen flex items-center justify-center bg-[#faf8f5] font-sans px-6 py-10"
role="main"
>
<div className="max-w-md w-full bg-white rounded-xl shadow-sm border border-stone-200 p-8">
<div className="w-12 h-12 rounded-full bg-emerald-100 text-emerald-700 flex items-center justify-center mx-auto mb-4">
<Sparkles size={22} />
</div>
<h1 className="text-lg font-semibold text-stone-800 text-center mb-1">
Welcome let's set up your portal
</h1>
<p className="text-sm text-stone-600 text-center mb-6">
You're signed in{sessionEmail ? ` as ${sessionEmail}` : ""}. We just need a few
details to create your customer record.
</p>
<form onSubmit={handleSubmit} className="space-y-4" noValidate>
<div>
<label
htmlFor="oobe-name"
className="block text-xs font-medium text-stone-700 mb-1"
>
Your name <span className="text-red-600">*</span>
</label>
<input
id="oobe-name"
name="name"
type="text"
required
autoComplete="name"
value={form.name}
onChange={handleChange("name")}
className="w-full px-3 py-2 rounded-lg border border-stone-300 text-sm text-stone-800 focus:outline-none focus:ring-2 focus:ring-emerald-500 focus:border-emerald-500"
disabled={status === "submitting"}
/>
</div>
<div>
<label
htmlFor="oobe-phone"
className="block text-xs font-medium text-stone-700 mb-1"
>
Phone <span className="text-stone-400">(optional)</span>
</label>
<input
id="oobe-phone"
name="phone"
type="tel"
autoComplete="tel"
value={form.phone}
onChange={handleChange("phone")}
className="w-full px-3 py-2 rounded-lg border border-stone-300 text-sm text-stone-800 focus:outline-none focus:ring-2 focus:ring-emerald-500 focus:border-emerald-500"
disabled={status === "submitting"}
/>
</div>
<div>
<label
htmlFor="oobe-address"
className="block text-xs font-medium text-stone-700 mb-1"
>
Address <span className="text-stone-400">(optional)</span>
</label>
<input
id="oobe-address"
name="address"
type="text"
autoComplete="street-address"
value={form.address}
onChange={handleChange("address")}
className="w-full px-3 py-2 rounded-lg border border-stone-300 text-sm text-stone-800 focus:outline-none focus:ring-2 focus:ring-emerald-500 focus:border-emerald-500"
disabled={status === "submitting"}
/>
</div>
<div>
<label
htmlFor="oobe-notes"
className="block text-xs font-medium text-stone-700 mb-1"
>
Notes <span className="text-stone-400">(optional)</span>
</label>
<textarea
id="oobe-notes"
name="notes"
rows={2}
value={form.notes}
onChange={handleChange("notes")}
className="w-full px-3 py-2 rounded-lg border border-stone-300 text-sm text-stone-800 focus:outline-none focus:ring-2 focus:ring-emerald-500 focus:border-emerald-500"
disabled={status === "submitting"}
/>
</div>
{error && (
<div
role="alert"
aria-live="polite"
className="flex items-start gap-2 text-sm text-red-700 bg-red-50 border border-red-200 rounded-lg px-3 py-2"
>
<Shield size={14} className="mt-0.5 shrink-0" />
<span>{error}</span>
</div>
)}
<button
type="submit"
disabled={status === "submitting"}
className="w-full inline-flex items-center justify-center gap-2 px-4 py-2.5 rounded-lg text-sm font-medium text-white bg-emerald-600 hover:bg-emerald-700 disabled:opacity-60 disabled:cursor-not-allowed transition-colors"
>
{status === "submitting" ? "Setting up…" : "Create my portal"}
</button>
</form>
<div className="mt-6 pt-4 border-t border-stone-100 flex items-center justify-between">
<p className="text-xs text-stone-500">
Wrong account? Sign out and try a different one.
</p>
<button
type="button"
onClick={() => {
void handleSignOut();
}}
className="inline-flex items-center gap-1.5 text-xs font-medium text-stone-600 hover:text-stone-900"
>
<LogOut size={12} />
Sign out
</button>
</div>
</div>
</div>
);
}
export default OOBE;
+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],
},
},
},
],
},
}),