style: redesign PropertyCard with better visual hierarchy
This commit is contained in:
parent
be2f0ef282
commit
812bfece4a
2 changed files with 132 additions and 165 deletions
|
|
@ -1,9 +1,8 @@
|
||||||
import { useState, useCallback, useEffect } from 'react';
|
import { useState, useCallback, useEffect } from 'react';
|
||||||
import useEmblaCarousel from 'embla-carousel-react';
|
import useEmblaCarousel from 'embla-carousel-react';
|
||||||
import { ExternalLink, Bed, Maximize2, PoundSterling, Clock, Building, Footprints, Bike, Train } from 'lucide-react';
|
import { ExternalLink, Heart, Bed, Maximize2, Clock, Footprints, Bike, Train } from 'lucide-react';
|
||||||
import { Button } from './ui/button';
|
|
||||||
import type { PropertyProperties, POIDistanceInfo, POI } from '@/types';
|
import type { PropertyProperties, POIDistanceInfo, POI } from '@/types';
|
||||||
import { formatDate, formatDuration } from '@/utils/format';
|
import { formatDuration } from '@/utils/format';
|
||||||
|
|
||||||
function TravelModeIcon({ mode }: { mode: string }) {
|
function TravelModeIcon({ mode }: { mode: string }) {
|
||||||
switch (mode) {
|
switch (mode) {
|
||||||
|
|
@ -26,12 +25,12 @@ function POIDistanceBadges({ distances }: { distances: POIDistanceInfo[] }) {
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-wrap gap-1.5 mt-1.5">
|
<div className="flex flex-wrap gap-1 mt-1.5">
|
||||||
{Array.from(byPoi.entries()).map(([poiName, dists]) => (
|
{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">
|
<div key={poiName} className="inline-flex items-center gap-1 text-xs text-muted-foreground">
|
||||||
<span className="font-medium">{poiName}:</span>
|
<span className="font-medium">{poiName}:</span>
|
||||||
{dists.map(d => (
|
{dists.map(d => (
|
||||||
<span key={d.travel_mode} className="flex items-center gap-0.5" title={`${d.travel_mode} to ${poiName}`}>
|
<span key={d.travel_mode} className="inline-flex items-center gap-0.5" title={`${d.travel_mode} to ${poiName}`}>
|
||||||
<TravelModeIcon mode={d.travel_mode} />
|
<TravelModeIcon mode={d.travel_mode} />
|
||||||
{formatDuration(d.duration_seconds)}
|
{formatDuration(d.duration_seconds)}
|
||||||
</span>
|
</span>
|
||||||
|
|
@ -54,16 +53,16 @@ function AllPOIDistances({ pois, distances }: { pois: POI[]; distances?: POIDist
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-wrap gap-1.5 mt-1.5">
|
<div className="flex flex-wrap gap-1 mt-1.5">
|
||||||
{pois.map(poi => (
|
{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">
|
<div key={poi.id} className="inline-flex items-center gap-1 text-xs text-muted-foreground">
|
||||||
<span className="font-medium">{poi.name}:</span>
|
<span className="font-medium">{poi.name}:</span>
|
||||||
{TRAVEL_MODES.map(mode => {
|
{TRAVEL_MODES.map(mode => {
|
||||||
const dist = distMap.get(`${poi.id}_${mode}`);
|
const dist = distMap.get(`${poi.id}_${mode}`);
|
||||||
return (
|
return (
|
||||||
<span key={mode} className="flex items-center gap-0.5" title={`${mode} to ${poi.name}`}>
|
<span key={mode} className="inline-flex items-center gap-0.5" title={`${mode} to ${poi.name}`}>
|
||||||
<TravelModeIcon mode={mode} />
|
<TravelModeIcon mode={mode} />
|
||||||
{dist ? formatDuration(dist.duration_seconds) : '—'}
|
{dist ? formatDuration(dist.duration_seconds) : '\u2014'}
|
||||||
</span>
|
</span>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
|
@ -154,9 +153,9 @@ export function PropertyCard({
|
||||||
const isExpensive = avgPricePerSqm && property.qmprice > avgPricePerSqm * 1.1;
|
const isExpensive = avgPricePerSqm && property.qmprice > avgPricePerSqm * 1.1;
|
||||||
|
|
||||||
const priceIndicator = isGoodDeal
|
const priceIndicator = isGoodDeal
|
||||||
? { color: 'text-green-600 bg-green-50', label: 'Good deal' }
|
? { dotColor: 'bg-[var(--deal-good)]', label: 'Good deal' }
|
||||||
: isExpensive
|
: isExpensive
|
||||||
? { color: 'text-red-600 bg-red-50', label: 'Above avg' }
|
? { dotColor: 'bg-[var(--deal-above)]', label: 'Above avg' }
|
||||||
: null;
|
: null;
|
||||||
|
|
||||||
const handleClick = () => {
|
const handleClick = () => {
|
||||||
|
|
@ -166,7 +165,7 @@ export function PropertyCard({
|
||||||
if (variant === 'compact') {
|
if (variant === 'compact') {
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={`flex gap-3 p-3 rounded-lg border transition-colors cursor-pointer hover:bg-muted/50 ${
|
className={`flex gap-3 p-3 rounded-lg border bg-card shadow-sm hover:shadow-md transition-shadow cursor-pointer ${
|
||||||
isHighlighted ? 'ring-2 ring-primary bg-primary/5' : ''
|
isHighlighted ? 'ring-2 ring-primary bg-primary/5' : ''
|
||||||
}`}
|
}`}
|
||||||
onClick={handleClick}
|
onClick={handleClick}
|
||||||
|
|
@ -183,158 +182,121 @@ export function PropertyCard({
|
||||||
|
|
||||||
{/* Details */}
|
{/* Details */}
|
||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0">
|
||||||
<div className="flex items-start justify-between gap-2">
|
{/* Price */}
|
||||||
<div className="font-semibold text-base truncate">
|
<div className="flex items-center gap-1.5">
|
||||||
|
<span className="text-lg font-bold tracking-tight">
|
||||||
£{property.total_price.toLocaleString()}
|
£{property.total_price.toLocaleString()}
|
||||||
{property.listing_type !== 'BUY' && (
|
{property.listing_type !== 'BUY' && (
|
||||||
<span className="text-muted-foreground font-normal text-sm">/mo</span>
|
<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>
|
</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>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center gap-3 mt-1 text-sm text-muted-foreground">
|
{/* Key metrics on one line */}
|
||||||
<span className="flex items-center gap-1">
|
<div className="flex items-center gap-1 text-sm text-muted-foreground mt-0.5">
|
||||||
<Bed className="h-3.5 w-3.5" />
|
<span>{property.rooms}</span><span>bed</span>
|
||||||
{property.rooms}
|
<span>·</span>
|
||||||
</span>
|
<span>{property.qm} m²</span>
|
||||||
<span className="flex items-center gap-1">
|
<span>·</span>
|
||||||
<Maximize2 className="h-3.5 w-3.5" />
|
<span>£{property.qmprice}/m²</span>
|
||||||
{property.qm} m²
|
|
||||||
</span>
|
|
||||||
<span className="flex items-center gap-1">
|
|
||||||
£{property.qmprice}/m²
|
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center gap-2 mt-1 text-xs text-muted-foreground">
|
{/* Agency + freshness */}
|
||||||
<span className="flex items-center gap-1">
|
<div className="flex items-center gap-1 text-xs text-muted-foreground mt-1">
|
||||||
<Clock className="h-3 w-3" />
|
<span>{property.agency}</span>
|
||||||
{lastSeenDays}d ago
|
<span>·</span>
|
||||||
</span>
|
<span>{lastSeenDays}d ago</span>
|
||||||
<span className="truncate">{property.agency}</span>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* POI badges */}
|
||||||
<POIDistanceBadges distances={property.poi_distances || []} />
|
<POIDistanceBadges distances={property.poi_distances || []} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Full variant (for popup/detail view)
|
// Full variant
|
||||||
return (
|
return (
|
||||||
<div className={`p-4 border rounded-lg ${isHighlighted ? 'ring-2 ring-primary' : ''}`}>
|
<div
|
||||||
{/* Header with image and price */}
|
className={`rounded-lg border bg-card shadow-sm hover:shadow-md transition-shadow overflow-hidden ${
|
||||||
<div className="flex gap-4">
|
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}
|
||||||
|
|
||||||
|
{/* Overlay buttons: heart + external link */}
|
||||||
|
<div className="absolute top-2 right-2 flex items-center gap-1.5" onClick={e => e.stopPropagation()}>
|
||||||
<a
|
<a
|
||||||
href={property.url}
|
href={property.url}
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noopener noreferrer"
|
rel="noopener noreferrer"
|
||||||
className="block w-32 h-24 rounded-md overflow-hidden flex-shrink-0 bg-muted hover:opacity-90 transition-opacity"
|
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"
|
||||||
>
|
>
|
||||||
{property.photo_thumbnail && (
|
<ExternalLink className="h-4 w-4" />
|
||||||
<img
|
|
||||||
src={property.photo_thumbnail}
|
|
||||||
alt={`${property.rooms}-bed, ${property.qm}m², £${property.total_price.toLocaleString()}`}
|
|
||||||
className="w-full h-full object-cover"
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</a>
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="flex-1">
|
{/* Content below image */}
|
||||||
<div className="flex items-start justify-between">
|
<div className="p-4 space-y-2">
|
||||||
<div>
|
{/* Price as dominant element */}
|
||||||
<div className="font-semibold text-xl">
|
<div className="flex items-center gap-1.5">
|
||||||
|
<span className="text-lg font-bold tracking-tight">
|
||||||
£{property.total_price.toLocaleString()}
|
£{property.total_price.toLocaleString()}
|
||||||
{property.listing_type !== 'BUY' && (
|
{property.listing_type !== 'BUY' && (
|
||||||
<span className="text-muted-foreground font-normal text-sm">/mo</span>
|
<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>
|
</span>
|
||||||
|
{priceIndicator && (
|
||||||
|
<span className={`w-2 h-2 rounded-full shrink-0 ${priceIndicator.dotColor}`} title={priceIndicator.label} />
|
||||||
)}
|
)}
|
||||||
</div>
|
{priceIndicator && (
|
||||||
</div>
|
<span className="text-xs text-muted-foreground">{priceIndicator.label}</span>
|
||||||
</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>
|
</div>
|
||||||
|
|
||||||
{/* Agency and last seen */}
|
{/* Key metrics on one line */}
|
||||||
<div className="flex items-center gap-2 mt-3 text-sm text-muted-foreground">
|
<div className="flex items-center gap-1 text-sm text-muted-foreground">
|
||||||
<Building className="h-4 w-4" />
|
<span>{property.rooms}</span><span>bed</span>
|
||||||
<span>{property.agency}</span>
|
<span>·</span>
|
||||||
<span className="mx-1">•</span>
|
<span>{property.qm} m²</span>
|
||||||
<span>Seen {lastSeenDays} days ago</span>
|
<span>·</span>
|
||||||
|
<span>£{property.qmprice}/m²</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* POI Distances */}
|
{/* Location */}
|
||||||
|
{property.city && (
|
||||||
|
<div className="text-sm font-medium">{property.city}</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* POI travel times */}
|
||||||
{allPOIs && allPOIs.length > 0 ? (
|
{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} />
|
<AllPOIDistances pois={allPOIs} distances={property.poi_distances} />
|
||||||
</div>
|
|
||||||
) : property.poi_distances && property.poi_distances.length > 0 ? (
|
) : 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} />
|
<POIDistanceBadges distances={property.poi_distances} />
|
||||||
</div>
|
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
{/* Price history */}
|
{/* Agency + freshness */}
|
||||||
{property.price_history.length > 1 && (
|
<div className="flex items-center gap-1 text-xs text-muted-foreground">
|
||||||
<div className="mt-3 pt-3 border-t">
|
<span>{property.agency}</span>
|
||||||
<div className="text-xs font-medium text-muted-foreground mb-1">Price history</div>
|
<span>·</span>
|
||||||
<div className="space-y-0.5">
|
<span>{lastSeenDays}d ago</span>
|
||||||
{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>
|
|
||||||
</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>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import { Bed, Maximize2 } from 'lucide-react';
|
import { Bed, MapPin } from 'lucide-react';
|
||||||
import type { PropertyProperties } from '@/types';
|
import type { PropertyProperties } from '@/types';
|
||||||
|
|
||||||
interface PropertyCardCompactProps {
|
interface PropertyCardCompactProps {
|
||||||
|
|
@ -20,20 +20,20 @@ export function PropertyCardCompact({
|
||||||
const isExpensive = avgPricePerSqm && property.qmprice > avgPricePerSqm * 1.1;
|
const isExpensive = avgPricePerSqm && property.qmprice > avgPricePerSqm * 1.1;
|
||||||
|
|
||||||
const priceIndicator = isGoodDeal
|
const priceIndicator = isGoodDeal
|
||||||
? { color: 'text-green-600 bg-green-50', label: 'Good deal' }
|
? { dotColor: 'bg-[var(--deal-good)]', label: 'Good deal' }
|
||||||
: isExpensive
|
: isExpensive
|
||||||
? { color: 'text-red-600 bg-red-50', label: 'Above avg' }
|
? { dotColor: 'bg-[var(--deal-above)]', label: 'Above avg' }
|
||||||
: null;
|
: null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={`w-[280px] shrink-0 snap-center rounded-lg border bg-background shadow-sm overflow-hidden cursor-pointer transition-all ${
|
className={`w-[280px] shrink-0 snap-center rounded-lg border bg-card shadow-sm overflow-hidden cursor-pointer transition-shadow hover:shadow-md ${
|
||||||
isActive ? 'ring-2 ring-primary scale-[1.02]' : ''
|
isActive ? 'ring-2 ring-primary scale-[1.02]' : ''
|
||||||
} ${isHighlighted ? 'ring-2 ring-primary' : ''}`}
|
} ${isHighlighted ? 'ring-2 ring-primary' : ''}`}
|
||||||
onClick={onClick}
|
onClick={onClick}
|
||||||
>
|
>
|
||||||
{/* Thumbnail */}
|
{/* Thumbnail with 4:3 aspect ratio */}
|
||||||
<div className="h-28 w-full bg-muted">
|
<div className="aspect-[4/3] w-full bg-muted">
|
||||||
{property.photo_thumbnail && (
|
{property.photo_thumbnail && (
|
||||||
<img
|
<img
|
||||||
src={property.photo_thumbnail}
|
src={property.photo_thumbnail}
|
||||||
|
|
@ -44,32 +44,37 @@ export function PropertyCardCompact({
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Details */}
|
{/* Details */}
|
||||||
<div className="p-3">
|
<div className="p-3 space-y-1">
|
||||||
<div className="flex items-start justify-between gap-2">
|
{/* Price bold */}
|
||||||
<div className="font-semibold text-base">
|
<div className="flex items-center gap-1.5">
|
||||||
|
<span className="font-bold text-base">
|
||||||
£{property.total_price.toLocaleString()}
|
£{property.total_price.toLocaleString()}
|
||||||
{property.listing_type !== 'BUY' && (
|
{property.listing_type !== 'BUY' && (
|
||||||
<span className="text-muted-foreground font-normal text-sm">/mo</span>
|
<span className="text-muted-foreground font-normal text-sm">/mo</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
|
||||||
{priceIndicator && (
|
|
||||||
<span className={`text-xs px-1.5 py-0.5 rounded shrink-0 ${priceIndicator.color}`}>
|
|
||||||
{priceIndicator.label}
|
|
||||||
</span>
|
</span>
|
||||||
|
{priceIndicator && (
|
||||||
|
<span className={`w-2 h-2 rounded-full shrink-0 ${priceIndicator.dotColor}`} title={priceIndicator.label} />
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center gap-3 mt-1.5 text-sm text-muted-foreground">
|
{/* Beds and size */}
|
||||||
|
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||||
<span className="flex items-center gap-1">
|
<span className="flex items-center gap-1">
|
||||||
<Bed className="h-3.5 w-3.5" />
|
<Bed className="h-3.5 w-3.5" />
|
||||||
{property.rooms}
|
{property.rooms} bed
|
||||||
</span>
|
</span>
|
||||||
<span className="flex items-center gap-1">
|
<span>·</span>
|
||||||
<Maximize2 className="h-3.5 w-3.5" />
|
<span>{property.qm} m²</span>
|
||||||
{property.qm} m²
|
|
||||||
</span>
|
|
||||||
<span>£{property.qmprice}/m²</span>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Location */}
|
||||||
|
{property.city && (
|
||||||
|
<div className="flex items-center gap-1 text-xs text-muted-foreground truncate">
|
||||||
|
<MapPin className="h-3 w-3 shrink-0" />
|
||||||
|
{property.city}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue