1/** 2 * Location Autocomplete Alpine Component 3 * 4 * Provides location autocomplete functionality with multiple geocoding provider support 5 * (Google, Nominatim, Mapbox, ArcGIS) 6 * 7 * @package MapsPro Add-on for JReviews 8 * @author ClickFWD LLC (https://www.jreviews.com) 9 * @copyright Copyright (C) 2010-2025 ClickFWD LLC 10 * @license GNU GPL version 3 or later 11 */ 12 13// Languages the Mapbox Search Box / Geocoding APIs localize results into. 14// Anything outside this set is left unset so Mapbox returns its default rather 15// than erroring. https://docs.mapbox.com/api/search/search-box/ 16// Shared global: both alpine-location-autocomplete.js and alpine-proximity-search.js 17// can load on the same page. Guard the assignment so the second script does not 18// redeclare a lexical `const` (which throws and aborts the whole file). 19window.JR_MAPBOX_LANGUAGES = window.JR_MAPBOX_LANGUAGES || ['cs', 'hr', 'da', 'nl', 'en', 'et', 'fi', 'fr', 'de', 'el', 'hu', 'it', 'ja', 'lt', 'lv', 'pl', 'pt', 'ro', 'ru', 'sk', 'sl', 'es', 'sv', 'tr', 'uk']; 20 21/** 22 * Registering only on alpine:init means this file has to be on the page before 23 * Alpine starts. If it ever arrives later it would miss that event and never 24 * register at all, leaving every widget that names it inert with no error to 25 * show for it. Register on whichever comes first, and only once. 26 */ 27/** 28 * Read a provider's JSON without turning a failure into an empty answer. 29 * 30 * Mirrors fetchJson in location-search-client.js. It is duplicated rather than 31 * imported because these providers are the fallback for pages where that script 32 * is NOT loaded - importing from it would make the fallback depend on the thing 33 * it is a fallback for. The thrown shape is identical so callers need only one 34 * branch. 35 */ 36async function jrFetchLocationJson(url, options) { 37 const fail = (reason, status, cause) => { 38 const Unavailable = window.jrMapsLocationSearch?.Unavailable; 39 if (Unavailable) return new Unavailable(reason, status, cause); 40 41 return Object.assign( 42 new Error(`Location search provider did not answer (${reason}${status ? ' ' + status : ''})`), 43 { name: 'LocationSearchUnavailable', reason, status, cause } 44 ); 45 }; 46 47 let response; 48 49 try { 50 response = await fetch(url, options); 51 } catch (cause) { 52 throw fail('network', 0, cause); 53 } 54 55 if (!response.ok) throw fail('http', response.status); 56 57 try { 58 return await response.json(); 59 } catch (cause) { 60 throw fail('malformed', response.status, cause); 61 } 62} 63 64/** Did the provider fail to answer, as opposed to answering with nothing? */ 65function jrIsLocationSearchUnavailable(error) { 66 return error?.name === 'LocationSearchUnavailable'; 67} 68 69function jrRegisterLocationAutocomplete() { 70 if (!window.Alpine || window.jrLocationAutocompleteRegistered) return; 71 window.jrLocationAutocompleteRegistered = true; 72 73 Alpine.data('locationAutocomplete', (fieldName, parentContainer, enableAutofill = true) => { 74 // Load global configuration 75 const globalConfig = window.jrMapsAutocomplete || {}; 76 77 return { 78 query: '', 79 suggestions: [], 80 selectedIndex: -1, 81 isOpen: false, 82 isLoading: false, 83 // The provider did not answer. Distinct from `suggestions` being 84 // empty, which means it answered and had nothing. 85 isUnavailable: false, 86 debounceTimer: null, 87 requestId: 0, 88 apiLoaded: false, 89 apiLoading: false, 90 apiType: null, // 'new' or 'legacy' 91 autocompleteService: null, // Legacy API service 92 placesService: null, // Legacy API service 93 locationSearchClient: null, 94 95 // Field-specific configuration 96 fieldName: fieldName, 97 parentContainer: parentContainer, 98 enableAutofill: enableAutofill, 99 100 // Global configuration from window.jrMapsAutocomplete 101 geocodingService: globalConfig.geocodingService, 102 countryRestriction: globalConfig.countryRestriction || '', 103 minChars: globalConfig.minChars || 3, 104 debounceDelay: globalConfig.debounceDelay || 600, 105 fieldMappings: globalConfig.fieldMappings || {}, 106 googleApiKey: globalConfig.googleApiKey || null, 107 googleApiLocale: globalConfig.googleApiLocale || 'en', 108 googleApiUrl: globalConfig.googleApiUrl || null,
109 geocodeLang: globalConfig.geocodeLang || '', 110 mapboxToken: globalConfig.mapboxToken || null, 111 coordinatorScript: globalConfig.coordinatorScript, 112 lang: globalConfig.lang || { 113 placeholder: 'Enter a location', 114 searchPrompt: 'Start typing to search...', 115 noResults: 'No locations found', 116 unavailable: 'Location search is unavailable right now. Please try again shortly.' 117 }, 118 119 init() { 120 this.locationSearchClient = window.jrMapsLocationSearch?.create(globalConfig) || null; 121 const field = this.getTargetField(); 122 if (!field) { 123 console.error('Autocomplete target field not found:', this.fieldName); 124 return; 125 } 126 127 // Set placeholder text if field doesn't have one 128 if (!field.placeholder) { 129 field.placeholder = this.lang.placeholder; 130 } 131 132 // Attach event listeners 133 field.addEventListener('input', (e) => this.handleInput(e)); 134 field.addEventListener('keydown', (e) => this.handleKeydown(e)); 135 field.addEventListener('focus', () => { 136 // Always show dropdown on focus (shows "Start typing to search..." or suggestions) 137 this.isOpen = true; 138 }); 139 140 // Don't load APIs here - load lazily when user starts typing 141 }, 142 143 async loadAPIs() { 144 // Already loaded or currently loading 145 if (this.apiLoaded || this.apiLoading) { 146 return; 147 } 148 149 this.apiLoading = true; 150 151 if (this.geocodingService === 'google' && this.googleApiKey) { 152 // Load coordinator script first if needed 153 if (typeof window.jrMapsApi === 'undefined') { 154 if (!this.coordinatorScript) { 155 console.error('Coordinator script URL is not defined!'); 156 this.apiLoading = false; 157 return; 158 } 159 try { 160 await this.loadScript(this.coordinatorScript); 161 } catch (error) { 162 console.error('Failed to load coordinator script:', error); 163 this.apiLoading = false; 164 return; 165 } 166 } 167 168 // Check if Google Maps API is already loaded (by MapIt or other component) 169 if (typeof google !== 'undefined' && typeof google.maps !== 'undefined' && typeof google.maps.places !== 'undefined') { 170 this.apiType = this.detectGooglePlacesAPI(); 171 172 if (this.apiType === 'legacy') { 173 // Initialize legacy API services 174 this.autocompleteService = new google.maps.places.AutocompleteService(); 175 const div = document.createElement('div'); 176 this.placesService = new google.maps.places.PlacesService(div); 177 } 178 179 this.apiLoaded = true; 180 this.apiLoading = false; 181 return; 182 } 183 184 try { 185 await window.jrMapsApi.loadGoogle(this.googleApiKey, { 186 libraries: ['places'], 187 locale: this.googleApiLocale, 188 apiUrl: this.googleApiUrl 189 }); 190 191 // Detect which API version is available 192 this.apiType = this.detectGooglePlacesAPI(); 193 194 if (this.apiType === 'legacy') { 195 // Initialize legacy API services 196 this.autocompleteService = new google.maps.places.AutocompleteService(); 197 const div = document.createElement('div'); 198 this.placesService = new google.maps.places.PlacesService(div); 199 } 200 201 this.apiLoaded = true; 202 } catch (error) { 203 console.error('Failed to load Google Maps API:', error); 204 } 205 } else { 206 // Non-Google providers don't need special API loading 207 this.apiLoaded = true; 208 } 209 210 this.apiLoading = false;
211 }, 212 213 /** 214 * Load a file on demand. 215 * 216 * Once the coordinator is present every request goes through its 217 * shared registry, so a file is fetched once however many widgets on 218 * the page ask for it. Treating "a matching tag exists" as "the file 219 * is ready" is what used to break this: a tag still in flight has not 220 * defined anything yet, so a second widget carried on against a 221 * client that was not there and silently did nothing. 222 * 223 * Fetching the coordinator itself is the one case that cannot use 224 * that registry, so it has a small bootstrap of its own - shared 225 * between widgets through a single promise, and confirmed by the 226 * global the file defines rather than by the tag being present. 227 */ 228 loadScript(url) { 229 if (window.jrMapsApi?.ensureScript) { 230 return window.jrMapsApi.ensureScript(url); 231 } 232 233 if (!url) return Promise.reject(new Error('A script URL is required.')); 234 235 window.jrMapsCoordinatorPromise = window.jrMapsCoordinatorPromise 236 || new Promise((resolve, reject) => { 237 if (window.jrMapsApi) return resolve(); 238 239 const existing = document.querySelector(`script[src="${url}"]`); 240 const script = existing || document.createElement('script'); 241 242 script.addEventListener('load', () => resolve(), { once: true }); 243 script.addEventListener('error', reject, { once: true }); 244 245 if (existing) { 246 // It may already have finished, in which case no 247 // further load event is coming. 248 if (window.jrMapsApi) return resolve(); 249 window.addEventListener('load', () => resolve(), { once: true }); 250 return; 251 } 252 253 script.src = url; 254 script.async = false; 255 document.head.appendChild(script); 256 }); 257 258 return window.jrMapsCoordinatorPromise; 259 }, 260 261 detectGooglePlacesAPI() { 262 // Check if Google Maps API is available 263 if (typeof google === 'undefined' || typeof google.maps === 'undefined' || typeof google.maps.places === 'undefined') { 264 console.error('Google Maps Places API is not available'); 265 return null; 266 } 267 268 // Check for New Places API with more thorough detection 269 if (typeof google.maps.places.AutocompleteSuggestion !== 'undefined' && 270 typeof google.maps.places.Place !== 'undefined' && 271 typeof google.maps.places.AutocompleteSuggestion.fetchAutocompleteSuggestions === 'function') { 272 273 // Verify the Place class has the expected methods 274 try { 275 const testPlace = new google.maps.places.Place({ id: 'test' }); 276 if (typeof testPlace.fetchFields === 'function') { 277 return 'new'; 278 } 279 } catch (e) { 280 console.warn('New Places API classes exist but are not functional:', e); 281 } 282 } 283 284 // Check for Legacy Places API 285 if (typeof google.maps.places.AutocompleteService !== 'undefined') { 286 return 'legacy'; 287 } 288 289 console.error('No compatible Google Places API found'); 290 return null; 291 }, 292 293 handleInput(event) { 294 this.query = event.target.value; 295 const requestId = ++this.requestId; 296 297 // Clear existing timer 298 clearTimeout(this.debounceTimer); 299 this.suggestions = []; 300 this.selectedIndex = -1; 301 302 // If address field is completely empty, clear coordinates 303 if (this.query.length === 0) { 304 this.isLoading = false;
305 this.clearCoordinates(); 306 this.closeSuggestions(); 307 return; 308 } 309 310 if (this.query.length < this.minChars) { 311 this.isLoading = false; 312 this.closeSuggestions(); 313 return; 314 } 315 316 // Debounce the fetch 317 this.debounceTimer = setTimeout(() => { 318 this.fetchSuggestions(requestId); 319 }, this.debounceDelay); 320 }, 321 322 async handleKeydown(event) { 323 if (event.key === 'Enter' && this.suggestions.length === 0 && this.query.length >= this.minChars && this.locationSearchClient && !this.locationSearchClient.supportsSuggestions) { 324 event.preventDefault(); 325 this.isLoading = true; 326 try { 327 const result = await this.locationSearchClient.search(this.query); 328 if (result) { 329 this.getTargetField().value = result.label; 330 this.query = result.label; 331 if (this.enableAutofill) this.autofillAddressFields(result); 332 this.closeSuggestions(); 333 } 334 } finally { 335 this.isLoading = false; 336 } 337 return; 338 } 339 340 if (!this.isOpen || this.suggestions.length === 0) return; 341 342 switch (event.key) { 343 case 'ArrowDown': 344 event.preventDefault(); 345 this.selectedIndex = Math.min(this.selectedIndex + 1, this.suggestions.length - 1); 346 break; 347 case 'ArrowUp': 348 event.preventDefault(); 349 this.selectedIndex = Math.max(this.selectedIndex - 1, 0); 350 break; 351 case 'Enter': 352 event.preventDefault(); 353 if (this.selectedIndex >= 0 && this.selectedIndex < this.suggestions.length) { 354 this.selectSuggestion(this.suggestions[this.selectedIndex]); 355 } 356 break; 357 case 'Escape': 358 this.closeSuggestions(); 359 break; 360 } 361 }, 362 363 async fetchSuggestions(requestId = ++this.requestId) { 364 const query = this.query; 365 this.isLoading = true; 366 this.isOpen = true; // Open dropdown to show loading indicator 367 368 try { 369 this.locationSearchClient ||= window.jrMapsLocationSearch?.create(globalConfig); 370 const suggestions = this.locationSearchClient 371 ? await this.locationSearchClient.suggest(query) 372 : await this.suggestionProviders[this.geocodingService].call(this, query); 373 if (requestId !== this.requestId || query !== this.query) return; 374 this.suggestions = suggestions; 375 // The provider answered. An empty list here is the honest 376 // empty state and still reads "No locations found". 377 this.isUnavailable = false; 378 // Keep dropdown open to show "No locations found" message if no results 379 this.selectedIndex = -1; 380 } catch (error) { 381 console.error('Failed to fetch suggestions:', error); 382 if (requestId === this.requestId) { 383 // A throw is the provider failing to answer. It used to 384 // land here and close the dropdown, so a network failure 385 // said nothing at all while an HTTP error said "No 386 // locations found" - both wrong, in opposite directions. 387 this.suggestions = []; 388 this.selectedIndex = -1; 389 this.isUnavailable = true; 390 this.isOpen = true; 391 window.jrMapsLocationSearch?.report?.(error, this.geocodingService); 392 } 393 } finally { 394 if (requestId === this.requestId) this.isLoading = false;
395 } 396 }, 397 398 async selectSuggestion(suggestion) { 399 const input = this.getTargetField(); 400 input.value = suggestion.label; 401 402 // Fetch full place details if autofill is enabled 403 if (this.enableAutofill) { 404 try { 405 const placeDetails = await this.fetchPlaceDetails(suggestion); 406 if (placeDetails) { 407 this.autofillAddressFields(placeDetails); 408 } 409 } catch (error) { 410 console.error('Failed to fetch place details:', error); 411 } 412 } 413 414 this.closeSuggestions(); 415 }, 416 417 // Google Places API helpers 418 async _fetchSuggestionsNewAPI(query) { 419 try { 420 const request = { input: query }; 421 422 if (this.countryRestriction) { 423 request.includedRegionCodes = [this.countryRestriction]; 424 } 425 426 const { suggestions } = await google.maps.places.AutocompleteSuggestion.fetchAutocompleteSuggestions(request); 427 428 return suggestions.map(suggestion => { 429 const pred = suggestion.placePrediction; 430 // New API uses structuredFormat for primary/secondary 431 const primary = pred.structuredFormat?.mainText?.text || pred.text.text; 432 const secondary = pred.structuredFormat?.secondaryText?.text || ''; 433 return { 434 id: pred.placeId, 435 label: pred.text.text, 436 ref: pred.placeId, 437 primary: primary, 438 secondary: secondary 439 }; 440 }); 441 } catch (error) { 442 // Switch to legacy API and retry 443 if (typeof google.maps.places.AutocompleteService !== 'undefined') { 444 this.apiType = 'legacy'; 445 this.autocompleteService = new google.maps.places.AutocompleteService(); 446 const div = document.createElement('div'); 447 this.placesService = new google.maps.places.PlacesService(div); 448 449 return await this._fetchSuggestionsLegacyAPI(query); 450 } 451 452 return []; 453 } 454 }, 455 456 async _fetchSuggestionsLegacyAPI(query) { 457 return new Promise((resolve) => { 458 try { 459 const request = { 460 input: query, 461 }; 462 463 if (this.countryRestriction) { 464 request.componentRestrictions = { country: this.countryRestriction }; 465 } 466 467 this.autocompleteService.getPlacePredictions(request, (predictions, status) => { 468 if (status === google.maps.places.PlacesServiceStatus.OK && predictions) { 469 const results = predictions.map(prediction => { 470 // Legacy API uses structured_formatting 471 const primary = prediction.structured_formatting?.main_text || prediction.description; 472 const secondary = prediction.structured_formatting?.secondary_text || ''; 473 return { 474 id: prediction.place_id, 475 label: prediction.description, 476 ref: prediction.place_id, 477 primary: primary, 478 secondary: secondary 479 }; 480 }); 481 resolve(results); 482 } else if (status === google.maps.places.PlacesServiceStatus.ZERO_RESULTS) { 483 resolve([]); 484 } else { 485 console.error('Google Places API error:', status); 486 resolve([]); 487 } 488 }); 489 } catch (error) { 490 console.error('Google autocomplete error:', error); 491 resolve([]); 492 } 493 }); 494 }, 495 496 // Provider-specific suggestion fetchers 497 suggestionProviders: { 498 google: async function (query) { 499 if (this.apiType === 'new') { 500 return this._fetchSuggestionsNewAPI(query); 501 } else if (this.apiType === 'legacy') { 502 return this._fetchSuggestionsLegacyAPI(query); 503 } else { 504 console.error('No Google Places API type detected'); 505 return []; 506 } 507 }, 508 509 nominatim: async function (query) { 510 const url = new URL('https://nominatim.openstreetmap.org/search'); 511 url.searchParams.set('q', query); 512 url.searchParams.set('format', 'json'); 513 url.searchParams.set('limit', 1); // Nominatim prohibits autocomplete, limit=1 for basic functionality 514 url.searchParams.set('addressdetails', 1); 515 516 if (this.countryRestriction) { 517 url.searchParams.set('countrycodes', this.countryRestriction); 518 } 519
520 if (this.geocodeLang) { 521 url.searchParams.set('accept-language', this.geocodeLang); 522 } 523 524 const data = await jrFetchLocationJson(url); 525 526 // Generic types that don't add value for the user 527 const skipTypes = ['yes', 'house', 'residential', 'building', 'address', 'apartments', 'detached', 'terrace']; 528 529 return data.map(item => { 530 // Parse display_name into primary (first part) and secondary (rest) 531 const parts = item.display_name.split(',').map(p => p.trim()); 532 let primary = parts[0] || item.display_name; 533 const secondaryParts = []; 534 535 // Add county to primary for better visual differentiation (especially for common names) 536 const county = item.address?.county; 537 if (county && !primary.includes(county)) { 538 primary = `${primary}, ${county}`; 539 } 540 541 // Add type only if it's meaningful (POI categories like hotel, restaurant, city, etc.) 542 if (item.type && !skipTypes.includes(item.type)) { 543 secondaryParts.push(item.type); 544 } 545 546 // Add state and country from address 547 if (item.address?.state && !primary.includes(item.address.state)) { 548 secondaryParts.push(item.address.state); 549 } 550 if (item.address?.country && !primary.includes(item.address.country)) { 551 secondaryParts.push(item.address.country); 552 } 553 554 return { 555 id: item.place_id, 556 label: item.display_name, 557 ref: item, 558 data: item, 559 primary: primary, 560 secondary: secondaryParts.join(', ') 561 }; 562 }); 563 }, 564 565 mapbox: async function (query) { 566 // Use Search Box API /suggest for better POI coverage 567 // Generate session token if not exists (for billing optimization) 568 if (!this.mapboxSessionToken) { 569 this.mapboxSessionToken = crypto.randomUUID(); 570 } 571 572 const params = new URLSearchParams({ 573 q: query, 574 access_token: this.mapboxToken, 575 session_token: this.mapboxSessionToken, 576 limit: 10 577 }); 578 579 if (this.countryRestriction) { 580 params.set('country', this.countryRestriction); 581 } 582 583 if (this.geocodeLang && JR_MAPBOX_LANGUAGES.includes(this.geocodeLang)) { 584 params.set('language', this.geocodeLang); 585 } 586 587 const data = await jrFetchLocationJson(`https://api.mapbox.com/search/searchbox/v1/suggest?${params}`); 588 589 if (!data.suggestions || !Array.isArray(data.suggestions)) { 590 return []; 591 } 592 593 return data.suggestions.map((suggestion, index) => { 594 let primary = suggestion.name || suggestion.full_address; 595 const secondaryParts = []; 596 597 // Add place (town/locality) to primary for better visual differentiation 598 const place = suggestion.context?.place?.name; 599 if (place && !primary.includes(place)) { 600 primary = `${primary}, ${place}`; 601 } 602 603 // Add POI category or feature type 604 if (suggestion.poi_category && suggestion.poi_category.length) { 605 secondaryParts.push(suggestion.poi_category[0]); 606 } else if (suggestion.feature_type && suggestion.feature_type !== 'address') { 607 secondaryParts.push(suggestion.feature_type); 608 } 609 610 // Add region and country from context
611 if (suggestion.context?.region?.name && !primary.includes(suggestion.context.region.name)) { 612 secondaryParts.push(suggestion.context.region.name); 613 } 614 if (suggestion.context?.country?.name && !primary.includes(suggestion.context.country.name)) { 615 secondaryParts.push(suggestion.context.country.name); 616 } 617 618 return { 619 id: suggestion.mapbox_id || `mapbox-${index}`, 620 label: suggestion.full_address || suggestion.name, 621 ref: suggestion.mapbox_id, 622 data: suggestion, 623 primary: primary, 624 secondary: secondaryParts.join(', ') 625 }; 626 }); 627 }, 628 629 arcgis: async function (query) { 630 const url = 'https://geocode.arcgis.com/arcgis/rest/services/World/GeocodeServer/suggest'; 631 const params = new URLSearchParams({ 632 text: query, 633 f: 'json', 634 maxSuggestions: 10 635 }); 636 637 if (this.countryRestriction) { 638 params.set('countryCode', this.countryRestriction); 639 } 640 641 const data = await jrFetchLocationJson(`${url}?${params}`); 642 643 return data.suggestions.map(s => { 644 // Parse text into primary (first part) and secondary (rest) 645 const parts = s.text.split(',').map(p => p.trim()); 646 const primary = parts[0] || s.text; 647 const secondary = parts.slice(1).join(', '); 648 649 return { 650 id: s.magicKey, 651 label: s.text, 652 ref: s.magicKey, 653 primary: primary, 654 secondary: secondary 655 }; 656 }); 657 }, 658 659 photon: async function (query) { 660 const url = new URL('https://photon.komoot.io/api/'); 661 url.searchParams.set('q', query); 662 url.searchParams.set('limit', 10); 663 664 if (this.geocodeLang) { 665 url.searchParams.set('lang', this.geocodeLang); 666 } 667 668 const data = await jrFetchLocationJson(url); 669 670 if (!data.features || !Array.isArray(data.features)) { 671 return []; 672 } 673 674 const self = this; 675 return data.features.map((feature, index) => { 676 const props = feature.properties || {}; 677 const formatted = self.formatPhotonSuggestion(props); 678 return { 679 id: `photon-${props.osm_id || ''}-${index}`, 680 label: formatted.label, 681 ref: feature, 682 data: feature, 683 primary: formatted.primary, 684 secondary: formatted.secondary 685 }; 686 }); 687 } 688 }, 689 690 /** 691 * Format Photon suggestion with primary (name) and secondary (type, region) lines 692 * Similar to Photon demo format: "Hood River Mountain Trail" + "track, Oregon, United States" 693 */ 694 formatPhotonSuggestion(props) { 695 let primary = props.name || 'Unknown location'; 696 const secondaryParts = []; 697 698 // Add county to primary for better visual differentiation (especially for common names) 699 const county = props.county; 700 if (county && !primary.includes(county)) { 701 primary = `${primary}, ${county}`; 702 } 703 704 // Generic types that don't add value for the user 705 const skipTypes = ['yes', 'house', 'residential', 'building', 'address', 'apartments', 'detached', 'terrace']; 706 707 // Add type only if it's meaningful (POI categories like hotel, restaurant, peak, etc.) 708 const type = props.osm_value || props.type; 709 if (type && !skipTypes.includes(type)) { 710 secondaryParts.push(type); 711 } 712 713 // Add state if not in name 714 if (props.state && !primary.includes(props.state)) { 715 secondaryParts.push(props.state); 716 } 717 718 // Add country if not in name 719 if (props.country && !primary.includes(props.country)) { 720 secondaryParts.push(props.country); 721 } 722 723 const secondary = secondaryParts.join(', '); 724 725 return { 726 primary: primary, 727 secondary: secondary, 728 label: secondary ? `${primary}, ${secondary}` : primary 729 }; 730 }, 731 732 formatPhotonLabel(props) { 733 const parts = []; 734 if (props.name) parts.push(props.name); 735 if (props.street) parts.push(props.street); 736 if (props.city) parts.push(props.city); 737 if (props.state) parts.push(props.state); 738 if (props.country) parts.push(props.country); 739 return parts.join(', ') || props.name || 'Unknown location'; 740 }, 741 742 async fetchPlaceDetails(suggestion) { 743 try { 744 if (this.locationSearchClient) { 745 return await this.locationSearchClient.resolve(suggestion); 746 } 747 return await this.placeDetailProviders[this.geocodingService].call(this, suggestion); 748 } catch (error) { 749 console.error('Failed to fetch place details:', error); 750 return null; 751 } 752 }, 753 754 async _fetchPlaceDetailsNewAPI(suggestion) { 755 try { 756 const place = new google.maps.places.Place({ 757 id: suggestion.ref 758 }); 759 760 await place.fetchFields({ 761 fields: ['displayName', 'formattedAddress', 'addressComponents', 'location'] 762 }); 763 764 return this.parseGooglePlace(place, 'new'); 765 } catch (error) { 766 console.error('Google New Places API error (place details):', error); 767 console.warn('Falling back to Legacy Places API...'); 768 769 // Switch to legacy API and retry 770 if (typeof google.maps.places.AutocompleteService !== 'undefined' && this.apiType === 'new') { 771 this.apiType = 'legacy'; 772 if (!this.autocompleteService) { 773 this.autocompleteService = new google.maps.places.AutocompleteService(); 774 } 775 if (!this.placesService) { 776 const div = document.createElement('div'); 777 this.placesService = new google.maps.places.PlacesService(div); 778 } 779 780 return await this._fetchPlaceDetailsLegacyAPI(suggestion); 781 } 782 783 return null; 784 } 785 }, 786 787 async _fetchPlaceDetailsLegacyAPI(suggestion) { 788 return new Promise((resolve) => { 789 try { 790 const request = { 791 placeId: suggestion.ref, 792 fields: ['name', 'formatted_address', 'address_components', 'geometry'] 793 }; 794 795 this.placesService.getDetails(request, (place, status) => { 796 if (status === google.maps.places.PlacesServiceStatus.OK) { 797 resolve(this.parseGooglePlace(place, 'legacy')); 798 } else { 799 console.error('Google Places API error:', status); 800 resolve(null); 801 } 802 }); 803 } catch (error) { 804 console.error('Failed to fetch Google place details:', error); 805 resolve(null); 806 } 807 }); 808 }, 809 810 // Provider-specific place detail fetchers 811 placeDetailProviders: { 812 google: async function (suggestion) { 813 if (this.apiType === 'new') { 814 return this._fetchPlaceDetailsNewAPI(suggestion); 815 } else if (this.apiType === 'legacy') { 816 return this._fetchPlaceDetailsLegacyAPI(suggestion); 817 } else { 818 console.error('No Google Places API type detected'); 819 return null; 820 } 821 }, 822 823 nominatim: async function (suggestion) { 824 // Nominatim already provides full details in search response 825 const item = suggestion.data; 826 return { 827 formatted_address: item.display_name, 828 lat: item.lat, 829 lng: item.lon, 830 components: { 831 city: item.address?.city || item.address?.town || item.address?.village, 832 state: item.address?.state, 833 postalcode: item.address?.postcode, 834 country: item.address?.country 835 } 836 }; 837 }, 838 839 mapbox: async function (suggestion) { 840 // Use Search Box API /retrieve to get full details including coordinates 841 const mapboxId = suggestion.ref; 842 843 const params = new URLSearchParams({ 844 access_token: this.mapboxToken, 845 session_token: this.mapboxSessionToken || '' 846 }); 847
848 if (this.geocodeLang && JR_MAPBOX_LANGUAGES.includes(this.geocodeLang)) { 849 params.set('language', this.geocodeLang); 850 } 851 852 const data = await jrFetchLocationJson(`https://api.mapbox.com/search/searchbox/v1/retrieve/${mapboxId}?${params}`); 853 854 if (!data.features || !data.features[0]) { 855 console.error('Mapbox retrieve failed'); 856 return null; 857 } 858 859 const feature = data.features[0]; 860 const coords = feature.geometry.coordinates; 861 const props = feature.properties; 862 863 // Reset session token after retrieve (session complete) 864 this.mapboxSessionToken = null; 865 866 return { 867 formatted_address: props.full_address || props.name, 868 lat: coords[1], // [lng, lat] 869 lng: coords[0], 870 components: { 871 city: props.context?.place?.name, 872 state: props.context?.region?.name, 873 postalcode: props.context?.postcode?.name, 874 country: props.context?.country?.name 875 } 876 }; 877 }, 878 879 arcgis: async function (suggestion) { 880 const url = 'https://geocode.arcgis.com/arcgis/rest/services/World/GeocodeServer/findAddressCandidates'; 881 const params = new URLSearchParams({ 882 magicKey: suggestion.ref, 883 f: 'json', 884 outFields: 'City,Region,Postal,Country' 885 }); 886 887 // ArcGIS localizes findAddressCandidates via langCode (not supported on /suggest). 888 if (this.geocodeLang) { 889 params.set('langCode', this.geocodeLang); 890 } 891 892 const data = await jrFetchLocationJson(`${url}?${params}`); 893 const candidate = data.candidates[0]; 894 895 return { 896 formatted_address: candidate.address, 897 lat: candidate.location.y, 898 lng: candidate.location.x, 899 components: { 900 city: candidate.attributes.City, 901 state: candidate.attributes.Region, 902 postalcode: candidate.attributes.Postal, 903 country: candidate.attributes.Country 904 } 905 }; 906 }, 907 908 photon: function (suggestion) { 909 // Photon already provides full details in search response 910 const feature = suggestion.data; 911 const props = feature.properties; 912 const coords = feature.geometry.coordinates; 913 914 return { 915 formatted_address: this.formatPhotonLabel(props), 916 lat: coords[1], // Photon returns [lng, lat] 917 lng: coords[0], 918 components: { 919 city: props.city, 920 state: props.state, 921 postalcode: props.postcode, 922 country: props.country 923 } 924 }; 925 } 926 }, 927 928 parseGooglePlace(place, apiType) { 929 const components = {}; 930 931 if (apiType === 'new') { 932 // New Place API uses camelCase field names 933 place.addressComponents?.forEach(component => { 934 if (component.types.includes('locality')) { 935 components.city = component.longText; 936 } 937 if (component.types.includes('administrative_area_level_1')) { 938 components.state = component.shortText; 939 } 940 if (component.types.includes('postal_code')) { 941 components.postalcode = component.longText; 942 } 943 if (component.types.includes('country')) {
944 components.country = component.longText; 945 } 946 }); 947 948 return { 949 formatted_address: place.formattedAddress, 950 lat: place.location.lat(), 951 lng: place.location.lng(), 952 components 953 }; 954 } else { 955 // Legacy API uses snake_case field names 956 place.address_components?.forEach(component => { 957 if (component.types.includes('locality')) { 958 components.city = component.long_name; 959 } 960 if (component.types.includes('administrative_area_level_1')) { 961 components.state = component.short_name; 962 } 963 if (component.types.includes('postal_code')) { 964 components.postalcode = component.long_name; 965 } 966 if (component.types.includes('country')) { 967 components.country = component.long_name; 968 } 969 }); 970 971 return { 972 formatted_address: place.formatted_address, 973 lat: place.geometry.location.lat(), 974 lng: place.geometry.location.lng(), 975 components 976 }; 977 } 978 }, 979 980 autofillAddressFields(placeDetails) { 981 const container = document.querySelector(this.parentContainer); 982 if (!container) return; 983 984 // Populate address components 985 Object.keys(placeDetails.components).forEach(component => { 986 const fieldName = this.fieldMappings[component]; 987 if (fieldName && placeDetails.components[component]) { 988 const selector = `[name*="${fieldName}"]`; 989 const field = container.querySelector(selector); 990 if (field && field.type === 'text') { 991 field.value = placeDetails.components[component]; 992 field.dispatchEvent(new Event('input', { bubbles: true })); 993 } 994 } 995 }); 996 997 // Populate coordinates 998 if (this.fieldMappings.latitude && placeDetails.lat) { 999 const latField = container.querySelector(`[name*="${this.fieldMappings.latitude}"]`); 1000 if (latField) { 1001 latField.value = placeDetails.lat; 1002 latField.dispatchEvent(new Event('input', { bubbles: true })); 1003 } 1004 } 1005 if (this.fieldMappings.longitude && placeDetails.lng) { 1006 const lngField = container.querySelector(`[name*="${this.fieldMappings.longitude}"]`); 1007 if (lngField) { 1008 lngField.value = placeDetails.lng; 1009 lngField.dispatchEvent(new Event('input', { bubbles: true })); 1010 } 1011 } 1012 }, 1013 1014 getTargetField() { 1015 const container = this.parentContainer ? document.querySelector(this.parentContainer) : document; 1016 1017 // Try multiple selectors to find the field input 1018 const selectors = [ 1019 `input#listing-${this.fieldName}`, // ID: listing-jr_address 1020 `input[name="fields[${this.fieldName}]"]`, // Name: fields[jr_address] 1021 `[data-jr-field="fields-${this.fieldName}"] input` // Wrapper has data attr, find input inside 1022 ]; 1023 1024 for (const selector of selectors) { 1025 try { 1026 const field = container.querySelector(selector); 1027 if (field) return field; 1028 } catch (e) { 1029 // Invalid selector, continue 1030 } 1031 } 1032 1033 return null; 1034 }, 1035 1036 closeSuggestions() { 1037 this.isOpen = false;
1038 this.selectedIndex = -1; 1039 // Keep suggestions in memory so they can be reopened 1040 }, 1041 1042 clearCoordinates() { 1043 const container = document.querySelector(this.parentContainer); 1044 if (!container) return; 1045 1046 // Clear latitude field 1047 if (this.fieldMappings.latitude) { 1048 const latField = container.querySelector(`[name*="${this.fieldMappings.latitude}"]`); 1049 if (latField) { 1050 latField.value = ''; 1051 latField.dispatchEvent(new Event('input', { bubbles: true })); 1052 } 1053 } 1054 1055 // Clear longitude field 1056 if (this.fieldMappings.longitude) { 1057 const lngField = container.querySelector(`[name*="${this.fieldMappings.longitude}"]`); 1058 if (lngField) { 1059 lngField.value = ''; 1060 lngField.dispatchEvent(new Event('input', { bubbles: true })); 1061 } 1062 } 1063 } 1064 }; 1065 }); 1066} 1067 1068document.addEventListener('alpine:init', jrRegisterLocationAutocomplete); 1069jrRegisterLocationAutocomplete();
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.