perfect-postcode/frontend/src/components/map/PropertiesPane.tsx
2026-07-12 20:37:39 +01:00

541 lines
19 KiB
TypeScript

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<number>;
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<HTMLDivElement>({
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 (
<EmptyState
icon={<InfoIcon className="w-8 h-8 text-warm-300 dark:text-warm-600" />}
title={t('common.noAreaSelected')}
description={t('common.noAreaSelectedDesc')}
centered
/>
);
}
return (
<div className="relative flex h-full flex-col">
<IndeterminateProgressBar show={loading && properties.length > 0} />
<div
ref={scrollRef}
onScroll={onScroll}
className="flex-1 overflow-y-auto pb-[env(safe-area-inset-bottom)]"
>
{showInfo && (
<InfoPopup
title={t('propertyCard.propertyData')}
onClose={() => setShowInfo(false)}
sourceLink={
onNavigateToSource
? {
label: t('common.viewDataSource'),
onClick: () => {
onNavigateToSource('epc');
setShowInfo(false);
},
}
: undefined
}
>
<p className="text-sm text-warm-700 dark:text-warm-300 mb-4 leading-relaxed">
{t('propertyCard.propertyDataDesc')}
</p>
</InfoPopup>
)}
<div className="p-2 space-y-2">
<SearchInput
value={search}
onChange={setSearch}
placeholder={t('propertyCard.searchPlaceholder')}
className="p-2"
/>
{filtersActive && (
<div className="grid grid-cols-2 rounded-md bg-warm-200 p-0.5 dark:bg-navy-800">
<button
type="button"
aria-pressed={statsUseFilters}
onClick={() => onStatsUseFiltersChange(true)}
className={`min-w-0 rounded px-2 py-1 text-center text-xs font-medium leading-tight break-words ${
statsUseFilters
? 'bg-white text-teal-700 shadow-sm dark:bg-navy-700 dark:text-teal-300'
: 'text-warm-600 hover:text-warm-900 dark:text-warm-400 dark:hover:text-warm-100'
}`}
>
{t('areaPane.matchingFiltersOption')}
</button>
<button
type="button"
aria-pressed={!statsUseFilters}
onClick={() => onStatsUseFiltersChange(false)}
className={`min-w-0 rounded px-2 py-1 text-center text-xs font-medium leading-tight break-words ${
!statsUseFilters
? 'bg-white text-teal-700 shadow-sm dark:bg-navy-700 dark:text-teal-300'
: 'text-warm-600 hover:text-warm-900 dark:text-warm-400 dark:hover:text-warm-100'
}`}
>
{t('areaPane.allPropertiesOption')}
</button>
</div>
)}
</div>
<div>
{loading && properties.length === 0 ? (
<PropertyLoadingSkeleton />
) : (
<>
{filtered.map((property) => (
<PropertyCard
key={`${property.lat},${property.lon}|${property.postcode ?? ''}|${property.address ?? ''}`}
property={property}
/>
))}
{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" />
{t('common.loading')}
</span>
) : (
`${t('common.loadMore')} (${t('common.remaining', { count: total - properties.length })})`
)}
</button>
)}
</>
)}
</div>
</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 }: { 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 (
<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 || t('propertyCard.unknownAddress')}
</div>
<div className="text-sm text-warm-600 dark:text-warm-400 flex items-center gap-1.5">
{property.postcode}
{property.former_council_house === 'Yes' && (
<span className="text-xs bg-teal-50 dark:bg-teal-900/30 text-teal-700 dark:text-teal-400 rounded-full px-1.5 py-0.5 font-medium leading-none">
{t('propertyCard.exCouncilBadge')}
</span>
)}
{property.listed_building === 'Yes' && (
<span className="text-xs bg-amber-50 dark:bg-amber-900/30 text-amber-700 dark:text-amber-300 rounded-full px-1.5 py-0.5 font-medium leading-none">
{t('propertyCard.listedBuildingBadge')}
</span>
)}
</div>
</div>
</div>
{property.property_sub_type && (
<div className="text-sm text-warm-600 dark:text-warm-400 mt-1">
{property.property_sub_type}
</div>
)}
{price !== undefined && (
<div className="mt-2 text-lg font-bold text-teal-700 dark:text-teal-400">
£{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">
{t('propertyCard.estValue')}{' '}
<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">{t('propertyCard.type')}</span>{' '}
{ts(property.property_type)}
</div>
)}
{property.built_form && (
<div>
<span className="text-warm-500 dark:text-warm-400">{t('propertyCard.builtForm')}</span>{' '}
{ts(property.built_form)}
</div>
)}
{property.duration && (
<div>
<span className="text-warm-500 dark:text-warm-400">{t('propertyCard.tenure')}</span>{' '}
{formatDuration(property.duration)}
</div>
)}
{property.within_conservation_area && (
<div>
<span className="text-warm-500 dark:text-warm-400">
{t('propertyCard.withinConservationArea')}
</span>{' '}
{ts(property.within_conservation_area)}
</div>
)}
{floorArea !== undefined && (
<div>
<span className="text-warm-500 dark:text-warm-400">{t('propertyCard.floorArea')}</span>{' '}
{formatNumber(floorArea)}m²
</div>
)}
{rooms !== undefined && (
<div>
<span className="text-warm-500 dark:text-warm-400">{t('propertyCard.rooms')}</span>{' '}
{formatNumber(rooms)}
</div>
)}
{age !== undefined && (
<div>
<span className="text-warm-500 dark:text-warm-400">{t('propertyCard.built')}</span>{' '}
{formatAge(age, property.is_construction_date_approximate)}
</div>
)}
{property.current_energy_rating && (
<div>
<span className="text-warm-500 dark:text-warm-400">{t('propertyCard.epcRating')}</span>{' '}
{ts(property.current_energy_rating)}
</div>
)}
{property.potential_energy_rating && (
<div>
<span className="text-warm-500 dark:text-warm-400">
{t('propertyCard.epcPotential')}
</span>{' '}
{ts(property.potential_energy_rating)}
</div>
)}
</div>
{property.postcode && property.current_energy_rating && (
<a
href={buildEpcCertificateUrl(property.postcode)}
target="_blank"
rel="noopener noreferrer"
className="mt-2 inline-flex items-center gap-1 text-sm text-teal-600 dark:text-teal-400 hover:underline"
>
{t('propertyCard.viewEpcCertificate')}
<svg
aria-hidden
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
className="w-3.5 h-3.5"
>
<path d="M7 17 17 7M9 7h8v8" />
</svg>
</a>
)}
<PropertyTimeline property={property} />
</div>
);
}
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 (
<div className="mt-3">
<div className="text-xs text-warm-500 dark:text-warm-400 mb-1.5">
{t('propertyCard.historyTitle')}
</div>
<ol className="relative ml-1.5 border-l border-warm-200 dark:border-warm-700">
{events.map((event, idx) => (
<li key={idx} className="relative pl-3 pb-1.5 last:pb-0">
<TimelineMarker kind={event.kind} />
<div className="text-sm leading-tight">
{event.kind === 'sale' && (
<>
<span className="font-semibold text-teal-700 dark:text-teal-400">
£{formatNumber(event.price)}
</span>
{event.isNew && (
<span className="ml-1.5 inline-block rounded px-1 py-0.5 text-[10px] font-medium uppercase tracking-wide bg-teal-50 text-teal-700 dark:bg-teal-900/40 dark:text-teal-300">
{t('propertyCard.historyNewBuild')}
</span>
)}
<span className="ml-1.5 text-xs text-warm-500 dark:text-warm-400">
{formatYearMonth(event.year, event.month)}
</span>
</>
)}
{event.kind === 'reno' && (
<>
<span className="text-warm-700 dark:text-warm-200">{event.event}</span>
<span className="ml-1.5 text-xs text-warm-500 dark:text-warm-400">
{event.year}
</span>
</>
)}
{event.kind === 'tenure' && (
<>
<span className="text-warm-700 dark:text-warm-200">
{tenureLabel(event.status)}
</span>
<span className="ml-1.5 text-xs text-warm-500 dark:text-warm-400">
{event.year}
</span>
</>
)}
{event.kind === 'built' && (
<>
<span className="text-warm-700 dark:text-warm-200">
{t('propertyCard.historyBuilt')}
</span>
<span className="ml-1.5 text-xs text-warm-500 dark:text-warm-400">
{event.approximate ? `~${event.year}` : event.year}
</span>
</>
)}
</div>
</li>
))}
</ol>
</div>
);
}
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 (
<span
aria-hidden
className={`${base} bg-teal-500 dark:bg-teal-400 ring-warm-50 dark:ring-warm-900`}
/>
);
}
if (kind === 'reno') {
return (
<span
aria-hidden
className={`${base} bg-warm-400 dark:bg-warm-500 ring-warm-50 dark:ring-warm-900`}
/>
);
}
if (kind === 'tenure') {
return (
<span
aria-hidden
className={`${base} bg-indigo-400 dark:bg-indigo-400 ring-warm-50 dark:ring-warm-900`}
/>
);
}
return (
<span
aria-hidden
className={`${base} bg-warm-50 border border-warm-400 dark:bg-warm-900 dark:border-warm-500 ring-warm-50 dark:ring-warm-900`}
/>
);
}