Шаблоны чата с ИИ-продажником
Три готовых интерфейса чата - как на нашем сайте, минимальный и тёмный - на чистых HTML, CSS и JavaScript, без библиотек. Скопируйте, перекрасьте переменными и подключите свой канал.
Как это работает
Шаблоны говорят с теми же маршрутами /api/widget/*, что и окно виджета, - подробности и все поля в описании клиентского Chat API. Ключа API нет и быть не должно: канал определяется открытым channel_id, посетитель - подписанным токеном сессии.
| Маршрут | Зачем |
|---|---|
POST /api/widget/init | настройки канала, согласие с офертой |
GET /api/widget/history | история разговора при открытии |
POST /api/widget/messages | отправить сообщение, получить ответ ИИ и новый токен |
GET /api/widget/events | входящие по потоку SSE; запасной путь - /api/widget/poll раз в 2,5 с |
Что учесть
- CORS есть только у
init: страница с чатом должна лежать на домене ячейки либо ходить через ваш прокси, пересылающий/api/widget/*. - Токен приходит в каждом ответе
messages: сохраняйте последний (шаблоны кладут его вlocalStorageпо ключу канала). - Если арендатор задал согласие с офертой, без него
messagesотвечает400; шаблоны показывают флажок и повторяют отправку. - Предел сообщения - 4000 знаков;
413,429и503шаблоны показывают как понятную строку состояния. - Текст ответов выводится через
textContent, неinnerHTML: чужой текст не становится разметкой. - Ответ ИИ списывается с кошелька арендатора - как и в обычном виджете.
- Расширения шаблона (в коде помечены «Расширение»):
stateиoperatorизinit, опроса и событияconversation:updateменяют шапку иdata-stateна корне (ИИ, очередь, оператор); вложения и карточки сообщений выводятся ссылками и блоком.ch-card. Отправка файлов посетителем в шаблоне не реализована - см. клиентский Chat API.
Шаблон «Как на formula-cream.pro»
Перенос окна чата нашего сайта на чистые HTML, CSS и JS: тёмное стеклянное окно с градиентом, шапка с аватаром и строкой «Онлайн», пузыри со временем, индикатор набора, карточка товара прямо в ленте, карточка «оставьте контакты», круглое поле ввода с самолётиком и плавающая кнопка с пульсом и счётчиком непрочитанных. Размеры, цвета, тени и анимации взяты из исходного компонента; вместо Tailwind - переменные --sc-*.
Статический предпросмотр: реплики и карточка вымышлены, поле выключено, анимации отключены. Окно построено кодом из вкладки «JS: элементы».
Показывается, когда сообщение передано оператору, либо по событию страницы.
Откуда берутся карточка и форма контактов
- Реплики, история и токен - клиентский Chat API
/api/widget/*(см. как это работает). Он отдаёт обычный текст: карточек и действий в ответе нет. - На нашем сайте карточки и форму присылает внутренний маршрут чата сайта; публичного аналога нет. В шаблоне карточка берётся из вашей витрины
CATALOGпо метке[CARD:код]в тексте ответа (если инструкция канала просит ИИ её ставить) или из событияsc:cardс объектом{ name, text, price, badge, image, url }. - Форма контактов появляется, когда ответ сообщает о передаче оператору, либо по событию
sc:contact-form. Данные уходят в событиеsc:contactи, если заданdata-contact-url, POST-запросом на ваш адрес - у Chat API маршрута для контактов нет. - Открыть чат снаружи:
window.dispatchEvent(new CustomEvent('sc:open', { detail: { prefill: 'Текст' } })).
HTML
<div class="sc" data-sc-root data-channel="CHANNEL_ID" data-api="" data-contact-url="" data-privacy-url="/privacy">
<div class="sc-panel" data-sc="panel" hidden role="complementary" aria-label="Чат с консультантом">
<div class="sc-box" data-nosnippet>
<div class="sc-bg"></div>
<header class="sc-head">
<div class="sc-who">
<span class="sc-avatar"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M12 8V4H8"/><rect width="16" height="12" x="4" y="8" rx="2"/><path d="M2 14h2"/><path d="M20 14h2"/><path d="M15 13v2"/><path d="M9 13v2"/></svg><span class="sc-dot"></span></span>
<div>
<div class="sc-name">Консультант</div>
<div class="sc-status"><span class="sc-pulse"></span>Онлайн • Формула крема</div>
</div>
</div>
<button class="sc-close" type="button" data-sc="close" aria-label="Закрыть чат"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M18 6 6 18"/><path d="m6 6 12 12"/></svg></button>
</header>
<div class="sc-log" data-sc="log" role="log" aria-live="polite"><!-- Сообщения, карточки и форму добавляет скрипт --></div>
<label class="sc-consent" data-sc="consent" hidden><input type="checkbox" data-sc="consent-box"><span data-sc="consent-text"></span></label><!-- Согласие показывает скрипт, если оно включено в канале -->
<div class="sc-note" data-sc="note" role="status"></div>
<form data-sc="form" class="sc-form">
<input class="sc-input" data-sc="input" maxlength="4000" autocomplete="off" required type="text" placeholder="Напишите сообщение..." aria-label="Напишите сообщение...">
<button class="sc-send" data-sc="send" type="submit" aria-label="Отправить"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" class="sc-plane"><path d="M14.536 21.686a.5.5 0 0 0 .937-.024l6.5-19a.496.496 0 0 0-.635-.635l-19 6.5a.5.5 0 0 0-.024.937l7.93 3.18a2 2 0 0 1 1.112 1.11z"/><path d="m21.854 2.147-10.94 10.939"/></svg><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" class="sc-spin"><path d="M21 12a9 9 0 1 1-6.219-8.56"/></svg></button>
</form>
</div>
</div>
<button class="sc-fab" data-sc="fab" type="button" aria-expanded="false" aria-label="Открыть чат">
<span class="sc-ping" aria-hidden="true"></span><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" class="sc-chat"><path d="M7.9 20A9 9 0 1 0 4 16.1L2 22Z"/></svg><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" class="sc-x"><path d="M18 6 6 18"/><path d="m6 6 12 12"/></svg>
<span class="sc-badge" data-sc="badge" hidden></span>
</button>
</div>CSS
.sc {
--sc-from: #1a3c2a; --sc-via: #0e1621; --sc-to: #1a2a3c; --sc-bg: #0e1621;
--sc-accent: #10b981; --sc-accent-strong: #047857; --sc-online: #34d399;
--sc-user: rgba(5, 150, 105, 0.4); --sc-ai: rgba(255, 255, 255, 0.1);
--sc-fab-from: #2d6a4f; --sc-fab-to: #1a8a5e; --sc-radius: 16px; --sc-send-radius: 12px;
--sc-placeholder: rgba(255, 255, 255, 0.55);
--sc-edge: 16px; --sc-gap: 10px; --sc-fab: 56px; --sc-bottom: calc(16px + env(safe-area-inset-bottom, 0px));
--sc-font: system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
font: 14px/1.5 var(--sc-font); color: #fff;
}
@media (min-width: 640px) { .sc { --sc-edge: 24px; --sc-gap: 12px; } }
.sc *, .sc *::before, .sc *::after { box-sizing: border-box; }
.sc h3, .sc p { margin: 0; }
.sc svg { display: block; flex: none; }
.sc-ico { display: flex; flex: none; }
.sc button, .sc input { font: inherit; }
.sc :focus-visible { outline: 2px solid var(--sc-online); outline-offset: 2px; }
.sc [hidden] { display: none !important; }
/* Окно: справа над кнопкой; на телефоне почти во всю ширину, высота не больше свободного места */
.sc-panel {
position: fixed; z-index: 9999; right: var(--sc-edge); left: var(--sc-edge); bottom: calc(var(--sc-bottom) + var(--sc-fab) + var(--sc-gap));
height: 520px; max-height: calc(100vh - var(--sc-bottom) - var(--sc-fab) - var(--sc-gap) - 12px);
border-radius: var(--sc-radius); overflow: hidden; box-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.25);
animation: sc-pop 0.3s cubic-bezier(0.16, 1, 0.3, 1);
}
@supports (height: 100dvh) { .sc-panel { max-height: calc(100dvh - var(--sc-bottom) - var(--sc-fab) - var(--sc-gap) - 12px); } }
@media (min-width: 640px) { .sc-panel { left: auto; width: 380px; } }
.sc-box { position: relative; display: flex; flex-direction: column; width: 100%; height: 100%; overflow: hidden; border: 1px solid rgba(255, 255, 255, 0.1); border-radius: var(--sc-radius); background: var(--sc-bg); }
@supports ((-webkit-backdrop-filter: blur(1px)) or (backdrop-filter: blur(1px))) {
.sc-box { background: rgba(14, 22, 33, 0.95); -webkit-backdrop-filter: blur(24px); backdrop-filter: blur(24px); }
}
.sc-bg { position: absolute; top: 0; right: 0; bottom: 0; left: 0; background: linear-gradient(to bottom right, var(--sc-from), var(--sc-via), var(--sc-to)); }
.sc-head, .sc-log, .sc-consent, .sc-note, .sc-form { position: relative; z-index: 1; }
/* Шапка: аватар с точкой «онлайн», название, строка статуса с пульсом, кнопка 44x44 */
.sc-head { display: flex; align-items: center; justify-content: space-between; padding: 12px 16px; border-bottom: 1px solid rgba(255, 255, 255, 0.1); background: rgba(0, 0, 0, 0.2); }
.sc-who { display: flex; align-items: center; gap: 12px; min-width: 0; }
.sc-avatar { position: relative; flex: none; width: 40px; height: 40px; border-radius: 50%; display: flex; align-items: center; justify-content: center; color: #fff; background: linear-gradient(to bottom right, #10b981, #0d9488); box-shadow: 0 10px 15px -3px rgba(0, 0, 0, 0.1), 0 4px 6px -4px rgba(0, 0, 0, 0.1); }
.sc-avatar svg { width: 20px; height: 20px; }
.sc-dot { position: absolute; right: 0; bottom: 0; width: 12px; height: 12px; border-radius: 50%; background: var(--sc-online); border: 2px solid var(--sc-bg); }
.sc-name { font-size: 14px; font-weight: 600; line-height: 1.25; letter-spacing: 0; color: #fff; }
.sc-status { display: flex; align-items: center; gap: 4px; font-size: 11px; line-height: 1.45; color: rgba(52, 211, 153, 0.9); }
.sc-pulse { width: 6px; height: 6px; border-radius: 50%; background: var(--sc-online); animation: sc-pulse 2s cubic-bezier(0.4, 0, 0.6, 1) infinite; }
.sc-close { flex: none; display: flex; align-items: center; justify-content: center; min-width: 44px; min-height: 44px; padding: 10px; border: 0; border-radius: 8px; background: transparent; color: rgba(255, 255, 255, 0.8); cursor: pointer; transition: background-color 0.15s; }
.sc-close:hover { background: rgba(255, 255, 255, 0.1); }
.sc-close svg { width: 20px; height: 20px; }
/* Лента: пузыри с временем, карточка товара, индикатор набора, форма контактов */
.sc-log { flex: 1; min-height: 0; display: flex; flex-direction: column; gap: 12px; padding: 12px 16px; overflow-y: auto; font-size: 14px; -webkit-overflow-scrolling: touch; }
.sc-log > * { flex-shrink: 0; }
.sc-msg { max-width: 85%; padding: 10px 12px; border-radius: 12px; align-self: flex-start; color: rgba(255, 255, 255, 0.9); background: var(--sc-ai); box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -2px rgba(0, 0, 0, 0.1); animation: sc-in 0.3s ease-out; }
.sc-msg[data-sender="user"] { align-self: flex-end; margin-left: auto; color: #fff; background: var(--sc-user); }
.sc-msg[data-sender="operator"] { color: #fff; background: rgba(14, 165, 233, 0.2); border: 1px solid rgba(125, 211, 252, 0.3); }
.sc-msg[data-sender="system"] { align-self: center; max-width: 100%; text-align: center; font-size: 12px; font-style: italic; color: #fef08a; background: rgba(234, 179, 8, 0.2); }
.sc-op { margin-bottom: 4px; font-size: 10px; font-weight: 500; color: rgba(186, 230, 253, 0.8); }
.sc-text { font-size: 13px; line-height: 1.625; white-space: pre-wrap; overflow-wrap: anywhere; }
.sc-time { margin-top: 4px; font-size: 9px; text-align: right; color: rgba(255, 255, 255, 0.45); }
.sc-typing { align-self: flex-start; display: flex; align-items: center; gap: 6px; width: fit-content; padding: 8px 12px; border-radius: 12px; background: var(--sc-ai); }
.sc-typing i { width: 8px; height: 8px; border-radius: 50%; background: var(--sc-online); animation: sc-pulse 2s cubic-bezier(0.4, 0, 0.6, 1) infinite; }
.sc-typing i:nth-child(2) { animation-delay: 0.2s; }
.sc-typing i:nth-child(3) { animation-delay: 0.4s; }
.sc-card { align-self: flex-start; display: block; width: 240px; max-width: 100%; overflow: hidden; border: 1px solid #e5e7eb; border-radius: 12px; background: #fff; color: #111827; text-align: left; text-decoration: none; box-shadow: 0 1px 2px rgba(0, 0, 0, 0.05); transition: box-shadow 0.15s; animation: sc-in 0.3s ease-out; }
.sc-card:hover { box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -2px rgba(0, 0, 0, 0.1); }
.sc-card-img { display: block; width: 100%; height: 112px; object-fit: cover; }
.sc-card-body { padding: 12px; }
.sc-card-top { display: flex; align-items: flex-start; justify-content: space-between; gap: 8px; }
.sc-card-name { font-size: 13px; font-weight: 600; line-height: 1.375; }
.sc-card-badge { flex: none; padding: 2px 6px; border-radius: 4px; font-size: 10px; font-weight: 500; color: #047857; background: #d1fae5; }
.sc-card-text { margin-top: 4px; font-size: 11px; line-height: 1.45; color: #6b7280; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; }
.sc-card-row { display: flex; align-items: center; justify-content: space-between; gap: 8px; margin-top: 8px; }
.sc-card-price { font-size: 12px; font-weight: 600; color: #047857; }
.sc-card-btn { display: inline-flex; align-items: center; gap: 4px; padding: 4px 10px; border-radius: 8px; font-size: 11px; font-weight: 500; color: #fff; background: var(--sc-accent-strong); }
.sc-card-btn svg { width: 12px; height: 12px; }
.sc-lead { padding: 16px; border: 1px solid rgba(255, 255, 255, 0.1); border-radius: 12px; background: var(--sc-ai); animation: sc-in 0.3s ease-out; }
@supports ((-webkit-backdrop-filter: blur(1px)) or (backdrop-filter: blur(1px))) { .sc-lead, .sc-msg { -webkit-backdrop-filter: blur(8px); backdrop-filter: blur(8px); } }
.sc-lead-title { margin-bottom: 12px !important; font-size: 14px; font-weight: 500; color: #fff; }
.sc-field { display: flex; align-items: center; gap: 8px; margin-bottom: 8px; padding: 8px 12px; border-radius: 8px; background: rgba(0, 0, 0, 0.3); }
.sc-field svg { width: 16px; height: 16px; color: rgba(255, 255, 255, 0.5); }
.sc-field input { flex: 1; min-width: 0; padding: 0; border: 0; background: transparent; color: #fff; font-size: 14px; }
.sc-field input::placeholder, .sc-input::placeholder { color: var(--sc-placeholder); opacity: 1; }
.sc-field:focus-within { outline: 2px solid var(--sc-online); outline-offset: 2px; }
.sc-field input:focus-visible { outline: none; }
.sc-check, .sc-consent { display: flex; align-items: flex-start; gap: 8px; margin-bottom: 8px; font-size: 11px; line-height: 1.25; color: rgba(255, 255, 255, 0.6); }
.sc-check input, .sc-consent input { margin: 2px 0 0; accent-color: var(--sc-accent); }
.sc-check a, .sc-consent a { color: rgba(255, 255, 255, 0.8); text-decoration: underline; }
.sc-error { margin: 0 0 8px !important; padding: 8px 12px; border: 1px solid rgba(244, 63, 94, 0.2); border-radius: 8px; font-size: 11px; line-height: 1.35; color: #fda4af; background: rgba(244, 63, 94, 0.1); }
.sc-submit { display: flex; align-items: center; justify-content: center; gap: 8px; width: 100%; padding: 8px; border: 0; border-radius: 8px; font-size: 14px; font-weight: 500; color: #fff; background: var(--sc-accent-strong); cursor: pointer; transition: background-color 0.15s; }
.sc-submit:hover:not(:disabled) { background: var(--sc-accent); }
.sc-submit:disabled { opacity: 0.4; cursor: default; }
.sc-submit svg { width: 16px; height: 16px; }
/* Строка состояния, согласие и поле ввода с круглой кнопкой-самолётиком */
.sc-consent { margin: 0; padding: 0 16px 8px; }
.sc-note { min-height: 1.3em; margin: 0; padding: 0 16px; font-size: 12px; color: #fef08a; }
.sc-form { display: flex; align-items: center; gap: 8px; padding: 12px; border-top: 1px solid rgba(255, 255, 255, 0.1); }
.sc-input { flex: 1; min-width: 0; padding: 10px 12px; border: 1px solid rgba(255, 255, 255, 0.1); border-radius: 12px; font-size: 14px; color: #fff; background: rgba(0, 0, 0, 0.4); }
.sc-send { flex: none; display: flex; align-items: center; justify-content: center; width: 40px; height: 40px; border: 0; border-radius: var(--sc-send-radius); color: #fff; background: rgba(5, 150, 105, 0.8); cursor: pointer; transition: background-color 0.15s; }
.sc-send:hover:not(:disabled) { background: var(--sc-accent); }
.sc-send:disabled { opacity: 0.3; cursor: default; }
.sc-send svg { width: 16px; height: 16px; }
.sc .sc-spin { display: none; animation: sc-spin 1s linear infinite; }
.sc-send[data-busy] .sc-spin { display: block; }
.sc-send[data-busy] .sc-plane { display: none; }
/* Плавающая кнопка: пульс-кольцо, счётчик непрочитанных; открытая - крестик */
.sc-fab { position: fixed; z-index: 9998; right: var(--sc-edge); bottom: var(--sc-bottom); width: var(--sc-fab); height: var(--sc-fab); display: flex; align-items: center; justify-content: center; padding: 0; border: 0; border-radius: 50%; color: #fff; cursor: pointer; background: linear-gradient(135deg, var(--sc-fab-from), var(--sc-fab-to)); box-shadow: 0 10px 15px -3px rgba(0, 0, 0, 0.1), 0 4px 6px -4px rgba(0, 0, 0, 0.1); transition: transform 0.15s; }
.sc-fab:hover { transform: scale(1.08); }
.sc-fab:active { transform: scale(0.95); }
.sc-fab svg { width: 24px; height: 24px; }
.sc-fab .sc-x, .sc[data-open] .sc-fab .sc-chat, .sc[data-open] .sc-ping { display: none; }
.sc[data-open] .sc-fab .sc-x { display: block; }
.sc-ping { position: absolute; top: 0; right: 0; bottom: 0; left: 0; border-radius: 50%; background: rgba(16, 185, 129, 0.3); pointer-events: none; animation: sc-ping 3s cubic-bezier(0, 0, 0.2, 1) infinite; }
.sc-badge { position: absolute; top: -4px; right: -4px; display: flex; align-items: center; justify-content: center; width: 20px; height: 20px; border-radius: 50%; font-size: 10px; font-weight: 700; color: #fff; background: #ef4444; animation: sc-pulse 2s cubic-bezier(0.4, 0, 0.6, 1) infinite; }
@keyframes sc-pop { from { opacity: 0; transform: translateY(20px) scale(0.95); } to { opacity: 1; transform: none; } }
@keyframes sc-in { from { opacity: 0; transform: translateY(10px); } to { opacity: 1; transform: none; } }
@keyframes sc-pulse { 50% { opacity: 0.5; } }
@keyframes sc-ping { 75%, 100% { transform: scale(2); opacity: 0; } }
@keyframes sc-spin { to { transform: rotate(360deg); } }
@media (prefers-reduced-motion: reduce) {
.sc *, .sc *::before, .sc *::after { animation: none !important; transition: none !important; }
.sc-ping { display: none; }
}JS: элементы
Строит пузыри, карточки и форму. Подключите этот скрипт первым.
var SC_TEXT = {
details: 'Подробнее', typing: 'Консультант печатает', operator: 'Оператор', contactTitle: 'Оставьте контакты, и менеджер свяжется с вами',
name: 'Ваше имя', phone: 'Телефон', email: 'Email', consent: 'Согласен на обработку персональных данных.', privacy: 'Политика конфиденциальности',
news: 'Хочу получать новости и полезные материалы.', submit: 'Отправить'
};
// Значки - контурные, 24 x 24 (набор Lucide)
var SC_ICONS = {
user: '<path d="M19 21v-2a4 4 0 0 0-4-4H9a4 4 0 0 0-4 4v2"/><circle cx="12" cy="7" r="4"/>',
phone: '<path d="M22 16.92v3a2 2 0 0 1-2.18 2 19.79 19.79 0 0 1-8.63-3.07 19.5 19.5 0 0 1-6-6 19.79 19.79 0 0 1-3.07-8.67A2 2 0 0 1 4.11 2h3a2 2 0 0 1 2 1.72 12.84 12.84 0 0 0 .7 2.81 2 2 0 0 1-.45 2.11L8.09 9.91a16 16 0 0 0 6 6l1.27-1.27a2 2 0 0 1 2.11-.45 12.84 12.84 0 0 0 2.81.7A2 2 0 0 1 22 16.92z"/>',
mail: '<rect width="20" height="16" x="2" y="4" rx="2"/><path d="m22 7-8.97 5.7a1.94 1.94 0 0 1-2.06 0L2 7"/>',
arrow: '<path d="M5 12h14"/><path d="m12 5 7 7-7 7"/>'
};
function scEl(tag, cls, text) {
var n = document.createElement(tag);
if (cls) n.className = cls;
if (text !== undefined) n.textContent = text; // textContent, не innerHTML: текст чужой
return n;
}
function scIcon(name) {
var s = scEl('span', 'sc-ico');
s.innerHTML = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">' + SC_ICONS[name] + '</svg>';
return s;
}
function scSafeUrl(u, image) { // Только свои пути, http(s) и картинки data:image - чужая схема в адрес не попадёт
if (typeof u !== 'string' || !u) return '';
if (u.charAt(0) === '/' && u.charAt(1) !== '/') return u;
if (image && u.indexOf('data:image/') === 0) return u;
try { var p = new URL(u).protocol; return p === 'https:' || p === 'http:' ? u : ''; } catch (e) { return ''; }
}
/* Пузырь: sender - user | ai | operator | system; time - готовая строка */
function scBubble(sender, text, time) {
var m = scEl('div', 'sc-msg');
m.setAttribute('data-sender', sender);
if (sender === 'operator') m.appendChild(scEl('div', 'sc-op', SC_TEXT.operator));
m.appendChild(scEl('div', 'sc-text', text));
if (time) m.appendChild(scEl('div', 'sc-time', time));
return m;
}
function scTyping() {
var t = scEl('div', 'sc-typing');
t.setAttribute('role', 'status');
t.setAttribute('aria-label', SC_TEXT.typing);
for (var i = 0; i < 3; i++) t.appendChild(scEl('i'));
return t;
}
/* Карточка товара: { name, text, price, badge, image, url } */
function scProductCard(card) {
var a = scEl('a', 'sc-card');
var href = scSafeUrl(card.url);
if (href) { a.setAttribute('href', href); a.setAttribute('target', '_blank'); a.setAttribute('rel', 'noopener noreferrer'); }
var src = scSafeUrl(card.image, true);
if (src) {
var img = scEl('img', 'sc-card-img');
img.setAttribute('src', src); img.setAttribute('alt', card.name); img.setAttribute('loading', 'lazy');
a.appendChild(img);
}
var body = scEl('div', 'sc-card-body');
var top = scEl('div', 'sc-card-top');
top.appendChild(scEl('span', 'sc-card-name', card.name));
if (card.badge) top.appendChild(scEl('span', 'sc-card-badge', card.badge));
body.appendChild(top);
if (card.text) body.appendChild(scEl('div', 'sc-card-text', card.text));
var row = scEl('div', 'sc-card-row');
row.appendChild(scEl('span', 'sc-card-price', card.price || ''));
var btn = scEl('span', 'sc-card-btn');
btn.appendChild(scEl('span', '', SC_TEXT.details));
btn.appendChild(scIcon('arrow'));
row.appendChild(btn);
body.appendChild(row);
a.appendChild(body);
return a;
}
/* Карточка контактов: имя, телефон, почта, два согласия, кнопка; привязка - data-sc */
function scField(icon, type, name, placeholder, autocomplete) {
var w = scEl('label', 'sc-field');
w.appendChild(scIcon(icon));
var i = scEl('input');
i.setAttribute('type', type); i.setAttribute('name', name); i.setAttribute('autocomplete', autocomplete);
i.setAttribute('placeholder', placeholder); i.setAttribute('aria-label', placeholder);
w.appendChild(i);
return w;
}
function scCheck(name, text, linkText, linkHref) {
var l = scEl('label', 'sc-check');
var box = scEl('input');
box.setAttribute('type', 'checkbox'); box.setAttribute('name', name);
l.appendChild(box);
var s = scEl('span');
s.appendChild(scEl('span', '', text + (linkText ? ' ' : '')));
if (linkText) {
var a = scEl('a', '', linkText);
if (linkHref) { a.setAttribute('href', linkHref); a.setAttribute('target', '_blank'); a.setAttribute('rel', 'noopener'); }
s.appendChild(a);
}
l.appendChild(s);
return l;
}
function scContactCard(privacyUrl) {
var c = scEl('div', 'sc-lead');
c.setAttribute('role', 'group'); c.setAttribute('aria-label', SC_TEXT.contactTitle); c.setAttribute('data-sc', 'lead');
c.appendChild(scEl('div', 'sc-lead-title', SC_TEXT.contactTitle));
c.appendChild(scField('user', 'text', 'name', SC_TEXT.name + ' *', 'name'));
c.appendChild(scField('phone', 'tel', 'phone', SC_TEXT.phone + ' *', 'tel'));
c.appendChild(scField('mail', 'email', 'email', SC_TEXT.email, 'email'));
c.appendChild(scCheck('consent', SC_TEXT.consent, SC_TEXT.privacy, privacyUrl));
c.appendChild(scCheck('news', SC_TEXT.news));
var err = scEl('div', 'sc-error');
err.setAttribute('role', 'alert'); err.setAttribute('data-sc', 'lead-error'); err.setAttribute('hidden', '');
c.appendChild(err);
var go = scEl('button', 'sc-submit');
go.setAttribute('type', 'button'); go.setAttribute('data-sc', 'lead-submit'); go.setAttribute('disabled', '');
go.appendChild(scEl('span', '', SC_TEXT.submit));
go.appendChild(scIcon('arrow'));
c.appendChild(go);
return c;
}JS: подключение
Работает с Chat API и событиями страницы; подключается вторым.
(function () {
var root = document.querySelector('[data-sc-root]');
if (!root) return;
var API = root.getAttribute('data-api') || ''; // Адрес ячейки (схема и домен). Пусто - страница лежит на домене самой ячейки.
var CHANNEL = root.getAttribute('data-channel');
var CONTACT_URL = root.getAttribute('data-contact-url') || '';
var PRIVACY_URL = root.getAttribute('data-privacy-url') || '';
var KEY = 'chat_token:' + CHANNEL;
var TEXT = {
unavailable: 'Чат недоступен', needConsent: 'Отметьте согласие, чтобы отправить сообщение', consentRetry: 'Отметьте согласие и отправьте сообщение ещё раз', tooLong: 'Сообщение слишком длинное',
tooMany: 'Слишком часто, подождите минуту', failed: 'Не удалось отправить', queued: 'Передано оператору, он ответит позже', open: 'Открыть чат', close: 'Закрыть чат',
leadSent: 'Спасибо! Контакты переданы, мы свяжемся с вами.', leadFail: 'Не удалось отправить, попробуйте ещё раз'
};
// Своя витрина: код -> карточка. Данные вымышлены, ссылку и картинку подставьте свои.
// Публичный Chat API карточек не отдаёт (ответ - обычный текст). На нашем сайте их присылает
// внутренний маршрут чата; здесь карточка берётся из CATALOG по метке [CARD:код] в тексте ответа
// (если инструкция вашего канала просит ИИ её ставить) либо из события sc:card.
var CATALOG = {
basic: { name: 'Базовый курс', text: 'Четыре недели практики: от первой эмульсии до своей рецептуры.', price: '12 900 ₽', badge: 'Хит', image: '', url: '/courses/basic' }
};
function q(name) { return root.querySelector('[data-sc="' + name + '"]'); }
var panel = q('panel'), fab = q('fab'), badge = q('badge'), log = q('log'), form = q('form'), input = q('input');
var sendBtn = q('send'), note = q('note'), closeBtn = q('close');
var consentWrap = q('consent'), consentBox = q('consent-box'), consentText = q('consent-text');
var shown = {}, token = '', consent = null, accepted = '', stream = null, timer = null, lastAt = '';
var unread = 0, busy = false, typing = null, lead = null;
try { token = localStorage.getItem(KEY) || ''; } catch (e) { /* storage blocked */ }
function time() { return new Date().toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }); }
function isOpen() { return !panel.hidden; }
function toBottom() { log.scrollTop = log.scrollHeight; }
function setNote(text) { note.textContent = text || ''; }
function paintBadge() { badge.hidden = unread < 1; badge.textContent = String(unread); }
function updateSend() { sendBtn.disabled = busy || !input.value.trim(); }
function setOpen(on) {
panel.hidden = !on;
if (on) root.setAttribute('data-open', ''); else root.removeAttribute('data-open');
fab.setAttribute('aria-expanded', on ? 'true' : 'false');
fab.setAttribute('aria-label', on ? TEXT.close : TEXT.open);
if (on) { unread = 0; paintBadge(); toBottom(); setTimeout(function () { input.focus(); }, 300); }
}
function push(node, fromThem) {
log.appendChild(node);
toBottom();
if (fromThem && !isOpen()) { unread++; paintBadge(); }
}
function addCard(card) { push(scProductCard(card), true); }
function addMessage(sender, content, id) {
if (id) { if (shown[id]) return; shown[id] = true; }
var last = log.lastElementChild; // Ответ ИИ может прийти и в ответе на отправку, и по потоку (без общего id): сравниваем с последним.
if (sender !== 'user' && last && last.getAttribute('data-sender') === sender && last.textContent.indexOf(content) === 0) return;
var codes = [];
var text = String(content).replace(/\[CARD:([a-z0-9_-]+)\]/gi, function (m, code) { codes.push(code.toLowerCase()); return ''; }).trim();
if (text) push(scBubble(sender, text, time()), sender !== 'user');
codes.forEach(function (code) { if (CATALOG[code]) addCard(CATALOG[code]); });
}
function setTyping(on) {
if (on && !typing) { typing = scTyping(); log.appendChild(typing); toBottom(); }
if (!on && typing) { log.removeChild(typing); typing = null; }
}
function showLead() {
if (lead) return;
lead = scContactCard(PRIVACY_URL);
push(lead, true);
var name = lead.querySelector('[name="name"]'), phone = lead.querySelector('[name="phone"]'), email = lead.querySelector('[name="email"]');
var ok = lead.querySelector('[name="consent"]'), news = lead.querySelector('[name="news"]');
var err = lead.querySelector('[data-sc="lead-error"]'), go = lead.querySelector('[data-sc="lead-submit"]');
function ready() { go.disabled = !(name.value.trim() && phone.value.trim() && ok.checked); }
[name, phone, ok].forEach(function (el) { el.addEventListener('input', ready); el.addEventListener('change', ready); });
go.addEventListener('click', async function () {
var data = { name: name.value.trim(), phone: phone.value.trim(), email: email.value.trim(), newsletter: news.checked, channel_id: CHANNEL, page_url: location.href };
go.disabled = true; err.hidden = true;
try {
window.dispatchEvent(new CustomEvent('sc:contact', { detail: data }));
if (CONTACT_URL) {
var r = await fetch(CONTACT_URL, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(data) });
if (!r.ok) throw new Error('contact');
}
log.removeChild(lead); lead = null;
addMessage('system', TEXT.leadSent);
} catch (e) { err.textContent = TEXT.leadFail; err.hidden = false; ready(); }
});
}
async function api(path, options) {
var res = await fetch(API + path, options);
var body = null;
try { body = await res.json(); } catch (e) { /* not JSON */ }
return { ok: res.ok, status: res.status, body: body };
}
function post(path, payload) {
return api(path, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload) });
}
function showConsent(c) {
consent = c;
consentText.textContent = c.text + ' ';
if (c.url) {
var a = document.createElement('a');
a.href = c.url; a.target = '_blank'; a.rel = 'noopener'; a.textContent = c.url;
consentText.appendChild(a);
}
if (accepted !== c.version) consentBox.checked = false;
consentWrap.hidden = false;
}
async function init() {
var r = await post('/api/widget/init', { channel_id: CHANNEL });
if (!r.ok) { setNote(TEXT.unavailable); sendBtn.disabled = true; return; }
if (r.body.consent) showConsent(r.body.consent);
var restored = false;
if (token) {
var h = await api('/api/widget/history?st=' + encodeURIComponent(token));
if (h.ok && h.body && h.body.messages && h.body.messages.length) {
h.body.messages.forEach(function (m) { addMessage(m.sender, m.content, m.id); lastAt = m.created_at; });
restored = true;
} else if (h.status === 401 || h.status === 404) {
token = '';
try { localStorage.removeItem(KEY); } catch (e) { /* ignore */ }
}
}
if (!restored && r.body.greeting) addMessage('ai', r.body.greeting); // closed window: the counter shows 1
listen();
}
function listen() { // Входящие: SSE с запасным опросом раз в 2,5 секунды
if (!token || stream || timer) return;
if (typeof EventSource === 'undefined') { poll(); return; }
stream = new EventSource(API + '/api/widget/events?st=' + encodeURIComponent(token));
stream.addEventListener('message', function (e) {
var m = JSON.parse(e.data);
setTyping(false); addMessage(m.sender, m.content, m.id); lastAt = m.created_at || lastAt;
});
stream.addEventListener('closed', stopStream);
stream.addEventListener('error', function () { stopStream(); poll(); });
}
function stopStream() { if (stream) { stream.close(); stream = null; } }
async function poll() {
if (!token) return;
var r = await api('/api/widget/poll?st=' + encodeURIComponent(token) + (lastAt ? '&after=' + encodeURIComponent(lastAt) : ''));
if (r.ok && r.body && r.body.new_messages) {
r.body.new_messages.forEach(function (m) { addMessage(m.sender, m.content, m.id); lastAt = m.created_at || lastAt; });
}
timer = setTimeout(poll, 2500);
}
async function send(text) {
var payload = { channel_id: CHANNEL, content: text, session_token: token || undefined, page_url: location.href, page_title: document.title };
if (consent && consentBox.checked) payload.consent_version = consent.version;
var r = await post('/api/widget/messages', payload);
if (r.status === 400 && r.body && r.body.error === 'consent_required') { showConsent(r.body.consent); throw new Error(TEXT.consentRetry); }
if (r.status === 413) throw new Error(TEXT.tooLong);
if (r.status === 429) throw new Error(TEXT.tooMany);
if (!r.ok) throw new Error(TEXT.failed);
if (consent && consentBox.checked) accepted = consent.version;
token = r.body.session_token;
try { localStorage.setItem(KEY, token); } catch (e) { /* ignore */ }
if (r.body.queued) { addMessage('system', TEXT.queued); showLead(); }
else if (r.body.response) addMessage('ai', r.body.response);
listen();
}
form.addEventListener('submit', async function (e) {
e.preventDefault();
var text = input.value.trim();
if (!text || busy) return;
if (consent && !consentBox.checked) { setNote(TEXT.needConsent); return; }
busy = true; sendBtn.setAttribute('data-busy', ''); updateSend(); setNote('');
addMessage('user', text); input.value = ''; setTyping(true);
try { await send(text); }
catch (err) { setNote(err.message); input.value = text; }
finally { setTyping(false); busy = false; sendBtn.removeAttribute('data-busy'); updateSend(); input.focus(); }
});
input.addEventListener('input', updateSend);
fab.addEventListener('click', function () { setOpen(!isOpen()); });
closeBtn.addEventListener('click', function () { setOpen(false); fab.focus(); });
root.addEventListener('keydown', function (e) { if (e.key === 'Escape' && isOpen()) { setOpen(false); fab.focus(); } });
// События страницы: sc:open ({ prefill }), sc:card (карточка), sc:contact-form (показать форму контактов).
// Форма контактов уходит в событие sc:contact и, если задан data-contact-url, POST-запросом на ВАШ адрес
// (публичного маршрута для контактов у Chat API нет).
window.addEventListener('sc:open', function (e) {
setOpen(true);
var p = e.detail && e.detail.prefill;
if (typeof p === 'string' && p && !input.value.trim()) { input.value = p; updateSend(); }
});
window.addEventListener('sc:card', function (e) { if (e.detail) addCard(e.detail); });
window.addEventListener('sc:contact-form', showLead);
updateSend();
init().catch(function () { setNote(TEXT.unavailable); sendBtn.disabled = true; });
})();Что перекрасить
| Переменная | По умолчанию | Что меняет |
|---|---|---|
--sc-from / --sc-via / --sc-to | #1a3c2a / #0e1621 / #1a2a3c | Градиент фона окна (по диагонали) |
--sc-bg | #0e1621 | Плотный фон там, где нет размытия, и рамка точки «онлайн» |
--sc-accent / --sc-accent-strong | #10b981 / #047857 | Кнопки, флажки и их наведение; цвет карточки |
--sc-online | #34d399 | Точка и строка «Онлайн», индикатор набора, фокус |
--sc-user / --sc-ai | rgba(5,150,105,.4) / rgba(255,255,255,.1) | Фон пузырей посетителя и ответов ИИ |
--sc-fab-from / --sc-fab-to | #2d6a4f / #1a8a5e | Градиент плавающей кнопки |
--sc-radius / --sc-send-radius | 16px / 12px | Скругление окна и кнопки отправки (50% - круглая) |
--sc-placeholder | rgba(255,255,255,.55) | Цвет подсказок в полях (не бледнее 4,5:1) |
--sc-edge / --sc-bottom | 24px / 16px | Отступ окна и кнопки от края экрана |
Как и на нашем сайте, на телефоне окно почти во всю ширину (по 16 px от краёв), его высота не выходит за экран; при prefers-reduced-motion пульсы и появление отключены. Отличия от оригинала намеренные: подсказки в полях светлее (контраст), кнопки зелёные темнее, чтобы белый текст читался.
Как это выглядит у нас
Снимки рабочего чата сайта школы; сообщения не отправлялись.




Что перекрасить в минимальном и тёмном
Вид этих двух шаблонов задают переменные на корне .ch; остальной CSS их только читает. Меняйте значения, структуру трогать не нужно.
| Переменная | По умолчанию | Что меняет |
|---|---|---|
--ch-accent | #2d6a4f | Кнопка отправки, сообщения посетителя, аватар, фокус |
--ch-accent-fg | #ffffff | Текст на акценте (держите контраст не ниже 4,5:1) |
--ch-bg / --ch-fg | #ffffff / #1f2d26 | Фон и основной текст окна |
--ch-muted | #4f6458 | Подписи, подсказки, служебные строки |
--ch-line | #e4ddcf | Рамки и разделители |
--ch-field | #7a8c80 | Рамка поля ввода (не бледнее 3:1 к фону) |
--ch-ai / --ch-ai-fg | #eef3ee / #1f2d26 | Фон и текст ответов ИИ и оператора |
--ch-radius | 14px | Скругление окна, пузырей и поля |
--ch-font | system-ui, ... | Шрифт; по умолчанию системный |
Шаблон «Минимальный»
Без шапки, строгие тона, углы 8 px. Подходит, когда чат должен раствориться в странице.
Статический предпросмотр: реплики вымышлены, поле выключено. Живой чат работает после подстановки вашего идентификатора канала.
HTML
<section class="ch" data-ch-root data-channel="CHANNEL_ID" data-api="" aria-label="Чат с консультантом">
<div class="ch-log" data-ch="log" role="log" aria-live="polite"><!-- Сообщения добавляет скрипт --></div>
<label class="ch-consent" data-ch="consent" hidden><input type="checkbox" data-ch="consent-box"><span data-ch="consent-text"></span></label>
<p class="ch-status" data-ch="status" role="status"></p>
<form class="ch-form" data-ch="form">
<input class="ch-input" data-ch="input" type="text" maxlength="4000" autocomplete="off" placeholder="Ваш вопрос" aria-label="Ваш вопрос" required>
<button class="ch-send" data-ch="send" type="submit">Отправить</button>
</form>
</section>CSS
.ch {
--ch-accent: #2d6a4f; --ch-accent-fg: #ffffff; --ch-bg: #ffffff; --ch-fg: #1f2d26; --ch-muted: #4f6458;
--ch-line: #e4ddcf; --ch-field: #7a8c80; --ch-ai: #eef3ee; --ch-ai-fg: #1f2d26; --ch-radius: 14px;
--ch-font: system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
display: flex; flex-direction: column; width: 100%; max-width: 380px; height: 520px; max-height: 100%;
background: var(--ch-bg); color: var(--ch-fg); border: 1px solid var(--ch-line); border-radius: var(--ch-radius);
font: 15px/1.45 var(--ch-font); overflow: hidden; box-sizing: border-box;
}
.ch *, .ch *::before, .ch *::after { box-sizing: border-box; }
.ch :focus-visible { outline: 2px solid var(--ch-accent); outline-offset: 2px; }
.ch-head { display: flex; align-items: center; gap: 10px; padding: 12px 14px; border-bottom: 1px solid var(--ch-line); }
.ch-avatar { flex: none; width: 36px; height: 36px; border-radius: 50%; display: flex; align-items: center; justify-content: center; font-weight: 700; background: var(--ch-accent); color: var(--ch-accent-fg); }
.ch-title { display: block; font-weight: 700; }
.ch-sub { display: block; font-size: 12px; color: var(--ch-muted); }
.ch-log { flex: 1; min-height: 0; overflow-y: auto; padding: 14px; display: flex; flex-direction: column; gap: 8px; -webkit-overflow-scrolling: touch; }
.ch-msg { max-width: 85%; padding: 9px 12px; border-radius: calc(var(--ch-radius) - 4px); white-space: pre-wrap; overflow-wrap: anywhere; }
.ch-msg[data-sender="user"] { align-self: flex-end; background: var(--ch-accent); color: var(--ch-accent-fg); }
.ch-msg[data-sender="ai"], .ch-msg[data-sender="operator"] { align-self: flex-start; background: var(--ch-ai); color: var(--ch-ai-fg); }
.ch-msg[data-sender="system"] { align-self: center; max-width: 100%; padding: 2px 8px; font-size: 13px; color: var(--ch-muted); text-align: center; }
.ch-msg a { color: inherit; text-decoration: underline; }
.ch-card { display: flex; flex-direction: column; gap: 4px; margin-top: 6px; padding: 8px; border: 1px solid var(--ch-line); border-radius: 8px; white-space: normal; }
.ch-consent { display: flex; gap: 8px; align-items: flex-start; padding: 0 14px 8px; font-size: 13px; color: var(--ch-muted); }
.ch-consent[hidden] { display: none; }
.ch-consent a { color: var(--ch-accent); overflow-wrap: anywhere; }
.ch-status { margin: 0; padding: 0 14px; min-height: 1.3em; font-size: 13px; color: var(--ch-muted); }
.ch-form { display: flex; gap: 8px; padding: 10px 14px 14px; }
.ch-input { flex: 1; min-width: 0; padding: 10px 12px; font: inherit; color: var(--ch-fg); background: var(--ch-bg); border: 1px solid var(--ch-field); border-radius: calc(var(--ch-radius) - 4px); }
.ch-input::placeholder { color: var(--ch-muted); opacity: 1; }
.ch-send { flex: none; padding: 10px 16px; font: inherit; font-weight: 600; cursor: pointer; color: var(--ch-accent-fg); background: var(--ch-accent); border: 0; border-radius: calc(var(--ch-radius) - 4px); }
.ch-send:disabled { opacity: 0.55; cursor: default; }
@media (max-width: 480px) { .ch { max-width: none; height: 70vh; height: 70dvh; } }
.ch { --ch-accent: #111111; --ch-accent-fg: #ffffff; --ch-line: #d9d9d9; --ch-field: #767676; --ch-ai: #f1f1f1; --ch-ai-fg: #111111; --ch-fg: #111111; --ch-muted: #595959; --ch-radius: 8px; }
@media (prefers-color-scheme: dark) {
.ch:not([data-theme="light"]) { --ch-bg: #121212; --ch-fg: #f2f2f2; --ch-muted: #b3b3b3; --ch-line: #333333; --ch-field: #8a8a8a; --ch-ai: #262626; --ch-ai-fg: #f2f2f2; --ch-accent: #f2f2f2; --ch-accent-fg: #111111; }
}
.ch[data-theme="dark"] { --ch-bg: #121212; --ch-fg: #f2f2f2; --ch-muted: #b3b3b3; --ch-line: #333333; --ch-field: #8a8a8a; --ch-ai: #262626; --ch-ai-fg: #f2f2f2; --ch-accent: #f2f2f2; --ch-accent-fg: #111111; }
.ch .ch-msg { border-radius: 6px; }JS
(function () {
var root = document.querySelector('[data-ch-root]');
if (!root) return;
var API = root.getAttribute('data-api') || ''; // Адрес ячейки (схема и домен). Пусто - страница лежит на домене самой ячейки.
// С другого домена браузер пропустит только init: нужен ваш прокси, пересылающий /api/widget/*
// (для /api/widget/events отключите буферизацию: proxy_buffering off).
var CHANNEL = root.getAttribute('data-channel');
var KEY = 'chat_token:' + CHANNEL;
var TEXT = {
unavailable: 'Чат недоступен', needConsent: 'Отметьте согласие, чтобы отправить сообщение', consentRetry: 'Отметьте согласие и отправьте сообщение ещё раз',
tooLong: 'Сообщение слишком длинное', tooMany: 'Слишком часто, подождите минуту', failed: 'Не удалось отправить', queued: 'Передано оператору, он ответит позже',
online: 'На связи', price: 'Цена'
};
function q(name) { return root.querySelector('[data-ch="' + name + '"]'); }
var log = q('log'), statusEl = q('status'), form = q('form'), input = q('input'), sendBtn = q('send');
var consentWrap = q('consent'), consentBox = q('consent-box'), consentText = q('consent-text');
var shown = {}; // id уже показанных сообщений
var token = '', consent = null, accepted = '', stream = null, timer = null, lastAt = '';
try { token = localStorage.getItem(KEY) || ''; } catch (e) { /* storage blocked: a new conversation after reload */ }
function saveToken(t) { // Токен обновляется в каждом ответе - сохраняем после каждого
token = t;
try { localStorage.setItem(KEY, t); } catch (e) { /* ignore */ }
}
async function api(path, options) {
var res = await fetch(API + path, options);
var body = null;
try { body = await res.json(); } catch (e) { /* not JSON */ }
return { ok: res.ok, status: res.status, body: body };
}
function post(path, payload) {
return api(path, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload) });
}
function setStatus(text) { statusEl.textContent = text || ''; }
function safeUrl(u) { return typeof u === 'string' && (/^https:\/\//.test(u) || (u.charAt(0) === '/' && u.charAt(1) !== '/')); }
function link(href, text) {
var a = document.createElement('a');
a.href = href; a.target = '_blank'; a.rel = 'noopener'; a.textContent = text;
return a;
}
function addMessage(sender, content, id, extra) {
if (id) { if (shown[id]) return; shown[id] = true; }
extra = extra || {};
var last = log.lastElementChild; // Ответ ИИ может прийти и в ответе на отправку, и по потоку (без общего id): сравниваем с последним.
if (sender !== 'user' && !extra.attachments && !extra.cards && last && last.getAttribute('data-sender') === sender && last.textContent === content) return;
var node = document.createElement('div');
node.className = 'ch-msg';
node.setAttribute('data-sender', sender); // user | ai | operator | system
if (extra.operator && extra.operator.name) node.setAttribute('data-operator', extra.operator.name);
if (content) node.appendChild(document.createTextNode(content)); // textContent, не innerHTML: текст чужой
// Расширение: вложения. Ссылка подписана и живёт ограниченное время (expires_at): не кэшируйте адрес.
(extra.attachments || []).forEach(function (f) {
if (!safeUrl(f.url)) return;
var line = document.createElement('div');
line.appendChild(link(f.url, f.name || 'file'));
node.appendChild(line);
});
// Расширение: карточка (товар или продукт) с кнопками. Адреса только https или путь вашего сайта.
(extra.cards || []).forEach(function (card) {
var box = document.createElement('div');
box.className = 'ch-card';
var title = document.createElement('strong');
title.textContent = card.name || '';
box.appendChild(title);
if (card.price_text) { var price = document.createElement('div'); price.textContent = TEXT.price + ': ' + card.price_text; box.appendChild(price); }
if (card.description) { var d = document.createElement('div'); d.textContent = card.description; box.appendChild(d); }
(card.buttons || []).forEach(function (b) { if (safeUrl(b.url)) box.appendChild(link(b.url, b.label || '')); });
node.appendChild(box);
});
log.appendChild(node);
log.scrollTop = log.scrollHeight;
}
function addFromServer(m) {
addMessage(m.sender, m.content, m.id, { operator: m.operator, attachments: m.attachments, cards: m.cards });
lastAt = m.created_at || lastAt;
}
// Расширение: состояние диалога (ai | queued | operator). Шапка и data-state на корне меняются по нему; оформление - ваше.
var cfg = {};
function applyState(state, operator) {
state = state || 'ai';
root.setAttribute('data-state', state);
var title = root.querySelector('.ch-title'), sub = root.querySelector('.ch-sub');
var t = null, s = null;
if (state === 'operator' && operator && operator.name) { t = operator.name; s = TEXT.online; }
else if (state === 'queued' && cfg.queue) { t = cfg.queue.title; s = cfg.queue.subtitle; }
else if (cfg.assistant) { t = cfg.assistant.name; s = cfg.assistant.subtitle; }
if (title && t) title.textContent = t;
if (sub && s) sub.textContent = s;
}
function showConsent(c) {
consent = c;
consentText.textContent = c.text + ' ';
if (c.url) {
var a = document.createElement('a');
a.href = c.url; a.target = '_blank'; a.rel = 'noopener'; a.textContent = c.url;
consentText.appendChild(a);
}
if (accepted !== c.version) consentBox.checked = false;
consentWrap.hidden = false;
}
async function init() { // Настройки канала, согласие, история; приветствие виджет не хранит - показываем сами
var r = await post('/api/widget/init', { channel_id: CHANNEL });
if (!r.ok) { setStatus(TEXT.unavailable); sendBtn.disabled = true; return; }
cfg = r.body;
applyState('ai');
if (r.body.color && root.hasAttribute('data-use-color')) root.style.setProperty('--ch-accent', r.body.color); // Необязательно: взять цвет из настроек канала (data-use-color на корне)
if (r.body.consent) showConsent(r.body.consent);
var restored = false;
if (token) {
var h = await api('/api/widget/history?st=' + encodeURIComponent(token));
if (h.ok && h.body && h.body.messages && h.body.messages.length) {
h.body.messages.forEach(addFromServer);
if (h.body.state) applyState(h.body.state, h.body.operator);
restored = true;
} else if (h.status === 401 || h.status === 404) {
token = '';
try { localStorage.removeItem(KEY); } catch (e) { /* ignore */ }
}
}
if (!restored) addMessage('ai', r.body.greeting || '');
listen();
}
function listen() { // Входящие: SSE с запасным опросом раз в 2,5 секунды
if (!token || stream || timer) return;
if (typeof EventSource === 'undefined') { poll(); return; }
stream = new EventSource(API + '/api/widget/events?st=' + encodeURIComponent(token));
stream.addEventListener('message', function (e) {
addFromServer(JSON.parse(e.data));
});
stream.addEventListener('conversation:update', function (e) {
var u = JSON.parse(e.data);
applyState(u.state, u.operator);
});
stream.addEventListener('closed', stopStream);
stream.addEventListener('error', function () { stopStream(); poll(); });
}
function stopStream() { if (stream) { stream.close(); stream = null; } }
async function poll() {
if (!token) return;
var r = await api('/api/widget/poll?st=' + encodeURIComponent(token) + (lastAt ? '&after=' + encodeURIComponent(lastAt) : ''));
if (r.ok && r.body && r.body.new_messages) {
r.body.new_messages.forEach(addFromServer);
}
if (r.ok && r.body && r.body.state) applyState(r.body.state, r.body.operator);
timer = setTimeout(poll, 2500);
}
async function send(text) {
var payload = { channel_id: CHANNEL, content: text, session_token: token || undefined, page_url: location.href, page_title: document.title };
if (consent && consentBox.checked) payload.consent_version = consent.version;
var r = await post('/api/widget/messages', payload);
if (r.status === 400 && r.body && r.body.error === 'consent_required') { showConsent(r.body.consent); throw new Error(TEXT.consentRetry); }
if (r.status === 413) throw new Error(TEXT.tooLong);
if (r.status === 429) throw new Error(TEXT.tooMany);
if (!r.ok) throw new Error(TEXT.failed);
if (consent && consentBox.checked) accepted = consent.version;
saveToken(r.body.session_token);
if (r.body.queued) addMessage('system', TEXT.queued);
else if (r.body.response) addMessage('ai', r.body.response);
listen();
}
form.addEventListener('submit', async function (e) {
e.preventDefault();
var text = input.value.trim();
if (!text) return;
if (consent && !consentBox.checked) { setStatus(TEXT.needConsent); return; }
sendBtn.disabled = true; setStatus('');
addMessage('user', text);
try { await send(text); input.value = ''; }
catch (err) { setStatus(err.message); }
finally { sendBtn.disabled = false; input.focus(); }
});
init().catch(function () { setStatus(TEXT.unavailable); sendBtn.disabled = true; });
})();Шаблон «Тёмный»
Всегда тёмный, бирюзовый акцент. Для сайтов с тёмным оформлением.
Статический предпросмотр: реплики вымышлены, поле выключено. Живой чат работает после подстановки вашего идентификатора канала.
HTML
<section class="ch" data-ch-root data-channel="CHANNEL_ID" data-api="" aria-label="Чат с консультантом">
<header class="ch-head">
<span class="ch-avatar" aria-hidden="true">К</span>
<div><strong class="ch-title">Консультант</strong><span class="ch-sub">Отвечает ИИ, оператор подключится при необходимости</span></div>
</header>
<div class="ch-log" data-ch="log" role="log" aria-live="polite"><!-- Сообщения добавляет скрипт --></div>
<label class="ch-consent" data-ch="consent" hidden><input type="checkbox" data-ch="consent-box"><span data-ch="consent-text"></span></label>
<p class="ch-status" data-ch="status" role="status"></p>
<form class="ch-form" data-ch="form">
<input class="ch-input" data-ch="input" type="text" maxlength="4000" autocomplete="off" placeholder="Ваш вопрос" aria-label="Ваш вопрос" required>
<button class="ch-send" data-ch="send" type="submit">Отправить</button>
</form>
</section>CSS
.ch {
--ch-accent: #2d6a4f; --ch-accent-fg: #ffffff; --ch-bg: #ffffff; --ch-fg: #1f2d26; --ch-muted: #4f6458;
--ch-line: #e4ddcf; --ch-field: #7a8c80; --ch-ai: #eef3ee; --ch-ai-fg: #1f2d26; --ch-radius: 14px;
--ch-font: system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
display: flex; flex-direction: column; width: 100%; max-width: 380px; height: 520px; max-height: 100%;
background: var(--ch-bg); color: var(--ch-fg); border: 1px solid var(--ch-line); border-radius: var(--ch-radius);
font: 15px/1.45 var(--ch-font); overflow: hidden; box-sizing: border-box;
}
.ch *, .ch *::before, .ch *::after { box-sizing: border-box; }
.ch :focus-visible { outline: 2px solid var(--ch-accent); outline-offset: 2px; }
.ch-head { display: flex; align-items: center; gap: 10px; padding: 12px 14px; border-bottom: 1px solid var(--ch-line); }
.ch-avatar { flex: none; width: 36px; height: 36px; border-radius: 50%; display: flex; align-items: center; justify-content: center; font-weight: 700; background: var(--ch-accent); color: var(--ch-accent-fg); }
.ch-title { display: block; font-weight: 700; }
.ch-sub { display: block; font-size: 12px; color: var(--ch-muted); }
.ch-log { flex: 1; min-height: 0; overflow-y: auto; padding: 14px; display: flex; flex-direction: column; gap: 8px; -webkit-overflow-scrolling: touch; }
.ch-msg { max-width: 85%; padding: 9px 12px; border-radius: calc(var(--ch-radius) - 4px); white-space: pre-wrap; overflow-wrap: anywhere; }
.ch-msg[data-sender="user"] { align-self: flex-end; background: var(--ch-accent); color: var(--ch-accent-fg); }
.ch-msg[data-sender="ai"], .ch-msg[data-sender="operator"] { align-self: flex-start; background: var(--ch-ai); color: var(--ch-ai-fg); }
.ch-msg[data-sender="system"] { align-self: center; max-width: 100%; padding: 2px 8px; font-size: 13px; color: var(--ch-muted); text-align: center; }
.ch-msg a { color: inherit; text-decoration: underline; }
.ch-card { display: flex; flex-direction: column; gap: 4px; margin-top: 6px; padding: 8px; border: 1px solid var(--ch-line); border-radius: 8px; white-space: normal; }
.ch-consent { display: flex; gap: 8px; align-items: flex-start; padding: 0 14px 8px; font-size: 13px; color: var(--ch-muted); }
.ch-consent[hidden] { display: none; }
.ch-consent a { color: var(--ch-accent); overflow-wrap: anywhere; }
.ch-status { margin: 0; padding: 0 14px; min-height: 1.3em; font-size: 13px; color: var(--ch-muted); }
.ch-form { display: flex; gap: 8px; padding: 10px 14px 14px; }
.ch-input { flex: 1; min-width: 0; padding: 10px 12px; font: inherit; color: var(--ch-fg); background: var(--ch-bg); border: 1px solid var(--ch-field); border-radius: calc(var(--ch-radius) - 4px); }
.ch-input::placeholder { color: var(--ch-muted); opacity: 1; }
.ch-send { flex: none; padding: 10px 16px; font: inherit; font-weight: 600; cursor: pointer; color: var(--ch-accent-fg); background: var(--ch-accent); border: 0; border-radius: calc(var(--ch-radius) - 4px); }
.ch-send:disabled { opacity: 0.55; cursor: default; }
@media (max-width: 480px) { .ch { max-width: none; height: 70vh; height: 70dvh; } }
.ch { --ch-bg: #0f1a24; --ch-fg: #e6eef5; --ch-muted: #9fb3c5; --ch-line: #243545; --ch-field: #5f7a92; --ch-ai: #1b2a38; --ch-ai-fg: #e6eef5; --ch-accent: #5eead4; --ch-accent-fg: #04201c; --ch-radius: 16px; }
.ch { box-shadow: 0 16px 40px rgba(0, 0, 0, 0.45); }
.ch .ch-head { background: linear-gradient(180deg, #162636, #0f1a24); }
.ch .ch-msg[data-sender="user"] { font-weight: 500; }
.ch .ch-input { background: #0a131b; }JS
(function () {
var root = document.querySelector('[data-ch-root]');
if (!root) return;
var API = root.getAttribute('data-api') || ''; // Адрес ячейки (схема и домен). Пусто - страница лежит на домене самой ячейки.
// С другого домена браузер пропустит только init: нужен ваш прокси, пересылающий /api/widget/*
// (для /api/widget/events отключите буферизацию: proxy_buffering off).
var CHANNEL = root.getAttribute('data-channel');
var KEY = 'chat_token:' + CHANNEL;
var TEXT = {
unavailable: 'Чат недоступен', needConsent: 'Отметьте согласие, чтобы отправить сообщение', consentRetry: 'Отметьте согласие и отправьте сообщение ещё раз',
tooLong: 'Сообщение слишком длинное', tooMany: 'Слишком часто, подождите минуту', failed: 'Не удалось отправить', queued: 'Передано оператору, он ответит позже',
online: 'На связи', price: 'Цена'
};
function q(name) { return root.querySelector('[data-ch="' + name + '"]'); }
var log = q('log'), statusEl = q('status'), form = q('form'), input = q('input'), sendBtn = q('send');
var consentWrap = q('consent'), consentBox = q('consent-box'), consentText = q('consent-text');
var shown = {}; // id уже показанных сообщений
var token = '', consent = null, accepted = '', stream = null, timer = null, lastAt = '';
try { token = localStorage.getItem(KEY) || ''; } catch (e) { /* storage blocked: a new conversation after reload */ }
function saveToken(t) { // Токен обновляется в каждом ответе - сохраняем после каждого
token = t;
try { localStorage.setItem(KEY, t); } catch (e) { /* ignore */ }
}
async function api(path, options) {
var res = await fetch(API + path, options);
var body = null;
try { body = await res.json(); } catch (e) { /* not JSON */ }
return { ok: res.ok, status: res.status, body: body };
}
function post(path, payload) {
return api(path, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload) });
}
function setStatus(text) { statusEl.textContent = text || ''; }
function safeUrl(u) { return typeof u === 'string' && (/^https:\/\//.test(u) || (u.charAt(0) === '/' && u.charAt(1) !== '/')); }
function link(href, text) {
var a = document.createElement('a');
a.href = href; a.target = '_blank'; a.rel = 'noopener'; a.textContent = text;
return a;
}
function addMessage(sender, content, id, extra) {
if (id) { if (shown[id]) return; shown[id] = true; }
extra = extra || {};
var last = log.lastElementChild; // Ответ ИИ может прийти и в ответе на отправку, и по потоку (без общего id): сравниваем с последним.
if (sender !== 'user' && !extra.attachments && !extra.cards && last && last.getAttribute('data-sender') === sender && last.textContent === content) return;
var node = document.createElement('div');
node.className = 'ch-msg';
node.setAttribute('data-sender', sender); // user | ai | operator | system
if (extra.operator && extra.operator.name) node.setAttribute('data-operator', extra.operator.name);
if (content) node.appendChild(document.createTextNode(content)); // textContent, не innerHTML: текст чужой
// Расширение: вложения. Ссылка подписана и живёт ограниченное время (expires_at): не кэшируйте адрес.
(extra.attachments || []).forEach(function (f) {
if (!safeUrl(f.url)) return;
var line = document.createElement('div');
line.appendChild(link(f.url, f.name || 'file'));
node.appendChild(line);
});
// Расширение: карточка (товар или продукт) с кнопками. Адреса только https или путь вашего сайта.
(extra.cards || []).forEach(function (card) {
var box = document.createElement('div');
box.className = 'ch-card';
var title = document.createElement('strong');
title.textContent = card.name || '';
box.appendChild(title);
if (card.price_text) { var price = document.createElement('div'); price.textContent = TEXT.price + ': ' + card.price_text; box.appendChild(price); }
if (card.description) { var d = document.createElement('div'); d.textContent = card.description; box.appendChild(d); }
(card.buttons || []).forEach(function (b) { if (safeUrl(b.url)) box.appendChild(link(b.url, b.label || '')); });
node.appendChild(box);
});
log.appendChild(node);
log.scrollTop = log.scrollHeight;
}
function addFromServer(m) {
addMessage(m.sender, m.content, m.id, { operator: m.operator, attachments: m.attachments, cards: m.cards });
lastAt = m.created_at || lastAt;
}
// Расширение: состояние диалога (ai | queued | operator). Шапка и data-state на корне меняются по нему; оформление - ваше.
var cfg = {};
function applyState(state, operator) {
state = state || 'ai';
root.setAttribute('data-state', state);
var title = root.querySelector('.ch-title'), sub = root.querySelector('.ch-sub');
var t = null, s = null;
if (state === 'operator' && operator && operator.name) { t = operator.name; s = TEXT.online; }
else if (state === 'queued' && cfg.queue) { t = cfg.queue.title; s = cfg.queue.subtitle; }
else if (cfg.assistant) { t = cfg.assistant.name; s = cfg.assistant.subtitle; }
if (title && t) title.textContent = t;
if (sub && s) sub.textContent = s;
}
function showConsent(c) {
consent = c;
consentText.textContent = c.text + ' ';
if (c.url) {
var a = document.createElement('a');
a.href = c.url; a.target = '_blank'; a.rel = 'noopener'; a.textContent = c.url;
consentText.appendChild(a);
}
if (accepted !== c.version) consentBox.checked = false;
consentWrap.hidden = false;
}
async function init() { // Настройки канала, согласие, история; приветствие виджет не хранит - показываем сами
var r = await post('/api/widget/init', { channel_id: CHANNEL });
if (!r.ok) { setStatus(TEXT.unavailable); sendBtn.disabled = true; return; }
cfg = r.body;
applyState('ai');
if (r.body.color && root.hasAttribute('data-use-color')) root.style.setProperty('--ch-accent', r.body.color); // Необязательно: взять цвет из настроек канала (data-use-color на корне)
if (r.body.consent) showConsent(r.body.consent);
var restored = false;
if (token) {
var h = await api('/api/widget/history?st=' + encodeURIComponent(token));
if (h.ok && h.body && h.body.messages && h.body.messages.length) {
h.body.messages.forEach(addFromServer);
if (h.body.state) applyState(h.body.state, h.body.operator);
restored = true;
} else if (h.status === 401 || h.status === 404) {
token = '';
try { localStorage.removeItem(KEY); } catch (e) { /* ignore */ }
}
}
if (!restored) addMessage('ai', r.body.greeting || '');
listen();
}
function listen() { // Входящие: SSE с запасным опросом раз в 2,5 секунды
if (!token || stream || timer) return;
if (typeof EventSource === 'undefined') { poll(); return; }
stream = new EventSource(API + '/api/widget/events?st=' + encodeURIComponent(token));
stream.addEventListener('message', function (e) {
addFromServer(JSON.parse(e.data));
});
stream.addEventListener('conversation:update', function (e) {
var u = JSON.parse(e.data);
applyState(u.state, u.operator);
});
stream.addEventListener('closed', stopStream);
stream.addEventListener('error', function () { stopStream(); poll(); });
}
function stopStream() { if (stream) { stream.close(); stream = null; } }
async function poll() {
if (!token) return;
var r = await api('/api/widget/poll?st=' + encodeURIComponent(token) + (lastAt ? '&after=' + encodeURIComponent(lastAt) : ''));
if (r.ok && r.body && r.body.new_messages) {
r.body.new_messages.forEach(addFromServer);
}
if (r.ok && r.body && r.body.state) applyState(r.body.state, r.body.operator);
timer = setTimeout(poll, 2500);
}
async function send(text) {
var payload = { channel_id: CHANNEL, content: text, session_token: token || undefined, page_url: location.href, page_title: document.title };
if (consent && consentBox.checked) payload.consent_version = consent.version;
var r = await post('/api/widget/messages', payload);
if (r.status === 400 && r.body && r.body.error === 'consent_required') { showConsent(r.body.consent); throw new Error(TEXT.consentRetry); }
if (r.status === 413) throw new Error(TEXT.tooLong);
if (r.status === 429) throw new Error(TEXT.tooMany);
if (!r.ok) throw new Error(TEXT.failed);
if (consent && consentBox.checked) accepted = consent.version;
saveToken(r.body.session_token);
if (r.body.queued) addMessage('system', TEXT.queued);
else if (r.body.response) addMessage('ai', r.body.response);
listen();
}
form.addEventListener('submit', async function (e) {
e.preventDefault();
var text = input.value.trim();
if (!text) return;
if (consent && !consentBox.checked) { setStatus(TEXT.needConsent); return; }
sendBtn.disabled = true; setStatus('');
addMessage('user', text);
try { await send(text); input.value = ''; }
catch (err) { setStatus(err.message); }
finally { sendBtn.disabled = false; input.focus(); }
});
init().catch(function () { setStatus(TEXT.unavailable); sendBtn.disabled = true; });
})();Своя кнопка и встроенный блок
Если нужен готовый чат виджета, но с вашей кнопкой или прямо в странице, шаблон открывает страницу чата /widget/<channel_id> в кадре. Это обходной путь: поддерживаемые методы открытия и режим «встроенный блок» у виджета запланированы - см. JS API виджета. Адрес страницы чата - внутренний, не контракт; кадр создаётся при первом открытии.
Панель с чатом открывается поверх страницы по нажатию; в предпросмотре кнопка выключена.
HTML
<!-- Своя кнопка и панель. data-origin - адрес ячейки (схема и домен); пусто - тот же домен.
Блок на странице вместо всплывающего окна: добавьте data-mode="inline" и уберите hidden у панели. -->
<div class="my-chat" data-chat-embed data-origin="" data-channel="CHANNEL_ID" data-frame-theme="light">
<button type="button" class="my-chat-open" data-chat-open aria-expanded="false" aria-controls="my-chat-panel">Задать вопрос</button>
<div class="my-chat-panel" id="my-chat-panel" data-chat-panel hidden>
<button type="button" class="my-chat-close" data-chat-close aria-label="Закрыть чат">×</button>
</div>
</div>CSS
.my-chat { --mc-accent: #2d6a4f; --mc-accent-fg: #ffffff; --mc-bg: #ffffff; --mc-radius: 16px; }
.my-chat-open {
font: 600 15px/1.2 system-ui, -apple-system, "Segoe UI", Roboto, sans-serif; cursor: pointer;
padding: 12px 22px; border: 0; border-radius: 999px; color: var(--mc-accent-fg); background: var(--mc-accent);
}
.my-chat-open:focus-visible, .my-chat-close:focus-visible { outline: 2px solid var(--mc-accent); outline-offset: 2px; }
.my-chat-panel {
position: fixed; right: 16px; bottom: 16px; z-index: 1000; width: 380px; max-width: calc(100vw - 32px);
height: 560px; max-height: calc(100vh - 32px); background: var(--mc-bg); border-radius: var(--mc-radius);
box-shadow: 0 16px 48px rgba(0, 0, 0, 0.25); overflow: hidden;
}
.my-chat-panel[hidden] { display: none; }
.my-chat-frame { display: block; width: 100%; height: 100%; border: 0; }
.my-chat-close {
position: absolute; top: 8px; right: 8px; z-index: 1; width: 32px; height: 32px; border: 0; border-radius: 50%;
font-size: 20px; line-height: 1; cursor: pointer; color: #1f2d26; background: rgba(255, 255, 255, 0.9);
}
.my-chat[data-mode="inline"] .my-chat-open, .my-chat[data-mode="inline"] .my-chat-close { display: none; }
.my-chat[data-mode="inline"] .my-chat-panel {
position: static; width: 100%; max-width: 380px; height: 560px; max-height: none; box-shadow: none; border: 1px solid #e4ddcf;
}
@media (max-width: 480px) { .my-chat-panel { right: 8px; bottom: 8px; max-width: calc(100vw - 16px); } }JS
(function () {
var box = document.querySelector('[data-chat-embed]');
if (!box) return;
var openBtn = box.querySelector('[data-chat-open]');
var closeBtn = box.querySelector('[data-chat-close]');
var panel = box.querySelector('[data-chat-panel]');
var frame = null;
function ensureFrame() { // Кадр создаётся при первом открытии: до этого адрес страницы на платформу не уходит
if (frame) return;
var origin = box.getAttribute('data-origin') || '';
var src = origin + '/widget/' + encodeURIComponent(box.getAttribute('data-channel'))
+ '?theme=' + encodeURIComponent(box.getAttribute('data-frame-theme') || 'light')
+ '&page=' + encodeURIComponent(location.origin + location.pathname)
+ '&title=' + encodeURIComponent(document.title);
frame = document.createElement('iframe');
frame.className = 'my-chat-frame';
frame.title = 'Чат с консультантом';
frame.setAttribute('allow', 'clipboard-write');
frame.src = src;
panel.appendChild(frame);
}
function setOpen(isOpen) {
if (isOpen) ensureFrame();
panel.hidden = !isOpen;
openBtn.setAttribute('aria-expanded', String(isOpen));
if (!isOpen) openBtn.focus();
}
openBtn.addEventListener('click', function () { setOpen(panel.hidden); });
closeBtn.addEventListener('click', function () { setOpen(false); });
document.addEventListener('keydown', function (e) { if (e.key === 'Escape' && !panel.hidden) setOpen(false); });
// Кнопка закрытия внутри кадра шлёт родителю строку sapport:close - внутренний протокол, не контракт
window.addEventListener('message', function (e) {
if (frame && e.source === frame.contentWindow && e.data === 'sapport:close') setOpen(false);
});
if (!panel.hidden) ensureFrame(); // data-mode="inline": блок открыт сразу
})();Перед запуском
- Подставьте
CHANNEL_IDвашего каналаweb_chatи, если страница не на домене ячейки, адрес прокси вdata-api. - Откройте чат на телефоне шириной 360 px: окно не должно уходить за экран, поле ввода - перекрываться клавиатурой.
- Проверьте фокус с клавиатуры (Tab) и контраст перекрашенных пар цветов.
- Отправьте тестовое сообщение со своего устройства и убедитесь, что ответ ИИ и токен сохраняются.