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)
This commit is contained in:
Viktor Barzin 2026-02-28 16:07:14 +00:00
parent 676fad520c
commit de47e2cca8
No known key found for this signature in database
GPG key ID: 0EB088298288D958
3 changed files with 199 additions and 9 deletions

View file

@ -1,4 +1,5 @@
import { useEffect, useState, useRef, useCallback, useMemo } from 'react';
import { Routes, Route, Navigate } from 'react-router-dom';
import './App.css';
import { getUser } from './auth/authService';
import { getStoredPasskeyUser } from './auth/passkeyService';
@ -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<GeoJSONFeatureCollection | null>(null);
const [user, setUser] = useState<AuthUser | null>(null);
const [queryParameters, setQueryParameters] = useState<ParameterValues | null>(null);
const [submitError, setSubmitError] = useState<string | null>(null);
const [alertDialogIsOpen, setAlertDialogIsOpen] = useState(false);
const [isLoading, setIsLoading] = useState(false);
const [viewMode, setViewMode] = useState<ViewMode>('map');
const { viewMode, setViewMode } = useFilterParams();
const [mobileFilterOpen, setMobileFilterOpen] = useState(false);
const [highlightedProperty, setHighlightedProperty] = useState<string | null>(null);
const [streamingProgress, setStreamingProgress] = useState<StreamingProgress | null>(null);
@ -89,11 +91,6 @@ function App() {
// Ref to abort in-flight streaming requests
const abortControllerRef = useRef<AbortController | null>(null);
// Check if this is the callback route - render dedicated component
if (window.location.pathname === '/callback') {
return <AuthCallback />;
}
useEffect(() => {
// Check passkey user first, then fall back to OIDC
const passkeyUser = getStoredPasskeyUser();
@ -706,4 +703,22 @@ function App() {
);
}
/** Top-level App component with React Router routes */
function App() {
return (
<Routes>
<Route path="/callback" element={<AuthCallback />} />
{/* All view modes share the same AppContent; viewMode is derived from pathname */}
<Route path="/map" element={<AppContent />} />
<Route path="/list" element={<AppContent />} />
<Route path="/split" element={<AppContent />} />
<Route path="/saved" element={<AppContent />} />
{/* Default: root maps to map view */}
<Route path="/" element={<AppContent />} />
{/* Catch-all: redirect unknown paths to root */}
<Route path="*" element={<Navigate to="/" replace />} />
</Routes>
);
}
export default App;

View file

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

View file

@ -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(
<StrictMode>
<AuthProvider {...oidcConfig}>
<App />
<BrowserRouter>
<App />
</BrowserRouter>
</AuthProvider>
</StrictMode>,
)