lgtm
This commit is contained in:
parent
5e73287eaf
commit
e2b85fe819
73 changed files with 1180 additions and 2028 deletions
|
|
@ -21,21 +21,14 @@ import {
|
|||
resolveTransitVariant,
|
||||
travelFieldKey,
|
||||
useTravelTime,
|
||||
type TransportMode,
|
||||
} from '../../hooks/useTravelTime';
|
||||
import {
|
||||
apiUrl,
|
||||
authHeaders,
|
||||
buildFilterString,
|
||||
persistDemoChoice,
|
||||
readDemoChoice,
|
||||
setDemoCenter,
|
||||
} from '../../lib/api';
|
||||
import DemoLocationPrompt from './DemoLocationPrompt';
|
||||
import { apiUrl, authHeaders, buildFilterString } from '../../lib/api';
|
||||
import { useFilterCounts } from '../../hooks/useFilterCounts';
|
||||
import { trackEvent } from '../../lib/analytics';
|
||||
import {
|
||||
DEMO_MAX_FILTERS,
|
||||
DEV_LOCATION,
|
||||
REGISTERED_MAX_FILTERS,
|
||||
INITIAL_VIEW_STATE,
|
||||
POSTCODE_ZOOM_THRESHOLD,
|
||||
} from '../../lib/consts';
|
||||
|
|
@ -85,8 +78,6 @@ import type { MapFlyTo, MapPageProps } from './map-page/types';
|
|||
|
||||
export type { ExportState } from './map-page/types';
|
||||
|
||||
declare const __DEV__: boolean;
|
||||
|
||||
const EMPTY_ACTUAL_LISTINGS: ActualListing[] = [];
|
||||
const EMPTY_POIS: POI[] = [];
|
||||
|
||||
|
|
@ -95,7 +86,6 @@ export default function MapPage({
|
|||
poiCategoryGroups,
|
||||
initialFilters,
|
||||
initialViewState,
|
||||
offerDemoLocation,
|
||||
initialPOICategories,
|
||||
initialOverlays,
|
||||
initialCrimeTypes,
|
||||
|
|
@ -155,11 +145,50 @@ export default function MapPage({
|
|||
initialPostcode ?? null
|
||||
);
|
||||
|
||||
// Demo (unlicensed) users are limited to DEMO_MAX_FILTERS filters; hitting the
|
||||
// cap opens the upgrade modal. Licensed users and admins are unlimited.
|
||||
// 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;
|
||||
const filtersUnlimited = user?.subscription === 'licensed' || user?.isAdmin === true;
|
||||
const effectiveFilterCap = isLoggedIn ? REGISTERED_MAX_FILTERS : DEMO_MAX_FILTERS;
|
||||
const filterLimit = filtersUnlimited ? null : effectiveFilterCap;
|
||||
// On a shared link, non-paying users may view and adjust the shared filters but
|
||||
// not add new ones — so a richer shared search can't become a way around the cap.
|
||||
const lockFilterAdds = !filtersUnlimited && !!shareCode;
|
||||
// Which upsell the modal frames: a shared-link block, or a plain filter-cap hit.
|
||||
const upgradeReason: 'filters' | 'shared' = lockFilterAdds ? 'shared' : 'filters';
|
||||
const [filterLimitHit, setFilterLimitHit] = useState(false);
|
||||
const handleFilterLimitReached = useCallback(() => setFilterLimitHit(true), []);
|
||||
const handleFilterLimitReached = useCallback(() => {
|
||||
trackEvent('Upgrade Modal Shown');
|
||||
setFilterLimitHit(true);
|
||||
}, []);
|
||||
|
||||
// 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,
|
||||
|
|
@ -183,7 +212,10 @@ export default function MapPage({
|
|||
} = useFilters({
|
||||
initialFilters,
|
||||
features,
|
||||
filterLimit: filtersUnlimited ? null : DEMO_MAX_FILTERS,
|
||||
filterLimit,
|
||||
// Travel-time entries reserve slots from the same cap (see cappedInitialTravelTime).
|
||||
reservedFilterSlots: filtersUnlimited ? 0 : entries.length,
|
||||
lockAdds: lockFilterAdds,
|
||||
onFilterLimitReached: handleFilterLimitReached,
|
||||
});
|
||||
|
||||
|
|
@ -196,20 +228,6 @@ export default function MapPage({
|
|||
summary: aiFilterSummary,
|
||||
} = useAiFilters();
|
||||
|
||||
const {
|
||||
entries,
|
||||
activeEntries,
|
||||
handleAddEntry,
|
||||
handleRemoveEntry,
|
||||
handleSetDestination,
|
||||
handleSetEntries,
|
||||
handleTimeRangeChange,
|
||||
handleToggleBest,
|
||||
handleToggleNoChange,
|
||||
handleToggleOneChange,
|
||||
handleToggleNoBuses,
|
||||
} = useTravelTime(initialTravelTime);
|
||||
|
||||
const mapFlyToRef = useRef<MapFlyTo | null>(null);
|
||||
const areaPaneScrollTopRef = useRef(0);
|
||||
const propertiesPaneScrollTopRef = useRef(0);
|
||||
|
|
@ -262,13 +280,22 @@ export default function MapPage({
|
|||
const result = await fetchAiFilters(query, hasContext ? context : undefined);
|
||||
if (!result) return;
|
||||
|
||||
handleSetFilters(result.filters);
|
||||
// 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 representable = result.travelTimeFilters
|
||||
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 }) => ({
|
||||
|
|
@ -323,12 +350,26 @@ export default function MapPage({
|
|||
activeEntries,
|
||||
fetchAiFilters,
|
||||
filters,
|
||||
filtersUnlimited,
|
||||
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.
|
||||
const handleAddTravelTimeEntry = useCallback(
|
||||
(mode: TransportMode) => {
|
||||
if (!filtersUnlimited && Object.keys(filters).length + entries.length >= DEMO_MAX_FILTERS) {
|
||||
handleFilterLimitReached();
|
||||
return;
|
||||
}
|
||||
handleAddEntry(mode);
|
||||
},
|
||||
[filtersUnlimited, filters, entries, handleAddEntry, handleFilterLimitReached]
|
||||
);
|
||||
|
||||
const handleClearAll = useCallback(() => {
|
||||
handleSetFilters({});
|
||||
handleCancelPin();
|
||||
|
|
@ -477,93 +518,6 @@ export default function MapPage({
|
|||
[handleCurrentLocationSearch, isMobile, openMobileDrawer, queueCurrentLocationFlyTo]
|
||||
);
|
||||
|
||||
const shareReturnViewRef = useRef(shareCode ? initialViewState : null);
|
||||
// Hide the upgrade modal as soon as the user dismisses it. We can't rely on
|
||||
// the camera fly alone to close it: flying back to the free/shared zone only
|
||||
// clears `licenseRequired` once the resulting refetch returns non-403, and
|
||||
// when the fly target equals the current view (e.g. "back to shared area"
|
||||
// while already at the shared view) `jumpTo` is a no-op, so no refetch fires
|
||||
// and the modal would otherwise stay stuck open.
|
||||
const [upgradeModalDismissed, setUpgradeModalDismissed] = useState(false);
|
||||
const handleZoomToFreeZone = useCallback(() => {
|
||||
setUpgradeModalDismissed(true);
|
||||
setFilterLimitHit(false);
|
||||
// Fly back to the visitor's own free zone. Prefer the server-reported zone
|
||||
// (reflects their chosen demo centre — including a GPS choice made this
|
||||
// session), then a share-return view, then the initial centre.
|
||||
const fz = mapData.freeZone;
|
||||
const target =
|
||||
shareReturnViewRef.current ??
|
||||
(fz
|
||||
? {
|
||||
latitude: (fz.south + fz.north) / 2,
|
||||
longitude: (fz.west + fz.east) / 2,
|
||||
zoom: initialViewState.zoom,
|
||||
}
|
||||
: initialViewState);
|
||||
mapFlyToRef.current?.(target.latitude, target.longitude, target.zoom);
|
||||
}, [initialViewState, mapData.freeZone]);
|
||||
|
||||
// "Check your area" demo prompt: GPS recenters the map AND moves the free zone
|
||||
// (the chosen centre is sent to the server via lib/api on each request);
|
||||
// declining keeps Canary Wharf. Persisted per session so we don't re-ask.
|
||||
const [demoPromptOpen, setDemoPromptOpen] = useState(
|
||||
// Also check the persisted choice directly: MapPage remounts on navigation
|
||||
// (route key change) while App's `offerDemoLocation` is frozen at mount, so a
|
||||
// choice made earlier this session must still suppress a re-prompt.
|
||||
() => !!offerDemoLocation && !screenshotMode && !ogMode && readDemoChoice() == null
|
||||
);
|
||||
const [demoLocating, setDemoLocating] = useState(false);
|
||||
const [demoLocationError, setDemoLocationError] = useState<string | null>(null);
|
||||
|
||||
const handleUseMyLocation = useCallback(() => {
|
||||
const applyCenter = (center: { lat: number; lon: number }) => {
|
||||
setDemoCenter(center);
|
||||
persistDemoChoice(center);
|
||||
setDemoLocating(false);
|
||||
setDemoPromptOpen(false);
|
||||
// handleFlyTo uses map.jumpTo (instant) — no flashing 403s through
|
||||
// intermediate viewports; the move triggers a refetch carrying demoLat/Lon.
|
||||
mapFlyToRef.current?.(
|
||||
center.lat,
|
||||
center.lon,
|
||||
INITIAL_VIEW_STATE.zoom,
|
||||
getMobileMapFlyToOptions()
|
||||
);
|
||||
};
|
||||
if (typeof navigator === 'undefined' || !navigator.geolocation) {
|
||||
setDemoLocationError(t('locationSearch.geolocationUnsupported'));
|
||||
return;
|
||||
}
|
||||
setDemoLocating(true);
|
||||
setDemoLocationError(null);
|
||||
navigator.geolocation.getCurrentPosition(
|
||||
(position) => applyCenter({ lat: position.coords.latitude, lon: position.coords.longitude }),
|
||||
() => {
|
||||
// In dev, geolocation is usually blocked (e.g. served over http), so fall
|
||||
// back to 10 Downing Street to keep the "data follows you" flow testable.
|
||||
if (__DEV__) {
|
||||
applyCenter({ lat: DEV_LOCATION.latitude, lon: DEV_LOCATION.longitude });
|
||||
return;
|
||||
}
|
||||
setDemoLocating(false);
|
||||
setDemoLocationError(t('locationSearch.geolocationFailed'));
|
||||
},
|
||||
{ enableHighAccuracy: false, timeout: 10000, maximumAge: 600000 }
|
||||
);
|
||||
}, [t, getMobileMapFlyToOptions]);
|
||||
|
||||
const handleUseDefaultLocation = useCallback(() => {
|
||||
persistDemoChoice('declined');
|
||||
setDemoPromptOpen(false);
|
||||
}, []);
|
||||
|
||||
// Never show the demo prompt to licensed/admin users — e.g. if the session
|
||||
// hydrated as free then refreshed to licensed, or they signed in in another tab.
|
||||
useEffect(() => {
|
||||
if (filtersUnlimited) setDemoPromptOpen(false);
|
||||
}, [filtersUnlimited]);
|
||||
|
||||
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
|
||||
|
|
@ -631,7 +585,6 @@ export default function MapPage({
|
|||
dataLength: mapData.data.length,
|
||||
postcodeDataLength: mapData.postcodeData.length,
|
||||
usePostcodeView: mapData.usePostcodeView,
|
||||
licenseRequired: mapData.licenseRequired,
|
||||
});
|
||||
|
||||
const handleMobileHexagonClick = useCallback(
|
||||
|
|
@ -650,7 +603,7 @@ export default function MapPage({
|
|||
mapData.resolution,
|
||||
areaStats
|
||||
);
|
||||
const tutorial = useTutorial(initialLoading, isMobile, deferTutorial || mapData.licenseRequired);
|
||||
const tutorial = useTutorial(initialLoading, isMobile, deferTutorial);
|
||||
const tutorialTheme = useMemo(() => getTutorialStyles(theme), [theme]);
|
||||
const densityLabel = t('mapLegend.historicalMatches');
|
||||
const mobileLegendMeta = useMobileLegendMeta(viewFeature, features);
|
||||
|
|
@ -725,11 +678,7 @@ export default function MapPage({
|
|||
}, [dashboardParams, onDashboardParamsChange]);
|
||||
|
||||
const dashboardReady =
|
||||
!initialLoading &&
|
||||
!mapData.loading &&
|
||||
!mapData.licenseRequired &&
|
||||
mapData.bounds != null &&
|
||||
mapData.currentView != null;
|
||||
!initialLoading && !mapData.loading && mapData.bounds != null && mapData.currentView != null;
|
||||
|
||||
useEffect(() => {
|
||||
onDashboardReadyChange?.(dashboardReady);
|
||||
|
|
@ -741,16 +690,6 @@ export default function MapPage({
|
|||
};
|
||||
}, [onDashboardReadyChange]);
|
||||
|
||||
useEffect(() => {
|
||||
if (mapData.licenseRequired) {
|
||||
trackEvent('Upgrade Modal Shown');
|
||||
} else {
|
||||
// Back in a viewable area — re-arm so the modal shows again the next time
|
||||
// the user pans into a gated area.
|
||||
setUpgradeModalDismissed(false);
|
||||
}
|
||||
}, [mapData.licenseRequired]);
|
||||
|
||||
// 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. Prevents a
|
||||
// stale flag from resurfacing the modal spuriously.
|
||||
|
|
@ -909,7 +848,7 @@ export default function MapPage({
|
|||
openInfoFeature={pendingInfoFeature}
|
||||
onClearOpenInfoFeature={onClearPendingInfoFeature}
|
||||
travelTimeEntries={entries}
|
||||
onTravelTimeAddEntry={handleAddEntry}
|
||||
onTravelTimeAddEntry={handleAddTravelTimeEntry}
|
||||
onTravelTimeRemoveEntry={handleTravelTimeRemoveEntry}
|
||||
onTravelTimeSetDestination={handleTravelTimeSetDestination}
|
||||
onTravelTimeRangeChange={handleTimeRangeChange}
|
||||
|
|
@ -956,7 +895,7 @@ export default function MapPage({
|
|||
features,
|
||||
filterCounts.impacts,
|
||||
filters,
|
||||
handleAddEntry,
|
||||
handleAddTravelTimeEntry,
|
||||
handleAddFilter,
|
||||
handleAiFilterSubmit,
|
||||
handleClearAll,
|
||||
|
|
@ -1082,51 +1021,25 @@ export default function MapPage({
|
|||
</div>
|
||||
) : null;
|
||||
|
||||
// The upgrade modal serves two cases: panning outside the free zone
|
||||
// (licenseRequired) and hitting the demo filter cap (filterLimitHit). The zone
|
||||
// case takes precedence; for the filter case, dismissing just closes the modal
|
||||
// (no camera move).
|
||||
const showZoneUpgradeModal = mapData.licenseRequired && !upgradeModalDismissed;
|
||||
const showFilterUpgradeModal = !showZoneUpgradeModal && filterLimitHit && !filtersUnlimited;
|
||||
const upgradeModal =
|
||||
showZoneUpgradeModal || showFilterUpgradeModal ? (
|
||||
<Suspense fallback={null}>
|
||||
<UpgradeModal
|
||||
isLoggedIn={!!user}
|
||||
onLoginClick={() =>
|
||||
onCheckoutLoginClick ? onCheckoutLoginClick(checkoutReturnPath) : onLoginClick()
|
||||
}
|
||||
onRegisterClick={() =>
|
||||
onCheckoutRegisterClick
|
||||
? onCheckoutRegisterClick(checkoutReturnPath)
|
||||
: onRegisterClick()
|
||||
}
|
||||
onStartCheckout={() => license.startCheckout(checkoutReturnPath)}
|
||||
onZoomToFreeZone={
|
||||
showFilterUpgradeModal ? () => setFilterLimitHit(false) : handleZoomToFreeZone
|
||||
}
|
||||
isShareReturn={showZoneUpgradeModal && !!shareReturnViewRef.current}
|
||||
/>
|
||||
</Suspense>
|
||||
) : null;
|
||||
|
||||
const demoLocationPrompt = demoPromptOpen ? (
|
||||
<DemoLocationPrompt
|
||||
locating={demoLocating}
|
||||
error={demoLocationError}
|
||||
onUseLocation={handleUseMyLocation}
|
||||
onUseDefault={handleUseDefaultLocation}
|
||||
/>
|
||||
// The upgrade modal is shown when a non-paying user hits the filter cap.
|
||||
// Dismissing it just closes the modal (the filters they have stay applied).
|
||||
const showFilterUpgradeModal = filterLimitHit && !filtersUnlimited;
|
||||
const upgradeModal = showFilterUpgradeModal ? (
|
||||
<Suspense fallback={null}>
|
||||
<UpgradeModal
|
||||
isLoggedIn={!!user}
|
||||
onLoginClick={() =>
|
||||
onCheckoutLoginClick ? onCheckoutLoginClick(checkoutReturnPath) : onLoginClick()
|
||||
}
|
||||
onRegisterClick={() =>
|
||||
onCheckoutRegisterClick ? onCheckoutRegisterClick(checkoutReturnPath) : onRegisterClick()
|
||||
}
|
||||
onStartCheckout={() => license.startCheckout(checkoutReturnPath)}
|
||||
onClose={() => setFilterLimitHit(false)}
|
||||
/>
|
||||
</Suspense>
|
||||
) : null;
|
||||
|
||||
// Both overlays render where the page components place {upgradeModal}.
|
||||
const overlayModals = (
|
||||
<>
|
||||
{upgradeModal}
|
||||
{demoLocationPrompt}
|
||||
</>
|
||||
);
|
||||
|
||||
if (isMobile) {
|
||||
return (
|
||||
<MobileMapPage
|
||||
|
|
@ -1178,7 +1091,7 @@ export default function MapPage({
|
|||
renderAreaPane={renderAreaPane}
|
||||
renderPropertiesPane={renderPropertiesPane}
|
||||
toasts={toasts}
|
||||
upgradeModal={overlayModals}
|
||||
upgradeModal={upgradeModal}
|
||||
editingBar={editingBar}
|
||||
/>
|
||||
);
|
||||
|
|
@ -1238,7 +1151,7 @@ export default function MapPage({
|
|||
renderAreaPane={renderAreaPane}
|
||||
renderPropertiesPane={renderPropertiesPane}
|
||||
toasts={toasts}
|
||||
upgradeModal={overlayModals}
|
||||
upgradeModal={upgradeModal}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue