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