1import { debounce, removeFieldError } from './helpers.js'; 2 3const MIN_QUERY_LENGTH = 2; 4 5const getSuggestConfig = () => { 6 if (window.photohumanCheckout) { 7 return window.photohumanCheckout; 8 } 9 10 const root = document.querySelector('.photohuman-checkout[data-checkout-page="1"]'); 11 12 if (!root) { 13 return {}; 14 } 15 16 return { 17 ajaxUrl: root.dataset.wcAjaxUrl || '/wp-admin/admin-ajax.php', 18 citySuggestNonce: root.dataset.citySuggestNonce || '', 19 yandexSuggestKey: root.dataset.yandexSuggestKey || '', 20 }; 21}; 22 23const getCheckoutCity = (form) => { 24 const cityField = form?.querySelector('#billing_city, input[name="billing_city"]'); 25 26 if (!cityField) { 27 return ''; 28 } 29 30 if (cityField.dataset.cityValidated === '1' && cityField.dataset.cityValidatedName) { 31 return cityField.dataset.cityValidatedName.trim(); 32 } 33 34 return cityField.value.trim(); 35}; 36 37const dedupeAddressResults = (results) => { 38 const unique = []; 39 const seen = new Set(); 40 41 results.forEach((item) => { 42 const key = String(item.address || '').toLowerCase(); 43 44 if (!key || seen.has(key)) { 45 return; 46 } 47 48 seen.add(key); 49 unique.push(item); 50 51 if (unique.length >= 7) { 52 return; 53 } 54 }); 55 56 return unique; 57}; 58 59const parseAddressGeosuggestPayload = (payload, city) => { 60 if (!payload?.results?.length) { 61 return []; 62 } 63 64 const results = []; 65 66 payload.results.forEach((item) => { 67 let address = ''; 68 69 if (item?.address?.formatted_address) { 70 address = String(item.address.formatted_address) 71 .replace(/^РоÑÑиÑ\s*,\s*/u, '') 72 .trim(); 73 74 if (city) { 75 const cityPrefix = new RegExp(`^${city.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}\\s*,\\s*`, 'iu'); 76 address = address.replace(cityPrefix, '').trim(); 77 } 78 } 79 80 if (!address && item?.title?.text) { 81 address = String(item.title.text).trim(); 82 83 if (city && address.toLowerCase().startsWith(city.toLowerCase())) { 84 address = address.slice(city.length).replace(/^[\s,]+/, '').trim(); 85 } 86 } 87 88 if (!address) { 89 return; 90 } 91 92 const subtitle = item?.subtitle?.text ? String(item.subtitle.text) : ''; 93 94 results.push({ 95 address, 96 label: address, 97 subtitle, 98 }); 99 }); 100 101 return dedupeAddressResults(results); 102}; 103 104const fetchYandexClientAddressSuggestions = async (query, city, apiKey) => { 105 const searchText = city ? `${city}, ${query}` : query; 106 const params = new URLSearchParams({ 107 apikey: apiKey, 108 text: searchText, 109 lang: 'ru', 110 results: '10', 111 types: 'street,house', 112 countries: 'ru', 113 print_address: '1', 114 highlight: '0', 115 }); 116 117 const response = await fetch(`https://suggest-maps.yandex.ru/v1/suggest?${params.toString()}`, { 118 method: 'GET', 119 credentials: 'omit', 120 }); 121 122 if (!response.ok) { 123 return []; 124 } 125 126 const payload = await response.json(); 127 128 return parseAddressGeosuggestPayload(payload, city); 129}; 130 131const fetchAddressSuggestionsAjax = async (query, city) => { 132 const config = getSuggestConfig(); 133 const params = new URLSearchParams({ 134 action: 'photohuman_address_suggest', 135 security: config.citySuggestNonce || '', 136 text: query, 137 city, 138 }); 139 140 const response = await fetch(`${config.ajaxUrl || '/wp-admin/admin-ajax.php'}?${params.toString()}`, { 141 method: 'GET', 142 credentials: 'same-origin', 143 }); 144 145 const raw = await response.text(); 146 147 if (raw === '-1' || raw === '0') { 148 return []; 149 } 150 151 let data; 152 153 try { 154 data = JSON.parse(raw); 155 } catch { 156 return []; 157 } 158 159 if (!data?.success || !Array.isArray(data.data?.results)) { 160 return []; 161 } 162 163 return data.data.results; 164}; 165 166const fetchAddressSuggestions = async (query, city) => { 167 const config = getSuggestConfig(); 168 169 if (config.yandexSuggestKey) { 170 try { 171 const clientResults = await fetchYandexClientAddressSuggestions(query, city, config.yandexSuggestKey); 172 173 if (clientResults.length) { 174 return clientResults; 175 } 176 } catch { 177 // Fall back to server proxy below. 178 } 179 } 180 181 return fetchAddressSuggestionsAjax(query, city); 182}; 183 184const renderSuggestions = (list, items, activeIndex) => { 185 list.innerHTML = ''; 186 187 if (!items.length) { 188 list.hidden = true; 189 return; 190 } 191
192 items.forEach((item, index) => { 193 const li = document.createElement('li'); 194 li.className = 'photohuman-city-suggest__item'; 195 li.setAttribute('role', 'option'); 196 li.dataset.address = item.address; 197 198 if (index === activeIndex) { 199 li.classList.add('_active'); 200 li.setAttribute('aria-selected', 'true'); 201 } else { 202 li.setAttribute('aria-selected', 'false'); 203 } 204 205 const title = document.createElement('span'); 206 title.className = 'photohuman-city-suggest__title'; 207 title.textContent = item.label || item.address; 208 li.appendChild(title); 209 210 if (item.subtitle) { 211 const meta = document.createElement('span'); 212 meta.className = 'photohuman-address-suggest__subtitle'; 213 meta.textContent = item.subtitle; 214 li.appendChild(meta); 215 } 216 217 list.appendChild(li); 218 }); 219 220 list.hidden = false; 221}; 222 223/** 224 * Promote <p>/<span> to <div> so a suggest <ul> can be appended without 225 * breaking HTML (ul is not allowed inside p or span). 226 * 227 * @param {Element|null} el 228 * @returns {Element|null} 229 */ 230const ensureFlowContainer = (el) => { 231 if (!el || el.tagName === 'DIV') { 232 return el; 233 } 234 235 const div = document.createElement('div'); 236 237 for (const attr of Array.from(el.attributes)) { 238 div.setAttribute(attr.name, attr.value); 239 } 240 241 while (el.firstChild) { 242 div.appendChild(el.firstChild); 243 } 244 245 el.replaceWith(div); 246 247 return div; 248}; 249 250const bindAddressSuggest = (input) => { 251 if (!input || input.dataset.addressSuggestBound === '1') { 252 return; 253 } 254 255 input.dataset.addressSuggestBound = '1'; 256 input.setAttribute('autocomplete', 'off'); 257 input.setAttribute('role', 'combobox'); 258 input.setAttribute('aria-autocomplete', 'list'); 259 input.setAttribute('aria-expanded', 'false'); 260 261 const formRow = input.closest('.form-row'); 262 263 if (formRow) { 264 ensureFlowContainer(formRow); 265 } 266 267 let wrapper = input.closest('.woocommerce-input-wrapper') || input.parentElement; 268 269 if (!wrapper) { 270 return; 271 } 272 273 wrapper = ensureFlowContainer(wrapper); 274 wrapper.classList.add('photohuman-city-suggest'); 275 276 const list = document.createElement('ul'); 277 list.className = 'photohuman-city-suggest__list'; 278 list.setAttribute('role', 'listbox'); 279 list.hidden = true; 280 wrapper.appendChild(list); 281 282 const form = input.closest('form'); 283 let suggestions = []; 284 let activeIndex = -1; 285 let requestId = 0; 286 let isSelecting = false; 287 288 const closeList = () => { 289 list.hidden = true; 290 activeIndex = -1; 291 input.setAttribute('aria-expanded', 'false'); 292 }; 293 294 const selectSuggestion = (item) => { 295 if (!item?.address) { 296 return; 297 } 298 299 isSelecting = true; 300 input.value = item.address; 301 removeFieldError(input); 302 input.dispatchEvent(new Event('change', { bubbles: true })); 303 suggestions = []; 304 closeList(); 305 window.setTimeout(() => { 306 isSelecting = false; 307 }, 0); 308 }; 309 310 const loadSuggestions = debounce(async (query) => { 311 const currentRequest = ++requestId; 312 const city = getCheckoutCity(form); 313 314 if (query.length < MIN_QUERY_LENGTH || !city) { 315 suggestions = []; 316 renderSuggestions(list, suggestions, activeIndex); 317 return; 318 } 319 320 try { 321 const results = await fetchAddressSuggestions(query, city); 322 323 if (currentRequest !== requestId) { 324 return; 325 } 326 327 suggestions = results; 328 activeIndex = results.length ? 0 : -1; 329 renderSuggestions(list, suggestions, activeIndex); 330 input.setAttribute('aria-expanded', results.length ? 'true' : 'false'); 331 } catch { 332 suggestions = []; 333 renderSuggestions(list, suggestions, activeIndex); 334 } 335 }, 300); 336 337 input.addEventListener('input', () => { 338 removeFieldError(input); 339 loadSuggestions(input.value.trim()); 340 }); 341 342 input.addEventListener('keydown', (event) => { 343 if (list.hidden || !suggestions.length) { 344 return; 345 } 346 347 if (event.key === 'ArrowDown') { 348 event.preventDefault(); 349 activeIndex = Math.min(activeIndex + 1, suggestions.length - 1); 350 renderSuggestions(list, suggestions, activeIndex); 351 return; 352 } 353 354 if (event.key === 'ArrowUp') { 355 event.preventDefault(); 356 activeIndex = Math.max(activeIndex - 1, 0); 357 renderSuggestions(list, suggestions, activeIndex); 358 return; 359 } 360 361 if (event.key === 'Enter') { 362 if (activeIndex >= 0 && suggestions[activeIndex]) { 363 event.preventDefault(); 364 selectSuggestion(suggestions[activeIndex]); 365 } 366 return; 367 } 368 369 if (event.key === 'Escape') { 370 closeList(); 371 } 372 }); 373 374 list.addEventListener('mousedown', (event) => { 375 event.preventDefault(); 376 }); 377 378 list.addEventListener('click', (event) => { 379 const itemNode = event.target.closest('.photohuman-city-suggest__item'); 380 381 if (!itemNode) { 382 return; 383 } 384 385 const address = itemNode.dataset.address || ''; 386 const match = suggestions.find((item) => item.address === address); 387 388 if (match) { 389 selectSuggestion(match); 390 } 391 }); 392 393 input.addEventListener('blur', () => { 394 window.setTimeout(() => { 395 if (isSelecting) { 396 return; 397 } 398 399 closeList(); 400 }, 150); 401 }); 402 403 input.addEventListener('focus', () => { 404 const query = input.value.trim(); 405 const city = getCheckoutCity(form); 406 407 if (query.length >= MIN_QUERY_LENGTH && city) { 408 loadSuggestions(query); 409 } 410 }); 411 412 document.addEventListener('click', (event) => { 413 if (!wrapper.contains(event.target)) { 414 closeList(); 415 } 416 }); 417}; 418 419export const initAddressSuggest = (scope = document) => { 420 scope.querySelectorAll('#billing_address_1, input[name="billing_address_1"][data-address-suggest]').forEach(bindAddressSuggest); 421};
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.