PageSourceSearch

https://www.hot-dinners.com/components/com_jreviews_addons/maps/resources/js/location-search-client.js?5.11.1

js hot-dinners.com collected 2026-10-02 04:06:42 UTC 24,292 bytes, 471 lines download raw bytes

1/**
2 * Provider-neutral place search client.
3 *
4 * Provider responses are normalized to:
5 * { id, label, primary, secondary, lat, lng, bounds, components }
6 */
7(function (window) {
8    'use strict';
9
10    if (window.jrMapsLocationSearch) return;
11
12    const 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'];
13    const GENERIC_LOCATION_TYPES = new Set(['yes', 'house', 'residential', 'building', 'address', 'apartments', 'detached', 'terrace']);
14    const scriptPromises = new Map();
15    // A class being present in the weekly Maps JavaScript payload does not mean
16    // the key is entitled to call that API. Cache only a mode that completed a
17    // real autocomplete request successfully, scoped to the browser-visible key.
18    const verifiedGoogleApiTypes = new Map();
19
20    function loadScript(url) {
21        if (!url) return Promise.reject(new Error('A script URL is required.'));
22        if (scriptPromises.has(url)) return scriptPromises.get(url);
23
24        const promise = new Promise((resolve, reject) => {
25            const existing = document.querySelector(`script[src="${url}"]`);
26            if (existing) {
27                if (existing.dataset.jrLoaded === 'true') return resolve();
28                existing.addEventListener('load', resolve, { once: true });
29                existing.addEventListener('error', reject, { once: true });
30                return;
31            }
32
33            const script = document.createElement('script');
34            script.src = url;
35            script.async = true;
36            script.addEventListener('load', () => {
37                script.dataset.jrLoaded = 'true';
38                resolve();
39            }, { once: true });
40            script.addEventListener('error', reject, { once: true });
41            document.head.appendChild(script);
42        });
43
44        scriptPromises.set(url, promise);
45        return promise;
46    }
47
48    function uuid() {
49        return window.crypto?.randomUUID?.() || `${Date.now()}-${Math.random().toString(16).slice(2)}`;
50    }
51
52    function parts(label) {
53        const values = String(label || '').split(',').map(value => value.trim()).filter(Boolean);
54        return { primary: values[0] || label || '', secondary: values.slice(1).join(', ') };
55    }
56
57    function countries(value) {
58        return String(value || '')
59            .split(',')
60            .map(country => country.trim().toLowerCase())
61            .filter(Boolean);
62    }
63
64    function suggestionLimit(value, fallback = 10) {
65        const limit = Number(value);
66        return Number.isInteger(limit) && limit > 0 ? Math.min(limit, 50) : fallback;
67    }
68
69    function photonType(properties) {
70        return properties.osm_value || properties.type || properties.osm_type || 'address';
71    }
72
73    function formatPhotonSuggestion(properties) {
74        const name = properties.name || 'Unknown location';
75        const county = properties.county;
76        const primary = county && !name.includes(county) ? `${name}, ${county}` : name;
77        const secondary = [];
78        const type = photonType(properties);
79
80        if (type && !GENERIC_LOCATION_TYPES.has(type)) secondary.push(type);
81        if (properties.state && !primary.includes(properties.state)) secondary.push(properties.state);
82        if (properties.country && !primary.includes(properties.country)) secondary.push(properties.country);
83
84        return {
85            primary,
86            secondary: secondary.join(', '),
87            label: [primary, ...secondary].join(', ')
88        };
89    }
90
91    function componentsFromGoogle(items, modern) {
92        const result = {};
93        (items || []).forEach(component => {
94            const long = modern ? component.longText : component.long_name;
95            const short = modern ? component.shortText : component.short_name;
96            if (component.types.includes('street_number')) result.streetNumber = long;
97            if (component.types.includes('route')) result.street = long;
98            if (component.types.includes('locality')) result.city = long;
99            if (!result.city && component.types.includes('postal_town')) result.city = long;
100            if (component.types.includes('administrative_area_level_1')) result.state = short || long;
101            if (component.types.includes('postal_code')) result.postalcode = long;
102            if (component.types.includes('country')) result.country = long;
103        });
104        result.address = [result.streetNumber, result.street].filter(
104Boolean).join(' ');
105        return result;
106    }
107
108    class LocationSearchClient {
109        constructor(config = {}) {
110            this.config = config;
111            this.provider = config.geocodingService || 'nominatim';
112            this.countryCodes = countries(config.countryRestriction);
113            this.limit = suggestionLimit(config.suggestionLimit);
114            this.mapboxSessionToken = null;
115            this.googleNewSessionToken = null;
116            this.googleLegacySessionToken = null;
117            this.googleModeKey = config.googleApiKey || '__loaded-google-api__';
118            this.googleApiType = verifiedGoogleApiTypes.get(this.googleModeKey) || null;
119            this.googleAutocomplete = null;
120            this.googlePlaces = null;
121        }
122
123        get supportsSuggestions() {
124            // The public Nominatim usage policy forbids autocomplete requests.
125            return this.provider !== 'nominatim';
126        }
127
128        async ensureReady() {
129            if (this.provider !== 'google') return;
130
131            // Legacy pages already load Google before constructing their provider.
132            // Do not require the modern coordinator in that case.
133            if (!window.google?.maps?.places) {
134                if (!window.jrMapsApi) await loadScript(this.config.coordinatorScript);
135                await window.jrMapsApi.loadGoogle(this.config.googleApiKey, {
136                    libraries: ['places'],
137                    locale: this.config.googleApiLocale || 'en',
138                    apiUrl: this.config.googleApiUrl
139                });
140            }
141
142            const places = window.google?.maps?.places;
143            if (places?.AutocompleteService) {
144                this.googleAutocomplete ||= new places.AutocompleteService();
145                this.googlePlaces ||= new places.PlacesService(document.createElement('div'));
146            }
147            if (!places?.AutocompleteSuggestion?.fetchAutocompleteSuggestions && !this.googleAutocomplete) {
148                throw new Error('Google Places could not be loaded.');
149            }
150        }
151
152        async suggest(query, options = {}) {
153            const value = String(query || '').trim();
154            if (!value) return [];
155            if (!this.supportsSuggestions && !options.explicit) return [];
156            await this.ensureReady();
157            const provider = this[`suggest_${this.provider}`];
158            if (!provider) throw new Error(`Unsupported geocoder: ${this.provider}`);
159            return provider.call(this, value, options);
160        }
161
162        async resolve(suggestion) {
163            await this.ensureReady();
164            const provider = this[`resolve_${this.provider}`];
165            if (!provider) throw new Error(`Unsupported geocoder: ${this.provider}`);
166            const result = await provider.call(this, suggestion);
167            return result ? { ...suggestion, ...result, label: result.label || result.formatted_address || suggestion.label } : null;
168        }
169
170        async search(query) {
171            const suggestions = await this.suggest(query, { explicit: true });
172            return suggestions[0] ? this.resolve(suggestions[0]) : null;
173        }
174
175        verifyGoogleApiType(type) {
176            this.googleApiType = type;
177            verifiedGoogleApiTypes.set(this.googleModeKey, type);
178        }
179
180        isGoogleEntitlementError(error) {
181            const code = String(error?.code || error?.status || '').toLowerCase();
182            const message = `${code} ${String(error?.message || error || '')}`.toLowerCase();
183            if (Number(error?.code) === 7) return true;
184            return message.includes('request_denied')
185                || message.includes('permission_denied')
186                || message.includes('api_not_activated')
187                || message.includes('apinotactivatedmaperror')
188                || message.includes('requests to this api')
189                || message.includes('blocked')
190                || message.includes('legacy api')
191                || message.includes('not enabled')
192                || message.includes('disabled')
193                || message.includes('has not been used')
194                || message.includes('not authorized')
195                || message.includes('unauthorized')
196                || message.includes('permission')
197                || message.includes('access not configured')
198                || message.includes('forbidden')
199                || message.includes('403')
200                || message.includes('rpcerror');
201        }
202
203        async suggest_google(query) {
204            const places = google.maps.places;
205            const hasNew = Boolean(places?.AutocompleteSuggestion?.fetchAutocompleteSuggestions);
206            const hasLegacy = Boolean(this.googleAutocomplete);
207            const preferred = this.googleApiType;
208
209            if (preferred === 'legacy' && hasLegacy) {
210                try {
211                    return await this.suggestGoogleLegacy(query);
212                } catch (error) {
213                    if (!hasNew || !this.isGoogleEntitlementError(error)) throw error;
214                    this.googleLegacySessionToken = null;
215                    const suggestions = await this.suggestGoogleNew(query);
216                    this.verifyGoogleApiType('new');
217                    return suggestions;
218                }
219            }
220
221            if (hasNew) {
222                try {
223                    const suggestions = await this.suggestGoogleNew(query);
224                    this.verifyGoogleApiType('new');
225                    return suggestions;
226                } catch (error) {
227                    if (!hasLegacy || !this.isGoogleEntitlementError(error)) throw error;
228                    this.googleNewSessionToken = null;
229                    const suggestions = await this.suggestGoogleLegacy(query);
230                    this.verifyGoogleApiType('legacy');
231                    return suggestions;
232                }
233            }
234
235            if (hasLegacy) {
236                const suggestions = await this.suggestGoogleLegacy(query);
237                this.verifyGoogleApiType('legacy');
238                return suggestions;
239            }
240
241            throw new Error('No compatible Google Places autocomplete API is available.');
242        }
243
244        async suggestGoogleNew(query) {
245            const places = google.maps.places;
246            this.googleNewSessionToken ||= new places.AutocompleteSessionToken();
247            const request = { input: query, sessionToken: this.googleNewSessionToken };
248            if (this.countryCodes.length) request.includedRegionCodes = this.countryCodes;
249            const response = await places.AutocompleteSuggestion.fetchAutocompleteSuggestions(request);
250            return (response.suggestions || []).filter(item => item.placePrediction).map(item => {
251                const prediction = item.placePrediction;
252                return {
253                    id: prediction.placeId,
254                    ref: prediction.placeId,
255                    data: prediction,
256                    apiGeneration: 'new',
257                    label: prediction.text?.text || '',
258                    primary: prediction.structuredFormat?.mainText?.text || prediction.text?.text || '',
259                    secondary: prediction.structuredFormat?.secondaryText?.text || ''
260                };
261            });
262        }
263
264        suggestGoogleLegacy(query) {
265            const places = google.maps.places;
266            this.googleLegacySessionToken ||= new places.AutocompleteSessionToken();
267            return new Promise((resolve, reject) => {
268                const request = { input: query, sessionToken: this.googleLegacySessionToken };
269                if (this.countryCodes.length) request.componentRestrictions = { country: this.countryCodes };
270                this.googleAutocomplete.getPlacePredictions(request, (predictions, status) => {
271                    if (status === places.PlacesServiceStatus.ZERO_RESULTS) return resolve([]);
272                    if (status !== places.PlacesServiceStatus.OK || !predictions) {
273                        return reject(new Error(`Google Places legacy autocomplete failed: ${status || 'UNKNOWN_ERROR'}`));
274                    }
275                    resolve(predictions.map(prediction => ({
276                        id: prediction.place_id,
277                        ref: prediction.place_id,
278                        data: prediction,
279                        googleSessionToken: this.googleLegacySessionToken,
280                        apiGeneration: 'legacy',
281                        label: prediction.description,
282                        primary: prediction.structured_formatting?.main_text || prediction.description,
283                        secondary: prediction.structured_formatting?.secondary_text || ''
284                    })));
285                });
286            });
287        }
288
289        async resolve_google(suggestion) {
290            const generation = suggestion.apiGeneration || this.googleApiType;
291            if (generation === 'new') {
292                if (suggestion.apiGeneration === 'new' && !suggestion.data?.toPlace) {
293                    throw new Error('Google Places New suggestion is missing its native PlacePrediction.');
294                }
295                const place = suggestion.data?.toPlace ? suggestion.data.toPlace() : new google.maps.places.Place({ id: suggestion.ref });
296                try {
297                    await place.fetchFields({ fields: ['formattedAddress', 'addressComponents', 'location', 'viewport'] });
298                    const viewport = place.viewport;
299                    return {
300                        formatted_address: place.formattedAddress,
301                        lat: place.location.lat(),
302                        lng: place.location.lng(),
303                        bounds: viewport ? [viewport.getSouthWest
303().lng(), viewport.getSouthWest().lat(), viewport.getNorthEast().lng(), viewport.getNorthEast().lat()] : null,
304                        components: componentsFromGoogle(place.addressComponents, true)
305                    };
306                } finally {
307                    this.googleNewSessionToken = null;
308                }
309            }
310
311            if (generation !== 'legacy') throw new Error('Google suggestion API generation is unknown.');
312            return new Promise((resolve, reject) => {
313                this.googlePlaces.getDetails({
314                    placeId: suggestion.ref,
315                    sessionToken: suggestion.googleSessionToken || this.googleLegacySessionToken,
316                    fields: ['formatted_address', 'address_components', 'geometry']
317                }, (place, status) => {
318                    this.googleLegacySessionToken = null;
319                    if (status !== google.maps.places.PlacesServiceStatus.OK || !place) {
320                        return reject(new Error(`Google Places legacy details failed: ${status || 'UNKNOWN_ERROR'}`));
321                    }
322                    const viewport = place.geometry.viewport;
323                    resolve({
324                        formatted_address: place.formatted_address,
325                        lat: place.geometry.location.lat(),
326                        lng: place.geometry.location.lng(),
327                        bounds: viewport ? [viewport.getSouthWest
327().lng(), viewport.getSouthWest().lat(), viewport.getNorthEast().lng(), viewport.getNorthEast().lat()] : null,
328                        components: componentsFromGoogle(place.address_components, false)
329                    });
330                });
331            });
332        }
333
334        async suggest_mapbox(query) {
335            this.mapboxSessionToken ||= uuid();
336            const params = new URLSearchParams({
337                q: query,
338                access_token: this.config.mapboxToken || '',
339                session_token: this.mapboxSessionToken,
340                limit: String(this.limit)
341            });
342            if (this.countryCodes.length) params.set('country', this.countryCodes.join(','));
343            if (MAPBOX_LANGUAGES.includes(this.config.geocodeLang)) params.set('language', this.config.geocodeLang);
344            const data = await fetch(`https://api.mapbox.com/search/searchbox/v1/suggest?${params}`).then(response => response.json());
345            return (data.suggestions || []).map((item, index) => {
346                const place = item.context?.place?.name;
347                let primary = item.name || item.full_address;
348                if (place && !primary.includes(place)) primary = `${primary}, ${place}`;
349                return {
350                    id: item.mapbox_id || `mapbox-${index}`,
351                    ref: item.mapbox_id,
352                    data: item,
353                    label: item.full_address || item.name,
354                    primary,
355                    secondary: [item.poi_category?.[0] || (item.feature_type !== 'address' ? item.feature_type : ''), item.context?.region?.name, item.context?.country?.name].filter(Boolean).join(', ')
356                };
357            });
358        }
359
360        async resolve_mapbox(suggestion) {
361            const params = new URLSearchParams({ access_token: this.config.mapboxToken || '', session_token: this.mapboxSessionToken || '' });
362            if (MAPBOX_LANGUAGES.includes(this.config.geocodeLang)) params.set('language', this.config.geocodeLang);
363            const data = await fetch(`https://api.mapbox.com/search/searchbox/v1/retrieve/${encodeURIComponent(suggestion.ref)}?${params}`).then(response => response.json());
364            this.mapboxSessionToken = null;
365            const feature = data.features?.[0];
366            if (!feature) return null;
367            const properties = feature.properties || {};
368            return {
369                formatted_address: properties.full_address || properties.name,
370                lat: feature.geometry.coordinates[1],
371                lng: feature.geometry.coordinates[0],
372                bounds: feature.bbox || null,
373                components: {
374                    city: properties.context?.place?.name,
375                    state: properties.context?.region?.name,
376                    postalcode: properties.context?.postcode?.name,
377                    country: properties.context?.country?.name
378                }
379            };
380        }
381
382        async suggest_nominatim(query) {
383            const url = new URL('https://nominatim.openstreetmap.org/search');
384            url.searchParams.set('q', query);
385            url.searchParams.set('format', 'json');
386            url.searchParams.set('limit', '1');
387            url.searchParams.set('addressdetails', '1');
388            if (this.countryCodes.length) url.searchParams.set('countrycodes', this.countryCodes.join(','));
389            if (this.config.geocodeLang) url.searchParams.set('accept-language', this.config.geocodeLang);
390            const data = await fetch(url).then(response => response.json());
391            return data.map(item => ({ id: item.place_id, ref: item, data: item, label: item.display_name, ...parts(item.display_name) }));
392        }
393
394        resolve_nominatim(suggestion) {
395            const item = suggestion.data;
396            const box = item.boundingbox?.map(Number);
397            return {
398                formatted_address: item.display_name,
399                lat: Number(item.lat),
400                lng: Number(item.lon),
401                bounds: box?.length === 4 ? [box[2], box[0], box[3], box[1]] : null,
402                components: { city: item.address?.city || item.address?.town || item.address?.village, state: item.address?.state, postalcode: item.address?.postcode, country: item.address?.country }
403            };
404        }
405
406        async suggest_arcgis(query) {
407            const params = new URLSearchParams({ text: query, f: 'json', maxSuggestions: String(this.limit) });
408            if (this.countryCodes.length) params.set('countryCode', this.countryCodes.join(','));
409            const data = await fetch(`https://geocode.arcgis.com/arcgis/rest/services/World/GeocodeServer/suggest?${params}`).then(response => response.json());
410            return (data.suggestions || []).map(item => ({ id: item.magicKey, ref: item.magicKey, label: item.text, ...parts(item.text) }));
411        }
412
413        async resolve_arcgis(suggestion) {
414            const params = new URLSearchParams({ magicKey: suggestion.ref, f: 'json', outFields: 'City,Region,Postal,Country' });
415            if (this.config.geocodeLang) params.set('langCode', this.config.geocodeLang);
416            const data = await fetch(`https://geocode.arcgis.com/arcgis/rest/services/World/GeocodeServer/findAddressCandidates?${params}`).then(response => response.json());
417            const item = data.candidates?.[0];
418            if (!item) return null;
419            return { formatted_address: item.address, lat: item.location.y, lng: item.location.x, bounds: item.extent ? [item.extent.xmin, item.extent.ymin, item.extent.xmax, item.extent.ymax] : null, components: { city: item.attributes.City, state: item.attributes.Region, postalcode: item.attributes.Postal, country: item.attributes.Country } };
420        }
421
422        async suggest_photon(query) {
423            const url = new URL('https://photon.komoot.io/api/');
424            url.searchParams.set('q', query);
425            // Photon has no country parameter. Fetch a wider candidate set when a
426            // country restriction is configured, then apply the setting locally.
427            url.searchParams.set('limit', String(this.countryCodes.length ? Math.min(Math.max(this.limit * 5, 10), 50) : this.limit));
428            if (this.config.geocodeLang) url.searchParams.set('lang', this.config.geocodeLang);
429            const data = await fetch(url).then(response => response.json());
430            return (data.features || []).filter(feature => {
431                if (!this.countryCodes.length) return true;
432                return this.countryCodes.includes(String(feature.properties?.countrycode || '').toLowerCase());
433            }).slice(0, this.limit).map((feature, index) => {
434                const properties = feature.properties || {};
435                const formatted = formatPhotonSuggestion(properties);
436                // Photon can return the same OSM object more than once with a
437                // different key/value (Golden Gate Bridge is a real example).
438                // Include every discriminator so Alpine x-for keys stay unique.
439                const id = ['photon', properties.osm_type, properties.osm_id, properties.osm_key, properties.osm_value, index]
440                    .filter(value => value !== undefined && value !== null && value !== '')
441                    .join('-');
442                return { id, ref: feature, data: feature, ...formatted };
443            });
444        }
445
446        resolve_photon(suggestion) {
447            const feature = suggestion.data;
448            const properties = feature.properties || {};
449            return {
450                formatted_address: suggestion.label,
451                lat: feature.geometry.coordinates[1],
452                lng: feature.geometry.coordinates[0],
453                bounds: feature.bbox || null,
454                type: photonType(properties),
455                components: {
456                    address: [properties.housenumber, properties.street].filter(Boolean).join(' '),
457                    city: properties.city || properties.town || properties.village,
458                    county: properties.county,
459                    state: properties.state,
460                    postalcode: properties.postcode,
461                    country: properties.country,
462                    countrycode: properties.countrycode
463                }
464            };
465        }
466    }
467
468    window.jrMapsLocationSearch = {
469        create(config) { return new LocationSearchClient(config); }
470    };
471})(window);

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.