mirror of
https://github.com/dartdavros/chatballs.git
synced 2026-10-05 01:14:58 +03:00
✨ feat(webchat): добавить настройки формы перед чатом
This commit is contained in:
1 parent
8b9fae8a8d
commit
6c04ce30d8
16 files changed
+364
-3
No files matched your search
@@ -0,0 +1,29 @@
|
||||
---
|
||||
id: T-017
|
||||
title: Раздел «Форма перед чатом» в настройках подключения
|
||||
milestone: M03
|
||||
status: done
|
||||
depends_on:
|
||||
- T-008
|
||||
- T-016
|
||||
- T-015
|
||||
order: 2
|
||||
spec: "0020"
|
||||
created: 2026-09-29
|
||||
branch: skaro/T-017-razdel-forma-pered-chatom-v-na
|
||||
---
|
||||
|
||||
## Цель
|
||||
|
||||
Администратор настраивает форму в разделе W2 ровно как в макете (R-2).
|
||||
|
||||
## Критерии приёмки
|
||||
|
||||
- [x] Включатель формы, список полей с чекбоксами Имя/Email/Телефон и «Своё поле из „Данных с сайта“», тумблер «Обязательное», заголовок формы, текст согласия
|
||||
- [x] Источник поля подписан как в макете (например «name · с сайта»)
|
||||
- [x] В пункте меню — подсказка «вкл», когда форма включена
|
||||
- [x] Все тексты через t() в ru и en, вид совпадает с кадром W2
|
||||
|
||||
## Итог
|
||||
|
||||
Реализован раздел «Форма перед чатом» по W2: включатель, встроенные и свои поля, обязательность, заголовок, согласие, серверная редакция и подсказка «вкл». Проверены сборка, адресные тесты, браузерные сценарии и размеры макета на frontend T-017:15177 с реальным backend основной копии:8010; изменения зафиксированы коммитом 634563c9. Демо-конфигурация восстановлена, серверная редакция согласия стала v3 после изменения текста и его возврата.
|
||||
@@ -11,13 +11,14 @@ import { WebIntegrationHeader } from "./WebIntegrationHeader";
|
||||
import { useWebIntegration } from "./useWebIntegration";
|
||||
import { SiteFieldsSection } from "./site-fields/SiteFieldsSection";
|
||||
import { WebIntegrationAppearance } from "./appearance/WebIntegrationAppearance";
|
||||
import { PreChatSection } from "./pre-chat/PreChatSection";
|
||||
|
||||
type Section = "basics" | "fields" | "form" | "look" | "danger";
|
||||
|
||||
const sections: SectionMenuItem<Section>[] = [
|
||||
{ key: "basics", label: t("portals.basics"), icon: "settings" },
|
||||
{ key: "fields", label: t("settings.site_data"), icon: "code" },
|
||||
{ key: "form", label: t("settings.pre_chat_form"), icon: "doc", disabled: true },
|
||||
{ key: "form", label: t("settings.pre_chat_form"), icon: "form" },
|
||||
{ key: "look", label: t("common.appearance"), icon: "paint", divider: true },
|
||||
{ key: "danger", label: t("settings.delete_connection"), icon: "trash", danger: true },
|
||||
];
|
||||
@@ -31,6 +32,7 @@ export function WebIntegrationPage({ integrationId, onOpenSettings, onBack }: {
|
||||
const [confirmingDelete, setConfirmingDelete] = useState(false);
|
||||
const [section, setSection] = useState<Section>("basics");
|
||||
const [fieldCount, setFieldCount] = useState<number>();
|
||||
const [formEnabled, setFormEnabled] = useState<boolean>();
|
||||
|
||||
if (loading) return <LoadingState />;
|
||||
if (failed) return <EmptyState title={t("settings.could_not_load_integrations")} />;
|
||||
@@ -41,7 +43,11 @@ export function WebIntegrationPage({ integrationId, onOpenSettings, onBack }: {
|
||||
<WebIntegrationHeader integration={integration} onOpenSettings={onOpenSettings} onBack={onBack} />
|
||||
<div className="portal-settings-layout">
|
||||
<SectionMenu
|
||||
items={sections.map((item) => item.key === "fields" ? { ...item, hint: { text: fmt.number(fieldCount ?? integration.config.fields?.length ?? 0) } } : item)}
|
||||
items={sections.map((item) => {
|
||||
if (item.key === "fields") return { ...item, hint: { text: fmt.number(fieldCount ?? integration.config.fields?.length ?? 0) } };
|
||||
if (item.key === "form" && (formEnabled ?? integration.config.preChat?.enabled)) return { ...item, hint: { text: t("pre_chat.on") } };
|
||||
return item;
|
||||
})}
|
||||
activeKey={section}
|
||||
note={t("settings.web_changes_apply_after_save")}
|
||||
onSelect={(next) => { if (next === "danger") setConfirmingDelete(true); else setSection(next); }}
|
||||
@@ -56,6 +62,9 @@ export function WebIntegrationPage({ integrationId, onOpenSettings, onBack }: {
|
||||
<div hidden={section !== "fields"}>
|
||||
<SiteFieldsSection key={integration.id} integration={integration} onSaved={setIntegration} onCount={setFieldCount} />
|
||||
</div>
|
||||
<div hidden={section !== "form"}>
|
||||
<PreChatSection key={integration.id} integration={integration} onSaved={setIntegration} onEnabled={setFormEnabled} />
|
||||
</div>
|
||||
{section === "look" && <WebIntegrationAppearance key={integration.id} integration={integration} onSaved={setIntegration} />}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -2,6 +2,7 @@ import { api } from "../../api/client";
|
||||
import { t } from "../../i18n";
|
||||
import type { SiteField } from "./site-fields/model";
|
||||
import type { WidgetAppearance } from "./appearance/model";
|
||||
import type { PreChat } from "./pre-chat/model";
|
||||
|
||||
export type IntegrationProvider = "OPENROUTER" | "CUSTOM" | "DEMO" | "MAX" | "TELEGRAM" | "VK" | "WEB" | "EMAIL";
|
||||
export type IntegrationKind = "LLM_PROVIDER" | "MESSENGER";
|
||||
@@ -28,6 +29,7 @@ export type Integration = {
|
||||
// email/imap*/smtp* — Email-подключение (SPEC-CHATBALLS-0025 §3.1).
|
||||
config: {
|
||||
fields?: SiteField[];
|
||||
preChat?: PreChat;
|
||||
baseUrl: string;
|
||||
defaultModel: string;
|
||||
transcriptionModel: string;
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
import { Dropdown } from "antd";
|
||||
import { t } from "../../../i18n";
|
||||
import { SwitchButton } from "../../../shared/form-controls";
|
||||
import { Icon } from "../../../shared/icons";
|
||||
import type { SiteField } from "../site-fields/model";
|
||||
import { CONTACT_KEYS, type PreChatField } from "./model";
|
||||
|
||||
export function PreChatFields({ fields, customFields, remainingFields, disabled, onSelect, onRequired, onAdd }: {
|
||||
fields: PreChatField[];
|
||||
customFields: SiteField[];
|
||||
remainingFields: SiteField[];
|
||||
disabled: boolean;
|
||||
onSelect: (key: string, selected: boolean) => void;
|
||||
onRequired: (key: string, required: boolean) => void;
|
||||
onAdd: (key: string) => void;
|
||||
}) {
|
||||
const rows = [
|
||||
...CONTACT_KEYS.map((key) => ({ key, label: t(`pre_chat.${key}`), custom: false })),
|
||||
...customFields.map(({ key, label }) => ({ key, label, custom: true })),
|
||||
];
|
||||
return <div className="pre-chat-fields">
|
||||
<div className="pre-chat-fields-head"><span /><span>{t("pre_chat.field")}</span><span>{t("pre_chat.source")}</span><span>{t("pre_chat.required")}</span></div>
|
||||
{rows.map((row) => {
|
||||
const selected = fields.find(({ key }) => key === row.key);
|
||||
return <div key={row.key} data-field-key={row.key} className={`pre-chat-field-row${selected ? "" : " is-off"}`}>
|
||||
<label className="pre-chat-check">
|
||||
<input type="checkbox" aria-label={row.label} checked={Boolean(selected)} disabled={disabled}
|
||||
onChange={(event) => onSelect(row.key, event.target.checked)} />
|
||||
<span>{selected && <Icon name="check" size={12} strokeWidth={3} />}</span>
|
||||
</label>
|
||||
<span className="pre-chat-field-label"><strong>{row.label}</strong>{row.custom && <small>{t("pre_chat.custom")}</small>}</span>
|
||||
<span className="pre-chat-source">{row.key === "name" || row.key === "email" ? t("pre_chat.site_source", { key: row.key }) : row.key}</span>
|
||||
<SwitchButton className="ui-switch is-compact" label={t("pre_chat.required_label", { label: row.label })}
|
||||
disabled={disabled || !selected} checked={selected?.required ?? false} onClick={() => onRequired(row.key, !selected?.required)} />
|
||||
</div>;
|
||||
})}
|
||||
<div className="pre-chat-fields-footer">
|
||||
<Dropdown trigger={["click"]} overlayClassName="app-dropdown" disabled={disabled || !remainingFields.length}
|
||||
menu={{ items: remainingFields.map(({ key, label }) => ({ key, label, onClick: () => onAdd(key) })) }}>
|
||||
<button className="row-menu-button" type="button" disabled={disabled || !remainingFields.length}>
|
||||
<Icon name="plus" size={14} />{t("pre_chat.add_custom")}
|
||||
</button>
|
||||
</Dropdown>
|
||||
<small>{t("pre_chat.types_hint")}</small>
|
||||
</div>
|
||||
</div>;
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
import { useEffect } from "react";
|
||||
import { t } from "../../../i18n";
|
||||
import { SwitchButton } from "../../../shared/form-controls";
|
||||
import type { Integration } from "../model";
|
||||
import { PreChatFields } from "./PreChatFields";
|
||||
import { PreChatText } from "./PreChatText";
|
||||
import { usePreChat } from "./usePreChat";
|
||||
import "./styles.css";
|
||||
|
||||
export function PreChatSection({ integration, onSaved, onEnabled }: {
|
||||
integration: Integration;
|
||||
onSaved: (integration: Integration) => void;
|
||||
onEnabled: (enabled: boolean) => void;
|
||||
}) {
|
||||
const state = usePreChat(integration, onSaved);
|
||||
const { preChat } = state.draft;
|
||||
useEffect(() => onEnabled(preChat.enabled), [preChat.enabled, onEnabled]);
|
||||
return <div className="pre-chat-section">
|
||||
<div className="portal-settings-heading"><h3>{t("settings.pre_chat_form")}</h3><p>{t("pre_chat.description")}</p></div>
|
||||
<div className="portal-settings-card pre-chat-enable">
|
||||
<span><strong>{t("pre_chat.enabled")}</strong><small>{t("pre_chat.disabled_hint")}</small></span>
|
||||
<SwitchButton className="ui-switch" label={t("pre_chat.enabled")} disabled={state.busy} checked={preChat.enabled}
|
||||
onClick={() => state.change({ preChat: { ...preChat, enabled: !preChat.enabled } })} />
|
||||
</div>
|
||||
<PreChatFields fields={preChat.fields} customFields={state.customFields} remainingFields={state.remainingFields} disabled={state.busy}
|
||||
onSelect={state.select} onAdd={state.add} onRequired={(key, required) => state.change({
|
||||
preChat: { ...preChat, fields: preChat.fields.map((field) => field.key === key ? { ...field, required } : field) },
|
||||
})} />
|
||||
<PreChatText draft={state.draft} version={integration.config.consentVersion} updatedAt={integration.updatedAt}
|
||||
busy={state.busy} error={state.error} onChange={state.change} onSave={() => void state.save()} />
|
||||
</div>;
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import { fmt, t } from "../../../i18n";
|
||||
import { Button } from "../../../shared/ui-controls";
|
||||
import type { PreChatDraft } from "./model";
|
||||
|
||||
export function PreChatText({ draft, version, updatedAt, busy, error, onChange, onSave }: {
|
||||
draft: PreChatDraft;
|
||||
version: string;
|
||||
updatedAt: string;
|
||||
busy: boolean;
|
||||
error: string;
|
||||
onChange: (patch: Partial<PreChatDraft>) => void;
|
||||
onSave: () => void;
|
||||
}) {
|
||||
const savedToday = new Date(updatedAt).toDateString() === new Date().toDateString();
|
||||
return <div className="portal-settings-card pre-chat-text">
|
||||
<label className="portal-field">
|
||||
<span className="portal-field-label">{t("pre_chat.title")}</span>
|
||||
<input value={draft.preChat.title} disabled={busy} onChange={(event) => onChange({ preChat: { ...draft.preChat, title: event.target.value } })} />
|
||||
</label>
|
||||
<label className="portal-field">
|
||||
<span className="portal-field-label">{t("pre_chat.consent")}</span>
|
||||
<textarea value={draft.consentText} disabled={busy} onChange={(event) => onChange({ consentText: event.target.value })} />
|
||||
<small>{t("pre_chat.consent_revision", { version: version.replace(/^v(?=\d)/, "") || "1" })}</small>
|
||||
</label>
|
||||
{error && <div className="integration-form-error" role="alert">{error}</div>}
|
||||
<div className="portal-settings-actions">
|
||||
<Button variant="primary" disabled={busy} onClick={onSave}>{busy ? t("ai.saving") : t("common.save")}</Button>
|
||||
<span className="portal-settings-gap" />
|
||||
{updatedAt && <span className="portal-settings-note">{savedToday
|
||||
? t("pre_chat.saved_today", { time: fmt.time(updatedAt) })
|
||||
: t("portals.saved_at_lower", { date: fmt.shortDateTime(updatedAt) })}</span>}
|
||||
</div>
|
||||
</div>;
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { Integration } from "../model";
|
||||
import type { SiteField } from "../site-fields/model";
|
||||
import { availableFields, preChatConfig, readPreChat, selectField } from "./model";
|
||||
|
||||
const custom: SiteField = { key: "order_number", label: "Order", type: "string", aiVisible: true, order: 0 };
|
||||
|
||||
describe("pre-chat settings", () => {
|
||||
it("leaves a legacy form disabled and reads the existing consent", () => {
|
||||
expect(readPreChat({ consentText: "Consent" } as Integration["config"])).toEqual({
|
||||
preChat: { enabled: false, title: "", fields: [] }, consentText: "Consent",
|
||||
});
|
||||
});
|
||||
|
||||
it("selects fields once, preserves required flags, and removes unchecked fields", () => {
|
||||
const fields = [{ key: "name", required: true }];
|
||||
const selected = selectField(fields, custom.key, true);
|
||||
expect(selectField(selected, custom.key, true)).toEqual([
|
||||
{ key: "name", required: true }, { key: custom.key, required: false },
|
||||
]);
|
||||
expect(selectField(selected, "name", false)).toEqual([{ key: custom.key, required: false }]);
|
||||
});
|
||||
|
||||
it("removes deleted custom fields while keeping contact and valid custom fields", () => {
|
||||
expect(availableFields([{ key: "name", required: true }, { key: "deleted", required: true }, { key: custom.key, required: false }], [custom]))
|
||||
.toEqual([{ key: "name", required: true }, { key: custom.key, required: false }]);
|
||||
});
|
||||
|
||||
it("preserves other settings and leaves consent version advancement to the server", () => {
|
||||
const config = { fields: [custom], allowedOrigins: ["example.com"], title: "Widget", consentVersion: "v3", appearance: { accent: "#1677ff" } } as Integration["config"];
|
||||
const result = preChatConfig(config, {
|
||||
preChat: { enabled: true, title: " Form ", fields: [{ key: "email", required: true }, { key: "deleted", required: false }] },
|
||||
consentText: " Consent ",
|
||||
});
|
||||
expect(result).toEqual({ ...config, preChat: { enabled: true, title: "Form", fields: [{ key: "email", required: true }] }, consentText: "Consent" });
|
||||
expect(config.title).toBe("Widget");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,32 @@
|
||||
import type { Integration } from "../model";
|
||||
import type { SiteField } from "../site-fields/model";
|
||||
|
||||
export type PreChatField = { key: string; required: boolean };
|
||||
export type PreChat = { enabled: boolean; title: string; fields: PreChatField[] };
|
||||
export type PreChatDraft = { preChat: PreChat; consentText: string };
|
||||
export const CONTACT_KEYS = ["name", "email", "phone"] as const;
|
||||
|
||||
export function readPreChat(config: Integration["config"]): PreChatDraft {
|
||||
return {
|
||||
preChat: config.preChat ?? { enabled: false, title: "", fields: [] },
|
||||
consentText: config.consentText ?? "",
|
||||
};
|
||||
}
|
||||
|
||||
export function availableFields(fields: PreChatField[], schema: SiteField[]): PreChatField[] {
|
||||
const allowed = new Set<string>([...CONTACT_KEYS, ...schema.map(({ key }) => key)]);
|
||||
return fields.filter(({ key }) => allowed.has(key));
|
||||
}
|
||||
|
||||
export function selectField(fields: PreChatField[], key: string, selected: boolean): PreChatField[] {
|
||||
if (!selected) return fields.filter((field) => field.key !== key);
|
||||
return fields.some((field) => field.key === key) ? fields : [...fields, { key, required: false }];
|
||||
}
|
||||
|
||||
export function preChatConfig(config: Integration["config"], draft: PreChatDraft) {
|
||||
return {
|
||||
...config,
|
||||
preChat: { ...draft.preChat, title: draft.preChat.title.trim(), fields: availableFields(draft.preChat.fields, config.fields ?? []) },
|
||||
consentText: draft.consentText.trim(),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
.pre-chat-section { display: flex; flex-direction: column; gap: 18px; max-width: 820px; padding: 26px 34px 48px; }
|
||||
.pre-chat-section .portal-settings-heading { margin: 0; }
|
||||
.pre-chat-section .portal-settings-heading p { line-height: 1.5; }
|
||||
.pre-chat-enable { flex-direction: row; align-items: center; gap: 14px; padding: 16px 18px; }
|
||||
.pre-chat-enable > span { flex: 1; }
|
||||
.pre-chat-enable strong { display: block; color: var(--n-1); font-size: 13.5px; }
|
||||
.pre-chat-enable small { color: var(--n-4); font-size: 12px; }
|
||||
.pre-chat-enable .ui-switch { width: 40px; height: 22px; border-radius: 11px; }
|
||||
.pre-chat-enable .ui-switch i { width: 18px; height: 18px; }
|
||||
.pre-chat-fields { overflow: hidden; border: 1px solid var(--n-7); border-radius: 10px; background: var(--surface-card); }
|
||||
.pre-chat-fields-head, .pre-chat-field-row { display: grid; grid-template-columns: 34px minmax(0, 1fr) 150px 130px; align-items: center; padding: 0 18px 0 14px; }
|
||||
.pre-chat-fields-head { padding-top: 8px; padding-bottom: 8px; border-bottom: 1px solid var(--n-8); background: var(--n-10); color: var(--n-4); font-size: 11px; font-weight: 600; letter-spacing: 0.04em; }
|
||||
.pre-chat-field-row { min-height: 48px; border-bottom: 1px solid var(--n-9); }
|
||||
.pre-chat-field-row.is-off { opacity: 0.6; }
|
||||
.pre-chat-check { position: relative; display: inline-flex; width: 18px; height: 18px; cursor: pointer; }
|
||||
.pre-chat-check input { position: absolute; inset: 0; width: 100%; height: 100%; margin: 0; opacity: 0; cursor: inherit; }
|
||||
.pre-chat-check > span { display: inline-flex; align-items: center; justify-content: center; width: 18px; height: 18px; border: 1.5px solid var(--n-6); border-radius: 5px; background: var(--surface-card); color: var(--on-primary); pointer-events: none; }
|
||||
.pre-chat-check input:checked + span { border-color: var(--primary); background: var(--primary); }
|
||||
.pre-chat-check input:focus-visible + span { outline: 2px solid var(--primary); outline-offset: 2px; }
|
||||
.pre-chat-check input:disabled { cursor: not-allowed; }
|
||||
.pre-chat-field-label { display: flex; min-width: 0; align-items: center; gap: 8px; }
|
||||
.pre-chat-field-label strong { color: var(--n-1); font-size: 13px; font-weight: 500; }
|
||||
.pre-chat-field-label small { display: inline-flex; height: 18px; align-items: center; padding: 0 6px; border-radius: 5px; background: var(--n-9); color: var(--n-4); font-size: 10.5px; font-weight: 600; white-space: nowrap; }
|
||||
.pre-chat-source { color: var(--n-4); font-size: 12px; overflow-wrap: anywhere; }
|
||||
.pre-chat-fields .ui-switch.is-compact { width: 32px; height: 18px; border-radius: 9px; }
|
||||
.pre-chat-fields .ui-switch.is-compact i { width: 14px; height: 14px; }
|
||||
.pre-chat-fields .ui-switch:disabled { opacity: 1; }
|
||||
.pre-chat-fields-footer { display: flex; align-items: center; gap: 10px; padding: 12px 18px; }
|
||||
.pre-chat-fields-footer .row-menu-button { display: inline-flex; width: auto; height: 32px; align-items: center; justify-content: center; gap: 7px; padding: 0 11px; border: 1px dashed var(--n-6); border-radius: 8px; background: transparent; color: var(--n-3); font: inherit; font-size: 12.5px; font-weight: 500; }
|
||||
.pre-chat-fields-footer .row-menu-button:hover { border-color: var(--primary); color: var(--primary-text); }
|
||||
.pre-chat-fields-footer small { color: var(--n-4); font-size: 11.5px; }
|
||||
.pre-chat-text { padding: 18px 20px; gap: 16px; }
|
||||
.pre-chat-text .portal-field textarea { height: 64px; padding: 9px 11px; font-size: 13px; line-height: 1.5; resize: none; }
|
||||
.pre-chat-text .portal-settings-actions { gap: 9px; }
|
||||
.pre-chat-text .primary-button { height: 34px; min-height: 34px; padding: 0 14px; border-radius: 8px; box-shadow: none; }
|
||||
.pre-chat-text .portal-field small { line-height: normal; }
|
||||
.web-integration-page .section-menu-item small { color: var(--n-4); }
|
||||
@@ -0,0 +1,51 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { api } from "../../../api/client";
|
||||
import { t } from "../../../i18n";
|
||||
import type { Integration } from "../model";
|
||||
import { availableFields, preChatConfig, readPreChat, selectField, type PreChatDraft } from "./model";
|
||||
|
||||
export function usePreChat(integration: Integration, onSaved: (integration: Integration) => void) {
|
||||
const [draft, setDraft] = useState(() => readPreChat(integration.config));
|
||||
const [customKeys, setCustomKeys] = useState(() => draft.preChat.fields.map(({ key }) => key));
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
const schema = integration.config.fields ?? [];
|
||||
|
||||
// Другой раздел может удалить своё поле: убираем ссылку и из черновика.
|
||||
useEffect(() => {
|
||||
setDraft((current) => ({ ...current, preChat: { ...current.preChat, fields: availableFields(current.preChat.fields, integration.config.fields ?? []) } }));
|
||||
}, [integration.config]);
|
||||
|
||||
function change(patch: Partial<PreChatDraft>) {
|
||||
setDraft((current) => ({ ...current, ...patch }));
|
||||
setError("");
|
||||
}
|
||||
function select(key: string, selected: boolean) {
|
||||
change({ preChat: { ...draft.preChat, fields: selectField(draft.preChat.fields, key, selected) } });
|
||||
}
|
||||
function add(key: string) {
|
||||
setCustomKeys((current) => [...new Set([...current, key])]);
|
||||
select(key, true);
|
||||
}
|
||||
async function save() {
|
||||
setBusy(true);
|
||||
setError("");
|
||||
try {
|
||||
const { integration: updated } = await api<{ integration: Integration }>(`/api/v1/integrations/${integration.id}/`, {
|
||||
method: "PATCH", body: JSON.stringify({ config: preChatConfig(integration.config, draft) }),
|
||||
});
|
||||
const normalized = readPreChat(updated.config);
|
||||
setDraft(normalized);
|
||||
onSaved(updated);
|
||||
} catch (caught) {
|
||||
setError(caught instanceof Error ? caught.message : t("common.could_not_save"));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
return {
|
||||
draft, change, select, add, save, busy, error,
|
||||
customFields: schema.filter(({ key }) => customKeys.includes(key)),
|
||||
remainingFields: schema.filter(({ key }) => !customKeys.includes(key)),
|
||||
};
|
||||
}
|
||||
@@ -8,10 +8,12 @@ import type { Message } from "@chatballs/shared";
|
||||
import type { MessageKey } from "./ru";
|
||||
import { siteFieldsEn } from "./site-fields.en";
|
||||
import { widgetAppearanceEn } from "./widget-appearance.en";
|
||||
import { preChatEn } from "./pre-chat.en";
|
||||
|
||||
export const en: Record<MessageKey, Message> = {
|
||||
...siteFieldsEn,
|
||||
...widgetAppearanceEn,
|
||||
...preChatEn,
|
||||
"admin.30_days": "30 days",
|
||||
"admin.7_days": "7 days",
|
||||
"admin.90_days": "90 days",
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
import type { preChatRu } from "./pre-chat.ru";
|
||||
|
||||
export const preChatEn: Record<keyof typeof preChatRu, string> = {
|
||||
"pre_chat.description": "Replaces the consent screen with fields, consent text and the “Start chat” button. If a value has already arrived from the website, the field is prefilled and the customer can edit it.",
|
||||
"pre_chat.enabled": "Request details at the start of a conversation",
|
||||
"pre_chat.disabled_hint": "When disabled, the customer sees consent and starts chatting",
|
||||
"pre_chat.field": "FIELD",
|
||||
"pre_chat.source": "SOURCE",
|
||||
"pre_chat.required": "REQUIRED",
|
||||
"pre_chat.required_label": "Required: {label}",
|
||||
"pre_chat.name": "Name",
|
||||
"pre_chat.email": "Email",
|
||||
"pre_chat.phone": "Phone",
|
||||
"pre_chat.site_source": "{key} · from website",
|
||||
"pre_chat.custom": "custom field",
|
||||
"pre_chat.add_custom": "Custom field from “Website data”",
|
||||
"pre_chat.types_hint": "Yes/No and List types use a switch and a dropdown",
|
||||
"pre_chat.title": "Form title",
|
||||
"pre_chat.consent": "Consent text",
|
||||
"pre_chat.consent_revision": "Revision {version} · changing the text increases the revision; customers with earlier consent will see the form again",
|
||||
"pre_chat.on": "on",
|
||||
"pre_chat.saved_today": "saved today, {time}",
|
||||
};
|
||||
@@ -0,0 +1,21 @@
|
||||
export const preChatRu = {
|
||||
"pre_chat.description": "Заменяет экран согласия: поля, текст согласия и кнопка «Начать чат». Если значение уже пришло с сайта — поле показывается заполненным, клиент может его поправить.",
|
||||
"pre_chat.enabled": "Запрашивать данные при начале диалога",
|
||||
"pre_chat.disabled_hint": "Выключено — клиент сразу видит согласие и пишет",
|
||||
"pre_chat.field": "ПОЛЕ",
|
||||
"pre_chat.source": "ИСТОЧНИК",
|
||||
"pre_chat.required": "ОБЯЗАТЕЛЬНОЕ",
|
||||
"pre_chat.required_label": "Обязательное: {label}",
|
||||
"pre_chat.name": "Имя",
|
||||
"pre_chat.email": "Email",
|
||||
"pre_chat.phone": "Телефон",
|
||||
"pre_chat.site_source": "{key} · с сайта",
|
||||
"pre_chat.custom": "своё поле",
|
||||
"pre_chat.add_custom": "Своё поле из «Данных с сайта»",
|
||||
"pre_chat.types_hint": "типы Да/Нет и Список — выключателем и выпадающим списком",
|
||||
"pre_chat.title": "Заголовок формы",
|
||||
"pre_chat.consent": "Текст согласия",
|
||||
"pre_chat.consent_revision": "Редакция {version} · при изменении текста редакция повышается, клиенты с прежним согласием увидят форму снова",
|
||||
"pre_chat.on": "вкл",
|
||||
"pre_chat.saved_today": "сохранено сегодня, {time}",
|
||||
} as const;
|
||||
@@ -9,10 +9,12 @@
|
||||
import type { Message } from "@chatballs/shared";
|
||||
import { siteFieldsRu } from "./site-fields.ru";
|
||||
import { widgetAppearanceRu } from "./widget-appearance.ru";
|
||||
import { preChatRu } from "./pre-chat.ru";
|
||||
|
||||
export const ru = {
|
||||
...siteFieldsRu,
|
||||
...widgetAppearanceRu,
|
||||
...preChatRu,
|
||||
"admin.30_days": "30 дней",
|
||||
"admin.7_days": "7 дней",
|
||||
"admin.90_days": "90 дней",
|
||||
|
||||
@@ -65,7 +65,7 @@ export function VkLogo({ size = 24 }: { size?: number }) {
|
||||
);
|
||||
}
|
||||
|
||||
export function Icon({ name, size = 17, strokeWidth = 1.8 }: { name: "grid" | "building" | "team" | "box" | "robot" | "plug" | "settings" | "bell" | "bellOff" | "chevron" | "chevronLeft" | "chevronRight" | "plus" | "arrow" | "paperclip" | "send" | "phone" | "video" | "lock" | "mail" | "eye" | "eyeOff" | "logout" | "user" | "refresh" | "transfer" | "warning" | "alert" | "bolt" | "shop" | "search" | "more" | "external" | "key" | "pause" | "play" | "percent" | "save" | "check" | "copy" | "clock" | "message" | "inbox" | "cart" | "columns" | "download" | "list" | "split" | "gitBranch" | "route" | "expand" | "edit" | "trash" | "wrench" | "xCircle" | "close" | "folder" | "grip" | "reply" | "sort" | "collapseLeft" | "mic" | "text" | "pin" | "smile" | "globe" | "doc" | "sparkles" | "database" | "sun" | "sunny" | "moon" | "monitor" | "laptop" | "smartphone" | "arrowDown" | "link" | "paint" | "widget" | "danger" | "image" | "file" | "bold" | "italic" | "code" | "numlist" | "quote" | "table" | "attach" | "undo" | "upload" | "import" | "thumbUp" | "thumbDown" | "book" | "move"; size?: number; strokeWidth?: number }) {
|
||||
export function Icon({ name, size = 17, strokeWidth = 1.8 }: { name: "grid" | "building" | "team" | "box" | "robot" | "plug" | "settings" | "bell" | "bellOff" | "chevron" | "chevronLeft" | "chevronRight" | "plus" | "arrow" | "paperclip" | "send" | "phone" | "video" | "lock" | "mail" | "eye" | "eyeOff" | "logout" | "user" | "refresh" | "transfer" | "warning" | "alert" | "bolt" | "shop" | "search" | "more" | "external" | "key" | "pause" | "play" | "percent" | "save" | "check" | "copy" | "clock" | "message" | "inbox" | "cart" | "columns" | "download" | "list" | "split" | "gitBranch" | "route" | "expand" | "edit" | "trash" | "wrench" | "xCircle" | "close" | "folder" | "grip" | "reply" | "sort" | "collapseLeft" | "mic" | "text" | "pin" | "smile" | "globe" | "doc" | "form" | "sparkles" | "database" | "sun" | "sunny" | "moon" | "monitor" | "laptop" | "smartphone" | "arrowDown" | "link" | "paint" | "widget" | "danger" | "image" | "file" | "bold" | "italic" | "code" | "numlist" | "quote" | "table" | "attach" | "undo" | "upload" | "import" | "thumbUp" | "thumbDown" | "book" | "move"; size?: number; strokeWidth?: number }) {
|
||||
const common = { width: size, height: size, fill: "none", stroke: "currentColor", strokeWidth, strokeLinecap: "round", strokeLinejoin: "round" } as const;
|
||||
const paths: Record<typeof name, ReactNode> = {
|
||||
thumbUp: <><path d="M7 10v11H4a2 2 0 0 1-2-2v-7a2 2 0 0 1 2-2h3z" /><path d="M7 10 11 3a2.2 2.2 0 0 1 4 1.8L14.5 8H20a2 2 0 0 1 2 2.4l-1.4 7A4 4 0 0 1 16.7 21H7" /></>,
|
||||
@@ -89,6 +89,7 @@ export function Icon({ name, size = 17, strokeWidth = 1.8 }: { name: "grid" | "b
|
||||
globe: <path d="M12 22a10 10 0 1 0 0-20 10 10 0 0 0 0 20zM2 12h20M12 2a14.5 14.5 0 0 0 0 20 14.5 14.5 0 0 0 0-20" />,
|
||||
// Документ библиотеки знаний (строка блока «Знания» на карточке агента).
|
||||
doc: <path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8zM14 2v6h6M8 13h8M8 17h5" />,
|
||||
form: <path d="M9 5H7a2 2 0 0 0-2 2v12a2 2 0 0 0 2 2h10a2 2 0 0 0 2-2V7a2 2 0 0 0-2-2h-2M9 5a2 2 0 0 1 2-2h2a2 2 0 0 1 2 2 2 2 0 0 1-2 2h-2a2 2 0 0 1-2-2zM9 12h6M9 16h4" />,
|
||||
columns: <><rect x="3" y="3" width="7" height="7" rx="1" /><rect x="14" y="3" width="7" height="7" rx="1" /><rect x="14" y="14" width="7" height="7" rx="1" /><rect x="3" y="14" width="7" height="7" rx="1" /></>,
|
||||
download: <><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" /><polyline points="7 10 12 15 17 10" /><line x1="12" y1="15" x2="12" y2="3" /></>,
|
||||
list: <path d="M8 6h13M8 12h13M8 18h13M3 6h.01M3 12h.01M3 18h.01" />,
|
||||
|
||||
@@ -31,6 +31,7 @@
|
||||
считаются от сырого акцента, а не от приглушённого. */
|
||||
--accent: #0f9b8e;
|
||||
--primary: var(--accent);
|
||||
--on-primary: #ffffff;
|
||||
--primary-hover: color-mix(in srgb, var(--accent) 88%, black);
|
||||
--primary-bg: color-mix(in srgb, var(--accent) 10%, var(--surface-card));
|
||||
--primary-border: color-mix(in srgb, var(--accent) 40%, var(--surface-card));
|
||||
|
||||
Reference in new issue
Block a user