This commit is contained in:
Andras Schmelczer 2026-07-03 19:27:02 +01:00
parent 463bd4c647
commit 982e0cc89c
16 changed files with 466 additions and 51 deletions

View file

@ -14,7 +14,6 @@ import {
} from '../../lib/seoLandingPages';
import { safeJsonLd } from '../../lib/json-ld';
const PUBLIC_URL = 'https://perfect-postcode.co.uk';
const ProductShowcase = lazy(() => import('../home/ProductShowcase'));
function ProductShowcaseFallback() {
@ -102,32 +101,11 @@ export default function SeoLandingPage({
}) {
const { t, i18n } = useTranslation();
const page = getLocalizedSeoLandingPage(pageKey, i18n.language);
const url = `${PUBLIC_URL}${page.path}`;
usePageMeta(page.metaTitle, page.metaDescription);
return (
<main className="flex-1 overflow-y-auto bg-warm-50 text-navy-950 dark:bg-navy-950 dark:text-warm-100">
<FaqJsonLd faq={page.faq} />
<JsonLd
data={{
'@context': 'https://schema.org',
'@type': 'BreadcrumbList',
itemListElement: [
{
'@type': 'ListItem',
position: 1,
name: 'Perfect Postcode',
item: `${PUBLIC_URL}/`,
},
{
'@type': 'ListItem',
position: 2,
name: page.title,
item: url,
},
],
}}
/>
<section className="bg-navy-950 text-white">
<div className="mx-auto max-w-6xl px-6 py-16 md:px-10 md:py-20">

View file

@ -0,0 +1,23 @@
import { render, cleanup } from '@testing-library/react';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { i18nReady } from '../../i18n';
import LearnPage from './LearnPage';
// jsdom doesn't implement Element.scrollTo, which LearnPage calls on mount/tab change.
beforeEach(() => {
Element.prototype.scrollTo = vi.fn();
});
afterEach(cleanup);
describe('LearnPage FAQ heading hierarchy', () => {
it('has no H3 directly under the H1 (no skipped H2)', async () => {
await i18nReady;
const { container } = render(<LearnPage />);
// default tab is 'faq'
expect(container.querySelector('h1')).not.toBeNull();
// FAQ section group titles must be h2, not h3
expect(container.querySelectorAll('h2').length).toBeGreaterThan(0);
expect(container.querySelector('h3')).toBeNull();
});
});

View file

@ -494,9 +494,9 @@ export default function LearnPage() {
<div className="space-y-8">
{FAQ_SECTIONS.map((section) => (
<div key={section.title}>
<h3 className="text-sm font-semibold text-warm-500 dark:text-warm-400 uppercase tracking-wide mb-3">
<h2 className="text-sm font-semibold text-warm-500 dark:text-warm-400 uppercase tracking-wide mb-3">
{section.title}
</h3>
</h2>
<div className="space-y-3">
{section.items.map((item, index) => (
<FAQItemCard key={index} question={item.question} answer={item.answer} />

View file

@ -32,7 +32,7 @@ export const TERMS: LegalDoc = {
{
heading: '1. The service',
paragraphs: [
'Perfect Postcode is a research tool that combines public datasets about England — property transactions, energy certificates, schools, crime, noise, broadband, transport and more — on an interactive map, so you can shortlist areas that fit your needs before booking viewings.',
'Perfect Postcode is a research tool that combines public datasets about England (property transactions, energy certificates, schools, crime, noise, broadband, transport and more) on an interactive map, so you can shortlist areas that fit your needs before booking viewings.',
'We are not an estate agent, mortgage broker, surveyor or financial adviser, and the service does not provide financial, legal or investment advice.',
],
},
@ -63,7 +63,7 @@ export const TERMS: LegalDoc = {
{
heading: '5. Data accuracy',
paragraphs: [
'The maps and figures are built from public datasets (HM Land Registry, EPC register, ONS, Ofsted, DfT, police.uk and others) combined with modelling and estimation. Sources can be incomplete, out of date or wrong at the level of an individual property, and our estimates — including estimated current prices — are statistical indications, not valuations.',
'The maps and figures are built from public datasets (HM Land Registry, EPC register, ONS, Ofsted, DfT, police.uk and others) combined with modelling and estimation. Sources can be incomplete, out of date or wrong at the level of an individual property, and our estimates (including estimated current prices) are statistical indications, not valuations.',
'Always verify anything that matters in person and through professional advice (surveys, solicitors, mortgage advisers) before making offers or financial decisions. We provide the service “as is” and do not warrant that any figure is accurate, complete or current.',
],
},
@ -95,7 +95,7 @@ export const TERMS: LegalDoc = {
{
heading: '10. Governing law and contact',
paragraphs: [
`These terms are governed by the law of England and Wales, and disputes are subject to the jurisdiction of the courts of England and Wales (consumers keep any mandatory protections of their country of residence). Questions and complaints: ${SUPPORT_EMAIL} — we typically respond within 24 hours.`,
`These terms are governed by the law of England and Wales, and disputes are subject to the jurisdiction of the courts of England and Wales (consumers keep any mandatory protections of their country of residence). Questions and complaints: ${SUPPORT_EMAIL}. We typically respond within 24 hours.`,
],
},
],
@ -128,7 +128,7 @@ export const PRIVACY: LegalDoc = {
'To provide and secure the service, including signing you in and remembering your saved work (performance of contract).',
'To process payments and keep the records tax law requires (legal obligation).',
'To answer support requests (performance of contract).',
'To send the newsletter, only if you opted in every email includes an unsubscribe link (consent).',
'To send the newsletter, only if you opted in; every email includes an unsubscribe link (consent).',
'To understand how features are used and improve them, using aggregated analytics and logged AI queries (legitimate interests).',
],
},
@ -138,8 +138,8 @@ export const PRIVACY: LegalDoc = {
'We do not sell personal data. We use a small number of processors to run the service:',
],
bullets: [
'Stripe payment processing.',
'Google sign-in (if you choose Google OAuth), embedded Maps/Street View imagery, and the Gemini API which processes the text of AI searches.',
'Stripe: payment processing.',
'Google: sign-in (if you choose Google OAuth), embedded Maps/Street View imagery, and the Gemini API which processes the text of AI searches.',
'Hosting and infrastructure providers that run our servers and store backups.',
],
},

View file

@ -64,7 +64,7 @@ export default function CrimeGroupBody({
// One selector drives every value and comparison in this group.
const [crimeWindow, setCrimeWindow] = useState<CrimeWindow>('7y');
const fmtCount = (value?: number) => (value == null ? '' : formatValue(value));
const fmtCount = (value?: number) => (value == null ? 'N/A' : formatValue(value));
const rollupCards = STACKED_GROUPS.Crime ?? [];

View file

@ -54,7 +54,7 @@ export function compactHistogramLabel(
const firstBoundary = Math.ceil(p1);
// This outlier bin holds values strictly below p1. When p1 <= 0 there are no
// (non-negative) integers below it, so the value 0 lives in the first middle
// bin instead — labelling this empty bin "0" too would show "0" twice.
// bin instead. Labelling this empty bin "0" too would show "0" twice.
if (firstBoundary <= 0) return '';
return firstBoundary === 1 ? '0' : `<${firstBoundary.toLocaleString()}`;
}

View file

@ -63,6 +63,14 @@ import {
isTenureFeatureName,
isTenureFilterName,
} from '../../lib/tenure-filter';
import {
COUNCIL_FILTER_NAME,
getCouncilFeatureName,
getCouncilFilterMeta,
getDefaultCouncilFeatureName,
isCouncilFeatureName,
isCouncilFilterName,
} from '../../lib/council-filter';
import {
SCHOOL_FILTER_NAME,
getDefaultSchoolFeatureName,
@ -214,6 +222,11 @@ export default memo(function Filters({
const qualificationMeta = useMemo(() => getQualificationFilterMeta(features), [features]);
const defaultTenureFeatureName = useMemo(() => getDefaultTenureFeatureName(features), [features]);
const tenureMeta = useMemo(() => getTenureFilterMeta(features), [features]);
const defaultCouncilFeatureName = useMemo(
() => getDefaultCouncilFeatureName(features),
[features]
);
const councilMeta = useMemo(() => getCouncilFilterMeta(features), [features]);
const crimeSeverityMetas = useMemo(
() =>
Object.fromEntries(
@ -364,6 +377,17 @@ export default memo(function Filters({
return { ...(backendFeature ?? tenureMeta), name, group: 'Neighbours' };
});
}, [filters, features, tenureMeta]);
const councilFilterItems = useMemo(() => {
return Object.keys(filters)
.filter(isCouncilFilterName)
.map((name) => {
const backendName = getCouncilFeatureName(name);
const backendFeature = backendName
? features.find((feature) => feature.name === backendName)
: undefined;
return { ...(backendFeature ?? councilMeta), name, group: 'Neighbours' };
});
}, [filters, features, councilMeta]);
const poiDistanceFilterItems = useMemo(() => {
return Object.keys(filters)
.filter(isPoiDistanceFilterName)
@ -388,6 +412,7 @@ export default memo(function Filters({
let insertedEthnicityFilter = false;
let insertedQualificationFilter = false;
let insertedTenureFilter = false;
let insertedCouncilFilter = false;
const insertedPoiFilters = new Set<PoiFilterName>();
const maybeInsertPoiFilter = (filterName: PoiFilterName | null) => {
if (
@ -430,7 +455,7 @@ export default memo(function Filters({
continue;
}
// "Serious crime" and "Minor crime" each fold their 7y/2y windows into one
// card with a period toggle (no variant dropdown a single feature each).
// card with a period toggle (no variant dropdown; a single feature each).
if (isCrimeSeverityFeatureName(feature.name)) {
maybeInsertCrimeSeverityFilter(getCrimeSeverityFilterName(feature.name));
continue;
@ -467,6 +492,16 @@ export default memo(function Filters({
}
continue;
}
// The two EPC council columns fold into one "Council housing" filter whose
// pill toggle adds the Census "% Social rent" as its "current" option.
// (% Social rent itself is handled by the Tenure branch above.)
if (isCouncilFeatureName(feature.name)) {
if (defaultCouncilFeatureName && !insertedCouncilFilter) {
result.push(councilMeta);
insertedCouncilFilter = true;
}
continue;
}
if (isPoiFilterFeatureName(feature.name)) {
maybeInsertPoiFilter(getPoiFilterName(feature.name));
continue;
@ -492,6 +527,8 @@ export default memo(function Filters({
qualificationMeta,
defaultTenureFeatureName,
tenureMeta,
defaultCouncilFeatureName,
councilMeta,
defaultPoiFilterFeatureNames,
poiFilterMetas,
]);
@ -503,6 +540,7 @@ export default memo(function Filters({
let insertedEthnicityFilters = false;
let insertedQualificationFilters = false;
let insertedTenureFilters = false;
let insertedCouncilFilters = false;
const insertedPoiFilters = new Set<PoiFilterName>();
const insertPoiFilterItems = (filterName: PoiFilterName | null) => {
if (!filterName || insertedPoiFilters.has(filterName)) return;
@ -572,6 +610,13 @@ export default memo(function Filters({
}
continue;
}
if (isCouncilFeatureName(feature.name)) {
if (!insertedCouncilFilters) {
result.push(...councilFilterItems);
insertedCouncilFilters = true;
}
continue;
}
if (isPoiFilterFeatureName(feature.name)) {
insertPoiFilterItems(getPoiFilterName(feature.name));
continue;
@ -590,6 +635,7 @@ export default memo(function Filters({
ethnicityFilterItems,
qualificationFilterItems,
tenureFilterItems,
councilFilterItems,
poiDistanceFilterItems,
]);
@ -627,6 +673,7 @@ export default memo(function Filters({
if (name === ETHNICITIES_FILTER_NAME) return ethnicityMeta.group ?? 'Neighbours';
if (name === QUALIFICATIONS_FILTER_NAME) return qualificationMeta.group ?? 'Neighbours';
if (name === TENURE_FILTER_NAME) return tenureMeta.group ?? 'Neighbours';
if (name === COUNCIL_FILTER_NAME) return councilMeta.group ?? 'Neighbours';
if (POI_FILTER_NAMES.includes(name as PoiFilterName)) {
return poiFilterMetas[name as PoiFilterName].group ?? null;
}
@ -637,6 +684,7 @@ export default memo(function Filters({
ethnicityMeta.group,
qualificationMeta.group,
tenureMeta.group,
councilMeta.group,
features,
poiFilterMetas,
crimeSeverityMetas,
@ -695,6 +743,12 @@ export default memo(function Filters({
onAddFilter(TENURE_FILTER_NAME);
return;
}
if (name === COUNCIL_FILTER_NAME) {
if (!defaultCouncilFeatureName) return;
queueActiveFilterScroll(COUNCIL_FILTER_NAME, getAddFilterGroupName(COUNCIL_FILTER_NAME));
onAddFilter(COUNCIL_FILTER_NAME);
return;
}
if (CRIME_SEVERITY_FILTER_NAMES.includes(name as CrimeSeverityFilterName)) {
const severityFilterName = name as CrimeSeverityFilterName;
if (!defaultCrimeSeverityFeatureNames[severityFilterName]) return;
@ -721,6 +775,7 @@ export default memo(function Filters({
defaultEthnicityFeatureName,
defaultQualificationFeatureName,
defaultTenureFeatureName,
defaultCouncilFeatureName,
defaultPoiFilterFeatureNames,
getAddFilterGroupName,
onAddFilter,
@ -883,6 +938,7 @@ export default memo(function Filters({
ethnicityMeta,
qualificationMeta,
tenureMeta,
councilMeta,
poiDistanceMeta,
transportDistanceMeta,
poiCount2KmMeta,
@ -896,6 +952,7 @@ export default memo(function Filters({
defaultEthnicityFeatureName={defaultEthnicityFeatureName}
defaultQualificationFeatureName={defaultQualificationFeatureName}
defaultTenureFeatureName={defaultTenureFeatureName}
defaultCouncilFeatureName={defaultCouncilFeatureName}
defaultPoiFilterFeatureNames={defaultPoiFilterFeatureNames}
openInfoFeature={openInfoFeature}
travelTimeEntries={travelTimeEntries}

View file

@ -1,7 +1,7 @@
import { useCallback, useRef, useEffect, useState, useMemo, memo } from 'react';
import type { CSSProperties } from 'react';
import { useTranslation } from 'react-i18next';
import { Map as MapGL, ScaleControl } from 'react-map-gl/maplibre';
import { Map as MapGL, NavigationControl, ScaleControl } from 'react-map-gl/maplibre';
import type { MapRef } from 'react-map-gl/maplibre';
import 'maplibre-gl/dist/maplibre-gl.css';
import type {
@ -355,7 +355,7 @@ export default memo(function Map({
// deck.gl runs interleaved, sharing this canvas's WebGL context. If the browser
// drops and restores that context, MapLibre rebuilds its own GL resources, but
// the deck instance keeps buffers/textures from the dead context the source of
// the deck instance keeps buffers/textures from the dead context, the source of
// the "bufferSubData: no buffer" / "bindTexture: deleted object" errors. Remount
// the overlay on restore so deck.gl rebuilds against the live context.
const [deckOverlayKey, setDeckOverlayKey] = useState(0);
@ -395,7 +395,7 @@ export default memo(function Map({
// Drive the camera imperatively rather than only through the controlled
// `viewState` prop. In controlled mode react-map-gl silently DROPS view
// state updates while the map is mid-movement _updateViewState writes to
// state updates while the map is mid-movement: _updateViewState writes to
// the real transform only `if (!map.isMoving())`. So a fly issued right
// after a scroll-zoom or pan, while inertia is still settling, was being
// ignored, which is why the jump/zoom only landed sometimes. stop() cancels
@ -516,7 +516,7 @@ export default memo(function Map({
AUTO_POI_CARD_MIN_DY,
MAX_AUTO_POI_CARDS
);
// viewState isn't read directly but drives map.project — recompute when the camera moves.
// viewState isn't read directly but drives map.project. Recompute when the camera moves.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [showAutoPoiCards, mapReady, visiblePois, dimensions, viewState]);
@ -550,6 +550,9 @@ export default memo(function Map({
zoom={viewState.zoom}
/>
<DeckOverlay key={deckOverlayKey} layers={layers} getTooltip={null} />
{!screenshotMode && (
<NavigationControl position="bottom-left" showZoom showCompass visualizePitch={false} />
)}
{!screenshotMode && <ScaleControl position="bottom-left" maxWidth={100} unit="metric" />}
</MapGL>
{basemap === 'satellite' && (

View file

@ -326,7 +326,7 @@ function PropertyCard({ property }: { property: Property }) {
}
type TimelineEvent =
| { kind: 'sale'; year: number; month: number; price: number; sortKey: number }
| { kind: 'sale'; year: number; month: number; price: number; isNew: boolean; sortKey: number }
| { kind: 'reno'; year: number; event: string; sortKey: number }
| { kind: 'tenure'; year: number; status: string; sortKey: number }
| { kind: 'built'; year: number; approximate: boolean; sortKey: number };
@ -344,6 +344,7 @@ export function buildTimelineEvents(property: Property): TimelineEvent[] {
year: sale.year,
month: sale.month,
price: sale.price,
isNew: sale.is_new,
sortKey: sale.year + (Math.max(sale.month, 1) - 1) / 12,
});
}
@ -425,6 +426,11 @@ function PropertyTimeline({ property }: { property: Property }) {
<span className="font-semibold text-teal-700 dark:text-teal-400">
£{formatNumber(event.price)}
</span>
{event.isNew && (
<span className="ml-1.5 inline-block rounded px-1 py-0.5 text-[10px] font-medium uppercase tracking-wide bg-teal-50 text-teal-700 dark:bg-teal-900/40 dark:text-teal-300">
{t('propertyCard.historyNewBuild')}
</span>
)}
<span className="ml-1.5 text-xs text-warm-500 dark:text-warm-400">
{formatYearMonth(event.year, event.month)}
</span>

View file

@ -65,7 +65,9 @@ interface DesktopMapPageProps {
actualListings: ActualListing[];
actualListingsEnabled: boolean;
actualListingsLoading: boolean;
onToggleActualListings?: () => void;
onToggleListingsPane?: () => void;
listingsPaneOpen: boolean;
listingsPane: ReactNode;
travelTimeEntries: TravelTimeEntry[];
densityLabel: string;
totalCount?: number;
@ -121,7 +123,9 @@ export function DesktopMapPage({
actualListings,
actualListingsEnabled,
actualListingsLoading,
onToggleActualListings,
onToggleListingsPane,
listingsPaneOpen,
listingsPane,
travelTimeEntries,
densityLabel,
totalCount,
@ -230,18 +234,14 @@ export function DesktopMapPage({
</Suspense>
</MapErrorBoundary>
<div className="absolute bottom-4 right-4 z-10 flex max-w-[calc(100%_-_2rem)] flex-row flex-wrap justify-end gap-2">
{onToggleActualListings && (
{onToggleListingsPane && (
<button
type="button"
onClick={onToggleActualListings}
aria-pressed={actualListingsEnabled}
onClick={onToggleListingsPane}
aria-expanded={listingsPaneOpen}
aria-busy={actualListingsLoading}
aria-label={
actualListingsEnabled ? t('map.actualListings.hide') : t('map.actualListings.show')
}
title={
actualListingsEnabled ? t('map.actualListings.hide') : t('map.actualListings.show')
}
aria-label={t('map.actualListings.label')}
title={t('map.actualListings.label')}
className={`flex items-center gap-2 rounded-lg bg-white px-3 py-2 shadow-lg dark:bg-warm-800 ${actualListingsEnabled ? 'text-red-600 hover:text-red-700 dark:text-red-400 dark:hover:text-red-300' : 'text-warm-500 hover:text-red-600 dark:text-warm-400 dark:hover:text-red-400'}`}
>
{actualListingsLoading ? (
@ -272,6 +272,11 @@ export function DesktopMapPage({
<span className="text-sm font-medium">{t('poiPane.pointsOfInterest')}</span>
</button>
</div>
{listingsPaneOpen && (
<div className="absolute bottom-16 right-4 z-10 flex max-h-[60vh] min-h-0 w-72 flex-col overflow-hidden rounded-lg border border-warm-200 bg-white shadow-xl dark:border-warm-700 dark:bg-warm-900">
{listingsPane}
</div>
)}
{overlayPaneOpen && (
<div className="absolute bottom-16 right-4 z-10 flex max-h-[60vh] min-h-0 w-80 flex-col overflow-hidden rounded-lg border border-warm-200 bg-white shadow-xl dark:border-warm-700 dark:bg-warm-900">
{overlayPane}

View file

@ -4,13 +4,18 @@ import type { FeatureMeta } from '../types';
type VariantDropdownLabelKey =
| 'filters.crimeType'
| 'filters.qualificationLevel'
| 'filters.tenureType';
| 'filters.tenureType'
| 'filters.councilType';
/** i18n keys (typed for the strict `t()`) usable as a window-toggle label. */
type VariantWindowLabelKey =
| 'filters.crimeWindow'
| 'filters.crimeWindow7y'
| 'filters.crimeWindow2y';
| 'filters.crimeWindow2y'
| 'filters.councilStatus'
| 'filters.councilCurrent'
| 'filters.councilEx'
| 'filters.councilBoth';
/** One option in a variant filter's secondary window/period toggle. */
export interface VariantWindowOption {