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('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 (
Click a hexagon to view properties
); } return (

Properties

{isHoveredPreview && ( Preview )}
{onHoverModeChange && ( )}

{search.trim() ? `${filteredAndSorted.length} match${filteredAndSorted.length !== 1 ? 'es' : ''} in ${properties.length} loaded` : `Showing ${properties.length} of ${total} properties`}

{showInfo && ( setShowInfo(false)} sourceLink={ onNavigateToSource ? { label: 'View data source', onClick: () => { onNavigateToSource('epc'); setShowInfo(false); }, } : undefined } >

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.

)}
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" />
{loading && properties.length === 0 ? (
Loading...
) : ( <> {filteredAndSorted.map((property, idx) => ( ))} {properties.length < total && ( )} )}
); } 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 (
{property.address || 'Unknown Address'}
{property.postcode}
{price !== undefined && (
£{fmt(price)} {pricePerSqm !== undefined && ( {' '} (£{fmt(pricePerSqm)}/m²) )}
)}
{property.property_type && (
Type: {property.property_type}
)} {property.built_form && (
Built form:{' '} {property.built_form}
)} {property.duration && (
Tenure:{' '} {formatDuration(property.duration)}
)} {floorArea !== undefined && (
Floor area: {fmt(floorArea)}m²
)} {rooms !== undefined && (
Rooms: {fmt(rooms)}
)} {age !== undefined && (
Built:{' '} {formatAge(age, property.is_construction_date_approximate ?? true)}
)} {property.current_energy_rating && (
EPC rating:{' '} {property.current_energy_rating}
)} {property.potential_energy_rating && (
EPC potential:{' '} {property.potential_energy_rating}
)} {councilTax !== undefined ? (
Council tax: £ {fmt(councilTax)}/yr
) : councilTaxD !== undefined ? (
Council tax (D): £ {fmt(councilTaxD)}/yr
) : null}
); }