This commit is contained in:
Andras Schmelczer 2026-02-02 21:56:35 +00:00
parent 2c613dc0d1
commit a677b9331f
28 changed files with 1647 additions and 1498 deletions

View file

@ -1,5 +1,7 @@
import React, { useMemo, useState, useRef, useEffect } from 'react';
import React, { useMemo, useState } from 'react';
import { Property } from '../types';
import { formatDuration, formatAge } from '../lib/format';
import InfoPopup from './InfoPopup';
interface PropertiesPaneProps {
properties: Property[];
@ -31,20 +33,7 @@ export function PropertiesPane({
const [sortBy, setSortBy] = useState<SortBy>('price');
const [search, setSearch] = useState('');
const [showInfo, setShowInfo] = useState(false);
const infoPopupRef = useRef<HTMLDivElement>(null);
useEffect(() => {
if (!showInfo) return;
function handleClickOutside(e: MouseEvent) {
if (infoPopupRef.current && !infoPopupRef.current.contains(e.target as Node)) {
setShowInfo(false);
}
}
document.addEventListener('mousedown', handleClickOutside);
return () => document.removeEventListener('mousedown', handleClickOutside);
}, [showInfo]);
// Filter and sort properties
const filteredAndSorted = useMemo(() => {
const query = search.trim().toLowerCase();
const filtered = query
@ -76,7 +65,6 @@ export function PropertiesPane({
return (
<div className="flex flex-col h-full">
{/* Header */}
<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">
@ -91,7 +79,13 @@ export function PropertiesPane({
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}>
<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>
@ -106,11 +100,29 @@ export function PropertiesPane({
? '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)'}
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
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>
)}
@ -118,7 +130,13 @@ export function PropertiesPane({
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}>
<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>
@ -130,47 +148,31 @@ export function PropertiesPane({
: `Showing ${properties.length} of ${total} properties`}
</p>
{showInfo && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/30">
<div
ref={infoPopupRef}
className="bg-white dark:bg-navy-800 border border-warm-200 dark:border-navy-700 rounded-lg shadow-xl max-w-md w-full mx-4 p-5"
>
<div className="flex items-start justify-between mb-3">
<h3 className="text-sm font-semibold text-warm-900 dark:text-warm-100 pr-4">
Property Data
</h3>
<button
onClick={() => setShowInfo(false)}
className="text-warm-400 hover:text-warm-700 dark:hover:text-warm-300 shrink-0"
>
<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>
<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>
{onNavigateToSource && (
<button
onClick={() => {
onNavigateToSource('epc');
setShowInfo(false);
}}
className="text-sm text-teal-600 dark:text-teal-400 hover:text-teal-800 dark:hover:text-teal-300 hover:underline"
>
View data source
</button>
)}
</div>
</div>
<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>
{/* Search and sort controls */}
<div className="p-2 border-b border-warm-200 dark:border-navy-700 space-y-2">
<input
type="text"
@ -190,7 +192,6 @@ export function PropertiesPane({
</select>
</div>
{/* Properties list */}
<div className="flex-1 overflow-y-auto">
{loading && properties.length === 0 ? (
<div className="p-4 dark:text-warm-400">Loading...</div>
@ -215,18 +216,6 @@ export function PropertiesPane({
);
}
function formatDuration(d: string): string {
if (d === 'F') return 'Freehold';
if (d === 'L') return 'Leasehold';
return d;
}
function formatAge(value: number, approximate = true): string {
if (value >= 1000) return approximate ? `~${Math.round(value)}` : `${Math.round(value)}`;
return Math.round(value).toString();
}
// Helper to get a numeric value from a property, trying multiple field names
function getNum(property: Property, ...keys: string[]): number | undefined {
for (const key of keys) {
const v = property[key];
@ -235,7 +224,6 @@ function getNum(property: Property, ...keys: string[]): number | undefined {
return undefined;
}
// Property card component showing all fields
function PropertyCard({ property }: { property: Property }) {
const fmt = (value: number | undefined, decimals = 0): string => {
if (value === undefined) return '';
@ -251,24 +239,27 @@ function PropertyCard({ property }: { property: Property }) {
'number_habitable_rooms'
);
const age = getNum(property, 'Approximate construction age', 'construction_age_band');
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">
{/* Address & postcode */}
<div className="font-semibold dark:text-warm-100">{property.address || 'Unknown Address'}</div>
<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 */}
{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>
<span className="text-sm font-normal text-warm-600 dark:text-warm-400">
{' '}
(£{fmt(pricePerSqm)}/m²)
</span>
)}
</div>
)}
{/* Property details grid */}
<div className="mt-2 grid grid-cols-2 gap-x-4 gap-y-1 text-sm dark:text-warm-300">
{property.property_type && (
<div>
@ -277,12 +268,14 @@ function PropertyCard({ property }: { property: Property }) {
)}
{property.built_form && (
<div>
<span className="text-warm-500 dark:text-warm-400">Built form:</span> {property.built_form}
<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)}
<span className="text-warm-500 dark:text-warm-400">Tenure:</span>{' '}
{formatDuration(property.duration)}
</div>
)}
{floorArea !== undefined && (
@ -297,17 +290,26 @@ function PropertyCard({ property }: { property: Property }) {
)}
{age !== undefined && (
<div>
<span className="text-warm-500 dark:text-warm-400">Built:</span> {formatAge(age, property.is_construction_date_approximate ?? true)}
<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}
<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}
<span className="text-warm-500 dark:text-warm-400">EPC potential:</span>{' '}
{property.potential_energy_rating}
</div>
)}
{councilTaxD !== undefined && (
<div>
<span className="text-warm-500 dark:text-warm-400">Council tax (D):</span> £
{fmt(councilTaxD)}/yr
</div>
)}
</div>