lgtm
Some checks failed
CI / Check (push) Failing after 2m47s
Build and publish Docker image / build-and-push (push) Successful in 5m37s

This commit is contained in:
Andras Schmelczer 2026-06-26 16:48:20 +01:00
parent 5e73287eaf
commit e2b85fe819
73 changed files with 1180 additions and 2028 deletions

View file

@ -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}
/>
);
}