Compare commits

..

1 Commits

Author SHA1 Message Date
Flea Flicker 0361b84bd5 feat(GRO-1179): add PetProfileCard component with medical alert severity badges
CI / Test (pull_request) Failing after 30s
CI / Lint & Typecheck (pull_request) Failing after 38s
CI / Build & Push Docker Image (pull_request) Has been skipped
- Create PetProfileCard fetching from GET /api/pets/:id/profile-summary
- Displays: pet photo/name/breed/age/weight, coat type badge, temperament
  score (1-5 dots) + flag badges, medical alerts (severity-colored),
  preferred cuts, recent visits, next appointment
- Loading skeleton and error/empty states
- Integrate into Appointments booking form after pet selection
- Integrate into ClientDetailPage as expandable card per pet
- Export PetProfileSummary + NextAppointment types in @groombook/types
- Add PetProfileCard tests covering full data, empty data, loading, error

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-05-25 23:49:23 +00:00
38 changed files with 702 additions and 1144 deletions
@@ -2,9 +2,9 @@ name: CI
on:
push:
branches: [main, dev, uat]
branches: [main, dev]
pull_request:
branches: [main, dev, uat]
branches: [main, dev]
workflow_dispatch:
inputs:
ref:
@@ -78,8 +78,6 @@ 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
@@ -94,7 +92,6 @@ 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 wget --spider -q http://localhost:80/ || exit 1
CMD curl -f http://localhost:80/ || exit 1
-89
View File
@@ -53,7 +53,6 @@ export const { signIn, signOut, useSession, changePassword } = authClient;
| TC-WEB-5.1.2 | OIDC redirect | Click OIDC login button | Redirected to OIDC provider, then back to app with session established |
| TC-WEB-5.1.3 | Logout | Click logout button | Session cleared, redirected to login page |
| TC-WEB-5.1.4 | Session indicator | After successful login | User info/initials visible in UI indicating active session |
| TC-WEB-5.1.5 | Unauthenticated `/login` renders the form (GRO-2011) | In a private/incognito window with no session cookie, navigate to UAT `/login` | React root mounts; the GroomBook sign-in card with the OIDC button is visible. Network tab shows `/api/auth/get-session` 200, `/api/setup/status` 200, and the login form is rendered (NOT a blank white viewport). |
### 5.2 Authentication — VITE_API_URL Set
@@ -70,7 +69,6 @@ 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
@@ -79,26 +77,6 @@ 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 |
@@ -184,29 +162,6 @@ export const { signIn, signOut, useSession, changePassword } = authClient;
| TC-WEB-5.12.3 | Confirm appointment | Click confirm on pending appointment | Appointment status updated to confirmed |
| TC-WEB-5.12.4 | Cancel appointment | Click cancel on appointment | Appointment marked as cancelled |
#### 5.12b Dynamic Portal Time Slots (GRO-1793)
| # | Scenario | Steps | Expected |
|---|----------|-------|----------|
| TC-WEB-5.12.5 | BookingFlow dynamic slots | Open Book New, select pet and service, pick a date | Time slots fetched from API; "Checking availability…" shown while loading |
| TC-WEB-5.12.6 | BookingFlow slots match wizard | Compare BookingFlow slot times with public booking wizard for same date | Same slots displayed |
| TC-WEB-5.12.7 | BookingFlow error state | Mock API failure on availability fetch | "Failed to load time slots" error shown |
| TC-WEB-5.12.8 | BookingFlow no slots | Select date with no availability | "No available slots on this date" shown |
| TC-WEB-5.12.9 | RescheduleFlow dynamic slots | Open reschedule, pick a new date | Time slots fetched from API; loading state shown |
| TC-WEB-5.12.10 | RescheduleFlow error state | Mock API failure on availability fetch | "Failed to load time slots" error shown |
| TC-WEB-5.12.11 | RescheduleFlow no slots | Select date with no availability | "No available slots on this date" shown |
#### 5.12c Waitlist/Booking Status Badges (GRO-1795)
| # | Scenario | Steps | Expected |
|---|----------|-------|----------|
| TC-WEB-5.12.12 | Confirmed badge | View appointment card with confirmed status | Green "Confirmed" badge displayed |
| TC-WEB-5.12.13 | Pending badge | View appointment card with pending status | Amber "Pending" badge displayed |
| TC-WEB-5.12.14 | Waitlisted badge | View appointment card with waitlisted status | Blue "Waitlisted" badge displayed |
| TC-WEB-5.12.15 | Badge uses CSS classes | Inspect badge element | Badge uses CSS variable-based classes (e.g., bg-green-100, text-amber-600), not hardcoded colors |
| TC-WEB-5.12.16 | Badge status from data | Compare badge label to appointment.status field | Badge label matches the API appointment status exactly |
| TC-WEB-5.12.17 | Unknown status fallback | Render badge with unknown status value | Badge renders with the raw status string as label and fallback CSS class |
### 5.13 Reports UI
| # | Scenario | Steps | Expected |
@@ -328,50 +283,6 @@ export const { signIn, signOut, useSession, changePassword } = authClient;
| TC-WEB-5.23.2 | Save pet — error state | Trigger an API save failure (e.g. network error) | Error message displayed; edit form stays open; no data cleared |
| TC-WEB-5.23.3 | Save pet — saving indicator | Click Save | Spinner/indicator shown while request is in flight; form controls disabled |
### 5.24 Booking Funnel Analytics Events (GRO-1794)
| # | Scenario | Steps | Expected |
|---|----------|-------|----------|
| TC-WEB-5.24.1 | booking_step_service — public | Select a service in the public booking wizard | `booking_step_service` CustomEvent fires with detail.step="service" and detail.flow="public" |
| TC-WEB-5.24.2 | booking_step_time — public | Select a time slot and click Continue | `booking_step_time` fires with detail.step="time" and detail.flow="public" |
| TC-WEB-5.24.3 | booking_step_contact — public | Fill in contact/pet form, click "Review booking" | `booking_step_contact` fires with detail.step="contact" and detail.flow="public" |
| TC-WEB-5.24.4 | booking_step_submit — public | Confirm and submit the booking | `booking_step_submit` fires with detail.step="submit" and detail.flow="public" |
| TC-WEB-5.24.5 | booking_confirmed — public | Navigate to /booking-confirmed | `booking_confirmed` fires once on mount with detail.step="confirmed" and detail.flow="public" |
| TC-WEB-5.24.6 | booking_error — public | Navigate to /booking-error | `booking_error` fires once on mount with detail.step="error" and detail.flow="public" |
| TC-WEB-5.24.7 | booking_step_service — portal | Select a pet in the portal BookingFlow | `booking_step_service` fires with detail.step="service" and detail.flow="portal" |
| TC-WEB-5.24.8 | booking_step_time — portal | Pick a date and time in portal BookingFlow | `booking_step_time` fires with detail.step="time" and detail.flow="portal" |
| TC-WEB-5.24.9 | booking_step_contact — portal | Proceed from groomer selection to review screen | `booking_step_contact` fires with detail.step="groomer" and detail.flow="portal" |
| TC-WEB-5.24.10 | booking_step_submit — portal | Submit booking in portal BookingFlow | `booking_step_submit` fires with detail.step="submit" and detail.flow="portal" |
| TC-WEB-5.24.11 | booking_confirmed — portal | Portal booking request succeeds | Inline success state is shown and `booking_confirmed` fires with detail.step="confirmed" and detail.flow="portal" |
| TC-WEB-5.24.12 | No PII in analytics payloads | Fire each event and inspect detail object | Payload contains only: step, flow, timestamp — no names, emails, phone numbers, or pet names |
| TC-WEB-5.24.13 | No-op safe | Trigger analytics with window.dispatchEvent blocked (e.g. CSP) | No error thrown; booking flow completes normally |
### 5.25 Customer Portal — Better Auth SSO Bridge (GRO-1867)
These cases cover the `CustomerPortal` initialisation path that bridges an Authentik / Better Auth session into a portal session via `POST /api/portal/session-from-auth`. The bridge runs after the URL-impersonation (`?sessionId=`) and dev-user paths have been ruled out.
**Pre-conditions:**
- UAT is configured with Authentik SSO and the `seed-uat-passwords` Secret in `groombook-uat` provides the seeded customer credentials (`uat-seed-password-source` memory).
- `POST /api/portal/session-from-auth` from [GRO-1866](https://paperclip.farhoodlabs.com/GRO/issues/GRO-1866) is deployed on UAT.
- Clear cookies and localStorage between cases unless otherwise noted.
| # | Scenario | Steps | Expected |
|---|----------|-------|----------|
| TC-WEB-5.25.1 | Authenticated customer reaches portal dashboard | 1. From clean state, navigate to UAT `/login`. 2. Click "Sign in with SSO" and complete Authentik flow with a seeded **customer** identity. 3. After callback, land on `/`. | Portal dashboard renders. No redirect to `/login`. No impersonation banner. Top-right greeting reads "Hi, &lt;FirstName&gt;". |
| TC-WEB-5.25.2 | Bridge call sequence | Repeat TC-WEB-5.25.1 with DevTools → Network open and the **All** tab filtered to `/api/`. | In order: `GET /api/auth/get-session` → 200. `POST /api/portal/session-from-auth` → 201 with body `{ sessionId, clientId, clientName }`. |
| TC-WEB-5.25.3 | Subsequent portal calls use the bridged session ID | After TC-WEB-5.25.1 succeeds, navigate to **Appointments**, **My Pets**, **Billing**, **Settings**. Inspect any `/api/portal/*` request in DevTools → Network. | Each portal API call carries an `X-Impersonation-Session-Id` header whose value equals the `sessionId` returned by `session-from-auth` (not a URL-param value). Each call returns 200 (or 404 for genuinely empty collections), never 401. |
| TC-WEB-5.25.4 | No impersonation chrome for the customer's own session | After TC-WEB-5.25.1, scan the portal UI. | No amber border around the page. No "STAFF VIEW" watermark. No "End Impersonation" button in the sidebar. The customer is themselves; only impersonation sessions started via `?sessionId=` show the banner. |
| TC-WEB-5.25.5 | 404 fallback for authenticated user with no client record | 1. Sign in via SSO with an Authentik account whose email is **not** present in `clients`. 2. Land on `/`. | `POST /api/portal/session-from-auth` returns 404. The portal renders a centred card titled **"Portal access not configured"** with the message about contacting the groomer and a **Sign out** button. No redirect loop, no portal chrome. |
| TC-WEB-5.25.6 | 404 fallback Sign-out escape hatch | From TC-WEB-5.25.5 click **Sign out**. | `POST /api/auth/sign-out` fires; browser navigates to `/login`; the Authentik session cookie is cleared. Reloading `/` no longer hits 404 (will show the login page). |
| TC-WEB-5.25.7 | Bridge precedence — impersonation URL wins | 1. Sign in via SSO as a customer. 2. Open a new tab to `https://uat.groombook.dev/?sessionId=<a-valid-staff-impersonation-session-id>`. | The impersonation path runs; the amber banner appears for the impersonated client. The Better Auth bridge is **not** called on this load (`session-from-auth` absent in Network). |
| TC-WEB-5.25.8 | Bridge precedence — dev user wins | In dev mode (e.g. local) with `localStorage["dev-user"]` set to a client persona, navigate to `/`. | The dev-session path runs (`POST /api/portal/dev-session`). The Better Auth bridge is **not** called (`session-from-auth` absent in Network). Staff dev users still redirect to `/admin`. |
| TC-WEB-5.25.9 | Staff Better Auth session does not run the customer bridge | Sign in via SSO with a staff identity. Navigate to `/`. | `App.tsx` routing redirects to `/admin`. `POST /api/portal/session-from-auth` is **not** called. |
| TC-WEB-5.25.10 | Unauthenticated user is sent to login (no infinite loop) | Without signing in, navigate directly to `/`. | `App.tsx` renders the LoginPage. `CustomerPortal` does not render. No `session-from-auth` request is made. |
| TC-WEB-5.25.11 | Session persists across reload via Better Auth cookie | After TC-WEB-5.25.1 succeeds, reload the page. | Portal dashboard re-renders. A fresh `GET /api/auth/get-session` + `POST /api/portal/session-from-auth` pair runs and yields 200/201. Greeting still reads "Hi, &lt;FirstName&gt;". |
## 6. Pass/Fail Criteria
**Pass:**
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 239 KiB

+21
View File
@@ -226,3 +226,24 @@ 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;
}
@@ -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>
@@ -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>
@@ -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>
@@ -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>
@@ -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>
@@ -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>
@@ -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>
@@ -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>
-10
View File
@@ -1,10 +0,0 @@
{
"$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"}
]
}
+5 -11
View File
@@ -327,16 +327,11 @@ export function App() {
.catch(() => setAuthDisabled(false));
}, []);
// After session is confirmed, check if setup is needed.
// Always run the setup/status fetch as soon as the auth state is known — even for
// unauthenticated users, so the `needsSetup` value is in place if they sign in
// mid-session. The unauth branch in the render below is handled before
// `needsSetup` is consulted, so this is safe and avoids a stuck-`null` state.
// See GRO-2011.
// After session is confirmed, check if setup is needed
useEffect(() => {
if (authDisabled === null || sessionLoading) return;
// In dev mode, only fetch when a dev user has been selected — otherwise the
// user is mid-redirect to the dev login selector and we don't need setup state.
// Skip if no authenticated session (will redirect to login or dev selector)
if (!authDisabled && !session) return;
if (authDisabled && !getDevUser()) return;
fetch("/api/setup/status")
@@ -391,10 +386,9 @@ export function App() {
return <Navigate to="/setup" replace />;
}
// Redirect staff to /admin; allow customers to access portal (preserve impersonation via ?sessionId=)
// Redirect authenticated users to /admin (but preserve impersonation flow via ?sessionId=)
const searchParams = new URLSearchParams(location.search);
const isStaff = session?.user && (session.user as any).role === "staff";
if (!authDisabled && session && !location.pathname.startsWith("/admin") && !searchParams.has("sessionId") && isStaff) {
if (!authDisabled && session && !location.pathname.startsWith("/admin") && !searchParams.has("sessionId")) {
return <Navigate to="/admin" replace />;
}
-59
View File
@@ -121,65 +121,6 @@ describe("App navigation", () => {
});
});
describe("GRO-2011 — setup/status fetch for unauthenticated users", () => {
it("calls /api/setup/status for unauthenticated users so needsSetup is never stuck null", async () => {
const setupStatusCalls: string[] = [];
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/auth/get-session") {
// Better Auth returns 200 with null session for unauthenticated users.
return Promise.resolve({
ok: true,
json: async () => null,
} as unknown as Response);
}
if (url === "/api/setup/status") {
setupStatusCalls.push(url);
return Promise.resolve({
ok: true,
json: async () => ({ needsSetup: 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;
render(
<MemoryRouter initialEntries={["/login"]}>
<App />
</MemoryRouter>
);
// The login page should be rendered for the unauthenticated user.
await screen.findByText("Sign in to continue");
// Crucially, /api/setup/status must be called even when the user is unauthenticated —
// otherwise `needsSetup` stays null and a later code path can short-circuit to a
// blank page (GRO-2011).
await waitFor(() => {
expect(setupStatusCalls.length).toBeGreaterThanOrEqual(1);
});
expect(setupStatusCalls[0]).toBe("/api/setup/status");
});
});
describe("Dev login selector", () => {
it("redirects to /login when auth is disabled and no user selected", async () => {
global.fetch = vi.fn((url: string) => {
+1 -199
View File
@@ -1,6 +1,6 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { render, screen, fireEvent, waitFor } from "@testing-library/react";
import { parseTimeTo24Hour, isUpcoming, CustomerNotesSection, ConfirmationSection, StatusBadge } from "../portal/sections/Appointments.tsx";
import { parseTimeTo24Hour, isUpcoming, CustomerNotesSection, ConfirmationSection } from "../portal/sections/Appointments.tsx";
const UPCOMING_APPT = {
id: "appt-1",
@@ -379,202 +379,4 @@ describe("ConfirmationSection", () => {
expect(screen.getByText(/Confirmed!/i)).toBeInTheDocument();
});
});
});
describe("StatusBadge", () => {
it("renders Confirmed for confirmed status", () => {
render(<StatusBadge status="confirmed" />);
expect(screen.getByText("Confirmed")).toBeInTheDocument();
});
it("renders Pending for pending status", () => {
render(<StatusBadge status="pending" />);
expect(screen.getByText("Pending")).toBeInTheDocument();
});
it("renders Waitlisted for waitlisted status", () => {
render(<StatusBadge status="waitlisted" />);
expect(screen.getByText("Waitlisted")).toBeInTheDocument();
});
it("renders Completed for completed status", () => {
render(<StatusBadge status="completed" />);
expect(screen.getByText("Completed")).toBeInTheDocument();
});
it("renders Cancelled for cancelled status", () => {
render(<StatusBadge status="cancelled" />);
expect(screen.getByText("Cancelled")).toBeInTheDocument();
});
it("falls back to status string for unknown status", () => {
render(<StatusBadge status="custom-status" />);
expect(screen.getByText("custom-status")).toBeInTheDocument();
});
it("uses correct CSS class for confirmed status", () => {
render(<StatusBadge status="confirmed" />);
const badge = screen.getByText("Confirmed").closest('span');
expect(badge?.className).toContain("bg-green-100");
expect(badge?.className).toContain("text-green-700");
});
it("uses correct CSS class for waitlisted status", () => {
render(<StatusBadge status="waitlisted" />);
const badge = screen.getByText("Waitlisted").closest('span');
expect(badge?.className).toContain("bg-blue-100");
expect(badge?.className).toContain("text-blue-600");
});
it("uses correct CSS class for pending status", () => {
render(<StatusBadge status="pending" />);
const badge = screen.getByText("Pending").closest('span');
expect(badge?.className).toContain("bg-amber-100");
expect(badge?.className).toContain("text-amber-600");
});
it("uses fallback styling for unknown status", () => {
render(<StatusBadge status="unknown" />);
const badge = screen.getByText("unknown").closest('span');
expect(badge?.className).toContain("bg-stone-100");
expect(badge?.className).toContain("text-stone-600");
});
});
describe("RescheduleFlow dynamic time slots", () => {
beforeEach(() => {
vi.clearAllMocks();
global.fetch = vi.fn();
});
const RESCHEDULE_APPT = {
id: "appt-r1",
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: "confirmed" as const,
};
it("shows loading state while fetching availability", async () => {
vi.mocked(global.fetch).mockReturnValue(new Promise(() => {})); // Never resolves
const { RescheduleFlow } = await import("../portal/sections/Appointments.tsx");
render(<RescheduleFlow appointment={RESCHEDULE_APPT} onClose={() => {}} sessionId="test-session-id" />);
const dateInput = screen.getByLabelText(/date/i) || screen.getByRole("textbox", { name: /date/i });
fireEvent.change(dateInput, { target: { value: "2027-01-15" } });
await waitFor(() => {
expect(screen.getByText(/Checking availability/i)).toBeInTheDocument();
});
});
it("displays fetched time slots from API", async () => {
vi.mocked(global.fetch).mockResolvedValue({
ok: true,
json: async () => ["9:00 AM", "10:00 AM", "2:00 PM"],
} as Response);
const { RescheduleFlow } = await import("../portal/sections/Appointments.tsx");
render(<RescheduleFlow appointment={RESCHEDULE_APPT} onClose={() => {}} sessionId="test-session-id" />);
const dateInput = screen.getByLabelText(/date/i) || screen.getByRole("textbox", { name: /date/i });
fireEvent.change(dateInput, { target: { value: "2027-01-15" } });
await waitFor(() => {
expect(screen.getByText("9:00 AM")).toBeInTheDocument();
expect(screen.getByText("10:00 AM")).toBeInTheDocument();
expect(screen.getByText("2:00 PM")).toBeInTheDocument();
});
});
it("shows error state when availability fetch fails", async () => {
vi.mocked(global.fetch).mockRejectedValue(new Error("Network error"));
const { RescheduleFlow } = await import("../portal/sections/Appointments.tsx");
render(<RescheduleFlow appointment={RESCHEDULE_APPT} onClose={() => {}} sessionId="test-session-id" />);
const dateInput = screen.getByLabelText(/date/i) || screen.getByRole("textbox", { name: /date/i });
fireEvent.change(dateInput, { target: { value: "2027-01-15" } });
await waitFor(() => {
expect(screen.getByText(/Failed to load time slots/i)).toBeInTheDocument();
});
});
it("shows no slots message when API returns empty array", async () => {
vi.mocked(global.fetch).mockResolvedValue({
ok: true,
json: async () => [] as string[],
} as Response);
const { RescheduleFlow } = await import("../portal/sections/Appointments.tsx");
render(<RescheduleFlow appointment={RESCHEDULE_APPT} onClose={() => {}} sessionId="test-session-id" />);
const dateInput = screen.getByLabelText(/date/i) || screen.getByRole("textbox", { name: /date/i });
fireEvent.change(dateInput, { target: { value: "2027-01-15" } });
await waitFor(() => {
expect(screen.getByText(/No available slots on this date/i)).toBeInTheDocument();
});
});
it("calls /api/book/availability with the selected date", async () => {
vi.mocked(global.fetch).mockResolvedValue({
ok: true,
json: async () => ["9:00 AM"] as string[],
} as Response);
const { RescheduleFlow } = await import("../portal/sections/Appointments.tsx");
render(<RescheduleFlow appointment={RESCHEDULE_APPT} onClose={() => {}} sessionId="test-session-id" />);
const dateInput = screen.getByLabelText(/date/i) || screen.getByRole("textbox", { name: /date/i });
fireEvent.change(dateInput, { target: { value: "2027-02-20" } });
await waitFor(() => {
expect(global.fetch).toHaveBeenCalledWith(
"/api/book/availability?date=2027-02-20",
expect.objectContaining({
headers: expect.objectContaining({ "X-Impersonation-Session-Id": "test-session-id" }),
})
);
});
});
it("re-fetches slots when date changes", async () => {
vi.mocked(global.fetch)
.mockResolvedValueOnce({
ok: true,
json: async () => ["9:00 AM"] as string[],
} as Response)
.mockResolvedValueOnce({
ok: true,
json: async () => ["11:00 AM", "1:00 PM"] as string[],
} as Response);
const { RescheduleFlow } = await import("../portal/sections/Appointments.tsx");
render(<RescheduleFlow appointment={RESCHEDULE_APPT} onClose={() => {}} sessionId="test-session-id" />);
const dateInput = screen.getByLabelText(/date/i) || screen.getByRole("textbox", { name: /date/i });
fireEvent.change(dateInput, { target: { value: "2027-01-10" } });
await waitFor(() => expect(screen.getByText("9:00 AM")).toBeInTheDocument());
fireEvent.change(dateInput, { target: { value: "2027-01-15" } });
await waitFor(() => {
expect(screen.getByText("11:00 AM")).toBeInTheDocument();
expect(screen.getByText("1:00 PM")).toBeInTheDocument();
});
});
});
-27
View File
@@ -1,27 +0,0 @@
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
@@ -1,38 +0,0 @@
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(new RegExp(BUSINESS_CONTACT_INFO.phone.replace(/[()]/g, "\\$&")))).toBeInTheDocument();
});
it("displays business contact email", () => {
render(<BookingErrorPage />);
expect(screen.getByText(new RegExp(BUSINESS_CONTACT_INFO.email))).toBeInTheDocument();
});
});
+157
View File
@@ -0,0 +1,157 @@
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");
});
});
-83
View File
@@ -1,83 +0,0 @@
import { describe, it, expect, vi } from "vitest";
import { ANALYTICS_EVENTS, fireAnalyticsEvent } from "../lib/analytics";
describe("analytics", () => {
describe("ANALYTICS_EVENTS constants", () => {
it("exports all required event names", () => {
expect(ANALYTICS_EVENTS.BOOKING_STEP_SERVICE).toBe("booking_step_service");
expect(ANALYTICS_EVENTS.BOOKING_STEP_TIME).toBe("booking_step_time");
expect(ANALYTICS_EVENTS.BOOKING_STEP_CONTACT).toBe("booking_step_contact");
expect(ANALYTICS_EVENTS.BOOKING_STEP_SUBMIT).toBe("booking_step_submit");
expect(ANALYTICS_EVENTS.BOOKING_CONFIRMED).toBe("booking_confirmed");
expect(ANALYTICS_EVENTS.BOOKING_ERROR).toBe("booking_error");
});
it("has no duplicate event names", () => {
const values = Object.values(ANALYTICS_EVENTS);
const unique = new Set(values);
expect(unique.size).toBe(values.length);
});
});
describe("fireAnalyticsEvent", () => {
it("dispatches a CustomEvent with the correct event name", () => {
const listener = vi.fn();
window.addEventListener(ANALYTICS_EVENTS.BOOKING_STEP_SERVICE, listener);
fireAnalyticsEvent(ANALYTICS_EVENTS.BOOKING_STEP_SERVICE, { step: "service", flow: "public" });
expect(listener).toHaveBeenCalledTimes(1);
const event = listener.mock.calls[0]![0] as CustomEvent;
expect(event.type).toBe("booking_step_service");
expect(event.detail.step).toBe("service");
expect(event.detail.flow).toBe("public");
expect(event.detail.timestamp).toBeDefined();
window.removeEventListener(ANALYTICS_EVENTS.BOOKING_STEP_SERVICE, listener);
});
it("includes a timestamp in the event detail", () => {
const listener = vi.fn();
window.addEventListener(ANALYTICS_EVENTS.BOOKING_CONFIRMED, listener);
fireAnalyticsEvent(ANALYTICS_EVENTS.BOOKING_CONFIRMED, { step: "confirmed", flow: "public" });
const event = listener.mock.calls[0]![0] as CustomEvent;
expect(event.detail.timestamp).toBeTruthy();
expect(new Date(event.detail.timestamp as string)).toBeInstanceOf(Date);
window.removeEventListener(ANALYTICS_EVENTS.BOOKING_CONFIRMED, listener);
});
it("does not throw when called with no payload", () => {
expect(() => {
fireAnalyticsEvent(ANALYTICS_EVENTS.BOOKING_ERROR, {});
}).not.toThrow();
});
it("does not throw when window.dispatchEvent throws", () => {
const original = window.dispatchEvent;
window.dispatchEvent = () => {
throw new Error("analytics blocked");
};
expect(() => {
fireAnalyticsEvent(ANALYTICS_EVENTS.BOOKING_STEP_SUBMIT, { step: "submit", flow: "public" });
}).not.toThrow();
window.dispatchEvent = original;
});
it("fires events for all event types", () => {
const events = Object.values(ANALYTICS_EVENTS);
for (const eventName of events) {
const listener = vi.fn();
window.addEventListener(eventName, listener);
fireAnalyticsEvent(eventName as typeof events[number], { step: "test", flow: "public" });
expect(listener).toHaveBeenCalledTimes(1);
window.removeEventListener(eventName, listener);
}
});
it("does not include PII in payload", () => {
// Payload only contains step, flow, and timestamp — no names, emails, or phones
const payload = { step: "contact", flow: "public" };
const keys = Object.keys(payload);
const piish = ["name", "email", "phone", "clientName", "clientEmail", "clientPhone", "petName"];
const hasPII = piish.some((k) => keys.includes(k));
expect(hasPII).toBe(false);
});
});
});
-161
View File
@@ -313,164 +313,3 @@ describe("CustomerPortal session loading", () => {
Object.defineProperty(window, "location", { value: originalLocation, writable: true });
});
});
// ─── CustomerPortal — Better Auth SSO bridge (GRO-1867) ────────────────────
describe("CustomerPortal SSO bridge", () => {
beforeEach(() => {
// Make sure no dev-user leaks across tests
window.localStorage.clear();
});
const brandingResponse = {
ok: true,
json: async () => ({
businessName: "GroomBook",
primaryColor: "#4f8a6f",
accentColor: "#8b7355",
logoBase64: null,
logoMimeType: null,
}),
} as Response;
it("bridges Better Auth session via /api/portal/session-from-auth and uses returned sessionId", async () => {
global.fetch = vi.fn((input: RequestInfo, init?: RequestInit) => {
const url = typeof input === "string" ? input : input.toString();
if (url === "/api/branding") return Promise.resolve(brandingResponse);
if (url === "/api/auth/get-session") {
return Promise.resolve({
ok: true,
json: async () => ({ user: { email: "customer@example.com", role: "customer" } }),
} as Response);
}
if (url === "/api/portal/session-from-auth" && init?.method === "POST") {
return Promise.resolve({
ok: true,
status: 201,
json: async () => ({ sessionId: "sso-sess-1", clientId: "client-1", clientName: "Jane Doe" }),
} as Response);
}
// Subsequent portal API calls — surface them so we can assert the header
return Promise.resolve({ ok: true, json: async () => ({}) } as Response);
}) as unknown as typeof fetch;
const { CustomerPortal } = await import("../portal/CustomerPortal.js");
render(
<MemoryRouter initialEntries={["/"]}>
<CustomerPortal />
</MemoryRouter>
);
await waitFor(() => {
expect(global.fetch).toHaveBeenCalledWith("/api/auth/get-session", expect.objectContaining({ credentials: "include" }));
});
await waitFor(() => {
expect(global.fetch).toHaveBeenCalledWith(
"/api/portal/session-from-auth",
expect.objectContaining({ method: "POST", credentials: "include" })
);
});
// Client greeting reflects the bridged customer name (proof the response was consumed)
await waitFor(() => {
expect(screen.getByText(/Hi, Jane/)).toBeInTheDocument();
});
// The impersonation banner must NOT appear — this is the customer themselves
expect(screen.queryByRole("button", { name: /End Session/i })).not.toBeInTheDocument();
});
it("shows a friendly fallback when session-from-auth returns 404 (no client record)", async () => {
global.fetch = vi.fn((input: RequestInfo) => {
const url = typeof input === "string" ? input : input.toString();
if (url === "/api/branding") return Promise.resolve(brandingResponse);
if (url === "/api/auth/get-session") {
return Promise.resolve({
ok: true,
json: async () => ({ user: { email: "stranger@example.com", role: "customer" } }),
} as Response);
}
if (url === "/api/portal/session-from-auth") {
return Promise.resolve({
ok: false,
status: 404,
json: async () => ({ error: "No client record found for this user" }),
} as Response);
}
return Promise.resolve({ ok: true, json: async () => ({}) } as Response);
}) as unknown as typeof fetch;
const { CustomerPortal } = await import("../portal/CustomerPortal.js");
render(
<MemoryRouter initialEntries={["/"]}>
<CustomerPortal />
</MemoryRouter>
);
await waitFor(() => {
expect(screen.getByText(/Portal access not configured/i)).toBeInTheDocument();
});
expect(screen.getByText(/not linked to a customer record/i)).toBeInTheDocument();
// Sign-out escape hatch is present so the user is not stuck in a loop
expect(screen.getByRole("button", { name: /Sign out/i })).toBeInTheDocument();
});
it("does not call session-from-auth when there is no Better Auth session", async () => {
global.fetch = vi.fn((input: RequestInfo) => {
const url = typeof input === "string" ? input : input.toString();
if (url === "/api/branding") return Promise.resolve(brandingResponse);
if (url === "/api/auth/get-session") {
return Promise.resolve({
ok: true,
json: async () => null,
} as Response);
}
return Promise.resolve({ ok: true, json: async () => ({}) } as Response);
}) as unknown as typeof fetch;
const { CustomerPortal } = await import("../portal/CustomerPortal.js");
render(
<MemoryRouter initialEntries={["/"]}>
<CustomerPortal />
</MemoryRouter>
);
await waitFor(() => {
expect(global.fetch).toHaveBeenCalledWith("/api/auth/get-session", expect.objectContaining({ credentials: "include" }));
});
// Wait one tick to ensure no subsequent bridge call is queued
await new Promise((r) => setTimeout(r, 30));
const bridgeCalls = vi.mocked(global.fetch).mock.calls.filter(
([u]) => typeof u === "string" && u === "/api/portal/session-from-auth"
);
expect(bridgeCalls).toHaveLength(0);
});
it("skips the bridge for staff Better Auth sessions", async () => {
global.fetch = vi.fn((input: RequestInfo) => {
const url = typeof input === "string" ? input : input.toString();
if (url === "/api/branding") return Promise.resolve(brandingResponse);
if (url === "/api/auth/get-session") {
return Promise.resolve({
ok: true,
json: async () => ({ user: { email: "staff@example.com", role: "staff" } }),
} as Response);
}
return Promise.resolve({ ok: true, json: async () => ({}) } as Response);
}) as unknown as typeof fetch;
const { CustomerPortal } = await import("../portal/CustomerPortal.js");
render(
<MemoryRouter initialEntries={["/"]}>
<CustomerPortal />
</MemoryRouter>
);
await waitFor(() => {
expect(global.fetch).toHaveBeenCalledWith("/api/auth/get-session", expect.objectContaining({ credentials: "include" }));
});
await new Promise((r) => setTimeout(r, 30));
const bridgeCalls = vi.mocked(global.fetch).mock.calls.filter(
([u]) => typeof u === "string" && u === "/api/portal/session-from-auth"
);
expect(bridgeCalls).toHaveLength(0);
});
});
+238
View File
@@ -0,0 +1,238 @@
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,19 +8,6 @@
--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 {
-40
View File
@@ -1,40 +0,0 @@
// Analytics event names — single source of truth
export const ANALYTICS_EVENTS = {
BOOKING_STEP_SERVICE: "booking_step_service",
BOOKING_STEP_TIME: "booking_step_time",
BOOKING_STEP_CONTACT: "booking_step_contact",
BOOKING_STEP_SUBMIT: "booking_step_submit",
BOOKING_CONFIRMED: "booking_confirmed",
BOOKING_ERROR: "booking_error",
} as const;
export type AnalyticsEventName = (typeof ANALYTICS_EVENTS)[keyof typeof ANALYTICS_EVENTS];
export type AnalyticsPayload = {
step?: string;
flow?: "public" | "portal";
[key: string]: string | undefined;
};
/**
* Fires a lightweight analytics event via window.dispatchEvent.
* No-op safe: failures are swallowed so analytics never breaks the booking flow.
* Designed for later Plausible/GTM integration.
*/
export function fireAnalyticsEvent(
eventName: AnalyticsEventName,
payload: AnalyticsPayload = {}
): void {
try {
window.dispatchEvent(
new CustomEvent(eventName, {
detail: {
...payload,
timestamp: new Date().toISOString(),
},
})
);
} catch {
// no-op: analytics must never break the booking flow
}
}
+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 || (typeof window !== "undefined" ? window.location.origin : ""),
baseURL: import.meta.env.VITE_API_URL ?? "",
});
export const { signIn, signOut, useSession, changePassword } = authClient;
-7
View File
@@ -1,7 +0,0 @@
// 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;
+9 -1
View File
@@ -1,5 +1,8 @@
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 ────────────────────────────────────────────────────────────────
@@ -113,6 +116,7 @@ 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);
@@ -510,7 +514,10 @@ export function AppointmentsPage() {
<Field label="Pet">
<select
value={form.petId}
onChange={(e) => setForm((f) => ({ ...f, petId: e.target.value }))}
onChange={(e) => {
setForm((f) => ({ ...f, petId: e.target.value }));
setSelectedPetId(e.target.value || null);
}}
required
disabled={!form.clientId}
style={inputStyle}
@@ -521,6 +528,7 @@ export function AppointmentsPage() {
))}
</select>
</Field>
{form.petId && <PetProfileCard petId={form.petId} />}
<Field label="Service">
<select
value={form.serviceId}
+1 -6
View File
@@ -1,7 +1,6 @@
import { useEffect, useState } from "react";
import { useSearchParams } from "react-router-dom";
import type { Service } from "@groombook/types";
import { ANALYTICS_EVENTS, fireAnalyticsEvent } from "../lib/analytics";
// ─── Types ───────────────────────────────────────────────────────────────────
@@ -194,14 +193,12 @@ export function BookPage() {
setSelectedService(svc);
setForm((f) => ({ ...f, serviceId: svc.id }));
setStep(2);
fireAnalyticsEvent(ANALYTICS_EVENTS.BOOKING_STEP_SERVICE, { step: "service", flow: "public" });
}
function goToStep3() {
if (!selectedSlot) return;
setForm((f) => ({ ...f, startTime: selectedSlot }));
setStep(3);
fireAnalyticsEvent(ANALYTICS_EVENTS.BOOKING_STEP_TIME, { step: "time", flow: "public" });
}
function goToStep4() {
@@ -211,7 +208,6 @@ export function BookPage() {
}
setFormError(null);
setStep(4);
fireAnalyticsEvent(ANALYTICS_EVENTS.BOOKING_STEP_CONTACT, { step: "contact", flow: "public" });
}
async function submitBooking() {
@@ -240,7 +236,6 @@ export function BookPage() {
throw new Error(body.error ?? `HTTP ${res.status}`);
}
const data = (await res.json()) as BookingResult;
fireAnalyticsEvent(ANALYTICS_EVENTS.BOOKING_STEP_SUBMIT, { step: "submit", flow: "public" });
setResult(data);
setStep(5);
} catch (e: unknown) {
@@ -524,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="xlarge">X-Large (over 80 lbs)</option>
<option value="x-large">X-Large (over 80 lbs)</option>
</select>
</div>
<div>
+22 -46
View File
@@ -1,10 +1,3 @@
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
@@ -14,12 +7,12 @@ export function BookingCancelledPage() {
alignItems: "center",
justifyContent: "center",
fontFamily: "system-ui, sans-serif",
background: "var(--color-cancelled-bg)",
background: "#fff7ed",
}}
>
<div
style={{
background: "var(--color-surface)",
background: "#fff",
borderRadius: 12,
padding: "2.5rem 3rem",
boxShadow: "0 4px 24px rgba(0,0,0,0.08)",
@@ -28,45 +21,28 @@ export function BookingCancelledPage() {
}}
>
<div style={{ fontSize: 56, marginBottom: "0.5rem" }}></div>
<h1 style={{ color: "var(--color-cancelled-dark)", fontSize: 24, margin: "0 0 0.5rem" }}>
{STRINGS.heading}
<h1 style={{ color: "#c2410c", fontSize: 24, margin: "0 0 0.5rem" }}>
Appointment Cancelled
</h1>
<p style={{ color: "var(--color-text-secondary)", margin: "0 0 1.5rem" }}>
{STRINGS.body}
<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>
<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>
<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>
</div>
);
-7
View File
@@ -1,11 +1,4 @@
import { useEffect } from "react";
import { ANALYTICS_EVENTS, fireAnalyticsEvent } from "../lib/analytics";
export function BookingConfirmedPage() {
useEffect(() => {
fireAnalyticsEvent(ANALYTICS_EVENTS.BOOKING_CONFIRMED, { step: "confirmed", flow: "public" });
}, []);
return (
<div
style={{
+22 -62
View File
@@ -1,20 +1,4 @@
import { useEffect } from "react";
import { BUSINESS_CONTACT_INFO } from "../lib/contact";
import { ANALYTICS_EVENTS, fireAnalyticsEvent } from "../lib/analytics";
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() {
useEffect(() => {
fireAnalyticsEvent(ANALYTICS_EVENTS.BOOKING_ERROR, { step: "error", flow: "public" });
}, []);
return (
<div
style={{
@@ -23,12 +7,12 @@ export function BookingErrorPage() {
alignItems: "center",
justifyContent: "center",
fontFamily: "system-ui, sans-serif",
background: "var(--color-error-bg)",
background: "#fef2f2",
}}
>
<div
style={{
background: "var(--color-surface)",
background: "#fff",
borderRadius: 12,
padding: "2.5rem 3rem",
boxShadow: "0 4px 24px rgba(0,0,0,0.08)",
@@ -37,52 +21,28 @@ export function BookingErrorPage() {
}}
>
<div style={{ fontSize: 56, marginBottom: "0.5rem" }}></div>
<h1 style={{ color: "var(--color-error-dark)", fontSize: 24, margin: "0 0 0.5rem" }}>
{STRINGS.heading}
<h1 style={{ color: "#b91c1c", fontSize: 24, margin: "0 0 0.5rem" }}>
Link Invalid or Expired
</h1>
<p style={{ color: "var(--color-text-secondary)", margin: "0 0 1.5rem" }}>
{STRINGS.body}
<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>
<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>
<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>
</div>
);
+96 -84
View File
@@ -3,6 +3,7 @@ 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 }>();
@@ -13,6 +14,7 @@ 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 }));
@@ -136,97 +138,107 @@ export function ClientDetailPage() {
) : (
<div style={{ display: "grid", gridTemplateColumns: "repeat(auto-fill, minmax(260px, 1fr))", gap: "0.75rem" }}>
{pets.map((p) => (
<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 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>
</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>
)}
{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 (
{/* Grooming preferences */}
{(p.cutStyle || p.shampooPreference || p.specialCareNotes || p.groomingNotes) && (
<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>
)}
</>
{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} />
)}
</div>
))}
</div>
+4 -100
View File
@@ -43,15 +43,6 @@ export function CustomerPortal() {
// Track whether an impersonation session fetch from URL param is in-flight
// Dashboard will not redirect while this is true, allowing the session to load
const [isImpersonating, setIsImpersonating] = useState(false);
// Portal session ID for real SSO customers (GRO-1867). Populated by the
// Better Auth → /api/portal/session-from-auth bridge below. Carries the
// X-Impersonation-Session-Id header on subsequent portal API calls without
// triggering the impersonation banner (the customer is themselves).
const [portalSessionId, setPortalSessionId] = useState<string | null>(null);
// User-facing message when the SSO bridge cannot resolve a client record
// (e.g. authenticated user with no matching client row). Rendered in place
// of the portal chrome instead of bouncing back to /login.
const [authError, setAuthError] = useState<string | null>(null);
const { branding } = useBranding();
const [searchParams, setSearchParams] = useSearchParams();
@@ -107,64 +98,10 @@ export function CustomerPortal() {
}
})
.finally(() => setInitComplete(true));
return;
}
if (devUser && devUser.type === "staff") {
// Staff dev user — fall through; App.tsx redirects to /admin.
} else {
// No valid session: staff dev users and unauthenticated users fall through here
setInitComplete(true);
return;
}
// Real SSO customer (GRO-1867): bridge a Better Auth session into a portal
// session via POST /api/portal/session-from-auth. The returned session ID
// is used in the X-Impersonation-Session-Id header for portal API calls.
(async () => {
try {
const sessionResp = await fetch("/api/auth/get-session", { credentials: "include" });
if (!sessionResp.ok) {
setInitComplete(true);
return;
}
let sessionData: { user?: { email?: string; role?: string | null } } | null = null;
try {
sessionData = (await sessionResp.json()) as { user?: { email?: string; role?: string | null } } | null;
} catch {
// Better Auth returns an empty body when there is no session
}
if (!sessionData || !sessionData.user) {
setInitComplete(true);
return;
}
// Staff are routed to /admin by App.tsx; don't run the customer bridge.
if (sessionData.user.role === "staff") {
setInitComplete(true);
return;
}
const bridgeResp = await fetch("/api/portal/session-from-auth", {
method: "POST",
credentials: "include",
});
if (bridgeResp.ok) {
const data = await bridgeResp.json() as { sessionId: string; clientId: string; clientName: string };
setPortalSessionId(data.sessionId);
setClientName(data.clientName);
} else if (bridgeResp.status === 404) {
// Authenticated but no matching client row — show a friendly message
// instead of bouncing back to /login (which would loop indefinitely).
setAuthError(
"Your account is not linked to a customer record. Please contact your groomer to set up portal access."
);
}
// 401/other: fall through; App.tsx render guard will redirect to /login.
} catch {
// Network error — fall through; the render guard will redirect to /login.
} finally {
setInitComplete(true);
}
})();
}, []);
const handleEnd = useCallback(async () => {
@@ -220,7 +157,7 @@ export function CustomerPortal() {
const isReadOnly = session?.status === "active";
const renderSection = () => {
const sessionId = session?.id ?? portalSessionId;
const sessionId = session?.id ?? null;
switch (activeSection) {
case "dashboard":
return <Dashboard onNavigate={handleNavClick} readOnly={!!isReadOnly} sessionId={sessionId} clientName={clientName} onReschedule={handleReschedule} isImpersonating={isImpersonating} />;
@@ -246,40 +183,7 @@ export function CustomerPortal() {
// For client dev users, we stay on the portal even if session is null — the dev-session
// response may not have id set immediately, or there may be timing issues with the
// session state. Dev users are verified via localStorage and the dev-session flow.
// SSO customers are recognised by portalSessionId (set by the Better Auth bridge).
if (initComplete && !session && !portalSessionId) {
if (authError) {
// GRO-1867: graceful 404 fallback — authenticated user has no client row.
return (
<div
className="min-h-screen flex items-center justify-center bg-[#faf8f5] font-sans px-6"
role="alert"
aria-live="polite"
>
<div className="max-w-md w-full bg-white rounded-xl shadow-sm border border-stone-200 p-8 text-center">
<div className="w-12 h-12 rounded-full bg-amber-100 text-amber-700 flex items-center justify-center mx-auto mb-4">
<Shield size={22} />
</div>
<h1 className="text-lg font-semibold text-stone-800 mb-2">Portal access not configured</h1>
<p className="text-sm text-stone-600 mb-6">{authError}</p>
<button
onClick={async () => {
try {
await fetch("/api/auth/sign-out", { method: "POST", credentials: "include" });
} catch {
// Best-effort sign-out; redirect to /login regardless.
}
window.location.href = "/login";
}}
className="inline-flex items-center justify-center gap-2 px-4 py-2 rounded-lg text-sm font-medium text-stone-700 bg-stone-100 hover:bg-stone-200 transition-colors"
>
<LogOut size={14} />
Sign out
</button>
</div>
</div>
);
}
if (initComplete && !session) {
const devUser = getDevUser();
if (devUser && devUser.type === "staff") {
return <Navigate to="/admin" replace />;
+32 -92
View File
@@ -1,6 +1,5 @@
import React, { useState, useEffect } from 'react';
import { Calendar, Clock, Plus, ChevronRight, ChevronDown, Loader2 } from 'lucide-react';
import { ANALYTICS_EVENTS, fireAnalyticsEvent } from '../../lib/analytics';
export interface Appointment {
id: string;
@@ -83,34 +82,14 @@ export function isUpcoming(appt: Appointment): boolean {
const STATUS_COLORS: Record<string, string> = {
confirmed: 'bg-green-100 text-green-700',
pending: 'bg-amber-100 text-amber-600',
waitlisted: 'bg-blue-100 text-blue-600',
pending: 'bg-amber-100 text-amber-700',
waitlisted: 'bg-blue-100 text-blue-700',
completed: 'bg-stone-100 text-stone-600',
cancelled: 'bg-red-100 text-red-600',
'no-show': 'bg-yellow-100 text-yellow-700',
scheduled: 'bg-blue-100 text-blue-600',
scheduled: 'bg-blue-100 text-blue-700',
};
const STATUS_LABELS: Record<string, string> = {
confirmed: 'Confirmed',
pending: 'Pending',
waitlisted: 'Waitlisted',
completed: 'Completed',
cancelled: 'Cancelled',
'no-show': 'No-show',
scheduled: 'Scheduled',
};
export function StatusBadge({ status }: { status: string }) {
const label = STATUS_LABELS[status] ?? status;
const colorClass = STATUS_COLORS[status] ?? 'bg-stone-100 text-stone-600';
return (
<span className={`px-2 py-0.5 rounded-full text-xs font-medium ${colorClass}`}>
{label}
</span>
);
}
const CONFIRMATION_STATUS_COLORS: Record<string, string> = {
confirmed: 'bg-green-100 text-green-700',
pending: 'bg-amber-100 text-amber-700',
@@ -318,7 +297,13 @@ function AppointmentCard({
<span>with {appt.groomerName || 'First Available'}</span>
</div>
</div>
<StatusBadge status={appt.status} />
<span
className={`px-2 py-0.5 rounded-full text-xs font-medium ${
STATUS_COLORS[appt.status] || ''
}`}
>
{appt.status}
</span>
{expanded ? (
<ChevronDown size={16} className="text-stone-400" />
) : (
@@ -588,26 +573,16 @@ export function RescheduleFlow({
const [submitting, setSubmitting] = useState(false);
const [error, setError] = useState<string | null>(null);
const [success, setSuccess] = useState(false);
const [slotsLoading, setSlotsLoading] = useState(false);
const [slotsError, setSlotsError] = useState<string | null>(null);
const [availableTimes, setAvailableTimes] = useState<string[]>([]);
useEffect(() => {
if (!selectedDate || !sessionId) {
setAvailableTimes([]);
return;
}
const params = new URLSearchParams({ date: selectedDate });
setSlotsLoading(true);
setSlotsError(null);
fetch(`/api/book/availability?${params.toString()}`, {
headers: { "X-Impersonation-Session-Id": sessionId ?? "" },
})
.then((r) => r.json() as Promise<string[]>)
.then(setAvailableTimes)
.catch(() => setSlotsError('Failed to load time slots'))
.finally(() => setSlotsLoading(false));
}, [selectedDate, sessionId]);
const availableTimes = [
'9:00 AM',
'10:00 AM',
'11:00 AM',
'1:00 PM',
'2:00 PM',
'3:00 PM',
'4:00 PM',
];
async function handleSubmit() {
if (!selectedDate || !selectedTime) return;
@@ -679,7 +654,6 @@ export function RescheduleFlow({
<h3 className="font-medium text-stone-800 mb-3">Pick a New Date & Time</h3>
<input
type="date"
aria-label="Select date"
value={selectedDate}
onChange={(e) => setSelectedDate(e.target.value)}
min={new Date().toISOString().split('T')[0]}
@@ -687,12 +661,7 @@ export function RescheduleFlow({
/>
{selectedDate && (
<div className="grid grid-cols-3 gap-2 mb-4">
{slotsLoading && <p className="col-span-3 text-sm text-stone-500 py-2">Checking availability</p>}
{!slotsLoading && slotsError && <p className="col-span-3 text-sm text-red-500 py-2">{slotsError}</p>}
{!slotsLoading && availableTimes.length === 0 && !slotsError && (
<p className="col-span-3 text-sm text-stone-500 py-2">No available slots on this date.</p>
)}
{!slotsLoading && availableTimes.map((time) => (
{availableTimes.map((time) => (
<button
key={time}
onClick={() => setSelectedTime(time)}
@@ -751,34 +720,19 @@ function BookingFlow({ onClose, sessionId }: BookingFlowProps) {
const [notes, setNotes] = useState('');
const [recurring, setRecurring] = useState('');
const [confirmed, setConfirmed] = useState(false);
useEffect(() => {
if (confirmed) {
fireAnalyticsEvent(ANALYTICS_EVENTS.BOOKING_CONFIRMED, { step: "confirmed", flow: "portal" });
}
}, [confirmed]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [submitting, setSubmitting] = useState(false);
const [slotsLoading, setSlotsLoading] = useState(false);
const [slotsError, setSlotsError] = useState<string | null>(null);
const [availableTimes, setAvailableTimes] = useState<string[]>([]);
useEffect(() => {
if (!selectedDate || !sessionId) {
setAvailableTimes([]);
return;
}
const params = new URLSearchParams({ date: selectedDate });
setSlotsLoading(true);
setSlotsError(null);
fetch(`/api/book/availability?${params.toString()}`, {
headers: { "X-Impersonation-Session-Id": sessionId ?? "" },
})
.then((r) => r.json() as Promise<string[]>)
.then(setAvailableTimes)
.catch(() => setSlotsError('Failed to load time slots'))
.finally(() => setSlotsLoading(false));
}, [selectedDate, sessionId]);
const availableTimes = [
'9:00 AM',
'10:00 AM',
'11:00 AM',
'1:00 PM',
'2:00 PM',
'3:00 PM',
'4:00 PM',
];
useEffect(() => {
const fetchData = async () => {
@@ -847,7 +801,6 @@ function BookingFlow({ onClose, sessionId }: BookingFlowProps) {
if (response.ok) {
setConfirmed(true);
fireAnalyticsEvent(ANALYTICS_EVENTS.BOOKING_STEP_SUBMIT, { step: "submit", flow: "portal" });
setTimeout(() => {
window.location.reload();
}, 1500);
@@ -923,7 +876,6 @@ function BookingFlow({ onClose, sessionId }: BookingFlowProps) {
onClick={() => {
setSelectedPet(pet);
setStep(2);
fireAnalyticsEvent(ANALYTICS_EVENTS.BOOKING_STEP_SERVICE, { step: "service", flow: "portal" });
}}
className={`w-full flex items-center gap-3 p-3 rounded-xl border text-left transition-colors ${
selectedPet?.id === pet.id
@@ -1082,10 +1034,7 @@ function BookingFlow({ onClose, sessionId }: BookingFlowProps) {
Back
</button>
<button
onClick={() => {
setStep(4);
fireAnalyticsEvent(ANALYTICS_EVENTS.BOOKING_STEP_CONTACT, { step: "groomer", flow: "portal" });
}}
onClick={() => setStep(4)}
className="flex-1 px-4 py-2 bg-blue-600 text-white rounded-lg text-sm font-medium"
>
Next
@@ -1099,7 +1048,6 @@ function BookingFlow({ onClose, sessionId }: BookingFlowProps) {
<h3 className="font-medium text-stone-800 mb-3">Pick Date & Time</h3>
<input
type="date"
aria-label="Select date"
value={selectedDate}
onChange={(e) => setSelectedDate(e.target.value)}
min={new Date().toISOString().split('T')[0]}
@@ -1107,12 +1055,7 @@ function BookingFlow({ onClose, sessionId }: BookingFlowProps) {
/>
{selectedDate && (
<div className="grid grid-cols-3 gap-2 mb-4">
{slotsLoading && <p className="col-span-3 text-sm text-stone-500 py-2">Checking availability</p>}
{!slotsLoading && slotsError && <p className="col-span-3 text-sm text-red-500 py-2">{slotsError}</p>}
{!slotsLoading && availableTimes.length === 0 && !slotsError && (
<p className="col-span-3 text-sm text-stone-500 py-2">No available slots on this date.</p>
)}
{!slotsLoading && availableTimes.map((time) => (
{availableTimes.map((time) => (
<button
key={time}
onClick={() => setSelectedTime(time)}
@@ -1150,10 +1093,7 @@ function BookingFlow({ onClose, sessionId }: BookingFlowProps) {
Back
</button>
<button
onClick={() => {
setStep(5);
fireAnalyticsEvent(ANALYTICS_EVENTS.BOOKING_STEP_TIME, { step: "time", flow: "portal" });
}}
onClick={() => setStep(5)}
disabled={!selectedDate || !selectedTime}
className="flex-1 px-4 py-2 bg-blue-600 text-white rounded-lg text-sm font-medium disabled:opacity-50"
>
-2
View File
@@ -39,8 +39,6 @@ export default defineConfig({
],
},
workbox: {
skipWaiting: true,
clientsClaim: true,
globPatterns: ["**/*.{js,css,html,ico,png,svg,woff2}"],
navigateFallbackDenylist: [
/^\/api\/auth\//,