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[];
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue