feat: customer portal with 7 sections and staff impersonation (#54)

* feat(web): add customer portal with 7 sections and staff impersonation

Implements the customer-facing portal for pet parents with:
- Dashboard showing upcoming appointments, pet cards, loyalty rewards
- Multi-step appointment booking flow with recurring scheduling
- Pet profiles with medical/behavioral notes and vaccination tracking
- Grooming report cards with before/after, behavior assessment, sharing
- Billing & payments with invoices, saved methods, autopay, tips, packages
- Communication with chat-style messaging and notification preferences
- Account settings with personal info, password, pet management, agreements
- Staff impersonation mode with required reason, 30-min session timer,
  non-dismissable banner, viewport border, watermark, read-only enforcement,
  and full audit trail viewer

Also adds Tailwind CSS, lucide-react, and recharts as dependencies.

Closes #53

Co-Authored-By: Paperclip <noreply@paperclip.ing>

* fix(web): remove unused imports to pass lint

Co-Authored-By: Paperclip <noreply@paperclip.ing>

---------

Co-authored-by: Groom Book CTO <cto@groombook.dev>
Co-authored-by: Paperclip <noreply@paperclip.ing>
This commit was merged in pull request #54.
This commit is contained in:
groombook-paperclip[bot]
2026-03-19 00:23:49 +00:00
committed by GitHub
parent 9ab05022a6
commit 5757cd0631
16 changed files with 3211 additions and 49 deletions
@@ -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>
);
}