1import { Controller } from '@hotwired/stimulus' 2 3const LOOKUP_ENDPOINT = 'https://us-autocomplete-pro.api.smarty.com/lookup' 4const AUTOCOMPLETE_COUNTRY = 'US' 5const SEARCH_DEBOUNCE_MS = 200 6 7/** 8 * Client-side SmartyStreets US Street Autocomplete Pro integration for the 9 * studio street address field. 10 * 11 * Modeled on the deprecated api/client/src/Pages/Address.ts wiring: search 12 * only fires once the input contains a space and the selected country is US, 13 * selecting a suggestion fills city/state/zip, and every lookup is logged 14 * into a hidden `autocompleteLogs` field using the same shape 15 * AddressService::buildAddressAutocompleteLogs() already expects from the 16 * ProDPI form. This calls the SmartyStreets REST endpoint directly with the 17 * embedded/website key rather than vendoring the smartystreets-javascript-sdk 18 * package, which isn't wired into the AssetMapper importmap and whose only 19 * capability used here is this one GET request. 20 */ 21/* stimulusFetch: 'lazy' */ 22export default class extends Controller { 23 static targets = [ 24 'input', 25 'menu', 26 'countrySelect', 27 'city', 28 'state', 29 'zip', 30 'autocompleteLogs', 31 ] 32 static values = { websiteKey: String } 33 34 #debounceTimer = null 35 #logs = [] 36 37 search() { 38 window.clearTimeout(this.#debounceTimer) 39 this.#debounceTimer = window.setTimeout( 40 () => this.#performSearch(), 41 SEARCH_DEBOUNCE_MS 42 ) 43 } 44 45 closeMenu() { 46 this.#toggleMenu(false) 47 } 48 49 async #performSearch() { 50 if (!this.websiteKeyValue) return 51 52 const search = this.inputTarget.value 53 if (!search.includes(' ')) return 54 if ( 55 this.hasCountrySelectTarget && 56 this.countrySelectTarget.value !== AUTOCOMPLETE_COUNTRY 57 ) { 58 return 59 } 60 61 try { 62 const result = await this.#lookup({ search }) 63 this.#renderSuggestions(result.suggestions ?? []) 64 } catch { 65 // Autocomplete is a convenience on top of manual entry; a failed 66 // lookup just leaves the field for the studio to type themselves. 67 } 68 } 69 70 async #lookup(params) { 71 const url = new URL(LOOKUP_ENDPOINT) 72 url.searchParams.set('key', this.websiteKeyValue) 73 for (const [key, value] of Object.entries(params)) { 74 if (value) url.searchParams.set(key, value) 75 } 76 77 const response = await fetch(url) 78 const result = await response.json() 79 this.#logLookup(params, result.suggestions ?? []) 80 return result 81 } 82 83 #logLookup(search, suggestions) { 84 this.#logs.push({ type: 'frontEnd', search, response: suggestions }) 85 if (this.hasAutocompleteLogsTarget) { 86 this.autocompleteLogsTarget.value = JSON.stringify(this.#logs) 87 } 88 } 89 90 #renderSuggestions(suggestions) { 91 this.menuTarget.innerHTML = '' 92 93 suggestions.forEach((suggestion) => { 94 const option = document.createElement('li') 95 option.textContent = this.#formatSuggestion(suggestion) 96 option.addEventListener('click', () => this.#selectSuggestion(suggestion)) 97 this.menuTarget.appendChild(option) 98 }) 99 100 this.#toggleMenu(suggestions.length > 0) 101 } 102 103 #formatSuggestion(suggestion) { 104 let address = suggestion.street_line 105 106 if (suggestion.secondary) { 107 address += ` ${suggestion.secondary}` 108 if (suggestion.entries > 1) address += ` (${suggestion.entries} entries)` 109 } 110 111 return `${address}, ${suggestion.city}, ${suggestion.state} ${suggestion.zipcode}` 112 } 113 114 async #selectSuggestion(suggestion) { 115 if (suggestion.entries > 1) { 116 const expanded = [suggestion.street_line, suggestion.secondary] 117 .filter(Boolean) 118 .join(' ') 119 const result = await this.#lookup({ 120 search: this.inputTarget.value, 121 selected: expanded, 122 }) 123 this.#renderSuggestions(result.suggestions ?? []) 124 return 125 } 126 127 this.inputTarget.value = [suggestion.street_line, suggestion.secondary] 128 .filter(Boolean) 129 .join(' ') 130 if (this.hasCityTarget) this.cityTarget.value = suggestion.city 131 if (this.hasStateTarget) this.stateTarget.value = suggestion.state 132 if (this.hasZipTarget) this.zipTarget.value = suggestion.zipcode 133 134 this.#toggleMenu(false) 135 } 136 137 #toggleMenu(active) { 138 this.menuTarget.toggleAttribute('hidden', !active) 139 } 140}
Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.