- 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
80 lines
2.9 KiB
TypeScript
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>
|
|
);
|
|
}
|