import { useState, useCallback, useEffect } from 'react'; import useEmblaCarousel from 'embla-carousel-react'; import { ExternalLink, Bed, Maximize2, PoundSterling, Clock, Building, Footprints, Bike, Train } from 'lucide-react'; import { Button } from './ui/button'; import type { PropertyProperties, POIDistanceInfo, POI } from '@/types'; import { formatDate, formatDuration } from '@/utils/format'; function TravelModeIcon({ mode }: { mode: string }) { switch (mode) { case 'WALK': return ; case 'BICYCLE': return ; case 'TRANSIT': return ; default: return null; } } function POIDistanceBadges({ distances }: { distances: POIDistanceInfo[] }) { if (!distances || distances.length === 0) return null; // Group by POI name const byPoi = new Map(); for (const d of distances) { const existing = byPoi.get(d.poi_name) || []; existing.push(d); byPoi.set(d.poi_name, existing); } return (
{Array.from(byPoi.entries()).map(([poiName, dists]) => (
{poiName}: {dists.map(d => ( {formatDuration(d.duration_seconds)} ))}
))}
); } const TRAVEL_MODES: Array<'WALK' | 'BICYCLE' | 'TRANSIT'> = ['WALK', 'BICYCLE', 'TRANSIT']; 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) : '—'} ); })}
))}
); } function CardCarousel({ photos, altText }: { photos: string[]; altText?: string }) { const [emblaRef, emblaApi] = useEmblaCarousel({ loop: true }); 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]); if (photos.length <= 1) { return ( {altText ); } return (
e.stopPropagation()}>
{photos.map((url, i) => (
{`Property
))}
{photos.map((_, i) => (
))}
); } interface PropertyCardProps { property: PropertyProperties; variant?: 'compact' | 'full'; isHighlighted?: boolean; avgPricePerSqm?: number; allPOIs?: POI[]; onClick?: () => void; } export function PropertyCard({ property, variant = 'compact', isHighlighted = false, avgPricePerSqm, allPOIs, onClick, }: PropertyCardProps) { const lastSeenDate = property.last_seen.split('T')[0]; const lastSeenDays = Math.round((Date.now() - new Date(lastSeenDate).getTime()) / (1000 * 60 * 60 * 24)); // Determine if this is a good deal const isGoodDeal = avgPricePerSqm && property.qmprice > 0 && property.qmprice < avgPricePerSqm * 0.9; const isExpensive = avgPricePerSqm && property.qmprice > avgPricePerSqm * 1.1; const priceIndicator = isGoodDeal ? { color: 'text-green-600 bg-green-50', label: 'Good deal' } : isExpensive ? { color: 'text-red-600 bg-red-50', label: 'Above avg' } : null; const handleClick = () => { onClick?.(); }; if (variant === 'compact') { return (
{/* Photo carousel */}
{(property.photos?.length || property.photo_thumbnail) ? ( ) : null}
{/* Details */}
£{property.total_price.toLocaleString()} {property.listing_type !== 'BUY' && ( /mo )}
{priceIndicator && ( {priceIndicator.label} )}
{property.rooms} {property.qm} m² £{property.qmprice}/m²
{lastSeenDays}d ago {property.agency}
); } // Full variant (for popup/detail view) return (
{/* Header with image and price */}
{property.photo_thumbnail && ( {`${property.rooms}-bed, )}
£{property.total_price.toLocaleString()} {property.listing_type !== 'BUY' && ( /mo )}
{priceIndicator && ( {priceIndicator.label} )}
{/* Stats grid */}
{property.rooms} bedrooms
{property.qm}
£{property.qmprice}/m²
{property.listing_type !== 'BUY' && property.available_from && (
Available {formatDate(property.available_from)}
)} {property.listing_type === 'BUY' && (
Seen {lastSeenDays}d ago
)}
{/* Agency and last seen */}
{property.agency} Seen {lastSeenDays} days ago
{/* POI Distances */} {allPOIs && allPOIs.length > 0 ? (
Travel times
) : property.poi_distances && property.poi_distances.length > 0 ? (
Travel times
) : null} {/* Price history */} {property.price_history.length > 1 && (
Price history
{property.price_history.slice(0, 5).map((entry) => (
{entry.last_seen.split('T')[0]} £{entry.price.toLocaleString()}
))}
)} {/* Actions */}
); }