From 4a0f00f2a4726d64c8297738df0421131053be0b Mon Sep 17 00:00:00 2001 From: Andras Schmelczer Date: Wed, 17 Jun 2026 07:54:30 +0100 Subject: [PATCH] new demo mode & tenure --- frontend/scripts/prerender.mjs | 132 +++---- frontend/src/App.tsx | 97 ++--- .../src/components/map/DemoLocationPrompt.tsx | 71 ++++ .../src/components/map/DualHistogram.test.ts | 14 +- frontend/src/components/map/DualHistogram.tsx | 6 +- .../src/components/map/LocationSearch.tsx | 9 +- frontend/src/components/map/Map.tsx | 32 +- .../components/map/MapErrorBoundary.test.tsx | 95 +++++ .../src/components/map/MapErrorBoundary.tsx | 141 +++++++ frontend/src/components/map/MapPage.tsx | 142 ++++++- frontend/src/components/map/MapTopCards.tsx | 8 + .../components/map/MobileBottomSheet.test.tsx | 36 ++ .../src/components/map/MobileBottomSheet.tsx | 36 +- .../components/map/PropertiesPane.test.tsx | 58 +++ .../src/components/map/PropertiesPane.tsx | 71 +++- .../map/map-page/DesktopMapPage.tsx | 81 ++-- .../components/map/map-page/MobileMapPage.tsx | 90 +++-- frontend/src/components/map/map-page/types.ts | 2 + .../map/map-page/useExportController.ts | 6 + frontend/src/hooks/useFilters.test.ts | 95 +++++ frontend/src/hooks/useFilters.ts | 49 ++- .../src/hooks/useHexagonSelection.test.ts | 68 ++++ frontend/src/hooks/useHexagonSelection.ts | 54 ++- frontend/src/hooks/useMapData.ts | 4 + frontend/src/i18n/locales/de.ts | 12 + frontend/src/i18n/locales/en.ts | 13 + frontend/src/i18n/locales/fr.ts | 12 + frontend/src/i18n/locales/hi.ts | 12 + frontend/src/i18n/locales/hu.ts | 12 + frontend/src/i18n/locales/zh.ts | 12 + frontend/src/index.html | 4 +- frontend/src/lib/api.ts | 70 +++- frontend/src/lib/consts.ts | 23 +- frontend/src/lib/external-search.ts | 8 + frontend/src/lib/seoLandingPages.ts | 2 +- frontend/src/lib/url-state.ts | 5 + frontend/src/types.ts | 10 + pipeline/transform/join_epc_pp.py | 121 +++++- pipeline/transform/merge.py | 46 ++- pipeline/transform/test_join_epc_pp.py | 153 +++++++- pipeline/transform/test_merge.py | 40 +- pipeline/utils/fuzzy_join.py | 28 +- pipeline/utils/test_fuzzy_join.py | 85 ++++- server-rs/src/consts.rs | 36 +- server-rs/src/data.rs | 2 +- server-rs/src/data/property/address_search.rs | 74 ++++ server-rs/src/data/property/loading.rs | 109 +++++- server-rs/src/data/property/mod.rs | 22 ++ server-rs/src/demo_zone.rs | 127 +++++++ server-rs/src/licensing.rs | 29 +- server-rs/src/main.rs | 5 + server-rs/src/ratelimit.rs | 256 +++++++++++++ server-rs/src/routes/actual_listings.rs | 3 +- server-rs/src/routes/export.rs | 354 ++++++++++++++++-- server-rs/src/routes/filter_counts.rs | 3 +- server-rs/src/routes/hexagon_stats.rs | 3 +- server-rs/src/routes/hexagons.rs | 3 +- server-rs/src/routes/journey.rs | 3 +- server-rs/src/routes/postcode_properties.rs | 2 + server-rs/src/routes/postcode_stats.rs | 31 +- server-rs/src/routes/postcodes.rs | 3 +- server-rs/src/routes/properties.rs | 9 +- server-rs/src/routes/stats.rs | 69 ++++ server-rs/src/state.rs | 5 +- 64 files changed, 2875 insertions(+), 338 deletions(-) create mode 100644 frontend/src/components/map/DemoLocationPrompt.tsx create mode 100644 frontend/src/components/map/MapErrorBoundary.test.tsx create mode 100644 frontend/src/components/map/MapErrorBoundary.tsx create mode 100644 frontend/src/components/map/PropertiesPane.test.tsx create mode 100644 server-rs/src/demo_zone.rs create mode 100644 server-rs/src/ratelimit.rs diff --git a/frontend/scripts/prerender.mjs b/frontend/scripts/prerender.mjs index 66d005c..5462f0a 100644 --- a/frontend/scripts/prerender.mjs +++ b/frontend/scripts/prerender.mjs @@ -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 { diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 54a90df..7dcf4b0 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -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(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(() => + 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 ? ( - ) : ( 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 ( +
+
+
+

+ {t('demoLocation.title')} +

+

{t('demoLocation.body')}

+
+ +
+ + + {error &&

{error}

} +
+
+
+ ); +} diff --git a/frontend/src/components/map/DualHistogram.test.ts b/frontend/src/components/map/DualHistogram.test.ts index 92aac74..94ca7d8 100644 --- a/frontend/src/components/map/DualHistogram.test.ts +++ b/frontend/src/components/map/DualHistogram.test.ts @@ -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', () => { diff --git a/frontend/src/components/map/DualHistogram.tsx b/frontend/src/components/map/DualHistogram.tsx index bd2670f..9b469f9 100644 --- a/frontend/src/components/map/DualHistogram.tsx +++ b/frontend/src/components/map/DualHistogram.tsx @@ -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)}+`; diff --git a/frontend/src/components/map/LocationSearch.tsx b/frontend/src/components/map/LocationSearch.tsx index f3d7177..e858356 100644 --- a/frontend/src/components/map/LocationSearch.tsx +++ b/frontend/src/components/map/LocationSearch.tsx @@ -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 = { 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((resolve, reject) => { if (!navigator.geolocation) { reject(new Error('Geolocation unsupported')); diff --git a/frontend/src/components/map/Map.tsx b/frontend/src/components/map/Map.tsx index 588bcbe..8970251 100644 --- a/frontend/src/components/map/Map.tsx +++ b/frontend/src/components/map/Map.tsx @@ -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} /> - + {!screenshotMode && } {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} diff --git a/frontend/src/components/map/MapErrorBoundary.test.tsx b/frontend/src/components/map/MapErrorBoundary.test.tsx new file mode 100644 index 0000000..9c72a92 --- /dev/null +++ b/frontend/src/components/map/MapErrorBoundary.test.tsx @@ -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
map content
; +} + +let consoleErrorSpy: ReturnType; + +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( + + + + ); + expect(screen.getByText('map content')).toBeTruthy(); + expect(captureException).not.toHaveBeenCalled(); + }); + + it('reports the error and auto-recovers by remounting the map', () => { + render( + + + + ); + + // 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( + + + + ); + + // 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(); + }); +}); diff --git a/frontend/src/components/map/MapErrorBoundary.tsx b/frontend/src/components/map/MapErrorBoundary.tsx new file mode 100644 index 0000000..4b19af0 --- /dev/null +++ b/frontend/src/components/map/MapErrorBoundary.tsx @@ -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 { + state: MapErrorBoundaryState = { + errored: false, + failedPermanently: false, + renderKey: 0, + recoveries: 0, + }; + + private recoveryTimer: ReturnType | null = null; + private lastErrorAt = 0; + + static getDerivedStateFromError(): Partial { + 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 ( +
+
+

+ The map ran into a problem +

+

+ This can happen when your browser's graphics context is interrupted. +

+ +
+
+ ); + } + + // 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 ; + } + + return {this.props.children}; + } +} diff --git a/frontend/src/components/map/MapPage.tsx b/frontend/src/components/map/MapPage.tsx index 51088ad..beaecb3 100644 --- a/frontend/src/components/map/MapPage.tsx +++ b/frontend/src/components/map/MapPage.tsx @@ -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(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({ ) : 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 ? ( license.startCheckout(checkoutReturnPath)} - onZoomToFreeZone={handleZoomToFreeZone} - isShareReturn={!!shareReturnViewRef.current} + onZoomToFreeZone={ + showFilterUpgradeModal ? () => setFilterLimitHit(false) : handleZoomToFreeZone + } + isShareReturn={showZoneUpgradeModal && !!shareReturnViewRef.current} /> ) : null; + const demoLocationPrompt = demoPromptOpen ? ( + + ) : null; + + // Both overlays render where the page components place {upgradeModal}. + const overlayModals = ( + <> + {upgradeModal} + {demoLocationPrompt} + + ); + if (isMobile) { return ( ); @@ -1099,7 +1225,7 @@ export default function MapPage({ renderAreaPane={renderAreaPane} renderPropertiesPane={renderPropertiesPane} toasts={toasts} - upgradeModal={upgradeModal} + upgradeModal={overlayModals} /> ); } diff --git a/frontend/src/components/map/MapTopCards.tsx b/frontend/src/components/map/MapTopCards.tsx index 1846fa5..526d6e2 100644 --- a/frontend/src/components/map/MapTopCards.tsx +++ b/frontend/src/components/map/MapTopCards.tsx @@ -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 (
{showLocationSearch && ( { 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); + }); }); diff --git a/frontend/src/components/map/MobileBottomSheet.tsx b/frontend/src/components/map/MobileBottomSheet.tsx index 407b88b..fb7c71f 100644 --- a/frontend/src/components/map/MobileBottomSheet.tsx +++ b/frontend/src/components/map/MobileBottomSheet.tsx @@ -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(null); const [height, setHeight] = useState(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); diff --git a/frontend/src/components/map/PropertiesPane.test.tsx b/frontend/src/components/map/PropertiesPane.test.tsx new file mode 100644 index 0000000..93993bc --- /dev/null +++ b/frontend/src/components/map/PropertiesPane.test.tsx @@ -0,0 +1,58 @@ +import { describe, expect, it } from 'vitest'; + +import { buildTimelineEvents } from './PropertiesPane'; +import type { Property } from '../../types'; + +function makeProperty(overrides: Partial): 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([]); + }); +}); diff --git a/frontend/src/components/map/PropertiesPane.tsx b/frontend/src/components/map/PropertiesPane.tsx index ff6e691..4b90977 100644 --- a/frontend/src/components/map/PropertiesPane.tsx +++ b/frontend/src/components/map/PropertiesPane.tsx @@ -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 }) { )}
+ {property.postcode && property.current_energy_rating && ( + + {t('propertyCard.viewEpcCertificate')} + + + + + )} + ); @@ -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 (
@@ -383,6 +434,16 @@ function PropertyTimeline({ property }: { property: Property }) { )} + {event.kind === 'tenure' && ( + <> + + {tenureLabel(event.status)} + + + {event.year} + + + )} {event.kind === 'built' && ( <> @@ -419,6 +480,14 @@ function TimelineMarker({ kind }: { kind: TimelineEvent['kind'] }) { /> ); } + if (kind === 'tenure') { + return ( + + ); + } return ( - }> - - + + }> + + +
{onToggleActualListings && (