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"
},
"devDependencies": {
"@types/google.maps": "^3.55.0",
"@types/nodemailer": "^6.4.0",
"@types/react": "^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";
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,
};
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]);
}
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 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
onSelect?.(address);
resolve(address);
} else {
setError('Failed to get address details');
resolve(null);
}
}
);
});
const address = resultToAddress(hit);
setQueryState(hit.displayName);
setResults([]);
onSelect?.(address);
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);
});
}

View File

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

View File

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