lgtm
This commit is contained in:
parent
8708bf000d
commit
11711c57e6
38 changed files with 5361 additions and 265 deletions
|
|
@ -301,24 +301,22 @@ export default function HomePage({
|
|||
{t('home.seeTheDifference')}
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-x-8 sm:gap-x-12 gap-y-4 pt-3 border-t border-white/10">
|
||||
<div>
|
||||
<div className="text-2xl md:text-3xl font-bold text-white">
|
||||
<div className="home-hero-stats flex flex-wrap pt-3 border-t border-white/10">
|
||||
<div className="home-hero-stat">
|
||||
<div className="home-hero-stat-value">
|
||||
<TickerValue text="13M" active={statsActive} />
|
||||
</div>
|
||||
<div className="text-sm text-warm-200">{t('home.statProperties')}</div>
|
||||
<div className="home-hero-stat-label">{t('home.statProperties')}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-2xl md:text-3xl font-bold text-white">
|
||||
<div className="home-hero-stat">
|
||||
<div className="home-hero-stat-value">
|
||||
<TickerValue text="56" active={statsActive} />
|
||||
</div>
|
||||
<div className="text-sm text-warm-200">{t('home.statFilters')}</div>
|
||||
<div className="home-hero-stat-label">{t('home.statFilters')}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-2xl md:text-3xl font-bold text-white">
|
||||
{t('home.statEvery')}
|
||||
</div>
|
||||
<div className="text-sm text-warm-200">{t('home.statPostcodeInEngland')}</div>
|
||||
<div className="home-hero-stat">
|
||||
<div className="home-hero-stat-value">{t('home.statEvery')}</div>
|
||||
<div className="home-hero-stat-label">{t('home.statPostcodeInEngland')}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -840,7 +840,7 @@ function ScoutScreen({ isActive }: { isActive: boolean }) {
|
|||
<div className="scout-export-action relative cursor-default select-none overflow-hidden rounded-lg border border-teal-300 bg-teal-600 p-2 text-white shadow-lg shadow-teal-900/20 dark:border-teal-500 dark:bg-teal-500 dark:text-navy-950 sm:p-4">
|
||||
<span className="scout-export-ripple" aria-hidden="true" />
|
||||
<div className="relative flex items-center gap-2 sm:gap-3">
|
||||
<span className="flex h-7 w-7 shrink-0 items-center justify-center rounded-md bg-white/15 text-white dark:bg-navy-950/10 dark:text-navy-950 sm:h-10 sm:w-10">
|
||||
<span className="scout-export-icon flex h-7 w-7 shrink-0 items-center justify-center rounded-md bg-white/15 text-white dark:bg-navy-950/10 dark:text-navy-950 sm:h-10 sm:w-10">
|
||||
<DownloadIcon className="h-4 w-4 sm:h-5 sm:w-5" />
|
||||
</span>
|
||||
<div className="min-w-0">
|
||||
|
|
@ -849,8 +849,8 @@ function ScoutScreen({ isActive }: { isActive: boolean }) {
|
|||
{t('home.showcaseDownloadXlsx')}
|
||||
</div>
|
||||
</div>
|
||||
<span className="scout-export-check ml-auto hidden h-6 w-6 shrink-0 items-center justify-center rounded-full bg-white text-teal-700 shadow-sm dark:bg-navy-950 dark:text-teal-300 sm:flex">
|
||||
<CheckIcon className="h-3.5 w-3.5" />
|
||||
<span className="scout-export-check absolute left-4 top-0 flex h-5 w-5 shrink-0 items-center justify-center rounded-full bg-white text-teal-700 shadow-sm dark:bg-navy-950 dark:text-teal-300 sm:static sm:ml-auto sm:h-6 sm:w-6">
|
||||
<CheckIcon className="h-3 w-3 sm:h-3.5 sm:w-3.5" />
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@ import { FilterIcon } from '../ui/icons';
|
|||
import { CollapsibleGroupHeader } from '../ui/CollapsibleGroupHeader';
|
||||
import { EmptyState } from '../ui/EmptyState';
|
||||
import type { FeatureMeta } from '../../types';
|
||||
import { groupFeaturesByCategory, orderFilterGroups } from '../../lib/features';
|
||||
import { groupFeaturesByCategory } from '../../lib/features';
|
||||
import { FeatureInfoPopup } from '../ui/FeatureInfoPopup';
|
||||
import { FeatureActions } from '../ui/FeatureIcons';
|
||||
import { FeatureLabel } from '../ui/FeatureLabel';
|
||||
|
|
@ -73,7 +73,7 @@ export default function FeatureBrowser({
|
|||
);
|
||||
}, [availableFeatures, search]);
|
||||
|
||||
const grouped = useMemo(() => orderFilterGroups(groupFeaturesByCategory(filtered)), [filtered]);
|
||||
const grouped = useMemo(() => groupFeaturesByCategory(filtered), [filtered]);
|
||||
|
||||
// When searching, expand all groups so results are visible
|
||||
const isSearching = search.length > 0;
|
||||
|
|
|
|||
|
|
@ -45,6 +45,7 @@ import {
|
|||
import {
|
||||
POI_FILTER_NAMES,
|
||||
POI_DISTANCE_FILTER_NAME,
|
||||
TRANSPORT_DISTANCE_FILTER_NAME,
|
||||
POI_COUNT_2KM_FILTER_NAME,
|
||||
POI_COUNT_5KM_FILTER_NAME,
|
||||
getDefaultPoiDistanceFeatureName,
|
||||
|
|
@ -170,6 +171,10 @@ export default memo(function Filters({
|
|||
() => getDefaultPoiDistanceFeatureName(features),
|
||||
[features]
|
||||
);
|
||||
const defaultTransportDistanceFeatureName = useMemo(
|
||||
() => getDefaultPoiFilterFeatureName(features, TRANSPORT_DISTANCE_FILTER_NAME),
|
||||
[features]
|
||||
);
|
||||
const defaultPoiCount2KmFeatureName = useMemo(
|
||||
() => getDefaultPoiFilterFeatureName(features, POI_COUNT_2KM_FILTER_NAME),
|
||||
[features]
|
||||
|
|
@ -179,6 +184,10 @@ export default memo(function Filters({
|
|||
[features]
|
||||
);
|
||||
const poiDistanceMeta = useMemo(() => getPoiDistanceFilterMeta(features), [features]);
|
||||
const transportDistanceMeta = useMemo(
|
||||
() => getPoiFilterMeta(features, TRANSPORT_DISTANCE_FILTER_NAME),
|
||||
[features]
|
||||
);
|
||||
const poiCount2KmMeta = useMemo(
|
||||
() => getPoiFilterMeta(features, POI_COUNT_2KM_FILTER_NAME),
|
||||
[features]
|
||||
|
|
@ -190,18 +199,25 @@ export default memo(function Filters({
|
|||
const poiFilterMetas = useMemo(
|
||||
() => ({
|
||||
[POI_DISTANCE_FILTER_NAME]: poiDistanceMeta,
|
||||
[TRANSPORT_DISTANCE_FILTER_NAME]: transportDistanceMeta,
|
||||
[POI_COUNT_2KM_FILTER_NAME]: poiCount2KmMeta,
|
||||
[POI_COUNT_5KM_FILTER_NAME]: poiCount5KmMeta,
|
||||
}),
|
||||
[poiDistanceMeta, poiCount2KmMeta, poiCount5KmMeta]
|
||||
[poiDistanceMeta, transportDistanceMeta, poiCount2KmMeta, poiCount5KmMeta]
|
||||
);
|
||||
const defaultPoiFilterFeatureNames = useMemo(
|
||||
() => ({
|
||||
[POI_DISTANCE_FILTER_NAME]: defaultPoiDistanceFeatureName,
|
||||
[TRANSPORT_DISTANCE_FILTER_NAME]: defaultTransportDistanceFeatureName,
|
||||
[POI_COUNT_2KM_FILTER_NAME]: defaultPoiCount2KmFeatureName,
|
||||
[POI_COUNT_5KM_FILTER_NAME]: defaultPoiCount5KmFeatureName,
|
||||
}),
|
||||
[defaultPoiDistanceFeatureName, defaultPoiCount2KmFeatureName, defaultPoiCount5KmFeatureName]
|
||||
[
|
||||
defaultPoiDistanceFeatureName,
|
||||
defaultTransportDistanceFeatureName,
|
||||
defaultPoiCount2KmFeatureName,
|
||||
defaultPoiCount5KmFeatureName,
|
||||
]
|
||||
);
|
||||
const schoolFilterItems = useMemo(() => {
|
||||
return Object.keys(filters)
|
||||
|
|
@ -256,7 +272,11 @@ export default memo(function Filters({
|
|||
const backendFeature = backendName
|
||||
? features.find((feature) => feature.name === backendName)
|
||||
: undefined;
|
||||
return { ...(backendFeature ?? poiFilterMetas[filterName]), name, group: 'Amenities' };
|
||||
return {
|
||||
...(backendFeature ?? poiFilterMetas[filterName]),
|
||||
name,
|
||||
group: poiFilterMetas[filterName].group,
|
||||
};
|
||||
});
|
||||
}, [filters, features, poiFilterMetas]);
|
||||
const availableFeatures = useMemo(() => {
|
||||
|
|
@ -266,8 +286,21 @@ export default memo(function Filters({
|
|||
let insertedElectionVoteShareFilter = false;
|
||||
let insertedEthnicityFilter = false;
|
||||
const insertedPoiFilters = new Set<PoiFilterName>();
|
||||
const maybeInsertPoiFilter = (filterName: PoiFilterName | null) => {
|
||||
if (
|
||||
filterName &&
|
||||
defaultPoiFilterFeatureNames[filterName] &&
|
||||
!insertedPoiFilters.has(filterName)
|
||||
) {
|
||||
result.push(poiFilterMetas[filterName]);
|
||||
insertedPoiFilters.add(filterName);
|
||||
}
|
||||
};
|
||||
|
||||
for (const feature of features) {
|
||||
if (feature.group === 'Transport') {
|
||||
maybeInsertPoiFilter(TRANSPORT_DISTANCE_FILTER_NAME);
|
||||
}
|
||||
if (isSchoolFilterName(feature.name)) {
|
||||
if (defaultSchoolFeatureName && !insertedSchoolFilter) {
|
||||
result.push(schoolMeta);
|
||||
|
|
@ -297,15 +330,7 @@ export default memo(function Filters({
|
|||
continue;
|
||||
}
|
||||
if (isPoiFilterFeatureName(feature.name)) {
|
||||
const filterName = getPoiFilterName(feature.name);
|
||||
if (
|
||||
filterName &&
|
||||
defaultPoiFilterFeatureNames[filterName] &&
|
||||
!insertedPoiFilters.has(filterName)
|
||||
) {
|
||||
result.push(poiFilterMetas[filterName]);
|
||||
insertedPoiFilters.add(filterName);
|
||||
}
|
||||
maybeInsertPoiFilter(getPoiFilterName(feature.name));
|
||||
continue;
|
||||
}
|
||||
if (!enabledFeatures.has(feature.name)) result.push(feature);
|
||||
|
|
@ -332,9 +357,19 @@ export default memo(function Filters({
|
|||
let insertedSpecificCrimeFilters = false;
|
||||
let insertedElectionVoteShareFilters = false;
|
||||
let insertedEthnicityFilters = false;
|
||||
let insertedPoiDistanceFilters = false;
|
||||
const insertedPoiFilters = new Set<PoiFilterName>();
|
||||
const insertPoiFilterItems = (filterName: PoiFilterName | null) => {
|
||||
if (!filterName || insertedPoiFilters.has(filterName)) return;
|
||||
result.push(
|
||||
...poiDistanceFilterItems.filter((item) => getPoiFilterName(item.name) === filterName)
|
||||
);
|
||||
insertedPoiFilters.add(filterName);
|
||||
};
|
||||
|
||||
for (const feature of features) {
|
||||
if (feature.group === 'Transport') {
|
||||
insertPoiFilterItems(TRANSPORT_DISTANCE_FILTER_NAME);
|
||||
}
|
||||
if (isSchoolFilterName(feature.name)) {
|
||||
if (!insertedSchoolFilter) {
|
||||
result.push(...schoolFilterItems);
|
||||
|
|
@ -364,10 +399,7 @@ export default memo(function Filters({
|
|||
continue;
|
||||
}
|
||||
if (isPoiFilterFeatureName(feature.name)) {
|
||||
if (!insertedPoiDistanceFilters) {
|
||||
result.push(...poiDistanceFilterItems);
|
||||
insertedPoiDistanceFilters = true;
|
||||
}
|
||||
insertPoiFilterItems(getPoiFilterName(feature.name));
|
||||
continue;
|
||||
}
|
||||
if (enabledFeatures.has(feature.name)) result.push(feature);
|
||||
|
|
@ -583,6 +615,7 @@ export default memo(function Filters({
|
|||
electionVoteShareMeta,
|
||||
ethnicityMeta,
|
||||
poiDistanceMeta,
|
||||
transportDistanceMeta,
|
||||
poiCount2KmMeta,
|
||||
poiCount5KmMeta,
|
||||
]}
|
||||
|
|
|
|||
|
|
@ -7,7 +7,11 @@ import { SCHOOL_FILTER_NAME, getSchoolBackendFeatureName } from '../../lib/schoo
|
|||
import { getSpecificCrimeFeatureName } from '../../lib/crime-filter';
|
||||
import { getElectionVoteShareFeatureName } from '../../lib/election-filter';
|
||||
import { getEthnicityFeatureName } from '../../lib/ethnicity-filter';
|
||||
import { POI_DISTANCE_FILTER_NAME, getPoiDistanceFeatureName } from '../../lib/poi-distance-filter';
|
||||
import {
|
||||
POI_DISTANCE_FILTER_NAME,
|
||||
getPoiDistanceFeatureName,
|
||||
getPoiFilterName,
|
||||
} from '../../lib/poi-distance-filter';
|
||||
|
||||
interface HoverCardData {
|
||||
count: number;
|
||||
|
|
@ -69,7 +73,7 @@ export default memo(function HoverCard({
|
|||
name: schoolBackendName
|
||||
? SCHOOL_FILTER_NAME
|
||||
: poiDistanceFeatureName
|
||||
? POI_DISTANCE_FILTER_NAME
|
||||
? (getPoiFilterName(name) ?? POI_DISTANCE_FILTER_NAME)
|
||||
: backendName,
|
||||
value: formatValue(val, meta),
|
||||
});
|
||||
|
|
|
|||
|
|
@ -141,7 +141,7 @@ describe('MobileBottomSheet keyboard avoidance', () => {
|
|||
it('reports covered height while the drawer is being dragged', async () => {
|
||||
installViewport({ innerHeight: 800, visualHeight: 800 });
|
||||
const { coveredHeights, sheet } = renderSheet();
|
||||
const handle = sheet.firstElementChild;
|
||||
const handle = sheet.firstElementChild?.firstElementChild;
|
||||
|
||||
if (!(handle instanceof HTMLElement)) throw new Error('Expected bottom sheet drag handle');
|
||||
|
||||
|
|
|
|||
|
|
@ -228,14 +228,18 @@ export default function MobileBottomSheet({
|
|||
: 'height 140ms ease, bottom 180ms ease',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className="shrink-0 touch-none px-4 py-2"
|
||||
onPointerDown={handlePointerDown}
|
||||
onPointerMove={handlePointerMove}
|
||||
onPointerUp={handlePointerUp}
|
||||
onPointerCancel={handlePointerUp}
|
||||
>
|
||||
<div className="w-full flex items-center justify-center" role="presentation">
|
||||
<div className="relative shrink-0 px-4 py-2">
|
||||
<div
|
||||
className="absolute inset-x-0 top-1/2 z-10 h-11 -translate-y-1/2 touch-none"
|
||||
onPointerDown={handlePointerDown}
|
||||
onPointerMove={handlePointerMove}
|
||||
onPointerUp={handlePointerUp}
|
||||
onPointerCancel={handlePointerUp}
|
||||
/>
|
||||
<div
|
||||
className="pointer-events-none flex w-full items-center justify-center"
|
||||
role="presentation"
|
||||
>
|
||||
<span className="h-1.5 w-12 rounded-full bg-warm-300 dark:bg-navy-600" />
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -123,7 +123,7 @@ export function ElectionVoteShareFilterCard({
|
|||
return (
|
||||
<div
|
||||
data-filter-name={ELECTION_VOTE_SHARE_FILTER_NAME}
|
||||
className={`space-y-2 rounded-lg border border-warm-200 bg-white px-2 py-2 shadow-sm dark:border-warm-700 dark:bg-warm-800 ${
|
||||
className={`space-y-1.5 px-2 py-1.5 rounded ${
|
||||
isActive
|
||||
? 'ring-2 ring-teal-400 bg-teal-50 dark:bg-teal-900/30'
|
||||
: isPinned
|
||||
|
|
|
|||
|
|
@ -119,7 +119,7 @@ export function EthnicityFilterCard({
|
|||
return (
|
||||
<div
|
||||
data-filter-name={ETHNICITIES_FILTER_NAME}
|
||||
className={`space-y-2 rounded-lg border border-warm-200 bg-white px-2 py-2 shadow-sm dark:border-warm-700 dark:bg-warm-800 ${
|
||||
className={`space-y-1.5 px-2 py-1.5 rounded ${
|
||||
isActive
|
||||
? 'ring-2 ring-teal-400 bg-teal-50 dark:bg-teal-900/30'
|
||||
: isPinned
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ import { getFeatureIcon } from '../../../lib/feature-icons';
|
|||
import { getGroupIcon } from '../../../lib/group-icons';
|
||||
import {
|
||||
POI_DISTANCE_FILTER_NAME,
|
||||
TRANSPORT_DISTANCE_FILTER_NAME,
|
||||
clampPoiFilterRange,
|
||||
getDefaultPoiFilterFeatureName,
|
||||
getPoiDistanceFeatureName,
|
||||
|
|
@ -119,7 +120,7 @@ export function PoiDistanceFilterCard({
|
|||
return (
|
||||
<div
|
||||
data-filter-name={filterName}
|
||||
className={`space-y-2 rounded-lg border border-warm-200 bg-white px-2 py-2 shadow-sm dark:border-warm-700 dark:bg-warm-800 ${
|
||||
className={`space-y-1.5 px-2 py-1.5 rounded ${
|
||||
isActive
|
||||
? 'ring-2 ring-teal-400 bg-teal-50 dark:bg-teal-900/30'
|
||||
: isPinned
|
||||
|
|
@ -192,6 +193,10 @@ export function PoiDistanceFilterCard({
|
|||
isAtMax={isAtMax}
|
||||
raw={selectedFeature.raw}
|
||||
feature={selectedFeature}
|
||||
showUnit={
|
||||
filterName === POI_DISTANCE_FILTER_NAME ||
|
||||
filterName === TRANSPORT_DISTANCE_FILTER_NAME
|
||||
}
|
||||
onValueChange={(v) =>
|
||||
onFilterChange(poiFeature.name, clampPoiFilterRange(v, selectedFeature))
|
||||
}
|
||||
|
|
|
|||
|
|
@ -119,7 +119,7 @@ export function SpecificCrimeFilterCard({
|
|||
return (
|
||||
<div
|
||||
data-filter-name={SPECIFIC_CRIMES_FILTER_NAME}
|
||||
className={`space-y-2 rounded-lg border border-warm-200 bg-white px-2 py-2 shadow-sm dark:border-warm-700 dark:bg-warm-800 ${
|
||||
className={`space-y-1.5 px-2 py-1.5 rounded ${
|
||||
isActive
|
||||
? 'ring-2 ring-teal-400 bg-teal-50 dark:bg-teal-900/30'
|
||||
: isPinned
|
||||
|
|
|
|||
|
|
@ -19,7 +19,7 @@ export function CollapsibleGroupHeader({
|
|||
return (
|
||||
<button
|
||||
onClick={onToggle}
|
||||
className={`w-full flex items-center justify-between border-b border-warm-300 dark:border-warm-700 ${className}`}
|
||||
className={`w-full cursor-pointer flex items-center justify-between border-b border-warm-300 dark:border-warm-700 ${className}`}
|
||||
>
|
||||
<span>{ts(name)}</span>
|
||||
<div className="flex items-center gap-1">
|
||||
|
|
|
|||
|
|
@ -621,6 +621,10 @@ const de: Translations = {
|
|||
dsGreenspaceOrigin: 'Ordnance Survey',
|
||||
dsGreenspaceUse:
|
||||
'Offizielle Grünflächengrenzen für Großbritannien, einschließlich öffentlicher Parks, Gärten, Sportplätze und Spielplätze. Polygon-Schwerpunkte werden für die Parknähezählung und Entfernungsberechnung zum nächsten Park verwendet.',
|
||||
dsTowName: 'Nationale Karte der Bäume außerhalb von Waldflächen',
|
||||
dsTowOrigin: 'Forest Research / Defra NCEA',
|
||||
dsTowUse:
|
||||
'Baumkronen-Polygone für Einzelbäume, Baumgruppen und kleine Gehölze in England. Hier verwendet, um die straßennahe Baumdichte rund um Immobilienadressen zu schätzen.',
|
||||
dsNaptanName: 'NaPTAN (Haltestellen des öffentlichen Verkehrs)',
|
||||
dsNaptanOrigin: 'Department for Transport',
|
||||
dsNaptanUse:
|
||||
|
|
@ -996,6 +1000,7 @@ const de: Translations = {
|
|||
'Specific crimes': 'Einzelne Delikte',
|
||||
Ethnicities: 'Ethnien',
|
||||
'Amenity distance': 'Entfernung zu Infrastruktur',
|
||||
'Closest transport option': 'Nächste Verkehrsoption',
|
||||
'Amenities within 2km': 'Infrastruktur im Umkreis von 2 km',
|
||||
'Amenities within 5km': 'Infrastruktur im Umkreis von 5 km',
|
||||
|
||||
|
|
|
|||
|
|
@ -410,18 +410,18 @@ const en = {
|
|||
|
||||
// ── Home Page ──────────────────────────────────────
|
||||
home: {
|
||||
heroEyebrow: 'For buyers asking “where should I even look?”',
|
||||
heroTitle1: 'Find the postcodes that',
|
||||
heroTitle2: 'fit your life',
|
||||
heroTitle3: 'Not just the areas you already know.',
|
||||
heroEyebrow: "For buyers who don't know where to start",
|
||||
heroTitle1: 'Start with your needs,',
|
||||
heroTitle2: 'not an area name',
|
||||
heroTitle3: 'Then shortlist postcodes worth viewing.',
|
||||
heroSubtitle:
|
||||
'From London boroughs to commuter towns and regional cities, England has too many places to research one by one.',
|
||||
'Most buyers start with a few familiar areas, then stitch together listing sites, commute checks, school reports, crime maps, broadband tools, and sold prices in separate tabs.',
|
||||
heroDescription:
|
||||
'Set your budget, commute, schools, safety, noise, broadband, and lifestyle needs. Perfect Postcode scans England’s postcodes and reveals the places that actually fit, including areas you’d never have typed into a listing portal.',
|
||||
exploreTheMap: 'Find my matching postcodes',
|
||||
seeTheDifference: 'See how it works',
|
||||
productDemoLabel: 'Perfect Postcode product demo',
|
||||
playProductDemo: 'Play Perfect Postcode product demo',
|
||||
'Set your budget, commute, schools, safety, noise, broadband, parks, shops, and property needs. Perfect Postcode checks postcodes across England and shows the areas worth shortlisting, including places you may not know by name.',
|
||||
exploreTheMap: 'Start matching postcodes',
|
||||
seeTheDifference: 'Watch the demo',
|
||||
productDemoLabel: 'Watch the postcode shortlist demo',
|
||||
playProductDemo: 'Play the postcode shortlist demo',
|
||||
scrollToProductDemo: 'Scroll to product demo',
|
||||
showcaseHeader: 'How it works',
|
||||
showcaseContext: 'How Perfect Postcode works',
|
||||
|
|
@ -429,43 +429,43 @@ const en = {
|
|||
showcaseFeatureNoiseShort: 'Noise',
|
||||
showcaseFeatureSchoolsShort: 'Schools',
|
||||
showcaseFeatureTravelShort: 'Travel',
|
||||
showcaseGoodPrimariesNearby: '{{count}}+ good primaries nearby',
|
||||
showcaseWithinRail: 'Within {{count}} min of rail',
|
||||
showcaseMatchingHomesLabel: 'Matching homes',
|
||||
showcaseMatchingHomes: '{{value}} matching homes',
|
||||
showcaseGoodPrimariesNearby: '{{count}}+ Good or Outstanding primary schools nearby',
|
||||
showcaseWithinRail: 'Within {{count}} min of a station',
|
||||
showcaseMatchingHomesLabel: 'Matching postcodes',
|
||||
showcaseMatchingHomes: '{{value}} matching postcodes',
|
||||
showcaseMedianPrice: '{{value}} median',
|
||||
showcaseJourneyRoutes: 'Journey routes',
|
||||
showcaseNearby: '{{value}} nearby',
|
||||
showcasePoliticalVoteShare: 'Political vote share',
|
||||
showcaseLotsMore: '...and lots more',
|
||||
showcaseLotsMore: 'More neighbourhood data',
|
||||
showcaseMinutes: '{{count}} min',
|
||||
showcaseSendShortlist: 'Send the shortlist',
|
||||
showcaseDownloadXlsx: 'Download .xlsx',
|
||||
showcaseTopThree: 'Top 3',
|
||||
showcaseScoutBullet1: 'Walk the streets before the listing search narrows your options.',
|
||||
showcaseScoutBullet1: 'Check the street before you commit to listing alerts.',
|
||||
showcaseScoutBullet2: 'Test the commute from a real front door, not a borough name.',
|
||||
showcaseScoutBullet3: 'Compare viewings with evidence already in hand.',
|
||||
showcaseScoutBullet3: 'Compare viewings with evidence already saved.',
|
||||
showcaseStep1Tab: 'Filter',
|
||||
showcaseStep1Title: 'Turn vague needs into a tight search',
|
||||
showcaseStep1Title: 'Turn your needs into clear search filters',
|
||||
showcaseStep1Body:
|
||||
'Set what matters and see exactly how many wrong-fit postcodes each requirement keeps out of your search.',
|
||||
'Set what matters and see how many unsuitable postcodes each requirement removes.',
|
||||
showcaseStep1Chip1: 'Quiet streets',
|
||||
showcaseStep1Chip2: 'Top-rated primaries',
|
||||
showcaseStep1Chip2: 'Good primaries nearby',
|
||||
showcaseStep1Chip3: 'Under £500k',
|
||||
showcaseStep1VennCenter: 'Postcodes that meet all three',
|
||||
showcaseStep2Tab: 'Match',
|
||||
showcaseStep2Title: 'Let the map surface places you wouldn’t have typed',
|
||||
showcaseStep2Title: 'Find places you would never have known to search',
|
||||
showcaseStep2Body:
|
||||
'Scan England by fit instead of starting from familiar area names. Hidden pockets become visible before listing portals narrow your imagination.',
|
||||
'Search by what you need, not by area name. The map shows suitable postcode clusters before listing sites narrow the search.',
|
||||
showcaseStep2Region: 'Greater London',
|
||||
showcaseStep2Sources: 'Land Registry · ONS · Ofsted · DfT',
|
||||
showcaseStep2ClustersLabel: 'Matching clusters',
|
||||
showcaseStep3Tab: 'Inspect',
|
||||
showcaseStep3Title: 'Inspect why a postcode made the cut',
|
||||
showcaseStep3Title: 'See why a postcode matches',
|
||||
showcaseStep3Body:
|
||||
'Open any matching area and check prices, safety, schools, broadband, and trade-offs in one pane before you spend a weekend there.',
|
||||
showcaseStep3HeaderArea: 'Your perfect postcode',
|
||||
showcaseStep3HeaderFit: 'Neighbourhood evidence',
|
||||
showcaseStep3HeaderArea: 'Shortlisted postcode',
|
||||
showcaseStep3HeaderFit: 'Why it matches',
|
||||
showcaseStep3Stat1Label: 'Sold price trend',
|
||||
showcaseStep3Stat2Label: 'Crime rate',
|
||||
showcaseStep3Stat2Value: 'Below borough avg.',
|
||||
|
|
@ -473,50 +473,50 @@ const en = {
|
|||
showcaseStep3Stat4Label: 'Broadband',
|
||||
showcaseStep3Stat4Value: '1 Gbps available',
|
||||
showcaseStep3Stat5Label: 'Primary schools',
|
||||
showcaseStep3Stat5Value: '3 outstanding within 1 mile',
|
||||
showcaseStep3Stat5Value: '3 Outstanding within 1 mile',
|
||||
showcaseStep4Tab: 'Scout',
|
||||
showcaseStep4Title: 'Scout it out yourself',
|
||||
showcaseStep4Title: 'Take the strongest areas into the real world',
|
||||
showcaseStep4Body:
|
||||
'Take three grounded starting points into the real world. Walk the streets, test the commute, and compare viewings with context.',
|
||||
'Export suggested postcodes to visit. Walk the streets, test the commute, and compare viewings with the data you saved.',
|
||||
showcaseStep4FileName: 'areas-to-scout.xlsx',
|
||||
showcaseStep4ExportLabel: 'Export to Excel',
|
||||
showcaseStep4ColPostcode: 'Postcode',
|
||||
showcaseStep4ColScore: 'Fit',
|
||||
showcaseStep4ColScore: 'Match',
|
||||
showcaseStep4ColCommute: 'Commute',
|
||||
showcaseStep4ColPrice: 'Median sold',
|
||||
showcaseStep4Conclusion: 'You can start your journey from here.',
|
||||
statProperties: 'historical sales',
|
||||
statFilters: 'combinable filters',
|
||||
showcaseStep4ColPrice: 'Median sold price',
|
||||
showcaseStep4Conclusion: 'Export a shortlist and start checking streets.',
|
||||
statProperties: 'HM Land Registry sales',
|
||||
statFilters: 'ways to narrow the map',
|
||||
statEvery: 'Every',
|
||||
statPostcodeInEngland: 'postcode in England',
|
||||
ourPhilosophy: 'Start with your life, not a postcode',
|
||||
statPostcodeInEngland: 'active postcode in England',
|
||||
ourPhilosophy: 'Start with needs. End with postcodes.',
|
||||
philosophyP1:
|
||||
'Most property sites ask where you want to live. In London that’s painfully hard, but the same problem shows up across England: buyers choose from the few places they know, then cross-check commute tools, Ofsted, police data, Street View, broadband checkers, and sold prices in separate tabs.',
|
||||
'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.',
|
||||
philosophyP2:
|
||||
'Perfect Postcode flips the search. Tell the map what matters and it shows the postcodes that qualify, with evidence for why they’re worth inspecting. Data first, then go test the vibe.',
|
||||
'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.',
|
||||
streetTitle: 'Places change street by street',
|
||||
streetIntro:
|
||||
'Broad 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 areas you may have missed',
|
||||
'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:
|
||||
'Surface postcodes that match your requirements instead of relying on familiar names, friend recommendations, or “up-and-coming” hype.',
|
||||
streetCard2Title: 'See the trade-offs before viewings',
|
||||
'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',
|
||||
streetCard2Body:
|
||||
'Compare price, space, commute, safety, schools, broadband, noise, and energy ratings before you spend weekends travelling between viewings.',
|
||||
othersVs: 'Others vs',
|
||||
checkMyPostcode: 'Listing portals',
|
||||
areaGuides: 'Postcode reports',
|
||||
compSearchWithout: 'Discover areas before you know their names',
|
||||
'Compare price, space, commute, safety, schools, broadband, noise, energy ratings, parks, and local amenities before you spend weekends travelling between viewings.',
|
||||
othersVs: 'Other tools vs',
|
||||
checkMyPostcode: 'Listing sites',
|
||||
areaGuides: 'Postcode checkers',
|
||||
compSearchWithout: 'Find areas before you know their names',
|
||||
compSearchWithoutSub: '(requirements first, location second)',
|
||||
compAreaData: 'Postcode-level neighbourhood evidence',
|
||||
compAreaData: 'Neighbourhood evidence in one place',
|
||||
compAreaDataSub: '(crime, schools, noise, broadband, amenities)',
|
||||
compPropertyData: 'Property-level history',
|
||||
compPropertyData: 'Street-level property context',
|
||||
compPropertyDataSub: '(sold prices, EPC, floor area, estimated value)',
|
||||
compFilters: '56 filters working together',
|
||||
compFiltersSub: '(not one postcode or one listing at a time)',
|
||||
ctaTitle: 'Stop guessing where to buy.',
|
||||
compFilters: 'All your requirements working together',
|
||||
compFiltersSub: '(budget + commute + schools + safety + local context)',
|
||||
ctaTitle: 'Do the area research before you book the viewing.',
|
||||
ctaDescription:
|
||||
'Build a shortlist of postcodes that fit your actual life, then test them in person.',
|
||||
'Build a postcode shortlist from price, commute, schools, safety, noise, broadband, amenities, and sold-price evidence, then verify the streets in person.',
|
||||
},
|
||||
|
||||
// ── Pricing Page ───────────────────────────────────
|
||||
|
|
@ -612,7 +612,7 @@ const en = {
|
|||
dsTowName: 'National Trees Outside Woodland Map',
|
||||
dsTowOrigin: 'Forest Research / Defra NCEA',
|
||||
dsTowUse:
|
||||
'Tree canopy polygons for lone trees, groups of trees, and small woodlands in England. Used here to estimate street-level tree density around property addresses.',
|
||||
'Tree canopy polygons for lone trees, groups of trees, and small woodlands in England. Used here to estimate street-level tree coverage percentiles around property addresses.',
|
||||
dsNaptanName: 'NaPTAN (Public Transport Stops)',
|
||||
dsNaptanOrigin: 'Department for Transport',
|
||||
dsNaptanUse:
|
||||
|
|
@ -980,6 +980,7 @@ const en = {
|
|||
'Specific crimes': 'Specific crimes',
|
||||
Ethnicities: 'Ethnicities',
|
||||
'Amenity distance': 'Amenity distance',
|
||||
'Closest transport option': 'Closest transport option',
|
||||
'Amenities within 2km': 'Amenities within 2km',
|
||||
'Amenities within 5km': 'Amenities within 5km',
|
||||
|
||||
|
|
|
|||
|
|
@ -622,6 +622,10 @@ const fr: Translations = {
|
|||
dsGreenspaceOrigin: 'Ordnance Survey',
|
||||
dsGreenspaceUse:
|
||||
'Limites officielles des espaces verts de Grande-Bretagne, incluant parcs publics, jardins, terrains de sport et aires de jeux. Les centroïdes des polygones sont utilisés pour le comptage de proximité des parcs et le calcul de la distance au parc le plus proche.',
|
||||
dsTowName: 'Carte nationale des arbres hors forêt',
|
||||
dsTowOrigin: 'Forest Research / Defra NCEA',
|
||||
dsTowUse:
|
||||
'Polygones de couvert arboré pour les arbres isolés, groupes d’arbres et petits bois en Angleterre. Utilisés ici pour estimer la densité d’arbres au niveau de la rue autour des adresses de biens.',
|
||||
dsNaptanName: 'NaPTAN (arrêts de transport public)',
|
||||
dsNaptanOrigin: 'Department for Transport',
|
||||
dsNaptanUse:
|
||||
|
|
@ -995,6 +999,7 @@ const fr: Translations = {
|
|||
'Specific crimes': 'Crimes spécifiques',
|
||||
Ethnicities: 'Origines ethniques',
|
||||
'Amenity distance': 'Distance aux commodités',
|
||||
'Closest transport option': 'Transport le plus proche',
|
||||
'Amenities within 2km': 'Commodités à moins de 2 km',
|
||||
'Amenities within 5km': 'Commodités à moins de 5 km',
|
||||
|
||||
|
|
|
|||
|
|
@ -583,6 +583,10 @@ const hi: Translations = {
|
|||
dsGreenspaceOrigin: 'Ordnance Survey',
|
||||
dsGreenspaceUse:
|
||||
'ग्रेट ब्रिटेन के लिए आधिकारिक हरित क्षेत्र सीमाएं, जिनमें सार्वजनिक पार्क, उद्यान, खेल मैदान और खेलने की जगहें शामिल हैं. पार्क निकटता गिनती और निकटतम पार्क दूरी गणना के लिए बहुभुज केंद्र बिंदु उपयोग होते हैं.',
|
||||
dsTowName: 'वन क्षेत्र से बाहर पेड़ों का राष्ट्रीय नक्शा',
|
||||
dsTowOrigin: 'Forest Research / Defra NCEA',
|
||||
dsTowUse:
|
||||
'इंग्लैंड में अकेले पेड़ों, पेड़ों के समूहों और छोटे वन क्षेत्रों के वृक्ष आच्छादन बहुभुज. यहां संपत्ति पतों के आसपास सड़क-स्तर पेड़ घनत्व का अनुमान लगाने के लिए उपयोग किया गया है.',
|
||||
dsNaptanName: 'NaPTAN (सार्वजनिक परिवहन स्टॉप)',
|
||||
dsNaptanOrigin: 'Department for Transport',
|
||||
dsNaptanUse:
|
||||
|
|
@ -912,6 +916,7 @@ const hi: Translations = {
|
|||
'Specific crimes': 'विशिष्ट अपराध',
|
||||
Ethnicities: 'जातीय समूह',
|
||||
'Amenity distance': 'सुविधा दूरी',
|
||||
'Closest transport option': 'निकटतम परिवहन विकल्प',
|
||||
'Amenities within 2km': '2 किमी के अंदर सुविधाएं',
|
||||
'Amenities within 5km': '5 किमी के अंदर सुविधाएं',
|
||||
Detached: 'अलग मकान',
|
||||
|
|
|
|||
|
|
@ -615,6 +615,10 @@ const hu: Translations = {
|
|||
dsGreenspaceOrigin: 'Ordnance Survey',
|
||||
dsGreenspaceUse:
|
||||
'Hivatalos zöldterületi határok Nagy-Britanniában, beleértve a közparkokat, kerteket, sportterületeket és játszótereket. A poligon középpontjait használjuk a park közelségi számláláshoz és a legközelebbi park távolságának számításához.',
|
||||
dsTowName: 'Országos, erdőn kívüli fák térképe',
|
||||
dsTowOrigin: 'Forest Research / Defra NCEA',
|
||||
dsTowUse:
|
||||
'Fa lombkorona-poligonok magányos fákhoz, facsoportokhoz és kisebb erdőfoltokhoz Angliában. Itt az ingatlancímek körüli utcaszintű fasűrűség becslésére használjuk.',
|
||||
dsNaptanName: 'NaPTAN (Tömegközlekedési megállók)',
|
||||
dsNaptanOrigin: 'Department for Transport',
|
||||
dsNaptanUse:
|
||||
|
|
@ -987,6 +991,7 @@ const hu: Translations = {
|
|||
'Specific crimes': 'Konkrét bűncselekmények',
|
||||
Ethnicities: 'Etnikai csoportok',
|
||||
'Amenity distance': 'Szolgáltatás-távolság',
|
||||
'Closest transport option': 'Legközelebbi közlekedési lehetőség',
|
||||
'Amenities within 2km': 'Szolgáltatások 2 km-en belül',
|
||||
'Amenities within 5km': 'Szolgáltatások 5 km-en belül',
|
||||
|
||||
|
|
|
|||
|
|
@ -597,6 +597,10 @@ const zh: Translations = {
|
|||
dsGreenspaceOrigin: 'Ordnance Survey',
|
||||
dsGreenspaceUse:
|
||||
'大不列颠地区权威的绿地边界数据,包括公共公园、花园、运动场和游乐场。多边形质心用于公园邻近度计数和最近公园距离计算。',
|
||||
dsTowName: '国家非林地树木地图',
|
||||
dsTowOrigin: 'Forest Research / Defra NCEA',
|
||||
dsTowUse:
|
||||
'英格兰孤立树木、树群和小片林地的树冠多边形。此处用于估算房产地址周围街道级树木密度。',
|
||||
dsNaptanName: 'NaPTAN(公共交通站点)',
|
||||
dsNaptanOrigin: 'Department for Transport',
|
||||
dsNaptanUse: '英格兰各地铁路、公交、地铁/有轨电车、渡轮和机场的站点位置。',
|
||||
|
|
@ -956,6 +960,7 @@ const zh: Translations = {
|
|||
'Specific crimes': '具体犯罪',
|
||||
Ethnicities: '族裔',
|
||||
'Amenity distance': '配套设施距离',
|
||||
'Closest transport option': '最近的交通选择',
|
||||
'Amenities within 2km': '2 公里内配套设施',
|
||||
'Amenities within 5km': '5 公里内配套设施',
|
||||
|
||||
|
|
|
|||
|
|
@ -192,6 +192,48 @@ h3 {
|
|||
height: 5rem;
|
||||
}
|
||||
|
||||
.home-hero-stats {
|
||||
flex-direction: column;
|
||||
gap: 0.7rem;
|
||||
}
|
||||
|
||||
.home-hero-stat {
|
||||
display: inline-flex;
|
||||
min-width: 0;
|
||||
align-items: baseline;
|
||||
gap: 0.4rem;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.home-hero-stat-value {
|
||||
flex: 0 0 auto;
|
||||
font-size: clamp(1.25rem, 5.2vw, 1.875rem);
|
||||
font-weight: 700;
|
||||
line-height: 1.05;
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
.home-hero-stat-label {
|
||||
min-width: 0;
|
||||
flex: 0 1 auto;
|
||||
color: #e7e5e4;
|
||||
font-size: clamp(0.78rem, 3.25vw, 0.875rem);
|
||||
line-height: 1.15;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
@media (min-width: 640px) {
|
||||
.home-hero-stats {
|
||||
flex-direction: row;
|
||||
column-gap: 3rem;
|
||||
row-gap: 1rem;
|
||||
}
|
||||
|
||||
.home-hero-stat-value {
|
||||
font-size: 1.875rem;
|
||||
}
|
||||
}
|
||||
|
||||
@media (min-width: 1200px) {
|
||||
.home-hero-container {
|
||||
padding-top: 3rem;
|
||||
|
|
@ -300,7 +342,30 @@ h3 {
|
|||
}
|
||||
}
|
||||
|
||||
@keyframes scout-export-icon-pop {
|
||||
0%,
|
||||
54%,
|
||||
100% {
|
||||
transform: scale(1);
|
||||
}
|
||||
62% {
|
||||
transform: scale(0.82) rotate(-5deg);
|
||||
}
|
||||
72% {
|
||||
transform: scale(1.13) rotate(4deg);
|
||||
}
|
||||
84% {
|
||||
transform: scale(1);
|
||||
}
|
||||
}
|
||||
|
||||
.scout-export-action {
|
||||
transform-origin: center;
|
||||
animation: none;
|
||||
}
|
||||
|
||||
.scout-export-icon {
|
||||
transform-origin: center;
|
||||
animation: none;
|
||||
}
|
||||
|
||||
|
|
@ -331,10 +396,31 @@ h3 {
|
|||
animation: scout-export-ripple 2.4s ease-out 1 both;
|
||||
}
|
||||
|
||||
.scout-screen-active .scout-export-icon {
|
||||
animation: scout-export-icon-pop 2.4s ease-in-out 1 both;
|
||||
}
|
||||
|
||||
.scout-screen-active .scout-export-check {
|
||||
animation: scout-export-check 2.4s ease-in-out 1 both;
|
||||
}
|
||||
|
||||
@media (max-width: 639px) {
|
||||
.scout-export-ripple {
|
||||
width: 5.5rem;
|
||||
height: 5.5rem;
|
||||
}
|
||||
|
||||
.scout-screen-active .scout-export-action {
|
||||
animation-duration: 2s;
|
||||
}
|
||||
|
||||
.scout-screen-active .scout-export-ripple,
|
||||
.scout-screen-active .scout-export-icon,
|
||||
.scout-screen-active .scout-export-check {
|
||||
animation-duration: 2s;
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.showcase-progress {
|
||||
animation: none !important;
|
||||
|
|
@ -342,6 +428,7 @@ h3 {
|
|||
}
|
||||
|
||||
.scout-export-action,
|
||||
.scout-export-icon,
|
||||
.scout-export-ripple,
|
||||
.scout-export-check {
|
||||
animation: none !important;
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ import { createElectionVoteShareFilterKey } from './election-filter';
|
|||
import { createEthnicityFilterKey } from './ethnicity-filter';
|
||||
import {
|
||||
POI_COUNT_2KM_FILTER_NAME,
|
||||
TRANSPORT_DISTANCE_FILTER_NAME,
|
||||
createPoiDistanceFilterKey,
|
||||
createPoiFilterKey,
|
||||
} from './poi-distance-filter';
|
||||
|
|
@ -141,18 +142,18 @@ describe('api utilities', () => {
|
|||
it('serializes amenity distance filters using their selected backend feature', () => {
|
||||
const features: FeatureMeta[] = [
|
||||
{ name: 'Distance to nearest park (km)', type: 'numeric', min: 0, max: 2 },
|
||||
{ name: 'Distance to nearest Tesco (km)', type: 'numeric', min: 0, max: 5 },
|
||||
{ name: 'Distance to nearest grocery store (km)', type: 'numeric', min: 0, max: 5 },
|
||||
];
|
||||
|
||||
expect(
|
||||
buildFilterString(
|
||||
{
|
||||
[createPoiDistanceFilterKey('Distance to nearest park (km)', 1)]: [0, 0.5],
|
||||
[createPoiDistanceFilterKey('Distance to nearest Tesco (km)', 2)]: [0, 1],
|
||||
[createPoiDistanceFilterKey('Distance to nearest grocery store (km)', 2)]: [0, 1],
|
||||
},
|
||||
features
|
||||
)
|
||||
).toBe('Distance to nearest park (km):0:0.5;;Distance to nearest Tesco (km):0:1');
|
||||
).toBe('Distance to nearest park (km):0:0.5;;Distance to nearest grocery store (km):0:1');
|
||||
});
|
||||
|
||||
it('serializes amenity count filters using their selected backend feature', () => {
|
||||
|
|
@ -173,4 +174,23 @@ describe('api utilities', () => {
|
|||
)
|
||||
).toBe('Number of amenities (Cafe) within 2km:2:10');
|
||||
});
|
||||
|
||||
it('serializes transport distance filters using their selected backend feature', () => {
|
||||
const features: FeatureMeta[] = [
|
||||
{ name: 'Distance to nearest amenity (Bus stop) (km)', type: 'numeric', min: 0, max: 2 },
|
||||
];
|
||||
|
||||
expect(
|
||||
buildFilterString(
|
||||
{
|
||||
[createPoiFilterKey(
|
||||
TRANSPORT_DISTANCE_FILTER_NAME,
|
||||
'Distance to nearest amenity (Bus stop) (km)',
|
||||
1
|
||||
)]: [0, 0.4],
|
||||
},
|
||||
features
|
||||
)
|
||||
).toBe('Distance to nearest amenity (Bus stop) (km):0:0.4');
|
||||
});
|
||||
});
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue