✨ feat(webchat): выводить HTML согласия в виджете

Экран согласия и форма перед чатом показывают очищенный сервером HTML
согласия через общий компонент ConsentText (SPEC-0020 R-16): ссылка — цвета
виджета, полужирная, без подчёркивания; жирный — полужирный основного цвета;
у списка отступ 18px. Своих правил очистки в виджете нет.

«Редакция N»: текст в одну строку — в конце через « · », как раньше; с
переносом, абзацем или списком — отдельной строкой под текстом. Для отдельной
строки в словарях ru и en добавлены подписи с прописной буквы.
This commit is contained in:
Andrey committed 2026-10-04 12:11:38 +03:00
1 parent 29b58e6a8f
commit 39e76d4e14
11 files changed
+143 -6

No files matched your search

@@ -0,0 +1,31 @@
---
id: T-027
title: Вывод HTML согласия в виджете
milestone: M05
status: done
depends_on:
- T-026
order: 3
spec: "0020"
created: 2026-10-02
branch: skaro/T-027-vyvod-html-soglasiya-v-vidzhet
---
## Цель
Клиент видит в согласии кликабельные ссылки, переносы и выделение — на экране согласия и в форме перед чатом (SPEC-0020 R-16).
## Критерии приёмки
- [x] `ChatBody.tsx` и `PreChatForm.tsx` выводят HTML согласия через один общий компонент, без дублирования.
- [x] Вид — ровно по кадрам M1, M3, M4: ссылка цвета виджета, полужирная, без подчёркивания, открывается в новой вкладке; жирный — полужирный основного цвета; у списка отступ 18px.
- [x] «Редакция N»: текст в одну строку — в конце через « · » (M4); с абзацами или списком — отдельной строкой под текстом мелким вторичным цветом (M1, M3).
- [x] Тест виджета проверяет вывод разметки и то, что текст без тегов показывается как раньше.
## Заметки
Макет: кадры M1, M3, M4 в `design/baseline/Веб-чат · поля и оформление/`; пояснение — `design/baseline/Инструменты агента и данные клиента.md`, п. 2. Виджет доверяет только очищенному сервером тексту из публичной конфигурации; своих правил очистки в виджете не заводить. Цвета — от цвета виджета и токенов, без новых сырых hex.
## Итог
Экран согласия и форма перед чатом выводят очищенный сервером HTML согласия через общий компонент ConsentText со стилями по кадрам M1, M3, M4; подпись редакции стоит в конце строки через « · » либо отдельной строкой под текстом. Добавлен тест виджета; вид проверен в браузере в окружении задачи skaro-chatballs-t-027 на настоящем бэкенде.
+2 -1
View File
@@ -2,6 +2,7 @@ import type { RefObject } from "react";
import type { WebConfig, WebMessage } from "./api";
import { BotAvatar } from "./BotIcon";
import { Bubble, SystemMessage, Typing } from "./ChatMessages";
import { ConsentText } from "./ConsentText";
import { PhoneForm } from "./PhoneForm";
import { t } from "./i18n";
import { widgetClasses as classes } from "./widgetClasses";
@@ -47,6 +48,6 @@ function Unavailable() {
}
function Consent({ config, accent, title }: { config: WebConfig; accent: string; title: string }) {
return <><div style={{ display: "flex", flexDirection: "column", alignItems: "center", textAlign: "center", padding: "20px 12px 8px" }}><div style={{ marginBottom: 14 }}><BotAvatar size={56} accent={accent} /></div><h3 style={{ margin: 0, fontSize: 17, fontWeight: 700 }}>{title}</h3><p style={{ margin: "8px 0 0", fontSize: 15.5, lineHeight: "22px", color: "#595959", maxWidth: 300 }}>{config.greeting}</p></div><div style={{ marginTop: 18, background: "#fff", border: "1px solid #f0f0f0", borderRadius: 12, padding: 14 }}><div style={{ fontSize: 12, color: "#595959", lineHeight: 1.5 }}>{config.consent?.text} · {t("chat.consent_revision", { version: config.consent?.version ?? "" })}</div></div></>;
return <><div style={{ display: "flex", flexDirection: "column", alignItems: "center", textAlign: "center", padding: "20px 12px 8px" }}><div style={{ marginBottom: 14 }}><BotAvatar size={56} accent={accent} /></div><h3 style={{ margin: 0, fontSize: 17, fontWeight: 700 }}>{title}</h3><p style={{ margin: "8px 0 0", fontSize: 15.5, lineHeight: "22px", color: "#595959", maxWidth: 300 }}>{config.greeting}</p></div><div style={{ marginTop: 18, background: "#fff", border: "1px solid #f0f0f0", borderRadius: 12, padding: 14 }}><ConsentText consent={config.consent} place="screen" /></div></>;
}
+33
View File
@@ -0,0 +1,33 @@
import type { WebConfig } from "./api";
import { t } from "./i18n";
import { widgetClasses as classes } from "./widgetClasses";
// Подпись редакции: в строке — со строчной, отдельной строкой — с прописной.
const REVISION = {
screen: { inline: "chat.consent_revision", line: "chat.consent_revision_line" },
form: { inline: "pre_chat.consent_revision", line: "pre_chat.consent_revision_line" },
} as const;
// Перенос, абзац или список: текст уже не в одну строку.
const MULTILINE = /<(br|p|ul|ol|li)\b/i;
/**
* Текст согласия с подписью редакции. Разметку очищает сервер по белому списку
* (SPEC-0020 R-14–R-15) — виджет выводит её как есть и своих правил не заводит.
*/
export function ConsentText({ consent, place, className }: {
consent: WebConfig["consent"];
place: keyof typeof REVISION;
className?: string;
}) {
const html = consent?.text ?? "";
const version = consent?.version ?? "";
const multiline = MULTILINE.test(html);
return (
<div className={className ? `${classes.consent} ${className}` : classes.consent}>
{multiline
? <><div dangerouslySetInnerHTML={{ __html: html }} /><div className={classes.consentRevision}>{t(REVISION[place].line, { version })}</div></>
: <><span dangerouslySetInnerHTML={{ __html: html }} /> · {t(REVISION[place].inline, { version })}</>}
</div>
);
}
+2 -3
View File
@@ -2,6 +2,7 @@ import { useState } from "react";
import { ConfigProvider } from "antd";
import type { SiteFields, WebConfig } from "./api";
import { BotAvatar } from "./BotIcon";
import { ConsentText } from "./ConsentText";
import { PreChatField } from "./PreChatField";
import { formPayload, inputValue, preChatFields, validField } from "./preChatModel";
import { t } from "./i18n";
@@ -39,9 +40,7 @@ export function PreChatForm({ config, accent, title, siteValues, starting, onAcc
fromSite={Object.hasOwn(siteValues, field.key) && siteValues[field.key] != null && siteValues[field.key] !== ""}
onChange={(value) => setEdits((previous) => ({ ...previous, [field.key]: value }))} />)}
</div>
<div className={classes.preChatConsent}>
{config.consent?.text} · {t("pre_chat.consent_revision", { version: config.consent?.version ?? "" })}
</div>
<ConsentText consent={config.consent} place="form" className={classes.preChatConsent} />
</div>
<div className={classes.startFooter}>
<button type="submit" className={classes.startButton} disabled={!valid || starting}>
+2 -1
View File
@@ -1,7 +1,8 @@
import baseCss from "./widget.css?raw";
import preChatCss from "./preChat.css?raw";
import consentCss from "./consent.css?raw";
/** Both sheets belong to the chat document, never the embedding page. */
export function WidgetStyles({ customCss }: { customCss: string }) {
return <><style data-cb-styles="base">{baseCss + preChatCss}</style><style data-cb-styles="custom">{customCss}</style></>;
return <><style data-cb-styles="base">{baseCss + preChatCss + consentCss}</style><style data-cb-styles="custom">{customCss}</style></>;
}
+6
View File
@@ -0,0 +1,6 @@
:where(.cb-consent) { font-size: 12px; color: var(--cb-secondary); line-height: 1.5; }
:where(.cb-consent a) { color: var(--cb-accent); font-weight: 600; text-decoration: none; }
:where(.cb-consent :is(b, strong)) { font-weight: 600; color: var(--cb-field-text); }
:where(.cb-consent :is(ul, ol)) { margin: 2px 0 0; padding-left: 18px; }
:where(.cb-consent p) { margin: 0; }
:where(.cb-consent-revision) { margin-top: 6px; color: var(--cb-muted); font-size: 11.5px; }
+60
View File
@@ -0,0 +1,60 @@
import { renderToStaticMarkup } from "react-dom/server";
import { beforeAll, describe, expect, it } from "vitest";
import type { WebConfig } from "./api";
import { ChatBody } from "./ChatBody";
import { ConsentText } from "./ConsentText";
import { PreChatForm } from "./PreChatForm";
import { applyWidgetLanguage } from "./i18n";
import { widgetClasses as classes } from "./widgetClasses";
const LINK = '<a href="https://example.ru/privacy" target="_blank" rel="noopener noreferrer nofollow">политику обработки данных</a>';
const RICH = `Нажимая «Начать чат», вы принимаете ${LINK}.<br><strong>Что мы храним:</strong><ul><li>имя и контакты;</li><li>историю диалога.</li></ul>`;
const PLAIN = "Продолжая, вы соглашаетесь на обработку сообщений.";
function config(text: string): WebConfig {
return { available: true, title: "Помощник", greeting: "Здравствуйте!", consent: { text, version: "v3" } };
}
function screen(text: string): string {
return renderToStaticMarkup(<ChatBody bodyRef={{ current: null }} config={config(text)} unavailable={false} accepted={false} accent="#1677ff" title="Помощник" messages={[]} pending={[]} awaiting={false} lastContactRequestId={0} showPhoneForm={false} onSubmitContact={async () => true} />);
}
function form(text: string): string {
return renderToStaticMarkup(<PreChatForm config={{ ...config(text), preChat: { enabled: true, title: "", fields: [] } }} accent="#1677ff" title="Помощник" siteValues={{}} starting={false} onAccept={async () => {}} />);
}
describe("consent text", () => {
beforeAll(() => applyWidgetLanguage("ru"));
it("shows text without tags as before, with the revision at the end of the line", () => {
expect(screen(PLAIN)).toContain(`${PLAIN}</span> · ред. v3</div>`);
expect(form(PLAIN)).toContain(`${PLAIN}</span> · редакция v3</div>`);
for (const html of [screen(PLAIN), form(PLAIN)]) expect(html).not.toContain(classes.consentRevision);
});
it("renders sanitized markup on the consent screen and in the pre-chat form", () => {
for (const html of [screen(RICH), form(RICH)]) {
expect(html).toContain(LINK);
expect(html).toContain("<br><strong>Что мы храним:</strong><ul><li>имя и контакты;</li>");
expect(html).not.toContain("&lt;");
}
});
it("keeps the revision inline for one line and moves it below paragraphs, breaks and lists", () => {
const inline = `Вы принимаете ${LINK} и <strong>запись чата</strong>`;
expect(screen(inline)).toContain("</strong></span> · ред. v3</div>");
expect(screen(RICH)).toContain(`</ul></div><div class="${classes.consentRevision}">Ред. v3</div>`);
expect(form(RICH)).toContain(`</ul></div><div class="${classes.consentRevision}">Редакция v3</div>`);
for (const text of ["Первая<br>вторая", "<p>Абзац</p>", "<ol><li>Пункт</li></ol>"]) {
expect(screen(text)).toContain(`class="${classes.consentRevision}"`);
expect(screen(text)).not.toContain(" · ");
}
});
it("goes through one shared component with the form's own spacing hook", () => {
const shared = renderToStaticMarkup(<ConsentText consent={config(RICH).consent} place="screen" />);
expect(screen(RICH)).toContain(shared);
expect(form(RICH)).toContain(`class="${classes.consent} ${classes.preChatConsent}"`);
});
});
+2
View File
@@ -12,6 +12,7 @@ export const en: Record<ChatMessageKey, Message> = {
"pre_chat.from_site": "from website",
"pre_chat.start": "Start chat",
"pre_chat.consent_revision": "revision {version}",
"pre_chat.consent_revision_line": "Revision {version}",
"chat.accept_and_start": "Accept and start the chat",
"chat.attach_file": "Attach a file",
"chat.cancel_recording": "Cancel the recording",
@@ -19,6 +20,7 @@ export const en: Record<ChatMessageKey, Message> = {
"chat.collapse": "Collapse",
"chat.today": "Today",
"chat.consent_revision": "rev. {version}",
"chat.consent_revision_line": "Rev. {version}",
"chat.invites_you_to_call": "{name} is inviting you to an online call",
"chat.download": "Download",
"chat.file_too_big": "The file is over 20 MB",
+2
View File
@@ -13,6 +13,7 @@ export const ru = {
"pre_chat.from_site": "с сайта",
"pre_chat.start": "Начать чат",
"pre_chat.consent_revision": "редакция {version}",
"pre_chat.consent_revision_line": "Редакция {version}",
"chat.accept_and_start": "Принять и начать чат",
"chat.attach_file": "Прикрепить файл",
"chat.cancel_recording": "Отменить запись",
@@ -20,6 +21,7 @@ export const ru = {
"chat.collapse": "Свернуть",
"chat.today": "Сегодня",
"chat.consent_revision": "ред. {version}",
"chat.consent_revision_line": "Ред. {version}",
"chat.invites_you_to_call": "{name} приглашает вас на онлайн-звонок",
"chat.download": "Скачать",
"chat.file_too_big": "Файл больше 20 МБ",
+1 -1
View File
@@ -35,5 +35,5 @@
font-size: 14px; font-family: inherit; color: var(--cb-field-text); background: var(--cb-surface);
}
:where(.cb-pre-chat-input[data-from-site="true"]) { background: var(--cb-site-surface); }
:where(.cb-pre-chat-consent) { margin-top: 12px; padding: 0 4px; font-size: 12px; color: var(--cb-secondary); line-height: 1.5; }
:where(.cb-pre-chat-consent) { margin-top: 12px; padding: 0 4px; }
:where(.cb-pre-chat .cb-start-button:disabled) { cursor: default; }
+2
View File
@@ -33,5 +33,7 @@ export const widgetClasses = {
preChatSource: "cb-pre-chat-source",
preChatInput: "cb-pre-chat-input",
preChatConsent: "cb-pre-chat-consent",
consent: "cb-consent",
consentRevision: "cb-consent-revision",
brand: "cb-brand",
} as const;