Fmt
This commit is contained in:
parent
63713c3a2b
commit
bd6b511f16
17 changed files with 544 additions and 377 deletions
|
|
@ -126,11 +126,7 @@ const DS_KEYS: Record<string, [string, string, string]> = {
|
|||
'learnPage.dsGreenspaceOrigin',
|
||||
'learnPage.dsGreenspaceUse',
|
||||
],
|
||||
'forest-research-tow': [
|
||||
'learnPage.dsTowName',
|
||||
'learnPage.dsTowOrigin',
|
||||
'learnPage.dsTowUse',
|
||||
],
|
||||
'forest-research-tow': ['learnPage.dsTowName', 'learnPage.dsTowOrigin', 'learnPage.dsTowUse'],
|
||||
naptan: ['learnPage.dsNaptanName', 'learnPage.dsNaptanOrigin', 'learnPage.dsNaptanUse'],
|
||||
noise: ['learnPage.dsNoiseName', 'learnPage.dsNoiseOrigin', 'learnPage.dsNoiseUse'],
|
||||
ofsted: ['learnPage.dsOfstedName', 'learnPage.dsOfstedOrigin', 'learnPage.dsOfstedUse'],
|
||||
|
|
|
|||
|
|
@ -17,7 +17,7 @@ import { travelFieldKey, useTravelTime } from '../../hooks/useTravelTime';
|
|||
import { apiUrl, authHeaders } from '../../lib/api';
|
||||
import { useFilterCounts } from '../../hooks/useFilterCounts';
|
||||
import { trackEvent } from '../../lib/analytics';
|
||||
import { INITIAL_VIEW_STATE } from '../../lib/consts';
|
||||
import { INITIAL_VIEW_STATE, POSTCODE_SEARCH_ZOOM } from '../../lib/consts';
|
||||
import { useLicense } from '../../hooks/useLicense';
|
||||
import {
|
||||
AreaPane,
|
||||
|
|
@ -51,6 +51,8 @@ import type { MapFlyTo, MapPageProps } from './map-page/types';
|
|||
|
||||
export type { ExportState } from './map-page/types';
|
||||
|
||||
type PendingFlyTo = { lat: number; lng: number; zoom: number };
|
||||
|
||||
export default function MapPage({
|
||||
features,
|
||||
poiCategoryGroups,
|
||||
|
|
@ -150,6 +152,7 @@ export default function MapPage({
|
|||
|
||||
const mapFlyToRef = useRef<MapFlyTo | null>(null);
|
||||
const pendingCurrentLocationFlyToRef = useRef<{ lat: number; lng: number } | null>(null);
|
||||
const pendingLocationSearchFlyToRef = useRef<PendingFlyTo | null>(null);
|
||||
const mobileDrawerPanelRectRef = useRef<DOMRectReadOnly | null>(null);
|
||||
|
||||
const mapData = useMapData({
|
||||
|
|
@ -296,11 +299,27 @@ export default function MapPage({
|
|||
journeyDest,
|
||||
});
|
||||
|
||||
const consumePendingLocationSearchFlyTo = useCallback((rect?: DOMRectReadOnly | null) => {
|
||||
const pending = pendingLocationSearchFlyToRef.current;
|
||||
const panelRect = rect ?? mobileDrawerPanelRectRef.current;
|
||||
if (!pending || !panelRect) return;
|
||||
|
||||
const bottomInset = Math.max(0, window.innerHeight - panelRect.top);
|
||||
const flyTo = mapFlyToRef.current;
|
||||
if (!flyTo) return;
|
||||
flyTo(pending.lat, pending.lng, pending.zoom, {
|
||||
visibleViewportArea: { bottom: bottomInset },
|
||||
});
|
||||
pendingLocationSearchFlyToRef.current = null;
|
||||
}, []);
|
||||
|
||||
const handleLocationSearchResult = useCallback(
|
||||
(result: SearchedLocation | null) => {
|
||||
if (result) {
|
||||
if (result.markerLatitude != null && result.markerLongitude != null) {
|
||||
setCurrentLocation({ lat: result.markerLatitude, lng: result.markerLongitude });
|
||||
const markerLat = result.markerLatitude;
|
||||
const markerLng = result.markerLongitude;
|
||||
if (markerLat != null && markerLng != null) {
|
||||
setCurrentLocation({ lat: markerLat, lng: markerLng });
|
||||
} else {
|
||||
setCurrentLocation(null);
|
||||
}
|
||||
|
|
@ -312,13 +331,22 @@ export default function MapPage({
|
|||
result.openProperties,
|
||||
result.focusAddress
|
||||
);
|
||||
if (isMobile) setMobileDrawerOpen(true);
|
||||
if (isMobile) {
|
||||
pendingLocationSearchFlyToRef.current = {
|
||||
lat: markerLat ?? result.latitude,
|
||||
lng: markerLng ?? result.longitude,
|
||||
zoom: result.openProperties ? 17 : POSTCODE_SEARCH_ZOOM,
|
||||
};
|
||||
setMobileDrawerOpen(true);
|
||||
consumePendingLocationSearchFlyTo();
|
||||
}
|
||||
} else {
|
||||
setCurrentLocation(null);
|
||||
pendingLocationSearchFlyToRef.current = null;
|
||||
handleCloseSelection();
|
||||
}
|
||||
},
|
||||
[handleCloseSelection, handleLocationSearch, isMobile]
|
||||
[consumePendingLocationSearchFlyTo, handleCloseSelection, handleLocationSearch, isMobile]
|
||||
);
|
||||
|
||||
const consumePendingCurrentLocationFlyTo = useCallback((rect?: DOMRectReadOnly | null) => {
|
||||
|
|
@ -327,7 +355,9 @@ export default function MapPage({
|
|||
if (!pending || !panelRect) return;
|
||||
|
||||
const bottomInset = Math.max(0, window.innerHeight - panelRect.top);
|
||||
mapFlyToRef.current?.(pending.lat, pending.lng, 17, {
|
||||
const flyTo = mapFlyToRef.current;
|
||||
if (!flyTo) return;
|
||||
flyTo(pending.lat, pending.lng, 17, {
|
||||
visibleViewportArea: { bottom: bottomInset },
|
||||
});
|
||||
pendingCurrentLocationFlyToRef.current = null;
|
||||
|
|
@ -352,12 +382,14 @@ export default function MapPage({
|
|||
(rect: DOMRectReadOnly) => {
|
||||
mobileDrawerPanelRectRef.current = rect;
|
||||
consumePendingCurrentLocationFlyTo(rect);
|
||||
consumePendingLocationSearchFlyTo(rect);
|
||||
},
|
||||
[consumePendingCurrentLocationFlyTo]
|
||||
[consumePendingCurrentLocationFlyTo, consumePendingLocationSearchFlyTo]
|
||||
);
|
||||
|
||||
const handleMobileDrawerClose = useCallback(() => {
|
||||
pendingCurrentLocationFlyToRef.current = null;
|
||||
pendingLocationSearchFlyToRef.current = null;
|
||||
mobileDrawerPanelRectRef.current = null;
|
||||
setMobileDrawerOpen(false);
|
||||
}, []);
|
||||
|
|
@ -387,7 +419,11 @@ export default function MapPage({
|
|||
isMobile,
|
||||
flyTo: mapFlyToRef,
|
||||
onLocationSearch: handleLocationSearch,
|
||||
onOpenMobileDrawer: () => setMobileDrawerOpen(true),
|
||||
onOpenMobileDrawer: (target) => {
|
||||
pendingLocationSearchFlyToRef.current = target;
|
||||
setMobileDrawerOpen(true);
|
||||
consumePendingLocationSearchFlyTo();
|
||||
},
|
||||
});
|
||||
useHorizontalSwipeNavigationGuard();
|
||||
useMobileBackNavigationGuard(isMobile);
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import type { MutableRefObject } from 'react';
|
|||
import type { PostcodeGeometry, ViewState } from '../../../types';
|
||||
import type { useMapData } from '../../../hooks/useMapData';
|
||||
import { authHeaders } from '../../../lib/api';
|
||||
import { POSTCODE_SEARCH_ZOOM } from '../../../lib/consts';
|
||||
import { canWheelScrollInsideTarget } from '../../../lib/dom-scroll';
|
||||
import type { MapFlyTo } from './types';
|
||||
|
||||
|
|
@ -32,7 +33,7 @@ interface UseInitialPostcodeSelectionOptions {
|
|||
lat?: number,
|
||||
lng?: number
|
||||
) => void;
|
||||
onOpenMobileDrawer: () => void;
|
||||
onOpenMobileDrawer: (target: { lat: number; lng: number; zoom: number }) => void;
|
||||
}
|
||||
|
||||
export function useInitialPostcodeSelection({
|
||||
|
|
@ -62,9 +63,15 @@ export function useInitialPostcodeSelection({
|
|||
longitude: number;
|
||||
geometry: PostcodeGeometry;
|
||||
}) => {
|
||||
flyTo.current?.(data.latitude, data.longitude, 16);
|
||||
flyTo.current?.(data.latitude, data.longitude, POSTCODE_SEARCH_ZOOM);
|
||||
onLocationSearch(data.postcode, data.geometry, data.latitude, data.longitude);
|
||||
if (isMobile) onOpenMobileDrawer();
|
||||
if (isMobile) {
|
||||
onOpenMobileDrawer({
|
||||
lat: data.latitude,
|
||||
lng: data.longitude,
|
||||
zoom: POSTCODE_SEARCH_ZOOM,
|
||||
});
|
||||
}
|
||||
}
|
||||
)
|
||||
.catch(() => {
|
||||
|
|
|
|||
|
|
@ -54,7 +54,10 @@ describe('useHexagonSelection', () => {
|
|||
requests.push(`${url.pathname}${url.search}`);
|
||||
|
||||
if (url.pathname === '/api/postcode-stats') {
|
||||
return Promise.resolve(jsonResponse(stats(url.searchParams.has('filters') ? 0 : 4)));
|
||||
const emptyPostcode = url.searchParams.get('postcode') === 'EMPTY 1AA';
|
||||
return Promise.resolve(
|
||||
jsonResponse(stats(url.searchParams.has('filters') || emptyPostcode ? 0 : 4))
|
||||
);
|
||||
}
|
||||
|
||||
if (url.pathname === '/api/hexagon-stats') {
|
||||
|
|
@ -91,12 +94,15 @@ describe('useHexagonSelection', () => {
|
|||
id: 'SW1A 1AA',
|
||||
type: 'postcode',
|
||||
resolution: 9,
|
||||
lockedResolution: true,
|
||||
});
|
||||
});
|
||||
|
||||
expect(result.current.selectedPostcodeGeometry).toBe(postcodeGeometry);
|
||||
expect(result.current.areaStats?.count).toBe(0);
|
||||
expect(result.current.unfilteredAreaCount).toBe(4);
|
||||
await waitFor(() => {
|
||||
expect(result.current.areaStats?.count).toBe(0);
|
||||
expect(result.current.unfilteredAreaCount).toBe(4);
|
||||
});
|
||||
expect(requests.some((url) => url.startsWith('/api/hexagon-stats'))).toBe(false);
|
||||
});
|
||||
|
||||
|
|
@ -124,6 +130,7 @@ describe('useHexagonSelection', () => {
|
|||
id: 'SW1A 1AA',
|
||||
type: 'postcode',
|
||||
resolution: 9,
|
||||
lockedResolution: true,
|
||||
});
|
||||
});
|
||||
|
||||
|
|
@ -131,4 +138,65 @@ describe('useHexagonSelection', () => {
|
|||
expect(result.current.unfilteredAreaCount).toBeNull();
|
||||
expect(requests.some((url) => url.startsWith('/api/hexagon-stats'))).toBe(false);
|
||||
});
|
||||
|
||||
it('keeps an empty postcode search selected instead of widening to hexagons', async () => {
|
||||
const { result } = renderHook(() =>
|
||||
useHexagonSelection({
|
||||
filters: {},
|
||||
features,
|
||||
hexagonData: [],
|
||||
resolution: 9,
|
||||
usePostcodeView: true,
|
||||
travelTimeEntries: [],
|
||||
})
|
||||
);
|
||||
|
||||
act(() => {
|
||||
result.current.handleLocationSearch('EMPTY 1AA', postcodeGeometry, 51.505, -0.115);
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.areaStats?.count).toBe(0);
|
||||
});
|
||||
|
||||
expect(result.current.selectedHexagon).toEqual({
|
||||
id: 'EMPTY 1AA',
|
||||
type: 'postcode',
|
||||
resolution: 9,
|
||||
lockedResolution: true,
|
||||
});
|
||||
expect(result.current.selectedPostcodeGeometry).toBe(postcodeGeometry);
|
||||
expect(requests.some((url) => url.startsWith('/api/hexagon-stats'))).toBe(false);
|
||||
});
|
||||
|
||||
it('does not convert a searched postcode back to a hexagon while the map reaches postcode zoom', async () => {
|
||||
const { result } = renderHook(() =>
|
||||
useHexagonSelection({
|
||||
filters: {},
|
||||
features,
|
||||
hexagonData: [],
|
||||
resolution: 9,
|
||||
usePostcodeView: false,
|
||||
travelTimeEntries: [],
|
||||
})
|
||||
);
|
||||
|
||||
act(() => {
|
||||
result.current.handleLocationSearch('SW1A 1AA', postcodeGeometry, 51.505, -0.115);
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.areaStats?.count).toBe(4);
|
||||
});
|
||||
|
||||
expect(result.current.selectedHexagon).toEqual({
|
||||
id: 'SW1A 1AA',
|
||||
type: 'postcode',
|
||||
resolution: 9,
|
||||
lockedResolution: true,
|
||||
});
|
||||
expect(result.current.selectedPostcodeGeometry).toBe(postcodeGeometry);
|
||||
expect(requests.some((url) => url.startsWith('/api/postcode/'))).toBe(false);
|
||||
expect(requests.some((url) => url.startsWith('/api/hexagon-stats'))).toBe(false);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -426,7 +426,7 @@ export function useHexagonSelection({
|
|||
selection.type === 'hexagon' &&
|
||||
!selection.lockedResolution &&
|
||||
areaStats?.central_postcode != null) ||
|
||||
(!usePostcodeView && selection.type === 'postcode') ||
|
||||
(!usePostcodeView && selection.type === 'postcode' && !selection.lockedResolution) ||
|
||||
(!usePostcodeView &&
|
||||
selection.type === 'hexagon' &&
|
||||
!selection.lockedResolution &&
|
||||
|
|
@ -628,103 +628,38 @@ export function useHexagonSelection({
|
|||
(
|
||||
postcode: string,
|
||||
geometry: PostcodeGeometry,
|
||||
lat?: number,
|
||||
lng?: number,
|
||||
_lat?: number,
|
||||
_lng?: number,
|
||||
openProperties = false,
|
||||
focusAddress?: string
|
||||
) => {
|
||||
const requestId = invalidateAreaRequests();
|
||||
invalidatePropertyRequests();
|
||||
const selection = {
|
||||
id: postcode,
|
||||
type: 'postcode' as const,
|
||||
resolution,
|
||||
lockedResolution: true,
|
||||
};
|
||||
trackEvent(openProperties ? 'Address Search' : 'Postcode Search');
|
||||
setSelectedHexagon(selection);
|
||||
setSelectedPostcodeGeometry(geometry);
|
||||
setProperties([]);
|
||||
setPropertiesTotal(0);
|
||||
setPropertiesOffset(0);
|
||||
setAreaStats(null);
|
||||
setUnfilteredAreaCount(null);
|
||||
setRightPaneTab(openProperties ? 'properties' : 'area');
|
||||
setLoadingAreaStats(true);
|
||||
|
||||
// First try the postcode; if it only has no matches because of active filters,
|
||||
// keep the searched postcode selected instead of widening to nearby hexagons.
|
||||
fetchPostcodeStats(postcode, undefined, areaStatsUseFilters)
|
||||
.then(async (stats) => {
|
||||
.then((stats) => {
|
||||
if (!isCurrentAreaRequest(requestId)) return;
|
||||
if (stats.count > 0) {
|
||||
const selection = { id: postcode, type: 'postcode' as const, resolution };
|
||||
setSelectedHexagon(selection);
|
||||
setSelectedPostcodeGeometry(geometry);
|
||||
setAreaStats(stats);
|
||||
refreshUnfilteredAreaCount(selection, stats.count, areaStatsUseFilters);
|
||||
if (openProperties) {
|
||||
fetchPostcodeProperties(postcode, 0, focusAddress);
|
||||
}
|
||||
return;
|
||||
setAreaStats(stats);
|
||||
refreshUnfilteredAreaCount(selection, stats.count, areaStatsUseFilters);
|
||||
if (openProperties && stats.count > 0) {
|
||||
fetchPostcodeProperties(postcode, 0, focusAddress);
|
||||
}
|
||||
|
||||
if (areaStatsUseFilters && hasStatsFilters) {
|
||||
const unfilteredStats = await fetchPostcodeStats(postcode, undefined, false);
|
||||
if (!isCurrentAreaRequest(requestId)) return;
|
||||
if (unfilteredStats.count > 0) {
|
||||
const selection = { id: postcode, type: 'postcode' as const, resolution };
|
||||
setSelectedHexagon(selection);
|
||||
setSelectedPostcodeGeometry(geometry);
|
||||
setAreaStats(stats);
|
||||
setUnfilteredAreaCount(unfilteredStats.count);
|
||||
setRightPaneTab(openProperties ? 'properties' : 'area');
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// No properties in this postcode — fall back to hexagons
|
||||
if (lat == null || lng == null) {
|
||||
// No coordinates available, show empty postcode anyway
|
||||
const selection = { id: postcode, type: 'postcode' as const, resolution };
|
||||
setSelectedHexagon(selection);
|
||||
setSelectedPostcodeGeometry(geometry);
|
||||
setAreaStats(stats);
|
||||
refreshUnfilteredAreaCount(selection, stats.count, areaStatsUseFilters);
|
||||
setRightPaneTab('area');
|
||||
return;
|
||||
}
|
||||
|
||||
// Try progressively coarser H3 resolutions until we find >1 property
|
||||
const resolutions = [9, 8, 7, 6, 5];
|
||||
for (const res of resolutions) {
|
||||
const h3 = latLngToCell(lat, lng, res);
|
||||
const hexStats = await fetchHexagonStats(
|
||||
h3,
|
||||
res,
|
||||
undefined,
|
||||
undefined,
|
||||
areaStatsUseFilters
|
||||
);
|
||||
if (!isCurrentAreaRequest(requestId)) return;
|
||||
if (hexStats.count > 1) {
|
||||
const selection = { id: h3, type: 'hexagon' as const, resolution: res };
|
||||
setSelectedHexagon(selection);
|
||||
setSelectedPostcodeGeometry(null);
|
||||
setAreaStats(hexStats);
|
||||
refreshUnfilteredAreaCount(selection, hexStats.count, areaStatsUseFilters);
|
||||
setRightPaneTab('area');
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Even the coarsest hexagon has ≤1 property — show whatever the finest has
|
||||
const h3 = latLngToCell(lat, lng, 9);
|
||||
const fallbackStats = await fetchHexagonStats(
|
||||
h3,
|
||||
9,
|
||||
undefined,
|
||||
undefined,
|
||||
areaStatsUseFilters
|
||||
);
|
||||
if (!isCurrentAreaRequest(requestId)) return;
|
||||
const selection = { id: h3, type: 'hexagon' as const, resolution: 9 };
|
||||
setSelectedHexagon(selection);
|
||||
setSelectedPostcodeGeometry(null);
|
||||
setAreaStats(fallbackStats);
|
||||
refreshUnfilteredAreaCount(selection, fallbackStats.count, areaStatsUseFilters);
|
||||
setRightPaneTab('area');
|
||||
})
|
||||
.catch((error) => logNonAbortError('Failed to fetch postcode stats', error))
|
||||
.finally(() => {
|
||||
|
|
@ -734,9 +669,7 @@ export function useHexagonSelection({
|
|||
[
|
||||
resolution,
|
||||
areaStatsUseFilters,
|
||||
hasStatsFilters,
|
||||
fetchPostcodeStats,
|
||||
fetchHexagonStats,
|
||||
fetchPostcodeProperties,
|
||||
invalidateAreaRequests,
|
||||
invalidatePropertyRequests,
|
||||
|
|
|
|||
|
|
@ -413,15 +413,14 @@ const en = {
|
|||
heroEyebrow: 'Find where to look first',
|
||||
heroTitle1: 'Stop searching',
|
||||
heroTitle2: 'the wrong places',
|
||||
heroTitle3: 'Before listings take over.',
|
||||
heroSubtitle:
|
||||
'Find postcodes where your budget, commute, and daily life line up.',
|
||||
heroTitle3: 'Before listings narrow your search.',
|
||||
heroSubtitle: 'Find postcodes where your budget, commute, and daily life line up.',
|
||||
heroDescription:
|
||||
'Perfect Postcode shows where to look before you start chasing viewings.',
|
||||
'Perfect Postcode filters every postcode first, so you only chase viewings in places that work.',
|
||||
exploreTheMap: 'Show me where to look',
|
||||
seeTheDifference: 'Watch demo',
|
||||
productDemoLabel: 'Watch the postcode shortlist demo',
|
||||
playProductDemo: 'Play the postcode shortlist demo',
|
||||
productDemoLabel: 'See how to find where to look first',
|
||||
playProductDemo: 'Play the where-to-look demo',
|
||||
scrollToProductDemo: 'Scroll to product demo',
|
||||
showcaseHeader: 'How it works',
|
||||
showcaseContext: 'How Perfect Postcode works',
|
||||
|
|
@ -446,26 +445,26 @@ const en = {
|
|||
showcaseScoutBullet2: 'Test the commute from a real front door, not a borough name.',
|
||||
showcaseScoutBullet3: 'Compare viewings with evidence already saved.',
|
||||
showcaseStep1Tab: 'Filter',
|
||||
showcaseStep1Title: 'Turn your needs into clear search filters',
|
||||
showcaseStep1Title: 'Set what has to work',
|
||||
showcaseStep1Body:
|
||||
'Set what matters and see how many unsuitable postcodes each requirement removes.',
|
||||
'Add budget, commute, schools, safety, noise, and local details. Watch the wrong postcodes drop out.',
|
||||
showcaseStep1Chip1: 'Quiet streets',
|
||||
showcaseStep1Chip2: 'Good primaries nearby',
|
||||
showcaseStep1Chip3: 'Under £500k',
|
||||
showcaseStep1VennCenter: 'Postcodes that meet all three',
|
||||
showcaseStep2Tab: 'Match',
|
||||
showcaseStep2Title: 'Find places you would never have known to search',
|
||||
showcaseStep2Title: 'See the places left standing',
|
||||
showcaseStep2Body:
|
||||
'Search by what you need, not by area name. The map shows suitable postcode clusters before listing sites narrow the search.',
|
||||
'Search by practical checks, not familiar names. The map shows postcode clusters worth checking first.',
|
||||
showcaseStep2Region: 'Greater London',
|
||||
showcaseStep2Sources: 'Land Registry · ONS · Ofsted · DfT',
|
||||
showcaseStep2ClustersLabel: 'Matching clusters',
|
||||
showcaseStep3Tab: 'Inspect',
|
||||
showcaseStep3Title: 'See why a postcode matches',
|
||||
showcaseStep3Title: 'Check the evidence',
|
||||
showcaseStep3Body:
|
||||
'Open any matching area and check prices, safety, schools, broadband, and trade-offs in one pane before you spend a weekend there.',
|
||||
'Open a postcode and see the price, commute, schools, crime, broadband, and trade-offs before you visit.',
|
||||
showcaseStep3HeaderArea: 'Shortlisted postcode',
|
||||
showcaseStep3HeaderFit: 'Why it matches',
|
||||
showcaseStep3HeaderFit: 'What works',
|
||||
showcaseStep3Stat1Label: 'Sold price trend',
|
||||
showcaseStep3Stat2Label: 'Crime rate',
|
||||
showcaseStep3Stat2Value: 'Below borough avg.',
|
||||
|
|
@ -475,9 +474,9 @@ const en = {
|
|||
showcaseStep3Stat5Label: 'Primary schools',
|
||||
showcaseStep3Stat5Value: '3 Outstanding within 1 mile',
|
||||
showcaseStep4Tab: 'Scout',
|
||||
showcaseStep4Title: 'Take the strongest areas into the real world',
|
||||
showcaseStep4Title: 'Take the shortlist to the streets',
|
||||
showcaseStep4Body:
|
||||
'Export suggested postcodes to visit. Walk the streets, test the commute, and compare viewings with the data you saved.',
|
||||
'Export the postcodes worth checking, test the commute, walk the roads, and compare viewings with context saved.',
|
||||
showcaseStep4FileName: 'areas-to-scout.xlsx',
|
||||
showcaseStep4ExportLabel: 'Export to Excel',
|
||||
showcaseStep4ColPostcode: 'Postcode',
|
||||
|
|
@ -489,20 +488,19 @@ const en = {
|
|||
statFilters: 'ways to narrow the map',
|
||||
statEvery: 'Every',
|
||||
statPostcodeInEngland: 'active postcode in England',
|
||||
ourPhilosophy: 'Start with needs. End with postcodes.',
|
||||
ourPhilosophy: 'Stop starting with towns you already know.',
|
||||
philosophyP1:
|
||||
'Listing sites force you to pick a town, borough, or postcode before you know which places can work. That means the search is limited by memory, recommendations, and whatever happens to be for sale this week.',
|
||||
'Most searches start with a place name, then hope the right homes appear. That skips the harder question: which places are actually worth searching?',
|
||||
philosophyP2:
|
||||
'Perfect Postcode starts with your requirements instead. Tell the map your budget, commute, school, safety, noise, broadband, and local-context needs, then inspect the postcodes that match before you open listings.',
|
||||
'Perfect Postcode starts before the listing site. Set the things a place must support, then see the postcodes that deserve your attention first.',
|
||||
streetTitle: 'Places change street by street',
|
||||
streetIntro:
|
||||
'Area names hide the details that matter: the station side, the road noise, the school mix, the exact commute, and what similar homes actually sold for.',
|
||||
streetCard1Title: 'Find places you may have missed',
|
||||
streetCard1Body:
|
||||
'Search postcode-level data by your requirements instead of relying on familiar names, friend recommendations, or “up-and-coming” hype.',
|
||||
streetCard2Title: 'Check the trade-offs before viewings',
|
||||
'The right side of a station, a noisy road, or one school catchment can change the search. Area names flatten all of that.',
|
||||
streetCard1Title: 'Escape the familiar-name trap',
|
||||
streetCard1Body: 'Find postcode-level matches outside the places already on your list.',
|
||||
streetCard2Title: 'Know the trade-offs before you go',
|
||||
streetCard2Body:
|
||||
'Compare price, space, commute, safety, schools, broadband, noise, energy ratings, parks, and local amenities before you spend weekends travelling between viewings.',
|
||||
'Check price, commute, noise, schools, safety, broadband, and nearby amenities before booking viewings.',
|
||||
othersVs: 'Other tools vs',
|
||||
checkMyPostcode: 'Listing sites',
|
||||
areaGuides: 'Postcode checkers',
|
||||
|
|
@ -512,11 +510,11 @@ const en = {
|
|||
compAreaDataSub: '(crime, schools, noise, broadband, amenities)',
|
||||
compPropertyData: 'Street-level property context',
|
||||
compPropertyDataSub: '(sold prices, EPC, floor area, estimated value)',
|
||||
compFilters: 'All your requirements working together',
|
||||
compFilters: 'Budget, commute, schools, safety, and local data together',
|
||||
compFiltersSub: '(budget + commute + schools + safety + local context)',
|
||||
ctaTitle: 'Do the area research before you book the viewing.',
|
||||
ctaTitle: 'Find where to look before you book viewings.',
|
||||
ctaDescription:
|
||||
'Build a postcode shortlist from price, commute, schools, safety, noise, broadband, amenities, and sold-price evidence, then verify the streets in person.',
|
||||
'Build a postcode shortlist from the things that matter, then check the streets in person.',
|
||||
},
|
||||
|
||||
// ── Pricing Page ───────────────────────────────────
|
||||
|
|
|
|||
|
|
@ -27,7 +27,10 @@ describe('poi-distance-filter', () => {
|
|||
|
||||
expect(
|
||||
getPoiFilterFeatureOptions(features, POI_DISTANCE_FILTER_NAME).map((f) => f.name)
|
||||
).toEqual(['Distance to nearest amenity (Cafe) (km)', 'Distance to nearest amenity (Park) (km)']);
|
||||
).toEqual([
|
||||
'Distance to nearest amenity (Cafe) (km)',
|
||||
'Distance to nearest amenity (Park) (km)',
|
||||
]);
|
||||
expect(
|
||||
getPoiFilterFeatureOptions(features, TRANSPORT_DISTANCE_FILTER_NAME).map((f) => f.name)
|
||||
).toEqual([
|
||||
|
|
@ -54,11 +57,4 @@ describe('poi-distance-filter', () => {
|
|||
);
|
||||
expect(getPoiFilterName('Number of amenities (Bus stop) within 2km')).toBeNull();
|
||||
});
|
||||
|
||||
it('recognizes the old static park distance name for URL migration only', () => {
|
||||
expect(getPoiFilterName('Distance to nearest park (km)')).toBe(POI_DISTANCE_FILTER_NAME);
|
||||
expect(
|
||||
getPoiFilterFeatureOptions([numeric('Distance to nearest park (km)')], POI_DISTANCE_FILTER_NAME)
|
||||
).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -225,14 +225,14 @@ describe('url-state', () => {
|
|||
});
|
||||
|
||||
it('round-trips repeated amenity distance filters with dedicated URL params', () => {
|
||||
const park = createPoiDistanceFilterKey('Distance to nearest park (km)', 3);
|
||||
const grocery = createPoiDistanceFilterKey('Distance to nearest grocery store (km)', 4);
|
||||
const park = createPoiDistanceFilterKey('Distance to nearest amenity (Park) (km)', 3);
|
||||
const cafe = createPoiDistanceFilterKey('Distance to nearest amenity (Café) (km)', 4);
|
||||
|
||||
const params = stateToParams(
|
||||
null,
|
||||
{
|
||||
[park]: [0, 0.4],
|
||||
[grocery]: [0, 1.5],
|
||||
[cafe]: [0, 1.5],
|
||||
},
|
||||
[],
|
||||
new Set(),
|
||||
|
|
@ -240,8 +240,8 @@ describe('url-state', () => {
|
|||
);
|
||||
|
||||
expect(params.getAll('amenityDistance')).toEqual([
|
||||
'Distance%20to%20nearest%20park%20(km):0:0.4',
|
||||
'Distance%20to%20nearest%20grocery%20store%20(km):0:1.5',
|
||||
'Distance%20to%20nearest%20amenity%20(Park)%20(km):0:0.4',
|
||||
'Distance%20to%20nearest%20amenity%20(Caf%C3%A9)%20(km):0:1.5',
|
||||
]);
|
||||
expect(params.getAll('filter')).toEqual([]);
|
||||
|
||||
|
|
@ -249,8 +249,8 @@ describe('url-state', () => {
|
|||
const state = parseUrlState();
|
||||
|
||||
expect(state.filters).toEqual({
|
||||
[createPoiDistanceFilterKey('Distance to nearest park (km)', 0)]: [0, 0.4],
|
||||
[createPoiDistanceFilterKey('Distance to nearest grocery store (km)', 1)]: [0, 1.5],
|
||||
[createPoiDistanceFilterKey('Distance to nearest amenity (Park) (km)', 0)]: [0, 0.4],
|
||||
[createPoiDistanceFilterKey('Distance to nearest amenity (Café) (km)', 1)]: [0, 1.5],
|
||||
});
|
||||
});
|
||||
|
||||
|
|
@ -289,24 +289,6 @@ describe('url-state', () => {
|
|||
});
|
||||
});
|
||||
|
||||
it('migrates legacy transport distance amenity params into transport filters', () => {
|
||||
window.history.replaceState(
|
||||
{},
|
||||
'',
|
||||
'/?amenityDistance=Distance%20to%20nearest%20amenity%20(Bus%20stop)%20(km):0:0.3'
|
||||
);
|
||||
|
||||
const state = parseUrlState();
|
||||
|
||||
expect(state.filters).toEqual({
|
||||
[createPoiFilterKey(
|
||||
TRANSPORT_DISTANCE_FILTER_NAME,
|
||||
'Distance to nearest amenity (Bus stop) (km)',
|
||||
0
|
||||
)]: [0, 0.3],
|
||||
});
|
||||
});
|
||||
|
||||
it('round-trips amenity count filters with dedicated URL params', () => {
|
||||
const cafes = createPoiFilterKey(
|
||||
POI_COUNT_2KM_FILTER_NAME,
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue