Compare commits

..

17 Commits

Author SHA1 Message Date
Barcode Betty 344a32e3e4 feat(GRO-1792): add recovery paths to booking error and cancellation pages
CI / Test (pull_request) Failing after 18s
CI / Lint & Typecheck (pull_request) Successful in 24s
CI / Build & Push Docker Image (pull_request) Has been skipped
- Add "Start a new booking" button to BookingError linking to /admin/book
- Add "Book again" button to BookingCancelled linking to /admin/book
- Add business contact info section to BookingError (from BUSINESS_CONTACT_INFO constant)
- Replace hardcoded colors with CSS variables (--color-error, --color-cancelled, etc.)
- Add page-level string constants to eliminate hardcoded strings
- Add unit tests for both pages (9 tests passing)

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-05-26 12:00:55 +00:00
Scrubs McBarkley b630b40c92 fix(GRO-1757): add SSO + OOBE test cases to groombook-web UAT_PLAYBOOK (#18)
CI / Test (push) Successful in 22s
CI / Lint & Typecheck (push) Successful in 28s
CI / Build & Push Docker Image (push) Successful in 9s
CI / Test (pull_request) Successful in 20s
CI / Lint & Typecheck (pull_request) Successful in 29s
CI / Build & Push Docker Image (pull_request) Successful in 17s
2026-05-25 23:39:46 +00:00
Flea Flicker db892409ef fix(GRO-1633): add buildx network=host and provenance:false to web CI (#17)
CI / Test (push) Successful in 17s
CI / Lint & Typecheck (push) Successful in 19s
CI / Build & Push Docker Image (push) Successful in 9s
2026-05-24 22:08:59 +00:00
The Dogfather c83214cf42 Merge pull request 'fix(GRO-1414): update pet size value from x-large to xlarge' (#12) from fix/gro-1414-pet-size-enum into dev
CI / Test (push) Successful in 13s
CI / Lint & Typecheck (push) Successful in 21s
CI / Build & Push Docker Image (push) Failing after 2m20s
fix(GRO-1414): update pet size value from x-large to xlarge (#12)
2026-05-23 18:31:05 +00:00
The Dogfather 80101fc37c Merge pull request 'fix(GRO-1592): fallback auth baseURL to window.location.origin' (#15) from fix/gro-1592-sso-session-cookie into dev
CI / Test (push) Successful in 14s
CI / Lint & Typecheck (push) Successful in 16s
CI / Build & Push Docker Image (push) Failing after 39s
2026-05-23 14:13:01 +00:00
Flea Flicker 8ee58471b2 docs(UAT_PLAYBOOK): add TC-AUTH-5.3.4 — SSO cookie after Authentik callback
CI / Test (pull_request) Successful in 14s
CI / Lint & Typecheck (pull_request) Successful in 16s
CI / Build & Push Docker Image (pull_request) Failing after 38s
Documents the acceptance criteria for GRO-1592: after completing
Authentik SSO login without VITE_API_URL set, the
__Secure-better-auth.session_token cookie must be present in the
browser and sent with subsequent /api/* calls.

Updated: UAT_PLAYBOOK.md §5.3

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-05-23 14:02:16 +00:00
Flea Flicker 35d31a984d fix(GRO-1592): fallback auth baseURL to window.location.origin
CI / Test (pull_request) Successful in 18s
CI / Lint & Typecheck (pull_request) Successful in 19s
CI / Build & Push Docker Image (pull_request) Failing after 38s
When VITE_API_URL is not set (e.g. in Docker/container deployments
where the env var was never injected), fallback to
window.location.origin so the auth client uses relative URLs and
cookies are sent to the correct origin.

Previously the fallback was empty string "", which caused the auth
client to default to http://localhost:3000 — the nginx sub_filter
workaround only handles strings baked into the JS bundle at build
time, not runtime-constructed URLs.

Fixes: SSO session cookie not set in browser after Authentik callback

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-05-23 13:57:47 +00:00
The Dogfather f62c0b112d Merge pull request 'feat(GRO-1173): admin UI buffer rules, service default buffer, pet size/coat' (#13) from flea-flicker/pet-profile-editor into dev
CI / Test (push) Successful in 15s
CI / Lint & Typecheck (push) Successful in 17s
CI / Build & Push Docker Image (push) Successful in 10s
CI / Test (pull_request) Successful in 15s
CI / Lint & Typecheck (pull_request) Successful in 17s
CI / Build & Push Docker Image (pull_request) Successful in 10s
feat(GRO-1173): admin UI buffer rules, service default buffer, pet size/coat (#13)

Merged-By: The Dogfather (CTO)
Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-05-21 19:46:01 +00:00
Flea Flicker f1bb7c4fa6 fix(GRO-1414): update pet size value from x-large to xlarge
CI / Test (pull_request) Successful in 14s
CI / Lint & Typecheck (pull_request) Successful in 18s
CI / Build & Push Docker Image (pull_request) Successful in 35s
Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-05-21 06:58:56 +00:00
The Dogfather 56b11befe9 Merge pull request 'feat(GRO-1174): add pet size/coat dropdowns to booking wizard' (#8) from flea-flicker/pet-profile-editor into dev
CI / Test (push) Successful in 14s
CI / Lint & Typecheck (push) Successful in 17s
CI / Build & Push Docker Image (push) Successful in 37s
test merge
2026-05-21 00:43:10 +00:00
Scrubs McBarkley f70dd96c65 Merge pull request 'feat: extract groombook/web from monorepo (GRO-903)' (#1) from dev into main
CI / Test (push) Successful in 14s
CI / Lint & Typecheck (push) Successful in 16s
CI / Build & Push Docker Image (push) Successful in 14s
feat: extract groombook/web from monorepo (GRO-903)

Bootstrap exception: dev → main

QA: Lint Roller (#2753)
CTO: The Dogfather (#2764)
CI: Lint & Typecheck ✓, Tests ✓, Docker Build ✓
UAT_PLAYBOOK.md: present
2026-05-20 15:26:27 +00:00
Chris Farhood 42f3e3211a fix(GRO-903): resolve CI/CD blockers on groombook/web PR #1
CI / Test (push) Successful in 15s
CI / Test (pull_request) Successful in 15s
CI / Lint & Typecheck (push) Successful in 17s
CI / Lint & Typecheck (pull_request) Successful in 17s
CI / Build & Push Docker Image (push) Successful in 10s
CI / Build & Push Docker Image (pull_request) Successful in 11s
- Move CI workflow from .github/workflows/ to .gitea/workflows/
- Add uat branch to CI triggers (push and pull_request)
- Fix Dockerfile HEALTHCHECK to use wget instead of curl

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-20 15:01:46 +00:00
Chris Farhood 465db89ab4 fix(GRO-1361): remove unused X import and delete corrupted demo-pet images
CI / Test (push) Successful in 16s
CI / Test (pull_request) Successful in 15s
CI / Lint & Typecheck (push) Successful in 19s
CI / Lint & Typecheck (pull_request) Successful in 19s
CI / Build & Push Docker Image (pull_request) Successful in 41s
CI / Build & Push Docker Image (push) Successful in 2m55s
- Remove unused 'X' import from lucide-react in PetProfiles.tsx
- Delete 10 corrupted demo-pet PNG files that contain Alibaba AccessDenied XML

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-20 14:09:20 +00:00
The Dogfather ee7fc2e9bf Merge pull request 'chore: add Renovate config (GRO-1081)' (#4) from add-renovate-config into dev
CI / Test (push) Successful in 15s
CI / Lint & Typecheck (push) Failing after 17s
CI / Build & Push Docker Image (push) Has been skipped
CI / Test (pull_request) Successful in 14s
CI / Lint & Typecheck (pull_request) Failing after 18s
CI / Build & Push Docker Image (pull_request) Has been skipped
chore: add Renovate config (GRO-1081)

Merge PR #4: add-renovate-config → dev
Approved by QA (Lint Roller) and CTO (The Dogfather).
2026-05-20 12:41:45 +00:00
The Dogfather c8610ec28d Merge pull request 'fix(ci): use Gitea registry for Docker push' (#9) from fix/ci-registry-auth into dev
CI / Test (push) Successful in 15s
CI / Lint & Typecheck (push) Failing after 17s
CI / Build & Push Docker Image (push) Has been skipped
CI / Lint & Typecheck (pull_request) Failing after 17s
CI / Test (pull_request) Successful in 13s
CI / Build & Push Docker Image (pull_request) Has been skipped
fix(ci): use Gitea registry for Docker push (#9)

GRO-1348

- Change Docker login from ghcr.io/GITHUB_TOKEN to git.farh.net/REGISTRY_TOKEN
- Update image tags from ghcr.io/groombook/web to git.farh.net/groombook/web
- Replace GitHub Actions cache with registry cache
2026-05-20 11:17:01 +00:00
Chris Farhood a582bd04b7 fix(ci): use Gitea registry for Docker push
CI / Lint & Typecheck (pull_request) Failing after 18s
CI / Test (pull_request) Successful in 15s
CI / Build & Push Docker Image (pull_request) Has been skipped
- Change Docker login from ghcr.io/GITHUB_TOKEN to git.farh.net/REGISTRY_TOKEN
- Update image tags from ghcr.io/groombook/web to git.farh.net/groombook/web
- Replace GitHub Actions cache (type=gha) with registry cache
- Remove GitHub Actions-specific permissions block
- GRO-1348

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-05-20 10:57:02 +00:00
Chris Farhood b8a9e8cc09 chore: add Renovate config
GRO-1081: add renovate.json to successor repos

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-05-14 17:42:22 +00:00
28 changed files with 313 additions and 662 deletions
@@ -2,9 +2,9 @@ name: CI
on:
push:
branches: [main, dev]
branches: [main, dev, uat]
pull_request:
branches: [main, dev]
branches: [main, dev, uat]
workflow_dispatch:
inputs:
ref:
@@ -78,6 +78,8 @@ jobs:
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
with:
driver-opts: network=host
- name: Log in to Gitea Container Registry
uses: docker/login-action@v3
@@ -92,6 +94,7 @@ jobs:
context: .
file: Dockerfile
push: true
provenance: false
tags: |
git.farh.net/groombook/web:${{ steps.version.outputs.tag }}
${{ github.ref == 'refs/heads/main' && 'git.farh.net/groombook/web:latest' || '' }}
+1 -1
View File
@@ -18,4 +18,4 @@ 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
CMD wget --spider -q http://localhost:80/ || exit 1
+21
View File
@@ -69,6 +69,7 @@ export const { signIn, signOut, useSession, changePassword } = authClient;
| TC-AUTH-5.3.1 | Auth client falls back to window.location.origin | Do not set `VITE_API_URL`, load app | Auth client uses `window.location.origin` as base URL |
| TC-AUTH-5.3.2 | Sign-in on localhost | Load app without `VITE_API_URL` on localhost:3000 | Auth client uses `http://localhost:3000` as base URL |
| TC-AUTH-5.3.3 | Sign-in on dev environment | Load app without `VITE_API_URL` on `https://dev.groombook.dev` | Auth client uses `https://dev.groombook.dev` as base URL |
| TC-AUTH-5.3.4 | SSO cookie set after Authentik callback (GRO-1592) | Complete Authentik SSO login on UAT without `VITE_API_URL` set | `__Secure-better-auth.session_token` cookie is present in browser; subsequent `/api/*` calls include the cookie and return 200 |
### 5.4 Session Persistence
@@ -77,6 +78,26 @@ export const { signIn, signOut, useSession, changePassword } = authClient;
| TC-AUTH-5.4.1 | Session persists across page reload | Sign in, reload page | Session remains active |
| TC-AUTH-5.4.2 | Session clears on sign-out | Sign in, sign out | User is logged out, redirected to login |
### 5.4.1 SSO Login Journey (Authentik OIDC end-to-end)
| # | Scenario | Steps | Pass Criteria | Fail Criteria |
|---|----------|-------|---------------|---------------|
| TC-WEB-SSO-1 | Sign-in page shows SSO button | Navigate to app root URL | Sign-in page displayed with "Sign in with SSO" button visible | No SSO button, 403 before page loads |
| TC-WEB-SSO-2 | Click SSO redirects to Authentik | Click "Sign in with SSO" button | Browser redirected to Authentik login at auth.farh.net | No redirect, error shown, button does nothing |
| TC-WEB-SSO-3 | Valid OIDC credentials authenticate | At Authentik, enter valid credentials and authenticate | Redirected back to app with active session | Redirect loop, 403, session not established |
| TC-WEB-SSO-4 | Post-login dashboard accessible | After SSO flow completes, dashboard loads | Dashboard displays correctly with user identity shown | Blank page, 403, session not active |
| TC-WEB-SSO-5 | User identity displayed correctly | After SSO login, check header/nav | User name/email/initials shown in nav, role reflected in UI | No user indicator, wrong user shown |
### 5.4.2 OOBE Flow Post-Login
| # | Scenario | Steps | Pass Criteria | Fail Criteria |
|---|----------|-------|---------------|---------------|
| TC-WEB-OOBE-1 | Fresh DB shows setup wizard | On fresh DB (no super user), navigate to app | Setup wizard / OOBE screen displayed | Regular login page shown instead of setup |
| TC-WEB-OOBE-2 | Configure OIDC via setup | During OOBE, configure OIDC auth provider via /api/setup/auth-provider | OIDC configured successfully, no 403 | 403 during setup, config rejected |
| TC-WEB-OOBE-3 | Setup completes and redirects | Complete OOBE setup with business name | Redirected to app dashboard as super user, setup bypassed on reload | Setup errors, wrong redirect, setup reappears |
| TC-WEB-OOBE-4 | Admin panel accessible after setup | After completing OOBE, navigate to admin panel | Admin features accessible | 403 on admin panel, insufficient permissions |
| TC-WEB-OOBE-5 | SSO login during OOBE does not interfere | During fresh OOBE, attempt SSO login before completing setup | SSO login redirected appropriately, setup can still complete | Auto-provision creates staff prematurely, setup flow broken |
### 5.5 Dashboard
| # | Scenario | Steps | Expected |
BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 239 KiB

-21
View File
@@ -226,24 +226,3 @@ export interface MedicalAlert {
}
export type CoatType = "smooth" | "double" | "curly" | "wire" | "long" | "hairless";
export interface NextAppointment {
id: string;
startTime: string;
serviceName: string;
}
export interface PetProfileSummary {
id: string;
name: string;
breed: string | null;
dateOfBirth: string | null;
weightKg: number | null;
coatType: CoatType | null;
temperamentScore: number | null;
temperamentFlags: string[];
medicalAlerts: MedicalAlert[];
preferredCuts: string[];
recentVisits: GroomingVisitLog[];
nextAppointment: NextAppointment | null;
}
@@ -1,9 +0,0 @@
<?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>
@@ -1,9 +0,0 @@
<?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>
@@ -1,9 +0,0 @@
<?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>
@@ -1,9 +0,0 @@
<?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>
@@ -1,9 +0,0 @@
<?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>
@@ -1,9 +0,0 @@
<?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>
@@ -1,9 +0,0 @@
<?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>
@@ -1,9 +0,0 @@
<?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>
@@ -1,9 +0,0 @@
<?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>
@@ -1,9 +0,0 @@
<?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>
+10
View File
@@ -0,0 +1,10 @@
{
"$schema": "https://docs.renovatebot.com/renovate-schema.json",
"extends": ["config:recommended", ":pinAllExceptPeerDependencies", "helpers:pinGitHubActionDigests"],
"labels": ["dependencies"],
"prConcurrentLimit": 5,
"packageRules": [
{"matchUpdateTypes": ["minor", "patch"], "groupName": "minor and patch dependencies", "automerge": false},
{"matchDepTypes": ["devDependencies"], "matchUpdateTypes": ["minor", "patch"], "automerge": true, "automergeType": "pr"}
]
}
+27
View File
@@ -0,0 +1,27 @@
import { describe, it, expect } from "vitest";
import { render, screen } from "@testing-library/react";
import { BookingCancelledPage } from "../pages/BookingCancelled.tsx";
describe("BookingCancelledPage", () => {
it("renders the cancelled heading", () => {
render(<BookingCancelledPage />);
expect(screen.getByRole("heading", { name: /Appointment Cancelled/i })).toBeInTheDocument();
});
it("renders the cancelled body text", () => {
render(<BookingCancelledPage />);
expect(screen.getByText(/Your appointment has been cancelled/i)).toBeInTheDocument();
});
it("has a Book again link pointing to /admin/book", () => {
render(<BookingCancelledPage />);
const link = screen.getByRole("link", { name: /Book again/i });
expect(link).toHaveAttribute("href", "/admin/book");
});
it("has a Back to Portal link pointing to /", () => {
render(<BookingCancelledPage />);
const link = screen.getByRole("link", { name: /Back to Portal/i });
expect(link).toHaveAttribute("href", "/");
});
});
+38
View File
@@ -0,0 +1,38 @@
import { describe, it, expect } from "vitest";
import { render, screen } from "@testing-library/react";
import { BookingErrorPage } from "../pages/BookingError.tsx";
import { BUSINESS_CONTACT_INFO } from "../lib/contact.ts";
describe("BookingErrorPage", () => {
it("renders the error heading", () => {
render(<BookingErrorPage />);
expect(screen.getByRole("heading", { name: /Link Invalid or Expired/i })).toBeInTheDocument();
});
it("renders the error body text", () => {
render(<BookingErrorPage />);
expect(screen.getByText(/This confirmation link is invalid/i)).toBeInTheDocument();
});
it("has a Start a new booking link pointing to /admin/book", () => {
render(<BookingErrorPage />);
const link = screen.getByRole("link", { name: /Start a new booking/i });
expect(link).toHaveAttribute("href", "/admin/book");
});
it("has a Back to Portal link pointing to /", () => {
render(<BookingErrorPage />);
const link = screen.getByRole("link", { name: /Back to Portal/i });
expect(link).toHaveAttribute("href", "/");
});
it("displays business contact phone", () => {
render(<BookingErrorPage />);
expect(screen.getByText(BUSINESS_CONTACT_INFO.phone)).toBeInTheDocument();
});
it("displays business contact email", () => {
render(<BookingErrorPage />);
expect(screen.getByText(BUSINESS_CONTACT_INFO.email)).toBeInTheDocument();
});
});
-157
View File
@@ -1,157 +0,0 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { render, screen, waitFor, within } from "@testing-library/react";
import { PetProfileCard } from "../components/PetProfileCard.js";
const FULL_SUMMARY = {
id: "pet-1",
name: "Buddy",
breed: "Golden Retriever",
dateOfBirth: "2022-03-15",
weightKg: 30.5,
coatType: "double",
temperamentScore: 4,
temperamentFlags: ["anxious", "friendly"],
medicalAlerts: [
{ id: "a1", type: "allergy", description: "Chicken allergy", severity: "high" },
{ id: "a2", type: "condition", description: "Hip dysplasia", severity: "medium" },
],
preferredCuts: ["teddy bear", "puppy cut"],
recentVisits: [
{ id: "v1", petId: "pet-1", appointmentId: "appt-1", staffId: "staff-1", cutStyle: "teddy bear", productsUsed: "oat shampoo", notes: "Good boy", groomedAt: "2025-05-01T10:00:00Z", createdAt: "2025-05-01T10:00:00Z" },
{ id: "v2", petId: "pet-1", appointmentId: "appt-2", staffId: "staff-2", cutStyle: "puppy cut", productsUsed: null, notes: null, groomedAt: "2025-04-01T10:00:00Z", createdAt: "2025-04-01T10:00:00Z" },
],
nextAppointment: { id: "appt-3", startTime: "2025-06-01T09:00:00Z", serviceName: "Full groom" },
};
const EMPTY_SUMMARY = {
id: "pet-2",
name: "Whiskers",
breed: null,
dateOfBirth: null,
weightKg: null,
coatType: null,
temperamentScore: null,
temperamentFlags: [],
medicalAlerts: [],
preferredCuts: [],
recentVisits: [],
nextAppointment: null,
};
beforeEach(() => {
vi.restoreAllMocks();
});
describe("PetProfileCard", () => {
it("shows loading skeleton while fetching", () => {
global.fetch = vi.fn(() => new Promise(() => {})) as unknown as typeof fetch;
render(<PetProfileCard petId="pet-1" />);
expect(screen.getByText(/loading/i)).toBeInTheDocument();
});
it("renders full profile data correctly", async () => {
global.fetch = vi.fn(() =>
Promise.resolve({
ok: true,
status: 200,
json: async () => FULL_SUMMARY,
} as Response)
) as unknown as typeof fetch;
render(<PetProfileCard petId="pet-1" />);
await waitFor(() => {
expect(screen.getByText("Buddy")).toBeInTheDocument();
});
expect(screen.getByText("Golden Retriever")).toBeInTheDocument();
// age computed from DOB
expect(screen.getByText(/yr/)).toBeInTheDocument();
// weight
expect(screen.getByText(/30.5 kg/)).toBeInTheDocument();
// coat type badge
expect(screen.getByText("double")).toBeInTheDocument();
// medical alerts
expect(screen.getByText("Chicken allergy")).toBeInTheDocument();
expect(screen.getByText("Hip dysplasia")).toBeInTheDocument();
// preferred cuts
expect(screen.getByText("teddy bear")).toBeInTheDocument();
});
it("displays severity-colored badges for medical alerts", async () => {
global.fetch = vi.fn(() =>
Promise.resolve({
ok: true,
status: 200,
json: async () => FULL_SUMMARY,
} as Response)
) as unknown as typeof fetch;
render(<PetProfileCard petId="pet-1" />);
await waitFor(() => {
expect(screen.getByText("Chicken allergy")).toBeInTheDocument();
});
const highAlert = screen.getByText("Chicken allergy").closest("span");
expect(highAlert).toHaveStyle({ color: "#dc2626" }); // high = red
const mediumAlert = screen.getByText("Hip dysplasia").closest("span");
expect(mediumAlert).toHaveStyle({ color: "#d97706" }); // medium = amber
});
it("handles empty pet data gracefully", async () => {
global.fetch = vi.fn(() =>
Promise.resolve({
ok: true,
status: 200,
json: async () => EMPTY_SUMMARY,
} as Response)
) as unknown as typeof fetch;
render(<PetProfileCard petId="pet-2" />);
await waitFor(() => {
expect(screen.getByText("Whiskers")).toBeInTheDocument();
});
// no section labels with no data
expect(screen.queryByText(/medical alerts/i)).not.toBeInTheDocument();
expect(screen.queryByText(/preferred cuts/i)).not.toBeInTheDocument();
expect(screen.queryByText(/recent visits/i)).not.toBeInTheDocument();
});
it("shows error message on fetch failure", async () => {
global.fetch = vi.fn(() => Promise.reject(new Error("network error"))) as unknown as typeof fetch;
render(<PetProfileCard petId="pet-1" />);
await waitFor(() => {
expect(screen.getByText(/failed to load/i)).toBeInTheDocument();
});
});
it("refetches when petId changes", async () => {
const fetchMock = vi.fn((url: string) => {
if ((url as string).includes("pet-1")) {
return Promise.resolve({
ok: true,
status: 200,
json: async () => ({ ...FULL_SUMMARY, name: "Buddy" }),
} as Response);
}
return Promise.resolve({
ok: true,
status: 200,
json: async () => ({ ...EMPTY_SUMMARY, name: "Whiskers" }),
} as Response);
}) as unknown as typeof fetch;
global.fetch = fetchMock;
const { rerender } = render(<PetProfileCard petId="pet-1" />);
await waitFor(() => expect(screen.getByText("Buddy")).toBeInTheDocument());
expect(fetchMock).toHaveBeenCalledWith("/api/pets/pet-1/profile-summary");
rerender(<PetProfileCard petId="pet-2" />);
await waitFor(() => expect(screen.getByText("Whiskers")).toBeInTheDocument());
expect(fetchMock).toHaveBeenCalledWith("/api/pets/pet-2/profile-summary");
});
});
-238
View File
@@ -1,238 +0,0 @@
import { useEffect, useState } from "react";
import type { MedicalAlert, PetProfileSummary } from "@groombook/types";
import { PetPhotoDisplay } from "./PetPhotoDisplay.js";
interface Props {
petId: string;
}
type LoadState =
| { status: "idle" }
| { status: "loading" }
| { status: "loaded"; data: PetProfileSummary }
| { status: "error"; message: string };
function computeAge(dateOfBirth: string | null): string | null {
if (!dateOfBirth) return null;
const birth = new Date(dateOfBirth);
const now = new Date();
const totalMonths = (now.getFullYear() - birth.getFullYear()) * 12 + (now.getMonth() - birth.getMonth());
if (totalMonths < 1) return "<1 mo";
if (totalMonths < 12) return `${totalMonths} mo`;
const years = Math.floor(totalMonths / 12);
const months = totalMonths % 12;
if (months === 0) return `${years} yr`;
return `${years} yr ${months} mo`;
}
function TemperamentDots({ score }: { score: number }) {
return (
<div style={{ display: "flex", gap: 3, alignItems: "center" }}>
{[1, 2, 3, 4, 5].map((n) => (
<div
key={n}
style={{
width: 10,
height: 10,
borderRadius: "50%",
background: n <= score ? "var(--color-primary)" : "#e2e8f0",
}}
/>
))}
</div>
);
}
const SEVERITY_STYLES: Record<MedicalAlert["severity"], { bg: string; color: string; border: string }> = {
high: { bg: "#fef2f2", color: "#dc2626", border: "#fca5a5" },
medium: { bg: "#fffbeb", color: "#d97706", border: "#fde68a" },
low: { bg: "#eff6ff", color: "#2563eb", border: "#bfdbfe" },
};
function MedicalAlertBadge({ alert }: { alert: MedicalAlert }) {
const s = SEVERITY_STYLES[alert.severity];
return (
<span
style={{
display: "inline-flex",
alignItems: "center",
gap: "0.25rem",
fontSize: 11,
fontWeight: 600,
padding: "0.15rem 0.45rem",
borderRadius: 99,
background: s.bg,
color: s.color,
border: `1px solid ${s.border}`,
}}
>
{alert.description}
</span>
);
}
function SectionLabel({ children }: { children: React.ReactNode }) {
return (
<div style={{ fontSize: 10, fontWeight: 700, color: "#9ca3af", letterSpacing: "0.05em", marginBottom: "0.25rem" }}>
{children}
</div>
);
}
export function PetProfileCard({ petId }: Props) {
const [state, setState] = useState<LoadState>({ status: "idle" });
useEffect(() => {
setState({ status: "loading" });
fetch(`/api/pets/${petId}/profile-summary`)
.then(async (res) => {
if (!res.ok) throw new Error(`HTTP ${res.status}`);
return res.json() as Promise<PetProfileSummary>;
})
.then((data) => setState({ status: "loaded", data }))
.catch((e: unknown) => setState({ status: "error", message: e instanceof Error ? e.message : "Unknown error" }));
}, [petId]);
if (state.status === "idle" || state.status === "loading") {
return (
<div style={{
border: "1px solid #e5e7eb",
borderRadius: 10,
padding: "1rem",
background: "#fff",
boxShadow: "0 1px 3px rgba(0,0,0,0.04)",
}}>
<div style={{ display: "flex", gap: "0.75rem", marginBottom: "0.75rem" }}>
<div style={{ width: 64, height: 64, borderRadius: 12, background: "linear-gradient(90deg,#f0ebe4 25%,#e8e0d8 50%,#f0ebe4 75%)", backgroundSize: "200% 100%", animation: "shimmer 1.5s infinite", flexShrink: 0 }} />
<div style={{ flex: 1 }}>
<div style={{ height: 14, borderRadius: 4, background: "#f0ebe4", width: "60%", marginBottom: 6, animation: "shimmer 1.5s infinite", backgroundSize: "200% 100%" }} />
<div style={{ height: 12, borderRadius: 4, background: "#f0ebe4", width: "40%", marginBottom: 6, animation: "shimmer 1.5s infinite", backgroundSize: "200% 100%" }} />
<div style={{ height: 12, borderRadius: 4, background: "#f0ebe4", width: "50%", animation: "shimmer 1.5s infinite", backgroundSize: "200% 100%" }} />
</div>
</div>
<div style={{ height: 10, borderRadius: 4, background: "#f0ebe4", width: "30%", marginBottom: 8, animation: "shimmer 1.5s infinite", backgroundSize: "200% 100%" }} />
<div style={{ display: "flex", gap: 6, flexWrap: "wrap" }}>
{[1, 2, 3].map((n) => <div key={n} style={{ height: 22, width: 72, borderRadius: 99, background: "#f0ebe4", animation: "shimmer 1.5s infinite", backgroundSize: "200% 100%" }} />)}
</div>
</div>
);
}
if (state.status === "error") {
return (
<div style={{
border: "1px solid #fca5a5",
borderRadius: 10,
padding: "1rem",
background: "#fef2f2",
color: "#dc2626",
fontSize: 13,
}}>
Failed to load profile: {state.message}
</div>
);
}
const { data } = state;
const age = computeAge(data.dateOfBirth);
return (
<div style={{
border: "1px solid #e5e7eb",
borderRadius: 10,
padding: "1rem",
background: "#fff",
boxShadow: "0 1px 3px rgba(0,0,0,0.04)",
}}>
{/* Header */}
<div style={{ display: "flex", gap: "0.75rem", marginBottom: "0.75rem" }}>
<PetPhotoDisplay petId={petId} size={64} />
<div style={{ flex: 1, minWidth: 0 }}>
<strong style={{ fontSize: 16 }}>{data.name}</strong>
<div style={{ fontSize: 13, color: "#6b7280", marginTop: "0.15rem" }}>
{data.breed ?? "Unknown breed"}
{age && <span> · {age}</span>}
</div>
{data.weightKg != null && (
<div style={{ fontSize: 12, color: "#6b7280" }}>{data.weightKg} kg</div>
)}
</div>
</div>
{/* Coat type */}
{data.coatType && (
<div style={{ marginBottom: "0.6rem" }}>
<SectionLabel>COAT TYPE</SectionLabel>
<span style={{ fontSize: 12, padding: "0.15rem 0.5rem", background: "#f0fdf4", color: "#166534", border: "1px solid #bbf7d0", borderRadius: 99, fontWeight: 500 }}>
{data.coatType}
</span>
</div>
)}
{/* Temperament */}
{(data.temperamentScore != null || data.temperamentFlags.length > 0) && (
<div style={{ marginBottom: "0.6rem" }}>
<SectionLabel>TEMPERAMENT</SectionLabel>
<div style={{ display: "flex", alignItems: "center", gap: "0.5rem", flexWrap: "wrap" }}>
{data.temperamentScore != null && <TemperamentDots score={data.temperamentScore} />}
{data.temperamentFlags.map((flag) => (
<span key={flag} style={{ fontSize: 11, padding: "0.1rem 0.4rem", background: "#f5f3ff", color: "#6d28d9", border: "1px solid #ddd6fe", borderRadius: 4, fontWeight: 500 }}>
{flag}
</span>
))}
</div>
</div>
)}
{/* Medical alerts — most prominent */}
{data.medicalAlerts.length > 0 && (
<div style={{ marginBottom: "0.6rem" }}>
<SectionLabel>MEDICAL ALERTS</SectionLabel>
<div style={{ display: "flex", flexWrap: "wrap", gap: "0.35rem" }}>
{data.medicalAlerts.map((alert) => (
<MedicalAlertBadge key={alert.id} alert={alert} />
))}
</div>
</div>
)}
{/* Preferred cuts */}
{data.preferredCuts.length > 0 && (
<div style={{ marginBottom: "0.6rem" }}>
<SectionLabel>PREFERRED CUTS</SectionLabel>
<div style={{ display: "flex", flexWrap: "wrap", gap: "0.35rem" }}>
{data.preferredCuts.map((cut) => (
<span key={cut} style={{ fontSize: 11, padding: "0.15rem 0.45rem", background: "#f8fafc", color: "#374151", border: "1px solid #e2e8f0", borderRadius: 4 }}>
{cut}
</span>
))}
</div>
</div>
)}
{/* Recent visits */}
{data.recentVisits.length > 0 && (
<div style={{ marginBottom: "0.6rem" }}>
<SectionLabel>RECENT VISITS</SectionLabel>
{data.recentVisits.slice(0, 3).map((log) => (
<div key={log.id} style={{ fontSize: 12, color: "#374151", marginBottom: "0.2rem", borderLeft: "2px solid #e2e8f0", paddingLeft: "0.4rem" }}>
<span style={{ color: "#6b7280" }}>{new Date(log.groomedAt).toLocaleDateString()}</span>
{log.cutStyle && <span> · {log.cutStyle}</span>}
{log.staffId && <span> ·</span>}
</div>
))}
</div>
)}
{/* Next appointment */}
{data.nextAppointment && (
<div>
<SectionLabel>NEXT APPOINTMENT</SectionLabel>
<div style={{ fontSize: 12, color: "#374151" }}>
{new Date(data.nextAppointment.startTime).toLocaleDateString()} {data.nextAppointment.serviceName}
</div>
</div>
)}
</div>
);
}
+13
View File
@@ -8,6 +8,19 @@
--color-accent-dark: color-mix(in srgb, var(--color-accent) 78%, #000);
--color-accent-light: color-mix(in srgb, var(--color-accent) 18%, #fff);
--color-accent-lighter: color-mix(in srgb, var(--color-accent) 9%, #fff);
/* Semantic / booking page tokens */
--color-error: #dc2626;
--color-error-dark: #b91c1c;
--color-error-bg: #fef2f2;
--color-cancelled: #ea580c;
--color-cancelled-dark: #c2410c;
--color-cancelled-bg: #fff7ed;
--color-success: #16a34a;
--color-success-dark: #15803d;
--color-success-bg: #f0fdf4;
--color-text-secondary: #4b5563;
--color-surface: #fff;
}
*, *::before, *::after {
+1 -1
View File
@@ -1,7 +1,7 @@
import { createAuthClient } from "better-auth/react";
export const authClient = createAuthClient({
baseURL: import.meta.env.VITE_API_URL ?? "",
baseURL: import.meta.env.VITE_API_URL || (typeof window !== "undefined" ? window.location.origin : ""),
});
export const { signIn, signOut, useSession, changePassword } = authClient;
+7
View File
@@ -0,0 +1,7 @@
// Business contact information — update values to reflect actual business details.
// Used on error/cancellation pages to help customers reach the business.
export const BUSINESS_CONTACT_INFO = {
phone: "(555) 000-1234",
email: "hello@groombook.example.com",
address: "123 Main St, Anytown, USA",
} as const;
+1 -9
View File
@@ -1,8 +1,5 @@
import { useEffect, useState, useCallback, useRef } from "react";
import type { Appointment, Client, Pet, Service, Staff } from "@groombook/types";
import { PetPhotoDisplay } from "../components/PetPhotoDisplay.js";
import { PetPhotoUpload } from "../components/PetPhotoUpload.js";
import { PetProfileCard } from "../components/PetProfileCard.js";
// ─── Helpers ────────────────────────────────────────────────────────────────
@@ -116,7 +113,6 @@ export function AppointmentsPage() {
// null key = unassigned; staffId string = that groomer; undefined set = all visible
const [hiddenGroomers, setHiddenGroomers] = useState<Set<string | null>>(new Set());
const [paymentStats, setPaymentStats] = useState<{ revenueThisMonth: number; outstanding: number; refundsThisMonth: number; methodBreakdown: { method: string | null; total: number }[] } | null>(null);
const [selectedPetId, setSelectedPetId] = useState<string | null>(null);
const weekEnd = addDays(weekStart, 6);
@@ -514,10 +510,7 @@ export function AppointmentsPage() {
<Field label="Pet">
<select
value={form.petId}
onChange={(e) => {
setForm((f) => ({ ...f, petId: e.target.value }));
setSelectedPetId(e.target.value || null);
}}
onChange={(e) => setForm((f) => ({ ...f, petId: e.target.value }))}
required
disabled={!form.clientId}
style={inputStyle}
@@ -528,7 +521,6 @@ export function AppointmentsPage() {
))}
</select>
</Field>
{form.petId && <PetProfileCard petId={form.petId} />}
<Field label="Service">
<select
value={form.serviceId}
+1 -1
View File
@@ -519,7 +519,7 @@ export function BookPage() {
<option value="small">Small (under 15 lbs)</option>
<option value="medium">Medium (1540 lbs)</option>
<option value="large">Large (4080 lbs)</option>
<option value="x-large">X-Large (over 80 lbs)</option>
<option value="xlarge">X-Large (over 80 lbs)</option>
</select>
</div>
<div>
+46 -22
View File
@@ -1,3 +1,10 @@
const STRINGS = {
heading: "Appointment Cancelled",
body: "Your appointment has been cancelled. If this was a mistake or you'd like to rebook, please contact us.",
bookAgain: "Book again",
backToPortal: "Back to Portal",
} as const;
export function BookingCancelledPage() {
return (
<div
@@ -7,12 +14,12 @@ export function BookingCancelledPage() {
alignItems: "center",
justifyContent: "center",
fontFamily: "system-ui, sans-serif",
background: "#fff7ed",
background: "var(--color-cancelled-bg)",
}}
>
<div
style={{
background: "#fff",
background: "var(--color-surface)",
borderRadius: 12,
padding: "2.5rem 3rem",
boxShadow: "0 4px 24px rgba(0,0,0,0.08)",
@@ -21,28 +28,45 @@ export function BookingCancelledPage() {
}}
>
<div style={{ fontSize: 56, marginBottom: "0.5rem" }}></div>
<h1 style={{ color: "#c2410c", fontSize: 24, margin: "0 0 0.5rem" }}>
Appointment Cancelled
<h1 style={{ color: "var(--color-cancelled-dark)", fontSize: 24, margin: "0 0 0.5rem" }}>
{STRINGS.heading}
</h1>
<p style={{ color: "#4b5563", margin: "0 0 1.5rem" }}>
Your appointment has been cancelled. If this was a mistake or you'd
like to rebook, please contact us.
<p style={{ color: "var(--color-text-secondary)", margin: "0 0 1.5rem" }}>
{STRINGS.body}
</p>
<a
href="/"
style={{
display: "inline-block",
padding: "0.6rem 1.5rem",
background: "#ea580c",
color: "#fff",
borderRadius: 6,
textDecoration: "none",
fontWeight: 600,
fontSize: 14,
}}
>
Back to Portal
</a>
<div style={{ display: "flex", flexDirection: "column", gap: "0.75rem", alignItems: "center" }}>
<a
href="/admin/book"
style={{
display: "inline-block",
padding: "0.6rem 1.5rem",
background: "var(--color-primary)",
color: "#fff",
borderRadius: 6,
textDecoration: "none",
fontWeight: 600,
fontSize: 14,
}}
>
{STRINGS.bookAgain}
</a>
<a
href="/"
style={{
display: "inline-block",
padding: "0.6rem 1.5rem",
background: "var(--color-cancelled)",
color: "#fff",
borderRadius: 6,
textDecoration: "none",
fontWeight: 600,
fontSize: 14,
}}
>
{STRINGS.backToPortal}
</a>
</div>
</div>
</div>
);
+56 -22
View File
@@ -1,3 +1,13 @@
import { BUSINESS_CONTACT_INFO } from "../lib/contact";
const STRINGS = {
heading: "Link Invalid or Expired",
body: "This confirmation link is invalid, has already been used, or your appointment has already passed. Please contact us if you need help.",
newBooking: "Start a new booking",
backToPortal: "Back to Portal",
contactLabel: "Need help?",
} as const;
export function BookingErrorPage() {
return (
<div
@@ -7,12 +17,12 @@ export function BookingErrorPage() {
alignItems: "center",
justifyContent: "center",
fontFamily: "system-ui, sans-serif",
background: "#fef2f2",
background: "var(--color-error-bg)",
}}
>
<div
style={{
background: "#fff",
background: "var(--color-surface)",
borderRadius: 12,
padding: "2.5rem 3rem",
boxShadow: "0 4px 24px rgba(0,0,0,0.08)",
@@ -21,28 +31,52 @@ export function BookingErrorPage() {
}}
>
<div style={{ fontSize: 56, marginBottom: "0.5rem" }}></div>
<h1 style={{ color: "#b91c1c", fontSize: 24, margin: "0 0 0.5rem" }}>
Link Invalid or Expired
<h1 style={{ color: "var(--color-error-dark)", fontSize: 24, margin: "0 0 0.5rem" }}>
{STRINGS.heading}
</h1>
<p style={{ color: "#4b5563", margin: "0 0 1.5rem" }}>
This confirmation link is invalid, has already been used, or your
appointment has already passed. Please contact us if you need help.
<p style={{ color: "var(--color-text-secondary)", margin: "0 0 1.5rem" }}>
{STRINGS.body}
</p>
<a
href="/"
style={{
display: "inline-block",
padding: "0.6rem 1.5rem",
background: "#dc2626",
color: "#fff",
borderRadius: 6,
textDecoration: "none",
fontWeight: 600,
fontSize: 14,
}}
>
Back to Portal
</a>
<div style={{ display: "flex", flexDirection: "column", gap: "0.75rem", alignItems: "center" }}>
<a
href="/admin/book"
style={{
display: "inline-block",
padding: "0.6rem 1.5rem",
background: "var(--color-primary)",
color: "#fff",
borderRadius: 6,
textDecoration: "none",
fontWeight: 600,
fontSize: 14,
}}
>
{STRINGS.newBooking}
</a>
<a
href="/"
style={{
display: "inline-block",
padding: "0.6rem 1.5rem",
background: "var(--color-error)",
color: "#fff",
borderRadius: 6,
textDecoration: "none",
fontWeight: 600,
fontSize: 14,
}}
>
{STRINGS.backToPortal}
</a>
</div>
<div style={{ marginTop: "1.5rem", paddingTop: "1rem", borderTop: "1px solid #e5e7eb", fontSize: 13, color: "var(--color-text-secondary)" }}>
<p style={{ margin: "0 0 0.25rem", fontWeight: 600 }}>{STRINGS.contactLabel}</p>
<p style={{ margin: 0 }}>
{BUSINESS_CONTACT_INFO.phone} · {BUSINESS_CONTACT_INFO.email}
</p>
</div>
</div>
</div>
);
+86 -98
View File
@@ -3,7 +3,6 @@ import { useParams, Link } from "react-router-dom";
import type { Client, GroomingVisitLog, Pet } from "@groombook/types";
import { PetPhotoDisplay } from "../components/PetPhotoDisplay.js";
import { PetPhotoUpload } from "../components/PetPhotoUpload.js";
import { PetProfileCard } from "../components/PetProfileCard.js";
export function ClientDetailPage() {
const { clientId } = useParams<{ clientId: string }>();
@@ -14,7 +13,6 @@ export function ClientDetailPage() {
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [photoRevisions, setPhotoRevisions] = useState<Record<string, number>>({});
const [expandedPetId, setExpandedPetId] = useState<string | null>(null);
const handlePhotoUploaded = useCallback((petId: string) => {
setPhotoRevisions((prev) => ({ ...prev, [petId]: (prev[petId] ?? 0) + 1 }));
@@ -138,107 +136,97 @@ export function ClientDetailPage() {
) : (
<div style={{ display: "grid", gridTemplateColumns: "repeat(auto-fill, minmax(260px, 1fr))", gap: "0.75rem" }}>
{pets.map((p) => (
<div key={p.id}>
{/* Compact pet card — always visible, clickable to expand */}
<div style={{ border: "1px solid #e5e7eb", borderRadius: 10, padding: "0.85rem", background: "#fff", boxShadow: "0 1px 3px rgba(0, 0, 0, 0.04)", cursor: "pointer" }}
onClick={() => setExpandedPetId(expandedPetId === p.id ? null : p.id)}>
{/* Photo + header */}
<div style={{ display: "flex", gap: "0.75rem", marginBottom: "0.4rem" }}>
<PetPhotoDisplay
petId={p.id}
size={56}
key={`${p.id}-photo-${photoRevisions[p.id] ?? 0}`}
/>
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "flex-start" }}>
<strong style={{ fontSize: 15 }}>{p.name}</strong>
<span style={{ fontSize: 11, color: "#9ca3af" }}>{expandedPetId === p.id ? "▲" : "▼"}</span>
</div>
<div style={{ fontSize: 13, color: "#6b7280", marginTop: "0.15rem" }}>
{p.species}{p.breed ? ` · ${p.breed}` : ""}
</div>
{p.weightKg != null && <div style={{ fontSize: 12, color: "#6b7280" }}>{p.weightKg} kg</div>}
{p.dateOfBirth && <div style={{ fontSize: 12, color: "#6b7280" }}>Born {new Date(p.dateOfBirth).toLocaleDateString()}</div>}
<div style={{ marginTop: "0.3rem" }}>
<PetPhotoUpload petId={p.id} onUploaded={() => handlePhotoUploaded(p.id)} />
</div>
<div key={p.id} style={{ border: "1px solid #e5e7eb", borderRadius: 10, padding: "0.85rem", background: "#fff", boxShadow: "0 1px 3px rgba(0, 0, 0, 0.04)" }}>
{/* Photo + header */}
<div style={{ display: "flex", gap: "0.75rem", marginBottom: "0.4rem" }}>
<PetPhotoDisplay
petId={p.id}
size={56}
key={`${p.id}-photo-${photoRevisions[p.id] ?? 0}`}
/>
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "flex-start" }}>
<strong style={{ fontSize: 15 }}>{p.name}</strong>
</div>
<div style={{ fontSize: 13, color: "#6b7280", marginTop: "0.15rem" }}>
{p.species}{p.breed ? ` · ${p.breed}` : ""}
</div>
{p.weightKg != null && <div style={{ fontSize: 12, color: "#6b7280" }}>{p.weightKg} kg</div>}
{p.dateOfBirth && <div style={{ fontSize: 12, color: "#6b7280" }}>Born {new Date(p.dateOfBirth).toLocaleDateString()}</div>}
<div style={{ marginTop: "0.3rem" }}>
<PetPhotoUpload petId={p.id} onUploaded={() => handlePhotoUploaded(p.id)} />
</div>
</div>
{p.healthAlerts && (
<div style={{ fontSize: 12, marginTop: "0.35rem", background: "#fef2f2", border: "1px solid #fecaca", borderRadius: 4, padding: "0.3rem 0.5rem", color: "#dc2626" }}>
<span style={{ fontWeight: 600 }}> Health alerts:</span> {p.healthAlerts}
</div>
)}
{/* Grooming preferences */}
{(p.cutStyle || p.shampooPreference || p.specialCareNotes || p.groomingNotes) && (
<div style={{ marginTop: "0.5rem", borderTop: "1px solid #f3f4f6", paddingTop: "0.4rem" }}>
{p.cutStyle && (
<div style={{ fontSize: 12, color: "#374151" }}>
<span style={{ fontWeight: 600 }}>Cut:</span> {p.cutStyle}
</div>
)}
{p.shampooPreference && (
<div style={{ fontSize: 12, color: "#374151" }}>
<span style={{ fontWeight: 600 }}>Shampoo:</span> {p.shampooPreference}
</div>
)}
{p.specialCareNotes && (
<div style={{ fontSize: 12, marginTop: "0.2rem", background: "#fffbeb", border: "1px solid #fde68a", borderRadius: 4, padding: "0.3rem 0.5rem", color: "#92400e" }}>
<span style={{ fontWeight: 600 }}>Special care:</span> {p.specialCareNotes}
</div>
)}
{p.groomingNotes && (
<div style={{ fontSize: 12, marginTop: "0.2rem", color: "#374151" }}>
<span style={{ fontWeight: 600 }}>Notes:</span> {p.groomingNotes}
</div>
)}
</div>
)}
{/* Visit history */}
{(() => {
const logs = visitLogs[p.id];
const loadingLogs = logsLoading[p.id];
return (
<div style={{ marginTop: "0.5rem", borderTop: "1px solid #f3f4f6", paddingTop: "0.4rem" }}>
<div style={{ display: "flex", alignItems: "center", justifyContent: "space-between", marginBottom: "0.25rem" }}>
<div style={{ fontSize: 11, fontWeight: 600, color: "#6b7280" }}>VISIT HISTORY</div>
{!logs && !loadingLogs && (
<button
onClick={(e) => { e.stopPropagation(); void loadVisitLogs(p.id); }}
style={{ fontSize: 11, color: "#4f8a6f", background: "none", border: "none", cursor: "pointer", padding: 0 }}
>
Load history
</button>
)}
</div>
{loadingLogs && <div style={{ fontSize: 11, color: "#9ca3af" }}>Loading</div>}
{logs && logs.length === 0 && <div style={{ fontSize: 11, color: "#9ca3af" }}>No visits yet</div>}
{logs && logs.length > 0 && (
<>
{logs.slice(0, 3).map((log) => (
<div key={log.id} style={{ fontSize: 11, color: "#374151", marginBottom: "0.2rem", borderLeft: "2px solid #e2e8f0", paddingLeft: "0.4rem" }}>
<span style={{ color: "#6b7280" }}>{new Date(log.groomedAt).toLocaleDateString()}</span>
{log.cutStyle && <span> · {log.cutStyle}</span>}
{log.notes && <span> · {log.notes}</span>}
</div>
))}
{logs.length > 3 && (
<div style={{ fontSize: 11, color: "#6b7280" }}>+{logs.length - 3} more visits</div>
)}
</>
)}
</div>
);
})()}
</div>
{/* Expanded pet profile card */}
{expandedPetId === p.id && (
<PetProfileCard petId={p.id} />
{p.healthAlerts && (
<div style={{ fontSize: 12, marginTop: "0.35rem", background: "#fef2f2", border: "1px solid #fecaca", borderRadius: 4, padding: "0.3rem 0.5rem", color: "#dc2626" }}>
<span style={{ fontWeight: 600 }}> Health alerts:</span> {p.healthAlerts}
</div>
)}
{/* Grooming preferences */}
{(p.cutStyle || p.shampooPreference || p.specialCareNotes || p.groomingNotes) && (
<div style={{ marginTop: "0.5rem", borderTop: "1px solid #f3f4f6", paddingTop: "0.4rem" }}>
{p.cutStyle && (
<div style={{ fontSize: 12, color: "#374151" }}>
<span style={{ fontWeight: 600 }}>Cut:</span> {p.cutStyle}
</div>
)}
{p.shampooPreference && (
<div style={{ fontSize: 12, color: "#374151" }}>
<span style={{ fontWeight: 600 }}>Shampoo:</span> {p.shampooPreference}
</div>
)}
{p.specialCareNotes && (
<div style={{ fontSize: 12, marginTop: "0.2rem", background: "#fffbeb", border: "1px solid #fde68a", borderRadius: 4, padding: "0.3rem 0.5rem", color: "#92400e" }}>
<span style={{ fontWeight: 600 }}>Special care:</span> {p.specialCareNotes}
</div>
)}
{p.groomingNotes && (
<div style={{ fontSize: 12, marginTop: "0.2rem", color: "#374151" }}>
<span style={{ fontWeight: 600 }}>Notes:</span> {p.groomingNotes}
</div>
)}
</div>
)}
{/* Visit history */}
{(() => {
const logs = visitLogs[p.id];
const loadingLogs = logsLoading[p.id];
return (
<div style={{ marginTop: "0.5rem", borderTop: "1px solid #f3f4f6", paddingTop: "0.4rem" }}>
<div style={{ display: "flex", alignItems: "center", justifyContent: "space-between", marginBottom: "0.25rem" }}>
<div style={{ fontSize: 11, fontWeight: 600, color: "#6b7280" }}>VISIT HISTORY</div>
{!logs && !loadingLogs && (
<button
onClick={() => { void loadVisitLogs(p.id); }}
style={{ fontSize: 11, color: "#4f8a6f", background: "none", border: "none", cursor: "pointer", padding: 0 }}
>
Load history
</button>
)}
</div>
{loadingLogs && <div style={{ fontSize: 11, color: "#9ca3af" }}>Loading</div>}
{logs && logs.length === 0 && <div style={{ fontSize: 11, color: "#9ca3af" }}>No visits yet</div>}
{logs && logs.length > 0 && (
<>
{logs.slice(0, 3).map((log) => (
<div key={log.id} style={{ fontSize: 11, color: "#374151", marginBottom: "0.2rem", borderLeft: "2px solid #e2e8f0", paddingLeft: "0.4rem" }}>
<span style={{ color: "#6b7280" }}>{new Date(log.groomedAt).toLocaleDateString()}</span>
{log.cutStyle && <span> · {log.cutStyle}</span>}
{log.notes && <span> · {log.notes}</span>}
</div>
))}
{logs.length > 3 && (
<div style={{ fontSize: 11, color: "#6b7280" }}>+{logs.length - 3} more visits</div>
)}
</>
)}
</div>
);
})()}
</div>
))}
</div>