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:
commit
8fcd530d7f
22 changed files with 1841 additions and 602 deletions
46
frontend/package-lock.json
generated
46
frontend/package-lock.json
generated
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
44
frontend/src/components/ErrorBoundary.tsx
Normal file
44
frontend/src/components/ErrorBoundary.tsx
Normal 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;
|
||||
}
|
||||
}
|
||||
675
frontend/src/components/FilterBar.tsx
Normal file
675
frontend/src/components/FilterBar.tsx
Normal 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²)</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²)</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 £/m²</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 £/m²</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 };
|
||||
126
frontend/src/components/FilterChips.tsx
Normal file
126
frontend/src/components/FilterChips.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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 && (
|
||||
<>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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 £/m²: <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²</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²</SelectItem>
|
||||
<SelectItem value={Metric.rooms}>Bedrooms</SelectItem>
|
||||
<SelectItem value={Metric.qm}>Size (m²)</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>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
172
frontend/src/hooks/useFilterParams.ts
Normal file
172
frontend/src/hooks/useFilterParams.ts
Normal 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 };
|
||||
}
|
||||
|
|
@ -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 = {
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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>,
|
||||
)
|
||||
|
|
|
|||
37
frontend/src/utils/format.ts
Normal file
37
frontend/src/utils/format.ts
Normal 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²`;
|
||||
}
|
||||
40
frontend/src/utils/taskUtils.ts
Normal file
40
frontend/src/utils/taskUtils.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
|
|
@ -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"}
|
||||
Loading…
Add table
Add a link
Reference in a new issue