This commit is contained in:
Andras Schmelczer 2026-07-12 15:03:33 +01:00
parent 982e0cc89c
commit cfaf58dfba
44 changed files with 793 additions and 201 deletions

View file

@ -39,6 +39,7 @@ import type { BasemapId } from '../../lib/basemaps';
import { useLicense } from '../../hooks/useLicense';
import { stateToParams } from '../../lib/url-state';
import { DEFAULT_COLOR_OPACITY, normalizeColorOpacity } from '../../lib/color-opacity';
import { DEFAULT_LISTINGS_MODE, filterListingsByMode, type ListingsMode } from '../../lib/listings';
import { groupFeaturesByCategory } from '../../lib/features';
import {
getActiveAmenityFilterFeatureNames,
@ -47,6 +48,7 @@ import {
import {
AreaPane,
Filters,
ListingPane,
OverlayPane,
POIPane,
PropertiesPane,
@ -91,6 +93,7 @@ export default function MapPage({
initialCrimeTypes,
initialBasemap = 'standard',
initialColorOpacity = DEFAULT_COLOR_OPACITY,
initialListingsMode = DEFAULT_LISTINGS_MODE,
initialTab,
initialLoading,
theme,
@ -134,13 +137,14 @@ export default function MapPage({
);
const [leftPaneWidth, leftPaneHandlers] = usePaneResize(384, 200, 0.45, 'left');
const [rightPaneWidth, rightPaneHandlers] = usePaneResize(384, 200, 0.45, 'right');
// The POI and overlay panes are mutually exclusive, so a single state tracks
// which one (if any) is open.
const [openMapPane, setOpenMapPane] = useState<'poi' | 'overlay' | null>(null);
// The POI, overlay and listings panes are mutually exclusive, so a single state
// tracks which one (if any) is open.
const [openMapPane, setOpenMapPane] = useState<'poi' | 'overlay' | 'listings' | null>(null);
const poiPaneOpen = openMapPane === 'poi';
const overlayPaneOpen = openMapPane === 'overlay';
const listingsPaneOpen = openMapPane === 'listings';
const [currentLocation, setCurrentLocation] = useState<{ lat: number; lng: number } | null>(null);
const [listingsToggleEnabled, setListingsToggleEnabled] = useState(true);
const [listingsMode, setListingsMode] = useState<ListingsMode>(initialListingsMode);
const [pendingInitialPostcode, setPendingInitialPostcode] = useState<string | null>(
initialPostcode ?? null
);
@ -151,7 +155,7 @@ export default function MapPage({
const isLoggedIn = !!user;
// Screenshot/OG renders are non-interactive previews of a (possibly shared) search.
// They go through the server's internal-request exemption, so they must render the
// full filter set rather than the capped demo view — otherwise a 45-filter shared
// full filter set rather than the capped demo view. Otherwise a 45-filter shared
// search is silently trimmed to the anonymous cap (3) in the generated preview image.
const filtersUnlimited =
screenshotMode === true ||
@ -162,7 +166,7 @@ export default function MapPage({
const filterLimit = filtersUnlimited ? null : effectiveFilterCap;
// On a shared link, anonymous visitors may view and adjust the shared filters but
// not add new ones. Keyed off login state (not paid status) so the "create a free
// account to build your own" upsell isn't a dead-end once registered, the user can
// account to build your own" upsell isn't a dead-end: once registered, the user can
// add filters up to their normal cap right there on the shared search. The
// `!filtersUnlimited` term also excludes the non-interactive screenshot/OG render
// (treated as unlimited above), which must show the shared filters in full.
@ -192,7 +196,7 @@ export default function MapPage({
}, []);
// Pop the upgrade modal once when the shared/bookmarked search carried more filters
// than the viewer's tier allows so trimmed filters are surfaced, not silently
// than the viewer's tier allows, so trimmed filters are surfaced, not silently
// dropped. Fires when the signal first turns true (which may be after features finish
// loading), and only once so dismissing it sticks.
const overCapPopupShownRef = useRef(false);
@ -308,7 +312,7 @@ export default function MapPage({
// On a shared link, non-paying users may adjust the shared filters but not build
// a new set. The AI box is a "build me a search" tool (and it also drives
// travel-time entries, which bypass the per-add lock), so block it outright here
// — mirroring handleAddTravelTimeEntry — and surface the upsell instead.
// (mirroring handleAddTravelTimeEntry) and surface the upsell instead.
if (lockFilterAdds) {
handleFilterLimitReached();
return;
@ -362,7 +366,7 @@ export default function MapPage({
}))
);
// Move the camera to where the matches actually are flying to the
// Move the camera to where the matches actually are: flying to the
// travel-time anchor often lands on a viewport with zero matches.
if (result.matchBounds) {
const target = boundsToCenterZoom(result.matchBounds);
@ -410,7 +414,7 @@ export default function MapPage({
);
// Travel-time entries share the non-paying user's filter cap, so block adding one
// when the combined feature + travel count is already at the limit and block it
// when the combined feature + travel count is already at the limit, and block it
// outright on a shared link, where non-paying users can't add filters at all.
const handleAddTravelTimeEntry = useCallback(
(mode: TransportMode) => {
@ -584,7 +588,7 @@ export default function MapPage({
const fetchedPois = usePOIData(mapData.bounds, selectedPOICategories);
// Disabling POIs (clearing every category) must remove all POI cards/markers
// immediately, not on the next fetch tick — gate on the selection itself so a
// immediately, not on the next fetch tick. Gate on the selection itself so a
// stale fetch result can never keep cards on screen.
const pois: POI[] = selectedPOICategories.size > 0 ? fetchedPois : EMPTY_POIS;
const overlaysZoomedIn = (mapData.currentView?.zoom ?? 0) >= POSTCODE_ZOOM_THRESHOLD;
@ -597,7 +601,7 @@ export default function MapPage({
// default). Only users with the flag get the toggle button and the fetch;
// the backing API independently rejects anyone else with 403.
const canSeeListings = user?.canSeeListings ?? false;
const actualListingsEnabled = canSeeListings && listingsToggleEnabled;
const actualListingsEnabled = canSeeListings && listingsMode !== 'none';
const { listings: actualListings, loading: actualListingsLoading } = useActualListings(
actualListingsEnabled ? mapData.visibleBounds : null,
{
@ -606,10 +610,28 @@ export default function MapPage({
shareCode,
}
);
const visibleActualListings = actualListingsEnabled ? actualListings : EMPTY_ACTUAL_LISTINGS;
const handleToggleActualListings = useCallback(() => {
if (!canSeeListings) return;
setListingsToggleEnabled((enabled) => !enabled);
// The fetch returns every listing in view; the new/non-new split is decided
// client-side from each listing's Rightmove channel URL.
const visibleActualListings = useMemo(
() =>
actualListingsEnabled
? filterListingsByMode(actualListings, listingsMode)
: EMPTY_ACTUAL_LISTINGS,
[actualListingsEnabled, actualListings, listingsMode]
);
const handleListingsModeChange = useCallback(
(mode: ListingsMode) => {
if (!canSeeListings) return;
setListingsMode(mode);
},
[canSeeListings]
);
// Losing listings access (e.g. logging out) hides the button that dismisses the
// pane, so close the pane here to avoid leaving an orphaned card on screen.
useEffect(() => {
if (!canSeeListings) {
setOpenMapPane((pane) => (pane === 'listings' ? null : pane));
}
}, [canSeeListings]);
const selectedPostcodeParam =
selectedHexagon?.type === 'postcode'
@ -628,7 +650,8 @@ export default function MapPage({
basemap,
crimeTypes,
selectedPostcodeParam,
colorOpacity
colorOpacity,
listingsMode
);
useInitialMapPageView(mapData, initialViewState, initialTab, setRightPaneTab);
@ -701,7 +724,8 @@ export default function MapPage({
basemap,
crimeTypes,
selectedPostcodeParam,
colorOpacity
colorOpacity,
listingsMode
).toString(),
[
activeOverlays,
@ -711,6 +735,7 @@ export default function MapPage({
entries,
features,
filters,
listingsMode,
rightPaneTab,
selectedPOICategories,
selectedPostcodeParam,
@ -756,7 +781,7 @@ export default function MapPage({
// Clear the filter-cap upsell once it's no longer relevant: the user became
// licensed/admin, or dropped back under the cap by removing filters. On a shared
// link adds are blocked regardless of count, so don't auto-clear there the user
// link adds are blocked regardless of count, so don't auto-clear there; the user
// dismisses it themselves. Prevents a stale flag from resurfacing spuriously.
useEffect(() => {
if (
@ -768,8 +793,8 @@ export default function MapPage({
}, [filtersUnlimited, lockFilterAdds, effectiveFilterCap, filters, entries]);
// When the cap tightens (logout / session invalidated / license lapse), useFilters
// re-clamps the feature filters, but travel-time entries which count toward the same
// server cap live here. Trim them to the cap too so the combined request can't exceed
// re-clamps the feature filters, but travel-time entries, which count toward the same
// server cap, live here. Trim them to the cap too so the combined request can't exceed
// it and blank the map. Read via ref so this only fires on a cap change, not on every
// travel edit (those are already gated by handleAddTravelTimeEntry).
const entriesRef = useRef(entries);
@ -796,6 +821,12 @@ export default function MapPage({
const handleCloseOverlayPane = useCallback(() => {
setOpenMapPane((pane) => (pane === 'overlay' ? null : pane));
}, []);
const handleToggleListingsPane = useCallback(() => {
setOpenMapPane((pane) => (pane === 'listings' ? null : 'listings'));
}, []);
const handleCloseListingsPane = useCallback(() => {
setOpenMapPane((pane) => (pane === 'listings' ? null : pane));
}, []);
const handleAreaTabClick = useCallback(() => {
setRightPaneTab('area');
}, [setRightPaneTab]);
@ -910,6 +941,27 @@ export default function MapPage({
[activeOverlays, basemap, colorOpacity, crimeTypes, handleCloseOverlayPane, overlaysZoomedIn]
);
const listingsPane = useMemo(
() => (
<Suspense fallback={<PaneFallback />}>
<ListingPane
mode={listingsMode}
onModeChange={handleListingsModeChange}
count={visibleActualListings.length}
loading={actualListingsLoading}
onClose={handleCloseListingsPane}
/>
</Suspense>
),
[
listingsMode,
handleListingsModeChange,
visibleActualListings.length,
actualListingsLoading,
handleCloseListingsPane,
]
);
const filtersPane = useMemo(
() => (
<Suspense fallback={<PaneFallback />}>
@ -1155,7 +1207,9 @@ export default function MapPage({
actualListings={visibleActualListings}
actualListingsEnabled={actualListingsEnabled}
actualListingsLoading={actualListingsLoading}
onToggleActualListings={canSeeListings ? handleToggleActualListings : undefined}
onToggleListingsPane={canSeeListings ? handleToggleListingsPane : undefined}
listingsPaneOpen={listingsPaneOpen}
listingsPane={listingsPane}
travelTimeEntries={entries}
bottomScreenInset={mobileBottomSheetHeight}
onBottomSheetCoveredHeightChange={setMobileBottomSheetHeight}
@ -1216,7 +1270,9 @@ export default function MapPage({
actualListings={visibleActualListings}
actualListingsEnabled={actualListingsEnabled}
actualListingsLoading={actualListingsLoading}
onToggleActualListings={canSeeListings ? handleToggleActualListings : undefined}
onToggleListingsPane={canSeeListings ? handleToggleListingsPane : undefined}
listingsPaneOpen={listingsPaneOpen}
listingsPane={listingsPane}
travelTimeEntries={entries}
densityLabel={densityLabel}
totalCount={filterCounts.total ?? undefined}