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:
parent
f2e8d7d9f9
commit
4deed9911c
5 changed files with 87 additions and 14 deletions
|
|
@ -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 { Button } from './ui/button';
|
||||
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 {
|
||||
property: PropertyProperties;
|
||||
variant?: 'compact' | 'full';
|
||||
|
|
@ -115,7 +173,6 @@ export function PropertyCard({
|
|||
: null;
|
||||
|
||||
const handleClick = () => {
|
||||
window.open(property.url, '_blank', 'noopener,noreferrer');
|
||||
onClick?.();
|
||||
};
|
||||
|
||||
|
|
@ -127,15 +184,11 @@ export function PropertyCard({
|
|||
}`}
|
||||
onClick={handleClick}
|
||||
>
|
||||
{/* Thumbnail */}
|
||||
<div className="w-20 h-20 rounded-md overflow-hidden flex-shrink-0 bg-muted">
|
||||
{property.photo_thumbnail && (
|
||||
<img
|
||||
src={property.photo_thumbnail}
|
||||
alt="Property"
|
||||
className="w-full h-full object-cover"
|
||||
/>
|
||||
)}
|
||||
{/* Photo carousel */}
|
||||
<div className="w-24 h-24 rounded-md overflow-hidden flex-shrink-0 bg-muted">
|
||||
{(property.photos?.length || property.photo_thumbnail) ? (
|
||||
<CardCarousel photos={property.photos?.length ? property.photos : [property.photo_thumbnail]} />
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{/* Details */}
|
||||
|
|
|
|||
|
|
@ -68,7 +68,7 @@ describe('PropertyCard', () => {
|
|||
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 onClick = vi.fn();
|
||||
const openSpy = vi.spyOn(window, 'open').mockImplementation(() => null);
|
||||
|
|
@ -77,7 +77,7 @@ describe('PropertyCard', () => {
|
|||
render(<PropertyCard property={property} onClick={onClick} />);
|
||||
|
||||
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();
|
||||
|
||||
openSpy.mockRestore();
|
||||
|
|
|
|||
|
|
@ -20,6 +20,7 @@ export interface PropertyProperties {
|
|||
available_from: string;
|
||||
last_seen: string;
|
||||
photo_thumbnail: string;
|
||||
photos?: string[];
|
||||
price_history: PropertyPriceHistory[];
|
||||
listing_type?: 'RENT' | 'BUY';
|
||||
poi_distances?: POIDistanceInfo[];
|
||||
|
|
|
|||
|
|
@ -16,12 +16,12 @@ from tqdm import tqdm
|
|||
|
||||
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 = [
|
||||
'id', 'price', 'number_of_bedrooms', 'square_meters',
|
||||
'longitude', 'latitude', 'photo_thumbnail', 'last_seen',
|
||||
'agency', 'price_history_json', 'available_from',
|
||||
'service_charge', 'lease_left',
|
||||
'service_charge', 'lease_left', 'additional_info',
|
||||
]
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -53,6 +53,17 @@ def convert_row_to_geojson(row: dict[str, Any], listing_type: str = "RENT") -> d
|
|||
else:
|
||||
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] = {
|
||||
"id": row['id'],
|
||||
"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,
|
||||
"url": f"https://www.rightmove.co.uk/properties/{row['id']}",
|
||||
"photo_thumbnail": row.get('photo_thumbnail'),
|
||||
"photos": photos,
|
||||
"last_seen": last_seen_str,
|
||||
"price_history": price_history,
|
||||
"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 {}
|
||||
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] = {
|
||||
"id": listing.id,
|
||||
"listing_type": listing_type,
|
||||
|
|
@ -109,6 +127,7 @@ def convert_to_geojson_feature(listing: RentListing | BuyListing) -> dict[str, A
|
|||
"total_price": listing.price,
|
||||
"url": listing.url,
|
||||
"photo_thumbnail": listing.photo_thumbnail,
|
||||
"photos": photos,
|
||||
"last_seen": listing.last_seen.isoformat(),
|
||||
"price_history": [item.to_dict() for item in listing.price_history],
|
||||
"agency": listing.agency,
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue