Fix FE
This commit is contained in:
parent
1241132095
commit
54a5c3ca9a
28 changed files with 826 additions and 422 deletions
|
|
@ -15,6 +15,7 @@ import type { FeatureMeta, FeatureGroup, POICategoriesResponse, POICategoryGroup
|
|||
import { fetchWithRetry, apiUrl, logNonAbortError } from './lib/api';
|
||||
import { trackEvent } from './lib/analytics';
|
||||
import { parseUrlState } from './lib/url-state';
|
||||
import pb from './lib/pocketbase';
|
||||
import { INITIAL_VIEW_STATE } from './lib/consts';
|
||||
import { useTheme } from './hooks/useTheme';
|
||||
import { useIsMobile } from './hooks/useIsMobile';
|
||||
|
|
@ -40,6 +41,7 @@ const SavedPage = lazy(() =>
|
|||
import('./components/account/AccountPage').then((module) => ({ default: module.SavedPage }))
|
||||
);
|
||||
const InvitePage = lazy(() => import('./components/invite/InvitePage'));
|
||||
const LegalPage = lazy(() => import('./components/legal/LegalPage'));
|
||||
const MapPage = lazy(() => import('./components/map/MapPage'));
|
||||
const AuthModal = lazy(() => import('./components/ui/AuthModal'));
|
||||
const SaveSearchModal = lazy(() => import('./components/ui/SaveSearchModal'));
|
||||
|
|
@ -77,19 +79,42 @@ function currentRelativePath(): string {
|
|||
return `${window.location.pathname}${window.location.search}`;
|
||||
}
|
||||
|
||||
const LAST_DASHBOARD_PARAMS_KEY = 'pp_last_dashboard_params';
|
||||
|
||||
function persistLastDashboardParams(params: string) {
|
||||
try {
|
||||
if (params) window.localStorage.setItem(LAST_DASHBOARD_PARAMS_KEY, params);
|
||||
} catch {
|
||||
// Storage unavailable (private mode/quota) — session restore is best-effort.
|
||||
}
|
||||
}
|
||||
|
||||
function readLastDashboardSearch(): string {
|
||||
try {
|
||||
const saved = window.localStorage.getItem(LAST_DASHBOARD_PARAMS_KEY);
|
||||
return saved ? `?${saved.replace(/^\?/, '')}` : '';
|
||||
} catch {
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Filters and map view live only in the URL. When the dashboard is opened bare
|
||||
* (no query), restore the last session's params so users pick up where they
|
||||
* left off. Explicit params and shared links always win.
|
||||
*/
|
||||
function restoreLastDashboardSession() {
|
||||
const pathname = window.location.pathname.replace(/\/+$/, '');
|
||||
if (pathname !== '/dashboard' || window.location.search) return;
|
||||
const saved = readLastDashboardSearch();
|
||||
if (!saved) return;
|
||||
window.history.replaceState(window.history.state, '', `/dashboard${saved}`);
|
||||
}
|
||||
|
||||
function isProtectedPage(page: Page): boolean {
|
||||
return page === 'account' || page === 'saved';
|
||||
}
|
||||
|
||||
function isSharedDashboardUrl(): boolean {
|
||||
const share = new URLSearchParams(window.location.search).get('share');
|
||||
return !!share && /^[a-z0-9]{1,20}$/i.test(share);
|
||||
}
|
||||
|
||||
function isAuthRequiredRoute(page: Page): boolean {
|
||||
return isProtectedPage(page) || (page === 'dashboard' && !isSharedDashboardUrl());
|
||||
}
|
||||
|
||||
function buildPageUrl(page: Page, inviteCode?: string, search = '', hash = ''): string {
|
||||
const normalizedHash = normalizeHash(hash);
|
||||
return `${pageToPath(page, inviteCode)}${search}${normalizedHash ? `#${normalizedHash}` : ''}`;
|
||||
|
|
@ -126,6 +151,10 @@ function pageToPath(page: Page, inviteCode?: string): string {
|
|||
case 'methodology':
|
||||
case 'privacy-security':
|
||||
return SEO_CONTENT_PATHS[page];
|
||||
case 'terms':
|
||||
return '/terms';
|
||||
case 'privacy':
|
||||
return '/privacy';
|
||||
case 'saved':
|
||||
return '/saved';
|
||||
case 'account':
|
||||
|
|
@ -140,7 +169,10 @@ function pageToPath(page: Page, inviteCode?: string): string {
|
|||
}
|
||||
}
|
||||
|
||||
function pathToPage(pathname: string): RouteMatch | null {
|
||||
function pathToPage(rawPathname: string): RouteMatch | null {
|
||||
// Proxies 307-redirect /learn -> /learn/; treat trailing slashes as equivalent.
|
||||
const pathname =
|
||||
rawPathname.length > 1 ? rawPathname.replace(/\/+$/, '') || '/' : rawPathname;
|
||||
if (pathname === '/dashboard') return { page: 'dashboard' };
|
||||
if (pathname === '/saved') return { page: 'saved' };
|
||||
if (pathname === '/invites') return { page: 'account', hash: 'invites' };
|
||||
|
|
@ -152,6 +184,8 @@ function pathToPage(pathname: string): RouteMatch | null {
|
|||
if (seoContentPage) return { page: seoContentPage };
|
||||
if (pathname === '/account') return { page: 'account' };
|
||||
if (pathname === '/support') return { page: 'learn' };
|
||||
if (pathname === '/terms') return { page: 'terms' };
|
||||
if (pathname === '/privacy') return { page: 'privacy' };
|
||||
if (pathname.startsWith('/invite/')) {
|
||||
const code = pathname.slice('/invite/'.length);
|
||||
return { page: 'invite', inviteCode: code };
|
||||
|
|
@ -169,7 +203,11 @@ function isSeoContentPage(page: Page): page is SeoContentKey {
|
|||
}
|
||||
|
||||
export default function App() {
|
||||
const urlState = useMemo(() => parseUrlState(), []);
|
||||
const urlState = useMemo(() => {
|
||||
// Must run before any reads of window.location.search below.
|
||||
restoreLastDashboardSession();
|
||||
return parseUrlState();
|
||||
}, []);
|
||||
const initialRoute = useMemo(() => pathToPage(window.location.pathname), []);
|
||||
const [mapUrlState, setMapUrlState] = useState(urlState);
|
||||
const [dashboardRouteKey, setDashboardRouteKey] = useState(() =>
|
||||
|
|
@ -276,7 +314,9 @@ export default function App() {
|
|||
if (!completed) {
|
||||
setPostAuthIntent(null);
|
||||
postAuthCheckoutReturnPathRef.current = null;
|
||||
if (isAuthRequiredRoute(activePageRef.current)) {
|
||||
// Only protected pages bounce home; the dashboard stays open in demo
|
||||
// mode (server-enforced free zone) when the modal is dismissed.
|
||||
if (isProtectedPage(activePageRef.current)) {
|
||||
window.history.replaceState({ page: 'home', hash: '' }, '', '/');
|
||||
setRouteHash('');
|
||||
setActivePage('home');
|
||||
|
|
@ -300,8 +340,11 @@ export default function App() {
|
|||
|
||||
async function refreshOnStartup() {
|
||||
if (!returnedFromCheckout) {
|
||||
// Always refresh auth on startup to pick up server-side subscription changes.
|
||||
refreshAuthRef.current().catch(() => {});
|
||||
// Refresh auth on startup to pick up server-side subscription changes,
|
||||
// but only when a token exists — logged-out visitors would just 401.
|
||||
if (pb.authStore.token) {
|
||||
refreshAuthRef.current().catch(() => {});
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
@ -384,8 +427,10 @@ export default function App() {
|
|||
if (infoFeature) {
|
||||
window.history.replaceState({ ...window.history.state, infoFeature }, '');
|
||||
}
|
||||
// Restore dashboard search params when navigating back
|
||||
const search = page === 'dashboard' ? dashboardSearchRef.current : '';
|
||||
// Restore dashboard search params when navigating back, falling back to
|
||||
// the last persisted session for first visits in this tab.
|
||||
const search =
|
||||
page === 'dashboard' ? dashboardSearchRef.current || readLastDashboardSearch() : '';
|
||||
const url = buildPageUrl(page, inviteCode ?? undefined, search, targetHash);
|
||||
window.history.pushState({ page, hash: targetHash }, '', url);
|
||||
if (page === 'dashboard') {
|
||||
|
|
@ -527,19 +572,20 @@ export default function App() {
|
|||
}
|
||||
}, [activePage, fetchSearches]);
|
||||
|
||||
const isAuthRequiredPage =
|
||||
activePage === 'account' ||
|
||||
activePage === 'saved' ||
|
||||
(activePage === 'dashboard' && !mapUrlState.share);
|
||||
const isProtectedPageActive = isProtectedPage(activePage);
|
||||
// Only protected pages (account/saved) prompt for login on entry. The
|
||||
// dashboard opens straight into demo mode (server-enforced free zone) so
|
||||
// visitors can try it without logging in; the upgrade modal still appears
|
||||
// when they pan outside the free zone.
|
||||
useEffect(() => {
|
||||
if (authLoading) return;
|
||||
if (isAuthRequiredPage && !user) {
|
||||
if (isProtectedPageActive && !user) {
|
||||
openAuthModal('login');
|
||||
}
|
||||
if (activePage === 'pricing' && hasFullAccess(user)) {
|
||||
navigateTo('dashboard');
|
||||
}
|
||||
}, [activePage, authLoading, isAuthRequiredPage, navigateTo, openAuthModal, user]);
|
||||
}, [activePage, authLoading, isProtectedPageActive, navigateTo, openAuthModal, user]);
|
||||
|
||||
const [exportState, setExportState] = useState<ExportState | null>(null);
|
||||
|
||||
|
|
@ -641,6 +687,8 @@ export default function App() {
|
|||
/>
|
||||
) : activePage === 'learn' ? (
|
||||
<LearnPage />
|
||||
) : activePage === 'terms' || activePage === 'privacy' ? (
|
||||
<LegalPage kind={activePage} />
|
||||
) : isSeoLandingPage(activePage) ? (
|
||||
<SeoLandingPage pageKey={activePage} onOpenDashboard={() => navigateTo('dashboard')} />
|
||||
) : isSeoContentPage(activePage) ? (
|
||||
|
|
@ -662,7 +710,7 @@ export default function App() {
|
|||
}}
|
||||
scrollTarget={routeHash}
|
||||
/>
|
||||
) : isAuthRequiredPage && !user ? (
|
||||
) : isProtectedPageActive && !user ? (
|
||||
<PageFallback />
|
||||
) : activePage === 'invite' && inviteCode ? (
|
||||
<InvitePage
|
||||
|
|
@ -695,7 +743,10 @@ export default function App() {
|
|||
onClearPendingInfoFeature={() => setPendingInfoFeature(null)}
|
||||
onNavigateTo={navigateTo}
|
||||
onExportStateChange={setExportState}
|
||||
onDashboardParamsChange={setDashboardParams}
|
||||
onDashboardParamsChange={(params) => {
|
||||
setDashboardParams(params);
|
||||
if (!mapUrlState.share) persistLastDashboardParams(params);
|
||||
}}
|
||||
onDashboardReadyChange={setDashboardReady}
|
||||
isMobile={isMobile}
|
||||
initialTravelTime={mapUrlState.travelTime}
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ import BottomIllustration from './BottomIllustration';
|
|||
import { TickerValue } from '../ui/TickerValue';
|
||||
import { ChevronIcon, LogoIcon, PlayIcon } from '../ui/icons';
|
||||
import { trackEvent } from '../../lib/analytics';
|
||||
import { apiUrl } from '../../lib/api';
|
||||
|
||||
const BRAND_NAME = 'Perfect Postcode';
|
||||
const BRAND_TEXT_CLASS = 'text-teal-600 dark:text-teal-400';
|
||||
|
|
@ -163,11 +164,78 @@ function ProductDemoVideo() {
|
|||
);
|
||||
}
|
||||
|
||||
interface PriceStripTier {
|
||||
up_to: number | null;
|
||||
price_pence: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Compact pricing teaser under the hero CTAs: surfaces the current lifetime
|
||||
* price and tier scarcity that otherwise hide behind the Pricing nav link.
|
||||
*/
|
||||
function PriceStrip({
|
||||
onOpenPricing,
|
||||
hidePricing,
|
||||
}: {
|
||||
onOpenPricing: () => void;
|
||||
hidePricing?: boolean;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const [pricing, setPricing] = useState<{
|
||||
licensed_count: number;
|
||||
current_price_pence: number;
|
||||
tiers: PriceStripTier[];
|
||||
} | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (hidePricing) return;
|
||||
const controller = new AbortController();
|
||||
fetch(apiUrl('pricing'), { signal: controller.signal })
|
||||
.then((res) => (res.ok ? res.json() : null))
|
||||
.then(setPricing)
|
||||
.catch(() => {});
|
||||
return () => controller.abort();
|
||||
}, [hidePricing]);
|
||||
|
||||
if (hidePricing || !pricing) return null;
|
||||
|
||||
const price = `£${pricing.current_price_pence / 100}`;
|
||||
const currentTier = pricing.tiers.find(
|
||||
(tier) => tier.up_to === null || pricing.licensed_count < tier.up_to
|
||||
);
|
||||
const spotsRemaining =
|
||||
currentTier?.up_to != null ? currentTier.up_to - pricing.licensed_count : 0;
|
||||
|
||||
return (
|
||||
<p className="text-sm text-warm-300 mb-2">
|
||||
{pricing.current_price_pence === 0
|
||||
? t('upgrade.freeForEarly')
|
||||
: t('home.priceStrip', { price })}{' '}
|
||||
{pricing.current_price_pence > 0 && spotsRemaining > 0 && (
|
||||
<span className="font-semibold text-teal-300">
|
||||
{spotsRemaining === 1
|
||||
? t('home.priceStripSpots', { count: spotsRemaining })
|
||||
: t('home.priceStripSpotsPlural', { count: spotsRemaining })}
|
||||
</span>
|
||||
)}{' '}
|
||||
<button
|
||||
onClick={() => {
|
||||
trackEvent('CTA Click', { location: 'hero', label: 'price_strip' });
|
||||
onOpenPricing();
|
||||
}}
|
||||
className="underline decoration-dotted underline-offset-2 text-teal-300 hover:text-teal-200"
|
||||
>
|
||||
{t('home.priceStripCta')}
|
||||
</button>
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
export default function HomePage({
|
||||
onOpenDashboard,
|
||||
onOpenPricing: _onOpenPricing,
|
||||
onOpenPricing,
|
||||
theme = 'light',
|
||||
hidePricing: _hidePricing,
|
||||
hidePricing,
|
||||
}: {
|
||||
onOpenDashboard: () => void;
|
||||
onOpenPricing: () => void;
|
||||
|
|
@ -327,7 +395,7 @@ export default function HomePage({
|
|||
<p className="text-base md:text-lg text-warm-200 mb-8 max-w-xl">
|
||||
{highlightBrandText(t('home.heroDescription'), 'font-semibold text-teal-300')}
|
||||
</p>
|
||||
<div className="flex flex-col sm:flex-row sm:items-center gap-3 sm:gap-4 mb-10">
|
||||
<div className="flex flex-col sm:flex-row sm:items-center gap-3 sm:gap-4 mb-5">
|
||||
<button
|
||||
onClick={() => {
|
||||
trackEvent('CTA Click', { location: 'hero', label: 'explore_map' });
|
||||
|
|
@ -347,6 +415,8 @@ export default function HomePage({
|
|||
{t('home.seeTheDifference')}
|
||||
</button>
|
||||
</div>
|
||||
<PriceStrip onOpenPricing={onOpenPricing} hidePricing={hidePricing} />
|
||||
<p className="text-sm text-warm-400 mb-8">{t('home.coverageNote')}</p>
|
||||
<div className="home-hero-stats flex flex-wrap pt-3 border-t border-white/10">
|
||||
<div className="home-hero-stat">
|
||||
<div className="home-hero-stat-value">
|
||||
|
|
@ -356,7 +426,7 @@ export default function HomePage({
|
|||
</div>
|
||||
<div className="home-hero-stat">
|
||||
<div className="home-hero-stat-value">
|
||||
<TickerValue text="56" active={statsActive} />
|
||||
<TickerValue text="40+" active={statsActive} />
|
||||
</div>
|
||||
<div className="home-hero-stat-label">{t('home.statFilters')}</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -82,7 +82,7 @@ const DEMO_FEATURES: FeatureMeta[] = [
|
|||
step: 1,
|
||||
},
|
||||
{
|
||||
name: 'Good+ primary schools within 2km',
|
||||
name: 'Good+ primary school catchments',
|
||||
type: 'numeric',
|
||||
group: 'Schools',
|
||||
min: 0,
|
||||
|
|
@ -300,7 +300,7 @@ function interpolateViewState(progress: number): ViewState {
|
|||
function demoSliderStep(feature: FeatureMeta): number {
|
||||
if (feature.name === 'Estimated price') return 1000;
|
||||
if (feature.name === 'Noise (dB)') return 0.05;
|
||||
if (feature.name === 'Good+ primary schools within 2km') return 0.01;
|
||||
if (feature.name === 'Good+ primary school catchments') return 0.01;
|
||||
if (feature.name === 'Travel time to nearest train or tube station (min)') return 0.1;
|
||||
return feature.step ?? 1;
|
||||
}
|
||||
|
|
@ -350,7 +350,7 @@ function FilterPreviewRow({
|
|||
const shortLabelKeys = {
|
||||
'Estimated price': 'home.showcaseFeaturePriceShort',
|
||||
'Noise (dB)': 'home.showcaseFeatureNoiseShort',
|
||||
'Good+ primary schools within 2km': 'home.showcaseFeatureSchoolsShort',
|
||||
'Good+ primary school catchments': 'home.showcaseFeatureSchoolsShort',
|
||||
'Travel time to nearest train or tube station (min)': 'home.showcaseFeatureTravelShort',
|
||||
} as const;
|
||||
const shortLabelKey = shortLabelKeys[feature.name as keyof typeof shortLabelKeys];
|
||||
|
|
@ -406,7 +406,7 @@ function formatDemoRange(feature: FeatureMeta, value: [number, number], t: TFunc
|
|||
if (feature.name === 'Noise (dB)') {
|
||||
return `${Math.round(value[0])} - ${Math.round(value[1])} dB`;
|
||||
}
|
||||
if (feature.name === 'Good+ primary schools within 2km') {
|
||||
if (feature.name === 'Good+ primary school catchments') {
|
||||
return t('home.showcaseGoodPrimariesNearby', { count: Math.round(value[0]) });
|
||||
}
|
||||
if (feature.name === 'Travel time to nearest train or tube station (min)') {
|
||||
|
|
|
|||
|
|
@ -3,9 +3,52 @@ import { useTranslation } from 'react-i18next';
|
|||
import { tDynamic } from '../../i18n';
|
||||
import { getLocalizedSeoPages } from '../../lib/seoLandingPages';
|
||||
import { ChevronIcon } from '../ui/icons/ChevronIcon';
|
||||
import { PlayIcon } from '../ui/icons';
|
||||
import { SubNav } from '../ui/SubNav';
|
||||
import Footer from '../ui/Footer';
|
||||
|
||||
type LearnTab = 'data-sources' | 'faq' | 'articles' | 'support';
|
||||
type LearnTab = 'data-sources' | 'faq' | 'articles' | 'videos' | 'support';
|
||||
|
||||
// Social-media ad cuts rendered by the recorder pipeline (video/src/storyboard.ts).
|
||||
// Each `<slug>.mp4` is a 9:16 clip with a matching `<slug>.jpg` poster in /public/video.
|
||||
const SOCIAL_VIDEOS: { slug: string; titleKey: string; descKey: string }[] = [
|
||||
{ slug: 'ad-01-say-it', titleKey: 'learnPage.video01Title', descKey: 'learnPage.video01Desc' },
|
||||
{
|
||||
slug: 'ad-02-twenty-minute-map',
|
||||
titleKey: 'learnPage.video02Title',
|
||||
descKey: 'learnPage.video02Desc',
|
||||
},
|
||||
{
|
||||
slug: 'ad-03-postcode-files',
|
||||
titleKey: 'learnPage.video03Title',
|
||||
descKey: 'learnPage.video03Desc',
|
||||
},
|
||||
{
|
||||
slug: 'ad-04-quiet-streets',
|
||||
titleKey: 'learnPage.video04Title',
|
||||
descKey: 'learnPage.video04Desc',
|
||||
},
|
||||
{
|
||||
slug: 'ad-05-school-run',
|
||||
titleKey: 'learnPage.video05Title',
|
||||
descKey: 'learnPage.video05Desc',
|
||||
},
|
||||
{
|
||||
slug: 'ad-06-waitrose-test',
|
||||
titleKey: 'learnPage.video06Title',
|
||||
descKey: 'learnPage.video06Desc',
|
||||
},
|
||||
{
|
||||
slug: 'ad-07-renters-map',
|
||||
titleKey: 'learnPage.video07Title',
|
||||
descKey: 'learnPage.video07Desc',
|
||||
},
|
||||
{
|
||||
slug: 'ad-08-cheap-insurance',
|
||||
titleKey: 'learnPage.video08Title',
|
||||
descKey: 'learnPage.video08Desc',
|
||||
},
|
||||
];
|
||||
|
||||
interface DataSourceDef {
|
||||
id: string;
|
||||
|
|
@ -176,6 +219,68 @@ function FAQItemCard({ question, answer }: { question: string; answer: string })
|
|||
);
|
||||
}
|
||||
|
||||
function SocialVideoCard({
|
||||
slug,
|
||||
title,
|
||||
description,
|
||||
}: {
|
||||
slug: string;
|
||||
title: string;
|
||||
description: string;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const videoRef = useRef<HTMLVideoElement | null>(null);
|
||||
const [isLoaded, setIsLoaded] = useState(false);
|
||||
const [isPlaying, setIsPlaying] = useState(false);
|
||||
const videoSrc = `/video/${slug}.mp4`;
|
||||
const posterSrc = `/video/${slug}.jpg`;
|
||||
|
||||
const playVideo = () => {
|
||||
const video = videoRef.current;
|
||||
setIsLoaded(true);
|
||||
if (!video) return;
|
||||
if (video.getAttribute('src') !== videoSrc) {
|
||||
video.src = videoSrc;
|
||||
video.load();
|
||||
}
|
||||
void video.play().catch(() => setIsPlaying(false));
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col">
|
||||
<div className="relative overflow-hidden rounded-xl border border-warm-200 bg-navy-950 shadow-sm dark:border-warm-700">
|
||||
<video
|
||||
ref={videoRef}
|
||||
src={isLoaded ? videoSrc : undefined}
|
||||
poster={posterSrc}
|
||||
controls={isPlaying}
|
||||
playsInline
|
||||
preload="none"
|
||||
className="block aspect-[9/16] w-full bg-navy-950 object-contain"
|
||||
aria-label={title}
|
||||
onPlay={() => setIsPlaying(true)}
|
||||
onPause={() => setIsPlaying(false)}
|
||||
onEnded={() => setIsPlaying(false)}
|
||||
/>
|
||||
{!isPlaying && (
|
||||
<div className="pointer-events-none absolute inset-0 flex items-center justify-center bg-navy-950/15 transition-colors">
|
||||
<button
|
||||
type="button"
|
||||
onClick={playVideo}
|
||||
className="pointer-events-auto group flex h-16 w-16 items-center justify-center rounded-full bg-white/95 text-coral-500 shadow-2xl shadow-navy-950/40 ring-1 ring-white/60 transition-transform hover:scale-105 focus:outline-none focus-visible:scale-105 focus-visible:ring-4 focus-visible:ring-teal-300/75"
|
||||
aria-label={t('home.playProductDemo')}
|
||||
>
|
||||
<PlayIcon className="h-8 w-8 -translate-x-0.5" />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<h2 className="mt-3 text-base font-bold text-warm-900 dark:text-warm-100">{title}</h2>
|
||||
<p className="mt-1 text-sm leading-relaxed text-warm-600 dark:text-warm-300">{description}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function LearnPage() {
|
||||
const { t, i18n } = useTranslation();
|
||||
const [tab, setTab] = useState<LearnTab>('faq');
|
||||
|
|
@ -197,6 +302,7 @@ export default function LearnPage() {
|
|||
{ key: 'faq', label: t('learnPage.faq') },
|
||||
{ key: 'data-sources', label: t('learnPage.dataSources') },
|
||||
{ key: 'articles', label: t('learnPage.articles') },
|
||||
{ key: 'videos', label: t('learnPage.videos') },
|
||||
{ key: 'support', label: t('learnPage.support') },
|
||||
];
|
||||
|
||||
|
|
@ -268,6 +374,9 @@ export default function LearnPage() {
|
|||
} else if (hash === 'articles') {
|
||||
setTab('articles');
|
||||
setHighlightedId(null);
|
||||
} else if (hash === 'videos') {
|
||||
setTab('videos');
|
||||
setHighlightedId(null);
|
||||
} else if (hash === 'support') {
|
||||
setTab('support');
|
||||
setHighlightedId(null);
|
||||
|
|
@ -300,141 +409,163 @@ export default function LearnPage() {
|
|||
<SubNav tabs={LEARN_TABS} activeTab={tab} onTabChange={switchTab} />
|
||||
|
||||
<div className="flex-1 overflow-y-auto flex flex-col" ref={scrollContainerRef}>
|
||||
{tab === 'data-sources' ? (
|
||||
<>
|
||||
<div className="flex-1">
|
||||
<div className="max-w-5xl mx-auto px-6 py-6">
|
||||
<h1 className="text-2xl md:text-3xl font-bold text-warm-900 dark:text-warm-100 mb-3">
|
||||
{t('learnPage.dataSources')}
|
||||
</h1>
|
||||
<p className="text-warm-600 dark:text-warm-400 mb-6">
|
||||
{t('learnPage.dataSourcesIntro', { count: DATA_SOURCE_DEFS.length })}
|
||||
</p>
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
|
||||
{DATA_SOURCE_DEFS.map((source) => {
|
||||
const keys = DS_KEYS[source.id];
|
||||
const [nameKey, originKey, useKey] = keys;
|
||||
return (
|
||||
<div
|
||||
key={source.id}
|
||||
id={source.id}
|
||||
ref={(el) => {
|
||||
cardRefs.current[source.id] = el;
|
||||
}}
|
||||
className={`bg-white dark:bg-warm-800 rounded-lg border p-5 ${
|
||||
highlightedId === source.id
|
||||
? 'border-teal-400 ring-2 ring-teal-400'
|
||||
: 'border-warm-200 dark:border-warm-700'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-start justify-between gap-4 mb-2">
|
||||
<h2 className="text-lg font-semibold text-warm-900 dark:text-warm-100">
|
||||
{tDynamic(nameKey)}
|
||||
</h2>
|
||||
<span className="max-w-44 text-left text-xs leading-snug bg-warm-100 dark:bg-navy-700 text-warm-600 dark:text-warm-300 px-2 py-1 rounded">
|
||||
{source.license}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-sm text-warm-500 dark:text-warm-400 mb-2">
|
||||
{t('learnPage.source')} {tDynamic(originKey)}
|
||||
</p>
|
||||
<p className="text-sm text-warm-700 dark:text-warm-300 mb-3">
|
||||
{tDynamic(useKey)}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<footer className="bg-navy-900 text-warm-400 px-6 py-6">
|
||||
<div className="max-w-5xl mx-auto">
|
||||
<h2 className="text-sm font-semibold text-warm-300 uppercase tracking-wide mb-3">
|
||||
{t('learnPage.attribution')}
|
||||
</h2>
|
||||
<ul className="space-y-1.5 text-sm">
|
||||
<li>{t('learnPage.attrLandRegistry')}</li>
|
||||
<li>
|
||||
{t('learnPage.attrOgl')} {t('learnPage.attrOglLink')}.
|
||||
</li>
|
||||
<li>{t('learnPage.attrOs')}</li>
|
||||
<li>{t('learnPage.attrTfl')}</li>
|
||||
<li>
|
||||
{t('learnPage.attrOsm')} {t('learnPage.attrOsmContrib')},{' '}
|
||||
{t('learnPage.attrOsmLicense')} {t('learnPage.attrOsmLicenseLink')}.
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</footer>
|
||||
</>
|
||||
) : tab === 'faq' ? (
|
||||
<div className="max-w-3xl mx-auto px-6 py-6 w-full">
|
||||
<h1 className="text-2xl md:text-3xl font-bold text-warm-900 dark:text-warm-100 mb-3">
|
||||
{t('learnPage.faq')}
|
||||
</h1>
|
||||
<p className="text-warm-600 dark:text-warm-400 mb-6">{t('learnPage.faqIntro')}</p>
|
||||
<div className="space-y-8">
|
||||
{FAQ_SECTIONS.map((section) => (
|
||||
<div key={section.title}>
|
||||
<h3 className="text-sm font-semibold text-warm-500 dark:text-warm-400 uppercase tracking-wide mb-3">
|
||||
{section.title}
|
||||
</h3>
|
||||
<div className="space-y-3">
|
||||
{section.items.map((item, index) => (
|
||||
<FAQItemCard key={index} question={item.question} answer={item.answer} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
) : tab === 'articles' ? (
|
||||
<div className="max-w-5xl mx-auto px-6 py-6 w-full">
|
||||
<h1 className="text-2xl md:text-3xl font-bold text-warm-900 dark:text-warm-100 mb-3">
|
||||
{t('learnPage.articles')}
|
||||
</h1>
|
||||
<p className="text-warm-600 dark:text-warm-400 mb-6">{t('learnPage.articlesIntro')}</p>
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
{seoPageLinks.map((link) => (
|
||||
<a
|
||||
key={link.path}
|
||||
href={link.path}
|
||||
className="rounded-lg border border-warm-200 bg-white p-5 transition-colors hover:border-teal-300 hover:bg-teal-50 dark:border-warm-700 dark:bg-warm-800 dark:hover:border-teal-500/60 dark:hover:bg-teal-950/30"
|
||||
>
|
||||
<div className="text-xs font-semibold uppercase tracking-wide text-teal-600 dark:text-teal-400">
|
||||
{link.eyebrow}
|
||||
</div>
|
||||
<h2 className="mt-1 text-lg font-bold text-warm-900 dark:text-warm-100">
|
||||
{link.title}
|
||||
</h2>
|
||||
<p className="mt-2 text-sm leading-relaxed text-warm-600 dark:text-warm-300">
|
||||
{link.description}
|
||||
<div className="flex flex-1 flex-col">
|
||||
{tab === 'data-sources' ? (
|
||||
<>
|
||||
<div className="flex-1">
|
||||
<div className="max-w-5xl mx-auto px-6 py-6">
|
||||
<h1 className="text-2xl md:text-3xl font-bold text-warm-900 dark:text-warm-100 mb-3">
|
||||
{t('learnPage.dataSources')}
|
||||
</h1>
|
||||
<p className="text-warm-600 dark:text-warm-400 mb-6">
|
||||
{t('learnPage.dataSourcesIntro', { count: DATA_SOURCE_DEFS.length })}
|
||||
</p>
|
||||
</a>
|
||||
))}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
|
||||
{DATA_SOURCE_DEFS.map((source) => {
|
||||
const keys = DS_KEYS[source.id];
|
||||
const [nameKey, originKey, useKey] = keys;
|
||||
return (
|
||||
<div
|
||||
key={source.id}
|
||||
id={source.id}
|
||||
ref={(el) => {
|
||||
cardRefs.current[source.id] = el;
|
||||
}}
|
||||
className={`bg-white dark:bg-warm-800 rounded-lg border p-5 ${
|
||||
highlightedId === source.id
|
||||
? 'border-teal-400 ring-2 ring-teal-400'
|
||||
: 'border-warm-200 dark:border-warm-700'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-start justify-between gap-4 mb-2">
|
||||
<h2 className="text-lg font-semibold text-warm-900 dark:text-warm-100">
|
||||
{tDynamic(nameKey)}
|
||||
</h2>
|
||||
<span className="max-w-44 text-left text-xs leading-snug bg-warm-100 dark:bg-navy-700 text-warm-600 dark:text-warm-300 px-2 py-1 rounded">
|
||||
{source.license}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-sm text-warm-500 dark:text-warm-400 mb-2">
|
||||
{t('learnPage.source')} {tDynamic(originKey)}
|
||||
</p>
|
||||
<p className="text-sm text-warm-700 dark:text-warm-300 mb-3">
|
||||
{tDynamic(useKey)}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<footer className="bg-navy-900 text-warm-400 px-6 py-6">
|
||||
<div className="max-w-5xl mx-auto">
|
||||
<h2 className="text-sm font-semibold text-warm-300 uppercase tracking-wide mb-3">
|
||||
{t('learnPage.attribution')}
|
||||
</h2>
|
||||
<ul className="space-y-1.5 text-sm">
|
||||
<li>{t('learnPage.attrLandRegistry')}</li>
|
||||
<li>
|
||||
{t('learnPage.attrOgl')} {t('learnPage.attrOglLink')}.
|
||||
</li>
|
||||
<li>{t('learnPage.attrOs')}</li>
|
||||
<li>{t('learnPage.attrTfl')}</li>
|
||||
<li>
|
||||
{t('learnPage.attrOsm')} {t('learnPage.attrOsmContrib')},{' '}
|
||||
{t('learnPage.attrOsmLicense')} {t('learnPage.attrOsmLicenseLink')}.
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</footer>
|
||||
</>
|
||||
) : tab === 'faq' ? (
|
||||
<div className="max-w-3xl mx-auto px-6 py-6 w-full">
|
||||
<h1 className="text-2xl md:text-3xl font-bold text-warm-900 dark:text-warm-100 mb-3">
|
||||
{t('learnPage.faq')}
|
||||
</h1>
|
||||
<p className="text-warm-600 dark:text-warm-400 mb-6">{t('learnPage.faqIntro')}</p>
|
||||
<div className="space-y-8">
|
||||
{FAQ_SECTIONS.map((section) => (
|
||||
<div key={section.title}>
|
||||
<h3 className="text-sm font-semibold text-warm-500 dark:text-warm-400 uppercase tracking-wide mb-3">
|
||||
{section.title}
|
||||
</h3>
|
||||
<div className="space-y-3">
|
||||
{section.items.map((item, index) => (
|
||||
<FAQItemCard key={index} question={item.question} answer={item.answer} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="max-w-2xl mx-auto px-6 py-6 w-full">
|
||||
<h1 className="text-2xl md:text-3xl font-bold text-warm-900 dark:text-warm-100 mb-3">
|
||||
{t('learnPage.support')}
|
||||
</h1>
|
||||
<p className="text-warm-600 dark:text-warm-400 mb-6">{t('learnPage.supportIntro')}</p>
|
||||
<div className="bg-white dark:bg-warm-800 rounded-xl border border-warm-200 dark:border-warm-700 p-6 text-center">
|
||||
<p className="text-warm-600 dark:text-warm-300 mb-2">{t('accountPage.needHelp')}</p>
|
||||
<a
|
||||
href="mailto:support@perfect-postcode.co.uk"
|
||||
className="text-teal-600 dark:text-teal-400 hover:text-teal-800 dark:hover:text-teal-300 font-medium text-lg"
|
||||
>
|
||||
support@perfect-postcode.co.uk
|
||||
</a>
|
||||
<p className="text-warm-400 dark:text-warm-500 text-sm mt-2">
|
||||
{t('accountPage.responseTime')}
|
||||
) : tab === 'articles' ? (
|
||||
<div className="max-w-5xl mx-auto px-6 py-6 w-full">
|
||||
<h1 className="text-2xl md:text-3xl font-bold text-warm-900 dark:text-warm-100 mb-3">
|
||||
{t('learnPage.articles')}
|
||||
</h1>
|
||||
<p className="text-warm-600 dark:text-warm-400 mb-6">
|
||||
{t('learnPage.articlesIntro')}
|
||||
</p>
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
{seoPageLinks.map((link) => (
|
||||
<a
|
||||
key={link.path}
|
||||
href={link.path}
|
||||
className="rounded-lg border border-warm-200 bg-white p-5 transition-colors hover:border-teal-300 hover:bg-teal-50 dark:border-warm-700 dark:bg-warm-800 dark:hover:border-teal-500/60 dark:hover:bg-teal-950/30"
|
||||
>
|
||||
<div className="text-xs font-semibold uppercase tracking-wide text-teal-600 dark:text-teal-400">
|
||||
{link.eyebrow}
|
||||
</div>
|
||||
<h2 className="mt-1 text-lg font-bold text-warm-900 dark:text-warm-100">
|
||||
{link.title}
|
||||
</h2>
|
||||
<p className="mt-2 text-sm leading-relaxed text-warm-600 dark:text-warm-300">
|
||||
{link.description}
|
||||
</p>
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
) : tab === 'videos' ? (
|
||||
<div className="max-w-5xl mx-auto px-6 py-6 w-full">
|
||||
<h1 className="text-2xl md:text-3xl font-bold text-warm-900 dark:text-warm-100 mb-3">
|
||||
{t('learnPage.videosTitle')}
|
||||
</h1>
|
||||
<p className="text-warm-600 dark:text-warm-400 mb-6">{t('learnPage.videosIntro')}</p>
|
||||
<div className="grid grid-cols-2 gap-4 sm:grid-cols-3 sm:gap-6 lg:grid-cols-4">
|
||||
{SOCIAL_VIDEOS.map((video) => (
|
||||
<SocialVideoCard
|
||||
key={video.slug}
|
||||
slug={video.slug}
|
||||
title={tDynamic(video.titleKey)}
|
||||
description={tDynamic(video.descKey)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="max-w-2xl mx-auto px-6 py-6 w-full">
|
||||
<h1 className="text-2xl md:text-3xl font-bold text-warm-900 dark:text-warm-100 mb-3">
|
||||
{t('learnPage.support')}
|
||||
</h1>
|
||||
<p className="text-warm-600 dark:text-warm-400 mb-6">{t('learnPage.supportIntro')}</p>
|
||||
<div className="bg-white dark:bg-warm-800 rounded-xl border border-warm-200 dark:border-warm-700 p-6 text-center">
|
||||
<p className="text-warm-600 dark:text-warm-300 mb-2">{t('accountPage.needHelp')}</p>
|
||||
<a
|
||||
href="mailto:support@perfect-postcode.co.uk"
|
||||
className="text-teal-600 dark:text-teal-400 hover:text-teal-800 dark:hover:text-teal-300 font-medium text-lg"
|
||||
>
|
||||
support@perfect-postcode.co.uk
|
||||
</a>
|
||||
<p className="text-warm-400 dark:text-warm-500 text-sm mt-2">
|
||||
{t('accountPage.responseTime')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<Footer />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -1172,10 +1172,7 @@ export default memo(function Map({
|
|||
? [postcodeCountRange.min, postcodeCountRange.max]
|
||||
: [countRange.min, countRange.max]
|
||||
}
|
||||
totalCount={
|
||||
totalCountProp ??
|
||||
(usePostcodeView ? postcodeCountRange.total : countRange.total)
|
||||
}
|
||||
totalCount={totalCountProp}
|
||||
showCancel={false}
|
||||
onCancel={onCancelPin}
|
||||
mode="density"
|
||||
|
|
|
|||
|
|
@ -26,6 +26,7 @@ import { apiUrl, authHeaders, buildFilterString } from '../../lib/api';
|
|||
import { useFilterCounts } from '../../hooks/useFilterCounts';
|
||||
import { trackEvent } from '../../lib/analytics';
|
||||
import { INITIAL_VIEW_STATE, POSTCODE_ZOOM_THRESHOLD } from '../../lib/consts';
|
||||
import { boundsToCenterZoom } from '../../lib/fit-bounds';
|
||||
import type { OverlayId } from '../../lib/overlays';
|
||||
import { CRIME_TYPE_VALUES } from '../../lib/crime-types';
|
||||
import type { BasemapId } from '../../lib/basemaps';
|
||||
|
|
@ -257,6 +258,14 @@ export default function MapPage({
|
|||
}))
|
||||
);
|
||||
|
||||
// Move the camera to where the matches actually are — flying to the
|
||||
// travel-time anchor often lands on a viewport with zero matches.
|
||||
if (result.matchBounds) {
|
||||
const target = boundsToCenterZoom(result.matchBounds);
|
||||
mapFlyToRef.current?.(target.lat, target.lng, target.zoom, getMobileMapFlyToOptions());
|
||||
return;
|
||||
}
|
||||
|
||||
const firstTravelTime = representable[0]?.tt;
|
||||
if (!firstTravelTime?.slug) return;
|
||||
|
||||
|
|
@ -482,7 +491,15 @@ export default function MapPage({
|
|||
}, []);
|
||||
|
||||
const shareReturnViewRef = useRef(shareCode ? initialViewState : null);
|
||||
// Hide the upgrade modal as soon as the user dismisses it. We can't rely on
|
||||
// the camera fly alone to close it: flying back to the free/shared zone only
|
||||
// clears `licenseRequired` once the resulting refetch returns non-403, and
|
||||
// when the fly target equals the current view (e.g. "back to shared area"
|
||||
// while already at the shared view) `jumpTo` is a no-op, so no refetch fires
|
||||
// and the modal would otherwise stay stuck open.
|
||||
const [upgradeModalDismissed, setUpgradeModalDismissed] = useState(false);
|
||||
const handleZoomToFreeZone = useCallback(() => {
|
||||
setUpgradeModalDismissed(true);
|
||||
const target = shareReturnViewRef.current ?? INITIAL_VIEW_STATE;
|
||||
mapFlyToRef.current?.(target.latitude, target.longitude, target.zoom);
|
||||
}, []);
|
||||
|
|
@ -576,7 +593,6 @@ export default function MapPage({
|
|||
const tutorial = useTutorial(initialLoading, isMobile, deferTutorial || mapData.licenseRequired);
|
||||
const tutorialTheme = useMemo(() => getTutorialStyles(theme), [theme]);
|
||||
const densityLabel = t('mapLegend.historicalMatches');
|
||||
const hasActiveFilters = Object.keys(filters).length > 0 || activeEntries.length > 0;
|
||||
const mobileLegendMeta = useMobileLegendMeta(viewFeature, features);
|
||||
const mapViewFeature = useMapViewFeature(viewFeature);
|
||||
const mobileDensityRange = useMobileDensityRange(mapData);
|
||||
|
|
@ -661,7 +677,13 @@ export default function MapPage({
|
|||
}, [onDashboardReadyChange]);
|
||||
|
||||
useEffect(() => {
|
||||
if (mapData.licenseRequired) trackEvent('Upgrade Modal Shown');
|
||||
if (mapData.licenseRequired) {
|
||||
trackEvent('Upgrade Modal Shown');
|
||||
} else {
|
||||
// Back in a viewable area — re-arm so the modal shows again the next time
|
||||
// the user pans into a gated area.
|
||||
setUpgradeModalDismissed(false);
|
||||
}
|
||||
}, [mapData.licenseRequired]);
|
||||
|
||||
if (screenshotMode) {
|
||||
|
|
@ -856,22 +878,25 @@ export default function MapPage({
|
|||
</div>
|
||||
) : null;
|
||||
|
||||
const upgradeModal = mapData.licenseRequired ? (
|
||||
<Suspense fallback={null}>
|
||||
<UpgradeModal
|
||||
isLoggedIn={!!user}
|
||||
onLoginClick={() =>
|
||||
onCheckoutLoginClick ? onCheckoutLoginClick(checkoutReturnPath) : onLoginClick()
|
||||
}
|
||||
onRegisterClick={() =>
|
||||
onCheckoutRegisterClick ? onCheckoutRegisterClick(checkoutReturnPath) : onRegisterClick()
|
||||
}
|
||||
onStartCheckout={() => license.startCheckout(checkoutReturnPath)}
|
||||
onZoomToFreeZone={handleZoomToFreeZone}
|
||||
isShareReturn={!!shareReturnViewRef.current}
|
||||
/>
|
||||
</Suspense>
|
||||
) : null;
|
||||
const upgradeModal =
|
||||
mapData.licenseRequired && !upgradeModalDismissed ? (
|
||||
<Suspense fallback={null}>
|
||||
<UpgradeModal
|
||||
isLoggedIn={!!user}
|
||||
onLoginClick={() =>
|
||||
onCheckoutLoginClick ? onCheckoutLoginClick(checkoutReturnPath) : onLoginClick()
|
||||
}
|
||||
onRegisterClick={() =>
|
||||
onCheckoutRegisterClick
|
||||
? onCheckoutRegisterClick(checkoutReturnPath)
|
||||
: onRegisterClick()
|
||||
}
|
||||
onStartCheckout={() => license.startCheckout(checkoutReturnPath)}
|
||||
onZoomToFreeZone={handleZoomToFreeZone}
|
||||
isShareReturn={!!shareReturnViewRef.current}
|
||||
/>
|
||||
</Suspense>
|
||||
) : null;
|
||||
|
||||
if (isMobile) {
|
||||
return (
|
||||
|
|
@ -980,7 +1005,7 @@ export default function MapPage({
|
|||
onToggleActualListings={canSeeListings ? handleToggleActualListings : undefined}
|
||||
travelTimeEntries={entries}
|
||||
densityLabel={densityLabel}
|
||||
totalCount={hasActiveFilters ? filterCounts.total : undefined}
|
||||
totalCount={filterCounts.total ?? undefined}
|
||||
poiPaneOpen={poiPaneOpen}
|
||||
onTogglePoiPane={handleTogglePoiPane}
|
||||
poiPane={renderPOIPane()}
|
||||
|
|
|
|||
|
|
@ -6,11 +6,16 @@ interface PriceHistoryChartProps {
|
|||
points: PricePoint[];
|
||||
}
|
||||
|
||||
const PADDING = { top: 8, right: 24, bottom: 20, left: 42 };
|
||||
const PADDING = { top: 8, right: 24, bottom: 20, left: 48 };
|
||||
const HEIGHT = 120;
|
||||
const PRICE_SCALE_TOP_PERCENTILE = 95;
|
||||
const priceFmt = { prefix: '£' };
|
||||
|
||||
/** Ticks are nice round values; "£800.0k" both clips and reads worse than "£800k". */
|
||||
function formatTick(tick: number): string {
|
||||
return formatValue(tick, priceFmt).replace(/\.0(?=[kM])/, '');
|
||||
}
|
||||
|
||||
interface PriceScale {
|
||||
min: number;
|
||||
max: number;
|
||||
|
|
@ -148,7 +153,7 @@ export default function PriceHistoryChart({ points }: PriceHistoryChartProps) {
|
|||
className="fill-warm-500 dark:fill-warm-400"
|
||||
fontSize={10}
|
||||
>
|
||||
{formatValue(tick, priceFmt)}
|
||||
{formatTick(tick)}
|
||||
</text>
|
||||
))}
|
||||
|
||||
|
|
|
|||
|
|
@ -9,7 +9,9 @@ import { TravelTimeInfoPopup } from '../ui/TravelTimeInfoPopup';
|
|||
import { CloseIcon } from '../ui/icons/CloseIcon';
|
||||
import { EyeIcon } from '../ui/icons/EyeIcon';
|
||||
import { InfoIcon } from '../ui/icons/InfoIcon';
|
||||
import { formatFilterValue, formatNumber } from '../../lib/format';
|
||||
import { SliderLabels } from './filters/SliderLabels';
|
||||
import { formatNumber } from '../../lib/format';
|
||||
import type { FeatureMeta } from '../../types';
|
||||
import { useTravelDestinations } from '../../hooks/useTravelDestinations';
|
||||
import {
|
||||
MAX_TRAVEL_MINUTES,
|
||||
|
|
@ -56,7 +58,7 @@ export function TravelTimeCard({
|
|||
dragValue,
|
||||
onTogglePin,
|
||||
onSetDestination,
|
||||
onTimeRangeChange: _onTimeRangeChange,
|
||||
onTimeRangeChange,
|
||||
onDragStart,
|
||||
onDragChange,
|
||||
onDragEnd,
|
||||
|
|
@ -86,6 +88,17 @@ export function TravelTimeCard({
|
|||
const sliderMax = MAX_TRAVEL_MINUTES;
|
||||
const displayRange = isActive && dragValue ? dragValue : (timeRange ?? [sliderMin, sliderMax]);
|
||||
|
||||
// Synthetic feature so the time labels reuse the shared SliderLabels (matching
|
||||
// every other filter card) — editable, thumb-following, with the minute unit.
|
||||
const travelFeature: FeatureMeta = {
|
||||
name: 'travelTime',
|
||||
type: 'numeric',
|
||||
min: sliderMin,
|
||||
max: sliderMax,
|
||||
suffix: ` ${t('common.minute')}`,
|
||||
raw: true,
|
||||
};
|
||||
|
||||
const ModeIcon = MODE_ICONS[mode];
|
||||
|
||||
return (
|
||||
|
|
@ -211,14 +224,17 @@ export function TravelTimeCard({
|
|||
onPointerDown={() => onDragStart(displayRange)}
|
||||
onPointerUp={() => onDragEnd()}
|
||||
/>
|
||||
<div className="relative h-4 mt-1 mx-2.5 text-[10px] text-warm-500 dark:text-warm-400 leading-tight">
|
||||
<span className="absolute left-0">
|
||||
{formatFilterValue(displayRange[0])} {t('common.minute')}
|
||||
</span>
|
||||
<span className="absolute right-0">
|
||||
{formatFilterValue(displayRange[1])} {t('common.minute')}
|
||||
</span>
|
||||
</div>
|
||||
<SliderLabels
|
||||
min={sliderMin}
|
||||
max={sliderMax}
|
||||
value={[displayRange[0], displayRange[1]]}
|
||||
isAtMin={displayRange[0] <= sliderMin}
|
||||
isAtMax={displayRange[1] >= sliderMax}
|
||||
raw
|
||||
showUnit
|
||||
feature={travelFeature}
|
||||
onValueChange={onTimeRangeChange}
|
||||
/>
|
||||
{filterImpact != null && filterImpact > 0 && (
|
||||
<p className="text-[10px] text-warm-400 dark:text-warm-500 -mt-1 ml-2.5">
|
||||
{t('filters.filtersOut', { value: formatNumber(filterImpact) })}
|
||||
|
|
|
|||
|
|
@ -11,7 +11,6 @@ import {
|
|||
getSchoolFilterConfig,
|
||||
getSchoolFilterMeta,
|
||||
replaceSchoolFilterKeySelection,
|
||||
type SchoolDistance,
|
||||
type SchoolPhase,
|
||||
type SchoolRating,
|
||||
} from '../../../lib/school-filter';
|
||||
|
|
@ -74,7 +73,6 @@ export function SchoolFilterCard({
|
|||
next: Partial<{
|
||||
phase: SchoolPhase;
|
||||
rating: SchoolRating;
|
||||
distance: SchoolDistance;
|
||||
}>
|
||||
) => {
|
||||
const nextName = replaceSchoolFilterKeySelection(schoolFeature.name, next);
|
||||
|
|
@ -180,35 +178,6 @@ export function SchoolFilterCard({
|
|||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="mb-0.5 text-[10px] font-medium uppercase text-warm-400 dark:text-warm-500">
|
||||
{t('filters.distance')}
|
||||
</div>
|
||||
<div
|
||||
className={segmentedClass}
|
||||
role="radiogroup"
|
||||
aria-label={t('filters.schoolDistance')}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
role="radio"
|
||||
aria-checked={config.distance === 2}
|
||||
onClick={() => replaceSchoolFeature({ distance: 2 })}
|
||||
className={optionClass(config.distance === 2)}
|
||||
>
|
||||
2 km
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
role="radio"
|
||||
aria-checked={config.distance === 5}
|
||||
onClick={() => replaceSchoolFeature({ distance: 5 })}
|
||||
className={optionClass(config.distance === 5)}
|
||||
>
|
||||
5 km
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Slider
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ import { logNonAbortError } from '../../lib/api';
|
|||
import { trackEvent } from '../../lib/analytics';
|
||||
import { apiUrl } from '../../lib/api';
|
||||
import HexCanvas from '../home/HexCanvas';
|
||||
import { useIsDarkTheme } from '../../hooks/useIsDarkTheme';
|
||||
|
||||
// Feature list keys — resolved inside the component via t()
|
||||
|
||||
|
|
@ -39,6 +40,7 @@ export default function PricingPage({
|
|||
onRegisterClick?: () => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const isDark = useIsDarkTheme();
|
||||
const license = useLicense();
|
||||
const [pricing, setPricing] = useState<PricingData | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
|
@ -137,17 +139,23 @@ export default function PricingPage({
|
|||
) : null;
|
||||
|
||||
return (
|
||||
<div className="flex-1 overflow-y-auto bg-navy-950 relative">
|
||||
<div className="flex-1 overflow-y-auto bg-warm-50 dark:bg-navy-950 relative">
|
||||
<div className="fixed inset-0 pointer-events-none overflow-hidden">
|
||||
<div className="absolute inset-0 bg-gradient-to-b from-navy-950 via-navy-900 to-navy-950" />
|
||||
<HexCanvas isDark />
|
||||
<div className="absolute inset-0 bg-[radial-gradient(circle_at_50%_12%,rgba(20,184,166,0.16),transparent_38%),linear-gradient(180deg,rgba(10,14,26,0.20),rgba(10,14,26,0.82))]" />
|
||||
<div className="absolute inset-0 bg-gradient-to-b from-warm-100 via-warm-50 to-warm-100 dark:from-navy-950 dark:via-navy-900 dark:to-navy-950" />
|
||||
<HexCanvas isDark={isDark} />
|
||||
<div className="absolute inset-0 hidden dark:block bg-[radial-gradient(circle_at_50%_12%,rgba(20,184,166,0.16),transparent_38%),linear-gradient(180deg,rgba(10,14,26,0.20),rgba(10,14,26,0.82))]" />
|
||||
</div>
|
||||
|
||||
<div className="relative z-10 max-w-5xl mx-auto px-6 pt-16 text-center mb-10">
|
||||
<h1 className="text-3xl md:text-4xl font-bold text-white mb-3">{t('pricingPage.title')}</h1>
|
||||
<p className="text-lg text-warm-300 max-w-lg mx-auto">{t('pricingPage.subtitle')}</p>
|
||||
<p className="mt-5 text-warm-200 font-semibold">{t('pricingPage.lessThanSurvey')}</p>
|
||||
<h1 className="text-3xl md:text-4xl font-bold text-navy-950 dark:text-white mb-3">
|
||||
{t('pricingPage.title')}
|
||||
</h1>
|
||||
<p className="text-lg text-warm-600 dark:text-warm-300 max-w-lg mx-auto">
|
||||
{t('pricingPage.subtitle')}
|
||||
</p>
|
||||
<p className="mt-5 text-warm-700 dark:text-warm-200 font-semibold">
|
||||
{t('pricingPage.lessThanSurvey')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="relative z-10 max-w-5xl mx-auto px-6 pb-8 md:pb-16">
|
||||
|
|
@ -203,7 +211,7 @@ export default function PricingPage({
|
|||
className={`relative flex flex-col rounded-2xl border overflow-hidden w-80 shrink-0 ${
|
||||
isCurrent
|
||||
? 'border-teal-400 ring-2 ring-teal-400 shadow-lg'
|
||||
: 'border-warm-700 shadow-md'
|
||||
: 'border-warm-300 dark:border-warm-700 shadow-md'
|
||||
} ${isFilled ? 'opacity-60' : ''}`}
|
||||
>
|
||||
{isCurrent && (
|
||||
|
|
@ -266,11 +274,6 @@ export default function PricingPage({
|
|||
})}
|
||||
</p>
|
||||
)}
|
||||
{isFilled && (
|
||||
<p className="text-warm-400 dark:text-warm-500 text-sm mt-2 flex items-center justify-center gap-1">
|
||||
<CheckIcon className="w-4 h-4" /> {t('pricingPage.filled')}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Progress bar for current tier */}
|
||||
|
|
@ -308,10 +311,14 @@ export default function PricingPage({
|
|||
{license.error}
|
||||
</p>
|
||||
)}
|
||||
{isFree && (
|
||||
{isFree ? (
|
||||
<p className="text-center text-xs text-warm-400 dark:text-warm-500 mt-2">
|
||||
{t('pricingPage.noCreditCard')}
|
||||
</p>
|
||||
) : (
|
||||
<p className="text-center text-xs text-warm-400 dark:text-warm-500 mt-2">
|
||||
{t('pricingPage.moneyBack')}
|
||||
</p>
|
||||
)}
|
||||
</>
|
||||
) : isFilled ? (
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import { useState, useCallback, useEffect, useId } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Trans, useTranslation } from 'react-i18next';
|
||||
import { CloseIcon } from './icons/CloseIcon';
|
||||
import { GoogleIcon } from './icons/GoogleIcon';
|
||||
import { trackEvent } from '../../lib/analytics';
|
||||
|
|
@ -106,7 +106,7 @@ export default function AuthModal({
|
|||
|
||||
return (
|
||||
<div
|
||||
className="fixed inset-0 z-50 flex items-center justify-center"
|
||||
className="fixed inset-0 z-[10001] flex items-center justify-center"
|
||||
onMouseDown={(e) => {
|
||||
if (e.target === e.currentTarget) onClose();
|
||||
}}
|
||||
|
|
@ -283,6 +283,32 @@ export default function AuthModal({
|
|||
{t('auth.backToLogin')}
|
||||
</button>
|
||||
)}
|
||||
|
||||
{view === 'register' && (
|
||||
<p className="text-[11px] leading-relaxed text-center text-warm-400 dark:text-warm-500">
|
||||
<Trans
|
||||
i18nKey="auth.registerConsent"
|
||||
components={{
|
||||
terms: (
|
||||
<a
|
||||
href="/terms"
|
||||
target="_blank"
|
||||
rel="noopener"
|
||||
className="underline hover:text-teal-600 dark:hover:text-teal-400"
|
||||
/>
|
||||
),
|
||||
privacy: (
|
||||
<a
|
||||
href="/privacy"
|
||||
target="_blank"
|
||||
rel="noopener"
|
||||
className="underline hover:text-teal-600 dark:hover:text-teal-400"
|
||||
/>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
</p>
|
||||
)}
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -33,6 +33,8 @@ export type Page =
|
|||
| 'data-sources'
|
||||
| 'methodology'
|
||||
| 'privacy-security'
|
||||
| 'terms'
|
||||
| 'privacy'
|
||||
| 'account'
|
||||
| 'saved'
|
||||
| 'invite';
|
||||
|
|
@ -58,6 +60,8 @@ export const PAGE_PATHS: Record<Page, string> = {
|
|||
'data-sources': '/data-sources',
|
||||
methodology: '/methodology',
|
||||
'privacy-security': '/privacy-security',
|
||||
terms: '/terms',
|
||||
privacy: '/privacy',
|
||||
saved: '/saved',
|
||||
account: '/account',
|
||||
invite: '/invite',
|
||||
|
|
|
|||
|
|
@ -18,6 +18,12 @@ interface SearchHook {
|
|||
handleInputChange: (value: string) => void;
|
||||
handleKeyDown: (e: React.KeyboardEvent, onSelect: (result: SearchResult) => void) => void;
|
||||
showEmptySearches: () => void;
|
||||
close?: () => void;
|
||||
}
|
||||
|
||||
/** Addresses arrive in raw ALL-CAPS Land Registry casing; title-case for display. */
|
||||
function titleCaseAddress(address: string): string {
|
||||
return address.toLowerCase().replace(/(^|[\s\-/(])([a-z])/g, (_, sep, c) => sep + c.toUpperCase());
|
||||
}
|
||||
|
||||
interface PlaceSearchInputProps {
|
||||
|
|
@ -65,6 +71,9 @@ export function PlaceSearchInput({
|
|||
const dropdown = showDropdown && (
|
||||
<div
|
||||
className={`bg-white dark:bg-warm-800 rounded shadow-lg border border-warm-200 dark:border-warm-700 ${sm ? 'max-h-64' : 'max-h-48'} overflow-y-auto`}
|
||||
// Keep focus on the input while interacting with the list (incl. its
|
||||
// scrollbar) so the blur-close below doesn't fire mid-click.
|
||||
onMouseDown={(e) => e.preventDefault()}
|
||||
style={
|
||||
portal && dropdownPos
|
||||
? {
|
||||
|
|
@ -116,8 +125,11 @@ export function PlaceSearchInput({
|
|||
) : result.type === 'address' ? (
|
||||
<>
|
||||
<HouseIcon className={`${iconSize} text-warm-400 dark:text-warm-500 shrink-0`} />
|
||||
<span className="min-w-0 text-warm-700 dark:text-warm-200">
|
||||
<span className="block truncate">{result.address}</span>
|
||||
<span
|
||||
className="min-w-0 text-warm-700 dark:text-warm-200"
|
||||
title={`${titleCaseAddress(result.address)}, ${result.postcode}`}
|
||||
>
|
||||
<span className="block truncate">{titleCaseAddress(result.address)}</span>
|
||||
<span className="block truncate text-warm-400 dark:text-warm-500">
|
||||
{result.postcode}
|
||||
</span>
|
||||
|
|
@ -126,7 +138,10 @@ export function PlaceSearchInput({
|
|||
) : (
|
||||
<>
|
||||
<MapPinIcon className={`${iconSize} text-warm-400 dark:text-warm-500 shrink-0`} />
|
||||
<span className="text-warm-700 dark:text-warm-200">
|
||||
<span
|
||||
className="text-warm-700 dark:text-warm-200"
|
||||
title={result.city ? `${result.name} (${result.city})` : result.name}
|
||||
>
|
||||
{result.name}
|
||||
{result.city && (
|
||||
<span className="text-warm-400 dark:text-warm-500"> ({result.city})</span>
|
||||
|
|
@ -154,6 +169,9 @@ export function PlaceSearchInput({
|
|||
onFocus={() => {
|
||||
search.showEmptySearches();
|
||||
}}
|
||||
// Without this, instances whose parents lack outside-click handling
|
||||
// leave a detached dropdown floating over the map.
|
||||
onBlur={() => search.close?.()}
|
||||
onKeyDown={(e) => search.handleKeyDown(e, onSelect)}
|
||||
aria-label={ariaLabel ?? placeholder}
|
||||
placeholder={placeholder}
|
||||
|
|
|
|||
|
|
@ -176,10 +176,9 @@ export function useDeckLayers({
|
|||
enumPaletteRef.current = enumPalette;
|
||||
|
||||
const countRange = useMemo(() => {
|
||||
if (data.length === 0) return { min: 0, max: 1, total: 0 };
|
||||
if (data.length === 0) return { min: 0, max: 1 };
|
||||
let min = Infinity;
|
||||
let max = -Infinity;
|
||||
let total = 0;
|
||||
for (const d of data) {
|
||||
if (viewportBounds) {
|
||||
if (
|
||||
|
|
@ -191,24 +190,22 @@ export function useDeckLayers({
|
|||
continue;
|
||||
}
|
||||
const c = d.count as number;
|
||||
total += c;
|
||||
if (c <= 0) continue;
|
||||
if (c < min) min = c;
|
||||
if (c > max) max = c;
|
||||
}
|
||||
if (min === Infinity) return { min: 0, max: 1, total: 0 };
|
||||
if (min === max) return { min, max: min + 1, total };
|
||||
return { min, max, total };
|
||||
if (min === Infinity) return { min: 0, max: 1 };
|
||||
if (min === max) return { min, max: min + 1 };
|
||||
return { min, max };
|
||||
}, [data, viewportBounds]);
|
||||
|
||||
const countRangeRef = useRef(countRange);
|
||||
countRangeRef.current = countRange;
|
||||
|
||||
const postcodeCountRange = useMemo(() => {
|
||||
if (postcodeData.length === 0) return { min: 0, max: 1, total: 0 };
|
||||
if (postcodeData.length === 0) return { min: 0, max: 1 };
|
||||
let min = Infinity;
|
||||
let max = -Infinity;
|
||||
let total = 0;
|
||||
for (const d of postcodeData) {
|
||||
if (viewportBounds) {
|
||||
const [lng, lat] = d.properties.centroid as [number, number];
|
||||
|
|
@ -221,14 +218,13 @@ export function useDeckLayers({
|
|||
continue;
|
||||
}
|
||||
const c = d.properties.count;
|
||||
total += c;
|
||||
if (c <= 0) continue;
|
||||
if (c < min) min = c;
|
||||
if (c > max) max = c;
|
||||
}
|
||||
if (min === Infinity) return { min: 0, max: 1, total: 0 };
|
||||
if (min === max) return { min, max: min + 1, total };
|
||||
return { min, max, total };
|
||||
if (min === Infinity) return { min: 0, max: 1 };
|
||||
if (min === max) return { min, max: min + 1 };
|
||||
return { min, max };
|
||||
}, [postcodeData, viewportBounds]);
|
||||
|
||||
const postcodeCountRangeRef = useRef(postcodeCountRange);
|
||||
|
|
|
|||
|
|
@ -180,12 +180,7 @@ export function useFilters({ initialFilters, features }: UseFiltersOptions) {
|
|||
undoStackRef.current.push(prev);
|
||||
if (undoStackRef.current.length > 50) undoStackRef.current.shift();
|
||||
if (name === SCHOOL_FILTER_NAME) {
|
||||
const schoolKey = createSchoolFilterKey(
|
||||
'primary',
|
||||
'good',
|
||||
2,
|
||||
schoolFilterIdRef.current++
|
||||
);
|
||||
const schoolKey = createSchoolFilterKey('primary', 'good', schoolFilterIdRef.current++);
|
||||
const defaultSchoolFeatureName = getDefaultSchoolFeatureName(features);
|
||||
const defaultSchoolFeature = defaultSchoolFeatureName
|
||||
? features.find((feature) => feature.name === defaultSchoolFeatureName)
|
||||
|
|
|
|||
|
|
@ -1111,6 +1111,34 @@ const de: Translations = {
|
|||
articlesIntro:
|
||||
'Durchsuche die öffentlichen Leitfäden zu Immobiliensuche, Pendeln, Schulen, Postleitzahlprüfungen, regionalen Vergleichen, Datenabdeckung, Methodik und Datenschutz.',
|
||||
supportIntro: 'Hast du eine Frage? Schau in unsere FAQ oder kontaktiere uns direkt.',
|
||||
videos: 'Videos',
|
||||
videosTitle: 'Social-Media-Videos',
|
||||
videosIntro:
|
||||
'Kurze Clips aus unseren Social-Media-Kanälen – jeder zeigt eine einzelne Suche in Aktion, von ruhigen Straßen über Schuleinzugsgebiete bis zu Pendelzeiten.',
|
||||
video01Title: 'Ein Satz, jede Postleitzahl',
|
||||
video01Desc:
|
||||
'Gib deinen kompletten Wohnungswunsch in normaler Sprache ein und sieh zu, wie jede passende Postleitzahl in England aufleuchtet.',
|
||||
video02Title: 'Die 20-Minuten-Karte',
|
||||
video02Desc:
|
||||
'Färbe die Karte nach Pendelzeit und sieh genau, was dir 20 Minuten ins Zentrum von London wirklich übrig lassen.',
|
||||
video03Title: 'Jede Postleitzahl hat eine Akte',
|
||||
video03Desc:
|
||||
'Tippe auf eine Postleitzahl und lies ihre Akte – verkaufte Preise, Schulen, Kriminalität und Street View an einem Ort.',
|
||||
video04Title: 'Ein Foto kann man nicht hören',
|
||||
video04Desc:
|
||||
'Anzeigenfotos sind stumm. Filtere nach Lärmpegel und finde die wirklich ruhigen Straßen unter 55 Dezibel.',
|
||||
video05Title: 'Die Schulweg-Karte',
|
||||
video05Desc:
|
||||
'Gute Grundschul-Einzugsgebiete, wenig Kriminalität und ein Budget – der Familienwunsch, über eine ganze Stadt kartiert.',
|
||||
video06Title: 'Der Waitrose-Test',
|
||||
video06Desc:
|
||||
'Zu Fuß zu einem Supermarkt, einer U-Bahn-Station und einem Park – filtere nach dem Leben, nicht nur nach dem Grundriss.',
|
||||
video07Title: 'Auch Mieter bekommen eine Karte',
|
||||
video07Desc:
|
||||
'Miete im Budget, kurzer Arbeitsweg und eine ruhige Straße – Mietportale zeigen Wohnungen, das hier zeigt dir Gegenden.',
|
||||
video08Title: '9,99 £ gegen einen verlorenen Samstag',
|
||||
video08Desc:
|
||||
'Eine schlechte Besichtigung kostet eine Zugfahrt und ein halbes Wochenende. Sieh vorher, wo du nicht hinmusst.',
|
||||
source: 'Quelle:',
|
||||
optOut: 'Widerspruch gegen öffentliche Offenlegung',
|
||||
attribution: 'Quellenangaben',
|
||||
|
|
|
|||
|
|
@ -1091,6 +1091,34 @@ const en = {
|
|||
articlesIntro:
|
||||
'Browse the public guides for property search, commute, schools, postcode checks, regional comparisons, data coverage, methodology, and privacy.',
|
||||
supportIntro: 'Have a question? Check our FAQ or reach out to us directly.',
|
||||
videos: 'Videos',
|
||||
videosTitle: 'Social media videos',
|
||||
videosIntro:
|
||||
'Short clips from our social channels — each one shows a single search in action, from quiet streets to school catchments and commute times.',
|
||||
video01Title: 'One sentence, every postcode',
|
||||
video01Desc:
|
||||
'Type your whole house brief in plain English and watch every matching postcode in England light up.',
|
||||
video02Title: 'The 20-minute map',
|
||||
video02Desc:
|
||||
'Colour the map by commute time and see exactly what a 20-minute journey to central London actually leaves you.',
|
||||
video03Title: 'Every postcode has a file',
|
||||
video03Desc:
|
||||
'Tap any postcode to read its file — sold prices, schools, crime and Street View, all in one place.',
|
||||
video04Title: 'You can’t hear a photo',
|
||||
video04Desc:
|
||||
'Listing photos are silent. Filter by noise level to find the genuinely quiet streets under 55 decibels.',
|
||||
video05Title: 'The school-run map',
|
||||
video05Desc:
|
||||
'Good primary catchments, low crime and a budget — the family brief, mapped across a whole city.',
|
||||
video06Title: 'The Waitrose test',
|
||||
video06Desc:
|
||||
'Walking distance to a Waitrose, a tube station and a park — filter for the life, not just the floor plan.',
|
||||
video07Title: 'Renters get a map too',
|
||||
video07Desc:
|
||||
'Rent under budget, a short commute and a quiet street — letting sites show flats, this shows you areas.',
|
||||
video08Title: '£9.99 vs a wasted Saturday',
|
||||
video08Desc:
|
||||
'A bad viewing costs a train ticket and half a weekend. See where not to go before you book one.',
|
||||
source: 'Source:',
|
||||
optOut: 'Opt out of public disclosure',
|
||||
attribution: 'Attribution',
|
||||
|
|
|
|||
|
|
@ -1125,6 +1125,34 @@ const fr: Translations = {
|
|||
articlesIntro:
|
||||
'Parcourez les guides publics sur la recherche immobilière, les trajets, les écoles, les codes postaux, les comparaisons régionales, la couverture des données, la méthodologie et la confidentialité.',
|
||||
supportIntro: 'Vous avez une question ? Consultez notre FAQ ou contactez-nous directement.',
|
||||
videos: 'Vidéos',
|
||||
videosTitle: 'Vidéos pour les réseaux sociaux',
|
||||
videosIntro:
|
||||
'De courtes vidéos de nos réseaux sociaux — chacune montre une recherche en action, des rues calmes aux secteurs scolaires en passant par les temps de trajet.',
|
||||
video01Title: 'Une phrase, chaque code postal',
|
||||
video01Desc:
|
||||
'Décrivez tout votre projet immobilier en langage courant et voyez s’allumer 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 s’entend pas',
|
||||
video04Desc:
|
||||
'Les photos d’annonces 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 d’un supermarché, d’une station de métro et d’un 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é d’un week-end. Voyez où ne pas aller avant d’en réserver une.',
|
||||
source: 'Source :',
|
||||
optOut: 'Refus de la publication publique',
|
||||
attribution: 'Attribution',
|
||||
|
|
|
|||
|
|
@ -1072,6 +1072,34 @@ const hi: Translations = {
|
|||
articlesIntro:
|
||||
'संपत्ति खोज, आवागमन, स्कूल, पोस्टकोड जांच, क्षेत्रीय तुलना, डेटा कवरेज, कार्यप्रणाली और गोपनीयता पर सार्वजनिक गाइड देखें.',
|
||||
supportIntro: 'कोई सवाल है? हमारे प्रश्नोत्तर देखें या सीधे संपर्क करें.',
|
||||
videos: 'वीडियो',
|
||||
videosTitle: 'सोशल मीडिया वीडियो',
|
||||
videosIntro:
|
||||
'हमारे सोशल चैनलों की छोटी क्लिप्स — हर एक एक खोज को क्रिया में दिखाती है, शांत गलियों से लेकर स्कूल कैचमेंट और सफर के समय तक.',
|
||||
video01Title: 'एक वाक्य, हर पोस्टकोड',
|
||||
video01Desc:
|
||||
'अपनी पूरी घर की ज़रूरत सामान्य भाषा में लिखें और इंग्लैंड का हर मेल खाता पोस्टकोड जगमगाते देखें.',
|
||||
video02Title: '20 मिनट का नक्शा',
|
||||
video02Desc:
|
||||
'नक्शे को सफर के समय के अनुसार रंगें और देखें कि सेंट्रल लंदन से 20 मिनट वास्तव में आपको क्या देते हैं.',
|
||||
video03Title: 'हर पोस्टकोड की एक फ़ाइल है',
|
||||
video03Desc:
|
||||
'किसी भी पोस्टकोड पर टैप करके उसकी फ़ाइल पढ़ें — बिक्री मूल्य, स्कूल, अपराध और स्ट्रीट व्यू, सब एक जगह.',
|
||||
video04Title: 'फ़ोटो सुनी नहीं जा सकती',
|
||||
video04Desc:
|
||||
'विज्ञापन की तस्वीरें खामोश होती हैं. शोर के स्तर से छानकर 55 डेसिबल से नीचे की सचमुच शांत गलियाँ खोजें.',
|
||||
video05Title: 'स्कूल-रन नक्शा',
|
||||
video05Desc:
|
||||
'अच्छे प्राइमरी कैचमेंट, कम अपराध और एक बजट — परिवार की ज़रूरत, पूरे शहर पर मैप की गई.',
|
||||
video06Title: 'वेट्रोज़ टेस्ट',
|
||||
video06Desc:
|
||||
'सुपरमार्केट, ट्यूब स्टेशन और पार्क से पैदल दूरी — सिर्फ़ फ़्लोर प्लान नहीं, अपनी ज़िंदगी के हिसाब से छानें.',
|
||||
video07Title: 'किराएदारों के लिए भी नक्शा',
|
||||
video07Desc:
|
||||
'बजट में किराया, छोटा सफर और शांत गली — किराये की साइटें फ़्लैट दिखाती हैं, यह आपको इलाके दिखाता है.',
|
||||
video08Title: '£9.99 बनाम एक बर्बाद शनिवार',
|
||||
video08Desc:
|
||||
'एक ख़राब विज़िट एक ट्रेन टिकट और आधा सप्ताहांत ले जाती है. बुकिंग से पहले देखें कि कहाँ नहीं जाना है.',
|
||||
source: 'स्रोत:',
|
||||
optOut: 'सार्वजनिक प्रकटीकरण से बाहर निकलें',
|
||||
attribution: 'श्रेय',
|
||||
|
|
|
|||
|
|
@ -1109,6 +1109,34 @@ const hu: Translations = {
|
|||
articlesIntro:
|
||||
'Böngészd a nyilvános útmutatókat ingatlankeresésről, ingázásról, iskolákról, irányítószám-ellenőrzésről, regionális összehasonlításokról, adatlefedettségről, módszertanról és adatvédelemről.',
|
||||
supportIntro: 'Kérdésed van? Nézd meg a GYIK-et, vagy írj nekünk közvetlenül.',
|
||||
videos: 'Videók',
|
||||
videosTitle: 'Közösségimédia-videók',
|
||||
videosIntro:
|
||||
'Rövid klipek a közösségi csatornáinkról – mindegyik egy-egy keresést mutat be működés közben, a csendes utcáktól az iskolai körzeteken át az utazási időkig.',
|
||||
video01Title: 'Egy mondat, minden irányítószám',
|
||||
video01Desc:
|
||||
'Írd le a teljes lakáskeresési igényedet hétköznapi nyelven, és nézd, ahogy Anglia minden illeszkedő irányítószáma felgyúl.',
|
||||
video02Title: 'A 20 perces térkép',
|
||||
video02Desc:
|
||||
'Színezd a térképet utazási idő szerint, és lásd pontosan, mit hagy neked valójában 20 perc London központjától.',
|
||||
video03Title: 'Minden irányítószámnak van aktája',
|
||||
video03Desc:
|
||||
'Koppints bármelyik irányítószámra, és olvasd el az aktáját – eladási árak, iskolák, bűnözés és Street View egy helyen.',
|
||||
video04Title: 'Egy fotót nem lehet meghallani',
|
||||
video04Desc:
|
||||
'A hirdetésfotók némák. Szűrj zajszint szerint, és találd meg a valóban csendes, 55 decibel alatti utcákat.',
|
||||
video05Title: 'Az iskolába vezető út térképe',
|
||||
video05Desc:
|
||||
'Jó általános iskolai körzetek, alacsony bűnözés és egy költségvetés – a családi igény, egy egész városra térképezve.',
|
||||
video06Title: 'A Waitrose-teszt',
|
||||
video06Desc:
|
||||
'Sétatávolságra egy szupermarkettől, egy metrómegállótól és egy parktól – az életedre szűrj, ne csak az alaprajzra.',
|
||||
video07Title: 'A bérlőknek is jár térkép',
|
||||
video07Desc:
|
||||
'Költségvetésbe férő bérleti díj, rövid ingázás és csendes utca – a bérleti oldalak lakásokat mutatnak, ez környékeket.',
|
||||
video08Title: '9,99 £ egy elpazarolt szombat ellen',
|
||||
video08Desc:
|
||||
'Egy rossz megtekintés egy vonatjegybe és egy fél hétvégébe kerül. Még foglalás előtt lásd, hova ne menj.',
|
||||
source: 'Forrás:',
|
||||
optOut: 'Nyilvános közzététel visszautasítása',
|
||||
attribution: 'Forrásmegnevezés',
|
||||
|
|
|
|||
|
|
@ -1045,6 +1045,26 @@ const zh: Translations = {
|
|||
articlesIntro:
|
||||
'浏览关于找房、通勤、学校、邮编速查、区域对比、数据覆盖、方法论和隐私的公开指南。',
|
||||
supportIntro: '还有疑问?欢迎查看常见问题,或直接与我们联系。',
|
||||
videos: '视频',
|
||||
videosTitle: '社交媒体视频',
|
||||
videosIntro:
|
||||
'来自我们社交平台的短片——每一个都展示一次实际搜索,从安静街道到学校学区,再到通勤时间。',
|
||||
video01Title: '一句话,每个邮编',
|
||||
video01Desc: '用日常语言写下你的全部购房需求,看着英格兰每个符合条件的邮编亮起来。',
|
||||
video02Title: '20 分钟地图',
|
||||
video02Desc: '按通勤时间为地图着色,看清从伦敦市中心 20 分钟究竟能到达哪里。',
|
||||
video03Title: '每个邮编都有一份档案',
|
||||
video03Desc: '点按任意邮编即可查看其档案——成交价、学校、犯罪和街景,尽在一处。',
|
||||
video04Title: '照片听不见声音',
|
||||
video04Desc: '房源照片是无声的。按噪音水平筛选,找到真正安静、低于 55 分贝的街道。',
|
||||
video05Title: '上学路线地图',
|
||||
video05Desc: '优质小学学区、低犯罪率和预算——把家庭需求映射到整座城市。',
|
||||
video06Title: 'Waitrose 测试',
|
||||
video06Desc: '步行可达超市、地铁站和公园——按你的生活方式筛选,而不只是户型图。',
|
||||
video07Title: '租房者也有地图',
|
||||
video07Desc: '预算内的租金、短通勤和安静街道——租房网站展示房源,这里为你展示区域。',
|
||||
video08Title: '£9.99 对比浪费的周六',
|
||||
video08Desc: '一次糟糕的看房要花一张车票和半个周末。在预约之前就看清哪里不该去。',
|
||||
source: '来源:',
|
||||
optOut: '选择不公开',
|
||||
attribution: '数据引用声明',
|
||||
|
|
|
|||
|
|
@ -82,18 +82,18 @@ describe('api utilities', () => {
|
|||
|
||||
it('deduplicates repeated synthetic school filters before backend routes', () => {
|
||||
const features: FeatureMeta[] = [
|
||||
{ name: 'Good+ primary schools within 2km', type: 'numeric', min: 0, max: 10 },
|
||||
{ name: 'Good+ primary school catchments', type: 'numeric', min: 0, max: 10 },
|
||||
];
|
||||
|
||||
expect(
|
||||
buildFilterString(
|
||||
{
|
||||
[createSchoolFilterKey('primary', 'good', 2, 1)]: [1, 10],
|
||||
[createSchoolFilterKey('primary', 'good', 2, 2)]: [2, 8],
|
||||
[createSchoolFilterKey('primary', 'good', 1)]: [1, 10],
|
||||
[createSchoolFilterKey('primary', 'good', 2)]: [2, 8],
|
||||
},
|
||||
features
|
||||
)
|
||||
).toBe('Good+ primary schools within 2km:2:8');
|
||||
).toBe('Good+ primary school catchments:2:8');
|
||||
});
|
||||
|
||||
it('serializes specific crime filters using their selected backend crime feature', () => {
|
||||
|
|
|
|||
|
|
@ -146,53 +146,27 @@ const FEATURE_ICON_PATHS: Record<string, ReactNode> = {
|
|||
<path d="M2 3h6a4 4 0 014 4 4 4 0 014-4h6v18a2 2 0 01-2 2h-4a4 4 0 00-4 4 4 4 0 00-4-4H4a2 2 0 01-2-2z" />
|
||||
</>
|
||||
),
|
||||
'Good+ primary schools within 5km': (
|
||||
'Good+ primary school catchments': (
|
||||
<>
|
||||
<path d="M4 19V9l8-6 8 6v10" />
|
||||
<path d="M9 19v-6h6v6" />
|
||||
<line x1="4" y1="19" x2="20" y2="19" />
|
||||
</>
|
||||
),
|
||||
'Good+ secondary schools within 5km': (
|
||||
'Good+ secondary school catchments': (
|
||||
<>
|
||||
<path d="M22 10v6M2 10l10-5 10 5-10 5z" />
|
||||
<path d="M6 12v5c0 2.5 3 4 6 4s6-1.5 6-4v-5" />
|
||||
</>
|
||||
),
|
||||
'Outstanding primary schools within 5km': (
|
||||
'Outstanding primary school catchments': (
|
||||
<>
|
||||
<path d="M4 19V9l8-6 8 6v10" />
|
||||
<path d="M9 19v-6h6v6" />
|
||||
<line x1="4" y1="19" x2="20" y2="19" />
|
||||
</>
|
||||
),
|
||||
'Outstanding secondary schools within 5km': (
|
||||
<>
|
||||
<path d="M22 10v6M2 10l10-5 10 5-10 5z" />
|
||||
<path d="M6 12v5c0 2.5 3 4 6 4s6-1.5 6-4v-5" />
|
||||
</>
|
||||
),
|
||||
'Good+ primary schools within 2km': (
|
||||
<>
|
||||
<path d="M4 19V9l8-6 8 6v10" />
|
||||
<path d="M9 19v-6h6v6" />
|
||||
<line x1="4" y1="19" x2="20" y2="19" />
|
||||
</>
|
||||
),
|
||||
'Good+ secondary schools within 2km': (
|
||||
<>
|
||||
<path d="M22 10v6M2 10l10-5 10 5-10 5z" />
|
||||
<path d="M6 12v5c0 2.5 3 4 6 4s6-1.5 6-4v-5" />
|
||||
</>
|
||||
),
|
||||
'Outstanding primary schools within 2km': (
|
||||
<>
|
||||
<path d="M4 19V9l8-6 8 6v10" />
|
||||
<path d="M9 19v-6h6v6" />
|
||||
<line x1="4" y1="19" x2="20" y2="19" />
|
||||
</>
|
||||
),
|
||||
'Outstanding secondary schools within 2km': (
|
||||
'Outstanding secondary school catchments': (
|
||||
<>
|
||||
<path d="M22 10v6M2 10l10-5 10 5-10 5z" />
|
||||
<path d="M6 12v5c0 2.5 3 4 6 4s6-1.5 6-4v-5" />
|
||||
|
|
|
|||
|
|
@ -24,6 +24,7 @@ const TRANSPORT_POI_CATEGORIES = new Set([
|
|||
'Ferry',
|
||||
'Rail station',
|
||||
'Taxi rank',
|
||||
'Tram & Metro stop',
|
||||
'Tube station',
|
||||
]);
|
||||
|
||||
|
|
|
|||
|
|
@ -5,12 +5,10 @@ export const SCHOOL_FILTER_KEY_PREFIX = `${SCHOOL_FILTER_NAME}:`;
|
|||
|
||||
export type SchoolPhase = 'primary' | 'secondary';
|
||||
export type SchoolRating = 'good' | 'outstanding';
|
||||
export type SchoolDistance = 2 | 5;
|
||||
|
||||
export interface SchoolFilterConfig {
|
||||
phase: SchoolPhase;
|
||||
rating: SchoolRating;
|
||||
distance: SchoolDistance;
|
||||
featureName: string;
|
||||
}
|
||||
|
||||
|
|
@ -18,50 +16,22 @@ export const SCHOOL_FILTERS: SchoolFilterConfig[] = [
|
|||
{
|
||||
phase: 'primary',
|
||||
rating: 'good',
|
||||
distance: 2,
|
||||
featureName: 'Good+ primary schools within 2km',
|
||||
featureName: 'Good+ primary school catchments',
|
||||
},
|
||||
{
|
||||
phase: 'secondary',
|
||||
rating: 'good',
|
||||
distance: 2,
|
||||
featureName: 'Good+ secondary schools within 2km',
|
||||
featureName: 'Good+ secondary school catchments',
|
||||
},
|
||||
{
|
||||
phase: 'primary',
|
||||
rating: 'outstanding',
|
||||
distance: 2,
|
||||
featureName: 'Outstanding primary schools within 2km',
|
||||
featureName: 'Outstanding primary school catchments',
|
||||
},
|
||||
{
|
||||
phase: 'secondary',
|
||||
rating: 'outstanding',
|
||||
distance: 2,
|
||||
featureName: 'Outstanding secondary schools within 2km',
|
||||
},
|
||||
{
|
||||
phase: 'primary',
|
||||
rating: 'good',
|
||||
distance: 5,
|
||||
featureName: 'Good+ primary schools within 5km',
|
||||
},
|
||||
{
|
||||
phase: 'secondary',
|
||||
rating: 'good',
|
||||
distance: 5,
|
||||
featureName: 'Good+ secondary schools within 5km',
|
||||
},
|
||||
{
|
||||
phase: 'primary',
|
||||
rating: 'outstanding',
|
||||
distance: 5,
|
||||
featureName: 'Outstanding primary schools within 5km',
|
||||
},
|
||||
{
|
||||
phase: 'secondary',
|
||||
rating: 'outstanding',
|
||||
distance: 5,
|
||||
featureName: 'Outstanding secondary schools within 5km',
|
||||
featureName: 'Outstanding secondary school catchments',
|
||||
},
|
||||
];
|
||||
|
||||
|
|
@ -81,42 +51,34 @@ export function getSchoolFilterConfig(name: string): SchoolFilterConfig | null {
|
|||
return SCHOOL_FILTERS.find((filter) => filter.featureName === name) ?? null;
|
||||
}
|
||||
|
||||
export function getSchoolFeatureName(
|
||||
phase: SchoolPhase,
|
||||
rating: SchoolRating,
|
||||
distance: SchoolDistance
|
||||
): string {
|
||||
export function getSchoolFeatureName(phase: SchoolPhase, rating: SchoolRating): string {
|
||||
return (
|
||||
SCHOOL_FILTERS.find(
|
||||
(filter) => filter.phase === phase && filter.rating === rating && filter.distance === distance
|
||||
)?.featureName ?? SCHOOL_FILTERS[0].featureName
|
||||
SCHOOL_FILTERS.find((filter) => filter.phase === phase && filter.rating === rating)
|
||||
?.featureName ?? SCHOOL_FILTERS[0].featureName
|
||||
);
|
||||
}
|
||||
|
||||
export function createSchoolFilterKey(
|
||||
phase: SchoolPhase,
|
||||
rating: SchoolRating,
|
||||
distance: SchoolDistance,
|
||||
id: number | string
|
||||
): string {
|
||||
return `${SCHOOL_FILTER_KEY_PREFIX}${phase}:${rating}:${distance}:${id}`;
|
||||
return `${SCHOOL_FILTER_KEY_PREFIX}${phase}:${rating}:${id}`;
|
||||
}
|
||||
|
||||
export function getSchoolFilterKeyId(name: string): string | null {
|
||||
if (!name.startsWith(SCHOOL_FILTER_KEY_PREFIX)) return null;
|
||||
return name.split(':')[4] ?? null;
|
||||
return name.split(':')[3] ?? null;
|
||||
}
|
||||
|
||||
export function parseSchoolFilterKey(name: string): SchoolFilterConfig | null {
|
||||
if (!name.startsWith(SCHOOL_FILTER_KEY_PREFIX)) return null;
|
||||
const [, phaseRaw, ratingRaw, distanceRaw] = name.split(':');
|
||||
const [, phaseRaw, ratingRaw] = name.split(':');
|
||||
const phase = phaseRaw as SchoolPhase;
|
||||
const rating = ratingRaw as SchoolRating;
|
||||
const distance = Number(distanceRaw) as SchoolDistance;
|
||||
if (
|
||||
(phase !== 'primary' && phase !== 'secondary') ||
|
||||
(rating !== 'good' && rating !== 'outstanding') ||
|
||||
(distance !== 2 && distance !== 5)
|
||||
(rating !== 'good' && rating !== 'outstanding')
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
|
@ -124,8 +86,7 @@ export function parseSchoolFilterKey(name: string): SchoolFilterConfig | null {
|
|||
return {
|
||||
phase,
|
||||
rating,
|
||||
distance,
|
||||
featureName: getSchoolFeatureName(phase, rating, distance),
|
||||
featureName: getSchoolFeatureName(phase, rating),
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -139,18 +100,12 @@ export function replaceSchoolFilterKeySelection(
|
|||
next: {
|
||||
phase?: SchoolPhase;
|
||||
rating?: SchoolRating;
|
||||
distance?: SchoolDistance;
|
||||
}
|
||||
): string {
|
||||
const config = getSchoolFilterConfig(key) ?? SCHOOL_FILTERS[0];
|
||||
const parts = key.startsWith(SCHOOL_FILTER_KEY_PREFIX) ? key.split(':') : [];
|
||||
const id = parts[4] ?? '0';
|
||||
return createSchoolFilterKey(
|
||||
next.phase ?? config.phase,
|
||||
next.rating ?? config.rating,
|
||||
next.distance ?? config.distance,
|
||||
id
|
||||
);
|
||||
const id = parts[3] ?? '0';
|
||||
return createSchoolFilterKey(next.phase ?? config.phase, next.rating ?? config.rating, id);
|
||||
}
|
||||
|
||||
export function getDefaultSchoolFeatureName(features: FeatureMeta[]): string | null {
|
||||
|
|
@ -171,14 +126,7 @@ export function normalizeSchoolFilters(filters: FeatureFilters): FeatureFilters
|
|||
if (isBackendSchoolFeatureName(name)) {
|
||||
const config = getSchoolFilterConfig(name);
|
||||
if (!config) continue;
|
||||
next[
|
||||
createSchoolFilterKey(
|
||||
config.phase,
|
||||
config.rating,
|
||||
config.distance,
|
||||
Object.keys(next).length
|
||||
)
|
||||
] = value;
|
||||
next[createSchoolFilterKey(config.phase, config.rating, Object.keys(next).length)] = value;
|
||||
changed = true;
|
||||
continue;
|
||||
}
|
||||
|
|
@ -201,9 +149,9 @@ export function getSchoolFilterMeta(features: FeatureMeta[]): FeatureMeta {
|
|||
min: sourceFeature?.min ?? 0,
|
||||
max: sourceFeature?.max ?? 10,
|
||||
step: 1,
|
||||
description: 'Rated primary and secondary schools nearby',
|
||||
description: 'Rated schools whose catchment area likely covers the postcode',
|
||||
detail:
|
||||
'Filter by primary or secondary schools, Ofsted rating, and whether schools are within 2km or 5km.',
|
||||
'Filter by how many Good+ or Outstanding primary or secondary schools have a historical catchment area covering the postcode. Catchments are modelled from each school’s pupil numbers and local child population, approximating distance-based admissions.',
|
||||
source: 'ofsted',
|
||||
raw: true,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -352,8 +352,8 @@ describe('url-state', () => {
|
|||
});
|
||||
|
||||
it('round-trips repeated school filters with dedicated URL params', () => {
|
||||
const schoolOne = createSchoolFilterKey('primary', 'good', 2, 1);
|
||||
const schoolTwo = createSchoolFilterKey('secondary', 'outstanding', 5, 2);
|
||||
const schoolOne = createSchoolFilterKey('primary', 'good', 1);
|
||||
const schoolTwo = createSchoolFilterKey('secondary', 'outstanding', 2);
|
||||
|
||||
const params = stateToParams(
|
||||
null,
|
||||
|
|
@ -366,18 +366,22 @@ describe('url-state', () => {
|
|||
'area'
|
||||
);
|
||||
|
||||
expect(params.getAll('school')).toEqual([
|
||||
'primary:good:2:1:10',
|
||||
'secondary:outstanding:5:2:15',
|
||||
]);
|
||||
expect(params.getAll('school')).toEqual(['primary:good:1:10', 'secondary:outstanding:2:15']);
|
||||
expect(params.getAll('filter')).toEqual([]);
|
||||
|
||||
window.history.replaceState({}, '', `/?${params.toString()}`);
|
||||
const state = parseUrlState();
|
||||
|
||||
expect(state.filters).toEqual({
|
||||
[createSchoolFilterKey('primary', 'good', 2, 0)]: [1, 10],
|
||||
[createSchoolFilterKey('secondary', 'outstanding', 5, 1)]: [2, 15],
|
||||
[createSchoolFilterKey('primary', 'good', 0)]: [1, 10],
|
||||
[createSchoolFilterKey('secondary', 'outstanding', 1)]: [2, 15],
|
||||
});
|
||||
});
|
||||
|
||||
it('parses legacy school URL params that still carry a distance segment', () => {
|
||||
window.history.replaceState({}, '', '/?school=primary%3Agood%3A2%3A1%3A10');
|
||||
expect(parseUrlState().filters).toEqual({
|
||||
[createSchoolFilterKey('primary', 'good', 0)]: [1, 10],
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -12,7 +12,6 @@ import {
|
|||
createSchoolFilterKey,
|
||||
getSchoolFilterConfig,
|
||||
isSchoolFilterName,
|
||||
type SchoolDistance,
|
||||
type SchoolPhase,
|
||||
type SchoolRating,
|
||||
} from './school-filter';
|
||||
|
|
@ -122,22 +121,22 @@ function parseFilters(params: URLSearchParams): FeatureFilters {
|
|||
|
||||
schoolParams.forEach((entry, index) => {
|
||||
const parts = entry.split(':');
|
||||
if (parts.length !== 5) return;
|
||||
// 4 parts is the current phase:rating:min:max form; 5 parts is the legacy
|
||||
// phase:rating:distance:min:max form, whose distance segment is ignored.
|
||||
if (parts.length !== 4 && parts.length !== 5) return;
|
||||
const phase = parts[0] as SchoolPhase;
|
||||
const rating = parts[1] as SchoolRating;
|
||||
const distance = Number(parts[2]) as SchoolDistance;
|
||||
const min = Number(parts[3]);
|
||||
const max = Number(parts[4]);
|
||||
const min = Number(parts[parts.length - 2]);
|
||||
const max = Number(parts[parts.length - 1]);
|
||||
if (
|
||||
(phase !== 'primary' && phase !== 'secondary') ||
|
||||
(rating !== 'good' && rating !== 'outstanding') ||
|
||||
(distance !== 2 && distance !== 5) ||
|
||||
isNaN(min) ||
|
||||
isNaN(max)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
filters[createSchoolFilterKey(phase, rating, distance, index)] = [min, max];
|
||||
filters[createSchoolFilterKey(phase, rating, index)] = [min, max];
|
||||
});
|
||||
|
||||
crimeParams.forEach((entry, index) => {
|
||||
|
|
@ -379,10 +378,7 @@ export function stateToParams(
|
|||
const schoolConfig = getSchoolFilterConfig(name);
|
||||
if (schoolConfig && isSchoolFilterName(name)) {
|
||||
const [min, max] = value as [number, number];
|
||||
params.append(
|
||||
'school',
|
||||
`${schoolConfig.phase}:${schoolConfig.rating}:${schoolConfig.distance}:${min}:${max}`
|
||||
);
|
||||
params.append('school', `${schoolConfig.phase}:${schoolConfig.rating}:${min}:${max}`);
|
||||
continue;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -43,12 +43,14 @@ type FormFactor = 'desktop' | 'mobile';
|
|||
* length, padding short `during` blocks with a trailing wait.
|
||||
*/
|
||||
|
||||
// School-count features as served by live /api/features TODAY. The data
|
||||
// pipeline has already moved to modelled catchment counts ("Good+ primary
|
||||
// school catchments"), so flip these two constants when that deploy lands
|
||||
// on prod — preflight will fail loudly if the names drift from the API.
|
||||
const SCHOOL_GOOD_PRIMARY = 'Good+ primary schools within 2km';
|
||||
const SCHOOL_OUTSTANDING_PRIMARY = 'Outstanding primary schools within 2km';
|
||||
// School features as served by live /api/features. The data pipeline moved
|
||||
// to modelled catchment counts ("Good+ primary school catchments"), which the
|
||||
// local stack already serves; prod still serves the older "…within 2km" names
|
||||
// until that deploy lands. Preflight fails loudly if these drift from
|
||||
// whichever API render.sh is pointed at — flip them back if you render against
|
||||
// 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
|
||||
// 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_DRAG_FROM_MIN = 35;
|
||||
// 25 (not 20): tight enough that the drag visibly prunes the map, loose
|
||||
// enough that street-level Manchester keeps plenty of matching postcodes —
|
||||
// at 20 the brief emptied the centre and the postcode tap had nothing
|
||||
// fresh to land on (the drawer then opened in its "filtered stats are
|
||||
// empty" fallback).
|
||||
// enough that street-level central London keeps plenty of matching
|
||||
// postcodes — at 20 the brief emptied the centre and the postcode tap had
|
||||
// nothing fresh to land on (the drawer then opened in its "filtered stats
|
||||
// are empty" fallback).
|
||||
const TT_DRAG_TO_MIN = 25;
|
||||
|
||||
// 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:
|
||||
"Welcome to the demonstration. This is the narrator voice you'll hear throughout the video.",
|
||||
promptText:
|
||||
'First home under £315k, 35 min to Manchester, good schools, low crime, quiet street, fast broadband',
|
||||
travelTimeLabel: 'Manchester city centre',
|
||||
'First home under £600k, 35 min to central London, good schools, low crime, quiet street, fast broadband',
|
||||
travelTimeLabel: 'Central London',
|
||||
exportButtonTitle: 'Export to Excel',
|
||||
exportConfirmLabel: 'Export',
|
||||
closeDrawerLabel: 'Close drawer',
|
||||
|
|
@ -173,8 +175,8 @@ const RECORDING_LOCALIZATIONS: Record<RecordingLocale, RecordingLocalization> =
|
|||
voiceReferenceText:
|
||||
'Willkommen zur Demonstration. Diese Sprecherstimme hörst du im gesamten Video.',
|
||||
promptText:
|
||||
'Wohnung unter £300k, 35 Min. nach Manchester, gute Schulen, niedrige Kriminalität, ruhige Straßen',
|
||||
travelTimeLabel: 'Stadtzentrum Manchester',
|
||||
'Wohnung unter £600k, 35 Min. ins Zentrum von London, gute Schulen, niedrige Kriminalität, ruhige Straßen',
|
||||
travelTimeLabel: 'Zentrum von London',
|
||||
exportButtonTitle: 'Nach Excel exportieren',
|
||||
exportConfirmLabel: 'Exportieren',
|
||||
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 ' +
|
||||
'pronunciation and a friendly, practical delivery.',
|
||||
voiceReferenceText: '欢迎观看演示。整段视频都会使用这位旁白的声音。',
|
||||
promptText: '30万英镑以内的公寓,35分钟到曼彻斯特,学校好,犯罪率低,街道安静',
|
||||
travelTimeLabel: '曼彻斯特市中心',
|
||||
promptText: '60万英镑以内的公寓,35分钟到伦敦市中心,学校好,犯罪率低,街道安静',
|
||||
travelTimeLabel: '伦敦市中心',
|
||||
exportButtonTitle: '导出为 Excel',
|
||||
exportConfirmLabel: '导出',
|
||||
closeDrawerLabel: '关闭侧栏',
|
||||
|
|
@ -237,8 +239,8 @@ const RECORDING_LOCALIZATIONS: Record<RecordingLocale, RecordingLocalization> =
|
|||
'and a friendly, practical delivery.',
|
||||
voiceReferenceText:
|
||||
"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',
|
||||
travelTimeLabel: 'Manchester city centre',
|
||||
promptText: 'Flat under £600k, 35 min to central London, good schools, low crime, quiet streets',
|
||||
travelTimeLabel: 'Central London',
|
||||
exportButtonTitle: 'Excel में export करें',
|
||||
exportConfirmLabel: 'Export',
|
||||
closeDrawerLabel: 'ड्रॉअर बंद करें',
|
||||
|
|
@ -520,7 +522,7 @@ function buildVideoConfig(formFactor: FormFactor): VideoConfig {
|
|||
outputFps: 50,
|
||||
minDurationS: 10,
|
||||
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.
|
||||
posterTimeS: 31,
|
||||
};
|
||||
|
|
@ -536,20 +538,22 @@ function createRecordingStoryboard(
|
|||
): Storyboard {
|
||||
const copy = RECORDING_LOCALIZATIONS[locale];
|
||||
// Mobile bumps the initial zoom from 11.5 → 12 so the narrower 540-wide
|
||||
// viewport still shows a Manchester-metro slice densely populated with
|
||||
// hexagons (otherwise the visible map gets dominated by Pennine moors
|
||||
// on the east edge with no matches).
|
||||
// viewport still shows an inner-London slice densely populated with
|
||||
// hexagons (otherwise the visible map gets dominated by the low-density
|
||||
// outer edges with no matches).
|
||||
const initialZoom = formFactor === 'mobile' ? 12 : 11.5;
|
||||
|
||||
// On mobile the MobileBottomSheet covers the bottom ~44% of the
|
||||
// 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°
|
||||
// south so Manchester city centre (53.4795) lands in the upper half of
|
||||
// the visible map area instead of getting hidden under the sheet. The
|
||||
// south so central London (51.515) lands in the upper half of the
|
||||
// visible map area instead of getting hidden under the sheet. The
|
||||
// desktop layout already has the map dominate the viewport, so it
|
||||
// keeps the original centre.
|
||||
const mapLat = formFactor === 'mobile' ? 53.4395 : 53.4795;
|
||||
const mapLon = -2.2451;
|
||||
// keeps the original centre. -0.13 lon centres on Holborn/Bloomsbury,
|
||||
// between the West End and the City, so the Bank-station travel filter
|
||||
// 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 {
|
||||
name: storyboardName(copy, formFactor),
|
||||
|
|
@ -572,10 +576,15 @@ function createRecordingStoryboard(
|
|||
// from /api/features (preflight validates against the live server).
|
||||
stubbedFilters: {
|
||||
'Property type': ['Flats/Maisonettes', 'Semi-Detached'],
|
||||
'Estimated current price': [0, 315000],
|
||||
// Loose enough to keep the Manchester map richly populated — a cap
|
||||
// of 20 emptied the city centre and left the zoom with nothing to
|
||||
// land on.
|
||||
// £600k (not £315k like the old Manchester cut): central London is
|
||||
// pricier, and at £315k the price+crime combo emptied the inner
|
||||
// boroughs. At £600k ~51k postcodes pass in-frame, dropping to ~10k
|
||||
// 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],
|
||||
[SCHOOL_GOOD_PRIMARY]: [1, 10],
|
||||
'Noise (dB)': [0, 65],
|
||||
|
|
@ -584,11 +593,13 @@ function createRecordingStoryboard(
|
|||
'Max available download speed (Mbps)': [30, 1000],
|
||||
},
|
||||
// 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: [
|
||||
{
|
||||
mode: 'transit',
|
||||
slug: 'manchester',
|
||||
slug: 'bank-tube-station',
|
||||
label: copy.travelTimeLabel,
|
||||
max: TT_DRAG_FROM_MIN,
|
||||
},
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue