All changes

This commit is contained in:
Andras Schmelczer 2026-03-14 21:36:00 +00:00
parent 593f380581
commit 49f7ec2f5a
60 changed files with 1783 additions and 679 deletions

View file

@ -1,15 +1,6 @@
import { memo, useState, useCallback } from 'react';
import { SpinnerIcon } from '../ui/icons/SpinnerIcon';
function SparklesIcon({ className }: { className?: string }) {
return (
<svg className={className} viewBox="0 0 16 16" fill="currentColor">
<path d="M8 1.5a.5.5 0 0 1 .5.5v2.5H11a.5.5 0 0 1 0 1H8.5V8a.5.5 0 0 1-1 0V5.5H5a.5.5 0 0 1 0-1h2.5V2a.5.5 0 0 1 .5-.5Z" />
<path d="M12.5 8a.5.5 0 0 1 .5.5v1.5h1.5a.5.5 0 0 1 0 1H13v1.5a.5.5 0 0 1-1 0V11H10.5a.5.5 0 0 1 0-1H12V8.5a.5.5 0 0 1 .5-.5Z" opacity=".7" />
<path d="M3.5 10a.5.5 0 0 1 .5.5V12h1.5a.5.5 0 0 1 0 1H4v1.5a.5.5 0 0 1-1 0V13H1.5a.5.5 0 0 1 0-1H3v-1.5a.5.5 0 0 1 .5-.5Z" opacity=".4" />
</svg>
);
}
import { SparklesIcon } from '../ui/icons/SparklesIcon';
interface AiFilterInputProps {
loading: boolean;

View file

@ -177,7 +177,7 @@ export default function AreaPane({
name={group.name}
expanded={isExpanded}
onToggle={() => toggleGroup(group.name)}
className="px-3 py-1.5 text-xs font-bold text-warm-500 bg-warm-50 dark:bg-warm-900 dark:text-warm-400 sticky top-0 z-10 hover:bg-warm-100 dark:hover:bg-warm-800"
className="px-3 py-2.5 text-sm font-bold text-warm-500 bg-warm-50 dark:bg-warm-900 dark:text-warm-400 sticky top-0 z-10 hover:bg-warm-100 dark:hover:bg-warm-800"
/>
{isExpanded && (
<div className="px-3 py-2 space-y-3">

View file

@ -12,7 +12,7 @@ import { FeatureLabel } from '../ui/FeatureLabel';
import { CarIcon, BicycleIcon, WalkingIcon, TransitIcon, PlusIcon } from '../ui/icons';
import type { ComponentType } from 'react';
import { IconButton } from '../ui/IconButton';
import { TRANSPORT_MODES, MODE_LABELS, 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,
@ -89,9 +89,9 @@ export default function FeatureBrowser({
name="Travel Time"
expanded={isSearching || expandedGroups.has('Travel Time')}
onToggle={() => toggleGroup('Travel Time')}
className="px-3 py-1.5 text-xs font-bold text-navy-950 bg-warm-200 dark:bg-navy-900 dark:text-warm-100 sticky top-0 z-10 hover:bg-warm-200 dark:hover:bg-warm-800"
className="px-3 py-2.5 text-sm font-bold text-navy-950 bg-warm-200 dark:bg-navy-900 dark:text-warm-100 sticky top-0 z-10 hover:bg-warm-200 dark:hover:bg-warm-800"
>
<span className="text-[10px] font-medium text-warm-400 dark:text-warm-500">
<span className="text-xs font-medium text-warm-400 dark:text-warm-500">
{TRANSPORT_MODES.length}
</span>
</CollapsibleGroupHeader>
@ -109,7 +109,7 @@ export default function FeatureBrowser({
{MODE_LABELS[mode]}
</span>
<span className="text-xs text-warm-400 dark:text-warm-500 block">
Filter by journey time to a destination
{MODE_DESCRIPTIONS[mode]}
</span>
</div>
</div>
@ -131,9 +131,9 @@ export default function FeatureBrowser({
name={group.name}
expanded={isExpanded}
onToggle={() => toggleGroup(group.name)}
className="px-3 py-1.5 text-xs font-bold text-navy-950 bg-warm-200 dark:bg-navy-900 dark:text-warm-100 sticky top-0 z-10 hover:bg-warm-200 dark:hover:bg-warm-800"
className="px-3 py-2.5 text-sm font-bold text-navy-950 bg-warm-200 dark:bg-navy-900 dark:text-warm-100 sticky top-0 z-10 hover:bg-warm-200 dark:hover:bg-warm-800"
>
<span className="text-[10px] font-medium text-warm-400 dark:text-warm-500">
<span className="text-xs font-medium text-warm-400 dark:text-warm-500">
{group.features.length}
</span>
</CollapsibleGroupHeader>

View file

@ -313,16 +313,16 @@ export default memo(function Filters({
name="Travel Time"
expanded={!collapsedGroups.has('Travel Time')}
onToggle={() => toggleGroup('Travel Time')}
className="px-3 py-1.5 text-xs font-bold text-navy-950 bg-warm-200 dark:bg-navy-900 dark:text-warm-100 sticky top-0 z-10 hover:bg-warm-200 dark:hover:bg-warm-800"
className="px-3 py-2.5 text-sm font-bold text-navy-950 bg-warm-200 dark:bg-navy-900 dark:text-warm-100 sticky top-0 z-10 hover:bg-warm-200 dark:hover:bg-warm-800"
>
<span className="text-[10px] font-medium text-warm-400 dark:text-warm-500">
<span className="text-xs font-medium text-warm-400 dark:text-warm-500">
{travelTimeEntries.length}
</span>
</CollapsibleGroupHeader>
{!collapsedGroups.has('Travel Time') && (
<div className="px-2 py-1 space-y-1">
{travelTimeEntries.map((entry, index) => (
<div key={index} data-filter-name={`tt_${index}`} className="scroll-mt-7">
<div key={index} data-filter-name={`tt_${index}`} className="scroll-mt-10">
<TravelTimeCard
mode={entry.mode}
slug={entry.slug}
@ -357,9 +357,9 @@ export default memo(function Filters({
name={group.name}
expanded={isExpanded}
onToggle={() => toggleGroup(group.name)}
className="px-3 py-1.5 text-xs font-bold text-navy-950 bg-warm-200 dark:bg-navy-900 dark:text-warm-100 sticky top-0 z-10 hover:bg-warm-200 dark:hover:bg-warm-800"
className="px-3 py-2.5 text-sm font-bold text-navy-950 bg-warm-200 dark:bg-navy-900 dark:text-warm-100 sticky top-0 z-10 hover:bg-warm-200 dark:hover:bg-warm-800"
>
<span className="text-[10px] font-medium text-warm-400 dark:text-warm-500">
<span className="text-xs font-medium text-warm-400 dark:text-warm-500">
{group.features.length}
</span>
</CollapsibleGroupHeader>
@ -373,7 +373,7 @@ export default memo(function Filters({
<div
key={feature.name}
data-filter-name={feature.name}
className={`scroll-mt-7 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={`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" />
@ -430,7 +430,7 @@ export default memo(function Filters({
<div
key={feature.name}
data-filter-name={feature.name}
className={`scroll-mt-7 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' : ''}`}
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" />

View file

@ -1,5 +1,5 @@
import { useState, useEffect, useMemo, useCallback, useRef } from 'react';
import type { FeatureMeta, FeatureFilters, POICategoryGroup, ViewState, PostcodeGeometry } 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';
@ -56,9 +56,14 @@ interface MapPageProps {
ogMode?: boolean;
isMobile?: boolean;
initialTravelTime?: TravelTimeInitial;
initialPostcode?: string;
user?: { id: string; subscription: string } | null;
onLoginClick?: () => void;
onRegisterClick?: () => void;
onSaveProperty?: (property: Property) => void;
onUnsaveProperty?: (id: string) => void;
isPropertySaved?: (address?: string, postcode?: string) => boolean;
getSavedPropertyId?: (address?: string, postcode?: string) => string | undefined;
}
export default function MapPage({
@ -78,9 +83,14 @@ export default function MapPage({
ogMode,
isMobile = false,
initialTravelTime,
initialPostcode,
user,
onLoginClick,
onRegisterClick,
onSaveProperty,
onUnsaveProperty,
isPropertySaved,
getSavedPropertyId,
}: MapPageProps) {
const [selectedPOICategories, setSelectedPOICategories] =
useState<Set<string>>(initialPOICategories);
@ -202,6 +212,31 @@ export default function MapPage({
selection.setRightPaneTab(initialTab);
}, []); // eslint-disable-line react-hooks/exhaustive-deps
// Navigate to a specific postcode on mount (e.g. from saved properties)
useEffect(() => {
if (!initialPostcode) return;
// Strip the `pc` param from the URL so it doesn't persist
const params = new URLSearchParams(window.location.search);
params.delete('pc');
const newUrl = params.toString() ? `/dashboard?${params}` : '/dashboard';
window.history.replaceState(window.history.state, '', newUrl);
// Fetch postcode geometry and fly to it
fetch(`/api/postcode/${encodeURIComponent(initialPostcode)}`, authHeaders())
.then((res) => {
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);
})
.catch(() => {
// Silently fail — postcode might not exist
});
}, []); // eslint-disable-line react-hooks/exhaustive-deps
// Prevent browser back/forward navigation from horizontal trackpad swipes
useEffect(() => {
const handleWheel = (e: WheelEvent) => {
@ -381,6 +416,10 @@ export default function MapPage({
loading={selection.loadingProperties}
hexagonId={selection.selectedHexagon?.id || null}
onLoadMore={selection.handleLoadMoreProperties}
onSaveProperty={onSaveProperty}
onUnsaveProperty={onUnsaveProperty}
isPropertySaved={isPropertySaved}
getSavedPropertyId={getSavedPropertyId}
/>
);

View file

@ -1,4 +1,4 @@
import { useMemo, useState } from 'react';
import { useMemo, useState, useCallback } from 'react';
import { Property } from '../../types';
import { formatDuration, formatAge, formatNumber, formatTransactionDate } from '../../lib/format';
import { getNum } from '../../lib/property-fields';
@ -6,6 +6,7 @@ import InfoPopup from '../ui/InfoPopup';
import { SearchInput } from '../ui/SearchInput';
import { EmptyState } from '../ui/EmptyState';
import { InfoIcon } from '../ui/icons';
import { BookmarkIcon } from '../ui/icons/BookmarkIcon';
interface PropertiesPaneProps {
properties: Property[];
@ -14,6 +15,10 @@ interface PropertiesPaneProps {
hexagonId: string | null;
onLoadMore: () => void;
onNavigateToSource?: (slug: string) => void;
onSaveProperty?: (property: Property) => void;
onUnsaveProperty?: (id: string) => void;
isPropertySaved?: (address?: string, postcode?: string) => boolean;
getSavedPropertyId?: (address?: string, postcode?: string) => string | undefined;
}
export function PropertiesPane({
@ -23,6 +28,10 @@ export function PropertiesPane({
hexagonId,
onLoadMore,
onNavigateToSource,
onSaveProperty,
onUnsaveProperty,
isPropertySaved,
getSavedPropertyId,
}: PropertiesPaneProps) {
const [search, setSearch] = useState('');
const [showInfo, setShowInfo] = useState(false);
@ -70,7 +79,7 @@ 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 age, and tenure from EPC surveys, plus the most recent sale price
ratings, construction year, and tenure from EPC surveys, plus the most recent sale price
from the Land Registry.
</p>
</InfoPopup>
@ -91,7 +100,14 @@ export function PropertiesPane({
) : (
<>
{filtered.map((property, idx) => (
<PropertyCard key={idx} property={property} />
<PropertyCard
key={idx}
property={property}
onSave={onSaveProperty}
onUnsave={onUnsaveProperty}
isSaved={isPropertySaved?.(property.address, property.postcode)}
savedId={getSavedPropertyId?.(property.address, property.postcode)}
/>
))}
{properties.length < total && (
<button
@ -135,14 +151,33 @@ function PropertyLoadingSkeleton() {
);
}
function PropertyCard({ property }: { property: Property }) {
function PropertyCard({
property,
onSave,
onUnsave,
isSaved,
savedId,
}: {
property: Property;
onSave?: (property: Property) => void;
onUnsave?: (id: string) => void;
isSaved?: boolean;
savedId?: string;
}) {
const handleToggleSave = useCallback(() => {
if (isSaved && savedId && onUnsave) {
onUnsave(savedId);
} else if (onSave) {
onSave(property);
}
}, [isSaved, savedId, onSave, onUnsave, property]);
const price = getNum(property, 'Last known price');
const estimatedPrice = getNum(property, 'Estimated current price');
const pricePerSqm = getNum(property, 'Price per sqm');
const estPricePerSqm = getNum(property, 'Est. price per sqm');
const floorArea = getNum(property, 'Total floor area (sqm)');
const rooms = getNum(property, 'Number of bedrooms & living rooms');
const age = getNum(property, 'Construction age');
const age = getNum(property, 'Construction year');
const transactionDate = getNum(property, 'Date of last transaction');
const askingPrice = getNum(property, 'Asking price');
const askingRent = getNum(property, 'Asking rent (monthly)');
@ -152,11 +187,26 @@ function PropertyCard({ property }: { property: Property }) {
return (
<div className="p-4 border-b border-warm-100 dark:border-warm-800 hover:bg-warm-50 dark:hover:bg-warm-800">
<div>
<div className="font-semibold dark:text-warm-100">
{property.address || 'Unknown Address'}
<div className="flex items-start justify-between gap-2">
<div className="min-w-0">
<div className="font-semibold dark:text-warm-100">
{property.address || 'Unknown Address'}
</div>
<div className="text-sm text-warm-600 dark:text-warm-400">{property.postcode}</div>
</div>
<div className="text-sm text-warm-600 dark:text-warm-400">{property.postcode}</div>
{onSave && (
<button
onClick={handleToggleSave}
className={`shrink-0 p-1 rounded ${
isSaved
? 'text-teal-600 dark:text-teal-400'
: 'text-warm-300 dark:text-warm-600 hover:text-warm-500 dark:hover:text-warm-400'
}`}
title={isSaved ? 'Unsave property' : 'Save property'}
>
<BookmarkIcon className="w-4 h-4" filled={isSaved} />
</button>
)}
</div>
{property.property_sub_type && (

View file

@ -7,7 +7,6 @@ import InfoPopup from '../ui/InfoPopup';
import { CloseIcon } from '../ui/icons/CloseIcon';
import { EyeIcon } from '../ui/icons/EyeIcon';
import { InfoIcon } from '../ui/icons/InfoIcon';
import { MapPinIcon } from '../ui/icons/MapPinIcon';
import { CarIcon } from '../ui/icons/CarIcon';
import { BicycleIcon } from '../ui/icons/BicycleIcon';
import { WalkingIcon } from '../ui/icons/WalkingIcon';
@ -52,6 +51,7 @@ export function TravelTimeCard({
onRemove,
}: TravelTimeCardProps) {
const { destinations, loading: destinationsLoading } = useTravelDestinations(mode);
const [showInfo, setShowInfo] = useState(false);
const [showBestInfo, setShowBestInfo] = useState(false);
const handleDestinationSelect = useCallback(
@ -78,6 +78,9 @@ export function TravelTimeCard({
</span>
</div>
<div className="flex items-center gap-0.5">
<IconButton onClick={() => setShowInfo(true)} title="Feature info">
<InfoIcon className="w-3.5 h-3.5" />
</IconButton>
{slug && (
<IconButton onClick={onTogglePin} active={isPinned} title={isPinned ? 'Stop previewing' : 'Preview on map'}>
<EyeIcon className="w-3.5 h-3.5" filled={isPinned} />
@ -90,28 +93,14 @@ export function TravelTimeCard({
</div>
{/* Destination */}
{slug && label ? (
<div className="flex items-center gap-1.5 px-2 py-1 rounded border border-warm-200 dark:border-warm-700 bg-warm-50 dark:bg-warm-800">
<MapPinIcon className="w-3 h-3 text-red-500 shrink-0" />
<span className="text-xs text-navy-950 dark:text-warm-200 flex-1 truncate">
{label}
</span>
<button
onClick={() => onSetDestination('', '')}
className="text-warm-400 hover:text-warm-700 dark:hover:text-warm-300 shrink-0"
title="Clear destination"
>
<CloseIcon className="w-3 h-3" />
</button>
</div>
) : (
<DestinationDropdown
destinations={destinations}
loading={destinationsLoading}
onSelect={handleDestinationSelect}
placeholder="Select destination..."
/>
)}
<DestinationDropdown
destinations={destinations}
loading={destinationsLoading}
onSelect={handleDestinationSelect}
value={label || undefined}
onClear={() => onSetDestination('', '')}
placeholder="Select destination..."
/>
{/* Best-case toggle — transit only, shown when destination is set */}
{slug && mode === 'transit' && (
@ -123,10 +112,26 @@ export function TravelTimeCard({
</div>
)}
{showInfo && (
<InfoPopup title={`Travel Time (${MODE_LABELS[mode]})`} onClose={() => setShowInfo(false)}>
<p className="text-sm text-warm-700 dark:text-warm-300 leading-relaxed">
Shows how long it takes to reach the selected destination from each area
{mode === 'transit'
? ' by public transport (bus, rail, tube). Times are computed across a typical weekday morning window.'
: mode === 'car'
? ' 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.
</p>
</InfoPopup>
)}
{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
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>