From d350b806ba8d71587cea1b4c2b10af7698821722 Mon Sep 17 00:00:00 2001 From: Viktor Barzin Date: Sat, 21 Feb 2026 13:53:06 +0000 Subject: [PATCH] feat: add SwipeCard and SwipeReviewMode components with gesture support --- frontend/src/components/SwipeCard.tsx | 145 ++++++++++++++++ frontend/src/components/SwipeReviewMode.tsx | 182 ++++++++++++++++++++ 2 files changed, 327 insertions(+) create mode 100644 frontend/src/components/SwipeCard.tsx create mode 100644 frontend/src/components/SwipeReviewMode.tsx diff --git a/frontend/src/components/SwipeCard.tsx b/frontend/src/components/SwipeCard.tsx new file mode 100644 index 0000000..5fe7248 --- /dev/null +++ b/frontend/src/components/SwipeCard.tsx @@ -0,0 +1,145 @@ +import { useRef } from 'react'; +import { animated, useSpring } from '@react-spring/web'; +import { useDrag } from '@use-gesture/react'; +import { Bed, Maximize2, ExternalLink } from 'lucide-react'; +import type { PropertyFeature } from '@/types'; + +interface SwipeCardProps { + feature: PropertyFeature; + onSwipe: (direction: 'left' | 'right' | 'up') => void; + isTop: boolean; + stackIndex: number; +} + +const SWIPE_THRESHOLD = 100; + +export function SwipeCard({ feature, onSwipe, isTop, stackIndex }: SwipeCardProps) { + const hasSwiped = useRef(false); + const p = feature.properties; + + const [style, api] = useSpring(() => ({ + x: 0, + y: 0, + rotate: 0, + scale: 1 - stackIndex * 0.05, + opacity: stackIndex <= 2 ? 1 : 0, + config: { tension: 300, friction: 25 }, + })); + + const bind = useDrag( + ({ active, movement: [mx, my], velocity: [vx, vy], direction: [dx, dy] }) => { + if (!isTop || hasSwiped.current) return; + + if (!active) { + const isSwipeRight = mx > SWIPE_THRESHOLD || (vx > 0.5 && dx > 0); + const isSwipeLeft = mx < -SWIPE_THRESHOLD || (vx > 0.5 && dx < 0); + const isSwipeUp = my < -SWIPE_THRESHOLD || (vy > 0.5 && dy < 0); + + if (isSwipeRight || isSwipeLeft || isSwipeUp) { + hasSwiped.current = true; + const direction = isSwipeRight ? 'right' : isSwipeLeft ? 'left' : 'up'; + const flyOutX = isSwipeRight ? 500 : isSwipeLeft ? -500 : 0; + const flyOutY = isSwipeUp ? -500 : 0; + api.start({ + x: flyOutX, + y: flyOutY, + rotate: isSwipeRight ? 15 : isSwipeLeft ? -15 : 0, + opacity: 0, + onRest: () => onSwipe(direction), + }); + } else { + api.start({ x: 0, y: 0, rotate: 0 }); + } + return; + } + + api.start({ + x: mx, + y: my, + rotate: mx / 20, + immediate: true, + }); + }, + { filterTaps: true, enabled: isTop }, + ); + + const overlayOpacity = style.x.to((x: number) => Math.min(Math.abs(x) / 150, 0.4)); + const overlayColor = style.x.to((x: number) => + x > 0 ? 'rgba(34,197,94,1)' : 'rgba(239,68,68,1)', + ); + + return ( + +
+ {/* Color overlay */} + {isTop && ( + + )} + + {/* Photo */} +
+ {p.photo_thumbnail && ( + Property + )} + +
+ + {/* Details */} +
+
+ £{p.total_price.toLocaleString()} + {p.listing_type !== 'BUY' && ( + /mo + )} +
+ +
+ + {p.rooms} bed + + + {p.qm} m² + + £{p.qmprice}/m² +
+ + {/* POI distances */} + {p.poi_distances && p.poi_distances.length > 0 && ( +
+ {p.poi_distances.slice(0, 4).map((d) => ( + + {d.poi_name}: {Math.round(d.duration_seconds / 60)}m + + ))} +
+ )} +
+
+
+ ); +} diff --git a/frontend/src/components/SwipeReviewMode.tsx b/frontend/src/components/SwipeReviewMode.tsx new file mode 100644 index 0000000..d739f08 --- /dev/null +++ b/frontend/src/components/SwipeReviewMode.tsx @@ -0,0 +1,182 @@ +import { useState, useCallback, useEffect } from 'react'; +import { X, Heart, ArrowUp, Undo2, ArrowLeft } from 'lucide-react'; +import { Button } from './ui/button'; +import { SwipeCard } from './SwipeCard'; +import type { PropertyFeature, DecisionType } from '@/types'; + +interface SwipeReviewModeProps { + features: PropertyFeature[]; + onDecide: (listingId: number, decision: DecisionType, listingType?: 'RENT' | 'BUY') => void; + onClear: (listingId: number, listingType?: 'RENT' | 'BUY') => void; + onClose: () => void; + getDecision: (listingId: number, listingType?: string) => DecisionType | undefined; +} + +interface HistoryEntry { + index: number; + listingId: number; + listingType: string; + action: 'liked' | 'disliked' | 'skipped'; +} + +function getListingId(feature: PropertyFeature): number { + const parts = feature.properties.url.split('/'); + return parseInt(parts[parts.length - 1], 10); +} + +function getListingType(feature: PropertyFeature): 'RENT' | 'BUY' { + return feature.properties.listing_type === 'BUY' ? 'BUY' : 'RENT'; +} + +export function SwipeReviewMode({ + features, + onDecide, + onClear, + onClose, + getDecision, +}: SwipeReviewModeProps) { + // Filter to only undecided features + const undecided = features.filter((f) => { + const id = getListingId(f); + const type = getListingType(f); + return getDecision(id, type) === undefined; + }); + + const [currentIndex, setCurrentIndex] = useState(0); + const [history, setHistory] = useState([]); + + const handleSwipe = useCallback( + (direction: 'left' | 'right' | 'up') => { + const feature = undecided[currentIndex]; + if (!feature) return; + + const listingId = getListingId(feature); + const listingType = getListingType(feature); + + let action: HistoryEntry['action']; + if (direction === 'right') { + action = 'liked'; + onDecide(listingId, 'liked', listingType); + } else if (direction === 'left') { + action = 'disliked'; + onDecide(listingId, 'disliked', listingType); + } else { + action = 'skipped'; + } + + setHistory((prev) => [...prev, { index: currentIndex, listingId, listingType, action }]); + setCurrentIndex((prev) => prev + 1); + }, + [currentIndex, undecided, onDecide], + ); + + const handleUndo = useCallback(() => { + const lastEntry = history[history.length - 1]; + if (!lastEntry) return; + + if (lastEntry.action !== 'skipped') { + onClear(lastEntry.listingId, lastEntry.listingType as 'RENT' | 'BUY'); + } + setHistory((prev) => prev.slice(0, -1)); + setCurrentIndex((prev) => prev - 1); + }, [history, onClear]); + + // Keyboard shortcuts + useEffect(() => { + const handler = (e: KeyboardEvent) => { + if (e.key === 'ArrowRight') handleSwipe('right'); + else if (e.key === 'ArrowLeft') handleSwipe('left'); + else if (e.key === 'ArrowUp') handleSwipe('up'); + else if ((e.ctrlKey || e.metaKey) && e.key === 'z') handleUndo(); + else if (e.key === 'Escape') onClose(); + }; + window.addEventListener('keydown', handler); + return () => window.removeEventListener('keydown', handler); + }, [handleSwipe, handleUndo, onClose]); + + const isFinished = currentIndex >= undecided.length; + + return ( +
+ {/* Header */} +
+ + + {Math.min(currentIndex + 1, undecided.length)} / {undecided.length} + +
+
+ + {/* Card stack */} +
+ {isFinished ? ( +
+
+

All done!

+

+ You've reviewed all {undecided.length} properties. +

+ +
+
+ ) : ( +
+ {undecided.slice(currentIndex, currentIndex + 3).map((feature, i) => ( + + ))} +
+ )} +
+ + {/* Action buttons */} + {!isFinished && ( +
+ + + + + + + +
+ )} +
+ ); +}