File size: 7,709 Bytes
de1e3fc 9122959 de1e3fc 9122959 de1e3fc 9122959 de1e3fc | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 | 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>
);
}
|