Формула ИИ для разработчиков Справочник API Каталог ошибок OpenAPI
Разделы

Шаблоны чата с ИИ-продажником

Три готовых интерфейса чата - как на нашем сайте, минимальный и тёмный - на чистых 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 с

Что учесть

Шаблон «Как на formula-cream.pro»

Перенос окна чата нашего сайта на чистые HTML, CSS и JS: тёмное стеклянное окно с градиентом, шапка с аватаром и строкой «Онлайн», пузыри со временем, индикатор набора, карточка товара прямо в ленте, карточка «оставьте контакты», круглое поле ввода с самолётиком и плавающая кнопка с пульсом и счётчиком непрочитанных. Размеры, цвета, тени и анимации взяты из исходного компонента; вместо Tailwind - переменные --sc-*.

Окно чата и кнопкапример

Статический предпросмотр: реплики и карточка вымышлены, поле выключено, анимации отключены. Окно построено кодом из вкладки «JS: элементы».

Карточка контактов в лентепример

Показывается, когда сообщение передано оператору, либо по событию страницы.

Откуда берутся карточка и форма контактов

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-airgba(5,150,105,.4) / rgba(255,255,255,.1)Фон пузырей посетителя и ответов ИИ
--sc-fab-from / --sc-fab-to#2d6a4f / #1a8a5eГрадиент плавающей кнопки
--sc-radius / --sc-send-radius16px / 12pxСкругление окна и кнопки отправки (50% - круглая)
--sc-placeholderrgba(255,255,255,.55)Цвет подсказок в полях (не бледнее 4,5:1)
--sc-edge / --sc-bottom24px / 16pxОтступ окна и кнопки от края экрана

Как и на нашем сайте, на телефоне окно почти во всю ширину (по 16 px от краёв), его высота не выходит за экран; при prefers-reduced-motion пульсы и появление отключены. Отличия от оригинала намеренные: подсказки в полях светлее (контраст), кнопки зелёные темнее, чтобы белый текст читался.

Как это выглядит у нас

Снимки рабочего чата сайта школы; сообщения не отправлялись.

Закрытый чат на сайте formula-cream.pro: круглая кнопка в правом нижнем углу
чат на нашем сайте formula-cream.pro - пример фирменного оформления; ПК, чат закрыт
Открытый чат на сайте formula-cream.pro: тёмное стеклянное окно с приветствием
чат на нашем сайте formula-cream.pro - пример фирменного оформления; ПК, чат открыт
Закрытый чат на сайте formula-cream.pro на телефоне
чат на нашем сайте formula-cream.pro - пример фирменного оформления; телефон, чат закрыт
Открытый чат на сайте formula-cream.pro на телефоне
чат на нашем сайте formula-cream.pro - пример фирменного оформления; телефон, чат открыт: окно почти на весь экран

Что перекрасить в минимальном и тёмном

Вид этих двух шаблонов задают переменные на корне .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-radius14pxСкругление окна, пузырей и поля
--ch-fontsystem-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="Закрыть чат">&times;</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": блок открыт сразу
})();

Перед запуском