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
80
frontend/src/components/FavoritesView.tsx
Normal file
80
frontend/src/components/FavoritesView.tsx
Normal file
|
|
@ -0,0 +1,80 @@
|
|||
import { useMemo } from 'react';
|
||||
import { Virtuoso } from 'react-virtuoso';
|
||||
import { Heart, X } from 'lucide-react';
|
||||
import { PropertyCard } from './PropertyCard';
|
||||
import type { GeoJSONFeatureCollection, DecisionType } from '@/types';
|
||||
|
||||
interface FavoritesViewProps {
|
||||
listingData: GeoJSONFeatureCollection;
|
||||
getDecision: (listingId: number, listingType?: string) => DecisionType | undefined;
|
||||
onSelectListing?: (id: number) => void;
|
||||
onRemoveFavorite?: (id: number, listingType: 'RENT' | 'BUY') => void;
|
||||
}
|
||||
|
||||
function getListingId(url: string): number {
|
||||
const parts = url.split('/');
|
||||
return parseInt(parts[parts.length - 1], 10);
|
||||
}
|
||||
|
||||
export function FavoritesView({ listingData, getDecision, onSelectListing, onRemoveFavorite }: FavoritesViewProps) {
|
||||
const favorites = useMemo(() => {
|
||||
return listingData.features.filter((f) => {
|
||||
const id = f.properties.id ?? getListingId(f.properties.url);
|
||||
const type = f.properties.listing_type === 'BUY' ? 'BUY' : 'RENT';
|
||||
return getDecision(id, type) === 'liked';
|
||||
});
|
||||
}, [listingData.features, getDecision]);
|
||||
|
||||
if (favorites.length === 0) {
|
||||
return (
|
||||
<div className="h-full flex items-center justify-center">
|
||||
<div className="text-center p-8">
|
||||
<Heart className="h-12 w-12 mx-auto mb-4 text-muted-foreground" />
|
||||
<h2 className="text-lg font-semibold mb-2">No favorites yet</h2>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
Swipe right or tap the heart button on listings you like
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="h-full flex flex-col bg-background">
|
||||
<div className="px-3 py-2 text-sm text-muted-foreground border-b flex items-center gap-2">
|
||||
<Heart className="h-4 w-4 text-green-600 fill-current" />
|
||||
{favorites.length} saved {favorites.length === 1 ? 'listing' : 'listings'}
|
||||
</div>
|
||||
<Virtuoso
|
||||
className="flex-1"
|
||||
data={favorites}
|
||||
overscan={200}
|
||||
itemContent={(_index, feature) => {
|
||||
const id = feature.properties.id ?? getListingId(feature.properties.url);
|
||||
const type = feature.properties.listing_type === 'BUY' ? 'BUY' : 'RENT';
|
||||
return (
|
||||
<div className="px-3 pb-2 first:pt-3 relative">
|
||||
<PropertyCard
|
||||
property={feature.properties}
|
||||
variant="compact"
|
||||
onClick={() => onSelectListing?.(id)}
|
||||
/>
|
||||
{onRemoveFavorite && (
|
||||
<button
|
||||
className="absolute top-4 right-4 p-1 rounded-full bg-red-100 text-red-600 hover:bg-red-200 transition-colors"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onRemoveFavorite(id, type);
|
||||
}}
|
||||
title="Remove from favorites"
|
||||
>
|
||||
<X className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue