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>
);
}
@@ -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>
);
}
+195
View File
@@ -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>
);
}