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] 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[];