lgtm
This commit is contained in:
parent
982e0cc89c
commit
cfaf58dfba
44 changed files with 793 additions and 201 deletions
|
|
@ -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 4–5-filter shared
|
||||
// full filter set rather than the capped demo view. Otherwise a 4–5-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}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue