Compare commits
92 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 34a5f2b775 | |||
| db11e5f2bd | |||
| 980615b8e6 | |||
| f549101962 | |||
| 62dc85b560 | |||
| bc21d6de09 | |||
| 32ef3bca4d | |||
| 2b494c01f8 | |||
| 3397767a01 | |||
| 47c29ecbc2 | |||
| f0c58c193c | |||
| de7386e47a | |||
| 4600dcf950 | |||
| 7daa9c480a | |||
| 746fad635f | |||
| f1cf58dc56 | |||
| 903ce2d675 | |||
| ec29f71974 | |||
| f29f1828c8 | |||
| bd2a0d9516 | |||
| 3d7b247562 | |||
| 198053fa31 | |||
| 0e5e9d1f16 | |||
| 228a3d746c | |||
| ad9a178c89 | |||
| 9a3b5d88c8 | |||
| 4e487db6f1 | |||
| 3b4d0f15f6 | |||
| 736535a24c | |||
| 87939e5413 | |||
| 33a1b3ed7a | |||
| 65686c8563 | |||
| 112c61ab1c | |||
| 106d31a95e | |||
| 4e3a038bf3 | |||
| 7e5a851d9c | |||
| 88ba9915c6 | |||
| 26cdd69a49 | |||
| 3bccb1ac01 | |||
| 2e99ed520f | |||
| a873369a9b | |||
| d78c859c2b | |||
| 344a32e3e4 | |||
| 8349ea00de | |||
| b630b40c92 | |||
| db892409ef | |||
| c83214cf42 | |||
| 0306c7fbd9 | |||
| 93da2f1dd8 | |||
| 80101fc37c | |||
| 8ee58471b2 | |||
| 35d31a984d | |||
| 62cbfe4e43 | |||
| f62c0b112d | |||
| 034f4ab295 | |||
| f958dbdb4f | |||
| 837b5f6d8a | |||
| 889e1e26ae | |||
| ef6d9d5ab5 | |||
| 5fec0c938a | |||
| 1820f82cfb | |||
| ec17f1e885 | |||
| f1bb7c4fa6 | |||
| 56b11befe9 | |||
| f70dd96c65 | |||
| 42f3e3211a | |||
| f414d2589f | |||
| 465db89ab4 | |||
| db6a2a1bbf | |||
| ee7fc2e9bf | |||
| 032a3796ba | |||
| c8610ec28d | |||
| a582bd04b7 | |||
| d1f8d27d1c | |||
| 6363465069 | |||
| 42d1c5cf34 | |||
| 2ec1b6a14d | |||
| 6132148cb5 | |||
| c047e277b9 | |||
| 29fa0bd02b | |||
| b8eb39e15c | |||
| c7f0635fff | |||
| cac8fc947e | |||
| 90f6a30b74 | |||
| b8a9e8cc09 | |||
| 592be1301c | |||
| 727e1db632 | |||
| e34bfc2933 | |||
| 9408ccff3e | |||
| 00fb7accbd | |||
| 7ee08d42b3 | |||
| 45ed3587ba |
@@ -0,0 +1,7 @@
|
||||
# Ignore untracked .js files containing JSX (build artifacts)
|
||||
src/__tests__/*.js
|
||||
src/portal/sections/*.js
|
||||
src/portal/*.js
|
||||
src/pages/*.js
|
||||
src/components/*.js
|
||||
src/lib/*.js
|
||||
@@ -0,0 +1,102 @@
|
||||
name: CI
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main, dev, uat]
|
||||
pull_request:
|
||||
branches: [main, dev, uat]
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
ref:
|
||||
description: "Branch or ref to run CI against"
|
||||
required: false
|
||||
default: "main"
|
||||
|
||||
jobs:
|
||||
lint-typecheck:
|
||||
name: Lint & Typecheck
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- uses: pnpm/action-setup@v4
|
||||
with:
|
||||
version: '9.15.4'
|
||||
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 20
|
||||
cache: pnpm
|
||||
|
||||
- name: Install dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
- name: Typecheck
|
||||
run: pnpm typecheck
|
||||
|
||||
- name: Lint
|
||||
run: pnpm lint
|
||||
|
||||
test:
|
||||
name: Test
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- uses: pnpm/action-setup@v4
|
||||
with:
|
||||
version: '9.15.4'
|
||||
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 20
|
||||
cache: pnpm
|
||||
|
||||
- name: Install dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
- name: Run tests
|
||||
run: pnpm test
|
||||
|
||||
docker:
|
||||
name: Build & Push Docker Image
|
||||
runs-on: ubuntu-latest
|
||||
needs: [lint-typecheck, test]
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Generate image tag
|
||||
id: version
|
||||
run: |
|
||||
if [ "${{ github.event_name }}" = "pull_request" ]; then
|
||||
TAG="pr-${{ github.event.pull_request.number }}-${GITHUB_SHA::7}"
|
||||
else
|
||||
TAG="$(date -u +%Y.%m.%d)-${GITHUB_SHA::7}"
|
||||
fi
|
||||
echo "tag=$TAG" >> "$GITHUB_OUTPUT"
|
||||
echo "Image tag: $TAG"
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
with:
|
||||
driver-opts: network=host
|
||||
|
||||
- name: Log in to Gitea Container Registry
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: git.farh.net
|
||||
username: ${{ gitea.actor }}
|
||||
password: ${{ secrets.REGISTRY_TOKEN }}
|
||||
|
||||
- name: Build and push Web image
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: .
|
||||
file: Dockerfile
|
||||
push: true
|
||||
provenance: false
|
||||
tags: |
|
||||
git.farh.net/groombook/web:${{ steps.version.outputs.tag }}
|
||||
${{ github.ref == 'refs/heads/main' && 'git.farh.net/groombook/web:latest' || '' }}
|
||||
cache-from: type=registry,ref=git.farh.net/groombook/cache:web
|
||||
cache-to: type=registry,ref=git.farh.net/groombook/cache:web,mode=max
|
||||
@@ -0,0 +1,8 @@
|
||||
node_modules/
|
||||
.env
|
||||
.env.production
|
||||
.env.local
|
||||
dist/
|
||||
playwright-report/
|
||||
test-results/
|
||||
*.log
|
||||
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"mcpServers": {
|
||||
"gitea": {
|
||||
"type": "http",
|
||||
"url": "https://git-mcp.farh.net/mcp",
|
||||
"headers": {
|
||||
"Authorization": "Bearer ${GITEA_TOKEN}"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
FROM node:20-alpine AS base
|
||||
RUN corepack enable && corepack prepare pnpm@9.15.4 --activate
|
||||
WORKDIR /app
|
||||
|
||||
FROM base AS deps
|
||||
COPY package.json pnpm-lock.yaml ./
|
||||
COPY packages/types/package.json packages/types/
|
||||
RUN pnpm install --frozen-lockfile
|
||||
|
||||
FROM deps AS builder
|
||||
COPY packages/types/ packages/types/
|
||||
COPY src/ src/
|
||||
COPY index.html vite.config.ts tsconfig.json ./
|
||||
RUN pnpm build
|
||||
|
||||
FROM nginx:alpine AS runner
|
||||
COPY nginx.conf /etc/nginx/conf.d/default.conf
|
||||
COPY --from=builder /app/dist /usr/share/nginx/html
|
||||
EXPOSE 80
|
||||
HEALTHCHECK --interval=30s --timeout=5s --start-period=5s --retries=3 \
|
||||
CMD wget --spider -q http://localhost:80/ || exit 1
|
||||
@@ -0,0 +1,531 @@
|
||||
# UAT Playbook — GroomBook Web
|
||||
|
||||
## 1. Overview
|
||||
|
||||
GroomBook Web is the React 19 PWA frontend for the GroomBook pet grooming management platform. Built with Vite, it provides the UI for client/pet management, appointment scheduling, invoicing, staff management, and the customer portal. Extracted from the `groombook/app` monorepo.
|
||||
|
||||
## 2. Environments
|
||||
|
||||
| Environment | URL | Purpose |
|
||||
|-------------|-----|---------|
|
||||
| Dev | `https://dev.groombook.dev` | Development environment for daily development |
|
||||
| UAT | `https://uat.groombook.dev` | User Acceptance Testing environment |
|
||||
| Prod | `https://demo.groombook.app` | Production/demo environment |
|
||||
|
||||
## 3. Pre-conditions
|
||||
|
||||
- UAT environment is accessible and running
|
||||
- Test accounts are seeded with appropriate personas (manager, staff, client)
|
||||
- OIDC authentication is configured and functional
|
||||
- GroomBook API service is running and healthy
|
||||
- Required test data exists (clients, pets, appointments, services, staff)
|
||||
|
||||
## 4. Auth Base URL Resolution
|
||||
|
||||
The auth client resolves its API base URL based on the `VITE_API_URL` environment variable:
|
||||
|
||||
- **When `VITE_API_URL` is set:** Uses the configured URL as the auth base URL.
|
||||
- **When `VITE_API_URL` is unset:** Falls back to `window.location.origin`.
|
||||
|
||||
This allows the app to work correctly in both:
|
||||
- **Dev/PR deployments:** Where `VITE_API_URL` is explicitly set to the deployed API endpoint.
|
||||
- **Local development:** Where `VITE_API_URL` is not set, using the same origin as the web app.
|
||||
|
||||
### Auth Client Configuration (src/lib/auth-client.ts)
|
||||
|
||||
```typescript
|
||||
import { createAuthClient } from "better-auth/react";
|
||||
|
||||
export const authClient = createAuthClient({
|
||||
baseURL: import.meta.env.VITE_API_URL ?? "",
|
||||
});
|
||||
|
||||
export const { signIn, signOut, useSession, changePassword } = authClient;
|
||||
```
|
||||
|
||||
## 5. Test Cases
|
||||
|
||||
### 5.1 Authentication UI
|
||||
|
||||
| # | Scenario | Steps | Expected |
|
||||
|---|----------|-------|----------|
|
||||
| TC-WEB-5.1.1 | Login page loads | Navigate to UAT URL | Login form is displayed with OIDC provider button(s) |
|
||||
| TC-WEB-5.1.2 | OIDC redirect | Click OIDC login button | Redirected to OIDC provider, then back to app with session established |
|
||||
| TC-WEB-5.1.3 | Logout | Click logout button | Session cleared, redirected to login page |
|
||||
| TC-WEB-5.1.4 | Session indicator | After successful login | User info/initials visible in UI indicating active session |
|
||||
| TC-WEB-5.1.5 | Unauthenticated `/login` renders the form (GRO-2011) | In a private/incognito window with no session cookie, navigate to UAT `/login` | React root mounts; the GroomBook sign-in card with the OIDC button is visible. Network tab shows `/api/auth/get-session` 200, `/api/setup/status` 200, and the login form is rendered (NOT a blank white viewport). |
|
||||
| TC-WEB-5.1.6 | Swallowed render error surfaces in DOM (GRO-2094) | Trigger a render-time exception in the React tree (e.g. via temporary throw in a child component on a test build) and load `/login` in a clean context | Either the login form renders normally (happy path) OR the top-level `ErrorBoundary` testid `error-boundary` is visible with a populated `error-boundary-message` pre block showing the exception name/message/stack. **NEVER** a blank `<div id="root">` with no error indicator. Browser console must contain either zero render errors or a `[ErrorBoundary]` line plus the raw exception. |
|
||||
| TC-WEB-5.1.7 | Global `error` and `unhandledrejection` listeners are wired (GRO-2094) | In a clean browser context, load `/login`, then trigger `setTimeout(() => { throw new Error("synthetic") }, 0)` from the console and `Promise.reject(new Error("synthetic-promise"))` | Browser console shows `[window.error]` and `[unhandledrejection]` log lines with the thrown values. Confirms global listeners are active in production. |
|
||||
|
||||
### 5.2 Authentication — VITE_API_URL Set
|
||||
|
||||
| # | Scenario | Steps | Expected |
|
||||
|---|----------|-------|----------|
|
||||
| TC-AUTH-5.2.1 | Auth client uses configured API URL | Configure `VITE_API_URL=https://api.example.com`, load app | Auth client sends requests to `https://api.example.com` |
|
||||
| TC-AUTH-5.2.2 | Sign-in flow with configured API | Sign in when `VITE_API_URL` is set | Auth requests go to configured URL |
|
||||
| TC-AUTH-5.2.3 | Sign-out flow with configured API | Sign out when `VITE_API_URL` is set | Auth requests go to configured URL |
|
||||
|
||||
### 5.3 Authentication — VITE_API_URL Unset (Fallback)
|
||||
|
||||
| # | Scenario | Steps | Expected |
|
||||
|---|----------|-------|----------|
|
||||
| TC-AUTH-5.3.1 | Auth client falls back to window.location.origin | Do not set `VITE_API_URL`, load app | Auth client uses `window.location.origin` as base URL |
|
||||
| TC-AUTH-5.3.2 | Sign-in on localhost | Load app without `VITE_API_URL` on localhost:3000 | Auth client uses `http://localhost:3000` as base URL |
|
||||
| TC-AUTH-5.3.3 | Sign-in on dev environment | Load app without `VITE_API_URL` on `https://dev.groombook.dev` | Auth client uses `https://dev.groombook.dev` as base URL |
|
||||
| TC-AUTH-5.3.4 | SSO cookie set after Authentik callback (GRO-1592) | Complete Authentik SSO login on UAT without `VITE_API_URL` set | `__Secure-better-auth.session_token` cookie is present in browser; subsequent `/api/*` calls include the cookie and return 200 |
|
||||
|
||||
### 5.4 Session Persistence
|
||||
|
||||
| # | Scenario | Steps | Expected |
|
||||
|---|----------|-------|----------|
|
||||
| TC-AUTH-5.4.1 | Session persists across page reload | Sign in, reload page | Session remains active |
|
||||
| TC-AUTH-5.4.2 | Session clears on sign-out | Sign in, sign out | User is logged out, redirected to login |
|
||||
|
||||
### 5.4.1 SSO Login Journey (Authentik OIDC end-to-end)
|
||||
|
||||
| # | Scenario | Steps | Pass Criteria | Fail Criteria |
|
||||
|---|----------|-------|---------------|---------------|
|
||||
| TC-WEB-SSO-1 | Sign-in page shows SSO button | Navigate to app root URL | Sign-in page displayed with "Sign in with SSO" button visible | No SSO button, 403 before page loads |
|
||||
| TC-WEB-SSO-2 | Click SSO redirects to Authentik | Click "Sign in with SSO" button | Browser redirected to Authentik login at auth.farh.net | No redirect, error shown, button does nothing |
|
||||
| TC-WEB-SSO-3 | Valid OIDC credentials authenticate | At Authentik, enter valid credentials and authenticate | Redirected back to app with active session | Redirect loop, 403, session not established |
|
||||
| TC-WEB-SSO-4 | Post-login dashboard accessible | After SSO flow completes, dashboard loads | Dashboard displays correctly with user identity shown | Blank page, 403, session not active |
|
||||
| TC-WEB-SSO-5 | User identity displayed correctly | After SSO login, check header/nav | User name/email/initials shown in nav, role reflected in UI | No user indicator, wrong user shown |
|
||||
|
||||
### 5.4.2 OOBE Flow Post-Login
|
||||
|
||||
| # | Scenario | Steps | Pass Criteria | Fail Criteria |
|
||||
|---|----------|-------|---------------|---------------|
|
||||
| TC-WEB-OOBE-1 | Fresh DB shows setup wizard | On fresh DB (no super user), navigate to app | Setup wizard / OOBE screen displayed | Regular login page shown instead of setup |
|
||||
| TC-WEB-OOBE-2 | Configure OIDC via setup | During OOBE, configure OIDC auth provider via /api/setup/auth-provider | OIDC configured successfully, no 403 | 403 during setup, config rejected |
|
||||
| TC-WEB-OOBE-3 | Setup completes and redirects | Complete OOBE setup with business name | Redirected to app dashboard as super user, setup bypassed on reload | Setup errors, wrong redirect, setup reappears |
|
||||
| TC-WEB-OOBE-4 | Admin panel accessible after setup | After completing OOBE, navigate to admin panel | Admin features accessible | 403 on admin panel, insufficient permissions |
|
||||
| TC-WEB-OOBE-5 | SSO login during OOBE does not interfere | During fresh OOBE, attempt SSO login before completing setup | SSO login redirected appropriately, setup can still complete | Auto-provision creates staff prematurely, setup flow broken |
|
||||
|
||||
### 5.5 Dashboard
|
||||
|
||||
| # | Scenario | Steps | Expected |
|
||||
|---|----------|-------|----------|
|
||||
| TC-WEB-5.5.1 | Dashboard loads after login | Complete authentication | Dashboard page loads without errors |
|
||||
| TC-WEB-5.5.2 | Key metrics visible | View dashboard | Revenue, appointments, clients, and other key metrics displayed |
|
||||
| TC-WEB-5.5.3 | No blank state | On fresh login | Dashboard shows meaningful data, not empty/blank state |
|
||||
|
||||
### 5.6 Client Management UI
|
||||
|
||||
| # | Scenario | Steps | Expected |
|
||||
|---|----------|-------|----------|
|
||||
| TC-WEB-5.6.1 | Client list loads | Navigate to Clients section | List of clients is displayed |
|
||||
| TC-WEB-5.6.2 | Create client | Click "New Client", fill form, submit | Client created successfully, appears in list |
|
||||
| TC-WEB-5.6.3 | Edit client | Click on client, modify details, save | Client updated successfully |
|
||||
| TC-WEB-5.6.4 | Search clients | Enter search term in search box | List filters to matching clients |
|
||||
| TC-WEB-5.6.5 | Archive client | Click archive on client record | Client marked as archived, removed from active list |
|
||||
|
||||
### 5.7 Pet Management UI
|
||||
|
||||
| # | Scenario | Steps | Expected |
|
||||
|---|----------|-------|----------|
|
||||
| TC-WEB-5.7.1 | Pet profiles visible | Open client details | All pets for client displayed with basic info |
|
||||
| TC-WEB-5.7.2 | Add pet | Click "Add Pet", fill form, submit | Pet created and linked to client |
|
||||
| TC-WEB-5.7.3 | Edit pet details | Click on pet, modify details, save | Pet updated successfully |
|
||||
| TC-WEB-5.7.4 | Grooming history view | View pet profile | Past appointments/grooming sessions displayed |
|
||||
| TC-WEB-5.7.5 | Add pet with size/coat | Create pet with Size Category and Coat Type filled | Size and coat type persisted, visible on pet profile |
|
||||
| TC-WEB-5.7.6 | Edit pet size/coat | Edit existing pet, change size/coat dropdowns | Updated values saved to pet record |
|
||||
| TC-WEB-5.7.7 | Size/coat optional | Create pet without selecting size or coat | Pet created successfully, fields remain unset |
|
||||
|
||||
### 5.8.1 Buffer Rules Management UI (GRO-1173)
|
||||
|
||||
| # | Scenario | Steps | Expected |
|
||||
|---|----------|-------|----------|
|
||||
| TC-WEB-5.8.2 | Buffer rules section visible | Navigate to Settings | "Buffer Rules" section shown with description |
|
||||
| TC-WEB-5.8.3 | Create buffer rule | Click "+ Add Rule", select service and buffer minutes, submit | Rule appears in list, matches service/size/coat |
|
||||
| TC-WEB-5.8.4 | Edit buffer minutes inline | Click Edit on a rule, change minutes, click Save | New buffer value reflected in list |
|
||||
| TC-WEB-5.8.5 | Delete buffer rule | Click Delete, confirm | Rule removed from list |
|
||||
| TC-WEB-5.8.6 | Create rule with size/coat | Create rule with Size Category or Coat Type specified | Rule shows size/coat tags in list |
|
||||
| TC-WEB-5.8.7 | Empty state | Navigate to Settings with no rules | "No buffer rules configured yet" message shown |
|
||||
|
||||
### 5.8 Appointment Scheduling UI
|
||||
|
||||
| # | Scenario | Steps | Expected |
|
||||
|---|----------|-------|----------|
|
||||
| TC-WEB-5.8.1 | Calendar view loads | Navigate to Appointments | Calendar view displays appointments |
|
||||
| TC-WEB-5.8.2 | Create booking | Click "New Appointment", fill details, submit | Appointment created and appears on calendar |
|
||||
| TC-WEB-5.8.3 | Modify appointment | Click on appointment, change details, save | Appointment updated successfully |
|
||||
| TC-WEB-5.8.4 | Cancel appointment | Click cancel on appointment | Appointment marked as cancelled |
|
||||
| TC-WEB-5.8.5 | Appointment groups | View grouped appointments | Related appointments display as group |
|
||||
|
||||
### 5.9 Service Management UI
|
||||
|
||||
| # | Scenario | Steps | Expected |
|
||||
|---|----------|-------|----------|
|
||||
| TC-WEB-5.9.1 | Service catalog loads | Navigate to Services | List of available services displayed |
|
||||
| TC-WEB-5.9.2 | Create service | Click "New Service", fill form, submit | Service created successfully |
|
||||
| TC-WEB-5.9.3 | Edit service | Click on service, modify details, save | Service updated successfully |
|
||||
| TC-WEB-5.9.4 | Create service with default buffer | Create service with "Default buffer time" filled | Buffer shown in service list and form after save |
|
||||
| TC-WEB-5.9.5 | Edit service buffer | Open existing service, change default buffer minutes | Updated value persisted after save |
|
||||
|
||||
### 5.10 Staff Management UI
|
||||
|
||||
| # | Scenario | Steps | Expected |
|
||||
|---|----------|-------|----------|
|
||||
| TC-WEB-5.10.1 | Staff list loads | Navigate to Staff | List of staff members displayed |
|
||||
| TC-WEB-5.10.2 | Role display | View staff member | Staff role/permissions clearly visible |
|
||||
|
||||
### 5.11 Invoicing & Payments UI
|
||||
|
||||
| # | Scenario | Steps | Expected |
|
||||
|---|----------|-------|----------|
|
||||
| TC-WEB-5.11.1 | Invoice list loads | Navigate to Invoices | List of invoices displayed with status |
|
||||
| TC-WEB-5.11.2 | Payment flow | Click "Pay" on unpaid invoice, complete payment | Payment processed, invoice marked as paid |
|
||||
| TC-WEB-5.11.3 | Receipts view | View paid invoice | Receipt/payment details displayed |
|
||||
|
||||
### 5.12 Customer Portal UI
|
||||
|
||||
| # | Scenario | Steps | Expected |
|
||||
|---|----------|-------|----------|
|
||||
| TC-WEB-5.12.1 | Client-facing view | Log in as client persona | Customer portal UI displayed |
|
||||
| TC-WEB-5.12.2 | Appointment list | View client portal appointments | List of client's appointments visible — each card shows pet name, service, formatted date/time, and groomer (no "Failed to load appointments" error, no blank screen). "Book New" button is visible and clickable. See 5.12d. |
|
||||
| TC-WEB-5.12.3 | Confirm appointment | Click confirm on pending appointment | Appointment status updated to confirmed |
|
||||
| TC-WEB-5.12.4 | Cancel appointment | Click cancel on appointment | Appointment marked as cancelled |
|
||||
|
||||
#### 5.12b Dynamic Portal Time Slots (GRO-1793, GRO-2105)
|
||||
|
||||
| # | Scenario | Steps | Expected |
|
||||
|---|----------|-------|----------|
|
||||
| TC-WEB-5.12.5 | BookingFlow dynamic slots | Open Book New, select pet and service, pick a date | `GET /api/book/availability?serviceId=<selected>&date=<picked>`; "Checking availability…" shown while loading; slot list rendered |
|
||||
| TC-WEB-5.12.6 | BookingFlow slots match wizard | Compare BookingFlow slot times with public booking wizard for same date | Same slots displayed |
|
||||
| TC-WEB-5.12.7 | BookingFlow error state | Mock API failure on availability fetch (4xx/5xx OR a 200 with non-array body) | "Failed to load time slots" error shown and the page stays interactive (no white screen) |
|
||||
| TC-WEB-5.12.8 | BookingFlow no slots | Select date with no availability | "No available slots on this date" shown |
|
||||
| TC-WEB-5.12.9 | RescheduleFlow dynamic slots | Open reschedule, pick a new date | `GET /api/book/availability?serviceId=<appt.serviceId>&date=<picked>`; loading state shown; slot list rendered |
|
||||
| TC-WEB-5.12.10 | RescheduleFlow error state | Mock API failure on availability fetch (4xx/5xx OR a 200 with non-array body) | "Failed to load time slots" error shown and the page stays interactive (no white screen) |
|
||||
| TC-WEB-5.12.11 | RescheduleFlow no slots | Select date with no availability | "No available slots on this date" shown |
|
||||
|
||||
> **GRO-2105 regression note:** prior to the fix, both `BookingFlow` and
|
||||
> `RescheduleFlow` called `/api/book/availability` with only `date=…`, so the
|
||||
> API responded 400 `{error:"serviceId and date are required"}`. The React
|
||||
> handler then `.map()`'d that error object, throwing `TypeError: ee.map is
|
||||
> not a function` and wiping `<div id="root">`. The fix ensures both flows
|
||||
> include `serviceId` in the query string and surface the API's error string
|
||||
> (or "Failed to load time slots") instead of crashing.
|
||||
|
||||
#### 5.12c Waitlist/Booking Status Badges (GRO-1795)
|
||||
|
||||
| # | Scenario | Steps | Expected |
|
||||
|---|----------|-------|----------|
|
||||
| TC-WEB-5.12.12 | Confirmed badge | View appointment card with confirmed status | Green "Confirmed" badge displayed |
|
||||
| TC-WEB-5.12.13 | Pending badge | View appointment card with pending status | Amber "Pending" badge displayed |
|
||||
| TC-WEB-5.12.14 | Waitlisted badge | View appointment card with waitlisted status | Blue "Waitlisted" badge displayed |
|
||||
| TC-WEB-5.12.15 | Badge uses CSS classes | Inspect badge element | Badge uses CSS variable-based classes (e.g., bg-green-100, text-amber-600), not hardcoded colors |
|
||||
| TC-WEB-5.12.16 | Badge status from data | Compare badge label to appointment.status field | Badge label matches the API appointment status exactly |
|
||||
| TC-WEB-5.12.17 | Unknown status fallback | Render badge with unknown status value | Badge renders with the raw status string as label and fallback CSS class |
|
||||
|
||||
#### 5.12d Appointment API Shape Normalization (GRO-2180)
|
||||
|
||||
| # | Scenario | Steps | Expected |
|
||||
|---|----------|-------|----------|
|
||||
| TC-WEB-5.12.18 | Portal appointments load (regression) | Sign in as `uat-customer@groombook.dev`, open `Appointments` | List renders without the "Failed to load appointments. Please try again." error; "Book New" button is visible and clickable |
|
||||
| TC-WEB-5.12.19 | Card fields populated from API | Inspect an appointment card | Pet name, service, formatted date (e.g. "Mon, Jun 1, 2026"), time (e.g. "10:00 AM"), and groomer name render — derived from the API's `startTime`/`endTime`/nested `pet`/`staff` objects |
|
||||
| TC-WEB-5.12.20 | Upcoming vs Past split | View both tabs | Future, non-cancelled/non-completed appointments appear under "Upcoming"; past/completed/cancelled under "Past" (classification uses absolute `startTime`) |
|
||||
| TC-WEB-5.12.21 | Reschedule from card | Expand an upcoming appointment, click Reschedule, pick a date | `GET /api/book/availability?serviceId=<appt.serviceId>&date=<picked>` fires with a non-empty `serviceId` (sourced from the API's nested `service.id`) |
|
||||
|
||||
> **GRO-2180 regression note:** `/api/portal/appointments` returns ISO
|
||||
> `startTime`/`endTime` and nested `pet`/`service`/`staff` objects, but the portal
|
||||
> client `Appointment` type expected flat `date`/`time`/`petName` fields.
|
||||
> `isUpcoming()` read `appt.date`/`appt.time` (both `undefined`), so
|
||||
> `parseTimeTo24Hour(undefined)` threw `TypeError`, the `useEffect` `try/catch`
|
||||
> set the error state, and the "Book New" button (only rendered in the success
|
||||
> path) became unreachable. The fix normalizes the API response into the flat
|
||||
> `Appointment` shape at the fetch boundary (`normalizeAppointment`), prefers the
|
||||
> absolute `startTime` in `isUpcoming`, and hardens `parseTimeTo24Hour` against
|
||||
> blank/undefined input.
|
||||
|
||||
#### 5.12e Book New `preferredTime` Formatting (GRO-2211, GRO-2213)
|
||||
|
||||
| # | Scenario | Steps | Expected |
|
||||
|---|----------|-------|----------|
|
||||
| TC-WEB-5.12.22 | Slot buttons show formatted label | Sign in as `uat-customer@groombook.dev`, open `Appointments`, click "Book New", select a pet and service, pick a date with availability | Each time-slot button shows a human-readable label like `10:00 AM` (UTC), never a raw ISO timestamp (e.g. not `2026-06-09T10:00:00.000Z`) |
|
||||
| TC-WEB-5.12.23 | Confirmation review shows formatted label | Continue the Book New wizard to the Review step | The "Date & Time" summary and the final confirmation both display the formatted slot label (e.g. `10:00 AM`), not a raw ISO string |
|
||||
| TC-WEB-5.12.24 | Booking submit succeeds (regression) | Complete the Book New wizard and submit the request | Request succeeds with no `500` / `invalid input syntax for type time` error; the booking POST sends `preferredTime` as `HH:MM:SS` (e.g. `10:00:00`); the new appointment appears in the Upcoming list |
|
||||
| TC-WEB-5.12.25 | Slow-wizard submit succeeds (GRO-2234) | Sign in as `uat-customer@groombook.dev`, open `Appointments`, click "Book New", then deliberately pace the wizard (pet → service → groomer → date/slot → review) so that **>2 minutes** elapse before clicking "Confirm Booking". | Submit returns success — **no** "Failed to book appointment. Please try again." error. In DevTools → Network, if the first `POST /api/portal/waitlist` returns `401`, a `POST /api/portal/session-from-auth` fires immediately after and the booking is retried once with the fresh `X-Impersonation-Session-Id`, then returns 201. The appointment appears in the Upcoming list. |
|
||||
|
||||
> **GRO-2234 note:** A deliberately-paced Book New wizard could outlive the
|
||||
> portal impersonation session, so the final `POST /api/portal/waitlist` returned
|
||||
> `401 {"error":"Unauthorized"}` ("Failed to book appointment"). The web fix adds
|
||||
> a transparent one-shot re-mint: on a `401` from the waitlist submit,
|
||||
> `BookingFlow` calls `POST /api/portal/session-from-auth` (the Better Auth
|
||||
> cookie is still valid) and retries the submit once with the fresh session id.
|
||||
> The companion API fix (groombook/api GRO-2234) adds bounded sliding expiration
|
||||
> so active sessions rarely lapse in the first place.
|
||||
|
||||
> **GRO-2211/GRO-2213 note:** The Book New wizard previously rendered the raw
|
||||
> UTC ISO slot string as the button/confirmation label and submitted that same
|
||||
> ISO value as `preferredTime`, which the API rejected with
|
||||
> `invalid input syntax for type time` (HTTP 500). The fix adds shared UTC
|
||||
> helpers `formatSlotLabel(slot)` (display → `10:00 AM`) and `slotToTime(slot)`
|
||||
> (payload → `HH:MM:SS`) in `src/portal/sections/Appointments.tsx`, so the
|
||||
> displayed label and the submitted `preferredTime` both derive from the same
|
||||
> canonical UTC ISO slot. (The sibling `RescheduleFlow` `startTime` raw-ISO issue
|
||||
> on a different endpoint is tracked separately and is out of scope here.)
|
||||
|
||||
### 5.13 Reports UI
|
||||
|
||||
| # | Scenario | Steps | Expected |
|
||||
|---|----------|-------|----------|
|
||||
| TC-WEB-5.13.1 | Revenue charts | Navigate to Reports | Revenue charts display with data |
|
||||
| TC-WEB-5.13.2 | Utilization graphs | View reports | Staff/resource utilization graphs visible |
|
||||
|
||||
### 5.14 Settings UI
|
||||
|
||||
| # | Scenario | Steps | Expected |
|
||||
|---|----------|-------|----------|
|
||||
| TC-WEB-5.14.1 | Configuration page | Navigate to Settings | Settings page loads without errors |
|
||||
| TC-WEB-5.14.2 | Form interactions | Modify settings, save | Settings saved successfully, changes reflected |
|
||||
|
||||
### 5.15 Navigation
|
||||
|
||||
| # | Scenario | Steps | Expected |
|
||||
|---|----------|-------|----------|
|
||||
| TC-WEB-5.15.1 | Sidebar/menu links | Click navigation items | Each section loads correctly |
|
||||
| TC-WEB-5.15.2 | All sections reachable | Navigate through all menu items | All sections accessible, no 404 errors |
|
||||
| TC-WEB-5.15.3 | No broken links | Test all navigation paths | All links work, no broken routes |
|
||||
|
||||
### 5.16 Mobile / PWA
|
||||
|
||||
| # | Scenario | Steps | Expected |
|
||||
|---|----------|-------|----------|
|
||||
| TC-WEB-5.16.1 | Responsive at 390x844 | Resize viewport to mobile dimensions | Layout adapts correctly, no horizontal scroll |
|
||||
| TC-WEB-5.16.2 | PWA install prompt | Load app on supported browser | Install prompt appears when criteria met |
|
||||
| TC-WEB-5.16.3 | Touch interactions | Use touch gestures on mobile | All interactions work with touch input |
|
||||
|
||||
### 5.17 Error & Empty States
|
||||
|
||||
| # | Scenario | Steps | Expected |
|
||||
|---|----------|-------|----------|
|
||||
| TC-WEB-5.17.1 | Form validation | Submit form with invalid data | Appropriate validation errors displayed |
|
||||
| TC-WEB-5.17.2 | Missing data | Navigate to section with no data | Empty state message displayed, not blank page |
|
||||
| TC-WEB-5.17.3 | Error boundaries | Trigger error condition | Friendly error message displayed, app doesn't crash |
|
||||
|
||||
### 5.18 Pet Profile UI — Enhanced Fields (GRO-1178)
|
||||
|
||||
| # | Scenario | Steps | Expected |
|
||||
|---|----------|-------|----------|
|
||||
| TC-WEB-5.18.1 | Coat type displayed in Grooming tab | Open pet profile, go to Grooming tab | Coat type shown (e.g. "Curly", "Double") |
|
||||
| TC-WEB-5.18.2 | Preferred cuts displayed | Open Grooming tab | Preferred cuts shown as tags/chips |
|
||||
| TC-WEB-5.18.3 | Temperament score displayed (read-only) | Open Basic Info tab | 1–5 star display with score label "(N/5 · staff-set)" |
|
||||
| TC-WEB-5.18.4 | Temperament flags displayed (read-only) | Open Basic Info tab | Flag chips shown (e.g. "Anxious", "Good with kids") |
|
||||
| TC-WEB-5.18.5 | Medical alerts in Medical tab | Open Medical tab | Alert cards with type, description, severity badge |
|
||||
| TC-WEB-5.18.6 | Medical alert severity badges | View Medical tab | Low=green, Medium=amber, High=red badges |
|
||||
| TC-WEB-5.18.7 | Edit pet — coat type dropdown | Click Edit on pet, select coat type | Coat type persisted on save |
|
||||
| TC-WEB-5.18.8 | Edit pet — add medical alert | Click Edit, add alert with type + severity, save | Alert appears in Medical tab after save |
|
||||
| TC-WEB-5.18.9 | Edit pet — remove medical alert | Click Edit, remove an alert, save | Alert removed after save |
|
||||
| TC-WEB-5.18.10 | Edit pet — add preferred cut (Enter) | Click Edit, type cut name, press Enter | Cut tag added; persists after save |
|
||||
| TC-WEB-5.18.11 | Edit pet — remove preferred cut | Click Edit, click X on cut tag | Cut removed; not persisted after save |
|
||||
| TC-WEB-5.18.12 | Medical alert validation | Click Edit, add alert with empty type, try to save | Error "Type is required"; form not submitted |
|
||||
| TC-WEB-5.18.13 | Temperament fields read-only | View edit form for pet with temperament data | Temperament score and flags not editable (display only) |
|
||||
|
||||
### 5.19 Booking Wizard — Pet Size & Coat (GRO-1174)
|
||||
|
||||
| # | Scenario | Steps | Expected |
|
||||
|---|----------|-------|----------|
|
||||
| TC-WEB-5.19.1 | Pet size dropdown visible | Step 3 of booking wizard (pet details) | Pet size dropdown shown after breed field with options: Small, Medium, Large, X-Large |
|
||||
| TC-WEB-5.19.2 | Coat type dropdown visible | Step 3 of booking wizard | Coat type dropdown shown after pet size with options: Smooth, Double, Curly, Wire, Long, Hairless |
|
||||
| TC-WEB-5.19.3 | Size/coat pre-fill from URL | Navigate to booking with `?petSizeCategory=large&petCoatType=curly` | Fields pre-filled with provided values |
|
||||
| TC-WEB-5.19.4 | Size/coat optional | Proceed through booking without selecting size/coat | Booking completes successfully |
|
||||
| TC-WEB-5.19.5 | Confirmation shows appointment duration | Confirm booking step | Service duration shown as "X min appointment" (buffer not exposed) |
|
||||
| TC-WEB-5.19.6 | Confirmation shows pet size/coat | Confirm booking with size/coat selected | Size and coat type shown on pet card in confirmation |
|
||||
| TC-WEB-5.19.7 | Availability uses buffer for large/x-large | Select large or x-large size, check availability | Availability slots reflect service duration + buffer for large/x-large |
|
||||
| TC-WEB-5.19.8 | Form reset clears size/coat | Complete booking, click "Book another" | Size and coat fields reset to empty |
|
||||
| TC-WEB-5.19.9 | New pet record has size/coat | Complete booking, view created pet in admin | Pet record shows selected size and coat type |
|
||||
|
||||
### 5.20 Buffer Rules Management — Admin UI (GRO-1173)
|
||||
|
||||
| # | Scenario | Steps | Expected |
|
||||
|---|----------|-------|----------|
|
||||
| TC-WEB-5.20.1 | Buffer rules section loads | Navigate to Settings page (admin) | "Buffer Rules" section visible with "+ Add Rule" button |
|
||||
| TC-WEB-5.20.2 | Add rule — required fields only | Click "+ Add Rule", select a service, enter buffer minutes, submit | Rule created, appears in list below |
|
||||
| TC-WEB-5.20.3 | Add rule — with size category | Add rule, select service + size category + buffer minutes | Rule created with size tag shown in list |
|
||||
| TC-WEB-5.20.4 | Add rule — with coat type | Add rule, select service + coat type + buffer minutes | Rule created with coat tag shown in list |
|
||||
| TC-WEB-5.20.5 | Add rule — with both size and coat | Add rule, select service + size + coat + buffer minutes | Rule created with both tags shown |
|
||||
| TC-WEB-5.20.6 | Validation — missing service | Submit form without selecting service | Error: "Service and valid buffer minutes are required" |
|
||||
| TC-WEB-5.20.7 | Validation — zero buffer | Submit form with 0 buffer minutes | Error: "Service and valid buffer minutes are required" |
|
||||
| TC-WEB-5.20.8 | Edit rule inline | Click "Edit" on a rule, change buffer value, click "Save" | Rule updated in list |
|
||||
| TC-WEB-5.20.9 | Cancel edit | Click "Edit", then "Cancel" | Original value unchanged |
|
||||
| TC-WEB-5.20.10 | Delete rule — confirmation | Click "Delete" on a rule | Confirmation prompt appears |
|
||||
| TC-WEB-5.20.11 | Confirm delete | On confirmation prompt, click "Confirm" | Rule removed from list |
|
||||
| TC-WEB-5.20.12 | Cancel delete | On confirmation prompt, click "Cancel" | Rule remains in list |
|
||||
| TC-WEB-5.20.13 | Empty state | No rules exist | Message: "No buffer rules configured yet." |
|
||||
| TC-WEB-5.20.14 | Toggle form | Click "+ Add Rule", then "Cancel" | Form hidden, no rule created |
|
||||
|
||||
### 5.21 Service Default Buffer Minutes (GRO-1173)
|
||||
|
||||
| # | Scenario | Steps | Expected |
|
||||
|---|----------|-------|----------|
|
||||
| TC-WEB-5.21.1 | Default buffer shown in table | Navigate to Services page | "Default Buffer" column visible in services table |
|
||||
| TC-WEB-5.21.2 | New service default is 0 | Click "+ Add Service" | Default Buffer field pre-filled with 0 |
|
||||
| TC-WEB-5.21.3 | Create service with buffer | Fill service form, set Default Buffer = 10, submit | Service created with 10 min default buffer |
|
||||
| TC-WEB-5.21.4 | Edit service — view buffer | Edit an existing service | Current default buffer value shown in form |
|
||||
| TC-WEB-5.21.5 | Update buffer on existing service | Edit service, change Default Buffer to 15, save | Buffer updated, table shows 15 min |
|
||||
| TC-WEB-5.21.6 | Buffer field — zero allowed | Set Default Buffer to 0, save | Service saved with 0 (no default buffer) |
|
||||
| TC-WEB-5.21.7 | Buffer field — integer only | Enter non-integer value | Field restricts to integer values |
|
||||
|
||||
### 5.22 Pet Profile — Size Category & Coat Type (GRO-1173)
|
||||
|
||||
| # | Scenario | Steps | Expected |
|
||||
|---|----------|-------|----------|
|
||||
| TC-WEB-5.22.1 | Size category dropdown visible | Open Add Pet or Edit Pet form (portal) | "Size Category" dropdown visible with options: Small, Medium, Large, X-Large |
|
||||
| TC-WEB-5.22.2 | Coat type dropdown visible | Open Add Pet or Edit Pet form | "Coat Type" dropdown visible with options: Smooth, Double, Curly, Wire, Long, Hairless |
|
||||
| TC-WEB-5.22.3 | Size and coat both optional | Submit pet form without selecting size or coat | Pet saved successfully |
|
||||
| TC-WEB-5.22.4 | Save pet with size category | Select "Large", fill required fields, save | Pet saved with size = "large" |
|
||||
| TC-WEB-5.22.5 | Save pet with coat type | Select "Curly", fill required fields, save | Pet saved with coat = "curly" |
|
||||
| TC-WEB-5.22.6 | Size and coat persisted | Save pet with size + coat, edit again | Both fields retain their selected values |
|
||||
| TC-WEB-5.22.7 | Clear size | Select size, then clear back to default | Size cleared on save |
|
||||
|
||||
### 5.23 Pet Profile — API Persistence & Save UX (GRO-1470)
|
||||
|
||||
| # | Scenario | Steps | Expected |
|
||||
|---|----------|-------|----------|
|
||||
| TC-WEB-5.23.1 | Save pet — API persistence | Edit a pet, change a field (e.g. coat type), click Save, reload the page | Changed field retained after reload (proves PATCH round-trip to server) |
|
||||
| TC-WEB-5.23.2 | Save pet — error state | Trigger an API save failure (e.g. network error) | Error message displayed; edit form stays open; no data cleared |
|
||||
| TC-WEB-5.23.3 | Save pet — saving indicator | Click Save | Spinner/indicator shown while request is in flight; form controls disabled |
|
||||
|
||||
|
||||
### 5.24 Booking Funnel Analytics Events (GRO-1794)
|
||||
|
||||
|
||||
| # | Scenario | Steps | Expected |
|
||||
|---|----------|-------|----------|
|
||||
| TC-WEB-5.24.1 | booking_step_service — public | Select a service in the public booking wizard | `booking_step_service` CustomEvent fires with detail.step="service" and detail.flow="public" |
|
||||
| TC-WEB-5.24.2 | booking_step_time — public | Select a time slot and click Continue | `booking_step_time` fires with detail.step="time" and detail.flow="public" |
|
||||
| TC-WEB-5.24.3 | booking_step_contact — public | Fill in contact/pet form, click "Review booking" | `booking_step_contact` fires with detail.step="contact" and detail.flow="public" |
|
||||
| TC-WEB-5.24.4 | booking_step_submit — public | Confirm and submit the booking | `booking_step_submit` fires with detail.step="submit" and detail.flow="public" |
|
||||
| TC-WEB-5.24.5 | booking_confirmed — public | Navigate to /booking-confirmed | `booking_confirmed` fires once on mount with detail.step="confirmed" and detail.flow="public" |
|
||||
| TC-WEB-5.24.6 | booking_error — public | Navigate to /booking-error | `booking_error` fires once on mount with detail.step="error" and detail.flow="public" |
|
||||
| TC-WEB-5.24.7 | booking_step_service — portal | Select a pet in the portal BookingFlow | `booking_step_service` fires with detail.step="service" and detail.flow="portal" |
|
||||
| TC-WEB-5.24.8 | booking_step_time — portal | Pick a date and time in portal BookingFlow | `booking_step_time` fires with detail.step="time" and detail.flow="portal" |
|
||||
| TC-WEB-5.24.9 | booking_step_contact — portal | Proceed from groomer selection to review screen | `booking_step_contact` fires with detail.step="groomer" and detail.flow="portal" |
|
||||
| TC-WEB-5.24.10 | booking_step_submit — portal | Submit booking in portal BookingFlow | `booking_step_submit` fires with detail.step="submit" and detail.flow="portal" |
|
||||
| TC-WEB-5.24.11 | booking_confirmed — portal | Portal booking request succeeds | Inline success state is shown and `booking_confirmed` fires with detail.step="confirmed" and detail.flow="portal" |
|
||||
| TC-WEB-5.24.12 | No PII in analytics payloads | Fire each event and inspect detail object | Payload contains only: step, flow, timestamp — no names, emails, phone numbers, or pet names |
|
||||
| TC-WEB-5.24.13 | No-op safe | Trigger analytics with window.dispatchEvent blocked (e.g. CSP) | No error thrown; booking flow completes normally |
|
||||
|
||||
### 5.25 Customer Portal — Better Auth SSO Bridge (GRO-1867)
|
||||
|
||||
These cases cover the `CustomerPortal` initialisation path that bridges an Authentik / Better Auth session into a portal session via `POST /api/portal/session-from-auth`. The bridge runs after the URL-impersonation (`?sessionId=`) and dev-user paths have been ruled out.
|
||||
|
||||
**Pre-conditions:**
|
||||
|
||||
- UAT is configured with Authentik SSO. The seeded customer **Authentik** password lives in the `authentik-uat-users-credentials` Secret in the `groombook-uat` namespace (key `uat_customer_password`) — **NOT** in `seed-uat-passwords:customer-password` (that Secret holds the *Better Auth* email+password credential, a separate identity store; see GRO-2089). Pull the Authentik password at the start of every run:
|
||||
```bash
|
||||
CUSTOMER_AUTHENTIK=$(kubectl get secret authentik-uat-users-credentials -n groombook-uat \
|
||||
-o jsonpath='{.data.uat_customer_password}' | base64 -d)
|
||||
```
|
||||
The Authentik user is provisioned by Terraform (`infra/terraform/users.tf`); the `lifecycle.ignore_changes = [password]` block means the password is set on initial creation and never auto-rotated, so the value held in the live Secret is the one Authentik itself has. If Authentik rejects it, the user was re-provisioned out-of-band via the Authentik admin UI and the Secret has drifted from the live identity — fix the Secret (or the admin-set password) and re-run.
|
||||
- `POST /api/portal/session-from-auth` from [GRO-1866](https://paperclip.farhoodlabs.com/GRO/issues/GRO-1866) is deployed on UAT.
|
||||
- Clear cookies and localStorage between cases unless otherwise noted.
|
||||
|
||||
| # | Scenario | Steps | Expected |
|
||||
|---|----------|-------|----------|
|
||||
| TC-WEB-5.25.1 | Authenticated customer reaches portal dashboard | 1. From clean state, navigate to UAT `/login`. 2. Click "Sign in with SSO" and complete Authentik flow with a seeded **customer** identity. 3. After callback, land on `/`. | Portal dashboard renders. No redirect to `/login`. No impersonation banner. Top-right greeting reads "Hi, <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 fallback for authenticated user with no client record | 1. Sign in via SSO with an Authentik account whose email is **not** present in `clients`. 2. Land on `/`. | `POST /api/portal/session-from-auth` returns 404. The portal renders a centred card titled **"Portal access not configured"** with the message about contacting the groomer and a **Sign out** button. No redirect loop, no portal chrome. |
|
||||
| TC-WEB-5.25.6 | 404 fallback Sign-out escape hatch | From TC-WEB-5.25.5 click **Sign out**. | `POST /api/auth/sign-out` fires; browser navigates to `/login`; the Authentik session cookie is cleared. Reloading `/` no longer hits 404 (will show the login page). |
|
||||
| TC-WEB-5.25.7 | Bridge precedence — impersonation URL wins | 1. Sign in via SSO as a customer. 2. Open a new tab to `https://uat.groombook.dev/?sessionId=<a-valid-staff-impersonation-session-id>`. | The impersonation path runs; the amber banner appears for the impersonated client. The Better Auth bridge is **not** called on this load (`session-from-auth` absent in Network). |
|
||||
| TC-WEB-5.25.8 | Bridge precedence — dev user wins | In dev mode (e.g. local) with `localStorage["dev-user"]` set to a client persona, navigate to `/`. | The dev-session path runs (`POST /api/portal/dev-session`). The Better Auth bridge is **not** called (`session-from-auth` absent in Network). Staff dev users still redirect to `/admin`. |
|
||||
| TC-WEB-5.25.9 | Staff Better Auth session does not run the customer bridge | Sign in via SSO with a staff identity. Navigate to `/`. | `App.tsx` routing redirects to `/admin`. `POST /api/portal/session-from-auth` is **not** called. |
|
||||
| TC-WEB-5.25.10 | Unauthenticated user is sent to login (no infinite loop) | Without signing in, navigate directly to `/`. | `App.tsx` renders the LoginPage. `CustomerPortal` does not render. No `session-from-auth` request is made. |
|
||||
| TC-WEB-5.25.11 | Session persists across reload via Better Auth cookie | After TC-WEB-5.25.1 succeeds, reload the page. | Portal dashboard re-renders. A fresh `GET /api/auth/get-session` + `POST /api/portal/session-from-auth` pair runs and yields 200/201. Greeting still reads "Hi, <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). |
|
||||
|
||||
## 6. Pass/Fail Criteria
|
||||
|
||||
**Pass:**
|
||||
- All test cases execute without errors
|
||||
- Expected results match actual results for all scenarios
|
||||
- No visual regressions compared to baseline
|
||||
- No console errors or warnings in browser DevTools
|
||||
|
||||
**Fail:**
|
||||
- Any unexpected result with severity
|
||||
- Steps to reproduce provided
|
||||
- Screenshot or screen recording of failure
|
||||
- Error details from browser console or network tab
|
||||
|
||||
## 7. Update Policy
|
||||
|
||||
**Any PR that changes user-facing behaviour MUST update this file.**
|
||||
|
||||
When modifying the GroomBook Web application in ways that affect the user interface or user experience:
|
||||
1. Review all relevant test cases in this playbook
|
||||
2. Add new test cases for new features or flows
|
||||
3. Modify existing test cases if behaviour changes
|
||||
4. Remove test cases for deprecated features
|
||||
5. Reference the updated section(s) in the PR description (e.g., "Updated UAT_PLAYBOOK.md §5.5 — new appointment group feature")
|
||||
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"name": "@groombook/web-e2e",
|
||||
"version": "0.0.1",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"test": "playwright test",
|
||||
"test:ui": "playwright test --ui",
|
||||
"test:report": "playwright show-report"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@playwright/test": "^1.50.1"
|
||||
},
|
||||
"license": "AGPL-3.0-only"
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import { defineConfig, devices } from "@playwright/test";
|
||||
|
||||
/**
|
||||
* Playwright configuration for GroomBook Web E2E tests.
|
||||
*
|
||||
* Targets the deployed dev environment at dev.groombook.dev.
|
||||
* Uses the dev login selector (/login) for authentication — no hardcoded credentials.
|
||||
*
|
||||
* Run locally:
|
||||
* pnpm --filter @groombook/web-e2e test
|
||||
*
|
||||
* CI: Runs on every PR targeting main, blocking merge on failure.
|
||||
*/
|
||||
export default defineConfig({
|
||||
testDir: "./tests",
|
||||
timeout: 30_000,
|
||||
retries: process.env.CI ? 2 : 0,
|
||||
workers: process.env.CI ? 1 : undefined,
|
||||
reporter: process.env.CI ? "github" : "list",
|
||||
|
||||
use: {
|
||||
baseURL: "https://dev.groombook.dev",
|
||||
trace: "on-first-retry",
|
||||
screenshot: "only-on-failure",
|
||||
serviceWorkers: "block",
|
||||
},
|
||||
|
||||
projects: [
|
||||
{
|
||||
name: "chromium",
|
||||
use: { ...devices["Desktop Chrome"] },
|
||||
},
|
||||
],
|
||||
});
|
||||
@@ -0,0 +1,50 @@
|
||||
import { test, expect } from "./fixtures.js";
|
||||
|
||||
/**
|
||||
* E2E test: Reports Data (GRO-306)
|
||||
*
|
||||
* Verifies that the reports page loads with non-zero data when date range
|
||||
* is set to the last 60 days.
|
||||
*
|
||||
* This test runs against current dev state (no GRO-300 dependency).
|
||||
* NOTE: Skipped because dev environment may have no report data in the last 60 days.
|
||||
*/
|
||||
test.describe("Admin Reports Data", () => {
|
||||
test.skip("reports page shows non-zero data for last 60 days", async ({
|
||||
staffPage,
|
||||
}) => {
|
||||
await staffPage.goto("/admin/reports");
|
||||
await staffPage.waitForLoadState("networkidle");
|
||||
|
||||
// Wait for reports to load
|
||||
await expect(staffPage.getByRole("heading", { name: "Reports" })).toBeVisible({ timeout: 10000 });
|
||||
|
||||
// Calculate 60 days ago date
|
||||
const today = new Date();
|
||||
const sixtyDaysAgo = new Date();
|
||||
sixtyDaysAgo.setDate(today.getDate() - 60);
|
||||
|
||||
const formatDate = (d: Date) => d.toISOString().slice(0, 10);
|
||||
|
||||
// Set the date range to last 60 days
|
||||
// The page has "From" and "To" date inputs
|
||||
const fromInput = staffPage.locator('input[type="date"]').first();
|
||||
const toInput = staffPage.locator('input[type="date"]').nth(1);
|
||||
|
||||
await fromInput.fill(formatDate(sixtyDaysAgo));
|
||||
await toInput.fill(formatDate(today));
|
||||
|
||||
// Click Refresh to reload the report
|
||||
await staffPage.getByRole("button", { name: /refresh/i }).click();
|
||||
|
||||
// Wait for data to reload
|
||||
await staffPage.waitForLoadState("networkidle");
|
||||
await staffPage.waitForTimeout(1000);
|
||||
|
||||
// Verify StatCards render with values (may be $0 or 0 in dev with no data)
|
||||
// The StatCards show: Revenue, Appointments, No-shows, Cancellations, New Clients
|
||||
const statCardValues = staffPage.locator('[style*="fontSize: 26"]');
|
||||
const count = await statCardValues.count();
|
||||
expect(count).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,94 @@
|
||||
import { test, expect } from "./fixtures.js";
|
||||
|
||||
/**
|
||||
* E2E test: Services Deduplication (GRO-306)
|
||||
*
|
||||
* Verifies there are no duplicate service names in:
|
||||
* 1. The admin services table (/admin/services)
|
||||
* 2. The booking wizard service picker (/admin/book)
|
||||
*
|
||||
* This test runs against current dev state (no GRO-300 dependency).
|
||||
*/
|
||||
test.describe("Admin Services Deduplication", () => {
|
||||
test.skip("admin services table has no duplicate names", async ({
|
||||
staffPage,
|
||||
}) => {
|
||||
await staffPage.goto("/admin/services");
|
||||
await staffPage.waitForLoadState("networkidle");
|
||||
|
||||
// Wait for the table to load
|
||||
await expect(staffPage.locator("table")).toBeVisible({ timeout: 10000 });
|
||||
|
||||
// Collect all service name cells from the Name column (first column)
|
||||
const nameCells = staffPage.locator("table tbody tr td:first-child");
|
||||
const count = await nameCells.count();
|
||||
expect(count).toBeGreaterThan(0);
|
||||
|
||||
const names: string[] = [];
|
||||
for (let i = 0; i < count; i++) {
|
||||
const text = (await nameCells.nth(i).textContent())?.trim() ?? "";
|
||||
names.push(text);
|
||||
}
|
||||
|
||||
// Check for duplicates
|
||||
const seen = new Set<string>();
|
||||
const duplicates: string[] = [];
|
||||
for (const name of names) {
|
||||
if (name === "—") continue; // skip empty/placeholder
|
||||
if (seen.has(name)) {
|
||||
duplicates.push(name);
|
||||
}
|
||||
seen.add(name);
|
||||
}
|
||||
|
||||
expect(duplicates).toHaveLength(0);
|
||||
});
|
||||
|
||||
test.skip("booking wizard service picker has no duplicate names", async ({
|
||||
staffPage,
|
||||
}) => {
|
||||
await staffPage.goto("/admin/book");
|
||||
await staffPage.waitForLoadState("networkidle");
|
||||
|
||||
// Wait for services to load
|
||||
await expect(
|
||||
staffPage.getByText("Choose a service")
|
||||
).toBeVisible({ timeout: 10000 });
|
||||
|
||||
// Wait a bit for the services to render
|
||||
await staffPage.waitForTimeout(1000);
|
||||
|
||||
// Collect all service names from the service cards
|
||||
// Each service card shows the name in a div with fontWeight 600
|
||||
const serviceNames = await staffPage
|
||||
.locator("text=/^[^-].*$/") // rough: get text nodes that aren't empty
|
||||
.all();
|
||||
|
||||
// More precise: get service name elements from the service cards
|
||||
// The service cards have div > div:first-child with the name
|
||||
const cards = staffPage.locator('[role="button"]');
|
||||
const cardCount = await cards.count();
|
||||
expect(cardCount).toBeGreaterThan(0);
|
||||
|
||||
const names: string[] = [];
|
||||
for (let i = 0; i < cardCount; i++) {
|
||||
const card = cards.nth(i);
|
||||
// The name is in the first child div with fontWeight 600
|
||||
const nameEl = card.locator("div").first();
|
||||
const text = (await nameEl.textContent())?.trim() ?? "";
|
||||
if (text) names.push(text);
|
||||
}
|
||||
|
||||
// Check for duplicates
|
||||
const seen = new Set<string>();
|
||||
const duplicates: string[] = [];
|
||||
for (const name of names) {
|
||||
if (seen.has(name)) {
|
||||
duplicates.push(name);
|
||||
}
|
||||
seen.add(name);
|
||||
}
|
||||
|
||||
expect(duplicates).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,121 @@
|
||||
import { test, expect } from "./fixtures.js";
|
||||
|
||||
/**
|
||||
* E2E test: Baseline Console Health (GRO-306)
|
||||
*
|
||||
* Verifies baseline console health on initial page load for both
|
||||
* admin and portal views:
|
||||
* - No 404s for favicon or PWA assets
|
||||
* - No uncaught JS exceptions on initial render
|
||||
*
|
||||
* This test runs against current dev state (no GRO-300 dependency).
|
||||
*/
|
||||
test.describe("Baseline Console Health", () => {
|
||||
test("admin page has no console errors on initial load", async ({
|
||||
staffPage,
|
||||
}) => {
|
||||
const errors: string[] = [];
|
||||
const failedRequests: string[] = [];
|
||||
|
||||
staffPage.on("console", (msg) => {
|
||||
if (msg.type() === "error") {
|
||||
errors.push(msg.text());
|
||||
}
|
||||
});
|
||||
|
||||
staffPage.on("requestfailed", (request) => {
|
||||
const url = request.url();
|
||||
// Only care about asset failures, not API failures (which may be expected in dev)
|
||||
if (
|
||||
url.includes("favicon") ||
|
||||
url.includes(".ico") ||
|
||||
url.includes("manifest") ||
|
||||
url.includes(".js") ||
|
||||
url.includes(".css") ||
|
||||
url.includes(".png") ||
|
||||
url.includes(".svg")
|
||||
) {
|
||||
failedRequests.push(`${request.failure()?.errorText} — ${url}`);
|
||||
}
|
||||
});
|
||||
|
||||
await staffPage.goto("/admin");
|
||||
await staffPage.waitForLoadState("networkidle");
|
||||
|
||||
// Filter out non-critical errors
|
||||
const criticalErrors = errors.filter(
|
||||
(e) =>
|
||||
!e.includes("favicon") &&
|
||||
!e.includes("net::ERR_") &&
|
||||
!e.includes("Failed to load resource")
|
||||
);
|
||||
|
||||
expect(criticalErrors).toHaveLength(0);
|
||||
expect(failedRequests).toHaveLength(0);
|
||||
});
|
||||
|
||||
test("portal page has no console errors on initial load", async ({
|
||||
clientPage,
|
||||
}) => {
|
||||
const errors: string[] = [];
|
||||
const failedRequests: string[] = [];
|
||||
|
||||
clientPage.on("console", (msg) => {
|
||||
if (msg.type() === "error") {
|
||||
errors.push(msg.text());
|
||||
}
|
||||
});
|
||||
|
||||
clientPage.on("requestfailed", (request) => {
|
||||
const url = request.url();
|
||||
if (
|
||||
url.includes("favicon") ||
|
||||
url.includes(".ico") ||
|
||||
url.includes("manifest") ||
|
||||
url.includes(".js") ||
|
||||
url.includes(".css") ||
|
||||
url.includes(".png") ||
|
||||
url.includes(".svg")
|
||||
) {
|
||||
failedRequests.push(`${request.failure()?.errorText} — ${url}`);
|
||||
}
|
||||
});
|
||||
|
||||
await clientPage.goto("/");
|
||||
await clientPage.waitForLoadState("networkidle");
|
||||
|
||||
const criticalErrors = errors.filter(
|
||||
(e) =>
|
||||
!e.includes("favicon") &&
|
||||
!e.includes("net::ERR_") &&
|
||||
!e.includes("Failed to load resource")
|
||||
);
|
||||
|
||||
expect(criticalErrors).toHaveLength(0);
|
||||
expect(failedRequests).toHaveLength(0);
|
||||
});
|
||||
|
||||
test("no 404s for favicon or PWA assets", async ({ staffPage }) => {
|
||||
const notFound: string[] = [];
|
||||
|
||||
staffPage.on("response", (response) => {
|
||||
const status = response.status();
|
||||
const url = response.url();
|
||||
if (
|
||||
status === 404 &&
|
||||
(url.includes("favicon") ||
|
||||
url.includes(".ico") ||
|
||||
url.includes("manifest") ||
|
||||
url.includes("sw.js") ||
|
||||
url.includes("workbox"))
|
||||
) {
|
||||
notFound.push(url);
|
||||
}
|
||||
});
|
||||
|
||||
await staffPage.goto("/admin");
|
||||
await staffPage.waitForLoadState("networkidle");
|
||||
|
||||
expect(notFound).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,111 @@
|
||||
import { test as base, Page, Browser, BrowserContext } from "@playwright/test";
|
||||
import path from "path";
|
||||
import fs from "fs";
|
||||
|
||||
const STAFF_STORAGE = path.join(process.cwd(), ".auth/staff.json");
|
||||
const CLIENT_STORAGE = path.join(process.cwd(), ".auth/client.json");
|
||||
|
||||
/**
|
||||
* Authenticates as a staff user via the dev login selector and saves storage state.
|
||||
*/
|
||||
async function authenticateStaff(browser: Browser): Promise<string> {
|
||||
const context = await browser.newContext();
|
||||
const page = await context.newPage();
|
||||
|
||||
await page.goto("/login");
|
||||
|
||||
// Click "Alice Groomer" (first staff user)
|
||||
const alice = page.getByText("Alice Groomer");
|
||||
if (await alice.isVisible({ timeout: 5000 })) {
|
||||
await alice.click();
|
||||
} else {
|
||||
// Fallback: click any staff user
|
||||
await page.getByText(/groomer|manager/i).first().click();
|
||||
}
|
||||
|
||||
await page.waitForURL(/\/(admin|portal)/, { timeout: 10000 });
|
||||
|
||||
const storageState = await context.storageState();
|
||||
await context.close();
|
||||
return JSON.stringify(storageState);
|
||||
}
|
||||
|
||||
/**
|
||||
* Authenticates as a client via the dev login selector and saves storage state.
|
||||
*/
|
||||
async function authenticateClient(browser: Browser): Promise<string> {
|
||||
const context = await browser.newContext();
|
||||
const page = await context.newPage();
|
||||
|
||||
await page.goto("/login");
|
||||
|
||||
// Click "Carol Client" (first client user)
|
||||
const carol = page.getByText("Carol Client");
|
||||
if (await carol.isVisible({ timeout: 5000 })) {
|
||||
await carol.click();
|
||||
} else {
|
||||
// Fallback: click any client user
|
||||
await page.getByText(/\d+ pets?/i).first().click();
|
||||
}
|
||||
|
||||
await page.waitForURL(/\//, { timeout: 10000 });
|
||||
await page.waitForLoadState("networkidle");
|
||||
|
||||
const storageState = await context.storageState();
|
||||
await context.close();
|
||||
return JSON.stringify(storageState);
|
||||
}
|
||||
|
||||
export type UserType = "staff" | "client";
|
||||
|
||||
/**
|
||||
* Returns the storage state file path for the given user type.
|
||||
* Creates the auth file if it doesn't exist.
|
||||
*/
|
||||
async function getStorageState(browser: Browser, userType: UserType): Promise<string> {
|
||||
const filePath = userType === "staff" ? STAFF_STORAGE : CLIENT_STORAGE;
|
||||
const dir = path.dirname(filePath);
|
||||
|
||||
if (!fs.existsSync(filePath)) {
|
||||
const state =
|
||||
userType === "staff"
|
||||
? await authenticateStaff(browser)
|
||||
: await authenticateClient(browser);
|
||||
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
fs.writeFileSync(filePath, state);
|
||||
}
|
||||
|
||||
return filePath;
|
||||
}
|
||||
|
||||
/**
|
||||
* Custom test fixture that provides an authenticated page for E2E tests.
|
||||
* Automatically handles login via the dev login selector.
|
||||
*
|
||||
* Usage:
|
||||
* test("my test", async ({ staffPage }) => { ... }); // staff user
|
||||
* test("my test", async ({ clientPage }) => { ... }); // client user
|
||||
*/
|
||||
export const test = base.extend<{
|
||||
staffPage: Page;
|
||||
clientPage: Page;
|
||||
}>({
|
||||
staffPage: async ({ browser }, use, workerInfo): Promise<void> => {
|
||||
const storageStatePath = await getStorageState(browser, "staff");
|
||||
const context = await browser.newContext({ storageState: storageStatePath });
|
||||
const page = await context.newPage();
|
||||
await use(page);
|
||||
await context.close();
|
||||
},
|
||||
|
||||
clientPage: async ({ browser }, use, workerInfo): Promise<void> => {
|
||||
const storageStatePath = await getStorageState(browser, "client");
|
||||
const context = await browser.newContext({ storageState: storageStatePath });
|
||||
const page = await context.newPage();
|
||||
await use(page);
|
||||
await context.close();
|
||||
},
|
||||
});
|
||||
|
||||
export { expect } from "@playwright/test";
|
||||
@@ -0,0 +1,61 @@
|
||||
import { test, expect } from "./fixtures.js";
|
||||
|
||||
/**
|
||||
* E2E test: Client Portal Auth (GRO-306 / GRO-300)
|
||||
*
|
||||
* Verifies that after logging in as a client via the dev login selector,
|
||||
* the portal displays the client's actual name (not "Hi, Guest" or "Please sign in").
|
||||
*
|
||||
* DEPENDENCY: Requires GRO-300 to be deployed to dev. This test will only
|
||||
* pass once the portal auth fix (proper session → customer name resolution) lands.
|
||||
*
|
||||
* Journey:
|
||||
* 1. Navigate to /login
|
||||
* 2. Select a client (Carol Client or any available client)
|
||||
* 3. Navigate to /
|
||||
* 4. Assert: heading contains client name (NOT "Hi, Guest" or "Please sign in")
|
||||
* 5. Assert: portal dashboard section renders with actual content
|
||||
*/
|
||||
test.describe("Client Portal Auth", () => {
|
||||
test.skip("portal shows client name after login, not 'Hi, Guest'", async ({
|
||||
clientPage,
|
||||
}) => {
|
||||
await clientPage.goto("/");
|
||||
|
||||
// Wait for the portal to fully load
|
||||
await clientPage.waitForLoadState("networkidle");
|
||||
|
||||
// The portal heading should contain the logged-in client's name, not "Guest"
|
||||
// We check for either the client name being present OR the anti-patterns being absent
|
||||
const bodyText = await clientPage.textContent("body");
|
||||
|
||||
// Assert the anti-patterns are NOT present
|
||||
await expect(clientPage.locator("text=Please sign in")).not.toBeVisible({
|
||||
timeout: 5000,
|
||||
});
|
||||
|
||||
// The portal should show something other than "Hi, Guest"
|
||||
// If the session is properly loaded, it should show the actual client name
|
||||
// We check that "Hi, Guest" is NOT visible
|
||||
const hiGuest = clientPage.locator("text=Hi, Guest");
|
||||
await expect(hiGuest).not.toBeVisible({ timeout: 5000 });
|
||||
|
||||
// The portal dashboard should be visible — the nav and main content area
|
||||
await expect(clientPage.locator("nav")).toBeVisible();
|
||||
});
|
||||
|
||||
test.skip("portal dashboard section renders with content", async ({ clientPage }) => {
|
||||
await clientPage.goto("/");
|
||||
await clientPage.waitForLoadState("networkidle");
|
||||
|
||||
// Check that the dashboard/home section renders
|
||||
// The portal has a nav with items like "Home", "Appointments", etc.
|
||||
const nav = clientPage.locator("nav");
|
||||
await expect(nav).toBeVisible();
|
||||
|
||||
// The greeting should NOT be the static mock default
|
||||
// After GRO-300, it should reflect the actual logged-in client
|
||||
const pageContent = await clientPage.textContent("body");
|
||||
expect(pageContent).not.toContain("Please sign in");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,89 @@
|
||||
import { test, expect } from "./fixtures.js";
|
||||
|
||||
/**
|
||||
* E2E test: Portal Data Integrity (GRO-306)
|
||||
*
|
||||
* Verifies that the client portal sections render correctly with actual data
|
||||
* and don't show auth-gate messages after login.
|
||||
*
|
||||
* DEPENDENCY: Requires GRO-300 to be deployed. Tests 1 & 2 share this dependency.
|
||||
*
|
||||
* Journey:
|
||||
* 1. Login as client
|
||||
* 2. Navigate to appointments section — assert no "Please sign in", content renders
|
||||
* 3. Navigate to pets section — assert content renders (or explicit empty state)
|
||||
* 4. Navigate to billing section — assert no JS errors, section renders
|
||||
*/
|
||||
test.describe("Portal Data Integrity", () => {
|
||||
test.beforeEach(async ({ clientPage }) => {
|
||||
await clientPage.goto("/");
|
||||
await clientPage.waitForLoadState("networkidle");
|
||||
});
|
||||
|
||||
test.skip("appointments section renders without auth gate", async ({
|
||||
clientPage,
|
||||
}) => {
|
||||
// Click the Appointments nav item
|
||||
const appointmentsNav = clientPage.getByRole("button", { name: /appointments/i });
|
||||
await appointmentsNav.click();
|
||||
await clientPage.waitForLoadState("networkidle");
|
||||
|
||||
// Must NOT show "Please sign in" gate
|
||||
await expect(
|
||||
clientPage.locator("text=Please sign in")
|
||||
).not.toBeVisible({ timeout: 5000 });
|
||||
|
||||
// The section heading or nav should indicate we're in appointments
|
||||
await expect(
|
||||
clientPage.getByRole("heading", { name: "Appointments" })
|
||||
).toBeVisible();
|
||||
});
|
||||
|
||||
test.skip("pets section renders with content or explicit empty state", async ({
|
||||
clientPage,
|
||||
}) => {
|
||||
// Click the My Pets nav item
|
||||
const petsNav = clientPage.getByRole("button", { name: /my pets/i });
|
||||
await petsNav.click();
|
||||
await clientPage.waitForLoadState("networkidle");
|
||||
|
||||
// Must NOT show auth gate
|
||||
await expect(
|
||||
clientPage.locator("text=Please sign in")
|
||||
).not.toBeVisible({ timeout: 5000 });
|
||||
|
||||
// Should show either pet content or a legitimate empty state
|
||||
const hasPetsContent =
|
||||
(await clientPage.locator("text=Add a pet").isVisible()) ||
|
||||
(await clientPage.locator("text=No pets").isVisible()) ||
|
||||
(await clientPage.locator('[role="button"]').count()) > 0;
|
||||
|
||||
expect(hasPetsContent).toBeTruthy();
|
||||
});
|
||||
|
||||
test.skip("billing section renders without JS errors", async ({ clientPage }) => {
|
||||
// Capture console errors
|
||||
const consoleErrors: string[] = [];
|
||||
clientPage.on("console", (msg) => {
|
||||
if (msg.type() === "error") {
|
||||
consoleErrors.push(msg.text());
|
||||
}
|
||||
});
|
||||
|
||||
// Click the Billing nav item
|
||||
const billingNav = clientPage.getByRole("button", { name: /billing/i });
|
||||
await billingNav.click();
|
||||
await clientPage.waitForLoadState("networkidle");
|
||||
|
||||
// Must NOT show auth gate
|
||||
await expect(
|
||||
clientPage.locator("text=Please sign in")
|
||||
).not.toBeVisible({ timeout: 5000 });
|
||||
|
||||
// No JS exceptions on this section
|
||||
const jsExceptions = consoleErrors.filter(
|
||||
(e) => !e.includes("favicon") && !e.includes("404")
|
||||
);
|
||||
expect(jsExceptions).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,18 @@
|
||||
import tseslint from "typescript-eslint";
|
||||
|
||||
export default tseslint.config(
|
||||
{
|
||||
ignores: [
|
||||
// Untracked .js files containing JSX (build artifacts)
|
||||
"src/**/*.js",
|
||||
"src/**/*.jsx",
|
||||
],
|
||||
},
|
||||
...tseslint.configs.recommended,
|
||||
{
|
||||
rules: {
|
||||
"@typescript-eslint/no-explicit-any": "warn",
|
||||
"@typescript-eslint/no-unused-vars": ["error", { argsIgnorePattern: "^_" }],
|
||||
},
|
||||
}
|
||||
);
|
||||
@@ -0,0 +1,14 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<meta name="theme-color" content="#4f8a6f" />
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||
<title>Groom Book</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,37 @@
|
||||
server {
|
||||
listen 80;
|
||||
root /usr/share/nginx/html;
|
||||
index index.html;
|
||||
|
||||
# Security headers
|
||||
add_header X-Content-Type-Options "nosniff" always;
|
||||
add_header X-Frame-Options "SAMEORIGIN" always;
|
||||
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
|
||||
add_header X-XSS-Protection "1; mode=block" always;
|
||||
add_header Permissions-Policy "camera=(), microphone=(), geolocation=()" always;
|
||||
|
||||
# Cache static assets
|
||||
location ~* \.(js|css|png|svg|ico|woff2)$ {
|
||||
expires 1y;
|
||||
add_header Cache-Control "public, immutable";
|
||||
add_header X-Content-Type-Options "nosniff" always;
|
||||
add_header X-Frame-Options "SAMEORIGIN" always;
|
||||
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
|
||||
add_header X-XSS-Protection "1; mode=block" always;
|
||||
add_header Permissions-Policy "camera=(), microphone=(), geolocation=()" always;
|
||||
}
|
||||
|
||||
# Proxy API calls to the API service
|
||||
location /api/ {
|
||||
proxy_pass http://api:3000/api/;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
}
|
||||
|
||||
# SPA fallback — serve index.html for all routes
|
||||
location / {
|
||||
try_files $uri $uri/ /index.html;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
{
|
||||
"name": "@groombook/web",
|
||||
"version": "0.0.1",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc && vite build",
|
||||
"preview": "vite preview",
|
||||
"lint": "eslint src --ext .ts,.tsx",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"test": "vitest run",
|
||||
"test:e2e": "playwright test -c e2e/playwright.config.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"@dnd-kit/core": "^6.3.1",
|
||||
"@dnd-kit/sortable": "^10.0.0",
|
||||
"@dnd-kit/utilities": "^3.2.2",
|
||||
"@groombook/types": "workspace:*",
|
||||
"@stripe/react-stripe-js": "^6.1.0",
|
||||
"@stripe/stripe-js": "^9.1.0",
|
||||
"@tailwindcss/vite": "^4.2.2",
|
||||
"better-auth": "^1.5.6",
|
||||
"leaflet": "^1.9.4",
|
||||
"lucide-react": "^0.577.0",
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0",
|
||||
"react-leaflet": "^5.0.0",
|
||||
"react-router-dom": "^7.1.2",
|
||||
"recharts": "^3.8.0",
|
||||
"tailwindcss": "^4.2.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@playwright/test": "^1.50.1",
|
||||
"@testing-library/jest-dom": "^6.9.1",
|
||||
"@testing-library/react": "^16.3.2",
|
||||
"@testing-library/user-event": "^14.6.1",
|
||||
"@types/leaflet": "^1.9.12",
|
||||
"@types/node": "^25.6.0",
|
||||
"@types/react": "^19.0.6",
|
||||
"@types/react-dom": "^19.0.3",
|
||||
"@vitejs/plugin-react": "^4.3.4",
|
||||
"@vitest/coverage-v8": "^3.2.4",
|
||||
"eslint": "^9.18.0",
|
||||
"jsdom": "^26.1.0",
|
||||
"typescript": "^5.7.3",
|
||||
"typescript-eslint": "^8.20.0",
|
||||
"vite": "^6.0.7",
|
||||
"vite-plugin-pwa": "^0.21.1",
|
||||
"vitest": "^3.0.4"
|
||||
},
|
||||
"license": "AGPL-3.0-only"
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"name": "@groombook/types",
|
||||
"version": "0.0.1",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "./dist/index.js",
|
||||
"types": "./src/index.ts",
|
||||
"exports": {
|
||||
".": {
|
||||
"default": "./dist/index.js",
|
||||
"types": "./src/index.ts"
|
||||
}
|
||||
},
|
||||
"scripts": {
|
||||
"build": "tsc",
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
"devDependencies": {
|
||||
"typescript": "^5.7.3"
|
||||
},
|
||||
"license": "AGPL-3.0-only"
|
||||
}
|
||||
@@ -0,0 +1,232 @@
|
||||
// Shared domain types for Groom Book
|
||||
|
||||
export type AppointmentStatus =
|
||||
| "scheduled"
|
||||
| "confirmed"
|
||||
| "in_progress"
|
||||
| "completed"
|
||||
| "cancelled"
|
||||
| "no_show";
|
||||
|
||||
export type ConfirmationStatus = "pending" | "confirmed" | "cancelled";
|
||||
|
||||
export type ClientStatus = "active" | "disabled";
|
||||
|
||||
export interface Client {
|
||||
id: string;
|
||||
name: string;
|
||||
email: string | null;
|
||||
phone: string | null;
|
||||
address: string | null;
|
||||
notes: string | null;
|
||||
emailOptOut: boolean;
|
||||
status: ClientStatus;
|
||||
disabledAt: string | null;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface Pet {
|
||||
id: string;
|
||||
clientId: string;
|
||||
name: string;
|
||||
species: string;
|
||||
breed: string | null;
|
||||
weightKg: number | null;
|
||||
dateOfBirth: string | null;
|
||||
/** Portal-shaped serialization of weightKg (GET/PATCH /api/portal/pets). */
|
||||
weight?: string | number | null;
|
||||
/** Portal-shaped serialization of dateOfBirth (GET/PATCH /api/portal/pets). */
|
||||
birthDate?: string | null;
|
||||
healthAlerts: string | null;
|
||||
groomingNotes: string | null;
|
||||
cutStyle: string | null;
|
||||
shampooPreference: string | null;
|
||||
specialCareNotes: string | null;
|
||||
coatType?: string | null;
|
||||
petSizeCategory?: string | null;
|
||||
preferredCuts: string[];
|
||||
medicalAlerts: MedicalAlert[];
|
||||
temperamentScore?: number;
|
||||
temperamentFlags?: string[];
|
||||
customFields: Record<string, string>;
|
||||
photoKey?: string;
|
||||
photoUploadedAt?: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface GroomingVisitLog {
|
||||
id: string;
|
||||
petId: string;
|
||||
appointmentId: string | null;
|
||||
staffId: string | null;
|
||||
cutStyle: string | null;
|
||||
productsUsed: string | null;
|
||||
notes: string | null;
|
||||
groomedAt: string;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export interface Service {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string | null;
|
||||
basePriceCents: number;
|
||||
durationMinutes: number;
|
||||
active: boolean;
|
||||
defaultBufferMinutes?: number;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface Staff {
|
||||
id: string;
|
||||
name: string;
|
||||
email: string;
|
||||
role: "groomer" | "receptionist" | "manager";
|
||||
isSuperUser: boolean;
|
||||
active: boolean;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface RecurringSeries {
|
||||
id: string;
|
||||
frequencyWeeks: number;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export interface AppointmentGroup {
|
||||
id: string;
|
||||
clientId: string;
|
||||
notes: string | null;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface Appointment {
|
||||
id: string;
|
||||
clientId: string;
|
||||
petId: string;
|
||||
serviceId: string;
|
||||
staffId: string | null;
|
||||
batherStaffId: string | null;
|
||||
status: AppointmentStatus;
|
||||
startTime: string;
|
||||
endTime: string;
|
||||
notes: string | null;
|
||||
priceCents: number | null;
|
||||
seriesId: string | null;
|
||||
seriesIndex: number | null;
|
||||
groupId: string | null;
|
||||
confirmationStatus: ConfirmationStatus;
|
||||
confirmedAt: string | null;
|
||||
cancelledAt: string | null;
|
||||
confirmationToken: string | null;
|
||||
customerNotes: string | null;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface InvoiceTipSplit {
|
||||
id: string;
|
||||
invoiceId: string;
|
||||
staffId: string | null;
|
||||
staffName: string;
|
||||
sharePct: string;
|
||||
shareCents: number;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export type InvoiceStatus = "draft" | "pending" | "paid" | "void";
|
||||
export type PaymentMethod = "cash" | "card" | "check" | "other";
|
||||
|
||||
export interface InvoiceLineItem {
|
||||
id: string;
|
||||
invoiceId: string;
|
||||
description: string;
|
||||
quantity: number;
|
||||
unitPriceCents: number;
|
||||
totalCents: number;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export interface Invoice {
|
||||
id: string;
|
||||
appointmentId: string | null;
|
||||
clientId: string;
|
||||
subtotalCents: number;
|
||||
taxCents: number;
|
||||
tipCents: number;
|
||||
totalCents: number;
|
||||
status: InvoiceStatus;
|
||||
paymentMethod: PaymentMethod | null;
|
||||
paidAt: string | null;
|
||||
stripePaymentIntentId: string | null;
|
||||
stripeRefundId: string | null;
|
||||
paymentFailureReason: string | null;
|
||||
notes: string | null;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
lineItems?: InvoiceLineItem[];
|
||||
// Transient fields populated from Stripe API (not stored in DB)
|
||||
cardLast4?: string | null;
|
||||
paymentStatus?: string | null;
|
||||
tipSplits?: InvoiceTipSplit[];
|
||||
}
|
||||
|
||||
// ─── Impersonation ──────────────────────────────────────────────────────────
|
||||
|
||||
export type ImpersonationSessionStatus = "active" | "ended" | "expired";
|
||||
|
||||
export interface ImpersonationSession {
|
||||
id: string;
|
||||
staffId: string;
|
||||
clientId: string;
|
||||
reason: string | null;
|
||||
status: ImpersonationSessionStatus;
|
||||
startedAt: string;
|
||||
endedAt: string | null;
|
||||
expiresAt: string;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export interface ImpersonationAuditLog {
|
||||
id: string;
|
||||
sessionId: string;
|
||||
action: string;
|
||||
pageVisited: string | null;
|
||||
metadata: Record<string, unknown> | null;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export interface BusinessSettings {
|
||||
id: string;
|
||||
businessName: string;
|
||||
logoBase64: string | null;
|
||||
logoMimeType: string | null;
|
||||
primaryColor: string;
|
||||
accentColor: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
// Paginated list response
|
||||
export interface PaginatedList<T> {
|
||||
items: T[];
|
||||
total: number;
|
||||
page: number;
|
||||
pageSize: number;
|
||||
}
|
||||
|
||||
export type AlertSeverity = "low" | "medium" | "high";
|
||||
|
||||
export interface MedicalAlert {
|
||||
id: string;
|
||||
type: string;
|
||||
description: string;
|
||||
severity: AlertSeverity;
|
||||
}
|
||||
|
||||
export type CoatType = "smooth" | "double" | "curly" | "wire" | "long" | "hairless";
|
||||
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "NodeNext",
|
||||
"moduleResolution": "NodeNext",
|
||||
"strict": true,
|
||||
"noUncheckedIndexedAccess": true,
|
||||
"skipLibCheck": true,
|
||||
"outDir": "./dist",
|
||||
"rootDir": "./src"
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
packages:
|
||||
- "packages/*"
|
||||
|
After Width: | Height: | Size: 184 KiB |
|
After Width: | Height: | Size: 227 KiB |
|
After Width: | Height: | Size: 243 KiB |
|
After Width: | Height: | Size: 154 KiB |
|
After Width: | Height: | Size: 260 KiB |
|
After Width: | Height: | Size: 196 KiB |
|
After Width: | Height: | Size: 262 KiB |
|
After Width: | Height: | Size: 347 KiB |
|
After Width: | Height: | Size: 250 KiB |
|
After Width: | Height: | Size: 205 KiB |
|
After Width: | Height: | Size: 246 KiB |
|
After Width: | Height: | Size: 172 KiB |
|
After Width: | Height: | Size: 253 KiB |
|
After Width: | Height: | Size: 220 KiB |
|
After Width: | Height: | Size: 274 KiB |
|
After Width: | Height: | Size: 193 KiB |
|
After Width: | Height: | Size: 292 KiB |
|
After Width: | Height: | Size: 289 KiB |
|
After Width: | Height: | Size: 276 KiB |
|
After Width: | Height: | Size: 307 KiB |
|
After Width: | Height: | Size: 179 KiB |
|
After Width: | Height: | Size: 234 KiB |
|
After Width: | Height: | Size: 256 KiB |
|
After Width: | Height: | Size: 193 KiB |
|
After Width: | Height: | Size: 186 KiB |
|
After Width: | Height: | Size: 275 KiB |
|
After Width: | Height: | Size: 233 KiB |
|
After Width: | Height: | Size: 211 KiB |
|
After Width: | Height: | Size: 252 KiB |
|
After Width: | Height: | Size: 269 KiB |
|
After Width: | Height: | Size: 252 KiB |
|
After Width: | Height: | Size: 235 KiB |
|
After Width: | Height: | Size: 194 KiB |
|
After Width: | Height: | Size: 282 KiB |
|
After Width: | Height: | Size: 226 KiB |
|
After Width: | Height: | Size: 182 KiB |
|
After Width: | Height: | Size: 195 KiB |
|
After Width: | Height: | Size: 283 KiB |
|
After Width: | Height: | Size: 265 KiB |
|
After Width: | Height: | Size: 199 KiB |
|
After Width: | Height: | Size: 312 KiB |
|
After Width: | Height: | Size: 242 KiB |
|
After Width: | Height: | Size: 288 KiB |
|
After Width: | Height: | Size: 230 KiB |
|
After Width: | Height: | Size: 279 KiB |
@@ -0,0 +1,4 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
|
||||
<rect width="32" height="32" rx="6" fill="#4f8a6f"/>
|
||||
<text x="16" y="22" font-size="18" text-anchor="middle" fill="white" font-family="sans-serif">🐾</text>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 231 B |
|
After Width: | Height: | Size: 64 KiB |
|
After Width: | Height: | Size: 547 B |
|
After Width: | Height: | Size: 1.8 KiB |
@@ -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"}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,436 @@
|
||||
import { Routes, Route, Link, useLocation, Navigate, useNavigate } from "react-router-dom";
|
||||
import { useEffect, useState } from "react";
|
||||
import { AppointmentsPage } from "./pages/Appointments.js";
|
||||
import { ClientsPage } from "./pages/Clients.js";
|
||||
import { ClientDetailPage } from "./pages/ClientDetailPage.js";
|
||||
import { ServicesPage } from "./pages/Services.js";
|
||||
import { StaffPage } from "./pages/Staff.js";
|
||||
import { InvoicesPage } from "./pages/Invoices.js";
|
||||
import { BookPage } from "./pages/Book.js";
|
||||
import { ReportsPage } from "./pages/Reports.js";
|
||||
import { RoutesPage } from "./pages/Routes.js";
|
||||
import { GroupBookingPage } from "./pages/GroupBooking.js";
|
||||
import { SettingsPage } from "./pages/Settings.js";
|
||||
import { BookingConfirmedPage } from "./pages/BookingConfirmed.js";
|
||||
import { BookingCancelledPage } from "./pages/BookingCancelled.js";
|
||||
import { BookingErrorPage } from "./pages/BookingError.js";
|
||||
import { SetupWizard } from "./pages/SetupWizard.tsx";
|
||||
import { CustomerPortal } from "./portal/CustomerPortal.js";
|
||||
import { DevLoginSelector, getDevUser } from "./pages/DevLoginSelector.js";
|
||||
import { DevSessionIndicator } from "./components/DevSessionIndicator.js";
|
||||
import { BrandingProvider, useBranding } from "./BrandingContext.js";
|
||||
import { GlobalSearch } from "./components/GlobalSearch.js";
|
||||
import { useSession, signIn, signOut } from "./lib/auth-client.js";
|
||||
|
||||
function LoginPage() {
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [providers, setProviders] = useState<string[]>([]);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
fetch("/api/auth/providers")
|
||||
.then((r) => r.json())
|
||||
.then((data) => setProviders(data.providers ?? []))
|
||||
.catch(() => setProviders([]));
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
const authError = params.get("error");
|
||||
if (authError) setError(authError.replace(/_/g, " "));
|
||||
}, []);
|
||||
|
||||
const handleSocialLogin = async (provider: string) => {
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
const result = await signIn.social({ provider, callbackURL: window.location.origin });
|
||||
if (result?.error) {
|
||||
setError(result.error.message ?? "Sign-in failed");
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const isGoogle = providers.includes("google");
|
||||
const isGitHub = providers.includes("github");
|
||||
const isAuthentik = providers.includes("authentik");
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
height: "100vh",
|
||||
fontFamily: "system-ui, sans-serif",
|
||||
background: "#f0f2f5",
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
background: "#fff",
|
||||
borderRadius: 12,
|
||||
padding: "2rem 2.5rem",
|
||||
boxShadow: "0 4px 24px rgba(0,0,0,0.08)",
|
||||
textAlign: "center",
|
||||
minWidth: 280,
|
||||
}}
|
||||
>
|
||||
<h1 style={{ fontSize: 22, marginBottom: "0.5rem", color: "#1a202c" }}>GroomBook</h1>
|
||||
<p style={{ color: "#6b7280", marginBottom: "1.5rem", fontSize: 14 }}>
|
||||
Sign in to continue
|
||||
</p>
|
||||
{error && (
|
||||
<div style={{ background: "#fef2f2", border: "1px solid #fecaca", borderRadius: 6, padding: "0.5rem 0.75rem", marginBottom: "1rem", color: "#991b1b", fontSize: 13 }}>
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
{isGoogle && (
|
||||
<button
|
||||
onClick={() => handleSocialLogin("google")}
|
||||
disabled={isLoading}
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
gap: 8,
|
||||
width: "100%",
|
||||
padding: "0.6rem 1.5rem",
|
||||
borderRadius: 6,
|
||||
border: "1px solid #e2e8f0",
|
||||
background: "#fff",
|
||||
color: "#1a202c",
|
||||
fontWeight: 600,
|
||||
fontSize: 14,
|
||||
cursor: isLoading ? "wait" : "pointer",
|
||||
opacity: isLoading ? 0.7 : 1,
|
||||
marginBottom: "0.5rem",
|
||||
}}
|
||||
>
|
||||
<svg width="18" height="18" viewBox="0 0 24 24">
|
||||
<path fill="#4285F4" d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92c-.26 1.37-1.04 2.53-2.21 3.31v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.09z"/>
|
||||
<path fill="#34A853" d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z"/>
|
||||
<path fill="#FBBC05" d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l2.85-2.22.81-.62z"/>
|
||||
<path fill="#EA4335" d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z"/>
|
||||
</svg>
|
||||
Sign in with Google
|
||||
</button>
|
||||
)}
|
||||
{isGitHub && (
|
||||
<button
|
||||
onClick={() => handleSocialLogin("github")}
|
||||
disabled={isLoading}
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
gap: 8,
|
||||
width: "100%",
|
||||
padding: "0.6rem 1.5rem",
|
||||
borderRadius: 6,
|
||||
border: "1px solid #e2e8f0",
|
||||
background: "#24292f",
|
||||
color: "#fff",
|
||||
fontWeight: 600,
|
||||
fontSize: 14,
|
||||
cursor: isLoading ? "wait" : "pointer",
|
||||
opacity: isLoading ? 0.7 : 1,
|
||||
marginBottom: isAuthentik ? "0.5rem" : 0,
|
||||
}}
|
||||
>
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="#fff">
|
||||
<path d="M12 0c-6.626 0-12 5.373-12 12 0 5.302 3.438 9.8 8.207 11.387.599.111.793-.261.793-.577v-2.234c-3.338.726-4.033-1.416-4.033-1.416-.546-1.387-1.333-1.756-1.333-1.756-1.089-.745.083-.729.083-.729 1.205.084 1.839 1.237 1.839 1.237 1.07 1.834 2.807 1.304 3.492.997.107-.775.418-1.305.762-1.604-2.665-.305-5.467-1.334-5.467-5.931 0-1.311.469-2.381 1.236-3.221-.124-.303-.535-1.524.117-3.176 0 0 1.008-.322 3.301 1.23.957-.266 1.983-.399 3.003-.404 1.02.005 2.047.138 3.006.404 2.291-1.552 3.297-1.23 3.297-1.23.653 1.653.242 2.874.118 3.176.77.84 1.235 1.911 1.235 3.221 0 4.609-2.807 5.624-5.479 5.921.43.372.823 1.102.823 2.222v3.293c0 .319.192.694.801.576 4.765-1.589 8.199-6.086 8.199-11.386 0-6.627-5.373-12-12-12z"/>
|
||||
</svg>
|
||||
Sign in with GitHub
|
||||
</button>
|
||||
)}
|
||||
{isAuthentik && (
|
||||
<button
|
||||
onClick={() => handleSocialLogin("authentik")}
|
||||
disabled={isLoading}
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
gap: 8,
|
||||
width: "100%",
|
||||
padding: "0.6rem 1.5rem",
|
||||
borderRadius: 6,
|
||||
border: "none",
|
||||
background: "#4f8a6f",
|
||||
color: "#fff",
|
||||
fontWeight: 600,
|
||||
fontSize: 14,
|
||||
cursor: isLoading ? "wait" : "pointer",
|
||||
opacity: isLoading ? 0.7 : 1,
|
||||
}}
|
||||
>
|
||||
{isLoading ? "Redirecting…" : "Sign in with SSO"}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const NAV_LINKS = [
|
||||
{ to: "/admin", label: "Appointments" },
|
||||
{ to: "/admin/clients", label: "Clients" },
|
||||
{ to: "/admin/services", label: "Services" },
|
||||
{ to: "/admin/staff", label: "Staff" },
|
||||
{ to: "/admin/invoices", label: "Invoices" },
|
||||
{ to: "/admin/group-bookings", label: "Group Bookings" },
|
||||
{ to: "/admin/routes", label: "Routes" },
|
||||
{ to: "/admin/reports", label: "Reports" },
|
||||
{ to: "/admin/settings", label: "Settings" },
|
||||
{ to: "/", label: "Customer Portal" },
|
||||
];
|
||||
|
||||
function AdminLayout() {
|
||||
const location = useLocation();
|
||||
const navigate = useNavigate();
|
||||
const { branding } = useBranding();
|
||||
|
||||
const logoSrc = branding.logoBase64 && branding.logoMimeType
|
||||
? `data:${branding.logoMimeType};base64,${branding.logoBase64}`
|
||||
: null;
|
||||
|
||||
return (
|
||||
<div style={{ minHeight: "100vh", fontFamily: "system-ui, sans-serif", background: "#f0f2f5" }}>
|
||||
<nav
|
||||
style={{
|
||||
padding: "0 1.25rem",
|
||||
height: 52,
|
||||
borderBottom: "1px solid #e2e8f0",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: "0.25rem",
|
||||
background: "#fff",
|
||||
boxShadow: "0 1px 3px rgba(0, 0, 0, 0.06), 0 1px 2px rgba(0, 0, 0, 0.04)",
|
||||
position: "sticky",
|
||||
top: 0,
|
||||
zIndex: 50,
|
||||
}}
|
||||
>
|
||||
<div style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 8,
|
||||
marginRight: "1.25rem",
|
||||
flexShrink: 0,
|
||||
}}>
|
||||
{logoSrc && (
|
||||
<img src={logoSrc} alt="" style={{ width: 24, height: 24, objectFit: "contain" }} />
|
||||
)}
|
||||
<strong style={{
|
||||
fontSize: 17,
|
||||
color: "#1a202c",
|
||||
letterSpacing: "-0.02em",
|
||||
}}>
|
||||
{branding.businessName}
|
||||
</strong>
|
||||
</div>
|
||||
<GlobalSearch />
|
||||
<div style={{
|
||||
display: "flex",
|
||||
overflowX: "auto",
|
||||
flex: 1,
|
||||
minWidth: 0,
|
||||
gap: "0.25rem",
|
||||
}}>
|
||||
<Link
|
||||
to="/admin/book"
|
||||
style={{
|
||||
padding: "0.4rem 0.85rem",
|
||||
borderRadius: 6,
|
||||
textDecoration: "none",
|
||||
fontSize: 13,
|
||||
fontWeight: 600,
|
||||
color: "#fff",
|
||||
background: branding.primaryColor,
|
||||
boxShadow: "0 1px 2px rgba(79, 138, 111, 0.3)",
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
Book
|
||||
</Link>
|
||||
{NAV_LINKS.map(({ to, label }) => {
|
||||
const active =
|
||||
to === "/admin"
|
||||
? location.pathname === "/admin"
|
||||
: location.pathname.startsWith(to);
|
||||
return (
|
||||
<Link
|
||||
key={to}
|
||||
to={to}
|
||||
style={{
|
||||
padding: "0.4rem 0.75rem",
|
||||
borderRadius: 6,
|
||||
textDecoration: "none",
|
||||
fontSize: 13,
|
||||
fontWeight: active ? 600 : 500,
|
||||
color: active ? "#2d6a4f" : "#4b5563",
|
||||
background: active ? "#ecfdf5" : "transparent",
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
{label}
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<button
|
||||
onClick={async () => {
|
||||
await signOut();
|
||||
navigate("/login");
|
||||
}}
|
||||
style={{
|
||||
flexShrink: 0,
|
||||
padding: "0.4rem 0.85rem",
|
||||
borderRadius: 6,
|
||||
border: "1px solid #e2e8f0",
|
||||
background: "#fff",
|
||||
color: "#4b5563",
|
||||
fontSize: 13,
|
||||
fontWeight: 500,
|
||||
cursor: "pointer",
|
||||
}}
|
||||
>
|
||||
Logout
|
||||
</button>
|
||||
</nav>
|
||||
<main style={{ padding: "1.25rem 1.5rem" }}>
|
||||
<Routes>
|
||||
<Route path="/" element={<AppointmentsPage />} />
|
||||
<Route path="/clients" element={<ClientsPage />} />
|
||||
<Route path="/clients/:clientId" element={<ClientDetailPage />} />
|
||||
<Route path="/services" element={<ServicesPage />} />
|
||||
<Route path="/staff" element={<StaffPage />} />
|
||||
<Route path="/invoices" element={<InvoicesPage />} />
|
||||
<Route path="/book" element={<BookPage />} />
|
||||
<Route path="/group-bookings" element={<GroupBookingPage />} />
|
||||
<Route path="/routes" element={<RoutesPage />} />
|
||||
<Route path="/reports" element={<ReportsPage />} />
|
||||
<Route path="/settings" element={<SettingsPage />} />
|
||||
</Routes>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function App() {
|
||||
const location = useLocation();
|
||||
const [authDisabled, setAuthDisabled] = useState<boolean | null>(null);
|
||||
const [needsSetup, setNeedsSetup] = useState<boolean | null>(null);
|
||||
const { data: rawSession, isPending: rawSessionLoading } = useSession();
|
||||
// In dev mode (authDisabled=true), session state is irrelevant - skip useSession result
|
||||
const session = authDisabled ? null : rawSession;
|
||||
const sessionLoading = authDisabled ? false : rawSessionLoading;
|
||||
|
||||
useEffect(() => {
|
||||
fetch("/api/dev/config")
|
||||
.then((r) => r.json())
|
||||
.then((data) => setAuthDisabled(data.authDisabled === true))
|
||||
.catch(() => setAuthDisabled(false));
|
||||
}, []);
|
||||
|
||||
// After session is confirmed, check if setup is needed.
|
||||
// Always run the setup/status fetch as soon as the auth state is known — even for
|
||||
// unauthenticated users, so the `needsSetup` value is in place if they sign in
|
||||
// mid-session. The unauth branch in the render below is handled before
|
||||
// `needsSetup` is consulted, so this is safe and avoids a stuck-`null` state.
|
||||
// See GRO-2011.
|
||||
useEffect(() => {
|
||||
if (authDisabled === null || sessionLoading) return;
|
||||
// In dev mode, only fetch when a dev user has been selected — otherwise the
|
||||
// user is mid-redirect to the dev login selector and we don't need setup state.
|
||||
if (authDisabled && !getDevUser()) return;
|
||||
|
||||
fetch("/api/setup/status")
|
||||
.then((r) => r.json())
|
||||
.then((data) => setNeedsSetup(data.needsSetup === true))
|
||||
.catch(() => setNeedsSetup(false));
|
||||
}, [authDisabled, session, sessionLoading]);
|
||||
|
||||
// Public booking redirect pages — no auth or portal chrome needed
|
||||
if (location.pathname === "/booking/confirmed") {
|
||||
return <BookingConfirmedPage />;
|
||||
}
|
||||
if (location.pathname === "/booking/cancelled") {
|
||||
return <BookingCancelledPage />;
|
||||
}
|
||||
if (location.pathname === "/booking/error") {
|
||||
return <BookingErrorPage />;
|
||||
}
|
||||
|
||||
// Setup wizard — standalone, no admin chrome
|
||||
if (location.pathname === "/setup") {
|
||||
return (
|
||||
<BrandingProvider>
|
||||
<SetupWizard onSetupComplete={() => setNeedsSetup(false)} />
|
||||
</BrandingProvider>
|
||||
);
|
||||
}
|
||||
|
||||
// Still loading auth state or setup check (skip setup check in dev mode)
|
||||
if (authDisabled === null || sessionLoading) return null;
|
||||
|
||||
// Dev mode: show login selector (no setup check needed in dev mode)
|
||||
if (authDisabled && location.pathname === "/login") {
|
||||
return <DevLoginSelector />;
|
||||
}
|
||||
|
||||
// Dev mode: use dev login selector (no setup check needed in dev mode)
|
||||
if (authDisabled && !getDevUser()) {
|
||||
return <Navigate to="/login" replace />;
|
||||
}
|
||||
|
||||
// Show login BEFORE checking needsSetup (needsSetup is never set for unauthenticated users).
|
||||
// At /login with a valid session, fall through so the staff redirect below can
|
||||
// route staff to /admin and the final render can redirect customers to / (portal).
|
||||
// Previously, an authenticated customer at /login would see a blank page because
|
||||
// the final render returns null at /login (showCustomerPortal is false). See GRO-2099.
|
||||
if (!authDisabled && !session && location.pathname === "/login") {
|
||||
return <LoginPage />;
|
||||
}
|
||||
|
||||
// Production: need setup check
|
||||
if (needsSetup === null) return null;
|
||||
|
||||
// Redirect to setup wizard if needed
|
||||
if (needsSetup) {
|
||||
return <Navigate to="/setup" replace />;
|
||||
}
|
||||
|
||||
// Redirect staff to /admin; allow customers to access portal (preserve impersonation via ?sessionId=)
|
||||
const searchParams = new URLSearchParams(location.search);
|
||||
const isStaff = session?.user && (session.user as any).role === "staff";
|
||||
if (!authDisabled && session && !location.pathname.startsWith("/admin") && !searchParams.has("sessionId") && isStaff) {
|
||||
return <Navigate to="/admin" replace />;
|
||||
}
|
||||
|
||||
// Don't render portal chrome at /login — DevLoginSelector is shown instead
|
||||
const showCustomerPortal = !location.pathname.startsWith("/admin") && location.pathname !== "/login";
|
||||
|
||||
// At /login with a valid session, redirect to the portal root. Without this,
|
||||
// the final render returns null at /login (showCustomerPortal is false) and
|
||||
// the user sees a blank page after a successful sign-in. Staff are routed
|
||||
// to /admin by the earlier staff check. See GRO-2099.
|
||||
if (!authDisabled && session && location.pathname === "/login") {
|
||||
return <Navigate to="/" replace />;
|
||||
}
|
||||
|
||||
return (
|
||||
<BrandingProvider>
|
||||
{location.pathname.startsWith("/admin") ? (
|
||||
<>
|
||||
<Routes>
|
||||
<Route path="/admin/*" element={<AdminLayout />} />
|
||||
</Routes>
|
||||
{authDisabled && <DevSessionIndicator />}
|
||||
</>
|
||||
) : showCustomerPortal ? (
|
||||
<>
|
||||
<CustomerPortal />
|
||||
{authDisabled && <DevSessionIndicator />}
|
||||
</>
|
||||
) : null}
|
||||
</BrandingProvider>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
import { createContext, useContext, useEffect, useRef, useState, useCallback } from "react";
|
||||
|
||||
export interface Branding {
|
||||
businessName: string;
|
||||
primaryColor: string;
|
||||
accentColor: string;
|
||||
logoUrl: string | null;
|
||||
logoBase64: string | null;
|
||||
logoMimeType: string | null;
|
||||
}
|
||||
|
||||
const DEFAULT_BRANDING: Branding = {
|
||||
businessName: "GroomBook",
|
||||
primaryColor: "#4f8a6f",
|
||||
accentColor: "#8b7355",
|
||||
logoUrl: null,
|
||||
logoBase64: null,
|
||||
logoMimeType: null,
|
||||
};
|
||||
|
||||
const BrandingContext = createContext<{
|
||||
branding: Branding;
|
||||
refresh: () => void;
|
||||
}>({ branding: DEFAULT_BRANDING, refresh: () => {} });
|
||||
|
||||
export function useBranding() {
|
||||
return useContext(BrandingContext);
|
||||
}
|
||||
|
||||
export function BrandingProvider({ children }: { children: React.ReactNode }) {
|
||||
const [branding, setBranding] = useState<Branding>(DEFAULT_BRANDING);
|
||||
const metaThemeColorRef = useRef<HTMLMetaElement | null>(null);
|
||||
|
||||
const fetchBranding = useCallback(() => {
|
||||
fetch("/api/branding")
|
||||
.then((r) => (r.ok ? r.json() : null))
|
||||
.then((data) => {
|
||||
if (data && typeof data.businessName === "string") setBranding(data);
|
||||
})
|
||||
.catch(() => {});
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
fetchBranding();
|
||||
}, [fetchBranding]);
|
||||
|
||||
// Apply CSS custom properties whenever branding changes
|
||||
useEffect(() => {
|
||||
document.documentElement.style.setProperty("--color-primary", branding.primaryColor);
|
||||
document.documentElement.style.setProperty("--color-accent", branding.accentColor);
|
||||
// Keep PWA theme-color meta tag in sync with primary color
|
||||
if (!metaThemeColorRef.current) {
|
||||
metaThemeColorRef.current = document.querySelector<HTMLMetaElement>("meta[name='theme-color']");
|
||||
if (!metaThemeColorRef.current) {
|
||||
metaThemeColorRef.current = document.createElement("meta");
|
||||
metaThemeColorRef.current.name = "theme-color";
|
||||
document.head.appendChild(metaThemeColorRef.current);
|
||||
}
|
||||
}
|
||||
metaThemeColorRef.current.content = branding.primaryColor;
|
||||
}, [branding.primaryColor, branding.accentColor]);
|
||||
|
||||
return (
|
||||
<BrandingContext.Provider value={{ branding, refresh: fetchBranding }}>
|
||||
{children}
|
||||
</BrandingContext.Provider>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,271 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { render, screen, within, waitFor } from "@testing-library/react";
|
||||
import { MemoryRouter } from "react-router-dom";
|
||||
import { App } from "../App";
|
||||
|
||||
|
||||
// Mock fetch to return appropriate responses based on URL
|
||||
beforeEach(() => {
|
||||
localStorage.clear();
|
||||
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/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;
|
||||
});
|
||||
|
||||
async function renderApp(route = "/admin") {
|
||||
render(
|
||||
<MemoryRouter initialEntries={[route]}>
|
||||
<App />
|
||||
</MemoryRouter>
|
||||
);
|
||||
// Wait for the config fetch to resolve
|
||||
const nav = await screen.findByRole("navigation");
|
||||
return nav;
|
||||
}
|
||||
|
||||
describe("App navigation", () => {
|
||||
// Use authDisabled=true (dev mode) so nav renders without needing Better Auth useSession() mock
|
||||
beforeEach(() => {
|
||||
localStorage.setItem("dev-user", JSON.stringify({ type: "staff", id: "s1", name: "Sarah" }));
|
||||
global.fetch = vi.fn((url: string) => {
|
||||
if (url === "/api/dev/config") {
|
||||
return Promise.resolve({
|
||||
ok: true,
|
||||
json: async () => ({ authDisabled: true }),
|
||||
} 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;
|
||||
});
|
||||
|
||||
it("renders the Groom Book brand", async () => {
|
||||
const nav = await renderApp();
|
||||
expect(
|
||||
within(nav).getByText((_, el) => el?.tagName === "STRONG" && /Groom\s*Book/.test(el.textContent ?? ""))
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders the Book CTA button", async () => {
|
||||
const nav = await renderApp();
|
||||
expect(within(nav).getByRole("link", { name: "Book" })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders all primary nav links", async () => {
|
||||
const nav = await renderApp();
|
||||
const expectedLinks = [
|
||||
"Appointments",
|
||||
"Clients",
|
||||
"Services",
|
||||
"Staff",
|
||||
"Invoices",
|
||||
"Group Bookings",
|
||||
"Reports",
|
||||
];
|
||||
expectedLinks.forEach((label) => {
|
||||
expect(within(nav).getByText(label)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it("highlights the active route link", async () => {
|
||||
const nav = await renderApp("/admin/clients");
|
||||
const clientsLink = within(nav).getByText("Clients");
|
||||
// Active links use fontWeight 600
|
||||
expect(clientsLink).toHaveStyle({ fontWeight: "600" });
|
||||
});
|
||||
|
||||
it("renders customer portal at root", async () => {
|
||||
render(
|
||||
<MemoryRouter initialEntries={["/"]}>
|
||||
<App />
|
||||
</MemoryRouter>
|
||||
);
|
||||
// Customer portal should render at root - no admin nav present
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
screen.queryByText((_, el) => el?.tagName === "STRONG" && /Groom\s*Book/.test(el.textContent ?? ""))
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("GRO-2011 — setup/status fetch for unauthenticated users", () => {
|
||||
it("calls /api/setup/status for unauthenticated users so needsSetup is never stuck null", async () => {
|
||||
const setupStatusCalls: string[] = [];
|
||||
|
||||
global.fetch = vi.fn((url: string) => {
|
||||
if (url === "/api/dev/config") {
|
||||
return Promise.resolve({
|
||||
ok: true,
|
||||
json: async () => ({ authDisabled: false }),
|
||||
} as Response);
|
||||
}
|
||||
if (url === "/api/auth/get-session") {
|
||||
// Better Auth returns 200 with null session for unauthenticated users.
|
||||
return Promise.resolve({
|
||||
ok: true,
|
||||
json: async () => null,
|
||||
} as unknown as Response);
|
||||
}
|
||||
if (url === "/api/setup/status") {
|
||||
setupStatusCalls.push(url);
|
||||
return Promise.resolve({
|
||||
ok: true,
|
||||
json: async () => ({ needsSetup: false }),
|
||||
} as Response);
|
||||
}
|
||||
if (url === "/api/branding") {
|
||||
return Promise.resolve({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
businessName: "GroomBook",
|
||||
primaryColor: "#4f8a6f",
|
||||
accentColor: "#8b7355",
|
||||
logoBase64: null,
|
||||
logoMimeType: null,
|
||||
}),
|
||||
} as Response);
|
||||
}
|
||||
return Promise.resolve({ ok: true, json: async () => [] } as Response);
|
||||
}) as unknown as typeof fetch;
|
||||
|
||||
render(
|
||||
<MemoryRouter initialEntries={["/login"]}>
|
||||
<App />
|
||||
</MemoryRouter>
|
||||
);
|
||||
|
||||
// The login page should be rendered for the unauthenticated user.
|
||||
await screen.findByText("Sign in to continue");
|
||||
|
||||
// Crucially, /api/setup/status must be called even when the user is unauthenticated —
|
||||
// otherwise `needsSetup` stays null and a later code path can short-circuit to a
|
||||
// blank page (GRO-2011).
|
||||
await waitFor(() => {
|
||||
expect(setupStatusCalls.length).toBeGreaterThanOrEqual(1);
|
||||
});
|
||||
expect(setupStatusCalls[0]).toBe("/api/setup/status");
|
||||
});
|
||||
});
|
||||
|
||||
describe("Dev login selector", () => {
|
||||
it("redirects to /login when auth is disabled and no user selected", async () => {
|
||||
global.fetch = vi.fn((url: string) => {
|
||||
if (url === "/api/dev/config") {
|
||||
return Promise.resolve({
|
||||
ok: true,
|
||||
json: async () => ({ authDisabled: true }),
|
||||
} as Response);
|
||||
}
|
||||
if (url === "/api/dev/users") {
|
||||
return Promise.resolve({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
staff: [{ id: "s1", name: "Sarah", email: "sarah@test.com", role: "groomer" }],
|
||||
clients: [{ id: "c1", name: "Client A", email: "a@test.com", petCount: 2 }],
|
||||
}),
|
||||
} 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);
|
||||
}
|
||||
if (url === "/api/auth/get-session") {
|
||||
return Promise.resolve({
|
||||
ok: true,
|
||||
json: async () => ({ user: null }),
|
||||
} as Response);
|
||||
}
|
||||
return Promise.resolve({ ok: true, json: async () => [] } as Response);
|
||||
}) as unknown as typeof fetch;
|
||||
|
||||
render(
|
||||
<MemoryRouter initialEntries={["/admin"]}>
|
||||
<App />
|
||||
</MemoryRouter>
|
||||
);
|
||||
|
||||
// Should redirect to login selector and show dev login UI
|
||||
await screen.findByText("Dev Login Selector");
|
||||
expect(screen.getByText("Sarah")).toBeInTheDocument();
|
||||
expect(screen.getByText("Client A")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("does not redirect when a dev user is already selected", async () => {
|
||||
localStorage.setItem("dev-user", JSON.stringify({ type: "staff", id: "s1", name: "Sarah" }));
|
||||
|
||||
global.fetch = vi.fn((url: string) => {
|
||||
if (url === "/api/dev/config") {
|
||||
return Promise.resolve({
|
||||
ok: true,
|
||||
json: async () => ({ authDisabled: true }),
|
||||
} 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={["/admin"]}>
|
||||
<App />
|
||||
</MemoryRouter>
|
||||
);
|
||||
|
||||
// Should show admin nav, not login selector
|
||||
const nav = await screen.findByRole("navigation");
|
||||
expect(
|
||||
within(nav).getByText((_, el) => el?.tagName === "STRONG" && /Groom\s*Book/.test(el.textContent ?? ""))
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,937 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import { render, screen, fireEvent, waitFor } from "@testing-library/react";
|
||||
import { parseTimeTo24Hour, isUpcoming, normalizeAppointment, normalizeService, formatServicePrice, CustomerNotesSection, ConfirmationSection, StatusBadge, formatSlotLabel, slotToTime, BookingFlow } from "../portal/sections/Appointments.tsx";
|
||||
|
||||
const UPCOMING_APPT = {
|
||||
id: "appt-1",
|
||||
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: "pending" as const,
|
||||
};
|
||||
|
||||
const PAST_APPT = {
|
||||
...UPCOMING_APPT,
|
||||
id: "appt-2",
|
||||
date: "2025-01-01",
|
||||
time: "10:00 AM",
|
||||
status: "completed" as const,
|
||||
};
|
||||
|
||||
describe("parseTimeTo24Hour", () => {
|
||||
it("converts AM times correctly", () => {
|
||||
expect(parseTimeTo24Hour("9:00 AM")).toBe("09:00:00");
|
||||
expect(parseTimeTo24Hour("10:00 AM")).toBe("10:00:00");
|
||||
expect(parseTimeTo24Hour("12:00 AM")).toBe("00:00:00");
|
||||
});
|
||||
|
||||
it("converts PM times correctly", () => {
|
||||
expect(parseTimeTo24Hour("1:00 PM")).toBe("13:00:00");
|
||||
expect(parseTimeTo24Hour("2:00 PM")).toBe("14:00:00");
|
||||
expect(parseTimeTo24Hour("11:00 PM")).toBe("23:00:00");
|
||||
expect(parseTimeTo24Hour("12:00 PM")).toBe("12:00:00");
|
||||
});
|
||||
|
||||
it("does not throw on undefined/null/empty input (GRO-2180)", () => {
|
||||
expect(() => parseTimeTo24Hour(undefined)).not.toThrow();
|
||||
expect(() => parseTimeTo24Hour(null)).not.toThrow();
|
||||
expect(parseTimeTo24Hour(undefined)).toBe("00:00:00");
|
||||
expect(parseTimeTo24Hour("")).toBe("00:00:00");
|
||||
});
|
||||
});
|
||||
|
||||
// GRO-2180: `/api/portal/appointments` returns ISO `startTime`/`endTime` + nested
|
||||
// pet/service/staff objects, not the flat date/time/petName shape the UI renders.
|
||||
describe("normalizeAppointment (API startTime shape — GRO-2180)", () => {
|
||||
const RAW_API_APPT = {
|
||||
id: "a0000001-0000-0000-0000-000000000001",
|
||||
startTime: "2026-06-01T10:00:00.000Z",
|
||||
endTime: "2026-06-01T10:45:00.000Z",
|
||||
status: "completed" as const,
|
||||
confirmationStatus: "confirmed" as const,
|
||||
customerNotes: "Please be gentle",
|
||||
notes: null,
|
||||
pet: { id: "c0000001-0000-0000-0000-000000000001", name: "UAT Pup Alpha", photo: null },
|
||||
service: { id: "b0000001-0000-0000-0000-000000000001", name: "Full Groom" },
|
||||
staff: { id: "00000000-0000-0000-0000-000000000004", name: "UAT Staff Groomer" },
|
||||
};
|
||||
|
||||
it("maps nested pet/service/staff and ISO startTime without throwing", () => {
|
||||
const appt = normalizeAppointment(RAW_API_APPT);
|
||||
expect(appt.id).toBe("a0000001-0000-0000-0000-000000000001");
|
||||
expect(appt.petId).toBe("c0000001-0000-0000-0000-000000000001");
|
||||
expect(appt.serviceId).toBe("b0000001-0000-0000-0000-000000000001");
|
||||
expect(appt.groomerId).toBe("00000000-0000-0000-0000-000000000004");
|
||||
expect(appt.petName).toBe("UAT Pup Alpha");
|
||||
expect(appt.serviceName).toBe("Full Groom");
|
||||
expect(appt.groomerName).toBe("UAT Staff Groomer");
|
||||
expect(appt.startTime).toBe("2026-06-01T10:00:00.000Z");
|
||||
expect(appt.customerNotes).toBe("Please be gentle");
|
||||
});
|
||||
|
||||
it("derives duration in minutes from start/end delta", () => {
|
||||
expect(normalizeAppointment(RAW_API_APPT).duration).toBe(45);
|
||||
});
|
||||
|
||||
it("produces a date/time pair that does not crash isUpcoming or formatDate", () => {
|
||||
const appt = normalizeAppointment(RAW_API_APPT);
|
||||
expect(typeof appt.date).toBe("string");
|
||||
expect(typeof appt.time).toBe("string");
|
||||
expect(() => isUpcoming(appt)).not.toThrow();
|
||||
});
|
||||
|
||||
it("classifies a past completed appointment as not upcoming", () => {
|
||||
expect(isUpcoming(normalizeAppointment(RAW_API_APPT))).toBe(false);
|
||||
});
|
||||
|
||||
it("classifies a future scheduled appointment as upcoming via startTime", () => {
|
||||
const future = normalizeAppointment({
|
||||
...RAW_API_APPT,
|
||||
startTime: "2099-01-01T10:00:00.000Z",
|
||||
endTime: "2099-01-01T11:00:00.000Z",
|
||||
status: "confirmed",
|
||||
});
|
||||
expect(isUpcoming(future)).toBe(true);
|
||||
});
|
||||
|
||||
it("tolerates null nested objects without throwing", () => {
|
||||
const appt = normalizeAppointment({
|
||||
id: "a2",
|
||||
startTime: "2099-01-01T10:00:00.000Z",
|
||||
endTime: "2099-01-01T11:00:00.000Z",
|
||||
status: "scheduled",
|
||||
pet: null,
|
||||
service: null,
|
||||
staff: null,
|
||||
});
|
||||
expect(appt.petId).toBe("");
|
||||
expect(appt.serviceId).toBe("");
|
||||
expect(appt.groomerId).toBeNull();
|
||||
expect(appt.petName).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("isUpcoming", () => {
|
||||
it("returns true for future confirmed appointments", () => {
|
||||
expect(isUpcoming(UPCOMING_APPT)).toBe(true);
|
||||
});
|
||||
|
||||
it("returns false for past appointments", () => {
|
||||
expect(isUpcoming(PAST_APPT)).toBe(false);
|
||||
});
|
||||
|
||||
it("returns false for cancelled appointments", () => {
|
||||
expect(isUpcoming({ ...UPCOMING_APPT, status: "cancelled" })).toBe(false);
|
||||
});
|
||||
|
||||
it("returns false for completed appointments", () => {
|
||||
expect(isUpcoming({ ...UPCOMING_APPT, status: "completed" })).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("CustomerNotesSection", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
global.fetch = vi.fn();
|
||||
});
|
||||
|
||||
it("renders textarea with existing notes", () => {
|
||||
render(<CustomerNotesSection appointment={{ ...UPCOMING_APPT, customerNotes: "Test note" }} sessionId="test-session-id" />);
|
||||
expect(screen.getByRole("textbox")).toHaveValue("Test note");
|
||||
});
|
||||
|
||||
it("renders Save Notes button", () => {
|
||||
render(<CustomerNotesSection appointment={UPCOMING_APPT} sessionId="test-session-id" />);
|
||||
expect(screen.getByRole("button", { name: /Save Notes/i })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("sends Authorization header when session exists", async () => {
|
||||
vi.mocked(global.fetch).mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => ({ id: "appt-1", customerNotes: "Updated", updatedAt: new Date().toISOString() }),
|
||||
} as Response);
|
||||
|
||||
render(<CustomerNotesSection appointment={UPCOMING_APPT} sessionId="test-session-id" />);
|
||||
fireEvent.change(screen.getByRole("textbox"), { target: { value: "New note" } });
|
||||
fireEvent.click(screen.getByRole("button", { name: /Save Notes/i }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(global.fetch).toHaveBeenCalledWith(
|
||||
"/api/portal/appointments/appt-1/notes",
|
||||
expect.objectContaining({
|
||||
headers: expect.objectContaining({
|
||||
"X-Impersonation-Session-Id": "test-session-id",
|
||||
}),
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it("does not send Authorization header when sessionId is null", async () => {
|
||||
vi.mocked(global.fetch).mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => ({ id: "appt-1", customerNotes: "Updated", updatedAt: new Date().toISOString() }),
|
||||
} as Response);
|
||||
|
||||
render(<CustomerNotesSection appointment={UPCOMING_APPT} sessionId={null} />);
|
||||
fireEvent.change(screen.getByRole("textbox"), { target: { value: "New note" } });
|
||||
fireEvent.click(screen.getByRole("button", { name: /Save Notes/i }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(global.fetch).toHaveBeenCalledWith(
|
||||
"/api/portal/appointments/appt-1/notes",
|
||||
expect.objectContaining({
|
||||
headers: expect.not.objectContaining({
|
||||
"Authorization": expect.anything(),
|
||||
}),
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it("shows error message when save fails", async () => {
|
||||
vi.mocked(global.fetch).mockResolvedValue({
|
||||
ok: false,
|
||||
status: 401,
|
||||
json: async () => ({ error: "Unauthorized" }),
|
||||
} as Response);
|
||||
|
||||
render(<CustomerNotesSection appointment={UPCOMING_APPT} sessionId="test-session-id" />);
|
||||
fireEvent.change(screen.getByRole("textbox"), { target: { value: "New note" } });
|
||||
fireEvent.click(screen.getByRole("button", { name: /Save Notes/i }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/Unauthorized/i)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it("shows success message when save succeeds", async () => {
|
||||
vi.mocked(global.fetch).mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => ({ id: "appt-1", customerNotes: "Saved", updatedAt: new Date().toISOString() }),
|
||||
} as Response);
|
||||
|
||||
render(<CustomerNotesSection appointment={UPCOMING_APPT} sessionId="test-session-id" />);
|
||||
fireEvent.change(screen.getByRole("textbox"), { target: { value: "Saved note" } });
|
||||
fireEvent.click(screen.getByRole("button", { name: /Save Notes/i }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/Saved!/i)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it("disables button when notes unchanged", () => {
|
||||
render(<CustomerNotesSection appointment={{ ...UPCOMING_APPT, customerNotes: "Existing" }} sessionId="test-session-id" />);
|
||||
expect(screen.getByRole("button", { name: /Save Notes/i })).toBeDisabled();
|
||||
});
|
||||
|
||||
it("enforces 500 character limit", () => {
|
||||
render(<CustomerNotesSection appointment={UPCOMING_APPT} sessionId="test-session-id" />);
|
||||
const textarea = screen.getByRole("textbox");
|
||||
const longText = "a".repeat(600);
|
||||
fireEvent.change(textarea, { target: { value: longText } });
|
||||
expect(textarea).toHaveValue("a".repeat(500));
|
||||
});
|
||||
|
||||
it("displays character count", () => {
|
||||
render(<CustomerNotesSection appointment={UPCOMING_APPT} sessionId="test-session-id" />);
|
||||
expect(screen.getByText(/0\/500/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows exceeded character count in red when limit exceeded", () => {
|
||||
render(<CustomerNotesSection appointment={UPCOMING_APPT} sessionId="test-session-id" />);
|
||||
const textarea = screen.getByRole("textbox");
|
||||
// Type characters one by one to exceed limit
|
||||
const longText = "a".repeat(501);
|
||||
fireEvent.change(textarea, { target: { value: longText } });
|
||||
// The textarea value is truncated to 500, so counter shows 500/500
|
||||
// The class check would need to verify text-red-500 appears
|
||||
// Since the onChange truncates, we test that limit is enforced
|
||||
expect(textarea).toHaveValue("a".repeat(500));
|
||||
});
|
||||
|
||||
it("does not render save button for completed appointments", () => {
|
||||
render(<CustomerNotesSection appointment={{ ...UPCOMING_APPT, status: "completed" }} sessionId="test-session-id" />);
|
||||
expect(screen.queryByRole("button", { name: /Save Notes/i })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("does not render save button for cancelled appointments", () => {
|
||||
render(<CustomerNotesSection appointment={{ ...UPCOMING_APPT, status: "cancelled" }} sessionId="test-session-id" />);
|
||||
expect(screen.queryByRole("button", { name: /Save Notes/i })).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe("ConfirmationSection", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
global.fetch = vi.fn();
|
||||
vi.stubGlobal("confirm", vi.fn(() => true));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it("renders pending badge when confirmationStatus is pending", () => {
|
||||
render(<ConfirmationSection appointment={UPCOMING_APPT} sessionId="test-session-id" />);
|
||||
expect(screen.getByText("Pending confirmation")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders confirmed badge when confirmationStatus is confirmed", () => {
|
||||
render(<ConfirmationSection appointment={{ ...UPCOMING_APPT, confirmationStatus: "confirmed" }} sessionId="test-session-id" />);
|
||||
expect(screen.getByText("Confirmed")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders cancelled badge when confirmationStatus is cancelled", () => {
|
||||
render(<ConfirmationSection appointment={{ ...UPCOMING_APPT, confirmationStatus: "cancelled" }} sessionId="test-session-id" />);
|
||||
expect(screen.getByText("Cancelled")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows Confirm Appointment button when status is pending", () => {
|
||||
render(<ConfirmationSection appointment={UPCOMING_APPT} sessionId="test-session-id" />);
|
||||
expect(screen.getByRole("button", { name: /Confirm Appointment/i })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("does not show Confirm button when already confirmed", () => {
|
||||
render(<ConfirmationSection appointment={{ ...UPCOMING_APPT, confirmationStatus: "confirmed" }} sessionId="test-session-id" />);
|
||||
expect(screen.queryByRole("button", { name: /Confirm Appointment/i })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("does not show Confirm button when cancelled", () => {
|
||||
render(<ConfirmationSection appointment={{ ...UPCOMING_APPT, confirmationStatus: "cancelled" }} sessionId="test-session-id" />);
|
||||
expect(screen.queryByRole("button", { name: /Confirm Appointment/i })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("calls confirm API and updates local status on success", async () => {
|
||||
vi.mocked(global.fetch).mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => ({ id: "appt-1", confirmationStatus: "confirmed" }),
|
||||
} as Response);
|
||||
|
||||
render(<ConfirmationSection appointment={UPCOMING_APPT} sessionId="test-session-id" />);
|
||||
fireEvent.click(screen.getByRole("button", { name: /Confirm Appointment/i }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(global.fetch).toHaveBeenCalledWith(
|
||||
"/api/portal/appointments/appt-1/confirm",
|
||||
expect.objectContaining({ method: "POST" })
|
||||
);
|
||||
});
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Confirmed")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it("sends Authorization header when session exists", async () => {
|
||||
vi.mocked(global.fetch).mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => ({ id: "appt-1", confirmationStatus: "confirmed" }),
|
||||
} as Response);
|
||||
|
||||
render(<ConfirmationSection appointment={UPCOMING_APPT} sessionId="test-session-id" />);
|
||||
fireEvent.click(screen.getByRole("button", { name: /Confirm Appointment/i }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(global.fetch).toHaveBeenCalledWith(
|
||||
"/api/portal/appointments/appt-1/confirm",
|
||||
expect.objectContaining({
|
||||
headers: expect.objectContaining({
|
||||
"X-Impersonation-Session-Id": "test-session-id",
|
||||
}),
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it("does not send Authorization header when sessionId is null", async () => {
|
||||
vi.mocked(global.fetch).mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => ({ id: "appt-1", confirmationStatus: "confirmed" }),
|
||||
} as Response);
|
||||
|
||||
render(<ConfirmationSection appointment={UPCOMING_APPT} sessionId={null} />);
|
||||
fireEvent.click(screen.getByRole("button", { name: /Confirm Appointment/i }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(global.fetch).toHaveBeenCalledWith(
|
||||
"/api/portal/appointments/appt-1/confirm",
|
||||
expect.objectContaining({
|
||||
headers: expect.not.objectContaining({
|
||||
"Authorization": expect.anything(),
|
||||
}),
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it("shows error message when confirm API returns 401", async () => {
|
||||
vi.mocked(global.fetch).mockResolvedValue({
|
||||
ok: false,
|
||||
status: 401,
|
||||
json: async () => ({ error: "Unauthorized" }),
|
||||
} as Response);
|
||||
|
||||
render(<ConfirmationSection appointment={UPCOMING_APPT} sessionId="test-session-id" />);
|
||||
fireEvent.click(screen.getByRole("button", { name: /Confirm Appointment/i }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/Unauthorized/i)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it("shows error message when confirm API returns 403", async () => {
|
||||
vi.mocked(global.fetch).mockResolvedValue({
|
||||
ok: false,
|
||||
status: 403,
|
||||
json: async () => ({ error: "Forbidden" }),
|
||||
} as Response);
|
||||
|
||||
render(<ConfirmationSection appointment={UPCOMING_APPT} sessionId="test-session-id" />);
|
||||
fireEvent.click(screen.getByRole("button", { name: /Confirm Appointment/i }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/Forbidden/i)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it("shows error message when confirm API returns 422 (invalid state)", async () => {
|
||||
vi.mocked(global.fetch).mockResolvedValue({
|
||||
ok: false,
|
||||
status: 422,
|
||||
json: async () => ({ error: "Cannot confirm - appointment is not in pending state" }),
|
||||
} as Response);
|
||||
|
||||
render(<ConfirmationSection appointment={UPCOMING_APPT} sessionId="test-session-id" />);
|
||||
fireEvent.click(screen.getByRole("button", { name: /Confirm Appointment/i }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/Cannot confirm/i)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it("does not call confirm API if user cancels the confirmation dialog", async () => {
|
||||
vi.stubGlobal("confirm", vi.fn(() => false));
|
||||
|
||||
render(<ConfirmationSection appointment={UPCOMING_APPT} sessionId="test-session-id" />);
|
||||
fireEvent.click(screen.getByRole("button", { name: /Confirm Appointment/i }));
|
||||
|
||||
expect(global.fetch).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("shows loading state while confirming", async () => {
|
||||
vi.mocked(global.fetch).mockReturnValue(new Promise(() => {})); // Never resolves
|
||||
|
||||
render(<ConfirmationSection appointment={UPCOMING_APPT} sessionId="test-session-id" />);
|
||||
// Get button reference before clicking
|
||||
const btn = screen.getByRole("button", { name: /Confirm Appointment/i });
|
||||
fireEvent.click(btn);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/Confirming.../i)).toBeInTheDocument();
|
||||
});
|
||||
// Button is disabled while loading
|
||||
expect(btn).toBeDisabled();
|
||||
});
|
||||
|
||||
it("shows success message briefly after confirm", async () => {
|
||||
vi.mocked(global.fetch).mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => ({ id: "appt-1", confirmationStatus: "confirmed" }),
|
||||
} as Response);
|
||||
|
||||
render(<ConfirmationSection appointment={UPCOMING_APPT} sessionId="test-session-id" />);
|
||||
fireEvent.click(screen.getByRole("button", { name: /Confirm Appointment/i }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/Confirmed!/i)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("StatusBadge", () => {
|
||||
it("renders Confirmed for confirmed status", () => {
|
||||
render(<StatusBadge status="confirmed" />);
|
||||
expect(screen.getByText("Confirmed")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders Pending for pending status", () => {
|
||||
render(<StatusBadge status="pending" />);
|
||||
expect(screen.getByText("Pending")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders Waitlisted for waitlisted status", () => {
|
||||
render(<StatusBadge status="waitlisted" />);
|
||||
expect(screen.getByText("Waitlisted")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders Completed for completed status", () => {
|
||||
render(<StatusBadge status="completed" />);
|
||||
expect(screen.getByText("Completed")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders Cancelled for cancelled status", () => {
|
||||
render(<StatusBadge status="cancelled" />);
|
||||
expect(screen.getByText("Cancelled")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("falls back to status string for unknown status", () => {
|
||||
render(<StatusBadge status="custom-status" />);
|
||||
expect(screen.getByText("custom-status")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("uses correct CSS class for confirmed status", () => {
|
||||
render(<StatusBadge status="confirmed" />);
|
||||
const badge = screen.getByText("Confirmed").closest('span');
|
||||
expect(badge?.className).toContain("bg-green-100");
|
||||
expect(badge?.className).toContain("text-green-700");
|
||||
});
|
||||
|
||||
it("uses correct CSS class for waitlisted status", () => {
|
||||
render(<StatusBadge status="waitlisted" />);
|
||||
const badge = screen.getByText("Waitlisted").closest('span');
|
||||
expect(badge?.className).toContain("bg-blue-100");
|
||||
expect(badge?.className).toContain("text-blue-600");
|
||||
});
|
||||
|
||||
it("uses correct CSS class for pending status", () => {
|
||||
render(<StatusBadge status="pending" />);
|
||||
const badge = screen.getByText("Pending").closest('span');
|
||||
expect(badge?.className).toContain("bg-amber-100");
|
||||
expect(badge?.className).toContain("text-amber-600");
|
||||
});
|
||||
|
||||
it("uses fallback styling for unknown status", () => {
|
||||
render(<StatusBadge status="unknown" />);
|
||||
const badge = screen.getByText("unknown").closest('span');
|
||||
expect(badge?.className).toContain("bg-stone-100");
|
||||
expect(badge?.className).toContain("text-stone-600");
|
||||
});
|
||||
});
|
||||
|
||||
describe("RescheduleFlow dynamic time slots", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
global.fetch = vi.fn();
|
||||
});
|
||||
|
||||
const RESCHEDULE_APPT = {
|
||||
id: "appt-r1",
|
||||
petId: "pet-1",
|
||||
petName: "Buddy",
|
||||
groomerId: "groomer-1",
|
||||
groomerName: "Sarah",
|
||||
services: ["Bath & Brush"],
|
||||
serviceId: "service-1",
|
||||
addOns: [],
|
||||
date: "2027-01-01",
|
||||
time: "10:00 AM",
|
||||
duration: 60,
|
||||
price: 50,
|
||||
status: "confirmed" as const,
|
||||
notes: "",
|
||||
customerNotes: "",
|
||||
confirmationStatus: "confirmed" as const,
|
||||
};
|
||||
|
||||
it("shows loading state while fetching availability", async () => {
|
||||
vi.mocked(global.fetch).mockReturnValue(new Promise(() => {})); // Never resolves
|
||||
|
||||
const { RescheduleFlow } = await import("../portal/sections/Appointments.tsx");
|
||||
render(<RescheduleFlow appointment={RESCHEDULE_APPT} onClose={() => {}} sessionId="test-session-id" />);
|
||||
|
||||
const dateInput = screen.getByLabelText(/date/i) || screen.getByRole("textbox", { name: /date/i });
|
||||
fireEvent.change(dateInput, { target: { value: "2027-01-15" } });
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/Checking availability/i)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it("displays fetched time slots from API", async () => {
|
||||
vi.mocked(global.fetch).mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => ["9:00 AM", "10:00 AM", "2:00 PM"],
|
||||
} as Response);
|
||||
|
||||
const { RescheduleFlow } = await import("../portal/sections/Appointments.tsx");
|
||||
render(<RescheduleFlow appointment={RESCHEDULE_APPT} onClose={() => {}} sessionId="test-session-id" />);
|
||||
|
||||
const dateInput = screen.getByLabelText(/date/i) || screen.getByRole("textbox", { name: /date/i });
|
||||
fireEvent.change(dateInput, { target: { value: "2027-01-15" } });
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("9:00 AM")).toBeInTheDocument();
|
||||
expect(screen.getByText("10:00 AM")).toBeInTheDocument();
|
||||
expect(screen.getByText("2:00 PM")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it("shows error state when availability fetch fails", async () => {
|
||||
vi.mocked(global.fetch).mockRejectedValue(new Error("Network error"));
|
||||
|
||||
const { RescheduleFlow } = await import("../portal/sections/Appointments.tsx");
|
||||
render(<RescheduleFlow appointment={RESCHEDULE_APPT} onClose={() => {}} sessionId="test-session-id" />);
|
||||
|
||||
const dateInput = screen.getByLabelText(/date/i) || screen.getByRole("textbox", { name: /date/i });
|
||||
fireEvent.change(dateInput, { target: { value: "2027-01-15" } });
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/Failed to load time slots/i)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it("shows no slots message when API returns empty array", async () => {
|
||||
vi.mocked(global.fetch).mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => [] as string[],
|
||||
} as Response);
|
||||
|
||||
const { RescheduleFlow } = await import("../portal/sections/Appointments.tsx");
|
||||
render(<RescheduleFlow appointment={RESCHEDULE_APPT} onClose={() => {}} sessionId="test-session-id" />);
|
||||
|
||||
const dateInput = screen.getByLabelText(/date/i) || screen.getByRole("textbox", { name: /date/i });
|
||||
fireEvent.change(dateInput, { target: { value: "2027-01-15" } });
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/No available slots on this date/i)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it("calls /api/book/availability with the 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,106 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { render, waitFor } from "@testing-library/react";
|
||||
import { BrandingProvider, useBranding } from "../BrandingContext.js";
|
||||
|
||||
function BrandingConsumer() {
|
||||
const { branding } = useBranding();
|
||||
return (
|
||||
<div data-testid="branding">
|
||||
<span data-testid="primary">{branding.primaryColor}</span>
|
||||
<span data-testid="accent">{branding.accentColor}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
document.documentElement.style.removeProperty("--color-primary");
|
||||
document.documentElement.style.removeProperty("--color-accent");
|
||||
// Remove any theme-color meta tags
|
||||
document.querySelectorAll("meta[name='theme-color']").forEach((el) => el.remove());
|
||||
});
|
||||
|
||||
describe("BrandingProvider", () => {
|
||||
it("applies CSS vars to document root when branding loads", async () => {
|
||||
const branding = {
|
||||
businessName: "Test Salon",
|
||||
primaryColor: "#123456",
|
||||
accentColor: "#654321",
|
||||
logoBase64: null,
|
||||
logoMimeType: null,
|
||||
};
|
||||
global.fetch = vi.fn(() =>
|
||||
Promise.resolve({ ok: true, json: async () => branding } as Response)
|
||||
) as unknown as typeof fetch;
|
||||
|
||||
render(
|
||||
<BrandingProvider>
|
||||
<BrandingConsumer />
|
||||
</BrandingProvider>
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(document.documentElement.style.getPropertyValue("--color-primary")).toBe("#123456");
|
||||
expect(document.documentElement.style.getPropertyValue("--color-accent")).toBe("#654321");
|
||||
});
|
||||
});
|
||||
|
||||
it("creates and updates meta[name=theme-color]", async () => {
|
||||
const branding = {
|
||||
businessName: "Test Salon",
|
||||
primaryColor: "#abcdef",
|
||||
accentColor: "#fedcba",
|
||||
logoBase64: null,
|
||||
logoMimeType: null,
|
||||
};
|
||||
global.fetch = vi.fn(() =>
|
||||
Promise.resolve({ ok: true, json: async () => branding } as Response)
|
||||
) as unknown as typeof fetch;
|
||||
|
||||
render(
|
||||
<BrandingProvider>
|
||||
<BrandingConsumer />
|
||||
</BrandingProvider>
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
const meta = document.querySelector<HTMLMetaElement>("meta[name='theme-color']");
|
||||
expect(meta).not.toBeNull();
|
||||
expect(meta!.content).toBe("#abcdef");
|
||||
});
|
||||
});
|
||||
|
||||
it("does not create duplicate meta[name=theme-color] tags on rerender", async () => {
|
||||
const branding = {
|
||||
businessName: "Test Salon",
|
||||
primaryColor: "#111111",
|
||||
accentColor: "#222222",
|
||||
logoBase64: null,
|
||||
logoMimeType: null,
|
||||
};
|
||||
global.fetch = vi.fn(() =>
|
||||
Promise.resolve({ ok: true, json: async () => branding } as Response)
|
||||
) as unknown as typeof fetch;
|
||||
|
||||
const { rerender } = render(
|
||||
<BrandingProvider>
|
||||
<BrandingConsumer />
|
||||
</BrandingProvider>
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(document.querySelector("meta[name='theme-color']")).not.toBeNull();
|
||||
});
|
||||
|
||||
rerender(
|
||||
<BrandingProvider>
|
||||
<BrandingConsumer />
|
||||
</BrandingProvider>
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
const metas = document.querySelectorAll("meta[name='theme-color']");
|
||||
expect(metas.length).toBe(1);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,141 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { render, screen, waitFor } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { MemoryRouter } from "react-router-dom";
|
||||
import { GlobalSearch } from "../components/GlobalSearch.js";
|
||||
|
||||
const mockNavigate = vi.fn();
|
||||
vi.mock("react-router-dom", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("react-router-dom")>();
|
||||
return { ...actual, useNavigate: () => mockNavigate };
|
||||
});
|
||||
|
||||
function renderSearch() {
|
||||
return render(
|
||||
<MemoryRouter>
|
||||
<GlobalSearch />
|
||||
</MemoryRouter>
|
||||
);
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
mockNavigate.mockReset();
|
||||
global.fetch = vi.fn();
|
||||
});
|
||||
|
||||
describe("GlobalSearch", () => {
|
||||
it("renders the search input with correct aria attributes", () => {
|
||||
renderSearch();
|
||||
const input = screen.getByRole("combobox");
|
||||
expect(input).toBeInTheDocument();
|
||||
expect(input).toHaveAttribute("aria-label", "Search clients and pets");
|
||||
expect(input).toHaveAttribute("placeholder", "Search clients & pets…");
|
||||
});
|
||||
|
||||
it("does not fetch when query is empty or whitespace", async () => {
|
||||
renderSearch();
|
||||
const user = userEvent.setup({ delay: null });
|
||||
const input = screen.getByRole("combobox");
|
||||
await user.type(input, " ");
|
||||
// No debounce fires for blank input — verify fetch was never called
|
||||
await new Promise((r) => setTimeout(r, 350));
|
||||
expect(global.fetch).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("fetches after debounce and renders client results", async () => {
|
||||
(global.fetch as ReturnType<typeof vi.fn>).mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
clients: [{ id: "c1", name: "Alice Johnson", email: "alice@example.com", phone: "555-1234" }],
|
||||
pets: [],
|
||||
}),
|
||||
} as Response);
|
||||
|
||||
renderSearch();
|
||||
const user = userEvent.setup({ delay: null });
|
||||
await user.type(screen.getByRole("combobox"), "Alice");
|
||||
|
||||
await waitFor(() => expect(screen.getByText("Alice Johnson")).toBeInTheDocument(), {
|
||||
timeout: 1500,
|
||||
});
|
||||
expect(global.fetch).toHaveBeenCalledWith(expect.stringContaining("/api/search?q=Alice"));
|
||||
// Section header should appear
|
||||
expect(screen.getByText("Clients")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("fetches after debounce and renders pet results with owner name", async () => {
|
||||
(global.fetch as ReturnType<typeof vi.fn>).mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
clients: [],
|
||||
pets: [
|
||||
{ id: "p1", name: "Bella", breed: "Golden Retriever", clientId: "c1", ownerName: "Alice Johnson" },
|
||||
],
|
||||
}),
|
||||
} as Response);
|
||||
|
||||
renderSearch();
|
||||
const user = userEvent.setup({ delay: null });
|
||||
await user.type(screen.getByRole("combobox"), "Bella");
|
||||
|
||||
await waitFor(() => expect(screen.getByText("Bella")).toBeInTheDocument(), { timeout: 1500 });
|
||||
expect(screen.getByText("Owner: Alice Johnson")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows 'No results found' for a query that matches nothing", async () => {
|
||||
(global.fetch as ReturnType<typeof vi.fn>).mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => ({ clients: [], pets: [] }),
|
||||
} as Response);
|
||||
|
||||
renderSearch();
|
||||
const user = userEvent.setup({ delay: null });
|
||||
await user.type(screen.getByRole("combobox"), "xyzzy");
|
||||
|
||||
await waitFor(() => expect(screen.getByText("No results found")).toBeInTheDocument(), {
|
||||
timeout: 1500,
|
||||
});
|
||||
});
|
||||
|
||||
it("navigates to ?highlight=<id> and clears input when a client result is clicked", async () => {
|
||||
(global.fetch as ReturnType<typeof vi.fn>).mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
clients: [{ id: "c1", name: "Alice Johnson", email: null, phone: null }],
|
||||
pets: [],
|
||||
}),
|
||||
} as Response);
|
||||
|
||||
renderSearch();
|
||||
const user = userEvent.setup({ delay: null });
|
||||
const input = screen.getByRole("combobox");
|
||||
await user.type(input, "Alice");
|
||||
|
||||
await waitFor(() => screen.getByText("Alice Johnson"), { timeout: 1500 });
|
||||
await user.click(screen.getByText("Alice Johnson"));
|
||||
|
||||
expect(mockNavigate).toHaveBeenCalledWith("/admin/clients?highlight=c1");
|
||||
expect(input).toHaveValue("");
|
||||
});
|
||||
|
||||
it("navigates to owner client ?highlight=<clientId> when a pet result is clicked", async () => {
|
||||
(global.fetch as ReturnType<typeof vi.fn>).mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
clients: [],
|
||||
pets: [{ id: "p1", name: "Bella", breed: null, clientId: "c1", ownerName: "Alice" }],
|
||||
}),
|
||||
} as Response);
|
||||
|
||||
renderSearch();
|
||||
const user = userEvent.setup({ delay: null });
|
||||
const input = screen.getByRole("combobox");
|
||||
await user.type(input, "Bella");
|
||||
|
||||
await waitFor(() => screen.getByText("Bella"), { timeout: 1500 });
|
||||
await user.click(screen.getByText("Bella"));
|
||||
|
||||
expect(mockNavigate).toHaveBeenCalledWith("/admin/clients?highlight=c1");
|
||||
expect(input).toHaveValue("");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,135 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import { render, screen, fireEvent, act } from "@testing-library/react";
|
||||
import { ImpersonationBanner } from "../portal/ImpersonationBanner.js";
|
||||
import type { ImpersonationSession } from "@groombook/types";
|
||||
|
||||
function makeSession(overrides: Partial<ImpersonationSession> = {}): ImpersonationSession {
|
||||
const now = new Date();
|
||||
const expires = new Date(now.getTime() + 30 * 60 * 1000); // 30 min from now
|
||||
return {
|
||||
id: "session-uuid-1",
|
||||
staffId: "staff-uuid-1",
|
||||
clientId: "client-uuid-1",
|
||||
reason: "Customer requested help",
|
||||
status: "active",
|
||||
startedAt: now.toISOString(),
|
||||
endedAt: null,
|
||||
expiresAt: expires.toISOString(),
|
||||
createdAt: now.toISOString(),
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe("ImpersonationBanner", () => {
|
||||
const onEnd = vi.fn();
|
||||
const onExtend = vi.fn();
|
||||
const onShowAudit = vi.fn();
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.useFakeTimers();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it("renders banner when session is active", () => {
|
||||
render(
|
||||
<ImpersonationBanner
|
||||
session={makeSession()}
|
||||
isExtended={false}
|
||||
onEnd={onEnd}
|
||||
onExtend={onExtend}
|
||||
onShowAudit={onShowAudit}
|
||||
/>
|
||||
);
|
||||
expect(screen.getByText(/STAFF VIEW/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("calls onEnd when End Session is clicked", () => {
|
||||
render(
|
||||
<ImpersonationBanner
|
||||
session={makeSession()}
|
||||
isExtended={false}
|
||||
onEnd={onEnd}
|
||||
onExtend={onExtend}
|
||||
onShowAudit={onShowAudit}
|
||||
/>
|
||||
);
|
||||
fireEvent.click(screen.getByRole("button", { name: /end session/i }));
|
||||
expect(onEnd).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("calls onShowAudit when Audit is clicked", () => {
|
||||
render(
|
||||
<ImpersonationBanner
|
||||
session={makeSession()}
|
||||
isExtended={false}
|
||||
onEnd={onEnd}
|
||||
onExtend={onExtend}
|
||||
onShowAudit={onShowAudit}
|
||||
/>
|
||||
);
|
||||
fireEvent.click(screen.getByRole("button", { name: /audit/i }));
|
||||
expect(onShowAudit).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("calls onEnd automatically when session expires", async () => {
|
||||
const expiredSoon = new Date(Date.now() + 500);
|
||||
const session = makeSession({ expiresAt: expiredSoon.toISOString() });
|
||||
|
||||
render(
|
||||
<ImpersonationBanner
|
||||
session={session}
|
||||
isExtended={false}
|
||||
onEnd={onEnd}
|
||||
onExtend={onExtend}
|
||||
onShowAudit={onShowAudit}
|
||||
/>
|
||||
);
|
||||
|
||||
// Advance past expiry
|
||||
await act(async () => {
|
||||
vi.advanceTimersByTime(2000);
|
||||
});
|
||||
|
||||
expect(onEnd).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("shows Extend button when warning is active and session not yet extended", () => {
|
||||
// Set expiry to 3 min from now — within warning threshold (< 5 min)
|
||||
const expiresAt = new Date(Date.now() + 3 * 60 * 1000).toISOString();
|
||||
render(
|
||||
<ImpersonationBanner
|
||||
session={makeSession({ expiresAt })}
|
||||
isExtended={false}
|
||||
onEnd={onEnd}
|
||||
onExtend={onExtend}
|
||||
onShowAudit={onShowAudit}
|
||||
/>
|
||||
);
|
||||
// Tick the timer once to trigger showWarning
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(1000);
|
||||
});
|
||||
expect(screen.getByRole("button", { name: /extend/i })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("does not show Extend button when already extended", () => {
|
||||
const expiresAt = new Date(Date.now() + 3 * 60 * 1000).toISOString();
|
||||
render(
|
||||
<ImpersonationBanner
|
||||
session={makeSession({ expiresAt })}
|
||||
isExtended={true}
|
||||
onEnd={onEnd}
|
||||
onExtend={onExtend}
|
||||
onShowAudit={onShowAudit}
|
||||
/>
|
||||
);
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(1000);
|
||||
});
|
||||
expect(screen.queryByRole("button", { name: /extend/i })).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,165 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { render, screen, fireEvent } from "@testing-library/react";
|
||||
import { PetForm } from "../portal/sections/PetForm.js";
|
||||
import type { Pet } from "@groombook/types";
|
||||
|
||||
const BASE_PET: Pet = {
|
||||
id: "pet-1",
|
||||
clientId: "client-1",
|
||||
name: "Buddy",
|
||||
species: "dog",
|
||||
breed: "Labrador",
|
||||
weightKg: 25,
|
||||
dateOfBirth: "2020-03-15T00:00:00.000Z",
|
||||
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("PetForm", () => {
|
||||
const onSave = vi.fn();
|
||||
const onCancel = vi.fn();
|
||||
|
||||
beforeEach(() => {
|
||||
onSave.mockClear();
|
||||
onCancel.mockClear();
|
||||
});
|
||||
|
||||
// ── Coat type ───────────────────────────────────────────────────────────────
|
||||
|
||||
it("allows coat type selection from dropdown", () => {
|
||||
render(<PetForm pet={BASE_PET} onSave={onSave} onCancel={onCancel} />);
|
||||
const select = screen.getByRole("combobox", { name: /coat type/i });
|
||||
fireEvent.change(select, { target: { value: "curly" } });
|
||||
expect((select as HTMLSelectElement).value).toBe("curly");
|
||||
});
|
||||
|
||||
it("persists coat type on save", () => {
|
||||
render(<PetForm pet={BASE_PET} onSave={onSave} onCancel={onCancel} />);
|
||||
fireEvent.change(screen.getByRole("combobox", { name: /coat type/i }), { target: { value: "double" } });
|
||||
fireEvent.click(screen.getByRole("button", { name: /save/i }));
|
||||
expect(onSave).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ coatType: "double" })
|
||||
);
|
||||
});
|
||||
|
||||
// ── Preferred cuts tag input ────────────────────────────────────────────────
|
||||
|
||||
it("adds a cut when Enter is pressed", () => {
|
||||
render(<PetForm pet={BASE_PET} onSave={onSave} onCancel={onCancel} />);
|
||||
const input = screen.getByPlaceholderText(/type a cut name/i);
|
||||
fireEvent.change(input, { target: { value: "Puppy Cut" } });
|
||||
fireEvent.keyDown(input, { key: "Enter" });
|
||||
expect(screen.getByText("Puppy Cut")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("adds a cut when the + button is clicked", () => {
|
||||
render(<PetForm pet={BASE_PET} onSave={onSave} onCancel={onCancel} />);
|
||||
const input = screen.getByPlaceholderText(/type a cut name/i);
|
||||
fireEvent.change(input, { target: { value: "Teddy Bear" } });
|
||||
fireEvent.click(screen.getByRole("button", { name: "Add" }));
|
||||
expect(screen.getByText("Teddy Bear")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("removes a cut when X is clicked", () => {
|
||||
const petWithCuts: Pet = {
|
||||
...BASE_PET,
|
||||
preferredCuts: ["Puppy Cut", "Teddy Bear"],
|
||||
};
|
||||
render(<PetForm pet={petWithCuts} onSave={onSave} onCancel={onCancel} />);
|
||||
const puppyCutSpans = screen.getAllByText("Puppy Cut");
|
||||
const puppyCutTag = puppyCutSpans[0]?.closest("span");
|
||||
if (!puppyCutTag) return;
|
||||
const removeBtn = puppyCutTag.querySelector("button");
|
||||
if (!removeBtn) return;
|
||||
fireEvent.click(removeBtn);
|
||||
expect(screen.queryByText("Puppy Cut")).toBeNull();
|
||||
expect(screen.getByText("Teddy Bear")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("includes preferred cuts in save payload", () => {
|
||||
render(<PetForm pet={BASE_PET} onSave={onSave} onCancel={onCancel} />);
|
||||
fireEvent.change(screen.getByPlaceholderText(/type a cut name/i), { target: { value: "Puppy Cut" } });
|
||||
fireEvent.keyDown(screen.getByPlaceholderText(/type a cut name/i), { key: "Enter" });
|
||||
fireEvent.click(screen.getByRole("button", { name: /save/i }));
|
||||
expect(onSave).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ preferredCuts: ["Puppy Cut"] })
|
||||
);
|
||||
});
|
||||
|
||||
// ── Medical alerts ───────────────────────────────────────────────────────────
|
||||
|
||||
it("adds a medical alert", () => {
|
||||
render(<PetForm pet={BASE_PET} onSave={onSave} onCancel={onCancel} />);
|
||||
fireEvent.click(screen.getByRole("button", { name: /add alert/i }));
|
||||
expect(screen.getByPlaceholderText(/alert type/i)).toBeTruthy();
|
||||
});
|
||||
|
||||
it("removes a medical alert", () => {
|
||||
const petWithAlert: Pet = {
|
||||
...BASE_PET,
|
||||
medicalAlerts: [{ id: "alert-1", type: "Allergic to chicken", description: "Causes hives", severity: "high" }],
|
||||
};
|
||||
render(<PetForm pet={petWithAlert} onSave={onSave} onCancel={onCancel} />);
|
||||
const removeButtons = screen.getAllByRole("button", { name: "" });
|
||||
if (removeButtons.length === 0) return;
|
||||
const removeButton = removeButtons[0]!;
|
||||
if (!removeButton) return;
|
||||
fireEvent.click(removeButton);
|
||||
expect(screen.queryByText("Allergic to chicken")).toBeNull();
|
||||
});
|
||||
|
||||
it("validates alert type is non-empty", () => {
|
||||
render(<PetForm pet={BASE_PET} onSave={onSave} onCancel={onCancel} />);
|
||||
fireEvent.click(screen.getByRole("button", { name: /add alert/i }));
|
||||
fireEvent.click(screen.getByRole("button", { name: /save/i }));
|
||||
expect(screen.getByText(/type is required/i)).toBeTruthy();
|
||||
expect(onSave).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("shows medical alerts in save payload", () => {
|
||||
const petWithAlert: Pet = {
|
||||
...BASE_PET,
|
||||
medicalAlerts: [{ id: "alert-1", type: "Sensitive skin", description: "Use hypoallergenic shampoo only", severity: "medium" }],
|
||||
};
|
||||
render(<PetForm pet={petWithAlert} onSave={onSave} onCancel={onCancel} />);
|
||||
fireEvent.click(screen.getByRole("button", { name: /save/i }));
|
||||
expect(onSave).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
medicalAlerts: expect.arrayContaining([
|
||||
expect.objectContaining({ type: "Sensitive skin", severity: "medium" }),
|
||||
]),
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
// ── Temperament read-only display ─────────────────────────────────────────────
|
||||
|
||||
it("displays temperament score as read-only stars", () => {
|
||||
const petWithTemperament: Pet = {
|
||||
...BASE_PET,
|
||||
temperamentScore: 4,
|
||||
temperamentFlags: ["Anxious", "Good with kids"],
|
||||
};
|
||||
render(<PetForm pet={petWithTemperament} onSave={onSave} onCancel={onCancel} />);
|
||||
expect(screen.getByText("(4/5)")).toBeTruthy();
|
||||
expect(screen.getByText("Anxious")).toBeTruthy();
|
||||
expect(screen.getByText("Good with kids")).toBeTruthy();
|
||||
});
|
||||
|
||||
// ── Weight pre-fill from portal `weight` key (GRO-2207) ───────────────────────
|
||||
|
||||
it("pre-fills weight from the portal `weight` key when weightKg is absent", () => {
|
||||
const portalPet: Pet = { ...BASE_PET, weightKg: null, weight: "12.50" };
|
||||
render(<PetForm pet={portalPet} onSave={onSave} onCancel={onCancel} />);
|
||||
expect(screen.getByDisplayValue(12.5)).toBeTruthy();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,100 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { render, screen, waitFor } from "@testing-library/react";
|
||||
import { PetPhotoDisplay } from "../components/PetPhotoDisplay.js";
|
||||
|
||||
beforeEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe("PetPhotoDisplay", () => {
|
||||
it("shows loading skeleton while fetching", () => {
|
||||
global.fetch = vi.fn(() => new Promise(() => {})) as unknown as typeof fetch;
|
||||
|
||||
render(<PetPhotoDisplay petId="pet-1" />);
|
||||
|
||||
expect(screen.getByLabelText("Loading photo…")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders photo img when fetch returns a URL", async () => {
|
||||
global.fetch = vi.fn(() =>
|
||||
Promise.resolve({
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: async () => ({ url: "https://storage.test/pet-1/photo.jpg" }),
|
||||
} as Response)
|
||||
) as unknown as typeof fetch;
|
||||
|
||||
render(<PetPhotoDisplay petId="pet-1" />);
|
||||
|
||||
const img = await screen.findByRole("img", { name: "Pet photo" });
|
||||
expect(img).toHaveAttribute("src", "https://storage.test/pet-1/photo.jpg");
|
||||
});
|
||||
|
||||
it("shows paw placeholder when API returns 404", async () => {
|
||||
global.fetch = vi.fn(() =>
|
||||
Promise.resolve({ ok: false, status: 404 } as Response)
|
||||
) as unknown as typeof fetch;
|
||||
|
||||
render(<PetPhotoDisplay petId="pet-1" />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByLabelText("No photo")).toBeInTheDocument();
|
||||
});
|
||||
expect(screen.queryByLabelText("Loading photo…")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows paw placeholder when fetch rejects (network error)", async () => {
|
||||
global.fetch = vi.fn(() => Promise.reject(new Error("network error"))) as unknown as typeof fetch;
|
||||
|
||||
render(<PetPhotoDisplay petId="pet-1" />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByLabelText("No photo")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it("shows paw placeholder on non-404 error status", async () => {
|
||||
global.fetch = vi.fn(() =>
|
||||
Promise.resolve({ ok: false, status: 500 } as Response)
|
||||
) as unknown as typeof fetch;
|
||||
|
||||
render(<PetPhotoDisplay petId="pet-1" />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByLabelText("No photo")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it("refetches when petId changes", async () => {
|
||||
const fetchMock = vi.fn((url: string) => {
|
||||
const petId = (url as string).match(/\/api\/pets\/([^/]+)\/photo/)?.[1];
|
||||
return Promise.resolve({
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: async () => ({ url: `https://storage.test/${petId}/photo.jpg` }),
|
||||
} as Response);
|
||||
}) as unknown as typeof fetch;
|
||||
global.fetch = fetchMock;
|
||||
|
||||
const { rerender } = render(<PetPhotoDisplay petId="pet-1" />);
|
||||
await screen.findByRole("img");
|
||||
expect(fetchMock).toHaveBeenCalledWith("/api/pets/pet-1/photo");
|
||||
|
||||
rerender(<PetPhotoDisplay petId="pet-2" />);
|
||||
await waitFor(() => {
|
||||
expect(fetchMock).toHaveBeenCalledWith("/api/pets/pet-2/photo");
|
||||
});
|
||||
});
|
||||
|
||||
it("applies custom size prop to container", async () => {
|
||||
global.fetch = vi.fn(() =>
|
||||
Promise.resolve({ ok: false, status: 404 } as Response)
|
||||
) as unknown as typeof fetch;
|
||||
|
||||
const { container } = render(<PetPhotoDisplay petId="pet-1" size={96} />);
|
||||
|
||||
await screen.findByLabelText("No photo");
|
||||
const div = container.firstChild as HTMLElement;
|
||||
expect(div).toHaveStyle({ width: "96px", height: "96px" });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,311 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import { render, screen, fireEvent, waitFor } from "@testing-library/react";
|
||||
import { PetPhotoUpload } from "../components/PetPhotoUpload.js";
|
||||
|
||||
// ── XHR mock ─────────────────────────────────────────────────────────────────
|
||||
|
||||
interface XhrMock {
|
||||
upload: { addEventListener: ReturnType<typeof vi.fn> };
|
||||
addEventListener: ReturnType<typeof vi.fn>;
|
||||
open: ReturnType<typeof vi.fn>;
|
||||
setRequestHeader: ReturnType<typeof vi.fn>;
|
||||
send: ReturnType<typeof vi.fn>;
|
||||
status: number;
|
||||
// Callbacks stored by the mock so tests can trigger them
|
||||
_triggerLoad: () => void;
|
||||
_triggerError: () => void;
|
||||
_triggerProgress: (loaded: number, total: number) => void;
|
||||
}
|
||||
|
||||
function makeXhrMock(status = 200): XhrMock {
|
||||
const uploadListeners: Record<string, (ev: ProgressEvent) => void> = {};
|
||||
const listeners: Record<string, () => void> = {};
|
||||
|
||||
const mock: XhrMock = {
|
||||
upload: {
|
||||
addEventListener: vi.fn((event: string, cb: (ev: ProgressEvent) => void) => {
|
||||
uploadListeners[event] = cb;
|
||||
}),
|
||||
},
|
||||
addEventListener: vi.fn((event: string, cb: () => void) => {
|
||||
listeners[event] = cb;
|
||||
}),
|
||||
open: vi.fn(),
|
||||
setRequestHeader: vi.fn(),
|
||||
send: vi.fn(),
|
||||
status,
|
||||
_triggerLoad: () => listeners["load"]?.(),
|
||||
_triggerError: () => listeners["error"]?.(),
|
||||
_triggerProgress: (loaded, total) =>
|
||||
uploadListeners["progress"]?.({ lengthComputable: true, loaded, total } as ProgressEvent),
|
||||
};
|
||||
return mock;
|
||||
}
|
||||
|
||||
// ── Canvas mock ───────────────────────────────────────────────────────────────
|
||||
|
||||
// jsdom doesn't implement canvas — provide a minimal stub
|
||||
function mockCanvas(blob: Blob) {
|
||||
const ctx = { drawImage: vi.fn() };
|
||||
const originalCreateElement = document.createElement.bind(document);
|
||||
vi.spyOn(document, "createElement").mockImplementation((tag: string) => {
|
||||
if (tag === "canvas") {
|
||||
const canvas = {
|
||||
width: 0,
|
||||
height: 0,
|
||||
getContext: () => ctx,
|
||||
toBlob: (cb: (b: Blob | null) => void) => cb(blob),
|
||||
};
|
||||
return canvas as unknown as HTMLCanvasElement;
|
||||
}
|
||||
return originalCreateElement(tag);
|
||||
});
|
||||
}
|
||||
|
||||
// ── Image mock ────────────────────────────────────────────────────────────────
|
||||
|
||||
function mockImage(width = 800, height = 600) {
|
||||
const originalImage = globalThis.Image;
|
||||
const ImageMock = vi.fn().mockImplementation(() => {
|
||||
const img = {
|
||||
width,
|
||||
height,
|
||||
onload: null as (() => void) | null,
|
||||
onerror: null as (() => void) | null,
|
||||
set src(_v: string) {
|
||||
// trigger onload asynchronously
|
||||
setTimeout(() => img.onload?.(), 0);
|
||||
},
|
||||
};
|
||||
return img;
|
||||
});
|
||||
globalThis.Image = ImageMock as unknown as typeof Image;
|
||||
return () => {
|
||||
globalThis.Image = originalImage;
|
||||
};
|
||||
}
|
||||
|
||||
// ── URL mock ──────────────────────────────────────────────────────────────────
|
||||
|
||||
beforeEach(() => {
|
||||
URL.createObjectURL = vi.fn(() => "blob:mock");
|
||||
URL.revokeObjectURL = vi.fn();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
// ── Helpers ───────────────────────────────────────────────────────────────────
|
||||
|
||||
function makeFile(type = "image/jpeg", name = "photo.jpg", sizeBytes = 1024): File {
|
||||
const buf = new Uint8Array(sizeBytes);
|
||||
return new File([buf], name, { type });
|
||||
}
|
||||
|
||||
function selectFile(file: File) {
|
||||
const input = document.querySelector('input[type="file"]') as HTMLInputElement;
|
||||
Object.defineProperty(input, "files", { value: [file], configurable: true });
|
||||
fireEvent.change(input);
|
||||
}
|
||||
|
||||
// ── Tests ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe("PetPhotoUpload", () => {
|
||||
it("renders the upload button in idle state", () => {
|
||||
render(<PetPhotoUpload petId="pet-1" onUploaded={vi.fn()} />);
|
||||
expect(screen.getByRole("button", { name: /upload photo/i })).toBeInTheDocument();
|
||||
expect(screen.getByRole("button")).not.toBeDisabled();
|
||||
});
|
||||
|
||||
it("shows an error for an unsupported file type", async () => {
|
||||
render(<PetPhotoUpload petId="pet-1" onUploaded={vi.fn()} />);
|
||||
selectFile(makeFile("text/plain", "doc.txt"));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/JPEG, PNG, WebP, or GIF/i)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it("disables the button while uploading", async () => {
|
||||
const restoreImage = mockImage();
|
||||
const resizedBlob = new Blob(["x"], { type: "image/jpeg" });
|
||||
mockCanvas(resizedBlob);
|
||||
|
||||
let xhrInstance: XhrMock;
|
||||
const XHRMock = vi.fn().mockImplementation(() => {
|
||||
xhrInstance = makeXhrMock(200);
|
||||
return xhrInstance;
|
||||
});
|
||||
globalThis.XMLHttpRequest = XHRMock as unknown as typeof XMLHttpRequest;
|
||||
|
||||
global.fetch = vi.fn(() =>
|
||||
Promise.resolve({
|
||||
ok: true,
|
||||
json: async () => ({ uploadUrl: "https://storage.test/put", key: "pets/pet-1/123.jpg" }),
|
||||
} as Response)
|
||||
) as unknown as typeof fetch;
|
||||
|
||||
render(<PetPhotoUpload petId="pet-1" onUploaded={vi.fn()} />);
|
||||
selectFile(makeFile("image/jpeg"));
|
||||
|
||||
// Button should become disabled during upload
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole("button")).toBeDisabled();
|
||||
});
|
||||
|
||||
restoreImage();
|
||||
});
|
||||
|
||||
it("calls onUploaded and resets after successful upload", async () => {
|
||||
const restoreImage = mockImage();
|
||||
const resizedBlob = new Blob(["x"], { type: "image/jpeg" });
|
||||
mockCanvas(resizedBlob);
|
||||
|
||||
let xhrInstance!: XhrMock;
|
||||
const XHRMock = vi.fn().mockImplementation(() => {
|
||||
xhrInstance = makeXhrMock(200);
|
||||
return xhrInstance;
|
||||
});
|
||||
globalThis.XMLHttpRequest = XHRMock as unknown as typeof XMLHttpRequest;
|
||||
|
||||
const onUploaded = vi.fn();
|
||||
global.fetch = vi.fn((url: string) => {
|
||||
if ((url as string).includes("upload-url")) {
|
||||
return Promise.resolve({
|
||||
ok: true,
|
||||
json: async () => ({ uploadUrl: "https://storage.test/put", key: "pets/pet-1/123.jpg" }),
|
||||
} as Response);
|
||||
}
|
||||
// confirm
|
||||
return Promise.resolve({ ok: true, json: async () => ({ ok: true }) } as Response);
|
||||
}) as unknown as typeof fetch;
|
||||
|
||||
render(<PetPhotoUpload petId="pet-1" onUploaded={onUploaded} />);
|
||||
selectFile(makeFile("image/jpeg"));
|
||||
|
||||
// Wait for XHR to be set up, then trigger load
|
||||
await waitFor(() => expect(xhrInstance).toBeDefined());
|
||||
xhrInstance._triggerLoad();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(onUploaded).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
restoreImage();
|
||||
});
|
||||
|
||||
it("shows error message when upload-url request fails", async () => {
|
||||
const restoreImage = mockImage();
|
||||
const resizedBlob = new Blob(["x"], { type: "image/jpeg" });
|
||||
mockCanvas(resizedBlob);
|
||||
|
||||
global.fetch = vi.fn(() =>
|
||||
Promise.resolve({
|
||||
ok: false,
|
||||
json: async () => ({ error: "Pet not found" }),
|
||||
} as Response)
|
||||
) as unknown as typeof fetch;
|
||||
|
||||
render(<PetPhotoUpload petId="pet-1" onUploaded={vi.fn()} />);
|
||||
selectFile(makeFile("image/jpeg"));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/Pet not found/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
restoreImage();
|
||||
});
|
||||
|
||||
it("shows error message when XHR upload fails", async () => {
|
||||
const restoreImage = mockImage();
|
||||
const resizedBlob = new Blob(["x"], { type: "image/jpeg" });
|
||||
mockCanvas(resizedBlob);
|
||||
|
||||
let xhrInstance!: XhrMock;
|
||||
const XHRMock = vi.fn().mockImplementation(() => {
|
||||
xhrInstance = makeXhrMock(0);
|
||||
return xhrInstance;
|
||||
});
|
||||
globalThis.XMLHttpRequest = XHRMock as unknown as typeof XMLHttpRequest;
|
||||
|
||||
global.fetch = vi.fn(() =>
|
||||
Promise.resolve({
|
||||
ok: true,
|
||||
json: async () => ({ uploadUrl: "https://storage.test/put", key: "pets/pet-1/123.jpg" }),
|
||||
} as Response)
|
||||
) as unknown as typeof fetch;
|
||||
|
||||
render(<PetPhotoUpload petId="pet-1" onUploaded={vi.fn()} />);
|
||||
selectFile(makeFile("image/jpeg"));
|
||||
|
||||
await waitFor(() => expect(xhrInstance).toBeDefined());
|
||||
xhrInstance._triggerError();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/network error/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
restoreImage();
|
||||
});
|
||||
|
||||
it("shows upload progress percentage", async () => {
|
||||
const restoreImage = mockImage();
|
||||
const resizedBlob = new Blob(["x"], { type: "image/jpeg" });
|
||||
mockCanvas(resizedBlob);
|
||||
|
||||
let xhrInstance!: XhrMock;
|
||||
const XHRMock = vi.fn().mockImplementation(() => {
|
||||
xhrInstance = makeXhrMock(200);
|
||||
return xhrInstance;
|
||||
});
|
||||
globalThis.XMLHttpRequest = XHRMock as unknown as typeof XMLHttpRequest;
|
||||
|
||||
global.fetch = vi.fn(() =>
|
||||
Promise.resolve({
|
||||
ok: true,
|
||||
json: async () => ({ uploadUrl: "https://storage.test/put", key: "pets/pet-1/123.jpg" }),
|
||||
} as Response)
|
||||
) as unknown as typeof fetch;
|
||||
|
||||
render(<PetPhotoUpload petId="pet-1" onUploaded={vi.fn()} />);
|
||||
selectFile(makeFile("image/jpeg"));
|
||||
|
||||
await waitFor(() => expect(xhrInstance).toBeDefined());
|
||||
xhrInstance._triggerProgress(50, 100);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/Uploading 50%/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
restoreImage();
|
||||
});
|
||||
|
||||
it("skips canvas resize for GIF files", async () => {
|
||||
const createElementSpy = vi.spyOn(document, "createElement");
|
||||
|
||||
global.fetch = vi.fn(() =>
|
||||
Promise.resolve({
|
||||
ok: true,
|
||||
json: async () => ({ uploadUrl: "https://storage.test/put", key: "pets/pet-1/123.gif" }),
|
||||
} as Response)
|
||||
) as unknown as typeof fetch;
|
||||
|
||||
let xhrInstance!: XhrMock;
|
||||
const XHRMock = vi.fn().mockImplementation(() => {
|
||||
xhrInstance = makeXhrMock(200);
|
||||
return xhrInstance;
|
||||
});
|
||||
globalThis.XMLHttpRequest = XHRMock as unknown as typeof XMLHttpRequest;
|
||||
|
||||
render(<PetPhotoUpload petId="pet-1" onUploaded={vi.fn()} />);
|
||||
selectFile(makeFile("image/gif", "anim.gif", 512));
|
||||
|
||||
// Wait for XHR to be invoked
|
||||
await waitFor(() => expect(xhrInstance).toBeDefined());
|
||||
|
||||
// canvas should NOT have been created for GIF
|
||||
const canvasCalls = createElementSpy.mock.calls.filter(([tag]) => tag === "canvas");
|
||||
expect(canvasCalls.length).toBe(0);
|
||||
});
|
||||
});
|
||||
@@ -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,172 @@
|
||||
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);
|
||||
}
|
||||
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" })
|
||||
)
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,615 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { render, screen, waitFor, fireEvent } from "@testing-library/react";
|
||||
import { MemoryRouter } from "react-router-dom";
|
||||
import { ImpersonationBanner } from "../portal/ImpersonationBanner.js";
|
||||
import { AuditLogViewer } from "../portal/AuditLogViewer.js";
|
||||
import type { ImpersonationSession, ImpersonationAuditLog } from "@groombook/types";
|
||||
|
||||
// Spy on the RescheduleFlow so we can assert the sessionId prop it receives
|
||||
// from CustomerPortal without rendering the full flow UI. The real module is
|
||||
// still loaded via importActual; only RescheduleFlow is swapped.
|
||||
const rescheduleFlowSpy = vi.hoisted(() =>
|
||||
vi.fn((_props: { sessionId: string | null; appointment: { id: string } }) => null)
|
||||
);
|
||||
vi.mock("../portal/sections/Appointments.js", async () => {
|
||||
const actual = await vi.importActual<typeof import("../portal/sections/Appointments.js")>(
|
||||
"../portal/sections/Appointments.js"
|
||||
);
|
||||
return {
|
||||
...actual,
|
||||
RescheduleFlow: rescheduleFlowSpy,
|
||||
};
|
||||
});
|
||||
|
||||
const SESSION: ImpersonationSession = {
|
||||
id: "sess-1",
|
||||
staffId: "staff-1",
|
||||
clientId: "client-1",
|
||||
reason: "Customer reported missing appointment",
|
||||
status: "active",
|
||||
startedAt: new Date(Date.now() - 5 * 60_000).toISOString(),
|
||||
endedAt: null,
|
||||
expiresAt: new Date(Date.now() + 25 * 60_000).toISOString(),
|
||||
createdAt: new Date(Date.now() - 5 * 60_000).toISOString(),
|
||||
};
|
||||
|
||||
const AUDIT_LOGS: ImpersonationAuditLog[] = [
|
||||
{
|
||||
id: "log-1",
|
||||
sessionId: "sess-1",
|
||||
action: "session_started",
|
||||
pageVisited: null,
|
||||
metadata: { reason: "Customer reported missing appointment" },
|
||||
createdAt: new Date(Date.now() - 5 * 60_000).toISOString(),
|
||||
},
|
||||
{
|
||||
id: "log-2",
|
||||
sessionId: "sess-1",
|
||||
action: "page_view",
|
||||
pageVisited: "appointments",
|
||||
metadata: null,
|
||||
createdAt: new Date(Date.now() - 3 * 60_000).toISOString(),
|
||||
},
|
||||
];
|
||||
|
||||
// ─── ImpersonationBanner ────────────────────────────────────────────────────
|
||||
|
||||
describe("ImpersonationBanner", () => {
|
||||
it("renders STAFF VIEW label", () => {
|
||||
render(
|
||||
<ImpersonationBanner
|
||||
session={SESSION}
|
||||
isExtended={false}
|
||||
onEnd={vi.fn()}
|
||||
onExtend={vi.fn()}
|
||||
onShowAudit={vi.fn()}
|
||||
/>
|
||||
);
|
||||
expect(screen.getByText("STAFF VIEW")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("displays the session reason", () => {
|
||||
render(
|
||||
<ImpersonationBanner
|
||||
session={SESSION}
|
||||
isExtended={false}
|
||||
onEnd={vi.fn()}
|
||||
onExtend={vi.fn()}
|
||||
onShowAudit={vi.fn()}
|
||||
/>
|
||||
);
|
||||
expect(screen.getByText(/Customer reported missing appointment/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("calls onEnd when End Session is clicked", () => {
|
||||
const onEnd = vi.fn();
|
||||
render(
|
||||
<ImpersonationBanner
|
||||
session={SESSION}
|
||||
isExtended={false}
|
||||
onEnd={onEnd}
|
||||
onExtend={vi.fn()}
|
||||
onShowAudit={vi.fn()}
|
||||
/>
|
||||
);
|
||||
fireEvent.click(screen.getByRole("button", { name: /End Session/i }));
|
||||
expect(onEnd).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("calls onShowAudit when Audit is clicked", () => {
|
||||
const onShowAudit = vi.fn();
|
||||
render(
|
||||
<ImpersonationBanner
|
||||
session={SESSION}
|
||||
isExtended={false}
|
||||
onEnd={vi.fn()}
|
||||
onExtend={vi.fn()}
|
||||
onShowAudit={onShowAudit}
|
||||
/>
|
||||
);
|
||||
fireEvent.click(screen.getByRole("button", { name: /Audit/i }));
|
||||
expect(onShowAudit).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("shows Extend button when less than 5 minutes remain and not yet extended", async () => {
|
||||
const nearlyExpiredSession: ImpersonationSession = {
|
||||
...SESSION,
|
||||
expiresAt: new Date(Date.now() + 3 * 60_000).toISOString(), // 3 min left
|
||||
};
|
||||
render(
|
||||
<ImpersonationBanner
|
||||
session={nearlyExpiredSession}
|
||||
isExtended={false}
|
||||
onEnd={vi.fn()}
|
||||
onExtend={vi.fn()}
|
||||
onShowAudit={vi.fn()}
|
||||
/>
|
||||
);
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole("button", { name: /Extend/i })).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it("does not show Extend button when already extended", async () => {
|
||||
const nearlyExpiredSession: ImpersonationSession = {
|
||||
...SESSION,
|
||||
expiresAt: new Date(Date.now() + 3 * 60_000).toISOString(),
|
||||
};
|
||||
render(
|
||||
<ImpersonationBanner
|
||||
session={nearlyExpiredSession}
|
||||
isExtended={true}
|
||||
onEnd={vi.fn()}
|
||||
onExtend={vi.fn()}
|
||||
onShowAudit={vi.fn()}
|
||||
/>
|
||||
);
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByRole("button", { name: /Extend/i })).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ─── AuditLogViewer ─────────────────────────────────────────────────────────
|
||||
|
||||
describe("AuditLogViewer", () => {
|
||||
beforeEach(() => {
|
||||
global.fetch = vi.fn();
|
||||
});
|
||||
|
||||
it("fetches and displays audit log entries", async () => {
|
||||
vi.mocked(global.fetch).mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => [...AUDIT_LOGS].reverse(), // API returns newest-first
|
||||
} as Response);
|
||||
|
||||
render(<AuditLogViewer sessionId="sess-1" onClose={vi.fn()} />);
|
||||
|
||||
await waitFor(() => {
|
||||
// "session started" appears in both the filter dropdown option and the log entry span
|
||||
expect(screen.getAllByText("session started").length).toBeGreaterThanOrEqual(1);
|
||||
});
|
||||
expect(screen.getByText("appointments")).toBeInTheDocument();
|
||||
expect(global.fetch).toHaveBeenCalledWith("/api/impersonation/sessions/sess-1/audit-log");
|
||||
});
|
||||
|
||||
it("shows error state when fetch fails", async () => {
|
||||
vi.mocked(global.fetch).mockResolvedValue({
|
||||
ok: false,
|
||||
status: 403,
|
||||
} as Response);
|
||||
|
||||
render(<AuditLogViewer sessionId="sess-1" onClose={vi.fn()} />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/Failed to load audit log/i)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it("shows loading state initially", () => {
|
||||
vi.mocked(global.fetch).mockReturnValue(new Promise(() => {}));
|
||||
|
||||
render(<AuditLogViewer sessionId="sess-1" onClose={vi.fn()} />);
|
||||
|
||||
expect(screen.getByText(/Loading audit log/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("calls onClose when X button is clicked", async () => {
|
||||
vi.mocked(global.fetch).mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => [],
|
||||
} as Response);
|
||||
|
||||
const onClose = vi.fn();
|
||||
render(<AuditLogViewer sessionId="sess-1" onClose={onClose} />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/No audit entries/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "" }));
|
||||
expect(onClose).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("filters entries by action type", async () => {
|
||||
vi.mocked(global.fetch).mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => [...AUDIT_LOGS].reverse(),
|
||||
} as Response);
|
||||
|
||||
render(<AuditLogViewer sessionId="sess-1" onClose={vi.fn()} />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getAllByText("session started").length).toBeGreaterThanOrEqual(1);
|
||||
});
|
||||
|
||||
// Filter to page_view only
|
||||
const select = screen.getByRole("combobox");
|
||||
fireEvent.change(select, { target: { value: "page_view" } });
|
||||
|
||||
expect(screen.getByText("appointments")).toBeInTheDocument();
|
||||
// After filtering, the "session started" span (log entry) should be gone
|
||||
// The option in the select still has the text but the log entry span does not
|
||||
const spans = document.querySelectorAll("span.inline-block");
|
||||
expect(Array.from(spans).every((s) => s.textContent !== "session started")).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── CustomerPortal — session loading ──────────────────────────────────────
|
||||
|
||||
describe("CustomerPortal session loading", () => {
|
||||
beforeEach(() => {
|
||||
global.fetch = vi.fn((url: string) => {
|
||||
if (url === "/api/branding") {
|
||||
return Promise.resolve({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
businessName: "GroomBook",
|
||||
primaryColor: "#4f8a6f",
|
||||
accentColor: "#8b7355",
|
||||
logoBase64: null,
|
||||
logoMimeType: null,
|
||||
}),
|
||||
} as Response);
|
||||
}
|
||||
if (url.startsWith("/api/impersonation/sessions/")) {
|
||||
return Promise.resolve({
|
||||
ok: true,
|
||||
json: async () => SESSION,
|
||||
} as Response);
|
||||
}
|
||||
return Promise.resolve({ ok: true, json: async () => [] } as Response);
|
||||
}) as unknown as typeof fetch;
|
||||
});
|
||||
|
||||
it("loads and displays impersonation banner when sessionId is in URL", async () => {
|
||||
const { CustomerPortal } = await import("../portal/CustomerPortal.js");
|
||||
render(
|
||||
<MemoryRouter initialEntries={["/?sessionId=sess-1"]}>
|
||||
<CustomerPortal />
|
||||
</MemoryRouter>
|
||||
);
|
||||
|
||||
// Wait for the session fetch and banner to appear
|
||||
await waitFor(() => {
|
||||
expect(global.fetch).toHaveBeenCalledWith("/api/impersonation/sessions/sess-1");
|
||||
});
|
||||
// Banner "End Session" button is unique to the active impersonation banner
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole("button", { name: /End Session/i })).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it("does not show banner when no sessionId in URL", async () => {
|
||||
vi.mocked(global.fetch).mockClear();
|
||||
const { CustomerPortal } = await import("../portal/CustomerPortal.js");
|
||||
render(
|
||||
<MemoryRouter initialEntries={["/"]}>
|
||||
<CustomerPortal />
|
||||
</MemoryRouter>
|
||||
);
|
||||
|
||||
// No impersonation session fetch should happen
|
||||
await new Promise((r) => setTimeout(r, 50));
|
||||
const impersonationFetches = vi.mocked(global.fetch).mock.calls.filter(
|
||||
([url]) => typeof url === "string" && url.startsWith("/api/impersonation/")
|
||||
);
|
||||
expect(impersonationFetches).toHaveLength(0);
|
||||
expect(screen.queryByRole("button", { name: /End Session/i })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("redirects to /admin/clients after ending impersonation session", async () => {
|
||||
// Mock window.location.href
|
||||
const originalLocation = window.location;
|
||||
Object.defineProperty(window, "location", {
|
||||
value: { href: "" },
|
||||
writable: true,
|
||||
});
|
||||
|
||||
const { CustomerPortal } = await import("../portal/CustomerPortal.js");
|
||||
render(
|
||||
<MemoryRouter initialEntries={["/?sessionId=sess-1"]}>
|
||||
<CustomerPortal />
|
||||
</MemoryRouter>
|
||||
);
|
||||
|
||||
// Wait for banner to appear
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole("button", { name: /End Session/i })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Click "End Session" — this triggers handleEnd which calls the API then redirects
|
||||
fireEvent.click(screen.getByRole("button", { name: /End Session/i }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(window.location.href).toBe("/admin/clients");
|
||||
});
|
||||
|
||||
// Restore
|
||||
Object.defineProperty(window, "location", { value: originalLocation, writable: true });
|
||||
});
|
||||
});
|
||||
|
||||
// ─── CustomerPortal — Better Auth SSO bridge (GRO-1867) ────────────────────
|
||||
|
||||
describe("CustomerPortal SSO bridge", () => {
|
||||
beforeEach(() => {
|
||||
// Make sure no dev-user leaks across tests
|
||||
window.localStorage.clear();
|
||||
});
|
||||
|
||||
const brandingResponse = {
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
businessName: "GroomBook",
|
||||
primaryColor: "#4f8a6f",
|
||||
accentColor: "#8b7355",
|
||||
logoBase64: null,
|
||||
logoMimeType: null,
|
||||
}),
|
||||
} as Response;
|
||||
|
||||
it("bridges Better Auth session via /api/portal/session-from-auth and uses returned sessionId", async () => {
|
||||
global.fetch = vi.fn((input: RequestInfo, init?: RequestInit) => {
|
||||
const url = typeof input === "string" ? input : input.toString();
|
||||
if (url === "/api/branding") return Promise.resolve(brandingResponse);
|
||||
if (url === "/api/auth/get-session") {
|
||||
return Promise.resolve({
|
||||
ok: true,
|
||||
json: async () => ({ user: { email: "customer@example.com", role: "customer" } }),
|
||||
} as Response);
|
||||
}
|
||||
if (url === "/api/portal/session-from-auth" && init?.method === "POST") {
|
||||
return Promise.resolve({
|
||||
ok: true,
|
||||
status: 201,
|
||||
json: async () => ({ sessionId: "sso-sess-1", clientId: "client-1", clientName: "Jane Doe" }),
|
||||
} as Response);
|
||||
}
|
||||
// Subsequent portal API calls — surface them so we can assert the header
|
||||
return Promise.resolve({ ok: true, json: async () => ({}) } as Response);
|
||||
}) as unknown as typeof fetch;
|
||||
|
||||
const { CustomerPortal } = await import("../portal/CustomerPortal.js");
|
||||
render(
|
||||
<MemoryRouter initialEntries={["/"]}>
|
||||
<CustomerPortal />
|
||||
</MemoryRouter>
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(global.fetch).toHaveBeenCalledWith("/api/auth/get-session", expect.objectContaining({ credentials: "include" }));
|
||||
});
|
||||
await waitFor(() => {
|
||||
expect(global.fetch).toHaveBeenCalledWith(
|
||||
"/api/portal/session-from-auth",
|
||||
expect.objectContaining({ method: "POST", credentials: "include" })
|
||||
);
|
||||
});
|
||||
// Client greeting reflects the bridged customer name (proof the response was consumed)
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/Hi, Jane/)).toBeInTheDocument();
|
||||
});
|
||||
// The impersonation banner must NOT appear — this is the customer themselves
|
||||
expect(screen.queryByRole("button", { name: /End Session/i })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows a friendly fallback when session-from-auth returns 404 (no client record)", async () => {
|
||||
global.fetch = vi.fn((input: RequestInfo) => {
|
||||
const url = typeof input === "string" ? input : input.toString();
|
||||
if (url === "/api/branding") return Promise.resolve(brandingResponse);
|
||||
if (url === "/api/auth/get-session") {
|
||||
return Promise.resolve({
|
||||
ok: true,
|
||||
json: async () => ({ user: { email: "stranger@example.com", role: "customer" } }),
|
||||
} as Response);
|
||||
}
|
||||
if (url === "/api/portal/session-from-auth") {
|
||||
return Promise.resolve({
|
||||
ok: false,
|
||||
status: 404,
|
||||
json: async () => ({ error: "No client record found for this user" }),
|
||||
} as Response);
|
||||
}
|
||||
return Promise.resolve({ ok: true, json: async () => ({}) } as Response);
|
||||
}) as unknown as typeof fetch;
|
||||
|
||||
const { CustomerPortal } = await import("../portal/CustomerPortal.js");
|
||||
render(
|
||||
<MemoryRouter initialEntries={["/"]}>
|
||||
<CustomerPortal />
|
||||
</MemoryRouter>
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/Portal access not configured/i)).toBeInTheDocument();
|
||||
});
|
||||
expect(screen.getByText(/not linked to a customer record/i)).toBeInTheDocument();
|
||||
// Sign-out escape hatch is present so the user is not stuck in a loop
|
||||
expect(screen.getByRole("button", { name: /Sign out/i })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("does not call session-from-auth when there is no Better Auth session", async () => {
|
||||
global.fetch = vi.fn((input: RequestInfo) => {
|
||||
const url = typeof input === "string" ? input : input.toString();
|
||||
if (url === "/api/branding") return Promise.resolve(brandingResponse);
|
||||
if (url === "/api/auth/get-session") {
|
||||
return Promise.resolve({
|
||||
ok: true,
|
||||
json: async () => null,
|
||||
} as Response);
|
||||
}
|
||||
return Promise.resolve({ ok: true, json: async () => ({}) } as Response);
|
||||
}) as unknown as typeof fetch;
|
||||
|
||||
const { CustomerPortal } = await import("../portal/CustomerPortal.js");
|
||||
render(
|
||||
<MemoryRouter initialEntries={["/"]}>
|
||||
<CustomerPortal />
|
||||
</MemoryRouter>
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(global.fetch).toHaveBeenCalledWith("/api/auth/get-session", expect.objectContaining({ credentials: "include" }));
|
||||
});
|
||||
// Wait one tick to ensure no subsequent bridge call is queued
|
||||
await new Promise((r) => setTimeout(r, 30));
|
||||
const bridgeCalls = vi.mocked(global.fetch).mock.calls.filter(
|
||||
([u]) => typeof u === "string" && u === "/api/portal/session-from-auth"
|
||||
);
|
||||
expect(bridgeCalls).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("skips the bridge for staff Better Auth sessions", async () => {
|
||||
global.fetch = vi.fn((input: RequestInfo) => {
|
||||
const url = typeof input === "string" ? input : input.toString();
|
||||
if (url === "/api/branding") return Promise.resolve(brandingResponse);
|
||||
if (url === "/api/auth/get-session") {
|
||||
return Promise.resolve({
|
||||
ok: true,
|
||||
json: async () => ({ user: { email: "staff@example.com", role: "staff" } }),
|
||||
} as Response);
|
||||
}
|
||||
return Promise.resolve({ ok: true, json: async () => ({}) } as Response);
|
||||
}) as unknown as typeof fetch;
|
||||
|
||||
const { CustomerPortal } = await import("../portal/CustomerPortal.js");
|
||||
render(
|
||||
<MemoryRouter initialEntries={["/"]}>
|
||||
<CustomerPortal />
|
||||
</MemoryRouter>
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(global.fetch).toHaveBeenCalledWith("/api/auth/get-session", expect.objectContaining({ credentials: "include" }));
|
||||
});
|
||||
await new Promise((r) => setTimeout(r, 30));
|
||||
const bridgeCalls = vi.mocked(global.fetch).mock.calls.filter(
|
||||
([u]) => typeof u === "string" && u === "/api/portal/session-from-auth"
|
||||
);
|
||||
expect(bridgeCalls).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("passes portalSessionId (not null) to RescheduleFlow for SSO bridge customers (GRO-2012)", async () => {
|
||||
rescheduleFlowSpy.mockClear();
|
||||
|
||||
global.fetch = vi.fn((input: RequestInfo, init?: RequestInit) => {
|
||||
const url = typeof input === "string" ? input : input.toString();
|
||||
if (url === "/api/branding") return Promise.resolve(brandingResponse);
|
||||
if (url === "/api/auth/get-session") {
|
||||
return Promise.resolve({
|
||||
ok: true,
|
||||
json: async () => ({ user: { email: "customer@example.com", role: "customer" } }),
|
||||
} as Response);
|
||||
}
|
||||
if (url === "/api/portal/session-from-auth" && init?.method === "POST") {
|
||||
return Promise.resolve({
|
||||
ok: true,
|
||||
status: 201,
|
||||
json: async () => ({ sessionId: "sso-sess-1", clientId: "client-1", clientName: "Jane Doe" }),
|
||||
} as Response);
|
||||
}
|
||||
// Dashboard data — return an upcoming appointment so the Reschedule
|
||||
// button is rendered on the dashboard card.
|
||||
if (url === "/api/portal/appointments") {
|
||||
return Promise.resolve({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
appointments: [
|
||||
{
|
||||
id: "appt-1",
|
||||
date: "2099-01-01",
|
||||
time: "10:00",
|
||||
petName: "Buddy",
|
||||
serviceName: "Bath & Brush",
|
||||
status: "confirmed",
|
||||
},
|
||||
],
|
||||
}),
|
||||
} as Response);
|
||||
}
|
||||
if (url === "/api/portal/pets") {
|
||||
return Promise.resolve({ ok: true, json: async () => ({ pets: [] }) } as Response);
|
||||
}
|
||||
if (url === "/api/portal/invoices") {
|
||||
return Promise.resolve({ ok: true, json: async () => ({ invoices: [] }) } as Response);
|
||||
}
|
||||
return Promise.resolve({ ok: true, json: async () => ({}) } as Response);
|
||||
}) as unknown as typeof fetch;
|
||||
|
||||
const { CustomerPortal } = await import("../portal/CustomerPortal.js");
|
||||
render(
|
||||
<MemoryRouter initialEntries={["/"]}>
|
||||
<CustomerPortal />
|
||||
</MemoryRouter>
|
||||
);
|
||||
|
||||
// Wait for the Reschedule button to appear on the dashboard card
|
||||
const rescheduleBtn = await screen.findByRole("button", { name: /^Reschedule$/i });
|
||||
fireEvent.click(rescheduleBtn);
|
||||
|
||||
// RescheduleFlow should have been invoked with the bridged portalSessionId,
|
||||
// NOT null. Pre-fix, the call would be sessionId={null} for SSO customers.
|
||||
await waitFor(() => {
|
||||
expect(rescheduleFlowSpy).toHaveBeenCalled();
|
||||
});
|
||||
const lastProps = rescheduleFlowSpy.mock.lastCall?.[0];
|
||||
expect(lastProps).toBeDefined();
|
||||
expect(lastProps!.sessionId).toBe("sso-sess-1");
|
||||
expect(lastProps!.appointment.id).toBe("appt-1");
|
||||
});
|
||||
|
||||
// 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();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,282 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { Loader2 } from "lucide-react";
|
||||
|
||||
interface Service {
|
||||
id: string;
|
||||
name: string;
|
||||
description?: string;
|
||||
basePriceCents: number;
|
||||
durationMinutes: number;
|
||||
active: boolean;
|
||||
}
|
||||
|
||||
interface BufferRule {
|
||||
id: string;
|
||||
serviceId: string;
|
||||
serviceName: string;
|
||||
sizeCategory?: string;
|
||||
coatType?: string;
|
||||
bufferMinutes: number;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
interface BufferRuleForm {
|
||||
serviceId: string;
|
||||
sizeCategory: string;
|
||||
coatType: string;
|
||||
bufferMinutes: string;
|
||||
}
|
||||
|
||||
const EMPTY_FORM: BufferRuleForm = {
|
||||
serviceId: "",
|
||||
sizeCategory: "",
|
||||
coatType: "",
|
||||
bufferMinutes: "",
|
||||
};
|
||||
|
||||
const SIZE_OPTIONS = ["", "small", "medium", "large", "xlarge"] as const;
|
||||
const COAT_OPTIONS = ["", "smooth", "double", "wire", "curly", "long", "hairless"] as const;
|
||||
|
||||
export function BufferRulesSection() {
|
||||
const [rules, setRules] = useState<BufferRule[]>([]);
|
||||
const [services, setServices] = useState<Service[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [showForm, setShowForm] = useState(false);
|
||||
const [form, setForm] = useState<BufferRuleForm>(EMPTY_FORM);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [formError, setFormError] = useState<string | null>(null);
|
||||
const [deletingId, setDeletingId] = useState<string | null>(null);
|
||||
const [confirmDeleteId, setConfirmDeleteId] = useState<string | null>(null);
|
||||
const [editingId, setEditingId] = useState<string | null>(null);
|
||||
const [editBuffer, setEditBuffer] = useState<string>("");
|
||||
|
||||
useEffect(() => {
|
||||
Promise.all([
|
||||
fetch("/api/buffer-rules").then(r => r.ok ? r.json() : []),
|
||||
fetch("/api/services?includeInactive=true").then(r => r.ok ? r.json() : []),
|
||||
]).then(([rulesData, servicesData]) => {
|
||||
setRules(rulesData as BufferRule[]);
|
||||
setServices(servicesData as Service[]);
|
||||
}).catch(() => setError("Failed to load")).finally(() => setLoading(false));
|
||||
}, []);
|
||||
|
||||
async function handleCreate(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
const mins = parseInt(form.bufferMinutes);
|
||||
if (!form.serviceId || isNaN(mins) || mins <= 0) {
|
||||
setFormError("Service and valid buffer minutes are required.");
|
||||
return;
|
||||
}
|
||||
setSaving(true);
|
||||
setFormError(null);
|
||||
try {
|
||||
const body: Record<string, string | number> = {
|
||||
serviceId: form.serviceId,
|
||||
bufferMinutes: mins,
|
||||
};
|
||||
if (form.sizeCategory) body.sizeCategory = form.sizeCategory;
|
||||
if (form.coatType) body.coatType = form.coatType;
|
||||
const res = await fetch("/api/buffer-rules", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
if (!res.ok) {
|
||||
const err = await res.json().catch(() => ({})) as { error?: string };
|
||||
throw new Error(err.error ?? `HTTP ${res.status}`);
|
||||
}
|
||||
const newRule = await res.json() as BufferRule;
|
||||
setRules(prev => [...prev, newRule]);
|
||||
setShowForm(false);
|
||||
setForm(EMPTY_FORM);
|
||||
} catch (e: unknown) {
|
||||
setFormError(e instanceof Error ? e.message : "Failed to create rule");
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDelete(id: string) {
|
||||
setDeletingId(id);
|
||||
try {
|
||||
await fetch(`/api/buffer-rules/${id}`, { method: "DELETE" });
|
||||
setRules(prev => prev.filter(r => r.id !== id));
|
||||
} finally {
|
||||
setDeletingId(null);
|
||||
setConfirmDeleteId(null);
|
||||
}
|
||||
}
|
||||
|
||||
function startEdit(rule: BufferRule) {
|
||||
setEditingId(rule.id);
|
||||
setEditBuffer(String(rule.bufferMinutes));
|
||||
}
|
||||
|
||||
async function saveEdit(rule: BufferRule) {
|
||||
const mins = parseInt(editBuffer);
|
||||
if (isNaN(mins) || mins <= 0) return;
|
||||
setSaving(true);
|
||||
try {
|
||||
const res = await fetch(`/api/buffer-rules/${rule.id}`, {
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ bufferMinutes: mins }),
|
||||
});
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
const updated = await res.json() as BufferRule;
|
||||
setRules(prev => prev.map(r => r.id === updated.id ? updated : r));
|
||||
} catch {
|
||||
// silent fail
|
||||
} finally {
|
||||
setSaving(false);
|
||||
setEditingId(null);
|
||||
setEditBuffer("");
|
||||
}
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center py-8">
|
||||
<Loader2 size={20} className="animate-spin text-stone-400" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="py-4 text-sm text-red-500">{error}</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div>
|
||||
<h2 className="text-base font-semibold text-stone-800">Buffer Rules</h2>
|
||||
<p className="text-sm text-stone-500">Extra time rules per service / pet size / coat type</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => { setShowForm(!showForm); setFormError(null); }}
|
||||
className="px-3 py-1.5 bg-(--color-primary) text-white text-sm rounded-lg hover:bg-(--color-primary-hover)"
|
||||
>
|
||||
{showForm ? "Cancel" : "+ Add Rule"}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{showForm && (
|
||||
<form onSubmit={handleCreate} className="mb-6 p-4 bg-stone-50 rounded-xl border border-stone-200 space-y-3">
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-stone-600 mb-1">Service *</label>
|
||||
<select
|
||||
value={form.serviceId}
|
||||
onChange={e => setForm(f => ({ ...f, serviceId: e.target.value }))}
|
||||
required
|
||||
className="w-full border border-stone-200 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-(--color-accent)"
|
||||
>
|
||||
<option value="">Select service…</option>
|
||||
{services.map(s => (
|
||||
<option key={s.id} value={s.id}>{s.name}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-stone-600 mb-1">Buffer (minutes) *</label>
|
||||
<input
|
||||
type="number"
|
||||
min="1"
|
||||
step="1"
|
||||
value={form.bufferMinutes}
|
||||
onChange={e => setForm(f => ({ ...f, bufferMinutes: e.target.value }))}
|
||||
required
|
||||
className="w-full border border-stone-200 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-(--color-accent)"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-stone-600 mb-1">Size Category</label>
|
||||
<select
|
||||
value={form.sizeCategory}
|
||||
onChange={e => setForm(f => ({ ...f, sizeCategory: e.target.value }))}
|
||||
className="w-full border border-stone-200 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-(--color-accent)"
|
||||
>
|
||||
<option value="">Any</option>
|
||||
{SIZE_OPTIONS.filter(s => s).map(s => (
|
||||
<option key={s} value={s}>{s.charAt(0).toUpperCase() + s.slice(1)}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-stone-600 mb-1">Coat Type</label>
|
||||
<select
|
||||
value={form.coatType}
|
||||
onChange={e => setForm(f => ({ ...f, coatType: e.target.value }))}
|
||||
className="w-full border border-stone-200 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-(--color-accent)"
|
||||
>
|
||||
<option value="">Any</option>
|
||||
{COAT_OPTIONS.filter(c => c).map(c => (
|
||||
<option key={c} value={c}>{c.charAt(0).toUpperCase() + c.slice(1)}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
{formError && <p className="text-sm text-red-500">{formError}</p>}
|
||||
<button
|
||||
type="submit"
|
||||
disabled={saving}
|
||||
className="px-4 py-2 bg-(--color-primary) text-white text-sm rounded-lg hover:bg-(--color-primary-hover) disabled:opacity-60"
|
||||
>
|
||||
{saving ? "Saving…" : "Create Rule"}
|
||||
</button>
|
||||
</form>
|
||||
)}
|
||||
|
||||
{rules.length === 0 && !showForm ? (
|
||||
<p className="text-sm text-stone-400 py-6 text-center">No buffer rules configured yet.</p>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{rules.map(rule => (
|
||||
<div key={rule.id} className="flex items-center gap-3 p-3 bg-white rounded-xl border border-stone-200">
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="text-sm font-medium text-stone-800 truncate">{rule.serviceName}</div>
|
||||
<div className="text-xs text-stone-500 flex gap-2 flex-wrap">
|
||||
{rule.sizeCategory && <span>Size: {rule.sizeCategory}</span>}
|
||||
{rule.coatType && <span>Coat: {rule.coatType}</span>}
|
||||
</div>
|
||||
</div>
|
||||
{editingId === rule.id ? (
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="number"
|
||||
min="1"
|
||||
value={editBuffer}
|
||||
onChange={e => setEditBuffer(e.target.value)}
|
||||
className="w-20 border border-stone-200 rounded px-2 py-1 text-sm"
|
||||
/>
|
||||
<span className="text-xs text-stone-500">min</span>
|
||||
<button onClick={() => saveEdit(rule)} disabled={saving} className="text-xs text-green-600 font-medium">Save</button>
|
||||
<button onClick={() => setEditingId(null)} className="text-xs text-stone-500">Cancel</button>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<span className="text-sm font-medium text-stone-700">{rule.bufferMinutes} min</span>
|
||||
<button onClick={() => startEdit(rule)} className="text-xs text-stone-500 hover:text-stone-700 px-2">Edit</button>
|
||||
</>
|
||||
)}
|
||||
{confirmDeleteId === rule.id ? (
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xs text-red-500">Delete?</span>
|
||||
<button onClick={() => handleDelete(rule.id)} disabled={deletingId === rule.id} className="text-xs text-red-600 font-medium">Confirm</button>
|
||||
<button onClick={() => setConfirmDeleteId(null)} className="text-xs text-stone-500">Cancel</button>
|
||||
</div>
|
||||
) : (
|
||||
<button onClick={() => setConfirmDeleteId(rule.id)} className="text-xs text-red-400 hover:text-red-600">Delete</button>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,203 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import { Calendar, RefreshCw, Trash2, Copy, Check } from "lucide-react";
|
||||
|
||||
interface Props {
|
||||
staffId: string;
|
||||
staffName: string;
|
||||
}
|
||||
|
||||
export function CalendarSyncSection({ staffId }: Props) {
|
||||
const [token, setToken] = useState<string | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [actionLoading, setActionLoading] = useState<"generate" | "revoke" | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [copied, setCopied] = useState(false);
|
||||
const [showRevokeConfirm, setShowRevokeConfirm] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
fetchToken();
|
||||
}, [staffId]);
|
||||
|
||||
async function fetchToken() {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const res = await fetch(`/api/staff/${staffId}`);
|
||||
if (!res.ok) throw new Error("Failed to fetch staff data");
|
||||
const data = await res.json();
|
||||
setToken(data.icalToken || null);
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : "Failed to load");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function generateToken() {
|
||||
setActionLoading("generate");
|
||||
setError(null);
|
||||
try {
|
||||
const res = await fetch(`/api/staff/${staffId}/ical-token`, { method: "POST" });
|
||||
if (!res.ok) {
|
||||
const err = await res.json();
|
||||
throw new Error(err.error || "Failed to generate token");
|
||||
}
|
||||
const data = await res.json();
|
||||
setToken(data.icalToken);
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : "Failed to generate token");
|
||||
} finally {
|
||||
setActionLoading(null);
|
||||
}
|
||||
}
|
||||
|
||||
async function revokeToken() {
|
||||
if (!showRevokeConfirm) {
|
||||
setShowRevokeConfirm(true);
|
||||
return;
|
||||
}
|
||||
setActionLoading("revoke");
|
||||
setError(null);
|
||||
try {
|
||||
const res = await fetch(`/api/staff/${staffId}/ical-token`, { method: "DELETE" });
|
||||
if (!res.ok) {
|
||||
const err = await res.json();
|
||||
throw new Error(err.error || "Failed to revoke token");
|
||||
}
|
||||
setToken(null);
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : "Failed to revoke token");
|
||||
} finally {
|
||||
setActionLoading(null);
|
||||
setShowRevokeConfirm(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function copyFeedUrl() {
|
||||
if (!token) return;
|
||||
const url = `${window.location.origin}/api/calendar/${staffId}.ics?token=${token}`;
|
||||
await navigator.clipboard.writeText(url);
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
}
|
||||
|
||||
const feedUrl = token ? `/api/calendar/${staffId}.ics?token=${token}` : null;
|
||||
|
||||
return (
|
||||
<div className="bg-white rounded-2xl border border-stone-200 p-5 shadow-sm">
|
||||
<div className="flex items-center gap-2 mb-4">
|
||||
<Calendar size={18} className="text-(--color-accent)" />
|
||||
<h3 className="font-medium text-stone-800">Calendar Sync</h3>
|
||||
</div>
|
||||
|
||||
<p className="text-sm text-stone-500 mb-4">
|
||||
Generate a calendar feed link to share your upcoming appointments with any calendar app that supports iCal (Apple Calendar, Google Calendar, Outlook).
|
||||
</p>
|
||||
|
||||
{error && (
|
||||
<div className="mb-4 p-3 bg-red-50 border border-red-200 rounded-lg text-sm text-red-600">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{loading ? (
|
||||
<div className="text-sm text-stone-400">Loading...</div>
|
||||
) : token ? (
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-stone-500 mb-1">Your Calendar Feed URL</label>
|
||||
<div className="flex gap-2">
|
||||
<input
|
||||
type="text"
|
||||
readOnly
|
||||
value={feedUrl ?? ""}
|
||||
className="flex-1 text-sm border border-stone-200 rounded-lg px-3 py-2 bg-stone-50 text-stone-600 font-mono"
|
||||
/>
|
||||
<button
|
||||
onClick={copyFeedUrl}
|
||||
className="flex items-center gap-1.5 px-3 py-2 border border-stone-200 rounded-lg text-sm text-stone-600 hover:bg-stone-50"
|
||||
title="Copy link"
|
||||
>
|
||||
{copied ? <Check size={14} className="text-green-600" /> : <Copy size={14} />}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{showRevokeConfirm ? (
|
||||
<div className="flex items-center gap-3 p-3 bg-red-50 border border-red-200 rounded-lg">
|
||||
<p className="flex-1 text-sm text-red-700">
|
||||
Revoke your calendar feed link? Anyone with the current link will lose access.
|
||||
</p>
|
||||
<button
|
||||
onClick={revokeToken}
|
||||
disabled={actionLoading !== null}
|
||||
className="flex items-center gap-1.5 px-3 py-1.5 bg-red-600 text-white rounded-lg text-sm font-medium hover:bg-red-700 disabled:opacity-50"
|
||||
>
|
||||
{actionLoading === "revoke" ? (
|
||||
<RefreshCw size={14} className="animate-spin" />
|
||||
) : (
|
||||
<Trash2 size={14} />
|
||||
)}
|
||||
Revoke
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setShowRevokeConfirm(false)}
|
||||
disabled={actionLoading !== null}
|
||||
className="px-3 py-1.5 border border-stone-200 rounded-lg text-sm text-stone-600 hover:bg-stone-50 disabled:opacity-50"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={generateToken}
|
||||
disabled={actionLoading !== null}
|
||||
className="flex items-center gap-1.5 px-3 py-2 bg-(--color-accent) text-white rounded-lg text-sm font-medium hover:bg-(--color-accent-hover) disabled:opacity-50"
|
||||
>
|
||||
{actionLoading === "generate" ? (
|
||||
<RefreshCw size={14} className="animate-spin" />
|
||||
) : (
|
||||
<RefreshCw size={14} />
|
||||
)}
|
||||
Regenerate
|
||||
</button>
|
||||
<button
|
||||
onClick={revokeToken}
|
||||
disabled={actionLoading !== null}
|
||||
className="flex items-center gap-1.5 px-3 py-2 border border-red-200 rounded-lg text-sm text-red-600 hover:bg-red-50 disabled:opacity-50"
|
||||
>
|
||||
{actionLoading === "revoke" ? (
|
||||
<RefreshCw size={14} className="animate-spin" />
|
||||
) : (
|
||||
<Trash2 size={14} />
|
||||
)}
|
||||
Revoke
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<p className="text-xs text-stone-400">
|
||||
Regenerating will create a new URL and invalidate the old one.
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
<p className="text-sm text-stone-600">You don't have a calendar feed set up yet.</p>
|
||||
<button
|
||||
onClick={generateToken}
|
||||
disabled={actionLoading !== null}
|
||||
className="flex items-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"
|
||||
>
|
||||
{actionLoading === "generate" ? (
|
||||
<RefreshCw size={14} className="animate-spin" />
|
||||
) : (
|
||||
<Calendar size={14} />
|
||||
)}
|
||||
{actionLoading === "generate" ? "Generating..." : "Generate Calendar Feed"}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
import { Link } from "react-router-dom";
|
||||
import { getDevUser } from "../pages/DevLoginSelector.js";
|
||||
|
||||
export function DevSessionIndicator() {
|
||||
const user = getDevUser();
|
||||
if (!user) return null;
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
position: "fixed",
|
||||
bottom: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
background: "#1a202c",
|
||||
color: "#e2e8f0",
|
||||
padding: "0.4rem 1rem",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
gap: "0.75rem",
|
||||
fontSize: 12,
|
||||
zIndex: 9999,
|
||||
}}
|
||||
>
|
||||
<span>
|
||||
Dev mode: acting as <strong>{user.name}</strong> ({user.type})
|
||||
</span>
|
||||
<Link
|
||||
to="/login"
|
||||
style={{
|
||||
color: "var(--color-primary)",
|
||||
textDecoration: "underline",
|
||||
fontSize: 12,
|
||||
}}
|
||||
>
|
||||
Switch user
|
||||
</Link>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,287 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { Search } from "lucide-react";
|
||||
|
||||
interface ClientResult {
|
||||
id: string;
|
||||
name: string;
|
||||
email: string | null;
|
||||
phone: string | null;
|
||||
}
|
||||
|
||||
interface PetResult {
|
||||
id: string;
|
||||
name: string;
|
||||
breed: string | null;
|
||||
clientId: string;
|
||||
ownerName: string;
|
||||
}
|
||||
|
||||
interface SearchResults {
|
||||
clients: ClientResult[];
|
||||
pets: PetResult[];
|
||||
}
|
||||
|
||||
export function GlobalSearch() {
|
||||
const [query, setQuery] = useState("");
|
||||
const [results, setResults] = useState<SearchResults | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [open, setOpen] = useState(false);
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const dropdownRef = useRef<HTMLDivElement>(null);
|
||||
const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const navigate = useNavigate();
|
||||
|
||||
// Debounced search
|
||||
useEffect(() => {
|
||||
if (debounceRef.current) clearTimeout(debounceRef.current);
|
||||
|
||||
const trimmed = query.trim();
|
||||
if (trimmed.length === 0) {
|
||||
setResults(null);
|
||||
setOpen(false);
|
||||
return;
|
||||
}
|
||||
|
||||
debounceRef.current = setTimeout(async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const res = await fetch(`/api/search?q=${encodeURIComponent(trimmed)}`);
|
||||
if (res.ok) {
|
||||
const data: SearchResults = await res.json();
|
||||
setResults(data);
|
||||
setOpen(true);
|
||||
} else {
|
||||
setError("Search failed. Please try again.");
|
||||
}
|
||||
} catch {
|
||||
setError("Search failed. Please try again.");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, 300);
|
||||
|
||||
return () => {
|
||||
if (debounceRef.current) clearTimeout(debounceRef.current);
|
||||
};
|
||||
}, [query]);
|
||||
|
||||
// Close dropdown on outside click
|
||||
useEffect(() => {
|
||||
function handleClick(e: MouseEvent) {
|
||||
if (
|
||||
inputRef.current &&
|
||||
!inputRef.current.contains(e.target as Node) &&
|
||||
dropdownRef.current &&
|
||||
!dropdownRef.current.contains(e.target as Node)
|
||||
) {
|
||||
setOpen(false);
|
||||
}
|
||||
}
|
||||
document.addEventListener("mousedown", handleClick);
|
||||
return () => document.removeEventListener("mousedown", handleClick);
|
||||
}, []);
|
||||
|
||||
function handleClientClick(client: ClientResult) {
|
||||
setOpen(false);
|
||||
setQuery("");
|
||||
navigate(`/admin/clients?highlight=${client.id}`);
|
||||
}
|
||||
|
||||
function handlePetClick(pet: PetResult) {
|
||||
setOpen(false);
|
||||
setQuery("");
|
||||
navigate(`/admin/clients?highlight=${pet.clientId}`);
|
||||
}
|
||||
|
||||
const hasResults = results && (results.clients.length > 0 || results.pets.length > 0);
|
||||
|
||||
return (
|
||||
<div style={{ position: "relative", flex: "1 1 0", maxWidth: 320, minWidth: 0 }}>
|
||||
<div style={{ position: "relative" }}>
|
||||
<Search
|
||||
size={15}
|
||||
style={{
|
||||
position: "absolute",
|
||||
left: 10,
|
||||
top: "50%",
|
||||
transform: "translateY(-50%)",
|
||||
color: "#9ca3af",
|
||||
pointerEvents: "none",
|
||||
}}
|
||||
/>
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="search"
|
||||
placeholder="Search clients & pets…"
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
onFocus={() => results && setOpen(true)}
|
||||
style={{
|
||||
width: "100%",
|
||||
boxSizing: "border-box",
|
||||
height: 44,
|
||||
paddingLeft: 32,
|
||||
paddingRight: 12,
|
||||
fontSize: 13,
|
||||
border: "1px solid #e2e8f0",
|
||||
borderRadius: 8,
|
||||
outline: "none",
|
||||
background: "#f8fafc",
|
||||
color: "#1a202c",
|
||||
}}
|
||||
aria-label="Search clients and pets"
|
||||
aria-expanded={open}
|
||||
aria-haspopup="listbox"
|
||||
role="combobox"
|
||||
aria-autocomplete="list"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{open && (
|
||||
<div
|
||||
ref={dropdownRef}
|
||||
role="listbox"
|
||||
style={{
|
||||
position: "absolute",
|
||||
top: "calc(100% + 4px)",
|
||||
left: 0,
|
||||
right: 0,
|
||||
background: "#fff",
|
||||
border: "1px solid #e2e8f0",
|
||||
borderRadius: 10,
|
||||
boxShadow: "0 8px 24px rgba(0,0,0,0.10)",
|
||||
zIndex: 100,
|
||||
overflow: "hidden",
|
||||
minWidth: "100%",
|
||||
}}
|
||||
>
|
||||
{loading && (
|
||||
<div style={{ padding: "12px 16px", fontSize: 13, color: "#6b7280" }}>
|
||||
Searching…
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!loading && error && (
|
||||
<div style={{ padding: "12px 16px", fontSize: 13, color: "#dc2626" }}>
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!loading && !error && !hasResults && (
|
||||
<div style={{ padding: "12px 16px", fontSize: 13, color: "#6b7280" }}>
|
||||
No results found
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!loading && results && results.clients.length > 0 && (
|
||||
<div>
|
||||
<div
|
||||
style={{
|
||||
padding: "6px 16px 4px",
|
||||
fontSize: 11,
|
||||
fontWeight: 600,
|
||||
color: "#9ca3af",
|
||||
textTransform: "uppercase",
|
||||
letterSpacing: "0.05em",
|
||||
borderBottom: "1px solid #f1f5f9",
|
||||
}}
|
||||
>
|
||||
Clients
|
||||
</div>
|
||||
{results.clients.map((client) => (
|
||||
<button
|
||||
key={client.id}
|
||||
role="option"
|
||||
onClick={() => handleClientClick(client)}
|
||||
style={{
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
width: "100%",
|
||||
padding: "12px 16px",
|
||||
minHeight: 48,
|
||||
background: "transparent",
|
||||
border: "none",
|
||||
borderBottom: "1px solid #f1f5f9",
|
||||
cursor: "pointer",
|
||||
textAlign: "left",
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
(e.currentTarget as HTMLButtonElement).style.background = "#f8fafc";
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
(e.currentTarget as HTMLButtonElement).style.background = "transparent";
|
||||
}}
|
||||
>
|
||||
<span style={{ fontSize: 13, fontWeight: 500, color: "#1a202c" }}>
|
||||
{client.name}
|
||||
</span>
|
||||
{client.phone && (
|
||||
<span style={{ fontSize: 12, color: "#6b7280", marginTop: 1 }}>
|
||||
{client.phone}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!loading && results && results.pets.length > 0 && (
|
||||
<div>
|
||||
<div
|
||||
style={{
|
||||
padding: "6px 16px 4px",
|
||||
fontSize: 11,
|
||||
fontWeight: 600,
|
||||
color: "#9ca3af",
|
||||
textTransform: "uppercase",
|
||||
letterSpacing: "0.05em",
|
||||
borderBottom: "1px solid #f1f5f9",
|
||||
}}
|
||||
>
|
||||
Pets
|
||||
</div>
|
||||
{results.pets.map((pet) => (
|
||||
<button
|
||||
key={pet.id}
|
||||
role="option"
|
||||
onClick={() => handlePetClick(pet)}
|
||||
style={{
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
width: "100%",
|
||||
padding: "12px 16px",
|
||||
minHeight: 48,
|
||||
background: "transparent",
|
||||
border: "none",
|
||||
borderBottom: "1px solid #f1f5f9",
|
||||
cursor: "pointer",
|
||||
textAlign: "left",
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
(e.currentTarget as HTMLButtonElement).style.background = "#f8fafc";
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
(e.currentTarget as HTMLButtonElement).style.background = "transparent";
|
||||
}}
|
||||
>
|
||||
<span style={{ fontSize: 13, fontWeight: 500, color: "#1a202c" }}>
|
||||
{pet.name}
|
||||
{pet.breed && (
|
||||
<span style={{ fontWeight: 400, color: "#4b5563" }}> · {pet.breed}</span>
|
||||
)}
|
||||
</span>
|
||||
<span style={{ fontSize: 12, color: "#6b7280", marginTop: 1 }}>
|
||||
Owner: {pet.ownerName}
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
interface Props {
|
||||
petId: string;
|
||||
/** Size of the photo avatar in pixels. Default: 64. */
|
||||
size?: number;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
type PhotoState =
|
||||
| { status: "idle" }
|
||||
| { status: "loading" }
|
||||
| { status: "loaded"; url: string }
|
||||
| { status: "none" }
|
||||
| { status: "error" };
|
||||
|
||||
/**
|
||||
* Fetches and displays a pet's photo from the API.
|
||||
* Shows a loading skeleton while fetching, a paw-print placeholder when no photo exists,
|
||||
* and gracefully falls back to the placeholder on error.
|
||||
*/
|
||||
export function PetPhotoDisplay({ petId, size = 64, className }: Props) {
|
||||
const [state, setState] = useState<PhotoState>({ status: "idle" });
|
||||
|
||||
useEffect(() => {
|
||||
setState({ status: "loading" });
|
||||
fetch(`/api/pets/${petId}/photo`)
|
||||
.then(async (res) => {
|
||||
if (res.status === 404) {
|
||||
setState({ status: "none" });
|
||||
return;
|
||||
}
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
const data = (await res.json()) as { url: string };
|
||||
setState({ status: "loaded", url: data.url });
|
||||
})
|
||||
.catch(() => setState({ status: "error" }));
|
||||
}, [petId]);
|
||||
|
||||
const containerStyle: React.CSSProperties = {
|
||||
width: size,
|
||||
height: size,
|
||||
borderRadius: Math.round(size * 0.2),
|
||||
overflow: "hidden",
|
||||
background: "#f0ebe4",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
flexShrink: 0,
|
||||
};
|
||||
|
||||
if (state.status === "loading") {
|
||||
return (
|
||||
<div
|
||||
className={className}
|
||||
style={{
|
||||
...containerStyle,
|
||||
background: "linear-gradient(90deg, #f0ebe4 25%, #e8e0d8 50%, #f0ebe4 75%)",
|
||||
backgroundSize: "200% 100%",
|
||||
animation: "shimmer 1.5s infinite",
|
||||
}}
|
||||
aria-label="Loading photo…"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (state.status === "loaded") {
|
||||
return (
|
||||
<div className={className} style={containerStyle}>
|
||||
<img
|
||||
src={state.url}
|
||||
alt="Pet photo"
|
||||
style={{ width: "100%", height: "100%", objectFit: "cover" }}
|
||||
loading="lazy"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// no photo / error — paw placeholder
|
||||
return (
|
||||
<div className={className} style={containerStyle} aria-label="No photo">
|
||||
<span style={{ fontSize: Math.round(size * 0.45), userSelect: "none" }}>🐾</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,212 @@
|
||||
import { useRef, useState } from "react";
|
||||
|
||||
interface Props {
|
||||
petId: string;
|
||||
/** Called after a successful upload so the parent can refresh the display. */
|
||||
onUploaded: () => void;
|
||||
}
|
||||
|
||||
const MAX_DIMENSION = 1200;
|
||||
const ACCEPTED_TYPES = ["image/jpeg", "image/png", "image/webp", "image/gif"];
|
||||
|
||||
/**
|
||||
* Client-side-resize-then-upload component.
|
||||
*
|
||||
* Flow:
|
||||
* 1. User selects a file
|
||||
* 2. Component resizes to max 1200px on the longest side (canvas)
|
||||
* 3. Requests a presigned PUT URL from the API
|
||||
* 4. PUTs the resized blob directly to object storage
|
||||
* 5. Confirms upload with the API (records the key in DB)
|
||||
*/
|
||||
export function PetPhotoUpload({ petId, onUploaded }: Props) {
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const [state, setState] = useState<
|
||||
| { status: "idle" }
|
||||
| { status: "resizing" }
|
||||
| { status: "uploading"; progress: number }
|
||||
| { status: "confirming" }
|
||||
| { status: "done" }
|
||||
| { status: "error"; message: string }
|
||||
>({ status: "idle" });
|
||||
|
||||
async function resizeImage(file: File): Promise<{ blob: Blob; contentType: string }> {
|
||||
// GIFs must bypass canvas resize — canvas destroys animation frames
|
||||
if (file.type === "image/gif") {
|
||||
return { blob: file, contentType: "image/gif" };
|
||||
}
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const img = new Image();
|
||||
const url = URL.createObjectURL(file);
|
||||
img.onload = () => {
|
||||
URL.revokeObjectURL(url);
|
||||
const { width, height } = img;
|
||||
const scale =
|
||||
Math.max(width, height) > MAX_DIMENSION
|
||||
? MAX_DIMENSION / Math.max(width, height)
|
||||
: 1;
|
||||
const canvas = document.createElement("canvas");
|
||||
canvas.width = Math.round(width * scale);
|
||||
canvas.height = Math.round(height * scale);
|
||||
const ctx = canvas.getContext("2d");
|
||||
if (!ctx) return reject(new Error("Canvas not supported"));
|
||||
ctx.drawImage(img, 0, 0, canvas.width, canvas.height);
|
||||
const contentType = file.type === "image/png" ? "image/png" : "image/jpeg";
|
||||
canvas.toBlob(
|
||||
(blob) => {
|
||||
if (!blob) return reject(new Error("Failed to encode image"));
|
||||
resolve({ blob, contentType });
|
||||
},
|
||||
contentType,
|
||||
0.85
|
||||
);
|
||||
};
|
||||
img.onerror = () => {
|
||||
URL.revokeObjectURL(url);
|
||||
reject(new Error("Failed to load image"));
|
||||
};
|
||||
img.src = url;
|
||||
});
|
||||
}
|
||||
|
||||
async function handleFile(file: File) {
|
||||
const MAX_FILE_SIZE = 50 * 1024 * 1024;
|
||||
if (file.size > MAX_FILE_SIZE) {
|
||||
setState({ status: "error", message: "File exceeds 50MB limit. Please choose a smaller image." });
|
||||
return;
|
||||
}
|
||||
|
||||
if (!ACCEPTED_TYPES.includes(file.type)) {
|
||||
setState({ status: "error", message: "Please select a JPEG, PNG, WebP, or GIF image." });
|
||||
return;
|
||||
}
|
||||
|
||||
setState({ status: "resizing" });
|
||||
|
||||
let blob: Blob;
|
||||
let contentType: string;
|
||||
try {
|
||||
({ blob, contentType } = await resizeImage(file));
|
||||
} catch (e) {
|
||||
setState({ status: "error", message: e instanceof Error ? e.message : "Image resize failed" });
|
||||
return;
|
||||
}
|
||||
|
||||
// Get presigned upload URL
|
||||
setState({ status: "uploading", progress: 0 });
|
||||
let uploadUrl: string;
|
||||
let key: string;
|
||||
try {
|
||||
const res = await fetch(`/api/pets/${petId}/photo/upload-url`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ contentType, fileSizeBytes: blob.size }),
|
||||
});
|
||||
if (!res.ok) {
|
||||
const err = (await res.json()) as { error?: string };
|
||||
throw new Error(err.error ?? `HTTP ${res.status}`);
|
||||
}
|
||||
const data = (await res.json()) as { uploadUrl: string; key: string };
|
||||
uploadUrl = data.uploadUrl;
|
||||
key = data.key;
|
||||
} catch (e) {
|
||||
setState({ status: "error", message: e instanceof Error ? e.message : "Failed to get upload URL" });
|
||||
return;
|
||||
}
|
||||
|
||||
// Upload directly to object storage
|
||||
try {
|
||||
const xhr = new XMLHttpRequest();
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
xhr.upload.addEventListener("progress", (ev) => {
|
||||
if (ev.lengthComputable) {
|
||||
setState({ status: "uploading", progress: Math.round((ev.loaded / ev.total) * 100) });
|
||||
}
|
||||
});
|
||||
xhr.addEventListener("load", () => {
|
||||
if (xhr.status >= 200 && xhr.status < 300) resolve();
|
||||
else reject(new Error(`Upload failed: HTTP ${xhr.status}`));
|
||||
});
|
||||
xhr.addEventListener("error", () => reject(new Error("Upload failed: network error")));
|
||||
xhr.open("PUT", uploadUrl);
|
||||
xhr.setRequestHeader("Content-Type", contentType);
|
||||
xhr.send(blob);
|
||||
});
|
||||
} catch (e) {
|
||||
setState({ status: "error", message: e instanceof Error ? e.message : "Upload failed" });
|
||||
return;
|
||||
}
|
||||
|
||||
// Confirm with API
|
||||
setState({ status: "confirming" });
|
||||
try {
|
||||
const res = await fetch(`/api/pets/${petId}/photo/confirm`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ key }),
|
||||
});
|
||||
if (!res.ok) {
|
||||
const err = (await res.json()) as { error?: string };
|
||||
throw new Error(err.error ?? `HTTP ${res.status}`);
|
||||
}
|
||||
} catch (e) {
|
||||
setState({ status: "error", message: e instanceof Error ? e.message : "Failed to confirm upload" });
|
||||
return;
|
||||
}
|
||||
|
||||
setState({ status: "done" });
|
||||
onUploaded();
|
||||
|
||||
// Reset after a moment
|
||||
setTimeout(() => setState({ status: "idle" }), 2000);
|
||||
}
|
||||
|
||||
const busy = state.status === "resizing" || state.status === "uploading" || state.status === "confirming";
|
||||
|
||||
return (
|
||||
<div>
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="file"
|
||||
accept={ACCEPTED_TYPES.join(",")}
|
||||
style={{ display: "none" }}
|
||||
onChange={(e) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (file) void handleFile(file);
|
||||
// reset so re-selecting same file works
|
||||
e.target.value = "";
|
||||
}}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
disabled={busy}
|
||||
onClick={() => inputRef.current?.click()}
|
||||
style={{
|
||||
fontSize: 12,
|
||||
padding: "0.2rem 0.55rem",
|
||||
borderRadius: 5,
|
||||
border: "1px solid #d1d5db",
|
||||
background: "#fff",
|
||||
cursor: busy ? "not-allowed" : "pointer",
|
||||
color: busy ? "#9ca3af" : "#374151",
|
||||
display: "inline-flex",
|
||||
alignItems: "center",
|
||||
gap: "0.3rem",
|
||||
}}
|
||||
>
|
||||
{state.status === "idle" && "📷 Upload photo"}
|
||||
{state.status === "resizing" && "Resizing…"}
|
||||
{state.status === "uploading" && `Uploading ${state.progress}%`}
|
||||
{state.status === "confirming" && "Saving…"}
|
||||
{state.status === "done" && "✓ Uploaded"}
|
||||
{state.status === "error" && "📷 Upload photo"}
|
||||
</button>
|
||||
{state.status === "error" && (
|
||||
<div style={{ fontSize: 11, color: "#dc2626", marginTop: "0.2rem" }}>
|
||||
{state.message}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
@import "tailwindcss";
|
||||
|
||||
:root {
|
||||
--color-primary: #4f8a6f;
|
||||
--color-primary-dark: color-mix(in srgb, var(--color-primary) 80%, #000);
|
||||
--color-accent: #8b7355;
|
||||
--color-accent-hover: color-mix(in srgb, var(--color-accent) 88%, #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-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 {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
|
||||
font-size: 16px;
|
||||
line-height: 1.5;
|
||||
color: #1a202c;
|
||||
background: #f0f2f5;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
}
|
||||
|
||||
a {
|
||||
color: var(--color-primary);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
a:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: 1.5rem;
|
||||
margin-top: 0;
|
||||
letter-spacing: -0.01em;
|
||||
}
|
||||
|
||||
h2, h3, h4 {
|
||||
letter-spacing: -0.01em;
|
||||
}
|
||||
|
||||
/* ─── Admin button polish ─── */
|
||||
button {
|
||||
transition: background-color 0.15s ease, border-color 0.15s ease, box-shadow 0.15s ease, opacity 0.15s ease;
|
||||
}
|
||||
|
||||
button:active:not(:disabled) {
|
||||
transform: translateY(0.5px);
|
||||
}
|
||||
|
||||
/* ─── Admin input / select focus states ─── */
|
||||
input:focus, select:focus, textarea:focus {
|
||||
outline: none;
|
||||
border-color: var(--color-primary);
|
||||
box-shadow: 0 0 0 3px color-mix(in srgb, var(--color-primary) 12%, transparent);
|
||||
}
|
||||
|
||||
/* ─── Admin card-like containers (borders get subtle shadow) ─── */
|
||||
[style*="border: 1px solid"] {
|
||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.04);
|
||||
}
|
||||
|
||||
/* ─── Scrollbar polish ─── */
|
||||
::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: #cbd5e1;
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
background: #94a3b8;
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||