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:
Viktor Barzin 2026-02-21 15:48:17 +00:00
parent 9e1beb7495
commit a2745c1478
No known key found for this signature in database
GPG key ID: 0EB088298288D958
14 changed files with 755 additions and 19 deletions

View file

@ -26,7 +26,8 @@ import { useDecisions } from '@/hooks/useDecisions';
import { useIsMobile } from '@/hooks/use-mobile';
import { MobileBottomSheet } from './components/MobileBottomSheet';
import { SwipeReviewMode } from './components/SwipeReviewMode';
import { SavedView } from './components/SavedView';
import { FavoritesView } from './components/FavoritesView';
import { ListingDetailSheet } from './components/ListingDetailSheet';
function isTerminalStatus(status: string): boolean {
return status === 'SUCCESS' || status === 'FAILURE' || status === 'REVOKED';
@ -54,8 +55,9 @@ function App() {
const [poiTravelFilters, setPoiTravelFilters] = useState<Record<number, POITravelFilter>>({});
const [currentMetric, setCurrentMetric] = useState<Metric>(DEFAULT_FILTER_VALUES.metric);
const isMobile = useIsMobile();
const [activeCardFeature, setActiveCardFeature] = useState<PropertyFeature | null>(null);
const [, setActiveCardFeature] = useState<PropertyFeature | null>(null);
const [showReviewMode, setShowReviewMode] = useState(false);
const [selectedListingId, setSelectedListingId] = useState<number | null>(null);
// Decision state (like/dislike)
const { decide, clear, getDecision, likedCount, isLoaded: isDecisionsLoaded } = useDecisions(user);
@ -364,9 +366,11 @@ function App() {
if (viewMode === 'saved') {
return (
<SavedView
listingData={processedListingData}
<FavoritesView
listingData={listingData!}
getDecision={getDecision}
onSelectListing={(id) => setSelectedListingId(id)}
onRemoveFavorite={(id, type) => clear(id, type)}
/>
);
}
@ -397,6 +401,10 @@ function App() {
onPropertyClick={handlePropertyClick}
highlightedPropertyUrl={highlightedProperty}
poiMetricSelection={poiMetricSelection}
onSelectListing={(id) => setSelectedListingId(id)}
onSwipeRight={(id) => decide(id, 'liked', (queryParameters?.listing_type || 'RENT') as 'RENT' | 'BUY')}
onSwipeLeft={(id) => decide(id, 'disliked', (queryParameters?.listing_type || 'RENT') as 'RENT' | 'BUY')}
getDecision={(id) => getDecision(id, queryParameters?.listing_type || 'RENT')}
/>
</div>
)}
@ -621,6 +629,17 @@ function App() {
/>
)}
{/* Listing Detail Bottom Sheet */}
<ListingDetailSheet
user={user}
listingId={selectedListingId}
listingType={(queryParameters?.listing_type || 'RENT') as 'RENT' | 'BUY'}
onClose={() => setSelectedListingId(null)}
onDecide={(id, decision, type) => decide(id, decision, type)}
onClearDecision={(id, type) => clear(id, type)}
currentDecision={selectedListingId ? getDecision(selectedListingId, queryParameters?.listing_type || 'RENT') : undefined}
/>
{/* Error Dialog */}
<AlertError message={submitError} open={alertDialogIsOpen} setIsOpen={setAlertDialogIsOpen} />
</div>