✨ feat(webchat): добавить раздел данных с сайта

This commit is contained in:
Andrey committed 2026-09-30 23:18:58 +03:00
1 parent 5527a13230
commit 223a962cb9
19 files changed
+636 -6

No files matched your search

@@ -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.
@@ -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<Section>[] = [
{ 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<Section>("basics");
const [fieldCount, setFieldCount] = useState<number>();
if (loading) return <LoadingState />;
if (failed) return <EmptyState title={t("settings.could_not_load_integrations")} />;
@@ -37,18 +40,21 @@ export function WebIntegrationPage({ integrationId, onOpenSettings, onBack }: {
<WebIntegrationHeader integration={integration} onOpenSettings={onOpenSettings} onBack={onBack} />
<div className="portal-settings-layout">
<SectionMenu
items={sections}
activeKey="basics"
items={sections.map((item) => 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); }}
/>
<div className="portal-settings-content">
<div className="portal-settings-inner">
<div className="portal-settings-inner" hidden={section !== "basics"}>
<div className="portal-settings-heading">
<h3>{t("portals.basics")}</h3>
</div>
<WebIntegrationBasics key={integration.id} integration={integration} onSaved={setIntegration} />
</div>
<div hidden={section !== "fields"}>
<SiteFieldsSection key={integration.id} integration={integration} onSaved={setIntegration} onCount={setFieldCount} />
</div>
</div>
</div>
{confirmingDelete && <DeleteIntegrationDialog integration={integration} onClose={() => setConfirmingDelete(false)} onDeleted={onBack} />}
@@ -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;
@@ -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 <div className="site-fields-card">
<div className="site-fields-card-head"><div>
<strong>{t("site_fields.contacts")}</strong><small>{t("site_fields.contacts_hint")}</small>
</div></div>
<table className="site-fields-table site-fields-contacts">
<thead><tr>{["key", "label", "type", "target"].map((key) => <th key={key}>{t(`site_fields.${key as "key" | "label" | "type" | "target"}`)}</th>)}</tr></thead>
<tbody>{contacts.map((field) => <tr key={field.key}>
<td><code>{field.key}</code></td><td>{t(field.label)}</td>
<td><span className="site-field-type"><FieldTypeIcon type={field.type} />{t(`site_fields.${field.type}`)}</span></td>
<td>{t(field.target)}</td>
</tr>)}</tbody>
</table>
</div>;
}
@@ -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 <tr data-field-key={field.key} className={field.type === "enum" ? "has-options" : ""}
onDragOver={(event) => { if (!disabled) event.preventDefault(); }}
onDrop={(event) => { event.preventDefault(); if (!disabled) onDrop(); }}>
<td><button type="button" className="site-field-grip" draggable={!disabled} disabled={disabled}
aria-label={t("site_fields.reorder", { label: field.label })}
onDragStart={(event) => { event.dataTransfer.setData("text/plain", field.key); onDrag(); }} onDragEnd={onDragEnd}
onKeyDown={(event) => {
if (event.key === "ArrowUp" || event.key === "ArrowDown") {
event.preventDefault(); onMove(event.key === "ArrowUp" ? -1 : 1);
}
}}><Icon name="grip" size={14} /></button></td>
<td><code>{field.key}</code></td>
<td><span className="site-field-label">{field.label}</span>
{field.type === "enum" && <div className="site-field-options">
{field.options?.map((option, index) => <button className="site-field-option" type="button" key={option.value}
disabled={disabled} onClick={() => onOption(index)}>
<i style={{ background: option.color || "var(--n-5)" }} />{option.label}<code>· {option.value}</code>
</button>)}
<button className="site-field-add-option" type="button" disabled={disabled} onClick={() => onOption()}>{t("site_fields.add_option")}</button>
</div>}
</td>
<td><SelectField label={t("site_fields.type")} adornment={<FieldTypeIcon type={field.type} />}
disabled={disabled} options={fieldTypeOptions} value={field.type} onChange={(type) => {
const { options, ...rest } = field;
onChange({ ...rest, type: type as FieldType, ...(type === "enum" ? { options: options ?? [] } : {}) });
}} /></td>
<td><SwitchButton className="ui-switch is-compact" label={`${t("site_fields.ai")}: ${field.label}`}
disabled={disabled} checked={field.aiVisible} onClick={() => onChange({ ...field, aiVisible: !field.aiVisible })} /></td>
<td><Dropdown trigger={["click"]} overlayClassName="app-dropdown" menu={{ items: [
{ key: "rename", label: t("site_fields.rename"), onClick: onRename },
{ key: "delete", label: t("common.delete"), danger: true, onClick: onDelete },
] }}><button className="row-menu-button" type="button" disabled={disabled} aria-label={`${t("common.actions")}: ${field.label}`}><Icon name="more" size={16} /></button></Dropdown></td>
</tr>;
}
@@ -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<string | undefined>(undefined);
return <div className="site-fields-card">
<div className="site-fields-card-head"><div>
<strong>{t("site_fields.custom")}</strong><small>{t("site_fields.count", { count: fmt.number(fields.length) })}</small>
</div><Button variant="primary" icon="plus" disabled={busy || fields.length >= 30} onClick={onAdd}>{t("site_fields.add")}</Button></div>
<table className="site-fields-table site-fields-custom">
<thead><tr><th /><th>{t("site_fields.key")}</th><th>{t("site_fields.label")}</th>
<th>{t("site_fields.type")}</th><th>{t("site_fields.ai")}</th><th /></tr></thead>
<tbody>{fields.map((field, index) => <CustomFieldRow key={field.key} field={field} disabled={busy}
onChange={(updated) => 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)); }}
/>)}</tbody>
</table>
<div className="site-fields-footer">{t("site_fields.types_hint")}</div>
</div>;
}
@@ -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<SiteField>(initial ?? {
key: "", label: "", type: "string", aiVisible: false, order: fields.length,
});
const [error, setError] = useState<string>();
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 <Modal open title={t(initial ? "site_fields.rename" : "site_fields.add")} onCancel={onClose} footer={null}>
<form className="integration-form" onSubmit={(event) => { event.preventDefault(); confirm(); }}>
<FormField label={t("site_fields.key")} mono value={field.key}
onChange={initial ? undefined : (key) => setField({ ...field, key })} />
{!initial && <small className="integration-form-hint">{t("site_fields.key_hint")}</small>}
<FormField label={t("site_fields.label")} value={field.label} onChange={(label) => setField({ ...field, label })} />
{!initial && <SelectField label={t("site_fields.type")} value={field.type} options={fieldTypeOptions}
onChange={(type) => setField({ ...field, type: type as FieldType })} />}
{error && <div className="integration-form-error" role="alert">{error}</div>}
<div className="integration-form-actions">
<Button variant="secondary" onClick={onClose}>{t("common.cancel")}</Button>
<Button variant="primary" type="submit">{t("common.save")}</Button>
</div>
</form>
</Modal>;
}
@@ -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 <Icon name={fieldIcons[type]} size={13} />;
return <svg viewBox="0 0 24 24" width={13} height={13} fill="none" stroke="currentColor"
strokeWidth={1.9} strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
<path d={type === "number" ? "M4 9h16M4 15h16M10 3 8 21M16 3l-2 18"
: "M8 7h8a5 5 0 0 1 0 10H8A5 5 0 0 1 8 7zM16 12a0 0 0 0 1 0 0M16 10a2 2 0 1 1 0 4 2 2 0 0 1 0-4z"} />
</svg>;
}
@@ -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 <div className="site-fields-card site-fields-code">
<div className="site-fields-card-head"><div><strong>{t("site_fields.code_title")}</strong><small>{t("site_fields.code_hint")}</small></div>
<CopyButton value={snippet} label={t("common.copy")} className="secondary-button" />
</div><pre>{snippet}</pre>
</div>;
}
@@ -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<FieldOption>(index === undefined ? { label: "", value: "" } : options[index]);
const [error, setError] = useState<string>();
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 <Modal open title={t("site_fields.option_title")} onCancel={onClose} footer={null}>
<form className="integration-form" onSubmit={(event) => { event.preventDefault(); confirm(); }}>
<FormField label={t("site_fields.label")} value={option.label} onChange={(label) => setOption({ ...option, label })} />
<FormField label={t("site_fields.value")} mono value={option.value} onChange={(value) => setOption({ ...option, value })} />
<div className="site-field-color">
<FormField label={t("site_fields.color")} mono value={option.color ?? ""} onChange={(color) => setOption({ ...option, color })} />
<ColorPicker value={option.color || token.colorPrimary} disabledAlpha onChangeComplete={(color) => setOption({ ...option, color: color.toHexString() })} />
</div>
{error && <div className="integration-form-error" role="alert">{error}</div>}
<div className="integration-form-actions">
{index !== undefined && <Button variant="danger-outline" onClick={onDelete}>{t("common.delete")}</Button>}
<Button variant="secondary" onClick={onClose}>{t("common.cancel")}</Button>
<Button variant="primary" type="submit">{t("common.save")}</Button>
</div>
</form>
</Modal>;
}
@@ -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<Editing>();
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 <div className="site-fields-section">
<div className="portal-settings-heading"><h3>{t("settings.site_data")}</h3><p>{t("site_fields.description")}</p></div>
<ContactFieldsTable />
<CustomFieldsTable fields={fields} busy={busy} onChange={change} onAdd={() => setEditing({ kind: "field" })}
onRename={(field) => setEditing({ kind: "field", field })}
onOption={(key, index) => setEditing({ kind: "option", key, index })} />
<FieldsCode fields={fields} />
{error && <div className="integration-form-error" role="alert">{error}</div>}
<div className="portal-settings-actions"><Button variant="primary" disabled={busy || !dirty} onClick={() => void save()}>
{busy ? t("ai.saving") : t("common.save")}
</Button></div>
{editing?.kind === "field" && <FieldDialog initial={editing.field} fields={fields} onClose={() => setEditing(undefined)} onConfirm={confirmField} />}
{editing?.kind === "option" && optionField && <OptionDialog options={optionField.options ?? []} index={editing.index}
onClose={() => setEditing(undefined)} onConfirm={confirmOption} onDelete={() => confirmOption()} />}
</div>;
}
@@ -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<string, unknown>[] = [];
new Function("Chatballs", fieldsSnippet(fields))({ setFields: (values: Record<string, unknown>) => 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"));
});
});
@@ -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<FieldType, Parameters<typeof Icon>[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)} });`;
}
@@ -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; }
}
@@ -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<string>();
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 };
}
+2
View File
@@ -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<MessageKey, Message> = {
...siteFieldsEn,
"admin.30_days": "30 days",
"admin.7_days": "7 days",
"admin.90_days": "90 days",
+2
View File
@@ -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 дней",
@@ -0,0 +1,47 @@
import type { siteFieldsRu } from "./site-fields.ru";
export const siteFieldsEn: Record<keyof typeof siteFieldsRu, string> = {
"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",
};
@@ -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;