seems fine

This commit is contained in:
Andras Schmelczer 2026-05-05 22:29:28 +01:00
parent 48983e3b4b
commit 7a1696541f
37 changed files with 4999 additions and 1242 deletions

View file

@ -5,11 +5,6 @@
<changefreq>weekly</changefreq>
<priority>1.0</priority>
</url>
<url>
<loc>https://perfect-postcode.co.uk/dashboard</loc>
<changefreq>daily</changefreq>
<priority>0.9</priority>
</url>
<url>
<loc>https://perfect-postcode.co.uk/learn</loc>
<changefreq>monthly</changefreq>
@ -20,4 +15,59 @@
<changefreq>monthly</changefreq>
<priority>0.8</priority>
</url>
<url>
<loc>https://perfect-postcode.co.uk/property-price-map</loc>
<changefreq>monthly</changefreq>
<priority>0.85</priority>
</url>
<url>
<loc>https://perfect-postcode.co.uk/postcode-property-search</loc>
<changefreq>monthly</changefreq>
<priority>0.85</priority>
</url>
<url>
<loc>https://perfect-postcode.co.uk/commute-property-search</loc>
<changefreq>monthly</changefreq>
<priority>0.85</priority>
</url>
<url>
<loc>https://perfect-postcode.co.uk/school-property-search</loc>
<changefreq>monthly</changefreq>
<priority>0.8</priority>
</url>
<url>
<loc>https://perfect-postcode.co.uk/postcode-checker</loc>
<changefreq>monthly</changefreq>
<priority>0.8</priority>
</url>
<url>
<loc>https://perfect-postcode.co.uk/property-search/birmingham</loc>
<changefreq>monthly</changefreq>
<priority>0.75</priority>
</url>
<url>
<loc>https://perfect-postcode.co.uk/property-search/manchester</loc>
<changefreq>monthly</changefreq>
<priority>0.75</priority>
</url>
<url>
<loc>https://perfect-postcode.co.uk/property-search/bristol</loc>
<changefreq>monthly</changefreq>
<priority>0.75</priority>
</url>
<url>
<loc>https://perfect-postcode.co.uk/data-sources</loc>
<changefreq>monthly</changefreq>
<priority>0.7</priority>
</url>
<url>
<loc>https://perfect-postcode.co.uk/methodology</loc>
<changefreq>monthly</changefreq>
<priority>0.7</priority>
</url>
<url>
<loc>https://perfect-postcode.co.uk/privacy-security</loc>
<changefreq>monthly</changefreq>
<priority>0.6</priority>
</url>
</urlset>

View file

@ -1,10 +1,141 @@
import { createServer } from 'http';
import { readFileSync, writeFileSync, existsSync, statSync } from 'fs';
import { join, extname } from 'path';
import { readFileSync, writeFileSync, existsSync, statSync, mkdirSync } from 'fs';
import { join, extname, dirname } from 'path';
import { launch } from 'puppeteer';
const DIST_DIR = join(import.meta.dirname, '..', 'dist');
const INDEX_PATH = join(DIST_DIR, 'index.html');
const PUBLIC_URL = 'https://perfect-postcode.co.uk';
const OG_PLACEHOLDER = '<meta name="x-og-placeholder" content="__PERFECT_POSTCODE_OG_TAGS__"/>';
const ROUTES = [
{
path: '/',
output: 'index.html',
title: 'Perfect Postcode - Find where to buy before browsing listings',
description:
'Search every postcode by budget, commute, schools, safety, noise, broadband, prices and more. Build a better home-buying shortlist before viewings.',
},
{
path: '/learn',
output: 'learn/index.html',
title: 'How Perfect Postcode works - Data sources, FAQ and support',
description:
'Learn how Perfect Postcode combines property prices, EPC records, travel times, crime, schools, broadband, noise, amenities and open data for postcode research.',
},
{
path: '/pricing',
output: 'pricing/index.html',
title: 'Perfect Postcode pricing - Lifetime property search map access',
description:
'Get lifetime access to the postcode property search map for England, including filters, saved searches, exports, and future data updates.',
},
{
path: '/property-price-map',
output: 'property-price-map/index.html',
title: 'Property price map for England - Compare postcodes before viewing',
description:
'Compare sold prices, estimated current value, price per square metre and local context across English postcodes before searching listings.',
},
{
path: '/postcode-property-search',
output: 'postcode-property-search/index.html',
title: 'Postcode property search - Find areas that match your criteria',
description:
'Search every postcode by budget, property type, floor area, tenure, commute, schools, crime, broadband, noise, parks and local amenities.',
},
{
path: '/commute-property-search',
output: 'commute-property-search/index.html',
title: 'Commute property search - Find places to live by travel time',
description:
'Filter postcodes by commute time, then compare price, schools, safety, broadband, road noise, parks and property data on one map.',
},
{
path: '/school-property-search',
output: 'school-property-search/index.html',
title: 'School property search - Compare postcodes for family moves',
description:
'Compare nearby schools, property size, prices, parks, safety, commute and local amenities before building a viewing shortlist.',
},
{
path: '/postcode-checker',
output: 'postcode-checker/index.html',
title: 'Postcode checker - Property, crime, broadband, noise and schools',
description:
'Check postcode-level property prices, EPC data, crime, broadband, road noise, schools, council tax, amenities and travel-time context.',
},
{
path: '/property-search/birmingham',
output: 'property-search/birmingham/index.html',
title: 'Birmingham property search - Compare postcodes by price and commute',
description:
'Use postcode-level data to compare Birmingham property prices, commute trade-offs, schools, crime, broadband and local amenities before viewings.',
},
{
path: '/property-search/manchester',
output: 'property-search/manchester/index.html',
title: 'Manchester property search - Compare postcodes before viewing',
description:
'Compare Manchester-area postcodes by budget, commute, property type, schools, broadband, crime, noise and amenities before booking viewings.',
},
{
path: '/property-search/bristol',
output: 'property-search/bristol/index.html',
title: 'Bristol property search - Compare postcodes by commute and price',
description:
'Compare Bristol postcodes by price, commute, property size, schools, broadband, crime, road noise, parks and amenities before viewings.',
},
{
path: '/data-sources',
output: 'data-sources/index.html',
title: 'Perfect Postcode data sources - Property, schools, commute and local context',
description:
'Review the public and official datasets used by Perfect Postcode, including property prices, EPC, schools, crime, broadband, noise and travel-time context.',
},
{
path: '/methodology',
output: 'methodology/index.html',
title: 'Perfect Postcode methodology - How to interpret postcode property data',
description:
'Understand how to use postcode filters, property estimates, travel-time data, school context and local signals as a home-buying shortlist tool.',
},
{
path: '/privacy-security',
output: 'privacy-security/index.html',
title: 'Perfect Postcode privacy and security - Saved searches and account data',
description:
'Learn how Perfect Postcode treats saved searches, account data and property research workflows with privacy and security in mind.',
},
];
const FAQ_SCHEMA_ITEMS = [
{
question: 'Where should I look once the obvious areas are too expensive?',
answer:
'Set your budget, property type, floor area, commute, schools, crime, noise, broadband, parks, and other must-haves. The map removes postcodes that fail those tests, so overlooked areas can surface before you start searching listings.',
},
{
question: 'What should I do when my search returns too many or too few areas?',
answer:
'Start with hard limits, then colour the map by a trade-off such as price per sqm, road noise, school score, or commute time. If the map gets too narrow, relax one slider and you can see exactly which compromise opens up more options.',
},
{
question: 'How are the travel times calculated?',
answer:
'Travel times are precomputed with Conveyal R5, a routing engine used for transport analysis. For each supported destination we route to reachable postcodes over the street and transit network, then store sparse postcode travel-time files for car, cycling, walking, and public transport.',
},
{
question: 'How does the estimated current price algorithm work?',
answer:
'The estimate starts with the last HM Land Registry sale price, adjusts it to current-market terms using repeat-sales modelling and fallback models, then blends that result with a nearest-neighbour estimate from nearby, recently sold, same-type homes.',
},
{
question: 'Should I use this before or after checking Rightmove?',
answer:
'Use Perfect Postcode before and alongside listing portals. Rightmove, Zoopla, and OnTheMarket are still where you check live availability, photos, agent contact, viewings, and alerts.',
},
];
const MIME_TYPES = {
'.html': 'text/html',
@ -13,9 +144,151 @@ const MIME_TYPES = {
'.json': 'application/json',
'.png': 'image/png',
'.jpg': 'image/jpeg',
'.mp4': 'video/mp4',
'.webm': 'video/webm',
'.svg': 'image/svg+xml',
'.ico': 'image/x-icon',
};
function escapeAttr(value) {
return value
.replaceAll('&', '&amp;')
.replaceAll('"', '&quot;')
.replaceAll('<', '&lt;')
.replaceAll('>', '&gt;');
}
function routeUrl(pathname) {
return `${PUBLIC_URL}${pathname === '/' ? '/' : pathname}`;
}
function jsonLd(data) {
return `<script type="application/ld+json">${JSON.stringify(data).replaceAll(
'<',
'\\u003c'
)}</script>`;
}
function structuredDataForRoute(route) {
const url = routeUrl(route.path);
const base = [
{
'@context': 'https://schema.org',
'@type': 'WebPage',
'@id': `${url}#webpage`,
url,
name: route.title,
description: route.description,
isPartOf: { '@id': `${PUBLIC_URL}/#website` },
},
{
'@context': 'https://schema.org',
'@type': 'BreadcrumbList',
itemListElement: [
{
'@type': 'ListItem',
position: 1,
name: 'Perfect Postcode',
item: `${PUBLIC_URL}/`,
},
...(route.path === '/'
? []
: [
{
'@type': 'ListItem',
position: 2,
name: route.title.split(' - ')[0],
item: url,
},
]),
],
},
];
if (route.path === '/') {
base.push(
{
'@context': 'https://schema.org',
'@type': 'Organization',
'@id': `${PUBLIC_URL}/#organization`,
name: 'Perfect Postcode',
url: `${PUBLIC_URL}/`,
logo: `${PUBLIC_URL}/favicon.svg`,
},
{
'@context': 'https://schema.org',
'@type': 'WebSite',
'@id': `${PUBLIC_URL}/#website`,
name: 'Perfect Postcode',
url: `${PUBLIC_URL}/`,
publisher: { '@id': `${PUBLIC_URL}/#organization` },
},
{
'@context': 'https://schema.org',
'@type': 'SoftwareApplication',
name: 'Perfect Postcode',
applicationCategory: 'BusinessApplication',
operatingSystem: 'Web',
url: `${PUBLIC_URL}/`,
description: route.description,
}
);
}
if (route.path === '/learn') {
base.push({
'@context': 'https://schema.org',
'@type': 'FAQPage',
mainEntity: FAQ_SCHEMA_ITEMS.map((item) => ({
'@type': 'Question',
name: item.question,
acceptedAnswer: {
'@type': 'Answer',
text: item.answer,
},
})),
});
}
if (route.path === '/pricing') {
base.push({
'@context': 'https://schema.org',
'@type': 'Product',
name: 'Perfect Postcode lifetime access',
description: route.description,
brand: { '@type': 'Brand', name: 'Perfect Postcode' },
});
}
return base;
}
function updateHead(indexHtml, route) {
const structuredData = structuredDataForRoute(route).map(jsonLd).join('\n ');
return indexHtml
.replace(/<title>.*?<\/title>/, `<title>${escapeAttr(route.title)}</title>`)
.replace(
/<meta name="description" content="[^"]*" ?\/?>/,
`<meta name="description" content="${escapeAttr(route.description)}" />`
)
.replace(OG_PLACEHOLDER, `${OG_PLACEHOLDER}\n ${structuredData}`);
}
function cleanBaseIndexHtml(indexHtml) {
const withoutStructuredData = indexHtml.replace(
/<script type="application\/ld\+json">.*?<\/script>\s*/gs,
''
);
const rootStart = withoutStructuredData.indexOf('<div id="root">');
const bodyEnd = withoutStructuredData.indexOf('</body>', rootStart);
if (rootStart === -1 || bodyEnd === -1) {
return withoutStructuredData;
}
return `${withoutStructuredData.slice(0, rootStart)}<div id="root"></div>${withoutStructuredData.slice(bodyEnd)}`;
}
function startServer() {
return new Promise((resolve) => {
const server = createServer((req, res) => {
@ -53,81 +326,101 @@ async function prerender() {
});
try {
const page = await browser.newPage();
const baseIndexHtml = cleanBaseIndexHtml(readFileSync(INDEX_PATH, 'utf-8'));
// Intercept API requests to prevent real fetches and retry loops
await page.setRequestInterception(true);
page.on('request', (req) => {
const url = req.url();
if (url.includes('/api/features')) {
req.respond({
status: 200,
contentType: 'application/json',
body: JSON.stringify({ groups: [] }),
for (const route of ROUTES) {
const page = await browser.newPage();
// Intercept API requests to prevent real fetches and retry loops.
await page.setRequestInterception(true);
page.on('request', (req) => {
const url = req.url();
if (url.includes('/api/features')) {
req.respond({
status: 200,
contentType: 'application/json',
body: JSON.stringify({ groups: [] }),
});
} else if (url.includes('/api/poi-categories')) {
req.respond({
status: 200,
contentType: 'application/json',
body: JSON.stringify({ groups: [] }),
});
} else if (url.includes('/api/pricing')) {
req.respond({
status: 200,
contentType: 'application/json',
body: JSON.stringify({
licensed_count: 120,
current_price_pence: 999,
tiers: [
{ up_to: 50, price_pence: 99, slots: 50 },
{ up_to: 150, price_pence: 999, slots: 100 },
{ up_to: 250, price_pence: 2999, slots: 100 },
{ up_to: 350, price_pence: 4999, slots: 100 },
{ up_to: null, price_pence: 9999, slots: 0 },
],
}),
});
} else if (url.includes('/api/')) {
req.respond({
status: 200,
contentType: 'application/json',
body: '{}',
});
} else {
req.continue();
}
});
await page.goto(`http://127.0.0.1:${port}${route.path}`, {
waitUntil: 'networkidle0',
timeout: 30000,
});
await page.waitForSelector('h1', { timeout: 10000 });
// Extract and clean the rendered HTML.
const html = await page.evaluate(() => {
const root = document.getElementById('root');
if (!root) return '';
// Strip fade-in-visible classes (added by IntersectionObserver effects).
root.querySelectorAll('.fade-in-visible').forEach((el) => {
el.classList.remove('fade-in-visible');
});
} else if (url.includes('/api/poi-categories')) {
req.respond({
status: 200,
contentType: 'application/json',
body: JSON.stringify({ groups: [] }),
// Clean canvas elements (dimensions set by ResizeObserver effect).
root.querySelectorAll('canvas').forEach((canvas) => {
canvas.removeAttribute('width');
canvas.removeAttribute('height');
canvas.style.removeProperty('width');
canvas.style.removeProperty('height');
});
} else if (url.includes('/api/')) {
req.respond({
status: 200,
contentType: 'application/json',
body: '{}',
});
} else {
req.continue();
return root.innerHTML;
});
if (!html || html.length < 100) {
throw new Error(`Prerender produced too little HTML for ${route.path}`);
}
});
await page.goto(`http://127.0.0.1:${port}/`, {
waitUntil: 'networkidle0',
timeout: 30000,
});
const updated = updateHead(baseIndexHtml, route).replace(
'<div id="root"></div>',
`<div id="root">${html}</div>`
);
// Wait for the home page heading to render
await page.waitForSelector('h1', { timeout: 10000 });
if (updated === baseIndexHtml) {
throw new Error('Could not find <div id="root"></div> in index.html');
}
// Extract and clean the rendered HTML
const html = await page.evaluate(() => {
const root = document.getElementById('root');
if (!root) return '';
// Strip fade-in-visible classes (added by IntersectionObserver effects)
root.querySelectorAll('.fade-in-visible').forEach((el) => {
el.classList.remove('fade-in-visible');
});
// Clean canvas elements (dimensions set by ResizeObserver effect)
root.querySelectorAll('canvas').forEach((canvas) => {
canvas.removeAttribute('width');
canvas.removeAttribute('height');
canvas.style.removeProperty('width');
canvas.style.removeProperty('height');
});
return root.innerHTML;
});
if (!html || html.length < 100) {
throw new Error('Prerender produced too little HTML — something went wrong');
const outputPath = join(DIST_DIR, route.output);
mkdirSync(dirname(outputPath), { recursive: true });
writeFileSync(outputPath, updated);
await page.close();
console.log(`Prerendered ${route.path} (${html.length} chars) into ${route.output}`);
}
// Inject into dist/index.html
const indexHtml = readFileSync(INDEX_PATH, 'utf-8');
const updated = indexHtml.replace(
'<div id="root"></div>',
`<div id="root">${html}</div>`
);
if (updated === indexHtml) {
throw new Error('Could not find <div id="root"></div> in index.html');
}
writeFileSync(INDEX_PATH, updated);
console.log(`Prerendered ${html.length} chars into dist/index.html`);
} finally {
await browser.close();
server.close();

View file

@ -1,14 +1,16 @@
import { useState, useEffect, useCallback, useMemo, useRef } from 'react';
import MapPage, { type ExportState } from './components/map/MapPage';
import PricingPage from './components/pricing/PricingPage';
import HomePage from './components/home/HomePage';
import LearnPage from './components/learn/LearnPage';
import AccountPage, { SavedPage, InvitesPage } from './components/account/AccountPage';
import InvitePage from './components/invite/InvitePage';
import { lazy, Suspense, useState, useEffect, useCallback, useMemo, useRef } from 'react';
import type { ExportState } from './components/map/MapPage';
import {
getSeoContentPage,
getSeoLandingPage,
isSeoContentKey,
isSeoLandingKey,
SEO_CONTENT_PATHS,
SEO_LANDING_PATHS,
type SeoContentKey,
type SeoLandingKey,
} from './lib/seoRoutes';
import Header, { type Page } from './components/ui/Header';
import AuthModal from './components/ui/AuthModal';
import SaveSearchModal from './components/ui/SaveSearchModal';
import LicenseSuccessModal from './components/ui/LicenseSuccessModal';
import type { FeatureMeta, FeatureGroup, POICategoriesResponse, POICategoryGroup } from './types';
import { fetchWithRetry, apiUrl } from './lib/api';
import { trackEvent } from './lib/analytics';
@ -28,6 +30,28 @@ declare global {
}
}
const HomePage = lazy(() => import('./components/home/HomePage'));
const PricingPage = lazy(() => import('./components/pricing/PricingPage'));
const LearnPage = lazy(() => import('./components/learn/LearnPage'));
const SeoLandingPage = lazy(() => import('./components/landing/SeoLandingPage'));
const SeoContentPage = lazy(() => import('./components/landing/SeoContentPage'));
const AccountPage = lazy(() => import('./components/account/AccountPage'));
const SavedPage = lazy(() =>
import('./components/account/AccountPage').then((module) => ({ default: module.SavedPage }))
);
const InvitesPage = lazy(() =>
import('./components/account/AccountPage').then((module) => ({ default: module.InvitesPage }))
);
const InvitePage = lazy(() => import('./components/invite/InvitePage'));
const MapPage = lazy(() => import('./components/map/MapPage'));
const AuthModal = lazy(() => import('./components/ui/AuthModal'));
const SaveSearchModal = lazy(() => import('./components/ui/SaveSearchModal'));
const LicenseSuccessModal = lazy(() => import('./components/ui/LicenseSuccessModal'));
function PageFallback() {
return <div className="flex-1 bg-warm-50 dark:bg-navy-950" />;
}
function pageToPath(page: Page, inviteCode?: string): string {
switch (page) {
case 'dashboard':
@ -36,6 +60,19 @@ function pageToPath(page: Page, inviteCode?: string): string {
return '/learn';
case 'pricing':
return '/pricing';
case 'property-price-map':
case 'postcode-property-search':
case 'commute-property-search':
case 'school-property-search':
case 'postcode-checker':
return SEO_LANDING_PATHS[page];
case 'birmingham-property-search':
case 'manchester-property-search':
case 'bristol-property-search':
case 'data-sources':
case 'methodology':
case 'privacy-security':
return SEO_CONTENT_PATHS[page];
case 'saved':
return '/saved';
case 'invites':
@ -55,6 +92,10 @@ function pathToPage(pathname: string): { page: Page; inviteCode?: string } | nul
if (pathname === '/invites') return { page: 'invites' };
if (pathname === '/learn') return { page: 'learn' };
if (pathname === '/pricing') return { page: 'pricing' };
const seoLandingPage = getSeoLandingPage(pathname);
if (seoLandingPage) return { page: seoLandingPage };
const seoContentPage = getSeoContentPage(pathname);
if (seoContentPage) return { page: seoContentPage };
if (pathname === '/account') return { page: 'account' };
if (pathname === '/support') return { page: 'learn' };
if (pathname.startsWith('/invite/')) {
@ -65,6 +106,14 @@ function pathToPage(pathname: string): { page: Page; inviteCode?: string } | nul
return null;
}
function isSeoLandingPage(page: Page): page is SeoLandingKey {
return isSeoLandingKey(page);
}
function isSeoContentPage(page: Page): page is SeoContentKey {
return isSeoContentKey(page);
}
export default function App() {
const urlState = useMemo(() => parseUrlState(), []);
const [mapUrlState, setMapUrlState] = useState(urlState);
@ -271,37 +320,41 @@ export default function App() {
if ((isScreenshotMode || isOgMode) && inviteCode) {
return (
<InvitePage
code={inviteCode}
user={null}
theme={theme}
screenshotMode
onLoginClick={() => {}}
onRegisterClick={() => {}}
onLicenseGranted={() => {}}
/>
<Suspense fallback={<PageFallback />}>
<InvitePage
code={inviteCode}
user={null}
theme={theme}
screenshotMode
onLoginClick={() => {}}
onRegisterClick={() => {}}
onLicenseGranted={() => {}}
/>
</Suspense>
);
}
if (isScreenshotMode) {
return (
<MapPage
features={features}
poiCategoryGroups={poiCategoryGroups}
initialFilters={urlState.filters || {}}
initialViewState={initialViewState}
initialPOICategories={urlState.poiCategories || new Set()}
initialTab={urlState.tab || 'area'}
initialLoading={initialLoading}
theme={theme}
pendingInfoFeature={null}
onClearPendingInfoFeature={() => {}}
onNavigateTo={() => {}}
screenshotMode
ogMode={isOgMode}
initialTravelTime={urlState.travelTime}
shareCode={urlState.share}
/>
<Suspense fallback={<PageFallback />}>
<MapPage
features={features}
poiCategoryGroups={poiCategoryGroups}
initialFilters={urlState.filters || {}}
initialViewState={initialViewState}
initialPOICategories={urlState.poiCategories || new Set()}
initialTab={urlState.tab || 'area'}
initialLoading={initialLoading}
theme={theme}
pendingInfoFeature={null}
onClearPendingInfoFeature={() => {}}
onNavigateTo={() => {}}
screenshotMode
ogMode={isOgMode}
initialTravelTime={urlState.travelTime}
shareCode={urlState.share}
/>
</Suspense>
);
}
@ -328,137 +381,145 @@ export default function App() {
onLogout={logout}
isMobile={isMobile}
/>
{activePage === 'home' ? (
<HomePage
onOpenDashboard={() => navigateTo('dashboard')}
onOpenPricing={() => navigateTo('pricing')}
theme={theme}
hidePricing={user?.subscription === 'licensed' || user?.isAdmin}
/>
) : activePage === 'pricing' && !(user?.subscription === 'licensed' || user?.isAdmin) ? (
<PricingPage
onOpenDashboard={() => navigateTo('dashboard')}
user={user}
onLoginClick={() => {
setAuthModalTab('login');
setShowAuthModal(true);
}}
onRegisterClick={() => {
setAuthModalTab('register');
setShowAuthModal(true);
}}
/>
) : activePage === 'learn' ? (
<LearnPage />
) : activePage === 'saved' && user ? (
<SavedPage
searches={savedSearches.searches}
searchesLoading={savedSearches.loading}
onDeleteSearch={savedSearches.deleteSearch}
onUpdateSearchNotes={savedSearches.updateSearchNotes}
onUpdateSearchName={savedSearches.updateSearchName}
onOpenSearch={(params) => {
window.location.href = `/dashboard?${params}`;
}}
savedProperties={savedProperties.properties}
propertiesLoading={savedProperties.loading}
onDeleteProperty={savedProperties.deleteProperty}
onUpdatePropertyNotes={savedProperties.updatePropertyNotes}
onOpenProperty={(postcode) => {
window.location.href = `/dashboard?pc=${encodeURIComponent(postcode)}`;
}}
/>
) : activePage === 'invites' && user ? (
<InvitesPage user={user} />
) : activePage === 'account' && user ? (
<AccountPage user={user} onRefreshAuth={refreshAuth} />
) : activePage === 'invite' && inviteCode ? (
<InvitePage
code={inviteCode}
user={user}
theme={theme}
onLoginClick={() => {
setAuthModalTab('login');
setShowAuthModal(true);
}}
onRegisterClick={() => {
setAuthModalTab('register');
setShowAuthModal(true);
}}
onLicenseGranted={() => {
setShowLicenseSuccess(true);
refreshAuth();
}}
/>
) : (
<MapPage
features={features}
poiCategoryGroups={poiCategoryGroups}
initialFilters={mapUrlState.filters || {}}
initialViewState={initialViewState}
initialPOICategories={mapUrlState.poiCategories || new Set()}
initialTab={mapUrlState.tab || 'area'}
initialLoading={initialLoading}
theme={theme}
pendingInfoFeature={pendingInfoFeature}
onClearPendingInfoFeature={() => setPendingInfoFeature(null)}
onNavigateTo={navigateTo}
onExportStateChange={setExportState}
isMobile={isMobile}
initialTravelTime={mapUrlState.travelTime}
initialPostcode={mapUrlState.postcode}
shareCode={mapUrlState.share}
user={user}
onLoginClick={() => {
setAuthModalTab('login');
setShowAuthModal(true);
}}
onRegisterClick={() => {
setAuthModalTab('register');
setShowAuthModal(true);
}}
onSaveProperty={user ? savedProperties.saveProperty : undefined}
onUnsaveProperty={user ? savedProperties.deleteProperty : undefined}
isPropertySaved={user ? savedProperties.isPropertySaved : undefined}
getSavedPropertyId={user ? savedProperties.getSavedPropertyId : undefined}
deferTutorial={showLicenseSuccess}
onSaveSearch={user ? savedSearches.saveSearch : undefined}
savingSearch={savedSearches.saving}
/>
)}
{showAuthModal && (
<AuthModal
onClose={() => setShowAuthModal(false)}
onLogin={login}
onRegister={register}
onOAuthLogin={loginWithOAuth}
onForgotPassword={requestPasswordReset}
loading={authLoading}
error={authError}
onClearError={clearError}
initialTab={authModalTab}
/>
)}
{showSaveModal && (
<SaveSearchModal
onClose={() => setShowSaveModal(false)}
onSave={savedSearches.saveSearch}
onViewSearches={() => {
setShowSaveModal(false);
navigateTo('saved');
}}
saving={savedSearches.saving}
error={savedSearches.error}
/>
)}
{showLicenseSuccess && (
<LicenseSuccessModal
onClose={() => {
setShowLicenseSuccess(false);
navigateTo('dashboard');
}}
/>
)}
<Suspense fallback={<PageFallback />}>
{activePage === 'home' ? (
<HomePage
onOpenDashboard={() => navigateTo('dashboard')}
onOpenPricing={() => navigateTo('pricing')}
theme={theme}
hidePricing={user?.subscription === 'licensed' || user?.isAdmin}
/>
) : activePage === 'pricing' && !(user?.subscription === 'licensed' || user?.isAdmin) ? (
<PricingPage
onOpenDashboard={() => navigateTo('dashboard')}
user={user}
onLoginClick={() => {
setAuthModalTab('login');
setShowAuthModal(true);
}}
onRegisterClick={() => {
setAuthModalTab('register');
setShowAuthModal(true);
}}
/>
) : activePage === 'learn' ? (
<LearnPage />
) : isSeoLandingPage(activePage) ? (
<SeoLandingPage pageKey={activePage} onOpenDashboard={() => navigateTo('dashboard')} />
) : isSeoContentPage(activePage) ? (
<SeoContentPage pageKey={activePage} onOpenDashboard={() => navigateTo('dashboard')} />
) : activePage === 'saved' && user ? (
<SavedPage
searches={savedSearches.searches}
searchesLoading={savedSearches.loading}
onDeleteSearch={savedSearches.deleteSearch}
onUpdateSearchNotes={savedSearches.updateSearchNotes}
onUpdateSearchName={savedSearches.updateSearchName}
onOpenSearch={(params) => {
window.location.href = `/dashboard?${params}`;
}}
savedProperties={savedProperties.properties}
propertiesLoading={savedProperties.loading}
onDeleteProperty={savedProperties.deleteProperty}
onUpdatePropertyNotes={savedProperties.updatePropertyNotes}
onOpenProperty={(postcode) => {
window.location.href = `/dashboard?pc=${encodeURIComponent(postcode)}`;
}}
/>
) : activePage === 'invites' && user ? (
<InvitesPage user={user} />
) : activePage === 'account' && user ? (
<AccountPage user={user} onRefreshAuth={refreshAuth} />
) : activePage === 'invite' && inviteCode ? (
<InvitePage
code={inviteCode}
user={user}
theme={theme}
onLoginClick={() => {
setAuthModalTab('login');
setShowAuthModal(true);
}}
onRegisterClick={() => {
setAuthModalTab('register');
setShowAuthModal(true);
}}
onLicenseGranted={() => {
setShowLicenseSuccess(true);
refreshAuth();
}}
/>
) : (
<MapPage
features={features}
poiCategoryGroups={poiCategoryGroups}
initialFilters={mapUrlState.filters || {}}
initialViewState={initialViewState}
initialPOICategories={mapUrlState.poiCategories || new Set()}
initialTab={mapUrlState.tab || 'area'}
initialLoading={initialLoading}
theme={theme}
pendingInfoFeature={pendingInfoFeature}
onClearPendingInfoFeature={() => setPendingInfoFeature(null)}
onNavigateTo={navigateTo}
onExportStateChange={setExportState}
isMobile={isMobile}
initialTravelTime={mapUrlState.travelTime}
initialPostcode={mapUrlState.postcode}
shareCode={mapUrlState.share}
user={user}
onLoginClick={() => {
setAuthModalTab('login');
setShowAuthModal(true);
}}
onRegisterClick={() => {
setAuthModalTab('register');
setShowAuthModal(true);
}}
onSaveProperty={user ? savedProperties.saveProperty : undefined}
onUnsaveProperty={user ? savedProperties.deleteProperty : undefined}
isPropertySaved={user ? savedProperties.isPropertySaved : undefined}
getSavedPropertyId={user ? savedProperties.getSavedPropertyId : undefined}
deferTutorial={showLicenseSuccess}
onSaveSearch={user ? savedSearches.saveSearch : undefined}
savingSearch={savedSearches.saving}
/>
)}
</Suspense>
<Suspense fallback={null}>
{showAuthModal && (
<AuthModal
onClose={() => setShowAuthModal(false)}
onLogin={login}
onRegister={register}
onOAuthLogin={loginWithOAuth}
onForgotPassword={requestPasswordReset}
loading={authLoading}
error={authError}
onClearError={clearError}
initialTab={authModalTab}
/>
)}
{showSaveModal && (
<SaveSearchModal
onClose={() => setShowSaveModal(false)}
onSave={savedSearches.saveSearch}
onViewSearches={() => {
setShowSaveModal(false);
navigateTo('saved');
}}
saving={savedSearches.saving}
error={savedSearches.error}
/>
)}
{showLicenseSuccess && (
<LicenseSuccessModal
onClose={() => {
setShowLicenseSuccess(false);
navigateTo('dashboard');
}}
/>
)}
</Suspense>
</div>
);
}

View file

@ -6,6 +6,7 @@ interface HexConfig {
size: number;
opacity: number;
top: number;
left: number;
driftDuration: number;
bobDuration: number;
bobAmount: number;
@ -21,6 +22,7 @@ function generateHexes(): HexConfig[] {
size: 10 + Math.random() * 32,
opacity: 0.06 + Math.random() * 0.18,
top: Math.random() * 100,
left: Math.random() * 100,
driftDuration,
bobDuration: 6 + Math.random() * 8,
bobAmount: 8 + Math.random() * 30,
@ -31,7 +33,13 @@ function generateHexes(): HexConfig[] {
return hexes;
}
export default function HexCanvas({ isDark = false }: { isDark?: boolean }) {
export default function HexCanvas({
isDark = false,
animated = true,
}: {
isDark?: boolean;
animated?: boolean;
}) {
const hexes = useMemo(generateHexes, []);
return (
@ -42,7 +50,14 @@ export default function HexCanvas({ isDark = false }: { isDark?: boolean }) {
className="absolute"
style={{
top: `${hex.top}%`,
animation: `hex-drift ${hex.driftDuration}s linear ${hex.delay}s infinite${hex.reverse ? ' reverse' : ''}`,
...(animated
? {
animation: `hex-drift ${hex.driftDuration}s linear ${hex.delay}s infinite${hex.reverse ? ' reverse' : ''}`,
}
: {
left: `${hex.left}%`,
transform: `translate(-50%, -50%) rotate(${hex.delay * 12}deg)`,
}),
}}
>
<div
@ -51,9 +66,9 @@ export default function HexCanvas({ isDark = false }: { isDark?: boolean }) {
{
width: hex.size,
height: (hex.size * 2) / Math.sqrt(3),
opacity: hex.opacity * (isDark ? 0.6 : 1),
opacity: hex.opacity * (isDark ? 0.45 : 0.6),
clipPath: 'polygon(50% 0%, 100% 25%, 100% 75%, 50% 100%, 0% 75%, 0% 25%)',
animation: `hex-bob ${hex.bobDuration}s ease-in-out infinite`,
animation: animated ? `hex-bob ${hex.bobDuration}s ease-in-out infinite` : undefined,
'--bob': `${hex.bobAmount}px`,
} as React.CSSProperties
}

File diff suppressed because it is too large Load diff

View file

@ -203,7 +203,7 @@ export default function InvitePage({
{`\u00A3${(Math.round(pricePence * 0.7) / 100).toFixed(2)}`}
</span>
<span className="text-warm-500 dark:text-warm-400 ml-2 text-3xl">
{t('upgrade.once')}
{t('pricingPage.lifetime')}
</span>
</div>
)}
@ -301,7 +301,9 @@ export default function InvitePage({
<span className="text-3xl font-extrabold text-teal-600 dark:text-teal-400">
{`\u00A3${(Math.round(pricePence * 0.7) / 100).toFixed(2)}`}
</span>
<span className="text-warm-500 dark:text-warm-400 ml-1">{t('upgrade.once')}</span>
<span className="text-warm-500 dark:text-warm-400 ml-1">
{t('pricingPage.lifetime')}
</span>
</div>
)}

View file

@ -260,6 +260,9 @@ export default function LearnPage() {
<>
<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>
@ -369,6 +372,9 @@ export default function LearnPage() {
</>
) : 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) => (
@ -387,6 +393,9 @@ export default function LearnPage() {
</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>

View file

@ -33,7 +33,6 @@ interface FeatureBrowserProps {
onClearOpenInfoFeature?: () => void;
travelTimeEntries: TravelTimeEntry[];
onAddTravelTimeEntry: (mode: TransportMode) => void;
isLicensed: boolean;
}
export default function FeatureBrowser({
@ -47,7 +46,6 @@ export default function FeatureBrowser({
onClearOpenInfoFeature,
travelTimeEntries: _travelTimeEntries,
onAddTravelTimeEntry,
isLicensed,
}: FeatureBrowserProps) {
const { t } = useTranslation();
const modes = useTranslatedModes();
@ -107,6 +105,11 @@ export default function FeatureBrowser({
onChange={setSearch}
placeholder={t('filters.searchFeatures')}
/>
{!search && (
<p className="mt-2 px-1 text-xs leading-relaxed text-warm-500 dark:text-warm-400">
{t('filters.chooseFilters')}
</p>
)}
</div>
<div>
{mergedGrouped.map((group) => {
@ -200,10 +203,6 @@ export default function FeatureBrowser({
description={search ? t('filters.tryDifferentSearch') : t('filters.removeFilterHint')}
className="px-3 py-4"
/>
) : isLicensed ? (
<p className="flex-1 flex items-center justify-center px-4 py-6 text-xs text-center text-warm-400 dark:text-warm-500">
{t('filters.chooseFilters')}
</p>
) : null}
</div>
{infoFeature && (

View file

@ -1082,7 +1082,6 @@ export default memo(function Filters({
onClearOpenInfoFeature={onClearOpenInfoFeature}
travelTimeEntries={travelTimeEntries}
onAddTravelTimeEntry={handleAddTravelTimeAndScroll}
isLicensed={isLicensed}
/>
{!isLicensed && (
<div className="shrink-0 flex flex-col items-center px-5 pt-4 pb-0 border-t border-warm-200 dark:border-warm-700">

View file

@ -11,6 +11,10 @@ interface JourneyInstructionsProps {
entries: TravelTimeEntry[];
/** When set, shown as a subtitle (e.g. the central postcode for a hexagon) */
label?: string;
/** Preloaded journey rows, useful for static demos that should not call the API. */
presetJourneys?: JourneyInstructionPreset[];
className?: string;
showGoogleMapsLink?: boolean;
}
interface JourneyData {
@ -24,6 +28,16 @@ interface JourneyData {
loading: boolean;
}
export interface JourneyInstructionPreset {
slug: string;
label: string;
legs: JourneyLeg[] | null;
/** Median (50th percentile) total travel time, including waiting. */
minutes: number | null;
/** Best-case (5th percentile) total travel time. */
bestMinutes?: number | null;
}
// Official TfL line colors + other known London transit
const ROUTE_COLORS: Record<string, { color: string; darkText?: boolean }> = {
Bakerloo: { color: '#B36305' },
@ -164,14 +178,23 @@ export default function JourneyInstructions({
postcode,
entries,
label,
presetJourneys,
className,
showGoogleMapsLink = true,
}: JourneyInstructionsProps) {
const { t } = useTranslation();
const [journeys, setJourneys] = useState<JourneyData[]>([]);
// Only transit entries with a destination set
const transitEntries = entries.filter((e) => e.mode === 'transit' && e.slug !== '');
const hasPresetJourneys = Boolean(presetJourneys?.length);
useEffect(() => {
if (hasPresetJourneys) {
setJourneys([]);
return;
}
if (transitEntries.length === 0) {
setJourneys([]);
return;
@ -227,18 +250,29 @@ export default function JourneyInstructions({
});
return () => controller.abort();
}, [postcode, transitEntries.map((e) => e.slug).join(',')]); // eslint-disable-line react-hooks/exhaustive-deps
}, [postcode, hasPresetJourneys, transitEntries.map((e) => e.slug).join(',')]); // eslint-disable-line react-hooks/exhaustive-deps
if (transitEntries.length === 0) return null;
if (transitEntries.length === 0 && !hasPresetJourneys) return null;
const displayedJourneys: JourneyData[] = hasPresetJourneys
? (presetJourneys ?? []).map((journey) => ({
slug: journey.slug,
label: journey.label,
legs: journey.legs,
minutes: journey.minutes,
bestMinutes: journey.bestMinutes ?? null,
loading: false,
}))
: journeys;
return (
<div className="mx-3 mt-2 space-y-2">
<div className={className ?? 'mx-3 mt-2 space-y-2'}>
{label && (
<div className="text-xs text-warm-500 dark:text-warm-400">
{t('areaPane.journeysFrom', { label })}
</div>
)}
{journeys.map((j) => {
{displayedJourneys.map((j) => {
const displayLegs = j.legs ? invertLegs(j.legs) : null;
const legSum = j.legs ? j.legs.reduce((sum, l) => sum + l.minutes, 0) : 0;
const totalMin = j.minutes ?? legSum;
@ -267,27 +301,29 @@ export default function JourneyInstructions({
{displayLegs.map((leg, i) => (
<TimelineLeg key={i} leg={leg} isLast={i === displayLegs.length - 1} />
))}
<a
href={googleMapsUrl(postcode, j.label || j.slug)}
target="_blank"
rel="noopener noreferrer"
className="mt-2 flex items-center justify-center gap-1.5 w-full text-[11px] font-medium text-teal-600 dark:text-teal-400 hover:text-teal-800 dark:hover:text-teal-300 bg-white dark:bg-warm-900 border border-warm-200 dark:border-warm-700 rounded-md py-1.5 transition-colors"
>
{t('areaPane.viewOnGoogleMaps')}
<svg
className="w-3 h-3"
viewBox="0 0 12 12"
fill="none"
stroke="currentColor"
strokeWidth="1.5"
{showGoogleMapsLink && (
<a
href={googleMapsUrl(postcode, j.label || j.slug)}
target="_blank"
rel="noopener noreferrer"
className="mt-2 flex items-center justify-center gap-1.5 w-full text-[11px] font-medium text-teal-600 dark:text-teal-400 hover:text-teal-800 dark:hover:text-teal-300 bg-white dark:bg-warm-900 border border-warm-200 dark:border-warm-700 rounded-md py-1.5 transition-colors"
>
<path
d="M4.5 1.5H2a.5.5 0 00-.5.5v8a.5.5 0 00.5.5h8a.5.5 0 00.5-.5V7.5M7.5 1.5H10.5V4.5M10.5 1.5L5.5 6.5"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
</a>
{t('areaPane.viewOnGoogleMaps')}
<svg
className="w-3 h-3"
viewBox="0 0 12 12"
fill="none"
stroke="currentColor"
strokeWidth="1.5"
>
<path
d="M4.5 1.5H2a.5.5 0 00-.5.5v8a.5.5 0 00.5.5h8a.5.5 0 00.5-.5V7.5M7.5 1.5H10.5V4.5M10.5 1.5L5.5 6.5"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
</a>
)}
</div>
) : j.minutes != null ? (
<div>
@ -297,27 +333,29 @@ export default function JourneyInstructions({
{t('areaPane.walk')} · {j.minutes} {t('common.min')}
</span>
</div>
<a
href={googleMapsUrl(postcode, j.label || j.slug)}
target="_blank"
rel="noopener noreferrer"
className="mt-2 flex items-center justify-center gap-1.5 w-full text-[11px] font-medium text-teal-600 dark:text-teal-400 hover:text-teal-800 dark:hover:text-teal-300 bg-white dark:bg-warm-900 border border-warm-200 dark:border-warm-700 rounded-md py-1.5 transition-colors"
>
{t('areaPane.viewOnGoogleMaps')}
<svg
className="w-3 h-3"
viewBox="0 0 12 12"
fill="none"
stroke="currentColor"
strokeWidth="1.5"
{showGoogleMapsLink && (
<a
href={googleMapsUrl(postcode, j.label || j.slug)}
target="_blank"
rel="noopener noreferrer"
className="mt-2 flex items-center justify-center gap-1.5 w-full text-[11px] font-medium text-teal-600 dark:text-teal-400 hover:text-teal-800 dark:hover:text-teal-300 bg-white dark:bg-warm-900 border border-warm-200 dark:border-warm-700 rounded-md py-1.5 transition-colors"
>
<path
d="M4.5 1.5H2a.5.5 0 00-.5.5v8a.5.5 0 00.5.5h8a.5.5 0 00.5-.5V7.5M7.5 1.5H10.5V4.5M10.5 1.5L5.5 6.5"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
</a>
{t('areaPane.viewOnGoogleMaps')}
<svg
className="w-3 h-3"
viewBox="0 0 12 12"
fill="none"
stroke="currentColor"
strokeWidth="1.5"
>
<path
d="M4.5 1.5H2a.5.5 0 00-.5.5v8a.5.5 0 00.5.5h8a.5.5 0 00.5-.5V7.5M7.5 1.5H10.5V4.5M10.5 1.5L5.5 6.5"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
</a>
)}
</div>
) : (
<span className="text-xs text-warm-500 dark:text-warm-400">

View file

@ -13,6 +13,10 @@ export interface SearchedLocation {
geometry: PostcodeGeometry;
latitude: number;
longitude: number;
markerLatitude?: number;
markerLongitude?: number;
openProperties?: boolean;
focusAddress?: string;
}
const ZOOM_FOR_TYPE: Record<string, number> = {
@ -81,6 +85,46 @@ export default function LocationSearch({
return;
}
if (result.type === 'address') {
setError(null);
setLoading(true);
search.close();
try {
const res = await fetch(
`/api/postcode/${encodeURIComponent(result.postcode)}`,
authHeaders()
);
if (!res.ok) {
setError(t('locationSearch.postcodeNotFound'));
return;
}
const json: {
postcode: string;
latitude: number;
longitude: number;
geometry: PostcodeGeometry;
} = await res.json();
onFlyTo(result.lat, result.lon, 17);
onLocationSearched?.({
postcode: json.postcode,
geometry: json.geometry,
latitude: result.lat,
longitude: result.lon,
markerLatitude: result.lat,
markerLongitude: result.lon,
openProperties: true,
focusAddress: result.address,
});
search.clear();
if (isMobile) setExpanded(false);
} catch {
setError(t('locationSearch.lookupFailed'));
} finally {
setLoading(false);
}
return;
}
// Postcode — fetch geometry
setError(null);
setLoading(true);

View file

@ -48,6 +48,8 @@ interface MapProps {
filterRange: [number, number] | null;
viewSource: 'drag' | 'eye' | null;
onCancelPin: () => void;
onResetPreviewScale?: () => void;
canResetPreviewScale?: boolean;
features: FeatureMeta[];
selectedHexagonId: string | null;
hoveredHexagonId: string | null;
@ -77,6 +79,49 @@ interface Dimensions {
height: number;
}
interface DeckWithPrivateDraw {
_drawLayers?: (
redrawReason: string,
renderOptions?: { viewports?: unknown[]; [key: string]: unknown }
) => unknown;
__propertyMapNullViewportPatch?: boolean;
}
function patchNullViewportDraw(overlay: MapboxOverlay) {
const deck = (overlay as unknown as { _deck?: DeckWithPrivateDraw })._deck;
if (!deck || deck.__propertyMapNullViewportPatch || typeof deck._drawLayers !== 'function') {
return;
}
const drawLayers = deck._drawLayers.bind(deck);
deck._drawLayers = (redrawReason, renderOptions) => {
const viewports = renderOptions?.viewports;
if (viewports) {
// Split-route startup can hand deck.gl a transient null viewport before MapLibre has sized the map.
const nonNullViewports = viewports.filter(Boolean);
if (nonNullViewports.length === 0) return;
if (nonNullViewports.length !== viewports.length) {
return drawLayers(redrawReason, { ...renderOptions, viewports: nonNullViewports });
}
}
return drawLayers(redrawReason, renderOptions);
};
deck.__propertyMapNullViewportPatch = true;
}
class SafeMapboxOverlay extends MapboxOverlay {
onAdd(map: unknown) {
const element = super.onAdd(map);
patchNullViewportDraw(this);
return element;
}
setProps(props: Parameters<MapboxOverlay['setProps']>[0]) {
super.setProps(props);
patchNullViewportDraw(this);
}
}
function DeckOverlay({
layers,
getTooltip,
@ -86,10 +131,13 @@ function DeckOverlay({
// eslint-disable-next-line @typescript-eslint/no-explicit-any
getTooltip: any;
}) {
const overlay = useControl(() => new MapboxOverlay({ interleaved: true }));
const overlay = useControl(() => new SafeMapboxOverlay({ interleaved: true }));
useEffect(() => {
overlay.setProps({ layers: layers.filter(Boolean), getTooltip });
overlay.setProps({
layers: layers.filter(Boolean),
getTooltip,
});
}, [overlay, layers, getTooltip]);
return null;
@ -106,6 +154,8 @@ export default memo(function Map({
filterRange,
viewSource,
onCancelPin,
onResetPreviewScale,
canResetPreviewScale = false,
features,
selectedHexagonId,
hoveredHexagonId,
@ -311,7 +361,7 @@ export default memo(function Map({
) : null
) : (
<>
<div className="absolute top-3 left-3 right-3 z-10 flex flex-wrap items-start justify-between gap-2 pointer-events-none">
<div className="absolute top-3 left-3 right-3 z-[60] flex flex-wrap items-start justify-between gap-2 pointer-events-none">
<LocationSearch
onFlyTo={handleFlyTo}
onLocationSearched={onLocationSearched}
@ -330,6 +380,8 @@ export default memo(function Map({
range={colorRange}
showCancel={viewSource === 'eye'}
onCancel={onCancelPin}
onResetScale={viewSource === 'eye' ? onResetPreviewScale : undefined}
resetScaleDisabled={!canResetPreviewScale}
mode="feature"
theme={theme}
suffix=" min"
@ -344,6 +396,8 @@ export default memo(function Map({
range={colorRange}
showCancel={viewSource === 'eye'}
onCancel={onCancelPin}
onResetScale={viewSource === 'eye' ? onResetPreviewScale : undefined}
resetScaleDisabled={!canResetPreviewScale}
mode="feature"
enumValues={
colorFeatureMeta.type === 'enum' ? colorFeatureMeta.values : undefined

View file

@ -1,4 +1,4 @@
import { useState, useEffect, useMemo, useCallback, useRef } from 'react';
import { lazy, Suspense, useState, useEffect, useMemo, useCallback, useRef } from 'react';
import { useTranslation } from 'react-i18next';
import { cellToLatLng } from 'h3-js';
import type {
@ -11,15 +11,8 @@ import type {
} from '../../types';
import type { SearchedLocation } from './LocationSearch';
import type { Page } from '../ui/Header';
import Map from './Map';
import Filters from './Filters';
import POIPane from './POIPane';
import { PropertiesPane } from './PropertiesPane';
import AreaPane from './AreaPane';
import MobileDrawer from './MobileDrawer';
import MobileBottomSheet from './MobileBottomSheet';
import MapLegend from './MapLegend';
import { MapPageSelectionPane } from './MapPageSelectionPane';
import { useMapData } from '../../hooks/useMapData';
import { usePOIData } from '../../hooks/usePOIData';
import { useFilters } from '../../hooks/useFilters';
@ -30,7 +23,6 @@ import { useAiFilters } from '../../hooks/useAiFilters';
import { useUrlSync } from '../../hooks/useUrlSync';
import { useTutorial } from '../../hooks/useTutorial';
import { getTutorialStyles } from '../../lib/tutorial-styles';
import Joyride from 'react-joyride';
import {
useTravelTime,
useTranslatedModes,
@ -44,11 +36,40 @@ import { trackEvent } from '../../lib/analytics';
import { INITIAL_VIEW_STATE } from '../../lib/consts';
import { getSchoolBackendFeatureName } from '../../lib/school-filter';
import { useLicense } from '../../hooks/useLicense';
import UpgradeModal from '../ui/UpgradeModal';
import { SpinnerIcon } from '../ui/icons/SpinnerIcon';
import { MapPinIcon } from '../ui/icons/MapPinIcon';
import { BookmarkIcon } from '../ui/icons/BookmarkIcon';
const Map = lazy(() => import('./Map'));
const Filters = lazy(() => import('./Filters'));
const POIPane = lazy(() => import('./POIPane'));
const AreaPane = lazy(() => import('./AreaPane'));
const PropertiesPane = lazy(() =>
import('./PropertiesPane').then((module) => ({ default: module.PropertiesPane }))
);
const MobileDrawer = lazy(() => import('./MobileDrawer'));
const MapPageSelectionPane = lazy(() =>
import('./MapPageSelectionPane').then((module) => ({ default: module.MapPageSelectionPane }))
);
const UpgradeModal = lazy(() => import('../ui/UpgradeModal'));
const Joyride = lazy(() => import('react-joyride'));
function MapFallback() {
return (
<div className="flex h-full w-full items-center justify-center bg-warm-100 dark:bg-navy-950">
<SpinnerIcon className="h-8 w-8 animate-spin text-teal-600 dark:text-teal-400" />
</div>
);
}
function PaneFallback() {
return (
<div className="flex h-full w-full items-center justify-center bg-white dark:bg-navy-950">
<SpinnerIcon className="h-6 w-6 animate-spin text-teal-600 dark:text-teal-400" />
</div>
);
}
export interface ExportState {
onExport: () => void;
exporting: boolean;
@ -193,6 +214,7 @@ export default function MapPage({
features,
viewFeature,
activeFeature,
pinnedFeature,
travelTimeEntries: entries,
shareCode,
});
@ -335,8 +357,19 @@ export default function MapPage({
const handleLocationSearchResult = useCallback(
(result: SearchedLocation | null) => {
if (result) {
setCurrentLocation(null);
handleLocationSearch(result.postcode, result.geometry, result.latitude, result.longitude);
if (result.markerLatitude != null && result.markerLongitude != null) {
setCurrentLocation({ lat: result.markerLatitude, lng: result.markerLongitude });
} else {
setCurrentLocation(null);
}
handleLocationSearch(
result.postcode,
result.geometry,
result.latitude,
result.longitude,
result.openProperties,
result.focusAddress
);
if (isMobile) setMobileDrawerOpen(true);
} else {
setCurrentLocation(null);
@ -604,121 +637,134 @@ export default function MapPage({
if (screenshotMode) {
return (
<div className="h-full w-full">
<Map
data={mapData.data}
postcodeData={mapData.postcodeData}
usePostcodeView={mapData.usePostcodeView}
pois={[]}
onViewChange={mapData.handleViewChange}
viewFeature={mapViewFeature}
colorRange={mapData.colorRange}
filterRange={filterRange}
viewSource={viewSource}
onCancelPin={() => {}}
features={features}
selectedHexagonId={null}
hoveredHexagonId={null}
onHexagonClick={() => {}}
onHexagonHover={() => {}}
initialViewState={initialViewState}
theme={theme}
screenshotMode
ogMode={ogMode}
bounds={mapData.bounds}
travelTimeEntries={entries}
/>
<Suspense fallback={<MapFallback />}>
<Map
data={mapData.data}
postcodeData={mapData.postcodeData}
usePostcodeView={mapData.usePostcodeView}
pois={[]}
onViewChange={mapData.handleViewChange}
viewFeature={mapViewFeature}
colorRange={mapData.colorRange}
filterRange={filterRange}
viewSource={viewSource}
onCancelPin={() => {}}
onResetPreviewScale={mapData.handleResetPreviewScale}
canResetPreviewScale={mapData.canResetPreviewScale}
features={features}
selectedHexagonId={null}
hoveredHexagonId={null}
onHexagonClick={() => {}}
onHexagonHover={() => {}}
initialViewState={initialViewState}
theme={theme}
screenshotMode
ogMode={ogMode}
bounds={mapData.bounds}
travelTimeEntries={entries}
/>
</Suspense>
</div>
);
}
const renderAreaPane = () => (
<AreaPane
stats={areaStats}
globalFeatures={features}
loading={loadingAreaStats}
hexagonId={selectedHexagon?.id || null}
isPostcode={selectedHexagon?.type === 'postcode'}
postcodeData={
selectedHexagon?.type === 'postcode'
? mapData.postcodeData.find((f) => f.properties.postcode === selectedHexagon?.id) || null
: null
}
onViewProperties={handleViewPropertiesFromArea}
onClearFilters={hasActiveFilters ? handleClearAll : undefined}
hexagonLocation={hexagonLocation}
filters={filters}
unfilteredCount={unfilteredAreaCount}
travelTimeEntries={activeEntries}
isGroupExpanded={isAreaGroupExpanded}
onToggleGroup={toggleAreaGroup}
/>
<Suspense fallback={<PaneFallback />}>
<AreaPane
stats={areaStats}
globalFeatures={features}
loading={loadingAreaStats}
hexagonId={selectedHexagon?.id || null}
isPostcode={selectedHexagon?.type === 'postcode'}
postcodeData={
selectedHexagon?.type === 'postcode'
? mapData.postcodeData.find((f) => f.properties.postcode === selectedHexagon?.id) ||
null
: null
}
onViewProperties={handleViewPropertiesFromArea}
onClearFilters={hasActiveFilters ? handleClearAll : undefined}
hexagonLocation={hexagonLocation}
filters={filters}
unfilteredCount={unfilteredAreaCount}
travelTimeEntries={activeEntries}
isGroupExpanded={isAreaGroupExpanded}
onToggleGroup={toggleAreaGroup}
/>
</Suspense>
);
const renderPropertiesPane = () => (
<PropertiesPane
properties={properties}
total={propertiesTotal}
loading={loadingProperties}
hexagonId={selectedHexagon?.id || null}
onLoadMore={handleLoadMoreProperties}
onSaveProperty={onSaveProperty ? handleSavePropertyWithToast : undefined}
onUnsaveProperty={onUnsaveProperty}
isPropertySaved={isPropertySaved}
getSavedPropertyId={getSavedPropertyId}
/>
<Suspense fallback={<PaneFallback />}>
<PropertiesPane
properties={properties}
total={propertiesTotal}
loading={loadingProperties}
hexagonId={selectedHexagon?.id || null}
onLoadMore={handleLoadMoreProperties}
onSaveProperty={onSaveProperty ? handleSavePropertyWithToast : undefined}
onUnsaveProperty={onUnsaveProperty}
isPropertySaved={isPropertySaved}
getSavedPropertyId={getSavedPropertyId}
/>
</Suspense>
);
const renderPOIPane = () => (
<POIPane
groups={poiCategoryGroups}
selectedCategories={selectedPOICategories}
onCategoriesChange={setSelectedPOICategories}
poiCount={pois.length}
onClose={() => setPoiPaneOpen(false)}
/>
<Suspense fallback={<PaneFallback />}>
<POIPane
groups={poiCategoryGroups}
selectedCategories={selectedPOICategories}
onCategoriesChange={setSelectedPOICategories}
poiCount={pois.length}
onClose={() => setPoiPaneOpen(false)}
/>
</Suspense>
);
const renderFilters = (options?: { destinationDropdownPortal?: boolean }) => (
<Filters
features={features}
filters={filters}
activeFeature={activeFeature}
dragValue={dragValue}
enabledFeatures={enabledFeatures}
onAddFilter={handleAddFilter}
onRemoveFilter={handleRemoveFilter}
onFilterChange={handleFilterChange}
onDragStart={handleDragStart}
onDragChange={handleDragChange}
onDragEnd={handleDragEnd}
pinnedFeature={pinnedFeature}
onTogglePin={handleTogglePin}
openInfoFeature={pendingInfoFeature}
onClearOpenInfoFeature={onClearPendingInfoFeature}
travelTimeEntries={entries}
onTravelTimeAddEntry={handleAddEntry}
onTravelTimeRemoveEntry={handleTravelTimeRemoveEntry}
onTravelTimeSetDestination={handleTravelTimeSetDestination}
onTravelTimeRangeChange={handleTimeRangeChange}
onTravelTimeDragEnd={handleTravelTimeDragEnd}
onTravelTimeToggleBest={handleToggleBest}
aiFilterLoading={aiFilterLoading}
aiFilterError={aiFilterError}
aiFilterErrorType={aiFilterErrorType}
aiFilterNotes={aiFilterNotes}
aiFilterSummary={aiFilterSummary}
onAiFilterSubmit={handleAiFilterSubmit}
isLoggedIn={!!user}
onLoginRequired={onRegisterClick ?? (() => {})}
isLicensed={user?.subscription === 'licensed'}
onUpgradeClick={() => onNavigateTo('pricing')}
onResetTutorial={tutorial.resetTutorial}
filterImpacts={filterCounts.impacts}
onClearAll={handleClearAll}
onSaveSearch={onSaveSearch}
savingSearch={savingSearch}
destinationDropdownPortal={options?.destinationDropdownPortal}
/>
<Suspense fallback={<PaneFallback />}>
<Filters
features={features}
filters={filters}
activeFeature={activeFeature}
dragValue={dragValue}
enabledFeatures={enabledFeatures}
onAddFilter={handleAddFilter}
onRemoveFilter={handleRemoveFilter}
onFilterChange={handleFilterChange}
onDragStart={handleDragStart}
onDragChange={handleDragChange}
onDragEnd={handleDragEnd}
pinnedFeature={pinnedFeature}
onTogglePin={handleTogglePin}
openInfoFeature={pendingInfoFeature}
onClearOpenInfoFeature={onClearPendingInfoFeature}
travelTimeEntries={entries}
onTravelTimeAddEntry={handleAddEntry}
onTravelTimeRemoveEntry={handleTravelTimeRemoveEntry}
onTravelTimeSetDestination={handleTravelTimeSetDestination}
onTravelTimeRangeChange={handleTimeRangeChange}
onTravelTimeDragEnd={handleTravelTimeDragEnd}
onTravelTimeToggleBest={handleToggleBest}
aiFilterLoading={aiFilterLoading}
aiFilterError={aiFilterError}
aiFilterErrorType={aiFilterErrorType}
aiFilterNotes={aiFilterNotes}
aiFilterSummary={aiFilterSummary}
onAiFilterSubmit={handleAiFilterSubmit}
isLoggedIn={!!user}
onLoginRequired={onRegisterClick ?? (() => {})}
isLicensed={user?.subscription === 'licensed'}
onUpgradeClick={() => onNavigateTo('pricing')}
onResetTutorial={tutorial.resetTutorial}
filterImpacts={filterCounts.impacts}
onClearAll={handleClearAll}
onSaveSearch={onSaveSearch}
savingSearch={savingSearch}
destinationDropdownPortal={options?.destinationDropdownPortal}
/>
</Suspense>
);
const renderMobileLegend = () => {
@ -734,6 +780,8 @@ export default function MapPage({
range={mapData.colorRange}
showCancel={viewSource === 'eye'}
onCancel={handleCancelPin}
onResetScale={viewSource === 'eye' ? mapData.handleResetPreviewScale : undefined}
resetScaleDisabled={!mapData.canResetPreviewScale}
mode="feature"
theme={theme}
inline
@ -753,6 +801,8 @@ export default function MapPage({
range={mapData.colorRange}
showCancel={viewSource === 'eye'}
onCancel={handleCancelPin}
onResetScale={viewSource === 'eye' ? mapData.handleResetPreviewScale : undefined}
resetScaleDisabled={!mapData.canResetPreviewScale}
mode="feature"
enumValues={mobileLegendMeta.type === 'enum' ? mobileLegendMeta.values : undefined}
featureName={mobileLegendMeta.name}
@ -794,34 +844,38 @@ export default function MapPage({
)}
<div className="absolute inset-0">
<Map
data={mapData.data}
postcodeData={mapData.postcodeData}
usePostcodeView={mapData.usePostcodeView}
pois={pois}
onViewChange={mapData.handleViewChange}
viewFeature={mapViewFeature}
colorRange={mapData.colorRange}
filterRange={filterRange}
viewSource={viewSource}
onCancelPin={handleCancelPin}
features={features}
selectedHexagonId={selectedHexagon?.id || null}
hoveredHexagonId={hoveredHexagon}
onHexagonClick={handleMobileHexagonClick}
onHexagonHover={handleHexagonHover}
initialViewState={initialViewState}
flyToRef={mapFlyToRef}
theme={theme}
filters={filters}
selectedPostcodeGeometry={selectedPostcodeGeometry}
onLocationSearched={handleLocationSearchResult}
onCurrentLocationFound={handleCurrentLocationFound}
currentLocation={currentLocation}
bounds={mapData.bounds}
hideLegend
travelTimeEntries={entries}
/>
<Suspense fallback={<MapFallback />}>
<Map
data={mapData.data}
postcodeData={mapData.postcodeData}
usePostcodeView={mapData.usePostcodeView}
pois={pois}
onViewChange={mapData.handleViewChange}
viewFeature={mapViewFeature}
colorRange={mapData.colorRange}
filterRange={filterRange}
viewSource={viewSource}
onCancelPin={handleCancelPin}
onResetPreviewScale={mapData.handleResetPreviewScale}
canResetPreviewScale={mapData.canResetPreviewScale}
features={features}
selectedHexagonId={selectedHexagon?.id || null}
hoveredHexagonId={hoveredHexagon}
onHexagonClick={handleMobileHexagonClick}
onHexagonHover={handleHexagonHover}
initialViewState={initialViewState}
flyToRef={mapFlyToRef}
theme={theme}
filters={filters}
selectedPostcodeGeometry={selectedPostcodeGeometry}
onLocationSearched={handleLocationSearchResult}
onCurrentLocationFound={handleCurrentLocationFound}
currentLocation={currentLocation}
bounds={mapData.bounds}
hideLegend
travelTimeEntries={entries}
/>
</Suspense>
</div>
{mapData.loading && (
@ -849,40 +903,41 @@ export default function MapPage({
</div>
)}
<MobileBottomSheet
activeCount={Object.keys(filters).length + entries.length}
legend={renderMobileLegend()}
>
<MobileBottomSheet legend={renderMobileLegend()}>
{renderFilters({ destinationDropdownPortal: false })}
</MobileBottomSheet>
{mobileDrawerOpen && selectedHexagon && (
<MobileDrawer
onClose={() => setMobileDrawerOpen(false)}
renderArea={renderAreaPane}
renderProperties={renderPropertiesPane}
tab={rightPaneTab}
onTabChange={(t) => {
if (t === 'properties') {
handlePropertiesTabClick();
} else {
setRightPaneTab(t);
}
}}
/>
<Suspense fallback={<PaneFallback />}>
<MobileDrawer
onClose={() => setMobileDrawerOpen(false)}
renderArea={renderAreaPane}
renderProperties={renderPropertiesPane}
tab={rightPaneTab}
onTabChange={(t) => {
if (t === 'properties') {
handlePropertiesTabClick();
} else {
setRightPaneTab(t);
}
}}
/>
</Suspense>
)}
{bookmarkToast}
{mapData.licenseRequired && (
<UpgradeModal
isLoggedIn={!!user}
onLoginClick={onLoginClick ?? (() => {})}
onRegisterClick={onRegisterClick ?? (() => {})}
onStartCheckout={() => license.startCheckout()}
onZoomToFreeZone={handleZoomToFreeZone}
isShareReturn={!!shareReturnViewRef.current}
/>
<Suspense fallback={null}>
<UpgradeModal
isLoggedIn={!!user}
onLoginClick={onLoginClick ?? (() => {})}
onRegisterClick={onRegisterClick ?? (() => {})}
onStartCheckout={() => license.startCheckout()}
onZoomToFreeZone={handleZoomToFreeZone}
isShareReturn={!!shareReturnViewRef.current}
/>
</Suspense>
)}
</div>
);
@ -901,17 +956,21 @@ export default function MapPage({
</div>
)}
<Joyride
steps={tutorial.steps}
run={tutorial.run}
continuous
showProgress
showSkipButton
callback={tutorial.handleCallback}
styles={getTutorialStyles(theme)}
disableScrolling
locale={{ last: 'Finish' }}
/>
{tutorial.run && (
<Suspense fallback={null}>
<Joyride
steps={tutorial.steps}
run={tutorial.run}
continuous
showProgress
showSkipButton
callback={tutorial.handleCallback}
styles={getTutorialStyles(theme)}
disableScrolling
locale={{ last: 'Finish' }}
/>
</Suspense>
)}
<div
data-tutorial="filters"
@ -932,35 +991,39 @@ export default function MapPage({
</div>
<div data-tutorial="map" className="flex-1 relative">
<Map
data={mapData.data}
postcodeData={mapData.postcodeData}
usePostcodeView={mapData.usePostcodeView}
pois={pois}
onViewChange={mapData.handleViewChange}
viewFeature={mapViewFeature}
colorRange={mapData.colorRange}
filterRange={filterRange}
viewSource={viewSource}
onCancelPin={handleCancelPin}
features={features}
selectedHexagonId={selectedHexagon?.id || null}
hoveredHexagonId={hoveredHexagon}
onHexagonClick={handleHexagonClick}
onHexagonHover={handleHexagonHover}
initialViewState={initialViewState}
flyToRef={mapFlyToRef}
theme={theme}
filters={filters}
selectedPostcodeGeometry={selectedPostcodeGeometry}
onLocationSearched={handleLocationSearchResult}
onCurrentLocationFound={handleCurrentLocationFound}
currentLocation={currentLocation}
bounds={mapData.bounds}
travelTimeEntries={entries}
densityLabel={densityLabel}
totalCount={hasActiveFilters ? filterCounts.total : undefined}
/>
<Suspense fallback={<MapFallback />}>
<Map
data={mapData.data}
postcodeData={mapData.postcodeData}
usePostcodeView={mapData.usePostcodeView}
pois={pois}
onViewChange={mapData.handleViewChange}
viewFeature={mapViewFeature}
colorRange={mapData.colorRange}
filterRange={filterRange}
viewSource={viewSource}
onCancelPin={handleCancelPin}
onResetPreviewScale={mapData.handleResetPreviewScale}
canResetPreviewScale={mapData.canResetPreviewScale}
features={features}
selectedHexagonId={selectedHexagon?.id || null}
hoveredHexagonId={hoveredHexagon}
onHexagonClick={handleHexagonClick}
onHexagonHover={handleHexagonHover}
initialViewState={initialViewState}
flyToRef={mapFlyToRef}
theme={theme}
filters={filters}
selectedPostcodeGeometry={selectedPostcodeGeometry}
onLocationSearched={handleLocationSearchResult}
onCurrentLocationFound={handleCurrentLocationFound}
currentLocation={currentLocation}
bounds={mapData.bounds}
travelTimeEntries={entries}
densityLabel={densityLabel}
totalCount={hasActiveFilters ? filterCounts.total : undefined}
/>
</Suspense>
{mapData.loading && (
<div className="absolute inset-0 flex items-center justify-center pointer-events-none">
<div className="flex items-center gap-2 bg-white/90 dark:bg-warm-800/90 px-4 py-2.5 rounded-lg shadow-lg pointer-events-auto">
@ -989,29 +1052,33 @@ export default function MapPage({
</div>
{selectedHexagon && (
<MapPageSelectionPane
width={rightPaneWidth}
resizeHandlers={rightPaneHandlers}
tab={rightPaneTab}
onAreaTabClick={() => setRightPaneTab('area')}
onPropertiesTabClick={handlePropertiesTabClick}
onClose={handleCloseSelection}
renderAreaPane={renderAreaPane}
renderPropertiesPane={renderPropertiesPane}
/>
<Suspense fallback={<PaneFallback />}>
<MapPageSelectionPane
width={rightPaneWidth}
resizeHandlers={rightPaneHandlers}
tab={rightPaneTab}
onAreaTabClick={() => setRightPaneTab('area')}
onPropertiesTabClick={handlePropertiesTabClick}
onClose={handleCloseSelection}
renderAreaPane={renderAreaPane}
renderPropertiesPane={renderPropertiesPane}
/>
</Suspense>
)}
{bookmarkToast}
{mapData.licenseRequired && (
<UpgradeModal
isLoggedIn={!!user}
onLoginClick={onLoginClick ?? (() => {})}
onRegisterClick={onRegisterClick ?? (() => {})}
onStartCheckout={() => license.startCheckout()}
onZoomToFreeZone={handleZoomToFreeZone}
isShareReturn={!!shareReturnViewRef.current}
/>
<Suspense fallback={null}>
<UpgradeModal
isLoggedIn={!!user}
onLoginClick={onLoginClick ?? (() => {})}
onRegisterClick={onRegisterClick ?? (() => {})}
onStartCheckout={() => license.startCheckout()}
onZoomToFreeZone={handleZoomToFreeZone}
isShareReturn={!!shareReturnViewRef.current}
/>
</Suspense>
)}
</div>
);

View file

@ -1,6 +1,5 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import type { ReactNode } from 'react';
import { useTranslation } from 'react-i18next';
interface VisualViewportState {
height: number;
@ -8,7 +7,6 @@ interface VisualViewportState {
}
interface MobileBottomSheetProps {
activeCount: number;
children: ReactNode;
legend?: ReactNode;
}
@ -57,11 +55,9 @@ function clamp(value: number, min: number, max: number): number {
}
export default function MobileBottomSheet({
activeCount,
children,
legend,
}: MobileBottomSheetProps) {
const { t } = useTranslation();
const viewport = useVisualViewportState();
const sheetRef = useRef<HTMLDivElement>(null);
const scrollRef = useRef<HTMLDivElement>(null);
@ -133,8 +129,6 @@ export default function MobileBottomSheet({
return () => sheet.removeEventListener('focusin', handleFocusIn);
}, [heightBounds.initial, heightBounds.max, viewport.height]);
const sheetTitle = activeCount === 0 ? t('filters.chooseFilters') : t('filters.activeFilters');
return (
<section
ref={sheetRef}
@ -148,29 +142,16 @@ export default function MobileBottomSheet({
? undefined
: 'height 140ms ease, bottom 180ms ease',
}}
aria-label={sheetTitle}
>
<div
className="shrink-0 touch-none px-4 pt-2 pb-1"
className="shrink-0 touch-none px-4 py-2"
onPointerDown={handlePointerDown}
onPointerMove={handlePointerMove}
onPointerUp={handlePointerUp}
onPointerCancel={handlePointerUp}
>
<div className="w-full flex flex-col items-center gap-2" role="presentation">
<div className="w-full flex items-center justify-center" role="presentation">
<span className="h-1.5 w-12 rounded-full bg-warm-300 dark:bg-navy-600" />
<span className="w-full flex items-center justify-between">
<span className="flex items-center gap-2 min-w-0">
<span className="text-sm font-semibold text-navy-950 dark:text-warm-100 truncate">
{sheetTitle}
</span>
{activeCount > 0 && (
<span className="text-xs font-medium px-1.5 py-0.5 rounded-full bg-teal-50 dark:bg-teal-900/30 text-teal-600 dark:text-teal-400">
{activeCount}
</span>
)}
</span>
</span>
</div>
</div>

View file

@ -6,7 +6,7 @@ interface PriceHistoryChartProps {
points: PricePoint[];
}
const PADDING = { top: 8, right: 8, bottom: 20, left: 42 };
const PADDING = { top: 8, right: 24, bottom: 20, left: 42 };
const HEIGHT = 120;
const priceFmt = { prefix: '£' };

View file

@ -147,7 +147,7 @@ export default function Header({
}`;
return (
<header className="h-12 bg-navy-900 text-white flex items-center px-4 shrink-0">
<header className="relative z-50 h-12 bg-navy-900 text-white flex items-center px-4 shrink-0">
{/* Left: Logo + nav */}
<div className="flex items-center gap-4">
<a

View file

@ -156,15 +156,16 @@ export default function MobileMenu({
</button>
{/* Language selector */}
<div className="flex gap-1 px-4">
<div className="flex max-w-full gap-1 overflow-x-auto overflow-y-hidden px-4 pb-1 scrollbar-hide">
{SUPPORTED_LANGUAGES.map((lang) => (
<button
key={lang.code}
aria-label={lang.label}
onClick={() => {
localStorage.setItem('language', lang.code);
void changeAppLanguage(lang.code);
}}
className={`flex-1 flex items-center justify-center gap-1.5 px-2 py-2 rounded text-sm ${
className={`flex-none min-w-[2.75rem] flex items-center justify-center gap-1.5 px-2 py-2 rounded text-sm ${
i18n.language === lang.code
? 'bg-navy-700 text-white font-medium'
: 'text-warm-400 hover:bg-navy-800 hover:text-white'

View file

@ -5,6 +5,7 @@ import type { SearchResult } from '../../hooks/useLocationSearch';
import { useDropdownPosition } from '../../hooks/useDropdownPosition';
import { SearchIcon } from './icons/SearchIcon';
import { MapPinIcon } from './icons/MapPinIcon';
import { HouseIcon } from './icons/HouseIcon';
interface SearchHook {
query: string;
@ -66,7 +67,11 @@ export function PlaceSearchInput({
{search.results.map((result, idx) => (
<button
key={
result.type === 'postcode' ? `pc-${result.label}` : `pl-${result.name}-${result.lat}`
result.type === 'postcode'
? `pc-${result.label}`
: result.type === 'address'
? `addr-${result.postcode}-${result.address}-${result.lat}`
: `pl-${result.name}-${result.lat}`
}
type="button"
className={`w-full text-left flex items-center cursor-pointer ${
@ -87,6 +92,16 @@ export function PlaceSearchInput({
<SearchIcon className={`${iconSize} text-warm-400 dark:text-warm-500 shrink-0`} />
<span className="text-warm-700 dark:text-warm-200">{result.label}</span>
</>
) : 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="block truncate text-warm-400 dark:text-warm-500">
{result.postcode}
</span>
</span>
</>
) : (
<>
<MapPinIcon className={`${iconSize} text-warm-400 dark:text-warm-500 shrink-0`} />

View file

@ -84,7 +84,9 @@ export default function UpgradeModal({
{priceLabel}
</span>
{!isFree && (
<span className="text-warm-500 dark:text-warm-400 text-lg">{t('upgrade.once')}</span>
<span className="text-warm-500 dark:text-warm-400 text-lg">
{t('pricingPage.lifetime')}
</span>
)}
</div>
<p className="text-center text-sm text-warm-500 dark:text-warm-400 mb-6">

View file

@ -178,7 +178,7 @@ export function useHexagonSelection({
);
const fetchPostcodeProperties = useCallback(
async (postcode: string, offset = 0) => {
async (postcode: string, offset = 0, focusAddress?: string) => {
setLoadingProperties(true);
try {
const params = new URLSearchParams({
@ -186,6 +186,9 @@ export function useHexagonSelection({
limit: '100',
offset: offset.toString(),
});
if (focusAddress && offset === 0) {
params.set('focus_address', focusAddress);
}
const filterStr = buildFilterString(filters, features);
if (filterStr) params.append('filters', filterStr);
@ -464,13 +467,20 @@ export function useHexagonSelection({
]);
const handleLocationSearch = useCallback(
(postcode: string, geometry: PostcodeGeometry, lat?: number, lng?: number) => {
trackEvent('Postcode Search');
(
postcode: string,
geometry: PostcodeGeometry,
lat?: number,
lng?: number,
openProperties = false,
focusAddress?: string
) => {
trackEvent(openProperties ? 'Address Search' : 'Postcode Search');
setProperties([]);
setPropertiesTotal(0);
setPropertiesOffset(0);
setUnfilteredAreaCount(null);
setRightPaneTab('area');
setRightPaneTab(openProperties ? 'properties' : 'area');
setLoadingAreaStats(true);
// First try the postcode; if it has no properties, fall back to hexagons
@ -482,6 +492,9 @@ export function useHexagonSelection({
setSelectedPostcodeGeometry(geometry);
setAreaStats(stats);
refreshUnfilteredAreaCount(selection, stats.count);
if (openProperties) {
fetchPostcodeProperties(postcode, 0, focusAddress);
}
return;
}
@ -493,6 +506,7 @@ export function useHexagonSelection({
setSelectedPostcodeGeometry(geometry);
setAreaStats(stats);
refreshUnfilteredAreaCount(selection, stats.count);
setRightPaneTab('area');
return;
}
@ -507,6 +521,7 @@ export function useHexagonSelection({
setSelectedPostcodeGeometry(null);
setAreaStats(hexStats);
refreshUnfilteredAreaCount(selection, hexStats.count);
setRightPaneTab('area');
return;
}
}
@ -519,11 +534,18 @@ export function useHexagonSelection({
setSelectedPostcodeGeometry(null);
setAreaStats(fallbackStats);
refreshUnfilteredAreaCount(selection, fallbackStats.count);
setRightPaneTab('area');
})
.catch((error) => logNonAbortError('Failed to fetch postcode stats', error))
.finally(() => setLoadingAreaStats(false));
},
[resolution, fetchPostcodeStats, fetchHexagonStats, refreshUnfilteredAreaCount]
[
resolution,
fetchPostcodeStats,
fetchHexagonStats,
fetchPostcodeProperties,
refreshUnfilteredAreaCount,
]
);
const handleCurrentLocationSearch = useCallback(

View file

@ -1,5 +1,5 @@
import { useState, useCallback, useRef, useEffect } from 'react';
import type { PlaceResult } from '../types';
import type { AddressResult, PlaceResult } from '../types';
import { authHeaders, logNonAbortError } from '../lib/api';
/** Matches a full UK postcode with complete inward code (e.g. "E14 2DG", "SW1A1AA").
@ -19,8 +19,54 @@ function normalizePostcode(s: string): string {
return stripped;
}
function searchableTextForResult(result: SearchResult): string {
if (result.type === 'postcode') {
return result.label;
}
if (result.type === 'address') {
return `${result.address} ${result.postcode}`;
}
return `${result.name} ${result.city ?? ''}`;
}
function searchTokens(value: string): string[] {
return value.toLowerCase().match(/[a-z0-9]+/g) ?? [];
}
function compactSearchText(value: string): string {
return searchTokens(value).join('');
}
function resultMatchesQuery(result: SearchResult, query: string): boolean {
const queryTokens = searchTokens(query);
if (queryTokens.length === 0) {
return false;
}
const searchable = searchableTextForResult(result);
const resultText = searchTokens(searchable).join(' ');
const resultCompact = compactSearchText(searchable);
const queryCompact = queryTokens.join('');
return (
queryTokens.every((token) => resultText.includes(token)) ||
(queryCompact.length >= 2 && resultCompact.includes(queryCompact))
);
}
function filterResultsForQuery(results: SearchResult[], query: string): SearchResult[] {
return results.filter((result) => resultMatchesQuery(result, query)).slice(0, 20);
}
export type SearchResult =
| { type: 'postcode'; label: string }
| {
type: 'address';
address: string;
postcode: string;
lat: number;
lon: number;
}
| {
type: 'place';
name: string;
@ -38,10 +84,13 @@ export function useLocationSearch(mode?: string) {
const [open, setOpen] = useState(false);
const abortRef = useRef<AbortController | null>(null);
const debounceRef = useRef<ReturnType<typeof setTimeout>>();
const latestQueryRef = useRef('');
const lastResultsRef = useRef<SearchResult[]>([]);
const handleInputChange = useCallback(
(value: string) => {
setQuery(value);
latestQueryRef.current = value;
setActiveIndex(-1);
abortRef.current?.abort();
@ -50,12 +99,16 @@ export function useLocationSearch(mode?: string) {
const trimmed = value.trim();
if (!trimmed) {
setResults([]);
lastResultsRef.current = [];
setOpen(false);
return;
}
if (!mode && looksLikePostcode(trimmed)) {
setResults([{ type: 'postcode', label: normalizePostcode(trimmed) }]);
const postcodeResults: SearchResult[] = [
{ type: 'postcode', label: normalizePostcode(trimmed) },
];
setResults(postcodeResults);
setOpen(true);
return;
}
@ -66,19 +119,27 @@ export function useLocationSearch(mode?: string) {
return;
}
const locallyFilteredResults = filterResultsForQuery(lastResultsRef.current, trimmed);
setResults(locallyFilteredResults);
setOpen(locallyFilteredResults.length > 0);
debounceRef.current = setTimeout(async () => {
const controller = new AbortController();
abortRef.current = controller;
try {
const params = new URLSearchParams({ q: trimmed, limit: '7' });
const params = new URLSearchParams({ q: trimmed, limit: '20' });
if (mode) params.set('mode', mode);
const res = await fetch(
`/api/places?${params}`,
authHeaders({ signal: controller.signal })
);
if (!res.ok) return;
const json: { places: PlaceResult[] } = await res.json();
const placeResults: SearchResult[] = json.places.map((p) => ({
const json: {
places: PlaceResult[];
postcodes?: string[];
addresses?: AddressResult[];
} = await res.json();
const placeResults = json.places.map((p) => ({
type: 'place' as const,
name: p.name,
slug: p.slug,
@ -87,8 +148,34 @@ export function useLocationSearch(mode?: string) {
lon: p.lon,
city: p.city === 'City of London' ? 'London' : p.city,
}));
setResults(placeResults);
setOpen(placeResults.length > 0);
const outcodeResults = placeResults.filter((result) => result.place_type === 'outcode');
const otherPlaceResults = placeResults.filter(
(result) => result.place_type !== 'outcode'
);
const postcodeResults: SearchResult[] = (json.postcodes ?? []).map((postcode) => ({
type: 'postcode' as const,
label: postcode,
}));
const addressResults: SearchResult[] = (json.addresses ?? []).map((address) => ({
type: 'address' as const,
address: address.address,
postcode: address.postcode,
lat: address.lat,
lon: address.lon,
}));
const containsHouseNumber = /\d/.test(trimmed);
const combinedResults = (
containsHouseNumber
? [...outcodeResults, ...postcodeResults, ...addressResults, ...otherPlaceResults]
: [...outcodeResults, ...postcodeResults, ...otherPlaceResults, ...addressResults]
).slice(0, 20);
if (controller.signal.aborted || latestQueryRef.current.trim() !== trimmed) {
return;
}
lastResultsRef.current = combinedResults;
const matchingResults = filterResultsForQuery(combinedResults, trimmed);
setResults(matchingResults);
setOpen(matchingResults.length > 0);
} catch (err) {
logNonAbortError('places search', err);
}
@ -101,7 +188,9 @@ export function useLocationSearch(mode?: string) {
const clear = useCallback(() => {
setQuery('');
latestQueryRef.current = '';
setResults([]);
lastResultsRef.current = [];
setOpen(false);
setActiveIndex(-1);
}, []);

View file

@ -134,7 +134,7 @@ export const details: Record<string, Record<string, string>> = {
'Distance to nearest park (km)':
"Distance à vol d'oiseau en kilomètres depuis le code postal jusqu'à l'entrée du parc la plus proche. Couvre les parcs publics, jardins, terrains de jeux et espaces de loisirs. Utilise les emplacements des points d'accès issus du jeu de données OS Open Greenspace, de sorte que les propriétés bordant un grand parc affichent correctement une courte distance.",
'Number of parks within 1km':
'Nombre de parcs publics, jardins, terrains de jeux et espaces de loisirs dont au moins une entrée se trouve dans un rayon de 1km du centroïde du code postal de la propri<72><69>té. Dérivé du jeu de données OS Open Greenspace (Ordnance Survey), utilisant les emplacements des entrées de parcs pour une correspondance de proximité précise.',
'Nombre de parcs publics, jardins, terrains de jeux et espaces de loisirs dont au moins une entrée se trouve dans un rayon de 1 km du centroïde du code postal de la propriété. Dérivé du jeu de données OS Open Greenspace (Ordnance Survey), utilisant les emplacements des entrées de parcs pour une correspondance de proximité précise.',
'Number of restaurants within 2km':
'Restaurants, cafés et établissements de restauration dans un rayon de 2km du code postal. Source : OpenStreetMap.',
'Number of grocery shops and supermarkets within 2km':
@ -392,7 +392,7 @@ export const details: Record<string, Record<string, string>> = {
'% Black': '来自2021年Census。地方政府人口中认同为黑人、英国黑人、加勒比人或非洲人的百分比。',
'% East Asian': '来自2021年Census。地方政府人口中认同为华人的百分比。',
'% Mixed':
'来自2021年Census。地方政府人口中认同为<EFBFBD><EFBFBD>血或多种族群体(白人与黑人加勒比裔、白人与黑人非洲裔、白人与亚洲裔,或其他混血或多种族背景)的百分比。',
'来自2021年 Census。地方政府人口中认同为混血或多种族群体(白人与黑人加勒比裔、白人与黑人非洲裔、白人与亚洲裔,或其他混血或多种族背景)的百分比。',
'% Other':
'来自2021年Census。地方政府人口中认同为其他族裔群体阿拉伯人或其他未被主要类别涵盖的族裔的百分比。',
'Voter turnout (%)':
@ -406,7 +406,7 @@ export const details: Record<string, Record<string, string>> = {
'% Other parties':
'该选区中投给工党、保守党、自由民主党、英国改革党和绿党以外政党的有效选票百分比。包括独立候选人、议长和小型政党。',
'Distance to nearest park (km)':
'从邮政编码到最近公园入口的直线距离km。涵盖公共公园、花园、运动<EFBFBD><EFBFBD><EFBFBD>和游乐场地。使用OS Open Greenspace数据集中的出入口位置,因此紧邻大型公园的房产可正确显示较短距离。',
'从邮政编码到最近公园入口的直线距离km。涵盖公共公园、花园、运动场和游乐场地。使用 OS Open Greenspace 数据集中的出入口位置,因此紧邻大型公园的房产可正确显示较短距离。',
'Number of parks within 1km':
'以房产邮政编码中心点为圆心1km半径内至少有一个入口的公共公园、花园、运动场和游乐场地数量。来源于OS Open Greenspace数据集英国地形测量局使用公园入口位置进行精确近距离匹配。',
'Number of restaurants within 2km':
@ -418,6 +418,146 @@ export const details: Record<string, Record<string, string>> = {
'Max available download speed (Mbps)':
'来自Ofcom Connected Nations 2025的任意运营商可提供的最大固定宽带下载速度。代表理论最大值而非实际达到的速度。10 Mbps为基础级30为超快级100+为极速级1000为千兆级。',
},
hi: {
'Property type':
'HM Land Registry Price Paid डेटा और EPC प्रमाणपत्रों से लिया गया. अलग मकान, अर्ध-स्वतंत्र मकान, कतारबद्ध मकान (सभी उप-प्रकार सहित), फ्लैट/मेज़ोनेट या अन्य (बंगले, मोबाइल होम आदि).',
'Leasehold/Freehold':
'HM Land Registry Price Paid डेटा से लिया गया. फ्रीहोल्ड का मतलब है कि भवन और जिस जमीन पर वह है, दोनों आपके हैं. लीजहोल्ड का मतलब है कि भवन आपका है लेकिन जमीन नहीं: फ्रीहोल्डर से निश्चित वर्षों के लिए लीज मिला होता है.',
'Last known price':
'इस संपत्ति की अंतिम दर्ज बिक्री कीमत HM Land Registry Price Paid डेटा से आती है. यह England की आवासीय बिक्री को कवर करती है. अगर संपत्ति हाल में नहीं बिकी है तो यह कीमत कई साल पुरानी हो सकती है.',
'Estimated current price':
'अंतिम बिक्री कीमत से शुरू करके स्थानीय कीमत बदलावों के अनुसार repeat-sales index से समायोजित किया गया है (postcode sector और property type के अनुसार ट्रैक किया गया). अगर EPC रिकॉर्ड से बिक्री के बाद नवीनीकरण दिखता है, तो नवीनीकरण प्रीमियम जोड़ा जाता है. हाल की बिक्री मूल कीमत के करीब रहेगी; पुरानी बिक्री में ज्यादा समायोजन होगा.',
'Price per sqm':
'अंतिम ज्ञात बिक्री कीमत को EPC प्रमाणपत्र में दर्ज कुल फर्श क्षेत्र से भाग देकर निकाला गया. अलग-अलग आकार की संपत्तियों की मूल्य तुलना के लिए उपयोगी. केवल तब उपलब्ध जब कीमत और फर्श क्षेत्र, दोनों डेटा मौजूद हों.',
'Est. price per sqm':
'मुद्रास्फीति-समायोजित अनुमानित मौजूदा कीमत (किसी नवीनीकरण प्रीमियम सहित) को EPC प्रमाणपत्र में दर्ज कुल फर्श क्षेत्र से भाग देकर निकाला गया. ऐतिहासिक बिक्री कीमत पर आधारित प्रति वर्ग मी कीमत की तुलना में ज्यादा ताजा कीमत/क्षेत्र तुलना देता है.',
'Estimated monthly rent':
'ONS Price Index of Private Rents (PIPR) से औसत मासिक किराया, जिसे स्थानीय प्राधिकरण और बेडरूम संख्या से मिलाया गया है.',
'Total floor area (sqm)':
'Energy Performance Certificate (EPC) आकलन के दौरान मापा गया कुल उपयोगी फर्श क्षेत्र, वर्ग मीटर में. सभी रहने योग्य कमरे शामिल, लेकिन गैरेज, बाहरी इमारतें और बाहरी क्षेत्र शामिल नहीं.',
'Number of bedrooms & living rooms':
'Energy Performance Certificate (EPC) में दर्ज कुल रहने योग्य कमरों की संख्या (बेडरूम और लिविंग रूम). रसोई और बाथरूम आमतौर पर शामिल नहीं होते, जब तक वे रहने योग्य कमरा माने जाने लायक बड़े न हों.',
'Construction year':
"EPC में दर्ज निर्माण आयु-श्रेणी (जैसे '1930-1949') से मध्य बिंदु लेकर अनुमानित. पुराने भवनों के लिए कम सटीक हो सकता है, जहां आयु-श्रेणी कई दशकों तक फैली होती है.",
'Date of last transaction':
'इस संपत्ति की सबसे हाल की दर्ज बिक्री तारीख, HM Land Registry Price Paid डेटा से. डेटा में तारीख/समय के रूप में रखी होती है; फिल्टर और चार्ट के लिए fractional year में बदली जाती है.',
'Former council house':
'Energy Performance Certificate डेटा के TENURE field से निकाला गया. अगर इस संपत्ति के किसी EPC प्रमाणपत्र ने टेन्योर को social rented के रूप में दर्ज किया, तो यह संकेत देता है कि उस निरीक्षण के समय संपत्ति council या housing association stock में थी. बाद में बेची गई संपत्तियां (जैसे Right to Buy के जरिए) यह संकेतक बनाए रखती हैं.',
'Current energy rating':
'Energy Performance Certificate (EPC) से current energy efficiency rating. A (सबसे efficient) से G (सबसे कम efficient) तक. Property की floor area प्रति energy use पर आधारित.',
'Potential energy rating':
'Energy Performance Certificate (EPC) से potential energy efficiency rating, अगर EPC report की सभी cost-effective recommended improvements कर दी जाएं. A (सबसे efficient) से G (सबसे कम efficient) तक.',
'Interior height (m)':
'Energy Performance Certificate (EPC) assessment के दौरान दर्ज average internal floor-to-ceiling height, metres में. Total internal volume को total floor area से divide करके निकाली जाती है.',
'Distance to nearest train or tube station (km)':
'Postcode से निकटतम railway station या tube/metro/tram stop तक सीधी रेखा में दूरी, kilometres में.',
'Good+ primary schools within 2km':
'2km के भीतर state-funded primary schools जिनकी current Ofsted rating Good या Outstanding है. जिन schools का अभी inspection नहीं हुआ, वे excluded हैं.',
'Good+ secondary schools within 2km':
'2km के भीतर state-funded secondary schools जिनकी current Ofsted rating Good या Outstanding है. जिन schools का अभी inspection नहीं हुआ, वे excluded हैं.',
'Good+ primary schools within 5km':
'5km के भीतर state-funded primary schools जिनकी current Ofsted rating Good या Outstanding है. जिन schools का अभी inspection नहीं हुआ, वे excluded हैं.',
'Good+ secondary schools within 5km':
'5km के भीतर state-funded secondary schools जिनकी current Ofsted rating Good या Outstanding है. जिन schools का अभी inspection नहीं हुआ, वे excluded हैं.',
'Outstanding primary schools within 2km':
'2km के भीतर state-funded primary schools जिनकी current Ofsted rating Outstanding है. जिन schools का अभी inspection नहीं हुआ, वे excluded हैं.',
'Outstanding secondary schools within 2km':
'2km के भीतर state-funded secondary schools जिनकी current Ofsted rating Outstanding है. जिन schools का अभी inspection नहीं हुआ, वे excluded हैं.',
'Outstanding primary schools within 5km':
'5km के भीतर state-funded primary schools जिनकी current Ofsted rating Outstanding है. जिन schools का अभी inspection नहीं हुआ, वे excluded हैं.',
'Outstanding secondary schools within 5km':
'5km के भीतर state-funded secondary schools जिनकी current Ofsted rating Outstanding है. जिन schools का अभी inspection नहीं हुआ, वे excluded हैं.',
'Education, Skills and Training Score':
'English Indices of Deprivation से लिया गया (invert किया गया ताकि higher = better). School attainment, higher education entry, adult qualifications और English language proficiency को cover करता है. Higher scores कम deprivation दिखाते हैं.',
'Income Score (rate)':
'English Indices of Deprivation से लिया गया (invert किया गया ताकि higher = better). Higher values कम income deprivation दिखाते हैं. Income support, income-based Jobseekers Allowance, income-based Employment and Support Allowance, Pension Credit, Working and Child Tax Credit, Universal Credit और asylum seekers पर आधारित.',
'Employment Score (rate)':
'English Indices of Deprivation से लिया गया (invert किया गया ताकि higher = better). Higher values कम employment deprivation दिखाते हैं. Jobseekers Allowance, Employment and Support Allowance, Incapacity Benefit, Severe Disablement Allowance, Carers Allowance और relevant Universal Credit claimants पर आधारित.',
'Health Deprivation and Disability Score':
'English Indices of Deprivation से लिया गया (invert किया गया ताकि higher = better). Higher scores premature death का कम risk और बेहतर quality of life दिखाते हैं. Years of potential life lost, comparative illness and disability ratio, acute morbidity और mood/anxiety disorders से derived.',
'Living Environment Score':
'English Indices of Deprivation से लिया गया (invert किया गया ताकि higher = better). Housing quality (condition, central heating) और outdoor environment (air quality, road safety) को combine करता है. Higher scores बेहतर living environments दिखाते हैं.',
'Indoors Sub-domain Score':
'English Indices of Deprivation, Living Environment domain से लिया गया (invert किया गया ताकि higher = better). Housing stock की quality मापता है: central heating availability, housing condition और Decent Homes standards. Higher scores बेहतर housing conditions दिखाते हैं.',
'Outdoors Sub-domain Score':
'English Indices of Deprivation, Living Environment domain से लिया गया (invert किया गया ताकि higher = better). Air quality indicators और pedestrians/cyclists से जुड़े road traffic accident casualties के जरिए outdoor living environment quality मापता है. Higher scores बेहतर outdoor environments दिखाते हैं.',
'Serious crime per 1k residents (avg/yr)':
'LSOA में प्रति 1,000 usual residents प्रति वर्ष violence, robbery, burglary और possession of weapons. police.uk street-level crime data (2023-2025) और Census 2021 population counts का उपयोग करता है. Population density normalize करता है ताकि areas size की परवाह किए बिना comparable हों.',
'Minor crime per 1k residents (avg/yr)':
'LSOA में प्रति 1,000 usual residents प्रति वर्ष anti-social behaviour, shoplifting, bicycle theft और other lower-severity crimes. police.uk street-level crime data (2023-2025) और Census 2021 population counts का उपयोग करता है. Population density normalize करता है ताकि areas comparable हों.',
'Serious crime (avg/yr)':
'LSOA में प्रति वर्ष violence, robbery, burglary और possession of weapons का sum, police.uk street-level crime data (2023-2025) से. Serious crime का एक single indicator देता है.',
'Minor crime (avg/yr)':
'LSOA में प्रति वर्ष anti-social behaviour, shoplifting, bicycle theft और other lower-severity crimes का sum, police.uk street-level crime data (2023-2025) से. Minor crime का एक single indicator देता है.',
'Violence and sexual offences (avg/yr)':
'LSOA में प्रति वर्ष violence और sexual offences की average count, police.uk street-level crime data (2023-2025) से. Assaults, harassment और sexual offences शामिल.',
'Burglary (avg/yr)':
'LSOA में प्रति वर्ष burglaries की average count, police.uk street-level crime data (2023-2025) से. Residential और commercial burglaries शामिल.',
'Robbery (avg/yr)':
'LSOA में प्रति वर्ष robberies की average count, police.uk street-level crime data (2023-2025) से. Robbery में force या threat of force के साथ theft शामिल है.',
'Vehicle crime (avg/yr)':
'LSOA में प्रति वर्ष vehicle-related crime incidents की average count, police.uk street-level crime data (2023-2025) से. Vehicles की theft और vehicles के अंदर से theft शामिल.',
'Anti-social behaviour (avg/yr)':
'LSOA में प्रति वर्ष anti-social behaviour incidents की average count, police.uk street-level crime data (2023-2025) से. Nuisance, environmental और personal anti-social behaviour शामिल.',
'Criminal damage and arson (avg/yr)':
'LSOA में प्रति वर्ष criminal damage और arson incidents की average count, police.uk street-level crime data (2023-2025) से.',
'Other theft (avg/yr)':
"'Other theft' offences की LSOA में प्रति वर्ष average count, police.uk street-level crime data (2023-2025) से. Burglary, vehicle crime, shoplifting या bicycle theft में न आने वाली theft शामिल.",
'Theft from the person (avg/yr)':
'LSOA में प्रति वर्ष theft-from-the-person offences की average count, police.uk street-level crime data (2023-2025) से. Pickpocketing और बिना force के bag snatching शामिल.',
'Shoplifting (avg/yr)':
'LSOA में प्रति वर्ष shoplifting offences की average count, police.uk street-level crime data (2023-2025) से.',
'Bicycle theft (avg/yr)':
'LSOA में प्रति वर्ष bicycle theft offences की average count, police.uk street-level crime data (2023-2025) से.',
'Drugs (avg/yr)':
'LSOA में प्रति वर्ष drug offences की average count, police.uk street-level crime data (2023-2025) से. Possession और trafficking offences शामिल.',
'Possession of weapons (avg/yr)':
'LSOA में प्रति वर्ष possession-of-weapons offences की average count, police.uk street-level crime data (2023-2025) से.',
'Public order (avg/yr)':
'LSOA में प्रति वर्ष public order offences की average count, police.uk street-level crime data (2023-2025) से. Fear, alarm या distress पैदा करने वाले acts शामिल.',
'Other crime (avg/yr)':
'LSOA में प्रति वर्ष other criminal offences की average count, police.uk street-level crime data (2023-2025) से. कहीं और classified न होने वाले offences के लिए catch-all category.',
'Median age':
'Census 2021 (TS007A) से. LSOA में usual residents की median age, five-year age band counts से linear interpolation द्वारा calculated. Younger-population areas आमतौर पर urban, university towns या family-heavy होते हैं; higher medians rural और coastal areas में common हैं.',
'% White':
'Census 2021 से. Local authority population का percentage जो White (English, Welsh, Scottish, Northern Irish, British, Irish, Gypsy or Irish Traveller, Roma या any other white background) के रूप में identify करता है.',
'% South Asian':
'Census 2021 से. Local authority population का percentage जो Indian, Pakistani, Bangladeshi या any other Asian background के रूप में identify करता है.',
'% Black':
'Census 2021 से. Local authority population का percentage जो Black, Black British, Caribbean या African के रूप में identify करता है.',
'% East Asian':
'Census 2021 से. Local authority population का percentage जो Chinese के रूप में identify करता है.',
'% Mixed':
'Census 2021 से. Local authority population का percentage जो Mixed या multiple ethnic groups (White and Black Caribbean, White and Black African, White and Asian या other mixed/multiple background) के रूप में identify करता है.',
'% Other':
'Census 2021 से. Local authority population का percentage जो another ethnic group (Arab या main categories से बाहर any other ethnic group) के रूप में identify करता है.',
'Voter turnout (%)':
'July 2024 UK General Election में valid vote देने वाले registered electorate का proportion. Valid votes divided by electorate size. Higher turnout अक्सर affluent areas और closer contests से correlate करता है.',
'% Labour':
'July 2024 UK General Election में इस postcode को cover करने वाली constituency में Labour Party candidates को पड़े valid votes का percentage.',
'% Conservative':
'July 2024 UK General Election में इस postcode को cover करने वाली constituency में Conservative Party को पड़े valid votes का percentage.',
'% Liberal Democrat':
'July 2024 UK General Election में इस postcode को cover करने वाली constituency में Liberal Democrats को पड़े valid votes का percentage.',
'% Reform UK':
'July 2024 UK General Election में इस postcode को cover करने वाली constituency में Reform UK को पड़े valid votes का percentage.',
'% Green':
'July 2024 UK General Election में इस postcode को cover करने वाली constituency में Green Party को पड़े valid votes का percentage.',
'% Other parties':
'इस postcode की constituency में Labour, Conservative, Liberal Democrat, Reform UK और Green के अलावा अन्य parties को पड़े valid votes का percentage. Independents, Speaker और minor parties शामिल.',
'Distance to nearest park (km)':
'Postcode से nearest park entrance तक सीधी रेखा में दूरी, kilometres में. Public parks, gardens, playing fields और play spaces को cover करता है. OS Open Greenspace dataset के access-point locations का उपयोग करता है, इसलिए बड़े park के किनारे properties सही short distance दिखाती हैं.',
'Number of parks within 1km':
'Property postcode centroid से 1km radius के भीतर कम से कम एक entrance रखने वाले public parks, gardens, playing fields और play spaces की संख्या. OS Open Greenspace dataset (Ordnance Survey) से derived, accurate proximity matching के लिए park entrance locations उपयोग करता है.',
'Number of restaurants within 2km':
'Postcode से 2km के भीतर restaurants, cafés और food-service venues. Source: OpenStreetMap.',
'Number of grocery shops and supermarkets within 2km':
'Property postcode centroid से 2km radius के भीतर supermarkets, convenience stores और other grocery shops की संख्या. OpenStreetMap POI data से derived.',
'Noise (dB)':
'Defra Strategic Noise Mapping Round 4 (2022) से road-noise level in decibels (Lden, 24-hour weighted average). Ground से 4m ऊपर 10m grid पर modelled. लगभग 55 dB से ऊपर शोर आमतौर पर noticeable होता है; लगभग 70 dB से ऊपर WHO harmful मानता है.',
'Max available download speed (Mbps)':
'Ofcom Connected Nations 2025 से किसी भी provider द्वारा उपलब्ध maximum fixed-broadband download speed. Theoretical maximum दर्शाता है, achieved speed नहीं. 10 Mbps = basic, 30 = superfast, 100+ = ultrafast, 1000 = gigabit.',
},
hu: {
'Property type':
'Az HM Land Registry Price Paid adatokból és EPC tanúsítványokból. Különálló, Ikerház, Sorház (minden sorház altípust tartalmaz), Lakás/Maisonette, vagy Egyéb (bungaló, mobilház stb.).',

View file

@ -23,11 +23,6 @@ html.dark {
color-scheme: dark;
}
.home-page-scroll,
.dark .home-page-scroll {
background: linear-gradient(180deg, #080d19 0%, #080d19 50%, #16a34a 50%, #16a34a 100%);
}
/* Smooth theme transitions (scoped to avoid map performance issues) */
body,
div,

View file

@ -4,6 +4,9 @@ const MiniCssExtractPlugin = require('mini-css-extract-plugin');
const CopyWebpackPlugin = require('copy-webpack-plugin');
const ReactRefreshWebpackPlugin = require('@pmmmwh/react-refresh-webpack-plugin');
const FaviconsWebpackPlugin = require('favicons-webpack-plugin');
const sharp = require('sharp');
const HOUSE_IMAGE_WIDTH = 260;
module.exports = (env, argv) => {
const isProduction = argv.mode === 'production';
@ -12,7 +15,8 @@ module.exports = (env, argv) => {
entry: './src/index.tsx',
output: {
path: path.resolve(__dirname, 'dist'),
filename: 'bundle.js',
filename: isProduction ? '[name].[contenthash:8].js' : 'bundle.js',
chunkFilename: isProduction ? '[name].[contenthash:8].js' : '[name].bundle.js',
clean: true,
publicPath: '/',
@ -52,7 +56,34 @@ module.exports = (env, argv) => {
template: './src/index.html',
}),
new CopyWebpackPlugin({
patterns: [{ from: 'public', noErrorOnMissing: true }],
patterns: [
{
from: 'public',
noErrorOnMissing: true,
globOptions: {
ignore: ['**/house.png'],
},
},
isProduction
? {
from: 'public/house.png',
to: 'house.png',
noErrorOnMissing: true,
transform: {
transformer(content) {
return sharp(content)
.resize({ width: HOUSE_IMAGE_WIDTH, withoutEnlargement: true })
.png({ compressionLevel: 9, palette: true, quality: 85 })
.toBuffer();
},
},
}
: {
from: 'public/house.png',
to: 'house.png',
noErrorOnMissing: true,
},
],
}),
new FaviconsWebpackPlugin({
logo: './public/favicon.svg',
@ -69,9 +100,111 @@ module.exports = (env, argv) => {
},
}),
...(isProduction
? [new MiniCssExtractPlugin()]
? [
new MiniCssExtractPlugin({
filename: '[name].[contenthash:8].css',
chunkFilename: '[name].[contenthash:8].css',
}),
]
: [new ReactRefreshWebpackPlugin()]),
],
optimization: isProduction
? {
splitChunks: {
chunks: 'all',
cacheGroups: {
maplibre: {
test: /[\\/]node_modules[\\/]maplibre-gl[\\/]/,
name: 'vendor-maplibre',
chunks: 'async',
priority: 50,
enforce: true,
reuseExistingChunk: true,
},
h3: {
test: /[\\/]node_modules[\\/]h3-js[\\/]/,
name: 'vendor-h3',
chunks: 'async',
priority: 45,
enforce: true,
reuseExistingChunk: true,
},
deckMapbox: {
test: /[\\/]node_modules[\\/]@deck\.gl[\\/]mapbox[\\/]/,
name: 'vendor-deck-mapbox',
chunks: 'async',
priority: 44,
enforce: true,
reuseExistingChunk: true,
},
deckCore: {
test: /[\\/]node_modules[\\/]@deck\.gl[\\/]core[\\/]/,
name: 'vendor-deck-core',
chunks: 'async',
priority: 43,
enforce: true,
reuseExistingChunk: true,
},
deckLayers: {
test: /[\\/]node_modules[\\/]@deck\.gl[\\/](?:layers|geo-layers)[\\/]/,
name: 'vendor-deck-layers',
chunks: 'async',
priority: 42,
enforce: true,
reuseExistingChunk: true,
},
deck: {
test: /[\\/]node_modules[\\/]@deck\.gl[\\/]/,
name: 'vendor-deck',
chunks: 'async',
priority: 40,
enforce: true,
reuseExistingChunk: true,
},
luma: {
test: /[\\/]node_modules[\\/]@luma\.gl[\\/]/,
name: 'vendor-luma',
chunks: 'async',
priority: 35,
enforce: true,
reuseExistingChunk: true,
},
webgpuSupport: {
test: /[\\/]node_modules[\\/]wgsl_reflect[\\/]/,
name: 'vendor-webgpu-support',
chunks: 'async',
priority: 34,
enforce: true,
reuseExistingChunk: true,
},
mapData: {
test: /[\\/]node_modules[\\/](?:@mapbox[\\/]tiny-sdf|@protomaps[\\/]basemaps|earcut|supercluster)[\\/]/,
name: 'vendor-map-data',
chunks: 'async',
priority: 33,
enforce: true,
reuseExistingChunk: true,
},
mapSupport: {
test: /[\\/]node_modules[\\/](?:@loaders\.gl|@math\.gl|@probe\.gl|@vis\.gl|mjolnir\.js|react-map-gl)[\\/]/,
name: 'vendor-map-support',
chunks: 'async',
priority: 30,
enforce: true,
reuseExistingChunk: true,
},
joyride: {
test: /[\\/]node_modules[\\/](?:react-joyride|deepmerge|scroll|scrollparent|react-innertext)[\\/]/,
name: 'vendor-joyride',
chunks: 'async',
priority: 25,
enforce: true,
reuseExistingChunk: true,
},
},
},
}
: undefined,
devServer: {
host: '0.0.0.0',
port: 3001,