From 81a348a2f4c776aa76e7967de964142cee2b3715 Mon Sep 17 00:00:00 2001 From: Groom Book CTO Date: Wed, 18 Mar 2026 23:28:16 +0000 Subject: [PATCH] feat(web): add customer portal with 7 sections and staff impersonation Implements the customer-facing portal for pet parents with: - Dashboard showing upcoming appointments, pet cards, loyalty rewards - Multi-step appointment booking flow with recurring scheduling - Pet profiles with medical/behavioral notes and vaccination tracking - Grooming report cards with before/after, behavior assessment, sharing - Billing & payments with invoices, saved methods, autopay, tips, packages - Communication with chat-style messaging and notification preferences - Account settings with personal info, password, pet management, agreements - Staff impersonation mode with required reason, 30-min session timer, non-dismissable banner, viewport border, watermark, read-only enforcement, and full audit trail viewer Also adds Tailwind CSS, lucide-react, and recharts as dependencies. Closes #53 Co-Authored-By: Paperclip --- apps/web/package.json | 6 +- apps/web/src/App.tsx | 3 + apps/web/src/index.css | 2 + apps/web/src/portal/AuditLogViewer.tsx | 62 ++ apps/web/src/portal/CustomerPortal.tsx | 344 ++++++++ apps/web/src/portal/ImpersonationBanner.tsx | 83 ++ apps/web/src/portal/mockData.ts | 348 ++++++++ .../src/portal/sections/AccountSettings.tsx | 177 +++++ apps/web/src/portal/sections/Appointments.tsx | 442 +++++++++++ .../src/portal/sections/BillingPayments.tsx | 252 ++++++ .../web/src/portal/sections/Communication.tsx | 196 +++++ apps/web/src/portal/sections/Dashboard.tsx | 195 +++++ apps/web/src/portal/sections/PetProfiles.tsx | 236 ++++++ apps/web/src/portal/sections/ReportCards.tsx | 172 ++++ apps/web/vite.config.ts | 2 + pnpm-lock.yaml | 740 ++++++++++++++++-- 16 files changed, 3211 insertions(+), 49 deletions(-) create mode 100644 apps/web/src/portal/AuditLogViewer.tsx create mode 100644 apps/web/src/portal/CustomerPortal.tsx create mode 100644 apps/web/src/portal/ImpersonationBanner.tsx create mode 100644 apps/web/src/portal/mockData.ts create mode 100644 apps/web/src/portal/sections/AccountSettings.tsx create mode 100644 apps/web/src/portal/sections/Appointments.tsx create mode 100644 apps/web/src/portal/sections/BillingPayments.tsx create mode 100644 apps/web/src/portal/sections/Communication.tsx create mode 100644 apps/web/src/portal/sections/Dashboard.tsx create mode 100644 apps/web/src/portal/sections/PetProfiles.tsx create mode 100644 apps/web/src/portal/sections/ReportCards.tsx 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 4ba6a85..14a1e61 100644 --- a/apps/web/src/App.tsx +++ b/apps/web/src/App.tsx @@ -7,6 +7,7 @@ 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 { CustomerPortal } from "./portal/CustomerPortal.js"; const NAV_LINKS = [ { to: "/", label: "Appointments" }, @@ -16,6 +17,7 @@ const NAV_LINKS = [ { to: "/invoices", label: "Invoices" }, { to: "/group-bookings", label: "Group Bookings" }, { to: "/reports", label: "Reports" }, + { to: "/portal", label: "Customer Portal" }, ]; export function App() { @@ -80,6 +82,7 @@ export function App() { } /> } /> } /> + } /> diff --git a/apps/web/src/index.css b/apps/web/src/index.css index 83e7286..67a2b22 100644 --- a/apps/web/src/index.css +++ b/apps/web/src/index.css @@ -1,3 +1,5 @@ +@import "tailwindcss"; + *, *::before, *::after { box-sizing: border-box; } diff --git a/apps/web/src/portal/AuditLogViewer.tsx b/apps/web/src/portal/AuditLogViewer.tsx new file mode 100644 index 0000000..7510c1a --- /dev/null +++ b/apps/web/src/portal/AuditLogViewer.tsx @@ -0,0 +1,62 @@ +import { useState } from "react"; +import { X, Filter } from "lucide-react"; +import type { AuditEntry } from "./mockData.js"; + +interface Props { + auditLog: AuditEntry[]; + onClose: () => void; +} + +export function AuditLogViewer({ auditLog, onClose }: Props) { + const [filterAction, setFilterAction] = useState("all"); + + const actionTypes = ["all", ...new Set(auditLog.map(e => e.action))]; + const filtered = filterAction === "all" ? auditLog : auditLog.filter(e => e.action === filterAction); + + return ( +
+
+
+

Impersonation Audit Log

+ +
+
+ + + {filtered.length} entries +
+
+ {filtered.length === 0 ? ( +

No audit entries

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

{entry.detail}

+
+
+ ))} +
+ )} +
+
+
+ ); +} diff --git a/apps/web/src/portal/CustomerPortal.tsx b/apps/web/src/portal/CustomerPortal.tsx new file mode 100644 index 0000000..22a4d1c --- /dev/null +++ b/apps/web/src/portal/CustomerPortal.tsx @@ -0,0 +1,344 @@ +import { useState, useReducer, useCallback } 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"; + +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; + } +} + +export function CustomerPortal() { + const [activeSection, setActiveSection] = useState
("dashboard"); + const [mobileNavOpen, setMobileNavOpen] = useState(false); + const [showAuditLog, setShowAuditLog] = useState(false); + const [showImpersonationSetup, setShowImpersonationSetup] = useState(false); + const [impersonation, dispatchImpersonation] = useReducer(impersonationReducer, null); + + 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]); + + 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 ( +
+ {impersonation?.active && ( + <> + dispatchImpersonation({ type: "END" })} + onExtend={() => dispatchImpersonation({ type: "EXTEND" })} + onShowAudit={() => setShowAuditLog(true)} + /> + {/* Watermark */} +
+
+ STAFF VIEW +
+
+ + )} + + {showAuditLog && impersonation && ( + setShowAuditLog(false)} + /> + )} + + {/* Mobile Header */} +
+ + Paws & Reflect +
+ 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()} +
+
+
+ + {/* 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}

+
+
+
+ +