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:
Viktor Barzin 2026-02-21 15:48:17 +00:00
parent 9e1beb7495
commit a2745c1478
No known key found for this signature in database
GPG key ID: 0EB088298288D958
14 changed files with 755 additions and 19 deletions

View file

@ -39,6 +39,7 @@
"crossfilter2": "^1.5.4", "crossfilter2": "^1.5.4",
"d3": "^7.9.0", "d3": "^7.9.0",
"date-fns": "^4.1.0", "date-fns": "^4.1.0",
"embla-carousel-react": "^8.6.0",
"lucide-react": "^0.515.0", "lucide-react": "^0.515.0",
"mapbox-gl": "^3.12.0", "mapbox-gl": "^3.12.0",
"oidc-client-ts": "^3.2.1", "oidc-client-ts": "^3.2.1",
@ -5088,6 +5089,34 @@
"integrity": "sha512-0l1/0gOjESMeQyYaK5IDiPNvFeu93Z/cO0TjZh9eZ1vyCtZnA7KMZ8rQggpsJHIbGSdrqYq9OhuveadOVHCshw==", "integrity": "sha512-0l1/0gOjESMeQyYaK5IDiPNvFeu93Z/cO0TjZh9eZ1vyCtZnA7KMZ8rQggpsJHIbGSdrqYq9OhuveadOVHCshw==",
"license": "ISC" "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": { "node_modules/emoji-regex": {
"version": "8.0.0", "version": "8.0.0",
"resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz",

View file

@ -44,6 +44,7 @@
"crossfilter2": "^1.5.4", "crossfilter2": "^1.5.4",
"d3": "^7.9.0", "d3": "^7.9.0",
"date-fns": "^4.1.0", "date-fns": "^4.1.0",
"embla-carousel-react": "^8.6.0",
"lucide-react": "^0.515.0", "lucide-react": "^0.515.0",
"mapbox-gl": "^3.12.0", "mapbox-gl": "^3.12.0",
"oidc-client-ts": "^3.2.1", "oidc-client-ts": "^3.2.1",

View file

@ -26,7 +26,8 @@ import { useDecisions } from '@/hooks/useDecisions';
import { useIsMobile } from '@/hooks/use-mobile'; import { useIsMobile } from '@/hooks/use-mobile';
import { MobileBottomSheet } from './components/MobileBottomSheet'; import { MobileBottomSheet } from './components/MobileBottomSheet';
import { SwipeReviewMode } from './components/SwipeReviewMode'; 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 { function isTerminalStatus(status: string): boolean {
return status === 'SUCCESS' || status === 'FAILURE' || status === 'REVOKED'; return status === 'SUCCESS' || status === 'FAILURE' || status === 'REVOKED';
@ -54,8 +55,9 @@ function App() {
const [poiTravelFilters, setPoiTravelFilters] = useState<Record<number, POITravelFilter>>({}); const [poiTravelFilters, setPoiTravelFilters] = useState<Record<number, POITravelFilter>>({});
const [currentMetric, setCurrentMetric] = useState<Metric>(DEFAULT_FILTER_VALUES.metric); const [currentMetric, setCurrentMetric] = useState<Metric>(DEFAULT_FILTER_VALUES.metric);
const isMobile = useIsMobile(); const isMobile = useIsMobile();
const [activeCardFeature, setActiveCardFeature] = useState<PropertyFeature | null>(null); const [, setActiveCardFeature] = useState<PropertyFeature | null>(null);
const [showReviewMode, setShowReviewMode] = useState(false); const [showReviewMode, setShowReviewMode] = useState(false);
const [selectedListingId, setSelectedListingId] = useState<number | null>(null);
// Decision state (like/dislike) // Decision state (like/dislike)
const { decide, clear, getDecision, likedCount, isLoaded: isDecisionsLoaded } = useDecisions(user); const { decide, clear, getDecision, likedCount, isLoaded: isDecisionsLoaded } = useDecisions(user);
@ -364,9 +366,11 @@ function App() {
if (viewMode === 'saved') { if (viewMode === 'saved') {
return ( return (
<SavedView <FavoritesView
listingData={processedListingData} listingData={listingData!}
getDecision={getDecision} getDecision={getDecision}
onSelectListing={(id) => setSelectedListingId(id)}
onRemoveFavorite={(id, type) => clear(id, type)}
/> />
); );
} }
@ -397,6 +401,10 @@ function App() {
onPropertyClick={handlePropertyClick} onPropertyClick={handlePropertyClick}
highlightedPropertyUrl={highlightedProperty} highlightedPropertyUrl={highlightedProperty}
poiMetricSelection={poiMetricSelection} 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> </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 */} {/* Error Dialog */}
<AlertError message={submitError} open={alertDialogIsOpen} setIsOpen={setAlertDialogIsOpen} /> <AlertError message={submitError} open={alertDialogIsOpen} setIsOpen={setAlertDialogIsOpen} />
</div> </div>

View 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>
);
}

View file

@ -2,8 +2,9 @@ import { useState, useMemo, useCallback } from 'react';
import { ArrowUpDown, ArrowUp, ArrowDown } from 'lucide-react'; import { ArrowUpDown, ArrowUp, ArrowDown } from 'lucide-react';
import { Virtuoso } from 'react-virtuoso'; import { Virtuoso } from 'react-virtuoso';
import { Button } from './ui/button'; import { Button } from './ui/button';
import { SwipeablePropertyCard } from './SwipeablePropertyCard';
import { PropertyCard } from './PropertyCard'; 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 SortField = 'total_price' | 'qmprice' | 'qm' | 'rooms' | 'last_seen' | 'poi_travel';
type SortOrder = 'asc' | 'desc'; type SortOrder = 'asc' | 'desc';
@ -13,6 +14,10 @@ interface ListViewProps {
onPropertyClick?: (property: PropertyProperties, coordinates: [number, number]) => void; onPropertyClick?: (property: PropertyProperties, coordinates: [number, number]) => void;
highlightedPropertyUrl?: string | null; highlightedPropertyUrl?: string | null;
poiMetricSelection?: { poiId: number; travelMode: 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 { interface SortConfig {
@ -28,7 +33,7 @@ const BASE_SORT_OPTIONS: { field: SortField; label: string }[] = [
{ field: 'last_seen', label: 'Last Seen' }, { 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' }); const [sortConfig, setSortConfig] = useState<SortConfig>({ field: 'qmprice', order: 'asc' });
// Calculate average price per sqm for "good deal" indicator // Calculate average price per sqm for "good deal" indicator
@ -153,18 +158,43 @@ export function ListView({ listingData, onPropertyClick, highlightedPropertyUrl,
className="flex-1" className="flex-1"
data={sortedFeatures} data={sortedFeatures}
overscan={200} overscan={200}
itemContent={(_index, feature) => ( itemContent={(_index, feature) => {
<div className="px-3 pb-2 first:pt-3"> const listingId = feature.properties.id;
<PropertyCard const hasSwipe = onSwipeLeft && onSwipeRight;
key={feature.properties.url}
property={feature.properties} if (hasSwipe && listingId !== undefined) {
variant="compact" return (
avgPricePerSqm={avgPricePerSqm} <div className="px-3 pb-2 first:pt-3">
isHighlighted={feature.properties.url === highlightedPropertyUrl} <SwipeablePropertyCard
onClick={() => handlePropertyClick(feature)} property={feature.properties}
/> variant="compact"
</div> 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> </div>
); );

View 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>
);
}

View 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>
);
}

View file

@ -25,7 +25,7 @@ export function MobileBottomSheet({
onActiveListingChange, onActiveListingChange,
poiMetricSelection, poiMetricSelection,
}: MobileBottomSheetProps) { }: MobileBottomSheetProps) {
const [snap, setSnap] = useState<string | number>("148px"); const [snap, setSnap] = useState<string | number | null>("148px");
const [activeCardIndex, setActiveCardIndex] = useState(0); const [activeCardIndex, setActiveCardIndex] = useState(0);
const features = listingData?.features ?? []; const features = listingData?.features ?? [];

View 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>
);
}

View 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>
);
}

View 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 };
}

View file

@ -6,3 +6,4 @@ export { fetchTasksForUser, fetchTaskStatus, cancelTask, clearAllTasks, type Can
export { checkBackendHealth, type HealthStatus, type HealthCheckResult } from './healthService'; export { checkBackendHealth, type HealthStatus, type HealthCheckResult } from './healthService';
export { fetchUserPOIs, createPOI, updatePOI, deletePOI, triggerPOICalculation, fetchPOIDistances } from './poiService'; export { fetchUserPOIs, createPOI, updatePOI, deletePOI, triggerPOICalculation, fetchPOIDistances } from './poiService';
export { fetchDecisions, setDecision, clearDecision } from './decisionService'; export { fetchDecisions, setDecision, clearDecision } from './decisionService';
export { fetchListingDetail } from './listingDetailService';

View 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 },
});
}

View file

@ -8,6 +8,7 @@ export interface PropertyPriceHistory {
} }
export interface PropertyProperties { export interface PropertyProperties {
id: number;
url: string; url: string;
city: string; city: string;
country: string; country: string;
@ -190,3 +191,39 @@ export interface ListingDecision {
created_at: string; created_at: string;
updated_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[];
}