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([]); const [activeIndex, setActiveIndex] = useState(-1); const [open, setOpen] = useState(false); const abortRef = useRef(null); const debounceRef = useRef>(); 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, }; }