feat: make frontend fully responsive with mobile-first layout

Add mobile-responsive design with full feature parity:
- Bottom sheet (vaul) with 3 snap points for map+list coexistence
- Swipeable property cards with horizontal scroll-snap
- Hamburger menu with health, tasks, user info
- Full-screen map with repositioned legend (top-left on mobile)
- Filter FAB opening Sheet drawer
- TaskProgressDrawer from bottom on mobile
- All changes gated behind useIsMobile() hook (768px breakpoint)
- Desktop layout completely untouched

New components: MobileBottomSheet, SwipeableCardRow,
PropertyCardCompact, MobileMenu

Also fixes: idempotent longitude migration, React hooks order
This commit is contained in:
Viktor Barzin 2026-02-21 11:34:53 +00:00
parent 8f068a581e
commit a744b33578
No known key found for this signature in database
GPG key ID: 0EB088298288D958
14 changed files with 1768 additions and 152 deletions

View file

@ -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",

View file

@ -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"
}
}

View file

@ -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<Record<number, POITravelFilter>>({});
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")
const [explicitTaskId, setExplicitTaskId] = useState<string | null>(null);
@ -258,6 +262,10 @@ function App() {
setExplicitTaskId(null);
}, []);
const handleActiveListingChange = useCallback((feature: PropertyFeature | null) => {
setActiveCardFeature(feature);
}, []);
if (!user) {
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) => {
setExplicitTaskId(taskId);
if (taskId) subscribe(taskId);
@ -393,6 +494,7 @@ function App() {
setPoiPickerActive(false);
};
return (
<div className="h-screen flex flex-col overflow-hidden">
{/* Header */}
@ -412,99 +514,65 @@ function App() {
onTaskCompleted={handleTaskCompleted}
/>
{/* Main content area */}
<div className="flex-1 flex overflow-hidden min-h-0">
{/* Filter Panel - Desktop (fixed sidebar) */}
<div className="hidden md:block w-80 shrink-0 h-full overflow-hidden">
<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 border-r">
<VisualizationCard
metric={currentMetric}
onMetricChange={handleMetricChange}
userPOIs={userPOIs}
onPoiMetricChange={setPoiMetricSelection}
/>
</div>
</div>
</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>
{isMobile ? (
renderMobileLayout()
) : (
/* Desktop layout */
<div className="flex-1 flex overflow-hidden min-h-0">
{/* Filter Panel - Desktop (fixed sidebar) */}
<div className="w-80 shrink-0 h-full overflow-hidden">
<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 border-r">
<VisualizationCard
metric={currentMetric}
onMetricChange={handleMetricChange}
userPOIs={userPOIs}
onPoiMetricChange={setPoiMetricSelection}
/>
</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>
{/* 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>
)}
{/* Error Dialog */}
<AlertError message={submitError} open={alertDialogIsOpen} setIsOpen={setAlertDialogIsOpen} />

View file

@ -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;
}
}

View file

@ -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 (
<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 */}
<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>
<Separator orientation="vertical" className="h-6" />
{/* Health Indicator */}
<HealthIndicator />
{/* Task Indicator */}
<TaskIndicator
tasks={tasks}
activeTaskId={activeTaskId}
isConnected={isConnected}
onCancelTask={onCancelTask}
onClearAllTasks={onClearAllTasks}
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>
{/* 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" />
{/* User Menu */}
<div className="flex items-center gap-3">
<span className="text-sm text-muted-foreground hidden md:inline">
{user.email}
</span>
<Button
variant="ghost"
size="sm"
onClick={handleLogout}
className="gap-2"
>
<LogOut className="h-4 w-4" />
<span className="hidden sm:inline">Logout</span>
</Button>
</div>
{/* 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>
);
}

View file

@ -0,0 +1,127 @@
import { useState, useMemo, useCallback } from 'react';
import { Drawer } from 'vaul';
import { MapPin, PoundSterling } from 'lucide-react';
import { SwipeableCardRow } from './SwipeableCardRow';
import { ListView } from './ListView';
import type { GeoJSONFeatureCollection, PropertyProperties, PropertyFeature } from '@/types';
interface MobileBottomSheetProps {
listingData: GeoJSONFeatureCollection | null;
onPropertyClick?: (property: PropertyProperties, coordinates: [number, number]) => void;
highlightedPropertyUrl?: string | null;
onActiveListingChange?: (feature: PropertyFeature | null) => void;
poiMetricSelection?: { poiId: number; travelMode: string } | null;
}
function formatCurrency(value: number): string {
if (value >= 1000) return `£${(value / 1000).toFixed(1)}k`;
return `£${Math.round(value)}`;
}
export function MobileBottomSheet({
listingData,
onPropertyClick,
highlightedPropertyUrl,
onActiveListingChange,
poiMetricSelection,
}: MobileBottomSheetProps) {
const [snap, setSnap] = useState<string | number>("148px");
const [activeCardIndex, setActiveCardIndex] = useState(0);
const features = listingData?.features ?? [];
const stats = useMemo(() => {
if (features.length === 0) return { count: 0, avgPrice: 0 };
const validPrices = features
.map((f) => f.properties.total_price)
.filter((p): p is number => typeof p === 'number' && p > 0);
const avgPrice = validPrices.length > 0
? validPrices.reduce((a, b) => a + b, 0) / validPrices.length
: 0;
return { count: features.length, avgPrice };
}, [features]);
const avgPricePerSqm = useMemo(() => {
const validPrices = features
.map((f) => f.properties.qmprice)
.filter((p): p is number => typeof p === 'number' && p > 0);
return validPrices.length > 0
? validPrices.reduce((a, b) => a + b, 0) / validPrices.length
: 0;
}, [features]);
const handleActiveIndexChange = useCallback((index: number) => {
setActiveCardIndex(index);
if (features[index]) {
onActiveListingChange?.(features[index]);
}
}, [features, onActiveListingChange]);
const handleCardClick = useCallback((feature: PropertyFeature) => {
window.open(feature.properties.url, '_blank', 'noopener,noreferrer');
onPropertyClick?.(feature.properties, feature.geometry.coordinates);
}, [onPropertyClick]);
const isExpanded = snap === 0.85;
return (
<Drawer.Root
open
snapPoints={["80px", "148px", 0.85]}
activeSnapPoint={snap}
setActiveSnapPoint={setSnap}
modal={false}
>
<Drawer.Portal>
<Drawer.Content
className="fixed inset-x-0 bottom-0 z-40 flex flex-col rounded-t-xl bg-background border-t shadow-lg"
style={{ maxHeight: '85vh' }}
>
{/* Drag handle */}
<div className="flex justify-center pt-2 pb-1">
<div className="h-1.5 w-10 rounded-full bg-muted-foreground/30" />
</div>
{/* Compact stats (always visible) */}
<div className="flex items-center gap-4 px-4 pb-2 text-sm text-muted-foreground">
<div className="flex items-center gap-1.5">
<MapPin className="h-4 w-4" />
<span className="font-medium text-foreground">{stats.count.toLocaleString()}</span>
<span>listings</span>
</div>
{stats.avgPrice > 0 && (
<div className="flex items-center gap-1.5">
<PoundSterling className="h-4 w-4" />
<span>Avg: <span className="font-medium text-foreground">{formatCurrency(stats.avgPrice)}</span></span>
</div>
)}
</div>
{/* Swipeable cards (visible at peek snap) */}
{!isExpanded && features.length > 0 && (
<SwipeableCardRow
features={features}
activeIndex={activeCardIndex}
onActiveIndexChange={handleActiveIndexChange}
avgPricePerSqm={avgPricePerSqm}
highlightedPropertyUrl={highlightedPropertyUrl}
onCardClick={handleCardClick}
/>
)}
{/* Full list view (visible at expanded snap) */}
{isExpanded && listingData && features.length > 0 && (
<div className="flex-1 min-h-0 overflow-hidden">
<ListView
listingData={listingData}
onPropertyClick={onPropertyClick}
highlightedPropertyUrl={highlightedPropertyUrl}
poiMetricSelection={poiMetricSelection}
/>
</div>
)}
</Drawer.Content>
</Drawer.Portal>
</Drawer.Root>
);
}

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

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

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

View file

@ -10,6 +10,7 @@ import {
import { Button } from './ui/button';
import { CheckCircle2, Circle, Loader2, XCircle, MapPin, Search } from 'lucide-react';
import { useEffect, useRef, useMemo } from 'react';
import { useIsMobile } from '@/hooks/use-mobile';
interface TaskProgressDrawerProps {
open: boolean;
@ -420,6 +421,7 @@ export function TaskProgressDrawer({
onSelectTask,
}: TaskProgressDrawerProps) {
// Determine which task's data to show
const isMobile = useIsMobile();
const hasMultipleTasks = tasks && Object.keys(tasks).length > 1;
const effectiveTaskId = selectedTaskId ?? taskID;
@ -446,7 +448,10 @@ export function TaskProgressDrawer({
return (
<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>
<div className="flex items-center justify-between pr-6">
<SheetTitle>{drawerTitle}</SheetTitle>

View file

@ -145,3 +145,11 @@
.animate-accordion-up {
animation: accordion-up 0.2s ease-out;
}
@utility scrollbar-none {
-ms-overflow-style: none;
scrollbar-width: none;
&::-webkit-scrollbar {
display: none;
}
}