wrongmove/frontend/src/components/PropertyCard.tsx

342 lines
14 KiB
TypeScript
Raw Normal View History

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 <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, 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 (
<img
src={photos[0]}
alt={altText || "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={`Property 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]}
altText={`${property.rooms}-bed, ${property.qm}m², £${property.total_price.toLocaleString()}`}
/>
) : 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.rooms}-bed, ${property.qm}m², £${property.total_price.toLocaleString()}`}
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>
);
}