PageSourceSearch

https://komplektadr.ru/local/js/kadr_chat_widget.js?v=30

js komplektadr.ru collected 2026-10-08 15:19:38 UTC 46,148 bytes, 1,039 lines download raw bytes

1/**
2 * Чат-консультант komplektadr.ru — клиент.
3 * Путь на сервере: /local/js/kadr_chat_widget.js (подключается из chat_widget_inject.php).
4 *
5 * Ждёт window.KADR_CW = { sessid, cartCount, version } из инъекции.
6 * Серверные точки: /local/ajax/chat_agent.php (мозг; stream=1 → NDJSON-статусы + финал),
7 * /local/ajax/transcribe.php (голос), /local/ajax/add2basket_by_id.php (кнопка «в корзину»).
8 * История диалога живёт в sessionStorage и шлётся с каждым запросом (сервер stateless).
9 *
10 * v20: живые статусы этапов, оценки 👍/👎 (kadr_widget_feedback), бейдж «Проверено: …»,
11 * FAB-пилюля с текстом + мобильный тизер, контекстные чипы, волна записи голоса,
12 * разворот панели, кнопка «вниз» + умный автоскролл, сворачивание длинных ответов,
13 * карусель карточек, скрим на мобильном, вибрация.
14 * v25: воронка в Я.Метрику (цели kcw_*): impression, invite_show, open(source),
15 * close(guardrail), first_message(input/chip), product_click, add_to_cart.
16 */
17(function () {
18  'use strict';
19
20  var CFG = window.KADR_CW || {};
21  if (!CFG.sessid || document.getElementById('kcw-root')) return;
22
23  var API_CHAT = '/local/ajax/chat_agent.php';
24  var API_VOICE = '/local/ajax/transcribe.php';
25  var API_CART = '/local/ajax/add2basket_by_id.php';
26  var STORE_KEY = 'kcw_state_v1';
27  var GREETING = 'Здравствуйте! Я ИИ-помощник KomplektADR.ru. Помогу подобрать оснащение для перевозки опасных грузов, подскажу цены и наличие, соберу корзину по вашему списку. Можно писать текстом или надиктовать голосом.';
28  var FAB_LABEL = 'Спросить ИИ';
29  var TEASER_TEXT = 'Здравствуйте! Подберу комплект ADR и отвечу на вопросы по перевозке опасных грузов.';
30  // чипы: стабильный id для аналитики (chip_id) + текст; текст можно менять, id — нет
31  var CHIPS_BASE = [
32    { id: 'base_unknown_cargo', t: 'Не знаю, что повезу — что нужно машине?' },
33    { id: 'base_price_class3', t: 'Сколько стоит комплект ADR 3 класса?' },
34    { id: 'base_gazel_plate', t: 'Какая табличка нужна на Газель?' },
35    { id: 'base_invoice_inn', t: 'Как быстро получить счёт по ИНН?' }
36  ];
37  // чип под текущую страницу — персонализация стартовых подсказок
38  function contextChip() {
39    var p = location.pathname;
40    // статьи классов ADR в блоге (adr-9-klass-txt и т.п.) — главный вход с поиска
41    var mCls = p.match(/^\/blog\/.*adr-(\d+)-klass/);
42    if (mCls) return { id: 'article_adr_class_' + mCls[1], t: 'Что нужно для перевозки груза класса ' + mCls[1] + '?' };
43    if (p.indexOf('/catalog/komplekty-adr') === 0) return { id: 'ctx_kit_contents', t: 'Что входит в комплект ADR?' };
44    if (/tablichk|znak|markirovka|nakleyk/i.test(p)) return { id: 'ctx_signs_pick', t: 'Подобрать знаки опасности на машину' };
45    if (/ognetushitel/i.test(p)) return { id: 'ctx_extinguishers', t: 'Какие огнетушители нужны по ДОПОГ?' };
46    if (p.indexOf('osnashchenie-ts-po-trebovaniyam-tr-ts') !== -1) return { id: 'ctx_trts', t: 'Чем ТР ТС 018/2011 отличается от ДОПОГ?' };
47    if (p === '/un' || p.indexOf('/un/') === 0) return { id: 'ctx_un_more', t: 'Что ещё понадобится для моего груза?' };
48    return null;
49  }
50
51  var reducedMotion = false;
52  try { reducedMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches; } catch (e) {}
53
54  // ---------- аналитика воронки (Я.Метрика 21641209, цели kcw_*) ----------
55  // Счётчик opt-out (invis-counter.php): грузится у всех, кроме явно отказавшихся в
56  // куки-баннере; проверка ym() покрывает отказ и блокировщики трекеров.
57  var YM_ID = 21641209;
58  var AB_VARIANT = 'baseline'; // этап 2 (A/B текста пилюли): 'control' | 'test'
59  var IS_MOBILE = false;
60  try { IS_MOBILE = window.matchMedia('(max-width: 899px)').matches; } catch (e) {}
61  function pageType() {
62    var p = location.pathname;
63    if (p.indexOf('/blog/') === 0) return 'article';
64    if (p.indexOf('/catalog/') === 0) return 'catalog';
65    if (p === '/un' || p.indexOf('/un/') === 0) return 'un';
66    return 'other';
67  }
68  function track(goal, params) {
69    try {
70      if (typeof ym !== 'function') return;
71      var p = { variant: AB_VARIANT, page_type: pageType(), device: IS_MOBILE ? 'mobile' : 'desktop', path: location.pathname.slice(0, 120) };
72      if (params) {
73        for (var k in params) {
74          if (Object.prototype.hasOwnProperty.call(params, k) && params[k] != null) p[k] = params[k];
75        }
76      }
77      ym(YM_ID, 'reachGoal', 'kcw_' + goal, p);
78    } catch (e) {}
79  }
80  // откуда пришёл текст в поле ввода — для параметра input у first_message
81  var inputSource = 'typed';
82  var inputChipId = null;
83
84  // лёгкая вибрация на телефоне (Android; iOS молча игнорирует)
85  function buzz(ms) {
86    try { if (navigator.vibrate) navigator.vibrate(ms); } catch (e) {}
87  }
88
89  // ---------- состояние ----------
90  var state = { transcript: [], history: [], lastProducts: [], cartCount: 0, open: false };
91  try {
92    var saved = JSON.parse(sessionStorage.getItem(STORE_KEY) || 'null');
93    if (saved && saved.transcript) state = saved;
94  } catch (e) {}
95  state.open = false;
96  // реальное количество в корзине с сервера — источник правды при каждой загрузке страницы
97  if (typeof CFG.cartCount === 'number') state.cartCount = CFG.cartCount;
98
99  function persist() {
100    try {
101      sessionStorage.setItem(STORE_KEY, JSON.stringify({
102        transcript: state.transcript.slice(-40),
103        history: state.history.slice(-10),
104        lastProducts: state.lastProducts,
105        cartCount: state.cartCount
106      }));
107    } catch (e) {}
108  }
109
110  // ---------- утилиты ----------
111  function el(tag, cls, parent) {
112    var node = document.createElement(tag);
113    if (cls) node.className = cls;
114    if (parent) parent.appendChild(node);
115    return node;
116  }
117  function esc(s) {
118    return String(s == null ? '' : s)
119      .replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
120      .replace(/"/g, '&quot;').replace(/'/g, '&#39;');
121  }
122  // ссылки кликабельны только на свой домен
123  function linkify(s) {
124    return esc(s).replace(/https:\/\/komplektadr\.ru[^\s<>"')<]*/g, function (u) {
125      var trail = '';
126      while (/[.,;:!?]$/.test(u)) { trail = u.slice(-1) + trail; u = u.slice(0, -1); }
127      return '<a href="' + u + '" target="_blank" rel="noopener noreferrer">' + u + '</a>' + trail;
128    });
129  }
130
131  // мини-markdown для ответов бота: [текст](url свой домен), **жирный**, списки «- » и «1. », абзацы
132  function mdInline(s) {
133    s = s.replace(/\[([^\]]+)\]\((https:\/\/komplektadr\.ru[^)\s]*)\)/g,
134      '<a href="$2" target="_blank" rel="noopener noreferrer">$1</a>');
135    s = s.replace(/(^|[\s(])(https:\/\/komplektadr\.ru[^\s<>"')]+)/g, function (m, pre, u) {
136      var trail = '';
137      while (/[.,;:!?]$/.test(u)) { trail = u.slice(-1) + trail; u = u.slice(0, -1); }
138      return pre + '<a href="' + u + '" target="_blank" rel="noopener noreferrer">' + u + '</a>' + trail;
139    });
140    s = s.replace(/\*\*([^*\n]+)\*\*/g, '<strong>$1</strong>');
141    return s;
142  }
143  function md(text) {
144    var lines = esc(text).split(/\r?\n/);
145    var html = '';
146    var list = null; // 'ul' | 'ol'
147    var para = [];
148    function flushPara() {
149      if (para.length) { html += '<p>
149' + para.map(mdInline).join('<br>') + '</p>'; para = []; }
150    }
151    function flushList() {
152      if (list) { html += '</' + list + '>'; list = null; }
153    }
154    for (var i = 0; i < lines.length; i++) {
155      var line = lines[i].trim();
156      var mUl = line.match(/^[-•]\s+(.*)/);
157      var mOl = line.match(/^\d+[.)]\s+(.*)/);
158      if (mUl || mOl) {
159        flushPara();
160        var want = mUl ? 'ul' : 'ol';
161        if (list !== want) { flushList(); html += '<' + want + '>'; list = want; }
162        html += '<li>' + mdInline((mUl || mOl)[1]) + '</li>';
163      } else if (line === '') {
164        flushPara();
165        flushList();
166      } else {
167        flushList();
168        para.push(line);
169      }
170    }
171    flushPara();
172    flushList();
173    return html;
174  }
175  function fmtPrice(p) {
176    if (p == null || isNaN(p)) return '';
177    return String(Math.round(p)).replace(/\B(?=(\d{3})+(?!\d))/g, ' ') + ' ₽';
178  }
179  function svg(pathHtml, cls) {
180    var NS = 'http://www.w3.org/2000/svg';
181    var node = document.createElementNS(NS, 'svg');
182    node.setAttribute('viewBox', '0 0 24 24');
183    if (cls) node.setAttribute('class', cls);
184    node.innerHTML = pathHtml;
185    return node;
186  }
187  var IC = {
188    chat: '<path d="M21 11.5a8.38 8.38 0 0 1-.9 3.8 8.5 8.5 0 0 1-7.6 4.7 8.38 8.38 0 0 1-3.8-.9L3 21l1.9-5.7a8.38 8.38 0 0 1-.9-3.8 8.5 8.5 0 0 1 4.7-7.6 8.38 8.38 0 0 1 3.8-.9h.5a8.48 8.48 0 0 1 8 8v.5z"/>',
189    close: '<path d="M18 6 6 18M6 6l12 12"/>',
190    mic: '<path d="M12 2a3 3 0 0 0-3 3v6a3 3 0 0 0 6 0V5a3 3 0 0 0-3-3z"/><path d="M19 10v1a7 7 0 0 1-14 0v-1M12 18v4"/>',
191    send: '<path d="m5 12 14-7-4 7 4 7-14-7z"/><path d="M5 12h9"/>',
192    cart: '<circle cx="9" cy="20" r="1.6"/><circle cx="17" cy="20" r="1.6"/><path d="M3 3h2.2l2.3 11.6a1.4 1.4 0 0 0 1.4 1.1h7.9a1.4 1.4 0 0 0 1.4-1.1L20 7H6"/>',
193    spark: '<path d="M12 3v3M12 18v3M3 12h3M18 12h3M5.6 5.6l2.1 2.1M16.3 16.3l2.1 2.1M18.4 5.6l-2.1 2.1M7.7 16.3l-2.1 2.1"/>',
194    link: '<path d="M10 13a5 5 0 0 0 7.5.5l3-3a5 5 0 0 0-7-7l-1.7 1.7"/><path d="M14 11a5 5 0 0 0-7.5-.5l-3 3a5 5 0 0 0 7 7l1.7-1.7"/>',
195    check: '<path d="M20 6 9 17l-5-5"/>',
196    down: '<path d="M12 5v14M5 12l7 7 7-7"/>',
197    expand: '<path d="M8 3H5a2 2 0 0 0-2 2v3M16 3h3a2 2 0 0 1 2 2v3M8 21H5a2 2 0 0 1-2-2v-3M16 21h3a2 2 0 0 0 2-2v-3"/>',
198    shrink: '<path d="M9 9H5M9 9V5M9 9 3.5 3.5M15 9h4M15 9V5M15 9l5.5-5.5M9 15H5M9 15v4M9 15l-5.5 5.5M15 15h4M15 15v4M15 15l5.5 5.5"/>',
199    thumbUp: '<path d="M14 9V5a3 3 0 0 0-3-3l-4 9v11h11.28a2 2 0 0 0 2-1.7l1.38-9a2 2 0 0 0-2-2.3zM7 22H4a2 2 0 0 1-2-2v-7a2 2 0 0 1 2-2h3"/>',
200    thumbDown: '<path d="M10 15v4a3 3 0 0 0 3 3l4-9V2H5.72a2 2 0 0 0-2 1.7l-1.38 9a2 2 0 0 0 2 2.3zM17 2h2.67A2.31 2.31 0 0 1 22 4v7a2.31 2.31 0 0 1-2.33 2H17"/>'
201  };
202
203  // ---------- DOM ----------
204  var root = el('div', null, document.body);
205  root.id = 'kcw-root';
206
207  // кнопка-пилюля запуска: иконка + подпись (подпись скрыта, когда чат открыт / на телефоне)
208  var fab = el('button', 'kcw-fab', root);
209  fab.type = 'button';
210  fab.setAttribute('aria-label', 'ИИ-помощник KomplektADR.ru');
211  var fabIc = el('span', 'kcw-fab-ic', fab);
212  fabIc.appendChild(svg(IC.chat, 'kcw-ic-chat'));
213  fabIc.appendChild(svg(IC.close, 'kcw-ic-close'));
214  var fabLabel = el('span', 'kcw-fab-label', fab);
215  fabLabel.textContent = FAB_LABEL;
216  var fabBadge = el('span', 'kcw-fab-badge', fab);
217
218  // затемнение страницы под мобильной шторкой (клик — закрыть)
219  var scrim = el('div', 'kcw-scrim', root);
220
221  var panel = el('div', 'kcw-panel', root);
222  panel.setAttribute('role', 'dialog');
223  panel.setAttribute('aria-label', 'ИИ-помощник KomplektADR.ru');
224
225  var head = el('div', 'kcw-head', panel);
226  var avatar = el('div', 'kcw-avatar', head);
227  // эмблема: фирменный логотип-сумка ADR (favicon сайта, векторный) + живое «ИИ»-кольцо (CSS)
228  var avatarImg = el('img', 'kcw-avatar-img', avatar);
229  avatarImg.src = '/favicon.svg';
230  avatarImg.alt = '';
231  var headTxt = el('div', 'kcw-head-txt', head);
232  el('div', 'kcw-title', headTxt).textContent = 'ИИ помощник KomplektADR.ru';
233  el('div', 'kcw-status', headTxt).textContent = 'онлайн';
234  var cartLink = el('a', 'kcw-cart-link', head);
235  cartLink.href = '/basket/';
236  cartLink.target = '_blank';
237  cartLink.rel = 'noopener';
238  cartLink.title = 'Корзина';
239  cartLink.appendChild(svg(IC.cart));
240  var cartBadge = el('span', 'kcw-cart-badge', cartLink);
241  var wideBtn = el('button', 'kcw-cart-link kcw-btn-wide', head);
242  wideBtn.type = 'button';
243  wideBtn.title = 'Развернуть';
244  wideBtn.setAttribute('aria-label', 'Развернуть окно чата');
245  wideBtn.appendChild(svg(IC.expand, 'kcw-ic-exp'));
246  wideBtn.appendChild(svg(IC.shrink, 'kcw-ic-shr'));
247  var closeBtn = el('button', 'kcw-cart-link kcw-close', head);
248  closeBtn.type = 'button';
249  closeBtn.title = 'Закрыть';
250  closeBtn.setAttribute('aria-label', 'Закрыть чат');
251  closeBtn.appendChild(svg(IC.close));
252
253  var body = el('div', 'kcw-body', panel);
254  body.setAttribute('role', 'log');
255  body.setAttribute('aria-live', 'polite');
256
257  // плавающая кнопка «к последнему сообщению»
258  var downBtn = el('button', 'kcw-down', panel);
259  downBtn.type = 'button';
260  downBtn.title = 'К последнему сообщению';
261  downBtn.appendChild(svg(IC.down));
262
263  var toast = el('div', 'kcw-toast', panel);
264
265  var foot = el('div', 'kcw-foot', panel);
266  var inrow = el('div', 'kcw-inrow', foot);
267  var input = el('textarea', 'kcw-input', inrow);
268  input.rows = 1;
269  input.placeholder = 'Спросите или продиктуйте…';
270  input.setAttribute('aria-label', 'Сообщение консультанту');
271  // визуализация записи голоса: [×] [Запись… 0:07 ▂▅▃▇] — показывается вместо поля ввода
272  var cancelBtn = el('button', 'kcw-btn-cancel', inrow);
273  cancelBtn.type = 'button';
274  cancelBtn.title = 'Отменить запись';
275  cancelBtn.appendChild(svg(IC.close));
276  var wave = el('div', 'kcw-wave', inrow);
277  var waveLabel = el('span', 'kcw-wave-label', wave);
278  waveLabel.textContent = 'Запись…';
279  var waveTime = el('span', 'kcw-wave-time', wave);
280  var waveCanvas = el('canvas', 'kcw-wave-c', wave);
281  var micBtn = el('button', 'kcw-btn-mic', inrow);
282  micBtn.type = 'button';
283  micBtn.title = 'Голосовой ввод';
284  micBtn.appendChild(svg(IC.mic));
285  var sendBtn = el('button', 'kcw-btn-send', inrow);
286  sendBtn.type = 'button';
287  sendBtn.title = 'Отправить';
288  sendBtn.appendChild(svg(IC.send));
289  el('div', 'kcw-hint', foot).textContent = 'ИИ-помощник может ошибаться — цены и наличие проверяйте у менеджеров.' + (CFG.version ? ' · v' + CFG.version : '');
290
291  // мобильный тизер: приглашение над кнопкой (десктоп ≥900px открывает панель сам)
292  var teaser = null;
293  function hideTeaser(remember) {
294    if (!teaser) return;
295    teaser.classList.remove('kcw-on');
296    if (remember !== false) {
297      try { sessionStorage.setItem('kcw_teaser', '1'); } catch (e) {}
298    }
299  }
300  try {
301    if (window.matchMedia('(max-width: 899px)').matches
302      && !sessionStorage.getItem('kcw_auto_open') && !sessionStorage.getItem('kcw_teaser')) {
303      teaser = el('div', 'kcw-teaser', root);
304      el('div', 'kcw-teaser-txt', teaser).textContent = TEASER_TEXT;
305      var teaserClose = el('button', 'kcw-teaser-close', teaser);
306      teaserClose.type = 'button';
307      teaserClose.setAttribute('aria-label', 'Скрыть подсказку');
308      teaserClose.appendChild(svg(IC.close));
309      teaser.addEventListener('click', function (ev) {
310        if (teaserClose.contains(ev.target)) return;
311        hideTeaser();
312        toggle(true, 'teaser');
313      });
314      teaserClose.addEventListener('click', function (ev) {
315        ev.stopPropagation();
316        hideTeaser(); // закрыл сам — больше не показываем в этой сессии
317        track('invite_close', { invite: 'teaser' });
318      });
319      setTimeout(function () {
320        if (teaser && !state.open) {
321          teaser.classList.add('kcw-on');
322          track('invite_show', { invite: 'teaser' });
323        }
324      }, 6000);
325      setTimeout(function () { hideTeaser(false); }, 34000); // тихо убрать; на следующей странице можно снова
326    }
327  } catch (e) {}
328
329  // ---------- прокрутка ----------
330  function nearBottom() {
331    return body.scrollHeight - body.scrollTop - body.clientHeight < 90;
332  }
333  // умный автоскролл: не дёргаем ленту, если человек читает выше; вместо этого — кнопка с точкой
334  function scrollDown(force) {
335    if (force || nearBottom()) {
336      body.scrollTop = body.scrollHeight;
337      downBtn.classList.remove('kcw-new');
338    } else {
339      downBtn.classList.add('kcw-new');
340    }
341    updateDown();
342  }
343  function updateDown() {
344    var gap = body.scrollHeight - body.scrollTop - body.clientHeight;
345    if (gap > 240) {
346      downBtn.classList.add('kcw-show');
347    } else {
348      downBtn.classList.remove('kcw-show');
349      downBtn.classList.remove('kcw-new');
350    }
351  }
352  var scrollRaf = 0;
353  body.addEventListener('scroll', function () {
354    if (scrollRaf) return;
355    scrollRaf = requestAnimationFrame(function () { scrollRaf = 0; updateDown(); });
356  }, { passive: true });
357  downBtn.addEventListener('click', function () {
358    try {
359      body.scrollTo({ top: body.scrollHeight, behavior: reducedMotion ? 'auto' : 'smooth' });
360    } catch (e) {
361      body.scrollTop = body.scrollHeight;
362    }
363    downBtn.classList.remove('kcw-new');
364  });
365
366  // ---------- отрисовка ----------
367  // длинные «простыни» сворачиваем: видно первые ~5 экранов строк, дальше «Показать полностью»
368  function maybeClamp(msg, item) {
369    if (item.checkout) return; // внутри может быть кнопка оформления — не прячем
370    if (msg.scrollHeight > 620) {
371      msg.classList.add('kcw-clamp');
372      var more = el('button', 'kcw-more', msg);
373      more.type = 'button';
374      more.textContent = 'Показать полностью';
375      more.addEventListener('click', function () {
376        msg.classList.remove('kcw-clamp');
377        more.remove();
378      });
379    }
380  }
381
382  // строка под ответом: «Проверено: …» + оценки 👍/👎
383  function renderMeta(item) {
384    var meta = el('div', 'kcw-meta', body);
385    if (item.src && item.src.length) {
386      var src = el('span', 'kcw-src', meta);
387      src.appendChild(svg(IC.check));
388      src.appendChild(document.createTextNode('Проверено: ' + item.src.join(' · ')));
389    }
390    var fb = el('span', 'kcw-fb', meta);
391    var up = el('button', 'kcw-fb-btn kcw-fb-up', fb);
392    up.type = 'button';
393    up.title = 'Полезный ответ';
394    up.setAttribute('aria-label', 'Полезный ответ');
395    up.appendChild(svg(IC.thumbUp));
396    var dn = el('button', 'kcw-fb-btn kcw-fb-dn', fb);
397    dn.type = 'button';
398    dn.title = 'Плохой ответ';
399    dn.setAttribute('aria-label', 'Плохой ответ');
400    dn.appendChild(svg(IC.thumbDown));
401    function apply() {
402      fb.classList.toggle('kcw-voted', !!item.fb);
403      up.classList.toggle('kcw-sel', item.fb === 1);
404      dn.classList.toggle('kcw-sel', item.fb === -1);
405    }
406    function vote(v) {
407      if (item.fb === v) return;
408      item.fb = v;
409      apply();
410      persist();
411      buzz(8);
412      postForm(API_CHAT, { sessid: CFG.sessid, cw_action: 'feedback', log_id: item.log, rating: v },
413        function () {}, function () {});
414      showToast(v === 1 ? 'Спасибо за оценку!' : 'Спасибо, учтём!');
415    }
416    up.addEventListener('click', function () { vote(1); });
417    dn.addEventListener('click', function () { vote(-1); });
418    apply();
419  }
420
421  // карточки товаров с кнопкой «В корзину»; set = { items, added } — состояние кнопок сохраняется в persist()
422  function renderCards(set) {
423    var wrap = el('div', 'kcw-cards', body);
424    var many = (set.items || []).length >= 3;
425    if (many) wrap.classList.add('kcw-carousel'); // 3+ товаров — горизонтальная лента
426    (set.items || []).forEach(function (p) {
427      var card = el('div', 'kcw-card', wrap);
428      if (p.picture) {
429        var img = el('img', 'kcw-card-img', card);
430        img.src = p.picture;
431        img.alt = '';
432        img.loading = 'lazy';
433      } else {
434        el('div', 'kcw-card-img kcw-noimg', card).textContent = '📦';
435      }
436      var info = el('div', 'kcw-card-info', card);
437      var name = el('a', 'kcw-card-name', info);
438      name.textContent = p.name || '';
439      if (p.url) {
440        name.href = p.url;
441        name.target = '_blank';
442        name.rel = 'noopener';
443        name.addEventListener('click', function () { track('product_click', { product_id: p.id }); });
444      }
445      if (p.variant) el('div', 'kcw-card-variant', info).textContent = p.variant;
446      if (p.price) el('div', 'kcw-card-price', info).textContent = fmtPrice(p.price);
447      var btn = el('button', 'kcw-card-btn', card);
448      btn.type = 'button';
449      if (set.added && set.added[p.id]) {
450        btn.textContent = '✓ В корзине';
451        btn.className += ' kcw-done';
452      } else {
453        btn.textContent = 'В корзину';
454        btn.addEventListener('click', function () { cardAddToCart(p, btn, set); });
455      }
456    });
457  }
458
459  function renderItem(item) {
460    if (item.k === 'user' || item.k === 'bot') {
461      var msg = el('div', 'kcw-msg ' + (item.k === 'user' ? 'kcw-msg-user' : 'kcw-msg-bot'), body);
462      msg.innerHTML = item.k === 'bot' ? md(item.t) : linkify(item.t);
463      // кнопка «к оформлению» — внутри пузыря ответа, после текста
464      if (item.k === 'bot' && item.checkout) {
465        var cbtn = el('a', 'kcw-msg-btn', msg);
466        cbtn.href = item.checkout;
467        cbtn.target = '_blank';
468        cbtn.rel = 'noopener';
469        cbtn.appendChild(svg(IC.cart));
470        cbtn.appendChild(document.createTextNode('Перейти к оформлению заказа'));
471      }
472      if (item.k === 'bot') {
473        maybeClamp(msg, item);
474        // карточки — сразу под текстом (ответ обычно заканчивается предложением купить), оценки — под карточками
475        (item.cards || []).forEach(renderCards);
476        if (item.log) renderMeta(item);
477      }
478      return;
479    }
480    if (item.k === 'products') { // старые сохранённые диалоги: карточки отдельным элементом над ответом
481      renderCards(item);
482      return;
483    }
484    if (item.k === 'service' || item.k === 'pagelink' || item.k === 'checkout') {
485      var link = el('a', 'kcw-service', body);
486      link.href = item.url;
487      link.target = '_blank';
488      link.rel = 'noopener noreferrer';
489      link.appendChild(svg(item.k === 'checkout' ? IC.cart : IC.link));
490      var txt = el('div', null, link);
491      el('div', 'kcw-service-title', txt).textContent = item.title || item.url;
492      el('div', 'kcw-service-sub', txt).textContent = item.k === 'service' ? 'Бесплатный сервис — открыть'
493        : (item.k === 'checkout' ? 'Данные уже заполнены — останется подтвердить' : 'Открыть страницу');
494      return;
495    }
496  }
497
498  function push(item) {
499    state.transcript.push(item);
500    renderItem(item);
501    persist();
502    scrollDown(item.k === 'user'); // своё сообщение — всегда вниз; ответы не дёргают читающего
503  }
504
505  function renderAll() {
506    body.innerHTML = '';
507    if (!state.transcript.length) {
508      push({ k: 'bot', t: GREETING });
509    } else {
510      state.transcript.forEach(renderItem);
511    }
512    // чипы-подсказки — пока пользователь ничего не написал; первый — под текущую страницу
513    if (!state.history.length) {
514      var chips = el('div', 'kcw-chips', body);
515      var ctx = contextChip();
516      var list = ctx ? [ctx].concat(CHIPS_BASE.slice(0, 3)) : CHIPS_BASE.slice(0);
517      list.forEach(function (q) {
518        var chip = el('button', 'kcw-chip', chips);
519        chip.type = 'button';
520        chip.textContent = q.t;
521        chip.addEventListener('click', function () {
522          input.value = q.t;
523          inputSource = 'chip';
524          inputChipId = q.id;
525          send();
526        });
527      });
528    }
529    scrollDown(true);
530  }
531
532  function setCart(n) {
533    if (n == null || isNaN(n)) return;
534    state.cartCount = Math.max(0, Math.round(n));
535    [cartBadge, fabBadge].forEach(function (b) {
536      b.textContent = state.cartCount > 99 ? '99+' : String(state.cartCount);
537      b.className = b.className.replace(' kcw-on', '') + (state.cartCount > 0 ? ' kcw-on' : '');
538    });
539    persist();
540  }
541
542  var toastTimer = null;
543  function showToast(text) {
544    toast.textContent = text;
545    toast.classList.add('kcw-on');
546    clearTimeout(toastTimer);
547    toastTimer = setTimeout(function () { toast.classList.remove('kcw-on'); }, 2600);
548  }
549
550  // ---------- индикатор набора + живой статус этапа ----------
551  var typingNode = null;
552  var typingTxt = null;
553  function typing(on) {
554    if (on && !typingNode) {
555      typingNode = el('div', 'kcw-typing', body);
556      typingNode.innerHTML = '<i></i><i></i><i></i>';
557      typingTxt = el('span', 'kcw-typing-txt', typingNode);
558      scrollDown(true);
559    } else if (!on && typingNode) {
560      typingNode.remove();
561      typingNode = null;
562      typingTxt = null;
563    }
564  }
565  function setTypingStatus(text) {
566    if (!typingTxt || typingTxt.textContent === text) return;
567    typingTxt.textContent = text;
568    typingTxt.classList.remove('kcw-fade');
569    void typingTxt.offsetWidth; // перезапуск анимации появления
570    typingTxt.classList.add('kcw-fade');
571    scrollDown(false);
572  }
573
574  // ---------- сеть ----------
575  function postForm(url, fields, done, fail) {
576    var fd = new FormData();
577    Object.keys(fields).forEach(function (key) { fd.append(key, fields[key]); });
578    fetch(url, { method: 'POST', body: fd, credentials: 'same-origin' })
579      .then(function (r) { return r.json(); })
580      .then(done)
581      .catch(fail);
582  }
583
584  var sending = false;
585  function send() {
586    var text = input.value.trim();
587    if (!text || sending) return;
588    // подсказки убираем при ЛЮБОЙ отправке (не только по клику на чип)
589    var chipsNode = body.querySelector('.kcw-chips');
590    if (chipsNode) chipsNode.remove();
591    sending = true;
592    sendBtn.disabled = true;
593    buzz(10);
594    input.value = '';
595    autosize();
596    // первое сообщение за сессию — главный KPI воронки
597    try {
598      if (!sessionStorage.getItem('kcw_msg_sent')) {
599        sessionStorage.setItem('kcw_msg_sent', '1');
600        track('first_message', { input: inputSource, chip_id: inputChipId });
601      }
602    } catch (e) {}
603    inputSource = 'typed';
604    inputChipId = null;
605    push({ k: 'user', t: text });
606    state.history.push({ role: 'user', content: text });
607    state.history = state.history.slice(-10);
608    persist();
609    typing(true);
610    root.classList.add('kcw-busy'); // ускоряет «ИИ»-кольцо на аватаре
611
612    // потоковый запрос: NDJSON-строки {"s":"Ищу в каталоге…"} + финальный объект ответа
613    var fd = new FormData();
614    fd.append('sessid', CFG.sessid);
615    fd.append('messages', JSON.stringify(state.history));
616    fd.append('context_products', JSON.stringify(state.lastProducts || []));
617    fd.append('stream', '1');
618
619    fetch(API_CHAT, { method: 'POST', body: fd, credentials: 'same-origin' })
620      .then(function (r) {
621        if (r.body && window.TextDecoder) {
622          var reader = r.body.getReader();
623          var dec = new TextDecoder();
624          var buf = '';
625          var last = null;
626          function pump() {
627            return reader.read().then(function (chunk) {
628              if (chunk.done) {
629                var tail = buf.trim();
630                if (tail) {
631                  try {
632                    var o = JSON.parse(tail);
633                    if (o && !o.s) last = o;
634                  } catch (e) {}
635                }
636                return last;
637              }
638              buf += dec.decode(chunk.value, { stream: true });
639              var i;
640              while ((i = buf.indexOf('\n')) >= 0) {
641                var line = buf.slice(0, i).trim();
642                buf = buf.slice(i + 1);
643                if (!line) continue;
644                try {
645                  var obj = JSON.parse(line);
646                  if (obj && obj.s) setTypingStatus(obj.s);
647                  else last = obj;
648                } catch (e) {}
649              }
650              return pump();
651            });
652          }
653          return pump();
654        }
655        // старый браузер без потокового чтения — разбираем ответ целиком
656        return r.text().then(function (t) {
657          var last = null;
658          t.split('\n').forEach(function (line) {
659            line = line.trim();
660            if (!line) return;
661            try {
662              var o = JSON.parse(line);
663              if (o && !o.s) last = o;
664            } catch (e) {}
665          });
666          return last;
667        });
668      })
669      .then(function (res) { finishSend(res, false); })
670      .catch(function () { finishSend(null, true); });
671  }
672
673  function finishSend(res, netFail) {
674    // если человек был внизу до вставки пачки (карточки + ответ) — докручиваем после неё целиком;
675    // первая же вставка делает nearBottom() ложным, поэтому решаем заранее
676    var stick = nearBottom();
677    typing(false);
678    sending = false;
679    sendBtn.disabled = false;
680    root.classList.remove('kcw-busy');
681    if (!res || !res.success) {
682      push({
683        k: 'bot',
684        t: netFail
685          ? 'Нет связи с сервером. Проверьте интернет и попробуйте ещё раз.'
686          : ((res && res.message) || 'Что-то пошло не так, попробуйте ещё раз.')
687      });
688      return;
689    }
690    var checkoutUrl = null;
691    var cardSets = []; // карточки — ПОД ответом: ответ заканчивается предложением купить, кнопка «В корзину» сразу под ним
692    (res.actions || []).forEach(function (a) {
693      if (a.type === 'products' && a.items && a.items.length) {
694        cardSets.push(a.items);
695      } else if (a.type === 'cart') {
696        if (a.cart_count != null) setCart(a.cart_count);
697        if (a.added > 0) {
698          showToast('Добавлено в корзину: ' + a.added);
699          buzz(15);
700          track('add_to_cart', { via: 'chat', count: a.added });
701        }
702      } else if (a.type === 'open_page' && a.url) {
703        push({ k: 'pagelink', url: a.url, title: a.url.replace('https://komplektadr.ru', 'komplektadr.ru') });
704        try { window.open(a.url, '_blank', 'noopener,noreferrer'); } catch (e) {}
705      } else if (a.type === 'service' && a.url) {
706        push({ k: 'service', url: a.url, title: a.title || a.url });
707      } else if (a.type === 'checkout_ready' && a.url) {
708        checkoutUrl = a.url; // кнопка встраивается в пузырь ответа ниже
709      }
710    });
711    var cards = cardSets.map(function (items) { return { items: items, added: {} }; });
712    if (res.reply) {
713      push({
714        k: 'bot',
715        t: res.reply,
716        checkout: checkoutUrl,
717        cards: cards.length ? cards : null,
718        log: res.log_id || null,
719        src: (res.sources && res.sources.length) ? res.sources : null
720      });
721      state.history.push({ role: 'assistant', content: res.reply });
722      state.history = state.history.slice(-10);
723    } else {
724      if (checkoutUrl) push({ k: 'checkout', url: checkoutUrl, title: 'Перейти к оформлению заказа' });
725      cards.forEach(function (set) {
726        push({ k: 'products', items: set.items, added: set.added });
727      });
728    }
729    if (res.last_products && res.last_products.length) {
730      state.lastProducts = res.last_products;
731    }
732    persist();
733    if (stick) scrollDown(true);
734    input.focus();
735  }
736
737  // кнопка «в корзину» на карточке — напрямую в готовый эндпоинт, без LLM-хода
738  function cardAddToCart(product, btn, item) {
739    btn.disabled = true;
740    postForm(API_CART, {
741      sessid: CFG.sessid,
742      // у товаров с торговыми предложениями в корзину идёт ТП (basket_id с сервера)
743      products: JSON.stringify([{ id: product.basket_id || product.id, quantity: 1 }])
744    }, function (res) {
745      if (res && res.success) {
746        btn.textContent = '✓ В корзине';
747        btn.classList.add('kcw-done');
748        item.added = item.added || {};
749        item.added[product.id] = true;
750        setCart(state.cartCount + 1);
751        showToast('Добавлено в корзину');
752        buzz(15);
753        track('add_to_cart', { via: 'card', product_id: product.id });
754        persist();
755      } else {
756        btn.disabled = false;
757        showToast('Не удалось добавить');
758      }
759    }, function () {
760      btn.disabled = false;
761      showToast('Нет связи с сервером');
762    });
763  }
764
765  // ---------- голос ----------
766  var recorder = null;
767  var recChunks = [];
768  var recStopTimer = null;
769  var recCancelled = false;
770  var audioCtx = null;
771  var waveRaf = 0;
772  var waveTimer = null;
773  var micSupported = !!(navigator.mediaDevices && navigator.mediaDevices.getUserMedia && window.MediaRecorder);
774  if (!micSupported) micBtn.style.display = 'none';
775
776  function pickMime() {
777    var types = ['audio/webm;codecs=opus', 'audio/webm', 'audio/mp4', 'audio/ogg;codecs=opus'];
778    for (var i = 0; i < types.length; i++) {
779      if (MediaRecorder.isTypeSupported && MediaRecorder.isTypeSupported(types[i])) return types[i];
780    }
781    return '';
782  }
783
784  // живая волна записи с микрофона (WebAudio); при reduced-motion — только таймер
785  function startWave(stream) {
786    var t0 = Date.now();
787    waveTime.textContent = '0:00';
788    waveTimer = setInterval(function () {
789      var s = Math.floor((Date.now() - t0) / 1000);
790      waveTime.textContent = Math.floor(s / 60) + ':' + ('0' + (s % 60)).slice(-2);
791    }, 300);
792    var AC = window.AudioContext || window.webkitAudioContext;
793    if (!AC || reducedMotion) return;
794    try {
795      audioCtx = new AC();
796      var srcNode = audioCtx.createMediaStreamSource(stream);
797      var analyser = audioCtx.createAnalyser();
798      analyser.fftSize = 128;
799      srcNode.connect(analyser);
800      var data = new Uint8Array(analyser.frequencyBinCount);
801      var dpr = window.devicePixelRatio || 1;
802      var ctx2d = waveCanvas.getContext('2d');
803      function draw() {
804        waveRaf = requestAnimationFrame(draw);
805        var w = waveCanvas.clientWidth;
806        var h = waveCanvas.clientHeight;
807        if (!w || !ctx2d) return;
808        if (waveCanvas.width !== Math.round(w * dpr)) {
809          waveCanvas.width = Math.round(w * dpr);
810          waveCanvas.height = Math.round(h * dpr);
811        }
812        analyser.getByteFrequencyData(data);
813        ctx2d.clearRect(0, 0, waveCanvas.width, waveCanvas.height);
814        ctx2d.fillStyle = '#e0402c';
815        var bars = 24;
816        var gap = 3 * dpr;
817        var bw = (waveCanvas.width - gap * (bars - 1)) / bars;
818        for (var i = 0; i < bars; i++) {
819          var v = data[Math.floor(i * data.length / bars / 1.6)] / 255;
820          var bh = Math.max(3 * dpr, v * waveCanvas.height * 0.95);
821          var x = i * (bw + gap);
822          var y = (waveCanvas.height - bh) / 2;
823          if (ctx2d.roundRect) {
824            ctx2d.beginPath();
825            ctx2d.roundRect(x, y, bw, bh, bw / 2);
826            ctx2d.fill();
827          } else {
828            ctx2d.fillRect(x, y, bw, bh);
829          }
830        }
831      }
832      draw();
833    } catch (e) {}
834  }
835  function stopWave() {
836    clearInterval(waveTimer);
837    waveTimer = null;
838    if (waveRaf) cancelAnimationFrame(waveRaf);
839    waveRaf = 0;
840    if (audioCtx) {
841      try { audioCtx.close(); } catch (e) {}
842      audioCtx = null;
843    }
844    try {
845      var c = waveCanvas.getContext('2d');
846      if (c) c.clearRect(0, 0, waveCanvas.width, waveCanvas.height);
847    } catch (e) {}
848  }
849
850  function startRec() {
851    navigator.mediaDevices.getUserMedia({ audio: true }).then(function (stream) {
852      var mime = pickMime();
853      recorder = new MediaRecorder(stream, mime ? { mimeType: mime } : undefined);
854      recChunks = [];
855      recCancelled = false;
856      recorder.ondataavailable = function (ev) {
857        if (ev.data && ev.data.size) recChunks.push(ev.data);
858      };
859      recorder.onstop = function () {
860        stream.getTracks().forEach(function (t) { t.stop(); });
861        micBtn.classList.remove('kcw-rec');
862        inrow.classList.remove('kcw-rec-on');
863        stopWave();
864        var blob = new Blob(recChunks, { type: recorder.mimeType || 'audio/webm' });
865        recorder = null;
866        if (recCancelled) { recCancelled = false; return; } // отменили — ничего не шлём
867        if (blob.size < 1200) return; // случайный клик — тишина
868        micBtn.classList.add('kcw-busy');
869        var fd = new FormData();
870        fd.append('sessid', CFG.sessid);
871        fd.append('audio', blob, 'voice');
872        fetch(API_VOICE, { method: 'POST', body: fd, credentials: 'same-origin' })
873          .then(function (r) { return r.json(); })
874          .then(function (res) {
875            micBtn.classList.remove('kcw-busy');
876            if (res && res.success && res.text) {
877              input.value = (input.value ? input.value + ' ' : '') + res.text;
878              inputSource = 'voice';
879              inputChipId = null;
880              autosize();
881              input.focus();
882            } else {
883              showToast((res && res.message) || 'Не расслышал — попробуйте ещё раз');
884            }
885          })
886          .catch(function () {
887            micBtn.classList.remove('kcw-busy');
888            showToast('Нет связи с сервером');
889          });
890      };
891      recorder.start();
892      micBtn.classList.add('kcw-rec');
893      inrow.classList.add('kcw-rec-on');
894      startWave(stream);
895      buzz(10);
896      micBtn.title = 'Остановить запись';
897      recStopTimer = setTimeout(stopRec, 60000);
898    }).catch(function () {
899      showToast('Нет доступа к микрофону');
900    });
901  }
902
903  function stopRec() {
904    clearTimeout(recStopTimer);
905    micBtn.title = 'Голосовой ввод';
906    if (recorder && recorder.state !== 'inactive') recorder.stop();
907  }
908
909  micBtn.addEventListener('click', function () {
910    if (recorder) stopRec();
911    else startRec();
912  });
913  cancelBtn.addEventListener('click', function () {
914    recCancelled = true;
915    stopRec();
916  });
917
918  // ---------- ввод ----------
919  function autosize() {
920    input.style.height = 'auto';
921    input.style.height = Math.min(input.scrollHeight, 108) + 'px';
922    input.style.overflowY = input.scrollHeight > 108 ? 'auto' : 'hidden';
923  }
924  input.addEventListener('input', function () {
925    // человек правит текст руками — источник ввода снова «клавиатура»
926    inputSource = 'typed';
927    inputChipId = null;
928    autosize();
929  });
930  input.addEventListener('keydown', function (ev) {
931    if (ev.key === 'Enter' && !ev.shiftKey) {
932      ev.preventDefault();
933      send();
934    }
935  });
936  sendBtn.addEventListener('click', send);
937
938  // ---------- открытие/закрытие ----------
939  var openSource = null;
940  var openedAt = 0;
941  function toggle(open, source) {
942    var was = state.open;
943    state.open = open == null ? !state.open : open;
944    root.classList.toggle('kcw-open', state.open);
945    fab.setAttribute('aria-expanded', state.open ? 'true' : 'false');
946    // на телефоне под шторкой блокируем прокрутку страницы (скрим + lock)
947    var sheet = window.matchMedia('(max-width: 520px)').matches;
948    document.documentElement.classList.toggle('kcw-lock', state.open && sheet);
949    if (state.open && !was) {
950      openSource = source || 'fab';
951      openedAt = Date.now();
952      track('open', { source: openSource });
953    }
953 else if (!state.open && was) {
954      // guardrail: быстрые закрытия после auto-open видны по open_source + secs
955      track('close', { open_source: openSource, secs: openedAt ? Math.round((Date.now() - openedAt) / 1000) : null });
956    }
957    if (state.open) {
958      hideTeaser();
959      try { sessionStorage.setItem('kcw_auto_open', '1'); } catch (e) {}
960      renderAll();
961      if (window.matchMedia('(min-width: 521px)').matches) {
962        setTimeout(function () { input.focus(); }, 350);
963      }
964    } else if (recorder) {
965      stopRec();
966    }
967  }
968  fab.addEventListener('click', function () { toggle(); });
969  closeBtn.addEventListener('click', function () { toggle(false); });
970  scrim.addEventListener('click', function () { toggle(false); });
971
972  // разворот панели на десктопе (Intercom-паттерн); выбор помним в сессии
973  function setWide(v) {
974    root.classList.toggle('kcw-wide', !!v);
975    wideBtn.title = v ? 'Свернуть' : 'Развернуть';
976    try {
977      if (v) sessionStorage.setItem('kcw_wide', '1');
978      else sessionStorage.removeItem('kcw_wide');
979    } catch (e) {}
980  }
981  wideBtn.addEventListener('click', function () {
982    setWide(!root.classList.contains('kcw-wide'));
983  });
984  try { if (sessionStorage.getItem('kcw_wide')) setWide(true); } catch (e) {}
985
986  // авто-разворачивание при первом заходе (как у живых чатов — сильно поднимает вовлечённость):
987  // один раз за сессию браузера, только десктоп, через 6 секунд после загрузки
988  try {
989    if (!sessionStorage.getItem('kcw_auto_open') && window.matchMedia('(min-width: 900px)').matches) {
990      setTimeout(function () {
991        if (!state.open && !sessionStorage.getItem('kcw_auto_open')) {
992          try { sessionStorage.setItem('kcw_auto_open', '1'); } catch (e) {}
993          toggle(true, 'auto');
994        }
995      }, 6000);
996    }
997  } catch (e) {}
998  document.addEventListener('keydown', function (ev) {
999    if (ev.key === 'Escape' && state.open) toggle(false);
1000  });
1001
1002  // ---------- разводка с липкими нижними панелями сайта ----------
1003  // Сервис /un/ (.unx-cartbar «Добавить в корзину») и куки-баннер перекрывались
1004  // кнопкой чата. Меряем видимые fixed-панели у низа экрана и поднимаем виджет
1005  // на их высоту через CSS-переменную --kcw-lift (на html — её видит и a.scroll-to-top).
1006  var LIFT_SELECTORS = ['.unx-cartbar', '#kadr-cookie-banner'];
1007  var liftLast = -1;
1008  function updLift() {
1009    var vh = window.innerHeight;
1010    var lift = 0;
1011    for (var i = 0; i < LIFT_SELECTORS.length; i++) {
1012      var n = document.querySelector(LIFT_SELECTORS[i]);
1013      if (!n) continue;
1014      var cs = getComputedStyle(n);
1015      if (cs.position !== 'fixed' || cs.display === 'none' || cs.visibility === 'hidden') continue;
1016      var r = n.getBoundingClientRect();
1017      if (r.height < 20 || r.top >= vh) continue; // спрятана или уехала за низ экрана
1018      var overlap = Math.min(vh - r.top, 160);    // насколько занимает низ (с потолком)
1019      if (overlap > lift) lift = overlap;
1020    }
1021    lift = lift ? Math.round(lift + 10) : 0;
1022    if (lift !== liftLast) {
1023      liftLast = lift;
1024      document.documentElement.style.setProperty('--kcw-lift', lift + 'px');
1025    }
1026  }
1027  var liftRaf = 0;
1028  function liftOnScroll() {
1029    if (liftRaf) return;
1030    liftRaf = requestAnimationFrame(function () { liftRaf = 0; updLift(); });
1031  }
1032  updLift();
1033  window.addEventListener('scroll', liftOnScroll, { passive: true });
1034  window.addEventListener('resize', liftOnScroll);
1035  setInterval(updLift, 900); // ловит появление/закрытие панелей без событий (куки-баннер и т.п.)
1036
1037  setCart(state.cartCount);
1038  track('impression'); // FAB отрисован на этой странице — знаменатель воронки
1039})();

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.