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'; } from '../../lib/seoLandingPages';
import { safeJsonLd } from '../../lib/json-ld'; import { safeJsonLd } from '../../lib/json-ld';
const PUBLIC_URL = 'https://perfect-postcode.co.uk';
const ProductShowcase = lazy(() => import('../home/ProductShowcase')); const ProductShowcase = lazy(() => import('../home/ProductShowcase'));
function ProductShowcaseFallback() { function ProductShowcaseFallback() {
@ -102,32 +101,11 @@ export default function SeoLandingPage({
}) { }) {
const { t, i18n } = useTranslation(); const { t, i18n } = useTranslation();
const page = getLocalizedSeoLandingPage(pageKey, i18n.language); const page = getLocalizedSeoLandingPage(pageKey, i18n.language);
const url = `${PUBLIC_URL}${page.path}`;
usePageMeta(page.metaTitle, page.metaDescription); usePageMeta(page.metaTitle, page.metaDescription);
return ( return (
<main className="flex-1 overflow-y-auto bg-warm-50 text-navy-950 dark:bg-navy-950 dark:text-warm-100"> <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} /> <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"> <section className="bg-navy-950 text-white">
<div className="mx-auto max-w-6xl px-6 py-16 md:px-10 md:py-20"> <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"> <div className="space-y-8">
{FAQ_SECTIONS.map((section) => ( {FAQ_SECTIONS.map((section) => (
<div key={section.title}> <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} {section.title}
</h3> </h2>
<div className="space-y-3"> <div className="space-y-3">
{section.items.map((item, index) => ( {section.items.map((item, index) => (
<FAQItemCard key={index} question={item.question} answer={item.answer} /> <FAQItemCard key={index} question={item.question} answer={item.answer} />

View file

@ -32,7 +32,7 @@ export const TERMS: LegalDoc = {
{ {
heading: '1. The service', heading: '1. The service',
paragraphs: [ 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.', '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', heading: '5. Data accuracy',
paragraphs: [ 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.', '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', heading: '10. Governing law and contact',
paragraphs: [ 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 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 process payments and keep the records tax law requires (legal obligation).',
'To answer support requests (performance of contract).', '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).', '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:', 'We do not sell personal data. We use a small number of processors to run the service:',
], ],
bullets: [ bullets: [
'Stripe payment processing.', '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.', '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.', '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. // One selector drives every value and comparison in this group.
const [crimeWindow, setCrimeWindow] = useState<CrimeWindow>('7y'); 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 ?? []; const rollupCards = STACKED_GROUPS.Crime ?? [];

View file

@ -54,7 +54,7 @@ export function compactHistogramLabel(
const firstBoundary = Math.ceil(p1); const firstBoundary = Math.ceil(p1);
// This outlier bin holds values strictly below p1. When p1 <= 0 there are no // 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 // (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 ''; if (firstBoundary <= 0) return '';
return firstBoundary === 1 ? '0' : `<${firstBoundary.toLocaleString()}`; return firstBoundary === 1 ? '0' : `<${firstBoundary.toLocaleString()}`;
} }

View file

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

View file

@ -1,7 +1,7 @@
import { useCallback, useRef, useEffect, useState, useMemo, memo } from 'react'; import { useCallback, useRef, useEffect, useState, useMemo, memo } from 'react';
import type { CSSProperties } from 'react'; import type { CSSProperties } from 'react';
import { useTranslation } from 'react-i18next'; 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 type { MapRef } from 'react-map-gl/maplibre';
import 'maplibre-gl/dist/maplibre-gl.css'; import 'maplibre-gl/dist/maplibre-gl.css';
import type { import type {
@ -355,7 +355,7 @@ export default memo(function Map({
// deck.gl runs interleaved, sharing this canvas's WebGL context. If the browser // 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 // 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 "bufferSubData: no buffer" / "bindTexture: deleted object" errors. Remount
// the overlay on restore so deck.gl rebuilds against the live context. // the overlay on restore so deck.gl rebuilds against the live context.
const [deckOverlayKey, setDeckOverlayKey] = useState(0); const [deckOverlayKey, setDeckOverlayKey] = useState(0);
@ -395,7 +395,7 @@ export default memo(function Map({
// Drive the camera imperatively rather than only through the controlled // Drive the camera imperatively rather than only through the controlled
// `viewState` prop. In controlled mode react-map-gl silently DROPS view // `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 // 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 // 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 // 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, AUTO_POI_CARD_MIN_DY,
MAX_AUTO_POI_CARDS 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 // eslint-disable-next-line react-hooks/exhaustive-deps
}, [showAutoPoiCards, mapReady, visiblePois, dimensions, viewState]); }, [showAutoPoiCards, mapReady, visiblePois, dimensions, viewState]);
@ -550,6 +550,9 @@ export default memo(function Map({
zoom={viewState.zoom} zoom={viewState.zoom}
/> />
<DeckOverlay key={deckOverlayKey} layers={layers} getTooltip={null} /> <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" />} {!screenshotMode && <ScaleControl position="bottom-left" maxWidth={100} unit="metric" />}
</MapGL> </MapGL>
{basemap === 'satellite' && ( {basemap === 'satellite' && (

View file

@ -326,7 +326,7 @@ function PropertyCard({ property }: { property: Property }) {
} }
type TimelineEvent = 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: 'reno'; year: number; event: string; sortKey: number }
| { kind: 'tenure'; year: number; status: string; sortKey: number } | { kind: 'tenure'; year: number; status: string; sortKey: number }
| { kind: 'built'; year: number; approximate: boolean; sortKey: number }; | { kind: 'built'; year: number; approximate: boolean; sortKey: number };
@ -344,6 +344,7 @@ export function buildTimelineEvents(property: Property): TimelineEvent[] {
year: sale.year, year: sale.year,
month: sale.month, month: sale.month,
price: sale.price, price: sale.price,
isNew: sale.is_new,
sortKey: sale.year + (Math.max(sale.month, 1) - 1) / 12, 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"> <span className="font-semibold text-teal-700 dark:text-teal-400">
£{formatNumber(event.price)} £{formatNumber(event.price)}
</span> </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"> <span className="ml-1.5 text-xs text-warm-500 dark:text-warm-400">
{formatYearMonth(event.year, event.month)} {formatYearMonth(event.year, event.month)}
</span> </span>

View file

@ -65,7 +65,9 @@ interface DesktopMapPageProps {
actualListings: ActualListing[]; actualListings: ActualListing[];
actualListingsEnabled: boolean; actualListingsEnabled: boolean;
actualListingsLoading: boolean; actualListingsLoading: boolean;
onToggleActualListings?: () => void; onToggleListingsPane?: () => void;
listingsPaneOpen: boolean;
listingsPane: ReactNode;
travelTimeEntries: TravelTimeEntry[]; travelTimeEntries: TravelTimeEntry[];
densityLabel: string; densityLabel: string;
totalCount?: number; totalCount?: number;
@ -121,7 +123,9 @@ export function DesktopMapPage({
actualListings, actualListings,
actualListingsEnabled, actualListingsEnabled,
actualListingsLoading, actualListingsLoading,
onToggleActualListings, onToggleListingsPane,
listingsPaneOpen,
listingsPane,
travelTimeEntries, travelTimeEntries,
densityLabel, densityLabel,
totalCount, totalCount,
@ -230,18 +234,14 @@ export function DesktopMapPage({
</Suspense> </Suspense>
</MapErrorBoundary> </MapErrorBoundary>
<div className="absolute bottom-4 right-4 z-10 flex max-w-[calc(100%_-_2rem)] flex-row flex-wrap justify-end gap-2"> <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 <button
type="button" type="button"
onClick={onToggleActualListings} onClick={onToggleListingsPane}
aria-pressed={actualListingsEnabled} aria-expanded={listingsPaneOpen}
aria-busy={actualListingsLoading} aria-busy={actualListingsLoading}
aria-label={ aria-label={t('map.actualListings.label')}
actualListingsEnabled ? t('map.actualListings.hide') : t('map.actualListings.show') title={t('map.actualListings.label')}
}
title={
actualListingsEnabled ? t('map.actualListings.hide') : t('map.actualListings.show')
}
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'}`} 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 ? ( {actualListingsLoading ? (
@ -272,6 +272,11 @@ export function DesktopMapPage({
<span className="text-sm font-medium">{t('poiPane.pointsOfInterest')}</span> <span className="text-sm font-medium">{t('poiPane.pointsOfInterest')}</span>
</button> </button>
</div> </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 && ( {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"> <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} {overlayPane}

View file

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

55
growth/README.md Normal file
View file

@ -0,0 +1,55 @@
# Growth: operating system
The single engine: **compute one finding → render it four ways → point everything at the free map.**
One dashboard URL (filters + lat/lon/zoom) becomes the live map you screen-record, the OG/unfurl
card, the prerendered SEO page, and the ≤3-filter deep-link CTA. No asset is built twice.
This folder holds the **founder-action** collateral (the things only you can send/post). The code that
generates the findings, pages, cards, and videos lives in the repo proper (see `analysis/` and the
SEO/video pipelines). Numbers below in `{{DOUBLE_BRACES}}` are filled from the compute-harness output.
## Two-track cadence (~24 hrs/week)
| Track | Effort | Skippable? |
|---|---|---|
| **Page batch** (Claude Code computes a finding → pages deploy; you spot-check ~5) | ~30 min / fortnight | **Never**: this is the floor |
| **Video** (Claude Code builds the storyboard/render/metadata; you sanity-check + optional voice hook + upload) | ~1.52 hrs / video, target 1 per 12 weeks | Yes, booster only |
**Bad-week rule: always ship the page batch, skip the video, never the reverse.**
## The anti-quit rule (pre-commit this in writing)
> Keep going for **6 months** as long as Search Console **impressions** AND **free-map opens** grow
> month-over-month, *regardless of revenue.* The #1 failure mode is quitting at month 23, right
> before SEO + the video back-catalogue start to pay.
## Honest milestones
- **Month 1:** small but non-zero: 13 sales from the launch/email push, ~10 free-map power users, 12 videos at 100300 views, first GSC impressions.
- **Month 3:** ~100+ engaged free users, ~510 paying, first long-tail pages ranking.
- **Month 69:** **~25 paying users** (the honest anchor); organic search becomes a real channel.
## Metrics that move *before* revenue (watch the slope, not weekly noise)
1. GSC impressions + which postcode pages surface (impressions up while clicks flat = warming up, **working**)
2. Map deep-link clicks per video/page (the only conversion metric that matters at this ARPU)
3. 3-filter cap-hit rate among engaged sessions (your one purchase-intent signal)
4. Free-map opens + % of sessions applying ≥1 filter
5. YouTube impressions, CTR (>4% healthy), avg-view-duration (>40% healthy)
6. Backlinks/referral hits from newsletter archives + Reach articles (the DR-seed payoff)
## Files here
- `launch-show-hn.md`: one-shot Show HN + r/InternetIsBeautiful submission (titles + maker comment)
- `outreach-emails.md`: ~1215 personalized newsletter/podcast/journalist emails (master template + per-target)
- `regional-tables.md`: Reach plc / National World local-desk pitches (template + table spec)
- `qwoted-reactive-pr.md`: near-passive reactive-PR profile bio + canned stock answers
## Defensibility rules (apply to EVERY public artifact, non-negotiable)
1. Aggregate to **postcode unit / sector** only; never republish address-level rows (Royal Mail / OS rights).
2. Attribution line on every table/chart: **"Contains HM Land Registry data © Crown copyright and database right {{YEAR}}. Licensed under the Open Government Licence v3.0."**
3. Exclude thin samples (min sold count) and pre-2012-only EPCs (floor area is null/sparse, which would fabricate twins).
4. Label every derived price **"estimate"**, never "valuation".
5. Present crime/area numbers **with the year**; let the data speak, never editorialise a real community as "bad".
6. Scope every title/post to **England** or a named city-region (avoid Scotland/Wales comment noise).

82
growth/launch-show-hn.md Normal file
View file

@ -0,0 +1,82 @@
# One-shot launch: Show HN + r/InternetIsBeautiful
**Goal:** NOT the day-one spike (HN is US-skewed for an England-only tool; outbound links are nofollow).
The real payoff is **25 aggregator/reblog backlinks that start your domain-authority clock**, plus
13 UK referral relationships. Treat it as a backlink seed, then walk away.
**Rules of engagement**
- Post **TueThu, ~14:0017:00 UK** (≈912am US Eastern). One platform each, same week.
- The link target is a **cheaper-twin STORY page** (e.g. `{{FLAGSHIP_TWIN_PAGE_URL}}`), **never the cold map** or a bare filter UI.
- Be present ~23h to answer data-method questions, then stop.
- Post the **maker comment first**, immediately after submitting.
- Both qualify only because the map has **no sign-up wall**; keep it that way for launch day.
---
## Show HN
**Title** (HN dislikes hype; lead with the mechanism + "open data"):
```
Show HN: Perfect Postcode, ranking every England postcode by what £1 of housing buys
```
Alternatives if you want to A/B in your head:
- `Show HN: I joined Land Registry, EPC, Ofsted and crime data to find England's "cheaper twin" postcodes`
- `Show HN: A no-signup map that ranks all of England by price per m², schools, commute and crime`
**Maker comment** (post as the first comment):
```
Maker here. Perfect Postcode ranks every postcode in England by what each £ actually buys
(£ per m² of floor space, Ofsted school catchments, commute time, crime, broadband, noise)
instead of by area reputation. It's a single cross-join of official open data (HM Land Registry
price-paid, EPC floor areas, Ofsted, DfT, ONS, Police.uk) over ~13M sales.
The thing I find most fun: "cheaper twins", pairs of adjacent postcodes that share a station,
a school catchment and a build era but sell tens of thousands apart because one name got bid up.
Example: {{FLAGSHIP_TWIN_ONE_LINER e.g. "Angel N1 vs Holloway N7: same line, overlapping
catchment, ~30% less per m²"}}.
No sign-up, no card. The map's free to explore: {{FLAGSHIP_TWIN_PAGE_URL}}
Honest caveats: England only for now (Scotland/Wales need different source datasets); EPC floor-area
coverage is sparse before 2012 so I exclude pre-2012-only properties from the £/m² figures; "estimated"
prices are comparison estimates, not valuations. Happy to go into the £/m² derivation, postcode-boundary
handling or the EPC gaps. Ask away.
```
Be ready for these HN questions (have a one-paragraph answer each):
- How is £/m² derived, and how do you handle properties with no recorded floor area?
- Postcode vs postcode-sector boundaries: what granularity are the "twins" at?
- What's the business model? *(Answer plainly: free map, one-time lifetime unlock for >3 filters, no subscription. Do NOT lead with this.)*
- Data licensing. *(OGL v3.0 in aggregate; you never expose address-level rows.)*
---
## r/InternetIsBeautiful
Read the subreddit rules the day you post (they change). It must read as a genuinely interesting
*thing to explore*, not an ad; the no-signup map is what makes it allowed.
**Title** (their format is a plain description of the site):
```
Every postcode in England, ranked by price-per-m², schools, commute and crime (no signup)
```
**First comment** (shorter, less "founder", more "here's a cool thing"):
```
Built this from official open data (Land Registry sold prices + EPC floor areas + Ofsted + DfT +
Police.uk). The bit people seem to like is "cheaper twins": two postcodes next to each other with the
same station and school catchment, priced thousands apart just because of the name. England-only for
now. No account needed; link goes straight to a worked example you can poke at.
```
---
## After the launch (same day, 10 minutes)
- Note every domain that reblogs/aggregates the HN post (e.g. hckrnews, Hacker News Daily, niche newsletters). Those are your seed backlinks; log them in your metrics sheet.
- Do **not** repost to other subreddits in a blast. One IIB post, done.

86
growth/outreach-emails.md Normal file
View file

@ -0,0 +1,86 @@
# Outreach: newsletters / podcasts / journalists (one-shot batch)
**Send once. Not a sequence. ~1215 emails, each personalised, from your own address.**
The pitch is the **finding**, never the product. You're offering a free, citable, ready-made data
asset (table + CSV + chart + a methodology box). Give **exactly one** outlet a genuine first-look
window; tell them so (it makes the exclusive worth covering).
**What you attach / link to every time**
- A clean comparison table (top "name premiums" / cheaper twins, national + their-audience cut)
- The same data as CSV
- One chart image (the OG card render works)
- A link to the live worked-example page: `https://perfect-postcode.co.uk/{{FLAGSHIP_TWIN_SLUG}}`
- The attribution line: *"Contains HM Land Registry data © Crown copyright and database right {{YEAR}}, OGL v3.0."*
**Numbers to fill from the harness:** `{{NATIONAL_HEADLINE_STAT}}`, `{{BIGGEST_TWIN_GAP}}`,
`{{TOP5_TWINS_TABLE}}`, `{{AUDIENCE_SPECIFIC_CUT}}`.
---
## Master template
**Subject:** `Data: the "cheaper twin" postcodes that share a school + station, {{BIGGEST_TWIN_GAP}} apart`
> Hi {{FirstName}},
>
> I built a dataset that joins HM Land Registry sold prices with EPC floor areas, Ofsted catchments,
> DfT commute times and Police.uk crime for **every postcode in England**, and it surfaces something
> I think your {{readers/listeners}} would find useful: **"cheaper twins."**
>
> These are pairs of neighbouring postcodes that share the same station, the same school catchment and
> the same era of housing, yet sell **tens of thousands apart**, purely because one name got bid up.
> Example: {{FLAGSHIP_TWIN_ONE_LINER}}. Nationally, the biggest gap I found is **{{BIGGEST_TWIN_GAP}}**.
>
> It's all official open data (OGL), aggregated to postcode level. Happy to send you a **ready-to-use
> table + CSV + chart**, including a cut specific to {{their audience/region}}. No ask in return; credit
> to *Perfect Postcode* with a link is plenty. I can give you a **first look before I share it more widely**
> if it's useful.
>
> Want me to send it over?
>
> {{Your name}}
> perfect-postcode.co.uk · {{your email}}
Keep it under ~150 words. No attachments on the *first* email (deliverability); offer, then send on reply.
---
## Targets (personalise the hook line per outlet)
### Consumer money / personal finance
1. **Andy Webb, Be Clever With Your Cash** (blog + newsletter + YouTube). Hook: the "are you overpaying for the name?" angle is squarely his money-saving lens. Consumer-friendly cut.
2. **Damien Fahy, Money to the Masses** (site + podcast). Hook: offer a podcast-ready segment ("how to find the cheaper twin of any postcode") + the data table.
3. **Finimize** (newsletter, large UK retail-finance audience). Hook: one punchy chart + the national headline stat; they run short data-led items.
4. **iNews / Metro money desk** (consumer national). Hook: "the postcode name premium", relatable, shareable, map-friendly.
### Property-buyer / homeowner
5. **HomeOwners Alliance** (consumer property advice + newsletter). Hook: pre-viewing research angle; their audience is active buyers.
6. **Charlie Lamdin, Moving Home with Charlie** (YouTube + BestAgent). Hook: he covers market value + buyer strategy daily; offer him the data for a "cheaper twin" episode (he'll likely screen-share the map).
7. **Rob Bence & Rob Dix, The Property Podcast / Property Hub** (investor-leaning, large). Hook: the £/m² value-gap framing for investors hunting under-priced areas; offer a data segment + regional cuts.
### National money desks (give ONE of these the exclusive first-look)
8. **Helen Crane, This is Money** ("Crane on the Case" / property). Hook: strong property-data appetite; the cleanest national-exclusive target.
9. **Ed Magnus, This is Money.** Hook: writes the data-led property explainers; alternative/second TiM contact.
10. **The Telegraph Money / The Times Money property desk** (one named property reporter each). Hook: "name premium" maps to their readership well, but paywalled, lower referral; pitch only if you have a name.
### Trade press (fast pickup, good backlinks, lower direct traffic)
11. **PropertyWire** (property trade news). Hook: ready-made data story, they publish data fast.
12. **The Negotiator / Estate Agent Today** (agent trade). Hook: "what buyers are about to start asking you about £/m²" angle.
13. **Property118** (landlord/investor community). Hook: value-gap data for portfolio buyers.
### Stretch (one shot, low odds, high payoff)
14. **MoneySavingExpert news team** (not the forum, the editorial desk). Hook: a genuinely novel free consumer data tool; they occasionally cover these. No forum self-promo.
15. **BBC / regional data journalism**: only if a finding is genuinely striking nationally; otherwise leave regional to `regional-tables.md`.
---
## Sequencing (do it in ~2 hours, once)
1. Pick your **one exclusive** (recommend Helen Crane / This is Money). Email them first, say "first look, ~5 days before I share wider."
2. Wait for a yes/no or ~5 days.
3. Send the rest in one sitting. Personalise only the **hook line**; keep the body identical.
4. Reply fast to anyone who bites; send the table+CSV+chart on reply.
5. Log every pickup (URL + do-follow?) in your metrics sheet; these backlinks are the real prize.
**Do not** chase more than one polite follow-up. Do not BCC a blast (kills deliverability and goodwill).

View file

@ -0,0 +1,39 @@
# Near-passive reactive PR
Set up once, then answer only on a perfect match (~10 min/week, fully skippable). Journalists come to
*you* for a quote/stat; each published answer is a backlink + credibility with a property/money reporter.
## Where
- **Qwoted** (free profile; many UK money/property reporters source quotes here), your primary channel.
- **#journorequest** / **#PRrequest** on X and Bluesky: monitor a saved search; UK property/money requests appear daily.
- **ResponseSource / Featurable**: optional UK alternatives. (Skip Featured.com, AI-spam heavy.)
## Profile bio (≈60 words)
> {{Your name}}, founder of Perfect Postcode, a tool that ranks every postcode in England by value
> for money (£ per m², schools, commute, crime) using official open data (HM Land Registry, EPC, Ofsted,
> DfT, Police.uk). I can provide data and comment on house prices, price-per-m², area "name premiums,"
> school-catchment effects, commute-vs-price trade-offs and where value sits across England.
**Expertise tags:** property, house prices, first-time buyers, cost of living, data/open data, England property market.
## Canned stock answers (pre-write these; fill numbers from the harness)
Keep each to 24 sentences, lead with a number, always offer the underlying table.
1. **"Cheapest areas / best value for money"** →
*"On £ per m² of floor space, the best value in {{region}} right now is {{CITY_CHEAPEST_SECTOR}} at ~£{{X}}/m², versus £{{Y}}/m² in {{nearby pricey area}} for near-identical homes. Happy to share the full ranked table (Land Registry / EPC, OGL)."*
2. **"Does a good school add to house prices?"** →
*"Crossing into an Outstanding-rated catchment adds roughly {{SCHOOL_PREMIUM_PCT}}% to £/m² for an otherwise-matched home, about £{{£}} on a typical {{size}} m² house in {{area}}."*
3. **"Commuter towns / value near a station"** →
*"Every extra ~10 minutes of train time to {{hub}} knocks about £{{COMMUTE_STEP}} off the price of the same flat; the value sweet-spot is the {{X}}{{Y}} minute band."*
4. **"Where are the hidden-gem / up-and-coming areas?"** →
*"I look for 'cheaper twins', postcodes next to a pricier name that share its station and school catchment but cost less per m². Example: {{FLAGSHIP_TWIN_ONE_LINER}}."*
5. **"Energy bills / EPC and running costs by area"** →
*"The postcodes with the oldest housing stock carry a hidden ~£{{EPC_DELTA}}/year heating gap for the same-size home. Here's the map of where EPC bands are worst."*
## Etiquette
- Reply only when you genuinely fit the request and can hit the deadline.
- Give the reporter something usable immediately (a number + the offer of the table), not a sales pitch.
- Always include the OGL attribution line. Never quote address-level data.
- Ask for a credit + link to perfect-postcode.co.uk; most will give it.

76
growth/regional-tables.md Normal file
View file

@ -0,0 +1,76 @@
# Regional press: Reach plc / National World local desks (one-shot)
Local desks publish ready-made local data fast, link back (often do-follow), and send
**geo-targeted England readers**, exactly your buyers. This is the same one-time email muscle as
`outreach-emails.md`, ~2 hours for the whole set. Each pickup is a durable archive backlink that
helps lift the domain out of the SEO sandbox.
**The angle (local, not national):** "The cheapest postcode for your money in {region}, and the
pricier next-door 'twin' you'd be mad to pay for." Every story links to that city's live page
(you already have Birmingham / Manchester / Bristol pages; add the others as the harness produces them).
**Numbers to fill from the harness, per city:** `{{CITY_CHEAPEST_SECTOR}}`,
`{{CITY_BEST_VALUE_PER_SQM}}`, `{{CITY_TWIN_PAIR + gap}}`, `{{CITY_TOP5_TABLE}}`.
---
## What you send each desk
1. A short pitch email (template below).
2. A **local table**: the 510 best-value postcodes in that city-region by est. £/m², each with its
pricier "twin" and the % gap, schools + commute columns alongside.
3. The CSV.
4. A link to the live local page: `https://perfect-postcode.co.uk/property-search/{{city}}`.
5. Attribution line (Land Registry / OGL).
---
## Pitch template
**Subject:** `{{City}} data: the cheapest postcodes for your money, and their overpriced twins`
> Hi {{FirstName / Newsdesk}},
>
> I've crunched HM Land Registry sold prices + floor areas for **every postcode in {{region}}** to find
> where you get the most home for your money, and the striking bit: **"cheaper twins."** {{CITY_TWIN_ONE_LINER,
> e.g. "Burnage is ~£X00/m² cheaper than next-door Didsbury despite sharing the same station and a 'Good'
> secondary catchment."}}
>
> I've put together a **ready-to-publish {{City}} table** (best-value postcodes + their pricier twin + £/m²
> gap), with the CSV and a chart, all from official open data. Free to use with a credit/link to
> *Perfect Postcode*. Want me to send it over?
>
> {{Your name}} · perfect-postcode.co.uk
---
## Desks to target (find the property/news reporter or use the newsdesk tip line)
**Reach plc regional titles** (match to your live/priority city pages first):
- BirminghamLive: Birmingham / West Midlands
- Manchester Evening News: Greater Manchester
- Liverpool Echo: Merseyside
- Bristol Live: Bristol / South West
- Leeds Live: West Yorkshire
- ChronicleLive: Newcastle / North East
- NottinghamshireLive, DerbyshireLive, LeicestershireLive: East Midlands
- MyLondon: London (huge, competitive; lead with a borough-level twin)
- GloucestershireLive, KentLive, HullLive: regional long tail
**National World titles** (also syndicate local property data aggressively):
- The Yorkshire Post / Sheffield Star
- Manchester World, Birmingham World, NationalWorld (national data desk)
**How to find the contact (5 min each):** search `site:birminghamlive.co.uk property reporter` or look at
recent local property-price articles and email the bylined journalist directly; fall back to the
newsdesk/tips address in the site footer.
---
## Rules
- One tailored table **per city**; never paste the same national table to a local desk.
- Lead with **their** city's number in the subject line.
- Stagger sends so you can answer replies; this is a once-off, not a recurring beat.
- Log pickups + whether the backlink is do-follow.
- Keep it England/region-scoped; don't let a national framing pull in Scotland/Wales noise.