diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c50089e..d43acf5 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -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 diff --git a/apps/api/src/index.ts b/apps/api/src/index.ts index 5403e02..b4866d1 100644 --- a/apps/api/src/index.ts +++ b/apps/api/src/index.ts @@ -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}`); diff --git a/apps/api/src/middleware/auth.ts b/apps/api/src/middleware/auth.ts index f8d6380..44f4100 100644 --- a/apps/api/src/middleware/auth.ts +++ b/apps/api/src/middleware/auth.ts @@ -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; } diff --git a/apps/api/src/routes/book.ts b/apps/api/src/routes/book.ts index 11428de..a60f008 100644 --- a/apps/api/src/routes/book.ts +++ b/apps/api/src/routes/book.ts @@ -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 diff --git a/apps/api/src/routes/clients.ts b/apps/api/src/routes/clients.ts index e560393..90313a2 100644 --- a/apps/api/src/routes/clients.ts +++ b/apps/api/src/routes/clients.ts @@ -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 = { ...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) diff --git a/apps/api/src/routes/dev.ts b/apps/api/src/routes/dev.ts new file mode 100644 index 0000000..dfc5708 --- /dev/null +++ b/apps/api/src/routes/dev.ts @@ -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`(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 }; diff --git a/apps/api/src/routes/reports.ts b/apps/api/src/routes/reports.ts index 7a56a31..8be162b 100644 --- a/apps/api/src/routes/reports.ts +++ b/apps/api/src/routes/reports.ts @@ -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`); diff --git a/apps/api/src/routes/settings.ts b/apps/api/src/routes/settings.ts new file mode 100644 index 0000000..2641c8c --- /dev/null +++ b/apps/api/src/routes/settings.ts @@ -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); + } +); diff --git a/apps/e2e/playwright.config.ts b/apps/e2e/playwright.config.ts index c25a7d2..e0970b4 100644 --- a/apps/e2e/playwright.config.ts +++ b/apps/e2e/playwright.config.ts @@ -22,6 +22,7 @@ export default defineConfig({ baseURL: "http://localhost:8080", trace: "on-first-retry", screenshot: "only-on-failure", + serviceWorkers: "block", }, projects: [ diff --git a/apps/e2e/tests/book.spec.ts b/apps/e2e/tests/book.spec.ts index 4a76f43..c3f30a5 100644 --- a/apps/e2e/tests/book.spec.ts +++ b/apps/e2e/tests/book.spec.ts @@ -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(); }); diff --git a/apps/e2e/tests/clients.spec.ts b/apps/e2e/tests/clients.spec.ts index 8e94e1a..cf99ad4 100644 --- a/apps/e2e/tests/clients.spec.ts +++ b/apps/e2e/tests/clients.spec.ts @@ -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 diff --git a/apps/e2e/tests/fixtures.ts b/apps/e2e/tests/fixtures.ts new file mode 100644 index 0000000..6dc1c72 --- /dev/null +++ b/apps/e2e/tests/fixtures.ts @@ -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"; diff --git a/apps/e2e/tests/navigation.spec.ts b/apps/e2e/tests/navigation.spec.ts index 1e7388a..544518a 100644 --- a/apps/e2e/tests/navigation.spec.ts +++ b/apps/e2e/tests/navigation.spec.ts @@ -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(); }); diff --git a/apps/web/package.json b/apps/web/package.json index c3a339b..34bc32a 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -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", diff --git a/apps/web/src/App.tsx b/apps/web/src/App.tsx index b2d13b8..23ecc24 100644 --- a/apps/web/src/App.tsx +++ b/apps/web/src/App.tsx @@ -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 ( - -
+
-
+
} /> } /> @@ -82,9 +118,52 @@ export function App() { } /> } /> } /> + } />
- + ); +} + +export function App() { + const location = useLocation(); + const [authDisabled, setAuthDisabled] = useState(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 ; + } + + // 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 ; + } + + return ( + + {location.pathname.startsWith("/admin") ? ( + <> + + } /> + + {authDisabled && } + + ) : ( + <> + + {authDisabled && } + + )} + ); } diff --git a/apps/web/src/BrandingContext.tsx b/apps/web/src/BrandingContext.tsx new file mode 100644 index 0000000..00a761c --- /dev/null +++ b/apps/web/src/BrandingContext.tsx @@ -0,0 +1,55 @@ +import { createContext, useContext, useEffect, useState, useCallback } from "react"; + +export interface Branding { + businessName: string; + primaryColor: string; + accentColor: string; + logoBase64: string | null; + logoMimeType: string | null; +} + +const DEFAULT_BRANDING: Branding = { + businessName: "GroomBook", + primaryColor: "#4f8a6f", + accentColor: "#8b7355", + logoBase64: null, + logoMimeType: null, +}; + +const BrandingContext = createContext<{ + branding: Branding; + refresh: () => void; +}>({ branding: DEFAULT_BRANDING, refresh: () => {} }); + +export function useBranding() { + return useContext(BrandingContext); +} + +export function BrandingProvider({ children }: { children: React.ReactNode }) { + const [branding, setBranding] = useState(DEFAULT_BRANDING); + + const fetchBranding = useCallback(() => { + fetch("/api/branding") + .then((r) => (r.ok ? r.json() : null)) + .then((data) => { + if (data && 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 ( + + {children} + + ); +} diff --git a/apps/web/src/__tests__/App.test.tsx b/apps/web/src/__tests__/App.test.tsx index 4854db3..97434eb 100644 --- a/apps/web/src/__tests__/App.test.tsx +++ b/apps/web/src/__tests__/App.test.tsx @@ -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( - - - - ); - }); - return screen.getByRole("navigation"); +async function renderApp(route = "/admin") { + render( + + + + ); + // 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( + + + + ); + // 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( + + + + ); + + // 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( + + + + ); + + // 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(); + }); }); diff --git a/apps/web/src/components/DevSessionIndicator.tsx b/apps/web/src/components/DevSessionIndicator.tsx new file mode 100644 index 0000000..993698d --- /dev/null +++ b/apps/web/src/components/DevSessionIndicator.tsx @@ -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 ( +
+ + Dev mode: acting as {user.name} ({user.type}) + + + Switch user + +
+ ); +} diff --git a/apps/web/src/index.css b/apps/web/src/index.css index 83e7286..7101912 100644 --- a/apps/web/src/index.css +++ b/apps/web/src/index.css @@ -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; } diff --git a/apps/web/src/lib/devFetch.ts b/apps/web/src/lib/devFetch.ts new file mode 100644 index 0000000..42078ce --- /dev/null +++ b/apps/web/src/lib/devFetch.ts @@ -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 }); + }; +} diff --git a/apps/web/src/main.tsx b/apps/web/src/main.tsx index b683e11..3920a8d 100644 --- a/apps/web/src/main.tsx +++ b/apps/web/src/main.tsx @@ -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"); diff --git a/apps/web/src/pages/Appointments.tsx b/apps/web/src/pages/Appointments.tsx index 20c5fae..ea2c539 100644 --- a/apps/web/src/pages/Appointments.tsx +++ b/apps/web/src/pages/Appointments.tsx @@ -291,7 +291,7 @@ export function AppointmentsPage() { @@ -370,11 +370,11 @@ export function AppointmentsPage() { {days.map((day, i) => { const isToday = formatDate(day) === formatDate(new Date()); return ( -
+
{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", }; diff --git a/apps/web/src/pages/Clients.tsx b/apps/web/src/pages/Clients.tsx index 79ce2b9..c46d7f3 100644 --- a/apps/web/src/pages/Clients.tsx +++ b/apps/web/src/pages/Clients.tsx @@ -66,6 +66,10 @@ export function ClientsPage() { const [savingPet, setSavingPet] = useState(false); const [deletingPetId, setDeletingPetId] = useState(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(null); @@ -76,17 +80,18 @@ export function ClientsPage() { const [logFormError, setLogFormError] = useState(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() {

Clients

@@ -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" }} /> + {filtered.length === 0 &&

No clients found.

} {filtered.map((c) => (
-
{c.name}
+
+ {c.name} + {c.status === "disabled" && ( + + Disabled + + )} +
{c.email &&
{c.email}
} {c.phone &&
{c.phone}
}
@@ -351,7 +417,14 @@ export function ClientsPage() {
-

{selectedClient.name}

+

+ {selectedClient.name} + {selectedClient.status === "disabled" && ( + + Disabled + + )} +

{selectedClient.email &&
{selectedClient.email}
} {selectedClient.phone &&
{selectedClient.phone}
} {selectedClient.address &&
{selectedClient.address}
} @@ -362,21 +435,37 @@ export function ClientsPage() { )}
- + + {selectedClient.status === "active" ? ( + + ) : ( + + )}
@@ -395,7 +484,7 @@ export function ClientsPage() { ) : (
{pets.map((p) => ( -
+
{p.name}
@@ -506,7 +595,7 @@ export function ClientsPage() { {clientFormError &&

{clientFormError}

}
- @@ -645,7 +734,7 @@ export function ClientsPage() { {logFormError &&

{logFormError}

}
- @@ -653,6 +742,42 @@ export function ClientsPage() { )} + + {/* ── Delete confirmation modal ── */} + {showDeleteConfirm && selectedClient && ( + setShowDeleteConfirm(false)}> +

Permanently Delete Client

+

+ This will permanently delete {selectedClient.name} and all their pets. This action cannot be undone. +

+

+ Consider disabling the client instead, which preserves their data for reporting. +

+ + setDeleteConfirmName(e.target.value)} + style={inputStyle} + placeholder={selectedClient.name} + /> + +
+ + +
+
+ )}
); } @@ -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", }; diff --git a/apps/web/src/pages/DevLoginSelector.tsx b/apps/web/src/pages/DevLoginSelector.tsx new file mode 100644 index 0000000..e171613 --- /dev/null +++ b/apps/web/src/pages/DevLoginSelector.tsx @@ -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([]); + const [clients, setClients] = useState([]); + 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 ( +
+

Loading users...

+
+ ); + } + + return ( +
+
+
+

+ GroomBook +

+

+ Dev Login Selector +

+
+ +

Staff

+
+ {staff.map((s) => ( + + ))} +
+ +

Clients

+
+ {clients.map((cl) => ( + + ))} +
+ +
+ +
+
+
+ ); +} + +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", +}; diff --git a/apps/web/src/pages/GroupBooking.tsx b/apps/web/src/pages/GroupBooking.tsx index 8662a07..445530a 100644 --- a/apps/web/src/pages/GroupBooking.tsx +++ b/apps/web/src/pages/GroupBooking.tsx @@ -287,7 +287,7 @@ function NewGroupBookingForm({ @@ -471,7 +471,7 @@ export function GroupBookingPage() { @@ -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", }; diff --git a/apps/web/src/pages/Invoices.tsx b/apps/web/src/pages/Invoices.tsx index dd86cf7..d1c3457 100644 --- a/apps/web/src/pages/Invoices.tsx +++ b/apps/web/src/pages/Invoices.tsx @@ -129,7 +129,7 @@ function CreateFromAppointmentForm({ @@ -540,7 +540,7 @@ export function InvoicesPage() { @@ -551,11 +551,12 @@ export function InvoicesPage() { No invoices yet. Create one from a completed appointment.

) : ( +
{["Date", "Client", "Subtotal", "Tax", "Tip", "Total", "Status", ""].map((h) => ( - ))} @@ -582,6 +583,7 @@ export function InvoicesPage() { ))}
+ {h}
+
)} {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", }; diff --git a/apps/web/src/pages/Reports.tsx b/apps/web/src/pages/Reports.tsx index 40d0087..f7b3ceb 100644 --- a/apps/web/src/pages/Reports.tsx +++ b/apps/web/src/pages/Reports.tsx @@ -84,14 +84,15 @@ function StatCard({ label, value, sub }: { label: string; value: string; sub?: s
-
+
{label}
{value}
@@ -102,7 +103,7 @@ function StatCard({ label, value, sub }: { label: string; value: string; sub?: s function SectionHeader({ children }: { children: React.ReactNode }) { return ( -

+

{children}

); @@ -110,35 +111,37 @@ function SectionHeader({ children }: { children: React.ReactNode }) { function Table({ headers, rows }: { headers: string[]; rows: (string | number)[][] }) { return ( - - - - {headers.map((h) => ( - - ))} - - - - {rows.map((row, i) => ( - - {row.map((cell, j) => ( - +
+
- {h} -
- {cell} -
+ + + {headers.map((h) => ( + ))} - ))} - {rows.length === 0 && ( - - - - )} - -
+ {h} +
- No data for this period. -
+ + + {rows.map((row, i) => ( + + {row.map((cell, j) => ( + + {cell} + + ))} + + ))} + {rows.length === 0 && ( + + + No data for this period. + + + )} + + +
); } @@ -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() { -
@@ -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", }; diff --git a/apps/web/src/pages/Services.tsx b/apps/web/src/pages/Services.tsx index 229b0d7..eb952b1 100644 --- a/apps/web/src/pages/Services.tsx +++ b/apps/web/src/pages/Services.tsx @@ -119,7 +119,7 @@ export function ServicesPage() {

Services

@@ -128,11 +128,12 @@ export function ServicesPage() { {services.length === 0 ? (

No services configured yet.

) : ( +
{["Name", "Description", "Price", "Duration", "Status", ""].map((h) => ( - ))} @@ -171,6 +172,7 @@ export function ServicesPage() { ))}
+ {h}
+
)} {showForm && ( @@ -230,7 +232,7 @@ export function ServicesPage() { @@ -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", }; diff --git a/apps/web/src/pages/Settings.tsx b/apps/web/src/pages/Settings.tsx new file mode 100644 index 0000000..09ff522 --- /dev/null +++ b/apps/web/src/pages/Settings.tsx @@ -0,0 +1,323 @@ +import { useState, useEffect, useRef } from "react"; +import { useBranding } from "../BrandingContext.js"; + +interface SettingsForm { + businessName: string; + primaryColor: string; + accentColor: string; + logoBase64: string | null; + logoMimeType: string | null; +} + +export function SettingsPage() { + const { refresh } = useBranding(); + const [form, setForm] = useState({ + businessName: "", + primaryColor: "#4f8a6f", + accentColor: "#8b7355", + logoBase64: null, + logoMimeType: null, + }); + const [saving, setSaving] = useState(false); + const [message, setMessage] = useState<{ type: "success" | "error"; text: string } | null>(null); + const [loaded, setLoaded] = useState(false); + const fileInputRef = useRef(null); + + useEffect(() => { + fetch("/api/admin/settings") + .then((r) => r.json()) + .then((data) => { + setForm({ + businessName: data.businessName ?? "GroomBook", + primaryColor: data.primaryColor ?? "#4f8a6f", + accentColor: data.accentColor ?? "#8b7355", + logoBase64: data.logoBase64 ?? null, + logoMimeType: data.logoMimeType ?? null, + }); + setLoaded(true); + }) + .catch(() => setLoaded(true)); + }, []); + + const handleLogoChange = (e: React.ChangeEvent) => { + const file = e.target.files?.[0]; + if (!file) return; + + if (file.size > 512 * 1024) { + setMessage({ type: "error", text: "Logo must be under 512KB." }); + return; + } + + const validTypes = ["image/png", "image/svg+xml", "image/jpeg", "image/webp"]; + if (!validTypes.includes(file.type)) { + setMessage({ type: "error", text: "Logo must be PNG, SVG, JPEG, or WebP." }); + return; + } + + const reader = new FileReader(); + reader.onload = () => { + const result = reader.result as string; + // Strip the data:...;base64, prefix + const base64 = result.split(",")[1] ?? null; + setForm((f) => ({ ...f, logoBase64: base64, logoMimeType: file.type as SettingsForm["logoMimeType"] })); + setMessage(null); + }; + reader.readAsDataURL(file); + }; + + const handleSave = async () => { + setSaving(true); + setMessage(null); + try { + const res = await fetch("/api/admin/settings", { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(form), + }); + if (!res.ok) { + const err = await res.json().catch(() => null); + throw new Error(err?.error ?? "Failed to save settings"); + } + setMessage({ type: "success", text: "Settings saved." }); + refresh(); + } catch (err: unknown) { + setMessage({ type: "error", text: err instanceof Error ? err.message : "Save failed" }); + } finally { + setSaving(false); + } + }; + + if (!loaded) return

Loading settings...

; + + const logoSrc = form.logoBase64 && form.logoMimeType + ? `data:${form.logoMimeType};base64,${form.logoBase64}` + : null; + + return ( +
+

Branding & Appearance

+

+ Customize your business name, logo, and color scheme. +

+ + {/* Business Name */} +
+ + setForm((f) => ({ ...f, businessName: e.target.value }))} + style={{ + width: "100%", + padding: "0.5rem 0.75rem", + border: "1px solid #d1d5db", + borderRadius: 6, + fontSize: 14, + }} + /> +
+ + {/* Logo Upload */} +
+ +
+ {logoSrc ? ( + Logo preview + ) : ( +
+ No logo +
+ )} +
+ + + {logoSrc && ( + + )} +

+ PNG, SVG, JPEG, or WebP. Max 512KB. +

+
+
+
+ + {/* Color Pickers */} +
+
+ +
+ setForm((f) => ({ ...f, primaryColor: e.target.value }))} + style={{ width: 40, height: 40, border: "none", cursor: "pointer" }} + /> + setForm((f) => ({ ...f, primaryColor: e.target.value }))} + style={{ + width: 90, + padding: "0.4rem 0.5rem", + border: "1px solid #d1d5db", + borderRadius: 6, + fontSize: 13, + fontFamily: "monospace", + }} + /> +
+
+
+ +
+ setForm((f) => ({ ...f, accentColor: e.target.value }))} + style={{ width: 40, height: 40, border: "none", cursor: "pointer" }} + /> + setForm((f) => ({ ...f, accentColor: e.target.value }))} + style={{ + width: 90, + padding: "0.4rem 0.5rem", + border: "1px solid #d1d5db", + borderRadius: 6, + fontSize: 13, + fontFamily: "monospace", + }} + /> +
+
+
+ + {/* Preview */} +
+

Preview

+
+ {logoSrc && ( + + )} + {form.businessName} + + Button + + + Accent + +
+
+ + {/* Save */} + {message && ( +
+ {message.text} +
+ )} + + +
+ ); +} diff --git a/apps/web/src/pages/Staff.tsx b/apps/web/src/pages/Staff.tsx index 0f34ffb..5e9b594 100644 --- a/apps/web/src/pages/Staff.tsx +++ b/apps/web/src/pages/Staff.tsx @@ -78,7 +78,7 @@ export function StaffPage() {

Staff

-
@@ -86,11 +86,12 @@ export function StaffPage() { {staff.length === 0 ? (

No staff members yet.

) : ( +
{["Name", "Email", "Role", "Status", ""].map((h) => ( - + ))} @@ -113,6 +114,7 @@ export function StaffPage() { ))}
{h}{h}
+
)} {showForm && ( @@ -143,7 +145,7 @@ export function StaffPage() {
{formError &&

{formError}

}
- @@ -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" }; diff --git a/apps/web/src/portal/AuditLogViewer.tsx b/apps/web/src/portal/AuditLogViewer.tsx index 1693e24..7510c1a 100644 --- a/apps/web/src/portal/AuditLogViewer.tsx +++ b/apps/web/src/portal/AuditLogViewer.tsx @@ -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([]); - const [loading, setLoading] = useState(true); +export function AuditLogViewer({ auditLog, onClose }: Props) { + const [filterAction, setFilterAction] = useState("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 ( -
{ if (e.target === e.currentTarget) onClose(); }} - > -
-
-

Audit Log

-
- - {loading ? ( -

Loading audit log...

- ) : logs.length === 0 ? ( -

No audit entries.

- ) : ( - - - - - - - - - - {logs.map((log) => ( - - - - - +
+ + + {filtered.length} entries +
+
+ {filtered.length === 0 ? ( +

No audit entries

+ ) : ( +
+ {filtered.map(entry => ( +
+
+ {new Date(entry.timestamp).toLocaleTimeString()} +
+
+ + {entry.action.replace(/_/g, " ")} + +

{entry.detail}

+
+
))} -
-
TimeActionPage
- {new Date(log.createdAt).toLocaleTimeString()} - {log.action} - {log.pageVisited ?? "—"} -
- )} +
+ )} +
); diff --git a/apps/web/src/portal/CustomerPortal.tsx b/apps/web/src/portal/CustomerPortal.tsx index 765e392..bb77072 100644 --- a/apps/web/src/portal/CustomerPortal.tsx +++ b/apps/web/src/portal/CustomerPortal.tsx @@ -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=. - * 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(null); - const [clientName, setClientName] = useState(""); +export function CustomerPortal() { + const [activeSection, setActiveSection] = useState
("dashboard"); + const [mobileNavOpen, setMobileNavOpen] = useState(false); const [showAuditLog, setShowAuditLog] = useState(false); - const [error, setError] = useState(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 ; + case "appointments": + return ; + case "pets": + return ; + case "reports": + return ; + case "billing": + return ; + case "messages": + return ; + case "settings": + return ; } - } + }; return ( - <> - {error && ( -
- {error} - -
+
+ {impersonation?.active && ( + <> + dispatchImpersonation({ type: "END" })} + onExtend={() => dispatchImpersonation({ type: "EXTEND" })} + onShowAudit={() => setShowAuditLog(true)} + /> + {/* Watermark */} +
+
+ STAFF VIEW +
+
+ )} - {session && session.status === "active" && ( - setShowAuditLog(false)} /> )} - {/* Push content down when banner is visible */} -
- {children} + {/* Mobile Header */} +
+ + {branding.businessName} +
+ SM +
+
+ +
+ {/* Sidebar Navigation */} + + + {/* Mobile nav overlay */} + {mobileNavOpen && ( +
setMobileNavOpen(false)} + /> + )} + + {/* Main Content */} +
+
+
+

+ {NAV_ITEMS.find(n => n.id === activeSection)?.label} +

+
+
+ Hi, {CUSTOMER.name.split(" ")[0]} +
+ SM +
+
+
+
+ {renderSection()} +
+
- {showAuditLog && session && ( - setShowAuditLog(false)} /> - )} - + {/* Impersonation Setup Modal */} + {showImpersonationSetup && { + dispatchImpersonation({ type: "START", staffName: "Chris", staffRole: "Admin", reason }); + setShowImpersonationSetup(false); + }} + onCancel={() => setShowImpersonationSetup(false)} + />} +
+ ); +} + +function ImpersonationSetupModal({ onStart, onCancel }: { onStart: (reason: string) => void; onCancel: () => void }) { + const [reason, setReason] = useState(""); + return ( +
+
+
+
+ +
+
+

Start Staff Impersonation

+

View portal as {CUSTOMER.name}

+
+
+
+ +