diff --git a/Makefile.data b/Makefile.data index e62facf..0c2e772 100644 --- a/Makefile.data +++ b/Makefile.data @@ -8,6 +8,7 @@ SHELL := /bin/bash DATA_DIR := ./property-data MANUAL_DATA := ./manual-data +FINDER_DATA := ./finder/data # ── Output files ────────────────────────────────────────────────────────────── @@ -27,6 +28,8 @@ MERGE_STAMP := $(DATA_DIR)/.merge_done PRICE_INDEX := $(DATA_DIR)/price_index.parquet PRICES_STAMP := $(DATA_DIR)/.prices_done EPC := $(MANUAL_DATA)/domestic-csv.zip +ACTUAL_LISTINGS_RAW := $(FINDER_DATA)/online_listings_buy.parquet +ACTUAL_LISTINGS_ENRICHED := $(FINDER_DATA)/online_listings_buy_enriched.parquet ETHNICITY := $(DATA_DIR)/ethnicity_by_la.parquet CRIME_DIR := $(DATA_DIR)/crime CRIME := $(DATA_DIR)/crime_by_lsoa.parquet @@ -106,12 +109,13 @@ MAP_ASSETS_DEPS := pipeline/download/map_assets.py pipeline/transform/transform_ download-map-assets \ transform-pois transform-epc-pp transform-crime transform-poi-proximity \ transform-school-proximity transform-tree-density \ - generate-postcode-boundaries generate-travel-times + generate-postcode-boundaries generate-travel-times enrich-actual-listings prepare: $(PRICES_STAMP) download-places tiles overlay-tiles generate-postcode-boundaries download-map-assets generate-travel-times | $(POSTCODES_PQ) $(PROPERTIES_PQ) $(PRICE_INDEX) $(VALIDATE_OUTPUTS) --parquet $(POSTCODES_PQ) --parquet $(PROPERTIES_PQ) --parquet $(PRICE_INDEX) merge: $(MERGE_STAMP) | $(POSTCODES_PQ) $(PROPERTIES_PQ) $(VALIDATE_OUTPUTS) --parquet $(POSTCODES_PQ) --parquet $(PROPERTIES_PQ) +enrich-actual-listings: $(ACTUAL_LISTINGS_ENRICHED) tiles: $(TILES) overlay-tiles: noise-overlay-tiles crime-hotspot-tiles tree-overlay-tiles noise-overlay-tiles: $(NOISE_OVERLAY_TILES) @@ -319,8 +323,8 @@ $(MAP_ASSETS_STAMP): $(MAP_ASSETS_DEPS) # ── Transforms ──────────────────────────────────────────────────────────────── -$(POIS_FILTERED): $(POIS_RAW) $(NAPTAN) $(GROCERY_RETAIL_POINTS) $(GIAS) $(ENGLAND_BOUNDARY) pipeline/transform/transform_poi.py pipeline/utils/england_geometry.py - uv run python -m pipeline.transform.transform_poi --input $(POIS_RAW) --naptan $(NAPTAN) --boundary $(ENGLAND_BOUNDARY) --grocery-retail-points $(GROCERY_RETAIL_POINTS) --gias $(GIAS) --output $@ +$(POIS_FILTERED): $(POIS_RAW) $(NAPTAN) $(GROCERY_RETAIL_POINTS) $(GIAS) $(OFSTED) $(ENGLAND_BOUNDARY) pipeline/transform/transform_poi.py pipeline/utils/england_geometry.py + uv run python -m pipeline.transform.transform_poi --input $(POIS_RAW) --naptan $(NAPTAN) --boundary $(ENGLAND_BOUNDARY) --grocery-retail-points $(GROCERY_RETAIL_POINTS) --gias $(GIAS) --ofsted $(OFSTED) --output $@ $(EPC_PP): $(PRICE_PAID) $(EPC) pipeline/transform/join_epc_pp.py pipeline/utils/fuzzy_join.py uv run python -m pipeline.transform.join_epc_pp --epc $(EPC) --price-paid $(PRICE_PAID) --output $@ @@ -404,3 +408,13 @@ $(PRICES_STAMP): $(MERGE_STAMP) $(PRICE_INDEX) $(PRICE_ESTIMATE_DEPS) | $(PROPER uv run python -m pipeline.transform.price_estimation.estimate --properties $(PROPERTIES_PQ) --postcodes $(POSTCODES_PQ) --index $(PRICE_INDEX) $(VALIDATE_OUTPUTS) --parquet $(PROPERTIES_PQ) --parquet $(POSTCODES_PQ) --parquet $(PRICE_INDEX) @touch $@ + +$(ACTUAL_LISTINGS_ENRICHED): $(ACTUAL_LISTINGS_RAW) $(PRICES_STAMP) $(POSTCODES_PQ) $(ARCGIS) $(EPC) \ + pipeline/transform/enrich_actual_listings.py pipeline/transform/join_epc_pp.py pipeline/utils/fuzzy_join.py + uv run python -m pipeline.transform.enrich_actual_listings \ + --listings $(ACTUAL_LISTINGS_RAW) \ + --properties $(PROPERTIES_PQ) \ + --postcode-features $(POSTCODES_PQ) \ + --arcgis $(ARCGIS) \ + --epc $(EPC) \ + --output $@ diff --git a/docker-compose.yml b/docker-compose.yml index 6623c8e..595fe5f 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -50,7 +50,7 @@ services: BUGSINK_ENVIRONMENT: ${BUGSINK_ENVIRONMENT:-development} BUGSINK_RELEASE: ${BUGSINK_RELEASE:-} BUGSINK_SEND_DEFAULT_PII: ${BUGSINK_SEND_DEFAULT_PII:-false} - ACTUAL_LISTINGS_PATH: /app/finder-data/online_listings_buy.parquet + ACTUAL_LISTINGS_PATH: /app/finder-data/online_listings_buy_enriched.parquet CRIME_BY_YEAR_PATH: /app/data/crime_by_year_by_lsoa.parquet depends_on: screenshot: diff --git a/frontend/public/assets/twemoji/1f392.png b/frontend/public/assets/twemoji/1f392.png new file mode 100644 index 0000000..c064023 Binary files /dev/null and b/frontend/public/assets/twemoji/1f392.png differ diff --git a/frontend/public/assets/twemoji/1f393.png b/frontend/public/assets/twemoji/1f393.png new file mode 100644 index 0000000..09d4757 Binary files /dev/null and b/frontend/public/assets/twemoji/1f393.png differ diff --git a/frontend/public/assets/twemoji/1f9f8.png b/frontend/public/assets/twemoji/1f9f8.png new file mode 100644 index 0000000..4ba5b7b Binary files /dev/null and b/frontend/public/assets/twemoji/1f9f8.png differ diff --git a/frontend/public/video/recording-mobile.jpg b/frontend/public/video/recording-mobile.jpg index a648e72..19f1cdb 100644 Binary files a/frontend/public/video/recording-mobile.jpg and b/frontend/public/video/recording-mobile.jpg differ diff --git a/frontend/public/video/recording-mobile.mp4 b/frontend/public/video/recording-mobile.mp4 index dacf313..95b868e 100644 Binary files a/frontend/public/video/recording-mobile.mp4 and b/frontend/public/video/recording-mobile.mp4 differ diff --git a/frontend/public/video/recording.jpg b/frontend/public/video/recording.jpg index c549445..de8c3ff 100644 Binary files a/frontend/public/video/recording.jpg and b/frontend/public/video/recording.jpg differ diff --git a/frontend/public/video/recording.mp4 b/frontend/public/video/recording.mp4 index 6b1f56e..d4cc29b 100644 Binary files a/frontend/public/video/recording.mp4 and b/frontend/public/video/recording.mp4 differ diff --git a/frontend/src/components/map/AreaPane.tsx b/frontend/src/components/map/AreaPane.tsx index d835d0d..aea2a48 100644 --- a/frontend/src/components/map/AreaPane.tsx +++ b/frontend/src/components/map/AreaPane.tsx @@ -41,7 +41,6 @@ import { EmptyState } from '../ui/EmptyState'; import { FeatureLabel } from '../ui/FeatureLabel'; import { IndeterminateProgressBar } from '../ui/IndeterminateProgressBar'; import StreetViewEmbed from './StreetViewEmbed'; -import HistogramLegend from './HistogramLegend'; import JourneyInstructions from './JourneyInstructions'; interface AreaPaneProps { @@ -462,7 +461,6 @@ export default function AreaPane({ ) : stats ? (
{hexagonLocation && } - {stats.count > 0 && } {stats.price_history && (() => { const uniqueYears = new Set(stats.price_history.map((p) => Math.floor(p.year))); @@ -547,6 +545,10 @@ export default function AreaPane({ if (total === 0) return null; + const crimeSeries = chart.feature + ? crimeByYearByFeatureName.get(chart.feature) + : undefined; + return (
+ {crimeSeries && crimeSeries.points.length > 1 && ( +
+ +
+ )}
); })} diff --git a/frontend/src/components/map/HistogramLegend.tsx b/frontend/src/components/map/HistogramLegend.tsx deleted file mode 100644 index a15433d..0000000 --- a/frontend/src/components/map/HistogramLegend.tsx +++ /dev/null @@ -1,21 +0,0 @@ -import { useTranslation } from 'react-i18next'; - -export default function HistogramLegend() { - const { t } = useTranslation(); - return ( -
-
-
- - {t('histogramLegend.tealBars')} - -
-
-
- - {t('histogramLegend.greyBars')} - -
-
- ); -} diff --git a/frontend/src/components/map/HoverCard.tsx b/frontend/src/components/map/HoverCard.tsx index 6e6596b..be04ef6 100644 --- a/frontend/src/components/map/HoverCard.tsx +++ b/frontend/src/components/map/HoverCard.tsx @@ -96,7 +96,7 @@ export default memo(function HoverCard({ if (!data) { return (
@@ -109,7 +109,7 @@ export default memo(function HoverCard({ return (
diff --git a/frontend/src/components/map/LocationSearch.test.tsx b/frontend/src/components/map/LocationSearch.test.tsx new file mode 100644 index 0000000..7d855de --- /dev/null +++ b/frontend/src/components/map/LocationSearch.test.tsx @@ -0,0 +1,215 @@ +import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'; +import { afterEach, describe, expect, it, vi } from 'vitest'; + +import LocationSearch from './LocationSearch'; +import type { PostcodeGeometry } from '../../types'; + +const RECENT_SEARCHES_STORAGE_KEY = 'perfect-postcode.locationSearch.recent'; + +vi.mock('react-i18next', () => ({ + useTranslation: () => ({ + t: (key: string) => + key === 'locationSearch.placeholder' ? 'Search places or postcodes...' : key, + }), +})); + +vi.mock('../../hooks/useIsMobile', () => ({ + useIsMobile: () => false, +})); + +vi.mock('../../lib/pocketbase', () => ({ + default: { authStore: { isValid: false, token: '' } }, +})); + +const postcodeGeometry: PostcodeGeometry = { + type: 'Polygon', + coordinates: [ + [ + [-0.12, 51.5], + [-0.11, 51.5], + [-0.11, 51.51], + [-0.12, 51.51], + [-0.12, 51.5], + ], + ], +}; + +function deferred() { + let resolve!: (value: T) => void; + let reject!: (reason?: unknown) => void; + const promise = new Promise((res, rej) => { + resolve = res; + reject = rej; + }); + return { promise, resolve, reject }; +} + +function jsonResponse(body: unknown): Response { + return new Response(JSON.stringify(body), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }); +} + +describe('LocationSearch', () => { + afterEach(() => { + cleanup(); + window.localStorage.clear(); + vi.unstubAllGlobals(); + }); + + it('ignores stale postcode lookups when a newer search starts', async () => { + const firstLookup = deferred(); + const secondLookup = deferred(); + const requests: { postcode: string; signal?: AbortSignal | null }[] = []; + + vi.stubGlobal( + 'fetch', + vi.fn((input: string | URL | Request, init?: RequestInit) => { + const url = new URL(String(input), 'http://localhost'); + const postcode = decodeURIComponent(url.pathname.replace('/api/postcode/', '')); + requests.push({ postcode, signal: init?.signal }); + if (postcode === 'SW1A 1AA') return firstLookup.promise; + if (postcode === 'E14 2DG') return secondLookup.promise; + return Promise.resolve(new Response(null, { status: 404 })); + }) + ); + + const onFlyTo = vi.fn(); + const onLocationSearched = vi.fn(); + + render(); + + const input = screen.getByRole('textbox'); + fireEvent.change(input, { target: { value: 'SW1A 1AA' } }); + fireEvent.keyDown(input, { key: 'Enter' }); + + fireEvent.change(input, { target: { value: 'E14 2DG' } }); + fireEvent.keyDown(input, { key: 'Enter' }); + + expect(requests).toHaveLength(2); + expect(requests[0].signal?.aborted).toBe(true); + + secondLookup.resolve( + jsonResponse({ + postcode: 'E14 2DG', + latitude: 51.505, + longitude: -0.01, + geometry: postcodeGeometry, + }) + ); + + await waitFor(() => { + expect(onLocationSearched).toHaveBeenCalledTimes(1); + }); + expect(onFlyTo).toHaveBeenCalledWith(51.505, -0.01, 16); + expect(onLocationSearched).toHaveBeenCalledWith({ + postcode: 'E14 2DG', + geometry: postcodeGeometry, + latitude: 51.505, + longitude: -0.01, + zoom: 16, + }); + + firstLookup.resolve( + jsonResponse({ + postcode: 'SW1A 1AA', + latitude: 51.501, + longitude: -0.141, + geometry: postcodeGeometry, + }) + ); + + await new Promise((resolve) => setTimeout(resolve, 0)); + expect(onFlyTo).toHaveBeenCalledTimes(1); + expect(onLocationSearched).toHaveBeenCalledTimes(1); + }); + + it('stores successful searches locally and shows them when the input is empty', async () => { + vi.stubGlobal( + 'fetch', + vi.fn((input: string | URL | Request) => { + const url = new URL(String(input), 'http://localhost'); + const postcode = decodeURIComponent(url.pathname.replace('/api/postcode/', '')); + return Promise.resolve( + jsonResponse({ + postcode, + latitude: 51.505, + longitude: -0.01, + geometry: postcodeGeometry, + }) + ); + }) + ); + + const onFlyTo = vi.fn(); + const onLocationSearched = vi.fn(); + + render(); + + const input = screen.getByRole('textbox'); + fireEvent.change(input, { target: { value: 'SW1A 1AA' } }); + fireEvent.keyDown(input, { key: 'Enter' }); + + await waitFor(() => { + expect(onLocationSearched).toHaveBeenCalledTimes(1); + }); + + expect(JSON.parse(window.localStorage.getItem(RECENT_SEARCHES_STORAGE_KEY) ?? '[]')).toEqual([ + { type: 'postcode', label: 'SW1A 1AA' }, + ]); + + fireEvent.focus(input); + + await waitFor(() => { + expect(screen.getByRole('button', { name: 'SW1A 1AA' })).toBeTruthy(); + }); + }); + + it('keeps only the three most recent local searches', async () => { + vi.stubGlobal( + 'fetch', + vi.fn((input: string | URL | Request) => { + const url = new URL(String(input), 'http://localhost'); + const postcode = decodeURIComponent(url.pathname.replace('/api/postcode/', '')); + return Promise.resolve( + jsonResponse({ + postcode, + latitude: 51.505, + longitude: -0.01, + geometry: postcodeGeometry, + }) + ); + }) + ); + + render(); + + const input = screen.getByRole('textbox'); + for (const postcode of ['SW1A 1AA', 'E14 2DG', 'W1A 1AA', 'EC1A 1BB']) { + fireEvent.change(input, { target: { value: postcode } }); + fireEvent.keyDown(input, { key: 'Enter' }); + + await waitFor(() => { + const stored = JSON.parse( + window.localStorage.getItem(RECENT_SEARCHES_STORAGE_KEY) ?? '[]' + ) as { label?: string }[]; + expect(stored[0]?.label).toBe(postcode); + }); + } + + const stored = JSON.parse(window.localStorage.getItem(RECENT_SEARCHES_STORAGE_KEY) ?? '[]') as { + label?: string; + }[]; + expect(stored.map((search) => search.label)).toEqual(['EC1A 1BB', 'W1A 1AA', 'E14 2DG']); + + fireEvent.focus(input); + + await waitFor(() => { + expect(screen.getByRole('button', { name: 'EC1A 1BB' })).toBeTruthy(); + expect(screen.getByRole('button', { name: 'W1A 1AA' })).toBeTruthy(); + expect(screen.getByRole('button', { name: 'E14 2DG' })).toBeTruthy(); + }); + expect(screen.queryByText('SW1A 1AA')).toBeNull(); + }); +}); diff --git a/frontend/src/components/map/LocationSearch.tsx b/frontend/src/components/map/LocationSearch.tsx index 761016d..508f7f5 100644 --- a/frontend/src/components/map/LocationSearch.tsx +++ b/frontend/src/components/map/LocationSearch.tsx @@ -1,7 +1,7 @@ import { useState, useCallback, useRef, useEffect } from 'react'; import { useTranslation } from 'react-i18next'; import type { MapFlyToOptions, PostcodeGeometry } from '../../types'; -import { authHeaders } from '../../lib/api'; +import { authHeaders, isAbortError } from '../../lib/api'; import { POSTCODE_SEARCH_ZOOM } from '../../lib/consts'; import { useIsMobile } from '../../hooks/useIsMobile'; import { useLocationSearch, type SearchResult } from '../../hooks/useLocationSearch'; @@ -16,6 +16,7 @@ export interface SearchedLocation { geometry: PostcodeGeometry; latitude: number; longitude: number; + zoom: number; markerLatitude?: number; markerLongitude?: number; openProperties?: boolean; @@ -73,6 +74,34 @@ export default function LocationSearch({ const isMobile = useIsMobile(); const containerRef = useRef(null); const inputRef = useRef(null); + const lookupAbortRef = useRef(null); + const lookupRequestIdRef = useRef(0); + + const cancelLookup = useCallback((updateLoading = true) => { + lookupRequestIdRef.current += 1; + lookupAbortRef.current?.abort(); + lookupAbortRef.current = null; + if (updateLoading) setLoading(false); + }, []); + + const beginLookup = useCallback(() => { + lookupAbortRef.current?.abort(); + const controller = new AbortController(); + lookupAbortRef.current = controller; + lookupRequestIdRef.current += 1; + setError(null); + setLoading(true); + search.close(); + return { controller, requestId: lookupRequestIdRef.current }; + }, [search]); + + const isCurrentLookup = useCallback((requestId: number, controller: AbortController) => { + return ( + lookupRequestIdRef.current === requestId && + lookupAbortRef.current === controller && + !controller.signal.aborted + ); + }, []); // Close on outside click useEffect(() => { @@ -93,106 +122,136 @@ export default function LocationSearch({ } }, [isMobile, expanded]); + useEffect(() => { + return () => cancelLookup(false); + }, [cancelLookup]); + const selectResult = useCallback( async (result: SearchResult) => { + const { controller, requestId } = beginLookup(); + if (result.type === 'place') { const zoom = ZOOM_FOR_TYPE[result.place_type] ?? 14; - setError(null); - setLoading(true); - search.close(); - onFlyTo(result.lat, result.lon, zoom); + // On mobile the drawer opens after onLocationSearched; MapPage handles + // the fly-to there with the correct viewport inset so the target isn't + // hidden behind the drawer. On desktop fly immediately for snappy feedback. + if (!isMobile) onFlyTo(result.lat, result.lon, zoom); try { const params = new URLSearchParams({ lat: String(result.lat), lng: String(result.lon), log: 'false', }); - const res = await fetch(`/api/nearest-postcode?${params}`, authHeaders()); + const res = await fetch( + `/api/nearest-postcode?${params}`, + authHeaders({ signal: controller.signal }) + ); + if (!isCurrentLookup(requestId, controller)) return; if (!res.ok) { setError(t('locationSearch.lookupFailed')); return; } const json: PostcodeLookupResponse = await res.json(); + if (!isCurrentLookup(requestId, controller)) return; onLocationSearched?.({ postcode: json.postcode, geometry: json.geometry, latitude: json.latitude, longitude: json.longitude, + zoom, markerLatitude: result.lat, markerLongitude: result.lon, }); + search.saveRecentSearch(result); search.clear(); if (isMobile) setExpanded(false); - } catch { + } catch (error) { + if (!isCurrentLookup(requestId, controller) || isAbortError(error)) return; setError(t('locationSearch.lookupFailed')); } finally { - setLoading(false); + if (isCurrentLookup(requestId, controller)) { + lookupAbortRef.current = null; + setLoading(false); + } } return; } if (result.type === 'address') { - setError(null); - setLoading(true); - search.close(); try { const res = await fetch( `/api/postcode/${encodeURIComponent(result.postcode)}`, - authHeaders() + authHeaders({ signal: controller.signal }) ); + if (!isCurrentLookup(requestId, controller)) return; if (!res.ok) { setError(t('locationSearch.postcodeNotFound')); return; } const json: PostcodeLookupResponse = await res.json(); - onFlyTo(result.lat, result.lon, 17); + if (!isCurrentLookup(requestId, controller)) return; + if (!isMobile) onFlyTo(result.lat, result.lon, 17); onLocationSearched?.({ postcode: json.postcode, geometry: json.geometry, latitude: result.lat, longitude: result.lon, + zoom: 17, markerLatitude: result.lat, markerLongitude: result.lon, openProperties: true, focusAddress: result.address, }); + search.saveRecentSearch(result); search.clear(); if (isMobile) setExpanded(false); - } catch { + } catch (error) { + if (!isCurrentLookup(requestId, controller) || isAbortError(error)) return; setError(t('locationSearch.lookupFailed')); } finally { - setLoading(false); + if (isCurrentLookup(requestId, controller)) { + lookupAbortRef.current = null; + setLoading(false); + } } return; } // Postcode — fetch geometry - setError(null); - setLoading(true); - search.close(); try { - const res = await fetch(`/api/postcode/${encodeURIComponent(result.label)}`, authHeaders()); + const res = await fetch( + `/api/postcode/${encodeURIComponent(result.label)}`, + authHeaders({ signal: controller.signal }) + ); + if (!isCurrentLookup(requestId, controller)) return; if (!res.ok) { setError(t('locationSearch.postcodeNotFound')); return; } const json: PostcodeLookupResponse = await res.json(); - onFlyTo(json.latitude, json.longitude, POSTCODE_SEARCH_ZOOM); + if (!isCurrentLookup(requestId, controller)) return; + if (!isMobile) onFlyTo(json.latitude, json.longitude, POSTCODE_SEARCH_ZOOM); onLocationSearched?.({ postcode: json.postcode, geometry: json.geometry, latitude: json.latitude, longitude: json.longitude, + zoom: POSTCODE_SEARCH_ZOOM, }); + search.saveRecentSearch(result); search.clear(); if (isMobile) setExpanded(false); - } catch { + } catch (error) { + if (!isCurrentLookup(requestId, controller) || isAbortError(error)) return; setError(t('locationSearch.lookupFailed')); } finally { - setLoading(false); + if (isCurrentLookup(requestId, controller)) { + lookupAbortRef.current = null; + setLoading(false); + } } }, - [onFlyTo, onLocationSearched, isMobile, search, t] + [beginLookup, isCurrentLookup, onFlyTo, onLocationSearched, isMobile, search, t] ); const [locating, setLocating] = useState(false); @@ -203,6 +262,7 @@ export default function LocationSearch({ return; } setError(null); + cancelLookup(); setLocating(true); search.close(); try { @@ -234,7 +294,7 @@ export default function LocationSearch({ } finally { setLocating(false); } - }, [onFlyTo, onCurrentLocationFound, isMobile, search, t]); + }, [cancelLookup, onFlyTo, onCurrentLocationFound, isMobile, search, t]); // Mobile collapsed state: search icon + locate button if (isMobile && !expanded) { @@ -281,7 +341,10 @@ export default function LocationSearch({ 'px-2 py-2 text-sm w-56 border-none outline-none bg-transparent text-warm-700 dark:text-warm-200 placeholder-warm-400 dark:placeholder-warm-500' } inputRef={inputRef} - onInputChange={() => setError(null)} + onInputChange={() => { + setError(null); + cancelLookup(); + }} />
) : ( -
-
- -
-
{popupInfo.name}
-
- - {ts(popupInfo.category)} -
-
-
- {popupInfo.school && renderSchoolMetadata(popupInfo.school)} -
+ )}
)} @@ -962,7 +1024,7 @@ export default memo(function Map({ left: listingPopup.x, top: listingPopup.y - 12, transform: 'translate(-50%, -100%)', - zIndex: 9999, + zIndex: 30, }} onMouseLeave={clearListingPopup} > diff --git a/frontend/src/components/map/MapPage.tsx b/frontend/src/components/map/MapPage.tsx index be63e19..5e66f2a 100644 --- a/frontend/src/components/map/MapPage.tsx +++ b/frontend/src/components/map/MapPage.tsx @@ -1,7 +1,7 @@ import { Suspense, useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { Trans, useTranslation } from 'react-i18next'; -import type { MapFlyToOptions, PostcodeGeometry } from '../../types'; +import type { ActualListing, MapFlyToOptions, PostcodeGeometry } from '../../types'; import type { SearchedLocation } from './LocationSearch'; import { useMapData } from '../../hooks/useMapData'; import { usePOIData } from '../../hooks/usePOIData'; @@ -25,11 +25,7 @@ import { import { apiUrl, authHeaders, buildFilterString } from '../../lib/api'; import { useFilterCounts } from '../../hooks/useFilterCounts'; import { trackEvent } from '../../lib/analytics'; -import { - INITIAL_VIEW_STATE, - POSTCODE_SEARCH_ZOOM, - POSTCODE_ZOOM_THRESHOLD, -} from '../../lib/consts'; +import { INITIAL_VIEW_STATE, POSTCODE_ZOOM_THRESHOLD } from '../../lib/consts'; import type { OverlayId } from '../../lib/overlays'; import { useLicense } from '../../hooks/useLicense'; import { stateToParams } from '../../lib/url-state'; @@ -67,6 +63,9 @@ import type { MapFlyTo, MapPageProps } from './map-page/types'; export type { ExportState } from './map-page/types'; type PendingFlyTo = { lat: number; lng: number; zoom: number }; +const EMPTY_ACTUAL_LISTINGS: ActualListing[] = []; + +declare const __DEV__: boolean; export default function MapPage({ features, @@ -115,6 +114,7 @@ export default function MapPage({ const [poiPaneOpen, setPoiPaneOpen] = useState(false); const [overlayPaneOpen, setOverlayPaneOpen] = useState(false); const [currentLocation, setCurrentLocation] = useState<{ lat: number; lng: number } | null>(null); + const [devActualListingsEnabled, setDevActualListingsEnabled] = useState(true); const { filters, @@ -378,7 +378,7 @@ export default function MapPage({ pendingLocationSearchFlyToRef.current = { lat: markerLat ?? result.latitude, lng: markerLng ?? result.longitude, - zoom: result.openProperties ? 17 : POSTCODE_SEARCH_ZOOM, + zoom: result.zoom, }; setMobileDrawerOpen(true); consumePendingLocationSearchFlyTo(); @@ -450,11 +450,20 @@ export default function MapPage({ [filters, features] ); const actualListingsTravelParam = useMemo(() => buildTravelParam(entries), [entries]); - const { listings: actualListings } = useActualListings(mapData.bounds, { - filterParam: actualListingsFilterParam, - travelParam: actualListingsTravelParam, - shareCode, - }); + const actualListingsEnabled = !__DEV__ || devActualListingsEnabled; + const { listings: actualListings } = useActualListings( + actualListingsEnabled ? mapData.bounds : null, + { + filterParam: actualListingsFilterParam, + travelParam: actualListingsTravelParam, + shareCode, + } + ); + const visibleActualListings = actualListingsEnabled ? actualListings : EMPTY_ACTUAL_LISTINGS; + const handleToggleActualListings = useCallback(() => { + if (!__DEV__) return; + setDevActualListingsEnabled((enabled) => !enabled); + }, []); const [isAreaGroupExpanded, toggleAreaGroup] = useCollapsibleGroups(true); useUrlSync( @@ -798,7 +807,9 @@ export default function MapPage({ onLocationSearched={handleLocationSearchResult} onCurrentLocationFound={handleCurrentLocationFound} currentLocation={currentLocation} - actualListings={actualListings} + actualListings={visibleActualListings} + actualListingsEnabled={actualListingsEnabled} + onToggleActualListings={__DEV__ ? handleToggleActualListings : undefined} travelTimeEntries={entries} bottomScreenInset={mobileBottomSheetHeight} onBottomSheetCoveredHeightChange={setMobileBottomSheetHeight} @@ -866,7 +877,9 @@ export default function MapPage({ onLocationSearched={handleLocationSearchResult} onCurrentLocationFound={handleCurrentLocationFound} currentLocation={currentLocation} - actualListings={actualListings} + actualListings={visibleActualListings} + actualListingsEnabled={actualListingsEnabled} + onToggleActualListings={__DEV__ ? handleToggleActualListings : undefined} travelTimeEntries={entries} densityLabel={densityLabel} totalCount={hasActiveFilters ? filterCounts.total : undefined} diff --git a/frontend/src/components/map/MapPageSelectionPane.tsx b/frontend/src/components/map/MapPageSelectionPane.tsx index 009a78e..9f6c820 100644 --- a/frontend/src/components/map/MapPageSelectionPane.tsx +++ b/frontend/src/components/map/MapPageSelectionPane.tsx @@ -36,7 +36,7 @@ export function MapPageSelectionPane({ return (
+
{/* Backdrop — top 10% */}
diff --git a/frontend/src/components/map/PropertiesPane.tsx b/frontend/src/components/map/PropertiesPane.tsx index be7a179..ff6e691 100644 --- a/frontend/src/components/map/PropertiesPane.tsx +++ b/frontend/src/components/map/PropertiesPane.tsx @@ -191,7 +191,7 @@ function PropertyCard({ property }: { property: Property }) { )} {property.listed_building === 'Yes' && ( - {ts('Listed building')} + {t('propertyCard.listedBuildingBadge')} )}
diff --git a/frontend/src/components/map/map-page/DesktopMapPage.tsx b/frontend/src/components/map/map-page/DesktopMapPage.tsx index e13088a..f24e611 100644 --- a/frontend/src/components/map/map-page/DesktopMapPage.tsx +++ b/frontend/src/components/map/map-page/DesktopMapPage.tsx @@ -17,6 +17,7 @@ import type { OverlayId } from '../../../lib/overlays'; import type { SearchedLocation } from '../LocationSearch'; import { MapPinIcon } from '../../ui/icons/MapPinIcon'; import { EyeIcon } from '../../ui/icons/EyeIcon'; +import { HouseIcon } from '../../ui/icons/HouseIcon'; import { IndeterminateProgressBar } from '../../ui/IndeterminateProgressBar'; import type { MapFlyTo, PaneResizeHandlers } from './types'; import { MapFallback, PaneFallback } from './Fallbacks'; @@ -56,6 +57,8 @@ interface DesktopMapPageProps { onCurrentLocationFound: (lat: number, lng: number) => void; currentLocation: { lat: number; lng: number } | null; actualListings: ActualListing[]; + actualListingsEnabled: boolean; + onToggleActualListings?: () => void; travelTimeEntries: TravelTimeEntry[]; densityLabel: string; totalCount?: number; @@ -106,6 +109,8 @@ export function DesktopMapPage({ onCurrentLocationFound, currentLocation, actualListings, + actualListingsEnabled, + onToggleActualListings, travelTimeEntries, densityLabel, totalCount, @@ -154,7 +159,7 @@ export function DesktopMapPage({
{filtersPane}
@@ -208,7 +213,20 @@ export function DesktopMapPage({ totalCount={totalCount} /> -
+
+ {onToggleActualListings && ( + + )}
-
+
+ {onToggleActualListings && ( + + )} +
+ +
+ + + + + {mode === 'list' && ( +
+ + {t('export.listLabel')} + +
+ {postcodes.map((value, idx) => ( +
+ { + inputRefs.current[idx] = el; + }} + type="text" + value={value} + onChange={(e) => updateAt(idx, e.target.value)} + onKeyDown={(e) => handleInputKeyDown(e, idx)} + placeholder={t('export.listPlaceholder')} + autoComplete="off" + spellCheck={false} + className="flex-1 rounded border border-warm-200 dark:border-warm-700 bg-white dark:bg-warm-900 px-3 py-1.5 text-sm text-navy-950 dark:text-warm-100 font-mono uppercase placeholder:normal-case placeholder:font-sans placeholder:text-warm-400 focus:outline-none focus:border-teal-500" + /> + +
+ ))} +
+ +
+ {t('export.listCount', { count: cleaned.length })} +
+
+ )} +
+ +
+ +
+
+ + ); +} diff --git a/frontend/src/components/ui/Header.tsx b/frontend/src/components/ui/Header.tsx index 248461d..c5b4015 100644 --- a/frontend/src/components/ui/Header.tsx +++ b/frontend/src/components/ui/Header.tsx @@ -15,6 +15,7 @@ import { SpinnerIcon } from './icons/SpinnerIcon'; import UserMenu from './UserMenu'; import MobileMenu from './MobileMenu'; import LanguageDropdown from './LanguageDropdown'; +import ExportMenu from './ExportMenu'; export type Page = | 'home' @@ -37,7 +38,7 @@ export type Page = | 'invite'; export interface HeaderExportState { - onExport: () => void; + onExport: (options?: { postcodes?: string[] }) => void; exporting: boolean; } @@ -110,6 +111,7 @@ export default function Header({ const [copied, setCopied] = useState(false); const [sharing, setSharing] = useState(false); const [menuOpen, setMenuOpen] = useState(false); + const [exportMenuOpen, setExportMenuOpen] = useState(false); const [isDashboardTabletSidebarWidth, setIsDashboardTabletSidebarWidth] = useState( () => window.matchMedia(DASHBOARD_TABLET_SIDEBAR_QUERY).matches ); @@ -292,7 +294,7 @@ export default function Header({ {exportState && (