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 { fetchWithRetry, apiUrl, logNonAbortError } from './lib/api';
import { trackEvent } from './lib/analytics'; import { trackEvent } from './lib/analytics';
import { parseUrlState } from './lib/url-state'; import { parseUrlState } from './lib/url-state';
import pb from './lib/pocketbase';
import { INITIAL_VIEW_STATE } from './lib/consts'; import { INITIAL_VIEW_STATE } from './lib/consts';
import { useTheme } from './hooks/useTheme'; import { useTheme } from './hooks/useTheme';
import { useIsMobile } from './hooks/useIsMobile'; import { useIsMobile } from './hooks/useIsMobile';
@ -40,6 +41,7 @@ const SavedPage = lazy(() =>
import('./components/account/AccountPage').then((module) => ({ default: module.SavedPage })) import('./components/account/AccountPage').then((module) => ({ default: module.SavedPage }))
); );
const InvitePage = lazy(() => import('./components/invite/InvitePage')); const InvitePage = lazy(() => import('./components/invite/InvitePage'));
const LegalPage = lazy(() => import('./components/legal/LegalPage'));
const MapPage = lazy(() => import('./components/map/MapPage')); const MapPage = lazy(() => import('./components/map/MapPage'));
const AuthModal = lazy(() => import('./components/ui/AuthModal')); const AuthModal = lazy(() => import('./components/ui/AuthModal'));
const SaveSearchModal = lazy(() => import('./components/ui/SaveSearchModal')); const SaveSearchModal = lazy(() => import('./components/ui/SaveSearchModal'));
@ -77,19 +79,42 @@ function currentRelativePath(): string {
return `${window.location.pathname}${window.location.search}`; 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 { function isProtectedPage(page: Page): boolean {
return page === 'account' || page === 'saved'; 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 { function buildPageUrl(page: Page, inviteCode?: string, search = '', hash = ''): string {
const normalizedHash = normalizeHash(hash); const normalizedHash = normalizeHash(hash);
return `${pageToPath(page, inviteCode)}${search}${normalizedHash ? `#${normalizedHash}` : ''}`; return `${pageToPath(page, inviteCode)}${search}${normalizedHash ? `#${normalizedHash}` : ''}`;
@ -126,6 +151,10 @@ function pageToPath(page: Page, inviteCode?: string): string {
case 'methodology': case 'methodology':
case 'privacy-security': case 'privacy-security':
return SEO_CONTENT_PATHS[page]; return SEO_CONTENT_PATHS[page];
case 'terms':
return '/terms';
case 'privacy':
return '/privacy';
case 'saved': case 'saved':
return '/saved'; return '/saved';
case 'account': 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 === '/dashboard') return { page: 'dashboard' };
if (pathname === '/saved') return { page: 'saved' }; if (pathname === '/saved') return { page: 'saved' };
if (pathname === '/invites') return { page: 'account', hash: 'invites' }; if (pathname === '/invites') return { page: 'account', hash: 'invites' };
@ -152,6 +184,8 @@ function pathToPage(pathname: string): RouteMatch | null {
if (seoContentPage) return { page: seoContentPage }; if (seoContentPage) return { page: seoContentPage };
if (pathname === '/account') return { page: 'account' }; if (pathname === '/account') return { page: 'account' };
if (pathname === '/support') return { page: 'learn' }; if (pathname === '/support') return { page: 'learn' };
if (pathname === '/terms') return { page: 'terms' };
if (pathname === '/privacy') return { page: 'privacy' };
if (pathname.startsWith('/invite/')) { if (pathname.startsWith('/invite/')) {
const code = pathname.slice('/invite/'.length); const code = pathname.slice('/invite/'.length);
return { page: 'invite', inviteCode: code }; return { page: 'invite', inviteCode: code };
@ -169,7 +203,11 @@ function isSeoContentPage(page: Page): page is SeoContentKey {
} }
export default function App() { 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 initialRoute = useMemo(() => pathToPage(window.location.pathname), []);
const [mapUrlState, setMapUrlState] = useState(urlState); const [mapUrlState, setMapUrlState] = useState(urlState);
const [dashboardRouteKey, setDashboardRouteKey] = useState(() => const [dashboardRouteKey, setDashboardRouteKey] = useState(() =>
@ -276,7 +314,9 @@ export default function App() {
if (!completed) { if (!completed) {
setPostAuthIntent(null); setPostAuthIntent(null);
postAuthCheckoutReturnPathRef.current = 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: '' }, '', '/'); window.history.replaceState({ page: 'home', hash: '' }, '', '/');
setRouteHash(''); setRouteHash('');
setActivePage('home'); setActivePage('home');
@ -300,8 +340,11 @@ export default function App() {
async function refreshOnStartup() { async function refreshOnStartup() {
if (!returnedFromCheckout) { if (!returnedFromCheckout) {
// Always refresh auth on startup to pick up server-side subscription changes. // Refresh auth on startup to pick up server-side subscription changes,
refreshAuthRef.current().catch(() => {}); // but only when a token exists — logged-out visitors would just 401.
if (pb.authStore.token) {
refreshAuthRef.current().catch(() => {});
}
return; return;
} }
@ -384,8 +427,10 @@ export default function App() {
if (infoFeature) { if (infoFeature) {
window.history.replaceState({ ...window.history.state, infoFeature }, ''); window.history.replaceState({ ...window.history.state, infoFeature }, '');
} }
// Restore dashboard search params when navigating back // Restore dashboard search params when navigating back, falling back to
const search = page === 'dashboard' ? dashboardSearchRef.current : ''; // 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); const url = buildPageUrl(page, inviteCode ?? undefined, search, targetHash);
window.history.pushState({ page, hash: targetHash }, '', url); window.history.pushState({ page, hash: targetHash }, '', url);
if (page === 'dashboard') { if (page === 'dashboard') {
@ -527,19 +572,20 @@ export default function App() {
} }
}, [activePage, fetchSearches]); }, [activePage, fetchSearches]);
const isAuthRequiredPage = const isProtectedPageActive = isProtectedPage(activePage);
activePage === 'account' || // Only protected pages (account/saved) prompt for login on entry. The
activePage === 'saved' || // dashboard opens straight into demo mode (server-enforced free zone) so
(activePage === 'dashboard' && !mapUrlState.share); // visitors can try it without logging in; the upgrade modal still appears
// when they pan outside the free zone.
useEffect(() => { useEffect(() => {
if (authLoading) return; if (authLoading) return;
if (isAuthRequiredPage && !user) { if (isProtectedPageActive && !user) {
openAuthModal('login'); openAuthModal('login');
} }
if (activePage === 'pricing' && hasFullAccess(user)) { if (activePage === 'pricing' && hasFullAccess(user)) {
navigateTo('dashboard'); navigateTo('dashboard');
} }
}, [activePage, authLoading, isAuthRequiredPage, navigateTo, openAuthModal, user]); }, [activePage, authLoading, isProtectedPageActive, navigateTo, openAuthModal, user]);
const [exportState, setExportState] = useState<ExportState | null>(null); const [exportState, setExportState] = useState<ExportState | null>(null);
@ -641,6 +687,8 @@ export default function App() {
/> />
) : activePage === 'learn' ? ( ) : activePage === 'learn' ? (
<LearnPage /> <LearnPage />
) : activePage === 'terms' || activePage === 'privacy' ? (
<LegalPage kind={activePage} />
) : isSeoLandingPage(activePage) ? ( ) : isSeoLandingPage(activePage) ? (
<SeoLandingPage pageKey={activePage} onOpenDashboard={() => navigateTo('dashboard')} /> <SeoLandingPage pageKey={activePage} onOpenDashboard={() => navigateTo('dashboard')} />
) : isSeoContentPage(activePage) ? ( ) : isSeoContentPage(activePage) ? (
@ -662,7 +710,7 @@ export default function App() {
}} }}
scrollTarget={routeHash} scrollTarget={routeHash}
/> />
) : isAuthRequiredPage && !user ? ( ) : isProtectedPageActive && !user ? (
<PageFallback /> <PageFallback />
) : activePage === 'invite' && inviteCode ? ( ) : activePage === 'invite' && inviteCode ? (
<InvitePage <InvitePage
@ -695,7 +743,10 @@ export default function App() {
onClearPendingInfoFeature={() => setPendingInfoFeature(null)} onClearPendingInfoFeature={() => setPendingInfoFeature(null)}
onNavigateTo={navigateTo} onNavigateTo={navigateTo}
onExportStateChange={setExportState} onExportStateChange={setExportState}
onDashboardParamsChange={setDashboardParams} onDashboardParamsChange={(params) => {
setDashboardParams(params);
if (!mapUrlState.share) persistLastDashboardParams(params);
}}
onDashboardReadyChange={setDashboardReady} onDashboardReadyChange={setDashboardReady}
isMobile={isMobile} isMobile={isMobile}
initialTravelTime={mapUrlState.travelTime} initialTravelTime={mapUrlState.travelTime}

View file

@ -8,6 +8,7 @@ import BottomIllustration from './BottomIllustration';
import { TickerValue } from '../ui/TickerValue'; import { TickerValue } from '../ui/TickerValue';
import { ChevronIcon, LogoIcon, PlayIcon } from '../ui/icons'; import { ChevronIcon, LogoIcon, PlayIcon } from '../ui/icons';
import { trackEvent } from '../../lib/analytics'; import { trackEvent } from '../../lib/analytics';
import { apiUrl } from '../../lib/api';
const BRAND_NAME = 'Perfect Postcode'; const BRAND_NAME = 'Perfect Postcode';
const BRAND_TEXT_CLASS = 'text-teal-600 dark:text-teal-400'; 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({ export default function HomePage({
onOpenDashboard, onOpenDashboard,
onOpenPricing: _onOpenPricing, onOpenPricing,
theme = 'light', theme = 'light',
hidePricing: _hidePricing, hidePricing,
}: { }: {
onOpenDashboard: () => void; onOpenDashboard: () => void;
onOpenPricing: () => 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"> <p className="text-base md:text-lg text-warm-200 mb-8 max-w-xl">
{highlightBrandText(t('home.heroDescription'), 'font-semibold text-teal-300')} {highlightBrandText(t('home.heroDescription'), 'font-semibold text-teal-300')}
</p> </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 <button
onClick={() => { onClick={() => {
trackEvent('CTA Click', { location: 'hero', label: 'explore_map' }); trackEvent('CTA Click', { location: 'hero', label: 'explore_map' });
@ -347,6 +415,8 @@ export default function HomePage({
{t('home.seeTheDifference')} {t('home.seeTheDifference')}
</button> </button>
</div> </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-stats flex flex-wrap pt-3 border-t border-white/10">
<div className="home-hero-stat"> <div className="home-hero-stat">
<div className="home-hero-stat-value"> <div className="home-hero-stat-value">
@ -356,7 +426,7 @@ export default function HomePage({
</div> </div>
<div className="home-hero-stat"> <div className="home-hero-stat">
<div className="home-hero-stat-value"> <div className="home-hero-stat-value">
<TickerValue text="56" active={statsActive} /> <TickerValue text="40+" active={statsActive} />
</div> </div>
<div className="home-hero-stat-label">{t('home.statFilters')}</div> <div className="home-hero-stat-label">{t('home.statFilters')}</div>
</div> </div>

View file

@ -82,7 +82,7 @@ const DEMO_FEATURES: FeatureMeta[] = [
step: 1, step: 1,
}, },
{ {
name: 'Good+ primary schools within 2km', name: 'Good+ primary school catchments',
type: 'numeric', type: 'numeric',
group: 'Schools', group: 'Schools',
min: 0, min: 0,
@ -300,7 +300,7 @@ function interpolateViewState(progress: number): ViewState {
function demoSliderStep(feature: FeatureMeta): number { function demoSliderStep(feature: FeatureMeta): number {
if (feature.name === 'Estimated price') return 1000; if (feature.name === 'Estimated price') return 1000;
if (feature.name === 'Noise (dB)') return 0.05; 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; if (feature.name === 'Travel time to nearest train or tube station (min)') return 0.1;
return feature.step ?? 1; return feature.step ?? 1;
} }
@ -350,7 +350,7 @@ function FilterPreviewRow({
const shortLabelKeys = { const shortLabelKeys = {
'Estimated price': 'home.showcaseFeaturePriceShort', 'Estimated price': 'home.showcaseFeaturePriceShort',
'Noise (dB)': 'home.showcaseFeatureNoiseShort', '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', 'Travel time to nearest train or tube station (min)': 'home.showcaseFeatureTravelShort',
} as const; } as const;
const shortLabelKey = shortLabelKeys[feature.name as keyof typeof shortLabelKeys]; 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)') { if (feature.name === 'Noise (dB)') {
return `${Math.round(value[0])} - ${Math.round(value[1])} 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]) }); return t('home.showcaseGoodPrimariesNearby', { count: Math.round(value[0]) });
} }
if (feature.name === 'Travel time to nearest train or tube station (min)') { 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 { tDynamic } from '../../i18n';
import { getLocalizedSeoPages } from '../../lib/seoLandingPages'; import { getLocalizedSeoPages } from '../../lib/seoLandingPages';
import { ChevronIcon } from '../ui/icons/ChevronIcon'; import { ChevronIcon } from '../ui/icons/ChevronIcon';
import { PlayIcon } from '../ui/icons';
import { SubNav } from '../ui/SubNav'; 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 { interface DataSourceDef {
id: string; 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() { export default function LearnPage() {
const { t, i18n } = useTranslation(); const { t, i18n } = useTranslation();
const [tab, setTab] = useState<LearnTab>('faq'); const [tab, setTab] = useState<LearnTab>('faq');
@ -197,6 +302,7 @@ export default function LearnPage() {
{ key: 'faq', label: t('learnPage.faq') }, { key: 'faq', label: t('learnPage.faq') },
{ key: 'data-sources', label: t('learnPage.dataSources') }, { key: 'data-sources', label: t('learnPage.dataSources') },
{ key: 'articles', label: t('learnPage.articles') }, { key: 'articles', label: t('learnPage.articles') },
{ key: 'videos', label: t('learnPage.videos') },
{ key: 'support', label: t('learnPage.support') }, { key: 'support', label: t('learnPage.support') },
]; ];
@ -268,6 +374,9 @@ export default function LearnPage() {
} else if (hash === 'articles') { } else if (hash === 'articles') {
setTab('articles'); setTab('articles');
setHighlightedId(null); setHighlightedId(null);
} else if (hash === 'videos') {
setTab('videos');
setHighlightedId(null);
} else if (hash === 'support') { } else if (hash === 'support') {
setTab('support'); setTab('support');
setHighlightedId(null); setHighlightedId(null);
@ -300,141 +409,163 @@ export default function LearnPage() {
<SubNav tabs={LEARN_TABS} activeTab={tab} onTabChange={switchTab} /> <SubNav tabs={LEARN_TABS} activeTab={tab} onTabChange={switchTab} />
<div className="flex-1 overflow-y-auto flex flex-col" ref={scrollContainerRef}> <div className="flex-1 overflow-y-auto flex flex-col" ref={scrollContainerRef}>
{tab === 'data-sources' ? ( <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"> <div className="flex-1">
<h1 className="text-2xl md:text-3xl font-bold text-warm-900 dark:text-warm-100 mb-3"> <div className="max-w-5xl mx-auto px-6 py-6">
{t('learnPage.dataSources')} <h1 className="text-2xl md:text-3xl font-bold text-warm-900 dark:text-warm-100 mb-3">
</h1> {t('learnPage.dataSources')}
<p className="text-warm-600 dark:text-warm-400 mb-6"> </h1>
{t('learnPage.dataSourcesIntro', { count: DATA_SOURCE_DEFS.length })} <p className="text-warm-600 dark:text-warm-400 mb-6">
</p> {t('learnPage.dataSourcesIntro', { count: DATA_SOURCE_DEFS.length })}
<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}
</p> </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> ) : tab === 'articles' ? (
) : ( <div className="max-w-5xl mx-auto px-6 py-6 w-full">
<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">
<h1 className="text-2xl md:text-3xl font-bold text-warm-900 dark:text-warm-100 mb-3"> {t('learnPage.articles')}
{t('learnPage.support')} </h1>
</h1> <p className="text-warm-600 dark:text-warm-400 mb-6">
<p className="text-warm-600 dark:text-warm-400 mb-6">{t('learnPage.supportIntro')}</p> {t('learnPage.articlesIntro')}
<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> </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>
</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>
</div> </div>
); );

View file

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

View file

@ -26,6 +26,7 @@ import { apiUrl, authHeaders, buildFilterString } from '../../lib/api';
import { useFilterCounts } from '../../hooks/useFilterCounts'; import { useFilterCounts } from '../../hooks/useFilterCounts';
import { trackEvent } from '../../lib/analytics'; import { trackEvent } from '../../lib/analytics';
import { INITIAL_VIEW_STATE, POSTCODE_ZOOM_THRESHOLD } from '../../lib/consts'; import { INITIAL_VIEW_STATE, POSTCODE_ZOOM_THRESHOLD } from '../../lib/consts';
import { boundsToCenterZoom } from '../../lib/fit-bounds';
import type { OverlayId } from '../../lib/overlays'; import type { OverlayId } from '../../lib/overlays';
import { CRIME_TYPE_VALUES } from '../../lib/crime-types'; import { CRIME_TYPE_VALUES } from '../../lib/crime-types';
import type { BasemapId } from '../../lib/basemaps'; 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; const firstTravelTime = representable[0]?.tt;
if (!firstTravelTime?.slug) return; if (!firstTravelTime?.slug) return;
@ -482,7 +491,15 @@ export default function MapPage({
}, []); }, []);
const shareReturnViewRef = useRef(shareCode ? initialViewState : null); 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(() => { const handleZoomToFreeZone = useCallback(() => {
setUpgradeModalDismissed(true);
const target = shareReturnViewRef.current ?? INITIAL_VIEW_STATE; const target = shareReturnViewRef.current ?? INITIAL_VIEW_STATE;
mapFlyToRef.current?.(target.latitude, target.longitude, target.zoom); 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 tutorial = useTutorial(initialLoading, isMobile, deferTutorial || mapData.licenseRequired);
const tutorialTheme = useMemo(() => getTutorialStyles(theme), [theme]); const tutorialTheme = useMemo(() => getTutorialStyles(theme), [theme]);
const densityLabel = t('mapLegend.historicalMatches'); const densityLabel = t('mapLegend.historicalMatches');
const hasActiveFilters = Object.keys(filters).length > 0 || activeEntries.length > 0;
const mobileLegendMeta = useMobileLegendMeta(viewFeature, features); const mobileLegendMeta = useMobileLegendMeta(viewFeature, features);
const mapViewFeature = useMapViewFeature(viewFeature); const mapViewFeature = useMapViewFeature(viewFeature);
const mobileDensityRange = useMobileDensityRange(mapData); const mobileDensityRange = useMobileDensityRange(mapData);
@ -661,7 +677,13 @@ export default function MapPage({
}, [onDashboardReadyChange]); }, [onDashboardReadyChange]);
useEffect(() => { 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]); }, [mapData.licenseRequired]);
if (screenshotMode) { if (screenshotMode) {
@ -856,22 +878,25 @@ export default function MapPage({
</div> </div>
) : null; ) : null;
const upgradeModal = mapData.licenseRequired ? ( const upgradeModal =
<Suspense fallback={null}> mapData.licenseRequired && !upgradeModalDismissed ? (
<UpgradeModal <Suspense fallback={null}>
isLoggedIn={!!user} <UpgradeModal
onLoginClick={() => isLoggedIn={!!user}
onCheckoutLoginClick ? onCheckoutLoginClick(checkoutReturnPath) : onLoginClick() onLoginClick={() =>
} onCheckoutLoginClick ? onCheckoutLoginClick(checkoutReturnPath) : onLoginClick()
onRegisterClick={() => }
onCheckoutRegisterClick ? onCheckoutRegisterClick(checkoutReturnPath) : onRegisterClick() onRegisterClick={() =>
} onCheckoutRegisterClick
onStartCheckout={() => license.startCheckout(checkoutReturnPath)} ? onCheckoutRegisterClick(checkoutReturnPath)
onZoomToFreeZone={handleZoomToFreeZone} : onRegisterClick()
isShareReturn={!!shareReturnViewRef.current} }
/> onStartCheckout={() => license.startCheckout(checkoutReturnPath)}
</Suspense> onZoomToFreeZone={handleZoomToFreeZone}
) : null; isShareReturn={!!shareReturnViewRef.current}
/>
</Suspense>
) : null;
if (isMobile) { if (isMobile) {
return ( return (
@ -980,7 +1005,7 @@ export default function MapPage({
onToggleActualListings={canSeeListings ? handleToggleActualListings : undefined} onToggleActualListings={canSeeListings ? handleToggleActualListings : undefined}
travelTimeEntries={entries} travelTimeEntries={entries}
densityLabel={densityLabel} densityLabel={densityLabel}
totalCount={hasActiveFilters ? filterCounts.total : undefined} totalCount={filterCounts.total ?? undefined}
poiPaneOpen={poiPaneOpen} poiPaneOpen={poiPaneOpen}
onTogglePoiPane={handleTogglePoiPane} onTogglePoiPane={handleTogglePoiPane}
poiPane={renderPOIPane()} poiPane={renderPOIPane()}

View file

@ -6,11 +6,16 @@ interface PriceHistoryChartProps {
points: PricePoint[]; 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 HEIGHT = 120;
const PRICE_SCALE_TOP_PERCENTILE = 95; const PRICE_SCALE_TOP_PERCENTILE = 95;
const priceFmt = { prefix: '£' }; 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 { interface PriceScale {
min: number; min: number;
max: number; max: number;
@ -148,7 +153,7 @@ export default function PriceHistoryChart({ points }: PriceHistoryChartProps) {
className="fill-warm-500 dark:fill-warm-400" className="fill-warm-500 dark:fill-warm-400"
fontSize={10} fontSize={10}
> >
{formatValue(tick, priceFmt)} {formatTick(tick)}
</text> </text>
))} ))}

View file

@ -9,7 +9,9 @@ import { TravelTimeInfoPopup } from '../ui/TravelTimeInfoPopup';
import { CloseIcon } from '../ui/icons/CloseIcon'; import { CloseIcon } from '../ui/icons/CloseIcon';
import { EyeIcon } from '../ui/icons/EyeIcon'; import { EyeIcon } from '../ui/icons/EyeIcon';
import { InfoIcon } from '../ui/icons/InfoIcon'; 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 { useTravelDestinations } from '../../hooks/useTravelDestinations';
import { import {
MAX_TRAVEL_MINUTES, MAX_TRAVEL_MINUTES,
@ -56,7 +58,7 @@ export function TravelTimeCard({
dragValue, dragValue,
onTogglePin, onTogglePin,
onSetDestination, onSetDestination,
onTimeRangeChange: _onTimeRangeChange, onTimeRangeChange,
onDragStart, onDragStart,
onDragChange, onDragChange,
onDragEnd, onDragEnd,
@ -86,6 +88,17 @@ export function TravelTimeCard({
const sliderMax = MAX_TRAVEL_MINUTES; const sliderMax = MAX_TRAVEL_MINUTES;
const displayRange = isActive && dragValue ? dragValue : (timeRange ?? [sliderMin, sliderMax]); 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]; const ModeIcon = MODE_ICONS[mode];
return ( return (
@ -211,14 +224,17 @@ export function TravelTimeCard({
onPointerDown={() => onDragStart(displayRange)} onPointerDown={() => onDragStart(displayRange)}
onPointerUp={() => onDragEnd()} onPointerUp={() => onDragEnd()}
/> />
<div className="relative h-4 mt-1 mx-2.5 text-[10px] text-warm-500 dark:text-warm-400 leading-tight"> <SliderLabels
<span className="absolute left-0"> min={sliderMin}
{formatFilterValue(displayRange[0])} {t('common.minute')} max={sliderMax}
</span> value={[displayRange[0], displayRange[1]]}
<span className="absolute right-0"> isAtMin={displayRange[0] <= sliderMin}
{formatFilterValue(displayRange[1])} {t('common.minute')} isAtMax={displayRange[1] >= sliderMax}
</span> raw
</div> showUnit
feature={travelFeature}
onValueChange={onTimeRangeChange}
/>
{filterImpact != null && filterImpact > 0 && ( {filterImpact != null && filterImpact > 0 && (
<p className="text-[10px] text-warm-400 dark:text-warm-500 -mt-1 ml-2.5"> <p className="text-[10px] text-warm-400 dark:text-warm-500 -mt-1 ml-2.5">
{t('filters.filtersOut', { value: formatNumber(filterImpact) })} {t('filters.filtersOut', { value: formatNumber(filterImpact) })}

View file

@ -11,7 +11,6 @@ import {
getSchoolFilterConfig, getSchoolFilterConfig,
getSchoolFilterMeta, getSchoolFilterMeta,
replaceSchoolFilterKeySelection, replaceSchoolFilterKeySelection,
type SchoolDistance,
type SchoolPhase, type SchoolPhase,
type SchoolRating, type SchoolRating,
} from '../../../lib/school-filter'; } from '../../../lib/school-filter';
@ -74,7 +73,6 @@ export function SchoolFilterCard({
next: Partial<{ next: Partial<{
phase: SchoolPhase; phase: SchoolPhase;
rating: SchoolRating; rating: SchoolRating;
distance: SchoolDistance;
}> }>
) => { ) => {
const nextName = replaceSchoolFilterKeySelection(schoolFeature.name, next); const nextName = replaceSchoolFilterKeySelection(schoolFeature.name, next);
@ -180,35 +178,6 @@ export function SchoolFilterCard({
</button> </button>
</div> </div>
</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> </div>
<Slider <Slider

View file

@ -8,6 +8,7 @@ import { logNonAbortError } from '../../lib/api';
import { trackEvent } from '../../lib/analytics'; import { trackEvent } from '../../lib/analytics';
import { apiUrl } from '../../lib/api'; import { apiUrl } from '../../lib/api';
import HexCanvas from '../home/HexCanvas'; import HexCanvas from '../home/HexCanvas';
import { useIsDarkTheme } from '../../hooks/useIsDarkTheme';
// Feature list keys — resolved inside the component via t() // Feature list keys — resolved inside the component via t()
@ -39,6 +40,7 @@ export default function PricingPage({
onRegisterClick?: () => void; onRegisterClick?: () => void;
}) { }) {
const { t } = useTranslation(); const { t } = useTranslation();
const isDark = useIsDarkTheme();
const license = useLicense(); const license = useLicense();
const [pricing, setPricing] = useState<PricingData | null>(null); const [pricing, setPricing] = useState<PricingData | null>(null);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
@ -137,17 +139,23 @@ export default function PricingPage({
) : null; ) : null;
return ( 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="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" /> <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 /> <HexCanvas isDark={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 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>
<div className="relative z-10 max-w-5xl mx-auto px-6 pt-16 text-center mb-10"> <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> <h1 className="text-3xl md:text-4xl font-bold text-navy-950 dark:text-white mb-3">
<p className="text-lg text-warm-300 max-w-lg mx-auto">{t('pricingPage.subtitle')}</p> {t('pricingPage.title')}
<p className="mt-5 text-warm-200 font-semibold">{t('pricingPage.lessThanSurvey')}</p> </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>
<div className="relative z-10 max-w-5xl mx-auto px-6 pb-8 md:pb-16"> <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 ${ className={`relative flex flex-col rounded-2xl border overflow-hidden w-80 shrink-0 ${
isCurrent isCurrent
? 'border-teal-400 ring-2 ring-teal-400 shadow-lg' ? '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' : ''}`} } ${isFilled ? 'opacity-60' : ''}`}
> >
{isCurrent && ( {isCurrent && (
@ -266,11 +274,6 @@ export default function PricingPage({
})} })}
</p> </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> </div>
{/* Progress bar for current tier */} {/* Progress bar for current tier */}
@ -308,10 +311,14 @@ export default function PricingPage({
{license.error} {license.error}
</p> </p>
)} )}
{isFree && ( {isFree ? (
<p className="text-center text-xs text-warm-400 dark:text-warm-500 mt-2"> <p className="text-center text-xs text-warm-400 dark:text-warm-500 mt-2">
{t('pricingPage.noCreditCard')} {t('pricingPage.noCreditCard')}
</p> </p>
) : (
<p className="text-center text-xs text-warm-400 dark:text-warm-500 mt-2">
{t('pricingPage.moneyBack')}
</p>
)} )}
</> </>
) : isFilled ? ( ) : isFilled ? (

View file

@ -1,5 +1,5 @@
import { useState, useCallback, useEffect, useId } from 'react'; import { useState, useCallback, useEffect, useId } from 'react';
import { useTranslation } from 'react-i18next'; import { Trans, useTranslation } from 'react-i18next';
import { CloseIcon } from './icons/CloseIcon'; import { CloseIcon } from './icons/CloseIcon';
import { GoogleIcon } from './icons/GoogleIcon'; import { GoogleIcon } from './icons/GoogleIcon';
import { trackEvent } from '../../lib/analytics'; import { trackEvent } from '../../lib/analytics';
@ -106,7 +106,7 @@ export default function AuthModal({
return ( return (
<div <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) => { onMouseDown={(e) => {
if (e.target === e.currentTarget) onClose(); if (e.target === e.currentTarget) onClose();
}} }}
@ -283,6 +283,32 @@ export default function AuthModal({
{t('auth.backToLogin')} {t('auth.backToLogin')}
</button> </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> </form>
</div> </div>
</div> </div>

View file

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

View file

@ -18,6 +18,12 @@ interface SearchHook {
handleInputChange: (value: string) => void; handleInputChange: (value: string) => void;
handleKeyDown: (e: React.KeyboardEvent, onSelect: (result: SearchResult) => void) => void; handleKeyDown: (e: React.KeyboardEvent, onSelect: (result: SearchResult) => void) => void;
showEmptySearches: () => 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 { interface PlaceSearchInputProps {
@ -65,6 +71,9 @@ export function PlaceSearchInput({
const dropdown = showDropdown && ( const dropdown = showDropdown && (
<div <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`} 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={ style={
portal && dropdownPos portal && dropdownPos
? { ? {
@ -116,8 +125,11 @@ export function PlaceSearchInput({
) : result.type === 'address' ? ( ) : result.type === 'address' ? (
<> <>
<HouseIcon className={`${iconSize} text-warm-400 dark:text-warm-500 shrink-0`} /> <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
<span className="block truncate">{result.address}</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"> <span className="block truncate text-warm-400 dark:text-warm-500">
{result.postcode} {result.postcode}
</span> </span>
@ -126,7 +138,10 @@ export function PlaceSearchInput({
) : ( ) : (
<> <>
<MapPinIcon className={`${iconSize} text-warm-400 dark:text-warm-500 shrink-0`} /> <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.name}
{result.city && ( {result.city && (
<span className="text-warm-400 dark:text-warm-500"> ({result.city})</span> <span className="text-warm-400 dark:text-warm-500"> ({result.city})</span>
@ -154,6 +169,9 @@ export function PlaceSearchInput({
onFocus={() => { onFocus={() => {
search.showEmptySearches(); 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)} onKeyDown={(e) => search.handleKeyDown(e, onSelect)}
aria-label={ariaLabel ?? placeholder} aria-label={ariaLabel ?? placeholder}
placeholder={placeholder} placeholder={placeholder}

View file

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

View file

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

View file

@ -1111,6 +1111,34 @@ const de: Translations = {
articlesIntro: articlesIntro:
'Durchsuche die öffentlichen Leitfäden zu Immobiliensuche, Pendeln, Schulen, Postleitzahlprüfungen, regionalen Vergleichen, Datenabdeckung, Methodik und Datenschutz.', '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.', 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:', source: 'Quelle:',
optOut: 'Widerspruch gegen öffentliche Offenlegung', optOut: 'Widerspruch gegen öffentliche Offenlegung',
attribution: 'Quellenangaben', attribution: 'Quellenangaben',

View file

@ -1091,6 +1091,34 @@ const en = {
articlesIntro: articlesIntro:
'Browse the public guides for property search, commute, schools, postcode checks, regional comparisons, data coverage, methodology, and privacy.', '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.', 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:', source: 'Source:',
optOut: 'Opt out of public disclosure', optOut: 'Opt out of public disclosure',
attribution: 'Attribution', attribution: 'Attribution',

View file

@ -1125,6 +1125,34 @@ const fr: Translations = {
articlesIntro: 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é.', '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.', 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 :', source: 'Source :',
optOut: 'Refus de la publication publique', optOut: 'Refus de la publication publique',
attribution: 'Attribution', attribution: 'Attribution',

View file

@ -1072,6 +1072,34 @@ const hi: Translations = {
articlesIntro: articlesIntro:
'संपत्ति खोज, आवागमन, स्कूल, पोस्टकोड जांच, क्षेत्रीय तुलना, डेटा कवरेज, कार्यप्रणाली और गोपनीयता पर सार्वजनिक गाइड देखें.', 'संपत्ति खोज, आवागमन, स्कूल, पोस्टकोड जांच, क्षेत्रीय तुलना, डेटा कवरेज, कार्यप्रणाली और गोपनीयता पर सार्वजनिक गाइड देखें.',
supportIntro: 'कोई सवाल है? हमारे प्रश्नोत्तर देखें या सीधे संपर्क करें.', 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: 'स्रोत:', source: 'स्रोत:',
optOut: 'सार्वजनिक प्रकटीकरण से बाहर निकलें', optOut: 'सार्वजनिक प्रकटीकरण से बाहर निकलें',
attribution: 'श्रेय', attribution: 'श्रेय',

View file

@ -1109,6 +1109,34 @@ const hu: Translations = {
articlesIntro: 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.', '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.', 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:', source: 'Forrás:',
optOut: 'Nyilvános közzététel visszautasítása', optOut: 'Nyilvános közzététel visszautasítása',
attribution: 'Forrásmegnevezés', attribution: 'Forrásmegnevezés',

View file

@ -1045,6 +1045,26 @@ const zh: Translations = {
articlesIntro: articlesIntro:
'浏览关于找房、通勤、学校、邮编速查、区域对比、数据覆盖、方法论和隐私的公开指南。', '浏览关于找房、通勤、学校、邮编速查、区域对比、数据覆盖、方法论和隐私的公开指南。',
supportIntro: '还有疑问?欢迎查看常见问题,或直接与我们联系。', 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: '来源:', source: '来源:',
optOut: '选择不公开', optOut: '选择不公开',
attribution: '数据引用声明', attribution: '数据引用声明',

View file

@ -82,18 +82,18 @@ describe('api utilities', () => {
it('deduplicates repeated synthetic school filters before backend routes', () => { it('deduplicates repeated synthetic school filters before backend routes', () => {
const features: FeatureMeta[] = [ 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( expect(
buildFilterString( buildFilterString(
{ {
[createSchoolFilterKey('primary', 'good', 2, 1)]: [1, 10], [createSchoolFilterKey('primary', 'good', 1)]: [1, 10],
[createSchoolFilterKey('primary', 'good', 2, 2)]: [2, 8], [createSchoolFilterKey('primary', 'good', 2)]: [2, 8],
}, },
features 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', () => { 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" /> <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="M4 19V9l8-6 8 6v10" />
<path d="M9 19v-6h6v6" /> <path d="M9 19v-6h6v6" />
<line x1="4" y1="19" x2="20" y2="19" /> <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="M22 10v6M2 10l10-5 10 5-10 5z" />
<path d="M6 12v5c0 2.5 3 4 6 4s6-1.5 6-4v-5" /> <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="M4 19V9l8-6 8 6v10" />
<path d="M9 19v-6h6v6" /> <path d="M9 19v-6h6v6" />
<line x1="4" y1="19" x2="20" y2="19" /> <line x1="4" y1="19" x2="20" y2="19" />
</> </>
), ),
'Outstanding secondary schools within 5km': ( '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" />
</>
),
'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': (
<> <>
<path d="M22 10v6M2 10l10-5 10 5-10 5z" /> <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" /> <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', 'Ferry',
'Rail station', 'Rail station',
'Taxi rank', 'Taxi rank',
'Tram & Metro stop',
'Tube station', 'Tube station',
]); ]);

View file

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

View file

@ -352,8 +352,8 @@ describe('url-state', () => {
}); });
it('round-trips repeated school filters with dedicated URL params', () => { it('round-trips repeated school filters with dedicated URL params', () => {
const schoolOne = createSchoolFilterKey('primary', 'good', 2, 1); const schoolOne = createSchoolFilterKey('primary', 'good', 1);
const schoolTwo = createSchoolFilterKey('secondary', 'outstanding', 5, 2); const schoolTwo = createSchoolFilterKey('secondary', 'outstanding', 2);
const params = stateToParams( const params = stateToParams(
null, null,
@ -366,18 +366,22 @@ describe('url-state', () => {
'area' 'area'
); );
expect(params.getAll('school')).toEqual([ expect(params.getAll('school')).toEqual(['primary:good:1:10', 'secondary:outstanding:2:15']);
'primary:good:2:1:10',
'secondary:outstanding:5:2:15',
]);
expect(params.getAll('filter')).toEqual([]); expect(params.getAll('filter')).toEqual([]);
window.history.replaceState({}, '', `/?${params.toString()}`); window.history.replaceState({}, '', `/?${params.toString()}`);
const state = parseUrlState(); const state = parseUrlState();
expect(state.filters).toEqual({ expect(state.filters).toEqual({
[createSchoolFilterKey('primary', 'good', 2, 0)]: [1, 10], [createSchoolFilterKey('primary', 'good', 0)]: [1, 10],
[createSchoolFilterKey('secondary', 'outstanding', 5, 1)]: [2, 15], [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, createSchoolFilterKey,
getSchoolFilterConfig, getSchoolFilterConfig,
isSchoolFilterName, isSchoolFilterName,
type SchoolDistance,
type SchoolPhase, type SchoolPhase,
type SchoolRating, type SchoolRating,
} from './school-filter'; } from './school-filter';
@ -122,22 +121,22 @@ function parseFilters(params: URLSearchParams): FeatureFilters {
schoolParams.forEach((entry, index) => { schoolParams.forEach((entry, index) => {
const parts = entry.split(':'); 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 phase = parts[0] as SchoolPhase;
const rating = parts[1] as SchoolRating; const rating = parts[1] as SchoolRating;
const distance = Number(parts[2]) as SchoolDistance; const min = Number(parts[parts.length - 2]);
const min = Number(parts[3]); const max = Number(parts[parts.length - 1]);
const max = Number(parts[4]);
if ( if (
(phase !== 'primary' && phase !== 'secondary') || (phase !== 'primary' && phase !== 'secondary') ||
(rating !== 'good' && rating !== 'outstanding') || (rating !== 'good' && rating !== 'outstanding') ||
(distance !== 2 && distance !== 5) ||
isNaN(min) || isNaN(min) ||
isNaN(max) isNaN(max)
) { ) {
return; return;
} }
filters[createSchoolFilterKey(phase, rating, distance, index)] = [min, max]; filters[createSchoolFilterKey(phase, rating, index)] = [min, max];
}); });
crimeParams.forEach((entry, index) => { crimeParams.forEach((entry, index) => {
@ -379,10 +378,7 @@ export function stateToParams(
const schoolConfig = getSchoolFilterConfig(name); const schoolConfig = getSchoolFilterConfig(name);
if (schoolConfig && isSchoolFilterName(name)) { if (schoolConfig && isSchoolFilterName(name)) {
const [min, max] = value as [number, number]; const [min, max] = value as [number, number];
params.append( params.append('school', `${schoolConfig.phase}:${schoolConfig.rating}:${min}:${max}`);
'school',
`${schoolConfig.phase}:${schoolConfig.rating}:${schoolConfig.distance}:${min}:${max}`
);
continue; continue;
} }

View file

@ -43,12 +43,14 @@ type FormFactor = 'desktop' | 'mobile';
* length, padding short `during` blocks with a trailing wait. * length, padding short `during` blocks with a trailing wait.
*/ */
// School-count features as served by live /api/features TODAY. The data // School features as served by live /api/features. The data pipeline moved
// pipeline has already moved to modelled catchment counts ("Good+ primary // to modelled catchment counts ("Good+ primary school catchments"), which the
// school catchments"), so flip these two constants when that deploy lands // local stack already serves; prod still serves the older "…within 2km" names
// on prod — preflight will fail loudly if the names drift from the API. // until that deploy lands. Preflight fails loudly if these drift from
const SCHOOL_GOOD_PRIMARY = 'Good+ primary schools within 2km'; // whichever API render.sh is pointed at — flip them back if you render against
const SCHOOL_OUTSTANDING_PRIMARY = 'Outstanding primary schools within 2km'; // prod before the catchment model deploys there.
const SCHOOL_GOOD_PRIMARY = 'Good+ primary school catchments';
const SCHOOL_OUTSTANDING_PRIMARY = 'Outstanding primary school catchments';
// Cold-open lean-in on the AI card. Desktop only; kept moderate so the // Cold-open lean-in on the AI card. Desktop only; kept moderate so the
// map remains visible on the right (zoomTo clamps the pan so the app // map remains visible on the right (zoomTo clamps the pan so the app
@ -59,10 +61,10 @@ const TT_CARD_SELECTOR = '[data-filter-name="tt_0"]';
const TT_SLIDER_MAX = 120; const TT_SLIDER_MAX = 120;
const TT_DRAG_FROM_MIN = 35; const TT_DRAG_FROM_MIN = 35;
// 25 (not 20): tight enough that the drag visibly prunes the map, loose // 25 (not 20): tight enough that the drag visibly prunes the map, loose
// enough that street-level Manchester keeps plenty of matching postcodes — // enough that street-level central London keeps plenty of matching
// at 20 the brief emptied the centre and the postcode tap had nothing // postcodes — at 20 the brief emptied the centre and the postcode tap had
// fresh to land on (the drawer then opened in its "filtered stats are // nothing fresh to land on (the drawer then opened in its "filtered stats
// empty" fallback). // are empty" fallback).
const TT_DRAG_TO_MIN = 25; const TT_DRAG_TO_MIN = 25;
// Where on the map the demo zoom-in lands. Desktop targets a fixed pixel // Where on the map the demo zoom-in lands. Desktop targets a fixed pixel
@ -138,8 +140,8 @@ const RECORDING_LOCALIZATIONS: Record<RecordingLocale, RecordingLocalization> =
voiceReferenceText: voiceReferenceText:
"Welcome to the demonstration. This is the narrator voice you'll hear throughout the video.", "Welcome to the demonstration. This is the narrator voice you'll hear throughout the video.",
promptText: promptText:
'First home under £315k, 35 min to Manchester, good schools, low crime, quiet street, fast broadband', 'First home under £600k, 35 min to central London, good schools, low crime, quiet street, fast broadband',
travelTimeLabel: 'Manchester city centre', travelTimeLabel: 'Central London',
exportButtonTitle: 'Export to Excel', exportButtonTitle: 'Export to Excel',
exportConfirmLabel: 'Export', exportConfirmLabel: 'Export',
closeDrawerLabel: 'Close drawer', closeDrawerLabel: 'Close drawer',
@ -173,8 +175,8 @@ const RECORDING_LOCALIZATIONS: Record<RecordingLocale, RecordingLocalization> =
voiceReferenceText: voiceReferenceText:
'Willkommen zur Demonstration. Diese Sprecherstimme hörst du im gesamten Video.', 'Willkommen zur Demonstration. Diese Sprecherstimme hörst du im gesamten Video.',
promptText: promptText:
'Wohnung unter £300k, 35 Min. nach Manchester, gute Schulen, niedrige Kriminalität, ruhige Straßen', 'Wohnung unter £600k, 35 Min. ins Zentrum von London, gute Schulen, niedrige Kriminalität, ruhige Straßen',
travelTimeLabel: 'Stadtzentrum Manchester', travelTimeLabel: 'Zentrum von London',
exportButtonTitle: 'Nach Excel exportieren', exportButtonTitle: 'Nach Excel exportieren',
exportConfirmLabel: 'Exportieren', exportConfirmLabel: 'Exportieren',
closeDrawerLabel: 'Drawer schließen', closeDrawerLabel: 'Drawer schließen',
@ -206,8 +208,8 @@ const RECORDING_LOCALIZATIONS: Record<RecordingLocale, RecordingLocalization> =
'Calm and cheerful Mandarin Chinese male narrator with clear standard Mandarin ' + 'Calm and cheerful Mandarin Chinese male narrator with clear standard Mandarin ' +
'pronunciation and a friendly, practical delivery.', 'pronunciation and a friendly, practical delivery.',
voiceReferenceText: '欢迎观看演示。整段视频都会使用这位旁白的声音。', voiceReferenceText: '欢迎观看演示。整段视频都会使用这位旁白的声音。',
promptText: '30万英镑以内的公寓35分钟到曼彻斯特,学校好,犯罪率低,街道安静', promptText: '60万英镑以内的公寓35分钟到伦敦市中心,学校好,犯罪率低,街道安静',
travelTimeLabel: '曼彻斯特市中心', travelTimeLabel: '伦敦市中心',
exportButtonTitle: '导出为 Excel', exportButtonTitle: '导出为 Excel',
exportConfirmLabel: '导出', exportConfirmLabel: '导出',
closeDrawerLabel: '关闭侧栏', closeDrawerLabel: '关闭侧栏',
@ -237,8 +239,8 @@ const RECORDING_LOCALIZATIONS: Record<RecordingLocale, RecordingLocalization> =
'and a friendly, practical delivery.', 'and a friendly, practical delivery.',
voiceReferenceText: voiceReferenceText:
"Welcome to the demonstration. This is the narrator voice you'll hear throughout the video.", "Welcome to the demonstration. This is the narrator voice you'll hear throughout the video.",
promptText: 'Flat under £300k, 35 min to Manchester, good schools, low crime, quiet streets', promptText: 'Flat under £600k, 35 min to central London, good schools, low crime, quiet streets',
travelTimeLabel: 'Manchester city centre', travelTimeLabel: 'Central London',
exportButtonTitle: 'Excel में export करें', exportButtonTitle: 'Excel में export करें',
exportConfirmLabel: 'Export', exportConfirmLabel: 'Export',
closeDrawerLabel: 'ड्रॉअर बंद करें', closeDrawerLabel: 'ड्रॉअर बंद करें',
@ -520,7 +522,7 @@ function buildVideoConfig(formFactor: FormFactor): VideoConfig {
outputFps: 50, outputFps: 50,
minDurationS: 10, minDurationS: 10,
maxDurationS: 75, maxDurationS: 75,
// Right-pane inspection: Manchester map, filters applied, data pane // Right-pane inspection: London map, filters applied, data pane
// open — the clearest paused-state preview. // open — the clearest paused-state preview.
posterTimeS: 31, posterTimeS: 31,
}; };
@ -536,20 +538,22 @@ function createRecordingStoryboard(
): Storyboard { ): Storyboard {
const copy = RECORDING_LOCALIZATIONS[locale]; const copy = RECORDING_LOCALIZATIONS[locale];
// Mobile bumps the initial zoom from 11.5 → 12 so the narrower 540-wide // Mobile bumps the initial zoom from 11.5 → 12 so the narrower 540-wide
// viewport still shows a Manchester-metro slice densely populated with // viewport still shows an inner-London slice densely populated with
// hexagons (otherwise the visible map gets dominated by Pennine moors // hexagons (otherwise the visible map gets dominated by the low-density
// on the east edge with no matches). // outer edges with no matches).
const initialZoom = formFactor === 'mobile' ? 12 : 11.5; const initialZoom = formFactor === 'mobile' ? 12 : 11.5;
// On mobile the MobileBottomSheet covers the bottom ~44% of the // On mobile the MobileBottomSheet covers the bottom ~44% of the
// viewport, so the map's geographic centre sits roughly at the seam // viewport, so the map's geographic centre sits roughly at the seam
// between the visible map and the sheet. Shift the centre lat ~0.04° // between the visible map and the sheet. Shift the centre lat ~0.04°
// south so Manchester city centre (53.4795) lands in the upper half of // south so central London (51.515) lands in the upper half of the
// the visible map area instead of getting hidden under the sheet. The // visible map area instead of getting hidden under the sheet. The
// desktop layout already has the map dominate the viewport, so it // desktop layout already has the map dominate the viewport, so it
// keeps the original centre. // keeps the original centre. -0.13 lon centres on Holborn/Bloomsbury,
const mapLat = formFactor === 'mobile' ? 53.4395 : 53.4795; // between the West End and the City, so the Bank-station travel filter
const mapLon = -2.2451; // and the deep zoom-in both land on richly matched inner-London blocks.
const mapLat = formFactor === 'mobile' ? 51.475 : 51.515;
const mapLon = -0.13;
return { return {
name: storyboardName(copy, formFactor), name: storyboardName(copy, formFactor),
@ -572,10 +576,15 @@ function createRecordingStoryboard(
// from /api/features (preflight validates against the live server). // from /api/features (preflight validates against the live server).
stubbedFilters: { stubbedFilters: {
'Property type': ['Flats/Maisonettes', 'Semi-Detached'], 'Property type': ['Flats/Maisonettes', 'Semi-Detached'],
'Estimated current price': [0, 315000], // £600k (not £315k like the old Manchester cut): central London is
// Loose enough to keep the Manchester map richly populated — a cap // pricier, and at £315k the price+crime combo emptied the inner
// of 20 emptied the city centre and left the zoom with nothing to // boroughs. At £600k ~51k postcodes pass in-frame, dropping to ~10k
// land on. // once the commute is dragged to 25 min — a visible prune that still
// leaves the zoom something to land on (verified via /api/filter-counts).
'Estimated current price': [0, 600000],
// Loose enough to keep the central-London map richly populated — a
// cap of 20 emptied the city centre and left the zoom with nothing
// to land on.
'Serious crime (avg/yr)': [0, 40], 'Serious crime (avg/yr)': [0, 40],
[SCHOOL_GOOD_PRIMARY]: [1, 10], [SCHOOL_GOOD_PRIMARY]: [1, 10],
'Noise (dB)': [0, 65], 'Noise (dB)': [0, 65],
@ -584,11 +593,13 @@ function createRecordingStoryboard(
'Max available download speed (Mbps)': [30, 1000], 'Max available download speed (Mbps)': [30, 1000],
}, },
// Travel-time filters returned by the AI stub. Slug matches the real // Travel-time filters returned by the AI stub. Slug matches the real
// /api/travel-destinations?mode=transit response. // /api/travel-destinations?mode=transit response. Bank tube station is
// the central-London transit anchor served by the local stack (the
// older city-level "manchester" slug only exists on prod's fuller data).
stubbedTravelTimeFilters: [ stubbedTravelTimeFilters: [
{ {
mode: 'transit', mode: 'transit',
slug: 'manchester', slug: 'bank-tube-station',
label: copy.travelTimeLabel, label: copy.travelTimeLabel,
max: TT_DRAG_FROM_MIN, max: TT_DRAG_FROM_MIN,
}, },