147 lines
4.5 KiB
TypeScript
147 lines
4.5 KiB
TypeScript
import { useState, useCallback, useRef, useEffect } from 'react';
|
|
import type { PostcodeGeometry } from '../../types';
|
|
import { authHeaders } from '../../lib/api';
|
|
import { useIsMobile } from '../../hooks/useIsMobile';
|
|
import { useLocationSearch, type SearchResult } from '../../hooks/useLocationSearch';
|
|
import { PlaceSearchInput } from '../ui/PlaceSearchInput';
|
|
import { SearchIcon } from '../ui/icons/SearchIcon';
|
|
|
|
export interface SearchedLocation {
|
|
postcode: string;
|
|
geometry: PostcodeGeometry;
|
|
}
|
|
|
|
const ZOOM_FOR_TYPE: Record<string, number> = {
|
|
city: 10,
|
|
borough: 12,
|
|
town: 13,
|
|
suburb: 14,
|
|
quarter: 14,
|
|
neighbourhood: 14,
|
|
village: 14,
|
|
station: 15,
|
|
island: 12,
|
|
locality: 14,
|
|
hamlet: 15,
|
|
isolated_dwelling: 16,
|
|
};
|
|
|
|
export default function LocationSearch({
|
|
onFlyTo,
|
|
onLocationSearched,
|
|
onMouseEnter,
|
|
}: {
|
|
onFlyTo: (lat: number, lng: number, zoom: number) => void;
|
|
onLocationSearched?: (postcode: SearchedLocation | null) => void;
|
|
onMouseEnter?: () => void;
|
|
}) {
|
|
const search = useLocationSearch();
|
|
const [error, setError] = useState<string | null>(null);
|
|
const [loading, setLoading] = useState(false);
|
|
const [expanded, setExpanded] = useState(false);
|
|
const isMobile = useIsMobile();
|
|
const containerRef = useRef<HTMLDivElement>(null);
|
|
const inputRef = useRef<HTMLInputElement>(null);
|
|
|
|
// Close on outside click
|
|
useEffect(() => {
|
|
const handler = (e: MouseEvent) => {
|
|
if (containerRef.current && !containerRef.current.contains(e.target as Node)) {
|
|
search.close();
|
|
if (isMobile) setExpanded(false);
|
|
}
|
|
};
|
|
document.addEventListener('mousedown', handler);
|
|
return () => document.removeEventListener('mousedown', handler);
|
|
}, [isMobile, search.close]);
|
|
|
|
// Focus input when expanding on mobile
|
|
useEffect(() => {
|
|
if (isMobile && expanded) {
|
|
inputRef.current?.focus();
|
|
}
|
|
}, [isMobile, expanded]);
|
|
|
|
const selectResult = useCallback(
|
|
async (result: SearchResult) => {
|
|
if (result.type === 'place') {
|
|
const zoom = ZOOM_FOR_TYPE[result.place_type] ?? 14;
|
|
onFlyTo(result.lat, result.lon, zoom);
|
|
onLocationSearched?.(null);
|
|
search.clear();
|
|
if (isMobile) setExpanded(false);
|
|
return;
|
|
}
|
|
|
|
// Postcode — fetch geometry
|
|
setError(null);
|
|
setLoading(true);
|
|
search.close();
|
|
try {
|
|
const res = await fetch(`/api/postcode/${encodeURIComponent(result.label)}`, authHeaders());
|
|
if (!res.ok) {
|
|
setError('Postcode not found');
|
|
return;
|
|
}
|
|
const json: {
|
|
postcode: string;
|
|
latitude: number;
|
|
longitude: number;
|
|
geometry: PostcodeGeometry;
|
|
} = await res.json();
|
|
onFlyTo(json.latitude, json.longitude, 16);
|
|
onLocationSearched?.({ postcode: json.postcode, geometry: json.geometry });
|
|
search.clear();
|
|
if (isMobile) setExpanded(false);
|
|
} catch {
|
|
setError('Lookup failed');
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
},
|
|
[onFlyTo, onLocationSearched, isMobile, search]
|
|
);
|
|
|
|
// Mobile collapsed state: just a search icon button
|
|
if (isMobile && !expanded) {
|
|
return (
|
|
<button
|
|
type="button"
|
|
onClick={() => setExpanded(true)}
|
|
className="absolute top-3 left-3 z-10 p-2 bg-white dark:bg-warm-800 rounded shadow-lg"
|
|
aria-label="Search places or postcodes"
|
|
>
|
|
<SearchIcon className="w-5 h-5 text-warm-600 dark:text-warm-300" />
|
|
</button>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div
|
|
ref={containerRef}
|
|
data-tutorial="search"
|
|
className="absolute top-3 left-3 z-10 flex flex-col"
|
|
onMouseEnter={onMouseEnter}
|
|
>
|
|
<div className="flex items-center shadow-lg rounded bg-white dark:bg-warm-800">
|
|
<SearchIcon className="w-4 h-4 text-warm-400 dark:text-warm-500 ml-3 shrink-0" />
|
|
<PlaceSearchInput
|
|
search={search}
|
|
onSelect={selectResult}
|
|
loading={loading}
|
|
placeholder="Search places or postcodes..."
|
|
size="sm"
|
|
inputClassName="px-2 py-2 text-sm w-56 border-none outline-none bg-transparent text-warm-700 dark:text-warm-200 placeholder-warm-400 dark:placeholder-warm-500"
|
|
inputRef={inputRef}
|
|
onInputChange={() => setError(null)}
|
|
/>
|
|
</div>
|
|
|
|
{error && (
|
|
<span className="text-xs text-red-600 dark:text-red-300 bg-white/90 dark:bg-warm-800/90 rounded px-2 py-0.5 shadow mt-1">
|
|
{error}
|
|
</span>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|