816 lines
27 KiB
TypeScript
816 lines
27 KiB
TypeScript
import { Suspense, useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
|
import { Trans, useTranslation } from 'react-i18next';
|
|
|
|
import type { MapFlyToOptions, 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 } 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 { travelFieldKey, useTravelTime } from '../../hooks/useTravelTime';
|
|
import { apiUrl, authHeaders, buildFilterString } from '../../lib/api';
|
|
import { useFilterCounts } from '../../hooks/useFilterCounts';
|
|
import { trackEvent } from '../../lib/analytics';
|
|
import { INITIAL_VIEW_STATE, POSTCODE_SEARCH_ZOOM } from '../../lib/consts';
|
|
import { useLicense } from '../../hooks/useLicense';
|
|
import { stateToParams } from '../../lib/url-state';
|
|
import {
|
|
AreaPane,
|
|
Filters,
|
|
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 type { MapFlyTo, MapPageProps } from './map-page/types';
|
|
|
|
export type { ExportState } from './map-page/types';
|
|
|
|
type PendingFlyTo = { lat: number; lng: number; zoom: number };
|
|
|
|
export default function MapPage({
|
|
features,
|
|
poiCategoryGroups,
|
|
initialFilters,
|
|
initialViewState,
|
|
initialPOICategories,
|
|
initialTab,
|
|
initialLoading,
|
|
theme,
|
|
pendingInfoFeature,
|
|
onClearPendingInfoFeature,
|
|
onNavigateTo,
|
|
onExportStateChange,
|
|
onDashboardParamsChange,
|
|
screenshotMode,
|
|
ogMode,
|
|
isMobile = false,
|
|
initialTravelTime,
|
|
initialPostcode,
|
|
shareCode,
|
|
user,
|
|
onLoginClick,
|
|
onRegisterClick,
|
|
onCheckoutLoginClick,
|
|
onCheckoutRegisterClick,
|
|
deferTutorial = false,
|
|
onSaveSearch,
|
|
savingSearch,
|
|
editingSearch,
|
|
onCancelEdit,
|
|
onUpdateEdit,
|
|
onUpdateEditInPlace,
|
|
}: MapPageProps) {
|
|
const { t } = useTranslation();
|
|
const [selectedPOICategories, setSelectedPOICategories] =
|
|
useState<Set<string>>(initialPOICategories);
|
|
const [leftPaneWidth, leftPaneHandlers] = usePaneResize(384, 200, 0.45, 'left');
|
|
const [rightPaneWidth, rightPaneHandlers] = usePaneResize(384, 200, 0.45, 'right');
|
|
const [mobileDrawerOpen, setMobileDrawerOpen] = useState(false);
|
|
const [mobileBottomSheetHeight, setMobileBottomSheetHeight] = useState(0);
|
|
const [poiPaneOpen, setPoiPaneOpen] = useState(false);
|
|
const [currentLocation, setCurrentLocation] = useState<{ lat: number; lng: number } | null>(null);
|
|
|
|
const {
|
|
filters,
|
|
activeFeature,
|
|
dragValue,
|
|
pinnedFeature,
|
|
enabledFeatures,
|
|
viewFeature,
|
|
viewSource,
|
|
filterRange,
|
|
handleAddFilter,
|
|
handleFilterChange,
|
|
handleRemoveFilter,
|
|
handleSetFilters,
|
|
handleDragStart,
|
|
handleDragChange,
|
|
handleDragEnd,
|
|
handleDragEndNoCommit,
|
|
handleTogglePin,
|
|
handleCancelPin,
|
|
} = useFilters({
|
|
initialFilters,
|
|
features,
|
|
});
|
|
|
|
const {
|
|
fetchAiFilters,
|
|
loading: aiFilterLoading,
|
|
error: aiFilterError,
|
|
errorType: aiFilterErrorType,
|
|
notes: aiFilterNotes,
|
|
summary: aiFilterSummary,
|
|
} = useAiFilters();
|
|
|
|
const {
|
|
entries,
|
|
activeEntries,
|
|
handleAddEntry,
|
|
handleRemoveEntry,
|
|
handleSetDestination,
|
|
handleSetEntries,
|
|
handleTimeRangeChange,
|
|
handleToggleBest,
|
|
} = useTravelTime(initialTravelTime);
|
|
|
|
const mapFlyToRef = useRef<MapFlyTo | null>(null);
|
|
const pendingCurrentLocationFlyToRef = useRef<{ lat: number; lng: number } | null>(null);
|
|
const pendingLocationSearchFlyToRef = useRef<PendingFlyTo | null>(null);
|
|
const mobileDrawerPanelRectRef = useRef<DOMRectReadOnly | null>(null);
|
|
const areaPaneScrollTopRef = useRef(0);
|
|
const propertiesPaneScrollTopRef = useRef(0);
|
|
|
|
const getMobileMapFlyToOptions = useCallback((): MapFlyToOptions | undefined => {
|
|
if (!isMobile) return undefined;
|
|
|
|
const panelRect = mobileDrawerPanelRectRef.current;
|
|
if (mobileDrawerOpen && panelRect) {
|
|
const bottomInset = Math.max(0, window.innerHeight - panelRect.top);
|
|
if (bottomInset > 0) {
|
|
return { visibleViewportArea: { bottom: bottomInset } };
|
|
}
|
|
}
|
|
|
|
return mobileBottomSheetHeight > 0
|
|
? { visibleArea: { bottom: mobileBottomSheetHeight } }
|
|
: undefined;
|
|
}, [isMobile, mobileBottomSheetHeight, mobileDrawerOpen]);
|
|
|
|
const mapData = useMapData({
|
|
filters,
|
|
features,
|
|
viewFeature,
|
|
activeFeature,
|
|
pinnedFeature,
|
|
filterRange,
|
|
travelTimeEntries: entries,
|
|
shareCode,
|
|
});
|
|
|
|
const handleAiFilterSubmit = useCallback(
|
|
async (query: string) => {
|
|
const context = {
|
|
filters,
|
|
travelTime: activeEntries.map((entry) => ({
|
|
mode: entry.mode,
|
|
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;
|
|
|
|
handleSetFilters(result.filters);
|
|
handleSetEntries(
|
|
result.travelTimeFilters.map((travelTimeFilter) => ({
|
|
mode: travelTimeFilter.mode,
|
|
slug: travelTimeFilter.slug,
|
|
label: travelTimeFilter.label,
|
|
timeRange: [travelTimeFilter.min ?? 0, travelTimeFilter.max ?? 120] as [number, number],
|
|
useBest: false,
|
|
}))
|
|
);
|
|
|
|
const firstTravelTime = result.travelTimeFilters[0];
|
|
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,
|
|
mapData.currentView?.zoom ?? INITIAL_VIEW_STATE.zoom,
|
|
getMobileMapFlyToOptions()
|
|
);
|
|
}
|
|
} catch {
|
|
// Filters are already applied; destination panning is non-critical.
|
|
}
|
|
},
|
|
[
|
|
activeEntries,
|
|
fetchAiFilters,
|
|
filters,
|
|
getMobileMapFlyToOptions,
|
|
handleSetEntries,
|
|
handleSetFilters,
|
|
mapData.currentView?.zoom,
|
|
]
|
|
);
|
|
|
|
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 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,
|
|
selectedPostcodeGeometry,
|
|
handleLocationSearch,
|
|
handleCurrentLocationSearch,
|
|
} = useHexagonSelection({
|
|
filters,
|
|
features,
|
|
hexagonData: mapData.committedHexagonData,
|
|
resolution: mapData.resolution,
|
|
usePostcodeView: mapData.usePostcodeView,
|
|
travelTimeEntries: entries,
|
|
shareCode,
|
|
journeyDest,
|
|
});
|
|
|
|
const consumePendingLocationSearchFlyTo = useCallback((rect?: DOMRectReadOnly | null) => {
|
|
const pending = pendingLocationSearchFlyToRef.current;
|
|
const panelRect = rect ?? mobileDrawerPanelRectRef.current;
|
|
if (!pending || !panelRect) return;
|
|
|
|
const bottomInset = Math.max(0, window.innerHeight - panelRect.top);
|
|
const flyTo = mapFlyToRef.current;
|
|
if (!flyTo) return;
|
|
flyTo(pending.lat, pending.lng, pending.zoom, {
|
|
visibleViewportArea: { bottom: bottomInset },
|
|
});
|
|
pendingLocationSearchFlyToRef.current = null;
|
|
}, []);
|
|
|
|
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) {
|
|
pendingLocationSearchFlyToRef.current = {
|
|
lat: markerLat ?? result.latitude,
|
|
lng: markerLng ?? result.longitude,
|
|
zoom: result.openProperties ? 17 : POSTCODE_SEARCH_ZOOM,
|
|
};
|
|
setMobileDrawerOpen(true);
|
|
consumePendingLocationSearchFlyTo();
|
|
}
|
|
} else {
|
|
setCurrentLocation(null);
|
|
pendingLocationSearchFlyToRef.current = null;
|
|
handleCloseSelection();
|
|
}
|
|
},
|
|
[consumePendingLocationSearchFlyTo, handleCloseSelection, handleLocationSearch, isMobile]
|
|
);
|
|
|
|
const consumePendingCurrentLocationFlyTo = useCallback((rect?: DOMRectReadOnly | null) => {
|
|
const pending = pendingCurrentLocationFlyToRef.current;
|
|
const panelRect = rect ?? mobileDrawerPanelRectRef.current;
|
|
if (!pending || !panelRect) return;
|
|
|
|
const bottomInset = Math.max(0, window.innerHeight - panelRect.top);
|
|
const flyTo = mapFlyToRef.current;
|
|
if (!flyTo) return;
|
|
flyTo(pending.lat, pending.lng, 17, {
|
|
visibleViewportArea: { bottom: bottomInset },
|
|
});
|
|
pendingCurrentLocationFlyToRef.current = null;
|
|
}, []);
|
|
|
|
const handleCurrentLocationFound = useCallback(
|
|
(lat: number, lng: number) => {
|
|
if (isMobile) {
|
|
pendingCurrentLocationFlyToRef.current = { lat, lng };
|
|
consumePendingCurrentLocationFlyTo();
|
|
} else {
|
|
mapFlyToRef.current?.(lat, lng, 17);
|
|
}
|
|
setCurrentLocation({ lat, lng });
|
|
handleCurrentLocationSearch(lat, lng);
|
|
if (isMobile) setMobileDrawerOpen(true);
|
|
},
|
|
[consumePendingCurrentLocationFlyTo, handleCurrentLocationSearch, isMobile]
|
|
);
|
|
|
|
const handleMobileDrawerPanelRectChange = useCallback(
|
|
(rect: DOMRectReadOnly) => {
|
|
mobileDrawerPanelRectRef.current = rect;
|
|
consumePendingCurrentLocationFlyTo(rect);
|
|
consumePendingLocationSearchFlyTo(rect);
|
|
},
|
|
[consumePendingCurrentLocationFlyTo, consumePendingLocationSearchFlyTo]
|
|
);
|
|
|
|
const handleMobileDrawerClose = useCallback(() => {
|
|
pendingCurrentLocationFlyToRef.current = null;
|
|
pendingLocationSearchFlyToRef.current = null;
|
|
mobileDrawerPanelRectRef.current = null;
|
|
setMobileDrawerOpen(false);
|
|
}, []);
|
|
|
|
const shareReturnViewRef = useRef(shareCode ? initialViewState : null);
|
|
const handleZoomToFreeZone = useCallback(() => {
|
|
const target = shareReturnViewRef.current ?? INITIAL_VIEW_STATE;
|
|
mapFlyToRef.current?.(target.latitude, target.longitude, target.zoom);
|
|
}, []);
|
|
|
|
const pois = usePOIData(mapData.bounds, selectedPOICategories);
|
|
const actualListingsFilterParam = useMemo(
|
|
() => buildFilterString(filters, features),
|
|
[filters, features]
|
|
);
|
|
const actualListingsTravelParam = useMemo(() => buildTravelParam(entries), [entries]);
|
|
const { listings: actualListings } = useActualListings(mapData.bounds, {
|
|
filterParam: actualListingsFilterParam,
|
|
travelParam: actualListingsTravelParam,
|
|
});
|
|
const [isAreaGroupExpanded, toggleAreaGroup] = useCollapsibleGroups(true);
|
|
|
|
useUrlSync(
|
|
mapData.currentView,
|
|
filters,
|
|
features,
|
|
selectedPOICategories,
|
|
rightPaneTab,
|
|
entries,
|
|
shareCode
|
|
);
|
|
|
|
useInitialMapPageView(mapData, initialViewState, initialTab, setRightPaneTab);
|
|
useInitialPostcodeSelection({
|
|
initialPostcode,
|
|
isMobile,
|
|
flyTo: mapFlyToRef,
|
|
onLocationSearch: handleLocationSearch,
|
|
onOpenMobileDrawer: (target) => {
|
|
pendingLocationSearchFlyToRef.current = target;
|
|
setMobileDrawerOpen(true);
|
|
consumePendingLocationSearchFlyTo();
|
|
},
|
|
});
|
|
useHorizontalSwipeNavigationGuard();
|
|
useMobileBackNavigationGuard(isMobile);
|
|
useScreenshotReadySignal({
|
|
screenshotMode,
|
|
loading: mapData.loading,
|
|
boundsReady: mapData.bounds != null,
|
|
dataLength: mapData.data.length,
|
|
postcodeDataLength: mapData.postcodeData.length,
|
|
usePostcodeView: mapData.usePostcodeView,
|
|
licenseRequired: mapData.licenseRequired,
|
|
});
|
|
|
|
const handleMobileHexagonClick = useCallback(
|
|
(id: string, isPostcode?: boolean, geometry?: PostcodeGeometry) => {
|
|
handleHexagonClick(id, isPostcode, geometry);
|
|
if (id) {
|
|
setMobileDrawerOpen(true);
|
|
}
|
|
},
|
|
[handleHexagonClick]
|
|
);
|
|
|
|
const hexagonLocation = useHexagonLocation(
|
|
selectedHexagon,
|
|
mapData.postcodeData,
|
|
mapData.resolution,
|
|
areaStats
|
|
);
|
|
const tutorial = useTutorial(initialLoading, isMobile, deferTutorial || mapData.licenseRequired);
|
|
const tutorialTheme = useMemo(() => getTutorialStyles(theme), [theme]);
|
|
const densityLabel = t('mapLegend.historicalMatches');
|
|
const hasActiveFilters = Object.keys(filters).length > 0 || activeEntries.length > 0;
|
|
const mobileLegendMeta = useMobileLegendMeta(viewFeature, features);
|
|
const mapViewFeature = useMapViewFeature(viewFeature);
|
|
const mobileDensityRange = useMobileDensityRange(mapData);
|
|
const { exportNotice, clearExportNotice } = useExportController({
|
|
bounds: mapData.bounds,
|
|
filters,
|
|
features,
|
|
travelTimeEntries: entries,
|
|
shareCode,
|
|
t,
|
|
onExportStateChange,
|
|
});
|
|
|
|
const shareAndSaveView = isMobile
|
|
? (mapData.currentVisibleView ?? mapData.currentView)
|
|
: mapData.currentView;
|
|
const dashboardParams = useMemo(
|
|
() =>
|
|
stateToParams(
|
|
shareAndSaveView,
|
|
filters,
|
|
features,
|
|
selectedPOICategories,
|
|
rightPaneTab,
|
|
entries,
|
|
shareCode
|
|
).toString(),
|
|
[entries, features, filters, rightPaneTab, selectedPOICategories, shareCode, shareAndSaveView]
|
|
);
|
|
const handleSaveSearch = useCallback(
|
|
async (name: string) => {
|
|
await onSaveSearch?.(name, dashboardParams);
|
|
},
|
|
[dashboardParams, onSaveSearch]
|
|
);
|
|
const handleUpdateEditInPlaceWithParams = useCallback(async () => {
|
|
await onUpdateEditInPlace?.(dashboardParams);
|
|
}, [dashboardParams, onUpdateEditInPlace]);
|
|
const checkoutReturnPath = useMemo(
|
|
() => `/dashboard${dashboardParams ? `?${dashboardParams}` : ''}`,
|
|
[dashboardParams]
|
|
);
|
|
|
|
useEffect(() => {
|
|
onDashboardParamsChange?.(dashboardParams);
|
|
}, [dashboardParams, onDashboardParamsChange]);
|
|
|
|
useEffect(() => {
|
|
if (mapData.licenseRequired) trackEvent('Upgrade Modal Shown');
|
|
}, [mapData.licenseRequired]);
|
|
|
|
if (screenshotMode) {
|
|
return (
|
|
<ScreenshotMapPage
|
|
mapData={mapData}
|
|
mapViewFeature={mapViewFeature}
|
|
filterRange={filterRange}
|
|
viewSource={viewSource}
|
|
features={features}
|
|
initialViewState={initialViewState}
|
|
theme={theme}
|
|
ogMode={ogMode}
|
|
travelTimeEntries={entries}
|
|
/>
|
|
);
|
|
}
|
|
|
|
const renderAreaPane = () => (
|
|
<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}
|
|
scrollTopRef={areaPaneScrollTopRef}
|
|
scrollRestoreKey={selectedHexagon ? `${selectedHexagon.type}:${selectedHexagon.id}` : null}
|
|
scrollSaveDisabled={loadingAreaStats && areaStats == null}
|
|
/>
|
|
</Suspense>
|
|
);
|
|
|
|
const renderPropertiesPane = () => (
|
|
<Suspense fallback={<PaneFallback />}>
|
|
<PropertiesPane
|
|
properties={properties}
|
|
total={propertiesTotal}
|
|
loading={loadingProperties}
|
|
hexagonId={selectedHexagon?.id || null}
|
|
onLoadMore={handleLoadMoreProperties}
|
|
scrollTopRef={propertiesPaneScrollTopRef}
|
|
scrollRestoreKey={selectedHexagon ? `${selectedHexagon.type}:${selectedHexagon.id}` : null}
|
|
scrollSaveDisabled={loadingProperties && properties.length === 0}
|
|
/>
|
|
</Suspense>
|
|
);
|
|
|
|
const renderPOIPane = () => (
|
|
<Suspense fallback={<PaneFallback />}>
|
|
<POIPane
|
|
groups={poiCategoryGroups}
|
|
selectedCategories={selectedPOICategories}
|
|
onCategoriesChange={setSelectedPOICategories}
|
|
poiCount={pois.length}
|
|
onClose={() => setPoiPaneOpen(false)}
|
|
/>
|
|
</Suspense>
|
|
);
|
|
|
|
const renderFilters = (options?: { destinationDropdownPortal?: boolean }) => (
|
|
<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={handleAddEntry}
|
|
onTravelTimeRemoveEntry={handleTravelTimeRemoveEntry}
|
|
onTravelTimeSetDestination={handleTravelTimeSetDestination}
|
|
onTravelTimeRangeChange={handleTimeRangeChange}
|
|
onTravelTimeDragEnd={handleTravelTimeDragEnd}
|
|
onTravelTimeToggleBest={handleToggleBest}
|
|
aiFilterLoading={aiFilterLoading}
|
|
aiFilterError={aiFilterError}
|
|
aiFilterErrorType={aiFilterErrorType}
|
|
aiFilterNotes={aiFilterNotes}
|
|
aiFilterSummary={aiFilterSummary}
|
|
onAiFilterSubmit={handleAiFilterSubmit}
|
|
isLoggedIn={!!user}
|
|
onLoginRequired={onRegisterClick}
|
|
isLicensed={user?.subscription === 'licensed'}
|
|
onUpgradeClick={() => onNavigateTo('pricing')}
|
|
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={options?.destinationDropdownPortal}
|
|
/>
|
|
</Suspense>
|
|
);
|
|
|
|
const handleTogglePoiPane = () => setPoiPaneOpen((open) => !open);
|
|
const handleMobileDrawerTabChange = (tab: 'area' | 'properties') => {
|
|
if (tab === 'properties') {
|
|
handlePropertiesTabClick();
|
|
} else {
|
|
setRightPaneTab(tab);
|
|
}
|
|
};
|
|
|
|
const exportToast = (
|
|
<ExportToast notice={exportNotice} closeLabel={t('common.close')} onClose={clearExportNotice} />
|
|
);
|
|
const toasts = exportToast;
|
|
|
|
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}
|
|
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-wait flex items-center gap-1.5"
|
|
>
|
|
{savingSearch ? t('savedPage.updating') : t('common.update')}
|
|
</button>
|
|
</div>
|
|
) : null;
|
|
|
|
const upgradeModal = mapData.licenseRequired ? (
|
|
<Suspense fallback={null}>
|
|
<UpgradeModal
|
|
isLoggedIn={!!user}
|
|
onLoginClick={() =>
|
|
onCheckoutLoginClick ? onCheckoutLoginClick(checkoutReturnPath) : onLoginClick()
|
|
}
|
|
onRegisterClick={() =>
|
|
onCheckoutRegisterClick ? onCheckoutRegisterClick(checkoutReturnPath) : onRegisterClick()
|
|
}
|
|
onStartCheckout={() => license.startCheckout(checkoutReturnPath)}
|
|
onZoomToFreeZone={handleZoomToFreeZone}
|
|
isShareReturn={!!shareReturnViewRef.current}
|
|
/>
|
|
</Suspense>
|
|
) : null;
|
|
|
|
if (isMobile) {
|
|
return (
|
|
<MobileMapPage
|
|
initialLoading={initialLoading}
|
|
mapData={mapData}
|
|
pois={pois}
|
|
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={actualListings}
|
|
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={renderPOIPane()}
|
|
filtersPane={renderFilters({ destinationDropdownPortal: false })}
|
|
mobileLegend={
|
|
<MobileMapLegend
|
|
mapViewFeature={mapViewFeature}
|
|
colorRange={mapData.colorRange}
|
|
viewSource={viewSource}
|
|
mobileLegendMeta={mobileLegendMeta}
|
|
densityLabel={densityLabel}
|
|
densityRange={mobileDensityRange}
|
|
theme={theme}
|
|
canResetPreviewScale={mapData.canResetPreviewScale}
|
|
onCancelPin={handleCancelPin}
|
|
onResetPreviewScale={mapData.handleResetPreviewScale}
|
|
/>
|
|
}
|
|
renderAreaPane={renderAreaPane}
|
|
renderPropertiesPane={renderPropertiesPane}
|
|
toasts={toasts}
|
|
upgradeModal={upgradeModal}
|
|
editingBar={editingBar}
|
|
/>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<DesktopMapPage
|
|
initialLoading={initialLoading}
|
|
tutorial={tutorial}
|
|
tutorialTheme={tutorialTheme}
|
|
leftPaneWidth={leftPaneWidth}
|
|
leftPaneHandlers={leftPaneHandlers}
|
|
filtersPane={renderFilters()}
|
|
mapData={mapData}
|
|
pois={pois}
|
|
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={actualListings}
|
|
travelTimeEntries={entries}
|
|
densityLabel={densityLabel}
|
|
totalCount={hasActiveFilters ? filterCounts.total : undefined}
|
|
poiPaneOpen={poiPaneOpen}
|
|
onTogglePoiPane={handleTogglePoiPane}
|
|
poiPane={renderPOIPane()}
|
|
showSelectionPane={!!selectedHexagon}
|
|
rightPaneWidth={rightPaneWidth}
|
|
rightPaneHandlers={rightPaneHandlers}
|
|
rightPaneTab={rightPaneTab}
|
|
onAreaTabClick={() => setRightPaneTab('area')}
|
|
onPropertiesTabClick={handlePropertiesTabClick}
|
|
onCloseSelection={handleCloseSelection}
|
|
renderAreaPane={renderAreaPane}
|
|
renderPropertiesPane={renderPropertiesPane}
|
|
toasts={toasts}
|
|
upgradeModal={upgradeModal}
|
|
/>
|
|
);
|
|
}
|