From b720013a086ee1931a705de6e05e6f45bbfd84b1 Mon Sep 17 00:00:00 2001 From: Viktor Barzin Date: Sat, 28 Feb 2026 15:58:39 +0000 Subject: [PATCH 01/12] chore: add react-router-dom dependency for URL-based navigation --- frontend/package-lock.json | 46 +++++++++++++++++++++++++++++++++++++- frontend/package.json | 1 + 2 files changed, 46 insertions(+), 1 deletion(-) diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 0dc84c3..9032ee5 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -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", diff --git a/frontend/package.json b/frontend/package.json index 97a1656..7530195 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -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", From 1037ff164d82e0d6853e331ccc436df074c3a01d Mon Sep 17 00:00:00 2001 From: Viktor Barzin Date: Sat, 28 Feb 2026 16:02:06 +0000 Subject: [PATCH 02/12] refactor: extract shared utility functions to eliminate duplication --- frontend/src/App.tsx | 5 +-- frontend/src/components/ListingDetail.tsx | 15 +------ frontend/src/components/MobileBottomSheet.tsx | 6 +-- frontend/src/components/PropertyCard.tsx | 15 +------ frontend/src/components/StatsBar.tsx | 8 +--- frontend/src/components/TaskIndicator.tsx | 32 +-------------- .../src/components/TaskProgressDrawer.tsx | 32 +-------------- frontend/src/hooks/useTaskProgress.ts | 5 +-- frontend/src/utils/format.ts | 37 +++++++++++++++++ frontend/src/utils/taskUtils.ts | 40 +++++++++++++++++++ 10 files changed, 85 insertions(+), 110 deletions(-) create mode 100644 frontend/src/utils/format.ts create mode 100644 frontend/src/utils/taskUtils.ts diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index dd440a3..6177d8e 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -22,6 +22,7 @@ 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 { useIsMobile } from '@/hooks/use-mobile'; @@ -30,10 +31,6 @@ import { SwipeReviewMode } from './components/SwipeReviewMode'; import { FavoritesView } from './components/FavoritesView'; import { ListingDetailSheet } from './components/ListingDetailSheet'; -function isTerminalStatus(status: string): boolean { - return status === 'SUCCESS' || status === 'FAILURE' || status === 'REVOKED'; -} - function App() { const [listingData, setListingData] = useState(null); const [user, setUser] = useState(null); diff --git a/frontend/src/components/ListingDetail.tsx b/frontend/src/components/ListingDetail.tsx index ff86995..3f04435 100644 --- a/frontend/src/components/ListingDetail.tsx +++ b/frontend/src/components/ListingDetail.tsx @@ -2,12 +2,7 @@ import { ExternalLink, Heart, X, Bed, Maximize2, PoundSterling, Building, Clock, import { Button } from './ui/button'; 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,14 +10,6 @@ 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 }) { switch (mode) { case 'WALK': return ; diff --git a/frontend/src/components/MobileBottomSheet.tsx b/frontend/src/components/MobileBottomSheet.tsx index 3337fe6..79a0fb4 100644 --- a/frontend/src/components/MobileBottomSheet.tsx +++ b/frontend/src/components/MobileBottomSheet.tsx @@ -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, diff --git a/frontend/src/components/PropertyCard.tsx b/frontend/src/components/PropertyCard.tsx index e4c1767..db49492 100644 --- a/frontend/src/components/PropertyCard.tsx +++ b/frontend/src/components/PropertyCard.tsx @@ -3,20 +3,7 @@ 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 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 { formatDate, formatDuration } from '@/utils/format'; function TravelModeIcon({ mode }: { mode: string }) { switch (mode) { diff --git a/frontend/src/components/StatsBar.tsx b/frontend/src/components/StatsBar.tsx index 00b4ef6..308a346 100644 --- a/frontend/src/components/StatsBar.tsx +++ b/frontend/src/components/StatsBar.tsx @@ -1,6 +1,7 @@ import { BarChart3, MapPin, PoundSterling, Maximize2, List, Map as MapIcon, Heart } from 'lucide-react'; import { Button } from './ui/button'; import type { GeoJSONFeatureCollection, PropertyFeature } from '@/types'; +import { formatCurrency } from '@/utils/format'; export type ViewMode = 'map' | 'list' | 'split' | 'saved'; @@ -53,13 +54,6 @@ 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) { const stats = calculateStats(listingData); diff --git a/frontend/src/components/TaskIndicator.tsx b/frontend/src/components/TaskIndicator.tsx index 2672489..542bc2e 100644 --- a/frontend/src/components/TaskIndicator.tsx +++ b/frontend/src/components/TaskIndicator.tsx @@ -4,6 +4,7 @@ import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from './ui/t 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; @@ -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, diff --git a/frontend/src/components/TaskProgressDrawer.tsx b/frontend/src/components/TaskProgressDrawer.tsx index ca90139..64552ae 100644 --- a/frontend/src/components/TaskProgressDrawer.tsx +++ b/frontend/src/components/TaskProgressDrawer.tsx @@ -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, diff --git a/frontend/src/hooks/useTaskProgress.ts b/frontend/src/hooks/useTaskProgress.ts index 6606c8b..574226b 100644 --- a/frontend/src/hooks/useTaskProgress.ts +++ b/frontend/src/hooks/useTaskProgress.ts @@ -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 = { diff --git a/frontend/src/utils/format.ts b/frontend/src/utils/format.ts new file mode 100644 index 0000000..7bf2ed2 --- /dev/null +++ b/frontend/src/utils/format.ts @@ -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²`; +} diff --git a/frontend/src/utils/taskUtils.ts b/frontend/src/utils/taskUtils.ts new file mode 100644 index 0000000..c27697c --- /dev/null +++ b/frontend/src/utils/taskUtils.ts @@ -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, + }; +} From 676fad520c7c2c66b5e44340ffa71098f550fa10 Mon Sep 17 00:00:00 2001 From: Viktor Barzin Date: Sat, 28 Feb 2026 16:03:56 +0000 Subject: [PATCH 03/12] style: update color palette from neutral to teal-accented property theme - Primary/accent changed from achromatic black to teal (oklch 0.55 0.14 175) - Background/foreground given subtle cool slate tint - Added --deal-good (emerald) and --deal-above (amber) custom properties - Ring color updated to teal for focus states - Dark mode updated to match teal theme --- frontend/src/index.css | 102 ++++++++++++++++++++++------------------- 1 file changed, 54 insertions(+), 48 deletions(-) diff --git a/frontend/src/index.css b/frontend/src/index.css index ebccdef..9154b02 100644 --- a/frontend/src/index.css +++ b/frontend/src/index.css @@ -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 { From de47e2cca88f17d7c783738659bb404cb8111440 Mon Sep 17 00:00:00 2001 From: Viktor Barzin Date: Sat, 28 Feb 2026 16:07:14 +0000 Subject: [PATCH 04/12] feat: add React Router with URL-based filter state and deep linking - Wrap App in BrowserRouter in main.tsx - Create useFilterParams hook that syncs filter state with URL search params and derives viewMode from the URL pathname - Replace window.location.pathname callback check with React Router Routes - Split App into AppContent (main UI) and App (route definitions) --- frontend/src/App.tsx | 31 +++-- frontend/src/hooks/useFilterParams.ts | 172 ++++++++++++++++++++++++++ frontend/src/main.tsx | 5 +- 3 files changed, 199 insertions(+), 9 deletions(-) create mode 100644 frontend/src/hooks/useFilterParams.ts diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 6177d8e..171d573 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -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'; @@ -10,7 +11,7 @@ import { Map } from './components/Map'; import { FilterPanel, type ParameterValues, DEFAULT_FILTER_VALUES, Metric } from './components/FilterPanel'; 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'; @@ -25,20 +26,21 @@ 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'; -function App() { +function AppContent() { const [listingData, setListingData] = useState(null); const [user, setUser] = useState(null); const [queryParameters, setQueryParameters] = useState(null); const [submitError, setSubmitError] = useState(null); const [alertDialogIsOpen, setAlertDialogIsOpen] = useState(false); const [isLoading, setIsLoading] = useState(false); - const [viewMode, setViewMode] = useState('map'); + const { viewMode, setViewMode } = useFilterParams(); const [mobileFilterOpen, setMobileFilterOpen] = useState(false); const [highlightedProperty, setHighlightedProperty] = useState(null); const [streamingProgress, setStreamingProgress] = useState(null); @@ -89,11 +91,6 @@ function App() { // Ref to abort in-flight streaming requests const abortControllerRef = useRef(null); - // Check if this is the callback route - render dedicated component - if (window.location.pathname === '/callback') { - return ; - } - useEffect(() => { // Check passkey user first, then fall back to OIDC const passkeyUser = getStoredPasskeyUser(); @@ -706,4 +703,22 @@ function App() { ); } +/** Top-level App component with React Router routes */ +function App() { + return ( + + } /> + {/* All view modes share the same AppContent; viewMode is derived from pathname */} + } /> + } /> + } /> + } /> + {/* Default: root maps to map view */} + } /> + {/* Catch-all: redirect unknown paths to root */} + } /> + + ); +} + export default App; diff --git a/frontend/src/hooks/useFilterParams.ts b/frontend/src/hooks/useFilterParams.ts new file mode 100644 index 0000000..9ecebe0 --- /dev/null +++ b/frontend/src/hooks/useFilterParams.ts @@ -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 }; +} diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx index b62c400..b93818c 100644 --- a/frontend/src/main.tsx +++ b/frontend/src/main.tsx @@ -1,5 +1,6 @@ import { StrictMode } from 'react'; import { createRoot } from 'react-dom/client'; +import { BrowserRouter } from 'react-router-dom'; import App from './App.tsx'; import './index.css'; @@ -12,7 +13,9 @@ startCollector(); createRoot(document.getElementById('root')!).render( - + + + , ) From 4053c0c7592cdf1785f10835c7f0e7f9ad6459d5 Mon Sep 17 00:00:00 2001 From: Viktor Barzin Date: Sat, 28 Feb 2026 16:12:09 +0000 Subject: [PATCH 05/12] feat: create FilterBar and FilterChips components Add a horizontal FilterBar component with popover-based dropdowns for Price, Beds, Size, and a "More Filters" panel with advanced options (price/m2, furnishing, district, date, POI travel filters). Action buttons (Show Listings / Scrape New) are aligned to the right. Add FilterChips component that renders active (non-default) filter values as removable pills below the filter bar. --- frontend/src/components/FilterBar.tsx | 686 ++++++++++++++++++++++++ frontend/src/components/FilterChips.tsx | 126 +++++ 2 files changed, 812 insertions(+) create mode 100644 frontend/src/components/FilterBar.tsx create mode 100644 frontend/src/components/FilterChips.tsx diff --git a/frontend/src/components/FilterBar.tsx b/frontend/src/components/FilterBar.tsx new file mode 100644 index 0000000..74581e8 --- /dev/null +++ b/frontend/src/components/FilterBar.tsx @@ -0,0 +1,686 @@ +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; + +const PRICE_BOUNDS = { + [ListingType.RENT]: { min: 0, max: 10000, step: 50 }, + [ListingType.BUY]: { min: 0, max: 2000000, step: 10000 }, +} as const; + +// ── Props ── +interface FilterBarProps { + onSubmit: (action: 'fetch-data' | 'visualize', parameters: ParameterValues) => void; + isLoading: boolean; + user: AuthUser; + userPOIs: POI[]; + onPOIsChange: (pois: POI[]) => void; + poiTravelFilters: Record; + onPoiTravelFiltersChange: (filters: Record) => 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, + poiPickerActive, + onPoiPickerActiveChange, + pickedPoiLocation, + onPickedPoiLocationChange, + currentMetric, + onTaskCreated, +}: FilterBarProps) { + const [selectedFurnishTypes, setSelectedFurnishTypes] = useState([]); + const [availableFromRawInput, setAvailableFromRawInput] = useState('now'); + + const form = useForm({ + 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], + ); + + /** Public getter so App can read current form values (e.g. for FilterChips) */ + const getValues = useCallback((): ParameterValues => { + return readFormParams(form.getValues(), currentMetric, selectedFurnishTypes); + }, [form, 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 ( +
+ e.preventDefault()} + > + {/* ── Price Popover ── */} + + + + + +

Price (GBP)

+ ( + + Min + + field.onChange(e.target.value ? Number(e.target.value) : undefined)} + /> + + + )} + /> + ( + + Max + + field.onChange(e.target.value ? Number(e.target.value) : undefined)} + /> + + + )} + /> + +
+
+ + {/* ── Beds Popover ── */} + + + + + +

Bedrooms

+
+ ( + + Min + + field.onChange(e.target.value ? Number(e.target.value) : undefined)} + /> + + + )} + /> + ( + + Max + + field.onChange(e.target.value ? Number(e.target.value) : undefined)} + /> + + + )} + /> +
+ +
+
+ + {/* ── Size Popover ── */} + + + + + +

Size (m²)

+ ( + + Min + + field.onChange(e.target.value ? Number(e.target.value) : undefined)} + /> + + + )} + /> + +
+
+ + {/* ── More Filters Popover ── */} + + + + + + +
+

Advanced Filters

+ +
+ {/* Max Size */} + ( + + Max Size (m²) + + field.onChange(e.target.value ? Number(e.target.value) : undefined)} + /> + + + )} + /> + + {/* Last Seen Days */} + ( + + Last Seen (days) + + field.onChange(e.target.value ? Number(e.target.value) : undefined)} + /> + + + )} + /> + + {/* Price per sqm min */} + ( + + Min £/m² + + field.onChange(e.target.value ? Number(e.target.value) : undefined)} + /> + + + )} + /> + + {/* Price per sqm max */} + ( + + Max £/m² + + field.onChange(e.target.value ? Number(e.target.value) : undefined)} + /> + + + )} + /> +
+ + {/* Furnishing (rent only) */} + {watchedListingType === ListingType.RENT && ( +
+ Furnishing +
+ {[ + { value: FurnishType.FURNISHED, label: 'Furnished' }, + { value: FurnishType.PART_FURNISHED, label: 'Part' }, + { value: FurnishType.UNFURNISHED, label: 'Unfurn.' }, + ].map((option) => ( + + ))} +
+
+ )} + + {/* District */} + ( + + District + + + + + Comma-separated list of districts + + + )} + /> + + {/* Available From (rent only) */} + {watchedListingType === ListingType.RENT && ( + ( + + Available From + + + + + )} + /> + )} + + {/* POI section */} + {user && userPOIs.length > 0 && ( +
+

+ + Points of Interest +

+
+ {userPOIs.map((poi) => { + const filter = poiTravelFilters?.[poi.id]; + const travelMode = filter?.travelMode ?? 'WALK'; + const maxMinutes = filter?.maxMinutes; + return ( +
+ + {poi.name} + + + { + onPoiTravelFiltersChange({ + ...poiTravelFilters, + [poi.id]: { + travelMode, + maxMinutes: e.target.value ? Number(e.target.value) : undefined, + }, + }); + }} + /> + min +
+ ); + })} +
+
+ )} + + {/* POI Manager (authenticated users) */} + {user && ( +
+ {userPOIs.length === 0 && ( +

+ + Points of Interest +

+ )} + { + onPoiPickerActiveChange(true); + onPickedPoiLocationChange(null); + }} + pickedLocation={pickedPoiLocation} + /> +
+ )} + + {/* Apply button inside More Filters */} + +
+
+
+
+ + {/* ── Spacer ── */} +
+ + {/* ── Action Buttons (right side) ── */} + + + + + + ); +} + +// Re-export getValues helper type for external access +export type { FilterBarProps }; diff --git a/frontend/src/components/FilterChips.tsx b/frontend/src/components/FilterChips.tsx new file mode 100644 index 0000000..fd295e9 --- /dev/null +++ b/frontend/src/components/FilterChips.tsx @@ -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 ( +
+ {chips.map((chip) => ( + + {chip.label} + + + ))} +
+ ); +} From 8f112f30e3816ef8c746e87123a7d9d07bdf6f2a Mon Sep 17 00:00:00 2001 From: Viktor Barzin Date: Sat, 28 Feb 2026 16:16:03 +0000 Subject: [PATCH 06/12] feat: integrate FilterBar into layout, remove sidebar Replace the fixed w-80 sidebar with a horizontal FilterBar below the header, giving the map full viewport width. Key changes: - App.tsx: Remove sidebar layout, add FilterBar + FilterChips + inline StreamingProgressBar between header and main content area - Header.tsx: Add Rent/Buy listing type toggle (compact Tabs) after logo - StatsBar.tsx: Add "Color by" metric selector (moved from VisualizationCard) as a compact Select alongside view mode toggles - Mobile: Replace Sheet-based filter panel with full-screen Dialog --- frontend/src/App.tsx | 199 ++++++++++++++++----------- frontend/src/components/Header.tsx | 27 ++++ frontend/src/components/StatsBar.tsx | 131 ++++++++++++------ 3 files changed, 235 insertions(+), 122 deletions(-) diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 171d573..527d6ba 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -8,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 } 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'; @@ -32,6 +35,7 @@ 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 AppContent() { const [listingData, setListingData] = useState(null); @@ -54,6 +58,7 @@ function AppContent() { } | null>(null); const [poiTravelFilters, setPoiTravelFilters] = useState>({}); const [currentMetric, setCurrentMetric] = useState(DEFAULT_FILTER_VALUES.metric); + const [listingType, setListingType] = useState(DEFAULT_FILTER_VALUES.listing_type); const isMobile = useIsMobile(); const [, setActiveCardFeature] = useState(null); const [showReviewMode, setShowReviewMode] = useState(false); @@ -373,6 +378,31 @@ function AppContent() { // 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)[key] = (DEFAULT_FILTER_VALUES as Record)[key]; + } + loadListings(updated); + }; + const renderMainContent = () => { if (!processedListingData) { return ( @@ -383,7 +413,7 @@ function AppContent() {
🏠

Loading Properties...

- Fetching listings with default filters. You can adjust filters on the left. + Fetching listings with default filters. Adjust filters above to refine results.

) : ( @@ -391,7 +421,7 @@ function AppContent() {
🏠

Welcome to Property Explorer

- 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.

)} @@ -520,40 +550,46 @@ function AppContent() { > - - - - - -
-
- + + + + + Filters + + +
+
+ { + 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} + /> +
+
+ +
-
- -
-
- - + + +
{/* Bottom Sheet */} @@ -596,7 +632,7 @@ function AppContent() { return (
- {/* Header */} + {/* Header with Listing Type Toggle */}
{isMobile ? ( renderMobileLayout() ) : ( - /* Desktop layout */ -
- {/* Filter Panel - Desktop (fixed sidebar) */} -
-
-
- -
-
- -
-
+ /* Desktop layout: no sidebar, full-width main area */ + <> + {/* Horizontal Filter Bar */} + + + {/* Active Filter Chips */} + {queryParameters && ( + + )} + + {/* Streaming Progress Bar */} +
+ abortControllerRef.current?.abort()} + />
- {/* Main View Area */} -
- {/* Streaming Progress Bar */} -
- abortControllerRef.current?.abort()} /> -
- + {/* Main content area (full width) */} +
{/* Map/List Container */}
{renderMainContent()}
- {/* Stats Bar */} + {/* Stats Bar with Metric Selector */} {processedListingData && processedListingData.features.length > 0 && (
)} -
-
+ + )} {/* Swipe Review Mode Overlay */} diff --git a/frontend/src/components/Header.tsx b/frontend/src/components/Header.tsx index aef17cf..022fa20 100644 --- a/frontend/src/components/Header.tsx +++ b/frontend/src/components/Header.tsx @@ -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; onClearAllTasks: () => Promise; 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({ Wrongmove
+ {/* Listing Type Toggle (Rent / Buy) */} + {listingType && onListingTypeChange && ( + <> + + onListingTypeChange(v as ListingType)} + > + + + Rent + + + Buy + + + + + )} + {/* Desktop-only items */} {!isMobile && ( <> diff --git a/frontend/src/components/StatsBar.tsx b/frontend/src/components/StatsBar.tsx index 308a346..1c14211 100644 --- a/frontend/src/components/StatsBar.tsx +++ b/frontend/src/components/StatsBar.tsx @@ -1,7 +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'; @@ -10,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 { @@ -54,7 +61,15 @@ function calculateStats(data: GeoJSONFeatureCollection | null): ListingStats { return { count, avgPrice, avgPricePerSqm, avgSize }; } -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 ( @@ -75,57 +90,83 @@ export function StatsBar({ listingData, viewMode, onViewModeChange, likedCount =
- Avg £/m²: {formatCurrency(stats.avgPricePerSqm)} + Avg £/m²: {formatCurrency(stats.avgPricePerSqm)}
- Avg: {Math.round(stats.avgSize)} m² + Avg: {Math.round(stats.avgSize)} m²
)}
- {/* View Mode Toggle */} -
- - - - + )} + + {/* View Mode Toggle */} +
+ + + + +
); From be2f0ef28212399b74911e33e4b9e5cd0b3f30b5 Mon Sep 17 00:00:00 2001 From: Viktor Barzin Date: Sat, 28 Feb 2026 16:18:10 +0000 Subject: [PATCH 07/12] feat: add error boundary to prevent white-screen crashes Wraps the entire app in an ErrorBoundary that shows a friendly error message with reload button instead of crashing to white. --- frontend/src/components/ErrorBoundary.tsx | 44 +++++++++++++++++++++++ frontend/src/main.tsx | 13 ++++--- 2 files changed, 52 insertions(+), 5 deletions(-) create mode 100644 frontend/src/components/ErrorBoundary.tsx diff --git a/frontend/src/components/ErrorBoundary.tsx b/frontend/src/components/ErrorBoundary.tsx new file mode 100644 index 0000000..4e0c8ac --- /dev/null +++ b/frontend/src/components/ErrorBoundary.tsx @@ -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 { + 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 ( +
+
+

Something went wrong

+

+ {this.state.error?.message ?? 'An unexpected error occurred.'} +

+ +
+
+ ); + } + return this.props.children; + } +} diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx index b93818c..f3bab93 100644 --- a/frontend/src/main.tsx +++ b/frontend/src/main.tsx @@ -2,6 +2,7 @@ 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"; @@ -12,10 +13,12 @@ startCollector(); createRoot(document.getElementById('root')!).render( - - - - - + + + + + + + , ) From 812bfece4a4aaea72a2aed8d447f5bdf81c97517 Mon Sep 17 00:00:00 2001 From: Viktor Barzin Date: Sat, 28 Feb 2026 16:21:17 +0000 Subject: [PATCH 08/12] style: redesign PropertyCard with better visual hierarchy --- frontend/src/components/PropertyCard.tsx | 252 ++++++++---------- .../src/components/PropertyCardCompact.tsx | 45 ++-- 2 files changed, 132 insertions(+), 165 deletions(-) diff --git a/frontend/src/components/PropertyCard.tsx b/frontend/src/components/PropertyCard.tsx index db49492..7c0a5c6 100644 --- a/frontend/src/components/PropertyCard.tsx +++ b/frontend/src/components/PropertyCard.tsx @@ -1,9 +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, Heart, Bed, Maximize2, Clock, Footprints, Bike, Train } from 'lucide-react'; import type { PropertyProperties, POIDistanceInfo, POI } from '@/types'; -import { formatDate, formatDuration } from '@/utils/format'; +import { formatDuration } from '@/utils/format'; function TravelModeIcon({ mode }: { mode: string }) { switch (mode) { @@ -26,12 +25,12 @@ function POIDistanceBadges({ distances }: { distances: POIDistanceInfo[] }) { } return ( -
+
{Array.from(byPoi.entries()).map(([poiName, dists]) => ( -
+
{poiName}: {dists.map(d => ( - + {formatDuration(d.duration_seconds)} @@ -54,16 +53,16 @@ function AllPOIDistances({ pois, distances }: { pois: POI[]; distances?: POIDist } return ( -
+
{pois.map(poi => ( -
+
{poi.name}: {TRAVEL_MODES.map(mode => { const dist = distMap.get(`${poi.id}_${mode}`); return ( - + - {dist ? formatDuration(dist.duration_seconds) : '—'} + {dist ? formatDuration(dist.duration_seconds) : '\u2014'} ); })} @@ -154,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 = () => { @@ -166,7 +165,7 @@ export function PropertyCard({ if (variant === 'compact') { return (
-
-
+ {/* Price */} +
+ £{property.total_price.toLocaleString()} {property.listing_type !== 'BUY' && ( /mo )} -
+ {priceIndicator && ( - - {priceIndicator.label} - + + )} + {priceIndicator && ( + {priceIndicator.label} )}
-
- - - {property.rooms} - - - - {property.qm} m² - - - £{property.qmprice}/m² - + {/* Key metrics on one line */} +
+ {property.rooms}bed + · + {property.qm} m² + · + £{property.qmprice}/m²
-
- - - {lastSeenDays}d ago - - {property.agency} + {/* Agency + freshness */} +
+ {property.agency} + · + {lastSeenDays}d ago
+ + {/* POI badges */}
); } - // Full variant (for popup/detail view) + // Full variant return ( -
- {/* Header with image and price */} -
- - {property.photo_thumbnail && ( - {`${property.rooms}-bed, - )} - +
+ {/* Image section with 16:10 aspect ratio */} +
+ {(property.photos?.length || property.photo_thumbnail) ? ( + + ) : null} -
-
-
-
- £{property.total_price.toLocaleString()} - {property.listing_type !== 'BUY' && ( - /mo - )} -
- {priceIndicator && ( - - {priceIndicator.label} - - )} -
-
-
-
- - {/* Stats grid */} -
-
- - {property.rooms} bedrooms -
-
- - {property.qm} -
-
- - £{property.qmprice}/m² -
- {property.listing_type !== 'BUY' && property.available_from && ( -
- - Available {formatDate(property.available_from)} -
- )} - {property.listing_type === 'BUY' && ( -
- - Seen {lastSeenDays}d ago -
- )} -
- - {/* Agency and last seen */} -
- - {property.agency} - - Seen {lastSeenDays} days ago -
- - {/* POI Distances */} - {allPOIs && allPOIs.length > 0 ? ( -
-
Travel times
- -
- ) : property.poi_distances && property.poi_distances.length > 0 ? ( -
-
Travel times
- -
- ) : null} - - {/* Price history */} - {property.price_history.length > 1 && ( -
-
Price history
-
- {property.price_history.slice(0, 5).map((entry) => ( -
- {entry.last_seen.split('T')[0]} - £{entry.price.toLocaleString()} -
- ))} -
-
- )} - - {/* Actions */} - +
+ + {/* Content below image */} +
+ {/* Price as dominant element */} +
+ + £{property.total_price.toLocaleString()} + {property.listing_type !== 'BUY' && ( + /mo + )} + + {priceIndicator && ( + + )} + {priceIndicator && ( + {priceIndicator.label} + )} +
+ + {/* Key metrics on one line */} +
+ {property.rooms}bed + · + {property.qm} m² + · + £{property.qmprice}/m² +
+ + {/* Location */} + {property.city && ( +
{property.city}
+ )} + + {/* POI travel times */} + {allPOIs && allPOIs.length > 0 ? ( + + ) : property.poi_distances && property.poi_distances.length > 0 ? ( + + ) : null} + + {/* Agency + freshness */} +
+ {property.agency} + · + {lastSeenDays}d ago +
); diff --git a/frontend/src/components/PropertyCardCompact.tsx b/frontend/src/components/PropertyCardCompact.tsx index 28e945c..685734a 100644 --- a/frontend/src/components/PropertyCardCompact.tsx +++ b/frontend/src/components/PropertyCardCompact.tsx @@ -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 (
- {/* Thumbnail */} -
+ {/* Thumbnail with 4:3 aspect ratio */} +
{property.photo_thumbnail && ( {/* Details */} -
-
-
+
+ {/* Price bold */} +
+ £{property.total_price.toLocaleString()} {property.listing_type !== 'BUY' && ( /mo )} -
+ {priceIndicator && ( - - {priceIndicator.label} - + )}
-
+ {/* Beds and size */} +
- {property.rooms} + {property.rooms} bed - - - {property.qm} m² - - £{property.qmprice}/m² + · + {property.qm} m²
+ + {/* Location */} + {property.city && ( +
+ + {property.city} +
+ )}
); From ab02fb120c6b108bc2d92a4440b0e6b0fde33018 Mon Sep 17 00:00:00 2001 From: Viktor Barzin Date: Sat, 28 Feb 2026 16:21:20 +0000 Subject: [PATCH 09/12] feat: redesign listing detail with tabbed sections and larger drawer --- frontend/src/components/ListingDetail.tsx | 343 +++++++++++------- .../src/components/ListingDetailSheet.tsx | 2 +- 2 files changed, 209 insertions(+), 136 deletions(-) diff --git a/frontend/src/components/ListingDetail.tsx b/frontend/src/components/ListingDetail.tsx index 3f04435..0db55c7 100644 --- a/frontend/src/components/ListingDetail.tsx +++ b/frontend/src/components/ListingDetail.tsx @@ -1,5 +1,6 @@ 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'; import { formatDate, formatDuration } from '@/utils/format'; @@ -19,28 +20,75 @@ function TravelModeIcon({ mode }: { mode: string }) { } } +function TravelModeLabel({ mode }: { mode: string }) { + switch (mode) { + case 'WALK': return 'Walk'; + case 'BICYCLE': return 'Cycle'; + case 'TRANSIT': return 'Transit'; + default: return mode; + } +} + export function ListingDetail({ detail, onDecide, onClearDecision }: ListingDetailProps) { const allPhotos = [ ...detail.photos, ...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>(); + 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 (
- {/* Photo carousel */} + {/* Photo carousel - always visible above tabs */}
- {/* Price + address */} + {/* Price header */}
-
+
£{detail.price.toLocaleString()} {detail.listing_type !== 'BUY' && ( /mo )}
+ {/* Key metrics */} +
+ + + {detail.number_of_bedrooms} bed + + · + + + {detail.square_meters ?? '\u2014'} m² + + {pricePerSqm && ( + <> + · + + + £{pricePerSqm}/m² + + + )} +
{detail.display_address && (
@@ -51,8 +99,8 @@ export function ListingDetail({ detail, onDecide, onClearDecision }: ListingDeta
- {/* Like/Dislike buttons */} -
+ {/* Action buttons */} +
+
- {/* Key stats */} -
-
- - {detail.number_of_bedrooms} beds -
-
- - {detail.square_meters ?? '\u2014'} -
-
- - {detail.square_meters ? `£${Math.round(detail.price / detail.square_meters)}` : '\u2014'}/m² -
-
+ {/* Tabbed sections */} + + + Overview + {hasTravel && Travel} + {hasPriceHistory && Price} + {hasDetails && Details} + - {/* Key features */} - {detail.key_features.length > 0 && ( -
-

Key Features

-
    - {detail.key_features.map((f, i) => ( -
  • {f}
  • - ))} -
-
- )} + {/* Overview tab */} + + {detail.key_features.length > 0 && ( +
+

Key Features

+
    + {detail.key_features.map((f, i) => ( +
  • {f}
  • + ))} +
+
+ )} - {/* Description */} - {detail.description && ( -
-

Description

-

{detail.description}

-
- )} + {detail.description && ( +
+

Description

+

{detail.description}

+
+ )} - {/* Property details grid */} -
-

Details

-
- {detail.property_sub_type && ( -
- - {detail.property_sub_type} -
- )} - {detail.furnish_type && ( -
- Furnishing: - {detail.furnish_type} -
- )} - {detail.council_tax_band && ( -
- Council Tax: - Band {detail.council_tax_band} -
- )} - {detail.available_from && ( -
- - Available {formatDate(detail.available_from)} -
- )} - {detail.service_charge != null && ( -
- Service charge: - £{detail.service_charge.toLocaleString()} -
- )} - {detail.lease_left != null && ( -
- Lease: - {detail.lease_left} years -
- )} -
-
- - {/* Floorplans */} - {detail.floorplans.length > 0 && ( -
-

Floorplans

-
- {detail.floorplans.map((fp, i) => ( - {fp.caption - ))} -
-
- )} - - {/* Price history */} - {detail.price_history.length > 1 && ( -
-

Price History

-
- {detail.price_history.map((entry) => ( -
- {entry.last_seen.split('T')[0]} - £{entry.price.toLocaleString()} + {/* Floorplans */} + {detail.floorplans.length > 0 && ( +
+

Floorplans

+
+ {detail.floorplans.map((fp, i) => ( + {fp.caption + ))}
- ))} -
-
- )} +
+ )} - {/* POI distances */} - {detail.poi_distances.length > 0 && ( -
-

Travel Times

-
- {detail.poi_distances.map((d: POIDistanceInfo) => ( -
- {d.poi_name}: - - {formatDuration(d.duration_seconds)} -
- ))} -
-
- )} + {/* Agency */} + {detail.agency && ( +
+ + {detail.agency} +
+ )} - {/* Agency */} - {detail.agency && ( -
- - {detail.agency} -
- )} + {!hasOverview && !detail.floorplans.length && !detail.agency && ( +

No overview information available.

+ )} + - {/* External link */} - + {/* Travel tab */} + {hasTravel && ( + +
+ + + + + + + + + + + {Array.from(poiGroups.entries()).map(([poiName, modes]) => ( + + + {(['WALK', 'BICYCLE', 'TRANSIT'] as const).map(mode => { + const d = modes.get(mode); + return ( + + ); + })} + + ))} + +
Destination + + + + + +
{poiName} + {d ? formatDuration(d.duration_seconds) : '\u2014'} +
+
+
+ )} + + {/* Price History tab */} + {hasPriceHistory && ( + +
+ {detail.price_history.map((entry) => ( +
+ {entry.last_seen.split('T')[0]} + £{entry.price.toLocaleString()} +
+ ))} +
+
+ )} + + {/* Details tab */} + {hasDetails && ( + +
+ {detail.property_sub_type && ( +
+
Property Type
+
+ + {detail.property_sub_type} +
+
+ )} + {detail.furnish_type && ( +
+
Furnishing
+
{detail.furnish_type}
+
+ )} + {detail.council_tax_band && ( +
+
Council Tax
+
Band {detail.council_tax_band}
+
+ )} + {detail.lease_left != null && ( +
+
Lease Remaining
+
{detail.lease_left} years
+
+ )} + {detail.service_charge != null && ( +
+
Service Charge
+
£{detail.service_charge.toLocaleString()}
+
+ )} + {detail.available_from && ( +
+
Available From
+
+ + {formatDate(detail.available_from)} +
+
+ )} +
+
+ )} +
); diff --git a/frontend/src/components/ListingDetailSheet.tsx b/frontend/src/components/ListingDetailSheet.tsx index 53734b1..ef63ec4 100644 --- a/frontend/src/components/ListingDetailSheet.tsx +++ b/frontend/src/components/ListingDetailSheet.tsx @@ -45,7 +45,7 @@ export function ListingDetailSheet({ > - + Listing Details
From dea930dbc4cab947f9ecaefe7d5fe088d4dab844 Mon Sep 17 00:00:00 2001 From: Viktor Barzin Date: Sat, 28 Feb 2026 16:23:36 +0000 Subject: [PATCH 10/12] fix: remove unused imports flagged by TypeScript --- frontend/src/components/FilterBar.tsx | 11 ----------- frontend/src/components/ListingDetail.tsx | 9 --------- frontend/src/components/PropertyCard.tsx | 2 +- frontend/src/components/TaskIndicator.tsx | 2 +- frontend/tsconfig.app.tsbuildinfo | 2 +- 5 files changed, 3 insertions(+), 23 deletions(-) diff --git a/frontend/src/components/FilterBar.tsx b/frontend/src/components/FilterBar.tsx index 74581e8..234f2ed 100644 --- a/frontend/src/components/FilterBar.tsx +++ b/frontend/src/components/FilterBar.tsx @@ -40,11 +40,6 @@ const formSchema = z.object({ type FormValues = z.infer; -const PRICE_BOUNDS = { - [ListingType.RENT]: { min: 0, max: 10000, step: 50 }, - [ListingType.BUY]: { min: 0, max: 2000000, step: 10000 }, -} as const; - // ── Props ── interface FilterBarProps { onSubmit: (action: 'fetch-data' | 'visualize', parameters: ParameterValues) => void; @@ -94,7 +89,6 @@ export function FilterBar({ onPoiTravelFiltersChange, listingType, onListingTypeChange, - poiPickerActive, onPoiPickerActiveChange, pickedPoiLocation, onPickedPoiLocationChange, @@ -161,11 +155,6 @@ export function FilterBar({ [form, onSubmit, currentMetric, selectedFurnishTypes], ); - /** Public getter so App can read current form values (e.g. for FilterChips) */ - const getValues = useCallback((): ParameterValues => { - return readFormParams(form.getValues(), currentMetric, selectedFurnishTypes); - }, [form, currentMetric, selectedFurnishTypes]); - const toggleFurnishType = (type: FurnishType) => { setSelectedFurnishTypes((prev) => prev.includes(type) ? prev.filter((t) => t !== type) : [...prev, type], diff --git a/frontend/src/components/ListingDetail.tsx b/frontend/src/components/ListingDetail.tsx index 0db55c7..4a5d2d0 100644 --- a/frontend/src/components/ListingDetail.tsx +++ b/frontend/src/components/ListingDetail.tsx @@ -11,15 +11,6 @@ interface ListingDetailProps { onClearDecision: () => void; } -function TravelModeIcon({ mode }: { mode: string }) { - switch (mode) { - case 'WALK': return ; - case 'BICYCLE': return ; - case 'TRANSIT': return ; - default: return null; - } -} - function TravelModeLabel({ mode }: { mode: string }) { switch (mode) { case 'WALK': return 'Walk'; diff --git a/frontend/src/components/PropertyCard.tsx b/frontend/src/components/PropertyCard.tsx index 7c0a5c6..dd4d9bf 100644 --- a/frontend/src/components/PropertyCard.tsx +++ b/frontend/src/components/PropertyCard.tsx @@ -1,6 +1,6 @@ import { useState, useCallback, useEffect } from 'react'; import useEmblaCarousel from 'embla-carousel-react'; -import { ExternalLink, Heart, Bed, Maximize2, Clock, Footprints, Bike, Train } from 'lucide-react'; +import { ExternalLink, Footprints, Bike, Train } from 'lucide-react'; import type { PropertyProperties, POIDistanceInfo, POI } from '@/types'; import { formatDuration } from '@/utils/format'; diff --git a/frontend/src/components/TaskIndicator.tsx b/frontend/src/components/TaskIndicator.tsx index 542bc2e..b5afead 100644 --- a/frontend/src/components/TaskIndicator.tsx +++ b/frontend/src/components/TaskIndicator.tsx @@ -1,4 +1,4 @@ -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'; diff --git a/frontend/tsconfig.app.tsbuildinfo b/frontend/tsconfig.app.tsbuildinfo index 700bd10..ae7d696 100644 --- a/frontend/tsconfig.app.tsbuildinfo +++ b/frontend/tsconfig.app.tsbuildinfo @@ -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"} \ No newline at end of file +{"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"} \ No newline at end of file From 69ce4583081d35a7c88ac9db0f2b1421adf0e4d2 Mon Sep 17 00:00:00 2001 From: Viktor Barzin Date: Sat, 28 Feb 2026 16:37:33 +0000 Subject: [PATCH 11/12] feat: add dev auth bypass for UI testing without backend Guarded by VITE_DEV_BYPASS_AUTH env var + import.meta.env.DEV check. Vite tree-shakes the DEV branch in production builds. The .env.development.local file is gitignored (**.env pattern). Includes mock listing data to preview property cards without API. --- frontend/src/App.tsx | 43 +++++++++++++++++++++++++++++++++++++++---- 1 file changed, 39 insertions(+), 4 deletions(-) diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 527d6ba..1568282 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -38,9 +38,44 @@ import { ListingDetailSheet } from './components/ListingDetailSheet'; import { FilterPanel } from './components/FilterPanel'; function AppContent() { - const [listingData, setListingData] = useState(null); - const [user, setUser] = useState(null); - const [queryParameters, setQueryParameters] = useState(null); + const DEV_BYPASS_AUTH = import.meta.env.DEV && import.meta.env.VITE_DEV_BYPASS_AUTH === 'true'; + + const [listingData, setListingData] = useState( + DEV_BYPASS_AUTH + ? { + type: 'FeatureCollection', + features: Array.from({ length: 12 }, (_, i): PropertyFeature => ({ + type: 'Feature', + geometry: { type: 'Point', coordinates: [-0.08 + (i % 4) * 0.03, 51.50 + Math.floor(i / 4) * 0.02] }, + properties: { + id: 100000000 + i, + url: `https://www.rightmove.co.uk/properties/${100000000 + i}`, + city: ['Shoreditch', 'Hackney', 'Islington', 'Camden', 'Dalston', 'Bethnal Green', 'Mile End', 'Bow', 'Stratford', 'Whitechapel', 'Bermondsey', 'Peckham'][i], + country: 'United Kingdom', + qm: 40 + i * 8, + qmprice: Math.round((1800 + i * 200) / (40 + i * 8)), + total_price: 1800 + i * 200, + rooms: 1 + (i % 4), + agency: ['Foxtons', 'Savills', 'KFH', 'Dexters'][i % 4], + available_from: new Date(Date.now() + i * 7 * 86400000).toISOString(), + last_seen: new Date(Date.now() - i * 2 * 86400000).toISOString(), + photo_thumbnail: `https://picsum.photos/seed/prop${i}/400/300`, + photos: [`https://picsum.photos/seed/prop${i}a/800/600`, `https://picsum.photos/seed/prop${i}b/800/600`, `https://picsum.photos/seed/prop${i}c/800/600`], + price_history: [{ id: i, price: 1800 + i * 200, last_seen: new Date().toISOString() }], + listing_type: 'RENT', + }, + })), + } + : null + ); + const [user, setUser] = useState( + DEV_BYPASS_AUTH + ? { sub: 'dev-user', email: 'dev@localhost', name: 'Dev User', accessToken: 'dev-token', provider: 'passkey' as const } + : null + ); + const [queryParameters, setQueryParameters] = useState( + DEV_BYPASS_AUTH ? { ...DEFAULT_FILTER_VALUES, available_from: new Date() } : null + ); const [submitError, setSubmitError] = useState(null); const [alertDialogIsOpen, setAlertDialogIsOpen] = useState(false); const [isLoading, setIsLoading] = useState(false); @@ -311,7 +346,7 @@ function AppContent() { // 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; From 3016cbb047c1867b0a27feee61170352ac19c455 Mon Sep 17 00:00:00 2001 From: Viktor Barzin Date: Sat, 28 Feb 2026 16:47:52 +0000 Subject: [PATCH 12/12] fix: use real Rightmove listings for dev mock data Replace picsum.photos placeholders with actual Rightmove listing data fetched from the API - real property photos, prices, addresses, and coordinates. Skip auto-load API call in dev bypass mode. --- frontend/src/App.tsx | 38 +++++++++++++++++--------------------- 1 file changed, 17 insertions(+), 21 deletions(-) diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 1568282..367df27 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -44,27 +44,23 @@ function AppContent() { DEV_BYPASS_AUTH ? { type: 'FeatureCollection', - features: Array.from({ length: 12 }, (_, i): PropertyFeature => ({ - type: 'Feature', - geometry: { type: 'Point', coordinates: [-0.08 + (i % 4) * 0.03, 51.50 + Math.floor(i / 4) * 0.02] }, - properties: { - id: 100000000 + i, - url: `https://www.rightmove.co.uk/properties/${100000000 + i}`, - city: ['Shoreditch', 'Hackney', 'Islington', 'Camden', 'Dalston', 'Bethnal Green', 'Mile End', 'Bow', 'Stratford', 'Whitechapel', 'Bermondsey', 'Peckham'][i], - country: 'United Kingdom', - qm: 40 + i * 8, - qmprice: Math.round((1800 + i * 200) / (40 + i * 8)), - total_price: 1800 + i * 200, - rooms: 1 + (i % 4), - agency: ['Foxtons', 'Savills', 'KFH', 'Dexters'][i % 4], - available_from: new Date(Date.now() + i * 7 * 86400000).toISOString(), - last_seen: new Date(Date.now() - i * 2 * 86400000).toISOString(), - photo_thumbnail: `https://picsum.photos/seed/prop${i}/400/300`, - photos: [`https://picsum.photos/seed/prop${i}a/800/600`, `https://picsum.photos/seed/prop${i}b/800/600`, `https://picsum.photos/seed/prop${i}c/800/600`], - price_history: [{ id: i, price: 1800 + i * 200, last_seen: new Date().toISOString() }], - listing_type: 'RENT', - }, - })), + 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 );