This commit is contained in:
Andras Schmelczer 2026-06-10 22:25:15 +01:00
parent 1241132095
commit 54a5c3ca9a
28 changed files with 826 additions and 422 deletions

View file

@ -15,6 +15,7 @@ import type { FeatureMeta, FeatureGroup, POICategoriesResponse, POICategoryGroup
import { fetchWithRetry, apiUrl, logNonAbortError } from './lib/api';
import { trackEvent } from './lib/analytics';
import { parseUrlState } from './lib/url-state';
import pb from './lib/pocketbase';
import { INITIAL_VIEW_STATE } from './lib/consts';
import { useTheme } from './hooks/useTheme';
import { useIsMobile } from './hooks/useIsMobile';
@ -40,6 +41,7 @@ const SavedPage = lazy(() =>
import('./components/account/AccountPage').then((module) => ({ default: module.SavedPage }))
);
const InvitePage = lazy(() => import('./components/invite/InvitePage'));
const LegalPage = lazy(() => import('./components/legal/LegalPage'));
const MapPage = lazy(() => import('./components/map/MapPage'));
const AuthModal = lazy(() => import('./components/ui/AuthModal'));
const SaveSearchModal = lazy(() => import('./components/ui/SaveSearchModal'));
@ -77,19 +79,42 @@ function currentRelativePath(): string {
return `${window.location.pathname}${window.location.search}`;
}
const LAST_DASHBOARD_PARAMS_KEY = 'pp_last_dashboard_params';
function persistLastDashboardParams(params: string) {
try {
if (params) window.localStorage.setItem(LAST_DASHBOARD_PARAMS_KEY, params);
} catch {
// Storage unavailable (private mode/quota) — session restore is best-effort.
}
}
function readLastDashboardSearch(): string {
try {
const saved = window.localStorage.getItem(LAST_DASHBOARD_PARAMS_KEY);
return saved ? `?${saved.replace(/^\?/, '')}` : '';
} catch {
return '';
}
}
/**
* Filters and map view live only in the URL. When the dashboard is opened bare
* (no query), restore the last session's params so users pick up where they
* left off. Explicit params and shared links always win.
*/
function restoreLastDashboardSession() {
const pathname = window.location.pathname.replace(/\/+$/, '');
if (pathname !== '/dashboard' || window.location.search) return;
const saved = readLastDashboardSearch();
if (!saved) return;
window.history.replaceState(window.history.state, '', `/dashboard${saved}`);
}
function isProtectedPage(page: Page): boolean {
return page === 'account' || page === 'saved';
}
function isSharedDashboardUrl(): boolean {
const share = new URLSearchParams(window.location.search).get('share');
return !!share && /^[a-z0-9]{1,20}$/i.test(share);
}
function isAuthRequiredRoute(page: Page): boolean {
return isProtectedPage(page) || (page === 'dashboard' && !isSharedDashboardUrl());
}
function buildPageUrl(page: Page, inviteCode?: string, search = '', hash = ''): string {
const normalizedHash = normalizeHash(hash);
return `${pageToPath(page, inviteCode)}${search}${normalizedHash ? `#${normalizedHash}` : ''}`;
@ -126,6 +151,10 @@ function pageToPath(page: Page, inviteCode?: string): string {
case 'methodology':
case 'privacy-security':
return SEO_CONTENT_PATHS[page];
case 'terms':
return '/terms';
case 'privacy':
return '/privacy';
case 'saved':
return '/saved';
case 'account':
@ -140,7 +169,10 @@ function pageToPath(page: Page, inviteCode?: string): string {
}
}
function pathToPage(pathname: string): RouteMatch | null {
function pathToPage(rawPathname: string): RouteMatch | null {
// Proxies 307-redirect /learn -> /learn/; treat trailing slashes as equivalent.
const pathname =
rawPathname.length > 1 ? rawPathname.replace(/\/+$/, '') || '/' : rawPathname;
if (pathname === '/dashboard') return { page: 'dashboard' };
if (pathname === '/saved') return { page: 'saved' };
if (pathname === '/invites') return { page: 'account', hash: 'invites' };
@ -152,6 +184,8 @@ function pathToPage(pathname: string): RouteMatch | null {
if (seoContentPage) return { page: seoContentPage };
if (pathname === '/account') return { page: 'account' };
if (pathname === '/support') return { page: 'learn' };
if (pathname === '/terms') return { page: 'terms' };
if (pathname === '/privacy') return { page: 'privacy' };
if (pathname.startsWith('/invite/')) {
const code = pathname.slice('/invite/'.length);
return { page: 'invite', inviteCode: code };
@ -169,7 +203,11 @@ function isSeoContentPage(page: Page): page is SeoContentKey {
}
export default function App() {
const urlState = useMemo(() => parseUrlState(), []);
const urlState = useMemo(() => {
// Must run before any reads of window.location.search below.
restoreLastDashboardSession();
return parseUrlState();
}, []);
const initialRoute = useMemo(() => pathToPage(window.location.pathname), []);
const [mapUrlState, setMapUrlState] = useState(urlState);
const [dashboardRouteKey, setDashboardRouteKey] = useState(() =>
@ -276,7 +314,9 @@ export default function App() {
if (!completed) {
setPostAuthIntent(null);
postAuthCheckoutReturnPathRef.current = null;
if (isAuthRequiredRoute(activePageRef.current)) {
// Only protected pages bounce home; the dashboard stays open in demo
// mode (server-enforced free zone) when the modal is dismissed.
if (isProtectedPage(activePageRef.current)) {
window.history.replaceState({ page: 'home', hash: '' }, '', '/');
setRouteHash('');
setActivePage('home');
@ -300,8 +340,11 @@ export default function App() {
async function refreshOnStartup() {
if (!returnedFromCheckout) {
// Always refresh auth on startup to pick up server-side subscription changes.
refreshAuthRef.current().catch(() => {});
// Refresh auth on startup to pick up server-side subscription changes,
// but only when a token exists — logged-out visitors would just 401.
if (pb.authStore.token) {
refreshAuthRef.current().catch(() => {});
}
return;
}
@ -384,8 +427,10 @@ export default function App() {
if (infoFeature) {
window.history.replaceState({ ...window.history.state, infoFeature }, '');
}
// Restore dashboard search params when navigating back
const search = page === 'dashboard' ? dashboardSearchRef.current : '';
// Restore dashboard search params when navigating back, falling back to
// the last persisted session for first visits in this tab.
const search =
page === 'dashboard' ? dashboardSearchRef.current || readLastDashboardSearch() : '';
const url = buildPageUrl(page, inviteCode ?? undefined, search, targetHash);
window.history.pushState({ page, hash: targetHash }, '', url);
if (page === 'dashboard') {
@ -527,19 +572,20 @@ export default function App() {
}
}, [activePage, fetchSearches]);
const isAuthRequiredPage =
activePage === 'account' ||
activePage === 'saved' ||
(activePage === 'dashboard' && !mapUrlState.share);
const isProtectedPageActive = isProtectedPage(activePage);
// Only protected pages (account/saved) prompt for login on entry. The
// dashboard opens straight into demo mode (server-enforced free zone) so
// visitors can try it without logging in; the upgrade modal still appears
// when they pan outside the free zone.
useEffect(() => {
if (authLoading) return;
if (isAuthRequiredPage && !user) {
if (isProtectedPageActive && !user) {
openAuthModal('login');
}
if (activePage === 'pricing' && hasFullAccess(user)) {
navigateTo('dashboard');
}
}, [activePage, authLoading, isAuthRequiredPage, navigateTo, openAuthModal, user]);
}, [activePage, authLoading, isProtectedPageActive, navigateTo, openAuthModal, user]);
const [exportState, setExportState] = useState<ExportState | null>(null);
@ -641,6 +687,8 @@ export default function App() {
/>
) : activePage === 'learn' ? (
<LearnPage />
) : activePage === 'terms' || activePage === 'privacy' ? (
<LegalPage kind={activePage} />
) : isSeoLandingPage(activePage) ? (
<SeoLandingPage pageKey={activePage} onOpenDashboard={() => navigateTo('dashboard')} />
) : isSeoContentPage(activePage) ? (
@ -662,7 +710,7 @@ export default function App() {
}}
scrollTarget={routeHash}
/>
) : isAuthRequiredPage && !user ? (
) : isProtectedPageActive && !user ? (
<PageFallback />
) : activePage === 'invite' && inviteCode ? (
<InvitePage
@ -695,7 +743,10 @@ export default function App() {
onClearPendingInfoFeature={() => setPendingInfoFeature(null)}
onNavigateTo={navigateTo}
onExportStateChange={setExportState}
onDashboardParamsChange={setDashboardParams}
onDashboardParamsChange={(params) => {
setDashboardParams(params);
if (!mapUrlState.share) persistLastDashboardParams(params);
}}
onDashboardReadyChange={setDashboardReady}
isMobile={isMobile}
initialTravelTime={mapUrlState.travelTime}

View file

@ -8,6 +8,7 @@ import BottomIllustration from './BottomIllustration';
import { TickerValue } from '../ui/TickerValue';
import { ChevronIcon, LogoIcon, PlayIcon } from '../ui/icons';
import { trackEvent } from '../../lib/analytics';
import { apiUrl } from '../../lib/api';
const BRAND_NAME = 'Perfect Postcode';
const BRAND_TEXT_CLASS = 'text-teal-600 dark:text-teal-400';
@ -163,11 +164,78 @@ function ProductDemoVideo() {
);
}
interface PriceStripTier {
up_to: number | null;
price_pence: number;
}
/**
* Compact pricing teaser under the hero CTAs: surfaces the current lifetime
* price and tier scarcity that otherwise hide behind the Pricing nav link.
*/
function PriceStrip({
onOpenPricing,
hidePricing,
}: {
onOpenPricing: () => void;
hidePricing?: boolean;
}) {
const { t } = useTranslation();
const [pricing, setPricing] = useState<{
licensed_count: number;
current_price_pence: number;
tiers: PriceStripTier[];
} | null>(null);
useEffect(() => {
if (hidePricing) return;
const controller = new AbortController();
fetch(apiUrl('pricing'), { signal: controller.signal })
.then((res) => (res.ok ? res.json() : null))
.then(setPricing)
.catch(() => {});
return () => controller.abort();
}, [hidePricing]);
if (hidePricing || !pricing) return null;
const price = `£${pricing.current_price_pence / 100}`;
const currentTier = pricing.tiers.find(
(tier) => tier.up_to === null || pricing.licensed_count < tier.up_to
);
const spotsRemaining =
currentTier?.up_to != null ? currentTier.up_to - pricing.licensed_count : 0;
return (
<p className="text-sm text-warm-300 mb-2">
{pricing.current_price_pence === 0
? t('upgrade.freeForEarly')
: t('home.priceStrip', { price })}{' '}
{pricing.current_price_pence > 0 && spotsRemaining > 0 && (
<span className="font-semibold text-teal-300">
{spotsRemaining === 1
? t('home.priceStripSpots', { count: spotsRemaining })
: t('home.priceStripSpotsPlural', { count: spotsRemaining })}
</span>
)}{' '}
<button
onClick={() => {
trackEvent('CTA Click', { location: 'hero', label: 'price_strip' });
onOpenPricing();
}}
className="underline decoration-dotted underline-offset-2 text-teal-300 hover:text-teal-200"
>
{t('home.priceStripCta')}
</button>
</p>
);
}
export default function HomePage({
onOpenDashboard,
onOpenPricing: _onOpenPricing,
onOpenPricing,
theme = 'light',
hidePricing: _hidePricing,
hidePricing,
}: {
onOpenDashboard: () => void;
onOpenPricing: () => void;
@ -327,7 +395,7 @@ export default function HomePage({
<p className="text-base md:text-lg text-warm-200 mb-8 max-w-xl">
{highlightBrandText(t('home.heroDescription'), 'font-semibold text-teal-300')}
</p>
<div className="flex flex-col sm:flex-row sm:items-center gap-3 sm:gap-4 mb-10">
<div className="flex flex-col sm:flex-row sm:items-center gap-3 sm:gap-4 mb-5">
<button
onClick={() => {
trackEvent('CTA Click', { location: 'hero', label: 'explore_map' });
@ -347,6 +415,8 @@ export default function HomePage({
{t('home.seeTheDifference')}
</button>
</div>
<PriceStrip onOpenPricing={onOpenPricing} hidePricing={hidePricing} />
<p className="text-sm text-warm-400 mb-8">{t('home.coverageNote')}</p>
<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">
@ -356,7 +426,7 @@ export default function HomePage({
</div>
<div className="home-hero-stat">
<div className="home-hero-stat-value">
<TickerValue text="56" active={statsActive} />
<TickerValue text="40+" active={statsActive} />
</div>
<div className="home-hero-stat-label">{t('home.statFilters')}</div>
</div>

View file

@ -82,7 +82,7 @@ const DEMO_FEATURES: FeatureMeta[] = [
step: 1,
},
{
name: 'Good+ primary schools within 2km',
name: 'Good+ primary school catchments',
type: 'numeric',
group: 'Schools',
min: 0,
@ -300,7 +300,7 @@ function interpolateViewState(progress: number): ViewState {
function demoSliderStep(feature: FeatureMeta): number {
if (feature.name === 'Estimated price') return 1000;
if (feature.name === 'Noise (dB)') return 0.05;
if (feature.name === 'Good+ primary schools within 2km') return 0.01;
if (feature.name === 'Good+ primary school catchments') return 0.01;
if (feature.name === 'Travel time to nearest train or tube station (min)') return 0.1;
return feature.step ?? 1;
}
@ -350,7 +350,7 @@ function FilterPreviewRow({
const shortLabelKeys = {
'Estimated price': 'home.showcaseFeaturePriceShort',
'Noise (dB)': 'home.showcaseFeatureNoiseShort',
'Good+ primary schools within 2km': 'home.showcaseFeatureSchoolsShort',
'Good+ primary school catchments': 'home.showcaseFeatureSchoolsShort',
'Travel time to nearest train or tube station (min)': 'home.showcaseFeatureTravelShort',
} as const;
const shortLabelKey = shortLabelKeys[feature.name as keyof typeof shortLabelKeys];
@ -406,7 +406,7 @@ function formatDemoRange(feature: FeatureMeta, value: [number, number], t: TFunc
if (feature.name === 'Noise (dB)') {
return `${Math.round(value[0])} - ${Math.round(value[1])} dB`;
}
if (feature.name === 'Good+ primary schools within 2km') {
if (feature.name === 'Good+ primary school catchments') {
return t('home.showcaseGoodPrimariesNearby', { count: Math.round(value[0]) });
}
if (feature.name === 'Travel time to nearest train or tube station (min)') {

View file

@ -3,9 +3,52 @@ import { useTranslation } from 'react-i18next';
import { tDynamic } from '../../i18n';
import { getLocalizedSeoPages } from '../../lib/seoLandingPages';
import { ChevronIcon } from '../ui/icons/ChevronIcon';
import { PlayIcon } from '../ui/icons';
import { SubNav } from '../ui/SubNav';
import Footer from '../ui/Footer';
type LearnTab = 'data-sources' | 'faq' | 'articles' | 'support';
type LearnTab = 'data-sources' | 'faq' | 'articles' | 'videos' | 'support';
// Social-media ad cuts rendered by the recorder pipeline (video/src/storyboard.ts).
// Each `<slug>.mp4` is a 9:16 clip with a matching `<slug>.jpg` poster in /public/video.
const SOCIAL_VIDEOS: { slug: string; titleKey: string; descKey: string }[] = [
{ slug: 'ad-01-say-it', titleKey: 'learnPage.video01Title', descKey: 'learnPage.video01Desc' },
{
slug: 'ad-02-twenty-minute-map',
titleKey: 'learnPage.video02Title',
descKey: 'learnPage.video02Desc',
},
{
slug: 'ad-03-postcode-files',
titleKey: 'learnPage.video03Title',
descKey: 'learnPage.video03Desc',
},
{
slug: 'ad-04-quiet-streets',
titleKey: 'learnPage.video04Title',
descKey: 'learnPage.video04Desc',
},
{
slug: 'ad-05-school-run',
titleKey: 'learnPage.video05Title',
descKey: 'learnPage.video05Desc',
},
{
slug: 'ad-06-waitrose-test',
titleKey: 'learnPage.video06Title',
descKey: 'learnPage.video06Desc',
},
{
slug: 'ad-07-renters-map',
titleKey: 'learnPage.video07Title',
descKey: 'learnPage.video07Desc',
},
{
slug: 'ad-08-cheap-insurance',
titleKey: 'learnPage.video08Title',
descKey: 'learnPage.video08Desc',
},
];
interface DataSourceDef {
id: string;
@ -176,6 +219,68 @@ function FAQItemCard({ question, answer }: { question: string; answer: string })
);
}
function SocialVideoCard({
slug,
title,
description,
}: {
slug: string;
title: string;
description: string;
}) {
const { t } = useTranslation();
const videoRef = useRef<HTMLVideoElement | null>(null);
const [isLoaded, setIsLoaded] = useState(false);
const [isPlaying, setIsPlaying] = useState(false);
const videoSrc = `/video/${slug}.mp4`;
const posterSrc = `/video/${slug}.jpg`;
const playVideo = () => {
const video = videoRef.current;
setIsLoaded(true);
if (!video) return;
if (video.getAttribute('src') !== videoSrc) {
video.src = videoSrc;
video.load();
}
void video.play().catch(() => setIsPlaying(false));
};
return (
<div className="flex flex-col">
<div className="relative overflow-hidden rounded-xl border border-warm-200 bg-navy-950 shadow-sm dark:border-warm-700">
<video
ref={videoRef}
src={isLoaded ? videoSrc : undefined}
poster={posterSrc}
controls={isPlaying}
playsInline
preload="none"
className="block aspect-[9/16] w-full bg-navy-950 object-contain"
aria-label={title}
onPlay={() => setIsPlaying(true)}
onPause={() => setIsPlaying(false)}
onEnded={() => setIsPlaying(false)}
/>
{!isPlaying && (
<div className="pointer-events-none absolute inset-0 flex items-center justify-center bg-navy-950/15 transition-colors">
<button
type="button"
onClick={playVideo}
className="pointer-events-auto group flex h-16 w-16 items-center justify-center rounded-full bg-white/95 text-coral-500 shadow-2xl shadow-navy-950/40 ring-1 ring-white/60 transition-transform hover:scale-105 focus:outline-none focus-visible:scale-105 focus-visible:ring-4 focus-visible:ring-teal-300/75"
aria-label={t('home.playProductDemo')}
>
<PlayIcon className="h-8 w-8 -translate-x-0.5" />
</button>
</div>
)}
</div>
<h2 className="mt-3 text-base font-bold text-warm-900 dark:text-warm-100">{title}</h2>
<p className="mt-1 text-sm leading-relaxed text-warm-600 dark:text-warm-300">{description}</p>
</div>
);
}
export default function LearnPage() {
const { t, i18n } = useTranslation();
const [tab, setTab] = useState<LearnTab>('faq');
@ -197,6 +302,7 @@ export default function LearnPage() {
{ key: 'faq', label: t('learnPage.faq') },
{ key: 'data-sources', label: t('learnPage.dataSources') },
{ key: 'articles', label: t('learnPage.articles') },
{ key: 'videos', label: t('learnPage.videos') },
{ key: 'support', label: t('learnPage.support') },
];
@ -268,6 +374,9 @@ export default function LearnPage() {
} else if (hash === 'articles') {
setTab('articles');
setHighlightedId(null);
} else if (hash === 'videos') {
setTab('videos');
setHighlightedId(null);
} else if (hash === 'support') {
setTab('support');
setHighlightedId(null);
@ -300,141 +409,163 @@ export default function LearnPage() {
<SubNav tabs={LEARN_TABS} activeTab={tab} onTabChange={switchTab} />
<div className="flex-1 overflow-y-auto flex flex-col" ref={scrollContainerRef}>
{tab === 'data-sources' ? (
<>
<div className="flex-1">
<div className="max-w-5xl mx-auto px-6 py-6">
<h1 className="text-2xl md:text-3xl font-bold text-warm-900 dark:text-warm-100 mb-3">
{t('learnPage.dataSources')}
</h1>
<p className="text-warm-600 dark:text-warm-400 mb-6">
{t('learnPage.dataSourcesIntro', { count: DATA_SOURCE_DEFS.length })}
</p>
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
{DATA_SOURCE_DEFS.map((source) => {
const keys = DS_KEYS[source.id];
const [nameKey, originKey, useKey] = keys;
return (
<div
key={source.id}
id={source.id}
ref={(el) => {
cardRefs.current[source.id] = el;
}}
className={`bg-white dark:bg-warm-800 rounded-lg border p-5 ${
highlightedId === source.id
? 'border-teal-400 ring-2 ring-teal-400'
: 'border-warm-200 dark:border-warm-700'
}`}
>
<div className="flex items-start justify-between gap-4 mb-2">
<h2 className="text-lg font-semibold text-warm-900 dark:text-warm-100">
{tDynamic(nameKey)}
</h2>
<span className="max-w-44 text-left text-xs leading-snug bg-warm-100 dark:bg-navy-700 text-warm-600 dark:text-warm-300 px-2 py-1 rounded">
{source.license}
</span>
</div>
<p className="text-sm text-warm-500 dark:text-warm-400 mb-2">
{t('learnPage.source')} {tDynamic(originKey)}
</p>
<p className="text-sm text-warm-700 dark:text-warm-300 mb-3">
{tDynamic(useKey)}
</p>
</div>
);
})}
</div>
</div>
</div>
<footer className="bg-navy-900 text-warm-400 px-6 py-6">
<div className="max-w-5xl mx-auto">
<h2 className="text-sm font-semibold text-warm-300 uppercase tracking-wide mb-3">
{t('learnPage.attribution')}
</h2>
<ul className="space-y-1.5 text-sm">
<li>{t('learnPage.attrLandRegistry')}</li>
<li>
{t('learnPage.attrOgl')} {t('learnPage.attrOglLink')}.
</li>
<li>{t('learnPage.attrOs')}</li>
<li>{t('learnPage.attrTfl')}</li>
<li>
{t('learnPage.attrOsm')} {t('learnPage.attrOsmContrib')},{' '}
{t('learnPage.attrOsmLicense')} {t('learnPage.attrOsmLicenseLink')}.
</li>
</ul>
</div>
</footer>
</>
) : tab === 'faq' ? (
<div className="max-w-3xl mx-auto px-6 py-6 w-full">
<h1 className="text-2xl md:text-3xl font-bold text-warm-900 dark:text-warm-100 mb-3">
{t('learnPage.faq')}
</h1>
<p className="text-warm-600 dark:text-warm-400 mb-6">{t('learnPage.faqIntro')}</p>
<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">
{section.title}
</h3>
<div className="space-y-3">
{section.items.map((item, index) => (
<FAQItemCard key={index} question={item.question} answer={item.answer} />
))}
</div>
</div>
))}
</div>
</div>
) : tab === 'articles' ? (
<div className="max-w-5xl mx-auto px-6 py-6 w-full">
<h1 className="text-2xl md:text-3xl font-bold text-warm-900 dark:text-warm-100 mb-3">
{t('learnPage.articles')}
</h1>
<p className="text-warm-600 dark:text-warm-400 mb-6">{t('learnPage.articlesIntro')}</p>
<div className="grid gap-4 md:grid-cols-2">
{seoPageLinks.map((link) => (
<a
key={link.path}
href={link.path}
className="rounded-lg border border-warm-200 bg-white p-5 transition-colors hover:border-teal-300 hover:bg-teal-50 dark:border-warm-700 dark:bg-warm-800 dark:hover:border-teal-500/60 dark:hover:bg-teal-950/30"
>
<div className="text-xs font-semibold uppercase tracking-wide text-teal-600 dark:text-teal-400">
{link.eyebrow}
</div>
<h2 className="mt-1 text-lg font-bold text-warm-900 dark:text-warm-100">
{link.title}
</h2>
<p className="mt-2 text-sm leading-relaxed text-warm-600 dark:text-warm-300">
{link.description}
<div className="flex flex-1 flex-col">
{tab === 'data-sources' ? (
<>
<div className="flex-1">
<div className="max-w-5xl mx-auto px-6 py-6">
<h1 className="text-2xl md:text-3xl font-bold text-warm-900 dark:text-warm-100 mb-3">
{t('learnPage.dataSources')}
</h1>
<p className="text-warm-600 dark:text-warm-400 mb-6">
{t('learnPage.dataSourcesIntro', { count: DATA_SOURCE_DEFS.length })}
</p>
</a>
))}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
{DATA_SOURCE_DEFS.map((source) => {
const keys = DS_KEYS[source.id];
const [nameKey, originKey, useKey] = keys;
return (
<div
key={source.id}
id={source.id}
ref={(el) => {
cardRefs.current[source.id] = el;
}}
className={`bg-white dark:bg-warm-800 rounded-lg border p-5 ${
highlightedId === source.id
? 'border-teal-400 ring-2 ring-teal-400'
: 'border-warm-200 dark:border-warm-700'
}`}
>
<div className="flex items-start justify-between gap-4 mb-2">
<h2 className="text-lg font-semibold text-warm-900 dark:text-warm-100">
{tDynamic(nameKey)}
</h2>
<span className="max-w-44 text-left text-xs leading-snug bg-warm-100 dark:bg-navy-700 text-warm-600 dark:text-warm-300 px-2 py-1 rounded">
{source.license}
</span>
</div>
<p className="text-sm text-warm-500 dark:text-warm-400 mb-2">
{t('learnPage.source')} {tDynamic(originKey)}
</p>
<p className="text-sm text-warm-700 dark:text-warm-300 mb-3">
{tDynamic(useKey)}
</p>
</div>
);
})}
</div>
</div>
</div>
<footer className="bg-navy-900 text-warm-400 px-6 py-6">
<div className="max-w-5xl mx-auto">
<h2 className="text-sm font-semibold text-warm-300 uppercase tracking-wide mb-3">
{t('learnPage.attribution')}
</h2>
<ul className="space-y-1.5 text-sm">
<li>{t('learnPage.attrLandRegistry')}</li>
<li>
{t('learnPage.attrOgl')} {t('learnPage.attrOglLink')}.
</li>
<li>{t('learnPage.attrOs')}</li>
<li>{t('learnPage.attrTfl')}</li>
<li>
{t('learnPage.attrOsm')} {t('learnPage.attrOsmContrib')},{' '}
{t('learnPage.attrOsmLicense')} {t('learnPage.attrOsmLicenseLink')}.
</li>
</ul>
</div>
</footer>
</>
) : tab === 'faq' ? (
<div className="max-w-3xl mx-auto px-6 py-6 w-full">
<h1 className="text-2xl md:text-3xl font-bold text-warm-900 dark:text-warm-100 mb-3">
{t('learnPage.faq')}
</h1>
<p className="text-warm-600 dark:text-warm-400 mb-6">{t('learnPage.faqIntro')}</p>
<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">
{section.title}
</h3>
<div className="space-y-3">
{section.items.map((item, index) => (
<FAQItemCard key={index} question={item.question} answer={item.answer} />
))}
</div>
</div>
))}
</div>
</div>
</div>
) : (
<div className="max-w-2xl mx-auto px-6 py-6 w-full">
<h1 className="text-2xl md:text-3xl font-bold text-warm-900 dark:text-warm-100 mb-3">
{t('learnPage.support')}
</h1>
<p className="text-warm-600 dark:text-warm-400 mb-6">{t('learnPage.supportIntro')}</p>
<div className="bg-white dark:bg-warm-800 rounded-xl border border-warm-200 dark:border-warm-700 p-6 text-center">
<p className="text-warm-600 dark:text-warm-300 mb-2">{t('accountPage.needHelp')}</p>
<a
href="mailto:support@perfect-postcode.co.uk"
className="text-teal-600 dark:text-teal-400 hover:text-teal-800 dark:hover:text-teal-300 font-medium text-lg"
>
support@perfect-postcode.co.uk
</a>
<p className="text-warm-400 dark:text-warm-500 text-sm mt-2">
{t('accountPage.responseTime')}
) : tab === 'articles' ? (
<div className="max-w-5xl mx-auto px-6 py-6 w-full">
<h1 className="text-2xl md:text-3xl font-bold text-warm-900 dark:text-warm-100 mb-3">
{t('learnPage.articles')}
</h1>
<p className="text-warm-600 dark:text-warm-400 mb-6">
{t('learnPage.articlesIntro')}
</p>
<div className="grid gap-4 md:grid-cols-2">
{seoPageLinks.map((link) => (
<a
key={link.path}
href={link.path}
className="rounded-lg border border-warm-200 bg-white p-5 transition-colors hover:border-teal-300 hover:bg-teal-50 dark:border-warm-700 dark:bg-warm-800 dark:hover:border-teal-500/60 dark:hover:bg-teal-950/30"
>
<div className="text-xs font-semibold uppercase tracking-wide text-teal-600 dark:text-teal-400">
{link.eyebrow}
</div>
<h2 className="mt-1 text-lg font-bold text-warm-900 dark:text-warm-100">
{link.title}
</h2>
<p className="mt-2 text-sm leading-relaxed text-warm-600 dark:text-warm-300">
{link.description}
</p>
</a>
))}
</div>
</div>
</div>
)}
) : tab === 'videos' ? (
<div className="max-w-5xl mx-auto px-6 py-6 w-full">
<h1 className="text-2xl md:text-3xl font-bold text-warm-900 dark:text-warm-100 mb-3">
{t('learnPage.videosTitle')}
</h1>
<p className="text-warm-600 dark:text-warm-400 mb-6">{t('learnPage.videosIntro')}</p>
<div className="grid grid-cols-2 gap-4 sm:grid-cols-3 sm:gap-6 lg:grid-cols-4">
{SOCIAL_VIDEOS.map((video) => (
<SocialVideoCard
key={video.slug}
slug={video.slug}
title={tDynamic(video.titleKey)}
description={tDynamic(video.descKey)}
/>
))}
</div>
</div>
) : (
<div className="max-w-2xl mx-auto px-6 py-6 w-full">
<h1 className="text-2xl md:text-3xl font-bold text-warm-900 dark:text-warm-100 mb-3">
{t('learnPage.support')}
</h1>
<p className="text-warm-600 dark:text-warm-400 mb-6">{t('learnPage.supportIntro')}</p>
<div className="bg-white dark:bg-warm-800 rounded-xl border border-warm-200 dark:border-warm-700 p-6 text-center">
<p className="text-warm-600 dark:text-warm-300 mb-2">{t('accountPage.needHelp')}</p>
<a
href="mailto:support@perfect-postcode.co.uk"
className="text-teal-600 dark:text-teal-400 hover:text-teal-800 dark:hover:text-teal-300 font-medium text-lg"
>
support@perfect-postcode.co.uk
</a>
<p className="text-warm-400 dark:text-warm-500 text-sm mt-2">
{t('accountPage.responseTime')}
</p>
</div>
</div>
)}
</div>
<Footer />
</div>
</div>
);

View file

@ -1172,10 +1172,7 @@ export default memo(function Map({
? [postcodeCountRange.min, postcodeCountRange.max]
: [countRange.min, countRange.max]
}
totalCount={
totalCountProp ??
(usePostcodeView ? postcodeCountRange.total : countRange.total)
}
totalCount={totalCountProp}
showCancel={false}
onCancel={onCancelPin}
mode="density"

View file

@ -26,6 +26,7 @@ import { apiUrl, authHeaders, buildFilterString } from '../../lib/api';
import { useFilterCounts } from '../../hooks/useFilterCounts';
import { trackEvent } from '../../lib/analytics';
import { INITIAL_VIEW_STATE, POSTCODE_ZOOM_THRESHOLD } from '../../lib/consts';
import { boundsToCenterZoom } from '../../lib/fit-bounds';
import type { OverlayId } from '../../lib/overlays';
import { CRIME_TYPE_VALUES } from '../../lib/crime-types';
import type { BasemapId } from '../../lib/basemaps';
@ -257,6 +258,14 @@ export default function MapPage({
}))
);
// Move the camera to where the matches actually are — flying to the
// travel-time anchor often lands on a viewport with zero matches.
if (result.matchBounds) {
const target = boundsToCenterZoom(result.matchBounds);
mapFlyToRef.current?.(target.lat, target.lng, target.zoom, getMobileMapFlyToOptions());
return;
}
const firstTravelTime = representable[0]?.tt;
if (!firstTravelTime?.slug) return;
@ -482,7 +491,15 @@ export default function MapPage({
}, []);
const shareReturnViewRef = useRef(shareCode ? initialViewState : null);
// Hide the upgrade modal as soon as the user dismisses it. We can't rely on
// the camera fly alone to close it: flying back to the free/shared zone only
// clears `licenseRequired` once the resulting refetch returns non-403, and
// when the fly target equals the current view (e.g. "back to shared area"
// while already at the shared view) `jumpTo` is a no-op, so no refetch fires
// and the modal would otherwise stay stuck open.
const [upgradeModalDismissed, setUpgradeModalDismissed] = useState(false);
const handleZoomToFreeZone = useCallback(() => {
setUpgradeModalDismissed(true);
const target = shareReturnViewRef.current ?? INITIAL_VIEW_STATE;
mapFlyToRef.current?.(target.latitude, target.longitude, target.zoom);
}, []);
@ -576,7 +593,6 @@ export default function MapPage({
const tutorial = useTutorial(initialLoading, isMobile, deferTutorial || mapData.licenseRequired);
const tutorialTheme = useMemo(() => getTutorialStyles(theme), [theme]);
const densityLabel = t('mapLegend.historicalMatches');
const hasActiveFilters = Object.keys(filters).length > 0 || activeEntries.length > 0;
const mobileLegendMeta = useMobileLegendMeta(viewFeature, features);
const mapViewFeature = useMapViewFeature(viewFeature);
const mobileDensityRange = useMobileDensityRange(mapData);
@ -661,7 +677,13 @@ export default function MapPage({
}, [onDashboardReadyChange]);
useEffect(() => {
if (mapData.licenseRequired) trackEvent('Upgrade Modal Shown');
if (mapData.licenseRequired) {
trackEvent('Upgrade Modal Shown');
} else {
// Back in a viewable area — re-arm so the modal shows again the next time
// the user pans into a gated area.
setUpgradeModalDismissed(false);
}
}, [mapData.licenseRequired]);
if (screenshotMode) {
@ -856,22 +878,25 @@ export default function MapPage({
</div>
) : null;
const upgradeModal = mapData.licenseRequired ? (
<Suspense fallback={null}>
<UpgradeModal
isLoggedIn={!!user}
onLoginClick={() =>
onCheckoutLoginClick ? onCheckoutLoginClick(checkoutReturnPath) : onLoginClick()
}
onRegisterClick={() =>
onCheckoutRegisterClick ? onCheckoutRegisterClick(checkoutReturnPath) : onRegisterClick()
}
onStartCheckout={() => license.startCheckout(checkoutReturnPath)}
onZoomToFreeZone={handleZoomToFreeZone}
isShareReturn={!!shareReturnViewRef.current}
/>
</Suspense>
) : null;
const upgradeModal =
mapData.licenseRequired && !upgradeModalDismissed ? (
<Suspense fallback={null}>
<UpgradeModal
isLoggedIn={!!user}
onLoginClick={() =>
onCheckoutLoginClick ? onCheckoutLoginClick(checkoutReturnPath) : onLoginClick()
}
onRegisterClick={() =>
onCheckoutRegisterClick
? onCheckoutRegisterClick(checkoutReturnPath)
: onRegisterClick()
}
onStartCheckout={() => license.startCheckout(checkoutReturnPath)}
onZoomToFreeZone={handleZoomToFreeZone}
isShareReturn={!!shareReturnViewRef.current}
/>
</Suspense>
) : null;
if (isMobile) {
return (
@ -980,7 +1005,7 @@ export default function MapPage({
onToggleActualListings={canSeeListings ? handleToggleActualListings : undefined}
travelTimeEntries={entries}
densityLabel={densityLabel}
totalCount={hasActiveFilters ? filterCounts.total : undefined}
totalCount={filterCounts.total ?? undefined}
poiPaneOpen={poiPaneOpen}
onTogglePoiPane={handleTogglePoiPane}
poiPane={renderPOIPane()}

View file

@ -6,11 +6,16 @@ interface PriceHistoryChartProps {
points: PricePoint[];
}
const PADDING = { top: 8, right: 24, bottom: 20, left: 42 };
const PADDING = { top: 8, right: 24, bottom: 20, left: 48 };
const HEIGHT = 120;
const PRICE_SCALE_TOP_PERCENTILE = 95;
const priceFmt = { prefix: '£' };
/** Ticks are nice round values; "£800.0k" both clips and reads worse than "£800k". */
function formatTick(tick: number): string {
return formatValue(tick, priceFmt).replace(/\.0(?=[kM])/, '');
}
interface PriceScale {
min: number;
max: number;
@ -148,7 +153,7 @@ export default function PriceHistoryChart({ points }: PriceHistoryChartProps) {
className="fill-warm-500 dark:fill-warm-400"
fontSize={10}
>
{formatValue(tick, priceFmt)}
{formatTick(tick)}
</text>
))}

View file

@ -9,7 +9,9 @@ import { TravelTimeInfoPopup } from '../ui/TravelTimeInfoPopup';
import { CloseIcon } from '../ui/icons/CloseIcon';
import { EyeIcon } from '../ui/icons/EyeIcon';
import { InfoIcon } from '../ui/icons/InfoIcon';
import { formatFilterValue, formatNumber } from '../../lib/format';
import { SliderLabels } from './filters/SliderLabels';
import { formatNumber } from '../../lib/format';
import type { FeatureMeta } from '../../types';
import { useTravelDestinations } from '../../hooks/useTravelDestinations';
import {
MAX_TRAVEL_MINUTES,
@ -56,7 +58,7 @@ export function TravelTimeCard({
dragValue,
onTogglePin,
onSetDestination,
onTimeRangeChange: _onTimeRangeChange,
onTimeRangeChange,
onDragStart,
onDragChange,
onDragEnd,
@ -86,6 +88,17 @@ export function TravelTimeCard({
const sliderMax = MAX_TRAVEL_MINUTES;
const displayRange = isActive && dragValue ? dragValue : (timeRange ?? [sliderMin, sliderMax]);
// Synthetic feature so the time labels reuse the shared SliderLabels (matching
// every other filter card) — editable, thumb-following, with the minute unit.
const travelFeature: FeatureMeta = {
name: 'travelTime',
type: 'numeric',
min: sliderMin,
max: sliderMax,
suffix: ` ${t('common.minute')}`,
raw: true,
};
const ModeIcon = MODE_ICONS[mode];
return (
@ -211,14 +224,17 @@ export function TravelTimeCard({
onPointerDown={() => onDragStart(displayRange)}
onPointerUp={() => onDragEnd()}
/>
<div className="relative h-4 mt-1 mx-2.5 text-[10px] text-warm-500 dark:text-warm-400 leading-tight">
<span className="absolute left-0">
{formatFilterValue(displayRange[0])} {t('common.minute')}
</span>
<span className="absolute right-0">
{formatFilterValue(displayRange[1])} {t('common.minute')}
</span>
</div>
<SliderLabels
min={sliderMin}
max={sliderMax}
value={[displayRange[0], displayRange[1]]}
isAtMin={displayRange[0] <= sliderMin}
isAtMax={displayRange[1] >= sliderMax}
raw
showUnit
feature={travelFeature}
onValueChange={onTimeRangeChange}
/>
{filterImpact != null && filterImpact > 0 && (
<p className="text-[10px] text-warm-400 dark:text-warm-500 -mt-1 ml-2.5">
{t('filters.filtersOut', { value: formatNumber(filterImpact) })}

View file

@ -11,7 +11,6 @@ import {
getSchoolFilterConfig,
getSchoolFilterMeta,
replaceSchoolFilterKeySelection,
type SchoolDistance,
type SchoolPhase,
type SchoolRating,
} from '../../../lib/school-filter';
@ -74,7 +73,6 @@ export function SchoolFilterCard({
next: Partial<{
phase: SchoolPhase;
rating: SchoolRating;
distance: SchoolDistance;
}>
) => {
const nextName = replaceSchoolFilterKeySelection(schoolFeature.name, next);
@ -180,35 +178,6 @@ export function SchoolFilterCard({
</button>
</div>
</div>
<div>
<div className="mb-0.5 text-[10px] font-medium uppercase text-warm-400 dark:text-warm-500">
{t('filters.distance')}
</div>
<div
className={segmentedClass}
role="radiogroup"
aria-label={t('filters.schoolDistance')}
>
<button
type="button"
role="radio"
aria-checked={config.distance === 2}
onClick={() => replaceSchoolFeature({ distance: 2 })}
className={optionClass(config.distance === 2)}
>
2 km
</button>
<button
type="button"
role="radio"
aria-checked={config.distance === 5}
onClick={() => replaceSchoolFeature({ distance: 5 })}
className={optionClass(config.distance === 5)}
>
5 km
</button>
</div>
</div>
</div>
<Slider

View file

@ -8,6 +8,7 @@ import { logNonAbortError } from '../../lib/api';
import { trackEvent } from '../../lib/analytics';
import { apiUrl } from '../../lib/api';
import HexCanvas from '../home/HexCanvas';
import { useIsDarkTheme } from '../../hooks/useIsDarkTheme';
// Feature list keys — resolved inside the component via t()
@ -39,6 +40,7 @@ export default function PricingPage({
onRegisterClick?: () => void;
}) {
const { t } = useTranslation();
const isDark = useIsDarkTheme();
const license = useLicense();
const [pricing, setPricing] = useState<PricingData | null>(null);
const [loading, setLoading] = useState(true);
@ -137,17 +139,23 @@ export default function PricingPage({
) : null;
return (
<div className="flex-1 overflow-y-auto bg-navy-950 relative">
<div className="flex-1 overflow-y-auto bg-warm-50 dark:bg-navy-950 relative">
<div className="fixed inset-0 pointer-events-none overflow-hidden">
<div className="absolute inset-0 bg-gradient-to-b from-navy-950 via-navy-900 to-navy-950" />
<HexCanvas isDark />
<div className="absolute inset-0 bg-[radial-gradient(circle_at_50%_12%,rgba(20,184,166,0.16),transparent_38%),linear-gradient(180deg,rgba(10,14,26,0.20),rgba(10,14,26,0.82))]" />
<div className="absolute inset-0 bg-gradient-to-b from-warm-100 via-warm-50 to-warm-100 dark:from-navy-950 dark:via-navy-900 dark:to-navy-950" />
<HexCanvas isDark={isDark} />
<div className="absolute inset-0 hidden dark:block bg-[radial-gradient(circle_at_50%_12%,rgba(20,184,166,0.16),transparent_38%),linear-gradient(180deg,rgba(10,14,26,0.20),rgba(10,14,26,0.82))]" />
</div>
<div className="relative z-10 max-w-5xl mx-auto px-6 pt-16 text-center mb-10">
<h1 className="text-3xl md:text-4xl font-bold text-white mb-3">{t('pricingPage.title')}</h1>
<p className="text-lg text-warm-300 max-w-lg mx-auto">{t('pricingPage.subtitle')}</p>
<p className="mt-5 text-warm-200 font-semibold">{t('pricingPage.lessThanSurvey')}</p>
<h1 className="text-3xl md:text-4xl font-bold text-navy-950 dark:text-white mb-3">
{t('pricingPage.title')}
</h1>
<p className="text-lg text-warm-600 dark:text-warm-300 max-w-lg mx-auto">
{t('pricingPage.subtitle')}
</p>
<p className="mt-5 text-warm-700 dark:text-warm-200 font-semibold">
{t('pricingPage.lessThanSurvey')}
</p>
</div>
<div className="relative z-10 max-w-5xl mx-auto px-6 pb-8 md:pb-16">
@ -203,7 +211,7 @@ export default function PricingPage({
className={`relative flex flex-col rounded-2xl border overflow-hidden w-80 shrink-0 ${
isCurrent
? 'border-teal-400 ring-2 ring-teal-400 shadow-lg'
: 'border-warm-700 shadow-md'
: 'border-warm-300 dark:border-warm-700 shadow-md'
} ${isFilled ? 'opacity-60' : ''}`}
>
{isCurrent && (
@ -266,11 +274,6 @@ export default function PricingPage({
})}
</p>
)}
{isFilled && (
<p className="text-warm-400 dark:text-warm-500 text-sm mt-2 flex items-center justify-center gap-1">
<CheckIcon className="w-4 h-4" /> {t('pricingPage.filled')}
</p>
)}
</div>
{/* Progress bar for current tier */}
@ -308,10 +311,14 @@ export default function PricingPage({
{license.error}
</p>
)}
{isFree && (
{isFree ? (
<p className="text-center text-xs text-warm-400 dark:text-warm-500 mt-2">
{t('pricingPage.noCreditCard')}
</p>
) : (
<p className="text-center text-xs text-warm-400 dark:text-warm-500 mt-2">
{t('pricingPage.moneyBack')}
</p>
)}
</>
) : isFilled ? (

View file

@ -1,5 +1,5 @@
import { useState, useCallback, useEffect, useId } from 'react';
import { useTranslation } from 'react-i18next';
import { Trans, useTranslation } from 'react-i18next';
import { CloseIcon } from './icons/CloseIcon';
import { GoogleIcon } from './icons/GoogleIcon';
import { trackEvent } from '../../lib/analytics';
@ -106,7 +106,7 @@ export default function AuthModal({
return (
<div
className="fixed inset-0 z-50 flex items-center justify-center"
className="fixed inset-0 z-[10001] flex items-center justify-center"
onMouseDown={(e) => {
if (e.target === e.currentTarget) onClose();
}}
@ -283,6 +283,32 @@ export default function AuthModal({
{t('auth.backToLogin')}
</button>
)}
{view === 'register' && (
<p className="text-[11px] leading-relaxed text-center text-warm-400 dark:text-warm-500">
<Trans
i18nKey="auth.registerConsent"
components={{
terms: (
<a
href="/terms"
target="_blank"
rel="noopener"
className="underline hover:text-teal-600 dark:hover:text-teal-400"
/>
),
privacy: (
<a
href="/privacy"
target="_blank"
rel="noopener"
className="underline hover:text-teal-600 dark:hover:text-teal-400"
/>
),
}}
/>
</p>
)}
</form>
</div>
</div>

View file

@ -33,6 +33,8 @@ export type Page =
| 'data-sources'
| 'methodology'
| 'privacy-security'
| 'terms'
| 'privacy'
| 'account'
| 'saved'
| 'invite';
@ -58,6 +60,8 @@ export const PAGE_PATHS: Record<Page, string> = {
'data-sources': '/data-sources',
methodology: '/methodology',
'privacy-security': '/privacy-security',
terms: '/terms',
privacy: '/privacy',
saved: '/saved',
account: '/account',
invite: '/invite',

View file

@ -18,6 +18,12 @@ interface SearchHook {
handleInputChange: (value: string) => void;
handleKeyDown: (e: React.KeyboardEvent, onSelect: (result: SearchResult) => void) => void;
showEmptySearches: () => void;
close?: () => void;
}
/** Addresses arrive in raw ALL-CAPS Land Registry casing; title-case for display. */
function titleCaseAddress(address: string): string {
return address.toLowerCase().replace(/(^|[\s\-/(])([a-z])/g, (_, sep, c) => sep + c.toUpperCase());
}
interface PlaceSearchInputProps {
@ -65,6 +71,9 @@ export function PlaceSearchInput({
const dropdown = showDropdown && (
<div
className={`bg-white dark:bg-warm-800 rounded shadow-lg border border-warm-200 dark:border-warm-700 ${sm ? 'max-h-64' : 'max-h-48'} overflow-y-auto`}
// Keep focus on the input while interacting with the list (incl. its
// scrollbar) so the blur-close below doesn't fire mid-click.
onMouseDown={(e) => e.preventDefault()}
style={
portal && dropdownPos
? {
@ -116,8 +125,11 @@ export function PlaceSearchInput({
) : result.type === 'address' ? (
<>
<HouseIcon className={`${iconSize} text-warm-400 dark:text-warm-500 shrink-0`} />
<span className="min-w-0 text-warm-700 dark:text-warm-200">
<span className="block truncate">{result.address}</span>
<span
className="min-w-0 text-warm-700 dark:text-warm-200"
title={`${titleCaseAddress(result.address)}, ${result.postcode}`}
>
<span className="block truncate">{titleCaseAddress(result.address)}</span>
<span className="block truncate text-warm-400 dark:text-warm-500">
{result.postcode}
</span>
@ -126,7 +138,10 @@ export function PlaceSearchInput({
) : (
<>
<MapPinIcon className={`${iconSize} text-warm-400 dark:text-warm-500 shrink-0`} />
<span className="text-warm-700 dark:text-warm-200">
<span
className="text-warm-700 dark:text-warm-200"
title={result.city ? `${result.name} (${result.city})` : result.name}
>
{result.name}
{result.city && (
<span className="text-warm-400 dark:text-warm-500"> ({result.city})</span>
@ -154,6 +169,9 @@ export function PlaceSearchInput({
onFocus={() => {
search.showEmptySearches();
}}
// Without this, instances whose parents lack outside-click handling
// leave a detached dropdown floating over the map.
onBlur={() => search.close?.()}
onKeyDown={(e) => search.handleKeyDown(e, onSelect)}
aria-label={ariaLabel ?? placeholder}
placeholder={placeholder}

View file

@ -176,10 +176,9 @@ export function useDeckLayers({
enumPaletteRef.current = enumPalette;
const countRange = useMemo(() => {
if (data.length === 0) return { min: 0, max: 1, total: 0 };
if (data.length === 0) return { min: 0, max: 1 };
let min = Infinity;
let max = -Infinity;
let total = 0;
for (const d of data) {
if (viewportBounds) {
if (
@ -191,24 +190,22 @@ export function useDeckLayers({
continue;
}
const c = d.count as number;
total += c;
if (c <= 0) continue;
if (c < min) min = c;
if (c > max) max = c;
}
if (min === Infinity) return { min: 0, max: 1, total: 0 };
if (min === max) return { min, max: min + 1, total };
return { min, max, total };
if (min === Infinity) return { min: 0, max: 1 };
if (min === max) return { min, max: min + 1 };
return { min, max };
}, [data, viewportBounds]);
const countRangeRef = useRef(countRange);
countRangeRef.current = countRange;
const postcodeCountRange = useMemo(() => {
if (postcodeData.length === 0) return { min: 0, max: 1, total: 0 };
if (postcodeData.length === 0) return { min: 0, max: 1 };
let min = Infinity;
let max = -Infinity;
let total = 0;
for (const d of postcodeData) {
if (viewportBounds) {
const [lng, lat] = d.properties.centroid as [number, number];
@ -221,14 +218,13 @@ export function useDeckLayers({
continue;
}
const c = d.properties.count;
total += c;
if (c <= 0) continue;
if (c < min) min = c;
if (c > max) max = c;
}
if (min === Infinity) return { min: 0, max: 1, total: 0 };
if (min === max) return { min, max: min + 1, total };
return { min, max, total };
if (min === Infinity) return { min: 0, max: 1 };
if (min === max) return { min, max: min + 1 };
return { min, max };
}, [postcodeData, viewportBounds]);
const postcodeCountRangeRef = useRef(postcodeCountRange);

View file

@ -180,12 +180,7 @@ export function useFilters({ initialFilters, features }: UseFiltersOptions) {
undoStackRef.current.push(prev);
if (undoStackRef.current.length > 50) undoStackRef.current.shift();
if (name === SCHOOL_FILTER_NAME) {
const schoolKey = createSchoolFilterKey(
'primary',
'good',
2,
schoolFilterIdRef.current++
);
const schoolKey = createSchoolFilterKey('primary', 'good', schoolFilterIdRef.current++);
const defaultSchoolFeatureName = getDefaultSchoolFeatureName(features);
const defaultSchoolFeature = defaultSchoolFeatureName
? features.find((feature) => feature.name === defaultSchoolFeatureName)

View file

@ -1111,6 +1111,34 @@ const de: Translations = {
articlesIntro:
'Durchsuche die öffentlichen Leitfäden zu Immobiliensuche, Pendeln, Schulen, Postleitzahlprüfungen, regionalen Vergleichen, Datenabdeckung, Methodik und Datenschutz.',
supportIntro: 'Hast du eine Frage? Schau in unsere FAQ oder kontaktiere uns direkt.',
videos: 'Videos',
videosTitle: 'Social-Media-Videos',
videosIntro:
'Kurze Clips aus unseren Social-Media-Kanälen jeder zeigt eine einzelne Suche in Aktion, von ruhigen Straßen über Schuleinzugsgebiete bis zu Pendelzeiten.',
video01Title: 'Ein Satz, jede Postleitzahl',
video01Desc:
'Gib deinen kompletten Wohnungswunsch in normaler Sprache ein und sieh zu, wie jede passende Postleitzahl in England aufleuchtet.',
video02Title: 'Die 20-Minuten-Karte',
video02Desc:
'Färbe die Karte nach Pendelzeit und sieh genau, was dir 20 Minuten ins Zentrum von London wirklich übrig lassen.',
video03Title: 'Jede Postleitzahl hat eine Akte',
video03Desc:
'Tippe auf eine Postleitzahl und lies ihre Akte verkaufte Preise, Schulen, Kriminalität und Street View an einem Ort.',
video04Title: 'Ein Foto kann man nicht hören',
video04Desc:
'Anzeigenfotos sind stumm. Filtere nach Lärmpegel und finde die wirklich ruhigen Straßen unter 55 Dezibel.',
video05Title: 'Die Schulweg-Karte',
video05Desc:
'Gute Grundschul-Einzugsgebiete, wenig Kriminalität und ein Budget der Familienwunsch, über eine ganze Stadt kartiert.',
video06Title: 'Der Waitrose-Test',
video06Desc:
'Zu Fuß zu einem Supermarkt, einer U-Bahn-Station und einem Park filtere nach dem Leben, nicht nur nach dem Grundriss.',
video07Title: 'Auch Mieter bekommen eine Karte',
video07Desc:
'Miete im Budget, kurzer Arbeitsweg und eine ruhige Straße Mietportale zeigen Wohnungen, das hier zeigt dir Gegenden.',
video08Title: '9,99 £ gegen einen verlorenen Samstag',
video08Desc:
'Eine schlechte Besichtigung kostet eine Zugfahrt und ein halbes Wochenende. Sieh vorher, wo du nicht hinmusst.',
source: 'Quelle:',
optOut: 'Widerspruch gegen öffentliche Offenlegung',
attribution: 'Quellenangaben',

View file

@ -1091,6 +1091,34 @@ const en = {
articlesIntro:
'Browse the public guides for property search, commute, schools, postcode checks, regional comparisons, data coverage, methodology, and privacy.',
supportIntro: 'Have a question? Check our FAQ or reach out to us directly.',
videos: 'Videos',
videosTitle: 'Social media videos',
videosIntro:
'Short clips from our social channels — each one shows a single search in action, from quiet streets to school catchments and commute times.',
video01Title: 'One sentence, every postcode',
video01Desc:
'Type your whole house brief in plain English and watch every matching postcode in England light up.',
video02Title: 'The 20-minute map',
video02Desc:
'Colour the map by commute time and see exactly what a 20-minute journey to central London actually leaves you.',
video03Title: 'Every postcode has a file',
video03Desc:
'Tap any postcode to read its file — sold prices, schools, crime and Street View, all in one place.',
video04Title: 'You cant hear a photo',
video04Desc:
'Listing photos are silent. Filter by noise level to find the genuinely quiet streets under 55 decibels.',
video05Title: 'The school-run map',
video05Desc:
'Good primary catchments, low crime and a budget — the family brief, mapped across a whole city.',
video06Title: 'The Waitrose test',
video06Desc:
'Walking distance to a Waitrose, a tube station and a park — filter for the life, not just the floor plan.',
video07Title: 'Renters get a map too',
video07Desc:
'Rent under budget, a short commute and a quiet street — letting sites show flats, this shows you areas.',
video08Title: '£9.99 vs a wasted Saturday',
video08Desc:
'A bad viewing costs a train ticket and half a weekend. See where not to go before you book one.',
source: 'Source:',
optOut: 'Opt out of public disclosure',
attribution: 'Attribution',

View file

@ -1125,6 +1125,34 @@ const fr: Translations = {
articlesIntro:
'Parcourez les guides publics sur la recherche immobilière, les trajets, les écoles, les codes postaux, les comparaisons régionales, la couverture des données, la méthodologie et la confidentialité.',
supportIntro: 'Vous avez une question ? Consultez notre FAQ ou contactez-nous directement.',
videos: 'Vidéos',
videosTitle: 'Vidéos pour les réseaux sociaux',
videosIntro:
'De courtes vidéos de nos réseaux sociaux — chacune montre une recherche en action, des rues calmes aux secteurs scolaires en passant par les temps de trajet.',
video01Title: 'Une phrase, chaque code postal',
video01Desc:
'Décrivez tout votre projet immobilier en langage courant et voyez sallumer chaque code postal correspondant en Angleterre.',
video02Title: 'La carte des 20 minutes',
video02Desc:
'Colorez la carte selon le temps de trajet et voyez exactement ce que 20 minutes du centre de Londres vous laissent vraiment.',
video03Title: 'Chaque code postal a sa fiche',
video03Desc:
'Touchez un code postal pour lire sa fiche — prix de vente, écoles, criminalité et Street View, au même endroit.',
video04Title: 'Une photo, ça ne sentend pas',
video04Desc:
'Les photos dannonces sont muettes. Filtrez par niveau de bruit pour trouver les rues vraiment calmes, sous 55 décibels.',
video05Title: 'La carte du trajet décole',
video05Desc:
'Bons secteurs décole primaire, faible criminalité et un budget — le projet des familles, cartographié sur toute une ville.',
video06Title: 'Le test Waitrose',
video06Desc:
'À pied dun supermarché, dune station de métro et dun parc — filtrez selon votre vie, pas seulement selon le plan.',
video07Title: 'Les locataires aussi ont une carte',
video07Desc:
'Loyer dans le budget, trajet court et rue calme — les sites de location montrent des logements, ceci vous montre des quartiers.',
video08Title: '9,99 £ contre un samedi gâché',
video08Desc:
'Une mauvaise visite coûte un billet de train et la moitié dun week-end. Voyez où ne pas aller avant den réserver une.',
source: 'Source :',
optOut: 'Refus de la publication publique',
attribution: 'Attribution',

View file

@ -1072,6 +1072,34 @@ const hi: Translations = {
articlesIntro:
'संपत्ति खोज, आवागमन, स्कूल, पोस्टकोड जांच, क्षेत्रीय तुलना, डेटा कवरेज, कार्यप्रणाली और गोपनीयता पर सार्वजनिक गाइड देखें.',
supportIntro: 'कोई सवाल है? हमारे प्रश्नोत्तर देखें या सीधे संपर्क करें.',
videos: 'वीडियो',
videosTitle: 'सोशल मीडिया वीडियो',
videosIntro:
'हमारे सोशल चैनलों की छोटी क्लिप्स — हर एक एक खोज को क्रिया में दिखाती है, शांत गलियों से लेकर स्कूल कैचमेंट और सफर के समय तक.',
video01Title: 'एक वाक्य, हर पोस्टकोड',
video01Desc:
'अपनी पूरी घर की ज़रूरत सामान्य भाषा में लिखें और इंग्लैंड का हर मेल खाता पोस्टकोड जगमगाते देखें.',
video02Title: '20 मिनट का नक्शा',
video02Desc:
'नक्शे को सफर के समय के अनुसार रंगें और देखें कि सेंट्रल लंदन से 20 मिनट वास्तव में आपको क्या देते हैं.',
video03Title: 'हर पोस्टकोड की एक फ़ाइल है',
video03Desc:
'किसी भी पोस्टकोड पर टैप करके उसकी फ़ाइल पढ़ें — बिक्री मूल्य, स्कूल, अपराध और स्ट्रीट व्यू, सब एक जगह.',
video04Title: 'फ़ोटो सुनी नहीं जा सकती',
video04Desc:
'विज्ञापन की तस्वीरें खामोश होती हैं. शोर के स्तर से छानकर 55 डेसिबल से नीचे की सचमुच शांत गलियाँ खोजें.',
video05Title: 'स्कूल-रन नक्शा',
video05Desc:
'अच्छे प्राइमरी कैचमेंट, कम अपराध और एक बजट — परिवार की ज़रूरत, पूरे शहर पर मैप की गई.',
video06Title: 'वेट्रोज़ टेस्ट',
video06Desc:
'सुपरमार्केट, ट्यूब स्टेशन और पार्क से पैदल दूरी — सिर्फ़ फ़्लोर प्लान नहीं, अपनी ज़िंदगी के हिसाब से छानें.',
video07Title: 'किराएदारों के लिए भी नक्शा',
video07Desc:
'बजट में किराया, छोटा सफर और शांत गली — किराये की साइटें फ़्लैट दिखाती हैं, यह आपको इलाके दिखाता है.',
video08Title: '£9.99 बनाम एक बर्बाद शनिवार',
video08Desc:
'एक ख़राब विज़िट एक ट्रेन टिकट और आधा सप्ताहांत ले जाती है. बुकिंग से पहले देखें कि कहाँ नहीं जाना है.',
source: 'स्रोत:',
optOut: 'सार्वजनिक प्रकटीकरण से बाहर निकलें',
attribution: 'श्रेय',

View file

@ -1109,6 +1109,34 @@ const hu: Translations = {
articlesIntro:
'Böngészd a nyilvános útmutatókat ingatlankeresésről, ingázásról, iskolákról, irányítószám-ellenőrzésről, regionális összehasonlításokról, adatlefedettségről, módszertanról és adatvédelemről.',
supportIntro: 'Kérdésed van? Nézd meg a GYIK-et, vagy írj nekünk közvetlenül.',
videos: 'Videók',
videosTitle: 'Közösségimédia-videók',
videosIntro:
'Rövid klipek a közösségi csatornáinkról mindegyik egy-egy keresést mutat be működés közben, a csendes utcáktól az iskolai körzeteken át az utazási időkig.',
video01Title: 'Egy mondat, minden irányítószám',
video01Desc:
'Írd le a teljes lakáskeresési igényedet hétköznapi nyelven, és nézd, ahogy Anglia minden illeszkedő irányítószáma felgyúl.',
video02Title: 'A 20 perces térkép',
video02Desc:
'Színezd a térképet utazási idő szerint, és lásd pontosan, mit hagy neked valójában 20 perc London központjától.',
video03Title: 'Minden irányítószámnak van aktája',
video03Desc:
'Koppints bármelyik irányítószámra, és olvasd el az aktáját eladási árak, iskolák, bűnözés és Street View egy helyen.',
video04Title: 'Egy fotót nem lehet meghallani',
video04Desc:
'A hirdetésfotók némák. Szűrj zajszint szerint, és találd meg a valóban csendes, 55 decibel alatti utcákat.',
video05Title: 'Az iskolába vezető út térképe',
video05Desc:
'Jó általános iskolai körzetek, alacsony bűnözés és egy költségvetés a családi igény, egy egész városra térképezve.',
video06Title: 'A Waitrose-teszt',
video06Desc:
'Sétatávolságra egy szupermarkettől, egy metrómegállótól és egy parktól az életedre szűrj, ne csak az alaprajzra.',
video07Title: 'A bérlőknek is jár térkép',
video07Desc:
'Költségvetésbe férő bérleti díj, rövid ingázás és csendes utca a bérleti oldalak lakásokat mutatnak, ez környékeket.',
video08Title: '9,99 £ egy elpazarolt szombat ellen',
video08Desc:
'Egy rossz megtekintés egy vonatjegybe és egy fél hétvégébe kerül. Még foglalás előtt lásd, hova ne menj.',
source: 'Forrás:',
optOut: 'Nyilvános közzététel visszautasítása',
attribution: 'Forrásmegnevezés',

View file

@ -1045,6 +1045,26 @@ const zh: Translations = {
articlesIntro:
'浏览关于找房、通勤、学校、邮编速查、区域对比、数据覆盖、方法论和隐私的公开指南。',
supportIntro: '还有疑问?欢迎查看常见问题,或直接与我们联系。',
videos: '视频',
videosTitle: '社交媒体视频',
videosIntro:
'来自我们社交平台的短片——每一个都展示一次实际搜索,从安静街道到学校学区,再到通勤时间。',
video01Title: '一句话,每个邮编',
video01Desc: '用日常语言写下你的全部购房需求,看着英格兰每个符合条件的邮编亮起来。',
video02Title: '20 分钟地图',
video02Desc: '按通勤时间为地图着色,看清从伦敦市中心 20 分钟究竟能到达哪里。',
video03Title: '每个邮编都有一份档案',
video03Desc: '点按任意邮编即可查看其档案——成交价、学校、犯罪和街景,尽在一处。',
video04Title: '照片听不见声音',
video04Desc: '房源照片是无声的。按噪音水平筛选,找到真正安静、低于 55 分贝的街道。',
video05Title: '上学路线地图',
video05Desc: '优质小学学区、低犯罪率和预算——把家庭需求映射到整座城市。',
video06Title: 'Waitrose 测试',
video06Desc: '步行可达超市、地铁站和公园——按你的生活方式筛选,而不只是户型图。',
video07Title: '租房者也有地图',
video07Desc: '预算内的租金、短通勤和安静街道——租房网站展示房源,这里为你展示区域。',
video08Title: '£9.99 对比浪费的周六',
video08Desc: '一次糟糕的看房要花一张车票和半个周末。在预约之前就看清哪里不该去。',
source: '来源:',
optOut: '选择不公开',
attribution: '数据引用声明',

View file

@ -82,18 +82,18 @@ describe('api utilities', () => {
it('deduplicates repeated synthetic school filters before backend routes', () => {
const features: FeatureMeta[] = [
{ name: 'Good+ primary schools within 2km', type: 'numeric', min: 0, max: 10 },
{ name: 'Good+ primary school catchments', type: 'numeric', min: 0, max: 10 },
];
expect(
buildFilterString(
{
[createSchoolFilterKey('primary', 'good', 2, 1)]: [1, 10],
[createSchoolFilterKey('primary', 'good', 2, 2)]: [2, 8],
[createSchoolFilterKey('primary', 'good', 1)]: [1, 10],
[createSchoolFilterKey('primary', 'good', 2)]: [2, 8],
},
features
)
).toBe('Good+ primary schools within 2km:2:8');
).toBe('Good+ primary school catchments:2:8');
});
it('serializes specific crime filters using their selected backend crime feature', () => {

View file

@ -146,53 +146,27 @@ const FEATURE_ICON_PATHS: Record<string, ReactNode> = {
<path d="M2 3h6a4 4 0 014 4 4 4 0 014-4h6v18a2 2 0 01-2 2h-4a4 4 0 00-4 4 4 4 0 00-4-4H4a2 2 0 01-2-2z" />
</>
),
'Good+ primary schools within 5km': (
'Good+ primary school catchments': (
<>
<path d="M4 19V9l8-6 8 6v10" />
<path d="M9 19v-6h6v6" />
<line x1="4" y1="19" x2="20" y2="19" />
</>
),
'Good+ secondary schools within 5km': (
'Good+ secondary school catchments': (
<>
<path d="M22 10v6M2 10l10-5 10 5-10 5z" />
<path d="M6 12v5c0 2.5 3 4 6 4s6-1.5 6-4v-5" />
</>
),
'Outstanding primary schools within 5km': (
'Outstanding primary school catchments': (
<>
<path d="M4 19V9l8-6 8 6v10" />
<path d="M9 19v-6h6v6" />
<line x1="4" y1="19" x2="20" y2="19" />
</>
),
'Outstanding secondary schools within 5km': (
<>
<path d="M22 10v6M2 10l10-5 10 5-10 5z" />
<path d="M6 12v5c0 2.5 3 4 6 4s6-1.5 6-4v-5" />
</>
),
'Good+ primary schools within 2km': (
<>
<path d="M4 19V9l8-6 8 6v10" />
<path d="M9 19v-6h6v6" />
<line x1="4" y1="19" x2="20" y2="19" />
</>
),
'Good+ secondary schools within 2km': (
<>
<path d="M22 10v6M2 10l10-5 10 5-10 5z" />
<path d="M6 12v5c0 2.5 3 4 6 4s6-1.5 6-4v-5" />
</>
),
'Outstanding primary schools within 2km': (
<>
<path d="M4 19V9l8-6 8 6v10" />
<path d="M9 19v-6h6v6" />
<line x1="4" y1="19" x2="20" y2="19" />
</>
),
'Outstanding secondary schools within 2km': (
'Outstanding secondary school catchments': (
<>
<path d="M22 10v6M2 10l10-5 10 5-10 5z" />
<path d="M6 12v5c0 2.5 3 4 6 4s6-1.5 6-4v-5" />

View file

@ -24,6 +24,7 @@ const TRANSPORT_POI_CATEGORIES = new Set([
'Ferry',
'Rail station',
'Taxi rank',
'Tram & Metro stop',
'Tube station',
]);

View file

@ -5,12 +5,10 @@ export const SCHOOL_FILTER_KEY_PREFIX = `${SCHOOL_FILTER_NAME}:`;
export type SchoolPhase = 'primary' | 'secondary';
export type SchoolRating = 'good' | 'outstanding';
export type SchoolDistance = 2 | 5;
export interface SchoolFilterConfig {
phase: SchoolPhase;
rating: SchoolRating;
distance: SchoolDistance;
featureName: string;
}
@ -18,50 +16,22 @@ export const SCHOOL_FILTERS: SchoolFilterConfig[] = [
{
phase: 'primary',
rating: 'good',
distance: 2,
featureName: 'Good+ primary schools within 2km',
featureName: 'Good+ primary school catchments',
},
{
phase: 'secondary',
rating: 'good',
distance: 2,
featureName: 'Good+ secondary schools within 2km',
featureName: 'Good+ secondary school catchments',
},
{
phase: 'primary',
rating: 'outstanding',
distance: 2,
featureName: 'Outstanding primary schools within 2km',
featureName: 'Outstanding primary school catchments',
},
{
phase: 'secondary',
rating: 'outstanding',
distance: 2,
featureName: 'Outstanding secondary schools within 2km',
},
{
phase: 'primary',
rating: 'good',
distance: 5,
featureName: 'Good+ primary schools within 5km',
},
{
phase: 'secondary',
rating: 'good',
distance: 5,
featureName: 'Good+ secondary schools within 5km',
},
{
phase: 'primary',
rating: 'outstanding',
distance: 5,
featureName: 'Outstanding primary schools within 5km',
},
{
phase: 'secondary',
rating: 'outstanding',
distance: 5,
featureName: 'Outstanding secondary schools within 5km',
featureName: 'Outstanding secondary school catchments',
},
];
@ -81,42 +51,34 @@ export function getSchoolFilterConfig(name: string): SchoolFilterConfig | null {
return SCHOOL_FILTERS.find((filter) => filter.featureName === name) ?? null;
}
export function getSchoolFeatureName(
phase: SchoolPhase,
rating: SchoolRating,
distance: SchoolDistance
): string {
export function getSchoolFeatureName(phase: SchoolPhase, rating: SchoolRating): string {
return (
SCHOOL_FILTERS.find(
(filter) => filter.phase === phase && filter.rating === rating && filter.distance === distance
)?.featureName ?? SCHOOL_FILTERS[0].featureName
SCHOOL_FILTERS.find((filter) => filter.phase === phase && filter.rating === rating)
?.featureName ?? SCHOOL_FILTERS[0].featureName
);
}
export function createSchoolFilterKey(
phase: SchoolPhase,
rating: SchoolRating,
distance: SchoolDistance,
id: number | string
): string {
return `${SCHOOL_FILTER_KEY_PREFIX}${phase}:${rating}:${distance}:${id}`;
return `${SCHOOL_FILTER_KEY_PREFIX}${phase}:${rating}:${id}`;
}
export function getSchoolFilterKeyId(name: string): string | null {
if (!name.startsWith(SCHOOL_FILTER_KEY_PREFIX)) return null;
return name.split(':')[4] ?? null;
return name.split(':')[3] ?? null;
}
export function parseSchoolFilterKey(name: string): SchoolFilterConfig | null {
if (!name.startsWith(SCHOOL_FILTER_KEY_PREFIX)) return null;
const [, phaseRaw, ratingRaw, distanceRaw] = name.split(':');
const [, phaseRaw, ratingRaw] = name.split(':');
const phase = phaseRaw as SchoolPhase;
const rating = ratingRaw as SchoolRating;
const distance = Number(distanceRaw) as SchoolDistance;
if (
(phase !== 'primary' && phase !== 'secondary') ||
(rating !== 'good' && rating !== 'outstanding') ||
(distance !== 2 && distance !== 5)
(rating !== 'good' && rating !== 'outstanding')
) {
return null;
}
@ -124,8 +86,7 @@ export function parseSchoolFilterKey(name: string): SchoolFilterConfig | null {
return {
phase,
rating,
distance,
featureName: getSchoolFeatureName(phase, rating, distance),
featureName: getSchoolFeatureName(phase, rating),
};
}
@ -139,18 +100,12 @@ export function replaceSchoolFilterKeySelection(
next: {
phase?: SchoolPhase;
rating?: SchoolRating;
distance?: SchoolDistance;
}
): string {
const config = getSchoolFilterConfig(key) ?? SCHOOL_FILTERS[0];
const parts = key.startsWith(SCHOOL_FILTER_KEY_PREFIX) ? key.split(':') : [];
const id = parts[4] ?? '0';
return createSchoolFilterKey(
next.phase ?? config.phase,
next.rating ?? config.rating,
next.distance ?? config.distance,
id
);
const id = parts[3] ?? '0';
return createSchoolFilterKey(next.phase ?? config.phase, next.rating ?? config.rating, id);
}
export function getDefaultSchoolFeatureName(features: FeatureMeta[]): string | null {
@ -171,14 +126,7 @@ export function normalizeSchoolFilters(filters: FeatureFilters): FeatureFilters
if (isBackendSchoolFeatureName(name)) {
const config = getSchoolFilterConfig(name);
if (!config) continue;
next[
createSchoolFilterKey(
config.phase,
config.rating,
config.distance,
Object.keys(next).length
)
] = value;
next[createSchoolFilterKey(config.phase, config.rating, Object.keys(next).length)] = value;
changed = true;
continue;
}
@ -201,9 +149,9 @@ export function getSchoolFilterMeta(features: FeatureMeta[]): FeatureMeta {
min: sourceFeature?.min ?? 0,
max: sourceFeature?.max ?? 10,
step: 1,
description: 'Rated primary and secondary schools nearby',
description: 'Rated schools whose catchment area likely covers the postcode',
detail:
'Filter by primary or secondary schools, Ofsted rating, and whether schools are within 2km or 5km.',
'Filter by how many Good+ or Outstanding primary or secondary schools have a historical catchment area covering the postcode. Catchments are modelled from each schools pupil numbers and local child population, approximating distance-based admissions.',
source: 'ofsted',
raw: true,
};

View file

@ -352,8 +352,8 @@ describe('url-state', () => {
});
it('round-trips repeated school filters with dedicated URL params', () => {
const schoolOne = createSchoolFilterKey('primary', 'good', 2, 1);
const schoolTwo = createSchoolFilterKey('secondary', 'outstanding', 5, 2);
const schoolOne = createSchoolFilterKey('primary', 'good', 1);
const schoolTwo = createSchoolFilterKey('secondary', 'outstanding', 2);
const params = stateToParams(
null,
@ -366,18 +366,22 @@ describe('url-state', () => {
'area'
);
expect(params.getAll('school')).toEqual([
'primary:good:2:1:10',
'secondary:outstanding:5:2:15',
]);
expect(params.getAll('school')).toEqual(['primary:good:1:10', 'secondary:outstanding:2:15']);
expect(params.getAll('filter')).toEqual([]);
window.history.replaceState({}, '', `/?${params.toString()}`);
const state = parseUrlState();
expect(state.filters).toEqual({
[createSchoolFilterKey('primary', 'good', 2, 0)]: [1, 10],
[createSchoolFilterKey('secondary', 'outstanding', 5, 1)]: [2, 15],
[createSchoolFilterKey('primary', 'good', 0)]: [1, 10],
[createSchoolFilterKey('secondary', 'outstanding', 1)]: [2, 15],
});
});
it('parses legacy school URL params that still carry a distance segment', () => {
window.history.replaceState({}, '', '/?school=primary%3Agood%3A2%3A1%3A10');
expect(parseUrlState().filters).toEqual({
[createSchoolFilterKey('primary', 'good', 0)]: [1, 10],
});
});

View file

@ -12,7 +12,6 @@ import {
createSchoolFilterKey,
getSchoolFilterConfig,
isSchoolFilterName,
type SchoolDistance,
type SchoolPhase,
type SchoolRating,
} from './school-filter';
@ -122,22 +121,22 @@ function parseFilters(params: URLSearchParams): FeatureFilters {
schoolParams.forEach((entry, index) => {
const parts = entry.split(':');
if (parts.length !== 5) return;
// 4 parts is the current phase:rating:min:max form; 5 parts is the legacy
// phase:rating:distance:min:max form, whose distance segment is ignored.
if (parts.length !== 4 && parts.length !== 5) return;
const phase = parts[0] as SchoolPhase;
const rating = parts[1] as SchoolRating;
const distance = Number(parts[2]) as SchoolDistance;
const min = Number(parts[3]);
const max = Number(parts[4]);
const min = Number(parts[parts.length - 2]);
const max = Number(parts[parts.length - 1]);
if (
(phase !== 'primary' && phase !== 'secondary') ||
(rating !== 'good' && rating !== 'outstanding') ||
(distance !== 2 && distance !== 5) ||
isNaN(min) ||
isNaN(max)
) {
return;
}
filters[createSchoolFilterKey(phase, rating, distance, index)] = [min, max];
filters[createSchoolFilterKey(phase, rating, index)] = [min, max];
});
crimeParams.forEach((entry, index) => {
@ -379,10 +378,7 @@ export function stateToParams(
const schoolConfig = getSchoolFilterConfig(name);
if (schoolConfig && isSchoolFilterName(name)) {
const [min, max] = value as [number, number];
params.append(
'school',
`${schoolConfig.phase}:${schoolConfig.rating}:${schoolConfig.distance}:${min}:${max}`
);
params.append('school', `${schoolConfig.phase}:${schoolConfig.rating}:${min}:${max}`);
continue;
}