Quick save

This commit is contained in:
Andras Schmelczer 2026-02-07 22:19:44 +00:00
parent e5d5819098
commit 2906b01734
25 changed files with 1070 additions and 237 deletions

View file

@ -1,7 +1,7 @@
import { useMemo, useState } from 'react';
import type { FeatureFilters, FeatureMeta, HexagonStatsResponse, PostcodeFeature } from '../../types';
import type { HexagonLocation } from '../../lib/external-search';
import { formatValue, formatFilterValue, calculateHistogramMean, FEATURE_FORMATS } from '../../lib/format';
import { formatValue, formatFilterValue, calculateHistogramMean } from '../../lib/format';
import { groupFeaturesByCategory } from '../../lib/features';
import { STACKED_GROUPS, STACKED_ENUM_GROUPS } from '../../lib/consts';
import { DualHistogram, LoadingSkeleton } from './DualHistogram';
@ -11,6 +11,8 @@ import StackedEnumChart from './StackedEnumChart';
import PriceHistoryChart from './PriceHistoryChart';
import ExternalSearchLinks from './ExternalSearchLinks';
import { InfoIcon, CloseIcon } from '../ui/icons';
import { CollapsibleGroupHeader } from '../ui/CollapsibleGroupHeader';
import { LightbulbIcon } from '../ui/icons/LightbulbIcon';
import { IconButton } from '../ui/IconButton';
import { FeatureInfoPopup } from '../ui/FeatureInfoPopup';
import { EmptyState } from '../ui/EmptyState';
@ -28,6 +30,10 @@ interface AreaPaneProps {
hexagonLocation: HexagonLocation | null;
filters: FeatureFilters;
onNavigateToSource?: (slug: string, featureName: string) => void;
aiSummary?: string;
aiSummaryLoading?: boolean;
aiSummaryError?: string | null;
onRetryAiSummary?: () => void;
}
export default function AreaPane({
@ -42,11 +48,24 @@ export default function AreaPane({
hexagonLocation,
filters,
onNavigateToSource,
aiSummary,
aiSummaryLoading,
aiSummaryError,
onRetryAiSummary,
}: AreaPaneProps) {
// For postcodes, use local data for count
const propertyCount = isPostcode && postcodeData ? postcodeData.properties.count : stats?.count;
const featureGroups = useMemo(() => groupFeaturesByCategory(globalFeatures), [globalFeatures]);
const [infoFeature, setInfoFeature] = useState<FeatureMeta | null>(null);
const [collapsedGroups, setCollapsedGroups] = useState<Set<string>>(new Set());
const toggleGroup = (name: string) =>
setCollapsedGroups((prev) => {
const next = new Set(prev);
if (next.has(name)) next.delete(name);
else next.add(name);
return next;
});
const numericByName = useMemo(() => {
if (!stats) return new Map();
@ -78,12 +97,17 @@ export default function AreaPane({
<div className="flex flex-col h-full">
<div className="p-3 border-b border-warm-200 dark:border-navy-700">
<div className="flex justify-between items-center">
<div>
<h2 className="text-sm font-semibold dark:text-warm-100">
{isPostcode ? hexagonId : 'Area Statistics'}
</h2>
{isPostcode && (
<span className="text-xs text-warm-500 dark:text-warm-400">Postcode</span>
<div className="flex items-center gap-2">
<div>
<h2 className="text-sm font-semibold dark:text-warm-100">
{isPostcode ? hexagonId : 'Area Statistics'}
</h2>
{isPostcode && (
<span className="text-xs text-warm-500 dark:text-warm-400">Postcode</span>
)}
</div>
{loading && stats && (
<div className="w-3 h-3 border-2 border-teal-600 dark:border-teal-400 border-t-transparent rounded-full animate-spin" />
)}
</div>
<IconButton onClick={onClose} title="Close">
@ -109,17 +133,68 @@ export default function AreaPane({
<ExternalSearchLinks location={hexagonLocation} filters={filters} />
)}
{/* AI Summary Card */}
{(aiSummary || aiSummaryLoading || aiSummaryError) && (
<div className="px-3 pt-3 pb-1">
<div className="bg-teal-50 dark:bg-teal-900/20 border border-teal-200 dark:border-teal-800/50 rounded p-2.5">
<div className="flex items-center gap-1.5 mb-1.5">
<LightbulbIcon className="w-3.5 h-3.5 text-teal-600 dark:text-teal-400" />
<span className="text-xs font-semibold text-teal-700 dark:text-teal-400">AI Summary</span>
</div>
{aiSummaryError ? (
<div className="text-xs text-warm-600 dark:text-warm-400">
<span>Failed to generate summary. </span>
{onRetryAiSummary && (
<button
onClick={onRetryAiSummary}
className="text-teal-600 dark:text-teal-400 hover:text-teal-800 dark:hover:text-teal-300 underline"
>
Retry
</button>
)}
</div>
) : aiSummaryLoading ? (
<div className="space-y-1.5">
<div className="h-3 bg-teal-200/60 dark:bg-teal-800/40 rounded animate-pulse w-full" />
<div className="h-3 bg-teal-200/60 dark:bg-teal-800/40 rounded animate-pulse w-4/5" />
</div>
) : (
<p className="text-xs text-warm-700 dark:text-warm-300 leading-relaxed">
{aiSummary}
</p>
)}
</div>
</div>
)}
<div className="flex-1 overflow-y-auto">
{loading && !stats ? (
<LoadingSkeleton />
) : stats ? (
<div className="p-3 space-y-4">
{stats.price_history && stats.price_history.length > 0 && (
<div className="bg-warm-50 dark:bg-warm-800 rounded p-2">
<span className="text-xs text-warm-700 dark:text-warm-300">Price History</span>
<PriceHistoryChart points={stats.price_history} />
<div>
{/* Histogram color legend */}
<div className="mx-3 mt-3 bg-teal-50 dark:bg-teal-900/20 border border-teal-200 dark:border-teal-800/50 rounded p-2.5 text-xs">
<div className="space-y-1.5">
<div className="flex items-center gap-2">
<div className="w-3 h-3 bg-teal-500 dark:bg-teal-400 rounded" />
<span className="text-warm-700 dark:text-warm-300">
<span className="font-medium text-warm-900 dark:text-warm-100">Teal bars</span> show the distribution in this selected area
</span>
</div>
<div className="flex items-center gap-2">
<div className="w-3 h-3 bg-warm-300/60 dark:bg-warm-600/60 rounded" />
<span className="text-warm-700 dark:text-warm-300">
<span className="font-medium text-warm-900 dark:text-warm-100">Gray bars</span> show the overall distribution across all areas
</span>
</div>
<div className="flex items-center gap-2">
<div className="w-3 h-px border-t border-dashed border-warm-500 dark:border-warm-400" />
<span className="text-warm-700 dark:text-warm-300">
<span className="font-medium text-warm-900 dark:text-warm-100">Dashed line</span> indicates the global average
</span>
</div>
</div>
)}
</div>
{featureGroups.map((group) => {
const hasData = group.features.some(
(feature) => numericByName.has(feature.name) || enumByName.has(feature.name)
@ -136,12 +211,28 @@ export default function AreaPane({
) as string[] ?? []
);
const isExpanded = !collapsedGroups.has(group.name);
return (
<div key={group.name}>
<h3 className="text-xs font-semibold text-warm-500 dark:text-warm-400 uppercase tracking-wider mb-2">
{group.name}
</h3>
<div className="space-y-3">
<CollapsibleGroupHeader
name={group.name}
expanded={isExpanded}
onToggle={() => toggleGroup(group.name)}
className="px-3 py-1.5 text-xs font-bold text-warm-500 bg-warm-50 dark:bg-warm-900 dark:text-warm-400 sticky top-0 hover:bg-warm-100 dark:hover:bg-warm-800"
/>
{isExpanded && <div className="px-3 py-2 space-y-3">
{/* Price History in Property group */}
{group.name === 'Property' && stats.price_history && (() => {
// Only show chart if there are at least 2 unique years
const uniqueYears = new Set(stats.price_history.map(p => Math.floor(p.year)));
return uniqueYears.size > 1;
})() && (
<div className="bg-warm-50 dark:bg-warm-800 rounded p-2">
<span className="text-xs text-warm-700 dark:text-warm-300">Price History</span>
<PriceHistoryChart points={stats.price_history} />
</div>
)}
{stackedCharts
? // Render stacked charts for this group
stackedCharts.map((chart) => {
@ -218,7 +309,7 @@ export default function AreaPane({
className="mr-2"
/>
<span className="text-xs font-semibold text-teal-700 dark:text-teal-400 whitespace-nowrap">
{formatValue(numericStats.mean, FEATURE_FORMATS[feature.name])}
{formatValue(numericStats.mean, feature)}
</span>
</div>
{numericStats.histogram && (
@ -343,10 +434,29 @@ export default function AreaPane({
</div>
);
})}
</div>
</div>}
</div>
);
})}
{/* Google Street View */}
{hexagonLocation && (
<div>
<div className="px-3 py-1.5 text-xs font-bold text-warm-500 bg-warm-50 dark:bg-warm-900 dark:text-warm-400 sticky top-0">
Street View
</div>
<div className="px-3 py-2">
<div className="rounded overflow-hidden border border-warm-200 dark:border-warm-700">
<iframe
className="w-full"
style={{ height: 240, border: 0 }}
loading="lazy"
referrerPolicy="no-referrer-when-downgrade"
src={`https://maps.google.com/maps?layer=c&cbll=${hexagonLocation.lat},${hexagonLocation.lon}&cbp=11,0,0,0,0&output=svembed`}
/>
</div>
</div>
</div>
)}
</div>
) : null}
</div>

View file

@ -2,7 +2,8 @@ import { memo, useState, useMemo, useEffect } from 'react';
import { Slider } from '../ui/Slider';
import { Label } from '../ui/Label';
import { SearchInput } from '../ui/SearchInput';
import { ChevronIcon, FilterIcon, LightbulbIcon } from '../ui/icons';
import { FilterIcon, LightbulbIcon } from '../ui/icons';
import { CollapsibleGroupHeader } from '../ui/CollapsibleGroupHeader';
import { EmptyState } from '../ui/EmptyState';
import type { FeatureMeta, FeatureFilters } from '../../types';
import { formatFilterValue } from '../../lib/format';
@ -95,18 +96,16 @@ function FeatureBrowser({
const isExpanded = isSearching || expandedGroups.has(group.name);
return (
<div key={group.name} className="shrink-0">
<button
onClick={() => toggleGroup(group.name)}
className="w-full flex items-center justify-between px-3 py-1.5 text-xs font-bold text-warm-500 bg-warm-50 dark:bg-navy-950 dark:text-warm-400 sticky top-0 hover:bg-warm-100 dark:hover:bg-warm-800"
<CollapsibleGroupHeader
name={group.name}
expanded={isExpanded}
onToggle={() => toggleGroup(group.name)}
className="px-3 py-1.5 text-xs font-bold text-warm-500 bg-warm-50 dark:bg-navy-950 dark:text-warm-400 sticky top-0 hover:bg-warm-100 dark:hover:bg-warm-800"
>
<span>{group.name}</span>
<div className="flex items-center gap-1">
<span className="text-[10px] font-medium text-warm-400 dark:text-warm-500">
{group.features.length}
</span>
<ChevronIcon direction={isExpanded ? 'down' : 'right'} className="w-3.5 h-3.5" />
</div>
</button>
<span className="text-[10px] font-medium text-warm-400 dark:text-warm-500">
{group.features.length}
</span>
</CollapsibleGroupHeader>
{isExpanded &&
group.features.map((f) => {
const isPinned = pinnedFeature === f.name;

View file

@ -41,7 +41,7 @@ export default memo(function HoverCard({ x, y, id, isPostcode, data, filters }:
return (
<div
className="absolute bg-white dark:bg-warm-800 rounded-lg shadow-lg p-3 text-sm dark:text-warm-200 pointer-events-none z-50 min-w-[180px] max-w-[260px]"
className="absolute bg-white dark:bg-warm-800 rounded-lg shadow-lg p-3 text-sm dark:text-white pointer-events-none z-50 min-w-[180px] max-w-[260px]"
style={{
left: x,
top: y - 12,
@ -61,14 +61,14 @@ export default memo(function HoverCard({ x, y, id, isPostcode, data, filters }:
<div className="relative">
{/* Header */}
<div className="flex items-center justify-between gap-2 mb-1">
<span className="font-semibold text-navy-950 dark:text-warm-100 truncate">
<span className="font-semibold text-navy-950 dark:text-white truncate">
{isPostcode ? id : 'Area'}
</span>
</div>
{/* Property count */}
{count != null && (
<div className="text-xs text-warm-500 dark:text-warm-400 mb-2">
<div className="text-xs text-warm-500 dark:text-warm-300 mb-2">
{count.toLocaleString()} {count === 1 ? 'property' : 'properties'}
</div>
)}
@ -78,8 +78,8 @@ export default memo(function HoverCard({ x, y, id, isPostcode, data, filters }:
<div className="space-y-1 border-t border-warm-200 dark:border-warm-700 pt-2">
{displayStats.map((stat) => (
<div key={stat.name} className="flex justify-between gap-2 text-xs">
<span className="text-warm-500 dark:text-warm-400 truncate">{stat.name}</span>
<span className="font-medium text-teal-700 dark:text-teal-400 whitespace-nowrap">
<span className="text-warm-500 dark:text-warm-300 truncate">{stat.name}</span>
<span className="font-medium text-teal-700 dark:text-teal-300 whitespace-nowrap">
{stat.value}
</span>
</div>
@ -89,7 +89,7 @@ export default memo(function HoverCard({ x, y, id, isPostcode, data, filters }:
{/* Hint */}
{data && (
<div className="text-[10px] text-warm-400 dark:text-warm-500 mt-2 text-center">
<div className="text-[10px] text-warm-400 dark:text-warm-400 mt-2 text-center">
Click for details
</div>
)}

View file

@ -59,6 +59,7 @@ interface MapProps {
initialViewState?: ViewState;
theme?: 'light' | 'dark';
screenshotMode?: boolean;
ogMode?: boolean;
filters?: FeatureFilters;
searchedPostcode?: SearchedPostcode | null;
onPostcodeSearched?: (postcode: SearchedPostcode | null) => void;
@ -109,6 +110,7 @@ export default memo(function Map({
initialViewState,
theme = 'light',
screenshotMode = false,
ogMode = false,
filters = {},
searchedPostcode,
onPostcodeSearched,
@ -452,7 +454,7 @@ export default memo(function Map({
getPosition: (f) => f.properties.centroid,
getText: (f) => f.properties.postcode,
getSize: 12,
getColor: theme === 'dark' ? [220, 220, 220, 220] : [40, 40, 40, 220],
getColor: theme === 'dark' ? [255, 255, 255, 240] : [40, 40, 40, 220],
getTextAnchor: 'middle',
getAlignmentBaseline: 'center',
fontFamily: 'Inter, system-ui, sans-serif',
@ -530,8 +532,15 @@ export default memo(function Map({
return baseLayers;
}, [usePostcodeView, hexLayer, postcodeLayer, postcodeLabelsLayer, poiLayer, searchedPostcodeHighlightLayer]);
const handleMouseLeave = useCallback(() => {
setHoverPosition(null);
setHoveredPostcode(null);
setPopupInfo(null);
onHexagonHoverRef.current(null);
}, []);
return (
<div className="flex-1 h-full relative" ref={containerRef}>
<div className="flex-1 h-full relative" ref={containerRef} onMouseLeave={handleMouseLeave}>
<MapGL
{...viewState}
onMove={handleMove}
@ -550,25 +559,27 @@ export default memo(function Map({
<DeckOverlay layers={layers} getTooltip={null} />
</MapGL>
{screenshotMode ? (
<div className="absolute inset-0 flex items-center justify-center z-20 pointer-events-none">
<h1
className="text-5xl font-bold text-white drop-shadow-lg"
style={{ textShadow: '0 2px 8px rgba(0,0,0,0.6)' }}
>
Your perfect postcodes
</h1>
</div>
ogMode ? (
<div className="absolute inset-0 flex items-center justify-center z-20 pointer-events-none">
<h1
className="text-5xl font-bold text-white drop-shadow-lg"
style={{ textShadow: '0 2px 8px rgba(0,0,0,0.6)' }}
>
Your perfect postcodes
</h1>
</div>
) : null
) : (
<>
<PostcodeSearch onFlyTo={handleFlyTo} onPostcodeSearched={onPostcodeSearched} />
{viewSource === 'eye' && viewFeature && (
<div className="absolute top-4 left-1/2 -translate-x-1/2 z-20 flex items-center gap-3 bg-white dark:bg-warm-800 rounded-lg shadow-lg px-5 py-3">
<span className="text-lg font-semibold text-navy-950 dark:text-warm-100">
<span className="text-lg font-semibold text-navy-950 dark:text-white">
Previewing &ldquo;{viewFeature}&rdquo;
</span>
<button
onClick={onCancelPin}
className="px-4 py-1.5 rounded-md bg-warm-200 dark:bg-warm-700 text-warm-700 dark:text-warm-200 hover:bg-warm-300 dark:hover:bg-warm-600 font-medium text-sm"
className="px-4 py-1.5 rounded-md bg-warm-200 dark:bg-warm-700 text-warm-700 dark:text-white hover:bg-warm-300 dark:hover:bg-warm-600 font-medium text-sm"
>
Cancel
</button>
@ -596,7 +607,7 @@ export default memo(function Map({
)}
{popupInfo && (
<div
className="absolute bg-white dark:bg-warm-800 rounded shadow-lg p-2 text-sm dark:text-warm-200"
className="absolute bg-white dark:bg-warm-800 rounded shadow-lg p-2 text-sm dark:text-white"
style={{
left: popupInfo.x,
top: popupInfo.y - 40,
@ -604,8 +615,8 @@ export default memo(function Map({
zIndex: 9999,
}}
>
<strong>{popupInfo.name}</strong>
<div className="text-warm-500 dark:text-warm-400 text-xs">{popupInfo.category}</div>
<strong className="dark:text-white">{popupInfo.name}</strong>
<div className="text-warm-500 dark:text-warm-300 text-xs">{popupInfo.category}</div>
{osmIdToUrl(popupInfo.id) && (
<a
href={osmIdToUrl(popupInfo.id)!}

View file

@ -24,9 +24,9 @@ export default function MapLegend({
const gradientStyle = mode === 'density' ? gradientToCss(densityGradient) : gradientToCss(FEATURE_GRADIENT);
return (
<div className="absolute top-3 right-3 z-10 bg-white dark:bg-navy-800 dark:text-warm-200 rounded shadow-lg p-3 text-xs min-w-[160px]">
<div className="absolute top-3 right-3 z-10 bg-white dark:bg-navy-800 dark:text-white rounded shadow-lg p-3 text-xs min-w-[160px]">
<div className="flex items-center justify-between mb-2">
<span className="font-semibold text-sm">{featureLabel}</span>
<span className="font-semibold text-sm dark:text-white">{featureLabel}</span>
{showCancel && (
<button
onClick={onCancel}
@ -38,7 +38,7 @@ export default function MapLegend({
)}
</div>
<div className="h-3 rounded" style={{ background: gradientStyle }} />
<div className="flex justify-between mt-1 text-warm-600 dark:text-warm-400">
<div className="flex justify-between mt-1 text-warm-600 dark:text-warm-200">
{mode === 'density' ? (
<>
<span>Few</span>

View file

@ -14,6 +14,8 @@ import { usePOIData } from '../../hooks/usePOIData';
import { useFilters } from '../../hooks/useFilters';
import { useHexagonSelection } from '../../hooks/useHexagonSelection';
import { usePaneResize } from '../../hooks/usePaneResize';
import { useAreaSummary } from '../../hooks/useAreaSummary';
import { useUrlSync } from '../../hooks/useUrlSync';
import { apiUrl, buildFilterString } from '../../lib/api';
import { SpinnerIcon } from '../ui/icons/SpinnerIcon';
@ -36,6 +38,7 @@ interface MapPageProps {
onNavigateTo: (page: Page, hash?: string, infoFeature?: string) => void;
onExportStateChange?: (state: ExportState) => void;
screenshotMode?: boolean;
ogMode?: boolean;
}
export default function MapPage({
@ -52,34 +55,8 @@ export default function MapPage({
onNavigateTo,
onExportStateChange,
screenshotMode,
ogMode,
}: MapPageProps) {
if (screenshotMode) {
return (
<div className="h-screen w-screen">
<Map
data={[]}
postcodeData={[]}
usePostcodeView={false}
pois={[]}
onViewChange={() => {}}
viewFeature={null}
colorRange={null}
filterRange={null}
viewSource={null}
onCancelPin={() => {}}
features={features}
selectedHexagonId={null}
hoveredHexagonId={null}
onHexagonClick={() => {}}
onHexagonHover={() => {}}
initialViewState={initialViewState}
theme={theme}
screenshotMode
/>
</div>
);
}
const [searchedPostcode, setSearchedPostcode] = useState<SearchedPostcode | null>(null);
const [selectedPOICategories, setSelectedPOICategories] = useState<Set<string>>(initialPOICategories);
@ -137,6 +114,9 @@ export default function MapPage({
// POI data
const pois = usePOIData(mapData.bounds, selectedPOICategories);
// Sync current state to URL
useUrlSync(mapData.currentView, filters, features, selectedPOICategories, selection.rightPaneTab);
// Set initial view and tab from URL state
useEffect(() => {
mapData.setInitialView(initialViewState);
@ -146,10 +126,30 @@ export default function MapPage({
// Compute hexagon location for external links
const hexagonLocation = useMemo(() => {
const hexId = selection.selectedHexagon?.id;
const hex = hexId ? mapData.data.find((d) => d.h3 === hexId) : null;
if (!hex || typeof hex.lat !== 'number' || typeof hex.lon !== 'number') return null;
return { lat: hex.lat as number, lon: hex.lon as number, resolution: mapData.resolution };
}, [selection.selectedHexagon?.id, mapData.data, mapData.resolution]);
const isPostcode = selection.selectedHexagon?.type === 'postcode';
if (isPostcode) {
// For postcodes, get centroid from postcodeData
const postcodeFeature = mapData.postcodeData.find((f) => f.properties.postcode === hexId);
if (!postcodeFeature?.properties.centroid) return null;
const [lon, lat] = postcodeFeature.properties.centroid;
return { lat, lon, resolution: mapData.resolution };
} else {
// For hexagons, get lat/lon from hexagon data
const hex = hexId ? mapData.data.find((d) => d.h3 === hexId) : null;
if (!hex || typeof hex.lat !== 'number' || typeof hex.lon !== 'number') return null;
return { lat: hex.lat as number, lon: hex.lon as number, resolution: mapData.resolution };
}
}, [selection.selectedHexagon?.id, selection.selectedHexagon?.type, mapData.data, mapData.postcodeData, mapData.resolution]);
// AI area summary
const aiSummary = useAreaSummary({
stats: selection.areaStats,
hexagonId: selection.selectedHexagon?.id || null,
isPostcode: selection.selectedHexagon?.type === 'postcode',
filters,
features,
});
// Export to Excel
const [exporting, setExporting] = useState(false);
@ -185,6 +185,41 @@ export default function MapPage({
onExportStateChange?.({ onExport: handleExport, exporting });
}, [handleExport, exporting, onExportStateChange]);
// Signal screenshot readiness once map data has loaded
useEffect(() => {
if (screenshotMode && !mapData.loading && mapData.data.length > 0) {
window.__og_ready = true;
}
}, [screenshotMode, mapData.loading, mapData.data.length]);
if (screenshotMode) {
return (
<div className="h-screen w-screen">
<Map
data={mapData.data}
postcodeData={mapData.postcodeData}
usePostcodeView={mapData.usePostcodeView}
pois={[]}
onViewChange={mapData.handleViewChange}
viewFeature={viewFeature}
colorRange={mapData.colorRange}
filterRange={filterRange}
viewSource={viewSource}
onCancelPin={() => {}}
features={features}
selectedHexagonId={null}
hoveredHexagonId={null}
onHexagonClick={() => {}}
onHexagonHover={() => {}}
initialViewState={initialViewState}
theme={theme}
screenshotMode
ogMode={ogMode}
/>
</div>
);
}
return (
<div className="flex-1 flex overflow-hidden relative">
{initialLoading && (
@ -295,6 +330,10 @@ export default function MapPage({
hexagonLocation={hexagonLocation}
filters={filters}
onNavigateToSource={(slug, featureName) => onNavigateTo('data-sources', slug, featureName)}
aiSummary={aiSummary.summary}
aiSummaryLoading={aiSummary.loading}
aiSummaryError={aiSummary.error}
onRetryAiSummary={aiSummary.retry}
/>
) : selection.rightPaneTab === 'properties' ? (
<PropertiesPane

View file

@ -61,7 +61,7 @@ export default function PostcodeSearch({
setError(null);
}}
placeholder="Search postcode..."
className="px-3 py-2 text-sm w-40 border-none outline-none bg-white dark:bg-navy-800 dark:text-warm-100 dark:placeholder-warm-500"
className="px-3 py-2 text-sm w-40 border-none outline-none bg-white dark:bg-navy-800 dark:text-white dark:placeholder-warm-400"
/>
<button
type="submit"
@ -72,7 +72,7 @@ export default function PostcodeSearch({
</button>
</div>
{error && (
<span className="text-xs text-red-600 dark:text-red-400 bg-white/90 dark:bg-navy-800/90 rounded px-2 py-0.5 shadow">
<span className="text-xs text-red-600 dark:text-red-300 bg-white/90 dark:bg-navy-800/90 rounded px-2 py-0.5 shadow">
{error}
</span>
)}

View file

@ -1,6 +1,6 @@
import { useMemo, useRef, useState, useEffect } from 'react';
import type { PricePoint } from '../../types';
import { formatValue, FEATURE_FORMATS } from '../../lib/format';
import { formatValue } from '../../lib/format';
interface PriceHistoryChartProps {
points: PricePoint[];
@ -8,7 +8,7 @@ interface PriceHistoryChartProps {
const PADDING = { top: 8, right: 8, bottom: 20, left: 42 };
const HEIGHT = 120;
const priceFmt = FEATURE_FORMATS['Last known price'];
const priceFmt = { prefix: '£' };
export default function PriceHistoryChart({ points }: PriceHistoryChartProps) {
const containerRef = useRef<HTMLDivElement>(null);

View file

@ -89,7 +89,7 @@ export function PropertiesPane({
<div className="flex-1 overflow-y-auto">
{loading && properties.length === 0 ? (
<div className="p-4 dark:text-warm-400">Loading...</div>
<PropertyLoadingSkeleton />
) : (
<>
{filtered.map((property, idx) => (
@ -99,9 +99,16 @@ export function PropertiesPane({
<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"
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 ? 'Loading...' : `Load More (${total - properties.length} remaining)`}
{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>
)}
</>
@ -111,6 +118,32 @@ export function PropertiesPane({
);
}
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"
>
{/* Address */}
<div className="h-5 w-3/4 bg-warm-200 dark:bg-warm-700 rounded mb-2" />
{/* Postcode */}
<div className="h-4 w-24 bg-warm-200 dark:bg-warm-700 rounded mb-3" />
{/* Price */}
<div className="h-6 w-32 bg-warm-200 dark:bg-warm-700 rounded mb-3" />
{/* Property details grid */}
<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 price = getNum(property, 'Last known price', 'latest_price');
const pricePerSqm = getNum(property, 'Price per sqm', 'price_per_sqm');