ee701f8d3a
Окошко чата: уголок для растягивания и кнопка «развернуть», размер запоминается — длинный ответ в маленьком окне читать неудобно. Само открывается только там, где страница просит (лендинг): на странице входа оно распахивалось и закрывало кнопку «Войти». Демо-стенд (demo:showcase) — цены как на боевом: 55 ₽ за заявку, сетка 55→25 ₽, сбор конкурентов 50 ₽, сбор источников 10 ₽. Кошелёк теперь сходится: пополнено 12 400 ₽ − 25 заявок × 55 ₽ = 11 025 ₽, пополнения датированы раньше списаний. Поле конкурентов пересобирается начисто (опыты со склейкой ломали порядок карточек), отчёты чистятся (портал держит не больше трёх в работе и гасит кнопку). Журнал денег переписать нельзя — только дописать (защита от подделки), поэтому firstOrCreate. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
371 lines
18 KiB
JavaScript
371 lines
18 KiB
JavaScript
/**
|
||
* Окошко чата Лидерры. Один файл, без сборки: подключается и в портал (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();
|
||
};
|
||
})();
|