1315 lines
46 KiB
TypeScript
1315 lines
46 KiB
TypeScript
import { Suspense, useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||
import { Trans, useTranslation } from 'react-i18next';
|
||
|
||
import type { ActualListing, POI, PostcodeGeometry } from '../../types';
|
||
import type { SearchedLocation } from './LocationSearch';
|
||
import { useMapData } from '../../hooks/useMapData';
|
||
import { usePOIData } from '../../hooks/usePOIData';
|
||
import { useActualListings } from '../../hooks/useActualListings';
|
||
import { buildTravelParam } from '../../lib/travel-params';
|
||
import { useFilters, countEffectiveFilters } from '../../hooks/useFilters';
|
||
import { useHexagonSelection } from '../../hooks/useHexagonSelection';
|
||
import { usePaneResize } from '../../hooks/usePaneResize';
|
||
import { useCollapsibleGroups } from '../../hooks/useCollapsibleGroups';
|
||
import { useAiFilters } from '../../hooks/useAiFilters';
|
||
import { useUrlSync } from '../../hooks/useUrlSync';
|
||
import { useTutorial } from '../../hooks/useTutorial';
|
||
import { getTutorialStyles } from '../../lib/tutorial-styles';
|
||
import {
|
||
MAX_TRAVEL_MINUTES,
|
||
parseServerMode,
|
||
resolveTransitVariant,
|
||
travelFieldKey,
|
||
useTravelTime,
|
||
type TransportMode,
|
||
} from '../../hooks/useTravelTime';
|
||
import { apiUrl, authHeaders, buildFilterString } from '../../lib/api';
|
||
import { useFilterCounts } from '../../hooks/useFilterCounts';
|
||
import { trackEvent } from '../../lib/analytics';
|
||
import {
|
||
DEMO_MAX_FILTERS,
|
||
REGISTERED_MAX_FILTERS,
|
||
INITIAL_VIEW_STATE,
|
||
POSTCODE_ZOOM_THRESHOLD,
|
||
} from '../../lib/consts';
|
||
import { boundsToCenterZoom } from '../../lib/fit-bounds';
|
||
import type { OverlayId } from '../../lib/overlays';
|
||
import { CRIME_TYPE_VALUES } from '../../lib/crime-types';
|
||
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,
|
||
isPoiFilterFeatureName,
|
||
} from '../../lib/poi-distance-filter';
|
||
import {
|
||
AreaPane,
|
||
Filters,
|
||
ListingPane,
|
||
OverlayPane,
|
||
POIPane,
|
||
PropertiesPane,
|
||
UpgradeModal,
|
||
} from './map-page/lazyComponents';
|
||
import { PaneFallback } from './map-page/Fallbacks';
|
||
import { DesktopMapPage } from './map-page/DesktopMapPage';
|
||
import { MobileMapPage } from './map-page/MobileMapPage';
|
||
import { ScreenshotMapPage } from './map-page/ScreenshotMapPage';
|
||
import { ExportToast } from './map-page/Toasts';
|
||
import { MobileMapLegend } from './map-page/MobileMapLegend';
|
||
import { useExportController } from './map-page/useExportController';
|
||
import {
|
||
useHexagonLocation,
|
||
useJourneyDestination,
|
||
useMapViewFeature,
|
||
useMobileDensityRange,
|
||
useMobileLegendMeta,
|
||
} from './map-page/derivedState';
|
||
import {
|
||
useHorizontalSwipeNavigationGuard,
|
||
useInitialMapPageView,
|
||
useInitialPostcodeSelection,
|
||
useMobileBackNavigationGuard,
|
||
useScreenshotReadySignal,
|
||
} from './map-page/effects';
|
||
import { useMobileDrawer } from './map-page/useMobileDrawer';
|
||
import type { MapFlyTo, MapPageProps } from './map-page/types';
|
||
|
||
export type { ExportState } from './map-page/types';
|
||
|
||
const EMPTY_ACTUAL_LISTINGS: ActualListing[] = [];
|
||
const EMPTY_POIS: POI[] = [];
|
||
|
||
export default function MapPage({
|
||
features,
|
||
poiCategoryGroups,
|
||
initialFilters,
|
||
initialViewState,
|
||
initialPOICategories,
|
||
initialOverlays,
|
||
initialCrimeTypes,
|
||
initialBasemap = 'standard',
|
||
initialColorOpacity = DEFAULT_COLOR_OPACITY,
|
||
initialListingsMode = DEFAULT_LISTINGS_MODE,
|
||
initialTab,
|
||
initialLoading,
|
||
theme,
|
||
pendingInfoFeature,
|
||
onClearPendingInfoFeature,
|
||
onNavigateTo,
|
||
onExportStateChange,
|
||
onDashboardParamsChange,
|
||
onDashboardReadyChange,
|
||
screenshotMode,
|
||
ogMode,
|
||
isMobile = false,
|
||
initialTravelTime,
|
||
initialPostcode,
|
||
shareCode,
|
||
onSessionRejected,
|
||
user,
|
||
onLoginClick,
|
||
onRegisterClick,
|
||
onCheckoutRegisterClick,
|
||
deferTutorial = false,
|
||
onSaveSearch,
|
||
savingSearch,
|
||
editingSearch,
|
||
onCancelEdit,
|
||
onUpdateEdit,
|
||
onUpdateEditInPlace,
|
||
}: MapPageProps) {
|
||
const { t } = useTranslation();
|
||
const [selectedPOICategories, setSelectedPOICategories] =
|
||
useState<Set<string>>(initialPOICategories);
|
||
const [activeOverlays, setActiveOverlays] = useState<Set<OverlayId>>(
|
||
() => new Set(initialOverlays ?? [])
|
||
);
|
||
const [crimeTypes, setCrimeTypes] = useState<Set<string>>(
|
||
() => new Set(initialCrimeTypes ?? CRIME_TYPE_VALUES)
|
||
);
|
||
const [basemap, setBasemap] = useState<BasemapId>(initialBasemap);
|
||
const [colorOpacity, setColorOpacity] = useState(() =>
|
||
normalizeColorOpacity(initialColorOpacity)
|
||
);
|
||
const [leftPaneWidth, leftPaneHandlers] = usePaneResize(384, 200, 0.45, 'left');
|
||
const [rightPaneWidth, rightPaneHandlers] = usePaneResize(384, 200, 0.45, 'right');
|
||
// 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 [listingsMode, setListingsMode] = useState<ListingsMode>(initialListingsMode);
|
||
const [pendingInitialPostcode, setPendingInitialPostcode] = useState<string | null>(
|
||
initialPostcode ?? null
|
||
);
|
||
|
||
// Filter allowance scales with the account tier: anonymous → DEMO_MAX_FILTERS,
|
||
// registered free → REGISTERED_MAX_FILTERS, licensed/admin → unlimited. Hitting the
|
||
// cap opens the upgrade modal.
|
||
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
|
||
// search is silently trimmed to the anonymous cap (3) in the generated preview image.
|
||
const filtersUnlimited =
|
||
screenshotMode === true ||
|
||
ogMode === true ||
|
||
user?.subscription === 'licensed' ||
|
||
user?.isAdmin === true;
|
||
const effectiveFilterCap = isLoggedIn ? REGISTERED_MAX_FILTERS : DEMO_MAX_FILTERS;
|
||
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
|
||
// 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.
|
||
const lockFilterAdds = !isLoggedIn && !filtersUnlimited && !!shareCode;
|
||
// A shared/bookmarked link can encode more filters than the viewer's tier allows.
|
||
// The initial set is then trimmed to the cap (the server 400s the full request), so
|
||
// the viewer would silently see a broader result than was shared. Count the effective
|
||
// filters (folded + known to the backend, matching what the server counts) so stale
|
||
// or unknown keys don't raise a false alarm; recomputed once features finish loading.
|
||
const initialEffectiveFilterCount = useMemo(
|
||
() =>
|
||
countEffectiveFilters(initialFilters, features) + (initialTravelTime?.entries?.length ?? 0),
|
||
[initialFilters, features, initialTravelTime]
|
||
);
|
||
// Gate on features being loaded: until then countEffectiveFilters can't drop unknown
|
||
// keys and would over-count (firing the popup for a set that actually fits the cap).
|
||
const sharedSearchTrimmed =
|
||
features.length > 0 && !filtersUnlimited && initialEffectiveFilterCount > effectiveFilterCap;
|
||
// Which upsell the modal frames: a shared-link context (lock, or a trimmed shared
|
||
// search), or a plain filter-cap hit.
|
||
const upgradeReason: 'filters' | 'shared' =
|
||
lockFilterAdds || sharedSearchTrimmed ? 'shared' : 'filters';
|
||
const [filterLimitHit, setFilterLimitHit] = useState(false);
|
||
const handleFilterLimitReached = useCallback(() => {
|
||
trackEvent('Upgrade Modal Shown');
|
||
setFilterLimitHit(true);
|
||
}, []);
|
||
|
||
// 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
|
||
// 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);
|
||
useEffect(() => {
|
||
if (sharedSearchTrimmed && !overCapPopupShownRef.current) {
|
||
overCapPopupShownRef.current = true;
|
||
handleFilterLimitReached();
|
||
}
|
||
}, [sharedSearchTrimmed, handleFilterLimitReached]);
|
||
|
||
// Travel-time destinations count toward the same cap as feature filters (each one
|
||
// restricts which properties match). Cap an over-budget initial travel set (from a
|
||
// bookmarked/shared URL), preferring feature filters, so the first data request
|
||
// never exceeds the server cap.
|
||
const cappedInitialTravelTime = useMemo(() => {
|
||
const initialEntries = initialTravelTime?.entries ?? [];
|
||
if (filtersUnlimited || initialEntries.length === 0) return initialTravelTime;
|
||
const travelBudget = Math.max(0, effectiveFilterCap - Object.keys(initialFilters).length);
|
||
return initialEntries.length > travelBudget
|
||
? { ...initialTravelTime, entries: initialEntries.slice(0, travelBudget) }
|
||
: initialTravelTime;
|
||
}, [filtersUnlimited, effectiveFilterCap, initialFilters, initialTravelTime]);
|
||
|
||
const {
|
||
entries,
|
||
activeEntries,
|
||
handleAddEntry,
|
||
handleRemoveEntry,
|
||
handleSetDestination,
|
||
handleSetEntries,
|
||
handleTimeRangeChange,
|
||
handleToggleBest,
|
||
handleToggleNoChange,
|
||
handleToggleOneChange,
|
||
handleToggleNoBuses,
|
||
} = useTravelTime(cappedInitialTravelTime);
|
||
|
||
const {
|
||
filters,
|
||
activeFeature,
|
||
dragValue,
|
||
pinnedFeature,
|
||
enabledFeatures,
|
||
viewFeature,
|
||
viewSource,
|
||
filterRange,
|
||
handleAddFilter,
|
||
handleFilterChange,
|
||
handleRemoveFilter,
|
||
handleSetFilters,
|
||
handleDragStart,
|
||
handleDragChange,
|
||
handleDragEnd,
|
||
handleDragEndNoCommit,
|
||
handleTogglePin,
|
||
handleCancelPin,
|
||
} = useFilters({
|
||
initialFilters,
|
||
features,
|
||
filterLimit,
|
||
// Travel-time entries reserve slots from the same cap (see cappedInitialTravelTime).
|
||
reservedFilterSlots: filtersUnlimited ? 0 : entries.length,
|
||
lockAdds: lockFilterAdds,
|
||
onFilterLimitReached: handleFilterLimitReached,
|
||
});
|
||
|
||
const {
|
||
fetchAiFilters,
|
||
loading: aiFilterLoading,
|
||
error: aiFilterError,
|
||
errorType: aiFilterErrorType,
|
||
notes: aiFilterNotes,
|
||
summary: aiFilterSummary,
|
||
} = useAiFilters();
|
||
|
||
const mapFlyToRef = useRef<MapFlyTo | null>(null);
|
||
const areaPaneScrollTopRef = useRef(0);
|
||
const propertiesPaneScrollTopRef = useRef(0);
|
||
|
||
const {
|
||
mobileDrawerOpen,
|
||
mobileBottomSheetHeight,
|
||
setMobileBottomSheetHeight,
|
||
openMobileDrawer,
|
||
openMobileDrawerForLocationSearch,
|
||
clearPendingLocationSearchFlyTo,
|
||
queueCurrentLocationFlyTo,
|
||
handleMobileDrawerPanelRectChange,
|
||
handleMobileDrawerClose,
|
||
getMobileMapFlyToOptions,
|
||
} = useMobileDrawer(isMobile, mapFlyToRef);
|
||
|
||
const mapData = useMapData({
|
||
filters,
|
||
features,
|
||
viewFeature,
|
||
activeFeature,
|
||
pinnedFeature,
|
||
filterRange,
|
||
travelTimeEntries: entries,
|
||
shareCode,
|
||
onSessionRejected,
|
||
});
|
||
|
||
// Read the zoom through a ref inside handleAiFilterSubmit so panning/zooming
|
||
// doesn't recreate the callback (it sits in the Filters pane's dependency
|
||
// chain, which would otherwise re-render on every camera move).
|
||
const currentViewZoomRef = useRef<number | undefined>(undefined);
|
||
currentViewZoomRef.current = mapData.currentView?.zoom;
|
||
|
||
const handleAiFilterSubmit = useCallback(
|
||
async (query: string) => {
|
||
// 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.
|
||
if (lockFilterAdds) {
|
||
handleFilterLimitReached();
|
||
return;
|
||
}
|
||
const context = {
|
||
filters,
|
||
// Send the resolved variant so the AI sees the actual server mode in use,
|
||
// not just the UI-side base mode. Lets the model refine no-change/no-buses.
|
||
travelTime: activeEntries.map((entry) => ({
|
||
mode: resolveTransitVariant(entry),
|
||
label: entry.label,
|
||
min: entry.timeRange?.[0],
|
||
max: entry.timeRange?.[1],
|
||
})),
|
||
};
|
||
const hasContext = Object.keys(context.filters).length > 0 || context.travelTime.length > 0;
|
||
|
||
const result = await fetchAiFilters(query, hasContext ? context : undefined);
|
||
if (!result) return;
|
||
|
||
// Non-paying users share one cap across feature filters and travel-time
|
||
// filters, so clamp the AI's combined result (features first, then travel).
|
||
const cappedFilters = filtersUnlimited
|
||
? result.filters
|
||
: Object.fromEntries(Object.entries(result.filters).slice(0, effectiveFilterCap));
|
||
handleSetFilters(cappedFilters);
|
||
// Filter out variants the UI cannot represent (e.g. transit-one-change*)
|
||
// FIRST so the same filtered list drives both entry state and fly-to.
|
||
// Otherwise we'd fly to a destination for a mode the user can't see.
|
||
const representableAll = result.travelTimeFilters
|
||
.map((tt) => ({ tt, parsed: parseServerMode(tt.mode) }))
|
||
.filter((x): x is { tt: typeof x.tt; parsed: NonNullable<typeof x.parsed> } => !!x.parsed);
|
||
const travelBudget = filtersUnlimited
|
||
? representableAll.length
|
||
: Math.max(0, effectiveFilterCap - Object.keys(cappedFilters).length);
|
||
const representable = representableAll.slice(0, travelBudget);
|
||
|
||
handleSetEntries(
|
||
representable.map(({ tt, parsed }) => ({
|
||
mode: parsed.mode,
|
||
noChange: parsed.noChange,
|
||
oneChange: parsed.oneChange,
|
||
noBuses: parsed.noBuses,
|
||
slug: tt.slug,
|
||
label: tt.label,
|
||
timeRange: [tt.min ?? 0, Math.min(tt.max ?? MAX_TRAVEL_MINUTES, MAX_TRAVEL_MINUTES)] as [
|
||
number,
|
||
number,
|
||
],
|
||
useBest: false,
|
||
}))
|
||
);
|
||
|
||
// 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);
|
||
mapFlyToRef.current?.(target.lat, target.lng, target.zoom, getMobileMapFlyToOptions());
|
||
return;
|
||
}
|
||
|
||
const firstTravelTime = representable[0]?.tt;
|
||
if (!firstTravelTime?.slug) return;
|
||
|
||
try {
|
||
const res = await fetch(
|
||
apiUrl('travel-destinations', new URLSearchParams({ mode: firstTravelTime.mode })),
|
||
authHeaders({})
|
||
);
|
||
if (!res.ok) return;
|
||
|
||
const data: { destinations: { slug: string; lat: number; lon: number }[] } =
|
||
await res.json();
|
||
const destination = data.destinations.find((item) => item.slug === firstTravelTime.slug);
|
||
if (destination) {
|
||
mapFlyToRef.current?.(
|
||
destination.lat,
|
||
destination.lon,
|
||
currentViewZoomRef.current ?? INITIAL_VIEW_STATE.zoom,
|
||
getMobileMapFlyToOptions()
|
||
);
|
||
}
|
||
} catch {
|
||
// Filters are already applied; destination panning is non-critical.
|
||
}
|
||
},
|
||
[
|
||
activeEntries,
|
||
effectiveFilterCap,
|
||
fetchAiFilters,
|
||
filters,
|
||
filtersUnlimited,
|
||
lockFilterAdds,
|
||
handleFilterLimitReached,
|
||
getMobileMapFlyToOptions,
|
||
handleSetEntries,
|
||
handleSetFilters,
|
||
]
|
||
);
|
||
|
||
// 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
|
||
// outright on a shared link, where non-paying users can't add filters at all.
|
||
const handleAddTravelTimeEntry = useCallback(
|
||
(mode: TransportMode) => {
|
||
if (
|
||
!filtersUnlimited &&
|
||
(lockFilterAdds || Object.keys(filters).length + entries.length >= effectiveFilterCap)
|
||
) {
|
||
handleFilterLimitReached();
|
||
return;
|
||
}
|
||
handleAddEntry(mode);
|
||
},
|
||
[
|
||
filtersUnlimited,
|
||
lockFilterAdds,
|
||
effectiveFilterCap,
|
||
filters,
|
||
entries,
|
||
handleAddEntry,
|
||
handleFilterLimitReached,
|
||
]
|
||
);
|
||
|
||
const handleClearAll = useCallback(() => {
|
||
handleSetFilters({});
|
||
handleCancelPin();
|
||
handleSetEntries([]);
|
||
}, [handleSetFilters, handleCancelPin, handleSetEntries]);
|
||
|
||
const handleTravelTimeRemoveEntry = useCallback(
|
||
(index: number) => {
|
||
const entry = entries[index];
|
||
if (entry?.slug && pinnedFeature === travelFieldKey(entry)) {
|
||
handleCancelPin();
|
||
}
|
||
handleRemoveEntry(index);
|
||
},
|
||
[handleCancelPin, handleRemoveEntry, entries, pinnedFeature]
|
||
);
|
||
|
||
const handleTravelTimeDragEnd = useCallback(
|
||
(index: number) => {
|
||
const dragEndValue = handleDragEndNoCommit();
|
||
if (dragEndValue) handleTimeRangeChange(index, dragEndValue);
|
||
},
|
||
[handleDragEndNoCommit, handleTimeRangeChange]
|
||
);
|
||
|
||
const filterCounts = useFilterCounts(filters, features, mapData.bounds, entries, shareCode);
|
||
const license = useLicense();
|
||
const [isAreaGroupExpanded, toggleAreaGroup] = useCollapsibleGroups(true);
|
||
const activeFilterNames = useMemo(() => new Set(Object.keys(filters)), [filters]);
|
||
const activeAmenityFeatureNames = useMemo(
|
||
() => getActiveAmenityFilterFeatureNames(filters),
|
||
[filters]
|
||
);
|
||
const areaStatsFields = useMemo(
|
||
() =>
|
||
groupFeaturesByCategory(features)
|
||
.filter((group) => isAreaGroupExpanded(group.name))
|
||
.flatMap((group) =>
|
||
group.features
|
||
.filter((feature) => {
|
||
if (group.name !== 'Amenities') return true;
|
||
if (isPoiFilterFeatureName(feature.name)) {
|
||
return activeAmenityFeatureNames.has(feature.name);
|
||
}
|
||
return activeFilterNames.has(feature.name);
|
||
})
|
||
.map((feature) => feature.name)
|
||
),
|
||
[activeAmenityFeatureNames, activeFilterNames, features, isAreaGroupExpanded]
|
||
);
|
||
|
||
const handleTravelTimeSetDestination = useCallback(
|
||
(index: number, slug: string, label: string, _lat: number, _lon: number) => {
|
||
handleSetDestination(index, slug, label);
|
||
},
|
||
[handleSetDestination]
|
||
);
|
||
|
||
const journeyDest = useJourneyDestination(entries);
|
||
|
||
const {
|
||
selectedHexagon,
|
||
properties,
|
||
propertiesTotal,
|
||
loadingProperties,
|
||
areaStats,
|
||
loadingAreaStats,
|
||
unfilteredAreaCount,
|
||
areaStatsUseFilters,
|
||
setAreaStatsUseFilters,
|
||
hoveredHexagon,
|
||
rightPaneTab,
|
||
setRightPaneTab,
|
||
handleHexagonClick,
|
||
handleHexagonHover,
|
||
handlePropertiesTabClick,
|
||
handleLoadMoreProperties,
|
||
handleCloseSelection,
|
||
loadCrimeRecords,
|
||
selectedPostcodeGeometry,
|
||
handleLocationSearch,
|
||
handleCurrentLocationSearch,
|
||
} = useHexagonSelection({
|
||
filters,
|
||
features,
|
||
hexagonData: mapData.committedHexagonData,
|
||
resolution: mapData.resolution,
|
||
usePostcodeView: mapData.usePostcodeView,
|
||
travelTimeEntries: entries,
|
||
areaStatsFields,
|
||
shareCode,
|
||
journeyDest,
|
||
});
|
||
|
||
const handleLocationSearchResult = useCallback(
|
||
(result: SearchedLocation | null) => {
|
||
if (result) {
|
||
const markerLat = result.markerLatitude;
|
||
const markerLng = result.markerLongitude;
|
||
if (markerLat != null && markerLng != null) {
|
||
setCurrentLocation({ lat: markerLat, lng: markerLng });
|
||
} else {
|
||
setCurrentLocation(null);
|
||
}
|
||
handleLocationSearch(
|
||
result.postcode,
|
||
result.geometry,
|
||
result.latitude,
|
||
result.longitude,
|
||
result.openProperties,
|
||
result.focusAddress
|
||
);
|
||
if (isMobile) {
|
||
openMobileDrawerForLocationSearch({
|
||
lat: markerLat ?? result.latitude,
|
||
lng: markerLng ?? result.longitude,
|
||
zoom: result.zoom,
|
||
});
|
||
}
|
||
} else {
|
||
setCurrentLocation(null);
|
||
clearPendingLocationSearchFlyTo();
|
||
handleCloseSelection();
|
||
}
|
||
},
|
||
[
|
||
clearPendingLocationSearchFlyTo,
|
||
handleCloseSelection,
|
||
handleLocationSearch,
|
||
isMobile,
|
||
openMobileDrawerForLocationSearch,
|
||
]
|
||
);
|
||
|
||
const handleCurrentLocationFound = useCallback(
|
||
(lat: number, lng: number) => {
|
||
if (isMobile) {
|
||
queueCurrentLocationFlyTo(lat, lng);
|
||
} else {
|
||
mapFlyToRef.current?.(lat, lng, 17);
|
||
}
|
||
setCurrentLocation({ lat, lng });
|
||
handleCurrentLocationSearch(lat, lng);
|
||
if (isMobile) openMobileDrawer();
|
||
},
|
||
[handleCurrentLocationSearch, isMobile, openMobileDrawer, queueCurrentLocationFlyTo]
|
||
);
|
||
|
||
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
|
||
// 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;
|
||
const actualListingsFilterParam = useMemo(
|
||
() => buildFilterString(filters, features),
|
||
[filters, features]
|
||
);
|
||
const actualListingsTravelParam = useMemo(() => buildTravelParam(entries), [entries]);
|
||
// Listings are gated behind the per-user `can_see_listings` flag (off by
|
||
// 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 && listingsMode !== 'none';
|
||
const { listings: actualListings, loading: actualListingsLoading } = useActualListings(
|
||
actualListingsEnabled ? mapData.visibleBounds : null,
|
||
{
|
||
filterParam: actualListingsFilterParam,
|
||
travelParam: actualListingsTravelParam,
|
||
shareCode,
|
||
}
|
||
);
|
||
// 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'
|
||
? selectedHexagon.id
|
||
: (pendingInitialPostcode ?? undefined);
|
||
|
||
useUrlSync(
|
||
mapData.currentView,
|
||
filters,
|
||
features,
|
||
selectedPOICategories,
|
||
rightPaneTab,
|
||
entries,
|
||
shareCode,
|
||
activeOverlays,
|
||
basemap,
|
||
crimeTypes,
|
||
selectedPostcodeParam,
|
||
colorOpacity,
|
||
listingsMode
|
||
);
|
||
|
||
useInitialMapPageView(mapData, initialViewState, initialTab, setRightPaneTab);
|
||
useInitialPostcodeSelection({
|
||
initialPostcode,
|
||
isMobile,
|
||
flyTo: mapFlyToRef,
|
||
onLocationSearch: handleLocationSearch,
|
||
onOpenMobileDrawer: openMobileDrawerForLocationSearch,
|
||
onSettled: () => setPendingInitialPostcode(null),
|
||
});
|
||
useHorizontalSwipeNavigationGuard();
|
||
useMobileBackNavigationGuard(isMobile);
|
||
useScreenshotReadySignal({
|
||
screenshotMode,
|
||
loading: mapData.loading,
|
||
boundsReady: mapData.bounds != null,
|
||
dataLength: mapData.data.length,
|
||
postcodeDataLength: mapData.postcodeData.length,
|
||
usePostcodeView: mapData.usePostcodeView,
|
||
});
|
||
|
||
const handleMobileHexagonClick = useCallback(
|
||
(id: string, isPostcode?: boolean, geometry?: PostcodeGeometry) => {
|
||
handleHexagonClick(id, isPostcode, geometry);
|
||
if (id) {
|
||
openMobileDrawer();
|
||
}
|
||
},
|
||
[handleHexagonClick, openMobileDrawer]
|
||
);
|
||
|
||
const hexagonLocation = useHexagonLocation(
|
||
selectedHexagon,
|
||
mapData.postcodeData,
|
||
mapData.resolution,
|
||
areaStats
|
||
);
|
||
const tutorial = useTutorial(initialLoading, isMobile, deferTutorial);
|
||
const tutorialTheme = useMemo(() => getTutorialStyles(theme), [theme]);
|
||
const densityLabel = t('mapLegend.historicalMatches');
|
||
const mobileLegendMeta = useMobileLegendMeta(viewFeature, features);
|
||
const mapViewFeature = useMapViewFeature(viewFeature);
|
||
const mobileDensityRange = useMobileDensityRange(mapData);
|
||
const { exportNotice, clearExportNotice } = useExportController({
|
||
bounds: mapData.bounds,
|
||
filters,
|
||
features,
|
||
travelTimeEntries: entries,
|
||
selectedOverlays: activeOverlays,
|
||
shareCode,
|
||
t,
|
||
onExportStateChange,
|
||
});
|
||
|
||
const shareAndSaveView = isMobile
|
||
? (mapData.currentVisibleView ?? mapData.currentView)
|
||
: mapData.currentView;
|
||
// Params for share/save/checkout-return/last-session, deliberately WITHOUT the
|
||
// selected postcode: the lat/lon/zoom already convey the location, so focusing
|
||
// a postcode adds nothing to a shared link and shouldn't be baked into a saved
|
||
// search. The live URL (useUrlSync above) still carries `pc` so a reload
|
||
// re-opens the selection.
|
||
const dashboardParams = useMemo(
|
||
() =>
|
||
stateToParams(
|
||
shareAndSaveView,
|
||
filters,
|
||
features,
|
||
selectedPOICategories,
|
||
rightPaneTab,
|
||
entries,
|
||
shareCode,
|
||
activeOverlays,
|
||
basemap,
|
||
crimeTypes,
|
||
undefined,
|
||
colorOpacity,
|
||
listingsMode
|
||
).toString(),
|
||
[
|
||
activeOverlays,
|
||
basemap,
|
||
crimeTypes,
|
||
colorOpacity,
|
||
entries,
|
||
features,
|
||
filters,
|
||
listingsMode,
|
||
rightPaneTab,
|
||
selectedPOICategories,
|
||
shareCode,
|
||
shareAndSaveView,
|
||
]
|
||
);
|
||
// dashboardParams changes on every camera move; read it through a ref so the
|
||
// save/update handlers (and the Filters pane depending on them) stay stable
|
||
// while panning. The ref always holds the params of the latest render.
|
||
const dashboardParamsRef = useRef(dashboardParams);
|
||
dashboardParamsRef.current = dashboardParams;
|
||
const handleSaveSearch = useCallback(
|
||
async (name: string) => {
|
||
await onSaveSearch?.(name, dashboardParamsRef.current);
|
||
},
|
||
[onSaveSearch]
|
||
);
|
||
const handleUpdateEditInPlaceWithParams = useCallback(async () => {
|
||
await onUpdateEditInPlace?.(dashboardParamsRef.current);
|
||
}, [onUpdateEditInPlace]);
|
||
const checkoutReturnPath = useMemo(
|
||
() => `/dashboard${dashboardParams ? `?${dashboardParams}` : ''}`,
|
||
[dashboardParams]
|
||
);
|
||
|
||
useEffect(() => {
|
||
onDashboardParamsChange?.(dashboardParams);
|
||
}, [dashboardParams, onDashboardParamsChange]);
|
||
|
||
const dashboardReady =
|
||
!initialLoading && !mapData.loading && mapData.bounds != null && mapData.currentView != null;
|
||
|
||
useEffect(() => {
|
||
onDashboardReadyChange?.(dashboardReady);
|
||
}, [dashboardReady, onDashboardReadyChange]);
|
||
|
||
useEffect(() => {
|
||
return () => {
|
||
onDashboardReadyChange?.(false);
|
||
};
|
||
}, [onDashboardReadyChange]);
|
||
|
||
// 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
|
||
// dismisses it themselves. Prevents a stale flag from resurfacing spuriously.
|
||
useEffect(() => {
|
||
if (
|
||
filtersUnlimited ||
|
||
(!lockFilterAdds && Object.keys(filters).length + entries.length < effectiveFilterCap)
|
||
) {
|
||
setFilterLimitHit(false);
|
||
}
|
||
}, [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
|
||
// 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);
|
||
entriesRef.current = entries;
|
||
useEffect(() => {
|
||
if (filtersUnlimited) return;
|
||
if (entriesRef.current.length > effectiveFilterCap) {
|
||
handleSetEntries(entriesRef.current.slice(0, effectiveFilterCap));
|
||
}
|
||
}, [effectiveFilterCap, filtersUnlimited, handleSetEntries]);
|
||
|
||
const handleUpgradeClick = useCallback(() => {
|
||
onNavigateTo('pricing');
|
||
}, [onNavigateTo]);
|
||
const handleTogglePoiPane = useCallback(() => {
|
||
setOpenMapPane((pane) => (pane === 'poi' ? null : 'poi'));
|
||
}, []);
|
||
const handleToggleOverlayPane = useCallback(() => {
|
||
setOpenMapPane((pane) => (pane === 'overlay' ? null : 'overlay'));
|
||
}, []);
|
||
const handleClosePoiPane = useCallback(() => {
|
||
setOpenMapPane((pane) => (pane === 'poi' ? null : pane));
|
||
}, []);
|
||
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]);
|
||
const handleMobileDrawerTabChange = useCallback(
|
||
(tab: 'area' | 'properties') => {
|
||
if (tab === 'properties') {
|
||
handlePropertiesTabClick();
|
||
} else {
|
||
setRightPaneTab(tab);
|
||
}
|
||
},
|
||
[handlePropertiesTabClick, setRightPaneTab]
|
||
);
|
||
|
||
const renderAreaPane = useCallback(
|
||
() => (
|
||
<Suspense fallback={<PaneFallback />}>
|
||
<AreaPane
|
||
stats={areaStats}
|
||
globalFeatures={features}
|
||
loading={loadingAreaStats}
|
||
hexagonId={selectedHexagon?.id || null}
|
||
isPostcode={selectedHexagon?.type === 'postcode'}
|
||
hexagonLocation={hexagonLocation}
|
||
filters={filters}
|
||
unfilteredCount={unfilteredAreaCount}
|
||
statsUseFilters={areaStatsUseFilters}
|
||
onStatsUseFiltersChange={setAreaStatsUseFilters}
|
||
travelTimeEntries={activeEntries}
|
||
shareCode={shareCode}
|
||
isGroupExpanded={isAreaGroupExpanded}
|
||
onToggleGroup={toggleAreaGroup}
|
||
onLoadCrimeRecords={loadCrimeRecords}
|
||
scrollTopRef={areaPaneScrollTopRef}
|
||
scrollRestoreKey={
|
||
selectedHexagon ? `${selectedHexagon.type}:${selectedHexagon.id}` : null
|
||
}
|
||
scrollSaveDisabled={loadingAreaStats && areaStats == null}
|
||
/>
|
||
</Suspense>
|
||
),
|
||
[
|
||
activeEntries,
|
||
areaStats,
|
||
areaStatsUseFilters,
|
||
features,
|
||
filters,
|
||
hexagonLocation,
|
||
isAreaGroupExpanded,
|
||
loadingAreaStats,
|
||
loadCrimeRecords,
|
||
selectedHexagon,
|
||
setAreaStatsUseFilters,
|
||
shareCode,
|
||
toggleAreaGroup,
|
||
unfilteredAreaCount,
|
||
]
|
||
);
|
||
|
||
const renderPropertiesPane = useCallback(
|
||
() => (
|
||
<Suspense fallback={<PaneFallback />}>
|
||
<PropertiesPane
|
||
properties={properties}
|
||
total={propertiesTotal}
|
||
loading={loadingProperties}
|
||
hexagonId={selectedHexagon?.id || null}
|
||
statsUseFilters={areaStatsUseFilters}
|
||
onStatsUseFiltersChange={setAreaStatsUseFilters}
|
||
filtersActive={Object.keys(filters).length + activeEntries.length > 0}
|
||
onLoadMore={handleLoadMoreProperties}
|
||
scrollTopRef={propertiesPaneScrollTopRef}
|
||
scrollRestoreKey={
|
||
selectedHexagon ? `${selectedHexagon.type}:${selectedHexagon.id}` : null
|
||
}
|
||
scrollSaveDisabled={loadingProperties && properties.length === 0}
|
||
/>
|
||
</Suspense>
|
||
),
|
||
[
|
||
activeEntries,
|
||
areaStatsUseFilters,
|
||
filters,
|
||
handleLoadMoreProperties,
|
||
loadingProperties,
|
||
properties,
|
||
propertiesTotal,
|
||
selectedHexagon,
|
||
setAreaStatsUseFilters,
|
||
]
|
||
);
|
||
|
||
const poiPane = useMemo(
|
||
() => (
|
||
<Suspense fallback={<PaneFallback />}>
|
||
<POIPane
|
||
groups={poiCategoryGroups}
|
||
selectedCategories={selectedPOICategories}
|
||
onCategoriesChange={setSelectedPOICategories}
|
||
poiCount={pois.length}
|
||
onClose={handleClosePoiPane}
|
||
/>
|
||
</Suspense>
|
||
),
|
||
[handleClosePoiPane, poiCategoryGroups, pois.length, selectedPOICategories]
|
||
);
|
||
|
||
const overlayPane = useMemo(
|
||
() => (
|
||
<Suspense fallback={<PaneFallback />}>
|
||
<OverlayPane
|
||
selectedOverlays={activeOverlays}
|
||
onOverlaysChange={setActiveOverlays}
|
||
selectedCrimeTypes={crimeTypes}
|
||
onCrimeTypesChange={setCrimeTypes}
|
||
basemap={basemap}
|
||
onBasemapChange={setBasemap}
|
||
colorOpacity={colorOpacity}
|
||
onColorOpacityChange={setColorOpacity}
|
||
zoomedIn={overlaysZoomedIn}
|
||
onClose={handleCloseOverlayPane}
|
||
/>
|
||
</Suspense>
|
||
),
|
||
[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 />}>
|
||
<Filters
|
||
features={features}
|
||
filters={filters}
|
||
activeFeature={activeFeature}
|
||
dragValue={dragValue}
|
||
enabledFeatures={enabledFeatures}
|
||
onAddFilter={handleAddFilter}
|
||
onRemoveFilter={handleRemoveFilter}
|
||
onFilterChange={handleFilterChange}
|
||
onDragStart={handleDragStart}
|
||
onDragChange={handleDragChange}
|
||
onDragEnd={handleDragEnd}
|
||
pinnedFeature={pinnedFeature}
|
||
onTogglePin={handleTogglePin}
|
||
openInfoFeature={pendingInfoFeature}
|
||
onClearOpenInfoFeature={onClearPendingInfoFeature}
|
||
travelTimeEntries={entries}
|
||
onTravelTimeAddEntry={handleAddTravelTimeEntry}
|
||
onTravelTimeRemoveEntry={handleTravelTimeRemoveEntry}
|
||
onTravelTimeSetDestination={handleTravelTimeSetDestination}
|
||
onTravelTimeRangeChange={handleTimeRangeChange}
|
||
onTravelTimeDragEnd={handleTravelTimeDragEnd}
|
||
onTravelTimeToggleBest={handleToggleBest}
|
||
onTravelTimeToggleNoChange={handleToggleNoChange}
|
||
onTravelTimeToggleOneChange={handleToggleOneChange}
|
||
onTravelTimeToggleNoBuses={handleToggleNoBuses}
|
||
aiFilterLoading={aiFilterLoading}
|
||
aiFilterError={aiFilterError}
|
||
aiFilterErrorType={aiFilterErrorType}
|
||
aiFilterNotes={aiFilterNotes}
|
||
aiFilterSummary={aiFilterSummary}
|
||
onAiFilterSubmit={handleAiFilterSubmit}
|
||
isLoggedIn={!!user}
|
||
onLoginRequired={onRegisterClick}
|
||
isLicensed={user?.subscription === 'licensed'}
|
||
onUpgradeClick={handleUpgradeClick}
|
||
onResetTutorial={!isMobile ? tutorial.resetTutorial : undefined}
|
||
filterImpacts={filterCounts.impacts}
|
||
onClearAll={handleClearAll}
|
||
onSaveSearch={onSaveSearch ? handleSaveSearch : undefined}
|
||
savingSearch={savingSearch}
|
||
editingSearchName={editingSearch?.name ?? null}
|
||
onUpdateSearch={
|
||
editingSearch && onUpdateEditInPlace ? handleUpdateEditInPlaceWithParams : undefined
|
||
}
|
||
onExitEditing={onCancelEdit}
|
||
destinationDropdownPortal={isMobile ? false : undefined}
|
||
/>
|
||
</Suspense>
|
||
),
|
||
[
|
||
activeFeature,
|
||
aiFilterError,
|
||
aiFilterErrorType,
|
||
aiFilterLoading,
|
||
aiFilterNotes,
|
||
aiFilterSummary,
|
||
dragValue,
|
||
editingSearch,
|
||
enabledFeatures,
|
||
entries,
|
||
features,
|
||
filterCounts.impacts,
|
||
filters,
|
||
handleAddTravelTimeEntry,
|
||
handleAddFilter,
|
||
handleAiFilterSubmit,
|
||
handleClearAll,
|
||
handleDragChange,
|
||
handleDragEnd,
|
||
handleDragStart,
|
||
handleFilterChange,
|
||
handleRemoveFilter,
|
||
handleSaveSearch,
|
||
handleTimeRangeChange,
|
||
handleToggleBest,
|
||
handleToggleNoBuses,
|
||
handleToggleNoChange,
|
||
handleToggleOneChange,
|
||
handleTogglePin,
|
||
handleTravelTimeDragEnd,
|
||
handleTravelTimeRemoveEntry,
|
||
handleTravelTimeSetDestination,
|
||
handleUpdateEditInPlaceWithParams,
|
||
handleUpgradeClick,
|
||
isMobile,
|
||
onCancelEdit,
|
||
onClearPendingInfoFeature,
|
||
onRegisterClick,
|
||
onSaveSearch,
|
||
onUpdateEditInPlace,
|
||
pendingInfoFeature,
|
||
pinnedFeature,
|
||
savingSearch,
|
||
tutorial.resetTutorial,
|
||
user,
|
||
]
|
||
);
|
||
|
||
const mobileLegend = useMemo(
|
||
() => (
|
||
<MobileMapLegend
|
||
mapViewFeature={mapViewFeature}
|
||
colorRange={mapData.colorRange}
|
||
viewSource={viewSource}
|
||
mobileLegendMeta={mobileLegendMeta}
|
||
densityLabel={densityLabel}
|
||
densityRange={mobileDensityRange}
|
||
theme={theme}
|
||
canResetPreviewScale={mapData.canResetPreviewScale}
|
||
onCancelPin={handleCancelPin}
|
||
onResetPreviewScale={mapData.handleResetPreviewScale}
|
||
/>
|
||
),
|
||
[
|
||
densityLabel,
|
||
handleCancelPin,
|
||
mapData.canResetPreviewScale,
|
||
mapData.colorRange,
|
||
mapData.handleResetPreviewScale,
|
||
mapViewFeature,
|
||
mobileDensityRange,
|
||
mobileLegendMeta,
|
||
theme,
|
||
viewSource,
|
||
]
|
||
);
|
||
|
||
const toasts = useMemo(
|
||
() => (
|
||
<ExportToast
|
||
notice={exportNotice}
|
||
closeLabel={t('common.close')}
|
||
onClose={clearExportNotice}
|
||
/>
|
||
),
|
||
[clearExportNotice, exportNotice, t]
|
||
);
|
||
|
||
if (screenshotMode) {
|
||
return (
|
||
<ScreenshotMapPage
|
||
mapData={mapData}
|
||
mapViewFeature={mapViewFeature}
|
||
filterRange={filterRange}
|
||
viewSource={viewSource}
|
||
features={features}
|
||
initialViewState={initialViewState}
|
||
theme={theme}
|
||
ogMode={ogMode}
|
||
travelTimeEntries={entries}
|
||
activeOverlays={activeOverlays}
|
||
activeCrimeTypes={crimeTypes}
|
||
basemap={basemap}
|
||
colorOpacity={colorOpacity}
|
||
/>
|
||
);
|
||
}
|
||
|
||
const editingBar =
|
||
editingSearch && isMobile ? (
|
||
<div className="flex items-center gap-2 px-3 py-2 border-b border-warm-200 dark:border-navy-700 bg-warm-50 dark:bg-navy-900">
|
||
<span
|
||
className="flex-1 min-w-0 truncate text-xs text-warm-700 dark:text-warm-200"
|
||
title={editingSearch.name}
|
||
>
|
||
<Trans
|
||
i18nKey="savedPage.isBeingUpdated"
|
||
values={{ name: editingSearch.name }}
|
||
components={{
|
||
strong: <strong className="font-semibold text-navy-950 dark:text-warm-100" />,
|
||
}}
|
||
/>
|
||
</span>
|
||
<button
|
||
onClick={onCancelEdit}
|
||
className="shrink-0 cursor-pointer px-2.5 py-1 rounded text-xs font-medium border border-warm-200 dark:border-warm-700 text-warm-700 dark:text-warm-200 hover:bg-warm-100 dark:hover:bg-navy-800"
|
||
>
|
||
{t('common.cancel')}
|
||
</button>
|
||
<button
|
||
onClick={() => onUpdateEdit?.(dashboardParams)}
|
||
disabled={savingSearch || !dashboardReady}
|
||
className="shrink-0 cursor-pointer px-2.5 py-1 rounded text-xs font-medium bg-teal-600 text-white hover:bg-teal-700 disabled:opacity-50 disabled:cursor-not-allowed flex items-center gap-1.5"
|
||
>
|
||
{savingSearch ? t('savedPage.updating') : t('common.update')}
|
||
</button>
|
||
</div>
|
||
) : null;
|
||
|
||
// The upgrade modal is shown when a non-paying user hits the filter cap (or tries
|
||
// to add filters on a shared link). Dismissing it just closes the modal (the
|
||
// filters they have stay applied). Anonymous users are nudged to register for free
|
||
// first (a higher filter allowance + save/share/export); paying is the way to
|
||
// unlimited. Registered users see the lifetime upgrade directly.
|
||
const showFilterUpgradeModal = filterLimitHit && !filtersUnlimited;
|
||
const upgradeModal = showFilterUpgradeModal ? (
|
||
<Suspense fallback={null}>
|
||
<UpgradeModal
|
||
isLoggedIn={isLoggedIn}
|
||
reason={upgradeReason}
|
||
onLoginClick={onLoginClick}
|
||
onRegisterClick={onRegisterClick}
|
||
onRegisterAndUpgrade={() =>
|
||
onCheckoutRegisterClick ? onCheckoutRegisterClick(checkoutReturnPath) : onRegisterClick()
|
||
}
|
||
onStartCheckout={() => license.startCheckout(checkoutReturnPath)}
|
||
onClose={() => setFilterLimitHit(false)}
|
||
/>
|
||
</Suspense>
|
||
) : null;
|
||
|
||
if (isMobile) {
|
||
return (
|
||
<MobileMapPage
|
||
initialLoading={initialLoading}
|
||
mapData={mapData}
|
||
pois={pois}
|
||
activeOverlays={activeOverlays}
|
||
activeCrimeTypes={crimeTypes}
|
||
basemap={basemap}
|
||
colorOpacity={colorOpacity}
|
||
mapViewFeature={mapViewFeature}
|
||
filterRange={filterRange}
|
||
viewSource={viewSource}
|
||
onCancelPin={handleCancelPin}
|
||
features={features}
|
||
selectedHexagonId={selectedHexagon?.id || null}
|
||
hoveredHexagonId={hoveredHexagon}
|
||
onHexagonClick={handleMobileHexagonClick}
|
||
onHexagonHover={handleHexagonHover}
|
||
initialViewState={initialViewState}
|
||
flyToRef={mapFlyToRef}
|
||
theme={theme}
|
||
filters={filters}
|
||
selectedPostcodeGeometry={selectedPostcodeGeometry}
|
||
onLocationSearched={handleLocationSearchResult}
|
||
onCurrentLocationFound={handleCurrentLocationFound}
|
||
currentLocation={currentLocation}
|
||
actualListings={visibleActualListings}
|
||
actualListingsEnabled={actualListingsEnabled}
|
||
actualListingsLoading={actualListingsLoading}
|
||
onToggleListingsPane={canSeeListings ? handleToggleListingsPane : undefined}
|
||
listingsPaneOpen={listingsPaneOpen}
|
||
listingsPane={listingsPane}
|
||
travelTimeEntries={entries}
|
||
bottomScreenInset={mobileBottomSheetHeight}
|
||
onBottomSheetCoveredHeightChange={setMobileBottomSheetHeight}
|
||
mobileDrawerOpen={mobileDrawerOpen}
|
||
onMobileDrawerClose={handleMobileDrawerClose}
|
||
onMobileDrawerPanelRectChange={handleMobileDrawerPanelRectChange}
|
||
rightPaneTab={rightPaneTab}
|
||
onMobileDrawerTabChange={handleMobileDrawerTabChange}
|
||
poiPaneOpen={poiPaneOpen}
|
||
onTogglePoiPane={handleTogglePoiPane}
|
||
poiButtonLabel={t('poiPane.pointsOfInterest')}
|
||
poiPane={poiPane}
|
||
overlayPaneOpen={overlayPaneOpen}
|
||
onToggleOverlayPane={handleToggleOverlayPane}
|
||
overlayPane={overlayPane}
|
||
filtersPane={filtersPane}
|
||
mobileLegend={mobileLegend}
|
||
renderAreaPane={renderAreaPane}
|
||
renderPropertiesPane={renderPropertiesPane}
|
||
toasts={toasts}
|
||
upgradeModal={upgradeModal}
|
||
editingBar={editingBar}
|
||
/>
|
||
);
|
||
}
|
||
|
||
return (
|
||
<DesktopMapPage
|
||
initialLoading={initialLoading}
|
||
tutorial={tutorial}
|
||
tutorialTheme={tutorialTheme}
|
||
leftPaneWidth={leftPaneWidth}
|
||
leftPaneHandlers={leftPaneHandlers}
|
||
filtersPane={filtersPane}
|
||
mapData={mapData}
|
||
pois={pois}
|
||
activeOverlays={activeOverlays}
|
||
activeCrimeTypes={crimeTypes}
|
||
basemap={basemap}
|
||
colorOpacity={colorOpacity}
|
||
mapViewFeature={mapViewFeature}
|
||
filterRange={filterRange}
|
||
viewSource={viewSource}
|
||
onCancelPin={handleCancelPin}
|
||
features={features}
|
||
selectedHexagonId={selectedHexagon?.id || null}
|
||
hoveredHexagonId={hoveredHexagon}
|
||
onHexagonClick={handleHexagonClick}
|
||
onHexagonHover={handleHexagonHover}
|
||
initialViewState={initialViewState}
|
||
flyToRef={mapFlyToRef}
|
||
theme={theme}
|
||
filters={filters}
|
||
selectedPostcodeGeometry={selectedPostcodeGeometry}
|
||
onLocationSearched={handleLocationSearchResult}
|
||
onCurrentLocationFound={handleCurrentLocationFound}
|
||
currentLocation={currentLocation}
|
||
actualListings={visibleActualListings}
|
||
actualListingsEnabled={actualListingsEnabled}
|
||
actualListingsLoading={actualListingsLoading}
|
||
onToggleListingsPane={canSeeListings ? handleToggleListingsPane : undefined}
|
||
listingsPaneOpen={listingsPaneOpen}
|
||
listingsPane={listingsPane}
|
||
travelTimeEntries={entries}
|
||
densityLabel={densityLabel}
|
||
totalCount={filterCounts.total ?? undefined}
|
||
poiPaneOpen={poiPaneOpen}
|
||
onTogglePoiPane={handleTogglePoiPane}
|
||
poiPane={poiPane}
|
||
overlayPaneOpen={overlayPaneOpen}
|
||
onToggleOverlayPane={handleToggleOverlayPane}
|
||
overlayPane={overlayPane}
|
||
showSelectionPane={!!selectedHexagon}
|
||
rightPaneWidth={rightPaneWidth}
|
||
rightPaneHandlers={rightPaneHandlers}
|
||
rightPaneTab={rightPaneTab}
|
||
onAreaTabClick={handleAreaTabClick}
|
||
onPropertiesTabClick={handlePropertiesTabClick}
|
||
onCloseSelection={handleCloseSelection}
|
||
renderAreaPane={renderAreaPane}
|
||
renderPropertiesPane={renderPropertiesPane}
|
||
toasts={toasts}
|
||
upgradeModal={upgradeModal}
|
||
/>
|
||
);
|
||
}
|