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
|
|
@ -19,8 +19,14 @@ depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
|
||||||
def upgrade() -> None:
|
def upgrade() -> None:
|
||||||
"""Rename 'longtitude' to 'longitude' in buylisting table."""
|
"""Rename 'longtitude' to 'longitude' in buylisting table (if the typo column exists)."""
|
||||||
op.alter_column('buylisting', 'longtitude', new_column_name='longitude', existing_type=sa.Float(), existing_nullable=False)
|
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:
|
def downgrade() -> None:
|
||||||
|
|
|
||||||
123
docs/plans/2026-02-21-mobile-responsive-design.md
Normal file
123
docs/plans/2026-02-21-mobile-responsive-design.md
Normal file
|
|
@ -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
|
||||||
994
docs/plans/2026-02-21-mobile-responsive-plan.md
Normal file
994
docs/plans/2026-02-21-mobile-responsive-plan.md
Normal file
|
|
@ -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 (
|
||||||
|
<div
|
||||||
|
className={`w-[280px] shrink-0 snap-center rounded-lg border bg-background shadow-sm overflow-hidden cursor-pointer transition-all ${
|
||||||
|
isActive ? 'ring-2 ring-primary scale-[1.02]' : ''
|
||||||
|
} ${isHighlighted ? 'ring-2 ring-primary' : ''}`}
|
||||||
|
onClick={onClick}
|
||||||
|
>
|
||||||
|
{/* Thumbnail */}
|
||||||
|
<div className="h-28 w-full bg-muted">
|
||||||
|
{property.photo_thumbnail && (
|
||||||
|
<img
|
||||||
|
src={property.photo_thumbnail}
|
||||||
|
alt="Property"
|
||||||
|
className="w-full h-full object-cover"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Details */}
|
||||||
|
<div className="p-3">
|
||||||
|
<div className="flex items-start justify-between gap-2">
|
||||||
|
<div className="font-semibold text-base">
|
||||||
|
£{property.total_price.toLocaleString()}
|
||||||
|
{property.listing_type !== 'BUY' && (
|
||||||
|
<span className="text-muted-foreground font-normal text-sm">/mo</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{priceIndicator && (
|
||||||
|
<span className={`text-xs px-1.5 py-0.5 rounded shrink-0 ${priceIndicator.color}`}>
|
||||||
|
{priceIndicator.label}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-3 mt-1.5 text-sm text-muted-foreground">
|
||||||
|
<span className="flex items-center gap-1">
|
||||||
|
<Bed className="h-3.5 w-3.5" />
|
||||||
|
{property.rooms}
|
||||||
|
</span>
|
||||||
|
<span className="flex items-center gap-1">
|
||||||
|
<Maximize2 className="h-3.5 w-3.5" />
|
||||||
|
{property.qm} m²
|
||||||
|
</span>
|
||||||
|
<span>£{property.qmprice}/m²</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**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<HTMLDivElement>(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<number | null>(null);
|
||||||
|
const debouncedScroll = useCallback(() => {
|
||||||
|
if (scrollTimerRef.current) clearTimeout(scrollTimerRef.current);
|
||||||
|
scrollTimerRef.current = window.setTimeout(handleScroll, 100);
|
||||||
|
}, [handleScroll]);
|
||||||
|
|
||||||
|
if (features.length === 0) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
ref={scrollRef}
|
||||||
|
onScroll={debouncedScroll}
|
||||||
|
className="flex gap-3 overflow-x-auto snap-x snap-mandatory px-4 py-2 scrollbar-none"
|
||||||
|
style={{ WebkitOverflowScrolling: 'touch' }}
|
||||||
|
>
|
||||||
|
{features.map((feature, index) => (
|
||||||
|
<PropertyCardCompact
|
||||||
|
key={feature.properties.url}
|
||||||
|
property={feature.properties}
|
||||||
|
isActive={index === activeIndex}
|
||||||
|
isHighlighted={feature.properties.url === highlightedPropertyUrl}
|
||||||
|
avgPricePerSqm={avgPricePerSqm}
|
||||||
|
onClick={() => onCardClick?.(feature)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**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<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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**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<string, TaskState>;
|
||||||
|
activeTaskId: string | null;
|
||||||
|
isConnected: boolean;
|
||||||
|
onCancelTask: (taskId: string) => Promise<boolean>;
|
||||||
|
onClearAllTasks: () => Promise<boolean>;
|
||||||
|
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 (
|
||||||
|
<Sheet>
|
||||||
|
<SheetTrigger asChild>
|
||||||
|
<Button variant="ghost" size="sm" className="h-10 w-10 p-0">
|
||||||
|
<Menu className="h-5 w-5" />
|
||||||
|
</Button>
|
||||||
|
</SheetTrigger>
|
||||||
|
<SheetContent side="right" className="w-72">
|
||||||
|
<SheetHeader>
|
||||||
|
<SheetTitle>Menu</SheetTitle>
|
||||||
|
</SheetHeader>
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-4 px-4">
|
||||||
|
{/* User info */}
|
||||||
|
<div className="text-sm text-muted-foreground">
|
||||||
|
{user.email}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Separator />
|
||||||
|
|
||||||
|
{/* Health */}
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="text-sm font-medium">System Health</span>
|
||||||
|
<HealthIndicator />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Separator />
|
||||||
|
|
||||||
|
{/* Tasks */}
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="text-sm font-medium">Tasks</span>
|
||||||
|
<TaskIndicator
|
||||||
|
tasks={tasks}
|
||||||
|
activeTaskId={activeTaskId}
|
||||||
|
isConnected={isConnected}
|
||||||
|
onCancelTask={onCancelTask}
|
||||||
|
onClearAllTasks={onClearAllTasks}
|
||||||
|
onTaskCompleted={onTaskCompleted}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Separator />
|
||||||
|
|
||||||
|
{/* Logout */}
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
onClick={handleLogout}
|
||||||
|
className="w-full gap-2"
|
||||||
|
>
|
||||||
|
<LogOut className="h-4 w-4" />
|
||||||
|
Logout
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</SheetContent>
|
||||||
|
</Sheet>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**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<string, TaskState>;
|
||||||
|
activeTaskId: string | null;
|
||||||
|
isConnected: boolean;
|
||||||
|
onCancelTask: (taskId: string) => Promise<boolean>;
|
||||||
|
onClearAllTasks: () => Promise<boolean>;
|
||||||
|
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 (
|
||||||
|
<header className={`flex shrink-0 items-center gap-3 border-b bg-background px-4 ${isMobile ? 'h-12' : 'h-14'}`}>
|
||||||
|
{/* Logo / Brand */}
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Home className="h-5 w-5 text-primary" />
|
||||||
|
<span className="font-semibold text-lg hidden sm:inline">Wrongmove</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Desktop-only items */}
|
||||||
|
{!isMobile && (
|
||||||
|
<>
|
||||||
|
<Separator orientation="vertical" className="h-6" />
|
||||||
|
<HealthIndicator />
|
||||||
|
<TaskIndicator
|
||||||
|
tasks={tasks}
|
||||||
|
activeTaskId={activeTaskId}
|
||||||
|
isConnected={isConnected}
|
||||||
|
onCancelTask={onCancelTask}
|
||||||
|
onClearAllTasks={onClearAllTasks}
|
||||||
|
onTaskCompleted={onTaskCompleted}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Spacer */}
|
||||||
|
<div className="flex-1" />
|
||||||
|
|
||||||
|
{/* Mobile: hamburger menu */}
|
||||||
|
{isMobile && (
|
||||||
|
<MobileMenu
|
||||||
|
user={user}
|
||||||
|
tasks={tasks}
|
||||||
|
activeTaskId={activeTaskId}
|
||||||
|
isConnected={isConnected}
|
||||||
|
onCancelTask={onCancelTask}
|
||||||
|
onClearAllTasks={onClearAllTasks}
|
||||||
|
onTaskCompleted={onTaskCompleted}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Desktop: user menu */}
|
||||||
|
{!isMobile && (
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<span className="text-sm text-muted-foreground">
|
||||||
|
{user.email}
|
||||||
|
</span>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={handleLogout}
|
||||||
|
className="gap-2"
|
||||||
|
>
|
||||||
|
<LogOut className="h-4 w-4" />
|
||||||
|
Logout
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</header>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**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<PropertyFeature | null>(null);
|
||||||
|
|
||||||
|
const handleActiveListingChange = useCallback((feature: PropertyFeature | null) => {
|
||||||
|
setActiveCardFeature(feature);
|
||||||
|
}, []);
|
||||||
|
```
|
||||||
|
|
||||||
|
4. Add mobile layout renderer after `renderMainContent`:
|
||||||
|
```tsx
|
||||||
|
const renderMobileLayout = () => (
|
||||||
|
<>
|
||||||
|
{/* Full-screen map */}
|
||||||
|
<div className="flex-1 relative min-h-0">
|
||||||
|
{/* Streaming Progress Bar */}
|
||||||
|
<div className="absolute top-0 left-0 right-0 z-10">
|
||||||
|
<StreamingProgressBar progress={streamingProgress} isLoading={isLoading} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{processedListingData && processedListingData.features.length > 0 ? (
|
||||||
|
<Map
|
||||||
|
listingData={processedListingData}
|
||||||
|
queryParameters={queryParameters}
|
||||||
|
effectiveMetric={effectiveMetric}
|
||||||
|
onPropertyClick={handlePropertyClick}
|
||||||
|
pois={userPOIs}
|
||||||
|
isPickingPOI={poiPickerActive}
|
||||||
|
onPoiLocationPick={handlePoiLocationPick}
|
||||||
|
onCancelPoiPicking={handleCancelPoiPicking}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className="flex-1 flex items-center justify-center bg-muted/20 h-full">
|
||||||
|
<div className="text-center p-8 max-w-md">
|
||||||
|
{isLoading ? (
|
||||||
|
<>
|
||||||
|
<div className="text-4xl mb-4 animate-pulse">🏠</div>
|
||||||
|
<h2 className="text-lg font-semibold mb-2">Loading...</h2>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<div className="text-4xl mb-4">🏠</div>
|
||||||
|
<h2 className="text-lg font-semibold mb-2">Property Explorer</h2>
|
||||||
|
<p className="text-muted-foreground text-sm">
|
||||||
|
Use the filter button to find properties.
|
||||||
|
</p>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* FABs - above bottom sheet */}
|
||||||
|
<div className="fixed bottom-24 right-4 z-50 flex flex-col gap-2">
|
||||||
|
<Sheet open={mobileFilterOpen} onOpenChange={setMobileFilterOpen}>
|
||||||
|
<SheetTrigger asChild>
|
||||||
|
<Button size="lg" className="rounded-full shadow-lg h-14 w-14">
|
||||||
|
<Filter className="h-6 w-6" />
|
||||||
|
</Button>
|
||||||
|
</SheetTrigger>
|
||||||
|
<SheetContent side="left" className="w-80 p-0">
|
||||||
|
<div className="h-full flex flex-col">
|
||||||
|
<div className="flex-1 min-h-0">
|
||||||
|
<FilterPanel
|
||||||
|
onSubmit={onSubmit}
|
||||||
|
currentMetric={currentMetric}
|
||||||
|
isLoading={isLoading}
|
||||||
|
listingCount={processedListingData?.features.length}
|
||||||
|
user={user}
|
||||||
|
onTaskCreated={handlePOITaskCreated}
|
||||||
|
onStartPoiPicking={handleStartPoiPicking}
|
||||||
|
pickedPoiLocation={pickedPoiLocation}
|
||||||
|
userPOIs={userPOIs}
|
||||||
|
poiTravelFilters={poiTravelFilters}
|
||||||
|
onPoiTravelFiltersChange={setPoiTravelFilters}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="shrink-0 p-4">
|
||||||
|
<VisualizationCard
|
||||||
|
metric={currentMetric}
|
||||||
|
onMetricChange={handleMetricChange}
|
||||||
|
userPOIs={userPOIs}
|
||||||
|
onPoiMetricChange={setPoiMetricSelection}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</SheetContent>
|
||||||
|
</Sheet>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Bottom Sheet */}
|
||||||
|
{processedListingData && processedListingData.features.length > 0 && (
|
||||||
|
<MobileBottomSheet
|
||||||
|
listingData={processedListingData}
|
||||||
|
onPropertyClick={handlePropertyClick}
|
||||||
|
highlightedPropertyUrl={highlightedProperty}
|
||||||
|
onActiveListingChange={handleActiveListingChange}
|
||||||
|
poiMetricSelection={poiMetricSelection}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
5. Replace the return statement to branch on `isMobile`:
|
||||||
|
```tsx
|
||||||
|
return (
|
||||||
|
<div className="h-screen flex flex-col overflow-hidden">
|
||||||
|
{/* Header */}
|
||||||
|
<Header
|
||||||
|
user={user}
|
||||||
|
tasks={tasks}
|
||||||
|
activeTaskId={activeTaskId}
|
||||||
|
isConnected={isConnected}
|
||||||
|
onCancelTask={cancelTask}
|
||||||
|
onClearAllTasks={async () => {
|
||||||
|
const result = await clearAllTasks();
|
||||||
|
if (result) handleTaskCancelled();
|
||||||
|
return result;
|
||||||
|
}}
|
||||||
|
onTaskCompleted={handleTaskCompleted}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{isMobile ? (
|
||||||
|
renderMobileLayout()
|
||||||
|
) : (
|
||||||
|
/* Existing desktop layout (the current flex-1 flex div) */
|
||||||
|
<div className="flex-1 flex overflow-hidden min-h-0">
|
||||||
|
{/* Desktop filter sidebar */}
|
||||||
|
<div className="hidden md:block w-80 shrink-0 h-full overflow-hidden">
|
||||||
|
{/* ... existing desktop filter panel ... */}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Main View Area */}
|
||||||
|
<div className="flex-1 flex flex-col overflow-hidden min-h-0">
|
||||||
|
<div className="relative shrink-0">
|
||||||
|
<StreamingProgressBar progress={streamingProgress} isLoading={isLoading} />
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 flex overflow-hidden min-h-0">
|
||||||
|
{renderMainContent()}
|
||||||
|
</div>
|
||||||
|
{processedListingData && processedListingData.features.length > 0 && (
|
||||||
|
<div className="shrink-0">
|
||||||
|
<StatsBar
|
||||||
|
listingData={processedListingData}
|
||||||
|
viewMode={viewMode}
|
||||||
|
onViewModeChange={setViewMode}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<AlertError message={submitError} open={alertDialogIsOpen} setIsOpen={setAlertDialogIsOpen} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
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
|
||||||
|
<SheetContent
|
||||||
|
side={isMobile ? "bottom" : "right"}
|
||||||
|
className={`flex flex-col ${isMobile ? 'h-[85vh] rounded-t-xl' : 'w-full sm:!max-w-lg'}`}
|
||||||
|
>
|
||||||
|
```
|
||||||
|
|
||||||
|
**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
|
||||||
14
frontend/package-lock.json
generated
14
frontend/package-lock.json
generated
|
|
@ -48,6 +48,7 @@
|
||||||
"react-virtuoso": "^4.18.1",
|
"react-virtuoso": "^4.18.1",
|
||||||
"tailwind-merge": "^3.3.1",
|
"tailwind-merge": "^3.3.1",
|
||||||
"tailwindcss": "^4.1.10",
|
"tailwindcss": "^4.1.10",
|
||||||
|
"vaul": "^1.1.2",
|
||||||
"zod": "^3.25.67"
|
"zod": "^3.25.67"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"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": {
|
"node_modules/vite": {
|
||||||
"version": "6.3.5",
|
"version": "6.3.5",
|
||||||
"resolved": "https://registry.npmjs.org/vite/-/vite-6.3.5.tgz",
|
"resolved": "https://registry.npmjs.org/vite/-/vite-6.3.5.tgz",
|
||||||
|
|
|
||||||
|
|
@ -53,10 +53,14 @@
|
||||||
"react-virtuoso": "^4.18.1",
|
"react-virtuoso": "^4.18.1",
|
||||||
"tailwind-merge": "^3.3.1",
|
"tailwind-merge": "^3.3.1",
|
||||||
"tailwindcss": "^4.1.10",
|
"tailwindcss": "^4.1.10",
|
||||||
|
"vaul": "^1.1.2",
|
||||||
"zod": "^3.25.67"
|
"zod": "^3.25.67"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@eslint/js": "^9.25.0",
|
"@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/node": "^24.0.1",
|
||||||
"@types/react": "^19.1.2",
|
"@types/react": "^19.1.2",
|
||||||
"@types/react-dom": "^19.1.2",
|
"@types/react-dom": "^19.1.2",
|
||||||
|
|
@ -65,15 +69,12 @@
|
||||||
"eslint-plugin-react-hooks": "^5.2.0",
|
"eslint-plugin-react-hooks": "^5.2.0",
|
||||||
"eslint-plugin-react-refresh": "^0.4.19",
|
"eslint-plugin-react-refresh": "^0.4.19",
|
||||||
"globals": "^16.0.0",
|
"globals": "^16.0.0",
|
||||||
|
"jsdom": "^25.0.0",
|
||||||
|
"msw": "^2.7.0",
|
||||||
"tw-animate-css": "^1.3.4",
|
"tw-animate-css": "^1.3.4",
|
||||||
"typescript": "~5.8.3",
|
"typescript": "~5.8.3",
|
||||||
"typescript-eslint": "^8.30.1",
|
"typescript-eslint": "^8.30.1",
|
||||||
"vite": "^6.3.5",
|
"vite": "^6.3.5",
|
||||||
"vitest": "^3.0.0",
|
"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"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -22,6 +22,8 @@ import { setOnUnauthorized } from '@/services/apiClient';
|
||||||
import { clearPasskeyUser } from './auth/passkeyService';
|
import { clearPasskeyUser } from './auth/passkeyService';
|
||||||
import { poiMetricPropertyName, injectPoiMetricProperty } from '@/utils/poiUtils';
|
import { poiMetricPropertyName, injectPoiMetricProperty } from '@/utils/poiUtils';
|
||||||
import { useTaskProgress } from '@/hooks/useTaskProgress';
|
import { useTaskProgress } from '@/hooks/useTaskProgress';
|
||||||
|
import { useIsMobile } from '@/hooks/use-mobile';
|
||||||
|
import { MobileBottomSheet } from './components/MobileBottomSheet';
|
||||||
|
|
||||||
function isTerminalStatus(status: string): boolean {
|
function isTerminalStatus(status: string): boolean {
|
||||||
return status === 'SUCCESS' || status === 'FAILURE' || status === 'REVOKED';
|
return status === 'SUCCESS' || status === 'FAILURE' || status === 'REVOKED';
|
||||||
|
|
@ -48,6 +50,8 @@ function App() {
|
||||||
} | null>(null);
|
} | null>(null);
|
||||||
const [poiTravelFilters, setPoiTravelFilters] = useState<Record<number, POITravelFilter>>({});
|
const [poiTravelFilters, setPoiTravelFilters] = useState<Record<number, POITravelFilter>>({});
|
||||||
const [currentMetric, setCurrentMetric] = useState<Metric>(DEFAULT_FILTER_VALUES.metric);
|
const [currentMetric, setCurrentMetric] = useState<Metric>(DEFAULT_FILTER_VALUES.metric);
|
||||||
|
const isMobile = useIsMobile();
|
||||||
|
const [activeCardFeature, setActiveCardFeature] = useState<PropertyFeature | null>(null);
|
||||||
|
|
||||||
// Explicit task ID set by fetch-data action (to track as "active")
|
// Explicit task ID set by fetch-data action (to track as "active")
|
||||||
const [explicitTaskId, setExplicitTaskId] = useState<string | null>(null);
|
const [explicitTaskId, setExplicitTaskId] = useState<string | null>(null);
|
||||||
|
|
@ -258,6 +262,10 @@ function App() {
|
||||||
setExplicitTaskId(null);
|
setExplicitTaskId(null);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
const handleActiveListingChange = useCallback((feature: PropertyFeature | null) => {
|
||||||
|
setActiveCardFeature(feature);
|
||||||
|
}, []);
|
||||||
|
|
||||||
if (!user) {
|
if (!user) {
|
||||||
return <LoginModal isOpen={user === null} onPasskeyLogin={handlePasskeyLogin} />;
|
return <LoginModal isOpen={user === null} onPasskeyLogin={handlePasskeyLogin} />;
|
||||||
}
|
}
|
||||||
|
|
@ -370,6 +378,99 @@ function App() {
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const renderMobileLayout = () => (
|
||||||
|
<>
|
||||||
|
{/* Full-screen map */}
|
||||||
|
<div className="flex-1 relative min-h-0">
|
||||||
|
{/* Streaming Progress Bar */}
|
||||||
|
<div className="absolute top-0 left-0 right-0 z-10">
|
||||||
|
<StreamingProgressBar progress={streamingProgress} isLoading={isLoading} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{processedListingData && processedListingData.features.length > 0 ? (
|
||||||
|
<Map
|
||||||
|
listingData={processedListingData}
|
||||||
|
queryParameters={queryParameters}
|
||||||
|
effectiveMetric={effectiveMetric}
|
||||||
|
onPropertyClick={handlePropertyClick}
|
||||||
|
pois={userPOIs}
|
||||||
|
isPickingPOI={poiPickerActive}
|
||||||
|
onPoiLocationPick={handlePoiLocationPick}
|
||||||
|
onCancelPoiPicking={handleCancelPoiPicking}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className="flex-1 flex items-center justify-center bg-muted/20 h-full">
|
||||||
|
<div className="text-center p-8 max-w-md">
|
||||||
|
{isLoading ? (
|
||||||
|
<>
|
||||||
|
<div className="text-4xl mb-4 animate-pulse">🏠</div>
|
||||||
|
<h2 className="text-lg font-semibold mb-2">Loading...</h2>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<div className="text-4xl mb-4">🏠</div>
|
||||||
|
<h2 className="text-lg font-semibold mb-2">Property Explorer</h2>
|
||||||
|
<p className="text-muted-foreground text-sm">
|
||||||
|
Use the filter button to find properties.
|
||||||
|
</p>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Filter FAB */}
|
||||||
|
<div className="fixed bottom-24 right-4 z-50 flex flex-col gap-2">
|
||||||
|
<Sheet open={mobileFilterOpen} onOpenChange={setMobileFilterOpen}>
|
||||||
|
<SheetTrigger asChild>
|
||||||
|
<Button size="lg" className="rounded-full shadow-lg h-14 w-14">
|
||||||
|
<Filter className="h-6 w-6" />
|
||||||
|
</Button>
|
||||||
|
</SheetTrigger>
|
||||||
|
<SheetContent side="left" className="w-80 p-0">
|
||||||
|
<div className="h-full flex flex-col">
|
||||||
|
<div className="flex-1 min-h-0">
|
||||||
|
<FilterPanel
|
||||||
|
onSubmit={onSubmit}
|
||||||
|
currentMetric={currentMetric}
|
||||||
|
isLoading={isLoading}
|
||||||
|
listingCount={processedListingData?.features.length}
|
||||||
|
user={user}
|
||||||
|
onTaskCreated={handlePOITaskCreated}
|
||||||
|
onStartPoiPicking={handleStartPoiPicking}
|
||||||
|
pickedPoiLocation={pickedPoiLocation}
|
||||||
|
userPOIs={userPOIs}
|
||||||
|
poiTravelFilters={poiTravelFilters}
|
||||||
|
onPoiTravelFiltersChange={setPoiTravelFilters}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="shrink-0 p-4">
|
||||||
|
<VisualizationCard
|
||||||
|
metric={currentMetric}
|
||||||
|
onMetricChange={handleMetricChange}
|
||||||
|
userPOIs={userPOIs}
|
||||||
|
onPoiMetricChange={setPoiMetricSelection}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</SheetContent>
|
||||||
|
</Sheet>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Bottom Sheet */}
|
||||||
|
{processedListingData && processedListingData.features.length > 0 && (
|
||||||
|
<MobileBottomSheet
|
||||||
|
listingData={processedListingData}
|
||||||
|
onPropertyClick={handlePropertyClick}
|
||||||
|
highlightedPropertyUrl={highlightedProperty}
|
||||||
|
onActiveListingChange={handleActiveListingChange}
|
||||||
|
poiMetricSelection={poiMetricSelection}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
|
||||||
const handlePOITaskCreated = (taskId: string) => {
|
const handlePOITaskCreated = (taskId: string) => {
|
||||||
setExplicitTaskId(taskId);
|
setExplicitTaskId(taskId);
|
||||||
if (taskId) subscribe(taskId);
|
if (taskId) subscribe(taskId);
|
||||||
|
|
@ -393,6 +494,7 @@ function App() {
|
||||||
setPoiPickerActive(false);
|
setPoiPickerActive(false);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="h-screen flex flex-col overflow-hidden">
|
<div className="h-screen flex flex-col overflow-hidden">
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
|
|
@ -412,99 +514,65 @@ function App() {
|
||||||
onTaskCompleted={handleTaskCompleted}
|
onTaskCompleted={handleTaskCompleted}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Main content area */}
|
{isMobile ? (
|
||||||
<div className="flex-1 flex overflow-hidden min-h-0">
|
renderMobileLayout()
|
||||||
{/* Filter Panel - Desktop (fixed sidebar) */}
|
) : (
|
||||||
<div className="hidden md:block w-80 shrink-0 h-full overflow-hidden">
|
/* Desktop layout */
|
||||||
<div className="h-full flex flex-col">
|
<div className="flex-1 flex overflow-hidden min-h-0">
|
||||||
<div className="flex-1 min-h-0">
|
{/* Filter Panel - Desktop (fixed sidebar) */}
|
||||||
<FilterPanel
|
<div className="w-80 shrink-0 h-full overflow-hidden">
|
||||||
onSubmit={onSubmit}
|
<div className="h-full flex flex-col">
|
||||||
currentMetric={currentMetric}
|
<div className="flex-1 min-h-0">
|
||||||
isLoading={isLoading}
|
<FilterPanel
|
||||||
listingCount={processedListingData?.features.length}
|
onSubmit={onSubmit}
|
||||||
user={user}
|
currentMetric={currentMetric}
|
||||||
onTaskCreated={handlePOITaskCreated}
|
isLoading={isLoading}
|
||||||
onStartPoiPicking={handleStartPoiPicking}
|
listingCount={processedListingData?.features.length}
|
||||||
pickedPoiLocation={pickedPoiLocation}
|
user={user}
|
||||||
userPOIs={userPOIs}
|
onTaskCreated={handlePOITaskCreated}
|
||||||
poiTravelFilters={poiTravelFilters}
|
onStartPoiPicking={handleStartPoiPicking}
|
||||||
onPoiTravelFiltersChange={setPoiTravelFilters}
|
pickedPoiLocation={pickedPoiLocation}
|
||||||
/>
|
userPOIs={userPOIs}
|
||||||
</div>
|
poiTravelFilters={poiTravelFilters}
|
||||||
<div className="shrink-0 p-4 border-r">
|
onPoiTravelFiltersChange={setPoiTravelFilters}
|
||||||
<VisualizationCard
|
/>
|
||||||
metric={currentMetric}
|
</div>
|
||||||
onMetricChange={handleMetricChange}
|
<div className="shrink-0 p-4 border-r">
|
||||||
userPOIs={userPOIs}
|
<VisualizationCard
|
||||||
onPoiMetricChange={setPoiMetricSelection}
|
metric={currentMetric}
|
||||||
/>
|
onMetricChange={handleMetricChange}
|
||||||
</div>
|
userPOIs={userPOIs}
|
||||||
</div>
|
onPoiMetricChange={setPoiMetricSelection}
|
||||||
</div>
|
/>
|
||||||
|
|
||||||
{/* Filter Panel - Mobile (sheet) */}
|
|
||||||
<div className="md:hidden fixed bottom-4 right-4 z-50">
|
|
||||||
<Sheet open={mobileFilterOpen} onOpenChange={setMobileFilterOpen}>
|
|
||||||
<SheetTrigger asChild>
|
|
||||||
<Button size="lg" className="rounded-full shadow-lg h-14 w-14">
|
|
||||||
<Filter className="h-6 w-6" />
|
|
||||||
</Button>
|
|
||||||
</SheetTrigger>
|
|
||||||
<SheetContent side="left" className="w-80 p-0">
|
|
||||||
<div className="h-full flex flex-col">
|
|
||||||
<div className="flex-1 min-h-0">
|
|
||||||
<FilterPanel
|
|
||||||
onSubmit={onSubmit}
|
|
||||||
currentMetric={currentMetric}
|
|
||||||
isLoading={isLoading}
|
|
||||||
listingCount={processedListingData?.features.length}
|
|
||||||
user={user}
|
|
||||||
onTaskCreated={handlePOITaskCreated}
|
|
||||||
onStartPoiPicking={handleStartPoiPicking}
|
|
||||||
pickedPoiLocation={pickedPoiLocation}
|
|
||||||
userPOIs={userPOIs}
|
|
||||||
poiTravelFilters={poiTravelFilters}
|
|
||||||
onPoiTravelFiltersChange={setPoiTravelFilters}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="shrink-0 p-4">
|
|
||||||
<VisualizationCard
|
|
||||||
metric={currentMetric}
|
|
||||||
onMetricChange={handleMetricChange}
|
|
||||||
userPOIs={userPOIs}
|
|
||||||
onPoiMetricChange={setPoiMetricSelection}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</SheetContent>
|
|
||||||
</Sheet>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Main View Area */}
|
|
||||||
<div className="flex-1 flex flex-col overflow-hidden min-h-0">
|
|
||||||
{/* Streaming Progress Bar */}
|
|
||||||
<div className="relative shrink-0">
|
|
||||||
<StreamingProgressBar progress={streamingProgress} isLoading={isLoading} />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Map/List Container */}
|
|
||||||
<div className="flex-1 flex overflow-hidden min-h-0">
|
|
||||||
{renderMainContent()}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Stats Bar */}
|
|
||||||
{processedListingData && processedListingData.features.length > 0 && (
|
|
||||||
<div className="shrink-0">
|
|
||||||
<StatsBar
|
|
||||||
listingData={processedListingData}
|
|
||||||
viewMode={viewMode}
|
|
||||||
onViewModeChange={setViewMode}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
</div>
|
||||||
|
|
||||||
|
{/* Main View Area */}
|
||||||
|
<div className="flex-1 flex flex-col overflow-hidden min-h-0">
|
||||||
|
{/* Streaming Progress Bar */}
|
||||||
|
<div className="relative shrink-0">
|
||||||
|
<StreamingProgressBar progress={streamingProgress} isLoading={isLoading} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Map/List Container */}
|
||||||
|
<div className="flex-1 flex overflow-hidden min-h-0">
|
||||||
|
{renderMainContent()}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Stats Bar */}
|
||||||
|
{processedListingData && processedListingData.features.length > 0 && (
|
||||||
|
<div className="shrink-0">
|
||||||
|
<StatsBar
|
||||||
|
listingData={processedListingData}
|
||||||
|
viewMode={viewMode}
|
||||||
|
onViewModeChange={setViewMode}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
)}
|
||||||
|
|
||||||
{/* Error Dialog */}
|
{/* Error Dialog */}
|
||||||
<AlertError message={submitError} open={alertDialogIsOpen} setIsOpen={setAlertDialogIsOpen} />
|
<AlertError message={submitError} open={alertDialogIsOpen} setIsOpen={setAlertDialogIsOpen} />
|
||||||
|
|
|
||||||
|
|
@ -69,12 +69,23 @@
|
||||||
/* Mobile adjustments */
|
/* Mobile adjustments */
|
||||||
@media (max-width: 768px) {
|
@media (max-width: 768px) {
|
||||||
#legend {
|
#legend {
|
||||||
width: 75px;
|
width: 70px;
|
||||||
padding: 8px;
|
padding: 6px;
|
||||||
min-height: 250px;
|
min-height: 200px;
|
||||||
|
top: 10px;
|
||||||
|
right: auto;
|
||||||
|
left: 10px;
|
||||||
|
font-size: 10px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.mapboxgl-popup-content {
|
.mapboxgl-popup-content {
|
||||||
max-width: 90vw !important;
|
max-width: 90vw !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.mapboxgl-popup-close-button {
|
||||||
|
font-size: 24px;
|
||||||
|
padding: 6px 10px;
|
||||||
|
min-width: 44px;
|
||||||
|
min-height: 44px;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -2,11 +2,13 @@ import type { AuthUser } from '@/auth/types';
|
||||||
import type { TaskState } from '@/types';
|
import type { TaskState } from '@/types';
|
||||||
import { Button } from './ui/button';
|
import { Button } from './ui/button';
|
||||||
import { Separator } from './ui/separator';
|
import { Separator } from './ui/separator';
|
||||||
import { LogOut, Home, Filter } from 'lucide-react';
|
import { LogOut, Home } from 'lucide-react';
|
||||||
import { logout } from '@/auth/authService';
|
import { logout } from '@/auth/authService';
|
||||||
import { clearPasskeyUser } from '@/auth/passkeyService';
|
import { clearPasskeyUser } from '@/auth/passkeyService';
|
||||||
import { HealthIndicator } from './HealthIndicator';
|
import { HealthIndicator } from './HealthIndicator';
|
||||||
import { TaskIndicator } from './TaskIndicator';
|
import { TaskIndicator } from './TaskIndicator';
|
||||||
|
import { MobileMenu } from './MobileMenu';
|
||||||
|
import { useIsMobile } from '@/hooks/use-mobile';
|
||||||
|
|
||||||
interface HeaderProps {
|
interface HeaderProps {
|
||||||
user: AuthUser;
|
user: AuthUser;
|
||||||
|
|
@ -25,9 +27,6 @@ interface HeaderProps {
|
||||||
|
|
||||||
export function Header({
|
export function Header({
|
||||||
user,
|
user,
|
||||||
activeFilterCount = 0,
|
|
||||||
onToggleFilters,
|
|
||||||
showFilterToggle = false,
|
|
||||||
tasks,
|
tasks,
|
||||||
activeTaskId,
|
activeTaskId,
|
||||||
isConnected,
|
isConnected,
|
||||||
|
|
@ -35,6 +34,8 @@ export function Header({
|
||||||
onClearAllTasks,
|
onClearAllTasks,
|
||||||
onTaskCompleted,
|
onTaskCompleted,
|
||||||
}: HeaderProps) {
|
}: HeaderProps) {
|
||||||
|
const isMobile = useIsMobile();
|
||||||
|
|
||||||
const handleLogout = async () => {
|
const handleLogout = async () => {
|
||||||
if (user.provider === 'passkey') {
|
if (user.provider === 'passkey') {
|
||||||
clearPasskeyUser();
|
clearPasskeyUser();
|
||||||
|
|
@ -45,63 +46,62 @@ export function Header({
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<header className="flex h-14 shrink-0 items-center gap-3 border-b bg-background px-4">
|
<header className={`flex shrink-0 items-center gap-3 border-b bg-background px-4 ${isMobile ? 'h-12' : 'h-14'}`}>
|
||||||
{/* Logo / Brand */}
|
{/* Logo / Brand */}
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Home className="h-5 w-5 text-primary" />
|
<Home className="h-5 w-5 text-primary" />
|
||||||
<span className="font-semibold text-lg hidden sm:inline">Wrongmove</span>
|
<span className="font-semibold text-lg hidden sm:inline">Wrongmove</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Separator orientation="vertical" className="h-6" />
|
{/* Desktop-only items */}
|
||||||
|
{!isMobile && (
|
||||||
{/* Health Indicator */}
|
<>
|
||||||
<HealthIndicator />
|
<Separator orientation="vertical" className="h-6" />
|
||||||
|
<HealthIndicator />
|
||||||
{/* Task Indicator */}
|
<TaskIndicator
|
||||||
<TaskIndicator
|
tasks={tasks}
|
||||||
tasks={tasks}
|
activeTaskId={activeTaskId}
|
||||||
activeTaskId={activeTaskId}
|
isConnected={isConnected}
|
||||||
isConnected={isConnected}
|
onCancelTask={onCancelTask}
|
||||||
onCancelTask={onCancelTask}
|
onClearAllTasks={onClearAllTasks}
|
||||||
onClearAllTasks={onClearAllTasks}
|
onTaskCompleted={onTaskCompleted}
|
||||||
onTaskCompleted={onTaskCompleted}
|
/>
|
||||||
/>
|
</>
|
||||||
|
|
||||||
{/* Filter Toggle (mobile) */}
|
|
||||||
{showFilterToggle && (
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
className="sm:hidden"
|
|
||||||
onClick={onToggleFilters}
|
|
||||||
>
|
|
||||||
<Filter className="h-4 w-4" />
|
|
||||||
{activeFilterCount > 0 && (
|
|
||||||
<span className="ml-1 bg-primary text-primary-foreground text-xs px-1.5 py-0.5 rounded-full">
|
|
||||||
{activeFilterCount}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</Button>
|
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Spacer */}
|
{/* Spacer */}
|
||||||
<div className="flex-1" />
|
<div className="flex-1" />
|
||||||
|
|
||||||
{/* User Menu */}
|
{/* Mobile: hamburger menu */}
|
||||||
<div className="flex items-center gap-3">
|
{isMobile && (
|
||||||
<span className="text-sm text-muted-foreground hidden md:inline">
|
<MobileMenu
|
||||||
{user.email}
|
user={user}
|
||||||
</span>
|
tasks={tasks}
|
||||||
<Button
|
activeTaskId={activeTaskId}
|
||||||
variant="ghost"
|
isConnected={isConnected}
|
||||||
size="sm"
|
onCancelTask={onCancelTask}
|
||||||
onClick={handleLogout}
|
onClearAllTasks={onClearAllTasks}
|
||||||
className="gap-2"
|
onTaskCompleted={onTaskCompleted}
|
||||||
>
|
/>
|
||||||
<LogOut className="h-4 w-4" />
|
)}
|
||||||
<span className="hidden sm:inline">Logout</span>
|
|
||||||
</Button>
|
{/* Desktop: user menu */}
|
||||||
</div>
|
{!isMobile && (
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<span className="text-sm text-muted-foreground">
|
||||||
|
{user.email}
|
||||||
|
</span>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={handleLogout}
|
||||||
|
className="gap-2"
|
||||||
|
>
|
||||||
|
<LogOut className="h-4 w-4" />
|
||||||
|
Logout
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</header>
|
</header>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
96
frontend/src/components/MobileMenu.tsx
Normal file
96
frontend/src/components/MobileMenu.tsx
Normal file
|
|
@ -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<string, TaskState>;
|
||||||
|
activeTaskId: string | null;
|
||||||
|
isConnected: boolean;
|
||||||
|
onCancelTask: (taskId: string) => Promise<boolean>;
|
||||||
|
onClearAllTasks: () => Promise<boolean>;
|
||||||
|
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 (
|
||||||
|
<Sheet>
|
||||||
|
<SheetTrigger asChild>
|
||||||
|
<Button variant="ghost" size="sm" className="h-10 w-10 p-0">
|
||||||
|
<Menu className="h-5 w-5" />
|
||||||
|
</Button>
|
||||||
|
</SheetTrigger>
|
||||||
|
<SheetContent side="right" className="w-72">
|
||||||
|
<SheetHeader>
|
||||||
|
<SheetTitle>Menu</SheetTitle>
|
||||||
|
</SheetHeader>
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-4 px-4">
|
||||||
|
{/* User info */}
|
||||||
|
<div className="text-sm text-muted-foreground">
|
||||||
|
{user.email}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Separator />
|
||||||
|
|
||||||
|
{/* Health */}
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="text-sm font-medium">System Health</span>
|
||||||
|
<HealthIndicator />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Separator />
|
||||||
|
|
||||||
|
{/* Tasks */}
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="text-sm font-medium">Tasks</span>
|
||||||
|
<TaskIndicator
|
||||||
|
tasks={tasks}
|
||||||
|
activeTaskId={activeTaskId}
|
||||||
|
isConnected={isConnected}
|
||||||
|
onCancelTask={onCancelTask}
|
||||||
|
onClearAllTasks={onClearAllTasks}
|
||||||
|
onTaskCompleted={onTaskCompleted}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Separator />
|
||||||
|
|
||||||
|
{/* Logout */}
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
onClick={handleLogout}
|
||||||
|
className="w-full gap-2"
|
||||||
|
>
|
||||||
|
<LogOut className="h-4 w-4" />
|
||||||
|
Logout
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</SheetContent>
|
||||||
|
</Sheet>
|
||||||
|
);
|
||||||
|
}
|
||||||
76
frontend/src/components/PropertyCardCompact.tsx
Normal file
76
frontend/src/components/PropertyCardCompact.tsx
Normal file
|
|
@ -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 (
|
||||||
|
<div
|
||||||
|
className={`w-[280px] shrink-0 snap-center rounded-lg border bg-background shadow-sm overflow-hidden cursor-pointer transition-all ${
|
||||||
|
isActive ? 'ring-2 ring-primary scale-[1.02]' : ''
|
||||||
|
} ${isHighlighted ? 'ring-2 ring-primary' : ''}`}
|
||||||
|
onClick={onClick}
|
||||||
|
>
|
||||||
|
{/* Thumbnail */}
|
||||||
|
<div className="h-28 w-full bg-muted">
|
||||||
|
{property.photo_thumbnail && (
|
||||||
|
<img
|
||||||
|
src={property.photo_thumbnail}
|
||||||
|
alt="Property"
|
||||||
|
className="w-full h-full object-cover"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Details */}
|
||||||
|
<div className="p-3">
|
||||||
|
<div className="flex items-start justify-between gap-2">
|
||||||
|
<div className="font-semibold text-base">
|
||||||
|
£{property.total_price.toLocaleString()}
|
||||||
|
{property.listing_type !== 'BUY' && (
|
||||||
|
<span className="text-muted-foreground font-normal text-sm">/mo</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{priceIndicator && (
|
||||||
|
<span className={`text-xs px-1.5 py-0.5 rounded shrink-0 ${priceIndicator.color}`}>
|
||||||
|
{priceIndicator.label}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-3 mt-1.5 text-sm text-muted-foreground">
|
||||||
|
<span className="flex items-center gap-1">
|
||||||
|
<Bed className="h-3.5 w-3.5" />
|
||||||
|
{property.rooms}
|
||||||
|
</span>
|
||||||
|
<span className="flex items-center gap-1">
|
||||||
|
<Maximize2 className="h-3.5 w-3.5" />
|
||||||
|
{property.qm} m²
|
||||||
|
</span>
|
||||||
|
<span>£{property.qmprice}/m²</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
87
frontend/src/components/SwipeableCardRow.tsx
Normal file
87
frontend/src/components/SwipeableCardRow.tsx
Normal file
|
|
@ -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<HTMLDivElement>(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<number | null>(null);
|
||||||
|
const debouncedScroll = useCallback(() => {
|
||||||
|
if (scrollTimerRef.current) clearTimeout(scrollTimerRef.current);
|
||||||
|
scrollTimerRef.current = window.setTimeout(handleScroll, 100);
|
||||||
|
}, [handleScroll]);
|
||||||
|
|
||||||
|
if (features.length === 0) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
ref={scrollRef}
|
||||||
|
onScroll={debouncedScroll}
|
||||||
|
className="flex gap-3 overflow-x-auto snap-x snap-mandatory px-4 py-2 scrollbar-none"
|
||||||
|
style={{ WebkitOverflowScrolling: 'touch' }}
|
||||||
|
>
|
||||||
|
{features.map((feature, index) => (
|
||||||
|
<PropertyCardCompact
|
||||||
|
key={feature.properties.url}
|
||||||
|
property={feature.properties}
|
||||||
|
isActive={index === activeIndex}
|
||||||
|
isHighlighted={feature.properties.url === highlightedPropertyUrl}
|
||||||
|
avgPricePerSqm={avgPricePerSqm}
|
||||||
|
onClick={() => onCardClick?.(feature)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -10,6 +10,7 @@ import {
|
||||||
import { Button } from './ui/button';
|
import { Button } from './ui/button';
|
||||||
import { CheckCircle2, Circle, Loader2, XCircle, MapPin, Search } from 'lucide-react';
|
import { CheckCircle2, Circle, Loader2, XCircle, MapPin, Search } from 'lucide-react';
|
||||||
import { useEffect, useRef, useMemo } from 'react';
|
import { useEffect, useRef, useMemo } from 'react';
|
||||||
|
import { useIsMobile } from '@/hooks/use-mobile';
|
||||||
|
|
||||||
interface TaskProgressDrawerProps {
|
interface TaskProgressDrawerProps {
|
||||||
open: boolean;
|
open: boolean;
|
||||||
|
|
@ -420,6 +421,7 @@ export function TaskProgressDrawer({
|
||||||
onSelectTask,
|
onSelectTask,
|
||||||
}: TaskProgressDrawerProps) {
|
}: TaskProgressDrawerProps) {
|
||||||
// Determine which task's data to show
|
// Determine which task's data to show
|
||||||
|
const isMobile = useIsMobile();
|
||||||
const hasMultipleTasks = tasks && Object.keys(tasks).length > 1;
|
const hasMultipleTasks = tasks && Object.keys(tasks).length > 1;
|
||||||
const effectiveTaskId = selectedTaskId ?? taskID;
|
const effectiveTaskId = selectedTaskId ?? taskID;
|
||||||
|
|
||||||
|
|
@ -446,7 +448,10 @@ export function TaskProgressDrawer({
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Sheet open={open} onOpenChange={onOpenChange}>
|
<Sheet open={open} onOpenChange={onOpenChange}>
|
||||||
<SheetContent side="right" className="flex flex-col w-full sm:!max-w-lg">
|
<SheetContent
|
||||||
|
side={isMobile ? "bottom" : "right"}
|
||||||
|
className={`flex flex-col ${isMobile ? 'h-[85vh] rounded-t-xl' : 'w-full sm:!max-w-lg'}`}
|
||||||
|
>
|
||||||
<SheetHeader>
|
<SheetHeader>
|
||||||
<div className="flex items-center justify-between pr-6">
|
<div className="flex items-center justify-between pr-6">
|
||||||
<SheetTitle>{drawerTitle}</SheetTitle>
|
<SheetTitle>{drawerTitle}</SheetTitle>
|
||||||
|
|
|
||||||
|
|
@ -145,3 +145,11 @@
|
||||||
.animate-accordion-up {
|
.animate-accordion-up {
|
||||||
animation: accordion-up 0.2s ease-out;
|
animation: accordion-up 0.2s ease-out;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@utility scrollbar-none {
|
||||||
|
-ms-overflow-style: none;
|
||||||
|
scrollbar-width: none;
|
||||||
|
&::-webkit-scrollbar {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue