Compare commits
16 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 17872e7b27 | |||
| 661bd4f902 | |||
| fe565861b9 | |||
| 7ef270312c | |||
| f58a0e569b | |||
| 2a401a4584 | |||
| e93017b279 | |||
| 27c59113e2 | |||
| db11e5f2bd | |||
| 980615b8e6 | |||
| 95c688764b | |||
| f549101962 | |||
| 5bb8fbcb7d | |||
| fdff0977ad | |||
| 2aad7cb6a0 | |||
| 0c41640f59 |
@@ -0,0 +1,54 @@
|
||||
# AGENTS.md
|
||||
|
||||
This repository (`groombook/web`) is part of the GroomBook application stack. The
|
||||
authoritative process, quality bar, and safety rules live in the shared
|
||||
[`groombook/org`](https://git.farh.net/groombook/org) skills repository. Read
|
||||
those first; this file is only a pointer.
|
||||
|
||||
## Authoritative skills
|
||||
|
||||
- **SDLC (branching, PRs, phases, handoffs):**
|
||||
[`groombook/org/skills/sdlc/SKILL.md`](https://git.farh.net/groombook/org/src/branch/main/skills/sdlc/SKILL.md)
|
||||
- **Coding standards (priority ordering, PR discipline, tests, no-hardcoded-values, CalVer):**
|
||||
[`groombook/org/skills/coding-standards/SKILL.md`](https://git.farh.net/groombook/org/src/branch/main/skills/coding-standards/SKILL.md)
|
||||
- **Safety (no plaintext secrets, no direct `kubectl apply` to `groombook`, no self-merge, board approval for destructive actions):**
|
||||
[`groombook/org/skills/safety/SKILL.md`](https://git.farh.net/groombook/org/src/branch/main/skills/safety/SKILL.md)
|
||||
|
||||
For human contributors and humans reviewing agent work, see
|
||||
[`CONTRIBUTING.md`](./CONTRIBUTING.md) in this repo for the phase-by-phase PR
|
||||
flow and the `uat→main` merge-gate policy summary.
|
||||
|
||||
## Non-negotiable operational rules
|
||||
|
||||
These mirror the org skills; they are restated here so any agent landing in
|
||||
this repo sees them without a cross-repo fetch.
|
||||
|
||||
- **All changes go through a PR.** Never push directly to `dev`, `uat`, or `main`.
|
||||
- **Branch strategy:** `feature/<name>` → `dev` → `uat` → `main`. Engineers
|
||||
always target `dev` first.
|
||||
- **No self-merge contract.** The engineer who opened a PR clicks merge only
|
||||
after the named reviewer (CI / QA / UAT / Security / CTO per phase)
|
||||
approves. Issue-thread QA / UAT / security approvals do **not** clear the
|
||||
Gitea `required_approvals` gate on `uat→main` — only a Gitea **Approve**
|
||||
click from a member of the `approvals_whitelist_username` does. On this
|
||||
repo that whitelist is `["gb_flea", "gb_dogfather"]` (engineer team).
|
||||
Board-level accounts cannot give the Approve click by policy.
|
||||
- **Always include `cc @cpfarhood`** at the bottom of every PR body for
|
||||
board visibility (not as a reviewer).
|
||||
- **Secrets in code are forbidden.** Use Bitnami Sealed Secrets; never commit
|
||||
plaintext. See the `safety` skill.
|
||||
- **Production (`groombook` namespace) is Flux-managed.** Never
|
||||
`kubectl apply` directly. Infrastructure changes go through PRs in
|
||||
`groombook/infra`.
|
||||
|
||||
## Local development
|
||||
|
||||
See the repo's own README, package scripts, and CI workflow. The
|
||||
authoritative pipeline (Gitea Actions, image build, deploy hooks) is the
|
||||
shared `groombook/infra` overlay; do not reimplement it here.
|
||||
|
||||
## When uncertain
|
||||
|
||||
If a task conflicts with the org skills, **the org skills win**. Open an
|
||||
issue in `groombook/org` to propose a change rather than encoding a local
|
||||
exception.
|
||||
+117
@@ -0,0 +1,117 @@
|
||||
# Contributing to `groombook/web`
|
||||
|
||||
Thanks for contributing. This document is the human-facing companion to
|
||||
[`AGENTS.md`](./AGENTS.md) and the authoritative
|
||||
[`groombook/org`](https://git.farh.net/groombook/org) skills. The org skills
|
||||
govern; this file is a quick-reference for the human/agent PR flow in this
|
||||
repo.
|
||||
|
||||
## Branch strategy
|
||||
|
||||
Three long-lived branches; one PR per promotion step.
|
||||
|
||||
| Branch | Environment | Who merges | Prerequisites for merge |
|
||||
|---------|-------------|-----------|-------------------------|
|
||||
| `dev` | Dev | Engineer | CI passes |
|
||||
| `uat` | UAT | Engineer | QA code review approval |
|
||||
| `main` | Production | Engineer | UAT validation + CTO Gitea Approve when the `uat→main` merge-gate policy applies (see below) |
|
||||
|
||||
Engineers always target `dev` first. Feature branches: `<agent-name>/<short-description>`.
|
||||
|
||||
## Phase-by-phase PR flow
|
||||
|
||||
### Phase 1 — Dev
|
||||
|
||||
1. Branch from `dev`: `git checkout -b <name>/<short-description> origin/dev`.
|
||||
2. Write code + tests. Run unit tests, type check, and lint locally (or rely on CI).
|
||||
3. Open a PR against `dev`:
|
||||
```bash
|
||||
tea pr create --base dev --title "..." --body "..."
|
||||
```
|
||||
Include `cc @cpfarhood` at the bottom of the body for board visibility.
|
||||
4. CI must pass. CI green → engineer self-merges.
|
||||
5. CI builds and deploys to Dev automatically.
|
||||
|
||||
### Phase 2 — UAT promotion
|
||||
|
||||
1. Open a PR from `dev` to `uat`.
|
||||
2. CI must pass.
|
||||
3. **QA (Lint Roller)** reviews and approves on the Gitea PR.
|
||||
4. QA approved → engineer self-merges.
|
||||
5. CI builds and deploys to UAT automatically.
|
||||
|
||||
### Phase 3 — UAT regression + Security review
|
||||
|
||||
1. **UAT (Shedward Scissorhands)** runs full regression against UAT — every
|
||||
feature, old and new, no exceptions.
|
||||
2. **Security (Barkley Trimsworth)** reviews the changes.
|
||||
3. Failures in either gate bounce back to Phase 1.
|
||||
|
||||
### Phase 4 — Production promotion (`uat → main`)
|
||||
|
||||
This is the gate the org PR
|
||||
[`groombook/org#13`](https://git.farh.net/groombook/org/pulls/13) defines.
|
||||
The full rule is in
|
||||
[`groombook/org/skills/sdlc/SKILL.md`](https://git.farh.net/groombook/org/src/branch/main/skills/sdlc/SKILL.md)
|
||||
and
|
||||
[`groombook/org/skills/coding-standards/SKILL.md`](https://git.farh.net/groombook/org/src/branch/main/skills/coding-standards/SKILL.md);
|
||||
the summary is below.
|
||||
|
||||
**The CTO Gitea Approve click is NOT the default gate.** Once the four
|
||||
pre-gates (QA, UAT deploy, UAT regression, security) are green, the engineer
|
||||
self-merges.
|
||||
|
||||
**A CTO Gitea Approve click IS required** only for PRs in one of three
|
||||
categories:
|
||||
|
||||
1. **Novel auth / session paths** — login, OIDC, OOBE, session middleware,
|
||||
token issuance, password reset, MFA, new auth provider integrations.
|
||||
Routine auth-gated UI (button styling, error messages, form layout) is
|
||||
**not** in this category.
|
||||
2. **Infra / prod-affecting merges** — deploys, infra manifests, secrets,
|
||||
GitOps overlays, CI/CD, `main` branch protection, production
|
||||
routing/ingress, prod state mutations. All Phase 5 infra overlay PRs in
|
||||
`groombook/infra` require CTO Gitea Approve without exception.
|
||||
3. **Risk-flagged merges** — `risk:cto-approve` label, or explicit CTO/CEO
|
||||
sign-off request in the PR or issue thread.
|
||||
|
||||
The engineer opens the `uat→main` PR, classifies it against the three
|
||||
categories above, and adds `cc @cpfarhood`. If the PR is in scope, the CTO
|
||||
clicks Approve; once approved (and the four pre-gates are green), the
|
||||
engineer merges.
|
||||
|
||||
### Phase 5 — Production deployment
|
||||
|
||||
A separate PR in `groombook/infra` bumps the overlay image tag for prod.
|
||||
Handed to QA (Lint Roller) for review, then self-merged by the engineer.
|
||||
|
||||
## The four pre-gates (uat→main)
|
||||
|
||||
A `uat→main` PR is mergeable when **all four** are green:
|
||||
|
||||
1. **QA code review** — done on the dev→uat promotion PR.
|
||||
2. **UAT deploy** — the UAT image built from the uat tip is live in UAT.
|
||||
3. **UAT regression** — Shedward's full-feature UAT pass is green (no
|
||||
pre-existing defects, no new defects).
|
||||
4. **Security review** — Barkley's security code review is green.
|
||||
|
||||
Issue-thread QA / UAT / security approvals do **not** clear the Gitea
|
||||
`required_approvals` gate. Only a Gitea **Approve** click from a member of
|
||||
the `approvals_whitelist_username` for `main` clears it. In this repo that
|
||||
whitelist is the engineer team (`gb_flea`, `gb_dogfather`).
|
||||
|
||||
## Style, tests, and quality bar
|
||||
|
||||
See
|
||||
[`groombook/org/skills/coding-standards/SKILL.md`](https://git.farh.net/groombook/org/src/branch/main/skills/coding-standards/SKILL.md)
|
||||
for the engineering priority ordering, test requirements, no-hardcoded-values
|
||||
rules, CalVer versioning policy, and the `git.farh.net` container registry
|
||||
policy.
|
||||
|
||||
## Safety
|
||||
|
||||
See
|
||||
[`groombook/org/skills/safety/SKILL.md`](https://git.farh.net/groombook/org/src/branch/main/skills/safety/SKILL.md)
|
||||
for the non-negotiable rules: no plaintext secrets, no `kubectl apply` to
|
||||
`groombook`, no self-merge, no direct `tofu` runs, board approval for
|
||||
destructive actions, escalation protocol.
|
||||
+50
-1
@@ -217,6 +217,25 @@ export const { signIn, signOut, useSession, changePassword } = authClient;
|
||||
| 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 |
|
||||
@@ -428,7 +447,8 @@ These cases cover the `CustomerPortal` initialisation path that bridges an Authe
|
||||
| TC-WEB-5.25.3 | Subsequent portal calls use the bridged session ID | After TC-WEB-5.25.1 succeeds, navigate to **Appointments**, **My Pets**, **Billing**, **Settings**. Inspect any `/api/portal/*` request in DevTools → Network. | Each portal API call carries an `X-Impersonation-Session-Id` header whose value equals the `sessionId` returned by `session-from-auth` (not a URL-param value). Each call returns 200 (or 404 for genuinely empty collections), never 401. |
|
||||
| TC-WEB-5.25.4 | No impersonation chrome for the customer's own session | After TC-WEB-5.25.1, scan the portal UI. | No amber border around the page. No "STAFF VIEW" watermark. No "End Impersonation" button in the sidebar. The customer is themselves; only impersonation sessions started via `?sessionId=` show the banner. |
|
||||
| TC-WEB-5.25.5 | 404 fallback for authenticated user with no client record | 1. Sign in via SSO with an Authentik account whose email is **not** present in `clients`. 2. Land on `/`. | `POST /api/portal/session-from-auth` returns 404. The portal renders a centred card titled **"Portal access not configured"** with the message about contacting the groomer and a **Sign out** button. No redirect loop, no portal chrome. |
|
||||
| TC-WEB-5.25.6 | 404 fallback Sign-out escape hatch | From TC-WEB-5.25.5 click **Sign out**. | `POST /api/auth/sign-out` fires; browser navigates to `/login`; the Authentik session cookie is cleared. Reloading `/` no longer hits 404 (will show the login page). |
|
||||
| TC-WEB-5.25.6 | 404 fallback Sign-out escape hatch (GRO-2358) | From TC-WEB-5.25.5 click **Sign out**. | The shared `signOut()` from `lib/auth-client` fires (same handler as `AdminLayout`); browser navigates to `/login`; the Authentik session cookie is cleared. Reloading `/` no longer hits 404 (will show the login page). The handler always navigates to `/login` — even if the network call to `/api/auth/sign-out` fails — so a transient auth-server hiccup never leaves the user trapped on an authenticated screen. |
|
||||
| TC-WEB-5.25.6b | 404 fallback Sign-out on deep-link (GRO-2358) | From TC-WEB-5.25.5, instead of staying on `/`, navigate directly to a portal sub-route (e.g. `/appointments`, `/pets`, `/billing`). The no-access card renders. Click **Sign out**. | The same shared `signOut()` handler fires and the browser navigates to `/login`. The no-access screen must surface an escape hatch on every authenticated route — not just `/` — so a stale or deep link into a portal the user has no access to can never trap them. |
|
||||
| TC-WEB-5.25.7 | Bridge precedence — impersonation URL wins | 1. Sign in via SSO as a customer. 2. Open a new tab to `https://uat.groombook.dev/?sessionId=<a-valid-staff-impersonation-session-id>`. | The impersonation path runs; the amber banner appears for the impersonated client. The Better Auth bridge is **not** called on this load (`session-from-auth` absent in Network). |
|
||||
| TC-WEB-5.25.8 | Bridge precedence — dev user wins | In dev mode (e.g. local) with `localStorage["dev-user"]` set to a client persona, navigate to `/`. | The dev-session path runs (`POST /api/portal/dev-session`). The Better Auth bridge is **not** called (`session-from-auth` absent in Network). Staff dev users still redirect to `/admin`. |
|
||||
| TC-WEB-5.25.9 | Staff Better Auth session does not run the customer bridge | Sign in via SSO with a staff identity. Navigate to `/`. | `App.tsx` routing redirects to `/admin`. `POST /api/portal/session-from-auth` is **not** called. |
|
||||
@@ -491,6 +511,35 @@ The admin Route Planner lives at `/admin/routes`. It shows a groomer's geocoded
|
||||
| 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
|
||||
|
||||
**Pass:**
|
||||
|
||||
@@ -13,6 +13,9 @@
|
||||
"test:e2e": "playwright test -c e2e/playwright.config.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"@dnd-kit/core": "^6.3.1",
|
||||
"@dnd-kit/sortable": "^10.0.0",
|
||||
"@dnd-kit/utilities": "^3.2.2",
|
||||
"@groombook/types": "workspace:*",
|
||||
"@stripe/react-stripe-js": "^6.1.0",
|
||||
"@stripe/stripe-js": "^9.1.0",
|
||||
|
||||
Generated
+61
@@ -8,6 +8,15 @@ importers:
|
||||
|
||||
.:
|
||||
dependencies:
|
||||
'@dnd-kit/core':
|
||||
specifier: ^6.3.1
|
||||
version: 6.3.1(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
|
||||
'@dnd-kit/sortable':
|
||||
specifier: ^10.0.0
|
||||
version: 10.0.0(@dnd-kit/core@6.3.1(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(react@19.2.6)
|
||||
'@dnd-kit/utilities':
|
||||
specifier: ^3.2.2
|
||||
version: 3.2.2(react@19.2.6)
|
||||
'@groombook/types':
|
||||
specifier: workspace:*
|
||||
version: link:packages/types
|
||||
@@ -748,6 +757,28 @@ packages:
|
||||
resolution: {integrity: sha512-Vd/9EVDiu6PPJt9yAh6roZP6El1xHrdvIVGjyBsHR0RYwNHgL7FJPyIIW4fANJNG6FtyZfvlRPpFI4ZM/lubvw==}
|
||||
engines: {node: '>=18'}
|
||||
|
||||
'@dnd-kit/accessibility@3.1.1':
|
||||
resolution: {integrity: sha512-2P+YgaXF+gRsIihwwY1gCsQSYnu9Zyj2py8kY5fFvUM1qm2WA2u639R6YNVfU4GWr+ZM5mqEsfHZZLoRONbemw==}
|
||||
peerDependencies:
|
||||
react: '>=16.8.0'
|
||||
|
||||
'@dnd-kit/core@6.3.1':
|
||||
resolution: {integrity: sha512-xkGBRQQab4RLwgXxoqETICr6S5JlogafbhNsidmrkVv2YRs5MLwpjoF2qpiGjQt8S9AoxtIV603s0GIUpY5eYQ==}
|
||||
peerDependencies:
|
||||
react: '>=16.8.0'
|
||||
react-dom: '>=16.8.0'
|
||||
|
||||
'@dnd-kit/sortable@10.0.0':
|
||||
resolution: {integrity: sha512-+xqhmIIzvAYMGfBYYnbKuNicfSsk4RksY2XdmJhT+HAC01nix6fHCztU68jooFiMUB01Ky3F0FyOvhG/BZrWkg==}
|
||||
peerDependencies:
|
||||
'@dnd-kit/core': ^6.3.0
|
||||
react: '>=16.8.0'
|
||||
|
||||
'@dnd-kit/utilities@3.2.2':
|
||||
resolution: {integrity: sha512-+MKAJEOfaBe5SmV6t34p80MMKhjvUz0vRrvVJbPT0WElzaOJ/1xs+D+KDv+tD/NE5ujfrChEcshd4fLn0wpiqg==}
|
||||
peerDependencies:
|
||||
react: '>=16.8.0'
|
||||
|
||||
'@esbuild/aix-ppc64@0.25.12':
|
||||
resolution: {integrity: sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==}
|
||||
engines: {node: '>=18'}
|
||||
@@ -3176,6 +3207,9 @@ packages:
|
||||
peerDependencies:
|
||||
typescript: '>=4.8.4'
|
||||
|
||||
tslib@2.8.1:
|
||||
resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==}
|
||||
|
||||
type-check@0.4.0:
|
||||
resolution: {integrity: sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==}
|
||||
engines: {node: '>= 0.8.0'}
|
||||
@@ -4261,6 +4295,31 @@ snapshots:
|
||||
|
||||
'@csstools/css-tokenizer@3.0.4': {}
|
||||
|
||||
'@dnd-kit/accessibility@3.1.1(react@19.2.6)':
|
||||
dependencies:
|
||||
react: 19.2.6
|
||||
tslib: 2.8.1
|
||||
|
||||
'@dnd-kit/core@6.3.1(react-dom@19.2.6(react@19.2.6))(react@19.2.6)':
|
||||
dependencies:
|
||||
'@dnd-kit/accessibility': 3.1.1(react@19.2.6)
|
||||
'@dnd-kit/utilities': 3.2.2(react@19.2.6)
|
||||
react: 19.2.6
|
||||
react-dom: 19.2.6(react@19.2.6)
|
||||
tslib: 2.8.1
|
||||
|
||||
'@dnd-kit/sortable@10.0.0(@dnd-kit/core@6.3.1(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(react@19.2.6)':
|
||||
dependencies:
|
||||
'@dnd-kit/core': 6.3.1(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
|
||||
'@dnd-kit/utilities': 3.2.2(react@19.2.6)
|
||||
react: 19.2.6
|
||||
tslib: 2.8.1
|
||||
|
||||
'@dnd-kit/utilities@3.2.2(react@19.2.6)':
|
||||
dependencies:
|
||||
react: 19.2.6
|
||||
tslib: 2.8.1
|
||||
|
||||
'@esbuild/aix-ppc64@0.25.12':
|
||||
optional: true
|
||||
|
||||
@@ -6627,6 +6686,8 @@ snapshots:
|
||||
dependencies:
|
||||
typescript: 5.9.3
|
||||
|
||||
tslib@2.8.1: {}
|
||||
|
||||
type-check@0.4.0:
|
||||
dependencies:
|
||||
prelude-ls: 1.2.1
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import { render, screen, fireEvent, waitFor } from "@testing-library/react";
|
||||
import { parseTimeTo24Hour, isUpcoming, normalizeAppointment, CustomerNotesSection, ConfirmationSection, StatusBadge, formatSlotLabel, slotToTime, BookingFlow } from "../portal/sections/Appointments.tsx";
|
||||
import { parseTimeTo24Hour, isUpcoming, normalizeAppointment, normalizeService, formatServicePrice, CustomerNotesSection, ConfirmationSection, StatusBadge, normalizeStatusKey, deriveDisplayStatus, formatSlotLabel, slotToTime, BookingFlow } from "../portal/sections/Appointments.tsx";
|
||||
|
||||
const UPCOMING_APPT = {
|
||||
id: "appt-1",
|
||||
@@ -517,6 +517,52 @@ describe("StatusBadge", () => {
|
||||
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", () => {
|
||||
@@ -873,3 +919,65 @@ describe("BookingFlow Book New funnel (GRO-2213)", () => {
|
||||
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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -79,6 +79,21 @@ function mockFetch(meRole: "manager" | "groomer") {
|
||||
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);
|
||||
});
|
||||
}
|
||||
@@ -124,6 +139,33 @@ describe("RoutesPage", () => {
|
||||
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;
|
||||
@@ -139,4 +181,31 @@ describe("RoutesPage", () => {
|
||||
)
|
||||
);
|
||||
});
|
||||
|
||||
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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -21,6 +21,18 @@ vi.mock("../portal/sections/Appointments.js", async () => {
|
||||
};
|
||||
});
|
||||
|
||||
// Spy on the canonical `signOut()` from the shared auth-client so we can
|
||||
// assert the no-access screen's logout button uses the SAME handler as
|
||||
// `AdminLayout`. We mock at the module boundary — the no-access screen is
|
||||
// the one authenticated surface that renders without the portal chrome, so
|
||||
// a regression here would trap the user. We do NOT use `importActual`
|
||||
// because the real `createAuthClient()` requires a runtime `baseURL`
|
||||
// (Better Auth) that the JSDOM test environment can't supply.
|
||||
const signOutSpy = vi.hoisted(() => vi.fn(async () => undefined));
|
||||
vi.mock("../lib/auth-client.js", () => ({
|
||||
signOut: signOutSpy,
|
||||
}));
|
||||
|
||||
const SESSION: ImpersonationSession = {
|
||||
id: "sess-1",
|
||||
staffId: "staff-1",
|
||||
@@ -336,6 +348,8 @@ describe("CustomerPortal SSO bridge", () => {
|
||||
beforeEach(() => {
|
||||
// Make sure no dev-user leaks across tests
|
||||
window.localStorage.clear();
|
||||
// Reset shared signOut() spy so per-test counts are deterministic
|
||||
signOutSpy.mockClear();
|
||||
});
|
||||
|
||||
const brandingResponse = {
|
||||
@@ -429,6 +443,132 @@ describe("CustomerPortal SSO bridge", () => {
|
||||
expect(screen.getByRole("button", { name: /Sign out/i })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("calls the shared signOut() handler and navigates to /login from the no-access screen (GRO-2358)", async () => {
|
||||
// Reset the spy so previous tests don't leak into this assertion.
|
||||
signOutSpy.mockClear();
|
||||
|
||||
// JSDOM throws on window.location.href assignment by default; swap in a
|
||||
// writable stub so the navigation is observable, then restore after.
|
||||
const originalLocation = window.location;
|
||||
Object.defineProperty(window, "location", {
|
||||
value: { href: "" },
|
||||
writable: true,
|
||||
configurable: true,
|
||||
});
|
||||
|
||||
global.fetch = vi.fn((input: RequestInfo) => {
|
||||
const url = typeof input === "string" ? input : input.toString();
|
||||
if (url === "/api/branding") return Promise.resolve(brandingResponse);
|
||||
if (url === "/api/auth/get-session") {
|
||||
return Promise.resolve({
|
||||
ok: true,
|
||||
json: async () => ({ user: { email: "stranger@example.com", role: "customer" } }),
|
||||
} as Response);
|
||||
}
|
||||
if (url === "/api/portal/session-from-auth") {
|
||||
return Promise.resolve({
|
||||
ok: false,
|
||||
status: 404,
|
||||
json: async () => ({ error: "No client record found for this user" }),
|
||||
} as Response);
|
||||
}
|
||||
return Promise.resolve({ ok: true, json: async () => ({}) } as Response);
|
||||
}) as unknown as typeof fetch;
|
||||
|
||||
const { CustomerPortal } = await import("../portal/CustomerPortal.js");
|
||||
render(
|
||||
<MemoryRouter initialEntries={["/"]}>
|
||||
<CustomerPortal />
|
||||
</MemoryRouter>
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/Portal access not configured/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Pre-condition: the shared signOut() must NOT have been called yet — the
|
||||
// no-access screen is mounted because the bridge failed, not because the
|
||||
// user clicked anything.
|
||||
expect(signOutSpy).not.toHaveBeenCalled();
|
||||
|
||||
// Drive the click. The handler is the SAME `signOut()` exported from
|
||||
// auth-client that AdminLayout uses, so verifying this call is enough to
|
||||
// prove the no-access screen reaches the canonical sign-out surface.
|
||||
const signOutButton = screen.getByRole("button", { name: /Sign out/i });
|
||||
fireEvent.click(signOutButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(signOutSpy).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
// The handler always navigates to /login — even if the network call to
|
||||
// /api/auth/sign-out fails — so a transient auth-server hiccup never
|
||||
// leaves the user trapped on an authenticated screen.
|
||||
await waitFor(() => {
|
||||
expect(window.location.href).toBe("/login");
|
||||
});
|
||||
|
||||
Object.defineProperty(window, "location", { value: originalLocation, configurable: true });
|
||||
});
|
||||
|
||||
it("reaches the same shared signOut() on a deep-link no-access screen (GRO-2358)", async () => {
|
||||
// AC requires verifying the SAME logout handler is reachable from at
|
||||
// least one other authenticated surface — here a deep link to a portal
|
||||
// sub-route (e.g. /appointments) for a user with a Better Auth session
|
||||
// but no client record. The no-access screen is the only authenticated
|
||||
// surface without a route guard, so the handler must fire identically.
|
||||
signOutSpy.mockClear();
|
||||
|
||||
const originalLocation = window.location;
|
||||
Object.defineProperty(window, "location", {
|
||||
value: { href: "" },
|
||||
writable: true,
|
||||
configurable: true,
|
||||
});
|
||||
|
||||
global.fetch = vi.fn((input: RequestInfo) => {
|
||||
const url = typeof input === "string" ? input : input.toString();
|
||||
if (url === "/api/branding") return Promise.resolve(brandingResponse);
|
||||
if (url === "/api/auth/get-session") {
|
||||
return Promise.resolve({
|
||||
ok: true,
|
||||
json: async () => ({ user: { email: "stranger@example.com", role: "customer" } }),
|
||||
} as Response);
|
||||
}
|
||||
if (url === "/api/portal/session-from-auth") {
|
||||
return Promise.resolve({
|
||||
ok: false,
|
||||
status: 404,
|
||||
json: async () => ({ error: "No client record found for this user" }),
|
||||
} as Response);
|
||||
}
|
||||
return Promise.resolve({ ok: true, json: async () => ({}) } as Response);
|
||||
}) as unknown as typeof fetch;
|
||||
|
||||
const { CustomerPortal } = await import("../portal/CustomerPortal.js");
|
||||
render(
|
||||
<MemoryRouter initialEntries={["/appointments"]}>
|
||||
<CustomerPortal />
|
||||
</MemoryRouter>
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/Portal access not configured/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
const signOutButton = screen.getByRole("button", { name: /Sign out/i });
|
||||
fireEvent.click(signOutButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(signOutSpy).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
await waitFor(() => {
|
||||
expect(window.location.href).toBe("/login");
|
||||
});
|
||||
|
||||
Object.defineProperty(window, "location", { value: originalLocation, configurable: true });
|
||||
});
|
||||
|
||||
it("does not call session-from-auth when there is no Better Auth session", async () => {
|
||||
global.fetch = vi.fn((input: RequestInfo) => {
|
||||
const url = typeof input === "string" ? input : input.toString();
|
||||
|
||||
+456
-51
@@ -1,4 +1,22 @@
|
||||
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";
|
||||
|
||||
@@ -76,6 +94,111 @@ function fmtTime(iso: string): string {
|
||||
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" },
|
||||
@@ -109,6 +232,211 @@ const inputStyle: React.CSSProperties = {
|
||||
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() {
|
||||
@@ -124,9 +452,12 @@ export function RoutesPage() {
|
||||
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(() => {
|
||||
@@ -166,6 +497,7 @@ export function RoutesPage() {
|
||||
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");
|
||||
@@ -193,6 +525,7 @@ export function RoutesPage() {
|
||||
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 {
|
||||
@@ -200,6 +533,71 @@ export function RoutesPage() {
|
||||
}
|
||||
}, [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) => ({
|
||||
@@ -212,6 +610,13 @@ export function RoutesPage() {
|
||||
[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;
|
||||
|
||||
@@ -287,9 +692,16 @@ export function RoutesPage() {
|
||||
<Summary label="Total distance" value={route?.totalDistanceKm != null ? `${route.totalDistanceKm} km` : "—"} />
|
||||
</div>
|
||||
|
||||
<div style={{ display: "grid", gridTemplateColumns: "minmax(0, 1.5fr) minmax(280px, 1fr)", gap: 16, alignItems: "stretch" }}>
|
||||
{/* 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: 540, background: "#e5e7eb", borderRadius: 8, overflow: "hidden", border: "1px solid #e2e8f0" }}>
|
||||
<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} />
|
||||
@@ -299,59 +711,52 @@ export function RoutesPage() {
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Stop list panel */}
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: 10, maxHeight: 540, overflowY: "auto" }}>
|
||||
{/* 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.map((s) => (
|
||||
<div
|
||||
key={s.id}
|
||||
style={{
|
||||
background: "#fff",
|
||||
border: `1px solid ${s.conflict?.hasConflict ? "#fca5a5" : "#e2e8f0"}`,
|
||||
borderRadius: 8,
|
||||
padding: "0.7rem 0.85rem",
|
||||
display: "flex",
|
||||
gap: 10,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
flexShrink: 0,
|
||||
width: 26,
|
||||
height: 26,
|
||||
borderRadius: "50%",
|
||||
background: primaryColor,
|
||||
color: "#fff",
|
||||
fontSize: 13,
|
||||
fontWeight: 700,
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
}}
|
||||
{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}
|
||||
>
|
||||
{s.stopOrder}
|
||||
</div>
|
||||
<div style={{ minWidth: 0, flex: 1 }}>
|
||||
<div style={{ display: "flex", justifyContent: "space-between", gap: 8 }}>
|
||||
<strong style={{ fontSize: 14, color: "#1a202c" }}>{s.clientName}</strong>
|
||||
<span style={{ fontSize: 13, color: "#4b5563", whiteSpace: "nowrap" }}>{fmtTime(s.appointmentStartTime)}</span>
|
||||
</div>
|
||||
<div style={{ fontSize: 12, color: "#6b7280", marginTop: 2 }}>{s.clientAddress || "No address on file"}</div>
|
||||
<div style={{ fontSize: 12, color: "#6b7280", marginTop: 4 }}>
|
||||
{s.stopOrder === 1 || s.travelMinsFromPrev == null
|
||||
? "Start of route"
|
||||
: `${fmtDuration(s.travelMinsFromPrev)} travel from previous`}
|
||||
</div>
|
||||
{s.conflict?.hasConflict && (
|
||||
<div style={{ fontSize: 12, color: "#b91c1c", marginTop: 4, fontWeight: 600 }}>
|
||||
⚠ Tight schedule — travel + buffer may exceed the gap
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
<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>
|
||||
|
||||
@@ -15,6 +15,7 @@ import { ImpersonationBanner } from "./ImpersonationBanner.js";
|
||||
import { AuditLogViewer } from "./AuditLogViewer.js";
|
||||
import { useBranding } from "../BrandingContext.js";
|
||||
import { getDevUser } from "../pages/DevLoginSelector.js";
|
||||
import { signOut } from "../lib/auth-client.js";
|
||||
import type { ImpersonationSession } from "@groombook/types";
|
||||
import type { Appointment as PortalAppointment } from "./sections/Appointments.js";
|
||||
|
||||
@@ -193,6 +194,19 @@ export function CustomerPortal() {
|
||||
}
|
||||
}, [session]);
|
||||
|
||||
// Shared sign-out handler — wires the canonical Better Auth `signOut()` so
|
||||
// every authenticated surface (no-access screen, portal chrome, etc.) uses
|
||||
// the same implementation as `AdminLayout`. Failure to reach the server
|
||||
// still leaves the SPA free to navigate to /login.
|
||||
const handleSignOut = useCallback(async () => {
|
||||
try {
|
||||
await signOut();
|
||||
} catch {
|
||||
// Best-effort; navigate to /login regardless so the user is never trapped.
|
||||
}
|
||||
window.location.href = "/login";
|
||||
}, []);
|
||||
|
||||
const logPageView = useCallback((page: string) => {
|
||||
if (!session) return;
|
||||
void fetch(`/api/impersonation/sessions/${session.id}/log`, {
|
||||
@@ -281,14 +295,7 @@ export function CustomerPortal() {
|
||||
<h1 className="text-lg font-semibold text-stone-800 mb-2">Portal access not configured</h1>
|
||||
<p className="text-sm text-stone-600 mb-6">{authError}</p>
|
||||
<button
|
||||
onClick={async () => {
|
||||
try {
|
||||
await fetch("/api/auth/sign-out", { method: "POST", credentials: "include" });
|
||||
} catch {
|
||||
// Best-effort sign-out; redirect to /login regardless.
|
||||
}
|
||||
window.location.href = "/login";
|
||||
}}
|
||||
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} />
|
||||
|
||||
@@ -89,8 +89,8 @@ interface Service {
|
||||
id: string;
|
||||
name: string;
|
||||
description?: string;
|
||||
duration: number;
|
||||
price: number;
|
||||
duration?: number;
|
||||
price?: number;
|
||||
priceRange?: string;
|
||||
isAddOn?: boolean;
|
||||
}
|
||||
@@ -249,6 +249,52 @@ export function normalizeAppointment(raw: RawApiAppointment): Appointment {
|
||||
};
|
||||
}
|
||||
|
||||
// Raw service shape from `GET /api/portal/services`, which projects the
|
||||
// canonical DB columns (`basePriceCents`, `durationMinutes`). Also tolerates an
|
||||
// already-normalized payload so either shape renders correctly.
|
||||
interface RawApiService {
|
||||
id: string;
|
||||
name: string;
|
||||
description?: string | null;
|
||||
basePriceCents?: number | null;
|
||||
durationMinutes?: number | null;
|
||||
price?: number | null;
|
||||
duration?: number | null;
|
||||
priceRange?: string | null;
|
||||
isAddOn?: boolean | null;
|
||||
}
|
||||
|
||||
// Normalizes a raw API service into the flat `Service` shape the cards render:
|
||||
// price as dollars (from `basePriceCents`) and duration in minutes (from
|
||||
// `durationMinutes`). Leaves fields undefined when genuinely absent so the card
|
||||
// can hide them rather than print `$undefined` / empty `min`.
|
||||
export function normalizeService(raw: RawApiService): Service {
|
||||
const price =
|
||||
raw.price ?? (typeof raw.basePriceCents === 'number' ? raw.basePriceCents / 100 : undefined);
|
||||
const duration = raw.duration ?? raw.durationMinutes ?? undefined;
|
||||
return {
|
||||
id: raw.id,
|
||||
name: raw.name,
|
||||
description: raw.description ?? undefined,
|
||||
duration: duration ?? undefined,
|
||||
price: price ?? undefined,
|
||||
priceRange: raw.priceRange ?? undefined,
|
||||
isAddOn: raw.isAddOn ?? undefined,
|
||||
};
|
||||
}
|
||||
|
||||
// Renders a service price for display, preferring an explicit `priceRange`
|
||||
// string, then a numeric dollar `price` (integers without trailing zeros, e.g.
|
||||
// `$45`; fractional values to cents, e.g. `$45.50`). Returns null when neither
|
||||
// is available so the caller can omit the price line entirely.
|
||||
export function formatServicePrice(svc: Pick<Service, 'price' | 'priceRange'>): string | null {
|
||||
if (svc.priceRange) return svc.priceRange;
|
||||
if (typeof svc.price === 'number' && Number.isFinite(svc.price)) {
|
||||
return `$${Number.isInteger(svc.price) ? svc.price : svc.price.toFixed(2)}`;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
const STATUS_COLORS: Record<string, string> = {
|
||||
confirmed: 'bg-green-100 text-green-700',
|
||||
pending: 'bg-amber-100 text-amber-600',
|
||||
@@ -269,9 +315,19 @@ const STATUS_LABELS: Record<string, string> = {
|
||||
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 label = STATUS_LABELS[status] ?? status;
|
||||
const colorClass = STATUS_COLORS[status] ?? 'bg-stone-100 text-stone-600';
|
||||
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}
|
||||
@@ -279,6 +335,19 @@ export function StatusBadge({ status }: { status: string }) {
|
||||
);
|
||||
}
|
||||
|
||||
// Derives the badge state shown on an Upcoming/Past card from the appointment's
|
||||
// raw status plus its confirmationStatus (GRO-2319 item 2, CMPO-approved):
|
||||
// - a synthetic waitlist entry (status `waitlisted`) always shows Waitlisted
|
||||
// - an upcoming appointment the groomer has not yet confirmed
|
||||
// (`confirmationStatus === 'pending'`) shows Pending — semantically honest
|
||||
// and reduces anxiety-driven follow-up messages
|
||||
// - otherwise the raw status drives the badge
|
||||
export function deriveDisplayStatus(appt: Appointment): string {
|
||||
if (appt.status === 'waitlisted') return 'waitlisted';
|
||||
if (isUpcoming(appt) && appt.confirmationStatus === 'pending') return 'pending';
|
||||
return appt.status;
|
||||
}
|
||||
|
||||
const CONFIRMATION_STATUS_COLORS: Record<string, string> = {
|
||||
confirmed: 'bg-green-100 text-green-700',
|
||||
pending: 'bg-amber-100 text-amber-700',
|
||||
@@ -462,11 +531,24 @@ function AppointmentCard({
|
||||
sessionId: string | null;
|
||||
onReschedule: (appt: Appointment) => void;
|
||||
}) {
|
||||
// A waitlist-backed entry (GRO-2319 item 2, CMPO UX spec GRO-2328) is not a
|
||||
// confirmed appointment: it gets a muted, dashed-border card and a subtext
|
||||
// line so the customer can tell it apart from booked appointments, and the
|
||||
// appointment-only actions (confirm / notes / reschedule / cancel) are hidden.
|
||||
const isWaitlist = appt.status === 'waitlisted';
|
||||
return (
|
||||
<div className="bg-white rounded-xl border border-stone-200 shadow-sm overflow-hidden">
|
||||
<div
|
||||
className={
|
||||
isWaitlist
|
||||
? 'bg-stone-50/60 rounded-xl border border-dashed border-stone-300 shadow-sm overflow-hidden'
|
||||
: 'bg-white rounded-xl border border-stone-200 shadow-sm overflow-hidden'
|
||||
}
|
||||
>
|
||||
<button
|
||||
onClick={onToggle}
|
||||
className="w-full flex items-center gap-4 p-4 text-left hover:bg-stone-50"
|
||||
className={`w-full flex items-center gap-4 p-4 text-left ${
|
||||
isWaitlist ? 'hover:bg-stone-100/60' : 'hover:bg-stone-50'
|
||||
}`}
|
||||
>
|
||||
<div className="w-10 h-10 rounded-lg bg-blue-100 flex items-center justify-center text-lg shrink-0">
|
||||
{appt.petName?.charAt(0) || 'P'}
|
||||
@@ -486,8 +568,13 @@ function AppointmentCard({
|
||||
</span>
|
||||
<span>with {appt.groomerName || 'First Available'}</span>
|
||||
</div>
|
||||
{isWaitlist && (
|
||||
<p className="text-xs text-stone-400 mt-1">
|
||||
You're on the waitlist — we'll let you know if a spot opens.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<StatusBadge status={appt.status} />
|
||||
<StatusBadge status={deriveDisplayStatus(appt)} />
|
||||
{expanded ? (
|
||||
<ChevronDown size={16} className="text-stone-400" />
|
||||
) : (
|
||||
@@ -521,11 +608,14 @@ function AppointmentCard({
|
||||
{appt.notes}
|
||||
</p>
|
||||
)}
|
||||
{isUpcoming(appt) && !readOnly && (
|
||||
{!isWaitlist && isUpcoming(appt) && !readOnly && (
|
||||
<CustomerNotesSection appointment={appt} sessionId={sessionId} />
|
||||
)}
|
||||
{isUpcoming(appt) && <ConfirmationSection appointment={appt} sessionId={sessionId} />}
|
||||
{appt.status !== 'completed' &&
|
||||
{!isWaitlist && isUpcoming(appt) && (
|
||||
<ConfirmationSection appointment={appt} sessionId={sessionId} />
|
||||
)}
|
||||
{!isWaitlist &&
|
||||
appt.status !== 'completed' &&
|
||||
appt.status !== 'cancelled' &&
|
||||
!readOnly && (
|
||||
<div className="flex gap-2 mt-3">
|
||||
@@ -994,7 +1084,8 @@ export function BookingFlow({ onClose, sessionId }: BookingFlowProps) {
|
||||
|
||||
if (servicesRes.ok) {
|
||||
const servicesData = await servicesRes.json();
|
||||
setServices(servicesData.services || servicesData || []);
|
||||
const rawServices: RawApiService[] = servicesData.services || servicesData || [];
|
||||
setServices(rawServices.map(normalizeService));
|
||||
}
|
||||
} catch {
|
||||
setError('Failed to load data. Please try again.');
|
||||
@@ -1190,10 +1281,14 @@ export function BookingFlow({ onClose, sessionId }: BookingFlowProps) {
|
||||
)}
|
||||
</div>
|
||||
<div className="text-right shrink-0 ml-3">
|
||||
<p className="text-sm font-medium text-stone-700">
|
||||
{svc.priceRange || `$${svc.price}`}
|
||||
</p>
|
||||
<p className="text-xs text-stone-400">{svc.duration} min</p>
|
||||
{formatServicePrice(svc) && (
|
||||
<p className="text-sm font-medium text-stone-700">
|
||||
{formatServicePrice(svc)}
|
||||
</p>
|
||||
)}
|
||||
{typeof svc.duration === 'number' && (
|
||||
<p className="text-xs text-stone-400">{svc.duration} min</p>
|
||||
)}
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
@@ -1226,9 +1321,11 @@ export function BookingFlow({ onClose, sessionId }: BookingFlowProps) {
|
||||
<p className="text-xs text-stone-500">{svc.description}</p>
|
||||
)}
|
||||
</div>
|
||||
<span className="text-stone-600 shrink-0 ml-3">
|
||||
{svc.priceRange || `$${svc.price}`}
|
||||
</span>
|
||||
{formatServicePrice(svc) && (
|
||||
<span className="text-stone-600 shrink-0 ml-3">
|
||||
{formatServicePrice(svc)}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -57,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