Compare commits

..

18 Commits

Author SHA1 Message Date
Chris Farhood 93da2f1dd8 chore: promote dev→uat for GRO-1592 SSO session cookie fix
CI / Lint & Typecheck (pull_request) Successful in 17s
CI / Test (pull_request) Successful in 18s
CI / Build & Push Docker Image (pull_request) Failing after 41s
- Fixed frontend auth client baseURL fallback to use window.location.origin
- Added UAT test coverage (TC-AUTH-5.3.4)

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-05-23 14:13:12 +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 62cbfe4e43 Merge pull request 'promote: dev → uat (GRO-1173 buffer rules + GRO-1470 pet save persistence)' (#14) from dev into uat
CI / Test (push) Successful in 14s
CI / Lint & Typecheck (push) Successful in 19s
CI / Build & Push Docker Image (push) Successful in 9s
promote: dev → uat (GRO-1173 buffer rules + GRO-1470 pet save persistence) (#14)

Merged-By: The Dogfather (CTO)
Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-05-21 19:46:41 +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
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 db6a2a1bbf Merge pull request 'promote: dev → uat (Renovate config, GRO-1081)' (#11) from dev into uat
promote: dev → uat (Renovate config, GRO-1081)

Merge PR #11: dev → uat promotion
Includes: chore: add Renovate config (GRO-1081)
2026-05-20 12:42:04 +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 032a3796ba Merge pull request 'chore: promote dev to uat (CI Docker registry fix)' (#10) from dev into uat
chore: promote dev to uat (CI Docker registry fix) (#10)

Promotes GRO-1348 CI registry fix to UAT.
2026-05-20 11:17:21 +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
the-dogfather-cto[bot] cac8fc947e chore(GRO-1289): promote dev to uat — add UAT_PLAYBOOK.md
chore(GRO-1289): promote dev to uat — add UAT_PLAYBOOK.md
2026-05-14 21:13:56 +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
the-dogfather-cto[bot] 592be1301c chore: promote dev to uat (#3)
chore: promote dev to uat
2026-05-11 13:19:33 +00:00
21 changed files with 102 additions and 617 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:
+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
+1
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
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"}
]
}
-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>
);
}
+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;
+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}
+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>