diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index b3a23be..7f03f83 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -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(null); @@ -641,6 +687,8 @@ export default function App() { /> ) : activePage === 'learn' ? ( + ) : activePage === 'terms' || activePage === 'privacy' ? ( + ) : isSeoLandingPage(activePage) ? ( navigateTo('dashboard')} /> ) : isSeoContentPage(activePage) ? ( @@ -662,7 +710,7 @@ export default function App() { }} scrollTarget={routeHash} /> - ) : isAuthRequiredPage && !user ? ( + ) : isProtectedPageActive && !user ? ( ) : activePage === 'invite' && inviteCode ? ( setPendingInfoFeature(null)} onNavigateTo={navigateTo} onExportStateChange={setExportState} - onDashboardParamsChange={setDashboardParams} + onDashboardParamsChange={(params) => { + setDashboardParams(params); + if (!mapUrlState.share) persistLastDashboardParams(params); + }} onDashboardReadyChange={setDashboardReady} isMobile={isMobile} initialTravelTime={mapUrlState.travelTime} diff --git a/frontend/src/components/home/HomePage.tsx b/frontend/src/components/home/HomePage.tsx index ed703a1..8cdaf4a 100644 --- a/frontend/src/components/home/HomePage.tsx +++ b/frontend/src/components/home/HomePage.tsx @@ -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 ( +

+ {pricing.current_price_pence === 0 + ? t('upgrade.freeForEarly') + : t('home.priceStrip', { price })}{' '} + {pricing.current_price_pence > 0 && spotsRemaining > 0 && ( + + {spotsRemaining === 1 + ? t('home.priceStripSpots', { count: spotsRemaining }) + : t('home.priceStripSpotsPlural', { count: spotsRemaining })} + + )}{' '} + +

+ ); +} + 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({

{highlightBrandText(t('home.heroDescription'), 'font-semibold text-teal-300')}

-
+
+ +

{t('home.coverageNote')}

@@ -356,7 +426,7 @@ export default function HomePage({
- +
{t('home.statFilters')}
diff --git a/frontend/src/components/home/ProductShowcase.tsx b/frontend/src/components/home/ProductShowcase.tsx index 3c9cefe..b4bc291 100644 --- a/frontend/src/components/home/ProductShowcase.tsx +++ b/frontend/src/components/home/ProductShowcase.tsx @@ -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)') { diff --git a/frontend/src/components/learn/LearnPage.tsx b/frontend/src/components/learn/LearnPage.tsx index ac2b05d..9b7a20f 100644 --- a/frontend/src/components/learn/LearnPage.tsx +++ b/frontend/src/components/learn/LearnPage.tsx @@ -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 `.mp4` is a 9:16 clip with a matching `.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(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 ( +
+
+
+

{title}

+

{description}

+
+ ); +} + export default function LearnPage() { const { t, i18n } = useTranslation(); const [tab, setTab] = useState('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() {
- {tab === 'data-sources' ? ( - <> -
-
-

- {t('learnPage.dataSources')} -

-

- {t('learnPage.dataSourcesIntro', { count: DATA_SOURCE_DEFS.length })} -

-
- {DATA_SOURCE_DEFS.map((source) => { - const keys = DS_KEYS[source.id]; - const [nameKey, originKey, useKey] = keys; - return ( -
{ - 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' - }`} - > -
-

- {tDynamic(nameKey)} -

- - {source.license} - -
-

- {t('learnPage.source')} {tDynamic(originKey)} -

-

- {tDynamic(useKey)} -

-
- ); - })} -
-
-
- -
-
-

- {t('learnPage.attribution')} -

-
    -
  • {t('learnPage.attrLandRegistry')}
  • -
  • - {t('learnPage.attrOgl')} {t('learnPage.attrOglLink')}. -
  • -
  • {t('learnPage.attrOs')}
  • -
  • {t('learnPage.attrTfl')}
  • -
  • - {t('learnPage.attrOsm')} {t('learnPage.attrOsmContrib')},{' '} - {t('learnPage.attrOsmLicense')} {t('learnPage.attrOsmLicenseLink')}. -
  • -
-
-
- - ) : tab === 'faq' ? ( -
-

- {t('learnPage.faq')} -

-

{t('learnPage.faqIntro')}

-
- {FAQ_SECTIONS.map((section) => ( -
-

- {section.title} -

-
- {section.items.map((item, index) => ( - - ))} -
-
- ))} -
-
- ) : tab === 'articles' ? ( -
-

- {t('learnPage.articles')} -

-

{t('learnPage.articlesIntro')}

-
- {seoPageLinks.map((link) => ( - -
- {link.eyebrow} -
-

- {link.title} -

-

- {link.description} +

+ {tab === 'data-sources' ? ( + <> +
+
+

+ {t('learnPage.dataSources')} +

+

+ {t('learnPage.dataSourcesIntro', { count: DATA_SOURCE_DEFS.length })}

-
- ))} +
+ {DATA_SOURCE_DEFS.map((source) => { + const keys = DS_KEYS[source.id]; + const [nameKey, originKey, useKey] = keys; + return ( +
{ + 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' + }`} + > +
+

+ {tDynamic(nameKey)} +

+ + {source.license} + +
+

+ {t('learnPage.source')} {tDynamic(originKey)} +

+

+ {tDynamic(useKey)} +

+
+ ); + })} +
+
+
+ +
+
+

+ {t('learnPage.attribution')} +

+
    +
  • {t('learnPage.attrLandRegistry')}
  • +
  • + {t('learnPage.attrOgl')} {t('learnPage.attrOglLink')}. +
  • +
  • {t('learnPage.attrOs')}
  • +
  • {t('learnPage.attrTfl')}
  • +
  • + {t('learnPage.attrOsm')} {t('learnPage.attrOsmContrib')},{' '} + {t('learnPage.attrOsmLicense')} {t('learnPage.attrOsmLicenseLink')}. +
  • +
+
+
+ + ) : tab === 'faq' ? ( +
+

+ {t('learnPage.faq')} +

+

{t('learnPage.faqIntro')}

+
+ {FAQ_SECTIONS.map((section) => ( +
+

+ {section.title} +

+
+ {section.items.map((item, index) => ( + + ))} +
+
+ ))} +
-
- ) : ( -
-

- {t('learnPage.support')} -

-

{t('learnPage.supportIntro')}

-
-

{t('accountPage.needHelp')}

- - support@perfect-postcode.co.uk - -

- {t('accountPage.responseTime')} + ) : tab === 'articles' ? ( +

+

+ {t('learnPage.articles')} +

+

+ {t('learnPage.articlesIntro')}

+
-
- )} + ) : tab === 'videos' ? ( +
+

+ {t('learnPage.videosTitle')} +

+

{t('learnPage.videosIntro')}

+
+ {SOCIAL_VIDEOS.map((video) => ( + + ))} +
+
+ ) : ( +
+

+ {t('learnPage.support')} +

+

{t('learnPage.supportIntro')}

+
+

{t('accountPage.needHelp')}

+ + support@perfect-postcode.co.uk + +

+ {t('accountPage.responseTime')} +

+
+
+ )} +
+
); diff --git a/frontend/src/components/map/Map.tsx b/frontend/src/components/map/Map.tsx index 0b06134..4d0f4a7 100644 --- a/frontend/src/components/map/Map.tsx +++ b/frontend/src/components/map/Map.tsx @@ -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" diff --git a/frontend/src/components/map/MapPage.tsx b/frontend/src/components/map/MapPage.tsx index 3d7df16..9a1c9ae 100644 --- a/frontend/src/components/map/MapPage.tsx +++ b/frontend/src/components/map/MapPage.tsx @@ -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({
) : null; - const upgradeModal = mapData.licenseRequired ? ( - - - onCheckoutLoginClick ? onCheckoutLoginClick(checkoutReturnPath) : onLoginClick() - } - onRegisterClick={() => - onCheckoutRegisterClick ? onCheckoutRegisterClick(checkoutReturnPath) : onRegisterClick() - } - onStartCheckout={() => license.startCheckout(checkoutReturnPath)} - onZoomToFreeZone={handleZoomToFreeZone} - isShareReturn={!!shareReturnViewRef.current} - /> - - ) : null; + const upgradeModal = + mapData.licenseRequired && !upgradeModalDismissed ? ( + + + onCheckoutLoginClick ? onCheckoutLoginClick(checkoutReturnPath) : onLoginClick() + } + onRegisterClick={() => + onCheckoutRegisterClick + ? onCheckoutRegisterClick(checkoutReturnPath) + : onRegisterClick() + } + onStartCheckout={() => license.startCheckout(checkoutReturnPath)} + onZoomToFreeZone={handleZoomToFreeZone} + isShareReturn={!!shareReturnViewRef.current} + /> + + ) : 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()} diff --git a/frontend/src/components/map/PriceHistoryChart.tsx b/frontend/src/components/map/PriceHistoryChart.tsx index b87b327..846de2a 100644 --- a/frontend/src/components/map/PriceHistoryChart.tsx +++ b/frontend/src/components/map/PriceHistoryChart.tsx @@ -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)} ))} diff --git a/frontend/src/components/map/TravelTimeCard.tsx b/frontend/src/components/map/TravelTimeCard.tsx index 646a2ce..e4ce40c 100644 --- a/frontend/src/components/map/TravelTimeCard.tsx +++ b/frontend/src/components/map/TravelTimeCard.tsx @@ -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()} /> -
- - {formatFilterValue(displayRange[0])} {t('common.minute')} - - - {formatFilterValue(displayRange[1])} {t('common.minute')} - -
+ = sliderMax} + raw + showUnit + feature={travelFeature} + onValueChange={onTimeRangeChange} + /> {filterImpact != null && filterImpact > 0 && (

{t('filters.filtersOut', { value: formatNumber(filterImpact) })} diff --git a/frontend/src/components/map/filters/SchoolFilterCard.tsx b/frontend/src/components/map/filters/SchoolFilterCard.tsx index e8de853..672916a 100644 --- a/frontend/src/components/map/filters/SchoolFilterCard.tsx +++ b/frontend/src/components/map/filters/SchoolFilterCard.tsx @@ -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({

-
-
- {t('filters.distance')} -
-
- - -
-
void; }) { const { t } = useTranslation(); + const isDark = useIsDarkTheme(); const license = useLicense(); const [pricing, setPricing] = useState(null); const [loading, setLoading] = useState(true); @@ -137,17 +139,23 @@ export default function PricingPage({ ) : null; return ( -
+
-
- -
+
+ +
-

{t('pricingPage.title')}

-

{t('pricingPage.subtitle')}

-

{t('pricingPage.lessThanSurvey')}

+

+ {t('pricingPage.title')} +

+

+ {t('pricingPage.subtitle')} +

+

+ {t('pricingPage.lessThanSurvey')} +

@@ -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({ })}

)} - {isFilled && ( -

- {t('pricingPage.filled')} -

- )}
{/* Progress bar for current tier */} @@ -308,10 +311,14 @@ export default function PricingPage({ {license.error}

)} - {isFree && ( + {isFree ? (

{t('pricingPage.noCreditCard')}

+ ) : ( +

+ {t('pricingPage.moneyBack')} +

)} ) : isFilled ? ( diff --git a/frontend/src/components/ui/AuthModal.tsx b/frontend/src/components/ui/AuthModal.tsx index b4ce606..408f9db 100644 --- a/frontend/src/components/ui/AuthModal.tsx +++ b/frontend/src/components/ui/AuthModal.tsx @@ -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 (
{ if (e.target === e.currentTarget) onClose(); }} @@ -283,6 +283,32 @@ export default function AuthModal({ {t('auth.backToLogin')} )} + + {view === 'register' && ( +

+ + ), + privacy: ( + + ), + }} + /> +

+ )}
diff --git a/frontend/src/components/ui/Header.tsx b/frontend/src/components/ui/Header.tsx index 5534dde..51f00d2 100644 --- a/frontend/src/components/ui/Header.tsx +++ b/frontend/src/components/ui/Header.tsx @@ -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 = { 'data-sources': '/data-sources', methodology: '/methodology', 'privacy-security': '/privacy-security', + terms: '/terms', + privacy: '/privacy', saved: '/saved', account: '/account', invite: '/invite', diff --git a/frontend/src/components/ui/PlaceSearchInput.tsx b/frontend/src/components/ui/PlaceSearchInput.tsx index eed23ed..9fa5081 100644 --- a/frontend/src/components/ui/PlaceSearchInput.tsx +++ b/frontend/src/components/ui/PlaceSearchInput.tsx @@ -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 && (
e.preventDefault()} style={ portal && dropdownPos ? { @@ -116,8 +125,11 @@ export function PlaceSearchInput({ ) : result.type === 'address' ? ( <> - - {result.address} + + {titleCaseAddress(result.address)} {result.postcode} @@ -126,7 +138,10 @@ export function PlaceSearchInput({ ) : ( <> - + {result.name} {result.city && ( ({result.city}) @@ -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} diff --git a/frontend/src/hooks/useDeckLayers.ts b/frontend/src/hooks/useDeckLayers.ts index 3e9cf32..d5c4c09 100644 --- a/frontend/src/hooks/useDeckLayers.ts +++ b/frontend/src/hooks/useDeckLayers.ts @@ -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); diff --git a/frontend/src/hooks/useFilters.ts b/frontend/src/hooks/useFilters.ts index b455905..a2fa31c 100644 --- a/frontend/src/hooks/useFilters.ts +++ b/frontend/src/hooks/useFilters.ts @@ -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) diff --git a/frontend/src/i18n/locales/de.ts b/frontend/src/i18n/locales/de.ts index 22c9f09..936ca4a 100644 --- a/frontend/src/i18n/locales/de.ts +++ b/frontend/src/i18n/locales/de.ts @@ -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', diff --git a/frontend/src/i18n/locales/en.ts b/frontend/src/i18n/locales/en.ts index 74fba04..75ba1a6 100644 --- a/frontend/src/i18n/locales/en.ts +++ b/frontend/src/i18n/locales/en.ts @@ -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', diff --git a/frontend/src/i18n/locales/fr.ts b/frontend/src/i18n/locales/fr.ts index 4433bbb..2087fd9 100644 --- a/frontend/src/i18n/locales/fr.ts +++ b/frontend/src/i18n/locales/fr.ts @@ -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', diff --git a/frontend/src/i18n/locales/hi.ts b/frontend/src/i18n/locales/hi.ts index 8d56359..acd9539 100644 --- a/frontend/src/i18n/locales/hi.ts +++ b/frontend/src/i18n/locales/hi.ts @@ -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: 'श्रेय', diff --git a/frontend/src/i18n/locales/hu.ts b/frontend/src/i18n/locales/hu.ts index 063fb09..b0ef4ab 100644 --- a/frontend/src/i18n/locales/hu.ts +++ b/frontend/src/i18n/locales/hu.ts @@ -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', diff --git a/frontend/src/i18n/locales/zh.ts b/frontend/src/i18n/locales/zh.ts index fbb7d41..827ef80 100644 --- a/frontend/src/i18n/locales/zh.ts +++ b/frontend/src/i18n/locales/zh.ts @@ -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: '数据引用声明', diff --git a/frontend/src/lib/api.test.ts b/frontend/src/lib/api.test.ts index 9894a89..75019f2 100644 --- a/frontend/src/lib/api.test.ts +++ b/frontend/src/lib/api.test.ts @@ -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', () => { diff --git a/frontend/src/lib/feature-icons.tsx b/frontend/src/lib/feature-icons.tsx index 0491fbe..215296e 100644 --- a/frontend/src/lib/feature-icons.tsx +++ b/frontend/src/lib/feature-icons.tsx @@ -146,53 +146,27 @@ const FEATURE_ICON_PATHS: Record = { ), - 'Good+ primary schools within 5km': ( + 'Good+ primary school catchments': ( <> ), - 'Good+ secondary schools within 5km': ( + 'Good+ secondary school catchments': ( <> ), - 'Outstanding primary schools within 5km': ( + 'Outstanding primary school catchments': ( <> ), - 'Outstanding secondary schools within 5km': ( - <> - - - - ), - 'Good+ primary schools within 2km': ( - <> - - - - - ), - 'Good+ secondary schools within 2km': ( - <> - - - - ), - 'Outstanding primary schools within 2km': ( - <> - - - - - ), - 'Outstanding secondary schools within 2km': ( + 'Outstanding secondary school catchments': ( <> diff --git a/frontend/src/lib/poi-distance-filter.ts b/frontend/src/lib/poi-distance-filter.ts index edadee1..187b11a 100644 --- a/frontend/src/lib/poi-distance-filter.ts +++ b/frontend/src/lib/poi-distance-filter.ts @@ -24,6 +24,7 @@ const TRANSPORT_POI_CATEGORIES = new Set([ 'Ferry', 'Rail station', 'Taxi rank', + 'Tram & Metro stop', 'Tube station', ]); diff --git a/frontend/src/lib/school-filter.ts b/frontend/src/lib/school-filter.ts index aa3a23b..e1f73b5 100644 --- a/frontend/src/lib/school-filter.ts +++ b/frontend/src/lib/school-filter.ts @@ -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, }; diff --git a/frontend/src/lib/url-state.test.ts b/frontend/src/lib/url-state.test.ts index 7d193ad..bfd3c2f 100644 --- a/frontend/src/lib/url-state.test.ts +++ b/frontend/src/lib/url-state.test.ts @@ -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], }); }); diff --git a/frontend/src/lib/url-state.ts b/frontend/src/lib/url-state.ts index 89f233e..d61cf22 100644 --- a/frontend/src/lib/url-state.ts +++ b/frontend/src/lib/url-state.ts @@ -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; } diff --git a/video/src/storyboard.ts b/video/src/storyboard.ts index 8d436a3..384eff8 100644 --- a/video/src/storyboard.ts +++ b/video/src/storyboard.ts @@ -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 = 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 = 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 = '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 = '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, },