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 (
+
+
+
+ );
+}
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("