Clean up
This commit is contained in:
parent
b94cf17d75
commit
0c6d207967
41 changed files with 1809 additions and 1204 deletions
|
|
@ -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}`}
|
||||
>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)}
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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'}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -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 ? (
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue