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
29
frontend/package-lock.json
generated
29
frontend/package-lock.json
generated
|
|
@ -39,6 +39,7 @@
|
|||
"crossfilter2": "^1.5.4",
|
||||
"d3": "^7.9.0",
|
||||
"date-fns": "^4.1.0",
|
||||
"embla-carousel-react": "^8.6.0",
|
||||
"lucide-react": "^0.515.0",
|
||||
"mapbox-gl": "^3.12.0",
|
||||
"oidc-client-ts": "^3.2.1",
|
||||
|
|
@ -5088,6 +5089,34 @@
|
|||
"integrity": "sha512-0l1/0gOjESMeQyYaK5IDiPNvFeu93Z/cO0TjZh9eZ1vyCtZnA7KMZ8rQggpsJHIbGSdrqYq9OhuveadOVHCshw==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/embla-carousel": {
|
||||
"version": "8.6.0",
|
||||
"resolved": "https://registry.npmjs.org/embla-carousel/-/embla-carousel-8.6.0.tgz",
|
||||
"integrity": "sha512-SjWyZBHJPbqxHOzckOfo8lHisEaJWmwd23XppYFYVh10bU66/Pn5tkVkbkCMZVdbUE5eTCI2nD8OyIP4Z+uwkA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/embla-carousel-react": {
|
||||
"version": "8.6.0",
|
||||
"resolved": "https://registry.npmjs.org/embla-carousel-react/-/embla-carousel-react-8.6.0.tgz",
|
||||
"integrity": "sha512-0/PjqU7geVmo6F734pmPqpyHqiM99olvyecY7zdweCw+6tKEXnrE90pBiBbMMU8s5tICemzpQ3hi5EpxzGW+JA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"embla-carousel": "8.6.0",
|
||||
"embla-carousel-reactive-utils": "8.6.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": "^16.8.0 || ^17.0.1 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc"
|
||||
}
|
||||
},
|
||||
"node_modules/embla-carousel-reactive-utils": {
|
||||
"version": "8.6.0",
|
||||
"resolved": "https://registry.npmjs.org/embla-carousel-reactive-utils/-/embla-carousel-reactive-utils-8.6.0.tgz",
|
||||
"integrity": "sha512-fMVUDUEx0/uIEDM0Mz3dHznDhfX+znCCDCeIophYb1QGVM7YThSWX+wz11zlYwWFOr74b4QLGg0hrGPJeG2s4A==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"embla-carousel": "8.6.0"
|
||||
}
|
||||
},
|
||||
"node_modules/emoji-regex": {
|
||||
"version": "8.0.0",
|
||||
"resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz",
|
||||
|
|
|
|||
|
|
@ -44,6 +44,7 @@
|
|||
"crossfilter2": "^1.5.4",
|
||||
"d3": "^7.9.0",
|
||||
"date-fns": "^4.1.0",
|
||||
"embla-carousel-react": "^8.6.0",
|
||||
"lucide-react": "^0.515.0",
|
||||
"mapbox-gl": "^3.12.0",
|
||||
"oidc-client-ts": "^3.2.1",
|
||||
|
|
|
|||
|
|
@ -26,7 +26,8 @@ import { useDecisions } from '@/hooks/useDecisions';
|
|||
import { useIsMobile } from '@/hooks/use-mobile';
|
||||
import { MobileBottomSheet } from './components/MobileBottomSheet';
|
||||
import { SwipeReviewMode } from './components/SwipeReviewMode';
|
||||
import { SavedView } from './components/SavedView';
|
||||
import { FavoritesView } from './components/FavoritesView';
|
||||
import { ListingDetailSheet } from './components/ListingDetailSheet';
|
||||
|
||||
function isTerminalStatus(status: string): boolean {
|
||||
return status === 'SUCCESS' || status === 'FAILURE' || status === 'REVOKED';
|
||||
|
|
@ -54,8 +55,9 @@ function App() {
|
|||
const [poiTravelFilters, setPoiTravelFilters] = useState<Record<number, POITravelFilter>>({});
|
||||
const [currentMetric, setCurrentMetric] = useState<Metric>(DEFAULT_FILTER_VALUES.metric);
|
||||
const isMobile = useIsMobile();
|
||||
const [activeCardFeature, setActiveCardFeature] = useState<PropertyFeature | null>(null);
|
||||
const [, setActiveCardFeature] = useState<PropertyFeature | null>(null);
|
||||
const [showReviewMode, setShowReviewMode] = useState(false);
|
||||
const [selectedListingId, setSelectedListingId] = useState<number | null>(null);
|
||||
|
||||
// Decision state (like/dislike)
|
||||
const { decide, clear, getDecision, likedCount, isLoaded: isDecisionsLoaded } = useDecisions(user);
|
||||
|
|
@ -364,9 +366,11 @@ function App() {
|
|||
|
||||
if (viewMode === 'saved') {
|
||||
return (
|
||||
<SavedView
|
||||
listingData={processedListingData}
|
||||
<FavoritesView
|
||||
listingData={listingData!}
|
||||
getDecision={getDecision}
|
||||
onSelectListing={(id) => setSelectedListingId(id)}
|
||||
onRemoveFavorite={(id, type) => clear(id, type)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
@ -397,6 +401,10 @@ function App() {
|
|||
onPropertyClick={handlePropertyClick}
|
||||
highlightedPropertyUrl={highlightedProperty}
|
||||
poiMetricSelection={poiMetricSelection}
|
||||
onSelectListing={(id) => setSelectedListingId(id)}
|
||||
onSwipeRight={(id) => decide(id, 'liked', (queryParameters?.listing_type || 'RENT') as 'RENT' | 'BUY')}
|
||||
onSwipeLeft={(id) => decide(id, 'disliked', (queryParameters?.listing_type || 'RENT') as 'RENT' | 'BUY')}
|
||||
getDecision={(id) => getDecision(id, queryParameters?.listing_type || 'RENT')}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
|
@ -621,6 +629,17 @@ function App() {
|
|||
/>
|
||||
)}
|
||||
|
||||
{/* Listing Detail Bottom Sheet */}
|
||||
<ListingDetailSheet
|
||||
user={user}
|
||||
listingId={selectedListingId}
|
||||
listingType={(queryParameters?.listing_type || 'RENT') as 'RENT' | 'BUY'}
|
||||
onClose={() => setSelectedListingId(null)}
|
||||
onDecide={(id, decision, type) => decide(id, decision, type)}
|
||||
onClearDecision={(id, type) => clear(id, type)}
|
||||
currentDecision={selectedListingId ? getDecision(selectedListingId, queryParameters?.listing_type || 'RENT') : undefined}
|
||||
/>
|
||||
|
||||
{/* Error Dialog */}
|
||||
<AlertError message={submitError} open={alertDialogIsOpen} setIsOpen={setAlertDialogIsOpen} />
|
||||
</div>
|
||||
|
|
|
|||
80
frontend/src/components/FavoritesView.tsx
Normal file
80
frontend/src/components/FavoritesView.tsx
Normal file
|
|
@ -0,0 +1,80 @@
|
|||
import { useMemo } from 'react';
|
||||
import { Virtuoso } from 'react-virtuoso';
|
||||
import { Heart, X } from 'lucide-react';
|
||||
import { PropertyCard } from './PropertyCard';
|
||||
import type { GeoJSONFeatureCollection, DecisionType } from '@/types';
|
||||
|
||||
interface FavoritesViewProps {
|
||||
listingData: GeoJSONFeatureCollection;
|
||||
getDecision: (listingId: number, listingType?: string) => DecisionType | undefined;
|
||||
onSelectListing?: (id: number) => void;
|
||||
onRemoveFavorite?: (id: number, listingType: 'RENT' | 'BUY') => void;
|
||||
}
|
||||
|
||||
function getListingId(url: string): number {
|
||||
const parts = url.split('/');
|
||||
return parseInt(parts[parts.length - 1], 10);
|
||||
}
|
||||
|
||||
export function FavoritesView({ listingData, getDecision, onSelectListing, onRemoveFavorite }: FavoritesViewProps) {
|
||||
const favorites = useMemo(() => {
|
||||
return listingData.features.filter((f) => {
|
||||
const id = f.properties.id ?? getListingId(f.properties.url);
|
||||
const type = f.properties.listing_type === 'BUY' ? 'BUY' : 'RENT';
|
||||
return getDecision(id, type) === 'liked';
|
||||
});
|
||||
}, [listingData.features, getDecision]);
|
||||
|
||||
if (favorites.length === 0) {
|
||||
return (
|
||||
<div className="h-full flex items-center justify-center">
|
||||
<div className="text-center p-8">
|
||||
<Heart className="h-12 w-12 mx-auto mb-4 text-muted-foreground" />
|
||||
<h2 className="text-lg font-semibold mb-2">No favorites yet</h2>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
Swipe right or tap the heart button on listings you like
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="h-full flex flex-col bg-background">
|
||||
<div className="px-3 py-2 text-sm text-muted-foreground border-b flex items-center gap-2">
|
||||
<Heart className="h-4 w-4 text-green-600 fill-current" />
|
||||
{favorites.length} saved {favorites.length === 1 ? 'listing' : 'listings'}
|
||||
</div>
|
||||
<Virtuoso
|
||||
className="flex-1"
|
||||
data={favorites}
|
||||
overscan={200}
|
||||
itemContent={(_index, feature) => {
|
||||
const id = feature.properties.id ?? getListingId(feature.properties.url);
|
||||
const type = feature.properties.listing_type === 'BUY' ? 'BUY' : 'RENT';
|
||||
return (
|
||||
<div className="px-3 pb-2 first:pt-3 relative">
|
||||
<PropertyCard
|
||||
property={feature.properties}
|
||||
variant="compact"
|
||||
onClick={() => onSelectListing?.(id)}
|
||||
/>
|
||||
{onRemoveFavorite && (
|
||||
<button
|
||||
className="absolute top-4 right-4 p-1 rounded-full bg-red-100 text-red-600 hover:bg-red-200 transition-colors"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onRemoveFavorite(id, type);
|
||||
}}
|
||||
title="Remove from favorites"
|
||||
>
|
||||
<X className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -2,8 +2,9 @@ import { useState, useMemo, useCallback } from 'react';
|
|||
import { ArrowUpDown, ArrowUp, ArrowDown } from 'lucide-react';
|
||||
import { Virtuoso } from 'react-virtuoso';
|
||||
import { Button } from './ui/button';
|
||||
import { SwipeablePropertyCard } from './SwipeablePropertyCard';
|
||||
import { PropertyCard } from './PropertyCard';
|
||||
import type { GeoJSONFeatureCollection, PropertyFeature, PropertyProperties, POIDistanceInfo } from '@/types';
|
||||
import type { GeoJSONFeatureCollection, PropertyFeature, PropertyProperties, POIDistanceInfo, DecisionType } from '@/types';
|
||||
|
||||
type SortField = 'total_price' | 'qmprice' | 'qm' | 'rooms' | 'last_seen' | 'poi_travel';
|
||||
type SortOrder = 'asc' | 'desc';
|
||||
|
|
@ -13,6 +14,10 @@ interface ListViewProps {
|
|||
onPropertyClick?: (property: PropertyProperties, coordinates: [number, number]) => void;
|
||||
highlightedPropertyUrl?: string | null;
|
||||
poiMetricSelection?: { poiId: number; travelMode: string } | null;
|
||||
onSwipeLeft?: (id: number) => void;
|
||||
onSwipeRight?: (id: number) => void;
|
||||
getDecision?: (id: number) => DecisionType | undefined;
|
||||
onSelectListing?: (id: number) => void;
|
||||
}
|
||||
|
||||
interface SortConfig {
|
||||
|
|
@ -28,7 +33,7 @@ const BASE_SORT_OPTIONS: { field: SortField; label: string }[] = [
|
|||
{ field: 'last_seen', label: 'Last Seen' },
|
||||
];
|
||||
|
||||
export function ListView({ listingData, onPropertyClick, highlightedPropertyUrl, poiMetricSelection }: ListViewProps) {
|
||||
export function ListView({ listingData, onPropertyClick, highlightedPropertyUrl, poiMetricSelection, onSwipeLeft, onSwipeRight, getDecision, onSelectListing }: ListViewProps) {
|
||||
const [sortConfig, setSortConfig] = useState<SortConfig>({ field: 'qmprice', order: 'asc' });
|
||||
|
||||
// Calculate average price per sqm for "good deal" indicator
|
||||
|
|
@ -153,18 +158,43 @@ export function ListView({ listingData, onPropertyClick, highlightedPropertyUrl,
|
|||
className="flex-1"
|
||||
data={sortedFeatures}
|
||||
overscan={200}
|
||||
itemContent={(_index, feature) => (
|
||||
<div className="px-3 pb-2 first:pt-3">
|
||||
<PropertyCard
|
||||
key={feature.properties.url}
|
||||
property={feature.properties}
|
||||
variant="compact"
|
||||
avgPricePerSqm={avgPricePerSqm}
|
||||
isHighlighted={feature.properties.url === highlightedPropertyUrl}
|
||||
onClick={() => handlePropertyClick(feature)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
itemContent={(_index, feature) => {
|
||||
const listingId = feature.properties.id;
|
||||
const hasSwipe = onSwipeLeft && onSwipeRight;
|
||||
|
||||
if (hasSwipe && listingId !== undefined) {
|
||||
return (
|
||||
<div className="px-3 pb-2 first:pt-3">
|
||||
<SwipeablePropertyCard
|
||||
property={feature.properties}
|
||||
variant="compact"
|
||||
avgPricePerSqm={avgPricePerSqm}
|
||||
isHighlighted={feature.properties.url === highlightedPropertyUrl}
|
||||
decision={getDecision?.(listingId) ?? null}
|
||||
onSwipeLeft={() => onSwipeLeft(listingId)}
|
||||
onSwipeRight={() => onSwipeRight(listingId)}
|
||||
onClick={() => {
|
||||
onSelectListing?.(listingId);
|
||||
handlePropertyClick(feature);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="px-3 pb-2 first:pt-3">
|
||||
<PropertyCard
|
||||
key={feature.properties.url}
|
||||
property={feature.properties}
|
||||
variant="compact"
|
||||
avgPricePerSqm={avgPricePerSqm}
|
||||
isHighlighted={feature.properties.url === highlightedPropertyUrl}
|
||||
onClick={() => handlePropertyClick(feature)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
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>
|
||||
);
|
||||
}
|
||||
73
frontend/src/components/ListingDetailSheet.tsx
Normal file
73
frontend/src/components/ListingDetailSheet.tsx
Normal file
|
|
@ -0,0 +1,73 @@
|
|||
import { Drawer } from 'vaul';
|
||||
import { Loader2 } from 'lucide-react';
|
||||
import { ListingDetail } from './ListingDetail';
|
||||
import type { AuthUser } from '@/auth/types';
|
||||
import type { DecisionType } from '@/types';
|
||||
import { useListingDetail } from '@/hooks/useListingDetail';
|
||||
import { useEffect } from 'react';
|
||||
|
||||
interface ListingDetailSheetProps {
|
||||
user: AuthUser;
|
||||
listingId: number | null;
|
||||
listingType: 'RENT' | 'BUY';
|
||||
onClose: () => void;
|
||||
onDecide: (listingId: number, decision: DecisionType, listingType: 'RENT' | 'BUY') => void;
|
||||
onClearDecision: (listingId: number, listingType: 'RENT' | 'BUY') => void;
|
||||
currentDecision: DecisionType | undefined;
|
||||
}
|
||||
|
||||
export function ListingDetailSheet({
|
||||
user,
|
||||
listingId,
|
||||
listingType,
|
||||
onClose,
|
||||
onDecide,
|
||||
onClearDecision,
|
||||
currentDecision,
|
||||
}: ListingDetailSheetProps) {
|
||||
const { detail, isLoading, error, loadDetail, clearDetail } = useListingDetail(user);
|
||||
|
||||
useEffect(() => {
|
||||
if (listingId) {
|
||||
loadDetail(listingId, listingType);
|
||||
} else {
|
||||
clearDetail();
|
||||
}
|
||||
}, [listingId, listingType, loadDetail, clearDetail]);
|
||||
|
||||
// Override the decision from the detail with the current one from useDecisions (optimistic)
|
||||
const detailWithDecision = detail ? { ...detail, decision: currentDecision ?? null } : null;
|
||||
|
||||
return (
|
||||
<Drawer.Root
|
||||
open={listingId !== null}
|
||||
onOpenChange={(open) => { if (!open) onClose(); }}
|
||||
>
|
||||
<Drawer.Portal>
|
||||
<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]">
|
||||
<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">
|
||||
{isLoading && (
|
||||
<div className="flex items-center justify-center py-16">
|
||||
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
)}
|
||||
{error && (
|
||||
<div className="p-4 text-center text-destructive">
|
||||
Failed to load listing details: {error}
|
||||
</div>
|
||||
)}
|
||||
{detailWithDecision && !isLoading && (
|
||||
<ListingDetail
|
||||
detail={detailWithDecision}
|
||||
onDecide={(decision) => onDecide(listingId!, decision, listingType)}
|
||||
onClearDecision={() => onClearDecision(listingId!, listingType)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</Drawer.Content>
|
||||
</Drawer.Portal>
|
||||
</Drawer.Root>
|
||||
);
|
||||
}
|
||||
|
|
@ -25,7 +25,7 @@ export function MobileBottomSheet({
|
|||
onActiveListingChange,
|
||||
poiMetricSelection,
|
||||
}: MobileBottomSheetProps) {
|
||||
const [snap, setSnap] = useState<string | number>("148px");
|
||||
const [snap, setSnap] = useState<string | number | null>("148px");
|
||||
const [activeCardIndex, setActiveCardIndex] = useState(0);
|
||||
|
||||
const features = listingData?.features ?? [];
|
||||
|
|
|
|||
68
frontend/src/components/PhotoCarousel.tsx
Normal file
68
frontend/src/components/PhotoCarousel.tsx
Normal file
|
|
@ -0,0 +1,68 @@
|
|||
import { useState, useCallback, useEffect } from 'react';
|
||||
import useEmblaCarousel from 'embla-carousel-react';
|
||||
import type { ListingDetailPhoto } from '@/types';
|
||||
|
||||
interface PhotoCarouselProps {
|
||||
photos: ListingDetailPhoto[];
|
||||
}
|
||||
|
||||
export function PhotoCarousel({ photos }: PhotoCarouselProps) {
|
||||
const [emblaRef, emblaApi] = useEmblaCarousel({ loop: true });
|
||||
const [selectedIndex, setSelectedIndex] = useState(0);
|
||||
|
||||
const onSelect = useCallback(() => {
|
||||
if (!emblaApi) return;
|
||||
setSelectedIndex(emblaApi.selectedScrollSnap());
|
||||
}, [emblaApi]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!emblaApi) return;
|
||||
emblaApi.on('select', onSelect);
|
||||
return () => { emblaApi.off('select', onSelect); };
|
||||
}, [emblaApi, onSelect]);
|
||||
|
||||
if (photos.length === 0) {
|
||||
return (
|
||||
<div className="w-full h-48 bg-muted flex items-center justify-center text-muted-foreground">
|
||||
No photos available
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="relative">
|
||||
<div className="overflow-hidden" ref={emblaRef}>
|
||||
<div className="flex">
|
||||
{photos.map((photo, i) => (
|
||||
<div key={i} className="flex-[0_0_100%] min-w-0">
|
||||
<img
|
||||
src={photo.url}
|
||||
alt={photo.caption || `Photo ${i + 1}`}
|
||||
className="w-full h-64 object-cover"
|
||||
loading="lazy"
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
{/* Counter */}
|
||||
<div className="absolute bottom-2 right-2 bg-black/60 text-white text-xs px-2 py-1 rounded">
|
||||
{selectedIndex + 1} / {photos.length}
|
||||
</div>
|
||||
{/* Dots */}
|
||||
{photos.length > 1 && photos.length <= 20 && (
|
||||
<div className="flex justify-center gap-1 mt-2">
|
||||
{photos.map((_, i) => (
|
||||
<button
|
||||
key={i}
|
||||
className={`w-1.5 h-1.5 rounded-full transition-colors ${
|
||||
i === selectedIndex ? 'bg-primary' : 'bg-muted-foreground/30'
|
||||
}`}
|
||||
onClick={() => emblaApi?.scrollTo(i)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
122
frontend/src/components/SwipeablePropertyCard.tsx
Normal file
122
frontend/src/components/SwipeablePropertyCard.tsx
Normal file
|
|
@ -0,0 +1,122 @@
|
|||
import { useRef, useState } from 'react';
|
||||
import { useDrag } from '@use-gesture/react';
|
||||
import { useSpring, animated } from '@react-spring/web';
|
||||
import { Heart, X } from 'lucide-react';
|
||||
import { PropertyCard } from './PropertyCard';
|
||||
import type { PropertyProperties, POI, DecisionType } from '@/types';
|
||||
|
||||
interface SwipeablePropertyCardProps {
|
||||
property: PropertyProperties;
|
||||
variant?: 'compact' | 'full';
|
||||
isHighlighted?: boolean;
|
||||
avgPricePerSqm?: number;
|
||||
allPOIs?: POI[];
|
||||
onClick?: () => void;
|
||||
onSwipeRight?: () => void;
|
||||
onSwipeLeft?: () => void;
|
||||
decision?: DecisionType | null;
|
||||
}
|
||||
|
||||
const SWIPE_THRESHOLD = 80;
|
||||
|
||||
export function SwipeablePropertyCard({
|
||||
property,
|
||||
variant = 'compact',
|
||||
isHighlighted = false,
|
||||
avgPricePerSqm,
|
||||
allPOIs,
|
||||
onClick,
|
||||
onSwipeRight,
|
||||
onSwipeLeft,
|
||||
decision,
|
||||
}: SwipeablePropertyCardProps) {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const [gone, setGone] = useState(false);
|
||||
|
||||
const [{ x, opacity }, api] = useSpring(() => ({
|
||||
x: 0,
|
||||
opacity: 1,
|
||||
config: { tension: 200, friction: 20 },
|
||||
}));
|
||||
|
||||
const bind = useDrag(
|
||||
({ active, movement: [mx], direction: [dx] }) => {
|
||||
if (gone) return;
|
||||
|
||||
// If dragging vertically more than horizontally, cancel (let scroll work)
|
||||
if (active && Math.abs(mx) < 15) return;
|
||||
|
||||
if (!active) {
|
||||
// Released
|
||||
if (Math.abs(mx) > SWIPE_THRESHOLD) {
|
||||
// Swipe confirmed
|
||||
setGone(true);
|
||||
const dir = dx > 0 ? 1 : -1;
|
||||
api.start({
|
||||
x: dir * 400,
|
||||
opacity: 0,
|
||||
onRest: () => {
|
||||
if (dir > 0) onSwipeRight?.();
|
||||
else onSwipeLeft?.();
|
||||
},
|
||||
});
|
||||
} else {
|
||||
// Spring back
|
||||
api.start({ x: 0, opacity: 1 });
|
||||
}
|
||||
} else {
|
||||
api.start({ x: mx, opacity: 1 - Math.abs(mx) / 400, immediate: true });
|
||||
}
|
||||
},
|
||||
{
|
||||
axis: 'x',
|
||||
filterTaps: true,
|
||||
from: () => [x.get(), 0],
|
||||
},
|
||||
);
|
||||
|
||||
if (gone) return null;
|
||||
|
||||
const likedBadge = decision === 'liked';
|
||||
|
||||
return (
|
||||
<div className="relative overflow-hidden" ref={containerRef}>
|
||||
{/* Background indicators */}
|
||||
<div className="absolute inset-0 flex items-center justify-between px-6 pointer-events-none">
|
||||
<div className="flex items-center gap-2 text-destructive">
|
||||
<X className="h-8 w-8" />
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-green-600">
|
||||
<Heart className="h-8 w-8" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Card */}
|
||||
<animated.div
|
||||
{...bind()}
|
||||
style={{
|
||||
x,
|
||||
opacity,
|
||||
touchAction: 'pan-y',
|
||||
}}
|
||||
className="relative"
|
||||
>
|
||||
<div className="relative">
|
||||
{likedBadge && (
|
||||
<div className="absolute top-1 right-1 z-10 bg-green-600 text-white rounded-full p-1">
|
||||
<Heart className="h-3 w-3 fill-current" />
|
||||
</div>
|
||||
)}
|
||||
<PropertyCard
|
||||
property={property}
|
||||
variant={variant}
|
||||
isHighlighted={isHighlighted}
|
||||
avgPricePerSqm={avgPricePerSqm}
|
||||
allPOIs={allPOIs}
|
||||
onClick={onClick}
|
||||
/>
|
||||
</div>
|
||||
</animated.div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
42
frontend/src/hooks/useListingDetail.ts
Normal file
42
frontend/src/hooks/useListingDetail.ts
Normal file
|
|
@ -0,0 +1,42 @@
|
|||
import { useState, useRef, useCallback } from 'react';
|
||||
import type { AuthUser } from '@/auth/types';
|
||||
import type { ListingDetailData } from '@/types';
|
||||
import { fetchListingDetail } from '@/services';
|
||||
|
||||
export function useListingDetail(user: AuthUser | null) {
|
||||
const [detail, setDetail] = useState<ListingDetailData | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const cache = useRef<Map<string, ListingDetailData>>(new Map());
|
||||
|
||||
const loadDetail = useCallback(
|
||||
async (listingId: number, listingType: 'RENT' | 'BUY' = 'RENT') => {
|
||||
if (!user) return;
|
||||
const cacheKey = `${listingId}-${listingType}`;
|
||||
const cached = cache.current.get(cacheKey);
|
||||
if (cached) {
|
||||
setDetail(cached);
|
||||
return;
|
||||
}
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const data = await fetchListingDetail(user, listingId, listingType);
|
||||
cache.current.set(cacheKey, data);
|
||||
setDetail(data);
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : String(e));
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
},
|
||||
[user],
|
||||
);
|
||||
|
||||
const clearDetail = useCallback(() => {
|
||||
setDetail(null);
|
||||
setError(null);
|
||||
}, []);
|
||||
|
||||
return { detail, isLoading, error, loadDetail, clearDetail };
|
||||
}
|
||||
|
|
@ -6,3 +6,4 @@ export { fetchTasksForUser, fetchTaskStatus, cancelTask, clearAllTasks, type Can
|
|||
export { checkBackendHealth, type HealthStatus, type HealthCheckResult } from './healthService';
|
||||
export { fetchUserPOIs, createPOI, updatePOI, deletePOI, triggerPOICalculation, fetchPOIDistances } from './poiService';
|
||||
export { fetchDecisions, setDecision, clearDecision } from './decisionService';
|
||||
export { fetchListingDetail } from './listingDetailService';
|
||||
|
|
|
|||
13
frontend/src/services/listingDetailService.ts
Normal file
13
frontend/src/services/listingDetailService.ts
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
import type { AuthUser } from '@/auth/types';
|
||||
import type { ListingDetailData } from '@/types';
|
||||
import { apiRequest } from './apiClient';
|
||||
|
||||
export async function fetchListingDetail(
|
||||
user: AuthUser,
|
||||
listingId: number,
|
||||
listingType: 'RENT' | 'BUY' = 'RENT',
|
||||
): Promise<ListingDetailData> {
|
||||
return apiRequest<ListingDetailData>(user, `/api/listing/${listingId}/detail`, {
|
||||
params: { listing_type: listingType },
|
||||
});
|
||||
}
|
||||
|
|
@ -8,6 +8,7 @@ export interface PropertyPriceHistory {
|
|||
}
|
||||
|
||||
export interface PropertyProperties {
|
||||
id: number;
|
||||
url: string;
|
||||
city: string;
|
||||
country: string;
|
||||
|
|
@ -190,3 +191,39 @@ export interface ListingDecision {
|
|||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
// Listing detail types
|
||||
export interface ListingDetailPhoto {
|
||||
url: string;
|
||||
caption: string | null;
|
||||
type: string | null;
|
||||
}
|
||||
|
||||
export interface ListingDetailFloorplan {
|
||||
url: string;
|
||||
caption: string | null;
|
||||
}
|
||||
|
||||
export interface ListingDetailData {
|
||||
id: number;
|
||||
price: number;
|
||||
number_of_bedrooms: number;
|
||||
square_meters: number | null;
|
||||
agency: string | null;
|
||||
council_tax_band: string | null;
|
||||
url: string;
|
||||
listing_type: 'RENT' | 'BUY';
|
||||
description: string | null;
|
||||
display_address: string | null;
|
||||
property_sub_type: string | null;
|
||||
key_features: string[];
|
||||
photos: ListingDetailPhoto[];
|
||||
floorplans: ListingDetailFloorplan[];
|
||||
price_history: PropertyPriceHistory[];
|
||||
furnish_type: string | null;
|
||||
available_from: string | null;
|
||||
service_charge: number | null;
|
||||
lease_left: number | null;
|
||||
decision: DecisionType | null;
|
||||
poi_distances: POIDistanceInfo[];
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue