From a744b3357839d74453abe368826f6701f9eda16e Mon Sep 17 00:00:00 2001 From: Viktor Barzin Date: Sat, 21 Feb 2026 11:34:53 +0000 Subject: [PATCH] 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 --- ...5f1bc4e3323_fix_typo_in_logitude_column.py | 10 +- .../2026-02-21-mobile-responsive-design.md | 123 +++ .../2026-02-21-mobile-responsive-plan.md | 994 ++++++++++++++++++ frontend/package-lock.json | 14 + frontend/package.json | 13 +- frontend/src/App.tsx | 248 +++-- frontend/src/assets/Map.css | 17 +- frontend/src/components/Header.tsx | 100 +- frontend/src/components/MobileBottomSheet.tsx | 127 +++ frontend/src/components/MobileMenu.tsx | 96 ++ .../src/components/PropertyCardCompact.tsx | 76 ++ frontend/src/components/SwipeableCardRow.tsx | 87 ++ .../src/components/TaskProgressDrawer.tsx | 7 +- frontend/src/index.css | 8 + 14 files changed, 1768 insertions(+), 152 deletions(-) create mode 100644 docs/plans/2026-02-21-mobile-responsive-design.md create mode 100644 docs/plans/2026-02-21-mobile-responsive-plan.md create mode 100644 frontend/src/components/MobileBottomSheet.tsx create mode 100644 frontend/src/components/MobileMenu.tsx create mode 100644 frontend/src/components/PropertyCardCompact.tsx create mode 100644 frontend/src/components/SwipeableCardRow.tsx diff --git a/alembic/versions/e5f1bc4e3323_fix_typo_in_logitude_column.py b/alembic/versions/e5f1bc4e3323_fix_typo_in_logitude_column.py index 33baaed..2ec32d2 100644 --- a/alembic/versions/e5f1bc4e3323_fix_typo_in_logitude_column.py +++ b/alembic/versions/e5f1bc4e3323_fix_typo_in_logitude_column.py @@ -19,8 +19,14 @@ depends_on: Union[str, Sequence[str], None] = None def upgrade() -> None: - """Rename 'longtitude' to 'longitude' in buylisting table.""" - op.alter_column('buylisting', 'longtitude', new_column_name='longitude', existing_type=sa.Float(), existing_nullable=False) + """Rename 'longtitude' to 'longitude' in buylisting table (if the typo column exists).""" + conn = op.get_bind() + result = conn.execute(sa.text( + "SELECT COLUMN_NAME FROM INFORMATION_SCHEMA.COLUMNS " + "WHERE TABLE_NAME = 'buylisting' AND COLUMN_NAME = 'longtitude'" + )) + if result.fetchone() is not None: + op.alter_column('buylisting', 'longtitude', new_column_name='longitude', existing_type=sa.Float(), existing_nullable=False) def downgrade() -> None: diff --git a/docs/plans/2026-02-21-mobile-responsive-design.md b/docs/plans/2026-02-21-mobile-responsive-design.md new file mode 100644 index 0000000..3d92386 --- /dev/null +++ b/docs/plans/2026-02-21-mobile-responsive-design.md @@ -0,0 +1,123 @@ +# Mobile Responsive Design + +## Goal + +Make the realestate-crawler frontend fully responsive with complete feature parity on mobile devices. All desktop functionality must be preserved, adapted to mobile-friendly interaction patterns. + +## Current State + +The frontend (React 19, TypeScript, Vite, Tailwind CSS, Radix UI, Mapbox GL) has partial mobile support: +- `useIsMobile()` hook with 768px breakpoint +- Filter panel converts to FAB + Sheet on mobile +- Scattered `md:hidden` / `hidden md:block` patterns + +Missing: bottom sheet for map+list coexistence, swipeable property cards, compact header, touch-optimized interactions, mobile task progress. + +## Design Decisions + +| Decision | Choice | Rationale | +|----------|--------|-----------| +| Map + list coexistence | Bottom sheet over full-screen map | Natural pattern for geo apps (Google Maps, Zillow) | +| Property browsing | Horizontal swipeable cards | Quick scanning with map context preserved | +| Heavy features (filters, POI, tasks) | Sheet drawers | Already partly implemented; consistent UX | +| Bottom sheet library | `vaul` | Lightweight (~4KB), snap points, gesture handling, Radix-compatible | +| Card swiping | CSS scroll-snap | Native, no extra dependency, smooth performance | +| Desktop impact | None | All changes gated behind `useIsMobile()` | + +## Layout Architecture + +### Mobile (< 768px) + +``` ++----------------------------+ +| HEADER (compact, h-12) | +| Logo | hamburger | ++----------------------------+ +| | +| | +| FULL-SCREEN MAP | +| | +| | +| | ++----------------------------+ <- draggable handle +| Bottom Sheet | +| Stats bar (compact) | +| +----+ +----+ +----+ | <- swipeable cards +| |Card|>|Card|>|Card| | +| +----+ +----+ +----+ | +| | +| (drag up to expand to | +| full list view) | ++----------------------------+ + +FAB buttons (bottom-right, above sheet): + Filter icon - opens filter Sheet + POI icon - opens POI manager Sheet +``` + +### Bottom Sheet Snap Points + +- `80px` - collapsed: drag handle + listing count +- `35%` - peek (default): stats + swipeable property cards +- `85%` - expanded: full scrollable list with sort controls + +### Desktop (>= 768px) + +No changes. Existing layout preserved exactly. + +## Component Changes + +### New Components + +| Component | Purpose | +|-----------|---------| +| `MobileBottomSheet` | vaul-based drawer with 3 snap points, contains stats + cards + list | +| `SwipeableCardRow` | Horizontal scroll-snap container of compact property cards, syncs with map | +| `PropertyCardCompact` | ~280px wide card for swipe browsing (thumbnail, price, beds, sqm) | +| `MobileMenu` | Hamburger menu Sheet with health, tasks, user info | + +### Modified Components + +| Component | Changes | +|-----------|---------| +| `App.tsx` | Conditional layout: mobile renders full-screen map + MobileBottomSheet + FABs | +| `Header.tsx` | Hamburger menu on mobile, hide inline items | +| `StatsBar.tsx` | Render inside bottom sheet header on mobile | +| `Map.tsx` | Full-screen on mobile, legend to top-left, bidirectional sync with cards | +| `ListView.tsx` | Render inside expanded bottom sheet on mobile | +| `TaskProgressDrawer.tsx` | Bottom Sheet on mobile instead of right-side drawer | +| `FilterPanel.tsx` | Touch-friendly sizing, no structural changes | +| `POIManager.tsx` | Touch-friendly sizing when in Sheet | + +### Unchanged + +- Backend, API, services, auth +- Core logic, data flow, state management +- Desktop layout + +## Map-Card Synchronization + +- Swipe to a card -> map pans/highlights that listing's marker +- Tap a map marker -> cards scroll to that listing +- Maintains spatial context during browsing + +## Navigation & Touch + +- Header: logo + hamburger on mobile. Hamburger opens Sheet with health, tasks, user info +- Two FABs stacked bottom-right (filter, POI). Auto-hide when sheet is fully expanded +- All tap targets minimum 44x44px +- No hover-dependent interactions on mobile (tooltips become tap-to-show) +- Map popup close buttons enlarged +- Gesture isolation: horizontal card swipe, vertical sheet drag, and map pan don't conflict + +## New Dependencies + +- `vaul` (~4KB gzipped) - bottom sheet drawer with snap points and gestures + +## Scope Boundaries + +- No new backend work +- No new API endpoints +- No changes to authentication flow +- No changes to data models +- Purely frontend layout and interaction changes diff --git a/docs/plans/2026-02-21-mobile-responsive-plan.md b/docs/plans/2026-02-21-mobile-responsive-plan.md new file mode 100644 index 0000000..e9b8736 --- /dev/null +++ b/docs/plans/2026-02-21-mobile-responsive-plan.md @@ -0,0 +1,994 @@ +# Mobile Responsive Implementation Plan + +> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. + +**Goal:** Make the frontend fully responsive with mobile-first bottom sheet + swipeable cards pattern, preserving all desktop functionality. + +**Architecture:** Mobile layout uses a full-screen map with a vaul-based bottom sheet (3 snap points) containing stats and property cards. All heavy features (filters, POI, tasks) use Sheet drawers. Desktop layout is untouched — all mobile changes gated behind `useIsMobile()`. New components: `MobileBottomSheet`, `SwipeableCardRow`, `PropertyCardCompact`, `MobileMenu`. + +**Tech Stack:** React 19, TypeScript, Tailwind CSS 4, vaul (new), Radix UI, Mapbox GL, react-virtuoso + +--- + +### Task 1: Install vaul dependency + +**Files:** +- Modify: `frontend/package.json` + +**Step 1: Install vaul** + +Run: `cd frontend && npm install vaul` + +**Step 2: Verify installation** + +Run: `cd frontend && node -e "require('vaul'); console.log('vaul OK')"` +Expected: `vaul OK` + +**Step 3: Commit** + +```bash +git add frontend/package.json frontend/package-lock.json +git commit -m "feat: add vaul drawer library for mobile bottom sheet" +``` + +--- + +### Task 2: Create PropertyCardCompact component + +A ~280px fixed-width card for horizontal swipe browsing. Shows thumbnail, price, beds, sqm, price/sqm badge. + +**Files:** +- Create: `frontend/src/components/PropertyCardCompact.tsx` + +**Step 1: Create the component** + +```tsx +import { Bed, Maximize2 } from 'lucide-react'; +import type { PropertyProperties } from '@/types'; + +interface PropertyCardCompactProps { + property: PropertyProperties; + isActive?: boolean; + isHighlighted?: boolean; + avgPricePerSqm?: number; + onClick?: () => void; +} + +export function PropertyCardCompact({ + property, + isActive = false, + isHighlighted = false, + avgPricePerSqm, + onClick, +}: PropertyCardCompactProps) { + const isGoodDeal = avgPricePerSqm && property.qmprice > 0 && property.qmprice < avgPricePerSqm * 0.9; + const isExpensive = avgPricePerSqm && property.qmprice > avgPricePerSqm * 1.1; + + const priceIndicator = isGoodDeal + ? { color: 'text-green-600 bg-green-50', label: 'Good deal' } + : isExpensive + ? { color: 'text-red-600 bg-red-50', label: 'Above avg' } + : null; + + return ( +
+ {/* Thumbnail */} +
+ {property.photo_thumbnail && ( + Property + )} +
+ + {/* Details */} +
+
+
+ £{property.total_price.toLocaleString()} + {property.listing_type !== 'BUY' && ( + /mo + )} +
+ {priceIndicator && ( + + {priceIndicator.label} + + )} +
+ +
+ + + {property.rooms} + + + + {property.qm} m² + + £{property.qmprice}/m² +
+
+
+ ); +} +``` + +**Step 2: Commit** + +```bash +git add frontend/src/components/PropertyCardCompact.tsx +git commit -m "feat: add PropertyCardCompact for mobile swipeable cards" +``` + +--- + +### Task 3: Create SwipeableCardRow component + +Horizontal scroll-snap container that renders `PropertyCardCompact` items. Tracks which card is centered and emits that index. + +**Files:** +- Create: `frontend/src/components/SwipeableCardRow.tsx` + +**Step 1: Create the component** + +```tsx +import { useRef, useEffect, useCallback } from 'react'; +import { PropertyCardCompact } from './PropertyCardCompact'; +import type { PropertyFeature } from '@/types'; + +interface SwipeableCardRowProps { + features: PropertyFeature[]; + activeIndex: number; + onActiveIndexChange: (index: number) => void; + avgPricePerSqm: number; + highlightedPropertyUrl?: string | null; + onCardClick?: (feature: PropertyFeature) => void; +} + +export function SwipeableCardRow({ + features, + activeIndex, + onActiveIndexChange, + avgPricePerSqm, + highlightedPropertyUrl, + onCardClick, +}: SwipeableCardRowProps) { + const scrollRef = useRef(null); + const isScrollingProgrammatically = useRef(false); + + // Scroll to active index when it changes externally (e.g., from map marker tap) + useEffect(() => { + const container = scrollRef.current; + if (!container) return; + + const cardWidth = 280 + 12; // card width + gap + const targetScroll = activeIndex * cardWidth - (container.clientWidth - 280) / 2; + + isScrollingProgrammatically.current = true; + container.scrollTo({ left: targetScroll, behavior: 'smooth' }); + + // Reset flag after scroll completes + const timer = setTimeout(() => { + isScrollingProgrammatically.current = false; + }, 500); + return () => clearTimeout(timer); + }, [activeIndex]); + + // Detect which card is centered after user scrolls + const handleScroll = useCallback(() => { + if (isScrollingProgrammatically.current) return; + const container = scrollRef.current; + if (!container) return; + + const cardWidth = 280 + 12; + const centerX = container.scrollLeft + container.clientWidth / 2; + const newIndex = Math.round((centerX - 280 / 2) / cardWidth); + const clampedIndex = Math.max(0, Math.min(newIndex, features.length - 1)); + + if (clampedIndex !== activeIndex) { + onActiveIndexChange(clampedIndex); + } + }, [activeIndex, features.length, onActiveIndexChange]); + + // Debounced scroll handler + const scrollTimerRef = useRef(null); + const debouncedScroll = useCallback(() => { + if (scrollTimerRef.current) clearTimeout(scrollTimerRef.current); + scrollTimerRef.current = window.setTimeout(handleScroll, 100); + }, [handleScroll]); + + if (features.length === 0) return null; + + return ( +
+ {features.map((feature, index) => ( + onCardClick?.(feature)} + /> + ))} +
+ ); +} +``` + +**Step 2: Commit** + +```bash +git add frontend/src/components/SwipeableCardRow.tsx +git commit -m "feat: add SwipeableCardRow with scroll-snap and map sync" +``` + +--- + +### Task 4: Create MobileBottomSheet component + +vaul-based drawer with 3 snap points. Contains compact stats, swipeable cards (peek), and full list view (expanded). + +**Files:** +- Create: `frontend/src/components/MobileBottomSheet.tsx` + +**Step 1: Create the component** + +```tsx +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("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 ( + + + + {/* Drag handle */} +
+
+
+ + {/* Compact stats (always visible) */} +
+
+ + {stats.count.toLocaleString()} + listings +
+ {stats.avgPrice > 0 && ( +
+ + Avg: {formatCurrency(stats.avgPrice)} +
+ )} +
+ + {/* Swipeable cards (visible at peek snap) */} + {!isExpanded && features.length > 0 && ( + + )} + + {/* Full list view (visible at expanded snap) */} + {isExpanded && listingData && features.length > 0 && ( +
+ +
+ )} + + + + ); +} +``` + +**Step 2: Commit** + +```bash +git add frontend/src/components/MobileBottomSheet.tsx +git commit -m "feat: add MobileBottomSheet with vaul drawer and 3 snap points" +``` + +--- + +### Task 5: Create MobileMenu component + +Hamburger menu that opens a Sheet containing health indicator, task indicator, user email, and logout button. + +**Files:** +- Create: `frontend/src/components/MobileMenu.tsx` + +**Step 1: Create the component** + +```tsx +import type { AuthUser } from '@/auth/types'; +import type { TaskState } from '@/types'; +import { Sheet, SheetContent, SheetHeader, SheetTitle, SheetTrigger } from './ui/sheet'; +import { Button } from './ui/button'; +import { Menu, LogOut } from 'lucide-react'; +import { logout } from '@/auth/authService'; +import { clearPasskeyUser } from '@/auth/passkeyService'; +import { HealthIndicator } from './HealthIndicator'; +import { TaskIndicator } from './TaskIndicator'; +import { Separator } from './ui/separator'; + +interface MobileMenuProps { + user: AuthUser; + tasks: Record; + activeTaskId: string | null; + isConnected: boolean; + onCancelTask: (taskId: string) => Promise; + onClearAllTasks: () => Promise; + onTaskCompleted?: () => void; +} + +export function MobileMenu({ + user, + tasks, + activeTaskId, + isConnected, + onCancelTask, + onClearAllTasks, + onTaskCompleted, +}: MobileMenuProps) { + const handleLogout = async () => { + if (user.provider === 'passkey') { + clearPasskeyUser(); + window.location.reload(); + } else { + await logout(); + } + }; + + return ( + + + + + + + Menu + + +
+ {/* User info */} +
+ {user.email} +
+ + + + {/* Health */} +
+ System Health + +
+ + + + {/* Tasks */} +
+ Tasks + +
+ + + + {/* Logout */} + +
+
+
+ ); +} +``` + +**Step 2: Commit** + +```bash +git add frontend/src/components/MobileMenu.tsx +git commit -m "feat: add MobileMenu hamburger component for mobile header" +``` + +--- + +### Task 6: Update Header for mobile layout + +On mobile, hide health indicator, task indicator, and user email/logout — replace with hamburger menu. + +**Files:** +- Modify: `frontend/src/components/Header.tsx` + +**Step 1: Modify Header.tsx** + +Import `MobileMenu` and `useIsMobile`. On mobile, render just logo + MobileMenu. On desktop, keep existing layout. + +```tsx +import type { AuthUser } from '@/auth/types'; +import type { TaskState } from '@/types'; +import { Button } from './ui/button'; +import { Separator } from './ui/separator'; +import { LogOut, Home } from 'lucide-react'; +import { logout } from '@/auth/authService'; +import { clearPasskeyUser } from '@/auth/passkeyService'; +import { HealthIndicator } from './HealthIndicator'; +import { TaskIndicator } from './TaskIndicator'; +import { MobileMenu } from './MobileMenu'; +import { useIsMobile } from '@/hooks/use-mobile'; + +interface HeaderProps { + user: AuthUser; + activeFilterCount?: number; + isLoading?: boolean; + onToggleFilters?: () => void; + showFilterToggle?: boolean; + // Task progress (unified) + tasks: Record; + activeTaskId: string | null; + isConnected: boolean; + onCancelTask: (taskId: string) => Promise; + onClearAllTasks: () => Promise; + onTaskCompleted?: () => void; +} + +export function Header({ + user, + tasks, + activeTaskId, + isConnected, + onCancelTask, + onClearAllTasks, + onTaskCompleted, +}: HeaderProps) { + const isMobile = useIsMobile(); + + const handleLogout = async () => { + if (user.provider === 'passkey') { + clearPasskeyUser(); + window.location.reload(); + } else { + await logout(); + } + }; + + return ( +
+ {/* Logo / Brand */} +
+ + Wrongmove +
+ + {/* Desktop-only items */} + {!isMobile && ( + <> + + + + + )} + + {/* Spacer */} +
+ + {/* Mobile: hamburger menu */} + {isMobile && ( + + )} + + {/* Desktop: user menu */} + {!isMobile && ( +
+ + {user.email} + + +
+ )} +
+ ); +} +``` + +**Step 2: Commit** + +```bash +git add frontend/src/components/Header.tsx +git commit -m "feat: add mobile hamburger menu to Header, hide desktop items on mobile" +``` + +--- + +### Task 7: Update App.tsx with mobile layout + +The main layout change. On mobile: full-screen map + MobileBottomSheet + FABs. On desktop: unchanged. + +**Files:** +- Modify: `frontend/src/App.tsx` + +**Step 1: Modify App.tsx** + +Add imports for `useIsMobile`, `MobileBottomSheet`, and `MapPin` icon. Add state for `activeCardFeature`. Add a `renderMobileLayout()` function. In the return, conditionally render mobile vs desktop layout. + +Key changes to `App.tsx`: + +1. Add imports at top: +```tsx +import { useIsMobile } from '@/hooks/use-mobile'; +import { MobileBottomSheet } from './components/MobileBottomSheet'; +import { MapPin as MapPinIcon } from 'lucide-react'; +``` + +2. Inside `App()`, after existing state declarations, add: +```tsx +const isMobile = useIsMobile(); +``` + +3. Add a handler for when the active card changes in the bottom sheet (for map sync): +```tsx +const [activeCardFeature, setActiveCardFeature] = useState(null); + +const handleActiveListingChange = useCallback((feature: PropertyFeature | null) => { + setActiveCardFeature(feature); +}, []); +``` + +4. Add mobile layout renderer after `renderMainContent`: +```tsx +const renderMobileLayout = () => ( + <> + {/* Full-screen map */} +
+ {/* Streaming Progress Bar */} +
+ +
+ + {processedListingData && processedListingData.features.length > 0 ? ( + + ) : ( +
+
+ {isLoading ? ( + <> +
🏠
+

Loading...

+ + ) : ( + <> +
🏠
+

Property Explorer

+

+ Use the filter button to find properties. +

+ + )} +
+
+ )} +
+ + {/* FABs - above bottom sheet */} +
+ + + + + +
+
+ +
+
+ +
+
+
+
+
+ + {/* Bottom Sheet */} + {processedListingData && processedListingData.features.length > 0 && ( + + )} + +); +``` + +5. Replace the return statement to branch on `isMobile`: +```tsx +return ( +
+ {/* Header */} +
{ + const result = await clearAllTasks(); + if (result) handleTaskCancelled(); + return result; + }} + onTaskCompleted={handleTaskCompleted} + /> + + {isMobile ? ( + renderMobileLayout() + ) : ( + /* Existing desktop layout (the current flex-1 flex div) */ +
+ {/* Desktop filter sidebar */} +
+ {/* ... existing desktop filter panel ... */} +
+ + {/* Main View Area */} +
+
+ +
+
+ {renderMainContent()} +
+ {processedListingData && processedListingData.features.length > 0 && ( +
+ +
+ )} +
+
+ )} + + +
+); +``` + +Note: The desktop path should keep the existing content from lines 416-506 of the current `App.tsx` exactly. The mobile filter FAB (currently at lines 447-482) should be removed from the desktop path since mobile now uses `renderMobileLayout()`. + +**Step 2: Commit** + +```bash +git add frontend/src/App.tsx +git commit -m "feat: add mobile layout with full-screen map, bottom sheet, and FABs" +``` + +--- + +### Task 8: Update Map legend position for mobile + +Move legend to top-left on mobile to avoid overlapping with FABs (which are bottom-right). + +**Files:** +- Modify: `frontend/src/assets/Map.css` + +**Step 1: Update Map.css mobile media query** + +Replace the existing `@media (max-width: 768px)` block: + +```css +@media (max-width: 768px) { + #legend { + width: 70px; + padding: 6px; + min-height: 200px; + top: 10px; + right: auto; + left: 10px; + font-size: 10px; + } + + .mapboxgl-popup-content { + max-width: 90vw !important; + } + + .mapboxgl-popup-close-button { + font-size: 24px; + padding: 6px 10px; + min-width: 44px; + min-height: 44px; + } +} +``` + +**Step 2: Commit** + +```bash +git add frontend/src/assets/Map.css +git commit -m "feat: reposition map legend to top-left on mobile, enlarge close button" +``` + +--- + +### Task 9: Update TaskProgressDrawer for mobile + +On mobile, render the task progress drawer from the bottom instead of the right. + +**Files:** +- Modify: `frontend/src/components/TaskProgressDrawer.tsx` + +**Step 1: Modify TaskProgressDrawer.tsx** + +Import `useIsMobile` and conditionally set the Sheet side: + +Add import: +```tsx +import { useIsMobile } from '@/hooks/use-mobile'; +``` + +Inside the `TaskProgressDrawer` component, add: +```tsx +const isMobile = useIsMobile(); +``` + +Change the `SheetContent` to: +```tsx + +``` + +**Step 2: Commit** + +```bash +git add frontend/src/components/TaskProgressDrawer.tsx +git commit -m "feat: render TaskProgressDrawer from bottom on mobile" +``` + +--- + +### Task 10: Add scrollbar-none utility and verify build + +Tailwind v4 may not include `scrollbar-none` by default. Add it as a custom utility if needed, then verify the full build compiles. + +**Files:** +- Modify: `frontend/src/index.css` (if needed) + +**Step 1: Check if scrollbar-none works in Tailwind v4** + +Run: `cd frontend && npx vite build 2>&1 | tail -20` + +If build succeeds, skip adding the utility. If there's a warning about `scrollbar-none`, add to `index.css`: + +```css +@utility scrollbar-none { + -ms-overflow-style: none; + scrollbar-width: none; + &::-webkit-scrollbar { + display: none; + } +} +``` + +**Step 2: Verify build passes** + +Run: `cd frontend && npx vite build` +Expected: Build completes with no errors. + +**Step 3: Commit if changes were needed** + +```bash +git add frontend/src/index.css +git commit -m "feat: add scrollbar-none utility for hidden scrollbars on swipeable cards" +``` + +--- + +### Task 11: Verify and fix TypeScript errors + +Run type checking and fix any issues. + +**Step 1: Run TypeScript check** + +Run: `cd frontend && npx tsc --noEmit 2>&1 | head -40` + +**Step 2: Fix any type errors found** + +Fix issues if any arise from the new components or modified files. + +**Step 3: Commit fixes** + +```bash +git add -A +git commit -m "fix: resolve TypeScript errors from mobile responsive changes" +``` + +--- + +### Task 12: Manual testing checklist + +Verify the implementation works correctly. This is a review task, not code. + +**Desktop verification:** +- [ ] Desktop layout unchanged — sidebar, map, list, split view all work as before +- [ ] StatsBar view mode toggle works +- [ ] Filter panel visible in sidebar +- [ ] Task progress drawer opens from right + +**Mobile verification (use browser devtools responsive mode at 375px width):** +- [ ] Header shows logo + hamburger menu +- [ ] Hamburger menu opens Sheet with health, tasks, user email, logout +- [ ] Map is full-screen behind bottom sheet +- [ ] Bottom sheet has drag handle +- [ ] Bottom sheet shows listing count and avg price +- [ ] Swiping cards horizontally works with snap behavior +- [ ] Dragging sheet up expands to full list view +- [ ] Filter FAB opens filter Sheet from left +- [ ] Legend is positioned top-left and doesn't overlap FABs +- [ ] Map popup close buttons are large enough for touch +- [ ] Task progress opens from bottom on mobile diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 9da92e7..f42038f 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -48,6 +48,7 @@ "react-virtuoso": "^4.18.1", "tailwind-merge": "^3.3.1", "tailwindcss": "^4.1.10", + "vaul": "^1.1.2", "zod": "^3.25.67" }, "devDependencies": { @@ -7844,6 +7845,19 @@ } } }, + "node_modules/vaul": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/vaul/-/vaul-1.1.2.tgz", + "integrity": "sha512-ZFkClGpWyI2WUQjdLJ/BaGuV6AVQiJ3uELGk3OYtP+B6yCO7Cmn9vPFXVJkRaGkOJu3m8bQMgtyzNHixULceQA==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-dialog": "^1.1.1" + }, + "peerDependencies": { + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0.0 || ^19.0.0-rc" + } + }, "node_modules/vite": { "version": "6.3.5", "resolved": "https://registry.npmjs.org/vite/-/vite-6.3.5.tgz", diff --git a/frontend/package.json b/frontend/package.json index 4544b91..2ae228d 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -53,10 +53,14 @@ "react-virtuoso": "^4.18.1", "tailwind-merge": "^3.3.1", "tailwindcss": "^4.1.10", + "vaul": "^1.1.2", "zod": "^3.25.67" }, "devDependencies": { "@eslint/js": "^9.25.0", + "@testing-library/jest-dom": "^6.6.0", + "@testing-library/react": "^16.3.0", + "@testing-library/user-event": "^14.6.0", "@types/node": "^24.0.1", "@types/react": "^19.1.2", "@types/react-dom": "^19.1.2", @@ -65,15 +69,12 @@ "eslint-plugin-react-hooks": "^5.2.0", "eslint-plugin-react-refresh": "^0.4.19", "globals": "^16.0.0", + "jsdom": "^25.0.0", + "msw": "^2.7.0", "tw-animate-css": "^1.3.4", "typescript": "~5.8.3", "typescript-eslint": "^8.30.1", "vite": "^6.3.5", - "vitest": "^3.0.0", - "@testing-library/react": "^16.3.0", - "@testing-library/jest-dom": "^6.6.0", - "@testing-library/user-event": "^14.6.0", - "jsdom": "^25.0.0", - "msw": "^2.7.0" + "vitest": "^3.0.0" } } diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index bdd8846..95bfaf6 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -22,6 +22,8 @@ import { setOnUnauthorized } from '@/services/apiClient'; import { clearPasskeyUser } from './auth/passkeyService'; import { poiMetricPropertyName, injectPoiMetricProperty } from '@/utils/poiUtils'; import { useTaskProgress } from '@/hooks/useTaskProgress'; +import { useIsMobile } from '@/hooks/use-mobile'; +import { MobileBottomSheet } from './components/MobileBottomSheet'; function isTerminalStatus(status: string): boolean { return status === 'SUCCESS' || status === 'FAILURE' || status === 'REVOKED'; @@ -48,6 +50,8 @@ function App() { } | null>(null); const [poiTravelFilters, setPoiTravelFilters] = useState>({}); const [currentMetric, setCurrentMetric] = useState(DEFAULT_FILTER_VALUES.metric); + const isMobile = useIsMobile(); + const [activeCardFeature, setActiveCardFeature] = useState(null); // Explicit task ID set by fetch-data action (to track as "active") const [explicitTaskId, setExplicitTaskId] = useState(null); @@ -258,6 +262,10 @@ function App() { setExplicitTaskId(null); }, []); + const handleActiveListingChange = useCallback((feature: PropertyFeature | null) => { + setActiveCardFeature(feature); + }, []); + if (!user) { return ; } @@ -370,6 +378,99 @@ function App() { ); }; + const renderMobileLayout = () => ( + <> + {/* Full-screen map */} +
+ {/* Streaming Progress Bar */} +
+ +
+ + {processedListingData && processedListingData.features.length > 0 ? ( + + ) : ( +
+
+ {isLoading ? ( + <> +
🏠
+

Loading...

+ + ) : ( + <> +
🏠
+

Property Explorer

+

+ Use the filter button to find properties. +

+ + )} +
+
+ )} +
+ + {/* Filter FAB */} +
+ + + + + +
+
+ +
+
+ +
+
+
+
+
+ + {/* Bottom Sheet */} + {processedListingData && processedListingData.features.length > 0 && ( + + )} + + ); + const handlePOITaskCreated = (taskId: string) => { setExplicitTaskId(taskId); if (taskId) subscribe(taskId); @@ -393,6 +494,7 @@ function App() { setPoiPickerActive(false); }; + return (
{/* Header */} @@ -412,99 +514,65 @@ function App() { onTaskCompleted={handleTaskCompleted} /> - {/* Main content area */} -
- {/* Filter Panel - Desktop (fixed sidebar) */} -
-
-
- -
-
- -
-
-
- - {/* Filter Panel - Mobile (sheet) */} -
- - - - - -
-
- -
-
- -
+ {isMobile ? ( + renderMobileLayout() + ) : ( + /* Desktop layout */ +
+ {/* Filter Panel - Desktop (fixed sidebar) */} +
+
+
+ +
+
+
- - -
- - {/* Main View Area */} -
- {/* Streaming Progress Bar */} -
- -
- - {/* Map/List Container */} -
- {renderMainContent()} -
- - {/* Stats Bar */} - {processedListingData && processedListingData.features.length > 0 && ( -
-
- )} +
+ + {/* Main View Area */} +
+ {/* Streaming Progress Bar */} +
+ +
+ + {/* Map/List Container */} +
+ {renderMainContent()} +
+ + {/* Stats Bar */} + {processedListingData && processedListingData.features.length > 0 && ( +
+ +
+ )} +
-
+ )} {/* Error Dialog */} diff --git a/frontend/src/assets/Map.css b/frontend/src/assets/Map.css index db47184..eaeffad 100644 --- a/frontend/src/assets/Map.css +++ b/frontend/src/assets/Map.css @@ -69,12 +69,23 @@ /* Mobile adjustments */ @media (max-width: 768px) { #legend { - width: 75px; - padding: 8px; - min-height: 250px; + width: 70px; + padding: 6px; + min-height: 200px; + top: 10px; + right: auto; + left: 10px; + font-size: 10px; } .mapboxgl-popup-content { max-width: 90vw !important; } + + .mapboxgl-popup-close-button { + font-size: 24px; + padding: 6px 10px; + min-width: 44px; + min-height: 44px; + } } diff --git a/frontend/src/components/Header.tsx b/frontend/src/components/Header.tsx index d392eb0..aef17cf 100644 --- a/frontend/src/components/Header.tsx +++ b/frontend/src/components/Header.tsx @@ -2,11 +2,13 @@ import type { AuthUser } from '@/auth/types'; import type { TaskState } from '@/types'; import { Button } from './ui/button'; import { Separator } from './ui/separator'; -import { LogOut, Home, Filter } from 'lucide-react'; +import { LogOut, Home } from 'lucide-react'; import { logout } from '@/auth/authService'; import { clearPasskeyUser } from '@/auth/passkeyService'; import { HealthIndicator } from './HealthIndicator'; import { TaskIndicator } from './TaskIndicator'; +import { MobileMenu } from './MobileMenu'; +import { useIsMobile } from '@/hooks/use-mobile'; interface HeaderProps { user: AuthUser; @@ -25,9 +27,6 @@ interface HeaderProps { export function Header({ user, - activeFilterCount = 0, - onToggleFilters, - showFilterToggle = false, tasks, activeTaskId, isConnected, @@ -35,6 +34,8 @@ export function Header({ onClearAllTasks, onTaskCompleted, }: HeaderProps) { + const isMobile = useIsMobile(); + const handleLogout = async () => { if (user.provider === 'passkey') { clearPasskeyUser(); @@ -45,63 +46,62 @@ export function Header({ }; return ( -
+
{/* Logo / Brand */}
Wrongmove
- - - {/* Health Indicator */} - - - {/* Task Indicator */} - - - {/* Filter Toggle (mobile) */} - {showFilterToggle && ( - + {/* Desktop-only items */} + {!isMobile && ( + <> + + + + )} {/* Spacer */}
- {/* User Menu */} -
- - {user.email} - - -
+ {/* Mobile: hamburger menu */} + {isMobile && ( + + )} + + {/* Desktop: user menu */} + {!isMobile && ( +
+ + {user.email} + + +
+ )}
); } diff --git a/frontend/src/components/MobileBottomSheet.tsx b/frontend/src/components/MobileBottomSheet.tsx new file mode 100644 index 0000000..f82f3fd --- /dev/null +++ b/frontend/src/components/MobileBottomSheet.tsx @@ -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("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 ( + + + + {/* Drag handle */} +
+
+
+ + {/* Compact stats (always visible) */} +
+
+ + {stats.count.toLocaleString()} + listings +
+ {stats.avgPrice > 0 && ( +
+ + Avg: {formatCurrency(stats.avgPrice)} +
+ )} +
+ + {/* Swipeable cards (visible at peek snap) */} + {!isExpanded && features.length > 0 && ( + + )} + + {/* Full list view (visible at expanded snap) */} + {isExpanded && listingData && features.length > 0 && ( +
+ +
+ )} + + + + ); +} diff --git a/frontend/src/components/MobileMenu.tsx b/frontend/src/components/MobileMenu.tsx new file mode 100644 index 0000000..a7fd3be --- /dev/null +++ b/frontend/src/components/MobileMenu.tsx @@ -0,0 +1,96 @@ +import type { AuthUser } from '@/auth/types'; +import type { TaskState } from '@/types'; +import { Sheet, SheetContent, SheetHeader, SheetTitle, SheetTrigger } from './ui/sheet'; +import { Button } from './ui/button'; +import { Menu, LogOut } from 'lucide-react'; +import { logout } from '@/auth/authService'; +import { clearPasskeyUser } from '@/auth/passkeyService'; +import { HealthIndicator } from './HealthIndicator'; +import { TaskIndicator } from './TaskIndicator'; +import { Separator } from './ui/separator'; + +interface MobileMenuProps { + user: AuthUser; + tasks: Record; + activeTaskId: string | null; + isConnected: boolean; + onCancelTask: (taskId: string) => Promise; + onClearAllTasks: () => Promise; + onTaskCompleted?: () => void; +} + +export function MobileMenu({ + user, + tasks, + activeTaskId, + isConnected, + onCancelTask, + onClearAllTasks, + onTaskCompleted, +}: MobileMenuProps) { + const handleLogout = async () => { + if (user.provider === 'passkey') { + clearPasskeyUser(); + window.location.reload(); + } else { + await logout(); + } + }; + + return ( + + + + + + + Menu + + +
+ {/* User info */} +
+ {user.email} +
+ + + + {/* Health */} +
+ System Health + +
+ + + + {/* Tasks */} +
+ Tasks + +
+ + + + {/* Logout */} + +
+
+
+ ); +} diff --git a/frontend/src/components/PropertyCardCompact.tsx b/frontend/src/components/PropertyCardCompact.tsx new file mode 100644 index 0000000..932e7da --- /dev/null +++ b/frontend/src/components/PropertyCardCompact.tsx @@ -0,0 +1,76 @@ +import { Bed, Maximize2 } from 'lucide-react'; +import type { PropertyProperties } from '@/types'; + +interface PropertyCardCompactProps { + property: PropertyProperties; + isActive?: boolean; + isHighlighted?: boolean; + avgPricePerSqm?: number; + onClick?: () => void; +} + +export function PropertyCardCompact({ + property, + isActive = false, + isHighlighted = false, + avgPricePerSqm, + onClick, +}: PropertyCardCompactProps) { + const isGoodDeal = avgPricePerSqm && property.qmprice > 0 && property.qmprice < avgPricePerSqm * 0.9; + const isExpensive = avgPricePerSqm && property.qmprice > avgPricePerSqm * 1.1; + + const priceIndicator = isGoodDeal + ? { color: 'text-green-600 bg-green-50', label: 'Good deal' } + : isExpensive + ? { color: 'text-red-600 bg-red-50', label: 'Above avg' } + : null; + + return ( +
+ {/* Thumbnail */} +
+ {property.photo_thumbnail && ( + Property + )} +
+ + {/* Details */} +
+
+
+ £{property.total_price.toLocaleString()} + {property.listing_type !== 'BUY' && ( + /mo + )} +
+ {priceIndicator && ( + + {priceIndicator.label} + + )} +
+ +
+ + + {property.rooms} + + + + {property.qm} m² + + £{property.qmprice}/m² +
+
+
+ ); +} diff --git a/frontend/src/components/SwipeableCardRow.tsx b/frontend/src/components/SwipeableCardRow.tsx new file mode 100644 index 0000000..819fe3f --- /dev/null +++ b/frontend/src/components/SwipeableCardRow.tsx @@ -0,0 +1,87 @@ +import { useRef, useEffect, useCallback } from 'react'; +import { PropertyCardCompact } from './PropertyCardCompact'; +import type { PropertyFeature } from '@/types'; + +interface SwipeableCardRowProps { + features: PropertyFeature[]; + activeIndex: number; + onActiveIndexChange: (index: number) => void; + avgPricePerSqm: number; + highlightedPropertyUrl?: string | null; + onCardClick?: (feature: PropertyFeature) => void; +} + +export function SwipeableCardRow({ + features, + activeIndex, + onActiveIndexChange, + avgPricePerSqm, + highlightedPropertyUrl, + onCardClick, +}: SwipeableCardRowProps) { + const scrollRef = useRef(null); + const isScrollingProgrammatically = useRef(false); + + // Scroll to active index when it changes externally (e.g., from map marker tap) + useEffect(() => { + const container = scrollRef.current; + if (!container) return; + + const cardWidth = 280 + 12; // card width + gap + const targetScroll = activeIndex * cardWidth - (container.clientWidth - 280) / 2; + + isScrollingProgrammatically.current = true; + container.scrollTo({ left: targetScroll, behavior: 'smooth' }); + + // Reset flag after scroll completes + const timer = setTimeout(() => { + isScrollingProgrammatically.current = false; + }, 500); + return () => clearTimeout(timer); + }, [activeIndex]); + + // Detect which card is centered after user scrolls + const handleScroll = useCallback(() => { + if (isScrollingProgrammatically.current) return; + const container = scrollRef.current; + if (!container) return; + + const cardWidth = 280 + 12; + const centerX = container.scrollLeft + container.clientWidth / 2; + const newIndex = Math.round((centerX - 280 / 2) / cardWidth); + const clampedIndex = Math.max(0, Math.min(newIndex, features.length - 1)); + + if (clampedIndex !== activeIndex) { + onActiveIndexChange(clampedIndex); + } + }, [activeIndex, features.length, onActiveIndexChange]); + + // Debounced scroll handler + const scrollTimerRef = useRef(null); + const debouncedScroll = useCallback(() => { + if (scrollTimerRef.current) clearTimeout(scrollTimerRef.current); + scrollTimerRef.current = window.setTimeout(handleScroll, 100); + }, [handleScroll]); + + if (features.length === 0) return null; + + return ( +
+ {features.map((feature, index) => ( + onCardClick?.(feature)} + /> + ))} +
+ ); +} diff --git a/frontend/src/components/TaskProgressDrawer.tsx b/frontend/src/components/TaskProgressDrawer.tsx index 97b8f4d..ca90139 100644 --- a/frontend/src/components/TaskProgressDrawer.tsx +++ b/frontend/src/components/TaskProgressDrawer.tsx @@ -10,6 +10,7 @@ import { import { Button } from './ui/button'; import { CheckCircle2, Circle, Loader2, XCircle, MapPin, Search } from 'lucide-react'; import { useEffect, useRef, useMemo } from 'react'; +import { useIsMobile } from '@/hooks/use-mobile'; interface TaskProgressDrawerProps { open: boolean; @@ -420,6 +421,7 @@ export function TaskProgressDrawer({ onSelectTask, }: TaskProgressDrawerProps) { // Determine which task's data to show + const isMobile = useIsMobile(); const hasMultipleTasks = tasks && Object.keys(tasks).length > 1; const effectiveTaskId = selectedTaskId ?? taskID; @@ -446,7 +448,10 @@ export function TaskProgressDrawer({ return ( - +
{drawerTitle} diff --git a/frontend/src/index.css b/frontend/src/index.css index 4f3edd6..d6d113b 100644 --- a/frontend/src/index.css +++ b/frontend/src/index.css @@ -145,3 +145,11 @@ .animate-accordion-up { animation: accordion-up 0.2s ease-out; } + +@utility scrollbar-none { + -ms-overflow-style: none; + scrollbar-width: none; + &::-webkit-scrollbar { + display: none; + } +}