This commit is contained in:
Andras Schmelczer 2026-04-04 17:44:44 +01:00
parent b94cf17d75
commit 0c6d207967
41 changed files with 1809 additions and 1204 deletions

View file

@ -601,7 +601,9 @@ function InviteTable({
<table className="w-full table-fixed text-sm">
<thead>
<tr className="border-b border-warm-200 dark:border-warm-700 text-left">
<th className="px-5 py-2 text-warm-500 dark:text-warm-400 font-medium">{t('invitesPage.link')}</th>
<th className="px-5 py-2 text-warm-500 dark:text-warm-400 font-medium">
{t('invitesPage.link')}
</th>
<th className="px-5 py-2 text-warm-500 dark:text-warm-400 font-medium w-24">
{t('invitesPage.status')}
</th>
@ -754,7 +756,9 @@ export function InvitesPage({ user }: { user: AuthUser }) {
{(user.isAdmin ? ['admin', 'referral'] : ['referral']).map((type) => (
<div key={type} className="px-5 py-4">
<p className="text-sm text-warm-500 dark:text-warm-400 mb-3">
{type === 'admin' ? t('invitesPage.inviteAdminLabel') : t('invitesPage.inviteReferralLabel')}
{type === 'admin'
? t('invitesPage.inviteAdminLabel')
: t('invitesPage.inviteReferralLabel')}
</p>
{inviteUrl[type] ? (
<div className="flex items-center gap-2">
@ -783,7 +787,9 @@ export function InvitesPage({ user }: { user: AuthUser }) {
className="px-4 py-2 rounded-lg bg-teal-600 hover:bg-teal-700 text-white text-sm font-medium disabled:opacity-50 disabled:cursor-wait flex items-center gap-2"
>
{creatingInvite[type] && <SpinnerIcon className="w-4 h-4 animate-spin" />}
{type === 'admin' ? t('invitesPage.generateFreeInvite') : t('invitesPage.generateReferralLink')}
{type === 'admin'
? t('invitesPage.generateFreeInvite')
: t('invitesPage.generateReferralLink')}
</button>
)}
{inviteError[type] && (
@ -845,7 +851,9 @@ export default function AccountPage({
{/* Email */}
<div className="px-5 py-4 flex items-center justify-between">
<div>
<p className="text-sm text-warm-500 dark:text-warm-400">{t('accountPage.emailLabel')}</p>
<p className="text-sm text-warm-500 dark:text-warm-400">
{t('accountPage.emailLabel')}
</p>
<p className="text-navy-950 dark:text-warm-100 font-medium">{user.email}</p>
</div>
</div>
@ -853,7 +861,9 @@ export default function AccountPage({
{/* Subscription */}
<div className="px-5 py-4 flex items-center justify-between">
<div>
<p className="text-sm text-warm-500 dark:text-warm-400">{t('accountPage.subscriptionLabel')}</p>
<p className="text-sm text-warm-500 dark:text-warm-400">
{t('accountPage.subscriptionLabel')}
</p>
<span
className={`inline-block text-sm font-medium px-2.5 py-0.5 rounded-full mt-1 ${badgeColor}`}
>

View file

@ -68,23 +68,24 @@ export default function HomePage({
<div className="relative z-10 max-w-4xl mx-auto px-6 md:px-10 pt-16 md:pt-24 backdrop-blur-[2px] flex-1 flex flex-col">
<div>
<h1 className="text-3xl md:text-5xl font-extrabold text-white mb-4 leading-[1.1] tracking-tight">
{t('home.heroTitle1')} <span className="text-teal-400">{t('home.heroTitle2')}</span>.
{t('home.heroTitle1')} <span className="text-teal-400">{t('home.heroTitle2')}</span>
.
<br />
{t('home.heroTitle3')}
</h1>
<p className="text-lg text-warm-300 mb-6 leading-relaxed max-w-xl">
<p className="text-base md:text-lg text-warm-300 mb-6 leading-relaxed max-w-xl">
{t('home.heroSubtitle')}
</p>
<p className="text-lg text-warm-400 mb-8 max-w-xl">
<p className="text-base md:text-lg text-warm-400 mb-8 max-w-xl">
{t('home.heroDescription')}
</p>
<div className="flex flex-wrap items-center gap-4 mb-10">
<div className="flex flex-col sm:flex-row sm:items-center gap-3 sm:gap-4 mb-10">
<button
onClick={() => {
trackEvent('CTA Click', { location: 'hero', label: 'explore_map' });
onOpenDashboard();
}}
className="px-7 py-3.5 bg-coral-500 text-white rounded-lg font-semibold hover:bg-coral-600 transition-colors text-base shadow-lg shadow-coral-500/25"
className="px-7 py-3.5 bg-coral-500 text-white rounded-lg font-semibold hover:bg-coral-600 transition-colors text-base shadow-lg shadow-coral-500/25 text-center"
>
{t('home.exploreTheMap')}
</button>
@ -113,12 +114,12 @@ export default function HomePage({
};
requestAnimationFrame(step);
}}
className="px-[26px] py-[12px] border-2 border-teal-400 text-teal-400 rounded-lg font-semibold hover:bg-teal-400/10 transition-colors text-base"
className="px-[26px] py-[12px] border-2 border-teal-400 text-teal-400 rounded-lg font-semibold hover:bg-teal-400/10 transition-colors text-base text-center"
>
{t('home.seeTheDifference')}
</button>
</div>
<div className="flex flex-wrap gap-x-12 gap-y-4 pt-3 border-t border-white/10">
<div className="flex flex-wrap gap-x-8 sm:gap-x-12 gap-y-4 pt-3 border-t border-white/10">
<div>
<div className="text-2xl md:text-3xl font-bold text-white">
<TickerValue text="13M" active={statsActive} />
@ -132,7 +133,9 @@ export default function HomePage({
<div className="text-sm text-warm-400">{t('home.statFilters')}</div>
</div>
<div>
<div className="text-2xl md:text-3xl font-bold text-white">{t('home.statEvery')}</div>
<div className="text-2xl md:text-3xl font-bold text-white">
{t('home.statEvery')}
</div>
<div className="text-sm text-warm-400">{t('home.statPostcodeInEngland')}</div>
</div>
</div>
@ -142,30 +145,26 @@ export default function HomePage({
</div>
{/* Our philosophy */}
<div className="px-6 md:px-12 lg:px-20 pt-20 pb-4">
<h2 className="text-3xl font-bold text-navy-950 dark:text-warm-100 mb-6">
<div className="px-6 md:px-12 lg:px-20 pt-12 md:pt-20 pb-4">
<h2 className="text-2xl md:text-3xl font-bold text-navy-950 dark:text-warm-100 mb-6">
{t('home.ourPhilosophy')}
</h2>
<div className="space-y-4 text-lg md:text-xl leading-relaxed text-warm-700 dark:text-warm-300">
<p>
{t('home.philosophyP1')}
</p>
<p>
{t('home.philosophyP2')}
</p>
<p>{t('home.philosophyP1')}</p>
<p>{t('home.philosophyP2')}</p>
</div>
</div>
{/* How to use it + Comparison table (two columns) */}
<div id="how-it-works" className="max-w-7xl mx-auto px-6 pt-10 pb-2">
<div ref={whyRef} className="fade-in-section">
<div className="grid lg:grid-cols-[2fr_3fr] gap-12 items-start">
<div className="grid lg:grid-cols-[2fr_3fr] gap-8 lg:gap-12 items-start">
{/* Left: How to use it */}
<div>
<h2 className="text-3xl font-bold text-navy-950 dark:text-warm-100 mb-10">
<h2 className="text-2xl md:text-3xl font-bold text-navy-950 dark:text-warm-100 mb-6 md:mb-10">
{t('home.howToUseIt')}
</h2>
<div className="space-y-8">
<div className="space-y-6 md:space-y-8">
{[
{ title: t('home.howStep1Title'), desc: t('home.howStep1Desc') },
{ title: t('home.howStep2Title'), desc: t('home.howStep2Desc') },
@ -190,11 +189,11 @@ export default function HomePage({
</div>
{/* Right: Comparison table */}
<div id="comparison">
<h2 className="text-3xl font-bold text-navy-950 dark:text-warm-100 mb-10">
<h2 className="text-2xl md:text-3xl font-bold text-navy-950 dark:text-warm-100 mb-6 md:mb-10">
{t('home.othersVs')}{' '}
<span className="inline-flex items-baseline gap-3 whitespace-nowrap">
<span className="inline-flex items-baseline gap-2 md:gap-3">
{t('header.appName')}{' '}
<LogoIcon className="w-8 h-8 text-teal-600 dark:text-teal-400" />
<LogoIcon className="w-6 h-6 md:w-8 md:h-8 text-teal-600 dark:text-teal-400" />
</span>
</h2>
<div className="overflow-x-auto rounded-xl border border-warm-200 dark:border-warm-700 bg-white dark:bg-warm-800 shadow-sm">
@ -218,10 +217,34 @@ export default function HomePage({
</thead>
<tbody>
{[
{ feature: t('home.compSearchWithout'), subtitle: t('home.compSearchWithoutSub'), listings: false, postcode: false, guides: false },
{ feature: t('home.compAreaData'), subtitle: t('home.compAreaDataSub'), listings: false, postcode: true, guides: true },
{ feature: t('home.compPropertyData'), subtitle: t('home.compPropertyDataSub'), listings: true, postcode: false, guides: false },
{ feature: t('home.compFilters'), subtitle: t('home.compFiltersSub'), listings: false, postcode: false, guides: false },
{
feature: t('home.compSearchWithout'),
subtitle: t('home.compSearchWithoutSub'),
listings: false,
postcode: false,
guides: false,
},
{
feature: t('home.compAreaData'),
subtitle: t('home.compAreaDataSub'),
listings: false,
postcode: true,
guides: true,
},
{
feature: t('home.compPropertyData'),
subtitle: t('home.compPropertyDataSub'),
listings: true,
postcode: false,
guides: false,
},
{
feature: t('home.compFilters'),
subtitle: t('home.compFiltersSub'),
listings: false,
postcode: false,
guides: false,
},
].map((row, i, arr) => (
<tr
key={i}
@ -261,7 +284,7 @@ export default function HomePage({
</div>
{/* The real cost CTA */}
<div className="max-w-4xl mx-auto px-6 pt-20 pb-12">
<div className="max-w-4xl mx-auto px-6 pt-12 md:pt-20 pb-12">
<div ref={ctaRef} className="fade-in-section text-center">
<h2 className="text-2xl md:text-3xl font-bold text-navy-950 dark:text-warm-100 mb-4 leading-snug">
{t('home.ctaTitle')}
@ -274,7 +297,7 @@ export default function HomePage({
trackEvent('CTA Click', { location: 'bottom', label: 'explore_map' });
onOpenDashboard();
}}
className="px-8 py-4 bg-coral-500 text-white rounded-lg font-semibold hover:bg-coral-600 transition-colors text-lg shadow-lg shadow-coral-500/25"
className="w-full sm:w-auto px-8 py-4 bg-coral-500 text-white rounded-lg font-semibold hover:bg-coral-600 transition-colors text-lg shadow-lg shadow-coral-500/25"
>
{t('home.exploreTheMap')}
</button>
@ -287,4 +310,3 @@ export default function HomePage({
</div>
);
}

View file

@ -202,7 +202,9 @@ export default function InvitePage({
<span className="text-[96px] leading-none 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-2 text-3xl">{t('upgrade.once')}</span>
<span className="text-warm-500 dark:text-warm-400 ml-2 text-3xl">
{t('upgrade.once')}
</span>
</div>
)}
<p className="text-warm-600 dark:text-warm-400 text-3xl">

View file

@ -14,37 +14,111 @@ interface DataSourceDef {
}
const DATA_SOURCE_DEFS: DataSourceDef[] = [
{ id: 'price-paid', url: 'https://www.gov.uk/government/statistical-data-sets/price-paid-data-downloads', license: 'Open Government Licence v3.0' },
{ id: 'epc', url: 'https://epc.opendatacommunities.org/downloads/domestic', license: 'Open Government Licence v3.0', optOutUrl: 'https://www.gov.uk/guidance/energy-performance-certificates-opt-out-of-public-disclosure' },
{ id: 'nspl', url: 'https://www.arcgis.com/sharing/rest/content/items/077631e063eb4e1ab43575d01381ec33/data', license: 'Open Government Licence v3.0' },
{ id: 'iod', url: 'https://www.gov.uk/government/statistics/english-indices-of-deprivation-2025', license: 'Open Government Licence v3.0' },
{ id: 'ethnicity', url: 'https://www.ethnicity-facts-figures.service.gov.uk/uk-population-by-ethnicity/national-and-regional-populations/regional-ethnic-diversity/latest/#download-the-data', license: 'Open Government Licence v3.0' },
{
id: 'price-paid',
url: 'https://www.gov.uk/government/statistical-data-sets/price-paid-data-downloads',
license: 'Open Government Licence v3.0',
},
{
id: 'epc',
url: 'https://epc.opendatacommunities.org/downloads/domestic',
license: 'Open Government Licence v3.0',
optOutUrl:
'https://www.gov.uk/guidance/energy-performance-certificates-opt-out-of-public-disclosure',
},
{
id: 'nspl',
url: 'https://www.arcgis.com/sharing/rest/content/items/077631e063eb4e1ab43575d01381ec33/data',
license: 'Open Government Licence v3.0',
},
{
id: 'iod',
url: 'https://www.gov.uk/government/statistics/english-indices-of-deprivation-2025',
license: 'Open Government Licence v3.0',
},
{
id: 'ethnicity',
url: 'https://www.ethnicity-facts-figures.service.gov.uk/uk-population-by-ethnicity/national-and-regional-populations/regional-ethnic-diversity/latest/#download-the-data',
license: 'Open Government Licence v3.0',
},
{ id: 'crime', url: 'https://data.police.uk/data/', license: 'Open Government Licence v3.0' },
{ id: 'osm-pois', url: 'https://download.geofabrik.de/europe/great-britain-latest.osm.pbf', license: 'Open Data Commons Open Database License (ODbL)' },
{ id: 'os-open-greenspace', url: 'https://osdatahub.os.uk/downloads/open/OpenGreenspace', license: 'Open Government Licence v3.0' },
{ id: 'naptan', url: 'https://naptan.dft.gov.uk/naptan/schema/2.4/doc/NaPTANSchemaGuide-2.4-v0.57.pdf', license: 'Open Government Licence v3.0' },
{ id: 'noise', url: 'https://environment.data.gov.uk/spatialdata/road-noise-all-metrics-england-round-4/wcs', license: 'Open Government Licence v3.0' },
{ id: 'ofsted', url: 'https://www.gov.uk/government/statistical-data-sets/monthly-management-information-ofsteds-school-inspections-outcomes', license: 'Open Government Licence v3.0' },
{ id: 'broadband', url: 'https://www.ofcom.org.uk/phones-and-broadband/coverage-and-speeds/connected-nations-20252/data-downloads-2025', license: 'Open Government Licence v3.0' },
{ id: 'council-tax', url: 'https://www.gov.uk/government/statistics/council-tax-levels-set-by-local-authorities-in-england-2025-to-2026', license: 'Open Government Licence v3.0' },
{ id: 'ons-rental', url: 'https://www.ons.gov.uk/peoplepopulationandcommunity/housing/datasets/privaterentalmarketsummarystatisticsinengland', license: 'Open Government Licence v3.0' },
{
id: 'osm-pois',
url: 'https://download.geofabrik.de/europe/great-britain-latest.osm.pbf',
license: 'Open Data Commons Open Database License (ODbL)',
},
{
id: 'os-open-greenspace',
url: 'https://osdatahub.os.uk/downloads/open/OpenGreenspace',
license: 'Open Government Licence v3.0',
},
{
id: 'naptan',
url: 'https://naptan.dft.gov.uk/naptan/schema/2.4/doc/NaPTANSchemaGuide-2.4-v0.57.pdf',
license: 'Open Government Licence v3.0',
},
{
id: 'noise',
url: 'https://environment.data.gov.uk/spatialdata/road-noise-all-metrics-england-round-4/wcs',
license: 'Open Government Licence v3.0',
},
{
id: 'ofsted',
url: 'https://www.gov.uk/government/statistical-data-sets/monthly-management-information-ofsteds-school-inspections-outcomes',
license: 'Open Government Licence v3.0',
},
{
id: 'broadband',
url: 'https://www.ofcom.org.uk/phones-and-broadband/coverage-and-speeds/connected-nations-20252/data-downloads-2025',
license: 'Open Government Licence v3.0',
},
{
id: 'council-tax',
url: 'https://www.gov.uk/government/statistics/council-tax-levels-set-by-local-authorities-in-england-2025-to-2026',
license: 'Open Government Licence v3.0',
},
{
id: 'ons-rental',
url: 'https://www.ons.gov.uk/peoplepopulationandcommunity/housing/datasets/privaterentalmarketsummarystatisticsinengland',
license: 'Open Government Licence v3.0',
},
];
// Maps data source id → [nameKey, originKey, useKey] in en.ts learnPage section
const DS_KEYS: Record<string, [string, string, string]> = {
'price-paid': ['learnPage.dsPricePaidName', 'learnPage.dsPricePaidOrigin', 'learnPage.dsPricePaidUse'],
'epc': ['learnPage.dsEpcName', 'learnPage.dsEpcOrigin', 'learnPage.dsEpcUse'],
'nspl': ['learnPage.dsNsplName', 'learnPage.dsNsplOrigin', 'learnPage.dsNsplUse'],
'iod': ['learnPage.dsIodName', 'learnPage.dsIodOrigin', 'learnPage.dsIodUse'],
'ethnicity': ['learnPage.dsEthnicityName', 'learnPage.dsEthnicityOrigin', 'learnPage.dsEthnicityUse'],
'crime': ['learnPage.dsCrimeName', 'learnPage.dsCrimeOrigin', 'learnPage.dsCrimeUse'],
'price-paid': [
'learnPage.dsPricePaidName',
'learnPage.dsPricePaidOrigin',
'learnPage.dsPricePaidUse',
],
epc: ['learnPage.dsEpcName', 'learnPage.dsEpcOrigin', 'learnPage.dsEpcUse'],
nspl: ['learnPage.dsNsplName', 'learnPage.dsNsplOrigin', 'learnPage.dsNsplUse'],
iod: ['learnPage.dsIodName', 'learnPage.dsIodOrigin', 'learnPage.dsIodUse'],
ethnicity: [
'learnPage.dsEthnicityName',
'learnPage.dsEthnicityOrigin',
'learnPage.dsEthnicityUse',
],
crime: ['learnPage.dsCrimeName', 'learnPage.dsCrimeOrigin', 'learnPage.dsCrimeUse'],
'osm-pois': ['learnPage.dsOsmName', 'learnPage.dsOsmOrigin', 'learnPage.dsOsmUse'],
'os-open-greenspace': ['learnPage.dsGreenspaceName', 'learnPage.dsGreenspaceOrigin', 'learnPage.dsGreenspaceUse'],
'naptan': ['learnPage.dsNaptanName', 'learnPage.dsNaptanOrigin', 'learnPage.dsNaptanUse'],
'noise': ['learnPage.dsNoiseName', 'learnPage.dsNoiseOrigin', 'learnPage.dsNoiseUse'],
'ofsted': ['learnPage.dsOfstedName', 'learnPage.dsOfstedOrigin', 'learnPage.dsOfstedUse'],
'broadband': ['learnPage.dsBroadbandName', 'learnPage.dsBroadbandOrigin', 'learnPage.dsBroadbandUse'],
'council-tax': ['learnPage.dsCouncilTaxName', 'learnPage.dsCouncilTaxOrigin', 'learnPage.dsCouncilTaxUse'],
'os-open-greenspace': [
'learnPage.dsGreenspaceName',
'learnPage.dsGreenspaceOrigin',
'learnPage.dsGreenspaceUse',
],
naptan: ['learnPage.dsNaptanName', 'learnPage.dsNaptanOrigin', 'learnPage.dsNaptanUse'],
noise: ['learnPage.dsNoiseName', 'learnPage.dsNoiseOrigin', 'learnPage.dsNoiseUse'],
ofsted: ['learnPage.dsOfstedName', 'learnPage.dsOfstedOrigin', 'learnPage.dsOfstedUse'],
broadband: [
'learnPage.dsBroadbandName',
'learnPage.dsBroadbandOrigin',
'learnPage.dsBroadbandUse',
],
'council-tax': [
'learnPage.dsCouncilTaxName',
'learnPage.dsCouncilTaxOrigin',
'learnPage.dsCouncilTaxUse',
],
'ons-rental': ['learnPage.dsRentalName', 'learnPage.dsRentalOrigin', 'learnPage.dsRentalUse'],
};
@ -207,53 +281,53 @@ export default function LearnPage() {
const keys = DS_KEYS[source.id];
const [nameKey, originKey, useKey] = keys;
return (
<div
key={source.id}
id={source.id}
ref={(el) => {
cardRefs.current[source.id] = el;
}}
className={`bg-white dark:bg-warm-800 rounded-lg border p-5 ${
highlightedId === source.id
? 'border-teal-400 ring-2 ring-teal-400'
: 'border-warm-200 dark:border-warm-700'
}`}
>
<div className="flex items-start justify-between gap-4 mb-2">
<h2 className="text-lg font-semibold text-warm-900 dark:text-warm-100">
{tDynamic(nameKey)}
</h2>
<span className="text-xs bg-warm-100 dark:bg-navy-700 text-warm-600 dark:text-warm-300 px-2 py-1 rounded text-right">
{source.license}
</span>
</div>
<p className="text-sm text-warm-500 dark:text-warm-400 mb-2">
{t('learnPage.source')} {tDynamic(originKey)}
</p>
<p className="text-sm text-warm-700 dark:text-warm-300 mb-3">
{tDynamic(useKey)}
</p>
<a
href={source.url}
target="_blank"
rel="noopener noreferrer"
className="text-sm text-teal-600 dark:text-teal-400 hover:text-teal-800 dark:hover:text-teal-300 hover:underline break-all"
<div
key={source.id}
id={source.id}
ref={(el) => {
cardRefs.current[source.id] = el;
}}
className={`bg-white dark:bg-warm-800 rounded-lg border p-5 ${
highlightedId === source.id
? 'border-teal-400 ring-2 ring-teal-400'
: 'border-warm-200 dark:border-warm-700'
}`}
>
{source.url}
</a>
{source.optOutUrl && (
<div className="mt-2">
<a
href={source.optOutUrl}
target="_blank"
rel="noopener noreferrer"
className="text-sm text-teal-600 dark:text-teal-400 hover:text-teal-800 dark:hover:text-teal-300 hover:underline"
>
{t('learnPage.optOut')}
</a>
<div className="flex items-start justify-between gap-4 mb-2">
<h2 className="text-lg font-semibold text-warm-900 dark:text-warm-100">
{tDynamic(nameKey)}
</h2>
<span className="text-xs bg-warm-100 dark:bg-navy-700 text-warm-600 dark:text-warm-300 px-2 py-1 rounded text-right">
{source.license}
</span>
</div>
)}
</div>
<p className="text-sm text-warm-500 dark:text-warm-400 mb-2">
{t('learnPage.source')} {tDynamic(originKey)}
</p>
<p className="text-sm text-warm-700 dark:text-warm-300 mb-3">
{tDynamic(useKey)}
</p>
<a
href={source.url}
target="_blank"
rel="noopener noreferrer"
className="text-sm text-teal-600 dark:text-teal-400 hover:text-teal-800 dark:hover:text-teal-300 hover:underline break-all"
>
{source.url}
</a>
{source.optOutUrl && (
<div className="mt-2">
<a
href={source.optOutUrl}
target="_blank"
rel="noopener noreferrer"
className="text-sm text-teal-600 dark:text-teal-400 hover:text-teal-800 dark:hover:text-teal-300 hover:underline"
>
{t('learnPage.optOut')}
</a>
</div>
)}
</div>
);
})}
</div>
@ -308,9 +382,7 @@ export default function LearnPage() {
</>
) : tab === 'faq' ? (
<div className="max-w-3xl mx-auto px-6 py-6 w-full">
<p className="text-warm-600 dark:text-warm-400 mb-6">
{t('learnPage.faqIntro')}
</p>
<p className="text-warm-600 dark:text-warm-400 mb-6">{t('learnPage.faqIntro')}</p>
<div className="space-y-8">
{FAQ_SECTIONS.map((section) => (
<div key={section.title}>
@ -328,9 +400,7 @@ export default function LearnPage() {
</div>
) : (
<div className="max-w-2xl mx-auto px-6 py-6 w-full">
<p className="text-warm-600 dark:text-warm-400 mb-6">
{t('learnPage.supportIntro')}
</p>
<p className="text-warm-600 dark:text-warm-400 mb-6">{t('learnPage.supportIntro')}</p>
<div className="bg-white dark:bg-warm-800 rounded-xl border border-warm-200 dark:border-warm-700 p-6 text-center">
<p className="text-warm-600 dark:text-warm-300 mb-2">{t('accountPage.needHelp')}</p>
<a

View file

@ -53,8 +53,19 @@ export default memo(function AiFilterInput({
const { t } = useTranslation();
const [query, setQuery] = useState('');
const [expanded, setExpanded] = useState(false);
const exampleQueries = useMemo(() => [t('aiFilter.example1'), t('aiFilter.example2'), t('aiFilter.example3')], [t]);
const loadingMessages = useMemo(() => [t('aiFilter.analysing'), t('aiFilter.searchingDestinations'), t('aiFilter.generatingFilters'), t('aiFilter.refiningResults')], [t]);
const exampleQueries = useMemo(
() => [t('aiFilter.example1'), t('aiFilter.example2'), t('aiFilter.example3')],
[t]
);
const loadingMessages = useMemo(
() => [
t('aiFilter.analysing'),
t('aiFilter.searchingDestinations'),
t('aiFilter.generatingFilters'),
t('aiFilter.refiningResults'),
],
[t]
);
const loadingMessage = useLoadingMessage(loading, loadingMessages);
const containerRef = useRef<HTMLDivElement>(null);
const textareaRef = useRef<HTMLTextAreaElement>(null);
@ -147,7 +158,9 @@ export default memo(function AiFilterInput({
<div ref={containerRef} className="px-3 py-2" data-tutorial="ai-filters">
<div className="flex items-center gap-1.5 mb-1.5">
<SparklesIcon className="w-3.5 h-3.5 text-teal-500 dark:text-teal-400 shrink-0" />
<span className="text-xs font-medium text-teal-700 dark:text-teal-300">{t('aiFilter.aiSearch')}</span>
<span className="text-xs font-medium text-teal-700 dark:text-teal-300">
{t('aiFilter.aiSearch')}
</span>
<span className="text-xs text-warm-400 dark:text-warm-500">
{t('aiFilter.describeHint')}
</span>

View file

@ -99,7 +99,9 @@ export default function AreaPane({
{isPostcode ? hexagonId : t('areaPane.areaStatistics')}
</h2>
{isPostcode && (
<span className="text-xs text-warm-500 dark:text-warm-400">{t('common.postcode')}</span>
<span className="text-xs text-warm-500 dark:text-warm-400">
{t('common.postcode')}
</span>
)}
</div>
{loading && stats && (
@ -112,7 +114,11 @@ export default function AreaPane({
</p>
)}
<p className="text-xs text-warm-500 dark:text-warm-400 mt-1">
{t('areaPane.statsFor', { type: isPostcode ? t('common.postcode').toLowerCase() : t('common.area').toLowerCase() })}
{t('areaPane.statsFor', {
type: isPostcode
? t('common.postcode').toLowerCase()
: t('common.area').toLowerCase(),
})}
{Object.keys(filters).length > 0 ? t('areaPane.matchingFilters') : ''}
</p>
{stats && stats.count > 0 && (
@ -150,7 +156,9 @@ export default function AreaPane({
return uniqueYears.size > 1;
})() && (
<div className="mx-3 mt-2 bg-warm-50 dark:bg-warm-800 rounded p-2">
<span className="text-xs text-warm-700 dark:text-warm-300">{t('areaPane.priceHistory')}</span>
<span className="text-xs text-warm-700 dark:text-warm-300">
{t('areaPane.priceHistory')}
</span>
<PriceHistoryChart points={stats.price_history} />
</div>
)}

View file

@ -1,4 +1,5 @@
import { useState, useMemo, useEffect } from 'react';
import { useTranslation } from 'react-i18next';
import { useCollapsibleGroups } from '../../hooks/useCollapsibleGroups';
import { useTravelModes } from '../../hooks/useTravelModes';
import { SearchInput } from '../ui/SearchInput';
@ -15,9 +16,8 @@ import { IconButton } from '../ui/IconButton';
import { TravelTimeInfoPopup } from '../ui/TravelTimeInfoPopup';
import {
TRANSPORT_MODES,
MODE_LABELS,
MODE_DESCRIPTIONS,
MODE_ICONS,
useTranslatedModes,
type TransportMode,
type TravelTimeEntry,
} from '../../hooks/useTravelTime';
@ -34,7 +34,6 @@ interface FeatureBrowserProps {
travelTimeEntries: TravelTimeEntry[];
onAddTravelTimeEntry: (mode: TransportMode) => void;
isLicensed: boolean;
onUpgradeClick?: () => void;
}
export default function FeatureBrowser({
@ -49,8 +48,9 @@ export default function FeatureBrowser({
travelTimeEntries: _travelTimeEntries,
onAddTravelTimeEntry,
isLicensed,
onUpgradeClick,
}: FeatureBrowserProps) {
const { t } = useTranslation();
const modes = useTranslatedModes();
const [search, setSearch] = useState('');
const [infoFeature, setInfoFeature] = useState<FeatureMeta | null>(null);
const [travelInfoMode, setTravelInfoMode] = useState<TransportMode | null>(null);
@ -102,9 +102,13 @@ export default function FeatureBrowser({
return (
<>
<div className="shrink-0 p-2 border-b border-warm-200 dark:border-navy-700">
<SearchInput value={search} onChange={setSearch} placeholder="Search features..." />
<SearchInput
value={search}
onChange={setSearch}
placeholder={t('filters.searchFeatures')}
/>
</div>
<div className="md:min-h-0 md:flex-1 md:overflow-y-auto flex flex-col">
<div>
{mergedGrouped.map((group) => {
const isExpanded = isSearching || isGroupExpanded(group.name);
return (
@ -158,24 +162,24 @@ export default function FeatureBrowser({
<ModeIcon className="w-4 h-4 text-teal-600 dark:text-teal-400 shrink-0" />
<div className="min-w-0">
<span className="text-sm font-medium text-navy-950 dark:text-warm-100">
{MODE_LABELS[mode]}
{modes.label(mode)}
</span>
<span className="text-xs text-warm-400 dark:text-warm-500 block">
{MODE_DESCRIPTIONS[mode]}
{modes.desc(mode)}
</span>
</div>
</div>
<div className="flex items-center gap-0.5 shrink-0">
<IconButton
onClick={() => setTravelInfoMode(mode)}
title="Feature info"
title={t('filters.featureInfo')}
size="md"
>
<InfoIcon className="w-5 h-5 md:w-3.5 md:h-3.5" />
</IconButton>
<button
onClick={() => onAddTravelTimeEntry(mode)}
title={`Add ${MODE_LABELS[mode]} travel time`}
title={t('travel.addTravelTime', { mode: modes.label(mode) })}
className="p-1 rounded-md text-teal-600 dark:text-teal-400 bg-teal-50 dark:bg-teal-900/30 hover:bg-teal-100 dark:hover:bg-teal-800/40"
>
<PlusIcon className="w-5 h-5 md:w-5 md:h-5" strokeWidth={2.5} />
@ -192,45 +196,15 @@ export default function FeatureBrowser({
{mergedGrouped.length === 0 ? (
<EmptyState
icon={<FilterIcon className="w-8 h-8 text-warm-300 dark:text-warm-600" />}
title={search ? 'No matching features' : 'All features are active'}
description={
search ? 'Try a different search term' : 'Remove a filter to see available features'
}
title={search ? t('filters.noMatchingFeatures') : t('filters.allFeaturesActive')}
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">
Choose the filters that matter to you. The map updates as you go.
{t('filters.chooseFilters')}
</p>
) : (
<div className="mt-auto flex flex-col items-center px-5 pt-6 pb-0">
<p className="text-sm text-warm-600 dark:text-warm-400 text-center leading-relaxed mb-1">
See crime, schools, noise, broadband, and 50+ more filters across all of England.
</p>
<p className="text-xs text-warm-400 dark:text-warm-500 text-center mb-4">
One-time payment, lifetime access.
</p>
<button
onClick={onUpgradeClick}
className="px-5 py-2.5 rounded-lg bg-gradient-to-r from-teal-500 to-teal-600 hover:from-teal-600 hover:to-teal-700 text-white font-medium text-sm shadow-sm hover:shadow-md"
>
Upgrade to full map
</button>
<svg
viewBox="0 120 1600 230"
className="w-full mt-4 block shrink-0"
preserveAspectRatio="xMidYMax meet"
>
<path
d="M0,350 C400,150 1200,150 1600,350 Z"
className="fill-green-500 dark:fill-green-600"
/>
<path d="M100,350 C450,180 1150,180 1500,350 Z" fill="#000" opacity="0.08" />
<path d="M250,350 C550,220 1050,220 1350,350 Z" fill="#000" opacity="0.06" />
<image href="/house.png" x="735" y="110" width="130" height="120" />
</svg>
</div>
)}
) : null}
</div>
{infoFeature && (
<FeatureInfoPopup

View file

@ -7,7 +7,12 @@ import { ChevronIcon, CloseIcon, LightbulbIcon, SpinnerIcon } from '../ui/icons'
import { PillToggle } from '../ui/PillToggle';
import { PillGroup } from '../ui/PillGroup';
import type { FeatureMeta, FeatureFilters } from '../../types';
import { formatFilterValue, formatNumber, parseInputValue, buildPercentileScale } from '../../lib/format';
import {
formatFilterValue,
formatNumber,
parseInputValue,
buildPercentileScale,
} from '../../lib/format';
import type { PercentileScale } from '../../lib/format';
import InfoPopup from '../ui/InfoPopup';
import { FeatureInfoPopup } from '../ui/FeatureInfoPopup';
@ -186,7 +191,13 @@ interface FiltersProps {
travelTimeEntries: TravelTimeEntry[];
onTravelTimeAddEntry: (mode: TransportMode) => void;
onTravelTimeRemoveEntry: (index: number) => void;
onTravelTimeSetDestination: (index: number, slug: string, label: string, lat: number, lon: number) => void;
onTravelTimeSetDestination: (
index: number,
slug: string,
label: string,
lat: number,
lon: number
) => void;
onTravelTimeRangeChange: (index: number, range: [number, number]) => void;
onTravelTimeDragEnd: (index: number) => void;
onTravelTimeToggleBest: (index: number) => void;
@ -472,10 +483,13 @@ export default memo(function Filters({
className="relative flex flex-col bg-white dark:bg-navy-950 overflow-y-auto md:overflow-hidden h-full touch-pan-y"
>
<div
className="flex flex-col min-h-0"
style={{
flex: activeFilterCollapsed ? '0 0 auto' : addFilterCollapsed ? '1 1 0' : '3 1 0',
}}
className={`flex flex-col md:min-h-0 ${
activeFilterCollapsed
? 'md:[flex:0_0_auto]'
: addFilterCollapsed
? 'md:[flex:1_1_0]'
: 'md:[flex:3_1_0]'
}`}
>
<button
onClick={() => setActiveFilterCollapsed((v) => !v)}
@ -518,291 +532,327 @@ export default memo(function Filters({
</div>
</button>
{!activeFilterCollapsed && <div ref={scrollRef} className="flex-1 min-h-0 overflow-y-auto overflow-x-hidden">
<AiFilterInput
loading={aiFilterLoading}
error={aiFilterError}
errorType={aiFilterErrorType}
notes={aiFilterNotes}
summary={aiFilterSummary}
onSubmit={onAiFilterSubmit}
isLoggedIn={isLoggedIn}
onLoginRequired={onLoginRequired}
/>
<div className="px-3 pb-2 space-y-2">
{isAdmin && (
<div className="flex rounded-lg bg-warm-100 dark:bg-warm-800 p-0.5">
{(['historical', 'buy', 'rent'] as const).map((type) => {
const labels = { historical: t('filters.historical'), buy: t('filters.buy'), rent: t('filters.rent') };
const isActive = activeListingType === type;
return (
<button
key={type}
onClick={() => handleListingSelect(type)}
className={`flex-1 px-3 py-1.5 text-sm font-medium rounded-md cursor-pointer ${
isActive
? 'bg-white dark:bg-warm-700 text-teal-600 dark:text-teal-400 ring-2 ring-teal-400 shadow-sm'
: 'text-warm-500 dark:text-warm-400 hover:text-warm-700 dark:hover:text-warm-300'
}`}
>
{labels[type]}
</button>
);
})}
</div>
{!activeFilterCollapsed && (
<div
ref={scrollRef}
className="md:flex-1 md:min-h-0 md:overflow-y-auto overflow-x-hidden"
>
<AiFilterInput
loading={aiFilterLoading}
error={aiFilterError}
errorType={aiFilterErrorType}
notes={aiFilterNotes}
summary={aiFilterSummary}
onSubmit={onAiFilterSubmit}
isLoggedIn={isLoggedIn}
onLoginRequired={onLoginRequired}
/>
<div className="px-3 pb-2 space-y-2">
{isAdmin && (
<div className="flex rounded-lg bg-warm-100 dark:bg-warm-800 p-0.5">
{(['historical', 'buy', 'rent'] as const).map((type) => {
const labels = {
historical: t('filters.historical'),
buy: t('filters.buy'),
rent: t('filters.rent'),
};
const isActive = activeListingType === type;
return (
<button
key={type}
onClick={() => handleListingSelect(type)}
className={`flex-1 px-3 py-1.5 text-sm font-medium rounded-md cursor-pointer ${
isActive
? 'bg-white dark:bg-warm-700 text-teal-600 dark:text-teal-400 ring-2 ring-teal-400 shadow-sm'
: 'text-warm-500 dark:text-warm-400 hover:text-warm-700 dark:hover:text-warm-300'
}`}
>
{labels[type]}
</button>
);
})}
</div>
)}
<button
onClick={() => setShowPhilosophy(true)}
className="w-full px-3 py-1.5 rounded-lg border border-warm-200 dark:border-warm-700 bg-white dark:bg-warm-800 hover:bg-warm-50 dark:hover:bg-warm-700 text-teal-600 dark:text-teal-400 font-medium text-sm flex items-center justify-center gap-2"
>
<LightbulbIcon />
{t('filters.findingPerfectPostcode')}
</button>
</div>
{enabledFeatureList.length === 0 && activeEntryCount === 0 && (
<p className="px-3 py-1.5 text-xs text-warm-400 dark:text-warm-500">
{t('filters.addFiltersHint')}
</p>
)}
<button
onClick={() => setShowPhilosophy(true)}
className="w-full px-3 py-1.5 rounded-lg border border-warm-200 dark:border-warm-700 bg-white dark:bg-warm-800 hover:bg-warm-50 dark:hover:bg-warm-700 text-teal-600 dark:text-teal-400 font-medium text-sm flex items-center justify-center gap-2"
>
<LightbulbIcon />
{t('filters.findingPerfectPostcode')}
</button>
</div>
{enabledFeatureList.length === 0 && activeEntryCount === 0 && (
<p className="px-3 py-1.5 text-xs text-warm-400 dark:text-warm-500">
{t('filters.addFiltersHint')}
</p>
)}
<div className="px-2 py-1 space-y-1">
{enabledFeatureList.map((feature, featureIdx) => {
if (feature.type === 'enum') {
const selectedValues = (filters[feature.name] as string[]) || [];
const allValues = feature.values || [];
<div className="px-2 py-1 space-y-1">
{enabledFeatureList.map((feature, featureIdx) => {
if (feature.type === 'enum') {
const selectedValues = (filters[feature.name] as string[]) || [];
const allValues = feature.values || [];
return (
<Fragment key={feature.name}>
{featureIdx === travelInsertIdx &&
travelTimeEntries.map((entry, index) => (
<div key={`tt_${index}`} data-filter-name={`tt_${index}`}>
<TravelTimeCard
mode={entry.mode}
slug={entry.slug}
label={entry.label}
timeRange={entry.timeRange}
useBest={entry.useBest}
isPinned={pinnedFeature === travelFieldKey(entry)}
isActive={activeFeature === travelFieldKey(entry)}
dragValue={activeFeature === travelFieldKey(entry) ? dragValue : null}
onTogglePin={() => onTogglePin(travelFieldKey(entry))}
onSetDestination={(slug, label, lat, lon) =>
onTravelTimeSetDestination(index, slug, label, lat, lon)
}
onTimeRangeChange={(range) => onTravelTimeRangeChange(index, range)}
onDragStart={() => onDragStart(travelFieldKey(entry))}
onDragChange={onDragChange}
onDragEnd={() => onTravelTimeDragEnd(index)}
onToggleBest={() => onTravelTimeToggleBest(index)}
onRemove={() => onTravelTimeRemoveEntry(index)}
filterImpact={filterImpacts?.[travelFieldKey(entry)]}
/>
</div>
))}
<div
data-filter-name={feature.name}
className={`space-y-0.5 px-2 py-1.5 rounded ${pinnedFeature === feature.name ? 'ring-2 ring-teal-400 bg-teal-50/50 dark:bg-teal-900/20' : ''}`}
>
<div className="flex items-center justify-between">
<FeatureLabel feature={feature} size="sm" />
<FeatureActions
feature={feature}
isPinned={pinnedFeature === feature.name}
onTogglePin={onTogglePin}
onShowInfo={setActiveInfoFeature}
onRemove={onRemoveFilter}
/>
</div>
<PillGroup>
{allValues.map((val) => (
<PillToggle
key={val}
label={ts(val)}
active={selectedValues.includes(val)}
onClick={() => {
const next = selectedValues.includes(val)
? selectedValues.filter((v) => v !== val)
: [...selectedValues, val];
onFilterChange(feature.name, next);
}}
size="xs"
/>
))}
</PillGroup>
{filterImpacts?.[feature.name] != null &&
filterImpacts[feature.name] > 0 && (
<p className="text-[10px] text-warm-400 dark:text-warm-500 mt-0.5">
+{formatNumber(filterImpacts[feature.name])} without this filter
</p>
)}
</div>
</Fragment>
);
}
const isActive = activeFeature === feature.name;
const isPinned = pinnedFeature === feature.name;
const hist = feature.histogram;
const displayValue =
isActive && dragValue
? dragValue
: (filters[feature.name] as [number, number]) || [
hist?.min ?? feature.min!,
hist?.max ?? feature.max!,
];
const scale = percentileScales.get(feature.name);
const dataMin = hist?.min ?? feature.min!;
const dataMax = hist?.max ?? feature.max!;
const clampMin = displayValue[0] <= dataMin;
const clampMax = displayValue[1] >= dataMax;
const isAtMin = displayValue[0] === dataMin;
const isAtMax = displayValue[1] === dataMax;
const sliderValue: [number, number] = scale
? [
clampMin ? 0 : Math.round(scale.toPercentile(displayValue[0])),
clampMax ? 100 : Math.round(scale.toPercentile(displayValue[1])),
]
: [
clampMin ? feature.min! : displayValue[0],
clampMax ? feature.max! : displayValue[1],
];
const mobileIconClass = 'w-4 h-4 text-teal-600 dark:text-teal-400 shrink-0';
const mobileIcon =
getFeatureIcon(feature.name, mobileIconClass) ||
(() => {
const G = feature.group ? getGroupIcon(feature.group) : null;
return G ? <G className={mobileIconClass} /> : null;
})();
return (
<Fragment key={feature.name}>
{featureIdx === travelInsertIdx && travelTimeEntries.map((entry, index) => (
<div key={`tt_${index}`} data-filter-name={`tt_${index}`}>
<TravelTimeCard
mode={entry.mode}
slug={entry.slug}
label={entry.label}
timeRange={entry.timeRange}
useBest={entry.useBest}
isPinned={pinnedFeature === travelFieldKey(entry)}
isActive={activeFeature === travelFieldKey(entry)}
dragValue={activeFeature === travelFieldKey(entry) ? dragValue : null}
onTogglePin={() => onTogglePin(travelFieldKey(entry))}
onSetDestination={(slug, label, lat, lon) => onTravelTimeSetDestination(index, slug, label, lat, lon)}
onTimeRangeChange={(range) => onTravelTimeRangeChange(index, range)}
onDragStart={() => onDragStart(travelFieldKey(entry))}
onDragChange={onDragChange}
onDragEnd={() => onTravelTimeDragEnd(index)}
onToggleBest={() => onTravelTimeToggleBest(index)}
onRemove={() => onTravelTimeRemoveEntry(index)}
filterImpact={filterImpacts?.[travelFieldKey(entry)]}
/>
</div>
))}
{featureIdx === travelInsertIdx &&
travelTimeEntries.map((entry, index) => (
<div key={`tt_${index}`} data-filter-name={`tt_${index}`}>
<TravelTimeCard
mode={entry.mode}
slug={entry.slug}
label={entry.label}
timeRange={entry.timeRange}
useBest={entry.useBest}
isPinned={pinnedFeature === travelFieldKey(entry)}
isActive={activeFeature === travelFieldKey(entry)}
dragValue={activeFeature === travelFieldKey(entry) ? dragValue : null}
onTogglePin={() => onTogglePin(travelFieldKey(entry))}
onSetDestination={(slug, label, lat, lon) =>
onTravelTimeSetDestination(index, slug, label, lat, lon)
}
onTimeRangeChange={(range) => onTravelTimeRangeChange(index, range)}
onDragStart={() => onDragStart(travelFieldKey(entry))}
onDragChange={onDragChange}
onDragEnd={() => onTravelTimeDragEnd(index)}
onToggleBest={() => onTravelTimeToggleBest(index)}
onRemove={() => onTravelTimeRemoveEntry(index)}
filterImpact={filterImpacts?.[travelFieldKey(entry)]}
/>
</div>
))}
<div
data-filter-name={feature.name}
className={`space-y-0.5 px-2 py-1.5 rounded ${pinnedFeature === feature.name ? 'ring-2 ring-teal-400 bg-teal-50/50 dark:bg-teal-900/20' : ''}`}
className={`space-y-0.5 px-2 py-1.5 rounded ${isActive ? 'ring-2 ring-teal-400 bg-teal-50 dark:bg-teal-900/30' : isPinned ? 'ring-2 ring-teal-400 bg-teal-50/50 dark:bg-teal-900/20' : ''}`}
>
<div className="flex items-center justify-between">
<FeatureLabel feature={feature} size="sm" />
<div className="relative z-10 flex items-center justify-between gap-1">
<FeatureLabel
feature={feature}
size="sm"
className="min-w-0 shrink"
hideIconOnMobile
/>
<FeatureActions
feature={feature}
isPinned={pinnedFeature === feature.name}
isPinned={isPinned}
onTogglePin={onTogglePin}
onShowInfo={setActiveInfoFeature}
onRemove={onRemoveFilter}
/>
</div>
<PillGroup>
{allValues.map((val) => (
<PillToggle
key={val}
label={ts(val)}
active={selectedValues.includes(val)}
onClick={() => {
const next = selectedValues.includes(val)
? selectedValues.filter((v) => v !== val)
: [...selectedValues, val];
onFilterChange(feature.name, next);
}}
size="xs"
<div className="flex md:block items-start gap-1.5">
{mobileIcon && (
<div className="md:hidden shrink-0 pt-0.5">{mobileIcon}</div>
)}
<div className="min-w-0 flex-1">
<Slider
min={scale ? 0 : feature.min!}
max={scale ? 100 : feature.max!}
step={scale ? 1 : (feature.step ?? (feature.max! - feature.min!) / 100)}
value={sliderValue}
onValueChange={
scale
? ([pMin, pMax]) => {
const step = feature.step ?? 1;
const snap = (v: number) => Math.round(v / step) * step;
onDragChange([
pMin <= 0
? (hist?.min ?? feature.min!)
: snap(scale.toValue(pMin)),
pMax >= 100
? (hist?.max ?? feature.max!)
: snap(scale.toValue(pMax)),
]);
}
: ([min, max]) =>
onDragChange([
min <= feature.min! ? (hist?.min ?? feature.min!) : min,
max >= feature.max! ? (hist?.max ?? feature.max!) : max,
])
}
onPointerDown={() => onDragStart(feature.name)}
onPointerUp={() => onDragEnd()}
/>
))}
</PillGroup>
{filterImpacts?.[feature.name] != null && filterImpacts[feature.name] > 0 && (
<p className="text-[10px] text-warm-400 dark:text-warm-500 mt-0.5">
+{formatNumber(filterImpacts[feature.name])} without this filter
</p>
)}
<SliderLabels
min={scale ? 0 : feature.min!}
max={scale ? 100 : feature.max!}
value={sliderValue}
displayValues={displayValue}
isAtMin={isAtMin}
isAtMax={isAtMax}
raw={feature.raw}
feature={feature}
onValueChange={(v) => onFilterChange(feature.name, v)}
/>
{filterImpacts?.[feature.name] != null &&
filterImpacts[feature.name] > 0 && (
<p className="text-[10px] text-warm-400 dark:text-warm-500 -mt-1 ml-2.5">
+{formatNumber(filterImpacts[feature.name])} without this filter
</p>
)}
</div>
</div>
</div>
</Fragment>
);
}
const isActive = activeFeature === feature.name;
const isPinned = pinnedFeature === feature.name;
const hist = feature.histogram;
const displayValue =
isActive && dragValue
? dragValue
: (filters[feature.name] as [number, number]) || [
hist?.min ?? feature.min!,
hist?.max ?? feature.max!,
];
const scale = percentileScales.get(feature.name);
const dataMin = hist?.min ?? feature.min!;
const dataMax = hist?.max ?? feature.max!;
const clampMin = displayValue[0] <= dataMin;
const clampMax = displayValue[1] >= dataMax;
const isAtMin = displayValue[0] === dataMin;
const isAtMax = displayValue[1] === dataMax;
const sliderValue: [number, number] = scale
? [
clampMin ? 0 : Math.round(scale.toPercentile(displayValue[0])),
clampMax ? 100 : Math.round(scale.toPercentile(displayValue[1])),
]
: [
clampMin ? feature.min! : displayValue[0],
clampMax ? feature.max! : displayValue[1],
];
const mobileIconClass = 'w-4 h-4 text-teal-600 dark:text-teal-400 shrink-0';
const mobileIcon = getFeatureIcon(feature.name, mobileIconClass) || (() => {
const G = feature.group ? getGroupIcon(feature.group) : null;
return G ? <G className={mobileIconClass} /> : null;
})();
return (
<Fragment key={feature.name}>
{featureIdx === travelInsertIdx && travelTimeEntries.map((entry, index) => (
<div key={`tt_${index}`} data-filter-name={`tt_${index}`}>
<TravelTimeCard
mode={entry.mode}
slug={entry.slug}
label={entry.label}
timeRange={entry.timeRange}
useBest={entry.useBest}
isPinned={pinnedFeature === travelFieldKey(entry)}
isActive={activeFeature === travelFieldKey(entry)}
dragValue={activeFeature === travelFieldKey(entry) ? dragValue : null}
onTogglePin={() => onTogglePin(travelFieldKey(entry))}
onSetDestination={(slug, label, lat, lon) => onTravelTimeSetDestination(index, slug, label, lat, lon)}
onTimeRangeChange={(range) => onTravelTimeRangeChange(index, range)}
onDragStart={() => onDragStart(travelFieldKey(entry))}
onDragChange={onDragChange}
onDragEnd={() => onTravelTimeDragEnd(index)}
onToggleBest={() => onTravelTimeToggleBest(index)}
onRemove={() => onTravelTimeRemoveEntry(index)}
filterImpact={filterImpacts?.[travelFieldKey(entry)]}
/>
</div>
))}
<div
data-filter-name={feature.name}
className={`space-y-0.5 px-2 py-1.5 rounded ${isActive ? 'ring-2 ring-teal-400 bg-teal-50 dark:bg-teal-900/30' : isPinned ? 'ring-2 ring-teal-400 bg-teal-50/50 dark:bg-teal-900/20' : ''}`}
>
<div className="relative z-10 flex items-center justify-between gap-1">
<FeatureLabel feature={feature} size="sm" className="min-w-0 shrink" hideIconOnMobile />
<FeatureActions
feature={feature}
isPinned={isPinned}
onTogglePin={onTogglePin}
onShowInfo={setActiveInfoFeature}
onRemove={onRemoveFilter}
/>
</div>
<div className="flex md:block items-start gap-1.5">
{mobileIcon && <div className="md:hidden shrink-0 pt-0.5">{mobileIcon}</div>}
<div className="min-w-0 flex-1">
<Slider
min={scale ? 0 : feature.min!}
max={scale ? 100 : feature.max!}
step={scale ? 1 : (feature.step ?? (feature.max! - feature.min!) / 100)}
value={sliderValue}
onValueChange={
scale
? ([pMin, pMax]) => {
const step = feature.step ?? 1;
const snap = (v: number) => Math.round(v / step) * step;
onDragChange([
pMin <= 0 ? (hist?.min ?? feature.min!) : snap(scale.toValue(pMin)),
pMax >= 100
? (hist?.max ?? feature.max!)
: snap(scale.toValue(pMax)),
]);
}
: ([min, max]) =>
onDragChange([
min <= feature.min! ? (hist?.min ?? feature.min!) : min,
max >= feature.max! ? (hist?.max ?? feature.max!) : max,
])
}
onPointerDown={() => onDragStart(feature.name)}
onPointerUp={() => onDragEnd()}
/>
<SliderLabels
min={scale ? 0 : feature.min!}
max={scale ? 100 : feature.max!}
value={sliderValue}
displayValues={displayValue}
isAtMin={isAtMin}
isAtMax={isAtMax}
raw={feature.raw}
feature={feature}
onValueChange={(v) => onFilterChange(feature.name, v)}
/>
{filterImpacts?.[feature.name] != null && filterImpacts[feature.name] > 0 && (
<p className="text-[10px] text-warm-400 dark:text-warm-500 -mt-1 ml-2.5">
+{formatNumber(filterImpacts[feature.name])} without this filter
</p>
)}
</div>
</div>
})}
{travelInsertIdx >= enabledFeatureList.length &&
travelTimeEntries.map((entry, index) => (
<div key={`tt_${index}`} data-filter-name={`tt_${index}`}>
<TravelTimeCard
mode={entry.mode}
slug={entry.slug}
label={entry.label}
timeRange={entry.timeRange}
useBest={entry.useBest}
isPinned={pinnedFeature === travelFieldKey(entry)}
isActive={activeFeature === travelFieldKey(entry)}
dragValue={activeFeature === travelFieldKey(entry) ? dragValue : null}
onTogglePin={() => onTogglePin(travelFieldKey(entry))}
onSetDestination={(slug, label, lat, lon) =>
onTravelTimeSetDestination(index, slug, label, lat, lon)
}
onTimeRangeChange={(range) => onTravelTimeRangeChange(index, range)}
onDragStart={() => onDragStart(travelFieldKey(entry))}
onDragChange={onDragChange}
onDragEnd={() => onTravelTimeDragEnd(index)}
onToggleBest={() => onTravelTimeToggleBest(index)}
onRemove={() => onTravelTimeRemoveEntry(index)}
filterImpact={filterImpacts?.[travelFieldKey(entry)]}
/>
</div>
</Fragment>
);
})}
{travelInsertIdx >= enabledFeatureList.length && travelTimeEntries.map((entry, index) => (
<div key={`tt_${index}`} data-filter-name={`tt_${index}`}>
<TravelTimeCard
mode={entry.mode}
slug={entry.slug}
label={entry.label}
timeRange={entry.timeRange}
useBest={entry.useBest}
isPinned={pinnedFeature === travelFieldKey(entry)}
isActive={activeFeature === travelFieldKey(entry)}
dragValue={activeFeature === travelFieldKey(entry) ? dragValue : null}
onTogglePin={() => onTogglePin(travelFieldKey(entry))}
onSetDestination={(slug, label, lat, lon) => onTravelTimeSetDestination(index, slug, label, lat, lon)}
onTimeRangeChange={(range) => onTravelTimeRangeChange(index, range)}
onDragStart={() => onDragStart(travelFieldKey(entry))}
onDragChange={onDragChange}
onDragEnd={() => onTravelTimeDragEnd(index)}
onToggleBest={() => onTravelTimeToggleBest(index)}
onRemove={() => onTravelTimeRemoveEntry(index)}
filterImpact={filterImpacts?.[travelFieldKey(entry)]}
/>
</div>
))}
))}
</div>
</div>
</div>}
)}
</div>
<div
className="flex flex-col min-h-0 border-t border-warm-200 dark:border-warm-700"
style={{
flex: addFilterCollapsed ? '0 0 auto' : activeFilterCollapsed ? '1 1 0' : '2 1 0',
}}
className={`flex flex-col md:min-h-0 border-t border-warm-200 dark:border-warm-700 ${
addFilterCollapsed
? 'md:[flex:0_0_auto]'
: activeFilterCollapsed
? 'md:[flex:1_1_0]'
: 'md:[flex:2_1_0]'
}`}
>
<button
onClick={() => setAddFilterCollapsed((v) => !v)}
className="shrink-0 flex items-center justify-between px-3 py-2 border-b border-warm-200 dark:border-navy-700 bg-teal-50 dark:bg-teal-900/30 cursor-pointer hover:bg-teal-100 dark:hover:bg-teal-900/50"
>
<span className="text-sm font-semibold text-teal-700 dark:text-teal-400">{t('filters.addFilter')}</span>
<span className="text-sm font-semibold text-teal-700 dark:text-teal-400">
{t('filters.addFilter')}
</span>
<ChevronIcon
direction={addFilterCollapsed ? 'down' : 'up'}
className="w-4 h-4 text-warm-400 dark:text-warm-500"
/>
</button>
{!addFilterCollapsed && (
<div className="flex-1 min-h-0 overflow-y-auto">
<div className="md:flex-1 md:min-h-0 md:overflow-y-auto">
<FeatureBrowser
availableFeatures={availableFeatures}
allFeatures={features}
@ -850,11 +900,12 @@ export default memo(function Filters({
</div>
{showPhilosophy && (
<InfoPopup title={t('filters.findingPerfectPostcode')} onClose={() => setShowPhilosophy(false)}>
<InfoPopup
title={t('filters.findingPerfectPostcode')}
onClose={() => setShowPhilosophy(false)}
>
<div className="space-y-4 text-sm">
<p className="text-warm-600 dark:text-warm-300">
{t('philosophy.intro')}
</p>
<p className="text-warm-600 dark:text-warm-300">{t('philosophy.intro')}</p>
<div className="space-y-2">
{([1, 2, 3, 4, 5, 6] as const).map((n) => (
@ -872,9 +923,7 @@ export default memo(function Filters({
))}
</div>
<p className="text-warm-500 dark:text-warm-400 italic text-xs">
{t('philosophy.tip')}
</p>
<p className="text-warm-500 dark:text-warm-400 italic text-xs">{t('philosophy.tip')}</p>
{onResetTutorial && (
<button
@ -900,7 +949,10 @@ export default memo(function Filters({
)}
{showClearPopup && (
<div className="fixed inset-0 z-50 flex items-center justify-center" onClick={() => setShowClearPopup(false)}>
<div
className="fixed inset-0 z-50 flex items-center justify-center"
onClick={() => setShowClearPopup(false)}
>
<div className="absolute inset-0 bg-black/50 dark:bg-black/70" />
<div
className="relative w-full max-w-sm mx-4 bg-white dark:bg-warm-900 rounded-lg shadow-xl border border-warm-200 dark:border-warm-700"

View file

@ -8,19 +8,27 @@ export default function HistogramLegend() {
<div className="flex items-center gap-2">
<div className="w-3 h-3 bg-teal-500 dark:bg-teal-400 rounded" />
<span className="text-warm-700 dark:text-warm-300">
<span className="font-medium text-warm-900 dark:text-warm-100">{t('histogramLegend.tealBars')}</span> {t('histogramLegend.tealBarsDesc')}
<span className="font-medium text-warm-900 dark:text-warm-100">
{t('histogramLegend.tealBars')}
</span>{' '}
{t('histogramLegend.tealBarsDesc')}
</span>
</div>
<div className="flex items-center gap-2">
<div className="w-3 h-3 bg-warm-300/60 dark:bg-warm-600/60 rounded" />
<span className="text-warm-700 dark:text-warm-300">
<span className="font-medium text-warm-900 dark:text-warm-100">{t('histogramLegend.greyBars')}</span> {t('histogramLegend.greyBarsDesc')}
<span className="font-medium text-warm-900 dark:text-warm-100">
{t('histogramLegend.greyBars')}
</span>{' '}
{t('histogramLegend.greyBarsDesc')}
</span>
</div>
<div className="flex items-center gap-2">
<div className="w-3 h-px border-t border-dashed border-warm-500 dark:border-warm-400" />
<span className="text-warm-700 dark:text-warm-300">
<span className="font-medium text-warm-900 dark:text-warm-100">{t('histogramLegend.dashedLine')}</span>{' '}
<span className="font-medium text-warm-900 dark:text-warm-100">
{t('histogramLegend.dashedLine')}
</span>{' '}
{t('histogramLegend.dashedLineDesc')}
</span>
</div>

View file

@ -95,7 +95,8 @@ export default memo(function HoverCard({
{/* Property count */}
{count != null && (
<div className="text-xs text-warm-500 dark:text-warm-300 mb-2">
{count.toLocaleString()} {count === 1 ? t('common.property') : t('common.propertiesPlural')}
{count.toLocaleString()}{' '}
{count === 1 ? t('common.property') : t('common.propertiesPlural')}
</div>
)}

View file

@ -125,7 +125,8 @@ function TimelineLeg({ leg, isLast }: { leg: JourneyLeg; isLast: boolean }) {
<BicycleIcon className="w-3 h-3 text-warm-400 dark:text-warm-500 shrink-0" />
)}
<span className="text-[11px] text-warm-500 dark:text-warm-400">
{leg.mode === 'walk' ? t('areaPane.walk') : t('areaPane.cycle')} · {leg.minutes} {t('common.min')}
{leg.mode === 'walk' ? t('areaPane.walk') : t('areaPane.cycle')} · {leg.minutes}{' '}
{t('common.min')}
</span>
</div>
</div>
@ -145,7 +146,9 @@ function TimelineLeg({ leg, isLast }: { leg: JourneyLeg; isLast: boolean }) {
<div className="pb-1.5 min-w-0 flex-1">
<div className="flex items-center gap-1.5 flex-wrap">
<RouteBadge mode={leg.mode} />
<span className="text-[11px] text-warm-500 dark:text-warm-400">{leg.minutes} {t('common.min')}</span>
<span className="text-[11px] text-warm-500 dark:text-warm-400">
{leg.minutes} {t('common.min')}
</span>
</div>
{leg.from && leg.to && (
<div className="text-[11px] text-warm-600 dark:text-warm-300 mt-0.5">
@ -231,7 +234,9 @@ export default function JourneyInstructions({
return (
<div 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>
<div className="text-xs text-warm-500 dark:text-warm-400">
{t('areaPane.journeysFrom', { label })}
</div>
)}
{journeys.map((j) => {
const displayLegs = j.legs ? invertLegs(j.legs) : null;
@ -253,7 +258,9 @@ export default function JourneyInstructions({
{j.loading ? (
<div className="flex items-center gap-2 py-1">
<div className="w-3 h-3 border-2 border-teal-600 dark:border-teal-400 border-t-transparent rounded-full animate-spin" />
<span className="text-xs text-warm-500 dark:text-warm-400">{t('common.loading')}</span>
<span className="text-xs text-warm-500 dark:text-warm-400">
{t('common.loading')}
</span>
</div>
) : displayLegs && displayLegs.length > 0 ? (
<div>

View file

@ -136,15 +136,26 @@ export default memo(function Map({
const container = containerRef.current;
if (!container) return;
let resizeTimer: ReturnType<typeof setTimeout> | null = null;
let initialized = false;
const observer = new ResizeObserver((entries) => {
const { width, height } = entries[0].contentRect;
if (width > 0 && height > 0) {
setDimensions({ width, height });
if (!initialized) {
initialized = true;
setDimensions({ width, height });
} else {
if (resizeTimer) clearTimeout(resizeTimer);
resizeTimer = setTimeout(() => setDimensions({ width, height }), 150);
}
}
});
observer.observe(container);
return () => observer.disconnect();
return () => {
observer.disconnect();
if (resizeTimer) clearTimeout(resizeTimer);
};
}, []);
useEffect(() => {
@ -209,7 +220,13 @@ export default memo(function Map({
{...viewState}
onMove={handleMove}
onLoad={undefined}
onIdle={screenshotMode ? () => { window.__map_idle = true; } : undefined}
onIdle={
screenshotMode
? () => {
window.__map_idle = true;
}
: undefined
}
mapStyle={mapStyle}
style={{ width: '100%', height: '100%' }}
attributionControl={false}
@ -222,9 +239,7 @@ export default memo(function Map({
maxBounds={MAP_BOUNDS}
>
<DeckOverlay layers={layers} getTooltip={null} />
{!screenshotMode && (
<ScaleControl position="bottom-left" maxWidth={100} unit="metric" />
)}
{!screenshotMode && <ScaleControl position="bottom-left" maxWidth={100} unit="metric" />}
</MapGL>
{screenshotMode ? (
ogMode ? (
@ -233,7 +248,10 @@ export default memo(function Map({
<div className="flex-1 flex items-center justify-center">
<div className="flex items-center gap-8 bg-navy-900/90 rounded-3xl px-14 py-10">
<LogoIcon className="w-24 h-24 text-teal-400" />
<span className="font-bold text-white whitespace-nowrap" style={{ fontSize: '5rem' }}>
<span
className="font-bold text-white whitespace-nowrap"
style={{ fontSize: '5rem' }}
>
Your perfect postcode
</span>
</div>
@ -280,7 +298,11 @@ export default memo(function Map({
(viewFeature && colorRange ? (
viewFeature.startsWith('tt_') ? (
<MapLegend
featureLabel={t('travel.travelTime', { mode: modes.label(viewFeature.split('_')[1] as 'car' | 'bicycle' | 'walking' | 'transit') })}
featureLabel={t('travel.travelTime', {
mode: modes.label(
viewFeature.split('_')[1] as 'car' | 'bicycle' | 'walking' | 'transit'
),
})}
range={colorRange}
showCancel={viewSource === 'eye'}
onCancel={onCancelPin}
@ -315,7 +337,8 @@ export default memo(function Map({
: [countRange.min, countRange.max]
}
totalCount={
totalCountProp ?? (usePostcodeView ? postcodeCountRange.total : countRange.total)
totalCountProp ??
(usePostcodeView ? postcodeCountRange.total : countRange.total)
}
showCancel={false}
onCancel={onCancelPin}

View file

@ -37,6 +37,7 @@ import {
} from '../../hooks/useTravelTime';
import { apiUrl, authHeaders, buildFilterString, logNonAbortError } from '../../lib/api';
import { useFilterCounts } from '../../hooks/useFilterCounts';
import { ts } from '../../i18n/server';
import { trackEvent } from '../../lib/analytics';
import { INITIAL_VIEW_STATE } from '../../lib/consts';
import { useLicense } from '../../hooks/useLicense';
@ -77,6 +78,8 @@ interface MapPageProps {
isPropertySaved?: (address?: string, postcode?: string) => boolean;
getSavedPropertyId?: (address?: string, postcode?: string) => string | undefined;
deferTutorial?: boolean;
onSaveSearch?: (name: string) => Promise<void>;
savingSearch?: boolean;
}
export default function MapPage({
@ -105,12 +108,20 @@ export default function MapPage({
isPropertySaved,
getSavedPropertyId,
deferTutorial = false,
onSaveSearch,
savingSearch,
}: MapPageProps) {
const [selectedPOICategories, setSelectedPOICategories] =
useState<Set<string>>(initialPOICategories);
const [leftPaneWidth, leftPaneHandlers] = usePaneResize(384, 200, 0.45, 'left');
const [rightPaneWidth, rightPaneHandlers] = usePaneResize(384, 200, 0.45, 'right');
const [mobileMapHeight, mobileResizeHandlers, mobileMapRef] = usePaneResize(
Math.round(window.innerHeight * 0.4),
120,
0.8,
'top'
);
const [mobileDrawerOpen, setMobileDrawerOpen] = useState(false);
const [poiPaneOpen, setPoiPaneOpen] = useState(false);
@ -150,7 +161,6 @@ export default function MapPage({
handleDragEnd,
handleDragEndNoCommit,
handleTogglePin,
handleSetPin,
handleCancelPin,
} = useFilters({
initialFilters,
@ -163,14 +173,6 @@ export default function MapPage({
const handleAiFilterSubmit = useCallback(
async (query: string) => {
// Derive current listing type from Listing status filter
const listingVal = filters['Listing status'] as string[] | undefined;
const listingType = listingVal?.includes('For sale')
? 'buy'
: listingVal?.includes('For rent')
? 'rent'
: 'historical';
// Build context from current filters for conversational refinement
const context = {
filters,
@ -183,11 +185,7 @@ export default function MapPage({
};
const hasContext = Object.keys(context.filters).length > 0 || context.travelTime.length > 0;
const result = await aiFilters.fetchAiFilters(
query,
hasContext ? context : undefined,
listingType
);
const result = await aiFilters.fetchAiFilters(query, hasContext ? context : undefined);
if (!result) return;
handleSetFilters(result.filters);
// Always sync travel time entries — clear stale ones when AI returns none
@ -209,6 +207,12 @@ export default function MapPage({
]
);
const handleClearAll = useCallback(() => {
handleSetFilters({});
handleCancelPin();
travelTime.handleSetEntries([]);
}, [handleSetFilters, handleCancelPin, travelTime.handleSetEntries]);
const handleTravelTimeRemoveEntry = useCallback(
(index: number) => {
const entry = travelTime.entries[index];
@ -240,7 +244,7 @@ export default function MapPage({
travelTimeEntries: travelTime.entries,
});
const filterCounts = useFilterCounts(filters, features, mapData.bounds);
const filterCounts = useFilterCounts(filters, features, mapData.bounds, travelTime.entries);
const handleTravelTimeSetDestination = useCallback(
(index: number, slug: string, label: string, lat: number, lon: number) => {
@ -439,10 +443,10 @@ export default function MapPage({
const densityLabel = useMemo(() => {
const listingVal = filters['Listing status'] as string[] | undefined;
if (listingVal?.includes('For sale')) return 'Properties for sale';
if (listingVal?.includes('For rent')) return 'Properties for rent';
return 'Historical property matches';
}, [filters]);
if (listingVal?.includes('For sale')) return t('mapLegend.propertiesForSale');
if (listingVal?.includes('For rent')) return t('mapLegend.propertiesForRent');
return t('mapLegend.historicalMatches');
}, [filters, t]);
const mobileLegendMeta = useMemo(
() => (viewFeature ? features.find((f) => f.name === viewFeature) || null : null),
@ -634,6 +638,9 @@ export default function MapPage({
onUpgradeClick={() => onNavigateTo('pricing')}
onResetTutorial={tutorial.resetTutorial}
filterImpacts={filterCounts.impacts}
onClearAll={handleClearAll}
onSaveSearch={onSaveSearch}
savingSearch={savingSearch}
/>
);
@ -651,7 +658,11 @@ export default function MapPage({
</div>
)}
<div className="relative overflow-hidden" style={{ flex: '45 0 0' }}>
<div
ref={mobileMapRef}
className="relative overflow-hidden"
style={{ height: mobileMapHeight }}
>
<Map
data={mapData.data}
postcodeData={mapData.postcodeData}
@ -702,13 +713,27 @@ export default function MapPage({
</div>
<div
className="bg-white dark:bg-warm-900 border-t border-warm-200 dark:border-warm-700 overflow-hidden flex flex-col"
style={{ flex: '55 0 0' }}
className="relative z-10 py-2 -my-2 cursor-row-resize touch-none group"
{...mobileResizeHandlers}
>
<div className="h-3 flex items-center justify-center bg-warm-100 dark:bg-navy-800 group-hover:bg-warm-200 dark:group-hover:bg-navy-700 border-y border-warm-200 dark:border-navy-700">
<div className="flex flex-row gap-1.5">
<div className="w-1 h-1 rounded-full bg-warm-300 dark:bg-navy-600 group-hover:bg-warm-400 dark:group-hover:bg-navy-500" />
<div className="w-1 h-1 rounded-full bg-warm-300 dark:bg-navy-600 group-hover:bg-warm-400 dark:group-hover:bg-navy-500" />
<div className="w-1 h-1 rounded-full bg-warm-300 dark:bg-navy-600 group-hover:bg-warm-400 dark:group-hover:bg-navy-500" />
</div>
</div>
</div>
<div className="flex-1 min-h-0 bg-white dark:bg-warm-900 overflow-hidden flex flex-col">
{viewFeature && mapData.colorRange ? (
viewFeature.startsWith('tt_') ? (
<MapLegend
featureLabel={t('travel.travelTime', { mode: modes.label(viewFeature.split('_')[1] as 'car' | 'bicycle' | 'walking' | 'transit') })}
featureLabel={t('travel.travelTime', {
mode: modes.label(
viewFeature.split('_')[1] as 'car' | 'bicycle' | 'walking' | 'transit'
),
})}
range={mapData.colorRange}
showCancel={viewSource === 'eye'}
onCancel={handleCancelPin}
@ -721,8 +746,8 @@ export default function MapPage({
<MapLegend
featureLabel={
viewSource === 'eye'
? `Previewing \u201c${mobileLegendMeta.name}\u201d`
: mobileLegendMeta.name
? t('mapLegend.previewing', { name: ts(mobileLegendMeta.name) })
: ts(mobileLegendMeta.name)
}
range={mapData.colorRange}
showCancel={viewSource === 'eye'}
@ -848,6 +873,7 @@ export default function MapPage({
bounds={mapData.bounds}
travelTimeEntries={travelTime.entries}
densityLabel={densityLabel}
totalCount={filterCounts.total || undefined}
/>
{mapData.loading && (
<div className="absolute inset-0 flex items-center justify-center pointer-events-none">

View file

@ -37,7 +37,11 @@ export default function MobileDrawer({
<div className="h-[90%] bg-white dark:bg-navy-950 rounded-t-2xl flex flex-col shadow-xl overflow-hidden">
{/* Tab bar + close */}
<div className="flex border-b border-warm-200 dark:border-navy-700 text-sm shrink-0">
<TabButton label={t('common.area')} isActive={tab === 'area'} onClick={() => onTabChange('area')} />
<TabButton
label={t('common.area')}
isActive={tab === 'area'}
onClick={() => onTabChange('area')}
/>
<TabButton
label={t('common.properties')}
isActive={tab === 'properties'}

View file

@ -230,7 +230,9 @@ function PropertyCard({
{askingRent !== undefined && (
<div className="mt-2 text-lg font-bold text-teal-700 dark:text-teal-400">
£{formatNumber(askingRent)}
<span className="text-sm font-normal text-warm-600 dark:text-warm-400">{t('propertyCard.perMonth')}</span>
<span className="text-sm font-normal text-warm-600 dark:text-warm-400">
{t('propertyCard.perMonth')}
</span>
</div>
)}
@ -275,7 +277,8 @@ function PropertyCard({
<div className="mt-2 grid grid-cols-2 gap-x-4 gap-y-1 text-sm dark:text-warm-300">
{property.property_type && (
<div>
<span className="text-warm-500 dark:text-warm-400">{t('propertyCard.type')}</span> {ts(property.property_type)}
<span className="text-warm-500 dark:text-warm-400">{t('propertyCard.type')}</span>{' '}
{ts(property.property_type)}
</div>
)}
{property.built_form && (
@ -310,7 +313,8 @@ function PropertyCard({
)}
{rooms !== undefined && (
<div>
<span className="text-warm-500 dark:text-warm-400">{t('propertyCard.rooms')}</span> {formatNumber(rooms)}
<span className="text-warm-500 dark:text-warm-400">{t('propertyCard.rooms')}</span>{' '}
{formatNumber(rooms)}
</div>
)}
{age !== undefined && (
@ -319,6 +323,14 @@ function PropertyCard({
{formatAge(age, property.is_construction_date_approximate)}
</div>
)}
{property.former_council_house === 'Yes' && (
<div>
<span className="text-warm-500 dark:text-warm-400">
{t('propertyCard.formerCouncil')}
</span>{' '}
{ts(property.former_council_house)}
</div>
)}
{property.current_energy_rating && (
<div>
<span className="text-warm-500 dark:text-warm-400">{t('propertyCard.epcRating')}</span>{' '}
@ -327,7 +339,9 @@ function PropertyCard({
)}
{property.potential_energy_rating && (
<div>
<span className="text-warm-500 dark:text-warm-400">{t('propertyCard.epcPotential')}</span>{' '}
<span className="text-warm-500 dark:text-warm-400">
{t('propertyCard.epcPotential')}
</span>{' '}
{ts(property.potential_energy_rating)}
</div>
)}
@ -341,7 +355,9 @@ function PropertyCard({
{property.listing_features && property.listing_features.length > 0 && (
<div className="mt-2">
<div className="text-xs text-warm-500 dark:text-warm-400 mb-1">{t('propertyCard.keyFeatures')}</div>
<div className="text-xs text-warm-500 dark:text-warm-400 mb-1">
{t('propertyCard.keyFeatures')}
</div>
<div className="flex flex-wrap gap-1">
{property.listing_features.map((feature, idx) => (
<span
@ -357,7 +373,9 @@ function PropertyCard({
{property.renovation_history && property.renovation_history.length > 0 && (
<div className="mt-2">
<div className="text-xs text-warm-500 dark:text-warm-400 mb-1">{t('propertyCard.renovations')}</div>
<div className="text-xs text-warm-500 dark:text-warm-400 mb-1">
{t('propertyCard.renovations')}
</div>
<div className="flex flex-wrap gap-1">
{property.renovation_history.map((reno, idx) => (
<span

View file

@ -44,7 +44,7 @@ export function TravelTimeCard({
dragValue,
onTogglePin,
onSetDestination,
onTimeRangeChange,
onTimeRangeChange: _onTimeRangeChange,
onDragStart,
onDragChange,
onDragEnd,
@ -115,7 +115,12 @@ export function TravelTimeCard({
{/* Best-case toggle — transit only, shown when destination is set */}
{slug && mode === 'transit' && (
<div className="flex items-center gap-1.5">
<PillToggle label={t('travel.bestCase')} active={useBest} onClick={onToggleBest} size="xs" />
<PillToggle
label={t('travel.bestCase')}
active={useBest}
onClick={onToggleBest}
size="xs"
/>
<IconButton onClick={() => setShowBestInfo(true)} title={t('travel.bestCaseTitle')}>
<InfoIcon className="w-3 h-3" />
</IconButton>
@ -149,8 +154,12 @@ export function TravelTimeCard({
onPointerUp={() => onDragEnd()}
/>
<div className="relative h-4 mt-1 mx-2.5 text-[10px] text-warm-500 dark:text-warm-400 leading-tight">
<span className="absolute left-0">{formatFilterValue(displayRange[0])} {t('common.min')}</span>
<span className="absolute right-0">{formatFilterValue(displayRange[1])} {t('common.min')}</span>
<span className="absolute left-0">
{formatFilterValue(displayRange[0])} {t('common.min')}
</span>
<span className="absolute right-0">
{formatFilterValue(displayRange[1])} {t('common.min')}
</span>
</div>
{filterImpact != null && filterImpact > 0 && (
<p className="text-[10px] text-warm-400 dark:text-warm-500 -mt-1 ml-2.5">

View file

@ -183,18 +183,12 @@ export default function PricingPage({
<div className="relative z-10 max-w-5xl mx-auto px-6 pt-16 text-center mb-6">
<h1 className="text-3xl md:text-4xl font-bold text-white mb-3">{t('pricingPage.title')}</h1>
<p className="text-lg text-warm-300 max-w-lg mx-auto">
{t('pricingPage.subtitle')}
</p>
<p className="text-lg text-warm-300 max-w-lg mx-auto">{t('pricingPage.subtitle')}</p>
</div>
<div className="relative z-10 max-w-2xl mx-auto px-6 mb-12 text-center">
<p className="text-warm-400 text-sm leading-relaxed mb-2">
{t('pricingPage.costContext')}
</p>
<p className="text-warm-200 font-semibold">
{t('pricingPage.lessThanSurvey')}
</p>
<p className="text-warm-400 text-sm leading-relaxed mb-2">{t('pricingPage.costContext')}</p>
<p className="text-warm-200 font-semibold">{t('pricingPage.lessThanSurvey')}</p>
</div>
<div className="relative z-10 max-w-5xl mx-auto px-6 pb-16">
@ -285,7 +279,9 @@ export default function PricingPage({
: 'text-navy-950 dark:text-warm-100'
}`}
>
{tier.price_pence === 0 ? t('upgrade.free') : formatPricePence(tier.price_pence)}
{tier.price_pence === 0
? t('upgrade.free')
: formatPricePence(tier.price_pence)}
</span>
{tier.price_pence > 0 && (
<span
@ -321,7 +317,14 @@ export default function PricingPage({
<div className="flex-1 flex flex-col px-6 py-6 bg-white dark:bg-warm-800">
<ul className="space-y-3 mb-6 flex-1">
{[t('pricingPage.feat1'), t('pricingPage.feat2'), t('pricingPage.feat3'), t('pricingPage.feat4'), t('pricingPage.feat5'), t('pricingPage.feat6')].map((feat, idx) => (
{[
t('pricingPage.feat1'),
t('pricingPage.feat2'),
t('pricingPage.feat3'),
t('pricingPage.feat4'),
t('pricingPage.feat5'),
t('pricingPage.feat6'),
].map((feat, idx) => (
<li key={idx} className="flex items-start gap-2.5 text-sm">
<CheckIcon className="w-4 h-4 text-teal-600 dark:text-teal-400 shrink-0 mt-0.5" />
<span className="text-warm-700 dark:text-warm-300">{feat}</span>
@ -338,7 +341,9 @@ export default function PricingPage({
</p>
)}
<p className="text-center text-xs text-warm-400 dark:text-warm-500 mt-2">
{isFree ? t('pricingPage.noCreditCard') : t('pricingPage.moneyBackGuarantee')}
{isFree
? t('pricingPage.noCreditCard')
: t('pricingPage.moneyBackGuarantee')}
</p>
</>
) : isFilled ? (
@ -357,9 +362,7 @@ export default function PricingPage({
</div>
</div>
) : (
<p className="text-center text-warm-400 py-16">
{t('pricingPage.failedToLoad')}
</p>
<p className="text-center text-warm-400 py-16">{t('pricingPage.failedToLoad')}</p>
)}
</div>
</div>

View file

@ -192,7 +192,11 @@ export default function AuthModal({
required
minLength={8}
className="w-full px-3 py-2 text-sm rounded border border-warm-200 dark:border-warm-700 bg-white dark:bg-warm-800 text-navy-950 dark:text-white placeholder-warm-400 dark:placeholder-warm-500 outline-none focus:ring-2 ring-teal-400 dark:ring-teal-500"
placeholder={view === 'register' ? t('auth.passwordPlaceholderRegister') : t('auth.passwordPlaceholderLogin')}
placeholder={
view === 'register'
? t('auth.passwordPlaceholderRegister')
: t('auth.passwordPlaceholderLogin')
}
/>
{view === 'login' && (
<button
@ -207,9 +211,7 @@ export default function AuthModal({
)}
{view === 'forgot' && resetSent && (
<p className="text-sm text-teal-700 dark:text-teal-400">
{t('auth.resetSent')}
</p>
<p className="text-sm text-teal-700 dark:text-teal-400">{t('auth.resetSent')}</p>
)}
{error && <p className="text-sm text-red-600 dark:text-red-300">{error}</p>}

View file

@ -1,6 +1,6 @@
import { useTranslation } from 'react-i18next';
import type { FeatureMeta } from '../../types';
import { ts, tsDesc } from '../../i18n/server';
import { ts, tsDesc, tsDetail } from '../../i18n/server';
import InfoPopup from './InfoPopup';
interface FeatureInfoPopupProps {
@ -34,7 +34,7 @@ export function FeatureInfoPopup({ feature, onClose, onNavigateToSource }: Featu
)}
{feature.detail && (
<p className="text-sm text-warm-700 dark:text-warm-300 mb-4 leading-relaxed">
{feature.detail}
{tsDetail(feature.name, feature.detail)}
</p>
)}
</InfoPopup>

View file

@ -7,7 +7,8 @@ export default function LanguageDropdown() {
const [open, setOpen] = useState(false);
const ref = useRef<HTMLDivElement>(null);
const current = SUPPORTED_LANGUAGES.find((l) => l.code === i18n.language) ?? SUPPORTED_LANGUAGES[0];
const current =
SUPPORTED_LANGUAGES.find((l) => l.code === i18n.language) ?? SUPPORTED_LANGUAGES[0];
useEffect(() => {
if (!open) return;
@ -32,7 +33,13 @@ export default function LanguageDropdown() {
aria-label="Language"
>
<span className="text-base leading-none">{current.flag}</span>
<svg className="w-3 h-3 text-warm-400" viewBox="0 0 12 12" fill="none" stroke="currentColor" strokeWidth="2">
<svg
className="w-3 h-3 text-warm-400"
viewBox="0 0 12 12"
fill="none"
stroke="currentColor"
strokeWidth="2"
>
<path d="M3 5l3 3 3-3" strokeLinecap="round" strokeLinejoin="round" />
</svg>
</button>

View file

@ -1,6 +1,8 @@
import { useTranslation } from 'react-i18next';
import type { Page } from './Header';
import { PAGE_PATHS } from './Header';
import type { AuthUser } from '../../hooks/useAuth';
import { SUPPORTED_LANGUAGES } from '../../i18n';
import { DownloadIcon } from './icons/DownloadIcon';
import { BookmarkIcon } from './icons/BookmarkIcon';
import { CheckIcon } from './icons/CheckIcon';
@ -45,6 +47,8 @@ export default function MobileMenu({
onShare,
copied,
}: MobileMenuProps) {
const { t, i18n } = useTranslation();
const mobileNavItem = (page: Page, label: string) => (
<a
key={page}
@ -72,24 +76,24 @@ export default function MobileMenu({
{/* Menu panel */}
<div className="fixed top-0 right-0 bottom-0 w-64 bg-navy-900 z-50 flex flex-col shadow-xl">
<div className="flex items-center justify-between px-4 h-12 border-b border-navy-700">
<span className="font-semibold">Menu</span>
<span className="font-semibold">{t('mobileMenu.menu')}</span>
<button
onClick={onClose}
className="flex items-center justify-center w-10 h-10 -mr-2 rounded hover:bg-navy-800 transition-colors"
aria-label="Close menu"
aria-label={t('header.closeMenu')}
>
<CloseIcon className="w-5 h-5" />
</button>
</div>
<nav className="flex-1 flex flex-col gap-1 p-3 overflow-y-auto">
{mobileNavItem('home', 'Home')}
{mobileNavItem('dashboard', 'Dashboard')}
{mobileNavItem('learn', 'Learn')}
{mobileNavItem('home', t('mobileMenu.home'))}
{mobileNavItem('dashboard', t('header.dashboard'))}
{mobileNavItem('learn', t('header.learn'))}
{user?.subscription !== 'licensed' &&
!user?.isAdmin &&
mobileNavItem('pricing', 'Pricing')}
{user && mobileNavItem('invites', 'Invite Friends')}
{user && mobileNavItem('account', 'Account')}
mobileNavItem('pricing', t('header.pricing'))}
{user && mobileNavItem('invites', t('header.inviteFriends'))}
{user && mobileNavItem('account', t('userMenu.account'))}
{/* Dashboard actions */}
{activePage === 'dashboard' && (
@ -102,7 +106,7 @@ export default function MobileMenu({
className="w-full flex items-center gap-2 px-4 py-3 text-base text-warm-300 hover:bg-navy-800 hover:text-white rounded"
>
{copied ? <CheckIcon className="w-5 h-5" /> : <ClipboardIcon className="w-5 h-5" />}
{copied ? 'Copied!' : 'Share'}
{copied ? t('common.copied') : t('common.share')}
</button>
<button
onClick={() => {
@ -113,7 +117,7 @@ export default function MobileMenu({
className="w-full flex items-center gap-2 px-4 py-3 text-base text-warm-300 hover:bg-navy-800 hover:text-white rounded disabled:opacity-50"
>
<DownloadIcon className="w-5 h-5" />
{exporting ? 'Exporting...' : 'Export'}
{exporting ? t('header.exporting') : t('header.exportLabel')}
</button>
{onSaveSearch && (
<button
@ -129,13 +133,13 @@ export default function MobileMenu({
) : (
<BookmarkIcon className="w-5 h-5" />
)}
Save
{t('common.save')}
</button>
)}
{user && mobileNavItem('saved', 'Saved')}
{user && mobileNavItem('saved', t('header.saved'))}
</div>
)}
{activePage !== 'dashboard' && user && mobileNavItem('saved', 'Saved')}
{activePage !== 'dashboard' && user && mobileNavItem('saved', t('header.saved'))}
</nav>
{/* Theme toggle + Auth section at bottom */}
@ -148,9 +152,30 @@ export default function MobileMenu({
className="w-full flex items-center gap-3 px-4 py-3 text-base text-warm-300 hover:bg-navy-800 hover:text-white rounded transition-colors"
>
{theme === 'light' ? <SunIcon className="w-5 h-5" /> : <MoonIcon className="w-5 h-5" />}
<span>Theme: {theme === 'light' ? 'Light' : 'Dark'}</span>
<span>{theme === 'light' ? t('userMenu.themeLight') : t('userMenu.themeDark')}</span>
</button>
{/* Language selector */}
<div className="flex gap-1 px-4">
{SUPPORTED_LANGUAGES.map((lang) => (
<button
key={lang.code}
onClick={() => {
i18n.changeLanguage(lang.code);
localStorage.setItem('language', lang.code);
}}
className={`flex-1 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'
}`}
>
<span className="text-base leading-none">{lang.flag}</span>
<span className="hidden sm:inline">{lang.label}</span>
</button>
))}
</div>
{/* Auth buttons */}
<div>
{user ? (
@ -163,7 +188,7 @@ export default function MobileMenu({
}}
className="shrink-0 text-sm text-warm-400 hover:text-white"
>
Log out
{t('userMenu.logOut')}
</button>
</div>
) : (
@ -175,7 +200,7 @@ export default function MobileMenu({
}}
className="flex-1 px-3 py-2.5 rounded bg-navy-800 hover:bg-navy-700 transition-colors text-sm text-center"
>
Log in
{t('header.logIn')}
</button>
<button
onClick={() => {
@ -184,7 +209,7 @@ export default function MobileMenu({
}}
className="flex-1 px-3 py-2.5 rounded bg-teal-600 hover:bg-teal-700 transition-colors text-sm font-medium text-center"
>
Create account
{t('header.createAccount')}
</button>
</div>
)}

View file

@ -34,7 +34,11 @@ export default function UpgradeModal({
}, []);
const priceLabel =
pricePence === null ? '...' : pricePence === 0 ? t('upgrade.free') : `\u00A3${pricePence / 100}`;
pricePence === null
? '...'
: pricePence === 0
? t('upgrade.free')
: `\u00A3${pricePence / 100}`;
const isFree = pricePence === 0;
const handleUpgrade = async () => {
@ -63,9 +67,7 @@ export default function UpgradeModal({
{/* Header */}
<div className="bg-gradient-to-br from-navy-950 to-teal-900 px-6 py-8 text-center">
<h2 className="text-2xl font-bold text-white mb-2">{t('upgrade.title')}</h2>
<p className="text-warm-300 text-sm">
{t('upgrade.description')}
</p>
<p className="text-warm-300 text-sm">{t('upgrade.description')}</p>
</div>
{/* Body */}
@ -74,12 +76,12 @@ export default function UpgradeModal({
<span className="text-4xl font-extrabold text-navy-950 dark:text-warm-100">
{priceLabel}
</span>
{!isFree && <span className="text-warm-500 dark:text-warm-400 text-lg">{t('upgrade.once')}</span>}
{!isFree && (
<span className="text-warm-500 dark:text-warm-400 text-lg">{t('upgrade.once')}</span>
)}
</div>
<p className="text-center text-sm text-warm-500 dark:text-warm-400 mb-6">
{isFree
? t('upgrade.freeForEarly')
: t('upgrade.oneTimePayment')}
{isFree ? t('upgrade.freeForEarly') : t('upgrade.oneTimePayment')}
</p>
{isLoggedIn ? (