diff --git a/.skaro/tasks/T-021-loader-primenyaet-oformlenie-knopki.md b/.skaro/tasks/T-021-loader-primenyaet-oformlenie-knopki.md
new file mode 100644
index 0000000..ef7befe
--- /dev/null
+++ b/.skaro/tasks/T-021-loader-primenyaet-oformlenie-knopki.md
@@ -0,0 +1,31 @@
+---
+id: T-021
+title: Лоадер применяет оформление кнопки
+milestone: M04
+status: done
+depends_on:
+ - T-020
+order: 3
+spec: "0021"
+created: 2026-09-29
+branch: skaro/T-021-loader-primenyaet-oformlenie-k
+---
+
+## Цель
+
+Кнопка на сайте получает цвет, иконку, положение, размер и форму из конфигурации (R-5).
+
+## Критерии приёмки
+
+- [x] Лоадер запрашивает публичную конфигурацию и применяет accent (включая тень и фокус), иконку, left/right, 48/56/64, circle/rounded/square
+- [x] Панель открывается над кнопкой с той же стороны; анимация «джин» и полноэкранный режим на телефоне работают с обеих сторон
+- [x] Ошибка конфигурации не ломает страницу: кнопка остаётся в стандартном виде
+- [x] Проверено на тестовой странице для обеих сторон и трёх размеров
+
+## Заметки
+
+webchat/loader.py — цвет сейчас зашит #1677ff, в genie-анимации тоже (paintGenie).
+
+## Итог
+
+Ветка T-021 перенесена через git rebase main на 223a962c, конфликт views.py разрешён. Общие публичные обработчики используются из access.py свежей main, дубликат public_views.py удалён; передача preChatFields сохранена, обработчики конфигурации с CORS и лоадера сохранены. Новый коммит a0eb9f31: связанные проверки прошли, исходные настройки dev-подключения восстановлены.
diff --git a/apps/backend/chatballs/webchat/config_views.py b/apps/backend/chatballs/webchat/config_views.py
new file mode 100644
index 0000000..14900c5
--- /dev/null
+++ b/apps/backend/chatballs/webchat/config_views.py
@@ -0,0 +1,35 @@
+"""Публичная конфигурация для iframe и лоадера на сайте организации."""
+
+from django.utils.cache import patch_vary_headers
+from rest_framework.request import Request
+from rest_framework.response import Response
+
+from chatballs.webchat import services
+from chatballs.webchat.access import _Public, _resolved_web_widget
+from chatballs.webchat.api_inputs import host_origin
+from chatballs.webchat.throttling import WebchatConfigThrottle
+
+
+class WebchatConfigView(_Public):
+ throttle_classes = [WebchatConfigThrottle]
+
+ def get(self, request: Request) -> Response:
+ widget_key = request.GET.get("widgetKey", "")
+ channel_code = request.GET.get("channel", "")
+ with _resolved_web_widget(widget_key, channel_code) as (context, widget):
+ if context is None or widget is None:
+ response = Response({"available": False})
+ else:
+ response = Response(
+ services.public_config(
+ context=context, widget=widget, origin=host_origin(request),
+ )
+ )
+ # Лоадер работает на другом домене. CORS разрешается только для
+ # конфигурации доступного этому сайту виджета, без cookies и сессии.
+ origin = request.headers.get("Origin")
+ if origin and response.data.get("available"):
+ response["Access-Control-Allow-Origin"] = origin
+ patch_vary_headers(response, ["Origin"])
+ response["Cache-Control"] = "no-cache"
+ return response
diff --git a/apps/backend/chatballs/webchat/loader.py b/apps/backend/chatballs/webchat/loader.py
index 35c76db..8a441c0 100644
--- a/apps/backend/chatballs/webchat/loader.py
+++ b/apps/backend/chatballs/webchat/loader.py
@@ -1,362 +1,14 @@
-# Публичный JS-лоадер виджета (SPEC-CHATBALLS-0003 §3). Подключается одним тегом:
-#
-# Лоадер рисует launcher и открывает панель в изолированном iframe (/chat/).
-#
-# TODO (security): для production настроить CSP `frame-ancestors` для /chat/
-# (раздаётся vite/nginx, не Django — настраивается в infra/deploy), разрешив
-# домены сайтов, где стоит виджет. Домены — у владельца.
+"""Публичный автономный JS-лоадер: оформление, панель и анимация джина.
-LOADER_JS = r"""
-(function () {
- var script = document.currentScript;
- if (!script) return;
- var widgetKey = script.getAttribute("data-widget-key") || "";
- var legacyChannel = script.getAttribute("data-channel") || "";
- if (!widgetKey && !legacyChannel) return;
- var origin = new URL(script.src, location.href).origin;
- var instanceId = "chat_" + Math.random().toString(36).slice(2);
- var entryQuery = widgetKey
- ? "widgetKey=" + encodeURIComponent(widgetKey)
- : "channel=" + encodeURIComponent(legacyChannel);
- var panelUrl = origin + "/chat/?" + entryQuery + "&instanceId=" + encodeURIComponent(instanceId);
-
- var open = false, frame = null, shell = null, unread = false, callActive = false, expanded = false;
- var contentTimer = 0;
- var api = window.ChatballsChat = window.ChatballsChat || {};
- window.ChatballsChat = api; // legacy alias для уже встроенных хостов
-
- var style = document.createElement("style");
- style.textContent = "@keyframes chatballs-chat-pop{from{opacity:0;transform:scale(.55) rotate(-25deg)}to{opacity:1;transform:none}}.chatballs-chat-launcher svg{animation:chatballs-chat-pop .24s cubic-bezier(.16,1,.3,1)}@keyframes chatballs-chat-blink{0%,92%,100%{transform:scaleY(1)}95%{transform:scaleY(.12)}}.chatballs-chat-eyes{transform-origin:24px 20px;animation:chatballs-chat-blink 4.2s infinite}@keyframes chatballs-chat-message-bump{0%,100%{transform:translateY(0)}35%{transform:translateY(-6px)}70%{transform:translateY(-2px)}}@keyframes chatballs-chat-call-shake{0%,18%,100%{transform:translateX(0)}3%{transform:translateX(-5px)}6%{transform:translateX(5px)}9%{transform:translateX(-4px)}12%{transform:translateX(4px)}15%{transform:translateX(-2px)}}.chatballs-chat-message-bump{animation:chatballs-chat-message-bump .42s ease-out}.chatballs-chat-call-shake{animation:chatballs-chat-call-shake 3.2s ease-in-out infinite}.chatballs-chat-launcher:hover{transform:translateY(-2px);box-shadow:0 12px 30px rgba(22,119,255,0.45)}.chatballs-chat-launcher:focus-visible{outline:3px solid rgba(22,119,255,0.45);outline-offset:2px}";
- (document.head || document.documentElement).appendChild(style);
-
- // Launcher — круглая кнопка со знаком агента. Знак вшит как inline SVG:
- // на сайте, где стоит виджет, наших файлов нет. Пока панель открыта, кнопка
- // остаётся на месте и показывает шеврон — им же панель и закрывают.
- var BOT_ICON = '';
- var CHEVRON_ICON = '';
-
- var btn = document.createElement("button");
- btn.className = "chatballs-chat-launcher";
- btn.type = "button";
- btn.setAttribute("aria-label", "Открыть чат");
- btn.style.cssText = "position:fixed;right:24px;bottom:24px;width:56px;height:56px;padding:0;border:none;border-radius:50%;background:#1677ff;box-shadow:0 8px 24px rgba(22,119,255,0.35);cursor:pointer;z-index:2147483002;display:flex;align-items:center;justify-content:center;color:#fff;transition:transform .18s ease,box-shadow .18s ease,opacity .14s ease;";
- btn.innerHTML = BOT_ICON;
-
- var dot = document.createElement("span");
- dot.style.cssText = "position:absolute;top:-2px;right:-2px;width:12px;height:12px;border-radius:50%;background:#faad14;border:2px solid #fff;display:none;";
- btn.appendChild(dot);
-
- var notification = new Audio(origin + "/chat/audio/notification.mp3");
- var ringtone = new Audio(origin + "/chat/audio/ringtone.mp3");
- notification.preload = "auto";
- ringtone.preload = "auto";
- ringtone.loop = true;
- ringtone.volume = 1;
-
- function play(audio) {
- var result = audio.play();
- if (result && result.catch) result.catch(function () {});
- }
-
- function updateDot() {
- dot.style.display = !open && (unread || callActive) ? "block" : "none";
- }
-
- function bumpLauncher() {
- btn.classList.remove("chatballs-chat-message-bump");
- void btn.offsetWidth;
- btn.classList.add("chatballs-chat-message-bump");
- window.setTimeout(function () { btn.classList.remove("chatballs-chat-message-bump"); }, 450);
- }
-
- function setCallActive(active) {
- callActive = active;
- btn.classList.toggle("chatballs-chat-call-shake", active);
- if (active) play(ringtone);
- else {
- ringtone.pause();
- ringtone.currentTime = 0;
- }
- updateDot();
- }
-
- function ensureFrame() {
- if (frame) return;
- shell = document.createElement("div");
- shell.style.cssText = "position:fixed;right:24px;bottom:92px;width:" + PANEL_WIDTH.normal + ";height:" + PANEL_HEIGHT.normal + ";z-index:2147483001;display:none;transition:width .32s cubic-bezier(.4,0,.2,1),height .32s cubic-bezier(.4,0,.2,1);";
- frame = document.createElement("iframe");
- frame.src = panelUrl;
- frame.title = "Чат";
- frame.style.cssText = "width:100%;height:100%;border:none;border-radius:24px;box-shadow:0 12px 40px rgba(0,0,0,0.18);background:transparent;display:block;transform-origin:0 0;";
- shell.appendChild(frame);
- layoutShell();
- // Панель может открыться раньше, чем загрузится iframe — тогда сообщение
- // «открыто» (по нему виджет доскроллит ленту вниз) будет потеряно, поэтому
- // повторяем его на load.
- frame.addEventListener("load", function () {
- notifyLayout();
- if (open) notifyOpened();
- });
- document.body.appendChild(shell);
- window.addEventListener("message", function (e) {
- if (e.origin !== origin || !frame || e.source !== frame.contentWindow) return;
- var d = e.data || {};
- if (d.instanceId && d.instanceId !== instanceId) return;
- if (d.type === "chatballs-chat-close") setOpen(false);
- if (d.type === "chatballs-chat-layout-request") notifyLayout();
- if (d.type === "chatballs-chat-expand") setExpanded(Boolean(d.expanded));
- if (d.type === "chatballs-chat-unread") {
- unread = Boolean(d.unread);
- updateDot();
- }
- if (d.type === "chatballs-chat-activity" && d.kind === "message") {
- unread = !open;
- updateDot();
- if (!open && !callActive) bumpLauncher();
- notification.currentTime = 0;
- play(notification);
- }
- if (d.type === "chatballs-chat-activity" && d.kind === "call") setCallActive(Boolean(d.active));
- });
- }
-
- // Развёрнутая панель почти вдвое шире и немного выше; переход анимируется
- // самим iframe (transition в его стилях).
- var PANEL_WIDTH = { normal: "min(440px,calc(100vw - 32px))", expanded: "min(820px,calc(100vw - 32px))" };
- var PANEL_HEIGHT = { normal: "min(720px,calc(100vh - 116px))", expanded: "min(820px,calc(100vh - 116px))" };
-
- // На телефоне плавающее окно не помещается: панель занимает весь экран,
- // разворачивать её уже некуда, а закрывают её кнопкой в шапке.
- var mobile = window.matchMedia("(max-width: 480px)");
-
- function panelRadius() { return mobile.matches ? 0 : 24; }
-
- function layoutShell() {
- if (!shell) return;
- var full = mobile.matches;
- shell.style.top = full ? "0" : "";
- shell.style.left = full ? "0" : "";
- shell.style.right = full ? "0" : "24px";
- shell.style.bottom = full ? "0" : "92px";
- shell.style.width = full ? "100%" : expanded ? PANEL_WIDTH.expanded : PANEL_WIDTH.normal;
- shell.style.height = full ? "100%" : expanded ? PANEL_HEIGHT.expanded : PANEL_HEIGHT.normal;
- frame.style.borderRadius = panelRadius() + "px";
- showLauncher();
- notifyLayout();
- }
-
- // Сама панель ширину экрана не видит: в iframe её вьюпорт — это окно
- // виджета. Поэтому про полноэкранный режим ей говорит лоадер.
- function notifyLayout() {
- try { frame.contentWindow.postMessage({ type: "chatballs-chat-layout", fullscreen: mobile.matches }, origin); } catch (_) {}
- }
-
- // Пока панель на весь экран, кнопка висела бы поверх поля ввода.
- function showLauncher() {
- var hidden = open && mobile.matches;
- btn.style.opacity = hidden ? "0" : "1";
- btn.style.pointerEvents = hidden ? "none" : "";
- }
-
- if (mobile.addEventListener) mobile.addEventListener("change", layoutShell);
- else if (mobile.addListener) mobile.addListener(layoutShell);
-
- function setExpanded(next) {
- expanded = next;
- layoutShell();
- }
-
- // Эффект джина: окно втягивается в кнопку, вытягивая горловину, — как в
- // доке macOS. Аффинными трансформациями такое не получить: там изгиб, а не
- // масштаб. Поэтому силуэт рисуется контуром SVG и перестраивается на каждом
- // кадре. Сам iframe в анимации не участвует — менять его размеры покадрово
- // нельзя, внутри переворачивалась бы вёрстка.
- var GENIE_OPEN_MS = 300;
- var GENIE_CLOSE_MS = 240;
- var SVG_NS = "http://www.w3.org/2000/svg";
- var genieSvg = null, geniePath = null, genieRaf = 0;
-
- function ensureGenie() {
- if (genieSvg) return;
- genieSvg = document.createElementNS(SVG_NS, "svg");
- genieSvg.style.cssText = "position:fixed;left:0;top:0;width:100%;height:100%;z-index:2147482999;pointer-events:none;display:none;overflow:visible;";
- geniePath = document.createElementNS(SVG_NS, "path");
- geniePath.style.filter = "drop-shadow(0 12px 40px rgba(0,0,0,0.18))";
- genieSvg.appendChild(geniePath);
- document.body.appendChild(genieSvg);
- }
-
- function smooth(x) {
- if (x <= 0) return 0;
- if (x >= 1) return 1;
- return x * x * (3 - 2 * x);
- }
-
- function mix(a, b, k) { return a + (b - a) * k; }
-
- function shapeAt(p, panel, icon) {
- // Низ втягивается раньше верха — из-за этого и появляется горловина.
- var pb = smooth(Math.min(1, p * 1.7));
- var pt = smooth(Math.max(0, (p - 0.3) / 0.7));
-
- var topY = mix(panel.top, icon.top, pt);
- var topHalf = mix(panel.half, icon.half, pt);
- var topCx = mix(panel.cx, icon.cx, pt);
- var topR = Math.min(mix(panel.radius, icon.half, pt), topHalf);
-
- var bottomY = mix(panel.bottom, icon.bottom, pb);
- var bottomHalf = mix(panel.half, icon.half, pb);
- var bottomCx = mix(panel.cx, icon.cx, pb);
- var bottomR = Math.min(mix(panel.radius, icon.half, pb), bottomHalf);
-
- return {
- topY: topY, topL: topCx - topHalf, topRt: topCx + topHalf, topR: topR,
- bottomY: bottomY, bottomL: bottomCx - bottomHalf, bottomRt: bottomCx + bottomHalf, bottomR: bottomR,
- // Плечо кривой: чем длиннее горловина, тем мягче изгиб.
- k: Math.max(0, (bottomY - topY) * 0.45),
- fill: pt
- };
- }
-
- function pathOf(g, dx, dy) {
- var tl = g.topL - dx, tr = g.topRt - dx, ty = g.topY - dy;
- var bl = g.bottomL - dx, br = g.bottomRt - dx, by = g.bottomY - dy;
- return "M" + (tl + g.topR) + "," + ty +
- "H" + (tr - g.topR) +
- "Q" + tr + "," + ty + " " + tr + "," + (ty + g.topR) +
- "C" + tr + "," + (ty + g.k) + " " + br + "," + (by - g.k) + " " + br + "," + (by - g.bottomR) +
- "Q" + br + "," + by + " " + (br - g.bottomR) + "," + by +
- "H" + (bl + g.bottomR) +
- "Q" + bl + "," + by + " " + bl + "," + (by - g.bottomR) +
- "C" + bl + "," + (by - g.k) + " " + tl + "," + (ty + g.k) + " " + tl + "," + (ty + g.topR) +
- "Q" + tl + "," + ty + " " + (tl + g.topR) + "," + ty + "Z";
- }
-
- function paintGenie(p, panel, icon) {
- var g = shapeAt(p, panel, icon);
- geniePath.setAttribute("d", pathOf(g, 0, 0));
- geniePath.setAttribute(
- "fill",
- "rgb(" + Math.round(mix(255, 22, g.fill)) + "," +
- Math.round(mix(255, 119, g.fill)) + "," +
- Math.round(mix(255, 255, g.fill)) + ")"
- );
- // Контур режет контейнер. Его собственная геометрия не меняется, поэтому
- // координаты контура честно переводятся в его систему простым сдвигом.
- var box = shell.getBoundingClientRect();
- shell.style.clipPath = "path('" + pathOf(g, box.left, box.top) + "')";
-
- // Окно внутри повторяет форму: ширина идёт за горловиной, высота — за
- // длиной силуэта, наклон — за уходом центра к кнопке. Содержимое из-за
- // этого сжимается и заваливается вместе с окном, а не стоит на месте.
- var width = Math.max(1, box.width);
- var height = Math.max(1, box.height);
- var sx = Math.max(0.001, (g.topRt - g.topL) / width);
- var sy = Math.max(0.001, (g.bottomY - g.topY) / height);
- var lean = ((g.bottomL + g.bottomRt) / 2 - (g.topL + g.topRt) / 2) /
- Math.max(1, g.bottomY - g.topY);
- var skew = Math.atan((sy / sx) * lean) * 180 / Math.PI;
- frame.style.transform = "translate(" + (g.topL - box.left) + "px," + (g.topY - box.top) + "px)" +
- " scale(" + sx + "," + sy + ") skewX(" + skew + "deg)";
- frame.style.borderRadius = Math.round(mix(panelRadius(), icon.half, g.fill)) + "px";
- }
-
- function restPanel() {
- shell.style.clipPath = "none";
- frame.style.transform = "none";
- frame.style.borderRadius = panelRadius() + "px";
- }
-
- function playGenie(from, to, duration, panel, icon, done) {
- ensureGenie();
- genieSvg.style.display = "block";
- var started = 0;
- window.cancelAnimationFrame(genieRaf);
- function step(now) {
- if (!started) started = now;
- var linear = Math.min(1, (now - started) / duration);
- // Мягкий вход и выход: резкий старт читается как рывок.
- var eased = linear < 0.5 ? 2 * linear * linear : 1 - Math.pow(-2 * linear + 2, 2) / 2;
- paintGenie(mix(from, to, eased), panel, icon);
- if (linear < 1) genieRaf = window.requestAnimationFrame(step);
- else done();
- }
- genieRaf = window.requestAnimationFrame(step);
- }
-
- function panelRect() {
- var box = shell.getBoundingClientRect();
- return {
- top: box.top,
- bottom: box.bottom,
- cx: box.left + box.width / 2,
- half: box.width / 2,
- radius: panelRadius()
- };
- }
-
- function iconRect() {
- var box = btn.getBoundingClientRect();
- return {
- top: box.top,
- bottom: box.bottom,
- cx: box.left + box.width / 2,
- half: box.width / 2
- };
- }
-
- function notifyOpened() {
- try { frame.contentWindow.postMessage({ type: "chatballs-chat-opened" }, origin); } catch (_) {}
- }
-
- function setOpen(next) {
- ensureFrame();
- // Слой силуэта нужен уже на первом кадре: первый же кадр рисуется до
- // запуска анимации, чтобы панель не мелькнула целиком.
- ensureGenie();
- open = next;
- window.clearTimeout(contentTimer);
- btn.style.opacity = "0";
- btn.style.pointerEvents = "none";
- if (open) {
- // Контейнер раскладывается до расчёта: силуэт строится по настоящим
- // границам панели, а они известны только разложенной.
- shell.style.display = "block";
- paintGenie(1, panelRect(), iconRect());
- playGenie(1, 0, GENIE_OPEN_MS, panelRect(), iconRect(), function () {
- if (!open) return;
- genieSvg.style.display = "none";
- restPanel();
- showLauncher();
- });
- } else {
- var box = panelRect();
- playGenie(0, 1, GENIE_CLOSE_MS, box, iconRect(), function () {
- if (open) return;
- genieSvg.style.display = "none";
- shell.style.display = "none";
- restPanel();
- showLauncher();
- });
- }
- btn.innerHTML = open ? CHEVRON_ICON : BOT_ICON;
- btn.setAttribute("aria-label", open ? "Свернуть чат" : "Открыть чат");
- btn.appendChild(dot);
- if (open) {
- unread = false;
- updateDot();
- if (callActive) play(ringtone);
- notifyOpened();
- } else updateDot();
- }
-
- btn.addEventListener("click", function () { setOpen(!open); });
- function mount() {
- document.body.appendChild(btn);
- ensureFrame();
- }
- if (document.body) mount();
- else window.addEventListener("DOMContentLoaded", mount);
-})();
+Модули собираются в одну IIFE; дополнительных запросов за JS на сайте нет.
"""
+
+from pathlib import Path
+
+_ASSETS = Path(__file__).with_name("loader_assets")
+LOADER_JS = (_ASSETS / "runtime.js").read_text(encoding="utf-8")
+for _module in ("appearance", "launcher", "genie"):
+ LOADER_JS = LOADER_JS.replace(
+ f"/*__{_module.upper()}__*/",
+ (_ASSETS / f"{_module}.js").read_text(encoding="utf-8"),
+ )
diff --git a/apps/backend/chatballs/webchat/loader_assets/appearance.js b/apps/backend/chatballs/webchat/loader_assets/appearance.js
new file mode 100644
index 0000000..2a8f153
--- /dev/null
+++ b/apps/backend/chatballs/webchat/loader_assets/appearance.js
@@ -0,0 +1,89 @@
+ // Публичная конфигурация не должна мешать загрузке страницы хоста.
+ var appearance = { accent: "#1677ff", rgb: [22, 119, 255], position: "right", size: 56, shape: "circle" };
+ var launcherIcon = null;
+
+ function choice(value, allowed, fallback) {
+ if (value === undefined) return fallback;
+ if (allowed.indexOf(value) < 0) throw new Error("Invalid launcher appearance");
+ return value;
+ }
+
+ function readAppearance(config) {
+ if (!config || config.available !== true) return null;
+ var raw = config.appearance === undefined ? {} : config.appearance;
+ if (!raw || typeof raw !== "object" || Array.isArray(raw)) throw new Error("Invalid appearance");
+ var accent = raw.accent || config.accent || "#1677ff";
+ if (typeof accent !== "string" || !/^#[0-9a-f]{6}$/i.test(accent)) throw new Error("Invalid accent");
+ var icon = raw.launcherIcon || "";
+ if (typeof icon !== "string") throw new Error("Invalid launcher icon");
+ if (icon) {
+ if (icon.length > 2048 || /[\x00-\x20\x7f\\]/.test(icon) ||
+ !(/^(https?:\/\/|\/(?!\/))/i.test(icon))) throw new Error("Invalid launcher icon URL");
+ icon = new URL(icon, origin).href;
+ }
+ return {
+ accent: accent,
+ rgb: [1, 3, 5].map(function (offset) { return parseInt(accent.slice(offset, offset + 2), 16); }),
+ position: choice(raw.launcherPosition, ["left", "right"], "right"),
+ size: choice(raw.launcherSize, [48, 56, 64], 56),
+ shape: choice(raw.launcherShape, ["circle", "rounded", "square"], "circle"),
+ icon: icon
+ };
+ }
+
+ function launcherRadius() {
+ return appearance.shape === "square" ? 10 : appearance.size * (appearance.shape === "rounded" ? 0.3 : 0.5);
+ }
+
+ function renderLauncher() {
+ btn.innerHTML = open ? CHEVRON_ICON : BOT_ICON;
+ if (!open && launcherIcon) {
+ btn.textContent = "";
+ btn.appendChild(launcherIcon);
+ }
+ btn.appendChild(dot);
+ }
+
+ function applyAppearance(next) {
+ if (animating) {
+ pendingAppearance = next;
+ return;
+ }
+ appearance = next;
+ btn.style.setProperty("--chatballs-accent", next.accent);
+ btn.style.setProperty("--chatballs-accent-rgb", next.rgb.join(","));
+ btn.style.left = next.position === "left" ? "24px" : "";
+ btn.style.right = next.position === "right" ? "24px" : "";
+ btn.style.width = btn.style.height = next.size + "px";
+ btn.style.borderRadius = next.shape === "square" ? "10px" : next.shape === "rounded" ? "30%" : "50%";
+ layoutShell();
+ if (!next.icon) return;
+ // Загружаем как изображение, не вставляем SVG/URL в HTML страницы хоста.
+ var image = document.createElement("img");
+ image.alt = "";
+ image.style.cssText = "width:30px;height:30px;object-fit:contain;flex:none;";
+ image.onload = function () {
+ launcherIcon = image;
+ renderLauncher();
+ };
+ image.onerror = function () {
+ launcherIcon = null;
+ renderLauncher();
+ };
+ image.src = next.icon;
+ }
+
+ function loadAppearance() {
+ if (!window.fetch) return;
+ var configUrl = origin + "/api/v1/webchat/config/?" + entryQuery + "&hostOrigin=" + encodeURIComponent(location.origin);
+ window.fetch(configUrl, { cache: "no-cache", credentials: "omit" })
+ .then(function (response) {
+ if (!response.ok) throw new Error("Widget configuration unavailable");
+ return response.json();
+ })
+ .then(function (config) {
+ var next = readAppearance(config);
+ if (next) applyAppearance(next);
+ })
+ .catch(function () { /* Стандартная кнопка уже смонтирована. */ });
+ }
diff --git a/apps/backend/chatballs/webchat/loader_assets/genie.js b/apps/backend/chatballs/webchat/loader_assets/genie.js
new file mode 100644
index 0000000..eea391d
--- /dev/null
+++ b/apps/backend/chatballs/webchat/loader_assets/genie.js
@@ -0,0 +1,139 @@
+ // Эффект джина: окно втягивается в кнопку, вытягивая горловину, — как в
+ // доке macOS. Аффинными трансформациями такое не получить: там изгиб, а не
+ // масштаб. Поэтому силуэт рисуется контуром SVG и перестраивается на каждом
+ // кадре. Сам iframe в анимации не участвует — менять его размеры покадрово
+ // нельзя, внутри переворачивалась бы вёрстка.
+ var GENIE_OPEN_MS = 300;
+ var GENIE_CLOSE_MS = 240;
+ var SVG_NS = "http://www.w3.org/2000/svg";
+ var genieSvg = null, geniePath = null, genieRaf = 0;
+
+ function ensureGenie() {
+ if (genieSvg) return;
+ genieSvg = document.createElementNS(SVG_NS, "svg");
+ genieSvg.style.cssText = "position:fixed;left:0;top:0;width:100%;height:100%;z-index:2147482999;pointer-events:none;display:none;overflow:visible;";
+ geniePath = document.createElementNS(SVG_NS, "path");
+ geniePath.style.filter = "drop-shadow(0 12px 40px rgba(0,0,0,0.18))";
+ genieSvg.appendChild(geniePath);
+ document.body.appendChild(genieSvg);
+ }
+
+ function smooth(x) {
+ if (x <= 0) return 0;
+ if (x >= 1) return 1;
+ return x * x * (3 - 2 * x);
+ }
+
+ function mix(a, b, k) { return a + (b - a) * k; }
+
+ function shapeAt(p, panel, icon) {
+ // Низ втягивается раньше верха — из-за этого и появляется горловина.
+ var pb = smooth(Math.min(1, p * 1.7));
+ var pt = smooth(Math.max(0, (p - 0.3) / 0.7));
+
+ var topY = mix(panel.top, icon.top, pt);
+ var topHalf = mix(panel.half, icon.half, pt);
+ var topCx = mix(panel.cx, icon.cx, pt);
+ var topR = Math.min(mix(panel.radius, icon.radius, pt), topHalf);
+
+ var bottomY = mix(panel.bottom, icon.bottom, pb);
+ var bottomHalf = mix(panel.half, icon.half, pb);
+ var bottomCx = mix(panel.cx, icon.cx, pb);
+ var bottomR = Math.min(mix(panel.radius, icon.radius, pb), bottomHalf);
+
+ return {
+ topY: topY, topL: topCx - topHalf, topRt: topCx + topHalf, topR: topR,
+ bottomY: bottomY, bottomL: bottomCx - bottomHalf, bottomRt: bottomCx + bottomHalf, bottomR: bottomR,
+ // Плечо кривой: чем длиннее горловина, тем мягче изгиб.
+ k: Math.max(0, (bottomY - topY) * 0.45),
+ fill: pt
+ };
+ }
+
+ function pathOf(g, dx, dy) {
+ var tl = g.topL - dx, tr = g.topRt - dx, ty = g.topY - dy;
+ var bl = g.bottomL - dx, br = g.bottomRt - dx, by = g.bottomY - dy;
+ return "M" + (tl + g.topR) + "," + ty +
+ "H" + (tr - g.topR) +
+ "Q" + tr + "," + ty + " " + tr + "," + (ty + g.topR) +
+ "C" + tr + "," + (ty + g.k) + " " + br + "," + (by - g.k) + " " + br + "," + (by - g.bottomR) +
+ "Q" + br + "," + by + " " + (br - g.bottomR) + "," + by +
+ "H" + (bl + g.bottomR) +
+ "Q" + bl + "," + by + " " + bl + "," + (by - g.bottomR) +
+ "C" + bl + "," + (by - g.k) + " " + tl + "," + (ty + g.k) + " " + tl + "," + (ty + g.topR) +
+ "Q" + tl + "," + ty + " " + (tl + g.topR) + "," + ty + "Z";
+ }
+
+ function paintGenie(p, panel, icon) {
+ var g = shapeAt(p, panel, icon);
+ geniePath.setAttribute("d", pathOf(g, 0, 0));
+ geniePath.setAttribute(
+ "fill",
+ "rgb(" + Math.round(mix(255, appearance.rgb[0], g.fill)) + "," +
+ Math.round(mix(255, appearance.rgb[1], g.fill)) + "," +
+ Math.round(mix(255, appearance.rgb[2], g.fill)) + ")"
+ );
+ // Контур режет контейнер. Его собственная геометрия не меняется, поэтому
+ // координаты контура честно переводятся в его систему простым сдвигом.
+ var box = shell.getBoundingClientRect();
+ shell.style.clipPath = "path('" + pathOf(g, box.left, box.top) + "')";
+
+ // Окно внутри повторяет форму: ширина идёт за горловиной, высота — за
+ // длиной силуэта, наклон — за уходом центра к кнопке. Содержимое из-за
+ // этого сжимается и заваливается вместе с окном, а не стоит на месте.
+ var width = Math.max(1, box.width);
+ var height = Math.max(1, box.height);
+ var sx = Math.max(0.001, (g.topRt - g.topL) / width);
+ var sy = Math.max(0.001, (g.bottomY - g.topY) / height);
+ var lean = ((g.bottomL + g.bottomRt) / 2 - (g.topL + g.topRt) / 2) /
+ Math.max(1, g.bottomY - g.topY);
+ var skew = Math.atan((sy / sx) * lean) * 180 / Math.PI;
+ frame.style.transform = "translate(" + (g.topL - box.left) + "px," + (g.topY - box.top) + "px)" +
+ " scale(" + sx + "," + sy + ") skewX(" + skew + "deg)";
+ frame.style.borderRadius = Math.round(mix(panelRadius(), icon.radius, g.fill)) + "px";
+ }
+
+ function restPanel() {
+ shell.style.clipPath = "none";
+ frame.style.transform = "none";
+ frame.style.borderRadius = panelRadius() + "px";
+ }
+
+ function playGenie(from, to, duration, panel, icon, done) {
+ ensureGenie();
+ genieSvg.style.display = "block";
+ var started = 0;
+ window.cancelAnimationFrame(genieRaf);
+ function step(now) {
+ if (!started) started = now;
+ var linear = Math.min(1, (now - started) / duration);
+ // Мягкий вход и выход: резкий старт читается как рывок.
+ var eased = linear < 0.5 ? 2 * linear * linear : 1 - Math.pow(-2 * linear + 2, 2) / 2;
+ paintGenie(mix(from, to, eased), panel, icon);
+ if (linear < 1) genieRaf = window.requestAnimationFrame(step);
+ else done();
+ }
+ genieRaf = window.requestAnimationFrame(step);
+ }
+
+ function panelRect() {
+ var box = shell.getBoundingClientRect();
+ return {
+ top: box.top,
+ bottom: box.bottom,
+ cx: box.left + box.width / 2,
+ half: box.width / 2,
+ radius: panelRadius()
+ };
+ }
+
+ function iconRect() {
+ var box = btn.getBoundingClientRect();
+ return {
+ top: box.top,
+ bottom: box.bottom,
+ cx: box.left + box.width / 2,
+ half: box.width / 2,
+ radius: launcherRadius()
+ };
+ }
diff --git a/apps/backend/chatballs/webchat/loader_assets/launcher.js b/apps/backend/chatballs/webchat/loader_assets/launcher.js
new file mode 100644
index 0000000..34a3ac6
--- /dev/null
+++ b/apps/backend/chatballs/webchat/loader_assets/launcher.js
@@ -0,0 +1,54 @@
+ var style = document.createElement("style");
+ style.textContent = "@keyframes chatballs-chat-pop{from{opacity:0;transform:scale(.55) rotate(-25deg)}to{opacity:1;transform:none}}.chatballs-chat-launcher svg{animation:chatballs-chat-pop .24s cubic-bezier(.16,1,.3,1)}@keyframes chatballs-chat-blink{0%,92%,100%{transform:scaleY(1)}95%{transform:scaleY(.12)}}.chatballs-chat-eyes{transform-origin:24px 20px;animation:chatballs-chat-blink 4.2s infinite}@keyframes chatballs-chat-message-bump{0%,100%{transform:translateY(0)}35%{transform:translateY(-6px)}70%{transform:translateY(-2px)}}@keyframes chatballs-chat-call-shake{0%,18%,100%{transform:translateX(0)}3%{transform:translateX(-5px)}6%{transform:translateX(5px)}9%{transform:translateX(-4px)}12%{transform:translateX(4px)}15%{transform:translateX(-2px)}}.chatballs-chat-message-bump{animation:chatballs-chat-message-bump .42s ease-out}.chatballs-chat-call-shake{animation:chatballs-chat-call-shake 3.2s ease-in-out infinite}.chatballs-chat-launcher{box-shadow:0 8px 24px rgba(var(--chatballs-accent-rgb),0.35)}.chatballs-chat-launcher:hover{transform:translateY(-2px);box-shadow:0 12px 30px rgba(var(--chatballs-accent-rgb),0.45)}.chatballs-chat-launcher:focus-visible{outline:3px solid rgba(var(--chatballs-accent-rgb),0.45);outline-offset:2px}";
+ (document.head || document.documentElement).appendChild(style);
+
+ // Launcher — круглая кнопка со знаком агента. Знак вшит как inline SVG:
+ // на сайте, где стоит виджет, наших файлов нет. Пока панель открыта, кнопка
+ // остаётся на месте и показывает шеврон — им же панель и закрывают.
+ var BOT_ICON = '';
+ var CHEVRON_ICON = '';
+
+ var btn = document.createElement("button");
+ btn.className = "chatballs-chat-launcher";
+ btn.type = "button";
+ btn.setAttribute("aria-label", "Открыть чат");
+ btn.style.cssText = "position:fixed;right:24px;bottom:24px;width:56px;height:56px;padding:0;border:none;border-radius:50%;--chatballs-accent-rgb:22,119,255;background:var(--chatballs-accent);--chatballs-accent:#1677ff;cursor:pointer;z-index:2147483002;display:flex;align-items:center;justify-content:center;color:#fff;transition:transform .18s ease,box-shadow .18s ease,opacity .14s ease;";
+ btn.innerHTML = BOT_ICON;
+
+ var dot = document.createElement("span");
+ dot.style.cssText = "position:absolute;top:-2px;right:-2px;width:12px;height:12px;border-radius:50%;background:#faad14;border:2px solid #fff;display:none;";
+ btn.appendChild(dot);
+
+ var notification = new Audio(origin + "/chat/audio/notification.mp3");
+ var ringtone = new Audio(origin + "/chat/audio/ringtone.mp3");
+ notification.preload = "auto";
+ ringtone.preload = "auto";
+ ringtone.loop = true;
+ ringtone.volume = 1;
+
+ function play(audio) {
+ var result = audio.play();
+ if (result && result.catch) result.catch(function () {});
+ }
+
+ function updateDot() {
+ dot.style.display = !open && (unread || callActive) ? "block" : "none";
+ }
+
+ function bumpLauncher() {
+ btn.classList.remove("chatballs-chat-message-bump");
+ void btn.offsetWidth;
+ btn.classList.add("chatballs-chat-message-bump");
+ window.setTimeout(function () { btn.classList.remove("chatballs-chat-message-bump"); }, 450);
+ }
+
+ function setCallActive(active) {
+ callActive = active;
+ btn.classList.toggle("chatballs-chat-call-shake", active);
+ if (active) play(ringtone);
+ else {
+ ringtone.pause();
+ ringtone.currentTime = 0;
+ }
+ updateDot();
+ }
diff --git a/apps/backend/chatballs/webchat/loader_assets/runtime.js b/apps/backend/chatballs/webchat/loader_assets/runtime.js
new file mode 100644
index 0000000..f9e7485
--- /dev/null
+++ b/apps/backend/chatballs/webchat/loader_assets/runtime.js
@@ -0,0 +1,173 @@
+(function () {
+ var script = document.currentScript;
+ if (!script) return;
+ var widgetKey = script.getAttribute("data-widget-key") || "";
+ var legacyChannel = script.getAttribute("data-channel") || "";
+ if (!widgetKey && !legacyChannel) return;
+ var origin = new URL(script.src, location.href).origin;
+ var instanceId = "chat_" + Math.random().toString(36).slice(2);
+ var entryQuery = widgetKey
+ ? "widgetKey=" + encodeURIComponent(widgetKey)
+ : "channel=" + encodeURIComponent(legacyChannel);
+ var panelUrl = origin + "/chat/?" + entryQuery + "&instanceId=" + encodeURIComponent(instanceId);
+
+ var open = false, frame = null, shell = null, unread = false, callActive = false, expanded = false;
+ var animating = false, pendingAppearance = null;
+ var api = window.ChatballsChat = window.ChatballsChat || {};
+ window.ChatballsChat = api; // legacy alias для уже встроенных хостов
+
+ /*__APPEARANCE__*/
+
+ /*__LAUNCHER__*/
+
+ function ensureFrame() {
+ if (frame) return;
+ shell = document.createElement("div");
+ shell.style.cssText = "position:fixed;width:" + PANEL_WIDTH.normal + ";height:" + panelHeight() + ";z-index:2147483001;display:none;transition:width .32s cubic-bezier(.4,0,.2,1),height .32s cubic-bezier(.4,0,.2,1);";
+ frame = document.createElement("iframe");
+ frame.src = panelUrl;
+ frame.title = "Чат";
+ frame.style.cssText = "width:100%;height:100%;border:none;border-radius:24px;box-shadow:0 12px 40px rgba(0,0,0,0.18);background:transparent;display:block;transform-origin:0 0;";
+ shell.appendChild(frame);
+ layoutShell();
+ // Панель может открыться раньше, чем загрузится iframe — тогда сообщение
+ // «открыто» (по нему виджет доскроллит ленту вниз) будет потеряно, поэтому
+ // повторяем его на load.
+ frame.addEventListener("load", function () {
+ notifyLayout();
+ if (open) notifyOpened();
+ });
+ document.body.appendChild(shell);
+ window.addEventListener("message", function (e) {
+ if (e.origin !== origin || !frame || e.source !== frame.contentWindow) return;
+ var d = e.data || {};
+ if (d.instanceId && d.instanceId !== instanceId) return;
+ if (d.type === "chatballs-chat-close") setOpen(false);
+ if (d.type === "chatballs-chat-layout-request") notifyLayout();
+ if (d.type === "chatballs-chat-expand") setExpanded(Boolean(d.expanded));
+ if (d.type === "chatballs-chat-unread") {
+ unread = Boolean(d.unread);
+ updateDot();
+ }
+ if (d.type === "chatballs-chat-activity" && d.kind === "message") {
+ unread = !open;
+ updateDot();
+ if (!open && !callActive) bumpLauncher();
+ notification.currentTime = 0;
+ play(notification);
+ }
+ if (d.type === "chatballs-chat-activity" && d.kind === "call") setCallActive(Boolean(d.active));
+ });
+ }
+
+ // Развёрнутая панель почти вдвое шире и немного выше; переход анимируется
+ // самим iframe (transition в его стилях).
+ var PANEL_WIDTH = { normal: "min(440px,calc(100vw - 32px))", expanded: "min(820px,calc(100vw - 32px))" };
+ function panelHeight() {
+ return "min(" + (expanded ? 820 : 720) + "px,calc(100vh - " + (appearance.size + 60) + "px))";
+ }
+
+ // На телефоне плавающее окно не помещается: панель занимает весь экран,
+ // разворачивать её уже некуда, а закрывают её кнопкой в шапке.
+ var mobile = window.matchMedia("(max-width: 480px)");
+
+ function panelRadius() { return mobile.matches ? 0 : 24; }
+
+ function layoutShell() {
+ if (!shell) return;
+ var full = mobile.matches;
+ shell.style.top = full ? "0" : "";
+ shell.style.left = full ? "0" : appearance.position === "left" ? "24px" : "";
+ shell.style.right = full ? "0" : appearance.position === "right" ? "24px" : "";
+ shell.style.bottom = full ? "0" : (appearance.size + 36) + "px";
+ shell.style.width = full ? "100%" : expanded ? PANEL_WIDTH.expanded : PANEL_WIDTH.normal;
+ shell.style.height = full ? "100%" : panelHeight();
+ frame.style.borderRadius = panelRadius() + "px";
+ showLauncher();
+ notifyLayout();
+ }
+
+ // Сама панель ширину экрана не видит: в iframe её вьюпорт — это окно
+ // виджета. Поэтому про полноэкранный режим ей говорит лоадер.
+ function notifyLayout() {
+ try { frame.contentWindow.postMessage({ type: "chatballs-chat-layout", fullscreen: mobile.matches }, origin); } catch (_) {}
+ }
+
+ // Пока панель на весь экран, кнопка висела бы поверх поля ввода.
+ function showLauncher() {
+ var hidden = open && mobile.matches;
+ btn.style.opacity = hidden ? "0" : "1";
+ btn.style.pointerEvents = hidden ? "none" : "";
+ }
+
+ if (mobile.addEventListener) mobile.addEventListener("change", layoutShell);
+ else if (mobile.addListener) mobile.addListener(layoutShell);
+
+ function setExpanded(next) {
+ expanded = next;
+ layoutShell();
+ }
+
+ /*__GENIE__*/
+
+ function notifyOpened() {
+ try { frame.contentWindow.postMessage({ type: "chatballs-chat-opened" }, origin); } catch (_) {}
+ }
+
+ function finishAnimation() {
+ animating = false;
+ genieSvg.style.display = "none";
+ restPanel();
+ showLauncher();
+ if (pendingAppearance) {
+ var next = pendingAppearance;
+ pendingAppearance = null;
+ applyAppearance(next);
+ }
+ }
+
+ function setOpen(next) {
+ ensureFrame();
+ // Слой силуэта нужен уже на первом кадре: первый же кадр рисуется до
+ // запуска анимации, чтобы панель не мелькнула целиком.
+ ensureGenie();
+ open = next;
+ animating = true;
+ btn.style.opacity = "0";
+ btn.style.pointerEvents = "none";
+ if (open) {
+ // Контейнер раскладывается до расчёта: силуэт строится по настоящим
+ // границам панели, а они известны только разложенной.
+ shell.style.display = "block";
+ paintGenie(1, panelRect(), iconRect());
+ playGenie(1, 0, GENIE_OPEN_MS, panelRect(), iconRect(), function () {
+ if (!open) return;
+ finishAnimation();
+ });
+ } else {
+ var box = panelRect();
+ playGenie(0, 1, GENIE_CLOSE_MS, box, iconRect(), function () {
+ if (open) return;
+ shell.style.display = "none";
+ finishAnimation();
+ });
+ }
+ renderLauncher();
+ btn.setAttribute("aria-label", open ? "Свернуть чат" : "Открыть чат");
+ if (open) {
+ unread = false;
+ updateDot();
+ if (callActive) play(ringtone);
+ notifyOpened();
+ } else updateDot();
+ }
+
+ btn.addEventListener("click", function () { setOpen(!open); });
+ function mount() {
+ document.body.appendChild(btn);
+ ensureFrame();
+ loadAppearance();
+ }
+ if (document.body) mount();
+ else window.addEventListener("DOMContentLoaded", mount);
+})();
diff --git a/apps/backend/chatballs/webchat/loader_views.py b/apps/backend/chatballs/webchat/loader_views.py
new file mode 100644
index 0000000..4e09dfc
--- /dev/null
+++ b/apps/backend/chatballs/webchat/loader_views.py
@@ -0,0 +1,16 @@
+"""Раздача автономного лоадера с кэшем не дольше пяти минут."""
+
+from django.http import HttpResponse
+from django.views import View
+
+from chatballs.webchat.loader import LOADER_JS
+
+LOADER_MAX_AGE_SECONDS = 300
+
+
+class WidgetLoaderView(View):
+ def get(self, request) -> HttpResponse:
+ response = HttpResponse(LOADER_JS, content_type="application/javascript; charset=utf-8")
+ # Не дольше 5 минут: лоадер применяет оформление кнопки (SPEC-0021 R-11).
+ response["Cache-Control"] = f"public, max-age={LOADER_MAX_AGE_SECONDS}"
+ return response
diff --git a/apps/backend/chatballs/webchat/test_appearance.py b/apps/backend/chatballs/webchat/test_appearance.py
index 78a96ec..f65ff6b 100644
--- a/apps/backend/chatballs/webchat/test_appearance.py
+++ b/apps/backend/chatballs/webchat/test_appearance.py
@@ -195,3 +195,34 @@ class WebAppearanceApiTests(TestCase):
def test_loader_is_cached_no_longer_than_five_minutes(self) -> None:
response = self.client.get("/chat-widget.js")
self.assertEqual(response["Cache-Control"], "public, max-age=300")
+
+ def test_allowed_site_can_read_public_config_without_credentials(self) -> None:
+ response = self.client.get(
+ "/api/v1/webchat/config/",
+ {"widgetKey": self.integration.web_chat_widget.public_key},
+ HTTP_ORIGIN="https://example.com",
+ )
+ self.assertTrue(response.json()["available"])
+ self.assertEqual(response["Access-Control-Allow-Origin"], "https://example.com")
+ self.assertFalse(response.has_header("Access-Control-Allow-Credentials"))
+ self.assertIn("Origin", response["Vary"])
+
+ def test_other_site_cannot_override_origin_or_read_config(self) -> None:
+ response = self.client.get(
+ "/api/v1/webchat/config/",
+ {
+ "widgetKey": self.integration.web_chat_widget.public_key,
+ "hostOrigin": "https://example.com",
+ },
+ HTTP_ORIGIN="https://other.example",
+ )
+ self.assertFalse(response.json()["available"])
+ self.assertFalse(response.has_header("Access-Control-Allow-Origin"))
+
+ def test_loader_host_origin_and_legacy_channel_are_supported(self) -> None:
+ response = self.client.get(
+ "/api/v1/webchat/config/",
+ {"channel": self.channel.code, "hostOrigin": "https://example.com"},
+ )
+ self.assertTrue(response.json()["available"])
+ self.assertEqual(response.json()["widgetKey"], self.integration.web_chat_widget.public_key)
diff --git a/apps/backend/chatballs/webchat/urls.py b/apps/backend/chatballs/webchat/urls.py
index a83ba80..321215a 100644
--- a/apps/backend/chatballs/webchat/urls.py
+++ b/apps/backend/chatballs/webchat/urls.py
@@ -2,10 +2,11 @@ from django.urls import path
from chatballs.webchat import views
from chatballs.webchat.asset_views import WidgetAssetView
+from chatballs.webchat.config_views import WebchatConfigView
urlpatterns = [
path("assets//", WidgetAssetView.as_view(), name="webchat-asset"),
- path("config/", views.WebchatConfigView.as_view(), name="webchat-config"),
+ path("config/", WebchatConfigView.as_view(), name="webchat-config"),
path("session/", views.WebchatSessionView.as_view(), name="webchat-session"),
path("fields/", views.WebchatFieldsView.as_view(), name="webchat-fields"),
path("messages/", views.WebchatMessagesView.as_view(), name="webchat-messages"),
diff --git a/apps/backend/chatballs/webchat/views.py b/apps/backend/chatballs/webchat/views.py
index ae6427c..2ae3567 100644
--- a/apps/backend/chatballs/webchat/views.py
+++ b/apps/backend/chatballs/webchat/views.py
@@ -1,5 +1,4 @@
-from django.http import FileResponse, HttpResponse
-from django.views import View
+from django.http import FileResponse
from rest_framework.parsers import FormParser, JSONParser, MultiPartParser
from rest_framework.request import Request
from rest_framework.response import Response
@@ -20,37 +19,10 @@ from chatballs.webchat.access import (
_resolved_web_widget,
)
from chatballs.webchat.api_inputs import host_origin
-from chatballs.webchat.loader import LOADER_JS
from chatballs.webchat.throttling import (
- WebchatConfigThrottle,
WebchatSessionIssueThrottle,
)
-LOADER_MAX_AGE_SECONDS = 300
-
-
-class WebchatConfigView(_Public):
- throttle_classes = [WebchatConfigThrottle]
-
- def get(self, request: Request) -> Response:
- widget_key = request.GET.get("widgetKey", "")
- channel_code = request.GET.get("channel", "")
- with _resolved_web_widget(widget_key, channel_code) as (context, widget):
- if context is None or widget is None:
- response = Response({"available": False})
- else:
- response = Response(
- services.public_config(
- context=context,
- widget=widget,
- origin=host_origin(request),
- )
- )
- # Оформление меняют в админке, и сайт должен увидеть его на следующей
- # загрузке страницы (SPEC-0021 R-11). Ответ к тому же зависит от Origin.
- response["Cache-Control"] = "no-cache"
- return response
-
class WebchatSessionView(_Public):
throttle_classes = [WebchatSessionIssueThrottle]
@@ -246,11 +218,3 @@ class WebchatCallDeclineView(_PublicSession):
except CallConflict as error:
return Response({"detail": str(error)}, status=409)
return Response({"ok": True})
-
-
-class WidgetLoaderView(View):
- def get(self, request) -> HttpResponse:
- response = HttpResponse(LOADER_JS, content_type="application/javascript; charset=utf-8")
- # Не дольше 5 минут: лоадер применяет оформление кнопки (SPEC-0021 R-11).
- response["Cache-Control"] = f"public, max-age={LOADER_MAX_AGE_SECONDS}"
- return response
diff --git a/apps/backend/chatballs_backend/urls_app.py b/apps/backend/chatballs_backend/urls_app.py
index 2df652a..49c0c6f 100644
--- a/apps/backend/chatballs_backend/urls_app.py
+++ b/apps/backend/chatballs_backend/urls_app.py
@@ -2,7 +2,7 @@ from django.urls import include, path
from rest_framework.schemas import get_schema_view
from chatballs.identity.demo_views import DemoMediaView
-from chatballs.webchat.views import WidgetLoaderView
+from chatballs.webchat.loader_views import WidgetLoaderView
urlpatterns = [
path("chat-widget.js", WidgetLoaderView.as_view(), name="chat-widget-loader"),
diff --git a/apps/web-chat/public/demo.html b/apps/web-chat/public/demo.html
index b4f7d54..48dabcc 100644
--- a/apps/web-chat/public/demo.html
+++ b/apps/web-chat/public/demo.html
@@ -30,6 +30,15 @@
Встраивание: <script src="/chat-widget.js" data-channel="demo" async></script>
-
+