import { useState, useCallback, useRef, useEffect } from 'react'; import type { AddressResult, PlaceResult } from '../types'; import { authHeaders, logNonAbortError } from '../lib/api'; const RECENT_SEARCHES_STORAGE_KEY = 'perfect-postcode.locationSearch.recent'; const RECENT_SEARCH_LIMIT = 3; /** Matches a full UK postcode with complete inward code (e.g. "E14 2DG", "SW1A1AA"). * Outcodes like "E14" or "SW1A" intentionally do NOT match — they go through /api/places instead. */ const FULL_POSTCODE_RE = /^[A-Z]{1,2}\d[A-Z\d]?\s*\d[A-Z]{2}$/i; function looksLikePostcode(s: string) { return FULL_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; } function searchableTextForResult(result: SearchResult): string { if (result.type === 'postcode') { return result.label; } if (result.type === 'address') { return `${result.address} ${result.postcode}`; } return `${result.name} ${result.city ?? ''}`; } function searchTokens(value: string): string[] { return value.toLowerCase().match(/[a-z0-9]+/g) ?? []; } function compactSearchText(value: string): string { return searchTokens(value).join(''); } function resultMatchesQuery(result: SearchResult, query: string): boolean { const queryTokens = searchTokens(query); if (queryTokens.length === 0) { return false; } const searchable = searchableTextForResult(result); const resultText = searchTokens(searchable).join(' '); const resultCompact = compactSearchText(searchable); const queryCompact = queryTokens.join(''); return ( queryTokens.every((token) => resultText.includes(token)) || (queryCompact.length >= 2 && resultCompact.includes(queryCompact)) ); } function filterResultsForQuery(results: SearchResult[], query: string): SearchResult[] { return results.filter((result) => resultMatchesQuery(result, query)).slice(0, 20); } export type SearchResult = | { type: 'postcode'; label: string } | { type: 'address'; address: string; postcode: string; lat: number; lon: number; } | { type: 'place'; name: string; slug: string; place_type: string; lat: number; lon: number; city?: string; }; function isFiniteNumber(value: unknown): value is number { return typeof value === 'number' && Number.isFinite(value); } function isSearchResult(value: unknown): value is SearchResult { if (!value || typeof value !== 'object') return false; const result = value as Record; if (result.type === 'postcode') { return typeof result.label === 'string'; } if (result.type === 'address') { return ( typeof result.address === 'string' && typeof result.postcode === 'string' && isFiniteNumber(result.lat) && isFiniteNumber(result.lon) ); } if (result.type === 'place') { return ( typeof result.name === 'string' && typeof result.slug === 'string' && typeof result.place_type === 'string' && isFiniteNumber(result.lat) && isFiniteNumber(result.lon) && (result.city === undefined || typeof result.city === 'string') ); } return false; } function readRecentSearches(): SearchResult[] { if (typeof window === 'undefined') return []; try { const raw = window.localStorage.getItem(RECENT_SEARCHES_STORAGE_KEY); if (!raw) return []; const parsed: unknown = JSON.parse(raw); if (!Array.isArray(parsed)) return []; return parsed.filter(isSearchResult).slice(0, RECENT_SEARCH_LIMIT); } catch { return []; } } function writeRecentSearches(searches: SearchResult[]) { if (typeof window === 'undefined') return; try { window.localStorage.setItem( RECENT_SEARCHES_STORAGE_KEY, JSON.stringify(searches.slice(0, RECENT_SEARCH_LIMIT)) ); } catch { // Recent searches are a convenience only; storage failures should not affect search. } } function searchResultKey(result: SearchResult): string { if (result.type === 'postcode') { return `postcode:${normalizePostcode(result.label)}`; } if (result.type === 'address') { return `address:${result.postcode.toUpperCase()}:${result.address.toLowerCase()}`; } return `place:${result.slug}`; } export function useLocationSearch(mode?: string) { const [query, setQuery] = useState(''); const [results, setResults] = useState([]); const [recentSearches, setRecentSearches] = useState(readRecentSearches); const [activeIndex, setActiveIndex] = useState(-1); const [open, setOpen] = useState(false); const [searching, setSearching] = useState(false); const abortRef = useRef(null); const debounceRef = useRef | null>(null); const latestQueryRef = useRef(''); const lastResultsRef = useRef([]); const handleInputChange = useCallback( (value: string) => { setQuery(value); latestQueryRef.current = value; setActiveIndex(-1); abortRef.current?.abort(); if (debounceRef.current) clearTimeout(debounceRef.current); const trimmed = value.trim(); if (!trimmed) { setSearching(false); setResults(recentSearches); lastResultsRef.current = []; setOpen(recentSearches.length > 0); return; } if (!mode && looksLikePostcode(trimmed)) { setSearching(false); const postcodeResults: SearchResult[] = [ { type: 'postcode', label: normalizePostcode(trimmed) }, ]; setResults(postcodeResults); setOpen(true); return; } if (trimmed.length < 2) { setSearching(false); setResults([]); setOpen(false); return; } const locallyFilteredResults = filterResultsForQuery(lastResultsRef.current, trimmed); setResults(locallyFilteredResults); setOpen(locallyFilteredResults.length > 0); setSearching(true); debounceRef.current = setTimeout(async () => { const controller = new AbortController(); abortRef.current = controller; try { const params = new URLSearchParams({ q: trimmed }); if (mode) params.set('mode', mode); const res = await fetch( `/api/places?${params}`, authHeaders({ signal: controller.signal }) ); if (!res.ok) { if (!controller.signal.aborted && latestQueryRef.current.trim() === trimmed) { setResults([]); setOpen(true); } return; } const json: { places: PlaceResult[]; postcodes?: string[]; addresses?: AddressResult[]; } = await res.json(); const placeResults = 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 === 'City of London' ? 'London' : p.city, })); const outcodeResults = placeResults.filter((result) => result.place_type === 'outcode'); const otherPlaceResults = placeResults.filter( (result) => result.place_type !== 'outcode' ); const postcodeResults: SearchResult[] = (json.postcodes ?? []).map((postcode) => ({ type: 'postcode' as const, label: postcode, })); const addressResults: SearchResult[] = (json.addresses ?? []).map((address) => ({ type: 'address' as const, address: address.address, postcode: address.postcode, lat: address.lat, lon: address.lon, })); const containsHouseNumber = /\d/.test(trimmed); const combinedResults = ( containsHouseNumber ? [...outcodeResults, ...postcodeResults, ...addressResults, ...otherPlaceResults] : [...outcodeResults, ...postcodeResults, ...otherPlaceResults, ...addressResults] ).slice(0, 20); if (controller.signal.aborted || latestQueryRef.current.trim() !== trimmed) { return; } lastResultsRef.current = combinedResults; const matchingResults = filterResultsForQuery(combinedResults, trimmed); setResults(matchingResults); setOpen(true); } catch (err) { logNonAbortError('places search', err); if (!controller.signal.aborted && latestQueryRef.current.trim() === trimmed) { setResults([]); setOpen(true); } } finally { if (!controller.signal.aborted && latestQueryRef.current.trim() === trimmed) { setSearching(false); } } }, 200); }, [mode, recentSearches] ); const showEmptySearches = useCallback(() => { if (latestQueryRef.current.trim()) { setOpen(results.length > 0 || latestQueryRef.current.trim().length >= 2); return; } setResults(recentSearches); setActiveIndex(-1); setOpen(recentSearches.length > 0); }, [recentSearches, results.length]); const close = useCallback(() => setOpen(false), []); const clear = useCallback(() => { setQuery(''); latestQueryRef.current = ''; setSearching(false); setResults([]); lastResultsRef.current = []; setOpen(false); setActiveIndex(-1); }, []); const saveRecentSearch = useCallback((result: SearchResult) => { setRecentSearches((prev) => { const key = searchResultKey(result); const next = [result, ...prev.filter((recent) => searchResultKey(recent) !== key)].slice( 0, RECENT_SEARCH_LIMIT ); writeRecentSearches(next); return next; }); }, []); 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 (results.length > 0) { onSelect(results[0]); } 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, searching, setOpen, handleInputChange, handleKeyDown, showEmptySearches, saveRecentSearch, close, clear, }; }