diff --git a/frontend/src/components/PropertyCard.tsx b/frontend/src/components/PropertyCard.tsx index 953b85c..7b3a8b9 100644 --- a/frontend/src/components/PropertyCard.tsx +++ b/frontend/src/components/PropertyCard.tsx @@ -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 ( + Property + ); + } + + return ( +
e.stopPropagation()}> +
+
+ {photos.map((url, i) => ( +
+ {`Photo +
+ ))} +
+
+
+ {photos.map((_, i) => ( +
+ ))} +
+
+ ); +} + 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 */} -
- {property.photo_thumbnail && ( - Property - )} + {/* Photo carousel */} +
+ {(property.photos?.length || property.photo_thumbnail) ? ( + + ) : null}
{/* Details */} diff --git a/frontend/src/components/__tests__/PropertyCard.test.tsx b/frontend/src/components/__tests__/PropertyCard.test.tsx index 72e6131..676b184 100644 --- a/frontend/src/components/__tests__/PropertyCard.test.tsx +++ b/frontend/src/components/__tests__/PropertyCard.test.tsx @@ -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(); 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(); diff --git a/frontend/src/types/index.ts b/frontend/src/types/index.ts index e1d09b8..6173ad8 100644 --- a/frontend/src/types/index.ts +++ b/frontend/src/types/index.ts @@ -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[]; diff --git a/repositories/listing_repository.py b/repositories/listing_repository.py index 3823847..f73e571 100644 --- a/repositories/listing_repository.py +++ b/repositories/listing_repository.py @@ -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', ] diff --git a/ui_exporter.py b/ui_exporter.py index b795e1d..4a8b914 100644 --- a/ui_exporter.py +++ b/ui_exporter.py @@ -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,