PageSourceSearch

https://services.unitedbaristas.com/wp-content/plugins/jreviews_ad…?v=1787990078&ver=5.12.0

js unitedbaristas.com collected 2026-10-04 02:40:16 UTC 48,757 bytes, 1,069 lines download raw bytes

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.