feat: redesign listing detail with tabbed sections and larger drawer

This commit is contained in:
Viktor Barzin 2026-02-28 16:21:20 +00:00
parent 812bfece4a
commit ab02fb120c
No known key found for this signature in database
GPG key ID: 0EB088298288D958
2 changed files with 209 additions and 136 deletions

View file

@ -1,5 +1,6 @@
import { ExternalLink, Heart, X, Bed, Maximize2, PoundSterling, Building, Clock, MapPin, Footprints, Bike, Train } from 'lucide-react'; import { ExternalLink, Heart, X, Bed, Maximize2, PoundSterling, Building, Clock, MapPin, Footprints, Bike, Train } from 'lucide-react';
import { Button } from './ui/button'; import { Button } from './ui/button';
import { Tabs, TabsList, TabsTrigger, TabsContent } from './ui/tabs';
import { PhotoCarousel } from './PhotoCarousel'; import { PhotoCarousel } from './PhotoCarousel';
import type { ListingDetailData, DecisionType, POIDistanceInfo } from '@/types'; import type { ListingDetailData, DecisionType, POIDistanceInfo } from '@/types';
import { formatDate, formatDuration } from '@/utils/format'; import { formatDate, formatDuration } from '@/utils/format';
@ -19,28 +20,75 @@ function TravelModeIcon({ mode }: { mode: string }) {
} }
} }
function TravelModeLabel({ mode }: { mode: string }) {
switch (mode) {
case 'WALK': return 'Walk';
case 'BICYCLE': return 'Cycle';
case 'TRANSIT': return 'Transit';
default: return mode;
}
}
export function ListingDetail({ detail, onDecide, onClearDecision }: ListingDetailProps) { export function ListingDetail({ detail, onDecide, onClearDecision }: ListingDetailProps) {
const allPhotos = [ const allPhotos = [
...detail.photos, ...detail.photos,
...detail.floorplans.map(fp => ({ url: fp.url, caption: fp.caption || 'Floorplan', type: 'FLOORPLAN' as string | null })), ...detail.floorplans.map(fp => ({ url: fp.url, caption: fp.caption || 'Floorplan', type: 'FLOORPLAN' as string | null })),
]; ];
const pricePerSqm = detail.square_meters ? Math.round(detail.price / detail.square_meters) : null;
// Group POI distances by POI name for the travel table
const poiGroups = new Map<string, Map<string, POIDistanceInfo>>();
for (const d of detail.poi_distances) {
if (!poiGroups.has(d.poi_name)) {
poiGroups.set(d.poi_name, new Map());
}
poiGroups.get(d.poi_name)!.set(d.travel_mode, d);
}
// Check which tabs have content
const hasOverview = detail.key_features.length > 0 || !!detail.description;
const hasTravel = detail.poi_distances.length > 0;
const hasPriceHistory = detail.price_history.length > 1;
const hasDetails = !!(detail.property_sub_type || detail.furnish_type || detail.council_tax_band || detail.available_from || detail.service_charge != null || detail.lease_left != null);
return ( return (
<div className="pb-8"> <div className="pb-8">
{/* Photo carousel */} {/* Photo carousel - always visible above tabs */}
<PhotoCarousel photos={allPhotos} /> <PhotoCarousel photos={allPhotos} />
<div className="px-4 pt-4 space-y-4"> <div className="px-4 pt-4 space-y-4">
{/* Price + address */} {/* Price header */}
<div> <div>
<div className="flex items-start justify-between"> <div className="flex items-start justify-between">
<div> <div>
<div className="text-2xl font-bold"> <div className="text-2xl font-bold tracking-tight">
£{detail.price.toLocaleString()} £{detail.price.toLocaleString()}
{detail.listing_type !== 'BUY' && ( {detail.listing_type !== 'BUY' && (
<span className="text-muted-foreground font-normal text-base">/mo</span> <span className="text-muted-foreground font-normal text-base">/mo</span>
)} )}
</div> </div>
{/* Key metrics */}
<div className="flex items-center gap-1.5 text-sm text-muted-foreground mt-1">
<span className="flex items-center gap-1">
<Bed className="h-3.5 w-3.5" />
{detail.number_of_bedrooms} bed
</span>
<span>·</span>
<span className="flex items-center gap-1">
<Maximize2 className="h-3.5 w-3.5" />
{detail.square_meters ?? '\u2014'} m²
</span>
{pricePerSqm && (
<>
<span>·</span>
<span className="flex items-center gap-1">
<PoundSterling className="h-3.5 w-3.5" />
£{pricePerSqm}/m²
</span>
</>
)}
</div>
{detail.display_address && ( {detail.display_address && (
<div className="flex items-center gap-1 text-sm text-muted-foreground mt-1"> <div className="flex items-center gap-1 text-sm text-muted-foreground mt-1">
<MapPin className="h-3.5 w-3.5" /> <MapPin className="h-3.5 w-3.5" />
@ -51,8 +99,8 @@ export function ListingDetail({ detail, onDecide, onClearDecision }: ListingDeta
</div> </div>
</div> </div>
{/* Like/Dislike buttons */} {/* Action buttons */}
<div className="flex gap-3"> <div className="flex gap-2">
<Button <Button
variant={detail.decision === 'disliked' ? 'destructive' : 'outline'} variant={detail.decision === 'disliked' ? 'destructive' : 'outline'}
className="flex-1" className="flex-1"
@ -69,145 +117,170 @@ export function ListingDetail({ detail, onDecide, onClearDecision }: ListingDeta
<Heart className={`h-4 w-4 mr-2 ${detail.decision === 'liked' ? 'fill-current' : ''}`} /> <Heart className={`h-4 w-4 mr-2 ${detail.decision === 'liked' ? 'fill-current' : ''}`} />
{detail.decision === 'liked' ? 'Liked' : 'Like'} {detail.decision === 'liked' ? 'Liked' : 'Like'}
</Button> </Button>
<Button asChild variant="outline" size="icon" className="shrink-0">
<a href={detail.url} target="_blank" rel="noopener noreferrer" title="View on Rightmove">
<ExternalLink className="h-4 w-4" />
</a>
</Button>
</div> </div>
{/* Key stats */} {/* Tabbed sections */}
<div className="grid grid-cols-3 gap-3"> <Tabs defaultValue="overview">
<div className="flex items-center gap-2 text-sm"> <TabsList>
<Bed className="h-4 w-4 text-muted-foreground" /> <TabsTrigger value="overview">Overview</TabsTrigger>
<span><strong>{detail.number_of_bedrooms}</strong> beds</span> {hasTravel && <TabsTrigger value="travel">Travel</TabsTrigger>}
</div> {hasPriceHistory && <TabsTrigger value="price-history">Price</TabsTrigger>}
<div className="flex items-center gap-2 text-sm"> {hasDetails && <TabsTrigger value="details">Details</TabsTrigger>}
<Maximize2 className="h-4 w-4 text-muted-foreground" /> </TabsList>
<span><strong>{detail.square_meters ?? '\u2014'}</strong> m²</span>
</div>
<div className="flex items-center gap-2 text-sm">
<PoundSterling className="h-4 w-4 text-muted-foreground" />
<span><strong>{detail.square_meters ? `£${Math.round(detail.price / detail.square_meters)}` : '\u2014'}</strong>/m²</span>
</div>
</div>
{/* Key features */} {/* Overview tab */}
{detail.key_features.length > 0 && ( <TabsContent value="overview" className="space-y-4">
<div> {detail.key_features.length > 0 && (
<h3 className="text-sm font-semibold mb-2">Key Features</h3> <div>
<ul className="list-disc list-inside text-sm text-muted-foreground space-y-1"> <h3 className="text-sm font-semibold mb-2">Key Features</h3>
{detail.key_features.map((f, i) => ( <ul className="list-disc list-inside text-sm text-muted-foreground space-y-1">
<li key={i}>{f}</li> {detail.key_features.map((f, i) => (
))} <li key={i}>{f}</li>
</ul> ))}
</div> </ul>
)} </div>
)}
{/* Description */} {detail.description && (
{detail.description && ( <div>
<div> <h3 className="text-sm font-semibold mb-2">Description</h3>
<h3 className="text-sm font-semibold mb-2">Description</h3> <p className="text-sm text-muted-foreground whitespace-pre-line">{detail.description}</p>
<p className="text-sm text-muted-foreground whitespace-pre-line">{detail.description}</p> </div>
</div> )}
)}
{/* Property details grid */} {/* Floorplans */}
<div> {detail.floorplans.length > 0 && (
<h3 className="text-sm font-semibold mb-2">Details</h3> <div>
<div className="grid grid-cols-2 gap-2 text-sm"> <h3 className="text-sm font-semibold mb-2">Floorplans</h3>
{detail.property_sub_type && ( <div className="space-y-2">
<div className="flex items-center gap-2"> {detail.floorplans.map((fp, i) => (
<Building className="h-4 w-4 text-muted-foreground" /> <img key={i} src={fp.url} alt={fp.caption || 'Floorplan'} className="w-full rounded-md border" />
<span>{detail.property_sub_type}</span> ))}
</div>
)}
{detail.furnish_type && (
<div className="flex items-center gap-2">
<span className="text-muted-foreground">Furnishing:</span>
<span>{detail.furnish_type}</span>
</div>
)}
{detail.council_tax_band && (
<div className="flex items-center gap-2">
<span className="text-muted-foreground">Council Tax:</span>
<span>Band {detail.council_tax_band}</span>
</div>
)}
{detail.available_from && (
<div className="flex items-center gap-2">
<Clock className="h-4 w-4 text-muted-foreground" />
<span>Available {formatDate(detail.available_from)}</span>
</div>
)}
{detail.service_charge != null && (
<div className="flex items-center gap-2">
<span className="text-muted-foreground">Service charge:</span>
<span>£{detail.service_charge.toLocaleString()}</span>
</div>
)}
{detail.lease_left != null && (
<div className="flex items-center gap-2">
<span className="text-muted-foreground">Lease:</span>
<span>{detail.lease_left} years</span>
</div>
)}
</div>
</div>
{/* Floorplans */}
{detail.floorplans.length > 0 && (
<div>
<h3 className="text-sm font-semibold mb-2">Floorplans</h3>
<div className="space-y-2">
{detail.floorplans.map((fp, i) => (
<img key={i} src={fp.url} alt={fp.caption || 'Floorplan'} className="w-full rounded-md border" />
))}
</div>
</div>
)}
{/* Price history */}
{detail.price_history.length > 1 && (
<div>
<h3 className="text-sm font-semibold mb-2">Price History</h3>
<div className="space-y-1">
{detail.price_history.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> )}
</div>
)}
{/* POI distances */} {/* Agency */}
{detail.poi_distances.length > 0 && ( {detail.agency && (
<div> <div className="flex items-center gap-2 text-sm text-muted-foreground">
<h3 className="text-sm font-semibold mb-2">Travel Times</h3> <Building className="h-4 w-4" />
<div className="flex flex-wrap gap-1.5"> <span>{detail.agency}</span>
{detail.poi_distances.map((d: POIDistanceInfo) => ( </div>
<div key={`${d.poi_id}_${d.travel_mode}`} 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">{d.poi_name}:</span>
<TravelModeIcon mode={d.travel_mode} />
{formatDuration(d.duration_seconds)}
</div>
))}
</div>
</div>
)}
{/* Agency */} {!hasOverview && !detail.floorplans.length && !detail.agency && (
{detail.agency && ( <p className="text-sm text-muted-foreground">No overview information available.</p>
<div className="flex items-center gap-2 text-sm text-muted-foreground"> )}
<Building className="h-4 w-4" /> </TabsContent>
<span>{detail.agency}</span>
</div>
)}
{/* External link */} {/* Travel tab */}
<Button asChild variant="outline" className="w-full"> {hasTravel && (
<a href={detail.url} target="_blank" rel="noopener noreferrer"> <TabsContent value="travel">
View on Rightmove <div className="overflow-x-auto">
<ExternalLink className="ml-2 h-4 w-4" /> <table className="w-full text-sm">
</a> <thead>
</Button> <tr className="border-b">
<th className="text-left py-2 pr-4 font-medium text-muted-foreground">Destination</th>
<th className="text-center py-2 px-2 font-medium text-muted-foreground">
<span className="inline-flex items-center gap-1"><Footprints className="h-3 w-3" /> <TravelModeLabel mode="WALK" /></span>
</th>
<th className="text-center py-2 px-2 font-medium text-muted-foreground">
<span className="inline-flex items-center gap-1"><Bike className="h-3 w-3" /> <TravelModeLabel mode="BICYCLE" /></span>
</th>
<th className="text-center py-2 px-2 font-medium text-muted-foreground">
<span className="inline-flex items-center gap-1"><Train className="h-3 w-3" /> <TravelModeLabel mode="TRANSIT" /></span>
</th>
</tr>
</thead>
<tbody>
{Array.from(poiGroups.entries()).map(([poiName, modes]) => (
<tr key={poiName} className="border-b last:border-0">
<td className="py-2 pr-4 font-medium">{poiName}</td>
{(['WALK', 'BICYCLE', 'TRANSIT'] as const).map(mode => {
const d = modes.get(mode);
return (
<td key={mode} className="py-2 px-2 text-center text-muted-foreground">
{d ? formatDuration(d.duration_seconds) : '\u2014'}
</td>
);
})}
</tr>
))}
</tbody>
</table>
</div>
</TabsContent>
)}
{/* Price History tab */}
{hasPriceHistory && (
<TabsContent value="price-history">
<div className="space-y-2">
{detail.price_history.map((entry) => (
<div key={entry.id} className="flex justify-between items-center text-sm py-1.5 border-b last:border-0">
<span className="text-muted-foreground">{entry.last_seen.split('T')[0]}</span>
<span className="font-medium">£{entry.price.toLocaleString()}</span>
</div>
))}
</div>
</TabsContent>
)}
{/* Details tab */}
{hasDetails && (
<TabsContent value="details">
<div className="grid grid-cols-2 gap-3 text-sm">
{detail.property_sub_type && (
<div className="space-y-0.5">
<div className="text-xs text-muted-foreground">Property Type</div>
<div className="font-medium flex items-center gap-1.5">
<Building className="h-3.5 w-3.5 text-muted-foreground" />
{detail.property_sub_type}
</div>
</div>
)}
{detail.furnish_type && (
<div className="space-y-0.5">
<div className="text-xs text-muted-foreground">Furnishing</div>
<div className="font-medium">{detail.furnish_type}</div>
</div>
)}
{detail.council_tax_band && (
<div className="space-y-0.5">
<div className="text-xs text-muted-foreground">Council Tax</div>
<div className="font-medium">Band {detail.council_tax_band}</div>
</div>
)}
{detail.lease_left != null && (
<div className="space-y-0.5">
<div className="text-xs text-muted-foreground">Lease Remaining</div>
<div className="font-medium">{detail.lease_left} years</div>
</div>
)}
{detail.service_charge != null && (
<div className="space-y-0.5">
<div className="text-xs text-muted-foreground">Service Charge</div>
<div className="font-medium">£{detail.service_charge.toLocaleString()}</div>
</div>
)}
{detail.available_from && (
<div className="space-y-0.5">
<div className="text-xs text-muted-foreground">Available From</div>
<div className="font-medium flex items-center gap-1.5">
<Clock className="h-3.5 w-3.5 text-muted-foreground" />
{formatDate(detail.available_from)}
</div>
</div>
)}
</div>
</TabsContent>
)}
</Tabs>
</div> </div>
</div> </div>
); );

View file

@ -45,7 +45,7 @@ export function ListingDetailSheet({
> >
<Drawer.Portal> <Drawer.Portal>
<Drawer.Overlay className="fixed inset-0 bg-black/40 z-50" /> <Drawer.Overlay className="fixed inset-0 bg-black/40 z-50" />
<Drawer.Content className="fixed bottom-0 left-0 right-0 z-50 flex flex-col bg-background rounded-t-xl max-h-[90vh]"> <Drawer.Content className="fixed bottom-0 left-0 right-0 z-50 flex flex-col bg-background rounded-t-xl max-h-[90vh] sm:!max-w-2xl sm:mx-auto">
<Drawer.Title className="sr-only">Listing Details</Drawer.Title> <Drawer.Title className="sr-only">Listing Details</Drawer.Title>
<div className="mx-auto w-12 h-1.5 flex-shrink-0 rounded-full bg-muted-foreground/20 my-3" /> <div className="mx-auto w-12 h-1.5 flex-shrink-0 rounded-full bg-muted-foreground/20 my-3" />
<div className="overflow-y-auto flex-1"> <div className="overflow-y-auto flex-1">