2026-02-21 19:19:32 +00:00
|
|
|
import { useState, useCallback, useEffect } from 'react';
|
|
|
|
|
import useEmblaCarousel from 'embla-carousel-react';
|
2026-02-28 16:23:36 +00:00
|
|
|
import { ExternalLink, Footprints, Bike, Train } from 'lucide-react';
|
2026-02-08 15:11:21 +00:00
|
|
|
import type { PropertyProperties, POIDistanceInfo, POI } from '@/types';
|
2026-02-28 16:21:17 +00:00
|
|
|
import { formatDuration } from '@/utils/format';
|
2026-02-08 13:16:32 +00:00
|
|
|
|
|
|
|
|
function TravelModeIcon({ mode }: { mode: string }) {
|
|
|
|
|
switch (mode) {
|
|
|
|
|
case 'WALK': return <Footprints className="h-3 w-3" />;
|
|
|
|
|
case 'BICYCLE': return <Bike className="h-3 w-3" />;
|
|
|
|
|
case 'TRANSIT': return <Train className="h-3 w-3" />;
|
|
|
|
|
default: return null;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function POIDistanceBadges({ distances }: { distances: POIDistanceInfo[] }) {
|
|
|
|
|
if (!distances || distances.length === 0) return null;
|
|
|
|
|
|
|
|
|
|
// Group by POI name
|
|
|
|
|
const byPoi = new Map<string, POIDistanceInfo[]>();
|
|
|
|
|
for (const d of distances) {
|
|
|
|
|
const existing = byPoi.get(d.poi_name) || [];
|
|
|
|
|
existing.push(d);
|
|
|
|
|
byPoi.set(d.poi_name, existing);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return (
|
2026-02-28 16:21:17 +00:00
|
|
|
<div className="flex flex-wrap gap-1 mt-1.5">
|
2026-02-08 13:16:32 +00:00
|
|
|
{Array.from(byPoi.entries()).map(([poiName, dists]) => (
|
2026-02-28 16:21:17 +00:00
|
|
|
<div key={poiName} className="inline-flex items-center gap-1 text-xs text-muted-foreground">
|
2026-02-08 13:16:32 +00:00
|
|
|
<span className="font-medium">{poiName}:</span>
|
|
|
|
|
{dists.map(d => (
|
2026-02-28 16:21:17 +00:00
|
|
|
<span key={d.travel_mode} className="inline-flex items-center gap-0.5" title={`${d.travel_mode} to ${poiName}`}>
|
2026-02-08 13:16:32 +00:00
|
|
|
<TravelModeIcon mode={d.travel_mode} />
|
|
|
|
|
{formatDuration(d.duration_seconds)}
|
|
|
|
|
</span>
|
|
|
|
|
))}
|
|
|
|
|
</div>
|
|
|
|
|
))}
|
|
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
}
|
2026-02-01 17:28:37 +00:00
|
|
|
|
2026-02-08 15:11:21 +00:00
|
|
|
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<string, POIDistanceInfo>();
|
|
|
|
|
if (distances) {
|
|
|
|
|
for (const d of distances) {
|
|
|
|
|
distMap.set(`${d.poi_id}_${d.travel_mode}`, d);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return (
|
2026-02-28 16:21:17 +00:00
|
|
|
<div className="flex flex-wrap gap-1 mt-1.5">
|
2026-02-08 15:11:21 +00:00
|
|
|
{pois.map(poi => (
|
2026-02-28 16:21:17 +00:00
|
|
|
<div key={poi.id} className="inline-flex items-center gap-1 text-xs text-muted-foreground">
|
2026-02-08 15:11:21 +00:00
|
|
|
<span className="font-medium">{poi.name}:</span>
|
|
|
|
|
{TRAVEL_MODES.map(mode => {
|
|
|
|
|
const dist = distMap.get(`${poi.id}_${mode}`);
|
|
|
|
|
return (
|
2026-02-28 16:21:17 +00:00
|
|
|
<span key={mode} className="inline-flex items-center gap-0.5" title={`${mode} to ${poi.name}`}>
|
2026-02-08 15:11:21 +00:00
|
|
|
<TravelModeIcon mode={mode} />
|
2026-02-28 16:21:17 +00:00
|
|
|
{dist ? formatDuration(dist.duration_seconds) : '\u2014'}
|
2026-02-08 15:11:21 +00:00
|
|
|
</span>
|
|
|
|
|
);
|
|
|
|
|
})}
|
|
|
|
|
</div>
|
|
|
|
|
))}
|
|
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-22 18:47:09 +00:00
|
|
|
function CardCarousel({ photos, altText }: { photos: string[]; altText?: string }) {
|
2026-02-21 19:19:32 +00:00
|
|
|
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 (
|
|
|
|
|
<img
|
|
|
|
|
src={photos[0]}
|
2026-02-22 18:47:09 +00:00
|
|
|
alt={altText || "Property"}
|
2026-02-21 19:19:32 +00:00
|
|
|
className="w-full h-full object-cover"
|
|
|
|
|
loading="lazy"
|
|
|
|
|
/>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
<div className="relative w-full h-full" onClick={e => e.stopPropagation()}>
|
|
|
|
|
<div className="overflow-hidden h-full" ref={emblaRef}>
|
|
|
|
|
<div className="flex h-full">
|
|
|
|
|
{photos.map((url, i) => (
|
|
|
|
|
<div key={i} className="flex-[0_0_100%] min-w-0 h-full">
|
|
|
|
|
<img
|
|
|
|
|
src={url}
|
2026-02-22 18:47:09 +00:00
|
|
|
alt={`Property photo ${i + 1}`}
|
2026-02-21 19:19:32 +00:00
|
|
|
className="w-full h-full object-cover"
|
|
|
|
|
loading="lazy"
|
|
|
|
|
/>
|
|
|
|
|
</div>
|
|
|
|
|
))}
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
<div className="absolute bottom-1 left-0 right-0 flex justify-center gap-1">
|
|
|
|
|
{photos.map((_, i) => (
|
|
|
|
|
<div
|
|
|
|
|
key={i}
|
|
|
|
|
className={`w-1 h-1 rounded-full ${
|
|
|
|
|
i === selectedIndex ? 'bg-white' : 'bg-white/40'
|
|
|
|
|
}`}
|
|
|
|
|
/>
|
|
|
|
|
))}
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-01 17:28:37 +00:00
|
|
|
interface PropertyCardProps {
|
|
|
|
|
property: PropertyProperties;
|
|
|
|
|
variant?: 'compact' | 'full';
|
|
|
|
|
isHighlighted?: boolean;
|
|
|
|
|
avgPricePerSqm?: number;
|
2026-02-08 15:11:21 +00:00
|
|
|
allPOIs?: POI[];
|
2026-02-01 17:28:37 +00:00
|
|
|
onClick?: () => void;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export function PropertyCard({
|
|
|
|
|
property,
|
|
|
|
|
variant = 'compact',
|
|
|
|
|
isHighlighted = false,
|
|
|
|
|
avgPricePerSqm,
|
2026-02-08 15:11:21 +00:00
|
|
|
allPOIs,
|
2026-02-01 17:28:37 +00:00
|
|
|
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
|
2026-02-28 16:21:17 +00:00
|
|
|
? { dotColor: 'bg-[var(--deal-good)]', label: 'Good deal' }
|
2026-02-01 17:28:37 +00:00
|
|
|
: isExpensive
|
2026-02-28 16:21:17 +00:00
|
|
|
? { dotColor: 'bg-[var(--deal-above)]', label: 'Above avg' }
|
2026-02-01 17:28:37 +00:00
|
|
|
: null;
|
|
|
|
|
|
|
|
|
|
const handleClick = () => {
|
|
|
|
|
onClick?.();
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
if (variant === 'compact') {
|
|
|
|
|
return (
|
|
|
|
|
<div
|
2026-02-28 16:21:17 +00:00
|
|
|
className={`flex gap-3 p-3 rounded-lg border bg-card shadow-sm hover:shadow-md transition-shadow cursor-pointer ${
|
2026-02-01 17:28:37 +00:00
|
|
|
isHighlighted ? 'ring-2 ring-primary bg-primary/5' : ''
|
|
|
|
|
}`}
|
|
|
|
|
onClick={handleClick}
|
|
|
|
|
>
|
2026-02-21 19:19:32 +00:00
|
|
|
{/* Photo carousel */}
|
|
|
|
|
<div className="w-24 h-24 rounded-md overflow-hidden flex-shrink-0 bg-muted">
|
|
|
|
|
{(property.photos?.length || property.photo_thumbnail) ? (
|
2026-02-22 18:47:09 +00:00
|
|
|
<CardCarousel
|
|
|
|
|
photos={property.photos?.length ? property.photos : [property.photo_thumbnail]}
|
|
|
|
|
altText={`${property.rooms}-bed, ${property.qm}m², £${property.total_price.toLocaleString()}`}
|
|
|
|
|
/>
|
2026-02-21 19:19:32 +00:00
|
|
|
) : null}
|
2026-02-01 17:28:37 +00:00
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
{/* Details */}
|
|
|
|
|
<div className="flex-1 min-w-0">
|
2026-02-28 16:21:17 +00:00
|
|
|
{/* Price */}
|
|
|
|
|
<div className="flex items-center gap-1.5">
|
|
|
|
|
<span className="text-lg font-bold tracking-tight">
|
2026-02-01 17:28:37 +00:00
|
|
|
£{property.total_price.toLocaleString()}
|
2026-02-01 19:13:29 +00:00
|
|
|
{property.listing_type !== 'BUY' && (
|
|
|
|
|
<span className="text-muted-foreground font-normal text-sm">/mo</span>
|
|
|
|
|
)}
|
2026-02-28 16:21:17 +00:00
|
|
|
</span>
|
2026-02-01 17:28:37 +00:00
|
|
|
{priceIndicator && (
|
2026-02-28 16:21:17 +00:00
|
|
|
<span className={`w-2 h-2 rounded-full shrink-0 ${priceIndicator.dotColor}`} title={priceIndicator.label} />
|
|
|
|
|
)}
|
|
|
|
|
{priceIndicator && (
|
|
|
|
|
<span className="text-xs text-muted-foreground">{priceIndicator.label}</span>
|
2026-02-01 17:28:37 +00:00
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
|
2026-02-28 16:21:17 +00:00
|
|
|
{/* Key metrics on one line */}
|
|
|
|
|
<div className="flex items-center gap-1 text-sm text-muted-foreground mt-0.5">
|
|
|
|
|
<span>{property.rooms}</span><span>bed</span>
|
|
|
|
|
<span>·</span>
|
|
|
|
|
<span>{property.qm} m²</span>
|
|
|
|
|
<span>·</span>
|
|
|
|
|
<span>£{property.qmprice}/m²</span>
|
2026-02-01 17:28:37 +00:00
|
|
|
</div>
|
|
|
|
|
|
2026-02-28 16:21:17 +00:00
|
|
|
{/* Agency + freshness */}
|
|
|
|
|
<div className="flex items-center gap-1 text-xs text-muted-foreground mt-1">
|
|
|
|
|
<span>{property.agency}</span>
|
|
|
|
|
<span>·</span>
|
|
|
|
|
<span>{lastSeenDays}d ago</span>
|
2026-02-01 17:28:37 +00:00
|
|
|
</div>
|
2026-02-28 16:21:17 +00:00
|
|
|
|
|
|
|
|
{/* POI badges */}
|
2026-02-08 13:16:32 +00:00
|
|
|
<POIDistanceBadges distances={property.poi_distances || []} />
|
2026-02-01 17:28:37 +00:00
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-28 16:21:17 +00:00
|
|
|
// Full variant
|
2026-02-01 17:28:37 +00:00
|
|
|
return (
|
2026-02-28 16:21:17 +00:00
|
|
|
<div
|
|
|
|
|
className={`rounded-lg border bg-card shadow-sm hover:shadow-md transition-shadow overflow-hidden ${
|
|
|
|
|
isHighlighted ? 'ring-2 ring-primary' : ''
|
|
|
|
|
}`}
|
|
|
|
|
onClick={handleClick}
|
|
|
|
|
>
|
|
|
|
|
{/* Image section with 16:10 aspect ratio */}
|
|
|
|
|
<div className="relative aspect-[16/10] bg-muted">
|
|
|
|
|
{(property.photos?.length || property.photo_thumbnail) ? (
|
|
|
|
|
<CardCarousel
|
|
|
|
|
photos={property.photos?.length ? property.photos : [property.photo_thumbnail]}
|
|
|
|
|
altText={`${property.rooms}-bed, ${property.qm}m², £${property.total_price.toLocaleString()}`}
|
|
|
|
|
/>
|
|
|
|
|
) : null}
|
2026-02-01 17:28:37 +00:00
|
|
|
|
2026-02-28 16:21:17 +00:00
|
|
|
{/* Overlay buttons: heart + external link */}
|
|
|
|
|
<div className="absolute top-2 right-2 flex items-center gap-1.5" onClick={e => e.stopPropagation()}>
|
|
|
|
|
<a
|
|
|
|
|
href={property.url}
|
|
|
|
|
target="_blank"
|
|
|
|
|
rel="noopener noreferrer"
|
|
|
|
|
className="flex items-center justify-center w-8 h-8 rounded-full bg-black/40 backdrop-blur-sm text-white hover:bg-black/60 transition-colors"
|
|
|
|
|
title="View on Rightmove"
|
|
|
|
|
>
|
|
|
|
|
<ExternalLink className="h-4 w-4" />
|
|
|
|
|
</a>
|
2026-02-01 17:28:37 +00:00
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
|
2026-02-28 16:21:17 +00:00
|
|
|
{/* Content below image */}
|
|
|
|
|
<div className="p-4 space-y-2">
|
|
|
|
|
{/* Price as dominant element */}
|
|
|
|
|
<div className="flex items-center gap-1.5">
|
|
|
|
|
<span className="text-lg font-bold tracking-tight">
|
|
|
|
|
£{property.total_price.toLocaleString()}
|
|
|
|
|
{property.listing_type !== 'BUY' && (
|
|
|
|
|
<span className="text-muted-foreground font-normal text-sm">/mo</span>
|
|
|
|
|
)}
|
|
|
|
|
</span>
|
|
|
|
|
{priceIndicator && (
|
|
|
|
|
<span className={`w-2 h-2 rounded-full shrink-0 ${priceIndicator.dotColor}`} title={priceIndicator.label} />
|
|
|
|
|
)}
|
|
|
|
|
{priceIndicator && (
|
|
|
|
|
<span className="text-xs text-muted-foreground">{priceIndicator.label}</span>
|
|
|
|
|
)}
|
2026-02-01 17:28:37 +00:00
|
|
|
</div>
|
2026-02-28 16:21:17 +00:00
|
|
|
|
|
|
|
|
{/* Key metrics on one line */}
|
|
|
|
|
<div className="flex items-center gap-1 text-sm text-muted-foreground">
|
|
|
|
|
<span>{property.rooms}</span><span>bed</span>
|
|
|
|
|
<span>·</span>
|
|
|
|
|
<span>{property.qm} m²</span>
|
|
|
|
|
<span>·</span>
|
|
|
|
|
<span>£{property.qmprice}/m²</span>
|
2026-02-01 17:28:37 +00:00
|
|
|
</div>
|
|
|
|
|
|
2026-02-28 16:21:17 +00:00
|
|
|
{/* Location */}
|
|
|
|
|
{property.city && (
|
|
|
|
|
<div className="text-sm font-medium">{property.city}</div>
|
|
|
|
|
)}
|
2026-02-01 17:28:37 +00:00
|
|
|
|
2026-02-28 16:21:17 +00:00
|
|
|
{/* POI travel times */}
|
|
|
|
|
{allPOIs && allPOIs.length > 0 ? (
|
2026-02-08 15:11:21 +00:00
|
|
|
<AllPOIDistances pois={allPOIs} distances={property.poi_distances} />
|
2026-02-28 16:21:17 +00:00
|
|
|
) : property.poi_distances && property.poi_distances.length > 0 ? (
|
2026-02-08 13:16:32 +00:00
|
|
|
<POIDistanceBadges distances={property.poi_distances} />
|
2026-02-28 16:21:17 +00:00
|
|
|
) : null}
|
2026-02-08 13:16:32 +00:00
|
|
|
|
2026-02-28 16:21:17 +00:00
|
|
|
{/* Agency + freshness */}
|
|
|
|
|
<div className="flex items-center gap-1 text-xs text-muted-foreground">
|
|
|
|
|
<span>{property.agency}</span>
|
|
|
|
|
<span>·</span>
|
|
|
|
|
<span>{lastSeenDays}d ago</span>
|
2026-02-01 17:28:37 +00:00
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
}
|