import { useState, useCallback, useEffect } from 'react'; import useEmblaCarousel from 'embla-carousel-react'; import { ExternalLink, Footprints, Bike, Train, ChevronLeft, ChevronRight } from 'lucide-react'; import type { PropertyProperties, POIDistanceInfo, POI } from '@/types'; import { formatDuration, formatPrice, formatInteger, formatPricePerSqmShort, isFiniteNumber, EM_DASH } from '@/utils/format'; function TravelModeIcon({ mode }: { mode: string }) { switch (mode) { case 'WALK': return ; case 'BICYCLE': return ; case 'TRANSIT': return ; default: return null; } } const TRAVEL_MODES: Array<'WALK' | 'BICYCLE' | 'TRANSIT'> = ['WALK', 'BICYCLE', 'TRANSIT']; function POIDistanceBadges({ distances }: { distances: POIDistanceInfo[] }) { if (!distances || distances.length === 0) return null; // Group by POI name, indexing by travel_mode for consistent rendering. const byPoi = new Map>(); for (const d of distances) { if (!byPoi.has(d.poi_name)) byPoi.set(d.poi_name, new Map()); byPoi.get(d.poi_name)!.set(d.travel_mode, d); } return (
{Array.from(byPoi.entries()).map(([poiName, dists]) => (
{poiName}: {TRAVEL_MODES.map(mode => { const d = dists.get(mode); return ( {d ? formatDuration(d.duration_seconds) : EM_DASH} ); })}
))}
); } function AllPOIDistances({ pois, distances }: { pois: POI[]; distances?: POIDistanceInfo[] }) { // Index distances by poi_id + travel_mode for O(1) lookup const distMap = new Map(); if (distances) { for (const d of distances) { distMap.set(`${d.poi_id}_${d.travel_mode}`, d); } } return (
{pois.map(poi => (
{poi.name}: {TRAVEL_MODES.map(mode => { const dist = distMap.get(`${poi.id}_${mode}`); return ( {dist ? formatDuration(dist.duration_seconds) : '\u2014'} ); })}
))}
); } function CardCarousel({ photos, altText }: { photos: string[]; altText?: string }) { // Only loop when there's more than one image (single-image carousels should // be static — mirrors PhotoCarousel B26). const hasMultiple = photos.length > 1; const [emblaRef, emblaApi] = useEmblaCarousel({ loop: hasMultiple }); const [selectedIndex, setSelectedIndex] = useState(0); const onSelect = useCallback(() => { if (!emblaApi) return; setSelectedIndex(emblaApi.selectedScrollSnap()); }, [emblaApi]); useEffect(() => { if (!emblaApi) return; emblaApi.on('select', onSelect); return () => { emblaApi.off('select', onSelect); }; }, [emblaApi, onSelect]); // Keyboard nav for the card carousel (B16). Listener is scoped to the // embla root so it only fires when the user focuses this carousel. useEffect(() => { if (!emblaApi || !hasMultiple) return; const root = emblaApi.rootNode(); if (!root) return; if (root.tabIndex === -1) { root.tabIndex = 0; } const handleKey = (e: KeyboardEvent) => { if (e.key === 'ArrowLeft') { e.preventDefault(); emblaApi.scrollPrev(); } else if (e.key === 'ArrowRight') { e.preventDefault(); emblaApi.scrollNext(); } }; root.addEventListener('keydown', handleKey); return () => { root.removeEventListener('keydown', handleKey); }; }, [emblaApi, hasMultiple]); if (!hasMultiple) { return ( {altText ); } return (
e.stopPropagation()}>
{photos.map((url, i) => (
{`Property
))}
{/* Prev / next click targets — visible on hover, always available for keyboard via tabbable buttons. */}
{photos.map((_, i) => (
))}
); } interface PropertyCardProps { property: PropertyProperties; variant?: 'compact' | 'full'; isHighlighted?: boolean; avgPricePerSqm?: number; allPOIs?: POI[]; onClick?: () => void; // Optional per-card color matching the heatmap gradient for the active map // metric (preserves the "color code" when multiple properties are visible // at the same place). Rendered as a left-edge stripe on the full variant. metricColor?: string; } export function PropertyCard({ property, variant = 'compact', isHighlighted = false, avgPricePerSqm, allPOIs, onClick, metricColor, }: PropertyCardProps) { // BUY listings may have null numeric / date fields; render "—" at the JSX leaf // when the source is null/undefined/non-finite so the user can't mistake a missing // value for a real £0 / 0 m². const lastSeenRaw = property.last_seen; const lastSeenDate = typeof lastSeenRaw === 'string' ? lastSeenRaw.split('T')[0] : null; const lastSeenTime = lastSeenDate ? new Date(lastSeenDate).getTime() : NaN; const lastSeenDaysRaw = Number.isFinite(lastSeenTime) ? Math.round((Date.now() - lastSeenTime) / (1000 * 60 * 60 * 24)) : null; // Clamp future timestamps to 0 so we don't render "-7d ago" for stale BUY rows. const lastSeenDays = lastSeenDaysRaw !== null ? Math.max(0, lastSeenDaysRaw) : null; // Coerced numerics used only where a number is structurally required (alt text, // boolean comparisons). All visible numeric leaves use the format helpers. const safeNum = (v: unknown): number => (typeof v === 'number' && Number.isFinite(v) ? v : 0); const safeTotalPrice = safeNum(property.total_price); const safeQm = safeNum(property.qm); const safeRooms = safeNum(property.rooms); // Determine if this is a good deal (guard requires a finite qmprice > 0) const qmpriceForCompare = isFiniteNumber(property.qmprice) ? property.qmprice : null; const isGoodDeal = avgPricePerSqm && qmpriceForCompare !== null && qmpriceForCompare > 0 && qmpriceForCompare < avgPricePerSqm * 0.9; const isExpensive = avgPricePerSqm && qmpriceForCompare !== null && qmpriceForCompare > avgPricePerSqm * 1.1; const priceIndicator = isGoodDeal ? { dotColor: 'bg-[var(--deal-good)]', label: 'Good deal' } : isExpensive ? { dotColor: 'bg-[var(--deal-above)]', label: 'Above avg' } : null; const handleClick = () => { onClick?.(); }; if (variant === 'compact') { return (
{/* Photo carousel */}
{(property.photos?.length || property.photo_thumbnail) ? ( ) : null}
{/* Details */}
{/* Price */}
{formatPrice(property.total_price)} {property.listing_type !== 'BUY' && isFiniteNumber(property.total_price) && ( /mo )} {priceIndicator && ( )} {priceIndicator && ( {priceIndicator.label} )}
{/* Key metrics on one line */}
{formatInteger(property.rooms)}bed · {formatInteger(property.qm)} m² · {formatPricePerSqmShort(property.qmprice)}
{/* Agency + freshness */}
{property.agency} {lastSeenDays !== null && ( <> · {lastSeenDays}d ago )}
{/* POI badges */}
); } // Full variant return (
{/* Image section with 16:10 aspect ratio */}
{(property.photos?.length || property.photo_thumbnail) ? ( ) : null} {/* Overlay buttons: heart + external link */}
e.stopPropagation()}>
{/* Content below image */}
{/* Price as dominant element */}
{formatPrice(property.total_price)} {property.listing_type !== 'BUY' && isFiniteNumber(property.total_price) && ( /mo )} {priceIndicator && ( )} {priceIndicator && ( {priceIndicator.label} )}
{/* Key metrics on one line */}
{formatInteger(property.rooms)}bed · {formatInteger(property.qm)} m² · {formatPricePerSqmShort(property.qmprice)}
{/* Location */} {property.city && (
{property.city}
)} {/* POI travel times */} {allPOIs && allPOIs.length > 0 ? ( ) : property.poi_distances && property.poi_distances.length > 0 ? ( ) : null} {/* Agency + freshness */}
{property.agency} {lastSeenDays !== null && ( <> · {lastSeenDays}d ago )}
); }