From 123cae65c22912023b8e6635f42c254e1098c6eb Mon Sep 17 00:00:00 2001 From: Groom Book CTO Date: Thu, 19 Mar 2026 03:00:23 +0000 Subject: [PATCH 1/9] =?UTF-8?q?Improve=20admin=20UI=20visual=20design=20?= =?UTF-8?q?=E2=80=94=20polish=20look=20and=20feel?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Sticky nav bar with subtle shadow, branded GroomBook wordmark, green gradient Book button - Consistent brand green (#4f8a6f) for primary buttons across all admin pages - Tables wrapped in white cards with rounded corners and soft shadows - Uppercase table headers with better spacing and hierarchy - Input/button border-radius increased to 6px for softer feel - Global CSS: button transitions, input focus states with brand green ring, subtle card shadows - Background changed from plain white to light gray (#f0f2f5) for depth - Reports: polished stat cards with shadows, refined section headers, card-wrapped tables - Custom scrollbar styling for a cleaner look Closes groombook/groombook#58 Co-Authored-By: Paperclip --- apps/web/src/App.tsx | 41 +++++++++------ apps/web/src/index.css | 50 ++++++++++++++++++- apps/web/src/pages/Appointments.tsx | 21 ++++---- apps/web/src/pages/Clients.tsx | 12 ++--- apps/web/src/pages/GroupBooking.tsx | 19 +++---- apps/web/src/pages/Invoices.tsx | 18 ++++--- apps/web/src/pages/Reports.tsx | 77 +++++++++++++++-------------- apps/web/src/pages/Services.tsx | 18 ++++--- apps/web/src/pages/Staff.tsx | 14 +++--- 9 files changed, 170 insertions(+), 100 deletions(-) diff --git a/apps/web/src/App.tsx b/apps/web/src/App.tsx index b3ef7fc..7df2c75 100644 --- a/apps/web/src/App.tsx +++ b/apps/web/src/App.tsx @@ -23,29 +23,42 @@ const NAV_LINKS = [ function AdminLayout() { const location = useLocation(); return ( -
+
-
+
} /> } /> diff --git a/apps/web/src/index.css b/apps/web/src/index.css index 67a2b22..6d09d02 100644 --- a/apps/web/src/index.css +++ b/apps/web/src/index.css @@ -10,11 +10,13 @@ body { font-size: 16px; line-height: 1.5; color: #1a202c; - background: #f7fafc; + background: #f0f2f5; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; } a { - color: #4f8a6f; + color: #3d7a5f; text-decoration: none; } @@ -25,4 +27,48 @@ a:hover { h1 { font-size: 1.5rem; margin-top: 0; + letter-spacing: -0.01em; +} + +h2, h3, h4 { + letter-spacing: -0.01em; +} + +/* ─── Admin button polish ─── */ +button { + transition: background-color 0.15s ease, border-color 0.15s ease, box-shadow 0.15s ease, opacity 0.15s ease; +} + +button:active:not(:disabled) { + transform: translateY(0.5px); +} + +/* ─── Admin input / select focus states ─── */ +input:focus, select:focus, textarea:focus { + outline: none; + border-color: #4f8a6f; + box-shadow: 0 0 0 3px rgba(79, 138, 111, 0.12); +} + +/* ─── Admin card-like containers (borders get subtle shadow) ─── */ +[style*="border: 1px solid"] { + box-shadow: 0 1px 3px rgba(0, 0, 0, 0.04); +} + +/* ─── Scrollbar polish ─── */ +::-webkit-scrollbar { + width: 6px; +} + +::-webkit-scrollbar-track { + background: transparent; +} + +::-webkit-scrollbar-thumb { + background: #cbd5e1; + border-radius: 3px; +} + +::-webkit-scrollbar-thumb:hover { + background: #94a3b8; } diff --git a/apps/web/src/pages/Appointments.tsx b/apps/web/src/pages/Appointments.tsx index 20c5fae..ea2c539 100644 --- a/apps/web/src/pages/Appointments.tsx +++ b/apps/web/src/pages/Appointments.tsx @@ -291,7 +291,7 @@ export function AppointmentsPage() { @@ -370,11 +370,11 @@ export function AppointmentsPage() { {days.map((day, i) => { const isToday = formatDate(day) === formatDate(new Date()); return ( -
+
{saving ? "Saving…" @@ -841,19 +841,20 @@ function Field({ label, children }: { label: string; children: React.ReactNode } } const btnStyle: React.CSSProperties = { - padding: "0.35rem 0.75rem", + padding: "0.4rem 0.85rem", border: "1px solid #d1d5db", - borderRadius: 4, - background: "#f9fafb", + borderRadius: 6, + background: "#fff", cursor: "pointer", fontSize: 13, + fontWeight: 500, }; const inputStyle: React.CSSProperties = { width: "100%", - padding: "0.4rem 0.5rem", + padding: "0.45rem 0.6rem", border: "1px solid #d1d5db", - borderRadius: 4, + borderRadius: 6, fontSize: 14, boxSizing: "border-box", }; diff --git a/apps/web/src/pages/Clients.tsx b/apps/web/src/pages/Clients.tsx index e28d424..50df051 100644 --- a/apps/web/src/pages/Clients.tsx +++ b/apps/web/src/pages/Clients.tsx @@ -315,7 +315,7 @@ export function ClientsPage() {

Clients

@@ -387,7 +387,7 @@ export function ClientsPage() { ) : (
{pets.map((p) => ( -
+
{p.name}
@@ -498,7 +498,7 @@ export function ClientsPage() { {clientFormError &&

{clientFormError}

}
- @@ -637,7 +637,7 @@ export function ClientsPage() { {logFormError &&

{logFormError}

}
- @@ -674,9 +674,9 @@ function Field({ label, children }: { label: string; children: React.ReactNode } } const btnStyle: React.CSSProperties = { - padding: "0.35rem 0.75rem", border: "1px solid #d1d5db", borderRadius: 4, background: "#f9fafb", cursor: "pointer", fontSize: 13, + padding: "0.4rem 0.85rem", border: "1px solid #d1d5db", borderRadius: 6, background: "#fff", cursor: "pointer", fontSize: 13, fontWeight: 500, }; const inputStyle: React.CSSProperties = { - width: "100%", padding: "0.4rem 0.5rem", border: "1px solid #d1d5db", borderRadius: 4, fontSize: 14, boxSizing: "border-box", + width: "100%", padding: "0.45rem 0.6rem", border: "1px solid #d1d5db", borderRadius: 6, fontSize: 14, boxSizing: "border-box", }; diff --git a/apps/web/src/pages/GroupBooking.tsx b/apps/web/src/pages/GroupBooking.tsx index 8662a07..445530a 100644 --- a/apps/web/src/pages/GroupBooking.tsx +++ b/apps/web/src/pages/GroupBooking.tsx @@ -287,7 +287,7 @@ function NewGroupBookingForm({ @@ -471,7 +471,7 @@ export function GroupBookingPage() { @@ -558,25 +558,26 @@ function Field({ } const btnStyle: React.CSSProperties = { - padding: "0.35rem 0.75rem", + padding: "0.4rem 0.85rem", border: "1px solid #d1d5db", - borderRadius: 4, - background: "#f9fafb", + borderRadius: 6, + background: "#fff", cursor: "pointer", fontSize: 13, + fontWeight: 500, }; const inputStyle: React.CSSProperties = { width: "100%", - padding: "0.4rem 0.5rem", + padding: "0.45rem 0.6rem", border: "1px solid #d1d5db", - borderRadius: 4, + borderRadius: 6, fontSize: 13, boxSizing: "border-box", }; const tdStyle: React.CSSProperties = { - padding: "0.45rem 1rem", - borderBottom: "1px solid #f1f5f9", + padding: "0.5rem 1rem", + borderBottom: "1px solid #f3f4f6", color: "#374151", }; diff --git a/apps/web/src/pages/Invoices.tsx b/apps/web/src/pages/Invoices.tsx index dd86cf7..d1c3457 100644 --- a/apps/web/src/pages/Invoices.tsx +++ b/apps/web/src/pages/Invoices.tsx @@ -129,7 +129,7 @@ function CreateFromAppointmentForm({ @@ -540,7 +540,7 @@ export function InvoicesPage() { @@ -551,11 +551,12 @@ export function InvoicesPage() { No invoices yet. Create one from a completed appointment.

) : ( +
{["Date", "Client", "Subtotal", "Tax", "Tip", "Total", "Status", ""].map((h) => ( - ))} @@ -582,6 +583,7 @@ export function InvoicesPage() { ))}
+ {h}
+
)} {showCreate && ( @@ -647,15 +649,15 @@ function Field({ label, children }: { label: string; children: React.ReactNode } } const btnStyle: React.CSSProperties = { - padding: "0.35rem 0.75rem", border: "1px solid #d1d5db", - borderRadius: 4, background: "#f9fafb", cursor: "pointer", fontSize: 13, + padding: "0.4rem 0.85rem", border: "1px solid #d1d5db", + borderRadius: 6, background: "#fff", cursor: "pointer", fontSize: 13, fontWeight: 500, }; const inputStyle: React.CSSProperties = { - width: "100%", padding: "0.4rem 0.5rem", border: "1px solid #d1d5db", - borderRadius: 4, fontSize: 14, boxSizing: "border-box", + width: "100%", padding: "0.45rem 0.6rem", border: "1px solid #d1d5db", + borderRadius: 6, fontSize: 14, boxSizing: "border-box", }; const tdStyle: React.CSSProperties = { - padding: "0.5rem 0.75rem", borderBottom: "1px solid #e2e8f0", + padding: "0.55rem 0.75rem", borderBottom: "1px solid #f3f4f6", }; diff --git a/apps/web/src/pages/Reports.tsx b/apps/web/src/pages/Reports.tsx index fabb159..f7b3ceb 100644 --- a/apps/web/src/pages/Reports.tsx +++ b/apps/web/src/pages/Reports.tsx @@ -84,14 +84,15 @@ function StatCard({ label, value, sub }: { label: string; value: string; sub?: s
-
+
{label}
{value}
@@ -102,7 +103,7 @@ function StatCard({ label, value, sub }: { label: string; value: string; sub?: s function SectionHeader({ children }: { children: React.ReactNode }) { return ( -

+

{children}

); @@ -110,35 +111,37 @@ function SectionHeader({ children }: { children: React.ReactNode }) { function Table({ headers, rows }: { headers: string[]; rows: (string | number)[][] }) { return ( - - - - {headers.map((h) => ( - - ))} - - - - {rows.map((row, i) => ( - - {row.map((cell, j) => ( - +
+
- {h} -
- {cell} -
+ + + {headers.map((h) => ( + ))} - ))} - {rows.length === 0 && ( - - - - )} - -
+ {h} +
- No data for this period. -
+ + + {rows.map((row, i) => ( + + {row.map((cell, j) => ( + + {cell} + + ))} + + ))} + {rows.length === 0 && ( + + + No data for this period. + + + )} + + +
); } @@ -267,7 +270,7 @@ export function ReportsPage() { -
@@ -390,19 +393,19 @@ export function ReportsPage() { // ─── Shared styles ──────────────────────────────────────────────────────────── const btnStyle: React.CSSProperties = { - padding: "0.35rem 0.75rem", + padding: "0.4rem 0.85rem", border: "1px solid #d1d5db", - borderRadius: 4, - background: "#f9fafb", + borderRadius: 6, + background: "#fff", cursor: "pointer", fontSize: 13, fontWeight: 500, }; const inputStyle: React.CSSProperties = { - padding: "0.3rem 0.4rem", + padding: "0.35rem 0.5rem", border: "1px solid #d1d5db", - borderRadius: 4, + borderRadius: 6, fontSize: 13, marginLeft: "0.25rem", }; diff --git a/apps/web/src/pages/Services.tsx b/apps/web/src/pages/Services.tsx index 229b0d7..eb952b1 100644 --- a/apps/web/src/pages/Services.tsx +++ b/apps/web/src/pages/Services.tsx @@ -119,7 +119,7 @@ export function ServicesPage() {

Services

@@ -128,11 +128,12 @@ export function ServicesPage() { {services.length === 0 ? (

No services configured yet.

) : ( +
{["Name", "Description", "Price", "Duration", "Status", ""].map((h) => ( - ))} @@ -171,6 +172,7 @@ export function ServicesPage() { ))}
+ {h}
+
)} {showForm && ( @@ -230,7 +232,7 @@ export function ServicesPage() { @@ -277,15 +279,15 @@ function Field({ label, children }: { label: string; children: React.ReactNode } } const btnStyle: React.CSSProperties = { - padding: "0.35rem 0.75rem", border: "1px solid #d1d5db", - borderRadius: 4, background: "#f9fafb", cursor: "pointer", fontSize: 13, + padding: "0.4rem 0.85rem", border: "1px solid #d1d5db", + borderRadius: 6, background: "#fff", cursor: "pointer", fontSize: 13, fontWeight: 500, }; const inputStyle: React.CSSProperties = { - width: "100%", padding: "0.4rem 0.5rem", border: "1px solid #d1d5db", - borderRadius: 4, fontSize: 14, boxSizing: "border-box", + width: "100%", padding: "0.45rem 0.6rem", border: "1px solid #d1d5db", + borderRadius: 6, fontSize: 14, boxSizing: "border-box", }; const tdStyle: React.CSSProperties = { - padding: "0.5rem 0.75rem", borderBottom: "1px solid #e2e8f0", + padding: "0.55rem 0.75rem", borderBottom: "1px solid #f3f4f6", }; diff --git a/apps/web/src/pages/Staff.tsx b/apps/web/src/pages/Staff.tsx index 0f34ffb..5e9b594 100644 --- a/apps/web/src/pages/Staff.tsx +++ b/apps/web/src/pages/Staff.tsx @@ -78,7 +78,7 @@ export function StaffPage() {

Staff

-
@@ -86,11 +86,12 @@ export function StaffPage() { {staff.length === 0 ? (

No staff members yet.

) : ( +
{["Name", "Email", "Role", "Status", ""].map((h) => ( - + ))} @@ -113,6 +114,7 @@ export function StaffPage() { ))}
{h}{h}
+
)} {showForm && ( @@ -143,7 +145,7 @@ export function StaffPage() {
{formError &&

{formError}

}
- @@ -156,7 +158,7 @@ export function StaffPage() { ); } -const btnStyle: React.CSSProperties = { padding: "0.35rem 0.75rem", border: "1px solid #d1d5db", borderRadius: 4, background: "#f9fafb", cursor: "pointer", fontSize: 13 }; -const inputStyle: React.CSSProperties = { width: "100%", padding: "0.4rem 0.5rem", border: "1px solid #d1d5db", borderRadius: 4, fontSize: 14, boxSizing: "border-box" }; +const btnStyle: React.CSSProperties = { padding: "0.4rem 0.85rem", border: "1px solid #d1d5db", borderRadius: 6, background: "#fff", cursor: "pointer", fontSize: 13, fontWeight: 500 }; +const inputStyle: React.CSSProperties = { width: "100%", padding: "0.45rem 0.6rem", border: "1px solid #d1d5db", borderRadius: 6, fontSize: 14, boxSizing: "border-box" }; const labelStyle: React.CSSProperties = { display: "block", fontWeight: 600, marginBottom: "0.25rem", fontSize: 13, color: "#374151" }; -const tdStyle: React.CSSProperties = { padding: "0.5rem 0.75rem", borderBottom: "1px solid #e2e8f0" }; +const tdStyle: React.CSSProperties = { padding: "0.55rem 0.75rem", borderBottom: "1px solid #f3f4f6" }; -- 2.52.0 From f21ac590400120415f6753c2bc5033ff7d5ba37e Mon Sep 17 00:00:00 2001 From: Groom Book CTO Date: Thu, 19 Mar 2026 03:16:46 +0000 Subject: [PATCH 2/9] Fix test selectors for branded nav text - Use regex /Groom\s*Book/ to match split-element brand text - Use getByRole("link") for Book CTA to avoid matching brand Co-Authored-By: Paperclip --- apps/web/src/__tests__/App.test.tsx | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/apps/web/src/__tests__/App.test.tsx b/apps/web/src/__tests__/App.test.tsx index cfd422d..47b9656 100644 --- a/apps/web/src/__tests__/App.test.tsx +++ b/apps/web/src/__tests__/App.test.tsx @@ -23,12 +23,12 @@ function renderApp(route = "/admin") { describe("App navigation", () => { it("renders the Groom Book brand", () => { const nav = renderApp(); - expect(within(nav).getByText("Groom Book")).toBeInTheDocument(); + expect(within(nav).getByText(/Groom\s*Book/)).toBeInTheDocument(); }); it("renders the Book CTA button", () => { const nav = renderApp(); - expect(within(nav).getByText("Book")).toBeInTheDocument(); + expect(within(nav).getByRole("link", { name: "Book" })).toBeInTheDocument(); }); it("renders all primary nav links", () => { @@ -61,6 +61,6 @@ describe("App navigation", () => { ); // Customer portal should render at root - no admin nav present - expect(screen.queryByText("Groom Book")).not.toBeInTheDocument(); + expect(screen.queryByText(/Groom\s*Book/)).not.toBeInTheDocument(); }); }); -- 2.52.0 From ad4cc56546fb314c1aad3657d2761bf0e08be5e8 Mon Sep 17 00:00:00 2001 From: Groom Book CTO Date: Thu, 19 Mar 2026 03:20:09 +0000 Subject: [PATCH 3/9] Fix brand text test to handle split-element rendering The nav brand was changed to GroomBook for color styling, but getByText with a regex can't match text split across child elements. Use a custom text matcher that checks the STRONG element's textContent. Co-Authored-By: Paperclip --- apps/web/src/__tests__/App.test.tsx | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/apps/web/src/__tests__/App.test.tsx b/apps/web/src/__tests__/App.test.tsx index 47b9656..0052984 100644 --- a/apps/web/src/__tests__/App.test.tsx +++ b/apps/web/src/__tests__/App.test.tsx @@ -23,7 +23,9 @@ function renderApp(route = "/admin") { describe("App navigation", () => { it("renders the Groom Book brand", () => { const nav = renderApp(); - expect(within(nav).getByText(/Groom\s*Book/)).toBeInTheDocument(); + expect( + within(nav).getByText((_, el) => el?.tagName === "STRONG" && /Groom\s*Book/.test(el.textContent ?? "")) + ).toBeInTheDocument(); }); it("renders the Book CTA button", () => { @@ -61,6 +63,8 @@ describe("App navigation", () => { ); // Customer portal should render at root - no admin nav present - expect(screen.queryByText(/Groom\s*Book/)).not.toBeInTheDocument(); + expect( + screen.queryByText((_, el) => el?.tagName === "STRONG" && /Groom\s*Book/.test(el.textContent ?? "")) + ).not.toBeInTheDocument(); }); }); -- 2.52.0 From dc37a5f59ed4795402362920315d8e89ab447894 Mon Sep 17 00:00:00 2001 From: Groom Book CTO Date: Thu, 19 Mar 2026 03:27:54 +0000 Subject: [PATCH 4/9] Fix E2E tests for split-element brand name The brand is now GroomBook (no space), so Playwright's getByText needs "GroomBook" instead of "Groom Book". Co-Authored-By: Paperclip --- apps/e2e/tests/navigation.spec.ts | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/apps/e2e/tests/navigation.spec.ts b/apps/e2e/tests/navigation.spec.ts index 0b8e78f..39d9530 100644 --- a/apps/e2e/tests/navigation.spec.ts +++ b/apps/e2e/tests/navigation.spec.ts @@ -48,38 +48,38 @@ test("customer portal loads at root", async ({ page }) => { test("admin appointments page loads", async ({ page }) => { await page.goto("/admin"); - await expect(page.getByText("Groom Book")).toBeVisible(); + await expect(page.getByText("GroomBook")).toBeVisible(); // Calendar/appointments view renders await expect(page.locator("nav")).toBeVisible(); }); test("admin clients page loads", async ({ page }) => { await page.goto("/admin/clients"); - await expect(page.getByText("Groom Book")).toBeVisible(); + await expect(page.getByText("GroomBook")).toBeVisible(); await expect(page.getByRole("link", { name: "Clients" })).toBeVisible(); }); test("admin services page loads", async ({ page }) => { await page.goto("/admin/services"); - await expect(page.getByText("Groom Book")).toBeVisible(); + await expect(page.getByText("GroomBook")).toBeVisible(); await expect(page.getByRole("link", { name: "Services" })).toBeVisible(); }); test("admin staff page loads", async ({ page }) => { await page.goto("/admin/staff"); - await expect(page.getByText("Groom Book")).toBeVisible(); + await expect(page.getByText("GroomBook")).toBeVisible(); await expect(page.getByRole("link", { name: "Staff" })).toBeVisible(); }); test("admin invoices page loads", async ({ page }) => { await page.goto("/admin/invoices"); - await expect(page.getByText("Groom Book")).toBeVisible(); + await expect(page.getByText("GroomBook")).toBeVisible(); await expect(page.getByRole("link", { name: "Invoices" })).toBeVisible(); }); test("admin reports page loads", async ({ page }) => { await page.goto("/admin/reports"); - await expect(page.getByText("Groom Book")).toBeVisible(); + await expect(page.getByText("GroomBook")).toBeVisible(); await expect(page.getByRole("link", { name: "Reports" })).toBeVisible(); }); -- 2.52.0 From 1cf1f19e1d54727d6790bdb917a10597e858bb7e Mon Sep 17 00:00:00 2001 From: "groombook-paperclip[bot]" <268890960+groombook-paperclip[bot]@users.noreply.github.com> Date: Thu, 19 Mar 2026 03:33:34 +0000 Subject: [PATCH 5/9] =?UTF-8?q?Improve=20admin=20UI=20visual=20design=20?= =?UTF-8?q?=E2=80=94=20polish=20look=20and=20feel=20(#59)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Improve admin UI visual design — polish look and feel - Sticky nav bar with subtle shadow, branded GroomBook wordmark, green gradient Book button - Consistent brand green (#4f8a6f) for primary buttons across all admin pages - Tables wrapped in white cards with rounded corners and soft shadows - Uppercase table headers with better spacing and hierarchy - Input/button border-radius increased to 6px for softer feel - Global CSS: button transitions, input focus states with brand green ring, subtle card shadows - Background changed from plain white to light gray (#f0f2f5) for depth - Reports: polished stat cards with shadows, refined section headers, card-wrapped tables - Custom scrollbar styling for a cleaner look Closes groombook/groombook#58 Co-Authored-By: Paperclip * Fix test selectors for branded nav text - Use regex /Groom\s*Book/ to match split-element brand text - Use getByRole("link") for Book CTA to avoid matching brand Co-Authored-By: Paperclip * Fix brand text test to handle split-element rendering The nav brand was changed to GroomBook for color styling, but getByText with a regex can't match text split across child elements. Use a custom text matcher that checks the STRONG element's textContent. Co-Authored-By: Paperclip * Fix E2E tests for split-element brand name The brand is now GroomBook (no space), so Playwright's getByText needs "GroomBook" instead of "Groom Book". Co-Authored-By: Paperclip --------- Co-authored-by: Groom Book CTO Co-authored-by: Paperclip Co-authored-by: Groom Book CTO --- apps/e2e/tests/navigation.spec.ts | 12 ++--- apps/web/src/App.tsx | 41 +++++++++------ apps/web/src/__tests__/App.test.tsx | 10 ++-- apps/web/src/index.css | 50 ++++++++++++++++++- apps/web/src/pages/Appointments.tsx | 21 ++++---- apps/web/src/pages/Clients.tsx | 12 ++--- apps/web/src/pages/GroupBooking.tsx | 19 +++---- apps/web/src/pages/Invoices.tsx | 18 ++++--- apps/web/src/pages/Reports.tsx | 77 +++++++++++++++-------------- apps/web/src/pages/Services.tsx | 18 ++++--- apps/web/src/pages/Staff.tsx | 14 +++--- 11 files changed, 183 insertions(+), 109 deletions(-) diff --git a/apps/e2e/tests/navigation.spec.ts b/apps/e2e/tests/navigation.spec.ts index 0b8e78f..39d9530 100644 --- a/apps/e2e/tests/navigation.spec.ts +++ b/apps/e2e/tests/navigation.spec.ts @@ -48,38 +48,38 @@ test("customer portal loads at root", async ({ page }) => { test("admin appointments page loads", async ({ page }) => { await page.goto("/admin"); - await expect(page.getByText("Groom Book")).toBeVisible(); + await expect(page.getByText("GroomBook")).toBeVisible(); // Calendar/appointments view renders await expect(page.locator("nav")).toBeVisible(); }); test("admin clients page loads", async ({ page }) => { await page.goto("/admin/clients"); - await expect(page.getByText("Groom Book")).toBeVisible(); + await expect(page.getByText("GroomBook")).toBeVisible(); await expect(page.getByRole("link", { name: "Clients" })).toBeVisible(); }); test("admin services page loads", async ({ page }) => { await page.goto("/admin/services"); - await expect(page.getByText("Groom Book")).toBeVisible(); + await expect(page.getByText("GroomBook")).toBeVisible(); await expect(page.getByRole("link", { name: "Services" })).toBeVisible(); }); test("admin staff page loads", async ({ page }) => { await page.goto("/admin/staff"); - await expect(page.getByText("Groom Book")).toBeVisible(); + await expect(page.getByText("GroomBook")).toBeVisible(); await expect(page.getByRole("link", { name: "Staff" })).toBeVisible(); }); test("admin invoices page loads", async ({ page }) => { await page.goto("/admin/invoices"); - await expect(page.getByText("Groom Book")).toBeVisible(); + await expect(page.getByText("GroomBook")).toBeVisible(); await expect(page.getByRole("link", { name: "Invoices" })).toBeVisible(); }); test("admin reports page loads", async ({ page }) => { await page.goto("/admin/reports"); - await expect(page.getByText("Groom Book")).toBeVisible(); + await expect(page.getByText("GroomBook")).toBeVisible(); await expect(page.getByRole("link", { name: "Reports" })).toBeVisible(); }); diff --git a/apps/web/src/App.tsx b/apps/web/src/App.tsx index b3ef7fc..7df2c75 100644 --- a/apps/web/src/App.tsx +++ b/apps/web/src/App.tsx @@ -23,29 +23,42 @@ const NAV_LINKS = [ function AdminLayout() { const location = useLocation(); return ( -
+
-
+
} /> } /> diff --git a/apps/web/src/__tests__/App.test.tsx b/apps/web/src/__tests__/App.test.tsx index cfd422d..0052984 100644 --- a/apps/web/src/__tests__/App.test.tsx +++ b/apps/web/src/__tests__/App.test.tsx @@ -23,12 +23,14 @@ function renderApp(route = "/admin") { describe("App navigation", () => { it("renders the Groom Book brand", () => { const nav = renderApp(); - expect(within(nav).getByText("Groom Book")).toBeInTheDocument(); + expect( + within(nav).getByText((_, el) => el?.tagName === "STRONG" && /Groom\s*Book/.test(el.textContent ?? "")) + ).toBeInTheDocument(); }); it("renders the Book CTA button", () => { const nav = renderApp(); - expect(within(nav).getByText("Book")).toBeInTheDocument(); + expect(within(nav).getByRole("link", { name: "Book" })).toBeInTheDocument(); }); it("renders all primary nav links", () => { @@ -61,6 +63,8 @@ describe("App navigation", () => { ); // Customer portal should render at root - no admin nav present - expect(screen.queryByText("Groom Book")).not.toBeInTheDocument(); + expect( + screen.queryByText((_, el) => el?.tagName === "STRONG" && /Groom\s*Book/.test(el.textContent ?? "")) + ).not.toBeInTheDocument(); }); }); diff --git a/apps/web/src/index.css b/apps/web/src/index.css index 67a2b22..6d09d02 100644 --- a/apps/web/src/index.css +++ b/apps/web/src/index.css @@ -10,11 +10,13 @@ body { font-size: 16px; line-height: 1.5; color: #1a202c; - background: #f7fafc; + background: #f0f2f5; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; } a { - color: #4f8a6f; + color: #3d7a5f; text-decoration: none; } @@ -25,4 +27,48 @@ a:hover { h1 { font-size: 1.5rem; margin-top: 0; + letter-spacing: -0.01em; +} + +h2, h3, h4 { + letter-spacing: -0.01em; +} + +/* ─── Admin button polish ─── */ +button { + transition: background-color 0.15s ease, border-color 0.15s ease, box-shadow 0.15s ease, opacity 0.15s ease; +} + +button:active:not(:disabled) { + transform: translateY(0.5px); +} + +/* ─── Admin input / select focus states ─── */ +input:focus, select:focus, textarea:focus { + outline: none; + border-color: #4f8a6f; + box-shadow: 0 0 0 3px rgba(79, 138, 111, 0.12); +} + +/* ─── Admin card-like containers (borders get subtle shadow) ─── */ +[style*="border: 1px solid"] { + box-shadow: 0 1px 3px rgba(0, 0, 0, 0.04); +} + +/* ─── Scrollbar polish ─── */ +::-webkit-scrollbar { + width: 6px; +} + +::-webkit-scrollbar-track { + background: transparent; +} + +::-webkit-scrollbar-thumb { + background: #cbd5e1; + border-radius: 3px; +} + +::-webkit-scrollbar-thumb:hover { + background: #94a3b8; } diff --git a/apps/web/src/pages/Appointments.tsx b/apps/web/src/pages/Appointments.tsx index 20c5fae..ea2c539 100644 --- a/apps/web/src/pages/Appointments.tsx +++ b/apps/web/src/pages/Appointments.tsx @@ -291,7 +291,7 @@ export function AppointmentsPage() { @@ -370,11 +370,11 @@ export function AppointmentsPage() { {days.map((day, i) => { const isToday = formatDate(day) === formatDate(new Date()); return ( -
+
{saving ? "Saving…" @@ -841,19 +841,20 @@ function Field({ label, children }: { label: string; children: React.ReactNode } } const btnStyle: React.CSSProperties = { - padding: "0.35rem 0.75rem", + padding: "0.4rem 0.85rem", border: "1px solid #d1d5db", - borderRadius: 4, - background: "#f9fafb", + borderRadius: 6, + background: "#fff", cursor: "pointer", fontSize: 13, + fontWeight: 500, }; const inputStyle: React.CSSProperties = { width: "100%", - padding: "0.4rem 0.5rem", + padding: "0.45rem 0.6rem", border: "1px solid #d1d5db", - borderRadius: 4, + borderRadius: 6, fontSize: 14, boxSizing: "border-box", }; diff --git a/apps/web/src/pages/Clients.tsx b/apps/web/src/pages/Clients.tsx index e28d424..50df051 100644 --- a/apps/web/src/pages/Clients.tsx +++ b/apps/web/src/pages/Clients.tsx @@ -315,7 +315,7 @@ export function ClientsPage() {

Clients

@@ -387,7 +387,7 @@ export function ClientsPage() { ) : (
{pets.map((p) => ( -
+
{p.name}
@@ -498,7 +498,7 @@ export function ClientsPage() { {clientFormError &&

{clientFormError}

}
- @@ -637,7 +637,7 @@ export function ClientsPage() { {logFormError &&

{logFormError}

}
- @@ -674,9 +674,9 @@ function Field({ label, children }: { label: string; children: React.ReactNode } } const btnStyle: React.CSSProperties = { - padding: "0.35rem 0.75rem", border: "1px solid #d1d5db", borderRadius: 4, background: "#f9fafb", cursor: "pointer", fontSize: 13, + padding: "0.4rem 0.85rem", border: "1px solid #d1d5db", borderRadius: 6, background: "#fff", cursor: "pointer", fontSize: 13, fontWeight: 500, }; const inputStyle: React.CSSProperties = { - width: "100%", padding: "0.4rem 0.5rem", border: "1px solid #d1d5db", borderRadius: 4, fontSize: 14, boxSizing: "border-box", + width: "100%", padding: "0.45rem 0.6rem", border: "1px solid #d1d5db", borderRadius: 6, fontSize: 14, boxSizing: "border-box", }; diff --git a/apps/web/src/pages/GroupBooking.tsx b/apps/web/src/pages/GroupBooking.tsx index 8662a07..445530a 100644 --- a/apps/web/src/pages/GroupBooking.tsx +++ b/apps/web/src/pages/GroupBooking.tsx @@ -287,7 +287,7 @@ function NewGroupBookingForm({ @@ -471,7 +471,7 @@ export function GroupBookingPage() { @@ -558,25 +558,26 @@ function Field({ } const btnStyle: React.CSSProperties = { - padding: "0.35rem 0.75rem", + padding: "0.4rem 0.85rem", border: "1px solid #d1d5db", - borderRadius: 4, - background: "#f9fafb", + borderRadius: 6, + background: "#fff", cursor: "pointer", fontSize: 13, + fontWeight: 500, }; const inputStyle: React.CSSProperties = { width: "100%", - padding: "0.4rem 0.5rem", + padding: "0.45rem 0.6rem", border: "1px solid #d1d5db", - borderRadius: 4, + borderRadius: 6, fontSize: 13, boxSizing: "border-box", }; const tdStyle: React.CSSProperties = { - padding: "0.45rem 1rem", - borderBottom: "1px solid #f1f5f9", + padding: "0.5rem 1rem", + borderBottom: "1px solid #f3f4f6", color: "#374151", }; diff --git a/apps/web/src/pages/Invoices.tsx b/apps/web/src/pages/Invoices.tsx index dd86cf7..d1c3457 100644 --- a/apps/web/src/pages/Invoices.tsx +++ b/apps/web/src/pages/Invoices.tsx @@ -129,7 +129,7 @@ function CreateFromAppointmentForm({ @@ -540,7 +540,7 @@ export function InvoicesPage() { @@ -551,11 +551,12 @@ export function InvoicesPage() { No invoices yet. Create one from a completed appointment.

) : ( +
{["Date", "Client", "Subtotal", "Tax", "Tip", "Total", "Status", ""].map((h) => ( - ))} @@ -582,6 +583,7 @@ export function InvoicesPage() { ))}
+ {h}
+
)} {showCreate && ( @@ -647,15 +649,15 @@ function Field({ label, children }: { label: string; children: React.ReactNode } } const btnStyle: React.CSSProperties = { - padding: "0.35rem 0.75rem", border: "1px solid #d1d5db", - borderRadius: 4, background: "#f9fafb", cursor: "pointer", fontSize: 13, + padding: "0.4rem 0.85rem", border: "1px solid #d1d5db", + borderRadius: 6, background: "#fff", cursor: "pointer", fontSize: 13, fontWeight: 500, }; const inputStyle: React.CSSProperties = { - width: "100%", padding: "0.4rem 0.5rem", border: "1px solid #d1d5db", - borderRadius: 4, fontSize: 14, boxSizing: "border-box", + width: "100%", padding: "0.45rem 0.6rem", border: "1px solid #d1d5db", + borderRadius: 6, fontSize: 14, boxSizing: "border-box", }; const tdStyle: React.CSSProperties = { - padding: "0.5rem 0.75rem", borderBottom: "1px solid #e2e8f0", + padding: "0.55rem 0.75rem", borderBottom: "1px solid #f3f4f6", }; diff --git a/apps/web/src/pages/Reports.tsx b/apps/web/src/pages/Reports.tsx index fabb159..f7b3ceb 100644 --- a/apps/web/src/pages/Reports.tsx +++ b/apps/web/src/pages/Reports.tsx @@ -84,14 +84,15 @@ function StatCard({ label, value, sub }: { label: string; value: string; sub?: s
-
+
{label}
{value}
@@ -102,7 +103,7 @@ function StatCard({ label, value, sub }: { label: string; value: string; sub?: s function SectionHeader({ children }: { children: React.ReactNode }) { return ( -

+

{children}

); @@ -110,35 +111,37 @@ function SectionHeader({ children }: { children: React.ReactNode }) { function Table({ headers, rows }: { headers: string[]; rows: (string | number)[][] }) { return ( - - - - {headers.map((h) => ( - - ))} - - - - {rows.map((row, i) => ( - - {row.map((cell, j) => ( - +
+
- {h} -
- {cell} -
+ + + {headers.map((h) => ( + ))} - ))} - {rows.length === 0 && ( - - - - )} - -
+ {h} +
- No data for this period. -
+ + + {rows.map((row, i) => ( + + {row.map((cell, j) => ( + + {cell} + + ))} + + ))} + {rows.length === 0 && ( + + + No data for this period. + + + )} + + +
); } @@ -267,7 +270,7 @@ export function ReportsPage() { -
@@ -390,19 +393,19 @@ export function ReportsPage() { // ─── Shared styles ──────────────────────────────────────────────────────────── const btnStyle: React.CSSProperties = { - padding: "0.35rem 0.75rem", + padding: "0.4rem 0.85rem", border: "1px solid #d1d5db", - borderRadius: 4, - background: "#f9fafb", + borderRadius: 6, + background: "#fff", cursor: "pointer", fontSize: 13, fontWeight: 500, }; const inputStyle: React.CSSProperties = { - padding: "0.3rem 0.4rem", + padding: "0.35rem 0.5rem", border: "1px solid #d1d5db", - borderRadius: 4, + borderRadius: 6, fontSize: 13, marginLeft: "0.25rem", }; diff --git a/apps/web/src/pages/Services.tsx b/apps/web/src/pages/Services.tsx index 229b0d7..eb952b1 100644 --- a/apps/web/src/pages/Services.tsx +++ b/apps/web/src/pages/Services.tsx @@ -119,7 +119,7 @@ export function ServicesPage() {

Services

@@ -128,11 +128,12 @@ export function ServicesPage() { {services.length === 0 ? (

No services configured yet.

) : ( +
{["Name", "Description", "Price", "Duration", "Status", ""].map((h) => ( - ))} @@ -171,6 +172,7 @@ export function ServicesPage() { ))}
+ {h}
+
)} {showForm && ( @@ -230,7 +232,7 @@ export function ServicesPage() { @@ -277,15 +279,15 @@ function Field({ label, children }: { label: string; children: React.ReactNode } } const btnStyle: React.CSSProperties = { - padding: "0.35rem 0.75rem", border: "1px solid #d1d5db", - borderRadius: 4, background: "#f9fafb", cursor: "pointer", fontSize: 13, + padding: "0.4rem 0.85rem", border: "1px solid #d1d5db", + borderRadius: 6, background: "#fff", cursor: "pointer", fontSize: 13, fontWeight: 500, }; const inputStyle: React.CSSProperties = { - width: "100%", padding: "0.4rem 0.5rem", border: "1px solid #d1d5db", - borderRadius: 4, fontSize: 14, boxSizing: "border-box", + width: "100%", padding: "0.45rem 0.6rem", border: "1px solid #d1d5db", + borderRadius: 6, fontSize: 14, boxSizing: "border-box", }; const tdStyle: React.CSSProperties = { - padding: "0.5rem 0.75rem", borderBottom: "1px solid #e2e8f0", + padding: "0.55rem 0.75rem", borderBottom: "1px solid #f3f4f6", }; diff --git a/apps/web/src/pages/Staff.tsx b/apps/web/src/pages/Staff.tsx index 0f34ffb..5e9b594 100644 --- a/apps/web/src/pages/Staff.tsx +++ b/apps/web/src/pages/Staff.tsx @@ -78,7 +78,7 @@ export function StaffPage() {

Staff

-
@@ -86,11 +86,12 @@ export function StaffPage() { {staff.length === 0 ? (

No staff members yet.

) : ( +
{["Name", "Email", "Role", "Status", ""].map((h) => ( - + ))} @@ -113,6 +114,7 @@ export function StaffPage() { ))}
{h}{h}
+
)} {showForm && ( @@ -143,7 +145,7 @@ export function StaffPage() {
{formError &&

{formError}

}
- @@ -156,7 +158,7 @@ export function StaffPage() { ); } -const btnStyle: React.CSSProperties = { padding: "0.35rem 0.75rem", border: "1px solid #d1d5db", borderRadius: 4, background: "#f9fafb", cursor: "pointer", fontSize: 13 }; -const inputStyle: React.CSSProperties = { width: "100%", padding: "0.4rem 0.5rem", border: "1px solid #d1d5db", borderRadius: 4, fontSize: 14, boxSizing: "border-box" }; +const btnStyle: React.CSSProperties = { padding: "0.4rem 0.85rem", border: "1px solid #d1d5db", borderRadius: 6, background: "#fff", cursor: "pointer", fontSize: 13, fontWeight: 500 }; +const inputStyle: React.CSSProperties = { width: "100%", padding: "0.45rem 0.6rem", border: "1px solid #d1d5db", borderRadius: 6, fontSize: 14, boxSizing: "border-box" }; const labelStyle: React.CSSProperties = { display: "block", fontWeight: 600, marginBottom: "0.25rem", fontSize: 13, color: "#374151" }; -const tdStyle: React.CSSProperties = { padding: "0.5rem 0.75rem", borderBottom: "1px solid #e2e8f0" }; +const tdStyle: React.CSSProperties = { padding: "0.55rem 0.75rem", borderBottom: "1px solid #f3f4f6" }; -- 2.52.0 From 3388895912b6652b84260c2dd74d03f923b92599 Mon Sep 17 00:00:00 2001 From: "groombook-paperclip[bot]" <268890960+groombook-paperclip[bot]@users.noreply.github.com> Date: Thu, 19 Mar 2026 07:35:07 +0000 Subject: [PATCH 6/9] Add dev/demo login selector for quick user switching (#62) * Add dev/demo login selector for quick user switching When AUTH_DISABLED=true, the app now shows a login selector page that lists staff members and clients from the database. Selecting a user sets a localStorage-based session and sends X-Dev-User-Id header on all API requests. A persistent bottom bar shows the active persona with a "Switch user" link. - API: /api/dev/config (public) and /api/dev/users (auth-disabled only) - API: auth middleware reads X-Dev-User-Id header when auth is disabled - Frontend: DevLoginSelector page, DevSessionIndicator bar - Frontend: fetch interceptor injects X-Dev-User-Id on /api/* calls - Tests: 7 passing (5 nav + 2 dev login) Closes #60 Co-Authored-By: Paperclip * fix(e2e): seed dev user in localStorage to prevent login redirect E2E tests were failing because the dev login selector redirects to /login when AUTH_DISABLED=true and no dev user is in localStorage. Added a shared Playwright fixture that pre-seeds localStorage with a default dev user before each test. Also rebased onto latest main to resolve merge conflict in App.test.tsx. Co-Authored-By: Paperclip * fix(e2e): mock /api/dev/config to bypass auth redirect in tests The fixture now also mocks /api/dev/config to return authDisabled: false, preventing the app from entering the redirect flow during E2E tests. Previously only seeded localStorage, but the async config fetch from the real Docker API was still triggering the redirect check. Co-Authored-By: Paperclip --------- Co-authored-by: Groom Book CTO Co-authored-by: Paperclip --- apps/api/src/index.ts | 4 + apps/api/src/middleware/auth.ts | 4 +- apps/api/src/routes/dev.ts | 45 +++++ apps/e2e/tests/book.spec.ts | 2 +- apps/e2e/tests/clients.spec.ts | 2 +- apps/e2e/tests/fixtures.ts | 30 ++++ apps/e2e/tests/navigation.spec.ts | 2 +- apps/web/src/App.tsx | 42 ++++- apps/web/src/__tests__/App.test.tsx | 113 +++++++++--- .../src/components/DevSessionIndicator.tsx | 41 +++++ apps/web/src/lib/devFetch.ts | 28 +++ apps/web/src/main.tsx | 3 + apps/web/src/pages/DevLoginSelector.tsx | 169 ++++++++++++++++++ 13 files changed, 456 insertions(+), 29 deletions(-) create mode 100644 apps/api/src/routes/dev.ts create mode 100644 apps/e2e/tests/fixtures.ts create mode 100644 apps/web/src/components/DevSessionIndicator.tsx create mode 100644 apps/web/src/lib/devFetch.ts create mode 100644 apps/web/src/pages/DevLoginSelector.tsx diff --git a/apps/api/src/index.ts b/apps/api/src/index.ts index ed494a2..0e169e7 100644 --- a/apps/api/src/index.ts +++ b/apps/api/src/index.ts @@ -13,6 +13,7 @@ import { reportsRouter } from "./routes/reports.js"; import { appointmentGroupsRouter } from "./routes/appointmentGroups.js"; import { groomingLogsRouter } from "./routes/groomingLogs.js"; import { authMiddleware } from "./middleware/auth.js"; +import { devRouter } from "./routes/dev.js"; import { startReminderScheduler } from "./services/reminders.js"; const app = new Hono(); @@ -33,6 +34,9 @@ app.get("/health", (c) => c.json({ status: "ok" })); // Public booking routes — no auth required, must be registered before auth middleware app.route("/api/book", bookRouter); +// Dev/demo routes — config is always public, users endpoint is guarded internally +app.route("/api/dev", devRouter); + // Protected API routes const api = app.basePath("/api"); api.use("*", authMiddleware); diff --git a/apps/api/src/middleware/auth.ts b/apps/api/src/middleware/auth.ts index f8d6380..44f4100 100644 --- a/apps/api/src/middleware/auth.ts +++ b/apps/api/src/middleware/auth.ts @@ -40,7 +40,9 @@ if (process.env.AUTH_DISABLED === "true") { export const authMiddleware: MiddlewareHandler = async (c, next) => { if (process.env.AUTH_DISABLED === "true") { - c.set("jwtPayload", { sub: "dev-user" } as JwtPayload); + const devUserId = c.req.header("X-Dev-User-Id"); + const sub = devUserId ?? "dev-user"; + c.set("jwtPayload", { sub } as JwtPayload); await next(); return; } diff --git a/apps/api/src/routes/dev.ts b/apps/api/src/routes/dev.ts new file mode 100644 index 0000000..dfc5708 --- /dev/null +++ b/apps/api/src/routes/dev.ts @@ -0,0 +1,45 @@ +import { Hono } from "hono"; +import { getDb, staff, clients, eq, sql } from "@groombook/db"; + +const devRouter = new Hono(); + +// GET /api/dev/config — tells the frontend whether auth is disabled +devRouter.get("/config", (c) => { + return c.json({ authDisabled: process.env.AUTH_DISABLED === "true" }); +}); + +// GET /api/dev/users — list staff and clients for the login selector +// Only available when AUTH_DISABLED=true +devRouter.get("/users", async (c) => { + if (process.env.AUTH_DISABLED !== "true") { + return c.json({ error: "Not available when auth is enabled" }, 403); + } + + const db = getDb(); + + const staffList = await db + .select({ + id: staff.id, + name: staff.name, + email: staff.email, + role: staff.role, + }) + .from(staff) + .where(eq(staff.active, true)) + .orderBy(staff.name); + + const clientList = await db + .select({ + id: clients.id, + name: clients.name, + email: clients.email, + petCount: sql`(SELECT count(*) FROM pets WHERE pets.client_id = ${clients.id})`.as("pet_count"), + }) + .from(clients) + .orderBy(clients.name) + .limit(20); + + return c.json({ staff: staffList, clients: clientList }); +}); + +export { devRouter }; diff --git a/apps/e2e/tests/book.spec.ts b/apps/e2e/tests/book.spec.ts index ecec404..c3f30a5 100644 --- a/apps/e2e/tests/book.spec.ts +++ b/apps/e2e/tests/book.spec.ts @@ -1,4 +1,4 @@ -import { test, expect } from "@playwright/test"; +import { test, expect } from "./fixtures.js"; /** * Booking portal happy-path E2E test. diff --git a/apps/e2e/tests/clients.spec.ts b/apps/e2e/tests/clients.spec.ts index 4e681da..8b0ea62 100644 --- a/apps/e2e/tests/clients.spec.ts +++ b/apps/e2e/tests/clients.spec.ts @@ -1,4 +1,4 @@ -import { test, expect } from "@playwright/test"; +import { test, expect } from "./fixtures.js"; /** * Client management E2E tests. diff --git a/apps/e2e/tests/fixtures.ts b/apps/e2e/tests/fixtures.ts new file mode 100644 index 0000000..bf26d5f --- /dev/null +++ b/apps/e2e/tests/fixtures.ts @@ -0,0 +1,30 @@ +import { test as base } from "@playwright/test"; + +/** + * Custom test fixture that bypasses the dev login redirect for E2E tests. + * + * When AUTH_DISABLED=true, the app fetches /api/dev/config and redirects to + * /login if no dev-user is in localStorage. This fixture: + * 1. Mocks /api/dev/config to return authDisabled: false + * 2. Seeds localStorage with a dev user as a fallback + * + * This ensures E2E tests render pages directly without the login redirect. + */ +export const test = base.extend({ + page: async ({ page }, use) => { + // Mock the dev config endpoint so the app skips the auth-disabled redirect + await page.route("**/api/dev/config", (route) => + route.fulfill({ json: { authDisabled: false } }) + ); + // Seed localStorage as a fallback in case the mock is bypassed + await page.addInitScript(() => { + localStorage.setItem( + "dev-user", + JSON.stringify({ type: "staff", id: "dev-user", name: "Dev User" }) + ); + }); + await use(page); + }, +}); + +export { expect } from "@playwright/test"; diff --git a/apps/e2e/tests/navigation.spec.ts b/apps/e2e/tests/navigation.spec.ts index 39d9530..e79a5f6 100644 --- a/apps/e2e/tests/navigation.spec.ts +++ b/apps/e2e/tests/navigation.spec.ts @@ -1,4 +1,4 @@ -import { test, expect } from "@playwright/test"; +import { test, expect } from "./fixtures.js"; /** * Navigation smoke tests — verifies that each page loads without errors. diff --git a/apps/web/src/App.tsx b/apps/web/src/App.tsx index 7df2c75..a9e9d89 100644 --- a/apps/web/src/App.tsx +++ b/apps/web/src/App.tsx @@ -1,4 +1,5 @@ -import { Routes, Route, Link, useLocation } from "react-router-dom"; +import { Routes, Route, Link, useLocation, Navigate } from "react-router-dom"; +import { useEffect, useState } from "react"; import { AppointmentsPage } from "./pages/Appointments.js"; import { ClientsPage } from "./pages/Clients.js"; import { ServicesPage } from "./pages/Services.js"; @@ -8,6 +9,8 @@ import { BookPage } from "./pages/Book.js"; import { ReportsPage } from "./pages/Reports.js"; import { GroupBookingPage } from "./pages/GroupBooking.js"; import { CustomerPortal } from "./portal/CustomerPortal.js"; +import { DevLoginSelector, getDevUser } from "./pages/DevLoginSelector.js"; +import { DevSessionIndicator } from "./components/DevSessionIndicator.js"; const NAV_LINKS = [ { to: "/admin", label: "Appointments" }, @@ -105,14 +108,43 @@ function AdminLayout() { export function App() { const location = useLocation(); + const [authDisabled, setAuthDisabled] = useState(null); + + useEffect(() => { + fetch("/api/dev/config") + .then((r) => r.json()) + .then((data) => setAuthDisabled(data.authDisabled === true)) + .catch(() => setAuthDisabled(false)); + }, []); + + // Show login selector page + if (location.pathname === "/login") { + return ; + } + + // While checking auth config, render nothing briefly + if (authDisabled === null) return null; + + // If auth is disabled and no dev user is selected, redirect to login selector + if (authDisabled && !getDevUser() && location.pathname !== "/login") { + return ; + } if (location.pathname.startsWith("/admin")) { return ( - - } /> - + <> + + } /> + + {authDisabled && } + ); } - return ; + return ( + <> + + {authDisabled && } + + ); } diff --git a/apps/web/src/__tests__/App.test.tsx b/apps/web/src/__tests__/App.test.tsx index 0052984..7da9657 100644 --- a/apps/web/src/__tests__/App.test.tsx +++ b/apps/web/src/__tests__/App.test.tsx @@ -1,40 +1,51 @@ import { describe, it, expect, vi, beforeEach } from "vitest"; -import { render, screen, within } from "@testing-library/react"; +import { render, screen, within, waitFor } from "@testing-library/react"; import { MemoryRouter } from "react-router-dom"; import { App } from "../App.js"; -// Prevent fetch errors from page components loading data on mount +// Mock fetch to return appropriate responses based on URL beforeEach(() => { - global.fetch = vi.fn().mockResolvedValue({ - ok: true, - json: async () => [], - } as unknown as Response); + localStorage.clear(); + global.fetch = vi.fn((url: string) => { + if (url === "/api/dev/config") { + return Promise.resolve({ + ok: true, + json: async () => ({ authDisabled: false }), + } as Response); + } + return Promise.resolve({ + ok: true, + json: async () => [], + } as Response); + }) as unknown as typeof fetch; }); -function renderApp(route = "/admin") { +async function renderApp(route = "/admin") { render( ); - return screen.getByRole("navigation"); + // Wait for the config fetch to resolve + const nav = await screen.findByRole("navigation"); + return nav; } describe("App navigation", () => { - it("renders the Groom Book brand", () => { - const nav = renderApp(); + it("renders the Groom Book brand", async () => { + const nav = await renderApp(); expect( within(nav).getByText((_, el) => el?.tagName === "STRONG" && /Groom\s*Book/.test(el.textContent ?? "")) ).toBeInTheDocument(); }); - it("renders the Book CTA button", () => { - const nav = renderApp(); + it("renders the Book CTA button", async () => { + const nav = await renderApp(); expect(within(nav).getByRole("link", { name: "Book" })).toBeInTheDocument(); }); - it("renders all primary nav links", () => { - const nav = renderApp(); + it("renders all primary nav links", async () => { + const nav = await renderApp(); const expectedLinks = [ "Appointments", "Clients", @@ -49,22 +60,84 @@ describe("App navigation", () => { }); }); - it("highlights the active route link", () => { - const nav = renderApp("/admin/clients"); + it("highlights the active route link", async () => { + const nav = await renderApp("/admin/clients"); const clientsLink = within(nav).getByText("Clients"); // Active links use fontWeight 600 expect(clientsLink).toHaveStyle({ fontWeight: "600" }); }); - it("renders customer portal at root", () => { + it("renders customer portal at root", async () => { render( ); // Customer portal should render at root - no admin nav present - expect( - screen.queryByText((_, el) => el?.tagName === "STRONG" && /Groom\s*Book/.test(el.textContent ?? "")) - ).not.toBeInTheDocument(); + await waitFor(() => { + expect( + screen.queryByText((_, el) => el?.tagName === "STRONG" && /Groom\s*Book/.test(el.textContent ?? "")) + ).not.toBeInTheDocument(); + }); + }); +}); + +describe("Dev login selector", () => { + it("redirects to /login when auth is disabled and no user selected", async () => { + global.fetch = vi.fn((url: string) => { + if (url === "/api/dev/config") { + return Promise.resolve({ + ok: true, + json: async () => ({ authDisabled: true }), + } as Response); + } + if (url === "/api/dev/users") { + return Promise.resolve({ + ok: true, + json: async () => ({ + staff: [{ id: "s1", name: "Sarah", email: "sarah@test.com", role: "groomer" }], + clients: [{ id: "c1", name: "Client A", email: "a@test.com", petCount: 2 }], + }), + } as Response); + } + return Promise.resolve({ ok: true, json: async () => [] } as Response); + }) as unknown as typeof fetch; + + render( + + + + ); + + // Should redirect to login selector and show dev login UI + await screen.findByText("Dev Login Selector"); + expect(screen.getByText("Sarah")).toBeInTheDocument(); + expect(screen.getByText("Client A")).toBeInTheDocument(); + }); + + it("does not redirect when a dev user is already selected", async () => { + localStorage.setItem("dev-user", JSON.stringify({ type: "staff", id: "s1", name: "Sarah" })); + + global.fetch = vi.fn((url: string) => { + if (url === "/api/dev/config") { + return Promise.resolve({ + ok: true, + json: async () => ({ authDisabled: true }), + } as Response); + } + return Promise.resolve({ ok: true, json: async () => [] } as Response); + }) as unknown as typeof fetch; + + render( + + + + ); + + // Should show admin nav, not login selector + const nav = await screen.findByRole("navigation"); + expect( + within(nav).getByText((_, el) => el?.tagName === "STRONG" && /Groom\s*Book/.test(el.textContent ?? "")) + ).toBeInTheDocument(); }); }); diff --git a/apps/web/src/components/DevSessionIndicator.tsx b/apps/web/src/components/DevSessionIndicator.tsx new file mode 100644 index 0000000..993698d --- /dev/null +++ b/apps/web/src/components/DevSessionIndicator.tsx @@ -0,0 +1,41 @@ +import { Link } from "react-router-dom"; +import { getDevUser } from "../pages/DevLoginSelector.js"; + +export function DevSessionIndicator() { + const user = getDevUser(); + if (!user) return null; + + return ( +
+ + Dev mode: acting as {user.name} ({user.type}) + + + Switch user + +
+ ); +} diff --git a/apps/web/src/lib/devFetch.ts b/apps/web/src/lib/devFetch.ts new file mode 100644 index 0000000..42078ce --- /dev/null +++ b/apps/web/src/lib/devFetch.ts @@ -0,0 +1,28 @@ +import { getDevUser } from "../pages/DevLoginSelector.js"; + +const originalFetch = window.fetch; + +/** + * Patches global fetch to include X-Dev-User-Id header on API requests + * when a dev user is selected via the login selector. + * + * Intentionally mutates window.fetch — this is dev-only (AUTH_DISABLED=true). + */ +export function installDevFetchInterceptor() { + window.fetch = function (input: RequestInfo | URL, init?: RequestInit) { + const user = getDevUser(); + if (!user) return originalFetch(input, init); + + const url = typeof input === "string" ? input : input instanceof URL ? input.toString() : (input as Request).url; + + // Only inject header for API calls + if (!url.startsWith("/api/")) return originalFetch(input, init); + + const headers = new Headers(init?.headers); + if (!headers.has("X-Dev-User-Id")) { + headers.set("X-Dev-User-Id", user.id); + } + + return originalFetch(input, { ...init, headers }); + }; +} diff --git a/apps/web/src/main.tsx b/apps/web/src/main.tsx index b683e11..3920a8d 100644 --- a/apps/web/src/main.tsx +++ b/apps/web/src/main.tsx @@ -2,8 +2,11 @@ import { StrictMode } from "react"; import { createRoot } from "react-dom/client"; import { BrowserRouter } from "react-router-dom"; import { App } from "./App.js"; +import { installDevFetchInterceptor } from "./lib/devFetch.js"; import "./index.css"; +installDevFetchInterceptor(); + const root = document.getElementById("root"); if (!root) throw new Error("Root element not found"); diff --git a/apps/web/src/pages/DevLoginSelector.tsx b/apps/web/src/pages/DevLoginSelector.tsx new file mode 100644 index 0000000..e171613 --- /dev/null +++ b/apps/web/src/pages/DevLoginSelector.tsx @@ -0,0 +1,169 @@ +import { useEffect, useState } from "react"; +import { useNavigate } from "react-router-dom"; + +interface StaffUser { + id: string; + name: string; + email: string; + role: string; +} + +interface ClientUser { + id: string; + name: string; + email: string | null; + petCount: number; +} + +export function DevLoginSelector() { + const navigate = useNavigate(); + const [staff, setStaff] = useState([]); + const [clients, setClients] = useState([]); + const [loading, setLoading] = useState(true); + + useEffect(() => { + fetch("/api/dev/users") + .then((r) => r.json()) + .then((data) => { + setStaff(data.staff ?? []); + setClients(data.clients ?? []); + }) + .finally(() => setLoading(false)); + }, []); + + function selectUser(type: "staff" | "client", id: string, name: string) { + localStorage.setItem("dev-user", JSON.stringify({ type, id, name })); + navigate(type === "staff" ? "/admin" : "/"); + } + + function skipLogin() { + localStorage.removeItem("dev-user"); + navigate("/admin"); + } + + if (loading) { + return ( +
+

Loading users...

+
+ ); + } + + return ( +
+
+
+

+ GroomBook +

+

+ Dev Login Selector +

+
+ +

Staff

+
+ {staff.map((s) => ( + + ))} +
+ +

Clients

+
+ {clients.map((cl) => ( + + ))} +
+ +
+ +
+
+
+ ); +} + +export function getDevUser(): { type: string; id: string; name: string } | null { + try { + const raw = localStorage.getItem("dev-user"); + if (!raw) return null; + return JSON.parse(raw); + } catch { + return null; + } +} + +export function clearDevUser() { + localStorage.removeItem("dev-user"); +} + +const containerStyle: React.CSSProperties = { + minHeight: "100vh", + display: "flex", + alignItems: "center", + justifyContent: "center", + fontFamily: "system-ui, sans-serif", + background: "#f0f2f5", + padding: "1rem", +}; + +const cardStyle: React.CSSProperties = { + background: "#fff", + borderRadius: 12, + padding: "2rem", + width: "100%", + maxWidth: 420, + boxShadow: "0 4px 12px rgba(0, 0, 0, 0.08)", +}; + +const sectionStyle: React.CSSProperties = { + fontSize: 11, + fontWeight: 600, + color: "#6b7280", + textTransform: "uppercase", + letterSpacing: "0.05em", + margin: "0 0 0.5rem", +}; + +const userButtonStyle: React.CSSProperties = { + display: "block", + width: "100%", + padding: "0.75rem 1rem", + border: "1px solid #e5e7eb", + borderRadius: 8, + background: "#fff", + cursor: "pointer", + textAlign: "left", + transition: "border-color 0.15s, background 0.15s", +}; + +const skipButtonStyle: React.CSSProperties = { + padding: "0.5rem 1.25rem", + border: "1px solid #d1d5db", + borderRadius: 6, + background: "transparent", + cursor: "pointer", + fontSize: 13, + color: "#6b7280", +}; -- 2.52.0 From f2501d99721273f4bcd9de4e44b69a1c673bf5dc Mon Sep 17 00:00:00 2001 From: "groombook-paperclip[bot]" <268890960+groombook-paperclip[bot]@users.noreply.github.com> Date: Thu, 19 Mar 2026 11:07:07 +0000 Subject: [PATCH 7/9] feat: customizable business branding (name, logo, colors) (#63) * feat: add customizable business branding (name, logo, colors) Add admin settings for business branding with name, logo upload, and color scheme via CSS custom properties. Includes database migration, API endpoints, admin settings page, and dynamic branding in both admin nav and customer portal. Closes #61 Co-Authored-By: Claude Opus 4.6 * fix: address review feedback on branding PR - Replace dynamic import with static import for @groombook/db in public branding endpoint - Restore active nav item background highlight (bg-stone-100) in CustomerPortal - Remove non-null assertion in settings route, add proper error handling Co-Authored-By: Claude Opus 4.6 * chore: trigger CI * fix: resolve lint error and test failure for branding feature Co-Authored-By: Claude Opus 4.6 * fix: update E2E tests for branding changes - Update navigation test to expect "GroomBook" (default branding) instead of hardcoded "Paws & Reflect" since CustomerPortal now uses dynamic branding - Add /api/branding mock to shared E2E fixtures so BrandingProvider resolves immediately in all tests, preventing unhandled fetch interference Co-Authored-By: Claude Opus 4.6 --------- Co-authored-by: GroomBook CTO Co-authored-by: Claude Opus 4.6 Co-authored-by: GroomBook CTO --- apps/api/src/index.ts | 17 + apps/api/src/routes/settings.ts | 60 ++++ apps/e2e/tests/fixtures.ts | 12 + apps/e2e/tests/navigation.spec.ts | 2 +- apps/web/src/App.tsx | 63 ++-- apps/web/src/BrandingContext.tsx | 55 +++ apps/web/src/__tests__/App.test.tsx | 36 ++ apps/web/src/index.css | 7 +- apps/web/src/pages/Settings.tsx | 323 ++++++++++++++++++ apps/web/src/portal/CustomerPortal.tsx | 26 +- .../db/migrations/0008_business_settings.sql | 15 + packages/db/src/schema.ts | 11 + packages/types/src/index.ts | 11 + 13 files changed, 606 insertions(+), 32 deletions(-) create mode 100644 apps/api/src/routes/settings.ts create mode 100644 apps/web/src/BrandingContext.tsx create mode 100644 apps/web/src/pages/Settings.tsx create mode 100644 packages/db/migrations/0008_business_settings.sql diff --git a/apps/api/src/index.ts b/apps/api/src/index.ts index 0e169e7..07a0014 100644 --- a/apps/api/src/index.ts +++ b/apps/api/src/index.ts @@ -12,6 +12,8 @@ import { bookRouter } from "./routes/book.js"; import { reportsRouter } from "./routes/reports.js"; import { appointmentGroupsRouter } from "./routes/appointmentGroups.js"; import { groomingLogsRouter } from "./routes/groomingLogs.js"; +import { settingsRouter } from "./routes/settings.js"; +import { getDb, businessSettings } from "@groombook/db"; import { authMiddleware } from "./middleware/auth.js"; import { devRouter } from "./routes/dev.js"; import { startReminderScheduler } from "./services/reminders.js"; @@ -37,6 +39,20 @@ app.route("/api/book", bookRouter); // Dev/demo routes — config is always public, users endpoint is guarded internally app.route("/api/dev", devRouter); +// Public branding endpoint — no auth required, returns business name/colors/logo +app.get("/api/branding", async (c) => { + const db = getDb(); + const [row] = await db.select().from(businessSettings).limit(1); + const settings = row ?? { businessName: "GroomBook", primaryColor: "#4f8a6f", accentColor: "#8b7355", logoBase64: null, logoMimeType: null }; + return c.json({ + businessName: settings.businessName, + primaryColor: settings.primaryColor, + accentColor: settings.accentColor, + logoBase64: settings.logoBase64, + logoMimeType: settings.logoMimeType, + }); +}); + // Protected API routes const api = app.basePath("/api"); api.use("*", authMiddleware); @@ -50,6 +66,7 @@ api.route("/invoices", invoicesRouter); api.route("/reports", reportsRouter); api.route("/appointment-groups", appointmentGroupsRouter); api.route("/grooming-logs", groomingLogsRouter); +api.route("/admin/settings", settingsRouter); const port = Number(process.env.PORT ?? 3000); console.log(`API server listening on port ${port}`); diff --git a/apps/api/src/routes/settings.ts b/apps/api/src/routes/settings.ts new file mode 100644 index 0000000..2641c8c --- /dev/null +++ b/apps/api/src/routes/settings.ts @@ -0,0 +1,60 @@ +import { Hono } from "hono"; +import { zValidator } from "@hono/zod-validator"; +import { z } from "zod"; +import { eq, getDb, businessSettings } from "@groombook/db"; + +export const settingsRouter = new Hono(); + +// GET /api/admin/settings — return current business settings +settingsRouter.get("/", async (c) => { + const db = getDb(); + const [row] = await db.select().from(businessSettings).limit(1); + if (!row) { + // Auto-create default settings if none exist + const [created] = await db.insert(businessSettings).values({}).returning(); + return c.json(created); + } + return c.json(row); +}); + +const hexColorRegex = /^#[0-9a-fA-F]{6}$/; + +const updateSettingsSchema = z.object({ + businessName: z.string().min(1).max(200).optional(), + primaryColor: z.string().regex(hexColorRegex, "Must be a hex color like #4f8a6f").optional(), + accentColor: z.string().regex(hexColorRegex, "Must be a hex color like #8b7355").optional(), + logoBase64: z.string().max(700_000).nullable().optional(), // ~512KB base64 + logoMimeType: z + .enum(["image/png", "image/svg+xml", "image/jpeg", "image/webp"]) + .nullable() + .optional(), +}); + +// PATCH /api/admin/settings — update business settings +settingsRouter.patch( + "/", + zValidator("json", updateSettingsSchema), + async (c) => { + const db = getDb(); + const body = c.req.valid("json"); + + // Get or create the settings row + const rows = await db.select().from(businessSettings).limit(1); + let settingsId: string; + if (rows[0]) { + settingsId = rows[0].id; + } else { + const [inserted] = await db.insert(businessSettings).values({}).returning(); + if (!inserted) throw new Error("Failed to create default settings"); + settingsId = inserted.id; + } + + const [updated] = await db + .update(businessSettings) + .set({ ...body, updatedAt: new Date() }) + .where(eq(businessSettings.id, settingsId)) + .returning(); + + return c.json(updated); + } +); diff --git a/apps/e2e/tests/fixtures.ts b/apps/e2e/tests/fixtures.ts index bf26d5f..6dc1c72 100644 --- a/apps/e2e/tests/fixtures.ts +++ b/apps/e2e/tests/fixtures.ts @@ -16,6 +16,18 @@ export const test = base.extend({ await page.route("**/api/dev/config", (route) => route.fulfill({ json: { authDisabled: false } }) ); + // Mock the branding endpoint so BrandingProvider resolves immediately + await page.route("**/api/branding", (route) => + route.fulfill({ + json: { + businessName: "GroomBook", + primaryColor: "#4f8a6f", + accentColor: "#8b7355", + logoBase64: null, + logoMimeType: null, + }, + }) + ); // Seed localStorage as a fallback in case the mock is bypassed await page.addInitScript(() => { localStorage.setItem( diff --git a/apps/e2e/tests/navigation.spec.ts b/apps/e2e/tests/navigation.spec.ts index e79a5f6..544518a 100644 --- a/apps/e2e/tests/navigation.spec.ts +++ b/apps/e2e/tests/navigation.spec.ts @@ -42,7 +42,7 @@ test.beforeEach(async ({ page }) => { test("customer portal loads at root", async ({ page }) => { await page.goto("/"); - await expect(page.getByRole("navigation").getByText("Paws & Reflect")).toBeVisible(); + await expect(page.getByRole("navigation").getByText("GroomBook")).toBeVisible(); await expect(page.locator("nav")).toBeVisible(); }); diff --git a/apps/web/src/App.tsx b/apps/web/src/App.tsx index a9e9d89..23ecc24 100644 --- a/apps/web/src/App.tsx +++ b/apps/web/src/App.tsx @@ -8,9 +8,11 @@ import { InvoicesPage } from "./pages/Invoices.js"; import { BookPage } from "./pages/Book.js"; import { ReportsPage } from "./pages/Reports.js"; import { GroupBookingPage } from "./pages/GroupBooking.js"; +import { SettingsPage } from "./pages/Settings.js"; import { CustomerPortal } from "./portal/CustomerPortal.js"; import { DevLoginSelector, getDevUser } from "./pages/DevLoginSelector.js"; import { DevSessionIndicator } from "./components/DevSessionIndicator.js"; +import { BrandingProvider, useBranding } from "./BrandingContext.js"; const NAV_LINKS = [ { to: "/admin", label: "Appointments" }, @@ -20,11 +22,18 @@ const NAV_LINKS = [ { to: "/admin/invoices", label: "Invoices" }, { to: "/admin/group-bookings", label: "Group Bookings" }, { to: "/admin/reports", label: "Reports" }, + { to: "/admin/settings", label: "Settings" }, { to: "/", label: "Customer Portal" }, ]; function AdminLayout() { const location = useLocation(); + const { branding } = useBranding(); + + const logoSrc = branding.logoBase64 && branding.logoMimeType + ? `data:${branding.logoMimeType};base64,${branding.logoBase64}` + : null; + return (
} /> } /> } /> + } />
@@ -130,21 +149,21 @@ export function App() { return ; } - if (location.pathname.startsWith("/admin")) { - return ( - <> - - } /> - - {authDisabled && } - - ); - } - return ( - <> - - {authDisabled && } - + + {location.pathname.startsWith("/admin") ? ( + <> + + } /> + + {authDisabled && } + + ) : ( + <> + + {authDisabled && } + + )} + ); } diff --git a/apps/web/src/BrandingContext.tsx b/apps/web/src/BrandingContext.tsx new file mode 100644 index 0000000..00a761c --- /dev/null +++ b/apps/web/src/BrandingContext.tsx @@ -0,0 +1,55 @@ +import { createContext, useContext, useEffect, useState, useCallback } from "react"; + +export interface Branding { + businessName: string; + primaryColor: string; + accentColor: string; + logoBase64: string | null; + logoMimeType: string | null; +} + +const DEFAULT_BRANDING: Branding = { + businessName: "GroomBook", + primaryColor: "#4f8a6f", + accentColor: "#8b7355", + logoBase64: null, + logoMimeType: null, +}; + +const BrandingContext = createContext<{ + branding: Branding; + refresh: () => void; +}>({ branding: DEFAULT_BRANDING, refresh: () => {} }); + +export function useBranding() { + return useContext(BrandingContext); +} + +export function BrandingProvider({ children }: { children: React.ReactNode }) { + const [branding, setBranding] = useState(DEFAULT_BRANDING); + + const fetchBranding = useCallback(() => { + fetch("/api/branding") + .then((r) => (r.ok ? r.json() : null)) + .then((data) => { + if (data && typeof data.businessName === "string") setBranding(data); + }) + .catch(() => {}); + }, []); + + useEffect(() => { + fetchBranding(); + }, [fetchBranding]); + + // Apply CSS custom properties whenever branding changes + useEffect(() => { + document.documentElement.style.setProperty("--color-primary", branding.primaryColor); + document.documentElement.style.setProperty("--color-accent", branding.accentColor); + }, [branding.primaryColor, branding.accentColor]); + + return ( + + {children} + + ); +} diff --git a/apps/web/src/__tests__/App.test.tsx b/apps/web/src/__tests__/App.test.tsx index 7da9657..97434eb 100644 --- a/apps/web/src/__tests__/App.test.tsx +++ b/apps/web/src/__tests__/App.test.tsx @@ -13,6 +13,18 @@ beforeEach(() => { json: async () => ({ authDisabled: 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 () => [], @@ -100,6 +112,18 @@ describe("Dev login selector", () => { }), } 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; @@ -125,6 +149,18 @@ describe("Dev login selector", () => { json: async () => ({ authDisabled: true }), } 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; diff --git a/apps/web/src/index.css b/apps/web/src/index.css index 6d09d02..7101912 100644 --- a/apps/web/src/index.css +++ b/apps/web/src/index.css @@ -1,5 +1,10 @@ @import "tailwindcss"; +:root { + --color-primary: #4f8a6f; + --color-accent: #8b7355; +} + *, *::before, *::after { box-sizing: border-box; } @@ -16,7 +21,7 @@ body { } a { - color: #3d7a5f; + color: var(--color-primary); text-decoration: none; } diff --git a/apps/web/src/pages/Settings.tsx b/apps/web/src/pages/Settings.tsx new file mode 100644 index 0000000..09ff522 --- /dev/null +++ b/apps/web/src/pages/Settings.tsx @@ -0,0 +1,323 @@ +import { useState, useEffect, useRef } from "react"; +import { useBranding } from "../BrandingContext.js"; + +interface SettingsForm { + businessName: string; + primaryColor: string; + accentColor: string; + logoBase64: string | null; + logoMimeType: string | null; +} + +export function SettingsPage() { + const { refresh } = useBranding(); + const [form, setForm] = useState({ + businessName: "", + primaryColor: "#4f8a6f", + accentColor: "#8b7355", + logoBase64: null, + logoMimeType: null, + }); + const [saving, setSaving] = useState(false); + const [message, setMessage] = useState<{ type: "success" | "error"; text: string } | null>(null); + const [loaded, setLoaded] = useState(false); + const fileInputRef = useRef(null); + + useEffect(() => { + fetch("/api/admin/settings") + .then((r) => r.json()) + .then((data) => { + setForm({ + businessName: data.businessName ?? "GroomBook", + primaryColor: data.primaryColor ?? "#4f8a6f", + accentColor: data.accentColor ?? "#8b7355", + logoBase64: data.logoBase64 ?? null, + logoMimeType: data.logoMimeType ?? null, + }); + setLoaded(true); + }) + .catch(() => setLoaded(true)); + }, []); + + const handleLogoChange = (e: React.ChangeEvent) => { + const file = e.target.files?.[0]; + if (!file) return; + + if (file.size > 512 * 1024) { + setMessage({ type: "error", text: "Logo must be under 512KB." }); + return; + } + + const validTypes = ["image/png", "image/svg+xml", "image/jpeg", "image/webp"]; + if (!validTypes.includes(file.type)) { + setMessage({ type: "error", text: "Logo must be PNG, SVG, JPEG, or WebP." }); + return; + } + + const reader = new FileReader(); + reader.onload = () => { + const result = reader.result as string; + // Strip the data:...;base64, prefix + const base64 = result.split(",")[1] ?? null; + setForm((f) => ({ ...f, logoBase64: base64, logoMimeType: file.type as SettingsForm["logoMimeType"] })); + setMessage(null); + }; + reader.readAsDataURL(file); + }; + + const handleSave = async () => { + setSaving(true); + setMessage(null); + try { + const res = await fetch("/api/admin/settings", { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(form), + }); + if (!res.ok) { + const err = await res.json().catch(() => null); + throw new Error(err?.error ?? "Failed to save settings"); + } + setMessage({ type: "success", text: "Settings saved." }); + refresh(); + } catch (err: unknown) { + setMessage({ type: "error", text: err instanceof Error ? err.message : "Save failed" }); + } finally { + setSaving(false); + } + }; + + if (!loaded) return

Loading settings...

; + + const logoSrc = form.logoBase64 && form.logoMimeType + ? `data:${form.logoMimeType};base64,${form.logoBase64}` + : null; + + return ( +
+

Branding & Appearance

+

+ Customize your business name, logo, and color scheme. +

+ + {/* Business Name */} +
+ + setForm((f) => ({ ...f, businessName: e.target.value }))} + style={{ + width: "100%", + padding: "0.5rem 0.75rem", + border: "1px solid #d1d5db", + borderRadius: 6, + fontSize: 14, + }} + /> +
+ + {/* Logo Upload */} +
+ +
+ {logoSrc ? ( + Logo preview + ) : ( +
+ No logo +
+ )} +
+ + + {logoSrc && ( + + )} +

+ PNG, SVG, JPEG, or WebP. Max 512KB. +

+
+
+
+ + {/* Color Pickers */} +
+
+ +
+ setForm((f) => ({ ...f, primaryColor: e.target.value }))} + style={{ width: 40, height: 40, border: "none", cursor: "pointer" }} + /> + setForm((f) => ({ ...f, primaryColor: e.target.value }))} + style={{ + width: 90, + padding: "0.4rem 0.5rem", + border: "1px solid #d1d5db", + borderRadius: 6, + fontSize: 13, + fontFamily: "monospace", + }} + /> +
+
+
+ +
+ setForm((f) => ({ ...f, accentColor: e.target.value }))} + style={{ width: 40, height: 40, border: "none", cursor: "pointer" }} + /> + setForm((f) => ({ ...f, accentColor: e.target.value }))} + style={{ + width: 90, + padding: "0.4rem 0.5rem", + border: "1px solid #d1d5db", + borderRadius: 6, + fontSize: 13, + fontFamily: "monospace", + }} + /> +
+
+
+ + {/* Preview */} +
+

Preview

+
+ {logoSrc && ( + + )} + {form.businessName} + + Button + + + Accent + +
+
+ + {/* Save */} + {message && ( +
+ {message.text} +
+ )} + + +
+ ); +} diff --git a/apps/web/src/portal/CustomerPortal.tsx b/apps/web/src/portal/CustomerPortal.tsx index 22a4d1c..e70543a 100644 --- a/apps/web/src/portal/CustomerPortal.tsx +++ b/apps/web/src/portal/CustomerPortal.tsx @@ -14,6 +14,7 @@ import { ImpersonationBanner } from "./ImpersonationBanner.js"; import { AuditLogViewer } from "./AuditLogViewer.js"; import type { ImpersonationSession, AuditEntry } from "./mockData.js"; import { CUSTOMER } from "./mockData.js"; +import { useBranding } from "../BrandingContext.js"; type Section = "dashboard" | "appointments" | "pets" | "reports" | "billing" | "messages" | "settings"; @@ -98,6 +99,7 @@ export function CustomerPortal() { const [showAuditLog, setShowAuditLog] = useState(false); const [showImpersonationSetup, setShowImpersonationSetup] = useState(false); const [impersonation, dispatchImpersonation] = useReducer(impersonationReducer, null); + const { branding } = useBranding(); const logPageView = useCallback((page: string) => { if (impersonation?.active) { @@ -180,8 +182,8 @@ export function CustomerPortal() { - Paws & Reflect -
+ {branding.businessName} +
SM
@@ -195,11 +197,19 @@ export function CustomerPortal() { flex flex-col transition-transform duration-200 `}>
-
- 🐾 -
+ {branding.logoBase64 && branding.logoMimeType ? ( + + ) : ( +
+ 🐾 +
+ )}
-
Paws & Reflect
+
{branding.businessName}
Grooming
@@ -214,7 +224,7 @@ export function CustomerPortal() { className={` w-full flex items-center gap-3 px-3 py-2.5 rounded-lg text-sm font-medium transition-colors ${active - ? "bg-[#f0ebe4] text-[#6b5a42]" + ? "bg-stone-100 text-stone-800 font-semibold" : "text-stone-600 hover:bg-stone-50 hover:text-stone-900" } `} @@ -270,7 +280,7 @@ export function CustomerPortal() {
Hi, {CUSTOMER.name.split(" ")[0]} -
+
SM
diff --git a/packages/db/migrations/0008_business_settings.sql b/packages/db/migrations/0008_business_settings.sql new file mode 100644 index 0000000..7b851c6 --- /dev/null +++ b/packages/db/migrations/0008_business_settings.sql @@ -0,0 +1,15 @@ +CREATE TABLE IF NOT EXISTS "business_settings" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "business_name" text DEFAULT 'GroomBook' NOT NULL, + "logo_base64" text, + "logo_mime_type" text, + "primary_color" text DEFAULT '#4f8a6f' NOT NULL, + "accent_color" text DEFAULT '#8b7355' NOT NULL, + "created_at" timestamp DEFAULT now() NOT NULL, + "updated_at" timestamp DEFAULT now() NOT NULL +); + +-- Seed a default row so GET always returns something +INSERT INTO "business_settings" ("business_name", "primary_color", "accent_color") +VALUES ('GroomBook', '#4f8a6f', '#8b7355') +ON CONFLICT DO NOTHING; diff --git a/packages/db/src/schema.ts b/packages/db/src/schema.ts index 8987c2a..c7db30c 100644 --- a/packages/db/src/schema.ts +++ b/packages/db/src/schema.ts @@ -218,6 +218,17 @@ export const reminderLogs = pgTable( (t) => [unique().on(t.appointmentId, t.reminderType)] ); +export const businessSettings = pgTable("business_settings", { + id: uuid("id").primaryKey().defaultRandom(), + businessName: text("business_name").notNull().default("GroomBook"), + logoBase64: text("logo_base64"), + logoMimeType: text("logo_mime_type"), + primaryColor: text("primary_color").notNull().default("#4f8a6f"), + accentColor: text("accent_color").notNull().default("#8b7355"), + createdAt: timestamp("created_at").notNull().defaultNow(), + updatedAt: timestamp("updated_at").notNull().defaultNow(), +}); + export const groomingVisitLogs = pgTable("grooming_visit_logs", { id: uuid("id").primaryKey().defaultRandom(), petId: uuid("pet_id") diff --git a/packages/types/src/index.ts b/packages/types/src/index.ts index dae5721..5b3250c 100644 --- a/packages/types/src/index.ts +++ b/packages/types/src/index.ts @@ -145,6 +145,17 @@ export interface Invoice { tipSplits?: InvoiceTipSplit[]; } +export interface BusinessSettings { + id: string; + businessName: string; + logoBase64: string | null; + logoMimeType: string | null; + primaryColor: string; + accentColor: string; + createdAt: string; + updatedAt: string; +} + // Paginated list response export interface PaginatedList { items: T[]; -- 2.52.0 From 12ad7c66a09470a0029b742f641ff378dd72654f Mon Sep 17 00:00:00 2001 From: "groombook-paperclip[bot]" <268890960+groombook-paperclip[bot]@users.noreply.github.com> Date: Thu, 19 Mar 2026 12:47:26 +0000 Subject: [PATCH 8/9] feat: add View as Customer impersonation button on Clients page (#64) Staff can now click "View as Customer" on any client profile in the admin panel. This navigates to the customer portal with impersonation auto-activated, showing the portal exactly as that customer would see it (read-only, with full audit trail). The portal reads impersonate/clientName/reason/staffName from URL search params on mount, auto-starts the impersonation session, then cleans up the URL. Co-authored-by: Groom Book CTO Co-authored-by: Paperclip --- apps/web/src/pages/Clients.tsx | 6 ++++++ apps/web/src/portal/CustomerPortal.tsx | 23 ++++++++++++++++++++++- 2 files changed, 28 insertions(+), 1 deletion(-) diff --git a/apps/web/src/pages/Clients.tsx b/apps/web/src/pages/Clients.tsx index 50df051..242d33a 100644 --- a/apps/web/src/pages/Clients.tsx +++ b/apps/web/src/pages/Clients.tsx @@ -360,6 +360,12 @@ export function ClientsPage() { )}
+ + 👁 View as Customer + diff --git a/apps/web/src/portal/CustomerPortal.tsx b/apps/web/src/portal/CustomerPortal.tsx index e70543a..bb77072 100644 --- a/apps/web/src/portal/CustomerPortal.tsx +++ b/apps/web/src/portal/CustomerPortal.tsx @@ -1,4 +1,4 @@ -import { useState, useReducer, useCallback } from "react"; +import { useState, useReducer, useCallback, useEffect } from "react"; import { Home, Calendar, PawPrint, FileText, CreditCard, MessageSquare, Settings, Eye, LogOut, Clock, Shield, @@ -101,6 +101,27 @@ export function CustomerPortal() { const [impersonation, dispatchImpersonation] = useReducer(impersonationReducer, null); const { branding } = useBranding(); + // Auto-start impersonation from URL params (staff flow from admin panel). + // Runs once on mount only — impersonation state is managed by the reducer after init. + const [impersonationInitDone, setImpersonationInitDone] = useState(false); + useEffect(() => { + if (impersonationInitDone) return; + const params = new URLSearchParams(window.location.search); + if (params.get("impersonate") === "true") { + const clientName = params.get("clientName") || "Unknown Customer"; + const reason = params.get("reason") || `Viewing portal as ${clientName}`; + const staffName = params.get("staffName") || "Staff"; + dispatchImpersonation({ + type: "START", + staffName, + staffRole: "Admin", + reason, + }); + window.history.replaceState({}, "", window.location.pathname); + } + setImpersonationInitDone(true); + }, [impersonationInitDone]); + const logPageView = useCallback((page: string) => { if (impersonation?.active) { dispatchImpersonation({ -- 2.52.0 From 4289abaa7fccb3c15b44a012438b1a9ce1dd7edb Mon Sep 17 00:00:00 2001 From: Groom Book CTO Date: Thu, 19 Mar 2026 12:56:30 +0000 Subject: [PATCH 9/9] fix(e2e): block service workers to prevent route mock bypass The PWA service worker (VitePWA workbox runtimeCaching) intercepts /api/* requests, which prevents Playwright's page.route() mocks from working. This caused the booking flow E2E test to fail because the availability request was handled by the service worker instead of the test mock, resulting in real (empty) API responses. Fixes #65 Co-Authored-By: Paperclip --- apps/e2e/playwright.config.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/apps/e2e/playwright.config.ts b/apps/e2e/playwright.config.ts index c25a7d2..e0970b4 100644 --- a/apps/e2e/playwright.config.ts +++ b/apps/e2e/playwright.config.ts @@ -22,6 +22,7 @@ export default defineConfig({ baseURL: "http://localhost:8080", trace: "on-first-retry", screenshot: "only-on-failure", + serviceWorkers: "block", }, projects: [ -- 2.52.0