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
This commit is contained in:
parent
9e1beb7495
commit
a2745c1478
14 changed files with 755 additions and 19 deletions
122
frontend/src/components/SwipeablePropertyCard.tsx
Normal file
122
frontend/src/components/SwipeablePropertyCard.tsx
Normal file
|
|
@ -0,0 +1,122 @@
|
|||
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>
|
||||
);
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue