wrongmove/frontend/src/components/MobileBottomSheet.tsx

129 lines
5.4 KiB
TypeScript
Raw Normal View History

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) {
const [snap, setSnap] = useState<string | number | null>("148px");
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' }}
>
<Drawer.Title className="sr-only">Property Listings</Drawer.Title>
{/* 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>
);
}