diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 95bfaf6..0a9c521 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -15,15 +15,18 @@ import { ListView } from './components/ListView'; import { StreamingProgressBar } from './components/StreamingProgressBar'; import { Sheet, SheetContent, SheetTrigger } from './components/ui/sheet'; import { Button } from './components/ui/button'; -import { Filter } from 'lucide-react'; +import { Filter, Heart } from 'lucide-react'; import type { GeoJSONFeatureCollection, PropertyProperties, PropertyFeature, POI, POITravelFilter } from '@/types'; import { refreshListings, streamListingGeoJSON, fetchUserPOIs, type StreamingProgress } from '@/services'; import { setOnUnauthorized } from '@/services/apiClient'; import { clearPasskeyUser } from './auth/passkeyService'; import { poiMetricPropertyName, injectPoiMetricProperty } from '@/utils/poiUtils'; import { useTaskProgress } from '@/hooks/useTaskProgress'; +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'; function isTerminalStatus(status: string): boolean { return status === 'SUCCESS' || status === 'FAILURE' || status === 'REVOKED'; @@ -52,6 +55,10 @@ function App() { const [currentMetric, setCurrentMetric] = useState(DEFAULT_FILTER_VALUES.metric); const isMobile = useIsMobile(); const [activeCardFeature, setActiveCardFeature] = useState(null); + const [showReviewMode, setShowReviewMode] = useState(false); + + // Decision state (like/dislike) + const { decide, clear, getDecision, likedCount, isLoaded: isDecisionsLoaded } = useDecisions(user); // Explicit task ID set by fetch-data action (to track as "active") const [explicitTaskId, setExplicitTaskId] = useState(null); @@ -226,8 +233,18 @@ function App() { }); } + // Filter out disliked listings (client-side for instant feedback) + if (isDecisionsLoaded) { + features = features.filter((f) => { + const parts = f.properties.url.split('/'); + const id = parseInt(parts[parts.length - 1], 10); + const type = f.properties.listing_type === 'BUY' ? 'BUY' : 'RENT'; + return getDecision(id, type) !== 'disliked'; + }); + } + return { ...listingData, features }; - }, [listingData, poiMetricSelection, poiTravelFilters]); + }, [listingData, poiMetricSelection, poiTravelFilters, isDecisionsLoaded, getDecision]); // Compute the effective metric string for the heatmap const effectiveMetric = useMemo(() => { @@ -345,6 +362,15 @@ function App() { ); } + if (viewMode === 'saved') { + return ( + + ); + } + return ( <> {/* Map View */} @@ -420,8 +446,17 @@ function App() { )} - {/* Filter FAB */} + {/* Filter & Review FABs */}
+
)} @@ -574,6 +610,17 @@ function App() { )} + {/* Swipe Review Mode Overlay */} + {showReviewMode && processedListingData && ( + setShowReviewMode(false)} + getDecision={getDecision} + /> + )} + {/* Error Dialog */} diff --git a/frontend/src/components/SavedView.tsx b/frontend/src/components/SavedView.tsx new file mode 100644 index 0000000..bb3fe5d --- /dev/null +++ b/frontend/src/components/SavedView.tsx @@ -0,0 +1,61 @@ +import { useMemo } from 'react'; +import { Virtuoso } from 'react-virtuoso'; +import { Heart } from 'lucide-react'; +import { PropertyCard } from './PropertyCard'; +import type { GeoJSONFeatureCollection, PropertyFeature, DecisionType } from '@/types'; + +interface SavedViewProps { + listingData: GeoJSONFeatureCollection; + getDecision: (listingId: number, listingType?: string) => DecisionType | undefined; +} + +function getListingId(feature: PropertyFeature): number { + const parts = feature.properties.url.split('/'); + return parseInt(parts[parts.length - 1], 10); +} + +export function SavedView({ listingData, getDecision }: SavedViewProps) { + const savedFeatures = useMemo(() => { + return listingData.features.filter((f) => { + const id = getListingId(f); + const type = f.properties.listing_type === 'BUY' ? 'BUY' : 'RENT'; + return getDecision(id, type) === 'liked'; + }); + }, [listingData, getDecision]); + + if (savedFeatures.length === 0) { + return ( +
+
+ +

No saved properties yet

+

+ Use the Review mode to swipe through properties and save ones you like. +

+
+
+ ); + } + + return ( +
+
+ {savedFeatures.length} saved {savedFeatures.length === 1 ? 'property' : 'properties'} +
+ ( +
+ +
+ )} + /> +
+ ); +} diff --git a/frontend/src/components/StatsBar.tsx b/frontend/src/components/StatsBar.tsx index 8bd5206..00b4ef6 100644 --- a/frontend/src/components/StatsBar.tsx +++ b/frontend/src/components/StatsBar.tsx @@ -1,13 +1,14 @@ -import { BarChart3, MapPin, PoundSterling, Maximize2, List, Map as MapIcon } from 'lucide-react'; +import { BarChart3, MapPin, PoundSterling, Maximize2, List, Map as MapIcon, Heart } from 'lucide-react'; import { Button } from './ui/button'; import type { GeoJSONFeatureCollection, PropertyFeature } from '@/types'; -export type ViewMode = 'map' | 'list' | 'split'; +export type ViewMode = 'map' | 'list' | 'split' | 'saved'; interface StatsBarProps { listingData: GeoJSONFeatureCollection | null; viewMode: ViewMode; onViewModeChange: (mode: ViewMode) => void; + likedCount?: number; } interface ListingStats { @@ -59,7 +60,7 @@ function formatCurrency(value: number): string { return `£${Math.round(value)}`; } -export function StatsBar({ listingData, viewMode, onViewModeChange }: StatsBarProps) { +export function StatsBar({ listingData, viewMode, onViewModeChange, likedCount = 0 }: StatsBarProps) { const stats = calculateStats(listingData); return ( @@ -122,6 +123,15 @@ export function StatsBar({ listingData, viewMode, onViewModeChange }: StatsBarPr Split + );