perfect-postcode/frontend/src/components/map/LocationSearch.tsx
2026-03-15 17:38:26 +00:00

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>
);
}