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

@ -12,9 +12,9 @@ const ROUTES = [
{
path: '/',
output: 'index.html',
title: 'Stop searching the wrong places | Perfect Postcode',
title: 'Find the best-value postcode in England | Perfect Postcode',
description:
'Filter every postcode in England by budget, commute, schools, crime, noise, broadband, property prices and amenities before you start chasing viewings.',
'Rank every postcode in England by value: £/sqm, sold prices, schools, commute, crime, noise and broadband. Find the underpriced postcode the market overlooked.',
},
{
path: '/learn',
@ -345,64 +345,64 @@ async function prerender() {
const MAX_ATTEMPTS = 3;
async function renderRoute(route) {
// A fresh context per attempt: pages otherwise share cache/storage, and a
// poisoned chunk-fetch in the shared cache makes a route fail every retry.
const context = await browser.createBrowserContext();
const page = await context.newPage();
// A fresh context per attempt: pages otherwise share cache/storage, and a
// poisoned chunk-fetch in the shared cache makes a route fail every retry.
const context = await browser.createBrowserContext();
const page = await context.newPage();
// Intercept API requests to prevent real fetches and retry loops.
await page.setRequestInterception(true);
page.on('request', (req) => {
const url = req.url();
if (url.includes('/api/features')) {
req.respond({
status: 200,
contentType: 'application/json',
body: JSON.stringify({ groups: [] }),
});
} else if (url.includes('/api/poi-categories')) {
req.respond({
status: 200,
contentType: 'application/json',
body: JSON.stringify({ groups: [] }),
});
} else if (url.includes('/api/pricing')) {
req.respond({
status: 200,
contentType: 'application/json',
body: JSON.stringify({
licensed_count: 120,
current_price_pence: 999,
tiers: [
{ up_to: 50, price_pence: 99, slots: 50 },
{ up_to: 150, price_pence: 999, slots: 100 },
{ up_to: 250, price_pence: 2999, slots: 100 },
{ up_to: 350, price_pence: 4999, slots: 100 },
{ up_to: null, price_pence: 9999, slots: 0 },
],
}),
});
} else if (url.includes('/api/')) {
req.respond({
status: 200,
contentType: 'application/json',
body: '{}',
});
} else {
req.continue();
}
// Intercept API requests to prevent real fetches and retry loops.
await page.setRequestInterception(true);
page.on('request', (req) => {
const url = req.url();
if (url.includes('/api/features')) {
req.respond({
status: 200,
contentType: 'application/json',
body: JSON.stringify({ groups: [] }),
});
} else if (url.includes('/api/poi-categories')) {
req.respond({
status: 200,
contentType: 'application/json',
body: JSON.stringify({ groups: [] }),
});
} else if (url.includes('/api/pricing')) {
req.respond({
status: 200,
contentType: 'application/json',
body: JSON.stringify({
licensed_count: 120,
current_price_pence: 999,
tiers: [
{ up_to: 50, price_pence: 99, slots: 50 },
{ up_to: 150, price_pence: 999, slots: 100 },
{ up_to: 250, price_pence: 2999, slots: 100 },
{ up_to: 350, price_pence: 4999, slots: 100 },
{ up_to: null, price_pence: 9999, slots: 0 },
],
}),
});
} else if (url.includes('/api/')) {
req.respond({
status: 200,
contentType: 'application/json',
body: '{}',
});
} else {
req.continue();
}
});
try {
await page.goto(`http://127.0.0.1:${port}${route.path}`, {
waitUntil: 'networkidle0',
timeout: 30000,
});
try {
await page.goto(`http://127.0.0.1:${port}${route.path}`, {
waitUntil: 'networkidle0',
timeout: 30000,
});
await page.waitForSelector('h1', { timeout: 10000 });
await page.waitForSelector('h1', { timeout: 10000 });
// Extract and clean the rendered HTML.
const html = await page.evaluate(() => {
// Extract and clean the rendered HTML.
const html = await page.evaluate(() => {
const root = document.getElementById('root');
if (!root) return '';
@ -420,18 +420,18 @@ async function prerender() {
});
return root.innerHTML;
});
});
if (!html || html.length < MIN_HTML_CHARS) {
throw new Error(
`Prerender produced too little HTML for ${route.path} (${html?.length ?? 0} chars)`
);
}
return html;
} finally {
await context.close().catch(() => {});
if (!html || html.length < MIN_HTML_CHARS) {
throw new Error(
`Prerender produced too little HTML for ${route.path} (${html?.length ?? 0} chars)`
);
}
return html;
} finally {
await context.close().catch(() => {});
}
}
try {

View file

@ -12,7 +12,13 @@ import {
} from './lib/seoRoutes';
import Header, { type Page } from './components/ui/Header';
import type { FeatureMeta, FeatureGroup, POICategoriesResponse, POICategoryGroup } from './types';
import { fetchWithRetry, apiUrl, logNonAbortError } from './lib/api';
import {
fetchWithRetry,
apiUrl,
logNonAbortError,
readDemoChoice,
setDemoCenter,
} from './lib/api';
import { trackEvent } from './lib/analytics';
import { parseUrlState } from './lib/url-state';
import pb from './lib/pocketbase';
@ -209,11 +215,35 @@ export default function App() {
}, []);
const initialRoute = useMemo(() => pathToPage(window.location.pathname), []);
const [mapUrlState, setMapUrlState] = useState(urlState);
// IP-derived initial map centre (fetched from /api/geo on a fresh visit). Null
// until resolved; `geoChecked` flips once the lookup settles (or times out) so
// the dashboard map mounts with the right centre instead of a London flash.
const [ipViewState, setIpViewState] = useState<typeof INITIAL_VIEW_STATE | null>(null);
const [geoChecked, setGeoChecked] = useState(() => urlState.hasExplicitView);
// Demo location: a fresh visitor (no explicit URL view) is offered the "check
// your area" prompt (in MapPage). A choice made earlier this session is reused —
// GPS coords re-centre the map and are sent to the server (via lib/api), while
// 'declined' keeps Canary Wharf. Resolve it once, before children fetch, so the
// very first data request already carries the centre.
// Read license synchronously from the persisted session so we never apply the
// demo location for a returning licensed user (they bypass the free zone, and we
// don't want their location in request URLs).
const licensedAtMount = useMemo(
() =>
pb.authStore.isValid &&
(pb.authStore.record?.subscription === 'licensed' ||
pb.authStore.record?.is_admin === true),
[]
);
const initialDemoChoice = useMemo(() => {
const choice = readDemoChoice();
if (choice && choice !== 'declined' && !licensedAtMount) setDemoCenter(choice);
return choice;
}, [licensedAtMount]);
const [demoView] = useState<typeof INITIAL_VIEW_STATE | null>(() =>
initialDemoChoice && initialDemoChoice !== 'declined' && !licensedAtMount
? {
...INITIAL_VIEW_STATE,
latitude: initialDemoChoice.lat,
longitude: initialDemoChoice.lon,
}
: null
);
const [dashboardRouteKey, setDashboardRouteKey] = useState(() =>
window.location.pathname === '/dashboard' ? window.location.search : ''
);
@ -228,8 +258,8 @@ export default function App() {
() =>
mapUrlState.hasExplicitView
? mapUrlState.viewState
: (ipViewState ?? INITIAL_VIEW_STATE),
[mapUrlState.hasExplicitView, mapUrlState.viewState, ipViewState]
: (demoView ?? INITIAL_VIEW_STATE),
[mapUrlState.hasExplicitView, mapUrlState.viewState, demoView]
);
const isScreenshotMode = useMemo(() => {
@ -282,6 +312,16 @@ export default function App() {
refreshAuth,
clearError,
} = useAuth();
// Only offer the "check your area" demo prompt to non-licensed visitors on a
// fresh visit. `user` is hydrated synchronously from the persisted PocketBase
// session, so a returning premium user is recognised at mount (no flash).
const offerDemoLocation =
!urlState.hasExplicitView && initialDemoChoice == null && !hasFullAccess(user);
// Stop transmitting the demo location once the user is licensed (in-session
// upgrade, cross-tab sign-in, or a stale session that refreshed to licensed).
useEffect(() => {
if (hasFullAccess(user)) setDemoCenter(null);
}, [user]);
const { startCheckout: startPostAuthCheckout } = useLicense();
const [showAuthModal, setShowAuthModal] = useState(false);
const [authModalTab, setAuthModalTab] = useState<'login' | 'register'>('login');
@ -424,44 +464,6 @@ export default function App() {
return () => controller.abort();
}, []);
// On a fresh visit (no view in the URL), centre the initial map on the visitor's
// approximate location from their IP. Falls back to INITIAL_VIEW_STATE (inner
// London) when geolocation is unavailable. Skipped when the URL already carries a
// view (shared/dashboard links) so we never override it. `geoChecked` gates the
// dashboard map render so it mounts once with the resolved centre.
useEffect(() => {
if (urlState.hasExplicitView) return; // geoChecked already initialised true
let cancelled = false;
const controller = new AbortController();
const finish = () => {
if (!cancelled) setGeoChecked(true);
};
// Never block the map for more than this if the lookup is slow/hangs.
const timeout = window.setTimeout(finish, 1500);
fetch(apiUrl('geo'), { signal: controller.signal })
.then((res) => (res.ok ? res.json() : null))
.then((data: { center?: { lat: number; lon: number } } | null) => {
if (cancelled || !data?.center) return;
setIpViewState({
...INITIAL_VIEW_STATE,
latitude: data.center.lat,
longitude: data.center.lon,
});
})
.catch(() => {})
.finally(() => {
window.clearTimeout(timeout);
finish();
});
return () => {
cancelled = true;
window.clearTimeout(timeout);
controller.abort();
};
// Mount-only: resolve the IP-based initial view exactly once.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const navigateTo = useCallback(
(page: Page, hash?: string, infoFeature?: string) => {
const targetHash = normalizeHash(hash);
@ -769,8 +771,6 @@ export default function App() {
refreshAuth();
}}
/>
) : !geoChecked ? (
<PageFallback />
) : (
<MapPage
key={dashboardRouteKey}
@ -778,6 +778,7 @@ export default function App() {
poiCategoryGroups={poiCategoryGroups}
initialFilters={mapUrlState.filters}
initialViewState={initialViewState}
offerDemoLocation={offerDemoLocation}
initialPOICategories={mapUrlState.poiCategories}
initialOverlays={mapUrlState.overlays}
initialCrimeTypes={mapUrlState.crimeTypes}

View file

@ -0,0 +1,71 @@
import { useTranslation } from 'react-i18next';
import { useModalA11y } from '../../hooks/useModalA11y';
import { SpinnerIcon } from '../ui/icons/SpinnerIcon';
interface DemoLocationPromptProps {
/** True while a geolocation request is in flight. */
locating: boolean;
/** Error message to show (e.g. permission denied), or null. */
error: string | null;
onUseLocation: () => void;
onUseDefault: () => void;
}
/**
* First-visit demo prompt: offers to centre the map on the visitor's GPS location
* (with consent) or start at the Canary Wharf default. Rendered over the map (which
* is already showing Canary Wharf), so declining is just a dismiss.
*/
export default function DemoLocationPrompt({
locating,
error,
onUseLocation,
onUseDefault,
}: DemoLocationPromptProps) {
const { t } = useTranslation();
const dialogRef = useModalA11y();
return (
<div
className="fixed inset-0 z-[10000] flex items-center justify-center bg-black/50"
role="presentation"
>
<div
ref={dialogRef}
role="dialog"
aria-modal="true"
aria-labelledby="demo-location-title"
tabIndex={-1}
className="relative w-full max-w-sm mx-4 bg-white dark:bg-warm-800 rounded-2xl shadow-2xl border border-warm-200 dark:border-warm-700 overflow-hidden outline-none"
>
<div className="bg-gradient-to-br from-navy-950 to-teal-900 px-6 py-7 text-center">
<h2 id="demo-location-title" className="text-xl font-bold text-white mb-2">
{t('demoLocation.title')}
</h2>
<p className="text-warm-300 text-sm">{t('demoLocation.body')}</p>
</div>
<div className="px-6 py-6 flex flex-col gap-3">
<button
type="button"
onClick={onUseLocation}
disabled={locating}
className="w-full px-6 py-3 border border-[#d27a11] bg-[#f09a22] text-navy-950 rounded-lg font-semibold hover:bg-[#df8614] transition-colors shadow-lg shadow-[#7a3905]/25 disabled:opacity-50 disabled:cursor-wait flex items-center justify-center gap-2"
>
{locating && <SpinnerIcon className="w-5 h-5 animate-spin" />}
{locating ? t('demoLocation.locating') : t('demoLocation.useLocation')}
</button>
<button
type="button"
onClick={onUseDefault}
className="w-full px-4 py-2 text-sm text-teal-600 dark:text-teal-400 hover:text-teal-800 dark:hover:text-teal-300"
>
{t('demoLocation.useDefault')}
</button>
{error && <p className="text-center text-sm text-red-600 dark:text-red-400">{error}</p>}
</div>
</div>
</div>
);
}

View file

@ -9,7 +9,19 @@ describe('compactHistogramLabel', () => {
compactHistogramLabel(index, 5, 0, 5.95, center, fmt, true)
);
expect(labels).toEqual(['0', '1', '3', '5', '6+']);
expect(labels).toEqual(['', '1', '3', '5', '6+']);
});
it('does not show 0 twice when the low-outlier bin starts at zero', () => {
const fmt = (value: number) => value.toFixed(2);
// Integer-binned count feature (e.g. school catchments): p1=0, p99=3,
// one-unit-wide middle bins. Bin 0 (< 0) is empty, bin 1 holds the value 0.
const labels = [0, 0.5, 1.5, 2.5, 3].map((center, index) =>
compactHistogramLabel(index, 5, 0, 3, center, fmt, true)
);
expect(labels).toEqual(['', '0', '1', '2', '3+']);
expect(labels.filter((label) => label === '0')).toHaveLength(1);
});
it('labels the first integer count bucket as zero when it means below one', () => {

View file

@ -52,7 +52,11 @@ export function compactHistogramLabel(
if (index === 0) {
if (!integerLabels) return `<${formatLabel(p1)}`;
const firstBoundary = Math.ceil(p1);
return firstBoundary <= 1 ? '0' : `<${firstBoundary.toLocaleString()}`;
// This outlier bin holds values strictly below p1. When p1 <= 0 there are no
// (non-negative) integers below it, so the value 0 lives in the first middle
// bin instead — labelling this empty bin "0" too would show "0" twice.
if (firstBoundary <= 0) return '';
return firstBoundary === 1 ? '0' : `<${firstBoundary.toLocaleString()}`;
}
if (index === barCount - 1) {
if (!integerLabels) return `${formatLabel(p99)}+`;

View file

@ -2,7 +2,7 @@ import { useState, useCallback, useRef, useEffect } from 'react';
import { useTranslation } from 'react-i18next';
import type { MapFlyToOptions, PostcodeGeometry } from '../../types';
import { authHeaders, isAbortError } from '../../lib/api';
import { POSTCODE_SEARCH_ZOOM } from '../../lib/consts';
import { DEV_LOCATION, POSTCODE_SEARCH_ZOOM } from '../../lib/consts';
import { useIsMobile } from '../../hooks/useIsMobile';
import {
useLocationSearch,
@ -56,11 +56,6 @@ const ZOOM_FOR_TYPE: Record<string, number> = {
retail: 15,
};
const DEV_CURRENT_LOCATION = {
latitude: 51.5033635,
longitude: -0.1276248,
};
export default function LocationSearch({
onFlyTo,
onLocationSearched,
@ -288,7 +283,7 @@ export default function LocationSearch({
search.close();
try {
const { latitude, longitude } = __DEV__
? DEV_CURRENT_LOCATION
? DEV_LOCATION
: await new Promise<GeolocationCoordinates>((resolve, reject) => {
if (!navigator.geolocation) {
reject(new Error('Geolocation unsupported'));

View file

@ -82,6 +82,9 @@ interface MapProps {
hideLegend?: boolean;
hideLocationSearch?: boolean;
hideTopCardsWhenNarrow?: boolean;
/** Space (px) to keep clear on the right of the top-card overlay so the
* search bar doesn't run under controls anchored there (mobile buttons). */
topCardsRightInset?: number;
travelTimeEntries?: TravelTimeEntry[];
densityLabel?: string;
totalCount?: number;
@ -227,6 +230,7 @@ export default memo(function Map({
hideLegend = false,
hideLocationSearch = false,
hideTopCardsWhenNarrow = false,
topCardsRightInset = 0,
travelTimeEntries = EMPTY_TRAVEL_ENTRIES,
densityLabel: densityLabelProp,
totalCount: totalCountProp,
@ -337,6 +341,31 @@ export default memo(function Map({
setMapReady(true);
}, []);
// deck.gl runs interleaved, sharing this canvas's WebGL context. If the browser
// drops and restores that context, MapLibre rebuilds its own GL resources, but
// the deck instance keeps buffers/textures from the dead context — the source of
// the "bufferSubData: no buffer" / "bindTexture: deleted object" errors. Remount
// the overlay on restore so deck.gl rebuilds against the live context.
const [deckOverlayKey, setDeckOverlayKey] = useState(0);
useEffect(() => {
if (!mapReady) return;
const canvas = mapRef.current?.getCanvas();
if (!canvas) return;
const handleContextLost = (event: Event) => {
// Let the browser restore the context instead of giving up on it.
event.preventDefault();
};
const handleContextRestored = () => setDeckOverlayKey((key) => key + 1);
canvas.addEventListener('webglcontextlost', handleContextLost);
canvas.addEventListener('webglcontextrestored', handleContextRestored);
return () => {
canvas.removeEventListener('webglcontextlost', handleContextLost);
canvas.removeEventListener('webglcontextrestored', handleContextRestored);
};
}, [mapReady]);
const handleFlyTo = useCallback(
(lat: number, lng: number, zoom: number, options?: MapFlyToOptions) => {
const targetPoint =
@ -486,7 +515,7 @@ export default memo(function Map({
activeCrimeTypes={activeCrimeTypes}
zoom={viewState.zoom}
/>
<DeckOverlay layers={layers} getTooltip={null} />
<DeckOverlay key={deckOverlayKey} layers={layers} getTooltip={null} />
{!screenshotMode && <ScaleControl position="bottom-left" maxWidth={100} unit="metric" />}
</MapGL>
{basemap === 'satellite' && (
@ -549,6 +578,7 @@ export default memo(function Map({
layoutClass={topCardsLayoutClass}
showLocationSearch={showLocationSearch}
showLegend={showLegend}
rightInset={topCardsRightInset}
onFlyTo={handleFlyTo}
onLocationSearched={onLocationSearched}
onCurrentLocationFound={onCurrentLocationFound}

View file

@ -0,0 +1,95 @@
// @vitest-environment jsdom
import { act, cleanup, fireEvent, render, screen } from '@testing-library/react';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { MapErrorBoundary } from './MapErrorBoundary';
const captureException = vi.fn();
vi.mock('@sentry/react', () => ({
captureException: (...args: unknown[]) => captureException(...args),
}));
// Controls whether the child throws on its next render. The boundary remounts
// the child on recovery, so flipping this lets us simulate a transient crash.
let shouldThrow = true;
function MaybeBoom() {
if (shouldThrow) {
throw new Error('webgl boom');
}
return <div>map content</div>;
}
let consoleErrorSpy: ReturnType<typeof vi.spyOn>;
beforeEach(() => {
shouldThrow = true;
captureException.mockClear();
// React logs caught render errors to console.error; keep test output clean.
consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
vi.useFakeTimers();
});
afterEach(() => {
vi.runOnlyPendingTimers();
vi.useRealTimers();
consoleErrorSpy.mockRestore();
cleanup();
});
describe('MapErrorBoundary', () => {
it('renders children when they do not throw', () => {
shouldThrow = false;
render(
<MapErrorBoundary>
<MaybeBoom />
</MapErrorBoundary>
);
expect(screen.getByText('map content')).toBeTruthy();
expect(captureException).not.toHaveBeenCalled();
});
it('reports the error and auto-recovers by remounting the map', () => {
render(
<MapErrorBoundary>
<MaybeBoom />
</MapErrorBoundary>
);
// The throw is caught: children are not shown, and the error is reported.
expect(screen.queryByText('map content')).toBeNull();
expect(captureException).toHaveBeenCalledTimes(1);
// The next mount succeeds; advancing past the recovery delay remounts it.
shouldThrow = false;
act(() => {
vi.advanceTimersByTime(1000);
});
expect(screen.getByText('map content')).toBeTruthy();
});
it('falls back to a manual retry after repeated crashes, then recovers on retry', () => {
render(
<MapErrorBoundary>
<MaybeBoom />
</MapErrorBoundary>
);
// Keep crashing through every auto-recovery attempt.
act(() => {
vi.advanceTimersByTime(1000);
});
act(() => {
vi.advanceTimersByTime(1000);
});
const retryButton = screen.getByRole('button', { name: /reload the map/i });
expect(retryButton).toBeTruthy();
expect(captureException.mock.calls.length).toBeGreaterThanOrEqual(3);
// Manual retry remounts the (now healthy) map.
shouldThrow = false;
fireEvent.click(retryButton);
expect(screen.getByText('map content')).toBeTruthy();
});
});

View file

@ -0,0 +1,141 @@
import { Component, Fragment, type ReactNode } from 'react';
import * as Sentry from '@sentry/react';
import { MapFallback } from './map-page/Fallbacks';
/**
* Scoped error boundary for the WebGL map.
*
* deck.gl runs in interleaved mode, sharing MapLibre's WebGL context. When that
* shared context gets into a bad state (lost context, a buffer/texture finalized
* while still referenced the "bufferSubData: no buffer" / "bindTexture: deleted
* object" storm), the next interleaved draw can throw. deck.gl's own animation
* loop swallows render errors via its `onError` prop, but interleaved draws fire
* from MapLibre's synchronous `render` event, which can run inside a React commit
* so the throw bubbles to the nearest React error boundary.
*
* Without this, that throw reaches the single top-level boundary and blanks the
* entire app ("Something went wrong / Refresh the page"). This boundary contains
* the blast radius to the map and auto-recovers by remounting it, which builds a
* fresh MapLibre + deck.gl context. Filters, selection and viewport live above
* this boundary (in MapPage / the URL), so a remount preserves them.
*/
const MAX_AUTO_RECOVERIES = 2;
// Remount after a short delay so a transient GL state can settle first.
const RECOVERY_DELAY_MS = 400;
// Crashes spaced further apart than this are treated as independent, so the
// auto-recovery budget resets — only a tight crash loop escalates to manual retry.
const STABILITY_WINDOW_MS = 30_000;
interface MapErrorBoundaryProps {
children: ReactNode;
}
interface MapErrorBoundaryState {
errored: boolean;
failedPermanently: boolean;
renderKey: number;
recoveries: number;
}
export class MapErrorBoundary extends Component<MapErrorBoundaryProps, MapErrorBoundaryState> {
state: MapErrorBoundaryState = {
errored: false,
failedPermanently: false,
renderKey: 0,
recoveries: 0,
};
private recoveryTimer: ReturnType<typeof setTimeout> | null = null;
private lastErrorAt = 0;
static getDerivedStateFromError(): Partial<MapErrorBoundaryState> {
return { errored: true };
}
componentDidCatch(error: Error, info: { componentStack?: string | null }) {
const now = Date.now();
const withinWindow = now - this.lastErrorAt < STABILITY_WINDOW_MS;
this.lastErrorAt = now;
const recoveries = withinWindow ? this.state.recoveries + 1 : 1;
Sentry.captureException(error, {
tags: { scope: 'map' },
extra: { componentStack: info.componentStack, recoveries },
});
if (recoveries <= MAX_AUTO_RECOVERIES) {
this.scheduleRecovery(recoveries);
} else {
this.setState({ failedPermanently: true, recoveries });
}
}
componentWillUnmount() {
this.clearRecoveryTimer();
}
private clearRecoveryTimer() {
if (this.recoveryTimer) {
clearTimeout(this.recoveryTimer);
this.recoveryTimer = null;
}
}
private scheduleRecovery(recoveries: number) {
this.clearRecoveryTimer();
this.recoveryTimer = setTimeout(() => {
this.recoveryTimer = null;
this.setState((prev) => ({
errored: false,
failedPermanently: false,
renderKey: prev.renderKey + 1,
recoveries,
}));
}, RECOVERY_DELAY_MS);
}
private handleManualRetry = () => {
this.clearRecoveryTimer();
this.lastErrorAt = 0;
this.setState((prev) => ({
errored: false,
failedPermanently: false,
renderKey: prev.renderKey + 1,
recoveries: 0,
}));
};
render() {
if (this.state.failedPermanently) {
return (
<div className="flex h-full w-full items-center justify-center bg-warm-100 px-6 text-center dark:bg-navy-950">
<div>
<h2 className="text-lg font-semibold text-warm-900 dark:text-warm-100">
The map ran into a problem
</h2>
<p className="mt-2 text-sm text-warm-600 dark:text-warm-300">
This can happen when your browser&apos;s graphics context is interrupted.
</p>
<button
type="button"
onClick={this.handleManualRetry}
className="mt-4 rounded-lg bg-teal-600 px-4 py-2 text-sm font-medium text-white shadow-sm hover:bg-teal-700 dark:bg-teal-500 dark:hover:bg-teal-400"
>
Reload the map
</button>
</div>
</div>
);
}
// While auto-recovering, show the same spinner used for the lazy-load
// fallback so the remount reads as a brief reload rather than a crash.
if (this.state.errored) {
return <MapFallback />;
}
return <Fragment key={this.state.renderKey}>{this.props.children}</Fragment>;
}
}

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

View file

@ -15,6 +15,12 @@ interface MapTopCardsProps {
layoutClass: string;
showLocationSearch: boolean;
showLegend: boolean;
/**
* Space (px) to keep clear on the right of the overlay so the cards don't
* slide under sibling controls anchored there (e.g. the mobile action
* buttons). The expanded location search shrinks to fit the remaining width.
*/
rightInset?: number;
onFlyTo: (lat: number, lng: number, zoom: number, options?: MapFlyToOptions) => void;
onLocationSearched?: (location: SearchedLocation | null) => void;
onCurrentLocationFound?: (lat: number, lng: number) => void;
@ -40,6 +46,7 @@ export const MapTopCards = memo(function MapTopCards({
layoutClass,
showLocationSearch,
showLegend,
rightInset = 0,
onFlyTo,
onLocationSearched,
onCurrentLocationFound,
@ -65,6 +72,7 @@ export const MapTopCards = memo(function MapTopCards({
return (
<div
className={`absolute top-3 left-3 right-3 z-20 flex gap-2 pointer-events-none ${layoutClass}`}
style={rightInset ? { paddingRight: rightInset } : undefined}
>
{showLocationSearch && (
<LocationSearch

View file

@ -154,4 +154,40 @@ describe('MobileBottomSheet keyboard avoidance', () => {
expect(coveredHeights[coveredHeights.length - 1]).toBe(452);
});
it('caps the height so the drag handle never slides under a top header', async () => {
installViewport({ innerHeight: 800, visualHeight: 800 });
const { coveredHeights, sheet, container } = renderSheet();
const handle = sheet.firstElementChild?.firstElementChild;
if (!(handle instanceof HTMLElement)) throw new Error('Expected bottom sheet drag handle');
// Pretend a 48px app header sits above the sheet's layout parent, so the
// sheet must reserve that strip (plus a small gap) at the top of the window.
container.getBoundingClientRect = () =>
({
top: 48,
bottom: 800,
left: 0,
right: 390,
width: 390,
height: 752,
x: 0,
y: 48,
toJSON: () => ({}),
}) as DOMRect;
await act(async () => {
window.dispatchEvent(new Event('resize'));
});
// Drag the handle far above the top of the window.
await act(async () => {
fireEvent.pointerDown(handle, { pointerId: 1, clientY: 700 });
fireEvent.pointerMove(handle, { pointerId: 1, clientY: -400 });
});
// 800 (viewport) - 48 (header) - 16 (gap) = 736 px ceiling; never taller.
expect(coveredHeights[coveredHeights.length - 1]).toBe(736);
});
});

View file

@ -1,6 +1,10 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
import type { ReactNode } from 'react';
// Small gap kept between the sheet's top edge (where the drag handle lives) and
// whatever sits above it, so the handle always stays fully on-screen.
const SHEET_TOP_GAP_PX = 16;
interface VisualViewportState {
height: number;
bottomInset: number;
@ -117,15 +121,39 @@ export default function MobileBottomSheet({
const scrollIntoViewTimerRef = useRef<number | null>(null);
const [height, setHeight] = useState<number | null>(null);
const [isDragging, setIsDragging] = useState(false);
// The sheet is fixed to the window, but it logically lives below the app
// header. Measure the top of our layout parent (the map area, which starts at
// the header's bottom edge) so the sheet can never grow tall enough to slide
// its drag handle up underneath the header, where it'd become unreachable and
// leave the sheet stuck open.
const [topInset, setTopInset] = useState(0);
useLayoutEffect(() => {
const measure = () => {
const parent = sheetRef.current?.parentElement;
setTopInset(parent ? Math.max(0, parent.getBoundingClientRect().top) : 0);
};
measure();
window.addEventListener('resize', measure);
window.addEventListener('orientationchange', measure);
return () => {
window.removeEventListener('resize', measure);
window.removeEventListener('orientationchange', measure);
};
}, []);
const heightBounds = useMemo(() => {
const available = viewport.height;
const min = Math.min(132, Math.max(104, available * 0.22));
// Reserve the header strip (topInset) plus a small gap at the top so the
// drag handle always stays on-screen and grabbable.
const ceiling = available - topInset - SHEET_TOP_GAP_PX;
return {
min: Math.min(132, Math.max(104, available * 0.22)),
min,
initial: Math.min(available * 0.56, Math.max(330, available * 0.44)),
max: Math.max(300, available - 12),
max: Math.max(min, ceiling),
};
}, [viewport.height]);
}, [viewport.height, topInset]);
const currentHeight = clamp(height ?? heightBounds.initial, heightBounds.min, heightBounds.max);

View file

@ -0,0 +1,58 @@
import { describe, expect, it } from 'vitest';
import { buildTimelineEvents } from './PropertiesPane';
import type { Property } from '../../types';
function makeProperty(overrides: Partial<Property>): Property {
return { lat: 51, lon: -0.1, ...overrides } as Property;
}
// Compact, discriminated summary of each event so assertions stay type-safe.
function summarize(property: Property): string[] {
return buildTimelineEvents(property).map((event) => {
switch (event.kind) {
case 'tenure':
return `tenure:${event.status}:${event.year}`;
case 'sale':
return `sale:${event.year}`;
case 'reno':
return `reno:${event.event}:${event.year}`;
case 'built':
return `built:${event.year}`;
}
});
}
describe('buildTimelineEvents', () => {
it('renders tenure changes newest-first with their status', () => {
expect(
summarize(
makeProperty({
tenure_history: [
{ year: 2019, status: 'Rented (private)' },
{ year: 2023, status: 'Owner-occupied' },
],
}),
),
).toEqual(['tenure:Owner-occupied:2023', 'tenure:Rented (private):2019']);
});
it('interleaves tenure changes with sales by year', () => {
expect(
summarize(
makeProperty({
historical_prices: [
{ year: 2015, month: 6, price: 200_000 },
// Most recent sale is the card headline, so it is dropped here.
{ year: 2024, month: 1, price: 300_000 },
],
tenure_history: [{ year: 2020, status: 'Rented (private)' }],
}),
),
).toEqual(['tenure:Rented (private):2020', 'sale:2015']);
});
it('emits nothing when there is no tenure history', () => {
expect(summarize(makeProperty({}))).toEqual([]);
});
});

View file

@ -9,6 +9,7 @@ import {
formatYearMonth,
} from '../../lib/format';
import { getNum } from '../../lib/property-fields';
import { buildEpcCertificateUrl } from '../../lib/external-search';
import { useRetainedScrollTop } from '../../hooks/useRetainedScrollTop';
import InfoPopup from '../ui/InfoPopup';
import { SearchInput } from '../ui/SearchInput';
@ -292,6 +293,29 @@ function PropertyCard({ property }: { property: Property }) {
)}
</div>
{property.postcode && property.current_energy_rating && (
<a
href={buildEpcCertificateUrl(property.postcode)}
target="_blank"
rel="noopener noreferrer"
className="mt-2 inline-flex items-center gap-1 text-sm text-teal-600 dark:text-teal-400 hover:underline"
>
{t('propertyCard.viewEpcCertificate')}
<svg
aria-hidden
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
className="w-3.5 h-3.5"
>
<path d="M7 17 17 7M9 7h8v8" />
</svg>
</a>
)}
<PropertyTimeline property={property} />
</div>
);
@ -300,9 +324,10 @@ function PropertyCard({ property }: { property: Property }) {
type TimelineEvent =
| { kind: 'sale'; year: number; month: number; price: number; sortKey: number }
| { kind: 'reno'; year: number; event: string; sortKey: number }
| { kind: 'tenure'; year: number; status: string; sortKey: number }
| { kind: 'built'; year: number; approximate: boolean; sortKey: number };
function buildTimelineEvents(property: Property): TimelineEvent[] {
export function buildTimelineEvents(property: Property): TimelineEvent[] {
const events: TimelineEvent[] = [];
// Skip the most recent sale: it's already shown in the card headline.
@ -329,6 +354,16 @@ function buildTimelineEvents(property: Property): TimelineEvent[] {
});
}
for (const tenure of property.tenure_history ?? []) {
events.push({
kind: 'tenure',
year: tenure.year,
status: tenure.status,
// Mid-year, like renos: a tenure change is dated only to the EPC year.
sortKey: tenure.year + 0.5,
});
}
const builtYear = getNum(property, 'Construction year');
const approximate = property.is_construction_date_approximate ?? true;
if (builtYear !== undefined && Number.isFinite(builtYear) && builtYear > 0) {
@ -355,6 +390,22 @@ function PropertyTimeline({ property }: { property: Property }) {
const events = useMemo(() => buildTimelineEvents(property), [property]);
if (events.length === 0) return null;
// Translate the pipeline's canonical tenure status. Literal keys keep the
// typed `t` happy; an unexpected status falls back to its raw value (the same
// contract the renovation labels use) rather than vanishing.
const tenureLabel = (status: string): string => {
switch (status) {
case 'Owner-occupied':
return t('propertyCard.tenureOwnerOccupied');
case 'Rented (private)':
return t('propertyCard.tenureRentedPrivate');
case 'Rented (social)':
return t('propertyCard.tenureRentedSocial');
default:
return status;
}
};
return (
<div className="mt-3">
<div className="text-xs text-warm-500 dark:text-warm-400 mb-1.5">
@ -383,6 +434,16 @@ function PropertyTimeline({ property }: { property: Property }) {
</span>
</>
)}
{event.kind === 'tenure' && (
<>
<span className="text-warm-700 dark:text-warm-200">
{tenureLabel(event.status)}
</span>
<span className="ml-1.5 text-xs text-warm-500 dark:text-warm-400">
{event.year}
</span>
</>
)}
{event.kind === 'built' && (
<>
<span className="text-warm-700 dark:text-warm-200">
@ -419,6 +480,14 @@ function TimelineMarker({ kind }: { kind: TimelineEvent['kind'] }) {
/>
);
}
if (kind === 'tenure') {
return (
<span
aria-hidden
className={`${base} bg-indigo-400 dark:bg-indigo-400 ring-warm-50 dark:ring-warm-900`}
/>
);
}
return (
<span
aria-hidden

View file

@ -23,6 +23,7 @@ import { SpinnerIcon } from '../../ui/icons/SpinnerIcon';
import { IndeterminateProgressBar } from '../../ui/IndeterminateProgressBar';
import type { MapFlyTo, PaneResizeHandlers } from './types';
import { MapFallback, PaneFallback } from './Fallbacks';
import { MapErrorBoundary } from '../MapErrorBoundary';
import { LoadingOverlay } from './LoadingOverlay';
import { Joyride, Map, MapPageSelectionPane } from './lazyComponents';
@ -187,45 +188,47 @@ export function DesktopMapPage({
<div data-tutorial="map" className="flex-1 relative">
<IndeterminateProgressBar show={mapData.loading && !initialLoading} />
<Suspense fallback={<MapFallback />}>
<Map
data={mapData.data}
postcodeData={mapData.postcodeData}
usePostcodeView={mapData.usePostcodeView}
pois={pois}
activeOverlays={activeOverlays}
activeCrimeTypes={activeCrimeTypes}
basemap={basemap}
colorOpacity={colorOpacity}
onViewChange={mapData.handleViewChange}
viewFeature={mapViewFeature}
colorRange={mapData.colorRange}
filterRange={filterRange}
viewSource={viewSource}
onCancelPin={onCancelPin}
onResetPreviewScale={mapData.handleResetPreviewScale}
canResetPreviewScale={mapData.canResetPreviewScale}
features={features}
selectedHexagonId={selectedHexagonId}
hoveredHexagonId={hoveredHexagonId}
onHexagonClick={onHexagonClick}
onHexagonHover={onHexagonHover}
initialViewState={initialViewState}
flyToRef={flyToRef}
theme={theme}
filters={filters}
selectedPostcodeGeometry={selectedPostcodeGeometry}
onLocationSearched={onLocationSearched}
onCurrentLocationFound={onCurrentLocationFound}
currentLocation={currentLocation}
actualListings={actualListings}
bounds={mapData.bounds}
hideTopCardsWhenNarrow
travelTimeEntries={travelTimeEntries}
densityLabel={densityLabel}
totalCount={totalCount}
/>
</Suspense>
<MapErrorBoundary>
<Suspense fallback={<MapFallback />}>
<Map
data={mapData.data}
postcodeData={mapData.postcodeData}
usePostcodeView={mapData.usePostcodeView}
pois={pois}
activeOverlays={activeOverlays}
activeCrimeTypes={activeCrimeTypes}
basemap={basemap}
colorOpacity={colorOpacity}
onViewChange={mapData.handleViewChange}
viewFeature={mapViewFeature}
colorRange={mapData.colorRange}
filterRange={filterRange}
viewSource={viewSource}
onCancelPin={onCancelPin}
onResetPreviewScale={mapData.handleResetPreviewScale}
canResetPreviewScale={mapData.canResetPreviewScale}
features={features}
selectedHexagonId={selectedHexagonId}
hoveredHexagonId={hoveredHexagonId}
onHexagonClick={onHexagonClick}
onHexagonHover={onHexagonHover}
initialViewState={initialViewState}
flyToRef={flyToRef}
theme={theme}
filters={filters}
selectedPostcodeGeometry={selectedPostcodeGeometry}
onLocationSearched={onLocationSearched}
onCurrentLocationFound={onCurrentLocationFound}
currentLocation={currentLocation}
actualListings={actualListings}
bounds={mapData.bounds}
hideTopCardsWhenNarrow
travelTimeEntries={travelTimeEntries}
densityLabel={densityLabel}
totalCount={totalCount}
/>
</Suspense>
</MapErrorBoundary>
<div className="absolute bottom-4 right-4 z-10 flex max-w-[calc(100%_-_2rem)] flex-row flex-wrap justify-end gap-2">
{onToggleActualListings && (
<button

View file

@ -21,6 +21,7 @@ import { SpinnerIcon } from '../../ui/icons/SpinnerIcon';
import { IndeterminateProgressBar } from '../../ui/IndeterminateProgressBar';
import type { MapFlyTo } from './types';
import { MapFallback, PaneFallback } from './Fallbacks';
import { MapErrorBoundary } from '../MapErrorBoundary';
import { LoadingOverlay } from './LoadingOverlay';
import { Map, MobileDrawer } from './lazyComponents';
@ -136,50 +137,63 @@ export function MobileMapPage({
bottomScreenInset
)}px - 7rem))`;
// The action buttons (listing/overlays/POI) are pinned to the top-right over
// the map. Reserve their width on the right of the top-card overlay so the
// expanded search bar shrinks to fit instead of sliding underneath them.
// Each button is p-2 + a 1.25rem icon (36px); they sit in a `gap-2` (8px) row.
const ACTION_BUTTON_WIDTH = 36;
const ACTION_BUTTON_GAP = 8;
const actionButtonCount = (onToggleActualListings ? 1 : 0) + 2;
const topCardsRightInset =
actionButtonCount * ACTION_BUTTON_WIDTH + actionButtonCount * ACTION_BUTTON_GAP; // inter-button gaps + breathing room before the search
return (
<div className="flex-1 overflow-hidden relative">
<LoadingOverlay show={initialLoading} />
<div className="absolute inset-0">
<IndeterminateProgressBar show={mapData.loading && !initialLoading} />
<Suspense fallback={<MapFallback />}>
<Map
data={mapData.data}
postcodeData={mapData.postcodeData}
usePostcodeView={mapData.usePostcodeView}
pois={pois}
activeOverlays={activeOverlays}
activeCrimeTypes={activeCrimeTypes}
basemap={basemap}
colorOpacity={colorOpacity}
onViewChange={mapData.handleViewChange}
viewFeature={mapViewFeature}
colorRange={mapData.colorRange}
filterRange={filterRange}
viewSource={viewSource}
onCancelPin={onCancelPin}
onResetPreviewScale={mapData.handleResetPreviewScale}
canResetPreviewScale={mapData.canResetPreviewScale}
features={features}
selectedHexagonId={selectedHexagonId}
hoveredHexagonId={hoveredHexagonId}
onHexagonClick={onHexagonClick}
onHexagonHover={onHexagonHover}
initialViewState={initialViewState}
flyToRef={flyToRef}
theme={theme}
filters={filters}
selectedPostcodeGeometry={selectedPostcodeGeometry}
onLocationSearched={onLocationSearched}
onCurrentLocationFound={onCurrentLocationFound}
currentLocation={currentLocation}
actualListings={actualListings}
bounds={mapData.bounds}
hideLegend
travelTimeEntries={travelTimeEntries}
bottomScreenInset={bottomScreenInset}
/>
</Suspense>
<MapErrorBoundary>
<Suspense fallback={<MapFallback />}>
<Map
data={mapData.data}
postcodeData={mapData.postcodeData}
usePostcodeView={mapData.usePostcodeView}
pois={pois}
activeOverlays={activeOverlays}
activeCrimeTypes={activeCrimeTypes}
basemap={basemap}
colorOpacity={colorOpacity}
onViewChange={mapData.handleViewChange}
viewFeature={mapViewFeature}
colorRange={mapData.colorRange}
filterRange={filterRange}
viewSource={viewSource}
onCancelPin={onCancelPin}
onResetPreviewScale={mapData.handleResetPreviewScale}
canResetPreviewScale={mapData.canResetPreviewScale}
features={features}
selectedHexagonId={selectedHexagonId}
hoveredHexagonId={hoveredHexagonId}
onHexagonClick={onHexagonClick}
onHexagonHover={onHexagonHover}
initialViewState={initialViewState}
flyToRef={flyToRef}
theme={theme}
filters={filters}
selectedPostcodeGeometry={selectedPostcodeGeometry}
onLocationSearched={onLocationSearched}
onCurrentLocationFound={onCurrentLocationFound}
currentLocation={currentLocation}
actualListings={actualListings}
bounds={mapData.bounds}
hideLegend
topCardsRightInset={topCardsRightInset}
travelTimeEntries={travelTimeEntries}
bottomScreenInset={bottomScreenInset}
/>
</Suspense>
</MapErrorBoundary>
</div>
<div className="absolute right-3 top-3 z-20 flex max-w-[calc(100%_-_1.5rem)] flex-row flex-wrap justify-end gap-2">

View file

@ -26,6 +26,8 @@ export interface MapPageProps {
poiCategoryGroups: POICategoryGroup[];
initialFilters: FeatureFilters;
initialViewState: ViewState;
/** Offer the "check your area" GPS prompt (fresh demo visit, no URL view). */
offerDemoLocation?: boolean;
initialPOICategories: Set<string>;
initialOverlays?: Set<OverlayId>;
initialCrimeTypes?: Set<string>;

View file

@ -156,6 +156,12 @@ export function useExportController({
// drive which rows appear.
const filterStr = buildFilterString(filters, features);
if (filterStr) params.set('filters', filterStr);
// Travel times are per-postcode, so include them as columns here too.
// The server ignores the time-range filter in list mode (the supplied
// postcodes drive the rows), but still shows each one's travel time.
const travelParam = buildTravelParam(travelTimeEntries);
if (travelParam) params.set('travel', travelParam);
appendTravelStateParams(params, travelTimeEntries);
if (shareCode) params.set('share', shareCode);
} else {
const { south, west, north, east } = bounds!;

View file

@ -59,6 +59,101 @@ describe('useFilters', () => {
expect(result.current.filters.price).toEqual([10, 90]);
});
it('caps the number of filters for demo users and reports when the limit is hit', () => {
const sixFeatures: FeatureMeta[] = ['a', 'b', 'c', 'd', 'e', 'f'].map((name) => ({
name,
type: 'numeric',
min: 0,
max: 100,
}));
const onFilterLimitReached = vi.fn();
const { result } = renderHook(() =>
useFilters({
initialFilters: {},
features: sixFeatures,
filterLimit: 5,
onFilterLimitReached,
})
);
act(() => {
['a', 'b', 'c', 'd', 'e'].forEach((name) => result.current.handleAddFilter(name));
});
expect(Object.keys(result.current.filters)).toHaveLength(5);
expect(onFilterLimitReached).not.toHaveBeenCalled();
// The 6th distinct filter is blocked and reported.
act(() => {
result.current.handleAddFilter('f');
});
expect(Object.keys(result.current.filters)).toHaveLength(5);
expect(result.current.filters.f).toBeUndefined();
expect(onFilterLimitReached).toHaveBeenCalledTimes(1);
// Re-adding an already-present feature is allowed (it replaces, no new key).
act(() => {
result.current.handleAddFilter('a');
});
expect(Object.keys(result.current.filters)).toHaveLength(5);
expect(onFilterLimitReached).toHaveBeenCalledTimes(1);
});
it('caps bulk handleSetFilters (e.g. AI results) to the demo limit and reports it', () => {
const sixFeatures: FeatureMeta[] = ['a', 'b', 'c', 'd', 'e', 'f'].map((name) => ({
name,
type: 'numeric',
min: 0,
max: 100,
}));
const onFilterLimitReached = vi.fn();
const { result } = renderHook(() =>
useFilters({
initialFilters: {},
features: sixFeatures,
filterLimit: 5,
onFilterLimitReached,
})
);
act(() => {
result.current.handleSetFilters({
a: [0, 1],
b: [0, 1],
c: [0, 1],
d: [0, 1],
e: [0, 1],
f: [0, 1],
});
});
expect(Object.keys(result.current.filters)).toHaveLength(5);
expect(onFilterLimitReached).toHaveBeenCalledTimes(1);
});
it('does not cap filters when filterLimit is null (licensed users)', () => {
const sixFeatures: FeatureMeta[] = ['a', 'b', 'c', 'd', 'e', 'f'].map((name) => ({
name,
type: 'numeric',
min: 0,
max: 100,
}));
const { result } = renderHook(() =>
useFilters({
initialFilters: {},
features: sixFeatures,
filterLimit: null,
})
);
act(() => {
['a', 'b', 'c', 'd', 'e', 'f'].forEach((name) => result.current.handleAddFilter(name));
});
expect(Object.keys(result.current.filters)).toHaveLength(6);
});
it('uses the provided initial range for drag-only feature keys', () => {
const { result } = renderHook(() =>
useFilters({

View file

@ -47,6 +47,10 @@ import {
interface UseFiltersOptions {
initialFilters: FeatureFilters;
features: FeatureMeta[];
/** Max simultaneous filters for the current user; `null`/`undefined` = unlimited. */
filterLimit?: number | null;
/** Called when an add is blocked because `filterLimit` is already reached. */
onFilterLimitReached?: () => void;
}
function normalizeFilters(filters: FeatureFilters): FeatureFilters {
@ -104,13 +108,25 @@ function getNextNumericKeyId(
return max + 1;
}
export function useFilters({ initialFilters, features }: UseFiltersOptions) {
export function useFilters({
initialFilters,
features,
filterLimit,
onFilterLimitReached,
}: UseFiltersOptions) {
const initialFiltersRef = useRef<FeatureFilters | null>(null);
if (!initialFiltersRef.current) {
initialFiltersRef.current = normalizeFilters(initialFilters);
}
const [filters, setFilters] = useState<FeatureFilters>(() => initialFiltersRef.current!);
// Mirrors used by handleAddFilter's limit check so its identity stays stable.
const filtersRef = useRef(filters);
filtersRef.current = filters;
const filterLimitRef = useRef(filterLimit);
filterLimitRef.current = filterLimit;
const onFilterLimitReachedRef = useRef(onFilterLimitReached);
onFilterLimitReachedRef.current = onFilterLimitReached;
const [activeFeature, setActiveFeature] = useState<string | null>(null);
const [dragValue, setDragValue] = useState<[number, number] | null>(null);
const [pinnedFeature, setPinnedFeature] = useState<string | null>(null);
@ -175,6 +191,26 @@ export function useFilters({ initialFilters, features }: UseFiltersOptions) {
) {
return;
}
// Demo (unlicensed) users are capped at `filterLimit` simultaneous filters.
// Re-applying an existing regular feature replaces it (no new key), so it's
// always allowed; the special/generated filters always add a new key.
const limit = filterLimitRef.current;
if (limit != null) {
const current = filtersRef.current;
const addsNewKey =
name === SCHOOL_FILTER_NAME ||
name === SPECIFIC_CRIMES_FILTER_NAME ||
name === ELECTION_VOTE_SHARE_FILTER_NAME ||
name === ETHNICITIES_FILTER_NAME ||
POI_FILTER_NAMES.includes(name as PoiFilterName) ||
!(name in current);
if (addsNewKey && Object.keys(current).length >= limit) {
onFilterLimitReachedRef.current?.();
return;
}
}
trackEvent('Filter Add', { feature: name });
setFilters((prev) => {
undoStackRef.current.push(prev);
@ -470,7 +506,16 @@ export function useFilters({ initialFilters, features }: UseFiltersOptions) {
}, []);
const handleSetFilters = useCallback((newFilters: FeatureFilters) => {
setFilters(normalizeFilters(newFilters));
// Enforce the demo cap on bulk sets too (e.g. AI filters, which bypass the
// per-add path). Without this, an unlicensed user could end up with >5 filters,
// which the server then rejects (400), breaking the map.
let next = newFilters;
const limit = filterLimitRef.current;
if (limit != null && Object.keys(next).length > limit) {
next = Object.fromEntries(Object.entries(next).slice(0, limit));
onFilterLimitReachedRef.current?.();
}
setFilters(normalizeFilters(next));
setActiveFeature(null);
setDragValue(null);
setPinnedFeature(null);

View file

@ -80,6 +80,18 @@ describe('useHexagonSelection', () => {
return Promise.resolve(jsonResponse(stats(12)));
}
if (url.pathname.startsWith('/api/postcode/')) {
const postcode = decodeURIComponent(url.pathname.slice('/api/postcode/'.length));
return Promise.resolve(
jsonResponse({
postcode,
latitude: 51.5,
longitude: -0.115,
geometry: postcodeGeometry,
})
);
}
if (url.pathname === '/api/postcode-properties') {
return Promise.resolve(
jsonResponse({ properties: [], total: 0, offset: 0, truncated: false })
@ -428,4 +440,60 @@ describe('useHexagonSelection', () => {
expect(allPropertiesParams.has('filters')).toBe(false);
expect(allPropertiesParams.has('travel')).toBe(false);
});
it('restores the original clicked postcode after zooming out to a hexagon and back', async () => {
// Stable references: the sync effect synchronously resets properties when it
// runs, so unstable filters/hexagonData/travel props (new identity each
// render) would re-trigger it forever. In the app these come from memoised
// hooks; mirror that here.
const stableFilters = {};
const stableHexagonData: never[] = [];
const stableTravel: TravelTimeEntry[] = [];
const { result, rerender } = renderHook(
({ usePostcodeView }: { usePostcodeView: boolean }) =>
useHexagonSelection({
filters: stableFilters,
features,
hexagonData: stableHexagonData,
resolution: 9,
usePostcodeView,
travelTimeEntries: stableTravel,
}),
{ initialProps: { usePostcodeView: true } }
);
// Click a postcode whose neighbouring hexagon reports a *different*
// central_postcode ('SW1A 1AA', see stats()).
act(() => {
result.current.handleHexagonClick('AB1 2CD', true, postcodeGeometry);
});
await waitFor(() => {
expect(result.current.selectedHexagon?.id).toBe('AB1 2CD');
expect(result.current.selectedHexagon?.type).toBe('postcode');
});
expect(result.current.selectedHexagon?.anchorPostcode?.id).toBe('AB1 2CD');
// Zoom out past the postcode threshold: the selection follows down to a hexagon.
act(() => {
rerender({ usePostcodeView: false });
});
await waitFor(() => {
expect(result.current.selectedHexagon?.type).toBe('hexagon');
});
expect(result.current.selectedHexagon?.anchorPostcode?.id).toBe('AB1 2CD');
// Zoom back in: the original postcode must be restored, not the hexagon's
// central_postcode ('SW1A 1AA').
act(() => {
rerender({ usePostcodeView: true });
});
await waitFor(() => {
expect(result.current.selectedHexagon?.type).toBe('postcode');
});
expect(result.current.selectedHexagon?.id).toBe('AB1 2CD');
expect(result.current.selectedPostcodeGeometry).toBe(postcodeGeometry);
});
});

View file

@ -21,6 +21,14 @@ interface SelectedHexagon {
type: 'hexagon' | 'postcode';
resolution: number;
lockedResolution?: boolean;
/**
* The postcode the user is really tracking. Set when a postcode is selected
* (or first derived from a hexagon) and carried through zoom-driven hexagon
* conversions, so that zooming out and back in restores the same postcode
* instead of drifting to a neighbour whose centre happens to fall in the same
* cell. Cleared implicitly on every explicit (re)selection.
*/
anchorPostcode?: { id: string; geometry: PostcodeGeometry | null };
}
interface JourneyDest {
@ -331,7 +339,12 @@ export function useHexagonSelection({
setSelectedPostcodeGeometry(null);
} else {
const type: SelectedHexagon['type'] = isPostcode ? 'postcode' : 'hexagon';
const selection = { id, type, resolution };
const selection: SelectedHexagon = {
id,
type,
resolution,
...(isPostcode ? { anchorPostcode: { id, geometry: geometry ?? null } } : {}),
};
const requestId = invalidateAreaRequests();
invalidatePropertyRequests();
trackEvent('Hexagon Click', { type });
@ -445,7 +458,7 @@ export function useHexagonSelection({
(usePostcodeView &&
selection.type === 'hexagon' &&
!selection.lockedResolution &&
areaStats?.central_postcode != null) ||
(selection.anchorPostcode != null || areaStats?.central_postcode != null)) ||
(!usePostcodeView && selection.type === 'postcode' && !selection.lockedResolution) ||
(!usePostcodeView &&
selection.type === 'hexagon' &&
@ -483,19 +496,29 @@ export function useHexagonSelection({
let nextStats: HexagonStatsResponse | null = null;
if (usePostcodeView && selection.type === 'hexagon' && !selection.lockedResolution) {
if (!areaStats?.central_postcode) return;
const lookup = await fetchPostcodeLookup(areaStats.central_postcode, controller.signal);
nextSelection = { id: lookup.postcode, type: 'postcode', resolution };
nextGeometry = lookup.geometry;
nextStats = await fetchPostcodeStats(
lookup.postcode,
controller.signal,
areaStatsUseFilters
);
// Prefer the anchored postcode (the one the user originally tracked) so
// a zoom-out/zoom-in round-trip restores it exactly, rather than the
// hexagon's current central_postcode, which may be a neighbour.
const anchor = selection.anchorPostcode;
const postcode = anchor?.id ?? areaStats?.central_postcode;
if (!postcode) return;
let geometry = anchor?.geometry ?? null;
if (!geometry) {
geometry = (await fetchPostcodeLookup(postcode, controller.signal)).geometry;
}
nextSelection = {
id: postcode,
type: 'postcode',
resolution,
anchorPostcode: { id: postcode, geometry },
};
nextGeometry = geometry;
nextStats = await fetchPostcodeStats(postcode, controller.signal, areaStatsUseFilters);
} else if (!usePostcodeView && selection.type === 'postcode') {
const lookup = await fetchPostcodeLookup(selection.id, controller.signal);
const nextId = latLngToCell(lookup.latitude, lookup.longitude, resolution);
nextSelection = { id: nextId, type: 'hexagon', resolution };
const anchor = selection.anchorPostcode ?? { id: selection.id, geometry: lookup.geometry };
nextSelection = { id: nextId, type: 'hexagon', resolution, anchorPostcode: anchor };
nextStats = await fetchHexagonStats(
nextId,
resolution,
@ -514,7 +537,12 @@ export function useHexagonSelection({
? cellToParent(selection.id, resolution)
: overlappingHexagon?.h3;
if (!nextId) return;
nextSelection = { id: nextId, type: 'hexagon', resolution };
nextSelection = {
id: nextId,
type: 'hexagon',
resolution,
anchorPostcode: selection.anchorPostcode,
};
nextStats = await fetchHexagonStats(
nextId,
resolution,

View file

@ -457,6 +457,10 @@ export function useMapData({
if (requestKey === latestDataRequestKeyRef.current && !isAbortError(err)) {
logNonAbortError('Failed to fetch data', err);
setLoading(false);
// Mark this request settled so a terminal error (e.g. 400 filter_limit or
// 429 rate_limited) clears the loading spinner instead of hanging it; the
// last successful data stays visible.
setLoadedDataKey(requestKey);
}
}
}, DEBOUNCE_MS);

View file

@ -834,11 +834,15 @@ const de: Translations = {
listedBuildingBadge: 'Wahrscheinlich Listed Building',
epcRating: 'EPC-Bewertung:',
epcPotential: 'EPC-Potenzial:',
viewEpcCertificate: 'EPC-Zertifikat ansehen',
renovations: 'Renovierungen',
perSqm: '/m²',
historyTitle: 'Historie',
historySale: 'Verkauf',
historyBuilt: 'Baujahr',
tenureOwnerOccupied: 'Eigennutzung',
tenureRentedPrivate: 'Privat vermietet',
tenureRentedSocial: 'Sozialwohnung',
searchPlaceholder: 'Nach Adresse oder Postcode suchen...',
propertyData: 'Immobiliendaten',
propertyDataDesc:
@ -923,6 +927,14 @@ const de: Translations = {
geolocationFailed: 'Standort konnte nicht ermittelt werden',
},
demoLocation: {
title: 'Ihre Umgebung erkunden',
body: 'Möchten Sie Immobiliendaten in Ihrer Nähe sehen? Verwenden Sie Ihren Standort oder starten Sie die Demo in Canary Wharf.',
useLocation: 'Meinen Standort verwenden',
locating: 'Standort wird ermittelt…',
useDefault: 'Canary Wharf anzeigen',
},
// ── Mobile Drawer ──────────────────────────────────
mobileDrawer: {
closeDrawer: 'Drawer schließen',

View file

@ -820,11 +820,15 @@ const en = {
listedBuildingBadge: 'Likely listed',
epcRating: 'EPC rating:',
epcPotential: 'EPC potential:',
viewEpcCertificate: 'View EPC certificate',
renovations: 'Renovations',
perSqm: '/m²',
historyTitle: 'History',
historySale: 'Sale',
historyBuilt: 'Built',
tenureOwnerOccupied: 'Owner-occupied',
tenureRentedPrivate: 'Privately rented',
tenureRentedSocial: 'Social housing',
searchPlaceholder: 'Search by address or postcode...',
propertyData: 'Property Data',
propertyDataDesc:
@ -907,6 +911,15 @@ const en = {
geolocationFailed: 'Couldnt determine your location',
},
// ── Demo location prompt ───────────────────────────
demoLocation: {
title: 'Explore your area',
body: 'Want to see property data around you? Use your location, or start the demo at Canary Wharf.',
useLocation: 'Use my location',
locating: 'Locating…',
useDefault: 'Show Canary Wharf',
},
// ── Mobile Drawer ──────────────────────────────────
mobileDrawer: {
closeDrawer: 'Close drawer',

View file

@ -848,11 +848,15 @@ const fr: Translations = {
listedBuildingBadge: 'Bâtiment probablement classé',
epcRating: 'Note EPC :',
epcPotential: 'Potentiel EPC :',
viewEpcCertificate: 'Voir le certificat EPC',
renovations: 'Rénovations',
perSqm: '/m²',
historyTitle: 'Historique',
historySale: 'Vente',
historyBuilt: 'Construit',
tenureOwnerOccupied: 'Propriétaire occupant',
tenureRentedPrivate: 'Loué (privé)',
tenureRentedSocial: 'Logement social',
searchPlaceholder: 'Rechercher par adresse ou code postal...',
propertyData: 'Données immobilières',
propertyDataDesc:
@ -936,6 +940,14 @@ const fr: Translations = {
geolocationFailed: 'Impossible de déterminer votre position',
},
demoLocation: {
title: 'Explorez votre quartier',
body: 'Envie de voir les données immobilières autour de vous ? Utilisez votre position, ou commencez la démo à Canary Wharf.',
useLocation: 'Utiliser ma position',
locating: 'Localisation…',
useDefault: 'Afficher Canary Wharf',
},
// ── Mobile Drawer ──────────────────────────────────
mobileDrawer: {
closeDrawer: 'Fermer le tiroir',

View file

@ -808,11 +808,15 @@ const hi: Translations = {
listedBuildingBadge: 'शायद सूचीबद्ध',
epcRating: 'EPC रेटिंग:',
epcPotential: 'संभावित EPC:',
viewEpcCertificate: 'EPC प्रमाणपत्र देखें',
renovations: 'नवीनीकरण',
perSqm: '/वर्ग मी',
historyTitle: 'इतिहास',
historySale: 'बिक्री',
historyBuilt: 'निर्मित',
tenureOwnerOccupied: 'मालिक का निवास',
tenureRentedPrivate: 'निजी किराये पर',
tenureRentedSocial: 'सामाजिक किराया आवास',
searchPlaceholder: 'पते या पोस्टकोड से खोजें...',
propertyData: 'संपत्ति डेटा',
propertyDataDesc:
@ -891,6 +895,14 @@ const hi: Translations = {
geolocationFailed: 'आपका स्थान निर्धारित नहीं किया जा सका',
},
demoLocation: {
title: 'अपना क्षेत्र देखें',
body: 'क्या आप अपने आसपास की संपत्ति डेटा देखना चाहते हैं? अपना स्थान उपयोग करें, या Canary Wharf से डेमो शुरू करें।',
useLocation: 'मेरा स्थान उपयोग करें',
locating: 'स्थान खोजा जा रहा है…',
useDefault: 'Canary Wharf दिखाएं',
},
mobileDrawer: {
closeDrawer: 'ड्रॉअर बंद करें',
},

View file

@ -836,11 +836,15 @@ const hu: Translations = {
listedBuildingBadge: 'Talán műemlék',
epcRating: 'EPC minősítés:',
epcPotential: 'Lehetséges EPC-minősítés:',
viewEpcCertificate: 'EPC-tanúsítvány megtekintése',
renovations: 'Felújítások',
perSqm: '/m²',
historyTitle: 'Előzmények',
historySale: 'Eladás',
historyBuilt: 'Építés',
tenureOwnerOccupied: 'Tulajdonos lakja',
tenureRentedPrivate: 'Magánbérlemény',
tenureRentedSocial: 'Szociális bérlakás',
searchPlaceholder: 'Keresés cím vagy irányítószám alapján...',
propertyData: 'Ingatlanadatok',
propertyDataDesc:
@ -924,6 +928,14 @@ const hu: Translations = {
geolocationFailed: 'Nem sikerült meghatározni a tartózkodási helyed',
},
demoLocation: {
title: 'Fedezd fel a környékedet',
body: 'Szeretnél ingatlanadatokat látni a környékeden? Használd a helyzeted, vagy indítsd a demót a Canary Wharfnál.',
useLocation: 'Saját helyzet használata',
locating: 'Helymeghatározás…',
useDefault: 'Canary Wharf megjelenítése',
},
// ── Mobile Drawer ──────────────────────────────────
mobileDrawer: {
closeDrawer: 'Panel bezárása',

View file

@ -781,11 +781,15 @@ const zh: Translations = {
listedBuildingBadge: '可能为受保护建筑',
epcRating: 'EPC 评级:',
epcPotential: '潜在 EPC 评级:',
viewEpcCertificate: '查看 EPC 证书',
renovations: '改造记录',
perSqm: '/m²',
historyTitle: '历史',
historySale: '成交',
historyBuilt: '建成',
tenureOwnerOccupied: '自住',
tenureRentedPrivate: '私人出租',
tenureRentedSocial: '社会租赁',
searchPlaceholder: '按地址或邮编搜索...',
propertyData: '房产数据',
propertyDataDesc:
@ -866,6 +870,14 @@ const zh: Translations = {
geolocationFailed: '无法确定您的位置',
},
demoLocation: {
title: '探索您的区域',
body: '想查看您周围的房产数据吗?使用您的位置,或从金丝雀码头开始演示。',
useLocation: '使用我的位置',
locating: '正在定位…',
useDefault: '显示金丝雀码头',
},
// ── Mobile Drawer ──────────────────────────────────
mobileDrawer: {
closeDrawer: '关闭侧栏',

View file

@ -6,8 +6,8 @@
<meta name="theme-color" content="#fafaf9" media="(prefers-color-scheme: light)" />
<meta name="theme-color" content="#0a0e1a" media="(prefers-color-scheme: dark)" />
<meta name="referrer" content="no-referrer" />
<title>Perfect Postcode - Find where to buy before browsing listings</title>
<meta name="description" content="Filter every postcode in England by budget, commute, schools, crime, noise, broadband, property prices and amenities before you start chasing viewings." />
<title>Perfect Postcode - Find the best-value postcode in England</title>
<meta name="description" content="Rank every postcode in England by value: £/sqm, sold prices, schools, commute, crime, noise and broadband. Find the underpriced postcode the market overlooked." />
<meta name="x-og-placeholder" content="__PERFECT_POSTCODE_OG_TAGS__" />
<script id="perfect-postcode-bugsink-config" type="application/json">__PERFECT_POSTCODE_BUGSINK_CONFIG__</script>
<script>

View file

@ -38,9 +38,77 @@ export function authHeaders(init?: RequestInit): RequestInit {
return { ...init, headers: { ...existing, ...headers } };
}
// --- Demo location -----------------------------------------------------------
// The visitor's chosen demo centre (their GPS location, with consent) is sent to
// the server on every API request as demoLat/demoLon so the server can build the
// free-zone box around it. Held module-side and injected by apiUrl(), so every
// gated data call carries it without threading it through each hook. Unset when
// the visitor declines (the server then defaults to Canary Wharf).
const DEMO_CENTER_KEY = 'demoCenter';
export type DemoCenter = { lat: number; lon: number };
let demoCenter: DemoCenter | null = null;
/** Endpoints whose access is gated by the demo free zone only these need the
* visitor's demo centre, so we keep the coarse location out of every other URL. */
const DEMO_GATED_ENDPOINTS = new Set([
'hexagons',
'postcodes',
'filter-counts',
'hexagon-stats',
'postcode-stats',
'postcode-properties',
'hexagon-properties',
'journey',
'actual-listings',
'export',
]);
/** Set (or clear) the demo centre sent on subsequent gated API requests. */
export function setDemoCenter(center: DemoCenter | null): void {
demoCenter = center;
}
/** Persist the visitor's demo-location choice for this browser session. */
export function persistDemoChoice(value: DemoCenter | 'declined'): void {
try {
sessionStorage.setItem(
DEMO_CENTER_KEY,
value === 'declined' ? 'declined' : JSON.stringify(value)
);
} catch {
// sessionStorage unavailable (private mode / SSR) — re-prompting is fine.
}
}
/** Read this session's demo-location choice: coords, 'declined', or null (unset). */
export function readDemoChoice(): DemoCenter | 'declined' | null {
try {
const raw = sessionStorage.getItem(DEMO_CENTER_KEY);
if (!raw) return null;
if (raw === 'declined') return 'declined';
const parsed = JSON.parse(raw) as Partial<DemoCenter>;
if (typeof parsed?.lat === 'number' && typeof parsed?.lon === 'number') {
return { lat: parsed.lat, lon: parsed.lon };
}
} catch {
// Ignore malformed/unavailable storage.
}
return null;
}
export function apiUrl(endpoint: string, params?: URLSearchParams): string {
const path = endpoint.startsWith('/') ? endpoint : `/api/${endpoint}`;
const query = params?.toString();
const merged = new URLSearchParams(params);
if (demoCenter && DEMO_GATED_ENDPOINTS.has(endpoint)) {
// Coarsen to ~1 km (the demo free-zone box is ~25 km wide) so a precise location
// never reaches the server or its access logs.
merged.set('demoLat', demoCenter.lat.toFixed(2));
merged.set('demoLon', demoCenter.lon.toFixed(2));
}
const query = merged.toString();
return query ? `${path}?${query}` : path;
}

View file

@ -13,16 +13,28 @@ export const MAP_MIN_ZOOM = 5.5;
export const BUFFER_MULTIPLIER = 1;
/** Demo free zone bounds (south, west, north, east) — must match server FREE_ZONE_BOUNDS */
export const FREE_ZONE_BOUNDS = { south: 51.44, west: -0.31, north: 51.59, east: 0.05 };
/**
* Default demo centre (Canary Wharf). Used as the initial map view and the demo
* free-zone centre when the visitor declines the "check your area" prompt. When
* they consent, their GPS location is used instead (see lib/api `setDemoCenter`).
* Keep in sync with FREE_ZONE_FALLBACK_LAT/LON in the Rust server.
*/
export const CANARY_WHARF = { latitude: 51.5054, longitude: -0.0235 };
/** 10 Downing Street geolocation fallback in dev so the GPS flows stay testable
* without a real fix (e.g. over http, where browsers deny geolocation). */
export const DEV_LOCATION = { latitude: 51.5033635, longitude: -0.1276248 };
export const INITIAL_VIEW_STATE: ViewState = {
longitude: (FREE_ZONE_BOUNDS.west + FREE_ZONE_BOUNDS.east) / 2,
latitude: (FREE_ZONE_BOUNDS.south + FREE_ZONE_BOUNDS.north) / 2,
longitude: CANARY_WHARF.longitude,
latitude: CANARY_WHARF.latitude,
zoom: 14,
pitch: 0,
};
/** Demo (unlicensed) users can apply at most this many filters simultaneously. */
export const DEMO_MAX_FILTERS = 5;
/**
* Zoom to H3 resolution mapping thresholds.
* Returns the H3 resolution to use for a given zoom level.
@ -32,6 +44,7 @@ export const ZOOM_TO_RESOLUTION_THRESHOLDS = [
{ maxZoom: 9, resolution: 6 },
{ maxZoom: 10.5, resolution: 7 },
{ maxZoom: 11.5, resolution: 8 },
{ maxZoom: 14, resolution: 9 },
] as const;
export const SMALLEST_VISIBLE_HEXAGON_RESOLUTION = Math.max(
@ -43,7 +56,7 @@ export const SMALLEST_VISIBLE_HEXAGON_RESOLUTION = Math.max(
// past the finest hexagon level so detail appears while still relatively
// zoomed out. (Each overlay additionally can't render below its own tile-data
// floor, OVERLAY_MIN_ZOOM, regardless of this limit.)
export const POSTCODE_ZOOM_THRESHOLD = 12.5;
export const POSTCODE_ZOOM_THRESHOLD = 14;
export const POSTCODE_SEARCH_ZOOM = 16;
export const FEATURE_GRADIENT: { t: number; color: [number, number, number] }[] = [

View file

@ -92,6 +92,14 @@ interface SearchUrlOptions {
rightmoveLocationId?: string;
}
/** UK government "Find an energy certificate" domestic search, scoped to the
* property's postcode. We don't store the per-certificate LMK key, so this lands
* the user on the postcode result list where they pick the matching address. */
export function buildEpcCertificateUrl(postcode: string): string {
const params = new URLSearchParams({ postcode });
return `https://find-energy-certificate.service.gov.uk/find-a-certificate/search-by-postcode?${params.toString()}`;
}
export function buildRightmoveExactPostcodeRedirectUrl(
postcode: string,
targetUrl: string

View file

@ -124,7 +124,7 @@ export const SEO_LANDING_PAGES: Record<SeoLandingKey, SeoLandingContent> = {
{
question: 'Is this a replacement for Rightmove or Zoopla?',
answer:
'No. Use it before and alongside listing portals. Perfect Postcode helps decide where to look; listing portals show whats currently for sale.',
'No, it does a different job. Listing portals show whats for sale today; Perfect Postcode shows where the value is across Englands postcodes.',
},
{
question: 'Can I compare price with schools or commute time?',

View file

@ -63,6 +63,9 @@ const CRIME_OVERLAY_ID: OverlayId = 'crime-hotspots';
export interface UrlState {
viewState: ViewState;
/** True only when the URL carried explicit lat/lon/zoom (shared/dashboard link).
* False on a fresh visit, so the app may centre on the visitor's IP instead. */
hasExplicitView: boolean;
filters: FeatureFilters;
poiCategories: Set<string>;
overlays: Set<OverlayId>;
@ -221,6 +224,7 @@ export function parseUrlState(): UrlState {
const params = new URLSearchParams(window.location.search);
const result: UrlState = {
viewState: INITIAL_VIEW_STATE,
hasExplicitView: false,
filters: parseFilters(params),
poiCategories: new Set(),
overlays: new Set(),
@ -246,6 +250,7 @@ export function parseUrlState(): UrlState {
const zoomN = Number(zoom);
if (!isNaN(latN) && !isNaN(lonN) && !isNaN(zoomN)) {
result.viewState = { latitude: latN, longitude: lonN, zoom: zoomN, pitch: 0 };
result.hasExplicitView = true;
}
}

View file

@ -202,6 +202,14 @@ export interface RenovationEvent {
event: string;
}
/** A point on the tenure timeline: the year an EPC certificate first recorded a
* new occupancy `status` ("Owner-occupied" / "Rented (private)" /
* "Rented (social)"). Surfaces when a home was let out vs lived in. */
export interface TenureEvent {
year: number;
status: string;
}
export interface HistoricalPrice {
year: number;
month: number;
@ -229,6 +237,7 @@ export interface Property {
is_construction_date_approximate?: boolean;
renovation_history?: RenovationEvent[];
historical_prices?: HistoricalPrice[];
tenure_history?: TenureEvent[];
// All other numeric features (dynamic, including construction_age_band)
[key: string]:
@ -237,6 +246,7 @@ export interface Property {
| boolean
| RenovationEvent[]
| HistoricalPrice[]
| TenureEvent[]
| string[]
| undefined;
}