Add photo carousel to swipe cards

SwipeCard now shows all available photos (up to 5) using
embla-carousel instead of just the thumbnail. Includes prev/next
arrow buttons and dot indicators. The photo area uses touch-action:
pan-x so carousel swipes don't trigger card swipes.
This commit is contained in:
Viktor Barzin 2026-02-21 23:57:26 +00:00
parent a742f9bb65
commit 611449d328
No known key found for this signature in database
GPG key ID: 0EB088298288D958

View file

@ -1,7 +1,8 @@
import { useRef } from 'react';
import { useRef, useState, useCallback, useEffect } from 'react';
import { animated, useSpring } from '@react-spring/web';
import { useDrag } from '@use-gesture/react';
import { Bed, Maximize2, ExternalLink } from 'lucide-react';
import useEmblaCarousel from 'embla-carousel-react';
import { Bed, Maximize2, ExternalLink, ChevronLeft, ChevronRight } from 'lucide-react';
import type { PropertyFeature } from '@/types';
interface SwipeCardProps {
@ -17,6 +18,21 @@ const SWIPE_THRESHOLD = 100;
export function SwipeCard({ feature, onSwipe, onTap, isTop, stackIndex }: SwipeCardProps) {
const hasSwiped = useRef(false);
const p = feature.properties;
const photos = p.photos?.length ? p.photos : p.photo_thumbnail ? [p.photo_thumbnail] : [];
const [emblaRef, emblaApi] = useEmblaCarousel({ loop: true });
const [selectedPhoto, setSelectedPhoto] = useState(0);
const onPhotoSelect = useCallback(() => {
if (!emblaApi) return;
setSelectedPhoto(emblaApi.selectedScrollSnap());
}, [emblaApi]);
useEffect(() => {
if (!emblaApi) return;
emblaApi.on('select', onPhotoSelect);
return () => { emblaApi.off('select', onPhotoSelect); };
}, [emblaApi, onPhotoSelect]);
const [style, api] = useSpring(() => ({
x: 0,
@ -96,11 +112,42 @@ export function SwipeCard({ feature, onSwipe, onTap, isTop, stackIndex }: SwipeC
/>
)}
{/* Photo */}
<div className="h-48 bg-muted relative">
{p.photo_thumbnail && (
<img src={p.photo_thumbnail} alt="Property" className="w-full h-full object-cover" />
)}
{/* Photo carousel */}
<div className="h-48 bg-muted relative" style={{ touchAction: 'pan-x' }}>
{photos.length > 1 ? (
<>
<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>
<button
className="absolute left-1 top-1/2 -translate-y-1/2 bg-black/50 hover:bg-black/70 text-white rounded-full p-1 z-20 transition-colors"
onClick={(e) => { e.stopPropagation(); emblaApi?.scrollPrev(); }}
aria-label="Previous photo"
>
<ChevronLeft className="w-4 h-4" />
</button>
<button
className="absolute right-10 top-1/2 -translate-y-1/2 bg-black/50 hover:bg-black/70 text-white rounded-full p-1 z-20 transition-colors"
onClick={(e) => { e.stopPropagation(); emblaApi?.scrollNext(); }}
aria-label="Next photo"
>
<ChevronRight className="w-4 h-4" />
</button>
<div className="absolute bottom-1 left-0 right-0 flex justify-center gap-1 z-20">
{photos.map((_, i) => (
<div key={i} className={`w-1.5 h-1.5 rounded-full ${i === selectedPhoto ? 'bg-white' : 'bg-white/40'}`} />
))}
</div>
</>
) : photos.length === 1 ? (
<img src={photos[0]} alt="Property" className="w-full h-full object-cover" />
) : null}
<button
className="absolute top-3 right-3 bg-background/80 backdrop-blur-sm rounded-full p-2 z-20"
onClick={(e) => {