Fun changes
Some checks failed
CI / Python (lint + test) (push) Failing after 3m38s
CI / Rust (lint + test) (push) Failing after 3m32s
CI / Frontend (lint + typecheck) (push) Failing after 4m12s
Build and publish Docker image / build-and-push (push) Failing after 4m48s

This commit is contained in:
Andras Schmelczer 2026-04-04 22:59:44 +01:00
parent cd778dd088
commit 349a6c1d53
60 changed files with 1260 additions and 2600 deletions

View file

@ -189,8 +189,7 @@ export default function AreaPane({
/>
{expanded && (
<div className="px-3 py-2 space-y-3">
{stackedCharts
? stackedCharts.map((chart) => {
{stackedCharts?.map((chart) => {
const segments = chart.components
.map((name) => ({
name,
@ -205,9 +204,22 @@ export default function AreaPane({
? aggregateStats.mean
: segments.reduce((sum, s) => sum + s.value, 0);
const featureMeta = chart.feature
? globalFeatureByName.get(chart.feature)
// Use rateFeature (e.g. per-1k) for display if available
const rateStats = chart.rateFeature
? numericByName.get(chart.rateFeature)
: undefined;
const displayValue = rateStats ? rateStats.mean : total;
// Use rateFeature for info popup and national average when available
const infoFeatureName = chart.rateFeature ?? chart.feature;
const featureMeta = infoFeatureName
? globalFeatureByName.get(infoFeatureName)
: undefined;
const globalMean =
featureMeta?.histogram
? calculateHistogramMean(featureMeta.histogram)
: undefined;
if (total === 0) return null;
@ -228,17 +240,30 @@ export default function AreaPane({
{ts(chart.label)}
</span>
)}
<span className="text-xs font-semibold text-teal-700 dark:text-teal-400 whitespace-nowrap">
{formatValue(total)}
{chart.unit ? ` ${chart.unit}` : ''}
</span>
<div className="text-right shrink-0">
<span className="text-xs font-semibold text-teal-700 dark:text-teal-400 whitespace-nowrap">
{formatValue(displayValue)}
{chart.unit ? ` ${chart.unit}` : ''}
</span>
{globalMean != null && (
<div className="text-[10px] text-warm-400 dark:text-warm-500 whitespace-nowrap">
{t('areaPane.nationalAvg')}: {formatValue(globalMean)}
</div>
)}
</div>
</div>
<StackedBarChart segments={segments} total={total} />
</div>
);
})
: group.features
.filter((f) => !stackedEnumFeatureNames.has(f.name))
})}
{(() => {
const stackedFeatureNames = new Set<string>(
stackedCharts?.flatMap((c) =>
[c.feature, c.rateFeature, ...c.components].filter((s): s is string => Boolean(s))
) ?? []
);
return group.features
.filter((f) => !stackedFeatureNames.has(f.name) && !stackedEnumFeatureNames.has(f.name))
.map((feature) => {
const numericStats = numericByName.get(feature.name);
const enumStats = enumByName.get(feature.name);
@ -289,19 +314,25 @@ export default function AreaPane({
}
if (enumStats) {
const globalFeature = globalFeatureByName.get(feature.name);
return (
<div
key={feature.name}
className="bg-warm-50 dark:bg-warm-800 rounded p-2"
>
<FeatureLabel feature={feature} onShowInfo={setInfoFeature} />
<EnumBarChart counts={enumStats.counts} />
<EnumBarChart
counts={enumStats.counts}
globalCounts={globalFeature?.counts}
featureName={feature.name}
/>
</div>
);
}
return null;
})}
});
})()}
{stackedEnumCharts?.map((chart) => {
const featureMeta = chart.feature
? globalFeatureByName.get(chart.feature)

View file

@ -1,23 +1,77 @@
export default function EnumBarChart({ counts }: { counts: Record<string, number> }) {
import { getEnumValueColor } from '../../lib/consts';
export default function EnumBarChart({
counts,
globalCounts,
featureName,
}: {
counts: Record<string, number>;
globalCounts?: Record<string, number>;
featureName?: string;
}) {
const entries = Object.entries(counts).sort(([, countA], [, countB]) => countB - countA);
const localTotal = entries.reduce((sum, [, c]) => sum + c, 0);
// When global counts are available, normalize both to percentages for comparison
const globalTotal = globalCounts
? Object.values(globalCounts).reduce((sum, c) => sum + c, 0)
: 0;
const hasGlobal = globalCounts && globalTotal > 0;
// Compute max percentage across both datasets for consistent bar scaling
const maxPct = entries.reduce((max, [label, count]) => {
const localPct = localTotal > 0 ? count / localTotal : 0;
const globalPct = hasGlobal ? (globalCounts[label] ?? 0) / globalTotal : 0;
return Math.max(max, localPct, globalPct);
}, 0);
// Fallback to raw count scaling when no global data
const maxCount = Math.max(...entries.map(([, count]) => count), 1);
return (
<div className="space-y-1 mt-1">
{entries.map(([label, count]) => (
<div key={label} className="flex items-center gap-2 text-xs">
<span className="w-16 truncate text-warm-500 dark:text-warm-400 text-right shrink-0">
{label}
</span>
<div className="flex-1 h-3 bg-warm-100 dark:bg-navy-700 rounded overflow-hidden">
<div
className="h-full bg-teal-500 dark:bg-teal-400 rounded"
style={{ width: `${(count / maxCount) * 100}%` }}
/>
{entries.map(([label, count]) => {
const localPct = localTotal > 0 ? count / localTotal : 0;
const globalPct = hasGlobal ? (globalCounts[label] ?? 0) / globalTotal : 0;
const localWidth = hasGlobal
? maxPct > 0
? (localPct / maxPct) * 100
: 0
: (count / maxCount) * 100;
const globalWidth = hasGlobal && maxPct > 0 ? (globalPct / maxPct) * 100 : 0;
const overrideColor = featureName ? getEnumValueColor(featureName, label) : null;
const barStyle = overrideColor
? `rgb(${overrideColor[0]},${overrideColor[1]},${overrideColor[2]})`
: undefined;
return (
<div key={label} className="flex items-center gap-2 text-xs">
<span className="w-16 truncate text-warm-500 dark:text-warm-400 text-right shrink-0">
{label}
</span>
<div className="flex-1 h-3 bg-warm-100 dark:bg-navy-700 rounded overflow-hidden relative">
{hasGlobal && (
<div
className="absolute inset-y-0 left-0 bg-warm-300/60 dark:bg-warm-600/60 rounded"
style={{ width: `${globalWidth}%` }}
/>
)}
<div
className={
barStyle ? 'h-full rounded relative' : 'h-full bg-teal-500 dark:bg-teal-400 rounded relative'
}
style={{ width: `${localWidth}%`, ...(barStyle ? { backgroundColor: barStyle } : {}) }}
/>
</div>
<span className="w-8 text-warm-500 dark:text-warm-400 text-right shrink-0">
{count}
</span>
</div>
<span className="w-8 text-warm-500 dark:text-warm-400 text-right shrink-0">{count}</span>
</div>
))}
);
})}
</div>
);
}

View file

@ -30,8 +30,8 @@ export default function ExternalSearchLinks({
() => buildPropertySearchUrls({ location, filters, rightmoveLocationId }),
[location, filters, rightmoveLocationId]
);
const radiusMiles = location.isPostcode ? 0.25 : (H3_RADIUS_MILES[location.resolution] ?? 1);
const label = `${radiusMiles}mi radius`;
const radiusMiles = location.isPostcode ? 0 : (H3_RADIUS_MILES[location.resolution] ?? 1);
const label = radiusMiles === 0 ? t('externalSearch.exact') : `${radiusMiles}mi radius`;
if (!urls) return null;
@ -61,11 +61,6 @@ export default function ExternalSearchLinks({
<a href={urls.zoopla} target="_blank" rel="noopener noreferrer" className={linkClass}>
Zoopla
</a>
{urls.openrent && (
<a href={urls.openrent} target="_blank" rel="noopener noreferrer" className={linkClass}>
OpenRent
</a>
)}
</div>
</div>
);

View file

@ -30,8 +30,6 @@ import {
travelFieldKey,
} from '../../hooks/useTravelTime';
type ListingType = 'historical' | 'buy' | 'rent';
function EditableLabel({
value,
formatted,
@ -210,7 +208,6 @@ interface FiltersProps {
isLoggedIn: boolean;
onLoginRequired: () => void;
isLicensed: boolean;
isAdmin: boolean;
onUpgradeClick?: () => void;
onResetTutorial?: () => void;
filterImpacts?: Record<string, number>;
@ -252,7 +249,6 @@ export default memo(function Filters({
isLoggedIn,
onLoginRequired,
isLicensed,
isAdmin,
onUpgradeClick,
onResetTutorial,
filterImpacts,
@ -261,119 +257,14 @@ export default memo(function Filters({
savingSearch,
}: FiltersProps) {
const { t } = useTranslation();
const modeRestrictions = useMemo(() => {
const map: Record<string, Set<ListingType>> = {};
for (const f of features) {
if (f.modes && f.modes.length > 0) {
map[f.name] = new Set(f.modes as ListingType[]);
}
}
return map;
}, [features]);
const linkedFeatures = useMemo(() => {
const pairs: [string, string][] = [];
const seen = new Set<string>();
for (const f of features) {
if (f.linked && !seen.has(f.name)) {
pairs.push([f.name, f.linked]);
seen.add(f.linked);
}
}
return pairs;
}, [features]);
const isAllowed = useCallback(
(name: string, mode: ListingType) => {
const allowed = modeRestrictions[name];
return !allowed || allowed.has(mode);
},
[modeRestrictions]
);
const activeListingType = useMemo((): ListingType => {
const val = filters['Listing status'] as string[] | undefined;
if (!val || val.length === 0) return 'historical';
if (val.includes('For sale')) return 'buy';
if (val.includes('For rent')) return 'rent';
return 'historical';
}, [filters]);
const availableFeatures = useMemo(
() =>
features.filter((f) => !enabledFeatures.has(f.name) && isAllowed(f.name, activeListingType)),
[features, enabledFeatures, activeListingType, isAllowed]
);
const enabledFeatureList = useMemo(
() => features.filter((f) => enabledFeatures.has(f.name) && f.name !== 'Listing status'),
() => features.filter((f) => !enabledFeatures.has(f.name)),
[features, enabledFeatures]
);
const parkedFiltersRef = useRef<FeatureFilters>({});
const handleListingSelect = useCallback(
(type: ListingType) => {
// Track what will be active after swaps (to avoid conflicts with restoration)
const activeAfterSwaps = new Set<string>();
for (const name of Object.keys(filters)) {
if (name === 'Listing status') continue;
if (isAllowed(name, type)) {
activeAfterSwaps.add(name);
continue;
}
// Check if this feature has a linked counterpart in the new mode
let swapped = false;
for (const [a, b] of linkedFeatures) {
const counterpart = name === a ? b : name === b ? a : null;
if (counterpart && isAllowed(counterpart, type)) {
onFilterChange(counterpart, filters[name] as [number, number]);
onRemoveFilter(name);
activeAfterSwaps.add(counterpart);
swapped = true;
break;
}
}
if (!swapped) {
parkedFiltersRef.current[name] = filters[name];
onRemoveFilter(name);
}
}
// Restore parked filters that are now allowed in the new mode
const restored: string[] = [];
for (const [name, value] of Object.entries(parkedFiltersRef.current)) {
if (isAllowed(name, type) && !activeAfterSwaps.has(name)) {
onFilterChange(name, value);
activeAfterSwaps.add(name);
restored.push(name);
} else if (!isAllowed(name, type)) {
// Try restoring as linked counterpart
for (const [a, b] of linkedFeatures) {
const counterpart = name === a ? b : name === b ? a : null;
if (counterpart && isAllowed(counterpart, type) && !activeAfterSwaps.has(counterpart)) {
onFilterChange(counterpart, value);
activeAfterSwaps.add(counterpart);
restored.push(name);
break;
}
}
}
}
for (const name of restored) {
delete parkedFiltersRef.current[name];
}
const valueMap: Record<string, string> = {
historical: 'Historical sale',
buy: 'For sale',
rent: 'For rent',
};
onFilterChange('Listing status', [valueMap[type]]);
},
[filters, onFilterChange, onRemoveFilter, isAllowed, linkedFeatures]
const enabledFeatureList = useMemo(
() => features.filter((f) => enabledFeatures.has(f.name)),
[features, enabledFeatures]
);
const containerRef = useRef<HTMLDivElement>(null);
@ -548,31 +439,6 @@ export default memo(function Filters({
onLoginRequired={onLoginRequired}
/>
<div className="px-3 pb-2 space-y-2">
{isAdmin && (
<div className="flex rounded-lg bg-warm-100 dark:bg-warm-800 p-0.5">
{(['historical', 'buy', 'rent'] as const).map((type) => {
const labels = {
historical: t('filters.historical'),
buy: t('filters.buy'),
rent: t('filters.rent'),
};
const isActive = activeListingType === type;
return (
<button
key={type}
onClick={() => handleListingSelect(type)}
className={`flex-1 px-3 py-1.5 text-sm font-medium rounded-md cursor-pointer ${
isActive
? 'bg-white dark:bg-warm-700 text-teal-600 dark:text-teal-400 ring-2 ring-teal-400 shadow-sm'
: 'text-warm-500 dark:text-warm-400 hover:text-warm-700 dark:hover:text-warm-300'
}`}
>
{labels[type]}
</button>
);
})}
</div>
)}
<button
onClick={() => setShowPhilosophy(true)}
className="w-full px-3 py-1.5 rounded-lg border border-warm-200 dark:border-warm-700 bg-white dark:bg-warm-800 hover:bg-warm-50 dark:hover:bg-warm-700 text-teal-600 dark:text-teal-400 font-medium text-sm flex items-center justify-center gap-2"

View file

@ -39,8 +39,8 @@ export default memo(function HoverCard({
const results: { name: string; value: string }[] = [];
// Show stats for active filters (up to 4), excluding Listing status
for (const name of activeFilterNames.filter((n) => n !== 'Listing status').slice(0, 4)) {
// Show stats for active filters (up to 4)
for (const name of activeFilterNames.slice(0, 4)) {
const val = data[`avg_${name}`] ?? data[`min_${name}`];
if (val == null || typeof val !== 'number') continue;
const meta = featureMap.get(name);

View file

@ -174,7 +174,22 @@ export default memo(function Map({
}, [viewState, dimensions, onViewChange]);
const handleMove = useCallback((evt: { viewState: ViewState }) => {
setInternalViewState(evt.viewState);
setInternalViewState((prev) => {
const next = evt.viewState;
// Skip re-render when viewport values haven't changed (e.g. container resize
// fires move events with identical lat/lng/zoom). Returning the same reference
// tells React to bail out.
if (
prev.latitude === next.latitude &&
prev.longitude === next.longitude &&
prev.zoom === next.zoom &&
prev.pitch === next.pitch &&
prev.bearing === next.bearing
) {
return prev;
}
return next;
});
}, []);
const handleFlyTo = useCallback((lat: number, lng: number, zoom: number) => {
@ -324,6 +339,7 @@ export default memo(function Map({
enumValues={
colorFeatureMeta.type === 'enum' ? colorFeatureMeta.values : undefined
}
featureName={colorFeatureMeta.name}
theme={theme}
raw={colorFeatureMeta.raw}
/>

View file

@ -5,17 +5,23 @@ import {
FEATURE_GRADIENT,
DENSITY_GRADIENT,
DENSITY_GRADIENT_DARK,
ENUM_PALETTE,
getEnumPaletteForFeature,
} from '../../lib/consts';
import { gradientToCss } from '../../lib/utils';
import { CloseIcon } from '../ui/icons/CloseIcon';
import { TickerValue } from '../ui/TickerValue';
function EnumSwatches({ values }: { values: string[] }) {
function EnumSwatches({
values,
palette,
}: {
values: string[];
palette: [number, number, number][];
}) {
return (
<div className="flex flex-col gap-1">
{values.map((label, i) => {
const color = ENUM_PALETTE[i % ENUM_PALETTE.length];
const color = palette[i % palette.length];
return (
<div key={label} className="flex items-center gap-1.5">
<div
@ -30,11 +36,17 @@ function EnumSwatches({ values }: { values: string[] }) {
);
}
function InlineEnumSwatches({ values }: { values: string[] }) {
function InlineEnumSwatches({
values,
palette,
}: {
values: string[];
palette: [number, number, number][];
}) {
return (
<div className="flex items-center gap-2 flex-1 min-w-[40%] flex-wrap">
{values.map((label, i) => {
const color = ENUM_PALETTE[i % ENUM_PALETTE.length];
const color = palette[i % palette.length];
return (
<div key={label} className="flex items-center gap-1">
<div
@ -58,6 +70,7 @@ export default function MapLegend({
onCancel,
mode,
enumValues,
featureName,
theme = 'light',
inline = false,
suffix,
@ -70,6 +83,7 @@ export default function MapLegend({
onCancel: () => void;
mode: 'feature' | 'density';
enumValues?: string[];
featureName?: string;
theme?: 'light' | 'dark';
inline?: boolean;
suffix?: string;
@ -78,6 +92,7 @@ export default function MapLegend({
}) {
const { t } = useTranslation();
const isEnum = enumValues && enumValues.length > 0;
const enumPalette = getEnumPaletteForFeature(featureName ?? null, enumValues);
const densityGradient = theme === 'dark' ? DENSITY_GRADIENT_DARK : DENSITY_GRADIENT;
const gradientStyle =
mode === 'density' ? gradientToCss(densityGradient) : gradientToCss(FEATURE_GRADIENT);
@ -114,7 +129,7 @@ export default function MapLegend({
</button>
)}
{isEnum ? (
<InlineEnumSwatches values={enumValues} />
<InlineEnumSwatches values={enumValues} palette={enumPalette} />
) : (
<div className="flex items-center gap-1.5 flex-1 min-w-[40%] text-warm-500 dark:text-warm-400">
{rangeMin}
@ -144,7 +159,7 @@ export default function MapLegend({
)}
</div>
{isEnum ? (
<EnumSwatches values={enumValues} />
<EnumSwatches values={enumValues} palette={enumPalette} />
) : (
<>
<div className="h-3 rounded" style={{ background: gradientStyle }} />

View file

@ -187,6 +187,16 @@ export default function MapPage({
handleToggleBest,
} = useTravelTime(initialTravelTime);
const mapFlyToRef = useRef<((lat: number, lng: number, zoom: number) => void) | null>(null);
const mapData = useMapData({
filters,
features,
viewFeature,
activeFeature,
travelTimeEntries: entries,
});
const handleAiFilterSubmit = useCallback(
async (query: string) => {
// Build context from current filters for conversational refinement
@ -213,8 +223,33 @@ export default function MapPage({
useBest: false,
}));
handleSetEntries(newEntries);
// Pan to the first travel time destination (mirroring handleTravelTimeSetDestination)
const firstTT = result.travelTimeFilters[0];
if (firstTT?.slug) {
try {
const res = await fetch(
apiUrl('travel-destinations', new URLSearchParams({ mode: firstTT.mode })),
authHeaders({})
);
if (res.ok) {
const data: { destinations: { slug: string; lat: number; lon: number }[] } =
await res.json();
const dest = data.destinations.find((d) => d.slug === firstTT.slug);
if (dest) {
mapFlyToRef.current?.(
dest.lat,
dest.lon,
mapData.currentView?.zoom ?? INITIAL_VIEW_STATE.zoom
);
}
}
} catch {
// Non-critical — filters are already applied, just skip the pan
}
}
},
[fetchAiFilters, handleSetFilters, handleSetEntries, activeEntries, filters]
[fetchAiFilters, handleSetFilters, handleSetEntries, activeEntries, filters, mapData.currentView?.zoom]
);
const handleClearAll = useCallback(() => {
@ -244,16 +279,6 @@ export default function MapPage({
const license = useLicense();
const mapFlyToRef = useRef<((lat: number, lng: number, zoom: number) => void) | null>(null);
const mapData = useMapData({
filters,
features,
viewFeature,
activeFeature,
travelTimeEntries: entries,
});
const filterCounts = useFilterCounts(filters, features, mapData.bounds, entries);
const handleTravelTimeSetDestination = useCallback(
@ -461,12 +486,7 @@ export default function MapPage({
if (mapData.licenseRequired) trackEvent('Upgrade Modal Shown');
}, [mapData.licenseRequired]);
const densityLabel = useMemo(() => {
const listingVal = filters['Listing status'] as string[] | undefined;
if (listingVal?.includes('For sale')) return t('mapLegend.propertiesForSale');
if (listingVal?.includes('For rent')) return t('mapLegend.propertiesForRent');
return t('mapLegend.historicalMatches');
}, [filters, t]);
const densityLabel = t('mapLegend.historicalMatches');
const mobileLegendMeta = useMemo(
() => (viewFeature ? features.find((f) => f.name === viewFeature) || null : null),
@ -652,7 +672,6 @@ export default function MapPage({
isLoggedIn={!!user}
onLoginRequired={onRegisterClick ?? (() => {})}
isLicensed={user?.subscription === 'licensed'}
isAdmin={user?.isAdmin === true}
onUpgradeClick={() => onNavigateTo('pricing')}
onResetTutorial={tutorial.resetTutorial}
filterImpacts={filterCounts.impacts}
@ -771,6 +790,7 @@ export default function MapPage({
onCancel={handleCancelPin}
mode="feature"
enumValues={mobileLegendMeta.type === 'enum' ? mobileLegendMeta.values : undefined}
featureName={mobileLegendMeta.name}
theme={theme}
inline
raw={mobileLegendMeta.raw}

View file

@ -180,11 +180,6 @@ function PropertyCard({
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">
@ -193,7 +188,14 @@ function PropertyCard({
<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">{property.postcode}</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>
)}
</div>
</div>
{onSave && (
<button
@ -216,51 +218,20 @@ function PropertyCard({
</div>
)}
{askingPrice !== undefined && (
{price !== 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}{' '}
£{formatNumber(price)}
{transactionDate !== undefined && (
<span className="text-sm font-normal text-warm-600 dark:text-warm-400">
{' '}
({formatTransactionDate(transactionDate)})
</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">
{t('propertyCard.perMonth')}
</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 ? (
{pricePerSqm !== undefined && (
<span className="text-sm font-normal text-warm-600 dark:text-warm-400">
{t('propertyCard.lastSold', { price: formatNumber(price) })}
{transactionDate !== undefined && ` (${formatTransactionDate(transactionDate)})`}
{' '}
£{formatNumber(pricePerSqm)}/m²
</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>
)}
@ -299,18 +270,6 @@ function PropertyCard({
{formatNumber(floorArea)}m²
</div>
)}
{bedrooms !== undefined && (
<div>
<span className="text-warm-500 dark:text-warm-400">{t('propertyCard.bedrooms')}</span>{' '}
{formatNumber(bedrooms)}
</div>
)}
{bathrooms !== undefined && (
<div>
<span className="text-warm-500 dark:text-warm-400">{t('propertyCard.bathrooms')}</span>{' '}
{formatNumber(bathrooms)}
</div>
)}
{rooms !== undefined && (
<div>
<span className="text-warm-500 dark:text-warm-400">{t('propertyCard.rooms')}</span>{' '}
@ -323,14 +282,6 @@ function PropertyCard({
{formatAge(age, property.is_construction_date_approximate)}
</div>
)}
{property.former_council_house === 'Yes' && (
<div>
<span className="text-warm-500 dark:text-warm-400">
{t('propertyCard.formerCouncil')}
</span>{' '}
{ts(property.former_council_house)}
</div>
)}
{property.current_energy_rating && (
<div>
<span className="text-warm-500 dark:text-warm-400">{t('propertyCard.epcRating')}</span>{' '}
@ -345,32 +296,8 @@ function PropertyCard({
{ts(property.potential_energy_rating)}
</div>
)}
{listingDate !== undefined && (
<div>
<span className="text-warm-500 dark:text-warm-400">{t('propertyCard.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">
{t('propertyCard.keyFeatures')}
</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">
@ -390,18 +317,6 @@ function PropertyCard({
</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"
>
{t('propertyCard.viewExternalListing')} &rarr;
</a>
</div>
)}
</div>
);
}