feat: add customizable business branding (name, logo, colors)
Add admin settings for business branding with name, logo upload, and color scheme via CSS custom properties. Includes database migration, API endpoints, admin settings page, and dynamic branding in both admin nav and customer portal. Closes #61 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -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}`);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
);
|
||||
+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) 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>
|
||||
);
|
||||
}
|
||||
@@ -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 { branding, 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]"
|
||||
? "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