From 1a71abfe77f6f8218c25e16f0844daec77adf386 Mon Sep 17 00:00:00 2001 From: Andrey Date: Thu, 1 Oct 2026 17:54:56 +0300 Subject: [PATCH] =?UTF-8?q?:sparkles:=20feat(webchat):=20=D0=B4=D0=BE?= =?UTF-8?q?=D0=B1=D0=B0=D0=B2=D0=B8=D1=82=D1=8C=20=D1=84=D0=BE=D1=80=D0=BC?= =?UTF-8?q?=D1=83=20=D0=BF=D0=B5=D1=80=D0=B5=D0=B4=20=D0=BD=D0=B0=D1=87?= =?UTF-8?q?=D0=B0=D0=BB=D0=BE=D0=BC=20=D1=87=D0=B0=D1=82=D0=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../T-018-forma-pered-chatom-v-vidzhete.md | 33 +++++++++ apps/web-chat/src/App.tsx | 9 ++- apps/web-chat/src/PhoneForm.tsx | 15 +--- apps/web-chat/src/PreChatField.tsx | 36 ++++++++++ apps/web-chat/src/PreChatForm.tsx | 54 ++++++++++++++ apps/web-chat/src/WidgetStyles.tsx | 3 +- apps/web-chat/src/api.ts | 7 +- apps/web-chat/src/i18n/en.ts | 7 ++ apps/web-chat/src/i18n/ru.ts | 7 ++ apps/web-chat/src/phoneFormat.ts | 18 +++++ apps/web-chat/src/preChat.css | 39 ++++++++++ apps/web-chat/src/preChatModel.test.ts | 68 ++++++++++++++++++ apps/web-chat/src/preChatModel.ts | 72 +++++++++++++++++++ apps/web-chat/src/siteFields.test.ts | 15 ++++ apps/web-chat/src/siteFields.ts | 15 +++- apps/web-chat/src/useChatSession.ts | 25 ++----- apps/web-chat/src/useSiteFields.ts | 5 +- apps/web-chat/src/useWidgetConsent.ts | 49 +++++++++++++ apps/web-chat/src/widgetClasses.ts | 8 +++ 19 files changed, 441 insertions(+), 44 deletions(-) create mode 100644 .skaro/tasks/T-018-forma-pered-chatom-v-vidzhete.md create mode 100644 apps/web-chat/src/PreChatField.tsx create mode 100644 apps/web-chat/src/PreChatForm.tsx create mode 100644 apps/web-chat/src/phoneFormat.ts create mode 100644 apps/web-chat/src/preChat.css create mode 100644 apps/web-chat/src/preChatModel.test.ts create mode 100644 apps/web-chat/src/preChatModel.ts create mode 100644 apps/web-chat/src/useWidgetConsent.ts diff --git a/.skaro/tasks/T-018-forma-pered-chatom-v-vidzhete.md b/.skaro/tasks/T-018-forma-pered-chatom-v-vidzhete.md new file mode 100644 index 0000000..d764fc3 --- /dev/null +++ b/.skaro/tasks/T-018-forma-pered-chatom-v-vidzhete.md @@ -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, временные настройки подключения полностью восстановлены. diff --git a/apps/web-chat/src/App.tsx b/apps/web-chat/src/App.tsx index 3605801..ffc4baa 100644 --- a/apps/web-chat/src/App.tsx +++ b/apps/web-chat/src/App.tsx @@ -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() {
- voiceAudioUrl(token, id) : undefined} attachmentUrlFor={token ? (id, inline) => attachmentUrl(token, id, inline) : undefined} /> + {showPreChat && config ? : voiceAudioUrl(token, id) : undefined} attachmentUrlFor={token ? (id, inline) => attachmentUrl(token, id, inline) : undefined} />} {config?.available && accepted && call && (call.status === "REQUESTED" || call.status === "RINGING") && void acceptCallInvite()} onDecline={() => void declineCallInvite()} />} - {config?.available && !accepted && void accept()} />} + {config?.available && !accepted && !showPreChat && void accept()} />} {config?.available && accepted && void send()} voice={config.features?.voiceMessages === false ? undefined : recorder} attachment={{ file: attachment, errorText: attachmentError, pick: (file) => { if (!file) return; if (file.size > MAX_FILE_BYTES) { setAttachmentError(t("chat.file_too_big")); return; } setAttachmentError(""); setAttachment(file); }, clear: () => setAttachment(null) }} />} {config?.available && }
diff --git a/apps/web-chat/src/PhoneForm.tsx b/apps/web-chat/src/PhoneForm.tsx index 4d6e466..9ccb676 100644 --- a/apps/web-chat/src/PhoneForm.tsx +++ b/apps/web-chat/src/PhoneForm.tsx @@ -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 }) { const [phone, setPhone] = useState(""); diff --git a/apps/web-chat/src/PreChatField.tsx b/apps/web-chat/src/PreChatField.tsx new file mode 100644 index 0000000..e10c7ea --- /dev/null +++ b/apps/web-chat/src/PreChatField.tsx @@ -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 ( +
+ + {field.type === "boolean" + ? + : field.type === "enum" + ? + : onChange(field.type === "phone" ? formatPhone(event.target.value) : event.target.value)} />} +
+ ); +} diff --git a/apps/web-chat/src/PreChatForm.tsx b/apps/web-chat/src/PreChatForm.tsx new file mode 100644 index 0000000..24406ae --- /dev/null +++ b/apps/web-chat/src/PreChatForm.tsx @@ -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; +}) { + const fields = preChatFields(config); + const [edits, setEdits] = useState>({}); + 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 ( + +
{ + event.preventDefault(); + if (valid && !starting) void onAccept(formPayload(fields, values)); + }}> +
+
+ +

{title}

+ {config.preChat?.title &&

{config.preChat.title}

} +
+
+ {fields.map((field) => setEdits((previous) => ({ ...previous, [field.key]: value }))} />)} +
+
+ {config.consent?.text} · {t("pre_chat.consent_revision", { version: config.consent?.version ?? "" })} +
+
+
+ +
+
+
+ ); +} diff --git a/apps/web-chat/src/WidgetStyles.tsx b/apps/web-chat/src/WidgetStyles.tsx index aa03dbd..9657630 100644 --- a/apps/web-chat/src/WidgetStyles.tsx +++ b/apps/web-chat/src/WidgetStyles.tsx @@ -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 <>; + return <>; } diff --git a/apps/web-chat/src/api.ts b/apps/web-chat/src/api.ts index 4d3f7c0..5e0c8a1 100644 --- a/apps/web-chat/src/api.ts +++ b/apps/web-chat/src/api.ts @@ -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; -export async function startSession(entry: WidgetEntry, hostOrigin: string, fields: SiteFields = {}): Promise { +export async function startSession(entry: WidgetEntry, hostOrigin: string, fields: SiteFields = {}, preChatFields?: SiteFields, consentVersion?: string): Promise { 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; diff --git a/apps/web-chat/src/i18n/en.ts b/apps/web-chat/src/i18n/en.ts index c0cb3b6..f15b627 100644 --- a/apps/web-chat/src/i18n/en.ts +++ b/apps/web-chat/src/i18n/en.ts @@ -5,6 +5,13 @@ import type { Message } from "@chatballs/shared"; import type { ChatMessageKey } from "./ru"; export const en: Record = { + "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", diff --git a/apps/web-chat/src/i18n/ru.ts b/apps/web-chat/src/i18n/ru.ts index d6c207f..27fb2a3 100644 --- a/apps/web-chat/src/i18n/ru.ts +++ b/apps/web-chat/src/i18n/ru.ts @@ -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": "Отменить запись", diff --git a/apps/web-chat/src/phoneFormat.ts b/apps/web-chat/src/phoneFormat.ts new file mode 100644 index 0000000..723458f --- /dev/null +++ b/apps/web-chat/src/phoneFormat.ts @@ -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, "")); +} diff --git a/apps/web-chat/src/preChat.css b/apps/web-chat/src/preChat.css new file mode 100644 index 0000000..d75f8c1 --- /dev/null +++ b/apps/web-chat/src/preChat.css @@ -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; } diff --git a/apps/web-chat/src/preChatModel.test.ts b/apps/web-chat/src/preChatModel.test.ts new file mode 100644 index 0000000..26a5045 --- /dev/null +++ b/apps/web-chat/src/preChatModel.test.ts @@ -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(" { + 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); + }); +}); diff --git a/apps/web-chat/src/preChatModel.ts b/apps/web-chat/src/preChatModel.ts new file mode 100644 index 0000000..9f2ced8 --- /dev/null +++ b/apps/web-chat/src/preChatModel.ts @@ -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): 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]; + })); +} diff --git a/apps/web-chat/src/siteFields.test.ts b/apps/web-chat/src/siteFields.test.ts index 7a72986..f88895d 100644 --- a/apps/web-chat/src/siteFields.test.ts +++ b/apps/web-chat/src/siteFields.test.ts @@ -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((done) => { finish = done; })); diff --git a/apps/web-chat/src/siteFields.ts b/apps/web-chat/src/siteFields.ts index 3250de9..b875e50 100644 --- a/apps/web-chat/src/siteFields.ts +++ b/apps/web-chat/src/siteFields.ts @@ -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 | 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(); } diff --git a/apps/web-chat/src/useChatSession.ts b/apps/web-chat/src/useChatSession.ts index a63aa1d..6f6b893 100644 --- a/apps/web-chat/src/useChatSession.ts +++ b/apps/web-chat/src/useChatSession.ts @@ -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(null); - const [token, setToken] = useState(() => localStorage.getItem(TOKEN_KEY)); - const [accepted, setAccepted] = useState(() => 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([]); const [pending, setPending] = useState([]); 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(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 }; } diff --git a/apps/web-chat/src/useSiteFields.ts b/apps/web-chat/src/useSiteFields.ts index 1d7b3be..4c66890 100644 --- a/apps/web-chat/src/useSiteFields.ts +++ b/apps/web-chat/src/useSiteFields.ts @@ -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) }; } diff --git a/apps/web-chat/src/useWidgetConsent.ts b/apps/web-chat/src/useWidgetConsent.ts new file mode 100644 index 0000000..eb2e761 --- /dev/null +++ b/apps/web-chat/src/useWidgetConsent.ts @@ -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(() => localStorage.getItem(tokenKey)); + const [version, setVersion] = useState(() => 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 }; +} diff --git a/apps/web-chat/src/widgetClasses.ts b/apps/web-chat/src/widgetClasses.ts index 667747d..6332243 100644 --- a/apps/web-chat/src/widgetClasses.ts +++ b/apps/web-chat/src/widgetClasses.ts @@ -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;