PageSourceSearch

https://pizzeria-planeta.pl/js/address-autocomplete.js

js pizzeria-planeta.pl collected 2026-10-08 07:35:52 UTC 26,434 bytes, 777 lines download raw bytes

1import { getConfig } from './config.js';
2import { isAddressValidationEnabled, pickBestGeocodeResult } from './address-validation.js';
3import { isCityBasedDelivery, isZoneBasedDelivery, usesDeliveryAreas } from './delivery-zones.js';
4import { fetchPlacesApiKey } from './site-api.js';
5
6const ELBLAG_BOUNDS = {
7  north: 54.195,
8  south: 54.118,
9  east: 19.485,
10  west: 19.355
11};
12
13let mapsInitPromise = null;
14
15export function emptyAddressParts() {
16    return { city: '', street: '', number: '', apartment: '' };
17}
18
19function findAddressPartInput(root, part) {
20    const scope = root && root.querySelector ? root : document;
21    if (scope !== document) {
22        const scoped = scope.querySelector(`[data-address-part="${part}"]`);
23        if (scoped) return scoped;
24    }
25    const idMap = {
26        city: 'address-city',
27        street: 'address-street',
28        number: 'address-number',
29        apartment: 'address-apartment'
30    };
31    return document.getElementById(idMap[part] || '');
32}
33
34export function readAddressPartsFromForm(root = document) {
35    return {
36        city: findAddressPartInput(root, 'city')?.value?.trim() || '',
37        street: findAddressPartInput(root, 'street')?.value?.trim() || '',
38        number: findAddressPartInput(root, 'number')?.value?.trim() || '',
39        apartment: findAddressPartInput(root, 'apartment')?.value?.trim() || ''
40    };
41}
42
43export function composeDeliveryAddress(parts = {}) {
44    const city = String(parts.city || '').trim();
45    const street = String(parts.street || '').trim();
46    const number = String(parts.number || '').trim();
47    const apartment = String(parts.apartment || '').trim();
48
49    const house = [number, apartment].filter(Boolean).join('/');
50    const line = [street, house].filter(Boolean).join(' ').trim();
51    return [line, city].filter(Boolean).join(', ');
52}
53
54export function areRequiredAddressPartsFilled(parts = {}) {
55    return !!(
56        String(parts.city || '').trim()
57        && String(parts.street || '').trim()
58        && String(parts.number || '').trim()
59    );
60}
61
62export function fillAddressPartsForm(parts = {}, root = document) {
63    const cityEl = findAddressPartInput(root, 'city');
64    const streetEl = findAddressPartInput(root, 'street');
65    const numberEl = findAddressPartInput(root, 'number');
66    const aptEl = findAddressPartInput(root, 'apartment');
67    if (cityEl && parts.city != null) cityEl.value = parts.city;
68    if (streetEl && parts.street != null) streetEl.value = parts.street;
69    if (numberEl && parts.number != null) numberEl.value = parts.number;
70    if (aptEl && parts.apartment != null) aptEl.value = parts.apartment;
71}
72
73function findAddressGroup(input) {
74    return input?.closest?.('[data-address-group], #address-group, .input-group--address') || null;
75}
76
77function componentLongName(components, ...types) {
78  const match = (components || []).find(component =>
79    types.some(type => (component.types || []).includes(type))
80  );
81  return match?.long_name || '';
82}
83
84export function parseGoogleAddressParts(components = []) {
85  const street = componentLongName(components, 'route');
86  let number = componentLongName(components, 'street_number');
87  let apartment = componentLongName(components, 'subpremise');
88  const city = componentLongName(components, 'locality', 'postal_town')
89    || componentLongName(components, 'administrative_area_level_2');
90
91  if (number.includes('/')) {
92    const [house, apt] = number.split('/');
93    number = house.trim();
94    if (!apartment) apartment = (apt || '').trim();
95  }
96
97  return {
98    city,
99    street,
100    number,
101    apartment
102  };
103}
104
105export function splitStreetLine(text = '') {
106  const raw = String(text || '').trim();
107  if (!raw) return emptyAddressParts();
108
109  const match = raw.match(/^(.*?)[\s,]+(\d+[a-zA-Z]?(?:-\d+[a-zA-Z]?)?)(?:\s*[/\s]\s*(\d+[a-zA-Z]?))?$/);
110  if (!match) {
111    return { ...emptyAddressParts(), street: raw };
112  }
113
114  return {
115    city: '',
116    street: match[1].trim(),
117    number: match[2].trim(),
118    apartment: (match[3] || '').trim()
119  };
120}
121
122/** Parsuje pojedynczy string adresu (np. z profilu) do pól koszyka. */
123export function parseDeliveryAddressString(text = '') {
124  const raw = String(text || '').trim();
125  if (!raw) return emptyAddressParts();
126
127  const chunks = raw.split(',').map((s) => s.trim()).filter(Boolean);
128  if (chunks.length >= 2) {
129    const city = chunks[chunks.length - 1];
130    const streetLine = chunks.slice(0, -1).join(', ');
131    return { ...splitStreetLine(streetLine), city };
132  }
133  return splitStreetLine(raw);
134}
135
136export function addressPartsLabel(parts = {}) {
137  const composed = composeDeliveryAddress(parts);
138  return composed || '';
139}
140
141function parseCityFromSecondary(text = '') {
142  return String(text || '')
143    .replace(/\b\d{2}-\d{3}\b/g, '')
144    .replace(/\bPolska\b/gi, '')
145    .replace(/\bPoland\b/gi, '')
146    .replace(/,\s*,/g, ',')
147    .replace(/^[\s,]+|[\s,]+$/g, '')
148    .trim();
149}
150
151function parsePredictionParts(prediction) {
152  const main = prediction?.structured_formatting?.main_text || '';
153  const secondary = prediction?.structured_formatting?.secondary_text || '';
154  const fromMain = splitStreetLine(main);
155  const city = parseCityFromSecondary(secondary);
156
157  if (fromMain.street || fromMain.number) {
158    return { ...fromMain, city: city || fromMain.city };
159  }
160
161  const description = String(prediction?.description || '')
162    .replace(/,\s*Polska$/i, '')
163    .replace(/,\s*Poland$/i, '')
164    .trim();
165  const [line = '', cityPart = ''] = description.split(',').map(part => part.trim());
166  return { ...splitStreetLine(line), city: city || cityPart };
167}
168
169async function resolvePlacesApiKey(config = {}) {
170  const fromConfig = String(config?.delivery?.googleMapsPlacesKey || '').trim();
171  if (fromConfig) return fromConfig;
172  return fetchPlacesApiKey();
173}
174
175function loadGoogleMapsPlaces(apiKey) {
176  if (window.google?.maps?.places) {
177    return Promise.resolve();
178  }
179
180  if (mapsInitPromise) {
181    return mapsInitPromise;
182  }
183
184  mapsInitPromise = new Promise((resolve, reject) => {
185    const script = document.createElement('script');
186    script.src = `https://maps.googleapis.com/maps/api/js?key=${encodeURIComponent(apiKey)}&libraries=places&language=pl&region=PL&loading=async`;
187    script.async = true;
188    script.defer = true;
189    script.onload = () => resolve();
190    script.onerror = () => reject(new Error('Nie udało się załadować Google Maps.'));
191    document.head.appendChild(script);
192  });
193
194  return mapsInitPromise;
195}
196
197function extractLatLngValue(value) {
198  if (typeof value === 'function') return Number(value());
199  return Number(value);
200}
201
202function mapGeocoderResult(result) {
203  const rawLocation = result.geometry?.location;
204  const lat = extractLatLngValue(rawLocation?.lat);
205  const lng = extractLatLngValue(rawLocation?.lng);
206  const location = Number.isFinite(lat) && Number.isFinite(lng) ? { lat, lng } : null;
207
208  return {
209    formatted_address: result.formatted_address,
210    partial_match: result.partial_match,
211    geometry: {
212      location_type: result.geometry?.location_type || '',
213      location
214    },
215    address_components: (result.address_components || []).map(component => ({
216      long_name: component.long_name,
217      short_name: component.short_name,
218      types: component.types
219    }))
220  };
221}
222
223/** Jak geocode.php: zwraca trafienie z współrzędnymi; strefy/miasta decydują o akceptacji. */
224function evaluationFromDeliveryAreaHit(mapped) {
225  const best = mapped.find(item => item.geometry?.location) || mapped[0];
226  if (!best) return { valid: false, reason: 'not_found' };
227
228  return {
229    valid: true,
230    reason: null,
231    formattedAddress: best.formatted_address || '',
232    location: best.geometry?.location || null,
233    address_components: best.address_components || [],
234    partial_match: !!best.partial_match,
235    geometry: best.geometry || null
236  };
237}
238
239export async function geocodeAddressClient(address, config = {}) {
240  const apiKey = await resolvePlacesApiKey(config);
241  if (!apiKey) {
242    return { valid: false, reason: 'not_configured' };
243  }
244
245  await loadGoogleMapsPlaces(apiKey);
246
247  const geocoder = new window.google.maps.Geocoder();
248  const query = String(address || '').trim();
249  const hasElblag = query.includes('Elbląg') || query.includes('Elblag');
250  const geocodeQuery = hasElblag
251    ? query
252    : (usesDeliveryAreas(config) ? `${query}, Polska` : `${query}, Elbląg, Polska`);
253
254  return new Promise(resolve => {
255    geocoder.geocode(
256      {
257        address: geocodeQuery,
258        componentRestrictions: { country: 'pl' }
259      },
260      (results, status) => {
261        if (status !== 'OK' || !Array.isArray(results) || results.length === 0) {
262          resolve({ valid: false, reason: 'not_found' });
263          return;
264        }
265
266        const mapped = results.map(mapGeocoderResult);
267        if (usesDeliveryAreas(config)) {
268          const areaEval = evaluationFromDeliveryAreaHit(mapped);
269          if (isZoneBasedDelivery(config) && !areaEval.location) {
270            resolve({ valid: false, reason: 'not_found' });
271            return;
272          }
273          resolve(areaEval);
274          return;
275        }
276
277        resolve(pickBestGeocodeResult(mapped));
278      }
279    );
280  });
281}
282
283function getElblagBounds() {
284  return new window.google.maps.LatLngBounds(
285    { lat: ELBLAG_BOUNDS.south, lng: ELBLAG_BOUNDS.west },
286    { lat: ELBLAG_BOUNDS.north, lng: ELBLAG_BOUNDS.east }
287  );
288}
289
290function ensureSuggestionsList(input) {
291  const group = findAddressGroup(input);
292  if (!group) return null;
293
294  // Na mobile lista żyje w <body> (position:fixed), żeby nie ginęła w overflow koszyka.
295  let list = group.querySelector('.address-suggestions');
296  if (!list && input.id) {
297    list = document.querySelector(`.address-suggestions[data-for="${CSS.escape(input.id)}"]`);
298  }
299  if (!list) {
300    list = document.createElement('ul');
301    list.className = 'address-suggestions';
302    list.hidden = true;
303  }
304
305  if (input.id) list.dataset.for = input.id;
306
307  if (isCoarsePointer()) {
308    if (list.parentElement !== document.body) document.body.appendChild(list);
309  } else if (list.parentElement !== group) {
310    group.appendChild(list);
311  }
312
313  return list;
314}
315
316function isCoarsePointer() {
317  try {
318    return window.matchMedia('(pointer: coarse)').matches;
319  } catch {
320    return 'ontouchstart' in window;
321  }
322}
323
324function clearFixedSuggestionStyles(list) {
325  if (!list) return;
326  list.classList.remove('address-suggestions--fixed');
327  list.style.position = '';
328  list.style.top = '';
329  list.style.bottom = '';
330  list.style.left = '';
331  list.style.right = '';
332  list.style.width = '';
333  list.style.maxHeight = '';
334  list.style.zIndex = '';
335}
336
337/** Fixed portal nad klawiaturą — unika obcinania przez overflow i skakania viewportu. */
338function positionFixedSuggestions(input, list) {
339  if (!input || !list || list.hidden) return;
340
341  const rect = input.getBoundingClientRect();
342  const vv = window.visualViewport;
343  const offsetTop = vv?.offsetTop ?? 0;
344  const vpH = vv?.height ?? window.innerHeight;
345  const vpW = vv?.width ?? window.innerWidth;
346  const gap = 4;
347  const sidePad = 8;
348  const spaceBelow = vpH - (rect.bottom - offsetTop);
349  const spaceAbove = rect.top - offsetTop;
350  const maxH = Math.min(280, Math.floor(vpH * 0.42));
351  const left = Math.min(Math.max(sidePad, rect.left), vpW - sidePad - 120);
352  const width = Math.max(120, Math.min(rect.width, vpW - left - sidePad));
353
354  list.classList.add('address-suggestions--fixed');
355  list.style.position = 'fixed';
356  list.style.left = `${left}px`;
357  list.style.width = `${width}px`;
358  list.style.right = 'auto';
359  list.style.zIndex = '4000';
360
361  const preferBelow = spaceBelow >= 120 || spaceBelow >= spaceAbove;
362  if (preferBelow) {
363    const h = Math.max(96, Math.min(maxH, spaceBelow - gap - 8));
364    list.style.top = `${rect.bottom + gap}px`;
365    list.style.bottom = 'auto';
366    list.style.maxHeight = `${h}px`;
367  } else {
368    const h = Math.max(96, Math.min(maxH, spaceAbove - gap - 8));
369    list.style.top = `${Math.max(offsetTop + sidePad, rect.top - gap - h)}px`;
370    list.style.bottom = 'auto';
371    list.style.maxHeight = `${h}px`;
372  }
373}
374
375function suppressNativeAutofill(input) {
376  if (!input) return;
377  // Własna lista — wyłącz natywną autofill / systemowe podpowiedzi klawiatury.
378  // „new-password” skuteczniej niż „off” tłumi natywną listę w Chrome/iOS.
379  input.setAttribute('autocomplete', 'new-password');
380  input.setAttribute('autocorrect', 'off');
381  input.setAttribute('autocapitalize', 'words');
382  input.setAttribute('spellcheck', 'false');
383  input.setAttribute('inputmode', 'text');
384  input.setAttribute('data-lpignore', 'true');
385  input.setAttribute('data-1p-ignore', 'true');
386  input.setAttribute('data-form-type', 'other');
387}
388
389function bindAddressSuggestions(input) {
390  if (input.dataset.addressSuggestionsBound === 'true') return;
391
392  const group = findAddressGroup(input) || document;
393  const service = new window.google.maps.places.AutocompleteService();
394  const bounds = getElblagBounds();
395  const list = ensureSuggestionsList(input);
396  if (!list) return;
397
398  let debounceTimer = null;
399  let activeIndex = -1;
400  let currentPredictions = [];
401  let suppressSuggestionsUntil = 0;
402  let selecting = false;
403  const mobile = isCoarsePointer();
404
405  list.setAttribute('role', 'listbox');
406  list.setAttribute('aria-label', 'Podpowiedzi adresu');
407
408  const syncListPosition = () => {
409    if (list.hidden) return;
410    if (mobile) positionFixedSuggestions(input, list);
411  };
412
413  const hideSuggestions = () => {
414    list.hidden = true;
415    list.innerHTML = '';
416    activeIndex = -1;
417    currentPredictions = [];
418    clearFixedSuggestionStyles(list);
419  };
420
421  const finishSelectionFocus = (parts) => {
422    if (!mobile) return;
423
424    const numberEl = findAddressPartInput(group, 'number');
425    const hasNumber = !!(parts?.number || numberEl?.value?.trim());
426    // Zawsze blur ulicy — kończy walkę klawiatury z listą / scrollIntoView.
427    input.blur();
428    if (!hasNumber && numberEl) {
429      window.setTimeout(() => {
430        numberEl.focus({ preventScroll: true });
431        numberEl.scrollIntoView({ block: 'nearest', behavior: 'auto' });
432      }, 60);
433    }
434  };
435
436  const selectPrediction = (prediction) => {
437    if (!prediction || selecting) return;
438    selecting = true;
439    suppressSuggestionsUntil = Date.now() + 600;
440    clearTimeout(debounceTimer);
441
442    const parts = parsePredictionParts(prediction);
443    hideSuggestions();
444    fillAddressPartsForm(parts, group);
445
446    // Synthetic input dla wyceny dostawy — bez ponownego otwierania listy.
447    input.dispatchEvent(new Event('input', { bubbles: true }));
448    findAddressPartInput(group, 'city')?.dispatchEvent(new Event('input', { bubbles: true }));
449    findAddressPartInput(group, 'number')?.dispatchEvent(new Event('input', { bubbles: true }));
450
451    finishSelectionFocus(parts);
452    selecting = false;
453  };
454
455  const renderSuggestions = (predictions) => {
456    if (Date.now() < suppressSuggestionsUntil) return;
457    currentPredictions = predictions;
458    activeIndex = -1;
459    // div + role=option (nie <button>) — na mobile button przejmuje fokus i otwiera/zamyka klawiaturę.
460    list.innerHTML = predictions.map((prediction, index) => `
461      <li role="presentation">
462        <div
463          class="address-suggestions__item"
464          data-index="${index}"
465          role="option"
466          tabindex="-1"
467          aria-selected="false"
468        >${prediction.description}</div>
469      </li>
470    `).join('');
471    list.hidden = false;
472    syncListPosition();
473  };
474
475  const requestSuggestions = () => {
476    if (Date.now() < suppressSuggestionsUntil || selecting) {
477      hideSuggestions();
478      return;
479    }
480
481    const parts = readAddressPartsFromForm(group);
482    const query = [parts.street, parts.number, parts.city].filter(
482Boolean).join(' ').trim()
483      || input.value.trim();
484    if (query.length < 2) {
485      hideSuggestions();
486      return;
487    }
488
489    service.getPlacePredictions(
490      {
491        input: query,
492        componentRestrictions: { country: 'pl' },
493        bounds,
494        types: ['address']
495      },
496      (predictions, status) => {
497        if (Date.now() < suppressSuggestionsUntil || selecting) {
498          hideSuggestions();
499          return;
500        }
501        if (
502          status !== window.google.maps.places.PlacesServiceStatus.OK
503          || !Array.isArray(predictions)
504          || predictions.length === 0
505        ) {
506          hideSuggestions();
507          return;
508        }
509
510        renderSuggestions(predictions);
511      }
512    );
513  };
514
515  input.addEventListener('input', () => {
516    if (Date.now() < suppressSuggestionsUntil || selecting) {
517      clearTimeout(debounceTimer);
518      hideSuggestions();
519      return;
520    }
521    clearTimeout(debounceTimer);
522    debounceTimer = setTimeout(requestSuggestions, 220);
523  });
524
525  input.addEventListener('focus', () => {
526    if (mobile && !list.hidden) syncListPosition();
527  });
528
529  input.addEventListener('keydown', (event) => {
530    if (list.hidden || !currentPredictions.length) return;
531
532    if (event.key === 'ArrowDown') {
533      event.preventDefault();
534      activeIndex = Math.min(activeIndex + 1, currentPredictions.length - 1);
535    } else if (event.key === 'ArrowUp') {
536      event.preventDefault();
537      activeIndex = Math.max(activeIndex - 1, 0);
538    } else if (event.key === 'Enter' && activeIndex >= 0) {
539      event.preventDefault();
540      selectPrediction(currentPredictions[activeIndex]);
541      return;
542    } else if (event.key === 'Escape') {
543      hideSuggestions();
544      return;
545    } else {
546      return;
547    }
548
549    list.querySelectorAll('.address-suggestions__item').forEach((item, index) => {
550      const active = index === activeIndex;
551      item.classList.toggle('is-active', active);
552      item.setAttribute('aria-selected', active ? 'true' : 'false');
553    });
554  });
555
556  // pointerdown + preventDefault: wybór bez blur inputu i bez fokusu na pozycji listy.
557  let lastPickAt = 0;
558  const onPickPointer = (event) => {
559    const item = event.target.closest('.address-suggestions__item');
560    if (!item || !list.contains(item)) return;
561    // Nie blokuj scrolla listy (tylko primary tap / lewy przycisk).
562    if (event.pointerType === 'mouse' && event.button !== 0) return;
563    event.preventDefault();
564    event.stopPropagation();
565    if (Date.now() - lastPickAt < 400) return;
566    lastPickAt = Date.now();
567    const index = Number(item.dataset.index);
568    selectPrediction(currentPredictions[index]);
569  };
570  if (window.PointerEvent) {
571    list.addEventListener('pointerdown', onPickPointer);
572  } else {
573    list.addEventListener('mousedown', onPickPointer);
574    list.addEventListener('touchstart', onPickPointer, { passive: false });
575  }
576
577  const onOutsidePointer = (event) => {
578    if (list.hidden) return;
579    if (input.contains(event.target) || list.contains(event.target)) return;
580    hideSuggestions();
581  };
582  document.addEventListener(window.PointerEvent ? 'pointerdown' : 'mousedown', onOutsidePointer);
583
584  if (mobile) {
585    const scrollRoot = input.closest('.cart-panel__body, .order-form, .account-panel, main') || null;
586    scrollRoot?.addEventListener('scroll', syncListPosition, { passive: true });
587    window.addEventListener('scroll', syncListPosition, { passive: true });
588    window.addEventListener('resize', syncListPosition);
589    window.visualViewport?.addEventListener('resize', syncListPosition);
590    window.visualViewport?.addEventListener('scroll', syncListPosition);
591  }
592
593  suppressNativeAutofill(input);
594  input.dataset.addressSuggestionsBound = 'true';
595}
596
597function parseNominatimAddressParts(data = {}) {
598  const a = data.address || {};
599  let number = String(a.house_number || '').trim();
600  let apartment = String(a.unit || a.flat || '').trim();
601  if (number.includes('/')) {
602    const [house, apt] = number.split('/');
603    number = house.trim();
604    if (!apartment) apartment = (apt || '').trim();
605  }
606
607  return {
608    street: String(a.road || a.pedestrian || a.residential || a.street || '').trim(),
609    number,
610    apartment,
611    city: String(a.city || a.town || a.village || a.municipality || a.suburb || '').trim()
612  };
613}
614
615async function reverseGeocodeWithNominatim(lat, lng) {
616  const url = new URL('https://nominatim.openstreetmap.org/reverse');
617  url.searchParams.set('format', 'jsonv2');
618  url.searchParams.set('lat', String(lat));
619  url.searchParams.set('lon', String(lng));
620  url.searchParams.set('addressdetails', '1');
621  url.searchParams.set('accept-language', 'pl');
622  url.searchParams.set('zoom', '18');
623
624  const response = await fetch(url.toString(), {
625    headers: { Accept: 'application/json' }
626  });
627  if (!response.ok) {
628    throw new Error('Nie udało się odczytać adresu z lokalizacji.');
629  }
630
631  const data = await response.json();
632  const parts = parseNominatimAddressParts(data);
633  const address = composeDeliveryAddress(parts) || data.display_name || '';
634  if (!address) {
635    throw new Error('Nie znaleziono adresu dla tej lokalizacji.');
636  }
637
638  return { address, parts };
639}
640
641async function reverseGeocodeWithGoogle(lat, lng, config = {}) {
642  const apiKey = await resolvePlacesApiKey(config);
643  if (!apiKey) return null;
644
645  await loadGoogleMapsPlaces(apiKey);
646  const geocoder = new window.google.maps.Geocoder();
647
648  return new Promise(resolve => {
649    geocoder.geocode({ location: { lat, lng } }, (results, status) => {
650      if (status !== 'OK' || !Array.isArray(results) || !results.length) {
651        resolve(null);
652        return;
653      }
654      const best = results[0];
655      const parts = parseGoogleAddressParts(best.address_components || []);
656      const address = composeDeliveryAddress(parts) || best.formatted_address || null;
657      if (!address) {
658        resolve(null);
659        return;
660      }
661      resolve({ address, parts });
662    });
663  });
664}
665
666export async function reverseGeocodeLocation(lat, lng, config = getConfig()) {
667  try {
668    const googleResult = await reverseGeocodeWithGoogle(lat, lng, config);
669    if (googleResult?.address) return googleResult;
670  } catch (err) {
671    console.warn('Google reverse geocode niedostępne:', err);
672  }
673
674  return reverseGeocodeWithNominatim(lat, lng);
675}
676
677export function getCurrentPosition() {
678  return new Promise((resolve, reject) => {
679    if (!navigator.geolocation) {
680      reject(new Error('Twoja przeglądarka nie obsługuje lokalizacji GPS.'));
681      return;
682    }
683
684    navigator.geolocation.getCurrentPosition(
685      position => resolve(position.coords),
686      error => {
687        if (error.code === error.PERMISSION_DENIED) {
688          reject(new Error('Brak zgody na lokalizację. Włącz GPS w przeglądarce.'));
689          return;
690        }
691        if (error.code === error.POSITION_UNAVAILABLE) {
692          reject(new Error('Nie udało się pobrać lokalizacji GPS.'));
693          return;
694        }
695        if (error.code === error.TIMEOUT) {
696          reject(new Error('Czas oczekiwania na lokalizację minął. Spróbuj ponownie.'));
697          return;
698        }
699        reject(new Error('Nie udało się pobrać lokalizacji.'));
700      },
701      {
702        enableHighAccuracy: true,
703        timeout: 15000,
704        maximumAge: 0
705      }
706    );
707  });
708}
709
710export async function locateAddressFromGps(config = getConfig()) {
711  const coords = await getCurrentPosition();
712  const located = await reverseGeocodeLocation(coords.latitude, coords.longitude, config);
713  const address = typeof located === 'string' ? located : located.address;
714  const parts = typeof located === 'string'
715    ? { ...splitStreetLine(located), city: '' }
716    : (located.parts || emptyAddressParts());
717
718  return {
719    address,
720    parts,
721    lat: coords.latitude,
722    lng: coords.longitude,
723    accuracy: coords.accuracy
724  };
725}
726
727export async function initAddressAutocomplete(config = getConfig(), streetInput = null) {
728  const input = streetInput || document.getElementById('address-street');
729  const apiKey = await resolvePlacesApiKey(config);
730  if (!input || !apiKey || !isAddressValidationEnabled(config)) return false;
731  if (input.dataset.addressSuggestionsBound === 'true') return true;
732
733  try {
734    await loadGoogleMapsPlaces(apiKey);
735    bindAddressSuggestions(input);
736    return true;
737  } catch (err) {
738    console.warn('Google Places suggestions niedostępne:', err);
739    return false;
740  }
741}
742
743/** Podpowiedzi adresu w dowolnym bloku (np. panel konta). */
744export async function initAddressAutocompleteInRoot(root, config = getConfig()) {
745  if (!root) return false;
746  const input = root.querySelector('[data-address-part="street"]');
747  if (!input) return false;
748  return initAddressAutocomplete(config, input);
749}
750
751export async function refreshAddressAutocompleteHint(config = getConfig()) {
752  const hint = document.getElementById('address-hint');
753  if (!hint) return;
754
755  if (!isAddressValidationEnabled(config)) {
756    if (usesDeliveryAreas(config)) {
757      hint.textContent = isCityBasedDelivery(config)
758        ? 'Tryb miejscowości — wpisz miasto, ulicę i numer domu.'
759        : 'Strefy dostawy wymagają włączonej weryfikacji Google Maps.';
760    } else {
761      hint.textContent = 'Weryfikacja Google Maps wyłączona — podaj miasto, ulicę i numer domu.';
762    }
763    return;
764  }
765
766  const hasPlacesKey = !!(await resolvePlacesApiKey(config));
767  if (usesDeliveryAreas(config)) {
768    hint.textContent = hasPlacesKey
769      ? 'Uzupełnij pola adresu — po weryfikacji pokażemy odległość, strefę i koszt dostawy.'
770      : 'Podaj miasto, ulicę i numer — po weryfikacji pokażemy odległość, strefę i koszt dostawy.';
771    return;
772  }
773
774  hint.textContent = hasPlacesKey
775    ? 'Zacznij pisać ulicę — wybierz podpowiedź z listy. Numer lokalu jest opcjonalny.'
776    : 'Podaj miasto, ulicę i numer domu. Numer lokalu jest opcjonalny.';
777}

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.