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,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>
);
}