Add photo carousel to listing cards and fix tap-to-detail

Backend: include first 5 photo URLs from additional_info in GeoJSON
streaming response, with fallback to photo_thumbnail.

Frontend: replace single thumbnail with swipeable embla-carousel on
compact cards. Remove window.open on card tap so clicking opens the
detail bottom sheet instead of navigating to Rightmove.
This commit is contained in:
Viktor Barzin 2026-02-21 19:19:32 +00:00
parent f2e8d7d9f9
commit 4deed9911c
No known key found for this signature in database
GPG key ID: 0EB088298288D958
5 changed files with 87 additions and 14 deletions

View file

@ -1,3 +1,5 @@
import { useState, useCallback, useEffect } from 'react';
import useEmblaCarousel from 'embla-carousel-react';
import { ExternalLink, Bed, Maximize2, PoundSterling, Clock, Building, Footprints, Bike, Train } from 'lucide-react'; import { ExternalLink, Bed, Maximize2, PoundSterling, Clock, Building, Footprints, Bike, Train } from 'lucide-react';
import { Button } from './ui/button'; import { Button } from './ui/button';
import type { PropertyProperties, POIDistanceInfo, POI } from '@/types'; import type { PropertyProperties, POIDistanceInfo, POI } from '@/types';
@ -84,6 +86,62 @@ function AllPOIDistances({ pois, distances }: { pois: POI[]; distances?: POIDist
); );
} }
function CardCarousel({ photos }: { photos: string[] }) {
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 <= 1) {
return (
<img
src={photos[0]}
alt="Property"
className="w-full h-full object-cover"
loading="lazy"
/>
);
}
return (
<div className="relative w-full h-full" onClick={e => e.stopPropagation()}>
<div className="overflow-hidden h-full" ref={emblaRef}>
<div className="flex h-full">
{photos.map((url, i) => (
<div key={i} className="flex-[0_0_100%] min-w-0 h-full">
<img
src={url}
alt={`Photo ${i + 1}`}
className="w-full h-full object-cover"
loading="lazy"
/>
</div>
))}
</div>
</div>
<div className="absolute bottom-1 left-0 right-0 flex justify-center gap-1">
{photos.map((_, i) => (
<div
key={i}
className={`w-1 h-1 rounded-full ${
i === selectedIndex ? 'bg-white' : 'bg-white/40'
}`}
/>
))}
</div>
</div>
);
}
interface PropertyCardProps { interface PropertyCardProps {
property: PropertyProperties; property: PropertyProperties;
variant?: 'compact' | 'full'; variant?: 'compact' | 'full';
@ -115,7 +173,6 @@ export function PropertyCard({
: null; : null;
const handleClick = () => { const handleClick = () => {
window.open(property.url, '_blank', 'noopener,noreferrer');
onClick?.(); onClick?.();
}; };
@ -127,15 +184,11 @@ export function PropertyCard({
}`} }`}
onClick={handleClick} onClick={handleClick}
> >
{/* Thumbnail */} {/* Photo carousel */}
<div className="w-20 h-20 rounded-md overflow-hidden flex-shrink-0 bg-muted"> <div className="w-24 h-24 rounded-md overflow-hidden flex-shrink-0 bg-muted">
{property.photo_thumbnail && ( {(property.photos?.length || property.photo_thumbnail) ? (
<img <CardCarousel photos={property.photos?.length ? property.photos : [property.photo_thumbnail]} />
src={property.photo_thumbnail} ) : null}
alt="Property"
className="w-full h-full object-cover"
/>
)}
</div> </div>
{/* Details */} {/* Details */}

View file

@ -68,7 +68,7 @@ describe('PropertyCard', () => {
expect(screen.queryByText('Above avg')).not.toBeInTheDocument(); expect(screen.queryByText('Above avg')).not.toBeInTheDocument();
}); });
it('calls onClick and opens window on click', async () => { it('calls onClick on click without opening window', async () => {
const user = userEvent.setup(); const user = userEvent.setup();
const onClick = vi.fn(); const onClick = vi.fn();
const openSpy = vi.spyOn(window, 'open').mockImplementation(() => null); const openSpy = vi.spyOn(window, 'open').mockImplementation(() => null);
@ -77,7 +77,7 @@ describe('PropertyCard', () => {
render(<PropertyCard property={property} onClick={onClick} />); render(<PropertyCard property={property} onClick={onClick} />);
await user.click(screen.getByText(/2,500/)); await user.click(screen.getByText(/2,500/));
expect(openSpy).toHaveBeenCalledWith('https://rightmove.co.uk/123', '_blank', 'noopener,noreferrer'); expect(openSpy).not.toHaveBeenCalled();
expect(onClick).toHaveBeenCalled(); expect(onClick).toHaveBeenCalled();
openSpy.mockRestore(); openSpy.mockRestore();

View file

@ -20,6 +20,7 @@ export interface PropertyProperties {
available_from: string; available_from: string;
last_seen: string; last_seen: string;
photo_thumbnail: string; photo_thumbnail: string;
photos?: string[];
price_history: PropertyPriceHistory[]; price_history: PropertyPriceHistory[];
listing_type?: 'RENT' | 'BUY'; listing_type?: 'RENT' | 'BUY';
poi_distances?: POIDistanceInfo[]; poi_distances?: POIDistanceInfo[];

View file

@ -16,12 +16,12 @@ from tqdm import tqdm
logger = logging.getLogger("uvicorn.error") logger = logging.getLogger("uvicorn.error")
# Columns needed for GeoJSON streaming (excludes routing_info_json, additional_info) # Columns needed for GeoJSON streaming (excludes routing_info_json)
STREAMING_COLUMNS = [ STREAMING_COLUMNS = [
'id', 'price', 'number_of_bedrooms', 'square_meters', 'id', 'price', 'number_of_bedrooms', 'square_meters',
'longitude', 'latitude', 'photo_thumbnail', 'last_seen', 'longitude', 'latitude', 'photo_thumbnail', 'last_seen',
'agency', 'price_history_json', 'available_from', 'agency', 'price_history_json', 'available_from',
'service_charge', 'lease_left', 'service_charge', 'lease_left', 'additional_info',
] ]

View file

@ -53,6 +53,17 @@ def convert_row_to_geojson(row: dict[str, Any], listing_type: str = "RENT") -> d
else: else:
last_seen_str = str(last_seen_val) last_seen_str = str(last_seen_val)
# Extract first 5 photo URLs from additional_info
photos: list[str] = []
additional_info = row.get('additional_info')
if additional_info:
if isinstance(additional_info, str):
additional_info = json.loads(additional_info)
images = additional_info.get('property', {}).get('images', [])
photos = [img['url'] for img in images[:5] if isinstance(img, dict) and 'url' in img]
if not photos and row.get('photo_thumbnail'):
photos = [row['photo_thumbnail']]
properties: dict[str, Any] = { properties: dict[str, Any] = {
"id": row['id'], "id": row['id'],
"listing_type": listing_type, "listing_type": listing_type,
@ -64,6 +75,7 @@ def convert_row_to_geojson(row: dict[str, Any], listing_type: str = "RENT") -> d
"total_price": price, "total_price": price,
"url": f"https://www.rightmove.co.uk/properties/{row['id']}", "url": f"https://www.rightmove.co.uk/properties/{row['id']}",
"photo_thumbnail": row.get('photo_thumbnail'), "photo_thumbnail": row.get('photo_thumbnail'),
"photos": photos,
"last_seen": last_seen_str, "last_seen": last_seen_str,
"price_history": price_history, "price_history": price_history,
"agency": row.get('agency'), "agency": row.get('agency'),
@ -98,6 +110,12 @@ def convert_to_geojson_feature(listing: RentListing | BuyListing) -> dict[str, A
property_info = listing.additional_info.get("property", {}) if listing.additional_info else {} property_info = listing.additional_info.get("property", {}) if listing.additional_info else {}
listing_type = "RENT" if isinstance(listing, RentListing) else "BUY" listing_type = "RENT" if isinstance(listing, RentListing) else "BUY"
# Extract first 5 photo URLs
images = property_info.get('images', [])
photos = [img['url'] for img in images[:5] if isinstance(img, dict) and 'url' in img]
if not photos and listing.photo_thumbnail:
photos = [listing.photo_thumbnail]
properties: dict[str, Any] = { properties: dict[str, Any] = {
"id": listing.id, "id": listing.id,
"listing_type": listing_type, "listing_type": listing_type,
@ -109,6 +127,7 @@ def convert_to_geojson_feature(listing: RentListing | BuyListing) -> dict[str, A
"total_price": listing.price, "total_price": listing.price,
"url": listing.url, "url": listing.url,
"photo_thumbnail": listing.photo_thumbnail, "photo_thumbnail": listing.photo_thumbnail,
"photos": photos,
"last_seen": listing.last_seen.isoformat(), "last_seen": listing.last_seen.isoformat(),
"price_history": [item.to_dict() for item in listing.price_history], "price_history": [item.to_dict() for item in listing.price_history],
"agency": listing.agency, "agency": listing.agency,