+
{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) => onInput(reply)} style={{ padding: "7px 13px", borderRadius: 16, border: "1px solid #d6e4ff", background: "#f0f7ff", color: "#0958d9", fontSize: 12.5, fontWeight: 500, cursor: "pointer" }}>{reply} )}
}
-
);
@@ -153,7 +192,7 @@ export function CallInviteBanner({ call, accent, onAccept, onDecline }: { call:
?
:
;
const title = isVideo ? t("chat.incoming_video_call") : t("chat.incoming_audio_call");
- return
{inviteIcon}
{title}
{t("chat.invites_you_to_call", { name: call.staffName || t("call.operator") })}
{phoneIcon(true)} {phoneIcon(false)} ;
+ return
{inviteIcon}
{title}
{t("chat.invites_you_to_call", { name: call.staffName || t("call.operator") })}
{phoneIcon(true)} {phoneIcon(false)} ;
}
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 (
+
+ {operator
+ ? {t("chat.operator_initial")}
+ : }
+ {name}
+
+ );
+}
+
+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
?
: attachment
?
: text;
- if (author === "client") return
{content}
{pendingState ? t("chat.sending_lower") : [at, t("chat.delivered")].filter(Boolean).join(" · ")}
;
- const isOperator = author === "operator";
- return
{isOperator ?
{t("chat.operator_initial")} :
}
{content}
{[isOperator ? t("chat.specialist") : t("chat.virtual_assistant"), at].filter(Boolean).join(" · ")}
;
+ if (author === "client") {
+ return (
+
+
+
{content}
+
{pendingState ? t("chat.sending_lower") : at}
+
+
+ );
+ }
+ const operator = author === "operator";
+ return (
+
+ );
}
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
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 }} />
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")} {error &&
{t("chat.share_number_failed")}
}
;
+ return
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 }} />
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")} {error &&
{t("chat.share_number_failed")}
}
;
}
-export function Typing() {
- return
{[0, 0.2, 0.4].map((delay) => )}
;
+export function Typing({ accent, authorName }: { accent: string; authorName: string }) {
+ return (
+
+
+
+ {t("chat.typing")}
+
+ {[0, 0.2, 0.4].map((delay) => )}
+
+
+
+ );
}
diff --git a/apps/web-chat/src/api.ts b/apps/web-chat/src/api.ts
index ed9aae1..917ea41 100644
--- a/apps/web-chat/src/api.ts
+++ b/apps/web-chat/src/api.ts
@@ -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 };
diff --git a/apps/web-chat/src/i18n/en.ts b/apps/web-chat/src/i18n/en.ts
index 3527c57..c0cb3b6 100644
--- a/apps/web-chat/src/i18n/en.ts
+++ b/apps/web-chat/src/i18n/en.ts
@@ -14,7 +14,6 @@ export const en: Record
= {
"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 = {
"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",
diff --git a/apps/web-chat/src/i18n/ru.ts b/apps/web-chat/src/i18n/ru.ts
index 0dc0b4b..d6c207f 100644
--- a/apps/web-chat/src/i18n/ru.ts
+++ b/apps/web-chat/src/i18n/ru.ts
@@ -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": "О",
diff --git a/apps/web-chat/src/styles.css b/apps/web-chat/src/styles.css
index 2a07601..460b7e5 100644
--- a/apps/web-chat/src/styles.css
+++ b/apps/web-chat/src/styles.css
@@ -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; }