Add tappable cards, detail bottom sheet, swipe gestures, and favorites view
- Decision types, services (decisionService, listingDetailService), and index exports
- useDecisions hook with optimistic updates and Map-based state
- useListingDetail hook with session-level caching
- PhotoCarousel component using embla-carousel-react
- ListingDetail component with full property info, like/dislike buttons
- ListingDetailSheet using vaul Drawer (slide-up bottom sheet)
- SwipeablePropertyCard with @use-gesture/react and @react-spring/web
- SwipeReviewMode for mobile full-screen swipe review
- FavoritesView with virtualized liked listings and remove button
- App.tsx integration: decision state, client-side disliked filtering, detail sheet, swipe handlers
- ListView conditionally renders SwipeablePropertyCard when handlers provided
- StatsBar adds 'saved' view mode with heart icon
- Header adds liked count indicator
- New deps: vaul, embla-carousel-react, @use-gesture/react, @react-spring/web
2026-02-21 15:48:17 +00:00
|
|
|
import { useState, useCallback, useEffect } from 'react';
|
|
|
|
|
import useEmblaCarousel from 'embla-carousel-react';
|
2026-02-21 21:07:17 +00:00
|
|
|
import { ChevronLeft, ChevronRight } from 'lucide-react';
|
Add tappable cards, detail bottom sheet, swipe gestures, and favorites view
- Decision types, services (decisionService, listingDetailService), and index exports
- useDecisions hook with optimistic updates and Map-based state
- useListingDetail hook with session-level caching
- PhotoCarousel component using embla-carousel-react
- ListingDetail component with full property info, like/dislike buttons
- ListingDetailSheet using vaul Drawer (slide-up bottom sheet)
- SwipeablePropertyCard with @use-gesture/react and @react-spring/web
- SwipeReviewMode for mobile full-screen swipe review
- FavoritesView with virtualized liked listings and remove button
- App.tsx integration: decision state, client-side disliked filtering, detail sheet, swipe handlers
- ListView conditionally renders SwipeablePropertyCard when handlers provided
- StatsBar adds 'saved' view mode with heart icon
- Header adds liked count indicator
- New deps: vaul, embla-carousel-react, @use-gesture/react, @react-spring/web
2026-02-21 15:48:17 +00:00
|
|
|
import type { ListingDetailPhoto } from '@/types';
|
|
|
|
|
|
|
|
|
|
interface PhotoCarouselProps {
|
|
|
|
|
photos: ListingDetailPhoto[];
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export function PhotoCarousel({ photos }: PhotoCarouselProps) {
|
|
|
|
|
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 === 0) {
|
|
|
|
|
return (
|
|
|
|
|
<div className="w-full h-48 bg-muted flex items-center justify-center text-muted-foreground">
|
|
|
|
|
No photos available
|
|
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
<div className="relative">
|
|
|
|
|
<div className="overflow-hidden" ref={emblaRef}>
|
|
|
|
|
<div className="flex">
|
|
|
|
|
{photos.map((photo, i) => (
|
|
|
|
|
<div key={i} className="flex-[0_0_100%] min-w-0">
|
|
|
|
|
<img
|
|
|
|
|
src={photo.url}
|
|
|
|
|
alt={photo.caption || `Photo ${i + 1}`}
|
|
|
|
|
className="w-full h-64 object-cover"
|
|
|
|
|
loading="lazy"
|
|
|
|
|
/>
|
|
|
|
|
</div>
|
|
|
|
|
))}
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
2026-02-21 21:07:17 +00:00
|
|
|
{/* Prev/Next arrows */}
|
|
|
|
|
{photos.length > 1 && (
|
|
|
|
|
<>
|
|
|
|
|
<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 transition-colors"
|
|
|
|
|
onClick={() => emblaApi?.scrollPrev()}
|
|
|
|
|
aria-label="Previous photo"
|
|
|
|
|
>
|
|
|
|
|
<ChevronLeft className="w-5 h-5" />
|
|
|
|
|
</button>
|
|
|
|
|
<button
|
|
|
|
|
className="absolute right-1 top-1/2 -translate-y-1/2 bg-black/50 hover:bg-black/70 text-white rounded-full p-1 transition-colors"
|
|
|
|
|
onClick={() => emblaApi?.scrollNext()}
|
|
|
|
|
aria-label="Next photo"
|
|
|
|
|
>
|
|
|
|
|
<ChevronRight className="w-5 h-5" />
|
|
|
|
|
</button>
|
|
|
|
|
</>
|
|
|
|
|
)}
|
Add tappable cards, detail bottom sheet, swipe gestures, and favorites view
- Decision types, services (decisionService, listingDetailService), and index exports
- useDecisions hook with optimistic updates and Map-based state
- useListingDetail hook with session-level caching
- PhotoCarousel component using embla-carousel-react
- ListingDetail component with full property info, like/dislike buttons
- ListingDetailSheet using vaul Drawer (slide-up bottom sheet)
- SwipeablePropertyCard with @use-gesture/react and @react-spring/web
- SwipeReviewMode for mobile full-screen swipe review
- FavoritesView with virtualized liked listings and remove button
- App.tsx integration: decision state, client-side disliked filtering, detail sheet, swipe handlers
- ListView conditionally renders SwipeablePropertyCard when handlers provided
- StatsBar adds 'saved' view mode with heart icon
- Header adds liked count indicator
- New deps: vaul, embla-carousel-react, @use-gesture/react, @react-spring/web
2026-02-21 15:48:17 +00:00
|
|
|
{/* Counter */}
|
|
|
|
|
<div className="absolute bottom-2 right-2 bg-black/60 text-white text-xs px-2 py-1 rounded">
|
|
|
|
|
{selectedIndex + 1} / {photos.length}
|
|
|
|
|
</div>
|
|
|
|
|
{/* Dots */}
|
|
|
|
|
{photos.length > 1 && photos.length <= 20 && (
|
|
|
|
|
<div className="flex justify-center gap-1 mt-2">
|
|
|
|
|
{photos.map((_, i) => (
|
|
|
|
|
<button
|
|
|
|
|
key={i}
|
|
|
|
|
className={`w-1.5 h-1.5 rounded-full transition-colors ${
|
|
|
|
|
i === selectedIndex ? 'bg-primary' : 'bg-muted-foreground/30'
|
|
|
|
|
}`}
|
|
|
|
|
onClick={() => emblaApi?.scrollTo(i)}
|
|
|
|
|
/>
|
|
|
|
|
))}
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
}
|