From d1f8d27d1c2726ca8df7544a703bd49ec558f2e7 Mon Sep 17 00:00:00 2001 From: Chris Farhood Date: Wed, 20 May 2026 10:55:24 +0000 Subject: [PATCH 01/12] fix: remove unused X import from lucide-react Resolves ESLint error: 'X' is defined but never used GRO-1347 Co-Authored-By: Paperclip --- src/portal/sections/PetProfiles.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/portal/sections/PetProfiles.tsx b/src/portal/sections/PetProfiles.tsx index 787eeb3..6f0fdb6 100644 --- a/src/portal/sections/PetProfiles.tsx +++ b/src/portal/sections/PetProfiles.tsx @@ -1,5 +1,5 @@ import { useState, useEffect } from "react"; -import { PawPrint, Heart, Scissors, Clock, Edit3, Loader2, Star, X } from "lucide-react"; +import { PawPrint, Heart, Scissors, Clock, Edit3, Loader2, Star } from "lucide-react"; import { PetForm } from "./PetForm.js"; import type { Pet } from "@groombook/types"; From 465db89ab4ee98dc02a893b93ed27a4d6ab84954 Mon Sep 17 00:00:00 2001 From: Chris Farhood Date: Wed, 20 May 2026 14:09:20 +0000 Subject: [PATCH 02/12] fix(GRO-1361): remove unused X import and delete corrupted demo-pet images - Remove unused 'X' import from lucide-react in PetProfiles.tsx - Delete 10 corrupted demo-pet PNG files that contain Alibaba AccessDenied XML Co-Authored-By: Claude Opus 4.7 --- public/demo-pets/dog-basset-brown-white.png | 9 --------- public/demo-pets/dog-bichon-white-groomed.png | 9 --------- public/demo-pets/dog-boxer-fawn-athletic.png | 9 --------- public/demo-pets/dog-cavalier-cream-gentle.png | 9 --------- public/demo-pets/dog-cocker-buff-friendly.png | 9 --------- public/demo-pets/dog-dachshund-black-tan.png | 9 --------- public/demo-pets/dog-pomeranian-white-studio.png | 9 --------- public/demo-pets/dog-schnauzer-black-groomed.png | 9 --------- public/demo-pets/dog-setter-red-sunlit.png | 9 --------- public/demo-pets/dog-sheepdog-merle-running.png | 9 --------- src/portal/sections/PetProfiles.tsx | 2 +- 11 files changed, 1 insertion(+), 91 deletions(-) delete mode 100644 public/demo-pets/dog-basset-brown-white.png delete mode 100644 public/demo-pets/dog-bichon-white-groomed.png delete mode 100644 public/demo-pets/dog-boxer-fawn-athletic.png delete mode 100644 public/demo-pets/dog-cavalier-cream-gentle.png delete mode 100644 public/demo-pets/dog-cocker-buff-friendly.png delete mode 100644 public/demo-pets/dog-dachshund-black-tan.png delete mode 100644 public/demo-pets/dog-pomeranian-white-studio.png delete mode 100644 public/demo-pets/dog-schnauzer-black-groomed.png delete mode 100644 public/demo-pets/dog-setter-red-sunlit.png delete mode 100644 public/demo-pets/dog-sheepdog-merle-running.png diff --git a/public/demo-pets/dog-basset-brown-white.png b/public/demo-pets/dog-basset-brown-white.png deleted file mode 100644 index 9c9fea0..0000000 --- a/public/demo-pets/dog-basset-brown-white.png +++ /dev/null @@ -1,9 +0,0 @@ - - - AccessDenied - You have no right to access this object because of bucket acl. - 69D96C853FAECD363909C4A0 - hailuo-image-algeng-data-us.oss-us-east-1.aliyuncs.com - 0003-00000001 - https://api.alibabacloud.com/troubleshoot?q=0003-00000001 - diff --git a/public/demo-pets/dog-bichon-white-groomed.png b/public/demo-pets/dog-bichon-white-groomed.png deleted file mode 100644 index 2d214d4..0000000 --- a/public/demo-pets/dog-bichon-white-groomed.png +++ /dev/null @@ -1,9 +0,0 @@ - - - AccessDenied - You have no right to access this object because of bucket acl. - 69D96CFC84D7A9333708F278 - hailuo-image-algeng-data-us.oss-us-east-1.aliyuncs.com - 0003-00000001 - https://api.alibabacloud.com/troubleshoot?q=0003-00000001 - diff --git a/public/demo-pets/dog-boxer-fawn-athletic.png b/public/demo-pets/dog-boxer-fawn-athletic.png deleted file mode 100644 index 4958ac7..0000000 --- a/public/demo-pets/dog-boxer-fawn-athletic.png +++ /dev/null @@ -1,9 +0,0 @@ - - - AccessDenied - You have no right to access this object because of bucket acl. - 69D96D48D7892E37386B9ACB - hailuo-image-algeng-data-us.oss-us-east-1.aliyuncs.com - 0003-00000001 - https://api.alibabacloud.com/troubleshoot?q=0003-00000001 - diff --git a/public/demo-pets/dog-cavalier-cream-gentle.png b/public/demo-pets/dog-cavalier-cream-gentle.png deleted file mode 100644 index a06164b..0000000 --- a/public/demo-pets/dog-cavalier-cream-gentle.png +++ /dev/null @@ -1,9 +0,0 @@ - - - AccessDenied - You have no right to access this object because of bucket acl. - 69D96C25663D703833F23607 - hailuo-image-algeng-data-us.oss-us-east-1.aliyuncs.com - 0003-00000001 - https://api.alibabacloud.com/troubleshoot?q=0003-00000001 - diff --git a/public/demo-pets/dog-cocker-buff-friendly.png b/public/demo-pets/dog-cocker-buff-friendly.png deleted file mode 100644 index b4beacc..0000000 --- a/public/demo-pets/dog-cocker-buff-friendly.png +++ /dev/null @@ -1,9 +0,0 @@ - - - AccessDenied - You have no right to access this object because of bucket acl. - 69D96D89851C843332073968 - hailuo-image-algeng-data-us.oss-us-east-1.aliyuncs.com - 0003-00000001 - https://api.alibabacloud.com/troubleshoot?q=0003-00000001 - diff --git a/public/demo-pets/dog-dachshund-black-tan.png b/public/demo-pets/dog-dachshund-black-tan.png deleted file mode 100644 index 506c523..0000000 --- a/public/demo-pets/dog-dachshund-black-tan.png +++ /dev/null @@ -1,9 +0,0 @@ - - - AccessDenied - You have no right to access this object because of bucket acl. - 69D96C9C5A03D33730C61AD8 - hailuo-image-algeng-data-us.oss-us-east-1.aliyuncs.com - 0003-00000001 - https://api.alibabacloud.com/troubleshoot?q=0003-00000001 - diff --git a/public/demo-pets/dog-pomeranian-white-studio.png b/public/demo-pets/dog-pomeranian-white-studio.png deleted file mode 100644 index 0d13b96..0000000 --- a/public/demo-pets/dog-pomeranian-white-studio.png +++ /dev/null @@ -1,9 +0,0 @@ - - - AccessDenied - You have no right to access this object because of bucket acl. - 69D96BEB91911B30317E3BE8 - hailuo-image-algeng-data-us.oss-us-east-1.aliyuncs.com - 0003-00000001 - https://api.alibabacloud.com/troubleshoot?q=0003-00000001 - diff --git a/public/demo-pets/dog-schnauzer-black-groomed.png b/public/demo-pets/dog-schnauzer-black-groomed.png deleted file mode 100644 index 2cdf304..0000000 --- a/public/demo-pets/dog-schnauzer-black-groomed.png +++ /dev/null @@ -1,9 +0,0 @@ - - - AccessDenied - You have no right to access this object because of bucket acl. - 69D96BFB7B92D33535D6D90D - hailuo-image-algeng-data-us.oss-us-east-1.aliyuncs.com - 0003-00000001 - https://api.alibabacloud.com/troubleshoot?q=0003-00000001 - diff --git a/public/demo-pets/dog-setter-red-sunlit.png b/public/demo-pets/dog-setter-red-sunlit.png deleted file mode 100644 index 4e1850d..0000000 --- a/public/demo-pets/dog-setter-red-sunlit.png +++ /dev/null @@ -1,9 +0,0 @@ - - - AccessDenied - You have no right to access this object because of bucket acl. - 69D96B8BDF4B473630A2E120 - hailuo-image-algeng-data-us.oss-us-east-1.aliyuncs.com - 0003-00000001 - https://api.alibabacloud.com/troubleshoot?q=0003-00000001 - diff --git a/public/demo-pets/dog-sheepdog-merle-running.png b/public/demo-pets/dog-sheepdog-merle-running.png deleted file mode 100644 index d164736..0000000 --- a/public/demo-pets/dog-sheepdog-merle-running.png +++ /dev/null @@ -1,9 +0,0 @@ - - - AccessDenied - You have no right to access this object because of bucket acl. - 69D96D78BFFCAD343037C27C - hailuo-image-algeng-data-us.oss-us-east-1.aliyuncs.com - 0003-00000001 - https://api.alibabacloud.com/troubleshoot?q=0003-00000001 - diff --git a/src/portal/sections/PetProfiles.tsx b/src/portal/sections/PetProfiles.tsx index 787eeb3..6f0fdb6 100644 --- a/src/portal/sections/PetProfiles.tsx +++ b/src/portal/sections/PetProfiles.tsx @@ -1,5 +1,5 @@ import { useState, useEffect } from "react"; -import { PawPrint, Heart, Scissors, Clock, Edit3, Loader2, Star, X } from "lucide-react"; +import { PawPrint, Heart, Scissors, Clock, Edit3, Loader2, Star } from "lucide-react"; import { PetForm } from "./PetForm.js"; import type { Pet } from "@groombook/types"; From f414d2589f44a18500e7d69706cb0984feee3ae0 Mon Sep 17 00:00:00 2001 From: Chris Farhood Date: Wed, 20 May 2026 14:22:02 +0000 Subject: [PATCH 03/12] fix(GRO-1366): add non-null assertion to removeButtons[0] Fix TypeScript error on line 114: HTMLElement | undefined is not assignable to Element. Added ! assertion since length guard already excludes the empty-array case. Co-Authored-By: Paperclip --- src/__tests__/PetForm.test.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/__tests__/PetForm.test.tsx b/src/__tests__/PetForm.test.tsx index 8c5f3b1..f49e135 100644 --- a/src/__tests__/PetForm.test.tsx +++ b/src/__tests__/PetForm.test.tsx @@ -111,7 +111,7 @@ describe("PetForm", () => { render(); const removeButtons = screen.getAllByRole("button", { name: "" }); if (removeButtons.length === 0) return; - const removeButton = removeButtons[0]; + const removeButton = removeButtons[0]!; if (!removeButton) return; fireEvent.click(removeButton); expect(screen.queryByText("Allergic to chicken")).toBeNull(); From 42f3e3211a89726e68662a4c5631e46da2f82905 Mon Sep 17 00:00:00 2001 From: Chris Farhood Date: Wed, 20 May 2026 15:01:46 +0000 Subject: [PATCH 04/12] fix(GRO-903): resolve CI/CD blockers on groombook/web PR #1 - Move CI workflow from .github/workflows/ to .gitea/workflows/ - Add uat branch to CI triggers (push and pull_request) - Fix Dockerfile HEALTHCHECK to use wget instead of curl Co-Authored-By: Claude Opus 4.7 --- {.github => .gitea}/workflows/ci.yml | 4 ++-- Dockerfile | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) rename {.github => .gitea}/workflows/ci.yml (97%) diff --git a/.github/workflows/ci.yml b/.gitea/workflows/ci.yml similarity index 97% rename from .github/workflows/ci.yml rename to .gitea/workflows/ci.yml index 905954e..c58375f 100644 --- a/.github/workflows/ci.yml +++ b/.gitea/workflows/ci.yml @@ -2,9 +2,9 @@ name: CI on: push: - branches: [main, dev] + branches: [main, dev, uat] pull_request: - branches: [main, dev] + branches: [main, dev, uat] workflow_dispatch: inputs: ref: diff --git a/Dockerfile b/Dockerfile index f64fe7b..5aeaba4 100644 --- a/Dockerfile +++ b/Dockerfile @@ -18,4 +18,4 @@ COPY nginx.conf /etc/nginx/conf.d/default.conf COPY --from=builder /app/dist /usr/share/nginx/html EXPOSE 80 HEALTHCHECK --interval=30s --timeout=5s --start-period=5s --retries=3 \ - CMD curl -f http://localhost:80/ || exit 1 \ No newline at end of file + CMD wget --spider -q http://localhost:80/ || exit 1 \ No newline at end of file From ec17f1e8851bd58f334b4850368b4233b0fac7a9 Mon Sep 17 00:00:00 2001 From: Flea Flicker Date: Thu, 21 May 2026 11:12:45 +0000 Subject: [PATCH 05/12] feat(GRO-1173): apply buffer rules UI changes to extracted groombook/web repo MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This commit ports the GRO-1173 admin UI changes from the app monorepo into the extracted groombook/web repo, using the correct source paths (src/ instead of apps/web/src/): - New BufferRulesSection component (full CRUD UI for /api/buffer-rules) - Default Buffer (minutes) field added to service create/edit form - Size Category and Coat Type dropdowns added to PetForm (portal) - @groombook/types Service interface extended with defaultBufferMinutes - BufferRulesSection embedded in Settings page The PetForm already had coatType — this commit adds petSizeCategory and renders both fields with proper dropdown selectors. Co-Authored-By: Paperclip --- packages/types/src/index.ts | 1 + src/components/BufferRules.tsx | 276 ++++++++++++++++++++++++++++++++ src/pages/Services.tsx | 20 ++- src/pages/Settings.tsx | 5 + src/portal/sections/PetForm.tsx | 20 +++ 5 files changed, 321 insertions(+), 1 deletion(-) create mode 100644 src/components/BufferRules.tsx diff --git a/packages/types/src/index.ts b/packages/types/src/index.ts index ea46cc2..a83b36d 100644 --- a/packages/types/src/index.ts +++ b/packages/types/src/index.ts @@ -71,6 +71,7 @@ export interface Service { basePriceCents: number; durationMinutes: number; active: boolean; + defaultBufferMinutes?: number; createdAt: string; updatedAt: string; } diff --git a/src/components/BufferRules.tsx b/src/components/BufferRules.tsx new file mode 100644 index 0000000..5103f0a --- /dev/null +++ b/src/components/BufferRules.tsx @@ -0,0 +1,276 @@ +import { useEffect, useState } from "react"; +import { Loader2 } from "lucide-react"; + +interface Service { + id: string; + name: string; + description?: string; + basePriceCents: number; + durationMinutes: number; + active: boolean; +} + +interface BufferRule { + id: string; + serviceId: string; + serviceName: string; + sizeCategory?: string; + coatType?: string; + bufferMinutes: number; + createdAt: string; + updatedAt: string; +} + +interface BufferRuleForm { + serviceId: string; + sizeCategory: string; + coatType: string; + bufferMinutes: string; +} + +const EMPTY_FORM: BufferRuleForm = { + serviceId: "", + sizeCategory: "", + coatType: "", + bufferMinutes: "", +}; + +const SIZE_OPTIONS = ["", "small", "medium", "large", "xlarge"] as const; +const COAT_OPTIONS = ["", "smooth", "double", "wire", "curly", "long", "hairless"] as const; + +export function BufferRulesSection() { + const [rules, setRules] = useState([]); + const [services, setServices] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [showForm, setShowForm] = useState(false); + const [form, setForm] = useState(EMPTY_FORM); + const [saving, setSaving] = useState(false); + const [formError, setFormError] = useState(null); + const [deletingId, setDeletingId] = useState(null); + const [confirmDeleteId, setConfirmDeleteId] = useState(null); + const [editingId, setEditingId] = useState(null); + const [editBuffer, setEditBuffer] = useState(""); + + useEffect(() => { + Promise.all([ + fetch("/api/buffer-rules").then(r => r.ok ? r.json() : []), + fetch("/api/services?includeInactive=true").then(r => r.ok ? r.json() : []), + ]).then(([rulesData, servicesData]) => { + setRules(rulesData as BufferRule[]); + setServices(servicesData as Service[]); + }).catch(() => setError("Failed to load")).finally(() => setLoading(false)); + }, []); + + async function handleCreate(e: React.FormEvent) { + e.preventDefault(); + const mins = parseInt(form.bufferMinutes); + if (!form.serviceId || isNaN(mins) || mins <= 0) { + setFormError("Service and valid buffer minutes are required."); + return; + } + setSaving(true); + setFormError(null); + try { + const body: Record = { + serviceId: form.serviceId, + bufferMinutes: mins, + }; + if (form.sizeCategory) body.sizeCategory = form.sizeCategory; + if (form.coatType) body.coatType = form.coatType; + const res = await fetch("/api/buffer-rules", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + }); + if (!res.ok) { + const err = await res.json().catch(() => ({})) as { error?: string }; + throw new Error(err.error ?? `HTTP ${res.status}`); + } + const newRule = await res.json() as BufferRule; + setRules(prev => [...prev, newRule]); + setShowForm(false); + setForm(EMPTY_FORM); + } catch (e: unknown) { + setFormError(e instanceof Error ? e.message : "Failed to create rule"); + } finally { + setSaving(false); + } + } + + async function handleDelete(id: string) { + setDeletingId(id); + try { + await fetch(`/api/buffer-rules/${id}`, { method: "DELETE" }); + setRules(prev => prev.filter(r => r.id !== id)); + } finally { + setDeletingId(null); + setConfirmDeleteId(null); + } + } + + function startEdit(rule: BufferRule) { + setEditingId(rule.id); + setEditBuffer(String(rule.bufferMinutes)); + } + + async function saveEdit(rule: BufferRule) { + const mins = parseInt(editBuffer); + if (isNaN(mins) || mins <= 0) return; + setSaving(true); + try { + const res = await fetch(`/api/buffer-rules/${rule.id}`, { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ bufferMinutes: mins }), + }); + if (!res.ok) throw new Error(`HTTP ${res.status}`); + const updated = await res.json() as BufferRule; + setRules(prev => prev.map(r => r.id === updated.id ? updated : r)); + } catch { + // silent fail + } finally { + setSaving(false); + setEditingId(null); + setEditBuffer(""); + } + } + + if (loading) { + return ( +
+ +
+ ); + } + + return ( +
+
+
+

Buffer Rules

+

Extra time rules per service / pet size / coat type

+
+ +
+ + {showForm && ( +
+
+
+ + +
+
+ + setForm(f => ({ ...f, bufferMinutes: e.target.value }))} + required + className="w-full border border-stone-200 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-(--color-accent)" + /> +
+
+ + +
+
+ + +
+
+ {formError &&

{formError}

} + +
+ )} + + {rules.length === 0 && !showForm ? ( +

No buffer rules configured yet.

+ ) : ( +
+ {rules.map(rule => ( +
+
+
{rule.serviceName}
+
+ {rule.sizeCategory && Size: {rule.sizeCategory}} + {rule.coatType && Coat: {rule.coatType}} +
+
+ {editingId === rule.id ? ( +
+ setEditBuffer(e.target.value)} + className="w-20 border border-stone-200 rounded px-2 py-1 text-sm" + /> + min + + +
+ ) : ( + <> + {rule.bufferMinutes} min + + + )} + {confirmDeleteId === rule.id ? ( +
+ Delete? + + +
+ ) : ( + + )} +
+ ))} +
+ )} +
+ ); +} \ No newline at end of file diff --git a/src/pages/Services.tsx b/src/pages/Services.tsx index 7398c9d..60c4932 100644 --- a/src/pages/Services.tsx +++ b/src/pages/Services.tsx @@ -6,6 +6,7 @@ interface ServiceForm { description: string; priceStr: string; durationMinutes: number; + defaultBufferMinutes: number; active: boolean; } @@ -14,6 +15,7 @@ const EMPTY_FORM: ServiceForm = { description: "", priceStr: "", durationMinutes: 60, + defaultBufferMinutes: 0, active: true, }; @@ -55,6 +57,7 @@ export function ServicesPage() { description: s.description ?? "", priceStr: (s.basePriceCents / 100).toFixed(2), durationMinutes: s.durationMinutes, + defaultBufferMinutes: s.defaultBufferMinutes ?? 0, active: s.active, }); setFormError(null); @@ -76,6 +79,7 @@ export function ServicesPage() { description: form.description || undefined, basePriceCents: Math.round(price * 100), durationMinutes: form.durationMinutes, + defaultBufferMinutes: form.defaultBufferMinutes, active: form.active, }; const res = editing @@ -138,7 +142,7 @@ export function ServicesPage() { - {["Name", "Description", "Price", "Duration", "Status", ""].map((h) => ( + {["Name", "Description", "Price", "Duration", "Default Buffer", "Status", ""].map((h) => ( @@ -152,6 +156,7 @@ export function ServicesPage() { +
{h} {s.description ?? "—"} ${(s.basePriceCents / 100).toFixed(2)} {s.durationMinutes} min{(s as Service & { defaultBufferMinutes?: number }).defaultBufferMinutes ?? 0} min + {saveError && ( +

{saveError}

+ )} ); diff --git a/src/portal/sections/PetProfiles.tsx b/src/portal/sections/PetProfiles.tsx index 6f0fdb6..412b475 100644 --- a/src/portal/sections/PetProfiles.tsx +++ b/src/portal/sections/PetProfiles.tsx @@ -83,9 +83,27 @@ export function PetProfiles({ sessionId, readOnly }: Props) { const petHistory = appointments.appointments.filter(a => a.pet?.id === selectedPetId && new Date(a.startTime) <= new Date()); const editingPet = editingPetId ? pets.find(p => p.id === editingPetId) ?? null : null; - function handlePetSave(updatedPet: Pet) { - setPets(prev => prev.map(p => p.id === updatedPet.id ? updatedPet : p)); - setEditingPetId(null); + const [saving, setSaving] = useState(false); + const [saveError, setSaveError] = useState(null); + + async function handlePetSave(updatedPet: Pet) { + setSaving(true); + setSaveError(null); + try { + const res = await fetch(`/api/portal/pets/${updatedPet.id}`, { + method: "PATCH", + headers: { "Content-Type": "application/json", ...buildHeaders(sessionId) }, + body: JSON.stringify(updatedPet), + }); + if (!res.ok) throw new Error("Failed to save pet"); + const saved: Pet = await res.json(); + setPets(prev => prev.map(p => p.id === saved.id ? saved : p)); + setEditingPetId(null); + } catch (e) { + setSaveError(e instanceof Error ? e.message : "Failed to save pet"); + } finally { + setSaving(false); + } } if (editingPet) { @@ -94,6 +112,8 @@ export function PetProfiles({ sessionId, readOnly }: Props) { pet={editingPet} onSave={handlePetSave} onCancel={() => setEditingPetId(null)} + saving={saving} + saveError={saveError} /> ); } From 034f4ab29542109f4b05d7ec74447b4ea3b65ff7 Mon Sep 17 00:00:00 2001 From: Flea Flicker Date: Thu, 21 May 2026 19:32:33 +0000 Subject: [PATCH 12/12] docs(GRO-1470): add UAT test cases for pet profile API persistence MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add §5.23 covering: - API persistence (page reload verification) - Save error state (form stays open on failure) - Saving indicator (spinner while in-flight) Updated UAT_PLAYBOOK.md §5.23 Co-Authored-By: Claude Opus 4.7 --- UAT_PLAYBOOK.md | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/UAT_PLAYBOOK.md b/UAT_PLAYBOOK.md index 0e89ca0..b138ae4 100644 --- a/UAT_PLAYBOOK.md +++ b/UAT_PLAYBOOK.md @@ -275,6 +275,14 @@ export const { signIn, signOut, useSession, changePassword } = authClient; | TC-WEB-5.22.6 | Size and coat persisted | Save pet with size + coat, edit again | Both fields retain their selected values | | TC-WEB-5.22.7 | Clear size | Select size, then clear back to default | Size cleared on save | +### 5.23 Pet Profile — API Persistence & Save UX (GRO-1470) + +| # | Scenario | Steps | Expected | +|---|----------|-------|----------| +| TC-WEB-5.23.1 | Save pet — API persistence | Edit a pet, change a field (e.g. coat type), click Save, reload the page | Changed field retained after reload (proves PATCH round-trip to server) | +| 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 | + ## 6. Pass/Fail Criteria **Pass:**