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"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/google.maps": "^3.55.0",
|
||||
"@types/nodemailer": "^6.4.0",
|
||||
"@types/react": "^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";
|
||||
|
||||
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 = [
|
||||
'at', 'be', 'bg', 'hr', 'cy', 'cz', 'dk', 'ee', 'fi', 'fr',
|
||||
'de', 'gr', 'hu', 'ie', 'it', 'lv', 'lt', 'lu', 'mt', 'nl',
|
||||
'pl', 'pt', 'ro', 'sk', 'si', 'es', 'se', 'gb', 'ch', 'no',
|
||||
'is', 'li', 'al', 'ad', 'ba', 'me', 'mk', 'rs', 'ua', 'md',
|
||||
];
|
||||
/**
|
||||
* Shape of a single result from `gsc-geocoder` (`/api/v1/search`). Built
|
||||
* on OSM data (Nominatim-style), backed by PostGIS + pg_trgm FTS on
|
||||
* denbgvdb04 (172.17.3.26). 183M addresses covering EU.
|
||||
*
|
||||
* 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 {
|
||||
query: string;
|
||||
setQuery: (value: string) => void;
|
||||
suggestions: AddressSuggestion[];
|
||||
/** Same suggestions, but with the full geocoder payload (lat/lon/etc). */
|
||||
results: GeocoderResult[];
|
||||
isLoading: boolean;
|
||||
error: string | undefined;
|
||||
selectSuggestion: (placeId: string) => Promise<EuropeanAddress | null>;
|
||||
selectSuggestion: (id: string) => Promise<EuropeanAddress | null>;
|
||||
clearSuggestions: () => void;
|
||||
inputProps: {
|
||||
value: string;
|
||||
@@ -27,194 +55,149 @@ export interface UseAddressAutocompleteReturn {
|
||||
};
|
||||
}
|
||||
|
||||
// Parse Google address components into structured format
|
||||
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 || '';
|
||||
|
||||
function resultToAddress(r: GeocoderResult): EuropeanAddress {
|
||||
return {
|
||||
street: get('route'),
|
||||
houseNumber: get('street_number'),
|
||||
postalCode: get('postal_code'),
|
||||
city: get('locality') || get('postal_town') || get('sublocality'),
|
||||
state: get('administrative_area_level_1'),
|
||||
country: getShort('country'),
|
||||
street: r.street || r.place || '',
|
||||
houseNumber: r.housenumber || undefined,
|
||||
postalCode: r.postcode || '',
|
||||
city: r.city || '',
|
||||
state: r.state || undefined,
|
||||
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(
|
||||
options: UseAddressAutocompleteOptions = {}
|
||||
): UseAddressAutocompleteReturn {
|
||||
const {
|
||||
apiKey,
|
||||
countries = DEFAULT_EUROPEAN_COUNTRIES,
|
||||
endpoint = '/api/geocoder',
|
||||
countries,
|
||||
debounceMs = 300,
|
||||
language = 'en',
|
||||
limit = 8,
|
||||
onSelect,
|
||||
} = options;
|
||||
|
||||
const [query, setQueryState] = useState('');
|
||||
const [suggestions, setSuggestions] = useState<AddressSuggestion[]>([]);
|
||||
const [results, setResults] = useState<GeocoderResult[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [error, setError] = useState<string | undefined>(undefined);
|
||||
|
||||
const autocompleteService = useRef<google.maps.places.AutocompleteService | null>(null);
|
||||
const placesService = useRef<google.maps.places.PlacesService | null>(null);
|
||||
const sessionToken = useRef<google.maps.places.AutocompleteSessionToken | null>(null);
|
||||
const debounceRef = useRef<NodeJS.Timeout | null>(null);
|
||||
const placesDiv = useRef<HTMLDivElement | null>(null);
|
||||
const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const abortRef = useRef<AbortController | 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(
|
||||
async (input: string) => {
|
||||
if (!input || input.length < 3) {
|
||||
setSuggestions([]);
|
||||
if (!input || input.length < 2) {
|
||||
setResults([]);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!autocompleteService.current) {
|
||||
// Google Maps API not loaded
|
||||
setError('Google Maps API not loaded. Please include the script in your page.');
|
||||
return;
|
||||
}
|
||||
// Cancel in-flight request when the user keeps typing.
|
||||
abortRef.current?.abort();
|
||||
const controller = new AbortController();
|
||||
abortRef.current = controller;
|
||||
|
||||
setIsLoading(true);
|
||||
setError(undefined);
|
||||
|
||||
try {
|
||||
const request: google.maps.places.AutocompletionRequest = {
|
||||
input,
|
||||
componentRestrictions: { country: countries },
|
||||
types: ['address'],
|
||||
sessionToken: sessionToken.current || undefined,
|
||||
};
|
||||
|
||||
autocompleteService.current.getPlacePredictions(
|
||||
request,
|
||||
(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 params = new URLSearchParams({
|
||||
q: input,
|
||||
limit: String(limit),
|
||||
});
|
||||
// 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.
|
||||
if (countries && countries.length > 0) {
|
||||
params.set('country', countries[0]);
|
||||
}
|
||||
|
||||
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) {
|
||||
setIsLoading(false);
|
||||
if ((err as { name?: string }).name === 'AbortError') return;
|
||||
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(
|
||||
(value: string) => {
|
||||
setQueryState(value);
|
||||
|
||||
if (debounceRef.current) {
|
||||
clearTimeout(debounceRef.current);
|
||||
}
|
||||
|
||||
if (debounceRef.current) clearTimeout(debounceRef.current);
|
||||
debounceRef.current = setTimeout(() => {
|
||||
fetchSuggestions(value);
|
||||
void fetchSuggestions(value);
|
||||
}, debounceMs);
|
||||
},
|
||||
[debounceMs, fetchSuggestions]
|
||||
);
|
||||
|
||||
// Select a suggestion and get full address details
|
||||
const selectSuggestion = useCallback(
|
||||
async (placeId: string): Promise<EuropeanAddress | null> => {
|
||||
if (!placesService.current) {
|
||||
setError('Google Maps API not loaded');
|
||||
async (id: string): Promise<EuropeanAddress | null> => {
|
||||
const hit = results.find((r) => `${r.osmType}-${r.osmId}` === id);
|
||||
if (!hit) {
|
||||
setError('Selected suggestion not in current result set');
|
||||
return null;
|
||||
}
|
||||
|
||||
return new Promise((resolve) => {
|
||||
placesService.current!.getDetails(
|
||||
{
|
||||
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
|
||||
const address = resultToAddress(hit);
|
||||
setQueryState(hit.displayName);
|
||||
setResults([]);
|
||||
onSelect?.(address);
|
||||
|
||||
resolve(address);
|
||||
} else {
|
||||
setError('Failed to get address details');
|
||||
resolve(null);
|
||||
}
|
||||
}
|
||||
);
|
||||
});
|
||||
return address;
|
||||
},
|
||||
[onSelect]
|
||||
[results, onSelect]
|
||||
);
|
||||
|
||||
// Clear suggestions
|
||||
const clearSuggestions = useCallback(() => {
|
||||
setSuggestions([]);
|
||||
setResults([]);
|
||||
}, []);
|
||||
|
||||
// Handle input change
|
||||
const handleChange = useCallback(
|
||||
(e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
setQuery(e.target.value);
|
||||
@@ -222,27 +205,24 @@ export function useAddressAutocomplete(
|
||||
[setQuery]
|
||||
);
|
||||
|
||||
// Handle blur
|
||||
const handleBlur = useCallback(() => {
|
||||
// Delay clearing to allow click on suggestion
|
||||
setTimeout(() => {
|
||||
clearSuggestions();
|
||||
}, 200);
|
||||
// Slight delay so a click on a suggestion fires before the list is
|
||||
// torn down. Same approach as the old hook.
|
||||
setTimeout(() => clearSuggestions(), 200);
|
||||
}, [clearSuggestions]);
|
||||
|
||||
// Cleanup on unmount
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (debounceRef.current) {
|
||||
clearTimeout(debounceRef.current);
|
||||
}
|
||||
if (debounceRef.current) clearTimeout(debounceRef.current);
|
||||
abortRef.current?.abort();
|
||||
};
|
||||
}, []);
|
||||
|
||||
return {
|
||||
query,
|
||||
setQuery,
|
||||
suggestions,
|
||||
suggestions: results.map(resultToSuggestion),
|
||||
results,
|
||||
isLoading,
|
||||
error,
|
||||
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
|
||||
export { useValidation } from './hooks/useValidation';
|
||||
export { useFieldValidation } from './hooks/useFieldValidation';
|
||||
export {
|
||||
useAddressAutocomplete,
|
||||
loadGoogleMapsScript,
|
||||
} from './hooks/useAddressAutocomplete';
|
||||
export { useAddressAutocomplete } from './hooks/useAddressAutocomplete';
|
||||
export type { GeocoderResult } from './hooks/useAddressAutocomplete';
|
||||
|
||||
// Components
|
||||
export { AddressAutocomplete } from './components/AddressAutocomplete';
|
||||
export type { AddressAutocompleteProps } from './components/AddressAutocomplete';
|
||||
|
||||
// Validators - namespaced export
|
||||
export * as validators from './validators';
|
||||
|
||||
@@ -53,7 +53,10 @@ export interface EuropeanAddress {
|
||||
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 {
|
||||
placeId: string;
|
||||
description: string;
|
||||
@@ -135,12 +138,18 @@ export interface UseFieldValidationOptions {
|
||||
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 {
|
||||
apiKey?: string;
|
||||
countries?: string[]; // ISO codes, default: European countries
|
||||
/** Same-origin proxy route. Default `/api/geocoder`. */
|
||||
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;
|
||||
language?: string;
|
||||
/** Max suggestions returned. Default 8. */
|
||||
limit?: number;
|
||||
onSelect?: (address: EuropeanAddress) => void;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user