mirror of
https://github.com/dartdavros/chatballs.git
synced 2026-10-05 17:14:59 +03:00
✨ feat(webchat): добавить раздел данных с сайта
This commit is contained in:
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 };
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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;
|
||||
Reference in new issue
Block a user