@zyplai/support-widget
v0.1.10
Published
Embeddable support-ticket widget (Preact + Vite, Shadow DOM, single IIFE bundle).
Maintainers
Readme
@zyplai/support-widget
Встраиваемый виджет тикетов поддержки. Один IIFE-бандл, без зависимостей у хост-приложения, изолированные стили через Shadow DOM. Собирается из этого репо, публикуется на npm, раздаётся через jsDelivr.
Возможности
- Изоляция стилей — рендер внутри Shadow DOM, никаких конфликтов с CSS хоста (включая Tailwind / Bootstrap / любой reset).
- Нет зависимостей у хоста — Preact + Tailwind зашиты внутрь IIFE, хосту достаточно одного
<script>. - i18n —
en,ru,ar(с автоматическим RTL для арабского). - Очередь до загрузки — вызовы
window.fw(...)сделанные до того, как скрипт догрузился, замораживаются и проигрываются после mount'а. - Late-binding авторизации — токен и
baseUrlчитаются интерсептором на каждом запросе, можно переинициализировать виджет в рантайме (например, после рефреша JWT). - Хоткей
⌘J/Ctrl+J— открывает/закрывает панель. - Идемпотентный mount — повторный
init()обновит конфиг, но не сделает remount.
Установка через CDN
<!-- pin exact (рекомендуется для прода) -->
<script async src="https://cdn.jsdelivr.net/npm/@zyplai/[email protected]/dist/widget.iife.js"></script>Альтернативные шаблоны URL — см. Версионирование CDN.
Подключение за 3 шага (vanilla HTML)
Шаг 1 — queue-stub до подключения скрипта
В <head> или сразу перед загрузкой виджета:
<script>
window.fw = window.fw || function () {
(window.fw.q = window.fw.q || []).push(arguments);
};
</script>Этот stub нужен, чтобы можно было вызывать window.fw(...) ещё до того, как сам бандл загрузился. Все вызовы попадут в очередь и выполнятся, как только IIFE отработает.
Шаг 2 — подключить виджет
<script async src="https://cdn.jsdelivr.net/npm/@zyplai/[email protected]/dist/widget.iife.js"></script>async безопасен — очередь из шага 1 гарантирует, что ни один init/open не потеряется.
Шаг 3 — init с авторизационным контекстом
window.fw('init', {
token: 'eyJhbGciOi...', // обязательно: JWT или "Bearer <jwt>"
partnerId: 42, // обязательно: integer или string
baseUrl: 'https://api.example.com', // опционально — см. ниже
lang: 'ru', // опционально — 'en' | 'ru' | 'ar'
hasAccess: true, // опционально: управление доступом (по умолчанию true)
});Без token и partnerId виджет не смонтируется, в консоль уйдёт [fw] init() requires token and partnerId.
Интеграция в React + Vite (TS)
1. index.html
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<title>Your app</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
<script>
window.fw = window.fw || function () {
(window.fw.q = window.fw.q || []).push(arguments);
};
</script>
<script async src="https://cdn.jsdelivr.net/npm/@zyplai/[email protected]/dist/widget.iife.js"></script>
</body>
</html>2. Типы window.fw для TS
Без этих типов tsc упадёт при использовании window.fw?.(...). Добавь в src/vite-env.d.ts:
/// <reference types="vite/client" />
type FwLang = "en" | "ru" | "ar";
interface FwInitConfig {
token: string;
partnerId: number | string;
baseUrl?: string;
lang?: FwLang;
hasAccess?: boolean;
}
interface FwApi {
(cmd: "init", config: FwInitConfig): void;
(cmd: "open" | "close" | "toggle"): void;
(cmd: "setLanguage", lang: FwLang | string): void;
(cmd: "setAccess", hasAccess: boolean): void;
(cmd: "destroy"): void;
q?: IArguments[];
onLanguageChange?: (cb: (lang: FwLang) => void) => () => void;
getLanguage?: () => FwLang;
}
interface Window {
fw?: FwApi;
}3. Инициализация после логина
Виджет нужно инициализировать, когда уже есть token и partnerId (обычно — после успешного логина и записи их в sessionStorage/store). Готовый хук:
// src/pages/<...>/lib/useInitSupportWidget.ts
import { useEffect } from "react";
export function useInitSupportWidget() {
useEffect(() => {
const token = sessionStorage.getItem("token");
const partnerId = sessionStorage.getItem("partnerId");
const lang = sessionStorage.getItem("language") ?? "en";
if (!token || !partnerId) return;
window.fw?.("init", {
token,
partnerId: Number(partnerId),
lang: lang as "en" | "ru" | "ar",
});
}, []);
}Вызывай хук в верхнем компоненте под приватным роутом — там, где гарантированно есть auth-контекст (например, в _layout.tsx / AuthenticatedShell.tsx).
4. Синхронизация языка приложения и виджета
Если у тебя свой LanguageContext/i18n, прокидывай язык в виджет при каждой смене:
const changeLanguage = (lang: FwLang) => {
window.fw?.("setLanguage", lang); // ← синхронизируем виджет
i18n.changeLanguage(lang);
setCurrentLanguage(lang);
sessionStorage.setItem("language", lang);
};Подробнее про управление языком — раздел Управление языком.
Init-конфиг
| Поле | Тип | Обязательно | Поведение |
|-------------|------------------------------|-------------|--------------------------------------------------------------------------------------------|
| token | string | да | JWT. Принимается как "<jwt>", так и "Bearer <jwt>" — префикс срезается автоматически. |
| partnerId | number \| string | да | Идентификатор партнёра. Используется в URL запросов: /partner/{partnerId}/.... |
| baseUrl | string | условно | Базовый URL API. Можно пропустить, если бандл собран с VITE_API_BASE_URL (см. ниже). |
| lang | 'en' \| 'ru' \| 'ar' | нет | Стартовый язык. По умолчанию — детект из navigator.language, fallback 'en'. |
| hasAccess | boolean | нет | Управление доступом к виджету. true (по умолчанию) — виджет видимый и функционален. false — виджет не рендерится. |
baseUrl: build-time vs init-time
- Build-time — если виджет собирается с
VITE_API_BASE_URL=https://...в окружении, этот URL запекается в бандл, и хост-приложение может не передаватьbaseUrlвinit(). - Init-time — если
VITE_API_BASE_URLне задан на сборке,baseUrlобязателен вinit(). Без него виджет не смонтируется и в консоль уйдёт[fw] Widget not mounted: baseUrl is not configured. - Значение из
init()всегда перекрывает build-time дефолт.
Повторный init()
Повторный вызов init() обновит конфиг (новый токен/partnerId/baseUrl), но не делает remount — DOM-узел остаётся прежним, виджет продолжает работать. Удобно для рефреша JWT.
Команды window.fw
| Команда | Описание |
|-------------------------------|-------------------------------------------|
| fw('init', config) | Настроить API и смонтировать виджет |
| fw('open') | Открыть панель |
| fw('close') | Закрыть панель |
| fw('toggle') | Переключить open/close |
| fw('setLanguage', lang) | Сменить язык ('en' / 'ru' / 'ar') |
| fw('setAccess', hasAccess) | Управлять доступом к виджету (true / false) |
| fw('destroy') | Полностью удалить виджет из DOM и очистить подписки |
Все вызовы, сделанные до того как скрипт догрузился, попадают в очередь window.fw.q и проигрываются автоматически — порядок сохраняется.
Управление языком
Три способа взаимодействия:
// 1) Программная смена (триггерит и виджет, и подписчиков)
window.fw('setLanguage', 'ru');
// 2) Подписка на смену (возвращает функцию unsubscribe)
const unsubscribe = window.fw.onLanguageChange((lang) => {
console.log('language is now', lang);
});
unsubscribe();
// 3) Подписка через DOM-событие
window.addEventListener('fw:languageChanged', (e) => {
console.log(e.detail.lang); // 'en' | 'ru' | 'ar'
});
// Текущий язык
const lang = window.fw.getLanguage();Стартовое определение: navigator.language.split('-')[0] → 'ru' / 'ar' распознаются, всё остальное → 'en'. Передача lang в init() перекрывает детект.
Управление доступом к виджету
Виджет поддерживает динамическое управление доступом через параметр hasAccess. Когда hasAccess: false, виджет полностью скрывается и не рендерится:
// 1) При инициализации
window.fw('init', {
token: '...',
partnerId: 42,
hasAccess: false, // ← виджет скрыт до изменения
});
// 2) Изменить доступ в рантайме
window.fw('setAccess', true); // ← виджет теперь виден и функционален
window.fw('setAccess', false); // ← виджет скрыт сноваТипичный сценарий: скрыть виджет для пользователей на бесплатном тарифе, включить для платных подписок:
const userTier = await getCurrentUserTier();
window.fw?.('setAccess', userTier === 'premium');Поведение и lifecycle
- Mount target: при первом
init()создаётся<div id="feedback-widget-host">вdocument.body, к нему подключается Shadow DOM (mode: 'open'), внутри инжектится<style>со скомпилированным Tailwind. Никакие селекторы хоста туда не дотянутся. - Radix-порталы (dropdown-меню, диалоги) рендерятся внутрь shadow-root через
PortalContainerContext— стилевая изоляция сохраняется. - Guard'ы:
- нет
tokenилиpartnerId→initотклоняется, лог[fw] init() requires token and partnerId - нет
baseUrl(ни в build, ни в init) → mount отменяется, лог[fw] Widget not mounted: baseUrl is not configured
- нет
- Хоткей
⌘J/Ctrl+J— кроссплатформенный (metaKey || ctrlKey),preventDefaultсрабатывает только если виджет уже смонтирован. - RTL: при
lang === 'ar'shadow-root получает атрибутdir="rtl". Tailwind-классы используют логические свойства (ps-*,pe-*,start-*,end-*), так что зеркалирование работает само. - Очистка: команда
destroyполностью удаляет виджет из DOM, отписывает все слушатели и сбрасывает внутреннее состояние. После этого можно снова вызватьinit()для переинициализации.
Контракт с бэкендом
Виджет ходит исключительно по эндпоинтам тикетинга:
| Метод | Путь |
|---------|--------------------------------------------------------------------|
| GET | /partner/{partnerId}/notion_ticketing/tickets |
| GET | /partner/{partnerId}/notion_ticketing/tickets/{ticketId} |
| POST | /partner/{partnerId}/notion_ticketing/tickets (multipart/form-data) |
| PATCH | /partner/{partnerId}/notion_ticketing/tickets/{ticketId} |
| DELETE| /partner/{partnerId}/notion_ticketing/tickets/{ticketId} |
- Базовый URL —
apiConfig.baseUrl(build-time или init-time, см. выше). - Заголовок
Authorization: Bearer <token>добавляется на каждый запрос — токен читается изapiConfigв момент запроса, поэтому переинициализация виджета сразу же подхватывается. - Timeout: 15 секунд.
- Ошибки нормализуются в
ApiError(status, message, data)— для хоста наружу не торчат, но видны в DevTools при сетевых сбоях.
Создание тикета (POST)
multipart/form-data, поля:
payload— JSON-строка с{ subject, priority, status, category, problem }attachments— повторяющийсяFile(опционально, до 5 файлов, до 25 МБ каждый; форматы PNG/JPG/MP4/WEBM/PDF — лимиты проверяются на клиенте)
Справочник: статусы и приоритеты
Используются при отображении и фильтрации тикетов.
Статусы:
To do, BackLog, In progress, In review, Testing, Bug, On hold, Completed, ArchivedОткрытыми считаются: To do, BackLog, In progress, In review, Testing, Bug, On hold (вкладка «Open» в виджете фильтрует по этому списку).
Приоритеты:
Low, Medium, High, Super HighВерсионирование CDN URL
| Шаблон | Когда использовать |
|------------------------------------------------------------------------------------|-------------------------------------------------------------|
| https://cdn.jsdelivr.net/npm/@zyplai/[email protected]/dist/widget.iife.js | Прод. Immutable-кэш на год, версия зафиксирована. |
| https://cdn.jsdelivr.net/npm/@zyplai/[email protected]/dist/widget.iife.js | Авто-патчи внутри минора. Можно для прода, если доверяешь semver. |
| https://cdn.jsdelivr.net/npm/@zyplai/support-widget@latest/dist/widget.iife.js | Только dev/demo. Может сломаться при мажоре. |
Сменить версию = поменять число в <script src=...>. Никаких пересборок хоста не требуется.
Локальная разработка
npm install
npm run dev # Vite dev server (использует src/main.tsx + index.html)
npm run build # tsc type-check + Vite IIFE → dist/widget.iife.js
npm run preview # превью продакшен-сборкиВиджет имеет два entry-point'а:
src/main.tsx— обычный Vite-app для dev-режима (рендерит<App>в#app).src/widget.tsx— продакшен-entry, билдится вdist/widget.iife.js. Регистрируетwindow.fw(...)и монтирует виджет в Shadow DOM.
Ручная проверка
После npm run build открой test.html в браузере — он подгружает локальный dist/widget.iife.js и вызывает fw('init', {...}) с тестовыми token/partnerId/baseUrl. Заменяй значения на свои перед тестом.
