PageSourceSearch

https://www.velodrive.ru/local/templates/velodrive/vd-search/header-script.v17.js

js velodrive.ru collected 2026-10-02 10:07:20 UTC 20,150 bytes, 438 lines download raw bytes

1(function(){
2  'use strict';
3  // v17 (2026-05-28): подсветка совпадений в подсказках (`highlightMatch`).
4  // v16 (2026-05-25): canonical api.velodrive.ru + JWT Bearer.
5
6  // ── Akyzo JWT attribution (v16, 2026-05-25) ───────────────────────
7  // Stormwall на velodrive.ru/api/v1/* срезает Cookie. Переключились
8  // на canonical api.velodrive.ru (вне Stormwall) + Authorization Bearer.
9  // window.AKYZO_JWT и AKYZO_JWT_READY ставит inline-script в
10  // /local/templates/velodrive/header.php.
11  function _akyzoAuthHeaders() {
12    return window.AKYZO_JWT ? { Authorization: 'Bearer ' + window.AKYZO_JWT } : {};
13  }
14  function _akyzoWaitJwt() {
15    if (window.AKYZO_JWT || !window.AKYZO_JWT_READY) return Promise.resolve();
16    return window.AKYZO_JWT_READY.catch(function(){});
17  }
18  function _akyzoFetch(url, opts) {
19    opts = opts || {};
20    opts.credentials = 'omit';
21    return _akyzoWaitJwt().then(function () {
22      var h = Object.assign({}, opts.headers || {}, _akyzoAuthHeaders());
23      opts.headers = h;
24      return fetch(url, opts);
25    });
26  }
27  // ──────────────────────────────────────────────────────────────────
28
29
30  var API = 'https://api.velodrive.ru/v1/search';
31  var MIN_LEN = 3;
32  var DEBOUNCE_MS = 180;
33  var PER_PAGE = 8;
34  // Совместимо с PWA/mobile (velodrive/Mobile использует тот же ключ)
35  var HISTORY_KEY = 'velodrive.search.history';
36  var HISTORY_LIMIT = 8;
37
38  function loadHistory() {
39    try {
40      var raw = localStorage.getItem(HISTORY_KEY);
41      var arr = raw ? JSON.parse(raw) : [];
42      return Array.isArray(arr) ? arr.filter(function(s){ return typeof s === 'string' && s.trim(); }) : [];
43    } catch(e) { return []; }
44  }
45  function saveHistory(q) {
46    q = (q || '').trim();
47    if (!q) return;
48    try {
49      var arr = loadHistory();
50      arr = arr.filter(function(s){ return s.toLowerCase() !== q.toLowerCase(); });
51      arr.unshift(q);
52      if (arr.length > HISTORY_LIMIT) arr.length = HISTORY_LIMIT;
53      localStorage.setItem(HISTORY_KEY, JSON.stringify(arr));
54    } catch(e) {}
55  }
56  function clearHistory() {
57    try { localStorage.removeItem(HISTORY_KEY); } catch(e) {}
58  }
59
60  function init() {
61    var input = document.getElementById('title-search-input');
62    if (!input) return false;
63    if (input.dataset.vdSuggestBound === '1') return true;
64    input.dataset.vdSuggestBound = '1';
65
66    var container = input.closest('.search') || input.parentElement;
67    if (!container) return false;
68    container.style.position = container.style.position || 'relative';
69
70    var dropdown = container.querySelector('.dropdown--search');
71    if (!dropdown) {
72      dropdown = document.createElement('div');
73      dropdown.className = 'dropdown--search vd-suggest-dropdown';
74      container.appendChild(dropdown);
75    }
76
77    var timer = null;
78    var lastReq = 0;
79
80    // Спиннер + кнопка очистки. Позиционируются абсолютно ВНУТРИ input'а
81    // (правый край), через расчёт по `getBoundingClientRect()` — чтобы
82    // не зависеть от ширины кнопки «Найти» снаружи.
83    var spinner = document.createElement('span');
84    spinner.className = 'vd-suggest-spinner';
85    spinner.style.display = 'none';
86    container.appendChild(spinner);
87
88    var clearBtnEl = document.createElement('button');
89    clearBtnEl.type = 'button';
90    clearBtnEl.className = 'vd-suggest-clear';
91    clearBtnEl.setAttribute('aria-label', 'Очистить');
92    clearBtnEl.innerHTML =
93      '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" aria-hidden="true" focusable="false" style="display:block;pointer-events:none">' +
94        '<line x1="6" y1="6" x2="18" y2="18"/>' +
95        '<line x1="18" y1="6" x2="6" y2="18"/>' +
96      '</svg>';
97    clearBtnEl.style.display = 'none';
98
99    // Размещение: на мобиле и десктопе одинаково — вставляем spinner и ✕
100    // внутрь `.api-query` (это flex-контейнер на мобиле, block на десктопе).
101    // На мобиле они становятся flex-children между input и кнопкой «Найти»
102    // — никаких координатных расчётов, всё выравнивается автоматически.
103    // На десктопе CSS делает их position:absolute и считает right от
104    // submit-кнопки внутри form.
105    var queryContainer = input.closest('.api-query') || input.parentElement;
106    var submitBtn = queryContainer.querySelector('button[type="submit"]');
107    if (queryContainer && submitBtn) {
108      queryContainer.insertBefore(spinner, submitBtn);
109      queryContainer.insertBefore(clearBtnEl, submitBtn);
110    } else {
111      container.appendChild(spinner);
112      container.appendChild(clearBtnEl);
113    }
114
115    function positionOverlay() {
116      // На мобиле spinner/clear — flex-children .api-query, ничего не считаем.
117      var mq = window.matchMedia('(max-width: 1010px)');
118      if (mq.matches) {
119        spinner.style.top = '';
120        spinner.style.right = '';
121        clearBtnEl.style.top = '';
122        clearBtnEl.style.right = '';
123        return;
124      }
125      // Десктоп: position absolute, считаем right от submit-кнопки.
126      if (!submitBtn) return;
127      var queryRect = queryContainer.getBoundingClientRect();
128      var bRect = submitBtn.getBoundingClientRect();
129      var topPx   = bRect.top - queryRect.top + bRect.height / 2;
130      var rightPx = queryRect.right - bRect.left + 14;
131      spinner.style.top      = (topPx - 9)  + 'px';
132      spinner.style.right    = rightPx + 'px';
133      clearBtnEl.style.top   = (topPx - 11) + 'px';
134      clearBtnEl.style.right = rightPx + 'px';
135    }
136    positionOverlay();
137    window.addEventListener('resize', positionOverlay);
138
139    function showSpinner() {
140      positionOverlay(); // submit-btn мог переехать (мобильный slideDown)
141      spinner.style.display = '';
142      // Если спиннер виден — clear-кнопку прячем (на её месте крутится спиннер),
143      // показываем обратно когда спиннер уйдёт.
144      clearBtnEl.style.display = 'none';
145    }
146    function hideSpinner() {
147      spinner.style.display = 'none';
148      updateClearVisibility();
149    }
150    function updateClearVisibility() {
151      var hasText = input.value.trim().length > 0;
152      var spinning = spinner.style.display !== 'none';
153      var shouldShow = hasText && !spinning;
154      if (shouldShow) positionOverlay();
155      clearBtnEl.style.display = shouldShow ? '' : 'none';
156    }
157
158    clearBtnEl.addEventListener('click', function(e){
159      e.preventDefault(); e.stopPropagation();
160      input.value = '';
161      hideSpinner();
162      close();
163      updateClearVisibility();
164      input.focus();
165    });
166
167    function escapeHtml(s) {
168      return String(s).replace(/[&<>"']/g, function(c){
169        return {'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c];
170      });
171    }
172    function highlightMatch(text, q) {
173      if (!q) return escapeHtml(text);
174      var tokens = String(q).trim().split(/\s+/).filter(Boolean).map(function(t){
175        return t.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
176      });
177      if (!tokens.length) return escapeHtml(text);
178      var re = new RegExp(tokens.join('|'), 'gi');
179      var out = '';
180      var last = 0;
181      var m;
182      while ((m = re.exec(text)) !== null) {
183        if (m.index > last) out += escapeHtml(text.slice(last, m.index));
184        out += '<mark class="vd-suggest-mark">' + escapeHtml(m[0]) + '</mark>';
185        last = re.lastIndex;
186        if (m[0].length === 0) re.lastIndex++;
187      }
188      if (last < text.length) out += escapeHtml(text.slice(last));
189      return out;
190    }
191    function formatPrice(n) {
192      if (!n && n !== 0) return '';
193      return new Intl.NumberFormat('ru-RU').format(Math.round(n)) + ' ₽';
194    }
195    function discountColor(percent) {
196      var t = Math.max(0, Math.min(1, percent / 30));
197      var r = Math.round(255 + (220 - 255) * t);
198      var g = Math.round(200 + (50  - 200) * t);
199      var b = Math.round(0   + (50  - 0)   * t);
200      return 'rgb(' + r + ',' + g + ',' + b + ')';
201    }
202    function close() { dropdown.innerHTML = ''; dropdown.style.display = 'none'; }
203    function open()  { dropdown.style.display = 'block'; }
204
205    function renderHistory() {
206      var items = loadHistory();
207      if (!items.length) { close(); return; }
208      var rows = items.map(function(q){
209        return '<a class="vd-suggest-history-item" href="/search/?q=' + encodeURIComponent(q) + '" data-q="' + escapeHtml(q) + '">' +
210          '<svg class="vd-suggest-history-ico" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">' +
211            '<circle cx="12" cy="12" r="10"/><polyline points="12 6 12 12 16 14"/>' +
212          '</svg>' +
213          '<span class="vd-suggest-history-q">' + escapeHtml(q) + '</span>' +
214          '<button type="button" class="vd-suggest-history-del" data-del="' + escapeHtml(q) + '" aria-label="Удалить">×</button>' +
215        '</a>';
216      }).join('');
217      dropdown.innerHTML =
218        '<div class="vd-suggest-history-head">Недавние запросы<button type="button" class="vd-suggest-history-clear" id="vd-history-clear">Очистить</button></div>' +
219        '<div class="vd-suggest-history-list">' + rows + '</div>';
220      open();
221    }
222
223    function render(payload, q) {
224      var items = payload.data || [];
225      var total = (payload.meta && payload.meta.total) || 0;
226      if (items.length === 0) {
227        dropdown.innerHTML = '<div class="vd-suggest-empty">По запросу <b>«' + escapeHtml(q) + '»</b> ничего не найдено.</div>';
228        open();
229        return;
230      }
231      var rows = items.map(function(item, idx){
232        var pos = idx + 1;
233        var pHref = '/p/?id=' + item.id + '&q=' + encodeURIComponent(q) + '&pos=' + pos;
234        var pic = (item.picture && (item.picture.thumb || item.picture.card)) || '';
235        var hasDiscount = item.discountPercent && item.priceFromBase && item.priceFromBase > item.priceFrom;
236        var discountBadge = hasDiscount
237          ? '<span class="vd-suggest-discount" style="background:' + discountColor(item.discountPercent) + '">−' + Math.round(item.discountPercent) + '%</span>'
238          : '';
239        return '<a class="vd-suggest-item" href="' + pHref + '" data-pid="' + item.id + '" data-pos="' + pos + '">' +
240            '<span class="vd-suggest-pic">' + (pic ? '<img src="' + escapeHtml(pic) + '" alt="">' : '') + '</span>' +
241            '<span class="vd-suggest-body">' +
242              '<span class="vd-suggest-name-row">' +
243                '<span class="vd-suggest-name">' + highlightMatch(item.name, q) + '</span>' +
244                discountBadge +
245              '</span>' +
246              '<span class="vd-suggest-price">' +
247                '<b>' + formatPrice(item.priceFrom) + '</b>' +
248                (hasDiscount ? '<s>' + formatPrice(item.priceFromBase) + '</s>' : '') +
249              '</span>' +
250            '</span>' +
251          '</a>';
252      }).join('');
253      var allHref = '/search/?q=' + encodeURIComponent(q);
254      dropdown.innerHTML = '<div class="vd-suggest-list">' + rows + '</div>' +
255        '<a class="vd-suggest-all" href="' + allHref + '">Показать все ' + total + ' →</a>';
256      open();
257    }
258
259    function load(q) {
260      var reqId = ++lastReq;
261      var u = new URL(API, location.origin);
262      u.searchParams.set('q', q);
263      u.searchParams.set('perPage', PER_PAGE);
264      showSpinner();
265      // credentials:'same-origin' — чтобы cookie BX-сессии пошёл в /api/v1/search,
266      // тогда $USER->GetID() в SearchController подхватит USER_ID и в
267      // b_akyzo_mobile_search_log запишется реальный пользователь. Нужно для
268      // атрибуции «поиск → заказ» в admin/akyzo_mobile_search_lab.php.
269      _akyzoFetch(u.toString())
270        .then(function(r){ return r.ok ? r.json() : null; })
271        .then(function(payload){
272          if (reqId !== lastReq) return;
273          hideSpinner();
274          if (!payload) { close(); return; }
275          render(payload, q);
276        })
277        .catch(function(){
278          if (reqId === lastReq) hideSpinner();
279        });
280    }
281
282    // Логирование клика по подсказке в dropdown'е. Fire-and-forget через
283    // sendBeacon — не блокирует переход на /p/?id=... Питает аналитику
284    // search→click в admin/akyzo_mobile_search_lab.php.
285    function sendSearchClick(q, productId, position) {
286      if (!q || !productId) return;
287      // sendBeacon не умеет в Authorization-заголовок — используем keepalive-fetch
288      // с Bearer JWT (см. _akyzoFetch).
289      var body = JSON.stringify({q: q, productId: productId, position: position});
290      try {
291        _akyzoFetch('https://api.velodrive.ru/v1/search/click', {
292          method: 'POST', body: body,
293          headers: {'Content-Type': 'application/json'},
294          keepalive: true,
295        }).catch(function(){});
296      } catch(e) {}
297    }
298    // Делегирование: ловим mousedown на любую .vd-suggest-item (подсказку
299    // товара). История запросов и кнопки удаления — не ловятся, у них
300    // нет data-pid.
301    dropdown.addEventListener('mousedown', function(e){
302      var a = e.target.closest('.vd-suggest-item[data-pid]');
303      if (!a) return;
304      var pid = parseInt(a.getAttribute('data-pid'), 10);
305      var pos = parseInt(a.getAttribute('data-pos'), 10) || 0;
306      sendSearchClick(input.value.trim(), pid, pos);
307    }, {capture: true, passive: true});
308
309    // Сохраняем историю при submit формы — пользователь подтверждает запрос.
310    var form = input.closest('form');
311    if (form) {
312      form.addEventListener('submit', function(){
313        saveHistory(input.value);
314      });
315    }
316    // Клики по элементам истории / удалению / очистке.
317    dropdown.addEventListener('click', function(e){
318      var del = e.target.closest('.vd-suggest-history-del');
319      if (del) {
320        e.preventDefault(); e.stopPropagation();
321        var q = del.getAttribute('data-del');
322        try {
323          var arr = loadHistory().filter(function(s){ return s.toLowerCase() !== q.toLowerCase(); });
324          localStorage.setItem(HISTORY_KEY, JSON.stringify(arr));
325        } catch(err) {}
326        renderHistory();
327        return;
328      }
329      if (e.target.id === 'vd-history-clear') {
330        e.preventDefault(); e.stopPropagation();
331        clearHistory();
332        close();
333        return;
334      }
335      var hi = e.target.closest('.vd-suggest-history-item');
336      if (hi) {
337        // Сохраняем при выборе из истории — поднимаем запрос наверх списка.
338        saveHistory(hi.getAttribute('data-q'));
339      }
340    });
341
342    input.addEventListener('input', function(){
343      var q = input.value.trim();
344      clearTimeout(timer);
345      updateClearVisibility();
346      if (q.length < MIN_LEN) { hideSpinner(); close(); return; }
347      showSpinner();
348      timer = setTimeout(function(){ load(q); }, DEBOUNCE_MS);
349    });
350    input.addEventListener('focus', function(){
351      var q = input.value.trim();
352      if (q.length >= MIN_LEN) { load(q); return; }
353      // Пустой фокус — показываем историю последних запросов
354      renderHistory();
355    });
356    input.addEventListener('keydown', function(e){
357      if (e.key === 'Escape') { close(); input.blur(); }
358    });
359    document.addEventListener('click', function(e){
360      if (!container.contains(e.target)) close();
361    });
362
363    // Старый битриксовый clear (если есть в шаблоне) тоже подключаем.
364    var bitrixClear = container.querySelector('[data-toggle="clearSearch"], .api-clear-icon');
365    if (bitrixClear) {
366      bitrixClear.addEventListener('click', function(){
367        input.value = '';
368        hideSpinner();
369        close();
370        updateClearVisibility();
371        input.focus();
372      });
373    }
374
375    // На мобиле header__search — absolute overlay поверх .row.
376    // Когда Bitrix-JS делает slideUp на #search (display:none / height:0),
377    // мы должны скрыть и саму обёртку .header__search — иначе её белая
378    // полоса перекрывает баннер и триггер-иконку «лупа». А когда поиск
379    // развёрнут — пробрасываем его высоту в CSS-переменную, чтобы контент
380    // под шапкой сдвинулся вниз (margin-bottom: var(--mobile-search-h)).
381    var hsOverlay = document.querySelector('.header__search');
382    var searchInner = document.getElementById('search');
383    if (hsOverlay && searchInner && typeof ResizeObserver !== 'undefined') {
384      var mq = window.matchMedia('(max-width: 1010px)');
385      // На мобиле принудительно закрываем поиск при загрузке: Bitrix-JS
386      // на разных страницах/состояниях оставляет #search видимым по умолчанию,
387      // и полоса поиска торчит под шапкой ещё ДО клика по триггеру-лупе.
388      // Открытие — только по нажатию `.searchCollapseTrigger` (это сделает
389      // Bitrix-JS slideDown).
390      if (mq.matches && searchInner.offsetHeight > 0) {
391        searchInner.style.display = 'none';
392      }
393      var sync = function(h) {
394        if (!mq.matches) {
395          hsOverlay.classList.remove('vd-search-collapsed');
396          document.documentElement.style.setProperty('--mobile-search-h', '0px');
397          return;
398        }
399        if (h > 0) {
400          hsOverlay.classList.remove('vd-search-collapsed');
401          document.documentElement.style.setProperty('--mobile-search-h', (hsOverlay.offsetHeight + 4) + 'px');
402        } else {
403          hsOverlay.classList.add('vd-search-collapsed');
404          document.documentElement.style.setProperty('--mobile-search-h', '0px');
405        }
406      };
407      var ro = new ResizeObserver(function(entries){
408        sync(entries[0].contentRect.height);
409      });
410      ro.observe(searchInner);
411      sync(searchInner.getBoundingClientRect().height);
412      mq.addEventListener && mq.addEventListener('change', function(){
413        sync(searchInner.getBoundingClientRect().height);
414      });
415    }
416
417    // Префил из URL: на /search/?q=… input приходит с сервера пустым
418    // (шаблон api:search.title не подставляет $_GET['q']). Заполняем сами.
419    try {
420      var urlQ = new URL(location.href).searchParams.get('q');
421      if (urlQ && !input.value) {
422        input.value = urlQ;
423        updateClearVisibility();
424      }
425    } catch(e) {}
426    return true;
427  }
428
429  if (document.readyState === 'loading') {
430    document.addEventListener('DOMContentLoaded', init);
431  } else {
432    if (!init()) {
433      // Шаблон Bitrix может допиливать DOM позже — добавим небольшую отложенную попытку
434      setTimeout(init, 300);
435      setTimeout(init, 1000);
436    }
437  }
438})();

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.