diff --git a/package.json b/package.json index 6fbebbd..ba58bc8 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/src/validation/components/AddressAutocomplete.tsx b/src/validation/components/AddressAutocomplete.tsx new file mode 100644 index 0000000..e386b79 --- /dev/null +++ b/src/validation/components/AddressAutocomplete.tsx @@ -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(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(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 ( +
+ + setQuery(e.target.value)} + onKeyDown={handleKeyDown} + onFocus={() => results.length > 0 && setIsOpen(true)} + autoComplete="off" + /> + + + {isOpen && ( +
    + {results.map((r, i) => ( +
  • handleSelect(r)} + onMouseEnter={() => setActiveIndex(i)} + > +
    {r.displayName}
    + {(r.postcode || r.country) && ( + + {[r.postcode, r.city, r.country].filter(Boolean).join(', ')} + + )} +
  • + ))} +
+ )} + + {isLoading && ( +
+
+ Loading… +
+
+ )} +
+ ); +} diff --git a/src/validation/hooks/useAddressAutocomplete.ts b/src/validation/hooks/useAddressAutocomplete.ts index aaf7f12..68dcc07 100644 --- a/src/validation/hooks/useAddressAutocomplete.ts +++ b/src/validation/hooks/useAddressAutocomplete.ts @@ -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; + selectSuggestion: (id: string) => Promise; 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([]); + const [results, setResults] = useState([]); const [isLoading, setIsLoading] = useState(false); const [error, setError] = useState(undefined); - const autocompleteService = useRef(null); - const placesService = useRef(null); - const sessionToken = useRef(null); - const debounceRef = useRef(null); - const placesDiv = useRef(null); + const debounceRef = useRef | null>(null); + const abortRef = useRef(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 => { - if (!placesService.current) { - setError('Google Maps API not loaded'); + async (id: string): Promise => { + 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) => { 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 { - 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); - }); -} diff --git a/src/validation/index.ts b/src/validation/index.ts index 08849c0..129e592 100644 --- a/src/validation/index.ts +++ b/src/validation/index.ts @@ -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'; diff --git a/src/validation/types.ts b/src/validation/types.ts index 8b42603..4a1df85 100644 --- a/src/validation/types.ts +++ b/src/validation/types.ts @@ -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; }