- 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
122 lines
3.2 KiB
TypeScript
122 lines
3.2 KiB
TypeScript
import { useRef, useState } from 'react';
|
|
import { useDrag } from '@use-gesture/react';
|
|
import { useSpring, animated } from '@react-spring/web';
|
|
import { Heart, X } from 'lucide-react';
|
|
import { PropertyCard } from './PropertyCard';
|
|
import type { PropertyProperties, POI, DecisionType } from '@/types';
|
|
|
|
interface SwipeablePropertyCardProps {
|
|
property: PropertyProperties;
|
|
variant?: 'compact' | 'full';
|
|
isHighlighted?: boolean;
|
|
avgPricePerSqm?: number;
|
|
allPOIs?: POI[];
|
|
onClick?: () => void;
|
|
onSwipeRight?: () => void;
|
|
onSwipeLeft?: () => void;
|
|
decision?: DecisionType | null;
|
|
}
|
|
|
|
const SWIPE_THRESHOLD = 80;
|
|
|
|
export function SwipeablePropertyCard({
|
|
property,
|
|
variant = 'compact',
|
|
isHighlighted = false,
|
|
avgPricePerSqm,
|
|
allPOIs,
|
|
onClick,
|
|
onSwipeRight,
|
|
onSwipeLeft,
|
|
decision,
|
|
}: SwipeablePropertyCardProps) {
|
|
const containerRef = useRef<HTMLDivElement>(null);
|
|
const [gone, setGone] = useState(false);
|
|
|
|
const [{ x, opacity }, api] = useSpring(() => ({
|
|
x: 0,
|
|
opacity: 1,
|
|
config: { tension: 200, friction: 20 },
|
|
}));
|
|
|
|
const bind = useDrag(
|
|
({ active, movement: [mx], direction: [dx] }) => {
|
|
if (gone) return;
|
|
|
|
// If dragging vertically more than horizontally, cancel (let scroll work)
|
|
if (active && Math.abs(mx) < 15) return;
|
|
|
|
if (!active) {
|
|
// Released
|
|
if (Math.abs(mx) > SWIPE_THRESHOLD) {
|
|
// Swipe confirmed
|
|
setGone(true);
|
|
const dir = dx > 0 ? 1 : -1;
|
|
api.start({
|
|
x: dir * 400,
|
|
opacity: 0,
|
|
onRest: () => {
|
|
if (dir > 0) onSwipeRight?.();
|
|
else onSwipeLeft?.();
|
|
},
|
|
});
|
|
} else {
|
|
// Spring back
|
|
api.start({ x: 0, opacity: 1 });
|
|
}
|
|
} else {
|
|
api.start({ x: mx, opacity: 1 - Math.abs(mx) / 400, immediate: true });
|
|
}
|
|
},
|
|
{
|
|
axis: 'x',
|
|
filterTaps: true,
|
|
from: () => [x.get(), 0],
|
|
},
|
|
);
|
|
|
|
if (gone) return null;
|
|
|
|
const likedBadge = decision === 'liked';
|
|
|
|
return (
|
|
<div className="relative overflow-hidden" ref={containerRef}>
|
|
{/* Background indicators */}
|
|
<div className="absolute inset-0 flex items-center justify-between px-6 pointer-events-none">
|
|
<div className="flex items-center gap-2 text-destructive">
|
|
<X className="h-8 w-8" />
|
|
</div>
|
|
<div className="flex items-center gap-2 text-green-600">
|
|
<Heart className="h-8 w-8" />
|
|
</div>
|
|
</div>
|
|
|
|
{/* Card */}
|
|
<animated.div
|
|
{...bind()}
|
|
style={{
|
|
x,
|
|
opacity,
|
|
touchAction: 'pan-y',
|
|
}}
|
|
className="relative"
|
|
>
|
|
<div className="relative">
|
|
{likedBadge && (
|
|
<div className="absolute top-1 right-1 z-10 bg-green-600 text-white rounded-full p-1">
|
|
<Heart className="h-3 w-3 fill-current" />
|
|
</div>
|
|
)}
|
|
<PropertyCard
|
|
property={property}
|
|
variant={variant}
|
|
isHighlighted={isHighlighted}
|
|
avgPricePerSqm={avgPricePerSqm}
|
|
allPOIs={allPOIs}
|
|
onClick={onClick}
|
|
/>
|
|
</div>
|
|
</animated.div>
|
|
</div>
|
|
);
|
|
}
|