Compare commits
20 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| f549101962 | |||
| 62dc85b560 | |||
| bc21d6de09 | |||
| 32ef3bca4d | |||
| 47c29ecbc2 | |||
| de7386e47a | |||
| ec29f71974 | |||
| bd2a0d9516 | |||
| 0e5e9d1f16 | |||
| 3b4d0f15f6 | |||
| 87939e5413 | |||
| 4e3a038bf3 | |||
| 8349ea00de | |||
| 0306c7fbd9 | |||
| 93da2f1dd8 | |||
| 62cbfe4e43 | |||
| db6a2a1bbf | |||
| 032a3796ba | |||
| cac8fc947e | |||
| 592be1301c |
@@ -237,6 +237,34 @@ export const { signIn, signOut, useSession, changePassword } = authClient;
|
|||||||
> absolute `startTime` in `isUpcoming`, and hardens `parseTimeTo24Hour` against
|
> absolute `startTime` in `isUpcoming`, and hardens `parseTimeTo24Hour` against
|
||||||
> blank/undefined input.
|
> blank/undefined input.
|
||||||
|
|
||||||
|
#### 5.12e Book New `preferredTime` Formatting (GRO-2211, GRO-2213)
|
||||||
|
|
||||||
|
| # | Scenario | Steps | Expected |
|
||||||
|
|---|----------|-------|----------|
|
||||||
|
| TC-WEB-5.12.22 | Slot buttons show formatted label | Sign in as `uat-customer@groombook.dev`, open `Appointments`, click "Book New", select a pet and service, pick a date with availability | Each time-slot button shows a human-readable label like `10:00 AM` (UTC), never a raw ISO timestamp (e.g. not `2026-06-09T10:00:00.000Z`) |
|
||||||
|
| TC-WEB-5.12.23 | Confirmation review shows formatted label | Continue the Book New wizard to the Review step | The "Date & Time" summary and the final confirmation both display the formatted slot label (e.g. `10:00 AM`), not a raw ISO string |
|
||||||
|
| TC-WEB-5.12.24 | Booking submit succeeds (regression) | Complete the Book New wizard and submit the request | Request succeeds with no `500` / `invalid input syntax for type time` error; the booking POST sends `preferredTime` as `HH:MM:SS` (e.g. `10:00:00`); the new appointment appears in the Upcoming list |
|
||||||
|
| TC-WEB-5.12.25 | Slow-wizard submit succeeds (GRO-2234) | Sign in as `uat-customer@groombook.dev`, open `Appointments`, click "Book New", then deliberately pace the wizard (pet → service → groomer → date/slot → review) so that **>2 minutes** elapse before clicking "Confirm Booking". | Submit returns success — **no** "Failed to book appointment. Please try again." error. In DevTools → Network, if the first `POST /api/portal/waitlist` returns `401`, a `POST /api/portal/session-from-auth` fires immediately after and the booking is retried once with the fresh `X-Impersonation-Session-Id`, then returns 201. The appointment appears in the Upcoming list. |
|
||||||
|
|
||||||
|
> **GRO-2234 note:** A deliberately-paced Book New wizard could outlive the
|
||||||
|
> portal impersonation session, so the final `POST /api/portal/waitlist` returned
|
||||||
|
> `401 {"error":"Unauthorized"}` ("Failed to book appointment"). The web fix adds
|
||||||
|
> a transparent one-shot re-mint: on a `401` from the waitlist submit,
|
||||||
|
> `BookingFlow` calls `POST /api/portal/session-from-auth` (the Better Auth
|
||||||
|
> cookie is still valid) and retries the submit once with the fresh session id.
|
||||||
|
> The companion API fix (groombook/api GRO-2234) adds bounded sliding expiration
|
||||||
|
> so active sessions rarely lapse in the first place.
|
||||||
|
|
||||||
|
> **GRO-2211/GRO-2213 note:** The Book New wizard previously rendered the raw
|
||||||
|
> UTC ISO slot string as the button/confirmation label and submitted that same
|
||||||
|
> ISO value as `preferredTime`, which the API rejected with
|
||||||
|
> `invalid input syntax for type time` (HTTP 500). The fix adds shared UTC
|
||||||
|
> helpers `formatSlotLabel(slot)` (display → `10:00 AM`) and `slotToTime(slot)`
|
||||||
|
> (payload → `HH:MM:SS`) in `src/portal/sections/Appointments.tsx`, so the
|
||||||
|
> displayed label and the submitted `preferredTime` both derive from the same
|
||||||
|
> canonical UTC ISO slot. (The sibling `RescheduleFlow` `startTime` raw-ISO issue
|
||||||
|
> on a different endpoint is tracked separately and is out of scope here.)
|
||||||
|
|
||||||
### 5.13 Reports UI
|
### 5.13 Reports UI
|
||||||
|
|
||||||
| # | Scenario | Steps | Expected |
|
| # | Scenario | Steps | Expected |
|
||||||
|
|||||||
@@ -34,6 +34,10 @@ export interface Pet {
|
|||||||
breed: string | null;
|
breed: string | null;
|
||||||
weightKg: number | null;
|
weightKg: number | null;
|
||||||
dateOfBirth: string | null;
|
dateOfBirth: string | null;
|
||||||
|
/** Portal-shaped serialization of weightKg (GET/PATCH /api/portal/pets). */
|
||||||
|
weight?: string | number | null;
|
||||||
|
/** Portal-shaped serialization of dateOfBirth (GET/PATCH /api/portal/pets). */
|
||||||
|
birthDate?: string | null;
|
||||||
healthAlerts: string | null;
|
healthAlerts: string | null;
|
||||||
groomingNotes: string | null;
|
groomingNotes: string | null;
|
||||||
cutStyle: string | null;
|
cutStyle: string | null;
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||||
import { render, screen, fireEvent, waitFor } from "@testing-library/react";
|
import { render, screen, fireEvent, waitFor } from "@testing-library/react";
|
||||||
import { parseTimeTo24Hour, isUpcoming, normalizeAppointment, CustomerNotesSection, ConfirmationSection, StatusBadge } from "../portal/sections/Appointments.tsx";
|
import { parseTimeTo24Hour, isUpcoming, normalizeAppointment, normalizeService, formatServicePrice, CustomerNotesSection, ConfirmationSection, StatusBadge, formatSlotLabel, slotToTime, BookingFlow } from "../portal/sections/Appointments.tsx";
|
||||||
|
|
||||||
const UPCOMING_APPT = {
|
const UPCOMING_APPT = {
|
||||||
id: "appt-1",
|
id: "appt-1",
|
||||||
@@ -690,4 +690,248 @@ describe("RescheduleFlow dynamic time slots", () => {
|
|||||||
expect(screen.getByText("1:00 PM")).toBeInTheDocument();
|
expect(screen.getByText("1:00 PM")).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
describe("slot helpers (GRO-2213)", () => {
|
||||||
|
it("formatSlotLabel formats a canonical UTC ISO slot to a UTC clock label", () => {
|
||||||
|
expect(formatSlotLabel("2026-06-09T10:00:00.000Z")).toBe("10:00 AM");
|
||||||
|
expect(formatSlotLabel("2026-06-09T14:30:00.000Z")).toBe("2:30 PM");
|
||||||
|
expect(formatSlotLabel("2026-06-09T09:00:00.000Z")).toBe("9:00 AM");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("formatSlotLabel never echoes a raw ISO string", () => {
|
||||||
|
expect(formatSlotLabel("2026-06-09T10:00:00.000Z")).not.toMatch(/\d{4}-\d{2}-\d{2}T/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("formatSlotLabel passes through an already-formatted label unchanged", () => {
|
||||||
|
expect(formatSlotLabel("10:00 AM")).toBe("10:00 AM");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("slotToTime extracts the UTC HH:MM:SS time component from an ISO slot", () => {
|
||||||
|
expect(slotToTime("2026-06-09T10:00:00.000Z")).toBe("10:00:00");
|
||||||
|
expect(slotToTime("2026-06-09T14:30:00.000Z")).toBe("14:30:00");
|
||||||
|
expect(slotToTime("2026-06-09T10:00:00.000Z")).toMatch(/^\d{2}:\d{2}:\d{2}$/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("slotToTime guards a value that is already HH:MM:SS", () => {
|
||||||
|
expect(slotToTime("10:00:00")).toBe("10:00:00");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("slotToTime converts a 12-hour label fallback to HH:MM:SS", () => {
|
||||||
|
expect(slotToTime("9:00 AM")).toBe("09:00:00");
|
||||||
|
expect(slotToTime("2:30 PM")).toBe("14:30:00");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("BookingFlow Book New funnel (GRO-2213)", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
global.fetch = vi.fn();
|
||||||
|
});
|
||||||
|
|
||||||
|
function routedFetch(captured: { waitlistBody?: Record<string, unknown> }) {
|
||||||
|
return (input: RequestInfo | URL, init?: RequestInit) => {
|
||||||
|
const url = typeof input === "string" ? input : input.toString();
|
||||||
|
if (url.includes("/api/portal/pets")) {
|
||||||
|
return Promise.resolve({
|
||||||
|
ok: true,
|
||||||
|
json: async () => ({ pets: [{ id: "pet-1", name: "Buddy", breed: "Lab" }] }),
|
||||||
|
} as Response);
|
||||||
|
}
|
||||||
|
if (url.includes("/api/portal/services")) {
|
||||||
|
return Promise.resolve({
|
||||||
|
ok: true,
|
||||||
|
json: async () => ({
|
||||||
|
services: [{ id: "service-1", name: "Bath & Brush", isAddOn: false, duration: 60, price: 50 }],
|
||||||
|
}),
|
||||||
|
} as Response);
|
||||||
|
}
|
||||||
|
if (url.includes("/api/book/availability")) {
|
||||||
|
return Promise.resolve({
|
||||||
|
ok: true,
|
||||||
|
json: async () => ["2026-06-09T10:00:00.000Z", "2026-06-09T14:30:00.000Z"],
|
||||||
|
} as Response);
|
||||||
|
}
|
||||||
|
if (url.includes("/api/portal/waitlist")) {
|
||||||
|
captured.waitlistBody = JSON.parse((init?.body as string) ?? "{}");
|
||||||
|
return Promise.resolve({ ok: true, json: async () => ({}) } as Response);
|
||||||
|
}
|
||||||
|
return Promise.resolve({ ok: true, json: async () => ({}) } as Response);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
it("renders formatted slot labels (not raw ISO) and submits preferredTime as HH:MM:SS", async () => {
|
||||||
|
const captured: { waitlistBody?: Record<string, unknown> } = {};
|
||||||
|
vi.mocked(global.fetch).mockImplementation(routedFetch(captured) as typeof fetch);
|
||||||
|
|
||||||
|
render(<BookingFlow onClose={() => {}} sessionId="test-session-id" />);
|
||||||
|
|
||||||
|
// Step 1 — pick pet (auto-advances to step 2)
|
||||||
|
await waitFor(() => expect(screen.getByText("Buddy")).toBeInTheDocument());
|
||||||
|
fireEvent.click(screen.getByText("Buddy"));
|
||||||
|
|
||||||
|
// Step 2 — pick service, then Next
|
||||||
|
await waitFor(() => expect(screen.getByText("Bath & Brush")).toBeInTheDocument());
|
||||||
|
fireEvent.click(screen.getByText("Bath & Brush"));
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: /^Next$/ }));
|
||||||
|
|
||||||
|
// Step 3 — groomer, Next
|
||||||
|
await waitFor(() => expect(screen.getByText("First Available")).toBeInTheDocument());
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: /^Next$/ }));
|
||||||
|
|
||||||
|
// Step 4 — date + slot
|
||||||
|
await waitFor(() => expect(screen.getByLabelText(/date/i)).toBeInTheDocument());
|
||||||
|
fireEvent.change(screen.getByLabelText(/date/i), { target: { value: "2026-06-09" } });
|
||||||
|
|
||||||
|
// Slot button shows the formatted UTC label, never the raw ISO
|
||||||
|
await waitFor(() => expect(screen.getByText("10:00 AM")).toBeInTheDocument());
|
||||||
|
expect(screen.queryByText(/2026-06-09T10:00:00/)).not.toBeInTheDocument();
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByText("10:00 AM"));
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: /^Next$/ }));
|
||||||
|
|
||||||
|
// Step 5 — review shows the formatted label
|
||||||
|
await waitFor(() => expect(screen.getByText(/Review & Confirm/i)).toBeInTheDocument());
|
||||||
|
expect(screen.getByText(/10:00 AM/)).toBeInTheDocument();
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: /Confirm Booking/i }));
|
||||||
|
|
||||||
|
await waitFor(() => expect(captured.waitlistBody).toBeDefined());
|
||||||
|
const body = captured.waitlistBody ?? {};
|
||||||
|
expect(body.preferredTime).toMatch(/^\d{2}:\d{2}:\d{2}$/);
|
||||||
|
expect(body.preferredTime).toBe("10:00:00");
|
||||||
|
expect(body.preferredDate).toBe("2026-06-09");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("re-mints the portal session and retries once when waitlist returns 401 (GRO-2234)", async () => {
|
||||||
|
const calls = { waitlist: 0, remint: 0 };
|
||||||
|
const waitlistHeaders: string[] = [];
|
||||||
|
const routed = (input: RequestInfo | URL, init?: RequestInit) => {
|
||||||
|
const url = typeof input === "string" ? input : input.toString();
|
||||||
|
if (url.includes("/api/portal/pets")) {
|
||||||
|
return Promise.resolve({
|
||||||
|
ok: true,
|
||||||
|
json: async () => ({ pets: [{ id: "pet-1", name: "Buddy", breed: "Lab" }] }),
|
||||||
|
} as Response);
|
||||||
|
}
|
||||||
|
if (url.includes("/api/portal/services")) {
|
||||||
|
return Promise.resolve({
|
||||||
|
ok: true,
|
||||||
|
json: async () => ({
|
||||||
|
services: [{ id: "service-1", name: "Bath & Brush", isAddOn: false, duration: 60, price: 50 }],
|
||||||
|
}),
|
||||||
|
} as Response);
|
||||||
|
}
|
||||||
|
if (url.includes("/api/book/availability")) {
|
||||||
|
return Promise.resolve({
|
||||||
|
ok: true,
|
||||||
|
json: async () => ["2026-06-09T10:00:00.000Z"],
|
||||||
|
} as Response);
|
||||||
|
}
|
||||||
|
if (url.includes("/api/portal/session-from-auth")) {
|
||||||
|
calls.remint += 1;
|
||||||
|
return Promise.resolve({
|
||||||
|
ok: true,
|
||||||
|
json: async () => ({ sessionId: "fresh-session-id", clientId: "c1", clientName: "Jane" }),
|
||||||
|
} as Response);
|
||||||
|
}
|
||||||
|
if (url.includes("/api/portal/waitlist")) {
|
||||||
|
calls.waitlist += 1;
|
||||||
|
const headers = (init?.headers ?? {}) as Record<string, string>;
|
||||||
|
waitlistHeaders.push(headers["X-Impersonation-Session-Id"] ?? "");
|
||||||
|
// First attempt: session lapsed → 401. Retry after re-mint: success.
|
||||||
|
if (calls.waitlist === 1) {
|
||||||
|
return Promise.resolve({ ok: false, status: 401, json: async () => ({ error: "Unauthorized" }) } as Response);
|
||||||
|
}
|
||||||
|
return Promise.resolve({ ok: true, status: 201, json: async () => ({}) } as Response);
|
||||||
|
}
|
||||||
|
return Promise.resolve({ ok: true, json: async () => ({}) } as Response);
|
||||||
|
};
|
||||||
|
global.fetch = vi.fn().mockImplementation(routed as typeof fetch);
|
||||||
|
|
||||||
|
render(<BookingFlow onClose={() => {}} sessionId="stale-session-id" />);
|
||||||
|
|
||||||
|
await waitFor(() => expect(screen.getByText("Buddy")).toBeInTheDocument());
|
||||||
|
fireEvent.click(screen.getByText("Buddy"));
|
||||||
|
await waitFor(() => expect(screen.getByText("Bath & Brush")).toBeInTheDocument());
|
||||||
|
fireEvent.click(screen.getByText("Bath & Brush"));
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: /^Next$/ }));
|
||||||
|
await waitFor(() => expect(screen.getByText("First Available")).toBeInTheDocument());
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: /^Next$/ }));
|
||||||
|
await waitFor(() => expect(screen.getByLabelText(/date/i)).toBeInTheDocument());
|
||||||
|
fireEvent.change(screen.getByLabelText(/date/i), { target: { value: "2026-06-09" } });
|
||||||
|
await waitFor(() => expect(screen.getByText("10:00 AM")).toBeInTheDocument());
|
||||||
|
fireEvent.click(screen.getByText("10:00 AM"));
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: /^Next$/ }));
|
||||||
|
await waitFor(() => expect(screen.getByText(/Review & Confirm/i)).toBeInTheDocument());
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: /Confirm Booking/i }));
|
||||||
|
|
||||||
|
// Re-mint happened exactly once, waitlist retried with the fresh id, and the
|
||||||
|
// booking succeeded (no error surfaced).
|
||||||
|
await waitFor(() => expect(calls.waitlist).toBe(2));
|
||||||
|
expect(calls.remint).toBe(1);
|
||||||
|
expect(waitlistHeaders).toEqual(["stale-session-id", "fresh-session-id"]);
|
||||||
|
expect(screen.queryByText(/Failed to book appointment/i)).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("normalizeService", () => {
|
||||||
|
it("maps API basePriceCents/durationMinutes to price (dollars)/duration", () => {
|
||||||
|
const svc = normalizeService({
|
||||||
|
id: "svc-1",
|
||||||
|
name: "Full Groom",
|
||||||
|
basePriceCents: 4500,
|
||||||
|
durationMinutes: 60,
|
||||||
|
});
|
||||||
|
expect(svc.price).toBe(45);
|
||||||
|
expect(svc.duration).toBe(60);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("preserves an already-normalized payload (price/duration)", () => {
|
||||||
|
const svc = normalizeService({
|
||||||
|
id: "svc-2",
|
||||||
|
name: "Bath",
|
||||||
|
price: 30,
|
||||||
|
duration: 30,
|
||||||
|
});
|
||||||
|
expect(svc.price).toBe(30);
|
||||||
|
expect(svc.duration).toBe(30);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("leaves price/duration undefined when both source shapes are absent", () => {
|
||||||
|
const svc = normalizeService({ id: "svc-3", name: "Mystery" });
|
||||||
|
expect(svc.price).toBeUndefined();
|
||||||
|
expect(svc.duration).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("coerces null fields to undefined", () => {
|
||||||
|
const svc = normalizeService({
|
||||||
|
id: "svc-4",
|
||||||
|
name: "Nail Trim",
|
||||||
|
basePriceCents: null,
|
||||||
|
durationMinutes: null,
|
||||||
|
description: null,
|
||||||
|
});
|
||||||
|
expect(svc.price).toBeUndefined();
|
||||||
|
expect(svc.duration).toBeUndefined();
|
||||||
|
expect(svc.description).toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("formatServicePrice", () => {
|
||||||
|
it("prefers an explicit priceRange string", () => {
|
||||||
|
expect(formatServicePrice({ priceRange: "$40–$60", price: 45 })).toBe("$40–$60");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("formats integer dollars without trailing zeros", () => {
|
||||||
|
expect(formatServicePrice({ price: 45 })).toBe("$45");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("formats fractional dollars to cents", () => {
|
||||||
|
expect(formatServicePrice({ price: 45.5 })).toBe("$45.50");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns null when no price is available (never '$undefined')", () => {
|
||||||
|
expect(formatServicePrice({})).toBeNull();
|
||||||
|
expect(formatServicePrice({ price: undefined })).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -154,4 +154,12 @@ describe("PetForm", () => {
|
|||||||
expect(screen.getByText("Anxious")).toBeTruthy();
|
expect(screen.getByText("Anxious")).toBeTruthy();
|
||||||
expect(screen.getByText("Good with kids")).toBeTruthy();
|
expect(screen.getByText("Good with kids")).toBeTruthy();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ── Weight pre-fill from portal `weight` key (GRO-2207) ───────────────────────
|
||||||
|
|
||||||
|
it("pre-fills weight from the portal `weight` key when weightKg is absent", () => {
|
||||||
|
const portalPet: Pet = { ...BASE_PET, weightKg: null, weight: "12.50" };
|
||||||
|
render(<PetForm pet={portalPet} onSave={onSave} onCancel={onCancel} />);
|
||||||
|
expect(screen.getByDisplayValue(12.5)).toBeTruthy();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
@@ -0,0 +1,80 @@
|
|||||||
|
import { describe, it, expect } from "vitest";
|
||||||
|
import { render, screen } from "@testing-library/react";
|
||||||
|
import { BasicInfoTab, formatSizeCategory } from "../portal/sections/PetProfiles.js";
|
||||||
|
import type { Pet } from "@groombook/types";
|
||||||
|
|
||||||
|
// The portal endpoint (GET /api/portal/pets) serializes DB columns under
|
||||||
|
// portal-shaped keys: weightKg→weight, dateOfBirth→birthDate. The read view
|
||||||
|
// must surface those keys (GRO-2207), not the raw staff-side weightKg/dateOfBirth.
|
||||||
|
const PORTAL_PET: Pet = {
|
||||||
|
id: "pet-1",
|
||||||
|
clientId: "client-1",
|
||||||
|
name: "Pup Alpha",
|
||||||
|
species: "dog",
|
||||||
|
breed: "Poodle",
|
||||||
|
// Staff-shaped keys intentionally null — only the portal keys are populated,
|
||||||
|
// proving the read view reads `weight`/`birthDate`.
|
||||||
|
weightKg: null,
|
||||||
|
dateOfBirth: null,
|
||||||
|
weight: "12.50",
|
||||||
|
birthDate: "2022-03-10T00:00:00.000Z",
|
||||||
|
petSizeCategory: "extra_large",
|
||||||
|
healthAlerts: null,
|
||||||
|
groomingNotes: null,
|
||||||
|
cutStyle: null,
|
||||||
|
shampooPreference: null,
|
||||||
|
specialCareNotes: null,
|
||||||
|
customFields: {},
|
||||||
|
coatType: null,
|
||||||
|
preferredCuts: [],
|
||||||
|
medicalAlerts: [],
|
||||||
|
createdAt: "2024-01-01T00:00:00.000Z",
|
||||||
|
updatedAt: "2024-01-01T00:00:00.000Z",
|
||||||
|
};
|
||||||
|
|
||||||
|
describe("BasicInfoTab read view (GRO-2207)", () => {
|
||||||
|
it("renders Weight from the portal `weight` key", () => {
|
||||||
|
render(<BasicInfoTab pet={PORTAL_PET} readOnly />);
|
||||||
|
expect(screen.getByText("12.50 kg")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders Date of Birth from the portal `birthDate` key", () => {
|
||||||
|
render(<BasicInfoTab pet={PORTAL_PET} readOnly />);
|
||||||
|
expect(screen.getByText("March 10, 2022")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders Size Category formatted from petSizeCategory", () => {
|
||||||
|
render(<BasicInfoTab pet={PORTAL_PET} readOnly />);
|
||||||
|
expect(screen.getByText("Size Category")).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("Extra Large")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("falls back to staff-shaped keys when portal keys are absent", () => {
|
||||||
|
const staffShaped: Pet = { ...PORTAL_PET, weight: null, birthDate: null, weightKg: 25, dateOfBirth: "2020-01-05T00:00:00.000Z" };
|
||||||
|
render(<BasicInfoTab pet={staffShaped} readOnly />);
|
||||||
|
expect(screen.getByText("25 kg")).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("January 5, 2020")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows Unknown for missing weight/DoB and size", () => {
|
||||||
|
const empty: Pet = { ...PORTAL_PET, weight: null, birthDate: null, weightKg: null, dateOfBirth: null, petSizeCategory: null };
|
||||||
|
render(<BasicInfoTab pet={empty} readOnly />);
|
||||||
|
// Weight, Date of Birth and Size Category rows all read "Unknown".
|
||||||
|
expect(screen.getAllByText("Unknown").length).toBeGreaterThanOrEqual(3);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("formatSizeCategory", () => {
|
||||||
|
it("title-cases each underscore-separated segment", () => {
|
||||||
|
expect(formatSizeCategory("extra_large")).toBe("Extra Large");
|
||||||
|
expect(formatSizeCategory("small")).toBe("Small");
|
||||||
|
expect(formatSizeCategory("medium")).toBe("Medium");
|
||||||
|
expect(formatSizeCategory("large")).toBe("Large");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns Unknown for null/undefined/empty", () => {
|
||||||
|
expect(formatSizeCategory(null)).toBe("Unknown");
|
||||||
|
expect(formatSizeCategory(undefined)).toBe("Unknown");
|
||||||
|
expect(formatSizeCategory("")).toBe("Unknown");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -8,6 +8,28 @@ import { ANALYTICS_EVENTS, fireAnalyticsEvent } from '../../lib/analytics';
|
|||||||
// responds with `{error: "..."}` on 4xx, and we must not treat that as slots.
|
// responds with `{error: "..."}` on 4xx, and we must not treat that as slots.
|
||||||
const AVAILABILITY_ERROR_MESSAGE = 'Failed to load time slots';
|
const AVAILABILITY_ERROR_MESSAGE = 'Failed to load time slots';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Re-mint an SSO-bridge portal session from the active Better Auth session.
|
||||||
|
* Defense-in-depth for GRO-2234: if a portal call returns 401 mid-flow (the
|
||||||
|
* impersonation session lapsed during a slow wizard), the customer's Better
|
||||||
|
* Auth cookie is still valid, so we can transparently obtain a fresh portal
|
||||||
|
* session id and retry once. Returns the new session id, or null if no Better
|
||||||
|
* Auth session is available (e.g. staff/dev impersonation paths).
|
||||||
|
*/
|
||||||
|
async function remintPortalSession(): Promise<string | null> {
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/portal/session-from-auth', {
|
||||||
|
method: 'POST',
|
||||||
|
credentials: 'include',
|
||||||
|
});
|
||||||
|
if (!res.ok) return null;
|
||||||
|
const data = (await res.json().catch(() => ({}))) as { sessionId?: string };
|
||||||
|
return data.sessionId ?? null;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function fetchAvailability(
|
async function fetchAvailability(
|
||||||
params: { serviceId: string; date: string },
|
params: { serviceId: string; date: string },
|
||||||
sessionId: string | null,
|
sessionId: string | null,
|
||||||
@@ -67,8 +89,8 @@ interface Service {
|
|||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
description?: string;
|
description?: string;
|
||||||
duration: number;
|
duration?: number;
|
||||||
price: number;
|
price?: number;
|
||||||
priceRange?: string;
|
priceRange?: string;
|
||||||
isAddOn?: boolean;
|
isAddOn?: boolean;
|
||||||
}
|
}
|
||||||
@@ -110,6 +132,41 @@ export function parseTimeTo24Hour(time: string | null | undefined): string {
|
|||||||
return `${hours24.toString().padStart(2, '0')}:${minutes.toString().padStart(2, '0')}:00`;
|
return `${hours24.toString().padStart(2, '0')}:${minutes.toString().padStart(2, '0')}:00`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// A booking slot is the canonical UTC ISO instant returned by
|
||||||
|
// `/api/book/availability` (e.g. "2026-06-09T10:00:00.000Z" is the 10:00 UTC
|
||||||
|
// business slot — see api `src/lib/slots.ts`, which builds them with
|
||||||
|
// `setUTCHours`). Display label and submit payload both derive from the slot via
|
||||||
|
// these helpers so they never desync. Always format/extract in UTC: slots are
|
||||||
|
// generated as UTC business hours, so a browser-local conversion would mislabel
|
||||||
|
// the slot and diverge from the stored Postgres `time` column.
|
||||||
|
export function formatSlotLabel(slot: string): string {
|
||||||
|
const d = new Date(slot);
|
||||||
|
// Non-ISO input (e.g. an already-formatted "10:00 AM" label) — show as-is.
|
||||||
|
if (Number.isNaN(d.getTime())) return slot;
|
||||||
|
return new Intl.DateTimeFormat('en-US', {
|
||||||
|
hour: 'numeric',
|
||||||
|
minute: '2-digit',
|
||||||
|
hour12: true,
|
||||||
|
timeZone: 'UTC',
|
||||||
|
}).format(d);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extracts the UTC `HH:MM:SS` time component the api stores in the Postgres
|
||||||
|
// `time` column. The api inserts this verbatim, so a full ISO datetime here is
|
||||||
|
// an `invalid input syntax for type time` 500 (GRO-2211).
|
||||||
|
export function slotToTime(slot: string): string {
|
||||||
|
if (/^\d{2}:\d{2}:\d{2}$/.test(slot)) return slot; // already HH:MM:SS
|
||||||
|
const d = new Date(slot);
|
||||||
|
if (!Number.isNaN(d.getTime())) {
|
||||||
|
const hh = String(d.getUTCHours()).padStart(2, '0');
|
||||||
|
const mm = String(d.getUTCMinutes()).padStart(2, '0');
|
||||||
|
const ss = String(d.getUTCSeconds()).padStart(2, '0');
|
||||||
|
return `${hh}:${mm}:${ss}`;
|
||||||
|
}
|
||||||
|
// "10:00 AM"-style label fallback.
|
||||||
|
return parseTimeTo24Hour(slot);
|
||||||
|
}
|
||||||
|
|
||||||
export function isUpcoming(appt: Appointment): boolean {
|
export function isUpcoming(appt: Appointment): boolean {
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
// Prefer the absolute ISO `startTime` from the API; fall back to the
|
// Prefer the absolute ISO `startTime` from the API; fall back to the
|
||||||
@@ -192,6 +249,52 @@ export function normalizeAppointment(raw: RawApiAppointment): Appointment {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Raw service shape from `GET /api/portal/services`, which projects the
|
||||||
|
// canonical DB columns (`basePriceCents`, `durationMinutes`). Also tolerates an
|
||||||
|
// already-normalized payload so either shape renders correctly.
|
||||||
|
interface RawApiService {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
description?: string | null;
|
||||||
|
basePriceCents?: number | null;
|
||||||
|
durationMinutes?: number | null;
|
||||||
|
price?: number | null;
|
||||||
|
duration?: number | null;
|
||||||
|
priceRange?: string | null;
|
||||||
|
isAddOn?: boolean | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Normalizes a raw API service into the flat `Service` shape the cards render:
|
||||||
|
// price as dollars (from `basePriceCents`) and duration in minutes (from
|
||||||
|
// `durationMinutes`). Leaves fields undefined when genuinely absent so the card
|
||||||
|
// can hide them rather than print `$undefined` / empty `min`.
|
||||||
|
export function normalizeService(raw: RawApiService): Service {
|
||||||
|
const price =
|
||||||
|
raw.price ?? (typeof raw.basePriceCents === 'number' ? raw.basePriceCents / 100 : undefined);
|
||||||
|
const duration = raw.duration ?? raw.durationMinutes ?? undefined;
|
||||||
|
return {
|
||||||
|
id: raw.id,
|
||||||
|
name: raw.name,
|
||||||
|
description: raw.description ?? undefined,
|
||||||
|
duration: duration ?? undefined,
|
||||||
|
price: price ?? undefined,
|
||||||
|
priceRange: raw.priceRange ?? undefined,
|
||||||
|
isAddOn: raw.isAddOn ?? undefined,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Renders a service price for display, preferring an explicit `priceRange`
|
||||||
|
// string, then a numeric dollar `price` (integers without trailing zeros, e.g.
|
||||||
|
// `$45`; fractional values to cents, e.g. `$45.50`). Returns null when neither
|
||||||
|
// is available so the caller can omit the price line entirely.
|
||||||
|
export function formatServicePrice(svc: Pick<Service, 'price' | 'priceRange'>): string | null {
|
||||||
|
if (svc.priceRange) return svc.priceRange;
|
||||||
|
if (typeof svc.price === 'number' && Number.isFinite(svc.price)) {
|
||||||
|
return `$${Number.isInteger(svc.price) ? svc.price : svc.price.toFixed(2)}`;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
const STATUS_COLORS: Record<string, string> = {
|
const STATUS_COLORS: Record<string, string> = {
|
||||||
confirmed: 'bg-green-100 text-green-700',
|
confirmed: 'bg-green-100 text-green-700',
|
||||||
pending: 'bg-amber-100 text-amber-600',
|
pending: 'bg-amber-100 text-amber-600',
|
||||||
@@ -860,7 +963,7 @@ interface BookingFlowProps {
|
|||||||
sessionId: string | null;
|
sessionId: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
function BookingFlow({ onClose, sessionId }: BookingFlowProps) {
|
export function BookingFlow({ onClose, sessionId }: BookingFlowProps) {
|
||||||
const [step, setStep] = useState(1);
|
const [step, setStep] = useState(1);
|
||||||
const [pets, setPets] = useState<Pet[]>([]);
|
const [pets, setPets] = useState<Pet[]>([]);
|
||||||
const [services, setServices] = useState<Service[]>([]);
|
const [services, setServices] = useState<Service[]>([]);
|
||||||
@@ -937,7 +1040,8 @@ function BookingFlow({ onClose, sessionId }: BookingFlowProps) {
|
|||||||
|
|
||||||
if (servicesRes.ok) {
|
if (servicesRes.ok) {
|
||||||
const servicesData = await servicesRes.json();
|
const servicesData = await servicesRes.json();
|
||||||
setServices(servicesData.services || servicesData || []);
|
const rawServices: RawApiService[] = servicesData.services || servicesData || [];
|
||||||
|
setServices(rawServices.map(normalizeService));
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
setError('Failed to load data. Please try again.');
|
setError('Failed to load data. Please try again.');
|
||||||
@@ -958,26 +1062,40 @@ function BookingFlow({ onClose, sessionId }: BookingFlowProps) {
|
|||||||
setSubmitting(true);
|
setSubmitting(true);
|
||||||
setError(null);
|
setError(null);
|
||||||
|
|
||||||
try {
|
const payload = JSON.stringify({
|
||||||
const response = await fetch('/api/portal/waitlist', {
|
petId: selectedPet.id,
|
||||||
|
serviceId: selectedServices[0]?.id,
|
||||||
|
serviceIds: selectedServices.map((s) => s.id),
|
||||||
|
addOnIds: selectedAddOns.map((s) => s.id),
|
||||||
|
groomerId: selectedGroomer === 'first-available' ? null : selectedGroomer,
|
||||||
|
preferredDate: selectedDate,
|
||||||
|
preferredTime: slotToTime(selectedTime),
|
||||||
|
notes: notes || undefined,
|
||||||
|
recurring: recurring || undefined,
|
||||||
|
});
|
||||||
|
const submitWaitlist = (id: string) =>
|
||||||
|
fetch('/api/portal/waitlist', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
'X-Impersonation-Session-Id': sessionId ?? '',
|
'X-Impersonation-Session-Id': id,
|
||||||
},
|
},
|
||||||
body: JSON.stringify({
|
body: payload,
|
||||||
petId: selectedPet.id,
|
|
||||||
serviceId: selectedServices[0]?.id,
|
|
||||||
serviceIds: selectedServices.map((s) => s.id),
|
|
||||||
addOnIds: selectedAddOns.map((s) => s.id),
|
|
||||||
groomerId: selectedGroomer === 'first-available' ? null : selectedGroomer,
|
|
||||||
preferredDate: selectedDate,
|
|
||||||
preferredTime: selectedTime,
|
|
||||||
notes: notes || undefined,
|
|
||||||
recurring: recurring || undefined,
|
|
||||||
}),
|
|
||||||
});
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
let response = await submitWaitlist(sessionId);
|
||||||
|
|
||||||
|
// GRO-2234: a deliberately-paced wizard can outlive the portal session.
|
||||||
|
// The customer's Better Auth session is still valid, so transparently
|
||||||
|
// re-mint a fresh portal session and retry once before surfacing an error.
|
||||||
|
if (response.status === 401) {
|
||||||
|
const freshSessionId = await remintPortalSession();
|
||||||
|
if (freshSessionId) {
|
||||||
|
response = await submitWaitlist(freshSessionId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (response.ok) {
|
if (response.ok) {
|
||||||
setConfirmed(true);
|
setConfirmed(true);
|
||||||
fireAnalyticsEvent(ANALYTICS_EVENTS.BOOKING_STEP_SUBMIT, { step: "submit", flow: "portal" });
|
fireAnalyticsEvent(ANALYTICS_EVENTS.BOOKING_STEP_SUBMIT, { step: "submit", flow: "portal" });
|
||||||
@@ -1035,7 +1153,7 @@ function BookingFlow({ onClose, sessionId }: BookingFlowProps) {
|
|||||||
Appointment Requested!
|
Appointment Requested!
|
||||||
</h3>
|
</h3>
|
||||||
<p className="text-sm text-stone-500 mb-4">
|
<p className="text-sm text-stone-500 mb-4">
|
||||||
{selectedPet?.name} on {formatDate(selectedDate)} at {selectedTime}
|
{selectedPet?.name} on {formatDate(selectedDate)} at {formatSlotLabel(selectedTime)}
|
||||||
</p>
|
</p>
|
||||||
<button
|
<button
|
||||||
onClick={onClose}
|
onClick={onClose}
|
||||||
@@ -1119,10 +1237,14 @@ function BookingFlow({ onClose, sessionId }: BookingFlowProps) {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="text-right shrink-0 ml-3">
|
<div className="text-right shrink-0 ml-3">
|
||||||
<p className="text-sm font-medium text-stone-700">
|
{formatServicePrice(svc) && (
|
||||||
{svc.priceRange || `$${svc.price}`}
|
<p className="text-sm font-medium text-stone-700">
|
||||||
</p>
|
{formatServicePrice(svc)}
|
||||||
<p className="text-xs text-stone-400">{svc.duration} min</p>
|
</p>
|
||||||
|
)}
|
||||||
|
{typeof svc.duration === 'number' && (
|
||||||
|
<p className="text-xs text-stone-400">{svc.duration} min</p>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
@@ -1155,9 +1277,11 @@ function BookingFlow({ onClose, sessionId }: BookingFlowProps) {
|
|||||||
<p className="text-xs text-stone-500">{svc.description}</p>
|
<p className="text-xs text-stone-500">{svc.description}</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<span className="text-stone-600 shrink-0 ml-3">
|
{formatServicePrice(svc) && (
|
||||||
{svc.priceRange || `$${svc.price}`}
|
<span className="text-stone-600 shrink-0 ml-3">
|
||||||
</span>
|
{formatServicePrice(svc)}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@@ -1255,7 +1379,7 @@ function BookingFlow({ onClose, sessionId }: BookingFlowProps) {
|
|||||||
: 'border-stone-200 hover:border-stone-300'
|
: 'border-stone-200 hover:border-stone-300'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{time}
|
{formatSlotLabel(time)}
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@@ -1325,7 +1449,7 @@ function BookingFlow({ onClose, sessionId }: BookingFlowProps) {
|
|||||||
<div className="flex justify-between">
|
<div className="flex justify-between">
|
||||||
<span className="text-stone-500">Date & Time</span>
|
<span className="text-stone-500">Date & Time</span>
|
||||||
<span className="font-medium">
|
<span className="font-medium">
|
||||||
{formatDate(selectedDate)} at {selectedTime}
|
{formatDate(selectedDate)} at {formatSlotLabel(selectedTime)}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
{recurring && (
|
{recurring && (
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ function newAlert(): Omit<MedicalAlert, "id"> {
|
|||||||
export function PetForm({ pet, onSave, onCancel, saving, saveError }: Props) {
|
export function PetForm({ pet, onSave, onCancel, saving, saveError }: Props) {
|
||||||
const [name, setName] = useState(pet?.name ?? "");
|
const [name, setName] = useState(pet?.name ?? "");
|
||||||
const [breed, setBreed] = useState(pet?.breed ?? "");
|
const [breed, setBreed] = useState(pet?.breed ?? "");
|
||||||
const [weight, setWeight] = useState(pet?.weightKg ?? 0);
|
const [weight, setWeight] = useState(Number(pet?.weight ?? pet?.weightKg ?? 0));
|
||||||
const [notes, setNotes] = useState(pet?.healthAlerts ?? "");
|
const [notes, setNotes] = useState(pet?.healthAlerts ?? "");
|
||||||
const [coatType, setCoatType] = useState<CoatType | "">((pet?.coatType as CoatType) ?? "");
|
const [coatType, setCoatType] = useState<CoatType | "">((pet?.coatType as CoatType) ?? "");
|
||||||
const [petSizeCategory, setPetSizeCategory] = useState<SizeOption | "">(pet?.petSizeCategory as SizeOption ?? "");
|
const [petSizeCategory, setPetSizeCategory] = useState<SizeOption | "">(pet?.petSizeCategory as SizeOption ?? "");
|
||||||
|
|||||||
@@ -176,9 +176,9 @@ export function PetProfiles({ sessionId, readOnly }: Props) {
|
|||||||
</div>
|
</div>
|
||||||
<div className="flex-1">
|
<div className="flex-1">
|
||||||
<h2 className="text-xl font-semibold text-stone-800">{selectedPet.name}</h2>
|
<h2 className="text-xl font-semibold text-stone-800">{selectedPet.name}</h2>
|
||||||
<p className="text-stone-500 text-sm">{selectedPet.breed ?? "Unknown breed"} · {selectedPet.weightKg ? `${selectedPet.weightKg} kg` : "Unknown weight"}</p>
|
<p className="text-stone-500 text-sm">{selectedPet.breed ?? "Unknown breed"} · {(() => { const w = selectedPet.weight ?? selectedPet.weightKg; return w != null && w !== "" ? `${w} kg` : "Unknown weight"; })()}</p>
|
||||||
<p className="text-stone-400 text-xs mt-0.5">
|
<p className="text-stone-400 text-xs mt-0.5">
|
||||||
Born {selectedPet.dateOfBirth ? new Date(selectedPet.dateOfBirth).toLocaleDateString("en-US", { month: "long", day: "numeric", year: "numeric" }) : "Unknown"}
|
Born {(() => { const d = selectedPet.birthDate ?? selectedPet.dateOfBirth; return d ? new Date(d).toLocaleDateString("en-US", { month: "long", day: "numeric", year: "numeric" }) : "Unknown"; })()}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
{!readOnly && (
|
{!readOnly && (
|
||||||
@@ -222,6 +222,14 @@ export function PetProfiles({ sessionId, readOnly }: Props) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function formatSizeCategory(size?: string | null): string {
|
||||||
|
if (!size) return "Unknown";
|
||||||
|
return size
|
||||||
|
.split("_")
|
||||||
|
.map((part) => part.charAt(0).toUpperCase() + part.slice(1))
|
||||||
|
.join(" ");
|
||||||
|
}
|
||||||
|
|
||||||
function InfoRow({ label, value }: { label: string; value: React.ReactNode }) {
|
function InfoRow({ label, value }: { label: string; value: React.ReactNode }) {
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col sm:flex-row sm:items-center py-2.5 border-b border-stone-100 last:border-0">
|
<div className="flex flex-col sm:flex-row sm:items-center py-2.5 border-b border-stone-100 last:border-0">
|
||||||
@@ -244,7 +252,7 @@ function SeverityBadge({ severity }: { severity: "low" | "medium" | "high" }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function BasicInfoTab({ pet, readOnly }: { pet: Pet; readOnly: boolean }) {
|
export function BasicInfoTab({ pet, readOnly }: { pet: Pet; readOnly: boolean }) {
|
||||||
const score = pet.temperamentScore;
|
const score = pet.temperamentScore;
|
||||||
const flags = pet.temperamentFlags ?? [];
|
const flags = pet.temperamentFlags ?? [];
|
||||||
|
|
||||||
@@ -252,8 +260,9 @@ function BasicInfoTab({ pet, readOnly }: { pet: Pet; readOnly: boolean }) {
|
|||||||
<div>
|
<div>
|
||||||
<InfoRow label="Name" value={pet.name} />
|
<InfoRow label="Name" value={pet.name} />
|
||||||
<InfoRow label="Breed" value={pet.breed || "Unknown"} />
|
<InfoRow label="Breed" value={pet.breed || "Unknown"} />
|
||||||
<InfoRow label="Weight" value={pet.weightKg ? `${pet.weightKg} kg` : "Unknown"} />
|
<InfoRow label="Weight" value={(() => { const w = pet.weight ?? pet.weightKg; return w != null && w !== "" ? `${w} kg` : "Unknown"; })()} />
|
||||||
<InfoRow label="Date of Birth" value={pet.dateOfBirth ? new Date(pet.dateOfBirth).toLocaleDateString("en-US", { month: "long", day: "numeric", year: "numeric" }) : "Unknown"} />
|
<InfoRow label="Date of Birth" value={(() => { const d = pet.birthDate ?? pet.dateOfBirth; return d ? new Date(d).toLocaleDateString("en-US", { month: "long", day: "numeric", year: "numeric" }) : "Unknown"; })()} />
|
||||||
|
<InfoRow label="Size Category" value={formatSizeCategory(pet.petSizeCategory)} />
|
||||||
|
|
||||||
{/* Temperament (staff-set, read-only) */}
|
{/* Temperament (staff-set, read-only) */}
|
||||||
{(score != null || flags.length > 0) && (
|
{(score != null || flags.length > 0) && (
|
||||||
|
|||||||
Reference in New Issue
Block a user