Compare commits

...

40 Commits

Author SHA1 Message Date
Flea Flicker f8c6737ede fix(GRO-2012): pass portalSessionId to RescheduleFlow for SSO bridge customers
CI / Test (pull_request) Successful in 21s
CI / Lint & Typecheck (pull_request) Successful in 25s
CI / Build & Push Docker Image (pull_request) Successful in 41s
The <RescheduleFlow> render in CustomerPortal.tsx (line 329) was passing
sessionId={session?.id ?? null}, so SSO-bridge customers (no impersonation
session, only a portalSessionId from the Better Auth bridge) received a
null sessionId. That null leaked into the X-Impersonation-Session-Id
header on the internal /api/book/availability call, causing 401s.

This was missed when renderSection() was updated to session?.id ??
portalSessionId; the RescheduleFlow render block was not.

Fix: align the RescheduleFlow prop with the rest of the portal by using
the same ?? portalSessionId fallback. Existing impersonation flow is
unaffected (session?.id is still preferred when present).

- UAT_PLAYBOOK §5.26 added covering RescheduleFlow under SSO bridge.
- Unit test added that asserts RescheduleFlow receives the bridged
  sessionId for SSO customers (fails without this fix).

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-06-01 17:25:58 +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 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 465db89ab4 fix(GRO-1361): remove unused X import and delete corrupted demo-pet images
CI / Test (push) Successful in 16s
CI / Test (pull_request) Successful in 15s
CI / Lint & Typecheck (push) Successful in 19s
CI / Lint & Typecheck (pull_request) Successful in 19s
CI / Build & Push Docker Image (pull_request) Successful in 41s
CI / Build & Push Docker Image (push) Successful in 2m55s
- Remove unused 'X' import from lucide-react in PetProfiles.tsx
- Delete 10 corrupted demo-pet PNG files that contain Alibaba AccessDenied XML

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-20 14:09:20 +00:00
The Dogfather ee7fc2e9bf Merge pull request 'chore: add Renovate config (GRO-1081)' (#4) from add-renovate-config into dev
CI / Test (push) Successful in 15s
CI / Lint & Typecheck (push) Failing after 17s
CI / Build & Push Docker Image (push) Has been skipped
CI / Test (pull_request) Successful in 14s
CI / Lint & Typecheck (pull_request) Failing after 18s
CI / Build & Push Docker Image (pull_request) Has been skipped
chore: add Renovate config (GRO-1081)

Merge PR #4: add-renovate-config → dev
Approved by QA (Lint Roller) and CTO (The Dogfather).
2026-05-20 12:41:45 +00:00
The Dogfather c8610ec28d Merge pull request 'fix(ci): use Gitea registry for Docker push' (#9) from fix/ci-registry-auth into dev
CI / Test (push) Successful in 15s
CI / Lint & Typecheck (push) Failing after 17s
CI / Build & Push Docker Image (push) Has been skipped
CI / Lint & Typecheck (pull_request) Failing after 17s
CI / Test (pull_request) Successful in 13s
CI / Build & Push Docker Image (pull_request) Has been skipped
fix(ci): use Gitea registry for Docker push (#9)

GRO-1348

- Change Docker login from ghcr.io/GITHUB_TOKEN to git.farh.net/REGISTRY_TOKEN
- Update image tags from ghcr.io/groombook/web to git.farh.net/groombook/web
- Replace GitHub Actions cache with registry cache
2026-05-20 11:17:01 +00:00
Chris Farhood a582bd04b7 fix(ci): use Gitea registry for Docker push
CI / Lint & Typecheck (pull_request) Failing after 18s
CI / Test (pull_request) Successful in 15s
CI / Build & Push Docker Image (pull_request) Has been skipped
- Change Docker login from ghcr.io/GITHUB_TOKEN to git.farh.net/REGISTRY_TOKEN
- Update image tags from ghcr.io/groombook/web to git.farh.net/groombook/web
- Replace GitHub Actions cache (type=gha) with registry cache
- Remove GitHub Actions-specific permissions block
- GRO-1348

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-05-20 10:57:02 +00:00
Chris Farhood b8a9e8cc09 chore: add Renovate config
GRO-1081: add renovate.json to successor repos

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-05-14 17:42:22 +00:00
35 changed files with 1258 additions and 193 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' || '' }}
+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
+129
View File
@@ -53,6 +53,7 @@ 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). |
### 5.2 Authentication — VITE_API_URL Set
@@ -69,6 +70,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 +79,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 +125,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 +157,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
@@ -146,6 +184,29 @@ export const { signIn, signOut, useSession, changePassword } = authClient;
| 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)
| # | Scenario | Steps | Expected |
|---|----------|-------|----------|
| TC-WEB-5.12.5 | BookingFlow dynamic slots | Open Book New, select pet and service, pick a date | Time slots fetched from API; "Checking availability…" shown while loading |
| 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 | "Failed to load time slots" error shown |
| 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 | Time slots fetched from API; loading state shown |
| TC-WEB-5.12.10 | RescheduleFlow error state | Mock API failure on availability fetch | "Failed to load time slots" error shown |
| TC-WEB-5.12.11 | RescheduleFlow no slots | Select date with no availability | "No available slots on this date" shown |
#### 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.13 Reports UI
| # | Scenario | Steps | Expected |
@@ -259,6 +320,74 @@ export const { signIn, signOut, useSession, changePassword } = authClient;
| 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 and the `seed-uat-passwords` Secret in `groombook-uat` provides the seeded customer credentials (`uat-seed-password-source` memory).
- `POST /api/portal/session-from-auth` from [GRO-1866](https://paperclip.farhoodlabs.com/GRO/issues/GRO-1866) is deployed on UAT.
- Clear cookies and localStorage between cases unless otherwise noted.
| # | Scenario | Steps | Expected |
|---|----------|-------|----------|
| TC-WEB-5.25.1 | Authenticated customer reaches portal dashboard | 1. From clean state, navigate to UAT `/login`. 2. Click "Sign in with SSO" and complete Authentik flow with a seeded **customer** identity. 3. After callback, land on `/`. | Portal dashboard renders. No redirect to `/login`. No impersonation banner. Top-right greeting reads "Hi, &lt;FirstName&gt;". |
| TC-WEB-5.25.2 | Bridge call sequence | Repeat TC-WEB-5.25.1 with DevTools → Network open and the **All** tab filtered to `/api/`. | In order: `GET /api/auth/get-session` → 200. `POST /api/portal/session-from-auth` → 201 with body `{ sessionId, clientId, clientName }`. |
| TC-WEB-5.25.3 | Subsequent portal calls use the bridged session ID | After TC-WEB-5.25.1 succeeds, navigate to **Appointments**, **My Pets**, **Billing**, **Settings**. Inspect any `/api/portal/*` request in DevTools → Network. | Each portal API call carries an `X-Impersonation-Session-Id` header whose value equals the `sessionId` returned by `session-from-auth` (not a URL-param value). Each call returns 200 (or 404 for genuinely empty collections), never 401. |
| TC-WEB-5.25.4 | No impersonation chrome for the customer's own session | After TC-WEB-5.25.1, scan the portal UI. | No amber border around the page. No "STAFF VIEW" watermark. No "End Impersonation" button in the sidebar. The customer is themselves; only impersonation sessions started via `?sessionId=` show the banner. |
| TC-WEB-5.25.5 | 404 fallback for authenticated user with no client record | 1. Sign in via SSO with an Authentik account whose email is **not** present in `clients`. 2. Land on `/`. | `POST /api/portal/session-from-auth` returns 404. The portal renders a centred card titled **"Portal access not configured"** with the message about contacting the groomer and a **Sign out** button. No redirect loop, no portal chrome. |
| TC-WEB-5.25.6 | 404 fallback Sign-out escape hatch | From TC-WEB-5.25.5 click **Sign out**. | `POST /api/auth/sign-out` fires; browser navigates to `/login`; the Authentik session cookie is cleared. Reloading `/` no longer hits 404 (will show the login page). |
| 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.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. |
## 6. Pass/Fail Criteria
**Pass:**
@@ -1,9 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<Error>
<Code>AccessDenied</Code>
<Message>You have no right to access this object because of bucket acl.</Message>
<RequestId>69D96C853FAECD363909C4A0</RequestId>
<HostId>hailuo-image-algeng-data-us.oss-us-east-1.aliyuncs.com</HostId>
<EC>0003-00000001</EC>
<RecommendDoc>https://api.alibabacloud.com/troubleshoot?q=0003-00000001</RecommendDoc>
</Error>
@@ -1,9 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<Error>
<Code>AccessDenied</Code>
<Message>You have no right to access this object because of bucket acl.</Message>
<RequestId>69D96CFC84D7A9333708F278</RequestId>
<HostId>hailuo-image-algeng-data-us.oss-us-east-1.aliyuncs.com</HostId>
<EC>0003-00000001</EC>
<RecommendDoc>https://api.alibabacloud.com/troubleshoot?q=0003-00000001</RecommendDoc>
</Error>
@@ -1,9 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<Error>
<Code>AccessDenied</Code>
<Message>You have no right to access this object because of bucket acl.</Message>
<RequestId>69D96D48D7892E37386B9ACB</RequestId>
<HostId>hailuo-image-algeng-data-us.oss-us-east-1.aliyuncs.com</HostId>
<EC>0003-00000001</EC>
<RecommendDoc>https://api.alibabacloud.com/troubleshoot?q=0003-00000001</RecommendDoc>
</Error>
@@ -1,9 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<Error>
<Code>AccessDenied</Code>
<Message>You have no right to access this object because of bucket acl.</Message>
<RequestId>69D96C25663D703833F23607</RequestId>
<HostId>hailuo-image-algeng-data-us.oss-us-east-1.aliyuncs.com</HostId>
<EC>0003-00000001</EC>
<RecommendDoc>https://api.alibabacloud.com/troubleshoot?q=0003-00000001</RecommendDoc>
</Error>
@@ -1,9 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<Error>
<Code>AccessDenied</Code>
<Message>You have no right to access this object because of bucket acl.</Message>
<RequestId>69D96D89851C843332073968</RequestId>
<HostId>hailuo-image-algeng-data-us.oss-us-east-1.aliyuncs.com</HostId>
<EC>0003-00000001</EC>
<RecommendDoc>https://api.alibabacloud.com/troubleshoot?q=0003-00000001</RecommendDoc>
</Error>
@@ -1,9 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<Error>
<Code>AccessDenied</Code>
<Message>You have no right to access this object because of bucket acl.</Message>
<RequestId>69D96C9C5A03D33730C61AD8</RequestId>
<HostId>hailuo-image-algeng-data-us.oss-us-east-1.aliyuncs.com</HostId>
<EC>0003-00000001</EC>
<RecommendDoc>https://api.alibabacloud.com/troubleshoot?q=0003-00000001</RecommendDoc>
</Error>
@@ -1,9 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<Error>
<Code>AccessDenied</Code>
<Message>You have no right to access this object because of bucket acl.</Message>
<RequestId>69D96BEB91911B30317E3BE8</RequestId>
<HostId>hailuo-image-algeng-data-us.oss-us-east-1.aliyuncs.com</HostId>
<EC>0003-00000001</EC>
<RecommendDoc>https://api.alibabacloud.com/troubleshoot?q=0003-00000001</RecommendDoc>
</Error>
@@ -1,9 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<Error>
<Code>AccessDenied</Code>
<Message>You have no right to access this object because of bucket acl.</Message>
<RequestId>69D96BFB7B92D33535D6D90D</RequestId>
<HostId>hailuo-image-algeng-data-us.oss-us-east-1.aliyuncs.com</HostId>
<EC>0003-00000001</EC>
<RecommendDoc>https://api.alibabacloud.com/troubleshoot?q=0003-00000001</RecommendDoc>
</Error>
@@ -1,9 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<Error>
<Code>AccessDenied</Code>
<Message>You have no right to access this object because of bucket acl.</Message>
<RequestId>69D96B8BDF4B473630A2E120</RequestId>
<HostId>hailuo-image-algeng-data-us.oss-us-east-1.aliyuncs.com</HostId>
<EC>0003-00000001</EC>
<RecommendDoc>https://api.alibabacloud.com/troubleshoot?q=0003-00000001</RecommendDoc>
</Error>
@@ -1,9 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<Error>
<Code>AccessDenied</Code>
<Message>You have no right to access this object because of bucket acl.</Message>
<RequestId>69D96D78BFFCAD343037C27C</RequestId>
<HostId>hailuo-image-algeng-data-us.oss-us-east-1.aliyuncs.com</HostId>
<EC>0003-00000001</EC>
<RecommendDoc>https://api.alibabacloud.com/troubleshoot?q=0003-00000001</RecommendDoc>
</Error>
+10
View File
@@ -0,0 +1,10 @@
{
"$schema": "https://docs.renovatebot.com/renovate-schema.json",
"extends": ["config:recommended", ":pinAllExceptPeerDependencies", "helpers:pinGitHubActionDigests"],
"labels": ["dependencies"],
"prConcurrentLimit": 5,
"packageRules": [
{"matchUpdateTypes": ["minor", "patch"], "groupName": "minor and patch dependencies", "automerge": false},
{"matchDepTypes": ["devDependencies"], "matchUpdateTypes": ["minor", "patch"], "automerge": true, "automergeType": "pr"}
]
}
+11 -5
View File
@@ -327,11 +327,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")
@@ -386,9 +391,10 @@ 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 />;
}
+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) => {
+199 -1
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, CustomerNotesSection, ConfirmationSection, StatusBadge } from "../portal/sections/Appointments.tsx";
const UPCOMING_APPT = {
id: "appt-1",
@@ -379,4 +379,202 @@ 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");
});
});
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 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?date=2027-02-20",
expect.objectContaining({
headers: expect.objectContaining({ "X-Impersonation-Session-Id": "test-session-id" }),
})
);
});
});
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();
});
});
});
+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();
});
});
+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);
});
});
});
+246
View File
@@ -5,6 +5,22 @@ 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,
};
});
const SESSION: ImpersonationSession = {
id: "sess-1",
staffId: "staff-1",
@@ -313,3 +329,233 @@ 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();
});
const brandingResponse = {
ok: true,
json: async () => ({
businessName: "GroomBook",
primaryColor: "#4f8a6f",
accentColor: "#8b7355",
logoBase64: null,
logoMimeType: null,
}),
} as Response;
it("bridges Better Auth session via /api/portal/session-from-auth and uses returned sessionId", async () => {
global.fetch = vi.fn((input: RequestInfo, init?: RequestInit) => {
const url = typeof input === "string" ? input : input.toString();
if (url === "/api/branding") return Promise.resolve(brandingResponse);
if (url === "/api/auth/get-session") {
return Promise.resolve({
ok: true,
json: async () => ({ user: { email: "customer@example.com", role: "customer" } }),
} as Response);
}
if (url === "/api/portal/session-from-auth" && init?.method === "POST") {
return Promise.resolve({
ok: true,
status: 201,
json: async () => ({ sessionId: "sso-sess-1", clientId: "client-1", clientName: "Jane Doe" }),
} as Response);
}
// Subsequent portal API calls — surface them so we can assert the header
return Promise.resolve({ ok: true, json: async () => ({}) } as Response);
}) as unknown as typeof fetch;
const { CustomerPortal } = await import("../portal/CustomerPortal.js");
render(
<MemoryRouter initialEntries={["/"]}>
<CustomerPortal />
</MemoryRouter>
);
await waitFor(() => {
expect(global.fetch).toHaveBeenCalledWith("/api/auth/get-session", expect.objectContaining({ credentials: "include" }));
});
await waitFor(() => {
expect(global.fetch).toHaveBeenCalledWith(
"/api/portal/session-from-auth",
expect.objectContaining({ method: "POST", credentials: "include" })
);
});
// Client greeting reflects the bridged customer name (proof the response was consumed)
await waitFor(() => {
expect(screen.getByText(/Hi, Jane/)).toBeInTheDocument();
});
// The impersonation banner must NOT appear — this is the customer themselves
expect(screen.queryByRole("button", { name: /End Session/i })).not.toBeInTheDocument();
});
it("shows a friendly fallback when session-from-auth returns 404 (no client record)", async () => {
global.fetch = vi.fn((input: RequestInfo) => {
const url = typeof input === "string" ? input : input.toString();
if (url === "/api/branding") return Promise.resolve(brandingResponse);
if (url === "/api/auth/get-session") {
return Promise.resolve({
ok: true,
json: async () => ({ user: { email: "stranger@example.com", role: "customer" } }),
} as Response);
}
if (url === "/api/portal/session-from-auth") {
return Promise.resolve({
ok: false,
status: 404,
json: async () => ({ error: "No client record found for this user" }),
} as Response);
}
return Promise.resolve({ ok: true, json: async () => ({}) } as Response);
}) as unknown as typeof fetch;
const { CustomerPortal } = await import("../portal/CustomerPortal.js");
render(
<MemoryRouter initialEntries={["/"]}>
<CustomerPortal />
</MemoryRouter>
);
await waitFor(() => {
expect(screen.getByText(/Portal access not configured/i)).toBeInTheDocument();
});
expect(screen.getByText(/not linked to a customer record/i)).toBeInTheDocument();
// Sign-out escape hatch is present so the user is not stuck in a loop
expect(screen.getByRole("button", { name: /Sign out/i })).toBeInTheDocument();
});
it("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");
});
});
+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;
+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>
+102 -6
View File
@@ -43,6 +43,15 @@ export function CustomerPortal() {
// Track whether an impersonation session fetch from URL param is in-flight
// Dashboard will not redirect while this is true, allowing the session to load
const [isImpersonating, setIsImpersonating] = useState(false);
// Portal session ID for real SSO customers (GRO-1867). Populated by the
// Better Auth → /api/portal/session-from-auth bridge below. Carries the
// X-Impersonation-Session-Id header on subsequent portal API calls without
// triggering the impersonation banner (the customer is themselves).
const [portalSessionId, setPortalSessionId] = useState<string | null>(null);
// User-facing message when the SSO bridge cannot resolve a client record
// (e.g. authenticated user with no matching client row). Rendered in place
// of the portal chrome instead of bouncing back to /login.
const [authError, setAuthError] = useState<string | null>(null);
const { branding } = useBranding();
const [searchParams, setSearchParams] = useSearchParams();
@@ -98,10 +107,64 @@ export function CustomerPortal() {
}
})
.finally(() => setInitComplete(true));
} else {
// No valid session: staff dev users and unauthenticated users fall through here
setInitComplete(true);
return;
}
if (devUser && devUser.type === "staff") {
// Staff dev user — fall through; App.tsx redirects to /admin.
setInitComplete(true);
return;
}
// Real SSO customer (GRO-1867): bridge a Better Auth session into a portal
// session via POST /api/portal/session-from-auth. The returned session ID
// is used in the X-Impersonation-Session-Id header for portal API calls.
(async () => {
try {
const sessionResp = await fetch("/api/auth/get-session", { credentials: "include" });
if (!sessionResp.ok) {
setInitComplete(true);
return;
}
let sessionData: { user?: { email?: string; role?: string | null } } | null = null;
try {
sessionData = (await sessionResp.json()) as { user?: { email?: string; role?: string | null } } | null;
} catch {
// Better Auth returns an empty body when there is no session
}
if (!sessionData || !sessionData.user) {
setInitComplete(true);
return;
}
// Staff are routed to /admin by App.tsx; don't run the customer bridge.
if (sessionData.user.role === "staff") {
setInitComplete(true);
return;
}
const bridgeResp = await fetch("/api/portal/session-from-auth", {
method: "POST",
credentials: "include",
});
if (bridgeResp.ok) {
const data = await bridgeResp.json() as { sessionId: string; clientId: string; clientName: string };
setPortalSessionId(data.sessionId);
setClientName(data.clientName);
} else if (bridgeResp.status === 404) {
// Authenticated but no matching client row — show a friendly message
// instead of bouncing back to /login (which would loop indefinitely).
setAuthError(
"Your account is not linked to a customer record. Please contact your groomer to set up portal access."
);
}
// 401/other: fall through; App.tsx render guard will redirect to /login.
} catch {
// Network error — fall through; the render guard will redirect to /login.
} finally {
setInitComplete(true);
}
})();
}, []);
const handleEnd = useCallback(async () => {
@@ -157,7 +220,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} />;
@@ -183,7 +246,40 @@ export function CustomerPortal() {
// 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 (initComplete && !session && !portalSessionId) {
if (authError) {
// GRO-1867: graceful 404 fallback — authenticated user has no client row.
return (
<div
className="min-h-screen flex items-center justify-center bg-[#faf8f5] font-sans px-6"
role="alert"
aria-live="polite"
>
<div className="max-w-md w-full bg-white rounded-xl shadow-sm border border-stone-200 p-8 text-center">
<div className="w-12 h-12 rounded-full bg-amber-100 text-amber-700 flex items-center justify-center mx-auto mb-4">
<Shield size={22} />
</div>
<h1 className="text-lg font-semibold text-stone-800 mb-2">Portal access not configured</h1>
<p className="text-sm text-stone-600 mb-6">{authError}</p>
<button
onClick={async () => {
try {
await fetch("/api/auth/sign-out", { method: "POST", credentials: "include" });
} catch {
// Best-effort sign-out; redirect to /login regardless.
}
window.location.href = "/login";
}}
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 +326,7 @@ export function CustomerPortal() {
<RescheduleFlow
appointment={rescheduleAppointment}
onClose={() => { setShowReschedule(false); setRescheduleAppointment(null); }}
sessionId={session?.id ?? null}
sessionId={session?.id ?? portalSessionId}
/>
)}
+92 -32
View File
@@ -1,5 +1,6 @@
import React, { useState, useEffect } from 'react';
import { Calendar, Clock, Plus, ChevronRight, ChevronDown, Loader2 } from 'lucide-react';
import { ANALYTICS_EVENTS, fireAnalyticsEvent } from '../../lib/analytics';
export interface Appointment {
id: string;
@@ -82,14 +83,34 @@ export function isUpcoming(appt: Appointment): boolean {
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',
};
export function StatusBadge({ status }: { status: string }) {
const label = STATUS_LABELS[status] ?? status;
const colorClass = STATUS_COLORS[status] ?? 'bg-stone-100 text-stone-600';
return (
<span className={`px-2 py-0.5 rounded-full text-xs font-medium ${colorClass}`}>
{label}
</span>
);
}
const CONFIRMATION_STATUS_COLORS: Record<string, string> = {
confirmed: 'bg-green-100 text-green-700',
pending: 'bg-amber-100 text-amber-700',
@@ -297,13 +318,7 @@ function AppointmentCard({
<span>with {appt.groomerName || 'First Available'}</span>
</div>
</div>
<span
className={`px-2 py-0.5 rounded-full text-xs font-medium ${
STATUS_COLORS[appt.status] || ''
}`}
>
{appt.status}
</span>
<StatusBadge status={appt.status} />
{expanded ? (
<ChevronDown size={16} className="text-stone-400" />
) : (
@@ -573,16 +588,26 @@ 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([]);
return;
}
const params = new URLSearchParams({ date: selectedDate });
setSlotsLoading(true);
setSlotsError(null);
fetch(`/api/book/availability?${params.toString()}`, {
headers: { "X-Impersonation-Session-Id": sessionId ?? "" },
})
.then((r) => r.json() as Promise<string[]>)
.then(setAvailableTimes)
.catch(() => setSlotsError('Failed to load time slots'))
.finally(() => setSlotsLoading(false));
}, [selectedDate, sessionId]);
async function handleSubmit() {
if (!selectedDate || !selectedTime) return;
@@ -654,6 +679,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 +687,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)}
@@ -720,19 +751,34 @@ 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([]);
return;
}
const params = new URLSearchParams({ date: selectedDate });
setSlotsLoading(true);
setSlotsError(null);
fetch(`/api/book/availability?${params.toString()}`, {
headers: { "X-Impersonation-Session-Id": sessionId ?? "" },
})
.then((r) => r.json() as Promise<string[]>)
.then(setAvailableTimes)
.catch(() => setSlotsError('Failed to load time slots'))
.finally(() => setSlotsLoading(false));
}, [selectedDate, sessionId]);
useEffect(() => {
const fetchData = async () => {
@@ -801,6 +847,7 @@ function BookingFlow({ onClose, sessionId }: BookingFlowProps) {
if (response.ok) {
setConfirmed(true);
fireAnalyticsEvent(ANALYTICS_EVENTS.BOOKING_STEP_SUBMIT, { step: "submit", flow: "portal" });
setTimeout(() => {
window.location.reload();
}, 1500);
@@ -876,6 +923,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
@@ -1034,7 +1082,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 +1099,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 +1107,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)}
@@ -1093,7 +1150,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"
>
+14 -7
View File
@@ -1,5 +1,5 @@
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"];
@@ -9,15 +9,17 @@ 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);
@@ -305,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>
);
+23 -3
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}
/>
);
}
+2
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\//,