import { useState, useCallback, useRef, useEffect } from 'react'; import { useTranslation } from 'react-i18next'; import type { MapFlyToOptions, PostcodeGeometry } from '../../types'; import { authHeaders, isAbortError } from '../../lib/api'; import { DEV_LOCATION, POSTCODE_SEARCH_ZOOM } from '../../lib/consts'; import { useIsMobile } from '../../hooks/useIsMobile'; import { useLocationSearch, type SearchResult, type ViewportCenter, } from '../../hooks/useLocationSearch'; import { PlaceSearchInput } from '../ui/PlaceSearchInput'; import { LocateIcon } from '../ui/icons/LocateIcon'; import { SearchIcon } from '../ui/icons/SearchIcon'; declare const __DEV__: boolean; export interface SearchedLocation { postcode: string; geometry: PostcodeGeometry; latitude: number; longitude: number; zoom: number; markerLatitude?: number; markerLongitude?: number; openProperties?: boolean; focusAddress?: string; } interface PostcodeLookupResponse { postcode: string; latitude: number; longitude: number; geometry: PostcodeGeometry; } const ZOOM_FOR_TYPE: Record = { city: 10, borough: 12, outcode: 14, town: 13, suburb: 14, quarter: 14, neighbourhood: 14, village: 14, station: 15, island: 12, locality: 14, hamlet: 15, isolated_dwelling: 16, street: 16, university: 15, park: 15, attraction: 16, hospital: 16, retail: 15, }; export default function LocationSearch({ onFlyTo, onLocationSearched, onCurrentLocationFound, onMouseEnter, getViewportCenter, className = '', inputClassName, }: { onFlyTo: (lat: number, lng: number, zoom: number, options?: MapFlyToOptions) => void; onLocationSearched?: (postcode: SearchedLocation | null) => void; onCurrentLocationFound?: (lat: number, lng: number) => void; onMouseEnter?: () => void; /** Returns the current map centre so search ranking can bias toward the visible area. */ getViewportCenter?: () => ViewportCenter | null; className?: string; inputClassName?: string; }) { const { t } = useTranslation(); const search = useLocationSearch(undefined, getViewportCenter); const [error, setError] = useState(null); const [loading, setLoading] = useState(false); const [expanded, setExpanded] = useState(false); const isMobile = useIsMobile(); const containerRef = useRef(null); const inputRef = useRef(null); const lookupAbortRef = useRef(null); const lookupRequestIdRef = useRef(0); const cancelLookup = useCallback((updateLoading = true) => { lookupRequestIdRef.current += 1; lookupAbortRef.current?.abort(); lookupAbortRef.current = null; if (updateLoading) setLoading(false); }, []); const beginLookup = useCallback(() => { lookupAbortRef.current?.abort(); const controller = new AbortController(); lookupAbortRef.current = controller; lookupRequestIdRef.current += 1; setError(null); setLoading(true); search.close(); return { controller, requestId: lookupRequestIdRef.current }; }, [search]); const isCurrentLookup = useCallback((requestId: number, controller: AbortController) => { return ( lookupRequestIdRef.current === requestId && lookupAbortRef.current === controller && !controller.signal.aborted ); }, []); // Close on outside click useEffect(() => { const handler = (e: MouseEvent) => { if (containerRef.current && !containerRef.current.contains(e.target as Node)) { search.close(); if (isMobile) setExpanded(false); } }; document.addEventListener('mousedown', handler); return () => document.removeEventListener('mousedown', handler); }, [isMobile, search]); // Focus input when expanding on mobile useEffect(() => { if (isMobile && expanded) { inputRef.current?.focus(); } }, [isMobile, expanded]); useEffect(() => { return () => cancelLookup(false); }, [cancelLookup]); const selectResult = useCallback( async (result: SearchResult) => { const { controller, requestId } = beginLookup(); if (result.type === 'place') { const zoom = ZOOM_FOR_TYPE[result.place_type] ?? 14; const flyZoom = result.place_type === 'outcode' ? POSTCODE_SEARCH_ZOOM : zoom; // Move the camera straight away using the coordinates already in the // search result. The nearest-postcode lookup below only feeds the side // panel, so gating the fly on it (and on isCurrentLookup) made the jump // intermittent whenever that request was slow, failed, or was superseded // by another keystroke/selection. if (!isMobile) onFlyTo(result.lat, result.lon, flyZoom); try { const params = new URLSearchParams({ lat: String(result.lat), lng: String(result.lon), log: 'false', }); const res = await fetch( `/api/nearest-postcode?${params}`, authHeaders({ signal: controller.signal }) ); if (!isCurrentLookup(requestId, controller)) return; if (!res.ok) { setError(t('locationSearch.lookupFailed')); return; } const json: PostcodeLookupResponse = await res.json(); if (!isCurrentLookup(requestId, controller)) return; onLocationSearched?.({ postcode: json.postcode, geometry: json.geometry, latitude: json.latitude, longitude: json.longitude, zoom: flyZoom, markerLatitude: result.lat, markerLongitude: result.lon, }); search.saveRecentSearch(result); search.clear(); if (isMobile) setExpanded(false); } catch (error) { if (!isCurrentLookup(requestId, controller) || isAbortError(error)) return; setError(t('locationSearch.lookupFailed')); } finally { if (isCurrentLookup(requestId, controller)) { lookupAbortRef.current = null; setLoading(false); } } return; } if (result.type === 'address') { // Fly from the result's own coordinates immediately; the postcode fetch // below only resolves the geometry for the side panel. See the note in // the place branch above. if (!isMobile) onFlyTo(result.lat, result.lon, 17); try { const res = await fetch( `/api/postcode/${encodeURIComponent(result.postcode)}`, authHeaders({ signal: controller.signal }) ); if (!isCurrentLookup(requestId, controller)) return; if (!res.ok) { setError(t('locationSearch.postcodeNotFound')); return; } const json: PostcodeLookupResponse = await res.json(); if (!isCurrentLookup(requestId, controller)) return; onLocationSearched?.({ postcode: json.postcode, geometry: json.geometry, latitude: result.lat, longitude: result.lon, zoom: 17, markerLatitude: result.lat, markerLongitude: result.lon, openProperties: true, focusAddress: result.address, }); search.saveRecentSearch(result); search.clear(); if (isMobile) setExpanded(false); } catch (error) { if (!isCurrentLookup(requestId, controller) || isAbortError(error)) return; setError(t('locationSearch.lookupFailed')); } finally { if (isCurrentLookup(requestId, controller)) { lookupAbortRef.current = null; setLoading(false); } } return; } // Postcode — fetch geometry. Unlike place/address results, a postcode // result carries no coordinates, so the camera move genuinely depends on // this response and stays gated by isCurrentLookup. try { const res = await fetch( `/api/postcode/${encodeURIComponent(result.label)}`, authHeaders({ signal: controller.signal }) ); if (!isCurrentLookup(requestId, controller)) return; if (!res.ok) { setError(t('locationSearch.postcodeNotFound')); return; } const json: PostcodeLookupResponse = await res.json(); if (!isCurrentLookup(requestId, controller)) return; if (!isMobile) onFlyTo(json.latitude, json.longitude, POSTCODE_SEARCH_ZOOM); onLocationSearched?.({ postcode: json.postcode, geometry: json.geometry, latitude: json.latitude, longitude: json.longitude, zoom: POSTCODE_SEARCH_ZOOM, }); search.saveRecentSearch(result); search.clear(); if (isMobile) setExpanded(false); } catch (error) { if (!isCurrentLookup(requestId, controller) || isAbortError(error)) return; setError(t('locationSearch.lookupFailed')); } finally { if (isCurrentLookup(requestId, controller)) { lookupAbortRef.current = null; setLoading(false); } } }, [beginLookup, isCurrentLookup, onFlyTo, onLocationSearched, isMobile, search, t] ); const [locating, setLocating] = useState(false); const locateUser = useCallback(async () => { if (!__DEV__ && !navigator.geolocation) { setError(t('locationSearch.geolocationUnsupported')); return; } setError(null); cancelLookup(); setLocating(true); search.close(); try { const { latitude, longitude } = __DEV__ ? DEV_LOCATION : await new Promise((resolve, reject) => { if (!navigator.geolocation) { reject(new Error('Geolocation unsupported')); return; } navigator.geolocation.getCurrentPosition( (position) => resolve(position.coords), reject, { enableHighAccuracy: true, timeout: 10000, } ); }); if (onCurrentLocationFound) { onCurrentLocationFound(latitude, longitude); } else { onFlyTo(latitude, longitude, 17); } search.clear(); if (isMobile) setExpanded(false); } catch { setError(t('locationSearch.geolocationFailed')); } finally { setLocating(false); } }, [cancelLookup, onFlyTo, onCurrentLocationFound, isMobile, search, t]); // Mobile collapsed state: search icon + locate button if (isMobile && !expanded) { return (
); } return (
{ setError(null); cancelLookup(); }} />
{error && ( {error} )}
); }