import { useMemo, useState, useCallback } from 'react'; import { Property } from '../../types'; import { formatDuration, formatAge, formatNumber, formatTransactionDate } from '../../lib/format'; import { getNum } from '../../lib/property-fields'; 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[]; total: number; loading: boolean; 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({ properties, total, loading, hexagonId, onLoadMore, onNavigateToSource, onSaveProperty, onUnsaveProperty, isPropertySaved, getSavedPropertyId, }: PropertiesPaneProps) { const [search, setSearch] = useState(''); const [showInfo, setShowInfo] = useState(false); const filtered = useMemo(() => { const query = search.trim().toLowerCase(); return query ? properties.filter((p) => { const addr = (p.address || '').toLowerCase(); const pc = (p.postcode || '').toLowerCase(); return addr.includes(query) || pc.includes(query); }) : properties; }, [properties, search]); if (!hexagonId) { return ( } title="No area selected" description="Click a hexagon or postcode to view area statistics" centered /> ); } return (
{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 year, and tenure from EPC surveys, plus the most recent sale price from the Land Registry.

)}
{loading && properties.length === 0 ? ( ) : ( <> {filtered.map((property, idx) => ( ))} {properties.length < total && ( )} )}
); } function PropertyLoadingSkeleton() { return (
{Array.from({ length: 5 }).map((_, idx) => (
{Array.from({ length: 6 }).map((_, i) => (
))}
))}
); } 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 year'); const transactionDate = getNum(property, 'Date of last transaction'); const askingPrice = getNum(property, 'Asking price'); const askingRent = getNum(property, 'Asking rent (monthly)'); const bedrooms = getNum(property, 'Bedrooms'); const bathrooms = getNum(property, 'Bathrooms'); const listingDate = getNum(property, 'Listing date'); return (
{property.address || 'Unknown Address'}
{property.postcode}
{onSave && ( )}
{property.property_sub_type && (
{property.property_sub_type}
)} {askingPrice !== undefined && (
{property.price_qualifier && ( {property.price_qualifier}{' '} )} £{formatNumber(askingPrice)}
)} {askingRent !== undefined && (
£{formatNumber(askingRent)} /mo
)} {price !== undefined && (
{askingPrice !== undefined || askingRent !== undefined ? ( Last sold: £{formatNumber(price)} {transactionDate !== undefined && ` (${formatTransactionDate(transactionDate)})`} ) : ( <> £{formatNumber(price)} {transactionDate !== undefined && ( {' '} ({formatTransactionDate(transactionDate)}) )} {pricePerSqm !== undefined && ( {' '} £{formatNumber(pricePerSqm)}/m² )} )}
)} {estimatedPrice !== undefined && (
Est. value:{' '} £{formatNumber(estimatedPrice)} {estPricePerSqm !== undefined && ( (£{formatNumber(estPricePerSqm)}/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:{' '} {formatNumber(floorArea)}m²
)} {bedrooms !== undefined && (
Bedrooms:{' '} {formatNumber(bedrooms)}
)} {bathrooms !== undefined && (
Bathrooms:{' '} {formatNumber(bathrooms)}
)} {rooms !== undefined && (
Rooms: {formatNumber(rooms)}
)} {age !== undefined && (
Built:{' '} {formatAge(age, property.is_construction_date_approximate)}
)} {property.current_energy_rating && (
EPC rating:{' '} {property.current_energy_rating}
)} {property.potential_energy_rating && (
EPC potential:{' '} {property.potential_energy_rating}
)} {listingDate !== undefined && (
Listed:{' '} {formatTransactionDate(listingDate)}
)}
{property.listing_features && property.listing_features.length > 0 && (
Key features
{property.listing_features.map((feature, idx) => ( {feature} ))}
)} {property.renovation_history && property.renovation_history.length > 0 && (
Renovations
{property.renovation_history.map((reno, idx) => ( {reno.event} {reno.year} ))}
)} {property.listing_url && ( )}
); }