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 = { 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 }; }