+
+
+
{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()}`;
}