perfect-postcode/frontend/src/hooks/useLocationSearch.ts
2026-03-15 17:38:26 +00:00

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,
};
}