2026-02-21 11:34:53 +00:00
|
|
|
import { useState, useMemo, useCallback } from 'react';
|
|
|
|
|
import { Drawer } from 'vaul';
|
|
|
|
|
import { MapPin, PoundSterling } from 'lucide-react';
|
|
|
|
|
import { SwipeableCardRow } from './SwipeableCardRow';
|
|
|
|
|
import { ListView } from './ListView';
|
|
|
|
|
import type { GeoJSONFeatureCollection, PropertyProperties, PropertyFeature } from '@/types';
|
|
|
|
|
|
|
|
|
|
interface MobileBottomSheetProps {
|
|
|
|
|
listingData: GeoJSONFeatureCollection | null;
|
|
|
|
|
onPropertyClick?: (property: PropertyProperties, coordinates: [number, number]) => void;
|
|
|
|
|
highlightedPropertyUrl?: string | null;
|
|
|
|
|
onActiveListingChange?: (feature: PropertyFeature | null) => void;
|
|
|
|
|
poiMetricSelection?: { poiId: number; travelMode: string } | null;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function formatCurrency(value: number): string {
|
|
|
|
|
if (value >= 1000) return `£${(value / 1000).toFixed(1)}k`;
|
|
|
|
|
return `£${Math.round(value)}`;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export function MobileBottomSheet({
|
|
|
|
|
listingData,
|
|
|
|
|
onPropertyClick,
|
|
|
|
|
highlightedPropertyUrl,
|
|
|
|
|
onActiveListingChange,
|
|
|
|
|
poiMetricSelection,
|
|
|
|
|
}: MobileBottomSheetProps) {
|
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:48:17 +00:00
|
|
|
const [snap, setSnap] = useState<string | number | null>("148px");
|
2026-02-21 11:34:53 +00:00
|
|
|
const [activeCardIndex, setActiveCardIndex] = useState(0);
|
|
|
|
|
|
|
|
|
|
const features = listingData?.features ?? [];
|
|
|
|
|
|
|
|
|
|
const stats = useMemo(() => {
|
|
|
|
|
if (features.length === 0) return { count: 0, avgPrice: 0 };
|
|
|
|
|
const validPrices = features
|
|
|
|
|
.map((f) => f.properties.total_price)
|
|
|
|
|
.filter((p): p is number => typeof p === 'number' && p > 0);
|
|
|
|
|
const avgPrice = validPrices.length > 0
|
|
|
|
|
? validPrices.reduce((a, b) => a + b, 0) / validPrices.length
|
|
|
|
|
: 0;
|
|
|
|
|
return { count: features.length, avgPrice };
|
|
|
|
|
}, [features]);
|
|
|
|
|
|
|
|
|
|
const avgPricePerSqm = useMemo(() => {
|
|
|
|
|
const validPrices = features
|
|
|
|
|
.map((f) => f.properties.qmprice)
|
|
|
|
|
.filter((p): p is number => typeof p === 'number' && p > 0);
|
|
|
|
|
return validPrices.length > 0
|
|
|
|
|
? validPrices.reduce((a, b) => a + b, 0) / validPrices.length
|
|
|
|
|
: 0;
|
|
|
|
|
}, [features]);
|
|
|
|
|
|
|
|
|
|
const handleActiveIndexChange = useCallback((index: number) => {
|
|
|
|
|
setActiveCardIndex(index);
|
|
|
|
|
if (features[index]) {
|
|
|
|
|
onActiveListingChange?.(features[index]);
|
|
|
|
|
}
|
|
|
|
|
}, [features, onActiveListingChange]);
|
|
|
|
|
|
|
|
|
|
const handleCardClick = useCallback((feature: PropertyFeature) => {
|
|
|
|
|
window.open(feature.properties.url, '_blank', 'noopener,noreferrer');
|
|
|
|
|
onPropertyClick?.(feature.properties, feature.geometry.coordinates);
|
|
|
|
|
}, [onPropertyClick]);
|
|
|
|
|
|
|
|
|
|
const isExpanded = snap === 0.85;
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
<Drawer.Root
|
|
|
|
|
open
|
|
|
|
|
snapPoints={["80px", "148px", 0.85]}
|
|
|
|
|
activeSnapPoint={snap}
|
|
|
|
|
setActiveSnapPoint={setSnap}
|
|
|
|
|
modal={false}
|
|
|
|
|
>
|
|
|
|
|
<Drawer.Portal>
|
|
|
|
|
<Drawer.Content
|
|
|
|
|
className="fixed inset-x-0 bottom-0 z-40 flex flex-col rounded-t-xl bg-background border-t shadow-lg"
|
|
|
|
|
style={{ maxHeight: '85vh' }}
|
|
|
|
|
>
|
|
|
|
|
{/* Drag handle */}
|
|
|
|
|
<div className="flex justify-center pt-2 pb-1">
|
|
|
|
|
<div className="h-1.5 w-10 rounded-full bg-muted-foreground/30" />
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
{/* Compact stats (always visible) */}
|
|
|
|
|
<div className="flex items-center gap-4 px-4 pb-2 text-sm text-muted-foreground">
|
|
|
|
|
<div className="flex items-center gap-1.5">
|
|
|
|
|
<MapPin className="h-4 w-4" />
|
|
|
|
|
<span className="font-medium text-foreground">{stats.count.toLocaleString()}</span>
|
|
|
|
|
<span>listings</span>
|
|
|
|
|
</div>
|
|
|
|
|
{stats.avgPrice > 0 && (
|
|
|
|
|
<div className="flex items-center gap-1.5">
|
|
|
|
|
<PoundSterling className="h-4 w-4" />
|
|
|
|
|
<span>Avg: <span className="font-medium text-foreground">{formatCurrency(stats.avgPrice)}</span></span>
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
{/* Swipeable cards (visible at peek snap) */}
|
|
|
|
|
{!isExpanded && features.length > 0 && (
|
|
|
|
|
<SwipeableCardRow
|
|
|
|
|
features={features}
|
|
|
|
|
activeIndex={activeCardIndex}
|
|
|
|
|
onActiveIndexChange={handleActiveIndexChange}
|
|
|
|
|
avgPricePerSqm={avgPricePerSqm}
|
|
|
|
|
highlightedPropertyUrl={highlightedPropertyUrl}
|
|
|
|
|
onCardClick={handleCardClick}
|
|
|
|
|
/>
|
|
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
{/* Full list view (visible at expanded snap) */}
|
|
|
|
|
{isExpanded && listingData && features.length > 0 && (
|
|
|
|
|
<div className="flex-1 min-h-0 overflow-hidden">
|
|
|
|
|
<ListView
|
|
|
|
|
listingData={listingData}
|
|
|
|
|
onPropertyClick={onPropertyClick}
|
|
|
|
|
highlightedPropertyUrl={highlightedPropertyUrl}
|
|
|
|
|
poiMetricSelection={poiMetricSelection}
|
|
|
|
|
/>
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
</Drawer.Content>
|
|
|
|
|
</Drawer.Portal>
|
|
|
|
|
</Drawer.Root>
|
|
|
|
|
);
|
|
|
|
|
}
|