feat: customer portal with 7 sections and staff impersonation #54
@@ -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",
|
||||
|
||||
@@ -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() {
|
||||
<Route path="/book" element={<BookPage />} />
|
||||
<Route path="/group-bookings" element={<GroupBookingPage />} />
|
||||
<Route path="/reports" element={<ReportsPage />} />
|
||||
<Route path="/portal" element={<CustomerPortal />} />
|
||||
</Routes>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
@import "tailwindcss";
|
||||
|
||||
*, *::before, *::after {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
@@ -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<string>("all");
|
||||
|
||||
const actionTypes = ["all", ...new Set(auditLog.map(e => e.action))];
|
||||
const filtered = filterAction === "all" ? auditLog : auditLog.filter(e => e.action === filterAction);
|
||||
|
||||
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-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>
|
||||
<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>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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<Section>("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 <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 (
|
||||
<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>
|
||||
</>
|
||||
)}
|
||||
|
||||
{showAuditLog && impersonation && (
|
||||
<AuditLogViewer
|
||||
auditLog={impersonation.auditLog}
|
||||
onClose={() => setShowAuditLog(false)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* 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">Paws & Reflect</span>
|
||||
<div className="w-8 h-8 rounded-full bg-[#8b7355] flex items-center justify-center text-white text-sm font-medium">
|
||||
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">
|
||||
<div className="w-10 h-10 rounded-xl bg-[#8b7355] flex items-center justify-center text-white text-lg">
|
||||
🐾
|
||||
</div>
|
||||
<div>
|
||||
<div className="font-semibold text-stone-800 text-sm">Paws & Reflect</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-[#f0ebe4] text-[#6b5a42]"
|
||||
: "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 bg-[#8b7355] flex items-center justify-center text-white text-sm font-medium">
|
||||
SM
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="p-4 md:p-8 max-w-6xl">
|
||||
{renderSection()}
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
{/* 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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import { Eye, Clock, LogOut, FileSearch } from "lucide-react";
|
||||
import type { ImpersonationSession } from "./mockData.js";
|
||||
|
||||
interface Props {
|
||||
session: ImpersonationSession;
|
||||
onEnd: () => void;
|
||||
onExtend: () => void;
|
||||
onShowAudit: () => void;
|
||||
}
|
||||
|
||||
export function ImpersonationBanner({ session, onEnd, onExtend, onShowAudit }: Props) {
|
||||
const [remaining, setRemaining] = useState("");
|
||||
const [showWarning, setShowWarning] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
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 / 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);
|
||||
}, [session.expiresAt, onEnd]);
|
||||
|
||||
if (!session.active) return null;
|
||||
|
||||
return (
|
||||
<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>
|
||||
{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={onShowAudit}
|
||||
className="px-2 py-1 text-xs bg-amber-100 text-amber-800 rounded hover:bg-amber-200 flex items-center gap-1"
|
||||
>
|
||||
<FileSearch size={12} />
|
||||
Audit
|
||||
</button>
|
||||
<button
|
||||
onClick={onEnd}
|
||||
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>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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"],
|
||||
|
||||
Generated
+692
-48
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user