190 lines
7.7 KiB
TypeScript
190 lines
7.7 KiB
TypeScript
import type { FeatureFilters } from '../types';
|
|
|
|
export interface HexagonLocation {
|
|
lat: number;
|
|
lon: number;
|
|
resolution: number;
|
|
postcode?: string;
|
|
isPostcode?: boolean;
|
|
}
|
|
|
|
const PROPERTY_TYPE_MAP: Record<
|
|
string,
|
|
{ rightmove: string; onthemarket: string; zoopla: string }
|
|
> = {
|
|
House: { rightmove: 'detached,semi-detached,terraced', onthemarket: 'property', zoopla: '' },
|
|
Detached: { rightmove: 'detached', onthemarket: 'detached', zoopla: 'detached' },
|
|
'Semi-Detached': {
|
|
rightmove: 'semi-detached',
|
|
onthemarket: 'semi-detached',
|
|
zoopla: 'semi_detached',
|
|
},
|
|
'Mid-Terrace': { rightmove: 'terraced', onthemarket: 'terraced', zoopla: 'terraced' },
|
|
'End-Terrace': { rightmove: 'terraced', onthemarket: 'terraced', zoopla: 'terraced' },
|
|
'Enclosed Mid-Terrace': { rightmove: 'terraced', onthemarket: 'terraced', zoopla: 'terraced' },
|
|
'Enclosed End-Terrace': { rightmove: 'terraced', onthemarket: 'terraced', zoopla: 'terraced' },
|
|
'Flats/Maisonettes': { rightmove: 'flat', onthemarket: 'flats', zoopla: 'flat' },
|
|
Bungalow: { rightmove: 'bungalow', onthemarket: 'bungalow', zoopla: 'bungalow' },
|
|
'Park home': { rightmove: 'park-home', onthemarket: 'property', zoopla: '' },
|
|
};
|
|
|
|
export const H3_RADIUS_MILES: Record<number, number> = {
|
|
4: 15,
|
|
5: 6,
|
|
6: 3,
|
|
7: 1,
|
|
8: 0.5,
|
|
9: 1,
|
|
10: 1,
|
|
11: 1,
|
|
12: 1,
|
|
};
|
|
|
|
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;
|
|
rightmoveLocationId?: string;
|
|
}
|
|
|
|
export function buildPropertySearchUrls({
|
|
location,
|
|
filters,
|
|
rightmoveLocationId,
|
|
}: SearchUrlOptions): { rightmove: string | null; onthemarket: string; zoopla: string } | null {
|
|
const { postcode, resolution, isPostcode } = location;
|
|
if (!postcode) return null;
|
|
|
|
const radiusMiles = isPostcode ? 0.25 : (H3_RADIUS_MILES[resolution] ?? 1);
|
|
|
|
const priceFilter = filters['Last known price'];
|
|
const minPrice =
|
|
Array.isArray(priceFilter) && typeof priceFilter[0] === 'number' ? priceFilter[0] : undefined;
|
|
const maxPrice =
|
|
Array.isArray(priceFilter) && typeof priceFilter[1] === 'number' ? priceFilter[1] : undefined;
|
|
|
|
const propertyTypes = filters['Property type'];
|
|
const selectedTypes =
|
|
Array.isArray(propertyTypes) && typeof propertyTypes[0] === 'string'
|
|
? (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['Leasehold/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) {
|
|
const rmParams = new URLSearchParams();
|
|
rmParams.set('searchLocation', postcode);
|
|
rmParams.set('useLocationIdentifier', 'true');
|
|
rmParams.set('locationIdentifier', rightmoveLocationId);
|
|
rmParams.set('radius', String(nearestRadius(radiusMiles, RIGHTMOVE_RADII)));
|
|
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(
|
|
selectedTypes.flatMap((t) => {
|
|
const mapped = PROPERTY_TYPE_MAP[t]?.rightmove;
|
|
return mapped ? mapped.split(',') : [];
|
|
})
|
|
),
|
|
];
|
|
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()}`;
|
|
}
|
|
|
|
// OnTheMarket — postcode slug in URL path (e.g. "SW1A 1AA" → "sw1a-1aa")
|
|
const otmSlug = postcode.toLowerCase().replace(/\s+/g, '-');
|
|
const otmParams = new URLSearchParams();
|
|
otmParams.set('radius', String(nearestRadius(radiusMiles, OTM_RADII)));
|
|
if (minPrice !== undefined) otmParams.set('min-price', String(Math.round(minPrice)));
|
|
if (maxPrice !== undefined) otmParams.set('max-price', String(Math.round(maxPrice)));
|
|
if (selectedTypes.length > 0) {
|
|
const otmTypes = [
|
|
...new Set(selectedTypes.map((t) => PROPERTY_TYPE_MAP[t]?.onthemarket).filter(Boolean)),
|
|
];
|
|
for (const ot of otmTypes) {
|
|
otmParams.append('prop-types', ot!);
|
|
}
|
|
}
|
|
otmParams.set('view', 'map-list');
|
|
const onthemarket = `https://www.onthemarket.com/for-sale/property/${otmSlug}/?${otmParams.toString()}`;
|
|
|
|
// Zoopla
|
|
const zParams = new URLSearchParams();
|
|
zParams.set('q', postcode);
|
|
zParams.set('search_source', 'for-sale');
|
|
zParams.set('radius', String(nearestRadius(radiusMiles, ZOOPLA_RADII)));
|
|
if (minPrice !== undefined) zParams.set('price_min', String(Math.round(minPrice)));
|
|
if (maxPrice !== undefined) zParams.set('price_max', String(Math.round(maxPrice)));
|
|
if (selectedTypes.length > 0) {
|
|
const zTypes = [
|
|
...new Set(selectedTypes.map((t) => PROPERTY_TYPE_MAP[t]?.zoopla).filter(Boolean)),
|
|
];
|
|
for (const zt of zTypes) {
|
|
zParams.append('property_sub_type', zt!);
|
|
}
|
|
}
|
|
const zoopla = `https://www.zoopla.co.uk/for-sale/property/?${zParams.toString()}`;
|
|
|
|
return { rightmove, onthemarket, zoopla };
|
|
}
|