324 lines
12 KiB
TypeScript
324 lines
12 KiB
TypeScript
import React, { useMemo, useState } from 'react';
|
|
import { Property } from '../types';
|
|
import { formatDuration, formatAge } from '../lib/format';
|
|
import InfoPopup from './InfoPopup';
|
|
|
|
interface PropertiesPaneProps {
|
|
properties: Property[];
|
|
total: number;
|
|
loading: boolean;
|
|
hexagonId: string | null;
|
|
onLoadMore: () => void;
|
|
onClose: () => void;
|
|
onNavigateToSource?: (slug: string) => void;
|
|
isHoveredPreview?: boolean;
|
|
hoverMode?: boolean;
|
|
onHoverModeChange?: (enabled: boolean) => void;
|
|
}
|
|
|
|
type SortBy = 'price' | 'size' | 'energy';
|
|
|
|
export function PropertiesPane({
|
|
properties,
|
|
total,
|
|
loading,
|
|
hexagonId,
|
|
onLoadMore,
|
|
onClose,
|
|
onNavigateToSource,
|
|
isHoveredPreview,
|
|
hoverMode,
|
|
onHoverModeChange,
|
|
}: PropertiesPaneProps) {
|
|
const [sortBy, setSortBy] = useState<SortBy>('price');
|
|
const [search, setSearch] = useState('');
|
|
const [showInfo, setShowInfo] = useState(false);
|
|
|
|
const filteredAndSorted = useMemo(() => {
|
|
const query = search.trim().toLowerCase();
|
|
const filtered = query
|
|
? properties.filter((p) => {
|
|
const addr = (p.address || '').toLowerCase();
|
|
const pc = (p.postcode || '').toLowerCase();
|
|
return addr.includes(query) || pc.includes(query);
|
|
})
|
|
: properties;
|
|
return [...filtered].sort((a, b) => {
|
|
switch (sortBy) {
|
|
case 'price':
|
|
return ((b.latest_price as number) || 0) - ((a.latest_price as number) || 0);
|
|
case 'size':
|
|
return ((b.total_floor_area as number) || 0) - ((a.total_floor_area as number) || 0);
|
|
case 'energy':
|
|
return (a.current_energy_rating || 'Z').localeCompare(b.current_energy_rating || 'Z');
|
|
}
|
|
});
|
|
}, [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 (
|
|
<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()
|
|
? `${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>
|
|
|
|
<div className="p-2 border-b border-warm-200 dark:border-navy-700 space-y-2">
|
|
<input
|
|
type="text"
|
|
value={search}
|
|
onChange={(e) => setSearch(e.target.value)}
|
|
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"
|
|
/>
|
|
<select
|
|
value={sortBy}
|
|
onChange={(e) => setSortBy(e.target.value as SortBy)}
|
|
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"
|
|
>
|
|
<option value="price">Price (High to Low)</option>
|
|
<option value="size">Size (Large to Small)</option>
|
|
<option value="energy">Energy Rating (Best to Worst)</option>
|
|
</select>
|
|
</div>
|
|
|
|
<div className="flex-1 overflow-y-auto">
|
|
{loading && properties.length === 0 ? (
|
|
<div className="p-4 dark:text-warm-400">Loading...</div>
|
|
) : (
|
|
<>
|
|
{filteredAndSorted.map((property, idx) => (
|
|
<PropertyCard key={idx} property={property} />
|
|
))}
|
|
{properties.length < total && (
|
|
<button
|
|
onClick={onLoadMore}
|
|
disabled={loading}
|
|
className="w-full p-4 text-teal-600 dark:text-teal-400 hover:bg-teal-50 dark:hover:bg-teal-900/30 disabled:opacity-50"
|
|
>
|
|
{loading ? 'Loading...' : `Load More (${total - properties.length} remaining)`}
|
|
</button>
|
|
)}
|
|
</>
|
|
)}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
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');
|
|
const rooms = getNum(
|
|
property,
|
|
'Rooms (including bedrooms & bathrooms)',
|
|
'number_habitable_rooms'
|
|
);
|
|
const age = getNum(property, 'Approximate construction age', 'construction_age_band');
|
|
const councilTax = getNum(property, 'Council tax (£/yr)');
|
|
const councilTaxD = getNum(property, 'Council tax Band D (£/yr)');
|
|
|
|
return (
|
|
<div className="p-4 border-b border-warm-100 dark:border-navy-800 hover:bg-warm-50 dark:hover:bg-navy-800">
|
|
<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>
|
|
|
|
{price !== undefined && (
|
|
<div className="mt-2 text-lg font-bold text-teal-700 dark:text-teal-400">
|
|
£{fmt(price)}
|
|
{pricePerSqm !== undefined && (
|
|
<span className="text-sm font-normal text-warm-600 dark:text-warm-400">
|
|
{' '}
|
|
(£{fmt(pricePerSqm)}/m²)
|
|
</span>
|
|
)}
|
|
</div>
|
|
)}
|
|
|
|
<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">Type:</span> {property.property_type}
|
|
</div>
|
|
)}
|
|
{property.built_form && (
|
|
<div>
|
|
<span className="text-warm-500 dark:text-warm-400">Built form:</span>{' '}
|
|
{property.built_form}
|
|
</div>
|
|
)}
|
|
{property.duration && (
|
|
<div>
|
|
<span className="text-warm-500 dark:text-warm-400">Tenure:</span>{' '}
|
|
{formatDuration(property.duration)}
|
|
</div>
|
|
)}
|
|
{floorArea !== undefined && (
|
|
<div>
|
|
<span className="text-warm-500 dark:text-warm-400">Floor area:</span> {fmt(floorArea)}m²
|
|
</div>
|
|
)}
|
|
{rooms !== undefined && (
|
|
<div>
|
|
<span className="text-warm-500 dark:text-warm-400">Rooms:</span> {fmt(rooms)}
|
|
</div>
|
|
)}
|
|
{age !== undefined && (
|
|
<div>
|
|
<span className="text-warm-500 dark:text-warm-400">Built:</span>{' '}
|
|
{formatAge(age, property.is_construction_date_approximate ?? true)}
|
|
</div>
|
|
)}
|
|
{property.current_energy_rating && (
|
|
<div>
|
|
<span className="text-warm-500 dark:text-warm-400">EPC rating:</span>{' '}
|
|
{property.current_energy_rating}
|
|
</div>
|
|
)}
|
|
{property.potential_energy_rating && (
|
|
<div>
|
|
<span className="text-warm-500 dark:text-warm-400">EPC potential:</span>{' '}
|
|
{property.potential_energy_rating}
|
|
</div>
|
|
)}
|
|
{councilTax !== undefined ? (
|
|
<div>
|
|
<span className="text-warm-500 dark:text-warm-400">Council tax:</span> £
|
|
{fmt(councilTax)}/yr
|
|
</div>
|
|
) : councilTaxD !== undefined ? (
|
|
<div>
|
|
<span className="text-warm-500 dark:text-warm-400">Council tax (D):</span> £
|
|
{fmt(councilTaxD)}/yr
|
|
</div>
|
|
) : null}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|