diff --git a/apps/api/src/index.ts b/apps/api/src/index.ts
index 0e169e7..0c09314 100644
--- a/apps/api/src/index.ts
+++ b/apps/api/src/index.ts
@@ -12,6 +12,7 @@ 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 { authMiddleware } from "./middleware/auth.js";
import { devRouter } from "./routes/dev.js";
import { startReminderScheduler } from "./services/reminders.js";
@@ -37,6 +38,21 @@ 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 { getDb, businessSettings } = await import("@groombook/db");
+ 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..86c7090
--- /dev/null
+++ b/apps/api/src/routes/settings.ts
@@ -0,0 +1,59 @@
+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();
+ settingsId = inserted[0]!.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/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..9b8233a
--- /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) 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/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..d44c2ed
--- /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 { branding, 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 ? (
+

+ ) : (
+
+ 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..82e6d2d 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]"
+ ? "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]}
-
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[];