diff --git a/frontend/src/components/ListingDetail.tsx b/frontend/src/components/ListingDetail.tsx index 3f04435..0db55c7 100644 --- a/frontend/src/components/ListingDetail.tsx +++ b/frontend/src/components/ListingDetail.tsx @@ -1,5 +1,6 @@ import { ExternalLink, Heart, X, Bed, Maximize2, PoundSterling, Building, Clock, MapPin, Footprints, Bike, Train } from 'lucide-react'; import { Button } from './ui/button'; +import { Tabs, TabsList, TabsTrigger, TabsContent } from './ui/tabs'; import { PhotoCarousel } from './PhotoCarousel'; import type { ListingDetailData, DecisionType, POIDistanceInfo } from '@/types'; 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) { const allPhotos = [ ...detail.photos, ...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>(); + 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 (
- {/* Photo carousel */} + {/* Photo carousel - always visible above tabs */}
- {/* Price + address */} + {/* Price header */}
-
+
£{detail.price.toLocaleString()} {detail.listing_type !== 'BUY' && ( /mo )}
+ {/* Key metrics */} +
+ + + {detail.number_of_bedrooms} bed + + · + + + {detail.square_meters ?? '\u2014'} m² + + {pricePerSqm && ( + <> + · + + + £{pricePerSqm}/m² + + + )} +
{detail.display_address && (
@@ -51,8 +99,8 @@ export function ListingDetail({ detail, onDecide, onClearDecision }: ListingDeta
- {/* Like/Dislike buttons */} -
+ {/* Action buttons */} +
+
- {/* Key stats */} -
-
- - {detail.number_of_bedrooms} beds -
-
- - {detail.square_meters ?? '\u2014'} -
-
- - {detail.square_meters ? `£${Math.round(detail.price / detail.square_meters)}` : '\u2014'}/m² -
-
+ {/* Tabbed sections */} + + + Overview + {hasTravel && Travel} + {hasPriceHistory && Price} + {hasDetails && Details} + - {/* Key features */} - {detail.key_features.length > 0 && ( -
-

Key Features

-
    - {detail.key_features.map((f, i) => ( -
  • {f}
  • - ))} -
-
- )} + {/* Overview tab */} + + {detail.key_features.length > 0 && ( +
+

Key Features

+
    + {detail.key_features.map((f, i) => ( +
  • {f}
  • + ))} +
+
+ )} - {/* Description */} - {detail.description && ( -
-

Description

-

{detail.description}

-
- )} + {detail.description && ( +
+

Description

+

{detail.description}

+
+ )} - {/* Property details grid */} -
-

Details

-
- {detail.property_sub_type && ( -
- - {detail.property_sub_type} -
- )} - {detail.furnish_type && ( -
- Furnishing: - {detail.furnish_type} -
- )} - {detail.council_tax_band && ( -
- Council Tax: - Band {detail.council_tax_band} -
- )} - {detail.available_from && ( -
- - Available {formatDate(detail.available_from)} -
- )} - {detail.service_charge != null && ( -
- Service charge: - £{detail.service_charge.toLocaleString()} -
- )} - {detail.lease_left != null && ( -
- Lease: - {detail.lease_left} years -
- )} -
-
- - {/* Floorplans */} - {detail.floorplans.length > 0 && ( -
-

Floorplans

-
- {detail.floorplans.map((fp, i) => ( - {fp.caption - ))} -
-
- )} - - {/* Price history */} - {detail.price_history.length > 1 && ( -
-

Price History

-
- {detail.price_history.map((entry) => ( -
- {entry.last_seen.split('T')[0]} - £{entry.price.toLocaleString()} + {/* Floorplans */} + {detail.floorplans.length > 0 && ( +
+

Floorplans

+
+ {detail.floorplans.map((fp, i) => ( + {fp.caption + ))}
- ))} -
-
- )} +
+ )} - {/* POI distances */} - {detail.poi_distances.length > 0 && ( -
-

Travel Times

-
- {detail.poi_distances.map((d: POIDistanceInfo) => ( -
- {d.poi_name}: - - {formatDuration(d.duration_seconds)} -
- ))} -
-
- )} + {/* Agency */} + {detail.agency && ( +
+ + {detail.agency} +
+ )} - {/* Agency */} - {detail.agency && ( -
- - {detail.agency} -
- )} + {!hasOverview && !detail.floorplans.length && !detail.agency && ( +

No overview information available.

+ )} + - {/* External link */} - + {/* Travel tab */} + {hasTravel && ( + +
+ + + + + + + + + + + {Array.from(poiGroups.entries()).map(([poiName, modes]) => ( + + + {(['WALK', 'BICYCLE', 'TRANSIT'] as const).map(mode => { + const d = modes.get(mode); + return ( + + ); + })} + + ))} + +
Destination + + + + + +
{poiName} + {d ? formatDuration(d.duration_seconds) : '\u2014'} +
+
+
+ )} + + {/* Price History tab */} + {hasPriceHistory && ( + +
+ {detail.price_history.map((entry) => ( +
+ {entry.last_seen.split('T')[0]} + £{entry.price.toLocaleString()} +
+ ))} +
+
+ )} + + {/* Details tab */} + {hasDetails && ( + +
+ {detail.property_sub_type && ( +
+
Property Type
+
+ + {detail.property_sub_type} +
+
+ )} + {detail.furnish_type && ( +
+
Furnishing
+
{detail.furnish_type}
+
+ )} + {detail.council_tax_band && ( +
+
Council Tax
+
Band {detail.council_tax_band}
+
+ )} + {detail.lease_left != null && ( +
+
Lease Remaining
+
{detail.lease_left} years
+
+ )} + {detail.service_charge != null && ( +
+
Service Charge
+
£{detail.service_charge.toLocaleString()}
+
+ )} + {detail.available_from && ( +
+
Available From
+
+ + {formatDate(detail.available_from)} +
+
+ )} +
+
+ )} +
); diff --git a/frontend/src/components/ListingDetailSheet.tsx b/frontend/src/components/ListingDetailSheet.tsx index 53734b1..ef63ec4 100644 --- a/frontend/src/components/ListingDetailSheet.tsx +++ b/frontend/src/components/ListingDetailSheet.tsx @@ -45,7 +45,7 @@ export function ListingDetailSheet({ > - + Listing Details