mirror of
https://github.com/dartdavros/chatballs.git
synced 2026-10-06 09:34:59 +03:00
✨ feat(webchat): выводить HTML согласия в виджете
Экран согласия и форма перед чатом показывают очищенный сервером HTML согласия через общий компонент ConsentText (SPEC-0020 R-16): ссылка — цвета виджета, полужирная, без подчёркивания; жирный — полужирный основного цвета; у списка отступ 18px. Своих правил очистки в виджете нет. «Редакция N»: текст в одну строку — в конце через « · », как раньше; с переносом, абзацем или списком — отдельной строкой под текстом. Для отдельной строки в словарях ru и en добавлены подписи с прописной буквы.
This commit is contained in:
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,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></>;
|
||||
}
|
||||
|
||||
@@ -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,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}>
|
||||
|
||||
@@ -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></>;
|
||||
}
|
||||
@@ -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; }
|
||||
@@ -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("<");
|
||||
}
|
||||
});
|
||||
|
||||
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}"`);
|
||||
});
|
||||
});
|
||||
@@ -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",
|
||||
|
||||
@@ -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 МБ",
|
||||
|
||||
@@ -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; }
|
||||
@@ -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;
|
||||
Reference in new issue
Block a user