new demo mode & tenure
This commit is contained in:
parent
7656f24544
commit
4a0f00f2a4
64 changed files with 2875 additions and 338 deletions
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
71
frontend/src/components/map/DemoLocationPrompt.tsx
Normal file
71
frontend/src/components/map/DemoLocationPrompt.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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', () => {
|
||||
|
|
|
|||
|
|
@ -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)}+`;
|
||||
|
|
|
|||
|
|
@ -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'));
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
95
frontend/src/components/map/MapErrorBoundary.test.tsx
Normal file
95
frontend/src/components/map/MapErrorBoundary.test.tsx
Normal 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();
|
||||
});
|
||||
});
|
||||
141
frontend/src/components/map/MapErrorBoundary.tsx
Normal file
141
frontend/src/components/map/MapErrorBoundary.tsx
Normal 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'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>;
|
||||
}
|
||||
}
|
||||
|
|
@ -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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
||||
|
|
|
|||
58
frontend/src/components/map/PropertiesPane.test.tsx
Normal file
58
frontend/src/components/map/PropertiesPane.test.tsx
Normal 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([]);
|
||||
});
|
||||
});
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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>;
|
||||
|
|
|
|||
|
|
@ -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!;
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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: 'Couldn’t 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',
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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: 'ड्रॉअर बंद करें',
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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: '关闭侧栏',
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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] }[] = [
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 what’s currently for sale.',
|
||||
'No, it does a different job. Listing portals show what’s for sale today; Perfect Postcode shows where the value is across England’s postcodes.',
|
||||
},
|
||||
{
|
||||
question: 'Can I compare price with schools or commute time?',
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue