feat: make frontend fully responsive with mobile-first layout
Add mobile-responsive design with full feature parity: - Bottom sheet (vaul) with 3 snap points for map+list coexistence - Swipeable property cards with horizontal scroll-snap - Hamburger menu with health, tasks, user info - Full-screen map with repositioned legend (top-left on mobile) - Filter FAB opening Sheet drawer - TaskProgressDrawer from bottom on mobile - All changes gated behind useIsMobile() hook (768px breakpoint) - Desktop layout completely untouched New components: MobileBottomSheet, SwipeableCardRow, PropertyCardCompact, MobileMenu Also fixes: idempotent longitude migration, React hooks order
This commit is contained in:
parent
8f068a581e
commit
a744b33578
14 changed files with 1768 additions and 152 deletions
127
frontend/src/components/MobileBottomSheet.tsx
Normal file
127
frontend/src/components/MobileBottomSheet.tsx
Normal file
|
|
@ -0,0 +1,127 @@
|
|||
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>("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' }}
|
||||
>
|
||||
{/* 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>
|
||||
);
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue