366 lines
11 KiB
TypeScript
366 lines
11 KiB
TypeScript
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<string, unknown>;
|
|
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<SearchResult[]>([]);
|
|
const [recentSearches, setRecentSearches] = useState<SearchResult[]>(readRecentSearches);
|
|
const [activeIndex, setActiveIndex] = useState(-1);
|
|
const [open, setOpen] = useState(false);
|
|
const [searching, setSearching] = useState(false);
|
|
const abortRef = useRef<AbortController | null>(null);
|
|
const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
const latestQueryRef = useRef('');
|
|
const lastResultsRef = useRef<SearchResult[]>([]);
|
|
|
|
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,
|
|
};
|
|
}
|