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 ? ( + 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..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]} -
+
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[];