✨ feat(webchat): добавить настройки оформления подключения

This commit is contained in:
Andrey committed 2026-10-01 13:11:16 +03:00
1 parent 52524287ae
commit 61b0dc2416
22 files changed
+689 -30

No files matched your search

@@ -0,0 +1,30 @@
---
id: T-023
title: Раздел «Оформление» в настройках подключения
milestone: M04
status: done
depends_on:
- T-008
- T-019
- T-020
order: 5
spec: "0021"
created: 2026-09-29
branch: skaro/T-023-razdel-oformlenie-v-nastroykah
---
## Цель
Администратор настраивает вид виджета в разделе W3 с живым предпросмотром, ровно как в макете (R-2, R-10).
## Критерии приёмки
- [x] 8 пресетов цвета и «Свой HEX» с подсказкой контраста: ≥4.5:1 — ок, меньше — предупреждение, неверный HEX — подсказка формата; сохранение не запрещено
- [x] Загрузка иконок кнопки и шапки, переключатели положения, размера и формы
- [x] Поле своего CSS со ссылкой «Классы виджета» и кнопкой «Сбросить»
- [x] Предпросмотр справа меняется сразу; цвета пресетов — данные, а не сырые hex в стилях компонентов
- [x] Все тексты через t() в ru и en, вид совпадает с кадром W3, файлы в пределах NO GOD FILES
## Итог
Ветка перенесена на main 52524287, четыре конфликта разрешены с сохранением раздела «Данные с сайта», счетчика полей, типов и обоих словарей. Результат зафиксирован переписанным коммитом 1184c6d7; рабочая папка чистая, main является предком HEAD. После разрешения конфликтов прошли сборка internal-ui, 20 связанных тестов и браузерная проверка всех трех разделов на реальном backend.
@@ -10,6 +10,7 @@ import { WebIntegrationBasics } from "./WebIntegrationBasics";
import { WebIntegrationHeader } from "./WebIntegrationHeader";
import { useWebIntegration } from "./useWebIntegration";
import { SiteFieldsSection } from "./site-fields/SiteFieldsSection";
import { WebIntegrationAppearance } from "./appearance/WebIntegrationAppearance";
type Section = "basics" | "fields" | "form" | "look" | "danger";
@@ -17,7 +18,7 @@ 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: "look", label: t("common.appearance"), icon: "paint", disabled: true, divider: true },
{ key: "look", label: t("common.appearance"), icon: "paint", divider: true },
{ key: "danger", label: t("settings.delete_connection"), icon: "trash", danger: true },
];
@@ -55,6 +56,7 @@ export function WebIntegrationPage({ integrationId, onOpenSettings, onBack }: {
<div hidden={section !== "fields"}>
<SiteFieldsSection key={integration.id} integration={integration} onSaved={setIntegration} onCount={setFieldCount} />
</div>
{section === "look" && <WebIntegrationAppearance key={integration.id} integration={integration} onSaved={setIntegration} />}
</div>
</div>
{confirmingDelete && <DeleteIntegrationDialog integration={integration} onClose={() => setConfirmingDelete(false)} onDeleted={onBack} />}
@@ -0,0 +1,30 @@
import { t } from "../../../i18n";
import { SHAPE_RADIUS, type WidgetAppearance } from "./model";
import { WidgetIcon } from "./WidgetIcon";
import "./preview.css";
/** Упрощённая копия из W3; демонстрационные реплики принадлежат макету. */
export function AppearancePreview({ appearance }: { appearance: WidgetAppearance }) {
const side = { [appearance.launcherPosition]: 16 };
return <aside className="widget-appearance-preview" aria-label={t("widget_appearance.preview")}>
<span className="widget-preview-label">{t("widget_appearance.preview")}</span>
<div className="widget-preview-site">
<span className="widget-preview-domain">{t("widget_appearance.preview_page", { domain: "obed.ru" })}</span>
<div className="widget-preview-panel" style={{ ...side, bottom: appearance.launcherSize + 28 }}>
<div className="widget-preview-header">
<WidgetIcon url={appearance.headerIcon === "" ? appearance.launcherIcon : appearance.headerIcon} size={28} />
<span>{t("widget_appearance.preview_agent")}</span>
<i><svg viewBox="0 0 24 24" width="15" height="15" fill="none" stroke="currentColor" strokeWidth="2.2" strokeLinecap="round"><path d="M5 12h14" /></svg></i>
</div>
<div className="widget-preview-body">
<div className="widget-preview-bubble is-agent">{t("widget_appearance.preview_greeting")}</div>
<div className="widget-preview-bubble is-client">{t("widget_appearance.preview_client")}</div>
</div>
<div className="widget-preview-composer"><span>{t("widget_appearance.preview_message")}</span><i><svg viewBox="0 0 24 24" width="15" height="15" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M12 19V5m-7 7 7-7 7 7" /></svg></i></div>
</div>
<div className="widget-preview-launcher" style={{ ...side, width: appearance.launcherSize, height: appearance.launcherSize, borderRadius: SHAPE_RADIUS[appearance.launcherShape] }}>
<WidgetIcon url={appearance.launcherIcon} size={Math.round(appearance.launcherSize * 0.55)} />
</div>
</div>
</aside>;
}
@@ -0,0 +1,41 @@
import type { CSSProperties } from "react";
import { fmt, t } from "../../../i18n";
import { Icon } from "../../../shared/icons";
import { COLOR_PRESETS, validHex, whiteContrast } from "./model";
export function ColorSettings({ accent, customHex, custom, onPreset, onHex }: {
accent: string; customHex: string; custom: boolean;
onPreset: (accent: string) => void; onHex: (hex: string) => void;
}) {
const invalid = custom && !validHex(customHex);
const ratio = whiteContrast(accent);
const note = invalid ? t("widget_appearance.hex_format") : t(ratio >= 4.5
? "widget_appearance.contrast_ok" : "widget_appearance.contrast_warning", {
ratio: fmt.number(ratio, { minimumFractionDigits: 1, maximumFractionDigits: 1 }),
});
return (
<div className="widget-appearance-card widget-color-settings">
<span className="widget-appearance-label">{t("widget_appearance.color")}</span>
<div className="widget-color-presets">
{COLOR_PRESETS.map(({ hex, label }) => (
<button key={hex} type="button" title={t(label)} aria-pressed={(!custom || invalid) && accent.toLowerCase() === hex}
className="widget-color-preset" style={{ "--swatch": hex } as CSSProperties} onClick={() => onPreset(hex)}>
<span>{(!custom || invalid) && accent.toLowerCase() === hex && <Icon name="check" size={15} strokeWidth={3} />}</span>
<small>{t(label)}</small>
</button>
))}
</div>
<div className="widget-custom-color-row">
<label className={`widget-custom-color${custom && !invalid ? " is-selected" : ""}`}>
<i style={{ background: validHex(customHex) ? customHex : "var(--n-9)" }} />
<span>{t("widget_appearance.custom")}</span>
<input aria-label={t("widget_appearance.custom_hex")} aria-describedby="widget-color-note"
value={customHex} placeholder={t("widget_appearance.hex_placeholder")} onChange={(event) => onHex(event.target.value)} />
</label>
<small id="widget-color-note" className={invalid || ratio < 4.5 ? "is-warning" : "is-readable"} aria-live="polite">{note}</small>
</div>
</div>
);
}
@@ -0,0 +1,13 @@
import { t } from "../../../i18n";
import { helpArticleUrl } from "../../../shared/help";
export function CssSettings({ value, onChange }: { value: string; onChange: (css: string) => void }) {
return <div className="widget-appearance-card widget-css-settings">
<div className="widget-css-heading">
<label className="widget-appearance-label" htmlFor="widget-custom-css">{t("widget_appearance.css")}</label>
<a className="link" href={helpArticleUrl("klassy-vidzheta")} target="_blank" rel="noreferrer">{t("widget_appearance.classes")}</a>
</div>
<textarea id="widget-custom-css" value={value} onChange={(event) => onChange(event.target.value)} spellCheck={false} />
<small className="widget-appearance-hint">{t("widget_appearance.css_hint")}</small>
</div>;
}
@@ -0,0 +1,63 @@
import { useRef, useState } from "react";
import { apiUpload } from "../../../api/client";
import { t, type MessageKey } from "../../../i18n";
import { Icon } from "../../../shared/icons";
import { SHAPE_RADIUS, type WidgetAppearance } from "./model";
import { WidgetIcon } from "./WidgetIcon";
function IconUpload({ integrationId, label, hint, url, radius, onUploaded, onBusy }: {
integrationId: number; label: MessageKey; hint: MessageKey; url: string | null; radius: string;
onUploaded: (url: string) => void; onBusy: (busy: boolean) => void;
}) {
const input = useRef<HTMLInputElement>(null);
const [busy, setBusy] = useState(false);
const [error, setError] = useState("");
async function upload(file: File) {
setBusy(true);
onBusy(true);
setError("");
try {
const form = new FormData();
form.append("file", file);
const { url } = await apiUpload<{ url: string }>(`/api/v1/integrations/${integrationId}/assets/`, form);
onUploaded(url);
} catch (caught) {
setError(caught instanceof Error ? caught.message : t("common.request_failed"));
} finally {
setBusy(false);
onBusy(false);
if (input.current) input.current.value = "";
}
}
return <div>
<span className="widget-appearance-label">{t(label)}</span>
<div className="widget-icon-upload">
<span className="widget-icon-tile" style={{ borderRadius: radius }}><WidgetIcon url={url} size={26} /></span>
<span className="widget-icon-caption"><strong>{t("widget_appearance.agent_mark")}</strong><small>{t("widget_appearance.icon_requirements")}</small></span>
<button type="button" className="portal-inline-button" disabled={busy} aria-label={`${t("widget_appearance.upload")}: ${t(label)}`} onClick={() => input.current?.click()}>
<Icon name="upload" size={13} />{t("widget_appearance.upload")}
</button>
<input hidden ref={input} type="file" accept="image/svg+xml,image/png,.svg,.png" aria-label={t(label)}
onChange={(event) => { const file = event.target.files?.[0]; if (file) void upload(file); }} />
</div>
<small className="widget-appearance-hint">{t(hint)}</small>
{error && <small className="integration-form-error" role="alert">{error}</small>}
</div>;
}
export function IconSettings({ integrationId, appearance, onChange, onBusy }: {
integrationId: number; appearance: WidgetAppearance;
onChange: (patch: Partial<WidgetAppearance>) => void; onBusy: (key: string, busy: boolean) => void;
}) {
return <div className="widget-appearance-card widget-icon-settings">
<IconUpload integrationId={integrationId} label="widget_appearance.launcher_icon" hint="widget_appearance.launcher_hint"
url={appearance.launcherIcon} radius={SHAPE_RADIUS[appearance.launcherShape]}
onUploaded={(launcherIcon) => onChange({ launcherIcon })} onBusy={(busy) => onBusy("launcher", busy)} />
<IconUpload integrationId={integrationId} label="widget_appearance.header_icon" hint="widget_appearance.header_hint"
url={appearance.headerIcon === "" ? appearance.launcherIcon : appearance.headerIcon} radius="10px"
onUploaded={(headerIcon) => onChange({ headerIcon })} onBusy={(busy) => onBusy("header", busy)} />
</div>;
}
@@ -0,0 +1,30 @@
import { fmt, t } from "../../../i18n";
import { Segmented } from "../../../shared/ui";
import type { WidgetAppearance } from "./model";
export function LauncherSettings({ appearance, onChange }: {
appearance: WidgetAppearance; onChange: (patch: Partial<WidgetAppearance>) => void;
}) {
return (
<div className="widget-appearance-card widget-launcher-settings">
<div>
<span className="widget-appearance-label">{t("widget_appearance.position")}</span>
<Segmented className="portal-scheme-segment" value={appearance.launcherPosition}
items={[["left", t("widget_appearance.left")], ["right", t("widget_appearance.right")]]}
setValue={(launcherPosition) => onChange({ launcherPosition })} />
</div>
<div>
<span className="widget-appearance-label">{t("widget_appearance.size")}</span>
<Segmented className="portal-scheme-segment" value={String(appearance.launcherSize)}
items={[48, 56, 64].map((size) => [String(size), fmt.number(size)])}
setValue={(size) => onChange({ launcherSize: Number(size) as WidgetAppearance["launcherSize"] })} />
</div>
<div>
<span className="widget-appearance-label">{t("widget_appearance.shape")}</span>
<Segmented className="portal-scheme-segment" value={appearance.launcherShape}
items={[["circle", t("widget_appearance.circle")], ["rounded", t("widget_appearance.rounded")], ["square", t("widget_appearance.square")]]}
setValue={(launcherShape) => onChange({ launcherShape })} />
</div>
</div>
);
}
@@ -0,0 +1,38 @@
import type { CSSProperties } from "react";
import { t } from "../../../i18n";
import { Button } from "../../../shared/ui-controls";
import type { Integration } from "../model";
import { AppearancePreview } from "./AppearancePreview";
import { ColorSettings } from "./ColorSettings";
import { CssSettings } from "./CssSettings";
import { IconSettings } from "./IconSettings";
import { LauncherSettings } from "./LauncherSettings";
import { useAppearance } from "./useAppearance";
import "./settings.css";
export function WebIntegrationAppearance({ integration, onSaved }: {
integration: Integration; onSaved: (integration: Integration) => void;
}) {
const state = useAppearance(integration, onSaved);
return <div className="widget-appearance-layout" style={{ "--widget-accent": state.appearance.accent } as CSSProperties}>
<div className="widget-appearance-controls">
<div className="portal-settings-heading">
<h3>{t("common.appearance")}</h3><p>{t("widget_appearance.description")}</p>
</div>
<ColorSettings accent={state.appearance.accent} customHex={state.customHex} custom={state.custom}
onPreset={state.pickPreset} onHex={state.changeHex} />
<IconSettings integrationId={integration.id} appearance={state.appearance} onChange={state.change} onBusy={state.uploadBusy} />
<LauncherSettings appearance={state.appearance} onChange={state.change} />
<CssSettings value={state.appearance.customCss} onChange={(customCss) => state.change({ customCss })} />
<div className="portal-settings-actions">
<Button variant="primary" disabled={state.busy} onClick={() => void state.save()}>{t("widget_appearance.save")}</Button>
<Button variant="secondary" onClick={() => state.change({ customCss: "" })}>{t("widget_appearance.reset")}</Button>
<span className="portal-settings-gap" />
{state.dirty && <span className="portal-settings-note">{t("widget_appearance.unsaved")}</span>}
</div>
{state.error && <div className="integration-form-error" role="alert">{state.error}</div>}
</div>
<AppearancePreview appearance={state.appearance} />
</div>;
}
@@ -0,0 +1,7 @@
import { BotIcon } from "@chatballs/ui";
export function WidgetIcon({ url, size }: { url: string | null; size: number }) {
if (url === null) return null;
return url ? <img src={url} alt="" width={size} height={size} style={{ objectFit: "contain" }} />
: <BotIcon size={size} blink={false} />;
}
@@ -0,0 +1,32 @@
import { describe, expect, it } from "vitest";
import { appearanceConfig, COLOR_PRESETS, DEFAULT_APPEARANCE, readAppearance, validHex, whiteContrast } from "./model";
describe("widget appearance", () => {
it("reads legacy color and preserves the header icon's three states", () => {
expect(readAppearance({ accent: "#0d8a7e" })).toEqual({ ...DEFAULT_APPEARANCE, accent: "#0d8a7e" });
for (const headerIcon of [null, "", "/icon.svg"]) {
expect(readAppearance({ accent: "#1677ff", appearance: { accent: "#c2410c", headerIcon } }))
.toMatchObject({ accent: "#c2410c", headerIcon });
}
});
it("calculates white text contrast with the WCAG luminance formula", () => {
expect(whiteContrast("#000000")).toBeCloseTo(21);
expect(whiteContrast("#ffffff")).toBeCloseTo(1);
expect(whiteContrast("#ffd400")).toBeLessThan(4.5);
expect(whiteContrast("#767676")).toBeGreaterThanOrEqual(4.5);
expect(whiteContrast("#777777")).toBeLessThan(4.5);
expect(whiteContrast(COLOR_PRESETS[2].hex)).toBeCloseTo(4.23919);
});
it("accepts only full six-digit HEX colors", () => {
expect(validHex("#ABCdef")).toBe(true);
for (const value of ["", "#abc", "1677ff", "#abcdefg", "#zzzzzz", "#abcdef;"]) expect(validHex(value)).toBe(false);
});
it("updates appearance and legacy accent without losing other configuration", () => {
const config = { accent: "", fields: [{ key: "order" }], form: { enabled: true }, allowedOrigins: ["example.com"] };
expect(appearanceConfig(config, DEFAULT_APPEARANCE)).toEqual({ ...config, accent: DEFAULT_APPEARANCE.accent, appearance: DEFAULT_APPEARANCE });
});
});
@@ -0,0 +1,53 @@
import type { MessageKey } from "../../../i18n";
export type WidgetAppearance = {
accent: string;
launcherIcon: string;
headerIcon: string | null;
launcherPosition: "left" | "right";
launcherSize: 48 | 56 | 64;
launcherShape: "circle" | "rounded" | "square";
customCss: string;
};
export const COLOR_PRESETS: ReadonlyArray<{ hex: string; label: MessageKey }> = [
{ hex: "#1677ff", label: "widget_appearance.blue" },
{ hex: "#4f46e5", label: "widget_appearance.indigo" },
{ hex: "#0d8a7e", label: "widget_appearance.teal" },
{ hex: "#15803d", label: "widget_appearance.green" },
{ hex: "#c2410c", label: "widget_appearance.terracotta" },
{ hex: "#be123c", label: "widget_appearance.raspberry" },
{ hex: "#7e22ce", label: "widget_appearance.plum" },
{ hex: "#262626", label: "widget_appearance.graphite" },
];
export const DEFAULT_APPEARANCE: WidgetAppearance = {
accent: COLOR_PRESETS[0].hex,
launcherIcon: "",
headerIcon: "",
launcherPosition: "right",
launcherSize: 56,
launcherShape: "circle",
customCss: "",
};
export const SHAPE_RADIUS = { circle: "50%", rounded: "30%", square: "10px" };
export const validHex = (value: string): boolean => /^#[0-9a-f]{6}$/i.test(value);
export function readAppearance(config: { accent?: string; appearance?: Partial<WidgetAppearance> }): WidgetAppearance {
const appearance = { ...DEFAULT_APPEARANCE, ...config.appearance };
appearance.accent = config.appearance?.accent || config.accent || DEFAULT_APPEARANCE.accent;
return appearance;
}
export function whiteContrast(hex: string): number {
const [r, g, b] = [1, 3, 5].map((offset) => {
const channel = parseInt(hex.slice(offset, offset + 2), 16) / 255;
return channel <= 0.04045 ? channel / 12.92 : ((channel + 0.055) / 1.055) ** 2.4;
});
return 1.05 / (0.2126 * r + 0.7152 * g + 0.0722 * b + 0.05);
}
export function appearanceConfig<T extends { accent: string }>(config: T, appearance: WidgetAppearance) {
return { ...config, accent: appearance.accent, appearance };
}
@@ -0,0 +1,56 @@
.widget-appearance-preview {
/* Цвета светлого превью W3: отдельные токены от темы рабочего места. */
--preview-stripe-light: #eef0f3;
--preview-stripe-dark: #e8eaee;
--preview-card: #fff;
--preview-body: #f7f8fa;
--preview-border: #e6e9ed;
--preview-bubble-border: #eee;
--preview-ink: #262626;
--preview-muted: #8c8c8c;
--preview-placeholder: #bfbfbf;
position: sticky;
top: 0;
}
.widget-preview-label { display: block; margin-bottom: 8px; color: var(--n-4); font-size: 11.5px; font-weight: 600; letter-spacing: 0.04em; }
.widget-preview-site {
position: relative;
height: 640px;
overflow: hidden;
border: 1px solid var(--n-7);
border-radius: 12px;
background: repeating-linear-gradient(135deg, var(--preview-stripe-light) 0 10px, var(--preview-stripe-dark) 10px 20px);
}
.widget-preview-domain {
position: absolute;
top: 14px;
left: 14px;
padding: 3px 8px;
border-radius: 5px;
background: color-mix(in srgb, var(--preview-card) 80%, transparent);
color: var(--preview-muted);
font-family: var(--font-mono);
font-size: 11px;
}
.widget-preview-panel {
position: absolute;
display: flex;
flex-direction: column;
width: 300px;
height: 430px;
overflow: hidden;
border-radius: 14px;
background: var(--preview-body);
box-shadow: 0 10px 30px rgb(0 0 0 / 18%);
}
.widget-preview-header { display: flex; align-items: center; gap: 9px; flex: none; padding: 11px 13px; background: var(--widget-accent); color: var(--preview-card); }
.widget-preview-header > span { flex: 1; font-size: 14px; font-weight: 600; }
.widget-preview-header i { display: flex; align-items: center; justify-content: center; width: 26px; height: 26px; border-radius: 8px; background: color-mix(in srgb, var(--preview-card) 14%, transparent); }
.widget-preview-body { display: flex; flex-direction: column; gap: 10px; flex: 1; padding: 14px 12px; }
.widget-preview-bubble { padding: 9px 12px; font-size: 13px; line-height: 19px; }
.widget-preview-bubble.is-agent { align-self: flex-start; max-width: 86%; border: 1px solid var(--preview-bubble-border); border-radius: 4px 14px 14px 14px; background: var(--preview-card); color: var(--preview-ink); }
.widget-preview-bubble.is-client { align-self: flex-end; max-width: 80%; border-radius: 14px 4px 14px 14px; background: var(--widget-accent); color: var(--preview-card); }
.widget-preview-composer { display: flex; align-items: center; gap: 6px; flex: none; padding: 8px 10px; border-top: 1px solid var(--preview-border); }
.widget-preview-composer > span { flex: 1; color: var(--preview-placeholder); font-size: 13px; }
.widget-preview-composer i { display: flex; align-items: center; justify-content: center; width: 30px; height: 30px; border-radius: 50%; background: var(--widget-accent); color: var(--preview-card); }
.widget-preview-launcher { position: absolute; bottom: 16px; display: flex; align-items: center; justify-content: center; background: var(--widget-accent); color: var(--preview-card); box-shadow: 0 6px 18px rgb(0 0 0 / 22%); }
@@ -0,0 +1,110 @@
/* W3: размеры, интервалы и порядок секций из утверждённого baseline. */
.widget-appearance-layout {
--widget-on-accent: #fff;
display: grid;
grid-template-columns: minmax(0, 1fr) 380px;
gap: 26px;
align-items: start;
padding: 26px 34px 48px;
}
.widget-appearance-controls { display: flex; flex-direction: column; gap: 18px; min-width: 0; }
.widget-appearance-controls .portal-settings-heading { margin: 0; }
.widget-appearance-controls .portal-settings-heading p { line-height: 1.5; }
.widget-appearance-card {
display: flex;
flex-direction: column;
gap: 12px;
padding: 18px 20px;
border: 1px solid var(--n-7);
border-radius: 10px;
background: var(--surface-card);
}
.widget-appearance-label { display: block; color: var(--n-2); font-size: 12.5px; font-weight: 600; }
.widget-color-presets { display: flex; flex-wrap: wrap; gap: 8px; }
.widget-color-preset {
display: flex;
flex-direction: column;
align-items: center;
gap: 6px;
width: 68px;
padding: 8px 4px 7px;
border: 1.5px solid var(--n-7);
border-radius: 10px;
background: var(--surface-card);
font-family: inherit;
cursor: pointer;
}
.widget-color-preset:hover { border-color: var(--n-6); }
.widget-color-preset[aria-pressed="true"] { border-color: var(--swatch); background: color-mix(in srgb, var(--swatch) 8%, var(--surface-card)); }
.widget-color-preset > span {
display: flex;
align-items: center;
justify-content: center;
width: 30px;
height: 30px;
border-radius: 50%;
background: var(--swatch);
color: var(--widget-on-accent);
}
.widget-color-preset small { color: var(--n-3); font-size: 11px; font-weight: 500; }
.widget-color-preset[aria-pressed="true"] small { color: var(--n-1); font-weight: 600; }
.widget-custom-color-row { display: flex; align-items: center; gap: 10px; }
.widget-custom-color {
display: flex;
align-items: center;
gap: 8px;
width: 190px;
height: 34px;
flex: none;
padding: 0 10px;
border: 1.5px solid var(--n-7);
border-radius: 8px;
background: var(--surface-card);
}
.widget-custom-color.is-selected { border-color: var(--widget-accent); }
.widget-custom-color i { width: 18px; height: 18px; flex: none; border: 1px solid var(--n-7); border-radius: 5px; }
.widget-custom-color span { color: var(--n-4); font-size: 12.5px; }
.widget-custom-color input {
flex: 1;
min-width: 0;
padding: 0;
border: 0;
outline: none;
background: transparent;
color: var(--n-1);
font-family: var(--font-mono);
font-size: 12.5px;
}
.widget-custom-color-row small { font-size: 11.5px; }
.widget-custom-color-row .is-readable { color: var(--success-text); }
.widget-custom-color-row .is-warning { color: var(--warning-text); }
.widget-icon-settings { gap: 18px; }
.widget-icon-settings .widget-appearance-label, .widget-launcher-settings .widget-appearance-label { margin-bottom: 8px; }
.widget-icon-upload { display: flex; align-items: center; gap: 11px; padding: 10px; border: 1px dashed var(--n-6); border-radius: 10px; }
.widget-icon-tile { display: flex; align-items: center; justify-content: center; width: 44px; height: 44px; flex: none; background: var(--widget-accent); color: var(--widget-on-accent); }
.widget-icon-caption { flex: 1; min-width: 0; line-height: 1.35; }
.widget-icon-caption strong { display: block; color: var(--n-1); font-size: 12.5px; font-weight: 600; }
.widget-icon-caption small, .widget-appearance-hint { color: var(--n-4); font-size: 11.5px; }
.widget-icon-upload .portal-inline-button { flex: none; padding: 0 10px; }
.widget-icon-settings .widget-appearance-hint { display: block; margin-top: 6px; }
.widget-launcher-settings { flex-direction: row; flex-wrap: wrap; gap: 22px; }
.widget-launcher-settings .segmented button { padding: 0 13px; }
.widget-css-settings { gap: 10px; }
.widget-css-heading { display: flex; align-items: center; justify-content: space-between; }
.widget-css-heading .link { font-size: 12px; font-weight: 600; }
.widget-css-settings textarea {
width: 100%;
height: 118px;
padding: 11px 13px;
border: 1px solid var(--n-7);
border-radius: 8px;
outline: none;
background: var(--n-10);
color: var(--n-2);
font-family: var(--font-mono);
font-size: 12.5px;
line-height: 1.6;
resize: none;
}
.widget-css-settings .widget-appearance-hint { line-height: 1.5; }
.widget-appearance-controls .portal-settings-actions .primary-button { min-height: 34px; }
@@ -0,0 +1,57 @@
import { useState } from "react";
import { api } from "../../../api/client";
import { t } from "../../../i18n";
import type { Integration } from "../model";
import { appearanceConfig, COLOR_PRESETS, readAppearance, validHex, type WidgetAppearance } from "./model";
export function useAppearance(integration: Integration, onSaved: (integration: Integration) => void) {
const initial = readAppearance(integration.config);
const [appearance, setAppearance] = useState(initial);
const [saved, setSaved] = useState(initial);
const [custom, setCustom] = useState(!COLOR_PRESETS.some(({ hex }) => hex === initial.accent.toLowerCase()));
const [customHex, setCustomHex] = useState(custom ? initial.accent : "");
const [busy, setBusy] = useState(false);
const [uploads, setUploads] = useState<Record<string, boolean>>({});
const [error, setError] = useState("");
const dirty = JSON.stringify(appearance) !== JSON.stringify(saved) || (custom && !validHex(customHex));
const uploading = Object.values(uploads).some(Boolean);
const change = (patch: Partial<WidgetAppearance>) => setAppearance((previous) => ({ ...previous, ...patch }));
function pickPreset(accent: string) {
setCustom(false);
change({ accent });
}
function changeHex(hex: string) {
setCustom(true);
setCustomHex(hex.trim());
// W3 использует «Терракоту», пока свой HEX некорректен. Сервер принимает
// только #RRGGBB: этот же цвет сохраняется, не блокируя остальные настройки.
change({ accent: validHex(hex.trim()) ? hex.trim().toLowerCase() : COLOR_PRESETS[4].hex });
}
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: appearanceConfig(integration.config, appearance) }),
});
const normalized = readAppearance(updated.config);
setSaved(normalized);
// Не перезаписывать изменения, внесённые во время запроса сохранения.
setAppearance((current) => JSON.stringify(current) === JSON.stringify(appearance) ? normalized : current);
onSaved(updated);
} catch (caught) {
setError(caught instanceof Error ? caught.message : t("common.could_not_save"));
} finally {
setBusy(false);
}
}
return {
appearance, custom, customHex, dirty, busy: busy || uploading, error, change, pickPreset, changeHex, save,
uploadBusy: (key: string, value: boolean) => setUploads((current) => ({ ...current, [key]: value })),
};
}
@@ -1,6 +1,7 @@
import { api } from "../../api/client";
import { t } from "../../i18n";
import type { SiteField } from "./site-fields/model";
import type { WidgetAppearance } from "./appearance/model";
export type IntegrationProvider = "OPENROUTER" | "CUSTOM" | "DEMO" | "MAX" | "TELEGRAM" | "VK" | "WEB" | "EMAIL";
export type IntegrationKind = "LLM_PROVIDER" | "MESSENGER";
@@ -38,6 +39,7 @@ export type Integration = {
allowedOrigins: string[];
title: string;
accent: string;
appearance?: WidgetAppearance;
greeting: string;
quickReplies: string[];
consentText: string;
+2
View File
@@ -7,9 +7,11 @@ import type { Message } from "@chatballs/shared";
import type { MessageKey } from "./ru";
import { siteFieldsEn } from "./site-fields.en";
import { widgetAppearanceEn } from "./widget-appearance.en";
export const en: Record<MessageKey, Message> = {
...siteFieldsEn,
...widgetAppearanceEn,
"admin.30_days": "30 days",
"admin.7_days": "7 days",
"admin.90_days": "90 days",
+2
View File
@@ -8,9 +8,11 @@
import type { Message } from "@chatballs/shared";
import { siteFieldsRu } from "./site-fields.ru";
import { widgetAppearanceRu } from "./widget-appearance.ru";
export const ru = {
...siteFieldsRu,
...widgetAppearanceRu,
"admin.30_days": "30 дней",
"admin.7_days": "7 дней",
"admin.90_days": "90 дней",
@@ -0,0 +1,47 @@
import type { widgetAppearanceRu } from "./widget-appearance.ru";
export const widgetAppearanceEn: Record<keyof typeof widgetAppearanceRu, string> = {
"widget_appearance.description": "The widget color, icons and launcher on your website. The preview on the right updates immediately.",
"widget_appearance.color": "Color",
"widget_appearance.blue": "Blue",
"widget_appearance.indigo": "Indigo",
"widget_appearance.teal": "Teal",
"widget_appearance.green": "Green",
"widget_appearance.terracotta": "Terracotta",
"widget_appearance.raspberry": "Raspberry",
"widget_appearance.plum": "Plum",
"widget_appearance.graphite": "Graphite",
"widget_appearance.custom": "Custom",
"widget_appearance.custom_hex": "Custom HEX",
"widget_appearance.hex_placeholder": "#RRGGBB",
"widget_appearance.hex_format": "Enter a 6-digit HEX, for example #0d8a7e",
"widget_appearance.contrast_ok": "White text is readable · contrast {ratio}:1",
"widget_appearance.contrast_warning": "White text is hard to read · contrast {ratio}:1 — choose a darker color",
"widget_appearance.launcher_icon": "Website launcher icon",
"widget_appearance.header_icon": "Chat header icon",
"widget_appearance.agent_mark": "Agent mark",
"widget_appearance.icon_requirements": "SVG or PNG · up to 256 KB · at least 96×96",
"widget_appearance.launcher_hint": "Default · transparent image, the launcher uses the selected background color",
"widget_appearance.header_hint": "Default · uses the launcher icon unless changed",
"widget_appearance.upload": "Upload",
"widget_appearance.position": "Launcher position",
"widget_appearance.left": "Left",
"widget_appearance.right": "Right",
"widget_appearance.size": "Size",
"widget_appearance.shape": "Shape",
"widget_appearance.circle": "Circle",
"widget_appearance.rounded": "Rounded",
"widget_appearance.square": "Square",
"widget_appearance.css": "Custom CSS styles",
"widget_appearance.classes": "Widget classes ↗",
"widget_appearance.css_hint": "Applied inside the chat window, without affecting the website. @import and external url() are discarded. If styles break the widget, Reset restores its default look.",
"widget_appearance.save": "Save appearance",
"widget_appearance.reset": "Reset",
"widget_appearance.unsaved": "unsaved changes",
"widget_appearance.preview": "WEBSITE PREVIEW",
"widget_appearance.preview_page": "{domain} page",
"widget_appearance.preview_agent": "Obed Assistant",
"widget_appearance.preview_greeting": "Hello! I can help with your order and the menu.",
"widget_appearance.preview_client": "Where is my order?",
"widget_appearance.preview_message": "Message…",
};
@@ -0,0 +1,45 @@
export const widgetAppearanceRu = {
"widget_appearance.description": "Цвет, иконки и кнопка виджета на сайте. Предпросмотр справа обновляется сразу.",
"widget_appearance.color": "Цвет",
"widget_appearance.blue": "Синий",
"widget_appearance.indigo": "Индиго",
"widget_appearance.teal": "Бирюза",
"widget_appearance.green": "Зелёный",
"widget_appearance.terracotta": "Терракота",
"widget_appearance.raspberry": "Малина",
"widget_appearance.plum": "Слива",
"widget_appearance.graphite": "Графит",
"widget_appearance.custom": "Свой",
"widget_appearance.custom_hex": "Свой HEX",
"widget_appearance.hex_placeholder": "#RRGGBB",
"widget_appearance.hex_format": "Нужен HEX из 6 знаков, например #0d8a7e",
"widget_appearance.contrast_ok": "Белый текст читается · контраст {ratio}:1",
"widget_appearance.contrast_warning": "Белый текст плохо читается · контраст {ratio}:1 — выберите цвет темнее",
"widget_appearance.launcher_icon": "Иконка кнопки на сайте",
"widget_appearance.header_icon": "Иконка в шапке чата",
"widget_appearance.agent_mark": "Знак агента",
"widget_appearance.icon_requirements": "SVG или PNG · до 256 КБ · от 96×96",
"widget_appearance.launcher_hint": "Стандартная · прозрачный фон, фон кнопки — выбранный цвет",
"widget_appearance.header_hint": "Стандартная · по умолчанию та же, что у кнопки",
"widget_appearance.upload": "Загрузить",
"widget_appearance.position": "Положение кнопки",
"widget_appearance.left": "Слева",
"widget_appearance.right": "Справа",
"widget_appearance.size": "Размер",
"widget_appearance.shape": "Форма",
"widget_appearance.circle": "Круг",
"widget_appearance.rounded": "Скруглённая",
"widget_appearance.square": "Квадрат",
"widget_appearance.css": "Свои CSS-стили",
"widget_appearance.classes": "Классы виджета ↗",
"widget_appearance.css_hint": "Применяются внутри окна чата, на сайт не влияют. @import и внешние url() отбрасываются. Если стили сломают виджет — «Сбросить» вернёт стандартный вид.",
"widget_appearance.save": "Сохранить оформление",
"widget_appearance.reset": "Сбросить",
"widget_appearance.unsaved": "есть несохранённые изменения",
"widget_appearance.preview": "ПРЕДПРОСМОТР НА САЙТЕ",
"widget_appearance.preview_page": "страница {domain}",
"widget_appearance.preview_agent": "Помощник Обед",
"widget_appearance.preview_greeting": "Здравствуйте! Подскажу по заказу и меню.",
"widget_appearance.preview_client": "Где мой заказ?",
"widget_appearance.preview_message": "Сообщение…",
} as const;
+2 -29
View File
@@ -1,32 +1,5 @@
import { useId } from "react";
/** Знак агента (утверждён владельцем 20.09.2026).
*
* Глаза вырезаны маской и моргают одной группой — синхронно, раз в четыре с
* небольшим секунды. Маска получает свой id на каждый экземпляр: с общим id
* все знаки на странице смотрели бы в одну маску, и размонтирование соседа
* ломало бы остальные.
*/
export function BotIcon({ size = 24, color = "currentColor" }: { size?: number; color?: string }) {
const maskId = useId();
return (
<svg width={size} height={size} viewBox="0 0 48 48" fill={color} aria-hidden="true" style={{ display: "block" }}>
<mask id={maskId} maskUnits="userSpaceOnUse" x="0" y="0" width="48" height="48">
<rect width="48" height="48" fill="#fff" />
<g style={{ transformOrigin: "24px 20px", animation: "wcBotBlink 4.2s infinite" }}>
<rect x="15" y="16.4" width="4" height="7.2" rx="2" fill="#000" />
<rect x="29" y="16.4" width="4" height="7.2" rx="2" fill="#000" />
</g>
<path d="M17.6 26.4c1.7 2.4 3.9 3.6 6.4 3.6s4.7-1.2 6.4-3.6" fill="none" stroke="#000" strokeWidth="2.6" strokeLinecap="round" />
</mask>
<circle cx="24" cy="3.6" r="2.4" />
<rect x="22.8" y="4.8" width="2.4" height="5" />
<rect x="2.2" y="18" width="2.6" height="8.4" rx="1.3" />
<rect x="43.2" y="18" width="2.6" height="8.4" rx="1.3" />
<path mask={`url(#${maskId})`} d="M14 9.2h20a6.8 6.8 0 0 1 6.8 6.8v12.4a6.8 6.8 0 0 1-6.8 6.8H21.6l-6.4 5.2a.9.9 0 0 1-1.5-.7v-4.5h-.3A6.8 6.8 0 0 1 7.2 28.4V16A6.8 6.8 0 0 1 14 9.2Z" />
</svg>
);
}
import { BotIcon } from "@chatballs/ui";
export { BotIcon } from "@chatballs/ui";
/** Круглый аватар агента: фон — акцент канала, внутри знак. */
export function BotAvatar({ size = 30, accent, color = "#fff" }: { size?: number; accent: string; color?: string }) {
+25
View File
@@ -0,0 +1,25 @@
import { useId } from "react";
/** Утверждённый знак агента: общая геометрия для виджета и предпросмотра. */
export function BotIcon({ size = 24, color = "currentColor", blink = true }: {
size?: number; color?: string; blink?: boolean;
}) {
const maskId = useId();
return (
<svg width={size} height={size} viewBox="0 0 48 48" fill={color} aria-hidden="true" style={{ display: "block" }}>
<mask id={maskId} maskUnits="userSpaceOnUse" x="0" y="0" width="48" height="48">
<rect width="48" height="48" fill="white" />
<g style={blink ? { transformOrigin: "24px 20px", animation: "wcBotBlink 4.2s infinite" } : undefined}>
<rect x="15" y="16.4" width="4" height="7.2" rx="2" fill="black" />
<rect x="29" y="16.4" width="4" height="7.2" rx="2" fill="black" />
</g>
<path d="M17.6 26.4c1.7 2.4 3.9 3.6 6.4 3.6s4.7-1.2 6.4-3.6" fill="none" stroke="black" strokeWidth="2.6" strokeLinecap="round" />
</mask>
<circle cx="24" cy="3.6" r="2.4" />
<rect x="22.8" y="4.8" width="2.4" height="5" />
<rect x="2.2" y="18" width="2.6" height="8.4" rx="1.3" />
<rect x="43.2" y="18" width="2.6" height="8.4" rx="1.3" />
<path mask={`url(#${maskId})`} d="M14 9.2h20a6.8 6.8 0 0 1 6.8 6.8v12.4a6.8 6.8 0 0 1-6.8 6.8H21.6l-6.4 5.2a.9.9 0 0 1-1.5-.7v-4.5h-.3A6.8 6.8 0 0 1 7.2 28.4V16A6.8 6.8 0 0 1 14 9.2Z" />
</svg>
);
}
+1
View File
@@ -5,4 +5,5 @@ export { type AudioBarKind, type AudioCallMode, type AudioCallStatus, buildAudio
export { useCallRtcSession, type CallMediaIssue } from "./call/useCallRtcSession";
export { useAudioCue, useLoopingAudio } from "./audio/useAudio";
export { Loader } from "./loader/Loader";
export { BotIcon } from "./BotIcon";
export { VOICE_WAVE_BARS, voiceWaveHeights } from "./voice/voiceWave";