PageSourceSearch

https://photohuman.space/wp-content/themes/photohuman/source/js/modules/addressSuggest.js?v=1.6.378

js photohuman.space collected 2026-09-28 08:54:50 UTC 9,765 bytes, 421 lines download raw bytes

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.