Files
portal/app/public/chat-widget.js
T
Дмитрий ee701f8d3a feat(bot): чат растягивается + демо-стенд на боевых ценах
Окошко чата: уголок для растягивания и кнопка «развернуть», размер
запоминается — длинный ответ в маленьком окне читать неудобно. Само
открывается только там, где страница просит (лендинг): на странице входа
оно распахивалось и закрывало кнопку «Войти».

Демо-стенд (demo:showcase) — цены как на боевом: 55 ₽ за заявку, сетка
55→25 ₽, сбор конкурентов 50 ₽, сбор источников 10 ₽. Кошелёк теперь
сходится: пополнено 12 400 ₽ − 25 заявок × 55 ₽ = 11 025 ₽, пополнения
датированы раньше списаний. Поле конкурентов пересобирается начисто (опыты
со склейкой ломали порядок карточек), отчёты чистятся (портал держит не
больше трёх в работе и гасит кнопку).

Журнал денег переписать нельзя — только дописать (защита от подделки),
поэтому firstOrCreate.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-14 08:58:53 +03:00

371 lines
18 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* Окошко чата Лидерры. Один файл, без сборки: подключается и в портал (Vue),
* и в лендинг (статический HTML) — так же, как раньше подключался скрипт Jivo.
* Спека: docs/superpowers/specs/2026-07-13-own-chat-widget-design.md
*
* Постоянного соединения нет: отправили сообщение → раз в секунду спрашиваем,
* готов ли ответ (бот отвечает за ~2,5 с). Ключ разговора живёт в браузере,
* поэтому переписка не пропадает при перезагрузке страницы.
*/
(function () {
'use strict';
if (window.__liderraChat) return;
window.__liderraChat = true;
// Само открываться окошко имеет право ТОЛЬКО там, где страница об этом просит:
// <script src="/chat-widget.js" data-autoopen>. Иначе оно распахивалось и на странице
// входа в кабинет, перекрывая кнопку «Войти» (поймано роботом-съёмщиком 14.07.2026).
var selfScript = document.currentScript;
var AUTO_OPEN = !!(selfScript && selfScript.dataset && 'autoopen' in selfScript.dataset);
var POLL_MS = 1000;
var POLL_LIMIT = 25; // 25 секунд — дальше сдаёмся и говорим честно
// Гостю (лендинг) — живое приветствие с улыбкой: он ещё не клиент, его надо
// РАЗГОВОРИТЬ. Вошедшему в кабинет — деловое: он пришёл за делом, не за шуткой.
var GREETING_GUEST = '👋 Здравствуйте! Я помощник Лидерры — знаю про портал всё.\n\n'
+ 'Начальник сказал: будешь молча сидеть без вопросов — отключим 😅 Так что спасите ИИ, спросите меня о чём-нибудь — а я расскажу без воды:';
var GREETING_USER = 'Здравствуйте! Я помощник Лидерры. Спрашивайте — отвечу по делу.';
var QUICK_GUEST = [
'📞 Как получить телефоны тех, кто заходил на мой сайт?',
'💰 Сколько стоит один номер?',
'🎁 Можно попробовать бесплатно?',
'⏱ Когда придут первые заявки?',
];
var QUICK_USER = [
'Сколько стоит заявка?',
'Как создать проект?',
'Что значат статусы сделок?',
'Как пополнить баланс?',
];
function isGuest() {
return !window.__liderraChatUser;
}
function greeting() {
return isGuest() ? GREETING_GUEST : GREETING_USER;
}
function quickList() {
return isGuest() ? QUICK_GUEST : QUICK_USER;
}
var FAILED = 'Что-то пошло не так, ответ не пришёл. Попробуйте ещё раз или напишите на support@liderra.ru.';
// Ключ разговора — СВОЙ на каждый вход: переписка вошедшего содержит его личные цифры
// (баланс, проекты), и следующий человек за тем же компьютером не должен их увидеть.
function keyFor(user) {
return user ? 'liderra_chat_id_u' + user : 'liderra_chat_id_guest';
}
function newChatId() {
return (crypto.randomUUID ? crypto.randomUUID() : String(Math.random()))
.replace(/[^a-f0-9]/g, '').slice(0, 32).padEnd(32, '0');
}
function chatIdFor(key) {
var id = localStorage.getItem(key);
if (!id || !/^[a-f0-9]{32}$/.test(id)) {
id = newChatId();
localStorage.setItem(key, id);
}
return id;
}
var KEY = keyFor(window.__liderraChatUser || null);
var chatId = chatIdFor(KEY);
var lastId = 0;
var busy = false;
var css = document.createElement('style');
css.textContent = [
'.lch-btn{position:fixed;right:20px;bottom:20px;width:56px;height:56px;border-radius:50%;background:#0F6E56;color:#fff;border:0;cursor:pointer;box-shadow:0 6px 20px rgba(1,32,25,.25);transform:translateZ(0);font-size:24px;z-index:2147483000}',
'.lch-btn:focus-visible{outline:2px solid #012019;outline-offset:2px}',
// translateZ(0) — не украшение: на «дымном» фоне лендинга тень окошка рисовалась
// сплошным ЧЁРНЫМ прямоугольником (браузер не смог сложить её со слоем фона).
// Свой слой — тень снова мягкая. Убирать нельзя, вернётся чёрное пятно.
'.lch-panel{position:fixed;right:20px;bottom:88px;width:360px;max-width:calc(100vw - 32px);height:520px;max-height:calc(100vh - 120px);background:#fff;border-radius:16px;box-shadow:0 12px 40px rgba(1,32,25,.28);transform:translateZ(0);will-change:transform;display:none;flex-direction:column;overflow:hidden;z-index:2147483000;font-family:Inter,system-ui,sans-serif}',
'.lch-panel.lch-open{display:flex}',
'.lch-head{background:#012019;color:#fff;padding:14px 16px;font-weight:600;display:flex;justify-content:space-between;align-items:center}',
'.lch-tools{display:flex;gap:2px;align-items:center}',
'.lch-head button{background:none;border:0;color:#fff;font-size:20px;cursor:pointer;line-height:1;padding:4px 6px;border-radius:6px}',
'.lch-head button:hover{background:rgba(255,255,255,.12)}',
'.lch-head button:focus-visible{outline:2px solid #fff;outline-offset:2px}',
'.lch-body{flex:1;overflow-y:auto;padding:12px;background:#F6F3EC}',
'.lch-msg{max-width:85%;padding:10px 12px;border-radius:12px;margin-bottom:8px;white-space:pre-wrap;line-height:1.45;font-size:14px}',
'.lch-bot{background:#fff;color:#012019}',
'.lch-me{background:#0F6E56;color:#fff;margin-left:auto}',
'.lch-quick{display:flex;flex-wrap:wrap;gap:6px;margin-bottom:8px}',
'.lch-quick button{background:#fff;border:1px solid #0F6E56;color:#0F6E56;border-radius:14px;padding:6px 10px;font-size:13px;cursor:pointer}',
'.lch-foot{display:flex;gap:8px;padding:10px;border-top:1px solid #e6e2d8;background:#fff}',
'.lch-foot input{flex:1;border:1px solid #d6d2c8;border-radius:10px;padding:10px;font-size:14px;outline:none}',
'.lch-foot button{background:#0F6E56;color:#fff;border:0;border-radius:10px;padding:0 14px;cursor:pointer}',
'.lch-msg a{color:#0F6E56;text-decoration:underline;word-break:break-all}',
'.lch-me a{color:#fff}',
'.lch-typing{opacity:.6;font-style:italic}',
// Уголок для растягивания: окошко «прибито» к правому нижнему углу, поэтому тянем
// за ЛЕВЫЙ ВЕРХНИЙ угол — окно растёт влево и вверх, не убегая за край экрана.
'.lch-grip{position:absolute;left:0;top:0;width:22px;height:22px;cursor:nwse-resize;z-index:2;background:linear-gradient(135deg,rgba(255,255,255,.55) 0 45%,transparent 45%);border-top-left-radius:16px}',
'.lch-grip:hover{background:linear-gradient(135deg,rgba(255,255,255,.9) 0 45%,transparent 45%)}',
].join('');
document.head.appendChild(css);
var btn = document.createElement('button');
btn.className = 'lch-btn';
btn.type = 'button';
btn.setAttribute('aria-label', 'Открыть чат поддержки');
btn.setAttribute('aria-expanded', 'false');
btn.textContent = '💬';
var panel = document.createElement('div');
panel.className = 'lch-panel';
panel.setAttribute('role', 'dialog');
panel.setAttribute('aria-modal', 'false');
panel.setAttribute('aria-label', 'Чат поддержки Лидерры');
panel.innerHTML =
'<div class="lch-grip" title="Потяните, чтобы растянуть окно"></div>' +
'<div class="lch-head"><span>Лидерра — помощник</span><span class="lch-tools">'
+ '<button type="button" class="lch-zoom" aria-label="Развернуть чат" title="Развернуть">⤢</button>'
+ '<button type="button" class="lch-close" aria-label="Закрыть чат">×</button></span></div>' +
'<div class="lch-body" id="lch-body" role="log" aria-live="polite"></div>' +
'<form class="lch-foot"><input type="text" maxlength="1000" placeholder="Напишите вопрос…" aria-label="Сообщение"><button type="submit" aria-label="Отправить">→</button></form>';
document.body.appendChild(btn);
document.body.appendChild(panel);
var body = panel.querySelector('#lch-body');
var form = panel.querySelector('form');
var input = panel.querySelector('input');
var closeBtn = panel.querySelector('.lch-close');
var zoomBtn = panel.querySelector('.lch-zoom');
var grip = panel.querySelector('.lch-grip');
// Размер окна клиент задаёт сам (тянет за уголок или жмёт «развернуть») — и мы его
// ЗАПОМИНАЕМ: длинный ответ бота в маленьком окошке читать неудобно.
var MIN_W = 320, MIN_H = 380;
function maxW() { return Math.max(MIN_W, window.innerWidth - 40); }
function maxH() { return Math.max(MIN_H, window.innerHeight - 120); }
function applySize(w, h) {
w = Math.min(Math.max(Math.round(w), MIN_W), maxW());
h = Math.min(Math.max(Math.round(h), MIN_H), maxH());
panel.style.width = w + 'px';
panel.style.height = h + 'px';
try { localStorage.setItem('liderra_chat_size', w + 'x' + h); } catch (e) { /* приватный режим */ }
}
(function restoreSize() {
var saved = null;
try { saved = localStorage.getItem('liderra_chat_size'); } catch (e) { /* приватный режим */ }
if (!saved || !/^\d+x\d+$/.test(saved)) return;
var parts = saved.split('x');
applySize(parseInt(parts[0], 10), parseInt(parts[1], 10));
})();
zoomBtn.onclick = function () {
var big = panel.offsetWidth >= Math.min(700, maxW()) - 1;
if (big) {
applySize(360, 520);
zoomBtn.textContent = '⤢';
zoomBtn.title = 'Развернуть';
} else {
applySize(Math.min(700, maxW()), maxH());
zoomBtn.textContent = '⤡';
zoomBtn.title = 'Свернуть до обычного';
}
};
grip.addEventListener('pointerdown', function (e) {
e.preventDefault();
var startX = e.clientX, startY = e.clientY;
var startW = panel.offsetWidth, startH = panel.offsetHeight;
grip.setPointerCapture(e.pointerId);
function move(ev) {
applySize(startW + (startX - ev.clientX), startH + (startY - ev.clientY));
}
function up() {
grip.removeEventListener('pointermove', move);
grip.removeEventListener('pointerup', up);
}
grip.addEventListener('pointermove', move);
grip.addEventListener('pointerup', up);
});
function bubble(text, mine, extraClass) {
var el = document.createElement('div');
el.className = 'lch-msg ' + (mine ? 'lch-me' : 'lch-bot') + (extraClass ? ' ' + extraClass : '');
// Ссылки делаем кликабельными, но БЕЗ innerHTML: текст ответа приходит от модели,
// вставлять его как разметку нельзя. Собираем узлы руками — так подделать тег
// через текст ответа невозможно.
var parts = String(text).split(/(https?:\/\/[^\s<>"']+)/g);
parts.forEach(function (part, i) {
if (i % 2 === 1) {
var a = document.createElement('a');
a.href = part;
a.textContent = part;
a.target = '_blank';
a.rel = 'noopener noreferrer';
el.appendChild(a);
} else if (part !== '') {
el.appendChild(document.createTextNode(part));
}
});
body.appendChild(el);
body.scrollTop = body.scrollHeight;
return el;
}
function quickButtons() {
var box = document.createElement('div');
box.className = 'lch-quick';
quickList().forEach(function (q) {
var b = document.createElement('button');
b.type = 'button';
b.textContent = q;
b.onclick = function () {
box.remove();
// Значок в начале — украшение кнопки, боту он не нужен: шлём чистый вопрос.
send(q.replace(/^[^A-Za-zА-Яа-яЁё0-9]+/, ''));
};
box.appendChild(b);
});
body.appendChild(box);
}
function loadHistory() {
fetch('/api/chat/' + chatId + '/messages?after=0', { headers: { Accept: 'application/json' } })
.then(function (r) { return r.json(); })
.then(function (data) {
var msgs = (data && data.messages) || [];
msgs.forEach(function (m) {
bubble(m.text, m.direction === 'in');
lastId = m.id;
});
if (msgs.length === 0) {
bubble(greeting(), false);
quickButtons();
}
})
.catch(function () {
bubble(greeting(), false);
quickButtons();
});
}
function poll(after, typing, tries) {
if (tries > POLL_LIMIT) {
typing.remove();
bubble(FAILED, false);
busy = false;
return;
}
fetch('/api/chat/' + chatId + '/messages?after=' + after, { headers: { Accept: 'application/json' } })
.then(function (r) { return r.json(); })
.then(function (data) {
var msgs = (data && data.messages) || [];
if (msgs.length === 0) {
setTimeout(function () { poll(after, typing, tries + 1); }, POLL_MS);
return;
}
typing.remove();
msgs.forEach(function (m) {
bubble(m.text, m.direction === 'in');
lastId = m.id;
});
busy = false;
})
.catch(function () {
setTimeout(function () { poll(after, typing, tries + 1); }, POLL_MS);
});
}
function send(text) {
if (busy || !text.trim()) return;
busy = true;
bubble(text, true);
var typing = bubble('печатает…', false, 'lch-typing');
fetch('/api/chat/message', {
method: 'POST',
headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
body: JSON.stringify({ chat_id: chatId, text: text }),
})
.then(function (r) {
return r.json().then(function (data) { return { ok: r.ok, data: data }; });
})
.then(function (res) {
if (!res.ok) {
typing.remove();
bubble(res.data.message || FAILED, false);
busy = false;
return;
}
lastId = res.data.message_id;
poll(res.data.message_id, typing, 0);
})
.catch(function () {
typing.remove();
bubble(FAILED, false);
busy = false;
});
}
function toggle(open) {
panel.classList.toggle('lch-open', open);
btn.setAttribute('aria-expanded', open ? 'true' : 'false');
if (open && body.childElementCount === 0) loadHistory();
if (open) input.focus();
}
btn.onclick = function () { toggle(!panel.classList.contains('lch-open')); };
closeBtn.onclick = function () { toggle(false); };
document.addEventListener('keydown', function (e) {
if (e.key === 'Escape' && panel.classList.contains('lch-open')) {
toggle(false);
btn.focus();
}
});
form.onsubmit = function (e) {
e.preventDefault();
var text = input.value;
input.value = '';
send(text);
};
// Гостю на лендинге окошко открываем САМИ: он пришёл посмотреть, а не искать
// кнопку чата. Вошедшему в кабинете — не открываем: он работает, а не знакомится.
// Фокус в поле ввода при авто-открытии не ставим — иначе страница прыгнет к окошку.
if (AUTO_OPEN && isGuest()) {
setTimeout(function () {
if (!panel.classList.contains('lch-open')) {
panel.classList.add('lch-open');
btn.setAttribute('aria-expanded', 'true');
if (body.childElementCount === 0) loadHistory();
}
}, 900);
}
// Скрипт грузится один раз за вкладку. Если в этой же вкладке вошли под другим клиентом
// (или вышли), окошко обязано забыть прежний разговор — иначе чужие цифры останутся
// на экране, а сервер будет отвечать 404 на чужой ключ.
window.__liderraChatSetUser = function (user) {
var next = keyFor(user || null);
if (next === KEY) return;
KEY = next;
chatId = chatIdFor(KEY);
lastId = 0;
busy = false;
while (body.firstChild) body.removeChild(body.firstChild);
if (panel.classList.contains('lch-open')) loadHistory();
};
})();