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
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;
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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;
|
||||
@@ -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 }) {
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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";
|
||||
Reference in new issue
Block a user