Replace Google Places autocomplete with OSM/gsc-geocoder
useAddressAutocomplete: drop the Google Maps JS SDK + loadGoogleMapsScript in favor of an HTTP-fetch loop against a configurable endpoint (default /api/geocoder), which we point at gsc-geocoder (183M-row OSM-derived PostGIS database). Promote AddressAutocomplete from gscRegister fork into the shared validation/components/. Pre-existing Google Maps TS namespace issues vanish with the dep removal. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -53,7 +53,6 @@
|
|||||||
"react-dom": "^18.2.0 || ^19.0.0"
|
"react-dom": "^18.2.0 || ^19.0.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/google.maps": "^3.55.0",
|
|
||||||
"@types/nodemailer": "^6.4.0",
|
"@types/nodemailer": "^6.4.0",
|
||||||
"@types/react": "^19.0.0",
|
"@types/react": "^19.0.0",
|
||||||
"@types/react-dom": "^19.0.0",
|
"@types/react-dom": "^19.0.0",
|
||||||
|
|||||||
208
src/validation/components/AddressAutocomplete.tsx
Normal file
208
src/validation/components/AddressAutocomplete.tsx
Normal file
@@ -0,0 +1,208 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useRef, useState, useCallback, useEffect } from 'react';
|
||||||
|
import { FormGroup } from '../../components/Form';
|
||||||
|
import { FormControl } from '../../components/Form';
|
||||||
|
import {
|
||||||
|
useAddressAutocomplete,
|
||||||
|
type GeocoderResult,
|
||||||
|
} from '../hooks/useAddressAutocomplete';
|
||||||
|
import type { EuropeanAddress } from '../types';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Drop-in address-field with autocomplete backed by `gsc-geocoder`
|
||||||
|
* (OSM data on our own server — no Google Places, no API key in the
|
||||||
|
* browser). Consumer wires a same-origin proxy at `endpoint` (default
|
||||||
|
* `/api/geocoder`) that forwards to `https://geocoder.gosec.internal`
|
||||||
|
* with the `X-API-Key` header.
|
||||||
|
*
|
||||||
|
* The full structured-address payload is delivered through `onSelect`
|
||||||
|
* — the geocoder's `/search` already includes street, house number,
|
||||||
|
* postcode, city, state, country, plus `lat`/`lon` if you need them
|
||||||
|
* (read from the second `onSelect` arg).
|
||||||
|
*/
|
||||||
|
export interface AddressAutocompleteProps {
|
||||||
|
label: string;
|
||||||
|
placeholder?: string;
|
||||||
|
id?: string;
|
||||||
|
name?: string;
|
||||||
|
/**
|
||||||
|
* Called when the user picks a suggestion. Receives the normalized
|
||||||
|
* `EuropeanAddress` and the raw geocoder result (for lat/lon, osmId,
|
||||||
|
* etc.).
|
||||||
|
*/
|
||||||
|
onSelect: (address: EuropeanAddress, raw: GeocoderResult) => void;
|
||||||
|
/** Initial / controlled query value. */
|
||||||
|
value?: string;
|
||||||
|
/** Proxy endpoint. Default `/api/geocoder`. */
|
||||||
|
endpoint?: string;
|
||||||
|
/** ISO-3166 alpha-2 country filter (first entry is used by the API). */
|
||||||
|
countries?: string[];
|
||||||
|
/** Bootstrap invalid styling. */
|
||||||
|
invalid?: boolean;
|
||||||
|
invalidFeedback?: string;
|
||||||
|
/** Max suggestions to display. Default 8. */
|
||||||
|
limit?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AddressAutocomplete({
|
||||||
|
label,
|
||||||
|
placeholder,
|
||||||
|
id = 'address',
|
||||||
|
name = 'addressQuery',
|
||||||
|
onSelect,
|
||||||
|
value: externalValue,
|
||||||
|
endpoint,
|
||||||
|
countries,
|
||||||
|
invalid,
|
||||||
|
invalidFeedback,
|
||||||
|
limit = 8,
|
||||||
|
}: AddressAutocompleteProps) {
|
||||||
|
const {
|
||||||
|
query,
|
||||||
|
setQuery,
|
||||||
|
results,
|
||||||
|
isLoading,
|
||||||
|
} = useAddressAutocomplete({
|
||||||
|
endpoint,
|
||||||
|
countries,
|
||||||
|
limit,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Sync external value (controlled mode) on mount + when it changes.
|
||||||
|
// The hook owns the query state internally; we drive it through
|
||||||
|
// setQuery when the caller updates `value` so the input stays in
|
||||||
|
// sync without re-firing the debounced search.
|
||||||
|
const externalSeen = useRef<string | undefined>(undefined);
|
||||||
|
useEffect(() => {
|
||||||
|
if (externalValue !== undefined && externalValue !== externalSeen.current) {
|
||||||
|
externalSeen.current = externalValue;
|
||||||
|
setQuery(externalValue);
|
||||||
|
}
|
||||||
|
}, [externalValue, setQuery]);
|
||||||
|
|
||||||
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
|
const [activeIndex, setActiveIndex] = useState(-1);
|
||||||
|
const containerRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setIsOpen(results.length > 0);
|
||||||
|
setActiveIndex(-1);
|
||||||
|
}, [results]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
function handleClickOutside(e: MouseEvent) {
|
||||||
|
if (
|
||||||
|
containerRef.current &&
|
||||||
|
!containerRef.current.contains(e.target as Node)
|
||||||
|
) {
|
||||||
|
setIsOpen(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
document.addEventListener('mousedown', handleClickOutside);
|
||||||
|
return () => document.removeEventListener('mousedown', handleClickOutside);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleSelect = useCallback(
|
||||||
|
(r: GeocoderResult) => {
|
||||||
|
setQuery(r.displayName);
|
||||||
|
setIsOpen(false);
|
||||||
|
onSelect(
|
||||||
|
{
|
||||||
|
street: r.street || r.place || '',
|
||||||
|
houseNumber: r.housenumber || undefined,
|
||||||
|
postalCode: r.postcode || '',
|
||||||
|
city: r.city || '',
|
||||||
|
state: r.state || undefined,
|
||||||
|
country: (r.country || '').toLowerCase(),
|
||||||
|
},
|
||||||
|
r,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
[onSelect, setQuery],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||||
|
if (!isOpen || results.length === 0) return;
|
||||||
|
if (e.key === 'ArrowDown') {
|
||||||
|
e.preventDefault();
|
||||||
|
setActiveIndex((i) => Math.min(i + 1, results.length - 1));
|
||||||
|
} else if (e.key === 'ArrowUp') {
|
||||||
|
e.preventDefault();
|
||||||
|
setActiveIndex((i) => Math.max(i - 1, 0));
|
||||||
|
} else if (e.key === 'Enter' && activeIndex >= 0) {
|
||||||
|
e.preventDefault();
|
||||||
|
handleSelect(results[activeIndex]);
|
||||||
|
} else if (e.key === 'Escape') {
|
||||||
|
setIsOpen(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div ref={containerRef} style={{ position: 'relative' }}>
|
||||||
|
<FormGroup label={label} id={id} invalid={invalid} invalidFeedback={invalidFeedback}>
|
||||||
|
<FormControl
|
||||||
|
type="text"
|
||||||
|
name={name}
|
||||||
|
placeholder={placeholder}
|
||||||
|
value={query}
|
||||||
|
onChange={(e) => setQuery(e.target.value)}
|
||||||
|
onKeyDown={handleKeyDown}
|
||||||
|
onFocus={() => results.length > 0 && setIsOpen(true)}
|
||||||
|
autoComplete="off"
|
||||||
|
/>
|
||||||
|
</FormGroup>
|
||||||
|
|
||||||
|
{isOpen && (
|
||||||
|
<ul
|
||||||
|
className="list-group shadow"
|
||||||
|
style={{
|
||||||
|
position: 'absolute',
|
||||||
|
top: '100%',
|
||||||
|
left: 0,
|
||||||
|
right: 0,
|
||||||
|
zIndex: 1050,
|
||||||
|
maxHeight: '240px',
|
||||||
|
overflowY: 'auto',
|
||||||
|
marginTop: '-0.5rem',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{results.map((r, i) => (
|
||||||
|
<li
|
||||||
|
key={`${r.osmType}-${r.osmId}`}
|
||||||
|
className={`list-group-item list-group-item-action py-2 px-3${
|
||||||
|
i === activeIndex ? ' active' : ''
|
||||||
|
}`}
|
||||||
|
style={{ cursor: 'pointer', fontSize: '0.875rem' }}
|
||||||
|
onMouseDown={() => handleSelect(r)}
|
||||||
|
onMouseEnter={() => setActiveIndex(i)}
|
||||||
|
>
|
||||||
|
<div className="fw-medium">{r.displayName}</div>
|
||||||
|
{(r.postcode || r.country) && (
|
||||||
|
<small className={i === activeIndex ? 'text-white-50' : 'text-muted'}>
|
||||||
|
{[r.postcode, r.city, r.country].filter(Boolean).join(', ')}
|
||||||
|
</small>
|
||||||
|
)}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{isLoading && (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
position: 'absolute',
|
||||||
|
right: '10px',
|
||||||
|
top: '38px',
|
||||||
|
width: '16px',
|
||||||
|
height: '16px',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className="spinner-border spinner-border-sm text-muted" role="status">
|
||||||
|
<span className="visually-hidden">Loading…</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,23 +1,51 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState, useCallback, useRef, useEffect } from 'react';
|
import { useState, useCallback, useRef, useEffect } from 'react';
|
||||||
import type { EuropeanAddress, AddressSuggestion, UseAddressAutocompleteOptions } from '../types';
|
import type {
|
||||||
|
EuropeanAddress,
|
||||||
|
AddressSuggestion,
|
||||||
|
UseAddressAutocompleteOptions,
|
||||||
|
} from '../types';
|
||||||
|
|
||||||
// European country codes for Google Places API
|
/**
|
||||||
const DEFAULT_EUROPEAN_COUNTRIES = [
|
* Shape of a single result from `gsc-geocoder` (`/api/v1/search`). Built
|
||||||
'at', 'be', 'bg', 'hr', 'cy', 'cz', 'dk', 'ee', 'fi', 'fr',
|
* on OSM data (Nominatim-style), backed by PostGIS + pg_trgm FTS on
|
||||||
'de', 'gr', 'hu', 'ie', 'it', 'lv', 'lt', 'lu', 'mt', 'nl',
|
* denbgvdb04 (172.17.3.26). 183M addresses covering EU.
|
||||||
'pl', 'pt', 'ro', 'sk', 'si', 'es', 'se', 'gb', 'ch', 'no',
|
*
|
||||||
'is', 'li', 'al', 'ad', 'ba', 'me', 'mk', 'rs', 'ua', 'md',
|
* Consumers typically reach this through a same-origin proxy route
|
||||||
];
|
* (e.g. `/api/geocoder`) that adds the X-API-Key server-side; we never
|
||||||
|
* call the geocoder directly from the browser.
|
||||||
|
*/
|
||||||
|
export interface GeocoderResult {
|
||||||
|
osmId: number;
|
||||||
|
osmType: string;
|
||||||
|
housenumber: string;
|
||||||
|
street: string;
|
||||||
|
place: string;
|
||||||
|
city: string;
|
||||||
|
postcode: string;
|
||||||
|
state: string;
|
||||||
|
country: string;
|
||||||
|
displayName: string;
|
||||||
|
lat: number;
|
||||||
|
lon: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface GeocoderSearchResponse {
|
||||||
|
data?: GeocoderResult[];
|
||||||
|
count?: number;
|
||||||
|
error?: string;
|
||||||
|
}
|
||||||
|
|
||||||
export interface UseAddressAutocompleteReturn {
|
export interface UseAddressAutocompleteReturn {
|
||||||
query: string;
|
query: string;
|
||||||
setQuery: (value: string) => void;
|
setQuery: (value: string) => void;
|
||||||
suggestions: AddressSuggestion[];
|
suggestions: AddressSuggestion[];
|
||||||
|
/** Same suggestions, but with the full geocoder payload (lat/lon/etc). */
|
||||||
|
results: GeocoderResult[];
|
||||||
isLoading: boolean;
|
isLoading: boolean;
|
||||||
error: string | undefined;
|
error: string | undefined;
|
||||||
selectSuggestion: (placeId: string) => Promise<EuropeanAddress | null>;
|
selectSuggestion: (id: string) => Promise<EuropeanAddress | null>;
|
||||||
clearSuggestions: () => void;
|
clearSuggestions: () => void;
|
||||||
inputProps: {
|
inputProps: {
|
||||||
value: string;
|
value: string;
|
||||||
@@ -27,194 +55,149 @@ export interface UseAddressAutocompleteReturn {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// Parse Google address components into structured format
|
function resultToAddress(r: GeocoderResult): EuropeanAddress {
|
||||||
function parseAddressComponents(
|
|
||||||
components: google.maps.GeocoderAddressComponent[]
|
|
||||||
): EuropeanAddress {
|
|
||||||
const get = (type: string) =>
|
|
||||||
components.find((c) => c.types.includes(type))?.long_name || '';
|
|
||||||
|
|
||||||
const getShort = (type: string) =>
|
|
||||||
components.find((c) => c.types.includes(type))?.short_name || '';
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
street: get('route'),
|
street: r.street || r.place || '',
|
||||||
houseNumber: get('street_number'),
|
houseNumber: r.housenumber || undefined,
|
||||||
postalCode: get('postal_code'),
|
postalCode: r.postcode || '',
|
||||||
city: get('locality') || get('postal_town') || get('sublocality'),
|
city: r.city || '',
|
||||||
state: get('administrative_area_level_1'),
|
state: r.state || undefined,
|
||||||
country: getShort('country'),
|
country: (r.country || '').toLowerCase(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function resultToSuggestion(r: GeocoderResult): AddressSuggestion {
|
||||||
|
// Mirror Google's `structured_formatting` split so existing callers
|
||||||
|
// can render a primary line + a muted secondary line without caring
|
||||||
|
// about the backend.
|
||||||
|
const primary = [r.street, r.housenumber].filter(Boolean).join(' ');
|
||||||
|
const secondary = [r.postcode, r.city, r.country].filter(Boolean).join(', ');
|
||||||
|
return {
|
||||||
|
placeId: `${r.osmType}-${r.osmId}`,
|
||||||
|
description: r.displayName,
|
||||||
|
mainText: primary || r.displayName,
|
||||||
|
secondaryText: secondary,
|
||||||
|
structured: resultToAddress(r),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Hook for address autocomplete using Google Places API
|
* Address autocomplete backed by our own OSM/Nominatim-derived geocoder
|
||||||
|
* (`gsc-geocoder`, 183M addresses, PostGIS + pg_trgm). No Google
|
||||||
|
* Places, no external API key, no PII leaving our network.
|
||||||
|
*
|
||||||
|
* Default endpoint is `/api/geocoder` — apps proxy through a Next.js
|
||||||
|
* route handler that injects `X-API-Key` from Infisical. Override with
|
||||||
|
* `endpoint` if your route lives elsewhere.
|
||||||
|
*
|
||||||
|
* Result-shape compatibility note: `selectSuggestion` returns an
|
||||||
|
* `EuropeanAddress` derived from the geocoder result with no extra
|
||||||
|
* round-trip — gsc-geocoder returns the full structured address in
|
||||||
|
* `/search`, so there's no "place details" call like Google needed.
|
||||||
|
* Calling `selectSuggestion(id)` is therefore synchronous-ish (only
|
||||||
|
* the in-memory lookup); the Promise return type is preserved for
|
||||||
|
* API compatibility with the old hook.
|
||||||
*/
|
*/
|
||||||
export function useAddressAutocomplete(
|
export function useAddressAutocomplete(
|
||||||
options: UseAddressAutocompleteOptions = {}
|
options: UseAddressAutocompleteOptions = {}
|
||||||
): UseAddressAutocompleteReturn {
|
): UseAddressAutocompleteReturn {
|
||||||
const {
|
const {
|
||||||
apiKey,
|
endpoint = '/api/geocoder',
|
||||||
countries = DEFAULT_EUROPEAN_COUNTRIES,
|
countries,
|
||||||
debounceMs = 300,
|
debounceMs = 300,
|
||||||
language = 'en',
|
limit = 8,
|
||||||
onSelect,
|
onSelect,
|
||||||
} = options;
|
} = options;
|
||||||
|
|
||||||
const [query, setQueryState] = useState('');
|
const [query, setQueryState] = useState('');
|
||||||
const [suggestions, setSuggestions] = useState<AddressSuggestion[]>([]);
|
const [results, setResults] = useState<GeocoderResult[]>([]);
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
const [error, setError] = useState<string | undefined>(undefined);
|
const [error, setError] = useState<string | undefined>(undefined);
|
||||||
|
|
||||||
const autocompleteService = useRef<google.maps.places.AutocompleteService | null>(null);
|
const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
const placesService = useRef<google.maps.places.PlacesService | null>(null);
|
const abortRef = useRef<AbortController | null>(null);
|
||||||
const sessionToken = useRef<google.maps.places.AutocompleteSessionToken | null>(null);
|
|
||||||
const debounceRef = useRef<NodeJS.Timeout | null>(null);
|
|
||||||
const placesDiv = useRef<HTMLDivElement | null>(null);
|
|
||||||
|
|
||||||
// Initialize Google Places service
|
|
||||||
useEffect(() => {
|
|
||||||
// Check if Google Maps API is loaded
|
|
||||||
if (typeof window !== 'undefined' && window.google?.maps?.places) {
|
|
||||||
autocompleteService.current = new google.maps.places.AutocompleteService();
|
|
||||||
|
|
||||||
// Create a hidden div for PlacesService (it requires a map or element)
|
|
||||||
if (!placesDiv.current) {
|
|
||||||
placesDiv.current = document.createElement('div');
|
|
||||||
placesDiv.current.style.display = 'none';
|
|
||||||
document.body.appendChild(placesDiv.current);
|
|
||||||
}
|
|
||||||
|
|
||||||
placesService.current = new google.maps.places.PlacesService(placesDiv.current);
|
|
||||||
sessionToken.current = new google.maps.places.AutocompleteSessionToken();
|
|
||||||
}
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
if (placesDiv.current && placesDiv.current.parentNode) {
|
|
||||||
placesDiv.current.parentNode.removeChild(placesDiv.current);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
// Fetch suggestions from Google Places
|
|
||||||
const fetchSuggestions = useCallback(
|
const fetchSuggestions = useCallback(
|
||||||
async (input: string) => {
|
async (input: string) => {
|
||||||
if (!input || input.length < 3) {
|
if (!input || input.length < 2) {
|
||||||
setSuggestions([]);
|
setResults([]);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!autocompleteService.current) {
|
// Cancel in-flight request when the user keeps typing.
|
||||||
// Google Maps API not loaded
|
abortRef.current?.abort();
|
||||||
setError('Google Maps API not loaded. Please include the script in your page.');
|
const controller = new AbortController();
|
||||||
return;
|
abortRef.current = controller;
|
||||||
}
|
|
||||||
|
|
||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
setError(undefined);
|
setError(undefined);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const request: google.maps.places.AutocompletionRequest = {
|
const params = new URLSearchParams({
|
||||||
input,
|
q: input,
|
||||||
componentRestrictions: { country: countries },
|
limit: String(limit),
|
||||||
types: ['address'],
|
});
|
||||||
sessionToken: sessionToken.current || undefined,
|
// gsc-geocoder accepts a single ISO-3166 country filter. If the
|
||||||
};
|
// caller passed a list, use the first entry — anything more
|
||||||
|
// selective belongs server-side.
|
||||||
autocompleteService.current.getPlacePredictions(
|
if (countries && countries.length > 0) {
|
||||||
request,
|
params.set('country', countries[0]);
|
||||||
(predictions, status) => {
|
|
||||||
setIsLoading(false);
|
|
||||||
|
|
||||||
if (status === google.maps.places.PlacesServiceStatus.OK && predictions) {
|
|
||||||
setSuggestions(
|
|
||||||
predictions.map((p) => ({
|
|
||||||
placeId: p.place_id,
|
|
||||||
description: p.description,
|
|
||||||
mainText: p.structured_formatting.main_text,
|
|
||||||
secondaryText: p.structured_formatting.secondary_text,
|
|
||||||
}))
|
|
||||||
);
|
|
||||||
} else if (status === google.maps.places.PlacesServiceStatus.ZERO_RESULTS) {
|
|
||||||
setSuggestions([]);
|
|
||||||
} else if (status !== google.maps.places.PlacesServiceStatus.OK) {
|
|
||||||
setError('Failed to fetch address suggestions');
|
|
||||||
setSuggestions([]);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const res = await fetch(`${endpoint}?${params.toString()}`, {
|
||||||
|
signal: controller.signal,
|
||||||
|
});
|
||||||
|
if (!res.ok) {
|
||||||
|
setError('Address lookup failed');
|
||||||
|
setResults([]);
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
);
|
const body = (await res.json()) as GeocoderSearchResponse;
|
||||||
|
setResults(body.data ?? []);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setIsLoading(false);
|
if ((err as { name?: string }).name === 'AbortError') return;
|
||||||
setError(err instanceof Error ? err.message : 'Address lookup failed');
|
setError(err instanceof Error ? err.message : 'Address lookup failed');
|
||||||
setSuggestions([]);
|
setResults([]);
|
||||||
|
} finally {
|
||||||
|
if (abortRef.current === controller) {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[countries]
|
[endpoint, countries, limit]
|
||||||
);
|
);
|
||||||
|
|
||||||
// Debounced query update
|
|
||||||
const setQuery = useCallback(
|
const setQuery = useCallback(
|
||||||
(value: string) => {
|
(value: string) => {
|
||||||
setQueryState(value);
|
setQueryState(value);
|
||||||
|
if (debounceRef.current) clearTimeout(debounceRef.current);
|
||||||
if (debounceRef.current) {
|
|
||||||
clearTimeout(debounceRef.current);
|
|
||||||
}
|
|
||||||
|
|
||||||
debounceRef.current = setTimeout(() => {
|
debounceRef.current = setTimeout(() => {
|
||||||
fetchSuggestions(value);
|
void fetchSuggestions(value);
|
||||||
}, debounceMs);
|
}, debounceMs);
|
||||||
},
|
},
|
||||||
[debounceMs, fetchSuggestions]
|
[debounceMs, fetchSuggestions]
|
||||||
);
|
);
|
||||||
|
|
||||||
// Select a suggestion and get full address details
|
|
||||||
const selectSuggestion = useCallback(
|
const selectSuggestion = useCallback(
|
||||||
async (placeId: string): Promise<EuropeanAddress | null> => {
|
async (id: string): Promise<EuropeanAddress | null> => {
|
||||||
if (!placesService.current) {
|
const hit = results.find((r) => `${r.osmType}-${r.osmId}` === id);
|
||||||
setError('Google Maps API not loaded');
|
if (!hit) {
|
||||||
|
setError('Selected suggestion not in current result set');
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
const address = resultToAddress(hit);
|
||||||
return new Promise((resolve) => {
|
setQueryState(hit.displayName);
|
||||||
placesService.current!.getDetails(
|
setResults([]);
|
||||||
{
|
|
||||||
placeId,
|
|
||||||
fields: ['address_components', 'formatted_address', 'geometry'],
|
|
||||||
sessionToken: sessionToken.current || undefined,
|
|
||||||
},
|
|
||||||
(place, status) => {
|
|
||||||
// Create new session token after place selection
|
|
||||||
sessionToken.current = new google.maps.places.AutocompleteSessionToken();
|
|
||||||
|
|
||||||
if (status === google.maps.places.PlacesServiceStatus.OK && place) {
|
|
||||||
const address = parseAddressComponents(place.address_components || []);
|
|
||||||
|
|
||||||
// Update query with formatted address
|
|
||||||
setQueryState(place.formatted_address || '');
|
|
||||||
setSuggestions([]);
|
|
||||||
|
|
||||||
// Call onSelect callback
|
|
||||||
onSelect?.(address);
|
onSelect?.(address);
|
||||||
|
return address;
|
||||||
resolve(address);
|
|
||||||
} else {
|
|
||||||
setError('Failed to get address details');
|
|
||||||
resolve(null);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
);
|
|
||||||
});
|
|
||||||
},
|
},
|
||||||
[onSelect]
|
[results, onSelect]
|
||||||
);
|
);
|
||||||
|
|
||||||
// Clear suggestions
|
|
||||||
const clearSuggestions = useCallback(() => {
|
const clearSuggestions = useCallback(() => {
|
||||||
setSuggestions([]);
|
setResults([]);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// Handle input change
|
|
||||||
const handleChange = useCallback(
|
const handleChange = useCallback(
|
||||||
(e: React.ChangeEvent<HTMLInputElement>) => {
|
(e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
setQuery(e.target.value);
|
setQuery(e.target.value);
|
||||||
@@ -222,27 +205,24 @@ export function useAddressAutocomplete(
|
|||||||
[setQuery]
|
[setQuery]
|
||||||
);
|
);
|
||||||
|
|
||||||
// Handle blur
|
|
||||||
const handleBlur = useCallback(() => {
|
const handleBlur = useCallback(() => {
|
||||||
// Delay clearing to allow click on suggestion
|
// Slight delay so a click on a suggestion fires before the list is
|
||||||
setTimeout(() => {
|
// torn down. Same approach as the old hook.
|
||||||
clearSuggestions();
|
setTimeout(() => clearSuggestions(), 200);
|
||||||
}, 200);
|
|
||||||
}, [clearSuggestions]);
|
}, [clearSuggestions]);
|
||||||
|
|
||||||
// Cleanup on unmount
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
return () => {
|
return () => {
|
||||||
if (debounceRef.current) {
|
if (debounceRef.current) clearTimeout(debounceRef.current);
|
||||||
clearTimeout(debounceRef.current);
|
abortRef.current?.abort();
|
||||||
}
|
|
||||||
};
|
};
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
query,
|
query,
|
||||||
setQuery,
|
setQuery,
|
||||||
suggestions,
|
suggestions: results.map(resultToSuggestion),
|
||||||
|
results,
|
||||||
isLoading,
|
isLoading,
|
||||||
error,
|
error,
|
||||||
selectSuggestion,
|
selectSuggestion,
|
||||||
@@ -255,44 +235,3 @@ export function useAddressAutocomplete(
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Load Google Maps Places API script dynamically
|
|
||||||
*/
|
|
||||||
export function loadGoogleMapsScript(apiKey: string, language = 'en'): Promise<void> {
|
|
||||||
return new Promise((resolve, reject) => {
|
|
||||||
if (typeof window === 'undefined') {
|
|
||||||
reject(new Error('Cannot load script on server side'));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if already loaded
|
|
||||||
if (window.google?.maps?.places) {
|
|
||||||
resolve();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if script is already being loaded
|
|
||||||
const existingScript = document.querySelector(
|
|
||||||
'script[src*="maps.googleapis.com/maps/api/js"]'
|
|
||||||
);
|
|
||||||
if (existingScript) {
|
|
||||||
existingScript.addEventListener('load', () => resolve());
|
|
||||||
existingScript.addEventListener('error', () =>
|
|
||||||
reject(new Error('Failed to load Google Maps API'))
|
|
||||||
);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Create and append script
|
|
||||||
const script = document.createElement('script');
|
|
||||||
script.src = `https://maps.googleapis.com/maps/api/js?key=${apiKey}&libraries=places&language=${language}`;
|
|
||||||
script.async = true;
|
|
||||||
script.defer = true;
|
|
||||||
|
|
||||||
script.onload = () => resolve();
|
|
||||||
script.onerror = () => reject(new Error('Failed to load Google Maps API'));
|
|
||||||
|
|
||||||
document.head.appendChild(script);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,10 +1,12 @@
|
|||||||
// Hooks
|
// Hooks
|
||||||
export { useValidation } from './hooks/useValidation';
|
export { useValidation } from './hooks/useValidation';
|
||||||
export { useFieldValidation } from './hooks/useFieldValidation';
|
export { useFieldValidation } from './hooks/useFieldValidation';
|
||||||
export {
|
export { useAddressAutocomplete } from './hooks/useAddressAutocomplete';
|
||||||
useAddressAutocomplete,
|
export type { GeocoderResult } from './hooks/useAddressAutocomplete';
|
||||||
loadGoogleMapsScript,
|
|
||||||
} from './hooks/useAddressAutocomplete';
|
// Components
|
||||||
|
export { AddressAutocomplete } from './components/AddressAutocomplete';
|
||||||
|
export type { AddressAutocompleteProps } from './components/AddressAutocomplete';
|
||||||
|
|
||||||
// Validators - namespaced export
|
// Validators - namespaced export
|
||||||
export * as validators from './validators';
|
export * as validators from './validators';
|
||||||
|
|||||||
@@ -53,7 +53,10 @@ export interface EuropeanAddress {
|
|||||||
country: string; // ISO 3166-1 alpha-2 code
|
country: string; // ISO 3166-1 alpha-2 code
|
||||||
}
|
}
|
||||||
|
|
||||||
// Address suggestion from Google Places
|
// Address suggestion from the gsc-geocoder API (OSM-derived). `placeId`
|
||||||
|
// is `${osmType}-${osmId}` (e.g. "way-12345") — opaque from the caller's
|
||||||
|
// perspective, used as a stable React key and to dereference back to
|
||||||
|
// the structured address on selection.
|
||||||
export interface AddressSuggestion {
|
export interface AddressSuggestion {
|
||||||
placeId: string;
|
placeId: string;
|
||||||
description: string;
|
description: string;
|
||||||
@@ -135,12 +138,18 @@ export interface UseFieldValidationOptions {
|
|||||||
debounceMs?: number;
|
debounceMs?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
// useAddressAutocomplete hook options
|
// useAddressAutocomplete hook options. Backed by gsc-geocoder (OSM),
|
||||||
|
// reached via a same-origin proxy route that adds the API key
|
||||||
|
// server-side.
|
||||||
export interface UseAddressAutocompleteOptions {
|
export interface UseAddressAutocompleteOptions {
|
||||||
apiKey?: string;
|
/** Same-origin proxy route. Default `/api/geocoder`. */
|
||||||
countries?: string[]; // ISO codes, default: European countries
|
endpoint?: string;
|
||||||
|
/** ISO-3166 alpha-2 country codes to narrow results (first is used). */
|
||||||
|
countries?: string[];
|
||||||
|
/** Debounce window before firing a search. Default 300ms. */
|
||||||
debounceMs?: number;
|
debounceMs?: number;
|
||||||
language?: string;
|
/** Max suggestions returned. Default 8. */
|
||||||
|
limit?: number;
|
||||||
onSelect?: (address: EuropeanAddress) => void;
|
onSelect?: (address: EuropeanAddress) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user