feat: add SavedView, integrate decisions into App with review mode and client-side filtering
This commit is contained in:
parent
341de89004
commit
43084ef19a
3 changed files with 124 additions and 6 deletions
|
|
@ -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<Metric>(DEFAULT_FILTER_VALUES.metric);
|
||||
const isMobile = useIsMobile();
|
||||
const [activeCardFeature, setActiveCardFeature] = useState<PropertyFeature | null>(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<string | null>(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 (
|
||||
<SavedView
|
||||
listingData={processedListingData}
|
||||
getDecision={getDecision}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Map View */}
|
||||
|
|
@ -420,8 +446,17 @@ function App() {
|
|||
)}
|
||||
</div>
|
||||
|
||||
{/* Filter FAB */}
|
||||
{/* Filter & Review FABs */}
|
||||
<div className="fixed bottom-24 right-4 z-50 flex flex-col gap-2">
|
||||
<Button
|
||||
size="lg"
|
||||
variant="outline"
|
||||
className="rounded-full shadow-lg h-14 w-14 bg-background"
|
||||
onClick={() => setShowReviewMode(true)}
|
||||
disabled={!processedListingData || processedListingData.features.length === 0}
|
||||
>
|
||||
<Heart className="h-6 w-6" />
|
||||
</Button>
|
||||
<Sheet open={mobileFilterOpen} onOpenChange={setMobileFilterOpen}>
|
||||
<SheetTrigger asChild>
|
||||
<Button size="lg" className="rounded-full shadow-lg h-14 w-14">
|
||||
|
|
@ -567,6 +602,7 @@ function App() {
|
|||
listingData={processedListingData}
|
||||
viewMode={viewMode}
|
||||
onViewModeChange={setViewMode}
|
||||
likedCount={likedCount}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
|
@ -574,6 +610,17 @@ function App() {
|
|||
</div>
|
||||
)}
|
||||
|
||||
{/* Swipe Review Mode Overlay */}
|
||||
{showReviewMode && processedListingData && (
|
||||
<SwipeReviewMode
|
||||
features={processedListingData.features}
|
||||
onDecide={decide}
|
||||
onClear={clear}
|
||||
onClose={() => setShowReviewMode(false)}
|
||||
getDecision={getDecision}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Error Dialog */}
|
||||
<AlertError message={submitError} open={alertDialogIsOpen} setIsOpen={setAlertDialogIsOpen} />
|
||||
</div>
|
||||
|
|
|
|||
61
frontend/src/components/SavedView.tsx
Normal file
61
frontend/src/components/SavedView.tsx
Normal file
|
|
@ -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 (
|
||||
<div className="flex-1 flex items-center justify-center">
|
||||
<div className="text-center p-8">
|
||||
<Heart className="h-12 w-12 mx-auto mb-4 text-muted-foreground" />
|
||||
<p className="text-xl font-semibold mb-2">No saved properties yet</p>
|
||||
<p className="text-muted-foreground">
|
||||
Use the Review mode to swipe through properties and save ones 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">
|
||||
{savedFeatures.length} saved {savedFeatures.length === 1 ? 'property' : 'properties'}
|
||||
</div>
|
||||
<Virtuoso
|
||||
className="flex-1"
|
||||
data={savedFeatures}
|
||||
overscan={200}
|
||||
itemContent={(_index, feature) => (
|
||||
<div className="px-3 pb-2 first:pt-3">
|
||||
<PropertyCard
|
||||
key={feature.properties.url}
|
||||
property={feature.properties}
|
||||
variant="compact"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -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
|
|||
</div>
|
||||
<span className="hidden sm:inline ml-1">Split</span>
|
||||
</Button>
|
||||
<Button
|
||||
variant={viewMode === 'saved' ? 'secondary' : 'ghost'}
|
||||
size="sm"
|
||||
className="h-7 px-2"
|
||||
onClick={() => onViewModeChange('saved')}
|
||||
>
|
||||
<Heart className="h-4 w-4" />
|
||||
<span className="hidden sm:inline ml-1">Saved{likedCount > 0 ? ` (${likedCount})` : ''}</span>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue