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:
Viktor Barzin 2026-02-21 11:34:53 +00:00
parent 8f068a581e
commit a744b33578
No known key found for this signature in database
GPG key ID: 0EB088298288D958
14 changed files with 1768 additions and 152 deletions

View 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>
);
}