"use client"; import { useEffect, useRef, useState } from "react"; import { ENV } from "../../core/env"; import { Input } from "../../shadcnui"; import { cn } from "../../utils"; import { FormFieldWrapper } from "./FormFieldWrapper"; /** * FormPlaceAutocomplete component integrates Google Places API (New) * to provide address suggestions as the user types. * * Prerequisites: * 1. Set NEXT_PUBLIC_GOOGLE_MAPS_API_KEY environment variable * 2. Enable Places API (New) in Google Cloud Console * 3. Configure API key restrictions as needed * * Note: This uses the new Places API via REST calls, not the legacy JavaScript API */ export interface PlaceAddressComponents { street_number: string; street: string; city: string; province: string; region: string; postcode: string; country: string; country_code: string; } export interface PlaceSuggestion { place_id: string; description: string; structured_formatting: { main_text: string; secondary_text: string; }; addressComponents?: PlaceAddressComponents; } interface PlaceAutocompleteProps { form: any; id: string; name?: string; description?: string; placeholder?: string; disabled?: boolean; testId?: string; isRequired?: boolean; onPlaceSelect?: (place: PlaceSuggestion) => void; className?: string; /** * Optional array of place types to include in search results. * When not specified, no type restriction is applied (all place types returned). * For addresses, use: ["street_address", "premise", "subpremise"] * For cities, use: ["locality", "administrative_area_level_3"] * For regions, use: ["administrative_area_level_1", "administrative_area_level_2"] */ includeTypes?: string[]; /** Google Places language code for results. Defaults to "en". */ languageCode?: string; } export function FormPlaceAutocomplete({ form, id, name, description, placeholder, disabled, testId, isRequired = false, onPlaceSelect, className, includeTypes, languageCode = "en", }: PlaceAutocompleteProps) { const [inputValue, setInputValue] = useState(""); const [suggestions, setSuggestions] = useState([]); const [isLoading, setIsLoading] = useState(false); const [showSuggestions, setShowSuggestions] = useState(false); const [loadError, setLoadError] = useState(false); const [apiKey, setApiKey] = useState(null); const debounceRef = useRef(null); const containerRef = useRef(null); // Initialize API key useEffect(() => { const key = ENV.GOOGLE_MAPS_API_KEY; if (!key) { console.error("Google Maps API key not found. Please set NEXT_PUBLIC_GOOGLE_MAPS_API_KEY environment variable."); setLoadError(true); return; } setApiKey(key); }, []); // Update input value when form value changes useEffect(() => { const formValue = form.getValues(id); if (formValue !== inputValue) { setInputValue(formValue || ""); } }, [form.watch(id), id, inputValue]); // Fetch place suggestions from Google Places API (New) const fetchSuggestions = async (input: string) => { if (!apiKey) return; try { setIsLoading(true); // Using the new Places API autocomplete endpoint const response = await fetch(`https://places.googleapis.com/v1/places:autocomplete`, { method: "POST", headers: { "Content-Type": "application/json", "X-Goog-Api-Key": apiKey, }, body: JSON.stringify({ input: input, ...(includeTypes ? { includedPrimaryTypes: includeTypes } : {}), languageCode: languageCode, }), }); if (!response.ok) { throw new Error(`Places API error: ${response.status}`); } const data = await response.json(); if (data.suggestions) { const formattedSuggestions: PlaceSuggestion[] = data.suggestions.map((suggestion: any) => ({ place_id: suggestion.placePrediction?.placeId || "", description: suggestion.placePrediction?.text?.text || "", structured_formatting: { main_text: suggestion.placePrediction?.structuredFormat?.mainText?.text || "", secondary_text: suggestion.placePrediction?.structuredFormat?.secondaryText?.text || "", }, })); setSuggestions(formattedSuggestions); setShowSuggestions(true); } else { setSuggestions([]); } } catch (error) { console.error("Error fetching place suggestions:", error); setSuggestions([]); } finally { setIsLoading(false); } }; const fetchPlaceDetails = async (placeId: string): Promise => { const defaults: PlaceAddressComponents = { street_number: "", street: "", city: "", province: "", region: "", postcode: "", country: "", country_code: "", }; if (!apiKey || !placeId) return defaults; try { const response = await fetch(`https://places.googleapis.com/v1/places/${placeId}`, { headers: { "X-Goog-Api-Key": apiKey, "X-Goog-FieldMask": "addressComponents", }, }); if (!response.ok) return defaults; const data = await response.json(); const components = data.addressComponents ?? []; const result = { ...defaults }; for (const comp of components) { const types: string[] = comp.types ?? []; if (types.includes("street_number")) { result.street_number = comp.longText ?? ""; } else if (types.includes("route")) { result.street = comp.longText ?? ""; } else if (types.includes("locality")) { result.city = comp.longText ?? ""; } else if (types.includes("administrative_area_level_2")) { result.province = comp.longText ?? ""; } else if (types.includes("administrative_area_level_1")) { result.region = comp.longText ?? ""; } else if (types.includes("postal_code")) { result.postcode = comp.longText ?? ""; } else if (types.includes("country")) { result.country = comp.longText ?? ""; result.country_code = comp.shortText ?? ""; } } return result; } catch { return defaults; } }; // Handle input changes with debouncing const handleInputChange = (value: string) => { setInputValue(value); // shouldValidate so a prior-submit "required" error on this field clears as // soon as it has a value (form.setValue alone does not re-run validation). form.setValue(id, value, { shouldValidate: true }); if (debounceRef.current) { clearTimeout(debounceRef.current); } if (value.length > 2 && apiKey) { debounceRef.current = setTimeout(() => { fetchSuggestions(value); }, 300); } else { setSuggestions([]); setShowSuggestions(false); setIsLoading(false); } }; // Handle suggestion selection const handleSuggestionSelect = async (suggestion: PlaceSuggestion) => { setInputValue(suggestion.description); form.setValue(id, suggestion.description, { shouldValidate: true }); setShowSuggestions(false); setSuggestions([]); const addressComponents = await fetchPlaceDetails(suggestion.place_id); const enrichedSuggestion: PlaceSuggestion = { ...suggestion, addressComponents, }; if (onPlaceSelect) { onPlaceSelect(enrichedSuggestion); } }; // Close suggestions when clicking outside useEffect(() => { const handleClickOutside = (event: MouseEvent) => { if (containerRef.current && !containerRef.current.contains(event.target as Node)) { setShowSuggestions(false); } }; document.addEventListener("mousedown", handleClickOutside); return () => { document.removeEventListener("mousedown", handleClickOutside); }; }, []); // Cleanup debounce on unmount useEffect(() => { return () => { if (debounceRef.current) { clearTimeout(debounceRef.current); } }; }, []); // Fallback to regular input if API key is not available if (loadError) { return (
{(field) => ( )}
); } return (
{(field) => (
handleInputChange(e.target.value)} onBlur={field.onBlur} onFocus={() => { if (suggestions.length > 0) { setShowSuggestions(true); } }} placeholder={placeholder} disabled={disabled || !apiKey} data-testid={testId} className={cn("w-full", className)} /> {/* Loading indicator */} {isLoading && (
)} {/* Suggestions dropdown */} {showSuggestions && suggestions.length > 0 && (
{suggestions.map((suggestion, index) => (
handleSuggestionSelect(suggestion)} >
{suggestion.structured_formatting?.main_text}
{suggestion.structured_formatting?.secondary_text}
))}
)}
)}
); }