import { useMemo, useState, useEffect, type MutableRefObject } from 'react'; import { useTranslation } from 'react-i18next'; import { Property } from '../../types'; import { formatDuration, formatAge, formatNumber, formatTransactionDate, formatYearMonth, } from '../../lib/format'; import { getNum } from '../../lib/property-fields'; import { buildEpcCertificateUrl } from '../../lib/external-search'; import { useRetainedScrollTop } from '../../hooks/useRetainedScrollTop'; import InfoPopup from '../ui/InfoPopup'; import { SearchInput } from '../ui/SearchInput'; import { EmptyState } from '../ui/EmptyState'; import { InfoIcon } from '../ui/icons'; import { IndeterminateProgressBar } from '../ui/IndeterminateProgressBar'; import { ts } from '../../i18n/server'; interface PropertiesPaneProps { properties: Property[]; total: number; loading: boolean; hexagonId: string | null; statsUseFilters: boolean; onStatsUseFiltersChange: (useFilters: boolean) => void; filtersActive: boolean; onLoadMore: () => void; onNavigateToSource?: (slug: string) => void; scrollTopRef?: MutableRefObject; scrollRestoreKey?: string | null; scrollSaveDisabled?: boolean; } export function PropertiesPane({ properties, total, loading, hexagonId, statsUseFilters, onStatsUseFiltersChange, filtersActive, onLoadMore, onNavigateToSource, scrollTopRef, scrollRestoreKey, scrollSaveDisabled, }: PropertiesPaneProps) { const { t } = useTranslation(); const [search, setSearch] = useState(''); const [showInfo, setShowInfo] = useState(false); const { scrollRef, onScroll } = useRetainedScrollTop({ restoreKey: scrollRestoreKey ?? hexagonId, scrollTopRef, suspendSave: scrollSaveDisabled ?? (loading && properties.length === 0), }); useEffect(() => { setSearch(''); }, [hexagonId]); 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={t('common.noAreaSelected')} description={t('common.noAreaSelectedDesc')} centered /> ); } return (
0} />
{showInfo && ( setShowInfo(false)} sourceLink={ onNavigateToSource ? { label: t('common.viewDataSource'), onClick: () => { onNavigateToSource('epc'); setShowInfo(false); }, } : undefined } >

{t('propertyCard.propertyDataDesc')}

)}
{filtersActive && (
)}
{loading && properties.length === 0 ? ( ) : ( <> {filtered.map((property) => ( ))} {properties.length < total && ( )} )}
); } function PropertyLoadingSkeleton() { return (
{Array.from({ length: 5 }).map((_, idx) => (
{Array.from({ length: 6 }).map((_, i) => (
))}
))}
); } function PropertyCard({ property }: { property: Property }) { const { t } = useTranslation(); 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'); return (
{property.address || t('propertyCard.unknownAddress')}
{property.postcode} {property.former_council_house === 'Yes' && ( {t('propertyCard.exCouncilBadge')} )} {property.listed_building === 'Yes' && ( {t('propertyCard.listedBuildingBadge')} )}
{property.property_sub_type && (
{property.property_sub_type}
)} {price !== undefined && (
£{formatNumber(price)} {transactionDate !== undefined && ( {' '} ({formatTransactionDate(transactionDate)}) )} {pricePerSqm !== undefined && ( {' '} £{formatNumber(pricePerSqm)}/m² )}
)} {estimatedPrice !== undefined && (
{t('propertyCard.estValue')}{' '} £{formatNumber(estimatedPrice)} {estPricePerSqm !== undefined && (£{formatNumber(estPricePerSqm)}/m²)}
)}
{property.property_type && (
{t('propertyCard.type')}{' '} {ts(property.property_type)}
)} {property.built_form && (
{t('propertyCard.builtForm')}{' '} {ts(property.built_form)}
)} {property.duration && (
{t('propertyCard.tenure')}{' '} {formatDuration(property.duration)}
)} {property.within_conservation_area && (
{t('propertyCard.withinConservationArea')} {' '} {ts(property.within_conservation_area)}
)} {floorArea !== undefined && (
{t('propertyCard.floorArea')}{' '} {formatNumber(floorArea)}m²
)} {rooms !== undefined && (
{t('propertyCard.rooms')}{' '} {formatNumber(rooms)}
)} {age !== undefined && (
{t('propertyCard.built')}{' '} {formatAge(age, property.is_construction_date_approximate)}
)} {property.current_energy_rating && (
{t('propertyCard.epcRating')}{' '} {ts(property.current_energy_rating)}
)} {property.potential_energy_rating && (
{t('propertyCard.epcPotential')} {' '} {ts(property.potential_energy_rating)}
)}
{property.postcode && property.current_energy_rating && ( {t('propertyCard.viewEpcCertificate')} )}
); } type TimelineEvent = | { kind: 'sale'; year: number; month: number; price: number; isNew: boolean; sortKey: number } | { kind: 'reno'; year: number; event: string; sortKey: number } | { kind: 'tenure'; year: number; status: string; sortKey: number } | { kind: 'built'; year: number; approximate: boolean; sortKey: number }; export function buildTimelineEvents(property: Property): TimelineEvent[] { const events: TimelineEvent[] = []; // Skip the most recent sale: it's already shown in the card headline. // historical_prices is sorted oldest→newest by the pipeline. const sales = property.historical_prices ?? []; const olderSales = sales.length > 0 ? sales.slice(0, -1) : []; for (const sale of olderSales) { events.push({ kind: 'sale', year: sale.year, month: sale.month, price: sale.price, isNew: sale.is_new, sortKey: sale.year + (Math.max(sale.month, 1) - 1) / 12, }); } for (const reno of property.renovation_history ?? []) { events.push({ kind: 'reno', year: reno.year, event: reno.event, // Mid-year so renos sort between Jan and Dec sales of the same year. sortKey: reno.year + 0.5, }); } for (const tenure of property.tenure_history ?? []) { events.push({ kind: 'tenure', year: tenure.year, status: tenure.status, // Mid-year, like renos: a tenure change is dated only to the EPC year. sortKey: tenure.year + 0.5, }); } const builtYear = getNum(property, 'Construction year'); const approximate = property.is_construction_date_approximate ?? true; if (builtYear !== undefined && Number.isFinite(builtYear) && builtYear > 0) { const builtYearRounded = Math.round(builtYear); // New-builds (exact date from price-paid) duplicate the first sale's year. // Suppress the "Built" marker in that case since the sale carries the info. const newBuildDuplicate = !approximate && sales.some((sale) => sale.year === builtYearRounded); if (!newBuildDuplicate) { events.push({ kind: 'built', year: builtYearRounded, approximate, sortKey: builtYear, }); } } events.sort((a, b) => b.sortKey - a.sortKey); return events; } function PropertyTimeline({ property }: { property: Property }) { const { t } = useTranslation(); const events = useMemo(() => buildTimelineEvents(property), [property]); if (events.length === 0) return null; // Translate the pipeline's canonical tenure status. Literal keys keep the // typed `t` happy; an unexpected status falls back to its raw value (the same // contract the renovation labels use) rather than vanishing. const tenureLabel = (status: string): string => { switch (status) { case 'Owner-occupied': return t('propertyCard.tenureOwnerOccupied'); case 'Rented (private)': return t('propertyCard.tenureRentedPrivate'); case 'Rented (social)': return t('propertyCard.tenureRentedSocial'); default: return status; } }; return (
{t('propertyCard.historyTitle')}
    {events.map((event, idx) => (
  1. {event.kind === 'sale' && ( <> £{formatNumber(event.price)} {event.isNew && ( {t('propertyCard.historyNewBuild')} )} {formatYearMonth(event.year, event.month)} )} {event.kind === 'reno' && ( <> {event.event} {event.year} )} {event.kind === 'tenure' && ( <> {tenureLabel(event.status)} {event.year} )} {event.kind === 'built' && ( <> {t('propertyCard.historyBuilt')} {event.approximate ? `~${event.year}` : event.year} )}
  2. ))}
); } function TimelineMarker({ kind }: { kind: TimelineEvent['kind'] }) { const base = 'absolute -left-[5px] top-1 w-2 h-2 rounded-full ring-2'; if (kind === 'sale') { return ( ); } if (kind === 'reno') { return ( ); } if (kind === 'tenure') { return ( ); } return ( ); }