This commit is contained in:
Andras Schmelczer 2026-05-26 19:45:13 +01:00
parent c645b0f1d4
commit 39ef5c6646
79 changed files with 5660 additions and 2199 deletions

View file

@ -2,6 +2,9 @@ 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;
@ -77,9 +80,84 @@ export type SearchResult =
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 abortRef = useRef<AbortController | null>(null);
@ -98,9 +176,9 @@ export function useLocationSearch(mode?: string) {
const trimmed = value.trim();
if (!trimmed) {
setResults([]);
setResults(recentSearches);
lastResultsRef.current = [];
setOpen(false);
setOpen(recentSearches.length > 0);
return;
}
@ -181,9 +259,20 @@ export function useLocationSearch(mode?: string) {
}
}, 200);
},
[mode]
[mode, recentSearches]
);
const showEmptySearches = useCallback(() => {
if (latestQueryRef.current.trim()) {
setOpen(results.length > 0);
return;
}
setResults(recentSearches);
setActiveIndex(-1);
setOpen(recentSearches.length > 0);
}, [recentSearches, results.length]);
const close = useCallback(() => setOpen(false), []);
const clear = useCallback(() => {
@ -195,6 +284,18 @@ export function useLocationSearch(mode?: string) {
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') {
@ -234,6 +335,8 @@ export function useLocationSearch(mode?: string) {
setOpen,
handleInputChange,
handleKeyDown,
showEmptySearches,
saveRecentSearch,
close,
clear,
};