💄 feat(webchat): виджет по утверждённому образцу и разворот окна

Виджет переложен по образцу, который дал владелец.

Лента: имя и аватар агента стоят над пузырём, у пузыря не скруглён верхний
угол со стороны автора — «хвостик». Аватар агента — знак робота на круге
цвета акцента; он же стоит в шапке вместо буквы, пока у виджета нет способа
получить логотип компании, и он же на кнопке чата вместо прежней подписи.
Пока ответ считается, в ленте висит «печатает» с бегущими точками.

Поле ввода: без рамки, скрепка слева, справа запись голосового, которая
сменяется отправкой, как только клиент начал печатать. При росте поля кнопки
держатся верхней строки. Подсказки убраны. Подвал — одного тона с лентой,
внизу «Работает на Chatballs» со ссылкой на сайт.

В шапке появилась кнопка разворота: окно становится почти вдвое шире и
немного выше. Размер держит лоадер на сайте-хосте — панель живёт в iframe и
сама себя не растянет.

Появление и исчезновение — эффект джина, как в доке macOS: окно вытягивается,
изгибается горловиной и втягивается в кнопку. Аффинными преобразованиями
такое не делается, поэтому силуэт считается контуром SVG покадрово, тем же
контуром режется контейнер панели, а само окно внутри получает матрицу,
построенную по этому силуэту, — вместе с окном деформируется и переписка.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
AndreyandClaude Opus 5 committed 2026-09-20 10:35:29 +03:00
1 parent 83b1119d6b
commit 1f46ed62bd
9 files changed
+462 -69

No files matched your search

+198 -11
View File
@@ -21,22 +21,27 @@ LOADER_JS = r"""
: "channel=" + encodeURIComponent(legacyChannel);
var panelUrl = origin + "/chat/?" + entryQuery + "&instanceId=" + encodeURIComponent(instanceId);
var open = false, frame = null, unread = false, callActive = false;
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-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}";
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 — «пилюля» с иконкой (Tabler brand-hipchat) и подписью. Иконка
// вшита как inline SVG: на сайте продукта шрифта Tabler нет.
// Launcher — круглая кнопка со знаком агента. Знак вшит как inline SVG:
// на сайте, где стоит виджет, наших файлов нет. Пока панель открыта, кнопка
// остаётся на месте и показывает шеврон — им же панель и закрывают.
var BOT_ICON = '<svg viewBox="0 0 48 48" width="30" height="30" fill="#fff" style="flex:none"><mask id="chatballs-launcher-face" maskUnits="userSpaceOnUse" x="0" y="0" width="48" height="48"><rect width="48" height="48" fill="#fff"/><g class="chatballs-chat-eyes"><rect x="15" y="16.4" width="4" height="7.2" rx="2" fill="#000"/><rect x="29" y="16.4" width="4" height="7.2" rx="2" fill="#000"/></g><path d="M17.6 26.4c1.7 2.4 3.9 3.6 6.4 3.6s4.7-1.2 6.4-3.6" fill="none" stroke="#000" stroke-width="2.6" stroke-linecap="round"/></mask><circle cx="24" cy="3.6" r="2.4"/><rect x="22.8" y="4.8" width="2.4" height="5"/><rect x="2.2" y="18" width="2.6" height="8.4" rx="1.3"/><rect x="43.2" y="18" width="2.6" height="8.4" rx="1.3"/><path mask="url(#chatballs-launcher-face)" d="M14 9.2h20a6.8 6.8 0 0 1 6.8 6.8v12.4a6.8 6.8 0 0 1-6.8 6.8H21.6l-6.4 5.2a.9.9 0 0 1-1.5-.7v-4.5h-.3A6.8 6.8 0 0 1 7.2 28.4V16A6.8 6.8 0 0 1 14 9.2Z"/></svg>';
var CHEVRON_ICON = '<svg viewBox="0 0 24 24" width="24" height="24" fill="none" stroke="#fff" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round" style="flex:none"><polyline points="6 9 12 15 18 9"/></svg>';
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;height:52px;padding:0 20px;border:none;border-radius:999px;background:#1677ff;box-shadow:0 8px 24px rgba(22,119,255,0.35);cursor:pointer;z-index:2147483000;display:flex;align-items:center;gap:9px;color:#fff;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,'Helvetica Neue',Arial,sans-serif;font-size:15px;font-weight:600;line-height:1;letter-spacing:0;transition:transform .18s ease,box-shadow .18s ease;";
btn.innerHTML = '<svg viewBox="0 0 24 24" width="22" height="22" fill="none" stroke="#fff" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="flex:none"><path d="M17.802 17.292s.077-.055.2-.149c1.843-1.425 3-3.49 3-5.789c0-4.286-4.03-7.764-9-7.764c-4.97 0-9 3.478-9 7.764c0 4.288 4.03 7.646 9 7.646c.424 0 1.12-.028 2.088-.084c1.262.82 3.104 1.493 4.716 1.493c.499 0 .734-.41.414-.828c-.486-.596-1.156-1.551-1.416-2.29z"/><path d="M7.5 13.5c2.5 2.5 6.5 2.5 9 0"/></svg><span style="white-space:nowrap">Чат</span>';
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;";
@@ -78,20 +83,24 @@ LOADER_JS = r"""
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 = "position:fixed;right:24px;bottom:24px;width:min(440px,calc(100vw - 32px));height:min(720px,calc(100vh - 48px));border:none;border-radius:16px;box-shadow:0 12px 40px rgba(0,0,0,0.18);z-index:2147483000;display:none;background:transparent;";
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);
// Панель может открыться раньше, чем загрузится iframe — тогда сообщение
// «открыто» (по нему виджет доскроллит ленту вниз) будет потеряно, поэтому
// повторяем его на load.
frame.addEventListener("load", function () { if (open) notifyOpened(); });
document.body.appendChild(frame);
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-expand") setExpanded(Boolean(d.expanded));
if (d.type === "chatballs-chat-unread") {
unread = Boolean(d.unread);
updateDot();
@@ -107,6 +116,157 @@ LOADER_JS = r"""
});
}
// Развёрнутая панель почти вдвое шире и немного выше; переход анимируется
// самим 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))" };
function setExpanded(next) {
expanded = next;
if (!shell) return;
shell.style.width = next ? PANEL_WIDTH.expanded : PANEL_WIDTH.normal;
shell.style.height = next ? PANEL_HEIGHT.expanded : PANEL_HEIGHT.normal;
}
// Эффект джина: окно втягивается в кнопку, вытягивая горловину, — как в
// доке 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(24, icon.half, g.fill)) + "px";
}
function restPanel() {
shell.style.clipPath = "none";
frame.style.transform = "none";
frame.style.borderRadius = "24px";
}
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: 24
};
}
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 (_) {}
}
@@ -114,8 +274,35 @@ LOADER_JS = r"""
function setOpen(next) {
ensureFrame();
open = next;
frame.style.display = open ? "block" : "none";
btn.style.display = open ? "none" : "flex";
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();
btn.style.opacity = "1";
btn.style.pointerEvents = "";
});
} 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();
btn.style.opacity = "1";
btn.style.pointerEvents = "";
});
}
btn.innerHTML = open ? CHEVRON_ICON : BOT_ICON;
btn.setAttribute("aria-label", open ? "Свернуть чат" : "Открыть чат");
btn.appendChild(dot);
if (open) {
unread = false;
updateDot();
@@ -124,7 +311,7 @@ LOADER_JS = r"""
} else updateDot();
}
btn.addEventListener("click", function () { setOpen(true); });
btn.addEventListener("click", function () { setOpen(!open); });
function mount() {
document.body.appendChild(btn);
ensureFrame();
+23 -9
View File
@@ -19,6 +19,7 @@ import {
type WebConfig,
type WebMessage,
} from "./api";
import { BrandFooter } from "./BrandFooter";
import { CallInviteBanner, ChatBody, ChatComposer, ChatHeader, StartChatFooter } from "./ChatView";
import { useScrollToLatest } from "./useScrollToLatest";
import { useVoiceRecorder } from "./useVoiceRecorder";
@@ -36,6 +37,11 @@ function closePanel() {
window.parent.postMessage({ type: "chatballs-chat-close" }, "*");
}
/** Размер окна держит лоадер: панель живёт в iframe и сама себя не растянет. */
function requestExpanded(expanded: boolean) {
window.parent.postMessage({ type: "chatballs-chat-expand", expanded }, "*");
}
export function App() {
const [config, setConfig] = useState<WebConfig | null>(null);
const [token, setToken] = useState<string | null>(() => localStorage.getItem(TOKEN_KEY));
@@ -44,6 +50,10 @@ export function App() {
const [pending, setPending] = useState<string[]>([]);
const [state, setState] = useState<"ai" | "operator" | "waiting">("ai");
const [awaiting, setAwaiting] = useState(false);
// Ответ считается на сервере и приедет следующим опросом: до тех пор в ленте
// висит «печатает».
const [thinking, setThinking] = useState(false);
const [expanded, setExpanded] = useState(false);
const [input, setInput] = useState("");
const [starting, setStarting] = useState(false);
const [contactSent, setContactSent] = useState(false);
@@ -88,6 +98,7 @@ export function App() {
setContactSent(false);
}
setState(data.state);
setThinking(Boolean(data.thinking));
setCall(data.call?.callId === openedCallId.current ? null : (data.call ?? null));
if (!data.messages.length) return;
if (notify && data.messages.some((message) => message.author === "ai" || message.author === "operator")) notifyNewMessage();
@@ -124,7 +135,13 @@ export function App() {
const accent = config?.accent || "#1677ff";
const title = config?.title || t("chat.chat");
const letter = title.trim()[0]?.toUpperCase() || "E";
function toggleExpanded() {
setExpanded((previous) => {
requestExpanded(!previous);
return !previous;
});
}
function forgetSession() {
localStorage.removeItem(TOKEN_KEY);
@@ -135,6 +152,7 @@ export function App() {
setMessages([]);
setPending([]);
setAwaiting(false);
setThinking(false);
setCall(null);
setContactSent(false);
}
@@ -196,20 +214,16 @@ export function App() {
const lastContactRequestId = messages.reduce((current, message) => message.kind === "contact_request" ? message.id : current, 0);
const showPhoneForm = lastContactRequestId > 0 && !(contactSent || messages.some((message) => message.kind === "contact"));
const status = state === "operator"
? { label: t("chat.operator_answering"), dot: "#52c41a" }
: state === "waiting"
? { label: t("chat.passing_to_operator"), dot: "#faad14" }
: { label: t("chat.virtual_assistant"), dot: "#52c41a" };
const unavailable = config !== null && !config.available;
return (
<div style={{ display: "flex", flexDirection: "column", height: "100%", width: "100%", background: "#fff", fontFamily: "-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,'Helvetica Neue',Arial,sans-serif", color: "#1f1f1f", overflow: "hidden" }}>
<ChatHeader accent={accent} letter={letter} title={title} statusLabel={status.label} statusDot={status.dot} unavailable={unavailable} onClose={closePanel} />
<ChatBody bodyRef={bodyRef} config={config} unavailable={unavailable} accepted={accepted} accent={accent} letter={letter} title={title} messages={messages} pending={pending} awaiting={awaiting} lastContactRequestId={lastContactRequestId} showPhoneForm={showPhoneForm} onSubmitContact={submitContact} audioUrlFor={token ? (id) => voiceAudioUrl(token, id) : undefined} attachmentUrlFor={token ? (id, inline) => attachmentUrl(token, id, inline) : undefined} />
<ChatHeader accent={accent} title={title} expanded={expanded} onToggleExpand={toggleExpanded} onClose={closePanel} />
<ChatBody bodyRef={bodyRef} config={config} unavailable={unavailable} accepted={accepted} accent={accent} title={title} messages={messages} pending={pending} awaiting={awaiting || thinking} lastContactRequestId={lastContactRequestId} showPhoneForm={showPhoneForm} onSubmitContact={submitContact} audioUrlFor={token ? (id) => voiceAudioUrl(token, id) : undefined} attachmentUrlFor={token ? (id, inline) => attachmentUrl(token, id, inline) : undefined} />
{config?.available && accepted && call && (call.status === "REQUESTED" || call.status === "RINGING") && <CallInviteBanner call={call} accent={accent} onAccept={() => void acceptCallInvite()} onDecline={() => void declineCallInvite()} />}
{config?.available && !accepted && <StartChatFooter accent={accent} starting={starting} onAccept={() => void accept()} />}
{config?.available && accepted && <ChatComposer accent={accent} state={state} quickReplies={config.quickReplies ?? []} pendingCount={pending.length} messageCount={messages.length} input={input} onInput={setInput} onSend={() => void send()} voice={config.features?.voiceMessages === false ? undefined : recorder} attachment={{ file: attachment, errorText: attachmentError, pick: (file) => { if (!file) return; if (file.size > MAX_FILE_BYTES) { setAttachmentError(t("chat.file_too_big")); return; } setAttachmentError(""); setAttachment(file); }, clear: () => setAttachment(null) }} />}
{config?.available && accepted && <ChatComposer accent={accent} input={input} onInput={setInput} onSend={() => void send()} voice={config.features?.voiceMessages === false ? undefined : recorder} attachment={{ file: attachment, errorText: attachmentError, pick: (file) => { if (!file) return; if (file.size > MAX_FILE_BYTES) { setAttachmentError(t("chat.file_too_big")); return; } setAttachmentError(""); setAttachment(file); }, clear: () => setAttachment(null) }} />}
{config?.available && <BrandFooter accent={accent} />}
</div>
);
}
+38
View File
@@ -0,0 +1,38 @@
import { useId } from "react";
/** Знак агента (утверждён владельцем 20.09.2026).
*
* Глаза вырезаны маской и моргают одной группой — синхронно, раз в четыре с
* небольшим секунды. Маска получает свой id на каждый экземпляр: с общим id
* все знаки на странице смотрели бы в одну маску, и размонтирование соседа
* ломало бы остальные.
*/
export function BotIcon({ size = 24, color = "currentColor" }: { size?: number; color?: string }) {
const maskId = useId();
return (
<svg width={size} height={size} viewBox="0 0 48 48" fill={color} aria-hidden="true" style={{ display: "block" }}>
<mask id={maskId} maskUnits="userSpaceOnUse" x="0" y="0" width="48" height="48">
<rect width="48" height="48" fill="#fff" />
<g style={{ transformOrigin: "24px 20px", animation: "wcBotBlink 4.2s infinite" }}>
<rect x="15" y="16.4" width="4" height="7.2" rx="2" fill="#000" />
<rect x="29" y="16.4" width="4" height="7.2" rx="2" fill="#000" />
</g>
<path d="M17.6 26.4c1.7 2.4 3.9 3.6 6.4 3.6s4.7-1.2 6.4-3.6" fill="none" stroke="#000" strokeWidth="2.6" strokeLinecap="round" />
</mask>
<circle cx="24" cy="3.6" r="2.4" />
<rect x="22.8" y="4.8" width="2.4" height="5" />
<rect x="2.2" y="18" width="2.6" height="8.4" rx="1.3" />
<rect x="43.2" y="18" width="2.6" height="8.4" rx="1.3" />
<path mask={`url(#${maskId})`} d="M14 9.2h20a6.8 6.8 0 0 1 6.8 6.8v12.4a6.8 6.8 0 0 1-6.8 6.8H21.6l-6.4 5.2a.9.9 0 0 1-1.5-.7v-4.5h-.3A6.8 6.8 0 0 1 7.2 28.4V16A6.8 6.8 0 0 1 14 9.2Z" />
</svg>
);
}
/** Круглый аватар агента: фон — акцент канала, внутри знак. */
export function BotAvatar({ size = 30, accent, color = "#fff" }: { size?: number; accent: string; color?: string }) {
return (
<span style={{ width: size, height: size, borderRadius: "50%", background: accent, display: "inline-flex", alignItems: "center", justifyContent: "center", flex: "none" }}>
<BotIcon size={Math.round(size * 0.62)} color={color} />
</span>
);
}
+39
View File
@@ -0,0 +1,39 @@
import { useState } from "react";
import { t } from "./i18n";
const SITE = "https://chatballs.ru";
/** Подпись под полем ввода: чей это чат.
*
* Ссылка открывается новой вкладкой: виджет живёт в iframe на чужой странице,
* и уводить эту страницу с собой он не должен. Наведение подсвечивает подпись
* акцентом канала — состояние держится в React: вокруг только inline-стили,
* а :hover в них не живёт.
*/
export function BrandFooter({ accent }: { accent: string }) {
const [hover, setHover] = useState(false);
return (
<div style={{ flex: "none", background: "#f7f8fa", padding: "7px 14px 10px", display: "flex", justifyContent: "center" }}>
<a
href={SITE}
target="_blank"
rel="noopener noreferrer"
onMouseEnter={() => setHover(true)}
onMouseLeave={() => setHover(false)}
style={{
display: "inline-flex",
alignItems: "baseline",
gap: 4,
fontSize: 13,
lineHeight: 1,
textDecoration: "none",
transition: "color .18s ease",
}}
>
<span style={{ fontWeight: 400, color: hover ? accent : "#8c8c8c", transition: "color .18s ease" }}>{t("chat.powered_by")}</span>
<span style={{ fontWeight: 700, color: hover ? accent : "#262626", transition: "color .18s ease" }}>Chatballs</span>
</a>
</div>
);
}
+123 -46
View File
@@ -1,6 +1,7 @@
import { useEffect, useRef, useState, type RefObject } from "react";
import { isVideoCall, type CallInfo, type WebConfig, type WebMessage } from "./api";
import { BotAvatar, BotIcon } from "./BotIcon";
import { VoiceMessage } from "./VoiceMessage";
import type { useVoiceRecorder } from "./useVoiceRecorder";
import { fmt, t } from "./i18n";
@@ -11,31 +12,43 @@ function formatSeconds(total: number): string {
return `${Math.floor(total / 60)}:${String(total % 60).padStart(2, "0")}`;
}
export function ChatHeader({ accent, letter, title, statusLabel, statusDot, unavailable, onClose }: { accent: string; letter: string; title: string; statusLabel: string; statusDot: string; unavailable: boolean; onClose: () => void }) {
const HEADER_BUTTON: React.CSSProperties = {
width: 30,
height: 30,
borderRadius: 9,
border: "none",
background: "rgba(255,255,255,0.14)",
display: "flex",
alignItems: "center",
justifyContent: "center",
cursor: "pointer",
flex: "none",
color: "#fff",
};
export function ChatHeader({ accent, title, expanded, onToggleExpand, onClose }: { accent: string; title: string; expanded: boolean; onToggleExpand: () => void; onClose: () => void }) {
return (
<div style={{ flex: "none", background: accent, padding: "14px 16px", display: "flex", alignItems: "center", gap: 11 }}>
<div style={{ width: 36, height: 36, borderRadius: 10, background: "rgba(255,255,255,0.18)", display: "flex", alignItems: "center", justifyContent: "center", flex: "none", fontSize: 15, fontWeight: 700, color: "#fff" }}>{letter}</div>
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ fontSize: 14.5, fontWeight: 600, color: "#fff", lineHeight: 1.2 }}>{title}</div>
<div style={{ display: "flex", alignItems: "center", gap: 6, marginTop: 2 }}>
<span style={{ width: 7, height: 7, borderRadius: "50%", background: statusDot, flex: "none" }} />
<span style={{ fontSize: 11.5, color: "rgba(255,255,255,0.92)" }}>{unavailable ? t("chat.temporarily_unavailable") : statusLabel}</span>
</div>
</div>
<button onClick={onClose} aria-label={t("chat.collapse")} style={{ width: 30, height: 30, borderRadius: 8, border: "none", background: "rgba(255,255,255,0.14)", display: "flex", alignItems: "center", justifyContent: "center", cursor: "pointer", flex: "none" }}>
<svg viewBox="0 0 24 24" width="17" height="17" fill="none" stroke="#fff" strokeWidth="2.2" strokeLinecap="round" strokeLinejoin="round"><line x1="5" y1="12" x2="19" y2="12" /></svg>
<span style={{ display: "flex", flex: "none", color: "#fff" }}><BotIcon size={34} /></span>
<div style={{ flex: 1, minWidth: 0, fontSize: 16, fontWeight: 600, color: "#fff", lineHeight: 1.25, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>{title}</div>
<button className="wc-icon-button" onClick={onToggleExpand} aria-label={expanded ? t("chat.shrink_panel") : t("chat.expand_panel")} style={HEADER_BUTTON}>
{expanded
? <svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" strokeWidth="2.1" strokeLinecap="round" strokeLinejoin="round"><path d="M9 3v4a2 2 0 0 1-2 2H3" /><path d="M15 3v4a2 2 0 0 0 2 2h4" /><path d="M9 21v-4a2 2 0 0 0-2-2H3" /><path d="M15 21v-4a2 2 0 0 1 2-2h4" /></svg>
: <svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" strokeWidth="2.1" strokeLinecap="round" strokeLinejoin="round"><path d="M8 3H5a2 2 0 0 0-2 2v3" /><path d="M16 3h3a2 2 0 0 1 2 2v3" /><path d="M8 21H5a2 2 0 0 1-2-2v-3" /><path d="M16 21h3a2 2 0 0 0 2-2v-3" /></svg>}
</button>
<button className="wc-icon-button" onClick={onClose} aria-label={t("chat.collapse")} style={HEADER_BUTTON}>
<svg viewBox="0 0 24 24" width="17" height="17" fill="none" stroke="currentColor" strokeWidth="2.2" strokeLinecap="round" strokeLinejoin="round"><line x1="5" y1="12" x2="19" y2="12" /></svg>
</button>
</div>
);
}
export function ChatBody({ bodyRef, config, unavailable, accepted, accent, letter, title, messages, pending, awaiting, lastContactRequestId, showPhoneForm, onSubmitContact, audioUrlFor, attachmentUrlFor }: {
export function ChatBody({ bodyRef, config, unavailable, accepted, accent, title, messages, pending, awaiting, lastContactRequestId, showPhoneForm, onSubmitContact, audioUrlFor, attachmentUrlFor }: {
bodyRef: RefObject<HTMLDivElement | null>;
config: WebConfig | null;
unavailable: boolean;
accepted: boolean;
accent: string;
letter: string;
title: string;
messages: WebMessage[];
pending: string[];
@@ -50,16 +63,16 @@ export function ChatBody({ bodyRef, config, unavailable, accepted, accent, lette
<div ref={bodyRef} style={{ flex: 1, minHeight: 0, overflowY: "auto", background: "#f7f8fa", padding: "18px 16px" }}>
{config === null && <div style={{ textAlign: "center", color: "#8c8c8c", padding: 24, fontSize: 13 }}>{t("chat.loading")}</div>}
{unavailable && <Unavailable />}
{config?.available && !accepted && <Consent config={config} accent={accent} letter={letter} title={title} />}
{config?.available && !accepted && <Consent config={config} accent={accent} title={title} />}
{config?.available && accepted && (
<>
<div style={{ textAlign: "center", marginBottom: 14 }}><span style={{ display: "inline-block", padding: "3px 11px", borderRadius: 20, background: "#eef0f2", fontSize: 11, color: "#8c8c8c" }}>{t("chat.today")}</span></div>
{config.greeting && <Bubble author="ai" text={config.greeting} accent={accent} />}
{config.greeting && <Bubble author="ai" text={config.greeting} accent={accent} authorName={title} />}
{messages.map((message) => message.author === "system"
? <SystemMessage key={message.id} text={message.text} />
: <div key={message.id}><Bubble author={message.author} text={message.hasAudio ? "" : message.text || (message.kind === "voice" ? t("chat.voice_message") : "")} accent={accent} time={message.createdAt} audioUrl={message.hasAudio && audioUrlFor ? audioUrlFor(message.id) : undefined} voice={{ messageId: message.id, durationSeconds: message.durationSeconds }} attachment={message.kind === "file" && message.attachment ? { ...message.attachment, url: message.attachment.available && attachmentUrlFor ? attachmentUrlFor(message.id, false) : "", inlineUrl: message.attachment.available && attachmentUrlFor ? attachmentUrlFor(message.id, true) : "" } : undefined} />{message.kind === "contact_request" && message.id === lastContactRequestId && showPhoneForm && <PhoneForm accent={accent} onSubmit={onSubmitContact} />}</div>)}
{pending.map((text, index) => <Bubble key={`p${index}`} author="client" text={text} accent={accent} pendingState />)}
{awaiting && <Typing />}
: <div key={message.id}><Bubble author={message.author} authorName={title} text={message.hasAudio ? "" : message.text || (message.kind === "voice" ? t("chat.voice_message") : "")} accent={accent} time={message.createdAt} audioUrl={message.hasAudio && audioUrlFor ? audioUrlFor(message.id) : undefined} voice={{ messageId: message.id, durationSeconds: message.durationSeconds }} attachment={message.kind === "file" && message.attachment ? { ...message.attachment, url: message.attachment.available && attachmentUrlFor ? attachmentUrlFor(message.id, false) : "", inlineUrl: message.attachment.available && attachmentUrlFor ? attachmentUrlFor(message.id, true) : "" } : undefined} />{message.kind === "contact_request" && message.id === lastContactRequestId && showPhoneForm && <PhoneForm accent={accent} onSubmit={onSubmitContact} />}</div>)}
{pending.map((text, index) => <Bubble key={`p${index}`} author="client" authorName={title} text={text} accent={accent} pendingState />)}
{awaiting && <Typing accent={accent} authorName={title} />}
</>
)}
</div>
@@ -70,8 +83,8 @@ function Unavailable() {
return <div style={{ display: "flex", flexDirection: "column", alignItems: "center", textAlign: "center", padding: "26px 14px" }}><div style={{ width: 52, height: 52, borderRadius: "50%", background: "#fff7e6", display: "flex", alignItems: "center", justifyContent: "center", marginBottom: 14 }}><svg viewBox="0 0 24 24" width="26" height="26" fill="none" stroke="#d48806" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"><path d="M10.29 3.86 1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0Z" /><line x1="12" y1="9" x2="12" y2="13" /><line x1="12" y1="17" x2="12.01" y2="17" /></svg></div><h3 style={{ margin: 0, fontSize: 15, fontWeight: 700 }}>{t("chat.unavailable_title")}</h3><p style={{ margin: "8px 0 0", fontSize: 13, color: "#595959", lineHeight: 1.5, maxWidth: 280 }}>{t("chat.unavailable_body")}</p></div>;
}
function Consent({ config, accent, letter, title }: { config: WebConfig; accent: string; letter: string; title: string }) {
return <><div style={{ display: "flex", flexDirection: "column", alignItems: "center", textAlign: "center", padding: "20px 12px 8px" }}><div style={{ width: 56, height: 56, borderRadius: 16, background: "#e6f4ff", display: "flex", alignItems: "center", justifyContent: "center", marginBottom: 14 }}><span style={{ fontSize: 24, fontWeight: 700, color: accent }}>{letter}</span></div><h3 style={{ margin: 0, fontSize: 17, fontWeight: 700 }}>{title}</h3><p style={{ margin: "8px 0 0", fontSize: 13, color: "#595959", lineHeight: 1.5, maxWidth: 280 }}>{config.greeting}</p></div><div style={{ marginTop: 18, background: "#fff", border: "1px solid #f0f0f0", borderRadius: 12, padding: 14 }}><div style={{ fontSize: 12, color: "#595959", lineHeight: 1.5 }}>{config.consent?.text} · {t("chat.consent_revision", { version: config.consent?.version ?? "" })}</div></div></>;
function Consent({ config, accent, title }: { config: WebConfig; accent: string; title: string }) {
return <><div style={{ display: "flex", flexDirection: "column", alignItems: "center", textAlign: "center", padding: "20px 12px 8px" }}><div style={{ marginBottom: 14 }}><BotAvatar size={56} accent={accent} /></div><h3 style={{ margin: 0, fontSize: 17, fontWeight: 700 }}>{title}</h3><p style={{ margin: "8px 0 0", fontSize: 15.5, lineHeight: "22px", color: "#595959", maxWidth: 300 }}>{config.greeting}</p></div><div style={{ marginTop: 18, background: "#fff", border: "1px solid #f0f0f0", borderRadius: 12, padding: 14 }}><div style={{ fontSize: 12, color: "#595959", lineHeight: 1.5 }}>{config.consent?.text} · {t("chat.consent_revision", { version: config.consent?.version ?? "" })}</div></div></>;
}
export function SystemMessage({ text }: { text: string }) {
@@ -79,45 +92,73 @@ export function SystemMessage({ text }: { text: string }) {
}
export function StartChatFooter({ accent, starting, onAccept }: { accent: string; starting: boolean; onAccept: () => void }) {
return <div style={{ flex: "none", background: "#fff", borderTop: "1px solid #f0f0f0", padding: "12px 14px 14px" }}><button onClick={onAccept} disabled={starting} style={{ width: "100%", height: 44, borderRadius: 10, border: "none", background: accent, color: "#fff", fontSize: 14, fontWeight: 600, cursor: "pointer" }}>{starting ? t("chat.starting") : t("chat.accept_and_start")}</button></div>;
return <div style={{ flex: "none", background: FOOTER_BG, borderTop: `1px solid ${FOOTER_CONTROL}`, padding: "12px 14px 10px" }}><button onClick={onAccept} disabled={starting} style={{ width: "100%", height: 44, borderRadius: 10, border: "none", background: accent, color: "#fff", fontSize: 14, fontWeight: 600, cursor: "pointer" }}>{starting ? t("chat.starting") : t("chat.accept_and_start")}</button></div>;
}
const COMPOSER_MAX_HEIGHT = 132;
export type ComposerAttachment = { file: File | null; errorText: string; pick: (file: File | null) => void; clear: () => void };
export function ChatComposer({ accent, state, quickReplies, pendingCount, messageCount, input, placeholder, onInput, onSend, voice, attachment }: { accent: string; state: "ai" | "operator" | "waiting"; quickReplies: string[]; pendingCount: number; messageCount: number; input: string; placeholder?: string; onInput: (value: string) => void; onSend: () => void; voice?: ComposerVoice; attachment?: ComposerAttachment }) {
// Подвал панели: фон как у ленты, а его элементы — на пару тонов темнее.
const FOOTER_BG = "#f7f8fa";
const FOOTER_CONTROL = "#e6e9ed";
const ICON_BUTTON: React.CSSProperties = {
width: 32,
height: 32,
borderRadius: "50%",
border: "none",
background: "transparent",
display: "flex",
alignItems: "center",
justifyContent: "center",
cursor: "pointer",
flex: "none",
color: "#8c8c8c",
};
export function ChatComposer({ accent, input, placeholder, onInput, onSend, voice, attachment }: { accent: string; input: string; placeholder?: string; onInput: (value: string) => void; onSend: () => void; voice?: ComposerVoice; attachment?: ComposerAttachment }) {
const textareaRef = useRef<HTMLTextAreaElement>(null);
const fileInputRef = useRef<HTMLInputElement>(null);
// Пока клиент не начал печатать, справа — запись голосового; как только в
// поле появился текст, её место занимает отправка.
const typing = Boolean(input.trim()) || Boolean(attachment?.file);
// Поле растёт под текст, как в мессенджере (до COMPOSER_MAX_HEIGHT, дальше скролл).
useEffect(() => {
const node = textareaRef.current;
if (!node) return;
node.style.height = "auto";
// Панель может быть ещё скрыта (у iframe display:none) — тогда scrollHeight
// равен нулю и высоту задавать нельзя, иначе поле схлопнется.
node.style.height = node.scrollHeight > 0 ? `${Math.min(node.scrollHeight, COMPOSER_MAX_HEIGHT)}px` : "";
// Мерить можно только разложенное поле. У скрытой панели (display:none у
// iframe) scrollHeight равен нулю, а у ещё не разложенной ширина нулевая и
// placeholder переносится по букве — тогда замер даёт максимум, и поле
// остаётся растянутым на всю высоту. В обоих случаях высоту не трогаем:
// пустое поле и так в одну строку.
if (!input || node.clientWidth === 0 || node.scrollHeight === 0) {
node.style.height = "";
return;
}
node.style.height = `${Math.min(node.scrollHeight, COMPOSER_MAX_HEIGHT)}px`;
}, [input]);
if (voice && voice.state !== "idle") {
// Режим записи (кадр H, упрощённый для виджета): корзина · таймер · отправить.
const sending = voice.state === "sending";
return (
<div style={{ flex: "none", background: "#fff", borderTop: "1px solid #f0f0f0", padding: "12px 14px 14px" }}>
<div style={{ display: "flex", alignItems: "center", gap: 10, border: "1px solid #e8e8e8", borderRadius: 14, padding: "6px 6px 6px 10px", minHeight: 48, boxSizing: "border-box" }}>
<button onClick={voice.cancel} disabled={sending} aria-label={t("chat.cancel_recording")} style={{ width: 34, height: 34, borderRadius: 9, border: "none", background: "transparent", display: "flex", alignItems: "center", justifyContent: "center", cursor: "pointer", flex: "none" }}><svg viewBox="0 0 24 24" width="17" height="17" fill="none" stroke="#8c8c8c" strokeWidth="1.9" strokeLinecap="round" strokeLinejoin="round"><path d="M3 6h18" /><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6" /><path d="M8 6V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2" /></svg></button>
<div style={{ flex: "none", background: FOOTER_BG, borderTop: `1px solid ${FOOTER_CONTROL}`, padding: "8px 12px" }}>
<div style={{ display: "flex", alignItems: "center", gap: 10, minHeight: 44, boxSizing: "border-box" }}>
<button className="wc-icon-button" onClick={voice.cancel} disabled={sending} aria-label={t("chat.cancel_recording")} style={ICON_BUTTON}><svg viewBox="0 0 24 24" width="17" height="17" fill="none" stroke="currentColor" strokeWidth="1.9" strokeLinecap="round" strokeLinejoin="round"><path d="M3 6h18" /><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6" /><path d="M8 6V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2" /></svg></button>
<span style={{ width: 8, height: 8, borderRadius: "50%", background: "#f5222d", flex: "none", animation: "wcTyping 1.2s infinite ease-in-out" }} />
<span style={{ fontSize: 13.5, fontWeight: 600, color: "#f5222d", fontVariantNumeric: "tabular-nums" }}>{formatSeconds(voice.seconds)}</span>
<span style={{ flex: 1, fontSize: 12.5, color: "#8c8c8c" }}>{sending ? t("chat.sending") : t("chat.recording")}</span>
<button onClick={voice.stopAndSend} disabled={sending} aria-label={t("chat.send_voice")} style={{ width: 36, height: 36, borderRadius: 10, border: "none", background: accent, display: "flex", alignItems: "center", justifyContent: "center", cursor: "pointer", flex: "none", opacity: sending ? 0.6 : 1 }}><svg viewBox="0 0 24 24" width="17" height="17" fill="none" stroke="#fff" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><line x1="22" y1="2" x2="11" y2="13" /><polygon points="22 2 15 22 11 13 2 9 22 2" /></svg></button>
<button className="wc-icon-button" onClick={voice.stopAndSend} disabled={sending} aria-label={t("chat.send_voice")} style={{ ...ICON_BUTTON, background: accent, color: "#fff", opacity: sending ? 0.6 : 1 }}><svg viewBox="0 0 24 24" width="17" height="17" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><line x1="12" y1="19" x2="12" y2="5" /><polyline points="5 12 12 5 19 12" /></svg></button>
</div>
</div>
);
}
return (
<div style={{ flex: "none", background: "#fff", borderTop: "1px solid #f0f0f0", padding: "12px 14px 14px" }}>
<div style={{ flex: "none", background: FOOTER_BG, borderTop: `1px solid ${FOOTER_CONTROL}`, padding: "8px 12px" }}>
{voice?.errorText && <div style={{ marginBottom: 8, fontSize: 12, color: "#cf1322" }}>{voice.errorText}</div>}
{attachment?.errorText && <div style={{ marginBottom: 8, fontSize: 12, color: "#cf1322" }}>{attachment.errorText}</div>}
{attachment?.file && (
@@ -128,19 +169,17 @@ export function ChatComposer({ accent, state, quickReplies, pendingCount, messag
<button onClick={attachment.clear} aria-label={t("chat.remove_file")} style={{ border: "none", background: "transparent", padding: 0, display: "flex", cursor: "pointer", color: "#8c8c8c", flex: "none" }}><svg viewBox="0 0 24 24" width="15" height="15" fill="none" stroke="currentColor" strokeWidth="1.9" strokeLinecap="round" strokeLinejoin="round"><circle cx="12" cy="12" r="10" /><path d="m15 9-6 6M9 9l6 6" /></svg></button>
</div>
)}
{state === "ai" && quickReplies.length > 0 && pendingCount === 0 && messageCount === 0 && <div style={{ display: "flex", gap: 7, flexWrap: "wrap", marginBottom: 10 }}>{quickReplies.map((reply) => <button key={reply} onClick={() => onInput(reply)} style={{ padding: "7px 13px", borderRadius: 16, border: "1px solid #d6e4ff", background: "#f0f7ff", color: "#0958d9", fontSize: 12.5, fontWeight: 500, cursor: "pointer" }}>{reply}</button>)}</div>}
<div style={{ display: "flex", alignItems: "flex-end", gap: 8, border: "1px solid #e8e8e8", borderRadius: 14, padding: "6px 6px 6px 14px", background: "#fff" }}>
<textarea ref={textareaRef} rows={1} value={input} onChange={(event) => onInput(event.target.value)} onKeyDown={(event) => { if (event.key === "Enter" && !event.shiftKey) { event.preventDefault(); onSend(); } }} placeholder={placeholder ?? t("chat.message_placeholder")} style={{ flex: 1, border: "none", outline: "none", resize: "none", fontSize: 14, lineHeight: 1.5, color: "#262626", fontFamily: "inherit", padding: "7px 0", maxHeight: COMPOSER_MAX_HEIGHT }} />
<div style={{ display: "flex", alignItems: "flex-start", gap: 4 }}>
{attachment && (
<>
<button onClick={() => fileInputRef.current?.click()} aria-label={t("chat.attach_file")} style={{ width: 36, height: 36, borderRadius: 10, border: "none", background: "transparent", display: "flex", alignItems: "center", justifyContent: "center", cursor: "pointer", flex: "none" }}><svg viewBox="0 0 24 24" width="17" height="17" fill="none" stroke="#8c8c8c" strokeWidth="1.9" strokeLinecap="round" strokeLinejoin="round"><path d="m21.44 11.05-9.19 9.19a6 6 0 0 1-8.49-8.49l8.57-8.57A4 4 0 1 1 18 8.84l-8.59 8.57a2 2 0 0 1-2.83-2.83l8.49-8.48" /></svg></button>
<button className="wc-icon-button" onClick={() => fileInputRef.current?.click()} aria-label={t("chat.attach_file")} style={ICON_BUTTON}><svg viewBox="0 0 24 24" width="17" height="17" fill="none" stroke="currentColor" strokeWidth="1.9" strokeLinecap="round" strokeLinejoin="round"><path d="m21.44 11.05-9.19 9.19a6 6 0 0 1-8.49-8.49l8.57-8.57A4 4 0 1 1 18 8.84l-8.59 8.57a2 2 0 0 1-2.83-2.83l8.49-8.48" /></svg></button>
<input ref={fileInputRef} type="file" hidden onChange={(event) => { attachment.pick(event.target.files?.[0] ?? null); event.target.value = ""; }} />
</>
)}
{voice?.supported && !input.trim() && !attachment?.file && (
<button onClick={() => void voice.start()} aria-label={t("chat.record_voice")} style={{ width: 36, height: 36, borderRadius: 10, border: "none", background: "transparent", display: "flex", alignItems: "center", justifyContent: "center", cursor: "pointer", flex: "none" }}><svg viewBox="0 0 24 24" width="17" height="17" fill="none" stroke="#8c8c8c" strokeWidth="1.9" strokeLinecap="round" strokeLinejoin="round"><path d="M12 2a3 3 0 0 0-3 3v7a3 3 0 0 0 6 0V5a3 3 0 0 0-3-3Z" /><path d="M19 10v2a7 7 0 0 1-14 0v-2" /><line x1="12" y1="19" x2="12" y2="22" /></svg></button>
)}
<button onClick={onSend} aria-label={t("chat.send")} style={{ width: 36, height: 36, borderRadius: 10, border: "none", background: accent, display: "flex", alignItems: "center", justifyContent: "center", cursor: "pointer", flex: "none" }}><svg viewBox="0 0 24 24" width="17" height="17" fill="none" stroke="#fff" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><line x1="22" y1="2" x2="11" y2="13" /><polygon points="22 2 15 22 11 13 2 9 22 2" /></svg></button>
<textarea ref={textareaRef} rows={1} value={input} onChange={(event) => onInput(event.target.value)} onKeyDown={(event) => { if (event.key === "Enter" && !event.shiftKey) { event.preventDefault(); onSend(); } }} placeholder={placeholder ?? t("chat.message_placeholder")} style={{ flex: 1, border: "none", outline: "none", resize: "none", background: "transparent", fontSize: 15, lineHeight: "21px", color: "#262626", fontFamily: "inherit", padding: "8px 2px", maxHeight: COMPOSER_MAX_HEIGHT }} />
{voice?.supported && !typing
? <button className="wc-icon-button" onClick={() => void voice.start()} aria-label={t("chat.record_voice")} style={{ ...ICON_BUTTON, background: FOOTER_CONTROL, color: "#595959" }}><svg viewBox="0 0 24 24" width="17" height="17" fill="none" stroke="currentColor" strokeWidth="1.9" strokeLinecap="round" strokeLinejoin="round"><path d="M12 2a3 3 0 0 0-3 3v7a3 3 0 0 0 6 0V5a3 3 0 0 0-3-3Z" /><path d="M19 10v2a7 7 0 0 1-14 0v-2" /><line x1="12" y1="19" x2="12" y2="22" /></svg></button>
: <button className="wc-icon-button" onClick={onSend} aria-label={t("chat.send")} style={{ ...ICON_BUTTON, background: accent, color: "#fff", transition: "background .18s ease" }}><svg viewBox="0 0 24 24" width="17" height="17" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><line x1="12" y1="19" x2="12" y2="5" /><polyline points="5 12 12 5 19 12" /></svg></button>}
</div>
</div>
);
@@ -153,7 +192,7 @@ export function CallInviteBanner({ call, accent, onAccept, onDecline }: { call:
? <svg viewBox="0 0 24 24" width="20" height="20" fill="none" stroke="#fff" strokeWidth="1.9" strokeLinecap="round" strokeLinejoin="round"><path d="M23 7l-7 5 7 5V7z" /><rect x="1" y="5" width="15" height="14" rx="2.5" /></svg>
: <svg viewBox="0 0 24 24" width="20" height="20" fill="none" stroke="#fff" strokeWidth="1.9" strokeLinecap="round" strokeLinejoin="round"><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.72c.13.81.36 1.6.7 2.34a2 2 0 0 1-.45 2.11L8.09 9.91a16 16 0 0 0 6 6l1.74-1.27a2 2 0 0 1 2.11-.45c.74.34 1.53.57 2.34.7A2 2 0 0 1 22 16.92z" /></svg>;
const title = isVideo ? t("chat.incoming_video_call") : t("chat.incoming_audio_call");
return <div style={{ flex: "none", background: "#fff", borderTop: "1px solid #f0f0f0", padding: "12px 14px", display: "flex", alignItems: "center", gap: 12 }}><div style={{ width: 40, height: 40, borderRadius: "50%", background: accent, display: "flex", alignItems: "center", justifyContent: "center", flex: "none" }}>{inviteIcon}</div><div style={{ flex: 1, minWidth: 0 }}><div style={{ fontSize: 13.5, fontWeight: 600, color: "#1f1f1f" }}>{title}</div><div style={{ fontSize: 12, color: "#8c8c8c", marginTop: 2, whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis" }}>{t("chat.invites_you_to_call", { name: call.staffName || t("call.operator") })}</div></div><button onClick={onDecline} aria-label={t("call.decline")} style={{ width: 42, height: 42, borderRadius: "50%", border: "none", background: "#ff4d4f", display: "flex", alignItems: "center", justifyContent: "center", cursor: "pointer", flex: "none", boxShadow: "0 2px 8px rgba(255,77,79,.35)" }}>{phoneIcon(true)}</button><button onClick={onAccept} aria-label={t("call.accept")} style={{ width: 42, height: 42, borderRadius: "50%", border: "none", background: "#52c41a", display: "flex", alignItems: "center", justifyContent: "center", cursor: "pointer", flex: "none", boxShadow: "0 2px 8px rgba(82,196,26,.35)" }}>{phoneIcon(false)}</button></div>;
return <div style={{ flex: "none", background: FOOTER_BG, borderTop: `1px solid ${FOOTER_CONTROL}`, padding: "12px 14px", display: "flex", alignItems: "center", gap: 12 }}><div style={{ width: 40, height: 40, borderRadius: "50%", background: accent, display: "flex", alignItems: "center", justifyContent: "center", flex: "none" }}>{inviteIcon}</div><div style={{ flex: 1, minWidth: 0 }}><div style={{ fontSize: 13.5, fontWeight: 600, color: "#1f1f1f" }}>{title}</div><div style={{ fontSize: 12, color: "#8c8c8c", marginTop: 2, whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis" }}>{t("chat.invites_you_to_call", { name: call.staffName || t("call.operator") })}</div></div><button onClick={onDecline} aria-label={t("call.decline")} style={{ width: 42, height: 42, borderRadius: "50%", border: "none", background: "#ff4d4f", display: "flex", alignItems: "center", justifyContent: "center", cursor: "pointer", flex: "none", boxShadow: "0 2px 8px rgba(255,77,79,.35)" }}>{phoneIcon(true)}</button><button onClick={onAccept} aria-label={t("call.accept")} style={{ width: 42, height: 42, borderRadius: "50%", border: "none", background: "#52c41a", display: "flex", alignItems: "center", justifyContent: "center", cursor: "pointer", flex: "none", boxShadow: "0 2px 8px rgba(82,196,26,.35)" }}>{phoneIcon(false)}</button></div>;
}
function formatTime(iso: string | undefined): string {
@@ -203,16 +242,44 @@ function AttachmentContent({ attachment, text, light }: { attachment: BubbleAtta
);
}
export function Bubble({ author, text, accent, time, pendingState, audioUrl, voice, attachment }: { author: "client" | "ai" | "operator"; text: string; accent: string; time?: string; pendingState?: boolean; audioUrl?: string; voice?: { messageId: number; durationSeconds?: number }; attachment?: BubbleAttachment }) {
/** Строка автора над пузырём: аватар и имя — как на утверждённом образце. */
function AuthorRow({ accent, name, operator }: { accent: string; name: string; operator: boolean }) {
return (
<div style={{ display: "flex", alignItems: "center", gap: 8, marginBottom: 6 }}>
{operator
? <span style={{ width: 32, height: 32, borderRadius: "50%", background: accent, color: "#fff", display: "flex", alignItems: "center", justifyContent: "center", flex: "none", fontSize: 11, fontWeight: 600 }}>{t("chat.operator_initial")}</span>
: <BotAvatar size={32} accent={accent} />}
<span style={{ fontSize: 12.5, fontWeight: 600, color: "#595959" }}>{name}</span>
</div>
);
}
export function Bubble({ author, authorName, text, accent, time, pendingState, audioUrl, voice, attachment }: { author: "client" | "ai" | "operator"; authorName: string; text: string; accent: string; time?: string; pendingState?: boolean; audioUrl?: string; voice?: { messageId: number; durationSeconds?: number }; attachment?: BubbleAttachment }) {
const at = formatTime(time);
const content = audioUrl && voice
? <VoiceMessage accent={accent} durationSeconds={voice.durationSeconds} light={author === "client"} messageId={voice.messageId} url={audioUrl} />
: attachment
? <AttachmentContent attachment={attachment} text={text} light={author === "client"} />
: text;
if (author === "client") return <div style={{ display: "flex", justifyContent: "flex-end", marginBottom: 12 }}><div style={{ maxWidth: "82%" }}><div style={{ background: accent, color: "#fff", borderRadius: "16px 16px 4px 16px", padding: audioUrl ? "8px" : "10px 14px", fontSize: 14.5, lineHeight: 1.5, whiteSpace: "pre-wrap", wordBreak: "break-word" }}>{content}</div><div style={{ ...META, marginRight: 4, textAlign: "right" }}>{pendingState ? t("chat.sending_lower") : [at, t("chat.delivered")].filter(Boolean).join(" · ")}</div></div></div>;
const isOperator = author === "operator";
return <div style={{ display: "flex", gap: 9, marginBottom: 12 }}><div style={{ width: 30, height: 30, borderRadius: "50%", background: isOperator ? accent : "#eef0f2", border: isOperator ? "none" : "1px solid #e3e6ea", color: "#fff", display: "flex", alignItems: "center", justifyContent: "center", flex: "none" }}>{isOperator ? <span style={{ fontSize: 11.5, fontWeight: 600 }}>{t("chat.operator_initial")}</span> : <svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="#8c8c8c" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"><circle cx="12" cy="8" r="3.2" /><path d="M5.5 20a6.5 6.5 0 0 1 13 0" /></svg>}</div><div style={{ maxWidth: "82%" }}><div style={{ background: "#fff", border: "1px solid #eee", borderRadius: "16px 16px 16px 4px", padding: audioUrl ? "8px" : "10px 14px", fontSize: 14.5, lineHeight: 1.5, color: "#262626", whiteSpace: "pre-wrap", wordBreak: "break-word" }}>{content}</div><div style={{ ...META, marginLeft: 4 }}>{[isOperator ? t("chat.specialist") : t("chat.virtual_assistant"), at].filter(Boolean).join(" · ")}</div></div></div>;
if (author === "client") {
return (
<div style={{ display: "flex", justifyContent: "flex-end", marginBottom: 14 }}>
<div style={{ maxWidth: "82%" }}>
<div style={{ background: accent, color: "#fff", borderRadius: "16px 4px 16px 16px", padding: audioUrl ? "8px" : "11px 15px", fontSize: 15.5, lineHeight: "22px", whiteSpace: "pre-wrap", wordBreak: "break-word" }}>{content}</div>
<div style={{ ...META, marginRight: 4, textAlign: "right" }}>{pendingState ? t("chat.sending_lower") : at}</div>
</div>
</div>
);
}
const operator = author === "operator";
return (
<div style={{ marginBottom: 14 }}>
<AuthorRow accent={accent} name={operator ? t("chat.specialist") : authorName} operator={operator} />
<div style={{ maxWidth: "88%" }}>
<div style={{ background: "#fff", border: "1px solid #eee", borderRadius: "4px 16px 16px 16px", padding: audioUrl ? "8px" : "11px 15px", fontSize: 15.5, lineHeight: "22px", color: "#262626", whiteSpace: "pre-wrap", wordBreak: "break-word" }}>{content}</div>
</div>
</div>
);
}
function formatPhone(raw: string): string {
@@ -236,9 +303,19 @@ function PhoneForm({ accent, onSubmit }: { accent: string; onSubmit: (phone: str
const [error, setError] = useState(false);
const valid = phone.replace(/\D/g, "").length === 11;
async function submit() { if (!valid || sending) return; setSending(true); setError(false); const ok = await onSubmit(phone); setSending(false); if (!ok) setError(true); }
return <div style={{ margin: "2px 0 10px 36px", maxWidth: "78%", background: "#fff", border: "1px solid #eee", borderRadius: 12, padding: 12 }}><input type="tel" inputMode="tel" value={phone} onChange={(event) => setPhone(formatPhone(event.target.value))} onKeyDown={(event) => { if (event.key === "Enter") { event.preventDefault(); void submit(); } }} placeholder="+7 (___) ___-__-__" style={{ width: "100%", boxSizing: "border-box", height: 38, borderRadius: 8, border: error ? "1px solid #ff4d4f" : "1px solid #d9d9d9", padding: "0 10px", outline: "none", fontSize: 13.5 }} /><button type="button" onClick={() => void submit()} disabled={!valid || sending} style={{ width: "100%", height: 36, marginTop: 8, borderRadius: 8, border: "none", background: valid ? accent : "#d9d9d9", color: "#fff", fontSize: 12.5, fontWeight: 600, cursor: valid ? "pointer" : "default" }}>{sending ? t("chat.sending") : t("chat.share_number")}</button>{error && <div style={{ marginTop: 6, color: "#cf1322", fontSize: 11.5 }}>{t("chat.share_number_failed")}</div>}</div>;
return <div style={{ margin: "2px 0 10px", maxWidth: "78%", background: "#fff", border: "1px solid #eee", borderRadius: 12, padding: 12 }}><input type="tel" inputMode="tel" value={phone} onChange={(event) => setPhone(formatPhone(event.target.value))} onKeyDown={(event) => { if (event.key === "Enter") { event.preventDefault(); void submit(); } }} placeholder="+7 (___) ___-__-__" style={{ width: "100%", boxSizing: "border-box", height: 38, borderRadius: 8, border: error ? "1px solid #ff4d4f" : "1px solid #d9d9d9", padding: "0 10px", outline: "none", fontSize: 13.5 }} /><button type="button" onClick={() => void submit()} disabled={!valid || sending} style={{ width: "100%", height: 36, marginTop: 8, borderRadius: 8, border: "none", background: valid ? accent : "#d9d9d9", color: "#fff", fontSize: 12.5, fontWeight: 600, cursor: valid ? "pointer" : "default" }}>{sending ? t("chat.sending") : t("chat.share_number")}</button>{error && <div style={{ marginTop: 6, color: "#cf1322", fontSize: 11.5 }}>{t("chat.share_number_failed")}</div>}</div>;
}
export function Typing() {
return <div style={{ display: "flex", gap: 9, marginBottom: 4 }}><div style={{ width: 30, height: 30, borderRadius: "50%", background: "#eef0f2", border: "1px solid #e3e6ea", display: "flex", alignItems: "center", justifyContent: "center", flex: "none" }}><svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="#8c8c8c" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"><circle cx="12" cy="8" r="3.2" /><path d="M5.5 20a6.5 6.5 0 0 1 13 0" /></svg></div><div style={{ background: "#fff", border: "1px solid #eee", borderRadius: "14px 14px 14px 4px", padding: "12px 14px", display: "flex", alignItems: "center", gap: 5 }}>{[0, 0.2, 0.4].map((delay) => <span key={delay} style={{ width: 6, height: 6, borderRadius: "50%", background: "#b37feb", animation: `wcTyping 1.2s infinite ease-in-out ${delay}s` }} />)}</div></div>;
export function Typing({ accent, authorName }: { accent: string; authorName: string }) {
return (
<div style={{ marginBottom: 6 }}>
<AuthorRow accent={accent} name={authorName} operator={false} />
<div style={{ display: "inline-flex", alignItems: "center", gap: 7, background: "#fff", border: "1px solid #eee", borderRadius: "4px 16px 16px 16px", padding: "11px 15px" }}>
<span style={{ fontSize: 12.5, color: "#8c8c8c" }}>{t("chat.typing")}</span>
<span style={{ display: "flex", alignItems: "center", gap: 5 }}>
{[0, 0.2, 0.4].map((delay) => <span key={delay} style={{ width: 6, height: 6, borderRadius: "50%", background: accent, animation: `wcTyping 1.2s infinite ease-in-out ${delay}s` }} />)}
</span>
</div>
</div>
);
}
+3 -1
View File
@@ -48,7 +48,9 @@ export type CallStateEnvelope = { call: CallInfo; iceServers: RTCIceServer[] };
// reset — диалог, который показывал виджет, удалили в рабочем месте: ленту
// надо начать с чистого листа, иначе клиент видит переписку, которой нет.
export type Poll = { state: "ai" | "operator" | "waiting"; lifecycle: string; messages: WebMessage[]; call?: CallInfo | null; reset?: boolean };
// thinking — агент уже считает ответ: он приходит не в том же запросе, что
// вопрос, и без этого признака клиент не понимал бы, ждать ему или нет.
export type Poll = { state: "ai" | "operator" | "waiting"; lifecycle: string; messages: WebMessage[]; call?: CallInfo | null; reset?: boolean; thinking?: boolean };
export type WidgetEntry = { widgetKey?: string; channel?: string };
+4 -1
View File
@@ -14,7 +14,6 @@ export const en: Record<ChatMessageKey, Message> = {
"chat.consent_revision": "rev. {version}",
"chat.invites_you_to_call": "{name} is inviting you to an online call",
"chat.download": "Download",
"chat.delivered": "delivered",
"chat.file_too_big": "The file is over 20 MB",
"chat.file_unavailable": "unavailable",
"chat.incoming_audio_call": "Incoming audio call",
@@ -40,6 +39,10 @@ export const en: Record<ChatMessageKey, Message> = {
"chat.temporarily_unavailable": "Temporarily unavailable",
"chat.unavailable_body": "Write to us on another channel — we will answer as soon as we can.",
"chat.unavailable_title": "The chat is temporarily unavailable",
"chat.powered_by": "Powered by",
"chat.expand_panel": "Expand the window",
"chat.shrink_panel": "Shrink the window",
"chat.typing": "typing",
"chat.virtual_assistant": "Virtual assistant",
"chat.voice_message": "Voice message",
"chat.operator_initial": "O",
+4 -1
View File
@@ -15,7 +15,6 @@ export const ru = {
"chat.consent_revision": "ред. {version}",
"chat.invites_you_to_call": "{name} приглашает вас на онлайн-звонок",
"chat.download": "Скачать",
"chat.delivered": "доставлено",
"chat.file_too_big": "Файл больше 20 МБ",
"chat.file_unavailable": "недоступен",
"chat.incoming_audio_call": "Входящий аудиозвонок",
@@ -41,6 +40,10 @@ export const ru = {
"chat.temporarily_unavailable": "Временно недоступен",
"chat.unavailable_body": "Напишите нам в другом канале — ответим как можно скорее.",
"chat.unavailable_title": "Чат временно недоступен",
"chat.powered_by": "Работает на",
"chat.expand_panel": "Развернуть окно",
"chat.shrink_panel": "Свернуть окно",
"chat.typing": "печатает",
"chat.virtual_assistant": "Виртуальный помощник",
"chat.voice_message": "Голосовое сообщение",
"chat.operator_initial": "О",
+30
View File
@@ -33,6 +33,36 @@ body,
40% { opacity: 1; transform: translateY(-2px); }
}
/* Кнопки виджета: отклик на наведение и нажатие в любом состоянии. */
.wc-icon-button {
transition: box-shadow 0.16s ease, transform 0.12s ease;
}
.wc-icon-button:hover {
box-shadow: inset 0 0 0 999px rgba(0, 0, 0, 0.07);
}
.wc-icon-button:active {
transform: scale(0.93);
}
.wc-icon-button:disabled {
box-shadow: none;
transform: none;
}
/* Смена знака на кнопке чата: лёгкий подскок. */
@keyframes wcLauncherPop {
from { opacity: 0; transform: scale(0.55) rotate(-25deg); }
to { opacity: 1; transform: none; }
}
/* Моргание знака агента: оба глаза одной группой, синхронно. */
@keyframes wcBotBlink {
0%, 92%, 100% { transform: scaleY(1); }
95% { transform: scaleY(0.12); }
}
/* Экран звонка (baseline «Экран звонка.dc.html») */
@keyframes edvRing {
0% { transform: scale(1); opacity: 0.55; }