0361b84bd5
- Create PetProfileCard fetching from GET /api/pets/:id/profile-summary - Displays: pet photo/name/breed/age/weight, coat type badge, temperament score (1-5 dots) + flag badges, medical alerts (severity-colored), preferred cuts, recent visits, next appointment - Loading skeleton and error/empty states - Integrate into Appointments booking form after pet selection - Integrate into ClientDetailPage as expandable card per pet - Export PetProfileSummary + NextAppointment types in @groombook/types - Add PetProfileCard tests covering full data, empty data, loading, error Co-Authored-By: Paperclip <noreply@paperclip.ing>
239 lines
8.7 KiB
TypeScript
239 lines
8.7 KiB
TypeScript
import { useEffect, useState } from "react";
|
|
import type { MedicalAlert, PetProfileSummary } from "@groombook/types";
|
|
import { PetPhotoDisplay } from "./PetPhotoDisplay.js";
|
|
|
|
interface Props {
|
|
petId: string;
|
|
}
|
|
|
|
type LoadState =
|
|
| { status: "idle" }
|
|
| { status: "loading" }
|
|
| { status: "loaded"; data: PetProfileSummary }
|
|
| { status: "error"; message: string };
|
|
|
|
function computeAge(dateOfBirth: string | null): string | null {
|
|
if (!dateOfBirth) return null;
|
|
const birth = new Date(dateOfBirth);
|
|
const now = new Date();
|
|
const totalMonths = (now.getFullYear() - birth.getFullYear()) * 12 + (now.getMonth() - birth.getMonth());
|
|
if (totalMonths < 1) return "<1 mo";
|
|
if (totalMonths < 12) return `${totalMonths} mo`;
|
|
const years = Math.floor(totalMonths / 12);
|
|
const months = totalMonths % 12;
|
|
if (months === 0) return `${years} yr`;
|
|
return `${years} yr ${months} mo`;
|
|
}
|
|
|
|
function TemperamentDots({ score }: { score: number }) {
|
|
return (
|
|
<div style={{ display: "flex", gap: 3, alignItems: "center" }}>
|
|
{[1, 2, 3, 4, 5].map((n) => (
|
|
<div
|
|
key={n}
|
|
style={{
|
|
width: 10,
|
|
height: 10,
|
|
borderRadius: "50%",
|
|
background: n <= score ? "var(--color-primary)" : "#e2e8f0",
|
|
}}
|
|
/>
|
|
))}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
const SEVERITY_STYLES: Record<MedicalAlert["severity"], { bg: string; color: string; border: string }> = {
|
|
high: { bg: "#fef2f2", color: "#dc2626", border: "#fca5a5" },
|
|
medium: { bg: "#fffbeb", color: "#d97706", border: "#fde68a" },
|
|
low: { bg: "#eff6ff", color: "#2563eb", border: "#bfdbfe" },
|
|
};
|
|
|
|
function MedicalAlertBadge({ alert }: { alert: MedicalAlert }) {
|
|
const s = SEVERITY_STYLES[alert.severity];
|
|
return (
|
|
<span
|
|
style={{
|
|
display: "inline-flex",
|
|
alignItems: "center",
|
|
gap: "0.25rem",
|
|
fontSize: 11,
|
|
fontWeight: 600,
|
|
padding: "0.15rem 0.45rem",
|
|
borderRadius: 99,
|
|
background: s.bg,
|
|
color: s.color,
|
|
border: `1px solid ${s.border}`,
|
|
}}
|
|
>
|
|
{alert.description}
|
|
</span>
|
|
);
|
|
}
|
|
|
|
function SectionLabel({ children }: { children: React.ReactNode }) {
|
|
return (
|
|
<div style={{ fontSize: 10, fontWeight: 700, color: "#9ca3af", letterSpacing: "0.05em", marginBottom: "0.25rem" }}>
|
|
{children}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
export function PetProfileCard({ petId }: Props) {
|
|
const [state, setState] = useState<LoadState>({ status: "idle" });
|
|
|
|
useEffect(() => {
|
|
setState({ status: "loading" });
|
|
fetch(`/api/pets/${petId}/profile-summary`)
|
|
.then(async (res) => {
|
|
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
|
return res.json() as Promise<PetProfileSummary>;
|
|
})
|
|
.then((data) => setState({ status: "loaded", data }))
|
|
.catch((e: unknown) => setState({ status: "error", message: e instanceof Error ? e.message : "Unknown error" }));
|
|
}, [petId]);
|
|
|
|
if (state.status === "idle" || state.status === "loading") {
|
|
return (
|
|
<div style={{
|
|
border: "1px solid #e5e7eb",
|
|
borderRadius: 10,
|
|
padding: "1rem",
|
|
background: "#fff",
|
|
boxShadow: "0 1px 3px rgba(0,0,0,0.04)",
|
|
}}>
|
|
<div style={{ display: "flex", gap: "0.75rem", marginBottom: "0.75rem" }}>
|
|
<div style={{ width: 64, height: 64, borderRadius: 12, background: "linear-gradient(90deg,#f0ebe4 25%,#e8e0d8 50%,#f0ebe4 75%)", backgroundSize: "200% 100%", animation: "shimmer 1.5s infinite", flexShrink: 0 }} />
|
|
<div style={{ flex: 1 }}>
|
|
<div style={{ height: 14, borderRadius: 4, background: "#f0ebe4", width: "60%", marginBottom: 6, animation: "shimmer 1.5s infinite", backgroundSize: "200% 100%" }} />
|
|
<div style={{ height: 12, borderRadius: 4, background: "#f0ebe4", width: "40%", marginBottom: 6, animation: "shimmer 1.5s infinite", backgroundSize: "200% 100%" }} />
|
|
<div style={{ height: 12, borderRadius: 4, background: "#f0ebe4", width: "50%", animation: "shimmer 1.5s infinite", backgroundSize: "200% 100%" }} />
|
|
</div>
|
|
</div>
|
|
<div style={{ height: 10, borderRadius: 4, background: "#f0ebe4", width: "30%", marginBottom: 8, animation: "shimmer 1.5s infinite", backgroundSize: "200% 100%" }} />
|
|
<div style={{ display: "flex", gap: 6, flexWrap: "wrap" }}>
|
|
{[1, 2, 3].map((n) => <div key={n} style={{ height: 22, width: 72, borderRadius: 99, background: "#f0ebe4", animation: "shimmer 1.5s infinite", backgroundSize: "200% 100%" }} />)}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
if (state.status === "error") {
|
|
return (
|
|
<div style={{
|
|
border: "1px solid #fca5a5",
|
|
borderRadius: 10,
|
|
padding: "1rem",
|
|
background: "#fef2f2",
|
|
color: "#dc2626",
|
|
fontSize: 13,
|
|
}}>
|
|
Failed to load profile: {state.message}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
const { data } = state;
|
|
const age = computeAge(data.dateOfBirth);
|
|
|
|
return (
|
|
<div style={{
|
|
border: "1px solid #e5e7eb",
|
|
borderRadius: 10,
|
|
padding: "1rem",
|
|
background: "#fff",
|
|
boxShadow: "0 1px 3px rgba(0,0,0,0.04)",
|
|
}}>
|
|
{/* Header */}
|
|
<div style={{ display: "flex", gap: "0.75rem", marginBottom: "0.75rem" }}>
|
|
<PetPhotoDisplay petId={petId} size={64} />
|
|
<div style={{ flex: 1, minWidth: 0 }}>
|
|
<strong style={{ fontSize: 16 }}>{data.name}</strong>
|
|
<div style={{ fontSize: 13, color: "#6b7280", marginTop: "0.15rem" }}>
|
|
{data.breed ?? "Unknown breed"}
|
|
{age && <span> · {age}</span>}
|
|
</div>
|
|
{data.weightKg != null && (
|
|
<div style={{ fontSize: 12, color: "#6b7280" }}>{data.weightKg} kg</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Coat type */}
|
|
{data.coatType && (
|
|
<div style={{ marginBottom: "0.6rem" }}>
|
|
<SectionLabel>COAT TYPE</SectionLabel>
|
|
<span style={{ fontSize: 12, padding: "0.15rem 0.5rem", background: "#f0fdf4", color: "#166534", border: "1px solid #bbf7d0", borderRadius: 99, fontWeight: 500 }}>
|
|
{data.coatType}
|
|
</span>
|
|
</div>
|
|
)}
|
|
|
|
{/* Temperament */}
|
|
{(data.temperamentScore != null || data.temperamentFlags.length > 0) && (
|
|
<div style={{ marginBottom: "0.6rem" }}>
|
|
<SectionLabel>TEMPERAMENT</SectionLabel>
|
|
<div style={{ display: "flex", alignItems: "center", gap: "0.5rem", flexWrap: "wrap" }}>
|
|
{data.temperamentScore != null && <TemperamentDots score={data.temperamentScore} />}
|
|
{data.temperamentFlags.map((flag) => (
|
|
<span key={flag} style={{ fontSize: 11, padding: "0.1rem 0.4rem", background: "#f5f3ff", color: "#6d28d9", border: "1px solid #ddd6fe", borderRadius: 4, fontWeight: 500 }}>
|
|
{flag}
|
|
</span>
|
|
))}
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* Medical alerts — most prominent */}
|
|
{data.medicalAlerts.length > 0 && (
|
|
<div style={{ marginBottom: "0.6rem" }}>
|
|
<SectionLabel>MEDICAL ALERTS</SectionLabel>
|
|
<div style={{ display: "flex", flexWrap: "wrap", gap: "0.35rem" }}>
|
|
{data.medicalAlerts.map((alert) => (
|
|
<MedicalAlertBadge key={alert.id} alert={alert} />
|
|
))}
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* Preferred cuts */}
|
|
{data.preferredCuts.length > 0 && (
|
|
<div style={{ marginBottom: "0.6rem" }}>
|
|
<SectionLabel>PREFERRED CUTS</SectionLabel>
|
|
<div style={{ display: "flex", flexWrap: "wrap", gap: "0.35rem" }}>
|
|
{data.preferredCuts.map((cut) => (
|
|
<span key={cut} style={{ fontSize: 11, padding: "0.15rem 0.45rem", background: "#f8fafc", color: "#374151", border: "1px solid #e2e8f0", borderRadius: 4 }}>
|
|
{cut}
|
|
</span>
|
|
))}
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* Recent visits */}
|
|
{data.recentVisits.length > 0 && (
|
|
<div style={{ marginBottom: "0.6rem" }}>
|
|
<SectionLabel>RECENT VISITS</SectionLabel>
|
|
{data.recentVisits.slice(0, 3).map((log) => (
|
|
<div key={log.id} style={{ fontSize: 12, color: "#374151", marginBottom: "0.2rem", borderLeft: "2px solid #e2e8f0", paddingLeft: "0.4rem" }}>
|
|
<span style={{ color: "#6b7280" }}>{new Date(log.groomedAt).toLocaleDateString()}</span>
|
|
{log.cutStyle && <span> · {log.cutStyle}</span>}
|
|
{log.staffId && <span> ·</span>}
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
|
|
{/* Next appointment */}
|
|
{data.nextAppointment && (
|
|
<div>
|
|
<SectionLabel>NEXT APPOINTMENT</SectionLabel>
|
|
<div style={{ fontSize: 12, color: "#374151" }}>
|
|
{new Date(data.nextAppointment.startTime).toLocaleDateString()} — {data.nextAppointment.serviceName}
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|