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:
Claude
2026-05-19 20:50:53 +02:00
parent 7eb18b15b8
commit 80ef0965ea
5 changed files with 361 additions and 204 deletions

View File

@@ -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",

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

View File

@@ -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.
if (countries && countries.length > 0) {
params.set('country', countries[0]);
}
autocompleteService.current.getPlacePredictions( const res = await fetch(`${endpoint}?${params.toString()}`, {
request, signal: controller.signal,
(predictions, status) => { });
setIsLoading(false); if (!res.ok) {
setError('Address lookup failed');
if (status === google.maps.places.PlacesServiceStatus.OK && predictions) { setResults([]);
setSuggestions( return;
predictions.map((p) => ({ }
placeId: p.place_id, const body = (await res.json()) as GeocoderSearchResponse;
description: p.description, setResults(body.data ?? []);
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([]);
}
}
);
} 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([]);
{ onSelect?.(address);
placeId, return address;
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);
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);
});
}

View File

@@ -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';

View File

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