149 lines
4.1 KiB
TypeScript
149 lines
4.1 KiB
TypeScript
import { useState, useCallback, useRef, useEffect } from 'react';
|
|
import type { PlaceResult } from '../types';
|
|
import { authHeaders, logNonAbortError } from '../lib/api';
|
|
|
|
const POSTCODE_RE = /^[A-Z]{1,2}\d[A-Z\d]?\s*\d?[A-Z]{0,2}$/i;
|
|
|
|
function looksLikePostcode(s: string) {
|
|
return POSTCODE_RE.test(s.trim());
|
|
}
|
|
|
|
/** Normalize a UK postcode: uppercase, strip spaces, insert canonical space before inward code. */
|
|
function normalizePostcode(s: string): string {
|
|
const stripped = s.replace(/\s+/g, '').toUpperCase();
|
|
if (stripped.length >= 5) {
|
|
return stripped.slice(0, -3) + ' ' + stripped.slice(-3);
|
|
}
|
|
return stripped;
|
|
}
|
|
|
|
export type SearchResult =
|
|
| { type: 'postcode'; label: string }
|
|
| {
|
|
type: 'place';
|
|
name: string;
|
|
slug: string;
|
|
place_type: string;
|
|
lat: number;
|
|
lon: number;
|
|
city?: string;
|
|
};
|
|
|
|
export function useLocationSearch(mode?: string) {
|
|
const [query, setQuery] = useState('');
|
|
const [results, setResults] = useState<SearchResult[]>([]);
|
|
const [activeIndex, setActiveIndex] = useState(-1);
|
|
const [open, setOpen] = useState(false);
|
|
const abortRef = useRef<AbortController | null>(null);
|
|
const debounceRef = useRef<ReturnType<typeof setTimeout>>();
|
|
|
|
const handleInputChange = useCallback(
|
|
(value: string) => {
|
|
setQuery(value);
|
|
setActiveIndex(-1);
|
|
|
|
abortRef.current?.abort();
|
|
if (debounceRef.current) clearTimeout(debounceRef.current);
|
|
|
|
const trimmed = value.trim();
|
|
if (!trimmed) {
|
|
setResults([]);
|
|
setOpen(false);
|
|
return;
|
|
}
|
|
|
|
if (!mode && looksLikePostcode(trimmed)) {
|
|
setResults([{ type: 'postcode', label: normalizePostcode(trimmed) }]);
|
|
setOpen(true);
|
|
return;
|
|
}
|
|
|
|
if (trimmed.length < 2) {
|
|
setResults([]);
|
|
setOpen(false);
|
|
return;
|
|
}
|
|
|
|
debounceRef.current = setTimeout(async () => {
|
|
const controller = new AbortController();
|
|
abortRef.current = controller;
|
|
try {
|
|
const params = new URLSearchParams({ q: trimmed, limit: '7' });
|
|
if (mode) params.set('mode', mode);
|
|
const res = await fetch(
|
|
`/api/places?${params}`,
|
|
authHeaders({ signal: controller.signal })
|
|
);
|
|
if (!res.ok) return;
|
|
const json: { places: PlaceResult[] } = await res.json();
|
|
const placeResults: SearchResult[] = json.places.map((p) => ({
|
|
type: 'place' as const,
|
|
name: p.name,
|
|
slug: p.slug,
|
|
place_type: p.place_type,
|
|
lat: p.lat,
|
|
lon: p.lon,
|
|
city: p.city,
|
|
}));
|
|
setResults(placeResults);
|
|
setOpen(placeResults.length > 0);
|
|
} catch (err) {
|
|
logNonAbortError('places search', err);
|
|
}
|
|
}, 200);
|
|
},
|
|
[mode]
|
|
);
|
|
|
|
const close = useCallback(() => setOpen(false), []);
|
|
|
|
const clear = useCallback(() => {
|
|
setQuery('');
|
|
setResults([]);
|
|
setOpen(false);
|
|
setActiveIndex(-1);
|
|
}, []);
|
|
|
|
const handleKeyDown = useCallback(
|
|
(e: React.KeyboardEvent, onSelect: (result: SearchResult) => void) => {
|
|
if (e.key === 'ArrowDown') {
|
|
e.preventDefault();
|
|
setActiveIndex((prev) => (prev < results.length - 1 ? prev + 1 : prev));
|
|
} else if (e.key === 'ArrowUp') {
|
|
e.preventDefault();
|
|
setActiveIndex((prev) => (prev > 0 ? prev - 1 : -1));
|
|
} else if (e.key === 'Enter') {
|
|
e.preventDefault();
|
|
if (activeIndex >= 0 && activeIndex < results.length) {
|
|
onSelect(results[activeIndex]);
|
|
} else if (looksLikePostcode(query)) {
|
|
onSelect({ type: 'postcode', label: normalizePostcode(query) });
|
|
}
|
|
} else if (e.key === 'Escape') {
|
|
setOpen(false);
|
|
}
|
|
},
|
|
[results, activeIndex, query]
|
|
);
|
|
|
|
// Cleanup on unmount
|
|
useEffect(() => {
|
|
return () => {
|
|
abortRef.current?.abort();
|
|
if (debounceRef.current) clearTimeout(debounceRef.current);
|
|
};
|
|
}, []);
|
|
|
|
return {
|
|
query,
|
|
results,
|
|
activeIndex,
|
|
setActiveIndex,
|
|
open,
|
|
setOpen,
|
|
handleInputChange,
|
|
handleKeyDown,
|
|
close,
|
|
clear,
|
|
};
|
|
}
|