wrongmove/frontend/src/components/FavoritesView.tsx
Viktor Barzin a2745c1478
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
2026-02-21 15:49:15 +00:00

80 lines
2.9 KiB
TypeScript

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>
);
}