new demo mode & tenure
Some checks failed
Build and publish Docker image / build-and-push (push) Successful in 8m43s
CI / Check (push) Failing after 8m49s

This commit is contained in:
Andras Schmelczer 2026-06-17 07:54:30 +01:00
parent 7656f24544
commit 4a0f00f2a4
64 changed files with 2875 additions and 338 deletions

View file

@ -22,10 +22,23 @@ import {
travelFieldKey,
useTravelTime,
} from '../../hooks/useTravelTime';
import { apiUrl, authHeaders, buildFilterString } from '../../lib/api';
import {
apiUrl,
authHeaders,
buildFilterString,
persistDemoChoice,
readDemoChoice,
setDemoCenter,
} from '../../lib/api';
import DemoLocationPrompt from './DemoLocationPrompt';
import { useFilterCounts } from '../../hooks/useFilterCounts';
import { trackEvent } from '../../lib/analytics';
import { INITIAL_VIEW_STATE, POSTCODE_ZOOM_THRESHOLD } from '../../lib/consts';
import {
DEMO_MAX_FILTERS,
DEV_LOCATION,
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';
@ -72,6 +85,8 @@ import type { MapFlyTo, MapPageProps } from './map-page/types';
export type { ExportState } from './map-page/types';
declare const __DEV__: boolean;
const EMPTY_ACTUAL_LISTINGS: ActualListing[] = [];
export default function MapPage({
@ -79,6 +94,7 @@ export default function MapPage({
poiCategoryGroups,
initialFilters,
initialViewState,
offerDemoLocation,
initialPOICategories,
initialOverlays,
initialCrimeTypes,
@ -138,6 +154,12 @@ 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.
const filtersUnlimited = user?.subscription === 'licensed' || user?.isAdmin === true;
const [filterLimitHit, setFilterLimitHit] = useState(false);
const handleFilterLimitReached = useCallback(() => setFilterLimitHit(true), []);
const {
filters,
activeFeature,
@ -160,6 +182,8 @@ export default function MapPage({
} = useFilters({
initialFilters,
features,
filterLimit: filtersUnlimited ? null : DEMO_MAX_FILTERS,
onFilterLimitReached: handleFilterLimitReached,
});
const {
@ -461,10 +485,78 @@ export default function MapPage({
const [upgradeModalDismissed, setUpgradeModalDismissed] = useState(false);
const handleZoomToFreeZone = useCallback(() => {
setUpgradeModalDismissed(true);
const target = shareReturnViewRef.current ?? INITIAL_VIEW_STATE;
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 pois = usePOIData(mapData.bounds, selectedPOICategories);
const overlaysZoomedIn = (mapData.currentView?.zoom ?? 0) >= POSTCODE_ZOOM_THRESHOLD;
const actualListingsFilterParam = useMemo(
@ -648,6 +740,15 @@ export default function MapPage({
}
}, [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.
useEffect(() => {
if (filtersUnlimited || Object.keys(filters).length < DEMO_MAX_FILTERS) {
setFilterLimitHit(false);
}
}, [filtersUnlimited, filters]);
const handleUpgradeClick = useCallback(() => {
onNavigateTo('pricing');
}, [onNavigateTo]);
@ -968,8 +1069,14 @@ 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 =
mapData.licenseRequired && !upgradeModalDismissed ? (
showZoneUpgradeModal || showFilterUpgradeModal ? (
<Suspense fallback={null}>
<UpgradeModal
isLoggedIn={!!user}
@ -982,12 +1089,31 @@ export default function MapPage({
: onRegisterClick()
}
onStartCheckout={() => license.startCheckout(checkoutReturnPath)}
onZoomToFreeZone={handleZoomToFreeZone}
isShareReturn={!!shareReturnViewRef.current}
onZoomToFreeZone={
showFilterUpgradeModal ? () => setFilterLimitHit(false) : handleZoomToFreeZone
}
isShareReturn={showZoneUpgradeModal && !!shareReturnViewRef.current}
/>
</Suspense>
) : null;
const demoLocationPrompt = demoPromptOpen ? (
<DemoLocationPrompt
locating={demoLocating}
error={demoLocationError}
onUseLocation={handleUseMyLocation}
onUseDefault={handleUseDefaultLocation}
/>
) : null;
// Both overlays render where the page components place {upgradeModal}.
const overlayModals = (
<>
{upgradeModal}
{demoLocationPrompt}
</>
);
if (isMobile) {
return (
<MobileMapPage
@ -1039,7 +1165,7 @@ export default function MapPage({
renderAreaPane={renderAreaPane}
renderPropertiesPane={renderPropertiesPane}
toasts={toasts}
upgradeModal={upgradeModal}
upgradeModal={overlayModals}
editingBar={editingBar}
/>
);
@ -1099,7 +1225,7 @@ export default function MapPage({
renderAreaPane={renderAreaPane}
renderPropertiesPane={renderPropertiesPane}
toasts={toasts}
upgradeModal={upgradeModal}
upgradeModal={overlayModals}
/>
);
}