This commit is contained in:
Andras Schmelczer 2026-03-15 17:38:26 +00:00
parent 80c093b7ba
commit f72c43a9fa
101 changed files with 2168 additions and 1177 deletions

View file

@ -114,7 +114,9 @@ export default memo(function AiFilterInput({
<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">AI Search</span>
<span className="text-xs text-warm-400 dark:text-warm-500"> describe what you're looking for</span>
<span className="text-xs text-warm-400 dark:text-warm-500">
&mdash; describe what you&apos;re looking for
</span>
</div>
<form onSubmit={handleSubmit} className="flex items-center gap-1.5">
<input
@ -141,11 +143,7 @@ export default memo(function AiFilterInput({
)}
</button>
</form>
{loading && (
<p className="mt-1 text-xs text-teal-600 dark:text-teal-400">
{loadingMessage}
</p>
)}
{loading && <p className="mt-1 text-xs text-teal-600 dark:text-teal-400">{loadingMessage}</p>}
{showExamples && (
<div className="mt-1.5 flex flex-wrap gap-1">
{EXAMPLE_QUERIES.map((example) => (
@ -162,12 +160,13 @@ export default memo(function AiFilterInput({
)}
{error && errorType === 'verification' && (
<p className="mt-1.5 text-xs text-amber-600 dark:text-amber-400">
Please verify your email address to use AI-powered search. Check your inbox for a verification link.
Please verify your email address to use AI-powered search. Check your inbox for a
verification link.
</p>
)}
{error && errorType === 'limit' && (
<p className="mt-1.5 text-xs text-amber-600 dark:text-amber-400">
You've reached the weekly AI usage limit. It will reset automatically next week.
You&apos;ve reached the weekly AI usage limit. It will reset automatically next week.
</p>
)}
{error && errorType === 'error' && (
@ -176,14 +175,10 @@ export default memo(function AiFilterInput({
</p>
)}
{summary && !error && !loading && (
<p className="mt-1 text-xs text-teal-600 dark:text-teal-400">
{summary}
</p>
<p className="mt-1 text-xs text-teal-600 dark:text-teal-400">{summary}</p>
)}
{notes && !error && !loading && (
<p className="mt-1 text-xs text-warm-500 dark:text-warm-400 italic">
{notes}
</p>
<p className="mt-1 text-xs text-warm-500 dark:text-warm-400 italic">{notes}</p>
)}
</div>
);

View file

@ -86,7 +86,7 @@ export default function AreaPane({
return (
<>
<div className="h-full overflow-y-auto">
<div className="h-full overflow-y-auto">
<div className="p-3">
<div className="flex items-center gap-2">
<div>
@ -107,8 +107,8 @@ export default function AreaPane({
</p>
)}
<p className="text-xs text-warm-500 dark:text-warm-400 mt-1">
Stats for {isPostcode ? 'current and historical' : 'all'} properties
in this {isPostcode ? 'postcode' : 'area'}
Stats for {isPostcode ? 'current and historical' : 'all'} properties in this{' '}
{isPostcode ? 'postcode' : 'area'}
{Object.keys(filters).length > 0 ? ' matching all active filters' : ''}
</p>
{stats && stats.count > 0 && (
@ -142,15 +142,11 @@ export default function AreaPane({
<HistogramLegend />
{stats.price_history &&
(() => {
const uniqueYears = new Set(
stats.price_history.map((p) => Math.floor(p.year))
);
const uniqueYears = new Set(stats.price_history.map((p) => Math.floor(p.year)));
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">
Price History
</span>
<span className="text-xs text-warm-700 dark:text-warm-300">Price History</span>
<PriceHistoryChart points={stats.price_history} />
</div>
)}

View file

@ -45,12 +45,7 @@ export default function ExternalSearchLinks({
</h3>
<div className="flex gap-2">
{urls.rightmove ? (
<a
href={urls.rightmove}
target="_blank"
rel="noopener noreferrer"
className={linkClass}
>
<a href={urls.rightmove} target="_blank" rel="noopener noreferrer" className={linkClass}>
Rightmove
</a>
) : (
@ -58,20 +53,10 @@ export default function ExternalSearchLinks({
Rightmove
</span>
)}
<a
href={urls.onthemarket}
target="_blank"
rel="noopener noreferrer"
className={linkClass}
>
<a href={urls.onthemarket} target="_blank" rel="noopener noreferrer" className={linkClass}>
OnTheMarket
</a>
<a
href={urls.zoopla}
target="_blank"
rel="noopener noreferrer"
className={linkClass}
>
<a href={urls.zoopla} target="_blank" rel="noopener noreferrer" className={linkClass}>
Zoopla
</a>
</div>

View file

@ -12,7 +12,13 @@ import { FeatureActions } from '../ui/FeatureIcons';
import { FeatureLabel } from '../ui/FeatureLabel';
import { CarIcon, BicycleIcon, WalkingIcon, TransitIcon, PlusIcon } from '../ui/icons';
import type { ComponentType } from 'react';
import { TRANSPORT_MODES, MODE_LABELS, MODE_DESCRIPTIONS, type TransportMode, type TravelTimeEntry } from '../../hooks/useTravelTime';
import {
TRANSPORT_MODES,
MODE_LABELS,
MODE_DESCRIPTIONS,
type TransportMode,
type TravelTimeEntry,
} from '../../hooks/useTravelTime';
const MODE_ICONS: Record<TransportMode, ComponentType<{ className?: string }>> = {
car: CarIcon,
@ -45,7 +51,7 @@ export default function FeatureBrowser({
onNavigateToSource,
openInfoFeature,
onClearOpenInfoFeature,
travelTimeEntries,
travelTimeEntries: _travelTimeEntries,
onAddTravelTimeEntry,
isLicensed,
onUpgradeClick,
@ -77,12 +83,13 @@ export default function FeatureBrowser({
// Only show modes that have precomputed travel time data
const visibleModes = useMemo(
() => (availableTravelModes ? TRANSPORT_MODES.filter((m) => availableTravelModes.has(m)) : []),
[availableTravelModes],
[availableTravelModes]
);
const showTravelModes =
visibleModes.length > 0 &&
(!search || 'travel time journey commute car bicycle walking transit'.includes(search.toLowerCase()));
(!search ||
'travel time journey commute car bicycle walking transit'.includes(search.toLowerCase()));
return (
<>
@ -102,36 +109,40 @@ export default function FeatureBrowser({
{visibleModes.length}
</span>
</CollapsibleGroupHeader>
{(isSearching || expandedGroups.has('Travel Time')) && visibleModes.map((mode) => {
const ModeIcon = MODE_ICONS[mode];
return (
<div
key={mode}
className="flex items-center justify-between px-3 py-1.5 hover:bg-teal-50 dark:hover:bg-teal-900/30 cursor-pointer"
>
<div className="flex items-center gap-2 min-w-0" onClick={() => onAddTravelTimeEntry(mode)}>
<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]}
</span>
<span className="text-xs text-warm-400 dark:text-warm-500 block">
{MODE_DESCRIPTIONS[mode]}
</span>
{(isSearching || expandedGroups.has('Travel Time')) &&
visibleModes.map((mode) => {
const ModeIcon = MODE_ICONS[mode];
return (
<div
key={mode}
className="flex items-center justify-between px-3 py-1.5 hover:bg-teal-50 dark:hover:bg-teal-900/30 cursor-pointer"
>
<div
className="flex items-center gap-2 min-w-0"
onClick={() => onAddTravelTimeEntry(mode)}
>
<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]}
</span>
<span className="text-xs text-warm-400 dark:text-warm-500 block">
{MODE_DESCRIPTIONS[mode]}
</span>
</div>
</div>
<div className="flex items-center gap-0.5 shrink-0">
<button
onClick={() => onAddTravelTimeEntry(mode)}
title={`Add ${MODE_LABELS[mode]} travel time`}
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-7 h-7 md:w-5 md:h-5" strokeWidth={2.5} />
</button>
</div>
</div>
<div className="flex items-center gap-0.5 shrink-0">
<button
onClick={() => onAddTravelTimeEntry(mode)}
title={`Add ${MODE_LABELS[mode]} travel time`}
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-7 h-7 md:w-5 md:h-5" strokeWidth={2.5} />
</button>
</div>
</div>
);
})}
);
})}
</div>
)}
{grouped.map((group) => {
@ -203,8 +214,15 @@ export default function FeatureBrowser({
>
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" />
<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" />

View file

@ -49,16 +49,10 @@ function SliderLabels({
const labels = displayValues || value;
return (
<div className="relative h-4 mt-2 mx-2.5 text-[10px] text-warm-500 dark:text-warm-400 leading-tight">
<span
className="absolute -translate-x-1/2"
style={{ left: `${leftPct}%` }}
>
<span className="absolute -translate-x-1/2" style={{ left: `${leftPct}%` }}>
{isAtMin ? 'min' : formatFilterValue(labels[0], raw)}
</span>
<span
className="absolute -translate-x-1/2"
style={{ left: `${rightPct}%` }}
>
<span className="absolute -translate-x-1/2" style={{ left: `${rightPct}%` }}>
{isAtMax ? 'max' : formatFilterValue(labels[1], raw)}
</span>
</div>
@ -175,7 +169,8 @@ export default memo(function Filters({
}, [filters]);
const availableFeatures = useMemo(
() => features.filter((f) => !enabledFeatures.has(f.name) && isAllowed(f.name, activeListingType)),
() =>
features.filter((f) => !enabledFeatures.has(f.name) && isAllowed(f.name, activeListingType)),
[features, enabledFeatures, activeListingType, isAllowed]
);
const enabledFeatureList = useMemo(
@ -271,7 +266,10 @@ export default memo(function Filters({
const badgeCount = enabledFeatureList.length + activeEntryCount;
return (
<div ref={containerRef} className="flex flex-col bg-white dark:bg-navy-950 overflow-y-auto md:overflow-hidden h-full">
<div
ref={containerRef}
className="flex flex-col bg-white dark:bg-navy-950 overflow-y-auto md:overflow-hidden h-full"
>
<div className="shrink-0 md:shrink md:min-h-0 flex flex-col md:basis-[40%]">
<div className="shrink-0 flex items-center justify-between px-3 py-2 border-b border-warm-200 dark:border-navy-700">
<div className="flex items-center gap-2">
@ -287,7 +285,16 @@ export default memo(function Filters({
</div>
<div ref={scrollRef} className="md:flex-1 md:overflow-y-auto">
<AiFilterInput loading={aiFilterLoading} error={aiFilterError} errorType={aiFilterErrorType} notes={aiFilterNotes} summary={aiFilterSummary} onSubmit={onAiFilterSubmit} isLoggedIn={isLoggedIn} onLoginRequired={onLoginRequired} />
<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">
<div className="flex rounded-lg bg-warm-100 dark:bg-warm-800 p-0.5">
{(['historical', 'buy', 'rent'] as const).map((type) => {
@ -332,19 +339,21 @@ export default memo(function Filters({
<div className="px-2 py-1 space-y-1">
{travelTimeEntries.map((entry, index) => (
<div key={index} data-filter-name={`tt_${index}`} className="scroll-mt-10">
<TravelTimeCard
mode={entry.mode}
slug={entry.slug}
label={entry.label}
timeRange={entry.timeRange}
useBest={entry.useBest}
isPinned={pinnedFeature === travelFieldKey(entry)}
onTogglePin={() => onTogglePin(travelFieldKey(entry))}
onSetDestination={(slug, label) => onTravelTimeSetDestination(index, slug, label)}
onTimeRangeChange={(range) => onTravelTimeRangeChange(index, range)}
onToggleBest={() => onTravelTimeToggleBest(index)}
onRemove={() => onTravelTimeRemoveEntry(index)}
/>
<TravelTimeCard
mode={entry.mode}
slug={entry.slug}
label={entry.label}
timeRange={entry.timeRange}
useBest={entry.useBest}
isPinned={pinnedFeature === travelFieldKey(entry)}
onTogglePin={() => onTogglePin(travelFieldKey(entry))}
onSetDestination={(slug, label) =>
onTravelTimeSetDestination(index, slug, label)
}
onTimeRangeChange={(range) => onTravelTimeRangeChange(index, range)}
onToggleBest={() => onTravelTimeToggleBest(index)}
onRemove={() => onTravelTimeRemoveEntry(index)}
/>
</div>
))}
</div>
@ -385,7 +394,11 @@ 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} onShowInfo={setActiveInfoFeature} size="sm" />
<FeatureLabel
feature={feature}
onShowInfo={setActiveInfoFeature}
size="sm"
/>
<FeatureActions
feature={feature}
isPinned={pinnedFeature === feature.name}
@ -419,7 +432,10 @@ export default memo(function Filters({
const displayValue =
isActive && dragValue
? dragValue
: (filters[feature.name] as [number, number]) || [hist?.min ?? feature.min!, hist?.max ?? feature.max!];
: (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!;
@ -442,7 +458,12 @@ 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} onShowInfo={setActiveInfoFeature} size="sm" className="min-w-0 shrink" />
<FeatureLabel
feature={feature}
onShowInfo={setActiveInfoFeature}
size="sm"
className="min-w-0 shrink"
/>
<FeatureActions
feature={feature}
isPinned={isPinned}
@ -454,7 +475,9 @@ export default memo(function Filters({
<Slider
min={scale ? 0 : feature.min!}
max={scale ? 100 : feature.max!}
step={scale ? 1 : (feature.step ?? (feature.max! - feature.min!) / 100)}
step={
scale ? 1 : (feature.step ?? (feature.max! - feature.min!) / 100)
}
value={sliderValue}
onValueChange={
scale
@ -462,14 +485,19 @@ export default memo(function Filters({
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)),
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,
])
: ([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()}
@ -521,8 +549,8 @@ export default memo(function Filters({
<InfoPopup title="Finding the Perfect Postcode" onClose={() => setShowPhilosophy(false)}>
<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.
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.
</p>
<div>
@ -530,9 +558,9 @@ export default memo(function Filters({
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.
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>
@ -541,9 +569,9 @@ export default memo(function Filters({
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.
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>
@ -552,9 +580,9 @@ export default memo(function Filters({
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.
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>
@ -563,9 +591,9 @@ export default memo(function Filters({
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.
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>
@ -574,9 +602,8 @@ export default memo(function Filters({
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.
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>
@ -585,16 +612,15 @@ export default memo(function Filters({
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.
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.
Tip: if nothing survives your filters, relax one constraint at a time to see which
compromise unlocks the most options.
</p>
</div>

View file

@ -17,13 +17,18 @@ interface HoverCardProps {
features: FeatureMeta[];
}
export default memo(function HoverCard({ x, y, id, isPostcode, data, filters, features }: HoverCardProps) {
export default memo(function HoverCard({
x,
y,
id,
isPostcode,
data,
filters,
features,
}: HoverCardProps) {
const activeFilterNames = Object.keys(filters);
const featureMap = useMemo(
() => new Map(features.map((f) => [f.name, f])),
[features]
);
const featureMap = useMemo(() => new Map(features.map((f) => [f.name, f])), [features]);
// Get key stats to show from local data (min_<feature> values)
const getDisplayStats = () => {

View file

@ -116,9 +116,7 @@ function TimelineLeg({ leg, isLast }: { leg: JourneyLeg; isLast: boolean }) {
className="w-2.5 h-2.5 rounded-full shrink-0 mt-0.5"
style={{ backgroundColor: color }}
/>
{!isLast && (
<div className="flex-1 min-h-[4px] w-px bg-warm-300 dark:bg-warm-600" />
)}
{!isLast && <div className="flex-1 min-h-[4px] w-px bg-warm-300 dark:bg-warm-600" />}
</div>
<div className="pb-1.5 min-w-0 flex-1">
<div className="flex items-center gap-1.5 flex-wrap">
@ -135,7 +133,11 @@ function TimelineLeg({ leg, isLast }: { leg: JourneyLeg; isLast: boolean }) {
);
}
export default function JourneyInstructions({ postcode, entries, label }: JourneyInstructionsProps) {
export default function JourneyInstructions({
postcode,
entries,
label,
}: JourneyInstructionsProps) {
const [journeys, setJourneys] = useState<JourneyData[]>([]);
// Only transit entries with a destination set
@ -192,9 +194,7 @@ export default function JourneyInstructions({ postcode, entries, label }: Journe
)
.catch((err) => {
logNonAbortError('journey', err);
setJourneys((prev) =>
prev.map((j, i) => (i === idx ? { ...j, loading: false } : j))
);
setJourneys((prev) => prev.map((j, i) => (i === idx ? { ...j, loading: false } : j)));
});
});

View file

@ -78,10 +78,7 @@ export default function LocationSearch({
setLoading(true);
search.close();
try {
const res = await fetch(
`/api/postcode/${encodeURIComponent(result.label)}`,
authHeaders(),
);
const res = await fetch(`/api/postcode/${encodeURIComponent(result.label)}`, authHeaders());
if (!res.ok) {
setError('Postcode not found');
return;
@ -102,7 +99,7 @@ export default function LocationSearch({
setLoading(false);
}
},
[onFlyTo, onLocationSearched, isMobile, search],
[onFlyTo, onLocationSearched, isMobile, search]
);
// Mobile collapsed state: just a search icon button
@ -120,7 +117,12 @@ export default function LocationSearch({
}
return (
<div ref={containerRef} data-tutorial="search" className="absolute top-3 left-3 z-10 flex flex-col" onMouseEnter={onMouseEnter}>
<div
ref={containerRef}
data-tutorial="search"
className="absolute top-3 left-3 z-10 flex flex-col"
onMouseEnter={onMouseEnter}
>
<div className="flex items-center shadow-lg rounded bg-white dark:bg-warm-800">
<SearchIcon className="w-4 h-4 text-warm-400 dark:text-warm-500 ml-3 shrink-0" />
<PlaceSearchInput

View file

@ -14,7 +14,13 @@ import type {
} from '../../types';
import { zoomToResolution, getBoundsFromViewState, getMapStyle } from '../../lib/map-utils';
import { INITIAL_VIEW_STATE, MAP_MIN_ZOOM, MAP_BOUNDS, POI_GROUP_COLORS, POI_DEFAULT_COLOR } from '../../lib/consts';
import {
INITIAL_VIEW_STATE,
MAP_MIN_ZOOM,
MAP_BOUNDS,
POI_GROUP_COLORS,
POI_DEFAULT_COLOR,
} from '../../lib/consts';
import LocationSearch, { type SearchedLocation } from './LocationSearch';
import MapLegend from './MapLegend';
import HoverCard from './HoverCard';
@ -114,8 +120,7 @@ export default memo(function Map({
const [dimensions, setDimensions] = useState<Dimensions>({ width: 0, height: 0 });
// In screenshot mode, use the prop directly for instant updates (no async lag)
const viewState =
screenshotMode && initialViewState ? initialViewState : internalViewState;
const viewState = screenshotMode && initialViewState ? initialViewState : internalViewState;
useEffect(() => {
const container = containerRef.current;
@ -245,10 +250,7 @@ export default memo(function Map({
Transport
</span>
</div>
<span
className="text-teal-600 font-semibold"
style={{ fontSize: '1rem' }}
>
<span className="text-teal-600 font-semibold" style={{ fontSize: '1rem' }}>
perfect-postcode.co.uk
</span>
</div>
@ -256,7 +258,11 @@ export default memo(function Map({
) : null
) : (
<>
<LocationSearch onFlyTo={handleFlyTo} onLocationSearched={onLocationSearched} onMouseEnter={handleMouseLeave} />
<LocationSearch
onFlyTo={handleFlyTo}
onLocationSearched={onLocationSearched}
onMouseEnter={handleMouseLeave}
/>
{!hideLegend &&
(viewFeature && colorRange ? (
viewFeature.startsWith('tt_') ? (
@ -280,7 +286,9 @@ export default memo(function Map({
showCancel={viewSource === 'eye'}
onCancel={onCancelPin}
mode="feature"
enumValues={colorFeatureMeta.type === 'enum' ? colorFeatureMeta.values : undefined}
enumValues={
colorFeatureMeta.type === 'enum' ? colorFeatureMeta.values : undefined
}
theme={theme}
raw={colorFeatureMeta.raw}
/>

View file

@ -1,5 +1,10 @@
import { formatValue } from '../../lib/format';
import { FEATURE_GRADIENT, DENSITY_GRADIENT, DENSITY_GRADIENT_DARK, ENUM_PALETTE } from '../../lib/consts';
import {
FEATURE_GRADIENT,
DENSITY_GRADIENT,
DENSITY_GRADIENT_DARK,
ENUM_PALETTE,
} from '../../lib/consts';
import { gradientToCss } from '../../lib/utils';
import { CloseIcon } from '../ui/icons/CloseIcon';
import { TickerValue } from '../ui/TickerValue';
@ -34,7 +39,9 @@ function InlineEnumSwatches({ values }: { values: string[] }) {
className="w-2.5 h-2.5 rounded-sm shrink-0"
style={{ backgroundColor: `rgb(${color[0]},${color[1]},${color[2]})` }}
/>
<span className="text-warm-500 dark:text-warm-400 whitespace-nowrap text-[11px]">{label}</span>
<span className="text-warm-500 dark:text-warm-400 whitespace-nowrap text-[11px]">
{label}
</span>
</div>
);
})}
@ -106,7 +113,10 @@ export default function MapLegend({
) : (
<div className="flex items-center gap-1.5 flex-1 min-w-[40%] text-warm-500 dark:text-warm-400">
{rangeMin}
<div className="h-2.5 rounded flex-1 min-w-[40px]" style={{ background: gradientStyle }} />
<div
className="h-2.5 rounded flex-1 min-w-[40px]"
style={{ background: gradientStyle }}
/>
{rangeMax}
</div>
)}

View file

@ -1,5 +1,12 @@
import { useState, useEffect, useMemo, useCallback, useRef } from 'react';
import type { FeatureMeta, FeatureFilters, POICategoryGroup, ViewState, PostcodeGeometry, Property } from '../../types';
import type {
FeatureMeta,
FeatureFilters,
POICategoryGroup,
ViewState,
PostcodeGeometry,
Property,
} from '../../types';
import type { SearchedLocation } from './LocationSearch';
import type { Page } from '../ui/Header';
import Map from './Map';
@ -103,9 +110,7 @@ export default function MapPage({
const [poiPaneOpen, setPoiPaneOpen] = useState(false);
const [showBookmarkToast, setShowBookmarkToast] = useState(false);
const bookmarkToastDismissed = useRef(
localStorage.getItem('bookmark_toast_dismissed') === '1'
);
const bookmarkToastDismissed = useRef(localStorage.getItem('bookmark_toast_dismissed') === '1');
const handleSavePropertyWithToast = useCallback(
(property: Property) => {
@ -158,8 +163,7 @@ export default function MapPage({
max: entry.timeRange?.[1],
})),
};
const hasContext =
Object.keys(context.filters).length > 0 || context.travelTime.length > 0;
const hasContext = Object.keys(context.filters).length > 0 || context.travelTime.length > 0;
const result = await aiFilters.fetchAiFilters(query, hasContext ? context : undefined);
if (!result) return;
@ -174,7 +178,13 @@ export default function MapPage({
}));
travelTime.handleSetEntries(newEntries);
},
[aiFilters.fetchAiFilters, handleSetFilters, travelTime.handleSetEntries, travelTime.activeEntries, filters]
[
aiFilters.fetchAiFilters,
handleSetFilters,
travelTime.handleSetEntries,
travelTime.activeEntries,
filters,
]
);
const handleTravelTimeSetDestination = useCallback(
@ -246,7 +256,14 @@ export default function MapPage({
const pois = usePOIData(mapData.bounds, selectedPOICategories);
useUrlSync(mapData.currentView, filters, features, selectedPOICategories, selection.rightPaneTab, travelTime.entries);
useUrlSync(
mapData.currentView,
filters,
features,
selectedPOICategories,
selection.rightPaneTab,
travelTime.entries
);
useEffect(() => {
mapData.setInitialView(initialViewState);
@ -268,11 +285,18 @@ export default function MapPage({
if (!res.ok) throw new Error('Postcode not found');
return res.json();
})
.then((data: { postcode: string; latitude: number; longitude: number; geometry: PostcodeGeometry }) => {
mapFlyToRef.current?.(data.latitude, data.longitude, 16);
selection.handleLocationSearch(data.postcode, data.geometry);
if (isMobile) setMobileDrawerOpen(true);
})
.then(
(data: {
postcode: string;
latitude: number;
longitude: number;
geometry: PostcodeGeometry;
}) => {
mapFlyToRef.current?.(data.latitude, data.longitude, 16);
selection.handleLocationSearch(data.postcode, data.geometry);
if (isMobile) setMobileDrawerOpen(true);
}
)
.catch(() => {
// Silently fail — postcode might not exist
});
@ -397,7 +421,13 @@ export default function MapPage({
window.__screenshot_ready = true;
}
}
}, [screenshotMode, mapData.loading, mapData.data.length, mapData.postcodeData.length, mapData.usePostcodeView]);
}, [
screenshotMode,
mapData.loading,
mapData.data.length,
mapData.postcodeData.length,
mapData.usePostcodeView,
]);
const bookmarkToast = showBookmarkToast && (
<div className="fixed bottom-6 left-1/2 -translate-x-1/2 z-[60] flex items-center gap-3 px-4 py-3 rounded-lg bg-navy-900 text-white text-sm shadow-lg animate-fade-in">
@ -580,7 +610,9 @@ export default function MapPage({
<div className="absolute inset-0 flex items-center justify-center pointer-events-none">
<div className="flex items-center gap-2 bg-white/90 dark:bg-warm-800/90 px-4 py-2.5 rounded-lg shadow-lg pointer-events-auto">
<SpinnerIcon className="w-5 h-5 text-teal-600 dark:text-teal-400 animate-spin" />
<span className="text-sm font-medium text-warm-700 dark:text-warm-200">Loading...</span>
<span className="text-sm font-medium text-warm-700 dark:text-warm-200">
Loading...
</span>
</div>
</div>
)}
@ -641,9 +673,7 @@ export default function MapPage({
inline
/>
)}
<div className="flex-1 min-h-0">
{renderFilters()}
</div>
<div className="flex-1 min-h-0">{renderFilters()}</div>
</div>
{mobileDrawerOpen && selection.selectedHexagon && (
@ -746,7 +776,9 @@ export default function MapPage({
<div className="absolute inset-0 flex items-center justify-center pointer-events-none">
<div className="flex items-center gap-2 bg-white/90 dark:bg-warm-800/90 px-4 py-2.5 rounded-lg shadow-lg pointer-events-auto">
<SpinnerIcon className="w-5 h-5 text-teal-600 dark:text-teal-400 animate-spin" />
<span className="text-sm font-medium text-warm-700 dark:text-warm-200">Loading...</span>
<span className="text-sm font-medium text-warm-700 dark:text-warm-200">
Loading...
</span>
</div>
</div>
)}
@ -794,9 +826,7 @@ export default function MapPage({
</div>
<div className="flex-1 overflow-hidden">
{selection.rightPaneTab === 'properties'
? renderPropertiesPane()
: renderAreaPane()}
{selection.rightPaneTab === 'properties' ? renderPropertiesPane() : renderAreaPane()}
</div>
</div>
</div>

View file

@ -17,7 +17,6 @@ export default function MobileDrawer({
tab,
onTabChange,
}: MobileDrawerProps) {
// Close on Escape
useEffect(() => {
const handler = (e: KeyboardEvent) => {

View file

@ -21,7 +21,7 @@ export default function POIPane({
groups,
selectedCategories,
onCategoriesChange,
poiCount,
poiCount: _poiCount,
onNavigateToSource,
}: POIPaneProps) {
const [searchTerm, setSearchTerm] = useState('');
@ -136,7 +136,6 @@ export default function POIPane({
</p>
</InfoPopup>
)}
</div>
<div className="flex-1 overflow-y-auto border-t border-warm-200 dark:border-warm-700">

View file

@ -234,7 +234,9 @@ function PropertyCard({
)}
{price !== undefined && (
<div className={`${askingPrice !== undefined || askingRent !== undefined ? '' : 'mt-2 '}text-lg font-bold text-teal-700 dark:text-teal-400`}>
<div
className={`${askingPrice !== undefined || askingRent !== undefined ? '' : 'mt-2 '}text-lg font-bold text-teal-700 dark:text-teal-400`}
>
{askingPrice !== undefined || askingRent !== undefined ? (
<span className="text-sm font-normal text-warm-600 dark:text-warm-400">
Last sold: £{formatNumber(price)}
@ -265,9 +267,7 @@ function PropertyCard({
<span className="font-semibold text-teal-700 dark:text-teal-400">
£{formatNumber(estimatedPrice)}
</span>
{estPricePerSqm !== undefined && (
<span> (£{formatNumber(estPricePerSqm)}/m²)</span>
)}
{estPricePerSqm !== undefined && <span> (£{formatNumber(estPricePerSqm)}/m²)</span>}
</div>
)}

View file

@ -58,7 +58,7 @@ export function TravelTimeCard({
(selectedSlug: string, selectedLabel: string) => {
onSetDestination(selectedSlug, selectedLabel);
},
[onSetDestination],
[onSetDestination]
);
const sliderMin = 0;
@ -68,7 +68,9 @@ export function TravelTimeCard({
const ModeIcon = MODE_ICONS[mode];
return (
<div className={`space-y-2 px-2 py-2 rounded ${isPinned ? 'ring-2 ring-teal-400 bg-teal-50/50 dark:bg-teal-900/20' : ''}`}>
<div
className={`space-y-2 px-2 py-2 rounded ${isPinned ? 'ring-2 ring-teal-400 bg-teal-50/50 dark:bg-teal-900/20' : ''}`}
>
{/* Header */}
<div className="flex items-center justify-between">
<div className="flex items-center gap-1.5">
@ -86,7 +88,11 @@ export function TravelTimeCard({
</div>
<div className="flex items-center gap-0.5">
{slug && (
<IconButton onClick={onTogglePin} active={isPinned} title={isPinned ? 'Stop previewing' : 'Preview on map'}>
<IconButton
onClick={onTogglePin}
active={isPinned}
title={isPinned ? 'Stop previewing' : 'Preview on map'}
>
<EyeIcon className="w-3.5 h-3.5" filled={isPinned} />
</IconButton>
)}
@ -126,8 +132,8 @@ export function TravelTimeCard({
? ' by car, based on typical road speeds and the road network.'
: mode === 'bicycle'
? ' by bicycle, using cycle-friendly routes.'
: ' on foot, using pedestrian paths and pavements.'}
{' '}Use the slider to filter areas within your preferred commute time.
: ' on foot, using pedestrian paths and pavements.'}{' '}
Use the slider to filter areas within your preferred commute time.
</p>
</InfoPopup>
)}
@ -135,8 +141,8 @@ export function TravelTimeCard({
{showBestInfo && (
<InfoPopup title="Best case travel time" onClose={() => setShowBestInfo(false)}>
<p className="text-sm text-warm-700 dark:text-warm-300 leading-relaxed">
Uses the <strong>5th percentile</strong> travel time - the fastest realistic journey
if you time your departure to catch optimal connections. The default uses the{' '}
Uses the <strong>5th percentile</strong> travel time - the fastest realistic journey if
you time your departure to catch optimal connections. The default uses the{' '}
<strong>median</strong>, representing a typical journey regardless of when you leave.
</p>
</InfoPopup>
@ -156,12 +162,8 @@ export function TravelTimeCard({
onValueChange={([min, max]) => onTimeRangeChange([min, max])}
/>
<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])} min
</span>
<span className="absolute right-0">
{formatFilterValue(displayRange[1])} min
</span>
<span className="absolute left-0">{formatFilterValue(displayRange[0])} min</span>
<span className="absolute right-0">{formatFilterValue(displayRange[1])} min</span>
</div>
</div>
)}