💄 feat(webchat): pill-лаунчер, открытие на последних сообщениях, окно шире

- launcher: круглая кнопка → пилюля с иконкой Tabler brand-hipchat и словом
  «Чат» (inline SVG — на сайте продукта шрифта Tabler нет), hover/focus-visible,
  анимации bump/shake переделаны под новую форму
- окно 384x600 → 440x720
- фикс: панель открывалась на начале истории. Лоадер держит iframe скрытым
  (display:none), у ленты scrollHeight === 0 и автоскролл не срабатывал —
  виджет теперь слушает edevs-chat-opened и доскроллит после показа, лоадер
  повторяет сигнал на load iframe
- лента ближе к мессенджеру: время у сообщений, растущее под текст поле ввода,
  крупнее типографика и шире пузыри
- support-режим переиспользует шапку/пузыри/composer из ChatView вместо своих копий

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
AndreyandClaude Opus 5 committed 2026-08-01 15:59:07 +03:00
1 parent bb7ed5e8f9
commit 8c4775f8a4
5 files changed
+119 -108

No files matched your search

+18 -6
View File
@@ -27,16 +27,20 @@ LOADER_JS = r"""
var open = false, frame = null, unread = false, callActive = false;
var style = document.createElement("style");
style.textContent = "@keyframes edevs-chat-message-bump{0%,100%{transform:translateX(0)}35%{transform:translateX(-4px) rotate(-3deg)}70%{transform:translateX(3px) rotate(2deg)}}@keyframes edevs-chat-call-shake{0%,18%,100%{transform:translateX(0) rotate(0)}3%{transform:translateX(-5px) rotate(-5deg)}6%{transform:translateX(5px) rotate(5deg)}9%{transform:translateX(-4px) rotate(-4deg)}12%{transform:translateX(4px) rotate(4deg)}15%{transform:translateX(-2px) rotate(-2deg)}}.edevs-chat-message-bump{animation:edevs-chat-message-bump .42s ease-out}.edevs-chat-call-shake{animation:edevs-chat-call-shake 3.2s ease-in-out infinite}";
style.textContent = "@keyframes edevs-chat-message-bump{0%,100%{transform:translateY(0)}35%{transform:translateY(-6px)}70%{transform:translateY(-2px)}}@keyframes edevs-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)}}.edevs-chat-message-bump{animation:edevs-chat-message-bump .42s ease-out}.edevs-chat-call-shake{animation:edevs-chat-call-shake 3.2s ease-in-out infinite}.edevs-chat-launcher:hover{transform:translateY(-2px);box-shadow:0 12px 30px rgba(22,119,255,0.45)}.edevs-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 нет.
var btn = document.createElement("button");
btn.className = "edevs-chat-launcher";
btn.type = "button";
btn.setAttribute("aria-label", "Открыть чат");
btn.style.cssText = "position:fixed;right:24px;bottom:24px;width:60px;height:60px;border-radius:50%;border:none;background:#1677ff;box-shadow:0 8px 24px rgba(22,119,255,0.4);cursor:pointer;z-index:2147483000;display:flex;align-items:center;justify-content:center;";
btn.innerHTML = '<svg viewBox="0 0 24 24" width="26" height="26" fill="none" stroke="#fff" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2Z"/></svg>';
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>';
var dot = document.createElement("span");
dot.style.cssText = "position:absolute;top:8px;right:8px;width:12px;height:12px;border-radius:50%;background:#faad14;border:2px solid #fff;display:none;";
dot.style.cssText = "position:absolute;top:-2px;right:-2px;width:12px;height:12px;border-radius:50%;background:#faad14;border:2px solid #fff;display:none;";
btn.appendChild(dot);
var notification = new Audio(origin + "/chat/audio/notification.mp3");
@@ -78,7 +82,11 @@ LOADER_JS = r"""
frame = document.createElement("iframe");
frame.src = panelUrl;
frame.title = "Чат";
frame.style.cssText = "position:fixed;right:24px;bottom:24px;width:min(384px,calc(100vw - 32px));height:min(600px,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 = "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;";
// Панель может открыться раньше, чем загрузится iframe — тогда сообщение
// «открыто» (по нему виджет доскроллит ленту вниз) будет потеряно, поэтому
// повторяем его на load.
frame.addEventListener("load", function () { if (open) notifyOpened(); });
document.body.appendChild(frame);
window.addEventListener("message", function (e) {
if (e.origin !== origin) return;
@@ -99,6 +107,10 @@ LOADER_JS = r"""
});
}
function notifyOpened() {
try { frame.contentWindow.postMessage({ type: "edevs-chat-opened" }, origin); } catch (_) {}
}
function setOpen(next) {
ensureFrame();
open = next;
@@ -108,7 +120,7 @@ LOADER_JS = r"""
unread = false;
updateDot();
if (callActive) play(ringtone);
try { frame.contentWindow.postMessage({ type: "edevs-chat-opened" }, origin); } catch (_) {}
notifyOpened();
} else updateDot();
}
+4 -3
View File
@@ -14,6 +14,7 @@ import {
type WebMessage,
} from "./api";
import { CallInviteBanner, ChatBody, ChatComposer, ChatHeader, StartChatFooter } from "./ChatView";
import { useScrollToLatest } from "./useScrollToLatest";
import { useWidgetActivity } from "./widgetActivity";
const CHANNEL = new URLSearchParams(location.search).get("channel") || "edevs";
@@ -39,6 +40,7 @@ export function App() {
const openedCallId = useRef("");
const pollingReady = useRef(false);
const bodyRef = useRef<HTMLDivElement>(null);
const scrollToLatest = useScrollToLatest(bodyRef);
const incomingCall = Boolean(call && (call.status === "REQUESTED" || call.status === "RINGING"));
const notifyNewMessage = useWidgetActivity(incomingCall);
@@ -77,9 +79,8 @@ export function App() {
}, [accepted, token]);
useEffect(() => {
const node = bodyRef.current;
if (node) node.scrollTop = node.scrollHeight;
}, [messages, pending, awaiting]);
scrollToLatest();
}, [scrollToLatest, messages, pending, awaiting]);
const accent = config?.accent || "#1677ff";
const title = config?.title || "Чат";
+38 -15
View File
@@ -1,4 +1,4 @@
import { useState, type RefObject } from "react";
import { useEffect, useRef, useState, type RefObject } from "react";
import { isVideoCall, type CallInfo, type WebConfig, type WebMessage } from "./api";
@@ -36,7 +36,7 @@ export function ChatBody({ bodyRef, config, unavailable, accepted, accent, lette
onSubmitContact: (phone: string) => Promise<boolean>;
}) {
return (
<div ref={bodyRef} style={{ flex: 1, minHeight: 0, overflowY: "auto", background: "#f7f8fa", padding: "16px 14px" }}>
<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 }}>Загрузка…</div>}
{unavailable && <Unavailable />}
{config?.available && !accepted && <Consent config={config} accent={accent} letter={letter} title={title} />}
@@ -46,7 +46,7 @@ export function ChatBody({ bodyRef, config, unavailable, accepted, accent, lette
{config.greeting && <Bubble author="ai" text={config.greeting} accent={accent} />}
{messages.map((message) => message.author === "system"
? <SystemMessage key={message.id} text={message.text} />
: <div key={message.id}><Bubble author={message.author} text={message.text} accent={accent} />{message.kind === "contact_request" && message.id === lastContactRequestId && showPhoneForm && <PhoneForm accent={accent} onSubmit={onSubmitContact} />}</div>)}
: <div key={message.id}><Bubble author={message.author} text={message.text} accent={accent} time={message.createdAt} />{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 />}
</>
@@ -63,7 +63,7 @@ function Consent({ config, accent, letter, title }: { config: WebConfig; accent:
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} · ред. {config.consent?.version}</div></div></>;
}
function SystemMessage({ text }: { text: string }) {
export function SystemMessage({ text }: { text: string }) {
return <div style={{ textAlign: "center", margin: "12px 0" }}><span style={{ display: "inline-block", padding: "4px 12px", borderRadius: 20, background: "#e6f4ff", border: "1px solid #91caff", fontSize: 11.5, color: "#0958d9" }}>{text}</span></div>;
}
@@ -71,13 +71,27 @@ export function StartChatFooter({ accent, starting, onAccept }: { accent: string
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 ? "Запуск…" : "Принять и начать чат"}</button></div>;
}
export function ChatComposer({ accent, state, quickReplies, pendingCount, messageCount, input, onInput, onSend }: { accent: string; state: "ai" | "operator" | "waiting"; quickReplies: string[]; pendingCount: number; messageCount: number; input: string; onInput: (value: string) => void; onSend: () => void }) {
const COMPOSER_MAX_HEIGHT = 132;
export function ChatComposer({ accent, state, quickReplies, pendingCount, messageCount, input, placeholder, onInput, onSend }: { accent: string; state: "ai" | "operator" | "waiting"; quickReplies: string[]; pendingCount: number; messageCount: number; input: string; placeholder?: string; onInput: (value: string) => void; onSend: () => void }) {
const textareaRef = useRef<HTMLTextAreaElement>(null);
// Поле растёт под текст, как в мессенджере (до 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` : "";
}, [input]);
return (
<div style={{ flex: "none", background: "#fff", borderTop: "1px solid #f0f0f0", padding: "10px 12px 12px" }}>
{state === "ai" && quickReplies.length > 0 && pendingCount === 0 && messageCount === 0 && <div style={{ display: "flex", gap: 7, flexWrap: "wrap", marginBottom: 9 }}>{quickReplies.map((reply) => <button key={reply} onClick={() => onInput(reply)} style={{ padding: "6px 12px", borderRadius: 16, border: "1px solid #d6e4ff", background: "#f0f7ff", color: "#0958d9", fontSize: 12, fontWeight: 500, cursor: "pointer" }}>{reply}</button>)}</div>}
<div style={{ display: "flex", alignItems: "flex-end", gap: 8, border: "1px solid #e8e8e8", borderRadius: 12, padding: "6px 6px 6px 12px", background: "#fff" }}>
<textarea rows={1} value={input} onChange={(event) => onInput(event.target.value)} onKeyDown={(event) => { if (event.key === "Enter" && !event.shiftKey) { event.preventDefault(); onSend(); } }} placeholder="Напишите сообщение…" style={{ flex: 1, border: "none", outline: "none", resize: "none", fontSize: 13.5, lineHeight: 1.5, color: "#262626", fontFamily: "inherit", padding: "6px 0", maxHeight: 90 }} />
<button onClick={onSend} aria-label="Отправить" style={{ width: 34, height: 34, borderRadius: 8, border: "none", background: accent, display: "flex", alignItems: "center", justifyContent: "center", cursor: "pointer", flex: "none" }}><svg viewBox="0 0 24 24" width="16" height="16" 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>
<div style={{ flex: "none", background: "#fff", borderTop: "1px solid #f0f0f0", padding: "12px 14px 14px" }}>
{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 ?? "Напишите сообщение…"} 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 }} />
<button onClick={onSend} aria-label="Отправить" 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>
</div>
</div>
);
@@ -93,10 +107,19 @@ export function CallInviteBanner({ call, accent, onAccept, onDecline }: { 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" }}>{call.staffName || "Оператор"} приглашает вас на онлайн-звонок</div></div><button onClick={onDecline} aria-label="Отклонить" 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="Принять" 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 Bubble({ author, text, accent, pendingState }: { author: "client" | "ai" | "operator"; text: string; accent: string; pendingState?: boolean }) {
if (author === "client") return <div style={{ display: "flex", justifyContent: "flex-end", marginBottom: 10 }}><div style={{ maxWidth: "78%" }}><div style={{ background: accent, color: "#fff", borderRadius: "14px 14px 4px 14px", padding: "9px 13px", fontSize: 13.5, lineHeight: 1.45, whiteSpace: "pre-wrap" }}>{text}</div><div style={{ fontSize: 10.5, color: "#bfbfbf", margin: "3px 4px 0 0", textAlign: "right" }}>{pendingState ? "отправка…" : "доставлено"}</div></div></div>;
function formatTime(iso: string | undefined): string {
if (!iso) return "";
const date = new Date(iso);
return Number.isNaN(date.getTime()) ? "" : date.toLocaleTimeString("ru-RU", { hour: "2-digit", minute: "2-digit" });
}
const META: React.CSSProperties = { fontSize: 11, color: "#bfbfbf", marginTop: 4 };
export function Bubble({ author, text, accent, time, pendingState }: { author: "client" | "ai" | "operator"; text: string; accent: string; time?: string; pendingState?: boolean }) {
const at = formatTime(time);
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: "10px 14px", fontSize: 14.5, lineHeight: 1.5, whiteSpace: "pre-wrap", wordBreak: "break-word" }}>{text}</div><div style={{ ...META, marginRight: 4, textAlign: "right" }}>{pendingState ? "отправка…" : [at, "доставлено"].filter(Boolean).join(" · ")}</div></div></div>;
const isOperator = author === "operator";
return <div style={{ display: "flex", gap: 8, marginBottom: 10 }}><div style={{ width: 28, height: 28, 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, fontWeight: 600 }}>О</span> : <svg viewBox="0 0 24 24" width="15" height="15" 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: "78%" }}><div style={{ background: "#fff", border: "1px solid #eee", borderRadius: "14px 14px 14px 4px", padding: "9px 13px", fontSize: 13.5, lineHeight: 1.45, color: "#262626", whiteSpace: "pre-wrap" }}>{text}</div><div style={{ fontSize: 10.5, color: "#bfbfbf", margin: "3px 0 0 4px" }}>{isOperator ? "Специалист" : "Виртуальный помощник"}</div></div></div>;
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 }}>О</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: "10px 14px", fontSize: 14.5, lineHeight: 1.5, color: "#262626", whiteSpace: "pre-wrap", wordBreak: "break-word" }}>{text}</div><div style={{ ...META, marginLeft: 4 }}>{[isOperator ? "Специалист" : "Виртуальный помощник", at].filter(Boolean).join(" · ")}</div></div></div>;
}
function formatPhone(raw: string): string {
@@ -123,6 +146,6 @@ function PhoneForm({ accent, onSubmit }: { accent: string; onSubmit: (phone: str
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 ? "Отправка…" : "Поделиться номером"}</button>{error && <div style={{ marginTop: 6, color: "#cf1322", fontSize: 11.5 }}>Не удалось отправить. Попробуйте ещё раз.</div>}</div>;
}
function Typing() {
return <div style={{ display: "flex", gap: 8, marginBottom: 4 }}><div style={{ width: 28, height: 28, borderRadius: "50%", background: "#eef0f2", border: "1px solid #e3e6ea", display: "flex", alignItems: "center", justifyContent: "center", flex: "none" }}><svg viewBox="0 0 24 24" width="15" height="15" 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() {
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>;
}
+20 -84
View File
@@ -1,6 +1,8 @@
import { useEffect, useRef, useState } from "react";
import { pollSupport, sendSupport, startSupportSession, type WebMessage } from "./api";
import { Bubble, ChatComposer, ChatHeader, SystemMessage, Typing } from "./ChatView";
import { useScrollToLatest } from "./useScrollToLatest";
import { useWidgetActivity } from "./widgetActivity";
// Support-режим виджета (SPEC-HUB-0010 §7): authenticated in-product чат.
@@ -24,6 +26,7 @@ export function SupportApp() {
const [input, setInput] = useState("");
const lastId = useRef(0);
const bodyRef = useRef<HTMLDivElement>(null);
const scrollToLatest = useScrollToLatest(bodyRef);
const notifyNewMessage = useWidgetActivity(false);
// Старт сессии один раз (SPEC §7.2: нет consent/accept flow).
@@ -82,9 +85,8 @@ export function SupportApp() {
}, [session]);
useEffect(() => {
const node = bodyRef.current;
if (node) node.scrollTop = node.scrollHeight;
}, [messages, pending, awaiting]);
scrollToLatest();
}, [scrollToLatest, messages, pending, awaiting]);
async function send() {
const text = input.trim();
@@ -116,10 +118,14 @@ export function SupportApp() {
? { label: "Передаём оператору", dot: "#faad14" }
: { label: "Виртуальный помощник", dot: "#52c41a" };
const header = (statusLabel: string, dot: string) => (
<ChatHeader accent={accent} letter="П" title="Поддержка" statusLabel={statusLabel} statusDot={dot} unavailable={false} onClose={closePanel} />
);
if (failed) {
return (
<div style={shell(accent)}>
<Header accent={accent} title="Поддержка" statusLabel="Временно недоступна" dot="#faad14" />
<div style={shell()}>
{header("Временно недоступна", "#faad14")}
<div style={{ flex: 1, display: "flex", alignItems: "center", justifyContent: "center", background: "#f7f8fa" }}>
<p style={{ color: "#595959", fontSize: 13, padding: 24, textAlign: "center" }}>Поддержка временно недоступна. Обновите страницу или обратитесь позже.</p>
</div>
@@ -129,8 +135,8 @@ export function SupportApp() {
if (!session) {
return (
<div style={shell(accent)}>
<Header accent={accent} title="Поддержка" statusLabel="Подключение…" dot="#52c41a" />
<div style={shell()}>
{header("Подключение…", "#52c41a")}
<div style={{ flex: 1, display: "flex", alignItems: "center", justifyContent: "center", background: "#f7f8fa" }}>
<span style={{ color: "#8c8c8c", fontSize: 13 }}>Загрузка…</span>
</div>
@@ -141,36 +147,22 @@ export function SupportApp() {
const greeting = displayName ? `Здравствуйте, ${displayName.split(" ")[0]}.` : "Здравствуйте.";
return (
<div style={shell(accent)}>
<Header accent={accent} title="Поддержка" statusLabel={status.label} dot={status.dot} />
<div style={shell()}>
{header(status.label, status.dot)}
<div ref={bodyRef} style={{ flex: 1, minHeight: 0, overflowY: "auto", background: "#f7f8fa", padding: "16px 14px" }}>
<div ref={bodyRef} style={{ flex: 1, minHeight: 0, overflowY: "auto", background: "#f7f8fa", padding: "18px 16px" }}>
<div style={{ textAlign: "center", marginBottom: 14 }}>
<span style={{ display: "inline-block", padding: "3px 11px", borderRadius: 20, background: "#eef0f2", fontSize: 11, color: "#8c8c8c" }}>Сегодня</span>
</div>
<Bubble author="ai" text={greeting + " Чем помочь?"} accent={accent} />
{messages.map((m) => (m.author === "system"
? <div key={m.id} style={{ textAlign: "center", margin: "12px 0" }}><span style={{ display: "inline-block", padding: "4px 12px", borderRadius: 20, background: "#e6f4ff", border: "1px solid #91caff", fontSize: 11.5, color: "#0958d9" }}>{m.text}</span></div>
: <Bubble key={m.id} author={m.author} text={m.text} accent={accent} />))}
? <SystemMessage key={m.id} text={m.text} />
: <Bubble key={m.id} author={m.author} text={m.text} accent={accent} time={m.createdAt} />))}
{pending.map((t, i) => <Bubble key={`p${i}`} author="client" text={t} accent={accent} pendingState />)}
{awaiting && <Typing />}
</div>
<div style={{ flex: "none", background: "#fff", borderTop: "1px solid #f0f0f0", padding: "10px 12px 12px" }}>
<div style={{ display: "flex", alignItems: "flex-end", gap: 8, border: "1px solid #e8e8e8", borderRadius: 12, padding: "6px 6px 6px 12px", background: "#fff" }}>
<textarea
rows={1}
value={input}
onChange={(e) => setInput(e.target.value)}
onKeyDown={(e) => { if (e.key === "Enter" && !e.shiftKey) { e.preventDefault(); void send(); } }}
placeholder="Опишите вопрос…"
style={{ flex: 1, border: "none", outline: "none", resize: "none", fontSize: 13.5, lineHeight: 1.5, color: "#262626", fontFamily: "inherit", padding: "6px 0", maxHeight: 90 }}
/>
<button onClick={() => void send()} aria-label="Отправить" style={{ width: 34, height: 34, borderRadius: 8, border: "none", background: accent, display: "flex", alignItems: "center", justifyContent: "center", cursor: "pointer", flex: "none" }}>
<svg viewBox="0 0 24 24" width={16} height={16} 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>
</div>
</div>
<ChatComposer accent={accent} state={state} quickReplies={[]} pendingCount={pending.length} messageCount={messages.length} input={input} placeholder="Опишите вопрос…" onInput={setInput} onSend={() => void send()} />
</div>
);
}
@@ -181,62 +173,6 @@ function modeOf(controlMode: string): "ai" | "operator" | "waiting" {
return "ai";
}
function shell(accent: string): React.CSSProperties {
function shell(): React.CSSProperties {
return { 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" };
}
function Header({ accent, title, statusLabel, dot }: { accent: string; title: string; statusLabel: string; dot: string }) {
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" }}>П</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: dot, flex: "none" }} />
<span style={{ fontSize: 11.5, color: "rgba(255,255,255,0.92)" }}>{statusLabel}</span>
</div>
</div>
<button onClick={closePanel} aria-label="Свернуть" 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>
</button>
</div>
);
}
function Bubble({ author, text, accent, pendingState }: { author: "client" | "ai" | "operator"; text: string; accent: string; pendingState?: boolean }) {
if (author === "client") {
return (
<div style={{ display: "flex", justifyContent: "flex-end", marginBottom: 10 }}>
<div style={{ maxWidth: "78%" }}>
<div style={{ background: accent, color: "#fff", borderRadius: "14px 14px 4px 14px", padding: "9px 13px", fontSize: 13.5, lineHeight: 1.45, whiteSpace: "pre-wrap" }}>{text}</div>
<div style={{ fontSize: 10.5, color: "#bfbfbf", margin: "3px 4px 0 0", textAlign: "right" }}>{pendingState ? "отправка…" : "доставлено"}</div>
</div>
</div>
);
}
const isOperator = author === "operator";
return (
<div style={{ display: "flex", gap: 8, marginBottom: 10 }}>
<div style={{ width: 28, height: 28, 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, fontWeight: 600 }}>О</span> : <svg viewBox="0 0 24 24" width={15} height={15} 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: "78%" }}>
<div style={{ background: "#fff", border: "1px solid #eee", borderRadius: "14px 14px 14px 4px", padding: "9px 13px", fontSize: 13.5, lineHeight: 1.45, color: "#262626", whiteSpace: "pre-wrap" }}>{text}</div>
<div style={{ fontSize: 10.5, color: "#bfbfbf", margin: "3px 0 0 4px" }}>{isOperator ? "Специалист" : "Виртуальный помощник"}</div>
</div>
</div>
);
}
function Typing() {
return (
<div style={{ display: "flex", gap: 8, marginBottom: 4 }}>
<div style={{ width: 28, height: 28, borderRadius: "50%", background: "#eef0f2", border: "1px solid #e3e6ea", display: "flex", alignItems: "center", justifyContent: "center", flex: "none" }}>
<svg viewBox="0 0 24 24" width={15} height={15} 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((d) => <span key={d} style={{ width: 6, height: 6, borderRadius: "50%", background: "#b37feb", animation: `wcTyping 1.2s infinite ease-in-out ${d}s` }} />)}
</div>
</div>
);
}
+39
View File
@@ -0,0 +1,39 @@
import { useCallback, useEffect, type RefObject } from "react";
/**
* Держит ленту прижатой к последнему сообщению.
*
* Лоадер создаёт iframe заранее и прячет его через `display:none`, поэтому при
* первом рендере у ленты `scrollHeight === 0` и обычный автоскролл ничего не
* делает — панель открывается с началом истории. Поэтому дополнительно слушаем
* `edevs-chat-opened` от лоадера и доскроллим уже после показа панели.
*
* Возвращает функцию скролла — компонент вызывает её из своего эффекта на
* приход новых сообщений.
*/
export function useScrollToLatest(bodyRef: RefObject<HTMLDivElement | null>): () => void {
const scrollToLatest = useCallback(() => {
const node = bodyRef.current;
if (node) node.scrollTop = node.scrollHeight;
}, [bodyRef]);
useEffect(() => {
let timer = 0;
function onParentMessage(event: MessageEvent) {
const data = event.data as { type?: string } | null;
if (data?.type !== "edevs-chat-opened") return;
scrollToLatest();
// Панель только что получила display:block: если размеры ленты ещё не
// пересчитаны, повторяем после текущей задачи. Через rAF нельзя — в
// фоновой вкладке кадры не отрисовываются и колбэк не приходит.
timer = window.setTimeout(scrollToLatest, 0);
}
window.addEventListener("message", onParentMessage);
return () => {
window.clearTimeout(timer);
window.removeEventListener("message", onParentMessage);
};
}, [scrollToLatest]);
return scrollToLatest;
}