diff --git a/frontend/src/components/map/AiFilterInput.tsx b/frontend/src/components/map/AiFilterInput.tsx index 3608d3a..ec38479 100644 --- a/frontend/src/components/map/AiFilterInput.tsx +++ b/frontend/src/components/map/AiFilterInput.tsx @@ -1,6 +1,16 @@ import { memo, useState, useCallback } from 'react'; import { SpinnerIcon } from '../ui/icons/SpinnerIcon'; +function SparklesIcon({ className }: { className?: string }) { + return ( + + + + + + ); +} + 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 (
-
- 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} - /> - + +
+ + 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} + /> +
+ {(hasContent || loading) && ( + + )}
{error && (

diff --git a/frontend/src/components/map/AreaPane.tsx b/frontend/src/components/map/AreaPane.tsx index 704385c..58d994b 100644 --- a/frontend/src/components/map/AreaPane.tsx +++ b/frontend/src/components/map/AreaPane.tsx @@ -114,7 +114,7 @@ export default function AreaPane({ in this {isPostcode ? 'postcode' : 'area'} {Object.keys(filters).length > 0 ? ' matching all active filters' : ''}

- {!isPostcode && stats && ( + {stats && stats.count > 0 && ( -
- -
-
- {(['historical', 'buy', 'rent'] as const).map((type) => { - const labels = { historical: 'Historical', buy: 'Buy', rent: 'Rent' }; - const isActive = activeListingType === type; - return ( - - ); - })} -
-
@@ -286,14 +262,43 @@ export default memo(function Filters({
-
+
+ +
+
+ {(['historical', 'buy', 'rent'] as const).map((type) => { + const labels = { historical: 'Historical', buy: 'Buy', rent: 'Rent' }; + const isActive = activeListingType === type; + return ( + + ); + })} +
+ +
{travelTimeEntries.length > 0 && (
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" > {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" > {group.features.length} @@ -351,6 +356,7 @@ export default memo(function Filters({ return (
@@ -407,6 +413,7 @@ export default memo(function Filters({ return (
diff --git a/frontend/src/components/map/POIPane.tsx b/frontend/src/components/map/POIPane.tsx index b4d6e00..ba3af89 100644 --- a/frontend/src/components/map/POIPane.tsx +++ b/frontend/src/components/map/POIPane.tsx @@ -85,7 +85,7 @@ export default function POIPane({ return (
-
+
POIs @@ -137,14 +137,16 @@ export default function POIPane({ )} -
+
+ +
{filteredGroups.map((group) => { const groupSelected = group.categories.filter((c) => selectedCategories.has(c)).length; const allInGroupSelected = groupSelected === group.categories.length; diff --git a/frontend/src/hooks/useMapData.ts b/frontend/src/hooks/useMapData.ts index 80e0fb6..8ce9f99 100644 --- a/frontend/src/hooks/useMapData.ts +++ b/frontend/src/hooks/useMapData.ts @@ -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); diff --git a/frontend/src/lib/external-search.ts b/frontend/src/lib/external-search.ts index 4a4dc79..a133517 100644 --- a/frontend/src/lib/external-search.ts +++ b/frontend/src/lib/external-search.ts @@ -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()}`; }