This commit is contained in:
Andras Schmelczer 2026-05-12 22:13:07 +01:00
parent 11711c57e6
commit 81a16f543c
21 changed files with 29072 additions and 1913 deletions

View file

@ -16,10 +16,19 @@ const HOME_SECTION_HEADING_CLASS =
const HOME_BODY_CLASS = 'text-base leading-relaxed text-warm-600 dark:text-warm-400';
const HOME_PRIMARY_BUTTON_CLASS =
'bg-coral-500 text-white rounded-lg font-semibold hover:bg-coral-600 transition-colors text-base shadow-lg shadow-coral-500/25 text-center';
const PRODUCT_DEMO_VIDEO_SRC = '/video/recording.mp4';
const PRODUCT_DEMO_POSTER_SRC = '/video/recording.jpg';
const PRODUCT_DEMO_VIDEO_BY_LANGUAGE: Record<string, string> = {
en: 'recording',
de: 'recording-de',
zh: 'recording-zh',
hi: 'recording-hi',
};
const PRODUCT_DEMO_SECTION_ID = 'product-demo-video';
function getProductDemoSlug(language: string | undefined): string {
const code = language?.toLowerCase().split('-')[0] ?? 'en';
return PRODUCT_DEMO_VIDEO_BY_LANGUAGE[code] ?? PRODUCT_DEMO_VIDEO_BY_LANGUAGE.en;
}
function highlightBrandText(text: string) {
const parts = text.split(BRAND_NAME);
if (parts.length === 1) return text;
@ -37,11 +46,26 @@ function highlightBrandText(text: string) {
}
function ProductDemoVideo() {
const { t } = useTranslation();
const { t, i18n } = useTranslation();
const sectionRef = useRef<HTMLDivElement | null>(null);
const videoRef = useRef<HTMLVideoElement | null>(null);
const currentVideoSrcRef = useRef<string | null>(null);
const [shouldLoadVideo, setShouldLoadVideo] = useState(false);
const [isVideoPlaying, setIsVideoPlaying] = useState(false);
const productDemoSlug = getProductDemoSlug(i18n.language);
const productDemoVideoSrc = `/video/${productDemoSlug}.mp4`;
const productDemoPosterSrc = `/video/${productDemoSlug}.jpg`;
useEffect(() => {
if (currentVideoSrcRef.current === productDemoVideoSrc) return;
currentVideoSrcRef.current = productDemoVideoSrc;
setIsVideoPlaying(false);
const video = videoRef.current;
if (!video || !shouldLoadVideo) return;
video.pause();
video.load();
}, [productDemoVideoSrc, shouldLoadVideo]);
useEffect(() => {
const section = sectionRef.current;
@ -70,8 +94,8 @@ function ProductDemoVideo() {
setShouldLoadVideo(true);
if (!video) return;
if (!video.getAttribute('src')) {
video.src = PRODUCT_DEMO_VIDEO_SRC;
if (video.getAttribute('src') !== productDemoVideoSrc) {
video.src = productDemoVideoSrc;
video.load();
}
@ -86,11 +110,14 @@ function ProductDemoVideo() {
ref={sectionRef}
className={`${HOME_SECTION_CONTAINER_CLASS} pt-8 md:pt-12 pb-2`}
>
<h2 className={`${HOME_SECTION_HEADING_CLASS} mb-5 text-center`}>
{t('home.productDemoLabel')}
</h2>
<div className="relative overflow-hidden rounded-lg border border-warm-200 bg-navy-950 shadow-sm dark:border-warm-700">
<video
ref={videoRef}
src={shouldLoadVideo ? PRODUCT_DEMO_VIDEO_SRC : undefined}
poster={PRODUCT_DEMO_POSTER_SRC}
src={shouldLoadVideo ? productDemoVideoSrc : undefined}
poster={productDemoPosterSrc}
controls
playsInline
preload={shouldLoadVideo ? 'metadata' : 'none'}
@ -349,6 +376,40 @@ export default function HomePage({
</div>
</div>
{/* Street-level detail */}
<div className={`${HOME_SECTION_CONTAINER_CLASS} pt-10 md:pt-16 pb-2`}>
<div className="grid gap-6 md:grid-cols-[minmax(0,0.9fr)_minmax(0,1.1fr)] md:items-start">
<div>
<h2 className={`${HOME_SECTION_HEADING_CLASS} mb-4`}>{t('home.streetTitle')}</h2>
<p className={`${HOME_BODY_CLASS} max-w-2xl`}>{t('home.streetIntro')}</p>
</div>
<div className="grid gap-4 sm:grid-cols-2">
{[
{
title: t('home.streetCard1Title'),
body: t('home.streetCard1Body'),
},
{
title: t('home.streetCard2Title'),
body: t('home.streetCard2Body'),
},
].map((item) => (
<div
key={item.title}
className="rounded-lg border border-warm-200 bg-white/90 p-5 shadow-sm dark:border-warm-700 dark:bg-warm-800/90"
>
<h3 className="text-base font-bold text-navy-950 dark:text-warm-100">
{item.title}
</h3>
<p className="mt-3 text-sm leading-relaxed text-warm-600 dark:text-warm-400">
{item.body}
</p>
</div>
))}
</div>
</div>
</div>
{/* Comparison table */}
<div
id="how-it-works"
@ -424,16 +485,25 @@ export default function HomePage({
</div>
)}
</td>
{[row.postcode, row.guides].map((has, j) => (
<td
key={j}
className={`px-1.5 md:px-3 py-2.5 md:py-3.5 text-center text-base ${has ? 'text-green-500' : 'text-red-500'}`}
>
{has ? '\u2713' : '\u2717'}
</td>
))}
<td className="px-1.5 md:px-3 py-2.5 md:py-3.5 text-center text-base text-green-500 bg-teal-50 dark:bg-teal-900/30">
&#x2713;
{[row.postcode, row.guides].map((has, j) => {
const statusLabel = has ? 'Yes' : 'No';
return (
<td
key={j}
aria-label={statusLabel}
className={`px-1.5 md:px-3 py-2.5 md:py-3.5 text-center text-base ${has ? 'text-green-500' : 'text-red-500'}`}
>
<span aria-hidden="true">{has ? '\u2713' : '\u2717'}</span>
<span className="sr-only">{statusLabel}</span>
</td>
);
})}
<td
aria-label="Yes"
className="px-1.5 md:px-3 py-2.5 md:py-3.5 text-center text-base text-green-500 bg-teal-50 dark:bg-teal-900/30"
>
<span aria-hidden="true">&#x2713;</span>
<span className="sr-only">Yes</span>
</td>
</tr>
))}

View file

@ -626,6 +626,9 @@ function EnglandHexMapScreen({ isActive }: { isActive: boolean }) {
value: SHOWCASE_MAP_TOTAL_COUNT.toLocaleString(),
})}
</div>
<div className="mt-2 text-[10px] font-bold uppercase tracking-wide text-warm-400">
{t('home.showcaseStep2Sources')}
</div>
</div>
</div>
);

View file

@ -91,11 +91,13 @@ export default function FeatureBrowser({
search.toLowerCase()
));
// Ensure "Transport" group exists first when travel modes should be shown.
// Keep "Transport" first because journey and transport proximity controls belong together.
const mergedGrouped = useMemo(() => {
if (!showTravelModes) return grouped;
if (grouped.some((g) => g.name === 'Transport')) return grouped;
return [{ name: 'Transport', features: [] }, ...grouped];
const transportGroup = grouped.find((g) => g.name === 'Transport');
const otherGroups = grouped.filter((g) => g.name !== 'Transport');
if (transportGroup) return [transportGroup, ...otherGroups];
if (showTravelModes) return [{ name: 'Transport', features: [] }, ...otherGroups];
return otherGroups;
}, [grouped, showTravelModes]);
return (

View file

@ -81,6 +81,7 @@ export function SliderLabels({
isAtMax,
raw,
feature,
showUnit,
onValueChange,
}: {
min: number;
@ -91,6 +92,7 @@ export function SliderLabels({
isAtMax?: boolean;
raw?: boolean;
feature?: FeatureMeta;
showUnit?: boolean;
onValueChange?: (v: [number, number]) => void;
}) {
const { t } = useTranslation();
@ -98,7 +100,10 @@ export function SliderLabels({
const leftPct = Math.max(0, Math.min(100, ((value[0] - min) / range) * 100));
const rightPct = Math.max(0, Math.min(100, ((value[1] - min) / range) * 100));
const labels = displayValues || value;
const labelFormat = feature?.suffix === '%' ? { raw, suffix: feature.suffix } : raw;
const shouldShowUnit = Boolean(feature && (showUnit || feature.suffix === '%'));
const labelFormat = shouldShowUnit
? { raw, prefix: feature?.prefix, suffix: feature?.suffix }
: raw;
const minLabel = isAtMin ? t('common.min') : formatFilterValue(labels[0], labelFormat);
const maxLabel = isAtMax ? t('common.max') : formatFilterValue(labels[1], labelFormat);

View file

@ -6,8 +6,8 @@
<meta name="theme-color" content="#fafaf9" media="(prefers-color-scheme: light)" />
<meta name="theme-color" content="#0a0e1a" media="(prefers-color-scheme: dark)" />
<meta name="referrer" content="no-referrer" />
<title>Perfect Postcode - Find where to buy before browsing listings</title>
<meta name="description" content="Search every postcode by budget, commute, schools, safety, noise, broadband, prices and more. Build a better home-buying shortlist before viewings." />
<title>Find the best postcodes and areas to live in England | Perfect Postcode</title>
<meta name="description" content="Discover where to live by comparing England postcodes by budget, commute, schools, crime, noise, broadband, property prices and local amenities before viewing homes." />
<meta name="x-og-placeholder" content="__PERFECT_POSTCODE_OG_TAGS__" />
<script>
(function() {

View file

@ -9,6 +9,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';
@ -225,13 +226,13 @@ describe('url-state', () => {
it('round-trips repeated amenity distance filters with dedicated URL params', () => {
const park = createPoiDistanceFilterKey('Distance to nearest park (km)', 3);
const tesco = createPoiDistanceFilterKey('Distance to nearest Tesco (km)', 4);
const grocery = createPoiDistanceFilterKey('Distance to nearest grocery store (km)', 4);
const params = stateToParams(
null,
{
[park]: [0, 0.4],
[tesco]: [0, 1.5],
[grocery]: [0, 1.5],
},
[],
new Set(),
@ -240,7 +241,7 @@ describe('url-state', () => {
expect(params.getAll('amenityDistance')).toEqual([
'Distance%20to%20nearest%20park%20(km):0:0.4',
'Distance%20to%20nearest%20Tesco%20(km):0:1.5',
'Distance%20to%20nearest%20grocery%20store%20(km):0:1.5',
]);
expect(params.getAll('filter')).toEqual([]);
@ -249,7 +250,60 @@ describe('url-state', () => {
expect(state.filters).toEqual({
[createPoiDistanceFilterKey('Distance to nearest park (km)', 0)]: [0, 0.4],
[createPoiDistanceFilterKey('Distance to nearest Tesco (km)', 1)]: [0, 1.5],
[createPoiDistanceFilterKey('Distance to nearest grocery store (km)', 1)]: [0, 1.5],
});
});
it('round-trips transport distance filters with dedicated URL params', () => {
const busStop = createPoiFilterKey(
TRANSPORT_DISTANCE_FILTER_NAME,
'Distance to nearest amenity (Bus stop) (km)',
3
);
const params = stateToParams(
null,
{
[busStop]: [0, 0.3],
},
[],
new Set(),
'area'
);
expect(params.getAll('transportDistance')).toEqual([
'Distance%20to%20nearest%20amenity%20(Bus%20stop)%20(km):0:0.3',
]);
expect(params.getAll('amenityDistance')).toEqual([]);
expect(params.getAll('filter')).toEqual([]);
window.history.replaceState({}, '', `/?${params.toString()}`);
const state = parseUrlState();
expect(state.filters).toEqual({
[createPoiFilterKey(
TRANSPORT_DISTANCE_FILTER_NAME,
'Distance to nearest amenity (Bus stop) (km)',
0
)]: [0, 0.3],
});
});
it('migrates legacy transport distance amenity params into transport filters', () => {
window.history.replaceState(
{},
'',
'/?amenityDistance=Distance%20to%20nearest%20amenity%20(Bus%20stop)%20(km):0:0.3'
);
const state = parseUrlState();
expect(state.filters).toEqual({
[createPoiFilterKey(
TRANSPORT_DISTANCE_FILTER_NAME,
'Distance to nearest amenity (Bus stop) (km)',
0
)]: [0, 0.3],
});
});
@ -279,8 +333,9 @@ describe('url-state', () => {
const state = parseUrlState();
expect(state.filters).toEqual({
[createPoiFilterKey(POI_COUNT_2KM_FILTER_NAME, 'Number of amenities (Cafe) within 2km', 0)]:
[2, 8],
[createPoiFilterKey(POI_COUNT_2KM_FILTER_NAME, 'Number of amenities (Cafe) within 2km', 0)]: [
2, 8,
],
});
});

View file

@ -38,13 +38,12 @@ import {
} from './ethnicity-filter';
import {
POI_DISTANCE_FILTER_NAME,
TRANSPORT_DISTANCE_FILTER_NAME,
POI_COUNT_2KM_FILTER_NAME,
POI_COUNT_5KM_FILTER_NAME,
createPoiFilterKey,
createPoiDistanceFilterKey,
getPoiDistanceFeatureName,
getPoiFilterName,
isPoiDistanceFeatureName,
isPoiDistanceFilterName,
type PoiFilterName,
} from './poi-distance-filter';
@ -68,6 +67,7 @@ function parseFilters(params: URLSearchParams): FeatureFilters {
const voteShareParams = params.getAll('voteShare');
const ethnicityParams = params.getAll('ethnicity');
const amenityDistanceParams = params.getAll('amenityDistance');
const transportDistanceParams = params.getAll('transportDistance');
const amenityCount2KmParams = params.getAll('amenityCount2km');
const amenityCount5KmParams = params.getAll('amenityCount5km');
if (
@ -77,6 +77,7 @@ function parseFilters(params: URLSearchParams): FeatureFilters {
voteShareParams.length === 0 &&
ethnicityParams.length === 0 &&
amenityDistanceParams.length === 0 &&
transportDistanceParams.length === 0 &&
amenityCount2KmParams.length === 0 &&
amenityCount5KmParams.length === 0
) {
@ -159,44 +160,51 @@ function parseFilters(params: URLSearchParams): FeatureFilters {
filters[createEthnicityFilterKey(featureName, index)] = [min, max];
});
amenityDistanceParams.forEach((entry, index) => {
const parts = entry.split(':');
if (parts.length < 3) return;
const featureName = decodeURIComponent(parts.slice(0, -2).join(':'));
const min = Number(parts[parts.length - 2]);
const max = Number(parts[parts.length - 1]);
if (!isPoiDistanceFeatureName(featureName) || isNaN(min) || isNaN(max)) {
return;
}
filters[createPoiDistanceFilterKey(featureName, index)] = [min, max];
});
const parsePoiCountParams = (
entries: string[],
filterName: PoiFilterName,
startIndex: number
) => {
const parsePoiParams = (entries: string[], filterName: PoiFilterName, startIndex: number) => {
entries.forEach((entry, index) => {
const parts = entry.split(':');
if (parts.length < 3) return;
const featureName = decodeURIComponent(parts.slice(0, -2).join(':'));
const min = Number(parts[parts.length - 2]);
const max = Number(parts[parts.length - 1]);
if (getPoiFilterName(featureName) !== filterName || isNaN(min) || isNaN(max)) {
const targetFilterName = getPoiFilterName(featureName);
const canMigrateTransportDistance =
filterName === POI_DISTANCE_FILTER_NAME &&
targetFilterName === TRANSPORT_DISTANCE_FILTER_NAME;
if (
!targetFilterName ||
(targetFilterName !== filterName && !canMigrateTransportDistance) ||
isNaN(min) ||
isNaN(max)
) {
return;
}
filters[createPoiFilterKey(filterName, featureName, startIndex + index)] = [min, max];
filters[createPoiFilterKey(targetFilterName, featureName, startIndex + index)] = [min, max];
});
};
const parsePoiCountParams = (
entries: string[],
filterName: PoiFilterName,
startIndex: number
) => {
parsePoiParams(entries, filterName, startIndex);
};
parsePoiParams(amenityDistanceParams, POI_DISTANCE_FILTER_NAME, 0);
parsePoiParams(
transportDistanceParams,
TRANSPORT_DISTANCE_FILTER_NAME,
amenityDistanceParams.length
);
parsePoiCountParams(
amenityCount2KmParams,
POI_COUNT_2KM_FILTER_NAME,
amenityDistanceParams.length
amenityDistanceParams.length + transportDistanceParams.length
);
parsePoiCountParams(
amenityCount5KmParams,
POI_COUNT_5KM_FILTER_NAME,
amenityDistanceParams.length + amenityCount2KmParams.length
amenityDistanceParams.length + transportDistanceParams.length + amenityCount2KmParams.length
);
return filters;
@ -349,11 +357,10 @@ export function stateToParams(
? 'amenityCount2km'
: filterName === POI_COUNT_5KM_FILTER_NAME
? 'amenityCount5km'
: 'amenityDistance';
params.append(
paramName,
`${encodeURIComponent(amenityDistanceFeatureName)}:${min}:${max}`
);
: filterName === TRANSPORT_DISTANCE_FILTER_NAME
? 'transportDistance'
: 'amenityDistance';
params.append(paramName, `${encodeURIComponent(amenityDistanceFeatureName)}:${min}:${max}`);
continue;
}
@ -410,6 +417,7 @@ export function summarizeParams(queryString: string): string {
const voteShareParams = params.getAll('voteShare');
const ethnicityParams = params.getAll('ethnicity');
const amenityDistanceParams = params.getAll('amenityDistance');
const transportDistanceParams = params.getAll('transportDistance');
const amenityCount2KmParams = params.getAll('amenityCount2km');
const amenityCount5KmParams = params.getAll('amenityCount5km');
if (
@ -419,6 +427,7 @@ export function summarizeParams(queryString: string): string {
voteShareParams.length > 0 ||
ethnicityParams.length > 0 ||
amenityDistanceParams.length > 0 ||
transportDistanceParams.length > 0 ||
amenityCount2KmParams.length > 0 ||
amenityCount5KmParams.length > 0
) {
@ -429,7 +438,8 @@ export function summarizeParams(queryString: string): string {
if (isSpecificCrimeFeatureName(name)) return SPECIFIC_CRIMES_FILTER_NAME;
if (isElectionVoteShareFeatureName(name)) return ELECTION_VOTE_SHARE_FILTER_NAME;
if (isEthnicityFeatureName(name)) return ETHNICITIES_FILTER_NAME;
if (isPoiDistanceFeatureName(name)) return POI_DISTANCE_FILTER_NAME;
const poiFilterName = getPoiFilterName(name);
if (poiFilterName) return poiFilterName;
return name;
})
.filter((n) => n);
@ -446,6 +456,9 @@ export function summarizeParams(queryString: string): string {
for (let i = 0; i < amenityDistanceParams.length; i++) {
filterNames.push(POI_DISTANCE_FILTER_NAME);
}
for (let i = 0; i < transportDistanceParams.length; i++) {
filterNames.push(TRANSPORT_DISTANCE_FILTER_NAME);
}
for (let i = 0; i < amenityCount2KmParams.length; i++) {
filterNames.push(POI_COUNT_2KM_FILTER_NAME);
}