PageSourceSearch

https://sportlandia.md/catalog/view/javascript/common.js?v=1791294237

js sportlandia.md collected 2026-10-08 19:59:05 UTC 108,001 bytes, 2,592 lines download raw bytes

1/**
2 * common.js — общий скрипт OpenCart (корзина, избранное, сравнение, формы).
3 * Подключается в header.twig. Чистый JS, без jQuery.
4 *
5 * Глобальный API (вызывается из onclick-атрибутов шаблонов):
6 *   cart.preview / cart.add / cart.add_single / cart.update / cart.remove /
7 *   cart.remove_key / cart.clear, wishlist.*, productCompare.*, compare.add,
8 *   voucher.remove, getURLVar, remove_product, showSizechart, showPreorderModal,
9 *   chozeSystemSize, beautifyTableSizes, initAutocomplete, sendSelectItemEvent
10 *
11 * ОГЛАВЛЕНИЕ:
12 *   1. Хелперы
13 *   2. Утилиты (getURLVar, remove_product)
14 *   3. Таблица размеров (sizechart)
15 *   4. Предзаказ
16 *   5. Формы: бонусы, контакты, рассылка, загрузка файлов
17 *   6. Карточки товара: выбор опций, переходы
18 *   7. Валюта, поиск, переключатель вида списка
19 *   8. Корзина (cart), купоны (voucher)
20 *   9. Избранное (wishlist)
21 *  10. Сравнение (productCompare, compare)
22 *  11. Модалка «Условия соглашения» (.agree)
23 *  12. Автокомплит (initAutocomplete)
24 *  13. Аналитика GA4 (view_item_list, select_item, поиск)
25 *  14. Подписка на товар не в наличии
26 *  15. Неиспользуемый код (закомментирован)
27 */
28
29;(() => {
30	'use strict'
31
32	/* ============================================================
33	 * 1. ХЕЛПЕРЫ
34	 * ============================================================ */
35
36	const qs = (selector, root = document) => root.querySelector(selector)
37	const qsa = (selector, root = document) => [...root.querySelectorAll(selector)]
38
39	// Делегирование событий (аналог $(document).on(event, selector, fn))
40	const delegate = (eventName, selector, handler) => {
41		document.addEventListener(eventName, e => {
42			const target = e.target.closest(selector)
43			if (target) handler(e, target)
44		})
45	}
46
47	// POST с ответом-JSON (аналог $.ajax type:post dataType:json).
48	// body — строка "a=1&b=2", URLSearchParams или FormData
49	const postJSON = (url, body) =>
50		fetch(url, {
51			method: 'POST',
52			headers: { 'X-Requested-With': 'XMLHttpRequest' },
53			body: typeof body === 'string' ? new URLSearchParams(body) : body
54		}).then(r => r.json())
55
56	const getJSON = url =>
57		fetch(url, { headers: { 'X-Requested-With': 'XMLHttpRequest' } }).then(r => r.json())
58
59	// Показ/скрытие (аналог .show()/.hide())
60	const show = (el, display = 'block') => {
61		if (el) el.style.display = display
62	}
63	const hide = el => {
64		if (el) el.style.display = 'none'
65	}
66
67	// Вставка HTML после элемента / удаление элементов
68	const insertAfter = (el, html) => el?.insertAdjacentHTML('afterend', html)
69	const removeAll = selector => qsa(selector).forEach(el => el.remove())
70
71	// Отправка dataLayer из ответа сервера (повторяется во всех AJAX-обработчиках)
72	const pushDataLayer = json => {
73		if (!json?.dataLayer) return
74		window.dataLayer = window.dataLayer || []
75		window.dataLayer.push({ ecommerce: null })
76		window.dataLayer.push(json.dataLayer)
77	}
78
79	// Обновление счётчиков корзины в шапке
80	const updateCartCounters = json => {
81		setTimeout(() => {
82			qsa('.cart > .cart_counter').forEach(el => (el.innerHTML = json.total ?? ''))
83		}, 100)
84		if (json.total) {
85			qsa('.cart-fragment_header--counter').forEach(el => (el.innerHTML = json.total))
86		}
87		if (json.cart_total) {
88			qsa('.cart-fragment_header--price').forEach(el => (el.innerHTML = json.cart_total))
89		}
90	}
91
92	// Страница скроллится внутри <body> — прокрутка к элементу
93	const scrollBodyToEl = el => {
94		if (!el) return
95		const top = el.getBoundingClientRect().top + document.body.scrollTop - 100
96		document.body.scrollTo({ top, behavior: 'smooth' })
97	}
98
99	// Сериализация формы опций товара рядом с кнопкой «в корзину».
100	// Заменяет jQuery-выражение из onclick-атрибутов:
101	//   $(this).parent().find('form.data_origin_option').serialize()
102	const serializeOptionForm = (el, scoped = false) => {
103		if (!el?.parentElement) return ''
104		const selector = scoped ? '.render_product_data_option form.data_origin_option' : 'form.data_origin_option'
105		const form = el.parentElement.querySelector(selector)
106		return form ? new URLSearchParams(new FormData(form)).toString() : ''
107	}
108
109	/* ============================================================
110	 * 2. УТИЛИТЫ
111	 * ============================================================ */
112
113	// Значение GET-параметра текущего URL (исторический API OpenCart)
114	function getURLVar(key) {
115		const params = new URLSearchParams(window.location.search)
116		return params.get(key) ?? ''
117	}
118
119	// Кнопка «удалить выбранное» в корзине: убирает отмеченные позиции и перезагружает
120	function remove_product(cart_link) {
121		qsa('.cart_items ._checkbox:checked').forEach(cb => cart.remove_key(cb.value))
122		setTimeout(() => {
123			location = cart_link
124		}, 200)
125	}
126
127	/* ============================================================
128	 * 3. ТАБЛИЦА РАЗМЕРОВ (SIZECHART)
129	 * ============================================================ */
130
131	// Выравнивает высоту ячеек таблицы размеров по заголовочной колонке
132	function beautifyTableSizes() {
133		if (!qs('#table_sizes')) return
134		setTimeout(() => {
135			qsa('#table_sizes table tr').forEach(tr => {
136				const headcol = tr.querySelector('.headcol')
137				if (!headcol) return
138				const height = headcol.getBoundingClientRect().height
139				tr.querySelectorAll('td').forEach(td => (td.style.height = `${height}px`))
140			})
141		}, 20)
142	}
143
144	// Переключение системы размеров (EUR/US/CM) — сохраняется в сессии
145	function chozeSystemSize(system_size, element) {
146		if (typeof system_size === 'undefined') return
147
148		// поддержка вызова и с DOM-элементом, и со старым jQuery-объектом
149		if (element && !(element instanceof Element) && element[0] instanceof Element) {
150			element = element[0]
151		}
152
153		const setBusy = busy => {
154			if (!(element instanceof Element)) return
155			element.style.opacity = busy ? '.5' : '1'
156			element.style.pointerEvents = busy ? 'none' : 'auto'
157		}
158
159		// lang обязателен: без языкового префикса в URL этого AJAX-запроса
160		// startup.php падает на дефолтный язык магазина (ro) вместо текущего языка страницы
161		const langSize = document.documentElement.lang
162		setBusy(true)
163		postJSON(`index.php?route=product/sizechart/setSystemSize${langSize ? `&lang=${langSize}` : ''}`, `sustem_size=${encodeURIComponent(system_size)}`)
164			.then(json => console.log(json))
165			.catch(err => console.log(err))
166			.finally(() => setBusy(false))
167	}
168
169	// Открывает модалку с таблицей размеров товара (подгружается AJAX-ом)
170	function showSizechart(product_id, sizechart_message) {
171		product_id = parseInt(product_id, 10)
172		if (isNaN(product_id)) return
173
174		const modal = qs('#table_sizes')
175		if (!modal) return
176
177		const text = modal.querySelector('.text')
178		if (text) text.innerHTML = sizechart_message ?? 'Loading...'
179		modal.querySelectorAll('.table_sizes').forEach(el => el.remove())
180
181		show(modal)
182
183		// lang обязателен: без языкового префикса в URL этого AJAX-запроса
184		// startup.php падает на дефолтный язык магазина (ro) вместо текущего языка страницы
185		const lang = document.documentElement.lang
186		postJSON(`index.php?route=product/sizechart/popup${lang ? `&lang=${lang}` : ''}`, `product_id=${product_id}`)
187			.then(json => {
188				if (json.view) {
189					// из HTML-ответа берём содержимое .modal_body и его класс
190					// (класс важен: .sizeguide-modal переключает вёрстку на
191					// фиксированную высоту, если у товара настроен калькулятор)
192					const tmp = document.createElement('div')
193					tmp.innerHTML = json.view
194					const body = tmp.querySelector('.modal_body')
195					const target = modal.querySelector('.modal_body')
196					if (body && target) {
197						target.className = body.className
198						target.innerHTML = body.innerHTML
199					}
200					beautifyTableSizes()
201					initSizeGuideUnitToggle(modal)
202					initSizeGuideCalculator(modal)
203				}
204			})
205			.catch(err => console.log(err))
206	}
207
208	// Закрывает все открытые модалки (использовалось раньше только кнопкой OK
209	// гида по размерам, теперь также кнопкой "Selectează", если рекомендованный
210	// размер совпал с уже открытым товаром)
211	const closeAllModals = () => {
212		qsa('.modal_').forEach(modal => {
213			hide(modal)
214			modal.dispatchEvent(new CustomEvent('modal:hide', { bubbles: true }))
215		})
216		qsa('.modal_overlay').forEach(hide)
217	}
218
219	// Кнопка OK в футере гида по размерам. Отдельный класс (не .close_modal),
220	// чтобы не путать с крестиком закрытия: у .close_modal свой вид
221	// (диагональные линии), не текст.
222	// Если открыта вкладка калькулятора ("Ghid de mărimi") и она ещё в
223	// состоянии ввода данных — кнопка валидирует введённый размер и
224	// переключает контент вкладки на экран результата/недоступности (см.
225	// initSizeGuideCalculator, tryConfirm). Во всех остальных случаях
226	// (вкладка "Tabel de mărimi", калькулятор не настроен, либо уже показан
227	// экран результата) — просто закрывает модалку, как раньше
228	delegate('click', '.sizeguide-ok', (e, btn) => {
229		const modal = btn.closest('.modal_')
230		if (modal?.__sizeGuideCalculator?.tryConfirm()) return
231		closeAllModals()
232	})
233
234	// Переключение вкладок "Ghid de mărimi"/"Tabel de mărimi" может вернуть
235	// футер с кнопкой OK, если он был скрыт экраном результата калькулятора.
236	// setTimeout — чтобы отработать после общего обработчика [data-toggle="tab"]
237	// (см. ниже, п.13), который выставляет класс .active на вкладке
238	delegate('click', '.sizeguide-tabs [data-toggle="tab"]', (e, toggle) => {
239		const modal = toggle.closest('.modal_')
240		setTimeout(() => modal?.__sizeGuideCalculator?.syncFooter?.(), 0)
241	})
242
243	// Навигация-плитка универсального справочника (calculator.type ==
244	// 'universal') — клик по категории скроллит к нужному разделу внутри
245	// прокручиваемой области модалки, без перехода по ссылке
246	delegate('click', '.sizeguide-universal-nav a[href^="#"]', (e, link) => {
247		e.preventDefault()
248		const target = document.getElementById(link.getAttribute('href').slice(1))
249		target?.scrollIntoView({ block: 'start' })
250	})
251
252	// Переключатель см/дюймы у строк таблицы, помеченных в админке как
253	// "значения в см" — просто прячет/показывает заранее посчитанные на
254	// бэкенде пары <span class="unit-cm">/<span class="unit-inch">
255	function initSizeGuideUnitToggle(modal) {
256		const select = qs('[data-table-unit-select]', modal)
257		if (!select) return
258
259		const apply = unit => {
260			// у .unit-inch есть базовый display:none в CSS, поэтому при показе
261			// нужно явное 'inline', а не '' (пустая строка просто снимает
262			// инлайн-стиль и откатывает к правилу из таблицы стилей)
263			qsa('.unit-cm', modal).forEach(el => (el.style.display = unit === 'inch' ? 'none' : 'inline'))
264			qsa('.unit-inch', modal).forEach(el => (el.style.display = unit === 'inch' ? 'inline' : 'none'))
265		}
266
267		select.onchange = () => apply(select.value)
268		apply(select.value)
269	}
270
271	// Синонимы буквенных размеров одежды (те же, что в admin's clothesSizeIndex,
272	// admin/view/template/catalog/sizechart_form.twig) — в таблице размеров
273	// брендом может быть подписано "1X"/"2X" и т.п., а у товара реальный
274	// вариант размера при этом называется "XL"/"XXL". Без этой нормализации
275	// такой размер никогда не находится среди вариантов товара и калькулятор
276	// ошибочно показывает "размер недоступен", хотя вариант есть.
277	const CLOTHES_SIZE_SYNONYMS = {
278		'2XL': 'XXL', 'XXXL': '3XL', 'XXXXL': '4XL', 'XXXXXL': '5XL',
279		'1X': 'XL', '2X': 'XXL', '3X': '3XL', '4X': '4XL', '5X': '5XL'
280	}
281
282	const normalizeClothesSize = raw => {
283		const norm = (raw || '').toString().trim().toUpperCase().replace(/\s+/g, '')
284		return CLOTHES_SIZE_SYNONYMS[norm] || norm
285	}
286
287	// Ищет среди уже отрисованных на странице товара размеров (список
288	// EUR/US/CM/RUS над кнопкой "Ghid de mărimi", .toggle_tab li) размер,
289	// совпадающий с тем, что вычислил калькулятор — чтобы понять, есть ли у
290	// товара такой размер вообще (иначе показываем экран "размер недоступен")
291	function findProductSizeMatch(label) {
292		const items = qsa('.block-sizes .toggle_tab li')
293		if (!items.length) return { checked: false }
294
295		// Ячейка таблицы может перечислять через запятую несколько равнозначных
296		// названий одного размера (напр. "XXL,2XL,2X") — сравниваем реальную
297		// кнопку с каждым названием по отдельности, а не со всей строкой целиком
298		const labelParts = label.toString().split(',')
299
300		const sizesMatch = text => {
301			if (text === label) return true
302
303			for (const part of labelParts) {
304				if (normalizeClothesSize(text) === normalizeClothesSize(part)) return true
305
306				const a = parseFloat(text)
307				const b = parseFloat(part)
308				if (!isNaN(a) && !isNaN(b) && a === b) return true
309			}
310
311			return false
312		}
313
314		for (const li of items) {
315			const text = (li.textContent || '').trim()
316			if (!sizesMatch(text)) continue
317			// Возвращаем text (а не исходный label) — так покупатель видит
318			// чистое название реального варианта товара ("XXL"), а не сырую
319			// ячейку таблицы, которая может перечислять через запятую
320			// несколько равнозначных названий ("XXL,2XL,2X")
321			const link = li.querySelector('a')
322			return { checked: true, available: true, href: link ? link.getAttribute('href') : null, text: text }
323		}
324		return { checked: true, available: false }
325	}
326
327	// Калькулятор размера (вкладка "Ghid de mărimi"): по введённому росту/весу
328	// или длине стопы находит подходящую колонку диапазонов из настроек
329	// админки и показывает соответствующий размер, взятый из строки таблицы,
330	// выбранной в админке как "строка с результатом" (напр. EU для обуви),
331	// либо из первой строки по умолчанию (подходит для одежды — там первая
332	// строка обычно и есть сам размер XS/S/M...).
333	// Экран калькулятора переключается между тремя состояниями:
334	//   'form'        — поля ввода (по умолчанию)
335	//   'result'      — найденный размер + кнопки "Selectează"/"Înapoi"
336	//   'unavailable' — у товара нет такого размера + кнопка "Înapoi"
337	// Переключение на 'result'/'unavailable' происходит по кнопке OK в футере
338	// модалки (см. delegate('click', '.sizeguide-ok', ...) выше), которая
339	// вызывает modal.__sizeGuideCalculator.tryConfirm()
340	function initSizeGuideCalculator(modal) {
341		const holder = qs('[data-calculator]', modal)
342		modal.__sizeGuideCalculator = null
343		if (!holder) return
344
345		let calculator
346		try {
347			calculator = JSON.parse(holder.dataset.calculator)
348		} catch (e) {
349			return
350		}
351		if (!calculator || !calculator.columns) return
352
353		const introEl = qs('.sizeguide-intro', holder)
354		const formEl = qs('.sizeguide-calculator-form', holder)
355		const resultEl = qs('.sizeguide-calculator-result', holder)
356		const unavailableEl = qs('.sizeguide-calculator-unavailable', holder)
357		const footerEl = qs('.sizeguide-footer', modal)
358
359		const resultLabelEl = qs('.sizeguide-result-label', resultEl)
360		const resultValueEl = qs('.sizeguide-result-value', resultEl)
361		const selectBtn = qs('.sizeguide-select', resultEl)
362		const unavailableTextEl = qs('.sizeguide-unavailable-text', unavailableEl)
363
364		if (resultLabelEl) resultLabelEl.textContent = holder.dataset.textResultLabel ?? ''
365		if (selectBtn) selectBtn.textContent = holder.dataset.textSelect ?? ''
366		if (unavailableTextEl) unavailableTextEl.textContent = holder.dataset.textUnavailable ?? ''
367		qsa('.sizeguide-back', holder).forEach(a => (a.textContent = holder.dataset.textBack ?? ''))
368
369		let state = 'form'
370		let selectHref = null
371		let matchedColumn = null
372		let confirm = () => {}
373
374		const setDisplay = (el, visible, display = 'block') => {
375			if (el) el.style.display = visible ? display : 'none'
376		}
377
378		const syncFooter = () => {
379			const paneActive = holder.closest('.tab-pane')?.classList.contains('active')
380			setDisplay(footerEl, !(paneActive && state !== 'form'))
381		}
382
383		const setState = next => {
384			state = next
385			setDisplay(introEl, state === 'form')
386			setDisplay(formEl, state === 'form')
387			setDisplay(resultEl, state === 'result', 'flex')
388			setDisplay(unavailableEl, state === 'unavailable', 'flex')
389			syncFooter()
390		}
391
392		// Есть ли хотя бы в одной колонке заданы границы для этого поля
393		// (напр. "weight") — используется, чтобы не требовать от покупателя
394		// значение, которого в этой конкретной таблице всё равно нет
395		// (см. calculator.columns у clothes/clothes_measure: часть таблиц
396		// заполнена по росту, часть — по обхватам, а не все поля сразу)
397		const fieldHasData = field => {
398			for (const range of Object.values(calculator.columns)) {
399				if (range[`${field}_min`] !== undefined || range[`${field}_max`] !== undefined) return true
400			}
401			return false
402		}
403
404		const columnLabel = col => {
405			const rowSelector =
406				calculator.result_row !== undefined && calculator.result_row !== null
407					? `[data-row-index="${calculator.result_row}"]`
408					: 'tr'
409			const row = qs(`.table_sizes table ${rowSelector}`, modal)
410			const cell = row && row.querySelector(`.long-${col}`)
411			if (!cell) return col
412			// в ячейке может быть пара скрытых span.unit-cm/unit-inch (см/дюймы) —
413			// textContent склеил бы оба, поэтому берём видимую единицу явно
414			const unitCm = cell.querySelector('.unit-cm')
415			return (unitCm ? unitCm.textContent : cell.textContent).trim()
416		}
417
418		const matchColumn = predicate => {
419			for (const [col, range] of Object.entries(calculator.columns)) {
420				if (predicate(range)) return col
421			}
422			return null
423		}
424
425		// Диапазон допустимых значений по всем колонкам сразу — для подсказки
426		// под полем ввода (напр. "от 21.9 до 38.1")
427		const globalRange = (minKey, maxKey) => {
428			let min = Infinity
429			let max = -Infinity
430			for (const range of Object.values(calculator.columns)) {
431				if (range[minKey] !== undefined && range[minKey] !== null) min = Math.min(min, range[minKey])
432				if (range[maxKey] !== undefined && range[maxKey] !== null) max = Math.max(max, range[maxKey])
433			}
434			return min <= max ? { min, max } : null
435		}
436
437		const formatNum = n => {
438			const rounded = Math.round(n * 10) / 10
439			return Number.isInteger(rounded) ? String(rounded) : rounded.toFixed(1)
440		}
441
442		const setHint = (hintEl, template, min, max) => {
443			if (!hintEl || !template) return
444			hintEl.textContent = template.replace('{min}
444', formatNum(min)).replace('{max}', formatNum(max))
445		}
446
447		const setHintError = (hintEl, isError) => {
448			hintEl?.classList.toggle('sizeguide-hint--error', isError)
449		}
450
451		// Переводит калькулятор в экран результата — если у товара нет такого
452		// размера вообще (не найден среди .toggle_tab li), показывает экран
453		// "размер недоступен" вместо результата
454		const resolveResult = col => {
455			const label = columnLabel(col)
456			const match = findProductSizeMatch(label)
457
458			if (match.checked && !match.available) {
459				selectHref = null
460				setState('unavailable')
461				return
462			}
463
464			selectHref = match.href
465			// match.text — чистое название реального варианта товара, найденное
466			// findProductSizeMatch (напр. "XXL"). Если товар вообще не проверялся
467			// (match.checked === false, нет .toggle_tab на странице), такого
468			// текста нет — тогда показываем первое имя из возможной строки с
469			// перечислением через запятую ("XXL,2XL,2X" -> "XXL"), а не сырую
470			// строку целиком
471			if (resultValueEl) resultValueEl.textContent = match.text || label.toString().split(',')[0].trim()
472			setState('result')
473		}
474
475		// Общий калькулятор для типов с двумя измеряемыми полями: рост+вес
476		// (clothes) или обхват груди+талии (clothes_measure). Поле, для
477		// которого ни у одной колонки не заданы границы (напр. "вес" в
478		// детской таблице, где реальных данных о весе нет — см. fieldHasData),
479		// не показывается и не требуется от покупателя.
480		const initTwoFieldCalculator = (fieldA, fieldB, hintKeyA, hintKeyB) => {
481			const activeA = fieldHasData(fieldA)
482			const activeB = fieldHasData(fieldB)
483
484			const inputA = qs(`[data-role="${fieldA}"]`, holder)
485			const inputB = qs(`[data-role="${fieldB}"]`, holder)
486			const hintA = qs(`[data-hint-for="${fieldA}"]`, holder)
487			const hintB = qs(`[data-hint-for="${fieldB}"]`, holder)
488
489			setDisplay(inputA?.closest('.sizeguide-field'), activeA)
490			setDisplay(inputB?.closest('.sizeguide-field'), activeB)
491
492			const rangeA = activeA ? globalRange(`${fieldA}_min`, `${fieldA}_max`) : null
493			const rangeB = activeB ? globalRange(`${fieldB}_min`, `${fieldB}_max`) : null
494			if (rangeA) setHint(hintA, holder.dataset[hintKeyA], rangeA.min, rangeA.max)
495			if (rangeB) setHint(hintB, holder.dataset[hintKeyB], rangeB.min, rangeB.max)
496
497			// Если строгий "рост И вес одновременно попадают в диапазон этой же
498			// колонки" не находит совпадения — не человек виноват, а сама модель:
499			// диапазоны роста и веса растут в жёсткой связке (выше рост = больше
500			// вес), а реальные люди бывают, например, высокими и поджарыми (рост
501			// тянет к одной колонке, вес — к соседней, меньшей). В этом случае
502			// берём колонку, чей центр (по обоим полям в нормированных единицах)
503			// ближе всего к введённым значениям — так результат почти всегда
504			// находится, и "размер недоступен" срабатывает по реальной причине
505			// (у товара нет такого варианта), а не из-за пробела в сетке диапазонов.
506			const nearestColumn = (valueA, valueB) => {
507				let best = null
508				let bestDist = Infinity
509
510				for (const [col, r] of Object.entries(calculator.columns)) {
511					let dist = 0
512
513					if (activeA && r[`${fieldA}_min`] !== undefined && r[`${fieldA}_max`] !== undefined) {
514						const center = (r[`${fieldA}_min`] + r[`${fieldA}_max`]) / 2
515						const halfWidth = (r[`${fieldA}_max`] - r[`${fieldA}_min`]) / 2 || 1
516						dist += ((valueA - center) / halfWidth) ** 2
517					}
518
519					if (activeB && r[`${fieldB}_min`] !== undefined && r[`${fieldB}_max`] !== undefined) {
520						const center = (r[`${fieldB}_min`] + r[`${fieldB}_max`]) / 2
521						const halfWidth = (r[`${fieldB}_max`] - r[`${fieldB}_min`]) / 2 || 1
522						dist += ((valueB - center) / halfWidth) ** 2
523					}
524
525					if (dist < bestDist) {
526						bestDist = dist
527						best = col
528					}
529				}
530
531				return best
532			}
533
534			const recalc = () => {
535				setHintError(hintA, false)
536				setHintError(hintB, false)
537
538				const valueA = activeA ? parseFloat(inputA?.value) : null
539				const valueB = activeB ? parseFloat(inputB?.value) : null
540				if ((activeA && isNaN(valueA)) || (activeB && isNaN(valueB))) {
541					matchedColumn = null
542					return
543				}
544				matchedColumn = matchColumn(
545					r =>
546						(!activeA || (valueA >= (r[`${fieldA}_min`] ?? -Infinity) && valueA <= (r[`${fieldA}_max`] ?? Infinity))) &&
547						(!activeB || (valueB >= (r[`${fieldB}_min`] ?? -Infinity) && valueB <= (r[`${fieldB}_max`] ?? Infinity)))
548				)
549
550				if (matchedColumn === null) {
551					matchedColumn = nearestColumn(valueA, valueB)
552				}
553			}
554
555			inputA?.addEventListener('input', recalc)
556			inputB?.addEventListener('input', recalc)
557			recalc()
558
559			confirm = () => {
560				if (matchedColumn === null) {
561					if (activeA) setHintError(hintA, true)
562					if (activeB) setHintError(hintB, true)
563					return
564				}
565				resolveResult(matchedColumn)
566			}
567		}
568
569		if (calculator.type === 'clothes') {
570			initTwoFieldCalculator('height', 'weight', 'hintHeight', 'hintWeight')
571		}
572
573		if (calculator.type === 'clothes_measure') {
574			initTwoFieldCalculator('chest', 'waist', 'hintChest', 'hintWaist')
575		}
576
577		if (calculator.type === 'shoes') {
578			const footInput = qs('[data-role="foot"]', holder)
579			const unitSelect = qs('[data-foot-unit-select]', holder)
580			const footHint = qs('[data-hint-for="foot"]', holder)
581
582			const footRangeCm = globalRange('foot_min', 'foot_max')
583
584			const updateHint = () => {
585				if (!footRangeCm) return
586				const factor = unitSelect?.value === 'inch' ? 1 / 2.54 : 1
587				setHint(footHint, holder.dataset.hintFoot, footRangeCm.min * factor, footRangeCm.max * factor)
588			}
589			updateHint()
590
591			const recalc = () => {
592				setHintError(footHint, false)
593
594				const raw = parseFloat(footInput?.value)
595				if (isNaN(raw)) {
596					matchedColumn = null
597					return
598				}
599				const cm = unitSelect?.value === 'inch' ? raw * 2.54 : raw
600				matchedColumn = matchColumn(r => cm >= (r.foot_min ?? -Infinity) && cm <= (r.foot_max ?? Infinity))
601			}
602
603			unitSelect?.addEventListener('change', () => {
604				updateHint()
605				recalc()
606			})
607			footInput?.addEventListener('input', recalc)
608			recalc()
609
610			confirm = () => {
611				if (matchedColumn === null) {
612					setHintError(footHint, true)
613					return
614				}
615				resolveResult(matchedColumn)
616			}
617		}
618
619		selectBtn?.addEventListener('click', () => {
620			if (selectHref) {
621				location.href = selectHref
622			} else {
623				closeAllModals()
624			}
625		})
626
627		qsa('.sizeguide-back', holder).forEach(a => a.addEventListener('click', () => setState('form')))
628
629		setState('form')
630
631		modal.__sizeGuideCalculator = {
632			tryConfirm: () => {
633				const paneActive = holder.closest('.tab-pane')?.classList.contains('active')
634				if (!paneActive || state !== 'form') return false
635				confirm()
636				return true
637			},
638			syncFooter
639		}
640	}
641
642	/* ============================================================
643	 * 4. ПРЕДЗАКАЗ
644	 * ============================================================ */
645
646	// Модалка предзаказа: открытие и отправка формы
647	function showPreorderModal(product_id) {
648		const modal = qs('#modal_preorder')
649		if (!modal) return
650
651		const idInput = modal.querySelector('[name="preorder_product_id"]')
652		if (idInput) idInput.value = product_id
653		show(modal)
654
655		const button = modal.querySelector('#button_preorder')
656		if (!button) return
657
658		// onclick-присваивание вместо addEventListener: модалка открывается
659		// многократно, обработчик не должен накапливаться
660		button.onclick = e => {
661			e.preventDefault()
662			const fields = qsa(':scope input, :scope select, :scope textarea', modal)
663			const params = new URLSearchParams()
664			fields.forEach(f => {
665				if (!f.name || f.disabled) return
666				if ((f.type === 'checkbox' || f.type === 'radio') && !f.checked) return
667				params.append(f.name, f.value)
668			})
669
670			button.classList.add('disabled')
671			postJSON('index.php?route=checkout/checkout/preorder', params)
672				.then(json => {
673					modal.querySelectorAll('.alert, .text-danger').forEach(el => el.remove())
674					modal.classList.remove('has-error')
675
676					if (json.error_warning) {
677						modal
678							.querySelector('.modal-header')
679							?.insertAdjacentHTML(
680								'afterend',
681								`<div class="alert alert-danger" style="margin:5px;"><i class="fa fa-exclamation-circle"></i> ${json.error_warning}</div>`
682							)
683					}
684					if (json.error) {
685						for (const [index, value] of Object.entries(json.error)) {
686							const element = modal.querySelector(`#input-preorder_${index}`)
687							element?.parentElement?.classList.add('has-error')
688							insertAfter(element, `<div class="text-danger">${value}</div>`)
689						}
690					}
691					if (json.success) {
692						const common = qs('#modal_common')
693						if (common) {
694							const heading = common.querySelector('.main-heading')
695							const message = common.querySelector('.message_text')
696							if (heading) heading.innerHTML = json.success
697							if (message) message.innerHTML = json.success_info ?? ''
698						}
699						hide(modal)
700						show(common)
701						modal.querySelector('#form_preorder')?.reset()
702					}
703				})
704				.catch(err => console.log(err))
705				.finally(() => button.classList.remove('disabled'))
706		}
707	}
708
709	/* ============================================================
710	 * 5. ФОРМЫ: БОНУСЫ, КОНТАКТЫ, РАССЫЛКА, ЗАГРУЗКА ФАЙЛОВ
711	 * ============================================================ */
712
713	// Собирает значения заполняемых полей контейнера (аналог jQuery-сериализации
714	// по списку input/select/textarea с checked-фильтром)
715	const collectFields = container => {
716		const params = new URLSearchParams()
717		qsa('input, select, textarea', container).forEach(f => {
718			if (!f.name || f.disabled) return
719			if (f.type === 'submit' || f.type === 'button' || f.type === 'file') return
720			if ((f.type === 'checkbox' || f.type === 'radio') && !f.checked) return
721			params.append(f.name, f.value)
722		})
723		return params
724	}
725
726	document.addEventListener('DOMContentLoaded', () => {
727		// пересчёт таблицы размеров при изменении окна
728		window.addEventListener('resize', beautifyTableSizes)
729
730		// Поиск по брендам на странице производителей (страница «Бренды»)
731		delegate('keyup', '#product-manufacturer .brands_search_input', (e, input) => {
732			const slug = input.value
733			const root = qs('#product-manufacturer')
734			if (!root) return
735
736			if (slug !== '') {
737				qsa('.brand_category_letter', root).forEach(hide)
738				qsa('.brand_category_letter .brand_single', root).forEach(el => {
739					hide(el)
740					el.classList.remove('show_element')
741				})
742				// показываем бренды, содержащие запрос (регистрозависимо, как было)
743				qsa('.brand_category_letter .brand_single_value', root).forEach(value => {
744					if (!value.textContent.includes(slug)) return
745					const single = value.closest('.brand_single')
746					if (single) {
747						single.classList.add('show_element')
748						show(single)
749					}
750				})
751				// показываем буквы, в которых остались видимые бренды
752				qsa('.brand_category_letter .list .brand_single.show_element', root).forEach(el => {
753					const letterId = el.closest('.brand_category_letter')?.dataset.id
754					if (letterId !== undefined) {
755						qsa(`[data-id="${letterId}"].brand_category_letter`, root).forEach(letter => show(letter))
756					}
757				})
758			} else {
759				qsa('.brand_category_letter', root).forEach(el => show(el))
760				qsa('.brand_category_letter .brand_single', root).forEach(el => show(el))
761			}
762		})
763
764		// Форма бонусной программы (страница «Бонусы»)
765		delegate('click', '#information-bonuses #send-data-bonuses', (e, button) => {
766			e.preventDefault()
767			const root = qs('#information-bonuses')
768			if (!root) return
769
770			button.classList.add('disabled')
771			postJSON('index.php?route=information/bonuses/send_form', collectFields(root))
772				.then(json => {
773					pushDataLayer(json)
774					qsa('.alert-dismissible, .text-danger', root).forEach(el => el.remove())
775
776					if (json.error_warning) {
777						root.insertAdjacentHTML(
778							'afterbegin',
779							`<div class="alert alert-danger alert-dismissible"><i class="fa fa-exclamation-circle"></i> ${json.error_warning} <button type="button" class="close" data-dismiss="alert">&times;</button></div>`
780						)
781						scrollBodyToEl(root.querySelector('.alert-dismissible'))
782					}
783
784					if (json.error) {
785						for (const key of Object.keys(json.error)) {
786							const element = root.querySelector(`#input-bonuses-${key.replace('_', '-')}`)
787							if (!element) continue
788							if (element.parentElement.classList.contains('check_it')) {
789								element.parentElement.insertAdjacentHTML('beforeend', `<div class="text-danger">${json.error[key]}</div>`)
790							} else {
791								insertAfter(element, `<div class="text-danger">${json.error[key]}</div>`)
792							}
793						}
794					}
795
796					if (json.success) {
797						root.querySelector('#bonuses_form')?.reset()
798						show(qs('#success_bonuses_form'))
799					}
800				})
801				.catch(err => console.log(err))
802				.finally(() => button.classList.remove('disabled'))
803		})
804
805		// Кнопка «связаться с магазином»: открывает модалку контактов с темой обращения
806		delegate('click', '#information-contact .contact_store', (e, button) => {
807			const form = qs('#contact_form')
808			if (!form) return
809
810			const title = button.dataset.title ?? ''
811			const modalTitle = form.querySelector('.modal_title')
812			if (modalTitle) modalTitle.innerHTML = title
813
814			const nameInput = form.querySelector('input[name="form_name"]')
815			if (nameInput) nameInput.value = title
816
817			const langInput = form.querySelector('input[name="lang_form_name"]')
818			if (langInput) langInput.value = button.dataset.langTitle ?? title
819
820			show(form)
821		})
822
823		// Отправка формы контактов
824		delegate('click', '#contact_form #send-data', (e, button) => {
825			const root = qs('#contact_form')
826			if (!root) return
827
828			button.classList.add('disabled')
829			postJSON('index.php?route=information/contact/send_form', collectFields(root))
830				.then(json => {
831					pushDataLayer(json)
832					qsa('.alert-dismissible, .text-danger', root).forEach(el => el.remove())
833
834					if (json.error_warning) {
835						root
836							.querySelector('.modal_body')
837							?.insertAdjacentHTML(
838								'afterbegin',
839								`<div class="alert alert-danger alert-dismissible"><i class="fa fa-exclamation-circle"></i> ${json.error_warning} <button type="button" class="close" data-dismiss="alert">&times;</button></div>`
840							)
841					}
842
843					if (json.error) {
844						for (const key of Object.keys(json.error)) {
845							const element = root.querySelector(`#input-contact-${key.replace('_', '-')}`)
846							if (!element) continue
847							if (element.parentElement.classList.contains('check_it')) {
848								element.parentElement.insertAdjacentHTML('beforeend', `<div class="text-danger">${json.error[key]}</div>`)
849							} else {
850								insertAfter(element, `<div class="text-danger">${json.error[key]}</div>`)
851							}
852						}
853					}
854
855					// прокручиваем контейнер модалки к первой ошибке
856					const container = root.querySelector('.modal_body')
857					if (container) {
858						if (json.error_warning) {
859							const elm = document.querySelector('.alert-danger')
860							if (elm) container.scrollTop = elm.offsetTop
861						} else if (json.error) {
862							const elm = document.querySelector('.text-danger')?.parentNode
863							if (elm) container.scrollTop = elm.offsetTop
864						}
865					}
866
867					if (json.success) {
868						root.querySelector('#contact_form_data')?.reset()
869						hide(root)
870						show(qs('#success_contact_form'))
871					}
872				})
873				.catch(err => console.log(err))
874				.finally(() => button.classList.remove('disabled'))
875		})
876
877		// Загрузка файла для опции товара (стандартный механизм OpenCart)
878		let uploadTimer = null
879		delegate('click', "button[data-id^='button-upload']", (e, node) => {
880			qs('#form-upload')?.remove()
881
882			document.body.insertAdjacentHTML(
883				'afterbegin',
884				'<form enctype="multipart/form-data" id="form-upload" style="display: none;"><input type="file" name="file" /></form>'
885			)
886
887			const fileInput = qs('#form-upload input[name="file"]')
888			fileInput.click()
889
890			clearInterval(uploadTimer)
891			uploadTimer = setInterval(() => {
892				if (!fileInput.value) return
893				clearInterval(uploadTimer)
894
895				node.classList.add('disabled')
896				fetch('index.php?route=tool/upload', {
897					method: 'POST',
898					headers: { 'X-Requested-With': 'XMLHttpRequest' },
899					body: new FormData(qs('#form-upload'))
900				})
901					.then(r => r.json())
902					.then(json => {
903						removeAll('.text-danger')
904
905						const input = node.parentElement?.querySelector('input')
906						if (json.error) {
907							insertAfter(input, `<div class="text-danger">${json.error}</div>`)
908						}
909						if (json.success) {
910							alert(json.success)
911							if (input) input.value = json.code
912						}
913					})
914					.catch(err => alert(err))
915					.finally(() => node.classList.remove('disabled'))
916			}, 500)
917		})
918
919		// Подписка на рассылку (форма в подвале)
920		delegate('click', '#newsletter_submit', (e, button) => {
921			e.preventDefault()
922			const form = button.closest('form')
923			if (!form) {
924				console.log('Form not found')
925				return
926			}
927
928			const setReadonly = readonly => {
929				;['email', 'name', 'phone'].forEach(name => {
930					const input = form.querySelector(`input[name=${name}]`)
931					if (input) input.readOnly = readonly
932				})
933			}
934
935			button.classList.add('disabled')
936			setReadonly(true)
937
938			postJSON('index.php?route=send_forms/send/subscribe', new URLSearchParams(new FormData(form)))
939				.then(json => {
940					pushDataLayer(json)
941					form.querySelectorAll('.text-danger').forEach(el => el.remove())
942
943					if (json.success) {
944						const modal = qs('#discount_mail')
945						if (modal) {
946							const title = modal.querySelector('.modal-notification__title')
947							const text = modal.querySelector('.modal-notification__text')
948							if (title) title.innerHTML = json.success
949							if (text) text.innerHTML = json.text_success ?? ''
950						}
951						;['email', 'name', 'phone'].forEach(name => {
952							const input = form.querySelector(`input[name=${name}]`)
953							if (input) input.value = ''
954						})
955						const policy = form.querySelector('input[name="policy_agreement"]')
956						if (policy) policy.checked = false
957						show(modal)
958					}
959
960					if (json.error_email) {
961						insertAfter(form.querySelector('input[name=email]'), `<div class="text-danger">${json.error_email}</div>`)
962					}
963					if (json.error_phone) {
964						insertAfter(form.querySelector('input[name=phone]'), `<div class="text-danger">${json.error_phone}</div>`)
965					}
966					if (json.error_policy) {
967						insertAfter(form.querySelector('input[name=policy_agreement]'), `<div class="text-danger">${json.error_policy}</div>`)
968					}
969
970					// сообщения об ошибках исчезают через 5 секунд
971					setTimeout(() => removeAll('.text-danger'), 5000)
972				})
973				.catch(err => console.log(err))
974				.finally(() => {
975					button.classList.remove('disabled')
976					setReadonly(false)
977				})
978		})
979
980		// Подсветка полей с ошибками, отрисованными сервером
981		qsa('.text-danger').forEach(el => {
982			const group = el.parentElement?.parentElement
983			if (group?.classList.contains('form-group')) group.classList.add('has-error')
984		})
985
986		/* ============================================================
987		 * 7. ВАЛЮТА, ПОИСК, ПЕРЕКЛЮЧАТЕЛЬ ВИДА СПИСКА
988		 * ============================================================ */
989
990		// Переключение валюты: пункт списка пишет код в скрытое поле и сабмитит форму
991		qsa('#form-currency .currency-select').forEach(item => {
992			item.addEventListener('click', e => {
993				e.preventDefault()
994				const form = qs('#form-currency')
995				const code = form?.querySelector('input[name="code"]')
996				if (code) code.value = item.getAttribute('name')
997				form?.submit()
998			})
999		})
1000
1001		// Поиск в шапке: кнопка ведёт на страницу выдачи, Enter лишь нажимает
1002		// кнопку. В текущей вёрстке кнопки нет — переходом на выдачу
1003		// (product/esearch) управляет inline-скрипт common/search.twig
1004		const headerSearchInput = qs('header #search input[name="search"]')
1005		const headerSearchButton = headerSearchInput?.parentElement?.querySelector('button')
1006
1007		const goToSearch = () => {
1008			let url = `${qs('base')?.href ?? '/'}index.php?route=product/search`
1009			const value = headerSearchInput?.value
1010			if (value) url += `&search=${encodeURIComponent(value)}`
1011			location = url
1012		}
1013
1014		headerSearchButton?.addEventListener('click', goToSearch)
1015		headerSearchInput?.addEventListener('keydown', e => {
1016			if (e.key === 'Enter') headerSearchButton?.click()
1017		})
1018
1019		// Переключатель вида «список/сетка» (страница поиска, блог)
1020		const listView = qs('#list-view')
1021		const gridView = qs('#grid-view')
1022
1023		const applyListView = () => {
1024			qsa('#content .product-grid > .clearfix').forEach(el => el.remove())
1025			qsa('#content .row > .product-grid').forEach(el => {
1026				el.className = 'product-layout product-list col-xs-12'
1027			})
1028			gridView?.classList.remove('active')
1029			listView?.classList.add('active')
1030			localStorage.setItem('display', 'list')
1031		}
1032
1033		const applyGridView = () => {
1034			// количество боковых колонок определяет ширину карточек
1035			const cols = qsa('#column-right, #column-left').length
1036			const className =
1037				cols === 2
1038					? 'product-layout product-grid col-lg-6 col-md-6 col-sm-12 col-xs-12'
1039					: cols === 1
1040						? 'product-layout product-grid col-lg-4 col-md-4 col-sm-6 col-xs-12'
1041						: 'product-layout product-grid col-lg-3 col-md-3 col-sm-6 col-xs-12'
1042			qsa('#content .product-list').forEach(el => (el.className = className))
1043			listView?.classList.remove('active')
1044			gridView?.classList.add('active')
1045			localStorage.setItem('display', 'grid')
1046		}
1047
1048		listView?.addEventListener('click', applyListView)
1049		gridView?.addEventListener('click', applyGridView)
1050
1051		if (listView || gridView) {
1052			if (localStorage.getItem('display') === 'list') {
1053				applyListView()
1054			} else {
1055				applyGridView()
1056			}
1057		}
1058
1059		// Форма логина в блоке отслеживания заказа: Enter отправляет
1060		delegate('keydown', "#collapse-checkout-option input[name='email'], #collapse-checkout-option input[name='password']", e => {
1061			if (e.key === 'Enter') qs('#collapse-checkout-option #button-login')?.click()
1062		})
1063
1064		// Кнопки «+/−» количества в корзине (js_count_cart): обновляют позицию на сервере
1065		delegate('click', '.js_count_cart div', (e, control) => {
1066			if (control.getAttribute('onclick')) return
1067			const wrap = control.closest('.js_count_cart')
1068			wrap?.querySelector('.minus')?.classList.add('ui-state-disabled')
1069			wrap?.querySelector('.plus')?.classList.add('ui-state-disabled')
1070			const count = wrap?.querySelector('input')
1071			if (!count) return
1072			setTimeout(() => {
1073				cart.update(count.dataset.key, count.value)
1074			}, 100)
1075		})
1076
1077		// Прямой ввод количества в корзине
1078		delegate('change', '.count_cart input', (e, input) => {
1079			if (Number(input.value) <= 0) input.value = 1
1080			cart.update(input.dataset.key, input.value)
1081		})
1082
1083		// Удаление превью загруженного файла (dropzone)
1084		delegate('click', '.dz-error-mark', (e, mark) => {
1085			mark.closest('.dz-preview')?.remove()
1086		})
1087
1088		/* ============================================================
1089		 * 6. КАРТОЧКИ ТОВАРА: ВЫБОР ОПЦИЙ, ПЕРЕХОДЫ
1090		 * ============================================================ */
1091
1092		// Плитка-ссылка с data-href
1093		delegate('click', '.custom__go-data_href [data-href]:not(.disable)', (e, el) => {
1094			location = el.dataset.href
1095		})
1096
1097		// Выбор опции (размера) в попапе быстрой покупки: перерисовка попапа
1098		delegate('click', '.render_product_popup_data_option [data-product-id]:not(.active):not(.disable)', (e, el) => {
1099			const product_id = parseInt(el.dataset.productId, 10)
1100			if (isNaN(product_id)) return
1101
1102			postJSON('index.php?route=product/product/getProductAjax', `product_id=${product_id}&option_popup=1`)
1103				.then(json => {
1104					removeAll('.render_product_popup_data_option .text-danger')
1105
1106					if (json.popup_modal_body_html) {
1107						const container = qs('.render_product_popup_data_option')
1108						if (container) container.outerHTML = json.popup_modal_body_html
1109					}
1110
1111					if (json.error_product) {
1112						el.classList.remove('active')
1113						el.classList.add('disable')
1114						const container = qs('.render_product_popup_data_option')
1115						container?.insertAdjacentHTML('beforeend', `<div class="text-danger">${json.error_product}</div>`)
1116						container?.querySelector('[data-active="active"]')?.classList.add('active')
1117					}
1118				})
1119				.catch(err => console.log(err))
1120		})
1121
1122		// Выбор опции на карточке товара в листинге: перерисовка карточки
1123		delegate('click', '.render_product_data_option [data-product-id]:not(.active):not(.disable)', (e, el) => {
1124			const product_id = parseInt(el.dataset.productId, 10)
1125			const view = el.dataset.productView ?? ''
1126			let image_width = parseFloat(el.dataset.productImageWidth)
1127			let image_height = parseFloat(el.dataset.productImageHeight)
1128
1129			const container = el.closest('.render_product_data')
1130			if (isNaN(product_id) || !container) return
1131
1132			if (isNaN(image_width)) image_width = 0
1133			if (isNaN(image_height)) image_height = 0
1134
1135			postJSON(
1136				'index.php?route=product/product/getProductAjax',
1137				`product_id=${product_id}&view=${view}&image_width=${image_width}&image_height=${image_height}`
1138			)
1139				.then(json => {
1140					container.querySelectorAll('.text-danger').forEach(elm => elm.remove())
1141
1142					if (json.product) {
1143						container.outerHTML = json.product
1144					}
1145
1146					if (json.error_product) {
1147						el.classList.remove('active')
1148						el.classList.add('disable')
1149						container.insertAdjacentHTML('beforeend', `<div class="text-danger">${json.error_product}</div>`)
1150						container.querySelector('[data-active="active"]')?.classList.add('active')
1151					}
1152				})
1153				.catch(err => console.log(err))
1154		})
1155	})
1156
1157	/* ============================================================
1158	 * 8. КОРЗИНА (CART), КУПОНЫ (VOUCHER)
1159	 * ============================================================ */
1160
1161	// -----------------------------
1162	// Карточки скидок (лучшая цена / купон / комплект / online) — общая
1163	// логика для блока цены на странице товара (product-core.js вызывает
1164	// window.renderDiscountCards) и для модалки «товар добавлен в корзин
1164у»
1165	// (эта модалка открывается с любой страницы — с карточки в каталоге,
1166	// со страницы товара, из корзины — поэтому логика живёт здесь, в common.js,
1167	// который подключён глобально, а не в product-core.js, который есть
1168	// только на странице товара).
1169	// -----------------------------
1170	const DISCOUNT_ICON_BUNDLE = `
1171		<svg class="discount-price-card__icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round">
1172			<rect x="3" y="3" width="13" height="13" rx="2"></rect>
1173			<path d="M8 21h10a2 2 0 0 0 2-2V9"></path>
1174		</svg>
1175	`
1176	const DISCOUNT_ICON_GIFT = `
1177		<svg class="discount-compare__icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round">
1178			<rect x="3" y="8" width="18" height="4" rx="1"></rect>
1179			<rect x="5" y="12" width="14" height="9" rx="1"></rect>
1180			<path d="M12 8v13"></path>
1181			<path d="M12 8c-1.6 0-3.2-1-3.2-2.6C8.8 3.9 10 3 12 3s3.2.9 3.2 2.4C15.2 7 13.6 8 12 8Z"></path>
1182		</svg>
1183	`
1184	const DISCOUNT_ICON_TAG = `
1185		<svg class="discount-compare__icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round">
1186			<path d="M20.6 13.4 13.4 20.6a2 2 0 0 1-2.8 0L3 13V3h10l7.6 7.6a2 2 0 0 1 0 2.8Z"></path>
1187			<circle cx="7.5" cy="7.5" r="1.5"></circle>
1188		</svg>
1189	`
1190	const DISCOUNT_ICON_CHEVRON = `
1191		<svg class="discount-compare__cta-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round">
1192			<path d="m9 6 6 6-6 6"></path>
1193		</svg>
1194	`
1195
1196	// Тултип-подсказка у ярлыка карточки: переиспользует глобальный паттерн
1197	// .pl_item/.pl_tooltip (клик — toggleTooltip() из common/footer.twig,
1198	// наведение — CSS-правило .pl_item:hover в style.scss), поэтому работает
1199	// сразу после вставки через innerHTML без повторной инициализации JS.
1200	function renderInfoTooltip(text) {
1201		if (!text) return ''
1202		return `
1203			<span class="discount-price-card__info pl_item" onclick="toggleTooltip(this)">
1204				<svg class="discount-price-card__info-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
1205					<circle cx="12" cy="12" r="10"></circle>
1206					<path d="M9.09 9a3 3 0 0 1 5.83 1c0 2-3 3-3 3"></path>
1207					<line x1="12" y1="17" x2="12.01" y2="17"></line>
1208				</svg>
1209				<span class="pl_tooltip">${text}</span>
1210			</span>
1211		`
1212	}
1213
1214	// Копирование кода купона по клику на кнопку: на 1.5с подменяем её подпись
1215	// на data-label-done, без общего тост-механизма — эта карточка рендерится
1216	// и на странице товара, и в модалке корзины, где отдельного тоста нет.
1217	// Подменяем текст только у .discount-compare__cta-label, чтобы не терять иконку.
1218	function copyPromoCode(el) {
1219		const { copy: code, labelDone } = el.dataset
1220		const label = el.querySelector('.discount-compare__cta-label') ?? el
1221		const initial = label.textContent
1222
1223		navigator.clipboard?.writeText(code).then(() => {
1224			label.textContent = labelDone
1225			setTimeout(() => {
1226				label.textContent = initial
1227			}, 1500)
1228		})
1229	}
1230	window.copyPromoCode = copyPromoCode
1231
1232	function formatDiscountPrice(amount, decimalPoint, thousandPoint, currencySuffix) {
1233		const rounded = String(Math.round(Math.max(0, amount)))
1234		const grouped = rounded.replace(/\B(?=(\d{3})+(?!\d))/g, thousandPoint || '')
1235		return `${grouped} ${currencySuffix}`.trim()
1236	}
1237
1238	function formatDiscountAmount(amount, thousandPoint) {
1239		const rounded = String(Math.round(Math.max(0, amount)))
1240		return rounded.replace(/\B(?=(\d{3})+(?!\d))/g, thousandPoint || '')
1241	}
1242
1243	// container — элемент .discount-price-rows, куда будут вставлены карточки.
1244	// basePrice — текущая цена товара за единицу (с учётом special, без скидоÐ
1244º
1245	// купона/бонусов). onlinePercent/onlineEligible — условия сквозной online-скидки
1246	// (total_special_discount), categoryPercent/categoryEligible — скидки по категории
1247	// (total_category_discount); оба приходят из window.PRODUCT_PAGE на странице товара.
1248	// В модалке корзины ни та, ни другая не считаются — см. cart_modal_body.twig.
1249	// Модули взаимоисключающие по товару (см. ModelExtensionTotalCategoryDiscount /
1250	// ModelExtensionTotalSpecialDiscount), поэтому показываем только одну карточку —
1251	// категорийная скидка приоритетнее как более специфичная.
1252	function renderDiscountCards(container, productId, basePrice, currencySuffix, onlinePercent, onlineEligible, categoryPercent, categoryEligible) {
1253		if (!container || !productId || !basePrice) return
1254
1255		const categoryAmount = (categoryEligible && categoryPercent > 0)
1256			? basePrice * categoryPercent / 100
1257			: 0
1258
1259		const onlineAmount = (!categoryAmount && onlineEligible && onlinePercent > 0)
1260			? basePrice * onlinePercent / 100
1261			: 0
1262
1263		const lang = document.documentElement.lang
1264		const langParam = lang ? `&lang=${lang}` : ''
1265
1266		fetch(`index.php?route=extension/total/product_pricing/info&product_id=${productId}${langParam}`, {
1267			headers: { 'X-Requested-With': 'XMLHttpRequest' }
1268		})
1269			.then(res => res.json())
1270			.then(({ coupon, reward, bundle, one_plus_one, is_logged, text }) => {
1271				const fmt = amount => formatDiscountPrice(amount, text.decimal_point, text.thousand_point, currencySuffix)
1272				const rows = []
1273				// Варианты купить дешевле с конечной ценой (категория/online/купон/бонус) —
1274				// взаимоисключающие, из них показывается только самый выгодный.
1275				const priceOptions = []
1276
1277				if (bundle) {
1278					const bundleNote = text.bundle_note
1279						.replace('{percent}', bundle.percent)
1280						.replace('{count}', bundle.count)
1281
1282					rows.push(`
1283						<div class="discount-price-card discount-price-card--bundle">
1284							<div class="discount-price-card__left">
1285<!--								${DISCOUNT_ICON_BUNDLE}-->
1286								<div class="discount-price-card__text">
1287									<span class="discount-price-card__label">${renderInfoTooltip(text.bundle_tooltip)}${text.bundle_label}</span>
1288									<span class="discount-price-card__note">${bundleNote}</span>
1289								</div>
1290							</div>
1291							<a class="discount-price-card__cta" href="${bundle.url}">${text.bundle_cta}</a>
1292						</div>
1293					`)
1294				}
1295
1296				if (one_plus_one) {
1297					const opoFill = str => str
1298						.replace('{units_needed}', one_plus_one.units_needed)
1299						.replace('{group_size}', one_plus_one.group_size)
1300						.replace('{discount_price}', formatDiscountAmount(one_plus_one.discount_price, text.thousand_point))
1301
1302					const note = opoFill(one_plus_one.is_free ? text.one_plus_one_note_free : text.one_plus_one_note)
1303					const tooltip = opoFill(one_plus_one.is_free ? text.one_plus_one_tooltip_free : text.one_plus_one_tooltip)
1304					const percent = one_plus_one.percent || 0
1305
1306					rows.push(`
1307						<div class="discount-price-card discount-price-card--bundle">
1308							<div class="discount-price-card__left">
1309								<div class="discount-price-card__text">
1310									<span class="discount-price-card__label">${text.one_plus_one_label}${renderInfoTooltip(tooltip)}</span>
1311									<div class="discount-price-card__bar${percent >= 100 ? ' is-complete' : ''}">
1312										<div class="discount-price-card__bar-fill" style="width: ${percent}%;"></div>
1313									</div>
1314									<span class="discount-price-card__note">${note}</span>
1315								</div>
1316							</div>
1317						</div>
1318					`)
1319				}
1320
1321				let couponAmount = 0
1322				let couponPrice = basePrice
1323
1324				if (coupon) {
1325					couponAmount = coupon.type === 'F'
1326						? Math.min(coupon.discount, basePrice)
1327						: basePrice * coupon.discount / 100
1328
1329					couponPrice = basePrice - couponAmount
1330					if (coupon.allow_stack && (onlineAmount > 0 || categoryAmount > 0)) {
1331						couponPrice -= (onlineAmount || categoryAmount)
1332					}
1333				}
1334
1335				// Зелёная кнопка карточки купона/бонуса — обработчик делегирован на document
1336				// (см. «ПРИМЕНЕНИЕ БОНУСА/КУПОНА ИЗ КАРТОЧКИ ЦЕНЫ» ниже): гостю — оÐ
1336ºÐ½Ð¾ входа,
1337				// клиенту — товар в корзину + применение бонуса/купона + переход на чекаут.
1338				const renderApplyCta = (label, attrs) => `
1339					<button type="button" class="discount-compare__cta" ${attrs} data-logged="${is_logged ? 1 : 0}">
1340						<span class="discount-compare__cta-label">${label}</span>${DISCOUNT_ICON_CHEVRON}
1341					</button>
1342				`
1343
1344				// Подпись под ценой: "<условие> · экономия X" — условие (например
1345				// "После регистрации") необязательно, тогда остаётся только экономия.
1346				const renderNote = (prefix, savingsAmount) => {
1347					const savings = text.savings_note.replace('{amount}', fmt(savingsAmount))
1348					return prefix ? `${prefix} · ${savings.charAt(0).toLowerCase()}${savings.slice(1)}` : savings
1349				}
1350
1351				if (categoryAmount > 0) {
1352					priceOptions.push({
1353						label: text.category_label,
1354						icon: DISCOUNT_ICON_TAG,
1355						note: text.category_note,
1356						finalPrice: basePrice - categoryAmount,
1357					})
1358				}
1359
1360				if (onlineAmount > 0) {
1361					priceOptions.push({
1362						label: text.online_label,
1363						icon: DISCOUNT_ICON_TAG,
1364						note: text.online_note,
1365						finalPrice: basePrice - onlineAmount,
1366					})
1367				}
1368
1369				if (coupon) {
1370					priceOptions.push({
1371						label: text.coupon_label,
1372						icon: DISCOUNT_ICON_TAG,
1373						// Код — чипом напротив тайтла (клик копирует), подпись — как у бонусной
1374						// карточки: гостю "После регистрации · экономия X", клиенту — только экономия.
1375						chip: `<span class="discount-compare__chip" data-copy="${coupon.code}" data-label-done="${text.coupon_copied}" onclick="copyPromoCode(this)">${coupon.code}</span>`,
1376						note: is_logged ? '' : text.reward_option_note,
1377						finalPrice: couponPrice,
1378						cta: renderApplyCta(text.coupon_cta, `data-discount-apply="coupon" data-coupon="${coupon.code}"`),
1379					})
1380				}
1381
1382				// Бонус считается от basePrice (основной/special-цены), так же, как
1383				// в реальном расчёте корзины (ModelExtensionTotalReward::getTotal()),
1384				// где используется $product['total'] без учёта купона/online-скидки.
1385				// Math.ceil — округляем в пользу покупателя. Чекаут (ControllerExtensionTotalReward::index()
1386				// и ::reward()) теперь тоже считает свой максимум через ceil(), так что показанное здесь
1387				// число всегда проходит валидацию при применении на чекауте.
1388				const bonusAmount = (reward && reward.percent > 0)
1389					? Math.ceil(Math.min(reward.balance, basePrice * reward.percent / 100))
1390					: 0
1391
1392				if (bonusAmount > 0) {
1393					priceOptions.push({
1394						label: text.reward_label,
1395						icon: DISCOUNT_ICON_GIFT,
1396						// Гостю — "После регистрации" (бонус ещё не начислен), клиенту — его баланс.
1397						note: reward.is_logged
1398							? text.reward_note.replace('{balance}', fmt(reward.balance))
1399							: text.reward_option_note,
1400						finalPrice: basePrice - bonusAmount,
1401						cta: renderApplyCta(reward.is_logged ? text.reward_cta_logged : text.reward_cta, 'data-discount-apply="reward"'),
1402					})
1403				}
1404
1405				// Способы взаимоисключающие (на чекауте применяется только один),
1406				// поэтому показываем одну карточку — самый выгодный вариант.
1407				if (priceOptions.length) {
1408					const best = priceOptions.reduce((acc, o) => o.finalPrice < acc.finalPrice ? o : acc)
1409
1410					rows.push(`
1411						<div class="discount-compare__option">
1412							<span class="discount-compare__head">
1413								<span class="discount-compare__title">${best.icon}${best.label}</span>
1414								${best.chip ?? ''}
1415							</span>
1416							<span class="discount-compare__price">
1417								<span class="discount-compare__number">
1417${formatDiscountAmount(best.finalPrice, text.thousand_point)}</span>
1418								<span class="discount-compare__currency">${currencySuffix}</span>
1419							</span>
1420							<span class="discount-compare__note">${renderNote(best.note, basePrice - best.finalPrice)}</span>
1421							${best.cta ?? ''}
1422						</div>
1423					`)
1424				}
1425
1426				// Больше одной карточки (акция bundle/1+1 и/или лучший вариант
1427				// цены) — над ними шапка "выбери вариант", под ними — "не суммируются".
1428				if (rows.length > 1) {
1429					rows.unshift(`<div class="discount-compare__header"><span>${text.choose_note}</span>${renderInfoTooltip(text.compare_header_tooltip)}</div>`)
1430					rows.push(`<div class="discount-compare__footer">${renderInfoTooltip(text.compare_footer_tooltip)}<span>${text.stack_footer_note}</span></div>`)
1431				}
1432
1433				container.innerHTML = rows.join('')
1434			})
1435			.catch(() => {})
1436	}
1437
1438	window.renderDiscountCards = renderDiscountCards
1439
1440	/* ============================================================
1441	 * ПРИМЕНЕНИЕ БОНУСА/КУПОНА ИЗ КАРТОЧКИ ЦЕНЫ
1442	 * ============================================================
1443	 * Кнопка "Folosește bonusul" / купона в карточке renderDiscountCards:
1444	 * - гость — открываем #login_modal (вход/регистрация по телефону; после
1445	 *   входа страница перезагружается, и кнопка уже работает как у клиента);
1446	 * - клиент на странице товара (#discount-price-rows) — сначала добавляем
1447	 *   товар в корзину тем же запросом, что и #button-cart
1448	 *   (window.productAddToCart из product-features.js, с выбранным размером);
1449	 *   в модалке корзины товар уже добавлен — этот шаг пропускается;
1450	 * - затем применяем бонус (extension/total/product_pricing/applyReward —
1451	 *   максимум, доступный по корзине) или купон (штатный
1452	 *   extension/total/coupon/coupon) и переходим на чекаут.
1453	 */
1454	document.addEventListener('click', async e => {
1455		const btn = e.target.closest('[data-discount-apply]')
1456		if (!btn || btn.disabled) return
1457		e.preventDefault()
1458
1459		if (btn.dataset.logged !== '1') {
1460			const loginModal = document.getElementById('login_modal')
1461			if (loginModal) loginModal.style.display = 'block'
1462			return
1463		}
1464
1465		const option = btn.closest('.discount-compare__option')
1466		const showError = message => {
1467			let box = option?.querySelector('.discount-compare__cta-error')
1468			if (!box && option) {
1469				box = document.createElement('span')
1470				box.className = 'discount-compare__cta-error'
1471				option.appendChild(box)
1472			}
1473			if (box) box.textContent = message
1474		}
1475		option?.querySelector('.discount-compare__cta-error')?.remove()
1476
1477		btn.disabled = true
1478
1479		try {
1480			if (btn.closest('#discount-price-rows') && typeof window.productAddToCart === 'function') {
1481				const added = await window.productAddToCart()
1482				if (!added) {
1483					// Ошибки опций (не выбран размер и т.п.) выводит сам productAddToCart —
1484					// прокручиваем к первой из них.
1485					qs('#product .text-danger')?.scrollIntoView({ behavior: 'smooth', block: 'center' })
1486					return
1487				}
1488			}
1489
1490			const lang = document.documentElement.lang
1491			const langParam = lang ? `&lang=${lang}` : ''
1492			const isCoupon = btn.dataset.discountApply === 'coupon'
1493			const body = new URLSearchParams()
1494			if (isCoupon) body.append('coupon', btn.dataset.coupon || '')
1495
1496			const res = await fetch(
1497				`index.php?route=${isCoupon ? 'extension/total/coupon/coupon' : 'extension/total/product_pricing/applyReward'}${langParam}`,
1498				{
1499					method: 'POST',
1500					headers: { 'X-Requested-With': 'XMLHttpRequest' },
1501					body
1502				}
1503			)
1504			const json = await res.json()
1505
1506			if (json.error) {
1507				showError(json.error)
1508				return
1509			}
1510
1511			window.location.href = json.redirect || `/index.php?route=checkout/checkout${langParam}`
1512		} catch (err) {
1513			console.error(err)
1514		} finally {
1515			btn.disabled = false
1516		}
1517	})
1518
1519	// Отрисовка модалки корзины из ответа сервера
1520	const renderCartModal = json => {
1521		const body = qs('#cartmodal .modal_body .render__modal_body')
1522		if (body) body.innerHTML = json.modal_body_html ?? ''
1523
1524		const discountRows = body?.querySelector('.discount-price-rows')
1525		if (discountRows) {
1526			const basePrice = parseFloat(discountRows.dataset.basePrice)
1527			renderDiscountCards(
1528				discountRows,
1529				discountRows.dataset.productId,
1530				basePrice,
1531				discountRows.dataset.currency || ''
1532			)
1533		}
1534
1535		/*
1536		 * НЕ ИСПОЛЬЗУЕТСЯ: карусель slick для блока «с этим покупают» в модалке —
1537		 * библиотека slick удалена с сайта, вызов был под проверкой typeof $.fn.slick.
1538		 */
1539
1540		updateCartCounters(json)
1541		show(qs('#cartmodal'))
1542
1543		if (json.fbq && typeof fbq !== 'undefined') fbq(json.fbq)
1544	}
1545
1546	// Вывод ошибок опций в попапе выбора размера (#chose_sizes)
1547	const renderCartOptionErrors = json => {
1548		if (json.error?.option) {
1549			for (const key of Object.keys(json.error.option)) {
1550				const element = qs(`#chose_sizes [data-input-option="${key}"]`)
1551				if (!element) continue
1552				if (element.parentElement.classList.contains('input-group')) {
1553					insertAfter(element.parentElement, `<div class="text-danger">${json.error.option[key]}</div>`)
1554				} else {
1555					insertAfter(element, `<div class="text-danger">${json.error.option[key]}</div>`)
1556				}
1557			}
1558		} else if (json.redirect && json.error?.recurring) {
1559			location = json.redirect
1560		}
1561	}
1562
1563	// Подмена данных листинга (item_list_id и т.п.) из localStorage для GA
1564	const applySavedListInfo = (json, product_id) => {
1565		if (!json.dataLayer) return
1566		const savedItems = JSON.parse(localStorage.getItem('selectedItems')) || {}
1567		if (!Object.prototype.hasOwnProperty.call(savedItems, product_id)) return
1568		const local = savedItems[product_id]
1569		const item = json.dataLayer.ecommerce?.items?.[0]
1570		if (!item) return
1571		if (local.item_list_id) item.item_list_id = local.item_list_id
1572		if (local.item_list_name) item.item_list_name = local.item_list_name
1573		if (local.index) item.index = local.index
1574	}
1575
1576	// Общий сценарий добавления в корзину (используется add и add_single).
1577	// option: строка параметров ИЛИ null — тогда форма опций ищется рядом с _this
1578	const cartAddRequest = (product_id, quantity, option, _this) => {
1579		const self = _this instanceof Element ? _this : null
1580
1581		if (option == null && self) option = serializeOptionForm(self)
1582
1583		const data = `product_id=${product_id}&quantity=${quantity ?? 1}${option ? `&${option}` : ''}`
1584
1585		self?.classList.add('disabled')
1586		qsa('#cart > button').forEach(b => b.classList.add('disabled'))
1587
1588		// lang обязателен: без языкового префикса в URL этого AJAX-запроса
1589		// startup.php падает на дефолтный язык магазина (ro) вместо текущего языка страницы
1590		const lang = document.documentElement.lang
1591		postJSON(`index.php?route=checkout/cart/add${lang ? `&lang=${lang}` : ''}`, data)
1592			.then(json => {
1593				removeAll('.alert-dismissible, .text-danger')
1594				if (json.error) {
1595					renderCartOptionErrors(json)
1596				} else if (json.success) {
1597					applySavedListInfo(json, product_id)
1598					pushDataLayer(json)
1599					hide(qs('#chose_sizes'))
1600					renderCartModal(json)
1601				}
1602			})
1603			.catch(err => alert(err))
1604			.finally(() => {
1605				self?.classList.remove('disabled')
1606				qsa('#cart > button').forEach(b => b.classList.remove('disabled'))
1607			})
1608	}
1609
1610	const cart = {
1611		// Быстрая покупка из листинга: сначала попап выбора размера, если нужны опции
1612		preview(product_id, quantity, option, _this) {
1613			const self = _this instanceof Element ? _this : null
1614
1615			if (option == null && self) option = serializeOptionForm(self, true)
1616
1617			const data = `product_id=${product_id}&quantity=${quantity ?? 1}${option ? `&${option}` : ''}`
1618
1619			self?.classList.add('disabled')
1620			qsa('#cart > button').forEach(b => b.classList.add('disabled'))
1621
1622			const lang = document.documentElement.lang
1623			postJSON(`index.php?route=checkout/cart/prev_add${lang ? `&lang=${lang}` : ''}`, data)
1624				.then(json => {
1625					if (json.success) {
1626						pushDataLayer(json)
1627						renderCartModal(json)
1628					} else if (json.popup) {
1629						// нужны опции — показываем попап выбора размера
1630						const body = qs('#chose_sizes .modal_body .render__modal_body')
1631						if (body) body.innerHTML = json.popup_modal_body_html ?? ''
1632						show(qs('#chose_sizes'))
1633					} else if (json.redirect) {
1634						location = json.redirect
1635					}
1636				})
1637				.catch(err => console.log(err))
1638				.finally(() => {
1639					self?.classList.remove('disabled')
1640					qsa('#cart > button').forEach(b => b.classList.remove('disabled'))
1641				})
1642		},
1643
1644		// Обычное добавление в корзину
1645		add(product_id, quantity, option, _this) {
1646			cartAddRequest(product_id, quantity, option, _this)
1647		},
1648
1649		// Добавление «особого» товара — сначала модалка подтверждения
1650		add_single(product_id, quantity, option, _this) {
1651			// форму опций сериализуем сразу: к моменту подтверждения DOM попапа может смениться
1652			const self = _this instanceof Element ? _this : null
1653			if (option == null && self) option = serializeOptionForm(self)
1654
1655			show(qs('#specialProductModal'))
1656
1657			const confirm = qs('#confirm-add-to-cart')
1658			if (confirm) {
1659				confirm.onclick = () => {
1660					hide(qs('#specialProductModal'))
1661					cartAddRequest(product_id, quantity, option, _this)
1662				}
1663			}
1664		},
1665
1666		// Изменение количества позиции
1667		update(key, quantity) {
1668			postJSON('index.php?route=checkout/cart/update', `key=${key}&quantity=${quantity ?? 1}`)
1669				.then(json => {
1670					pushDataLayer(json)
1671
1672					qsa('.cart > .cart_counter').forEach(el => (el.innerHTML = json.total ?? ''))
1673
1674					// предупреждение о превышении остатка на складе
1675					const limitDiv = qs(`.quantity_limit_check_${key}`)
1676					if (limitDiv) {
1677						if (json.quantity_limit) {
1678							limitDiv.innerHTML = json.quantity_limit
1679							limitDiv.classList.add('show')
1680							setTimeout(() => limitDiv.classList.remove('show'), 1000)
1681						} else {
1682							limitDiv.classList.remove('show')
1683							limitDiv.innerHTML = ''
1684						}
1685					}
1686
1687					if (json.cart_total) {
1688						qsa('.cart-fragment_header--price').forEach(el => (el.innerHTML = json.cart_total))
1689					}
1690					if (json.total) {
1691						qsa('.cart-fragment_header--counter').forEach(el => (el.innerHTML = json.total))
1692					}
1693
1694					// перерисовываем содержимое страницы корзины
1695					const cartContent = qs('.cart_page .cart_content')
1696					if (cartContent) {
1697						fetch('index.php?route=checkout/cart', { headers: { 'X-Requested-With': 'XMLHttpRequest' } })
1698							.then(r => r.text())
1699							.then(html => {
1700								// аналог jQuery .load(url + ' .cart_content .js_load')
1701								const tmp = document.createElement('div')
1702								tmp.innerHTML = html
1703								const fresh = tmp.querySelectorAll('.cart_content .js_load')
1704								if (fresh.length) {
1705									cartContent.replaceChildren(...fresh)
1706								}
1707							})
1708							.catch(() => {})
1709					}
1710				})
1711				.catch(() => {})
1712		},
1713
1714		// Удаление позиции с переходом (кнопка «удалить» в корзине)
1715		remove(key, cart_link) {
1716			qsa('#cart > button').forEach(b => b.classList.add('disabled'))
1717			postJSON('index.php?route=checkout/cart/remove', `key=${key}`)
1718				.then(json => {
1719					pushDataLayer(json)
1720					setTimeout(() => {
1721						qsa('.cart > .cart_counter').forEach(el => (el.innerHTML = json.total ?? ''))
1722					}, 100)
1723					location = cart_link
1724				})
1725				.catch(err => alert(err))
1726				.finally(() => qsa('#cart > button').forEach(b => b.classList.remove('disabled')))
1727		},
1728
1729		// Тихое удаление позиции (без перехода) — для «удалить выбранное»
1730		remove_key(key) {
1731			postJSON('index.php?route=checkout/cart/remove', `key=${key}`)
1732				.then(json => {
1733					pushDataLayer(json)
1734					setTimeout(() => {
1735						qsa('.cart > .cart_counter').forEach(el => (el.innerHTML = json.total ?? ''))
1736					}, 100)
1737				})
1738				.catch(() => {})
1739		},
1740
1741		// Полная очистка корзины
1742		clear(cart_link) {
1743			postJSON('index.php?route=checkout/cart/clear', '')
1744				.then(json => {
1745					pushDataLayer(json)
1746					location = cart_link
1747				})
1748				.catch(() => {})
1749		}
1750	}
1751
1752	// Оформление заказа при нехватке остатка: checkout/checkout.php сам просто
1753	// молча редиректит обратно в корзину (никакого сообщения не выставляет) —
1754	// без этого клик по кнопке выглядит как «страница просто перезагрузилась».
1755	// Подсвечиваем проблемные позиции и не даём уйти со страницы раньше времени.
1756	document.addEventListener('DOMContentLoaded', () => {
1757		const checkoutBtn = document.querySelector('#checkout-cart .order_confirm')
1758		if (!checkoutBtn) return
1759
1760		checkoutBtn.addEventListener('click', e => {
1761			const problems = qsa('#checkout-cart .cart_item[data-stock="0"]')
1762			if (!problems.length) return
1763
1764			e.preventDefault()
1765
1766			problems.forEach(item => item.classList.add('is-shake'))
1767			problems[0].scrollIntoView({ behavior: 'smooth', block: 'center' })
1768			setTimeout(() => problems.forEach(item => item.classList.remove('is-shake')), 600)
1769		})
1770	})
1771
1772	const voucher = {
1773		add() {},
1774		remove(key) {
1775			qsa('#cart > button').forEach(b => b.classList.add('disabled'))
1776			postJSON('index.php?route=checkout/cart/remove', `key=${key}`)
1777				.then(json => {
1778					setTimeout(() => {
1779						qsa('.cart > .cart_counter').forEach(el => (el.innerHTML = json.total ?? ''))
1780					}, 100)
1781
1782					if (getURLVar('route') === 'checkout/cart' || getURLVar('route') === 'checkout/checkout') {
1783						location = 'index.php?route=checkout/cart'
1784					}
1785				})
1786				.catch(err => alert(err))
1787				.finally(() => qsa('#cart > button').forEach(b => b.classList.remove('disabled')))
1788		}
1789	}
1790
1791	/* ============================================================
1792	 * 9. ИЗБРАННОЕ (WISHLIST)
1793	 * ============================================================ */
1794
1795	const wishlist = (() => {
1796		let _productIds = []
1797
1798		// Подсветка кнопок-сердечек на всех карточках
1799		const _setButtons = productIds => {
1800			_productIds = productIds || []
1801			qsa('.wishlist-btn[data-product-id], .wishlist-btn-product[data-product-id]').forEach(btn => {
1802				const pid = parseInt(btn.dataset.productId, 10)
1803				if (!pid) return
1804				const inList = _productIds.includes(pid)
1805				btn.classList.toggle('active', inList)
1806				const icon = btn.querySelector('i')
1807				icon?.classList.toggle('ri-heart-3-fill', inList)
1808				icon?.classList.toggle('ri-heart-3-line', !inList)
1809			})
1810		}
1811
1812		const _setCounter = total => {
1813			qsa('.wishlist_counter').forEach(counter => {
1814				counter.textContent = total > 0 ? total : ''
1815				counter.classList.toggle('has-items', total > 0)
1816			})
1817		}
1818
1819		return {
1820			has(product_id) {
1821				return _productIds.includes(parseInt(product_id, 10))
1822			},
1823			// Повторная подсветка — для карточек, добавленных в DOM после init (ленивые модули главной)
1824			refresh() {
1825				_setButtons(_productIds)
1826			},
1827			// Начальное состояние: счётчик и подсветка кнопок
1828			init() {
1829				getJSON('index.php?route=account/wishlist/info')
1830					.then(json => {
1831						if (json && typeof json.total !== 'undefined') {
1832							_setCounter(json.total)
1833							_setButtons(json.product_ids || [])
1834						}
1835					})
1836					.catch(() => {})
1837			},
1838			add(product_id) {
1839				postJSON('index.php?route=account/wishlist/add', `product_id=${product_id}`)
1840					.then(json => {
1841						if (json.success) {
1842							if (!_productIds.includes(product_id)) _productIds.push(product_id)
1843							_setCounter(json.total)
1844							_setButtons(_productIds)
1845						}
1846					})
1847					.catch(err => console.log(err))
1848			},
1849			remove(product_id) {
1850				postJSON('index.php?route=account/wishlist/remove', `product_id=${product_id}`)
1851					.then(json => {
1852						if (!json.success) return
1853						const idx = _productIds.indexOf(product_id)
1854						if (idx !== -1) _productIds.splice(idx, 1)
1855						_setCounter(json.total)
1856						_setButtons(_productIds)
1857
1858						// на странице избранного убираем карточку товара
1859						if (qs('#account-wishlist')) {
1860							const btns = qsa(
1861								`.wishlist-btn[data-product-id="${product_id}"], .wishlist-btn-product[data-product-id="${product_id}"]`
1862							)
1863							btns.forEach(btn => btn.closest('.container.render_product_data')?.remove())
1864
1865							if (!qs('#account-wishlist .render_product_data')) {
1866								const row = qs('#account-wishlist #content .row')
1867								if (row) {
1868									const emptyText =
1869										typeof wishlist_text_empty !== 'undefined' ? wishlist_text_empty : 'Your wish list is empty.'
1870									const continueText =
1871										typeof wishlist_text_continue !== 'undefined' ? wishlist_text_continue : 'Continue'
1872									row.innerHTML = `<div class="wishlist-empty"><i class="ri-heart-3-line"></i><p>${emptyText}</p><a href="/" class="btn-continue">${continueText}</a></div>`
1873								}
1874							}
1875						}
1876					})
1877					.catch(err => console.log(err))
1878			},
1879			toggle(product_id) {
1880				product_id = parseInt(product_id, 10)
1881				if (_productIds.includes(product_id)) {
1882					wishlist.remove(product_id)
1883				} else {
1884					wishlist.add(product_id)
1885				}
1886			}
1887		}
1888	})()
1889
1890	document.addEventListener('DOMContentLoaded', () =>
1890 wishlist.init())
1891	// ленивые модули главной (app.js → initLazyModules) приходят после DOMContentLoaded
1892	document.addEventListener('lazy-module:loaded', () => wishlist.refresh())
1893
1894	/* ============================================================
1895	 * 10. СРАВНЕНИЕ (PRODUCTCOMPARE, COMPARE)
1896	 * ============================================================ */
1897
1898	const productCompare = (() => {
1899		let _productIds = []
1900
1901		const _setButtons = productIds => {
1902			_productIds = productIds || []
1903			qsa('[data-compare-id]').forEach(btn => {
1904				const pid = parseInt(btn.dataset.compareId, 10)
1905				if (!pid) return
1906				const inList = _productIds.includes(pid)
1907				btn.classList.toggle('active', inList)
1908				const icon = btn.querySelector('i')
1909				icon?.classList.toggle('ri-arrow-left-right-fill', inList)
1910				icon?.classList.toggle('ri-arrow-left-right-line', !inList)
1911			})
1912		}
1913
1914		const _setCounter = total => {
1915			qsa('.compare_counter, #footer-compare-total').forEach(counter => {
1916				counter.textContent = total > 0 ? total : ''
1917				counter.classList.toggle('has-items', total > 0)
1918			})
1919		}
1920
1921		return {
1922			has(product_id) {
1923				return _productIds.includes(parseInt(product_id, 10))
1924			},
1925			// Повторная подсветка — для карточек, добавленных в DOM после init (ленивые модули главной)
1926			refresh() {
1927				_setButtons(_productIds)
1928			},
1929			init() {
1930				getJSON('index.php?route=product/compare/info')
1931					.then(json => {
1932						if (json && typeof json.total !== 'undefined') {
1933							_setCounter(json.total)
1934							_setButtons(json.product_ids || [])
1935						}
1936					})
1937					.catch(() => {})
1938			},
1939			add(product_id) {
1940				product_id = parseInt(product_id, 10)
1941				postJSON('index.php?route=product/compare/add', `product_id=${product_id}`)
1942					.then(json => {
1943						if (json.success) {
1944							if (!_productIds.includes(product_id)) _productIds.push(product_id)
1945							_setCounter(json.total)
1946							_setButtons(_productIds)
1947						}
1948					})
1949					.catch(err => console.log(err))
1950			},
1951			remove(product_id) {
1952				product_id = parseInt(product_id, 10)
1953				postJSON('index.php?route=product/compare/remove', `product_id=${product_id}`)
1954					.then(json => {
1955						if (!json.success) return
1956						const idx = _productIds.indexOf(product_id)
1957						if (idx !== -1) _productIds.splice(idx, 1)
1958						_setCounter(json.total)
1959						_setButtons(_productIds)
1960
1961						// на странице сравнения убираем колонку товара из таблицы
1962						const table = qs('#product-compare table')
1963						if (table) {
1964							const col = table.querySelector(`[data-compare-id="${product_id}"]`)?.closest('td')
1965							if (col) {
1966								const colIndex = [...col.parentElement.children].indexOf(col)
1967								const totalCols = table.querySelectorAll('thead tr:first-child td').length
1968
1969								table.querySelectorAll('tr').forEach(tr => {
1970									const cells = tr.querySelectorAll('td, th')
1971									if (cells.length >= totalCols) {
1972										cells[colIndex]?.remove()
1973									} else {
1974										cells.forEach(cell => {
1975											const span = parseInt(cell.getAttribute('colspan') || '1', 10)
1976											if (span > 1) cell.setAttribute('colspan', span - 1)
1977										})
1978									}
1979								})
1980
1981								if (table.querySelectorAll('thead tr:first-child td').length === 0) {
1982									hide(table.closest('.compare__table-wrap'))
1983									show(qs('.compare__empty'))
1984								}
1985							}
1986						}
1987					})
1988					.catch(err => console.log(err))
1989			},
1990			toggle(product_id) {
1991				product_id = parseInt(product_id, 10)
1992				if (_productIds.includes(product_id)) {
1993					productCompare.remove(product_id)
1994				} else {
1995					productCompare.add(product_id)
1996				}
1997			}
1998		}
1999	})()
2000
2001	document.addEventListener('lazy-module:loaded', () => productCompare.refresh())
2002
2003	document.addEventListener('DOMContentLoaded', () => {
2004		productCompare.init()
2005
2006		delegate('click', '[data-compare-id]', (e, btn) => {
2007			const product_id = parseInt(btn.dataset.compareId, 10)
2008			if (!product_id) return
2009			if (qs('#product-compare') && btn.classList.contains('btn-danger')) {
2010				productCompare.remove(product_id)
2011			} else {
2012				productCompare.toggle(product_id)
2013			}
2014		})
2015	})
2016
2017	// Старый API сравнения — используется в blog/article.twig
2018	const compare = {
2019		add(product_id) {
2020			postJSON('index.php?route=product/compare/add', `product_id=${product_id}`)
2021				.then(json => {
2022					removeAll('.alert-dismissible')
2023
2024					if (json.success) {
2025						qs('#content')?.parentElement?.insertAdjacentHTML(
2026							'beforebegin',
2027							`<div class="alert alert-success alert-dismissible"><i class="fa fa-check-circle"></i> ${json.success} <button type="button" class="close" data-dismiss="alert">&times;</button></div>`
2028						)
2029
2030						const total = qs('#compare-total')
2031						if (total) total.innerHTML = json.total
2032
2033						document.body.scrollTo({ top: 0, behavior: 'smooth' })
2034					}
2035				})
2036				.catch(err => alert(err))
2037		},
2038		remove() {}
2039	}
2040
2041	/* ============================================================
2042	 * 11. МОДАЛКА «УСЛОВИЯ СОГЛАШЕНИЯ» (.agree)
2043	 * ============================================================ */
2044
2045	// Ссылки на условия/политику в формах: подгружают текст и показывают модалку.
2046	// Раньше показ делал bootstrap ($.modal) — заменено на vanilla
2047	delegate('click', '.agree', (e, link) => {
2048		e.preventDefault()
2049
2050		qs('#modal-agree')?.remove()
2051
2052		fetch(link.href, { headers: { 'X-Requested-With': 'XMLHttpRequest' } })
2053			.then(r => r.text())
2054			.then(data => {
2055				const html = `
2056					<div id="modal-agree" class="modal" style="display:block; position:fixed; inset:0; z-index:1050; overflow-y:auto; background:rgba(0,0,0,.5);">
2057						<div class="modal-dialog" style="max-width:800px; margin:40px auto; background:#fff; border-radius:6px;">
2058							<div class="modal-content">
2059								<div class="modal-header">
2060									<button type="button" class="close" data-dismiss="modal" aria-hidden="true">&times;</button>
2061									<h4 class="modal-title">${link.textContent}</h4>
2062								</div>
2063								<div class="modal-body">${data}</div>
2064							</div>
2065						</div>
2066					</div>`
2067
2068				document.body.insertAdjacentHTML('beforeend', html)
2069			})
2070			.catch(() => {})
2071	})
2072
2073	// Закрытие модалки соглашения: крестик или клик по подложке
2074	delegate('click', '#modal-agree .close, #modal-agree [data-dismiss="modal"]', () => {
2075		qs('#modal-agree')?.remove()
2076	})
2077	document.addEventListener('click', e => {
2078		if (e.target.id === 'modal-agree') e.target.remove()
2079	})
2080
2081	/* ============================================================
2082	 * 12. АВТОКОМПЛИТ (initAutocomplete)
2083	 * ============================================================ */
2084
2085	// Vanilla-замена jQuery-плагина $.fn.autocomplete из OpenCart.
2086	// Контракт сохранён: source(value, responseCallback), select(item)
2087	function initAutocomplete(input, { source, select }) {
2088		if (!input) return
2089
2090		let timer = null
2091		const items = {}
2092
2093		input.setAttribute('autocomplete', 'off')
2094
2095		// выпадающий список — соседний ul, как в оригинальном плагине
2096		const dropdown = document.createElement('ul')
2097		dropdown.className = 'dropdown-menu'
2098		input.after(dropdown)
2099
2100		const showList = () => {
2101			dropdown.style.top = `${input.offsetTop + input.offsetHeight}px`
2102			dropdown.style.left = `${input.offsetLeft}px`
2103			dropdown.style.display = 'block'
2104		}
2105		const hideList = () => {
2106			dropdown.style.display = 'none'
2107		}
2108
2109		const response = json => {
2110			let html = ''
2111
2112			if (json.length) {
2113				json.forEach(item => {
2114					items[item.value] = item
2115				})
2116
2117				// сначала элементы без категории
2118				json.filter(item => !item.category).forEach(item => {
2119					html += `<li data-value="${item.value}"><a href="#">${item.label}</a></li>`
2120				})
2121
2122				// затем — сгруппированные по категориям
2123				const categories = {}
2124				json.filter(item => item.category).forEach(item => {
2125					;(categories[item.category] ??= []).push(item)
2126				})
2127				for (const [name, list] of Object.entries(categories)) {
2128					html += `<li class="dropdown-header">${name}</li>`
2129					list.forEach(item => {
2130						html += `<li data-value="${item.value}"><a href="#">&nbsp;&nbsp;&nbsp;${item.label}</a></li>`
2131					})
2132				}
2133			}
2134
2135			if (html) {
2136				showList()
2137			} else {
2138				hideList()
2139			}
2140
2141			dropdown.innerHTML = html
2142		}
2143
2144		const request = () => {
2145			clearTimeout(timer)
2146			timer = setTimeout(() => source(input.value, response), 200)
2147		}
2148
2149		input.addEventListener('focus', request)
2150		input.addEventListener('blur', () => setTimeout(hideList, 200))
2151		input.addEventListener('keydown', e => {
2152			if (e.key === 'Escape') {
2153				hideList()
2154			} else {
2155				request()
2156			}
2157		})
2158
2159		dropdown.addEventListener('click', e => {
2160			e.preventDefault()
2161			const li = e.target.closest('li[data-value]')
2162			if (!li) return
2163			const value = li.dataset.value
2164			if (value && items[value]) select(items[value])
2165		})
2166	}
2167
2168	/* ============================================================
2169	 * 13. АНАЛИТИКА GA4
2170	 * ============================================================ */
2171
2172	// view_item_list: отправка показанных карточек товара (порциями, через IO)
2173	document.addEventListener('DOMContentLoaded', function () {
2174		// Множество для хранения уникальных идентификаторов добавленных товаров
2175		const addedItems = new Set()
2176
2177		// Массив для хранения товаров, которые будут отправлены в dataLayer
2178		let itemsToSend = []
2179
2180		// Функция для отправки данных в dataLayer
2181		const sendDataLayerEvent = () => {
2182			if (itemsToSend.length > 0) {
2183				window.dataLayer = window.dataLayer || []
2184				window.dataLayer.push({
2185					event: 'view_item_list',
2186					currency: 'MDL',
2187					ecommerce: {
2188						items: itemsToSend
2189					}
2190				})
2191				// Очищаем массив, чтобы избежать повторной отправки тех же товаров
2192				itemsToSend = []
2193			}
2194		}
2195
2196		const itemListTitle = document.querySelector('.ecommerce_title')?.getAttribute('data-item_list_title')
2197
2198		// Инициализация Intersection Observer с оптимизацией
2199		const observer = new IntersectionObserver(
2200			entries => {
2201				entries.forEach(entry => {
2202					if (entry.isIntersecting) {
2203						const productData = entry.target
2204
2205						// Получаем уникальный идентификатор товара
2206						const productId = productData.getAttribute('data-product_id')
2207
2208						// Проверяем, был ли товар уже добавлен в Set для предотвращения повторной отправки
2209						if (!addedItems.has(productId)) {
2210							addedItems.add(productId)
2211
2212							// Собираем данные товара из data-атрибутов
2213							const item = {
2214								item_id: productId,
2215								item_name: productData.getAttribute('data-item_name'),
2216								price: productData.getAttribute('data-price'),
2217								item_variant: productData.getAttribute('data-variant'),
2218								item_brand: productData.getAttribute('data-brand'),
2219								index: productData.getAttribute('data-index'),
2220								item_list_id: productData.getAttribute('data-item_list_id'),
2221								item_list_name: itemListTitle ?? productData.getAttribute('data-item_list_name')
2222							}
2223
2224							// Добавляем дополнительные данные, если они присутствуют
2225							if (productData.hasAttribute('data-discount')) {
2226								item.discount = productData.getAttribute('data-discount')
2227							}
2228							if (productData.hasAttribute('data-color')) {
2229								item.color = productData.getAttribute('data-color')
2230							}
2231							if (productData.hasAttribute('data-size')) {
2232								item.size = productData.getAttribute('data-size')
2233							}
2234
2235							// Проверка наличия категорий и их добавление
2236							for (let i = 0; i <= 6; i++) {
2237								const categoryAttr = `data-category-${i}`
2238								if (productData.hasAttribute(categoryAttr)) {
2239									if (i === 1) {
2240										item[`item_category`] = productData.getAttribute(categoryAttr)
2241									} else {
2242										item[`item_category${i ? i : ''}`] = productData.getAttribute(categoryAttr)
2243									}
2244								}
2245							}
2246
2247							itemsToSend.push(item)
2248
2249							// Отключаем отслеживание для данного элемента
2250							observer.unobserve(productData)
2251						}
2252					}
2253				})
2254
2255				// Запланировать отправку данных в dataLayer (200 мс — батчинг)
2256				clearTimeout(window.ecommerceTimeout)
2257				window.ecommerceTimeout = setTimeout(sendDataLayerEvent, 200)
2258			},
2259			{
2260				threshold: 0.5 // Товар должен быть виден на 50% для срабатывания
2261			}
2262		)
2263
2264		document.querySelectorAll('.ecommerce_data').forEach(product => observer.observe(product))
2265	})
2266
2267	// select_item: отправка события при клике на карточку + сохранение
2268	// данных листинга в localStorage (подхватываются при add to cart)
2269	function sendSelectItemEvent(element) {
2270		const ecommerceData = element.closest('.item').querySelector('.ecommerce_data')
2271
2272		const ecommerceItemData = {
2273			item_name: ecommerceData.dataset.item_name,
2274			item_id: ecommerceData.dataset.product_id,
2275			price: ecommerceData.getAttribute('data-price') || '',
2276			item_brand: ecommerceData.dataset.brand,
2277			item_variant: ecommerceData.dataset.variant
2278		}
2279
2280		// Добавляем категории товаров
2281		for (let i = 0; i <= 6; i++) {
2282			const categoryAttr = `data-category-${i}`
2283			if (ecommerceData.hasAttribute(categoryAttr)) {
2284				if (i === 1) {
2285					ecommerceItemData['item_category'] = ecommerceData.getAttribute(categoryAttr)
2286				} else {
2287					ecommerceItemData[`item_category${i ? i : ''}`] = ecommerceData.getAttribute(categoryAttr)
2288				}
2289			}
2290		}
2291
2292		if (ecommerceData.hasAttribute('data-discount')) {
2293			ecommerceItemData['discount'] = ecommerceData.getAttribute('data-discount')
2294		}
2295
2296		// Сохранение данных листинга в localStorage по item_id
2297		const selectedFields = {
2298			item_list_id: ecommerceData.dataset.item_list_id || '',
2299			item_list_name: ecommerceData.dataset.item_list_name || '',
2300			index: ecommerceData.getAttribute('data-index') || ''
2301		}
2302
2303		const savedItems = JSON.parse(localStorage.getItem('selectedItems')) || {}
2304		savedItems[ecommerceData.dataset.product_id] = selectedFields
2305		localStorage.setItem('selectedItems', JSON.stringify(savedItems))
2306
2307		window.dataLayer = window.dataLayer || []
2308		window.dataLayer.push({
2309			event: 'select_item',
2310			currency: 'MDL',
2311			ecommerce: {
2312				items: [ecommerceItemData]
2313			}
2314		})
2315	}
2316
2317	// Навешивает отправку select_item на ссылки карточек
2318	function addSelectItemEventListeners() {
2319		document.querySelectorAll('.item .images a').forEach(link => {
2320			link.addEventListener('click', event => {
2321				if (event.ctrlKey || event.metaKey) {
2322					sendSelectItemEvent(event.currentTarget)
2323				} else {
2324					sendSelectItemEvent(event.currentTarget)
2325					window.location.href = event.currentTarget.href
2326					event.preventDefault()
2327				}
2328			})
2329
2330			// клик колесиком (открытие в новой вкладке)
2331			link.addEventListener('mousedown', event => {
2332				if (event.button === 1) sendSelectItemEvent(event.currentTarget)
2333			})
2334
2335			// правый клик (контекстное меню)
2336			link.addEventListener('contextmenu', event => {
2337				sendSelectItemEvent(event.currentTarget)
2338			})
2339		})
2340
2341		document.querySelectorAll('.item .add_to_cart').forEach(link => {
2342			link.addEventListener('click', event => {
2343				if (event.ctrlKey || event.metaKey) {
2344					sendSelectItemEvent(event.currentTarget)
2345				} else {
2346					sendSelectItemEvent(event.currentTarget)
2347					window.location.href = event.currentTarget.href
2348					event.preventDefault()
2349				}
2350			})
2351		})
2352	}
2353
2354	document.addEventListener('DOMContentLoaded', addSelectItemEventListeners)
2355
2356	// view_search_results: отправка поискового запроса (от 3 символов, с паузой)
2357	document.addEventListener('DOMContentLoaded', () => {
2358		const searchInput = document.querySelector('.search_form input[name="search"]')
2359		if (!searchInput) return
2360
2361		let searchTimeout
2362		searchInput.addEventListener('input', () => {
2363			const searchQuery = searchInput.value.trim()
2364			clearTimeout(searchTimeout)
2365			searchTimeout = setTimeout(() => {
2366				if (searchQuery.length >= 3) {
2367					window.dataLayer = window.dataLayer || []
2368					window.dataLayer.push({
2369						event: 'view_search_results',
2370						search_query: searchQuery
2371					})
2372				}
2373			}, 1000)
2374		})
2375	})
2376
2377	// Сброс повреждённой куки _ga (защита от спам-значения)
2378	document.addEventListener('DOMContentLoaded', () => {
2379		const cookieName = '_ga='
2380		const targetValue = '@^_2965645322497708761'
2381		const cookies = document.cookie.split('; ')
2382
2383		for (const cookie of cookies) {
2384			if (cookie.startsWith(cookieName) && cookie.substring(cookieName.length) === targetValue) {
2385				document.cookie = `_ga=; path=/; expires=Thu, 01 Jan 1970 00:00:00 UTC; domain=${window.location.hostname}`
2386				console.log("Кука '_ga' сброшена")
2387				return
2388			}
2389		}
2390	})
2391
2392	/* ============================================================
2393	 * 14. ПОДПИСКА НА ТОВАР НЕ В НАЛИЧИИ
2394	 * ============================================================ */
2395
2396	document.addEventListener('DOMContentLoaded', () => {
2397		const form = document.getElementById('stock_notify_form')
2398		const emailInput = document.getElementById('stock_notify_email')
2399		const messageBox = document.getElementById('stock_notify_message')
2400		const lang = document.documentElement.lang || 'ro'
2401		let url = `/index.php?route=extension/module/stock_notify/add`
2402
2403		if (lang !== 'ro') {
2404			url = `/${lang}${url}`
2405		}
2406
2407		if (!form) return
2408
2409		form.addEventListener('submit', async event => {
2410			event.preventDefault()
2411
2412			const email = emailInput.value.trim()
2413			messageBox.textContent = ''
2414			messageBox.className = 'stock-notify-message'
2415
2416			// простая валидация
2417			const emailPattern = /^[^\s@]+@[^\s@]+\.[^\s@]+$/
2418			if (!emailPattern.test(email)) {
2419				emailInput.classList.add('error')
2420				messageBox.textContent = '{{ error_email }}'
2421				messageBox.classList.add('error-text')
2422				return
2423			}
2424
2425			emailInput.classList.remove('error')
2426			form.classList.add('loading')
2427
2428			try {
2429				const response = await fetch(url, {
2430					method: 'POST',
2431					headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
2432					body: new URLSearchParams(new FormData(form))
2433				})
2434
2435				const data = await response.json()
2436				form.classList.remove('loading')
2437
2438				messageBox.textContent = data.success || data.error || 'Ошибка'
2439				messageBox.classList.add(data.success ? 'success-text' : 'error-text')
2440
2441				if (data.success) form.reset()
2442			} catch (err) {
2443				form.classList.remove('loading')
2444				messageBox.textContent = 'Ошибка соединения'
2445				messageBox.classList.add('error-text')
2446			}
2447		})
2448
2449		emailInput.addEventListener('input', () => {
2450			emailInput.classList.remove('error')
2451			messageBox.textContent = ''
2452		})
2453	})
2454
2455	/* ============================================================
2456	 * ЗАМЕНА BOOTSTRAP 3 JS (data-toggle) НА VANILLA
2457	 * ============================================================
2458	 * bootstrap.min.js отключён вместе с jQuery. Из его компонентов на сайте
2459	 * реально работали только вкладки (таблица размеров) и частично collapse.
2460	 * Тултипы ([data-toggle="tooltip"]) не инициализировались и до этого
2461	 * (вызов .tooltip() был закомментирован), bootstrap CSS не подключён.
2462	 */
2463
2464	// Вкладки (data-toggle="tab"): активная ссылка + показ панели .tab-pane
2465	delegate('click', '[data-toggle="tab"]', (e, toggle) => {
2466		e.preventDefault()
2467		const target = qs(toggle.getAttribute('href') || toggle.dataset.target || '#')
2468		if (!target) return
2469
2470		toggle.closest('ul')?.querySelectorAll('li').forEach(li => li.classList.remove('active'))
2471		toggle.closest('li')?.classList.add('active')
2472
2473		target.parentElement?.querySelectorAll(':scope > .tab-pane').forEach(pane => pane.classList.remove('active', 'in'))
2474		target.classList.add('active', 'in')
2475	})
2476
2477	// Сворачиваемые блоки (data-toggle="collapse") с поддержкой data-parent (аккордеон).
2478	// Чекаут не трогаем — у него собственный аккордеон в checkout.twig
2479	delegate('click', '[data-toggle="collapse"]', (e, toggle) => {
2480		if (toggle.closest('#checkout-checkout')) return
2481		e.preventDefault()
2482		const target = qs(toggle.dataset.target || toggle.getAttribute('href') || '#')
2483		if (!target) return
2484
2485		const parentSelector = toggle.dataset.parent
2486		if (parentSelector) {
2487			qsa(`${parentSelector} .panel-collapse.in`).forEach(panel => {
2488				if (panel !== target) panel.classList.remove('in')
2489			})
2490		}
2491
2492		target.classList.toggle('in')
2493		toggle.setAttribute('aria-expanded', target.classList.contains('in') ? 'true' : 'false')
2494	})
2495
2496	// Выпадающие меню (data-toggle="dropdown"): класс .open на родителе
2497	delegate('click', '[data-toggle="dropdown"]', (e, toggle) => {
2498		e.preventDefault()
2499		toggle.parentElement?.classList.toggle('open')
2500	})
2501
2502	// клик вне дропдауна закрывает его
2503	document.addEventListener('click', e => {
2504		qsa('.btn-group.open, .dropdown.open').forEach(group => {
2505			if (!group.contains(e.target)) group.classList.remove('open')
2506		})
2507	})
2508
2509	/* ============================================================
2510	 * АВТООТКРЫТИЕ МОДАЛКИ ВХОДА ПО ССЫЛКЕ (?show_login=1)
2511	 * ============================================================
2512	 * Нужно для ссылок из email-рассылок и т.п.: гостя редиректят на
2513	 * страницу с этим параметром, чтобы сразу показать вход/регистрацию
2514	 * (см. account/invite.php — незалогиненного отправляет на
2515	 * common/home?show_login=1 вместо полноценной страницы логина).
2516	 * #login_modal — тот же модал, что открывают .quick_signup и попапы
2517	 * бонусов (onclick="...getElementById('login_modal').style.display=...").
2518	 * Параметр читается на клиенте, а не через сессию/PHP-флаг — фрагмент
2519	 * common/quicksignup кэшируется по языку в footer.php, так что серверный
2520	 * флаг на конкретного посетителя надёжно не сработает.
2521	 */
2522	document.addEventListener('DOMContentLoaded', () => {
2523		if (!new URLSearchParams(window.location.search).has('show_login')) return
2524		const modal = document.getElementById('login_modal')
2525		if (modal) modal.style.display = 'block'
2526	})
2527
2528	/* ============================================================
2529	 * ЭКСПОРТ ГЛОБАЛЬНОГО API
2530	 * ============================================================ */
2531
2532	Object.assign(window, {
2533		getURLVar,
2534		remove_product,
2535		beautifyTableSizes,
2536		chozeSystemSize,
2537		showSizechart,
2538		showPreorderModal,
2539		cart,
2540		voucher,
2541		wishlist,
2542		productCompare,
2543		compare,
2544		initAutocomplete,
2545		sendSelectItemEvent
2546	})
2547})()
2548
2549/* ============================================================
2550 * 15. НЕИСПОЛЬЗУЕМЫЙ КОД (закомментирован)
2551 * ============================================================ */
2552
2553/*
2554 * НЕ ИСПОЛЬЗУЕТСЯ: AJAX-переходы по фильтру #ocfilter-content1 —
2555 * элемента нет ни в одном шаблоне (OCFilter работает через свой ocfilter.js).
2556 *
2557 * $("#ocfilter-content1").on("click", "a", function (e) { ...загрузка #content... });
2558 */
2559
2560/*
2561 * НЕ ИСПОЛЬЗУЕТСЯ: позиционирование выпадающих меню бутстрап-навигации #menu —
2562 * элемента #menu нет в шаблонах темы.
2563 *
2564 * $('#menu .dropdown-menu').each(function () { ...margin-left... });
2565 */
2566
2567/*
2568 * НЕ ИСПОЛЬЗУЕТСЯ: AJAX-отправка форм .custom__forms с bootstrap-модалками —
2569 * класса .custom__forms нет ни в одном шаблоне.
2570 *
2571 * $('.custom__forms input, .custom__forms textarea').on('focus', ...);
2572 * $(document).on('submit', ".custom__forms", function (e) { ...$.ajax + $('.modal').modal()... });
2573 */
2574
2575/*
2576 * НЕ ИСПОЛЬЗУЕТСЯ: переключение языка через .language-select —
2577 * класса нет в шаблонах (язык переключается ссылками в language.twig).
2578 *
2579 * $('.form-language .language-select').on('click', function (e) { ... });
2580 */
2581
2582/*
2583 * НЕ ИСПОЛЬЗУЕТСЯ: в cart.add был вывод .quantity_limit_check_ + key,
2584 * но переменная key в этой области не существовала (ReferenceError) —
2585 * рабочий вариант этого кода живёт в cart.update.
2586 */
2587
2588/* Phone Register Module — старые версии, давно закомментированы.
2589 * Актуальная реализация — initPhoneRegister в assets/src/js/app.js.
2590 * (два больших закомментированных блока удалены при переписывании файла;
2591 * см. git-историю, коммиты до перевода на чистый JS)
2592 */

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.