Compare commits
61 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| ddc4e3e052 | |||
| 7a8b59ab87 | |||
| 250c7a5ac9 | |||
| dee7465190 | |||
| 66bac2c6f8 | |||
| 044eeaae61 | |||
| 59a29a2d03 | |||
| c58e4e4b23 | |||
| 98c8a7bb83 | |||
| 1ceac35437 | |||
| 3d0c3c551b | |||
| c7417dc9e3 | |||
| 0d52ddd9f0 | |||
| 2b494c01f8 | |||
| 3397767a01 | |||
| f0c58c193c | |||
| 4600dcf950 | |||
| 7daa9c480a | |||
| 746fad635f | |||
| f1cf58dc56 | |||
| 903ce2d675 | |||
| f29f1828c8 | |||
| 3d7b247562 | |||
| 198053fa31 | |||
| 228a3d746c | |||
| ad9a178c89 | |||
| 9a3b5d88c8 | |||
| 4e487db6f1 | |||
| 736535a24c | |||
| 33a1b3ed7a | |||
| 65686c8563 | |||
| 112c61ab1c | |||
| 106d31a95e | |||
| 7e5a851d9c | |||
| 88ba9915c6 | |||
| 26cdd69a49 | |||
| 3bccb1ac01 | |||
| 2e99ed520f | |||
| a873369a9b | |||
| d78c859c2b | |||
| 344a32e3e4 | |||
| b630b40c92 | |||
| db892409ef | |||
| c83214cf42 | |||
| 80101fc37c | |||
| 8ee58471b2 | |||
| 35d31a984d | |||
| f62c0b112d | |||
| 034f4ab295 | |||
| f958dbdb4f | |||
| 837b5f6d8a | |||
| 889e1e26ae | |||
| f1bb7c4fa6 | |||
| 56b11befe9 | |||
| f70dd96c65 | |||
| 42f3e3211a | |||
| 465db89ab4 | |||
| ee7fc2e9bf | |||
| c8610ec28d | |||
| a582bd04b7 | |||
| b8a9e8cc09 |
@@ -2,9 +2,9 @@ name: CI
|
|||||||
|
|
||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
branches: [main, dev]
|
branches: [main, dev, uat]
|
||||||
pull_request:
|
pull_request:
|
||||||
branches: [main, dev]
|
branches: [main, dev, uat]
|
||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
inputs:
|
inputs:
|
||||||
ref:
|
ref:
|
||||||
@@ -78,6 +78,8 @@ jobs:
|
|||||||
|
|
||||||
- name: Set up Docker Buildx
|
- name: Set up Docker Buildx
|
||||||
uses: docker/setup-buildx-action@v3
|
uses: docker/setup-buildx-action@v3
|
||||||
|
with:
|
||||||
|
driver-opts: network=host
|
||||||
|
|
||||||
- name: Log in to Gitea Container Registry
|
- name: Log in to Gitea Container Registry
|
||||||
uses: docker/login-action@v3
|
uses: docker/login-action@v3
|
||||||
@@ -92,6 +94,7 @@ jobs:
|
|||||||
context: .
|
context: .
|
||||||
file: Dockerfile
|
file: Dockerfile
|
||||||
push: true
|
push: true
|
||||||
|
provenance: false
|
||||||
tags: |
|
tags: |
|
||||||
git.farh.net/groombook/web:${{ steps.version.outputs.tag }}
|
git.farh.net/groombook/web:${{ steps.version.outputs.tag }}
|
||||||
${{ github.ref == 'refs/heads/main' && 'git.farh.net/groombook/web:latest' || '' }}
|
${{ github.ref == 'refs/heads/main' && 'git.farh.net/groombook/web:latest' || '' }}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
{
|
||||||
|
"mcpServers": {
|
||||||
|
"gitea": {
|
||||||
|
"type": "http",
|
||||||
|
"url": "https://git-mcp.farh.net/mcp",
|
||||||
|
"headers": {
|
||||||
|
"Authorization": "Bearer ${GITEA_TOKEN}"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+1
-1
@@ -18,4 +18,4 @@ COPY nginx.conf /etc/nginx/conf.d/default.conf
|
|||||||
COPY --from=builder /app/dist /usr/share/nginx/html
|
COPY --from=builder /app/dist /usr/share/nginx/html
|
||||||
EXPOSE 80
|
EXPOSE 80
|
||||||
HEALTHCHECK --interval=30s --timeout=5s --start-period=5s --retries=3 \
|
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
|
||||||
+286
-1
@@ -53,6 +53,9 @@ export const { signIn, signOut, useSession, changePassword } = authClient;
|
|||||||
| TC-WEB-5.1.2 | OIDC redirect | Click OIDC login button | Redirected to OIDC provider, then back to app with session established |
|
| TC-WEB-5.1.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.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.4 | Session indicator | After successful login | User info/initials visible in UI indicating active session |
|
||||||
|
| TC-WEB-5.1.5 | Unauthenticated `/login` renders the form (GRO-2011) | In a private/incognito window with no session cookie, navigate to UAT `/login` | React root mounts; the GroomBook sign-in card with the OIDC button is visible. Network tab shows `/api/auth/get-session` 200, `/api/setup/status` 200, and the login form is rendered (NOT a blank white viewport). |
|
||||||
|
| TC-WEB-5.1.6 | Swallowed render error surfaces in DOM (GRO-2094) | Trigger a render-time exception in the React tree (e.g. via temporary throw in a child component on a test build) and load `/login` in a clean context | Either the login form renders normally (happy path) OR the top-level `ErrorBoundary` testid `error-boundary` is visible with a populated `error-boundary-message` pre block showing the exception name/message/stack. **NEVER** a blank `<div id="root">` with no error indicator. Browser console must contain either zero render errors or a `[ErrorBoundary]` line plus the raw exception. |
|
||||||
|
| TC-WEB-5.1.7 | Global `error` and `unhandledrejection` listeners are wired (GRO-2094) | In a clean browser context, load `/login`, then trigger `setTimeout(() => { throw new Error("synthetic") }, 0)` from the console and `Promise.reject(new Error("synthetic-promise"))` | Browser console shows `[window.error]` and `[unhandledrejection]` log lines with the thrown values. Confirms global listeners are active in production. |
|
||||||
|
|
||||||
### 5.2 Authentication — VITE_API_URL Set
|
### 5.2 Authentication — VITE_API_URL Set
|
||||||
|
|
||||||
@@ -69,6 +72,7 @@ export const { signIn, signOut, useSession, changePassword } = authClient;
|
|||||||
| TC-AUTH-5.3.1 | Auth client falls back to window.location.origin | Do not set `VITE_API_URL`, load app | Auth client uses `window.location.origin` as base URL |
|
| TC-AUTH-5.3.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.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.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
|
### 5.4 Session Persistence
|
||||||
|
|
||||||
@@ -77,6 +81,26 @@ export const { signIn, signOut, useSession, changePassword } = authClient;
|
|||||||
| TC-AUTH-5.4.1 | Session persists across page reload | Sign in, reload page | Session remains active |
|
| TC-AUTH-5.4.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 |
|
| 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
|
### 5.5 Dashboard
|
||||||
|
|
||||||
| # | Scenario | Steps | Expected |
|
| # | Scenario | Steps | Expected |
|
||||||
@@ -103,6 +127,20 @@ export const { signIn, signOut, useSession, changePassword } = authClient;
|
|||||||
| TC-WEB-5.7.2 | Add pet | Click "Add Pet", fill form, submit | Pet created and linked to client |
|
| TC-WEB-5.7.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.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.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
|
### 5.8 Appointment Scheduling UI
|
||||||
|
|
||||||
@@ -121,6 +159,8 @@ export const { signIn, signOut, useSession, changePassword } = authClient;
|
|||||||
| TC-WEB-5.9.1 | Service catalog loads | Navigate to Services | List of available services displayed |
|
| TC-WEB-5.9.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.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.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
|
### 5.10 Staff Management UI
|
||||||
|
|
||||||
@@ -142,10 +182,108 @@ export const { signIn, signOut, useSession, changePassword } = authClient;
|
|||||||
| # | Scenario | Steps | Expected |
|
| # | Scenario | Steps | Expected |
|
||||||
|---|----------|-------|----------|
|
|---|----------|-------|----------|
|
||||||
| TC-WEB-5.12.1 | Client-facing view | Log in as client persona | Customer portal UI displayed |
|
| TC-WEB-5.12.1 | Client-facing view | Log in as client persona | Customer portal UI displayed |
|
||||||
| TC-WEB-5.12.2 | Appointment list | View client portal appointments | List of client's appointments visible |
|
| TC-WEB-5.12.2 | Appointment list | View client portal appointments | List of client's appointments visible — each card shows pet name, service, formatted date/time, and groomer (no "Failed to load appointments" error, no blank screen). "Book New" button is visible and clickable. See 5.12d. |
|
||||||
| TC-WEB-5.12.3 | Confirm appointment | Click confirm on pending appointment | Appointment status updated to confirmed |
|
| TC-WEB-5.12.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 |
|
| TC-WEB-5.12.4 | Cancel appointment | Click cancel on appointment | Appointment marked as cancelled |
|
||||||
|
|
||||||
|
#### 5.12b Dynamic Portal Time Slots (GRO-1793, GRO-2105)
|
||||||
|
|
||||||
|
| # | Scenario | Steps | Expected |
|
||||||
|
|---|----------|-------|----------|
|
||||||
|
| TC-WEB-5.12.5 | BookingFlow dynamic slots | Open Book New, select pet and service, pick a date | `GET /api/book/availability?serviceId=<selected>&date=<picked>`; "Checking availability…" shown while loading; slot list rendered |
|
||||||
|
| TC-WEB-5.12.6 | BookingFlow slots match wizard | Compare BookingFlow slot times with public booking wizard for same date | Same slots displayed |
|
||||||
|
| TC-WEB-5.12.7 | BookingFlow error state | Mock API failure on availability fetch (4xx/5xx OR a 200 with non-array body) | "Failed to load time slots" error shown and the page stays interactive (no white screen) |
|
||||||
|
| TC-WEB-5.12.8 | BookingFlow no slots | Select date with no availability | "No available slots on this date" shown |
|
||||||
|
| TC-WEB-5.12.9 | RescheduleFlow dynamic slots | Open reschedule, pick a new date | `GET /api/book/availability?serviceId=<appt.serviceId>&date=<picked>`; loading state shown; slot list rendered |
|
||||||
|
| TC-WEB-5.12.10 | RescheduleFlow error state | Mock API failure on availability fetch (4xx/5xx OR a 200 with non-array body) | "Failed to load time slots" error shown and the page stays interactive (no white screen) |
|
||||||
|
| TC-WEB-5.12.11 | RescheduleFlow no slots | Select date with no availability | "No available slots on this date" shown |
|
||||||
|
|
||||||
|
> **GRO-2105 regression note:** prior to the fix, both `BookingFlow` and
|
||||||
|
> `RescheduleFlow` called `/api/book/availability` with only `date=…`, so the
|
||||||
|
> API responded 400 `{error:"serviceId and date are required"}`. The React
|
||||||
|
> handler then `.map()`'d that error object, throwing `TypeError: ee.map is
|
||||||
|
> not a function` and wiping `<div id="root">`. The fix ensures both flows
|
||||||
|
> include `serviceId` in the query string and surface the API's error string
|
||||||
|
> (or "Failed to load time slots") instead of crashing.
|
||||||
|
|
||||||
|
#### 5.12c Waitlist/Booking Status Badges (GRO-1795)
|
||||||
|
|
||||||
|
| # | Scenario | Steps | Expected |
|
||||||
|
|---|----------|-------|----------|
|
||||||
|
| TC-WEB-5.12.12 | Confirmed badge | View appointment card with confirmed status | Green "Confirmed" badge displayed |
|
||||||
|
| TC-WEB-5.12.13 | Pending badge | View appointment card with pending status | Amber "Pending" badge displayed |
|
||||||
|
| TC-WEB-5.12.14 | Waitlisted badge | View appointment card with waitlisted status | Blue "Waitlisted" badge displayed |
|
||||||
|
| TC-WEB-5.12.15 | Badge uses CSS classes | Inspect badge element | Badge uses CSS variable-based classes (e.g., bg-green-100, text-amber-600), not hardcoded colors |
|
||||||
|
| TC-WEB-5.12.16 | Badge status from data | Compare badge label to appointment.status field | Badge label matches the API appointment status exactly |
|
||||||
|
| TC-WEB-5.12.17 | Unknown status fallback | Render badge with unknown status value | Badge renders with the raw status string as label and fallback CSS class |
|
||||||
|
|
||||||
|
#### 5.12f Live StatusBadge palette — no-show / pending / waitlisted (GRO-2319)
|
||||||
|
|
||||||
|
These cases exercise the full StatusBadge palette as it is now produced live by
|
||||||
|
the seeded UAT customer (`uat-customer@groombook.dev`), not just unit-rendered.
|
||||||
|
|
||||||
|
| # | Scenario | Steps | Expected |
|
||||||
|
|---|----------|-------|----------|
|
||||||
|
| TC-WEB-5.12.26 | No-show badge (item 1) | Sign in as `uat-customer@groombook.dev`, open `Appointments` → **Past** tab, find the seeded `no_show` appointment | A styled yellow **"No-show"** badge renders (`bg-yellow-100 text-yellow-700`) — **not** a raw gray `no_show` label. The DB `no_show` (underscore) status is normalized to the `no-show` palette key. |
|
||||||
|
| TC-WEB-5.12.27 | Pending derivation (item 2) | On the **Upcoming** tab, find the seeded upcoming appointment whose `confirmationStatus` is `pending` (groomer-unconfirmed) | The card's top-row badge reads amber **"Pending"** (derived from `confirmationStatus`), even though the raw appointment status is `scheduled`. |
|
||||||
|
| TC-WEB-5.12.28 | Confirmed not overridden | On the **Upcoming** tab, find the seeded confirmed appointment (`confirmationStatus = confirmed`) | Badge still reads green **"Confirmed"** — the pending derivation does not override a confirmed appointment. |
|
||||||
|
| TC-WEB-5.12.29 | Waitlisted card (item 2) | On the **Upcoming** tab, find the seeded waitlist entry for the customer | A card renders with a blue **"Waitlisted"** badge, a **dashed muted border**, and the subtext _"You're on the waitlist — we'll let you know if a spot opens."_ The Confirm / Reschedule / Cancel / Notes actions are **not** shown for this entry (it is not a booked appointment). |
|
||||||
|
|
||||||
|
> **GRO-2319 note:** the DB `appointment_status` enum cannot represent `pending`
|
||||||
|
> or `waitlisted`, so those badges are derived in the portal: `pending` from an
|
||||||
|
> upcoming appointment's `confirmationStatus`, and `waitlisted` from active
|
||||||
|
> `waitlist_entries` surfaced by `GET /api/portal/appointments` as synthetic
|
||||||
|
> cards. The `no_show` → `no-show` key normalization fixes the cosmetic badge
|
||||||
|
> mismatch (item 1).
|
||||||
|
|
||||||
|
#### 5.12d Appointment API Shape Normalization (GRO-2180)
|
||||||
|
|
||||||
|
| # | Scenario | Steps | Expected |
|
||||||
|
|---|----------|-------|----------|
|
||||||
|
| TC-WEB-5.12.18 | Portal appointments load (regression) | Sign in as `uat-customer@groombook.dev`, open `Appointments` | List renders without the "Failed to load appointments. Please try again." error; "Book New" button is visible and clickable |
|
||||||
|
| TC-WEB-5.12.19 | Card fields populated from API | Inspect an appointment card | Pet name, service, formatted date (e.g. "Mon, Jun 1, 2026"), time (e.g. "10:00 AM"), and groomer name render — derived from the API's `startTime`/`endTime`/nested `pet`/`staff` objects |
|
||||||
|
| TC-WEB-5.12.20 | Upcoming vs Past split | View both tabs | Future, non-cancelled/non-completed appointments appear under "Upcoming"; past/completed/cancelled under "Past" (classification uses absolute `startTime`) |
|
||||||
|
| TC-WEB-5.12.21 | Reschedule from card | Expand an upcoming appointment, click Reschedule, pick a date | `GET /api/book/availability?serviceId=<appt.serviceId>&date=<picked>` fires with a non-empty `serviceId` (sourced from the API's nested `service.id`) |
|
||||||
|
|
||||||
|
> **GRO-2180 regression note:** `/api/portal/appointments` returns ISO
|
||||||
|
> `startTime`/`endTime` and nested `pet`/`service`/`staff` objects, but the portal
|
||||||
|
> client `Appointment` type expected flat `date`/`time`/`petName` fields.
|
||||||
|
> `isUpcoming()` read `appt.date`/`appt.time` (both `undefined`), so
|
||||||
|
> `parseTimeTo24Hour(undefined)` threw `TypeError`, the `useEffect` `try/catch`
|
||||||
|
> set the error state, and the "Book New" button (only rendered in the success
|
||||||
|
> path) became unreachable. The fix normalizes the API response into the flat
|
||||||
|
> `Appointment` shape at the fetch boundary (`normalizeAppointment`), prefers the
|
||||||
|
> absolute `startTime` in `isUpcoming`, and hardens `parseTimeTo24Hour` against
|
||||||
|
> blank/undefined input.
|
||||||
|
|
||||||
|
#### 5.12e Book New `preferredTime` Formatting (GRO-2211, GRO-2213)
|
||||||
|
|
||||||
|
| # | Scenario | Steps | Expected |
|
||||||
|
|---|----------|-------|----------|
|
||||||
|
| TC-WEB-5.12.22 | Slot buttons show formatted label | Sign in as `uat-customer@groombook.dev`, open `Appointments`, click "Book New", select a pet and service, pick a date with availability | Each time-slot button shows a human-readable label like `10:00 AM` (UTC), never a raw ISO timestamp (e.g. not `2026-06-09T10:00:00.000Z`) |
|
||||||
|
| TC-WEB-5.12.23 | Confirmation review shows formatted label | Continue the Book New wizard to the Review step | The "Date & Time" summary and the final confirmation both display the formatted slot label (e.g. `10:00 AM`), not a raw ISO string |
|
||||||
|
| TC-WEB-5.12.24 | Booking submit succeeds (regression) | Complete the Book New wizard and submit the request | Request succeeds with no `500` / `invalid input syntax for type time` error; the booking POST sends `preferredTime` as `HH:MM:SS` (e.g. `10:00:00`); the new appointment appears in the Upcoming list |
|
||||||
|
| TC-WEB-5.12.25 | Slow-wizard submit succeeds (GRO-2234) | Sign in as `uat-customer@groombook.dev`, open `Appointments`, click "Book New", then deliberately pace the wizard (pet → service → groomer → date/slot → review) so that **>2 minutes** elapse before clicking "Confirm Booking". | Submit returns success — **no** "Failed to book appointment. Please try again." error. In DevTools → Network, if the first `POST /api/portal/waitlist` returns `401`, a `POST /api/portal/session-from-auth` fires immediately after and the booking is retried once with the fresh `X-Impersonation-Session-Id`, then returns 201. The appointment appears in the Upcoming list. |
|
||||||
|
|
||||||
|
> **GRO-2234 note:** A deliberately-paced Book New wizard could outlive the
|
||||||
|
> portal impersonation session, so the final `POST /api/portal/waitlist` returned
|
||||||
|
> `401 {"error":"Unauthorized"}` ("Failed to book appointment"). The web fix adds
|
||||||
|
> a transparent one-shot re-mint: on a `401` from the waitlist submit,
|
||||||
|
> `BookingFlow` calls `POST /api/portal/session-from-auth` (the Better Auth
|
||||||
|
> cookie is still valid) and retries the submit once with the fresh session id.
|
||||||
|
> The companion API fix (groombook/api GRO-2234) adds bounded sliding expiration
|
||||||
|
> so active sessions rarely lapse in the first place.
|
||||||
|
|
||||||
|
> **GRO-2211/GRO-2213 note:** The Book New wizard previously rendered the raw
|
||||||
|
> UTC ISO slot string as the button/confirmation label and submitted that same
|
||||||
|
> ISO value as `preferredTime`, which the API rejected with
|
||||||
|
> `invalid input syntax for type time` (HTTP 500). The fix adds shared UTC
|
||||||
|
> helpers `formatSlotLabel(slot)` (display → `10:00 AM`) and `slotToTime(slot)`
|
||||||
|
> (payload → `HH:MM:SS`) in `src/portal/sections/Appointments.tsx`, so the
|
||||||
|
> displayed label and the submitted `preferredTime` both derive from the same
|
||||||
|
> canonical UTC ISO slot. (The sibling `RescheduleFlow` `startTime` raw-ISO issue
|
||||||
|
> on a different endpoint is tracked separately and is out of scope here.)
|
||||||
|
|
||||||
### 5.13 Reports UI
|
### 5.13 Reports UI
|
||||||
|
|
||||||
| # | Scenario | Steps | Expected |
|
| # | Scenario | Steps | Expected |
|
||||||
@@ -259,6 +397,153 @@ 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.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 |
|
| TC-WEB-5.22.7 | Clear size | Select size, then clear back to default | Size cleared on save |
|
||||||
|
|
||||||
|
### 5.23 Pet Profile — API Persistence & Save UX (GRO-1470)
|
||||||
|
|
||||||
|
| # | Scenario | Steps | Expected |
|
||||||
|
|---|----------|-------|----------|
|
||||||
|
| TC-WEB-5.23.1 | Save pet — API persistence | Edit a pet, change a field (e.g. coat type), click Save, reload the page | Changed field retained after reload (proves PATCH round-trip to server) |
|
||||||
|
| TC-WEB-5.23.2 | Save pet — error state | Trigger an API save failure (e.g. network error) | Error message displayed; edit form stays open; no data cleared |
|
||||||
|
| TC-WEB-5.23.3 | Save pet — saving indicator | Click Save | Spinner/indicator shown while request is in flight; form controls disabled |
|
||||||
|
|
||||||
|
|
||||||
|
### 5.24 Booking Funnel Analytics Events (GRO-1794)
|
||||||
|
|
||||||
|
|
||||||
|
| # | Scenario | Steps | Expected |
|
||||||
|
|---|----------|-------|----------|
|
||||||
|
| TC-WEB-5.24.1 | booking_step_service — public | Select a service in the public booking wizard | `booking_step_service` CustomEvent fires with detail.step="service" and detail.flow="public" |
|
||||||
|
| TC-WEB-5.24.2 | booking_step_time — public | Select a time slot and click Continue | `booking_step_time` fires with detail.step="time" and detail.flow="public" |
|
||||||
|
| TC-WEB-5.24.3 | booking_step_contact — public | Fill in contact/pet form, click "Review booking" | `booking_step_contact` fires with detail.step="contact" and detail.flow="public" |
|
||||||
|
| TC-WEB-5.24.4 | booking_step_submit — public | Confirm and submit the booking | `booking_step_submit` fires with detail.step="submit" and detail.flow="public" |
|
||||||
|
| TC-WEB-5.24.5 | booking_confirmed — public | Navigate to /booking-confirmed | `booking_confirmed` fires once on mount with detail.step="confirmed" and detail.flow="public" |
|
||||||
|
| TC-WEB-5.24.6 | booking_error — public | Navigate to /booking-error | `booking_error` fires once on mount with detail.step="error" and detail.flow="public" |
|
||||||
|
| TC-WEB-5.24.7 | booking_step_service — portal | Select a pet in the portal BookingFlow | `booking_step_service` fires with detail.step="service" and detail.flow="portal" |
|
||||||
|
| TC-WEB-5.24.8 | booking_step_time — portal | Pick a date and time in portal BookingFlow | `booking_step_time` fires with detail.step="time" and detail.flow="portal" |
|
||||||
|
| TC-WEB-5.24.9 | booking_step_contact — portal | Proceed from groomer selection to review screen | `booking_step_contact` fires with detail.step="groomer" and detail.flow="portal" |
|
||||||
|
| TC-WEB-5.24.10 | booking_step_submit — portal | Submit booking in portal BookingFlow | `booking_step_submit` fires with detail.step="submit" and detail.flow="portal" |
|
||||||
|
| TC-WEB-5.24.11 | booking_confirmed — portal | Portal booking request succeeds | Inline success state is shown and `booking_confirmed` fires with detail.step="confirmed" and detail.flow="portal" |
|
||||||
|
| TC-WEB-5.24.12 | No PII in analytics payloads | Fire each event and inspect detail object | Payload contains only: step, flow, timestamp — no names, emails, phone numbers, or pet names |
|
||||||
|
| TC-WEB-5.24.13 | No-op safe | Trigger analytics with window.dispatchEvent blocked (e.g. CSP) | No error thrown; booking flow completes normally |
|
||||||
|
|
||||||
|
### 5.25 Customer Portal — Better Auth SSO Bridge (GRO-1867)
|
||||||
|
|
||||||
|
These cases cover the `CustomerPortal` initialisation path that bridges an Authentik / Better Auth session into a portal session via `POST /api/portal/session-from-auth`. The bridge runs after the URL-impersonation (`?sessionId=`) and dev-user paths have been ruled out.
|
||||||
|
|
||||||
|
**Pre-conditions:**
|
||||||
|
|
||||||
|
- UAT is configured with Authentik SSO. The seeded customer **Authentik** password lives in the `authentik-uat-users-credentials` Secret in the `groombook-uat` namespace (key `uat_customer_password`) — **NOT** in `seed-uat-passwords:customer-password` (that Secret holds the *Better Auth* email+password credential, a separate identity store; see GRO-2089). Pull the Authentik password at the start of every run:
|
||||||
|
```bash
|
||||||
|
CUSTOMER_AUTHENTIK=$(kubectl get secret authentik-uat-users-credentials -n groombook-uat \
|
||||||
|
-o jsonpath='{.data.uat_customer_password}' | base64 -d)
|
||||||
|
```
|
||||||
|
The Authentik user is provisioned by Terraform (`infra/terraform/users.tf`); the `lifecycle.ignore_changes = [password]` block means the password is set on initial creation and never auto-rotated, so the value held in the live Secret is the one Authentik itself has. If Authentik rejects it, the user was re-provisioned out-of-band via the Authentik admin UI and the Secret has drifted from the live identity — fix the Secret (or the admin-set password) and re-run.
|
||||||
|
- `POST /api/portal/session-from-auth` from [GRO-1866](https://paperclip.farhoodlabs.com/GRO/issues/GRO-1866) is deployed on UAT.
|
||||||
|
- Clear cookies and localStorage between cases unless otherwise noted.
|
||||||
|
|
||||||
|
| # | Scenario | Steps | Expected |
|
||||||
|
|---|----------|-------|----------|
|
||||||
|
| TC-WEB-5.25.1 | Authenticated customer reaches portal dashboard | 1. From clean state, navigate to UAT `/login`. 2. Click "Sign in with SSO" and complete Authentik flow with a seeded **customer** identity. 3. After callback, land on `/`. | Portal dashboard renders. No redirect to `/login`. No impersonation banner. Top-right greeting reads "Hi, <FirstName>". |
|
||||||
|
| TC-WEB-5.25.2 | Bridge call sequence | Repeat TC-WEB-5.25.1 with DevTools → Network open and the **All** tab filtered to `/api/`. | In order: `GET /api/auth/get-session` → 200. `POST /api/portal/session-from-auth` → 201 with body `{ sessionId, clientId, clientName }`. |
|
||||||
|
| TC-WEB-5.25.3 | Subsequent portal calls use the bridged session ID | After TC-WEB-5.25.1 succeeds, navigate to **Appointments**, **My Pets**, **Billing**, **Settings**. Inspect any `/api/portal/*` request in DevTools → Network. | Each portal API call carries an `X-Impersonation-Session-Id` header whose value equals the `sessionId` returned by `session-from-auth` (not a URL-param value). Each call returns 200 (or 404 for genuinely empty collections), never 401. |
|
||||||
|
| TC-WEB-5.25.4 | No impersonation chrome for the customer's own session | After TC-WEB-5.25.1, scan the portal UI. | No amber border around the page. No "STAFF VIEW" watermark. No "End Impersonation" button in the sidebar. The customer is themselves; only impersonation sessions started via `?sessionId=` show the banner. |
|
||||||
|
| TC-WEB-5.25.5 | 404 from SSO bridge routes to OOBE (GRO-2359) | 1. Sign in via SSO with an Authentik account whose email is **not** present in `clients`. 2. Land on `/`. | `POST /api/portal/session-from-auth` returns 404. The post-auth handler mounts the **OOBE** (`src/portal/OOBE.tsx`) — a centred card titled **"Welcome — let's set up your portal"** with form fields for name (prefilled from the Better Auth session), phone, address, and notes. The legacy "Portal access not configured" card is **not** rendered on the new-user path. No redirect loop, no portal chrome. |
|
||||||
|
| TC-WEB-5.25.6 | OOBE form submission creates the portal (GRO-2359) | From TC-WEB-5.25.5, fill in the OOBE form and click **Create my portal**. | `POST /api/portal/clients-from-auth` is called with `{ name, phone, address, notes }`; the email is taken from the Better Auth session (the API binds the new client row to the SSO identity). The page reloads to `/`, the bridge re-runs, and the user lands in their portal dashboard. DevTools → Network shows `POST /api/portal/clients-from-auth` → 201 followed by `POST /api/portal/session-from-auth` → 201. |
|
||||||
|
| TC-WEB-5.25.6b | OOBE handles portal selection (409 from clients-from-auth) (GRO-2359) | 1. Sign in via SSO with an email that already exists in `clients` (e.g. a previously deleted-then-recreated account). 2. Land on OOBE. 3. Click **Create my portal**. | The API returns 409 "A customer record with this email already exists". The OOBE re-enables the submit button and shows the portal-selection message: "A customer record with this email already exists. Please contact your groomer to link your account." The shared signOut() button remains reachable so the user can exit if needed. |
|
||||||
|
| TC-WEB-5.25.6c | OOBE uses the shared signOut() handler (GRO-2358, GRO-2359) | From TC-WEB-5.25.5, click **Sign out** in the OOBE footer. | The same shared `signOut()` from `lib/auth-client` fires (same handler as `AdminLayout` and the no-access card); browser navigates to `/login`; the Authentik session cookie is cleared. The handler always navigates to `/login` — even if the network call to `/api/auth/sign-out` fails — so a transient auth-server hiccup never leaves the user trapped on an authenticated screen. |
|
||||||
|
| TC-WEB-5.25.6d | OOBE is mountable from a direct deep-link (GRO-2359) | 1. Sign in via SSO as any customer. 2. In a new tab, navigate to `https://uat.groombook.dev/onboarding`. | The OOBE form mounts (the App.tsx `/onboarding` route resolves before the CustomerPortal `!sessionId` guards). The submit, signOut, and field-validation behaviour are identical to the post-auth mount. |
|
||||||
|
| TC-WEB-5.25.6e | Deleted-portal deep-link still reaches the no-access card (GRO-2358, GRO-2359) | 1. Sign in via SSO as a customer whose `clients` row was disabled/deleted by the groomer. 2. Land on a portal sub-route with `?noAccess=deleted-portal` (e.g. visit `https://uat.groombook.dev/appointments?noAccess=deleted-portal` directly). | The no-access card renders (the deep-link deleted-portal case — the OOBE is reserved for first-time creation). The shared signOut() from GRO-2358 is wired identically. This proves the no-access card is still reachable for non-new-user failure modes and the CMPO "no-trap" invariant holds across the auth boundary. |
|
||||||
|
| TC-WEB-5.25.6f | In-portal chrome sidebar exposes a Sign out button (GRO-2373) | 1. Complete TC-WEB-5.25.1 (SSO sign-in as a customer). 2. From the portal chrome, look at the sidebar footer (the section below the navigation links, where "Customer Portal v1.0" sits). 3. Locate the **Sign out** button (a stone-grey button above the version label, with a LogOut icon). 4. Click it. | A **Sign out** button is present in the sidebar footer (not buried in the Settings page, not hidden in a dropdown — it's visible on every portal sub-route, including Home, Appointments, My Pets, Report Cards, Billing, Messages, Settings). Clicking it fires the same shared `signOut()` from `lib/auth-client` (same handler as the OOBE footer, the no-access card, and `AdminLayout`'s top-bar "Logout"); `POST /api/auth/sign-out` → 200 `{"success":true}`; the browser navigates to `/login`; the Better Auth / Authentik session cookie is cleared. Proves the CMPO "no-trap" invariant (originally established in GRO-2355) holds on the third authenticated surface — the in-portal chrome — which the GRO-2358 P1 fix did not cover. |
|
||||||
|
| TC-WEB-5.25.7 | Bridge precedence — impersonation URL wins | 1. Sign in via SSO as a customer. 2. Open a new tab to `https://uat.groombook.dev/?sessionId=<a-valid-staff-impersonation-session-id>`. | The impersonation path runs; the amber banner appears for the impersonated client. The Better Auth bridge is **not** called on this load (`session-from-auth` absent in Network). |
|
||||||
|
| TC-WEB-5.25.8 | Bridge precedence — dev user wins | In dev mode (e.g. local) with `localStorage["dev-user"]` set to a client persona, navigate to `/`. | The dev-session path runs (`POST /api/portal/dev-session`). The Better Auth bridge is **not** called (`session-from-auth` absent in Network). Staff dev users still redirect to `/admin`. |
|
||||||
|
| TC-WEB-5.25.9 | Staff Better Auth session does not run the customer bridge | Sign in via SSO with a staff identity. Navigate to `/`. | `App.tsx` routing redirects to `/admin`. `POST /api/portal/session-from-auth` is **not** called. |
|
||||||
|
| TC-WEB-5.25.10 | Unauthenticated user is sent to login (no infinite loop) | Without signing in, navigate directly to `/`. | `App.tsx` renders the LoginPage. `CustomerPortal` does not render. No `session-from-auth` request is made. |
|
||||||
|
| TC-WEB-5.25.11 | Session persists across reload via Better Auth cookie | After TC-WEB-5.25.1 succeeds, reload the page. | Portal dashboard re-renders. A fresh `GET /api/auth/get-session` + `POST /api/portal/session-from-auth` pair runs and yields 200/201. Greeting still reads "Hi, <FirstName>". |
|
||||||
|
|
||||||
|
### 5.27 Customer Portal — Authenticated HTML-route cold mount (GRO-2099)
|
||||||
|
|
||||||
|
These cases guard against the regression where a customer who had just completed SSO sign-in was bounced back to `/login` (with a blank React root) when navigating directly to `/portal`, `/book`, `/schedule`, or even `/login` itself. Root cause: `Dashboard.tsx`'s `!sessionId && !isImpersonating && !getDevUser()` guard fired during the CustomerPortal's bootstrap — before the SSO bridge resolved `portalSessionId` — and redirected to `/login`. The fix: `CustomerPortal` now shows a loading state while the bootstrap is in flight, so the portal chrome and its `!sessionId` child guards do not mount prematurely. App.tsx additionally redirects an authenticated user at `/login` to `/` instead of rendering `null`.
|
||||||
|
|
||||||
|
**Pre-conditions:**
|
||||||
|
|
||||||
|
- TC-WEB-5.25.1 — TC-WEB-5.25.3 must pass on the build under test.
|
||||||
|
- Clear cookies and localStorage between cases.
|
||||||
|
|
||||||
|
| # | Scenario | Steps | Expected |
|
||||||
|
|---|----------|-------|----------|
|
||||||
|
| TC-WEB-5.27.1 | Authenticated customer lands on `/portal` after direct nav | 1. From clean state, complete TC-WEB-5.25.1 (SSO sign-in as a customer). 2. Land on `/`. 3. `browser_navigate` (full page load) directly to `/portal`. | Final URL stays at `/portal`. The React root is non-empty. The portal dashboard renders with the customer's name. No `Navigate to /login` fires. |
|
||||||
|
| TC-WEB-5.27.2 | Authenticated customer lands on `/book` and `/schedule` after direct nav | From TC-WEB-5.27.1, `browser_navigate` to `/book` then `/schedule` (one fresh navigation each). | Each final URL stays at the navigated path. The portal chrome is visible. The page does not redirect to `/login`. |
|
||||||
|
| TC-WEB-5.27.3 | Authenticated customer at `/login` is auto-redirected to `/` | From TC-WEB-5.27.1, `browser_navigate` to `/login`. | The browser ends at `/` (not at a blank `/login`). The portal dashboard renders. No blank React root at `/login`. |
|
||||||
|
| TC-WEB-5.27.4 | Loading state is visible during the bootstrap, no portal chrome flash | 1. With the UAT build under test, open DevTools → Network and throttle to Slow 3G. 2. Sign in via SSO. 3. Land on `/`. | A "Loading…" element (`role="status"`) is briefly visible. The portal nav (Home / Appointments / etc.) is NOT visible during the loading window. No `Navigate to /login` fires during the bootstrap. |
|
||||||
|
| TC-WEB-5.27.5 | SSO bridge still runs and yields 201 | From TC-WEB-5.27.4 (or TC-WEB-5.27.1), inspect Network. | The same `GET /api/auth/get-session` (200) → `POST /api/portal/session-from-auth` (201) sequence from TC-WEB-5.25.2 still runs. The customer name appears in the greeting. |
|
||||||
|
| TC-WEB-5.27.6 | Unauthenticated direct nav to `/portal` still ends at `/login` (no regression) | Clear cookies. `browser_navigate` to `/portal`. | The portal briefly shows the loading state, then `CustomerPortal`'s `!session && !portalSessionId` guard redirects to `/login`. The login form renders. No infinite loop. |
|
||||||
|
| TC-WEB-5.27.7 | Groomer SSO still works (no regression) | 1. From clean state, sign in via SSO as the groomer identity (uat-groomer). 2. Land on `/`. | `App.tsx`'s staff check redirects to `/admin`. The groomer nav renders. No `CustomerPortal` flash. No `/portal` redirect loop. |
|
||||||
|
| TC-WEB-5.27.8 | Impersonation session still works (no regression) | 1. With an active impersonation session, open `/?sessionId=<id>`. | The amber "STAFF VIEW" chrome renders. The portal loads. No `/login` redirect. |
|
||||||
|
|
||||||
|
### 5.26 Customer Portal — RescheduleFlow under SSO Bridge (GRO-2012)
|
||||||
|
|
||||||
|
These cases guard against the regression where an SSO-bridge customer (no `?sessionId=` URL param, no impersonation session) could trigger the RescheduleFlow and have `RescheduleFlow` receive `sessionId={null}`, which caused the internal `/api/book/availability` call to send `X-Impersonation-Session-Id: ` (empty) and return 401. The fix: `CustomerPortal` now passes `sessionId={session?.id ?? portalSessionId}` to `<RescheduleFlow>` (matching the fallback `renderSection()` already used).
|
||||||
|
|
||||||
|
**Pre-conditions:**
|
||||||
|
|
||||||
|
- TC-WEB-5.25.1 — TC-WEB-5.25.3 must pass on the build under test.
|
||||||
|
- The seeded customer used has at least one upcoming, non-cancelled appointment with `status` ∈ {`pending`, `confirmed`}.
|
||||||
|
|
||||||
|
| # | Scenario | Steps | Expected |
|
||||||
|
|---|----------|-------|----------|
|
||||||
|
| TC-WEB-5.26.1 | RescheduleFlow receives portalSessionId (no 401) | 1. Complete TC-WEB-5.25.1 (SSO sign-in as a customer). 2. From the dashboard, click **Reschedule** on the next-upcoming appointment. 3. In the RescheduleFlow modal, pick a future date. 4. Open DevTools → Network and filter to `/api/`. | The `GET /api/book/availability?date=<picked>` request includes an `X-Impersonation-Session-Id` header whose value equals the `sessionId` from `session-from-auth`. The request returns 200. The time-slot list populates. No 401. |
|
||||||
|
| TC-WEB-5.26.2 | RescheduleFlow submit succeeds | From TC-WEB-5.26.1, pick a time slot and confirm. | `POST /api/portal/appointments/<id>/reschedule` (or the equivalent) includes the same `X-Impersonation-Session-Id` value. Returns 200. The modal closes and the appointment card reflects the new time. |
|
||||||
|
| TC-WEB-5.26.3 | Impersonation flow reschedule is unchanged (no regression) | 1. With an active impersonation session (`?sessionId=<active>`), load `/`. 2. Click **Reschedule** on an appointment. 3. Pick a date. | `GET /api/book/availability` includes `X-Impersonation-Session-Id` equal to the impersonation `sessionId` (not `portalSessionId`). Returns 200. Behaves identically to the pre-fix build. |
|
||||||
|
| TC-WEB-5.26.4 | No `X-Impersonation-Session-Id` is empty / null | From TC-WEB-5.26.1, inspect every `/api/portal/*` and `/api/book/*` request. | No request has an empty or `null` `X-Impersonation-Session-Id` header. |
|
||||||
|
|
||||||
|
### 5.28 Route Planner Page (GRO-2158)
|
||||||
|
|
||||||
|
The admin Route Planner lives at `/admin/routes`. It shows a groomer's geocoded appointment stops for a chosen date on a `react-leaflet` / OpenStreetMap map (numbered pins + a connecting polyline), a stop-list panel, a travel-time/distance summary, a route status badge, and an **Optimize** button wired to `POST /api/routes/optimize`. Leaflet is loaded via a dynamic import so it ships as a separate code-split chunk. Groomers are auto-filtered to their own route (no groomer selector); managers/receptionists pick a groomer.
|
||||||
|
|
||||||
|
**Pre-conditions:**
|
||||||
|
|
||||||
|
- Sign in to `/admin` as a manager (e.g. uat-manager) and, separately, as a groomer (uat-groomer).
|
||||||
|
- At least one groomer has appointments on the test date whose clients have geocoded addresses.
|
||||||
|
|
||||||
|
| # | Scenario | Steps | Expected |
|
||||||
|
|---|----------|-------|----------|
|
||||||
|
| TC-WEB-5.28.1 | Page loads and is reachable from nav | 1. Sign in as a manager. 2. Click **Routes** in the admin nav. | URL is `/admin/routes`. The "Route Planner" heading, a Date picker, a Groomer selector, and an **Optimize** button render. No console errors. |
|
||||||
|
| TC-WEB-5.28.2 | Leaflet map is code-split | 1. Open DevTools → Network (JS filter). 2. Load `/admin/reports` first, confirm no `RouteMap` chunk loads. 3. Navigate to `/admin/routes`. | A separate `RouteMap-*.js` chunk (and `RouteMap-*.css`) is fetched only when the Routes page renders, not on other admin pages. |
|
||||||
|
| TC-WEB-5.28.3 | Map shows numbered pins + polyline | Select a groomer + date that has a built route with ≥2 geocoded stops. | The OSM map renders with one numbered pin per stop (1, 2, 3…) and a polyline connecting them in order. Tile attribution to OpenStreetMap is visible. |
|
||||||
|
| TC-WEB-5.28.4 | Stop-list panel cards | Inspect the panel beside the map. | Each stop card shows the stop number, client name, appointment time, address, and travel time from the previous stop (stop 1 shows "Start of route"). |
|
||||||
|
| TC-WEB-5.28.5 | Summary + status badge | Inspect the summary bar and badge. | Stops count, total travel time, and total distance (km) are shown. A status badge reads one of Draft / Optimized / In progress / Completed matching the route's status. |
|
||||||
|
| TC-WEB-5.28.6 | Optimize button | Click **Optimize**. | A `POST /api/routes/optimize` with `{ staffId, date }` fires. On success the map, stop order, summary, and status badge refresh. Any skipped (non-geocoded) clients surface as a warning. |
|
||||||
|
| TC-WEB-5.28.7 | Groomer role auto-filter | Sign in as a groomer and open `/admin/routes`. | No groomer selector is shown. The page loads the signed-in groomer's own route for the selected date. The groomer cannot view another groomer's route. |
|
||||||
|
| TC-WEB-5.28.8 | Empty / no-route state | Select a date with no appointments. | The map area and stop panel show a friendly empty state ("No stops…"). No crash; **Optimize** is still clickable. |
|
||||||
|
|
||||||
|
### 5.29 Route Planner — Drag-to-Reorder & Re-optimize (GRO-2159)
|
||||||
|
|
||||||
|
The stop-list panel is drag-sortable (`@dnd-kit`). Each stop card has a grab handle (⠿). Dropping a stop in a new position calls `PATCH /api/routes/:routeId/reorder` with `{ stopOrder: [routeStopId…] }` (full first-to-last order); the UI updates optimistically and rolls back on error. The server recomputes per-leg travel, buffers, totals and tight-schedule conflict flags, and the panel/map/summary adopt the response. A "tight schedule" warning is shown on any stop whose gap is shorter than its travel + buffer. After a manual reorder a hint with a **Re-optimize** button appears (re-runs `POST /api/routes/optimize`). Drag works via mouse (desktop), press-and-hold touch (mobile groomers), and keyboard (focus handle → Space → arrows → Space).
|
||||||
|
|
||||||
|
| Test Case | Description | Steps | Expected Result |
|
||||||
|
|-----------|-------------|-------|-----------------|
|
||||||
|
| TC-WEB-5.29.1 | Drag handle present | Open `/admin/routes` for a route with ≥2 stops. | Each stop card shows a grab handle (⠿) with an accessible label "Drag to reorder <client>". |
|
||||||
|
| TC-WEB-5.29.2 | Reorder persists | Drag a stop to a new position and drop it. | A `PATCH /api/routes/:routeId/reorder` fires with the new `stopOrder` (every stop id once, new order). Stop numbers, the map polyline order, and travel-from-previous labels refresh to match. |
|
||||||
|
| TC-WEB-5.29.3 | Optimistic update + rollback | Simulate a failing reorder (e.g. server returns an error / offline). | The list shows the new order immediately, then reverts to the prior order when the PATCH fails, and an error message is shown. No stuck/partial order. |
|
||||||
|
| TC-WEB-5.29.4 | Tight-schedule warning re-evaluated | Reorder so two stops are too close together. | The affected stop card shows "⚠ Tight schedule — travel + buffer may exceed the gap" (red border) after the server recomputes; warnings clear on a roomier order. |
|
||||||
|
| TC-WEB-5.29.5 | Re-optimize button | After a manual drag reorder, locate the hint banner. | A "Stops reordered manually…" hint with a **Re-optimize** button appears. Clicking it fires `POST /api/routes/optimize` and the hint clears once the optimized route loads. The hint is absent before any manual reorder. |
|
||||||
|
| TC-WEB-5.29.6 | Touch / mobile drag | On a touch device (or mobile emulation), press-and-hold a stop's handle (~200ms) then drag. | The stop lifts and can be dropped in a new position; page scroll is not hijacked by a quick swipe. Reorder persists as in 5.29.2. |
|
||||||
|
| TC-WEB-5.29.7 | Groomer reorders own route | Sign in as a groomer, reorder stops on the own route. | Reorder succeeds (groomer is authorized for their own route). |
|
||||||
|
|
||||||
|
### 5.30 Route Planner — Navigation Export & Offline (GRO-2160)
|
||||||
|
|
||||||
|
When a route has stops, an export panel offers **Open in Google Maps** and **Open in Apple Maps** buttons. Each fetches `GET /api/routes/:routeId/export/google-maps` (or `/apple-maps`) and opens the returned deep-link URL in the device's maps app (Google Maps `https://www.google.com/maps/dir/?...`, Apple Maps `maps://...`). The page detects the device OS (iOS / Android / desktop) and renders the most relevant button prominently (filled) with the other as a secondary outline button; on iOS Apple Maps leads, otherwise Google Maps leads. Offline support: the existing Workbox `NetworkFirst` rule caches `/api/routes/*` responses (24h TTL) so a previously-loaded route still renders without network; a `CacheFirst` rule (`osm-tiles`, 7-day TTL, 400 entries) caches OpenStreetMap tiles. On every route load and after each optimize/reorder, the page pre-warms the OSM tiles covering the route's bounding box (zooms 12–14, capped at 80 tiles) so the map is viewable offline. The layout is responsive: below 768px the map/stop-list stack to one column, the map shrinks, and the export buttons go full-width.
|
||||||
|
|
||||||
|
| Test Case | Description | Steps | Expected Result |
|
||||||
|
|-----------|-------------|-------|-----------------|
|
||||||
|
| TC-WEB-5.30.1 | Export buttons render | Open `/admin/routes` for a route with ≥1 stop. | An export panel shows both **Open in Google Maps** and **Open in Apple Maps** buttons. Buttons are absent when there are no stops. |
|
||||||
|
| TC-WEB-5.30.2 | Google Maps deep link | Click **Open in Google Maps**. | A `GET /api/routes/:routeId/export/google-maps` fires and the returned `https://www.google.com/maps/dir/?...` URL opens (new tab / Google Maps app) with origin, destination, and waypoints in route order. |
|
||||||
|
| TC-WEB-5.30.3 | Apple Maps deep link | On iOS (or emulation), click **Open in Apple Maps**. | A `GET /api/routes/:routeId/export/apple-maps` fires and the returned `maps://...` URL opens Apple Maps with the route chained `+to:`. |
|
||||||
|
| TC-WEB-5.30.4 | Platform-aware prominence | Open the page on an iPhone (or iOS UA emulation) vs Android/desktop. | On iOS the **Apple Maps** button is the prominent (filled) one and Google Maps is the secondary (outline); on Android/desktop **Google Maps** is prominent and Apple Maps secondary. Both buttons are always available. |
|
||||||
|
| TC-WEB-5.30.5 | Export error handling | Trigger an export that errors (e.g. route exceeds the platform waypoint cap). | The pre-opened tab is closed and an inline error message is shown; no silent failure. |
|
||||||
|
| TC-WEB-5.30.6 | Offline route data | Load a route online, then in DevTools → Network set **Offline** and reload `/admin/routes` for the same groomer/date. | The route data still loads from the `api-cache` (NetworkFirst fallback); stops, summary, and badge render without network. |
|
||||||
|
| TC-WEB-5.30.7 | Offline map tiles | After viewing/optimizing a route online, go **Offline** and view the same route. | The OSM map tiles for the route area render from the `osm-tiles` CacheFirst cache (pre-warmed); the map is not blank in the route's vicinity. |
|
||||||
|
| TC-WEB-5.30.8 | Responsive mobile layout | Open the page at a phone width (≤768px, e.g. 390px). | Map and stop-list stack into a single column, the map height shrinks, and the export buttons span full width. No horizontal scroll; controls remain usable with a thumb. |
|
||||||
|
|
||||||
## 6. Pass/Fail Criteria
|
## 6. Pass/Fail Criteria
|
||||||
|
|
||||||
**Pass:**
|
**Pass:**
|
||||||
|
|||||||
@@ -13,14 +13,19 @@
|
|||||||
"test:e2e": "playwright test -c e2e/playwright.config.ts"
|
"test:e2e": "playwright test -c e2e/playwright.config.ts"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@dnd-kit/core": "^6.3.1",
|
||||||
|
"@dnd-kit/sortable": "^10.0.0",
|
||||||
|
"@dnd-kit/utilities": "^3.2.2",
|
||||||
"@groombook/types": "workspace:*",
|
"@groombook/types": "workspace:*",
|
||||||
"@stripe/react-stripe-js": "^6.1.0",
|
"@stripe/react-stripe-js": "^6.1.0",
|
||||||
"@stripe/stripe-js": "^9.1.0",
|
"@stripe/stripe-js": "^9.1.0",
|
||||||
"@tailwindcss/vite": "^4.2.2",
|
"@tailwindcss/vite": "^4.2.2",
|
||||||
"better-auth": "^1.5.6",
|
"better-auth": "^1.5.6",
|
||||||
|
"leaflet": "^1.9.4",
|
||||||
"lucide-react": "^0.577.0",
|
"lucide-react": "^0.577.0",
|
||||||
"react": "^19.0.0",
|
"react": "^19.0.0",
|
||||||
"react-dom": "^19.0.0",
|
"react-dom": "^19.0.0",
|
||||||
|
"react-leaflet": "^5.0.0",
|
||||||
"react-router-dom": "^7.1.2",
|
"react-router-dom": "^7.1.2",
|
||||||
"recharts": "^3.8.0",
|
"recharts": "^3.8.0",
|
||||||
"tailwindcss": "^4.2.2"
|
"tailwindcss": "^4.2.2"
|
||||||
@@ -30,6 +35,7 @@
|
|||||||
"@testing-library/jest-dom": "^6.9.1",
|
"@testing-library/jest-dom": "^6.9.1",
|
||||||
"@testing-library/react": "^16.3.2",
|
"@testing-library/react": "^16.3.2",
|
||||||
"@testing-library/user-event": "^14.6.1",
|
"@testing-library/user-event": "^14.6.1",
|
||||||
|
"@types/leaflet": "^1.9.12",
|
||||||
"@types/node": "^25.6.0",
|
"@types/node": "^25.6.0",
|
||||||
"@types/react": "^19.0.6",
|
"@types/react": "^19.0.6",
|
||||||
"@types/react-dom": "^19.0.3",
|
"@types/react-dom": "^19.0.3",
|
||||||
|
|||||||
@@ -34,6 +34,10 @@ export interface Pet {
|
|||||||
breed: string | null;
|
breed: string | null;
|
||||||
weightKg: number | null;
|
weightKg: number | null;
|
||||||
dateOfBirth: string | null;
|
dateOfBirth: string | null;
|
||||||
|
/** Portal-shaped serialization of weightKg (GET/PATCH /api/portal/pets). */
|
||||||
|
weight?: string | number | null;
|
||||||
|
/** Portal-shaped serialization of dateOfBirth (GET/PATCH /api/portal/pets). */
|
||||||
|
birthDate?: string | null;
|
||||||
healthAlerts: string | null;
|
healthAlerts: string | null;
|
||||||
groomingNotes: string | null;
|
groomingNotes: string | null;
|
||||||
cutStyle: string | null;
|
cutStyle: string | null;
|
||||||
|
|||||||
Generated
+138
-3
@@ -8,6 +8,15 @@ importers:
|
|||||||
|
|
||||||
.:
|
.:
|
||||||
dependencies:
|
dependencies:
|
||||||
|
'@dnd-kit/core':
|
||||||
|
specifier: ^6.3.1
|
||||||
|
version: 6.3.1(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
|
||||||
|
'@dnd-kit/sortable':
|
||||||
|
specifier: ^10.0.0
|
||||||
|
version: 10.0.0(@dnd-kit/core@6.3.1(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(react@19.2.6)
|
||||||
|
'@dnd-kit/utilities':
|
||||||
|
specifier: ^3.2.2
|
||||||
|
version: 3.2.2(react@19.2.6)
|
||||||
'@groombook/types':
|
'@groombook/types':
|
||||||
specifier: workspace:*
|
specifier: workspace:*
|
||||||
version: link:packages/types
|
version: link:packages/types
|
||||||
@@ -23,6 +32,9 @@ importers:
|
|||||||
better-auth:
|
better-auth:
|
||||||
specifier: ^1.5.6
|
specifier: ^1.5.6
|
||||||
version: 1.6.10(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(vitest@3.2.4(@types/node@25.6.2)(jiti@2.7.0)(jsdom@26.1.0)(lightningcss@1.32.0)(terser@5.47.1))
|
version: 1.6.10(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(vitest@3.2.4(@types/node@25.6.2)(jiti@2.7.0)(jsdom@26.1.0)(lightningcss@1.32.0)(terser@5.47.1))
|
||||||
|
leaflet:
|
||||||
|
specifier: ^1.9.4
|
||||||
|
version: 1.9.4
|
||||||
lucide-react:
|
lucide-react:
|
||||||
specifier: ^0.577.0
|
specifier: ^0.577.0
|
||||||
version: 0.577.0(react@19.2.6)
|
version: 0.577.0(react@19.2.6)
|
||||||
@@ -32,12 +44,15 @@ importers:
|
|||||||
react-dom:
|
react-dom:
|
||||||
specifier: ^19.0.0
|
specifier: ^19.0.0
|
||||||
version: 19.2.6(react@19.2.6)
|
version: 19.2.6(react@19.2.6)
|
||||||
|
react-leaflet:
|
||||||
|
specifier: ^5.0.0
|
||||||
|
version: 5.0.0(leaflet@1.9.4)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
|
||||||
react-router-dom:
|
react-router-dom:
|
||||||
specifier: ^7.1.2
|
specifier: ^7.1.2
|
||||||
version: 7.15.0(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
|
version: 7.15.0(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
|
||||||
recharts:
|
recharts:
|
||||||
specifier: ^3.8.0
|
specifier: ^3.8.0
|
||||||
version: 3.8.1(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react-is@16.13.1)(react@19.2.6)(redux@5.0.1)
|
version: 3.8.1(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react-is@17.0.2)(react@19.2.6)(redux@5.0.1)
|
||||||
tailwindcss:
|
tailwindcss:
|
||||||
specifier: ^4.2.2
|
specifier: ^4.2.2
|
||||||
version: 4.3.0
|
version: 4.3.0
|
||||||
@@ -54,6 +69,9 @@ importers:
|
|||||||
'@testing-library/user-event':
|
'@testing-library/user-event':
|
||||||
specifier: ^14.6.1
|
specifier: ^14.6.1
|
||||||
version: 14.6.1(@testing-library/dom@10.4.1)
|
version: 14.6.1(@testing-library/dom@10.4.1)
|
||||||
|
'@types/leaflet':
|
||||||
|
specifier: ^1.9.12
|
||||||
|
version: 1.9.21
|
||||||
'@types/node':
|
'@types/node':
|
||||||
specifier: ^25.6.0
|
specifier: ^25.6.0
|
||||||
version: 25.6.2
|
version: 25.6.2
|
||||||
@@ -739,6 +757,28 @@ packages:
|
|||||||
resolution: {integrity: sha512-Vd/9EVDiu6PPJt9yAh6roZP6El1xHrdvIVGjyBsHR0RYwNHgL7FJPyIIW4fANJNG6FtyZfvlRPpFI4ZM/lubvw==}
|
resolution: {integrity: sha512-Vd/9EVDiu6PPJt9yAh6roZP6El1xHrdvIVGjyBsHR0RYwNHgL7FJPyIIW4fANJNG6FtyZfvlRPpFI4ZM/lubvw==}
|
||||||
engines: {node: '>=18'}
|
engines: {node: '>=18'}
|
||||||
|
|
||||||
|
'@dnd-kit/accessibility@3.1.1':
|
||||||
|
resolution: {integrity: sha512-2P+YgaXF+gRsIihwwY1gCsQSYnu9Zyj2py8kY5fFvUM1qm2WA2u639R6YNVfU4GWr+ZM5mqEsfHZZLoRONbemw==}
|
||||||
|
peerDependencies:
|
||||||
|
react: '>=16.8.0'
|
||||||
|
|
||||||
|
'@dnd-kit/core@6.3.1':
|
||||||
|
resolution: {integrity: sha512-xkGBRQQab4RLwgXxoqETICr6S5JlogafbhNsidmrkVv2YRs5MLwpjoF2qpiGjQt8S9AoxtIV603s0GIUpY5eYQ==}
|
||||||
|
peerDependencies:
|
||||||
|
react: '>=16.8.0'
|
||||||
|
react-dom: '>=16.8.0'
|
||||||
|
|
||||||
|
'@dnd-kit/sortable@10.0.0':
|
||||||
|
resolution: {integrity: sha512-+xqhmIIzvAYMGfBYYnbKuNicfSsk4RksY2XdmJhT+HAC01nix6fHCztU68jooFiMUB01Ky3F0FyOvhG/BZrWkg==}
|
||||||
|
peerDependencies:
|
||||||
|
'@dnd-kit/core': ^6.3.0
|
||||||
|
react: '>=16.8.0'
|
||||||
|
|
||||||
|
'@dnd-kit/utilities@3.2.2':
|
||||||
|
resolution: {integrity: sha512-+MKAJEOfaBe5SmV6t34p80MMKhjvUz0vRrvVJbPT0WElzaOJ/1xs+D+KDv+tD/NE5ujfrChEcshd4fLn0wpiqg==}
|
||||||
|
peerDependencies:
|
||||||
|
react: '>=16.8.0'
|
||||||
|
|
||||||
'@esbuild/aix-ppc64@0.25.12':
|
'@esbuild/aix-ppc64@0.25.12':
|
||||||
resolution: {integrity: sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==}
|
resolution: {integrity: sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==}
|
||||||
engines: {node: '>=18'}
|
engines: {node: '>=18'}
|
||||||
@@ -1005,6 +1045,13 @@ packages:
|
|||||||
engines: {node: '>=18'}
|
engines: {node: '>=18'}
|
||||||
hasBin: true
|
hasBin: true
|
||||||
|
|
||||||
|
'@react-leaflet/core@3.0.0':
|
||||||
|
resolution: {integrity: sha512-3EWmekh4Nz+pGcr+xjf0KNyYfC3U2JjnkWsh0zcqaexYqmmB5ZhH37kz41JXGmKzpaMZCnPofBBm64i+YrEvGQ==}
|
||||||
|
peerDependencies:
|
||||||
|
leaflet: ^1.9.0
|
||||||
|
react: ^19.0.0
|
||||||
|
react-dom: ^19.0.0
|
||||||
|
|
||||||
'@reduxjs/toolkit@2.11.2':
|
'@reduxjs/toolkit@2.11.2':
|
||||||
resolution: {integrity: sha512-Kd6kAHTA6/nUpp8mySPqj3en3dm0tdMIgbttnQ1xFMVpufoj+ADi8pXLBsd4xzTRHQa7t/Jv8W5UnCuW4kuWMQ==}
|
resolution: {integrity: sha512-Kd6kAHTA6/nUpp8mySPqj3en3dm0tdMIgbttnQ1xFMVpufoj+ADi8pXLBsd4xzTRHQa7t/Jv8W5UnCuW4kuWMQ==}
|
||||||
peerDependencies:
|
peerDependencies:
|
||||||
@@ -1102,66 +1149,79 @@ packages:
|
|||||||
resolution: {integrity: sha512-DV6fJoxEYWJOvaZIsok7KrYl0tPvga5OZ2yvKHNNYyk/2roMLqQAbGhr78EQ5YhHpnhLKJD3S1WFusAkmUuV5g==}
|
resolution: {integrity: sha512-DV6fJoxEYWJOvaZIsok7KrYl0tPvga5OZ2yvKHNNYyk/2roMLqQAbGhr78EQ5YhHpnhLKJD3S1WFusAkmUuV5g==}
|
||||||
cpu: [arm]
|
cpu: [arm]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
|
libc: [glibc]
|
||||||
|
|
||||||
'@rollup/rollup-linux-arm-musleabihf@4.60.3':
|
'@rollup/rollup-linux-arm-musleabihf@4.60.3':
|
||||||
resolution: {integrity: sha512-mQKoJAzvuOs6F+TZybQO4GOTSMUu7v0WdxEk24krQ/uUxXoPTtHjuaUuPmFhtBcM4K0ons8nrE3JyhTuCFtT/w==}
|
resolution: {integrity: sha512-mQKoJAzvuOs6F+TZybQO4GOTSMUu7v0WdxEk24krQ/uUxXoPTtHjuaUuPmFhtBcM4K0ons8nrE3JyhTuCFtT/w==}
|
||||||
cpu: [arm]
|
cpu: [arm]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
|
libc: [musl]
|
||||||
|
|
||||||
'@rollup/rollup-linux-arm64-gnu@4.60.3':
|
'@rollup/rollup-linux-arm64-gnu@4.60.3':
|
||||||
resolution: {integrity: sha512-Whjj2qoiJ6+OOJMGptTYazaJvjOJm+iKHpXQM1P3LzGjt7Ff++Tp7nH4N8J/BUA7R9IHfDyx4DJIflifwnbmIA==}
|
resolution: {integrity: sha512-Whjj2qoiJ6+OOJMGptTYazaJvjOJm+iKHpXQM1P3LzGjt7Ff++Tp7nH4N8J/BUA7R9IHfDyx4DJIflifwnbmIA==}
|
||||||
cpu: [arm64]
|
cpu: [arm64]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
|
libc: [glibc]
|
||||||
|
|
||||||
'@rollup/rollup-linux-arm64-musl@4.60.3':
|
'@rollup/rollup-linux-arm64-musl@4.60.3':
|
||||||
resolution: {integrity: sha512-4YTNHKqGng5+yiZt3mg77nmyuCfmNfX4fPmyUapBcIk+BdwSwmCWGXOUxhXbBEkFHtoN5boLj/5NON+u5QC9tg==}
|
resolution: {integrity: sha512-4YTNHKqGng5+yiZt3mg77nmyuCfmNfX4fPmyUapBcIk+BdwSwmCWGXOUxhXbBEkFHtoN5boLj/5NON+u5QC9tg==}
|
||||||
cpu: [arm64]
|
cpu: [arm64]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
|
libc: [musl]
|
||||||
|
|
||||||
'@rollup/rollup-linux-loong64-gnu@4.60.3':
|
'@rollup/rollup-linux-loong64-gnu@4.60.3':
|
||||||
resolution: {integrity: sha512-SU3kNlhkpI4UqlUc2VXPGK9o886ZsSeGfMAX2ba2b8DKmMXq4AL7KUrkSWVbb7koVqx41Yczx6dx5PNargIrEA==}
|
resolution: {integrity: sha512-SU3kNlhkpI4UqlUc2VXPGK9o886ZsSeGfMAX2ba2b8DKmMXq4AL7KUrkSWVbb7koVqx41Yczx6dx5PNargIrEA==}
|
||||||
cpu: [loong64]
|
cpu: [loong64]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
|
libc: [glibc]
|
||||||
|
|
||||||
'@rollup/rollup-linux-loong64-musl@4.60.3':
|
'@rollup/rollup-linux-loong64-musl@4.60.3':
|
||||||
resolution: {integrity: sha512-6lDLl5h4TXpB1mTf2rQWnAk/LcXrx9vBfu/DT5TIPhvMhRWaZ5MxkIc8u4lJAmBo6klTe1ywXIUHFjylW505sg==}
|
resolution: {integrity: sha512-6lDLl5h4TXpB1mTf2rQWnAk/LcXrx9vBfu/DT5TIPhvMhRWaZ5MxkIc8u4lJAmBo6klTe1ywXIUHFjylW505sg==}
|
||||||
cpu: [loong64]
|
cpu: [loong64]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
|
libc: [musl]
|
||||||
|
|
||||||
'@rollup/rollup-linux-ppc64-gnu@4.60.3':
|
'@rollup/rollup-linux-ppc64-gnu@4.60.3':
|
||||||
resolution: {integrity: sha512-BMo8bOw8evlup/8G+cj5xWtPyp93xPdyoSN16Zy90Q2QZ0ZYRhCt6ZJSwbrRzG9HApFabjwj2p25TUPDWrhzqQ==}
|
resolution: {integrity: sha512-BMo8bOw8evlup/8G+cj5xWtPyp93xPdyoSN16Zy90Q2QZ0ZYRhCt6ZJSwbrRzG9HApFabjwj2p25TUPDWrhzqQ==}
|
||||||
cpu: [ppc64]
|
cpu: [ppc64]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
|
libc: [glibc]
|
||||||
|
|
||||||
'@rollup/rollup-linux-ppc64-musl@4.60.3':
|
'@rollup/rollup-linux-ppc64-musl@4.60.3':
|
||||||
resolution: {integrity: sha512-E0L8X1dZN1/Rph+5VPF6Xj2G7JJvMACVXtamTJIDrVI44Y3K+G8gQaMEAavbqCGTa16InptiVrX6eM6pmJ+7qA==}
|
resolution: {integrity: sha512-E0L8X1dZN1/Rph+5VPF6Xj2G7JJvMACVXtamTJIDrVI44Y3K+G8gQaMEAavbqCGTa16InptiVrX6eM6pmJ+7qA==}
|
||||||
cpu: [ppc64]
|
cpu: [ppc64]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
|
libc: [musl]
|
||||||
|
|
||||||
'@rollup/rollup-linux-riscv64-gnu@4.60.3':
|
'@rollup/rollup-linux-riscv64-gnu@4.60.3':
|
||||||
resolution: {integrity: sha512-oZJ/WHaVfHUiRAtmTAeo3DcevNsVvH8mbvodjZy7D5QKvCefO371SiKRpxoDcCxB3PTRTLayWBkvmDQKTcX/sw==}
|
resolution: {integrity: sha512-oZJ/WHaVfHUiRAtmTAeo3DcevNsVvH8mbvodjZy7D5QKvCefO371SiKRpxoDcCxB3PTRTLayWBkvmDQKTcX/sw==}
|
||||||
cpu: [riscv64]
|
cpu: [riscv64]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
|
libc: [glibc]
|
||||||
|
|
||||||
'@rollup/rollup-linux-riscv64-musl@4.60.3':
|
'@rollup/rollup-linux-riscv64-musl@4.60.3':
|
||||||
resolution: {integrity: sha512-Dhbyh7j9FybM3YaTgaHmVALwA8AkUwTPccyCQ79TG9AJUsMQqgN1DDEZNr4+QUfwiWvLDumW5vdwzoeUF+TNxQ==}
|
resolution: {integrity: sha512-Dhbyh7j9FybM3YaTgaHmVALwA8AkUwTPccyCQ79TG9AJUsMQqgN1DDEZNr4+QUfwiWvLDumW5vdwzoeUF+TNxQ==}
|
||||||
cpu: [riscv64]
|
cpu: [riscv64]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
|
libc: [musl]
|
||||||
|
|
||||||
'@rollup/rollup-linux-s390x-gnu@4.60.3':
|
'@rollup/rollup-linux-s390x-gnu@4.60.3':
|
||||||
resolution: {integrity: sha512-cJd1X5XhHHlltkaypz1UcWLA8AcoIi1aWhsvaWDskD1oz2eKCypnqvTQ8ykMNI0RSmm7NkTdSqSSD7zM0xa6Ig==}
|
resolution: {integrity: sha512-cJd1X5XhHHlltkaypz1UcWLA8AcoIi1aWhsvaWDskD1oz2eKCypnqvTQ8ykMNI0RSmm7NkTdSqSSD7zM0xa6Ig==}
|
||||||
cpu: [s390x]
|
cpu: [s390x]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
|
libc: [glibc]
|
||||||
|
|
||||||
'@rollup/rollup-linux-x64-gnu@4.60.3':
|
'@rollup/rollup-linux-x64-gnu@4.60.3':
|
||||||
resolution: {integrity: sha512-DAZDBHQfG2oQuhY7mc6I3/qB4LU2fQCjRvxbDwd/Jdvb9fypP4IJ4qmtu6lNjes6B531AI8cg1aKC2di97bUxA==}
|
resolution: {integrity: sha512-DAZDBHQfG2oQuhY7mc6I3/qB4LU2fQCjRvxbDwd/Jdvb9fypP4IJ4qmtu6lNjes6B531AI8cg1aKC2di97bUxA==}
|
||||||
cpu: [x64]
|
cpu: [x64]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
|
libc: [glibc]
|
||||||
|
|
||||||
'@rollup/rollup-linux-x64-musl@4.60.3':
|
'@rollup/rollup-linux-x64-musl@4.60.3':
|
||||||
resolution: {integrity: sha512-cRxsE8c13mZOh3vP+wLDxpQBRrOHDIGOWyDL93Sy0Ga8y515fBcC2pjUfFwUe5T7tqvTvWbCpg1URM/AXdWIXA==}
|
resolution: {integrity: sha512-cRxsE8c13mZOh3vP+wLDxpQBRrOHDIGOWyDL93Sy0Ga8y515fBcC2pjUfFwUe5T7tqvTvWbCpg1URM/AXdWIXA==}
|
||||||
cpu: [x64]
|
cpu: [x64]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
|
libc: [musl]
|
||||||
|
|
||||||
'@rollup/rollup-openbsd-x64@4.60.3':
|
'@rollup/rollup-openbsd-x64@4.60.3':
|
||||||
resolution: {integrity: sha512-QaWcIgRxqEdQdhJqW4DJctsH6HCmo5vHxY0krHSX4jMtOqfzC+dqDGuHM87bu4H8JBeibWx7jFz+h6/4C8wA5Q==}
|
resolution: {integrity: sha512-QaWcIgRxqEdQdhJqW4DJctsH6HCmo5vHxY0krHSX4jMtOqfzC+dqDGuHM87bu4H8JBeibWx7jFz+h6/4C8wA5Q==}
|
||||||
@@ -1248,24 +1308,28 @@ packages:
|
|||||||
engines: {node: '>= 20'}
|
engines: {node: '>= 20'}
|
||||||
cpu: [arm64]
|
cpu: [arm64]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
|
libc: [glibc]
|
||||||
|
|
||||||
'@tailwindcss/oxide-linux-arm64-musl@4.3.0':
|
'@tailwindcss/oxide-linux-arm64-musl@4.3.0':
|
||||||
resolution: {integrity: sha512-Z6sukiQsngnWO+l39X4pPbiWT81IC+PLKF+PHxIlyZbGNb9MODfYlXEVlFvej5BOZInWX01kVyzeLvHsXhfczQ==}
|
resolution: {integrity: sha512-Z6sukiQsngnWO+l39X4pPbiWT81IC+PLKF+PHxIlyZbGNb9MODfYlXEVlFvej5BOZInWX01kVyzeLvHsXhfczQ==}
|
||||||
engines: {node: '>= 20'}
|
engines: {node: '>= 20'}
|
||||||
cpu: [arm64]
|
cpu: [arm64]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
|
libc: [musl]
|
||||||
|
|
||||||
'@tailwindcss/oxide-linux-x64-gnu@4.3.0':
|
'@tailwindcss/oxide-linux-x64-gnu@4.3.0':
|
||||||
resolution: {integrity: sha512-DRNdQRpSGzRGfARVuVkxvM8Q12nh19l4BF/G7zGA1oe+9wcC6saFBHTISrpIcKzhiXtSrlSrluCfvMuledoCTQ==}
|
resolution: {integrity: sha512-DRNdQRpSGzRGfARVuVkxvM8Q12nh19l4BF/G7zGA1oe+9wcC6saFBHTISrpIcKzhiXtSrlSrluCfvMuledoCTQ==}
|
||||||
engines: {node: '>= 20'}
|
engines: {node: '>= 20'}
|
||||||
cpu: [x64]
|
cpu: [x64]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
|
libc: [glibc]
|
||||||
|
|
||||||
'@tailwindcss/oxide-linux-x64-musl@4.3.0':
|
'@tailwindcss/oxide-linux-x64-musl@4.3.0':
|
||||||
resolution: {integrity: sha512-Z0IADbDo8bh6I7h2IQMx601AdXBLfFpEdUotft86evd/8ZPflZe9COPO8Q1vw+pfLWIUo9zN/JGZvwuAJqduqg==}
|
resolution: {integrity: sha512-Z0IADbDo8bh6I7h2IQMx601AdXBLfFpEdUotft86evd/8ZPflZe9COPO8Q1vw+pfLWIUo9zN/JGZvwuAJqduqg==}
|
||||||
engines: {node: '>= 20'}
|
engines: {node: '>= 20'}
|
||||||
cpu: [x64]
|
cpu: [x64]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
|
libc: [musl]
|
||||||
|
|
||||||
'@tailwindcss/oxide-wasm32-wasi@4.3.0':
|
'@tailwindcss/oxide-wasm32-wasi@4.3.0':
|
||||||
resolution: {integrity: sha512-HNZGOUxEmElksYR7S6sC5jTeNGpobAsy9u7Gu0AskJ8/20FR9GqebUyB+HBcU/ax6BHuiuJi+Oda4B+YX6H1yA==}
|
resolution: {integrity: sha512-HNZGOUxEmElksYR7S6sC5jTeNGpobAsy9u7Gu0AskJ8/20FR9GqebUyB+HBcU/ax6BHuiuJi+Oda4B+YX6H1yA==}
|
||||||
@@ -1387,9 +1451,15 @@ packages:
|
|||||||
'@types/estree@1.0.9':
|
'@types/estree@1.0.9':
|
||||||
resolution: {integrity: sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==}
|
resolution: {integrity: sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==}
|
||||||
|
|
||||||
|
'@types/geojson@7946.0.16':
|
||||||
|
resolution: {integrity: sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg==}
|
||||||
|
|
||||||
'@types/json-schema@7.0.15':
|
'@types/json-schema@7.0.15':
|
||||||
resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==}
|
resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==}
|
||||||
|
|
||||||
|
'@types/leaflet@1.9.21':
|
||||||
|
resolution: {integrity: sha512-TbAd9DaPGSnzp6QvtYngntMZgcRk+igFELwR2N99XZn7RXUdKgsXMR+28bUO0rPsWp8MIu/f47luLIQuSLYv/w==}
|
||||||
|
|
||||||
'@types/node@25.6.2':
|
'@types/node@25.6.2':
|
||||||
resolution: {integrity: sha512-sokuT28dxf9JT5Kady1fsXOvI4HVpjZa95NKT5y9PNTIrs2AsobR4GFAA90ZG8M+nxVRLysCXsVj6eGC7Vbrlw==}
|
resolution: {integrity: sha512-sokuT28dxf9JT5Kady1fsXOvI4HVpjZa95NKT5y9PNTIrs2AsobR4GFAA90ZG8M+nxVRLysCXsVj6eGC7Vbrlw==}
|
||||||
|
|
||||||
@@ -2462,6 +2532,9 @@ packages:
|
|||||||
resolution: {integrity: sha512-nbD8lB9EB3wNdMhOCdx5Li8DxnLbvKByylRLcJ1h+4SkrowVeECAyZlyiKMThF7xFdRz0jSQ2MoJr+wXux2y0Q==}
|
resolution: {integrity: sha512-nbD8lB9EB3wNdMhOCdx5Li8DxnLbvKByylRLcJ1h+4SkrowVeECAyZlyiKMThF7xFdRz0jSQ2MoJr+wXux2y0Q==}
|
||||||
engines: {node: '>=20.0.0'}
|
engines: {node: '>=20.0.0'}
|
||||||
|
|
||||||
|
leaflet@1.9.4:
|
||||||
|
resolution: {integrity: sha512-nxS1ynzJOmOlHp+iL3FyWqK89GtNL8U8rvlMOsQdTTssxZwCXh8N2NB3GDQOL+YR3XnWyZAxwQixURb+FA74PA==}
|
||||||
|
|
||||||
leven@3.1.0:
|
leven@3.1.0:
|
||||||
resolution: {integrity: sha512-qsda+H8jTaUaN/x5vzW2rzc+8Rw4TAQ/4KjB46IwK5VH+IlVeeeje/EoZRpiXvIqjFgK84QffqPztGI3VBLG1A==}
|
resolution: {integrity: sha512-qsda+H8jTaUaN/x5vzW2rzc+8Rw4TAQ/4KjB46IwK5VH+IlVeeeje/EoZRpiXvIqjFgK84QffqPztGI3VBLG1A==}
|
||||||
engines: {node: '>=6'}
|
engines: {node: '>=6'}
|
||||||
@@ -2505,24 +2578,28 @@ packages:
|
|||||||
engines: {node: '>= 12.0.0'}
|
engines: {node: '>= 12.0.0'}
|
||||||
cpu: [arm64]
|
cpu: [arm64]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
|
libc: [glibc]
|
||||||
|
|
||||||
lightningcss-linux-arm64-musl@1.32.0:
|
lightningcss-linux-arm64-musl@1.32.0:
|
||||||
resolution: {integrity: sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==}
|
resolution: {integrity: sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==}
|
||||||
engines: {node: '>= 12.0.0'}
|
engines: {node: '>= 12.0.0'}
|
||||||
cpu: [arm64]
|
cpu: [arm64]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
|
libc: [musl]
|
||||||
|
|
||||||
lightningcss-linux-x64-gnu@1.32.0:
|
lightningcss-linux-x64-gnu@1.32.0:
|
||||||
resolution: {integrity: sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==}
|
resolution: {integrity: sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==}
|
||||||
engines: {node: '>= 12.0.0'}
|
engines: {node: '>= 12.0.0'}
|
||||||
cpu: [x64]
|
cpu: [x64]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
|
libc: [glibc]
|
||||||
|
|
||||||
lightningcss-linux-x64-musl@1.32.0:
|
lightningcss-linux-x64-musl@1.32.0:
|
||||||
resolution: {integrity: sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==}
|
resolution: {integrity: sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==}
|
||||||
engines: {node: '>= 12.0.0'}
|
engines: {node: '>= 12.0.0'}
|
||||||
cpu: [x64]
|
cpu: [x64]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
|
libc: [musl]
|
||||||
|
|
||||||
lightningcss-win32-arm64-msvc@1.32.0:
|
lightningcss-win32-arm64-msvc@1.32.0:
|
||||||
resolution: {integrity: sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==}
|
resolution: {integrity: sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==}
|
||||||
@@ -2764,6 +2841,13 @@ packages:
|
|||||||
react-is@17.0.2:
|
react-is@17.0.2:
|
||||||
resolution: {integrity: sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==}
|
resolution: {integrity: sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==}
|
||||||
|
|
||||||
|
react-leaflet@5.0.0:
|
||||||
|
resolution: {integrity: sha512-CWbTpr5vcHw5bt9i4zSlPEVQdTVcML390TjeDG0cK59z1ylexpqC6M1PJFjV8jD7CF+ACBFsLIDs6DRMoLEofw==}
|
||||||
|
peerDependencies:
|
||||||
|
leaflet: ^1.9.0
|
||||||
|
react: ^19.0.0
|
||||||
|
react-dom: ^19.0.0
|
||||||
|
|
||||||
react-redux@9.2.0:
|
react-redux@9.2.0:
|
||||||
resolution: {integrity: sha512-ROY9fvHhwOD9ySfrF0wmvu//bKCQ6AeZZq1nJNtbDC+kk5DuSuNX/n6YWYF/SYy7bSba4D4FSz8DJeKY/S/r+g==}
|
resolution: {integrity: sha512-ROY9fvHhwOD9ySfrF0wmvu//bKCQ6AeZZq1nJNtbDC+kk5DuSuNX/n6YWYF/SYy7bSba4D4FSz8DJeKY/S/r+g==}
|
||||||
peerDependencies:
|
peerDependencies:
|
||||||
@@ -3123,6 +3207,9 @@ packages:
|
|||||||
peerDependencies:
|
peerDependencies:
|
||||||
typescript: '>=4.8.4'
|
typescript: '>=4.8.4'
|
||||||
|
|
||||||
|
tslib@2.8.1:
|
||||||
|
resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==}
|
||||||
|
|
||||||
type-check@0.4.0:
|
type-check@0.4.0:
|
||||||
resolution: {integrity: sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==}
|
resolution: {integrity: sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==}
|
||||||
engines: {node: '>= 0.8.0'}
|
engines: {node: '>= 0.8.0'}
|
||||||
@@ -4208,6 +4295,31 @@ snapshots:
|
|||||||
|
|
||||||
'@csstools/css-tokenizer@3.0.4': {}
|
'@csstools/css-tokenizer@3.0.4': {}
|
||||||
|
|
||||||
|
'@dnd-kit/accessibility@3.1.1(react@19.2.6)':
|
||||||
|
dependencies:
|
||||||
|
react: 19.2.6
|
||||||
|
tslib: 2.8.1
|
||||||
|
|
||||||
|
'@dnd-kit/core@6.3.1(react-dom@19.2.6(react@19.2.6))(react@19.2.6)':
|
||||||
|
dependencies:
|
||||||
|
'@dnd-kit/accessibility': 3.1.1(react@19.2.6)
|
||||||
|
'@dnd-kit/utilities': 3.2.2(react@19.2.6)
|
||||||
|
react: 19.2.6
|
||||||
|
react-dom: 19.2.6(react@19.2.6)
|
||||||
|
tslib: 2.8.1
|
||||||
|
|
||||||
|
'@dnd-kit/sortable@10.0.0(@dnd-kit/core@6.3.1(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(react@19.2.6)':
|
||||||
|
dependencies:
|
||||||
|
'@dnd-kit/core': 6.3.1(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
|
||||||
|
'@dnd-kit/utilities': 3.2.2(react@19.2.6)
|
||||||
|
react: 19.2.6
|
||||||
|
tslib: 2.8.1
|
||||||
|
|
||||||
|
'@dnd-kit/utilities@3.2.2(react@19.2.6)':
|
||||||
|
dependencies:
|
||||||
|
react: 19.2.6
|
||||||
|
tslib: 2.8.1
|
||||||
|
|
||||||
'@esbuild/aix-ppc64@0.25.12':
|
'@esbuild/aix-ppc64@0.25.12':
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
@@ -4398,6 +4510,12 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
playwright: 1.59.1
|
playwright: 1.59.1
|
||||||
|
|
||||||
|
'@react-leaflet/core@3.0.0(leaflet@1.9.4)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)':
|
||||||
|
dependencies:
|
||||||
|
leaflet: 1.9.4
|
||||||
|
react: 19.2.6
|
||||||
|
react-dom: 19.2.6(react@19.2.6)
|
||||||
|
|
||||||
'@reduxjs/toolkit@2.11.2(react-redux@9.2.0(@types/react@19.2.14)(react@19.2.6)(redux@5.0.1))(react@19.2.6)':
|
'@reduxjs/toolkit@2.11.2(react-redux@9.2.0(@types/react@19.2.14)(react@19.2.6)(redux@5.0.1))(react@19.2.6)':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@standard-schema/spec': 1.1.0
|
'@standard-schema/spec': 1.1.0
|
||||||
@@ -4711,8 +4829,14 @@ snapshots:
|
|||||||
|
|
||||||
'@types/estree@1.0.9': {}
|
'@types/estree@1.0.9': {}
|
||||||
|
|
||||||
|
'@types/geojson@7946.0.16': {}
|
||||||
|
|
||||||
'@types/json-schema@7.0.15': {}
|
'@types/json-schema@7.0.15': {}
|
||||||
|
|
||||||
|
'@types/leaflet@1.9.21':
|
||||||
|
dependencies:
|
||||||
|
'@types/geojson': 7946.0.16
|
||||||
|
|
||||||
'@types/node@25.6.2':
|
'@types/node@25.6.2':
|
||||||
dependencies:
|
dependencies:
|
||||||
undici-types: 7.19.2
|
undici-types: 7.19.2
|
||||||
@@ -5884,6 +6008,8 @@ snapshots:
|
|||||||
|
|
||||||
kysely@0.28.17: {}
|
kysely@0.28.17: {}
|
||||||
|
|
||||||
|
leaflet@1.9.4: {}
|
||||||
|
|
||||||
leven@3.1.0: {}
|
leven@3.1.0: {}
|
||||||
|
|
||||||
levn@0.4.1:
|
levn@0.4.1:
|
||||||
@@ -6135,6 +6261,13 @@ snapshots:
|
|||||||
|
|
||||||
react-is@17.0.2: {}
|
react-is@17.0.2: {}
|
||||||
|
|
||||||
|
react-leaflet@5.0.0(leaflet@1.9.4)(react-dom@19.2.6(react@19.2.6))(react@19.2.6):
|
||||||
|
dependencies:
|
||||||
|
'@react-leaflet/core': 3.0.0(leaflet@1.9.4)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
|
||||||
|
leaflet: 1.9.4
|
||||||
|
react: 19.2.6
|
||||||
|
react-dom: 19.2.6(react@19.2.6)
|
||||||
|
|
||||||
react-redux@9.2.0(@types/react@19.2.14)(react@19.2.6)(redux@5.0.1):
|
react-redux@9.2.0(@types/react@19.2.14)(react@19.2.6)(redux@5.0.1):
|
||||||
dependencies:
|
dependencies:
|
||||||
'@types/use-sync-external-store': 0.0.6
|
'@types/use-sync-external-store': 0.0.6
|
||||||
@@ -6162,7 +6295,7 @@ snapshots:
|
|||||||
|
|
||||||
react@19.2.6: {}
|
react@19.2.6: {}
|
||||||
|
|
||||||
recharts@3.8.1(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react-is@16.13.1)(react@19.2.6)(redux@5.0.1):
|
recharts@3.8.1(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react-is@17.0.2)(react@19.2.6)(redux@5.0.1):
|
||||||
dependencies:
|
dependencies:
|
||||||
'@reduxjs/toolkit': 2.11.2(react-redux@9.2.0(@types/react@19.2.14)(react@19.2.6)(redux@5.0.1))(react@19.2.6)
|
'@reduxjs/toolkit': 2.11.2(react-redux@9.2.0(@types/react@19.2.14)(react@19.2.6)(redux@5.0.1))(react@19.2.6)
|
||||||
clsx: 2.1.1
|
clsx: 2.1.1
|
||||||
@@ -6172,7 +6305,7 @@ snapshots:
|
|||||||
immer: 10.2.0
|
immer: 10.2.0
|
||||||
react: 19.2.6
|
react: 19.2.6
|
||||||
react-dom: 19.2.6(react@19.2.6)
|
react-dom: 19.2.6(react@19.2.6)
|
||||||
react-is: 16.13.1
|
react-is: 17.0.2
|
||||||
react-redux: 9.2.0(@types/react@19.2.14)(react@19.2.6)(redux@5.0.1)
|
react-redux: 9.2.0(@types/react@19.2.14)(react@19.2.6)(redux@5.0.1)
|
||||||
reselect: 5.1.1
|
reselect: 5.1.1
|
||||||
tiny-invariant: 1.3.3
|
tiny-invariant: 1.3.3
|
||||||
@@ -6553,6 +6686,8 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
typescript: 5.9.3
|
typescript: 5.9.3
|
||||||
|
|
||||||
|
tslib@2.8.1: {}
|
||||||
|
|
||||||
type-check@0.4.0:
|
type-check@0.4.0:
|
||||||
dependencies:
|
dependencies:
|
||||||
prelude-ls: 1.2.1
|
prelude-ls: 1.2.1
|
||||||
|
|||||||
@@ -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>
|
|
||||||
@@ -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"}
|
||||||
|
]
|
||||||
|
}
|
||||||
+41
-8
@@ -8,6 +8,7 @@ import { StaffPage } from "./pages/Staff.js";
|
|||||||
import { InvoicesPage } from "./pages/Invoices.js";
|
import { InvoicesPage } from "./pages/Invoices.js";
|
||||||
import { BookPage } from "./pages/Book.js";
|
import { BookPage } from "./pages/Book.js";
|
||||||
import { ReportsPage } from "./pages/Reports.js";
|
import { ReportsPage } from "./pages/Reports.js";
|
||||||
|
import { RoutesPage } from "./pages/Routes.js";
|
||||||
import { GroupBookingPage } from "./pages/GroupBooking.js";
|
import { GroupBookingPage } from "./pages/GroupBooking.js";
|
||||||
import { SettingsPage } from "./pages/Settings.js";
|
import { SettingsPage } from "./pages/Settings.js";
|
||||||
import { BookingConfirmedPage } from "./pages/BookingConfirmed.js";
|
import { BookingConfirmedPage } from "./pages/BookingConfirmed.js";
|
||||||
@@ -15,6 +16,7 @@ import { BookingCancelledPage } from "./pages/BookingCancelled.js";
|
|||||||
import { BookingErrorPage } from "./pages/BookingError.js";
|
import { BookingErrorPage } from "./pages/BookingError.js";
|
||||||
import { SetupWizard } from "./pages/SetupWizard.tsx";
|
import { SetupWizard } from "./pages/SetupWizard.tsx";
|
||||||
import { CustomerPortal } from "./portal/CustomerPortal.js";
|
import { CustomerPortal } from "./portal/CustomerPortal.js";
|
||||||
|
import { OOBE } from "./portal/OOBE.js";
|
||||||
import { DevLoginSelector, getDevUser } from "./pages/DevLoginSelector.js";
|
import { DevLoginSelector, getDevUser } from "./pages/DevLoginSelector.js";
|
||||||
import { DevSessionIndicator } from "./components/DevSessionIndicator.js";
|
import { DevSessionIndicator } from "./components/DevSessionIndicator.js";
|
||||||
import { BrandingProvider, useBranding } from "./BrandingContext.js";
|
import { BrandingProvider, useBranding } from "./BrandingContext.js";
|
||||||
@@ -175,6 +177,7 @@ const NAV_LINKS = [
|
|||||||
{ to: "/admin/staff", label: "Staff" },
|
{ to: "/admin/staff", label: "Staff" },
|
||||||
{ to: "/admin/invoices", label: "Invoices" },
|
{ to: "/admin/invoices", label: "Invoices" },
|
||||||
{ to: "/admin/group-bookings", label: "Group Bookings" },
|
{ to: "/admin/group-bookings", label: "Group Bookings" },
|
||||||
|
{ to: "/admin/routes", label: "Routes" },
|
||||||
{ to: "/admin/reports", label: "Reports" },
|
{ to: "/admin/reports", label: "Reports" },
|
||||||
{ to: "/admin/settings", label: "Settings" },
|
{ to: "/admin/settings", label: "Settings" },
|
||||||
{ to: "/", label: "Customer Portal" },
|
{ to: "/", label: "Customer Portal" },
|
||||||
@@ -303,6 +306,7 @@ function AdminLayout() {
|
|||||||
<Route path="/invoices" element={<InvoicesPage />} />
|
<Route path="/invoices" element={<InvoicesPage />} />
|
||||||
<Route path="/book" element={<BookPage />} />
|
<Route path="/book" element={<BookPage />} />
|
||||||
<Route path="/group-bookings" element={<GroupBookingPage />} />
|
<Route path="/group-bookings" element={<GroupBookingPage />} />
|
||||||
|
<Route path="/routes" element={<RoutesPage />} />
|
||||||
<Route path="/reports" element={<ReportsPage />} />
|
<Route path="/reports" element={<ReportsPage />} />
|
||||||
<Route path="/settings" element={<SettingsPage />} />
|
<Route path="/settings" element={<SettingsPage />} />
|
||||||
</Routes>
|
</Routes>
|
||||||
@@ -327,11 +331,16 @@ export function App() {
|
|||||||
.catch(() => setAuthDisabled(false));
|
.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(() => {
|
useEffect(() => {
|
||||||
if (authDisabled === null || sessionLoading) return;
|
if (authDisabled === null || sessionLoading) return;
|
||||||
// Skip if no authenticated session (will redirect to login or dev selector)
|
// In dev mode, only fetch when a dev user has been selected — otherwise the
|
||||||
if (!authDisabled && !session) return;
|
// user is mid-redirect to the dev login selector and we don't need setup state.
|
||||||
if (authDisabled && !getDevUser()) return;
|
if (authDisabled && !getDevUser()) return;
|
||||||
|
|
||||||
fetch("/api/setup/status")
|
fetch("/api/setup/status")
|
||||||
@@ -373,8 +382,12 @@ export function App() {
|
|||||||
return <Navigate to="/login" replace />;
|
return <Navigate to="/login" replace />;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Show login BEFORE checking needsSetup (needsSetup is never set for unauthenticated users)
|
// Show login BEFORE checking needsSetup (needsSetup is never set for unauthenticated users).
|
||||||
if (!authDisabled && !session) {
|
// At /login with a valid session, fall through so the staff redirect below can
|
||||||
|
// route staff to /admin and the final render can redirect customers to / (portal).
|
||||||
|
// Previously, an authenticated customer at /login would see a blank page because
|
||||||
|
// the final render returns null at /login (showCustomerPortal is false). See GRO-2099.
|
||||||
|
if (!authDisabled && !session && location.pathname === "/login") {
|
||||||
return <LoginPage />;
|
return <LoginPage />;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -386,14 +399,29 @@ export function App() {
|
|||||||
return <Navigate to="/setup" replace />;
|
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);
|
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 />;
|
return <Navigate to="/admin" replace />;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Don't render portal chrome at /login — DevLoginSelector is shown instead
|
// Don't render portal chrome at /login — DevLoginSelector is shown instead
|
||||||
const showCustomerPortal = !location.pathname.startsWith("/admin") && location.pathname !== "/login";
|
const showCustomerPortal = !location.pathname.startsWith("/admin") && location.pathname !== "/login" && location.pathname !== "/onboarding";
|
||||||
|
// GRO-2359: OOBE is mountable from a direct link (deep-link to /onboarding)
|
||||||
|
// and from the post-auth callback (CustomerPortal navigates here when the
|
||||||
|
// SSO bridge returns 404). Render the OOBE component standalone so it's
|
||||||
|
// outside the portal chrome (no `!sessionId` guards, no `!initComplete`
|
||||||
|
// loading states to fight — the OOBE handles its own auth resolution).
|
||||||
|
const showOOBE = location.pathname === "/onboarding";
|
||||||
|
|
||||||
|
// At /login with a valid session, redirect to the portal root. Without this,
|
||||||
|
// the final render returns null at /login (showCustomerPortal is false) and
|
||||||
|
// the user sees a blank page after a successful sign-in. Staff are routed
|
||||||
|
// to /admin by the earlier staff check. See GRO-2099.
|
||||||
|
if (!authDisabled && session && location.pathname === "/login") {
|
||||||
|
return <Navigate to="/" replace />;
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<BrandingProvider>
|
<BrandingProvider>
|
||||||
@@ -404,6 +432,11 @@ export function App() {
|
|||||||
</Routes>
|
</Routes>
|
||||||
{authDisabled && <DevSessionIndicator />}
|
{authDisabled && <DevSessionIndicator />}
|
||||||
</>
|
</>
|
||||||
|
) : showOOBE ? (
|
||||||
|
<>
|
||||||
|
<OOBE />
|
||||||
|
{authDisabled && <DevSessionIndicator />}
|
||||||
|
</>
|
||||||
) : showCustomerPortal ? (
|
) : showCustomerPortal ? (
|
||||||
<>
|
<>
|
||||||
<CustomerPortal />
|
<CustomerPortal />
|
||||||
|
|||||||
@@ -0,0 +1,77 @@
|
|||||||
|
import { Component } from "react";
|
||||||
|
import type { ErrorInfo, ReactNode } from "react";
|
||||||
|
|
||||||
|
interface ErrorBoundaryProps {
|
||||||
|
children: ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ErrorBoundaryState {
|
||||||
|
error: Error | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Top-level ErrorBoundary — renders the error visibly so the actual exception
|
||||||
|
* appears in the DOM (and therefore in the Playwright snapshot) instead of
|
||||||
|
* React 18+ unmounting the entire tree to a blank `<div id="root">`.
|
||||||
|
*
|
||||||
|
* Background: GRO-2094. The bundle was executing but never painting, with
|
||||||
|
* the failure swallowed. Surfacing the error here is the first step; the
|
||||||
|
* real fix is in the underlying component that threw.
|
||||||
|
*/
|
||||||
|
export class ErrorBoundary extends Component<ErrorBoundaryProps, ErrorBoundaryState> {
|
||||||
|
state: ErrorBoundaryState = { error: null };
|
||||||
|
|
||||||
|
static getDerivedStateFromError(error: Error): ErrorBoundaryState {
|
||||||
|
return { error };
|
||||||
|
}
|
||||||
|
|
||||||
|
componentDidCatch(error: Error, info: ErrorInfo): void {
|
||||||
|
// Also surface to the console — this is what the test harness greps for.
|
||||||
|
// eslint-disable-next-line no-console
|
||||||
|
console.error("[ErrorBoundary] Uncaught render error:", error, info);
|
||||||
|
}
|
||||||
|
|
||||||
|
render() {
|
||||||
|
if (this.state.error) {
|
||||||
|
const err = this.state.error;
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-testid="error-boundary"
|
||||||
|
style={{
|
||||||
|
padding: "2rem",
|
||||||
|
fontFamily: "ui-monospace, SFMono-Regular, Menlo, monospace",
|
||||||
|
color: "#7f1d1d",
|
||||||
|
background: "#fef2f2",
|
||||||
|
minHeight: "100vh",
|
||||||
|
boxSizing: "border-box",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<h1 style={{ fontSize: 18, margin: "0 0 0.5rem" }}>Something went wrong</h1>
|
||||||
|
<p style={{ margin: "0 0 1rem", color: "#991b1b" }}>
|
||||||
|
The app failed to render. The full error is shown below — please share this
|
||||||
|
output when reporting the bug.
|
||||||
|
</p>
|
||||||
|
<pre
|
||||||
|
data-testid="error-boundary-message"
|
||||||
|
style={{
|
||||||
|
whiteSpace: "pre-wrap",
|
||||||
|
wordBreak: "break-word",
|
||||||
|
background: "#fff",
|
||||||
|
border: "1px solid #fecaca",
|
||||||
|
borderRadius: 6,
|
||||||
|
padding: "0.75rem 1rem",
|
||||||
|
margin: 0,
|
||||||
|
fontSize: 13,
|
||||||
|
lineHeight: 1.4,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{err.name}: {err.message}
|
||||||
|
{"\n\n"}
|
||||||
|
{err.stack ?? "(no stack)"}
|
||||||
|
</pre>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return this.props.children;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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", () => {
|
describe("Dev login selector", () => {
|
||||||
it("redirects to /login when auth is disabled and no user selected", async () => {
|
it("redirects to /login when auth is disabled and no user selected", async () => {
|
||||||
global.fetch = vi.fn((url: string) => {
|
global.fetch = vi.fn((url: string) => {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||||
import { render, screen, fireEvent, waitFor } from "@testing-library/react";
|
import { render, screen, fireEvent, waitFor } from "@testing-library/react";
|
||||||
import { parseTimeTo24Hour, isUpcoming, CustomerNotesSection, ConfirmationSection } from "../portal/sections/Appointments.tsx";
|
import { parseTimeTo24Hour, isUpcoming, normalizeAppointment, normalizeService, formatServicePrice, CustomerNotesSection, ConfirmationSection, StatusBadge, normalizeStatusKey, deriveDisplayStatus, formatSlotLabel, slotToTime, BookingFlow } from "../portal/sections/Appointments.tsx";
|
||||||
|
|
||||||
const UPCOMING_APPT = {
|
const UPCOMING_APPT = {
|
||||||
id: "appt-1",
|
id: "appt-1",
|
||||||
@@ -42,6 +42,84 @@ describe("parseTimeTo24Hour", () => {
|
|||||||
expect(parseTimeTo24Hour("11:00 PM")).toBe("23:00:00");
|
expect(parseTimeTo24Hour("11:00 PM")).toBe("23:00:00");
|
||||||
expect(parseTimeTo24Hour("12:00 PM")).toBe("12:00:00");
|
expect(parseTimeTo24Hour("12:00 PM")).toBe("12:00:00");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("does not throw on undefined/null/empty input (GRO-2180)", () => {
|
||||||
|
expect(() => parseTimeTo24Hour(undefined)).not.toThrow();
|
||||||
|
expect(() => parseTimeTo24Hour(null)).not.toThrow();
|
||||||
|
expect(parseTimeTo24Hour(undefined)).toBe("00:00:00");
|
||||||
|
expect(parseTimeTo24Hour("")).toBe("00:00:00");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// GRO-2180: `/api/portal/appointments` returns ISO `startTime`/`endTime` + nested
|
||||||
|
// pet/service/staff objects, not the flat date/time/petName shape the UI renders.
|
||||||
|
describe("normalizeAppointment (API startTime shape — GRO-2180)", () => {
|
||||||
|
const RAW_API_APPT = {
|
||||||
|
id: "a0000001-0000-0000-0000-000000000001",
|
||||||
|
startTime: "2026-06-01T10:00:00.000Z",
|
||||||
|
endTime: "2026-06-01T10:45:00.000Z",
|
||||||
|
status: "completed" as const,
|
||||||
|
confirmationStatus: "confirmed" as const,
|
||||||
|
customerNotes: "Please be gentle",
|
||||||
|
notes: null,
|
||||||
|
pet: { id: "c0000001-0000-0000-0000-000000000001", name: "UAT Pup Alpha", photo: null },
|
||||||
|
service: { id: "b0000001-0000-0000-0000-000000000001", name: "Full Groom" },
|
||||||
|
staff: { id: "00000000-0000-0000-0000-000000000004", name: "UAT Staff Groomer" },
|
||||||
|
};
|
||||||
|
|
||||||
|
it("maps nested pet/service/staff and ISO startTime without throwing", () => {
|
||||||
|
const appt = normalizeAppointment(RAW_API_APPT);
|
||||||
|
expect(appt.id).toBe("a0000001-0000-0000-0000-000000000001");
|
||||||
|
expect(appt.petId).toBe("c0000001-0000-0000-0000-000000000001");
|
||||||
|
expect(appt.serviceId).toBe("b0000001-0000-0000-0000-000000000001");
|
||||||
|
expect(appt.groomerId).toBe("00000000-0000-0000-0000-000000000004");
|
||||||
|
expect(appt.petName).toBe("UAT Pup Alpha");
|
||||||
|
expect(appt.serviceName).toBe("Full Groom");
|
||||||
|
expect(appt.groomerName).toBe("UAT Staff Groomer");
|
||||||
|
expect(appt.startTime).toBe("2026-06-01T10:00:00.000Z");
|
||||||
|
expect(appt.customerNotes).toBe("Please be gentle");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("derives duration in minutes from start/end delta", () => {
|
||||||
|
expect(normalizeAppointment(RAW_API_APPT).duration).toBe(45);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("produces a date/time pair that does not crash isUpcoming or formatDate", () => {
|
||||||
|
const appt = normalizeAppointment(RAW_API_APPT);
|
||||||
|
expect(typeof appt.date).toBe("string");
|
||||||
|
expect(typeof appt.time).toBe("string");
|
||||||
|
expect(() => isUpcoming(appt)).not.toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("classifies a past completed appointment as not upcoming", () => {
|
||||||
|
expect(isUpcoming(normalizeAppointment(RAW_API_APPT))).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("classifies a future scheduled appointment as upcoming via startTime", () => {
|
||||||
|
const future = normalizeAppointment({
|
||||||
|
...RAW_API_APPT,
|
||||||
|
startTime: "2099-01-01T10:00:00.000Z",
|
||||||
|
endTime: "2099-01-01T11:00:00.000Z",
|
||||||
|
status: "confirmed",
|
||||||
|
});
|
||||||
|
expect(isUpcoming(future)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("tolerates null nested objects without throwing", () => {
|
||||||
|
const appt = normalizeAppointment({
|
||||||
|
id: "a2",
|
||||||
|
startTime: "2099-01-01T10:00:00.000Z",
|
||||||
|
endTime: "2099-01-01T11:00:00.000Z",
|
||||||
|
status: "scheduled",
|
||||||
|
pet: null,
|
||||||
|
service: null,
|
||||||
|
staff: null,
|
||||||
|
});
|
||||||
|
expect(appt.petId).toBe("");
|
||||||
|
expect(appt.serviceId).toBe("");
|
||||||
|
expect(appt.groomerId).toBeNull();
|
||||||
|
expect(appt.petName).toBeUndefined();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("isUpcoming", () => {
|
describe("isUpcoming", () => {
|
||||||
@@ -380,3 +458,526 @@ describe("ConfirmationSection", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("StatusBadge", () => {
|
||||||
|
it("renders Confirmed for confirmed status", () => {
|
||||||
|
render(<StatusBadge status="confirmed" />);
|
||||||
|
expect(screen.getByText("Confirmed")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders Pending for pending status", () => {
|
||||||
|
render(<StatusBadge status="pending" />);
|
||||||
|
expect(screen.getByText("Pending")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders Waitlisted for waitlisted status", () => {
|
||||||
|
render(<StatusBadge status="waitlisted" />);
|
||||||
|
expect(screen.getByText("Waitlisted")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders Completed for completed status", () => {
|
||||||
|
render(<StatusBadge status="completed" />);
|
||||||
|
expect(screen.getByText("Completed")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders Cancelled for cancelled status", () => {
|
||||||
|
render(<StatusBadge status="cancelled" />);
|
||||||
|
expect(screen.getByText("Cancelled")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("falls back to status string for unknown status", () => {
|
||||||
|
render(<StatusBadge status="custom-status" />);
|
||||||
|
expect(screen.getByText("custom-status")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("uses correct CSS class for confirmed status", () => {
|
||||||
|
render(<StatusBadge status="confirmed" />);
|
||||||
|
const badge = screen.getByText("Confirmed").closest('span');
|
||||||
|
expect(badge?.className).toContain("bg-green-100");
|
||||||
|
expect(badge?.className).toContain("text-green-700");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("uses correct CSS class for waitlisted status", () => {
|
||||||
|
render(<StatusBadge status="waitlisted" />);
|
||||||
|
const badge = screen.getByText("Waitlisted").closest('span');
|
||||||
|
expect(badge?.className).toContain("bg-blue-100");
|
||||||
|
expect(badge?.className).toContain("text-blue-600");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("uses correct CSS class for pending status", () => {
|
||||||
|
render(<StatusBadge status="pending" />);
|
||||||
|
const badge = screen.getByText("Pending").closest('span');
|
||||||
|
expect(badge?.className).toContain("bg-amber-100");
|
||||||
|
expect(badge?.className).toContain("text-amber-600");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("uses fallback styling for unknown status", () => {
|
||||||
|
render(<StatusBadge status="unknown" />);
|
||||||
|
const badge = screen.getByText("unknown").closest('span');
|
||||||
|
expect(badge?.className).toContain("bg-stone-100");
|
||||||
|
expect(badge?.className).toContain("text-stone-600");
|
||||||
|
});
|
||||||
|
|
||||||
|
// GRO-2319 item 1: DB stores `no_show` (underscore) but the palette key is
|
||||||
|
// `no-show` (hyphen) — without normalization it rendered raw gray text.
|
||||||
|
it("renders the styled No-show badge for DB `no_show` status", () => {
|
||||||
|
render(<StatusBadge status="no_show" />);
|
||||||
|
const badge = screen.getByText("No-show").closest('span');
|
||||||
|
expect(badge?.className).toContain("bg-yellow-100");
|
||||||
|
expect(badge?.className).toContain("text-yellow-700");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("normalizeStatusKey (GRO-2319 item 1)", () => {
|
||||||
|
it("maps underscore status keys to the hyphen palette key", () => {
|
||||||
|
expect(normalizeStatusKey("no_show")).toBe("no-show");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("leaves already-hyphenated / single-word keys unchanged", () => {
|
||||||
|
expect(normalizeStatusKey("no-show")).toBe("no-show");
|
||||||
|
expect(normalizeStatusKey("confirmed")).toBe("confirmed");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("deriveDisplayStatus (GRO-2319 item 2)", () => {
|
||||||
|
it("derives Pending for an upcoming, unconfirmed appointment", () => {
|
||||||
|
expect(
|
||||||
|
deriveDisplayStatus({ ...UPCOMING_APPT, status: "scheduled", confirmationStatus: "pending" }),
|
||||||
|
).toBe("pending");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps the raw status when the appointment is confirmed", () => {
|
||||||
|
expect(
|
||||||
|
deriveDisplayStatus({ ...UPCOMING_APPT, status: "confirmed", confirmationStatus: "confirmed" }),
|
||||||
|
).toBe("confirmed");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not derive Pending for a past appointment", () => {
|
||||||
|
expect(
|
||||||
|
deriveDisplayStatus({ ...PAST_APPT, status: "completed", confirmationStatus: "pending" }),
|
||||||
|
).toBe("completed");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("always shows Waitlisted for a waitlist-backed entry", () => {
|
||||||
|
expect(
|
||||||
|
deriveDisplayStatus({ ...UPCOMING_APPT, status: "waitlisted", confirmationStatus: undefined }),
|
||||||
|
).toBe("waitlisted");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("RescheduleFlow dynamic time slots", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
global.fetch = vi.fn();
|
||||||
|
});
|
||||||
|
|
||||||
|
const RESCHEDULE_APPT = {
|
||||||
|
id: "appt-r1",
|
||||||
|
petId: "pet-1",
|
||||||
|
petName: "Buddy",
|
||||||
|
groomerId: "groomer-1",
|
||||||
|
groomerName: "Sarah",
|
||||||
|
services: ["Bath & Brush"],
|
||||||
|
serviceId: "service-1",
|
||||||
|
addOns: [],
|
||||||
|
date: "2027-01-01",
|
||||||
|
time: "10:00 AM",
|
||||||
|
duration: 60,
|
||||||
|
price: 50,
|
||||||
|
status: "confirmed" as const,
|
||||||
|
notes: "",
|
||||||
|
customerNotes: "",
|
||||||
|
confirmationStatus: "confirmed" as const,
|
||||||
|
};
|
||||||
|
|
||||||
|
it("shows loading state while fetching availability", async () => {
|
||||||
|
vi.mocked(global.fetch).mockReturnValue(new Promise(() => {})); // Never resolves
|
||||||
|
|
||||||
|
const { RescheduleFlow } = await import("../portal/sections/Appointments.tsx");
|
||||||
|
render(<RescheduleFlow appointment={RESCHEDULE_APPT} onClose={() => {}} sessionId="test-session-id" />);
|
||||||
|
|
||||||
|
const dateInput = screen.getByLabelText(/date/i) || screen.getByRole("textbox", { name: /date/i });
|
||||||
|
fireEvent.change(dateInput, { target: { value: "2027-01-15" } });
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText(/Checking availability/i)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("displays fetched time slots from API", async () => {
|
||||||
|
vi.mocked(global.fetch).mockResolvedValue({
|
||||||
|
ok: true,
|
||||||
|
json: async () => ["9:00 AM", "10:00 AM", "2:00 PM"],
|
||||||
|
} as Response);
|
||||||
|
|
||||||
|
const { RescheduleFlow } = await import("../portal/sections/Appointments.tsx");
|
||||||
|
render(<RescheduleFlow appointment={RESCHEDULE_APPT} onClose={() => {}} sessionId="test-session-id" />);
|
||||||
|
|
||||||
|
const dateInput = screen.getByLabelText(/date/i) || screen.getByRole("textbox", { name: /date/i });
|
||||||
|
fireEvent.change(dateInput, { target: { value: "2027-01-15" } });
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText("9:00 AM")).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("10:00 AM")).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("2:00 PM")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows error state when availability fetch fails", async () => {
|
||||||
|
vi.mocked(global.fetch).mockRejectedValue(new Error("Network error"));
|
||||||
|
|
||||||
|
const { RescheduleFlow } = await import("../portal/sections/Appointments.tsx");
|
||||||
|
render(<RescheduleFlow appointment={RESCHEDULE_APPT} onClose={() => {}} sessionId="test-session-id" />);
|
||||||
|
|
||||||
|
const dateInput = screen.getByLabelText(/date/i) || screen.getByRole("textbox", { name: /date/i });
|
||||||
|
fireEvent.change(dateInput, { target: { value: "2027-01-15" } });
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText(/Failed to load time slots/i)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows no slots message when API returns empty array", async () => {
|
||||||
|
vi.mocked(global.fetch).mockResolvedValue({
|
||||||
|
ok: true,
|
||||||
|
json: async () => [] as string[],
|
||||||
|
} as Response);
|
||||||
|
|
||||||
|
const { RescheduleFlow } = await import("../portal/sections/Appointments.tsx");
|
||||||
|
render(<RescheduleFlow appointment={RESCHEDULE_APPT} onClose={() => {}} sessionId="test-session-id" />);
|
||||||
|
|
||||||
|
const dateInput = screen.getByLabelText(/date/i) || screen.getByRole("textbox", { name: /date/i });
|
||||||
|
fireEvent.change(dateInput, { target: { value: "2027-01-15" } });
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText(/No available slots on this date/i)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("calls /api/book/availability with the serviceId and selected date", async () => {
|
||||||
|
vi.mocked(global.fetch).mockResolvedValue({
|
||||||
|
ok: true,
|
||||||
|
json: async () => ["9:00 AM"] as string[],
|
||||||
|
} as Response);
|
||||||
|
|
||||||
|
const { RescheduleFlow } = await import("../portal/sections/Appointments.tsx");
|
||||||
|
render(<RescheduleFlow appointment={RESCHEDULE_APPT} onClose={() => {}} sessionId="test-session-id" />);
|
||||||
|
|
||||||
|
const dateInput = screen.getByLabelText(/date/i) || screen.getByRole("textbox", { name: /date/i });
|
||||||
|
fireEvent.change(dateInput, { target: { value: "2027-02-20" } });
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(global.fetch).toHaveBeenCalledWith(
|
||||||
|
"/api/book/availability?serviceId=service-1&date=2027-02-20",
|
||||||
|
expect.objectContaining({
|
||||||
|
headers: expect.objectContaining({ "X-Impersonation-Session-Id": "test-session-id" }),
|
||||||
|
})
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows error message when API returns a 4xx error object instead of an array", async () => {
|
||||||
|
vi.mocked(global.fetch).mockResolvedValue({
|
||||||
|
ok: false,
|
||||||
|
status: 400,
|
||||||
|
json: async () => ({ error: "serviceId and date are required" }),
|
||||||
|
} as Response);
|
||||||
|
|
||||||
|
const { RescheduleFlow } = await import("../portal/sections/Appointments.tsx");
|
||||||
|
render(<RescheduleFlow appointment={RESCHEDULE_APPT} onClose={() => {}} sessionId="test-session-id" />);
|
||||||
|
|
||||||
|
const dateInput = screen.getByLabelText(/date/i) || screen.getByRole("textbox", { name: /date/i });
|
||||||
|
fireEvent.change(dateInput, { target: { value: "2027-02-20" } });
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText(/serviceId and date are required/i)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows generic error when API returns 200 but body is not an array", async () => {
|
||||||
|
vi.mocked(global.fetch).mockResolvedValue({
|
||||||
|
ok: true,
|
||||||
|
json: async () => ({ error: "serviceId and date are required" }),
|
||||||
|
} as Response);
|
||||||
|
|
||||||
|
const { RescheduleFlow } = await import("../portal/sections/Appointments.tsx");
|
||||||
|
render(<RescheduleFlow appointment={RESCHEDULE_APPT} onClose={() => {}} sessionId="test-session-id" />);
|
||||||
|
|
||||||
|
const dateInput = screen.getByLabelText(/date/i) || screen.getByRole("textbox", { name: /date/i });
|
||||||
|
fireEvent.change(dateInput, { target: { value: "2027-02-20" } });
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText(/Failed to load time slots/i)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("re-fetches slots when date changes", async () => {
|
||||||
|
vi.mocked(global.fetch)
|
||||||
|
.mockResolvedValueOnce({
|
||||||
|
ok: true,
|
||||||
|
json: async () => ["9:00 AM"] as string[],
|
||||||
|
} as Response)
|
||||||
|
.mockResolvedValueOnce({
|
||||||
|
ok: true,
|
||||||
|
json: async () => ["11:00 AM", "1:00 PM"] as string[],
|
||||||
|
} as Response);
|
||||||
|
|
||||||
|
const { RescheduleFlow } = await import("../portal/sections/Appointments.tsx");
|
||||||
|
render(<RescheduleFlow appointment={RESCHEDULE_APPT} onClose={() => {}} sessionId="test-session-id" />);
|
||||||
|
|
||||||
|
const dateInput = screen.getByLabelText(/date/i) || screen.getByRole("textbox", { name: /date/i });
|
||||||
|
|
||||||
|
fireEvent.change(dateInput, { target: { value: "2027-01-10" } });
|
||||||
|
await waitFor(() => expect(screen.getByText("9:00 AM")).toBeInTheDocument());
|
||||||
|
|
||||||
|
fireEvent.change(dateInput, { target: { value: "2027-01-15" } });
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText("11:00 AM")).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("1:00 PM")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
describe("slot helpers (GRO-2213)", () => {
|
||||||
|
it("formatSlotLabel formats a canonical UTC ISO slot to a UTC clock label", () => {
|
||||||
|
expect(formatSlotLabel("2026-06-09T10:00:00.000Z")).toBe("10:00 AM");
|
||||||
|
expect(formatSlotLabel("2026-06-09T14:30:00.000Z")).toBe("2:30 PM");
|
||||||
|
expect(formatSlotLabel("2026-06-09T09:00:00.000Z")).toBe("9:00 AM");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("formatSlotLabel never echoes a raw ISO string", () => {
|
||||||
|
expect(formatSlotLabel("2026-06-09T10:00:00.000Z")).not.toMatch(/\d{4}-\d{2}-\d{2}T/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("formatSlotLabel passes through an already-formatted label unchanged", () => {
|
||||||
|
expect(formatSlotLabel("10:00 AM")).toBe("10:00 AM");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("slotToTime extracts the UTC HH:MM:SS time component from an ISO slot", () => {
|
||||||
|
expect(slotToTime("2026-06-09T10:00:00.000Z")).toBe("10:00:00");
|
||||||
|
expect(slotToTime("2026-06-09T14:30:00.000Z")).toBe("14:30:00");
|
||||||
|
expect(slotToTime("2026-06-09T10:00:00.000Z")).toMatch(/^\d{2}:\d{2}:\d{2}$/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("slotToTime guards a value that is already HH:MM:SS", () => {
|
||||||
|
expect(slotToTime("10:00:00")).toBe("10:00:00");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("slotToTime converts a 12-hour label fallback to HH:MM:SS", () => {
|
||||||
|
expect(slotToTime("9:00 AM")).toBe("09:00:00");
|
||||||
|
expect(slotToTime("2:30 PM")).toBe("14:30:00");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("BookingFlow Book New funnel (GRO-2213)", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
global.fetch = vi.fn();
|
||||||
|
});
|
||||||
|
|
||||||
|
function routedFetch(captured: { waitlistBody?: Record<string, unknown> }) {
|
||||||
|
return (input: RequestInfo | URL, init?: RequestInit) => {
|
||||||
|
const url = typeof input === "string" ? input : input.toString();
|
||||||
|
if (url.includes("/api/portal/pets")) {
|
||||||
|
return Promise.resolve({
|
||||||
|
ok: true,
|
||||||
|
json: async () => ({ pets: [{ id: "pet-1", name: "Buddy", breed: "Lab" }] }),
|
||||||
|
} as Response);
|
||||||
|
}
|
||||||
|
if (url.includes("/api/portal/services")) {
|
||||||
|
return Promise.resolve({
|
||||||
|
ok: true,
|
||||||
|
json: async () => ({
|
||||||
|
services: [{ id: "service-1", name: "Bath & Brush", isAddOn: false, duration: 60, price: 50 }],
|
||||||
|
}),
|
||||||
|
} as Response);
|
||||||
|
}
|
||||||
|
if (url.includes("/api/book/availability")) {
|
||||||
|
return Promise.resolve({
|
||||||
|
ok: true,
|
||||||
|
json: async () => ["2026-06-09T10:00:00.000Z", "2026-06-09T14:30:00.000Z"],
|
||||||
|
} as Response);
|
||||||
|
}
|
||||||
|
if (url.includes("/api/portal/waitlist")) {
|
||||||
|
captured.waitlistBody = JSON.parse((init?.body as string) ?? "{}");
|
||||||
|
return Promise.resolve({ ok: true, json: async () => ({}) } as Response);
|
||||||
|
}
|
||||||
|
return Promise.resolve({ ok: true, json: async () => ({}) } as Response);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
it("renders formatted slot labels (not raw ISO) and submits preferredTime as HH:MM:SS", async () => {
|
||||||
|
const captured: { waitlistBody?: Record<string, unknown> } = {};
|
||||||
|
vi.mocked(global.fetch).mockImplementation(routedFetch(captured) as typeof fetch);
|
||||||
|
|
||||||
|
render(<BookingFlow onClose={() => {}} sessionId="test-session-id" />);
|
||||||
|
|
||||||
|
// Step 1 — pick pet (auto-advances to step 2)
|
||||||
|
await waitFor(() => expect(screen.getByText("Buddy")).toBeInTheDocument());
|
||||||
|
fireEvent.click(screen.getByText("Buddy"));
|
||||||
|
|
||||||
|
// Step 2 — pick service, then Next
|
||||||
|
await waitFor(() => expect(screen.getByText("Bath & Brush")).toBeInTheDocument());
|
||||||
|
fireEvent.click(screen.getByText("Bath & Brush"));
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: /^Next$/ }));
|
||||||
|
|
||||||
|
// Step 3 — groomer, Next
|
||||||
|
await waitFor(() => expect(screen.getByText("First Available")).toBeInTheDocument());
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: /^Next$/ }));
|
||||||
|
|
||||||
|
// Step 4 — date + slot
|
||||||
|
await waitFor(() => expect(screen.getByLabelText(/date/i)).toBeInTheDocument());
|
||||||
|
fireEvent.change(screen.getByLabelText(/date/i), { target: { value: "2026-06-09" } });
|
||||||
|
|
||||||
|
// Slot button shows the formatted UTC label, never the raw ISO
|
||||||
|
await waitFor(() => expect(screen.getByText("10:00 AM")).toBeInTheDocument());
|
||||||
|
expect(screen.queryByText(/2026-06-09T10:00:00/)).not.toBeInTheDocument();
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByText("10:00 AM"));
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: /^Next$/ }));
|
||||||
|
|
||||||
|
// Step 5 — review shows the formatted label
|
||||||
|
await waitFor(() => expect(screen.getByText(/Review & Confirm/i)).toBeInTheDocument());
|
||||||
|
expect(screen.getByText(/10:00 AM/)).toBeInTheDocument();
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: /Confirm Booking/i }));
|
||||||
|
|
||||||
|
await waitFor(() => expect(captured.waitlistBody).toBeDefined());
|
||||||
|
const body = captured.waitlistBody ?? {};
|
||||||
|
expect(body.preferredTime).toMatch(/^\d{2}:\d{2}:\d{2}$/);
|
||||||
|
expect(body.preferredTime).toBe("10:00:00");
|
||||||
|
expect(body.preferredDate).toBe("2026-06-09");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("re-mints the portal session and retries once when waitlist returns 401 (GRO-2234)", async () => {
|
||||||
|
const calls = { waitlist: 0, remint: 0 };
|
||||||
|
const waitlistHeaders: string[] = [];
|
||||||
|
const routed = (input: RequestInfo | URL, init?: RequestInit) => {
|
||||||
|
const url = typeof input === "string" ? input : input.toString();
|
||||||
|
if (url.includes("/api/portal/pets")) {
|
||||||
|
return Promise.resolve({
|
||||||
|
ok: true,
|
||||||
|
json: async () => ({ pets: [{ id: "pet-1", name: "Buddy", breed: "Lab" }] }),
|
||||||
|
} as Response);
|
||||||
|
}
|
||||||
|
if (url.includes("/api/portal/services")) {
|
||||||
|
return Promise.resolve({
|
||||||
|
ok: true,
|
||||||
|
json: async () => ({
|
||||||
|
services: [{ id: "service-1", name: "Bath & Brush", isAddOn: false, duration: 60, price: 50 }],
|
||||||
|
}),
|
||||||
|
} as Response);
|
||||||
|
}
|
||||||
|
if (url.includes("/api/book/availability")) {
|
||||||
|
return Promise.resolve({
|
||||||
|
ok: true,
|
||||||
|
json: async () => ["2026-06-09T10:00:00.000Z"],
|
||||||
|
} as Response);
|
||||||
|
}
|
||||||
|
if (url.includes("/api/portal/session-from-auth")) {
|
||||||
|
calls.remint += 1;
|
||||||
|
return Promise.resolve({
|
||||||
|
ok: true,
|
||||||
|
json: async () => ({ sessionId: "fresh-session-id", clientId: "c1", clientName: "Jane" }),
|
||||||
|
} as Response);
|
||||||
|
}
|
||||||
|
if (url.includes("/api/portal/waitlist")) {
|
||||||
|
calls.waitlist += 1;
|
||||||
|
const headers = (init?.headers ?? {}) as Record<string, string>;
|
||||||
|
waitlistHeaders.push(headers["X-Impersonation-Session-Id"] ?? "");
|
||||||
|
// First attempt: session lapsed → 401. Retry after re-mint: success.
|
||||||
|
if (calls.waitlist === 1) {
|
||||||
|
return Promise.resolve({ ok: false, status: 401, json: async () => ({ error: "Unauthorized" }) } as Response);
|
||||||
|
}
|
||||||
|
return Promise.resolve({ ok: true, status: 201, json: async () => ({}) } as Response);
|
||||||
|
}
|
||||||
|
return Promise.resolve({ ok: true, json: async () => ({}) } as Response);
|
||||||
|
};
|
||||||
|
global.fetch = vi.fn().mockImplementation(routed as typeof fetch);
|
||||||
|
|
||||||
|
render(<BookingFlow onClose={() => {}} sessionId="stale-session-id" />);
|
||||||
|
|
||||||
|
await waitFor(() => expect(screen.getByText("Buddy")).toBeInTheDocument());
|
||||||
|
fireEvent.click(screen.getByText("Buddy"));
|
||||||
|
await waitFor(() => expect(screen.getByText("Bath & Brush")).toBeInTheDocument());
|
||||||
|
fireEvent.click(screen.getByText("Bath & Brush"));
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: /^Next$/ }));
|
||||||
|
await waitFor(() => expect(screen.getByText("First Available")).toBeInTheDocument());
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: /^Next$/ }));
|
||||||
|
await waitFor(() => expect(screen.getByLabelText(/date/i)).toBeInTheDocument());
|
||||||
|
fireEvent.change(screen.getByLabelText(/date/i), { target: { value: "2026-06-09" } });
|
||||||
|
await waitFor(() => expect(screen.getByText("10:00 AM")).toBeInTheDocument());
|
||||||
|
fireEvent.click(screen.getByText("10:00 AM"));
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: /^Next$/ }));
|
||||||
|
await waitFor(() => expect(screen.getByText(/Review & Confirm/i)).toBeInTheDocument());
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: /Confirm Booking/i }));
|
||||||
|
|
||||||
|
// Re-mint happened exactly once, waitlist retried with the fresh id, and the
|
||||||
|
// booking succeeded (no error surfaced).
|
||||||
|
await waitFor(() => expect(calls.waitlist).toBe(2));
|
||||||
|
expect(calls.remint).toBe(1);
|
||||||
|
expect(waitlistHeaders).toEqual(["stale-session-id", "fresh-session-id"]);
|
||||||
|
expect(screen.queryByText(/Failed to book appointment/i)).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("normalizeService", () => {
|
||||||
|
it("maps API basePriceCents/durationMinutes to price (dollars)/duration", () => {
|
||||||
|
const svc = normalizeService({
|
||||||
|
id: "svc-1",
|
||||||
|
name: "Full Groom",
|
||||||
|
basePriceCents: 4500,
|
||||||
|
durationMinutes: 60,
|
||||||
|
});
|
||||||
|
expect(svc.price).toBe(45);
|
||||||
|
expect(svc.duration).toBe(60);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("preserves an already-normalized payload (price/duration)", () => {
|
||||||
|
const svc = normalizeService({
|
||||||
|
id: "svc-2",
|
||||||
|
name: "Bath",
|
||||||
|
price: 30,
|
||||||
|
duration: 30,
|
||||||
|
});
|
||||||
|
expect(svc.price).toBe(30);
|
||||||
|
expect(svc.duration).toBe(30);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("leaves price/duration undefined when both source shapes are absent", () => {
|
||||||
|
const svc = normalizeService({ id: "svc-3", name: "Mystery" });
|
||||||
|
expect(svc.price).toBeUndefined();
|
||||||
|
expect(svc.duration).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("coerces null fields to undefined", () => {
|
||||||
|
const svc = normalizeService({
|
||||||
|
id: "svc-4",
|
||||||
|
name: "Nail Trim",
|
||||||
|
basePriceCents: null,
|
||||||
|
durationMinutes: null,
|
||||||
|
description: null,
|
||||||
|
});
|
||||||
|
expect(svc.price).toBeUndefined();
|
||||||
|
expect(svc.duration).toBeUndefined();
|
||||||
|
expect(svc.description).toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("formatServicePrice", () => {
|
||||||
|
it("prefers an explicit priceRange string", () => {
|
||||||
|
expect(formatServicePrice({ priceRange: "$40–$60", price: 45 })).toBe("$40–$60");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("formats integer dollars without trailing zeros", () => {
|
||||||
|
expect(formatServicePrice({ price: 45 })).toBe("$45");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("formats fractional dollars to cents", () => {
|
||||||
|
expect(formatServicePrice({ price: 45.5 })).toBe("$45.50");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns null when no price is available (never '$undefined')", () => {
|
||||||
|
expect(formatServicePrice({})).toBeNull();
|
||||||
|
expect(formatServicePrice({ price: undefined })).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -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", "/");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||||
|
import { render, screen, cleanup } from "@testing-library/react";
|
||||||
|
import { ErrorBoundary } from "../ErrorBoundary";
|
||||||
|
|
||||||
|
function ThrowingChild(): never {
|
||||||
|
throw new Error("synthetic render-time failure for GRO-2094");
|
||||||
|
}
|
||||||
|
|
||||||
|
function GoodChild() {
|
||||||
|
return <div data-testid="good-child">ok</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("ErrorBoundary (GRO-2094)", () => {
|
||||||
|
let errorSpy: ReturnType<typeof vi.spyOn>;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
// React 18+ logs caught render errors to console.error via React's own
|
||||||
|
// instrumentation; suppress it so test output is clean but capture it
|
||||||
|
// for an assertion below.
|
||||||
|
errorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
errorSpy.mockRestore();
|
||||||
|
cleanup();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders children when nothing throws", () => {
|
||||||
|
render(
|
||||||
|
<ErrorBoundary>
|
||||||
|
<GoodChild />
|
||||||
|
</ErrorBoundary>
|
||||||
|
);
|
||||||
|
expect(screen.getByTestId("good-child")).toBeInTheDocument();
|
||||||
|
expect(screen.queryByTestId("error-boundary")).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders the error visibly when a child throws during render", () => {
|
||||||
|
render(
|
||||||
|
<ErrorBoundary>
|
||||||
|
<ThrowingChild />
|
||||||
|
</ErrorBoundary>
|
||||||
|
);
|
||||||
|
|
||||||
|
const fallback = screen.getByTestId("error-boundary");
|
||||||
|
expect(fallback).toBeInTheDocument();
|
||||||
|
const message = screen.getByTestId("error-boundary-message");
|
||||||
|
// The actual exception is shown — no more silent blank root.
|
||||||
|
expect(message.textContent).toContain("synthetic render-time failure for GRO-2094");
|
||||||
|
// The boundary also calls console.error so it shows up in the Playwright
|
||||||
|
// console log even if the DOM-rendered fallback is somehow missed.
|
||||||
|
expect(errorSpy).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -154,4 +154,12 @@ describe("PetForm", () => {
|
|||||||
expect(screen.getByText("Anxious")).toBeTruthy();
|
expect(screen.getByText("Anxious")).toBeTruthy();
|
||||||
expect(screen.getByText("Good with kids")).toBeTruthy();
|
expect(screen.getByText("Good with kids")).toBeTruthy();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ── Weight pre-fill from portal `weight` key (GRO-2207) ───────────────────────
|
||||||
|
|
||||||
|
it("pre-fills weight from the portal `weight` key when weightKg is absent", () => {
|
||||||
|
const portalPet: Pet = { ...BASE_PET, weightKg: null, weight: "12.50" };
|
||||||
|
render(<PetForm pet={portalPet} onSave={onSave} onCancel={onCancel} />);
|
||||||
|
expect(screen.getByDisplayValue(12.5)).toBeTruthy();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
@@ -0,0 +1,80 @@
|
|||||||
|
import { describe, it, expect } from "vitest";
|
||||||
|
import { render, screen } from "@testing-library/react";
|
||||||
|
import { BasicInfoTab, formatSizeCategory } from "../portal/sections/PetProfiles.js";
|
||||||
|
import type { Pet } from "@groombook/types";
|
||||||
|
|
||||||
|
// The portal endpoint (GET /api/portal/pets) serializes DB columns under
|
||||||
|
// portal-shaped keys: weightKg→weight, dateOfBirth→birthDate. The read view
|
||||||
|
// must surface those keys (GRO-2207), not the raw staff-side weightKg/dateOfBirth.
|
||||||
|
const PORTAL_PET: Pet = {
|
||||||
|
id: "pet-1",
|
||||||
|
clientId: "client-1",
|
||||||
|
name: "Pup Alpha",
|
||||||
|
species: "dog",
|
||||||
|
breed: "Poodle",
|
||||||
|
// Staff-shaped keys intentionally null — only the portal keys are populated,
|
||||||
|
// proving the read view reads `weight`/`birthDate`.
|
||||||
|
weightKg: null,
|
||||||
|
dateOfBirth: null,
|
||||||
|
weight: "12.50",
|
||||||
|
birthDate: "2022-03-10T00:00:00.000Z",
|
||||||
|
petSizeCategory: "extra_large",
|
||||||
|
healthAlerts: null,
|
||||||
|
groomingNotes: null,
|
||||||
|
cutStyle: null,
|
||||||
|
shampooPreference: null,
|
||||||
|
specialCareNotes: null,
|
||||||
|
customFields: {},
|
||||||
|
coatType: null,
|
||||||
|
preferredCuts: [],
|
||||||
|
medicalAlerts: [],
|
||||||
|
createdAt: "2024-01-01T00:00:00.000Z",
|
||||||
|
updatedAt: "2024-01-01T00:00:00.000Z",
|
||||||
|
};
|
||||||
|
|
||||||
|
describe("BasicInfoTab read view (GRO-2207)", () => {
|
||||||
|
it("renders Weight from the portal `weight` key", () => {
|
||||||
|
render(<BasicInfoTab pet={PORTAL_PET} readOnly />);
|
||||||
|
expect(screen.getByText("12.50 kg")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders Date of Birth from the portal `birthDate` key", () => {
|
||||||
|
render(<BasicInfoTab pet={PORTAL_PET} readOnly />);
|
||||||
|
expect(screen.getByText("March 10, 2022")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders Size Category formatted from petSizeCategory", () => {
|
||||||
|
render(<BasicInfoTab pet={PORTAL_PET} readOnly />);
|
||||||
|
expect(screen.getByText("Size Category")).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("Extra Large")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("falls back to staff-shaped keys when portal keys are absent", () => {
|
||||||
|
const staffShaped: Pet = { ...PORTAL_PET, weight: null, birthDate: null, weightKg: 25, dateOfBirth: "2020-01-05T00:00:00.000Z" };
|
||||||
|
render(<BasicInfoTab pet={staffShaped} readOnly />);
|
||||||
|
expect(screen.getByText("25 kg")).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("January 5, 2020")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows Unknown for missing weight/DoB and size", () => {
|
||||||
|
const empty: Pet = { ...PORTAL_PET, weight: null, birthDate: null, weightKg: null, dateOfBirth: null, petSizeCategory: null };
|
||||||
|
render(<BasicInfoTab pet={empty} readOnly />);
|
||||||
|
// Weight, Date of Birth and Size Category rows all read "Unknown".
|
||||||
|
expect(screen.getAllByText("Unknown").length).toBeGreaterThanOrEqual(3);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("formatSizeCategory", () => {
|
||||||
|
it("title-cases each underscore-separated segment", () => {
|
||||||
|
expect(formatSizeCategory("extra_large")).toBe("Extra Large");
|
||||||
|
expect(formatSizeCategory("small")).toBe("Small");
|
||||||
|
expect(formatSizeCategory("medium")).toBe("Medium");
|
||||||
|
expect(formatSizeCategory("large")).toBe("Large");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns Unknown for null/undefined/empty", () => {
|
||||||
|
expect(formatSizeCategory(null)).toBe("Unknown");
|
||||||
|
expect(formatSizeCategory(undefined)).toBe("Unknown");
|
||||||
|
expect(formatSizeCategory("")).toBe("Unknown");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,211 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||||
|
import { render, screen, waitFor, fireEvent } from "@testing-library/react";
|
||||||
|
import { RoutesPage } from "../pages/Routes.tsx";
|
||||||
|
|
||||||
|
// Leaflet does not render in jsdom — replace the lazily-loaded map with a stub
|
||||||
|
// that just reports the stop count so we can assert it received the route data.
|
||||||
|
vi.mock("../components/RouteMap.js", () => ({
|
||||||
|
default: ({ stops }: { stops: unknown[] }) => (
|
||||||
|
<div data-testid="route-map">map:{stops.length}</div>
|
||||||
|
),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const MANAGER = { id: "m1", name: "Manager", role: "manager", active: true };
|
||||||
|
const GROOMER = { id: "g1", name: "Sam Groomer", role: "groomer", active: true };
|
||||||
|
|
||||||
|
const ROUTE_RESPONSE = {
|
||||||
|
route: {
|
||||||
|
id: "r1",
|
||||||
|
staffId: "g1",
|
||||||
|
routeDate: "2026-06-09",
|
||||||
|
status: "optimized",
|
||||||
|
totalTravelMins: 95,
|
||||||
|
totalDistanceKm: "42.50",
|
||||||
|
},
|
||||||
|
stops: [
|
||||||
|
{
|
||||||
|
id: "s1",
|
||||||
|
appointmentId: "a1",
|
||||||
|
stopOrder: 1,
|
||||||
|
latitude: 51.5,
|
||||||
|
longitude: -0.1,
|
||||||
|
travelMinsFromPrev: null,
|
||||||
|
travelDistanceKmFromPrev: null,
|
||||||
|
bufferMins: 15,
|
||||||
|
appointmentStartTime: "2026-06-09T09:00:00.000Z",
|
||||||
|
appointmentEndTime: "2026-06-09T10:00:00.000Z",
|
||||||
|
appointmentStatus: "confirmed",
|
||||||
|
clientId: "c1",
|
||||||
|
clientName: "Alice",
|
||||||
|
clientAddress: "1 High St",
|
||||||
|
conflict: { hasConflict: false },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "s2",
|
||||||
|
appointmentId: "a2",
|
||||||
|
stopOrder: 2,
|
||||||
|
latitude: 51.52,
|
||||||
|
longitude: -0.12,
|
||||||
|
travelMinsFromPrev: 20,
|
||||||
|
travelDistanceKmFromPrev: "8.00",
|
||||||
|
bufferMins: 15,
|
||||||
|
appointmentStartTime: "2026-06-09T11:00:00.000Z",
|
||||||
|
appointmentEndTime: "2026-06-09T12:00:00.000Z",
|
||||||
|
appointmentStatus: "confirmed",
|
||||||
|
clientId: "c2",
|
||||||
|
clientName: "Bob",
|
||||||
|
clientAddress: "2 Low St",
|
||||||
|
conflict: { hasConflict: true },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
hasConflicts: true,
|
||||||
|
conflictCount: 1,
|
||||||
|
};
|
||||||
|
|
||||||
|
function mockFetch(meRole: "manager" | "groomer") {
|
||||||
|
return vi.fn((url: string, opts?: RequestInit) => {
|
||||||
|
if (url === "/api/staff/me") {
|
||||||
|
return Promise.resolve({
|
||||||
|
ok: true,
|
||||||
|
json: () => Promise.resolve(meRole === "manager" ? MANAGER : GROOMER),
|
||||||
|
} as Response);
|
||||||
|
}
|
||||||
|
if (url === "/api/staff") {
|
||||||
|
return Promise.resolve({ ok: true, json: () => Promise.resolve([MANAGER, GROOMER]) } as Response);
|
||||||
|
}
|
||||||
|
if (url.startsWith("/api/routes/daily")) {
|
||||||
|
return Promise.resolve({ ok: true, json: () => Promise.resolve(ROUTE_RESPONSE) } as Response);
|
||||||
|
}
|
||||||
|
if (url === "/api/routes/optimize" && opts?.method === "POST") {
|
||||||
|
return Promise.resolve({ ok: true, json: () => Promise.resolve(ROUTE_RESPONSE) } as Response);
|
||||||
|
}
|
||||||
|
if (/^\/api\/routes\/[^/]+\/reorder$/.test(url) && opts?.method === "PATCH") {
|
||||||
|
return Promise.resolve({ ok: true, json: () => Promise.resolve(ROUTE_RESPONSE) } as Response);
|
||||||
|
}
|
||||||
|
if (/^\/api\/routes\/[^/]+\/export\/google-maps$/.test(url)) {
|
||||||
|
return Promise.resolve({
|
||||||
|
ok: true,
|
||||||
|
json: () => Promise.resolve({ platform: "google-maps", url: "https://www.google.com/maps/dir/?api=1", stopCount: 2, waypointCount: 0 }),
|
||||||
|
} as Response);
|
||||||
|
}
|
||||||
|
if (/^\/api\/routes\/[^/]+\/export\/apple-maps$/.test(url)) {
|
||||||
|
return Promise.resolve({
|
||||||
|
ok: true,
|
||||||
|
json: () => Promise.resolve({ platform: "apple-maps", url: "maps://?saddr=51.5,-0.1&daddr=51.52,-0.12", stopCount: 2, waypointCount: 0 }),
|
||||||
|
} as Response);
|
||||||
|
}
|
||||||
|
return Promise.resolve({ ok: true, json: () => Promise.resolve({}) } as Response);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.restoreAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("RoutesPage", () => {
|
||||||
|
it("renders stop cards, summary, status badge and map for a manager", async () => {
|
||||||
|
global.fetch = mockFetch("manager") as unknown as typeof fetch;
|
||||||
|
render(<RoutesPage />);
|
||||||
|
|
||||||
|
await waitFor(() => expect(screen.getByText("Alice")).toBeInTheDocument());
|
||||||
|
expect(screen.getByText("Bob")).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("1 High St")).toBeInTheDocument();
|
||||||
|
|
||||||
|
// Summary: travel time formatted, distance shown
|
||||||
|
expect(screen.getByText("1 h 35 min")).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("42.50 km")).toBeInTheDocument();
|
||||||
|
|
||||||
|
// Status badge
|
||||||
|
expect(screen.getByText("Optimized")).toBeInTheDocument();
|
||||||
|
|
||||||
|
// First stop is start-of-route; second shows travel from previous
|
||||||
|
expect(screen.getByText("Start of route")).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("20 min travel from previous")).toBeInTheDocument();
|
||||||
|
|
||||||
|
// Map received both stops (lazy chunk resolves asynchronously)
|
||||||
|
expect(await screen.findByTestId("route-map")).toHaveTextContent("map:2");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows the groomer selector for managers", async () => {
|
||||||
|
global.fetch = mockFetch("manager") as unknown as typeof fetch;
|
||||||
|
render(<RoutesPage />);
|
||||||
|
await waitFor(() => expect(screen.getByText("Groomer")).toBeInTheDocument());
|
||||||
|
});
|
||||||
|
|
||||||
|
it("hides the groomer selector for groomer role (auto-filtered)", async () => {
|
||||||
|
global.fetch = mockFetch("groomer") as unknown as typeof fetch;
|
||||||
|
render(<RoutesPage />);
|
||||||
|
await waitFor(() => expect(screen.getByText("Alice")).toBeInTheDocument());
|
||||||
|
expect(screen.queryByText("Groomer")).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders a drag handle for each stop (drag-to-reorder enabled)", async () => {
|
||||||
|
global.fetch = mockFetch("manager") as unknown as typeof fetch;
|
||||||
|
render(<RoutesPage />);
|
||||||
|
|
||||||
|
await waitFor(() => expect(screen.getByText("Alice")).toBeInTheDocument());
|
||||||
|
expect(screen.getByLabelText("Drag to reorder Alice")).toBeInTheDocument();
|
||||||
|
expect(screen.getByLabelText("Drag to reorder Bob")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("flags the tight-schedule conflict on the affected stop", async () => {
|
||||||
|
global.fetch = mockFetch("manager") as unknown as typeof fetch;
|
||||||
|
render(<RoutesPage />);
|
||||||
|
|
||||||
|
await waitFor(() => expect(screen.getByText("Bob")).toBeInTheDocument());
|
||||||
|
expect(
|
||||||
|
screen.getByText(/Tight schedule — travel \+ buffer may exceed the gap/)
|
||||||
|
).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not show the re-optimize hint before any manual reorder", async () => {
|
||||||
|
global.fetch = mockFetch("manager") as unknown as typeof fetch;
|
||||||
|
render(<RoutesPage />);
|
||||||
|
|
||||||
|
await waitFor(() => expect(screen.getByText("Alice")).toBeInTheDocument());
|
||||||
|
expect(screen.queryByText("Re-optimize")).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("calls the optimize endpoint when Optimize is clicked", async () => {
|
||||||
|
const fetchMock = mockFetch("manager");
|
||||||
|
global.fetch = fetchMock as unknown as typeof fetch;
|
||||||
|
render(<RoutesPage />);
|
||||||
|
|
||||||
|
await waitFor(() => expect(screen.getByText("Alice")).toBeInTheDocument());
|
||||||
|
fireEvent.click(screen.getByText("Optimize"));
|
||||||
|
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(fetchMock).toHaveBeenCalledWith(
|
||||||
|
"/api/routes/optimize",
|
||||||
|
expect.objectContaining({ method: "POST" })
|
||||||
|
)
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders both navigation export buttons when the route has stops", async () => {
|
||||||
|
global.fetch = mockFetch("manager") as unknown as typeof fetch;
|
||||||
|
render(<RoutesPage />);
|
||||||
|
|
||||||
|
await waitFor(() => expect(screen.getByText("Alice")).toBeInTheDocument());
|
||||||
|
expect(screen.getByRole("button", { name: "Open in Google Maps" })).toBeInTheDocument();
|
||||||
|
expect(screen.getByRole("button", { name: "Open in Apple Maps" })).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("fetches the export deep link and opens it when Open in Google Maps is clicked", async () => {
|
||||||
|
const fetchMock = mockFetch("manager");
|
||||||
|
global.fetch = fetchMock as unknown as typeof fetch;
|
||||||
|
const openSpy = vi
|
||||||
|
.spyOn(window, "open")
|
||||||
|
.mockReturnValue({ location: { href: "" }, close: vi.fn() } as unknown as Window);
|
||||||
|
|
||||||
|
render(<RoutesPage />);
|
||||||
|
await waitFor(() => expect(screen.getByText("Alice")).toBeInTheDocument());
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: "Open in Google Maps" }));
|
||||||
|
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(fetchMock).toHaveBeenCalledWith("/api/routes/r1/export/google-maps")
|
||||||
|
);
|
||||||
|
expect(openSpy).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -5,6 +5,34 @@ import { ImpersonationBanner } from "../portal/ImpersonationBanner.js";
|
|||||||
import { AuditLogViewer } from "../portal/AuditLogViewer.js";
|
import { AuditLogViewer } from "../portal/AuditLogViewer.js";
|
||||||
import type { ImpersonationSession, ImpersonationAuditLog } from "@groombook/types";
|
import type { ImpersonationSession, ImpersonationAuditLog } from "@groombook/types";
|
||||||
|
|
||||||
|
// Spy on the RescheduleFlow so we can assert the sessionId prop it receives
|
||||||
|
// from CustomerPortal without rendering the full flow UI. The real module is
|
||||||
|
// still loaded via importActual; only RescheduleFlow is swapped.
|
||||||
|
const rescheduleFlowSpy = vi.hoisted(() =>
|
||||||
|
vi.fn((_props: { sessionId: string | null; appointment: { id: string } }) => null)
|
||||||
|
);
|
||||||
|
vi.mock("../portal/sections/Appointments.js", async () => {
|
||||||
|
const actual = await vi.importActual<typeof import("../portal/sections/Appointments.js")>(
|
||||||
|
"../portal/sections/Appointments.js"
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
...actual,
|
||||||
|
RescheduleFlow: rescheduleFlowSpy,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
// Spy on the canonical `signOut()` from the shared auth-client so we can
|
||||||
|
// assert the no-access screen's logout button uses the SAME handler as
|
||||||
|
// `AdminLayout`. We mock at the module boundary — the no-access screen is
|
||||||
|
// the one authenticated surface that renders without the portal chrome, so
|
||||||
|
// a regression here would trap the user. We do NOT use `importActual`
|
||||||
|
// because the real `createAuthClient()` requires a runtime `baseURL`
|
||||||
|
// (Better Auth) that the JSDOM test environment can't supply.
|
||||||
|
const signOutSpy = vi.hoisted(() => vi.fn(async () => undefined));
|
||||||
|
vi.mock("../lib/auth-client.js", () => ({
|
||||||
|
signOut: signOutSpy,
|
||||||
|
}));
|
||||||
|
|
||||||
const SESSION: ImpersonationSession = {
|
const SESSION: ImpersonationSession = {
|
||||||
id: "sess-1",
|
id: "sess-1",
|
||||||
staffId: "staff-1",
|
staffId: "staff-1",
|
||||||
@@ -36,6 +64,22 @@ const AUDIT_LOGS: ImpersonationAuditLog[] = [
|
|||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
|
// ─── Shared test fixtures ───────────────────────────────────────────────────
|
||||||
|
|
||||||
|
// `brandingResponse` is the mock /api/branding payload used by every test
|
||||||
|
// in this file. Hoisted to module scope so the SSO bridge and the OOBE
|
||||||
|
// describe blocks can both reach it without redefining the same body.
|
||||||
|
const brandingResponse = {
|
||||||
|
ok: true,
|
||||||
|
json: async () => ({
|
||||||
|
businessName: "GroomBook",
|
||||||
|
primaryColor: "#4f8a6f",
|
||||||
|
accentColor: "#8b7355",
|
||||||
|
logoBase64: null,
|
||||||
|
logoMimeType: null,
|
||||||
|
}),
|
||||||
|
} as Response;
|
||||||
|
|
||||||
// ─── ImpersonationBanner ────────────────────────────────────────────────────
|
// ─── ImpersonationBanner ────────────────────────────────────────────────────
|
||||||
|
|
||||||
describe("ImpersonationBanner", () => {
|
describe("ImpersonationBanner", () => {
|
||||||
@@ -313,3 +357,775 @@ describe("CustomerPortal session loading", () => {
|
|||||||
Object.defineProperty(window, "location", { value: originalLocation, writable: true });
|
Object.defineProperty(window, "location", { value: originalLocation, writable: true });
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ─── CustomerPortal — Better Auth SSO bridge (GRO-1867) ────────────────────
|
||||||
|
|
||||||
|
describe("CustomerPortal SSO bridge", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
// Make sure no dev-user leaks across tests
|
||||||
|
window.localStorage.clear();
|
||||||
|
// Reset shared signOut() spy so per-test counts are deterministic
|
||||||
|
signOutSpy.mockClear();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("bridges Better Auth session via /api/portal/session-from-auth and uses returned sessionId", async () => {
|
||||||
|
global.fetch = vi.fn((input: RequestInfo, init?: RequestInit) => {
|
||||||
|
const url = typeof input === "string" ? input : input.toString();
|
||||||
|
if (url === "/api/branding") return Promise.resolve(brandingResponse);
|
||||||
|
if (url === "/api/auth/get-session") {
|
||||||
|
return Promise.resolve({
|
||||||
|
ok: true,
|
||||||
|
json: async () => ({ user: { email: "customer@example.com", role: "customer" } }),
|
||||||
|
} as Response);
|
||||||
|
}
|
||||||
|
if (url === "/api/portal/session-from-auth" && init?.method === "POST") {
|
||||||
|
return Promise.resolve({
|
||||||
|
ok: true,
|
||||||
|
status: 201,
|
||||||
|
json: async () => ({ sessionId: "sso-sess-1", clientId: "client-1", clientName: "Jane Doe" }),
|
||||||
|
} as Response);
|
||||||
|
}
|
||||||
|
// Subsequent portal API calls — surface them so we can assert the header
|
||||||
|
return Promise.resolve({ ok: true, json: async () => ({}) } as Response);
|
||||||
|
}) as unknown as typeof fetch;
|
||||||
|
|
||||||
|
const { CustomerPortal } = await import("../portal/CustomerPortal.js");
|
||||||
|
render(
|
||||||
|
<MemoryRouter initialEntries={["/"]}>
|
||||||
|
<CustomerPortal />
|
||||||
|
</MemoryRouter>
|
||||||
|
);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(global.fetch).toHaveBeenCalledWith("/api/auth/get-session", expect.objectContaining({ credentials: "include" }));
|
||||||
|
});
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(global.fetch).toHaveBeenCalledWith(
|
||||||
|
"/api/portal/session-from-auth",
|
||||||
|
expect.objectContaining({ method: "POST", credentials: "include" })
|
||||||
|
);
|
||||||
|
});
|
||||||
|
// Client greeting reflects the bridged customer name (proof the response was consumed)
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText(/Hi, Jane/)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
// The impersonation banner must NOT appear — this is the customer themselves
|
||||||
|
expect(screen.queryByRole("button", { name: /End Session/i })).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("routes to /onboarding when session-from-auth returns 404 (GRO-2359)", async () => {
|
||||||
|
// GRO-2359 replaces the P1 no-access fallback for the new-user path.
|
||||||
|
// The post-auth handler must now navigate to /onboarding so the OOBE
|
||||||
|
// component can drive portal creation. The no-access card itself is
|
||||||
|
// reserved for the deep-link deleted-portal case (see the next two
|
||||||
|
// tests, which exercise ?noAccess=deleted-portal).
|
||||||
|
global.fetch = vi.fn((input: RequestInfo) => {
|
||||||
|
const url = typeof input === "string" ? input : input.toString();
|
||||||
|
if (url === "/api/branding") return Promise.resolve(brandingResponse);
|
||||||
|
if (url === "/api/auth/get-session") {
|
||||||
|
return Promise.resolve({
|
||||||
|
ok: true,
|
||||||
|
json: async () => ({
|
||||||
|
user: { email: "stranger@example.com", name: "Stranger", role: "customer" },
|
||||||
|
}),
|
||||||
|
} as Response);
|
||||||
|
}
|
||||||
|
if (url === "/api/portal/session-from-auth") {
|
||||||
|
return Promise.resolve({
|
||||||
|
ok: false,
|
||||||
|
status: 404,
|
||||||
|
json: async () => ({ error: "No client record found for this user" }),
|
||||||
|
} as Response);
|
||||||
|
}
|
||||||
|
return Promise.resolve({ ok: true, json: async () => ({}) } as Response);
|
||||||
|
}) as unknown as typeof fetch;
|
||||||
|
|
||||||
|
// MemoryRouter is required for the React Router context used by
|
||||||
|
// useNavigate inside CustomerPortal. We pass `initialEntries=["/"]`
|
||||||
|
// and let the post-auth handler navigate the router to /onboarding.
|
||||||
|
const { CustomerPortal } = await import("../portal/CustomerPortal.js");
|
||||||
|
render(
|
||||||
|
<MemoryRouter initialEntries={["/"]}>
|
||||||
|
<CustomerPortal />
|
||||||
|
</MemoryRouter>
|
||||||
|
);
|
||||||
|
|
||||||
|
// The bridge 404 must NOT render the legacy no-access card. The OOBE
|
||||||
|
// form is the new-user surface.
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText(/set up your portal/i)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
expect(screen.queryByText(/Portal access not configured/i)).not.toBeInTheDocument();
|
||||||
|
expect(screen.getByRole("button", { name: /Create my portal/i })).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("calls the shared signOut() handler and navigates to /login from the no-access screen (GRO-2358)", async () => {
|
||||||
|
// Reset the spy so previous tests don't leak into this assertion.
|
||||||
|
signOutSpy.mockClear();
|
||||||
|
|
||||||
|
// JSDOM throws on window.location.href assignment by default; swap in a
|
||||||
|
// writable stub so the navigation is observable, then restore after.
|
||||||
|
const originalLocation = window.location;
|
||||||
|
Object.defineProperty(window, "location", {
|
||||||
|
value: { href: "" },
|
||||||
|
writable: true,
|
||||||
|
configurable: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
// GRO-2359: the post-auth bridge 404 now routes to /onboarding (OOBE)
|
||||||
|
// on the new-user path. The no-access card itself is reserved for the
|
||||||
|
// deep-link deleted-portal case, which is signalled via
|
||||||
|
// ?noAccess=deleted-portal. A server-side "client disabled" check
|
||||||
|
// (future GRO) is the natural trigger.
|
||||||
|
global.fetch = vi.fn((input: RequestInfo) => {
|
||||||
|
const url = typeof input === "string" ? input : input.toString();
|
||||||
|
if (url === "/api/branding") return Promise.resolve(brandingResponse);
|
||||||
|
if (url === "/api/auth/get-session") {
|
||||||
|
return Promise.resolve({
|
||||||
|
ok: true,
|
||||||
|
json: async () => ({ user: { email: "stranger@example.com", role: "customer" } }),
|
||||||
|
} as Response);
|
||||||
|
}
|
||||||
|
// The bridge must NOT succeed (so portalSessionId stays null) and must
|
||||||
|
// NOT be 404 (which would route to /onboarding). A 500 models a
|
||||||
|
// server-side portal-disabled check; the no-access card is mounted
|
||||||
|
// because of the URL param, not because of the bridge.
|
||||||
|
if (url === "/api/portal/session-from-auth") {
|
||||||
|
return Promise.resolve({
|
||||||
|
ok: false,
|
||||||
|
status: 500,
|
||||||
|
json: async () => ({ error: "Portal disabled" }),
|
||||||
|
} as Response);
|
||||||
|
}
|
||||||
|
return Promise.resolve({ ok: true, json: async () => ({}) } as Response);
|
||||||
|
}) as unknown as typeof fetch;
|
||||||
|
|
||||||
|
const { CustomerPortal } = await import("../portal/CustomerPortal.js");
|
||||||
|
render(
|
||||||
|
<MemoryRouter initialEntries={["/?noAccess=deleted-portal"]}>
|
||||||
|
<CustomerPortal />
|
||||||
|
</MemoryRouter>
|
||||||
|
);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText(/Portal access not configured/i)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Pre-condition: the shared signOut() must NOT have been called yet — the
|
||||||
|
// no-access screen is mounted because of the deleted-portal signal, not
|
||||||
|
// because the user clicked anything.
|
||||||
|
expect(signOutSpy).not.toHaveBeenCalled();
|
||||||
|
|
||||||
|
// Drive the click. The handler is the SAME `signOut()` exported from
|
||||||
|
// auth-client that AdminLayout uses, so verifying this call is enough to
|
||||||
|
// prove the no-access screen reaches the canonical sign-out surface.
|
||||||
|
const signOutButton = screen.getByRole("button", { name: /Sign out/i });
|
||||||
|
fireEvent.click(signOutButton);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(signOutSpy).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
// The handler always navigates to /login — even if the network call to
|
||||||
|
// /api/auth/sign-out fails — so a transient auth-server hiccup never
|
||||||
|
// leaves the user trapped on an authenticated screen.
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(window.location.href).toBe("/login");
|
||||||
|
});
|
||||||
|
|
||||||
|
Object.defineProperty(window, "location", { value: originalLocation, configurable: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reaches the same shared signOut() on a deep-link no-access screen (GRO-2358)", async () => {
|
||||||
|
// AC requires verifying the SAME logout handler is reachable from at
|
||||||
|
// least one other authenticated surface — here a deep link to a portal
|
||||||
|
// sub-route (e.g. /appointments) for a user with a Better Auth session
|
||||||
|
// whose portal was deleted. The no-access screen is the only
|
||||||
|
// authenticated surface without a route guard, so the handler must
|
||||||
|
// fire identically.
|
||||||
|
//
|
||||||
|
// GRO-2359: the bridge 404 now routes to /onboarding (OOBE) on the
|
||||||
|
// new-user path; ?noAccess=deleted-portal is the surviving trigger.
|
||||||
|
signOutSpy.mockClear();
|
||||||
|
|
||||||
|
const originalLocation = window.location;
|
||||||
|
Object.defineProperty(window, "location", {
|
||||||
|
value: { href: "" },
|
||||||
|
writable: true,
|
||||||
|
configurable: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
global.fetch = vi.fn((input: RequestInfo) => {
|
||||||
|
const url = typeof input === "string" ? input : input.toString();
|
||||||
|
if (url === "/api/branding") return Promise.resolve(brandingResponse);
|
||||||
|
if (url === "/api/auth/get-session") {
|
||||||
|
return Promise.resolve({
|
||||||
|
ok: true,
|
||||||
|
json: async () => ({ user: { email: "stranger@example.com", role: "customer" } }),
|
||||||
|
} as Response);
|
||||||
|
}
|
||||||
|
// The bridge must NOT succeed (so portalSessionId stays null) and must
|
||||||
|
// NOT be 404 (which would route to /onboarding). A 500 models a
|
||||||
|
// server-side portal-disabled check; the no-access card is mounted
|
||||||
|
// because of the URL param, not because of the bridge.
|
||||||
|
if (url === "/api/portal/session-from-auth") {
|
||||||
|
return Promise.resolve({
|
||||||
|
ok: false,
|
||||||
|
status: 500,
|
||||||
|
json: async () => ({ error: "Portal disabled" }),
|
||||||
|
} as Response);
|
||||||
|
}
|
||||||
|
return Promise.resolve({ ok: true, json: async () => ({}) } as Response);
|
||||||
|
}) as unknown as typeof fetch;
|
||||||
|
|
||||||
|
const { CustomerPortal } = await import("../portal/CustomerPortal.js");
|
||||||
|
render(
|
||||||
|
<MemoryRouter initialEntries={["/appointments?noAccess=deleted-portal"]}>
|
||||||
|
<CustomerPortal />
|
||||||
|
</MemoryRouter>
|
||||||
|
);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText(/Portal access not configured/i)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
const signOutButton = screen.getByRole("button", { name: /Sign out/i });
|
||||||
|
fireEvent.click(signOutButton);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(signOutSpy).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(window.location.href).toBe("/login");
|
||||||
|
});
|
||||||
|
|
||||||
|
Object.defineProperty(window, "location", { value: originalLocation, configurable: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not call session-from-auth when there is no Better Auth session", async () => {
|
||||||
|
global.fetch = vi.fn((input: RequestInfo) => {
|
||||||
|
const url = typeof input === "string" ? input : input.toString();
|
||||||
|
if (url === "/api/branding") return Promise.resolve(brandingResponse);
|
||||||
|
if (url === "/api/auth/get-session") {
|
||||||
|
return Promise.resolve({
|
||||||
|
ok: true,
|
||||||
|
json: async () => null,
|
||||||
|
} as Response);
|
||||||
|
}
|
||||||
|
return Promise.resolve({ ok: true, json: async () => ({}) } as Response);
|
||||||
|
}) as unknown as typeof fetch;
|
||||||
|
|
||||||
|
const { CustomerPortal } = await import("../portal/CustomerPortal.js");
|
||||||
|
render(
|
||||||
|
<MemoryRouter initialEntries={["/"]}>
|
||||||
|
<CustomerPortal />
|
||||||
|
</MemoryRouter>
|
||||||
|
);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(global.fetch).toHaveBeenCalledWith("/api/auth/get-session", expect.objectContaining({ credentials: "include" }));
|
||||||
|
});
|
||||||
|
// Wait one tick to ensure no subsequent bridge call is queued
|
||||||
|
await new Promise((r) => setTimeout(r, 30));
|
||||||
|
const bridgeCalls = vi.mocked(global.fetch).mock.calls.filter(
|
||||||
|
([u]) => typeof u === "string" && u === "/api/portal/session-from-auth"
|
||||||
|
);
|
||||||
|
expect(bridgeCalls).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("skips the bridge for staff Better Auth sessions", async () => {
|
||||||
|
global.fetch = vi.fn((input: RequestInfo) => {
|
||||||
|
const url = typeof input === "string" ? input : input.toString();
|
||||||
|
if (url === "/api/branding") return Promise.resolve(brandingResponse);
|
||||||
|
if (url === "/api/auth/get-session") {
|
||||||
|
return Promise.resolve({
|
||||||
|
ok: true,
|
||||||
|
json: async () => ({ user: { email: "staff@example.com", role: "staff" } }),
|
||||||
|
} as Response);
|
||||||
|
}
|
||||||
|
return Promise.resolve({ ok: true, json: async () => ({}) } as Response);
|
||||||
|
}) as unknown as typeof fetch;
|
||||||
|
|
||||||
|
const { CustomerPortal } = await import("../portal/CustomerPortal.js");
|
||||||
|
render(
|
||||||
|
<MemoryRouter initialEntries={["/"]}>
|
||||||
|
<CustomerPortal />
|
||||||
|
</MemoryRouter>
|
||||||
|
);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(global.fetch).toHaveBeenCalledWith("/api/auth/get-session", expect.objectContaining({ credentials: "include" }));
|
||||||
|
});
|
||||||
|
await new Promise((r) => setTimeout(r, 30));
|
||||||
|
const bridgeCalls = vi.mocked(global.fetch).mock.calls.filter(
|
||||||
|
([u]) => typeof u === "string" && u === "/api/portal/session-from-auth"
|
||||||
|
);
|
||||||
|
expect(bridgeCalls).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("passes portalSessionId (not null) to RescheduleFlow for SSO bridge customers (GRO-2012)", async () => {
|
||||||
|
rescheduleFlowSpy.mockClear();
|
||||||
|
|
||||||
|
global.fetch = vi.fn((input: RequestInfo, init?: RequestInit) => {
|
||||||
|
const url = typeof input === "string" ? input : input.toString();
|
||||||
|
if (url === "/api/branding") return Promise.resolve(brandingResponse);
|
||||||
|
if (url === "/api/auth/get-session") {
|
||||||
|
return Promise.resolve({
|
||||||
|
ok: true,
|
||||||
|
json: async () => ({ user: { email: "customer@example.com", role: "customer" } }),
|
||||||
|
} as Response);
|
||||||
|
}
|
||||||
|
if (url === "/api/portal/session-from-auth" && init?.method === "POST") {
|
||||||
|
return Promise.resolve({
|
||||||
|
ok: true,
|
||||||
|
status: 201,
|
||||||
|
json: async () => ({ sessionId: "sso-sess-1", clientId: "client-1", clientName: "Jane Doe" }),
|
||||||
|
} as Response);
|
||||||
|
}
|
||||||
|
// Dashboard data — return an upcoming appointment so the Reschedule
|
||||||
|
// button is rendered on the dashboard card.
|
||||||
|
if (url === "/api/portal/appointments") {
|
||||||
|
return Promise.resolve({
|
||||||
|
ok: true,
|
||||||
|
json: async () => ({
|
||||||
|
appointments: [
|
||||||
|
{
|
||||||
|
id: "appt-1",
|
||||||
|
date: "2099-01-01",
|
||||||
|
time: "10:00",
|
||||||
|
petName: "Buddy",
|
||||||
|
serviceName: "Bath & Brush",
|
||||||
|
status: "confirmed",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
} as Response);
|
||||||
|
}
|
||||||
|
if (url === "/api/portal/pets") {
|
||||||
|
return Promise.resolve({ ok: true, json: async () => ({ pets: [] }) } as Response);
|
||||||
|
}
|
||||||
|
if (url === "/api/portal/invoices") {
|
||||||
|
return Promise.resolve({ ok: true, json: async () => ({ invoices: [] }) } as Response);
|
||||||
|
}
|
||||||
|
return Promise.resolve({ ok: true, json: async () => ({}) } as Response);
|
||||||
|
}) as unknown as typeof fetch;
|
||||||
|
|
||||||
|
const { CustomerPortal } = await import("../portal/CustomerPortal.js");
|
||||||
|
render(
|
||||||
|
<MemoryRouter initialEntries={["/"]}>
|
||||||
|
<CustomerPortal />
|
||||||
|
</MemoryRouter>
|
||||||
|
);
|
||||||
|
|
||||||
|
// Wait for the Reschedule button to appear on the dashboard card
|
||||||
|
const rescheduleBtn = await screen.findByRole("button", { name: /^Reschedule$/i });
|
||||||
|
fireEvent.click(rescheduleBtn);
|
||||||
|
|
||||||
|
// RescheduleFlow should have been invoked with the bridged portalSessionId,
|
||||||
|
// NOT null. Pre-fix, the call would be sessionId={null} for SSO customers.
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(rescheduleFlowSpy).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
const lastProps = rescheduleFlowSpy.mock.lastCall?.[0];
|
||||||
|
expect(lastProps).toBeDefined();
|
||||||
|
expect(lastProps!.sessionId).toBe("sso-sess-1");
|
||||||
|
expect(lastProps!.appointment.id).toBe("appt-1");
|
||||||
|
});
|
||||||
|
|
||||||
|
// GRO-2099 regression: the portal chrome (and Dashboard's `!sessionId` guard)
|
||||||
|
// must NOT render before the SSO bridge resolves. A loading state must be
|
||||||
|
// shown instead. Previously, the Dashboard's redirect-to-/login guard fired
|
||||||
|
// mid-bootstrap, leaving the user with a blank page after sign-in.
|
||||||
|
it("renders a loading state during the SSO bridge (does not flash portal chrome)", async () => {
|
||||||
|
// Slow bridge: resolve get-session and session-from-auth after a tick so
|
||||||
|
// we can observe the loading state mid-bootstrap.
|
||||||
|
let resolveBridge!: (value: Response) => void;
|
||||||
|
const bridgePromise = new Promise<Response>((resolve) => {
|
||||||
|
resolveBridge = resolve;
|
||||||
|
});
|
||||||
|
|
||||||
|
global.fetch = vi.fn((input: RequestInfo, init?: RequestInit) => {
|
||||||
|
const url = typeof input === "string" ? input : input.toString();
|
||||||
|
if (url === "/api/branding") return Promise.resolve(brandingResponse);
|
||||||
|
if (url === "/api/auth/get-session") {
|
||||||
|
return Promise.resolve({
|
||||||
|
ok: true,
|
||||||
|
json: async () => ({ user: { email: "customer@example.com", role: "customer" } }),
|
||||||
|
} as Response);
|
||||||
|
}
|
||||||
|
if (url === "/api/portal/session-from-auth" && init?.method === "POST") {
|
||||||
|
return bridgePromise;
|
||||||
|
}
|
||||||
|
return Promise.resolve({ ok: true, json: async () => ({}) } as Response);
|
||||||
|
}) as unknown as typeof fetch;
|
||||||
|
|
||||||
|
const { CustomerPortal } = await import("../portal/CustomerPortal.js");
|
||||||
|
render(
|
||||||
|
<MemoryRouter initialEntries={["/"]}>
|
||||||
|
<CustomerPortal />
|
||||||
|
</MemoryRouter>
|
||||||
|
);
|
||||||
|
|
||||||
|
// Loading state is visible while the bridge is in flight. The portal nav
|
||||||
|
// (Home / Appointments / etc.) must NOT be present — its presence would
|
||||||
|
// indicate the chrome is rendering with a null session, which is the
|
||||||
|
// pre-GRO-2099 bug.
|
||||||
|
expect(await screen.findByRole("status")).toHaveTextContent(/Loading/i);
|
||||||
|
expect(screen.queryByText("Home")).not.toBeInTheDocument();
|
||||||
|
expect(screen.queryByText("Appointments")).not.toBeInTheDocument();
|
||||||
|
|
||||||
|
// Resolve the bridge and confirm the portal renders normally.
|
||||||
|
resolveBridge({
|
||||||
|
ok: true,
|
||||||
|
status: 201,
|
||||||
|
json: async () => ({ sessionId: "sso-sess-1", clientId: "client-1", clientName: "Jane Doe" }),
|
||||||
|
} as Response);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText(/Hi, Jane/)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("OOBE portal-creation flow (GRO-2359)", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
window.localStorage.clear();
|
||||||
|
});
|
||||||
|
|
||||||
|
// The OOBE is mounted both from the post-auth callback (CustomerPortal
|
||||||
|
// navigates to /onboarding on bridge 404) and from a direct deep-link.
|
||||||
|
// This set of tests exercises the direct-link mount, the form submit, and
|
||||||
|
// the shared signOut() handler. The post-auth routing is covered by the
|
||||||
|
// "routes to /onboarding when session-from-auth returns 404" test above.
|
||||||
|
|
||||||
|
function setupOOBEAuthMock(opts: { role?: string } = {}) {
|
||||||
|
const role = opts.role ?? "customer";
|
||||||
|
return vi.fn((input: RequestInfo) => {
|
||||||
|
const url = typeof input === "string" ? input : input.toString();
|
||||||
|
if (url === "/api/branding") return Promise.resolve(brandingResponse);
|
||||||
|
if (url === "/api/auth/get-session") {
|
||||||
|
return Promise.resolve({
|
||||||
|
ok: true,
|
||||||
|
json: async () => ({
|
||||||
|
user: { email: "new-sso@example.com", name: "New SSO", role },
|
||||||
|
}),
|
||||||
|
} as Response);
|
||||||
|
}
|
||||||
|
return Promise.resolve({ ok: true, json: async () => ({}) } as Response);
|
||||||
|
}) as unknown as typeof fetch;
|
||||||
|
}
|
||||||
|
|
||||||
|
it("renders the OOBE form when navigated to /onboarding directly (GRO-2359)", async () => {
|
||||||
|
global.fetch = setupOOBEAuthMock();
|
||||||
|
|
||||||
|
const { OOBE } = await import("../portal/OOBE.js");
|
||||||
|
render(
|
||||||
|
<MemoryRouter initialEntries={["/onboarding"]}>
|
||||||
|
<OOBE />
|
||||||
|
</MemoryRouter>
|
||||||
|
);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByRole("heading", { name: /set up your portal/i })).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
// All three primary form fields are present.
|
||||||
|
expect(screen.getByLabelText(/your name/i)).toBeInTheDocument();
|
||||||
|
expect(screen.getByLabelText(/phone/i)).toBeInTheDocument();
|
||||||
|
expect(screen.getByLabelText(/address/i)).toBeInTheDocument();
|
||||||
|
// Submit and shared signOut are both present.
|
||||||
|
expect(screen.getByRole("button", { name: /Create my portal/i })).toBeInTheDocument();
|
||||||
|
expect(screen.getByRole("button", { name: /Sign out/i })).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("prefills the name field from the Better Auth session (GRO-2359)", async () => {
|
||||||
|
global.fetch = setupOOBEAuthMock();
|
||||||
|
|
||||||
|
const { OOBE } = await import("../portal/OOBE.js");
|
||||||
|
render(
|
||||||
|
<MemoryRouter initialEntries={["/onboarding"]}>
|
||||||
|
<OOBE />
|
||||||
|
</MemoryRouter>
|
||||||
|
);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByLabelText(/your name/i)).toHaveValue("New SSO");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("calls POST /api/portal/clients-from-auth and navigates to / on success (GRO-2359)", async () => {
|
||||||
|
const fetchMock = vi.fn((input: RequestInfo, init?: RequestInit) => {
|
||||||
|
const url = typeof input === "string" ? input : input.toString();
|
||||||
|
if (url === "/api/branding") return Promise.resolve(brandingResponse);
|
||||||
|
if (url === "/api/auth/get-session") {
|
||||||
|
return Promise.resolve({
|
||||||
|
ok: true,
|
||||||
|
json: async () => ({
|
||||||
|
user: { email: "new-sso@example.com", name: "New SSO", role: "customer" },
|
||||||
|
}),
|
||||||
|
} as Response);
|
||||||
|
}
|
||||||
|
if (url === "/api/portal/clients-from-auth" && init?.method === "POST") {
|
||||||
|
return Promise.resolve({
|
||||||
|
ok: true,
|
||||||
|
status: 201,
|
||||||
|
json: async () => ({
|
||||||
|
id: "new-client-id",
|
||||||
|
name: "New SSO",
|
||||||
|
email: "new-sso@example.com",
|
||||||
|
}),
|
||||||
|
} as Response);
|
||||||
|
}
|
||||||
|
return Promise.resolve({ ok: true, json: async () => ({}) } as Response);
|
||||||
|
}) as unknown as typeof fetch;
|
||||||
|
global.fetch = fetchMock;
|
||||||
|
|
||||||
|
const { OOBE } = await import("../portal/OOBE.js");
|
||||||
|
render(
|
||||||
|
<MemoryRouter initialEntries={["/onboarding"]}>
|
||||||
|
<OOBE />
|
||||||
|
</MemoryRouter>
|
||||||
|
);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByLabelText(/your name/i)).toHaveValue("New SSO");
|
||||||
|
});
|
||||||
|
|
||||||
|
// Fill phone + address and submit.
|
||||||
|
fireEvent.change(screen.getByLabelText(/phone/i), {
|
||||||
|
target: { value: "555-1234" },
|
||||||
|
});
|
||||||
|
fireEvent.change(screen.getByLabelText(/address/i), {
|
||||||
|
target: { value: "1 Main St" },
|
||||||
|
});
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: /Create my portal/i }));
|
||||||
|
|
||||||
|
// The endpoint must have been called with the form values, normalised
|
||||||
|
// (phone/address trimmed). We don't assert navigation here because the
|
||||||
|
// MemoryRouter would need a history prop to assert a URL change — the
|
||||||
|
// internal `navigate("/")` call is the contract.
|
||||||
|
await waitFor(() => {
|
||||||
|
const calls = vi.mocked(fetchMock).mock.calls;
|
||||||
|
const onboardCall = calls.find(([u]) =>
|
||||||
|
typeof u === "string" && (u as string).endsWith("/api/portal/clients-from-auth"),
|
||||||
|
);
|
||||||
|
expect(onboardCall).toBeDefined();
|
||||||
|
const body = JSON.parse(((onboardCall?.[1] as RequestInit | undefined)?.body as string) ?? "{}");
|
||||||
|
expect(body).toEqual({
|
||||||
|
name: "New SSO",
|
||||||
|
phone: "555-1234",
|
||||||
|
address: "1 Main St",
|
||||||
|
notes: null,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows the portal-selection message when the API returns 409 (GRO-2359)", async () => {
|
||||||
|
global.fetch = vi.fn((input: RequestInfo, init?: RequestInit) => {
|
||||||
|
const url = typeof input === "string" ? input : input.toString();
|
||||||
|
if (url === "/api/branding") return Promise.resolve(brandingResponse);
|
||||||
|
if (url === "/api/auth/get-session") {
|
||||||
|
return Promise.resolve({
|
||||||
|
ok: true,
|
||||||
|
json: async () => ({
|
||||||
|
user: { email: "new-sso@example.com", name: "New SSO", role: "customer" },
|
||||||
|
}),
|
||||||
|
} as Response);
|
||||||
|
}
|
||||||
|
if (url === "/api/portal/clients-from-auth" && init?.method === "POST") {
|
||||||
|
return Promise.resolve({
|
||||||
|
ok: false,
|
||||||
|
status: 409,
|
||||||
|
json: async () => ({ error: "A customer record with this email already exists" }),
|
||||||
|
} as Response);
|
||||||
|
}
|
||||||
|
return Promise.resolve({ ok: true, json: async () => ({}) } as Response);
|
||||||
|
}) as unknown as typeof fetch;
|
||||||
|
|
||||||
|
const { OOBE } = await import("../portal/OOBE.js");
|
||||||
|
render(
|
||||||
|
<MemoryRouter initialEntries={["/onboarding"]}>
|
||||||
|
<OOBE />
|
||||||
|
</MemoryRouter>
|
||||||
|
);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByLabelText(/your name/i)).toHaveValue("New SSO");
|
||||||
|
});
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: /Create my portal/i }));
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText(/already exists/i)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
// The submit button is re-enabled after the error so the user can retry.
|
||||||
|
expect(screen.getByRole("button", { name: /Create my portal/i })).not.toBeDisabled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("requires the name field before submitting (GRO-2359)", async () => {
|
||||||
|
// Use a session WITHOUT a name so the OOBE starts with an empty form.
|
||||||
|
global.fetch = vi.fn((input: RequestInfo) => {
|
||||||
|
const url = typeof input === "string" ? input : input.toString();
|
||||||
|
if (url === "/api/branding") return Promise.resolve(brandingResponse);
|
||||||
|
if (url === "/api/auth/get-session") {
|
||||||
|
return Promise.resolve({
|
||||||
|
ok: true,
|
||||||
|
json: async () => ({ user: { email: "noname@example.com", role: "customer" } }),
|
||||||
|
} as Response);
|
||||||
|
}
|
||||||
|
return Promise.resolve({ ok: true, json: async () => ({}) } as Response);
|
||||||
|
}) as unknown as typeof fetch;
|
||||||
|
|
||||||
|
const { OOBE } = await import("../portal/OOBE.js");
|
||||||
|
render(
|
||||||
|
<MemoryRouter initialEntries={["/onboarding"]}>
|
||||||
|
<OOBE />
|
||||||
|
</MemoryRouter>
|
||||||
|
);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByLabelText(/your name/i)).toHaveValue("");
|
||||||
|
});
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: /Create my portal/i }));
|
||||||
|
|
||||||
|
// The name-required error is shown; no API call was made.
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText(/tell us your name/i)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("uses the shared signOut() handler on the OOBE Sign out button (GRO-2359)", async () => {
|
||||||
|
signOutSpy.mockClear();
|
||||||
|
const originalLocation = window.location;
|
||||||
|
Object.defineProperty(window, "location", {
|
||||||
|
value: { href: "" },
|
||||||
|
writable: true,
|
||||||
|
configurable: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
global.fetch = setupOOBEAuthMock();
|
||||||
|
|
||||||
|
const { OOBE } = await import("../portal/OOBE.js");
|
||||||
|
render(
|
||||||
|
<MemoryRouter initialEntries={["/onboarding"]}>
|
||||||
|
<OOBE />
|
||||||
|
</MemoryRouter>
|
||||||
|
);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByRole("button", { name: /Sign out/i })).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: /Sign out/i }));
|
||||||
|
|
||||||
|
// Same canonical handler as AdminLayout and the no-access card, per
|
||||||
|
// GRO-2358 — never a raw fetch("/api/auth/sign-out").
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(signOutSpy).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(window.location.href).toBe("/login");
|
||||||
|
});
|
||||||
|
|
||||||
|
Object.defineProperty(window, "location", { value: originalLocation, configurable: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("redirects to /login when no Better Auth session is present (GRO-2359)", async () => {
|
||||||
|
const originalLocation = window.location;
|
||||||
|
Object.defineProperty(window, "location", {
|
||||||
|
value: { href: "" },
|
||||||
|
writable: true,
|
||||||
|
configurable: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
global.fetch = vi.fn((input: RequestInfo) => {
|
||||||
|
const url = typeof input === "string" ? input : input.toString();
|
||||||
|
if (url === "/api/branding") return Promise.resolve(brandingResponse);
|
||||||
|
if (url === "/api/auth/get-session") {
|
||||||
|
return Promise.resolve({ ok: false, status: 401, json: async () => ({}) } as Response);
|
||||||
|
}
|
||||||
|
return Promise.resolve({ ok: true, json: async () => ({}) } as Response);
|
||||||
|
}) as unknown as typeof fetch;
|
||||||
|
|
||||||
|
const { OOBE } = await import("../portal/OOBE.js");
|
||||||
|
render(
|
||||||
|
<MemoryRouter initialEntries={["/onboarding"]}>
|
||||||
|
<OOBE />
|
||||||
|
</MemoryRouter>
|
||||||
|
);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(window.location.href).toBe("/login");
|
||||||
|
});
|
||||||
|
|
||||||
|
Object.defineProperty(window, "location", { value: originalLocation, configurable: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reaches the shared signOut() handler from the in-portal chrome sidebar (GRO-2373)", async () => {
|
||||||
|
// Pre-GRO-2373, the customer portal chrome (Home, Appointments, My Pets,
|
||||||
|
// Report Cards, Billing, Messages, Settings) had no visible sign-out
|
||||||
|
// control — only the OOBE and the no-access card exposed one. This
|
||||||
|
// leaves users signed-in with no escape hatch. The fix lands a
|
||||||
|
// "Sign out" button in the sidebar footer that wires to the same
|
||||||
|
// canonical `signOut()` already used by OOBE / no-access / AdminLayout.
|
||||||
|
signOutSpy.mockClear();
|
||||||
|
|
||||||
|
const originalLocation = window.location;
|
||||||
|
Object.defineProperty(window, "location", {
|
||||||
|
value: { href: "" },
|
||||||
|
writable: true,
|
||||||
|
configurable: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
global.fetch = vi.fn((input: RequestInfo, init?: RequestInit) => {
|
||||||
|
const url = typeof input === "string" ? input : input.toString();
|
||||||
|
if (url === "/api/branding") return Promise.resolve(brandingResponse);
|
||||||
|
if (url === "/api/auth/get-session") {
|
||||||
|
return Promise.resolve({
|
||||||
|
ok: true,
|
||||||
|
json: async () => ({ user: { email: "uat-customer@groombook.dev", role: "customer" } }),
|
||||||
|
} as Response);
|
||||||
|
}
|
||||||
|
if (url === "/api/portal/session-from-auth" && init?.method === "POST") {
|
||||||
|
return Promise.resolve({
|
||||||
|
ok: true,
|
||||||
|
status: 201,
|
||||||
|
json: async () => ({ sessionId: "chrome-sess-1", clientId: "client-1", clientName: "Jane Doe" }),
|
||||||
|
} as Response);
|
||||||
|
}
|
||||||
|
return Promise.resolve({ ok: true, json: async () => ({}) } as Response);
|
||||||
|
}) as unknown as typeof fetch;
|
||||||
|
|
||||||
|
const { CustomerPortal } = await import("../portal/CustomerPortal.js");
|
||||||
|
render(
|
||||||
|
<MemoryRouter initialEntries={["/"]}>
|
||||||
|
<CustomerPortal />
|
||||||
|
</MemoryRouter>
|
||||||
|
);
|
||||||
|
|
||||||
|
// Land on the chrome (proof: customer greeting is rendered, no
|
||||||
|
// no-access card, no OOBE).
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText(/Hi, Jane/)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
expect(screen.queryByText(/Portal access not configured/i)).not.toBeInTheDocument();
|
||||||
|
expect(screen.queryByText(/set up your portal/i)).not.toBeInTheDocument();
|
||||||
|
|
||||||
|
// The new chrome sign-out is scoped by data-testid so it doesn't
|
||||||
|
// collide with other surfaces that may also render "Sign out" labels
|
||||||
|
// (e.g. the impersonation banner uses "End Session").
|
||||||
|
const signOutButton = screen.getByTestId("portal-chrome-signout");
|
||||||
|
expect(signOutButton).toHaveTextContent(/Sign out/i);
|
||||||
|
fireEvent.click(signOutButton);
|
||||||
|
|
||||||
|
// Same canonical handler as OOBE / no-access / AdminLayout — never
|
||||||
|
// a raw fetch("/api/auth/sign-out") and never a navigate() without
|
||||||
|
// signOut() (the OOBE/no-access surface uses window.location.href
|
||||||
|
// for a hard reload so cached state is reset).
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(signOutSpy).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(window.location.href).toBe("/login");
|
||||||
|
});
|
||||||
|
|
||||||
|
Object.defineProperty(window, "location", { value: originalLocation, configurable: true });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -0,0 +1,97 @@
|
|||||||
|
import { useEffect } from "react";
|
||||||
|
import {
|
||||||
|
MapContainer,
|
||||||
|
TileLayer,
|
||||||
|
Marker,
|
||||||
|
Polyline,
|
||||||
|
Tooltip,
|
||||||
|
useMap,
|
||||||
|
} from "react-leaflet";
|
||||||
|
import L from "leaflet";
|
||||||
|
import "leaflet/dist/leaflet.css";
|
||||||
|
|
||||||
|
// This component is loaded via React.lazy from the route planner page so that
|
||||||
|
// Leaflet + react-leaflet land in a separate code-split chunk and never weigh
|
||||||
|
// down the main admin bundle.
|
||||||
|
|
||||||
|
export interface RouteMapStop {
|
||||||
|
id: string;
|
||||||
|
stopOrder: number;
|
||||||
|
latitude: number;
|
||||||
|
longitude: number;
|
||||||
|
clientName: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface RouteMapProps {
|
||||||
|
stops: RouteMapStop[];
|
||||||
|
primaryColor: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A numbered teardrop pin rendered as an inline-SVG divIcon (no image assets). */
|
||||||
|
function numberedIcon(order: number, color: string): L.DivIcon {
|
||||||
|
return L.divIcon({
|
||||||
|
className: "route-stop-pin",
|
||||||
|
html: `<div style="position:relative;width:28px;height:40px">
|
||||||
|
<svg width="28" height="40" viewBox="0 0 28 40" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path d="M14 0C6.27 0 0 6.27 0 14c0 9.5 14 26 14 26s14-16.5 14-26C28 6.27 21.73 0 14 0z" fill="${color}" stroke="#ffffff" stroke-width="1.5"/>
|
||||||
|
</svg>
|
||||||
|
<span style="position:absolute;top:5px;left:0;width:28px;text-align:center;color:#fff;font-size:13px;font-weight:700;font-family:system-ui,sans-serif">${order}</span>
|
||||||
|
</div>`,
|
||||||
|
iconSize: [28, 40],
|
||||||
|
iconAnchor: [14, 40],
|
||||||
|
tooltipAnchor: [0, -34],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Keeps the viewport framed around all stops whenever the route changes. */
|
||||||
|
function FitBounds({ stops }: { stops: RouteMapStop[] }) {
|
||||||
|
const map = useMap();
|
||||||
|
useEffect(() => {
|
||||||
|
if (stops.length === 0) return;
|
||||||
|
const latlngs = stops.map((s) => [s.latitude, s.longitude] as [number, number]);
|
||||||
|
if (latlngs.length === 1) {
|
||||||
|
map.setView(latlngs[0]!, 14);
|
||||||
|
} else {
|
||||||
|
map.fitBounds(L.latLngBounds(latlngs), { padding: [40, 40] });
|
||||||
|
}
|
||||||
|
}, [map, stops]);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function RouteMap({ stops, primaryColor }: RouteMapProps) {
|
||||||
|
// Fallback centre (London) only used briefly before FitBounds runs or when the
|
||||||
|
// route has no geocoded stops.
|
||||||
|
const center: [number, number] = stops[0]
|
||||||
|
? [stops[0].latitude, stops[0].longitude]
|
||||||
|
: [51.505, -0.09];
|
||||||
|
const line = stops.map((s) => [s.latitude, s.longitude] as [number, number]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<MapContainer
|
||||||
|
center={center}
|
||||||
|
zoom={12}
|
||||||
|
scrollWheelZoom
|
||||||
|
style={{ height: "100%", width: "100%", borderRadius: 8 }}
|
||||||
|
>
|
||||||
|
<TileLayer
|
||||||
|
attribution='© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors'
|
||||||
|
url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png"
|
||||||
|
/>
|
||||||
|
{line.length >= 2 && (
|
||||||
|
<Polyline positions={line} color={primaryColor} weight={4} opacity={0.7} />
|
||||||
|
)}
|
||||||
|
{stops.map((s) => (
|
||||||
|
<Marker
|
||||||
|
key={s.id}
|
||||||
|
position={[s.latitude, s.longitude]}
|
||||||
|
icon={numberedIcon(s.stopOrder, primaryColor)}
|
||||||
|
>
|
||||||
|
<Tooltip>
|
||||||
|
{s.stopOrder}. {s.clientName}
|
||||||
|
</Tooltip>
|
||||||
|
</Marker>
|
||||||
|
))}
|
||||||
|
<FitBounds stops={stops} />
|
||||||
|
</MapContainer>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -8,6 +8,19 @@
|
|||||||
--color-accent-dark: color-mix(in srgb, var(--color-accent) 78%, #000);
|
--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-light: color-mix(in srgb, var(--color-accent) 18%, #fff);
|
||||||
--color-accent-lighter: color-mix(in srgb, var(--color-accent) 9%, #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 {
|
*, *::before, *::after {
|
||||||
|
|||||||
@@ -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,7 +1,7 @@
|
|||||||
import { createAuthClient } from "better-auth/react";
|
import { createAuthClient } from "better-auth/react";
|
||||||
|
|
||||||
export const authClient = createAuthClient({
|
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;
|
export const { signIn, signOut, useSession, changePassword } = authClient;
|
||||||
@@ -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;
|
||||||
@@ -2,9 +2,41 @@ import { StrictMode } from "react";
|
|||||||
import { createRoot } from "react-dom/client";
|
import { createRoot } from "react-dom/client";
|
||||||
import { BrowserRouter } from "react-router-dom";
|
import { BrowserRouter } from "react-router-dom";
|
||||||
import { App } from "./App.js";
|
import { App } from "./App.js";
|
||||||
|
import { ErrorBoundary } from "./ErrorBoundary.js";
|
||||||
import { installDevFetchInterceptor } from "./lib/devFetch.js";
|
import { installDevFetchInterceptor } from "./lib/devFetch.js";
|
||||||
import "./index.css";
|
import "./index.css";
|
||||||
|
|
||||||
|
// --------------------------------------------------------------------
|
||||||
|
// Global error capture (GRO-2094).
|
||||||
|
//
|
||||||
|
// Symptom: React root stays empty at /login — bundle parses, no console
|
||||||
|
// errors, no error boundary fallback. Some failure is being swallowed
|
||||||
|
// before it reaches React's commit phase. These listeners make sure any
|
||||||
|
// thrown error or unhandled promise rejection is at least visible in
|
||||||
|
// the console (and in the Playwright network/console log) instead of
|
||||||
|
// vanishing into the void.
|
||||||
|
// --------------------------------------------------------------------
|
||||||
|
function reportGlobalError(kind: string, payload: unknown): void {
|
||||||
|
// eslint-disable-next-line no-console
|
||||||
|
console.error(`[${kind}]`, payload);
|
||||||
|
}
|
||||||
|
|
||||||
|
window.addEventListener("error", (event) => {
|
||||||
|
reportGlobalError("window.error", {
|
||||||
|
message: event.message,
|
||||||
|
filename: event.filename,
|
||||||
|
lineno: event.lineno,
|
||||||
|
colno: event.colno,
|
||||||
|
error: event.error,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
window.addEventListener("unhandledrejection", (event) => {
|
||||||
|
reportGlobalError("unhandledrejection", {
|
||||||
|
reason: event.reason,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
installDevFetchInterceptor();
|
installDevFetchInterceptor();
|
||||||
|
|
||||||
const root = document.getElementById("root");
|
const root = document.getElementById("root");
|
||||||
@@ -12,8 +44,10 @@ if (!root) throw new Error("Root element not found");
|
|||||||
|
|
||||||
createRoot(root).render(
|
createRoot(root).render(
|
||||||
<StrictMode>
|
<StrictMode>
|
||||||
|
<ErrorBoundary>
|
||||||
<BrowserRouter>
|
<BrowserRouter>
|
||||||
<App />
|
<App />
|
||||||
</BrowserRouter>
|
</BrowserRouter>
|
||||||
|
</ErrorBoundary>
|
||||||
</StrictMode>
|
</StrictMode>
|
||||||
);
|
);
|
||||||
|
|||||||
+6
-1
@@ -1,6 +1,7 @@
|
|||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { useSearchParams } from "react-router-dom";
|
import { useSearchParams } from "react-router-dom";
|
||||||
import type { Service } from "@groombook/types";
|
import type { Service } from "@groombook/types";
|
||||||
|
import { ANALYTICS_EVENTS, fireAnalyticsEvent } from "../lib/analytics";
|
||||||
|
|
||||||
// ─── Types ───────────────────────────────────────────────────────────────────
|
// ─── Types ───────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -193,12 +194,14 @@ export function BookPage() {
|
|||||||
setSelectedService(svc);
|
setSelectedService(svc);
|
||||||
setForm((f) => ({ ...f, serviceId: svc.id }));
|
setForm((f) => ({ ...f, serviceId: svc.id }));
|
||||||
setStep(2);
|
setStep(2);
|
||||||
|
fireAnalyticsEvent(ANALYTICS_EVENTS.BOOKING_STEP_SERVICE, { step: "service", flow: "public" });
|
||||||
}
|
}
|
||||||
|
|
||||||
function goToStep3() {
|
function goToStep3() {
|
||||||
if (!selectedSlot) return;
|
if (!selectedSlot) return;
|
||||||
setForm((f) => ({ ...f, startTime: selectedSlot }));
|
setForm((f) => ({ ...f, startTime: selectedSlot }));
|
||||||
setStep(3);
|
setStep(3);
|
||||||
|
fireAnalyticsEvent(ANALYTICS_EVENTS.BOOKING_STEP_TIME, { step: "time", flow: "public" });
|
||||||
}
|
}
|
||||||
|
|
||||||
function goToStep4() {
|
function goToStep4() {
|
||||||
@@ -208,6 +211,7 @@ export function BookPage() {
|
|||||||
}
|
}
|
||||||
setFormError(null);
|
setFormError(null);
|
||||||
setStep(4);
|
setStep(4);
|
||||||
|
fireAnalyticsEvent(ANALYTICS_EVENTS.BOOKING_STEP_CONTACT, { step: "contact", flow: "public" });
|
||||||
}
|
}
|
||||||
|
|
||||||
async function submitBooking() {
|
async function submitBooking() {
|
||||||
@@ -236,6 +240,7 @@ export function BookPage() {
|
|||||||
throw new Error(body.error ?? `HTTP ${res.status}`);
|
throw new Error(body.error ?? `HTTP ${res.status}`);
|
||||||
}
|
}
|
||||||
const data = (await res.json()) as BookingResult;
|
const data = (await res.json()) as BookingResult;
|
||||||
|
fireAnalyticsEvent(ANALYTICS_EVENTS.BOOKING_STEP_SUBMIT, { step: "submit", flow: "public" });
|
||||||
setResult(data);
|
setResult(data);
|
||||||
setStep(5);
|
setStep(5);
|
||||||
} catch (e: unknown) {
|
} catch (e: unknown) {
|
||||||
@@ -519,7 +524,7 @@ export function BookPage() {
|
|||||||
<option value="small">Small (under 15 lbs)</option>
|
<option value="small">Small (under 15 lbs)</option>
|
||||||
<option value="medium">Medium (15–40 lbs)</option>
|
<option value="medium">Medium (15–40 lbs)</option>
|
||||||
<option value="large">Large (40–80 lbs)</option>
|
<option value="large">Large (40–80 lbs)</option>
|
||||||
<option value="x-large">X-Large (over 80 lbs)</option>
|
<option value="xlarge">X-Large (over 80 lbs)</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
|
|||||||
@@ -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() {
|
export function BookingCancelledPage() {
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
@@ -7,12 +14,12 @@ export function BookingCancelledPage() {
|
|||||||
alignItems: "center",
|
alignItems: "center",
|
||||||
justifyContent: "center",
|
justifyContent: "center",
|
||||||
fontFamily: "system-ui, sans-serif",
|
fontFamily: "system-ui, sans-serif",
|
||||||
background: "#fff7ed",
|
background: "var(--color-cancelled-bg)",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
background: "#fff",
|
background: "var(--color-surface)",
|
||||||
borderRadius: 12,
|
borderRadius: 12,
|
||||||
padding: "2.5rem 3rem",
|
padding: "2.5rem 3rem",
|
||||||
boxShadow: "0 4px 24px rgba(0,0,0,0.08)",
|
boxShadow: "0 4px 24px rgba(0,0,0,0.08)",
|
||||||
@@ -21,19 +28,20 @@ export function BookingCancelledPage() {
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div style={{ fontSize: 56, marginBottom: "0.5rem" }}>✗</div>
|
<div style={{ fontSize: 56, marginBottom: "0.5rem" }}>✗</div>
|
||||||
<h1 style={{ color: "#c2410c", fontSize: 24, margin: "0 0 0.5rem" }}>
|
<h1 style={{ color: "var(--color-cancelled-dark)", fontSize: 24, margin: "0 0 0.5rem" }}>
|
||||||
Appointment Cancelled
|
{STRINGS.heading}
|
||||||
</h1>
|
</h1>
|
||||||
<p style={{ color: "#4b5563", margin: "0 0 1.5rem" }}>
|
<p style={{ color: "var(--color-text-secondary)", margin: "0 0 1.5rem" }}>
|
||||||
Your appointment has been cancelled. If this was a mistake or you'd
|
{STRINGS.body}
|
||||||
like to rebook, please contact us.
|
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
|
<div style={{ display: "flex", flexDirection: "column", gap: "0.75rem", alignItems: "center" }}>
|
||||||
<a
|
<a
|
||||||
href="/"
|
href="/admin/book"
|
||||||
style={{
|
style={{
|
||||||
display: "inline-block",
|
display: "inline-block",
|
||||||
padding: "0.6rem 1.5rem",
|
padding: "0.6rem 1.5rem",
|
||||||
background: "#ea580c",
|
background: "var(--color-primary)",
|
||||||
color: "#fff",
|
color: "#fff",
|
||||||
borderRadius: 6,
|
borderRadius: 6,
|
||||||
textDecoration: "none",
|
textDecoration: "none",
|
||||||
@@ -41,8 +49,24 @@ export function BookingCancelledPage() {
|
|||||||
fontSize: 14,
|
fontSize: 14,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
Back to Portal
|
{STRINGS.bookAgain}
|
||||||
</a>
|
</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>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,4 +1,11 @@
|
|||||||
|
import { useEffect } from "react";
|
||||||
|
import { ANALYTICS_EVENTS, fireAnalyticsEvent } from "../lib/analytics";
|
||||||
|
|
||||||
export function BookingConfirmedPage() {
|
export function BookingConfirmedPage() {
|
||||||
|
useEffect(() => {
|
||||||
|
fireAnalyticsEvent(ANALYTICS_EVENTS.BOOKING_CONFIRMED, { step: "confirmed", flow: "public" });
|
||||||
|
}, []);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
|
|||||||
+50
-10
@@ -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() {
|
export function BookingErrorPage() {
|
||||||
|
useEffect(() => {
|
||||||
|
fireAnalyticsEvent(ANALYTICS_EVENTS.BOOKING_ERROR, { step: "error", flow: "public" });
|
||||||
|
}, []);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
@@ -7,12 +23,12 @@ export function BookingErrorPage() {
|
|||||||
alignItems: "center",
|
alignItems: "center",
|
||||||
justifyContent: "center",
|
justifyContent: "center",
|
||||||
fontFamily: "system-ui, sans-serif",
|
fontFamily: "system-ui, sans-serif",
|
||||||
background: "#fef2f2",
|
background: "var(--color-error-bg)",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
background: "#fff",
|
background: "var(--color-surface)",
|
||||||
borderRadius: 12,
|
borderRadius: 12,
|
||||||
padding: "2.5rem 3rem",
|
padding: "2.5rem 3rem",
|
||||||
boxShadow: "0 4px 24px rgba(0,0,0,0.08)",
|
boxShadow: "0 4px 24px rgba(0,0,0,0.08)",
|
||||||
@@ -21,19 +37,20 @@ export function BookingErrorPage() {
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div style={{ fontSize: 56, marginBottom: "0.5rem" }}>⚠️</div>
|
<div style={{ fontSize: 56, marginBottom: "0.5rem" }}>⚠️</div>
|
||||||
<h1 style={{ color: "#b91c1c", fontSize: 24, margin: "0 0 0.5rem" }}>
|
<h1 style={{ color: "var(--color-error-dark)", fontSize: 24, margin: "0 0 0.5rem" }}>
|
||||||
Link Invalid or Expired
|
{STRINGS.heading}
|
||||||
</h1>
|
</h1>
|
||||||
<p style={{ color: "#4b5563", margin: "0 0 1.5rem" }}>
|
<p style={{ color: "var(--color-text-secondary)", margin: "0 0 1.5rem" }}>
|
||||||
This confirmation link is invalid, has already been used, or your
|
{STRINGS.body}
|
||||||
appointment has already passed. Please contact us if you need help.
|
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
|
<div style={{ display: "flex", flexDirection: "column", gap: "0.75rem", alignItems: "center" }}>
|
||||||
<a
|
<a
|
||||||
href="/"
|
href="/admin/book"
|
||||||
style={{
|
style={{
|
||||||
display: "inline-block",
|
display: "inline-block",
|
||||||
padding: "0.6rem 1.5rem",
|
padding: "0.6rem 1.5rem",
|
||||||
background: "#dc2626",
|
background: "var(--color-primary)",
|
||||||
color: "#fff",
|
color: "#fff",
|
||||||
borderRadius: 6,
|
borderRadius: 6,
|
||||||
textDecoration: "none",
|
textDecoration: "none",
|
||||||
@@ -41,8 +58,31 @@ export function BookingErrorPage() {
|
|||||||
fontSize: 14,
|
fontSize: 14,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
Back to Portal
|
{STRINGS.newBooking}
|
||||||
</a>
|
</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>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -25,6 +25,8 @@ interface PetForm {
|
|||||||
cutStyle: string;
|
cutStyle: string;
|
||||||
shampooPreference: string;
|
shampooPreference: string;
|
||||||
specialCareNotes: string;
|
specialCareNotes: string;
|
||||||
|
coatType: string;
|
||||||
|
sizeCategory: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface VisitLogForm {
|
interface VisitLogForm {
|
||||||
@@ -38,6 +40,7 @@ const EMPTY_CLIENT: ClientForm = { name: "", email: "", phone: "", address: "",
|
|||||||
const EMPTY_PET: PetForm = {
|
const EMPTY_PET: PetForm = {
|
||||||
name: "", species: "Dog", breed: "", weightStr: "", dob: "",
|
name: "", species: "Dog", breed: "", weightStr: "", dob: "",
|
||||||
healthAlerts: "", groomingNotes: "", cutStyle: "", shampooPreference: "", specialCareNotes: "",
|
healthAlerts: "", groomingNotes: "", cutStyle: "", shampooPreference: "", specialCareNotes: "",
|
||||||
|
coatType: "", sizeCategory: "",
|
||||||
};
|
};
|
||||||
const EMPTY_VISIT_LOG: VisitLogForm = { cutStyle: "", productsUsed: "", notes: "", groomedAt: "" };
|
const EMPTY_VISIT_LOG: VisitLogForm = { cutStyle: "", productsUsed: "", notes: "", groomedAt: "" };
|
||||||
|
|
||||||
@@ -209,6 +212,8 @@ export function ClientsPage() {
|
|||||||
cutStyle: p.cutStyle ?? "",
|
cutStyle: p.cutStyle ?? "",
|
||||||
shampooPreference: p.shampooPreference ?? "",
|
shampooPreference: p.shampooPreference ?? "",
|
||||||
specialCareNotes: p.specialCareNotes ?? "",
|
specialCareNotes: p.specialCareNotes ?? "",
|
||||||
|
coatType: p.coatType ?? "",
|
||||||
|
sizeCategory: p.petSizeCategory ?? "",
|
||||||
});
|
});
|
||||||
setPetFormError(null);
|
setPetFormError(null);
|
||||||
setShowPetForm(true);
|
setShowPetForm(true);
|
||||||
@@ -315,6 +320,8 @@ export function ClientsPage() {
|
|||||||
cutStyle: petForm.cutStyle || undefined,
|
cutStyle: petForm.cutStyle || undefined,
|
||||||
shampooPreference: petForm.shampooPreference || undefined,
|
shampooPreference: petForm.shampooPreference || undefined,
|
||||||
specialCareNotes: petForm.specialCareNotes || undefined,
|
specialCareNotes: petForm.specialCareNotes || undefined,
|
||||||
|
coatType: petForm.coatType || undefined,
|
||||||
|
petSizeCategory: petForm.sizeCategory || undefined,
|
||||||
};
|
};
|
||||||
const res = editingPet
|
const res = editingPet
|
||||||
? await fetch(`/api/pets/${editingPet.id}`, { method: "PATCH", headers: { "Content-Type": "application/json" }, body: JSON.stringify(body) })
|
? 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)">
|
<Field label="Breed (optional)">
|
||||||
<input value={petForm.breed} onChange={(e) => setPetForm((f) => ({ ...f, breed: e.target.value }))} style={inputStyle} />
|
<input value={petForm.breed} onChange={(e) => setPetForm((f) => ({ ...f, breed: e.target.value }))} style={inputStyle} />
|
||||||
</Field>
|
</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)">
|
<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} />
|
<input type="number" step="0.1" min="0" value={petForm.weightStr} onChange={(e) => setPetForm((f) => ({ ...f, weightStr: e.target.value }))} style={inputStyle} />
|
||||||
</Field>
|
</Field>
|
||||||
|
|||||||
@@ -0,0 +1,781 @@
|
|||||||
|
import { Suspense, lazy, useCallback, useEffect, useMemo, useState } from "react";
|
||||||
|
import {
|
||||||
|
DndContext,
|
||||||
|
KeyboardSensor,
|
||||||
|
PointerSensor,
|
||||||
|
TouchSensor,
|
||||||
|
closestCenter,
|
||||||
|
useSensor,
|
||||||
|
useSensors,
|
||||||
|
type DragEndEvent,
|
||||||
|
} from "@dnd-kit/core";
|
||||||
|
import {
|
||||||
|
SortableContext,
|
||||||
|
arrayMove,
|
||||||
|
sortableKeyboardCoordinates,
|
||||||
|
useSortable,
|
||||||
|
verticalListSortingStrategy,
|
||||||
|
} from "@dnd-kit/sortable";
|
||||||
|
import { CSS } from "@dnd-kit/utilities";
|
||||||
|
import { useBranding } from "../BrandingContext.js";
|
||||||
|
import type { RouteMapStop } from "../components/RouteMap.js";
|
||||||
|
|
||||||
|
// Leaflet is heavy and only needed on this page — load it as a separate chunk.
|
||||||
|
const RouteMap = lazy(() => import("../components/RouteMap.js"));
|
||||||
|
|
||||||
|
// ─── Types (mirror groombook/api /api/routes responses) ─────────────────────────
|
||||||
|
|
||||||
|
type RouteStatus = "draft" | "optimized" | "in_progress" | "completed";
|
||||||
|
|
||||||
|
interface RouteRow {
|
||||||
|
id: string;
|
||||||
|
staffId: string;
|
||||||
|
routeDate: string;
|
||||||
|
status: RouteStatus;
|
||||||
|
totalTravelMins: number | null;
|
||||||
|
totalDistanceKm: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ConflictFlags {
|
||||||
|
hasConflict: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface RouteStop {
|
||||||
|
id: string;
|
||||||
|
appointmentId: string;
|
||||||
|
stopOrder: number;
|
||||||
|
latitude: number;
|
||||||
|
longitude: number;
|
||||||
|
travelMinsFromPrev: number | null;
|
||||||
|
travelDistanceKmFromPrev: string | null;
|
||||||
|
bufferMins: number;
|
||||||
|
appointmentStartTime: string;
|
||||||
|
appointmentEndTime: string;
|
||||||
|
appointmentStatus: string;
|
||||||
|
clientId: string;
|
||||||
|
clientName: string;
|
||||||
|
clientAddress: string | null;
|
||||||
|
conflict: ConflictFlags;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface RouteResponse {
|
||||||
|
route: RouteRow;
|
||||||
|
stops: RouteStop[];
|
||||||
|
hasConflicts: boolean;
|
||||||
|
conflictCount: number;
|
||||||
|
warnings?: string[];
|
||||||
|
skipped?: Array<{ appointmentId: string; clientName: string; reason: string }>;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface StaffMember {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
role: "groomer" | "receptionist" | "manager";
|
||||||
|
active: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Helpers ────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function todayIso(): string {
|
||||||
|
return new Date().toISOString().slice(0, 10);
|
||||||
|
}
|
||||||
|
|
||||||
|
function fmtDuration(mins: number | null | undefined): string {
|
||||||
|
if (mins == null) return "—";
|
||||||
|
if (mins < 60) return `${mins} min`;
|
||||||
|
const h = Math.floor(mins / 60);
|
||||||
|
const m = mins % 60;
|
||||||
|
return m === 0 ? `${h} h` : `${h} h ${m} min`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function fmtTime(iso: string): string {
|
||||||
|
const d = new Date(iso);
|
||||||
|
if (Number.isNaN(d.getTime())) return iso;
|
||||||
|
return d.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" });
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Navigation export ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/** Navigation target platforms supported by the API export endpoints. */
|
||||||
|
type NavigationPlatform = "google-maps" | "apple-maps";
|
||||||
|
|
||||||
|
type DevicePlatform = "ios" | "android" | "other";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Best-effort mobile-OS detection so we can surface the most useful navigation
|
||||||
|
* app first. Apple Maps deep links (`maps://`) only resolve on iOS; everywhere
|
||||||
|
* else Google Maps is the safe default. iPadOS 13+ reports a desktop UA, so we
|
||||||
|
* also treat a touch-capable "MacIntel" device as iOS.
|
||||||
|
*/
|
||||||
|
function detectPlatform(): DevicePlatform {
|
||||||
|
if (typeof navigator === "undefined") return "other";
|
||||||
|
const ua = navigator.userAgent || "";
|
||||||
|
if (/iphone|ipad|ipod/i.test(ua)) return "ios";
|
||||||
|
if (navigator.platform === "MacIntel" && navigator.maxTouchPoints > 1) return "ios";
|
||||||
|
if (/android/i.test(ua)) return "android";
|
||||||
|
return "other";
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Offline map-tile pre-warming ────────────────────────────────────────────
|
||||||
|
|
||||||
|
/** OSM tile zoom levels pre-fetched around a route so the map renders offline. */
|
||||||
|
const PREWARM_ZOOM_LEVELS = [12, 13, 14] as const;
|
||||||
|
/** Hard cap on tiles fetched per pre-warm pass — keeps us friendly to OSM. */
|
||||||
|
const MAX_PREWARM_TILES = 80;
|
||||||
|
/** Subdomains Leaflet's default OSM TileLayer rotates through (`{s}`). */
|
||||||
|
const TILE_SUBDOMAINS = ["a", "b", "c"] as const;
|
||||||
|
|
||||||
|
/** Web-Mercator longitude → tile X index at the given zoom. */
|
||||||
|
function lonToTileX(lon: number, z: number): number {
|
||||||
|
return Math.floor(((lon + 180) / 360) * 2 ** z);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Web-Mercator latitude → tile Y index at the given zoom. */
|
||||||
|
function latToTileY(lat: number, z: number): number {
|
||||||
|
const rad = (lat * Math.PI) / 180;
|
||||||
|
return Math.floor(
|
||||||
|
((1 - Math.log(Math.tan(rad) + 1 / Math.cos(rad)) / Math.PI) / 2) * 2 ** z
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Warm the browser/service-worker cache with the OSM tiles covering the route's
|
||||||
|
* bounding box (plus a one-tile margin) across a few zoom levels. Tiles are
|
||||||
|
* fetched via `new Image()` so they hit the same URLs Leaflet later requests and
|
||||||
|
* land in the CacheFirst tile cache, making the map viewable offline. Bounded by
|
||||||
|
* MAX_PREWARM_TILES so a sprawling route never floods the network.
|
||||||
|
*/
|
||||||
|
function prewarmRouteTiles(
|
||||||
|
stops: Array<{ latitude: number; longitude: number }>
|
||||||
|
): void {
|
||||||
|
if (typeof window === "undefined" || stops.length === 0) return;
|
||||||
|
const lats = stops.map((s) => s.latitude);
|
||||||
|
const lons = stops.map((s) => s.longitude);
|
||||||
|
const minLat = Math.min(...lats);
|
||||||
|
const maxLat = Math.max(...lats);
|
||||||
|
const minLon = Math.min(...lons);
|
||||||
|
const maxLon = Math.max(...lons);
|
||||||
|
|
||||||
|
const urls: string[] = [];
|
||||||
|
for (const z of PREWARM_ZOOM_LEVELS) {
|
||||||
|
const x0 = lonToTileX(minLon, z) - 1;
|
||||||
|
const x1 = lonToTileX(maxLon, z) + 1;
|
||||||
|
// Tile Y grows as latitude decreases, so maxLat → smaller Y.
|
||||||
|
const y0 = latToTileY(maxLat, z) - 1;
|
||||||
|
const y1 = latToTileY(minLat, z) + 1;
|
||||||
|
for (let x = x0; x <= x1; x++) {
|
||||||
|
for (let y = y0; y <= y1; y++) {
|
||||||
|
if (x < 0 || y < 0 || x >= 2 ** z || y >= 2 ** z) continue;
|
||||||
|
const s = TILE_SUBDOMAINS[(x + y) % TILE_SUBDOMAINS.length];
|
||||||
|
urls.push(`https://${s}.tile.openstreetmap.org/${z}/${x}/${y}.png`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const url of urls.slice(0, MAX_PREWARM_TILES)) {
|
||||||
|
const img = new Image();
|
||||||
|
img.src = url;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Responsive layout ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/** Tracks a `max-width` media query so the page can adapt to phone widths. */
|
||||||
|
function useIsMobile(maxWidthPx = 768): boolean {
|
||||||
|
const query = `(max-width: ${maxWidthPx}px)`;
|
||||||
|
const [isMobile, setIsMobile] = useState(
|
||||||
|
() => typeof window !== "undefined" && typeof window.matchMedia === "function"
|
||||||
|
? window.matchMedia(query).matches
|
||||||
|
: false
|
||||||
|
);
|
||||||
|
useEffect(() => {
|
||||||
|
if (typeof window === "undefined" || typeof window.matchMedia !== "function") return;
|
||||||
|
const mq = window.matchMedia(query);
|
||||||
|
const onChange = (e: MediaQueryListEvent) => setIsMobile(e.matches);
|
||||||
|
setIsMobile(mq.matches);
|
||||||
|
mq.addEventListener("change", onChange);
|
||||||
|
return () => mq.removeEventListener("change", onChange);
|
||||||
|
}, [query]);
|
||||||
|
return isMobile;
|
||||||
|
}
|
||||||
|
|
||||||
|
const STATUS_STYLES: Record<RouteStatus, { bg: string; fg: string; label: string }> = {
|
||||||
|
draft: { bg: "#f1f5f9", fg: "#475569", label: "Draft" },
|
||||||
|
optimized: { bg: "#ecfdf5", fg: "#047857", label: "Optimized" },
|
||||||
|
in_progress: { bg: "#eff6ff", fg: "#1d4ed8", label: "In progress" },
|
||||||
|
completed: { bg: "#f5f3ff", fg: "#6d28d9", label: "Completed" },
|
||||||
|
};
|
||||||
|
|
||||||
|
function StatusBadge({ status }: { status: RouteStatus }) {
|
||||||
|
const s = STATUS_STYLES[status] ?? STATUS_STYLES.draft;
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
background: s.bg,
|
||||||
|
color: s.fg,
|
||||||
|
borderRadius: 999,
|
||||||
|
padding: "0.2rem 0.7rem",
|
||||||
|
fontSize: 12,
|
||||||
|
fontWeight: 600,
|
||||||
|
textTransform: "none",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{s.label}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const inputStyle: React.CSSProperties = {
|
||||||
|
padding: "0.4rem 0.6rem",
|
||||||
|
borderRadius: 6,
|
||||||
|
border: "1px solid #cbd5e1",
|
||||||
|
fontSize: 14,
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A single draggable stop card. The drag handle (⠿) carries the dnd-kit
|
||||||
|
* listeners so the rest of the card stays scrollable/selectable; the handle is
|
||||||
|
* sized for touch and works with pointer, touch and keyboard sensors.
|
||||||
|
*/
|
||||||
|
function SortableStop({
|
||||||
|
stop,
|
||||||
|
primaryColor,
|
||||||
|
disabled,
|
||||||
|
}: {
|
||||||
|
stop: RouteStop;
|
||||||
|
primaryColor: string;
|
||||||
|
disabled: boolean;
|
||||||
|
}) {
|
||||||
|
const { attributes, listeners, setNodeRef, transform, transition, isDragging } =
|
||||||
|
useSortable({ id: stop.id, disabled });
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
ref={setNodeRef}
|
||||||
|
style={{
|
||||||
|
background: "#fff",
|
||||||
|
border: `1px solid ${stop.conflict?.hasConflict ? "#fca5a5" : "#e2e8f0"}`,
|
||||||
|
borderRadius: 8,
|
||||||
|
padding: "0.7rem 0.85rem",
|
||||||
|
display: "flex",
|
||||||
|
gap: 10,
|
||||||
|
transform: CSS.Transform.toString(transform),
|
||||||
|
transition,
|
||||||
|
opacity: isDragging ? 0.6 : 1,
|
||||||
|
boxShadow: isDragging ? "0 6px 16px rgba(0,0,0,0.18)" : "none",
|
||||||
|
touchAction: "none",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
aria-label={`Drag to reorder ${stop.clientName}`}
|
||||||
|
disabled={disabled}
|
||||||
|
{...attributes}
|
||||||
|
{...listeners}
|
||||||
|
style={{
|
||||||
|
flexShrink: 0,
|
||||||
|
alignSelf: "stretch",
|
||||||
|
width: 28,
|
||||||
|
border: "none",
|
||||||
|
background: "transparent",
|
||||||
|
color: "#94a3b8",
|
||||||
|
fontSize: 18,
|
||||||
|
lineHeight: 1,
|
||||||
|
cursor: disabled ? "not-allowed" : "grab",
|
||||||
|
touchAction: "none",
|
||||||
|
padding: 0,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
⠿
|
||||||
|
</button>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
flexShrink: 0,
|
||||||
|
width: 26,
|
||||||
|
height: 26,
|
||||||
|
borderRadius: "50%",
|
||||||
|
background: primaryColor,
|
||||||
|
color: "#fff",
|
||||||
|
fontSize: 13,
|
||||||
|
fontWeight: 700,
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{stop.stopOrder}
|
||||||
|
</div>
|
||||||
|
<div style={{ minWidth: 0, flex: 1 }}>
|
||||||
|
<div style={{ display: "flex", justifyContent: "space-between", gap: 8 }}>
|
||||||
|
<strong style={{ fontSize: 14, color: "#1a202c" }}>{stop.clientName}</strong>
|
||||||
|
<span style={{ fontSize: 13, color: "#4b5563", whiteSpace: "nowrap" }}>{fmtTime(stop.appointmentStartTime)}</span>
|
||||||
|
</div>
|
||||||
|
<div style={{ fontSize: 12, color: "#6b7280", marginTop: 2 }}>{stop.clientAddress || "No address on file"}</div>
|
||||||
|
<div style={{ fontSize: 12, color: "#6b7280", marginTop: 4 }}>
|
||||||
|
{stop.stopOrder === 1 || stop.travelMinsFromPrev == null
|
||||||
|
? "Start of route"
|
||||||
|
: `${fmtDuration(stop.travelMinsFromPrev)} travel from previous`}
|
||||||
|
</div>
|
||||||
|
{stop.conflict?.hasConflict && (
|
||||||
|
<div style={{ fontSize: 12, color: "#b91c1c", marginTop: 4, fontWeight: 600 }}>
|
||||||
|
⚠ Tight schedule — travel + buffer may exceed the gap
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Navigation export controls. Fetches a platform deep-link from the API and opens
|
||||||
|
* it. The button matching the detected device OS is shown prominently (filled);
|
||||||
|
* the other is offered as a secondary outline button. On desktop both are
|
||||||
|
* secondary and Google Maps leads.
|
||||||
|
*/
|
||||||
|
function NavExportButtons({
|
||||||
|
routeId,
|
||||||
|
primaryColor,
|
||||||
|
fullWidth,
|
||||||
|
}: {
|
||||||
|
routeId: string;
|
||||||
|
primaryColor: string;
|
||||||
|
fullWidth: boolean;
|
||||||
|
}) {
|
||||||
|
const [busy, setBusy] = useState<NavigationPlatform | null>(null);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const platform = useMemo(detectPlatform, []);
|
||||||
|
|
||||||
|
const openIn = useCallback(
|
||||||
|
async (target: NavigationPlatform) => {
|
||||||
|
setBusy(target);
|
||||||
|
setError(null);
|
||||||
|
// Pre-open a tab synchronously: mobile Safari/Chrome block window.open()
|
||||||
|
// calls that happen after an await (no longer in the user-gesture turn).
|
||||||
|
const win = window.open("", "_blank");
|
||||||
|
try {
|
||||||
|
const r = await fetch(`/api/routes/${encodeURIComponent(routeId)}/export/${target}`);
|
||||||
|
if (!r.ok) {
|
||||||
|
const body = await r.json().catch(() => ({}));
|
||||||
|
throw new Error(body.error || `Export failed (${r.status})`);
|
||||||
|
}
|
||||||
|
const { url } = (await r.json()) as { url: string };
|
||||||
|
if (win) win.location.href = url;
|
||||||
|
else window.location.href = url;
|
||||||
|
} catch (e) {
|
||||||
|
win?.close();
|
||||||
|
setError(e instanceof Error ? e.message : "Export failed");
|
||||||
|
} finally {
|
||||||
|
setBusy(null);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[routeId]
|
||||||
|
);
|
||||||
|
|
||||||
|
const baseBtn: React.CSSProperties = {
|
||||||
|
padding: "0.55rem 1rem",
|
||||||
|
borderRadius: 6,
|
||||||
|
fontWeight: 600,
|
||||||
|
fontSize: 14,
|
||||||
|
cursor: busy ? "wait" : "pointer",
|
||||||
|
flex: fullWidth ? "1 1 0" : "0 0 auto",
|
||||||
|
};
|
||||||
|
const primaryBtn: React.CSSProperties = {
|
||||||
|
...baseBtn,
|
||||||
|
border: "none",
|
||||||
|
background: primaryColor,
|
||||||
|
color: "#fff",
|
||||||
|
};
|
||||||
|
const secondaryBtn: React.CSSProperties = {
|
||||||
|
...baseBtn,
|
||||||
|
border: `1px solid ${primaryColor}`,
|
||||||
|
background: "#fff",
|
||||||
|
color: primaryColor,
|
||||||
|
};
|
||||||
|
|
||||||
|
const label = (p: NavigationPlatform) =>
|
||||||
|
busy === p
|
||||||
|
? "Opening…"
|
||||||
|
: p === "google-maps"
|
||||||
|
? "Open in Google Maps"
|
||||||
|
: "Open in Apple Maps";
|
||||||
|
|
||||||
|
const google = (
|
||||||
|
<button
|
||||||
|
key="google"
|
||||||
|
type="button"
|
||||||
|
onClick={() => openIn("google-maps")}
|
||||||
|
disabled={busy !== null}
|
||||||
|
style={platform === "ios" ? secondaryBtn : primaryBtn}
|
||||||
|
>
|
||||||
|
{label("google-maps")}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
const apple = (
|
||||||
|
<button
|
||||||
|
key="apple"
|
||||||
|
type="button"
|
||||||
|
onClick={() => openIn("apple-maps")}
|
||||||
|
disabled={busy !== null}
|
||||||
|
style={platform === "ios" ? primaryBtn : secondaryBtn}
|
||||||
|
>
|
||||||
|
{label("apple-maps")}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
// Prominent (filled) button first; secondary second.
|
||||||
|
const ordered = platform === "ios" ? [apple, google] : [google, apple];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ display: "flex", flexDirection: "column", gap: 6 }}>
|
||||||
|
<div style={{ display: "flex", gap: 8, flexWrap: "wrap", alignItems: "center" }}>
|
||||||
|
<span style={{ fontSize: 12, color: "#4b5563", fontWeight: 600, marginRight: 4 }}>
|
||||||
|
Navigate
|
||||||
|
</span>
|
||||||
|
{ordered}
|
||||||
|
</div>
|
||||||
|
{error && <div style={{ fontSize: 12, color: "#991b1b" }}>{error}</div>}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Page ───────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export function RoutesPage() {
|
||||||
|
const { branding } = useBranding();
|
||||||
|
const primaryColor = branding.primaryColor || "#4f8a6f";
|
||||||
|
|
||||||
|
const [me, setMe] = useState<StaffMember | null>(null);
|
||||||
|
const [meLoaded, setMeLoaded] = useState(false);
|
||||||
|
const [groomers, setGroomers] = useState<StaffMember[]>([]);
|
||||||
|
const [staffId, setStaffId] = useState<string>("");
|
||||||
|
const [date, setDate] = useState<string>(todayIso());
|
||||||
|
|
||||||
|
const [data, setData] = useState<RouteResponse | null>(null);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [optimizing, setOptimizing] = useState(false);
|
||||||
|
const [reordering, setReordering] = useState(false);
|
||||||
|
const [manuallyReordered, setManuallyReordered] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const isGroomer = me?.role === "groomer";
|
||||||
|
const isMobile = useIsMobile();
|
||||||
|
|
||||||
|
// Resolve the current staff member; groomers are pinned to their own route.
|
||||||
|
useEffect(() => {
|
||||||
|
fetch("/api/staff/me")
|
||||||
|
.then((r) => (r.ok ? r.json() : null))
|
||||||
|
.then((row: StaffMember | null) => {
|
||||||
|
setMe(row);
|
||||||
|
if (row?.role === "groomer") setStaffId(row.id);
|
||||||
|
})
|
||||||
|
.catch(() => setMe(null))
|
||||||
|
.finally(() => setMeLoaded(true));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Managers / receptionists pick a groomer; groomers never see the selector.
|
||||||
|
useEffect(() => {
|
||||||
|
if (!meLoaded || isGroomer) return;
|
||||||
|
fetch("/api/staff")
|
||||||
|
.then((r) => (r.ok ? r.json() : []))
|
||||||
|
.then((rows: StaffMember[]) => {
|
||||||
|
const gs = rows.filter((s) => s.active && s.role === "groomer");
|
||||||
|
setGroomers(gs);
|
||||||
|
setStaffId((cur) => cur || gs[0]?.id || "");
|
||||||
|
})
|
||||||
|
.catch(() => setGroomers([]));
|
||||||
|
}, [meLoaded, isGroomer]);
|
||||||
|
|
||||||
|
const loadRoute = useCallback(async () => {
|
||||||
|
if (!staffId || !date) return;
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
const r = await fetch(
|
||||||
|
`/api/routes/daily?staffId=${encodeURIComponent(staffId)}&date=${encodeURIComponent(date)}`
|
||||||
|
);
|
||||||
|
if (!r.ok) {
|
||||||
|
const body = await r.json().catch(() => ({}));
|
||||||
|
throw new Error(body.error || `Failed to load route (${r.status})`);
|
||||||
|
}
|
||||||
|
setData(await r.json());
|
||||||
|
setManuallyReordered(false);
|
||||||
|
} catch (e) {
|
||||||
|
setData(null);
|
||||||
|
setError(e instanceof Error ? e.message : "Failed to load route");
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, [staffId, date]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
void loadRoute();
|
||||||
|
}, [loadRoute]);
|
||||||
|
|
||||||
|
const optimize = useCallback(async () => {
|
||||||
|
if (!staffId || !date) return;
|
||||||
|
setOptimizing(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
const r = await fetch("/api/routes/optimize", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ staffId, date }),
|
||||||
|
});
|
||||||
|
if (!r.ok) {
|
||||||
|
const body = await r.json().catch(() => ({}));
|
||||||
|
throw new Error(body.error || `Optimization failed (${r.status})`);
|
||||||
|
}
|
||||||
|
setData(await r.json());
|
||||||
|
setManuallyReordered(false);
|
||||||
|
} catch (e) {
|
||||||
|
setError(e instanceof Error ? e.message : "Optimization failed");
|
||||||
|
} finally {
|
||||||
|
setOptimizing(false);
|
||||||
|
}
|
||||||
|
}, [staffId, date]);
|
||||||
|
|
||||||
|
// Drag-to-reorder: pointer for desktop, touch (press-and-hold) for mobile
|
||||||
|
// groomers, keyboard for accessibility. Touch uses a short delay so vertical
|
||||||
|
// scrolling of the stop list still works without triggering a drag.
|
||||||
|
const sensors = useSensors(
|
||||||
|
useSensor(PointerSensor, { activationConstraint: { distance: 5 } }),
|
||||||
|
useSensor(TouchSensor, { activationConstraint: { delay: 200, tolerance: 8 } }),
|
||||||
|
useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates })
|
||||||
|
);
|
||||||
|
|
||||||
|
// Persist a manually reordered stop list. Optimistic: the UI is updated
|
||||||
|
// immediately from the dropped order and rolled back if the PATCH fails.
|
||||||
|
const reorder = useCallback(
|
||||||
|
async (orderedIds: string[]) => {
|
||||||
|
const routeId = data?.route?.id;
|
||||||
|
if (!routeId) return;
|
||||||
|
const previous = data;
|
||||||
|
// Optimistic local update: renumber stopOrder to match the new order so
|
||||||
|
// the list and the map reflect the drop before the server responds.
|
||||||
|
const byId = new Map((data?.stops ?? []).map((s) => [s.id, s]));
|
||||||
|
const optimisticStops = orderedIds
|
||||||
|
.map((id, i) => {
|
||||||
|
const s = byId.get(id);
|
||||||
|
return s ? { ...s, stopOrder: i + 1 } : null;
|
||||||
|
})
|
||||||
|
.filter((s): s is RouteStop => s !== null);
|
||||||
|
setData((cur) => (cur ? { ...cur, stops: optimisticStops } : cur));
|
||||||
|
setManuallyReordered(true);
|
||||||
|
setReordering(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
const r = await fetch(`/api/routes/${encodeURIComponent(routeId)}/reorder`, {
|
||||||
|
method: "PATCH",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ stopOrder: orderedIds }),
|
||||||
|
});
|
||||||
|
if (!r.ok) {
|
||||||
|
const body = await r.json().catch(() => ({}));
|
||||||
|
throw new Error(body.error || `Reorder failed (${r.status})`);
|
||||||
|
}
|
||||||
|
// Server recomputes travel legs, buffers and conflict flags — adopt its
|
||||||
|
// authoritative response over the optimistic guess.
|
||||||
|
setData(await r.json());
|
||||||
|
} catch (e) {
|
||||||
|
setData(previous); // rollback
|
||||||
|
setError(e instanceof Error ? e.message : "Reorder failed");
|
||||||
|
} finally {
|
||||||
|
setReordering(false);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[data]
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleDragEnd = useCallback(
|
||||||
|
(event: DragEndEvent) => {
|
||||||
|
const { active, over } = event;
|
||||||
|
if (!over || active.id === over.id) return;
|
||||||
|
const ids = (data?.stops ?? []).map((s) => s.id);
|
||||||
|
const from = ids.indexOf(String(active.id));
|
||||||
|
const to = ids.indexOf(String(over.id));
|
||||||
|
if (from === -1 || to === -1) return;
|
||||||
|
void reorder(arrayMove(ids, from, to));
|
||||||
|
},
|
||||||
|
[data, reorder]
|
||||||
|
);
|
||||||
|
|
||||||
|
const mapStops: RouteMapStop[] = useMemo(
|
||||||
|
() =>
|
||||||
|
(data?.stops ?? []).map((s) => ({
|
||||||
|
id: s.id,
|
||||||
|
stopOrder: s.stopOrder,
|
||||||
|
latitude: s.latitude,
|
||||||
|
longitude: s.longitude,
|
||||||
|
clientName: s.clientName,
|
||||||
|
})),
|
||||||
|
[data]
|
||||||
|
);
|
||||||
|
|
||||||
|
// Pre-warm OSM map tiles for the route area whenever a route (re)loads or is
|
||||||
|
// re-optimized, so the map stays viewable offline. Runs after today's route is
|
||||||
|
// fetched on page load and after every optimize/reorder that yields new stops.
|
||||||
|
useEffect(() => {
|
||||||
|
if (mapStops.length > 0) prewarmRouteTiles(mapStops);
|
||||||
|
}, [mapStops]);
|
||||||
|
|
||||||
|
const stops = data?.stops ?? [];
|
||||||
|
const route = data?.route ?? null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ padding: "1.25rem", maxWidth: 1280, margin: "0 auto" }}>
|
||||||
|
<div style={{ display: "flex", alignItems: "center", gap: 12, flexWrap: "wrap", marginBottom: "1rem" }}>
|
||||||
|
<h1 style={{ fontSize: 22, fontWeight: 700, color: "#1a202c", margin: 0 }}>Route Planner</h1>
|
||||||
|
{route && <StatusBadge status={route.status} />}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Controls */}
|
||||||
|
<div style={{ display: "flex", gap: 12, flexWrap: "wrap", alignItems: "flex-end", marginBottom: "1rem" }}>
|
||||||
|
<label style={{ display: "flex", flexDirection: "column", gap: 4, fontSize: 12, color: "#4b5563", fontWeight: 600 }}>
|
||||||
|
Date
|
||||||
|
<input type="date" value={date} onChange={(e) => setDate(e.target.value)} style={inputStyle} />
|
||||||
|
</label>
|
||||||
|
|
||||||
|
{!isGroomer && (
|
||||||
|
<label style={{ display: "flex", flexDirection: "column", gap: 4, fontSize: 12, color: "#4b5563", fontWeight: 600 }}>
|
||||||
|
Groomer
|
||||||
|
<select
|
||||||
|
value={staffId}
|
||||||
|
onChange={(e) => setStaffId(e.target.value)}
|
||||||
|
style={{ ...inputStyle, minWidth: 180 }}
|
||||||
|
>
|
||||||
|
{groomers.length === 0 && <option value="">No groomers</option>}
|
||||||
|
{groomers.map((g) => (
|
||||||
|
<option key={g.id} value={g.id}>
|
||||||
|
{g.name}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={optimize}
|
||||||
|
disabled={optimizing || !staffId}
|
||||||
|
style={{
|
||||||
|
padding: "0.5rem 1.1rem",
|
||||||
|
borderRadius: 6,
|
||||||
|
border: "none",
|
||||||
|
background: primaryColor,
|
||||||
|
color: "#fff",
|
||||||
|
fontWeight: 600,
|
||||||
|
fontSize: 14,
|
||||||
|
cursor: optimizing || !staffId ? "wait" : "pointer",
|
||||||
|
opacity: optimizing || !staffId ? 0.7 : 1,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{optimizing ? "Optimizing…" : "Optimize"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<div style={{ background: "#fef2f2", border: "1px solid #fecaca", borderRadius: 6, padding: "0.6rem 0.8rem", color: "#991b1b", fontSize: 13, marginBottom: "1rem" }}>
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{data?.warnings && data.warnings.length > 0 && (
|
||||||
|
<div style={{ background: "#fffbeb", border: "1px solid #fde68a", borderRadius: 6, padding: "0.6rem 0.8rem", color: "#92400e", fontSize: 13, marginBottom: "1rem" }}>
|
||||||
|
{data.warnings.map((w, i) => (
|
||||||
|
<div key={i}>{w}</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Summary */}
|
||||||
|
<div style={{ display: "flex", gap: 24, flexWrap: "wrap", marginBottom: "1rem", padding: "0.8rem 1rem", background: "#fff", borderRadius: 8, border: "1px solid #e2e8f0" }}>
|
||||||
|
<Summary label="Stops" value={String(stops.length)} />
|
||||||
|
<Summary label="Total travel time" value={fmtDuration(route?.totalTravelMins)} />
|
||||||
|
<Summary label="Total distance" value={route?.totalDistanceKm != null ? `${route.totalDistanceKm} km` : "—"} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Navigation export — open the route in the device's maps app */}
|
||||||
|
{route && stops.length > 0 && (
|
||||||
|
<div style={{ marginBottom: "1rem", padding: "0.8rem 1rem", background: "#fff", borderRadius: 8, border: "1px solid #e2e8f0" }}>
|
||||||
|
<NavExportButtons routeId={route.id} primaryColor={primaryColor} fullWidth={isMobile} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div style={{ display: "grid", gridTemplateColumns: isMobile ? "1fr" : "minmax(0, 1.5fr) minmax(280px, 1fr)", gap: 16, alignItems: "stretch" }}>
|
||||||
|
{/* Map */}
|
||||||
|
<div style={{ height: isMobile ? 340 : 540, background: "#e5e7eb", borderRadius: 8, overflow: "hidden", border: "1px solid #e2e8f0" }}>
|
||||||
|
{mapStops.length > 0 ? (
|
||||||
|
<Suspense fallback={<Centered>Loading map…</Centered>}>
|
||||||
|
<RouteMap stops={mapStops} primaryColor={primaryColor} />
|
||||||
|
</Suspense>
|
||||||
|
) : (
|
||||||
|
<Centered>{loading ? "Loading route…" : "No stops to display. Click Optimize to build the route."}</Centered>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Stop list panel — drag-to-reorder */}
|
||||||
|
<div style={{ display: "flex", flexDirection: "column", gap: 10, maxHeight: isMobile ? "none" : 540, overflowY: isMobile ? "visible" : "auto" }}>
|
||||||
|
{stops.length === 0 && !loading && (
|
||||||
|
<div style={{ color: "#6b7280", fontSize: 14, padding: "1rem" }}>No stops for this day.</div>
|
||||||
|
)}
|
||||||
|
{stops.length > 0 && (
|
||||||
|
<>
|
||||||
|
{manuallyReordered && (
|
||||||
|
<div style={{ display: "flex", alignItems: "center", justifyContent: "space-between", gap: 8, background: "#fffbeb", border: "1px solid #fde68a", borderRadius: 8, padding: "0.55rem 0.7rem" }}>
|
||||||
|
<span style={{ fontSize: 12, color: "#92400e" }}>
|
||||||
|
Stops reordered manually. Re-optimize to recompute the best route.
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={optimize}
|
||||||
|
disabled={optimizing || reordering || !staffId}
|
||||||
|
style={{
|
||||||
|
flexShrink: 0,
|
||||||
|
padding: "0.35rem 0.7rem",
|
||||||
|
borderRadius: 6,
|
||||||
|
border: "none",
|
||||||
|
background: primaryColor,
|
||||||
|
color: "#fff",
|
||||||
|
fontSize: 13,
|
||||||
|
fontWeight: 600,
|
||||||
|
cursor: optimizing || reordering || !staffId ? "not-allowed" : "pointer",
|
||||||
|
opacity: optimizing || reordering || !staffId ? 0.6 : 1,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{optimizing ? "Optimizing…" : "Re-optimize"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<DndContext
|
||||||
|
sensors={sensors}
|
||||||
|
collisionDetection={closestCenter}
|
||||||
|
onDragEnd={handleDragEnd}
|
||||||
|
>
|
||||||
|
<SortableContext items={stops.map((s) => s.id)} strategy={verticalListSortingStrategy}>
|
||||||
|
{stops.map((s) => (
|
||||||
|
<SortableStop key={s.id} stop={s} primaryColor={primaryColor} disabled={reordering} />
|
||||||
|
))}
|
||||||
|
</SortableContext>
|
||||||
|
</DndContext>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Summary({ label, value }: { label: string; value: string }) {
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div style={{ fontSize: 12, color: "#6b7280", fontWeight: 600 }}>{label}</div>
|
||||||
|
<div style={{ fontSize: 18, color: "#1a202c", fontWeight: 700 }}>{value}</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Centered({ children }: { children: React.ReactNode }) {
|
||||||
|
return (
|
||||||
|
<div style={{ height: "100%", display: "flex", alignItems: "center", justifyContent: "center", color: "#6b7280", fontSize: 14, textAlign: "center", padding: "1rem" }}>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -13,8 +13,10 @@ import { Communication } from "./sections/Communication.js";
|
|||||||
import { AccountSettings } from "./sections/AccountSettings.js";
|
import { AccountSettings } from "./sections/AccountSettings.js";
|
||||||
import { ImpersonationBanner } from "./ImpersonationBanner.js";
|
import { ImpersonationBanner } from "./ImpersonationBanner.js";
|
||||||
import { AuditLogViewer } from "./AuditLogViewer.js";
|
import { AuditLogViewer } from "./AuditLogViewer.js";
|
||||||
|
import { OOBE } from "./OOBE.js";
|
||||||
import { useBranding } from "../BrandingContext.js";
|
import { useBranding } from "../BrandingContext.js";
|
||||||
import { getDevUser } from "../pages/DevLoginSelector.js";
|
import { getDevUser } from "../pages/DevLoginSelector.js";
|
||||||
|
import { signOut } from "../lib/auth-client.js";
|
||||||
import type { ImpersonationSession } from "@groombook/types";
|
import type { ImpersonationSession } from "@groombook/types";
|
||||||
import type { Appointment as PortalAppointment } from "./sections/Appointments.js";
|
import type { Appointment as PortalAppointment } from "./sections/Appointments.js";
|
||||||
|
|
||||||
@@ -43,6 +45,22 @@ export function CustomerPortal() {
|
|||||||
// Track whether an impersonation session fetch from URL param is in-flight
|
// 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
|
// Dashboard will not redirect while this is true, allowing the session to load
|
||||||
const [isImpersonating, setIsImpersonating] = useState(false);
|
const [isImpersonating, setIsImpersonating] = useState(false);
|
||||||
|
// Portal session ID for real SSO customers (GRO-1867). Populated by the
|
||||||
|
// Better Auth → /api/portal/session-from-auth bridge below. Carries the
|
||||||
|
// X-Impersonation-Session-Id header on subsequent portal API calls without
|
||||||
|
// triggering the impersonation banner (the customer is themselves).
|
||||||
|
const [portalSessionId, setPortalSessionId] = useState<string | null>(null);
|
||||||
|
// User-facing message when the SSO bridge cannot resolve a client record
|
||||||
|
// (e.g. authenticated user with no matching client row). Rendered in place
|
||||||
|
// of the portal chrome instead of bouncing back to /login.
|
||||||
|
const [authError, setAuthError] = useState<string | null>(null);
|
||||||
|
// GRO-2359 — the SSO bridge 404 (no client row for the user's email)
|
||||||
|
// routes the user into the OOBE. We mount the OOBE inline rather than
|
||||||
|
// navigating to /onboarding so the post-auth flow stays inside the
|
||||||
|
// CustomerPortal render tree (test-isolated, no App-level router needed
|
||||||
|
// for the integration to work). The /onboarding route in App.tsx is
|
||||||
|
// still the mount point for direct deep-links to the same component.
|
||||||
|
const [showOOBE, setShowOOBE] = useState(false);
|
||||||
const { branding } = useBranding();
|
const { branding } = useBranding();
|
||||||
const [searchParams, setSearchParams] = useSearchParams();
|
const [searchParams, setSearchParams] = useSearchParams();
|
||||||
|
|
||||||
@@ -53,6 +71,18 @@ export function CustomerPortal() {
|
|||||||
initDone.current = true;
|
initDone.current = true;
|
||||||
|
|
||||||
const sessionId = searchParams.get("sessionId");
|
const sessionId = searchParams.get("sessionId");
|
||||||
|
// GRO-2359: a deep-link to a portal sub-route with ?noAccess=deleted-portal
|
||||||
|
// is the only path that still shows the no-access card. The post-auth
|
||||||
|
// 404-from-bridge path now navigates to /onboarding (OOBE) so the new
|
||||||
|
// user can create a portal. The deleted-portal case is set explicitly
|
||||||
|
// (e.g. a groomer who disabled a client) and uses the same no-access
|
||||||
|
// UI with the shared signOut() — that was the GRO-2358 invariant.
|
||||||
|
const noAccess = searchParams.get("noAccess");
|
||||||
|
if (noAccess === "deleted-portal") {
|
||||||
|
setAuthError(
|
||||||
|
"Your portal access has been removed. Please contact your groomer if you think this is a mistake.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
if (sessionId) {
|
if (sessionId) {
|
||||||
setIsImpersonating(true);
|
setIsImpersonating(true);
|
||||||
@@ -98,10 +128,66 @@ export function CustomerPortal() {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
.finally(() => setInitComplete(true));
|
.finally(() => setInitComplete(true));
|
||||||
} else {
|
return;
|
||||||
// No valid session: staff dev users and unauthenticated users fall through here
|
}
|
||||||
|
|
||||||
|
if (devUser && devUser.type === "staff") {
|
||||||
|
// Staff dev user — fall through; App.tsx redirects to /admin.
|
||||||
|
setInitComplete(true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Real SSO customer (GRO-1867): bridge a Better Auth session into a portal
|
||||||
|
// session via POST /api/portal/session-from-auth. The returned session ID
|
||||||
|
// is used in the X-Impersonation-Session-Id header for portal API calls.
|
||||||
|
(async () => {
|
||||||
|
try {
|
||||||
|
const sessionResp = await fetch("/api/auth/get-session", { credentials: "include" });
|
||||||
|
if (!sessionResp.ok) {
|
||||||
|
setInitComplete(true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let sessionData: { user?: { email?: string; role?: string | null } } | null = null;
|
||||||
|
try {
|
||||||
|
sessionData = (await sessionResp.json()) as { user?: { email?: string; role?: string | null } } | null;
|
||||||
|
} catch {
|
||||||
|
// Better Auth returns an empty body when there is no session
|
||||||
|
}
|
||||||
|
if (!sessionData || !sessionData.user) {
|
||||||
|
setInitComplete(true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Staff are routed to /admin by App.tsx; don't run the customer bridge.
|
||||||
|
if (sessionData.user.role === "staff") {
|
||||||
|
setInitComplete(true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const bridgeResp = await fetch("/api/portal/session-from-auth", {
|
||||||
|
method: "POST",
|
||||||
|
credentials: "include",
|
||||||
|
});
|
||||||
|
|
||||||
|
if (bridgeResp.ok) {
|
||||||
|
const data = await bridgeResp.json() as { sessionId: string; clientId: string; clientName: string };
|
||||||
|
setPortalSessionId(data.sessionId);
|
||||||
|
setClientName(data.clientName);
|
||||||
|
} else if (bridgeResp.status === 404) {
|
||||||
|
// Authenticated but no matching client row — mount the OOBE
|
||||||
|
// (GRO-2359) so the user can create their portal record instead
|
||||||
|
// of landing on the no-access card. The no-access card itself is
|
||||||
|
// still reachable for the deleted-portal case (see GRO-2358) via
|
||||||
|
// the ?noAccess=deleted-portal deep-link, but is no longer in
|
||||||
|
// the new-user path.
|
||||||
|
setShowOOBE(true);
|
||||||
|
}
|
||||||
|
// 401/other: fall through; App.tsx render guard will redirect to /login.
|
||||||
|
} catch {
|
||||||
|
// Network error — fall through; the render guard will redirect to /login.
|
||||||
|
} finally {
|
||||||
setInitComplete(true);
|
setInitComplete(true);
|
||||||
}
|
}
|
||||||
|
})();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const handleEnd = useCallback(async () => {
|
const handleEnd = useCallback(async () => {
|
||||||
@@ -130,6 +216,19 @@ export function CustomerPortal() {
|
|||||||
}
|
}
|
||||||
}, [session]);
|
}, [session]);
|
||||||
|
|
||||||
|
// Shared sign-out handler — wires the canonical Better Auth `signOut()` so
|
||||||
|
// every authenticated surface (no-access screen, portal chrome, etc.) uses
|
||||||
|
// the same implementation as `AdminLayout`. Failure to reach the server
|
||||||
|
// still leaves the SPA free to navigate to /login.
|
||||||
|
const handleSignOut = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
await signOut();
|
||||||
|
} catch {
|
||||||
|
// Best-effort; navigate to /login regardless so the user is never trapped.
|
||||||
|
}
|
||||||
|
window.location.href = "/login";
|
||||||
|
}, []);
|
||||||
|
|
||||||
const logPageView = useCallback((page: string) => {
|
const logPageView = useCallback((page: string) => {
|
||||||
if (!session) return;
|
if (!session) return;
|
||||||
void fetch(`/api/impersonation/sessions/${session.id}/log`, {
|
void fetch(`/api/impersonation/sessions/${session.id}/log`, {
|
||||||
@@ -157,7 +256,7 @@ export function CustomerPortal() {
|
|||||||
const isReadOnly = session?.status === "active";
|
const isReadOnly = session?.status === "active";
|
||||||
|
|
||||||
const renderSection = () => {
|
const renderSection = () => {
|
||||||
const sessionId = session?.id ?? null;
|
const sessionId = session?.id ?? portalSessionId;
|
||||||
switch (activeSection) {
|
switch (activeSection) {
|
||||||
case "dashboard":
|
case "dashboard":
|
||||||
return <Dashboard onNavigate={handleNavClick} readOnly={!!isReadOnly} sessionId={sessionId} clientName={clientName} onReschedule={handleReschedule} isImpersonating={isImpersonating} />;
|
return <Dashboard onNavigate={handleNavClick} readOnly={!!isReadOnly} sessionId={sessionId} clientName={clientName} onReschedule={handleReschedule} isImpersonating={isImpersonating} />;
|
||||||
@@ -178,12 +277,65 @@ export function CustomerPortal() {
|
|||||||
|
|
||||||
const avatarInitials = (clientName.split(" ")[0] || "G").charAt(0).toUpperCase();
|
const avatarInitials = (clientName.split(" ")[0] || "G").charAt(0).toUpperCase();
|
||||||
|
|
||||||
|
// Show a loading state while the SSO bridge is in progress. The portal chrome
|
||||||
|
// and its sections (e.g. Dashboard) assume a session is established and run
|
||||||
|
// their own auth guards — rendering them before the bridge resolves triggers
|
||||||
|
// a redirect to /login from `Dashboard.tsx`'s `!sessionId` check, breaking the
|
||||||
|
// post-sign-in flow. Once `initComplete` is true we know whether a session was
|
||||||
|
// established and can render the correct branch. See GRO-2099.
|
||||||
|
if (!initComplete) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="min-h-screen flex items-center justify-center bg-[#faf8f5]"
|
||||||
|
role="status"
|
||||||
|
aria-live="polite"
|
||||||
|
>
|
||||||
|
<div className="text-stone-500 text-sm">Loading…</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// After init completes, redirect unauthenticated users to /login and staff to /admin.
|
// After init completes, redirect unauthenticated users to /login and staff to /admin.
|
||||||
// The portal chrome must NEVER be visible to users without a valid client session.
|
// The portal chrome must NEVER be visible to users without a valid client session.
|
||||||
// For client dev users, we stay on the portal even if session is null — the dev-session
|
// 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
|
// 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.
|
// session state. Dev users are verified via localStorage and the dev-session flow.
|
||||||
if (initComplete && !session) {
|
// SSO customers are recognised by portalSessionId (set by the Better Auth bridge).
|
||||||
|
if (!session && !portalSessionId) {
|
||||||
|
// GRO-2359 — new-user path: mount the OOBE inline so the SSO bridge's
|
||||||
|
// 404 hands the user a portal-creation form instead of the no-access
|
||||||
|
// card. onCompleted triggers a full page reload to /, which re-runs
|
||||||
|
// the bridge (now with a matching client row) and lands the user in
|
||||||
|
// the portal. A full reload (not React Router navigate) is the
|
||||||
|
// safest reset of the bridge's cached state.
|
||||||
|
if (showOOBE) {
|
||||||
|
return <OOBE onCompleted={() => { window.location.href = "/"; }} />;
|
||||||
|
}
|
||||||
|
if (authError) {
|
||||||
|
// GRO-1867: graceful 404 fallback — authenticated user has no client row.
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="min-h-screen flex items-center justify-center bg-[#faf8f5] font-sans px-6"
|
||||||
|
role="alert"
|
||||||
|
aria-live="polite"
|
||||||
|
>
|
||||||
|
<div className="max-w-md w-full bg-white rounded-xl shadow-sm border border-stone-200 p-8 text-center">
|
||||||
|
<div className="w-12 h-12 rounded-full bg-amber-100 text-amber-700 flex items-center justify-center mx-auto mb-4">
|
||||||
|
<Shield size={22} />
|
||||||
|
</div>
|
||||||
|
<h1 className="text-lg font-semibold text-stone-800 mb-2">Portal access not configured</h1>
|
||||||
|
<p className="text-sm text-stone-600 mb-6">{authError}</p>
|
||||||
|
<button
|
||||||
|
onClick={() => { void handleSignOut(); }}
|
||||||
|
className="inline-flex items-center justify-center gap-2 px-4 py-2 rounded-lg text-sm font-medium text-stone-700 bg-stone-100 hover:bg-stone-200 transition-colors"
|
||||||
|
>
|
||||||
|
<LogOut size={14} />
|
||||||
|
Sign out
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
const devUser = getDevUser();
|
const devUser = getDevUser();
|
||||||
if (devUser && devUser.type === "staff") {
|
if (devUser && devUser.type === "staff") {
|
||||||
return <Navigate to="/admin" replace />;
|
return <Navigate to="/admin" replace />;
|
||||||
@@ -230,7 +382,7 @@ export function CustomerPortal() {
|
|||||||
<RescheduleFlow
|
<RescheduleFlow
|
||||||
appointment={rescheduleAppointment}
|
appointment={rescheduleAppointment}
|
||||||
onClose={() => { setShowReschedule(false); setRescheduleAppointment(null); }}
|
onClose={() => { setShowReschedule(false); setRescheduleAppointment(null); }}
|
||||||
sessionId={session?.id ?? null}
|
sessionId={session?.id ?? portalSessionId}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -299,7 +451,14 @@ export function CustomerPortal() {
|
|||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Session controls (only shown during active impersonation) */}
|
{/* Session controls — Sign out is always reachable from the portal
|
||||||
|
chrome (GRO-2373). End Impersonation is staff-only and only
|
||||||
|
appears during an active impersonation session. Both share the
|
||||||
|
same LogOut icon for visual consistency, but route to distinct
|
||||||
|
handlers: handleSignOut calls the canonical Better Auth
|
||||||
|
`signOut()` (mirroring OOBE and the no-access card); handleEnd
|
||||||
|
tears down the staff impersonation session and returns to the
|
||||||
|
admin clients list. */}
|
||||||
<div className="border-t border-stone-100 p-4 space-y-2">
|
<div className="border-t border-stone-100 p-4 space-y-2">
|
||||||
{session?.status === "active" && (
|
{session?.status === "active" && (
|
||||||
<button
|
<button
|
||||||
@@ -310,6 +469,15 @@ export function CustomerPortal() {
|
|||||||
End Impersonation
|
End Impersonation
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => { void handleSignOut(); }}
|
||||||
|
className="w-full flex items-center gap-2 px-3 py-2 rounded-lg text-xs font-medium text-stone-700 bg-stone-50 hover:bg-stone-100 transition-colors"
|
||||||
|
data-testid="portal-chrome-signout"
|
||||||
|
>
|
||||||
|
<LogOut size={14} />
|
||||||
|
Sign out
|
||||||
|
</button>
|
||||||
<div className="flex items-center gap-2 px-3 py-2 text-xs text-stone-400">
|
<div className="flex items-center gap-2 px-3 py-2 text-xs text-stone-400">
|
||||||
<Shield size={12} />
|
<Shield size={12} />
|
||||||
Customer Portal v1.0
|
Customer Portal v1.0
|
||||||
|
|||||||
@@ -0,0 +1,312 @@
|
|||||||
|
import { useCallback, useEffect, useState } from "react";
|
||||||
|
import { LogOut, Shield, Sparkles } from "lucide-react";
|
||||||
|
import { signOut } from "../lib/auth-client.js";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* OOBE (Out-of-Box Experience) for a first-time Authentik SSO user whose
|
||||||
|
* email does not match any existing `clients` row.
|
||||||
|
*
|
||||||
|
* The post-auth handler in `CustomerPortal.tsx` redirects to this component
|
||||||
|
* when `POST /api/portal/session-from-auth` returns 404. From here the new
|
||||||
|
* user can either:
|
||||||
|
* (a) Create a fresh customer record bound to their SSO email, or
|
||||||
|
* (b) Sign out (the no-access screen is no longer in the new-user path).
|
||||||
|
*
|
||||||
|
* After successful creation, the OOBE navigates to `/` so the portal's
|
||||||
|
* existing SSO bridge re-runs and lands the user in their portal with a
|
||||||
|
* real `X-Impersonation-Session-Id` header. No new client state is
|
||||||
|
* required — the bridge re-resolves the session and the rest of the
|
||||||
|
* portal is unchanged.
|
||||||
|
*
|
||||||
|
* GRO-2359 — root-cause fix for "new SSO user lands on Portal access not
|
||||||
|
* configured" (companion to GRO-2358, which restored logout on that screen).
|
||||||
|
*/
|
||||||
|
|
||||||
|
type OOBEFormState = {
|
||||||
|
name: string;
|
||||||
|
phone: string;
|
||||||
|
address: string;
|
||||||
|
notes: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type OOBEStatus = "loading" | "ready" | "submitting" | "error";
|
||||||
|
|
||||||
|
const EMPTY_FORM: OOBEFormState = {
|
||||||
|
name: "",
|
||||||
|
phone: "",
|
||||||
|
address: "",
|
||||||
|
notes: "",
|
||||||
|
};
|
||||||
|
|
||||||
|
type OOBEProps = {
|
||||||
|
/**
|
||||||
|
* Override the post-success destination. Defaults to `/` so the SSO bridge
|
||||||
|
* re-runs. Test suites pass a custom destination to keep assertions
|
||||||
|
* deterministic without a real portal session.
|
||||||
|
*/
|
||||||
|
onCompleted?: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function OOBE({ onCompleted }: OOBEProps = {}) {
|
||||||
|
const [status, setStatus] = useState<OOBEStatus>("loading");
|
||||||
|
const [form, setForm] = useState<OOBEFormState>(EMPTY_FORM);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [sessionEmail, setSessionEmail] = useState<string | null>(null);
|
||||||
|
|
||||||
|
// Resolve the Better Auth session on mount. The OOBE is gated to
|
||||||
|
// authenticated users — if no session exists the API will reject the
|
||||||
|
// creation request, so we redirect to /login early. We prefill `name`
|
||||||
|
// from the Better Auth `user.name` if the SSO provider returned one.
|
||||||
|
//
|
||||||
|
// We use a full `window.location.href` redirect (not `navigate`) so the
|
||||||
|
// OOBE works the same way whether it's mounted from the post-auth
|
||||||
|
// callback (inside CustomerPortal's render tree) or from a direct
|
||||||
|
// deep-link (mounted by App.tsx). A full reload also resets any
|
||||||
|
// cached state in the parent component.
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false;
|
||||||
|
(async () => {
|
||||||
|
try {
|
||||||
|
const r = await fetch("/api/auth/get-session", { credentials: "include" });
|
||||||
|
if (!r.ok) {
|
||||||
|
if (!cancelled) window.location.href = "/login";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const data = (await r.json().catch(() => null)) as
|
||||||
|
| { user?: { email?: string; name?: string; role?: string | null } }
|
||||||
|
| null;
|
||||||
|
if (cancelled) return;
|
||||||
|
if (!data?.user) {
|
||||||
|
window.location.href = "/login";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (data.user.role === "staff") {
|
||||||
|
window.location.href = "/admin";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setSessionEmail(data.user.email ?? null);
|
||||||
|
setForm((prev) => ({ ...prev, name: data.user?.name ?? prev.name }));
|
||||||
|
setStatus("ready");
|
||||||
|
} catch {
|
||||||
|
if (!cancelled) window.location.href = "/login";
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleChange = useCallback(
|
||||||
|
(field: keyof OOBEFormState) => (e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) => {
|
||||||
|
const value = e.target.value;
|
||||||
|
setForm((prev) => ({ ...prev, [field]: value }));
|
||||||
|
},
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleSubmit = useCallback(
|
||||||
|
async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
if (status === "submitting") return;
|
||||||
|
if (!form.name.trim()) {
|
||||||
|
setError("Please tell us your name so we can set up your portal.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setStatus("submitting");
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
const r = await fetch("/api/portal/clients-from-auth", {
|
||||||
|
method: "POST",
|
||||||
|
credentials: "include",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({
|
||||||
|
name: form.name.trim(),
|
||||||
|
phone: form.phone.trim() || null,
|
||||||
|
address: form.address.trim() || null,
|
||||||
|
notes: form.notes.trim() || null,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
if (r.ok) {
|
||||||
|
// Let the parent (or default) decide where to land. The default
|
||||||
|
// is the portal root, which re-runs the SSO bridge. A full
|
||||||
|
// `window.location.href` reload resets any cached state in the
|
||||||
|
// parent (the bridge reads from Better Auth cookies, so a fresh
|
||||||
|
// request picks up the new client row).
|
||||||
|
if (onCompleted) {
|
||||||
|
onCompleted();
|
||||||
|
} else {
|
||||||
|
window.location.href = "/";
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (r.status === 409) {
|
||||||
|
setStatus("ready");
|
||||||
|
setError(
|
||||||
|
"A customer record with this email already exists. Please contact your groomer to link your account.",
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const body = (await r.json().catch(() => null)) as { error?: string } | null;
|
||||||
|
setStatus("ready");
|
||||||
|
setError(body?.error ?? "We couldn't set up your portal. Please try again.");
|
||||||
|
} catch {
|
||||||
|
setStatus("ready");
|
||||||
|
setError("Network error. Please check your connection and try again.");
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[form, onCompleted, status],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleSignOut = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
await signOut();
|
||||||
|
} catch {
|
||||||
|
// Best-effort; navigate to /login regardless so the user is never trapped.
|
||||||
|
}
|
||||||
|
window.location.href = "/login";
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
if (status === "loading") {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="min-h-screen flex items-center justify-center bg-[#faf8f5]"
|
||||||
|
role="status"
|
||||||
|
aria-live="polite"
|
||||||
|
>
|
||||||
|
<div className="text-stone-500 text-sm">Loading…</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="min-h-screen flex items-center justify-center bg-[#faf8f5] font-sans px-6 py-10"
|
||||||
|
role="main"
|
||||||
|
>
|
||||||
|
<div className="max-w-md w-full bg-white rounded-xl shadow-sm border border-stone-200 p-8">
|
||||||
|
<div className="w-12 h-12 rounded-full bg-emerald-100 text-emerald-700 flex items-center justify-center mx-auto mb-4">
|
||||||
|
<Sparkles size={22} />
|
||||||
|
</div>
|
||||||
|
<h1 className="text-lg font-semibold text-stone-800 text-center mb-1">
|
||||||
|
Welcome — let's set up your portal
|
||||||
|
</h1>
|
||||||
|
<p className="text-sm text-stone-600 text-center mb-6">
|
||||||
|
You're signed in{sessionEmail ? ` as ${sessionEmail}` : ""}. We just need a few
|
||||||
|
details to create your customer record.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<form onSubmit={handleSubmit} className="space-y-4" noValidate>
|
||||||
|
<div>
|
||||||
|
<label
|
||||||
|
htmlFor="oobe-name"
|
||||||
|
className="block text-xs font-medium text-stone-700 mb-1"
|
||||||
|
>
|
||||||
|
Your name <span className="text-red-600">*</span>
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="oobe-name"
|
||||||
|
name="name"
|
||||||
|
type="text"
|
||||||
|
required
|
||||||
|
autoComplete="name"
|
||||||
|
value={form.name}
|
||||||
|
onChange={handleChange("name")}
|
||||||
|
className="w-full px-3 py-2 rounded-lg border border-stone-300 text-sm text-stone-800 focus:outline-none focus:ring-2 focus:ring-emerald-500 focus:border-emerald-500"
|
||||||
|
disabled={status === "submitting"}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label
|
||||||
|
htmlFor="oobe-phone"
|
||||||
|
className="block text-xs font-medium text-stone-700 mb-1"
|
||||||
|
>
|
||||||
|
Phone <span className="text-stone-400">(optional)</span>
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="oobe-phone"
|
||||||
|
name="phone"
|
||||||
|
type="tel"
|
||||||
|
autoComplete="tel"
|
||||||
|
value={form.phone}
|
||||||
|
onChange={handleChange("phone")}
|
||||||
|
className="w-full px-3 py-2 rounded-lg border border-stone-300 text-sm text-stone-800 focus:outline-none focus:ring-2 focus:ring-emerald-500 focus:border-emerald-500"
|
||||||
|
disabled={status === "submitting"}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label
|
||||||
|
htmlFor="oobe-address"
|
||||||
|
className="block text-xs font-medium text-stone-700 mb-1"
|
||||||
|
>
|
||||||
|
Address <span className="text-stone-400">(optional)</span>
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="oobe-address"
|
||||||
|
name="address"
|
||||||
|
type="text"
|
||||||
|
autoComplete="street-address"
|
||||||
|
value={form.address}
|
||||||
|
onChange={handleChange("address")}
|
||||||
|
className="w-full px-3 py-2 rounded-lg border border-stone-300 text-sm text-stone-800 focus:outline-none focus:ring-2 focus:ring-emerald-500 focus:border-emerald-500"
|
||||||
|
disabled={status === "submitting"}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label
|
||||||
|
htmlFor="oobe-notes"
|
||||||
|
className="block text-xs font-medium text-stone-700 mb-1"
|
||||||
|
>
|
||||||
|
Notes <span className="text-stone-400">(optional)</span>
|
||||||
|
</label>
|
||||||
|
<textarea
|
||||||
|
id="oobe-notes"
|
||||||
|
name="notes"
|
||||||
|
rows={2}
|
||||||
|
value={form.notes}
|
||||||
|
onChange={handleChange("notes")}
|
||||||
|
className="w-full px-3 py-2 rounded-lg border border-stone-300 text-sm text-stone-800 focus:outline-none focus:ring-2 focus:ring-emerald-500 focus:border-emerald-500"
|
||||||
|
disabled={status === "submitting"}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<div
|
||||||
|
role="alert"
|
||||||
|
aria-live="polite"
|
||||||
|
className="flex items-start gap-2 text-sm text-red-700 bg-red-50 border border-red-200 rounded-lg px-3 py-2"
|
||||||
|
>
|
||||||
|
<Shield size={14} className="mt-0.5 shrink-0" />
|
||||||
|
<span>{error}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={status === "submitting"}
|
||||||
|
className="w-full inline-flex items-center justify-center gap-2 px-4 py-2.5 rounded-lg text-sm font-medium text-white bg-emerald-600 hover:bg-emerald-700 disabled:opacity-60 disabled:cursor-not-allowed transition-colors"
|
||||||
|
>
|
||||||
|
{status === "submitting" ? "Setting up…" : "Create my portal"}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<div className="mt-6 pt-4 border-t border-stone-100 flex items-center justify-between">
|
||||||
|
<p className="text-xs text-stone-500">
|
||||||
|
Wrong account? Sign out and try a different one.
|
||||||
|
</p>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
void handleSignOut();
|
||||||
|
}}
|
||||||
|
className="inline-flex items-center gap-1.5 text-xs font-medium text-stone-600 hover:text-stone-900"
|
||||||
|
>
|
||||||
|
<LogOut size={12} />
|
||||||
|
Sign out
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default OOBE;
|
||||||
@@ -1,11 +1,67 @@
|
|||||||
import React, { useState, useEffect } from 'react';
|
import React, { useState, useEffect } from 'react';
|
||||||
import { Calendar, Clock, Plus, ChevronRight, ChevronDown, Loader2 } from 'lucide-react';
|
import { Calendar, Clock, Plus, ChevronRight, ChevronDown, Loader2 } from 'lucide-react';
|
||||||
|
import { ANALYTICS_EVENTS, fireAnalyticsEvent } from '../../lib/analytics';
|
||||||
|
|
||||||
|
// ─── Availability fetch helper ───────────────────────────────────────────────
|
||||||
|
// Returns ISO startTime strings for the given service/date, or an error message.
|
||||||
|
// Validates HTTP status and that the body is actually an array — the API
|
||||||
|
// responds with `{error: "..."}` on 4xx, and we must not treat that as slots.
|
||||||
|
const AVAILABILITY_ERROR_MESSAGE = 'Failed to load time slots';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Re-mint an SSO-bridge portal session from the active Better Auth session.
|
||||||
|
* Defense-in-depth for GRO-2234: if a portal call returns 401 mid-flow (the
|
||||||
|
* impersonation session lapsed during a slow wizard), the customer's Better
|
||||||
|
* Auth cookie is still valid, so we can transparently obtain a fresh portal
|
||||||
|
* session id and retry once. Returns the new session id, or null if no Better
|
||||||
|
* Auth session is available (e.g. staff/dev impersonation paths).
|
||||||
|
*/
|
||||||
|
async function remintPortalSession(): Promise<string | null> {
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/portal/session-from-auth', {
|
||||||
|
method: 'POST',
|
||||||
|
credentials: 'include',
|
||||||
|
});
|
||||||
|
if (!res.ok) return null;
|
||||||
|
const data = (await res.json().catch(() => ({}))) as { sessionId?: string };
|
||||||
|
return data.sessionId ?? null;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchAvailability(
|
||||||
|
params: { serviceId: string; date: string },
|
||||||
|
sessionId: string | null,
|
||||||
|
): Promise<{ times: string[]; error: string | null }> {
|
||||||
|
const url = `/api/book/availability?${new URLSearchParams(params).toString()}`;
|
||||||
|
const headers: Record<string, string> = {};
|
||||||
|
if (sessionId) headers['X-Impersonation-Session-Id'] = sessionId;
|
||||||
|
try {
|
||||||
|
const res = await fetch(url, { headers });
|
||||||
|
if (!res.ok) {
|
||||||
|
const body = (await res.json().catch(() => ({}))) as { error?: string };
|
||||||
|
return { times: [], error: body.error ?? `${AVAILABILITY_ERROR_MESSAGE} (HTTP ${res.status})` };
|
||||||
|
}
|
||||||
|
const data: unknown = await res.json();
|
||||||
|
if (!Array.isArray(data)) {
|
||||||
|
return { times: [], error: AVAILABILITY_ERROR_MESSAGE };
|
||||||
|
}
|
||||||
|
return { times: data as string[], error: null };
|
||||||
|
} catch {
|
||||||
|
return { times: [], error: AVAILABILITY_ERROR_MESSAGE };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export interface Appointment {
|
export interface Appointment {
|
||||||
id: string;
|
id: string;
|
||||||
petId: string;
|
petId: string;
|
||||||
serviceId: string;
|
serviceId: string;
|
||||||
groomerId: string | null;
|
groomerId: string | null;
|
||||||
|
// Absolute ISO instants as returned by `/api/portal/appointments`. `date`/`time`
|
||||||
|
// below are the locally-formatted display strings derived from `startTime`.
|
||||||
|
startTime?: string;
|
||||||
|
endTime?: string;
|
||||||
date: string;
|
date: string;
|
||||||
time: string;
|
time: string;
|
||||||
status: 'scheduled' | 'confirmed' | 'pending' | 'waitlisted' | 'completed' | 'cancelled' | 'no-show';
|
status: 'scheduled' | 'confirmed' | 'pending' | 'waitlisted' | 'completed' | 'cancelled' | 'no-show';
|
||||||
@@ -33,8 +89,8 @@ interface Service {
|
|||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
description?: string;
|
description?: string;
|
||||||
duration: number;
|
duration?: number;
|
||||||
price: number;
|
price?: number;
|
||||||
priceRange?: string;
|
priceRange?: string;
|
||||||
isAddOn?: boolean;
|
isAddOn?: boolean;
|
||||||
}
|
}
|
||||||
@@ -61,35 +117,237 @@ export function formatDate(dateStr: string): string {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export function parseTimeTo24Hour(time: string): string {
|
export function parseTimeTo24Hour(time: string | null | undefined): string {
|
||||||
const parts = time.split(' ');
|
const parts = (time ?? '').split(' ');
|
||||||
const hoursMinutes = parts[0] ?? '';
|
const hoursMinutes = parts[0] ?? '';
|
||||||
const period = parts[1] ?? '';
|
const period = parts[1] ?? '';
|
||||||
const [hoursStr, minutesStr] = hoursMinutes.split(':');
|
const [hoursStr, minutesStr] = hoursMinutes.split(':');
|
||||||
const hours = parseInt(hoursStr ?? '0', 10);
|
// `|| '0'` (not `?? '0'`) so empty strings from blank/undefined input
|
||||||
const minutes = parseInt(minutesStr ?? '0', 10);
|
// fall back to 0 rather than parsing to NaN.
|
||||||
|
const hours = parseInt(hoursStr || '0', 10);
|
||||||
|
const minutes = parseInt(minutesStr || '0', 10);
|
||||||
let hours24 = hours;
|
let hours24 = hours;
|
||||||
if (period === 'PM' && hours !== 12) hours24 += 12;
|
if (period === 'PM' && hours !== 12) hours24 += 12;
|
||||||
if (period === 'AM' && hours === 12) hours24 = 0;
|
if (period === 'AM' && hours === 12) hours24 = 0;
|
||||||
return `${hours24.toString().padStart(2, '0')}:${minutes.toString().padStart(2, '0')}:00`;
|
return `${hours24.toString().padStart(2, '0')}:${minutes.toString().padStart(2, '0')}:00`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// A booking slot is the canonical UTC ISO instant returned by
|
||||||
|
// `/api/book/availability` (e.g. "2026-06-09T10:00:00.000Z" is the 10:00 UTC
|
||||||
|
// business slot — see api `src/lib/slots.ts`, which builds them with
|
||||||
|
// `setUTCHours`). Display label and submit payload both derive from the slot via
|
||||||
|
// these helpers so they never desync. Always format/extract in UTC: slots are
|
||||||
|
// generated as UTC business hours, so a browser-local conversion would mislabel
|
||||||
|
// the slot and diverge from the stored Postgres `time` column.
|
||||||
|
export function formatSlotLabel(slot: string): string {
|
||||||
|
const d = new Date(slot);
|
||||||
|
// Non-ISO input (e.g. an already-formatted "10:00 AM" label) — show as-is.
|
||||||
|
if (Number.isNaN(d.getTime())) return slot;
|
||||||
|
return new Intl.DateTimeFormat('en-US', {
|
||||||
|
hour: 'numeric',
|
||||||
|
minute: '2-digit',
|
||||||
|
hour12: true,
|
||||||
|
timeZone: 'UTC',
|
||||||
|
}).format(d);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extracts the UTC `HH:MM:SS` time component the api stores in the Postgres
|
||||||
|
// `time` column. The api inserts this verbatim, so a full ISO datetime here is
|
||||||
|
// an `invalid input syntax for type time` 500 (GRO-2211).
|
||||||
|
export function slotToTime(slot: string): string {
|
||||||
|
if (/^\d{2}:\d{2}:\d{2}$/.test(slot)) return slot; // already HH:MM:SS
|
||||||
|
const d = new Date(slot);
|
||||||
|
if (!Number.isNaN(d.getTime())) {
|
||||||
|
const hh = String(d.getUTCHours()).padStart(2, '0');
|
||||||
|
const mm = String(d.getUTCMinutes()).padStart(2, '0');
|
||||||
|
const ss = String(d.getUTCSeconds()).padStart(2, '0');
|
||||||
|
return `${hh}:${mm}:${ss}`;
|
||||||
|
}
|
||||||
|
// "10:00 AM"-style label fallback.
|
||||||
|
return parseTimeTo24Hour(slot);
|
||||||
|
}
|
||||||
|
|
||||||
export function isUpcoming(appt: Appointment): boolean {
|
export function isUpcoming(appt: Appointment): boolean {
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
const apptDate = new Date(`${appt.date}T${parseTimeTo24Hour(appt.time)}`);
|
// Prefer the absolute ISO `startTime` from the API; fall back to the
|
||||||
|
// locally-formatted date/time pair for already-normalized/legacy shapes.
|
||||||
|
const apptDate = appt.startTime
|
||||||
|
? new Date(appt.startTime)
|
||||||
|
: new Date(`${appt.date}T${parseTimeTo24Hour(appt.time)}`);
|
||||||
return apptDate > now && appt.status !== 'cancelled' && appt.status !== 'completed';
|
return apptDate > now && appt.status !== 'cancelled' && appt.status !== 'completed';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ─── API → UI shape normalization ────────────────────────────────────────────
|
||||||
|
// `/api/portal/appointments` returns ISO `startTime`/`endTime` plus nested
|
||||||
|
// pet/service/staff objects, NOT the flat `date`/`time`/`petName` shape the
|
||||||
|
// portal UI renders. Every field below is optional so the legacy flat shape
|
||||||
|
// (used by tests/fixtures) is tolerated unchanged.
|
||||||
|
export interface RawApiAppointment {
|
||||||
|
id: string;
|
||||||
|
startTime?: string | null;
|
||||||
|
endTime?: string | null;
|
||||||
|
status: Appointment['status'];
|
||||||
|
confirmationStatus?: Appointment['confirmationStatus'];
|
||||||
|
customerNotes?: string | null;
|
||||||
|
notes?: string | null;
|
||||||
|
pet?: { id?: string | null; name?: string | null; photo?: string | null } | null;
|
||||||
|
service?: { id?: string | null; name?: string | null } | null;
|
||||||
|
staff?: { id?: string | null; name?: string | null } | null;
|
||||||
|
// Legacy / already-flat fields
|
||||||
|
petId?: string;
|
||||||
|
serviceId?: string;
|
||||||
|
groomerId?: string | null;
|
||||||
|
date?: string;
|
||||||
|
time?: string;
|
||||||
|
petName?: string;
|
||||||
|
serviceName?: string;
|
||||||
|
groomerName?: string;
|
||||||
|
duration?: number;
|
||||||
|
price?: number;
|
||||||
|
addOns?: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
function toLocalDateString(d: Date): string {
|
||||||
|
const year = d.getFullYear();
|
||||||
|
const month = String(d.getMonth() + 1).padStart(2, '0');
|
||||||
|
const day = String(d.getDate()).padStart(2, '0');
|
||||||
|
return `${year}-${month}-${day}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function toLocalTimeString(d: Date): string {
|
||||||
|
return d.toLocaleTimeString('en-US', { hour: 'numeric', minute: '2-digit', hour12: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Maps a raw API appointment into the flat `Appointment` shape the portal
|
||||||
|
// renders. Derives display `date`/`time` from the absolute `startTime` and
|
||||||
|
// `duration` from the start/end delta. Tolerates the legacy flat shape.
|
||||||
|
export function normalizeAppointment(raw: RawApiAppointment): Appointment {
|
||||||
|
const start = raw.startTime ? new Date(raw.startTime) : null;
|
||||||
|
const end = raw.endTime ? new Date(raw.endTime) : null;
|
||||||
|
const derivedDuration =
|
||||||
|
start && end ? Math.round((end.getTime() - start.getTime()) / 60000) : undefined;
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: raw.id,
|
||||||
|
petId: raw.pet?.id ?? raw.petId ?? '',
|
||||||
|
serviceId: raw.service?.id ?? raw.serviceId ?? '',
|
||||||
|
groomerId: raw.staff?.id ?? raw.groomerId ?? null,
|
||||||
|
startTime: raw.startTime ?? undefined,
|
||||||
|
endTime: raw.endTime ?? undefined,
|
||||||
|
date: start ? toLocalDateString(start) : raw.date ?? '',
|
||||||
|
time: start ? toLocalTimeString(start) : raw.time ?? '',
|
||||||
|
status: raw.status,
|
||||||
|
petName: raw.pet?.name ?? raw.petName,
|
||||||
|
serviceName: raw.service?.name ?? raw.serviceName,
|
||||||
|
groomerName: raw.staff?.name ?? raw.groomerName,
|
||||||
|
duration: raw.duration ?? derivedDuration,
|
||||||
|
price: raw.price,
|
||||||
|
notes: raw.notes ?? undefined,
|
||||||
|
customerNotes: raw.customerNotes ?? undefined,
|
||||||
|
addOns: raw.addOns,
|
||||||
|
confirmationStatus: raw.confirmationStatus,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Raw service shape from `GET /api/portal/services`, which projects the
|
||||||
|
// canonical DB columns (`basePriceCents`, `durationMinutes`). Also tolerates an
|
||||||
|
// already-normalized payload so either shape renders correctly.
|
||||||
|
interface RawApiService {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
description?: string | null;
|
||||||
|
basePriceCents?: number | null;
|
||||||
|
durationMinutes?: number | null;
|
||||||
|
price?: number | null;
|
||||||
|
duration?: number | null;
|
||||||
|
priceRange?: string | null;
|
||||||
|
isAddOn?: boolean | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Normalizes a raw API service into the flat `Service` shape the cards render:
|
||||||
|
// price as dollars (from `basePriceCents`) and duration in minutes (from
|
||||||
|
// `durationMinutes`). Leaves fields undefined when genuinely absent so the card
|
||||||
|
// can hide them rather than print `$undefined` / empty `min`.
|
||||||
|
export function normalizeService(raw: RawApiService): Service {
|
||||||
|
const price =
|
||||||
|
raw.price ?? (typeof raw.basePriceCents === 'number' ? raw.basePriceCents / 100 : undefined);
|
||||||
|
const duration = raw.duration ?? raw.durationMinutes ?? undefined;
|
||||||
|
return {
|
||||||
|
id: raw.id,
|
||||||
|
name: raw.name,
|
||||||
|
description: raw.description ?? undefined,
|
||||||
|
duration: duration ?? undefined,
|
||||||
|
price: price ?? undefined,
|
||||||
|
priceRange: raw.priceRange ?? undefined,
|
||||||
|
isAddOn: raw.isAddOn ?? undefined,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Renders a service price for display, preferring an explicit `priceRange`
|
||||||
|
// string, then a numeric dollar `price` (integers without trailing zeros, e.g.
|
||||||
|
// `$45`; fractional values to cents, e.g. `$45.50`). Returns null when neither
|
||||||
|
// is available so the caller can omit the price line entirely.
|
||||||
|
export function formatServicePrice(svc: Pick<Service, 'price' | 'priceRange'>): string | null {
|
||||||
|
if (svc.priceRange) return svc.priceRange;
|
||||||
|
if (typeof svc.price === 'number' && Number.isFinite(svc.price)) {
|
||||||
|
return `$${Number.isInteger(svc.price) ? svc.price : svc.price.toFixed(2)}`;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
const STATUS_COLORS: Record<string, string> = {
|
const STATUS_COLORS: Record<string, string> = {
|
||||||
confirmed: 'bg-green-100 text-green-700',
|
confirmed: 'bg-green-100 text-green-700',
|
||||||
pending: 'bg-amber-100 text-amber-700',
|
pending: 'bg-amber-100 text-amber-600',
|
||||||
waitlisted: 'bg-blue-100 text-blue-700',
|
waitlisted: 'bg-blue-100 text-blue-600',
|
||||||
completed: 'bg-stone-100 text-stone-600',
|
completed: 'bg-stone-100 text-stone-600',
|
||||||
cancelled: 'bg-red-100 text-red-600',
|
cancelled: 'bg-red-100 text-red-600',
|
||||||
'no-show': 'bg-yellow-100 text-yellow-700',
|
'no-show': 'bg-yellow-100 text-yellow-700',
|
||||||
scheduled: 'bg-blue-100 text-blue-700',
|
scheduled: 'bg-blue-100 text-blue-600',
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const STATUS_LABELS: Record<string, string> = {
|
||||||
|
confirmed: 'Confirmed',
|
||||||
|
pending: 'Pending',
|
||||||
|
waitlisted: 'Waitlisted',
|
||||||
|
completed: 'Completed',
|
||||||
|
cancelled: 'Cancelled',
|
||||||
|
'no-show': 'No-show',
|
||||||
|
scheduled: 'Scheduled',
|
||||||
|
};
|
||||||
|
|
||||||
|
// The DB `appointment_status` enum stores `no_show` (underscore), but the badge
|
||||||
|
// palette is keyed on `no-show` (hyphen). Without normalization a no-show
|
||||||
|
// appointment renders as a raw gray `no_show` label instead of the styled
|
||||||
|
// "No-show" badge (GRO-2319 item 1). Map underscore status keys to the hyphen
|
||||||
|
// palette key so DB-sourced statuses resolve to their intended badge style.
|
||||||
|
export function normalizeStatusKey(status: string): string {
|
||||||
|
return status.replace(/_/g, '-');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function StatusBadge({ status }: { status: string }) {
|
||||||
|
const key = normalizeStatusKey(status);
|
||||||
|
const label = STATUS_LABELS[key] ?? status;
|
||||||
|
const colorClass = STATUS_COLORS[key] ?? 'bg-stone-100 text-stone-600';
|
||||||
|
return (
|
||||||
|
<span className={`px-2 py-0.5 rounded-full text-xs font-medium ${colorClass}`}>
|
||||||
|
{label}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Derives the badge state shown on an Upcoming/Past card from the appointment's
|
||||||
|
// raw status plus its confirmationStatus (GRO-2319 item 2, CMPO-approved):
|
||||||
|
// - a synthetic waitlist entry (status `waitlisted`) always shows Waitlisted
|
||||||
|
// - an upcoming appointment the groomer has not yet confirmed
|
||||||
|
// (`confirmationStatus === 'pending'`) shows Pending — semantically honest
|
||||||
|
// and reduces anxiety-driven follow-up messages
|
||||||
|
// - otherwise the raw status drives the badge
|
||||||
|
export function deriveDisplayStatus(appt: Appointment): string {
|
||||||
|
if (appt.status === 'waitlisted') return 'waitlisted';
|
||||||
|
if (isUpcoming(appt) && appt.confirmationStatus === 'pending') return 'pending';
|
||||||
|
return appt.status;
|
||||||
|
}
|
||||||
|
|
||||||
const CONFIRMATION_STATUS_COLORS: Record<string, string> = {
|
const CONFIRMATION_STATUS_COLORS: Record<string, string> = {
|
||||||
confirmed: 'bg-green-100 text-green-700',
|
confirmed: 'bg-green-100 text-green-700',
|
||||||
pending: 'bg-amber-100 text-amber-700',
|
pending: 'bg-amber-100 text-amber-700',
|
||||||
@@ -123,7 +381,8 @@ export const AppointmentsSection: React.FC<AppointmentsSectionProps> = ({ sessio
|
|||||||
|
|
||||||
if (response.ok) {
|
if (response.ok) {
|
||||||
const data = await response.json();
|
const data = await response.json();
|
||||||
const fetchedAppointments: Appointment[] = data.appointments || data || [];
|
const rawAppointments: RawApiAppointment[] = data.appointments || data || [];
|
||||||
|
const fetchedAppointments: Appointment[] = rawAppointments.map(normalizeAppointment);
|
||||||
|
|
||||||
const upcoming = fetchedAppointments.filter((appt) => isUpcoming(appt));
|
const upcoming = fetchedAppointments.filter((appt) => isUpcoming(appt));
|
||||||
const past = fetchedAppointments.filter((appt) => !isUpcoming(appt));
|
const past = fetchedAppointments.filter((appt) => !isUpcoming(appt));
|
||||||
@@ -272,11 +531,24 @@ function AppointmentCard({
|
|||||||
sessionId: string | null;
|
sessionId: string | null;
|
||||||
onReschedule: (appt: Appointment) => void;
|
onReschedule: (appt: Appointment) => void;
|
||||||
}) {
|
}) {
|
||||||
|
// A waitlist-backed entry (GRO-2319 item 2, CMPO UX spec GRO-2328) is not a
|
||||||
|
// confirmed appointment: it gets a muted, dashed-border card and a subtext
|
||||||
|
// line so the customer can tell it apart from booked appointments, and the
|
||||||
|
// appointment-only actions (confirm / notes / reschedule / cancel) are hidden.
|
||||||
|
const isWaitlist = appt.status === 'waitlisted';
|
||||||
return (
|
return (
|
||||||
<div className="bg-white rounded-xl border border-stone-200 shadow-sm overflow-hidden">
|
<div
|
||||||
|
className={
|
||||||
|
isWaitlist
|
||||||
|
? 'bg-stone-50/60 rounded-xl border border-dashed border-stone-300 shadow-sm overflow-hidden'
|
||||||
|
: 'bg-white rounded-xl border border-stone-200 shadow-sm overflow-hidden'
|
||||||
|
}
|
||||||
|
>
|
||||||
<button
|
<button
|
||||||
onClick={onToggle}
|
onClick={onToggle}
|
||||||
className="w-full flex items-center gap-4 p-4 text-left hover:bg-stone-50"
|
className={`w-full flex items-center gap-4 p-4 text-left ${
|
||||||
|
isWaitlist ? 'hover:bg-stone-100/60' : 'hover:bg-stone-50'
|
||||||
|
}`}
|
||||||
>
|
>
|
||||||
<div className="w-10 h-10 rounded-lg bg-blue-100 flex items-center justify-center text-lg shrink-0">
|
<div className="w-10 h-10 rounded-lg bg-blue-100 flex items-center justify-center text-lg shrink-0">
|
||||||
{appt.petName?.charAt(0) || 'P'}
|
{appt.petName?.charAt(0) || 'P'}
|
||||||
@@ -296,14 +568,13 @@ function AppointmentCard({
|
|||||||
</span>
|
</span>
|
||||||
<span>with {appt.groomerName || 'First Available'}</span>
|
<span>with {appt.groomerName || 'First Available'}</span>
|
||||||
</div>
|
</div>
|
||||||
|
{isWaitlist && (
|
||||||
|
<p className="text-xs text-stone-400 mt-1">
|
||||||
|
You're on the waitlist — we'll let you know if a spot opens.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<span
|
<StatusBadge status={deriveDisplayStatus(appt)} />
|
||||||
className={`px-2 py-0.5 rounded-full text-xs font-medium ${
|
|
||||||
STATUS_COLORS[appt.status] || ''
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{appt.status}
|
|
||||||
</span>
|
|
||||||
{expanded ? (
|
{expanded ? (
|
||||||
<ChevronDown size={16} className="text-stone-400" />
|
<ChevronDown size={16} className="text-stone-400" />
|
||||||
) : (
|
) : (
|
||||||
@@ -337,11 +608,14 @@ function AppointmentCard({
|
|||||||
{appt.notes}
|
{appt.notes}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
{isUpcoming(appt) && !readOnly && (
|
{!isWaitlist && isUpcoming(appt) && !readOnly && (
|
||||||
<CustomerNotesSection appointment={appt} sessionId={sessionId} />
|
<CustomerNotesSection appointment={appt} sessionId={sessionId} />
|
||||||
)}
|
)}
|
||||||
{isUpcoming(appt) && <ConfirmationSection appointment={appt} sessionId={sessionId} />}
|
{!isWaitlist && isUpcoming(appt) && (
|
||||||
{appt.status !== 'completed' &&
|
<ConfirmationSection appointment={appt} sessionId={sessionId} />
|
||||||
|
)}
|
||||||
|
{!isWaitlist &&
|
||||||
|
appt.status !== 'completed' &&
|
||||||
appt.status !== 'cancelled' &&
|
appt.status !== 'cancelled' &&
|
||||||
!readOnly && (
|
!readOnly && (
|
||||||
<div className="flex gap-2 mt-3">
|
<div className="flex gap-2 mt-3">
|
||||||
@@ -573,16 +847,36 @@ export function RescheduleFlow({
|
|||||||
const [submitting, setSubmitting] = useState(false);
|
const [submitting, setSubmitting] = useState(false);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [success, setSuccess] = useState(false);
|
const [success, setSuccess] = useState(false);
|
||||||
|
const [slotsLoading, setSlotsLoading] = useState(false);
|
||||||
|
const [slotsError, setSlotsError] = useState<string | null>(null);
|
||||||
|
const [availableTimes, setAvailableTimes] = useState<string[]>([]);
|
||||||
|
|
||||||
const availableTimes = [
|
useEffect(() => {
|
||||||
'9:00 AM',
|
if (!selectedDate || !sessionId) {
|
||||||
'10:00 AM',
|
setAvailableTimes([]);
|
||||||
'11:00 AM',
|
setSlotsError(null);
|
||||||
'1:00 PM',
|
return;
|
||||||
'2:00 PM',
|
}
|
||||||
'3:00 PM',
|
if (!appt.serviceId) {
|
||||||
'4:00 PM',
|
setAvailableTimes([]);
|
||||||
];
|
setSlotsError('Failed to load time slots');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let cancelled = false;
|
||||||
|
setSlotsLoading(true);
|
||||||
|
setSlotsError(null);
|
||||||
|
fetchAvailability({ serviceId: appt.serviceId, date: selectedDate }, sessionId).then(
|
||||||
|
({ times, error }) => {
|
||||||
|
if (cancelled) return;
|
||||||
|
setAvailableTimes(times);
|
||||||
|
setSlotsError(error);
|
||||||
|
setSlotsLoading(false);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}, [selectedDate, sessionId, appt.serviceId]);
|
||||||
|
|
||||||
async function handleSubmit() {
|
async function handleSubmit() {
|
||||||
if (!selectedDate || !selectedTime) return;
|
if (!selectedDate || !selectedTime) return;
|
||||||
@@ -654,6 +948,7 @@ export function RescheduleFlow({
|
|||||||
<h3 className="font-medium text-stone-800 mb-3">Pick a New Date & Time</h3>
|
<h3 className="font-medium text-stone-800 mb-3">Pick a New Date & Time</h3>
|
||||||
<input
|
<input
|
||||||
type="date"
|
type="date"
|
||||||
|
aria-label="Select date"
|
||||||
value={selectedDate}
|
value={selectedDate}
|
||||||
onChange={(e) => setSelectedDate(e.target.value)}
|
onChange={(e) => setSelectedDate(e.target.value)}
|
||||||
min={new Date().toISOString().split('T')[0]}
|
min={new Date().toISOString().split('T')[0]}
|
||||||
@@ -661,7 +956,12 @@ export function RescheduleFlow({
|
|||||||
/>
|
/>
|
||||||
{selectedDate && (
|
{selectedDate && (
|
||||||
<div className="grid grid-cols-3 gap-2 mb-4">
|
<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
|
<button
|
||||||
key={time}
|
key={time}
|
||||||
onClick={() => setSelectedTime(time)}
|
onClick={() => setSelectedTime(time)}
|
||||||
@@ -707,7 +1007,7 @@ interface BookingFlowProps {
|
|||||||
sessionId: string | null;
|
sessionId: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
function BookingFlow({ onClose, sessionId }: BookingFlowProps) {
|
export function BookingFlow({ onClose, sessionId }: BookingFlowProps) {
|
||||||
const [step, setStep] = useState(1);
|
const [step, setStep] = useState(1);
|
||||||
const [pets, setPets] = useState<Pet[]>([]);
|
const [pets, setPets] = useState<Pet[]>([]);
|
||||||
const [services, setServices] = useState<Service[]>([]);
|
const [services, setServices] = useState<Service[]>([]);
|
||||||
@@ -720,19 +1020,45 @@ function BookingFlow({ onClose, sessionId }: BookingFlowProps) {
|
|||||||
const [notes, setNotes] = useState('');
|
const [notes, setNotes] = useState('');
|
||||||
const [recurring, setRecurring] = useState('');
|
const [recurring, setRecurring] = useState('');
|
||||||
const [confirmed, setConfirmed] = useState(false);
|
const [confirmed, setConfirmed] = useState(false);
|
||||||
|
useEffect(() => {
|
||||||
|
if (confirmed) {
|
||||||
|
fireAnalyticsEvent(ANALYTICS_EVENTS.BOOKING_CONFIRMED, { step: "confirmed", flow: "portal" });
|
||||||
|
}
|
||||||
|
}, [confirmed]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [submitting, setSubmitting] = useState(false);
|
const [submitting, setSubmitting] = useState(false);
|
||||||
|
const [slotsLoading, setSlotsLoading] = useState(false);
|
||||||
|
const [slotsError, setSlotsError] = useState<string | null>(null);
|
||||||
|
const [availableTimes, setAvailableTimes] = useState<string[]>([]);
|
||||||
|
|
||||||
const availableTimes = [
|
useEffect(() => {
|
||||||
'9:00 AM',
|
if (!selectedDate || !sessionId) {
|
||||||
'10:00 AM',
|
setAvailableTimes([]);
|
||||||
'11:00 AM',
|
setSlotsError(null);
|
||||||
'1:00 PM',
|
return;
|
||||||
'2:00 PM',
|
}
|
||||||
'3:00 PM',
|
const serviceId = selectedServices[0]?.id;
|
||||||
'4:00 PM',
|
if (!serviceId) {
|
||||||
];
|
setAvailableTimes([]);
|
||||||
|
setSlotsError('Failed to load time slots');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let cancelled = false;
|
||||||
|
setSlotsLoading(true);
|
||||||
|
setSlotsError(null);
|
||||||
|
fetchAvailability({ serviceId, date: selectedDate }, sessionId).then(
|
||||||
|
({ times, error }) => {
|
||||||
|
if (cancelled) return;
|
||||||
|
setAvailableTimes(times);
|
||||||
|
setSlotsError(error);
|
||||||
|
setSlotsLoading(false);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}, [selectedDate, sessionId, selectedServices]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const fetchData = async () => {
|
const fetchData = async () => {
|
||||||
@@ -758,7 +1084,8 @@ function BookingFlow({ onClose, sessionId }: BookingFlowProps) {
|
|||||||
|
|
||||||
if (servicesRes.ok) {
|
if (servicesRes.ok) {
|
||||||
const servicesData = await servicesRes.json();
|
const servicesData = await servicesRes.json();
|
||||||
setServices(servicesData.services || servicesData || []);
|
const rawServices: RawApiService[] = servicesData.services || servicesData || [];
|
||||||
|
setServices(rawServices.map(normalizeService));
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
setError('Failed to load data. Please try again.');
|
setError('Failed to load data. Please try again.');
|
||||||
@@ -779,28 +1106,43 @@ function BookingFlow({ onClose, sessionId }: BookingFlowProps) {
|
|||||||
setSubmitting(true);
|
setSubmitting(true);
|
||||||
setError(null);
|
setError(null);
|
||||||
|
|
||||||
try {
|
const payload = JSON.stringify({
|
||||||
const response = await fetch('/api/portal/waitlist', {
|
|
||||||
method: 'POST',
|
|
||||||
headers: {
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
'X-Impersonation-Session-Id': sessionId ?? '',
|
|
||||||
},
|
|
||||||
body: JSON.stringify({
|
|
||||||
petId: selectedPet.id,
|
petId: selectedPet.id,
|
||||||
serviceId: selectedServices[0]?.id,
|
serviceId: selectedServices[0]?.id,
|
||||||
serviceIds: selectedServices.map((s) => s.id),
|
serviceIds: selectedServices.map((s) => s.id),
|
||||||
addOnIds: selectedAddOns.map((s) => s.id),
|
addOnIds: selectedAddOns.map((s) => s.id),
|
||||||
groomerId: selectedGroomer === 'first-available' ? null : selectedGroomer,
|
groomerId: selectedGroomer === 'first-available' ? null : selectedGroomer,
|
||||||
preferredDate: selectedDate,
|
preferredDate: selectedDate,
|
||||||
preferredTime: selectedTime,
|
preferredTime: slotToTime(selectedTime),
|
||||||
notes: notes || undefined,
|
notes: notes || undefined,
|
||||||
recurring: recurring || undefined,
|
recurring: recurring || undefined,
|
||||||
}),
|
|
||||||
});
|
});
|
||||||
|
const submitWaitlist = (id: string) =>
|
||||||
|
fetch('/api/portal/waitlist', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'X-Impersonation-Session-Id': id,
|
||||||
|
},
|
||||||
|
body: payload,
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
let response = await submitWaitlist(sessionId);
|
||||||
|
|
||||||
|
// GRO-2234: a deliberately-paced wizard can outlive the portal session.
|
||||||
|
// The customer's Better Auth session is still valid, so transparently
|
||||||
|
// re-mint a fresh portal session and retry once before surfacing an error.
|
||||||
|
if (response.status === 401) {
|
||||||
|
const freshSessionId = await remintPortalSession();
|
||||||
|
if (freshSessionId) {
|
||||||
|
response = await submitWaitlist(freshSessionId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (response.ok) {
|
if (response.ok) {
|
||||||
setConfirmed(true);
|
setConfirmed(true);
|
||||||
|
fireAnalyticsEvent(ANALYTICS_EVENTS.BOOKING_STEP_SUBMIT, { step: "submit", flow: "portal" });
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
window.location.reload();
|
window.location.reload();
|
||||||
}, 1500);
|
}, 1500);
|
||||||
@@ -855,7 +1197,7 @@ function BookingFlow({ onClose, sessionId }: BookingFlowProps) {
|
|||||||
Appointment Requested!
|
Appointment Requested!
|
||||||
</h3>
|
</h3>
|
||||||
<p className="text-sm text-stone-500 mb-4">
|
<p className="text-sm text-stone-500 mb-4">
|
||||||
{selectedPet?.name} on {formatDate(selectedDate)} at {selectedTime}
|
{selectedPet?.name} on {formatDate(selectedDate)} at {formatSlotLabel(selectedTime)}
|
||||||
</p>
|
</p>
|
||||||
<button
|
<button
|
||||||
onClick={onClose}
|
onClick={onClose}
|
||||||
@@ -876,6 +1218,7 @@ function BookingFlow({ onClose, sessionId }: BookingFlowProps) {
|
|||||||
onClick={() => {
|
onClick={() => {
|
||||||
setSelectedPet(pet);
|
setSelectedPet(pet);
|
||||||
setStep(2);
|
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 ${
|
className={`w-full flex items-center gap-3 p-3 rounded-xl border text-left transition-colors ${
|
||||||
selectedPet?.id === pet.id
|
selectedPet?.id === pet.id
|
||||||
@@ -938,10 +1281,14 @@ function BookingFlow({ onClose, sessionId }: BookingFlowProps) {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="text-right shrink-0 ml-3">
|
<div className="text-right shrink-0 ml-3">
|
||||||
|
{formatServicePrice(svc) && (
|
||||||
<p className="text-sm font-medium text-stone-700">
|
<p className="text-sm font-medium text-stone-700">
|
||||||
{svc.priceRange || `$${svc.price}`}
|
{formatServicePrice(svc)}
|
||||||
</p>
|
</p>
|
||||||
|
)}
|
||||||
|
{typeof svc.duration === 'number' && (
|
||||||
<p className="text-xs text-stone-400">{svc.duration} min</p>
|
<p className="text-xs text-stone-400">{svc.duration} min</p>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
@@ -974,9 +1321,11 @@ function BookingFlow({ onClose, sessionId }: BookingFlowProps) {
|
|||||||
<p className="text-xs text-stone-500">{svc.description}</p>
|
<p className="text-xs text-stone-500">{svc.description}</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
{formatServicePrice(svc) && (
|
||||||
<span className="text-stone-600 shrink-0 ml-3">
|
<span className="text-stone-600 shrink-0 ml-3">
|
||||||
{svc.priceRange || `$${svc.price}`}
|
{formatServicePrice(svc)}
|
||||||
</span>
|
</span>
|
||||||
|
)}
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@@ -1034,7 +1383,10 @@ function BookingFlow({ onClose, sessionId }: BookingFlowProps) {
|
|||||||
Back
|
Back
|
||||||
</button>
|
</button>
|
||||||
<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"
|
className="flex-1 px-4 py-2 bg-blue-600 text-white rounded-lg text-sm font-medium"
|
||||||
>
|
>
|
||||||
Next
|
Next
|
||||||
@@ -1048,6 +1400,7 @@ function BookingFlow({ onClose, sessionId }: BookingFlowProps) {
|
|||||||
<h3 className="font-medium text-stone-800 mb-3">Pick Date & Time</h3>
|
<h3 className="font-medium text-stone-800 mb-3">Pick Date & Time</h3>
|
||||||
<input
|
<input
|
||||||
type="date"
|
type="date"
|
||||||
|
aria-label="Select date"
|
||||||
value={selectedDate}
|
value={selectedDate}
|
||||||
onChange={(e) => setSelectedDate(e.target.value)}
|
onChange={(e) => setSelectedDate(e.target.value)}
|
||||||
min={new Date().toISOString().split('T')[0]}
|
min={new Date().toISOString().split('T')[0]}
|
||||||
@@ -1055,7 +1408,12 @@ function BookingFlow({ onClose, sessionId }: BookingFlowProps) {
|
|||||||
/>
|
/>
|
||||||
{selectedDate && (
|
{selectedDate && (
|
||||||
<div className="grid grid-cols-3 gap-2 mb-4">
|
<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
|
<button
|
||||||
key={time}
|
key={time}
|
||||||
onClick={() => setSelectedTime(time)}
|
onClick={() => setSelectedTime(time)}
|
||||||
@@ -1065,7 +1423,7 @@ function BookingFlow({ onClose, sessionId }: BookingFlowProps) {
|
|||||||
: 'border-stone-200 hover:border-stone-300'
|
: 'border-stone-200 hover:border-stone-300'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{time}
|
{formatSlotLabel(time)}
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@@ -1093,7 +1451,10 @@ function BookingFlow({ onClose, sessionId }: BookingFlowProps) {
|
|||||||
Back
|
Back
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={() => setStep(5)}
|
onClick={() => {
|
||||||
|
setStep(5);
|
||||||
|
fireAnalyticsEvent(ANALYTICS_EVENTS.BOOKING_STEP_TIME, { step: "time", flow: "portal" });
|
||||||
|
}}
|
||||||
disabled={!selectedDate || !selectedTime}
|
disabled={!selectedDate || !selectedTime}
|
||||||
className="flex-1 px-4 py-2 bg-blue-600 text-white rounded-lg text-sm font-medium disabled:opacity-50"
|
className="flex-1 px-4 py-2 bg-blue-600 text-white rounded-lg text-sm font-medium disabled:opacity-50"
|
||||||
>
|
>
|
||||||
@@ -1132,7 +1493,7 @@ function BookingFlow({ onClose, sessionId }: BookingFlowProps) {
|
|||||||
<div className="flex justify-between">
|
<div className="flex justify-between">
|
||||||
<span className="text-stone-500">Date & Time</span>
|
<span className="text-stone-500">Date & Time</span>
|
||||||
<span className="font-medium">
|
<span className="font-medium">
|
||||||
{formatDate(selectedDate)} at {selectedTime}
|
{formatDate(selectedDate)} at {formatSlotLabel(selectedTime)}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
{recurring && (
|
{recurring && (
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useState } from "react";
|
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";
|
import type { Pet, MedicalAlert, CoatType, AlertSeverity } from "@groombook/types";
|
||||||
|
|
||||||
const COAT_TYPES: CoatType[] = ["double", "wire", "curly", "smooth", "long", "hairless"];
|
const COAT_TYPES: CoatType[] = ["double", "wire", "curly", "smooth", "long", "hairless"];
|
||||||
@@ -9,18 +9,20 @@ type SizeOption = typeof SIZE_OPTIONS[number];
|
|||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
pet?: Pet;
|
pet?: Pet;
|
||||||
onSave: (pet: Pet) => void;
|
onSave: (pet: Pet) => void | Promise<void>;
|
||||||
onCancel: () => void;
|
onCancel: () => void;
|
||||||
|
saving?: boolean;
|
||||||
|
saveError?: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
function newAlert(): Omit<MedicalAlert, "id"> {
|
function newAlert(): Omit<MedicalAlert, "id"> {
|
||||||
return { type: "", description: "", severity: "low" };
|
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 [name, setName] = useState(pet?.name ?? "");
|
||||||
const [breed, setBreed] = useState(pet?.breed ?? "");
|
const [breed, setBreed] = useState(pet?.breed ?? "");
|
||||||
const [weight, setWeight] = useState(pet?.weightKg ?? 0);
|
const [weight, setWeight] = useState(Number(pet?.weight ?? pet?.weightKg ?? 0));
|
||||||
const [notes, setNotes] = useState(pet?.healthAlerts ?? "");
|
const [notes, setNotes] = useState(pet?.healthAlerts ?? "");
|
||||||
const [coatType, setCoatType] = useState<CoatType | "">((pet?.coatType as CoatType) ?? "");
|
const [coatType, setCoatType] = useState<CoatType | "">((pet?.coatType as CoatType) ?? "");
|
||||||
const [petSizeCategory, setPetSizeCategory] = useState<SizeOption | "">(pet?.petSizeCategory as SizeOption ?? "");
|
const [petSizeCategory, setPetSizeCategory] = useState<SizeOption | "">(pet?.petSizeCategory as SizeOption ?? "");
|
||||||
@@ -305,18 +307,23 @@ export function PetForm({ pet, onSave, onCancel }: Props) {
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={onCancel}
|
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
|
Cancel
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
type="submit"
|
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} />
|
{saving ? <Loader2 size={14} className="animate-spin" /> : <Save size={14} />}
|
||||||
Save
|
{saving ? "Saving…" : "Save"}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
{saveError && (
|
||||||
|
<p className="text-sm text-red-500 text-center">{saveError}</p>
|
||||||
|
)}
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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 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;
|
const editingPet = editingPetId ? pets.find(p => p.id === editingPetId) ?? null : null;
|
||||||
|
|
||||||
function handlePetSave(updatedPet: Pet) {
|
const [saving, setSaving] = useState(false);
|
||||||
setPets(prev => prev.map(p => p.id === updatedPet.id ? updatedPet : p));
|
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);
|
setEditingPetId(null);
|
||||||
|
} catch (e) {
|
||||||
|
setSaveError(e instanceof Error ? e.message : "Failed to save pet");
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (editingPet) {
|
if (editingPet) {
|
||||||
@@ -94,6 +112,8 @@ export function PetProfiles({ sessionId, readOnly }: Props) {
|
|||||||
pet={editingPet}
|
pet={editingPet}
|
||||||
onSave={handlePetSave}
|
onSave={handlePetSave}
|
||||||
onCancel={() => setEditingPetId(null)}
|
onCancel={() => setEditingPetId(null)}
|
||||||
|
saving={saving}
|
||||||
|
saveError={saveError}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -156,9 +176,9 @@ export function PetProfiles({ sessionId, readOnly }: Props) {
|
|||||||
</div>
|
</div>
|
||||||
<div className="flex-1">
|
<div className="flex-1">
|
||||||
<h2 className="text-xl font-semibold text-stone-800">{selectedPet.name}</h2>
|
<h2 className="text-xl font-semibold text-stone-800">{selectedPet.name}</h2>
|
||||||
<p className="text-stone-500 text-sm">{selectedPet.breed ?? "Unknown breed"} · {selectedPet.weightKg ? `${selectedPet.weightKg} kg` : "Unknown weight"}</p>
|
<p className="text-stone-500 text-sm">{selectedPet.breed ?? "Unknown breed"} · {(() => { const w = selectedPet.weight ?? selectedPet.weightKg; return w != null && w !== "" ? `${w} kg` : "Unknown weight"; })()}</p>
|
||||||
<p className="text-stone-400 text-xs mt-0.5">
|
<p className="text-stone-400 text-xs mt-0.5">
|
||||||
Born {selectedPet.dateOfBirth ? new Date(selectedPet.dateOfBirth).toLocaleDateString("en-US", { month: "long", day: "numeric", year: "numeric" }) : "Unknown"}
|
Born {(() => { const d = selectedPet.birthDate ?? selectedPet.dateOfBirth; return d ? new Date(d).toLocaleDateString("en-US", { month: "long", day: "numeric", year: "numeric" }) : "Unknown"; })()}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
{!readOnly && (
|
{!readOnly && (
|
||||||
@@ -202,6 +222,14 @@ export function PetProfiles({ sessionId, readOnly }: Props) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function formatSizeCategory(size?: string | null): string {
|
||||||
|
if (!size) return "Unknown";
|
||||||
|
return size
|
||||||
|
.split("_")
|
||||||
|
.map((part) => part.charAt(0).toUpperCase() + part.slice(1))
|
||||||
|
.join(" ");
|
||||||
|
}
|
||||||
|
|
||||||
function InfoRow({ label, value }: { label: string; value: React.ReactNode }) {
|
function InfoRow({ label, value }: { label: string; value: React.ReactNode }) {
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col sm:flex-row sm:items-center py-2.5 border-b border-stone-100 last:border-0">
|
<div className="flex flex-col sm:flex-row sm:items-center py-2.5 border-b border-stone-100 last:border-0">
|
||||||
@@ -224,7 +252,7 @@ function SeverityBadge({ severity }: { severity: "low" | "medium" | "high" }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function BasicInfoTab({ pet, readOnly }: { pet: Pet; readOnly: boolean }) {
|
export function BasicInfoTab({ pet, readOnly }: { pet: Pet; readOnly: boolean }) {
|
||||||
const score = pet.temperamentScore;
|
const score = pet.temperamentScore;
|
||||||
const flags = pet.temperamentFlags ?? [];
|
const flags = pet.temperamentFlags ?? [];
|
||||||
|
|
||||||
@@ -232,8 +260,9 @@ function BasicInfoTab({ pet, readOnly }: { pet: Pet; readOnly: boolean }) {
|
|||||||
<div>
|
<div>
|
||||||
<InfoRow label="Name" value={pet.name} />
|
<InfoRow label="Name" value={pet.name} />
|
||||||
<InfoRow label="Breed" value={pet.breed || "Unknown"} />
|
<InfoRow label="Breed" value={pet.breed || "Unknown"} />
|
||||||
<InfoRow label="Weight" value={pet.weightKg ? `${pet.weightKg} kg` : "Unknown"} />
|
<InfoRow label="Weight" value={(() => { const w = pet.weight ?? pet.weightKg; return w != null && w !== "" ? `${w} kg` : "Unknown"; })()} />
|
||||||
<InfoRow label="Date of Birth" value={pet.dateOfBirth ? new Date(pet.dateOfBirth).toLocaleDateString("en-US", { month: "long", day: "numeric", year: "numeric" }) : "Unknown"} />
|
<InfoRow label="Date of Birth" value={(() => { const d = pet.birthDate ?? pet.dateOfBirth; return d ? new Date(d).toLocaleDateString("en-US", { month: "long", day: "numeric", year: "numeric" }) : "Unknown"; })()} />
|
||||||
|
<InfoRow label="Size Category" value={formatSizeCategory(pet.petSizeCategory)} />
|
||||||
|
|
||||||
{/* Temperament (staff-set, read-only) */}
|
{/* Temperament (staff-set, read-only) */}
|
||||||
{(score != null || flags.length > 0) && (
|
{(score != null || flags.length > 0) && (
|
||||||
|
|||||||
@@ -39,6 +39,8 @@ export default defineConfig({
|
|||||||
],
|
],
|
||||||
},
|
},
|
||||||
workbox: {
|
workbox: {
|
||||||
|
skipWaiting: true,
|
||||||
|
clientsClaim: true,
|
||||||
globPatterns: ["**/*.{js,css,html,ico,png,svg,woff2}"],
|
globPatterns: ["**/*.{js,css,html,ico,png,svg,woff2}"],
|
||||||
navigateFallbackDenylist: [
|
navigateFallbackDenylist: [
|
||||||
/^\/api\/auth\//,
|
/^\/api\/auth\//,
|
||||||
@@ -55,6 +57,23 @@ export default defineConfig({
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
// OpenStreetMap raster tiles for the Route Planner map. CacheFirst so
|
||||||
|
// tiles pre-warmed for a route render offline during the day. Capped
|
||||||
|
// entries + 7-day TTL keep the cache bounded.
|
||||||
|
urlPattern: /^https:\/\/[abc]\.tile\.openstreetmap\.org\/.*\.png$/i,
|
||||||
|
handler: "CacheFirst",
|
||||||
|
options: {
|
||||||
|
cacheName: "osm-tiles",
|
||||||
|
expiration: {
|
||||||
|
maxEntries: 400,
|
||||||
|
maxAgeSeconds: 60 * 60 * 24 * 7, // 7 days
|
||||||
|
},
|
||||||
|
cacheableResponse: {
|
||||||
|
statuses: [0, 200],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
|
|||||||
Reference in New Issue
Block a user