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 { 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 */}
|
||||||
|
|
|
||||||
|
|
@ -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();
|
||||||
|
|
|
||||||
|
|
@ -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[];
|
||||||
|
|
|
||||||
|
|
@ -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',
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue