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 { 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}`);
|
||||
|
||||
@@ -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) =>
|
||||
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(
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
|
||||
+41
-22
@@ -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 (
|
||||
<div style={{ minHeight: "100vh", fontFamily: "system-ui, sans-serif", background: "#f0f2f5" }}>
|
||||
<nav
|
||||
@@ -42,14 +51,23 @@ function AdminLayout() {
|
||||
zIndex: 50,
|
||||
}}
|
||||
>
|
||||
<strong style={{
|
||||
<div style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 8,
|
||||
marginRight: "1.25rem",
|
||||
fontSize: 17,
|
||||
color: "#1a202c",
|
||||
letterSpacing: "-0.02em",
|
||||
}}>
|
||||
<span style={{ color: "#4f8a6f" }}>Groom</span>Book
|
||||
</strong>
|
||||
{logoSrc && (
|
||||
<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
|
||||
to="/admin/book"
|
||||
style={{
|
||||
@@ -59,7 +77,7 @@ function AdminLayout() {
|
||||
fontSize: 13,
|
||||
fontWeight: 600,
|
||||
color: "#fff",
|
||||
background: "linear-gradient(135deg, #4f8a6f, #3d7a5f)",
|
||||
background: branding.primaryColor,
|
||||
marginRight: "0.5rem",
|
||||
boxShadow: "0 1px 2px rgba(79, 138, 111, 0.3)",
|
||||
}}
|
||||
@@ -100,6 +118,7 @@ function AdminLayout() {
|
||||
<Route path="/book" element={<BookPage />} />
|
||||
<Route path="/group-bookings" element={<GroupBookingPage />} />
|
||||
<Route path="/reports" element={<ReportsPage />} />
|
||||
<Route path="/settings" element={<SettingsPage />} />
|
||||
</Routes>
|
||||
</main>
|
||||
</div>
|
||||
@@ -130,21 +149,21 @@ export function App() {
|
||||
return <Navigate to="/login" replace />;
|
||||
}
|
||||
|
||||
if (location.pathname.startsWith("/admin")) {
|
||||
return (
|
||||
<>
|
||||
<Routes>
|
||||
<Route path="/admin/*" element={<AdminLayout />} />
|
||||
</Routes>
|
||||
{authDisabled && <DevSessionIndicator />}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<CustomerPortal />
|
||||
{authDisabled && <DevSessionIndicator />}
|
||||
</>
|
||||
<BrandingProvider>
|
||||
{location.pathname.startsWith("/admin") ? (
|
||||
<>
|
||||
<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 }),
|
||||
} 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;
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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 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() {
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d={mobileNavOpen ? "M6 18L18 6M6 6l12 12" : "M4 6h16M4 12h16M4 18h16"} />
|
||||
</svg>
|
||||
</button>
|
||||
<span className="text-lg font-semibold text-stone-800">Paws & Reflect</span>
|
||||
<div className="w-8 h-8 rounded-full bg-[#8b7355] flex items-center justify-center text-white text-sm font-medium">
|
||||
<span className="text-lg font-semibold text-stone-800">{branding.businessName}</span>
|
||||
<div className="w-8 h-8 rounded-full flex items-center justify-center text-white text-sm font-medium" style={{ background: branding.accentColor }}>
|
||||
SM
|
||||
</div>
|
||||
</header>
|
||||
@@ -195,11 +197,19 @@ export function CustomerPortal() {
|
||||
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="w-10 h-10 rounded-xl bg-[#8b7355] flex items-center justify-center text-white text-lg">
|
||||
🐾
|
||||
</div>
|
||||
{branding.logoBase64 && branding.logoMimeType ? (
|
||||
<img
|
||||
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 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>
|
||||
</div>
|
||||
@@ -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() {
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<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
|
||||
</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)]
|
||||
);
|
||||
|
||||
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")
|
||||
|
||||
@@ -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<T> {
|
||||
items: T[];
|
||||
|
||||
Reference in New Issue
Block a user