| 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 }; |
|
|
| |
| 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; |
| |
| onReconsider?: (m: Match) => void; |
| |
| queryPhotos?: Photo[]; |
| queryLabel?: string; |
| |
| 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); |
| 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> |
| ); |
| } |
|
|