mirror of
https://github.com/dartdavros/chatballs.git
synced 2026-10-05 01:14:58 +03:00
✨ feat(webchat): добавить форму перед началом чата
This commit is contained in:
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, временные настройки подключения полностью восстановлены.
|
||||
@@ -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,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("");
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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></>;
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "Отменить запись",
|
||||
|
||||
@@ -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, ""));
|
||||
}
|
||||
@@ -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; }
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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];
|
||||
}));
|
||||
}
|
||||
@@ -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; }));
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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) };
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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;
|
||||
Reference in new issue
Block a user