From 223a962cb9def81dbdd485b3a618078175eec4e1 Mon Sep 17 00:00:00 2001 From: Andrey Date: Wed, 30 Sep 2026 23:18:58 +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=80=D0=B0=D0=B7=D0=B4?= =?UTF-8?q?=D0=B5=D0=BB=20=D0=B4=D0=B0=D0=BD=D0=BD=D1=8B=D1=85=20=D1=81=20?= =?UTF-8?q?=D1=81=D0=B0=D0=B9=D1=82=D0=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ...azdel-dannye-s-sayta-v-nastroykah-podkl.md | 33 ++++++++ .../integrations/WebIntegrationPage.tsx | 18 ++-- .../src/features/integrations/model.ts | 2 + .../site-fields/ContactFieldsTable.tsx | 24 ++++++ .../site-fields/CustomFieldRow.tsx | 53 ++++++++++++ .../site-fields/CustomFieldsTable.tsx | 34 ++++++++ .../integrations/site-fields/FieldDialog.tsx | 40 +++++++++ .../site-fields/FieldTypeIcon.tsx | 12 +++ .../integrations/site-fields/FieldsCode.tsx | 12 +++ .../integrations/site-fields/OptionDialog.tsx | 40 +++++++++ .../site-fields/SiteFieldsSection.tsx | 53 ++++++++++++ .../integrations/site-fields/model.test.ts | 54 ++++++++++++ .../integrations/site-fields/model.ts | 83 +++++++++++++++++++ .../integrations/site-fields/styles.css | 54 ++++++++++++ .../integrations/site-fields/useSiteFields.ts | 34 ++++++++ apps/internal-ui/src/i18n/en.ts | 2 + apps/internal-ui/src/i18n/ru.ts | 2 + apps/internal-ui/src/i18n/site-fields.en.ts | 47 +++++++++++ apps/internal-ui/src/i18n/site-fields.ru.ts | 45 ++++++++++ 19 files changed, 636 insertions(+), 6 deletions(-) create mode 100644 .skaro/tasks/T-015-razdel-dannye-s-sayta-v-nastroykah-podkl.md create mode 100644 apps/internal-ui/src/features/integrations/site-fields/ContactFieldsTable.tsx create mode 100644 apps/internal-ui/src/features/integrations/site-fields/CustomFieldRow.tsx create mode 100644 apps/internal-ui/src/features/integrations/site-fields/CustomFieldsTable.tsx create mode 100644 apps/internal-ui/src/features/integrations/site-fields/FieldDialog.tsx create mode 100644 apps/internal-ui/src/features/integrations/site-fields/FieldTypeIcon.tsx create mode 100644 apps/internal-ui/src/features/integrations/site-fields/FieldsCode.tsx create mode 100644 apps/internal-ui/src/features/integrations/site-fields/OptionDialog.tsx create mode 100644 apps/internal-ui/src/features/integrations/site-fields/SiteFieldsSection.tsx create mode 100644 apps/internal-ui/src/features/integrations/site-fields/model.test.ts create mode 100644 apps/internal-ui/src/features/integrations/site-fields/model.ts create mode 100644 apps/internal-ui/src/features/integrations/site-fields/styles.css create mode 100644 apps/internal-ui/src/features/integrations/site-fields/useSiteFields.ts create mode 100644 apps/internal-ui/src/i18n/site-fields.en.ts create mode 100644 apps/internal-ui/src/i18n/site-fields.ru.ts diff --git a/.skaro/tasks/T-015-razdel-dannye-s-sayta-v-nastroykah-podkl.md b/.skaro/tasks/T-015-razdel-dannye-s-sayta-v-nastroykah-podkl.md new file mode 100644 index 0000000..9f7c585 --- /dev/null +++ b/.skaro/tasks/T-015-razdel-dannye-s-sayta-v-nastroykah-podkl.md @@ -0,0 +1,33 @@ +--- +id: T-015 +title: Раздел «Данные с сайта» в настройках подключения +milestone: M02 +status: done +depends_on: + - T-008 + - T-009 +order: 7 +spec: "0019" +created: 2026-09-29 +branch: skaro/T-015-razdel-dannye-s-sayta-v-nastro +--- + +## Цель + +Администратор управляет своими полями в разделе W1 ровно как в макете (R-3, R-4). + +## Критерии приёмки + +- [x] Таблица «Поля контакта» (Имя, Email, Телефон) только для чтения +- [x] Таблица «Свои поля»: добавление, перетаскивание порядка, тип через select, чипы значений списка «label · value» и «+ значение» с цветом, тумблер «Видит AI», меню ⋯ (antd Dropdown, app-dropdown) — переименовать, удалить +- [x] Блок с примером кода Chatballs.setFields и копированием +- [x] Ошибки сервера (лимит 30, резерв ключа, неверный ключ) показываются по-человечески, без служебных слов +- [x] Счётчик полей в пункте меню, все тексты через t() в ru и en, файлы в пределах NO GOD FILES + +## Заметки + +Макет W1. Удаление поля не удаляет значения — это делает сервер (R-4), UI только сохраняет схему. + +## Итог + +Добавлен раздел «Данные с сайта» по W1: поля контакта только для чтения, редактор своих полей со стандартными диалогами, перетаскиванием, типами, цветными значениями списка, «Видит AI» и меню antd. Проверены сборка, 18 связанных тестов и браузерные сценарии на frontend T-015 (15175) с реальным backend основной копии (8010), без заглушек и seed; исходная схема демо-подключения восстановлена. Изменения зафиксированы коммитом 0f2c6f67. diff --git a/apps/internal-ui/src/features/integrations/WebIntegrationPage.tsx b/apps/internal-ui/src/features/integrations/WebIntegrationPage.tsx index 7c676b3..a635948 100644 --- a/apps/internal-ui/src/features/integrations/WebIntegrationPage.tsx +++ b/apps/internal-ui/src/features/integrations/WebIntegrationPage.tsx @@ -2,19 +2,20 @@ import { useState } from "react"; import "../support-portals/styles-detail.css"; import "../support-portals/styles-settings.css"; -import { t } from "../../i18n"; +import { fmt, t } from "../../i18n"; import { SectionMenu, type SectionMenuItem } from "../../shared/SectionMenu"; import { EmptyState, LoadingState } from "../../shared/ui"; import { DeleteIntegrationDialog } from "./DeleteIntegrationDialog"; import { WebIntegrationBasics } from "./WebIntegrationBasics"; import { WebIntegrationHeader } from "./WebIntegrationHeader"; import { useWebIntegration } from "./useWebIntegration"; +import { SiteFieldsSection } from "./site-fields/SiteFieldsSection"; 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", disabled: true }, + { key: "fields", label: t("settings.site_data"), icon: "code" }, { key: "form", label: t("settings.pre_chat_form"), icon: "doc", disabled: true }, { key: "look", label: t("common.appearance"), icon: "paint", disabled: true, divider: true }, { key: "danger", label: t("settings.delete_connection"), icon: "trash", danger: true }, @@ -27,6 +28,8 @@ export function WebIntegrationPage({ integrationId, onOpenSettings, onBack }: { }) { const { integration, loading, failed, setIntegration } = useWebIntegration(integrationId); const [confirmingDelete, setConfirmingDelete] = useState(false); + const [section, setSection] = useState
("basics"); + const [fieldCount, setFieldCount] = useState(); if (loading) return ; if (failed) return ; @@ -37,18 +40,21 @@ export function WebIntegrationPage({ integrationId, onOpenSettings, onBack }: {
item.key === "fields" ? { ...item, hint: { text: fmt.number(fieldCount ?? integration.config.fields?.length ?? 0) } } : item)} + activeKey={section} note={t("settings.web_changes_apply_after_save")} - onSelect={(section) => { if (section === "danger") setConfirmingDelete(true); }} + onSelect={(next) => { if (next === "danger") setConfirmingDelete(true); else setSection(next); }} />
-
+ +
{confirmingDelete && setConfirmingDelete(false)} onDeleted={onBack} />} diff --git a/apps/internal-ui/src/features/integrations/model.ts b/apps/internal-ui/src/features/integrations/model.ts index 2b96b18..a8cb251 100644 --- a/apps/internal-ui/src/features/integrations/model.ts +++ b/apps/internal-ui/src/features/integrations/model.ts @@ -1,5 +1,6 @@ import { api } from "../../api/client"; import { t } from "../../i18n"; +import type { SiteField } from "./site-fields/model"; export type IntegrationProvider = "OPENROUTER" | "CUSTOM" | "DEMO" | "MAX" | "TELEGRAM" | "VK" | "WEB" | "EMAIL"; export type IntegrationKind = "LLM_PROVIDER" | "MESSENGER"; @@ -25,6 +26,7 @@ export type Integration = { // purpose="notifications" — сервисный бот уведомлений сотрудников (не привязан к каналу продаж). // email/imap*/smtp* — Email-подключение (SPEC-CHATBALLS-0025 §3.1). config: { + fields?: SiteField[]; baseUrl: string; defaultModel: string; transcriptionModel: string; diff --git a/apps/internal-ui/src/features/integrations/site-fields/ContactFieldsTable.tsx b/apps/internal-ui/src/features/integrations/site-fields/ContactFieldsTable.tsx new file mode 100644 index 0000000..a8f2720 --- /dev/null +++ b/apps/internal-ui/src/features/integrations/site-fields/ContactFieldsTable.tsx @@ -0,0 +1,24 @@ +import { t } from "../../../i18n"; +import { FieldTypeIcon } from "./FieldTypeIcon"; + +const contacts = [ + { key: "name", type: "string", label: "common.name", target: "site_fields.target_name" }, + { key: "email", type: "email", label: "site_fields.email", target: "site_fields.target_email" }, + { key: "phone", type: "phone", label: "site_fields.phone", target: "site_fields.target_phone" }, +] as const; + +export function ContactFieldsTable() { + return
+
+ {t("site_fields.contacts")}{t("site_fields.contacts_hint")} +
+ + {["key", "label", "type", "target"].map((key) => )} + {contacts.map((field) => + + + + )} +
{t(`site_fields.${key as "key" | "label" | "type" | "target"}`)}
{field.key}{t(field.label)}{t(`site_fields.${field.type}`)}{t(field.target)}
+
; +} diff --git a/apps/internal-ui/src/features/integrations/site-fields/CustomFieldRow.tsx b/apps/internal-ui/src/features/integrations/site-fields/CustomFieldRow.tsx new file mode 100644 index 0000000..7ab0674 --- /dev/null +++ b/apps/internal-ui/src/features/integrations/site-fields/CustomFieldRow.tsx @@ -0,0 +1,53 @@ +import { Dropdown } from "antd"; +import { t } from "../../../i18n"; +import { SelectField, SwitchButton } from "../../../shared/form-controls"; +import { Icon } from "../../../shared/icons"; +import { fieldTypeOptions, type FieldType, type SiteField } from "./model"; +import { FieldTypeIcon } from "./FieldTypeIcon"; + +export function CustomFieldRow({ field, disabled, onChange, onRename, onDelete, onOption, onDrag, onDrop, onDragEnd, onMove }: { + field: SiteField; + disabled: boolean; + onChange: (field: SiteField) => void; + onRename: () => void; + onDelete: () => void; + onOption: (index?: number) => void; + onDrag: () => void; + onDrop: () => void; + onDragEnd: () => void; + onMove: (direction: number) => void; +}) { + return { if (!disabled) event.preventDefault(); }} + onDrop={(event) => { event.preventDefault(); if (!disabled) onDrop(); }}> + + {field.key} + {field.label} + {field.type === "enum" &&
+ {field.options?.map((option, index) => )} + +
} + + } + disabled={disabled} options={fieldTypeOptions} value={field.type} onChange={(type) => { + const { options, ...rest } = field; + onChange({ ...rest, type: type as FieldType, ...(type === "enum" ? { options: options ?? [] } : {}) }); + }} /> + onChange({ ...field, aiVisible: !field.aiVisible })} /> + + ; +} diff --git a/apps/internal-ui/src/features/integrations/site-fields/CustomFieldsTable.tsx b/apps/internal-ui/src/features/integrations/site-fields/CustomFieldsTable.tsx new file mode 100644 index 0000000..9e65832 --- /dev/null +++ b/apps/internal-ui/src/features/integrations/site-fields/CustomFieldsTable.tsx @@ -0,0 +1,34 @@ +import { useRef } from "react"; +import { fmt, t } from "../../../i18n"; +import { Button } from "../../../shared/ui-controls"; +import { CustomFieldRow } from "./CustomFieldRow"; +import { moveField, type SiteField } from "./model"; + +export function CustomFieldsTable({ fields, busy, onChange, onAdd, onRename, onOption }: { + fields: SiteField[]; + busy: boolean; + onChange: (fields: SiteField[]) => void; + onAdd: () => void; + onRename: (field: SiteField) => void; + onOption: (key: string, index?: number) => void; +}) { + const dragged = useRef(undefined); + return
+
+ {t("site_fields.custom")}{t("site_fields.count", { count: fmt.number(fields.length) })} +
+ + + + {fields.map((field, index) => onChange(fields.map((item) => item.key === field.key ? updated : item))} + onRename={() => onRename(field)} onDelete={() => onChange(fields.filter((item) => item.key !== field.key))} + onOption={(optionIndex) => onOption(field.key, optionIndex)} + onDrag={() => { dragged.current = field.key; }} onDragEnd={() => { dragged.current = undefined; }} + onDrop={() => { if (dragged.current) onChange(moveField(fields, dragged.current, field.key)); dragged.current = undefined; }} + onMove={(direction) => { const target = fields[index + direction]; if (target) onChange(moveField(fields, field.key, target.key)); }} + />)} +
{t("site_fields.key")}{t("site_fields.label")}{t("site_fields.type")}{t("site_fields.ai")}
+
{t("site_fields.types_hint")}
+
; +} diff --git a/apps/internal-ui/src/features/integrations/site-fields/FieldDialog.tsx b/apps/internal-ui/src/features/integrations/site-fields/FieldDialog.tsx new file mode 100644 index 0000000..deb65a0 --- /dev/null +++ b/apps/internal-ui/src/features/integrations/site-fields/FieldDialog.tsx @@ -0,0 +1,40 @@ +import { Modal } from "antd"; +import { useState } from "react"; +import { t } from "../../../i18n"; +import { FormField, SelectField } from "../../../shared/form-controls"; +import { Button } from "../../../shared/ui-controls"; +import { fieldError, fieldTypeOptions, type FieldType, type SiteField } from "./model"; + +export function FieldDialog({ initial, fields, onClose, onConfirm }: { + initial?: SiteField; + fields: SiteField[]; + onClose: () => void; + onConfirm: (field: SiteField) => void; +}) { + const [field, setField] = useState(initial ?? { + key: "", label: "", type: "string", aiVisible: false, order: fields.length, + }); + const [error, setError] = useState(); + function confirm() { + const next = { ...field, key: field.key.trim(), label: field.label.trim(), + ...(field.type === "enum" ? { options: field.options ?? [] } : {}) }; + const invalid = fieldError(next, fields, !initial); + if (invalid) { setError(invalid); return; } + onConfirm(next); + } + return +
{ event.preventDefault(); confirm(); }}> + setField({ ...field, key })} /> + {!initial && {t("site_fields.key_hint")}} + setField({ ...field, label })} /> + {!initial && setField({ ...field, type: type as FieldType })} />} + {error &&
{error}
} +
+ + +
+ +
; +} diff --git a/apps/internal-ui/src/features/integrations/site-fields/FieldTypeIcon.tsx b/apps/internal-ui/src/features/integrations/site-fields/FieldTypeIcon.tsx new file mode 100644 index 0000000..a3fb72c --- /dev/null +++ b/apps/internal-ui/src/features/integrations/site-fields/FieldTypeIcon.tsx @@ -0,0 +1,12 @@ +import { Icon } from "../../../shared/icons"; +import { fieldIcons, type FieldType } from "./model"; + +// The number and boolean glyphs come from W1; other glyphs already exist in Icon. +export function FieldTypeIcon({ type }: { type: FieldType }) { + if (type !== "number" && type !== "boolean") return ; + return ; +} diff --git a/apps/internal-ui/src/features/integrations/site-fields/FieldsCode.tsx b/apps/internal-ui/src/features/integrations/site-fields/FieldsCode.tsx new file mode 100644 index 0000000..21f4e3e --- /dev/null +++ b/apps/internal-ui/src/features/integrations/site-fields/FieldsCode.tsx @@ -0,0 +1,12 @@ +import { t } from "../../../i18n"; +import { CopyButton } from "../../../shared/ui-controls"; +import { fieldsSnippet, type SiteField } from "./model"; + +export function FieldsCode({ fields }: { fields: SiteField[] }) { + const snippet = fieldsSnippet(fields); + return
+
{t("site_fields.code_title")}{t("site_fields.code_hint")}
+ +
{snippet}
+
; +} diff --git a/apps/internal-ui/src/features/integrations/site-fields/OptionDialog.tsx b/apps/internal-ui/src/features/integrations/site-fields/OptionDialog.tsx new file mode 100644 index 0000000..f83ba04 --- /dev/null +++ b/apps/internal-ui/src/features/integrations/site-fields/OptionDialog.tsx @@ -0,0 +1,40 @@ +import { ColorPicker, Modal, theme } from "antd"; +import { useState } from "react"; +import { t } from "../../../i18n"; +import { FormField } from "../../../shared/form-controls"; +import { Button } from "../../../shared/ui-controls"; +import { optionError, type FieldOption } from "./model"; + +export function OptionDialog({ options, index, onClose, onConfirm, onDelete }: { + options: FieldOption[]; + index?: number; + onClose: () => void; + onConfirm: (option: FieldOption) => void; + onDelete: () => void; +}) { + const [option, setOption] = useState(index === undefined ? { label: "", value: "" } : options[index]); + const [error, setError] = useState(); + const { token } = theme.useToken(); + function confirm() { + const next = { ...option, label: option.label.trim(), value: option.value.trim() }; + const invalid = optionError(next, options, index); + if (invalid) { setError(invalid); return; } + onConfirm(next); + } + return +
{ event.preventDefault(); confirm(); }}> + setOption({ ...option, label })} /> + setOption({ ...option, value })} /> +
+ setOption({ ...option, color })} /> + setOption({ ...option, color: color.toHexString() })} /> +
+ {error &&
{error}
} +
+ {index !== undefined && } + + +
+ +
; +} diff --git a/apps/internal-ui/src/features/integrations/site-fields/SiteFieldsSection.tsx b/apps/internal-ui/src/features/integrations/site-fields/SiteFieldsSection.tsx new file mode 100644 index 0000000..9f42f88 --- /dev/null +++ b/apps/internal-ui/src/features/integrations/site-fields/SiteFieldsSection.tsx @@ -0,0 +1,53 @@ +import { useEffect, useState } from "react"; +import { t } from "../../../i18n"; +import { Button } from "../../../shared/ui-controls"; +import type { Integration } from "../model"; +import { ContactFieldsTable } from "./ContactFieldsTable"; +import { CustomFieldsTable } from "./CustomFieldsTable"; +import { FieldDialog } from "./FieldDialog"; +import { FieldsCode } from "./FieldsCode"; +import { OptionDialog } from "./OptionDialog"; +import type { FieldOption, SiteField } from "./model"; +import { useSiteFields } from "./useSiteFields"; +import "./styles.css"; + +type Editing = { kind: "field"; field?: SiteField } | { kind: "option"; key: string; index?: number }; + +export function SiteFieldsSection({ integration, onSaved, onCount }: { + integration: Integration; + onSaved: (integration: Integration) => void; + onCount: (count: number) => void; +}) { + const { fields, change, save, busy, error, dirty } = useSiteFields(integration, onSaved); + const [editing, setEditing] = useState(); + useEffect(() => onCount(fields.length), [fields.length, onCount]); + const optionField = editing?.kind === "option" ? fields.find((field) => field.key === editing.key) : undefined; + function confirmField(field: SiteField) { + change(editing?.kind === "field" && editing.field + ? fields.map((item) => item.key === field.key ? field : item) : [...fields, field]); + setEditing(undefined); + } + function confirmOption(option?: FieldOption) { + if (editing?.kind !== "option" || !optionField) return; + const options = [...(optionField.options ?? [])]; + if (editing.index === undefined && option) options.push(option); + else if (editing.index !== undefined) options.splice(editing.index, 1, ...(option ? [option] : [])); + change(fields.map((field) => field.key === optionField.key ? { ...field, options } : field)); + setEditing(undefined); + } + return
+

{t("settings.site_data")}

{t("site_fields.description")}

+ + setEditing({ kind: "field" })} + onRename={(field) => setEditing({ kind: "field", field })} + onOption={(key, index) => setEditing({ kind: "option", key, index })} /> + + {error &&
{error}
} +
+ {editing?.kind === "field" && setEditing(undefined)} onConfirm={confirmField} />} + {editing?.kind === "option" && optionField && setEditing(undefined)} onConfirm={confirmOption} onDelete={() => confirmOption()} />} +
; +} diff --git a/apps/internal-ui/src/features/integrations/site-fields/model.test.ts b/apps/internal-ui/src/features/integrations/site-fields/model.test.ts new file mode 100644 index 0000000..5d50b6c --- /dev/null +++ b/apps/internal-ui/src/features/integrations/site-fields/model.test.ts @@ -0,0 +1,54 @@ +import { describe, expect, it } from "vitest"; +import { ApiError } from "../../../api/client"; +import { t } from "../../../i18n"; +import { fieldError, fieldsSaveError, fieldsSnippet, moveField, optionError, type SiteField } from "./model"; + +const customer: SiteField = { id: "server-id", key: "user_id", label: "ID", type: "string", aiVisible: false, order: 0 }; +const status: SiteField = { key: "order_status", label: "Status", type: "enum", aiVisible: true, order: 1, + options: [{ label: "Cooking", value: "cooking" }, { label: "On the way", value: "on_the_way" }] }; + +describe("website field schema editor", () => { + it("rejects reserved, malformed and duplicate keys and the field limit", () => { + for (const key of ["name", "email", "phone"]) { + expect(fieldError({ ...customer, key }, [], true)).toBe(t("site_fields.key_reserved")); + } + for (const key of ["Upper", "1field", "a-b", "a".repeat(41)]) { + expect(fieldError({ ...customer, key }, [], true)).toBe(t("site_fields.key_invalid")); + } + expect(fieldError(customer, [customer], true)).toBe(t("site_fields.key_duplicate")); + expect(fieldError(customer, Array.from({ length: 30 }, () => customer), true)).toBe(t("site_fields.limit")); + expect(fieldError({ ...customer, label: "a".repeat(61) }, [], false)).toBe(t("site_fields.label_invalid")); + }); + + it("reorders without losing immutable server ids, options or AI visibility", () => { + const moved = moveField([customer, status], "order_status", "user_id"); + expect(moved).toEqual([{ ...status, order: 0 }, { ...customer, order: 1 }]); + expect(moveField(moved, "missing", "user_id")).toBe(moved); + }); + + it("validates unique list values and six-digit colors while allowing an existing value to be edited", () => { + const option = { label: "Cooking", value: "cooking", color: "#faad14" }; + expect(optionError(option, [option])).toBe(t("site_fields.option_duplicate")); + expect(optionError(option, [option], 0)).toBeUndefined(); + expect(optionError({ ...option, color: "red" }, [])).toBe(t("site_fields.option_invalid")); + }); + + it("generates executable partial updates from the current schema, escaping values", () => { + const fields = [customer, { ...status, key: "quoted_status", options: [{ label: "Quoted", value: 'a"b' }, status.options![1]] }]; + const calls: Record[] = []; + new Function("Chatballs", fieldsSnippet(fields))({ setFields: (values: Record) => calls.push(values) }); + expect(calls[0]).toMatchObject({ user_id: "u_58213", quoted_status: 'a"b' }); + expect(calls[1]).toEqual({ quoted_status: "on_the_way" }); + expect(fieldsSnippet([customer])).not.toContain("order_status"); + const example = fieldsSnippet([{ ...status, options: [{ label: "Accepted", value: "accepted" }, ...status.options!] }]); + expect(example).toContain('order_status: "cooking"'); + expect(example).toContain('Chatballs.setFields({ order_status: "on_the_way" });'); + }); + + it("keeps user-facing server validation messages and hides technical runtime failures", () => { + const detail = "Своих полей может быть не больше 30 — удалите лишние"; + expect(fieldsSaveError(new ApiError(400, { detail }))).toBe(detail); + expect(fieldsSaveError(new Error("Organization context is required"))).toBe(t("common.could_not_save")); + expect(fieldsSaveError(new ApiError(500, { detail: "Database failure" }))).toBe(t("common.could_not_save")); + }); +}); diff --git a/apps/internal-ui/src/features/integrations/site-fields/model.ts b/apps/internal-ui/src/features/integrations/site-fields/model.ts new file mode 100644 index 0000000..5b80ed5 --- /dev/null +++ b/apps/internal-ui/src/features/integrations/site-fields/model.ts @@ -0,0 +1,83 @@ +import { ApiError } from "../../../api/client"; +import { t } from "../../../i18n"; +import type { Icon } from "../../../shared/icons"; + +export const FIELD_TYPES = ["string", "number", "boolean", "datetime", "enum", "email", "phone", "url"] as const; +export type FieldType = typeof FIELD_TYPES[number]; +export type FieldOption = { value: string; label: string; color?: string }; +export type SiteField = { + id?: string; + key: string; + label: string; + type: FieldType; + options?: FieldOption[]; + aiVisible: boolean; + order: number; +}; +export const fieldTypeOptions = FIELD_TYPES.map((type): [string, string] => [type, t(`site_fields.${type}`)]); +export const fieldIcons: Record[0]["name"]> = { + string: "text", number: "numlist", boolean: "check", datetime: "clock", + enum: "list", email: "mail", phone: "phone", url: "link", +}; + +export function fieldError(field: SiteField, fields: SiteField[], creating: boolean): string | undefined { + if (!field.label.trim() || field.label.trim().length > 60) return t("site_fields.label_invalid"); + if (!creating) return undefined; + if (fields.length >= 30) return t("site_fields.limit"); + if (!/^[a-z][a-z0-9_]{0,39}$/.test(field.key)) return t("site_fields.key_invalid"); + if (["name", "email", "phone"].includes(field.key)) return t("site_fields.key_reserved"); + if (fields.some((item) => item.key === field.key)) return t("site_fields.key_duplicate"); +} + +export function optionError(option: FieldOption, options: FieldOption[], index?: number): string | undefined { + if (!option.value.trim() || !option.label.trim() || option.label.trim().length > 60 + || (option.color && !/^#[\da-f]{6}$/i.test(option.color))) return t("site_fields.option_invalid"); + if (options.some((item, position) => position !== index && item.value === option.value.trim())) return t("site_fields.option_duplicate"); +} + +export const orderedFields = (fields: SiteField[]): SiteField[] => + [...fields].sort((a, b) => a.order - b.order).map((field, order) => ({ ...field, order })); + +export function moveField(fields: SiteField[], from: string, to: string): SiteField[] { + const result = [...fields]; + const start = result.findIndex((field) => field.key === from); + const end = result.findIndex((field) => field.key === to); + if (start < 0 || end < 0) return fields; + result.splice(end, 0, result.splice(start, 1)[0]); + return result.map((field, order) => ({ ...field, order })); +} + +export function fieldsSaveError(error: unknown): string { + // Only the server's user-facing validation message may reach the UI. + return error instanceof ApiError && error.status === 400 + ? error.message : t("common.could_not_save"); +} + +function exampleValue(field: SiteField): unknown { + if (field.type === "boolean") return true; + if (field.type === "number") return 42; + if (field.type === "datetime") return "2026-09-30T12:00:00Z"; + if (field.type === "enum") return (field.key === "order_status" + ? field.options?.find((option) => option.value === "cooking")?.value : undefined) + ?? field.options?.[0]?.value ?? null; + if (field.type === "email") return "irina.sokolova@mail.ru"; + if (field.type === "phone") return "+79991234567"; + if (field.type === "url") return "https://example.com"; + if (field.key === "user_id") return "u_58213"; + if (field.key === "order_number") return "10482"; + return t("site_fields.example_value"); +} + +export function fieldsSnippet(fields: SiteField[]): string { + const values = { name: t("site_fields.example_name"), email: "irina.sokolova@mail.ru", + ...Object.fromEntries(fields.map((field) => [field.key, exampleValue(field)])) }; + const lines = Object.entries(values).map(([key, value]) => ` ${key}: ${JSON.stringify(value)}`); + const updated = fields.find((field) => field.type === "enum" && (field.options?.length ?? 0) > 1); + const updateKey = updated?.key ?? fields[0]?.key ?? "name"; + const updateValue = updated + ? (updated.key === "order_status" ? updated.options?.find((option) => option.value === "on_the_way")?.value : undefined) + ?? updated.options![1].value + : null; + const comment = t(updated?.key === "order_status" ? "site_fields.status_comment" : "site_fields.update_comment"); + return `Chatballs.setFields({\n${lines.join(",\n")}\n});\n\n// ${comment}\nChatballs.setFields({ ${updateKey}: ${JSON.stringify(updateValue)} });`; +} diff --git a/apps/internal-ui/src/features/integrations/site-fields/styles.css b/apps/internal-ui/src/features/integrations/site-fields/styles.css new file mode 100644 index 0000000..81db6ab --- /dev/null +++ b/apps/internal-ui/src/features/integrations/site-fields/styles.css @@ -0,0 +1,54 @@ +.site-fields-section { display: flex; flex-direction: column; gap: 18px; max-width: 900px; padding: 26px 34px 48px; } +.site-fields-section .portal-settings-heading { margin: 0; } +.site-fields-section .portal-settings-heading p { line-height: 1.5; } +.site-fields-card { background: var(--surface-card); border: 1px solid var(--n-7); border-radius: 10px; overflow: hidden; } +.site-fields-card-head { display: flex; align-items: center; justify-content: space-between; gap: 12px; padding: 14px 18px 12px; } +.site-fields-card-head strong { display: block; font-size: 13.5px; color: var(--n-1); } +.site-fields-card-head small { font-size: 12px; color: var(--n-4); } +.site-fields-card-head .primary-button { height: 32px; min-height: 32px; padding: 0 12px; border-radius: 8px; } +.site-fields-table { width: 100%; border-collapse: collapse; } +.site-fields-table th { text-align: left; padding: 8px 12px; font-size: 11px; font-weight: 600; letter-spacing: .04em; text-transform: uppercase; color: var(--n-4); background: var(--n-10); border-top: 1px solid var(--n-8); border-bottom: 1px solid var(--n-8); } +.site-fields-table td { padding: 10px 12px; font-size: 13px; color: var(--n-2); border-bottom: 1px solid var(--n-9); } +.site-fields-table code { font-family: var(--font-mono); font-size: 12.5px; } +.site-fields-contacts th:first-child, .site-fields-contacts td:first-child { width: 200px; padding-left: 18px; } +.site-fields-contacts th:nth-child(3) { width: 170px; } +.site-fields-contacts th:last-child { width: 150px; } +.site-fields-contacts td:last-child { font-size: 12.5px; color: var(--n-4); padding-right: 18px; } +.site-field-type { display: inline-flex; align-items: center; gap: 6px; height: 24px; padding: 0 8px; border-radius: 6px; background: var(--n-9); color: var(--n-3); font-size: 12px; font-weight: 500; } +.site-fields-custom td { vertical-align: top; } +.site-fields-custom th:first-child { width: 34px; } +.site-fields-custom td:first-child { padding: 10px 0 10px 14px; } +.site-fields-custom th:nth-child(2) { width: 180px; } +.site-fields-custom th:nth-child(2), .site-fields-custom td:nth-child(2) { padding-left: 6px; padding-right: 6px; } +.site-fields-custom td:nth-child(2) code { display: inline-block; margin-top: 3px; overflow-wrap: anywhere; } +.site-fields-custom th:nth-child(4) { width: 170px; } +.site-fields-custom th:nth-child(5) { width: 100px; } +.site-fields-custom th:last-child { width: 44px; } +.site-fields-custom td:last-child { padding-left: 0; } +.site-fields-custom .has-options { background: var(--n-10); } +.site-field-grip { display: block; margin-top: 4px; padding: 0; border: 0; background: transparent; color: var(--n-5); cursor: grab; } +.site-field-grip:active { cursor: grabbing; } +.site-field-label { display: block; margin-top: 3px; font-weight: 500; color: var(--n-1); overflow-wrap: anywhere; } +.site-fields-custom .readonly-field > span:first-child { display: none; } +.site-fields-custom .select-like .select-box { height: 30px; padding: 0 9px; border-radius: 7px; background: var(--surface-card); font-size: 12.5px; gap: 6px; } +.site-fields-custom .select-box > svg { color: var(--n-4); } +.site-fields-custom .ui-switch.is-compact { margin-top: 5px; width: 32px; height: 18px; } +.site-fields-custom .ui-switch.is-compact i { width: 14px; height: 14px; } +.site-fields-custom .row-menu-button { width: 28px; height: 28px; } +.site-field-options { display: flex; flex-wrap: wrap; gap: 5px; margin-top: 8px; } +.site-field-option, .site-field-add-option { display: inline-flex; align-items: center; gap: 6px; min-height: 22px; padding: 0 7px; border: 1px solid var(--n-8); border-radius: 5px; background: var(--surface-card); font: inherit; font-size: 11.5px; color: var(--n-2); cursor: pointer; } +.site-field-option i { width: 7px; height: 7px; flex: none; border-radius: 50%; } +.site-field-option code { font-size: 10.5px; color: var(--n-4); overflow-wrap: anywhere; } +.site-field-add-option { border: 1px dashed var(--n-6); background: transparent; color: var(--n-3); } +.site-fields-footer { padding: 10px 18px; background: var(--n-10); color: var(--n-4); font-size: 12px; } +.site-fields-code { padding: 16px 18px; } +.site-fields-code .site-fields-card-head { padding: 0; margin-bottom: 10px; } +.site-fields-code .secondary-button { height: 30px; padding: 0 11px; border-radius: 7px; font-size: 12.5px; } +.site-fields-code pre { margin: 0; padding: 13px 15px; border: 1px solid var(--n-8); border-radius: 8px; background: var(--n-10); color: var(--n-2); font: 12.5px/1.6 var(--font-mono); white-space: pre; overflow-x: auto; } +.site-field-color { display: flex; align-items: flex-end; gap: 10px; } +.site-field-color .readonly-field { flex: 1; } +@media (max-width: 900px) { + .site-fields-section { padding: 20px 16px; } + .site-fields-card { overflow-x: auto; } + .site-fields-table { min-width: 720px; } +} diff --git a/apps/internal-ui/src/features/integrations/site-fields/useSiteFields.ts b/apps/internal-ui/src/features/integrations/site-fields/useSiteFields.ts new file mode 100644 index 0000000..8811237 --- /dev/null +++ b/apps/internal-ui/src/features/integrations/site-fields/useSiteFields.ts @@ -0,0 +1,34 @@ +import { useState } from "react"; +import { api } from "../../../api/client"; +import type { Integration } from "../model"; +import { fieldsSaveError, orderedFields, type SiteField } from "./model"; + +export function useSiteFields(integration: Integration, onSaved: (integration: Integration) => void) { + const [fields, setFields] = useState(() => orderedFields(integration.config.fields ?? [])); + const [saved, setSaved] = useState(fields); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(); + const dirty = JSON.stringify(fields) !== JSON.stringify(saved); + function change(next: SiteField[]) { + setFields(next.map((field, order) => ({ ...field, order }))); + setError(undefined); + } + async function save() { + setBusy(true); + setError(undefined); + try { + const response = await api<{ integration: Integration }>(`/api/v1/integrations/${integration.id}/`, { + method: "PATCH", body: JSON.stringify({ config: { ...integration.config, fields } }), + }); + const next = orderedFields(response.integration.config.fields ?? []); + setFields(next); + setSaved(next); + onSaved(response.integration); + } catch (caught) { + setError(fieldsSaveError(caught)); + } finally { + setBusy(false); + } + } + return { fields, change, save, busy, error, dirty }; +} diff --git a/apps/internal-ui/src/i18n/en.ts b/apps/internal-ui/src/i18n/en.ts index 31fef2f..a3822a9 100644 --- a/apps/internal-ui/src/i18n/en.ts +++ b/apps/internal-ui/src/i18n/en.ts @@ -6,8 +6,10 @@ import type { Message } from "@chatballs/shared"; import type { MessageKey } from "./ru"; +import { siteFieldsEn } from "./site-fields.en"; export const en: Record = { + ...siteFieldsEn, "admin.30_days": "30 days", "admin.7_days": "7 days", "admin.90_days": "90 days", diff --git a/apps/internal-ui/src/i18n/ru.ts b/apps/internal-ui/src/i18n/ru.ts index c12abfa..bb4fd18 100644 --- a/apps/internal-ui/src/i18n/ru.ts +++ b/apps/internal-ui/src/i18n/ru.ts @@ -7,8 +7,10 @@ // переименовывают, и «conversations.assign_to_me» переживает смену текста. import type { Message } from "@chatballs/shared"; +import { siteFieldsRu } from "./site-fields.ru"; export const ru = { + ...siteFieldsRu, "admin.30_days": "30 дней", "admin.7_days": "7 дней", "admin.90_days": "90 дней", diff --git a/apps/internal-ui/src/i18n/site-fields.en.ts b/apps/internal-ui/src/i18n/site-fields.en.ts new file mode 100644 index 0000000..1cb13ec --- /dev/null +++ b/apps/internal-ui/src/i18n/site-fields.en.ts @@ -0,0 +1,47 @@ +import type { siteFieldsRu } from "./site-fields.ru"; + +export const siteFieldsEn: Record = { + "site_fields.description": "Fields the website sends with the conversation. Operators see the values in the contact card, and they update while the conversation is open. Fields outside this list are discarded.", + "site_fields.contacts": "Contact fields", + "site_fields.contacts_hint": "Built-in fields populate the name, email and phone in the contact card", + "site_fields.custom": "Custom fields", + "site_fields.count": "{count} of 30 · shown in the contact card in this order", + "site_fields.add": "Add field", + "site_fields.key": "Key", + "site_fields.label": "Name", + "site_fields.type": "Type", + "site_fields.target": "Destination", + "site_fields.ai": "Visible to AI", + "site_fields.target_name": "contact name", + "site_fields.target_email": "contact email", + "site_fields.target_phone": "contact phone", + "site_fields.string": "Text", + "site_fields.number": "Number", + "site_fields.boolean": "Yes/No", + "site_fields.datetime": "Date and time", + "site_fields.enum": "List", + "site_fields.email": "Email", + "site_fields.phone": "Phone", + "site_fields.url": "Link", + "site_fields.types_hint": "Types: Text · Number · Yes/No · Date and time · List · Email · Phone · Link. The key cannot be changed — the website already sends it.", + "site_fields.add_option": "+ value", + "site_fields.option_title": "List value", + "site_fields.value": "Value", + "site_fields.color": "Color", + "site_fields.rename": "Rename field", + "site_fields.reorder": "Change order: {label}", + "site_fields.key_hint": "Up to 40 characters: start with a lowercase Latin letter, then use lowercase letters, digits and underscores. The key cannot be changed after creation.", + "site_fields.key_invalid": "Start the key with a lowercase Latin letter. Use only lowercase Latin letters, digits and underscores, up to 40 characters.", + "site_fields.key_reserved": "Name, email and phone are already contact fields. Choose another key.", + "site_fields.key_duplicate": "A field with this key already exists. Choose another key.", + "site_fields.limit": "A connection can have at most 30 custom fields — delete the extra ones.", + "site_fields.label_invalid": "Enter a name up to 60 characters long.", + "site_fields.option_duplicate": "This value is already in the list. Enter another one.", + "site_fields.option_invalid": "Enter a value, a name up to 60 characters long and a color in #RRGGBB format.", + "site_fields.code_title": "How to send from the website", + "site_fields.code_hint": "After the embed code. Each subsequent call updates only the keys provided.", + "site_fields.example_name": "Irene Sokolova", + "site_fields.example_value": "example", + "site_fields.update_comment": "the value changed — the operator's conversation will update", + "site_fields.status_comment": "the status changed — the operator's conversation will update", +}; diff --git a/apps/internal-ui/src/i18n/site-fields.ru.ts b/apps/internal-ui/src/i18n/site-fields.ru.ts new file mode 100644 index 0000000..1974a88 --- /dev/null +++ b/apps/internal-ui/src/i18n/site-fields.ru.ts @@ -0,0 +1,45 @@ +export const siteFieldsRu = { + "site_fields.description": "Поля, которые сайт передаёт вместе с диалогом. Значения видны оператору в карточке контакта и обновляются, пока диалог открыт. Поля не из списка сервер отбрасывает.", + "site_fields.contacts": "Поля контакта", + "site_fields.contacts_hint": "Встроенные — заполняют имя, почту и телефон в карточке", + "site_fields.custom": "Свои поля", + "site_fields.count": "{count} из 30 · выводятся в карточке в этом порядке", + "site_fields.add": "Добавить поле", + "site_fields.key": "Ключ", + "site_fields.label": "Название", + "site_fields.type": "Тип", + "site_fields.target": "Куда попадает", + "site_fields.ai": "Видит AI", + "site_fields.target_name": "имя контакта", + "site_fields.target_email": "почта контакта", + "site_fields.target_phone": "телефон контакта", + "site_fields.string": "Строка", + "site_fields.number": "Число", + "site_fields.boolean": "Да/Нет", + "site_fields.datetime": "Дата и время", + "site_fields.enum": "Список", + "site_fields.email": "Email", + "site_fields.phone": "Телефон", + "site_fields.url": "Ссылка", + "site_fields.types_hint": "Типы: Строка · Число · Да/Нет · Дата и время · Список · Email · Телефон · Ссылка. Ключ менять нельзя — сайт уже шлёт его.", + "site_fields.add_option": "+ значение", + "site_fields.option_title": "Значение списка", + "site_fields.value": "Значение", + "site_fields.color": "Цвет", + "site_fields.rename": "Переименовать поле", + "site_fields.reorder": "Изменить порядок: {label}", + "site_fields.key_hint": "До 40 символов: латинская строчная буква в начале, затем строчные буквы, цифры и подчёркивание. После создания ключ менять нельзя.", + "site_fields.key_invalid": "Начните ключ со строчной латинской буквы. Используйте только строчные латинские буквы, цифры и подчёркивание, не больше 40 символов.", + "site_fields.key_reserved": "Имя, email и телефон уже есть в полях контакта. Выберите другой ключ.", + "site_fields.key_duplicate": "Поле с таким ключом уже есть. Выберите другой ключ.", + "site_fields.limit": "Своих полей может быть не больше 30 — удалите лишние.", + "site_fields.label_invalid": "Введите название длиной до 60 символов.", + "site_fields.option_duplicate": "Такое значение уже есть в списке. Введите другое.", + "site_fields.option_invalid": "Введите значение, название до 60 символов и цвет в формате #RRGGBB.", + "site_fields.code_title": "Как передать с сайта", + "site_fields.code_hint": "После кода вставки. Повторный вызов обновляет только переданные ключи.", + "site_fields.example_name": "Ирина Соколова", + "site_fields.example_value": "пример", + "site_fields.update_comment": "значение изменилось — диалог обновится у оператора", + "site_fields.status_comment": "статус изменился — диалог обновится у оператора", +} as const;