This commit is contained in:
Andras Schmelczer 2026-05-26 19:45:13 +01:00
parent c645b0f1d4
commit 39ef5c6646
79 changed files with 5660 additions and 2199 deletions

View file

@ -1,7 +1,7 @@
import { useState, useCallback, useRef, useEffect } from 'react';
import { useTranslation } from 'react-i18next';
import type { MapFlyToOptions, PostcodeGeometry } from '../../types';
import { authHeaders } from '../../lib/api';
import { authHeaders, isAbortError } from '../../lib/api';
import { POSTCODE_SEARCH_ZOOM } from '../../lib/consts';
import { useIsMobile } from '../../hooks/useIsMobile';
import { useLocationSearch, type SearchResult } from '../../hooks/useLocationSearch';
@ -16,6 +16,7 @@ export interface SearchedLocation {
geometry: PostcodeGeometry;
latitude: number;
longitude: number;
zoom: number;
markerLatitude?: number;
markerLongitude?: number;
openProperties?: boolean;
@ -73,6 +74,34 @@ export default function LocationSearch({
const isMobile = useIsMobile();
const containerRef = useRef<HTMLDivElement>(null);
const inputRef = useRef<HTMLInputElement>(null);
const lookupAbortRef = useRef<AbortController | null>(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(() => {
@ -93,106 +122,136 @@ export default function LocationSearch({
}
}, [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;
setError(null);
setLoading(true);
search.close();
onFlyTo(result.lat, result.lon, zoom);
// On mobile the drawer opens after onLocationSearched; MapPage handles
// the fly-to there with the correct viewport inset so the target isn't
// hidden behind the drawer. On desktop fly immediately for snappy feedback.
if (!isMobile) onFlyTo(result.lat, result.lon, zoom);
try {
const params = new URLSearchParams({
lat: String(result.lat),
lng: String(result.lon),
log: 'false',
});
const res = await fetch(`/api/nearest-postcode?${params}`, authHeaders());
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,
markerLatitude: result.lat,
markerLongitude: result.lon,
});
search.saveRecentSearch(result);
search.clear();
if (isMobile) setExpanded(false);
} catch {
} catch (error) {
if (!isCurrentLookup(requestId, controller) || isAbortError(error)) return;
setError(t('locationSearch.lookupFailed'));
} finally {
setLoading(false);
if (isCurrentLookup(requestId, controller)) {
lookupAbortRef.current = null;
setLoading(false);
}
}
return;
}
if (result.type === 'address') {
setError(null);
setLoading(true);
search.close();
try {
const res = await fetch(
`/api/postcode/${encodeURIComponent(result.postcode)}`,
authHeaders()
authHeaders({ signal: controller.signal })
);
if (!isCurrentLookup(requestId, controller)) return;
if (!res.ok) {
setError(t('locationSearch.postcodeNotFound'));
return;
}
const json: PostcodeLookupResponse = await res.json();
onFlyTo(result.lat, result.lon, 17);
if (!isCurrentLookup(requestId, controller)) return;
if (!isMobile) onFlyTo(result.lat, result.lon, 17);
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 {
} catch (error) {
if (!isCurrentLookup(requestId, controller) || isAbortError(error)) return;
setError(t('locationSearch.lookupFailed'));
} finally {
setLoading(false);
if (isCurrentLookup(requestId, controller)) {
lookupAbortRef.current = null;
setLoading(false);
}
}
return;
}
// Postcode — fetch geometry
setError(null);
setLoading(true);
search.close();
try {
const res = await fetch(`/api/postcode/${encodeURIComponent(result.label)}`, authHeaders());
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();
onFlyTo(json.latitude, json.longitude, POSTCODE_SEARCH_ZOOM);
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 {
} catch (error) {
if (!isCurrentLookup(requestId, controller) || isAbortError(error)) return;
setError(t('locationSearch.lookupFailed'));
} finally {
setLoading(false);
if (isCurrentLookup(requestId, controller)) {
lookupAbortRef.current = null;
setLoading(false);
}
}
},
[onFlyTo, onLocationSearched, isMobile, search, t]
[beginLookup, isCurrentLookup, onFlyTo, onLocationSearched, isMobile, search, t]
);
const [locating, setLocating] = useState(false);
@ -203,6 +262,7 @@ export default function LocationSearch({
return;
}
setError(null);
cancelLookup();
setLocating(true);
search.close();
try {
@ -234,7 +294,7 @@ export default function LocationSearch({
} finally {
setLocating(false);
}
}, [onFlyTo, onCurrentLocationFound, isMobile, search, t]);
}, [cancelLookup, onFlyTo, onCurrentLocationFound, isMobile, search, t]);
// Mobile collapsed state: search icon + locate button
if (isMobile && !expanded) {
@ -281,7 +341,10 @@ export default function LocationSearch({
'px-2 py-2 text-sm w-56 border-none outline-none bg-transparent text-warm-700 dark:text-warm-200 placeholder-warm-400 dark:placeholder-warm-500'
}
inputRef={inputRef}
onInputChange={() => setError(null)}
onInputChange={() => {
setError(null);
cancelLookup();
}}
/>
<button
type="button"