chore: promote dev to uat (#3)

chore: promote dev to uat
This commit was merged in pull request #3.
This commit is contained in:
the-dogfather-cto[bot]
2026-05-11 13:19:33 +00:00
committed by GitHub
133 changed files with 22726 additions and 0 deletions
+7
View File
@@ -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
+103
View File
@@ -0,0 +1,103 @@
name: CI
on:
push:
branches: [main, dev]
pull_request:
branches: [main, dev]
workflow_dispatch:
inputs:
ref:
description: "Branch or ref to run CI against"
required: false
default: "main"
jobs:
lint-typecheck:
name: Lint & Typecheck
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
with:
version: '9.15.4'
- uses: actions/setup-node@v4
with:
node-version: 20
cache: pnpm
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Typecheck
run: pnpm typecheck
- name: Lint
run: pnpm lint
test:
name: Test
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
with:
version: '9.15.4'
- uses: actions/setup-node@v4
with:
node-version: 20
cache: pnpm
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Run tests
run: pnpm test
docker:
name: Build & Push Docker Image
runs-on: ubuntu-latest
needs: [lint-typecheck, test]
permissions:
contents: read
packages: write
id-token: write
steps:
- uses: actions/checkout@v4
- name: Generate image tag
id: version
run: |
if [ "${{ github.event_name }}" = "pull_request" ]; then
TAG="pr-${{ github.event.pull_request.number }}-${GITHUB_SHA::7}"
else
TAG="$(date -u +%Y.%m.%d)-${GITHUB_SHA::7}"
fi
echo "tag=$TAG" >> "$GITHUB_OUTPUT"
echo "Image tag: $TAG"
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Log in to GitHub Container Registry
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Build and push Web image
uses: docker/build-push-action@v6
with:
context: .
file: Dockerfile
push: true
tags: |
ghcr.io/groombook/web:${{ steps.version.outputs.tag }}
${{ github.ref == 'refs/heads/main' && 'ghcr.io/groombook/web:latest' || '' }}
cache-from: type=gha
cache-to: type=gha,mode=max
+8
View File
@@ -0,0 +1,8 @@
node_modules/
.env
.env.production
.env.local
dist/
playwright-report/
test-results/
*.log
+21
View File
@@ -0,0 +1,21 @@
FROM node:20-alpine AS base
RUN corepack enable && corepack prepare pnpm@9.15.4 --activate
WORKDIR /app
FROM base AS deps
COPY package.json pnpm-lock.yaml ./
COPY packages/types/package.json packages/types/
RUN pnpm install --frozen-lockfile
FROM deps AS builder
COPY packages/types/ packages/types/
COPY src/ src/
COPY index.html vite.config.ts tsconfig.json ./
RUN pnpm build
FROM nginx:alpine AS runner
COPY nginx.conf /etc/nginx/conf.d/default.conf
COPY --from=builder /app/dist /usr/share/nginx/html
EXPOSE 80
HEALTHCHECK --interval=30s --timeout=5s --start-period=5s --retries=3 \
CMD curl -f http://localhost:80/ || exit 1
+164
View File
@@ -0,0 +1,164 @@
# UAT Playbook — GroomBook Web
## 1. Overview
GroomBook Web is the React 19 PWA frontend for the GroomBook pet grooming management platform. Built with Vite, it provides the UI for client/pet management, appointment scheduling, invoicing, staff management, and the customer portal. Extracted from the `groombook/app` monorepo.
## 2. Environments
| Environment | URL | Purpose |
|-------------|-----|---------|
| Dev | `https://dev.groombook.dev` | Development environment for daily development |
| UAT | `https://uat.groombook.dev` | User Acceptance Testing environment |
| Prod | `https://demo.groombook.app` | Production/demo environment |
## 3. Pre-conditions
- UAT environment is accessible and running
- Test accounts are seeded with appropriate personas (manager, staff, client)
- OIDC authentication is configured and functional
- GroomBook API service is running and healthy
- Required test data exists (clients, pets, appointments, services, staff)
## 4. Test Cases
### 4.1 Authentication UI
| # | Scenario | Steps | Expected |
|---|----------|-------|----------|
| TC-WEB-4.1.1 | Login page loads | Navigate to UAT URL | Login form is displayed with OIDC provider button(s) |
| TC-WEB-4.1.2 | OIDC redirect | Click OIDC login button | Redirected to OIDC provider, then back to app with session established |
| TC-WEB-4.1.3 | Logout | Click logout button | Session cleared, redirected to login page |
| TC-WEB-4.1.4 | Session indicator | After successful login | User info/initials visible in UI indicating active session |
### 4.2 Dashboard
| # | Scenario | Steps | Expected |
|---|----------|-------|----------|
| TC-WEB-4.2.1 | Dashboard loads after login | Complete authentication | Dashboard page loads without errors |
| TC-WEB-4.2.2 | Key metrics visible | View dashboard | Revenue, appointments, clients, and other key metrics displayed |
| TC-WEB-4.2.3 | No blank state | On fresh login | Dashboard shows meaningful data, not empty/blank state |
### 4.3 Client Management UI
| # | Scenario | Steps | Expected |
|---|----------|-------|----------|
| TC-WEB-4.3.1 | Client list loads | Navigate to Clients section | List of clients is displayed |
| TC-WEB-4.3.2 | Create client | Click "New Client", fill form, submit | Client created successfully, appears in list |
| TC-WEB-4.3.3 | Edit client | Click on client, modify details, save | Client updated successfully |
| TC-WEB-4.3.4 | Search clients | Enter search term in search box | List filters to matching clients |
| TC-WEB-4.3.5 | Archive client | Click archive on client record | Client marked as archived, removed from active list |
### 4.4 Pet Management UI
| # | Scenario | Steps | Expected |
|---|----------|-------|----------|
| TC-WEB-4.4.1 | Pet profiles visible | Open client details | All pets for client displayed with basic info |
| TC-WEB-4.4.2 | Add pet | Click "Add Pet", fill form, submit | Pet created and linked to client |
| TC-WEB-4.4.3 | Edit pet details | Click on pet, modify details, save | Pet updated successfully |
| TC-WEB-4.4.4 | Grooming history view | View pet profile | Past appointments/grooming sessions displayed |
### 4.5 Appointment Scheduling UI
| # | Scenario | Steps | Expected |
|---|----------|-------|----------|
| TC-WEB-4.5.1 | Calendar view loads | Navigate to Appointments | Calendar view displays appointments |
| TC-WEB-4.5.2 | Create booking | Click "New Appointment", fill details, submit | Appointment created and appears on calendar |
| TC-WEB-4.5.3 | Modify appointment | Click on appointment, change details, save | Appointment updated successfully |
| TC-WEB-4.5.4 | Cancel appointment | Click cancel on appointment | Appointment marked as cancelled |
| TC-WEB-4.5.5 | Appointment groups | View grouped appointments | Related appointments display as group |
### 4.6 Service Management UI
| # | Scenario | Steps | Expected |
|---|----------|-------|----------|
| TC-WEB-4.6.1 | Service catalog loads | Navigate to Services | List of available services displayed |
| TC-WEB-4.6.2 | Create service | Click "New Service", fill form, submit | Service created successfully |
| TC-WEB-4.6.3 | Edit service | Click on service, modify details, save | Service updated successfully |
### 4.7 Staff Management UI
| # | Scenario | Steps | Expected |
|---|----------|-------|----------|
| TC-WEB-4.7.1 | Staff list loads | Navigate to Staff | List of staff members displayed |
| TC-WEB-4.7.2 | Role display | View staff member | Staff role/permissions clearly visible |
### 4.8 Invoicing & Payments UI
| # | Scenario | Steps | Expected |
|---|----------|-------|----------|
| TC-WEB-4.8.1 | Invoice list loads | Navigate to Invoices | List of invoices displayed with status |
| TC-WEB-4.8.2 | Payment flow | Click "Pay" on unpaid invoice, complete payment | Payment processed, invoice marked as paid |
| TC-WEB-4.8.3 | Receipts view | View paid invoice | Receipt/payment details displayed |
### 4.9 Customer Portal UI
| # | Scenario | Steps | Expected |
|---|----------|-------|----------|
| TC-WEB-4.9.1 | Client-facing view | Log in as client persona | Customer portal UI displayed |
| TC-WEB-4.9.2 | Appointment list | View client portal appointments | List of client's appointments visible |
| TC-WEB-4.9.3 | Confirm appointment | Click confirm on pending appointment | Appointment status updated to confirmed |
| TC-WEB-4.9.4 | Cancel appointment | Click cancel on appointment | Appointment marked as cancelled |
### 4.10 Reports UI
| # | Scenario | Steps | Expected |
|---|----------|-------|----------|
| TC-WEB-4.10.1 | Revenue charts | Navigate to Reports | Revenue charts display with data |
| TC-WEB-4.10.2 | Utilization graphs | View reports | Staff/resource utilization graphs visible |
### 4.11 Settings UI
| # | Scenario | Steps | Expected |
|---|----------|-------|----------|
| TC-WEB-4.11.1 | Configuration page | Navigate to Settings | Settings page loads without errors |
| TC-WEB-4.11.2 | Form interactions | Modify settings, save | Settings saved successfully, changes reflected |
### 4.12 Navigation
| # | Scenario | Steps | Expected |
|---|----------|-------|----------|
| TC-WEB-4.12.1 | Sidebar/menu links | Click navigation items | Each section loads correctly |
| TC-WEB-4.12.2 | All sections reachable | Navigate through all menu items | All sections accessible, no 404 errors |
| TC-WEB-4.12.3 | No broken links | Test all navigation paths | All links work, no broken routes |
### 4.13 Mobile / PWA
| # | Scenario | Steps | Expected |
|---|----------|-------|----------|
| TC-WEB-4.13.1 | Responsive at 390x844 | Resize viewport to mobile dimensions | Layout adapts correctly, no horizontal scroll |
| TC-WEB-4.13.2 | PWA install prompt | Load app on supported browser | Install prompt appears when criteria met |
| TC-WEB-4.13.3 | Touch interactions | Use touch gestures on mobile | All interactions work with touch input |
### 4.14 Error & Empty States
| # | Scenario | Steps | Expected |
|---|----------|-------|----------|
| TC-WEB-4.14.1 | Form validation | Submit form with invalid data | Appropriate validation errors displayed |
| TC-WEB-4.14.2 | Missing data | Navigate to section with no data | Empty state message displayed, not blank page |
| TC-WEB-4.14.3 | Error boundaries | Trigger error condition | Friendly error message displayed, app doesn't crash |
## 5. Pass/Fail Criteria
**Pass:**
- All test cases execute without errors
- Expected results match actual results for all scenarios
- No visual regressions compared to baseline
- No console errors or warnings in browser DevTools
**Fail:**
- Any unexpected result with severity
- Steps to reproduce provided
- Screenshot or screen recording of failure
- Error details from browser console or network tab
## 6. Update Policy
**Any PR that changes user-facing behaviour MUST update this file.**
When modifying the GroomBook Web application in ways that affect the user interface or user experience:
1. Review all relevant test cases in this playbook
2. Add new test cases for new features or flows
3. Modify existing test cases if behaviour changes
4. Remove test cases for deprecated features
5. Reference the updated section(s) in the PR description (e.g., "Updated UAT_PLAYBOOK.md §4.5 — new appointment group feature")
+15
View File
@@ -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"
}
+34
View File
@@ -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"] },
},
],
});
+50
View File
@@ -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);
});
});
+94
View File
@@ -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);
});
});
+121
View File
@@ -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);
});
});
+111
View File
@@ -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";
+61
View File
@@ -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");
});
});
+89
View File
@@ -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);
});
});
+18
View File
@@ -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: "^_" }],
},
}
);
+14
View File
@@ -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>
+37
View File
@@ -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;
}
}
+47
View File
@@ -0,0 +1,47 @@
{
"name": "@groombook/web",
"version": "0.0.1",
"private": true,
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc && vite build",
"preview": "vite preview",
"lint": "eslint src --ext .ts,.tsx",
"typecheck": "tsc --noEmit",
"test": "vitest run",
"test:e2e": "playwright test -c e2e/playwright.config.ts"
},
"dependencies": {
"@groombook/types": "workspace:*",
"@stripe/react-stripe-js": "^6.1.0",
"@stripe/stripe-js": "^9.1.0",
"@tailwindcss/vite": "^4.2.2",
"better-auth": "^1.5.6",
"lucide-react": "^0.577.0",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"react-router-dom": "^7.1.2",
"recharts": "^3.8.0",
"tailwindcss": "^4.2.2"
},
"devDependencies": {
"@playwright/test": "^1.50.1",
"@testing-library/jest-dom": "^6.9.1",
"@testing-library/react": "^16.3.2",
"@testing-library/user-event": "^14.6.1",
"@types/node": "^25.6.0",
"@types/react": "^19.0.6",
"@types/react-dom": "^19.0.3",
"@vitejs/plugin-react": "^4.3.4",
"@vitest/coverage-v8": "^3.2.4",
"eslint": "^9.18.0",
"jsdom": "^26.1.0",
"typescript": "^5.7.3",
"typescript-eslint": "^8.20.0",
"vite": "^6.0.7",
"vite-plugin-pwa": "^0.21.1",
"vitest": "^3.0.4"
},
"license": "AGPL-3.0-only"
}
+22
View File
@@ -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"
}
+210
View File
@@ -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;
}
+13
View File
@@ -0,0 +1,13 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"strict": true,
"noUncheckedIndexedAccess": true,
"skipLibCheck": true,
"outDir": "./dist",
"rootDir": "./src"
},
"include": ["src"]
}
+6968
View File
File diff suppressed because it is too large Load Diff
+2
View File
@@ -0,0 +1,2 @@
packages:
- "packages/*"
Binary file not shown.

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>
Binary file not shown.

After

Width:  |  Height:  |  Size: 227 KiB

Binary file not shown.

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>
Binary file not shown.

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>
Binary file not shown.

After

Width:  |  Height:  |  Size: 260 KiB

Binary file not shown.

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>
Binary file not shown.

After

Width:  |  Height:  |  Size: 262 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 347 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 250 KiB

Binary file not shown.

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>
Binary file not shown.

After

Width:  |  Height:  |  Size: 246 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 172 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 253 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 220 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 274 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 193 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 292 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 289 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 276 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 307 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 179 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 234 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 256 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 193 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 186 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 275 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 233 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 211 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 252 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 269 KiB

Binary file not shown.

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>
Binary file not shown.

After

Width:  |  Height:  |  Size: 235 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 194 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 282 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 226 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 182 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 195 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 283 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 265 KiB

Binary file not shown.

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>
Binary file not shown.

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>
Binary file not shown.

After

Width:  |  Height:  |  Size: 242 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 288 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 230 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 279 KiB

+4
View File
@@ -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

Binary file not shown.

After

Width:  |  Height:  |  Size: 64 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 547 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 KiB

+415
View File
@@ -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>
);
}
+68
View File
@@ -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>
);
}
+212
View File
@@ -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();
});
});
+382
View File
@@ -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();
});
});
});
+106
View File
@@ -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);
});
});
});
+141
View File
@@ -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("");
});
});
+135
View File
@@ -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();
});
});
+100
View File
@@ -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" });
});
});
+311
View File
@@ -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);
});
});
+315
View File
@@ -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 });
});
});
+203
View File
@@ -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&apos;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>
);
}
+41
View File
@@ -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>
);
}
+287
View File
@@ -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>
);
}
+86
View File
@@ -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>
);
}
+212
View File
@@ -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>
);
}
+84
View File
@@ -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;
}
+7
View File
@@ -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;
+28
View File
@@ -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 });
};
}
+19
View File
@@ -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>
);

Some files were not shown because too many files have changed in this diff Show More