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
This commit is contained in:
parent
9e1beb7495
commit
a2745c1478
14 changed files with 755 additions and 19 deletions
221
frontend/src/components/ListingDetail.tsx
Normal file
221
frontend/src/components/ListingDetail.tsx
Normal file
|
|
@ -0,0 +1,221 @@
|
|||
import { ExternalLink, Heart, X, Bed, Maximize2, PoundSterling, Building, Clock, MapPin, Footprints, Bike, Train } from 'lucide-react';
|
||||
import { Button } from './ui/button';
|
||||
import { PhotoCarousel } from './PhotoCarousel';
|
||||
import type { ListingDetailData, DecisionType, POIDistanceInfo } from '@/types';
|
||||
|
||||
interface ListingDetailProps {
|
||||
detail: ListingDetailData;
|
||||
onDecide: (decision: DecisionType) => void;
|
||||
onClearDecision: () => void;
|
||||
}
|
||||
|
||||
function formatDuration(seconds: number): string {
|
||||
const minutes = Math.round(seconds / 60);
|
||||
if (minutes < 60) return `${minutes}m`;
|
||||
const hours = Math.floor(minutes / 60);
|
||||
const mins = minutes % 60;
|
||||
return mins > 0 ? `${hours}h${mins}m` : `${hours}h`;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
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 })),
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="pb-8">
|
||||
{/* Photo carousel */}
|
||||
<PhotoCarousel photos={allPhotos} />
|
||||
|
||||
<div className="px-4 pt-4 space-y-4">
|
||||
{/* Price + address */}
|
||||
<div>
|
||||
<div className="flex items-start justify-between">
|
||||
<div>
|
||||
<div className="text-2xl font-bold">
|
||||
£{detail.price.toLocaleString()}
|
||||
{detail.listing_type !== 'BUY' && (
|
||||
<span className="text-muted-foreground font-normal text-base">/mo</span>
|
||||
)}
|
||||
</div>
|
||||
{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>
|
||||
|
||||
{/* Like/Dislike buttons */}
|
||||
<div className="flex gap-3">
|
||||
<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>
|
||||
</div>
|
||||
|
||||
{/* Key stats */}
|
||||
<div className="grid grid-cols-3 gap-3">
|
||||
<div className="flex items-center gap-2 text-sm">
|
||||
<Bed className="h-4 w-4 text-muted-foreground" />
|
||||
<span><strong>{detail.number_of_bedrooms}</strong> beds</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-sm">
|
||||
<Maximize2 className="h-4 w-4 text-muted-foreground" />
|
||||
<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 */}
|
||||
{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>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Description */}
|
||||
{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>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Property details grid */}
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold mb-2">Details</h3>
|
||||
<div className="grid grid-cols-2 gap-2 text-sm">
|
||||
{detail.property_sub_type && (
|
||||
<div className="flex items-center gap-2">
|
||||
<Building className="h-4 w-4 text-muted-foreground" />
|
||||
<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 {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>
|
||||
)}
|
||||
|
||||
{/* POI distances */}
|
||||
{detail.poi_distances.length > 0 && (
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold mb-2">Travel Times</h3>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{detail.poi_distances.map((d: POIDistanceInfo) => (
|
||||
<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 */}
|
||||
{detail.agency && (
|
||||
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<Building className="h-4 w-4" />
|
||||
<span>{detail.agency}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* External link */}
|
||||
<Button asChild variant="outline" className="w-full">
|
||||
<a href={detail.url} target="_blank" rel="noopener noreferrer">
|
||||
View on Rightmove
|
||||
<ExternalLink className="ml-2 h-4 w-4" />
|
||||
</a>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue