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 && (
+

+ )}
+
+
+ {/* 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 (
+
+ );
+}
+```
+
+**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 (
+
+);
+```
+
+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 (
-