From de47e2cca88f17d7c783738659bb404cb8111440 Mon Sep 17 00:00:00 2001 From: Viktor Barzin Date: Sat, 28 Feb 2026 16:07:14 +0000 Subject: [PATCH] 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( - + + + , )