merge: resolve conflicts between feat/impersonation-backend and main

Keep both backend impersonation (schema, routes, types) and main's
additions (settings, branding, dev login, full customer portal UI).

Portal frontend files retain main's versions (complete UI with sidebar,
sections, mock impersonation). Wiring frontend to real impersonation
backend API remains as follow-up work.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Groom Book CTO
2026-03-20 02:17:02 +00:00
50 changed files with 4677 additions and 503 deletions
+11 -4
View File
@@ -118,6 +118,13 @@ jobs:
steps:
- uses: actions/checkout@v4
- name: Generate version tag
id: version
run: |
TAG="$(date -u +%Y.%m.%d)-${GITHUB_SHA::7}"
echo "tag=$TAG" >> "$GITHUB_OUTPUT"
echo "Image version: $TAG"
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
@@ -136,7 +143,7 @@ jobs:
target: runner
push: true
tags: |
ghcr.io/groombook/api:${{ github.sha }}
ghcr.io/groombook/api:${{ steps.version.outputs.tag }}
ghcr.io/groombook/api:latest
cache-from: type=gha
cache-to: type=gha,mode=max
@@ -149,7 +156,7 @@ jobs:
target: migrate
push: true
tags: |
ghcr.io/groombook/migrate:${{ github.sha }}
ghcr.io/groombook/migrate:${{ steps.version.outputs.tag }}
ghcr.io/groombook/migrate:latest
cache-from: type=gha
cache-to: type=gha,mode=max
@@ -162,7 +169,7 @@ jobs:
target: seed
push: true
tags: |
ghcr.io/groombook/seed:${{ github.sha }}
ghcr.io/groombook/seed:${{ steps.version.outputs.tag }}
ghcr.io/groombook/seed:latest
cache-from: type=gha
cache-to: type=gha,mode=max
@@ -174,7 +181,7 @@ jobs:
file: apps/web/Dockerfile
push: true
tags: |
ghcr.io/groombook/web:${{ github.sha }}
ghcr.io/groombook/web:${{ steps.version.outputs.tag }}
ghcr.io/groombook/web:latest
cache-from: type=gha
cache-to: type=gha,mode=max
+21
View File
@@ -13,7 +13,10 @@ import { reportsRouter } from "./routes/reports.js";
import { appointmentGroupsRouter } from "./routes/appointmentGroups.js";
import { groomingLogsRouter } from "./routes/groomingLogs.js";
import { impersonationRouter } from "./routes/impersonation.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";
const app = new Hono();
@@ -34,6 +37,23 @@ app.get("/health", (c) => c.json({ status: "ok" }));
// Public booking routes — no auth required, must be registered before auth middleware
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);
@@ -48,6 +68,7 @@ api.route("/reports", reportsRouter);
api.route("/appointment-groups", appointmentGroupsRouter);
api.route("/grooming-logs", groomingLogsRouter);
api.route("/impersonation", impersonationRouter);
api.route("/admin/settings", settingsRouter);
const port = Number(process.env.PORT ?? 3000);
console.log(`API server listening on port ${port}`);
+3 -1
View File
@@ -40,7 +40,9 @@ if (process.env.AUTH_DISABLED === "true") {
export const authMiddleware: MiddlewareHandler = async (c, next) => {
if (process.env.AUTH_DISABLED === "true") {
c.set("jwtPayload", { sub: "dev-user" } as JwtPayload);
const devUserId = c.req.header("X-Dev-User-Id");
const sub = devUserId ?? "dev-user";
c.set("jwtPayload", { sub } as JwtPayload);
await next();
return;
}
+2 -2
View File
@@ -160,11 +160,11 @@ bookRouter.post(
);
}
// Find or create client by email
// Find or create client by email (skip disabled clients)
let [client] = await db
.select()
.from(clients)
.where(eq(clients.email, body.clientEmail));
.where(and(eq(clients.email, body.clientEmail), eq(clients.status, "active")));
if (!client) {
const inserted = await db
+33 -7
View File
@@ -13,12 +13,15 @@ const createClientSchema = z.object({
notes: z.string().max(2000).optional(),
});
const updateClientSchema = createClientSchema.partial();
// List all clients
// List clients — defaults to active only, ?includeDisabled=true shows all
clientsRouter.get("/", async (c) => {
const db = getDb();
const rows = await db.select().from(clients).orderBy(clients.name);
const includeDisabled = c.req.query("includeDisabled") === "true";
const query = includeDisabled
? db.select().from(clients).orderBy(clients.name)
: db.select().from(clients).where(eq(clients.status, "active")).orderBy(clients.name);
const rows = await query;
return c.json(rows);
});
@@ -41,16 +44,31 @@ clientsRouter.post("/", zValidator("json", createClientSchema), async (c) => {
return c.json(row, 201);
});
// Update a client
// Update a client (including status changes)
const patchClientSchema = createClientSchema.partial().extend({
status: z.enum(["active", "disabled"]).optional(),
});
clientsRouter.patch(
"/:id",
zValidator("json", updateClientSchema),
zValidator("json", patchClientSchema),
async (c) => {
const db = getDb();
const body = c.req.valid("json");
const now = new Date();
const setValues: Record<string, unknown> = { ...body, updatedAt: now };
// When disabling, set disabledAt; when re-enabling, clear it
if (body.status === "disabled") {
setValues.disabledAt = now;
} else if (body.status === "active") {
setValues.disabledAt = null;
}
const [row] = await db
.update(clients)
.set({ ...body, updatedAt: new Date() })
.set(setValues)
.where(eq(clients.id, c.req.param("id")))
.returning();
if (!row) return c.json({ error: "Not found" }, 404);
@@ -58,8 +76,16 @@ clientsRouter.patch(
}
);
// Delete a client
// Delete a client — requires ?confirm=true query param
clientsRouter.delete("/:id", async (c) => {
const confirm = c.req.query("confirm");
if (confirm !== "true") {
return c.json(
{ error: "Permanent deletion requires ?confirm=true. Consider disabling the client instead." },
400
);
}
const db = getDb();
const [row] = await db
.delete(clients)
+45
View File
@@ -0,0 +1,45 @@
import { Hono } from "hono";
import { getDb, staff, clients, eq, sql } from "@groombook/db";
const devRouter = new Hono();
// GET /api/dev/config — tells the frontend whether auth is disabled
devRouter.get("/config", (c) => {
return c.json({ authDisabled: process.env.AUTH_DISABLED === "true" });
});
// GET /api/dev/users — list staff and clients for the login selector
// Only available when AUTH_DISABLED=true
devRouter.get("/users", async (c) => {
if (process.env.AUTH_DISABLED !== "true") {
return c.json({ error: "Not available when auth is enabled" }, 403);
}
const db = getDb();
const staffList = await db
.select({
id: staff.id,
name: staff.name,
email: staff.email,
role: staff.role,
})
.from(staff)
.where(eq(staff.active, true))
.orderBy(staff.name);
const clientList = await db
.select({
id: clients.id,
name: clients.name,
email: clients.email,
petCount: sql<number>`(SELECT count(*) FROM pets WHERE pets.client_id = ${clients.id})`.as("pet_count"),
})
.from(clients)
.orderBy(clients.name)
.limit(20);
return c.json({ staff: staffList, clients: clientList });
});
export { devRouter };
+7 -1
View File
@@ -16,6 +16,11 @@ import {
export const reportsRouter = new Hono();
reportsRouter.onError((err, c) => {
console.error("[reports] unhandled error:", err);
return c.json({ error: "Internal server error", message: err.message }, 500);
});
// ─── Helpers ──────────────────────────────────────────────────────────────────
function parseDate(value: string | undefined, fallback: Date): Date {
@@ -279,6 +284,7 @@ reportsRouter.get("/clients", async (c) => {
// Clients with no appointment in last 90 days (churn risk)
const ninetyDaysAgo = new Date();
ninetyDaysAgo.setDate(ninetyDaysAgo.getDate() - 90);
const ninetyDaysAgoISO = ninetyDaysAgo.toISOString();
const churnRisk = await db
.select({
@@ -290,7 +296,7 @@ reportsRouter.get("/clients", async (c) => {
.leftJoin(appointments, eq(appointments.clientId, clients.id))
.groupBy(clients.id, clients.name)
.having(
sql`MAX(${appointments.startTime}) < ${ninetyDaysAgo} OR MAX(${appointments.startTime}) IS NULL`
sql`MAX(${appointments.startTime}) < ${ninetyDaysAgoISO}::timestamptz OR MAX(${appointments.startTime}) IS NULL`
)
.orderBy(sql`MAX(${appointments.startTime}) ASC NULLS FIRST`);
+60
View File
@@ -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);
}
);
+1
View File
@@ -22,6 +22,7 @@ export default defineConfig({
baseURL: "http://localhost:8080",
trace: "on-first-retry",
screenshot: "only-on-failure",
serviceWorkers: "block",
},
projects: [
+4 -4
View File
@@ -1,4 +1,4 @@
import { test, expect } from "@playwright/test";
import { test, expect } from "./fixtures.js";
/**
* Booking portal happy-path E2E test.
@@ -46,7 +46,7 @@ test("complete booking flow", async ({ page }) => {
// ── Step 1: Select a service ──────────────────────────────────────────────
await page.goto("/book");
await page.goto("/admin/book");
await expect(page.getByText("Book an Appointment")).toBeVisible();
await expect(page.getByText("Choose a service")).toBeVisible();
@@ -99,7 +99,7 @@ test("booking form validation — required fields", async ({ page }) => {
route.fulfill({ json: [MOCK_SLOT] })
);
await page.goto("/book");
await page.goto("/admin/book");
await page.getByText("Full Groom").click();
await page.getByRole("button", { name: /\d{1,2}:\d{2}/ }).first().click();
await page.getByRole("button", { name: "Continue" }).click();
@@ -115,6 +115,6 @@ test("no services available — shows message", async ({ page }) => {
route.fulfill({ json: [] })
);
await page.goto("/book");
await page.goto("/admin/book");
await expect(page.getByText("No services available")).toBeVisible();
});
+10 -4
View File
@@ -1,4 +1,4 @@
import { test, expect } from "@playwright/test";
import { test, expect } from "./fixtures.js";
/**
* Client management E2E tests.
@@ -14,6 +14,9 @@ const MOCK_CLIENTS = [
phone: "555-0101",
address: null,
notes: null,
emailOptOut: false,
status: "active",
disabledAt: null,
createdAt: "2026-01-01T00:00:00.000Z",
updatedAt: "2026-01-01T00:00:00.000Z",
},
@@ -24,6 +27,9 @@ const MOCK_CLIENTS = [
phone: null,
address: null,
notes: null,
emailOptOut: false,
status: "active",
disabledAt: null,
createdAt: "2026-01-02T00:00:00.000Z",
updatedAt: "2026-01-02T00:00:00.000Z",
},
@@ -40,18 +46,18 @@ test.beforeEach(async ({ page }) => {
});
test("clients page shows client list", async ({ page }) => {
await page.goto("/clients");
await page.goto("/admin/clients");
await expect(page.getByText("Alice Johnson")).toBeVisible();
await expect(page.getByText("Bob Williams")).toBeVisible();
});
test("clients page shows search input", async ({ page }) => {
await page.goto("/clients");
await page.goto("/admin/clients");
await expect(page.getByPlaceholder(/search/i)).toBeVisible();
});
test("clicking a client shows their details", async ({ page }) => {
await page.goto("/clients");
await page.goto("/admin/clients");
await expect(page.getByText("Alice Johnson")).toBeVisible();
await page.getByText("Alice Johnson").click();
// Email appears in both the list row and the detail panel once selected
+42
View File
@@ -0,0 +1,42 @@
import { test as base } from "@playwright/test";
/**
* Custom test fixture that bypasses the dev login redirect for E2E tests.
*
* When AUTH_DISABLED=true, the app fetches /api/dev/config and redirects to
* /login if no dev-user is in localStorage. This fixture:
* 1. Mocks /api/dev/config to return authDisabled: false
* 2. Seeds localStorage with a dev user as a fallback
*
* This ensures E2E tests render pages directly without the login redirect.
*/
export const test = base.extend({
page: async ({ page }, use) => {
// Mock the dev config endpoint so the app skips the auth-disabled redirect
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(
"dev-user",
JSON.stringify({ type: "staff", id: "dev-user", name: "Dev User" })
);
});
await use(page);
},
});
export { expect } from "@playwright/test";
+26 -20
View File
@@ -1,4 +1,4 @@
import { test, expect } from "@playwright/test";
import { test, expect } from "./fixtures.js";
/**
* Navigation smoke tests — verifies that each page loads without errors.
@@ -40,45 +40,51 @@ test.beforeEach(async ({ page }) => {
});
});
test("appointments page loads", async ({ page }) => {
test("customer portal loads at root", async ({ page }) => {
await page.goto("/");
await expect(page.getByText("Groom Book")).toBeVisible();
await expect(page.getByRole("navigation").getByText("GroomBook")).toBeVisible();
await expect(page.locator("nav")).toBeVisible();
});
test("admin appointments page loads", async ({ page }) => {
await page.goto("/admin");
await expect(page.getByText("GroomBook")).toBeVisible();
// Calendar/appointments view renders
await expect(page.locator("nav")).toBeVisible();
});
test("clients page loads", async ({ page }) => {
await page.goto("/clients");
await expect(page.getByText("Groom Book")).toBeVisible();
test("admin clients page loads", async ({ page }) => {
await page.goto("/admin/clients");
await expect(page.getByText("GroomBook")).toBeVisible();
await expect(page.getByRole("link", { name: "Clients" })).toBeVisible();
});
test("services page loads", async ({ page }) => {
await page.goto("/services");
await expect(page.getByText("Groom Book")).toBeVisible();
test("admin services page loads", async ({ page }) => {
await page.goto("/admin/services");
await expect(page.getByText("GroomBook")).toBeVisible();
await expect(page.getByRole("link", { name: "Services" })).toBeVisible();
});
test("staff page loads", async ({ page }) => {
await page.goto("/staff");
await expect(page.getByText("Groom Book")).toBeVisible();
test("admin staff page loads", async ({ page }) => {
await page.goto("/admin/staff");
await expect(page.getByText("GroomBook")).toBeVisible();
await expect(page.getByRole("link", { name: "Staff" })).toBeVisible();
});
test("invoices page loads", async ({ page }) => {
await page.goto("/invoices");
await expect(page.getByText("Groom Book")).toBeVisible();
test("admin invoices page loads", async ({ page }) => {
await page.goto("/admin/invoices");
await expect(page.getByText("GroomBook")).toBeVisible();
await expect(page.getByRole("link", { name: "Invoices" })).toBeVisible();
});
test("reports page loads", async ({ page }) => {
await page.goto("/reports");
await expect(page.getByText("Groom Book")).toBeVisible();
test("admin reports page loads", async ({ page }) => {
await page.goto("/admin/reports");
await expect(page.getByText("GroomBook")).toBeVisible();
await expect(page.getByRole("link", { name: "Reports" })).toBeVisible();
});
test("booking portal loads", async ({ page }) => {
await page.goto("/book");
test("admin booking portal loads", async ({ page }) => {
await page.goto("/admin/book");
await expect(page.getByText("Book an Appointment")).toBeVisible();
await expect(page.getByText("Choose a service")).toBeVisible();
});
+5 -1
View File
@@ -13,9 +13,13 @@
},
"dependencies": {
"@groombook/types": "workspace:*",
"@tailwindcss/vite": "^4.2.2",
"lucide-react": "^0.577.0",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"react-router-dom": "^7.1.2"
"react-router-dom": "^7.1.2",
"recharts": "^3.8.0",
"tailwindcss": "^4.2.2"
},
"devDependencies": {
"@testing-library/jest-dom": "^6.9.1",
+106 -27
View File
@@ -1,4 +1,5 @@
import { Routes, Route, Link, useLocation } from "react-router-dom";
import { Routes, Route, Link, useLocation, Navigate } from "react-router-dom";
import { useEffect, useState } from "react";
import { AppointmentsPage } from "./pages/Appointments.js";
import { ClientsPage } from "./pages/Clients.js";
import { ServicesPage } from "./pages/Services.js";
@@ -7,64 +8,99 @@ 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: "/", label: "Appointments" },
{ to: "/clients", label: "Clients" },
{ to: "/services", label: "Services" },
{ to: "/staff", label: "Staff" },
{ to: "/invoices", label: "Invoices" },
{ to: "/group-bookings", label: "Group Bookings" },
{ to: "/reports", label: "Reports" },
{ to: "/admin", label: "Appointments" },
{ to: "/admin/clients", label: "Clients" },
{ to: "/admin/services", label: "Services" },
{ to: "/admin/staff", label: "Staff" },
{ 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" },
];
export function App() {
function AdminLayout() {
const location = useLocation();
const { branding } = useBranding();
const logoSrc = branding.logoBase64 && branding.logoMimeType
? `data:${branding.logoMimeType};base64,${branding.logoBase64}`
: null;
return (
<CustomerPortal>
<div style={{ minHeight: "100vh", fontFamily: "system-ui, sans-serif" }}>
<div style={{ minHeight: "100vh", fontFamily: "system-ui, sans-serif", background: "#f0f2f5" }}>
<nav
style={{
padding: "0.75rem 1rem",
padding: "0 1.25rem",
height: 52,
borderBottom: "1px solid #e2e8f0",
display: "flex",
alignItems: "center",
gap: "0.25rem",
background: "#fff",
boxShadow: "0 1px 3px rgba(0, 0, 0, 0.06), 0 1px 2px rgba(0, 0, 0, 0.04)",
position: "sticky",
top: 0,
zIndex: 50,
}}
>
<strong style={{ marginRight: "1rem", fontSize: 16 }}>Groom Book</strong>
<div style={{
display: "flex",
alignItems: "center",
gap: 8,
marginRight: "1.25rem",
}}>
{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="/book"
to="/admin/book"
style={{
padding: "0.35rem 0.75rem",
borderRadius: 4,
padding: "0.4rem 0.85rem",
borderRadius: 6,
textDecoration: "none",
fontSize: 14,
fontSize: 13,
fontWeight: 600,
color: "#fff",
background: "#4f8a6f",
background: branding.primaryColor,
marginRight: "0.5rem",
boxShadow: "0 1px 2px rgba(79, 138, 111, 0.3)",
}}
>
Book
</Link>
{NAV_LINKS.map(({ to, label }) => {
const active =
to === "/" ? location.pathname === "/" : location.pathname.startsWith(to);
to === "/admin"
? location.pathname === "/admin"
: location.pathname.startsWith(to);
return (
<Link
key={to}
to={to}
style={{
padding: "0.35rem 0.75rem",
borderRadius: 4,
padding: "0.4rem 0.75rem",
borderRadius: 6,
textDecoration: "none",
fontSize: 14,
fontWeight: active ? 600 : 400,
color: active ? "#1d4ed8" : "#374151",
background: active ? "#eff6ff" : "transparent",
fontSize: 13,
fontWeight: active ? 600 : 500,
color: active ? "#2d6a4f" : "#4b5563",
background: active ? "#ecfdf5" : "transparent",
}}
>
{label}
@@ -72,7 +108,7 @@ export function App() {
);
})}
</nav>
<main style={{ padding: "1rem 1.5rem" }}>
<main style={{ padding: "1.25rem 1.5rem" }}>
<Routes>
<Route path="/" element={<AppointmentsPage />} />
<Route path="/clients" element={<ClientsPage />} />
@@ -82,9 +118,52 @@ export function App() {
<Route path="/book" element={<BookPage />} />
<Route path="/group-bookings" element={<GroupBookingPage />} />
<Route path="/reports" element={<ReportsPage />} />
<Route path="/settings" element={<SettingsPage />} />
</Routes>
</main>
</div>
</CustomerPortal>
);
}
export function App() {
const location = useLocation();
const [authDisabled, setAuthDisabled] = useState<boolean | null>(null);
useEffect(() => {
fetch("/api/dev/config")
.then((r) => r.json())
.then((data) => setAuthDisabled(data.authDisabled === true))
.catch(() => setAuthDisabled(false));
}, []);
// Show login selector page
if (location.pathname === "/login") {
return <DevLoginSelector />;
}
// While checking auth config, render nothing briefly
if (authDisabled === null) return null;
// If auth is disabled and no dev user is selected, redirect to login selector
if (authDisabled && !getDevUser() && location.pathname !== "/login") {
return <Navigate to="/login" replace />;
}
return (
<BrandingProvider>
{location.pathname.startsWith("/admin") ? (
<>
<Routes>
<Route path="/admin/*" element={<AdminLayout />} />
</Routes>
{authDisabled && <DevSessionIndicator />}
</>
) : (
<>
<CustomerPortal />
{authDisabled && <DevSessionIndicator />}
</>
)}
</BrandingProvider>
);
}
+55
View File
@@ -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>
);
}
+139 -18
View File
@@ -1,36 +1,59 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { render, screen, within, act } from "@testing-library/react";
import { render, screen, within, waitFor } from "@testing-library/react";
import { MemoryRouter } from "react-router-dom";
import { App } from "../App.js";
// Prevent fetch errors from page components loading data on mount
// Mock fetch to return appropriate responses based on URL
beforeEach(() => {
global.fetch = vi.fn().mockResolvedValue({
ok: true,
json: async () => [],
} as unknown as Response);
localStorage.clear();
global.fetch = vi.fn((url: string) => {
if (url === "/api/dev/config") {
return Promise.resolve({
ok: true,
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 () => [],
} as Response);
}) as unknown as typeof fetch;
});
async function renderApp(route = "/") {
await act(async () => {
render(
<MemoryRouter initialEntries={[route]}>
<App />
</MemoryRouter>
);
});
return screen.getByRole("navigation");
async function renderApp(route = "/admin") {
render(
<MemoryRouter initialEntries={[route]}>
<App />
</MemoryRouter>
);
// Wait for the config fetch to resolve
const nav = await screen.findByRole("navigation");
return nav;
}
describe("App navigation", () => {
it("renders the Groom Book brand", async () => {
const nav = await renderApp();
expect(within(nav).getByText("Groom Book")).toBeInTheDocument();
expect(
within(nav).getByText((_, el) => el?.tagName === "STRONG" && /Groom\s*Book/.test(el.textContent ?? ""))
).toBeInTheDocument();
});
it("renders the Book CTA button", async () => {
const nav = await renderApp();
expect(within(nav).getByText("Book")).toBeInTheDocument();
expect(within(nav).getByRole("link", { name: "Book" })).toBeInTheDocument();
});
it("renders all primary nav links", async () => {
@@ -50,9 +73,107 @@ describe("App navigation", () => {
});
it("highlights the active route link", async () => {
const nav = await renderApp("/clients");
const nav = await renderApp("/admin/clients");
const clientsLink = within(nav).getByText("Clients");
// Active links use fontWeight 600
expect(clientsLink).toHaveStyle({ fontWeight: "600" });
});
it("renders customer portal at root", async () => {
render(
<MemoryRouter initialEntries={["/"]}>
<App />
</MemoryRouter>
);
// Customer portal should render at root - no admin nav present
await waitFor(() => {
expect(
screen.queryByText((_, el) => el?.tagName === "STRONG" && /Groom\s*Book/.test(el.textContent ?? ""))
).not.toBeInTheDocument();
});
});
});
describe("Dev login selector", () => {
it("redirects to /login when auth is disabled and no user selected", async () => {
global.fetch = vi.fn((url: string) => {
if (url === "/api/dev/config") {
return Promise.resolve({
ok: true,
json: async () => ({ authDisabled: true }),
} as Response);
}
if (url === "/api/dev/users") {
return Promise.resolve({
ok: true,
json: async () => ({
staff: [{ id: "s1", name: "Sarah", email: "sarah@test.com", role: "groomer" }],
clients: [{ id: "c1", name: "Client A", email: "a@test.com", petCount: 2 }],
}),
} 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;
render(
<MemoryRouter initialEntries={["/admin"]}>
<App />
</MemoryRouter>
);
// Should redirect to login selector and show dev login UI
await screen.findByText("Dev Login Selector");
expect(screen.getByText("Sarah")).toBeInTheDocument();
expect(screen.getByText("Client A")).toBeInTheDocument();
});
it("does not redirect when a dev user is already selected", async () => {
localStorage.setItem("dev-user", JSON.stringify({ type: "staff", id: "s1", name: "Sarah" }));
global.fetch = vi.fn((url: string) => {
if (url === "/api/dev/config") {
return Promise.resolve({
ok: true,
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;
render(
<MemoryRouter initialEntries={["/admin"]}>
<App />
</MemoryRouter>
);
// Should show admin nav, not login selector
const nav = await screen.findByRole("navigation");
expect(
within(nav).getByText((_, el) => el?.tagName === "STRONG" && /Groom\s*Book/.test(el.textContent ?? ""))
).toBeInTheDocument();
});
});
@@ -0,0 +1,41 @@
import { Link } from "react-router-dom";
import { getDevUser } from "../pages/DevLoginSelector.js";
export function DevSessionIndicator() {
const user = getDevUser();
if (!user) return null;
return (
<div
style={{
position: "fixed",
bottom: 0,
left: 0,
right: 0,
background: "#1a202c",
color: "#e2e8f0",
padding: "0.4rem 1rem",
display: "flex",
alignItems: "center",
justifyContent: "center",
gap: "0.75rem",
fontSize: 12,
zIndex: 9999,
}}
>
<span>
Dev mode: acting as <strong>{user.name}</strong> ({user.type})
</span>
<Link
to="/login"
style={{
color: "#4f8a6f",
textDecoration: "underline",
fontSize: 12,
}}
>
Switch user
</Link>
</div>
);
}
+55 -2
View File
@@ -1,3 +1,10 @@
@import "tailwindcss";
:root {
--color-primary: #4f8a6f;
--color-accent: #8b7355;
}
*, *::before, *::after {
box-sizing: border-box;
}
@@ -8,11 +15,13 @@ body {
font-size: 16px;
line-height: 1.5;
color: #1a202c;
background: #f7fafc;
background: #f0f2f5;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
a {
color: #4f8a6f;
color: var(--color-primary);
text-decoration: none;
}
@@ -23,4 +32,48 @@ a:hover {
h1 {
font-size: 1.5rem;
margin-top: 0;
letter-spacing: -0.01em;
}
h2, h3, h4 {
letter-spacing: -0.01em;
}
/* ─── Admin button polish ─── */
button {
transition: background-color 0.15s ease, border-color 0.15s ease, box-shadow 0.15s ease, opacity 0.15s ease;
}
button:active:not(:disabled) {
transform: translateY(0.5px);
}
/* ─── Admin input / select focus states ─── */
input:focus, select:focus, textarea:focus {
outline: none;
border-color: #4f8a6f;
box-shadow: 0 0 0 3px rgba(79, 138, 111, 0.12);
}
/* ─── Admin card-like containers (borders get subtle shadow) ─── */
[style*="border: 1px solid"] {
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.04);
}
/* ─── Scrollbar polish ─── */
::-webkit-scrollbar {
width: 6px;
}
::-webkit-scrollbar-track {
background: transparent;
}
::-webkit-scrollbar-thumb {
background: #cbd5e1;
border-radius: 3px;
}
::-webkit-scrollbar-thumb:hover {
background: #94a3b8;
}
+28
View File
@@ -0,0 +1,28 @@
import { getDevUser } from "../pages/DevLoginSelector.js";
const originalFetch = window.fetch;
/**
* Patches global fetch to include X-Dev-User-Id header on API requests
* when a dev user is selected via the login selector.
*
* Intentionally mutates window.fetch — this is dev-only (AUTH_DISABLED=true).
*/
export function installDevFetchInterceptor() {
window.fetch = function (input: RequestInfo | URL, init?: RequestInit) {
const user = getDevUser();
if (!user) return originalFetch(input, init);
const url = typeof input === "string" ? input : input instanceof URL ? input.toString() : (input as Request).url;
// Only inject header for API calls
if (!url.startsWith("/api/")) return originalFetch(input, init);
const headers = new Headers(init?.headers);
if (!headers.has("X-Dev-User-Id")) {
headers.set("X-Dev-User-Id", user.id);
}
return originalFetch(input, { ...init, headers });
};
}
+3
View File
@@ -2,8 +2,11 @@ import { StrictMode } from "react";
import { createRoot } from "react-dom/client";
import { BrowserRouter } from "react-router-dom";
import { App } from "./App.js";
import { installDevFetchInterceptor } from "./lib/devFetch.js";
import "./index.css";
installDevFetchInterceptor();
const root = document.getElementById("root");
if (!root) throw new Error("Root element not found");
+11 -10
View File
@@ -291,7 +291,7 @@ export function AppointmentsPage() {
</button>
<button
onClick={() => openNewForm()}
style={{ ...btnStyle, backgroundColor: "#3b82f6", color: "#fff", marginLeft: "auto", borderColor: "#3b82f6" }}
style={{ ...btnStyle, backgroundColor: "#4f8a6f", color: "#fff", marginLeft: "auto", borderColor: "#4f8a6f" }}
>
+ New Appointment
</button>
@@ -370,11 +370,11 @@ export function AppointmentsPage() {
{days.map((day, i) => {
const isToday = formatDate(day) === formatDate(new Date());
return (
<div key={i} style={{ border: "1px solid #e2e8f0", borderRadius: 6, overflow: "hidden", minHeight: 180 }}>
<div key={i} style={{ border: "1px solid #e5e7eb", borderRadius: 8, overflow: "hidden", minHeight: 180, background: "#fff", boxShadow: "0 1px 3px rgba(0, 0, 0, 0.04)" }}>
<div
style={{
padding: "0.35rem 0.5rem",
background: isToday ? "#3b82f6" : "#f8fafc",
padding: "0.4rem 0.6rem",
background: isToday ? "linear-gradient(135deg, #4f8a6f, #3d7a5f)" : "#f8fafc",
color: isToday ? "#fff" : "#374151",
fontWeight: 600,
fontSize: 12,
@@ -594,7 +594,7 @@ export function AppointmentsPage() {
<button
type="submit"
disabled={saving}
style={{ ...btnStyle, backgroundColor: "#3b82f6", color: "#fff", borderColor: "#3b82f6" }}
style={{ ...btnStyle, backgroundColor: "#4f8a6f", color: "#fff", borderColor: "#4f8a6f" }}
>
{saving
? "Saving…"
@@ -841,19 +841,20 @@ function Field({ label, children }: { label: string; children: React.ReactNode }
}
const btnStyle: React.CSSProperties = {
padding: "0.35rem 0.75rem",
padding: "0.4rem 0.85rem",
border: "1px solid #d1d5db",
borderRadius: 4,
background: "#f9fafb",
borderRadius: 6,
background: "#fff",
cursor: "pointer",
fontSize: 13,
fontWeight: 500,
};
const inputStyle: React.CSSProperties = {
width: "100%",
padding: "0.4rem 0.5rem",
padding: "0.45rem 0.6rem",
border: "1px solid #d1d5db",
borderRadius: 4,
borderRadius: 6,
fontSize: 14,
boxSizing: "border-box",
};
+149 -24
View File
@@ -66,6 +66,10 @@ export function ClientsPage() {
const [savingPet, setSavingPet] = useState(false);
const [deletingPetId, setDeletingPetId] = useState<string | null>(null);
const [deletingClient, setDeletingClient] = useState(false);
const [disablingClient, setDisablingClient] = useState(false);
const [showDisabled, setShowDisabled] = useState(false);
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
const [deleteConfirmName, setDeleteConfirmName] = useState("");
// Visit log
const [logPetId, setLogPetId] = useState<string | null>(null);
@@ -76,17 +80,18 @@ export function ClientsPage() {
const [logFormError, setLogFormError] = useState<string | null>(null);
const [savingLog, setSavingLog] = useState(false);
async function loadClients() {
const r = await fetch("/api/clients");
async function loadClients(includeDisabled = false) {
const url = includeDisabled ? "/api/clients?includeDisabled=true" : "/api/clients";
const r = await fetch(url);
if (!r.ok) throw new Error(`HTTP ${r.status}`);
setClients((await r.json()) as Client[]);
}
useEffect(() => {
loadClients()
loadClients(showDisabled)
.catch((e: unknown) => setError(e instanceof Error ? e.message : "Unknown error"))
.finally(() => setLoading(false));
}, []);
}, [showDisabled]);
async function loadPets(clientId: string) {
setPetsLoading(true);
@@ -148,7 +153,7 @@ export function ClientsPage() {
}
const updated = (await res.json()) as Client;
setShowClientForm(false);
await loadClients();
await loadClients(showDisabled);
if (editingClient) setSelectedClient(updated);
} catch (e: unknown) {
setClientFormError(e instanceof Error ? e.message : "Failed to save");
@@ -200,18 +205,64 @@ export function ClientsPage() {
}
}
async function disableClient(clientId: string) {
if (!window.confirm("Disable this client? They will be hidden from the client list and booking flow.")) return;
setDisablingClient(true);
try {
const res = await fetch(`/api/clients/${clientId}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ status: "disabled" }),
});
if (!res.ok) {
const err = (await res.json()) as { error?: string };
throw new Error(err.error ?? `HTTP ${res.status}`);
}
const updated = (await res.json()) as Client;
setSelectedClient(updated);
await loadClients(showDisabled);
} catch (e: unknown) {
alert(e instanceof Error ? e.message : "Failed to disable client");
} finally {
setDisablingClient(false);
}
}
async function enableClient(clientId: string) {
setDisablingClient(true);
try {
const res = await fetch(`/api/clients/${clientId}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ status: "active" }),
});
if (!res.ok) {
const err = (await res.json()) as { error?: string };
throw new Error(err.error ?? `HTTP ${res.status}`);
}
const updated = (await res.json()) as Client;
setSelectedClient(updated);
await loadClients(showDisabled);
} catch (e: unknown) {
alert(e instanceof Error ? e.message : "Failed to re-enable client");
} finally {
setDisablingClient(false);
}
}
async function deleteClient(clientId: string) {
if (!window.confirm("Delete this client and all their pets? This cannot be undone.")) return;
setDeletingClient(true);
try {
const res = await fetch(`/api/clients/${clientId}`, { method: "DELETE" });
const res = await fetch(`/api/clients/${clientId}?confirm=true`, { method: "DELETE" });
if (!res.ok) {
const err = (await res.json()) as { error?: string };
throw new Error(err.error ?? `HTTP ${res.status}`);
}
setSelectedClient(null);
setShowDeleteConfirm(false);
setDeleteConfirmName("");
setPets([]);
await loadClients();
await loadClients(showDisabled);
} catch (e: unknown) {
alert(e instanceof Error ? e.message : "Failed to delete client");
} finally {
@@ -317,7 +368,7 @@ export function ClientsPage() {
<h1 style={{ margin: 0, fontSize: 20 }}>Clients</h1>
<button
onClick={openNewClient}
style={{ ...btnStyle, backgroundColor: "#3b82f6", color: "#fff", borderColor: "#3b82f6", marginLeft: "auto", padding: "0.25rem 0.6rem" }}
style={{ ...btnStyle, backgroundColor: "#4f8a6f", color: "#fff", borderColor: "#4f8a6f", marginLeft: "auto", padding: "0.3rem 0.7rem" }}
>
+ New
</button>
@@ -326,8 +377,16 @@ export function ClientsPage() {
placeholder="Search…"
value={search}
onChange={(e) => setSearch(e.target.value)}
style={{ ...inputStyle, marginBottom: "0.75rem" }}
style={{ ...inputStyle, marginBottom: "0.5rem" }}
/>
<label style={{ display: "flex", alignItems: "center", gap: "0.4rem", fontSize: 12, color: "#6b7280", marginBottom: "0.75rem", cursor: "pointer" }}>
<input
type="checkbox"
checked={showDisabled}
onChange={(e) => setShowDisabled(e.target.checked)}
/>
Show disabled clients
</label>
{filtered.length === 0 && <p style={{ color: "#6b7280", fontSize: 14 }}>No clients found.</p>}
{filtered.map((c) => (
<div
@@ -339,7 +398,14 @@ export function ClientsPage() {
border: selectedClient?.id === c.id ? "1px solid #bfdbfe" : "1px solid transparent",
}}
>
<div style={{ fontWeight: 600, fontSize: 14 }}>{c.name}</div>
<div style={{ fontWeight: 600, fontSize: 14, display: "flex", alignItems: "center", gap: "0.4rem" }}>
{c.name}
{c.status === "disabled" && (
<span style={{ fontSize: 10, background: "#fef2f2", color: "#dc2626", padding: "0.1rem 0.4rem", borderRadius: 4, fontWeight: 500 }}>
Disabled
</span>
)}
</div>
{c.email && <div style={{ fontSize: 12, color: "#6b7280" }}>{c.email}</div>}
{c.phone && <div style={{ fontSize: 12, color: "#6b7280" }}>{c.phone}</div>}
</div>
@@ -351,7 +417,14 @@ export function ClientsPage() {
<div style={{ flex: 1 }}>
<div style={{ display: "flex", alignItems: "flex-start", marginBottom: "1rem" }}>
<div>
<h2 style={{ margin: "0 0 0.2rem" }}>{selectedClient.name}</h2>
<h2 style={{ margin: "0 0 0.2rem", display: "flex", alignItems: "center", gap: "0.5rem" }}>
{selectedClient.name}
{selectedClient.status === "disabled" && (
<span style={{ fontSize: 12, background: "#fef2f2", color: "#dc2626", padding: "0.15rem 0.5rem", borderRadius: 4, fontWeight: 500 }}>
Disabled
</span>
)}
</h2>
{selectedClient.email && <div style={{ fontSize: 14, color: "#6b7280" }}>{selectedClient.email}</div>}
{selectedClient.phone && <div style={{ fontSize: 14, color: "#6b7280" }}>{selectedClient.phone}</div>}
{selectedClient.address && <div style={{ fontSize: 13, color: "#6b7280" }}>{selectedClient.address}</div>}
@@ -362,21 +435,37 @@ export function ClientsPage() {
)}
</div>
<div style={{ display: "flex", gap: "0.5rem", marginLeft: "auto" }}>
<button
onClick={() => navigate(`/clients?impersonate=${selectedClient.id}`)}
style={{ ...btnStyle, backgroundColor: "#7c3aed", color: "#fff", borderColor: "#7c3aed" }}
<a
href={`/?impersonate=true&clientName=${encodeURIComponent(selectedClient.name)}&staffName=${encodeURIComponent("Staff")}&reason=${encodeURIComponent(`Support view for ${selectedClient.name}`)}`}
style={{ ...btnStyle, backgroundColor: "#fef3c7", color: "#92400e", borderColor: "#fde68a", textDecoration: "none", display: "inline-flex", alignItems: "center", gap: "0.3rem" }}
>
View as Customer
</button>
</a>
<button onClick={() => openEditClient(selectedClient)} style={btnStyle}>
Edit client
</button>
{selectedClient.status === "active" ? (
<button
onClick={() => { void disableClient(selectedClient.id); }}
disabled={disablingClient}
style={{ ...btnStyle, color: "#d97706", borderColor: "#fde68a" }}
>
{disablingClient ? "Disabling…" : "Disable client"}
</button>
) : (
<button
onClick={() => { void enableClient(selectedClient.id); }}
disabled={disablingClient}
style={{ ...btnStyle, color: "#059669", borderColor: "#6ee7b7" }}
>
{disablingClient ? "Enabling…" : "Re-enable client"}
</button>
)}
<button
onClick={() => { void deleteClient(selectedClient.id); }}
disabled={deletingClient}
onClick={() => { setShowDeleteConfirm(true); setDeleteConfirmName(""); }}
style={{ ...btnStyle, color: "#dc2626", borderColor: "#fca5a5" }}
>
{deletingClient ? "Deleting…" : "Delete client"}
Delete permanently
</button>
</div>
</div>
@@ -395,7 +484,7 @@ export function ClientsPage() {
) : (
<div style={{ display: "grid", gridTemplateColumns: "repeat(auto-fill, minmax(260px, 1fr))", gap: "0.75rem" }}>
{pets.map((p) => (
<div key={p.id} style={{ border: "1px solid #e2e8f0", borderRadius: 8, padding: "0.75rem" }}>
<div key={p.id} style={{ border: "1px solid #e5e7eb", borderRadius: 10, padding: "0.85rem", background: "#fff", boxShadow: "0 1px 3px rgba(0, 0, 0, 0.04)" }}>
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "flex-start" }}>
<strong style={{ fontSize: 15 }}>{p.name}</strong>
<div style={{ display: "flex", gap: "0.3rem" }}>
@@ -506,7 +595,7 @@ export function ClientsPage() {
</Field>
{clientFormError && <p style={{ color: "red", margin: "0.5rem 0 0" }}>{clientFormError}</p>}
<div style={{ display: "flex", gap: "0.5rem", marginTop: "1rem" }}>
<button type="submit" disabled={savingClient} style={{ ...btnStyle, backgroundColor: "#3b82f6", color: "#fff", borderColor: "#3b82f6" }}>
<button type="submit" disabled={savingClient} style={{ ...btnStyle, backgroundColor: "#4f8a6f", color: "#fff", borderColor: "#4f8a6f" }}>
{savingClient ? "Saving…" : editingClient ? "Save Changes" : "Create Client"}
</button>
<button type="button" onClick={() => setShowClientForm(false)} style={btnStyle}>Cancel</button>
@@ -645,7 +734,7 @@ export function ClientsPage() {
</Field>
{logFormError && <p style={{ color: "red", margin: "0.5rem 0 0" }}>{logFormError}</p>}
<div style={{ display: "flex", gap: "0.5rem", marginTop: "1rem" }}>
<button type="submit" disabled={savingLog} style={{ ...btnStyle, backgroundColor: "#3b82f6", color: "#fff", borderColor: "#3b82f6" }}>
<button type="submit" disabled={savingLog} style={{ ...btnStyle, backgroundColor: "#4f8a6f", color: "#fff", borderColor: "#4f8a6f" }}>
{savingLog ? "Saving…" : "Save Visit Log"}
</button>
<button type="button" onClick={() => setShowLogForm(false)} style={btnStyle}>Cancel</button>
@@ -653,6 +742,42 @@ export function ClientsPage() {
</form>
</Modal>
)}
{/* ── Delete confirmation modal ── */}
{showDeleteConfirm && selectedClient && (
<Modal onClose={() => setShowDeleteConfirm(false)}>
<h2 style={{ marginTop: 0, color: "#dc2626" }}>Permanently Delete Client</h2>
<p style={{ fontSize: 14, color: "#374151" }}>
This will permanently delete <strong>{selectedClient.name}</strong> and all their pets. This action cannot be undone.
</p>
<p style={{ fontSize: 14, color: "#374151" }}>
Consider disabling the client instead, which preserves their data for reporting.
</p>
<Field label={`Type "${selectedClient.name}" to confirm`}>
<input
value={deleteConfirmName}
onChange={(e) => setDeleteConfirmName(e.target.value)}
style={inputStyle}
placeholder={selectedClient.name}
/>
</Field>
<div style={{ display: "flex", gap: "0.5rem", marginTop: "1rem" }}>
<button
onClick={() => { void deleteClient(selectedClient.id); }}
disabled={deletingClient || deleteConfirmName !== selectedClient.name}
style={{
...btnStyle,
backgroundColor: deleteConfirmName === selectedClient.name ? "#dc2626" : "#f3f4f6",
color: deleteConfirmName === selectedClient.name ? "#fff" : "#9ca3af",
borderColor: deleteConfirmName === selectedClient.name ? "#dc2626" : "#d1d5db",
}}
>
{deletingClient ? "Deleting…" : "Delete permanently"}
</button>
<button type="button" onClick={() => setShowDeleteConfirm(false)} style={btnStyle}>Cancel</button>
</div>
</Modal>
)}
</div>
);
}
@@ -682,9 +807,9 @@ function Field({ label, children }: { label: string; children: React.ReactNode }
}
const btnStyle: React.CSSProperties = {
padding: "0.35rem 0.75rem", border: "1px solid #d1d5db", borderRadius: 4, background: "#f9fafb", cursor: "pointer", fontSize: 13,
padding: "0.4rem 0.85rem", border: "1px solid #d1d5db", borderRadius: 6, background: "#fff", cursor: "pointer", fontSize: 13, fontWeight: 500,
};
const inputStyle: React.CSSProperties = {
width: "100%", padding: "0.4rem 0.5rem", border: "1px solid #d1d5db", borderRadius: 4, fontSize: 14, boxSizing: "border-box",
width: "100%", padding: "0.45rem 0.6rem", border: "1px solid #d1d5db", borderRadius: 6, fontSize: 14, boxSizing: "border-box",
};
+169
View File
@@ -0,0 +1,169 @@
import { useEffect, useState } from "react";
import { useNavigate } from "react-router-dom";
interface StaffUser {
id: string;
name: string;
email: string;
role: string;
}
interface ClientUser {
id: string;
name: string;
email: string | null;
petCount: number;
}
export function DevLoginSelector() {
const navigate = useNavigate();
const [staff, setStaff] = useState<StaffUser[]>([]);
const [clients, setClients] = useState<ClientUser[]>([]);
const [loading, setLoading] = useState(true);
useEffect(() => {
fetch("/api/dev/users")
.then((r) => r.json())
.then((data) => {
setStaff(data.staff ?? []);
setClients(data.clients ?? []);
})
.finally(() => setLoading(false));
}, []);
function selectUser(type: "staff" | "client", id: string, name: string) {
localStorage.setItem("dev-user", JSON.stringify({ type, id, name }));
navigate(type === "staff" ? "/admin" : "/");
}
function skipLogin() {
localStorage.removeItem("dev-user");
navigate("/admin");
}
if (loading) {
return (
<div style={containerStyle}>
<p style={{ color: "#6b7280" }}>Loading users...</p>
</div>
);
}
return (
<div style={containerStyle}>
<div style={cardStyle}>
<div style={{ textAlign: "center", marginBottom: "1.5rem" }}>
<h1 style={{ margin: 0, fontSize: 22, color: "#1a202c" }}>
<span style={{ color: "#4f8a6f" }}>Groom</span>Book
</h1>
<p style={{ margin: "0.5rem 0 0", color: "#6b7280", fontSize: 14 }}>
Dev Login Selector
</p>
</div>
<h2 style={sectionStyle}>Staff</h2>
<div style={{ display: "flex", flexDirection: "column", gap: "0.5rem" }}>
{staff.map((s) => (
<button
key={s.id}
onClick={() => selectUser("staff", s.id, s.name)}
style={userButtonStyle}
>
<div style={{ fontWeight: 600, fontSize: 14 }}>{s.name}</div>
<div style={{ fontSize: 12, color: "#6b7280" }}>
{s.role} &middot; {s.email}
</div>
</button>
))}
</div>
<h2 style={{ ...sectionStyle, marginTop: "1.5rem" }}>Clients</h2>
<div style={{ display: "flex", flexDirection: "column", gap: "0.5rem" }}>
{clients.map((cl) => (
<button
key={cl.id}
onClick={() => selectUser("client", cl.id, cl.name)}
style={userButtonStyle}
>
<div style={{ fontWeight: 600, fontSize: 14 }}>{cl.name}</div>
<div style={{ fontSize: 12, color: "#6b7280" }}>
{cl.petCount} pet{cl.petCount !== 1 ? "s" : ""}
{cl.email ? ` \u00b7 ${cl.email}` : ""}
</div>
</button>
))}
</div>
<div style={{ marginTop: "1.5rem", textAlign: "center" }}>
<button onClick={skipLogin} style={skipButtonStyle}>
Continue as default dev user
</button>
</div>
</div>
</div>
);
}
export function getDevUser(): { type: string; id: string; name: string } | null {
try {
const raw = localStorage.getItem("dev-user");
if (!raw) return null;
return JSON.parse(raw);
} catch {
return null;
}
}
export function clearDevUser() {
localStorage.removeItem("dev-user");
}
const containerStyle: React.CSSProperties = {
minHeight: "100vh",
display: "flex",
alignItems: "center",
justifyContent: "center",
fontFamily: "system-ui, sans-serif",
background: "#f0f2f5",
padding: "1rem",
};
const cardStyle: React.CSSProperties = {
background: "#fff",
borderRadius: 12,
padding: "2rem",
width: "100%",
maxWidth: 420,
boxShadow: "0 4px 12px rgba(0, 0, 0, 0.08)",
};
const sectionStyle: React.CSSProperties = {
fontSize: 11,
fontWeight: 600,
color: "#6b7280",
textTransform: "uppercase",
letterSpacing: "0.05em",
margin: "0 0 0.5rem",
};
const userButtonStyle: React.CSSProperties = {
display: "block",
width: "100%",
padding: "0.75rem 1rem",
border: "1px solid #e5e7eb",
borderRadius: 8,
background: "#fff",
cursor: "pointer",
textAlign: "left",
transition: "border-color 0.15s, background 0.15s",
};
const skipButtonStyle: React.CSSProperties = {
padding: "0.5rem 1.25rem",
border: "1px solid #d1d5db",
borderRadius: 6,
background: "transparent",
cursor: "pointer",
fontSize: 13,
color: "#6b7280",
};
+10 -9
View File
@@ -287,7 +287,7 @@ function NewGroupBookingForm({
<button
type="submit"
disabled={saving}
style={{ ...btnStyle, backgroundColor: "#3b82f6", color: "#fff", borderColor: "#3b82f6" }}
style={{ ...btnStyle, backgroundColor: "#4f8a6f", color: "#fff", borderColor: "#4f8a6f" }}
>
{saving ? "Booking…" : "Create Group Booking"}
</button>
@@ -471,7 +471,7 @@ export function GroupBookingPage() {
</select>
<button
onClick={() => setShowCreate(true)}
style={{ ...btnStyle, marginLeft: "auto", backgroundColor: "#3b82f6", color: "#fff", borderColor: "#3b82f6" }}
style={{ ...btnStyle, marginLeft: "auto", backgroundColor: "#4f8a6f", color: "#fff", borderColor: "#4f8a6f" }}
>
+ New Group Booking
</button>
@@ -558,25 +558,26 @@ function Field({
}
const btnStyle: React.CSSProperties = {
padding: "0.35rem 0.75rem",
padding: "0.4rem 0.85rem",
border: "1px solid #d1d5db",
borderRadius: 4,
background: "#f9fafb",
borderRadius: 6,
background: "#fff",
cursor: "pointer",
fontSize: 13,
fontWeight: 500,
};
const inputStyle: React.CSSProperties = {
width: "100%",
padding: "0.4rem 0.5rem",
padding: "0.45rem 0.6rem",
border: "1px solid #d1d5db",
borderRadius: 4,
borderRadius: 6,
fontSize: 13,
boxSizing: "border-box",
};
const tdStyle: React.CSSProperties = {
padding: "0.45rem 1rem",
borderBottom: "1px solid #f1f5f9",
padding: "0.5rem 1rem",
borderBottom: "1px solid #f3f4f6",
color: "#374151",
};
+10 -8
View File
@@ -129,7 +129,7 @@ function CreateFromAppointmentForm({
<button
type="submit"
disabled={saving || !selectedApptId}
style={{ ...btnStyle, backgroundColor: "#3b82f6", color: "#fff", borderColor: "#3b82f6" }}
style={{ ...btnStyle, backgroundColor: "#4f8a6f", color: "#fff", borderColor: "#4f8a6f" }}
>
{saving ? "Creating…" : "Create Invoice"}
</button>
@@ -540,7 +540,7 @@ export function InvoicesPage() {
</select>
<button
onClick={() => setShowCreate(true)}
style={{ ...btnStyle, backgroundColor: "#3b82f6", color: "#fff", borderColor: "#3b82f6", marginLeft: "auto" }}
style={{ ...btnStyle, backgroundColor: "#4f8a6f", color: "#fff", borderColor: "#4f8a6f", marginLeft: "auto" }}
>
+ Create Invoice
</button>
@@ -551,11 +551,12 @@ export function InvoicesPage() {
No invoices yet. Create one from a completed appointment.
</p>
) : (
<div style={{ background: "#fff", borderRadius: 8, border: "1px solid #e5e7eb", overflow: "hidden", boxShadow: "0 1px 3px rgba(0, 0, 0, 0.04)" }}>
<table style={{ width: "100%", borderCollapse: "collapse", fontSize: 14 }}>
<thead>
<tr style={{ background: "#f8fafc" }}>
{["Date", "Client", "Subtotal", "Tax", "Tip", "Total", "Status", ""].map((h) => (
<th key={h} style={{ textAlign: "left", padding: "0.5rem 0.75rem", borderBottom: "1px solid #e2e8f0" }}>
<th key={h} style={{ textAlign: "left", padding: "0.55rem 0.75rem", borderBottom: "1px solid #e5e7eb", fontSize: 11, fontWeight: 600, color: "#6b7280", textTransform: "uppercase", letterSpacing: "0.04em" }}>
{h}
</th>
))}
@@ -582,6 +583,7 @@ export function InvoicesPage() {
))}
</tbody>
</table>
</div>
)}
{showCreate && (
@@ -647,15 +649,15 @@ function Field({ label, children }: { label: string; children: React.ReactNode }
}
const btnStyle: React.CSSProperties = {
padding: "0.35rem 0.75rem", border: "1px solid #d1d5db",
borderRadius: 4, background: "#f9fafb", cursor: "pointer", fontSize: 13,
padding: "0.4rem 0.85rem", border: "1px solid #d1d5db",
borderRadius: 6, background: "#fff", cursor: "pointer", fontSize: 13, fontWeight: 500,
};
const inputStyle: React.CSSProperties = {
width: "100%", padding: "0.4rem 0.5rem", border: "1px solid #d1d5db",
borderRadius: 4, fontSize: 14, boxSizing: "border-box",
width: "100%", padding: "0.45rem 0.6rem", border: "1px solid #d1d5db",
borderRadius: 6, fontSize: 14, boxSizing: "border-box",
};
const tdStyle: React.CSSProperties = {
padding: "0.5rem 0.75rem", borderBottom: "1px solid #e2e8f0",
padding: "0.55rem 0.75rem", borderBottom: "1px solid #f3f4f6",
};
+57 -39
View File
@@ -84,14 +84,15 @@ function StatCard({ label, value, sub }: { label: string; value: string; sub?: s
<div
style={{
background: "#fff",
border: "1px solid #e2e8f0",
borderRadius: 8,
border: "1px solid #e5e7eb",
borderRadius: 10,
padding: "1rem 1.25rem",
flex: 1,
minWidth: 140,
boxShadow: "0 1px 3px rgba(0, 0, 0, 0.06), 0 1px 2px rgba(0, 0, 0, 0.04)",
}}
>
<div style={{ fontSize: 12, color: "#6b7280", fontWeight: 600, textTransform: "uppercase", letterSpacing: "0.05em" }}>
<div style={{ fontSize: 11, color: "#6b7280", fontWeight: 600, textTransform: "uppercase", letterSpacing: "0.05em" }}>
{label}
</div>
<div style={{ fontSize: 26, fontWeight: 700, margin: "0.25rem 0", color: "#111827" }}>{value}</div>
@@ -102,7 +103,7 @@ function StatCard({ label, value, sub }: { label: string; value: string; sub?: s
function SectionHeader({ children }: { children: React.ReactNode }) {
return (
<h2 style={{ fontSize: 16, fontWeight: 700, margin: "1.5rem 0 0.75rem", color: "#111827", borderBottom: "1px solid #e2e8f0", paddingBottom: "0.4rem" }}>
<h2 style={{ fontSize: 15, fontWeight: 700, margin: "1.75rem 0 0.75rem", color: "#1a202c", borderBottom: "2px solid #e5e7eb", paddingBottom: "0.5rem" }}>
{children}
</h2>
);
@@ -110,35 +111,37 @@ function SectionHeader({ children }: { children: React.ReactNode }) {
function Table({ headers, rows }: { headers: string[]; rows: (string | number)[][] }) {
return (
<table style={{ width: "100%", borderCollapse: "collapse", fontSize: 13 }}>
<thead>
<tr style={{ background: "#f8fafc" }}>
{headers.map((h) => (
<th key={h} style={{ textAlign: "left", padding: "0.4rem 0.75rem", borderBottom: "1px solid #e2e8f0", fontWeight: 600, color: "#374151" }}>
{h}
</th>
))}
</tr>
</thead>
<tbody>
{rows.map((row, i) => (
<tr key={i} style={{ borderBottom: "1px solid #f1f5f9" }}>
{row.map((cell, j) => (
<td key={j} style={{ padding: "0.4rem 0.75rem", color: "#374151" }}>
{cell}
</td>
<div style={{ background: "#fff", borderRadius: 8, border: "1px solid #e5e7eb", overflow: "hidden", boxShadow: "0 1px 3px rgba(0, 0, 0, 0.04)" }}>
<table style={{ width: "100%", borderCollapse: "collapse", fontSize: 13 }}>
<thead>
<tr style={{ background: "#f8fafc" }}>
{headers.map((h) => (
<th key={h} style={{ textAlign: "left", padding: "0.5rem 0.75rem", borderBottom: "1px solid #e5e7eb", fontWeight: 600, fontSize: 11, color: "#6b7280", textTransform: "uppercase", letterSpacing: "0.04em" }}>
{h}
</th>
))}
</tr>
))}
{rows.length === 0 && (
<tr>
<td colSpan={headers.length} style={{ padding: "1rem 0.75rem", color: "#9ca3af" }}>
No data for this period.
</td>
</tr>
)}
</tbody>
</table>
</thead>
<tbody>
{rows.map((row, i) => (
<tr key={i} style={{ borderBottom: "1px solid #f3f4f6" }}>
{row.map((cell, j) => (
<td key={j} style={{ padding: "0.5rem 0.75rem", color: "#374151" }}>
{cell}
</td>
))}
</tr>
))}
{rows.length === 0 && (
<tr>
<td colSpan={headers.length} style={{ padding: "1.5rem 0.75rem", color: "#9ca3af", textAlign: "center" }}>
No data for this period.
</td>
</tr>
)}
</tbody>
</table>
</div>
);
}
@@ -176,8 +179,23 @@ export function ReportsPage() {
fetch(`/api/reports/clients?${qs}`),
]);
if (!summRes.ok || !revRes.ok || !apptRes.ok || !svcRes.ok || !clientRes.ok) {
throw new Error("Failed to load report data");
const failures = [
["summary", summRes],
["revenue", revRes],
["appointments", apptRes],
["services", svcRes],
["clients", clientRes],
].filter(([, r]) => !(r as Response).ok);
if (failures.length > 0) {
const details = await Promise.all(
failures.map(async ([name, r]) => {
const res = r as Response;
let body = "";
try { body = await res.text(); } catch { /* ignore */ }
return `${name} (HTTP ${res.status}${body ? `: ${body.slice(0, 120)}` : ""})`;
})
);
throw new Error(`Failed to load report data — ${details.join(", ")}`);
}
const [summData, revData, apptData, svcData, clientData] = await Promise.all([
@@ -252,7 +270,7 @@ export function ReportsPage() {
<option value="month">Month</option>
</select>
</label>
<button onClick={loadAll} style={{ ...btnStyle, background: "#1d4ed8", color: "#fff", borderColor: "#1d4ed8" }}>
<button onClick={loadAll} style={{ ...btnStyle, background: "#4f8a6f", color: "#fff", borderColor: "#4f8a6f" }}>
{loading ? "Loading…" : "Refresh"}
</button>
<div style={{ marginLeft: "auto", display: "flex", gap: "0.5rem" }}>
@@ -375,19 +393,19 @@ export function ReportsPage() {
// ─── Shared styles ────────────────────────────────────────────────────────────
const btnStyle: React.CSSProperties = {
padding: "0.35rem 0.75rem",
padding: "0.4rem 0.85rem",
border: "1px solid #d1d5db",
borderRadius: 4,
background: "#f9fafb",
borderRadius: 6,
background: "#fff",
cursor: "pointer",
fontSize: 13,
fontWeight: 500,
};
const inputStyle: React.CSSProperties = {
padding: "0.3rem 0.4rem",
padding: "0.35rem 0.5rem",
border: "1px solid #d1d5db",
borderRadius: 4,
borderRadius: 6,
fontSize: 13,
marginLeft: "0.25rem",
};
+10 -8
View File
@@ -119,7 +119,7 @@ export function ServicesPage() {
<h1 style={{ margin: 0 }}>Services</h1>
<button
onClick={openNew}
style={{ ...btnStyle, backgroundColor: "#3b82f6", color: "#fff", borderColor: "#3b82f6", marginLeft: "auto" }}
style={{ ...btnStyle, backgroundColor: "#4f8a6f", color: "#fff", borderColor: "#4f8a6f", marginLeft: "auto" }}
>
+ Add Service
</button>
@@ -128,11 +128,12 @@ export function ServicesPage() {
{services.length === 0 ? (
<p>No services configured yet.</p>
) : (
<div style={{ background: "#fff", borderRadius: 8, border: "1px solid #e5e7eb", overflow: "hidden", boxShadow: "0 1px 3px rgba(0, 0, 0, 0.04)" }}>
<table style={{ width: "100%", borderCollapse: "collapse", fontSize: 14 }}>
<thead>
<tr style={{ background: "#f8fafc" }}>
{["Name", "Description", "Price", "Duration", "Status", ""].map((h) => (
<th key={h} style={{ textAlign: "left", padding: "0.5rem 0.75rem", borderBottom: "1px solid #e2e8f0" }}>
<th key={h} style={{ textAlign: "left", padding: "0.55rem 0.75rem", borderBottom: "1px solid #e5e7eb", fontSize: 11, fontWeight: 600, color: "#6b7280", textTransform: "uppercase", letterSpacing: "0.04em" }}>
{h}
</th>
))}
@@ -171,6 +172,7 @@ export function ServicesPage() {
))}
</tbody>
</table>
</div>
)}
{showForm && (
@@ -230,7 +232,7 @@ export function ServicesPage() {
<button
type="submit"
disabled={saving}
style={{ ...btnStyle, backgroundColor: "#3b82f6", color: "#fff", borderColor: "#3b82f6" }}
style={{ ...btnStyle, backgroundColor: "#4f8a6f", color: "#fff", borderColor: "#4f8a6f" }}
>
{saving ? "Saving…" : editing ? "Save Changes" : "Create Service"}
</button>
@@ -277,15 +279,15 @@ function Field({ label, children }: { label: string; children: React.ReactNode }
}
const btnStyle: React.CSSProperties = {
padding: "0.35rem 0.75rem", border: "1px solid #d1d5db",
borderRadius: 4, background: "#f9fafb", cursor: "pointer", fontSize: 13,
padding: "0.4rem 0.85rem", border: "1px solid #d1d5db",
borderRadius: 6, background: "#fff", cursor: "pointer", fontSize: 13, fontWeight: 500,
};
const inputStyle: React.CSSProperties = {
width: "100%", padding: "0.4rem 0.5rem", border: "1px solid #d1d5db",
borderRadius: 4, fontSize: 14, boxSizing: "border-box",
width: "100%", padding: "0.45rem 0.6rem", border: "1px solid #d1d5db",
borderRadius: 6, fontSize: 14, boxSizing: "border-box",
};
const tdStyle: React.CSSProperties = {
padding: "0.5rem 0.75rem", borderBottom: "1px solid #e2e8f0",
padding: "0.55rem 0.75rem", borderBottom: "1px solid #f3f4f6",
};
+323
View File
@@ -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>
);
}
+8 -6
View File
@@ -78,7 +78,7 @@ export function StaffPage() {
<div style={{ fontFamily: "system-ui, sans-serif" }}>
<div style={{ display: "flex", alignItems: "center", gap: "1rem", marginBottom: "1rem" }}>
<h1 style={{ margin: 0 }}>Staff</h1>
<button onClick={openNew} style={{ ...btnStyle, backgroundColor: "#3b82f6", color: "#fff", borderColor: "#3b82f6", marginLeft: "auto" }}>
<button onClick={openNew} style={{ ...btnStyle, backgroundColor: "#4f8a6f", color: "#fff", borderColor: "#4f8a6f", marginLeft: "auto" }}>
+ Add Staff
</button>
</div>
@@ -86,11 +86,12 @@ export function StaffPage() {
{staff.length === 0 ? (
<p>No staff members yet.</p>
) : (
<div style={{ background: "#fff", borderRadius: 8, border: "1px solid #e5e7eb", overflow: "hidden", boxShadow: "0 1px 3px rgba(0, 0, 0, 0.04)" }}>
<table style={{ width: "100%", borderCollapse: "collapse", fontSize: 14 }}>
<thead>
<tr style={{ background: "#f8fafc" }}>
{["Name", "Email", "Role", "Status", ""].map((h) => (
<th key={h} style={{ textAlign: "left", padding: "0.5rem 0.75rem", borderBottom: "1px solid #e2e8f0" }}>{h}</th>
<th key={h} style={{ textAlign: "left", padding: "0.55rem 0.75rem", borderBottom: "1px solid #e5e7eb", fontSize: 11, fontWeight: 600, color: "#6b7280", textTransform: "uppercase", letterSpacing: "0.04em" }}>{h}</th>
))}
</tr>
</thead>
@@ -113,6 +114,7 @@ export function StaffPage() {
))}
</tbody>
</table>
</div>
)}
{showForm && (
@@ -143,7 +145,7 @@ export function StaffPage() {
</div>
{formError && <p style={{ color: "red", margin: "0.5rem 0 0" }}>{formError}</p>}
<div style={{ display: "flex", gap: "0.5rem", marginTop: "1rem" }}>
<button type="submit" disabled={saving} style={{ ...btnStyle, backgroundColor: "#3b82f6", color: "#fff", borderColor: "#3b82f6" }}>
<button type="submit" disabled={saving} style={{ ...btnStyle, backgroundColor: "#4f8a6f", color: "#fff", borderColor: "#4f8a6f" }}>
{saving ? "Saving…" : editing ? "Save Changes" : "Add Staff"}
</button>
<button type="button" onClick={() => setShowForm(false)} style={btnStyle}>Cancel</button>
@@ -156,7 +158,7 @@ export function StaffPage() {
);
}
const btnStyle: React.CSSProperties = { padding: "0.35rem 0.75rem", border: "1px solid #d1d5db", borderRadius: 4, background: "#f9fafb", cursor: "pointer", fontSize: 13 };
const inputStyle: React.CSSProperties = { width: "100%", padding: "0.4rem 0.5rem", border: "1px solid #d1d5db", borderRadius: 4, fontSize: 14, boxSizing: "border-box" };
const btnStyle: React.CSSProperties = { padding: "0.4rem 0.85rem", border: "1px solid #d1d5db", borderRadius: 6, background: "#fff", cursor: "pointer", fontSize: 13, fontWeight: 500 };
const inputStyle: React.CSSProperties = { width: "100%", padding: "0.45rem 0.6rem", border: "1px solid #d1d5db", borderRadius: 6, fontSize: 14, boxSizing: "border-box" };
const labelStyle: React.CSSProperties = { display: "block", fontWeight: 600, marginBottom: "0.25rem", fontSize: 13, color: "#374151" };
const tdStyle: React.CSSProperties = { padding: "0.5rem 0.75rem", borderBottom: "1px solid #e2e8f0" };
const tdStyle: React.CSSProperties = { padding: "0.55rem 0.75rem", borderBottom: "1px solid #f3f4f6" };
+47 -78
View File
@@ -1,92 +1,61 @@
import { useEffect, useState } from "react";
import type { ImpersonationAuditLog } from "@groombook/types";
import { useState } from "react";
import { X, Filter } from "lucide-react";
import type { AuditEntry } from "./mockData.js";
interface Props {
sessionId: string;
auditLog: AuditEntry[];
onClose: () => void;
}
export function AuditLogViewer({ sessionId, onClose }: Props) {
const [logs, setLogs] = useState<ImpersonationAuditLog[]>([]);
const [loading, setLoading] = useState(true);
export function AuditLogViewer({ auditLog, onClose }: Props) {
const [filterAction, setFilterAction] = useState<string>("all");
useEffect(() => {
fetch(`/api/impersonation/sessions/${sessionId}/audit-log`)
.then((r) => r.json())
.then((data) => setLogs(data as ImpersonationAuditLog[]))
.finally(() => setLoading(false));
}, [sessionId]);
const actionTypes = ["all", ...new Set(auditLog.map(e => e.action))];
const filtered = filterAction === "all" ? auditLog : auditLog.filter(e => e.action === filterAction);
return (
<div
style={{
position: "fixed",
inset: 0,
background: "rgba(0,0,0,0.45)",
display: "flex",
alignItems: "center",
justifyContent: "center",
zIndex: 10000,
}}
onClick={(e) => { if (e.target === e.currentTarget) onClose(); }}
>
<div
style={{
background: "#fff",
borderRadius: 8,
padding: "1.5rem",
maxWidth: 600,
width: "calc(100% - 2rem)",
maxHeight: "80vh",
overflowY: "auto",
boxShadow: "0 20px 60px rgba(0,0,0,0.3)",
}}
>
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center", marginBottom: "1rem" }}>
<h2 style={{ margin: 0, fontSize: 18 }}>Audit Log</h2>
<button
onClick={onClose}
style={{
padding: "0.25rem 0.6rem",
border: "1px solid #d1d5db",
borderRadius: 4,
background: "#f9fafb",
cursor: "pointer",
fontSize: 13,
}}
>
Close
<div className="fixed inset-0 bg-black/50 z-50 flex items-center justify-center p-4">
<div className="bg-white rounded-2xl shadow-xl max-w-2xl w-full max-h-[80vh] flex flex-col">
<div className="flex items-center justify-between px-6 py-4 border-b border-stone-200">
<h2 className="font-semibold text-stone-800">Impersonation Audit Log</h2>
<button onClick={onClose} className="p-1.5 hover:bg-stone-100 rounded-lg">
<X size={18} className="text-stone-500" />
</button>
</div>
{loading ? (
<p style={{ color: "#6b7280", fontSize: 14 }}>Loading audit log...</p>
) : logs.length === 0 ? (
<p style={{ color: "#6b7280", fontSize: 14 }}>No audit entries.</p>
) : (
<table style={{ width: "100%", borderCollapse: "collapse", fontSize: 13 }}>
<thead>
<tr style={{ borderBottom: "2px solid #e5e7eb" }}>
<th style={{ textAlign: "left", padding: "0.4rem 0.5rem", color: "#6b7280" }}>Time</th>
<th style={{ textAlign: "left", padding: "0.4rem 0.5rem", color: "#6b7280" }}>Action</th>
<th style={{ textAlign: "left", padding: "0.4rem 0.5rem", color: "#6b7280" }}>Page</th>
</tr>
</thead>
<tbody>
{logs.map((log) => (
<tr key={log.id} style={{ borderBottom: "1px solid #f3f4f6" }}>
<td style={{ padding: "0.4rem 0.5rem", color: "#6b7280", whiteSpace: "nowrap" }}>
{new Date(log.createdAt).toLocaleTimeString()}
</td>
<td style={{ padding: "0.4rem 0.5rem" }}>{log.action}</td>
<td style={{ padding: "0.4rem 0.5rem", color: "#6b7280" }}>
{log.pageVisited ?? "—"}
</td>
</tr>
<div className="px-6 py-3 border-b border-stone-100 flex items-center gap-2">
<Filter size={14} className="text-stone-400" />
<select
value={filterAction}
onChange={e => setFilterAction(e.target.value)}
className="text-sm border border-stone-200 rounded-lg px-2 py-1"
>
{actionTypes.map(a => (
<option key={a} value={a}>{a === "all" ? "All actions" : a.replace(/_/g, " ")}</option>
))}
</select>
<span className="text-xs text-stone-400 ml-auto">{filtered.length} entries</span>
</div>
<div className="flex-1 overflow-y-auto px-6 py-3">
{filtered.length === 0 ? (
<p className="text-sm text-stone-400 text-center py-8">No audit entries</p>
) : (
<div className="space-y-3">
{filtered.map(entry => (
<div key={entry.id} className="flex gap-3 text-sm">
<div className="text-xs text-stone-400 whitespace-nowrap pt-0.5 w-20 shrink-0">
{new Date(entry.timestamp).toLocaleTimeString()}
</div>
<div>
<span className="inline-block px-2 py-0.5 bg-stone-100 text-stone-600 rounded text-xs font-medium mb-0.5">
{entry.action.replace(/_/g, " ")}
</span>
<p className="text-stone-700">{entry.detail}</p>
</div>
</div>
))}
</tbody>
</table>
)}
</div>
)}
</div>
</div>
</div>
);
+351 -124
View File
@@ -1,148 +1,375 @@
import { useCallback, useEffect, useState } from "react";
import { useSearchParams, useLocation } from "react-router-dom";
import type { ImpersonationSession } from "@groombook/types";
import { useState, useReducer, useCallback, useEffect } from "react";
import {
Home, Calendar, PawPrint, FileText, CreditCard, MessageSquare,
Settings, Eye, LogOut, Clock, Shield,
} from "lucide-react";
import { Dashboard } from "./sections/Dashboard.js";
import { AppointmentsSection } from "./sections/Appointments.js";
import { PetProfiles } from "./sections/PetProfiles.js";
import { ReportCards } from "./sections/ReportCards.js";
import { BillingPayments } from "./sections/BillingPayments.js";
import { Communication } from "./sections/Communication.js";
import { AccountSettings } from "./sections/AccountSettings.js";
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";
interface Props {
children: React.ReactNode;
type Section = "dashboard" | "appointments" | "pets" | "reports" | "billing" | "messages" | "settings";
const NAV_ITEMS: { id: Section; label: string; icon: typeof Home }[] = [
{ id: "dashboard", label: "Home", icon: Home },
{ id: "appointments", label: "Appointments", icon: Calendar },
{ id: "pets", label: "My Pets", icon: PawPrint },
{ id: "reports", label: "Report Cards", icon: FileText },
{ id: "billing", label: "Billing", icon: CreditCard },
{ id: "messages", label: "Messages", icon: MessageSquare },
{ id: "settings", label: "Settings", icon: Settings },
];
type ImpersonationAction =
| { type: "START"; staffName: string; staffRole: string; reason: string }
| { type: "END" }
| { type: "EXTEND" }
| { type: "LOG"; entry: AuditEntry };
function impersonationReducer(
state: ImpersonationSession | null,
action: ImpersonationAction
): ImpersonationSession | null {
switch (action.type) {
case "START": {
const now = new Date();
const expires = new Date(now.getTime() + 30 * 60 * 1000);
return {
active: true,
staffName: action.staffName,
staffRole: action.staffRole,
customerName: CUSTOMER.name,
reason: action.reason,
startedAt: now.toISOString(),
expiresAt: expires.toISOString(),
extended: false,
readOnly: true,
auditLog: [{
id: "audit-0",
timestamp: now.toISOString(),
action: "session_start",
detail: `Impersonation started by ${action.staffName} (${action.staffRole}). Reason: ${action.reason}`,
}],
};
}
case "END":
if (!state) return null;
return {
...state,
active: false,
auditLog: [...state.auditLog, {
id: `audit-${state.auditLog.length}`,
timestamp: new Date().toISOString(),
action: "session_end",
detail: "Impersonation session ended",
}],
};
case "EXTEND":
if (!state) return null;
return {
...state,
expiresAt: new Date(new Date(state.expiresAt).getTime() + 30 * 60 * 1000).toISOString(),
extended: true,
auditLog: [...state.auditLog, {
id: `audit-${state.auditLog.length}`,
timestamp: new Date().toISOString(),
action: "session_extended",
detail: "Session extended by 30 minutes",
}],
};
case "LOG":
if (!state) return null;
return { ...state, auditLog: [...state.auditLog, action.entry] };
default:
return state;
}
}
/**
* Wraps the app to provide impersonation state.
* Start impersonation by navigating with ?impersonate=<clientId>.
* The banner is non-dismissable while a session is active.
*/
export function CustomerPortal({ children }: Props) {
const [searchParams, setSearchParams] = useSearchParams();
const location = useLocation();
const [session, setSession] = useState<ImpersonationSession | null>(null);
const [clientName, setClientName] = useState("");
export function CustomerPortal() {
const [activeSection, setActiveSection] = useState<Section>("dashboard");
const [mobileNavOpen, setMobileNavOpen] = useState(false);
const [showAuditLog, setShowAuditLog] = useState(false);
const [error, setError] = useState<string | null>(null);
// Start session from URL param
const impersonateClientId = searchParams.get("impersonate");
const startSession = useCallback(
async (clientId: string) => {
try {
const res = await fetch("/api/impersonation/sessions", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ clientId }),
});
if (!res.ok) {
const err = (await res.json()) as { error?: string; sessionId?: string };
if (res.status === 409 && err.sessionId) {
// Already have an active session — load it
const existing = await fetch(`/api/impersonation/sessions/${err.sessionId}`);
if (existing.ok) {
setSession((await existing.json()) as ImpersonationSession);
}
} else {
setError(err.error ?? `HTTP ${res.status}`);
}
return;
}
setSession((await res.json()) as ImpersonationSession);
} catch {
setError("Failed to start impersonation session");
}
},
[]
);
const [showImpersonationSetup, setShowImpersonationSetup] = useState(false);
const [impersonation, dispatchImpersonation] = useReducer(impersonationReducer, null);
const { branding } = useBranding();
// Auto-start impersonation from URL params (staff flow from admin panel).
// Runs once on mount only — impersonation state is managed by the reducer after init.
const [impersonationInitDone, setImpersonationInitDone] = useState(false);
useEffect(() => {
if (impersonateClientId && !session) {
// Fetch client name
fetch(`/api/clients/${impersonateClientId}`)
.then((r) => r.json())
.then((c: { name?: string }) => setClientName(c.name ?? "Unknown"))
.catch(() => setClientName("Unknown"));
void startSession(impersonateClientId);
// Clean the URL param
const next = new URLSearchParams(searchParams);
next.delete("impersonate");
setSearchParams(next, { replace: true });
if (impersonationInitDone) return;
const params = new URLSearchParams(window.location.search);
if (params.get("impersonate") === "true") {
const clientName = params.get("clientName") || "Unknown Customer";
const reason = params.get("reason") || `Viewing portal as ${clientName}`;
const staffName = params.get("staffName") || "Staff";
dispatchImpersonation({
type: "START",
staffName,
staffRole: "Admin",
reason,
});
window.history.replaceState({}, "", window.location.pathname);
}
}, [impersonateClientId, session, searchParams, setSearchParams, startSession]);
setImpersonationInitDone(true);
}, [impersonationInitDone]);
// Log page visits
useEffect(() => {
if (!session || session.status !== "active") return;
void fetch(`/api/impersonation/sessions/${session.id}/log`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ action: "page_visit", pageVisited: location.pathname }),
});
}, [location.pathname, session]);
async function endSession() {
if (!session) return;
const res = await fetch(`/api/impersonation/sessions/${session.id}/end`, {
method: "POST",
});
if (res.ok) {
setSession(null);
setClientName("");
const logPageView = useCallback((page: string) => {
if (impersonation?.active) {
dispatchImpersonation({
type: "LOG",
entry: {
id: `audit-${Date.now()}`,
timestamp: new Date().toISOString(),
action: "page_view",
detail: `Viewed: ${page}`,
},
});
}
}
}, [impersonation?.active]);
async function extendSession() {
if (!session) return;
const res = await fetch(`/api/impersonation/sessions/${session.id}/extend`, {
method: "POST",
});
if (res.ok) {
setSession((await res.json()) as ImpersonationSession);
const handleNavClick = (section: Section) => {
setActiveSection(section);
setMobileNavOpen(false);
logPageView(section);
};
const isReadOnly = impersonation?.active && impersonation.readOnly;
const renderSection = () => {
switch (activeSection) {
case "dashboard":
return <Dashboard onNavigate={handleNavClick} readOnly={!!isReadOnly} />;
case "appointments":
return <AppointmentsSection readOnly={!!isReadOnly} />;
case "pets":
return <PetProfiles readOnly={!!isReadOnly} />;
case "reports":
return <ReportCards />;
case "billing":
return <BillingPayments readOnly={!!isReadOnly} />;
case "messages":
return <Communication readOnly={!!isReadOnly} />;
case "settings":
return <AccountSettings readOnly={!!isReadOnly} />;
}
}
};
return (
<>
{error && (
<div
style={{
position: "fixed",
top: 0,
left: 0,
right: 0,
background: "#fef2f2",
color: "#dc2626",
padding: "0.5rem 1rem",
fontSize: 14,
zIndex: 9999,
textAlign: "center",
}}
>
{error}
<button
onClick={() => setError(null)}
style={{ marginLeft: "1rem", cursor: "pointer", background: "none", border: "none", color: "#dc2626", textDecoration: "underline" }}
>
Dismiss
</button>
</div>
<div
className="min-h-screen bg-[#faf8f5] font-sans"
style={impersonation?.active ? { border: "3px solid #f59e0b" } : undefined}
>
{impersonation?.active && (
<>
<ImpersonationBanner
session={impersonation}
onEnd={() => dispatchImpersonation({ type: "END" })}
onExtend={() => dispatchImpersonation({ type: "EXTEND" })}
onShowAudit={() => setShowAuditLog(true)}
/>
{/* Watermark */}
<div className="fixed inset-0 pointer-events-none z-10 flex items-center justify-center opacity-[0.04]">
<div className="text-8xl font-bold text-amber-900 -rotate-45 select-none tracking-widest">
STAFF VIEW
</div>
</div>
</>
)}
{session && session.status === "active" && (
<ImpersonationBanner
clientName={clientName}
expiresAt={session.expiresAt}
onEnd={endSession}
onExtend={extendSession}
{showAuditLog && impersonation && (
<AuditLogViewer
auditLog={impersonation.auditLog}
onClose={() => setShowAuditLog(false)}
/>
)}
{/* Push content down when banner is visible */}
<div style={{ paddingTop: session?.status === "active" ? "2.5rem" : 0 }}>
{children}
{/* Mobile Header */}
<header className="md:hidden flex items-center justify-between px-4 py-3 bg-white border-b border-stone-200">
<button
onClick={() => setMobileNavOpen(!mobileNavOpen)}
className="p-2 text-stone-600 hover:text-stone-900"
aria-label="Toggle navigation"
>
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<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">{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>
<div className="flex">
{/* Sidebar Navigation */}
<nav className={`
${mobileNavOpen ? "translate-x-0" : "-translate-x-full"}
md:translate-x-0 fixed md:sticky top-0 left-0 z-30
w-64 h-screen bg-white border-r border-stone-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">
{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">{branding.businessName}</div>
<div className="text-xs text-stone-500">Grooming</div>
</div>
</div>
<div className="flex-1 py-4 px-3 space-y-1 overflow-y-auto">
{NAV_ITEMS.map(({ id, label, icon: Icon }) => {
const active = id === activeSection;
return (
<button
key={id}
onClick={() => handleNavClick(id)}
className={`
w-full flex items-center gap-3 px-3 py-2.5 rounded-lg text-sm font-medium transition-colors
${active
? "bg-stone-100 text-stone-800 font-semibold"
: "text-stone-600 hover:bg-stone-50 hover:text-stone-900"
}
`}
>
<Icon size={18} />
{label}
</button>
);
})}
</div>
{/* Demo Controls */}
<div className="border-t border-stone-100 p-4 space-y-2">
{!impersonation?.active ? (
<button
onClick={() => setShowImpersonationSetup(true)}
className="w-full flex items-center gap-2 px-3 py-2 rounded-lg text-xs font-medium text-amber-700 bg-amber-50 hover:bg-amber-100 transition-colors"
>
<Eye size={14} />
Demo: Staff Impersonation
</button>
) : (
<button
onClick={() => dispatchImpersonation({ type: "END" })}
className="w-full flex items-center gap-2 px-3 py-2 rounded-lg text-xs font-medium text-red-700 bg-red-50 hover:bg-red-100 transition-colors"
>
<LogOut size={14} />
End Impersonation
</button>
)}
<div className="flex items-center gap-2 px-3 py-2 text-xs text-stone-400">
<Shield size={12} />
Customer Portal v1.0
</div>
</div>
</nav>
{/* Mobile nav overlay */}
{mobileNavOpen && (
<div
className="fixed inset-0 bg-black/30 z-20 md:hidden"
onClick={() => setMobileNavOpen(false)}
/>
)}
{/* Main Content */}
<main className="flex-1 min-h-screen">
<div className="hidden md:flex items-center justify-between px-8 py-4 border-b border-stone-200 bg-white">
<div>
<h1 className="text-lg font-semibold text-stone-800">
{NAV_ITEMS.find(n => n.id === activeSection)?.label}
</h1>
</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 flex items-center justify-center text-white text-sm font-medium" style={{ background: branding.accentColor }}>
SM
</div>
</div>
</div>
<div className="p-4 md:p-8 max-w-6xl">
{renderSection()}
</div>
</main>
</div>
{showAuditLog && session && (
<AuditLogViewer sessionId={session.id} onClose={() => setShowAuditLog(false)} />
)}
</>
{/* Impersonation Setup Modal */}
{showImpersonationSetup && <ImpersonationSetupModal
onStart={(reason) => {
dispatchImpersonation({ type: "START", staffName: "Chris", staffRole: "Admin", reason });
setShowImpersonationSetup(false);
}}
onCancel={() => setShowImpersonationSetup(false)}
/>}
</div>
);
}
function ImpersonationSetupModal({ onStart, onCancel }: { onStart: (reason: string) => void; onCancel: () => void }) {
const [reason, setReason] = useState("");
return (
<div className="fixed inset-0 bg-black/50 z-50 flex items-center justify-center p-4">
<div className="bg-white rounded-2xl shadow-xl max-w-md w-full p-6">
<div className="flex items-center gap-3 mb-4">
<div className="w-10 h-10 rounded-full bg-amber-100 flex items-center justify-center">
<Eye size={20} className="text-amber-700" />
</div>
<div>
<h2 className="font-semibold text-stone-800">Start Staff Impersonation</h2>
<p className="text-sm text-stone-500">View portal as {CUSTOMER.name}</p>
</div>
</div>
<div className="mb-4">
<label className="block text-sm font-medium text-stone-700 mb-1">
Reason for impersonation <span className="text-red-500">*</span>
</label>
<textarea
className="w-full border border-stone-300 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-amber-500 focus:border-amber-500"
rows={3}
placeholder="e.g., Customer reports they can't see their upcoming appointment"
value={reason}
onChange={e => setReason(e.target.value)}
/>
</div>
<div className="flex items-center gap-2 mb-4 px-3 py-2 bg-amber-50 rounded-lg">
<Clock size={14} className="text-amber-600" />
<span className="text-xs text-amber-700">Session will auto-expire after 30 minutes</span>
</div>
<div className="flex gap-3">
<button
onClick={onCancel}
className="flex-1 px-4 py-2 border border-stone-300 rounded-lg text-sm font-medium text-stone-700 hover:bg-stone-50"
>
Cancel
</button>
<button
onClick={() => reason.trim() && onStart(reason.trim())}
disabled={!reason.trim()}
className="flex-1 px-4 py-2 bg-amber-600 text-white rounded-lg text-sm font-medium hover:bg-amber-700 disabled:opacity-50 disabled:cursor-not-allowed"
>
Start Session
</button>
</div>
</div>
</div>
);
}
+51 -54
View File
@@ -1,83 +1,80 @@
import { useEffect, useState } from "react";
import { useState, useEffect } from "react";
import { Eye, Clock, LogOut, FileSearch } from "lucide-react";
import type { ImpersonationSession } from "./mockData.js";
interface Props {
clientName: string;
expiresAt: string;
session: ImpersonationSession;
onEnd: () => void;
onExtend: () => void;
onShowAudit: () => void;
}
export function ImpersonationBanner({ clientName, expiresAt, onEnd, onExtend }: Props) {
export function ImpersonationBanner({ session, onEnd, onExtend, onShowAudit }: Props) {
const [remaining, setRemaining] = useState("");
const [showWarning, setShowWarning] = useState(false);
useEffect(() => {
function tick() {
const diff = new Date(expiresAt).getTime() - Date.now();
const tick = () => {
const now = Date.now();
const expires = new Date(session.expiresAt).getTime();
const diff = expires - now;
if (diff <= 0) {
setRemaining("Expired");
onEnd();
return;
}
const mins = Math.floor(diff / 60_000);
const secs = Math.floor((diff % 60_000) / 1000);
const mins = Math.floor(diff / 60000);
const secs = Math.floor((diff % 60000) / 1000);
setRemaining(`${mins}:${secs.toString().padStart(2, "0")}`);
}
setShowWarning(mins < 5);
};
tick();
const id = setInterval(tick, 1000);
return () => clearInterval(id);
}, [expiresAt]);
}, [session.expiresAt, onEnd]);
if (!session.active) return null;
return (
<div
style={{
position: "fixed",
top: 0,
left: 0,
right: 0,
background: "#dc2626",
color: "#fff",
padding: "0.5rem 1rem",
display: "flex",
alignItems: "center",
justifyContent: "space-between",
zIndex: 9999,
fontSize: 14,
fontFamily: "system-ui, sans-serif",
}}
>
<div>
<strong>IMPERSONATING:</strong> {clientName} Read-only mode
<span style={{ marginLeft: "1rem", opacity: 0.85 }}>
Time remaining: {remaining}
<div className="sticky top-0 z-40 bg-amber-500 text-amber-950 px-4 py-2.5 flex flex-wrap items-center gap-x-4 gap-y-1 text-sm font-medium shadow-md">
<span className="flex items-center gap-1.5">
<Eye size={16} />
STAFF VIEW
</span>
<span className="hidden sm:inline">
Viewing as <strong>{session.customerName}</strong>
</span>
<span className="hidden md:inline text-amber-800 text-xs">
Reason: {session.reason}
</span>
<span className="hidden sm:inline text-amber-800 text-xs">
Started {new Date(session.startedAt).toLocaleTimeString()}
</span>
<div className="flex items-center gap-2 ml-auto">
<span className={`flex items-center gap-1 text-xs ${showWarning ? "text-red-800 font-bold animate-pulse" : "text-amber-800"}`}>
<Clock size={14} />
{remaining}
</span>
</div>
<div style={{ display: "flex", gap: "0.5rem" }}>
{showWarning && !session.extended && (
<button
onClick={onExtend}
className="px-2 py-1 text-xs bg-amber-600 text-white rounded hover:bg-amber-700"
>
Extend
</button>
)}
<button
onClick={onExtend}
style={{
padding: "0.25rem 0.6rem",
border: "1px solid rgba(255,255,255,0.5)",
borderRadius: 4,
background: "transparent",
color: "#fff",
cursor: "pointer",
fontSize: 13,
}}
onClick={onShowAudit}
className="px-2 py-1 text-xs bg-amber-100 text-amber-800 rounded hover:bg-amber-200 flex items-center gap-1"
>
Extend
<FileSearch size={12} />
Audit
</button>
<button
onClick={onEnd}
style={{
padding: "0.25rem 0.6rem",
border: "1px solid #fff",
borderRadius: 4,
background: "#fff",
color: "#dc2626",
cursor: "pointer",
fontSize: 13,
fontWeight: 600,
}}
className="px-2 py-1 text-xs bg-red-600 text-white rounded hover:bg-red-700 flex items-center gap-1"
>
<LogOut size={12} />
End Session
</button>
</div>
+348
View File
@@ -0,0 +1,348 @@
export interface Pet {
id: string;
name: string;
breed: string;
weight: number;
dob: string;
sex: "male" | "female";
spayedNeutered: boolean;
photo: string;
allergies: string;
skinConditions: string;
anxietyTriggers: string;
aggressionNotes: string;
mobilityIssues: string;
medications: string;
preferredCut: string;
shampooPreference: string;
sensitiveAreas: string;
standingInstructions: string;
vaccinations: Vaccination[];
}
export interface Vaccination {
name: string;
lastAdministered: string;
expirationDate: string;
status: "valid" | "expiring" | "expired";
documentUploaded: boolean;
}
export interface Appointment {
id: string;
petId: string;
petName: string;
groomerId: string;
groomerName: string;
services: string[];
addOns: string[];
date: string;
time: string;
duration: number;
price: number;
status: "confirmed" | "pending" | "waitlisted" | "completed" | "cancelled";
notes: string;
reportCardId?: string;
}
export interface ReportCard {
id: string;
appointmentId: string;
petName: string;
groomerName: string;
date: string;
servicesPerformed: string[];
behaviorMood: "calm" | "anxious" | "wiggly" | "cooperative";
conditionObservations: string[];
groomerNote: string;
nextRecommendedDate: string;
beforeDescription: string;
afterDescription: string;
}
export interface Invoice {
id: string;
date: string;
amount: number;
status: "paid" | "outstanding" | "overdue";
items: string[];
}
export interface Message {
id: string;
sender: "customer" | "business";
senderName: string;
text: string;
timestamp: string;
read: boolean;
}
export interface Service {
id: string;
name: string;
description: string;
duration: number;
priceRange: string;
isAddOn: boolean;
}
export interface Groomer {
id: string;
name: string;
specialties: string[];
avatar: string;
}
export interface ImpersonationSession {
active: boolean;
staffName: string;
staffRole: string;
customerName: string;
reason: string;
startedAt: string;
expiresAt: string;
extended: boolean;
readOnly: boolean;
auditLog: AuditEntry[];
}
export interface AuditEntry {
id: string;
timestamp: string;
action: string;
detail: string;
}
export interface LoyaltyInfo {
points: number;
nextRewardAt: number;
rewardName: string;
}
export const GROOMERS: Groomer[] = [
{ id: "g1", name: "Jamie", specialties: ["Large breeds", "Dematting"], avatar: "🧑‍🎨" },
{ id: "g2", name: "Alex", specialties: ["Small breeds", "Creative cuts"], avatar: "💇" },
{ id: "g3", name: "Morgan", specialties: ["Anxious pets", "Cats"], avatar: "✂️" },
];
export const SERVICES: Service[] = [
{ id: "s1", name: "Bath & Brush", description: "Full bath, blow-dry, and brush-out", duration: 45, priceRange: "$45$65", isAddOn: false },
{ id: "s2", name: "Full Groom", description: "Bath, haircut, nail trim, ear cleaning", duration: 90, priceRange: "$75$120", isAddOn: false },
{ id: "s3", name: "Puppy's First Groom", description: "Gentle introduction to grooming for puppies under 6 months", duration: 60, priceRange: "$55$70", isAddOn: false },
{ id: "s4", name: "Nail Trim", description: "Quick nail trim and file", duration: 15, priceRange: "$15$20", isAddOn: false },
{ id: "s5", name: "Teeth Brushing", description: "Enzymatic toothpaste brushing", duration: 10, priceRange: "$10$15", isAddOn: true },
{ id: "s6", name: "Nail Grinding", description: "Smooth finish with a Dremel tool", duration: 15, priceRange: "$12$18", isAddOn: true },
{ id: "s7", name: "De-shedding Treatment", description: "Specialized undercoat removal and conditioning", duration: 30, priceRange: "$25$40", isAddOn: true },
{ id: "s8", name: "Blueberry Facial", description: "Gentle face wash with brightening blueberry formula", duration: 10, priceRange: "$8$12", isAddOn: true },
];
export const PETS: Pet[] = [
{
id: "p1",
name: "Biscuit",
breed: "Golden Retriever",
weight: 65,
dob: "2022-01-15",
sex: "male",
spayedNeutered: true,
photo: "🐕",
allergies: "None known",
skinConditions: "Mild dry skin in winter",
anxietyTriggers: "None — very calm",
aggressionNotes: "None",
mobilityIssues: "None",
medications: "Monthly heartworm prevention",
preferredCut: "Teddy bear cut",
shampooPreference: "Oatmeal-based (sensitive skin)",
sensitiveAreas: "Ears — prone to irritation",
standingInstructions: "Extra gentle around ears. Likes treats during nail trim.",
vaccinations: [
{ name: "Rabies", lastAdministered: "2025-06-10", expirationDate: "2028-06-10", status: "valid", documentUploaded: true },
{ name: "DHPP", lastAdministered: "2025-08-20", expirationDate: "2026-08-20", status: "valid", documentUploaded: true },
{ name: "Bordetella", lastAdministered: "2025-09-01", expirationDate: "2026-09-01", status: "valid", documentUploaded: true },
{ name: "Leptospirosis", lastAdministered: "2025-08-20", expirationDate: "2026-08-20", status: "valid", documentUploaded: false },
],
},
{
id: "p2",
name: "Mochi",
breed: "Shih Tzu",
weight: 12,
dob: "2024-02-28",
sex: "female",
spayedNeutered: true,
photo: "🐩",
allergies: "Chicken-based products",
skinConditions: "None",
anxietyTriggers: "Loud dryers, nail clipping",
aggressionNotes: "May nip during nail trimming",
mobilityIssues: "None",
medications: "None",
preferredCut: "Puppy cut — even length all over",
shampooPreference: "Hypoallergenic",
sensitiveAreas: "Paws — very sensitive to handling",
standingInstructions: "Use quiet dryer setting. Take breaks during nail trim. Distract with peanut butter mat.",
vaccinations: [
{ name: "Rabies", lastAdministered: "2025-04-15", expirationDate: "2026-04-15", status: "valid", documentUploaded: true },
{ name: "DHPP", lastAdministered: "2025-04-15", expirationDate: "2026-04-15", status: "valid", documentUploaded: true },
{ name: "Bordetella", lastAdministered: "2025-06-28", expirationDate: "2026-03-28", status: "expiring", documentUploaded: true },
{ name: "Leptospirosis", lastAdministered: "2025-04-15", expirationDate: "2026-04-15", status: "valid", documentUploaded: false },
],
},
];
export const UPCOMING_APPOINTMENTS: Appointment[] = [
{
id: "a1", petId: "p1", petName: "Biscuit", groomerId: "g1", groomerName: "Jamie",
services: ["Full Groom"], addOns: ["De-shedding Treatment"],
date: "2026-03-21", time: "10:00 AM", duration: 120, price: 145,
status: "confirmed", notes: "Spring shed is heavy — extra undercoat work needed",
},
{
id: "a2", petId: "p2", petName: "Mochi", groomerId: "g3", groomerName: "Morgan",
services: ["Full Groom"], addOns: ["Teeth Brushing"],
date: "2026-03-25", time: "2:00 PM", duration: 100, price: 90,
status: "confirmed", notes: "First visit with Morgan — patient with anxious pets",
},
{
id: "a3", petId: "p1", petName: "Biscuit", groomerId: "g1", groomerName: "Jamie",
services: ["Bath & Brush"], addOns: [],
date: "2026-04-18", time: "11:00 AM", duration: 45, price: 55,
status: "pending", notes: "",
},
];
export const PAST_APPOINTMENTS: Appointment[] = [
{
id: "pa1", petId: "p1", petName: "Biscuit", groomerId: "g1", groomerName: "Jamie",
services: ["Full Groom"], addOns: ["De-shedding Treatment", "Blueberry Facial"],
date: "2026-02-15", time: "10:00 AM", duration: 130, price: 160,
status: "completed", notes: "", reportCardId: "rc1",
},
{
id: "pa2", petId: "p2", petName: "Mochi", groomerId: "g2", groomerName: "Alex",
services: ["Full Groom"], addOns: ["Teeth Brushing"],
date: "2026-02-20", time: "1:00 PM", duration: 100, price: 88,
status: "completed", notes: "", reportCardId: "rc2",
},
{
id: "pa3", petId: "p1", petName: "Biscuit", groomerId: "g1", groomerName: "Jamie",
services: ["Bath & Brush"], addOns: [],
date: "2026-01-18", time: "9:00 AM", duration: 45, price: 55,
status: "completed", notes: "",
},
{
id: "pa4", petId: "p2", petName: "Mochi", groomerId: "g2", groomerName: "Alex",
services: ["Puppy's First Groom"], addOns: [],
date: "2026-01-10", time: "3:00 PM", duration: 60, price: 62,
status: "completed", notes: "",
},
{
id: "pa5", petId: "p1", petName: "Biscuit", groomerId: "g1", groomerName: "Jamie",
services: ["Full Groom"], addOns: ["Nail Grinding"],
date: "2025-12-20", time: "10:00 AM", duration: 105, price: 132,
status: "completed", notes: "Holiday groom",
},
{
id: "pa6", petId: "p1", petName: "Biscuit", groomerId: "g2", groomerName: "Alex",
services: ["Full Groom"], addOns: [],
date: "2025-11-15", time: "11:00 AM", duration: 90, price: 110,
status: "completed", notes: "",
},
{
id: "pa7", petId: "p2", petName: "Mochi", groomerId: "g3", groomerName: "Morgan",
services: ["Bath & Brush"], addOns: [],
date: "2025-11-08", time: "2:00 PM", duration: 45, price: 48,
status: "completed", notes: "",
},
{
id: "pa8", petId: "p1", petName: "Biscuit", groomerId: "g1", groomerName: "Jamie",
services: ["Bath & Brush"], addOns: ["De-shedding Treatment"],
date: "2025-10-12", time: "10:00 AM", duration: 75, price: 85,
status: "completed", notes: "",
},
];
export const REPORT_CARDS: ReportCard[] = [
{
id: "rc1",
appointmentId: "pa1",
petName: "Biscuit",
groomerName: "Jamie",
date: "2026-02-15",
servicesPerformed: ["Full Groom", "De-shedding Treatment", "Blueberry Facial"],
behaviorMood: "calm",
conditionObservations: [
"Mild ear wax buildup — recommend ear cleaning solution at home",
"Slight matting behind ears — addressed during groom",
"Coat and skin in great overall condition",
],
groomerNote: "Biscuit was an absolute angel today as always! His coat came out beautifully after the de-shedding treatment. The blueberry facial really brightened up his face. He got extra treats for being the best boy. See you next month!",
nextRecommendedDate: "2026-03-21",
beforeDescription: "Thick winter coat with moderate shedding, minor matting behind ears, slightly dull facial fur",
afterDescription: "Fluffy teddy bear cut, smooth and tangle-free, bright clean face, nails trimmed short",
},
{
id: "rc2",
appointmentId: "pa2",
petName: "Mochi",
groomerName: "Alex",
date: "2026-02-20",
servicesPerformed: ["Full Groom", "Teeth Brushing"],
behaviorMood: "anxious",
conditionObservations: [
"Tear staining around eyes — may benefit from daily wipe routine",
"Slight tartar buildup on back molars — consider dental checkup",
"Paw pads healthy, no cracking",
],
groomerNote: "Mochi was a little nervous today but did so well! We took it slow with the dryer on low setting and gave plenty of breaks. She started to relax halfway through. The teeth brushing went smoothly. She's getting more comfortable each visit — such a brave girl!",
nextRecommendedDate: "2026-03-25",
beforeDescription: "Overgrown puppy cut, tear staining visible, coat slightly tangled around legs",
afterDescription: "Even puppy cut all over, tear stains cleaned, smooth silky coat, fresh and fluffy",
},
];
export const INVOICES: Invoice[] = [
{ id: "inv1", date: "2026-02-20", amount: 88, status: "outstanding", items: ["Mochi — Full Groom", "Teeth Brushing"] },
{ id: "inv2", date: "2026-02-15", amount: 160, status: "paid", items: ["Biscuit — Full Groom", "De-shedding Treatment", "Blueberry Facial"] },
{ id: "inv3", date: "2026-01-18", amount: 55, status: "paid", items: ["Biscuit — Bath & Brush"] },
{ id: "inv4", date: "2026-01-10", amount: 62, status: "paid", items: ["Mochi — Puppy's First Groom"] },
{ id: "inv5", date: "2025-12-20", amount: 132, status: "paid", items: ["Biscuit — Full Groom", "Nail Grinding"] },
];
export const MESSAGES: Message[] = [
{ id: "m1", sender: "customer", senderName: "Sarah", text: "Hi! Can Biscuit get the same cut as last time on the 21st?", timestamp: "2026-03-16T10:30:00Z", read: true },
{ id: "m2", sender: "business", senderName: "Paws & Reflect", text: "Absolutely, Sarah! Jamie has Biscuit's teddy bear cut notes on file. We'll make sure he looks just as handsome. See you Saturday!", timestamp: "2026-03-16T11:15:00Z", read: true },
{ id: "m3", sender: "customer", senderName: "Sarah", text: "Perfect, thanks! Also, Mochi's Bordetella is expiring soon — should I get that updated before her appointment on the 25th?", timestamp: "2026-03-17T09:00:00Z", read: true },
{ id: "m4", sender: "business", senderName: "Paws & Reflect", text: "Great question! Yes, we require current Bordetella for all grooms. As long as it's updated before the 25th, you're all set. You can upload the new certificate through your pet profile once you have it.", timestamp: "2026-03-17T09:45:00Z", read: false },
];
export const LOYALTY: LoyaltyInfo = {
points: 340,
nextRewardAt: 500,
rewardName: "Free Bath & Brush",
};
export const CUSTOMER = {
name: "Sarah Mitchell",
email: "sarah.mitchell@email.com",
phone: "(555) 234-5678",
address: "142 Maple Lane, Portland, OR 97201",
};
export const BUSINESS_NAME = "Paws & Reflect Grooming";
export const SAVED_PAYMENT_METHODS = [
{ id: "pm1", type: "visa", last4: "4242", expiry: "09/27", isDefault: true },
{ id: "pm2", type: "mastercard", last4: "8888", expiry: "03/28", isDefault: false },
];
export const SIGNED_AGREEMENTS = [
{ id: "wa1", name: "Liability Waiver", dateSigned: "2025-09-15" },
{ id: "wa2", name: "Service Agreement", dateSigned: "2025-09-15" },
{ id: "wa3", name: "Photo Release", dateSigned: "2025-09-15" },
];
export const PREPAID_PACKAGES = [
{ id: "pkg1", name: "5-Groom Bundle", totalCredits: 5, usedCredits: 3, expiresAt: "2026-09-15" },
];
@@ -0,0 +1,177 @@
import { useState } from "react";
import { User, Lock, PawPrint, FileCheck, Plus, Archive } from "lucide-react";
import { CUSTOMER, PETS, SIGNED_AGREEMENTS } from "../mockData.js";
interface Props {
readOnly: boolean;
}
export function AccountSettings({ readOnly }: Props) {
const [tab, setTab] = useState<"personal" | "password" | "pets" | "agreements">("personal");
return (
<div className="space-y-6">
<div className="flex gap-1 flex-wrap">
{([
{ id: "personal" as const, label: "Personal Info", icon: User },
{ id: "password" as const, label: "Password", icon: Lock },
{ id: "pets" as const, label: "Manage Pets", icon: PawPrint },
{ id: "agreements" as const, label: "Agreements", icon: FileCheck },
]).map(({ id, label, icon: Icon }) => (
<button
key={id}
onClick={() => setTab(id)}
className={`flex items-center gap-1.5 px-4 py-2 rounded-lg text-sm font-medium ${
tab === id ? "bg-[#f0ebe4] text-[#6b5a42]" : "text-stone-500 hover:bg-stone-50"
}`}
>
<Icon size={14} />
{label}
</button>
))}
</div>
{tab === "personal" && <PersonalInfo readOnly={readOnly} />}
{tab === "password" && <PasswordChange readOnly={readOnly} />}
{tab === "pets" && <ManagePets readOnly={readOnly} />}
{tab === "agreements" && <Agreements />}
</div>
);
}
function PersonalInfo({ readOnly }: { readOnly: boolean }) {
const [form, setForm] = useState({
name: CUSTOMER.name,
email: CUSTOMER.email,
phone: CUSTOMER.phone,
address: CUSTOMER.address,
});
return (
<div className="bg-white rounded-2xl border border-stone-200 p-5 shadow-sm">
<h3 className="font-medium text-stone-800 mb-4">Personal Information</h3>
<div className="space-y-4 max-w-md">
{([
{ key: "name" as const, label: "Full Name", type: "text" },
{ key: "email" as const, label: "Email", type: "email" },
{ key: "phone" as const, label: "Phone", type: "tel" },
{ key: "address" as const, label: "Address", type: "text" },
]).map(({ key, label, type }) => (
<div key={key}>
<label className="block text-sm font-medium text-stone-700 mb-1">{label}</label>
<input
type={type}
value={form[key]}
onChange={e => !readOnly && setForm({ ...form, [key]: e.target.value })}
disabled={readOnly}
className="w-full border border-stone-300 rounded-lg px-3 py-2 text-sm disabled:bg-stone-50 disabled:text-stone-500"
/>
</div>
))}
{!readOnly && (
<button className="px-4 py-2 bg-[#8b7355] text-white rounded-lg text-sm font-medium hover:bg-[#7a6549]">
Save Changes
</button>
)}
</div>
</div>
);
}
function PasswordChange({ readOnly }: { readOnly: boolean }) {
if (readOnly) {
return (
<div className="bg-white rounded-2xl border border-stone-200 p-5 shadow-sm">
<p className="text-sm text-stone-500">Password changes are not available during staff impersonation.</p>
</div>
);
}
return (
<div className="bg-white rounded-2xl border border-stone-200 p-5 shadow-sm">
<h3 className="font-medium text-stone-800 mb-4">Change Password</h3>
<div className="space-y-4 max-w-md">
<div>
<label className="block text-sm font-medium text-stone-700 mb-1">Current Password</label>
<input type="password" className="w-full border border-stone-300 rounded-lg px-3 py-2 text-sm" />
</div>
<div>
<label className="block text-sm font-medium text-stone-700 mb-1">New Password</label>
<input type="password" className="w-full border border-stone-300 rounded-lg px-3 py-2 text-sm" />
</div>
<div>
<label className="block text-sm font-medium text-stone-700 mb-1">Confirm New Password</label>
<input type="password" className="w-full border border-stone-300 rounded-lg px-3 py-2 text-sm" />
</div>
<button className="px-4 py-2 bg-[#8b7355] text-white rounded-lg text-sm font-medium hover:bg-[#7a6549]">
Update Password
</button>
</div>
</div>
);
}
function ManagePets({ readOnly }: { readOnly: boolean }) {
return (
<div className="space-y-4">
{PETS.map(pet => (
<div key={pet.id} className="bg-white rounded-2xl border border-stone-200 p-4 shadow-sm flex items-center gap-4">
<div className="w-14 h-14 rounded-xl bg-[#f0ebe4] flex items-center justify-center text-3xl">
{pet.photo}
</div>
<div className="flex-1">
<p className="font-medium text-stone-800">{pet.name}</p>
<p className="text-sm text-stone-500">{pet.breed} · {pet.weight} lbs</p>
</div>
{!readOnly && (
<div className="flex gap-2">
<button className="px-3 py-1.5 border border-stone-200 rounded-lg text-xs text-stone-600 hover:bg-stone-50">
Edit
</button>
<button className="p-1.5 border border-stone-200 rounded-lg text-stone-400 hover:text-amber-600 hover:border-amber-200">
<Archive size={14} />
</button>
</div>
)}
</div>
))}
{!readOnly && (
<button className="w-full flex items-center justify-center gap-2 py-3 border-2 border-dashed border-stone-300 rounded-2xl text-sm text-stone-500 hover:border-[#8b7355] hover:text-[#6b5a42] transition-colors">
<Plus size={16} />
Add New Pet
</button>
)}
</div>
);
}
function Agreements() {
return (
<div className="bg-white rounded-2xl border border-stone-200 shadow-sm overflow-hidden">
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="text-left text-xs text-stone-400 border-b border-stone-100">
<th className="px-5 py-3 font-medium">Document</th>
<th className="px-5 py-3 font-medium">Date Signed</th>
<th className="px-5 py-3 font-medium"></th>
</tr>
</thead>
<tbody>
{SIGNED_AGREEMENTS.map(agr => (
<tr key={agr.id} className="border-b border-stone-50">
<td className="px-5 py-3 font-medium text-stone-800">{agr.name}</td>
<td className="px-5 py-3 text-stone-600">
{new Date(agr.dateSigned).toLocaleDateString("en-US", { month: "short", day: "numeric", year: "numeric" })}
</td>
<td className="px-5 py-3">
<button className="text-sm text-[#6b5a42] font-medium hover:underline">View</button>
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
);
}
@@ -0,0 +1,442 @@
import { useState } from "react";
import { Calendar, Clock, Plus, ChevronRight, ChevronDown, Search, Repeat } from "lucide-react";
import { UPCOMING_APPOINTMENTS, PAST_APPOINTMENTS, PETS, SERVICES, GROOMERS } from "../mockData.js";
import type { Appointment, Pet, Service, Groomer } from "../mockData.js";
interface Props {
readOnly: boolean;
}
function formatDate(dateStr: string): string {
return new Date(dateStr).toLocaleDateString("en-US", { weekday: "short", month: "short", day: "numeric", year: "numeric" });
}
const STATUS_COLORS: Record<string, string> = {
confirmed: "bg-green-100 text-green-700",
pending: "bg-amber-100 text-amber-700",
waitlisted: "bg-blue-100 text-blue-700",
completed: "bg-stone-100 text-stone-600",
cancelled: "bg-red-100 text-red-600",
};
export function AppointmentsSection({ readOnly }: Props) {
const [showBooking, setShowBooking] = useState(false);
const [expandedId, setExpandedId] = useState<string | null>(null);
const [tab, setTab] = useState<"upcoming" | "past">("upcoming");
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<div className="flex gap-2">
<button
onClick={() => setTab("upcoming")}
className={`px-4 py-2 rounded-lg text-sm font-medium ${tab === "upcoming" ? "bg-[#f0ebe4] text-[#6b5a42]" : "text-stone-500 hover:bg-stone-50"}`}
>
Upcoming ({UPCOMING_APPOINTMENTS.length})
</button>
<button
onClick={() => setTab("past")}
className={`px-4 py-2 rounded-lg text-sm font-medium ${tab === "past" ? "bg-[#f0ebe4] text-[#6b5a42]" : "text-stone-500 hover:bg-stone-50"}`}
>
Past ({PAST_APPOINTMENTS.length})
</button>
</div>
{!readOnly && (
<button
onClick={() => setShowBooking(true)}
className="flex items-center gap-1.5 px-4 py-2 bg-[#8b7355] text-white rounded-lg text-sm font-medium hover:bg-[#7a6549]"
>
<Plus size={16} />
Book New
</button>
)}
</div>
{tab === "upcoming" && (
<div className="space-y-3">
{UPCOMING_APPOINTMENTS.map(appt => (
<AppointmentCard
key={appt.id}
appointment={appt}
expanded={expandedId === appt.id}
onToggle={() => setExpandedId(expandedId === appt.id ? null : appt.id)}
readOnly={readOnly}
/>
))}
{UPCOMING_APPOINTMENTS.length === 0 && (
<p className="text-center text-stone-400 py-8">No upcoming appointments</p>
)}
</div>
)}
{tab === "past" && (
<div className="space-y-3">
{PAST_APPOINTMENTS.map(appt => (
<AppointmentCard
key={appt.id}
appointment={appt}
expanded={expandedId === appt.id}
onToggle={() => setExpandedId(expandedId === appt.id ? null : appt.id)}
readOnly={readOnly}
/>
))}
</div>
)}
{showBooking && (
<BookingFlow
onClose={() => setShowBooking(false)}
readOnly={readOnly}
/>
)}
</div>
);
}
function AppointmentCard({
appointment: appt, expanded, onToggle, readOnly,
}: {
appointment: Appointment; expanded: boolean; onToggle: () => void; readOnly: boolean;
}) {
return (
<div className="bg-white rounded-xl border border-stone-200 shadow-sm overflow-hidden">
<button onClick={onToggle} className="w-full flex items-center gap-4 p-4 text-left hover:bg-stone-50">
<div className="w-10 h-10 rounded-lg bg-[#f0ebe4] flex items-center justify-center text-lg shrink-0">
{PETS.find(p => p.id === appt.petId)?.photo || "🐾"}
</div>
<div className="flex-1 min-w-0">
<p className="font-medium text-stone-800 text-sm">{appt.petName} {appt.services.join(", ")}</p>
<div className="flex items-center gap-3 text-xs text-stone-500 mt-0.5">
<span className="flex items-center gap-1"><Calendar size={12} />{formatDate(appt.date)}</span>
<span className="flex items-center gap-1"><Clock size={12} />{appt.time}</span>
<span>with {appt.groomerName}</span>
</div>
</div>
<span className={`px-2 py-0.5 rounded-full text-xs font-medium ${STATUS_COLORS[appt.status] || ""}`}>
{appt.status}
</span>
{expanded ? <ChevronDown size={16} className="text-stone-400" /> : <ChevronRight size={16} className="text-stone-400" />}
</button>
{expanded && (
<div className="px-4 pb-4 pt-0 border-t border-stone-100">
<div className="grid grid-cols-2 sm:grid-cols-4 gap-3 py-3 text-sm">
<div>
<p className="text-xs text-stone-400">Duration</p>
<p className="text-stone-700">{appt.duration} min</p>
</div>
<div>
<p className="text-xs text-stone-400">Estimated Price</p>
<p className="text-stone-700">${appt.price}</p>
</div>
{appt.addOns.length > 0 && (
<div className="col-span-2">
<p className="text-xs text-stone-400">Add-ons</p>
<p className="text-stone-700">{appt.addOns.join(", ")}</p>
</div>
)}
</div>
{appt.notes && (
<p className="text-sm text-stone-600 bg-stone-50 rounded-lg px-3 py-2 mb-3">{appt.notes}</p>
)}
{appt.status !== "completed" && appt.status !== "cancelled" && !readOnly && (
<div className="flex gap-2">
<button className="text-xs px-3 py-1.5 border border-stone-200 rounded-lg text-stone-600 hover:bg-stone-50">
Reschedule
</button>
<button className="text-xs px-3 py-1.5 border border-red-200 rounded-lg text-red-600 hover:bg-red-50">
Cancel
</button>
</div>
)}
{appt.reportCardId && (
<div className="mt-2">
<span className="text-xs text-[#6b5a42] font-medium cursor-pointer hover:underline">
View Report Card
</span>
</div>
)}
</div>
)}
</div>
);
}
function BookingFlow({ onClose, readOnly }: { onClose: () => void; readOnly: boolean }) {
const [step, setStep] = useState(1);
const [selectedPet, setSelectedPet] = useState<Pet | null>(null);
const [selectedServices, setSelectedServices] = useState<Service[]>([]);
const [selectedAddOns, setSelectedAddOns] = useState<Service[]>([]);
const [selectedGroomer, setSelectedGroomer] = useState<Groomer | null>(null);
const [selectedDate, setSelectedDate] = useState("");
const [selectedTime, setSelectedTime] = useState("");
const [notes, setNotes] = useState("");
const [recurring, setRecurring] = useState("");
const [confirmed, setConfirmed] = useState(false);
const availableTimes = ["9:00 AM", "10:00 AM", "11:00 AM", "1:00 PM", "2:00 PM", "3:00 PM", "4:00 PM"];
const mainServices = SERVICES.filter(s => !s.isAddOn);
const addOnServices = SERVICES.filter(s => s.isAddOn);
if (readOnly) return null;
return (
<div className="fixed inset-0 bg-black/50 z-50 flex items-center justify-center p-4">
<div className="bg-white rounded-2xl shadow-xl max-w-lg w-full max-h-[90vh] overflow-y-auto">
<div className="flex items-center justify-between p-5 border-b border-stone-200">
<h2 className="font-semibold text-stone-800">Book Appointment</h2>
<button onClick={onClose} className="text-stone-400 hover:text-stone-600"></button>
</div>
{/* Step Indicator */}
<div className="flex items-center gap-1 px-5 pt-4">
{[1, 2, 3, 4, 5].map(s => (
<div key={s} className={`flex-1 h-1.5 rounded-full ${s <= step ? "bg-[#8b7355]" : "bg-stone-200"}`} />
))}
</div>
<div className="p-5">
{confirmed ? (
<div className="text-center py-8">
<div className="text-4xl mb-3">🎉</div>
<h3 className="text-lg font-semibold text-stone-800 mb-1">Appointment Booked!</h3>
<p className="text-sm text-stone-500 mb-4">
{selectedPet?.name} with {selectedGroomer?.name || "First Available"} on {formatDate(selectedDate)} at {selectedTime}
</p>
<button onClick={onClose} className="px-4 py-2 bg-[#8b7355] text-white rounded-lg text-sm font-medium">
Done
</button>
</div>
) : (
<>
{/* Step 1: Select Pet */}
{step === 1 && (
<div>
<h3 className="font-medium text-stone-800 mb-3">Select Pet</h3>
<div className="space-y-2">
{PETS.map(pet => (
<button
key={pet.id}
onClick={() => { setSelectedPet(pet); setStep(2); }}
className={`w-full flex items-center gap-3 p-3 rounded-xl border text-left transition-colors ${
selectedPet?.id === pet.id ? "border-[#8b7355] bg-[#faf5ef]" : "border-stone-200 hover:border-stone-300"
}`}
>
<span className="text-2xl">{pet.photo}</span>
<div>
<p className="font-medium text-stone-800">{pet.name}</p>
<p className="text-xs text-stone-500">{pet.breed} · {pet.weight} lbs</p>
</div>
</button>
))}
</div>
</div>
)}
{/* Step 2: Select Services */}
{step === 2 && (
<div>
<h3 className="font-medium text-stone-800 mb-3">Select Services</h3>
<div className="space-y-2 mb-4">
{mainServices.map(svc => (
<button
key={svc.id}
onClick={() => {
setSelectedServices(prev =>
prev.find(s => s.id === svc.id) ? prev.filter(s => s.id !== svc.id) : [...prev, svc]
);
}}
className={`w-full flex items-center justify-between p-3 rounded-xl border text-left ${
selectedServices.find(s => s.id === svc.id) ? "border-[#8b7355] bg-[#faf5ef]" : "border-stone-200 hover:border-stone-300"
}`}
>
<div>
<p className="font-medium text-stone-800 text-sm">{svc.name}</p>
<p className="text-xs text-stone-500">{svc.description}</p>
</div>
<div className="text-right shrink-0 ml-3">
<p className="text-sm font-medium text-stone-700">{svc.priceRange}</p>
<p className="text-xs text-stone-400">{svc.duration} min</p>
</div>
</button>
))}
</div>
{selectedServices.length > 0 && (
<>
<h4 className="font-medium text-stone-700 text-sm mb-2">Add-ons (optional)</h4>
<div className="space-y-2 mb-4">
{addOnServices.map(svc => (
<button
key={svc.id}
onClick={() => {
setSelectedAddOns(prev =>
prev.find(s => s.id === svc.id) ? prev.filter(s => s.id !== svc.id) : [...prev, svc]
);
}}
className={`w-full flex items-center justify-between p-2.5 rounded-lg border text-left text-sm ${
selectedAddOns.find(s => s.id === svc.id) ? "border-[#8b7355] bg-[#faf5ef]" : "border-stone-200 hover:border-stone-300"
}`}
>
<div>
<p className="font-medium text-stone-800">{svc.name}</p>
<p className="text-xs text-stone-500">{svc.description}</p>
</div>
<span className="text-stone-600 shrink-0 ml-3">{svc.priceRange}</span>
</button>
))}
</div>
</>
)}
<div className="flex gap-2 mt-4">
<button onClick={() => setStep(1)} className="flex-1 px-4 py-2 border border-stone-200 rounded-lg text-sm">Back</button>
<button
onClick={() => setStep(3)}
disabled={selectedServices.length === 0}
className="flex-1 px-4 py-2 bg-[#8b7355] text-white rounded-lg text-sm font-medium disabled:opacity-50"
>
Next
</button>
</div>
</div>
)}
{/* Step 3: Select Groomer */}
{step === 3 && (
<div>
<h3 className="font-medium text-stone-800 mb-3">Select Groomer</h3>
<div className="space-y-2">
<button
onClick={() => { setSelectedGroomer(null); setStep(4); }}
className={`w-full flex items-center gap-3 p-3 rounded-xl border text-left ${
selectedGroomer === null ? "border-[#8b7355] bg-[#faf5ef]" : "border-stone-200 hover:border-stone-300"
}`}
>
<div className="w-10 h-10 rounded-full bg-stone-100 flex items-center justify-center">
<Search size={16} className="text-stone-400" />
</div>
<div>
<p className="font-medium text-stone-800">First Available</p>
<p className="text-xs text-stone-500">We'll match you with the best available groomer</p>
</div>
</button>
{GROOMERS.map(g => (
<button
key={g.id}
onClick={() => { setSelectedGroomer(g); setStep(4); }}
className={`w-full flex items-center gap-3 p-3 rounded-xl border text-left ${
selectedGroomer?.id === g.id ? "border-[#8b7355] bg-[#faf5ef]" : "border-stone-200 hover:border-stone-300"
}`}
>
<div className="w-10 h-10 rounded-full bg-[#f0ebe4] flex items-center justify-center text-xl">
{g.avatar}
</div>
<div>
<p className="font-medium text-stone-800">{g.name}</p>
<p className="text-xs text-stone-500">{g.specialties.join(" · ")}</p>
</div>
</button>
))}
</div>
<button onClick={() => setStep(2)} className="w-full mt-4 px-4 py-2 border border-stone-200 rounded-lg text-sm">Back</button>
</div>
)}
{/* Step 4: Date & Time */}
{step === 4 && (
<div>
<h3 className="font-medium text-stone-800 mb-3">Pick Date & Time</h3>
<input
type="date"
value={selectedDate}
onChange={e => setSelectedDate(e.target.value)}
min={new Date().toISOString().split("T")[0]}
className="w-full border border-stone-300 rounded-lg px-3 py-2 text-sm mb-3"
/>
{selectedDate && (
<div className="grid grid-cols-3 gap-2 mb-4">
{availableTimes.map(time => (
<button
key={time}
onClick={() => setSelectedTime(time)}
className={`px-3 py-2 rounded-lg text-sm border ${
selectedTime === time ? "border-[#8b7355] bg-[#faf5ef] font-medium" : "border-stone-200 hover:border-stone-300"
}`}
>
{time}
</button>
))}
</div>
)}
<div className="mb-4">
<label className="flex items-center gap-2 text-sm text-stone-700 mb-1">
<Repeat size={14} />
Recurring (optional)
</label>
<select
value={recurring}
onChange={e => setRecurring(e.target.value)}
className="w-full border border-stone-200 rounded-lg px-3 py-2 text-sm"
>
<option value="">One-time</option>
<option value="4">Every 4 weeks</option>
<option value="6">Every 6 weeks</option>
<option value="8">Every 8 weeks</option>
</select>
</div>
<div className="flex gap-2">
<button onClick={() => setStep(3)} className="flex-1 px-4 py-2 border border-stone-200 rounded-lg text-sm">Back</button>
<button
onClick={() => setStep(5)}
disabled={!selectedDate || !selectedTime}
className="flex-1 px-4 py-2 bg-[#8b7355] text-white rounded-lg text-sm font-medium disabled:opacity-50"
>
Next
</button>
</div>
</div>
)}
{/* Step 5: Review & Confirm */}
{step === 5 && (
<div>
<h3 className="font-medium text-stone-800 mb-3">Review & Confirm</h3>
<div className="bg-stone-50 rounded-xl p-4 space-y-2 text-sm mb-4">
<div className="flex justify-between"><span className="text-stone-500">Pet</span><span className="font-medium">{selectedPet?.name}</span></div>
<div className="flex justify-between"><span className="text-stone-500">Services</span><span className="font-medium">{selectedServices.map(s => s.name).join(", ")}</span></div>
{selectedAddOns.length > 0 && (
<div className="flex justify-between"><span className="text-stone-500">Add-ons</span><span className="font-medium">{selectedAddOns.map(s => s.name).join(", ")}</span></div>
)}
<div className="flex justify-between"><span className="text-stone-500">Groomer</span><span className="font-medium">{selectedGroomer?.name || "First Available"}</span></div>
<div className="flex justify-between"><span className="text-stone-500">Date & Time</span><span className="font-medium">{formatDate(selectedDate)} at {selectedTime}</span></div>
{recurring && <div className="flex justify-between"><span className="text-stone-500">Recurring</span><span className="font-medium">Every {recurring} weeks</span></div>}
</div>
<div className="mb-4">
<label className="block text-sm font-medium text-stone-700 mb-1">Notes for groomer (optional)</label>
<textarea
value={notes}
onChange={e => setNotes(e.target.value)}
className="w-full border border-stone-300 rounded-lg px-3 py-2 text-sm"
rows={2}
placeholder="Any special instructions..."
/>
</div>
<div className="bg-amber-50 rounded-lg px-3 py-2 text-xs text-amber-700 mb-4">
Free cancellation up to 24 hours before. Late cancellation fee: $25.
</div>
<div className="flex gap-2">
<button onClick={() => setStep(4)} className="flex-1 px-4 py-2 border border-stone-200 rounded-lg text-sm">Back</button>
<button
onClick={() => setConfirmed(true)}
className="flex-1 px-4 py-2 bg-[#8b7355] text-white rounded-lg text-sm font-medium hover:bg-[#7a6549]"
>
Confirm Booking
</button>
</div>
</div>
)}
</>
)}
</div>
</div>
</div>
);
}
@@ -0,0 +1,252 @@
import { useState } from "react";
import { CreditCard, Download, DollarSign, Package, Zap, Plus, Trash2 } from "lucide-react";
import { INVOICES, SAVED_PAYMENT_METHODS, PREPAID_PACKAGES } from "../mockData.js";
interface Props {
readOnly: boolean;
}
const STATUS_STYLES: Record<string, string> = {
paid: "bg-green-100 text-green-700",
outstanding: "bg-amber-100 text-amber-700",
overdue: "bg-red-100 text-red-700",
};
export function BillingPayments({ readOnly }: Props) {
const [tab, setTab] = useState<"invoices" | "payment" | "packages">("invoices");
const [autopay, setAutopay] = useState(false);
const [showTipModal, setShowTipModal] = useState(false);
const outstanding = INVOICES.filter(i => i.status === "outstanding");
const totalOutstanding = outstanding.reduce((sum, i) => sum + i.amount, 0);
return (
<div className="space-y-6">
{/* Outstanding Balance Banner */}
{totalOutstanding > 0 && (
<div className="bg-white rounded-2xl border border-stone-200 p-5 shadow-sm flex flex-col sm:flex-row items-start sm:items-center justify-between gap-4">
<div>
<p className="text-sm text-stone-500">Outstanding Balance</p>
<p className="text-3xl font-bold text-stone-800">${totalOutstanding.toFixed(2)}</p>
<p className="text-xs text-stone-400 mt-0.5">{outstanding.length} unpaid invoice{outstanding.length > 1 ? "s" : ""}</p>
</div>
{!readOnly && (
<div className="flex gap-2">
<button
onClick={() => setShowTipModal(true)}
className="px-4 py-2 border border-stone-200 rounded-lg text-sm font-medium text-stone-600 hover:bg-stone-50"
>
Add Tip
</button>
<button className="px-6 py-2 bg-[#8b7355] text-white rounded-lg text-sm font-medium hover:bg-[#7a6549]">
Pay Now
</button>
</div>
)}
</div>
)}
{/* Tabs */}
<div className="flex gap-2">
{([
{ id: "invoices" as const, label: "Invoices", icon: DollarSign },
{ id: "payment" as const, label: "Payment Methods", icon: CreditCard },
{ id: "packages" as const, label: "Packages", icon: Package },
]).map(({ id, label, icon: Icon }) => (
<button
key={id}
onClick={() => setTab(id)}
className={`flex items-center gap-1.5 px-4 py-2 rounded-lg text-sm font-medium ${
tab === id ? "bg-[#f0ebe4] text-[#6b5a42]" : "text-stone-500 hover:bg-stone-50"
}`}
>
<Icon size={14} />
{label}
</button>
))}
</div>
{/* Invoices */}
{tab === "invoices" && (
<div className="bg-white rounded-2xl border border-stone-200 shadow-sm overflow-hidden">
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="text-left text-xs text-stone-400 border-b border-stone-100">
<th className="px-5 py-3 font-medium">Date</th>
<th className="px-5 py-3 font-medium">Items</th>
<th className="px-5 py-3 font-medium">Amount</th>
<th className="px-5 py-3 font-medium">Status</th>
<th className="px-5 py-3 font-medium"></th>
</tr>
</thead>
<tbody>
{INVOICES.map(inv => (
<tr key={inv.id} className="border-b border-stone-50 hover:bg-stone-50/50">
<td className="px-5 py-3 text-stone-700">
{new Date(inv.date).toLocaleDateString("en-US", { month: "short", day: "numeric", year: "numeric" })}
</td>
<td className="px-5 py-3 text-stone-600">{inv.items.join(", ")}</td>
<td className="px-5 py-3 font-medium text-stone-800">${inv.amount.toFixed(2)}</td>
<td className="px-5 py-3">
<span className={`px-2 py-0.5 rounded-full text-xs font-medium ${STATUS_STYLES[inv.status]}`}>
{inv.status}
</span>
</td>
<td className="px-5 py-3">
<button className="text-stone-400 hover:text-stone-600">
<Download size={14} />
</button>
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
)}
{/* Payment Methods */}
{tab === "payment" && (
<div className="space-y-4">
<div className="bg-white rounded-2xl border border-stone-200 p-5 shadow-sm space-y-3">
{SAVED_PAYMENT_METHODS.map(pm => (
<div key={pm.id} className="flex items-center justify-between py-2 border-b border-stone-50 last:border-0">
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-lg bg-stone-100 flex items-center justify-center">
<CreditCard size={18} className="text-stone-500" />
</div>
<div>
<p className="text-sm font-medium text-stone-800 capitalize">{pm.type} {pm.last4}</p>
<p className="text-xs text-stone-400">Expires {pm.expiry}</p>
</div>
</div>
<div className="flex items-center gap-2">
{pm.isDefault && (
<span className="text-xs bg-green-100 text-green-700 px-2 py-0.5 rounded-full">Default</span>
)}
{!readOnly && (
<button className="p-1 text-stone-400 hover:text-red-500">
<Trash2 size={14} />
</button>
)}
</div>
</div>
))}
{!readOnly && (
<button className="flex items-center gap-2 text-sm text-[#6b5a42] font-medium hover:underline mt-2">
<Plus size={14} />
Add Payment Method
</button>
)}
</div>
{/* Autopay */}
<div className="bg-white rounded-2xl border border-stone-200 p-5 shadow-sm">
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-lg bg-[#f0ebe4] flex items-center justify-center">
<Zap size={18} className="text-[#8b7355]" />
</div>
<div>
<p className="text-sm font-medium text-stone-800">Autopay</p>
<p className="text-xs text-stone-500">Automatically charge after each appointment</p>
</div>
</div>
{!readOnly ? (
<button
onClick={() => setAutopay(!autopay)}
className={`w-12 h-6 rounded-full transition-colors ${autopay ? "bg-[#8b7355]" : "bg-stone-300"}`}
>
<div className={`w-5 h-5 bg-white rounded-full shadow transition-transform ${autopay ? "translate-x-6" : "translate-x-0.5"}`} />
</button>
) : (
<span className="text-xs text-stone-400">{autopay ? "Enabled" : "Disabled"}</span>
)}
</div>
</div>
</div>
)}
{/* Packages */}
{tab === "packages" && (
<div className="space-y-4">
{PREPAID_PACKAGES.map(pkg => (
<div key={pkg.id} className="bg-white rounded-2xl border border-stone-200 p-5 shadow-sm">
<div className="flex items-center gap-3 mb-3">
<Package size={20} className="text-[#8b7355]" />
<h3 className="font-medium text-stone-800">{pkg.name}</h3>
</div>
<div className="flex items-center gap-4 mb-3">
<div>
<p className="text-2xl font-bold text-stone-800">{pkg.totalCredits - pkg.usedCredits}</p>
<p className="text-xs text-stone-500">remaining of {pkg.totalCredits}</p>
</div>
<div className="flex-1 bg-stone-100 rounded-full h-3 overflow-hidden">
<div
className="bg-[#8b7355] h-full rounded-full"
style={{ width: `${((pkg.totalCredits - pkg.usedCredits) / pkg.totalCredits) * 100}%` }}
/>
</div>
</div>
<p className="text-xs text-stone-400">Expires {new Date(pkg.expiresAt).toLocaleDateString()}</p>
</div>
))}
</div>
)}
{/* Tip Modal */}
{showTipModal && !readOnly && (
<TipModal onClose={() => setShowTipModal(false)} />
)}
</div>
);
}
function TipModal({ onClose }: { onClose: () => void }) {
const [tipPercent, setTipPercent] = useState<number | null>(20);
const [customTip, setCustomTip] = useState("");
const presets = [15, 20, 25];
return (
<div className="fixed inset-0 bg-black/50 z-50 flex items-center justify-center p-4">
<div className="bg-white rounded-2xl shadow-xl max-w-sm w-full p-6">
<h2 className="font-semibold text-stone-800 mb-4">Add a Tip</h2>
<div className="flex gap-2 mb-4">
{presets.map(pct => (
<button
key={pct}
onClick={() => { setTipPercent(pct); setCustomTip(""); }}
className={`flex-1 py-2 rounded-lg text-sm font-medium border ${
tipPercent === pct ? "border-[#8b7355] bg-[#faf5ef] text-[#6b5a42]" : "border-stone-200 text-stone-600"
}`}
>
{pct}%
</button>
))}
<button
onClick={() => { setTipPercent(null); }}
className={`flex-1 py-2 rounded-lg text-sm font-medium border ${
tipPercent === null ? "border-[#8b7355] bg-[#faf5ef] text-[#6b5a42]" : "border-stone-200 text-stone-600"
}`}
>
Custom
</button>
</div>
{tipPercent === null && (
<input
type="number"
placeholder="Enter amount"
value={customTip}
onChange={e => setCustomTip(e.target.value)}
className="w-full border border-stone-300 rounded-lg px-3 py-2 text-sm mb-4"
/>
)}
<div className="flex gap-2">
<button onClick={onClose} className="flex-1 px-4 py-2 border border-stone-200 rounded-lg text-sm">Cancel</button>
<button onClick={onClose} className="flex-1 px-4 py-2 bg-[#8b7355] text-white rounded-lg text-sm font-medium">Add Tip</button>
</div>
</div>
</div>
);
}
@@ -0,0 +1,196 @@
import { useState } from "react";
import { Send, Check, CheckCheck, Bell, Mail, Smartphone, Megaphone, FileText, CreditCard } from "lucide-react";
import { MESSAGES, BUSINESS_NAME } from "../mockData.js";
import type { Message } from "../mockData.js";
interface Props {
readOnly: boolean;
}
export function Communication({ readOnly }: Props) {
const [tab, setTab] = useState<"messages" | "notifications">("messages");
return (
<div className="space-y-6">
<div className="flex gap-2">
<button
onClick={() => setTab("messages")}
className={`flex items-center gap-1.5 px-4 py-2 rounded-lg text-sm font-medium ${
tab === "messages" ? "bg-[#f0ebe4] text-[#6b5a42]" : "text-stone-500 hover:bg-stone-50"
}`}
>
Messages
</button>
<button
onClick={() => setTab("notifications")}
className={`flex items-center gap-1.5 px-4 py-2 rounded-lg text-sm font-medium ${
tab === "notifications" ? "bg-[#f0ebe4] text-[#6b5a42]" : "text-stone-500 hover:bg-stone-50"
}`}
>
<Bell size={14} />
Notification Preferences
</button>
</div>
{tab === "messages" && <MessageThread readOnly={readOnly} />}
{tab === "notifications" && <NotificationPreferences readOnly={readOnly} />}
</div>
);
}
function MessageThread({ readOnly }: { readOnly: boolean }) {
const [messages, setMessages] = useState<Message[]>(MESSAGES);
const [newMessage, setNewMessage] = useState("");
const handleSend = () => {
if (!newMessage.trim() || readOnly) return;
const msg: Message = {
id: `m-${Date.now()}`,
sender: "customer",
senderName: "Sarah",
text: newMessage.trim(),
timestamp: new Date().toISOString(),
read: false,
};
setMessages([...messages, msg]);
setNewMessage("");
};
return (
<div className="bg-white rounded-2xl border border-stone-200 shadow-sm overflow-hidden flex flex-col" style={{ height: "500px" }}>
<div className="px-5 py-3 border-b border-stone-200 bg-stone-50">
<p className="text-sm font-medium text-stone-800">{BUSINESS_NAME}</p>
<p className="text-xs text-stone-400">Usually replies within a few hours</p>
</div>
<div className="flex-1 overflow-y-auto p-4 space-y-3">
{messages.map(msg => (
<div key={msg.id} className={`flex ${msg.sender === "customer" ? "justify-end" : "justify-start"}`}>
<div className={`max-w-[80%] rounded-2xl px-4 py-2.5 ${
msg.sender === "customer"
? "bg-[#8b7355] text-white rounded-br-md"
: "bg-stone-100 text-stone-800 rounded-bl-md"
}`}>
<p className="text-sm">{msg.text}</p>
<div className={`flex items-center gap-1 mt-1 ${msg.sender === "customer" ? "justify-end" : ""}`}>
<span className={`text-xs ${msg.sender === "customer" ? "text-white/60" : "text-stone-400"}`}>
{new Date(msg.timestamp).toLocaleTimeString([], { hour: "numeric", minute: "2-digit" })}
</span>
{msg.sender === "customer" && (
msg.read
? <CheckCheck size={12} className="text-white/60" />
: <Check size={12} className="text-white/60" />
)}
</div>
</div>
</div>
))}
</div>
{!readOnly && (
<div className="border-t border-stone-200 p-3 flex gap-2">
<input
type="text"
value={newMessage}
onChange={e => setNewMessage(e.target.value)}
onKeyDown={e => e.key === "Enter" && handleSend()}
placeholder="Type a message..."
className="flex-1 border border-stone-200 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-[#8b7355]/30 focus:border-[#8b7355]"
/>
<button
onClick={handleSend}
disabled={!newMessage.trim()}
className="px-4 py-2 bg-[#8b7355] text-white rounded-lg hover:bg-[#7a6549] disabled:opacity-50"
>
<Send size={16} />
</button>
</div>
)}
</div>
);
}
function NotificationPreferences({ readOnly }: { readOnly: boolean }) {
const [prefs, setPrefs] = useState({
appointmentReminders: { email: true, sms: true, push: true },
vaccinationAlerts: { email: true, sms: false, push: true },
promotional: { email: false, sms: false, push: false },
reportCards: { email: true, sms: false, push: true },
invoiceReceipts: { email: true, sms: false, push: false },
});
type PrefKey = keyof typeof prefs;
type ChannelKey = "email" | "sms" | "push";
const toggle = (category: PrefKey, channel: ChannelKey) => {
if (readOnly) return;
setPrefs(prev => ({
...prev,
[category]: {
...prev[category],
[channel]: !prev[category][channel],
},
}));
};
const categories: { key: PrefKey; label: string; desc: string; icon: typeof Bell }[] = [
{ key: "appointmentReminders", label: "Appointment Reminders", desc: "Upcoming appointment notifications", icon: Bell },
{ key: "vaccinationAlerts", label: "Vaccination Alerts", desc: "Expiration and renewal reminders", icon: FileText },
{ key: "promotional", label: "Promotions & Offers", desc: "Deals and seasonal specials", icon: Megaphone },
{ key: "reportCards", label: "Report Cards", desc: "Grooming report card delivery", icon: FileText },
{ key: "invoiceReceipts", label: "Invoice & Receipts", desc: "Payment confirmations", icon: CreditCard },
];
const channels: { key: ChannelKey; label: string; icon: typeof Mail }[] = [
{ key: "email", label: "Email", icon: Mail },
{ key: "sms", label: "SMS", icon: Smartphone },
{ key: "push", label: "Push", icon: Bell },
];
return (
<div className="bg-white rounded-2xl border border-stone-200 shadow-sm overflow-hidden">
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-stone-100">
<th className="text-left px-5 py-3 text-xs text-stone-400 font-medium">Category</th>
{channels.map(ch => (
<th key={ch.key} className="px-5 py-3 text-xs text-stone-400 font-medium text-center">
<div className="flex items-center justify-center gap-1">
<ch.icon size={12} />
{ch.label}
</div>
</th>
))}
</tr>
</thead>
<tbody>
{categories.map(cat => (
<tr key={cat.key} className="border-b border-stone-50">
<td className="px-5 py-3">
<p className="font-medium text-stone-800">{cat.label}</p>
<p className="text-xs text-stone-400">{cat.desc}</p>
</td>
{channels.map(ch => (
<td key={ch.key} className="px-5 py-3 text-center">
<button
onClick={() => toggle(cat.key, ch.key)}
disabled={readOnly}
className={`w-10 h-5 rounded-full transition-colors inline-block ${
prefs[cat.key][ch.key] ? "bg-[#8b7355]" : "bg-stone-300"
} ${readOnly ? "cursor-not-allowed opacity-60" : ""}`}
>
<div className={`w-4 h-4 bg-white rounded-full shadow transition-transform ${
prefs[cat.key][ch.key] ? "translate-x-5" : "translate-x-0.5"
}`} />
</button>
</td>
))}
</tr>
))}
</tbody>
</table>
</div>
</div>
);
}
+195
View File
@@ -0,0 +1,195 @@
import { Calendar, Clock, PawPrint, CreditCard, Star, ChevronRight, AlertTriangle } from "lucide-react";
import { PETS, UPCOMING_APPOINTMENTS, PAST_APPOINTMENTS, INVOICES, LOYALTY, BUSINESS_NAME } from "../mockData.js";
interface Props {
onNavigate: (section: "appointments" | "pets" | "billing" | "reports") => void;
readOnly: boolean;
}
function daysUntil(dateStr: string): number {
const now = new Date();
now.setHours(0, 0, 0, 0);
const target = new Date(dateStr);
target.setHours(0, 0, 0, 0);
return Math.ceil((target.getTime() - now.getTime()) / (1000 * 60 * 60 * 24));
}
function formatDate(dateStr: string): string {
return new Date(dateStr).toLocaleDateString("en-US", { weekday: "short", month: "short", day: "numeric" });
}
export function Dashboard({ onNavigate, readOnly }: Props) {
const nextAppt = UPCOMING_APPOINTMENTS[0];
const outstanding = INVOICES.filter(i => i.status === "outstanding").reduce((sum, i) => sum + i.amount, 0);
const recentEvents = [
...PAST_APPOINTMENTS.slice(0, 3).map(a => ({
id: a.id, date: a.date, text: `${a.petName}${a.services.join(", ")}`, type: "appointment" as const,
})),
...INVOICES.filter(i => i.status === "paid").slice(0, 2).map(i => ({
id: i.id, date: i.date, text: `Invoice paid — $${i.amount}`, type: "payment" as const,
})),
].sort((a, b) => new Date(b.date).getTime() - new Date(a.date).getTime()).slice(0, 5);
return (
<div className="space-y-6">
{/* Welcome */}
<div>
<h2 className="text-2xl font-semibold text-stone-800">Welcome back, Sarah</h2>
<p className="text-stone-500 text-sm mt-1">Here's what's happening at {BUSINESS_NAME}</p>
</div>
{/* Next Appointment */}
{nextAppt && (
<div className="bg-white rounded-2xl border border-stone-200 p-5 shadow-sm">
<div className="flex items-start justify-between mb-3">
<div className="flex items-center gap-2 text-sm font-medium text-[#6b5a42]">
<Calendar size={16} />
Next Appointment
</div>
<span className="text-xs bg-green-100 text-green-700 px-2 py-0.5 rounded-full font-medium">
{nextAppt.status}
</span>
</div>
<div className="flex flex-col sm:flex-row sm:items-center gap-4">
<div className="flex-1">
<p className="text-lg font-semibold text-stone-800">
{nextAppt.petName} with {nextAppt.groomerName}
</p>
<p className="text-stone-600 text-sm mt-1">
{nextAppt.services.join(", ")}
{nextAppt.addOns.length > 0 && ` + ${nextAppt.addOns.join(", ")}`}
</p>
<div className="flex items-center gap-4 mt-2 text-sm text-stone-500">
<span className="flex items-center gap-1">
<Calendar size={14} />
{formatDate(nextAppt.date)}
</span>
<span className="flex items-center gap-1">
<Clock size={14} />
{nextAppt.time}
</span>
</div>
</div>
<div className="text-center sm:text-right">
<div className="text-3xl font-bold text-[#6b5a42]">{daysUntil(nextAppt.date)}</div>
<div className="text-xs text-stone-500">days away</div>
</div>
</div>
{!readOnly && (
<div className="flex gap-2 mt-4">
<button className="text-sm px-3 py-1.5 border border-stone-200 rounded-lg text-stone-600 hover:bg-stone-50">
Reschedule
</button>
<button className="text-sm px-3 py-1.5 border border-stone-200 rounded-lg text-stone-600 hover:bg-stone-50">
Cancel
</button>
<button className="text-sm px-3 py-1.5 border border-stone-200 rounded-lg text-stone-600 hover:bg-stone-50">
Add Notes
</button>
</div>
)}
</div>
)}
{/* Pet Cards & Loyalty */}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{/* Pet Cards */}
{PETS.map(pet => {
const expiringVax = pet.vaccinations.filter(v => v.status !== "valid");
return (
<button
key={pet.id}
onClick={() => onNavigate("pets")}
className="bg-white rounded-2xl border border-stone-200 p-4 shadow-sm text-left hover:border-stone-300 transition-colors"
>
<div className="flex items-center gap-3 mb-3">
<div className="w-12 h-12 rounded-full bg-[#f0ebe4] flex items-center justify-center text-2xl">
{pet.photo}
</div>
<div>
<p className="font-semibold text-stone-800">{pet.name}</p>
<p className="text-xs text-stone-500">{pet.breed} · {pet.weight} lbs</p>
</div>
</div>
{expiringVax.length > 0 ? (
<div className="flex items-center gap-1.5 text-xs text-amber-700 bg-amber-50 px-2 py-1 rounded-lg">
<AlertTriangle size={12} />
{expiringVax.map(v => v.name).join(", ")} {expiringVax[0]?.status === "expired" ? "expired" : "expiring soon"}
</div>
) : (
<div className="flex items-center gap-1.5 text-xs text-green-700 bg-green-50 px-2 py-1 rounded-lg">
<PawPrint size={12} />
All vaccinations current
</div>
)}
</button>
);
})}
{/* Loyalty Card */}
<div className="bg-white rounded-2xl border border-stone-200 p-4 shadow-sm">
<div className="flex items-center gap-2 text-sm font-medium text-[#6b5a42] mb-3">
<Star size={16} />
Loyalty Rewards
</div>
<p className="text-2xl font-bold text-stone-800">{LOYALTY.points} <span className="text-sm font-normal text-stone-500">pts</span></p>
<div className="mt-2 bg-stone-100 rounded-full h-2 overflow-hidden">
<div
className="bg-[#8b7355] h-full rounded-full transition-all"
style={{ width: `${(LOYALTY.points / LOYALTY.nextRewardAt) * 100}%` }}
/>
</div>
<p className="text-xs text-stone-500 mt-1">
{LOYALTY.nextRewardAt - LOYALTY.points} pts to {LOYALTY.rewardName}
</p>
</div>
</div>
{/* Outstanding Balance & Recent Activity */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{/* Outstanding Balance */}
{outstanding > 0 && (
<div className="bg-white rounded-2xl border border-stone-200 p-5 shadow-sm">
<div className="flex items-center justify-between">
<div>
<div className="flex items-center gap-2 text-sm font-medium text-stone-500 mb-1">
<CreditCard size={16} />
Outstanding Balance
</div>
<p className="text-2xl font-bold text-stone-800">${outstanding.toFixed(2)}</p>
</div>
{!readOnly && (
<button
onClick={() => onNavigate("billing")}
className="px-4 py-2 bg-[#8b7355] text-white rounded-lg text-sm font-medium hover:bg-[#7a6549]"
>
Pay Now
</button>
)}
</div>
</div>
)}
{/* Recent Activity */}
<div className="bg-white rounded-2xl border border-stone-200 p-5 shadow-sm">
<h3 className="text-sm font-medium text-stone-500 mb-3">Recent Activity</h3>
<div className="space-y-2.5">
{recentEvents.map(evt => (
<div key={evt.id} className="flex items-center gap-3 text-sm">
<div className={`w-2 h-2 rounded-full shrink-0 ${evt.type === "payment" ? "bg-green-400" : "bg-[#8b7355]"}`} />
<span className="text-stone-600 flex-1">{evt.text}</span>
<span className="text-xs text-stone-400">{formatDate(evt.date)}</span>
</div>
))}
</div>
<button
onClick={() => onNavigate("appointments")}
className="flex items-center gap-1 text-sm text-[#6b5a42] font-medium mt-3 hover:text-[#8b7355]"
>
View all <ChevronRight size={14} />
</button>
</div>
</div>
</div>
);
}
@@ -0,0 +1,236 @@
import { useState } from "react";
import { PawPrint, Heart, Scissors, Syringe, AlertTriangle, CheckCircle, Clock, Upload, Edit3 } from "lucide-react";
import { PETS, PAST_APPOINTMENTS } from "../mockData.js";
import type { Pet } from "../mockData.js";
interface Props {
readOnly: boolean;
}
type VaxStatus = "valid" | "expiring" | "expired";
const VAX_STATUS_STYLES: Record<VaxStatus, { bg: string; text: string; icon: typeof CheckCircle }> = {
valid: { bg: "bg-green-100", text: "text-green-700", icon: CheckCircle },
expiring: { bg: "bg-amber-100", text: "text-amber-700", icon: Clock },
expired: { bg: "bg-red-100", text: "text-red-700", icon: AlertTriangle },
};
export function PetProfiles({ readOnly }: Props) {
const [selectedPetId, setSelectedPetId] = useState<string>(PETS[0]?.id ?? "");
const [activeTab, setActiveTab] = useState<"info" | "medical" | "grooming" | "vaccinations" | "history">("info");
const pet = PETS.find(p => p.id === selectedPetId)!;
const petHistory = PAST_APPOINTMENTS.filter(a => a.petId === selectedPetId);
return (
<div className="space-y-6">
{/* Pet Selector */}
<div className="flex gap-3">
{PETS.map(p => (
<button
key={p.id}
onClick={() => { setSelectedPetId(p.id); setActiveTab("info"); }}
className={`flex items-center gap-3 px-4 py-3 rounded-xl border transition-colors ${
p.id === selectedPetId ? "border-[#8b7355] bg-[#faf5ef]" : "border-stone-200 bg-white hover:border-stone-300"
}`}
>
<span className="text-2xl">{p.photo}</span>
<div className="text-left">
<p className="font-medium text-stone-800 text-sm">{p.name}</p>
<p className="text-xs text-stone-500">{p.breed}</p>
</div>
</button>
))}
</div>
{/* Profile Header */}
<div className="bg-white rounded-2xl border border-stone-200 p-5 shadow-sm">
<div className="flex items-center gap-4">
<div className="w-20 h-20 rounded-2xl bg-[#f0ebe4] flex items-center justify-center text-4xl">
{pet.photo}
</div>
<div className="flex-1">
<h2 className="text-xl font-semibold text-stone-800">{pet.name}</h2>
<p className="text-stone-500 text-sm">{pet.breed} · {pet.weight} lbs · {pet.sex === "male" ? "♂" : "♀"} {pet.spayedNeutered ? "(spayed/neutered)" : ""}</p>
<p className="text-stone-400 text-xs mt-0.5">Born {new Date(pet.dob).toLocaleDateString("en-US", { month: "long", day: "numeric", year: "numeric" })}</p>
</div>
{!readOnly && (
<button className="p-2 hover:bg-stone-50 rounded-lg">
<Edit3 size={16} className="text-stone-400" />
</button>
)}
</div>
</div>
{/* Tabs */}
<div className="flex gap-1 bg-white rounded-xl border border-stone-200 p-1 overflow-x-auto">
{([
{ id: "info", label: "Basic Info", icon: PawPrint },
{ id: "medical", label: "Medical", icon: Heart },
{ id: "grooming", label: "Grooming", icon: Scissors },
{ id: "vaccinations", label: "Vaccinations", icon: Syringe },
{ id: "history", label: "History", icon: Clock },
] as const).map(({ id, label, icon: Icon }) => (
<button
key={id}
onClick={() => setActiveTab(id)}
className={`flex items-center gap-1.5 px-3 py-2 rounded-lg text-sm font-medium whitespace-nowrap ${
activeTab === id ? "bg-[#f0ebe4] text-[#6b5a42]" : "text-stone-500 hover:text-stone-700"
}`}
>
<Icon size={14} />
{label}
</button>
))}
</div>
{/* Tab Content */}
<div className="bg-white rounded-2xl border border-stone-200 p-5 shadow-sm">
{activeTab === "info" && <BasicInfoTab pet={pet} readOnly={readOnly} />}
{activeTab === "medical" && <MedicalTab pet={pet} readOnly={readOnly} />}
{activeTab === "grooming" && <GroomingTab pet={pet} readOnly={readOnly} />}
{activeTab === "vaccinations" && <VaccinationsTab pet={pet} readOnly={readOnly} />}
{activeTab === "history" && <HistoryTab petHistory={petHistory} />}
</div>
</div>
);
}
function InfoRow({ label, value }: { label: string; value: string }) {
return (
<div className="flex flex-col sm:flex-row sm:items-center py-2.5 border-b border-stone-100 last:border-0">
<span className="text-sm text-stone-500 sm:w-40 shrink-0">{label}</span>
<span className="text-sm text-stone-800">{value}</span>
</div>
);
}
function BasicInfoTab({ pet, readOnly }: { pet: Pet; readOnly: boolean }) {
return (
<div>
<InfoRow label="Name" value={pet.name} />
<InfoRow label="Breed" value={pet.breed} />
<InfoRow label="Weight" value={`${pet.weight} lbs`} />
<InfoRow label="Date of Birth" value={new Date(pet.dob).toLocaleDateString("en-US", { month: "long", day: "numeric", year: "numeric" })} />
<InfoRow label="Sex" value={pet.sex === "male" ? "Male" : "Female"} />
<InfoRow label="Spayed/Neutered" value={pet.spayedNeutered ? "Yes" : "No"} />
{!readOnly && (
<button className="mt-4 text-sm text-[#6b5a42] font-medium hover:underline">
Upload Photo
</button>
)}
</div>
);
}
function MedicalTab({ pet, readOnly }: { pet: Pet; readOnly: boolean }) {
return (
<div>
<InfoRow label="Allergies" value={pet.allergies} />
<InfoRow label="Skin Conditions" value={pet.skinConditions} />
<InfoRow label="Anxiety Triggers" value={pet.anxietyTriggers} />
<InfoRow label="Aggression Notes" value={pet.aggressionNotes} />
<InfoRow label="Mobility Issues" value={pet.mobilityIssues} />
<InfoRow label="Medications" value={pet.medications} />
{!readOnly && (
<p className="mt-3 text-xs text-stone-400">
Changes to medical notes will be flagged for staff review.
</p>
)}
</div>
);
}
function GroomingTab({ pet, readOnly }: { pet: Pet; readOnly: boolean }) {
return (
<div>
<InfoRow label="Preferred Cut" value={pet.preferredCut} />
<InfoRow label="Shampoo Preference" value={pet.shampooPreference} />
<InfoRow label="Sensitive Areas" value={pet.sensitiveAreas} />
<InfoRow label="Standing Instructions" value={pet.standingInstructions} />
{!readOnly && (
<button className="mt-4 text-sm text-[#6b5a42] font-medium hover:underline">
Upload Reference Photo
</button>
)}
</div>
);
}
function VaccinationsTab({ pet, readOnly }: { pet: Pet; readOnly: boolean }) {
return (
<div>
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="text-left text-xs text-stone-400 border-b border-stone-100">
<th className="pb-2 font-medium">Vaccine</th>
<th className="pb-2 font-medium">Administered</th>
<th className="pb-2 font-medium">Expires</th>
<th className="pb-2 font-medium">Status</th>
<th className="pb-2 font-medium">Proof</th>
</tr>
</thead>
<tbody>
{pet.vaccinations.map(vax => {
const style = VAX_STATUS_STYLES[vax.status];
const StatusIcon = style.icon;
return (
<tr key={vax.name} className="border-b border-stone-50">
<td className="py-2.5 font-medium text-stone-800">{vax.name}</td>
<td className="py-2.5 text-stone-600">{new Date(vax.lastAdministered).toLocaleDateString()}</td>
<td className="py-2.5 text-stone-600">{new Date(vax.expirationDate).toLocaleDateString()}</td>
<td className="py-2.5">
<span className={`inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs font-medium ${style.bg} ${style.text}`}>
<StatusIcon size={12} />
{vax.status}
</span>
</td>
<td className="py-2.5">
{vax.documentUploaded ? (
<span className="text-green-600 text-xs">Uploaded</span>
) : !readOnly ? (
<button className="flex items-center gap-1 text-xs text-[#6b5a42] hover:underline">
<Upload size={12} />
Upload
</button>
) : (
<span className="text-stone-400 text-xs">Missing</span>
)}
</td>
</tr>
);
})}
</tbody>
</table>
</div>
</div>
);
}
function HistoryTab({ petHistory }: { petHistory: typeof PAST_APPOINTMENTS }) {
return (
<div className="space-y-3">
{petHistory.length === 0 ? (
<p className="text-sm text-stone-400 text-center py-4">No history yet</p>
) : (
petHistory.map(appt => (
<div key={appt.id} className="flex items-center gap-3 py-2 border-b border-stone-50 last:border-0">
<div className="w-8 h-8 rounded-lg bg-stone-100 flex items-center justify-center text-xs text-stone-500">
<Scissors size={14} />
</div>
<div className="flex-1">
<p className="text-sm font-medium text-stone-800">{appt.services.join(", ")}</p>
<p className="text-xs text-stone-500">with {appt.groomerName} · ${appt.price}</p>
</div>
<span className="text-xs text-stone-400">
{new Date(appt.date).toLocaleDateString("en-US", { month: "short", day: "numeric", year: "numeric" })}
</span>
{appt.reportCardId && (
<span className="text-xs text-[#6b5a42] font-medium">Report </span>
)}
</div>
))
)}
</div>
);
}
@@ -0,0 +1,172 @@
import { useState } from "react";
import { FileText, Share2, Calendar, Smile, Meh, AlertCircle, ChevronRight } from "lucide-react";
import { REPORT_CARDS } from "../mockData.js";
import type { ReportCard } from "../mockData.js";
type MoodKey = "calm" | "cooperative" | "anxious" | "wiggly";
const MOOD_CONFIG: Record<MoodKey, { icon: typeof Smile; label: string; color: string; bg: string }> = {
calm: { icon: Smile, label: "Calm & Relaxed", color: "text-green-700", bg: "bg-green-100" },
cooperative: { icon: Smile, label: "Cooperative", color: "text-blue-700", bg: "bg-blue-100" },
anxious: { icon: Meh, label: "Anxious", color: "text-amber-700", bg: "bg-amber-100" },
wiggly: { icon: Meh, label: "Wiggly", color: "text-purple-700", bg: "bg-purple-100" },
};
export function ReportCards() {
const [selectedCard, setSelectedCard] = useState<ReportCard | null>(null);
if (selectedCard) {
return <ReportCardDetail card={selectedCard} onBack={() => setSelectedCard(null)} />;
}
return (
<div className="space-y-6">
<p className="text-sm text-stone-500">Grooming report cards from your recent visits</p>
<div className="space-y-4">
{REPORT_CARDS.map(card => {
const mood = MOOD_CONFIG[card.behaviorMood];
const MoodIcon = mood.icon;
return (
<button
key={card.id}
onClick={() => setSelectedCard(card)}
className="w-full bg-white rounded-2xl border border-stone-200 p-5 shadow-sm text-left hover:border-stone-300 transition-colors"
>
<div className="flex items-start gap-4">
<div className="w-14 h-14 rounded-xl bg-[#f0ebe4] flex items-center justify-center text-[#8b7355]">
<FileText size={24} />
</div>
<div className="flex-1">
<div className="flex items-center justify-between">
<h3 className="font-semibold text-stone-800">{card.petName}'s Report Card</h3>
<ChevronRight size={16} className="text-stone-400" />
</div>
<p className="text-sm text-stone-500 mt-0.5">
{card.servicesPerformed.join(", ")} with {card.groomerName}
</p>
<div className="flex items-center gap-3 mt-2">
<span className="flex items-center gap-1 text-xs text-stone-400">
<Calendar size={12} />
{new Date(card.date).toLocaleDateString("en-US", { month: "short", day: "numeric", year: "numeric" })}
</span>
<span className={`flex items-center gap-1 text-xs px-2 py-0.5 rounded-full ${mood.bg} ${mood.color}`}>
<MoodIcon size={12} />
{mood.label}
</span>
</div>
</div>
</div>
</button>
);
})}
</div>
</div>
);
}
function ReportCardDetail({ card, onBack }: { card: ReportCard; onBack: () => void }) {
const mood = MOOD_CONFIG[card.behaviorMood];
const MoodIcon = mood.icon;
return (
<div className="space-y-6">
<button onClick={onBack} className="text-sm text-[#6b5a42] font-medium hover:underline">
← Back to Report Cards
</button>
<div className="bg-white rounded-2xl border border-stone-200 shadow-sm overflow-hidden">
{/* Header */}
<div className="bg-gradient-to-r from-[#f0ebe4] to-[#e8e0d5] p-6">
<div className="flex items-center justify-between mb-1">
<h2 className="text-xl font-semibold text-stone-800">{card.petName}'s Grooming Report</h2>
<button className="flex items-center gap-1.5 px-3 py-1.5 bg-white/80 text-stone-700 rounded-lg text-sm font-medium hover:bg-white">
<Share2 size={14} />
Share
</button>
</div>
<p className="text-sm text-stone-600">
{new Date(card.date).toLocaleDateString("en-US", { weekday: "long", month: "long", day: "numeric", year: "numeric" })} · Groomer: {card.groomerName}
</p>
</div>
<div className="p-6 space-y-6">
{/* Before & After */}
<div>
<h3 className="font-medium text-stone-800 mb-3">Before & After</h3>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div className="rounded-xl bg-stone-50 p-4">
<p className="text-xs font-medium text-stone-400 uppercase mb-2">Before</p>
<div className="w-full h-32 bg-stone-200 rounded-lg flex items-center justify-center text-stone-400 text-sm mb-2">
Photo placeholder
</div>
<p className="text-sm text-stone-600">{card.beforeDescription}</p>
</div>
<div className="rounded-xl bg-[#faf5ef] p-4">
<p className="text-xs font-medium text-[#8b7355] uppercase mb-2">After</p>
<div className="w-full h-32 bg-[#f0ebe4] rounded-lg flex items-center justify-center text-[#8b7355] text-sm mb-2">
Photo placeholder
</div>
<p className="text-sm text-stone-700">{card.afterDescription}</p>
</div>
</div>
</div>
{/* Services */}
<div>
<h3 className="font-medium text-stone-800 mb-2">Services Performed</h3>
<div className="flex flex-wrap gap-2">
{card.servicesPerformed.map(s => (
<span key={s} className="px-3 py-1 bg-stone-100 rounded-full text-sm text-stone-700">
{s}
</span>
))}
</div>
</div>
{/* Behavior */}
<div>
<h3 className="font-medium text-stone-800 mb-2">Behavior & Mood</h3>
<div className={`inline-flex items-center gap-2 px-4 py-2 rounded-xl ${mood.bg}`}>
<MoodIcon size={20} className={mood.color} />
<span className={`font-medium ${mood.color}`}>{mood.label}</span>
</div>
</div>
{/* Condition Observations */}
{card.conditionObservations.length > 0 && (
<div>
<h3 className="font-medium text-stone-800 mb-2">Condition Observations</h3>
<div className="space-y-2">
{card.conditionObservations.map((obs, i) => (
<div key={i} className="flex items-start gap-2 text-sm">
<AlertCircle size={16} className="text-amber-500 mt-0.5 shrink-0" />
<span className="text-stone-700">{obs}</span>
</div>
))}
</div>
</div>
)}
{/* Groomer's Note */}
<div className="bg-[#faf5ef] rounded-xl p-4">
<h3 className="font-medium text-stone-800 mb-2">A Note from {card.groomerName}</h3>
<p className="text-sm text-stone-700 italic leading-relaxed">"{card.groomerNote}"</p>
</div>
{/* Next Appointment CTA */}
<div className="bg-white border border-stone-200 rounded-xl p-4 flex items-center justify-between">
<div>
<p className="text-sm font-medium text-stone-800">Next recommended visit</p>
<p className="text-xs text-stone-500">
{new Date(card.nextRecommendedDate).toLocaleDateString("en-US", { month: "long", day: "numeric", year: "numeric" })}
</p>
</div>
<button className="px-4 py-2 bg-[#8b7355] text-white rounded-lg text-sm font-medium hover:bg-[#7a6549]">
Rebook Now
</button>
</div>
</div>
</div>
</div>
);
}
+2
View File
@@ -1,10 +1,12 @@
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
import tailwindcss from "@tailwindcss/vite";
import { VitePWA } from "vite-plugin-pwa";
export default defineConfig({
plugins: [
react(),
tailwindcss(),
VitePWA({
registerType: "autoUpdate",
includeAssets: ["favicon.svg", "apple-touch-icon.png"],
@@ -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;
@@ -0,0 +1,6 @@
-- Add client status (soft-delete support)
CREATE TYPE "client_status" AS ENUM ('active', 'disabled');
ALTER TABLE "clients"
ADD COLUMN "status" "client_status" NOT NULL DEFAULT 'active',
ADD COLUMN "disabled_at" timestamp;
+14
View File
@@ -57,6 +57,20 @@
"when": 1773820800000,
"tag": "0007_tip_splitting",
"breakpoints": true
},
{
"idx": 8,
"version": "7",
"when": 1773907200000,
"tag": "0008_business_settings",
"breakpoints": true
},
{
"idx": 9,
"version": "7",
"when": 1773993600000,
"tag": "0009_client_soft_delete",
"breakpoints": true
}
]
}
+2 -2
View File
@@ -7,8 +7,8 @@
"types": "./src/index.ts",
"exports": {
".": {
"types": "./src/index.ts",
"default": "./dist/index.js"
"default": "./dist/index.js",
"types": "./src/index.ts"
}
},
"scripts": {
+18
View File
@@ -42,6 +42,11 @@ export const paymentMethodEnum = pgEnum("payment_method", [
"other",
]);
export const clientStatusEnum = pgEnum("client_status", [
"active",
"disabled",
]);
// ─── Tables ───────────────────────────────────────────────────────────────────
export const clients = pgTable("clients", {
@@ -53,6 +58,8 @@ export const clients = pgTable("clients", {
notes: text("notes"),
// Set to true if the client has opted out of email reminders/notifications
emailOptOut: boolean("email_opt_out").notNull().default(false),
status: clientStatusEnum("status").notNull().default("active"),
disabledAt: timestamp("disabled_at"),
createdAt: timestamp("created_at").notNull().defaultNow(),
updatedAt: timestamp("updated_at").notNull().defaultNow(),
});
@@ -252,6 +259,17 @@ export const impersonationAuditLogs = pgTable("impersonation_audit_logs", {
createdAt: timestamp("created_at").notNull().defaultNow(),
});
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")
+2 -2
View File
@@ -7,8 +7,8 @@
"types": "./src/index.ts",
"exports": {
".": {
"types": "./src/index.ts",
"default": "./dist/index.js"
"default": "./dist/index.js",
"types": "./src/index.ts"
}
},
"scripts": {
+15
View File
@@ -8,6 +8,8 @@ export type AppointmentStatus =
| "cancelled"
| "no_show";
export type ClientStatus = "active" | "disabled";
export interface Client {
id: string;
name: string;
@@ -16,6 +18,8 @@ export interface Client {
address: string | null;
notes: string | null;
emailOptOut: boolean;
status: ClientStatus;
disabledAt: string | null;
createdAt: string;
updatedAt: string;
}
@@ -170,6 +174,17 @@ export interface ImpersonationAuditLog {
createdAt: string;
}
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[];
+692 -48
View File
File diff suppressed because it is too large Load Diff