chore: promote dev to uat #3
@@ -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,103 @@
|
||||
name: CI
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main, dev]
|
||||
pull_request:
|
||||
branches: [main, dev]
|
||||
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]
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
id-token: write
|
||||
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
|
||||
|
||||
- name: Log in to GitHub Container Registry
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Build and push Web image
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: .
|
||||
file: Dockerfile
|
||||
push: true
|
||||
tags: |
|
||||
ghcr.io/groombook/web:${{ steps.version.outputs.tag }}
|
||||
${{ github.ref == 'refs/heads/main' && 'ghcr.io/groombook/web:latest' || '' }}
|
||||
cache-from: type=gha
|
||||
cache-to: type=gha,mode=max
|
||||
@@ -0,0 +1,8 @@
|
||||
node_modules/
|
||||
.env
|
||||
.env.production
|
||||
.env.local
|
||||
dist/
|
||||
playwright-report/
|
||||
test-results/
|
||||
*.log
|
||||
@@ -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 curl -f http://localhost:80/ || exit 1
|
||||
@@ -0,0 +1,164 @@
|
||||
# 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. Test Cases
|
||||
|
||||
### 4.1 Authentication UI
|
||||
|
||||
| # | Scenario | Steps | Expected |
|
||||
|---|----------|-------|----------|
|
||||
| TC-WEB-4.1.1 | Login page loads | Navigate to UAT URL | Login form is displayed with OIDC provider button(s) |
|
||||
| TC-WEB-4.1.2 | OIDC redirect | Click OIDC login button | Redirected to OIDC provider, then back to app with session established |
|
||||
| TC-WEB-4.1.3 | Logout | Click logout button | Session cleared, redirected to login page |
|
||||
| TC-WEB-4.1.4 | Session indicator | After successful login | User info/initials visible in UI indicating active session |
|
||||
|
||||
### 4.2 Dashboard
|
||||
|
||||
| # | Scenario | Steps | Expected |
|
||||
|---|----------|-------|----------|
|
||||
| TC-WEB-4.2.1 | Dashboard loads after login | Complete authentication | Dashboard page loads without errors |
|
||||
| TC-WEB-4.2.2 | Key metrics visible | View dashboard | Revenue, appointments, clients, and other key metrics displayed |
|
||||
| TC-WEB-4.2.3 | No blank state | On fresh login | Dashboard shows meaningful data, not empty/blank state |
|
||||
|
||||
### 4.3 Client Management UI
|
||||
|
||||
| # | Scenario | Steps | Expected |
|
||||
|---|----------|-------|----------|
|
||||
| TC-WEB-4.3.1 | Client list loads | Navigate to Clients section | List of clients is displayed |
|
||||
| TC-WEB-4.3.2 | Create client | Click "New Client", fill form, submit | Client created successfully, appears in list |
|
||||
| TC-WEB-4.3.3 | Edit client | Click on client, modify details, save | Client updated successfully |
|
||||
| TC-WEB-4.3.4 | Search clients | Enter search term in search box | List filters to matching clients |
|
||||
| TC-WEB-4.3.5 | Archive client | Click archive on client record | Client marked as archived, removed from active list |
|
||||
|
||||
### 4.4 Pet Management UI
|
||||
|
||||
| # | Scenario | Steps | Expected |
|
||||
|---|----------|-------|----------|
|
||||
| TC-WEB-4.4.1 | Pet profiles visible | Open client details | All pets for client displayed with basic info |
|
||||
| TC-WEB-4.4.2 | Add pet | Click "Add Pet", fill form, submit | Pet created and linked to client |
|
||||
| TC-WEB-4.4.3 | Edit pet details | Click on pet, modify details, save | Pet updated successfully |
|
||||
| TC-WEB-4.4.4 | Grooming history view | View pet profile | Past appointments/grooming sessions displayed |
|
||||
|
||||
### 4.5 Appointment Scheduling UI
|
||||
|
||||
| # | Scenario | Steps | Expected |
|
||||
|---|----------|-------|----------|
|
||||
| TC-WEB-4.5.1 | Calendar view loads | Navigate to Appointments | Calendar view displays appointments |
|
||||
| TC-WEB-4.5.2 | Create booking | Click "New Appointment", fill details, submit | Appointment created and appears on calendar |
|
||||
| TC-WEB-4.5.3 | Modify appointment | Click on appointment, change details, save | Appointment updated successfully |
|
||||
| TC-WEB-4.5.4 | Cancel appointment | Click cancel on appointment | Appointment marked as cancelled |
|
||||
| TC-WEB-4.5.5 | Appointment groups | View grouped appointments | Related appointments display as group |
|
||||
|
||||
### 4.6 Service Management UI
|
||||
|
||||
| # | Scenario | Steps | Expected |
|
||||
|---|----------|-------|----------|
|
||||
| TC-WEB-4.6.1 | Service catalog loads | Navigate to Services | List of available services displayed |
|
||||
| TC-WEB-4.6.2 | Create service | Click "New Service", fill form, submit | Service created successfully |
|
||||
| TC-WEB-4.6.3 | Edit service | Click on service, modify details, save | Service updated successfully |
|
||||
|
||||
### 4.7 Staff Management UI
|
||||
|
||||
| # | Scenario | Steps | Expected |
|
||||
|---|----------|-------|----------|
|
||||
| TC-WEB-4.7.1 | Staff list loads | Navigate to Staff | List of staff members displayed |
|
||||
| TC-WEB-4.7.2 | Role display | View staff member | Staff role/permissions clearly visible |
|
||||
|
||||
### 4.8 Invoicing & Payments UI
|
||||
|
||||
| # | Scenario | Steps | Expected |
|
||||
|---|----------|-------|----------|
|
||||
| TC-WEB-4.8.1 | Invoice list loads | Navigate to Invoices | List of invoices displayed with status |
|
||||
| TC-WEB-4.8.2 | Payment flow | Click "Pay" on unpaid invoice, complete payment | Payment processed, invoice marked as paid |
|
||||
| TC-WEB-4.8.3 | Receipts view | View paid invoice | Receipt/payment details displayed |
|
||||
|
||||
### 4.9 Customer Portal UI
|
||||
|
||||
| # | Scenario | Steps | Expected |
|
||||
|---|----------|-------|----------|
|
||||
| TC-WEB-4.9.1 | Client-facing view | Log in as client persona | Customer portal UI displayed |
|
||||
| TC-WEB-4.9.2 | Appointment list | View client portal appointments | List of client's appointments visible |
|
||||
| TC-WEB-4.9.3 | Confirm appointment | Click confirm on pending appointment | Appointment status updated to confirmed |
|
||||
| TC-WEB-4.9.4 | Cancel appointment | Click cancel on appointment | Appointment marked as cancelled |
|
||||
|
||||
### 4.10 Reports UI
|
||||
|
||||
| # | Scenario | Steps | Expected |
|
||||
|---|----------|-------|----------|
|
||||
| TC-WEB-4.10.1 | Revenue charts | Navigate to Reports | Revenue charts display with data |
|
||||
| TC-WEB-4.10.2 | Utilization graphs | View reports | Staff/resource utilization graphs visible |
|
||||
|
||||
### 4.11 Settings UI
|
||||
|
||||
| # | Scenario | Steps | Expected |
|
||||
|---|----------|-------|----------|
|
||||
| TC-WEB-4.11.1 | Configuration page | Navigate to Settings | Settings page loads without errors |
|
||||
| TC-WEB-4.11.2 | Form interactions | Modify settings, save | Settings saved successfully, changes reflected |
|
||||
|
||||
### 4.12 Navigation
|
||||
|
||||
| # | Scenario | Steps | Expected |
|
||||
|---|----------|-------|----------|
|
||||
| TC-WEB-4.12.1 | Sidebar/menu links | Click navigation items | Each section loads correctly |
|
||||
| TC-WEB-4.12.2 | All sections reachable | Navigate through all menu items | All sections accessible, no 404 errors |
|
||||
| TC-WEB-4.12.3 | No broken links | Test all navigation paths | All links work, no broken routes |
|
||||
|
||||
### 4.13 Mobile / PWA
|
||||
|
||||
| # | Scenario | Steps | Expected |
|
||||
|---|----------|-------|----------|
|
||||
| TC-WEB-4.13.1 | Responsive at 390x844 | Resize viewport to mobile dimensions | Layout adapts correctly, no horizontal scroll |
|
||||
| TC-WEB-4.13.2 | PWA install prompt | Load app on supported browser | Install prompt appears when criteria met |
|
||||
| TC-WEB-4.13.3 | Touch interactions | Use touch gestures on mobile | All interactions work with touch input |
|
||||
|
||||
### 4.14 Error & Empty States
|
||||
|
||||
| # | Scenario | Steps | Expected |
|
||||
|---|----------|-------|----------|
|
||||
| TC-WEB-4.14.1 | Form validation | Submit form with invalid data | Appropriate validation errors displayed |
|
||||
| TC-WEB-4.14.2 | Missing data | Navigate to section with no data | Empty state message displayed, not blank page |
|
||||
| TC-WEB-4.14.3 | Error boundaries | Trigger error condition | Friendly error message displayed, app doesn't crash |
|
||||
|
||||
## 5. 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
|
||||
|
||||
## 6. 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 §4.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,47 @@
|
||||
{
|
||||
"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": {
|
||||
"@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",
|
||||
"lucide-react": "^0.577.0",
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.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/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,210 @@
|
||||
// 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;
|
||||
healthAlerts: string | null;
|
||||
groomingNotes: string | null;
|
||||
cutStyle: string | null;
|
||||
shampooPreference: string | null;
|
||||
specialCareNotes: string | null;
|
||||
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;
|
||||
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;
|
||||
}
|
||||
@@ -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 |
@@ -0,0 +1,9 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<Error>
|
||||
<Code>AccessDenied</Code>
|
||||
<Message>You have no right to access this object because of bucket acl.</Message>
|
||||
<RequestId>69D96C853FAECD363909C4A0</RequestId>
|
||||
<HostId>hailuo-image-algeng-data-us.oss-us-east-1.aliyuncs.com</HostId>
|
||||
<EC>0003-00000001</EC>
|
||||
<RecommendDoc>https://api.alibabacloud.com/troubleshoot?q=0003-00000001</RecommendDoc>
|
||||
</Error>
|
||||
|
After Width: | Height: | Size: 227 KiB |
|
After Width: | Height: | Size: 243 KiB |
@@ -0,0 +1,9 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<Error>
|
||||
<Code>AccessDenied</Code>
|
||||
<Message>You have no right to access this object because of bucket acl.</Message>
|
||||
<RequestId>69D96CFC84D7A9333708F278</RequestId>
|
||||
<HostId>hailuo-image-algeng-data-us.oss-us-east-1.aliyuncs.com</HostId>
|
||||
<EC>0003-00000001</EC>
|
||||
<RecommendDoc>https://api.alibabacloud.com/troubleshoot?q=0003-00000001</RecommendDoc>
|
||||
</Error>
|
||||
|
After Width: | Height: | Size: 154 KiB |
@@ -0,0 +1,9 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<Error>
|
||||
<Code>AccessDenied</Code>
|
||||
<Message>You have no right to access this object because of bucket acl.</Message>
|
||||
<RequestId>69D96D48D7892E37386B9ACB</RequestId>
|
||||
<HostId>hailuo-image-algeng-data-us.oss-us-east-1.aliyuncs.com</HostId>
|
||||
<EC>0003-00000001</EC>
|
||||
<RecommendDoc>https://api.alibabacloud.com/troubleshoot?q=0003-00000001</RecommendDoc>
|
||||
</Error>
|
||||
|
After Width: | Height: | Size: 260 KiB |
|
After Width: | Height: | Size: 196 KiB |
@@ -0,0 +1,9 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<Error>
|
||||
<Code>AccessDenied</Code>
|
||||
<Message>You have no right to access this object because of bucket acl.</Message>
|
||||
<RequestId>69D96C25663D703833F23607</RequestId>
|
||||
<HostId>hailuo-image-algeng-data-us.oss-us-east-1.aliyuncs.com</HostId>
|
||||
<EC>0003-00000001</EC>
|
||||
<RecommendDoc>https://api.alibabacloud.com/troubleshoot?q=0003-00000001</RecommendDoc>
|
||||
</Error>
|
||||
@@ -0,0 +1,9 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<Error>
|
||||
<Code>AccessDenied</Code>
|
||||
<Message>You have no right to access this object because of bucket acl.</Message>
|
||||
<RequestId>69D96D89851C843332073968</RequestId>
|
||||
<HostId>hailuo-image-algeng-data-us.oss-us-east-1.aliyuncs.com</HostId>
|
||||
<EC>0003-00000001</EC>
|
||||
<RecommendDoc>https://api.alibabacloud.com/troubleshoot?q=0003-00000001</RecommendDoc>
|
||||
</Error>
|
||||
|
After Width: | Height: | Size: 262 KiB |
|
After Width: | Height: | Size: 347 KiB |
|
After Width: | Height: | Size: 250 KiB |
|
After Width: | Height: | Size: 205 KiB |
@@ -0,0 +1,9 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<Error>
|
||||
<Code>AccessDenied</Code>
|
||||
<Message>You have no right to access this object because of bucket acl.</Message>
|
||||
<RequestId>69D96C9C5A03D33730C61AD8</RequestId>
|
||||
<HostId>hailuo-image-algeng-data-us.oss-us-east-1.aliyuncs.com</HostId>
|
||||
<EC>0003-00000001</EC>
|
||||
<RecommendDoc>https://api.alibabacloud.com/troubleshoot?q=0003-00000001</RecommendDoc>
|
||||
</Error>
|
||||
|
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 |
@@ -0,0 +1,9 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<Error>
|
||||
<Code>AccessDenied</Code>
|
||||
<Message>You have no right to access this object because of bucket acl.</Message>
|
||||
<RequestId>69D96BEB91911B30317E3BE8</RequestId>
|
||||
<HostId>hailuo-image-algeng-data-us.oss-us-east-1.aliyuncs.com</HostId>
|
||||
<EC>0003-00000001</EC>
|
||||
<RecommendDoc>https://api.alibabacloud.com/troubleshoot?q=0003-00000001</RecommendDoc>
|
||||
</Error>
|
||||
|
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 |
@@ -0,0 +1,9 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<Error>
|
||||
<Code>AccessDenied</Code>
|
||||
<Message>You have no right to access this object because of bucket acl.</Message>
|
||||
<RequestId>69D96BFB7B92D33535D6D90D</RequestId>
|
||||
<HostId>hailuo-image-algeng-data-us.oss-us-east-1.aliyuncs.com</HostId>
|
||||
<EC>0003-00000001</EC>
|
||||
<RecommendDoc>https://api.alibabacloud.com/troubleshoot?q=0003-00000001</RecommendDoc>
|
||||
</Error>
|
||||
|
After Width: | Height: | Size: 312 KiB |
@@ -0,0 +1,9 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<Error>
|
||||
<Code>AccessDenied</Code>
|
||||
<Message>You have no right to access this object because of bucket acl.</Message>
|
||||
<RequestId>69D96B8BDF4B473630A2E120</RequestId>
|
||||
<HostId>hailuo-image-algeng-data-us.oss-us-east-1.aliyuncs.com</HostId>
|
||||
<EC>0003-00000001</EC>
|
||||
<RecommendDoc>https://api.alibabacloud.com/troubleshoot?q=0003-00000001</RecommendDoc>
|
||||
</Error>
|
||||
@@ -0,0 +1,9 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<Error>
|
||||
<Code>AccessDenied</Code>
|
||||
<Message>You have no right to access this object because of bucket acl.</Message>
|
||||
<RequestId>69D96D78BFFCAD343037C27C</RequestId>
|
||||
<HostId>hailuo-image-algeng-data-us.oss-us-east-1.aliyuncs.com</HostId>
|
||||
<EC>0003-00000001</EC>
|
||||
<RecommendDoc>https://api.alibabacloud.com/troubleshoot?q=0003-00000001</RecommendDoc>
|
||||
</Error>
|
||||
|
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,415 @@
|
||||
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 { 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/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="/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
|
||||
useEffect(() => {
|
||||
if (authDisabled === null || sessionLoading) return;
|
||||
// Skip if no authenticated session (will redirect to login or dev selector)
|
||||
if (!authDisabled && !session) return;
|
||||
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)
|
||||
if (!authDisabled && !session) {
|
||||
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 authenticated users to /admin (but preserve impersonation flow via ?sessionId=)
|
||||
const searchParams = new URLSearchParams(location.search);
|
||||
if (!authDisabled && session && !location.pathname.startsWith("/admin") && !searchParams.has("sessionId")) {
|
||||
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";
|
||||
|
||||
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,212 @@
|
||||
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("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,382 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import { render, screen, fireEvent, waitFor } from "@testing-library/react";
|
||||
import { parseTimeTo24Hour, isUpcoming, CustomerNotesSection, ConfirmationSection } from "../portal/sections/Appointments.tsx";
|
||||
|
||||
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");
|
||||
});
|
||||
});
|
||||
|
||||
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();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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,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,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,315 @@
|
||||
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";
|
||||
|
||||
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 });
|
||||
});
|
||||
});
|
||||
@@ -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,84 @@
|
||||
@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);
|
||||
}
|
||||
|
||||
*, *::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,7 @@
|
||||
import { createAuthClient } from "better-auth/react";
|
||||
|
||||
export const authClient = createAuthClient({
|
||||
baseURL: import.meta.env.VITE_API_URL ?? "",
|
||||
});
|
||||
|
||||
export const { signIn, signOut, useSession, changePassword } = authClient;
|
||||
@@ -0,0 +1,28 @@
|
||||
import { getDevUser } from "../pages/DevLoginSelector.js";
|
||||
|
||||
const originalFetch = window.fetch;
|
||||
|
||||
/**
|
||||
* Patches global fetch to include X-Dev-User-Id header on API requests
|
||||
* when a dev user is selected via the login selector.
|
||||
*
|
||||
* Intentionally mutates window.fetch — this is dev-only (AUTH_DISABLED=true).
|
||||
*/
|
||||
export function installDevFetchInterceptor() {
|
||||
window.fetch = function (input: RequestInfo | URL, init?: RequestInit) {
|
||||
const user = getDevUser();
|
||||
if (!user) return originalFetch(input, init);
|
||||
|
||||
const url = typeof input === "string" ? input : input instanceof URL ? input.toString() : (input as Request).url;
|
||||
|
||||
// Only inject header for API calls
|
||||
if (!url.startsWith("/api/")) return originalFetch(input, init);
|
||||
|
||||
const headers = new Headers(init?.headers);
|
||||
if (!headers.has("X-Dev-User-Id")) {
|
||||
headers.set("X-Dev-User-Id", user.id);
|
||||
}
|
||||
|
||||
return originalFetch(input, { ...init, headers });
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import { StrictMode } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { BrowserRouter } from "react-router-dom";
|
||||
import { App } from "./App.js";
|
||||
import { installDevFetchInterceptor } from "./lib/devFetch.js";
|
||||
import "./index.css";
|
||||
|
||||
installDevFetchInterceptor();
|
||||
|
||||
const root = document.getElementById("root");
|
||||
if (!root) throw new Error("Root element not found");
|
||||
|
||||
createRoot(root).render(
|
||||
<StrictMode>
|
||||
<BrowserRouter>
|
||||
<App />
|
||||
</BrowserRouter>
|
||||
</StrictMode>
|
||||
);
|
||||