From 39e76d4e148b252ee174e2956ad02150e52297ee Mon Sep 17 00:00:00 2001 From: Andrey Date: Sun, 4 Oct 2026 12:11:38 +0300 Subject: [PATCH] =?UTF-8?q?:sparkles:=20feat(webchat):=20=D0=B2=D1=8B?= =?UTF-8?q?=D0=B2=D0=BE=D0=B4=D0=B8=D1=82=D1=8C=20HTML=20=D1=81=D0=BE?= =?UTF-8?q?=D0=B3=D0=BB=D0=B0=D1=81=D0=B8=D1=8F=20=D0=B2=20=D0=B2=D0=B8?= =?UTF-8?q?=D0=B4=D0=B6=D0=B5=D1=82=D0=B5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Экран согласия и форма перед чатом показывают очищенный сервером HTML согласия через общий компонент ConsentText (SPEC-0020 R-16): ссылка — цвета виджета, полужирная, без подчёркивания; жирный — полужирный основного цвета; у списка отступ 18px. Своих правил очистки в виджете нет. «Редакция N»: текст в одну строку — в конце через « · », как раньше; с переносом, абзацем или списком — отдельной строкой под текстом. Для отдельной строки в словарях ru и en добавлены подписи с прописной буквы. --- .../T-027-vyvod-html-soglasiya-v-vidzhete.md | 31 ++++++++++ apps/web-chat/src/ChatBody.tsx | 3 +- apps/web-chat/src/ConsentText.tsx | 33 ++++++++++ apps/web-chat/src/PreChatForm.tsx | 5 +- apps/web-chat/src/WidgetStyles.tsx | 3 +- apps/web-chat/src/consent.css | 6 ++ apps/web-chat/src/consentText.test.tsx | 60 +++++++++++++++++++ apps/web-chat/src/i18n/en.ts | 2 + apps/web-chat/src/i18n/ru.ts | 2 + apps/web-chat/src/preChat.css | 2 +- apps/web-chat/src/widgetClasses.ts | 2 + 11 files changed, 143 insertions(+), 6 deletions(-) create mode 100644 .skaro/tasks/T-027-vyvod-html-soglasiya-v-vidzhete.md create mode 100644 apps/web-chat/src/ConsentText.tsx create mode 100644 apps/web-chat/src/consent.css create mode 100644 apps/web-chat/src/consentText.test.tsx diff --git a/.skaro/tasks/T-027-vyvod-html-soglasiya-v-vidzhete.md b/.skaro/tasks/T-027-vyvod-html-soglasiya-v-vidzhete.md new file mode 100644 index 0000000..e64205c --- /dev/null +++ b/.skaro/tasks/T-027-vyvod-html-soglasiya-v-vidzhete.md @@ -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 на настоящем бэкенде. diff --git a/apps/web-chat/src/ChatBody.tsx b/apps/web-chat/src/ChatBody.tsx index 3d6cbd5..488587a 100644 --- a/apps/web-chat/src/ChatBody.tsx +++ b/apps/web-chat/src/ChatBody.tsx @@ -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 <>

{title}

{config.greeting}

{config.consent?.text} · {t("chat.consent_revision", { version: config.consent?.version ?? "" })}
; + return <>

{title}

{config.greeting}

; } diff --git a/apps/web-chat/src/ConsentText.tsx b/apps/web-chat/src/ConsentText.tsx new file mode 100644 index 0000000..7984916 --- /dev/null +++ b/apps/web-chat/src/ConsentText.tsx @@ -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 ( +
+ {multiline + ? <>
{t(REVISION[place].line, { version })}
+ : <> · {t(REVISION[place].inline, { version })}} +
+ ); +} diff --git a/apps/web-chat/src/PreChatForm.tsx b/apps/web-chat/src/PreChatForm.tsx index 24406ae..531d924 100644 --- a/apps/web-chat/src/PreChatForm.tsx +++ b/apps/web-chat/src/PreChatForm.tsx @@ -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 }))} />)}
-
- {config.consent?.text} · {t("pre_chat.consent_revision", { version: config.consent?.version ?? "" })} -
+
`); + expect(form(PLAIN)).toContain(`${PLAIN} · редакция v3`); + 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("
Что мы храним:
Ред. v3
`); + expect(form(RICH)).toContain(`
Редакция v3
`); + for (const text of ["Первая
вторая", "

Абзац

", "
  1. Пункт
"]) { + 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(); + expect(screen(RICH)).toContain(shared); + expect(form(RICH)).toContain(`class="${classes.consent} ${classes.preChatConsent}"`); + }); +}); diff --git a/apps/web-chat/src/i18n/en.ts b/apps/web-chat/src/i18n/en.ts index f15b627..65ccfa6 100644 --- a/apps/web-chat/src/i18n/en.ts +++ b/apps/web-chat/src/i18n/en.ts @@ -12,6 +12,7 @@ export const en: Record = { "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 = { "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", diff --git a/apps/web-chat/src/i18n/ru.ts b/apps/web-chat/src/i18n/ru.ts index 27fb2a3..078a9d1 100644 --- a/apps/web-chat/src/i18n/ru.ts +++ b/apps/web-chat/src/i18n/ru.ts @@ -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 МБ", diff --git a/apps/web-chat/src/preChat.css b/apps/web-chat/src/preChat.css index d75f8c1..a157d74 100644 --- a/apps/web-chat/src/preChat.css +++ b/apps/web-chat/src/preChat.css @@ -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; } diff --git a/apps/web-chat/src/widgetClasses.ts b/apps/web-chat/src/widgetClasses.ts index 6332243..bfab3c6 100644 --- a/apps/web-chat/src/widgetClasses.ts +++ b/apps/web-chat/src/widgetClasses.ts @@ -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;