541 lines
19 KiB
TypeScript
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`}
|
|
/>
|
|
);
|
|
}
|