diff --git a/.skaro/tasks/T-017-razdel-forma-pered-chatom-v-nastroykah-p.md b/.skaro/tasks/T-017-razdel-forma-pered-chatom-v-nastroykah-p.md new file mode 100644 index 0000000..f651aef --- /dev/null +++ b/.skaro/tasks/T-017-razdel-forma-pered-chatom-v-nastroykah-p.md @@ -0,0 +1,29 @@ +--- +id: T-017 +title: Раздел «Форма перед чатом» в настройках подключения +milestone: M03 +status: done +depends_on: + - T-008 + - T-016 + - T-015 +order: 2 +spec: "0020" +created: 2026-09-29 +branch: skaro/T-017-razdel-forma-pered-chatom-v-na +--- + +## Цель + +Администратор настраивает форму в разделе W2 ровно как в макете (R-2). + +## Критерии приёмки + +- [x] Включатель формы, список полей с чекбоксами Имя/Email/Телефон и «Своё поле из „Данных с сайта“», тумблер «Обязательное», заголовок формы, текст согласия +- [x] Источник поля подписан как в макете (например «name · с сайта») +- [x] В пункте меню — подсказка «вкл», когда форма включена +- [x] Все тексты через t() в ru и en, вид совпадает с кадром W2 + +## Итог + +Реализован раздел «Форма перед чатом» по W2: включатель, встроенные и свои поля, обязательность, заголовок, согласие, серверная редакция и подсказка «вкл». Проверены сборка, адресные тесты, браузерные сценарии и размеры макета на frontend T-017:15177 с реальным backend основной копии:8010; изменения зафиксированы коммитом 634563c9. Демо-конфигурация восстановлена, серверная редакция согласия стала v3 после изменения текста и его возврата. diff --git a/apps/internal-ui/src/features/integrations/WebIntegrationPage.tsx b/apps/internal-ui/src/features/integrations/WebIntegrationPage.tsx index afde9ae..3eb314e 100644 --- a/apps/internal-ui/src/features/integrations/WebIntegrationPage.tsx +++ b/apps/internal-ui/src/features/integrations/WebIntegrationPage.tsx @@ -11,13 +11,14 @@ import { WebIntegrationHeader } from "./WebIntegrationHeader"; import { useWebIntegration } from "./useWebIntegration"; import { SiteFieldsSection } from "./site-fields/SiteFieldsSection"; import { WebIntegrationAppearance } from "./appearance/WebIntegrationAppearance"; +import { PreChatSection } from "./pre-chat/PreChatSection"; type Section = "basics" | "fields" | "form" | "look" | "danger"; const sections: SectionMenuItem
[] = [ { key: "basics", label: t("portals.basics"), icon: "settings" }, { key: "fields", label: t("settings.site_data"), icon: "code" }, - { key: "form", label: t("settings.pre_chat_form"), icon: "doc", disabled: true }, + { key: "form", label: t("settings.pre_chat_form"), icon: "form" }, { key: "look", label: t("common.appearance"), icon: "paint", divider: true }, { key: "danger", label: t("settings.delete_connection"), icon: "trash", danger: true }, ]; @@ -31,6 +32,7 @@ export function WebIntegrationPage({ integrationId, onOpenSettings, onBack }: { const [confirmingDelete, setConfirmingDelete] = useState(false); const [section, setSection] = useState
("basics"); const [fieldCount, setFieldCount] = useState(); + const [formEnabled, setFormEnabled] = useState(); if (loading) return ; if (failed) return ; @@ -41,7 +43,11 @@ export function WebIntegrationPage({ integrationId, onOpenSettings, onBack }: {
item.key === "fields" ? { ...item, hint: { text: fmt.number(fieldCount ?? integration.config.fields?.length ?? 0) } } : item)} + items={sections.map((item) => { + if (item.key === "fields") return { ...item, hint: { text: fmt.number(fieldCount ?? integration.config.fields?.length ?? 0) } }; + if (item.key === "form" && (formEnabled ?? integration.config.preChat?.enabled)) return { ...item, hint: { text: t("pre_chat.on") } }; + return item; + })} activeKey={section} note={t("settings.web_changes_apply_after_save")} onSelect={(next) => { if (next === "danger") setConfirmingDelete(true); else setSection(next); }} @@ -56,6 +62,9 @@ export function WebIntegrationPage({ integrationId, onOpenSettings, onBack }: { + {section === "look" && }
diff --git a/apps/internal-ui/src/features/integrations/model.ts b/apps/internal-ui/src/features/integrations/model.ts index 3a3d0ae..db977eb 100644 --- a/apps/internal-ui/src/features/integrations/model.ts +++ b/apps/internal-ui/src/features/integrations/model.ts @@ -2,6 +2,7 @@ import { api } from "../../api/client"; import { t } from "../../i18n"; import type { SiteField } from "./site-fields/model"; import type { WidgetAppearance } from "./appearance/model"; +import type { PreChat } from "./pre-chat/model"; export type IntegrationProvider = "OPENROUTER" | "CUSTOM" | "DEMO" | "MAX" | "TELEGRAM" | "VK" | "WEB" | "EMAIL"; export type IntegrationKind = "LLM_PROVIDER" | "MESSENGER"; @@ -28,6 +29,7 @@ export type Integration = { // email/imap*/smtp* — Email-подключение (SPEC-CHATBALLS-0025 §3.1). config: { fields?: SiteField[]; + preChat?: PreChat; baseUrl: string; defaultModel: string; transcriptionModel: string; diff --git a/apps/internal-ui/src/features/integrations/pre-chat/PreChatFields.tsx b/apps/internal-ui/src/features/integrations/pre-chat/PreChatFields.tsx new file mode 100644 index 0000000..e414a05 --- /dev/null +++ b/apps/internal-ui/src/features/integrations/pre-chat/PreChatFields.tsx @@ -0,0 +1,47 @@ +import { Dropdown } from "antd"; +import { t } from "../../../i18n"; +import { SwitchButton } from "../../../shared/form-controls"; +import { Icon } from "../../../shared/icons"; +import type { SiteField } from "../site-fields/model"; +import { CONTACT_KEYS, type PreChatField } from "./model"; + +export function PreChatFields({ fields, customFields, remainingFields, disabled, onSelect, onRequired, onAdd }: { + fields: PreChatField[]; + customFields: SiteField[]; + remainingFields: SiteField[]; + disabled: boolean; + onSelect: (key: string, selected: boolean) => void; + onRequired: (key: string, required: boolean) => void; + onAdd: (key: string) => void; +}) { + const rows = [ + ...CONTACT_KEYS.map((key) => ({ key, label: t(`pre_chat.${key}`), custom: false })), + ...customFields.map(({ key, label }) => ({ key, label, custom: true })), + ]; + return
+
{t("pre_chat.field")}{t("pre_chat.source")}{t("pre_chat.required")}
+ {rows.map((row) => { + const selected = fields.find(({ key }) => key === row.key); + return
+ + {row.label}{row.custom && {t("pre_chat.custom")}} + {row.key === "name" || row.key === "email" ? t("pre_chat.site_source", { key: row.key }) : row.key} + onRequired(row.key, !selected?.required)} /> +
; + })} +
+ ({ key, label, onClick: () => onAdd(key) })) }}> + + + {t("pre_chat.types_hint")} +
+
; +} diff --git a/apps/internal-ui/src/features/integrations/pre-chat/PreChatSection.tsx b/apps/internal-ui/src/features/integrations/pre-chat/PreChatSection.tsx new file mode 100644 index 0000000..c4af55d --- /dev/null +++ b/apps/internal-ui/src/features/integrations/pre-chat/PreChatSection.tsx @@ -0,0 +1,32 @@ +import { useEffect } from "react"; +import { t } from "../../../i18n"; +import { SwitchButton } from "../../../shared/form-controls"; +import type { Integration } from "../model"; +import { PreChatFields } from "./PreChatFields"; +import { PreChatText } from "./PreChatText"; +import { usePreChat } from "./usePreChat"; +import "./styles.css"; + +export function PreChatSection({ integration, onSaved, onEnabled }: { + integration: Integration; + onSaved: (integration: Integration) => void; + onEnabled: (enabled: boolean) => void; +}) { + const state = usePreChat(integration, onSaved); + const { preChat } = state.draft; + useEffect(() => onEnabled(preChat.enabled), [preChat.enabled, onEnabled]); + return
+

{t("settings.pre_chat_form")}

{t("pre_chat.description")}

+
+ {t("pre_chat.enabled")}{t("pre_chat.disabled_hint")} + state.change({ preChat: { ...preChat, enabled: !preChat.enabled } })} /> +
+ state.change({ + preChat: { ...preChat, fields: preChat.fields.map((field) => field.key === key ? { ...field, required } : field) }, + })} /> + void state.save()} /> +
; +} diff --git a/apps/internal-ui/src/features/integrations/pre-chat/PreChatText.tsx b/apps/internal-ui/src/features/integrations/pre-chat/PreChatText.tsx new file mode 100644 index 0000000..a61c33a --- /dev/null +++ b/apps/internal-ui/src/features/integrations/pre-chat/PreChatText.tsx @@ -0,0 +1,34 @@ +import { fmt, t } from "../../../i18n"; +import { Button } from "../../../shared/ui-controls"; +import type { PreChatDraft } from "./model"; + +export function PreChatText({ draft, version, updatedAt, busy, error, onChange, onSave }: { + draft: PreChatDraft; + version: string; + updatedAt: string; + busy: boolean; + error: string; + onChange: (patch: Partial) => void; + onSave: () => void; +}) { + const savedToday = new Date(updatedAt).toDateString() === new Date().toDateString(); + return
+ +