This commit is contained in:
Andras Schmelczer 2026-03-15 21:54:48 +00:00
parent c38d654ac7
commit 3e9fba5303
17 changed files with 195 additions and 174 deletions

View file

@ -127,7 +127,7 @@ export default memo(function AiFilterInput({
<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">AI Search</span>
<span className="text-xs text-warm-400 dark:text-warm-500">
&mdash; describe what you&apos;re looking for
describe what you&apos;re looking for
</span>
</div>
<form onSubmit={handleSubmit} className="flex items-center gap-1.5">

View file

@ -78,7 +78,7 @@ export default function AreaPane({
<EmptyState
icon={<InfoIcon className="w-8 h-8 text-warm-300 dark:text-warm-600" />}
title="No area selected"
description="Click a hexagon or postcode to view area statistics"
description="Click any coloured area on the map to see crime, schools, prices, and more"
centered
/>
);

View file

@ -127,28 +127,28 @@ export default function FeatureBrowser({
{isExpanded && (
<>
{group.features.map((f) => {
const isPinned = pinnedFeature === f.name;
return (
<div
key={f.name}
className="flex items-center justify-between px-3 py-1.5 hover:bg-teal-50 dark:hover:bg-teal-900/30 dark:text-warm-300"
>
<div className="min-w-0 mr-2">
<FeatureLabel feature={f} onShowInfo={setInfoFeature} size="sm" />
{f.description && (
<span className="text-xs text-warm-400 dark:text-warm-500 truncate block">
{f.description}
</span>
)}
const isPinned = pinnedFeature === f.name;
return (
<div
key={f.name}
className="flex items-center justify-between px-3 py-1.5 hover:bg-teal-50 dark:hover:bg-teal-900/30 dark:text-warm-300"
>
<div className="min-w-0 mr-2">
<FeatureLabel feature={f} onShowInfo={setInfoFeature} size="sm" />
{f.description && (
<span className="text-xs text-warm-400 dark:text-warm-500 truncate block">
{f.description}
</span>
)}
</div>
<FeatureActions
feature={f}
isPinned={isPinned}
onTogglePin={onTogglePin}
onAdd={onAddFilter}
/>
</div>
<FeatureActions
feature={f}
isPinned={isPinned}
onTogglePin={onTogglePin}
onAdd={onAddFilter}
/>
</div>
);
);
})}
{group.name === 'Transport' &&
showTravelModes &&

View file

@ -370,15 +370,14 @@ export default memo(function Filters({
</div>
{enabledFeatureList.length === 0 && activeEntryCount === 0 && (
<p className="px-3 py-1.5 text-xs text-warm-400 dark:text-warm-500">
Browse features below and click + to add a filter
Add filters below to narrow the map to areas that match
</p>
)}
{mergedGroups.map((group) => {
const isExpanded = !collapsedGroups.has(group.name);
const isTransport = group.name === 'Transport';
const groupCount =
group.features.length + (isTransport ? travelTimeEntries.length : 0);
const groupCount = group.features.length + (isTransport ? travelTimeEntries.length : 0);
return (
<div key={group.name}>
<CollapsibleGroupHeader
@ -428,10 +427,7 @@ export default memo(function Filters({
className={`scroll-mt-10 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"
/>
<FeatureLabel feature={feature} size="sm" />
<FeatureActions
feature={feature}
isPinned={pinnedFeature === feature.name}
@ -492,11 +488,7 @@ export default memo(function Filters({
className={`scroll-mt-10 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 gap-1">
<FeatureLabel
feature={feature}
size="sm"
className="min-w-0 shrink"
/>
<FeatureLabel feature={feature} size="sm" className="min-w-0 shrink" />
<FeatureActions
feature={feature}
isPinned={isPinned}
@ -584,79 +576,72 @@ export default memo(function Filters({
<div className="space-y-4 text-sm">
<p className="text-warm-600 dark:text-warm-300">
Start with your must-haves, then layer on nice-to-haves. The map narrows down as you
add filters &mdash; the areas that survive are your best matches.
add filters. The areas that survive are your best matches.
</p>
<div>
<h4 className="font-semibold text-navy-950 dark:text-warm-100 mb-1">
1. Budget &amp; property basics
</h4>
<p className="text-warm-600 dark:text-warm-300">
Set your price range, minimum floor area, and property type. If you need a lease
over freehold (or vice versa), filter for that too. This eliminates most of the map
immediately.
</p>
<div className="space-y-2">
<div className="flex gap-2">
<span className="shrink-0 w-5 h-5 rounded-full bg-teal-600 text-white flex items-center justify-center text-xs font-bold">
1
</span>
<p className="text-warm-600 dark:text-warm-300">
<span className="font-semibold text-navy-950 dark:text-warm-100">
Budget &amp; basics
</span>{' '}
(price range, floor area, property type)
</p>
</div>
<div className="flex gap-2">
<span className="shrink-0 w-5 h-5 rounded-full bg-teal-600 text-white flex items-center justify-center text-xs font-bold">
2
</span>
<p className="text-warm-600 dark:text-warm-300">
<span className="font-semibold text-navy-950 dark:text-warm-100">Commute</span>{' '}
(travel time to your workplace by car, bike, or transit)
</p>
</div>
<div className="flex gap-2">
<span className="shrink-0 w-5 h-5 rounded-full bg-teal-600 text-white flex items-center justify-center text-xs font-bold">
3
</span>
<p className="text-warm-600 dark:text-warm-300">
<span className="font-semibold text-navy-950 dark:text-warm-100">Safety</span>{' '}
(crime rates, noise levels, ground stability)
</p>
</div>
<div className="flex gap-2">
<span className="shrink-0 w-5 h-5 rounded-full bg-teal-600 text-white flex items-center justify-center text-xs font-bold">
4
</span>
<p className="text-warm-600 dark:text-warm-300">
<span className="font-semibold text-navy-950 dark:text-warm-100">Schools</span>{' '}
(nearby Ofsted-rated Good or Outstanding schools)
</p>
</div>
<div className="flex gap-2">
<span className="shrink-0 w-5 h-5 rounded-full bg-teal-600 text-white flex items-center justify-center text-xs font-bold">
5
</span>
<p className="text-warm-600 dark:text-warm-300">
<span className="font-semibold text-navy-950 dark:text-warm-100">Lifestyle</span>{' '}
(restaurants, parks, broadband speed)
</p>
</div>
<div className="flex gap-2">
<span className="shrink-0 w-5 h-5 rounded-full bg-teal-600 text-white flex items-center justify-center text-xs font-bold">
6
</span>
<p className="text-warm-600 dark:text-warm-300">
<span className="font-semibold text-navy-950 dark:text-warm-100">Energy</span>{' '}
(EPC ratings for lower bills and fewer surprises)
</p>
</div>
</div>
<div>
<h4 className="font-semibold text-navy-950 dark:text-warm-100 mb-1">
2. Commute &amp; transport
</h4>
<p className="text-warm-600 dark:text-warm-300">
Add a travel time filter to your workplace &mdash; choose public transport or
cycling and set your maximum tolerable commute. You can also filter by how many
stations are within walking distance.
</p>
</div>
<div>
<h4 className="font-semibold text-navy-950 dark:text-warm-100 mb-1">
3. Safety &amp; environment
</h4>
<p className="text-warm-600 dark:text-warm-300">
Use the crime filters to cap serious or minor crime rates. Check road noise levels
if you&apos;re a light sleeper, and environmental risk filters for ground stability
concerns.
</p>
</div>
<div>
<h4 className="font-semibold text-navy-950 dark:text-warm-100 mb-1">
4. Schools &amp; education
</h4>
<p className="text-warm-600 dark:text-warm-300">
Filter by the number of Ofsted-rated Good or Outstanding primary and secondary
schools nearby. The education deprivation score captures broader area-level
attainment.
</p>
</div>
<div>
<h4 className="font-semibold text-navy-950 dark:text-warm-100 mb-1">
5. Lifestyle &amp; amenities
</h4>
<p className="text-warm-600 dark:text-warm-300">
Want restaurants, parks, or grocery shops within walking distance? Filter by nearby
amenity counts. Broadband speed filters help if you work from home.
</p>
</div>
<div>
<h4 className="font-semibold text-navy-950 dark:text-warm-100 mb-1">
6. Energy &amp; running costs
</h4>
<p className="text-warm-600 dark:text-warm-300">
EPC ratings from A to G indicate energy efficiency. Filter for better ratings to
find homes with lower bills and fewer upgrade headaches.
</p>
</div>
<div className="pt-1 border-t border-warm-200 dark:border-warm-700">
<p className="text-warm-500 dark:text-warm-400 italic">
Tip: if nothing survives your filters, relax one constraint at a time to see which
compromise unlocks the most options.
</p>
</div>
<p className="text-warm-500 dark:text-warm-400 italic text-xs">
Tip: if nothing survives, relax one constraint at a time to see which compromise
unlocks the most options.
</p>
{onResetTutorial && (
<button

View file

@ -64,6 +64,27 @@ function getRouteDisplay(mode: string): { label: string; color: string; darkText
return { label: clean, color: '#6b7280', darkText: false };
}
/** Returns a Unix timestamp for the next Monday at 07:30 local time. */
function nextMondayAt730(): number {
const now = new Date();
const day = now.getDay(); // 0=Sun … 6=Sat
const daysUntil = day === 0 ? 1 : day === 1 ? 7 : 8 - day;
const monday = new Date(now);
monday.setDate(now.getDate() + daysUntil);
monday.setHours(7, 30, 0, 0);
return Math.floor(monday.getTime() / 1000);
}
function googleMapsUrl(postcode: string, destination: string): string {
const params = new URLSearchParams({
api: '1',
origin: postcode,
destination,
travelmode: 'transit',
});
return `https://www.google.com/maps/dir/?${params}&departure_time=${nextMondayAt730()}`;
}
function invertLegs(legs: JourneyLeg[]): JourneyLeg[] {
return [...legs]
.reverse()
@ -235,6 +256,17 @@ export default function JourneyInstructions({
{displayLegs.map((leg, i) => (
<TimelineLeg key={i} leg={leg} isLast={i === displayLegs.length - 1} />
))}
<a
href={googleMapsUrl(postcode, j.label || j.slug)}
target="_blank"
rel="noopener noreferrer"
className="mt-2 flex items-center justify-center gap-1.5 w-full text-[11px] font-medium text-teal-600 dark:text-teal-400 hover:text-teal-800 dark:hover:text-teal-300 bg-white dark:bg-warm-900 border border-warm-200 dark:border-warm-700 rounded-md py-1.5 transition-colors"
>
View on Google Maps
<svg className="w-3 h-3" viewBox="0 0 12 12" fill="none" stroke="currentColor" strokeWidth="1.5">
<path d="M4.5 1.5H2a.5.5 0 00-.5.5v8a.5.5 0 00.5.5h8a.5.5 0 00.5-.5V7.5M7.5 1.5H10.5V4.5M10.5 1.5L5.5 6.5" strokeLinecap="round" strokeLinejoin="round" />
</svg>
</a>
</div>
) : (
<span className="text-xs text-warm-500 dark:text-warm-400">

View file

@ -52,7 +52,7 @@ export function PropertiesPane({
<EmptyState
icon={<InfoIcon className="w-8 h-8 text-warm-300 dark:text-warm-600" />}
title="No area selected"
description="Click a hexagon or postcode to view area statistics"
description="Click any coloured area on the map to see crime, schools, prices, and more"
centered
/>
);
@ -77,10 +77,9 @@ export function PropertiesPane({
}
>
<p className="text-sm text-warm-700 dark:text-warm-300 mb-4 leading-relaxed">
Property data combines Energy Performance Certificates (EPC) with HM Land Registry Price
Paid records, fuzzy-matched by address within each postcode. Includes floor area, energy
ratings, construction year, and tenure from EPC surveys, plus the most recent sale price
from the Land Registry.
Prices come from HM Land Registry (what buyers actually paid). Floor area, energy
ratings, construction year, and tenure come from official EPC surveys. Both sources are
matched by address within each postcode.
</p>
</InfoPopup>
)}