feat: customizable business branding (name, logo, colors) #63
@@ -12,6 +12,8 @@ import { bookRouter } from "./routes/book.js";
|
|||||||
import { reportsRouter } from "./routes/reports.js";
|
import { reportsRouter } from "./routes/reports.js";
|
||||||
import { appointmentGroupsRouter } from "./routes/appointmentGroups.js";
|
import { appointmentGroupsRouter } from "./routes/appointmentGroups.js";
|
||||||
import { groomingLogsRouter } from "./routes/groomingLogs.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 { authMiddleware } from "./middleware/auth.js";
|
||||||
import { devRouter } from "./routes/dev.js";
|
import { devRouter } from "./routes/dev.js";
|
||||||
import { startReminderScheduler } from "./services/reminders.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
|
// Dev/demo routes — config is always public, users endpoint is guarded internally
|
||||||
app.route("/api/dev", devRouter);
|
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
|
// Protected API routes
|
||||||
const api = app.basePath("/api");
|
const api = app.basePath("/api");
|
||||||
api.use("*", authMiddleware);
|
api.use("*", authMiddleware);
|
||||||
@@ -50,6 +66,7 @@ api.route("/invoices", invoicesRouter);
|
|||||||
api.route("/reports", reportsRouter);
|
api.route("/reports", reportsRouter);
|
||||||
api.route("/appointment-groups", appointmentGroupsRouter);
|
api.route("/appointment-groups", appointmentGroupsRouter);
|
||||||
api.route("/grooming-logs", groomingLogsRouter);
|
api.route("/grooming-logs", groomingLogsRouter);
|
||||||
|
api.route("/admin/settings", settingsRouter);
|
||||||
|
|
||||||
const port = Number(process.env.PORT ?? 3000);
|
const port = Number(process.env.PORT ?? 3000);
|
||||||
console.log(`API server listening on port ${port}`);
|
console.log(`API server listening on port ${port}`);
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
);
|
||||||
@@ -16,6 +16,18 @@ export const test = base.extend({
|
|||||||
await page.route("**/api/dev/config", (route) =>
|
await page.route("**/api/dev/config", (route) =>
|
||||||
route.fulfill({ json: { authDisabled: false } })
|
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
|
// Seed localStorage as a fallback in case the mock is bypassed
|
||||||
await page.addInitScript(() => {
|
await page.addInitScript(() => {
|
||||||
localStorage.setItem(
|
localStorage.setItem(
|
||||||
|
|||||||
@@ -42,7 +42,7 @@ test.beforeEach(async ({ page }) => {
|
|||||||
|
|
||||||
test("customer portal loads at root", async ({ page }) => {
|
test("customer portal loads at root", async ({ page }) => {
|
||||||
await page.goto("/");
|
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();
|
await expect(page.locator("nav")).toBeVisible();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
+41
-22
@@ -8,9 +8,11 @@ import { InvoicesPage } from "./pages/Invoices.js";
|
|||||||
import { BookPage } from "./pages/Book.js";
|
import { BookPage } from "./pages/Book.js";
|
||||||
import { ReportsPage } from "./pages/Reports.js";
|
import { ReportsPage } from "./pages/Reports.js";
|
||||||
import { GroupBookingPage } from "./pages/GroupBooking.js";
|
import { GroupBookingPage } from "./pages/GroupBooking.js";
|
||||||
|
import { SettingsPage } from "./pages/Settings.js";
|
||||||
import { CustomerPortal } from "./portal/CustomerPortal.js";
|
import { CustomerPortal } from "./portal/CustomerPortal.js";
|
||||||
import { DevLoginSelector, getDevUser } from "./pages/DevLoginSelector.js";
|
import { DevLoginSelector, getDevUser } from "./pages/DevLoginSelector.js";
|
||||||
import { DevSessionIndicator } from "./components/DevSessionIndicator.js";
|
import { DevSessionIndicator } from "./components/DevSessionIndicator.js";
|
||||||
|
import { BrandingProvider, useBranding } from "./BrandingContext.js";
|
||||||
|
|
||||||
const NAV_LINKS = [
|
const NAV_LINKS = [
|
||||||
{ to: "/admin", label: "Appointments" },
|
{ to: "/admin", label: "Appointments" },
|
||||||
@@ -20,11 +22,18 @@ const NAV_LINKS = [
|
|||||||
{ to: "/admin/invoices", label: "Invoices" },
|
{ to: "/admin/invoices", label: "Invoices" },
|
||||||
{ to: "/admin/group-bookings", label: "Group Bookings" },
|
{ to: "/admin/group-bookings", label: "Group Bookings" },
|
||||||
{ to: "/admin/reports", label: "Reports" },
|
{ to: "/admin/reports", label: "Reports" },
|
||||||
|
{ to: "/admin/settings", label: "Settings" },
|
||||||
{ to: "/", label: "Customer Portal" },
|
{ to: "/", label: "Customer Portal" },
|
||||||
];
|
];
|
||||||
|
|
||||||
function AdminLayout() {
|
function AdminLayout() {
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
|
const { branding } = useBranding();
|
||||||
|
|
||||||
|
const logoSrc = branding.logoBase64 && branding.logoMimeType
|
||||||
|
? `data:${branding.logoMimeType};base64,${branding.logoBase64}`
|
||||||
|
: null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div style={{ minHeight: "100vh", fontFamily: "system-ui, sans-serif", background: "#f0f2f5" }}>
|
<div style={{ minHeight: "100vh", fontFamily: "system-ui, sans-serif", background: "#f0f2f5" }}>
|
||||||
<nav
|
<nav
|
||||||
@@ -42,14 +51,23 @@ function AdminLayout() {
|
|||||||
zIndex: 50,
|
zIndex: 50,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<strong style={{
|
<div style={{
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: 8,
|
||||||
marginRight: "1.25rem",
|
marginRight: "1.25rem",
|
||||||
fontSize: 17,
|
|
||||||
color: "#1a202c",
|
|
||||||
letterSpacing: "-0.02em",
|
|
||||||
}}>
|
}}>
|
||||||
<span style={{ color: "#4f8a6f" }}>Groom</span>Book
|
{logoSrc && (
|
||||||
</strong>
|
<img src={logoSrc} alt="" style={{ width: 24, height: 24, objectFit: "contain" }} />
|
||||||
|
)}
|
||||||
|
<strong style={{
|
||||||
|
fontSize: 17,
|
||||||
|
color: "#1a202c",
|
||||||
|
letterSpacing: "-0.02em",
|
||||||
|
}}>
|
||||||
|
{branding.businessName}
|
||||||
|
</strong>
|
||||||
|
</div>
|
||||||
<Link
|
<Link
|
||||||
to="/admin/book"
|
to="/admin/book"
|
||||||
style={{
|
style={{
|
||||||
@@ -59,7 +77,7 @@ function AdminLayout() {
|
|||||||
fontSize: 13,
|
fontSize: 13,
|
||||||
fontWeight: 600,
|
fontWeight: 600,
|
||||||
color: "#fff",
|
color: "#fff",
|
||||||
background: "linear-gradient(135deg, #4f8a6f, #3d7a5f)",
|
background: branding.primaryColor,
|
||||||
marginRight: "0.5rem",
|
marginRight: "0.5rem",
|
||||||
boxShadow: "0 1px 2px rgba(79, 138, 111, 0.3)",
|
boxShadow: "0 1px 2px rgba(79, 138, 111, 0.3)",
|
||||||
}}
|
}}
|
||||||
@@ -100,6 +118,7 @@ function AdminLayout() {
|
|||||||
<Route path="/book" element={<BookPage />} />
|
<Route path="/book" element={<BookPage />} />
|
||||||
<Route path="/group-bookings" element={<GroupBookingPage />} />
|
<Route path="/group-bookings" element={<GroupBookingPage />} />
|
||||||
<Route path="/reports" element={<ReportsPage />} />
|
<Route path="/reports" element={<ReportsPage />} />
|
||||||
|
<Route path="/settings" element={<SettingsPage />} />
|
||||||
</Routes>
|
</Routes>
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
@@ -130,21 +149,21 @@ export function App() {
|
|||||||
return <Navigate to="/login" replace />;
|
return <Navigate to="/login" replace />;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (location.pathname.startsWith("/admin")) {
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<Routes>
|
|
||||||
<Route path="/admin/*" element={<AdminLayout />} />
|
|
||||||
</Routes>
|
|
||||||
{authDisabled && <DevSessionIndicator />}
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<BrandingProvider>
|
||||||
<CustomerPortal />
|
{location.pathname.startsWith("/admin") ? (
|
||||||
{authDisabled && <DevSessionIndicator />}
|
<>
|
||||||
</>
|
<Routes>
|
||||||
|
<Route path="/admin/*" element={<AdminLayout />} />
|
||||||
|
</Routes>
|
||||||
|
{authDisabled && <DevSessionIndicator />}
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<CustomerPortal />
|
||||||
|
{authDisabled && <DevSessionIndicator />}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</BrandingProvider>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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<Branding>(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 (
|
||||||
|
<BrandingContext.Provider value={{ branding, refresh: fetchBranding }}>
|
||||||
|
{children}
|
||||||
|
</BrandingContext.Provider>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -13,6 +13,18 @@ beforeEach(() => {
|
|||||||
json: async () => ({ authDisabled: false }),
|
json: async () => ({ authDisabled: false }),
|
||||||
} as Response);
|
} 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({
|
return Promise.resolve({
|
||||||
ok: true,
|
ok: true,
|
||||||
json: async () => [],
|
json: async () => [],
|
||||||
@@ -100,6 +112,18 @@ describe("Dev login selector", () => {
|
|||||||
}),
|
}),
|
||||||
} as Response);
|
} 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);
|
return Promise.resolve({ ok: true, json: async () => [] } as Response);
|
||||||
}) as unknown as typeof fetch;
|
}) as unknown as typeof fetch;
|
||||||
|
|
||||||
@@ -125,6 +149,18 @@ describe("Dev login selector", () => {
|
|||||||
json: async () => ({ authDisabled: true }),
|
json: async () => ({ authDisabled: true }),
|
||||||
} as Response);
|
} 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);
|
return Promise.resolve({ ok: true, json: async () => [] } as Response);
|
||||||
}) as unknown as typeof fetch;
|
}) as unknown as typeof fetch;
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,10 @@
|
|||||||
@import "tailwindcss";
|
@import "tailwindcss";
|
||||||
|
|
||||||
|
:root {
|
||||||
|
--color-primary: #4f8a6f;
|
||||||
|
--color-accent: #8b7355;
|
||||||
|
}
|
||||||
|
|
||||||
*, *::before, *::after {
|
*, *::before, *::after {
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
}
|
}
|
||||||
@@ -16,7 +21,7 @@ body {
|
|||||||
}
|
}
|
||||||
|
|
||||||
a {
|
a {
|
||||||
color: #3d7a5f;
|
color: var(--color-primary);
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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<SettingsForm>({
|
||||||
|
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<HTMLInputElement>(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<HTMLInputElement>) => {
|
||||||
|
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 <p>Loading settings...</p>;
|
||||||
|
|
||||||
|
const logoSrc = form.logoBase64 && form.logoMimeType
|
||||||
|
? `data:${form.logoMimeType};base64,${form.logoBase64}`
|
||||||
|
: null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ maxWidth: 600 }}>
|
||||||
|
<h1>Branding & Appearance</h1>
|
||||||
|
<p style={{ color: "#6b7280", marginBottom: "1.5rem" }}>
|
||||||
|
Customize your business name, logo, and color scheme.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{/* Business Name */}
|
||||||
|
<div style={{ marginBottom: "1.25rem" }}>
|
||||||
|
<label style={{ display: "block", fontWeight: 600, marginBottom: 4 }}>
|
||||||
|
Business Name
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={form.businessName}
|
||||||
|
onChange={(e) => setForm((f) => ({ ...f, businessName: e.target.value }))}
|
||||||
|
style={{
|
||||||
|
width: "100%",
|
||||||
|
padding: "0.5rem 0.75rem",
|
||||||
|
border: "1px solid #d1d5db",
|
||||||
|
borderRadius: 6,
|
||||||
|
fontSize: 14,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Logo Upload */}
|
||||||
|
<div style={{ marginBottom: "1.25rem" }}>
|
||||||
|
<label style={{ display: "block", fontWeight: 600, marginBottom: 4 }}>
|
||||||
|
Logo
|
||||||
|
</label>
|
||||||
|
<div style={{ display: "flex", alignItems: "center", gap: "1rem" }}>
|
||||||
|
{logoSrc ? (
|
||||||
|
<img
|
||||||
|
src={logoSrc}
|
||||||
|
alt="Logo preview"
|
||||||
|
style={{ width: 64, height: 64, objectFit: "contain", borderRadius: 8, border: "1px solid #e5e7eb" }}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div style={{
|
||||||
|
width: 64, height: 64, borderRadius: 8,
|
||||||
|
border: "2px dashed #d1d5db", display: "flex",
|
||||||
|
alignItems: "center", justifyContent: "center",
|
||||||
|
color: "#9ca3af", fontSize: 12,
|
||||||
|
}}>
|
||||||
|
No logo
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div>
|
||||||
|
<button
|
||||||
|
onClick={() => fileInputRef.current?.click()}
|
||||||
|
style={{
|
||||||
|
padding: "0.4rem 0.75rem",
|
||||||
|
border: "1px solid #d1d5db",
|
||||||
|
borderRadius: 6,
|
||||||
|
background: "#fff",
|
||||||
|
cursor: "pointer",
|
||||||
|
fontSize: 13,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Upload Logo
|
||||||
|
</button>
|
||||||
|
<input
|
||||||
|
ref={fileInputRef}
|
||||||
|
type="file"
|
||||||
|
accept="image/png,image/svg+xml,image/jpeg,image/webp"
|
||||||
|
onChange={handleLogoChange}
|
||||||
|
style={{ display: "none" }}
|
||||||
|
/>
|
||||||
|
{logoSrc && (
|
||||||
|
<button
|
||||||
|
onClick={() => setForm((f) => ({ ...f, logoBase64: null, logoMimeType: null }))}
|
||||||
|
style={{
|
||||||
|
marginLeft: 8,
|
||||||
|
padding: "0.4rem 0.75rem",
|
||||||
|
border: "1px solid #fca5a5",
|
||||||
|
borderRadius: 6,
|
||||||
|
background: "#fff",
|
||||||
|
color: "#dc2626",
|
||||||
|
cursor: "pointer",
|
||||||
|
fontSize: 13,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Remove
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<p style={{ fontSize: 12, color: "#9ca3af", marginTop: 4 }}>
|
||||||
|
PNG, SVG, JPEG, or WebP. Max 512KB.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Color Pickers */}
|
||||||
|
<div style={{ display: "flex", gap: "1.5rem", marginBottom: "1.5rem" }}>
|
||||||
|
<div>
|
||||||
|
<label style={{ display: "block", fontWeight: 600, marginBottom: 4 }}>
|
||||||
|
Primary Color
|
||||||
|
</label>
|
||||||
|
<div style={{ display: "flex", alignItems: "center", gap: 8 }}>
|
||||||
|
<input
|
||||||
|
type="color"
|
||||||
|
value={form.primaryColor}
|
||||||
|
onChange={(e) => setForm((f) => ({ ...f, primaryColor: e.target.value }))}
|
||||||
|
style={{ width: 40, height: 40, border: "none", cursor: "pointer" }}
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={form.primaryColor}
|
||||||
|
onChange={(e) => 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",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label style={{ display: "block", fontWeight: 600, marginBottom: 4 }}>
|
||||||
|
Accent Color
|
||||||
|
</label>
|
||||||
|
<div style={{ display: "flex", alignItems: "center", gap: 8 }}>
|
||||||
|
<input
|
||||||
|
type="color"
|
||||||
|
value={form.accentColor}
|
||||||
|
onChange={(e) => setForm((f) => ({ ...f, accentColor: e.target.value }))}
|
||||||
|
style={{ width: 40, height: 40, border: "none", cursor: "pointer" }}
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={form.accentColor}
|
||||||
|
onChange={(e) => 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",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Preview */}
|
||||||
|
<div style={{
|
||||||
|
padding: "1rem",
|
||||||
|
border: "1px solid #e5e7eb",
|
||||||
|
borderRadius: 8,
|
||||||
|
marginBottom: "1.5rem",
|
||||||
|
background: "#fafafa",
|
||||||
|
}}>
|
||||||
|
<p style={{ fontWeight: 600, marginBottom: 8, fontSize: 13, color: "#6b7280" }}>Preview</p>
|
||||||
|
<div style={{
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: 12,
|
||||||
|
padding: "0.5rem 1rem",
|
||||||
|
background: "#fff",
|
||||||
|
borderRadius: 6,
|
||||||
|
border: "1px solid #e5e7eb",
|
||||||
|
}}>
|
||||||
|
{logoSrc && (
|
||||||
|
<img src={logoSrc} alt="" style={{ width: 28, height: 28, objectFit: "contain" }} />
|
||||||
|
)}
|
||||||
|
<strong style={{ color: form.primaryColor }}>{form.businessName}</strong>
|
||||||
|
<span style={{
|
||||||
|
marginLeft: "auto",
|
||||||
|
padding: "0.25rem 0.75rem",
|
||||||
|
borderRadius: 4,
|
||||||
|
color: "#fff",
|
||||||
|
background: form.primaryColor,
|
||||||
|
fontSize: 13,
|
||||||
|
}}>
|
||||||
|
Button
|
||||||
|
</span>
|
||||||
|
<span style={{
|
||||||
|
padding: "0.25rem 0.75rem",
|
||||||
|
borderRadius: 4,
|
||||||
|
color: "#fff",
|
||||||
|
background: form.accentColor,
|
||||||
|
fontSize: 13,
|
||||||
|
}}>
|
||||||
|
Accent
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Save */}
|
||||||
|
{message && (
|
||||||
|
<div style={{
|
||||||
|
padding: "0.5rem 0.75rem",
|
||||||
|
borderRadius: 6,
|
||||||
|
marginBottom: "1rem",
|
||||||
|
fontSize: 14,
|
||||||
|
background: message.type === "success" ? "#ecfdf5" : "#fef2f2",
|
||||||
|
color: message.type === "success" ? "#065f46" : "#991b1b",
|
||||||
|
border: `1px solid ${message.type === "success" ? "#a7f3d0" : "#fecaca"}`,
|
||||||
|
}}>
|
||||||
|
{message.text}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={handleSave}
|
||||||
|
disabled={saving || !form.businessName.trim()}
|
||||||
|
style={{
|
||||||
|
padding: "0.5rem 1.5rem",
|
||||||
|
borderRadius: 6,
|
||||||
|
border: "none",
|
||||||
|
background: form.primaryColor,
|
||||||
|
color: "#fff",
|
||||||
|
fontWeight: 600,
|
||||||
|
fontSize: 14,
|
||||||
|
cursor: saving ? "wait" : "pointer",
|
||||||
|
opacity: saving ? 0.7 : 1,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{saving ? "Saving..." : "Save Changes"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -14,6 +14,7 @@ import { ImpersonationBanner } from "./ImpersonationBanner.js";
|
|||||||
import { AuditLogViewer } from "./AuditLogViewer.js";
|
import { AuditLogViewer } from "./AuditLogViewer.js";
|
||||||
import type { ImpersonationSession, AuditEntry } from "./mockData.js";
|
import type { ImpersonationSession, AuditEntry } from "./mockData.js";
|
||||||
import { CUSTOMER } from "./mockData.js";
|
import { CUSTOMER } from "./mockData.js";
|
||||||
|
import { useBranding } from "../BrandingContext.js";
|
||||||
|
|
||||||
type Section = "dashboard" | "appointments" | "pets" | "reports" | "billing" | "messages" | "settings";
|
type Section = "dashboard" | "appointments" | "pets" | "reports" | "billing" | "messages" | "settings";
|
||||||
|
|
||||||
@@ -98,6 +99,7 @@ export function CustomerPortal() {
|
|||||||
const [showAuditLog, setShowAuditLog] = useState(false);
|
const [showAuditLog, setShowAuditLog] = useState(false);
|
||||||
const [showImpersonationSetup, setShowImpersonationSetup] = useState(false);
|
const [showImpersonationSetup, setShowImpersonationSetup] = useState(false);
|
||||||
const [impersonation, dispatchImpersonation] = useReducer(impersonationReducer, null);
|
const [impersonation, dispatchImpersonation] = useReducer(impersonationReducer, null);
|
||||||
|
const { branding } = useBranding();
|
||||||
|
|
||||||
const logPageView = useCallback((page: string) => {
|
const logPageView = useCallback((page: string) => {
|
||||||
if (impersonation?.active) {
|
if (impersonation?.active) {
|
||||||
@@ -180,8 +182,8 @@ export function CustomerPortal() {
|
|||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d={mobileNavOpen ? "M6 18L18 6M6 6l12 12" : "M4 6h16M4 12h16M4 18h16"} />
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d={mobileNavOpen ? "M6 18L18 6M6 6l12 12" : "M4 6h16M4 12h16M4 18h16"} />
|
||||||
</svg>
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
<span className="text-lg font-semibold text-stone-800">Paws & Reflect</span>
|
<span className="text-lg font-semibold text-stone-800">{branding.businessName}</span>
|
||||||
<div className="w-8 h-8 rounded-full bg-[#8b7355] flex items-center justify-center text-white text-sm font-medium">
|
<div className="w-8 h-8 rounded-full flex items-center justify-center text-white text-sm font-medium" style={{ background: branding.accentColor }}>
|
||||||
SM
|
SM
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
@@ -195,11 +197,19 @@ export function CustomerPortal() {
|
|||||||
flex flex-col transition-transform duration-200
|
flex flex-col transition-transform duration-200
|
||||||
`}>
|
`}>
|
||||||
<div className="hidden md:flex items-center gap-3 px-6 py-5 border-b border-stone-100">
|
<div className="hidden md:flex items-center gap-3 px-6 py-5 border-b border-stone-100">
|
||||||
<div className="w-10 h-10 rounded-xl bg-[#8b7355] flex items-center justify-center text-white text-lg">
|
{branding.logoBase64 && branding.logoMimeType ? (
|
||||||
🐾
|
<img
|
||||||
</div>
|
src={`data:${branding.logoMimeType};base64,${branding.logoBase64}`}
|
||||||
|
alt=""
|
||||||
|
className="w-10 h-10 rounded-xl object-contain"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className="w-10 h-10 rounded-xl flex items-center justify-center text-white text-lg" style={{ background: branding.accentColor }}>
|
||||||
|
🐾
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
<div>
|
<div>
|
||||||
<div className="font-semibold text-stone-800 text-sm">Paws & Reflect</div>
|
<div className="font-semibold text-stone-800 text-sm">{branding.businessName}</div>
|
||||||
<div className="text-xs text-stone-500">Grooming</div>
|
<div className="text-xs text-stone-500">Grooming</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -214,7 +224,7 @@ export function CustomerPortal() {
|
|||||||
className={`
|
className={`
|
||||||
w-full flex items-center gap-3 px-3 py-2.5 rounded-lg text-sm font-medium transition-colors
|
w-full flex items-center gap-3 px-3 py-2.5 rounded-lg text-sm font-medium transition-colors
|
||||||
${active
|
${active
|
||||||
? "bg-[#f0ebe4] text-[#6b5a42]"
|
? "bg-stone-100 text-stone-800 font-semibold"
|
||||||
: "text-stone-600 hover:bg-stone-50 hover:text-stone-900"
|
: "text-stone-600 hover:bg-stone-50 hover:text-stone-900"
|
||||||
}
|
}
|
||||||
`}
|
`}
|
||||||
@@ -270,7 +280,7 @@ export function CustomerPortal() {
|
|||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<span className="text-sm text-stone-600">Hi, {CUSTOMER.name.split(" ")[0]}</span>
|
<span className="text-sm text-stone-600">Hi, {CUSTOMER.name.split(" ")[0]}</span>
|
||||||
<div className="w-8 h-8 rounded-full bg-[#8b7355] flex items-center justify-center text-white text-sm font-medium">
|
<div className="w-8 h-8 rounded-full flex items-center justify-center text-white text-sm font-medium" style={{ background: branding.accentColor }}>
|
||||||
SM
|
SM
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -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;
|
||||||
@@ -218,6 +218,17 @@ export const reminderLogs = pgTable(
|
|||||||
(t) => [unique().on(t.appointmentId, t.reminderType)]
|
(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", {
|
export const groomingVisitLogs = pgTable("grooming_visit_logs", {
|
||||||
id: uuid("id").primaryKey().defaultRandom(),
|
id: uuid("id").primaryKey().defaultRandom(),
|
||||||
petId: uuid("pet_id")
|
petId: uuid("pet_id")
|
||||||
|
|||||||
@@ -145,6 +145,17 @@ export interface Invoice {
|
|||||||
tipSplits?: InvoiceTipSplit[];
|
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
|
// Paginated list response
|
||||||
export interface PaginatedList<T> {
|
export interface PaginatedList<T> {
|
||||||
items: T[];
|
items: T[];
|
||||||
|
|||||||
Reference in New Issue
Block a user