Add tappable cards, detail bottom sheet, swipe gestures, and favorites view
- Decision types, services (decisionService, listingDetailService), and index exports
- useDecisions hook with optimistic updates and Map-based state
- useListingDetail hook with session-level caching
- PhotoCarousel component using embla-carousel-react
- ListingDetail component with full property info, like/dislike buttons
- ListingDetailSheet using vaul Drawer (slide-up bottom sheet)
- SwipeablePropertyCard with @use-gesture/react and @react-spring/web
- SwipeReviewMode for mobile full-screen swipe review
- FavoritesView with virtualized liked listings and remove button
- App.tsx integration: decision state, client-side disliked filtering, detail sheet, swipe handlers
- ListView conditionally renders SwipeablePropertyCard when handlers provided
- StatsBar adds 'saved' view mode with heart icon
- Header adds liked count indicator
- New deps: vaul, embla-carousel-react, @use-gesture/react, @react-spring/web
2026-02-21 15:48:17 +00:00
|
|
|
import { ExternalLink, Heart, X, Bed, Maximize2, PoundSterling, Building, Clock, MapPin, Footprints, Bike, Train } from 'lucide-react';
|
|
|
|
|
import { Button } from './ui/button';
|
2026-02-28 16:21:20 +00:00
|
|
|
import { Tabs, TabsList, TabsTrigger, TabsContent } from './ui/tabs';
|
Add tappable cards, detail bottom sheet, swipe gestures, and favorites view
- Decision types, services (decisionService, listingDetailService), and index exports
- useDecisions hook with optimistic updates and Map-based state
- useListingDetail hook with session-level caching
- PhotoCarousel component using embla-carousel-react
- ListingDetail component with full property info, like/dislike buttons
- ListingDetailSheet using vaul Drawer (slide-up bottom sheet)
- SwipeablePropertyCard with @use-gesture/react and @react-spring/web
- SwipeReviewMode for mobile full-screen swipe review
- FavoritesView with virtualized liked listings and remove button
- App.tsx integration: decision state, client-side disliked filtering, detail sheet, swipe handlers
- ListView conditionally renders SwipeablePropertyCard when handlers provided
- StatsBar adds 'saved' view mode with heart icon
- Header adds liked count indicator
- New deps: vaul, embla-carousel-react, @use-gesture/react, @react-spring/web
2026-02-21 15:48:17 +00:00
|
|
|
import { PhotoCarousel } from './PhotoCarousel';
|
|
|
|
|
import type { ListingDetailData, DecisionType, POIDistanceInfo } from '@/types';
|
2026-02-28 16:02:06 +00:00
|
|
|
import { formatDate, formatDuration } from '@/utils/format';
|
2026-02-21 18:02:14 +00:00
|
|
|
|
Add tappable cards, detail bottom sheet, swipe gestures, and favorites view
- Decision types, services (decisionService, listingDetailService), and index exports
- useDecisions hook with optimistic updates and Map-based state
- useListingDetail hook with session-level caching
- PhotoCarousel component using embla-carousel-react
- ListingDetail component with full property info, like/dislike buttons
- ListingDetailSheet using vaul Drawer (slide-up bottom sheet)
- SwipeablePropertyCard with @use-gesture/react and @react-spring/web
- SwipeReviewMode for mobile full-screen swipe review
- FavoritesView with virtualized liked listings and remove button
- App.tsx integration: decision state, client-side disliked filtering, detail sheet, swipe handlers
- ListView conditionally renders SwipeablePropertyCard when handlers provided
- StatsBar adds 'saved' view mode with heart icon
- Header adds liked count indicator
- New deps: vaul, embla-carousel-react, @use-gesture/react, @react-spring/web
2026-02-21 15:48:17 +00:00
|
|
|
interface ListingDetailProps {
|
|
|
|
|
detail: ListingDetailData;
|
|
|
|
|
onDecide: (decision: DecisionType) => void;
|
|
|
|
|
onClearDecision: () => void;
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-28 16:21:20 +00:00
|
|
|
function TravelModeLabel({ mode }: { mode: string }) {
|
|
|
|
|
switch (mode) {
|
|
|
|
|
case 'WALK': return 'Walk';
|
|
|
|
|
case 'BICYCLE': return 'Cycle';
|
|
|
|
|
case 'TRANSIT': return 'Transit';
|
|
|
|
|
default: return mode;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
Add tappable cards, detail bottom sheet, swipe gestures, and favorites view
- Decision types, services (decisionService, listingDetailService), and index exports
- useDecisions hook with optimistic updates and Map-based state
- useListingDetail hook with session-level caching
- PhotoCarousel component using embla-carousel-react
- ListingDetail component with full property info, like/dislike buttons
- ListingDetailSheet using vaul Drawer (slide-up bottom sheet)
- SwipeablePropertyCard with @use-gesture/react and @react-spring/web
- SwipeReviewMode for mobile full-screen swipe review
- FavoritesView with virtualized liked listings and remove button
- App.tsx integration: decision state, client-side disliked filtering, detail sheet, swipe handlers
- ListView conditionally renders SwipeablePropertyCard when handlers provided
- StatsBar adds 'saved' view mode with heart icon
- Header adds liked count indicator
- New deps: vaul, embla-carousel-react, @use-gesture/react, @react-spring/web
2026-02-21 15:48:17 +00:00
|
|
|
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 })),
|
|
|
|
|
];
|
|
|
|
|
|
2026-02-28 16:21:20 +00:00
|
|
|
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);
|
|
|
|
|
|
Add tappable cards, detail bottom sheet, swipe gestures, and favorites view
- Decision types, services (decisionService, listingDetailService), and index exports
- useDecisions hook with optimistic updates and Map-based state
- useListingDetail hook with session-level caching
- PhotoCarousel component using embla-carousel-react
- ListingDetail component with full property info, like/dislike buttons
- ListingDetailSheet using vaul Drawer (slide-up bottom sheet)
- SwipeablePropertyCard with @use-gesture/react and @react-spring/web
- SwipeReviewMode for mobile full-screen swipe review
- FavoritesView with virtualized liked listings and remove button
- App.tsx integration: decision state, client-side disliked filtering, detail sheet, swipe handlers
- ListView conditionally renders SwipeablePropertyCard when handlers provided
- StatsBar adds 'saved' view mode with heart icon
- Header adds liked count indicator
- New deps: vaul, embla-carousel-react, @use-gesture/react, @react-spring/web
2026-02-21 15:48:17 +00:00
|
|
|
return (
|
|
|
|
|
<div className="pb-8">
|
2026-02-28 16:21:20 +00:00
|
|
|
{/* Photo carousel - always visible above tabs */}
|
Add tappable cards, detail bottom sheet, swipe gestures, and favorites view
- Decision types, services (decisionService, listingDetailService), and index exports
- useDecisions hook with optimistic updates and Map-based state
- useListingDetail hook with session-level caching
- PhotoCarousel component using embla-carousel-react
- ListingDetail component with full property info, like/dislike buttons
- ListingDetailSheet using vaul Drawer (slide-up bottom sheet)
- SwipeablePropertyCard with @use-gesture/react and @react-spring/web
- SwipeReviewMode for mobile full-screen swipe review
- FavoritesView with virtualized liked listings and remove button
- App.tsx integration: decision state, client-side disliked filtering, detail sheet, swipe handlers
- ListView conditionally renders SwipeablePropertyCard when handlers provided
- StatsBar adds 'saved' view mode with heart icon
- Header adds liked count indicator
- New deps: vaul, embla-carousel-react, @use-gesture/react, @react-spring/web
2026-02-21 15:48:17 +00:00
|
|
|
<PhotoCarousel photos={allPhotos} />
|
|
|
|
|
|
|
|
|
|
<div className="px-4 pt-4 space-y-4">
|
2026-02-28 16:21:20 +00:00
|
|
|
{/* Price header */}
|
Add tappable cards, detail bottom sheet, swipe gestures, and favorites view
- Decision types, services (decisionService, listingDetailService), and index exports
- useDecisions hook with optimistic updates and Map-based state
- useListingDetail hook with session-level caching
- PhotoCarousel component using embla-carousel-react
- ListingDetail component with full property info, like/dislike buttons
- ListingDetailSheet using vaul Drawer (slide-up bottom sheet)
- SwipeablePropertyCard with @use-gesture/react and @react-spring/web
- SwipeReviewMode for mobile full-screen swipe review
- FavoritesView with virtualized liked listings and remove button
- App.tsx integration: decision state, client-side disliked filtering, detail sheet, swipe handlers
- ListView conditionally renders SwipeablePropertyCard when handlers provided
- StatsBar adds 'saved' view mode with heart icon
- Header adds liked count indicator
- New deps: vaul, embla-carousel-react, @use-gesture/react, @react-spring/web
2026-02-21 15:48:17 +00:00
|
|
|
<div>
|
|
|
|
|
<div className="flex items-start justify-between">
|
|
|
|
|
<div>
|
2026-02-28 16:21:20 +00:00
|
|
|
<div className="text-2xl font-bold tracking-tight">
|
Add tappable cards, detail bottom sheet, swipe gestures, and favorites view
- Decision types, services (decisionService, listingDetailService), and index exports
- useDecisions hook with optimistic updates and Map-based state
- useListingDetail hook with session-level caching
- PhotoCarousel component using embla-carousel-react
- ListingDetail component with full property info, like/dislike buttons
- ListingDetailSheet using vaul Drawer (slide-up bottom sheet)
- SwipeablePropertyCard with @use-gesture/react and @react-spring/web
- SwipeReviewMode for mobile full-screen swipe review
- FavoritesView with virtualized liked listings and remove button
- App.tsx integration: decision state, client-side disliked filtering, detail sheet, swipe handlers
- ListView conditionally renders SwipeablePropertyCard when handlers provided
- StatsBar adds 'saved' view mode with heart icon
- Header adds liked count indicator
- New deps: vaul, embla-carousel-react, @use-gesture/react, @react-spring/web
2026-02-21 15:48:17 +00:00
|
|
|
£{detail.price.toLocaleString()}
|
|
|
|
|
{detail.listing_type !== 'BUY' && (
|
|
|
|
|
<span className="text-muted-foreground font-normal text-base">/mo</span>
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
2026-02-28 16:21:20 +00:00
|
|
|
{/* 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>
|
Add tappable cards, detail bottom sheet, swipe gestures, and favorites view
- Decision types, services (decisionService, listingDetailService), and index exports
- useDecisions hook with optimistic updates and Map-based state
- useListingDetail hook with session-level caching
- PhotoCarousel component using embla-carousel-react
- ListingDetail component with full property info, like/dislike buttons
- ListingDetailSheet using vaul Drawer (slide-up bottom sheet)
- SwipeablePropertyCard with @use-gesture/react and @react-spring/web
- SwipeReviewMode for mobile full-screen swipe review
- FavoritesView with virtualized liked listings and remove button
- App.tsx integration: decision state, client-side disliked filtering, detail sheet, swipe handlers
- ListView conditionally renders SwipeablePropertyCard when handlers provided
- StatsBar adds 'saved' view mode with heart icon
- Header adds liked count indicator
- New deps: vaul, embla-carousel-react, @use-gesture/react, @react-spring/web
2026-02-21 15:48:17 +00:00
|
|
|
{detail.display_address && (
|
|
|
|
|
<div className="flex items-center gap-1 text-sm text-muted-foreground mt-1">
|
|
|
|
|
<MapPin className="h-3.5 w-3.5" />
|
|
|
|
|
{detail.display_address}
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
|
2026-02-28 16:21:20 +00:00
|
|
|
{/* Action buttons */}
|
|
|
|
|
<div className="flex gap-2">
|
Add tappable cards, detail bottom sheet, swipe gestures, and favorites view
- Decision types, services (decisionService, listingDetailService), and index exports
- useDecisions hook with optimistic updates and Map-based state
- useListingDetail hook with session-level caching
- PhotoCarousel component using embla-carousel-react
- ListingDetail component with full property info, like/dislike buttons
- ListingDetailSheet using vaul Drawer (slide-up bottom sheet)
- SwipeablePropertyCard with @use-gesture/react and @react-spring/web
- SwipeReviewMode for mobile full-screen swipe review
- FavoritesView with virtualized liked listings and remove button
- App.tsx integration: decision state, client-side disliked filtering, detail sheet, swipe handlers
- ListView conditionally renders SwipeablePropertyCard when handlers provided
- StatsBar adds 'saved' view mode with heart icon
- Header adds liked count indicator
- New deps: vaul, embla-carousel-react, @use-gesture/react, @react-spring/web
2026-02-21 15:48:17 +00:00
|
|
|
<Button
|
|
|
|
|
variant={detail.decision === 'disliked' ? 'destructive' : 'outline'}
|
|
|
|
|
className="flex-1"
|
|
|
|
|
onClick={() => detail.decision === 'disliked' ? onClearDecision() : onDecide('disliked')}
|
|
|
|
|
>
|
|
|
|
|
<X className="h-4 w-4 mr-2" />
|
|
|
|
|
{detail.decision === 'disliked' ? 'Disliked' : 'Dislike'}
|
|
|
|
|
</Button>
|
|
|
|
|
<Button
|
|
|
|
|
variant={detail.decision === 'liked' ? 'default' : 'outline'}
|
|
|
|
|
className={`flex-1 ${detail.decision === 'liked' ? 'bg-green-600 hover:bg-green-700' : ''}`}
|
|
|
|
|
onClick={() => detail.decision === 'liked' ? onClearDecision() : onDecide('liked')}
|
|
|
|
|
>
|
|
|
|
|
<Heart className={`h-4 w-4 mr-2 ${detail.decision === 'liked' ? 'fill-current' : ''}`} />
|
|
|
|
|
{detail.decision === 'liked' ? 'Liked' : 'Like'}
|
|
|
|
|
</Button>
|
2026-02-28 16:21:20 +00:00
|
|
|
<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>
|
Add tappable cards, detail bottom sheet, swipe gestures, and favorites view
- Decision types, services (decisionService, listingDetailService), and index exports
- useDecisions hook with optimistic updates and Map-based state
- useListingDetail hook with session-level caching
- PhotoCarousel component using embla-carousel-react
- ListingDetail component with full property info, like/dislike buttons
- ListingDetailSheet using vaul Drawer (slide-up bottom sheet)
- SwipeablePropertyCard with @use-gesture/react and @react-spring/web
- SwipeReviewMode for mobile full-screen swipe review
- FavoritesView with virtualized liked listings and remove button
- App.tsx integration: decision state, client-side disliked filtering, detail sheet, swipe handlers
- ListView conditionally renders SwipeablePropertyCard when handlers provided
- StatsBar adds 'saved' view mode with heart icon
- Header adds liked count indicator
- New deps: vaul, embla-carousel-react, @use-gesture/react, @react-spring/web
2026-02-21 15:48:17 +00:00
|
|
|
</div>
|
|
|
|
|
|
2026-02-28 16:21:20 +00:00
|
|
|
{/* Tabbed sections */}
|
|
|
|
|
<Tabs defaultValue="overview">
|
|
|
|
|
<TabsList>
|
|
|
|
|
<TabsTrigger value="overview">Overview</TabsTrigger>
|
|
|
|
|
{hasTravel && <TabsTrigger value="travel">Travel</TabsTrigger>}
|
|
|
|
|
{hasPriceHistory && <TabsTrigger value="price-history">Price</TabsTrigger>}
|
|
|
|
|
{hasDetails && <TabsTrigger value="details">Details</TabsTrigger>}
|
|
|
|
|
</TabsList>
|
Add tappable cards, detail bottom sheet, swipe gestures, and favorites view
- Decision types, services (decisionService, listingDetailService), and index exports
- useDecisions hook with optimistic updates and Map-based state
- useListingDetail hook with session-level caching
- PhotoCarousel component using embla-carousel-react
- ListingDetail component with full property info, like/dislike buttons
- ListingDetailSheet using vaul Drawer (slide-up bottom sheet)
- SwipeablePropertyCard with @use-gesture/react and @react-spring/web
- SwipeReviewMode for mobile full-screen swipe review
- FavoritesView with virtualized liked listings and remove button
- App.tsx integration: decision state, client-side disliked filtering, detail sheet, swipe handlers
- ListView conditionally renders SwipeablePropertyCard when handlers provided
- StatsBar adds 'saved' view mode with heart icon
- Header adds liked count indicator
- New deps: vaul, embla-carousel-react, @use-gesture/react, @react-spring/web
2026-02-21 15:48:17 +00:00
|
|
|
|
2026-02-28 16:21:20 +00:00
|
|
|
{/* Overview tab */}
|
|
|
|
|
<TabsContent value="overview" className="space-y-4">
|
|
|
|
|
{detail.key_features.length > 0 && (
|
|
|
|
|
<div>
|
|
|
|
|
<h3 className="text-sm font-semibold mb-2">Key Features</h3>
|
|
|
|
|
<ul className="list-disc list-inside text-sm text-muted-foreground space-y-1">
|
|
|
|
|
{detail.key_features.map((f, i) => (
|
|
|
|
|
<li key={i}>{f}</li>
|
|
|
|
|
))}
|
|
|
|
|
</ul>
|
Add tappable cards, detail bottom sheet, swipe gestures, and favorites view
- Decision types, services (decisionService, listingDetailService), and index exports
- useDecisions hook with optimistic updates and Map-based state
- useListingDetail hook with session-level caching
- PhotoCarousel component using embla-carousel-react
- ListingDetail component with full property info, like/dislike buttons
- ListingDetailSheet using vaul Drawer (slide-up bottom sheet)
- SwipeablePropertyCard with @use-gesture/react and @react-spring/web
- SwipeReviewMode for mobile full-screen swipe review
- FavoritesView with virtualized liked listings and remove button
- App.tsx integration: decision state, client-side disliked filtering, detail sheet, swipe handlers
- ListView conditionally renders SwipeablePropertyCard when handlers provided
- StatsBar adds 'saved' view mode with heart icon
- Header adds liked count indicator
- New deps: vaul, embla-carousel-react, @use-gesture/react, @react-spring/web
2026-02-21 15:48:17 +00:00
|
|
|
</div>
|
|
|
|
|
)}
|
2026-02-28 16:21:20 +00:00
|
|
|
|
|
|
|
|
{detail.description && (
|
|
|
|
|
<div>
|
|
|
|
|
<h3 className="text-sm font-semibold mb-2">Description</h3>
|
|
|
|
|
<p className="text-sm text-muted-foreground whitespace-pre-line">{detail.description}</p>
|
Add tappable cards, detail bottom sheet, swipe gestures, and favorites view
- Decision types, services (decisionService, listingDetailService), and index exports
- useDecisions hook with optimistic updates and Map-based state
- useListingDetail hook with session-level caching
- PhotoCarousel component using embla-carousel-react
- ListingDetail component with full property info, like/dislike buttons
- ListingDetailSheet using vaul Drawer (slide-up bottom sheet)
- SwipeablePropertyCard with @use-gesture/react and @react-spring/web
- SwipeReviewMode for mobile full-screen swipe review
- FavoritesView with virtualized liked listings and remove button
- App.tsx integration: decision state, client-side disliked filtering, detail sheet, swipe handlers
- ListView conditionally renders SwipeablePropertyCard when handlers provided
- StatsBar adds 'saved' view mode with heart icon
- Header adds liked count indicator
- New deps: vaul, embla-carousel-react, @use-gesture/react, @react-spring/web
2026-02-21 15:48:17 +00:00
|
|
|
</div>
|
|
|
|
|
)}
|
2026-02-28 16:21:20 +00:00
|
|
|
|
|
|
|
|
{/* 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>
|
Add tappable cards, detail bottom sheet, swipe gestures, and favorites view
- Decision types, services (decisionService, listingDetailService), and index exports
- useDecisions hook with optimistic updates and Map-based state
- useListingDetail hook with session-level caching
- PhotoCarousel component using embla-carousel-react
- ListingDetail component with full property info, like/dislike buttons
- ListingDetailSheet using vaul Drawer (slide-up bottom sheet)
- SwipeablePropertyCard with @use-gesture/react and @react-spring/web
- SwipeReviewMode for mobile full-screen swipe review
- FavoritesView with virtualized liked listings and remove button
- App.tsx integration: decision state, client-side disliked filtering, detail sheet, swipe handlers
- ListView conditionally renders SwipeablePropertyCard when handlers provided
- StatsBar adds 'saved' view mode with heart icon
- Header adds liked count indicator
- New deps: vaul, embla-carousel-react, @use-gesture/react, @react-spring/web
2026-02-21 15:48:17 +00:00
|
|
|
</div>
|
|
|
|
|
)}
|
2026-02-28 16:21:20 +00:00
|
|
|
|
|
|
|
|
{/* Agency */}
|
|
|
|
|
{detail.agency && (
|
|
|
|
|
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
|
|
|
|
<Building className="h-4 w-4" />
|
|
|
|
|
<span>{detail.agency}</span>
|
Add tappable cards, detail bottom sheet, swipe gestures, and favorites view
- Decision types, services (decisionService, listingDetailService), and index exports
- useDecisions hook with optimistic updates and Map-based state
- useListingDetail hook with session-level caching
- PhotoCarousel component using embla-carousel-react
- ListingDetail component with full property info, like/dislike buttons
- ListingDetailSheet using vaul Drawer (slide-up bottom sheet)
- SwipeablePropertyCard with @use-gesture/react and @react-spring/web
- SwipeReviewMode for mobile full-screen swipe review
- FavoritesView with virtualized liked listings and remove button
- App.tsx integration: decision state, client-side disliked filtering, detail sheet, swipe handlers
- ListView conditionally renders SwipeablePropertyCard when handlers provided
- StatsBar adds 'saved' view mode with heart icon
- Header adds liked count indicator
- New deps: vaul, embla-carousel-react, @use-gesture/react, @react-spring/web
2026-02-21 15:48:17 +00:00
|
|
|
</div>
|
|
|
|
|
)}
|
2026-02-28 16:21:20 +00:00
|
|
|
|
|
|
|
|
{!hasOverview && !detail.floorplans.length && !detail.agency && (
|
|
|
|
|
<p className="text-sm text-muted-foreground">No overview information available.</p>
|
Add tappable cards, detail bottom sheet, swipe gestures, and favorites view
- Decision types, services (decisionService, listingDetailService), and index exports
- useDecisions hook with optimistic updates and Map-based state
- useListingDetail hook with session-level caching
- PhotoCarousel component using embla-carousel-react
- ListingDetail component with full property info, like/dislike buttons
- ListingDetailSheet using vaul Drawer (slide-up bottom sheet)
- SwipeablePropertyCard with @use-gesture/react and @react-spring/web
- SwipeReviewMode for mobile full-screen swipe review
- FavoritesView with virtualized liked listings and remove button
- App.tsx integration: decision state, client-side disliked filtering, detail sheet, swipe handlers
- ListView conditionally renders SwipeablePropertyCard when handlers provided
- StatsBar adds 'saved' view mode with heart icon
- Header adds liked count indicator
- New deps: vaul, embla-carousel-react, @use-gesture/react, @react-spring/web
2026-02-21 15:48:17 +00:00
|
|
|
)}
|
2026-02-28 16:21:20 +00:00
|
|
|
</TabsContent>
|
|
|
|
|
|
|
|
|
|
{/* Travel tab */}
|
|
|
|
|
{hasTravel && (
|
|
|
|
|
<TabsContent value="travel">
|
|
|
|
|
<div className="overflow-x-auto">
|
|
|
|
|
<table className="w-full text-sm">
|
|
|
|
|
<thead>
|
|
|
|
|
<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>
|
Add tappable cards, detail bottom sheet, swipe gestures, and favorites view
- Decision types, services (decisionService, listingDetailService), and index exports
- useDecisions hook with optimistic updates and Map-based state
- useListingDetail hook with session-level caching
- PhotoCarousel component using embla-carousel-react
- ListingDetail component with full property info, like/dislike buttons
- ListingDetailSheet using vaul Drawer (slide-up bottom sheet)
- SwipeablePropertyCard with @use-gesture/react and @react-spring/web
- SwipeReviewMode for mobile full-screen swipe review
- FavoritesView with virtualized liked listings and remove button
- App.tsx integration: decision state, client-side disliked filtering, detail sheet, swipe handlers
- ListView conditionally renders SwipeablePropertyCard when handlers provided
- StatsBar adds 'saved' view mode with heart icon
- Header adds liked count indicator
- New deps: vaul, embla-carousel-react, @use-gesture/react, @react-spring/web
2026-02-21 15:48:17 +00:00
|
|
|
</div>
|
2026-02-28 16:21:20 +00:00
|
|
|
</TabsContent>
|
|
|
|
|
)}
|
Add tappable cards, detail bottom sheet, swipe gestures, and favorites view
- Decision types, services (decisionService, listingDetailService), and index exports
- useDecisions hook with optimistic updates and Map-based state
- useListingDetail hook with session-level caching
- PhotoCarousel component using embla-carousel-react
- ListingDetail component with full property info, like/dislike buttons
- ListingDetailSheet using vaul Drawer (slide-up bottom sheet)
- SwipeablePropertyCard with @use-gesture/react and @react-spring/web
- SwipeReviewMode for mobile full-screen swipe review
- FavoritesView with virtualized liked listings and remove button
- App.tsx integration: decision state, client-side disliked filtering, detail sheet, swipe handlers
- ListView conditionally renders SwipeablePropertyCard when handlers provided
- StatsBar adds 'saved' view mode with heart icon
- Header adds liked count indicator
- New deps: vaul, embla-carousel-react, @use-gesture/react, @react-spring/web
2026-02-21 15:48:17 +00:00
|
|
|
|
2026-02-28 16:21:20 +00:00
|
|
|
{/* 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">
|
wrongmove: guard property cards against null backend fields (fix BUY crash)
BUY listings can come back from the API with null `total_price`, `qm`,
`qmprice`, `rooms`, and `last_seen` even though the TypeScript types
declare them non-nullable. The cards called `.toLocaleString()` and
`.split('T')` on those values, throwing TypeError and tripping the
ErrorBoundary — which is the "BUY part of the page crashes" symptom.
Coerce numeric fields via a `safeNum` helper (0 fallback), gate the
"Nd ago" line on a finite last_seen, and apply the same guards in
PropertyCard, PropertyCardCompact, SwipeCard, and the price-history
section of ListingDetail. Added regression tests asserting both card
variants render with all-null backend fields without throwing.
2026-05-10 21:17:41 +00:00
|
|
|
<span className="text-muted-foreground">
|
|
|
|
|
{typeof entry.last_seen === 'string' ? entry.last_seen.split('T')[0] : '—'}
|
|
|
|
|
</span>
|
|
|
|
|
<span className="font-medium">
|
|
|
|
|
£{typeof entry.price === 'number' && Number.isFinite(entry.price) ? entry.price.toLocaleString() : '—'}
|
|
|
|
|
</span>
|
2026-02-28 16:21:20 +00:00
|
|
|
</div>
|
|
|
|
|
))}
|
|
|
|
|
</div>
|
|
|
|
|
</TabsContent>
|
|
|
|
|
)}
|
Add tappable cards, detail bottom sheet, swipe gestures, and favorites view
- Decision types, services (decisionService, listingDetailService), and index exports
- useDecisions hook with optimistic updates and Map-based state
- useListingDetail hook with session-level caching
- PhotoCarousel component using embla-carousel-react
- ListingDetail component with full property info, like/dislike buttons
- ListingDetailSheet using vaul Drawer (slide-up bottom sheet)
- SwipeablePropertyCard with @use-gesture/react and @react-spring/web
- SwipeReviewMode for mobile full-screen swipe review
- FavoritesView with virtualized liked listings and remove button
- App.tsx integration: decision state, client-side disliked filtering, detail sheet, swipe handlers
- ListView conditionally renders SwipeablePropertyCard when handlers provided
- StatsBar adds 'saved' view mode with heart icon
- Header adds liked count indicator
- New deps: vaul, embla-carousel-react, @use-gesture/react, @react-spring/web
2026-02-21 15:48:17 +00:00
|
|
|
|
2026-02-28 16:21:20 +00:00
|
|
|
{/* 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>
|
Add tappable cards, detail bottom sheet, swipe gestures, and favorites view
- Decision types, services (decisionService, listingDetailService), and index exports
- useDecisions hook with optimistic updates and Map-based state
- useListingDetail hook with session-level caching
- PhotoCarousel component using embla-carousel-react
- ListingDetail component with full property info, like/dislike buttons
- ListingDetailSheet using vaul Drawer (slide-up bottom sheet)
- SwipeablePropertyCard with @use-gesture/react and @react-spring/web
- SwipeReviewMode for mobile full-screen swipe review
- FavoritesView with virtualized liked listings and remove button
- App.tsx integration: decision state, client-side disliked filtering, detail sheet, swipe handlers
- ListView conditionally renders SwipeablePropertyCard when handlers provided
- StatsBar adds 'saved' view mode with heart icon
- Header adds liked count indicator
- New deps: vaul, embla-carousel-react, @use-gesture/react, @react-spring/web
2026-02-21 15:48:17 +00:00
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
}
|