Backend: include first 5 photo URLs from additional_info in GeoJSON streaming response, with fallback to photo_thumbnail. Frontend: replace single thumbnail with swipeable embla-carousel on compact cards. Remove window.open on card tap so clicking opens the detail bottom sheet instead of navigating to Rightmove.
351 lines
15 KiB
TypeScript
351 lines
15 KiB
TypeScript
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';
|
|
|
|
function formatDate(value: string): string {
|
|
const date = new Date(value);
|
|
if (isNaN(date.getTime())) return value;
|
|
return date.toLocaleDateString('en-GB', { day: 'numeric', month: 'short', year: 'numeric' });
|
|
}
|
|
|
|
function formatDuration(seconds: number): string {
|
|
const minutes = Math.round(seconds / 60);
|
|
if (minutes < 60) return `${minutes}m`;
|
|
const hours = Math.floor(minutes / 60);
|
|
const mins = minutes % 60;
|
|
return mins > 0 ? `${hours}h${mins}m` : `${hours}h`;
|
|
}
|
|
|
|
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 (
|
|
<div className="flex flex-wrap gap-1.5 mt-1.5">
|
|
{Array.from(byPoi.entries()).map(([poiName, dists]) => (
|
|
<div key={poiName} className="flex items-center gap-1 text-xs text-muted-foreground bg-muted/50 px-1.5 py-0.5 rounded">
|
|
<span className="font-medium">{poiName}:</span>
|
|
{dists.map(d => (
|
|
<span key={d.travel_mode} className="flex items-center gap-0.5" title={`${d.travel_mode} to ${poiName}`}>
|
|
<TravelModeIcon mode={d.travel_mode} />
|
|
{formatDuration(d.duration_seconds)}
|
|
</span>
|
|
))}
|
|
</div>
|
|
))}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
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 (
|
|
<div className="flex flex-wrap gap-1.5 mt-1.5">
|
|
{pois.map(poi => (
|
|
<div key={poi.id} className="flex items-center gap-1 text-xs text-muted-foreground bg-muted/50 px-1.5 py-0.5 rounded">
|
|
<span className="font-medium">{poi.name}:</span>
|
|
{TRAVEL_MODES.map(mode => {
|
|
const dist = distMap.get(`${poi.id}_${mode}`);
|
|
return (
|
|
<span key={mode} className="flex items-center gap-0.5" title={`${mode} to ${poi.name}`}>
|
|
<TravelModeIcon mode={mode} />
|
|
{dist ? formatDuration(dist.duration_seconds) : '—'}
|
|
</span>
|
|
);
|
|
})}
|
|
</div>
|
|
))}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function CardCarousel({ photos }: { photos: 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 (
|
|
<img
|
|
src={photos[0]}
|
|
alt="Property"
|
|
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}
|
|
alt={`Photo ${i + 1}`}
|
|
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>
|
|
);
|
|
}
|
|
|
|
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 (
|
|
<div
|
|
className={`flex gap-3 p-3 rounded-lg border transition-colors cursor-pointer hover:bg-muted/50 ${
|
|
isHighlighted ? 'ring-2 ring-primary bg-primary/5' : ''
|
|
}`}
|
|
onClick={handleClick}
|
|
>
|
|
{/* Photo carousel */}
|
|
<div className="w-24 h-24 rounded-md overflow-hidden flex-shrink-0 bg-muted">
|
|
{(property.photos?.length || property.photo_thumbnail) ? (
|
|
<CardCarousel photos={property.photos?.length ? property.photos : [property.photo_thumbnail]} />
|
|
) : null}
|
|
</div>
|
|
|
|
{/* Details */}
|
|
<div className="flex-1 min-w-0">
|
|
<div className="flex items-start justify-between gap-2">
|
|
<div className="font-semibold text-base truncate">
|
|
£{property.total_price.toLocaleString()}
|
|
{property.listing_type !== 'BUY' && (
|
|
<span className="text-muted-foreground font-normal text-sm">/mo</span>
|
|
)}
|
|
</div>
|
|
{priceIndicator && (
|
|
<span className={`text-xs px-1.5 py-0.5 rounded ${priceIndicator.color}`}>
|
|
{priceIndicator.label}
|
|
</span>
|
|
)}
|
|
</div>
|
|
|
|
<div className="flex items-center gap-3 mt-1 text-sm text-muted-foreground">
|
|
<span className="flex items-center gap-1">
|
|
<Bed className="h-3.5 w-3.5" />
|
|
{property.rooms}
|
|
</span>
|
|
<span className="flex items-center gap-1">
|
|
<Maximize2 className="h-3.5 w-3.5" />
|
|
{property.qm} m²
|
|
</span>
|
|
<span className="flex items-center gap-1">
|
|
£{property.qmprice}/m²
|
|
</span>
|
|
</div>
|
|
|
|
<div className="flex items-center gap-2 mt-1 text-xs text-muted-foreground">
|
|
<span className="flex items-center gap-1">
|
|
<Clock className="h-3 w-3" />
|
|
{lastSeenDays}d ago
|
|
</span>
|
|
<span className="truncate">{property.agency}</span>
|
|
</div>
|
|
<POIDistanceBadges distances={property.poi_distances || []} />
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// Full variant (for popup/detail view)
|
|
return (
|
|
<div className={`p-4 border rounded-lg ${isHighlighted ? 'ring-2 ring-primary' : ''}`}>
|
|
{/* Header with image and price */}
|
|
<div className="flex gap-4">
|
|
<a
|
|
href={property.url}
|
|
target="_blank"
|
|
rel="noopener noreferrer"
|
|
className="block w-32 h-24 rounded-md overflow-hidden flex-shrink-0 bg-muted hover:opacity-90 transition-opacity"
|
|
>
|
|
{property.photo_thumbnail && (
|
|
<img
|
|
src={property.photo_thumbnail}
|
|
alt="Property"
|
|
className="w-full h-full object-cover"
|
|
/>
|
|
)}
|
|
</a>
|
|
|
|
<div className="flex-1">
|
|
<div className="flex items-start justify-between">
|
|
<div>
|
|
<div className="font-semibold text-xl">
|
|
£{property.total_price.toLocaleString()}
|
|
{property.listing_type !== 'BUY' && (
|
|
<span className="text-muted-foreground font-normal text-sm">/mo</span>
|
|
)}
|
|
</div>
|
|
{priceIndicator && (
|
|
<span className={`inline-block mt-1 text-xs px-2 py-0.5 rounded ${priceIndicator.color}`}>
|
|
{priceIndicator.label}
|
|
</span>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Stats grid */}
|
|
<div className="grid grid-cols-2 gap-3 mt-4">
|
|
<div className="flex items-center gap-2 text-sm">
|
|
<Bed className="h-4 w-4 text-muted-foreground" />
|
|
<span><strong>{property.rooms}</strong> bedrooms</span>
|
|
</div>
|
|
<div className="flex items-center gap-2 text-sm">
|
|
<Maximize2 className="h-4 w-4 text-muted-foreground" />
|
|
<span><strong>{property.qm}</strong> m²</span>
|
|
</div>
|
|
<div className="flex items-center gap-2 text-sm">
|
|
<PoundSterling className="h-4 w-4 text-muted-foreground" />
|
|
<span><strong>£{property.qmprice}</strong>/m²</span>
|
|
</div>
|
|
{property.listing_type !== 'BUY' && property.available_from && (
|
|
<div className="flex items-center gap-2 text-sm">
|
|
<Clock className="h-4 w-4 text-muted-foreground" />
|
|
<span>Available <strong>{formatDate(property.available_from)}</strong></span>
|
|
</div>
|
|
)}
|
|
{property.listing_type === 'BUY' && (
|
|
<div className="flex items-center gap-2 text-sm">
|
|
<Clock className="h-4 w-4 text-muted-foreground" />
|
|
<span>Seen <strong>{lastSeenDays}d</strong> ago</span>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* Agency and last seen */}
|
|
<div className="flex items-center gap-2 mt-3 text-sm text-muted-foreground">
|
|
<Building className="h-4 w-4" />
|
|
<span>{property.agency}</span>
|
|
<span className="mx-1">•</span>
|
|
<span>Seen {lastSeenDays} days ago</span>
|
|
</div>
|
|
|
|
{/* POI Distances */}
|
|
{allPOIs && allPOIs.length > 0 ? (
|
|
<div className="mt-3 pt-3 border-t">
|
|
<div className="text-xs font-medium text-muted-foreground mb-1">Travel times</div>
|
|
<AllPOIDistances pois={allPOIs} distances={property.poi_distances} />
|
|
</div>
|
|
) : property.poi_distances && property.poi_distances.length > 0 ? (
|
|
<div className="mt-3 pt-3 border-t">
|
|
<div className="text-xs font-medium text-muted-foreground mb-1">Travel times</div>
|
|
<POIDistanceBadges distances={property.poi_distances} />
|
|
</div>
|
|
) : null}
|
|
|
|
{/* Price history */}
|
|
{property.price_history.length > 1 && (
|
|
<div className="mt-3 pt-3 border-t">
|
|
<div className="text-xs font-medium text-muted-foreground mb-1">Price history</div>
|
|
<div className="space-y-0.5">
|
|
{property.price_history.slice(0, 5).map((entry) => (
|
|
<div key={entry.id} className="text-sm flex justify-between">
|
|
<span className="text-muted-foreground">{entry.last_seen.split('T')[0]}</span>
|
|
<span>£{entry.price.toLocaleString()}</span>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* Actions */}
|
|
<div className="mt-4">
|
|
<Button asChild className="w-full">
|
|
<a href={property.url} target="_blank" rel="noopener noreferrer">
|
|
View Listing
|
|
<ExternalLink className="ml-2 h-4 w-4" />
|
|
</a>
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|