✨ feat(webchat): добавить форму перед началом чата

This commit is contained in:
Andrey committed 2026-10-01 17:54:56 +03:00
1 parent 6c04ce30d8
commit 1a71abfe77
19 files changed
+441 -44

No files matched your search

@@ -0,0 +1,33 @@
---
id: T-018
title: Форма перед чатом в виджете
milestone: M03
status: done
depends_on:
- T-016
- T-013
order: 3
spec: "0020"
created: 2026-09-29
branch: skaro/T-018-forma-pered-chatom-v-vidzhete
---
## Цель
Клиент видит форму вместо согласия, как в кадре M1, и начинает чат только с заполненными обязательными полями (R-6 – R-9, R-11 – R-13).
## Критерии приёмки
- [x] При включённой форме Consent и StartChatFooter заменяются формой: аватар агента, заголовок, карточка полей, текст согласия, «Начать чат»
- [x] «Начать чат» недоступна без обязательных полей и при невалидных email/телефоне (formatPhone); контролы по типу: переключатель, select, нативный datetime
- [x] Значения из setFields показаны заполненными с пометкой «с сайта» и редактируемы
- [x] Форма не показывается повторно при действующей сессии и той же версии согласия; модель не вызывается до «Начать чат»
- [x] ChatView.tsx разделён: форма — отдельные компоненты, ни один файл не превышает ~300 строк; ключи в web-chat/src/i18n/ru.ts и en.ts
## Заметки
Макет M1. web-chat/src/ChatView.tsx (уже 321 строка), App.tsx.
## Итог
Реализована форма перед чатом по M1: типовые поля, проверка обязательности и формата, предзаполнение из setFields и приоритет правок клиента. Согласие сохраняется вместе с его версией, повторное принятие сохраняет действующую сессию. Коммит 7dc5cf88; сборка и адресные тесты прошли, браузерная проверка выполнена через task-local Playwright на реальном backend, временные настройки подключения полностью восстановлены.
+6 -3
View File
@@ -8,6 +8,7 @@ import { resolveWidgetAppearance } from "./widgetAppearance";
import { WidgetStyles } from "./WidgetStyles";
import { widgetClasses as classes } from "./widgetClasses";
import { t } from "./i18n";
import { PreChatForm } from "./PreChatForm";
function closePanel() {
window.parent.postMessage({ type: "chatballs-chat-close" }, "*");
@@ -19,12 +20,14 @@ function requestExpanded(expanded: boolean) {
}
export function App() {
const { config, accepted, messages, pending, awaiting, thinking, call, input, setInput, starting, recorder, bodyRef, token, lastContactRequestId, showPhoneForm, unavailable, accept, send, submitContact, acceptCallInvite, declineCallInvite, attachment, attachmentError, setAttachment, setAttachmentError } = useChatSession();
const { config, accepted, siteValues, messages, pending, awaiting, thinking, call, input, setInput, starting, recorder, bodyRef, token, lastContactRequestId, showPhoneForm, unavailable, accept, send, submitContact, acceptCallInvite, declineCallInvite, attachment, attachmentError, setAttachment, setAttachmentError } = useChatSession();
const [expanded, setExpanded] = useState(false);
const fullscreen = usePanelFullscreen();
const { accent, headerIcon, customCss } = resolveWidgetAppearance(config);
const title = config?.title || t("chat.chat");
const showPreChat = Boolean(config?.available && config.preChat?.enabled && !accepted);
function toggleExpanded() {
setExpanded((previous) => {
requestExpanded(!previous);
@@ -36,9 +39,9 @@ export function App() {
<div className={classes.root} style={{ "--cb-accent": accent } as React.CSSProperties}>
<WidgetStyles customCss={customCss} />
<ChatHeader icon={headerIcon} accent={accent} title={title} expanded={expanded} canExpand={!fullscreen} 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} />
{showPreChat && config ? <PreChatForm config={config} accent={accent} title={title} siteValues={siteValues} starting={starting} onAccept={accept} /> : <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 && !showPreChat && <StartChatFooter accent={accent} starting={starting} onAccept={() => void accept()} />}
{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>
+1 -14
View File
@@ -1,20 +1,7 @@
import { useState } from "react";
import { t } from "./i18n";
import { widgetClasses as classes } from "./widgetClasses";
function formatPhone(raw: string): string {
let digits = raw.replace(/\D/g, "");
if (!digits) return "";
if (digits.startsWith("8")) digits = "7" + digits.slice(1);
if (!digits.startsWith("7")) digits = "7" + digits;
digits = digits.slice(0, 11);
let out = "+7";
if (digits.length > 1) out += ` (${digits.slice(1, 4)}`;
if (digits.length >= 4) out += ")";
if (digits.length > 4) out += ` ${digits.slice(4, 7)}`;
if (digits.length > 7) out += `-${digits.slice(7, 9)}`;
if (digits.length > 9) out += `-${digits.slice(9, 11)}`;
return out;
}
import { formatPhone } from "./phoneFormat";
export function PhoneForm({ accent, onSubmit }: { accent: string; onSubmit: (phone: string) => Promise<boolean> }) {
const [phone, setPhone] = useState("");
+36
View File
@@ -0,0 +1,36 @@
import { Switch } from "antd";
import type { PreChatField as Field } from "./preChatModel";
import { formatPhone } from "./phoneFormat";
import { t } from "./i18n";
import { widgetClasses as classes } from "./widgetClasses";
export function PreChatField({ field, value, fromSite, onChange }: {
field: Field;
value: string | boolean;
fromSite: boolean;
onChange: (value: string | boolean) => void;
}) {
const id = `pre-chat-${field.key}`;
return (
<div>
<label htmlFor={id} className={classes.preChatLabel}>
{field.label}
{field.required && <span className={classes.preChatRequired} aria-hidden="true">*</span>}
{fromSite && <small className={classes.preChatSource}>{t("pre_chat.from_site")}</small>}
</label>
{field.type === "boolean"
? <Switch id={id} checked={value === true} onChange={onChange} aria-label={field.label} aria-required={field.required} />
: field.type === "enum"
? <select id={id} className={classes.preChatInput} data-from-site={fromSite} value={String(value)} required={field.required} onChange={(event) => onChange(event.target.value)}>
<option value="" />
{field.options?.map((option) => <option key={option.value} value={option.value}>{option.label}</option>)}
</select>
: <input id={id} className={classes.preChatInput} data-from-site={fromSite}
type={field.type === "datetime" ? "datetime-local" : field.type === "email" ? "email" : field.type === "phone" ? "tel" : "text"}
inputMode={field.type === "phone" ? "tel" : field.type === "number" ? "decimal" : undefined}
value={String(value)} required={field.required} maxLength={500}
placeholder={field.type === "phone" ? t("pre_chat.phone_placeholder") : undefined}
onChange={(event) => onChange(field.type === "phone" ? formatPhone(event.target.value) : event.target.value)} />}
</div>
);
}
+54
View File
@@ -0,0 +1,54 @@
import { useState } from "react";
import { ConfigProvider } from "antd";
import type { SiteFields, WebConfig } from "./api";
import { BotAvatar } from "./BotIcon";
import { PreChatField } from "./PreChatField";
import { formPayload, inputValue, preChatFields, validField } from "./preChatModel";
import { t } from "./i18n";
import { widgetClasses as classes } from "./widgetClasses";
export function PreChatForm({ config, accent, title, siteValues, starting, onAccept }: {
config: WebConfig;
accent: string;
title: string;
siteValues: SiteFields;
starting: boolean;
onAccept: (fields: SiteFields) => Promise<void>;
}) {
const fields = preChatFields(config);
const [edits, setEdits] = useState<Record<string, string | boolean>>({});
const values = Object.fromEntries(fields.map((field) => [field.key,
Object.hasOwn(edits, field.key) ? edits[field.key] : inputValue(field, siteValues[field.key]),
]));
const valid = fields.every((field) => validField(field, values[field.key]));
return (
<ConfigProvider theme={{ token: { colorPrimary: accent } }}>
<form className={classes.preChat} noValidate onSubmit={(event) => {
event.preventDefault();
if (valid && !starting) void onAccept(formPayload(fields, values));
}}>
<div className={classes.body}>
<div className={classes.preChatIntro}>
<BotAvatar size={56} accent={accent} />
<h3>{title}</h3>
{config.preChat?.title && <p>{config.preChat.title}</p>}
</div>
<div className={classes.preChatCard}>
{fields.map((field) => <PreChatField key={field.key} field={field} value={values[field.key]}
fromSite={Object.hasOwn(siteValues, field.key) && siteValues[field.key] != null && siteValues[field.key] !== ""}
onChange={(value) => setEdits((previous) => ({ ...previous, [field.key]: value }))} />)}
</div>
<div className={classes.preChatConsent}>
{config.consent?.text} · {t("pre_chat.consent_revision", { version: config.consent?.version ?? "" })}
</div>
</div>
<div className={classes.startFooter}>
<button type="submit" className={classes.startButton} disabled={!valid || starting}>
{starting ? t("chat.starting") : t("pre_chat.start")}
</button>
</div>
</form>
</ConfigProvider>
);
}
+2 -1
View File
@@ -1,6 +1,7 @@
import baseCss from "./widget.css?raw";
import preChatCss from "./preChat.css?raw";
/** Both sheets belong to the chat document, never the embedding page. */
export function WidgetStyles({ customCss }: { customCss: string }) {
return <><style data-cb-styles="base">{baseCss}</style><style data-cb-styles="custom">{customCss}</style></>;
return <><style data-cb-styles="base">{baseCss + preChatCss}</style><style data-cb-styles="custom">{customCss}</style></>;
}
+5 -2
View File
@@ -1,4 +1,5 @@
import type { WidgetAppearance } from "./widgetAppearance";
import type { FieldSchema, PreChatConfig } from "./preChatModel";
import { t } from "./i18n";
@@ -21,6 +22,8 @@ export type WebConfig = {
consent?: { text: string; version: string };
quickReplies?: string[];
fallback?: { label: string; url: string }[];
fields?: FieldSchema[];
preChat?: PreChatConfig;
};
// kind: "" — текст, "contact_request" — виджет рисует форму телефона,
@@ -69,11 +72,11 @@ export async function getConfig(entry: WidgetEntry, hostOrigin: string): Promise
export type SiteFields = Record<string, string | number | boolean | null>;
export async function startSession(entry: WidgetEntry, hostOrigin: string, fields: SiteFields = {}): Promise<string | null> {
export async function startSession(entry: WidgetEntry, hostOrigin: string, fields: SiteFields = {}, preChatFields?: SiteFields, consentVersion?: string): Promise<string | null> {
const r = await fetch(`${API}/session/`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ ...entry, hostOrigin, fields }),
body: JSON.stringify({ ...entry, hostOrigin, fields, preChatFields, consentVersion }),
});
if (!r.ok) return null;
return (await r.json()).token as string;
+7
View File
@@ -5,6 +5,13 @@ import type { Message } from "@chatballs/shared";
import type { ChatMessageKey } from "./ru";
export const en: Record<ChatMessageKey, Message> = {
"pre_chat.name": "Name",
"pre_chat.email": "Email",
"pre_chat.phone": "Phone",
"pre_chat.phone_placeholder": "+7 (___) ___-__-__",
"pre_chat.from_site": "from website",
"pre_chat.start": "Start chat",
"pre_chat.consent_revision": "revision {version}",
"chat.accept_and_start": "Accept and start the chat",
"chat.attach_file": "Attach a file",
"chat.cancel_recording": "Cancel the recording",
+7
View File
@@ -6,6 +6,13 @@
import type { Message } from "@chatballs/shared";
export const ru = {
"pre_chat.name": "Имя",
"pre_chat.email": "Email",
"pre_chat.phone": "Телефон",
"pre_chat.phone_placeholder": "+7 (___) ___-__-__",
"pre_chat.from_site": "с сайта",
"pre_chat.start": "Начать чат",
"pre_chat.consent_revision": "редакция {version}",
"chat.accept_and_start": "Принять и начать чат",
"chat.attach_file": "Прикрепить файл",
"chat.cancel_recording": "Отменить запись",
+18
View File
@@ -0,0 +1,18 @@
export function formatPhone(raw: string): string {
let digits = raw.replace(/\D/g, "");
if (!digits) return "";
if (digits.startsWith("8")) digits = "7" + digits.slice(1);
if (!digits.startsWith("7")) digits = "7" + digits;
digits = digits.slice(0, 11);
let out = "+7";
if (digits.length > 1) out += ` (${digits.slice(1, 4)}`;
if (digits.length >= 4) out += ")";
if (digits.length > 4) out += ` ${digits.slice(4, 7)}`;
if (digits.length > 7) out += `-${digits.slice(7, 9)}`;
if (digits.length > 9) out += `-${digits.slice(9, 11)}`;
return out;
}
export function validPhone(raw: string): boolean {
return /^7\d{10}$/.test(raw.replace(/\D/g, ""));
}
+39
View File
@@ -0,0 +1,39 @@
:where(.cb-widget) {
--cb-text: #1f1f1f;
--cb-field-text: #262626;
--cb-label: #434343;
--cb-secondary: #595959;
--cb-muted: #8c8c8c;
--cb-required: #cf1322;
--cb-surface: #fff;
--cb-site-surface: #fafafa;
--cb-card-border: #f0f0f0;
--cb-field-border: #d9d9d9;
}
:where(.cb-pre-chat) { display: flex; flex: 1; flex-direction: column; min-height: 0; }
:where(.cb-pre-chat-intro) {
display: flex; flex-direction: column; align-items: center; text-align: center; padding: 6px 12px 4px;
}
:where(.cb-pre-chat-intro > span) { margin-bottom: 12px; }
:where(.cb-pre-chat-intro h3) { margin: 0; font-size: 17px; font-weight: 700; color: var(--cb-text); }
:where(.cb-pre-chat-intro p) {
margin: 8px 0 0; font-size: 14.5px; line-height: 21px; color: var(--cb-secondary); max-width: 300px;
}
:where(.cb-pre-chat-card) {
margin-top: 16px; background: var(--cb-surface); border: 1px solid var(--cb-card-border);
border-radius: 12px; padding: 14px; display: flex; flex-direction: column; gap: 11px;
}
:where(.cb-pre-chat-label) {
display: flex; align-items: center; gap: 6px; margin-bottom: 5px;
font-size: 12.5px; font-weight: 600; color: var(--cb-label);
}
:where(.cb-pre-chat-required) { color: var(--cb-required); }
:where(.cb-pre-chat-source) { margin-left: auto; font-size: 11px; font-weight: 500; color: var(--cb-muted); }
:where(.cb-pre-chat-input) {
width: 100%; box-sizing: border-box; height: 40px; border-radius: 8px;
border: 1px solid var(--cb-field-border); padding: 0 11px; outline: none;
font-size: 14px; font-family: inherit; color: var(--cb-field-text); background: var(--cb-surface);
}
:where(.cb-pre-chat-input[data-from-site="true"]) { background: var(--cb-site-surface); }
:where(.cb-pre-chat-consent) { margin-top: 12px; padding: 0 4px; font-size: 12px; color: var(--cb-secondary); line-height: 1.5; }
:where(.cb-pre-chat .cb-start-button:disabled) { cursor: default; }
+68
View File
@@ -0,0 +1,68 @@
import { describe, expect, it } from "vitest";
import { formPayload, inputValue, preChatFields, validField, type PreChatField } from "./preChatModel";
import { formatPhone } from "./phoneFormat";
import { consentMatches } from "./useWidgetConsent";
import { createElement } from "react";
import { renderToStaticMarkup } from "react-dom/server";
import { PreChatField as FieldControl } from "./PreChatField";
function field(type: PreChatField["type"], required = true): PreChatField {
return { key: "value", label: "Value", type, required };
}
describe("pre-chat validation and session consent", () => {
it("renders a switch, enum choices and a native date/time control", () => {
const render = (definition: PreChatField, value: string | boolean) => renderToStaticMarkup(createElement(FieldControl, {
field: definition, value, fromSite: false, onChange: () => {},
}));
expect(render(field("boolean"), false)).toContain('role="switch"');
const select = render({ ...field("enum"), options: [{ value: "new", label: "New" }] }, "new");
expect(select).toContain("<select");
expect(select).toContain('value="new" selected=""');
expect(render(field("datetime"), "2026-10-01T12:30")).toContain('type="datetime-local"');
});
it("uses configured order, required flags and custom schema labels", () => {
expect(preChatFields({ available: true, fields: [{ key: "order", label: "Order", type: "number" }],
preChat: { enabled: true, title: "", fields: [{ key: "order", required: false }, { key: "email", required: true }] },
}).map(({ key, label, type, required }) => ({ key, label, type, required }))).toEqual([
{ key: "order", label: "Order", type: "number", required: false },
{ key: "email", label: "Email", type: "email", required: true },
]);
});
it("blocks missing required values and invalid optional email and phone", () => {
expect(validField(field("string"), " ")).toBe(false);
expect(validField(field("email", false), "")).toBe(true);
expect(validField(field("email", false), "client@")).toBe(false);
expect(validField(field("email", false), "client..name@example.test")).toBe(false);
expect(validField(field("email", false), "client@-example.test")).toBe(false);
expect(validField(field("email"), "client@example.test")).toBe(true);
expect(validField(field("phone", false), formatPhone("8999"))).toBe(false);
expect(validField(field("phone"), formatPhone("89991234567"))).toBe(true);
expect(inputValue(field("phone"), "89991234567")).toBe("+7 (999) 123-45-67");
});
it("treats false and zero as values, checks enum membership and typed values", () => {
expect(validField(field("boolean"), false)).toBe(true);
expect(validField(field("boolean"), "")).toBe(false);
expect(validField(field("number"), "0")).toBe(true);
expect(validField(field("number"), "abc")).toBe(false);
expect(validField({ ...field("enum"), options: [{ value: "new", label: "New" }] }, "old")).toBe(false);
expect(validField(field("datetime"), "not-a-date")).toBe(false);
expect(validField(field("datetime"), "2026-10-01T12:30")).toBe(true);
expect(validField(field("url"), "javascript:alert(1)")).toBe(false);
});
it("sends typed values and explicit clears overriding site values", () => {
const fields = [field("string", false), { ...field("boolean"), key: "flag" }, { ...field("number"), key: "count" }];
expect(formPayload(fields, { value: " ", flag: false, count: "0" })).toEqual({ value: null, flag: false, count: 0 });
});
it("requires both a session and consent to the current version", () => {
expect(consentMatches("session", "v3", "v3")).toBe(true);
expect(consentMatches("session", "v2", "v3")).toBe(false);
expect(consentMatches("session", null, "v3")).toBe(false);
expect(consentMatches(null, "v3", "v3")).toBe(false);
expect(consentMatches("session", "v3", undefined)).toBe(false);
});
});
+72
View File
@@ -0,0 +1,72 @@
import type { SiteFields, WebConfig } from "./api";
import { t } from "./i18n";
import { formatPhone, validPhone } from "./phoneFormat";
export type FieldType = "string" | "number" | "boolean" | "datetime" | "enum" | "email" | "phone" | "url";
export type FieldSchema = { key: string; label: string; type: FieldType; options?: { value: string; label: string }[] };
export type PreChatConfig = { enabled: boolean; title: string; fields: { key: string; required: boolean }[] };
export type PreChatField = FieldSchema & { required: boolean };
function validEmail(text: string): boolean {
const parts = text.split("@");
if (parts.length !== 2) return false;
const [local, domain] = parts;
if (!local || local.startsWith(".") || local.endsWith(".") || local.includes("..")) return false;
return /^[a-zA-Z0-9.!#$%&'*+/=?^_`{|}~-]+$/.test(local)
&& domain.includes(".")
&& domain.split(".").every((part) => /^[a-zA-Z0-9](?:[a-zA-Z0-9-]*[a-zA-Z0-9])?$/.test(part));
}
export function preChatFields(config: WebConfig): PreChatField[] {
const schema: FieldSchema[] = [
{ key: "name", label: t("pre_chat.name"), type: "string" },
{ key: "email", label: t("pre_chat.email"), type: "email" },
{ key: "phone", label: t("pre_chat.phone"), type: "phone" },
...(config.fields ?? []),
];
return (config.preChat?.fields ?? []).flatMap((selected) => {
const field = schema.find((item) => item.key === selected.key);
return field ? [{ ...field, required: selected.required }] : [];
});
}
export function inputValue(field: PreChatField, value: SiteFields[string] | undefined): string | boolean {
if (field.type === "boolean") return typeof value === "boolean" ? value : "";
if (value == null) return "";
const text = String(value);
if (field.type === "phone") return formatPhone(text);
if (field.type === "datetime" && text && !Number.isNaN(Date.parse(text))) {
const date = new Date(text);
const local = new Date(date.getTime() - date.getTimezoneOffset() * 60_000);
return local.toISOString().slice(0, 16);
}
return text;
}
export function validField(field: PreChatField, value: string | boolean): boolean {
if (typeof value === "boolean") return field.type === "boolean";
const text = value.trim();
if (!text) return !field.required;
if (text.length > 500) return false;
switch (field.type) {
case "email": return validEmail(text);
case "phone": return validPhone(text);
case "boolean": return false;
case "enum": return Boolean(field.options?.some((option) => option.value === text));
case "datetime": return !Number.isNaN(Date.parse(text));
case "number": return Number.isFinite(Number(text));
case "url": {
try { return ["http:", "https:"].includes(new URL(text).protocol); } catch { return false; }
}
default: return true;
}
}
export function formPayload(fields: PreChatField[], values: Record<string, string | boolean>): SiteFields {
return Object.fromEntries(fields.map((field) => {
const value = values[field.key];
if (typeof value === "boolean") return [field.key, value];
const text = (value ?? "").trim();
return [field.key, !text ? null : field.type === "number" ? Number(text) : text];
}));
}
+15
View File
@@ -38,6 +38,21 @@ describe("site fields delivery", () => {
expect(calls[0]).toMatchObject({ token: "session", fields: { status: "delivered" } });
});
it("publishes site values before a session without sending them", async () => {
const { sender, calls } = setup();
const initial = sender.getSnapshot();
let updates = 0;
const unsubscribe = sender.subscribe(() => { updates += 1; });
sender.merge({ name: "Иван", confirmed: false });
sender.merge({ name: "Иван" });
expect(sender.getSnapshot()).toEqual({ name: "Иван", confirmed: false });
expect(initial).toEqual({});
expect(updates).toBe(1);
await vi.advanceTimersByTimeAsync(1000);
expect(calls).toEqual([]);
unsubscribe();
});
it("coalesces updates and sends at most once every 500 ms without overlapping", async () => {
let finish!: (ok: boolean) => void;
const { sender, calls } = setup(() => new Promise<boolean>((done) => { finish = done; }));
+14 -1
View File
@@ -5,6 +5,12 @@ const INTERVAL_MS = 500;
/** Поля живут в памяти; запросы сериализованы, включая старт сессии. */
export class SiteFieldsSender {
private values: SiteFields = {};
private listeners = new Set<() => void>();
getSnapshot = () => this.values;
subscribe = (listener: () => void) => {
this.listeners.add(listener);
return () => { this.listeners.delete(listener); };
};
private pending: SiteFields = {};
private token: string | null = null;
private timer: ReturnType<typeof setTimeout> | undefined;
@@ -27,13 +33,20 @@ export class SiteFieldsSender {
merge(fields: unknown) {
if (!fields || typeof fields !== "object" || Array.isArray(fields)) return;
const next = { ...this.values };
let changed = false;
for (const [key, value] of Object.entries(fields)) {
if (!/^[a-z][a-z0-9_]{0,39}$/.test(key)) continue;
if (value !== null && typeof value !== "string" && typeof value !== "boolean"
&& (typeof value !== "number" || !Number.isFinite(value))) continue;
if (Object.hasOwn(this.values, key) && this.values[key] === value) continue;
this.values[key] = value;
next[key] = value;
this.pending[key] = value;
changed = true;
}
if (changed) {
this.values = next;
this.listeners.forEach((listener) => listener());
}
this.schedule();
}
+4 -21
View File
@@ -9,14 +9,13 @@ import {
sendMessage,
sendFile,
sendVoice,
startSession,
type CallInfo,
type Poll,
type WebConfig,
type WebMessage,
} from "./api";
import { useScrollToLatest } from "./useScrollToLatest";
import { useSiteFields } from "./useSiteFields";
import { useWidgetConsent } from "./useWidgetConsent";
import { useChatPolling } from "./useChatPolling";
import { useVoiceRecorder } from "./useVoiceRecorder";
import { useWidgetActivity } from "./widgetActivity";
@@ -31,9 +30,7 @@ const TOKEN_KEY = `chatballs-chat-token:${WIDGET_KEY || `channel:${LEGACY_CHANNE
export function useChatSession() {
const [config, setConfig] = useState<WebConfig | null>(null);
const [token, setToken] = useState<string | null>(() => localStorage.getItem(TOKEN_KEY));
const [accepted, setAccepted] = useState<boolean>(() => Boolean(localStorage.getItem(TOKEN_KEY)));
const siteFields = useSiteFields(token, HOST_ORIGIN);
const { token, accepted, starting, accept, forgetConsent, siteValues } = useWidgetConsent(config, ENTRY, HOST_ORIGIN, TOKEN_KEY);
const [messages, setMessages] = useState<WebMessage[]>([]);
const [pending, setPending] = useState<string[]>([]);
const [state, setState] = useState<"ai" | "operator" | "waiting">("ai");
@@ -42,7 +39,6 @@ export function useChatSession() {
// висит «печатает».
const [thinking, setThinking] = useState(false);
const [input, setInput] = useState("");
const [starting, setStarting] = useState(false);
const [contactSent, setContactSent] = useState(false);
const [attachment, setAttachment] = useState<File | null>(null);
const [attachmentError, setAttachmentError] = useState("");
@@ -102,11 +98,9 @@ export function useChatSession() {
}, [scrollToLatest, messages, pending, awaiting]);
function forgetSession() {
localStorage.removeItem(TOKEN_KEY);
forgetConsent();
lastId.current = 0;
pollingReady.current = false;
setToken(null);
setAccepted(false);
setMessages([]);
setPending([]);
setAwaiting(false);
@@ -115,17 +109,6 @@ export function useChatSession() {
setContactSent(false);
}
async function accept() {
if (starting) return;
setStarting(true);
const nextToken = await siteFields.start((fields) => startSession(ENTRY, HOST_ORIGIN, fields));
setStarting(false);
if (!nextToken) return;
localStorage.setItem(TOKEN_KEY, nextToken);
setToken(nextToken);
setAccepted(true);
}
async function send() {
const text = input.trim();
if ((!text && !attachment) || !token || awaiting) return;
@@ -175,5 +158,5 @@ export function useChatSession() {
const showPhoneForm = lastContactRequestId > 0 && !(contactSent || messages.some((message) => message.kind === "contact"));
const unavailable = config !== null && !config.available;
return { config, accepted, messages, pending, awaiting, thinking, call, input, setInput, starting, recorder, bodyRef, token, lastContactRequestId, showPhoneForm, unavailable, accept, send, submitContact, acceptCallInvite, declineCallInvite, attachment, attachmentError, setAttachment, setAttachmentError };
return { config, accepted, siteValues, messages, pending, awaiting, thinking, call, input, setInput, starting, recorder, bodyRef, token, lastContactRequestId, showPhoneForm, unavailable, accept, send, submitContact, acceptCallInvite, declineCallInvite, attachment, attachmentError, setAttachment, setAttachmentError };
}
+3 -2
View File
@@ -1,9 +1,10 @@
import { useEffect, useState } from "react";
import { useEffect, useState, useSyncExternalStore } from "react";
import { sendSiteFields } from "./api";
import { SiteFieldsSender } from "./siteFields";
export function useSiteFields(token: string | null, hostOrigin: string) {
const [sender] = useState(() => new SiteFieldsSender(sendSiteFields));
const values = useSyncExternalStore(sender.subscribe, sender.getSnapshot);
useEffect(() => { sender.setToken(token); }, [sender, token]);
useEffect(() => {
function receive(event: MessageEvent) {
@@ -19,5 +20,5 @@ export function useSiteFields(token: string | null, hostOrigin: string) {
sender.pause();
};
}, [sender, hostOrigin]);
return sender;
return { values, start: sender.start.bind(sender) };
}
+49
View File
@@ -0,0 +1,49 @@
import { useState } from "react";
import { poll, sendSiteFields, SessionExpired, startSession, type SiteFields, type WebConfig, type WidgetEntry } from "./api";
import { useSiteFields } from "./useSiteFields";
export function consentMatches(token: string | null, saved: string | null, current: string | undefined): boolean {
return Boolean(token && current && saved === current);
}
export function useWidgetConsent(config: WebConfig | null, entry: WidgetEntry, hostOrigin: string, tokenKey: string) {
const versionKey = `${tokenKey}:consent-version`;
const [token, setToken] = useState<string | null>(() => localStorage.getItem(tokenKey));
const [version, setVersion] = useState<string | null>(() => localStorage.getItem(versionKey));
const [starting, setStarting] = useState(false);
const accepted = consentMatches(token, version, config?.consent?.version);
const siteFields = useSiteFields(accepted ? token : null, hostOrigin);
function forgetConsent() {
localStorage.removeItem(tokenKey);
localStorage.removeItem(versionKey);
setToken(null);
setVersion(null);
}
async function accept(preChatFields?: SiteFields) {
if (starting || !config?.available) return;
setStarting(true);
try {
const nextToken = await siteFields.start(async (fields) => {
if (token) {
try {
await poll(token, 0);
return await sendSiteFields(token, { ...fields, ...preChatFields }) ? token : null;
} catch (error) {
if (!(error instanceof SessionExpired)) return null;
}
}
return startSession(entry, hostOrigin, fields, preChatFields, config.consent?.version);
});
if (!nextToken) return;
const nextVersion = config.consent?.version ?? "";
localStorage.setItem(tokenKey, nextToken);
localStorage.setItem(versionKey, nextVersion);
setToken(nextToken);
setVersion(nextVersion);
} finally { setStarting(false); }
}
return { token, accepted, starting, accept, forgetConsent, siteValues: siteFields.values };
}
+8
View File
@@ -25,5 +25,13 @@ export const widgetClasses = {
form: "cb-form",
formField: "cb-form-field",
formSubmit: "cb-form-submit",
preChat: "cb-pre-chat",
preChatIntro: "cb-pre-chat-intro",
preChatCard: "cb-pre-chat-card",
preChatLabel: "cb-pre-chat-label",
preChatRequired: "cb-pre-chat-required",
preChatSource: "cb-pre-chat-source",
preChatInput: "cb-pre-chat-input",
preChatConsent: "cb-pre-chat-consent",
brand: "cb-brand",
} as const;