2026-02-08 13:16:32 +00:00
|
|
|
import { ExternalLink, Bed, Maximize2, PoundSterling, Clock, Building, Footprints, Bike, Train } from 'lucide-react';
|
2026-02-01 17:28:37 +00:00
|
|
|
import { Button } from './ui/button';
|
2026-02-08 13:16:32 +00:00
|
|
|
import type { PropertyProperties, POIDistanceInfo } from '@/types';
|
|
|
|
|
|
|
|
|
|
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>
|
|
|
|
|
);
|
|
|
|
|
}
|
2026-02-01 17:28:37 +00:00
|
|
|
|
|
|
|
|
interface PropertyCardProps {
|
|
|
|
|
property: PropertyProperties;
|
|
|
|
|
variant?: 'compact' | 'full';
|
|
|
|
|
isHighlighted?: boolean;
|
|
|
|
|
avgPricePerSqm?: number;
|
|
|
|
|
onClick?: () => void;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export function PropertyCard({
|
|
|
|
|
property,
|
|
|
|
|
variant = 'compact',
|
|
|
|
|
isHighlighted = false,
|
|
|
|
|
avgPricePerSqm,
|
|
|
|
|
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 = () => {
|
|
|
|
|
window.open(property.url, '_blank', 'noopener,noreferrer');
|
|
|
|
|
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}
|
|
|
|
|
>
|
|
|
|
|
{/* Thumbnail */}
|
|
|
|
|
<div className="w-20 h-20 rounded-md overflow-hidden flex-shrink-0 bg-muted">
|
|
|
|
|
{property.photo_thumbnail && (
|
|
|
|
|
<img
|
|
|
|
|
src={property.photo_thumbnail}
|
|
|
|
|
alt="Property"
|
|
|
|
|
className="w-full h-full object-cover"
|
|
|
|
|
/>
|
|
|
|
|
)}
|
|
|
|
|
</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()}
|
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-01 17:28:37 +00:00
|
|
|
</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>
|
2026-02-08 13:16:32 +00:00
|
|
|
<POIDistanceBadges distances={property.poi_distances || []} />
|
2026-02-01 17:28:37 +00:00
|
|
|
</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()}
|
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-01 17:28:37 +00:00
|
|
|
</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>
|
2026-02-01 19:13:29 +00:00
|
|
|
{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>{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>
|
|
|
|
|
)}
|
2026-02-01 17:28:37 +00:00
|
|
|
</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>
|
|
|
|
|
|
2026-02-08 13:16:32 +00:00
|
|
|
{/* POI Distances */}
|
|
|
|
|
{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>
|
|
|
|
|
)}
|
|
|
|
|
|
2026-02-01 17:28:37 +00:00
|
|
|
{/* 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>
|
|
|
|
|
);
|
|
|
|
|
}
|