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 (
+
+ );
+ }
+
+ return (
+
- {property.photo_thumbnail && (
-

- )}
+ {/* 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,