PawTrace / frontend /src /components /MatchCard.tsx
Elliott Duke
Site polish: hero illustration, About rewrite, copy and styling pass
9122959
Raw
History Blame Contribute Delete
7.71 kB
import { useState } from "react";
import { Link } from "react-router-dom";
import type { Match } from "../types";
import ConfidenceBar from "./ConfidenceBar";
type Photo = { id: number; url: string | null; thumb_url: string | null };
// Top-3 estimated breeds as a comma-joined label (owners don't enter breed — we estimate it).
export function estimatedBreeds(breeds?: string[] | null): string {
return breeds && breeds.length ? breeds.slice(0, 3).join(", ") : "Breed not estimated";
}
interface Props {
match: Match;
onConfirm?: (m: Match) => void;
onReject?: (m: Match) => void;
// For the "Not a match" list: move a rejected candidate back to pending for another look.
onReconsider?: (m: Match) => void;
// When provided, the photo modal compares these (the case's own dog) side-by-side with the match.
queryPhotos?: Photo[];
queryLabel?: string;
// When set, a confirmed match links here (reunion next-steps page).
reclaimTo?: string;
}
export default function MatchCard({
match,
onConfirm,
onReject,
onReconsider,
queryPhotos,
queryLabel,
reclaimTo,
}: Props) {
const [busy, setBusy] = useState(false);
const [gallery, setGallery] = useState(false);
const c = match.candidate;
const photos = c?.pictures ?? [];
const compare = (queryPhotos?.length ?? 0) > 0;
const pct = (Math.round(match.similarity_score * 1000) / 10).toFixed(1); // one decimal
const primary = photos.find((p) => p.is_primary) ?? photos[0];
const title = c?.name || c?.description || (c?.type === "known" ? "Lost dog" : "Found dog");
const zip = c?.current_zip || c?.last_known_zip;
async function act(fn?: (m: Match) => void) {
if (!fn) return;
setBusy(true);
try {
await fn(match);
} finally {
setBusy(false);
}
}
return (
<div className="card flex gap-4">
<button
type="button"
className="relative h-28 w-28 shrink-0 group"
onClick={() => photos.length && setGallery(true)}
title={photos.length ? (compare ? "Compare photos" : "View all photos") : undefined}
disabled={!photos.length}
>
<img
src={primary?.thumb_url || primary?.url || "/paw-placeholder.svg"}
alt={title}
className="h-28 w-28 rounded-md object-contain bg-gray-100 group-hover:ring-2 group-hover:ring-brand-300"
/>
{photos.length > 1 && (
<span className="badge absolute bottom-1 right-1 bg-black/60 text-white text-[10px]">
{photos.length} photos
</span>
)}
</button>
<div className="flex-1 min-w-0">
<div className="flex items-center justify-between gap-2">
<h3 className="font-semibold truncate">
#{match.rank} · {title}
</h3>
{match.status !== "pending" && (
<span
className={`badge ${
match.status === "confirmed" ? "bg-green-100 text-green-700" : "bg-gray-100 text-gray-600"
}`}
>
{match.status}
</span>
)}
</div>
<p className="text-xs text-gray-500 mb-2">
{estimatedBreeds(c?.estimated_breeds)}
{zip ? ` · near ${zip}` : ""}
</p>
{c?.current_location_detail && (
<p className="text-xs text-brand-700 mb-2">
Being held at <span className="font-medium">{c.current_location_detail}</span>
</p>
)}
{c?.estimated_breeds && c.estimated_breeds.length > 0 && (
<div className="flex flex-wrap gap-1 mb-2">
{c.estimated_breeds.slice(0, 3).map((b) => (
<span key={b} className="badge bg-brand-50 text-brand-700 capitalize" title="AI breed estimate (top 3)">
{b}
</span>
))}
</div>
)}
<ConfidenceBar score={match.similarity_score} />
{photos.length > 0 && (
<button
type="button"
className="btn-secondary mt-3"
onClick={() => setGallery(true)}
>
{compare ? "Examine photos side by side" : "Examine photos"}
</button>
)}
{(onConfirm || onReject) && match.status === "pending" && (
<div className="flex gap-2 mt-3">
<button className="btn-primary" disabled={busy} onClick={() => act(onConfirm)}>
Confirm match
</button>
<button className="btn-secondary" disabled={busy} onClick={() => act(onReject)}>
Not my dog
</button>
</div>
)}
{onReconsider && match.status === "rejected" && (
<button className="btn-secondary mt-3" disabled={busy} onClick={() => act(onReconsider)}>
Reconsider — move back to candidates
</button>
)}
{reclaimTo && match.status === "confirmed" && (
<Link to={reclaimTo} className="btn-primary inline-block mt-3">
Reunion next steps →
</Link>
)}
</div>
{gallery && (
<div
className="fixed inset-0 bg-black/50 flex items-center justify-center p-4 z-40"
role="dialog"
onClick={() => setGallery(false)}
>
<div
className="card max-w-4xl w-full max-h-[88vh] flex flex-col"
onClick={(e) => e.stopPropagation()}
>
<div className="flex items-start justify-between mb-3 shrink-0">
<div>
<h3 className="font-bold text-lg">
{compare ? `Compare · ${pct}% match` : title}
</h3>
<p className="text-xs text-gray-500">
{estimatedBreeds(c?.estimated_breeds)}
{zip ? ` · near ${zip}` : ""}
</p>
</div>
<button className="btn-secondary" onClick={() => setGallery(false)}>
Close
</button>
</div>
{compare ? (
// Each side scrolls on its own so you can line up one photo against the other.
<div className="grid grid-cols-2 gap-4 flex-1 min-h-0">
<PhotoColumn label={queryLabel ?? "Case dog"} photos={queryPhotos!} />
<PhotoColumn label={`${title} (match)`} photos={photos} />
</div>
) : (
<div className="flex-1 min-h-0 overflow-y-auto">
<div className="grid grid-cols-2 sm:grid-cols-3 gap-2">
{photos.map((p) => (
<img
key={p.id}
src={p.url || p.thumb_url || "/paw-placeholder.svg"}
alt={title}
className="w-full h-48 object-contain rounded-md bg-gray-100"
/>
))}
</div>
</div>
)}
</div>
</div>
)}
</div>
);
}
function PhotoColumn({ label, photos }: { label: string; photos: Photo[] }) {
return (
<div className="h-full min-h-0 overflow-y-auto pr-1">
<p className="text-sm font-medium text-gray-700 mb-2 sticky top-0 bg-white py-1 truncate z-10">
{label} <span className="text-gray-400 font-normal">({photos.length})</span>
</p>
<div className="space-y-2">
{photos.map((p) => (
<img
key={p.id}
src={p.url || p.thumb_url || "/paw-placeholder.svg"}
alt={label}
className="w-full h-44 object-contain rounded-md bg-gray-100"
/>
))}
{photos.length === 0 && <p className="text-gray-500 text-sm">No photos.</p>}
</div>
</div>
);
}