From 1f46ed62bd6e962e75abc505545cad9f450de393 Mon Sep 17 00:00:00 2001 From: Andrey Date: Sun, 20 Sep 2026 10:35:29 +0300 Subject: [PATCH] =?UTF-8?q?:lipstick:=20feat(webchat):=20=D0=B2=D0=B8?= =?UTF-8?q?=D0=B4=D0=B6=D0=B5=D1=82=20=D0=BF=D0=BE=20=D1=83=D1=82=D0=B2?= =?UTF-8?q?=D0=B5=D1=80=D0=B6=D0=B4=D1=91=D0=BD=D0=BD=D0=BE=D0=BC=D1=83=20?= =?UTF-8?q?=D0=BE=D0=B1=D1=80=D0=B0=D0=B7=D1=86=D1=83=20=D0=B8=20=D1=80?= =?UTF-8?q?=D0=B0=D0=B7=D0=B2=D0=BE=D1=80=D0=BE=D1=82=20=D0=BE=D0=BA=D0=BD?= =?UTF-8?q?=D0=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Виджет переложен по образцу, который дал владелец. Лента: имя и аватар агента стоят над пузырём, у пузыря не скруглён верхний угол со стороны автора — «хвостик». Аватар агента — знак робота на круге цвета акцента; он же стоит в шапке вместо буквы, пока у виджета нет способа получить логотип компании, и он же на кнопке чата вместо прежней подписи. Пока ответ считается, в ленте висит «печатает» с бегущими точками. Поле ввода: без рамки, скрепка слева, справа запись голосового, которая сменяется отправкой, как только клиент начал печатать. При росте поля кнопки держатся верхней строки. Подсказки убраны. Подвал — одного тона с лентой, внизу «Работает на Chatballs» со ссылкой на сайт. В шапке появилась кнопка разворота: окно становится почти вдвое шире и немного выше. Размер держит лоадер на сайте-хосте — панель живёт в iframe и сама себя не растянет. Появление и исчезновение — эффект джина, как в доке macOS: окно вытягивается, изгибается горловиной и втягивается в кнопку. Аффинными преобразованиями такое не делается, поэтому силуэт считается контуром SVG покадрово, тем же контуром режется контейнер панели, а само окно внутри получает матрицу, построенную по этому силуэту, — вместе с окном деформируется и переписка. Co-Authored-By: Claude Opus 5 --- apps/backend/chatballs/webchat/loader.py | 209 +++++++++++++++++++++-- apps/web-chat/src/App.tsx | 32 +++- apps/web-chat/src/BotIcon.tsx | 38 +++++ apps/web-chat/src/BrandFooter.tsx | 39 +++++ apps/web-chat/src/ChatView.tsx | 169 +++++++++++++----- apps/web-chat/src/api.ts | 4 +- apps/web-chat/src/i18n/en.ts | 5 +- apps/web-chat/src/i18n/ru.ts | 5 +- apps/web-chat/src/styles.css | 30 ++++ 9 files changed, 462 insertions(+), 69 deletions(-) create mode 100644 apps/web-chat/src/BotIcon.tsx create mode 100644 apps/web-chat/src/BrandFooter.tsx diff --git a/apps/backend/chatballs/webchat/loader.py b/apps/backend/chatballs/webchat/loader.py index 730f1bc..f98ea8b 100644 --- a/apps/backend/chatballs/webchat/loader.py +++ b/apps/backend/chatballs/webchat/loader.py @@ -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 = ''; + 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;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 = 'Чат'; + 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(); diff --git a/apps/web-chat/src/App.tsx b/apps/web-chat/src/App.tsx index 3869375..a41583f 100644 --- a/apps/web-chat/src/App.tsx +++ b/apps/web-chat/src/App.tsx @@ -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(null); const [token, setToken] = useState(() => localStorage.getItem(TOKEN_KEY)); @@ -44,6 +50,10 @@ export function App() { const [pending, setPending] = useState([]); 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 (
- - voiceAudioUrl(token, id) : undefined} attachmentUrlFor={token ? (id, inline) => attachmentUrl(token, id, inline) : undefined} /> + + voiceAudioUrl(token, id) : undefined} attachmentUrlFor={token ? (id, inline) => attachmentUrl(token, id, inline) : undefined} /> {config?.available && accepted && call && (call.status === "REQUESTED" || call.status === "RINGING") && void acceptCallInvite()} onDecline={() => void declineCallInvite()} />} {config?.available && !accepted && void accept()} />} - {config?.available && accepted && 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 && 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 && }
); } diff --git a/apps/web-chat/src/BotIcon.tsx b/apps/web-chat/src/BotIcon.tsx new file mode 100644 index 0000000..b0de5c3 --- /dev/null +++ b/apps/web-chat/src/BotIcon.tsx @@ -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 ( + + ); +} + +/** Круглый аватар агента: фон — акцент канала, внутри знак. */ +export function BotAvatar({ size = 30, accent, color = "#fff" }: { size?: number; accent: string; color?: string }) { + return ( + + + + ); +} diff --git a/apps/web-chat/src/BrandFooter.tsx b/apps/web-chat/src/BrandFooter.tsx new file mode 100644 index 0000000..8f8ca55 --- /dev/null +++ b/apps/web-chat/src/BrandFooter.tsx @@ -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 ( + + ); +} diff --git a/apps/web-chat/src/ChatView.tsx b/apps/web-chat/src/ChatView.tsx index c470473..6be0dbc 100644 --- a/apps/web-chat/src/ChatView.tsx +++ b/apps/web-chat/src/ChatView.tsx @@ -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 (
-
{letter}
-
-
{title}
-
- - {unavailable ? t("chat.temporarily_unavailable") : statusLabel} -
-
- +
); } -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; 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
{config === null &&
{t("chat.loading")}
} {unavailable && } - {config?.available && !accepted && } + {config?.available && !accepted && } {config?.available && accepted && ( <>
{t("chat.today")}
- {config.greeting && } + {config.greeting && } {messages.map((message) => message.author === "system" ? - :
{message.kind === "contact_request" && message.id === lastContactRequestId && showPhoneForm && }
)} - {pending.map((text, index) => )} - {awaiting && } + :
{message.kind === "contact_request" && message.id === lastContactRequestId && showPhoneForm && }
)} + {pending.map((text, index) => )} + {awaiting && } )}
@@ -70,8 +83,8 @@ function Unavailable() { return

{t("chat.unavailable_title")}

{t("chat.unavailable_body")}

; } -function Consent({ config, accent, letter, title }: { config: WebConfig; accent: string; letter: string; title: string }) { - return <>
{letter}

{title}

{config.greeting}

{config.consent?.text} · {t("chat.consent_revision", { version: config.consent?.version ?? "" })}
; +function Consent({ config, accent, title }: { config: WebConfig; accent: string; title: string }) { + return <>

{title}

{config.greeting}

{config.consent?.text} · {t("chat.consent_revision", { version: config.consent?.version ?? "" })}
; } 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
; + return
; } 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(null); const fileInputRef = useRef(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 ( -
-
- +
+
+ {formatSeconds(voice.seconds)} {sending ? t("chat.sending") : t("chat.recording")} - +
); } return ( -
+
{voice?.errorText &&
{voice.errorText}
} {attachment?.errorText &&
{attachment.errorText}
} {attachment?.file && ( @@ -128,19 +169,17 @@ export function ChatComposer({ accent, state, quickReplies, pendingCount, messag
)} - {state === "ai" && quickReplies.length > 0 && pendingCount === 0 && messageCount === 0 &&
{quickReplies.map((reply) => )}
} -
-