1// Languages the Mapbox Search Box / Geocoding APIs localize results into. 2// Anything outside this set is left unset so Mapbox returns its default rather 3// than erroring. https://docs.mapbox.com/api/search/search-box/ 4// Shared global: both alpine-location-autocomplete.js and alpine-proximity-search.js 5// can load on the same page. Guard the assignment so the second script does not 6// redeclare a lexical `const` (which throws and aborts the whole file). 7window.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']; 8 9/** 10 * Register on whichever comes first. See the matching note in 11 * alpine-location-autocomplete.js: relying on alpine:init alone means this file 12 * has to be on the page before Alpine starts, and arriving any later would leave 13 * every widget that names it inert with nothing to show for it. 14 */ 15function jrRegisterProximitySearch() { 16 if (!window.Alpine || window.jrProximitySearchRegistered) return; 17 window.jrProximitySearchRegistered = true; 18 19 Alpine.data('jrMapsProximitySearch', (config = {}) => { 20 const globalConfig = window.jrMapsAutocomplete || {}; 21 22 return { 23 query: config.address || '', 24 lat: config.lat ?? '', 25 lng: config.lng ?? '', 26 radius: String(config.radius ?? config.defaultRadius ?? 30), 27 defaultRadius: Number(config.defaultRadius ?? 30), 28 autoResolveOnBlur: config.autoResolveOnBlur ?? true, 29 maxRadius: Number(config.maxRadius ?? 100), 30 metric: config.metric || 'km', 31 autocompleteEnabled: config.autocompleteEnabled ?? globalConfig.autocompleteEnabled ?? false, 32 suggestions: [], 33 selectedIndex: -1, 34 isOpen: false, 35 isLoading: false, 36 debounceTimer: null, 37 apiLoaded: false, 38 apiLoading: false, 39 apiType: null, 40 autocompleteService: null, 41 placesService: null, 42 locationSearchClient: null, 43 geocodingService: globalConfig.geocodingService, 44 countryRestriction: config.countryRestriction ?? globalConfig.countryRestriction ?? '', 45 minChars: globalConfig.minChars || 3, 46 debounceDelay: globalConfig.debounceDelay || 600, 47 googleApiKey: globalConfig.googleApiKey || null, 48 googleApiLocale: globalConfig.googleApiLocale || 'en', 49 googleApiUrl: globalConfig.googleApiUrl || null, 50 geocodeLang: globalConfig.geocodeLang || '', 51 mapboxToken: globalConfig.mapboxToken || null, 52 coordinatorScript: globalConfig.coordinatorScript, 53 lang: globalConfig.lang || { 54 placeholder: 'Enter a location', 55 searchPrompt: 'Start typing to search...', 56 noResults: 'No locations found', 57 unavailable: 'Location search is unavailable right now. Please try again shortly.' 58 }, 59 geolocating: false, 60 // The provider did not answer, as distinct from answering with nothing. 61 isUnavailable: false, 62 63 get isActive() { 64 return this.query.trim() !== '' || this.lat !== '' || this.lng !== ''; 65 }, 66 67 radiusLabel() { 68 return `${this.radius} ${this.metric}`; 69 }, 70 71 emptyMessage() { 72 if (this.isLoading) return ''; 73 if (this.isUnavailable) return this.lang.unavailable; 74 return !this.query ? this.lang.searchPrompt : this.lang.noResults; 75 }, 76 async handleFocus() { 77 if (!this.autocompleteEnabled || this.query.length < this.minChars) { 78 return; 79 } 80 81 if (this.suggestions.length > 0) { 82 this.isOpen = true; 83 return; 84 } 85 86 await this.fetchSuggestions(); 87 }, 88 89 async handleBlur(event) { 90 const nextTarget = event.relatedTarget; 91 92 if (nextTarget && nextTarget.closest('[data-jr-proximity-search]')) { 93 return; 94 } 95 96 if (!this.autoResolveOnBlur || !this.query.trim() || (this.lat !== '' && this.lng !== '')) { 97 return; 98 } 99 100 await this.ensureResolvedForSubmit(); 101 }, 102 103 handleInput(event) { 104 this.query = event.target.value; 105 clearTimeout(this.debounceTimer); 106 this.clearCoordinates(false); 107 108 if (this.query.length === 0) { 109 this.closeSuggestions(); 110 this.dispatchChange(); 111 return; 112 } 113 114 if (!this.autocompleteEnabled || this.query.length < this.minChars) { 115 this.closeSuggestions(); 116 return; 117 } 118 119 this.debounceTimer = setTimeout(() => this.fetchSuggestions(), this.debounceDelay); 120 }, 121 122 async handleKeydown(event) { 123 if (this.isOpen && this.suggestions.length > 0) { 124 switch (event.key) { 125 case 'ArrowDown': 126 event.preventDefault(); 127 this.selectedIndex = Math.min(this.selectedIndex + 1, this.suggestions.length - 1); 128 return; 129 case 'ArrowUp': 130 event.preventDefault(); 131 this.selectedIndex = Math.max(this.selectedIndex - 1, 0); 132 return; 133 case 'Enter': 134 event.preventDefault(); 135 if (this.selectedIndex >= 0 && this.selectedIndex < this.suggestions.length) { 136 await this.selectSuggestion(this.suggestions[this.selectedIndex]); 137 } else { 138 await this.resolveAddress(); 139 } 140 return; 141 case 'Escape': 142 this.closeSuggestions(); 143 return; 144 } 145 } 146 147 if (event.key === 'Enter' && this.query.trim() !== '' && (this.lat === '' || this.lng === '')) { 148 event.preventDefault(); 149 await this.resolveAddress(); 150 } 151 }, 152 153 async resolveAddress() { 154 if (this.query.trim().length < this.minChars) { 155 return false;
156 } 157 158 const suggestions = await this.fetchSuggestions(true); 159 160 if (suggestions.length > 0) { 161 await this.selectSuggestion(suggestions[0]); 162 return this.lat !== '' && this.lng !== ''; 163 } 164 165 return false; 166 }, 167 168 async ensureResolvedForSubmit() { 169 if (this.query.trim() === '' || (this.lat !== '' && this.lng !== '')) { 170 return this.lat !== '' && this.lng !== ''; 171 } 172 173 return await this.resolveAddress(); 174 }, 175 176 async loadAPIs() { 177 if (this.apiLoaded) return; 178 if (this._apiLoadPromise) return this._apiLoadPromise; 179 this._apiLoadPromise = this._doLoadAPIs(); 180 return this._apiLoadPromise; 181 }, 182 183 async _doLoadAPIs() { 184 this.apiLoading = true; 185 186 if (this.geocodingService === 'google' && this.googleApiKey) { 187 if (typeof window.jrMapsApi === 'undefined' && this.coordinatorScript) { 188 await this.loadScript(this.coordinatorScript); 189 } 190 191 if (typeof google !== 'undefined' && typeof google.maps !== 'undefined' && typeof google.maps.places !== 'undefined') { 192 this.apiType = this.detectGooglePlacesAPI(); 193 } else if (window.jrMapsApi) { 194 await window.jrMapsApi.loadGoogle(this.googleApiKey, { 195 libraries: ['places'], 196 locale: this.googleApiLocale, 197 apiUrl: this.googleApiUrl 198 }); 199 this.apiType = this.detectGooglePlacesAPI(); 200 } 201 202 if (this.apiType === 'legacy') { 203 this.ensureLegacyGoogleServices(); 204 } 205 } 206 207 this.apiLoaded = true; 208 this.apiLoading = false; 209 }, 210 211 /** 212 * Load a file on demand. See the matching note in 213 * alpine-location-autocomplete.js: a tag being present is not the 214 * same as the file having run, and two location widgets on one page 215 * is the ordinary case that exposed it. 216 */ 217 loadScript(url) { 218 if (window.jrMapsApi?.ensureScript) { 219 return window.jrMapsApi.ensureScript(url); 220 } 221 222 if (!url) return Promise.reject(new Error('A script URL is required.')); 223 224 window.jrMapsCoordinatorPromise = window.jrMapsCoordinatorPromise 225 || new Promise((resolve, reject) => { 226 if (window.jrMapsApi) return resolve(); 227 228 const existing = document.querySelector(`script[src="${url}"]`); 229 const script = existing || document.createElement('script'); 230 231 script.addEventListener('load', () => resolve(), { once: true }); 232 script.addEventListener('error', reject, { once: true }); 233 234 if (existing) { 235 if (window.jrMapsApi) return resolve(); 236 window.addEventListener('load', () => resolve(), { once: true }); 237 return; 238 } 239 240 script.src = url; 241 script.async = false; 242 document.head.appendChild(script); 243 }); 244 245 return window.jrMapsCoordinatorPromise; 246 }, 247 248 detectGooglePlacesAPI() { 249 if (typeof google === 'undefined' || typeof google.maps === 'undefined' || typeof google.maps.places === 'undefined') { 250 return null; 251 } 252 253 if ( 254 typeof google.maps.places.AutocompleteSuggestion !== 'undefined' 255 && typeof google.maps.places.Place !== 'undefined' 256 && typeof google.maps.places.AutocompleteSuggestion.fetchAutocompleteSuggestions === 'function' 257 ) { 258 try { 259 const place = new google.maps.places.Place({ id: 'test' }); 260 if (typeof place.fetchFields === 'function') { 261 return 'new'; 262 } 263 } catch (e) {} 264 } 265 266 return typeof google.maps.places.AutocompleteService !== 'undefined' ? 'legacy' : null; 267 }, 268 269 effectiveGoogleApiType() { 270 return this.apiType; 271 }, 272 273 ensureLegacyGoogleServices() { 274 if ( 275 typeof google === 'undefined' 276 || typeof google.maps === 'undefined' 277 || typeof google.maps.places === 'undefined' 278 || typeof google.maps.places.AutocompleteService === 'undefined' 279 ) { 280 return false;
281 } 282 283 if (!this.autocompleteService) { 284 this.autocompleteService = new google.maps.places.AutocompleteService(); 285 } 286 287 if (!this.placesService) { 288 const div = document.createElement('div'); 289 this.placesService = new google.maps.places.PlacesService(div); 290 } 291 292 return true; 293 }, 294 295 async fetchSuggestions(returnResults = false) { 296 this.isLoading = true; 297 this.isOpen = true; 298 299 try { 300 this.locationSearchClient ||= window.jrMapsLocationSearch?.create({ 301 ...globalConfig, 302 countryRestriction: this.countryRestriction 303 }); 304 const provider = this.suggestionProviders[this.geocodingService]; 305 this.suggestions = this.locationSearchClient 306 ? await this.locationSearchClient.suggest(this.query, { explicit: returnResults }) 307 : provider ? await provider.call(this, this.query) : []; 308 // The provider answered; an empty list is the honest empty state. 309 this.isUnavailable = false; 310 this.selectedIndex = -1; 311 } catch (error) { 312 console.error('Failed to fetch proximity suggestions:', error); 313 // A throw means it did not answer. Closing silently left the 314 // visitor with no explanation at all. 315 this.suggestions = []; 316 this.selectedIndex = -1; 317 this.isUnavailable = true; 318 this.isOpen = true; 319 window.jrMapsLocationSearch?.report?.(error, this.geocodingService); 320 } finally { 321 this.isLoading = false; 322 } 323 324 return returnResults ? this.suggestions : undefined; 325 }, 326 327 async selectSuggestion(suggestion) { 328 const placeDetails = await this.fetchPlaceDetails(suggestion); 329 this.query = placeDetails?.formatted_address || suggestion.label; 330 this.lat = placeDetails?.lat ?? ''; 331 this.lng = placeDetails?.lng ?? ''; 332 this.closeSuggestions(); 333 this.dispatchChange(); 334 }, 335 336 async fetchPlaceDetails(suggestion) { 337 try { 338 if (this.locationSearchClient) { 339 return await this.locationSearchClient.resolve(suggestion); 340 } 341 const provider = this.placeDetailProviders[this.geocodingService]; 342 return provider ? await provider.call(this, suggestion) : null; 343 } catch (error) { 344 console.error('Failed to fetch proximity place details:', error); 345 return null; 346 } 347 }, 348 349 closeSuggestions() { 350 this.isOpen = false; 351 this.selectedIndex = -1; 352 }, 353 354 clearCoordinates(dispatch = true) { 355 this.lat = ''; 356 this.lng = ''; 357 if (dispatch) this.dispatchChange(); 358 }, 359 360 clear() { 361 this.query = ''; 362 this.radius = String(this.defaultRadius); 363 this.clearCoordinates(false); 364 const root = this.$root || this.$el || null; 365 if (root) { 366 ['geosearch[address]', 'geosearch[lat]', 'geosearch[lng]', 'geosearch[radius]'].forEach(name => { 367 root.querySelectorAll(`input[name="${name}"]`).forEach(input => { 368 input.value = ''; 369 }); 370 }); 371 } 372 if (this.$refs?.address) { 373 this.$refs.address.value = ''; 374 } 375 this.closeSuggestions(); 376 this.dispatchChange(); 377 }, 378 379 async useMyLocation() { 380 if (this.geolocating) return; 381 382 if (!navigator.geolocation) { 383 console.warn('Geolocation is not supported in this browser.'); 384 return; 385 } 386 387 this.geolocating = true; 388 this.isLoading = true; 389 this.closeSuggestions(); 390 391 try { 392 const position = await new Promise((resolve, reject) => { 393 navigator.geolocation.getCurrentPosition(resolve, reject, {
394 enableHighAccuracy: false, 395 timeout: 10000, 396 maximumAge: 60000, 397 }); 398 }); 399 400 const lat = position.coords.latitude; 401 const lng = position.coords.longitude; 402 403 if (!this.apiLoaded) { 404 await this.loadAPIs(); 405 } 406 407 let address = ''; 408 try { 409 const provider = this.reverseGeocodeProviders[this.geocodingService]; 410 address = provider ? await provider.call(this, lat, lng) : ''; 411 } catch (error) { 412 console.error('Reverse geocoding failed:', error); 413 } 414 415 this.lat = lat; 416 this.lng = lng; 417 this.query = address || `${lat.toFixed(5)}, ${lng.toFixed(5)}`; 418 this.dispatchChange(); 419 } catch (error) { 420 console.error('Geolocation error:', error); 421 const messages = { 422 1: this.lang.geolocationDenied || 'Location permission denied. Please allow location access in your browser.', 423 2: this.lang.geolocationUnavailable || 'Could not determine your location. Please try again or enter an address manually.', 424 3: this.lang.geolocationTimeout || 'Location request timed out. Please try again.', 425 }; 426 const message = messages[error?.code] || (this.lang.geolocationFailed || 'Could not get your location.'); 427 if (typeof window.alert === 'function') { 428 window.alert(message); 429 } 430 } finally { 431 this.geolocating = false; 432 this.isLoading = false; 433 } 434 }, 435 436 reverseGeocodeProviders: { 437 google: async function(lat, lng) { 438 if (typeof google === 'undefined' || !google.maps?.Geocoder) return ''; 439 const geocoder = new google.maps.Geocoder(); 440 return new Promise(resolve => { 441 geocoder.geocode({ location: { lat, lng } }, (results, status) => { 442 resolve(status === 'OK' && results?.[0] ? results[0].formatted_address : ''); 443 }); 444 }); 445 }, 446 nominatim: async function(lat, lng) { 447 const url = new URL('https://nominatim.openstreetmap.org/reverse'); 448 url.searchParams.set('lat', lat); 449 url.searchParams.set('lon', lng); 450 url.searchParams.set('format', 'json'); 451 if (this.geocodeLang) url.searchParams.set('accept-language', this.geocodeLang); 452 const response = await fetch(url); 453 const data = await response.json(); 454 return data?.display_name || ''; 455 }, 456 mapbox: async function(lat, lng) { 457 const params = new URLSearchParams({ 458 longitude: lng, 459 latitude: lat, 460 access_token: this.mapboxToken, 461 limit: 1, 462 }); 463 if (this.geocodeLang && JR_MAPBOX_LANGUAGES.includes(this.geocodeLang)) params.set('language', this.geocodeLang); 464 const response = await fetch(`https://api.mapbox.com/search/geocode/v6/reverse?${params}`); 465 const data = await response.json(); 466 const props = data?.features?.[0]?.properties; 467 return props?.full_address || props?.name || ''; 468 }, 469 arcgis: async function(lat, lng) { 470 const params = new URLSearchParams({ 471 location: `${lng},${lat}`, 472 f: 'json', 473 });
474 if (this.geocodeLang) params.set('langCode', this.geocodeLang); 475 const response = await fetch(`https://geocode.arcgis.com/arcgis/rest/services/World/GeocodeServer/reverseGeocode?${params}`); 476 const data = await response.json(); 477 return data?.address?.LongLabel || data?.address?.Match_addr || ''; 478 }, 479 photon: async function(lat, lng) { 480 const url = new URL('https://photon.komoot.io/reverse'); 481 url.searchParams.set('lat', lat); 482 url.searchParams.set('lon', lng); 483 url.searchParams.set('limit', 1); 484 if (this.geocodeLang) url.searchParams.set('lang', this.geocodeLang); 485 const response = await fetch(url); 486 const data = await response.json(); 487 const feature = data?.features?.[0]; 488 if (!feature) return ''; 489 const props = feature.properties || {}; 490 return [props.name, props.city, props.state, props.country].filter(Boolean).join(', '); 491 }, 492 }, 493 494 dispatchChange() { 495 this.$nextTick(() => { 496 if (this.$refs.address) { 497 this.$refs.address.dispatchEvent(new Event('change', { bubbles: true })); 498 } 499 }); 500 }, 501 502 async _fetchSuggestionsNewAPI(query) { 503 const request = { input: query }; 504 if (this.countryRestriction) request.includedRegionCodes = [this.countryRestriction]; 505 const { suggestions } = await google.maps.places.AutocompleteSuggestion.fetchAutocompleteSuggestions(request); 506 return suggestions.map(suggestion => { 507 const pred = suggestion.placePrediction; 508 return { 509 id: pred.placeId, 510 label: pred.text.text, 511 ref: pred.placeId, 512 primary: pred.structuredFormat?.mainText?.text || pred.text.text, 513 secondary: pred.structuredFormat?.secondaryText?.text || '' 514 }; 515 }); 516 }, 517 518 async _fetchSuggestionsLegacyAPI(query) { 519 return new Promise(resolve => { 520 const request = { input: query }; 521 if (this.countryRestriction) request.componentRestrictions = { country: this.countryRestriction }; 522 this.autocompleteService.getPlacePredictions(request, (predictions, status) => { 523 if (status === google.maps.places.PlacesServiceStatus.OK && predictions) { 524 resolve(predictions.map(prediction => ({ 525 id: prediction.place_id, 526 label: prediction.description, 527 ref: prediction.place_id, 528 primary: prediction.structured_formatting?.main_text || prediction.description, 529 secondary: prediction.structured_formatting?.secondary_text || '' 530 }))); 531 return; 532 } 533 resolve([]); 534 }); 535 }); 536 }, 537 538 suggestionProviders: { 539 google: async function(query) { 540 const apiType = this.effectiveGoogleApiType(); 541 if (apiType === 'new') return this._fetchSuggestionsNewAPI(query); 542 if (apiType === 'legacy') return this._fetchSuggestionsLegacyAPI(query); 543 return []; 544 }, 545 nominatim: async function(query) { 546 const url = new URL('https://nominatim.openstreetmap.org/search'); 547 url.searchParams.set('q', query); 548 url.searchParams.set('format', 'json'); 549 url.searchParams.set('limit', 10); 550 url.searchParams.set('addressdetails', 1); 551 if (this.countryRestriction) url.searchParams.set('countrycodes', this.countryRestriction);
552 if (this.geocodeLang) url.searchParams.set('accept-language', this.geocodeLang); 553 const response = await fetch(url); 554 const data = await response.json(); 555 const skipTypes = ['yes', 'house', 'residential', 'building', 'address', 'apartments', 'detached', 'terrace']; 556 return data.map(item => { 557 const parts = item.display_name.split(',').map(p => p.trim()); 558 let primary = parts[0] || item.display_name; 559 const secondaryParts = []; 560 const county = item.address?.county; 561 if (county && !primary.includes(county)) { 562 primary = `${primary}, ${county}`; 563 } 564 if (item.type && !skipTypes.includes(item.type)) { 565 secondaryParts.push(item.type); 566 } 567 if (item.address?.state && !primary.includes(item.address.state)) { 568 secondaryParts.push(item.address.state); 569 } 570 if (item.address?.country && !primary.includes(item.address.country)) { 571 secondaryParts.push(item.address.country); 572 } 573 return { 574 id: item.place_id, 575 label: item.display_name, 576 ref: item, 577 data: item, 578 primary, 579 secondary: secondaryParts.join(', ') 580 }; 581 }); 582 }, 583 mapbox: async function(query) { 584 if (!this.mapboxSessionToken) this.mapboxSessionToken = crypto.randomUUID(); 585 const params = new URLSearchParams({ 586 q: query, 587 access_token: this.mapboxToken, 588 session_token: this.mapboxSessionToken, 589 limit: 10 590 }); 591 if (this.countryRestriction) params.set('country', this.countryRestriction); 592 if (this.geocodeLang && JR_MAPBOX_LANGUAGES.includes(this.geocodeLang)) params.set('language', this.geocodeLang); 593 const response = await fetch(`https://api.mapbox.com/search/searchbox/v1/suggest?${params}`); 594 const data = await response.json(); 595 return (data.suggestions || []).map((suggestion, index) => { 596 let primary = suggestion.name || suggestion.full_address; 597 const secondaryParts = []; 598 const place = suggestion.context?.place?.name; 599 if (place && !primary.includes(place)) { 600 primary = `${primary}, ${place}`; 601 } 602 if (suggestion.poi_category && suggestion.poi_category.length) { 603 secondaryParts.push(suggestion.poi_category[0]); 604 } else if (suggestion.feature_type && suggestion.feature_type !== 'address') { 605 secondaryParts.push(suggestion.feature_type); 606 } 607 if (suggestion.context?.region?.name && !primary.includes(suggestion.context.region.name)) { 608 secondaryParts.push(suggestion.context.region.name); 609 } 610 if (suggestion.context?.country?.name && !primary.includes(suggestion.context.country.name)) { 611 secondaryParts.push(suggestion.context.country.name); 612 } 613 return { 614 id: suggestion.mapbox_id || `mapbox-${index}`, 615 label: suggestion.full_address || suggestion.name, 616 ref: suggestion.mapbox_id, 617 data: suggestion, 618 primary, 619 secondary: secondaryParts.join(', ') 620 }; 621 }); 622 }, 623 arcgis: async function(query) { 624 const params = new URLSearchParams({ text: query, f: 'json', maxSuggestions: 10 }); 625 if (this.countryRestriction) params.set('countryCode', this.countryRestriction); 626 const response = await fetch(`https://geocode.arcgis.com/arcgis/rest/services/World/GeocodeServer/suggest?${params}`); 627 const data = await response.json(); 628 return (data.suggestions || []).map(suggestion => { 629 const parts = suggestion.text.split(',').map(p => p.trim()); 630 return { 631 id: suggestion.magicKey, 632 label: suggestion.text, 633 ref: suggestion.magicKey, 634 primary: parts[0] || suggestion.text, 635 secondary: parts.slice(1).join(', ') 636 }; 637 }); 638 }, 639 photon: async function(query) { 640 const url = new URL('https://photon.komoot.io/api/'); 641 url.searchParams.set('q', query); 642 url.searchParams.set('limit', 10);
643 if (this.geocodeLang) url.searchParams.set('lang', this.geocodeLang); 644 const response = await fetch(url); 645 const data = await response.json(); 646 const restrict = (this.countryRestriction || '').toUpperCase(); 647 const features = restrict 648 ? (data.features || []).filter(f => ((f.properties || {}).countrycode || '').toUpperCase() === restrict) 649 : (data.features || []); 650 const skipTypes = ['yes', 'house', 'residential', 'building', 'address', 'apartments', 'detached', 'terrace']; 651 return features.map((feature, index) => { 652 const props = feature.properties || {}; 653 let primary = props.name || 'Unknown location'; 654 const secondaryParts = []; 655 656 if (props.county && !primary.includes(props.county)) { 657 primary = `${primary}, ${props.county}`; 658 } 659 660 const type = props.osm_value || props.type; 661 if (type && !skipTypes.includes(type)) { 662 secondaryParts.push(type); 663 } 664 665 if (props.state && !primary.includes(props.state)) { 666 secondaryParts.push(props.state); 667 } 668 if (props.country && !primary.includes(props.country)) { 669 secondaryParts.push(props.country); 670 } 671 672 const secondary = secondaryParts.join(', '); 673 const labelParts = [props.name].filter(Boolean); 674 if (props.state) labelParts.push(props.state); 675 if (props.country) labelParts.push(props.country); 676 return { 677 id: `photon-${props.osm_id || ''}-${index}`, 678 label: labelParts.join(', ') || primary, 679 ref: feature, 680 data: feature, 681 primary, 682 secondary 683 }; 684 }); 685 } 686 }, 687 688 async _fetchPlaceDetailsNewAPI(suggestion) { 689 const place = new google.maps.places.Place({ id: suggestion.ref }); 690 await place.fetchFields({ fields: ['displayName', 'formattedAddress', 'location'] }); 691 return { 692 formatted_address: place.formattedAddress, 693 lat: place.location.lat(), 694 lng: place.location.lng() 695 }; 696 }, 697 698 async _fetchPlaceDetailsLegacyAPI(suggestion) { 699 return new Promise(resolve => { 700 this.placesService.getDetails({ 701 placeId: suggestion.ref, 702 fields: ['formatted_address', 'geometry'] 703 }, (place, status) => { 704 if (status === google.maps.places.PlacesServiceStatus.OK) { 705 resolve({ 706 formatted_address: place.formatted_address, 707 lat: place.geometry.location.lat(), 708 lng: place.geometry.location.lng() 709 }); 710 return; 711 } 712 resolve(null); 713 }); 714 }); 715 }, 716 717 placeDetailProviders: { 718 google: async function(suggestion) { 719 const apiType = this.effectiveGoogleApiType(); 720 if (apiType === 'new') return this._fetchPlaceDetailsNewAPI(suggestion); 721 if (apiType === 'legacy') return this._fetchPlaceDetailsLegacyAPI(suggestion); 722 return null; 723 }, 724 nominatim: async function(suggestion) { 725 const item = suggestion.data; 726 return { 727 formatted_address: item.display_name, 728 lat: item.lat, 729 lng: item.lon 730 }; 731 }, 732 mapbox: async function(suggestion) { 733 const params = new URLSearchParams({ 734 access_token: this.mapboxToken, 735 session_token: this.mapboxSessionToken || '' 736 });
737 if (this.geocodeLang && JR_MAPBOX_LANGUAGES.includes(this.geocodeLang)) params.set('language', this.geocodeLang); 738 const response = await fetch(`https://api.mapbox.com/search/searchbox/v1/retrieve/${suggestion.ref}?${params}`); 739 const data = await response.json(); 740 const feature = data.features?.[0]; 741 if (!feature) return null; 742 this.mapboxSessionToken = null; 743 return { 744 formatted_address: feature.properties?.full_address || feature.properties?.name || suggestion.label, 745 lat: feature.geometry.coordinates[1], 746 lng: feature.geometry.coordinates[0] 747 }; 748 }, 749 arcgis: async function(suggestion) { 750 const params = new URLSearchParams({ magicKey: suggestion.ref, f: 'json' }); 751 if (this.geocodeLang) params.set('langCode', this.geocodeLang); 752 const response = await fetch(`https://geocode.arcgis.com/arcgis/rest/services/World/GeocodeServer/findAddressCandidates?${params}`); 753 const data = await response.json(); 754 const candidate = data.candidates?.[0]; 755 if (!candidate) return null; 756 return { 757 formatted_address: candidate.address, 758 lat: candidate.location.y, 759 lng: candidate.location.x 760 }; 761 }, 762 photon: async function(suggestion) { 763 const feature = suggestion.data; 764 return { 765 formatted_address: suggestion.label, 766 lat: feature.geometry.coordinates[1], 767 lng: feature.geometry.coordinates[0] 768 }; 769 } 770 } 771 }; 772 }); 773} 774 775document.addEventListener('alpine:init', jrRegisterProximitySearch); 776jrRegisterProximitySearch(); 777 778(() => { 779 window.jrFieldAdapters = window.jrFieldAdapters || []; 780 781 if (window.jrFieldAdapters.some(adapter => adapter && adapter.id === 'maps-proximity-geosearch')) { 782 return; 783 } 784 785 const fieldMappings = () => window.jrMapsAutocomplete?.fieldMappings || {}; 786 const latField = () => fieldMappings().latitude || 'jr_latitude'; 787 const lngField = () => fieldMappings().longitude || 'jr_longitude'; 788 const clearNames = ['geosearch[address]', 'geosearch[lat]', 'geosearch[lng]', 'geosearch[radius]']; 789 790 const matchesFieldName = name => { 791 const value = String(name || ''); 792 return value === 'geosearch' || value.startsWith('geosearch['); 793 }; 794 795 const getRoot = (form, target = null) => { 796 return target?.closest?.('[data-jr-proximity-search]') 797 || form?.querySelector?.('[data-jr-proximity-search]') 798 || null; 799 }; 800 801 const getData = root => { 802 if (!root) return null; 803 if (root._x_dataStack?.[0]) return root._x_dataStack[0]; 804 if (typeof Alpine !== 'undefined' && typeof Alpine.$data === 'function') return Alpine.$data(root); 805 return null; 806 }; 807 808 const clearNamedField = (form, name) => { 809 Array.from(form?.elements || []) 810 .filter(element => element.name === name) 811 .forEach(element => { 812 if (element.type === 'checkbox' || element.type === 'radio') { 813 element.checked = false; 814 } else { 815 element.value = ''; 816 } 817 }); 818 }; 819 820 const resetComponentState = data => { 821 if (!data) return; 822 data.query = ''; 823 data.lat = ''; 824 data.lng = ''; 825 data.radius = String(data.defaultRadius ?? data.radius ?? 30); 826 if (typeof data.closeSuggestions === 'function') data.closeSuggestions(); 827 }; 828 829 const clearKeys = () => { 830 const keys = [latField(), lngField(), 'jr_radius', 'location']; 831 const order = new URL(window.location.href).searchParams.get('order'); 832 if (order === 'distance') keys.push('order'); 833 return keys; 834 }; 835 836 window.jrFieldAdapters.push({ 837 id: 'maps-proximity-geosearch', 838 rootFieldName: 'geosearch', 839 matchesFieldName, 840 getCarryForwardKeys() { 841 return ['location']; 842 }, 843 getClearKeys: clearKeys, 844 shouldHandleInput({ event, form }) { 845 if (event?.target?.name === 'geosearch[address]') return false;
846 847 const root = getRoot(form, event?.target); 848 if (root && root.contains(event?.target)) { 849 const latInput = root.querySelector('input[name="geosearch[lat]"]'); 850 const lngInput = root.querySelector('input[name="geosearch[lng]"]'); 851 if (!(latInput?.value && lngInput?.value)) return false; 852 } 853 854 return null; 855 }, 856 shouldApplyChange({ event, form }) { 857 const root = getRoot(form, event?.target); 858 const latInput = root?.querySelector?.('input[name="geosearch[lat]"]') || form?.querySelector?.('input[name="geosearch[lat]"]'); 859 const lngInput = root?.querySelector?.('input[name="geosearch[lng]"]') || form?.querySelector?.('input[name="geosearch[lng]"]'); 860 const hasCoords = !!(latInput?.value && lngInput?.value); 861 862 if (event?.target?.name === 'geosearch[address]') { 863 const hasAddress = !!String(event.target.value || '').trim(); 864 if (hasAddress && !hasCoords) return false; 865 return true; 866 } 867 868 if (root && root.contains(event?.target) && !hasCoords) { 869 return false; 870 } 871 872 return null; 873 }, 874 clear(form, { target = null } = {}) { 875 const root = getRoot(form, target); 876 const data = getData(root); 877 878 if (data) { 879 resetComponentState(data); 880 } 881 882 clearNames.forEach(name => clearNamedField(form, name)); 883 884 return clearKeys(); 885 }, 886 syncParams(params, { form, target = null } = {}) { 887 const root = getRoot(form, target); 888 const data = getData(root); 889 const addressInput = root?.querySelector?.('input[name="geosearch[address]"]') || form?.querySelector?.('input[name="geosearch[address]"]'); 890 const latInput = root?.querySelector?.('input[name="geosearch[lat]"]') || form?.querySelector?.('input[name="geosearch[lat]"]'); 891 const lngInput = root?.querySelector?.('input[name="geosearch[lng]"]') || form?.querySelector?.('input[name="geosearch[lng]"]'); 892 const radiusInput = root?.querySelector?.('input[name="geosearch[radius]"]') || form?.querySelector?.('input[name="geosearch[radius]"]'); 893 894 const address = String(data?.query ?? addressInput?.value ?? '').trim(); 895 const lat = String(data?.lat ?? latInput?.value ?? '').trim(); 896 const lng = String(data?.lng ?? lngInput?.value ?? '').trim(); 897 const radius = String(data?.radius ?? radiusInput?.value ?? '').trim(); 898 899 delete params[latField()]; 900 delete params[lngField()]; 901 delete params.jr_radius; 902 delete params.location; 903 904 if (!lat || !lng) { 905 if (String(params.order || '') === 'distance') { 906 delete params.order; 907 } 908 return; 909 } 910 911 params[latField()] = lat; 912 params[lngField()] = lng; 913 params.jr_radius = radius || String(data?.defaultRadius ?? ''); 914 915 if (address) { 916 params.location = address; 917 } 918 919 if (!params.order) { 920 params.order = 'distance'; 921 } 922 }, 923 async serialize(collected, { form, resolve = false } = {}) { 924 let address = (collected['geosearch[address]'] || [])[0] || ''; 925 let lat = (collected['geosearch[lat]'] || [])[0] || ''; 926 let lng = (collected['geosearch[lng]'] || [])[0] || ''; 927 let radius = (collected['geosearch[radius]'] || [])[0] || ''; 928 929 if (resolve && address && (!lat || !lng)) { 930 const root = getRoot(form); 931 const data = getData(root); 932 933 if (data?.ensureResolvedForSubmit) { 934 await data.ensureResolvedForSubmit(); 935 address = data.query ? String(data.query) : ''; 936 lat = data.lat !== '' && data.lat != null ? String(data.lat) : ''; 937 lng = data.lng !== '' && data.lng != null ? String(data.lng) : ''; 938 radius = data.radius !== '' && data.radius != null ? String(data.radius) : radiu
938s; 939 } 940 } 941 942 delete collected['geosearch[address]']; 943 delete collected['geosearch[lat]']; 944 delete collected['geosearch[lng]']; 945 delete collected['geosearch[radius]']; 946 947 if (lat && lng) { 948 collected[latField()] = [lat]; 949 collected[lngField()] = [lng]; 950 if (radius) collected.jr_radius = [radius]; 951 if (address) collected.location = [address]; 952 if (!collected.order || !collected.order[0]) collected.order = ['distance']; 953 } else { 954 delete collected[latField()]; 955 delete collected[lngField()]; 956 delete collected.jr_radius; 957 delete collected.location; 958 if ((collected.order || [])[0] === 'distance') delete collected.order; 959 } 960 961 return collected; 962 }, 963 }); 964})();
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.