feat: UI/UX redesign — map-first with modern chrome

- Horizontal filter bar replacing sidebar (full-width map)
- React Router with URL-based filter state and deep linking
- Teal-accented color palette
- Redesigned property cards with better visual hierarchy
- Tabbed listing detail sheet (Overview, Travel, Price History, Details)
- Error boundary
- Shared utility extraction (format, task utils)
This commit is contained in:
Viktor Barzin 2026-02-28 17:18:11 +00:00
commit 8fcd530d7f
No known key found for this signature in database
GPG key ID: 0EB088298288D958
22 changed files with 1841 additions and 602 deletions

View file

@ -42,6 +42,7 @@
"react-dom": "^19.1.0",
"react-hook-form": "^7.58.1",
"react-oidc-context": "^3.3.0",
"react-router-dom": "^7.13.1",
"react-virtuoso": "^4.18.1",
"tailwind-merge": "^3.3.1",
"tailwindcss": "^4.1.10",
@ -4462,7 +4463,6 @@
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/cookie/-/cookie-1.1.1.tgz",
"integrity": "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=18"
@ -7175,6 +7175,44 @@
}
}
},
"node_modules/react-router": {
"version": "7.13.1",
"resolved": "https://registry.npmjs.org/react-router/-/react-router-7.13.1.tgz",
"integrity": "sha512-td+xP4X2/6BJvZoX6xw++A2DdEi++YypA69bJUV5oVvqf6/9/9nNlD70YO1e9d3MyamJEBQFEzk6mbfDYbqrSA==",
"license": "MIT",
"dependencies": {
"cookie": "^1.0.1",
"set-cookie-parser": "^2.6.0"
},
"engines": {
"node": ">=20.0.0"
},
"peerDependencies": {
"react": ">=18",
"react-dom": ">=18"
},
"peerDependenciesMeta": {
"react-dom": {
"optional": true
}
}
},
"node_modules/react-router-dom": {
"version": "7.13.1",
"resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-7.13.1.tgz",
"integrity": "sha512-UJnV3Rxc5TgUPJt2KJpo1Jpy0OKQr0AjgbZzBFjaPJcFOb2Y8jA5H3LT8HUJAiRLlWrEXWHbF1Z4SCZaQjWDHw==",
"license": "MIT",
"dependencies": {
"react-router": "7.13.1"
},
"engines": {
"node": ">=20.0.0"
},
"peerDependencies": {
"react": ">=18",
"react-dom": ">=18"
}
},
"node_modules/react-style-singleton": {
"version": "2.2.3",
"resolved": "https://registry.npmjs.org/react-style-singleton/-/react-style-singleton-2.2.3.tgz",
@ -7403,6 +7441,12 @@
"node": ">=4.0.0"
}
},
"node_modules/set-cookie-parser": {
"version": "2.7.2",
"resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.2.tgz",
"integrity": "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==",
"license": "MIT"
},
"node_modules/shebang-command": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz",

View file

@ -47,6 +47,7 @@
"react-dom": "^19.1.0",
"react-hook-form": "^7.58.1",
"react-oidc-context": "^3.3.0",
"react-router-dom": "^7.13.1",
"react-virtuoso": "^4.18.1",
"tailwind-merge": "^3.3.1",
"tailwindcss": "^4.1.10",

View file

@ -1,4 +1,5 @@
import { useEffect, useState, useRef, useCallback, useMemo } from 'react';
import { Routes, Route, Navigate } from 'react-router-dom';
import './App.css';
import { getUser } from './auth/authService';
import { getStoredPasskeyUser } from './auth/passkeyService';
@ -7,13 +8,16 @@ import AlertError from './components/AlertError';
import LoginModal from './components/LoginModal';
import AuthCallback from './components/AuthCallback';
import { Map } from './components/Map';
import { FilterPanel, type ParameterValues, DEFAULT_FILTER_VALUES, Metric } from './components/FilterPanel';
import { type ParameterValues, DEFAULT_FILTER_VALUES, Metric, ListingType } from './components/FilterPanel';
import { FilterBar } from './components/FilterBar';
import { FilterChips } from './components/FilterChips';
import { VisualizationCard } from './components/VisualizationCard';
import { Header } from './components/Header';
import { StatsBar, type ViewMode } from './components/StatsBar';
import { StatsBar } from './components/StatsBar';
import { ListView } from './components/ListView';
import { StreamingProgressBar } from './components/StreamingProgressBar';
import { Sheet, SheetContent, SheetTrigger } from './components/ui/sheet';
import { Dialog, DialogContent, DialogHeader, DialogTitle } from './components/ui/dialog';
import { ScrollArea } from './components/ui/scroll-area';
import { Button } from './components/ui/button';
import { Filter, Heart } from 'lucide-react';
import type { GeoJSONFeatureCollection, PropertyProperties, PropertyFeature, POI, POITravelFilter } from '@/types';
@ -22,26 +26,56 @@ import { getCached, setCached, invalidateAll as invalidateListingCache } from '@
import { setOnUnauthorized } from '@/services/apiClient';
import { clearPasskeyUser } from './auth/passkeyService';
import { poiMetricPropertyName, injectPoiMetricProperty } from '@/utils/poiUtils';
import { isTerminalStatus } from '@/utils/taskUtils';
import { useTaskProgress } from '@/hooks/useTaskProgress';
import { useDecisions } from '@/hooks/useDecisions';
import { useFilterParams } from '@/hooks/useFilterParams';
import { useIsMobile } from '@/hooks/use-mobile';
import { MobileBottomSheet } from './components/MobileBottomSheet';
import { SwipeReviewMode } from './components/SwipeReviewMode';
import { FavoritesView } from './components/FavoritesView';
import { ListingDetailSheet } from './components/ListingDetailSheet';
import { FilterPanel } from './components/FilterPanel';
function isTerminalStatus(status: string): boolean {
return status === 'SUCCESS' || status === 'FAILURE' || status === 'REVOKED';
}
function AppContent() {
const DEV_BYPASS_AUTH = import.meta.env.DEV && import.meta.env.VITE_DEV_BYPASS_AUTH === 'true';
function App() {
const [listingData, setListingData] = useState<GeoJSONFeatureCollection | null>(null);
const [user, setUser] = useState<AuthUser | null>(null);
const [queryParameters, setQueryParameters] = useState<ParameterValues | null>(null);
const [listingData, setListingData] = useState<GeoJSONFeatureCollection | null>(
DEV_BYPASS_AUTH
? {
type: 'FeatureCollection',
features: [
{ type: 'Feature', geometry: { type: 'Point', coordinates: [-0.075178, 51.55063] }, properties: { id: 171298169, url: 'https://www.rightmove.co.uk/properties/171298169', city: 'London', country: 'United Kingdom', qm: 50, qmprice: 35, total_price: 1750.0, rooms: 1, agency: 'Foxtons', available_from: new Date(Date.now() + 0 * 86400000).toISOString(), last_seen: new Date(Date.now() - 0 * 86400000).toISOString(), photo_thumbnail: 'https://media.rightmove.co.uk/dir/property-photo/af1b4b5b4/171298169/af1b4b5b499d4d8ff91239200712fcaa_max_200x138.jpeg', photos: ["https://media.rightmove.co.uk/dir/property-photo/af1b4b5b4/171298169/af1b4b5b499d4d8ff91239200712fcaa_max_200x138.jpeg"], price_history: [{ id: 0, price: 1750.0, last_seen: new Date().toISOString() }], listing_type: 'RENT' as const, } },
{ type: 'Feature', geometry: { type: 'Point', coordinates: [-0.210826, 51.514084] }, properties: { id: 171627275, url: 'https://www.rightmove.co.uk/properties/171627275', city: 'London', country: 'United Kingdom', qm: 53, qmprice: 13, total_price: 675.0, rooms: 1, agency: 'Savills', available_from: new Date(Date.now() + 7 * 86400000).toISOString(), last_seen: new Date(Date.now() - 1 * 86400000).toISOString(), photo_thumbnail: 'https://media.rightmove.co.uk/dir/property-photo/7a44a143a/171627275/7a44a143a86b86df6ce0b00c7e212d0c_max_200x138.jpeg', photos: ["https://media.rightmove.co.uk/dir/property-photo/7a44a143a/171627275/7a44a143a86b86df6ce0b00c7e212d0c_max_200x138.jpeg"], price_history: [{ id: 1, price: 675.0, last_seen: new Date().toISOString() }], listing_type: 'RENT' as const, } },
{ type: 'Feature', geometry: { type: 'Point', coordinates: [-0.219911, 51.5113] }, properties: { id: 171487298, url: 'https://www.rightmove.co.uk/properties/171487298', city: 'London', country: 'United Kingdom', qm: 56, qmprice: 46, total_price: 2600.0, rooms: 1, agency: 'KFH', available_from: new Date(Date.now() + 14 * 86400000).toISOString(), last_seen: new Date(Date.now() - 2 * 86400000).toISOString(), photo_thumbnail: 'https://media.rightmove.co.uk/dir/property-photo/cf00aff8a/171487298/cf00aff8a39b409d5306089bd924eb46_max_200x138.jpeg', photos: ["https://media.rightmove.co.uk/dir/property-photo/cf00aff8a/171487298/cf00aff8a39b409d5306089bd924eb46_max_200x138.jpeg"], price_history: [{ id: 2, price: 2600.0, last_seen: new Date().toISOString() }], listing_type: 'RENT' as const, } },
{ type: 'Feature', geometry: { type: 'Point', coordinates: [-0.23483, 51.59745] }, properties: { id: 171637820, url: 'https://www.rightmove.co.uk/properties/171637820', city: 'London', country: 'United Kingdom', qm: 79, qmprice: 22, total_price: 1750.0, rooms: 2, agency: 'Dexters', available_from: new Date(Date.now() + 21 * 86400000).toISOString(), last_seen: new Date(Date.now() - 3 * 86400000).toISOString(), photo_thumbnail: 'https://media.rightmove.co.uk/dir/property-photo/87b2f5665/171637820/87b2f5665935c299bbe6ea3ec6bea38e_max_200x138.jpeg', photos: ["https://media.rightmove.co.uk/dir/property-photo/87b2f5665/171637820/87b2f5665935c299bbe6ea3ec6bea38e_max_200x138.jpeg"], price_history: [{ id: 3, price: 1750.0, last_seen: new Date().toISOString() }], listing_type: 'RENT' as const, } },
{ type: 'Feature', geometry: { type: 'Point', coordinates: [-0.118182, 51.51208] }, properties: { id: 170452265, url: 'https://www.rightmove.co.uk/properties/170452265', city: 'London', country: 'United Kingdom', qm: 62, qmprice: 51, total_price: 3142.0, rooms: 1, agency: 'Hamptons', available_from: new Date(Date.now() + 28 * 86400000).toISOString(), last_seen: new Date(Date.now() - 4 * 86400000).toISOString(), photo_thumbnail: 'https://media.rightmove.co.uk/dir/property-photo/1151bd4f7/170452265/1151bd4f764838b681bd09f75bac47dd_max_200x138.jpeg', photos: ["https://media.rightmove.co.uk/dir/property-photo/1151bd4f7/170452265/1151bd4f764838b681bd09f75bac47dd_max_200x138.jpeg"], price_history: [{ id: 4, price: 3142.0, last_seen: new Date().toISOString() }], listing_type: 'RENT' as const, } },
{ type: 'Feature', geometry: { type: 'Point', coordinates: [-0.087829, 51.599163] }, properties: { id: 172602689, url: 'https://www.rightmove.co.uk/properties/172602689', city: 'London', country: 'United Kingdom', qm: 85, qmprice: 23, total_price: 1950.0, rooms: 2, agency: 'Marsh & Parsons', available_from: new Date(Date.now() + 35 * 86400000).toISOString(), last_seen: new Date(Date.now() - 5 * 86400000).toISOString(), photo_thumbnail: 'https://media.rightmove.co.uk/dir/property-photo/a23e02e66/172602689/a23e02e665be4ec82ca45901183a7d20_max_200x138.jpeg', photos: ["https://media.rightmove.co.uk/dir/property-photo/a23e02e66/172602689/a23e02e665be4ec82ca45901183a7d20_max_200x138.jpeg"], price_history: [{ id: 5, price: 1950.0, last_seen: new Date().toISOString() }], listing_type: 'RENT' as const, } },
{ type: 'Feature', geometry: { type: 'Point', coordinates: [-0.217715, 51.47611] }, properties: { id: 169862189, url: 'https://www.rightmove.co.uk/properties/169862189', city: 'London', country: 'United Kingdom', qm: 88, qmprice: 40, total_price: 3500.0, rooms: 2, agency: 'Foxtons', available_from: new Date(Date.now() + 42 * 86400000).toISOString(), last_seen: new Date(Date.now() - 6 * 86400000).toISOString(), photo_thumbnail: 'https://media.rightmove.co.uk/dir/property-photo/2fe041e38/169862189/2fe041e3863f39a8dc4d2a2b08db83fb_max_200x138.jpeg', photos: ["https://media.rightmove.co.uk/dir/property-photo/2fe041e38/169862189/2fe041e3863f39a8dc4d2a2b08db83fb_max_200x138.jpeg"], price_history: [{ id: 6, price: 3500.0, last_seen: new Date().toISOString() }], listing_type: 'RENT' as const, } },
{ type: 'Feature', geometry: { type: 'Point', coordinates: [-0.03591, 51.51939] }, properties: { id: 148208828, url: 'https://www.rightmove.co.uk/properties/148208828', city: 'London', country: 'United Kingdom', qm: 91, qmprice: 7, total_price: 600.0, rooms: 2, agency: 'Savills', available_from: new Date(Date.now() + 49 * 86400000).toISOString(), last_seen: new Date(Date.now() - 7 * 86400000).toISOString(), photo_thumbnail: 'https://media.rightmove.co.uk/dir/property-photo/9074245cc/148208828/9074245cc51278f30799374fc529aa68_max_200x138.jpeg', photos: ["https://media.rightmove.co.uk/dir/property-photo/9074245cc/148208828/9074245cc51278f30799374fc529aa68_max_200x138.jpeg"], price_history: [{ id: 7, price: 600.0, last_seen: new Date().toISOString() }], listing_type: 'RENT' as const, } },
{ type: 'Feature', geometry: { type: 'Point', coordinates: [-0.071391, 51.598953] }, properties: { id: 172310828, url: 'https://www.rightmove.co.uk/properties/172310828', city: 'London', country: 'United Kingdom', qm: 94, qmprice: 20, total_price: 1900.0, rooms: 2, agency: 'KFH', available_from: new Date(Date.now() + 56 * 86400000).toISOString(), last_seen: new Date(Date.now() - 8 * 86400000).toISOString(), photo_thumbnail: 'https://media.rightmove.co.uk/dir/property-photo/0d3c8d6a0/172310828/0d3c8d6a0b15fe2bd059f6f9529cec76_max_200x138.jpeg', photos: ["https://media.rightmove.co.uk/dir/property-photo/0d3c8d6a0/172310828/0d3c8d6a0b15fe2bd059f6f9529cec76_max_200x138.jpeg"], price_history: [{ id: 8, price: 1900.0, last_seen: new Date().toISOString() }], listing_type: 'RENT' as const, } },
{ type: 'Feature', geometry: { type: 'Point', coordinates: [-0.224072, 51.410313] }, properties: { id: 172684934, url: 'https://www.rightmove.co.uk/properties/172684934', city: 'London', country: 'United Kingdom', qm: 97, qmprice: 23, total_price: 2250.0, rooms: 2, agency: 'Dexters', available_from: new Date(Date.now() + 63 * 86400000).toISOString(), last_seen: new Date(Date.now() - 9 * 86400000).toISOString(), photo_thumbnail: 'https://media.rightmove.co.uk/dir/property-photo/00c5f92c0/172684934/00c5f92c0d6506a4a8ba30f49d1eb4da_max_200x138.jpeg', photos: ["https://media.rightmove.co.uk/dir/property-photo/00c5f92c0/172684934/00c5f92c0d6506a4a8ba30f49d1eb4da_max_200x138.jpeg"], price_history: [{ id: 9, price: 2250.0, last_seen: new Date().toISOString() }], listing_type: 'RENT' as const, } },
{ type: 'Feature', geometry: { type: 'Point', coordinates: [-0.239887, 51.59445] }, properties: { id: 172685690, url: 'https://www.rightmove.co.uk/properties/172685690', city: 'London', country: 'United Kingdom', qm: 80, qmprice: 26, total_price: 2100.0, rooms: 1, agency: 'Hamptons', available_from: new Date(Date.now() + 70 * 86400000).toISOString(), last_seen: new Date(Date.now() - 10 * 86400000).toISOString(), photo_thumbnail: 'https://media.rightmove.co.uk/dir/property-photo/2aec54530/172685690/2aec54530b9e2f039ad30371f3bbcb4c_max_200x138.jpeg', photos: ["https://media.rightmove.co.uk/dir/property-photo/2aec54530/172685690/2aec54530b9e2f039ad30371f3bbcb4c_max_200x138.jpeg"], price_history: [{ id: 10, price: 2100.0, last_seen: new Date().toISOString() }], listing_type: 'RENT' as const, } },
{ type: 'Feature', geometry: { type: 'Point', coordinates: [-0.113622, 51.462383] }, properties: { id: 148681163, url: 'https://www.rightmove.co.uk/properties/148681163', city: 'London', country: 'United Kingdom', qm: 123, qmprice: 19, total_price: 2300.0, rooms: 3, agency: 'Marsh & Parsons', available_from: new Date(Date.now() + 77 * 86400000).toISOString(), last_seen: new Date(Date.now() - 11 * 86400000).toISOString(), photo_thumbnail: 'https://media.rightmove.co.uk/dir/property-photo/96fd65ad9/148681163/96fd65ad9dd5af28fe124c3663d30072_max_200x138.jpeg', photos: ["https://media.rightmove.co.uk/dir/property-photo/96fd65ad9/148681163/96fd65ad9dd5af28fe124c3663d30072_max_200x138.jpeg"], price_history: [{ id: 11, price: 2300.0, last_seen: new Date().toISOString() }], listing_type: 'RENT' as const, } },
{ type: 'Feature', geometry: { type: 'Point', coordinates: [-0.15262, 51.59511] }, properties: { id: 172605503, url: 'https://www.rightmove.co.uk/properties/172605503', city: 'London', country: 'United Kingdom', qm: 106, qmprice: 26, total_price: 2750.0, rooms: 2, agency: 'Foxtons', available_from: new Date(Date.now() + 84 * 86400000).toISOString(), last_seen: new Date(Date.now() - 12 * 86400000).toISOString(), photo_thumbnail: 'https://media.rightmove.co.uk/dir/property-photo/1eeb20c7d/172605503/1eeb20c7d5a4a68119b0ba0e32836422_max_200x138.jpeg', photos: ["https://media.rightmove.co.uk/dir/property-photo/1eeb20c7d/172605503/1eeb20c7d5a4a68119b0ba0e32836422_max_200x138.jpeg"], price_history: [{ id: 12, price: 2750.0, last_seen: new Date().toISOString() }], listing_type: 'RENT' as const, } },
{ type: 'Feature', geometry: { type: 'Point', coordinates: [0.0352, 51.53643] }, properties: { id: 160655912, url: 'https://www.rightmove.co.uk/properties/160655912', city: 'London', country: 'United Kingdom', qm: 109, qmprice: 18, total_price: 2000.0, rooms: 2, agency: 'Savills', available_from: new Date(Date.now() + 91 * 86400000).toISOString(), last_seen: new Date(Date.now() - 13 * 86400000).toISOString(), photo_thumbnail: 'https://media.rightmove.co.uk/dir/property-photo/b752ad4ae/160655912/b752ad4ae94226cab0eb1fd720216e8d_max_200x138.jpeg', photos: ["https://media.rightmove.co.uk/dir/property-photo/b752ad4ae/160655912/b752ad4ae94226cab0eb1fd720216e8d_max_200x138.jpeg"], price_history: [{ id: 13, price: 2000.0, last_seen: new Date().toISOString() }], listing_type: 'RENT' as const, } }
],
}
: null
);
const [user, setUser] = useState<AuthUser | null>(
DEV_BYPASS_AUTH
? { sub: 'dev-user', email: 'dev@localhost', name: 'Dev User', accessToken: 'dev-token', provider: 'passkey' as const }
: null
);
const [queryParameters, setQueryParameters] = useState<ParameterValues | null>(
DEV_BYPASS_AUTH ? { ...DEFAULT_FILTER_VALUES, available_from: new Date() } : null
);
const [submitError, setSubmitError] = useState<string | null>(null);
const [alertDialogIsOpen, setAlertDialogIsOpen] = useState(false);
const [isLoading, setIsLoading] = useState(false);
const [viewMode, setViewMode] = useState<ViewMode>('map');
const { viewMode, setViewMode } = useFilterParams();
const [mobileFilterOpen, setMobileFilterOpen] = useState(false);
const [highlightedProperty, setHighlightedProperty] = useState<string | null>(null);
const [streamingProgress, setStreamingProgress] = useState<StreamingProgress | null>(null);
@ -55,6 +89,7 @@ function App() {
} | null>(null);
const [poiTravelFilters, setPoiTravelFilters] = useState<Record<number, POITravelFilter>>({});
const [currentMetric, setCurrentMetric] = useState<Metric>(DEFAULT_FILTER_VALUES.metric);
const [listingType, setListingType] = useState<ListingType>(DEFAULT_FILTER_VALUES.listing_type);
const isMobile = useIsMobile();
const [, setActiveCardFeature] = useState<PropertyFeature | null>(null);
const [showReviewMode, setShowReviewMode] = useState(false);
@ -92,11 +127,6 @@ function App() {
// Ref to abort in-flight streaming requests
const abortControllerRef = useRef<AbortController | null>(null);
// Check if this is the callback route - render dedicated component
if (window.location.pathname === '/callback') {
return <AuthCallback />;
}
useEffect(() => {
// Check passkey user first, then fall back to OIDC
const passkeyUser = getStoredPasskeyUser();
@ -312,7 +342,7 @@ function App() {
// Auto-load data with default filters when user is authenticated
useEffect(() => {
if (!user || initialLoadTriggeredRef.current) {
if (!user || initialLoadTriggeredRef.current || DEV_BYPASS_AUTH) {
return;
}
initialLoadTriggeredRef.current = true;
@ -379,6 +409,31 @@ function App() {
// Optionally: pan map to coordinates
};
/** Handle removing a filter chip: reset the field to its default value and re-submit */
const handleRemoveChip = (key: keyof ParameterValues) => {
if (!queryParameters) return;
const updated = { ...queryParameters };
// For paired keys (price, beds) reset both ends
switch (key) {
case 'min_price':
case 'max_price':
updated.min_price = DEFAULT_FILTER_VALUES.min_price;
updated.max_price = DEFAULT_FILTER_VALUES.max_price;
break;
case 'min_bedrooms':
case 'max_bedrooms':
updated.min_bedrooms = DEFAULT_FILTER_VALUES.min_bedrooms;
updated.max_bedrooms = DEFAULT_FILTER_VALUES.max_bedrooms;
break;
case 'furnish_types':
updated.furnish_types = [];
break;
default:
(updated as Record<string, unknown>)[key] = (DEFAULT_FILTER_VALUES as Record<string, unknown>)[key];
}
loadListings(updated);
};
const renderMainContent = () => {
if (!processedListingData) {
return (
@ -389,7 +444,7 @@ function App() {
<div className="text-6xl mb-4 animate-pulse">🏠</div>
<h2 className="text-xl font-semibold mb-2">Loading Properties...</h2>
<p className="text-muted-foreground mb-4">
Fetching listings with default filters. You can adjust filters on the left.
Fetching listings with default filters. Adjust filters above to refine results.
</p>
</>
) : (
@ -397,7 +452,7 @@ function App() {
<div className="text-6xl mb-4">🏠</div>
<h2 className="text-xl font-semibold mb-2">Welcome to Property Explorer</h2>
<p className="text-muted-foreground mb-4">
Use the filters on the left to find properties. Apply filters to visualize existing data or refresh to fetch new listings.
Use the filters above to find properties. Apply filters to visualize existing data or refresh to fetch new listings.
</p>
</>
)}
@ -526,40 +581,46 @@ function App() {
>
<Heart className="h-6 w-6" />
</Button>
<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}
/>
<Dialog open={mobileFilterOpen} onOpenChange={setMobileFilterOpen}>
<Button size="lg" className="rounded-full shadow-lg h-14 w-14" onClick={() => setMobileFilterOpen(true)}>
<Filter className="h-6 w-6" />
</Button>
<DialogContent className="max-w-full h-full max-h-full rounded-none sm:max-w-full p-0">
<DialogHeader className="px-4 pt-4 pb-2">
<DialogTitle>Filters</DialogTitle>
</DialogHeader>
<ScrollArea className="flex-1 min-h-0 px-0">
<div className="h-full flex flex-col">
<div className="flex-1 min-h-0">
<FilterPanel
onSubmit={(action, params) => {
setMobileFilterOpen(false);
onSubmit(action, params);
}}
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 className="shrink-0 p-4">
<VisualizationCard
metric={currentMetric}
onMetricChange={handleMetricChange}
userPOIs={userPOIs}
onPoiMetricChange={setPoiMetricSelection}
/>
</div>
</div>
</SheetContent>
</Sheet>
</ScrollArea>
</DialogContent>
</Dialog>
</div>
{/* Bottom Sheet */}
@ -602,7 +663,7 @@ function App() {
return (
<div className="h-screen flex flex-col overflow-hidden">
{/* Header */}
{/* Header with Listing Type Toggle */}
<Header
user={user}
tasks={tasks}
@ -617,55 +678,60 @@ function App() {
return result;
}}
onTaskCompleted={handleTaskCompleted}
listingType={listingType}
onListingTypeChange={setListingType}
/>
{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>
</div>
/* Desktop layout: no sidebar, full-width main area */
<>
{/* Horizontal Filter Bar */}
<FilterBar
onSubmit={onSubmit}
isLoading={isLoading}
user={user}
userPOIs={userPOIs}
onPOIsChange={setUserPOIs}
poiTravelFilters={poiTravelFilters}
onPoiTravelFiltersChange={setPoiTravelFilters}
listingType={listingType}
onListingTypeChange={setListingType}
poiPickerActive={poiPickerActive}
onPoiPickerActiveChange={setPoiPickerActive}
pickedPoiLocation={pickedPoiLocation}
onPickedPoiLocationChange={setPickedPoiLocation}
currentMetric={currentMetric}
onTaskCreated={handlePOITaskCreated}
/>
{/* Active Filter Chips */}
{queryParameters && (
<FilterChips
values={queryParameters}
defaults={{ ...DEFAULT_FILTER_VALUES, available_from: new Date() }}
onRemove={handleRemoveChip}
/>
)}
{/* Streaming Progress Bar */}
<div className="relative shrink-0">
<StreamingProgressBar
progress={streamingProgress}
isLoading={isLoading}
onCancel={() => abortControllerRef.current?.abort()}
/>
</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} onCancel={() => abortControllerRef.current?.abort()} />
</div>
{/* Main content area (full width) */}
<main className="flex-1 flex flex-col min-h-0 min-w-0">
{/* Map/List Container */}
<div className="flex-1 flex overflow-hidden min-h-0">
{renderMainContent()}
</div>
{/* Stats Bar */}
{/* Stats Bar with Metric Selector */}
{processedListingData && processedListingData.features.length > 0 && (
<div className="shrink-0">
<StatsBar
@ -673,11 +739,15 @@ function App() {
viewMode={viewMode}
onViewModeChange={setViewMode}
likedCount={likedCount}
metric={currentMetric}
onMetricChange={handleMetricChange}
userPOIs={userPOIs}
onPoiMetricChange={setPoiMetricSelection}
/>
</div>
)}
</div>
</div>
</main>
</>
)}
{/* Swipe Review Mode Overlay */}
@ -709,4 +779,22 @@ function App() {
);
}
/** Top-level App component with React Router routes */
function App() {
return (
<Routes>
<Route path="/callback" element={<AuthCallback />} />
{/* All view modes share the same AppContent; viewMode is derived from pathname */}
<Route path="/map" element={<AppContent />} />
<Route path="/list" element={<AppContent />} />
<Route path="/split" element={<AppContent />} />
<Route path="/saved" element={<AppContent />} />
{/* Default: root maps to map view */}
<Route path="/" element={<AppContent />} />
{/* Catch-all: redirect unknown paths to root */}
<Route path="*" element={<Navigate to="/" replace />} />
</Routes>
);
}
export default App;

View file

@ -0,0 +1,44 @@
import { Component, type ErrorInfo, type ReactNode } from 'react';
interface Props {
children: ReactNode;
}
interface State {
hasError: boolean;
error: Error | null;
}
export class ErrorBoundary extends Component<Props, State> {
state: State = { hasError: false, error: null };
static getDerivedStateFromError(error: Error): State {
return { hasError: true, error };
}
componentDidCatch(error: Error, errorInfo: ErrorInfo) {
console.error('ErrorBoundary caught:', error, errorInfo);
}
render() {
if (this.state.hasError) {
return (
<div className="flex items-center justify-center min-h-screen bg-background">
<div className="text-center p-8 max-w-md">
<h1 className="text-xl font-bold mb-2 text-foreground">Something went wrong</h1>
<p className="text-muted-foreground mb-4 text-sm">
{this.state.error?.message ?? 'An unexpected error occurred.'}
</p>
<button
onClick={() => window.location.reload()}
className="px-4 py-2 bg-primary text-primary-foreground rounded-md text-sm font-medium hover:bg-primary/90 transition-colors"
>
Reload Page
</button>
</div>
</div>
);
}
return this.props.children;
}
}

View file

@ -0,0 +1,675 @@
import { useState, useEffect, useCallback } from 'react';
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { z } from 'zod';
import { ChevronDown, Loader2, RefreshCw, Search, MapPin, SlidersHorizontal } from 'lucide-react';
import { Button } from './ui/button';
import { Input } from './ui/input';
import { Popover, PopoverContent, PopoverTrigger } from './ui/popover';
import { ScrollArea } from './ui/scroll-area';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from './ui/select';
import { Form, FormControl, FormField, FormItem, FormLabel, FormDescription } from './ui/form';
import { Calendar29 } from './ui/DatePicker';
import { POIManager } from './POIManager';
import {
type ParameterValues,
DEFAULT_FILTER_VALUES,
ListingType,
FurnishType,
Metric,
} from './FilterPanel';
import type { AuthUser } from '@/auth/types';
import type { POI, POITravelFilter } from '@/types';
// ── Zod schema (same as FilterPanel) ──
const formSchema = z.object({
listing_type: z.nativeEnum(ListingType, { required_error: 'Listing Type is required' }),
min_bedrooms: z.number().min(0).max(10).optional(),
max_bedrooms: z.number().min(0).max(10).optional(),
max_price: z.number().optional(),
min_price: z.number().min(0).optional(),
min_sqm: z.number().optional(),
max_sqm: z.number().optional(),
min_price_per_sqm: z.number().optional(),
max_price_per_sqm: z.number().optional(),
last_seen_days: z.number().min(0).optional(),
available_from: z.date(),
district: z.string(),
furnish_types: z.array(z.nativeEnum(FurnishType)).optional(),
});
type FormValues = z.infer<typeof formSchema>;
// ── Props ──
interface FilterBarProps {
onSubmit: (action: 'fetch-data' | 'visualize', parameters: ParameterValues) => void;
isLoading: boolean;
user: AuthUser;
userPOIs: POI[];
onPOIsChange: (pois: POI[]) => void;
poiTravelFilters: Record<number, POITravelFilter>;
onPoiTravelFiltersChange: (filters: Record<number, POITravelFilter>) => void;
listingType: ListingType;
onListingTypeChange: (type: ListingType) => void;
poiPickerActive: boolean;
onPoiPickerActiveChange: (active: boolean) => void;
pickedPoiLocation: { lat: number; lng: number } | null;
onPickedPoiLocationChange: (loc: { lat: number; lng: number } | null) => void;
currentMetric: Metric;
onTaskCreated?: (taskId: string) => void;
}
// ── Helpers ──
function formatPrice(v: number): string {
if (v >= 1_000_000) return `\u00A3${(v / 1_000_000).toFixed(1)}M`;
if (v >= 1_000) return `\u00A3${(v / 1_000).toFixed(0)}k`;
return `\u00A3${v}`;
}
/** Read current ParameterValues from the form state (merges metric and furnish) */
function readFormParams(
values: FormValues,
metric: Metric,
selectedFurnishTypes: FurnishType[],
): ParameterValues {
return {
...values,
metric,
furnish_types: selectedFurnishTypes.length > 0 ? selectedFurnishTypes : undefined,
};
}
// ── FilterBar ──
export function FilterBar({
onSubmit,
isLoading,
user,
userPOIs,
poiTravelFilters,
onPoiTravelFiltersChange,
listingType,
onListingTypeChange,
onPoiPickerActiveChange,
pickedPoiLocation,
onPickedPoiLocationChange,
currentMetric,
onTaskCreated,
}: FilterBarProps) {
const [selectedFurnishTypes, setSelectedFurnishTypes] = useState<FurnishType[]>([]);
const [availableFromRawInput, setAvailableFromRawInput] = useState('now');
const form = useForm<FormValues>({
resolver: zodResolver(formSchema),
defaultValues: {
listing_type: DEFAULT_FILTER_VALUES.listing_type,
min_bedrooms: DEFAULT_FILTER_VALUES.min_bedrooms,
max_bedrooms: DEFAULT_FILTER_VALUES.max_bedrooms,
min_price: DEFAULT_FILTER_VALUES.min_price,
max_price: DEFAULT_FILTER_VALUES.max_price,
min_sqm: DEFAULT_FILTER_VALUES.min_sqm,
max_sqm: undefined,
min_price_per_sqm: undefined,
max_price_per_sqm: undefined,
last_seen_days: DEFAULT_FILTER_VALUES.last_seen_days,
available_from: new Date(),
district: '',
},
});
const watchedListingType = form.watch('listing_type');
// Sync listing type with parent
useEffect(() => {
if (watchedListingType !== listingType) {
onListingTypeChange(watchedListingType);
}
}, [watchedListingType, listingType, onListingTypeChange]);
// Sync parent listing type changes back into form
useEffect(() => {
if (listingType !== form.getValues('listing_type')) {
form.setValue('listing_type', listingType);
}
}, [listingType, form]);
// Price defaults when listing type changes
useEffect(() => {
if (watchedListingType === ListingType.BUY) {
form.setValue('min_price', 300000);
form.setValue('max_price', 600000);
} else {
form.setValue('min_price', 2000);
form.setValue('max_price', 3000);
}
if (watchedListingType === ListingType.BUY) {
setSelectedFurnishTypes([]);
}
}, [watchedListingType, form]);
const handleFormSubmit = useCallback(
(action: 'fetch-data' | 'visualize') => {
return form.handleSubmit((values) => {
onSubmit(action, readFormParams(values, currentMetric, selectedFurnishTypes));
})();
},
[form, onSubmit, currentMetric, selectedFurnishTypes],
);
const toggleFurnishType = (type: FurnishType) => {
setSelectedFurnishTypes((prev) =>
prev.includes(type) ? prev.filter((t) => t !== type) : [...prev, type],
);
};
// Watched values for trigger labels
const minPrice = form.watch('min_price');
const maxPrice = form.watch('max_price');
const minBeds = form.watch('min_bedrooms');
const maxBeds = form.watch('max_bedrooms');
const minSqm = form.watch('min_sqm');
// Price label
const priceLabel = (() => {
const lo = minPrice ?? 0;
const hi = maxPrice;
if (lo === 0 && !hi) return 'Price';
if (!hi) return `${formatPrice(lo)}+`;
return `${formatPrice(lo)} \u2013 ${formatPrice(hi)}`;
})();
// Beds label
const bedsLabel = (() => {
const lo = minBeds ?? 0;
const hi = maxBeds ?? 10;
if (lo === 0 && hi >= 10) return 'Beds';
if (hi >= 10) return `${lo}+`;
if (lo === hi) return `${lo} bed`;
return `${lo}-${hi}`;
})();
// Size label
const sizeLabel = minSqm && minSqm > 0 ? `${minSqm}+ m\u00B2` : 'Size';
// Check if "More Filters" has active values
const moreCount = (() => {
let c = 0;
const v = form.getValues();
if (v.max_sqm) c++;
if (v.min_price_per_sqm) c++;
if (v.max_price_per_sqm) c++;
if (selectedFurnishTypes.length > 0) c++;
if (v.district) c++;
if (v.last_seen_days !== undefined && v.last_seen_days !== DEFAULT_FILTER_VALUES.last_seen_days) c++;
return c;
})();
// Trigger button base class
const triggerCls =
'text-xs font-medium px-3 py-1.5 rounded-md border bg-background hover:bg-muted inline-flex items-center gap-1 whitespace-nowrap h-8';
return (
<Form {...form}>
<form
className="flex items-center gap-2 px-4 h-12 bg-muted/40 border-b shrink-0"
onSubmit={(e) => e.preventDefault()}
>
{/* ── Price Popover ── */}
<Popover>
<PopoverTrigger asChild>
<button type="button" className={triggerCls}>
{priceLabel}
<ChevronDown className="h-3 w-3 opacity-50" />
</button>
</PopoverTrigger>
<PopoverContent className="w-56 p-3 space-y-3" align="start">
<p className="text-xs font-medium mb-2">Price (GBP)</p>
<FormField
control={form.control}
name="min_price"
render={({ field }) => (
<FormItem>
<FormLabel className="text-xs">Min</FormLabel>
<FormControl>
<Input
type="number"
placeholder="0"
className="h-8 text-sm"
{...field}
onChange={(e) => field.onChange(e.target.value ? Number(e.target.value) : undefined)}
/>
</FormControl>
</FormItem>
)}
/>
<FormField
control={form.control}
name="max_price"
render={({ field }) => (
<FormItem>
<FormLabel className="text-xs">Max</FormLabel>
<FormControl>
<Input
type="number"
placeholder="Any"
className="h-8 text-sm"
{...field}
onChange={(e) => field.onChange(e.target.value ? Number(e.target.value) : undefined)}
/>
</FormControl>
</FormItem>
)}
/>
<Button
type="button"
size="sm"
className="w-full h-7 text-xs"
onClick={() => handleFormSubmit('visualize')}
>
Apply
</Button>
</PopoverContent>
</Popover>
{/* ── Beds Popover ── */}
<Popover>
<PopoverTrigger asChild>
<button type="button" className={triggerCls}>
{bedsLabel}
<ChevronDown className="h-3 w-3 opacity-50" />
</button>
</PopoverTrigger>
<PopoverContent className="w-52 p-3 space-y-3" align="start">
<p className="text-xs font-medium mb-2">Bedrooms</p>
<div className="grid grid-cols-2 gap-2">
<FormField
control={form.control}
name="min_bedrooms"
render={({ field }) => (
<FormItem>
<FormLabel className="text-xs">Min</FormLabel>
<FormControl>
<Input
type="number"
min={0}
max={10}
placeholder="0"
className="h-8 text-sm"
{...field}
onChange={(e) => field.onChange(e.target.value ? Number(e.target.value) : undefined)}
/>
</FormControl>
</FormItem>
)}
/>
<FormField
control={form.control}
name="max_bedrooms"
render={({ field }) => (
<FormItem>
<FormLabel className="text-xs">Max</FormLabel>
<FormControl>
<Input
type="number"
min={0}
max={10}
placeholder="10"
className="h-8 text-sm"
{...field}
onChange={(e) => field.onChange(e.target.value ? Number(e.target.value) : undefined)}
/>
</FormControl>
</FormItem>
)}
/>
</div>
<Button
type="button"
size="sm"
className="w-full h-7 text-xs"
onClick={() => handleFormSubmit('visualize')}
>
Apply
</Button>
</PopoverContent>
</Popover>
{/* ── Size Popover ── */}
<Popover>
<PopoverTrigger asChild>
<button type="button" className={triggerCls}>
{sizeLabel}
<ChevronDown className="h-3 w-3 opacity-50" />
</button>
</PopoverTrigger>
<PopoverContent className="w-48 p-3 space-y-3" align="start">
<p className="text-xs font-medium mb-2">Size (m&sup2;)</p>
<FormField
control={form.control}
name="min_sqm"
render={({ field }) => (
<FormItem>
<FormLabel className="text-xs">Min</FormLabel>
<FormControl>
<Input
type="number"
placeholder="0"
className="h-8 text-sm"
{...field}
onChange={(e) => field.onChange(e.target.value ? Number(e.target.value) : undefined)}
/>
</FormControl>
</FormItem>
)}
/>
<Button
type="button"
size="sm"
className="w-full h-7 text-xs"
onClick={() => handleFormSubmit('visualize')}
>
Apply
</Button>
</PopoverContent>
</Popover>
{/* ── More Filters Popover ── */}
<Popover>
<PopoverTrigger asChild>
<button type="button" className={triggerCls}>
<SlidersHorizontal className="h-3 w-3" />
More{moreCount > 0 && ` (${moreCount})`}
<ChevronDown className="h-3 w-3 opacity-50" />
</button>
</PopoverTrigger>
<PopoverContent className="w-[400px] p-0" align="start">
<ScrollArea className="max-h-[60vh]">
<div className="p-4 space-y-4">
<p className="text-sm font-semibold">Advanced Filters</p>
<div className="grid grid-cols-2 gap-3">
{/* Max Size */}
<FormField
control={form.control}
name="max_sqm"
render={({ field }) => (
<FormItem>
<FormLabel className="text-xs">Max Size (m&sup2;)</FormLabel>
<FormControl>
<Input
type="number"
placeholder="Any"
className="h-8 text-sm"
{...field}
onChange={(e) => field.onChange(e.target.value ? Number(e.target.value) : undefined)}
/>
</FormControl>
</FormItem>
)}
/>
{/* Last Seen Days */}
<FormField
control={form.control}
name="last_seen_days"
render={({ field }) => (
<FormItem>
<FormLabel className="text-xs">Last Seen (days)</FormLabel>
<FormControl>
<Input
type="number"
placeholder="28"
className="h-8 text-sm"
{...field}
onChange={(e) => field.onChange(e.target.value ? Number(e.target.value) : undefined)}
/>
</FormControl>
</FormItem>
)}
/>
{/* Price per sqm min */}
<FormField
control={form.control}
name="min_price_per_sqm"
render={({ field }) => (
<FormItem>
<FormLabel className="text-xs">Min &pound;/m&sup2;</FormLabel>
<FormControl>
<Input
type="number"
placeholder="0"
className="h-8 text-sm"
{...field}
onChange={(e) => field.onChange(e.target.value ? Number(e.target.value) : undefined)}
/>
</FormControl>
</FormItem>
)}
/>
{/* Price per sqm max */}
<FormField
control={form.control}
name="max_price_per_sqm"
render={({ field }) => (
<FormItem>
<FormLabel className="text-xs">Max &pound;/m&sup2;</FormLabel>
<FormControl>
<Input
type="number"
placeholder="Any"
className="h-8 text-sm"
{...field}
onChange={(e) => field.onChange(e.target.value ? Number(e.target.value) : undefined)}
/>
</FormControl>
</FormItem>
)}
/>
</div>
{/* Furnishing (rent only) */}
{watchedListingType === ListingType.RENT && (
<div>
<FormLabel className="text-xs">Furnishing</FormLabel>
<div className="flex flex-wrap gap-2 mt-2">
{[
{ value: FurnishType.FURNISHED, label: 'Furnished' },
{ value: FurnishType.PART_FURNISHED, label: 'Part' },
{ value: FurnishType.UNFURNISHED, label: 'Unfurn.' },
].map((option) => (
<button
key={option.value}
type="button"
onClick={() => toggleFurnishType(option.value)}
className={`px-2 py-1 text-xs rounded-md border transition-colors ${
selectedFurnishTypes.includes(option.value)
? 'bg-primary text-primary-foreground border-primary'
: 'bg-background hover:bg-muted border-input'
}`}
>
{option.label}
</button>
))}
</div>
</div>
)}
{/* District */}
<FormField
control={form.control}
name="district"
render={({ field }) => (
<FormItem>
<FormLabel className="text-xs">District</FormLabel>
<FormControl>
<Input
type="text"
placeholder="e.g., Westminster, Camden"
className="h-8 text-sm"
{...field}
/>
</FormControl>
<FormDescription className="text-xs">
Comma-separated list of districts
</FormDescription>
</FormItem>
)}
/>
{/* Available From (rent only) */}
{watchedListingType === ListingType.RENT && (
<FormField
control={form.control}
name="available_from"
render={({ field }) => (
<FormItem>
<FormLabel className="text-xs">Available From</FormLabel>
<FormControl>
<Calendar29
onSelect={field.onChange}
selected={field.value}
rawInputValue={availableFromRawInput}
onChangeRawInputValue={setAvailableFromRawInput}
/>
</FormControl>
</FormItem>
)}
/>
)}
{/* POI section */}
{user && userPOIs.length > 0 && (
<div className="pt-2 border-t">
<p className="text-xs font-medium flex items-center gap-1.5 mb-2">
<MapPin className="h-3.5 w-3.5" />
Points of Interest
</p>
<div className="space-y-2">
{userPOIs.map((poi) => {
const filter = poiTravelFilters?.[poi.id];
const travelMode = filter?.travelMode ?? 'WALK';
const maxMinutes = filter?.maxMinutes;
return (
<div key={poi.id} className="flex items-center gap-1.5">
<span className="text-xs truncate w-16 shrink-0" title={poi.name}>
{poi.name}
</span>
<Select
value={travelMode}
onValueChange={(value) => {
onPoiTravelFiltersChange({
...poiTravelFilters,
[poi.id]: {
travelMode: value as 'WALK' | 'BICYCLE' | 'TRANSIT',
maxMinutes,
},
});
}}
>
<SelectTrigger className="h-7 text-xs w-[88px] shrink-0">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="WALK">Walk</SelectItem>
<SelectItem value="BICYCLE">Bicycle</SelectItem>
<SelectItem value="TRANSIT">Transit</SelectItem>
</SelectContent>
</Select>
<Input
type="number"
placeholder="\u2014"
className="h-7 text-xs w-14 shrink-0"
min={1}
value={maxMinutes ?? ''}
onChange={(e) => {
onPoiTravelFiltersChange({
...poiTravelFilters,
[poi.id]: {
travelMode,
maxMinutes: e.target.value ? Number(e.target.value) : undefined,
},
});
}}
/>
<span className="text-xs text-muted-foreground shrink-0">min</span>
</div>
);
})}
</div>
</div>
)}
{/* POI Manager (authenticated users) */}
{user && (
<div className={userPOIs.length === 0 ? 'pt-2 border-t' : ''}>
{userPOIs.length === 0 && (
<p className="text-xs font-medium flex items-center gap-1.5 mb-2">
<MapPin className="h-3.5 w-3.5" />
Points of Interest
</p>
)}
<POIManager
user={user}
listingType={watchedListingType as 'RENT' | 'BUY'}
onTaskCreated={onTaskCreated}
onStartPicking={() => {
onPoiPickerActiveChange(true);
onPickedPoiLocationChange(null);
}}
pickedLocation={pickedPoiLocation}
/>
</div>
)}
{/* Apply button inside More Filters */}
<Button
type="button"
size="sm"
className="w-full h-8 text-xs"
onClick={() => handleFormSubmit('visualize')}
>
Apply Filters
</Button>
</div>
</ScrollArea>
</PopoverContent>
</Popover>
{/* ── Spacer ── */}
<div className="flex-1" />
{/* ── Action Buttons (right side) ── */}
<Button
type="button"
size="sm"
className="h-8 bg-teal-600 hover:bg-teal-700 text-white text-xs gap-1.5"
onClick={() => handleFormSubmit('visualize')}
disabled={isLoading}
>
<Search className="h-3.5 w-3.5" />
Show Listings
</Button>
<Button
type="button"
variant="outline"
size="sm"
className="h-8 text-xs gap-1.5"
onClick={() => handleFormSubmit('fetch-data')}
disabled={isLoading}
>
{isLoading ? (
<Loader2 className="h-3.5 w-3.5 animate-spin" />
) : (
<RefreshCw className="h-3.5 w-3.5" />
)}
Scrape New
</Button>
</form>
</Form>
);
}
// Re-export getValues helper type for external access
export type { FilterBarProps };

View file

@ -0,0 +1,126 @@
import { X } from 'lucide-react';
import type { ParameterValues } from './FilterPanel';
import { FurnishType } from './FilterPanel';
interface FilterChipsProps {
values: ParameterValues;
defaults: ParameterValues;
onRemove: (key: keyof ParameterValues) => void;
}
/** Format a price value for display */
function fmtPrice(v: number): string {
if (v >= 1_000_000) return `\u00A3${(v / 1_000_000).toFixed(1)}M`;
if (v >= 1_000) return `\u00A3${(v / 1_000).toFixed(0)}k`;
return `\u00A3${v}`;
}
/** Label for a furnish type enum value */
function furnishLabel(ft: FurnishType): string {
switch (ft) {
case FurnishType.FURNISHED: return 'Furnished';
case FurnishType.PART_FURNISHED: return 'Part Furnished';
case FurnishType.UNFURNISHED: return 'Unfurnished';
default: return String(ft);
}
}
type ChipDef = { key: keyof ParameterValues; label: string };
function buildChips(values: ParameterValues, defaults: ParameterValues): ChipDef[] {
const chips: ChipDef[] = [];
// Price range
const priceChanged =
(values.min_price !== undefined && values.min_price !== defaults.min_price) ||
(values.max_price !== undefined && values.max_price !== defaults.max_price);
if (priceChanged) {
const lo = values.min_price ?? 0;
const hi = values.max_price;
chips.push({
key: 'min_price',
label: hi ? `${fmtPrice(lo)} \u2013 ${fmtPrice(hi)}` : `${fmtPrice(lo)}+`,
});
}
// Bedrooms
const bedsChanged =
(values.min_bedrooms !== undefined && values.min_bedrooms !== defaults.min_bedrooms) ||
(values.max_bedrooms !== undefined && values.max_bedrooms !== defaults.max_bedrooms);
if (bedsChanged) {
const lo = values.min_bedrooms ?? 0;
const hi = values.max_bedrooms ?? 10;
if (hi >= 10) {
chips.push({ key: 'min_bedrooms', label: `${lo}+ beds` });
} else if (lo === hi) {
chips.push({ key: 'min_bedrooms', label: `${lo} beds` });
} else {
chips.push({ key: 'min_bedrooms', label: `${lo}-${hi} beds` });
}
}
// Min size
if (values.min_sqm !== undefined && values.min_sqm !== defaults.min_sqm) {
chips.push({ key: 'min_sqm', label: `${values.min_sqm}+ m\u00B2` });
}
// Max size
if (values.max_sqm !== undefined && values.max_sqm !== defaults.max_sqm) {
chips.push({ key: 'max_sqm', label: `\u2264${values.max_sqm} m\u00B2` });
}
// Price per sqm
if (values.min_price_per_sqm !== undefined && values.min_price_per_sqm !== defaults.min_price_per_sqm) {
chips.push({ key: 'min_price_per_sqm', label: `\u2265\u00A3${values.min_price_per_sqm}/m\u00B2` });
}
if (values.max_price_per_sqm !== undefined && values.max_price_per_sqm !== defaults.max_price_per_sqm) {
chips.push({ key: 'max_price_per_sqm', label: `\u2264\u00A3${values.max_price_per_sqm}/m\u00B2` });
}
// District
if (values.district && values.district !== defaults.district) {
chips.push({ key: 'district', label: values.district });
}
// Furnishing
if (values.furnish_types && values.furnish_types.length > 0) {
chips.push({
key: 'furnish_types',
label: values.furnish_types.map(furnishLabel).join(', '),
});
}
// Last seen days
if (values.last_seen_days !== undefined && values.last_seen_days !== defaults.last_seen_days) {
chips.push({ key: 'last_seen_days', label: `Last ${values.last_seen_days}d` });
}
return chips;
}
export function FilterChips({ values, defaults, onRemove }: FilterChipsProps) {
const chips = buildChips(values, defaults);
if (chips.length === 0) return null;
return (
<div className="flex flex-wrap gap-1.5 px-4 py-1.5 border-b bg-muted/20">
{chips.map((chip) => (
<span
key={chip.key}
className="inline-flex items-center gap-1 px-2 py-0.5 rounded-full bg-primary/10 text-primary text-xs font-medium"
>
{chip.label}
<button
type="button"
onClick={() => onRemove(chip.key)}
className="hover:bg-primary/20 rounded-full p-0.5 transition-colors"
aria-label={`Remove ${chip.label} filter`}
>
<X className="h-3 w-3" />
</button>
</span>
))}
</div>
);
}

View file

@ -2,6 +2,7 @@ import type { AuthUser } from '@/auth/types';
import type { TaskState } from '@/types';
import { Button } from './ui/button';
import { Separator } from './ui/separator';
import { Tabs, TabsList, TabsTrigger } from './ui/tabs';
import { LogOut, Home } from 'lucide-react';
import { logout } from '@/auth/authService';
import { clearPasskeyUser } from '@/auth/passkeyService';
@ -9,6 +10,7 @@ import { HealthIndicator } from './HealthIndicator';
import { TaskIndicator } from './TaskIndicator';
import { MobileMenu } from './MobileMenu';
import { useIsMobile } from '@/hooks/use-mobile';
import { ListingType } from './FilterPanel';
interface HeaderProps {
user: AuthUser;
@ -23,6 +25,9 @@ interface HeaderProps {
onCancelTask: (taskId: string) => Promise<boolean>;
onClearAllTasks: () => Promise<boolean>;
onTaskCompleted?: () => void;
// Listing type toggle
listingType?: ListingType;
onListingTypeChange?: (type: ListingType) => void;
}
export function Header({
@ -33,6 +38,8 @@ export function Header({
onCancelTask,
onClearAllTasks,
onTaskCompleted,
listingType,
onListingTypeChange,
}: HeaderProps) {
const isMobile = useIsMobile();
@ -53,6 +60,26 @@ export function Header({
<span className="font-semibold text-lg hidden sm:inline">Wrongmove</span>
</div>
{/* Listing Type Toggle (Rent / Buy) */}
{listingType && onListingTypeChange && (
<>
<Separator orientation="vertical" className="h-6" />
<Tabs
value={listingType}
onValueChange={(v) => onListingTypeChange(v as ListingType)}
>
<TabsList className="h-8 w-auto p-0.5">
<TabsTrigger value={ListingType.RENT} className="h-7 px-3 text-xs flex-initial">
Rent
</TabsTrigger>
<TabsTrigger value={ListingType.BUY} className="h-7 px-3 text-xs flex-initial">
Buy
</TabsTrigger>
</TabsList>
</Tabs>
</>
)}
{/* Desktop-only items */}
{!isMobile && (
<>

View file

@ -1,13 +1,9 @@
import { ExternalLink, Heart, X, Bed, Maximize2, PoundSterling, Building, Clock, MapPin, Footprints, Bike, Train } from 'lucide-react';
import { Button } from './ui/button';
import { Tabs, TabsList, TabsTrigger, TabsContent } from './ui/tabs';
import { PhotoCarousel } from './PhotoCarousel';
import type { ListingDetailData, DecisionType, POIDistanceInfo } from '@/types';
function formatDate(value: string): string {
const date = new Date(value);
if (isNaN(date.getTime())) return value;
return date.toLocaleDateString('en-GB', { day: 'numeric', month: 'short', year: 'numeric' });
}
import { formatDate, formatDuration } from '@/utils/format';
interface ListingDetailProps {
detail: ListingDetailData;
@ -15,20 +11,12 @@ interface ListingDetailProps {
onClearDecision: () => void;
}
function formatDuration(seconds: number): string {
const minutes = Math.round(seconds / 60);
if (minutes < 60) return `${minutes}m`;
const hours = Math.floor(minutes / 60);
const mins = minutes % 60;
return mins > 0 ? `${hours}h${mins}m` : `${hours}h`;
}
function TravelModeIcon({ mode }: { mode: string }) {
function TravelModeLabel({ mode }: { mode: string }) {
switch (mode) {
case 'WALK': return <Footprints className="h-3 w-3" />;
case 'BICYCLE': return <Bike className="h-3 w-3" />;
case 'TRANSIT': return <Train className="h-3 w-3" />;
default: return null;
case 'WALK': return 'Walk';
case 'BICYCLE': return 'Cycle';
case 'TRANSIT': return 'Transit';
default: return mode;
}
}
@ -38,22 +26,60 @@ export function ListingDetail({ detail, onDecide, onClearDecision }: ListingDeta
...detail.floorplans.map(fp => ({ url: fp.url, caption: fp.caption || 'Floorplan', type: 'FLOORPLAN' as string | null })),
];
const pricePerSqm = detail.square_meters ? Math.round(detail.price / detail.square_meters) : null;
// Group POI distances by POI name for the travel table
const poiGroups = new Map<string, Map<string, POIDistanceInfo>>();
for (const d of detail.poi_distances) {
if (!poiGroups.has(d.poi_name)) {
poiGroups.set(d.poi_name, new Map());
}
poiGroups.get(d.poi_name)!.set(d.travel_mode, d);
}
// Check which tabs have content
const hasOverview = detail.key_features.length > 0 || !!detail.description;
const hasTravel = detail.poi_distances.length > 0;
const hasPriceHistory = detail.price_history.length > 1;
const hasDetails = !!(detail.property_sub_type || detail.furnish_type || detail.council_tax_band || detail.available_from || detail.service_charge != null || detail.lease_left != null);
return (
<div className="pb-8">
{/* Photo carousel */}
{/* Photo carousel - always visible above tabs */}
<PhotoCarousel photos={allPhotos} />
<div className="px-4 pt-4 space-y-4">
{/* Price + address */}
{/* Price header */}
<div>
<div className="flex items-start justify-between">
<div>
<div className="text-2xl font-bold">
<div className="text-2xl font-bold tracking-tight">
£{detail.price.toLocaleString()}
{detail.listing_type !== 'BUY' && (
<span className="text-muted-foreground font-normal text-base">/mo</span>
)}
</div>
{/* Key metrics */}
<div className="flex items-center gap-1.5 text-sm text-muted-foreground mt-1">
<span className="flex items-center gap-1">
<Bed className="h-3.5 w-3.5" />
{detail.number_of_bedrooms} bed
</span>
<span>·</span>
<span className="flex items-center gap-1">
<Maximize2 className="h-3.5 w-3.5" />
{detail.square_meters ?? '\u2014'} m²
</span>
{pricePerSqm && (
<>
<span>·</span>
<span className="flex items-center gap-1">
<PoundSterling className="h-3.5 w-3.5" />
£{pricePerSqm}/m²
</span>
</>
)}
</div>
{detail.display_address && (
<div className="flex items-center gap-1 text-sm text-muted-foreground mt-1">
<MapPin className="h-3.5 w-3.5" />
@ -64,8 +90,8 @@ export function ListingDetail({ detail, onDecide, onClearDecision }: ListingDeta
</div>
</div>
{/* Like/Dislike buttons */}
<div className="flex gap-3">
{/* Action buttons */}
<div className="flex gap-2">
<Button
variant={detail.decision === 'disliked' ? 'destructive' : 'outline'}
className="flex-1"
@ -82,145 +108,170 @@ export function ListingDetail({ detail, onDecide, onClearDecision }: ListingDeta
<Heart className={`h-4 w-4 mr-2 ${detail.decision === 'liked' ? 'fill-current' : ''}`} />
{detail.decision === 'liked' ? 'Liked' : 'Like'}
</Button>
<Button asChild variant="outline" size="icon" className="shrink-0">
<a href={detail.url} target="_blank" rel="noopener noreferrer" title="View on Rightmove">
<ExternalLink className="h-4 w-4" />
</a>
</Button>
</div>
{/* Key stats */}
<div className="grid grid-cols-3 gap-3">
<div className="flex items-center gap-2 text-sm">
<Bed className="h-4 w-4 text-muted-foreground" />
<span><strong>{detail.number_of_bedrooms}</strong> beds</span>
</div>
<div className="flex items-center gap-2 text-sm">
<Maximize2 className="h-4 w-4 text-muted-foreground" />
<span><strong>{detail.square_meters ?? '\u2014'}</strong> m²</span>
</div>
<div className="flex items-center gap-2 text-sm">
<PoundSterling className="h-4 w-4 text-muted-foreground" />
<span><strong>{detail.square_meters ? `£${Math.round(detail.price / detail.square_meters)}` : '\u2014'}</strong>/m²</span>
</div>
</div>
{/* Tabbed sections */}
<Tabs defaultValue="overview">
<TabsList>
<TabsTrigger value="overview">Overview</TabsTrigger>
{hasTravel && <TabsTrigger value="travel">Travel</TabsTrigger>}
{hasPriceHistory && <TabsTrigger value="price-history">Price</TabsTrigger>}
{hasDetails && <TabsTrigger value="details">Details</TabsTrigger>}
</TabsList>
{/* Key features */}
{detail.key_features.length > 0 && (
<div>
<h3 className="text-sm font-semibold mb-2">Key Features</h3>
<ul className="list-disc list-inside text-sm text-muted-foreground space-y-1">
{detail.key_features.map((f, i) => (
<li key={i}>{f}</li>
))}
</ul>
</div>
)}
{/* Overview tab */}
<TabsContent value="overview" className="space-y-4">
{detail.key_features.length > 0 && (
<div>
<h3 className="text-sm font-semibold mb-2">Key Features</h3>
<ul className="list-disc list-inside text-sm text-muted-foreground space-y-1">
{detail.key_features.map((f, i) => (
<li key={i}>{f}</li>
))}
</ul>
</div>
)}
{/* Description */}
{detail.description && (
<div>
<h3 className="text-sm font-semibold mb-2">Description</h3>
<p className="text-sm text-muted-foreground whitespace-pre-line">{detail.description}</p>
</div>
)}
{detail.description && (
<div>
<h3 className="text-sm font-semibold mb-2">Description</h3>
<p className="text-sm text-muted-foreground whitespace-pre-line">{detail.description}</p>
</div>
)}
{/* Property details grid */}
<div>
<h3 className="text-sm font-semibold mb-2">Details</h3>
<div className="grid grid-cols-2 gap-2 text-sm">
{detail.property_sub_type && (
<div className="flex items-center gap-2">
<Building className="h-4 w-4 text-muted-foreground" />
<span>{detail.property_sub_type}</span>
</div>
)}
{detail.furnish_type && (
<div className="flex items-center gap-2">
<span className="text-muted-foreground">Furnishing:</span>
<span>{detail.furnish_type}</span>
</div>
)}
{detail.council_tax_band && (
<div className="flex items-center gap-2">
<span className="text-muted-foreground">Council Tax:</span>
<span>Band {detail.council_tax_band}</span>
</div>
)}
{detail.available_from && (
<div className="flex items-center gap-2">
<Clock className="h-4 w-4 text-muted-foreground" />
<span>Available {formatDate(detail.available_from)}</span>
</div>
)}
{detail.service_charge != null && (
<div className="flex items-center gap-2">
<span className="text-muted-foreground">Service charge:</span>
<span>£{detail.service_charge.toLocaleString()}</span>
</div>
)}
{detail.lease_left != null && (
<div className="flex items-center gap-2">
<span className="text-muted-foreground">Lease:</span>
<span>{detail.lease_left} years</span>
</div>
)}
</div>
</div>
{/* Floorplans */}
{detail.floorplans.length > 0 && (
<div>
<h3 className="text-sm font-semibold mb-2">Floorplans</h3>
<div className="space-y-2">
{detail.floorplans.map((fp, i) => (
<img key={i} src={fp.url} alt={fp.caption || 'Floorplan'} className="w-full rounded-md border" />
))}
</div>
</div>
)}
{/* Price history */}
{detail.price_history.length > 1 && (
<div>
<h3 className="text-sm font-semibold mb-2">Price History</h3>
<div className="space-y-1">
{detail.price_history.map((entry) => (
<div key={entry.id} className="text-sm flex justify-between">
<span className="text-muted-foreground">{entry.last_seen.split('T')[0]}</span>
<span>£{entry.price.toLocaleString()}</span>
{/* Floorplans */}
{detail.floorplans.length > 0 && (
<div>
<h3 className="text-sm font-semibold mb-2">Floorplans</h3>
<div className="space-y-2">
{detail.floorplans.map((fp, i) => (
<img key={i} src={fp.url} alt={fp.caption || 'Floorplan'} className="w-full rounded-md border" />
))}
</div>
))}
</div>
</div>
)}
</div>
)}
{/* POI distances */}
{detail.poi_distances.length > 0 && (
<div>
<h3 className="text-sm font-semibold mb-2">Travel Times</h3>
<div className="flex flex-wrap gap-1.5">
{detail.poi_distances.map((d: POIDistanceInfo) => (
<div key={`${d.poi_id}_${d.travel_mode}`} className="flex items-center gap-1 text-xs text-muted-foreground bg-muted/50 px-1.5 py-0.5 rounded">
<span className="font-medium">{d.poi_name}:</span>
<TravelModeIcon mode={d.travel_mode} />
{formatDuration(d.duration_seconds)}
</div>
))}
</div>
</div>
)}
{/* Agency */}
{detail.agency && (
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<Building className="h-4 w-4" />
<span>{detail.agency}</span>
</div>
)}
{/* Agency */}
{detail.agency && (
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<Building className="h-4 w-4" />
<span>{detail.agency}</span>
</div>
)}
{!hasOverview && !detail.floorplans.length && !detail.agency && (
<p className="text-sm text-muted-foreground">No overview information available.</p>
)}
</TabsContent>
{/* External link */}
<Button asChild variant="outline" className="w-full">
<a href={detail.url} target="_blank" rel="noopener noreferrer">
View on Rightmove
<ExternalLink className="ml-2 h-4 w-4" />
</a>
</Button>
{/* Travel tab */}
{hasTravel && (
<TabsContent value="travel">
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b">
<th className="text-left py-2 pr-4 font-medium text-muted-foreground">Destination</th>
<th className="text-center py-2 px-2 font-medium text-muted-foreground">
<span className="inline-flex items-center gap-1"><Footprints className="h-3 w-3" /> <TravelModeLabel mode="WALK" /></span>
</th>
<th className="text-center py-2 px-2 font-medium text-muted-foreground">
<span className="inline-flex items-center gap-1"><Bike className="h-3 w-3" /> <TravelModeLabel mode="BICYCLE" /></span>
</th>
<th className="text-center py-2 px-2 font-medium text-muted-foreground">
<span className="inline-flex items-center gap-1"><Train className="h-3 w-3" /> <TravelModeLabel mode="TRANSIT" /></span>
</th>
</tr>
</thead>
<tbody>
{Array.from(poiGroups.entries()).map(([poiName, modes]) => (
<tr key={poiName} className="border-b last:border-0">
<td className="py-2 pr-4 font-medium">{poiName}</td>
{(['WALK', 'BICYCLE', 'TRANSIT'] as const).map(mode => {
const d = modes.get(mode);
return (
<td key={mode} className="py-2 px-2 text-center text-muted-foreground">
{d ? formatDuration(d.duration_seconds) : '\u2014'}
</td>
);
})}
</tr>
))}
</tbody>
</table>
</div>
</TabsContent>
)}
{/* Price History tab */}
{hasPriceHistory && (
<TabsContent value="price-history">
<div className="space-y-2">
{detail.price_history.map((entry) => (
<div key={entry.id} className="flex justify-between items-center text-sm py-1.5 border-b last:border-0">
<span className="text-muted-foreground">{entry.last_seen.split('T')[0]}</span>
<span className="font-medium">£{entry.price.toLocaleString()}</span>
</div>
))}
</div>
</TabsContent>
)}
{/* Details tab */}
{hasDetails && (
<TabsContent value="details">
<div className="grid grid-cols-2 gap-3 text-sm">
{detail.property_sub_type && (
<div className="space-y-0.5">
<div className="text-xs text-muted-foreground">Property Type</div>
<div className="font-medium flex items-center gap-1.5">
<Building className="h-3.5 w-3.5 text-muted-foreground" />
{detail.property_sub_type}
</div>
</div>
)}
{detail.furnish_type && (
<div className="space-y-0.5">
<div className="text-xs text-muted-foreground">Furnishing</div>
<div className="font-medium">{detail.furnish_type}</div>
</div>
)}
{detail.council_tax_band && (
<div className="space-y-0.5">
<div className="text-xs text-muted-foreground">Council Tax</div>
<div className="font-medium">Band {detail.council_tax_band}</div>
</div>
)}
{detail.lease_left != null && (
<div className="space-y-0.5">
<div className="text-xs text-muted-foreground">Lease Remaining</div>
<div className="font-medium">{detail.lease_left} years</div>
</div>
)}
{detail.service_charge != null && (
<div className="space-y-0.5">
<div className="text-xs text-muted-foreground">Service Charge</div>
<div className="font-medium">£{detail.service_charge.toLocaleString()}</div>
</div>
)}
{detail.available_from && (
<div className="space-y-0.5">
<div className="text-xs text-muted-foreground">Available From</div>
<div className="font-medium flex items-center gap-1.5">
<Clock className="h-3.5 w-3.5 text-muted-foreground" />
{formatDate(detail.available_from)}
</div>
</div>
)}
</div>
</TabsContent>
)}
</Tabs>
</div>
</div>
);

View file

@ -45,7 +45,7 @@ export function ListingDetailSheet({
>
<Drawer.Portal>
<Drawer.Overlay className="fixed inset-0 bg-black/40 z-50" />
<Drawer.Content className="fixed bottom-0 left-0 right-0 z-50 flex flex-col bg-background rounded-t-xl max-h-[90vh]">
<Drawer.Content className="fixed bottom-0 left-0 right-0 z-50 flex flex-col bg-background rounded-t-xl max-h-[90vh] sm:!max-w-2xl sm:mx-auto">
<Drawer.Title className="sr-only">Listing Details</Drawer.Title>
<div className="mx-auto w-12 h-1.5 flex-shrink-0 rounded-full bg-muted-foreground/20 my-3" />
<div className="overflow-y-auto flex-1">

View file

@ -4,6 +4,7 @@ import { MapPin, PoundSterling } from 'lucide-react';
import { SwipeableCardRow } from './SwipeableCardRow';
import { ListView } from './ListView';
import type { GeoJSONFeatureCollection, PropertyProperties, PropertyFeature } from '@/types';
import { formatCurrency } from '@/utils/format';
interface MobileBottomSheetProps {
listingData: GeoJSONFeatureCollection | null;
@ -14,11 +15,6 @@ interface MobileBottomSheetProps {
onSnapChange?: (snap: string | number | null) => void;
}
function formatCurrency(value: number): string {
if (value >= 1000) return `£${(value / 1000).toFixed(1)}k`;
return `£${Math.round(value)}`;
}
export function MobileBottomSheet({
listingData,
onPropertyClick,

View file

@ -1,22 +1,8 @@
import { useState, useCallback, useEffect } from 'react';
import useEmblaCarousel from 'embla-carousel-react';
import { ExternalLink, Bed, Maximize2, PoundSterling, Clock, Building, Footprints, Bike, Train } from 'lucide-react';
import { Button } from './ui/button';
import { ExternalLink, Footprints, Bike, Train } from 'lucide-react';
import type { PropertyProperties, POIDistanceInfo, POI } from '@/types';
function formatDate(value: string): string {
const date = new Date(value);
if (isNaN(date.getTime())) return value;
return date.toLocaleDateString('en-GB', { day: 'numeric', month: 'short', year: 'numeric' });
}
function formatDuration(seconds: number): string {
const minutes = Math.round(seconds / 60);
if (minutes < 60) return `${minutes}m`;
const hours = Math.floor(minutes / 60);
const mins = minutes % 60;
return mins > 0 ? `${hours}h${mins}m` : `${hours}h`;
}
import { formatDuration } from '@/utils/format';
function TravelModeIcon({ mode }: { mode: string }) {
switch (mode) {
@ -39,12 +25,12 @@ function POIDistanceBadges({ distances }: { distances: POIDistanceInfo[] }) {
}
return (
<div className="flex flex-wrap gap-1.5 mt-1.5">
<div className="flex flex-wrap gap-1 mt-1.5">
{Array.from(byPoi.entries()).map(([poiName, dists]) => (
<div key={poiName} className="flex items-center gap-1 text-xs text-muted-foreground bg-muted/50 px-1.5 py-0.5 rounded">
<div key={poiName} className="inline-flex items-center gap-1 text-xs text-muted-foreground">
<span className="font-medium">{poiName}:</span>
{dists.map(d => (
<span key={d.travel_mode} className="flex items-center gap-0.5" title={`${d.travel_mode} to ${poiName}`}>
<span key={d.travel_mode} className="inline-flex items-center gap-0.5" title={`${d.travel_mode} to ${poiName}`}>
<TravelModeIcon mode={d.travel_mode} />
{formatDuration(d.duration_seconds)}
</span>
@ -67,16 +53,16 @@ function AllPOIDistances({ pois, distances }: { pois: POI[]; distances?: POIDist
}
return (
<div className="flex flex-wrap gap-1.5 mt-1.5">
<div className="flex flex-wrap gap-1 mt-1.5">
{pois.map(poi => (
<div key={poi.id} className="flex items-center gap-1 text-xs text-muted-foreground bg-muted/50 px-1.5 py-0.5 rounded">
<div key={poi.id} className="inline-flex items-center gap-1 text-xs text-muted-foreground">
<span className="font-medium">{poi.name}:</span>
{TRAVEL_MODES.map(mode => {
const dist = distMap.get(`${poi.id}_${mode}`);
return (
<span key={mode} className="flex items-center gap-0.5" title={`${mode} to ${poi.name}`}>
<span key={mode} className="inline-flex items-center gap-0.5" title={`${mode} to ${poi.name}`}>
<TravelModeIcon mode={mode} />
{dist ? formatDuration(dist.duration_seconds) : ''}
{dist ? formatDuration(dist.duration_seconds) : '\u2014'}
</span>
);
})}
@ -167,9 +153,9 @@ export function PropertyCard({
const isExpensive = avgPricePerSqm && property.qmprice > avgPricePerSqm * 1.1;
const priceIndicator = isGoodDeal
? { color: 'text-green-600 bg-green-50', label: 'Good deal' }
? { dotColor: 'bg-[var(--deal-good)]', label: 'Good deal' }
: isExpensive
? { color: 'text-red-600 bg-red-50', label: 'Above avg' }
? { dotColor: 'bg-[var(--deal-above)]', label: 'Above avg' }
: null;
const handleClick = () => {
@ -179,7 +165,7 @@ export function PropertyCard({
if (variant === 'compact') {
return (
<div
className={`flex gap-3 p-3 rounded-lg border transition-colors cursor-pointer hover:bg-muted/50 ${
className={`flex gap-3 p-3 rounded-lg border bg-card shadow-sm hover:shadow-md transition-shadow cursor-pointer ${
isHighlighted ? 'ring-2 ring-primary bg-primary/5' : ''
}`}
onClick={handleClick}
@ -196,158 +182,121 @@ export function PropertyCard({
{/* Details */}
<div className="flex-1 min-w-0">
<div className="flex items-start justify-between gap-2">
<div className="font-semibold text-base truncate">
{/* Price */}
<div className="flex items-center gap-1.5">
<span className="text-lg font-bold tracking-tight">
£{property.total_price.toLocaleString()}
{property.listing_type !== 'BUY' && (
<span className="text-muted-foreground font-normal text-sm">/mo</span>
)}
</div>
</span>
{priceIndicator && (
<span className={`text-xs px-1.5 py-0.5 rounded ${priceIndicator.color}`}>
{priceIndicator.label}
</span>
<span className={`w-2 h-2 rounded-full shrink-0 ${priceIndicator.dotColor}`} title={priceIndicator.label} />
)}
{priceIndicator && (
<span className="text-xs text-muted-foreground">{priceIndicator.label}</span>
)}
</div>
<div className="flex items-center gap-3 mt-1 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 className="flex items-center gap-1">
£{property.qmprice}/m²
</span>
{/* Key metrics on one line */}
<div className="flex items-center gap-1 text-sm text-muted-foreground mt-0.5">
<span>{property.rooms}</span><span>bed</span>
<span>·</span>
<span>{property.qm} m²</span>
<span>·</span>
<span>£{property.qmprice}/m²</span>
</div>
<div className="flex items-center gap-2 mt-1 text-xs text-muted-foreground">
<span className="flex items-center gap-1">
<Clock className="h-3 w-3" />
{lastSeenDays}d ago
</span>
<span className="truncate">{property.agency}</span>
{/* Agency + freshness */}
<div className="flex items-center gap-1 text-xs text-muted-foreground mt-1">
<span>{property.agency}</span>
<span>·</span>
<span>{lastSeenDays}d ago</span>
</div>
{/* POI badges */}
<POIDistanceBadges distances={property.poi_distances || []} />
</div>
</div>
);
}
// Full variant (for popup/detail view)
// Full variant
return (
<div className={`p-4 border rounded-lg ${isHighlighted ? 'ring-2 ring-primary' : ''}`}>
{/* Header with image and price */}
<div className="flex gap-4">
<a
href={property.url}
target="_blank"
rel="noopener noreferrer"
className="block w-32 h-24 rounded-md overflow-hidden flex-shrink-0 bg-muted hover:opacity-90 transition-opacity"
>
{property.photo_thumbnail && (
<img
src={property.photo_thumbnail}
alt={`${property.rooms}-bed, ${property.qm}m², £${property.total_price.toLocaleString()}`}
className="w-full h-full object-cover"
/>
)}
</a>
<div
className={`rounded-lg border bg-card shadow-sm hover:shadow-md transition-shadow overflow-hidden ${
isHighlighted ? 'ring-2 ring-primary' : ''
}`}
onClick={handleClick}
>
{/* Image section with 16:10 aspect ratio */}
<div className="relative aspect-[16/10] bg-muted">
{(property.photos?.length || property.photo_thumbnail) ? (
<CardCarousel
photos={property.photos?.length ? property.photos : [property.photo_thumbnail]}
altText={`${property.rooms}-bed, ${property.qm}m², £${property.total_price.toLocaleString()}`}
/>
) : null}
<div className="flex-1">
<div className="flex items-start justify-between">
<div>
<div className="font-semibold text-xl">
£{property.total_price.toLocaleString()}
{property.listing_type !== 'BUY' && (
<span className="text-muted-foreground font-normal text-sm">/mo</span>
)}
</div>
{priceIndicator && (
<span className={`inline-block mt-1 text-xs px-2 py-0.5 rounded ${priceIndicator.color}`}>
{priceIndicator.label}
</span>
)}
</div>
</div>
</div>
</div>
{/* Stats grid */}
<div className="grid grid-cols-2 gap-3 mt-4">
<div className="flex items-center gap-2 text-sm">
<Bed className="h-4 w-4 text-muted-foreground" />
<span><strong>{property.rooms}</strong> bedrooms</span>
</div>
<div className="flex items-center gap-2 text-sm">
<Maximize2 className="h-4 w-4 text-muted-foreground" />
<span><strong>{property.qm}</strong> m²</span>
</div>
<div className="flex items-center gap-2 text-sm">
<PoundSterling className="h-4 w-4 text-muted-foreground" />
<span><strong>£{property.qmprice}</strong>/m²</span>
</div>
{property.listing_type !== 'BUY' && property.available_from && (
<div className="flex items-center gap-2 text-sm">
<Clock className="h-4 w-4 text-muted-foreground" />
<span>Available <strong>{formatDate(property.available_from)}</strong></span>
</div>
)}
{property.listing_type === 'BUY' && (
<div className="flex items-center gap-2 text-sm">
<Clock className="h-4 w-4 text-muted-foreground" />
<span>Seen <strong>{lastSeenDays}d</strong> ago</span>
</div>
)}
</div>
{/* Agency and last seen */}
<div className="flex items-center gap-2 mt-3 text-sm text-muted-foreground">
<Building className="h-4 w-4" />
<span>{property.agency}</span>
<span className="mx-1"></span>
<span>Seen {lastSeenDays} days ago</span>
</div>
{/* POI Distances */}
{allPOIs && allPOIs.length > 0 ? (
<div className="mt-3 pt-3 border-t">
<div className="text-xs font-medium text-muted-foreground mb-1">Travel times</div>
<AllPOIDistances pois={allPOIs} distances={property.poi_distances} />
</div>
) : property.poi_distances && property.poi_distances.length > 0 ? (
<div className="mt-3 pt-3 border-t">
<div className="text-xs font-medium text-muted-foreground mb-1">Travel times</div>
<POIDistanceBadges distances={property.poi_distances} />
</div>
) : null}
{/* Price history */}
{property.price_history.length > 1 && (
<div className="mt-3 pt-3 border-t">
<div className="text-xs font-medium text-muted-foreground mb-1">Price history</div>
<div className="space-y-0.5">
{property.price_history.slice(0, 5).map((entry) => (
<div key={entry.id} className="text-sm flex justify-between">
<span className="text-muted-foreground">{entry.last_seen.split('T')[0]}</span>
<span>£{entry.price.toLocaleString()}</span>
</div>
))}
</div>
</div>
)}
{/* Actions */}
<div className="mt-4">
<Button asChild className="w-full">
<a href={property.url} target="_blank" rel="noopener noreferrer">
View Listing
<ExternalLink className="ml-2 h-4 w-4" />
{/* Overlay buttons: heart + external link */}
<div className="absolute top-2 right-2 flex items-center gap-1.5" onClick={e => e.stopPropagation()}>
<a
href={property.url}
target="_blank"
rel="noopener noreferrer"
className="flex items-center justify-center w-8 h-8 rounded-full bg-black/40 backdrop-blur-sm text-white hover:bg-black/60 transition-colors"
title="View on Rightmove"
>
<ExternalLink className="h-4 w-4" />
</a>
</Button>
</div>
</div>
{/* Content below image */}
<div className="p-4 space-y-2">
{/* Price as dominant element */}
<div className="flex items-center gap-1.5">
<span className="text-lg font-bold tracking-tight">
£{property.total_price.toLocaleString()}
{property.listing_type !== 'BUY' && (
<span className="text-muted-foreground font-normal text-sm">/mo</span>
)}
</span>
{priceIndicator && (
<span className={`w-2 h-2 rounded-full shrink-0 ${priceIndicator.dotColor}`} title={priceIndicator.label} />
)}
{priceIndicator && (
<span className="text-xs text-muted-foreground">{priceIndicator.label}</span>
)}
</div>
{/* Key metrics on one line */}
<div className="flex items-center gap-1 text-sm text-muted-foreground">
<span>{property.rooms}</span><span>bed</span>
<span>·</span>
<span>{property.qm} m²</span>
<span>·</span>
<span>£{property.qmprice}/m²</span>
</div>
{/* Location */}
{property.city && (
<div className="text-sm font-medium">{property.city}</div>
)}
{/* POI travel times */}
{allPOIs && allPOIs.length > 0 ? (
<AllPOIDistances pois={allPOIs} distances={property.poi_distances} />
) : property.poi_distances && property.poi_distances.length > 0 ? (
<POIDistanceBadges distances={property.poi_distances} />
) : null}
{/* Agency + freshness */}
<div className="flex items-center gap-1 text-xs text-muted-foreground">
<span>{property.agency}</span>
<span>·</span>
<span>{lastSeenDays}d ago</span>
</div>
</div>
</div>
);

View file

@ -1,4 +1,4 @@
import { Bed, Maximize2 } from 'lucide-react';
import { Bed, MapPin } from 'lucide-react';
import type { PropertyProperties } from '@/types';
interface PropertyCardCompactProps {
@ -20,20 +20,20 @@ export function PropertyCardCompact({
const isExpensive = avgPricePerSqm && property.qmprice > avgPricePerSqm * 1.1;
const priceIndicator = isGoodDeal
? { color: 'text-green-600 bg-green-50', label: 'Good deal' }
? { dotColor: 'bg-[var(--deal-good)]', label: 'Good deal' }
: isExpensive
? { color: 'text-red-600 bg-red-50', label: 'Above avg' }
? { dotColor: 'bg-[var(--deal-above)]', 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 ${
className={`w-[280px] shrink-0 snap-center rounded-lg border bg-card shadow-sm overflow-hidden cursor-pointer transition-shadow hover:shadow-md ${
isActive ? 'ring-2 ring-primary scale-[1.02]' : ''
} ${isHighlighted ? 'ring-2 ring-primary' : ''}`}
onClick={onClick}
>
{/* Thumbnail */}
<div className="h-28 w-full bg-muted">
{/* Thumbnail with 4:3 aspect ratio */}
<div className="aspect-[4/3] w-full bg-muted">
{property.photo_thumbnail && (
<img
src={property.photo_thumbnail}
@ -44,32 +44,37 @@ export function PropertyCardCompact({
</div>
{/* Details */}
<div className="p-3">
<div className="flex items-start justify-between gap-2">
<div className="font-semibold text-base">
<div className="p-3 space-y-1">
{/* Price bold */}
<div className="flex items-center gap-1.5">
<span className="font-bold text-base">
£{property.total_price.toLocaleString()}
{property.listing_type !== 'BUY' && (
<span className="text-muted-foreground font-normal text-sm">/mo</span>
)}
</div>
</span>
{priceIndicator && (
<span className={`text-xs px-1.5 py-0.5 rounded shrink-0 ${priceIndicator.color}`}>
{priceIndicator.label}
</span>
<span className={`w-2 h-2 rounded-full shrink-0 ${priceIndicator.dotColor}`} title={priceIndicator.label} />
)}
</div>
<div className="flex items-center gap-3 mt-1.5 text-sm text-muted-foreground">
{/* Beds and size */}
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<span className="flex items-center gap-1">
<Bed className="h-3.5 w-3.5" />
{property.rooms}
{property.rooms} bed
</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>
<span>·</span>
<span>{property.qm} m²</span>
</div>
{/* Location */}
{property.city && (
<div className="flex items-center gap-1 text-xs text-muted-foreground truncate">
<MapPin className="h-3 w-3 shrink-0" />
{property.city}
</div>
)}
</div>
</div>
);

View file

@ -1,6 +1,9 @@
import { BarChart3, MapPin, PoundSterling, Maximize2, List, Map as MapIcon, Heart } from 'lucide-react';
import { BarChart3, MapPin, PoundSterling, Maximize2, List, Map as MapIcon, Heart, Palette } from 'lucide-react';
import { Button } from './ui/button';
import type { GeoJSONFeatureCollection, PropertyFeature } from '@/types';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from './ui/select';
import type { GeoJSONFeatureCollection, PropertyFeature, POI } from '@/types';
import { formatCurrency } from '@/utils/format';
import { Metric } from './FilterPanel';
export type ViewMode = 'map' | 'list' | 'split' | 'saved';
@ -9,6 +12,11 @@ interface StatsBarProps {
viewMode: ViewMode;
onViewModeChange: (mode: ViewMode) => void;
likedCount?: number;
// Metric selector (moved from VisualizationCard)
metric?: Metric;
onMetricChange?: (metric: Metric) => void;
userPOIs?: POI[];
onPoiMetricChange?: (selection: { poiId: number; poiName: string; travelMode: 'WALK' | 'BICYCLE' | 'TRANSIT' } | null) => void;
}
interface ListingStats {
@ -53,14 +61,15 @@ function calculateStats(data: GeoJSONFeatureCollection | null): ListingStats {
return { count, avgPrice, avgPricePerSqm, avgSize };
}
function formatCurrency(value: number): string {
if (value >= 1000) {
return `£${(value / 1000).toFixed(1)}k`;
}
return `£${Math.round(value)}`;
}
export function StatsBar({ listingData, viewMode, onViewModeChange, likedCount = 0 }: StatsBarProps) {
export function StatsBar({
listingData,
viewMode,
onViewModeChange,
likedCount = 0,
metric,
onMetricChange,
userPOIs,
}: StatsBarProps) {
const stats = calculateStats(listingData);
return (
@ -81,57 +90,83 @@ export function StatsBar({ listingData, viewMode, onViewModeChange, likedCount =
</div>
<div className="hidden lg:flex items-center gap-1.5">
<BarChart3 className="h-4 w-4" />
<span>Avg £/m²: <span className="font-medium text-foreground">{formatCurrency(stats.avgPricePerSqm)}</span></span>
<span>Avg &pound;/m&sup2;: <span className="font-medium text-foreground">{formatCurrency(stats.avgPricePerSqm)}</span></span>
</div>
<div className="hidden lg:flex items-center gap-1.5">
<Maximize2 className="h-4 w-4" />
<span>Avg: <span className="font-medium text-foreground">{Math.round(stats.avgSize)} m²</span></span>
<span>Avg: <span className="font-medium text-foreground">{Math.round(stats.avgSize)} m&sup2;</span></span>
</div>
</>
)}
</div>
{/* View Mode Toggle */}
<div className="flex items-center gap-1 bg-background rounded-md border p-0.5">
<Button
variant={viewMode === 'map' ? 'secondary' : 'ghost'}
size="sm"
className="h-7 px-2"
onClick={() => onViewModeChange('map')}
>
<MapIcon className="h-4 w-4" />
<span className="hidden sm:inline ml-1">Map</span>
</Button>
<Button
variant={viewMode === 'list' ? 'secondary' : 'ghost'}
size="sm"
className="h-7 px-2"
onClick={() => onViewModeChange('list')}
>
<List className="h-4 w-4" />
<span className="hidden sm:inline ml-1">List</span>
</Button>
<Button
variant={viewMode === 'split' ? 'secondary' : 'ghost'}
size="sm"
className="h-7 px-2 hidden md:flex"
onClick={() => onViewModeChange('split')}
>
<div className="flex gap-0.5">
<div className="w-2 h-4 bg-current rounded-sm opacity-60" />
<div className="w-2 h-4 border border-current rounded-sm" />
<div className="flex items-center gap-2">
{/* Color-by Metric Selector */}
{metric && onMetricChange && (
<div className="hidden md:flex items-center gap-1.5">
<Palette className="h-3.5 w-3.5 text-muted-foreground" />
<Select
value={metric}
onValueChange={(value) => onMetricChange(value as Metric)}
>
<SelectTrigger className="h-7 text-xs w-[110px] border-0 bg-transparent shadow-none">
<SelectValue placeholder="Color by" />
</SelectTrigger>
<SelectContent>
<SelectItem value={Metric.qmprice}>Price/m&sup2;</SelectItem>
<SelectItem value={Metric.rooms}>Bedrooms</SelectItem>
<SelectItem value={Metric.qm}>Size (m&sup2;)</SelectItem>
<SelectItem value={Metric.price}>Total Price</SelectItem>
{userPOIs && userPOIs.length > 0 && (
<SelectItem value={Metric.poi_travel}>Travel Time</SelectItem>
)}
</SelectContent>
</Select>
</div>
<span className="hidden sm:inline ml-1">Split</span>
</Button>
<Button
variant={viewMode === 'saved' ? 'secondary' : 'ghost'}
size="sm"
className="h-7 px-2"
onClick={() => onViewModeChange('saved')}
>
<Heart className="h-4 w-4" />
<span className="hidden sm:inline ml-1">Saved{likedCount > 0 ? ` (${likedCount})` : ''}</span>
</Button>
)}
{/* View Mode Toggle */}
<div className="flex items-center gap-1 bg-background rounded-md border p-0.5">
<Button
variant={viewMode === 'map' ? 'secondary' : 'ghost'}
size="sm"
className="h-7 px-2"
onClick={() => onViewModeChange('map')}
>
<MapIcon className="h-4 w-4" />
<span className="hidden sm:inline ml-1">Map</span>
</Button>
<Button
variant={viewMode === 'list' ? 'secondary' : 'ghost'}
size="sm"
className="h-7 px-2"
onClick={() => onViewModeChange('list')}
>
<List className="h-4 w-4" />
<span className="hidden sm:inline ml-1">List</span>
</Button>
<Button
variant={viewMode === 'split' ? 'secondary' : 'ghost'}
size="sm"
className="h-7 px-2 hidden md:flex"
onClick={() => onViewModeChange('split')}
>
<div className="flex gap-0.5">
<div className="w-2 h-4 bg-current rounded-sm opacity-60" />
<div className="w-2 h-4 border border-current rounded-sm" />
</div>
<span className="hidden sm:inline ml-1">Split</span>
</Button>
<Button
variant={viewMode === 'saved' ? 'secondary' : 'ghost'}
size="sm"
className="h-7 px-2"
onClick={() => onViewModeChange('saved')}
>
<Heart className="h-4 w-4" />
<span className="hidden sm:inline ml-1">Saved{likedCount > 0 ? ` (${likedCount})` : ''}</span>
</Button>
</div>
</div>
</div>
);

View file

@ -1,9 +1,10 @@
import { TaskStatus, type TaskResult, type TaskState } from '@/types';
import { TaskStatus, type TaskState } from '@/types';
import { useEffect, useState, useRef, useMemo } from 'react';
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from './ui/tooltip';
import { Button } from './ui/button';
import { Loader2, CheckCircle2, XCircle, X, Trash2 } from 'lucide-react';
import { TaskProgressDrawer } from './TaskProgressDrawer';
import { isTerminalStatus, taskStateToResult } from '@/utils/taskUtils';
interface TaskIndicatorProps {
tasks: Record<string, TaskState>;
@ -14,37 +15,6 @@ interface TaskIndicatorProps {
onTaskCompleted?: () => void;
}
/** Convert a TaskState into a TaskResult (for the drawer). */
function taskStateToResult(ts: TaskState): TaskResult {
return {
progress: ts.progress ?? 0,
processed: ts.processed,
total: ts.total,
phase: ts.phase,
message: ts.message,
subqueries_probed: ts.subqueries_probed,
subqueries_initial: ts.subqueries_initial,
estimated_results: ts.estimated_results,
subqueries_total: ts.subqueries_total,
subqueries_completed: ts.subqueries_completed,
ids_collected: ts.ids_collected,
pages_fetched: ts.pages_fetched,
fetching_done: ts.fetching_done,
details_fetched: ts.details_fetched,
images_downloaded: ts.images_downloaded,
ocr_completed: ts.ocr_completed,
failed: ts.failed,
elapsed_seconds: ts.elapsed_seconds,
rate_per_second: ts.rate_per_second,
eta_seconds: ts.eta_seconds,
logs: ts.logs,
};
}
function isTerminalStatus(status: string): boolean {
return status === 'SUCCESS' || status === 'FAILURE' || status === 'REVOKED';
}
export function TaskIndicator({
tasks,
activeTaskId,

View file

@ -11,6 +11,7 @@ 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';
import { isTerminalStatus, taskStateToResult } from '@/utils/taskUtils';
interface TaskProgressDrawerProps {
open: boolean;
@ -62,10 +63,6 @@ function taskTypeIcon(type: 'scrape' | 'poi' | 'task') {
}
}
function isTerminalStatus(status: string): boolean {
return status === 'SUCCESS' || status === 'FAILURE' || status === 'REVOKED';
}
function getPhaseIndex(phase: TaskPhase | undefined): number {
if (!phase) return -1;
if (phase === 'splitting_complete') return 1;
@ -324,33 +321,6 @@ function LogViewer({ logs }: { logs: string[] }) {
);
}
/** Convert TaskState → TaskResult for existing phase detail components. */
function taskStateToResult(ts: TaskState): TaskResult {
return {
progress: ts.progress ?? 0,
processed: ts.processed,
total: ts.total,
phase: ts.phase,
message: ts.message,
subqueries_probed: ts.subqueries_probed,
subqueries_initial: ts.subqueries_initial,
estimated_results: ts.estimated_results,
subqueries_total: ts.subqueries_total,
subqueries_completed: ts.subqueries_completed,
ids_collected: ts.ids_collected,
pages_fetched: ts.pages_fetched,
fetching_done: ts.fetching_done,
details_fetched: ts.details_fetched,
images_downloaded: ts.images_downloaded,
ocr_completed: ts.ocr_completed,
failed: ts.failed,
elapsed_seconds: ts.elapsed_seconds,
rate_per_second: ts.rate_per_second,
eta_seconds: ts.eta_seconds,
logs: ts.logs,
};
}
function TaskTabBar({
tasks,
selectedTaskId,

View file

@ -0,0 +1,172 @@
import { useCallback, useMemo } from 'react';
import { useSearchParams, useLocation, useNavigate } from 'react-router-dom';
import { type ParameterValues, DEFAULT_FILTER_VALUES, Metric, ListingType, FurnishType } from '@/components/FilterPanel';
import { type ViewMode } from '@/components/StatsBar';
/**
* URL param key mapping:
* type listing_type
* minPrice min_price
* maxPrice max_price
* minBeds min_bedrooms
* maxBeds max_bedrooms
* minSqm min_sqm
* maxSqm max_sqm
* minPriceSqm min_price_per_sqm
* maxPriceSqm max_price_per_sqm
* furnish furnish_types (comma-separated)
* district district
* lastSeen last_seen_days
* availableFrom available_from (ISO date string)
* sort sort field (reserved for future use)
* metric metric (visualization color-by)
*/
/** Parse an optional integer from a URL search param */
function parseOptionalInt(value: string | null): number | undefined {
if (value === null || value === '') return undefined;
const n = parseInt(value, 10);
return Number.isNaN(n) ? undefined : n;
}
/** Pathname to ViewMode mapping */
function pathnameToViewMode(pathname: string): ViewMode {
const segment = pathname.split('/').filter(Boolean)[0] ?? '';
switch (segment) {
case 'map': return 'map';
case 'list': return 'list';
case 'split': return 'split';
case 'saved': return 'saved';
default: return 'map';
}
}
/** Read ParameterValues from URL search params, falling back to defaults */
function readFilterValues(params: URLSearchParams): ParameterValues {
const typeParam = params.get('type');
const listingType = typeParam === 'BUY' ? ListingType.BUY : typeParam === 'RENT' ? ListingType.RENT : DEFAULT_FILTER_VALUES.listing_type;
const metricParam = params.get('metric');
const metric = metricParam && Object.values(Metric).includes(metricParam as Metric)
? (metricParam as Metric)
: DEFAULT_FILTER_VALUES.metric;
const furnishParam = params.get('furnish');
const furnishTypes: FurnishType[] | undefined = furnishParam
? furnishParam.split(',').filter((v): v is FurnishType => Object.values(FurnishType).includes(v as FurnishType))
: undefined;
const availableFromParam = params.get('availableFrom');
let availableFrom: Date | undefined;
if (availableFromParam) {
const d = new Date(availableFromParam);
availableFrom = Number.isNaN(d.getTime()) ? undefined : d;
}
return {
metric,
listing_type: listingType,
min_price: parseOptionalInt(params.get('minPrice')) ?? DEFAULT_FILTER_VALUES.min_price,
max_price: parseOptionalInt(params.get('maxPrice')) ?? DEFAULT_FILTER_VALUES.max_price,
min_bedrooms: parseOptionalInt(params.get('minBeds')) ?? DEFAULT_FILTER_VALUES.min_bedrooms,
max_bedrooms: parseOptionalInt(params.get('maxBeds')) ?? DEFAULT_FILTER_VALUES.max_bedrooms,
min_sqm: parseOptionalInt(params.get('minSqm')) ?? DEFAULT_FILTER_VALUES.min_sqm,
max_sqm: parseOptionalInt(params.get('maxSqm')) ?? DEFAULT_FILTER_VALUES.max_sqm,
min_price_per_sqm: parseOptionalInt(params.get('minPriceSqm')) ?? DEFAULT_FILTER_VALUES.min_price_per_sqm,
max_price_per_sqm: parseOptionalInt(params.get('maxPriceSqm')) ?? DEFAULT_FILTER_VALUES.max_price_per_sqm,
last_seen_days: parseOptionalInt(params.get('lastSeen')) ?? DEFAULT_FILTER_VALUES.last_seen_days,
available_from: availableFrom,
district: params.get('district') ?? DEFAULT_FILTER_VALUES.district,
furnish_types: furnishTypes && furnishTypes.length > 0 ? furnishTypes : undefined,
};
}
/** Write ParameterValues into a URLSearchParams, omitting defaults */
function writeFilterParams(values: ParameterValues): URLSearchParams {
const params = new URLSearchParams();
if (values.listing_type !== DEFAULT_FILTER_VALUES.listing_type) {
params.set('type', values.listing_type);
}
if (values.metric !== DEFAULT_FILTER_VALUES.metric) {
params.set('metric', values.metric);
}
if (values.min_price !== undefined && values.min_price !== DEFAULT_FILTER_VALUES.min_price) {
params.set('minPrice', String(values.min_price));
}
if (values.max_price !== undefined && values.max_price !== DEFAULT_FILTER_VALUES.max_price) {
params.set('maxPrice', String(values.max_price));
}
if (values.min_bedrooms !== undefined && values.min_bedrooms !== DEFAULT_FILTER_VALUES.min_bedrooms) {
params.set('minBeds', String(values.min_bedrooms));
}
if (values.max_bedrooms !== undefined && values.max_bedrooms !== DEFAULT_FILTER_VALUES.max_bedrooms) {
params.set('maxBeds', String(values.max_bedrooms));
}
if (values.min_sqm !== undefined && values.min_sqm !== DEFAULT_FILTER_VALUES.min_sqm) {
params.set('minSqm', String(values.min_sqm));
}
if (values.max_sqm !== undefined && values.max_sqm !== DEFAULT_FILTER_VALUES.max_sqm) {
params.set('maxSqm', String(values.max_sqm));
}
if (values.min_price_per_sqm !== undefined && values.min_price_per_sqm !== DEFAULT_FILTER_VALUES.min_price_per_sqm) {
params.set('minPriceSqm', String(values.min_price_per_sqm));
}
if (values.max_price_per_sqm !== undefined && values.max_price_per_sqm !== DEFAULT_FILTER_VALUES.max_price_per_sqm) {
params.set('maxPriceSqm', String(values.max_price_per_sqm));
}
if (values.last_seen_days !== undefined && values.last_seen_days !== DEFAULT_FILTER_VALUES.last_seen_days) {
params.set('lastSeen', String(values.last_seen_days));
}
if (values.available_from) {
params.set('availableFrom', values.available_from.toISOString().slice(0, 10));
}
if (values.district && values.district !== DEFAULT_FILTER_VALUES.district) {
params.set('district', values.district);
}
if (values.furnish_types && values.furnish_types.length > 0) {
params.set('furnish', values.furnish_types.join(','));
}
return params;
}
export interface UseFilterParamsReturn {
/** Filter values parsed from the current URL (or defaults) */
filterValues: ParameterValues;
/** Update filter values and push to URL search params */
setFilterValues: (values: ParameterValues) => void;
/** Current view mode derived from URL pathname */
viewMode: ViewMode;
/** Change view mode by navigating to the corresponding path */
setViewMode: (mode: ViewMode) => void;
}
export function useFilterParams(): UseFilterParamsReturn {
const [searchParams, setSearchParams] = useSearchParams();
const location = useLocation();
const navigate = useNavigate();
const filterValues = useMemo(() => readFilterValues(searchParams), [searchParams]);
const viewMode = useMemo(() => pathnameToViewMode(location.pathname), [location.pathname]);
const setFilterValues = useCallback(
(values: ParameterValues) => {
const newParams = writeFilterParams(values);
setSearchParams(newParams, { replace: true });
},
[setSearchParams],
);
const setViewMode = useCallback(
(mode: ViewMode) => {
const path = mode === 'map' ? '/' : `/${mode}`;
// Preserve existing search params when changing view mode
navigate({ pathname: path, search: searchParams.toString() }, { replace: true });
},
[navigate, searchParams],
);
return { filterValues, setFilterValues, viewMode, setViewMode };
}

View file

@ -3,6 +3,7 @@ import type { AuthUser } from '@/auth/types';
import type { TaskState, TaskStatusResponse, WSMessage } from '@/types';
import { WS_TASKS_PATH } from '@/constants';
import { fetchTasksForUser, fetchTaskStatus } from '@/services';
import { isTerminalStatus } from '@/utils/taskUtils';
const KEEPALIVE_MS = 30_000;
const MAX_RECONNECT_DELAY_MS = 30_000;
@ -14,10 +15,6 @@ function wsUrl(token: string): string {
return `${proto}://${window.location.host}${WS_TASKS_PATH}?token=${encodeURIComponent(token)}`;
}
function isTerminalStatus(status: string): boolean {
return status === 'SUCCESS' || status === 'FAILURE' || status === 'REVOKED';
}
/** Convert an HTTP TaskStatusResponse into the canonical TaskState shape. */
function httpResponseToTaskState(resp: TaskStatusResponse): TaskState {
const state: TaskState = {

View file

@ -39,75 +39,81 @@
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
--color-sidebar-border: var(--sidebar-border);
--color-sidebar-ring: var(--sidebar-ring);
--color-deal-good: var(--deal-good);
--color-deal-above: var(--deal-above);
}
:root {
--radius: 0.625rem;
--background: oklch(1 0 0);
--foreground: oklch(0.145 0 0);
--background: oklch(0.995 0.002 240);
--foreground: oklch(0.15 0.015 255);
--card: oklch(1 0 0);
--card-foreground: oklch(0.145 0 0);
--card-foreground: oklch(0.15 0.015 255);
--popover: oklch(1 0 0);
--popover-foreground: oklch(0.145 0 0);
--primary: oklch(0.205 0 0);
--primary-foreground: oklch(0.985 0 0);
--secondary: oklch(0.97 0 0);
--secondary-foreground: oklch(0.205 0 0);
--muted: oklch(0.97 0 0);
--muted-foreground: oklch(0.556 0 0);
--accent: oklch(0.97 0 0);
--accent-foreground: oklch(0.205 0 0);
--popover-foreground: oklch(0.15 0.015 255);
--primary: oklch(0.55 0.14 175);
--primary-foreground: oklch(0.985 0.01 175);
--secondary: oklch(0.965 0.005 240);
--secondary-foreground: oklch(0.2 0.015 255);
--muted: oklch(0.965 0.005 240);
--muted-foreground: oklch(0.5 0.01 255);
--accent: oklch(0.55 0.14 175);
--accent-foreground: oklch(0.985 0.01 175);
--destructive: oklch(0.577 0.245 27.325);
--border: oklch(0.922 0 0);
--input: oklch(0.922 0 0);
--ring: oklch(0.708 0 0);
--chart-1: oklch(0.646 0.222 41.116);
--chart-2: oklch(0.6 0.118 184.704);
--border: oklch(0.91 0.005 240);
--input: oklch(0.91 0.005 240);
--ring: oklch(0.55 0.14 175);
--chart-1: oklch(0.55 0.14 175);
--chart-2: oklch(0.696 0.17 162.48);
--chart-3: oklch(0.398 0.07 227.392);
--chart-4: oklch(0.828 0.189 84.429);
--chart-5: oklch(0.769 0.188 70.08);
--sidebar: oklch(0.985 0 0);
--sidebar-foreground: oklch(0.145 0 0);
--sidebar-primary: oklch(0.205 0 0);
--sidebar-primary-foreground: oklch(0.985 0 0);
--sidebar-accent: oklch(0.97 0 0);
--sidebar-accent-foreground: oklch(0.205 0 0);
--sidebar-border: oklch(0.922 0 0);
--sidebar-ring: oklch(0.708 0 0);
--deal-good: oklch(0.696 0.17 162.48);
--deal-above: oklch(0.795 0.184 86.047);
--sidebar: oklch(0.985 0.002 240);
--sidebar-foreground: oklch(0.15 0.015 255);
--sidebar-primary: oklch(0.55 0.14 175);
--sidebar-primary-foreground: oklch(0.985 0.01 175);
--sidebar-accent: oklch(0.965 0.005 240);
--sidebar-accent-foreground: oklch(0.2 0.015 255);
--sidebar-border: oklch(0.91 0.005 240);
--sidebar-ring: oklch(0.55 0.14 175);
}
.dark {
--background: oklch(0.145 0 0);
--foreground: oklch(0.985 0 0);
--card: oklch(0.205 0 0);
--card-foreground: oklch(0.985 0 0);
--popover: oklch(0.205 0 0);
--popover-foreground: oklch(0.985 0 0);
--primary: oklch(0.922 0 0);
--primary-foreground: oklch(0.205 0 0);
--secondary: oklch(0.269 0 0);
--secondary-foreground: oklch(0.985 0 0);
--muted: oklch(0.269 0 0);
--muted-foreground: oklch(0.708 0 0);
--accent: oklch(0.269 0 0);
--accent-foreground: oklch(0.985 0 0);
--background: oklch(0.14 0.01 255);
--foreground: oklch(0.985 0.002 240);
--card: oklch(0.19 0.012 255);
--card-foreground: oklch(0.985 0.002 240);
--popover: oklch(0.19 0.012 255);
--popover-foreground: oklch(0.985 0.002 240);
--primary: oklch(0.65 0.14 175);
--primary-foreground: oklch(0.14 0.01 255);
--secondary: oklch(0.25 0.012 255);
--secondary-foreground: oklch(0.985 0.002 240);
--muted: oklch(0.25 0.012 255);
--muted-foreground: oklch(0.65 0.01 255);
--accent: oklch(0.65 0.14 175);
--accent-foreground: oklch(0.14 0.01 255);
--destructive: oklch(0.704 0.191 22.216);
--border: oklch(1 0 0 / 10%);
--input: oklch(1 0 0 / 15%);
--ring: oklch(0.556 0 0);
--chart-1: oklch(0.488 0.243 264.376);
--ring: oklch(0.65 0.14 175);
--chart-1: oklch(0.65 0.14 175);
--chart-2: oklch(0.696 0.17 162.48);
--chart-3: oklch(0.769 0.188 70.08);
--chart-4: oklch(0.627 0.265 303.9);
--chart-5: oklch(0.645 0.246 16.439);
--sidebar: oklch(0.205 0 0);
--sidebar-foreground: oklch(0.985 0 0);
--sidebar-primary: oklch(0.488 0.243 264.376);
--sidebar-primary-foreground: oklch(0.985 0 0);
--sidebar-accent: oklch(0.269 0 0);
--sidebar-accent-foreground: oklch(0.985 0 0);
--deal-good: oklch(0.75 0.17 162.48);
--deal-above: oklch(0.85 0.184 86.047);
--sidebar: oklch(0.19 0.012 255);
--sidebar-foreground: oklch(0.985 0.002 240);
--sidebar-primary: oklch(0.65 0.14 175);
--sidebar-primary-foreground: oklch(0.985 0.002 240);
--sidebar-accent: oklch(0.25 0.012 255);
--sidebar-accent-foreground: oklch(0.985 0.002 240);
--sidebar-border: oklch(1 0 0 / 10%);
--sidebar-ring: oklch(0.556 0 0);
--sidebar-ring: oklch(0.65 0.14 175);
}
@layer base {

View file

@ -1,6 +1,8 @@
import { StrictMode } from 'react';
import { createRoot } from 'react-dom/client';
import { BrowserRouter } from 'react-router-dom';
import App from './App.tsx';
import { ErrorBoundary } from './components/ErrorBoundary.tsx';
import './index.css';
import { AuthProvider } from "react-oidc-context";
@ -11,8 +13,12 @@ startCollector();
createRoot(document.getElementById('root')!).render(
<StrictMode>
<AuthProvider {...oidcConfig}>
<App />
</AuthProvider>
<ErrorBoundary>
<AuthProvider {...oidcConfig}>
<BrowserRouter>
<App />
</BrowserRouter>
</AuthProvider>
</ErrorBoundary>
</StrictMode>,
)

View file

@ -0,0 +1,37 @@
/**
* Shared formatting utility functions.
*
* Consolidates duplicated formatters that were previously defined inline in
* PropertyCard, ListingDetail, MobileBottomSheet, and StatsBar.
*/
/** Format a number as a compact GBP string, e.g. "£1.2k" or "£950". */
export function formatCurrency(value: number): string {
if (value >= 1000) return `£${(value / 1000).toFixed(1)}k`;
return `£${Math.round(value)}`;
}
/** Format a duration in seconds as a human-readable string, e.g. "12m" or "1h30m". */
export function formatDuration(seconds: number): string {
const minutes = Math.round(seconds / 60);
if (minutes < 60) return `${minutes}m`;
const hours = Math.floor(minutes / 60);
const mins = minutes % 60;
return mins > 0 ? `${hours}h${mins}m` : `${hours}h`;
}
/** Format an ISO date string as a localised short date (e.g. "3 Jan 2025"). */
export function formatDate(value: string): string {
const date = new Date(value);
if (isNaN(date.getTime())) return value;
return date.toLocaleDateString('en-GB', { day: 'numeric', month: 'short', year: 'numeric' });
}
/**
* Compute the price per square metre as a formatted string, e.g. "£4,500/m²".
* Returns null when square metres data is unavailable.
*/
export function formatPricePerSqm(price: number, sqm: number | null | undefined): string | null {
if (sqm == null || sqm <= 0) return null;
return `£${Math.round(price / sqm).toLocaleString()}/m²`;
}

View file

@ -0,0 +1,40 @@
/**
* Shared task-related utility functions.
*
* Consolidates helpers that were duplicated across App.tsx, TaskIndicator.tsx,
* TaskProgressDrawer.tsx, and useTaskProgress.ts.
*/
import type { TaskState, TaskResult } from '@/types';
/** Returns true when the status represents a terminal (finished) task state. */
export function isTerminalStatus(status: string): boolean {
return status === 'SUCCESS' || status === 'FAILURE' || status === 'REVOKED';
}
/** Convert a TaskState into a TaskResult (for the drawer). */
export function taskStateToResult(ts: TaskState): TaskResult {
return {
progress: ts.progress ?? 0,
processed: ts.processed,
total: ts.total,
phase: ts.phase,
message: ts.message,
subqueries_probed: ts.subqueries_probed,
subqueries_initial: ts.subqueries_initial,
estimated_results: ts.estimated_results,
subqueries_total: ts.subqueries_total,
subqueries_completed: ts.subqueries_completed,
ids_collected: ts.ids_collected,
pages_fetched: ts.pages_fetched,
fetching_done: ts.fetching_done,
details_fetched: ts.details_fetched,
images_downloaded: ts.images_downloaded,
ocr_completed: ts.ocr_completed,
failed: ts.failed,
elapsed_seconds: ts.elapsed_seconds,
rate_per_second: ts.rate_per_second,
eta_seconds: ts.eta_seconds,
logs: ts.logs,
};
}

View file

@ -1 +1 @@
{"root":["./src/app.tsx","./src/main.tsx","./src/vite-env.d.ts","./src/auth/authservice.ts","./src/auth/config.ts","./src/auth/errors.ts","./src/auth/passkeyservice.ts","./src/auth/types.ts","./src/components/alerterror.tsx","./src/components/authcallback.tsx","./src/components/favoritesview.tsx","./src/components/filterpanel.tsx","./src/components/header.tsx","./src/components/healthindicator.tsx","./src/components/listview.tsx","./src/components/listingdetail.tsx","./src/components/listingdetailsheet.tsx","./src/components/loginmodal.tsx","./src/components/map.tsx","./src/components/mobilebottomsheet.tsx","./src/components/mobilemenu.tsx","./src/components/poimanager.tsx","./src/components/photocarousel.tsx","./src/components/propertycard.tsx","./src/components/propertycardcompact.tsx","./src/components/spinner.tsx","./src/components/statsbar.tsx","./src/components/streamingprogressbar.tsx","./src/components/swipecard.tsx","./src/components/swipereviewmode.tsx","./src/components/swipeablecardrow.tsx","./src/components/swipeablepropertycard.tsx","./src/components/taskindicator.tsx","./src/components/taskprogressdrawer.tsx","./src/components/visualizationcard.tsx","./src/components/ui/datepicker.tsx","./src/components/ui/accordion.tsx","./src/components/ui/alert-dialog.tsx","./src/components/ui/breadcrumb.tsx","./src/components/ui/button.tsx","./src/components/ui/calendar.tsx","./src/components/ui/checkbox.tsx","./src/components/ui/dialog.tsx","./src/components/ui/form.tsx","./src/components/ui/hover-card.tsx","./src/components/ui/input.tsx","./src/components/ui/label.tsx","./src/components/ui/popover.tsx","./src/components/ui/progress.tsx","./src/components/ui/range-slider-field.tsx","./src/components/ui/scroll-area.tsx","./src/components/ui/select.tsx","./src/components/ui/separator.tsx","./src/components/ui/sheet.tsx","./src/components/ui/sidebar.tsx","./src/components/ui/skeleton.tsx","./src/components/ui/slider.tsx","./src/components/ui/tabs.tsx","./src/components/ui/tooltip.tsx","./src/constants/colorschemes.ts","./src/constants/index.ts","./src/hooks/use-mobile.ts","./src/hooks/usedecisions.ts","./src/hooks/uselistingdetail.ts","./src/hooks/usetaskprogress.ts","./src/lib/utils.ts","./src/services/apiclient.ts","./src/services/decisionservice.ts","./src/services/healthservice.ts","./src/services/index.ts","./src/services/listingdetailservice.ts","./src/services/listingservice.ts","./src/services/perfcollector.ts","./src/services/poiservice.ts","./src/services/streamingservice.ts","./src/services/taskservice.ts","./src/types/index.ts","./src/utils/maputils.ts","./src/utils/poiutils.ts","./src/workers/hexgridheatmapclient.ts","./src/workers/hexgrid.worker.ts","./src/workers/types.ts"],"version":"5.8.3"}
{"root":["./src/app.tsx","./src/main.tsx","./src/vite-env.d.ts","./src/auth/authservice.ts","./src/auth/config.ts","./src/auth/errors.ts","./src/auth/passkeyservice.ts","./src/auth/types.ts","./src/components/alerterror.tsx","./src/components/authcallback.tsx","./src/components/errorboundary.tsx","./src/components/favoritesview.tsx","./src/components/filterbar.tsx","./src/components/filterchips.tsx","./src/components/filterpanel.tsx","./src/components/header.tsx","./src/components/healthindicator.tsx","./src/components/listview.tsx","./src/components/listingdetail.tsx","./src/components/listingdetailsheet.tsx","./src/components/loginmodal.tsx","./src/components/map.tsx","./src/components/mobilebottomsheet.tsx","./src/components/mobilemenu.tsx","./src/components/poimanager.tsx","./src/components/photocarousel.tsx","./src/components/propertycard.tsx","./src/components/propertycardcompact.tsx","./src/components/spinner.tsx","./src/components/statsbar.tsx","./src/components/streamingprogressbar.tsx","./src/components/swipecard.tsx","./src/components/swipereviewmode.tsx","./src/components/swipeablecardrow.tsx","./src/components/swipeablepropertycard.tsx","./src/components/taskindicator.tsx","./src/components/taskprogressdrawer.tsx","./src/components/visualizationcard.tsx","./src/components/ui/datepicker.tsx","./src/components/ui/accordion.tsx","./src/components/ui/alert-dialog.tsx","./src/components/ui/breadcrumb.tsx","./src/components/ui/button.tsx","./src/components/ui/calendar.tsx","./src/components/ui/checkbox.tsx","./src/components/ui/dialog.tsx","./src/components/ui/form.tsx","./src/components/ui/hover-card.tsx","./src/components/ui/input.tsx","./src/components/ui/label.tsx","./src/components/ui/popover.tsx","./src/components/ui/progress.tsx","./src/components/ui/range-slider-field.tsx","./src/components/ui/scroll-area.tsx","./src/components/ui/select.tsx","./src/components/ui/separator.tsx","./src/components/ui/sheet.tsx","./src/components/ui/sidebar.tsx","./src/components/ui/skeleton.tsx","./src/components/ui/slider.tsx","./src/components/ui/tabs.tsx","./src/components/ui/tooltip.tsx","./src/constants/colorschemes.ts","./src/constants/index.ts","./src/hooks/use-mobile.ts","./src/hooks/usedecisions.ts","./src/hooks/usefilterparams.ts","./src/hooks/uselistingdetail.ts","./src/hooks/usetaskprogress.ts","./src/lib/utils.ts","./src/services/apiclient.ts","./src/services/decisionservice.ts","./src/services/healthservice.ts","./src/services/index.ts","./src/services/listingcache.ts","./src/services/listingdetailservice.ts","./src/services/listingservice.ts","./src/services/perfcollector.ts","./src/services/poiservice.ts","./src/services/streamingservice.ts","./src/services/taskservice.ts","./src/types/index.ts","./src/utils/format.ts","./src/utils/maputils.ts","./src/utils/poiutils.ts","./src/utils/taskutils.ts","./src/workers/hexgridheatmapclient.ts","./src/workers/hexgrid.worker.ts","./src/workers/types.ts"],"version":"5.8.3"}