Widget
Для разработчиков

Widget

Чат-виджет — это один скрипт-тег, который можно вставить на любую страницу. Он создаёт плавающую кнопку в углу, открывает чат с оператором, тянет ленту сообщений и историю тикетов. Никакой установки или авторизации клиенту не нужно.

Минимальная установка

index.html
<script>
(function(w, d){
  function loadWidget(token){
    var s = d.createElement('script');
    s.async = 1;
    s.src = 'https://support.forestsnet.com/widget-bundle?ws=WORKSPACE_UUID';
    if (token) s.setAttribute('data-visitor-token', token);
    d.head.appendChild(s);
  }
  // Optional cross-device identity: fetch a signed token from your
  // backend. Drop the fetch + just call loadWidget() if you don't
  // need cross-device. Endpoint format: see /docs/widget/identify.
  fetch('/api/supporthub/token', { credentials: 'include' })
    .then(function(r){ return r.ok ? r.json() : null; })
    .then(function(data){ loadWidget(data && data.token); })
    .catch(function(){ loadWidget(); });
})(window, document);
</script>
Обязателен только параметр ?ws=. UUID воркспейса возьмите в «Настройки → Виджет» или скопируйте готовый сниппет прямо оттуда (кнопка Embed). Loader-pattern с IIFE добавляет тег <script async> в head на лету — то же что прямой <script async src="..."></script>, но лучше переживает CSP-ограничения и SPA-роутинг. Бандл сам знает, куда ходить за API — URL бэкенда инжектится сервером в preamble. Конфиг виджета тоже инлайнится в ответ на /widget-bundle?ws=…, поэтому виджет стартует за один запрос без отдельного /config.

Атрибуты тега

АтрибутТипНазначение
?ws= (query)uuidОбязательно. ID воркспейса в URL скрипта — сервер по нему инлайнит конфиг и API URL в бандл.
data-api-baseurlНеобязательно. Override базового URL API (например https://api.staging.example.com/api). По умолчанию бандл уже знает прод-URL — атрибут нужен только для стейджа / self-hosted инсталляций.
data-cookie-domainstringНеобязательно. Домен для visitor-session cookie с лидирующей точкой (.example.com) — чтобы один визитор сохранял одну беседу при переходе между поддоменами. То же самое можно задать через «Конструктор → Поведение → Домен cookie» — атрибут выигрывает у конфига, удобно если стейдж на другом домене.
data-emailstringEmail клиента. Если передан — pre-chat форма пропускается, контакт создаётся/обновляется на стороне бэка через /widget/identify.
data-namestringПолное имя клиента. Отображается в админке и в TG-карточке.
data-phonestringНомер телефона (E.164).
data-telegram-idintTelegram ID для связки виджета и TG-канала бота.
data-visitor-tokenstringHMAC-токен для cross-device идентификации (если посетитель уже залогинен в вашей CRM с подписанным jwt — передайте его сюда, тикеты подцепятся к тому же контакту с других устройств).
data-localeBCP-47Принудительный язык виджета — en или ru (BCP-47 с субтегом тоже работает: en-GB en). Полезно когда воркспейс задан на одну локаль, но сайт мультиязычный — ставите свой data-locale на каждой языковой версии. Без атрибута виджет читает <html lang="…"> страницы и далееnavigator.language.

Передача данных пользователя

Если ваш сайт уже знает посетителя — передайте поля прямо в тег. Виджет создаст / обновит контакт на бэке через POST /widget/identifyавтоматически при загрузке (даже если посетитель ещё не открывал чат).

Когда нужны data-* атрибуты — используйте прямой <script async> вместо loader-сниппета. IIFE-loader создаёт тег без вашей атрибутики, бандл прочитать её не сможет.

index.html (SSR / Blade / EJS / Twig)
<script
  async
  src="https://support.forestsnet.com/widget-bundle?ws=WORKSPACE_UUID"
  data-email="{{ user.email }}"
  data-name="{{ user.full_name }}"
  data-phone="{{ user.phone }}"
  data-telegram-id="{{ user.telegram_id }}"
></script>

Динамическая идентификация из JS

Если пользователь логинится после загрузки страницы (SPA), вызовите window.SupportHub.identify(). Он экспортируется ещё до завершения init() — вызовы до загрузки бандла буферизуются и отправляются автоматически как только виджет инициализируется.

app.js
// После того как юзер вошёл в аккаунт:
window.SupportHub.identify({
  email: user.email,
  name: user.fullName,
  phone: user.phone,
  telegram_id: user.telegramId,
});

Поля совпадают с data-*-атрибутами. Передавайте только то что знаете — пустые поля не затирают существующие значения. Email при повторном identify обновится (раньше был хард-409 при попытке сменить email — это исправлено в d62e0ef).

Локализация

Виджет говорит на двух языках: ru и en. Локаль выбирается по цепочке приоритетов сверху вниз — первая подходящая выигрывает, остальные игнорируются:

ПриоритетИсточникКогда использовать
1init({ locale })Программно через SupportHub.init({ locale: 'en' }) или SupportHub.applyConfig({ locale: 'en' }) на лету. Удобно для SPA, где язык переключается клиентом.
2data-locale="…"Атрибут на script-теге. Самое простое для статических страниц — одна языковая версия = один тег.
3window.__SH_LOCALEГлобальный override до загрузки бандла — ставьте если script-тег генерируется CMS'ом без поддержки data-*.
4<html lang="…">Виджет автоматически парсит document.documentElement.lang — если он валидный (ru, en, en-GB), подхватывается. Закрывает 90% случаев без всяких атрибутов.
5cfg.localeЛокаль воркспейса (Конструктор → Бренд → язык). Применяется ко всем посетителям, кроме тех, кому переопределили выше.
6navigator.languageЯзык браузера посетителя.
7enФинальный фолбэк, когда ничего из выше не дало валидное значение.

Невалидные значения (fr, xx) пропускаются — падаем на следующий уровень. Поддерживаются BCP-47 субтеги: en-GBen, ru-RUru.

Через атрибут на script-теге
<!-- Английская версия страницы -->
<script
  src="https://support.forestsnet.com/widget-bundle?ws=YOUR_WS"
  data-locale="en"
  async
></script>
Через init() в SPA
window.SupportHub.init({
  workspace_id: "YOUR_WS",
  locale: "en",
});

// Переключение на лету (если у вас smene-language кнопка):
window.SupportHub.applyConfig({ locale: "ru" });
Глобальный override до загрузки
<script>window.__SH_LOCALE = "en";</script>
<script src="https://support.forestsnet.com/widget-bundle?ws=YOUR_WS" async></script>

Часто хватает не делать ничего: если у вас на странице есть <html lang="en"> (а это норма по SEO/a11y), виджет сам подхватит без единой строки кода с вашей стороны.

Переводы текстов

Локаль выше определяет какой bundle виджет подгружает (тексты системных кнопок, надписи компоновщика, плашки), но тексты, которые вы редактируете в Конструкторе виджета, по умолчанию хранятся в одной локали — и показываются всем визитерам одинаково. Чтобы заголовок «Hi there 👋» в английской версии страницы не отображался у русскоязычного гостя, в конструкторе есть переключатель языка RU / EN в правом верхнем углу редактора.

Переключаете на EN, вводите английский текст — перезаключаете на RU, поле снова показывает русский. Сохраняете один раз — каждый визитер получит вариант в своей локали (резолв на сервере по той же цепочке, что описана выше). На полях где переводы поддерживаются: Брендинг (заголовок, подзаголовок, название проекта), Приветствие (заголовок, подзаголовок, сообщение, кнопка, время ответа), Формы (pre-chat сообщение), Чат (история, prompt оценки), Часы работы (offline, outside, кнопка закрытия), Системные сообщения (все toggleable + auto-reply при закрытом тикете), Email-мост (label, hint, auto-ack subject/body).

Если оператор оставил перевод пустым в одной из локалей — виджет фолбэкается на заполненную, чтобы не показывать пустую строку (правило: предпочитаем визиторскую локаль → другая локаль → i18n-bundle дефолт).

Хранение visitor session

ID визитора (vs_<random>) — единственное что привязывает анонимную сессию к контакту в БД. Чтобы он переживал Safari ITP, private mode, перезагрузки страницы и закрытие вкладок, виджет хранит его параллельно в пяти местах:

TierХранилищеЖизненный цикл
1cookie sh_visitor_<ws>1 год, Lax, Secure on https. Единственный tier с поддержкой cross-subdomain (через cookie_domain). ITP в Safari режет до ~7 дней.
2localStorage sh-visitor-<ws>До «очистить данные сайта». Per-origin. Переживает ITP лучше cookie.
3sessionStorageДо закрытия вкладки. Последняя линия обороны для Safari private mode + sandboxed iframe, где tiers 1–2 могут быть недоступны.
4IndexedDB sh-widgetСамые большие квоты, тяжелее всего эвиктится. Async write-back; читается через primeFromIndexedDB при init если все sync tier'ы пусты.
5localStorage sh-visitor-sessionLegacy ключ из эпохи single-workspace. Read-only, для миграции существующих визиторов.

Read precedence: 1 → 2 → 3 → 5. Первое непустое значение побеждает; затем оно зеркалируется во все доступные tier'ы, так что если в одной браузерной сессии cookie был выкинут ITP, при следующей загрузке value поднимается из localStorage и записывается обратно в cookie. IndexedDB пишется fire-and-forget — потерять запись туда не страшно, sync tier'ы уже держат значение.

Cross-tab sync

BroadcastChannel('sh-widget-sync') уведомляет другие вкладки в том же браузере как только session id создан или промотирован — две одновременно открытые вкладки не сгенерируют два параллельных id. Когда BroadcastChannel недоступен (Firefox private mode), используется fallback через storage-event на window (он firing'ится в OTHER вкладках при изменении localStorage).

Эту схему не надо настраивать — она работает из коробки сразу как виджет загружен. Единственная настройка которую вы можете задать —cookie_domain для cross-subdomain (см. ниже). Всё остальное автоматическое.

Один визитор на нескольких поддоменах

По умолчанию tier 1 (cookie) scoped по host: визитор на example.com и app.example.com — два разных контакта, две беседы. Tiers 2–5 тоже scoped per-origin — cross-subdomain работает только через cookie с явно указанным доменом.

Чтобы сохранять одну беседу между корневым доменом и поддоменами, задайте cookie-домен с точкой в начале:

  • Через админку: «Конструктор виджета → Поведение → Домен cookie» → ввести .example.com. Снипет на всех поддоменах остаётся тем же — виджет прочитает значение из конфига.
  • Через атрибут: data-cookie-domain=".example.com"в теге <script>. Атрибут выигрывает у конфига — удобно если стейдж и прод на разных доменах.
Не задавайте слишком широкий домен (например .co.uk) — браузер отклонит cookie, потому что это публичный суффикс. Указывайте именно ваш домен второго уровня. Точка в начале обязательна — без неё cookie ставится только на указанный хост.

Миграция автоматическая: при первом заходе виджет читает старый localStorage-ключ sh-visitor-session и переписывает значение в cookie с указанным доменом. На следующем поддомене браузер найдёт cookie и подхватит ту же сессию.

JS controller API

После загрузки бандла на window.SupportHub доступен controller с публичным API. Методы безопасно вызывать до завершения init() — bundle экспортирует init и identify сразу при загрузке скрипта; вызовы до init буферизуются и выполняются как только controller онлайн.

МетодНазначение
init(opts)Программная инициализация. Опции: workspace_id, config, apiBase, mode. Авто-буут (через ?ws=) уже вызывает init.
open()Открыть панель программно (например, по клику на свою кнопку).
close()Свернуть панель в FAB.
isOpen()Возвращает boolean — открыта ли панель сейчас.
setTab(tab)Переключить активную вкладку: "home" | "chat" | "help" | "news" | "miniapp".
identify(payload)Привязать визитора к контакту. Поля: email, name, phone, telegram_id, visitor_token. Async; возвращает Promise.
applyConfig(cfg)Применить новый конфиг. Используется live-preview iframe в Builder; на проде обычно не нужен.
showState(s, sub?)Демо-mode preview-state переключатель. Только в preview-mode; на проде no-op.
destroy()Размонтировать виджет. Закрывает WS, удаляет DOM. Используйте после logout перед повторным init().

Lifecycle / порядок инициализации

  1. Bundle грузится через <script src=".../widget-bundle?ws=...">. При загрузке IIFE auto-boot:
    • Pin'ит window.__SH_API_BASE из data-api-base или script origin (см. preamble).
    • Скрейпит data-* атрибуты в _identifyState.
    • Если ?ws= в URL — вызывает initInternal().
  2. initInternal():
    • Загружает конфиг (либо из preamble __SH_WIDGET_CONFIG, либо GET /webhooks/widget/{ws}/config).
    • Создаёт shell, state, api client.
    • primeFromIndexedDB — async-восстановление session из IDB если все sync tier'ы пусты.
    • openVisitorWs — opportunistic WS connect (4401 пока нет Contact'а).
    • flushIdentify — POST в /widget/identify если есть pending данные.
    • subscribeSessionSync — cross-tab BroadcastChannel listener.
  3. Controller возвращается из init() и присваивается в window.SupportHub. Дальнейший SupportHub.identify(...) работает через тот же controller.

Глобалы которые виджет ставит на window

  • window.SupportHub — controller (методы выше)
  • window.__SH_API_BASE — base URL API (pinned из script tag origin или preamble)
  • window.__SH_WIDGET_CONFIG — заинлайненный конфиг (только если bundle серверу удалось pre-fetch)
  • window.__SH_WIDGET_V2_MOUNTED — guard от двойного auto-boot

WebSocket protocol

После Contact'a создания виджет открывает wss://api.support.forestsnet.com/api/ws/widget/{visitor_session}?workspace_id={ws} (плюс &signed_token=... для cross-device). Backend пушит JSON-фреймы:

  • { type: "message.created", data: { ticket_id, content, ... } } — новое сообщение от оператора
  • { type: "ticket.assigned", data: { ticket_id, operator_name, operator_avatar_url } } — оператор взял тикет
  • { type: "ticket.transferred", data: { ticket_id, from, to } } — передача между операторами
  • { type: "message.reaction.added"|"removed", data: { message_id, emoji } }
  • { type: "operator_seen", data: { ticket_id, last_seen_message_id } } — оператор открыл тикет
  • { type: "operator_typing", data: { ticket_id, is_typing } }

Visitor-side ping/pong каждые ~30s — иначе backend закрывает connection через 60s неактивности (и помечает offline в ContactChannelStatus).

Расширение через events

Если нужно реагировать на события виджета (открытие чата, отправка сообщения) из своего кода — используйте webhooks API на бекенде (/docs/api-guide/webhooks). Public DOM-events на стороне клиента в текущей версии не эмитятся — это в roadmap (SupportHub.on('open', cb) планируется).

Была ли страница полезной?