388 lines
14 KiB
TypeScript
388 lines
14 KiB
TypeScript
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 (
|
|
<EmptyState
|
|
icon={<InfoIcon className="w-8 h-8 text-warm-300 dark:text-warm-600" />}
|
|
title="No area selected"
|
|
description="Click a hexagon or postcode to view area statistics"
|
|
centered
|
|
/>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div className="h-full overflow-y-auto">
|
|
{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 year, and tenure from EPC surveys, plus the most recent sale price
|
|
from the Land Registry.
|
|
</p>
|
|
</InfoPopup>
|
|
)}
|
|
|
|
<div className="p-2">
|
|
<SearchInput
|
|
value={search}
|
|
onChange={setSearch}
|
|
placeholder="Search by address or postcode..."
|
|
className="p-2"
|
|
/>
|
|
</div>
|
|
|
|
<div>
|
|
{loading && properties.length === 0 ? (
|
|
<PropertyLoadingSkeleton />
|
|
) : (
|
|
<>
|
|
{filtered.map((property, idx) => (
|
|
<PropertyCard
|
|
key={idx}
|
|
property={property}
|
|
onSave={onSaveProperty}
|
|
onUnsave={onUnsaveProperty}
|
|
isSaved={isPropertySaved?.(property.address, property.postcode)}
|
|
savedId={getSavedPropertyId?.(property.address, property.postcode)}
|
|
/>
|
|
))}
|
|
{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 transition-colors"
|
|
>
|
|
{loading ? (
|
|
<span className="flex items-center justify-center gap-2">
|
|
<span className="inline-block w-4 h-4 border-2 border-teal-600 dark:border-teal-400 border-t-transparent rounded-full animate-spin" />
|
|
Loading...
|
|
</span>
|
|
) : (
|
|
`Load More (${total - properties.length} remaining)`
|
|
)}
|
|
</button>
|
|
)}
|
|
</>
|
|
)}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function PropertyLoadingSkeleton() {
|
|
return (
|
|
<div className="space-y-0">
|
|
{Array.from({ length: 5 }).map((_, idx) => (
|
|
<div key={idx} className="p-4 border-b border-warm-100 dark:border-navy-800 animate-pulse">
|
|
<div className="h-5 w-3/4 bg-warm-200 dark:bg-warm-700 rounded mb-2" />
|
|
<div className="h-4 w-24 bg-warm-200 dark:bg-warm-700 rounded mb-3" />
|
|
<div className="h-6 w-32 bg-warm-200 dark:bg-warm-700 rounded mb-3" />
|
|
<div className="grid grid-cols-2 gap-x-4 gap-y-2">
|
|
{Array.from({ length: 6 }).map((_, i) => (
|
|
<div key={i} className="h-4 bg-warm-200 dark:bg-warm-700 rounded" />
|
|
))}
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
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 (
|
|
<div className="p-4 border-b border-warm-100 dark:border-warm-800 hover:bg-warm-50 dark:hover:bg-warm-800">
|
|
<div className="flex items-start justify-between gap-2">
|
|
<div className="min-w-0">
|
|
<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>
|
|
</div>
|
|
{onSave && (
|
|
<button
|
|
onClick={handleToggleSave}
|
|
className={`shrink-0 p-1 rounded ${
|
|
isSaved
|
|
? 'text-teal-600 dark:text-teal-400'
|
|
: 'text-warm-300 dark:text-warm-600 hover:text-warm-500 dark:hover:text-warm-400'
|
|
}`}
|
|
title={isSaved ? 'Unsave property' : 'Save property'}
|
|
>
|
|
<BookmarkIcon className="w-4 h-4" filled={isSaved} />
|
|
</button>
|
|
)}
|
|
</div>
|
|
|
|
{property.property_sub_type && (
|
|
<div className="text-sm text-warm-600 dark:text-warm-400 mt-1">
|
|
{property.property_sub_type}
|
|
</div>
|
|
)}
|
|
|
|
{askingPrice !== undefined && (
|
|
<div className="mt-2 text-lg font-bold text-teal-700 dark:text-teal-400">
|
|
{property.price_qualifier && (
|
|
<span className="text-sm font-normal text-warm-500 dark:text-warm-400">
|
|
{property.price_qualifier}{' '}
|
|
</span>
|
|
)}
|
|
£{formatNumber(askingPrice)}
|
|
</div>
|
|
)}
|
|
|
|
{askingRent !== undefined && (
|
|
<div className="mt-2 text-lg font-bold text-teal-700 dark:text-teal-400">
|
|
£{formatNumber(askingRent)}
|
|
<span className="text-sm font-normal text-warm-600 dark:text-warm-400">/mo</span>
|
|
</div>
|
|
)}
|
|
|
|
{price !== undefined && (
|
|
<div className={`${askingPrice !== undefined || askingRent !== undefined ? '' : 'mt-2 '}text-lg font-bold text-teal-700 dark:text-teal-400`}>
|
|
{askingPrice !== undefined || askingRent !== undefined ? (
|
|
<span className="text-sm font-normal text-warm-600 dark:text-warm-400">
|
|
Last sold: £{formatNumber(price)}
|
|
{transactionDate !== undefined && ` (${formatTransactionDate(transactionDate)})`}
|
|
</span>
|
|
) : (
|
|
<>
|
|
£{formatNumber(price)}
|
|
{transactionDate !== undefined && (
|
|
<span className="text-sm font-normal text-warm-600 dark:text-warm-400">
|
|
{' '}
|
|
({formatTransactionDate(transactionDate)})
|
|
</span>
|
|
)}
|
|
{pricePerSqm !== undefined && (
|
|
<span className="text-sm font-normal text-warm-600 dark:text-warm-400">
|
|
{' '}
|
|
£{formatNumber(pricePerSqm)}/m²
|
|
</span>
|
|
)}
|
|
</>
|
|
)}
|
|
</div>
|
|
)}
|
|
{estimatedPrice !== undefined && (
|
|
<div className="text-sm text-warm-600 dark:text-warm-400">
|
|
Est. value:{' '}
|
|
<span className="font-semibold text-teal-700 dark:text-teal-400">
|
|
£{formatNumber(estimatedPrice)}
|
|
</span>
|
|
{estPricePerSqm !== undefined && (
|
|
<span> (£{formatNumber(estPricePerSqm)}/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>{' '}
|
|
{formatNumber(floorArea)}m²
|
|
</div>
|
|
)}
|
|
{bedrooms !== undefined && (
|
|
<div>
|
|
<span className="text-warm-500 dark:text-warm-400">Bedrooms:</span>{' '}
|
|
{formatNumber(bedrooms)}
|
|
</div>
|
|
)}
|
|
{bathrooms !== undefined && (
|
|
<div>
|
|
<span className="text-warm-500 dark:text-warm-400">Bathrooms:</span>{' '}
|
|
{formatNumber(bathrooms)}
|
|
</div>
|
|
)}
|
|
{rooms !== undefined && (
|
|
<div>
|
|
<span className="text-warm-500 dark:text-warm-400">Rooms:</span> {formatNumber(rooms)}
|
|
</div>
|
|
)}
|
|
{age !== undefined && (
|
|
<div>
|
|
<span className="text-warm-500 dark:text-warm-400">Built:</span>{' '}
|
|
{formatAge(age, property.is_construction_date_approximate)}
|
|
</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>
|
|
)}
|
|
{listingDate !== undefined && (
|
|
<div>
|
|
<span className="text-warm-500 dark:text-warm-400">Listed:</span>{' '}
|
|
{formatTransactionDate(listingDate)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{property.listing_features && property.listing_features.length > 0 && (
|
|
<div className="mt-2">
|
|
<div className="text-xs text-warm-500 dark:text-warm-400 mb-1">Key features</div>
|
|
<div className="flex flex-wrap gap-1">
|
|
{property.listing_features.map((feature, idx) => (
|
|
<span
|
|
key={idx}
|
|
className="text-xs bg-warm-100 dark:bg-warm-700 text-warm-700 dark:text-warm-300 rounded px-1.5 py-0.5"
|
|
>
|
|
{feature}
|
|
</span>
|
|
))}
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{property.renovation_history && property.renovation_history.length > 0 && (
|
|
<div className="mt-2">
|
|
<div className="text-xs text-warm-500 dark:text-warm-400 mb-1">Renovations</div>
|
|
<div className="flex flex-wrap gap-1">
|
|
{property.renovation_history.map((reno, idx) => (
|
|
<span
|
|
key={idx}
|
|
className="inline-flex items-center gap-1 text-xs bg-warm-100 dark:bg-warm-700 text-warm-700 dark:text-warm-300 rounded px-1.5 py-0.5"
|
|
>
|
|
{reno.event}
|
|
<span className="text-warm-500 dark:text-warm-400">{reno.year}</span>
|
|
</span>
|
|
))}
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{property.listing_url && (
|
|
<div className="mt-2">
|
|
<a
|
|
href={property.listing_url}
|
|
target="_blank"
|
|
rel="noopener noreferrer"
|
|
className="text-xs text-teal-600 dark:text-teal-400 hover:text-teal-800 dark:hover:text-teal-300"
|
|
>
|
|
View external listing →
|
|
</a>
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|