Refactor UI

This commit is contained in:
Andras Schmelczer 2026-02-04 22:27:56 +00:00
parent ce4c0cc08c
commit 34a4d0ba86
32 changed files with 1726 additions and 845 deletions

View file

@ -1,7 +1,11 @@
import React, { useMemo, useState } from 'react';
import { Property } from '../types';
import { formatDuration, formatAge } from '../lib/format';
import { formatDuration, formatAge, formatNumber } from '../lib/format';
import { getNum } from '../lib/property-fields';
import InfoPopup from './InfoPopup';
import { SearchInput } from './ui/SearchInput';
import { PaneHeader } from './ui/PaneHeader';
import { PaneEmptyState } from './ui/EmptyState';
interface PropertiesPaneProps {
properties: Property[];
@ -11,9 +15,6 @@ interface PropertiesPaneProps {
onLoadMore: () => void;
onClose: () => void;
onNavigateToSource?: (slug: string) => void;
isHoveredPreview?: boolean;
hoverMode?: boolean;
onHoverModeChange?: (enabled: boolean) => void;
}
type SortBy = 'price' | 'size' | 'energy';
@ -26,9 +27,6 @@ export function PropertiesPane({
onLoadMore,
onClose,
onNavigateToSource,
isHoveredPreview,
hoverMode,
onHoverModeChange,
}: PropertiesPaneProps) {
const [sortBy, setSortBy] = useState<SortBy>('price');
const [search, setSearch] = useState('');
@ -56,130 +54,52 @@ export function PropertiesPane({
}, [properties, sortBy, search]);
if (!hexagonId) {
return (
<div className="flex items-center justify-center h-full text-warm-500 dark:text-warm-400">
Click a hexagon to view properties
</div>
);
return <PaneEmptyState message="Click a hexagon to view properties" />;
}
return (
<div className="flex flex-col h-full">
<div className="p-4 border-b border-warm-200 dark:border-navy-700">
<div className="flex justify-between items-center">
<div className="flex items-center gap-2">
<h2 className="text-lg font-semibold dark:text-warm-100">Properties</h2>
{isHoveredPreview && (
<span className="text-xs px-1.5 py-0.5 rounded bg-teal-50 dark:bg-teal-900/30 text-teal-600 dark:text-teal-400">
Preview
</span>
)}
<button
onClick={() => setShowInfo(true)}
className="text-warm-400 hover:text-warm-700 dark:hover:text-warm-300 p-0.5 rounded"
title="Data source info"
>
<svg
className="w-3.5 h-3.5"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth={2}
>
<circle cx="12" cy="12" r="10" />
<path strokeLinecap="round" d="M12 16v-4m0-4h.01" />
</svg>
</button>
</div>
<div className="flex items-center gap-1">
{onHoverModeChange && (
<button
onClick={() => onHoverModeChange(!hoverMode)}
className={`p-1 rounded ${
hoverMode
? 'text-teal-600 dark:text-teal-400 bg-teal-50 dark:bg-teal-900/30'
: 'text-warm-400 hover:text-warm-700 dark:hover:text-warm-300'
}`}
title={
hoverMode
? 'Live preview on (click to lock)'
: 'Live preview off (click to enable)'
}
>
<svg
className="w-4 h-4"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth={2}
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"
/>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z"
/>
</svg>
</button>
)}
<button
onClick={onClose}
className="text-warm-400 hover:text-warm-700 dark:hover:text-warm-300 p-1"
>
<svg
className="w-4 h-4"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth={2}
>
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
</div>
<p className="text-sm text-warm-600 dark:text-warm-400">
{search.trim()
<PaneHeader
title="Properties"
subtitle={
search.trim()
? `${filteredAndSorted.length} match${filteredAndSorted.length !== 1 ? 'es' : ''} in ${properties.length} loaded`
: `Showing ${properties.length} of ${total} properties`}
</p>
{showInfo && (
<InfoPopup
title="Property Data"
onClose={() => setShowInfo(false)}
sourceLink={
onNavigateToSource
? {
label: 'View data source',
onClick: () => {
onNavigateToSource('epc');
setShowInfo(false);
},
}
: undefined
}
>
<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 from the Land Registry.
</p>
</InfoPopup>
)}
</div>
: `Showing ${properties.length} of ${total} properties`
}
onClose={onClose}
onInfoClick={() => setShowInfo(true)}
/>
{showInfo && (
<InfoPopup
title="Property Data"
onClose={() => setShowInfo(false)}
sourceLink={
onNavigateToSource
? {
label: 'View data source',
onClick: () => {
onNavigateToSource('epc');
setShowInfo(false);
},
}
: undefined
}
>
<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 from the Land Registry.
</p>
</InfoPopup>
)}
<div className="p-2 border-b border-warm-200 dark:border-navy-700 space-y-2">
<input
type="text"
<SearchInput
value={search}
onChange={(e) => setSearch(e.target.value)}
onChange={setSearch}
placeholder="Search by address or postcode..."
className="w-full p-2 border border-warm-300 dark:border-navy-700 rounded text-sm bg-white dark:bg-navy-800 dark:text-warm-200 placeholder-warm-400 dark:placeholder-warm-500"
className="p-2"
/>
<select
value={sortBy}
@ -216,20 +136,7 @@ export function PropertiesPane({
);
}
function getNum(property: Property, ...keys: string[]): number | undefined {
for (const key of keys) {
const v = property[key];
if (v !== undefined && v !== null && typeof v === 'number') return v;
}
return undefined;
}
function PropertyCard({ property }: { property: Property }) {
const fmt = (value: number | undefined, decimals = 0): string => {
if (value === undefined) return '';
return decimals > 0 ? value.toFixed(decimals) : Math.round(value).toLocaleString();
};
const price = getNum(property, 'Last known price', 'latest_price');
const pricePerSqm = getNum(property, 'Price per sqm', 'price_per_sqm');
const floorArea = getNum(property, 'Total floor area (sqm)', 'total_floor_area');
@ -251,11 +158,11 @@ function PropertyCard({ property }: { property: Property }) {
{price !== undefined && (
<div className="mt-2 text-lg font-bold text-teal-700 dark:text-teal-400">
£{fmt(price)}
£{formatNumber(price)}
{pricePerSqm !== undefined && (
<span className="text-sm font-normal text-warm-600 dark:text-warm-400">
{' '}
(£{fmt(pricePerSqm)}/m²)
(£{formatNumber(pricePerSqm)}/m²)
</span>
)}
</div>
@ -281,12 +188,12 @@ function PropertyCard({ property }: { property: Property }) {
)}
{floorArea !== undefined && (
<div>
<span className="text-warm-500 dark:text-warm-400">Floor area:</span> {fmt(floorArea)}m²
<span className="text-warm-500 dark:text-warm-400">Floor area:</span> {formatNumber(floorArea)}m²
</div>
)}
{rooms !== undefined && (
<div>
<span className="text-warm-500 dark:text-warm-400">Rooms:</span> {fmt(rooms)}
<span className="text-warm-500 dark:text-warm-400">Rooms:</span> {formatNumber(rooms)}
</div>
)}
{age !== undefined && (
@ -310,12 +217,12 @@ function PropertyCard({ property }: { property: Property }) {
{councilTax !== undefined ? (
<div>
<span className="text-warm-500 dark:text-warm-400">Council tax:</span> £
{fmt(councilTax)}/yr
{formatNumber(councilTax)}/yr
</div>
) : councilTaxD !== undefined ? (
<div>
<span className="text-warm-500 dark:text-warm-400">Council tax (D):</span> £
{fmt(councilTaxD)}/yr
{formatNumber(councilTaxD)}/yr
</div>
) : null}
</div>