Frontend fixes

This commit is contained in:
Andras Schmelczer 2026-03-08 21:01:33 +00:00
parent 135f4be401
commit 245c16a212
7 changed files with 169 additions and 89 deletions

View file

@ -1,6 +1,16 @@
import { memo, useState, useCallback } from 'react';
import { SpinnerIcon } from '../ui/icons/SpinnerIcon';
function SparklesIcon({ className }: { className?: string }) {
return (
<svg className={className} viewBox="0 0 16 16" fill="currentColor">
<path d="M8 1.5a.5.5 0 0 1 .5.5v2.5H11a.5.5 0 0 1 0 1H8.5V8a.5.5 0 0 1-1 0V5.5H5a.5.5 0 0 1 0-1h2.5V2a.5.5 0 0 1 .5-.5Z" />
<path d="M12.5 8a.5.5 0 0 1 .5.5v1.5h1.5a.5.5 0 0 1 0 1H13v1.5a.5.5 0 0 1-1 0V11H10.5a.5.5 0 0 1 0-1H12V8.5a.5.5 0 0 1 .5-.5Z" opacity=".7" />
<path d="M3.5 10a.5.5 0 0 1 .5.5V12h1.5a.5.5 0 0 1 0 1H4v1.5a.5.5 0 0 1-1 0V13H1.5a.5.5 0 0 1 0-1H3v-1.5a.5.5 0 0 1 .5-.5Z" opacity=".4" />
</svg>
);
}
interface AiFilterInputProps {
loading: boolean;
error: string | null;
@ -21,28 +31,35 @@ export default memo(function AiFilterInput({ loading, error, notes, onSubmit }:
[query, loading, onSubmit]
);
const hasContent = query.trim().length > 0;
return (
<div className="px-3 py-2">
<form onSubmit={handleSubmit} className="flex flex-col gap-1.5">
<input
type="text"
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="Describe your ideal property and area..."
className="w-full px-2.5 py-1.5 text-sm rounded-lg border border-warm-200 dark:border-warm-700 bg-white dark:bg-warm-800 text-warm-700 dark:text-warm-200 placeholder-warm-400 dark:placeholder-warm-500 focus:outline-none focus:ring-2 focus:ring-teal-400"
disabled={loading}
/>
<button
type="submit"
disabled={loading || !query.trim()}
className="w-full px-3 py-1.5 rounded-lg bg-teal-600 hover:bg-teal-700 disabled:opacity-50 disabled:cursor-not-allowed text-white text-sm font-medium flex items-center justify-center gap-1.5"
>
{loading ? (
<SpinnerIcon className="w-4 h-4 animate-spin" />
) : (
'Set filters with AI'
)}
</button>
<form onSubmit={handleSubmit} className="flex items-center gap-1.5">
<div className="relative flex-1">
<SparklesIcon className="absolute left-2 top-1/2 -translate-y-1/2 w-3.5 h-3.5 text-teal-500 dark:text-teal-400 pointer-events-none" />
<input
type="text"
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="Describe your ideal area..."
className="w-full pl-7 pr-2.5 py-1.5 text-sm rounded-lg border border-warm-200 dark:border-warm-700 bg-warm-50 dark:bg-warm-800 text-warm-700 dark:text-warm-200 placeholder-warm-400 dark:placeholder-warm-500 focus:outline-none focus:ring-2 focus:ring-teal-400 focus:bg-white dark:focus:bg-warm-800"
disabled={loading}
/>
</div>
{(hasContent || loading) && (
<button
type="submit"
disabled={loading || !hasContent}
className="shrink-0 px-2.5 py-1.5 rounded-lg bg-teal-600 hover:bg-teal-700 disabled:opacity-50 disabled:cursor-not-allowed text-white text-sm font-medium flex items-center justify-center"
>
{loading ? (
<SpinnerIcon className="w-4 h-4 animate-spin" />
) : (
<SparklesIcon className="w-4 h-4" />
)}
</button>
)}
</form>
{error && (
<p className="mt-1 text-xs text-red-600 dark:text-red-400 truncate" title={error}>

View file

@ -114,7 +114,7 @@ export default function AreaPane({
in this {isPostcode ? 'postcode' : 'area'}
{Object.keys(filters).length > 0 ? ' matching all active filters' : ''}
</p>
{!isPostcode && stats && (
{stats && stats.count > 0 && (
<button
onClick={onViewProperties}
className="mt-2 w-full text-sm py-1.5 rounded bg-teal-600 hover:bg-teal-700 text-white font-medium"
@ -135,6 +135,20 @@ export default function AreaPane({
<div>
{hexagonLocation && <StreetViewEmbed location={hexagonLocation} />}
<HistogramLegend />
{stats.price_history &&
(() => {
const uniqueYears = new Set(
stats.price_history.map((p) => Math.floor(p.year))
);
return uniqueYears.size > 1;
})() && (
<div className="mx-3 mt-2 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>
)}
{featureGroups.map((group) => {
const hasData = group.features.some(
(feature) => numericByName.has(feature.name) || enumByName.has(feature.name)
@ -158,25 +172,10 @@ export default function AreaPane({
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"
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 z-10 hover:bg-warm-100 dark:hover:bg-warm-800"
/>
{isExpanded && (
<div className="px-3 py-2 space-y-3">
{group.name === 'Property' &&
stats.price_history &&
(() => {
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
? stackedCharts.map((chart) => {
const segments = chart.components

View file

@ -89,7 +89,7 @@ export default function FeatureBrowser({
name="Travel Time"
expanded={isSearching || expandedGroups.has('Travel Time')}
onToggle={() => toggleGroup('Travel Time')}
className="px-3 py-1.5 text-xs font-bold text-navy-950 bg-warm-200 dark:bg-navy-900 dark:text-warm-100 sticky top-0 hover:bg-warm-200 dark:hover:bg-warm-800"
className="px-3 py-1.5 text-xs font-bold text-navy-950 bg-warm-200 dark:bg-navy-900 dark:text-warm-100 sticky top-0 z-10 hover:bg-warm-200 dark:hover:bg-warm-800"
>
<span className="text-[10px] font-medium text-warm-400 dark:text-warm-500">
{TRANSPORT_MODES.length}
@ -131,7 +131,7 @@ export default function FeatureBrowser({
name={group.name}
expanded={isExpanded}
onToggle={() => toggleGroup(group.name)}
className="px-3 py-1.5 text-xs font-bold text-navy-950 bg-warm-200 dark:bg-navy-900 dark:text-warm-100 sticky top-0 hover:bg-warm-200 dark:hover:bg-warm-800"
className="px-3 py-1.5 text-xs font-bold text-navy-950 bg-warm-200 dark:bg-navy-900 dark:text-warm-100 sticky top-0 z-10 hover:bg-warm-200 dark:hover:bg-warm-800"
>
<span className="text-[10px] font-medium text-warm-400 dark:text-warm-500">
{group.features.length}

View file

@ -205,20 +205,29 @@ export default memo(function Filters({
);
const containerRef = useRef<HTMLDivElement>(null);
const scrollRef = useRef<HTMLDivElement>(null);
const [showPhilosophy, setShowPhilosophy] = useState(false);
const [activeInfoFeature, setActiveInfoFeature] = useState<FeatureMeta | null>(null);
const [collapsedGroups, toggleGroup] = useCollapsibleGroups();
const [collapsedGroups, toggleGroup, expandGroup] = useCollapsibleGroups();
const activeEntryCount = travelTimeEntries.length;
const handleAddAndScroll = useCallback(
(name: string) => {
const feature = features.find((f) => f.name === name);
if (feature?.group) expandGroup(feature.group);
onAddFilter(name);
// Double rAF: first lets React commit the DOM update, second lets layout settle
requestAnimationFrame(() => {
containerRef.current?.scrollTo({ top: 0, behavior: 'smooth' });
requestAnimationFrame(() => {
const el = scrollRef.current?.querySelector(`[data-filter-name="${CSS.escape(name)}"]`);
if (el) {
el.scrollIntoView({ behavior: 'smooth', block: 'start' });
}
});
});
},
[onAddFilter]
[onAddFilter, features, expandGroup]
);
const enabledGroups = useMemo(
() => groupFeaturesByCategory(enabledFeatureList),
@ -239,39 +248,6 @@ export default memo(function Filters({
return (
<div ref={containerRef} className="flex flex-col bg-white dark:bg-navy-950 overflow-y-auto md:overflow-hidden h-full">
<div className="shrink-0 border-b border-warm-200 dark:border-navy-700">
<AiFilterInput loading={aiFilterLoading} error={aiFilterError} notes={aiFilterNotes} onSubmit={onAiFilterSubmit} />
<div className="flex items-center gap-2 px-3 pb-2">
<button
onClick={() => setShowPhilosophy(true)}
className="flex-1 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"
>
<LightbulbIcon />
Finding the Perfect Postcode
</button>
</div>
</div>
<div className="shrink-0 px-3 py-2.5 border-b border-warm-200 dark:border-navy-700">
<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: 'Historical', buy: 'Buy', rent: '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>
</div>
<div className="shrink-0 md:shrink md:min-h-0 flex flex-col md:basis-[40%]">
<div className="shrink-0 flex items-center justify-between px-3 py-2 border-b border-warm-200 dark:border-navy-700">
<div className="flex items-center gap-2">
@ -286,14 +262,43 @@ export default memo(function Filters({
</div>
</div>
<div className="md:flex-1 md:overflow-y-auto">
<div ref={scrollRef} className="md:flex-1 md:overflow-y-auto">
<AiFilterInput loading={aiFilterLoading} error={aiFilterError} notes={aiFilterNotes} onSubmit={onAiFilterSubmit} />
<div className="px-3 pb-2 space-y-2">
<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: 'Historical', buy: 'Buy', rent: '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"
>
<LightbulbIcon />
Finding the Perfect Postcode
</button>
</div>
{travelTimeEntries.length > 0 && (
<div>
<CollapsibleGroupHeader
name="Travel Time"
expanded={!collapsedGroups.has('Travel Time')}
onToggle={() => toggleGroup('Travel Time')}
className="px-3 py-1.5 text-xs font-bold text-navy-950 bg-warm-200 dark:bg-navy-900 dark:text-warm-100 sticky top-0 hover:bg-warm-200 dark:hover:bg-warm-800"
className="px-3 py-1.5 text-xs font-bold text-navy-950 bg-warm-200 dark:bg-navy-900 dark:text-warm-100 sticky top-0 z-10 hover:bg-warm-200 dark:hover:bg-warm-800"
>
<span className="text-[10px] font-medium text-warm-400 dark:text-warm-500">
{travelTimeEntries.length}
@ -336,7 +341,7 @@ export default memo(function Filters({
name={group.name}
expanded={isExpanded}
onToggle={() => toggleGroup(group.name)}
className="px-3 py-1.5 text-xs font-bold text-navy-950 bg-warm-200 dark:bg-navy-900 dark:text-warm-100 sticky top-0 hover:bg-warm-200 dark:hover:bg-warm-800"
className="px-3 py-1.5 text-xs font-bold text-navy-950 bg-warm-200 dark:bg-navy-900 dark:text-warm-100 sticky top-0 z-10 hover:bg-warm-200 dark:hover:bg-warm-800"
>
<span className="text-[10px] font-medium text-warm-400 dark:text-warm-500">
{group.features.length}
@ -351,6 +356,7 @@ export default memo(function Filters({
return (
<div
key={feature.name}
data-filter-name={feature.name}
className={`space-y-0.5 px-2 py-1.5 rounded ${pinnedFeature === feature.name ? 'ring-2 ring-teal-400 bg-teal-50/50 dark:bg-teal-900/20' : ''}`}
>
<div className="flex items-center justify-between">
@ -407,6 +413,7 @@ export default memo(function Filters({
return (
<div
key={feature.name}
data-filter-name={feature.name}
className={`space-y-0.5 px-2 py-1.5 rounded ${isActive ? 'ring-2 ring-teal-400 bg-teal-50 dark:bg-teal-900/30' : isPinned ? 'ring-2 ring-teal-400 bg-teal-50/50 dark:bg-teal-900/20' : ''}`}
>
<div className="flex items-center justify-between gap-1">

View file

@ -85,7 +85,7 @@ export default function POIPane({
return (
<div className="flex flex-col h-full bg-white dark:bg-navy-950 shadow-lg overflow-hidden">
<div className="flex-shrink-0 px-3 pt-3 pb-2 space-y-2">
<div className="flex-shrink-0 px-3 pt-3 pb-2">
<div className="flex items-center gap-2">
<span className="text-xs font-semibold text-warm-500 dark:text-warm-400 uppercase tracking-wide">
POIs
@ -137,14 +137,16 @@ export default function POIPane({
</InfoPopup>
)}
<SearchInput
value={searchTerm}
onChange={setSearchTerm}
placeholder="Search categories..."
/>
</div>
<div className="flex-1 overflow-y-auto border-t border-warm-200 dark:border-warm-700">
<div className="px-3 pt-2 pb-1">
<SearchInput
value={searchTerm}
onChange={setSearchTerm}
placeholder="Search categories..."
/>
</div>
{filteredGroups.map((group) => {
const groupSelected = group.categories.filter((c) => selectedCategories.has(c)).length;
const allInGroupSelected = groupSelected === group.categories.length;

View file

@ -180,6 +180,7 @@ export function useMapData({
if (errBody.error === 'license_required' && errBody.free_zone) {
setLicenseRequired(true);
setFreeZone(errBody.free_zone);
setLoading(false);
return;
}
}
@ -209,6 +210,7 @@ export function useMapData({
if (errBody.error === 'license_required' && errBody.free_zone) {
setLicenseRequired(true);
setFreeZone(errBody.free_zone);
setLoading(false);
return;
}
}
@ -225,10 +227,12 @@ export function useMapData({
setDragPostcodeData(null);
dragFeatureRef.current = null;
}
} catch (err) {
if (!isAbortError(err)) logNonAbortError('Failed to fetch data', err);
} finally {
setLoading(false);
} catch (err) {
if (!isAbortError(err)) {
logNonAbortError('Failed to fetch data', err);
setLoading(false);
}
}
}, DEBOUNCE_MS);

View file

@ -44,10 +44,35 @@ const RIGHTMOVE_RADII = [0.25, 0.5, 1, 3, 5, 10, 15, 20, 30, 40];
const OTM_RADII = [0.25, 0.5, 1, 3, 5, 10, 15, 20, 30, 40];
const ZOOPLA_RADII = [0.25, 0.5, 1, 3, 5, 10, 15, 20, 25, 30];
// Rightmove only accepts these specific price values
const RIGHTMOVE_PRICES = [
50000, 60000, 70000, 80000, 90000, 100000, 110000, 120000, 125000, 130000, 140000, 150000,
160000, 170000, 175000, 180000, 190000, 200000, 210000, 220000, 230000, 240000, 250000, 260000,
270000, 280000, 290000, 300000, 325000, 350000, 375000, 400000, 425000, 450000, 475000, 500000,
550000, 600000, 650000, 700000, 800000, 900000, 1000000, 1250000, 1500000, 1750000, 2000000,
2500000, 3000000, 4000000, 5000000, 7500000, 10000000, 15000000, 20000000,
];
function nearestRadius(target: number, allowed: number[]): number {
return allowed.reduce((best, r) => (Math.abs(r - target) < Math.abs(best - target) ? r : best));
}
/** Snap minPrice down and maxPrice up so Rightmove doesn't ignore them */
function snapRightmovePrice(value: number, direction: 'floor' | 'ceil'): number {
if (direction === 'floor') {
// Largest supported value <= target
for (let i = RIGHTMOVE_PRICES.length - 1; i >= 0; i--) {
if (RIGHTMOVE_PRICES[i] <= value) return RIGHTMOVE_PRICES[i];
}
return RIGHTMOVE_PRICES[0];
}
// Smallest supported value >= target
for (const p of RIGHTMOVE_PRICES) {
if (p >= value) return p;
}
return RIGHTMOVE_PRICES[RIGHTMOVE_PRICES.length - 1];
}
interface SearchUrlOptions {
location: HexagonLocation;
filters: FeatureFilters;
@ -76,6 +101,24 @@ export function buildPropertySearchUrls({
? (propertyTypes as string[])
: [];
const bedroomFilter = filters['Bedrooms'];
const minBedrooms =
Array.isArray(bedroomFilter) && typeof bedroomFilter[0] === 'number' ? bedroomFilter[0] : undefined;
const maxBedrooms =
Array.isArray(bedroomFilter) && typeof bedroomFilter[1] === 'number' ? bedroomFilter[1] : undefined;
const bathroomFilter = filters['Bathrooms'];
const minBathrooms =
Array.isArray(bathroomFilter) && typeof bathroomFilter[0] === 'number' ? bathroomFilter[0] : undefined;
const maxBathrooms =
Array.isArray(bathroomFilter) && typeof bathroomFilter[1] === 'number' ? bathroomFilter[1] : undefined;
const tenureFilter = filters['Leashold/Freehold'];
const selectedTenures =
Array.isArray(tenureFilter) && typeof tenureFilter[0] === 'string'
? (tenureFilter as string[])
: [];
// Rightmove — requires locationIdentifier from typeahead API
let rightmove: string | null = null;
if (rightmoveLocationId) {
@ -84,8 +127,12 @@ export function buildPropertySearchUrls({
rmParams.set('useLocationIdentifier', 'true');
rmParams.set('locationIdentifier', rightmoveLocationId);
rmParams.set('radius', String(nearestRadius(radiusMiles, RIGHTMOVE_RADII)));
if (minPrice !== undefined) rmParams.set('minPrice', String(Math.round(minPrice)));
if (maxPrice !== undefined) rmParams.set('maxPrice', String(Math.round(maxPrice)));
if (minPrice !== undefined) rmParams.set('minPrice', String(snapRightmovePrice(minPrice, 'floor')));
if (maxPrice !== undefined) rmParams.set('maxPrice', String(snapRightmovePrice(maxPrice, 'ceil')));
if (minBedrooms !== undefined) rmParams.set('minBedrooms', String(Math.floor(minBedrooms)));
if (maxBedrooms !== undefined) rmParams.set('maxBedrooms', String(Math.ceil(maxBedrooms)));
if (minBathrooms !== undefined) rmParams.set('minBathrooms', String(Math.floor(minBathrooms)));
if (maxBathrooms !== undefined) rmParams.set('maxBathrooms', String(Math.ceil(maxBathrooms)));
if (selectedTypes.length > 0) {
const rmTypes = [
...new Set(
@ -97,6 +144,10 @@ export function buildPropertySearchUrls({
];
if (rmTypes.length > 0) rmParams.set('propertyTypes', rmTypes.join(','));
}
if (selectedTenures.length > 0) {
const rmTenures = selectedTenures.map((t) => (t === 'Freehold' ? 'FREEHOLD' : 'LEASEHOLD'));
rmParams.set('tenureTypes', rmTenures.join(','));
}
rmParams.set('_includeSSTC', 'on');
rightmove = `https://www.rightmove.co.uk/property-for-sale/find.html?${rmParams.toString()}`;
}