diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 129ac44..905954e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -62,10 +62,6 @@ jobs: name: Build & Push Docker Image runs-on: ubuntu-latest needs: [lint-typecheck, test] - permissions: - contents: read - packages: write - id-token: write steps: - uses: actions/checkout@v4 @@ -83,12 +79,12 @@ jobs: - name: Set up Docker Buildx uses: docker/setup-buildx-action@v3 - - name: Log in to GitHub Container Registry + - name: Log in to Gitea Container Registry uses: docker/login-action@v3 with: - registry: ghcr.io - username: ${{ github.actor }} - password: ${{ secrets.GITHUB_TOKEN }} + registry: git.farh.net + username: ${{ gitea.actor }} + password: ${{ secrets.REGISTRY_TOKEN }} - name: Build and push Web image uses: docker/build-push-action@v6 @@ -97,7 +93,7 @@ jobs: file: Dockerfile push: true tags: | - ghcr.io/groombook/web:${{ steps.version.outputs.tag }} - ${{ github.ref == 'refs/heads/main' && 'ghcr.io/groombook/web:latest' || '' }} - cache-from: type=gha - cache-to: type=gha,mode=max \ No newline at end of file + git.farh.net/groombook/web:${{ steps.version.outputs.tag }} + ${{ github.ref == 'refs/heads/main' && 'git.farh.net/groombook/web:latest' || '' }} + cache-from: type=registry,ref=git.farh.net/groombook/cache:web + cache-to: type=registry,ref=git.farh.net/groombook/cache:web,mode=max \ No newline at end of file diff --git a/UAT_PLAYBOOK.md b/UAT_PLAYBOOK.md index a7c76e7..655c505 100644 --- a/UAT_PLAYBOOK.md +++ b/UAT_PLAYBOOK.md @@ -184,6 +184,38 @@ export const { signIn, signOut, useSession, changePassword } = authClient; | TC-WEB-5.17.2 | Missing data | Navigate to section with no data | Empty state message displayed, not blank page | | TC-WEB-5.17.3 | Error boundaries | Trigger error condition | Friendly error message displayed, app doesn't crash | +### 5.18 Pet Profile UI — Enhanced Fields (GRO-1178) + +| # | Scenario | Steps | Expected | +|---|----------|-------|----------| +| TC-WEB-5.18.1 | Coat type displayed in Grooming tab | Open pet profile, go to Grooming tab | Coat type shown (e.g. "Curly", "Double") | +| TC-WEB-5.18.2 | Preferred cuts displayed | Open Grooming tab | Preferred cuts shown as tags/chips | +| TC-WEB-5.18.3 | Temperament score displayed (read-only) | Open Basic Info tab | 1–5 star display with score label "(N/5 · staff-set)" | +| TC-WEB-5.18.4 | Temperament flags displayed (read-only) | Open Basic Info tab | Flag chips shown (e.g. "Anxious", "Good with kids") | +| TC-WEB-5.18.5 | Medical alerts in Medical tab | Open Medical tab | Alert cards with type, description, severity badge | +| TC-WEB-5.18.6 | Medical alert severity badges | View Medical tab | Low=green, Medium=amber, High=red badges | +| TC-WEB-5.18.7 | Edit pet — coat type dropdown | Click Edit on pet, select coat type | Coat type persisted on save | +| TC-WEB-5.18.8 | Edit pet — add medical alert | Click Edit, add alert with type + severity, save | Alert appears in Medical tab after save | +| TC-WEB-5.18.9 | Edit pet — remove medical alert | Click Edit, remove an alert, save | Alert removed after save | +| TC-WEB-5.18.10 | Edit pet — add preferred cut (Enter) | Click Edit, type cut name, press Enter | Cut tag added; persists after save | +| TC-WEB-5.18.11 | Edit pet — remove preferred cut | Click Edit, click X on cut tag | Cut removed; not persisted after save | +| TC-WEB-5.18.12 | Medical alert validation | Click Edit, add alert with empty type, try to save | Error "Type is required"; form not submitted | +| TC-WEB-5.18.13 | Temperament fields read-only | View edit form for pet with temperament data | Temperament score and flags not editable (display only) | + +### 5.19 Booking Wizard — Pet Size & Coat (GRO-1174) + +| # | Scenario | Steps | Expected | +|---|----------|-------|----------| +| TC-WEB-5.19.1 | Pet size dropdown visible | Step 3 of booking wizard (pet details) | Pet size dropdown shown after breed field with options: Small, Medium, Large, X-Large | +| TC-WEB-5.19.2 | Coat type dropdown visible | Step 3 of booking wizard | Coat type dropdown shown after pet size with options: Smooth, Double, Curly, Wire, Long, Hairless | +| TC-WEB-5.19.3 | Size/coat pre-fill from URL | Navigate to booking with `?petSizeCategory=large&petCoatType=curly` | Fields pre-filled with provided values | +| TC-WEB-5.19.4 | Size/coat optional | Proceed through booking without selecting size/coat | Booking completes successfully | +| TC-WEB-5.19.5 | Confirmation shows appointment duration | Confirm booking step | Service duration shown as "X min appointment" (buffer not exposed) | +| TC-WEB-5.19.6 | Confirmation shows pet size/coat | Confirm booking with size/coat selected | Size and coat type shown on pet card in confirmation | +| TC-WEB-5.19.7 | Availability uses buffer for large/x-large | Select large or x-large size, check availability | Availability slots reflect service duration + buffer for large/x-large | +| TC-WEB-5.19.8 | Form reset clears size/coat | Complete booking, click "Book another" | Size and coat fields reset to empty | +| TC-WEB-5.19.9 | New pet record has size/coat | Complete booking, view created pet in admin | Pet record shows selected size and coat type | + ## 6. Pass/Fail Criteria **Pass:** diff --git a/packages/types/src/index.ts b/packages/types/src/index.ts index 90ef116..ea46cc2 100644 --- a/packages/types/src/index.ts +++ b/packages/types/src/index.ts @@ -39,6 +39,12 @@ export interface Pet { cutStyle: string | null; shampooPreference: string | null; specialCareNotes: string | null; + coatType?: string | null; + petSizeCategory?: string | null; + preferredCuts: string[]; + medicalAlerts: MedicalAlert[]; + temperamentScore?: number; + temperamentFlags?: string[]; customFields: Record; photoKey?: string; photoUploadedAt?: string; @@ -208,3 +214,14 @@ export interface PaginatedList { page: number; pageSize: number; } + +export type AlertSeverity = "low" | "medium" | "high"; + +export interface MedicalAlert { + id: string; + type: string; + description: string; + severity: AlertSeverity; +} + +export type CoatType = "smooth" | "double" | "curly" | "wire" | "long" | "hairless"; diff --git a/src/__tests__/PetForm.test.tsx b/src/__tests__/PetForm.test.tsx new file mode 100644 index 0000000..8c5f3b1 --- /dev/null +++ b/src/__tests__/PetForm.test.tsx @@ -0,0 +1,157 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { render, screen, fireEvent } from "@testing-library/react"; +import { PetForm } from "../portal/sections/PetForm.js"; +import type { Pet } from "@groombook/types"; + +const BASE_PET: Pet = { + id: "pet-1", + clientId: "client-1", + name: "Buddy", + species: "dog", + breed: "Labrador", + weightKg: 25, + dateOfBirth: "2020-03-15T00:00:00.000Z", + 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("PetForm", () => { + const onSave = vi.fn(); + const onCancel = vi.fn(); + + beforeEach(() => { + onSave.mockClear(); + onCancel.mockClear(); + }); + + // ── Coat type ─────────────────────────────────────────────────────────────── + + it("allows coat type selection from dropdown", () => { + render(); + const select = screen.getByRole("combobox", { name: /coat type/i }); + fireEvent.change(select, { target: { value: "curly" } }); + expect((select as HTMLSelectElement).value).toBe("curly"); + }); + + it("persists coat type on save", () => { + render(); + fireEvent.change(screen.getByRole("combobox", { name: /coat type/i }), { target: { value: "double" } }); + fireEvent.click(screen.getByRole("button", { name: /save/i })); + expect(onSave).toHaveBeenCalledWith( + expect.objectContaining({ coatType: "double" }) + ); + }); + + // ── Preferred cuts tag input ──────────────────────────────────────────────── + + it("adds a cut when Enter is pressed", () => { + render(); + const input = screen.getByPlaceholderText(/type a cut name/i); + fireEvent.change(input, { target: { value: "Puppy Cut" } }); + fireEvent.keyDown(input, { key: "Enter" }); + expect(screen.getByText("Puppy Cut")).toBeTruthy(); + }); + + it("adds a cut when the + button is clicked", () => { + render(); + const input = screen.getByPlaceholderText(/type a cut name/i); + fireEvent.change(input, { target: { value: "Teddy Bear" } }); + fireEvent.click(screen.getByRole("button", { name: "Add" })); + expect(screen.getByText("Teddy Bear")).toBeTruthy(); + }); + + it("removes a cut when X is clicked", () => { + const petWithCuts: Pet = { + ...BASE_PET, + preferredCuts: ["Puppy Cut", "Teddy Bear"], + }; + render(); + const puppyCutSpans = screen.getAllByText("Puppy Cut"); + const puppyCutTag = puppyCutSpans[0]?.closest("span"); + if (!puppyCutTag) return; + const removeBtn = puppyCutTag.querySelector("button"); + if (!removeBtn) return; + fireEvent.click(removeBtn); + expect(screen.queryByText("Puppy Cut")).toBeNull(); + expect(screen.getByText("Teddy Bear")).toBeTruthy(); + }); + + it("includes preferred cuts in save payload", () => { + render(); + fireEvent.change(screen.getByPlaceholderText(/type a cut name/i), { target: { value: "Puppy Cut" } }); + fireEvent.keyDown(screen.getByPlaceholderText(/type a cut name/i), { key: "Enter" }); + fireEvent.click(screen.getByRole("button", { name: /save/i })); + expect(onSave).toHaveBeenCalledWith( + expect.objectContaining({ preferredCuts: ["Puppy Cut"] }) + ); + }); + + // ── Medical alerts ─────────────────────────────────────────────────────────── + + it("adds a medical alert", () => { + render(); + fireEvent.click(screen.getByRole("button", { name: /add alert/i })); + expect(screen.getByPlaceholderText(/alert type/i)).toBeTruthy(); + }); + + it("removes a medical alert", () => { + const petWithAlert: Pet = { + ...BASE_PET, + medicalAlerts: [{ id: "alert-1", type: "Allergic to chicken", description: "Causes hives", severity: "high" }], + }; + render(); + const removeButtons = screen.getAllByRole("button", { name: "" }); + if (removeButtons.length === 0) return; + const removeButton = removeButtons[0]; + if (!removeButton) return; + fireEvent.click(removeButton); + expect(screen.queryByText("Allergic to chicken")).toBeNull(); + }); + + it("validates alert type is non-empty", () => { + render(); + fireEvent.click(screen.getByRole("button", { name: /add alert/i })); + fireEvent.click(screen.getByRole("button", { name: /save/i })); + expect(screen.getByText(/type is required/i)).toBeTruthy(); + expect(onSave).not.toHaveBeenCalled(); + }); + + it("shows medical alerts in save payload", () => { + const petWithAlert: Pet = { + ...BASE_PET, + medicalAlerts: [{ id: "alert-1", type: "Sensitive skin", description: "Use hypoallergenic shampoo only", severity: "medium" }], + }; + render(); + fireEvent.click(screen.getByRole("button", { name: /save/i })); + expect(onSave).toHaveBeenCalledWith( + expect.objectContaining({ + medicalAlerts: expect.arrayContaining([ + expect.objectContaining({ type: "Sensitive skin", severity: "medium" }), + ]), + }) + ); + }); + + // ── Temperament read-only display ───────────────────────────────────────────── + + it("displays temperament score as read-only stars", () => { + const petWithTemperament: Pet = { + ...BASE_PET, + temperamentScore: 4, + temperamentFlags: ["Anxious", "Good with kids"], + }; + render(); + expect(screen.getByText("(4/5)")).toBeTruthy(); + expect(screen.getByText("Anxious")).toBeTruthy(); + expect(screen.getByText("Good with kids")).toBeTruthy(); + }); +}); \ No newline at end of file diff --git a/src/pages/Book.tsx b/src/pages/Book.tsx index dc58c9b..495c440 100644 --- a/src/pages/Book.tsx +++ b/src/pages/Book.tsx @@ -13,6 +13,8 @@ interface BookingBody { petName: string; petSpecies: string; petBreed: string; + petSizeCategory: string; + petCoatType: string; notes: string; } @@ -123,6 +125,8 @@ export function BookPage() { petName: "", petSpecies: "", petBreed: "", + petSizeCategory: "", + petCoatType: "", notes: "", }); const [formError, setFormError] = useState(null); @@ -136,7 +140,9 @@ export function BookPage() { const petName = searchParams.get("petName"); const petSpecies = searchParams.get("petSpecies"); const petBreed = searchParams.get("petBreed"); - if (clientName || clientEmail || clientPhone || petName || petSpecies || petBreed) { + const petSizeCategory = searchParams.get("petSizeCategory"); + const petCoatType = searchParams.get("petCoatType"); + if (clientName || clientEmail || clientPhone || petName || petSpecies || petBreed || petSizeCategory || petCoatType) { setForm((f) => ({ ...f, ...(clientName && { clientName }), @@ -145,6 +151,8 @@ export function BookPage() { ...(petName && { petName }), ...(petSpecies && { petSpecies }), ...(petBreed && { petBreed }), + ...(petSizeCategory && { petSizeCategory }), + ...(petCoatType && { petCoatType }), })); } }, [searchParams]); @@ -168,14 +176,18 @@ export function BookPage() { if (!selectedService || !date) return; setSlotsLoading(true); setSelectedSlot(null); - fetch( - `/api/book/availability?serviceId=${encodeURIComponent(selectedService.id)}&date=${encodeURIComponent(date)}` - ) + const params = new URLSearchParams({ + serviceId: selectedService.id, + date, + }); + if (form.petSizeCategory) params.set("petSizeCategory", form.petSizeCategory); + if (form.petCoatType) params.set("petCoatType", form.petCoatType); + fetch(`/api/book/availability?${params.toString()}`) .then((r) => r.json() as Promise) .then(setSlots) .catch(() => setSlots([])) .finally(() => setSlotsLoading(false)); - }, [selectedService, date]); + }, [selectedService, date, form.petSizeCategory, form.petCoatType]); function goToStep2(svc: Service) { setSelectedService(svc); @@ -214,6 +226,8 @@ export function BookPage() { petName: form.petName, petSpecies: form.petSpecies, petBreed: form.petBreed || undefined, + petSizeCategory: form.petSizeCategory || undefined, + petCoatType: form.petCoatType || undefined, notes: form.notes || undefined, }), }); @@ -494,6 +508,36 @@ export function BookPage() { placeholder="Golden Retriever" /> +
+ + +
+
+ + +