alright
This commit is contained in:
parent
c645b0f1d4
commit
39ef5c6646
79 changed files with 5660 additions and 2199 deletions
|
|
@ -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,
|
||||
};
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue