feat: extract groombook/web from monorepo
- Copy apps/web/ with all src, components, pages, portal - Inline packages/types/ as local packages/types module - Add tsconfig path aliases for @groombook/types - Port Dockerfile and CI workflow - Image name: ghcr.io/groombook/web Co-Authored-By: Paperclip <noreply@paperclip.ing>
@@ -0,0 +1,7 @@
|
|||||||
|
# Ignore untracked .js files containing JSX (build artifacts)
|
||||||
|
src/__tests__/*.js
|
||||||
|
src/portal/sections/*.js
|
||||||
|
src/portal/*.js
|
||||||
|
src/pages/*.js
|
||||||
|
src/components/*.js
|
||||||
|
src/lib/*.js
|
||||||
@@ -0,0 +1,102 @@
|
|||||||
|
name: CI
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [main, dev]
|
||||||
|
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 --filter @groombook/web typecheck
|
||||||
|
|
||||||
|
- name: Lint
|
||||||
|
run: pnpm --filter @groombook/web 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 --filter @groombook/web test
|
||||||
|
|
||||||
|
docker:
|
||||||
|
name: Build & Push Docker Image
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
needs: [lint-typecheck, test]
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
packages: 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 --filter @groombook/web 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,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,45 @@
|
|||||||
|
{
|
||||||
|
"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": {
|
||||||
|
"@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/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"]
|
||||||
|
}
|
||||||
|
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>
|
||||||
|
);
|
||||||
@@ -0,0 +1,957 @@
|
|||||||
|
import { useEffect, useState, useCallback, useRef } from "react";
|
||||||
|
import type { Appointment, Client, Pet, Service, Staff } from "@groombook/types";
|
||||||
|
|
||||||
|
// ─── Helpers ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function startOfWeek(date: Date): Date {
|
||||||
|
const d = new Date(date);
|
||||||
|
const day = d.getDay(); // 0=Sun
|
||||||
|
const diff = day === 0 ? -6 : 1 - day; // Monday start
|
||||||
|
d.setDate(d.getDate() + diff);
|
||||||
|
d.setHours(0, 0, 0, 0);
|
||||||
|
return d;
|
||||||
|
}
|
||||||
|
|
||||||
|
function addDays(date: Date, n: number): Date {
|
||||||
|
const d = new Date(date);
|
||||||
|
d.setDate(d.getDate() + n);
|
||||||
|
return d;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDate(d: Date): string {
|
||||||
|
return d.toISOString().slice(0, 10);
|
||||||
|
}
|
||||||
|
|
||||||
|
function fmtTime(iso: string): string {
|
||||||
|
return new Date(iso).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" });
|
||||||
|
}
|
||||||
|
|
||||||
|
function fmtDateShort(d: Date): string {
|
||||||
|
return d.toLocaleDateString([], { weekday: "short", month: "short", day: "numeric" });
|
||||||
|
}
|
||||||
|
|
||||||
|
const STATUS_COLORS: Record<string, string> = {
|
||||||
|
scheduled: "#3b82f6",
|
||||||
|
confirmed: "#10b981",
|
||||||
|
in_progress: "#f59e0b",
|
||||||
|
completed: "#6b7280",
|
||||||
|
cancelled: "#ef4444",
|
||||||
|
no_show: "#9ca3af",
|
||||||
|
};
|
||||||
|
|
||||||
|
const GROOMER_PALETTE = [
|
||||||
|
"#8b5cf6", // violet
|
||||||
|
"#0ea5e9", // sky
|
||||||
|
"#f43f5e", // rose
|
||||||
|
"#14b8a6", // teal
|
||||||
|
"#f97316", // orange
|
||||||
|
"#a855f7", // purple
|
||||||
|
"#84cc16", // lime
|
||||||
|
"#e879f9", // fuchsia
|
||||||
|
];
|
||||||
|
const UNASSIGNED_COLOR = "#94a3b8";
|
||||||
|
|
||||||
|
const STATUS_TRANSITIONS: Record<string, string[]> = {
|
||||||
|
scheduled: ["confirmed", "cancelled", "no_show"],
|
||||||
|
confirmed: ["in_progress", "cancelled", "no_show"],
|
||||||
|
in_progress: ["completed", "no_show"],
|
||||||
|
completed: [],
|
||||||
|
cancelled: [],
|
||||||
|
no_show: [],
|
||||||
|
};
|
||||||
|
|
||||||
|
// ─── Types ───────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
type CascadeMode = "this_only" | "this_and_future" | "all";
|
||||||
|
|
||||||
|
interface BookingForm {
|
||||||
|
clientId: string;
|
||||||
|
petId: string;
|
||||||
|
serviceId: string;
|
||||||
|
staffId: string;
|
||||||
|
batherStaffId: string;
|
||||||
|
date: string;
|
||||||
|
startTime: string;
|
||||||
|
notes: string;
|
||||||
|
recurring: boolean;
|
||||||
|
recurrenceFrequencyWeeks: string;
|
||||||
|
recurrenceCount: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const EMPTY_FORM: BookingForm = {
|
||||||
|
clientId: "",
|
||||||
|
petId: "",
|
||||||
|
serviceId: "",
|
||||||
|
staffId: "",
|
||||||
|
batherStaffId: "",
|
||||||
|
date: formatDate(new Date()),
|
||||||
|
startTime: "09:00",
|
||||||
|
notes: "",
|
||||||
|
recurring: false,
|
||||||
|
recurrenceFrequencyWeeks: "4",
|
||||||
|
recurrenceCount: "12",
|
||||||
|
};
|
||||||
|
|
||||||
|
// ─── Component ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export function AppointmentsPage() {
|
||||||
|
const [weekStart, setWeekStart] = useState(() => startOfWeek(new Date()));
|
||||||
|
const [appointments, setAppointments] = useState<Appointment[]>([]);
|
||||||
|
const [clients, setClients] = useState<Client[]>([]);
|
||||||
|
const [pets, setPets] = useState<Pet[]>([]);
|
||||||
|
const [services, setServices] = useState<Service[]>([]);
|
||||||
|
const [staff, setStaff] = useState<Staff[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [showForm, setShowForm] = useState(false);
|
||||||
|
const [form, setForm] = useState<BookingForm>(EMPTY_FORM);
|
||||||
|
const [formError, setFormError] = useState<string | null>(null);
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
const [selectedAppt, setSelectedAppt] = useState<Appointment | null>(null);
|
||||||
|
// Groomer view state
|
||||||
|
const [viewMode, setViewMode] = useState<"status" | "groomer">("status");
|
||||||
|
// null key = unassigned; staffId string = that groomer; undefined set = all visible
|
||||||
|
const [hiddenGroomers, setHiddenGroomers] = useState<Set<string | null>>(new Set());
|
||||||
|
const [paymentStats, setPaymentStats] = useState<{ revenueThisMonth: number; outstanding: number; refundsThisMonth: number; methodBreakdown: { method: string | null; total: number }[] } | null>(null);
|
||||||
|
|
||||||
|
const weekEnd = addDays(weekStart, 6);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetch("/api/invoices/stats/summary")
|
||||||
|
.then((r) => r.ok ? r.json() : null)
|
||||||
|
.then((data) => { if (data) setPaymentStats(data); })
|
||||||
|
.catch(() => {});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const loadAppointments = useCallback(() => {
|
||||||
|
const from = weekStart.toISOString();
|
||||||
|
const to = addDays(weekStart, 7).toISOString();
|
||||||
|
return fetch(`/api/appointments?from=${encodeURIComponent(from)}&to=${encodeURIComponent(to)}`)
|
||||||
|
.then((r) => {
|
||||||
|
if (!r.ok) throw new Error(`HTTP ${r.status}`);
|
||||||
|
return r.json() as Promise<Appointment[]>;
|
||||||
|
})
|
||||||
|
.then(setAppointments);
|
||||||
|
}, [weekStart]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
Promise.all([
|
||||||
|
loadAppointments(),
|
||||||
|
fetch("/api/clients").then((r) => {
|
||||||
|
if (!r.ok) throw new Error(`HTTP ${r.status}`);
|
||||||
|
return r.json() as Promise<Client[]>;
|
||||||
|
}).then(setClients),
|
||||||
|
fetch("/api/services").then((r) => {
|
||||||
|
if (!r.ok) throw new Error(`HTTP ${r.status}`);
|
||||||
|
return r.json() as Promise<Service[]>;
|
||||||
|
}).then(setServices),
|
||||||
|
fetch("/api/staff").then((r) => {
|
||||||
|
if (!r.ok) throw new Error(`HTTP ${r.status}`);
|
||||||
|
return r.json() as Promise<Staff[]>;
|
||||||
|
}).then(setStaff),
|
||||||
|
])
|
||||||
|
.catch((e: unknown) => setError(e instanceof Error ? e.message : "Unknown error"))
|
||||||
|
.finally(() => setLoading(false));
|
||||||
|
}, [loadAppointments]);
|
||||||
|
|
||||||
|
// Load pets when client is selected
|
||||||
|
useEffect(() => {
|
||||||
|
if (!form.clientId) {
|
||||||
|
setPets([]);
|
||||||
|
setForm((f) => ({ ...f, petId: "" }));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
fetch(`/api/pets?clientId=${encodeURIComponent(form.clientId)}`)
|
||||||
|
.then((r) => r.json() as Promise<Pet[]>)
|
||||||
|
.then(setPets);
|
||||||
|
}, [form.clientId]);
|
||||||
|
|
||||||
|
const days = Array.from({ length: 7 }, (_, i) => addDays(weekStart, i));
|
||||||
|
|
||||||
|
// Assign a stable color to each active groomer by index
|
||||||
|
const activeGroomers = staff.filter((s) => s.active && s.role === "groomer");
|
||||||
|
const groomerColorMap = new Map<string, string>(
|
||||||
|
activeGroomers.map((s, i) => [s.id, GROOMER_PALETTE[i % GROOMER_PALETTE.length] ?? UNASSIGNED_COLOR])
|
||||||
|
);
|
||||||
|
|
||||||
|
function groomerColor(staffId: string | null): string {
|
||||||
|
if (!staffId) return UNASSIGNED_COLOR;
|
||||||
|
return groomerColorMap.get(staffId) ?? UNASSIGNED_COLOR;
|
||||||
|
}
|
||||||
|
|
||||||
|
function apptColor(a: Appointment): string {
|
||||||
|
return viewMode === "groomer" ? groomerColor(a.staffId) : (STATUS_COLORS[a.status] ?? "#94a3b8");
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleGroomer(key: string | null) {
|
||||||
|
setHiddenGroomers((prev) => {
|
||||||
|
const next = new Set(prev);
|
||||||
|
if (next.has(key)) next.delete(key);
|
||||||
|
else next.add(key);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const apptsByDay = days.map((day) => {
|
||||||
|
const dateStr = formatDate(day);
|
||||||
|
const dayAppts = appointments.filter((a) => a.startTime.startsWith(dateStr));
|
||||||
|
if (viewMode !== "groomer" || hiddenGroomers.size === 0) return dayAppts;
|
||||||
|
return dayAppts.filter((a) => !hiddenGroomers.has(a.staffId));
|
||||||
|
});
|
||||||
|
|
||||||
|
function openNewForm(date?: Date) {
|
||||||
|
setForm({ ...EMPTY_FORM, date: formatDate(date ?? new Date()) });
|
||||||
|
setFormError(null);
|
||||||
|
setShowForm(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function submitBooking(e: React.FormEvent) {
|
||||||
|
e.preventDefault();
|
||||||
|
if (!form.clientId || !form.petId || !form.serviceId) {
|
||||||
|
setFormError("Client, pet, and service are required.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const service = services.find((s) => s.id === form.serviceId);
|
||||||
|
if (!service) return;
|
||||||
|
|
||||||
|
const startISO = new Date(`${form.date}T${form.startTime}`).toISOString();
|
||||||
|
const endDate = new Date(`${form.date}T${form.startTime}`);
|
||||||
|
endDate.setMinutes(endDate.getMinutes() + service.durationMinutes);
|
||||||
|
const endISO = endDate.toISOString();
|
||||||
|
|
||||||
|
const payload: Record<string, unknown> = {
|
||||||
|
clientId: form.clientId,
|
||||||
|
petId: form.petId,
|
||||||
|
serviceId: form.serviceId,
|
||||||
|
staffId: form.staffId || undefined,
|
||||||
|
batherStaffId: form.batherStaffId || undefined,
|
||||||
|
startTime: startISO,
|
||||||
|
endTime: endISO,
|
||||||
|
notes: form.notes || undefined,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (form.recurring) {
|
||||||
|
payload.recurrence = {
|
||||||
|
frequencyWeeks: parseInt(form.recurrenceFrequencyWeeks),
|
||||||
|
count: parseInt(form.recurrenceCount),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
setSaving(true);
|
||||||
|
setFormError(null);
|
||||||
|
try {
|
||||||
|
const res = await fetch("/api/appointments", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify(payload),
|
||||||
|
});
|
||||||
|
if (!res.ok) {
|
||||||
|
const err = (await res.json()) as { error?: string };
|
||||||
|
throw new Error(err.error ?? `HTTP ${res.status}`);
|
||||||
|
}
|
||||||
|
setShowForm(false);
|
||||||
|
await loadAppointments();
|
||||||
|
} catch (e: unknown) {
|
||||||
|
setFormError(e instanceof Error ? e.message : "Failed to save");
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function updateStatus(appt: Appointment, status: string) {
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/appointments/${appt.id}`, {
|
||||||
|
method: "PATCH",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ status }),
|
||||||
|
});
|
||||||
|
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||||
|
setSelectedAppt(null);
|
||||||
|
await loadAppointments();
|
||||||
|
} catch (e: unknown) {
|
||||||
|
alert(e instanceof Error ? e.message : "Failed to update");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deleteAppt(id: string, cascade: CascadeMode) {
|
||||||
|
const url =
|
||||||
|
cascade !== "this_only"
|
||||||
|
? `/api/appointments/${id}?cascade=${cascade}`
|
||||||
|
: `/api/appointments/${id}`;
|
||||||
|
try {
|
||||||
|
const res = await fetch(url, { method: "DELETE" });
|
||||||
|
if (!res.ok) {
|
||||||
|
const err = (await res.json()) as { error?: string };
|
||||||
|
throw new Error(err.error ?? `HTTP ${res.status}`);
|
||||||
|
}
|
||||||
|
} catch (e: unknown) {
|
||||||
|
alert(e instanceof Error ? e.message : "Failed to delete appointment");
|
||||||
|
}
|
||||||
|
setSelectedAppt(null);
|
||||||
|
await loadAppointments();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (loading) return <p style={{ padding: "1rem" }}>Loading…</p>;
|
||||||
|
if (error) return <p style={{ padding: "1rem", color: "red" }}>Error: {error}</p>;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ fontFamily: "system-ui, sans-serif" }}>
|
||||||
|
{/* ── Header ── */}
|
||||||
|
<div style={{ display: "flex", alignItems: "center", gap: "0.75rem", marginBottom: "1rem", flexWrap: "wrap" }}>
|
||||||
|
<h1 style={{ margin: 0 }}>Appointments</h1>
|
||||||
|
<button onClick={() => setWeekStart((w) => addDays(w, -7))} style={btnStyle}>
|
||||||
|
← Prev
|
||||||
|
</button>
|
||||||
|
<span style={{ fontWeight: 600, fontSize: 14 }}>
|
||||||
|
{fmtDateShort(weekStart)} – {fmtDateShort(weekEnd)}
|
||||||
|
</span>
|
||||||
|
<button onClick={() => setWeekStart((w) => addDays(w, 7))} style={btnStyle}>
|
||||||
|
Next →
|
||||||
|
</button>
|
||||||
|
<button onClick={() => setWeekStart(startOfWeek(new Date()))} style={btnStyle}>
|
||||||
|
Today
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => openNewForm()}
|
||||||
|
style={{ ...btnStyle, backgroundColor: "var(--color-primary)", color: "#fff", marginLeft: "auto", borderColor: "var(--color-primary)" }}
|
||||||
|
>
|
||||||
|
+ New Appointment
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Payment Stats Summary */}
|
||||||
|
{paymentStats && (
|
||||||
|
<div style={{ display: "grid", gridTemplateColumns: "repeat(auto-fit, minmax(160px, 1fr))", gap: "0.75rem", marginBottom: "1.25rem" }}>
|
||||||
|
<div style={{ background: "#f0fdf4", border: "1px solid #bbf7d0", borderRadius: 8, padding: "0.75rem 1rem" }}>
|
||||||
|
<div style={{ fontSize: 12, color: "#166534", fontWeight: 600, marginBottom: "0.25rem" }}>Revenue (paid)</div>
|
||||||
|
<div style={{ fontSize: 20, fontWeight: 700, color: "#15803d" }}>${(paymentStats.revenueThisMonth / 100).toFixed(2)}</div>
|
||||||
|
</div>
|
||||||
|
<div style={{ background: "#fefce8", border: "1px solid #fde047", borderRadius: 8, padding: "0.75rem 1rem" }}>
|
||||||
|
<div style={{ fontSize: 12, color: "#854d0e", fontWeight: 600, marginBottom: "0.25rem" }}>Outstanding</div>
|
||||||
|
<div style={{ fontSize: 20, fontWeight: 700, color: "#a16207" }}>${(paymentStats.outstanding / 100).toFixed(2)}</div>
|
||||||
|
</div>
|
||||||
|
<div style={{ background: "#fef2f2", border: "1px solid #fecaca", borderRadius: 8, padding: "0.75rem 1rem" }}>
|
||||||
|
<div style={{ fontSize: 12, color: "#991b1b", fontWeight: 600, marginBottom: "0.25rem" }}>Refunds (this mo.)</div>
|
||||||
|
<div style={{ fontSize: 20, fontWeight: 700, color: "#dc2626" }}>${(paymentStats.refundsThisMonth / 100).toFixed(2)}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* ── View Mode + Groomer Filters ── */}
|
||||||
|
<div style={{ display: "flex", alignItems: "center", gap: "0.5rem", marginBottom: "0.75rem", flexWrap: "wrap" }}>
|
||||||
|
<span style={{ fontSize: 13, fontWeight: 600, color: "#374151" }}>Color by:</span>
|
||||||
|
{(["status", "groomer"] as const).map((mode) => (
|
||||||
|
<button
|
||||||
|
key={mode}
|
||||||
|
onClick={() => setViewMode(mode)}
|
||||||
|
style={{
|
||||||
|
...btnStyle,
|
||||||
|
backgroundColor: viewMode === mode ? "#1e293b" : "#f9fafb",
|
||||||
|
color: viewMode === mode ? "#fff" : "#374151",
|
||||||
|
borderColor: viewMode === mode ? "#1e293b" : "#d1d5db",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{mode === "status" ? "Status" : "Groomer"}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
{viewMode === "groomer" && (
|
||||||
|
<>
|
||||||
|
<span style={{ fontSize: 13, color: "#6b7280", marginLeft: "0.5rem" }}>Show:</span>
|
||||||
|
{activeGroomers.map((s) => {
|
||||||
|
const color = groomerColorMap.get(s.id) ?? UNASSIGNED_COLOR;
|
||||||
|
const visible = !hiddenGroomers.has(s.id);
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={s.id}
|
||||||
|
onClick={() => toggleGroomer(s.id)}
|
||||||
|
title={visible ? `Hide ${s.name}` : `Show ${s.name}`}
|
||||||
|
style={{
|
||||||
|
...btnStyle,
|
||||||
|
backgroundColor: visible ? color : "#f1f5f9",
|
||||||
|
color: visible ? "#fff" : "#94a3b8",
|
||||||
|
borderColor: visible ? color : "#e2e8f0",
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: "0.3rem",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span style={{ width: 8, height: 8, borderRadius: "50%", background: visible ? "#fff" : color, display: "inline-block" }} />
|
||||||
|
{s.name}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
{/* Unassigned toggle */}
|
||||||
|
{(() => {
|
||||||
|
const visible = !hiddenGroomers.has(null);
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
onClick={() => toggleGroomer(null)}
|
||||||
|
style={{
|
||||||
|
...btnStyle,
|
||||||
|
backgroundColor: visible ? UNASSIGNED_COLOR : "#f1f5f9",
|
||||||
|
color: visible ? "#fff" : "#94a3b8",
|
||||||
|
borderColor: visible ? UNASSIGNED_COLOR : "#e2e8f0",
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: "0.3rem",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span style={{ width: 8, height: 8, borderRadius: "50%", background: visible ? "#fff" : UNASSIGNED_COLOR, display: "inline-block" }} />
|
||||||
|
Unassigned
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})()}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ── Weekly Calendar ── */}
|
||||||
|
<div style={{ display: "grid", gridTemplateColumns: "repeat(7, 1fr)", gap: "0.5rem" }}>
|
||||||
|
{days.map((day, i) => {
|
||||||
|
const isToday = formatDate(day) === formatDate(new Date());
|
||||||
|
return (
|
||||||
|
<div key={i} style={{ border: "1px solid #e5e7eb", borderRadius: 8, overflow: "hidden", minHeight: 180, background: "#fff", boxShadow: "0 1px 3px rgba(0, 0, 0, 0.04)" }}>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
padding: "0.4rem 0.6rem",
|
||||||
|
background: isToday ? "linear-gradient(135deg, var(--color-primary), var(--color-primary-dark))" : "#f8fafc",
|
||||||
|
color: isToday ? "#fff" : "#374151",
|
||||||
|
fontWeight: 600,
|
||||||
|
fontSize: 12,
|
||||||
|
display: "flex",
|
||||||
|
justifyContent: "space-between",
|
||||||
|
alignItems: "center",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span>{fmtDateShort(day)}</span>
|
||||||
|
<button
|
||||||
|
onClick={() => openNewForm(day)}
|
||||||
|
style={{
|
||||||
|
border: "none",
|
||||||
|
background: "transparent",
|
||||||
|
color: isToday ? "#fff" : "#6b7280",
|
||||||
|
cursor: "pointer",
|
||||||
|
fontSize: 16,
|
||||||
|
padding: 0,
|
||||||
|
lineHeight: 1,
|
||||||
|
}}
|
||||||
|
title="Add appointment"
|
||||||
|
>
|
||||||
|
+
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div style={{ padding: "0.3rem" }}>
|
||||||
|
{(apptsByDay[i] ?? []).map((a) => {
|
||||||
|
const svc = services.find((s) => s.id === a.serviceId);
|
||||||
|
const cli = clients.find((c) => c.id === a.clientId);
|
||||||
|
const groomer = staff.find((s) => s.id === a.staffId);
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={a.id}
|
||||||
|
onClick={() => setSelectedAppt(a)}
|
||||||
|
style={{
|
||||||
|
background: apptColor(a),
|
||||||
|
color: "#fff",
|
||||||
|
borderRadius: 4,
|
||||||
|
padding: "0.2rem 0.35rem",
|
||||||
|
marginBottom: "0.2rem",
|
||||||
|
fontSize: 11,
|
||||||
|
cursor: "pointer",
|
||||||
|
lineHeight: 1.4,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div style={{ fontWeight: 600 }}>{fmtTime(a.startTime)}</div>
|
||||||
|
<div>{cli?.name ?? "—"}</div>
|
||||||
|
<div style={{ opacity: 0.9 }}>{svc?.name ?? "—"}</div>
|
||||||
|
{viewMode === "groomer" && (
|
||||||
|
<div style={{ opacity: 0.85, fontSize: 10 }}>
|
||||||
|
{groomer?.name ?? "Unassigned"}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{a.seriesId && (
|
||||||
|
<div style={{ opacity: 0.85, fontSize: 10 }}>↻ recurring</div>
|
||||||
|
)}
|
||||||
|
{a.confirmationStatus === "confirmed" && (
|
||||||
|
<div style={{ opacity: 0.95, fontSize: 10 }}>✓ confirmed</div>
|
||||||
|
)}
|
||||||
|
{a.confirmationStatus === "cancelled" && (
|
||||||
|
<div style={{ opacity: 0.95, fontSize: 10, textDecoration: "line-through" }}>✗ cust. cancelled</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ── Booking Form Modal ── */}
|
||||||
|
{showForm && (
|
||||||
|
<Modal onClose={() => setShowForm(false)}>
|
||||||
|
<h2 style={{ marginTop: 0 }}>New Appointment</h2>
|
||||||
|
<form onSubmit={submitBooking}>
|
||||||
|
<Field label="Client">
|
||||||
|
<select
|
||||||
|
value={form.clientId}
|
||||||
|
onChange={(e) => setForm((f) => ({ ...f, clientId: e.target.value }))}
|
||||||
|
required
|
||||||
|
style={inputStyle}
|
||||||
|
>
|
||||||
|
<option value="">— select client —</option>
|
||||||
|
{clients.map((c) => (
|
||||||
|
<option key={c.id} value={c.id}>{c.name}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</Field>
|
||||||
|
<Field label="Pet">
|
||||||
|
<select
|
||||||
|
value={form.petId}
|
||||||
|
onChange={(e) => setForm((f) => ({ ...f, petId: e.target.value }))}
|
||||||
|
required
|
||||||
|
disabled={!form.clientId}
|
||||||
|
style={inputStyle}
|
||||||
|
>
|
||||||
|
<option value="">— select pet —</option>
|
||||||
|
{pets.map((p) => (
|
||||||
|
<option key={p.id} value={p.id}>{p.name}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</Field>
|
||||||
|
<Field label="Service">
|
||||||
|
<select
|
||||||
|
value={form.serviceId}
|
||||||
|
onChange={(e) => setForm((f) => ({ ...f, serviceId: e.target.value }))}
|
||||||
|
required
|
||||||
|
style={inputStyle}
|
||||||
|
>
|
||||||
|
<option value="">— select service —</option>
|
||||||
|
{services.map((s) => (
|
||||||
|
<option key={s.id} value={s.id}>
|
||||||
|
{s.name} ({s.durationMinutes} min — ${(s.basePriceCents / 100).toFixed(2)})
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</Field>
|
||||||
|
<Field label="Groomer (optional)">
|
||||||
|
<select
|
||||||
|
value={form.staffId}
|
||||||
|
onChange={(e) => setForm((f) => ({ ...f, staffId: e.target.value }))}
|
||||||
|
style={inputStyle}
|
||||||
|
>
|
||||||
|
<option value="">— any / unassigned —</option>
|
||||||
|
{staff.filter((s) => s.active).map((s) => (
|
||||||
|
<option key={s.id} value={s.id}>{s.name}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</Field>
|
||||||
|
<Field label="Bather / Assistant (optional)">
|
||||||
|
<select
|
||||||
|
value={form.batherStaffId}
|
||||||
|
onChange={(e) => setForm((f) => ({ ...f, batherStaffId: e.target.value }))}
|
||||||
|
style={inputStyle}
|
||||||
|
>
|
||||||
|
<option value="">— none —</option>
|
||||||
|
{staff.filter((s) => s.active).map((s) => (
|
||||||
|
<option key={s.id} value={s.id}>{s.name}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</Field>
|
||||||
|
<Field label="Date">
|
||||||
|
<input
|
||||||
|
type="date"
|
||||||
|
value={form.date}
|
||||||
|
onChange={(e) => setForm((f) => ({ ...f, date: e.target.value }))}
|
||||||
|
required
|
||||||
|
style={inputStyle}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
<Field label="Start time">
|
||||||
|
<input
|
||||||
|
type="time"
|
||||||
|
value={form.startTime}
|
||||||
|
onChange={(e) => setForm((f) => ({ ...f, startTime: e.target.value }))}
|
||||||
|
required
|
||||||
|
style={inputStyle}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
<Field label="Notes">
|
||||||
|
<textarea
|
||||||
|
value={form.notes}
|
||||||
|
onChange={(e) => setForm((f) => ({ ...f, notes: e.target.value }))}
|
||||||
|
rows={3}
|
||||||
|
style={{ ...inputStyle, resize: "vertical" }}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
|
||||||
|
{/* Recurrence */}
|
||||||
|
<div style={{ marginBottom: "0.75rem" }}>
|
||||||
|
<label style={{ display: "flex", alignItems: "center", gap: "0.5rem", cursor: "pointer", fontSize: 13, fontWeight: 600, color: "#374151" }}>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={form.recurring}
|
||||||
|
onChange={(e) => setForm((f) => ({ ...f, recurring: e.target.checked }))}
|
||||||
|
/>
|
||||||
|
Recurring appointment
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{form.recurring && (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
background: "#f0f9ff",
|
||||||
|
border: "1px solid #bae6fd",
|
||||||
|
borderRadius: 6,
|
||||||
|
padding: "0.75rem",
|
||||||
|
marginBottom: "0.75rem",
|
||||||
|
display: "grid",
|
||||||
|
gridTemplateColumns: "1fr 1fr",
|
||||||
|
gap: "0.75rem",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Field label="Repeat every">
|
||||||
|
<select
|
||||||
|
value={form.recurrenceFrequencyWeeks}
|
||||||
|
onChange={(e) => setForm((f) => ({ ...f, recurrenceFrequencyWeeks: e.target.value }))}
|
||||||
|
style={inputStyle}
|
||||||
|
>
|
||||||
|
<option value="2">2 weeks</option>
|
||||||
|
<option value="4">4 weeks</option>
|
||||||
|
<option value="6">6 weeks</option>
|
||||||
|
<option value="8">8 weeks</option>
|
||||||
|
<option value="12">12 weeks</option>
|
||||||
|
</select>
|
||||||
|
</Field>
|
||||||
|
<Field label="Number of appointments">
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min={2}
|
||||||
|
max={52}
|
||||||
|
value={form.recurrenceCount}
|
||||||
|
onChange={(e) => setForm((f) => ({ ...f, recurrenceCount: e.target.value }))}
|
||||||
|
style={inputStyle}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{formError && <p style={{ color: "red", margin: "0.5rem 0 0" }}>{formError}</p>}
|
||||||
|
<div style={{ display: "flex", gap: "0.5rem", marginTop: "1rem" }}>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={saving}
|
||||||
|
style={{ ...btnStyle, backgroundColor: "var(--color-primary)", color: "#fff", borderColor: "var(--color-primary)" }}
|
||||||
|
>
|
||||||
|
{saving
|
||||||
|
? "Saving…"
|
||||||
|
: form.recurring
|
||||||
|
? `Book ${form.recurrenceCount} appointments`
|
||||||
|
: "Book Appointment"}
|
||||||
|
</button>
|
||||||
|
<button type="button" onClick={() => setShowForm(false)} style={btnStyle}>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</Modal>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* ── Appointment Detail Modal ── */}
|
||||||
|
{selectedAppt && (
|
||||||
|
<Modal onClose={() => setSelectedAppt(null)}>
|
||||||
|
<AppointmentDetail
|
||||||
|
appt={selectedAppt}
|
||||||
|
clients={clients}
|
||||||
|
services={services}
|
||||||
|
staff={staff}
|
||||||
|
onUpdateStatus={updateStatus}
|
||||||
|
onDelete={deleteAppt}
|
||||||
|
onClose={() => setSelectedAppt(null)}
|
||||||
|
/>
|
||||||
|
</Modal>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Sub-components ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function AppointmentDetail({
|
||||||
|
appt,
|
||||||
|
clients,
|
||||||
|
services,
|
||||||
|
staff,
|
||||||
|
onUpdateStatus,
|
||||||
|
onDelete,
|
||||||
|
onClose,
|
||||||
|
}: {
|
||||||
|
appt: Appointment;
|
||||||
|
clients: Client[];
|
||||||
|
services: Service[];
|
||||||
|
staff: Staff[];
|
||||||
|
onUpdateStatus: (a: Appointment, status: string) => void;
|
||||||
|
onDelete: (id: string, cascade: CascadeMode) => void;
|
||||||
|
onClose: () => void;
|
||||||
|
}) {
|
||||||
|
const [showDeleteOptions, setShowDeleteOptions] = useState(false);
|
||||||
|
const [deleteCascade, setDeleteCascade] = useState<CascadeMode>("this_only");
|
||||||
|
|
||||||
|
const client = clients.find((c) => c.id === appt.clientId);
|
||||||
|
const service = services.find((s) => s.id === appt.serviceId);
|
||||||
|
const groomer = staff.find((s) => s.id === appt.staffId);
|
||||||
|
const bather = staff.find((s) => s.id === appt.batherStaffId);
|
||||||
|
const transitions = STATUS_TRANSITIONS[appt.status] ?? [];
|
||||||
|
|
||||||
|
function handleDeleteClick() {
|
||||||
|
if (appt.seriesId) {
|
||||||
|
setShowDeleteOptions(true);
|
||||||
|
} else {
|
||||||
|
if (confirm("Delete this appointment?")) {
|
||||||
|
onDelete(appt.id, "this_only");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<h2 style={{ marginTop: 0, display: "flex", alignItems: "center", gap: "0.5rem", flexWrap: "wrap" }}>
|
||||||
|
Appointment Details
|
||||||
|
{appt.seriesId && (
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
fontSize: 12,
|
||||||
|
fontWeight: 500,
|
||||||
|
background: "#ede9fe",
|
||||||
|
color: "#6d28d9",
|
||||||
|
padding: "0.15rem 0.5rem",
|
||||||
|
borderRadius: 99,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
↻ Recurring series
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</h2>
|
||||||
|
<table style={{ borderCollapse: "collapse", width: "100%", marginBottom: "1rem", fontSize: 14 }}>
|
||||||
|
<tbody>
|
||||||
|
{([
|
||||||
|
["Client", client?.name ?? "—"],
|
||||||
|
["Service", service?.name ?? "—"],
|
||||||
|
["Groomer", groomer?.name ?? "Unassigned"],
|
||||||
|
...(bather ? [["Bather/Asst.", bather.name] as [string, string]] : []),
|
||||||
|
["Start", new Date(appt.startTime).toLocaleString()],
|
||||||
|
["End", new Date(appt.endTime).toLocaleString()],
|
||||||
|
["Status", appt.status.replace("_", " ")],
|
||||||
|
["Confirmation", appt.confirmationStatus === "confirmed"
|
||||||
|
? `✓ Confirmed${appt.confirmedAt ? ` (${new Date(appt.confirmedAt).toLocaleString()})` : ""}`
|
||||||
|
: appt.confirmationStatus === "cancelled"
|
||||||
|
? `✗ Customer cancelled${appt.cancelledAt ? ` (${new Date(appt.cancelledAt).toLocaleString()})` : ""}`
|
||||||
|
: "Pending"],
|
||||||
|
["Notes", appt.notes ?? "—"],
|
||||||
|
...(appt.customerNotes ? [["Customer Notes", appt.customerNotes] as [string, string]] : []),
|
||||||
|
...(appt.seriesId
|
||||||
|
? [["Series slot", `#${(appt.seriesIndex ?? 0) + 1}`] as [string, string]]
|
||||||
|
: []),
|
||||||
|
] as [string, string][]).map(([label, value]) => (
|
||||||
|
<tr key={label}>
|
||||||
|
<td style={{ padding: "4px 12px 4px 0", fontWeight: 600, whiteSpace: "nowrap", verticalAlign: "top", color: "#6b7280" }}>
|
||||||
|
{label}
|
||||||
|
</td>
|
||||||
|
<td style={{ padding: "4px 0" }}>{value}</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
{transitions.length > 0 && (
|
||||||
|
<div style={{ marginBottom: "0.75rem" }}>
|
||||||
|
<span style={{ fontWeight: 600, fontSize: 13, marginRight: "0.5rem" }}>Move to:</span>
|
||||||
|
{transitions.map((s) => (
|
||||||
|
<button
|
||||||
|
key={s}
|
||||||
|
onClick={() => onUpdateStatus(appt, s)}
|
||||||
|
style={{
|
||||||
|
...btnStyle,
|
||||||
|
backgroundColor: STATUS_COLORS[s],
|
||||||
|
color: "#fff",
|
||||||
|
borderColor: STATUS_COLORS[s],
|
||||||
|
marginRight: "0.4rem",
|
||||||
|
marginBottom: "0.3rem",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{s.replace("_", " ")}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Cascade delete picker (series appointments only) */}
|
||||||
|
{showDeleteOptions && (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
background: "#fef2f2",
|
||||||
|
border: "1px solid #fca5a5",
|
||||||
|
borderRadius: 6,
|
||||||
|
padding: "0.75rem",
|
||||||
|
marginBottom: "0.75rem",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<p style={{ margin: "0 0 0.5rem", fontWeight: 600, fontSize: 13 }}>
|
||||||
|
This is part of a recurring series. Which appointments should be cancelled?
|
||||||
|
</p>
|
||||||
|
{(
|
||||||
|
[
|
||||||
|
["this_only", "This appointment only"],
|
||||||
|
["this_and_future", "This and all future appointments in the series"],
|
||||||
|
["all", "All appointments in the series"],
|
||||||
|
] as [CascadeMode, string][]
|
||||||
|
).map(([value, label]) => (
|
||||||
|
<label
|
||||||
|
key={value}
|
||||||
|
style={{ display: "flex", alignItems: "center", gap: "0.4rem", marginBottom: "0.35rem", fontSize: 13, cursor: "pointer" }}
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="radio"
|
||||||
|
name="deleteCascade"
|
||||||
|
value={value}
|
||||||
|
checked={deleteCascade === value}
|
||||||
|
onChange={() => setDeleteCascade(value)}
|
||||||
|
/>
|
||||||
|
{label}
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
<div style={{ display: "flex", gap: "0.5rem", marginTop: "0.5rem" }}>
|
||||||
|
<button
|
||||||
|
onClick={() => onDelete(appt.id, deleteCascade)}
|
||||||
|
style={{ ...btnStyle, backgroundColor: "#ef4444", color: "#fff", borderColor: "#ef4444" }}
|
||||||
|
>
|
||||||
|
Confirm cancellation
|
||||||
|
</button>
|
||||||
|
<button onClick={() => setShowDeleteOptions(false)} style={btnStyle}>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!showDeleteOptions && (
|
||||||
|
<div style={{ display: "flex", gap: "0.5rem" }}>
|
||||||
|
{appt.status !== "completed" && appt.status !== "cancelled" && (
|
||||||
|
<button
|
||||||
|
onClick={handleDeleteClick}
|
||||||
|
style={{ ...btnStyle, backgroundColor: "#ef4444", color: "#fff", borderColor: "#ef4444" }}
|
||||||
|
>
|
||||||
|
Delete
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<button onClick={onClose} style={btnStyle}>Close</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Modal({ children, onClose }: { children: React.ReactNode; onClose: () => void }) {
|
||||||
|
const modalRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const previouslyFocused = document.activeElement as HTMLElement;
|
||||||
|
const focusableSelectors = 'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])';
|
||||||
|
const focusableElements = modalRef.current?.querySelectorAll<HTMLElement>(focusableSelectors);
|
||||||
|
const firstFocusable = focusableElements?.[0];
|
||||||
|
firstFocusable?.focus();
|
||||||
|
|
||||||
|
function handleKeyDown(e: KeyboardEvent) {
|
||||||
|
if (e.key === "Escape") {
|
||||||
|
onClose();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (e.key !== "Tab") return;
|
||||||
|
if (!modalRef.current) return;
|
||||||
|
const focusables = modalRef.current.querySelectorAll<HTMLElement>(focusableSelectors);
|
||||||
|
const first = focusables[0];
|
||||||
|
const last = focusables[focusables.length - 1];
|
||||||
|
if (e.shiftKey) {
|
||||||
|
if (document.activeElement === first) {
|
||||||
|
e.preventDefault();
|
||||||
|
last?.focus();
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if (document.activeElement === last) {
|
||||||
|
e.preventDefault();
|
||||||
|
first?.focus();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
document.addEventListener("keydown", handleKeyDown);
|
||||||
|
return () => {
|
||||||
|
document.removeEventListener("keydown", handleKeyDown);
|
||||||
|
previouslyFocused?.focus();
|
||||||
|
};
|
||||||
|
}, [onClose]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
role="dialog"
|
||||||
|
aria-modal="true"
|
||||||
|
style={{
|
||||||
|
position: "fixed",
|
||||||
|
inset: 0,
|
||||||
|
background: "rgba(0,0,0,0.45)",
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
zIndex: 100,
|
||||||
|
}}
|
||||||
|
onClick={(e) => { if (e.target === e.currentTarget) onClose(); }}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
ref={modalRef}
|
||||||
|
style={{
|
||||||
|
background: "#fff",
|
||||||
|
borderRadius: 8,
|
||||||
|
padding: "1.5rem",
|
||||||
|
maxWidth: 500,
|
||||||
|
width: "calc(100% - 2rem)",
|
||||||
|
maxHeight: "90vh",
|
||||||
|
overflowY: "auto",
|
||||||
|
boxShadow: "0 20px 60px rgba(0,0,0,0.3)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Field({ label, children }: { label: string; children: React.ReactNode }) {
|
||||||
|
return (
|
||||||
|
<div style={{ marginBottom: "0.75rem" }}>
|
||||||
|
<label style={{ display: "block", fontWeight: 600, marginBottom: "0.25rem", fontSize: 13, color: "#374151" }}>
|
||||||
|
{label}
|
||||||
|
</label>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const btnStyle: React.CSSProperties = {
|
||||||
|
padding: "0.4rem 0.85rem",
|
||||||
|
border: "1px solid #d1d5db",
|
||||||
|
borderRadius: 6,
|
||||||
|
background: "#fff",
|
||||||
|
cursor: "pointer",
|
||||||
|
fontSize: 13,
|
||||||
|
fontWeight: 500,
|
||||||
|
};
|
||||||
|
|
||||||
|
const inputStyle: React.CSSProperties = {
|
||||||
|
width: "100%",
|
||||||
|
padding: "0.45rem 0.6rem",
|
||||||
|
border: "1px solid #d1d5db",
|
||||||
|
borderRadius: 6,
|
||||||
|
fontSize: 14,
|
||||||
|
boxSizing: "border-box",
|
||||||
|
};
|
||||||
@@ -0,0 +1,612 @@
|
|||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { useSearchParams } from "react-router-dom";
|
||||||
|
import type { Service } from "@groombook/types";
|
||||||
|
|
||||||
|
// ─── Types ───────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
interface BookingBody {
|
||||||
|
serviceId: string;
|
||||||
|
startTime: string;
|
||||||
|
clientName: string;
|
||||||
|
clientEmail: string;
|
||||||
|
clientPhone: string;
|
||||||
|
petName: string;
|
||||||
|
petSpecies: string;
|
||||||
|
petBreed: string;
|
||||||
|
notes: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface BookingResult {
|
||||||
|
appointment: { id: string; startTime: string; endTime: string };
|
||||||
|
client: { id: string; name: string; email: string | null };
|
||||||
|
pet: { id: string; name: string };
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Helpers ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function fmtPrice(cents: number): string {
|
||||||
|
return `$${(cents / 100).toFixed(2)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function fmtDuration(minutes: number): string {
|
||||||
|
if (minutes < 60) return `${minutes} min`;
|
||||||
|
const h = Math.floor(minutes / 60);
|
||||||
|
const m = minutes % 60;
|
||||||
|
return m > 0 ? `${h}h ${m}m` : `${h}h`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function fmtTime(iso: string): string {
|
||||||
|
return new Date(iso).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" });
|
||||||
|
}
|
||||||
|
|
||||||
|
function fmtDateLong(isoDate: string): string {
|
||||||
|
const d = new Date(isoDate + "T12:00:00Z");
|
||||||
|
return d.toLocaleDateString([], { weekday: "long", year: "numeric", month: "long", day: "numeric" });
|
||||||
|
}
|
||||||
|
|
||||||
|
function todayIso(): string {
|
||||||
|
return new Date().toISOString().slice(0, 10);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Sub-components ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function StepIndicator({ step }: { step: number }) {
|
||||||
|
const steps = ["Service", "Date & Time", "Your Info", "Confirm"];
|
||||||
|
return (
|
||||||
|
<div style={{ display: "flex", gap: 0, marginBottom: "1.5rem" }}>
|
||||||
|
{steps.map((label, i) => {
|
||||||
|
const idx = i + 1;
|
||||||
|
const active = idx === step;
|
||||||
|
const done = idx < step;
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={label}
|
||||||
|
style={{
|
||||||
|
flex: 1,
|
||||||
|
textAlign: "center",
|
||||||
|
padding: "0.5rem 0.25rem",
|
||||||
|
fontSize: 12,
|
||||||
|
fontWeight: active ? 700 : 400,
|
||||||
|
color: active ? "var(--color-primary)" : done ? "var(--color-primary)" : "#9ca3af",
|
||||||
|
borderBottom: `3px solid ${active ? "var(--color-primary)" : done ? "var(--color-primary)" : "#e5e7eb"}`,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
display: "inline-flex",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
width: 22,
|
||||||
|
height: 22,
|
||||||
|
borderRadius: "50%",
|
||||||
|
background: active ? "var(--color-primary)" : done ? "var(--color-primary)" : "#e5e7eb",
|
||||||
|
color: active || done ? "#fff" : "#6b7280",
|
||||||
|
fontSize: 12,
|
||||||
|
fontWeight: 700,
|
||||||
|
marginRight: 4,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{done ? "✓" : idx}
|
||||||
|
</span>
|
||||||
|
{label}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Main Component ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export function BookPage() {
|
||||||
|
const [step, setStep] = useState(1);
|
||||||
|
|
||||||
|
// Step 1 — service
|
||||||
|
const [services, setServices] = useState<Service[]>([]);
|
||||||
|
const [servicesLoading, setServicesLoading] = useState(true);
|
||||||
|
const [selectedService, setSelectedService] = useState<Service | null>(null);
|
||||||
|
|
||||||
|
// Step 2 — date & time
|
||||||
|
const [date, setDate] = useState(todayIso());
|
||||||
|
const [dateError, setDateError] = useState<string | null>(null);
|
||||||
|
const [slots, setSlots] = useState<string[]>([]);
|
||||||
|
const [slotsLoading, setSlotsLoading] = useState(false);
|
||||||
|
const [selectedSlot, setSelectedSlot] = useState<string | null>(null);
|
||||||
|
|
||||||
|
// Step 3 — contact info
|
||||||
|
const [form, setForm] = useState<BookingBody>({
|
||||||
|
serviceId: "",
|
||||||
|
startTime: "",
|
||||||
|
clientName: "",
|
||||||
|
clientEmail: "",
|
||||||
|
clientPhone: "",
|
||||||
|
petName: "",
|
||||||
|
petSpecies: "",
|
||||||
|
petBreed: "",
|
||||||
|
notes: "",
|
||||||
|
});
|
||||||
|
const [formError, setFormError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
// Pre-fill form from URL params (e.g., ?clientName=Jane&clientEmail=jane@example.com)
|
||||||
|
const [searchParams] = useSearchParams();
|
||||||
|
useEffect(() => {
|
||||||
|
const clientName = searchParams.get("clientName");
|
||||||
|
const clientEmail = searchParams.get("clientEmail");
|
||||||
|
const clientPhone = searchParams.get("clientPhone");
|
||||||
|
const petName = searchParams.get("petName");
|
||||||
|
const petSpecies = searchParams.get("petSpecies");
|
||||||
|
const petBreed = searchParams.get("petBreed");
|
||||||
|
if (clientName || clientEmail || clientPhone || petName || petSpecies || petBreed) {
|
||||||
|
setForm((f) => ({
|
||||||
|
...f,
|
||||||
|
...(clientName && { clientName }),
|
||||||
|
...(clientEmail && { clientEmail }),
|
||||||
|
...(clientPhone && { clientPhone }),
|
||||||
|
...(petName && { petName }),
|
||||||
|
...(petSpecies && { petSpecies }),
|
||||||
|
...(petBreed && { petBreed }),
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
}, [searchParams]);
|
||||||
|
|
||||||
|
// Step 4 — result
|
||||||
|
const [submitting, setSubmitting] = useState(false);
|
||||||
|
const [result, setResult] = useState<BookingResult | null>(null);
|
||||||
|
const [submitError, setSubmitError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
// Load services on mount
|
||||||
|
useEffect(() => {
|
||||||
|
fetch("/api/book/services")
|
||||||
|
.then((r) => r.json() as Promise<Service[]>)
|
||||||
|
.then(setServices)
|
||||||
|
.catch(() => setServices([]))
|
||||||
|
.finally(() => setServicesLoading(false));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Load slots when service or date changes (step 2)
|
||||||
|
useEffect(() => {
|
||||||
|
if (!selectedService || !date) return;
|
||||||
|
setSlotsLoading(true);
|
||||||
|
setSelectedSlot(null);
|
||||||
|
fetch(
|
||||||
|
`/api/book/availability?serviceId=${encodeURIComponent(selectedService.id)}&date=${encodeURIComponent(date)}`
|
||||||
|
)
|
||||||
|
.then((r) => r.json() as Promise<string[]>)
|
||||||
|
.then(setSlots)
|
||||||
|
.catch(() => setSlots([]))
|
||||||
|
.finally(() => setSlotsLoading(false));
|
||||||
|
}, [selectedService, date]);
|
||||||
|
|
||||||
|
function goToStep2(svc: Service) {
|
||||||
|
setSelectedService(svc);
|
||||||
|
setForm((f) => ({ ...f, serviceId: svc.id }));
|
||||||
|
setStep(2);
|
||||||
|
}
|
||||||
|
|
||||||
|
function goToStep3() {
|
||||||
|
if (!selectedSlot) return;
|
||||||
|
setForm((f) => ({ ...f, startTime: selectedSlot }));
|
||||||
|
setStep(3);
|
||||||
|
}
|
||||||
|
|
||||||
|
function goToStep4() {
|
||||||
|
if (!form.clientName.trim() || !form.clientEmail.trim() || !form.petName.trim() || !form.petSpecies.trim()) {
|
||||||
|
setFormError("Please fill in all required fields.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setFormError(null);
|
||||||
|
setStep(4);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function submitBooking() {
|
||||||
|
setSubmitting(true);
|
||||||
|
setSubmitError(null);
|
||||||
|
try {
|
||||||
|
const res = await fetch("/api/book/appointments", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({
|
||||||
|
serviceId: form.serviceId,
|
||||||
|
startTime: form.startTime,
|
||||||
|
clientName: form.clientName,
|
||||||
|
clientEmail: form.clientEmail,
|
||||||
|
clientPhone: form.clientPhone || undefined,
|
||||||
|
petName: form.petName,
|
||||||
|
petSpecies: form.petSpecies,
|
||||||
|
petBreed: form.petBreed || undefined,
|
||||||
|
notes: form.notes || undefined,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
if (!res.ok) {
|
||||||
|
const body = (await res.json()) as { error?: string };
|
||||||
|
throw new Error(body.error ?? `HTTP ${res.status}`);
|
||||||
|
}
|
||||||
|
const data = (await res.json()) as BookingResult;
|
||||||
|
setResult(data);
|
||||||
|
setStep(5);
|
||||||
|
} catch (e: unknown) {
|
||||||
|
setSubmitError(e instanceof Error ? e.message : "Something went wrong. Please try again.");
|
||||||
|
} finally {
|
||||||
|
setSubmitting(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Styles ──
|
||||||
|
const card: React.CSSProperties = {
|
||||||
|
background: "#fff",
|
||||||
|
border: "1px solid #e5e7eb",
|
||||||
|
borderRadius: 8,
|
||||||
|
padding: "1rem",
|
||||||
|
cursor: "pointer",
|
||||||
|
};
|
||||||
|
|
||||||
|
const selectedCard: React.CSSProperties = {
|
||||||
|
...card,
|
||||||
|
border: "2px solid var(--color-primary)",
|
||||||
|
background: "#f0faf5",
|
||||||
|
};
|
||||||
|
|
||||||
|
const input: React.CSSProperties = {
|
||||||
|
width: "100%",
|
||||||
|
padding: "0.5rem 0.75rem",
|
||||||
|
border: "1px solid #d1d5db",
|
||||||
|
borderRadius: 6,
|
||||||
|
fontSize: 14,
|
||||||
|
boxSizing: "border-box",
|
||||||
|
};
|
||||||
|
|
||||||
|
const label: React.CSSProperties = {
|
||||||
|
display: "block",
|
||||||
|
fontSize: 13,
|
||||||
|
fontWeight: 600,
|
||||||
|
color: "#374151",
|
||||||
|
marginBottom: 4,
|
||||||
|
};
|
||||||
|
|
||||||
|
const btn: React.CSSProperties = {
|
||||||
|
padding: "0.6rem 1.25rem",
|
||||||
|
borderRadius: 6,
|
||||||
|
border: "none",
|
||||||
|
cursor: "pointer",
|
||||||
|
fontSize: 14,
|
||||||
|
fontWeight: 600,
|
||||||
|
};
|
||||||
|
|
||||||
|
const primaryBtn: React.CSSProperties = {
|
||||||
|
...btn,
|
||||||
|
background: "var(--color-primary)",
|
||||||
|
color: "#fff",
|
||||||
|
};
|
||||||
|
|
||||||
|
const secondaryBtn: React.CSSProperties = {
|
||||||
|
...btn,
|
||||||
|
background: "#f3f4f6",
|
||||||
|
color: "#374151",
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ maxWidth: 640, margin: "0 auto", padding: "1rem" }}>
|
||||||
|
<div style={{ marginBottom: "1.5rem" }}>
|
||||||
|
<h1 style={{ fontSize: 24, fontWeight: 700, color: "#1f2937", margin: 0 }}>
|
||||||
|
Book an Appointment
|
||||||
|
</h1>
|
||||||
|
<p style={{ fontSize: 14, color: "#6b7280", marginTop: 4 }}>
|
||||||
|
Schedule a grooming appointment for your pet in minutes.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{step < 5 && <StepIndicator step={step} />}
|
||||||
|
|
||||||
|
{/* ── Step 1: Select Service ── */}
|
||||||
|
{step === 1 && (
|
||||||
|
<div>
|
||||||
|
<h2 style={{ fontSize: 16, fontWeight: 600, marginBottom: "0.75rem" }}>
|
||||||
|
Choose a service
|
||||||
|
</h2>
|
||||||
|
{servicesLoading && <p style={{ color: "#6b7280" }}>Loading services…</p>}
|
||||||
|
{!servicesLoading && services.length === 0 && (
|
||||||
|
<p style={{ color: "#ef4444" }}>No services available. Please contact us to book.</p>
|
||||||
|
)}
|
||||||
|
<div style={{ display: "flex", flexDirection: "column", gap: "0.75rem" }}>
|
||||||
|
{services.map((svc) => (
|
||||||
|
<div
|
||||||
|
key={svc.id}
|
||||||
|
style={selectedService?.id === svc.id ? selectedCard : card}
|
||||||
|
onClick={() => goToStep2(svc)}
|
||||||
|
role="button"
|
||||||
|
tabIndex={0}
|
||||||
|
onKeyDown={(e) => e.key === "Enter" && goToStep2(svc)}
|
||||||
|
>
|
||||||
|
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "flex-start" }}>
|
||||||
|
<div>
|
||||||
|
<div style={{ fontWeight: 600, fontSize: 15, color: "#1f2937" }}>{svc.name}</div>
|
||||||
|
{svc.description && (
|
||||||
|
<div style={{ fontSize: 13, color: "#6b7280", marginTop: 2 }}>{svc.description}</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div style={{ textAlign: "right", flexShrink: 0, marginLeft: "1rem" }}>
|
||||||
|
<div style={{ fontWeight: 700, color: "var(--color-primary)", fontSize: 15 }}>
|
||||||
|
{fmtPrice(svc.basePriceCents)}
|
||||||
|
</div>
|
||||||
|
<div style={{ fontSize: 12, color: "#9ca3af" }}>{fmtDuration(svc.durationMinutes)}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* ── Step 2: Date & Time ── */}
|
||||||
|
{step === 2 && selectedService && (
|
||||||
|
<div>
|
||||||
|
<h2 style={{ fontSize: 16, fontWeight: 600, marginBottom: 4 }}>Choose a date and time</h2>
|
||||||
|
<p style={{ fontSize: 13, color: "#6b7280", marginBottom: "1rem" }}>
|
||||||
|
{selectedService.name} — {fmtDuration(selectedService.durationMinutes)} — {fmtPrice(selectedService.basePriceCents)}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div style={{ marginBottom: "1rem" }}>
|
||||||
|
<label style={label}>Date</label>
|
||||||
|
<input
|
||||||
|
type="date"
|
||||||
|
value={date}
|
||||||
|
min={todayIso()}
|
||||||
|
style={{ ...input, width: "auto" }}
|
||||||
|
onChange={(e) => {
|
||||||
|
const val = e.target.value;
|
||||||
|
// HTML5 date input enforces yyyy-MM-dd; empty value means invalid format
|
||||||
|
if (!val) {
|
||||||
|
setDateError("Please enter a valid date (YYYY-MM-DD).");
|
||||||
|
setDate("");
|
||||||
|
} else {
|
||||||
|
setDateError(null);
|
||||||
|
setDate(val);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
{dateError && (
|
||||||
|
<p style={{ color: "#dc2626", fontSize: 12, marginTop: 4 }}>{dateError}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ marginBottom: "1.25rem" }}>
|
||||||
|
<label style={label}>Available times on {fmtDateLong(date)}</label>
|
||||||
|
{slotsLoading && <p style={{ color: "#6b7280", fontSize: 13 }}>Checking availability…</p>}
|
||||||
|
{!slotsLoading && slots.length === 0 && (
|
||||||
|
<p style={{ color: "#6b7280", fontSize: 13 }}>
|
||||||
|
No available slots on this date. Please try another day.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{!slotsLoading && slots.length > 0 && (
|
||||||
|
<div style={{ display: "flex", flexWrap: "wrap", gap: "0.5rem", marginTop: "0.5rem" }}>
|
||||||
|
{slots.map((slot) => (
|
||||||
|
<button
|
||||||
|
key={slot}
|
||||||
|
onClick={() => setSelectedSlot(slot)}
|
||||||
|
style={{
|
||||||
|
padding: "0.4rem 0.85rem",
|
||||||
|
borderRadius: 6,
|
||||||
|
border: `2px solid ${selectedSlot === slot ? "var(--color-primary)" : "#d1d5db"}`,
|
||||||
|
background: selectedSlot === slot ? "var(--color-primary)" : "#fff",
|
||||||
|
color: selectedSlot === slot ? "#fff" : "#374151",
|
||||||
|
fontSize: 13,
|
||||||
|
fontWeight: 500,
|
||||||
|
cursor: "pointer",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{fmtTime(slot)}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ display: "flex", gap: "0.75rem" }}>
|
||||||
|
<button style={secondaryBtn} onClick={() => setStep(1)}>Back</button>
|
||||||
|
<button
|
||||||
|
style={{ ...primaryBtn, opacity: selectedSlot ? 1 : 0.5 }}
|
||||||
|
disabled={!selectedSlot}
|
||||||
|
onClick={goToStep3}
|
||||||
|
>
|
||||||
|
Continue
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* ── Step 3: Contact Info ── */}
|
||||||
|
{step === 3 && (
|
||||||
|
<div>
|
||||||
|
<h2 style={{ fontSize: 16, fontWeight: 600, marginBottom: "1rem" }}>Your information</h2>
|
||||||
|
|
||||||
|
<div style={{ display: "flex", flexDirection: "column", gap: "1rem" }}>
|
||||||
|
<fieldset style={{ border: "1px solid #e5e7eb", borderRadius: 8, padding: "0.75rem 1rem" }}>
|
||||||
|
<legend style={{ fontSize: 13, fontWeight: 600, color: "#374151", padding: "0 0.25rem" }}>
|
||||||
|
Contact details
|
||||||
|
</legend>
|
||||||
|
<div style={{ display: "flex", flexDirection: "column", gap: "0.75rem" }}>
|
||||||
|
<div>
|
||||||
|
<label style={label}>Full name *</label>
|
||||||
|
<input
|
||||||
|
style={input}
|
||||||
|
value={form.clientName}
|
||||||
|
onChange={(e) => setForm((f) => ({ ...f, clientName: e.target.value }))}
|
||||||
|
placeholder="Jane Smith"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label style={label}>Email *</label>
|
||||||
|
<input
|
||||||
|
type="email"
|
||||||
|
style={input}
|
||||||
|
value={form.clientEmail}
|
||||||
|
onChange={(e) => setForm((f) => ({ ...f, clientEmail: e.target.value }))}
|
||||||
|
placeholder="jane@example.com"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label style={label}>Phone</label>
|
||||||
|
<input
|
||||||
|
type="tel"
|
||||||
|
style={input}
|
||||||
|
value={form.clientPhone}
|
||||||
|
onChange={(e) => setForm((f) => ({ ...f, clientPhone: e.target.value }))}
|
||||||
|
placeholder="(555) 000-1234"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</fieldset>
|
||||||
|
|
||||||
|
<fieldset style={{ border: "1px solid #e5e7eb", borderRadius: 8, padding: "0.75rem 1rem" }}>
|
||||||
|
<legend style={{ fontSize: 13, fontWeight: 600, color: "#374151", padding: "0 0.25rem" }}>
|
||||||
|
Pet details
|
||||||
|
</legend>
|
||||||
|
<div style={{ display: "flex", flexDirection: "column", gap: "0.75rem" }}>
|
||||||
|
<div>
|
||||||
|
<label style={label}>Pet name *</label>
|
||||||
|
<input
|
||||||
|
style={input}
|
||||||
|
value={form.petName}
|
||||||
|
onChange={(e) => setForm((f) => ({ ...f, petName: e.target.value }))}
|
||||||
|
placeholder="Buddy"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label style={label}>Species *</label>
|
||||||
|
<select
|
||||||
|
style={input}
|
||||||
|
value={form.petSpecies}
|
||||||
|
onChange={(e) => setForm((f) => ({ ...f, petSpecies: e.target.value }))}
|
||||||
|
>
|
||||||
|
<option value="">Select species…</option>
|
||||||
|
<option value="dog">Dog</option>
|
||||||
|
<option value="cat">Cat</option>
|
||||||
|
<option value="rabbit">Rabbit</option>
|
||||||
|
<option value="other">Other</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label style={label}>Breed</label>
|
||||||
|
<input
|
||||||
|
style={input}
|
||||||
|
value={form.petBreed}
|
||||||
|
onChange={(e) => setForm((f) => ({ ...f, petBreed: e.target.value }))}
|
||||||
|
placeholder="Golden Retriever"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label style={label}>Notes for groomer</label>
|
||||||
|
<textarea
|
||||||
|
style={{ ...input, minHeight: 64, resize: "vertical", fontFamily: "inherit" }}
|
||||||
|
value={form.notes}
|
||||||
|
onChange={(e) => setForm((f) => ({ ...f, notes: e.target.value }))}
|
||||||
|
placeholder="Any special requests or things we should know…"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</fieldset>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{formError && (
|
||||||
|
<p style={{ color: "#ef4444", fontSize: 13, marginTop: "0.75rem" }}>{formError}</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div style={{ display: "flex", gap: "0.75rem", marginTop: "1.25rem" }}>
|
||||||
|
<button style={secondaryBtn} onClick={() => setStep(2)}>Back</button>
|
||||||
|
<button style={primaryBtn} onClick={goToStep4}>Review booking</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* ── Step 4: Confirm ── */}
|
||||||
|
{step === 4 && selectedService && selectedSlot && (
|
||||||
|
<div>
|
||||||
|
<h2 style={{ fontSize: 16, fontWeight: 600, marginBottom: "1rem" }}>Confirm your booking</h2>
|
||||||
|
|
||||||
|
<div style={{ ...card, cursor: "default", marginBottom: "1.25rem" }}>
|
||||||
|
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: "0.75rem", fontSize: 14 }}>
|
||||||
|
<div>
|
||||||
|
<div style={{ color: "#9ca3af", fontSize: 12, fontWeight: 600, textTransform: "uppercase" }}>Service</div>
|
||||||
|
<div style={{ fontWeight: 600 }}>{selectedService.name}</div>
|
||||||
|
<div style={{ color: "#6b7280" }}>{fmtPrice(selectedService.basePriceCents)} · {fmtDuration(selectedService.durationMinutes)}</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div style={{ color: "#9ca3af", fontSize: 12, fontWeight: 600, textTransform: "uppercase" }}>Date & Time</div>
|
||||||
|
<div style={{ fontWeight: 600 }}>{fmtDateLong(date)}</div>
|
||||||
|
<div style={{ color: "#6b7280" }}>{fmtTime(selectedSlot)}</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div style={{ color: "#9ca3af", fontSize: 12, fontWeight: 600, textTransform: "uppercase" }}>Client</div>
|
||||||
|
<div style={{ fontWeight: 600 }}>{form.clientName}</div>
|
||||||
|
<div style={{ color: "#6b7280" }}>{form.clientEmail}</div>
|
||||||
|
{form.clientPhone && <div style={{ color: "#6b7280" }}>{form.clientPhone}</div>}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div style={{ color: "#9ca3af", fontSize: 12, fontWeight: 600, textTransform: "uppercase" }}>Pet</div>
|
||||||
|
<div style={{ fontWeight: 600 }}>{form.petName}</div>
|
||||||
|
<div style={{ color: "#6b7280", textTransform: "capitalize" }}>{form.petSpecies}{form.petBreed ? ` · ${form.petBreed}` : ""}</div>
|
||||||
|
</div>
|
||||||
|
{form.notes && (
|
||||||
|
<div style={{ gridColumn: "1 / -1" }}>
|
||||||
|
<div style={{ color: "#9ca3af", fontSize: 12, fontWeight: 600, textTransform: "uppercase" }}>Notes</div>
|
||||||
|
<div style={{ color: "#374151" }}>{form.notes}</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{submitError && (
|
||||||
|
<p style={{ color: "#ef4444", fontSize: 13, marginBottom: "0.75rem" }}>{submitError}</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div style={{ display: "flex", gap: "0.75rem" }}>
|
||||||
|
<button style={secondaryBtn} onClick={() => setStep(3)} disabled={submitting}>Back</button>
|
||||||
|
<button
|
||||||
|
style={{ ...primaryBtn, opacity: submitting ? 0.7 : 1 }}
|
||||||
|
onClick={submitBooking}
|
||||||
|
disabled={submitting}
|
||||||
|
>
|
||||||
|
{submitting ? "Booking…" : "Confirm booking"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* ── Step 5: Success ── */}
|
||||||
|
{step === 5 && result && (
|
||||||
|
<div style={{ textAlign: "center", padding: "2rem 1rem" }}>
|
||||||
|
<div style={{ fontSize: 48, marginBottom: "0.75rem" }}>🐾</div>
|
||||||
|
<h2 style={{ fontSize: 20, fontWeight: 700, color: "#1f2937", marginBottom: "0.5rem" }}>
|
||||||
|
Booking confirmed!
|
||||||
|
</h2>
|
||||||
|
<p style={{ color: "#6b7280", fontSize: 14, marginBottom: "1.5rem" }}>
|
||||||
|
We've booked {result.pet.name} in for{" "}
|
||||||
|
{selectedService?.name} on {fmtDateLong(date)} at{" "}
|
||||||
|
{fmtTime(result.appointment.startTime)}.
|
||||||
|
</p>
|
||||||
|
<div style={{ ...card, cursor: "default", textAlign: "left", marginBottom: "1.5rem" }}>
|
||||||
|
<p style={{ margin: 0, fontSize: 14, color: "#374151" }}>
|
||||||
|
A confirmation will be sent to <strong>{result.client.email}</strong>.
|
||||||
|
If you need to reschedule or cancel, please contact us.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
style={primaryBtn}
|
||||||
|
onClick={() => {
|
||||||
|
setStep(1);
|
||||||
|
setSelectedService(null);
|
||||||
|
setSelectedSlot(null);
|
||||||
|
setResult(null);
|
||||||
|
setForm({
|
||||||
|
serviceId: "", startTime: "", clientName: "", clientEmail: "",
|
||||||
|
clientPhone: "", petName: "", petSpecies: "", petBreed: "", notes: "",
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Book another appointment
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||