✨ feat(onboarding): визард «Начало работы» вместо блока «Запуск»

Онбординг из восьми шагов: приветствие, визард с точным путём и превью
результата на каждом шаге, тур «Показать где» с подсветкой реального
элемента и финальный экран. Точки возврата — ссылка внизу субменю
«Настроек» и пилюля в углу рабочей области. Вёрстка по макету
design/baseline/Онбординг.

Признак «закрыл» и «прошёл» живёт на членстве человека в организации, а
не в localStorage: требование — показать визард всем, кто его ещё не
закрывал, включая тех, кто работает в установке давно. У каждого он свой,
поэтому один администратор не прячет визард команде. Прогресс шагов
считается по факту настройки, ручное «Далее» его не подменяет.

Заодно все селекты приложения переведены на общее меню: SelectMenu в
shared/ui-controls, поверх него FilterDropdown (фильтры списков) и
SelectField (поля форм). Нативных <select> не осталось, вместе с ними
ушли пять копий скина селекта. Изменения переплетены с онбордингом через
общие файлы (словари, e2e-сценарии), поэтому одним коммитом.

Устаревший design/design-system удалён: источник истины по интерфейсу —
макеты design/baseline/<фича>/*.dc.html.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
AndreyandClaude Opus 5 committed 2026-09-13 07:22:35 +03:00
1 parent d8420e4379
commit 1cff9e2183
69 files changed
+6017 -3727

No files matched your search

+1 -1
View File
@@ -16,7 +16,7 @@ Production deployment / миграция:
- Перед любым изменяющим действием в production сначала представить владельцу точный план миграции и получить его явное согласование. Разрешение на диагностику или общая просьба «исправить» не являются разрешением самостоятельно выбирать архитектуру миграции.
UI / дизайн:
- Никакой отсебятины в UI: не добавлять экраны, блоки, карточки, иконки, тексты, анимации, цвета, layout-решения и состояния, которых нет в утвержденной документации или design-system.
- Никакой отсебятины в UI: не добавлять экраны, блоки, карточки, иконки, тексты, анимации, цвета, layout-решения и состояния, которых нет в утверждённом дизайн-макете (`design/baseline/<фича>/*.dc.html`). Макет — источник истины, README рядом с ним лишь пересказ.
- Если UI-этап еще не наступил, UI не считается реализованным и не должен маскироваться под готовый продуктовый интерфейс.
- Для построения UI использовать существующие компоненты и их стили, если они уже реализованы; если подходящего компонента нет, создавать переиспользуемый компонент в рамках существующей системы.
- Не упрощать UI, анимации, иконки, состояния или поведение по своему усмотрению. Любое отклонение от baseline требует явного согласования до правок.
@@ -340,30 +340,34 @@ class ReplyTemplateTests(ChatExtrasTestCase):
self.assertFalse(ReplyTemplate.objects.exists())
class LaunchChecklistTests(TestCase):
class OnboardingTests(TestCase):
def setUp(self) -> None:
self.organization = Organization.objects.create(name="New", slug="launch-org")
owner = HumanUser.objects.create_user(
self.owner = HumanUser.objects.create_user(
email="owner@launch.test", password="Password-123"
)
OrganizationMembership.objects.create(
user=owner,
self.membership = OrganizationMembership.objects.create(
user=self.owner,
organization=self.organization,
role=EmployeeRole.OWNER,
position_title="Owner",
)
self.client = APIClient()
self.client.force_authenticate(owner)
self.client.force_authenticate(self.owner)
def test_checklist_marks_steps_by_fact(self) -> None:
initial = self.client.get("/api/v1/company/launch-checklist/").json()
def test_steps_are_marked_by_fact(self) -> None:
initial = self.client.get("/api/v1/company/onboarding/").json()
self.assertEqual(
initial,
initial["steps"],
{
"agentCreated": False,
"providerConnected": False,
"agentActive": False,
"knowledgeFilled": False,
"connectionBound": False,
"widgetPublished": False,
"employeeInvited": False,
"done": False,
"platformConfigured": False,
"firstConversation": False,
},
)
@@ -393,5 +397,56 @@ class LaunchChecklistTests(TestCase):
position_title="Operator",
)
final = self.client.get("/api/v1/company/launch-checklist/").json()
self.assertTrue(final["done"])
final = self.client.get("/api/v1/company/onboarding/").json()
self.assertTrue(final["steps"]["connectionBound"])
self.assertTrue(final["steps"]["employeeInvited"])
def test_existing_member_has_not_dismissed_onboarding(self) -> None:
"""Признак пустой у всех, кто заведён до появления онбординга."""
payload = self.client.get("/api/v1/company/onboarding/").json()
self.assertIsNone(payload["dismissedAt"])
self.assertIsNone(payload["completedAt"])
def test_dismiss_and_complete_and_restart(self) -> None:
dismissed = self.client.post(
"/api/v1/company/onboarding/", {"action": "dismiss"}, format="json"
).json()
self.assertIsNotNone(dismissed["dismissedAt"])
self.assertIsNone(dismissed["completedAt"])
completed = self.client.post(
"/api/v1/company/onboarding/", {"action": "complete"}, format="json"
).json()
self.assertIsNotNone(completed["completedAt"])
restarted = self.client.post(
"/api/v1/company/onboarding/", {"action": "restart"}, format="json"
).json()
self.assertIsNone(restarted["dismissedAt"])
self.assertIsNone(restarted["completedAt"])
def test_dismissal_is_personal(self) -> None:
"""Закрытие одним администратором не прячет визард у второго."""
self.client.post(
"/api/v1/company/onboarding/", {"action": "dismiss"}, format="json"
)
colleague = HumanUser.objects.create_user(
email="admin@launch.test", password="Password-123"
)
OrganizationMembership.objects.create(
user=colleague,
organization=self.organization,
role=EmployeeRole.ADMIN,
position_title="Admin",
)
other = APIClient()
other.force_authenticate(colleague)
self.assertIsNone(other.get("/api/v1/company/onboarding/").json()["dismissedAt"])
def test_unknown_action_is_rejected(self) -> None:
response = self.client.post(
"/api/v1/company/onboarding/", {"action": "nope"}, format="json"
)
self.assertEqual(response.status_code, 400)
@@ -98,6 +98,8 @@ MESSAGES: dict[str, object] = {
"notifications.new_message": "New message · {contact}",
"notifications.operator_needed": "An operator is needed · {contact}",
"notifications.voice_without_transcript": "A voice message without a transcript",
"onboarding.membership_required": "Onboarding is available to organization members",
"onboarding.unknown_action": "Unknown onboarding action",
"portals.article_not_found": "Article not found",
"portals.file_field_required": "A file is required in the file field (multipart/form-data)",
"portals.file_not_found": "File not found",
@@ -102,6 +102,8 @@ MESSAGES: dict[str, object] = {
"notifications.new_message": "Новое сообщение · {contact}",
"notifications.operator_needed": "Нужен оператор · {contact}",
"notifications.voice_without_transcript": "Голосовое без расшифровки",
"onboarding.membership_required": "Онбординг доступен участнику организации",
"onboarding.unknown_action": "Неизвестное действие онбординга",
"portals.article_not_found": "Статья не найдена",
"portals.file_field_required": "Нужен файл в поле file (multipart/form-data)",
"portals.file_not_found": "Файл не найден",
@@ -293,37 +293,100 @@ def _audit_actors(base) -> list[dict[str, object]]:
return actors
class LaunchChecklistView(APIView):
"""Чек-лист «Запуск» (SPEC-CHATBALLS-0031 §5, дизайн-базлайн v2): три шага с
автоотметкой по факту. Скрытие блока — предпочтение клиента (localStorage)."""
class OnboardingView(APIView):
"""Состояние онбординга «Начало работы» для текущего человека.
Шаги отмечаются по факту, а не по нажатию «Далее»: визард — проводник, а
не чек-лист с галочками вручную. Признаки «закрыл» и «прошёл» — на членстве
в организации: у каждого человека свои, и один администратор не прячет
визард команде.
"""
permission_classes = [HasCapability]
required_capability = "settings.view"
def get(self, request: Request) -> Response:
from chatballs.channels.models import Channel
from chatballs.identity.models import OrganizationMembership
from chatballs.integrations.models import Integration, IntegrationKind
membership = request.tenant_context.membership
return Response(
{
"steps": onboarding_steps(request.tenant_context.organization_id),
"dismissedAt": _isoformat(membership.onboarding_dismissed_at if membership else None),
"completedAt": _isoformat(membership.onboarding_completed_at if membership else None),
}
)
organization_id = request.tenant_context.organization_id
agent_created = Channel.objects.filter(organization_id=organization_id).exists()
connection_bound = Integration.objects.filter(
def post(self, request: Request) -> Response:
"""Закрыть визард или отметить его пройденным.
Тело ``{"action": "dismiss" | "complete" | "restart"}``. «Заново»
снимает оба признака: визард снова открывается по ссылке и пилюле.
"""
membership = request.tenant_context.membership
if membership is None:
return Response({"detail": t("onboarding.membership_required")}, status=403)
action = str(request.data.get("action") or "dismiss")
now = django_timezone.now()
if action == "dismiss":
membership.onboarding_dismissed_at = now
elif action == "complete":
membership.onboarding_dismissed_at = now
membership.onboarding_completed_at = now
elif action == "restart":
membership.onboarding_dismissed_at = None
membership.onboarding_completed_at = None
else:
return Response({"detail": t("onboarding.unknown_action")}, status=400)
membership.save(update_fields=["onboarding_dismissed_at", "onboarding_completed_at"])
return Response(
{
"steps": onboarding_steps(request.tenant_context.organization_id),
"dismissedAt": _isoformat(membership.onboarding_dismissed_at),
"completedAt": _isoformat(membership.onboarding_completed_at),
}
)
def _isoformat(value: datetime | None) -> str | None:
return value.isoformat() if value is not None else None
def onboarding_steps(organization_id: int) -> dict[str, bool]:
"""Восемь фактов настройки установки, каждый — один запрос на существование."""
from chatballs.ai.models import AIAgent, AIAgentStatus, Knowledge
from chatballs.conversations.models import Conversation
from chatballs.identity import instance_settings
from chatballs.identity.models import Organization, OrganizationMembership
from chatballs.integrations.models import Integration, IntegrationKind
from chatballs.webchat.models import WebChatWidget, WebChatWidgetStatus
organization = Organization.objects.filter(pk=organization_id).first()
return {
"providerConnected": Integration.objects.filter(
organization_id=organization_id,
kind=IntegrationKind.LLM_PROVIDER,
).exists(),
"agentActive": AIAgent.objects.filter(
organization_id=organization_id,
status=AIAgentStatus.ACTIVE,
).exists(),
"knowledgeFilled": Knowledge.objects.filter(organization_id=organization_id).exists(),
"connectionBound": Integration.objects.filter(
organization_id=organization_id,
kind=IntegrationKind.MESSENGER,
channel__isnull=False,
).exists()
employee_invited = (
OrganizationMembership.objects.filter(
organization_id=organization_id
).count()
> 1
or request.tenant_context.organization.invitations.exists()
)
return Response(
{
"agentCreated": agent_created,
"connectionBound": connection_bound,
"employeeInvited": employee_invited,
"done": agent_created and connection_bound and employee_invited,
}
)
).exists(),
"widgetPublished": WebChatWidget.objects.filter(
organization_id=organization_id,
status=WebChatWidgetStatus.PUBLISHED,
).exists(),
"employeeInvited": (
OrganizationMembership.objects.filter(organization_id=organization_id).count() > 1
or (organization is not None and organization.invitations.exists())
),
# Шаг про саму установку, а не про организацию: домен и исходящая почта
# общие для всех организаций на сервере.
"platformConfigured": bool(instance_settings.public_host()) and instance_settings.email_is_configured(),
"firstConversation": Conversation.objects.filter(organization_id=organization_id).exists(),
}
@@ -23,9 +23,9 @@ urlpatterns = [
name="organization-logo",
),
path(
"launch-checklist/",
administration_views.LaunchChecklistView.as_view(),
name="launch-checklist",
"onboarding/",
administration_views.OnboardingView.as_view(),
name="onboarding",
),
path("demo/", demo_views.DemoDataView.as_view(), name="organization-demo-data"),
path("administration/communication/", feature_views.CommunicationSettingsView.as_view(), name="communication-settings"),
@@ -0,0 +1,25 @@
# Онбординг «Начало работы» показывается всем, кто его ещё не закрыл, включая
# тех, кто работает в установке давно. Признак закрытия — на членстве человека
# в организации: у каждого он свой, и закрытие одним администратором не прячет
# визард у остальных. NULL по умолчанию, поэтому существующие записи считаются
# «не закрывал» и увидят визард при следующем входе.
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("identity", "0037_invitation_membership_fields"),
]
operations = [
migrations.AddField(
model_name="organizationmembership",
name="onboarding_dismissed_at",
field=models.DateTimeField(blank=True, null=True),
),
migrations.AddField(
model_name="organizationmembership",
name="onboarding_completed_at",
field=models.DateTimeField(blank=True, null=True),
),
]
@@ -184,6 +184,14 @@ class OrganizationMembership(models.Model):
phone = models.CharField(max_length=32, blank=True)
totp_required = models.BooleanField(default=False)
blocked_at = models.DateTimeField(null=True, blank=True)
# Онбординг закрыт этим человеком в этой организации. Признак живёт на
# членстве, а не на организации: иначе первый же закрывший спрятал бы
# визард всей команде. NULL — не закрывал, значит увидит при следующем
# входе, включая тех, кто работает в системе давно.
onboarding_dismissed_at = models.DateTimeField(null=True, blank=True)
# Визард пройден до конца. Отдельно от «закрыл»: закрыть можно на первом
# шаге, и тогда возвращаться к настройке ещё есть зачем.
onboarding_completed_at = models.DateTimeField(null=True, blank=True)
created_at = models.DateTimeField(auto_now_add=True)
class Meta:
@@ -40,7 +40,7 @@
overflow: hidden;
color: var(--n-1);
background: var(--n-9);
border: 1px dashed var(--n-6);
border: 1px solid var(--n-7);
border-radius: var(--radius-4xl);
font-size: 22px;
font-weight: 700;
@@ -112,6 +112,12 @@
grid-column: 1 / -1;
}
/* Строка «Язык интерфейса» в карточке организации одна, разделять её сверху
нечем: линия из «Профиля», где такие строки идут подряд, здесь лишняя. */
.administration-language {
border-top: 0;
}
.administration-message {
margin-top: 14px;
padding: 9px 12px;
@@ -7,6 +7,7 @@ import {
type MarkdownTool,
} from "../../../shared/markdown/markdownTools";
import { EmptyState, LoadingState, Segmented } from "../../../shared/ui";
import { SelectField } from "../../../shared/form-controls";
import { Button } from "../../../shared/ui-controls";
import type { RouteKey } from "../../../types";
import type { AgentRef } from "../../agents/model";
@@ -432,19 +433,12 @@ export function KnowledgeEditorPage({
<aside className="knowledge-editor-rail">
<div className="knowledge-rail-fields">
<label>
<span>{t("ai.category")}</span>
<span className="knowledge-select">
<select value={categoryId} onChange={(event) => setCategoryId(event.target.value)}>
{catalog.categories.map((item) => (
<option key={item.id} value={item.id}>
{knowledgeCategoryPath(catalog.categories, item.id) || item.name}
</option>
))}
</select>
<Icon name="chevron" size={13} strokeWidth={2} />
</span>
</label>
<SelectField
label={t("ai.category")}
value={categoryId}
onChange={setCategoryId}
options={catalog.categories.map((item) => [String(item.id), knowledgeCategoryPath(catalog.categories, item.id) || item.name])}
/>
<label>
<span>{t("ai.short_description")}<small>{t("ai.helps_search_agent")}</small></span>
<textarea value={description} onChange={(event) => setDescription(event.target.value)} />
@@ -518,36 +518,6 @@
font-weight: 500;
}
.knowledge-select {
position: relative;
display: flex;
height: 34px;
align-items: center;
justify-content: space-between;
padding: 0 10px;
border: 1px solid var(--n-7);
border-radius: 8px;
color: var(--n-1);
font-size: 13px;
}
.knowledge-select select {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
padding: 0 10px;
border: 0;
background: transparent;
color: var(--n-1);
font-family: inherit;
font-size: 13px;
appearance: none;
cursor: pointer;
}
.knowledge-select svg { position: relative; margin-left: auto; color: var(--n-4); }
.knowledge-rail-fields textarea {
width: 100%;
min-height: 58px;
@@ -53,7 +53,7 @@ export function DialogList({ title = t("common.conversations"), searchPlaceholde
// Ширина списка: тянется за правый край (280–520px), запоминается в браузере.
const listWidth = useResizableWidth("dialogList", { fallback: 323, min: 280, max: 520 });
return (
<section className={`sales-dialog-list ${listWidth.dragging ? "is-resizing" : ""}`} style={{ "--dialog-list-width": `${listWidth.width}px` } as CSSProperties}>
<section className={`sales-dialog-list ${listWidth.dragging ? "is-resizing" : ""}`} data-onboarding-target="chat-list" style={{ "--dialog-list-width": `${listWidth.width}px` } as CSSProperties}>
<div className="pane-resizer" role="separator" aria-orientation="vertical" aria-label={t("conversations.conversation_list_width")} title={t("profile.drag_resize_double_click_reset")} onPointerDown={listWidth.onPointerDown} onDoubleClick={listWidth.reset} />
{mobileHeader?.({ total })}
<div className="sales-dialog-list-head">
@@ -2,6 +2,7 @@ import { useEffect, useState } from "react";
import { api } from "../../api/client";
import { Icon } from "../../shared/icons";
import { SelectField } from "../../shared/form-controls";
import { Avatar } from "../../shared/ui";
import { SearchInput } from "../../shared/ui-controls";
import { useDebounced } from "../../shared/useDebounced";
@@ -100,20 +101,18 @@ export function OwnershipTransferModal({ onClose }: { onClose: () => void }) {
<p className="employee-create-note">{t("admin.there_no_active_administrators_transfer")}</p>
) : (
<>
<label className="employee-field is-single">
<span>{t("admin.new_owner_administrators_only")}</span>
<span className="employee-transfer-select">
<select value={targetId === null ? "" : String(targetId)} onChange={(event) => setTargetId(Number(event.target.value))}>
{candidates.map((employee) => (
<option value={employee.id} key={employee.id}>
{employee.fullName || employee.email} · {roleBadge(employee.role).text} · {employee.positionTitle}
</option>
))}
</select>
<Icon name="chevron" size={14} strokeWidth={1.8} />
</span>
<div className="employee-field is-single">
<SelectField
label={t("admin.new_owner_administrators_only")}
value={targetId === null ? "" : String(targetId)}
onChange={(value) => setTargetId(Number(value))}
options={candidates.map((employee) => [
String(employee.id),
`${employee.fullName || employee.email} · ${roleBadge(employee.role).text} · ${employee.positionTitle}`,
])}
/>
<small className="employee-transfer-note">{t("admin.list_holds_active_administrators_only")}</small>
</label>
</div>
<div className="employee-transfer-pair">
<div>
@@ -440,39 +440,6 @@
margin-bottom: 0;
}
.employee-transfer-select {
position: relative;
display: flex;
align-items: center;
justify-content: space-between;
height: 36px;
padding: 0 12px;
border: 1px solid var(--n-7);
border-radius: 8px;
background: var(--n-10);
color: var(--n-1);
font-size: 13.5px;
}
.employee-transfer-select select {
flex: 1;
height: 34px;
padding: 0;
border: 0;
outline: none;
background: transparent;
color: inherit;
font-family: inherit;
font-size: 13.5px;
appearance: none;
cursor: pointer;
}
.employee-transfer-select > svg {
flex: none;
color: var(--n-4);
}
.employee-transfer-note {
color: var(--n-4);
font-size: 12px;
@@ -0,0 +1,19 @@
import { Icon } from "../../shared/icons";
import { t } from "../../i18n";
import { useOnboarding } from "./useOnboarding";
// Пилюля возврата в правом нижнем углу рабочей области. Появляется, когда
// окно закрыто, тур не идёт, а настроено ещё не всё: закрыть онбординг можно
// одним кликом, но дорогу назад стоит оставить на виду.
export function OnboardingLauncher() {
const onboarding = useOnboarding();
if (!onboarding?.showLauncher) return null;
return (
<button className="ob-launcher" type="button" onClick={onboarding.open}>
<Icon name="sparkles" size={17} strokeWidth={1.9} />
{t("onboarding.title")}
<small>{t("onboarding.progress_short", { done: onboarding.doneCount, total: onboarding.steps.length })}</small>
</button>
);
}
@@ -0,0 +1,256 @@
import { useEffect, useRef } from "react";
import { Icon, LogoIcon } from "../../shared/icons";
import { t } from "../../i18n";
import type { OnboardingStep } from "./steps";
import { useOnboarding, type OnboardingApi } from "./useOnboarding";
// Окно онбординга: приветствие, визард шагов и финальный экран.
//
// Размеры, зазоры и толщины обводок иконок взяты из дизайн-референса
// `design/baseline/Онбординг` — сверяться нужно с ним, а не с соседними
// экранами рабочего места.
//
// Окно живёт внутри `.hub-shell`, а не в портале на body: тур подсвечивает
// элементы приложения теми же координатами от корня, и держать оба слоя в
// одной системе отсчёта проще, чем сводить две.
const FOCUSABLE = 'button:not([disabled]), [href], input, select, textarea, [tabindex]:not([tabindex="-1"])';
/** Ловушка фокуса и клавиши: Tab по кругу, Esc закрывает, ←/→ листают шаги. */
function useModalKeys(active: boolean, onboarding: OnboardingApi | null, cardRef: React.RefObject<HTMLDivElement | null>) {
useEffect(() => {
if (!active || !onboarding) return;
cardRef.current?.querySelector<HTMLElement>(FOCUSABLE)?.focus();
}, [active, onboarding, cardRef]);
useEffect(() => {
if (!active || !onboarding) return;
const onKey = (event: KeyboardEvent) => {
if (event.key === "Escape") {
event.preventDefault();
onboarding.dismiss();
return;
}
if (onboarding.view === "steps" && (event.key === "ArrowRight" || event.key === "ArrowLeft")) {
const target = event.target as HTMLElement | null;
// Стрелки внутри поля ввода принадлежат полю, а не визарду.
if (target && (target.tagName === "INPUT" || target.tagName === "TEXTAREA")) return;
event.preventDefault();
onboarding.goTo(onboarding.index + (event.key === "ArrowRight" ? 1 : -1));
return;
}
if (event.key !== "Tab") return;
const items = [...(cardRef.current?.querySelectorAll<HTMLElement>(FOCUSABLE) ?? [])].filter((item) => item.offsetParent !== null);
if (items.length === 0) return;
const first = items[0];
const last = items[items.length - 1];
if (!event.shiftKey && document.activeElement === last) {
event.preventDefault();
first.focus();
} else if (event.shiftKey && document.activeElement === first) {
event.preventDefault();
last.focus();
}
};
window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey);
}, [active, onboarding, cardRef]);
}
export function OnboardingOverlay() {
const onboarding = useOnboarding();
const cardRef = useRef<HTMLDivElement>(null);
const visible = Boolean(onboarding && onboarding.view !== null && onboarding.tourIndex === null);
useModalKeys(visible, onboarding, cardRef);
if (!onboarding || !visible) return null;
if (onboarding.view === "welcome") return <WelcomeView onboarding={onboarding} cardRef={cardRef} />;
if (onboarding.view === "done") return <DoneView onboarding={onboarding} cardRef={cardRef} />;
return <StepsView onboarding={onboarding} cardRef={cardRef} />;
}
/** В приветствии крестик лежит поверх градиентной шапки, в визарде стоит в
* строке рядом с чипом шага. */
function CloseButton({ floating = false, onClick }: { floating?: boolean; onClick: () => void }) {
return (
<button className={`ob-close ${floating ? "is-floating" : ""}`} type="button" aria-label={t("common.close")} title={t("common.close")} onClick={onClick}>
<Icon name="close" size={17} strokeWidth={1.8} />
</button>
);
}
function WelcomeView({ onboarding, cardRef }: { onboarding: OnboardingApi; cardRef: React.RefObject<HTMLDivElement | null> }) {
return (
<div className="ob-overlay is-welcome">
<div className="ob-card ob-welcome" ref={cardRef} role="dialog" aria-modal="true" aria-labelledby="ob-welcome-title">
<div className="ob-welcome-head">
<CloseButton floating onClick={onboarding.dismiss} />
<span className="ob-logo-tile"><LogoIcon size={28} /></span>
<p className="ob-eyebrow">{t("onboarding.eyebrow")}</p>
<h2 id="ob-welcome-title">{onboarding.welcomeTitle}</h2>
<p>{t("onboarding.welcome_lead")}</p>
</div>
<div className="ob-facts">
<div><strong>{t("onboarding.fact_time")}</strong><small>{t("onboarding.fact_time_note")}</small></div>
<div><strong>{t("onboarding.fact_key")}</strong><small>{t("onboarding.fact_key_note")}</small></div>
<div><strong>{t("onboarding.fact_data")}</strong><small>{t("onboarding.fact_data_note")}</small></div>
</div>
<div className="ob-welcome-foot">
<button className="ob-text-button" type="button" onClick={onboarding.dismiss}>{t("onboarding.later")}</button>
<button className="ob-primary is-welcome" type="button" onClick={onboarding.startSteps}>
{t("onboarding.start")}
<Icon name="arrow" size={17} strokeWidth={1.9} />
</button>
</div>
</div>
</div>
);
}
function StepsView({ onboarding, cardRef }: { onboarding: OnboardingApi; cardRef: React.RefObject<HTMLDivElement | null> }) {
const { steps, index, doneCount } = onboarding;
const step = steps[index];
if (!step) return null;
const total = steps.length;
const percent = Math.round((doneCount / total) * 100);
const last = index === total - 1;
return (
<div className="ob-overlay is-steps">
<div className="ob-card ob-steps" ref={cardRef} role="dialog" aria-modal="true" aria-labelledby="ob-step-title">
<aside className="ob-rail">
<div className="ob-rail-head">
<div className="ob-rail-brand"><span><LogoIcon size={22} /></span><strong>{t("onboarding.title")}</strong></div>
<div className="ob-rail-progress-row">
<span>{t("onboarding.progress", { done: doneCount, total })}</span>
<small>{percent}%</small>
</div>
<div className="ob-rail-bar"><i style={{ width: `${percent}%` }} /></div>
</div>
<ol className="ob-rail-list" aria-label={t("onboarding.steps_list")}>
{steps.map((item, position) => (
<li key={item.key}>
<button
className={`ob-rail-step ${position === index ? "is-current" : ""} ${onboarding.state.steps[item.key] ? "is-done" : ""}`}
type="button"
aria-current={position === index ? "step" : undefined}
onClick={() => onboarding.goTo(position)}
>
<span className="ob-rail-mark">
{onboarding.state.steps[item.key] ? <Icon name="check" size={11} strokeWidth={2.6} /> : position + 1}
</span>
<span className="ob-rail-name">{item.name}</span>
<small>{item.time}</small>
</button>
</li>
))}
</ol>
<div className="ob-rail-foot">
<button className="ob-text-button" type="button" onClick={onboarding.dismiss}>{t("onboarding.close_and_self")}</button>
</div>
</aside>
<div className="ob-body">
<header className="ob-body-head">
<span className="ob-chip is-accent">{t("onboarding.step_of", { index: index + 1, total, time: step.time })}</span>
<CloseButton onClick={onboarding.dismiss} />
</header>
{/* На узком экране рейка скрыта — полоса прогресса остаётся здесь. */}
<div className="ob-rail-bar ob-narrow-bar"><i style={{ width: `${percent}%` }} /></div>
<div className="ob-body-scroll">
<h2 id="ob-step-title">{step.title}</h2>
<p className="ob-lead">{step.lead}</p>
<div className="ob-path">
{step.path.map((crumb, position) => (
<span className="ob-crumb" key={crumb}>
{crumb}
{position < step.path.length - 1 && <Icon name="chevronRight" size={12} strokeWidth={2} />}
</span>
))}
</div>
<ol className="ob-instructions">
{step.instructions.map((line, position) => (
<li key={line}><span>{position + 1}</span><span>{line}</span></li>
))}
</ol>
<div className="ob-warning">
<Icon name="alert" size={16} strokeWidth={1.9} />
<p>{step.warning}</p>
</div>
<StepPreview step={step} />
</div>
<footer className="ob-body-foot">
<button className="ob-ghost" type="button" onClick={onboarding.back}>
<Icon name="chevronLeft" size={16} strokeWidth={1.9} />
{t("onboarding.back")}
</button>
<div className="ob-body-actions">
<button className="ob-secondary" type="button" onClick={onboarding.showTour}>
<Icon name="eye" size={16} strokeWidth={1.9} />
{t("onboarding.show_where")}
</button>
<button className="ob-secondary" type="button" onClick={onboarding.openSection}>{t("onboarding.open_section")}</button>
<button className="ob-primary" type="button" onClick={onboarding.next}>
{last ? t("onboarding.finish") : t("onboarding.next")}
<Icon name="arrow" size={16} strokeWidth={1.9} />
</button>
</div>
</footer>
</div>
</div>
</div>
);
}
function StepPreview({ step }: { step: OnboardingStep }) {
const { preview } = step;
return (
<div className="ob-preview">
<div className="ob-preview-head">
<span className="ob-preview-label"><i />{preview.label}</span>
<span className={`ob-chip ${preview.tone === "success" ? "is-success" : "is-waiting"}`}>
{preview.tone === "success" && <Icon name="check" size={11} strokeWidth={2.6} />}
{preview.chip}
</span>
</div>
<div className="ob-preview-rows">
{preview.rows.map((row) => (
<div className="ob-preview-row" key={row.label}>
<span>{row.label}</span>
<b>{row.value}</b>
</div>
))}
</div>
{preview.code && <div className="ob-preview-code"><code>{preview.code}</code></div>}
</div>
);
}
function DoneView({ onboarding, cardRef }: { onboarding: OnboardingApi; cardRef: React.RefObject<HTMLDivElement | null> }) {
return (
<div className="ob-overlay is-done">
<div className="ob-card ob-done" ref={cardRef} role="dialog" aria-modal="true" aria-labelledby="ob-done-title">
<div className="ob-done-head">
<span className="ob-logo-tile is-success"><Icon name="check" size={24} strokeWidth={2.4} /></span>
<h2 id="ob-done-title">{t("onboarding.done_title")}</h2>
<p>{t("onboarding.done_lead")}</p>
</div>
<div className="ob-summary">
{onboarding.steps.map((step) => (
<div className={`ob-summary-item ${onboarding.state.steps[step.key] ? "is-done" : ""}`} key={step.key}>
<span>{onboarding.state.steps[step.key] && <Icon name="check" size={10} strokeWidth={2.8} />}</span>
<span>{step.name}</span>
</div>
))}
</div>
<div className="ob-done-foot">
<button className="ob-text-button" type="button" onClick={onboarding.restart}>{t("onboarding.restart")}</button>
<button className="ob-primary is-done" type="button" onClick={onboarding.completeToChat}>
{t("onboarding.go_chat")}
<Icon name="arrow" size={17} strokeWidth={1.9} />
</button>
</div>
</div>
</div>
);
}
@@ -0,0 +1,164 @@
import { useEffect, useLayoutEffect, useState } from "react";
import { Icon } from "../../shared/icons";
import { t } from "../../i18n";
import { useOnboarding } from "./useOnboarding";
// Тур «Показать где»: приложение остаётся видимым, а нужный элемент —
// единственное светлое место на затемнённом экране.
//
// Геометрия слоёв повторяет дизайн-референс `design/baseline/Онбординг`:
// рамка цели с отступом 6px, обводка `0 0 0 2px` и поверх неё пульсирующее
// кольцо, разлетающееся до scale(1.45).
//
// Все слои абсолютные внутри `.hub-shell` и считают координаты от него: цель
// живёт в прокручиваемой колонке, и фиксированный слой уехал бы от неё при
// первой же прокрутке. Затемнение — четыре прямоугольника вокруг цели, а не
// один `box-shadow: 0 0 0 9999px`: так корректно выходит на скриншотах и при
// экспорте страницы.
const CALLOUT_WIDTH = 344;
const PAD = 6;
type Rect = { x: number; y: number; w: number; h: number };
function measure(element: string): { target: Rect; rootW: number; rootH: number } | null {
const root = document.querySelector(".hub-shell");
const target = document.querySelector(`[data-onboarding-target="${element}"]`);
if (!(root instanceof HTMLElement) || !(target instanceof HTMLElement)) return null;
const rootBox = root.getBoundingClientRect();
const targetBox = target.getBoundingClientRect();
if (targetBox.width === 0 && targetBox.height === 0) return null;
return {
target: { x: targetBox.left - rootBox.left, y: targetBox.top - rootBox.top, w: targetBox.width, h: targetBox.height },
rootW: rootBox.width,
rootH: rootBox.height,
};
}
/** Конечные анимации, внутри которых лежит цель, — прежде всего въезд нового
* экрана (`surface-enter`, translateY 6px). Их надо дождаться: пока они идут,
* `getBoundingClientRect()` отдаёт позицию в полёте, причём композитная
* анимация повторяет одно и то же значение несколько кадров подряд, так что
* «подождать, пока перестанет меняться» здесь не работает. Бесконечные
* анимации (свой же пульс рамки, прелоадеры) пропускаем — их не дождаться. */
function settlingAnimations(target: Element): Promise<unknown>[] {
return document.getAnimations().flatMap((animation) => {
const effect = animation.effect;
const owner = effect instanceof KeyframeEffect ? effect.target : null;
if (!owner || !owner.contains(target)) return [];
if (effect!.getComputedTiming().iterations === Infinity) return [];
return [animation.finished.catch(() => undefined)];
});
}
export function OnboardingTour() {
const onboarding = useOnboarding();
const tourIndex = onboarding?.tourIndex ?? null;
const step = tourIndex === null ? null : onboarding?.steps[tourIndex] ?? null;
const element = step?.target.element ?? null;
const hideTour = onboarding?.hideTour;
const [box, setBox] = useState<{ target: Rect; rootW: number; rootH: number } | null>(null);
useLayoutEffect(() => {
if (!element) {
setBox(null);
return;
}
// Экран за окном только что переключился: цель может ещё не смонтироваться,
// а смонтировавшись — ехать вместе с новым маршрутом. Сначала ищем её по
// кадрам, показываем подсветку сразу, а как экран встанет — перемеряем.
// Если цель так и не нашлась (раздел пуст, элемент скрыт узким экраном),
// возвращаем окно: иначе человек остался бы перед приложением без
// единственного пути назад.
let cancelled = false;
let frame = 0;
const apply = () => {
const measured = measure(element);
if (measured) setBox(measured);
};
const locate = (attempts: number) => {
if (cancelled) return;
const target = document.querySelector(`[data-onboarding-target="${element}"]`);
const found = target instanceof HTMLElement && (target.offsetWidth > 0 || target.offsetHeight > 0);
if (!found) {
if (attempts >= 40) {
hideTour?.();
return;
}
frame = requestAnimationFrame(() => locate(attempts + 1));
return;
}
apply();
void Promise.all(settlingAnimations(target)).then(() => {
if (cancelled) return;
frame = requestAnimationFrame(() => {
if (!cancelled) apply();
});
});
};
locate(0);
return () => {
cancelled = true;
cancelAnimationFrame(frame);
};
}, [element, hideTour]);
useEffect(() => {
if (!element) return;
const remeasure = () => setBox(measure(element));
window.addEventListener("resize", remeasure);
return () => window.removeEventListener("resize", remeasure);
}, [element]);
useEffect(() => {
if (!element || !hideTour) return;
const onKey = (event: KeyboardEvent) => {
if (event.key === "Escape") {
event.stopPropagation();
hideTour();
}
};
window.addEventListener("keydown", onKey, true);
return () => window.removeEventListener("keydown", onKey, true);
}, [element, hideTour]);
if (!onboarding || tourIndex === null || !step || !box) return null;
const { target, rootW, rootH } = box;
const px = (value: number) => `${Math.round(value)}px`;
// Границы рамки цели: отступ 6px со всех сторон.
const top = Math.max(0, target.y - PAD);
const bottom = target.y + target.h + PAD;
const left = Math.max(0, target.x - PAD);
const right = target.x + target.w + PAD;
const frame = { top: px(target.y - PAD), left: px(target.x - PAD), width: px(target.w + PAD * 2), height: px(target.h + PAD * 2) };
// Колаут справа от цели, а если не помещается — слева.
const preferred = target.x + target.w + 20;
const calloutX = preferred + CALLOUT_WIDTH > rootW - 16 ? Math.max(16, target.x - CALLOUT_WIDTH - 20) : preferred;
const calloutY = Math.min(Math.max(16, target.y - 14), Math.max(16, rootH - 250));
return (
<>
<button className="ob-tour-backdrop" type="button" aria-label={t("onboarding.tour_ok")} onClick={onboarding.hideTour} />
<div className="ob-tour-dim" style={{ left: 0, top: 0, right: 0, height: px(top) }} />
<div className="ob-tour-dim" style={{ left: 0, top: px(bottom), right: 0, bottom: 0 }} />
<div className="ob-tour-dim" style={{ left: 0, top: px(top), width: px(left), height: px(bottom - top) }} />
<div className="ob-tour-dim" style={{ left: px(right), top: px(top), right: 0, height: px(bottom - top) }} />
<div className="ob-tour-spot" style={frame} />
<div className="ob-tour-ring" style={frame} />
<div className="ob-tour-callout" style={{ top: px(calloutY), left: px(calloutX) }} role="dialog" aria-label={step.target.title}>
<p className="ob-eyebrow">{t("onboarding.tour_eyebrow", { index: tourIndex + 1 })}</p>
<strong>{step.target.title}</strong>
<p>{step.target.text}</p>
<button className="ob-tour-back" type="button" onClick={onboarding.hideTour} autoFocus>
{t("onboarding.tour_ok")}
<Icon name="chevronLeft" size={15} strokeWidth={1.9} />
</button>
</div>
</>
);
}
@@ -0,0 +1,32 @@
import { api } from "../../api/client";
import { EMPTY_PROGRESS, type OnboardingProgress } from "./steps";
// Состояние онбординга живёт на сервере, а не в localStorage: требование —
// показать визард всем, кто его ещё не закрыл, включая тех, кто работает в
// установке давно. Признак хранится на членстве человека в организации, так
// что у каждого он свой.
export type OnboardingState = {
steps: OnboardingProgress;
dismissedAt: string | null;
completedAt: string | null;
};
export type OnboardingAction = "dismiss" | "complete" | "restart";
const ENDPOINT = "/api/v1/company/onboarding/";
export function fetchOnboarding(): Promise<OnboardingState> {
return api<OnboardingState>(ENDPOINT);
}
export function postOnboardingAction(action: OnboardingAction): Promise<OnboardingState> {
return api<OnboardingState>(ENDPOINT, { method: "POST", body: JSON.stringify({ action }) });
}
/** Состояние до первого ответа сервера: визард ещё не показываем. */
export const PENDING_STATE: OnboardingState = {
steps: EMPTY_PROGRESS,
dismissedAt: null,
completedAt: null,
};
@@ -0,0 +1,255 @@
// Восемь шагов «Начала работы»: от ключа модели до первого диалога с клиентом.
//
// Таблица описательная — она не знает ни про состояние визарда, ни про сеть.
// Порядок шагов равен порядку, в котором установку разумно настраивать: без
// провайдера молчит агент, без агента некому отвечать, без точки входа некуда
// писать.
//
// Ключ шага совпадает с именем факта в ответе `/company/onboarding/`: галочку
// ставит сервер по факту настройки, а не нажатие «Далее».
import type { SettingsSectionKey } from "../settings/sections";
import type { RouteKey } from "../../types";
import { webWidgetSnippet } from "../integrations/model";
import { t } from "../../i18n";
export type OnboardingStepKey =
| "providerConnected"
| "agentActive"
| "knowledgeFilled"
| "connectionBound"
| "widgetPublished"
| "employeeInvited"
| "platformConfigured"
| "firstConversation";
export type OnboardingProgress = Record<OnboardingStepKey, boolean>;
/** Элемент интерфейса, который подсвечивает тур: `data-onboarding-target`. */
export type TourTarget =
| "nav-agents"
| "nav-knowledge"
| "nav-employees"
| "settings-ai"
| "settings-integrations"
| "settings-platform"
| "chat-list";
export type PreviewRow = { label: string; value: string };
export type OnboardingStep = {
key: OnboardingStepKey;
name: string;
time: string;
title: string;
lead: string;
/** Хлебные крошки пути до раздела. */
path: string[];
instructions: string[];
warning: string;
preview: {
label: string;
rows: PreviewRow[];
chip: string;
/** «Ожидание» — жёлтый чип: результат шага виден не сразу. */
tone: "success" | "waiting";
code?: string;
};
/** Куда ведёт «Открыть раздел» и что подсвечивает «Показать где». */
target: { route: RouteKey; section?: SettingsSectionKey; element: TourTarget; title: string; text: string };
/** Раздел «Платформа» есть только у администратора установки. */
instanceOnly?: boolean;
};
// Путь в словаре хранится одной строкой со стрелками: переводчику так понятнее,
// чем три отдельных ключа, а крошки получаются разбором.
function crumbs(path: string): string[] {
return path.split("→").map((crumb) => crumb.trim()).filter(Boolean);
}
const STEPS: OnboardingStep[] = [
{
key: "providerConnected",
name: t("onboarding.s1.name"),
time: t("onboarding.s1.time"),
title: t("onboarding.s1.title"),
lead: t("onboarding.s1.lead"),
path: crumbs(t("onboarding.s1.path")),
instructions: [t("onboarding.s1.do_1"), t("onboarding.s1.do_2"), t("onboarding.s1.do_3"), t("onboarding.s1.do_4")],
warning: t("onboarding.s1.warning"),
preview: {
label: t("onboarding.s1.preview"),
rows: [
{ label: t("onboarding.s1.row1_label"), value: t("onboarding.s1.row1_value") },
{ label: t("onboarding.s1.row2_label"), value: t("onboarding.s1.row2_value") },
{ label: t("onboarding.s1.row3_label"), value: t("onboarding.s1.row3_value") },
],
chip: t("onboarding.s1.chip"),
tone: "success",
},
target: { route: "settings", section: "ai", element: "settings-ai", title: t("onboarding.s1.tour_title"), text: t("onboarding.s1.tour_text") },
},
{
key: "agentActive",
name: t("onboarding.s2.name"),
time: t("onboarding.s2.time"),
title: t("onboarding.s2.title"),
lead: t("onboarding.s2.lead"),
path: crumbs(t("onboarding.s2.path")),
instructions: [t("onboarding.s2.do_1"), t("onboarding.s2.do_2"), t("onboarding.s2.do_3"), t("onboarding.s2.do_4"), t("onboarding.s2.do_5")],
warning: t("onboarding.s2.warning"),
preview: {
label: t("onboarding.s2.preview"),
rows: [
{ label: t("onboarding.s2.row1_label"), value: t("onboarding.s2.row1_value") },
{ label: t("onboarding.s2.row2_label"), value: t("onboarding.s2.row2_value") },
{ label: t("onboarding.s2.row3_label"), value: t("onboarding.s2.row3_value") },
],
chip: t("onboarding.s2.chip"),
tone: "success",
},
target: { route: "agents", element: "nav-agents", title: t("onboarding.s2.tour_title"), text: t("onboarding.s2.tour_text") },
},
{
key: "knowledgeFilled",
name: t("onboarding.s3.name"),
time: t("onboarding.s3.time"),
title: t("onboarding.s3.title"),
lead: t("onboarding.s3.lead"),
path: crumbs(t("onboarding.s3.path")),
instructions: [t("onboarding.s3.do_1"), t("onboarding.s3.do_2"), t("onboarding.s3.do_3"), t("onboarding.s3.do_4")],
warning: t("onboarding.s3.warning"),
preview: {
label: t("onboarding.s3.preview"),
rows: [
{ label: t("onboarding.s3.row1_label"), value: t("onboarding.s3.row1_value") },
{ label: t("onboarding.s3.row2_label"), value: t("onboarding.s3.row2_value") },
{ label: t("onboarding.s3.row3_label"), value: t("onboarding.s3.row3_value") },
],
chip: t("onboarding.s3.chip"),
tone: "success",
},
target: { route: "knowledge", element: "nav-knowledge", title: t("onboarding.s3.tour_title"), text: t("onboarding.s3.tour_text") },
},
{
key: "connectionBound",
name: t("onboarding.s4.name"),
time: t("onboarding.s4.time"),
title: t("onboarding.s4.title"),
lead: t("onboarding.s4.lead"),
path: crumbs(t("onboarding.s4.path")),
instructions: [t("onboarding.s4.do_1"), t("onboarding.s4.do_2"), t("onboarding.s4.do_3"), t("onboarding.s4.do_4"), t("onboarding.s4.do_5")],
warning: t("onboarding.s4.warning"),
preview: {
label: t("onboarding.s4.preview"),
rows: [
{ label: t("onboarding.s4.row1_label"), value: t("onboarding.s4.row1_value") },
{ label: t("onboarding.s4.row2_label"), value: t("onboarding.s4.row2_value") },
{ label: t("onboarding.s4.row3_label"), value: t("onboarding.s4.row3_value") },
],
chip: t("onboarding.s4.chip"),
tone: "success",
},
target: { route: "settings", section: "integrations", element: "settings-integrations", title: t("onboarding.s4.tour_title"), text: t("onboarding.s4.tour_text") },
},
{
key: "widgetPublished",
name: t("onboarding.s5.name"),
time: t("onboarding.s5.time"),
title: t("onboarding.s5.title"),
lead: t("onboarding.s5.lead"),
path: crumbs(t("onboarding.s5.path")),
instructions: [t("onboarding.s5.do_1"), t("onboarding.s5.do_2"), t("onboarding.s5.do_3"), t("onboarding.s5.do_4")],
warning: t("onboarding.s5.warning"),
preview: {
label: t("onboarding.s5.preview"),
rows: [{ label: t("onboarding.s5.row1_label"), value: t("onboarding.s5.row1_value") }],
chip: t("onboarding.s5.chip"),
tone: "success",
// Тег ровно тот, что выдаёт раздел виджета, — с образцовым ключом.
code: webWidgetSnippet("wgt_9f2c41d8"),
},
target: { route: "settings", section: "integrations", element: "settings-integrations", title: t("onboarding.s5.tour_title"), text: t("onboarding.s5.tour_text") },
},
{
key: "employeeInvited",
name: t("onboarding.s6.name"),
time: t("onboarding.s6.time"),
title: t("onboarding.s6.title"),
lead: t("onboarding.s6.lead"),
path: crumbs(t("onboarding.s6.path")),
instructions: [t("onboarding.s6.do_1"), t("onboarding.s6.do_2"), t("onboarding.s6.do_3"), t("onboarding.s6.do_4")],
warning: t("onboarding.s6.warning"),
preview: {
label: t("onboarding.s6.preview"),
rows: [
{ label: t("onboarding.s6.row1_label"), value: t("onboarding.s6.row1_value") },
{ label: t("onboarding.s6.row2_label"), value: t("onboarding.s6.row2_value") },
{ label: t("onboarding.s6.row3_label"), value: t("onboarding.s6.row3_value") },
],
chip: t("onboarding.s6.chip"),
tone: "waiting",
},
target: { route: "employees", element: "nav-employees", title: t("onboarding.s6.tour_title"), text: t("onboarding.s6.tour_text") },
},
{
key: "platformConfigured",
name: t("onboarding.s7.name"),
time: t("onboarding.s7.time"),
title: t("onboarding.s7.title"),
lead: t("onboarding.s7.lead"),
path: crumbs(t("onboarding.s7.path")),
instructions: [t("onboarding.s7.do_1"), t("onboarding.s7.do_2"), t("onboarding.s7.do_3"), t("onboarding.s7.do_4")],
warning: t("onboarding.s7.warning"),
preview: {
label: t("onboarding.s7.preview"),
rows: [
{ label: t("onboarding.s7.row1_label"), value: t("onboarding.s7.row1_value") },
{ label: t("onboarding.s7.row2_label"), value: t("onboarding.s7.row2_value") },
{ label: t("onboarding.s7.row3_label"), value: t("onboarding.s7.row3_value") },
],
chip: t("onboarding.s7.chip"),
tone: "success",
},
target: { route: "settings", section: "platform", element: "settings-platform", title: t("onboarding.s7.tour_title"), text: t("onboarding.s7.tour_text") },
instanceOnly: true,
},
{
key: "firstConversation",
name: t("onboarding.s8.name"),
time: t("onboarding.s8.time"),
title: t("onboarding.s8.title"),
lead: t("onboarding.s8.lead"),
path: crumbs(t("onboarding.s8.path")),
instructions: [t("onboarding.s8.do_1"), t("onboarding.s8.do_2"), t("onboarding.s8.do_3"), t("onboarding.s8.do_4")],
warning: t("onboarding.s8.warning"),
preview: {
label: t("onboarding.s8.preview"),
rows: [
{ label: t("onboarding.s8.row1_label"), value: t("onboarding.s8.row1_value") },
{ label: t("onboarding.s8.row2_label"), value: t("onboarding.s8.row2_value") },
{ label: t("onboarding.s8.row3_label"), value: t("onboarding.s8.row3_value") },
],
chip: t("onboarding.s8.chip"),
tone: "success",
},
target: { route: "chat", element: "chat-list", title: t("onboarding.s8.tour_title"), text: t("onboarding.s8.tour_text") },
},
];
/** Шаги, доступные этому человеку: «Домен и почта» — только администратору
* установки, у остальных шагов семь, и счётчик считает от семи. */
export function visibleOnboardingSteps(isInstanceAdmin: boolean): OnboardingStep[] {
return STEPS.filter((step) => !step.instanceOnly || isInstanceAdmin);
}
export const EMPTY_PROGRESS: OnboardingProgress = {
providerConnected: false,
agentActive: false,
knowledgeFilled: false,
connectionBound: false,
widgetPublished: false,
employeeInvited: false,
platformConfigured: false,
firstConversation: false,
};
@@ -0,0 +1,999 @@
/* Онбординг «Начало работы»: приветствие, визард шагов, тур и точки возврата.
Верстка снята с дизайн-референса `design/baseline/Онбординг`: размеры, зазоры,
толщины обводок иконок и ховеры — оттуда, отсебятины здесь быть не должно.
Своих цветов нет — всё через токены `styles/base.css`, поэтому тёмная тема
получается сама. */
/* ── Оверлей и карточка ─────────────────────────────────────────────────── */
.ob-overlay {
position: absolute;
inset: 0;
z-index: 30;
display: flex;
align-items: center;
justify-content: center;
padding: 40px;
background: var(--scrim);
backdrop-filter: blur(3px);
animation: ob-fade 200ms ease-out;
}
.ob-overlay.is-welcome { animation-duration: 220ms; }
.ob-overlay.is-steps { padding: 34px; }
.ob-card {
border: 1px solid var(--border-default);
border-radius: 18px;
background: var(--surface-card);
box-shadow: var(--modal-shadow);
overflow: hidden;
animation: ob-rise 280ms cubic-bezier(0.2, 0.8, 0.2, 1);
}
.ob-welcome {
width: min(700px, 100%);
animation-duration: 320ms;
}
.ob-done { width: min(620px, 100%); }
.ob-steps {
width: min(1020px, 100%);
height: min(700px, 100%);
display: flex;
}
@keyframes ob-fade {
from { opacity: 0; }
to { opacity: 1; }
}
@keyframes ob-rise {
from { opacity: 0; transform: translateY(14px) scale(0.985); }
to { opacity: 1; transform: none; }
}
@keyframes ob-pulse {
0%, 100% { opacity: 0.5; transform: scale(1); }
50% { opacity: 0; transform: scale(1.45); }
}
/* ── Общие элементы ─────────────────────────────────────────────────────── */
.ob-close {
width: 32px;
height: 32px;
flex: none;
display: inline-flex;
align-items: center;
justify-content: center;
border: 0;
border-radius: 8px;
background: transparent;
color: var(--n-4);
cursor: pointer;
transition: background 140ms ease, color 140ms ease;
}
/* В приветствии кнопка лежит поверх градиентной шапки, в визарде — обычный
элемент строки с чипом шага. */
.ob-close.is-floating {
position: absolute;
top: 18px;
right: 18px;
}
.ob-close:hover {
background: var(--n-9);
color: var(--n-2);
}
.ob-eyebrow {
display: block;
font-size: 11.5px;
font-weight: 700;
letter-spacing: 0.1em;
text-transform: uppercase;
color: var(--primary-text);
}
.ob-logo-tile {
width: 46px;
height: 46px;
display: flex;
align-items: center;
justify-content: center;
border-radius: 14px;
background: var(--primary);
color: #fff;
box-shadow: 0 8px 20px color-mix(in srgb, var(--accent) 34%, transparent);
}
.ob-logo-tile.is-success {
background: var(--success);
box-shadow: none;
}
.ob-text-button {
border: 0;
padding: 0;
background: none;
color: var(--n-4);
font-size: 13px;
font-weight: 500;
cursor: pointer;
transition: color 140ms ease;
}
.ob-text-button:hover { color: var(--n-2); }
.ob-primary {
height: 36px;
display: inline-flex;
align-items: center;
gap: 8px;
padding: 0 18px;
border: 0;
border-radius: 8px;
background: var(--primary);
color: #fff;
font-size: 13px;
font-weight: 600;
cursor: pointer;
box-shadow: 0 1px 2px color-mix(in srgb, var(--accent) 30%, transparent);
transition: background 140ms ease;
}
.ob-primary:hover { background: var(--primary-hover); }
/* Приветствие: кнопка крупнее и с выраженной тенью. */
.ob-primary.is-welcome {
height: 42px;
gap: 9px;
padding: 0 22px;
border-radius: 10px;
font-size: 14px;
box-shadow: 0 6px 16px color-mix(in srgb, var(--accent) 32%, transparent);
}
/* Финальный экран: та же крупная форма, но без тени. */
.ob-primary.is-done {
height: 40px;
gap: 9px;
padding: 0 20px;
border-radius: 10px;
font-size: 14px;
box-shadow: none;
}
.ob-secondary {
height: 36px;
display: inline-flex;
align-items: center;
gap: 7px;
padding: 0 14px;
border: 1px solid var(--n-6);
border-radius: 8px;
background: var(--surface-card);
color: var(--n-3);
font-size: 13px;
font-weight: 500;
cursor: pointer;
transition: background 140ms ease, color 140ms ease;
}
.ob-secondary:hover {
background: var(--n-10);
color: var(--n-2);
}
.ob-ghost {
height: 36px;
display: inline-flex;
align-items: center;
gap: 7px;
padding: 0 12px;
border: 0;
border-radius: 8px;
background: transparent;
color: var(--n-3);
font-size: 13px;
font-weight: 500;
cursor: pointer;
transition: background 140ms ease, color 140ms ease;
}
.ob-ghost:hover {
background: var(--n-9);
color: var(--n-2);
}
.ob-chip {
display: inline-flex;
align-items: center;
border-radius: var(--radius-pill);
font-weight: 700;
white-space: nowrap;
}
.ob-chip.is-accent {
height: 24px;
gap: 8px;
padding: 0 10px;
border: 1px solid var(--primary-border);
background: var(--primary-bg);
color: var(--primary-text);
font-size: 11.5px;
letter-spacing: 0.02em;
}
.ob-chip.is-success,
.ob-chip.is-waiting {
height: 21px;
gap: 5px;
padding: 0 9px;
border: 1px solid transparent;
font-size: 11px;
}
.ob-chip.is-success {
border-color: var(--success-border);
background: var(--success-bg);
color: var(--success-text);
}
.ob-chip.is-waiting {
border-color: var(--warning-border);
background: var(--warning-bg);
color: var(--warning-text);
}
/* ── Приветствие ────────────────────────────────────────────────────────── */
.ob-welcome-head {
position: relative;
padding: 44px 48px 34px;
background: linear-gradient(180deg, var(--primary-bg), var(--surface-card));
}
.ob-welcome-head .ob-logo-tile { margin-bottom: 22px; }
.ob-welcome-head .ob-eyebrow { margin-bottom: 11px; }
.ob-welcome-head h2 {
margin: 0;
max-width: 520px;
font-size: 29px;
font-weight: 700;
line-height: 1.18;
letter-spacing: -0.025em;
color: var(--n-1);
text-wrap: pretty;
}
.ob-welcome-head p {
margin: 14px 0 0;
max-width: 520px;
font-size: 14.5px;
line-height: 1.6;
color: var(--n-3);
text-wrap: pretty;
}
.ob-facts {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 1px;
border-top: 1px solid var(--border-default);
border-bottom: 1px solid var(--border-default);
background: var(--border-default);
}
.ob-facts > div {
padding: 17px 20px;
background: var(--surface-card);
}
.ob-facts strong {
display: block;
font-size: 13.5px;
font-weight: 700;
color: var(--n-1);
}
.ob-facts small {
display: block;
margin-top: 3px;
font-size: 12.5px;
line-height: 1.45;
color: var(--n-4);
}
.ob-welcome-foot {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
padding: 20px 48px 26px;
}
.ob-welcome-foot .ob-text-button {
height: 38px;
padding: 0 4px;
}
/* ── Визард: левая рейка ────────────────────────────────────────────────── */
.ob-rail {
width: 300px;
flex: none;
display: flex;
flex-direction: column;
background: var(--n-10);
border-right: 1px solid var(--border-default);
}
.ob-rail-head {
flex: none;
padding: 24px 22px 18px;
}
.ob-rail-brand {
display: flex;
align-items: center;
gap: 9px;
margin-bottom: 18px;
font-size: 14px;
font-weight: 700;
color: var(--n-1);
}
.ob-rail-brand > span:first-child {
width: 24px;
height: 24px;
flex: none;
display: flex;
align-items: center;
justify-content: center;
}
.ob-rail-progress-row {
display: flex;
align-items: baseline;
justify-content: space-between;
margin-bottom: 8px;
}
.ob-rail-progress-row span {
font-size: 12px;
font-weight: 600;
color: var(--n-3);
}
.ob-rail-progress-row small {
font-size: 11.5px;
font-weight: 600;
color: var(--n-4);
}
.ob-rail-bar {
height: 4px;
border-radius: var(--radius-pill);
background: var(--n-8);
overflow: hidden;
}
.ob-rail-bar i {
display: block;
height: 100%;
border-radius: inherit;
background: var(--primary);
transition: width 280ms ease;
}
.ob-rail-list {
flex: 1;
min-height: 0;
display: flex;
flex-direction: column;
gap: 2px;
margin: 0;
padding: 2px 12px 12px;
list-style: none;
overflow-y: auto;
}
.ob-rail-step {
display: flex;
align-items: center;
gap: 11px;
width: 100%;
padding: 9px 11px;
border: 0;
border-radius: 9px;
background: transparent;
text-align: left;
cursor: pointer;
}
.ob-rail-step.is-current {
background: var(--surface-card);
box-shadow: var(--shadow-sm);
}
.ob-rail-mark {
width: 22px;
height: 22px;
flex: none;
display: inline-flex;
align-items: center;
justify-content: center;
border: 1px solid var(--n-7);
border-radius: 50%;
background: var(--n-8);
color: var(--n-4);
font-size: 11.5px;
font-weight: 700;
}
.ob-rail-step.is-current .ob-rail-mark {
border-color: var(--primary);
background: var(--primary);
color: #fff;
}
/* Пройденный шаг остаётся зелёным, даже когда он же текущий: галочка важнее
номера — так в референсе. */
.ob-rail-step.is-done .ob-rail-mark {
border-color: var(--success);
background: var(--success);
color: #fff;
}
.ob-rail-name {
flex: 1;
min-width: 0;
font-size: 13px;
font-weight: 500;
color: var(--n-2);
}
.ob-rail-step.is-done .ob-rail-name { color: var(--n-3); }
.ob-rail-step.is-current .ob-rail-name {
font-weight: 700;
color: var(--n-1);
}
.ob-rail-step small {
flex: none;
font-size: 11px;
font-weight: 500;
color: var(--n-5);
}
.ob-rail-foot {
flex: none;
padding: 12px 22px 20px;
border-top: 1px solid var(--border-default);
}
.ob-rail-foot .ob-text-button { font-size: 12.5px; }
/* Полоса прогресса в правой части нужна только там, где рейка скрыта. */
.ob-narrow-bar { display: none; }
/* ── Визард: правая часть ───────────────────────────────────────────────── */
.ob-body {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
}
.ob-body-head {
flex: none;
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 22px 32px 0;
}
.ob-body-scroll {
flex: 1;
min-height: 0;
padding: 14px 32px 26px;
overflow-y: auto;
}
.ob-body-scroll h2 {
margin: 0;
font-size: 23px;
font-weight: 700;
line-height: 1.2;
letter-spacing: -0.022em;
color: var(--n-1);
text-wrap: pretty;
}
.ob-lead {
margin: 10px 0 0;
max-width: 560px;
font-size: 13.5px;
line-height: 1.6;
color: var(--n-3);
text-wrap: pretty;
}
.ob-path {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 6px;
margin: 18px 0 0;
padding: 8px 11px;
border: 1px solid var(--border-default);
border-radius: 9px;
background: var(--n-10);
}
.ob-crumb {
display: inline-flex;
align-items: center;
gap: 6px;
font-family: var(--font-mono);
font-size: 11.5px;
font-weight: 600;
color: var(--n-2);
}
.ob-crumb svg { color: var(--n-5); }
.ob-instructions {
display: flex;
flex-direction: column;
gap: 13px;
margin: 22px 0 0;
padding: 0;
list-style: none;
}
.ob-instructions li {
display: flex;
align-items: flex-start;
gap: 12px;
}
.ob-instructions li span:first-child {
width: 21px;
height: 21px;
flex: none;
display: inline-flex;
align-items: center;
justify-content: center;
margin-top: 1px;
border: 1px solid var(--primary-border);
border-radius: 50%;
background: var(--primary-bg);
color: var(--primary-text);
font-size: 11px;
font-weight: 700;
}
.ob-instructions li span + span {
flex: 1;
min-width: 0;
font-size: 13.5px;
line-height: 1.55;
color: var(--n-2);
text-wrap: pretty;
}
.ob-warning {
display: flex;
align-items: flex-start;
gap: 10px;
margin-top: 22px;
padding: 12px 14px;
border: 1px solid var(--warning-border);
border-radius: 10px;
background: var(--warning-bg);
color: var(--warning-text);
}
.ob-warning svg {
flex: none;
margin-top: 1px;
}
.ob-warning p {
flex: 1;
margin: 0;
font-size: 12.5px;
font-weight: 500;
line-height: 1.5;
text-wrap: pretty;
}
.ob-preview {
margin-top: 22px;
border: 1px solid var(--border-default);
border-radius: 12px;
background: var(--n-10);
overflow: hidden;
}
.ob-preview-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
padding: 9px 14px;
border-bottom: 1px solid var(--border-default);
background: var(--surface-card);
}
.ob-preview-label {
display: inline-flex;
align-items: center;
gap: 7px;
font-family: var(--font-mono);
font-size: 11px;
font-weight: 600;
letter-spacing: 0.02em;
color: var(--n-4);
}
.ob-preview-label i {
width: 7px;
height: 7px;
border-radius: 50%;
background: var(--n-6);
}
.ob-preview-rows {
display: flex;
flex-direction: column;
gap: 1px;
background: var(--border-default);
}
.ob-preview-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
padding: 11px 14px;
background: var(--n-10);
}
.ob-preview-row span {
font-size: 12px;
color: var(--n-4);
}
.ob-preview-row b {
min-width: 0;
font-size: 12.5px;
font-weight: 600;
color: var(--n-2);
text-align: right;
}
.ob-preview-code {
padding: 12px 14px;
border-top: 1px solid var(--border-default);
background: var(--surface-card);
}
.ob-preview-code code {
display: block;
font-family: var(--font-mono);
font-size: 11.5px;
line-height: 1.6;
color: var(--n-3);
word-break: break-all;
}
.ob-body-foot {
flex: none;
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 14px 32px 18px;
border-top: 1px solid var(--border-default);
background: var(--surface-card);
}
.ob-body-actions {
display: flex;
align-items: center;
gap: 8px;
}
/* ── Финальный экран ────────────────────────────────────────────────────── */
.ob-done-head { padding: 40px 40px 26px; }
.ob-done-head .ob-logo-tile { margin-bottom: 20px; }
.ob-done-head h2 {
margin: 0;
font-size: 25px;
font-weight: 700;
letter-spacing: -0.022em;
line-height: 1.2;
color: var(--n-1);
}
.ob-done-head p {
margin: 12px 0 0;
font-size: 14px;
line-height: 1.6;
color: var(--n-3);
text-wrap: pretty;
}
.ob-summary {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 1px;
border-top: 1px solid var(--border-default);
border-bottom: 1px solid var(--border-default);
background: var(--border-default);
}
.ob-summary-item {
display: flex;
align-items: center;
gap: 9px;
padding: 12px 16px;
background: var(--surface-card);
}
.ob-summary-item > span:first-child {
width: 20px;
height: 20px;
flex: none;
display: inline-flex;
align-items: center;
justify-content: center;
border: 1px solid var(--n-7);
border-radius: 50%;
background: var(--n-7);
color: #fff;
}
.ob-summary-item.is-done > span:first-child {
border-color: var(--success);
background: var(--success);
}
.ob-summary-item > span + span {
flex: 1;
min-width: 0;
font-size: 13px;
font-weight: 500;
color: var(--n-4);
}
.ob-summary-item.is-done > span + span {
font-weight: 600;
color: var(--n-2);
}
.ob-done-foot {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
padding: 20px 40px 26px;
}
/* ── Тур «Показать где» ─────────────────────────────────────────────────── */
.ob-tour-backdrop {
position: absolute;
inset: 0;
z-index: 39;
border: 0;
padding: 0;
background: transparent;
cursor: default;
}
.ob-tour-dim {
position: absolute;
z-index: 40;
background: var(--scrim);
pointer-events: none;
}
.ob-tour-spot,
.ob-tour-ring {
position: absolute;
z-index: 41;
border-radius: 11px;
pointer-events: none;
}
.ob-tour-spot { box-shadow: 0 0 0 2px var(--primary); }
.ob-tour-ring {
border: 2px solid var(--primary);
animation: ob-pulse 1.9s ease-out infinite;
}
.ob-tour-callout {
position: absolute;
z-index: 42;
width: 344px;
padding: 16px 18px 14px;
border: 1px solid var(--primary-border);
border-radius: 13px;
background: var(--surface-card);
box-shadow: 0 20px 48px -12px rgba(16, 18, 22, 0.45);
animation: ob-rise 220ms cubic-bezier(0.2, 0.8, 0.2, 1);
}
.ob-tour-callout .ob-eyebrow {
margin: 0 0 8px;
font-size: 11px;
letter-spacing: 0.08em;
}
.ob-tour-callout strong {
display: block;
font-size: 14.5px;
font-weight: 700;
letter-spacing: -0.01em;
color: var(--n-1);
}
.ob-tour-callout p {
margin: 7px 0 0;
font-size: 13px;
line-height: 1.55;
color: var(--n-3);
text-wrap: pretty;
}
.ob-tour-back {
width: 100%;
height: 34px;
display: inline-flex;
align-items: center;
justify-content: center;
gap: 7px;
margin-top: 14px;
border: 0;
border-radius: 8px;
background: var(--primary);
color: #fff;
font-size: 13px;
font-weight: 600;
cursor: pointer;
transition: background 140ms ease;
}
.ob-tour-back:hover { background: var(--primary-hover); }
/* ── Точки возврата ─────────────────────────────────────────────────────── */
.ob-launcher {
position: absolute;
right: 24px;
bottom: 24px;
z-index: 20;
height: 44px;
display: inline-flex;
align-items: center;
gap: 10px;
padding: 0 18px 0 15px;
border: 1px solid var(--primary-border);
border-radius: var(--radius-pill);
background: var(--surface-card);
color: var(--primary-text);
font-size: 13px;
font-weight: 600;
cursor: pointer;
box-shadow: 0 10px 28px -8px rgba(16, 18, 22, 0.3);
transition: background 140ms ease;
}
.ob-launcher:hover { background: var(--primary-bg); }
.ob-launcher small {
padding: 1px 7px;
border-radius: var(--radius-pill);
background: var(--primary-bg);
font-size: 11.5px;
font-weight: 700;
}
/* Ссылка «Начало работы» внизу субменю «Настроек». */
.settings-subnav-onboarding {
display: flex;
align-items: center;
gap: 9px;
width: 100%;
min-height: 38px;
padding: 0 11px;
border: 1px solid var(--primary-border);
border-radius: 9px;
background: var(--primary-bg);
color: var(--primary-text);
font-size: 13px;
font-weight: 600;
text-align: left;
cursor: pointer;
transition: background 140ms ease;
}
.settings-subnav-onboarding:hover { background: color-mix(in srgb, var(--accent) 16%, var(--surface-card)); }
.settings-subnav-onboarding span { flex: 1; }
.settings-subnav-onboarding small {
font-size: 11.5px;
font-weight: 700;
}
.settings-subnav-onboarding-note {
margin: 7px 11px 2px;
font-size: 11.5px;
line-height: 1.45;
color: var(--n-4);
}
/* ── Узкий экран ────────────────────────────────────────────────────────── */
@media (max-width: 900px) {
.ob-overlay { padding: 16px; }
.ob-steps {
width: calc(100% - 32px);
height: auto;
max-height: 100%;
}
.ob-rail { display: none; }
.ob-narrow-bar {
display: block;
flex: none;
margin: 12px 32px 0;
}
.ob-body-foot {
flex-wrap: wrap;
row-gap: 10px;
}
.ob-body-actions {
flex-wrap: wrap;
width: 100%;
justify-content: flex-end;
}
.ob-tour-callout { width: min(344px, calc(100% - 32px)); }
}
@media (prefers-reduced-motion: reduce) {
.ob-overlay,
.ob-card,
.ob-tour-callout,
.ob-tour-ring {
animation: none;
}
.ob-rail-bar i { transition: none; }
}
@@ -0,0 +1,200 @@
import { createContext, useCallback, useContext, useEffect, useMemo, useState } from "react";
import type { SettingsSectionKey } from "../settings/sections";
import type { RouteKey, SessionUser } from "../../types";
import { isManager } from "../../auth/access";
import { PENDING_STATE, fetchOnboarding, postOnboardingAction, type OnboardingState } from "./api";
import { visibleOnboardingSteps, type OnboardingStep } from "./steps";
import { t } from "../../i18n";
// Состояние онбординга на одну сессию рабочего места.
//
// Живёт контекстом, а не пропсами: визард монтируется в Shell, а ссылка
// «Начало работы» — в субменю «Настроек», глубоко в дереве страницы. Тянуть
// один и тот же прогресс двумя маршрутами дороже, чем один провайдер.
export type OnboardingView = "welcome" | "steps" | "done" | null;
export type OnboardingApi = {
/** Шаги, доступные этому человеку: у не-администратора установки их семь. */
steps: OnboardingStep[];
state: OnboardingState;
view: OnboardingView;
index: number;
/** Шаг, чьё место показывает тур; модалка на это время прячется. */
tourIndex: number | null;
doneCount: number;
/** Заголовок приветствия числом-словом, как в макете: «Восемь шагов…». */
welcomeTitle: string;
/** Есть что донастроить и человек уже закрывал визард — показываем пилюлю. */
showLauncher: boolean;
available: boolean;
open: () => void;
openWelcome: () => void;
startSteps: () => void;
goTo: (index: number) => void;
next: () => void;
back: () => void;
showTour: () => void;
hideTour: () => void;
openSection: () => void;
dismiss: () => void;
complete: () => void;
completeToChat: () => void;
restart: () => void;
};
const OnboardingContext = createContext<OnboardingApi | null>(null);
export const OnboardingProvider = OnboardingContext.Provider;
/** Внутри Shell контекст есть всегда; заглушка нужна тестам отдельных страниц. */
export function useOnboarding(): OnboardingApi | null {
return useContext(OnboardingContext);
}
export function useOnboardingState({ user, setRoute, openSettings }: {
user: SessionUser;
setRoute: (route: RouteKey) => void;
openSettings: (section: SettingsSectionKey | null) => void;
}): OnboardingApi {
const available = isManager(user);
const steps = useMemo(() => visibleOnboardingSteps(user.isInstanceAdmin), [user.isInstanceAdmin]);
const [state, setState] = useState<OnboardingState>(PENDING_STATE);
const [loaded, setLoaded] = useState(false);
const [view, setView] = useState<OnboardingView>(null);
const [index, setIndex] = useState(0);
const [tourIndex, setTourIndex] = useState<number | null>(null);
const refresh = useCallback(async () => {
try {
setState(await fetchOnboarding());
} catch {
// Молчим: онбординг — подсказка, а не рабочая функция. Не смог
// загрузиться — просто не показывается.
}
}, []);
useEffect(() => {
if (!available) return;
let cancelled = false;
fetchOnboarding()
.then((payload) => {
if (cancelled) return;
setState(payload);
setLoaded(true);
// Ни закрывал, ни проходил — значит видит визард при этом входе,
// сколько бы он ни работал в системе до сегодня.
if (!payload.dismissedAt && !payload.completedAt) setView("welcome");
})
.catch(() => undefined);
return () => {
cancelled = true;
};
}, [available]);
const act = useCallback(async (action: "dismiss" | "complete" | "restart") => {
try {
setState(await postOnboardingAction(action));
} catch {
// Не записалось — визард всё равно закрыт до конца сессии.
}
}, []);
const doneCount = steps.filter((step) => state.steps[step.key]).length;
const goTo = useCallback((next: number) => {
setIndex(Math.max(0, Math.min(steps.length - 1, next)));
setTourIndex(null);
}, [steps.length]);
// Стабильная ссылка: тур вызывает её из эффекта замера, и меняющаяся
// функция перезапускала бы замер на каждом рендере Shell.
const hideTour = useCallback(() => setTourIndex(null), []);
const api: OnboardingApi = {
steps,
state,
view,
index,
tourIndex,
doneCount,
// Шагов бывает семь или восемь — по одному заголовку на каждый случай:
// в макете число написано словом, а не цифрой.
welcomeTitle: steps.length === 7 ? t("onboarding.welcome_title_7") : t("onboarding.welcome_title_8"),
available,
showLauncher: available && loaded && view === null && tourIndex === null && doneCount < steps.length,
open: () => {
void refresh();
setIndex(0);
setView("steps");
},
openWelcome: () => {
void refresh();
setIndex(0);
setView("welcome");
},
startSteps: () => {
setIndex(0);
setView("steps");
},
goTo,
next: () => {
if (index >= steps.length - 1) {
void refresh();
setView("done");
return;
}
goTo(index + 1);
},
back: () => {
if (index === 0) {
setView("welcome");
return;
}
goTo(index - 1);
},
showTour: () => {
const step = steps[index];
if (!step) return;
// Раздел «Настроек» открывается одним вызовом вместе с маршрутом.
if (step.target.section) openSettings(step.target.section);
else setRoute(step.target.route);
setTourIndex(index);
},
hideTour,
openSection: () => {
const step = steps[index];
if (!step) return;
if (step.target.section) openSettings(step.target.section);
else setRoute(step.target.route);
// Закрываем окно, но не отмечаем «закрыл»: человек ушёл настраивать и
// вернётся пилюлей в углу.
setTourIndex(null);
setView(null);
},
dismiss: () => {
setTourIndex(null);
setView(null);
void act("dismiss");
},
complete: () => {
setTourIndex(null);
setView(null);
void act("complete");
},
completeToChat: () => {
setTourIndex(null);
setView(null);
void act("complete");
setRoute("chat");
},
restart: () => {
setIndex(0);
setView("welcome");
void act("restart");
},
};
return api;
}
@@ -19,8 +19,17 @@ import { StorageSettingsCard } from "./StorageSettingsCard";
import { GroupsSettingsCard } from "./GroupsSettingsCard";
import { DEFAULT_SETTINGS_SECTION, visibleSettingsSections, type SettingsSectionKey } from "./sections";
import { useIntegrations } from "./useIntegrations";
import { useOnboarding } from "../onboarding/useOnboarding";
import type { TourTarget } from "../onboarding/steps";
import { t } from "../../i18n";
// Раздел «Настроек» как цель тура онбординга: имена совпадают с таблицей шагов.
const SECTION_TOUR_TARGET: Partial<Record<SettingsSectionKey, TourTarget>> = {
ai: "settings-ai",
integrations: "settings-integrations",
platform: "settings-platform",
};
// «Настройки» (дизайн-базлайн v2, кадры N1–N7): субменю разделов 240px и один
// раздел на экране; ниже субменю — переходы на отдельные экраны «База знаний» и
// «Аудит действий». Профиль пользователя сюда не входит — это отдельная
@@ -56,6 +65,7 @@ export function SettingsPage({ user, onUserUpdated, reload, groups = [], section
const active = section ?? (mobile ? null : DEFAULT_SETTINGS_SECTION);
// Разделы про саму установку видит только её администратор.
const sections = visibleSettingsSections(user);
const onboarding = useOnboarding();
const current = sections.find((item) => item.key === active) ?? null;
const links = (
@@ -65,6 +75,19 @@ export function SettingsPage({ user, onUserUpdated, reload, groups = [], section
<button className="settings-subnav-link" type="button" onClick={() => setRoute("administrationAudit")}>
<Icon name="list" size={16} strokeWidth={1.9} /><span>{t("settings.action_audit")}</span><Icon name="external" size={13} strokeWidth={2} />
</button>
{/* Возврат в онбординг: он открывается в любой момент, а не только при
первом входе. */}
{onboarding?.available && (
<>
<div className="settings-subnav-divider" />
<button className="settings-subnav-onboarding" type="button" onClick={onboarding.open}>
<Icon name="sparkles" size={16} strokeWidth={1.9} />
<span>{t("onboarding.title")}</span>
<small>{t("onboarding.progress_short", { done: onboarding.doneCount, total: onboarding.steps.length })}</small>
</button>
<p className="settings-subnav-onboarding-note">{t("onboarding.settings_hint")}</p>
</>
)}
</>
);
@@ -77,6 +100,7 @@ export function SettingsPage({ user, onUserUpdated, reload, groups = [], section
className={`settings-subnav-item ${item.key === active ? "is-active" : ""}`}
key={item.key}
type="button"
data-onboarding-target={SECTION_TOUR_TARGET[item.key]}
onClick={() => openSection(item.key)}
>
<Icon name={item.icon} size={16} strokeWidth={1.9} />
@@ -2,6 +2,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { Icon } from "../../shared/icons";
import { Segmented } from "../../shared/ui";
import { SelectField } from "../../shared/form-controls";
import { Button } from "../../shared/ui-controls";
import { shortDateTime } from "../../shared/utils";
import { MarkdownContent } from "../help-center/MarkdownContent";
@@ -444,24 +445,18 @@ export function PortalArticleEditor({
<aside className="portal-editor-rail">
<div className="portal-rail-fields">
<label>
<span>{t("admin.section")}</span>
<span className="portal-select">
<select value={categoryId} onChange={(event) => setCategoryId(event.target.value)}>
{categories.map((item) => <option key={item.id} value={item.id}>{item.name}</option>)}
</select>
<Icon name="chevron" size={13} strokeWidth={2} />
</span>
</label>
<label>
<span>{t("portals.language")}</span>
<span className="portal-select">
<select value={locale} onChange={(event) => setLocale(event.target.value)}>
{LOCALE_OPTIONS.map(([value, label]) => <option key={value} value={value}>{label}</option>)}
</select>
<Icon name="chevron" size={13} strokeWidth={2} />
</span>
</label>
<SelectField
label={t("admin.section")}
value={categoryId}
onChange={setCategoryId}
options={categories.map((item) => [String(item.id), item.name])}
/>
<SelectField
label={t("portals.language")}
value={locale}
onChange={setLocale}
options={LOCALE_OPTIONS.map(([value, label]) => [value, label])}
/>
<label>
<span>{t("ai.short_description")}<small>{t("portals.shown_list_search")}</small></span>
<textarea value={summary} onChange={(event) => setSummary(event.target.value)} />
@@ -1,5 +1,6 @@
import { useEffect, useState } from "react";
import { SelectField } from "../../shared/form-controls";
import { Icon } from "../../shared/icons";
import { Button, CopyButton } from "../../shared/ui-controls";
import {
@@ -87,16 +88,16 @@ export function PortalBasicsSettings({
{fieldErrors.slug && <small className="portal-field-error">{fieldErrors.slug}</small>}
</div>
<label className="portal-field is-narrow">
<span className="portal-field-label">{t("portals.primary_language")}</span>
<span className="portal-select">
<select disabled={!canManage} value={locale} onChange={(event) => setLocale(event.target.value)}>
{LOCALE_OPTIONS.map(([value, label]) => <option key={value} value={value}>{label}</option>)}
</select>
<Icon name="chevron" size={14} strokeWidth={2} />
</span>
<div className="portal-field is-narrow">
<SelectField
label={t("portals.primary_language")}
disabled={!canManage}
value={locale}
onChange={setLocale}
options={LOCALE_OPTIONS.map(([value, label]) => [value, label])}
/>
<small>{t("portals.language_filled_new_articles_articles")}</small>
</label>
</div>
<div className="portal-final-link">
<span>
@@ -1,6 +1,7 @@
import { useEffect, useState } from "react";
import { Icon } from "../../shared/icons";
import { SelectField } from "../../shared/form-controls";
import { Button } from "../../shared/ui-controls";
import {
portalErrorMessage,
@@ -43,21 +44,16 @@ export function PortalWidgetSettings({
return (
<div className="portal-settings-card">
<label className="portal-field is-narrow">
<span className="portal-field-label">{t("common.web_widget_2")}</span>
<span className="portal-select">
<select
disabled={!canManage}
value={String(widgetId ?? "")}
onChange={(event) => setWidgetId(event.target.value ? Number(event.target.value) : null)}
>
<option value="">{t("portals.do_not_show")}</option>
{widgets.map((widget) => <option key={widget.id} value={widget.id}>{widget.name}</option>)}
</select>
<Icon name="chevron" size={14} strokeWidth={2} />
</span>
<div className="portal-field is-narrow">
<SelectField
label={t("common.web_widget_2")}
disabled={!canManage}
value={String(widgetId ?? "")}
onChange={(value) => setWidgetId(value ? Number(value) : null)}
options={[["", t("portals.do_not_show")], ...widgets.map((widget): [string, string] => [String(widget.id), widget.name])]}
/>
<small>{t("portals.widget_available_anonymous_portal_visitors")}</small>
</label>
</div>
{canManage && (
<div className="portal-settings-actions">
<Button variant="primary" disabled={busy} onClick={() => void save()}>{t("portals.save_widget")}</Button>
@@ -477,8 +477,6 @@
font-weight: 500;
}
.portal-rail-fields .portal-select { height: 34px; font-size: 13px; }
.portal-rail-fields textarea {
width: 100%;
/* Краткое описание видно в списке статей и в поиске портала — его пишут
@@ -148,8 +148,7 @@
}
.portal-field > input:focus,
.portal-field textarea:focus,
.portal-select:focus-within {
.portal-field textarea:focus {
border-color: var(--primary);
}
@@ -197,36 +196,6 @@
font-weight: 400;
}
.portal-select {
position: relative;
display: flex;
height: 36px;
align-items: center;
padding: 0 11px;
border: 1px solid var(--n-7);
border-radius: 8px;
background: var(--surface-card);
color: var(--n-1);
font-size: 13.5px;
}
.portal-select select {
width: 100%;
padding: 0;
border: 0;
outline: 0;
appearance: none;
background: transparent;
color: inherit;
font: inherit;
cursor: pointer;
}
.portal-select > svg {
pointer-events: none;
color: var(--n-4);
}
.portal-final-link {
display: flex;
align-items: center;
+188
View File
@@ -1010,6 +1010,194 @@ export const en: Record<MessageKey, Message> = {
"portals.working": "working",
"portals.yaml_format_note": "A YAML file · format: articles: [{format}]",
"portals.yes_article_helped": "Yes, the article helped",
"onboarding.title": "Getting started",
"onboarding.eyebrow": "Your installation is ready to configure",
"onboarding.welcome_title_7": "Seven steps to your first customer reply",
"onboarding.welcome_title_8": "Eight steps to your first customer reply",
"onboarding.welcome_lead": "We'll go through what to set up and where: the model key, the agent, the knowledge, the entry point and the team. Every step gives the exact path in the interface and highlights the element on screen.",
"onboarding.fact_time": "≈ 25 minutes",
"onboarding.fact_time_note": "You can stop and come back",
"onboarding.fact_key": "Your own model key",
"onboarding.fact_key_note": "Or the demo provider, no key needed",
"onboarding.fact_data": "Your data stays with you",
"onboarding.fact_data_note": "Everything runs on your server",
"onboarding.later": "I'll do it later",
"onboarding.start": "Start setup",
"onboarding.progress": "{done} of {total} done",
"onboarding.progress_short": "{done}/{total}",
"onboarding.step_of": "Step {index} of {total} · {time}",
"onboarding.close_and_self": "Close and set up on my own",
"onboarding.back": "Back",
"onboarding.show_where": "Show me where",
"onboarding.open_section": "Open the section",
"onboarding.next": "Next",
"onboarding.finish": "Finish",
"onboarding.tour_eyebrow": "Step {index} · where to find it",
"onboarding.tour_ok": "Got it, back to the steps",
"onboarding.done_title": "All set — you can take customers now",
"onboarding.done_lead": "The agent answers from your knowledge and hands the hard cases to the team. You can come back to the steps in Settings → Getting started.",
"onboarding.restart": "Start over",
"onboarding.go_chat": "Go to chat",
"onboarding.settings_hint": "Step-by-step setup of your installation. Opens whenever you need it.",
"onboarding.steps_list": "Setup steps",
"onboarding.s1.name": "AI provider",
"onboarding.s1.time": "3 min",
"onboarding.s1.title": "Connect an AI provider",
"onboarding.s1.lead": "Without a provider the agent cannot answer anyone. The key is yours: spend, limits and tokens are all counted in the system.",
"onboarding.s1.path": "Settings → AI provider → Add provider",
"onboarding.s1.do_1": "Open Settings in the sidebar and pick the AI provider section.",
"onboarding.s1.do_2": "Click Add provider: OpenRouter, Custom (OpenAI-compatible) or a local model.",
"onboarding.s1.do_3": "Paste the API key and set a default model — openai/gpt-4o-mini, for example.",
"onboarding.s1.do_4": "Click Check: the status should turn to Connected.",
"onboarding.s1.warning": "No key yet? Take the demo provider without a key — it answers from the agent's knowledge so you can look around, and you can swap it for a real one at any time.",
"onboarding.s1.preview": "Settings → AI provider",
"onboarding.s1.row1_label": "Provider",
"onboarding.s1.row1_value": "OpenRouter",
"onboarding.s1.row2_label": "Secret",
"onboarding.s1.row2_value": "sk-or-••••••••",
"onboarding.s1.row3_label": "Model",
"onboarding.s1.row3_value": "openai/gpt-4o-mini",
"onboarding.s1.chip": "Connected",
"onboarding.s1.tour_title": "Settings → AI provider",
"onboarding.s1.tour_text": "A section in the Settings submenu. This is where the organization's keys live: adding a provider, checking it and its status.",
"onboarding.s2.name": "AI agent",
"onboarding.s2.time": "4 min",
"onboarding.s2.title": "Create an AI agent",
"onboarding.s2.lead": "The agent is who talks to the customer: who they are, how they answer, what rules they follow and how much they may spend per day.",
"onboarding.s2.path": "Agents → Create agent",
"onboarding.s2.do_1": "Open Agents in the sidebar and click Create agent.",
"onboarding.s2.do_2": "Describe the role and tone: who they are to the customer, what they may promise, what they must not do.",
"onboarding.s2.do_3": "Pick a model from the connected provider and a daily budget in dollars.",
"onboarding.s2.do_4": "Attach knowledge base articles — the agent answers only from those.",
"onboarding.s2.do_5": "Switch the status from Draft to Active, otherwise the agent stays silent.",
"onboarding.s2.warning": "If there is nothing to answer from, the agent says so honestly and offers to call a human — it will not make things up.",
"onboarding.s2.preview": "Agents → Consultant",
"onboarding.s2.row1_label": "Model",
"onboarding.s2.row1_value": "openai/gpt-4o-mini",
"onboarding.s2.row2_label": "Daily budget",
"onboarding.s2.row2_value": "$5.00",
"onboarding.s2.row3_label": "Knowledge",
"onboarding.s2.row3_value": "12 articles",
"onboarding.s2.chip": "Active",
"onboarding.s2.tour_title": "The Agents section",
"onboarding.s2.tour_text": "The organization's agents. An agent card is its role, its model, its budget and the knowledge attached to it.",
"onboarding.s3.name": "Knowledge base",
"onboarding.s3.time": "from 10 min",
"onboarding.s3.title": "Fill the knowledge base",
"onboarding.s3.lead": "This is the only source the agent answers from. Search goes by meaning, so write the way your customers ask.",
"onboarding.s3.path": "Knowledge base → Create article",
"onboarding.s3.do_1": "Open the Knowledge base and create categories for the topics people write about.",
"onboarding.s3.do_2": "Add articles: the question in the title, a short unambiguous answer in the body.",
"onboarding.s3.do_3": "Load existing material through Import — as files, with no manual copying.",
"onboarding.s3.do_4": "Check on the agent card that the articles are attached to it.",
"onboarding.s3.warning": "Editing an article changes the bot's answers right away, and the public support portal reads the same article — there is no need to keep two texts.",
"onboarding.s3.preview": "Knowledge base",
"onboarding.s3.row1_label": "Categories",
"onboarding.s3.row1_value": "Delivery · Payment · Returns",
"onboarding.s3.row2_label": "Articles",
"onboarding.s3.row2_value": "12",
"onboarding.s3.row3_label": "Search",
"onboarding.s3.row3_value": "Semantic",
"onboarding.s3.chip": "Attached to the agent",
"onboarding.s3.tour_title": "The Knowledge base section",
"onboarding.s3.tour_text": "Articles, categories and import. This is where the agent takes its answers from, and where the support portal reads them.",
"onboarding.s4.name": "Entry point",
"onboarding.s4.time": "5 min",
"onboarding.s4.title": "Connect an entry point",
"onboarding.s4.lead": "An entry point is the channel a customer arrives through: Telegram, MAX, email or the chat on your site. Without one there is nowhere to write.",
"onboarding.s4.path": "Settings → Integrations → Add connection",
"onboarding.s4.do_1": "Settings → Integrations → Add connection.",
"onboarding.s4.do_2": "For a bot, paste the token: Telegram from BotFather, MAX from the developer console.",
"onboarding.s4.do_3": "For email, give the mailbox IMAP and SMTP settings and an app password.",
"onboarding.s4.do_4": "Bind the connection to the agent you created — otherwise there is nobody to handle the messages.",
"onboarding.s4.do_5": "Click Check and make sure the status is Connected.",
"onboarding.s4.warning": "Messages from Telegram and MAX are picked up by a background worker. If it is stopped the status stays Connected, but no conversations appear.",
"onboarding.s4.preview": "Settings → Integrations",
"onboarding.s4.row1_label": "Connection",
"onboarding.s4.row1_value": "Telegram · @romashka_bot",
"onboarding.s4.row2_label": "Agent",
"onboarding.s4.row2_value": "Consultant",
"onboarding.s4.row3_label": "Secret",
"onboarding.s4.row3_value": "78••••••••",
"onboarding.s4.chip": "Connected",
"onboarding.s4.tour_title": "Settings → Integrations",
"onboarding.s4.tour_text": "Bots, email and the web widget. A red dot on the item means some connection has dropped.",
"onboarding.s5.name": "Site widget",
"onboarding.s5.time": "3 min",
"onboarding.s5.title": "Put the chat on your site",
"onboarding.s5.lead": "The web widget is a single tag in your template. The chat opens in an isolated window on top of the site.",
"onboarding.s5.path": "Settings → Integrations → Web widget",
"onboarding.s5.do_1": "Create a Web widget connection and style it: title, accent, greeting.",
"onboarding.s5.do_2": "Fill in the allowed domains — an empty list blocks every site.",
"onboarding.s5.do_3": "Copy the snippet and paste it before the closing body tag on your site.",
"onboarding.s5.do_4": "Set the widget to Published and reload your site.",
"onboarding.s5.warning": "The key in data-widget-key must match the key in the widget settings — if they differ the chat simply will not appear.",
"onboarding.s5.preview": "Snippet for your site",
"onboarding.s5.row1_label": "Allowed domains",
"onboarding.s5.row1_value": "romashka.ru, *.romashka.ru",
"onboarding.s5.chip": "Published",
"onboarding.s5.tour_title": "Settings → Integrations",
"onboarding.s5.tour_text": "The widget lives among the connections: its key, its allowed domains and the ready-made tag to paste.",
"onboarding.s6.name": "Team",
"onboarding.s6.time": "4 min",
"onboarding.s6.title": "Invite your team",
"onboarding.s6.lead": "The agent hands hard questions to people. Add the people it will hand them to, and decide who sees what.",
"onboarding.s6.path": "Team → Invite",
"onboarding.s6.do_1": "Open Team and send invitations by email.",
"onboarding.s6.do_2": "Assign roles: the owner and the administrator see everything, an employee works only in the chat.",
"onboarding.s6.do_3": "In Settings → Groups, split conversations into visibility groups.",
"onboarding.s6.do_4": "Ask the team to turn on code-based sign-in (TOTP) in their profile.",
"onboarding.s6.warning": "Invitations go out by email. Until SMTP is configured (the Domain and email step) the letters will not arrive, and passwords have to be set by hand.",
"onboarding.s6.preview": "Team",
"onboarding.s6.row1_label": "Invited",
"onboarding.s6.row1_value": "3 people",
"onboarding.s6.row2_label": "Groups",
"onboarding.s6.row2_value": "First line · Billing",
"onboarding.s6.row3_label": "Roles",
"onboarding.s6.row3_value": "Admin · Employee",
"onboarding.s6.chip": "Waiting to sign in",
"onboarding.s6.tour_title": "The Team section",
"onboarding.s6.tour_text": "Invitations, roles and groups. An employee sees the conversations of their groups and the ones they are responsible for.",
"onboarding.s7.name": "Domain and email",
"onboarding.s7.time": "5 min",
"onboarding.s7.title": "Set the domain and email",
"onboarding.s7.lead": "For now the installation opens by IP — no HTTPS and no email. A domain and SMTP turn it into a working service.",
"onboarding.s7.path": "Settings → Platform",
"onboarding.s7.do_1": "Settings → Platform: enter the installation domain and switch to https.",
"onboarding.s7.do_2": "Make sure the domain's DNS points at the server and ports 80 and 443 are open from outside.",
"onboarding.s7.do_3": "Fill in outgoing email: host, port, encryption, login and app password.",
"onboarding.s7.do_4": "Click Send a test message and check that it arrives.",
"onboarding.s7.warning": "The certificate is issued on the first request to the domain: open it in a browser and wait a few seconds.",
"onboarding.s7.preview": "Settings → Platform",
"onboarding.s7.row1_label": "Address",
"onboarding.s7.row1_value": "https://support.romashka.ru",
"onboarding.s7.row2_label": "SMTP",
"onboarding.s7.row2_value": "smtp.yandex.ru : 465",
"onboarding.s7.row3_label": "Certificate",
"onboarding.s7.row3_value": "Let's Encrypt",
"onboarding.s7.chip": "HTTPS is on",
"onboarding.s7.tour_title": "Settings → Platform",
"onboarding.s7.tour_text": "Properties of the installation itself: address, outgoing email, updates. The section is visible to the installation administrator.",
"onboarding.s8.name": "Check",
"onboarding.s8.time": "2 min",
"onboarding.s8.title": "Message the bot as a customer",
"onboarding.s8.lead": "The last step is to walk the customer's path end to end: a message, the agent's answer and the handover to a human.",
"onboarding.s8.path": "Chat → All conversations",
"onboarding.s8.do_1": "Ask your bot, or the widget on your site, a question from the knowledge base.",
"onboarding.s8.do_2": "Open Chat: the conversation appears in the list with its channel, agent and waiting timer.",
"onboarding.s8.do_3": "Check the agent's answer — it should rest on your article.",
"onboarding.s8.do_4": "Click Take the conversation and answer yourself: that is how the handover to an operator works.",
"onboarding.s8.warning": "The three conversation modes — AI answers, an employee answers, paused — switch in the conversation header at any time.",
"onboarding.s8.preview": "Chat → All conversations",
"onboarding.s8.row1_label": "Conversation",
"onboarding.s8.row1_value": "Anna Smirnova · Telegram",
"onboarding.s8.row2_label": "Agent",
"onboarding.s8.row2_value": "Consultant",
"onboarding.s8.row3_label": "Mode",
"onboarding.s8.row3_value": "AI answers",
"onboarding.s8.chip": "Answered in 4s",
"onboarding.s8.tour_title": "The conversation list",
"onboarding.s8.tour_text": "Every request to the organization in one window: channel, agent, waiting time, labels and group.",
"organizations.add": "Add organization",
"organizations.create_title": "New organization",
"organizations.create_lead": "You will own the new organization and switch to it right away. The name, time zone and language can be changed later in Settings.",
+188
View File
@@ -1011,6 +1011,194 @@ export const ru = {
"portals.working": "работает",
"portals.yaml_format_note": "Файл YAML · формат: articles: [{format}]",
"portals.yes_article_helped": "Да, статья полезна",
"onboarding.title": "Начало работы",
"onboarding.eyebrow": "Установка готова к настройке",
"onboarding.welcome_title_7": "Семь шагов до первого ответа клиенту",
"onboarding.welcome_title_8": "Восемь шагов до первого ответа клиенту",
"onboarding.welcome_lead": "Покажем по порядку, что и где настроить: ключ модели, агента, знания, точку входа и команду. На каждом шаге — точный путь в интерфейсе и подсветка нужного элемента на экране.",
"onboarding.fact_time": "≈ 25 минут",
"onboarding.fact_time_note": "Можно прерваться и вернуться",
"onboarding.fact_key": "Свой ключ модели",
"onboarding.fact_key_note": "Или демо-провайдер без ключа",
"onboarding.fact_data": "Данные у вас",
"onboarding.fact_data_note": "Всё на вашем сервере",
"onboarding.later": "Пройду позже",
"onboarding.start": "Начать настройку",
"onboarding.progress": "Готово {done} из {total}",
"onboarding.progress_short": "{done}/{total}",
"onboarding.step_of": "Шаг {index} из {total} · {time}",
"onboarding.close_and_self": "Закрыть и настроить самому",
"onboarding.back": "Назад",
"onboarding.show_where": "Показать где",
"onboarding.open_section": "Открыть раздел",
"onboarding.next": "Далее",
"onboarding.finish": "Завершить",
"onboarding.tour_eyebrow": "Шаг {index} · где это находится",
"onboarding.tour_ok": "Понятно, к шагам",
"onboarding.done_title": "Всё готово — можно принимать клиентов",
"onboarding.done_lead": "Агент отвечает по вашим знаниям, сложное передаёт команде. Вернуться к шагам можно в «Настройки → Начало работы».",
"onboarding.restart": "Пройти заново",
"onboarding.go_chat": "Перейти в чат",
"onboarding.settings_hint": "Пошаговая настройка установки. Открывается в любой момент.",
"onboarding.steps_list": "Шаги настройки",
"onboarding.s1.name": "AI-провайдер",
"onboarding.s1.time": "3 мин",
"onboarding.s1.title": "Подключите AI-провайдера",
"onboarding.s1.lead": "Без провайдера агент не ответит ни одному клиенту. Ключ ваш: расходы, лимиты и токены считаются в системе.",
"onboarding.s1.path": "Настройки → AI-провайдер → Добавить провайдера",
"onboarding.s1.do_1": "В сайдбаре откройте «Настройки» и выберите раздел «AI-провайдер».",
"onboarding.s1.do_2": "Нажмите «Добавить провайдера»: OpenRouter, Custom (OpenAI-compatible) или локальная модель.",
"onboarding.s1.do_3": "Вставьте API-ключ и укажите модель по умолчанию — например openai/gpt-4o-mini.",
"onboarding.s1.do_4": "Нажмите «Проверить»: статус должен стать «Подключено».",
"onboarding.s1.warning": "Ключа ещё нет? Возьмите «Демо-провайдер без ключа» — он отвечает по знаниям агента, чтобы осмотреться, и меняется на настоящий в любой момент.",
"onboarding.s1.preview": "Настройки → AI-провайдер",
"onboarding.s1.row1_label": "Провайдер",
"onboarding.s1.row1_value": "OpenRouter",
"onboarding.s1.row2_label": "Секрет",
"onboarding.s1.row2_value": "sk-or-••••••••",
"onboarding.s1.row3_label": "Модель",
"onboarding.s1.row3_value": "openai/gpt-4o-mini",
"onboarding.s1.chip": "Подключено",
"onboarding.s1.tour_title": "Настройки → AI-провайдер",
"onboarding.s1.tour_text": "Раздел в субменю «Настроек». Здесь живут ключи организации: добавление, проверка и статус провайдера.",
"onboarding.s2.name": "AI-агент",
"onboarding.s2.time": "4 мин",
"onboarding.s2.title": "Создайте AI-агента",
"onboarding.s2.lead": "Агент — тот, кто говорит с клиентом: кто он, как отвечает, каким правилам подчиняется и сколько может потратить в день.",
"onboarding.s2.path": "Агенты → Создать агента",
"onboarding.s2.do_1": "Откройте «Агенты» в сайдбаре и нажмите «Создать агента».",
"onboarding.s2.do_2": "Опишите роль и тон: кто он для клиента, что можно обещать, чего делать нельзя.",
"onboarding.s2.do_3": "Выберите модель подключённого провайдера и дневной бюджет в долларах.",
"onboarding.s2.do_4": "Прикрепите статьи базы знаний — агент отвечает только из них.",
"onboarding.s2.do_5": "Переведите статус из «Черновик» в «Активен», иначе агент молчит.",
"onboarding.s2.warning": "Если данных для ответа нет, агент честно это скажет и предложит позвать сотрудника — придумывать он не станет.",
"onboarding.s2.preview": "Агенты → Консультант",
"onboarding.s2.row1_label": "Модель",
"onboarding.s2.row1_value": "openai/gpt-4o-mini",
"onboarding.s2.row2_label": "Дневной бюджет",
"onboarding.s2.row2_value": "$5.00",
"onboarding.s2.row3_label": "Знания",
"onboarding.s2.row3_value": "12 статей",
"onboarding.s2.chip": "Активен",
"onboarding.s2.tour_title": "Раздел «Агенты»",
"onboarding.s2.tour_text": "Список агентов организации. Карточка агента — это его роль, модель, бюджет и прикреплённые знания.",
"onboarding.s3.name": "База знаний",
"onboarding.s3.time": "от 10 мин",
"onboarding.s3.title": "Наполните базу знаний",
"onboarding.s3.lead": "Это единственный источник ответов агента. Поиск идёт по смыслу, поэтому пишите так, как спрашивают клиенты.",
"onboarding.s3.path": "База знаний → Создать статью",
"onboarding.s3.do_1": "Откройте «База знаний» и создайте категории под темы обращений.",
"onboarding.s3.do_2": "Добавьте статьи: вопрос в заголовке, короткий однозначный ответ в тексте.",
"onboarding.s3.do_3": "Готовые материалы залейте через «Импорт» — файлами, без ручного переноса.",
"onboarding.s3.do_4": "Проверьте на карточке агента, что статьи к нему прикреплены.",
"onboarding.s3.warning": "Правка статьи меняет ответы бота сразу, и ту же статью читает публичный портал поддержки — держать два текста не нужно.",
"onboarding.s3.preview": "База знаний",
"onboarding.s3.row1_label": "Категории",
"onboarding.s3.row1_value": "Доставка · Оплата · Возврат",
"onboarding.s3.row2_label": "Статей",
"onboarding.s3.row2_value": "12",
"onboarding.s3.row3_label": "Поиск",
"onboarding.s3.row3_value": "Семантический",
"onboarding.s3.chip": "Прикреплено к агенту",
"onboarding.s3.tour_title": "Раздел «База знаний»",
"onboarding.s3.tour_text": "Статьи, категории и импорт. Отсюда агент берёт ответы, отсюда же их читает портал поддержки.",
"onboarding.s4.name": "Точка входа",
"onboarding.s4.time": "5 мин",
"onboarding.s4.title": "Подключите точку входа",
"onboarding.s4.lead": "Точка входа — канал, по которому приходит клиент: Telegram, MAX, почта или чат на сайте. Без неё писать некуда.",
"onboarding.s4.path": "Настройки → Интеграции → Добавить подключение",
"onboarding.s4.do_1": "Настройки → «Интеграции» → «Добавить подключение».",
"onboarding.s4.do_2": "Для бота вставьте токен: Telegram — от BotFather, MAX — из кабинета разработчика.",
"onboarding.s4.do_3": "Для почты укажите IMAP и SMTP ящика и пароль приложения.",
"onboarding.s4.do_4": "Привяжите подключение к созданному агенту — иначе сообщения некому обрабатывать.",
"onboarding.s4.do_5": "Нажмите «Проверить» и убедитесь в статусе «Подключено».",
"onboarding.s4.warning": "Сообщения из Telegram и MAX забирает фоновый рабочий. Если он остановлен, статус останется «Подключено», а переписки не появятся.",
"onboarding.s4.preview": "Настройки → Интеграции",
"onboarding.s4.row1_label": "Подключение",
"onboarding.s4.row1_value": "Telegram · @romashka_bot",
"onboarding.s4.row2_label": "Агент",
"onboarding.s4.row2_value": "Консультант",
"onboarding.s4.row3_label": "Секрет",
"onboarding.s4.row3_value": "78••••••••",
"onboarding.s4.chip": "Подключено",
"onboarding.s4.tour_title": "Настройки → Интеграции",
"onboarding.s4.tour_text": "Боты, почта и веб-виджет. Красная точка у пункта означает, что какое-то подключение отвалилось.",
"onboarding.s5.name": "Виджет на сайт",
"onboarding.s5.time": "3 мин",
"onboarding.s5.title": "Поставьте чат на сайт",
"onboarding.s5.lead": "Веб-виджет — один тег в вашем шаблоне. Чат открывается в изолированном окне поверх сайта.",
"onboarding.s5.path": "Настройки → Интеграции → Web-виджет",
"onboarding.s5.do_1": "Создайте подключение «Web-виджет» и оформите его: заголовок, акцент, приветствие.",
"onboarding.s5.do_2": "Заполните разрешённые домены — пустой список запрещает все сайты.",
"onboarding.s5.do_3": "Скопируйте сниппет и вставьте его перед закрывающим body на своём сайте.",
"onboarding.s5.do_4": "Переведите виджет в статус «Опубликован» и обновите страницу сайта.",
"onboarding.s5.warning": "Ключ в data-widget-key должен совпадать с ключом из настроек виджета — при несовпадении чат просто не появится.",
"onboarding.s5.preview": "Сниппет для сайта",
"onboarding.s5.row1_label": "Разрешённые домены",
"onboarding.s5.row1_value": "romashka.ru, *.romashka.ru",
"onboarding.s5.chip": "Опубликован",
"onboarding.s5.tour_title": "Настройки → Интеграции",
"onboarding.s5.tour_text": "Виджет живёт среди подключений: там его ключ, разрешённые домены и готовый тег для вставки.",
"onboarding.s6.name": "Сотрудники",
"onboarding.s6.time": "4 мин",
"onboarding.s6.title": "Пригласите команду",
"onboarding.s6.lead": "Сложные вопросы агент передаёт людям. Заведите тех, кому он будет их отдавать, и решите, кто что видит.",
"onboarding.s6.path": "Сотрудники → Пригласить",
"onboarding.s6.do_1": "Откройте «Сотрудники» и отправьте приглашения по email.",
"onboarding.s6.do_2": "Назначьте роли: владелец и администратор видят всё, сотрудник работает только в чате.",
"onboarding.s6.do_3": "В Настройки → «Группы» распределите диалоги по группам видимости.",
"onboarding.s6.do_4": "Предложите команде включить вход по коду (TOTP) в своём профиле.",
"onboarding.s6.warning": "Приглашения уходят почтой. Пока не настроен SMTP (шаг «Домен и почта»), письма не дойдут — пароль придётся задавать вручную.",
"onboarding.s6.preview": "Сотрудники",
"onboarding.s6.row1_label": "Приглашены",
"onboarding.s6.row1_value": "3 человека",
"onboarding.s6.row2_label": "Группы",
"onboarding.s6.row2_value": "Первая линия · Биллинг",
"onboarding.s6.row3_label": "Роли",
"onboarding.s6.row3_value": "Админ · Сотрудник",
"onboarding.s6.chip": "Ожидают входа",
"onboarding.s6.tour_title": "Раздел «Сотрудники»",
"onboarding.s6.tour_text": "Приглашения, роли и группы. Сотрудник видит диалоги своих групп и те, за которые отвечает.",
"onboarding.s7.name": "Домен и почта",
"onboarding.s7.time": "5 мин",
"onboarding.s7.title": "Укажите домен и почту",
"onboarding.s7.lead": "Пока установка открывается по IP — без HTTPS и без писем. Домен и SMTP превращают её в рабочий сервис.",
"onboarding.s7.path": "Настройки → Платформа",
"onboarding.s7.do_1": "Настройки → «Платформа»: введите домен установки и переключитесь на https.",
"onboarding.s7.do_2": "Убедитесь, что DNS домена смотрит на сервер, а порты 80 и 443 открыты снаружи.",
"onboarding.s7.do_3": "Заполните исходящую почту: хост, порт, шифрование, логин и пароль приложения.",
"onboarding.s7.do_4": "Нажмите «Отправить тестовое письмо» и проверьте, что оно пришло.",
"onboarding.s7.warning": "Сертификат выдаётся по первому запросу к домену: откройте его в браузере и подождите несколько секунд.",
"onboarding.s7.preview": "Настройки → Платформа",
"onboarding.s7.row1_label": "Адрес",
"onboarding.s7.row1_value": "https://support.romashka.ru",
"onboarding.s7.row2_label": "SMTP",
"onboarding.s7.row2_value": "smtp.yandex.ru : 465",
"onboarding.s7.row3_label": "Сертификат",
"onboarding.s7.row3_value": "Let's Encrypt",
"onboarding.s7.chip": "HTTPS включён",
"onboarding.s7.tour_title": "Настройки → Платформа",
"onboarding.s7.tour_text": "Свойства самой установки: адрес, исходящая почта, обновления. Раздел виден администратору установки.",
"onboarding.s8.name": "Проверка",
"onboarding.s8.time": "2 мин",
"onboarding.s8.title": "Напишите боту как клиент",
"onboarding.s8.lead": "Последний шаг — пройти путь клиента целиком: сообщение, ответ агента и передача на человека.",
"onboarding.s8.path": "Чат → Все диалоги",
"onboarding.s8.do_1": "Напишите своему боту или в виджет на сайте вопрос из базы знаний.",
"onboarding.s8.do_2": "Откройте «Чат»: диалог появится в списке с каналом, агентом и таймером ожидания.",
"onboarding.s8.do_3": "Проверьте ответ агента — он должен опираться на вашу статью.",
"onboarding.s8.do_4": "Нажмите «Взять диалог» и ответьте сами: так работает передача на оператора.",
"onboarding.s8.warning": "Три режима диалога — отвечает AI, отвечает сотрудник, пауза — переключаются в шапке переписки в любой момент.",
"onboarding.s8.preview": "Чат → Все диалоги",
"onboarding.s8.row1_label": "Диалог",
"onboarding.s8.row1_value": "Анна Смирнова · Telegram",
"onboarding.s8.row2_label": "Агент",
"onboarding.s8.row2_value": "Консультант",
"onboarding.s8.row3_label": "Режим",
"onboarding.s8.row3_value": "Отвечает AI",
"onboarding.s8.chip": "Ответ за 4 сек",
"onboarding.s8.tour_title": "Список диалогов",
"onboarding.s8.tour_text": "Все обращения организации в одном окне: канал, агент, время ожидания, метки и группа.",
"organizations.add": "Добавить организацию",
"organizations.create_title": "Новая организация",
"organizations.create_lead": "Вы станете владельцем новой организации и сразу в неё переключитесь. Название, часовой пояс и язык потом можно поменять в «Настройках».",
@@ -1,107 +0,0 @@
import { useEffect, useState } from "react";
import { api } from "../api/client";
import { Icon } from "../shared/icons";
import type { RouteKey, SessionUser } from "../types";
import type { SettingsSectionKey } from "../features/settings/sections";
import { t } from "../i18n";
// Блок «Запуск» в сайдбаре (SPEC-CHATBALLS-0031 §5, дизайн-базлайн v2 A1): три шага
// с автоотметкой по факту, прогресс и primary-действие текущего шага.
// «Скрыть» — предпочтение клиента (localStorage); при полном прохождении блок
// исчезает сам.
type Checklist = {
agentCreated: boolean;
connectionBound: boolean;
employeeInvited: boolean;
done: boolean;
};
type Step = {
key: keyof Omit<Checklist, "done">;
label: string;
action: string;
route: RouteKey;
// Раздел «Настроек», если шаг ведёт в субменю (кадры N1–N7).
section?: SettingsSectionKey;
icon: Parameters<typeof Icon>[0]["name"];
};
const STEPS: Step[] = [
{ key: "agentCreated", label: t("profile.create_agent"), action: t("profile.create_agent"), route: "agents", icon: "robot" },
{ key: "connectionBound", label: t("profile.connect_entry_point"), action: t("profile.connect"), route: "settings", section: "integrations", icon: "plug" },
{ key: "employeeInvited", label: t("profile.invite_operators"), action: t("profile.invite"), route: "employees", icon: "team" },
];
function storageKey(user: SessionUser): string {
return `chatballs.launch.${user.organizationPublicId}.hidden`;
}
function isHidden(user: SessionUser): boolean {
try {
return localStorage.getItem(storageKey(user)) === "1";
} catch {
return false;
}
}
export function LaunchChecklist({ user, setRoute, openSettings }: { user: SessionUser; setRoute: (route: RouteKey) => void; openSettings: (section: SettingsSectionKey) => void }) {
const [checklist, setChecklist] = useState<Checklist | null>(null);
const [hidden, setHidden] = useState(() => isHidden(user));
useEffect(() => {
if (hidden) return;
let cancelled = false;
api<Checklist>("/api/v1/company/launch-checklist/")
.then((payload) => {
if (!cancelled) setChecklist(payload);
})
.catch(() => undefined);
return () => {
cancelled = true;
};
}, [hidden]);
if (hidden || !checklist || checklist.done) return null;
const doneCount = STEPS.filter((step) => checklist[step.key]).length;
const current = STEPS.find((step) => !checklist[step.key]);
function hide() {
try {
localStorage.setItem(storageKey(user), "1");
} catch {
// приватный режим — просто скрываем до перезагрузки
}
setHidden(true);
}
return (
<div className="launch-checklist">
<div className="launch-checklist-head">
<div><strong>{t("profile.getting_started")}</strong><small>{t("profile.getting_started_progress", { done: doneCount, total: STEPS.length })}</small></div>
<button aria-label={t("common.hide")} title={t("common.hide")} type="button" onClick={hide}>×</button>
</div>
<div className="launch-checklist-progress"><i style={{ width: `${Math.round((doneCount / STEPS.length) * 100)}%` }} /></div>
<div className="launch-checklist-steps">
{STEPS.map((step) => {
const stepDone = checklist[step.key];
const isCurrent = current?.key === step.key;
return (
<div className={`launch-checklist-step ${stepDone ? "is-done" : ""} ${isCurrent ? "is-current" : ""}`} key={step.key}>
<span className="launch-checklist-mark">{stepDone && <Icon name="check" size={10} />}</span>
<span>{step.label}</span>
</div>
);
})}
</div>
{current && (
<button className="launch-checklist-action" type="button" onClick={() => (current.section ? openSettings(current.section) : setRoute(current.route))}>
<Icon name={current.icon} size={13} />
{current.action}
</button>
)}
</div>
);
}
+34 -21
View File
@@ -11,6 +11,10 @@ import { useChatScope } from "../features/chat/useChatScope";
import { isManager } from "../auth/access";
import { DemoInstallBanner } from "../features/settings/DemoInstallBanner";
import { Sidebar } from "./Sidebar";
import { OnboardingLauncher } from "../features/onboarding/OnboardingLauncher";
import { OnboardingOverlay } from "../features/onboarding/OnboardingOverlay";
import { OnboardingTour } from "../features/onboarding/OnboardingTour";
import { OnboardingProvider, useOnboardingState } from "../features/onboarding/useOnboarding";
import { UpdateBanner } from "../features/updates/UpdateBanner";
import { ShellRouteContent } from "./ShellRouteContent";
@@ -27,6 +31,10 @@ export function Shell({ route, setRoute, settingsSection, openSettingsRoute, sel
// Охват чата живёт здесь: сотрудницкий сайдбар и страница чата делят одно
// состояние (дизайн-базлайн v2 §4.1).
const chatScope = useChatScope(true);
// Онбординг «Начало работы»: окно, тур и пилюля возврата. Состояние живёт
// здесь, потому что ссылка «Начало работы» в субменю «Настроек» читает тот
// же прогресс.
const onboarding = useOnboardingState({ user, setRoute, openSettings: openSettingsRoute });
const loadWaitingCount = useCallback(async () => {
try {
@@ -112,27 +120,32 @@ export function Shell({ route, setRoute, settingsSection, openSettingsRoute, sel
// «Порталы» — своя лента на --surface-feed и полноэкранные сплиты (кадры PT1–PT8).
const isPortals = route === "supportPortals" || route === "supportPortalDetail" || route === "supportPortalSettings";
return (
<div className={`hub-shell ${isDialogsWorkspace ? "is-chat-route" : ""} ${isSettings ? "is-settings-route" : ""} ${isProfile ? "is-profile-route" : ""} ${isContacts ? "is-contacts-route" : ""}`}>
<Sidebar route={route} user={user} setRoute={setRoute} openSettings={openSettingsRoute} onLogout={onLogout} onSwitchOrganization={onSwitchOrganization} waitingCount={waitingCount} chatScope={chatScope.scope} setChatScope={chatScope.setScope} chatCounters={chatScope.counters} unreadCount={unreadCount} onOpenNotifications={() => { setNotifOpen(true); void loadNotifications(); }} expanded={sidebarExpanded} setExpanded={setSidebarExpanded} />
<div className="hub-main">
{manager && <DemoInstallBanner reload={reload} />}
<UpdateBanner enabled={user.isInstanceAdmin} />
{/* Верхней панели нет ни у одной роли (дизайн-базлайн v2): заголовок и
«назад» живут в самой странице, уведомления — в меню профиля сайдбара. */}
<main className={`hub-scroll ${isDialogsWorkspace ? "sales-dialogs-scroll" : ""} ${isAiFullWidth ? "ai-fullwidth-scroll" : ""} ${isSettings ? "settings-scroll" : ""} ${isProfile ? "profile-scroll" : ""} ${isContacts ? "contacts-scroll" : ""} ${isAgents ? "agents-scroll" : ""} ${isEmployees ? "employees-scroll" : ""} ${isAudit ? "audit-scroll" : ""} ${isPortals ? "portals-scroll" : ""} ${isKnowledge || isKnowledgeEditor ? "knowledge-scroll" : ""}`}>
<div key={route} className={`hub-page enter-surface ${isSalesWorkspace || isSupportWorkspace ? "sales-workspace-page" : ""} ${isDialogsWorkspace ? "sales-dialogs-page" : ""} ${isAiFullWidth ? "ai-fullwidth-page" : ""} ${isSettings ? "settings-page" : ""} ${isProfile ? "profile-page-shell" : ""} ${isContacts ? "contacts-page-shell" : ""} ${isAgents ? "agents-page-shell" : ""} ${isEmployees ? "employees-page-shell" : ""} ${isAudit ? "audit-page-shell" : ""} ${isKnowledge ? "knowledge-page-shell" : ""} ${isKnowledgeEditor ? "knowledge-editor-shell" : ""} ${isPortals ? "portals-page-shell" : ""}`}>
<ShellRouteContent settingsSection={settingsSection} openSettings={openSettingsRoute} chatScope={chatScope.scope} setChatScope={chatScope.setScope} chatCounters={chatScope.counters} chatScopeSwitcher={manager} route={route} data={data} selectedEmployeeId={selectedEmployeeId} selectedAgentId={selectedAgentId} selectedKnowledgeId={selectedKnowledgeId} selectedConversationId={selectedConversationId} selectedClientId={selectedClientId} openClient={openClientRoute} selectedChannelId={selectedChannelId} openChannel={openChannelRoute} selectedSupportPortalId={selectedSupportPortalId} portalSettingsSection={portalSettingsSection} openSupportPortal={openSupportPortalRoute} openPortalSettings={openPortalSettingsRoute} openConversation={openConversationRoute} openEmployee={openEmployee} openAgent={openAgentRoute} openKnowledge={openKnowledgeRoute} openKnowledgeEditor={openKnowledgeEditorRoute} onAgentLoaded={() => undefined} onChannelLoaded={() => undefined} reload={reload} setRoute={setRoute} user={user} onUserUpdated={onUserUpdated} onLogout={onLogout} onOpenSidebar={() => setSidebarExpanded(true)} onOrganizationCreated={onOrganizationCreated} />
</div>
</main>
<OnboardingProvider value={onboarding}>
<div className={`hub-shell ${isDialogsWorkspace ? "is-chat-route" : ""} ${isSettings ? "is-settings-route" : ""} ${isProfile ? "is-profile-route" : ""} ${isContacts ? "is-contacts-route" : ""}`}>
<Sidebar route={route} user={user} setRoute={setRoute} onLogout={onLogout} onSwitchOrganization={onSwitchOrganization} waitingCount={waitingCount} chatScope={chatScope.scope} setChatScope={chatScope.setScope} chatCounters={chatScope.counters} unreadCount={unreadCount} onOpenNotifications={() => { setNotifOpen(true); void loadNotifications(); }} expanded={sidebarExpanded} setExpanded={setSidebarExpanded} />
<div className="hub-main">
{manager && <DemoInstallBanner reload={reload} />}
<UpdateBanner enabled={user.isInstanceAdmin} />
{/* Верхней панели нет ни у одной роли (дизайн-базлайн v2): заголовок и
«назад» живут в самой странице, уведомления — в меню профиля сайдбара. */}
<main className={`hub-scroll ${isDialogsWorkspace ? "sales-dialogs-scroll" : ""} ${isAiFullWidth ? "ai-fullwidth-scroll" : ""} ${isSettings ? "settings-scroll" : ""} ${isProfile ? "profile-scroll" : ""} ${isContacts ? "contacts-scroll" : ""} ${isAgents ? "agents-scroll" : ""} ${isEmployees ? "employees-scroll" : ""} ${isAudit ? "audit-scroll" : ""} ${isPortals ? "portals-scroll" : ""} ${isKnowledge || isKnowledgeEditor ? "knowledge-scroll" : ""}`}>
<div key={route} className={`hub-page enter-surface ${isSalesWorkspace || isSupportWorkspace ? "sales-workspace-page" : ""} ${isDialogsWorkspace ? "sales-dialogs-page" : ""} ${isAiFullWidth ? "ai-fullwidth-page" : ""} ${isSettings ? "settings-page" : ""} ${isProfile ? "profile-page-shell" : ""} ${isContacts ? "contacts-page-shell" : ""} ${isAgents ? "agents-page-shell" : ""} ${isEmployees ? "employees-page-shell" : ""} ${isAudit ? "audit-page-shell" : ""} ${isKnowledge ? "knowledge-page-shell" : ""} ${isKnowledgeEditor ? "knowledge-editor-shell" : ""} ${isPortals ? "portals-page-shell" : ""}`}>
<ShellRouteContent settingsSection={settingsSection} openSettings={openSettingsRoute} chatScope={chatScope.scope} setChatScope={chatScope.setScope} chatCounters={chatScope.counters} chatScopeSwitcher={manager} route={route} data={data} selectedEmployeeId={selectedEmployeeId} selectedAgentId={selectedAgentId} selectedKnowledgeId={selectedKnowledgeId} selectedConversationId={selectedConversationId} selectedClientId={selectedClientId} openClient={openClientRoute} selectedChannelId={selectedChannelId} openChannel={openChannelRoute} selectedSupportPortalId={selectedSupportPortalId} portalSettingsSection={portalSettingsSection} openSupportPortal={openSupportPortalRoute} openPortalSettings={openPortalSettingsRoute} openConversation={openConversationRoute} openEmployee={openEmployee} openAgent={openAgentRoute} openKnowledge={openKnowledgeRoute} openKnowledgeEditor={openKnowledgeEditorRoute} onAgentLoaded={() => undefined} onChannelLoaded={() => undefined} reload={reload} setRoute={setRoute} user={user} onUserUpdated={onUserUpdated} onLogout={onLogout} onOpenSidebar={() => setSidebarExpanded(true)} onOrganizationCreated={onOrganizationCreated} />
</div>
</main>
</div>
<NotificationDrawer
open={notifOpen}
items={notifications}
unreadCount={unreadCount}
onClose={() => setNotifOpen(false)}
onItemClick={onNotificationClick}
onMarkAll={onMarkAll}
/>
<OnboardingOverlay />
<OnboardingTour />
<OnboardingLauncher />
</div>
<NotificationDrawer
open={notifOpen}
items={notifications}
unreadCount={unreadCount}
onClose={() => setNotifOpen(false)}
onItemClick={onNotificationClick}
onMarkAll={onMarkAll}
/>
</div>
</OnboardingProvider>
);
}
+8 -9
View File
@@ -2,13 +2,12 @@ import { Dropdown } from "antd";
import type { CSSProperties } from "react";
import type { RouteKey, SessionUser } from "../types";
import type { SettingsSectionKey } from "../features/settings/sections";
import { useResizableWidth } from "../shared/useResizableWidth";
import { Icon, LogoIcon } from "../shared/icons";
import { canCreateOrganization, defaultRoute, isManager } from "../auth/access";
import type { TourTarget } from "../features/onboarding/steps";
import type { DialogScope } from "../features/conversations/ConversationWorkspace";
import { agentColorOf, groupColorOf, type ConversationCounters } from "../features/conversations/model";
import { LaunchChecklist } from "./LaunchChecklist";
import { SidebarUserMenu } from "./SidebarUserMenu";
import { t } from "../i18n";
@@ -24,14 +23,17 @@ type SidebarLinkProps = {
route: RouteKey;
routeKey: RouteKey;
setRoute: (route: RouteKey) => void;
// Имя цели для тура онбординга: по нему «Показать где» находит пункт на экране.
target?: TourTarget;
};
function SidebarLink({ activeRoutes, badge, icon, label, route, routeKey, setRoute }: SidebarLinkProps) {
function SidebarLink({ activeRoutes, badge, icon, label, route, routeKey, setRoute, target }: SidebarLinkProps) {
const active = activeRoutes?.includes(route) ?? routeKey === route;
return (
<button
className={`hub-nav-item ${active ? "is-active" : ""}`}
type="button"
data-onboarding-target={target}
onClick={() => setRoute(routeKey)}
>
<Icon name={icon} />
@@ -45,7 +47,6 @@ export function Sidebar({
route,
user,
setRoute,
openSettings,
onLogout,
onSwitchOrganization,
waitingCount = 0,
@@ -60,7 +61,6 @@ export function Sidebar({
route: RouteKey;
user: SessionUser;
setRoute: (route: RouteKey) => void;
openSettings: (section: SettingsSectionKey | null) => void;
onLogout: () => void;
onSwitchOrganization: (organizationPublicId: string) => void;
waitingCount?: number;
@@ -170,10 +170,10 @@ export function Sidebar({
<nav className="hub-nav">
<SidebarLink icon="message" label={t("common.chat")} badge={waitingCount > 0 ? String(waitingCount) : undefined} route={route} routeKey="chat" setRoute={setRoute} />
<SidebarLink activeRoutes={["salesClients", "salesClientDetail"]} icon="user" label={t("common.contacts")} route={route} routeKey="salesClients" setRoute={setRoute} />
<SidebarLink activeRoutes={["agents", "agentDetail"]} icon="robot" label={t("common.agents")} route={route} routeKey="agents" setRoute={setRoute} />
<SidebarLink activeRoutes={["employees", "employeeDetail"]} icon="team" label={t("common.operators")} route={route} routeKey="employees" setRoute={setRoute} />
<SidebarLink activeRoutes={["agents", "agentDetail"]} icon="robot" label={t("common.agents")} route={route} routeKey="agents" setRoute={setRoute} target="nav-agents" />
<SidebarLink activeRoutes={["employees", "employeeDetail"]} icon="team" label={t("common.operators")} route={route} routeKey="employees" setRoute={setRoute} target="nav-employees" />
<SidebarLink activeRoutes={["supportPortals", "supportPortalDetail", "supportPortalSettings"]} icon="globe" label={t("common.portals")} route={route} routeKey="supportPortals" setRoute={setRoute} />
<SidebarLink activeRoutes={["knowledge", "knowledgeDetail", "knowledgeCreate", "knowledgeEdit", "knowledgeCategories", "knowledgeImport"]} icon="book" label={t("common.knowledge_base")} route={route} routeKey="knowledge" setRoute={setRoute} />
<SidebarLink activeRoutes={["knowledge", "knowledgeDetail", "knowledgeCreate", "knowledgeEdit", "knowledgeCategories", "knowledgeImport"]} icon="book" label={t("common.knowledge_base")} route={route} routeKey="knowledge" setRoute={setRoute} target="nav-knowledge" />
<SidebarLink activeRoutes={["settings", "administrationAudit"]} icon="settings" label={t("common.settings")} route={route} routeKey="settings" setRoute={setRoute} />
</nav>
) : route === "profile" ? (
@@ -191,7 +191,6 @@ export function Sidebar({
}}
/>
)}
{manager && <LaunchChecklist user={user} setRoute={setRoute} openSettings={openSettings} />}
<SidebarUserMenu user={user} route={route} setRoute={setRoute} onLogout={onLogout} unreadCount={unreadCount} onOpenNotifications={onOpenNotifications} />
</div>
</aside>
+1 -125
View File
@@ -1,4 +1,5 @@
.hub-shell {
position: relative;
height: 100vh;
display: flex;
overflow: hidden;
@@ -670,131 +671,6 @@
}
/* --- Блок «Запуск» (SPEC-CHATBALLS-0031 §5, дизайн-базлайн v2 A1) --- */
.launch-checklist {
flex: none;
margin: 6px 10px 8px;
padding: 12px 12px 10px;
border: 1px solid var(--border-default);
border-radius: 10px;
background: var(--surface-card);
}
.launch-checklist-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
margin-bottom: 10px;
}
.launch-checklist-head strong {
color: var(--text-heading);
font-size: 13px;
font-weight: 700;
}
.launch-checklist-head small {
margin-left: 7px;
color: var(--text-tertiary);
font-size: 11.5px;
font-weight: 500;
}
.launch-checklist-head button {
width: 22px;
height: 22px;
border: none;
border-radius: 6px;
background: transparent;
color: var(--text-disabled);
font-size: 15px;
line-height: 1;
cursor: pointer;
}
.launch-checklist-head button:hover {
background: var(--n-9);
color: var(--text-body);
}
.launch-checklist-progress {
height: 3px;
border-radius: 2px;
background: var(--n-8);
margin-bottom: 12px;
overflow: hidden;
}
.launch-checklist-progress i {
display: block;
height: 100%;
background: var(--primary);
}
.launch-checklist-steps {
display: flex;
flex-direction: column;
gap: 8px;
}
.launch-checklist-step {
display: flex;
align-items: center;
gap: 9px;
color: var(--text-tertiary);
font-size: 12.5px;
}
.launch-checklist-step.is-current {
color: var(--text-heading);
font-weight: 600;
}
.launch-checklist-step.is-done span:last-child {
text-decoration: line-through;
}
.launch-checklist-mark {
width: 16px;
height: 16px;
flex: none;
border: 1.5px solid var(--border-strong);
border-radius: 50%;
display: inline-flex;
align-items: center;
justify-content: center;
color: #fff;
}
.launch-checklist-step.is-done .launch-checklist-mark {
border-color: var(--primary);
background: var(--primary);
}
.launch-checklist-action {
margin-top: 11px;
width: 100%;
height: 30px;
border: none;
border-radius: 7px;
background: var(--primary);
color: #fff;
font-size: 12.5px;
font-weight: 600;
cursor: pointer;
font-family: inherit;
display: inline-flex;
align-items: center;
justify-content: center;
gap: 6px;
}
.launch-checklist-action:hover {
background: var(--primary-hover);
}
/* Полоса установки демо-данных: пока worker наполняет организацию, человек
видит причину пустых разделов, а не гадает. */
.demo-install-banner {
@@ -1,6 +1,7 @@
import { Modal } from "antd";
import { useState } from "react";
import { SelectField } from "../form-controls";
import { Button } from "../ui-controls";
import { t, tn, type MessageKey } from "../../i18n";
@@ -69,19 +70,16 @@ export function AgentLinkDialog({
<button className={action === "detach" ? "active" : ""} disabled={busy} type="button" onClick={() => setAction("detach")}>{t("common.detach")}</button>
</div>
</div>
<label className="content-dialog-field content-dialog-select">
<span>{t("common.agent")}</span>
<div>
<select disabled={busy || agents.length === 0} value={agentId ?? ""} onChange={(event) => setAgentId(event.target.value ? Number(event.target.value) : null)}>
<option value="">{t("shared.pick_agent")}</option>
{agents.map((agent) => (
<option value={agent.id} key={agent.id}>
{agent.name}{agent.groupName ? ` · ${agent.groupName}` : ""}
</option>
))}
</select>
</div>
</label>
<SelectField
label={t("common.agent")}
disabled={busy || agents.length === 0}
value={agentId === null ? "" : String(agentId)}
onChange={(value) => setAgentId(value ? Number(value) : null)}
options={[
["", t("shared.pick_agent")],
...agents.map((agent): [string, string] => [String(agent.id), `${agent.name}${agent.groupName ? ` · ${agent.groupName}` : ""}`]),
]}
/>
{agents.length === 0 && <div className="content-dialog-error">{t("shared.there_no_agents_attach_material")}</div>}
</>
)}
@@ -143,29 +143,6 @@
font-weight: 500;
}
.content-dialog-select > div {
display: flex;
height: 42px;
align-items: center;
gap: 8px;
padding: 0 13px;
border: 1px solid var(--n-7);
border-radius: 9px;
color: var(--n-4);
background: var(--surface-card);
}
.content-dialog-select select {
min-width: 0;
flex: 1;
border: 0;
outline: 0;
color: var(--n-2);
background: transparent;
font: inherit;
font-size: 14px;
}
.content-dialog-segmented {
display: inline-flex;
gap: 3px;
+22 -51
View File
@@ -38,8 +38,7 @@
.form-field.is-editable input:focus,
.form-field.is-editable:focus-within textarea,
.form-field.is-editable textarea:focus,
.select-like:focus-within select,
.select-like .select-box:focus-within {
.select-like .select-box:focus-visible {
border-color: var(--primary);
}
@@ -68,58 +67,40 @@
border-color: var(--n-8);
}
.select-like {
position: relative;
}
.select-like select {
width: 100%;
height: 36px;
padding: 0 32px 0 12px;
cursor: pointer;
color: var(--n-1);
background: var(--n-10);
border: 1px solid var(--n-7);
border-radius: 8px;
outline: none;
appearance: none;
font-size: 13.5px;
line-height: normal;
}
/* Шеврон по центру поля: 36px бокса минус 14px иконки. Отступ справа — те же
12px внутреннего поля плюс 1px рамки, что и у шеврона внутри `.select-box`. */
.select-like > svg {
position: absolute;
right: 13px;
bottom: 11px;
color: var(--n-4);
pointer-events: none;
}
/* Бокс селекта — для метки перед значением (точка группы на карточке агента) и
для режима без прав правки. Рамку держит бокс, селект внутри без своей. */
/* Бокс селекта — он же кнопка, открывающая меню приложения. Нативного
`<select>` в проекте нет: список вариантов рисует `SelectMenu`, тот же, что
у фильтров списков. */
.select-like .select-box {
display: flex;
align-items: center;
gap: 7px;
width: 100%;
height: 36px;
padding: 0 12px;
color: var(--n-1);
background: var(--n-10);
border: 1px solid var(--n-7);
border-radius: 8px;
outline: none;
font-family: inherit;
font-size: 13.5px;
line-height: normal;
text-align: left;
}
.select-like .select-box select {
flex: 1;
height: 34px;
padding: 0;
color: inherit;
background: transparent;
border: 0;
font-size: inherit;
button.select-box {
cursor: pointer;
}
button.select-box:hover:not(:disabled) {
border-color: var(--n-6);
}
button.select-box:disabled {
color: var(--n-4);
background: var(--n-9);
border-color: var(--n-8);
cursor: default;
}
.select-like .select-box > svg {
@@ -136,7 +117,6 @@
text-overflow: ellipsis;
}
.select-like.is-invalid select,
.select-like.is-invalid .select-box {
color: var(--error-text);
border-color: var(--error-border);
@@ -146,19 +126,10 @@
текст 15px. Это тоже лежало копиями в профиле и настройках. */
@media (max-width: 900px) {
.readonly-field input,
.select-like select,
.select-like .select-box {
height: 44px;
font-size: 15px;
}
.select-like .select-box select {
height: 42px;
}
.select-like > svg {
bottom: 15px;
}
}
/* Единый переключатель приложения. Раньше в проекте было четыре его копии
+49 -16
View File
@@ -1,6 +1,7 @@
import type { ReactNode } from "react";
import { useRef, useState, type ReactNode } from "react";
import { Icon } from "./icons";
import { SelectMenu } from "./ui-controls";
type FormFieldProps = {
disabled?: boolean;
@@ -61,26 +62,58 @@ type SelectFieldProps = {
wide?: boolean;
};
/** Селект приложения — один на всё: подпись, бокс поля и шеврон. Точку перед
* значением и режим без прав правки держит `.select-box`: рамка тогда на нём,
* а сам селект внутри без рамки и фона. */
/** Селект приложения — один на всё: подпись, бокс поля и шеврон. Список
* вариантов рисует общий `SelectMenu`, тот же, что у фильтров списков:
* нативного `<select>` в проекте нет нигде, иначе вместо меню приложения
* открывался бы список операционной системы. */
export function SelectField({ adornment, disabled = false, invalid = false, label, onChange, options, readOnly = false, readOnlyText, value, wide = false }: SelectFieldProps) {
const className = ["readonly-field", "select-like", invalid ? "is-invalid" : "", readOnly ? "is-readonly" : "", wide ? "wide" : ""].filter(Boolean).join(" ");
const control = readOnly
? <span className="select-value">{readOnlyText ?? options.find(([optionValue]) => optionValue === value)?.[1] ?? ""}</span>
: (
<select disabled={disabled} value={value} onChange={(event) => onChange(event.target.value)}>
{options.map(([optionValue, labelText]) => <option value={optionValue} key={optionValue}>{labelText}</option>)}
</select>
);
const [open, setOpen] = useState(false);
const [menuWidth, setMenuWidth] = useState<number>();
const trigger = useRef<HTMLButtonElement>(null);
const className = ["readonly-field", "select-like", invalid ? "is-invalid" : "", readOnly ? "is-readonly" : "", disabled ? "is-disabled" : "", wide ? "wide" : ""].filter(Boolean).join(" ");
const current = options.find(([optionValue]) => optionValue === value)?.[1] ?? "";
const chevron = <Icon name="chevron" size={14} strokeWidth={2.2} />;
const boxed = Boolean(adornment) || readOnly;
if (readOnly) {
return (
<div className={className}>
<span>{label}</span>
<span className="select-box">{adornment}<span className="select-value">{readOnlyText ?? current}</span>{chevron}</span>
</div>
);
}
return (
<label className={className}>
<div className={className}>
<span>{label}</span>
{boxed ? <span className="select-box">{adornment}{control}{chevron}</span> : <>{control}{chevron}</>}
</label>
<SelectMenu
disabled={disabled}
onOpenChange={setOpen}
onSelect={(next) => {
onChange(next);
setOpen(false);
}}
open={open}
options={options.map(([optionValue, optionLabel]) => ({ value: optionValue, label: optionLabel }))}
// Меню селекта равно ширине поля, а не фиксированным 216px фильтра.
overlayClassName="app-dropdown is-field"
overlayStyle={menuWidth ? { width: menuWidth } : undefined}
selected={value ? [value] : []}
>
<button
className="select-box"
ref={trigger}
type="button"
aria-label={label}
disabled={disabled}
onClick={() => setMenuWidth(trigger.current?.offsetWidth)}
>
{adornment}
<span className="select-value">{current}</span>
{chevron}
</button>
</SelectMenu>
</div>
);
}
+13
View File
@@ -25,6 +25,19 @@
width: 216px;
}
/* Меню селекта в форме: ширину задаёт поле, ширина приходит инлайном на
оверлей — фиксированные 216px фильтра здесь были бы уже или шире поля. */
.app-dropdown.is-field .ant-dropdown-menu {
width: 100%;
}
/* Текущее значение в одиночном селекте: подсветка вместо галочки. */
.app-dropdown .ant-dropdown-menu-item button.is-selected {
color: var(--primary-text);
background: var(--primary-bg);
font-weight: 600;
}
.app-dropdown.is-channels-menu .ant-dropdown-menu {
width: 246px;
}
+52 -16
View File
@@ -1,5 +1,5 @@
import { Dropdown } from "antd";
import { useEffect, useRef, useState, type ButtonHTMLAttributes, type ReactNode, type RefObject } from "react";
import { useEffect, useRef, useState, type ButtonHTMLAttributes, type CSSProperties, type ReactElement, type ReactNode, type RefObject } from "react";
import { Icon } from "./icons";
import { t } from "../i18n";
@@ -132,28 +132,34 @@ export function SearchInput({ className = "", placeholder, value, onChange, inpu
);
}
export type FilterOption = { value: string; label: string; dot?: string };
export type SelectOption = { value: string; label: string; dot?: string };
/** Фильтр-селект списка — один на всё приложение: кнопка с текущим значением и
* шевроном, меню — общий `app-dropdown`. `multiple` включает галочки и счётчик
* выбранных (кадры K1 «Контакты», E1 «Сотрудники»). */
export function FilterDropdown({ caption, className = "", icon, label, options, selected, multiple = false, open, onOpenChange, onSelect }: {
/** Приглушённая подпись перед значением: «Статус: Все» (кадры PT1/PT3). */
caption?: string;
className?: string;
icon?: IconName;
label: string;
options: FilterOption[];
selected: string[];
/** Совместимое имя: фильтры списков звали вариант `FilterOption`. */
export type FilterOption = SelectOption;
/** Выпадающий список приложения — один на все селекты: и на фильтры списков, и
* на поля форм. Своей разметки у него нет, только меню в общем `app-dropdown`
* и триггер, который передают снаружи: у фильтра это кнопка-пилюля, у поля
* формы — бокс на всю ширину. Галочки включает `multiple`. Нативных `<select>`
* в проекте нет — список вариантов всегда рисуем сами, иначе на месте меню
* оказывается список операционной системы. */
export function SelectMenu({ children, disabled = false, multiple = false, onOpenChange, onSelect, open, options, overlayClassName = "app-dropdown is-wide", overlayStyle, selected }: {
children: ReactElement;
disabled?: boolean;
multiple?: boolean;
open: boolean;
onOpenChange: (open: boolean) => void;
onSelect: (value: string) => void;
open: boolean;
options: SelectOption[];
overlayClassName?: string;
overlayStyle?: CSSProperties;
selected: string[];
}) {
const items = options.map((option) => ({
key: option.value,
label: (
<button
className={!multiple && selected.includes(option.value) ? "is-selected" : ""}
type="button"
onClick={(event) => {
if (multiple) event.stopPropagation();
@@ -170,9 +176,39 @@ export function FilterDropdown({ caption, className = "", icon, label, options,
</button>
),
}));
return (
<Dropdown
menu={{ items }}
open={disabled ? false : open}
onOpenChange={onOpenChange}
trigger={["click"]}
disabled={disabled}
overlayClassName={overlayClassName}
overlayStyle={overlayStyle}
>
{children}
</Dropdown>
);
}
/** Фильтр-селект списка: кнопка с текущим значением и шевроном. `multiple`
* включает галочки и счётчик выбранных (кадры K1 «Контакты», E1 «Сотрудники»). */
export function FilterDropdown({ caption, className = "", icon, label, options, selected, multiple = false, open, onOpenChange, onSelect }: {
/** Приглушённая подпись перед значением: «Статус: Все» (кадры PT1/PT3). */
caption?: string;
className?: string;
icon?: IconName;
label: string;
options: SelectOption[];
selected: string[];
multiple?: boolean;
open: boolean;
onOpenChange: (open: boolean) => void;
onSelect: (value: string) => void;
}) {
const active = selected.length > 0;
return (
<Dropdown menu={{ items }} open={open} onOpenChange={onOpenChange} trigger={["click"]} overlayClassName="app-dropdown is-wide">
<SelectMenu multiple={multiple} onOpenChange={onOpenChange} onSelect={onSelect} open={open} options={options} selected={selected}>
<button className={`ui-filter-button ${active ? "is-active" : ""} ${className}`.trim()} type="button">
{icon && <Icon name={icon} size={14} strokeWidth={2} />}
{caption && <i className="ui-filter-caption">{caption}</i>}
@@ -180,7 +216,7 @@ export function FilterDropdown({ caption, className = "", icon, label, options,
{multiple && selected.length > 0 && <span>{selected.length}</span>}
<Icon name="chevron" size={13} strokeWidth={2.2} />
</button>
</Dropdown>
</SelectMenu>
);
}
+1
View File
@@ -26,5 +26,6 @@
@import "./features/administration/styles.css";
@import "./features/organizations/styles.css";
@import "./features/settings/styles.css";
@import "./features/onboarding/styles.css";
@import "./features/notifications/styles.css";
@import "./shared/state.css";
+6
View File
@@ -70,6 +70,10 @@
--shadow-xs: 0 1px 2px rgba(0, 0, 0, 0.03);
--shadow-sm: 0 1px 3px rgba(0, 0, 0, 0.04);
--shadow-lg: 0 6px 20px rgba(0, 0, 0, 0.12);
/* Тень модального окна и затемнение под ним: своя пара, потому что окно
висит над всем приложением, а не над карточкой. */
--modal-shadow: 0 32px 80px -12px rgba(16, 18, 22, 0.38), 0 8px 24px rgba(16, 18, 22, 0.12);
--scrim: rgba(16, 18, 22, 0.55);
--font-mono: "SF Mono", ui-monospace, Menlo, monospace;
}
@@ -119,6 +123,8 @@
--shadow-xs: 0 1px 2px rgba(0, 0, 0, 0.3);
--shadow-sm: 0 1px 3px rgba(0, 0, 0, 0.35);
--shadow-lg: 0 6px 20px rgba(0, 0, 0, 0.5);
--modal-shadow: 0 32px 80px -12px rgba(0, 0, 0, 0.7), 0 8px 24px rgba(0, 0, 0, 0.4);
--scrim: rgba(4, 5, 7, 0.68);
}
html,
@@ -0,0 +1,272 @@
# Handoff: новый онбординг Chatballs (первый запуск)
## Overview
Замена текущего блока «Запуск» (`apps/internal-ui/src/layout/LaunchChecklist.tsx`) на полноценный онбординг:
1. **Модальное окно при входе** — приветствие + визард из 8 шагов, который доскональнo показывает, что и где настроить, чтобы установка заработала целиком.
2. **Тур по интерфейсу** — кнопка «Показать где» затемняет приложение и подсвечивает реальный элемент (пункт сайдбара, строку субменю «Настроек», список диалогов) с колаутом-объяснением.
3. **Точки возврата** — ссылка «Начало работы» внизу субменю «Настроек» и плавающая пилюля с прогрессом в правом нижнем углу рабочей области.
4. Старый блок «Запуск» удаляется полностью.
Требование заказчика: онбординг **должен показаться всем, кто его ещё не прошёл и не закрыл, включая уже существующих пользователей**. Значит признак «пройден/закрыт» хранится на сервере, а не в `localStorage` (как сейчас в `LaunchChecklist`).
## About the Design Files
Файл `Онбординг Chatballs.dc.html` в этом архиве — **дизайн-референс, написанный на HTML**. Это прототип: он показывает задуманный вид, тексты и поведение. Это **не продакшн-код, который нужно копировать**.
Задача — воспроизвести этот дизайн в существующей среде репозитория: `apps/internal-ui` (React 18 + TypeScript + Vite, antd для оверлеев/дропдаунов, собственные CSS-файлы с токенами в `src/styles/base.css`, i18n через `src/i18n`). Все значения ниже уже совпадают с токенами репозитория — новых цветов и шрифтов не вводилось.
Прототип содержит и **воссозданное окружение** (сайдбар, экран «Чат», экран «Настройки»), чтобы было видно онбординг в контексте. Воссозданное окружение реализовывать не нужно — оно уже есть в коде. Реализуется только онбординг, тур, ссылка в «Настройках» и пилюля.
## Fidelity
**High-fidelity.** Цвета, типографика, отступы, радиусы, тени, тексты и переходы — финальные. Верстать пиксель-в-пиксель, но через существующие примитивы репозитория (`shared/icons.tsx`, `shared/ui-controls.tsx`, токены `base.css`), а не через копирование инлайновых стилей прототипа.
Темы: светлая и тёмная (в прототипе переключается тумблером `theme`). Всё выражено через существующие CSS-переменные, поэтому тёмная тема получается сама — своих хексов в компоненте быть не должно, кроме затемнения-скрима (см. «Design Tokens»).
---
## Screens / Views
### 1. Приветственное окно (`view = "welcome"`)
**Purpose:** объяснить владельцу, что установка поднялась и что дальше 8 шагов; дать войти в визард или отложить.
**Layout:** оверлей `position: absolute; inset: 0` (внутри `.hub-shell`), `z-index: 30`, фон-скрим + `backdrop-filter: blur(3px)`, контент по центру, внешний отступ 40px. Карточка: `width: min(700px, 100%)`, `border-radius: 18px`, `border: 1px solid var(--border-default)`, `background: var(--surface-card)`, тень `--modal-shadow`, `overflow: hidden`. Появление: `ob-rise 320ms cubic-bezier(.2,.8,.2,1)`, оверлей — `ob-fade 220ms ease-out`.
**Components (сверху вниз):**
| Элемент | Спецификация |
|---|---|
| Шапка-градиент | `padding: 44px 48px 34px`, `background: linear-gradient(180deg, var(--primary-bg), var(--surface-card))` |
| Кнопка «×» | `position: absolute; top: 18px; right: 18px`, 32×32, `border-radius: 8px`, иконка `Icon name="close"` 17px, цвет `var(--n-4)`; hover — фон `var(--n-9)`, цвет `var(--n-2)` |
| Плитка логотипа | 46×46, `border-radius: 14px`, фон `var(--primary)`, внутри `LogoIcon` 28px белым, тень `0 8px 20px color-mix(in srgb, var(--accent) 34%, transparent)`, `margin-bottom: 22px` |
| Надзаголовок | «УСТАНОВКА ГОТОВА К НАСТРОЙКЕ» — 11.5px/700, `letter-spacing: .1em`, `text-transform: uppercase`, цвет `var(--primary-text)`, `margin-bottom: 11px` |
| Заголовок | «Восемь шагов до первого ответа клиенту» — 29px/700, `letter-spacing: -.025em`, `line-height: 1.18`, `max-width: 520px`, цвет `var(--n-1)` |
| Лид | «Покажем по порядку, что и где настроить: ключ модели, агента, знания, точку входа и команду. На каждом шаге — точный путь в интерфейсе и подсветка нужного элемента на экране.» — 14.5px/1.6, `var(--n-3)`, `max-width: 520px`, `margin-top: 14px` |
| Три факта | `display: grid; grid-template-columns: repeat(3, minmax(0,1fr)); gap: 1px`, фон грида `var(--border-default)` (даёт разделители в 1px), рамки сверху и снизу 1px. Ячейка: `padding: 17px 20px`, фон `var(--surface-card)`; `strong` 13.5px/700 `var(--n-1)`, `small` 12.5px/1.45 `var(--n-4)`, отступ 3px. Тексты: «≈ 25 минут / Можно прерваться и вернуться», «Свой ключ модели / Или демо-провайдер без ключа», «Данные у вас / Всё на вашем сервере» |
| Футер | `padding: 20px 48px 26px`, `space-between`. Слева текст-кнопка «Пройду позже» 13px/500 `var(--n-4)` (hover `var(--n-2)`). Справа primary: высота 42, `padding: 0 22px`, `border-radius: 10px`, фон `var(--primary)`, белый текст 14px/600, тень `0 6px 16px color-mix(in srgb, var(--accent) 32%, transparent)`, иконка `Icon name="arrow"` 17px справа, подпись «Начать настройку» |
### 2. Визард шагов (`view = "steps"`)
**Purpose:** провести по 8 шагам настройки, на каждом — точный путь, инструкции, предупреждение и превью результата.
**Layout:** тот же оверлей (внешний отступ 34px). Карточка: `width: min(1020px, 100%)`, `height: min(700px, 100%)`, `display: flex`, `border-radius: 18px`, тень `--modal-shadow`, `overflow: hidden`. Появление `ob-rise 280ms`, оверлей `ob-fade 200ms`.
**Левая рейка — 300px, `flex: none`, `background: var(--n-10)`, `border-right: 1px solid var(--border-default)`:**
- Шапка `padding: 24px 22px 18px`: `LogoIcon` 22px + «Начало работы» 14px/700 `var(--n-1)`, зазор 9px, `margin-bottom: 18px` под строкой.
- Строка прогресса: слева «Готово N из 8» 12px/600 `var(--n-3)`, справа «NN%» 11.5px/600 `var(--n-4)`, `margin-bottom: 8px`. Полоса: высота 4, `border-radius: 999px`, трек `var(--n-8)`, заполнение `var(--primary)`, `transition: width 280ms ease`.
- Список шагов: `padding: 2px 12px 12px`, `gap: 2px`, прокручиваемый. Строка-кнопка: `padding: 9px 11px`, `border-radius: 9px`, `gap: 11px`; текущая — фон `var(--surface-card)` + тень `var(--shadow-sm)`, остальные прозрачные.
- Маркер 22×22, круг, 11.5px/700: пройденный — фон и рамка `var(--success)`, белая галочка (`Icon name="check"`, 11px, `stroke-width: 2.6`); текущий — фон и рамка `var(--primary)`, белая цифра; остальные — фон `var(--n-8)`, рамка `var(--n-7)`, цифра `var(--n-4)`.
- Подпись 13px: текущая `var(--n-1)`/700, пройденная `var(--n-3)`/500, остальные `var(--n-2)`/500. Справа оценка времени 11px/500 `var(--n-5)`.
- Футер рейки: `padding: 12px 22px 20px`, `border-top: 1px solid var(--border-default)`, текст-кнопка «Закрыть и настроить самому» 12.5px/500 `var(--n-4)`.
**Правая часть (`flex: 1`), три полосы:**
1. Шапка `padding: 22px 32px 0`: слева чип «Шаг N из 8 · 3 мин» — высота 24, `padding: 0 10px`, `border-radius: 999px`, фон `var(--primary-bg)`, рамка `var(--primary-border)`, текст `var(--primary-text)` 11.5px/700. Справа «×» 32×32 (как в приветствии).
2. Тело, прокручиваемое, `padding: 14px 32px 26px`:
- Заголовок шага — 23px/700, `letter-spacing: -.022em`, `line-height: 1.2`, `var(--n-1)`.
- Лид — 13.5px/1.6, `var(--n-3)`, `max-width: 560px`, `margin-top: 10px`.
- **Путь (хлебные крошки)** — контейнер `margin-top: 18px`, `padding: 8px 11px`, `border: 1px solid var(--border-default)`, `border-radius: 9px`, фон `var(--n-10)`, `flex-wrap`, `gap: 6px`. Крошка: `var(--font-mono)` 11.5px/600 `var(--n-2)`, между крошками `Icon name="chevronRight"` 12px `var(--n-5)`.
- **Инструкции** — `<ol>` без маркеров, `gap: 13px`, `margin-top: 22px`. Пункт: круг 21×21 (`border: 1px solid var(--primary-border)`, фон `var(--primary-bg)`, цифра `var(--primary-text)` 11px/700) + текст 13.5px/1.55 `var(--n-2)`, зазор 12px.
- **Предупреждение** — `margin-top: 22px`, `padding: 12px 14px`, `border: 1px solid var(--warning-border)`, `border-radius: 10px`, фон `var(--warning-bg)`; иконка `Icon name="alert"` 16px + текст 12.5px/500/1.5, всё цветом `var(--warning-text)`.
- **Превью результата** — `margin-top: 22px`, `border: 1px solid var(--border-default)`, `border-radius: 12px`, фон `var(--n-10)`, `overflow: hidden`.
- Шапка превью: `padding: 9px 14px`, фон `var(--surface-card)`, нижняя рамка 1px. Слева точка 7×7 `var(--n-6)` + подпись `var(--font-mono)` 11px/600 `var(--n-4)`. Справа чип статуса: «успех» — рамка `var(--success-border)`, фон `var(--success-bg)`, текст `var(--success-text)`, галочка 11px; «ожидание» — рамка `var(--warning-border)`, фон `var(--warning-bg)`, текст `var(--warning-text)`. Чип: высота 21, `padding: 0 9px`, `border-radius: 999px`, 11px/700.
- Строки: `gap: 1px` на фоне `var(--border-default)`; строка `padding: 11px 14px`, фон `var(--n-10)`, слева подпись 12px `var(--n-4)`, справа значение 12.5px/600 `var(--n-2)`.
- Блок кода (только шаг 5): `padding: 12px 14px`, фон `var(--surface-card)`, верхняя рамка 1px, `code` — `var(--font-mono)` 11.5px/1.6 `var(--n-3)`, `word-break: break-all`.
3. Футер `padding: 14px 32px 18px`, `border-top: 1px solid var(--border-default)`, фон `var(--surface-card)`, `space-between`:
- Слева «Назад» — высота 36, `padding: 0 12px`, прозрачная, `var(--n-3)` 13px/500, иконка `chevronLeft` 16px; hover фон `var(--n-9)`.
- Справа три кнопки, `gap: 8px`: «Показать где» (вторичная: высота 36, `padding: 0 14px`, `border: 1px solid var(--n-6)`, фон `var(--surface-card)`, `var(--n-3)` 13px/500, иконка `eye` 16px), «Открыть раздел» (такая же вторичная, без иконки), «Далее»/«Завершить» (primary: высота 36, `padding: 0 18px`, `border-radius: 8px`, фон `var(--primary)`, белый 13px/600, тень `0 1px 2px color-mix(in srgb, var(--accent) 30%, transparent)`, иконка `arrow` 16px).
### 3. Тур «Показать где» (`tour != null`)
**Purpose:** показать физическое место элемента в интерфейсе.
Модальное окно скрывается, приложение остаётся видимым. Слои внутри корневого контейнера (`.hub-shell`, он `position: relative`) — **только `position: absolute`, координаты относительно корня**, иначе подсветка уезжает при прокрутке:
- Прозрачная кнопка-подложка `inset: 0`, `z-index: 39` — клик закрывает тур.
- Четыре прямоугольника затемнения (сверху/снизу/слева/справа от цели), `z-index: 40`, цвет: светлая тема `rgba(16,18,22,.55)`, тёмная `rgba(4,5,7,.68)`. Четыре прямоугольника вместо `box-shadow: 0 0 0 9999px` — чтобы корректно рендерилось при экспорте и в скриншотах.
- Рамка цели: прямоугольник цели + `padding: 6px` со всех сторон, `border-radius: 11px`, `box-shadow: 0 0 0 2px var(--primary)`, `z-index: 41`; поверх — второй такой же с `border: 2px solid var(--primary)` и анимацией `ob-pulse 1.9s ease-out infinite`. Оба `pointer-events: none`.
- Колаут, `z-index: 42`, ширина 344: по умолчанию справа от цели (`x + w + 20`), если не влезает — слева (`x - 344 - 20`); по вертикали `y - 14`, зажат в границах корня (мин. 16, макс. `rootH - 250`). Карточка: `padding: 16px 18px 14px`, `border: 1px solid var(--primary-border)`, `border-radius: 13px`, фон `var(--surface-card)`, тень `0 20px 48px -12px rgba(16,18,22,.45)`, анимация `ob-rise 220ms`.
- Надзаголовок «ШАГ N · ГДЕ ЭТО НАХОДИТСЯ» 11px/700 uppercase `letter-spacing: .08em` `var(--primary-text)`.
- Заголовок 14.5px/700 `var(--n-1)`, текст 13px/1.55 `var(--n-3)` (`margin-top: 7px`).
- Кнопка на всю ширину «Понятно, к шагам»: высота 34, `border-radius: 8px`, фон `var(--primary)`, белый 13px/600, иконка `chevronLeft` 15px.
Пересчёт координат — на `resize` и при смене экрана. Реализация замера: взять `getBoundingClientRect()` цели и корня, вычесть.
### 4. Финальный экран (`view = "done"`)
Карточка `min(620px, 100%)`. Плитка 46×46 `border-radius: 14px` фон `var(--success)` с белой галочкой 24px. Заголовок «Всё готово — можно принимать клиентов» 25px/700 `letter-spacing: -.022em`. Лид «Агент отвечает по вашим знаниям, сложное передаёт команде. Вернуться к шагам можно в «Настройки → Начало работы».» 14px/1.6 `var(--n-3)`. Ниже сводка: `grid-template-columns: repeat(2, minmax(0,1fr))`, `gap: 1px` на `var(--border-default)`, ячейка `padding: 12px 16px` фон `var(--surface-card)`: круг 20×20 (`var(--success)` с галочкой 10px, если пройден; иначе `var(--n-7)` пустой) + подпись 13px (пройден — `var(--n-2)`/600, иначе `var(--n-4)`/500). Футер: «Пройти заново» (текст-кнопка) и primary «Перейти в чат» (высота 40, `padding: 0 20px`, `border-radius: 10px`, иконка `arrow` 17px).
### 5. Ссылка в субменю «Настроек»
В `SettingsPage.tsx`, в блоке `links` (после «Аудит действий»): разделитель `height: 1px; margin: 10px 6px 8px; background: var(--n-8)`, затем кнопка:
`min-height: 38px`, `padding: 0 11px`, `border: 1px solid var(--primary-border)`, `border-radius: 9px`, фон `var(--primary-bg)`, цвет `var(--primary-text)` 13px/600; иконка `Icon name="sparkles"` 16px, подпись «Начало работы», справа `small` с прогрессом «3/8» 11.5px/700. Hover: `color-mix(in srgb, var(--accent) 16%, var(--surface-card))`. Под кнопкой пояснение `margin: 7px 11px 2px`, 11.5px/1.45 `var(--n-4)`: «Пошаговая настройка установки. Открывается в любой момент.»
### 6. Плавающая пилюля возврата
Показывается, когда онбординг закрыт, тур не активен и пройдены не все шаги. `position: absolute; right: 24px; bottom: 24px; z-index: 20`, высота 44, `padding: 0 18px 0 15px`, `border-radius: 999px`, фон `var(--surface-card)`, `border: 1px solid var(--primary-border)`, цвет `var(--primary-text)` 13px/600, тень `0 10px 28px -8px rgba(16,18,22,.3)`; иконка `sparkles` 17px, подпись «Начало работы», счётчик в `small` (`padding: 1px 7px`, `border-radius: 999px`, фон `var(--primary-bg)`, 11.5px/700). Hover: фон `var(--primary-bg)`.
---
## Содержание 8 шагов (финальные тексты, RU)
Ниже — данные для таблицы шагов. `tour.target` — элемент, который подсвечивает тур; `tour.screen` / `tour.section` — куда предварительно переключиться.
### Шаг 1 — «AI-провайдер» · 3 мин
- **Заголовок:** Подключите AI-провайдера
- **Лид:** Без провайдера агент не ответит ни одному клиенту. Ключ ваш: расходы, лимиты и токены считаются в системе.
- **Путь:** Настройки → AI-провайдер → Добавить провайдера
- **Инструкции:** 1) В сайдбаре откройте «Настройки» и выберите раздел «AI-провайдер». 2) Нажмите «Добавить провайдера»: OpenRouter, Custom (OpenAI-compatible) или локальная модель. 3) Вставьте API-ключ и укажите модель по умолчанию — например openai/gpt-4o-mini. 4) Нажмите «Проверить»: статус должен стать «Подключено».
- **Предупреждение:** Ключа ещё нет? Возьмите «Демо-провайдер без ключа» — он отвечает по знаниям агента, чтобы осмотреться, и меняется на настоящий в любой момент.
- **Превью:** «Настройки → AI-провайдер»; Провайдер — OpenRouter; Секрет — `sk-or-••••••••`; Модель — openai/gpt-4o-mini; чип «Подключено» (успех)
- **Тур:** `screen: settings`, `section: ai`, `target: settings-ai`; «Настройки → AI-провайдер» — «Раздел в субменю «Настроек». Здесь живут ключи организации: добавление, проверка и статус провайдера.»
### Шаг 2 — «AI-агент» · 4 мин
- **Заголовок:** Создайте AI-агента
- **Лид:** Агент — тот, кто говорит с клиентом: кто он, как отвечает, каким правилам подчиняется и сколько может потратить в день.
- **Путь:** Агенты → Создать агента
- **Инструкции:** 1) Откройте «Агенты» в сайдбаре и нажмите «Создать агента». 2) Опишите роль и тон: кто он для клиента, что можно обещать, чего делать нельзя. 3) Выберите модель подключённого провайдера и дневной бюджет в долларах. 4) Прикрепите статьи базы знаний — агент отвечает только из них. 5) Переведите статус из «Черновик» в «Активен», иначе агент молчит.
- **Предупреждение:** Если данных для ответа нет, агент честно это скажет и предложит позвать сотрудника — придумывать он не станет.
- **Превью:** «Агенты → Консультант»; Модель — openai/gpt-4o-mini; Дневной бюджет — $5.00; Знания — 12 статей; чип «Активен» (успех)
- **Тур:** `screen: chat`, `target: nav-agents`; «Раздел «Агенты»» — «Список агентов организации. Карточка агента — это его роль, модель, бюджет и прикреплённые знания.»
### Шаг 3 — «База знаний» · от 10 мин
- **Заголовок:** Наполните базу знаний
- **Лид:** Это единственный источник ответов агента. Поиск идёт по смыслу, поэтому пишите так, как спрашивают клиенты.
- **Путь:** База знаний → Создать статью
- **Инструкции:** 1) Откройте «База знаний» и создайте категории под темы обращений. 2) Добавьте статьи: вопрос в заголовке, короткий однозначный ответ в тексте. 3) Готовые материалы залейте через «Импорт» — файлами, без ручного переноса. 4) Проверьте на карточке агента, что статьи к нему прикреплены.
- **Предупреждение:** Правка статьи меняет ответы бота сразу, и ту же статью читает публичный портал поддержки — держать два текста не нужно.
- **Превью:** «База знаний»; Категории — Доставка · Оплата · Возврат; Статей — 12; Поиск — Семантический; чип «Прикреплено к агенту» (успех)
- **Тур:** `screen: chat`, `target: nav-knowledge`; «Раздел «База знаний»» — «Статьи, категории и импорт. Отсюда агент берёт ответы, отсюда же их читает портал поддержки.»
### Шаг 4 — «Точка входа» · 5 мин
- **Заголовок:** Подключите точку входа
- **Лид:** Точка входа — канал, по которому приходит клиент: Telegram, MAX, почта или чат на сайте. Без неё писать некуда.
- **Путь:** Настройки → Интеграции → Добавить подключение
- **Инструкции:** 1) Настройки → «Интеграции» → «Добавить подключение». 2) Для бота вставьте токен: Telegram — от BotFather, MAX — из кабинета разработчика. 3) Для почты укажите IMAP и SMTP ящика и пароль приложения. 4) Привяжите подключение к созданному агенту — иначе сообщения некому обрабатывать. 5) Нажмите «Проверить» и убедитесь в статусе «Подключено».
- **Предупреждение:** Сообщения из Telegram и MAX забирает фоновый рабочий. Если он остановлен, статус останется «Подключено», а переписки не появятся.
- **Превью:** «Настройки → Интеграции»; Подключение — Telegram · @romashka_bot; Агент — Консультант; Секрет — `78••••••••`; чип «Подключено» (успех)
- **Тур:** `screen: settings`, `section: integrations`, `target: settings-integrations`; «Настройки → Интеграции» — «Боты, почта и веб-виджет. Красная точка у пункта означает, что какое-то подключение отвалилось.»
### Шаг 5 — «Виджет на сайт» · 3 мин
- **Заголовок:** Поставьте чат на сайт
- **Лид:** Веб-виджет — один тег в вашем шаблоне. Чат открывается в изолированном окне поверх сайта.
- **Путь:** Настройки → Интеграции → Web-виджет
- **Инструкции:** 1) Создайте подключение «Web-виджет» и оформите его: заголовок, акцент, приветствие. 2) Заполните разрешённые домены — пустой список запрещает все сайты. 3) Скопируйте сниппет и вставьте его перед закрывающим body на своём сайте. 4) Переведите виджет в статус «Опубликован» и обновите страницу сайта.
- **Предупреждение:** Ключ в data-widget-key должен совпадать с ключом из настроек виджета — при несовпадении чат просто не появится.
- **Превью:** «Сниппет для сайта»; Разрешённые домены — romashka.ru, *.romashka.ru; чип «Опубликован» (успех); блок кода — тег `chat-widget.js` с `data-widget-key` (брать из `webWidgetSnippet()` в `features/integrations/model.ts`)
- **Тур:** `screen: settings`, `section: integrations`, `target: settings-integrations`; «Настройки → Интеграции» — «Виджет живёт среди подключений: там его ключ, разрешённые домены и готовый тег для вставки.»
### Шаг 6 — «Сотрудники» · 4 мин
- **Заголовок:** Пригласите команду
- **Лид:** Сложные вопросы агент передаёт людям. Заведите тех, кому он будет их отдавать, и решите, кто что видит.
- **Путь:** Сотрудники → Пригласить
- **Инструкции:** 1) Откройте «Сотрудники» и отправьте приглашения по email. 2) Назначьте роли: владелец и администратор видят всё, сотрудник работает только в чате. 3) В Настройки → «Группы» распределите диалоги по группам видимости. 4) Предложите команде включить вход по коду (TOTP) в своём профиле.
- **Предупреждение:** Приглашения уходят почтой. Пока не настроен SMTP (шаг 7), письма не дойдут — пароль придётся задавать вручную.
- **Превью:** «Сотрудники»; Приглашены — 3 человека; Группы — Первая линия · Биллинг; Роли — Админ · Сотрудник; чип «Ожидают входа» (**ожидание**, жёлтый)
- **Тур:** `screen: chat`, `target: nav-employees`; «Раздел «Сотрудники»» — «Приглашения, роли и группы. Сотрудник видит диалоги своих групп и те, за которые отвечает.»
### Шаг 7 — «Домен и почта» · 5 мин
- **Заголовок:** Укажите домен и почту
- **Лид:** Пока установка открывается по IP — без HTTPS и без писем. Домен и SMTP превращают её в рабочий сервис.
- **Путь:** Настройки → Платформа
- **Инструкции:** 1) Настройки → «Платформа»: введите домен установки и переключитесь на https. 2) Убедитесь, что DNS домена смотрит на сервер, а порты 80 и 443 открыты снаружи. 3) Заполните исходящую почту: хост, порт, шифрование, логин и пароль приложения. 4) Нажмите «Отправить тестовое письмо» и проверьте, что оно пришло.
- **Предупреждение:** Сертификат выдаётся по первому запросу к домену: откройте его в браузере и подождите несколько секунд.
- **Превью:** «Настройки → Платформа»; Адрес — https://support.romashka.ru; SMTP — smtp.yandex.ru : 465; Сертификат — Let's Encrypt; чип «HTTPS включён» (успех)
- **Тур:** `screen: settings`, `section: platform`, `target: settings-platform`; «Настройки → Платформа» — «Свойства самой установки: адрес, исходящая почта, обновления. Раздел виден администратору установки.»
- **Важно:** раздел «Платформа» есть только у администратора установки (`user.isInstanceAdmin`, см. `visibleSettingsSections`). Для остальных шаг скрыть и считать общее число шагов динамически (7 вместо 8).
### Шаг 8 — «Проверка» · 2 мин
- **Заголовок:** Напишите боту как клиент
- **Лид:** Последний шаг — пройти путь клиента целиком: сообщение, ответ агента и передача на человека.
- **Путь:** Чат → Все диалоги
- **Инструкции:** 1) Напишите своему боту или в виджет на сайте вопрос из базы знаний. 2) Откройте «Чат»: диалог появится в списке с каналом, агентом и таймером ожидания. 3) Проверьте ответ агента — он должен опираться на вашу статью. 4) Нажмите «Взять диалог» и ответьте сами: так работает передача на оператора.
- **Предупреждение:** Три режима диалога — отвечает AI, отвечает сотрудник, пауза — переключаются в шапке переписки в любой момент.
- **Превью:** «Чат → Все диалоги»; Диалог — Анна Смирнова · Telegram; Агент — Консультант; Режим — Отвечает AI; чип «Ответ за 4 сек» (успех)
- **Тур:** `screen: chat`, `target: chat-list`; «Список диалогов» — «Все обращения организации в одном окне: канал, агент, время ожидания, метки и группа.»
---
## Interactions & Behavior
- **Показ при загрузке:** после успешного входа менеджера (`isManager(user)`) и получения состояния онбординга — если он не пройден и не закрыт, открыть в режиме `welcome`. Сотрудникам (`EMPLOYEE`) не показывать.
- **«Начать настройку»** → `view: "steps"`, шаг 0.
- **«Далее»** → отмечает текущий шаг как пройденный и переходит к следующему; на последнем — `view: "done"`.
- **«Назад»** → предыдущий шаг; с первого — обратно в `welcome`.
- **Клик по строке рейки** → переход на этот шаг (свободная навигация, без блокировок).
- **«Показать где»** → переключить экран/раздел за окном, скрыть модалку, замерить цель, показать тур. «Понятно, к шагам» или клик по подложке → вернуться к модалке на том же шаге.
- **«Открыть раздел»** → закрыть модалку и перейти на нужный экран/раздел настроек. Возврат — пилюля в правом нижнем углу или ссылка в «Настройках».
- **«×» / «Пройду позже» / «Закрыть и настроить самому»** → закрыть и записать «закрыто» на сервере. Онбординг больше не открывается автоматически; открывается только по ссылке/пилюле.
- **Прогресс шагов** должен подхватываться автоматически по факту (как сейчас `/api/v1/company/launch-checklist/`), а не только по нажатию «Далее»: провайдер создан, агент активен, статьи есть, подключение привязано, виджет опубликован, сотрудник приглашён, домен и SMTP заданы, входящий диалог получен. Ручная отметка «Далее» — дополнение, не замена.
- **Анимации:** `ob-fade` 200–220ms ease-out (оверлей), `ob-rise` 280–320ms `cubic-bezier(.2,.8,.2,1)` (карточка, `translateY(14px) scale(.985)` → норма), `ob-pulse` 1.9s ease-out infinite (рамка тура), прогресс `width 280ms ease`. Уважать `prefers-reduced-motion` — в репозитории уже есть такой блок в `styles/base.css`.
- **Клавиатура:** Esc закрывает тур, затем модалку; ←/→ переключают шаги; фокус-ловушка внутри модалки; `aria-modal="true"`, `role="dialog"`, `aria-labelledby` на заголовок шага.
- **Responsive:** экраны вне чата держат `min-width: 1024px` (см. `.hub-shell:not(.is-chat-route)`), поэтому визард рассчитан на десктоп. На ≤900px: рейку скрыть, оставив строку «Шаг N из 8» и полосу прогресса; карточку растянуть на `calc(100% - 32px)`; кнопки футера переносить в два ряда.
## State Management
Клиентское состояние (один хук `useOnboarding`):
| Поле | Тип | Назначение |
|---|---|---|
| `view` | `"welcome" \| "steps" \| "done" \| null` | что показано; `null` — закрыто |
| `index` | `number` | текущий шаг |
| `progress` | `Record<StepKey, boolean>` | серверный факт выполнения |
| `tour` | `{ cfg, x, y, w, h, rootW, rootH } \| null` | активная подсветка, координаты **относительно корня** |
Серверное состояние — расширить существующий эндпоинт `/api/v1/company/launch-checklist/` (`apps/backend/chatballs/...`) или добавить `/api/v1/company/onboarding/`:
- `GET` → `{ steps: { providerConnected, agentActive, knowledgeFilled, connectionBound, widgetPublished, employeeInvited, platformConfigured, firstConversation }, dismissedAt, completedAt }`
- `POST /dismiss` → ставит `dismissedAt = now()` для текущего пользователя в текущей организации.
- Миграция: `onboarding_dismissed_at TIMESTAMPTZ NULL` на членстве пользователя в организации (не на организации — иначе один админ закроет для всех). `NULL` по умолчанию, поэтому **существующие пользователи увидят онбординг при следующем входе** — это и требовалось.
- Старый ключ `localStorage` `chatballs.launch.<orgId>.hidden` больше не используется; можно один раз прочитать его при миграции состояния, но проще игнорировать.
## Design Tokens
Всё из `apps/internal-ui/src/styles/base.css`, новых значений не добавлялось:
- Поверхности: `--surface-card`, `--surface-sidebar`, `--surface-feed`, `--surface-page`, `--n-10`, `--n-11`
- Нейтрали: `--n-1 … --n-11` (текст `--n-1/--n-2/--n-3/--n-4/--n-5`, рамки `--n-6/--n-7/--n-8`)
- Акцент: `--accent` (по умолчанию `#1677ff`), `--primary`, `--primary-hover`, `--primary-bg`, `--primary-border`, `--primary-text`
- Статусы: `--success`, `--success-bg`, `--success-border`, `--success-text`; `--warning`, `--warning-bg`, `--warning-border`, `--warning-text`
- Рамки/радиусы: `--border-default`, `--radius-md: 8px`, `--radius-2xl: 12px`, `--radius-pill: 999px`; в онбординге дополнительно 9px, 10px, 11px, 13px, 14px, 18px
- Тени: `--shadow-sm`, `--shadow-lg`; **новые две переменные для модалки** (добавить в `base.css` рядом с остальными):
- `--modal-shadow: 0 32px 80px -12px rgba(16,18,22,.38), 0 8px 24px rgba(16,18,22,.12)`; тёмная тема — `0 32px 80px -12px rgba(0,0,0,.7), 0 8px 24px rgba(0,0,0,.4)`
- `--scrim: rgba(16,18,22,.55)`; тёмная тема — `rgba(4,5,7,.68)`
- Моно: `--font-mono`
- Типографика: системный стек из `body`; размеры 11 / 11.5 / 12 / 12.5 / 13 / 13.5 / 14 / 14.5 / 23 / 25 / 29px, веса 500 / 600 / 700, `letter-spacing` −0.022em…−0.025em на заголовках, `.08em`/`.1em` на надзаголовках
- Отступы: шаг 4px (4, 6, 7, 8, 9, 10, 11, 12, 14, 18, 20, 22, 24, 26, 32, 34, 40, 44, 48)
## Assets
Только существующие: `LogoIcon` и `Icon` из `apps/internal-ui/src/shared/icons.tsx` — используются `close`, `check`, `arrow`, `chevronLeft`, `chevronRight`, `eye`, `alert`, `sparkles`, `plug`, `robot`, `book`, `team`, `globe`, `message`, `settings`, `building`, `mic`, `database`, `sun`, `list`, `external`, `plus`, `search`, `sort`, `collapseLeft`, `more`, `user`. Своих SVG не рисовать. Картинок нет.
## Файлы, которые затрагивает реализация
Удалить:
- `apps/internal-ui/src/layout/LaunchChecklist.tsx`
- блок стилей `.launch-checklist*` в `apps/internal-ui/src/layout/styles.css`
- импорт и рендер `<LaunchChecklist />` в `apps/internal-ui/src/layout/Sidebar.tsx`
Добавить:
- `apps/internal-ui/src/features/onboarding/OnboardingOverlay.tsx` (welcome / steps / done)
- `apps/internal-ui/src/features/onboarding/OnboardingTour.tsx` (скрим, рамка, колаут, замер)
- `apps/internal-ui/src/features/onboarding/OnboardingLauncher.tsx` (пилюля)
- `apps/internal-ui/src/features/onboarding/steps.ts` (таблица шагов из этого README, тексты через `t()`)
- `apps/internal-ui/src/features/onboarding/useOnboarding.ts`, `api.ts`, `styles.css` (+ `@import` в `src/styles.css`)
Изменить:
- `apps/internal-ui/src/layout/Shell.tsx` — монтировать оверлей, тур и пилюлю для `isManager(user)`
- `apps/internal-ui/src/layout/Sidebar.tsx` — `data-target="nav-chat|nav-agents|nav-employees|nav-knowledge|nav-settings"` на пунктах
- `apps/internal-ui/src/features/settings/SettingsPage.tsx` — `data-target="settings-ai|settings-integrations|settings-platform"` на пунктах субменю + ссылка «Начало работы» в блоке `links`
- `apps/internal-ui/src/features/conversations/DialogList.tsx` — `data-target="chat-list"` на корневой секции
- `apps/internal-ui/src/styles/base.css` — `--modal-shadow`, `--scrim` (обе темы)
- `apps/internal-ui/src/i18n/ru.ts` и `en.ts` — ключи `onboarding.*` (парность проверяет `i18n/catalog.test.ts`); RU-тексты выше, EN — перевод
- backend: эндпоинт состояния онбординга + миграция `onboarding_dismissed_at`
## Files in this bundle
- `Онбординг Chatballs.dc.html` — дизайн-референс: приветствие, 8 шагов, тур, финальный экран, ссылка в «Настройках», пилюля. Открывается в браузере. Внутри — переключатели темы (`theme: light|dark`) и акцента (`accent`).
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,815 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<script src="./support.js"></script>
</head>
<body>
<x-dc>
<helmet>
<style>
:root {
--n-1: #1f1f1f; --n-2: #262626; --n-3: #595959; --n-4: #8c8c8c; --n-5: #bfbfbf;
--n-6: #d9d9d9; --n-7: #e8e8e8; --n-8: #f0f0f0; --n-9: #f5f5f5; --n-10: #fafafa; --n-11: #f0f2f5;
--surface-page: var(--n-11); --surface-card: #ffffff; --surface-sidebar: #fafafa; --surface-feed: #f5f6f8;
--accent: #1677ff;
--primary: var(--accent);
--primary-hover: color-mix(in srgb, var(--accent) 88%, black);
--primary-bg: color-mix(in srgb, var(--accent) 10%, var(--surface-card));
--primary-border: color-mix(in srgb, var(--accent) 40%, var(--surface-card));
--primary-text: color-mix(in srgb, var(--accent) 82%, black);
--warning-text: #d48806;
--warning-bg: color-mix(in srgb, #faad14 11%, var(--surface-card));
--warning-border: color-mix(in srgb, #faad14 36%, var(--surface-card));
--success: #52c41a; --success-text: #237a3f;
--success-bg: color-mix(in srgb, #52c41a 11%, var(--surface-card));
--success-border: color-mix(in srgb, #52c41a 31%, var(--surface-card));
--text-heading: var(--n-1); --text-body: var(--n-2); --text-tertiary: var(--n-4);
--border-default: var(--n-8);
--shadow-sm: 0 1px 3px rgba(0,0,0,.04); --shadow-lg: 0 6px 20px rgba(0,0,0,.12);
--font-mono: "SF Mono", ui-monospace, Menlo, monospace;
--scrim: rgba(16, 18, 22, .55);
--modal-shadow: 0 32px 80px -12px rgba(16,18,22,.38), 0 8px 24px rgba(16,18,22,.12);
}
:root[data-theme="dark"] {
--n-1: #d6d7db; --n-2: #c3c5ca; --n-3: #9a9ca3; --n-4: #767881; --n-5: #5b5d66;
--n-6: #45474f; --n-7: #37393f; --n-8: #2d2f35; --n-9: #27292e; --n-10: #212327; --n-11: #101113;
--surface-page: #101113; --surface-card: #1b1c1f; --surface-sidebar: #181a1d; --surface-feed: #15161a;
--primary: color-mix(in srgb, var(--accent) 84%, var(--surface-card));
--primary-hover: color-mix(in srgb, var(--accent) 92%, white);
--primary-bg: color-mix(in srgb, var(--accent) 16%, var(--surface-card));
--primary-border: color-mix(in srgb, var(--accent) 40%, var(--surface-card));
--primary-text: color-mix(in srgb, var(--accent) 62%, white);
--warning-text: #d9a441;
--warning-bg: color-mix(in srgb, #faad14 12%, var(--surface-card));
--success-text: #5fbf7a;
--success-bg: color-mix(in srgb, #52c41a 12%, var(--surface-card));
--success-border: color-mix(in srgb, #52c41a 32%, var(--surface-card));
--shadow-lg: 0 6px 20px rgba(0,0,0,.5);
--scrim: rgba(4, 5, 7, .68);
--modal-shadow: 0 32px 80px -12px rgba(0,0,0,.7), 0 8px 24px rgba(0,0,0,.4);
}
* { box-sizing: border-box; }
html, body { margin: 0; min-height: 100%; }
body {
background: var(--surface-page);
color: var(--text-body);
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
-webkit-font-smoothing: antialiased;
}
button { cursor: pointer; font: inherit; }
button:focus { outline: none; }
a { color: var(--primary-text); text-decoration: none; }
a:hover { color: var(--primary); text-decoration: underline; }
@keyframes ob-rise { from { opacity: 0; transform: translateY(14px) scale(.985); } to { opacity: 1; transform: none; } }
@keyframes ob-fade { from { opacity: 0; } to { opacity: 1; } }
@keyframes ob-pulse { 0%, 100% { opacity: .5; transform: scale(1); } 50% { opacity: 0; transform: scale(1.45); } }
</style>
</helmet>
<div data-ob-root="true" style="position: relative; width: 100%; height: 900px; min-width: 1024px; display: flex; overflow: hidden; background: var(--n-11);">
<aside style="position: relative; width: 220px; height: 100%; flex: none; display: flex; flex-direction: column; background: var(--surface-sidebar); border-right: 1px solid var(--n-8);">
<div style="width: 100%; height: 56px; flex: none; display: flex; align-items: center; gap: 10px; padding: 14px 14px 10px;">
<span style="width: 28px; height: 28px; flex: none; display: flex; align-items: center; justify-content: center; color: var(--n-1);">
<svg viewBox="0 0 100 100" width="26" height="26" fill="currentColor" aria-hidden="true"><defs><mask id="cb-logo-mask"><rect width="100" height="100" fill="white"></rect><circle cx="50" cy="24.54" r="9" fill="black"></circle><circle cx="50" cy="75.46" r="9" fill="black"></circle></mask></defs><g mask="url(#cb-logo-mask)"><circle cx="29" cy="50" r="24"></circle><circle cx="71" cy="50" r="24"></circle><rect x="44.2" y="31.4" width="11.6" height="37.2"></rect></g></svg>
</span>
<button type="button" style="flex: 1; min-width: 0; height: 32px; display: flex; align-items: center; justify-content: space-between; gap: 6px; padding: 0 6px 0 8px; border: 0; border-radius: 8px; background: transparent; color: var(--n-1); font-size: 14px; font-weight: 700; text-align: left;" style-hover="background: var(--n-9);">
<span style="min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;">Ромашка</span>
<svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" style="flex: none; color: var(--n-4);"><polyline points="6 9 12 15 18 9"></polyline></svg>
</button>
</div>
<nav style="flex: 1; display: flex; flex-direction: column; gap: 2px; overflow-y: auto; padding: 4px 10px 8px;">
<button type="button" data-target="nav-chat" onClick="{{ goChat }}" style="{{ navChatStyle }}">
<svg viewBox="0 0 24 24" width="17" height="17" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2Z"></path></svg>
Чат
</button>
<button type="button" style="position: relative; width: 100%; display: flex; align-items: center; flex: none; gap: 10px; height: 36px; padding: 0 10px; color: var(--n-2); background: transparent; border: none; border-radius: 8px; font-size: 13.5px; font-weight: 500; text-align: left;" style-hover="background: var(--n-9);">
<svg viewBox="0 0 24 24" width="17" height="17" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"></path><circle cx="12" cy="7" r="4"></circle></svg>
Контакты
</button>
<button type="button" data-target="nav-agents" style="position: relative; width: 100%; display: flex; align-items: center; flex: none; gap: 10px; height: 36px; padding: 0 10px; color: var(--n-2); background: transparent; border: none; border-radius: 8px; font-size: 13.5px; font-weight: 500; text-align: left;" style-hover="background: var(--n-9);">
<svg viewBox="0 0 24 24" width="17" height="17" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M6 6a2 2 0 0 1 2-2h8a2 2 0 0 1 2 2v4a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2zM12 2v2M9 12v9M15 12v9M9 18h6M10 8v.01M14 8v.01"></path></svg>
Агенты
</button>
<button type="button" data-target="nav-employees" style="position: relative; width: 100%; display: flex; align-items: center; flex: none; gap: 10px; height: 36px; padding: 0 10px; color: var(--n-2); background: transparent; border: none; border-radius: 8px; font-size: 13.5px; font-weight: 500; text-align: left;" style-hover="background: var(--n-9);">
<svg viewBox="0 0 24 24" width="17" height="17" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2M13 7a4 4 0 1 1-8 0 4 4 0 0 1 8 0zM23 21v-2a4 4 0 0 0-3-3.87M16 3.13a4 4 0 0 1 0 7.75"></path></svg>
Сотрудники
</button>
<button type="button" style="position: relative; width: 100%; display: flex; align-items: center; flex: none; gap: 10px; height: 36px; padding: 0 10px; color: var(--n-2); background: transparent; border: none; border-radius: 8px; font-size: 13.5px; font-weight: 500; text-align: left;" style-hover="background: var(--n-9);">
<svg viewBox="0 0 24 24" width="17" height="17" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M12 22a10 10 0 1 0 0-20 10 10 0 0 0 0 20zM2 12h20M12 2a14.5 14.5 0 0 0 0 20 14.5 14.5 0 0 0 0-20"></path></svg>
Порталы
</button>
<button type="button" data-target="nav-knowledge" style="position: relative; width: 100%; display: flex; align-items: center; flex: none; gap: 10px; height: 36px; padding: 0 10px; color: var(--n-2); background: transparent; border: none; border-radius: 8px; font-size: 13.5px; font-weight: 500; text-align: left;" style-hover="background: var(--n-9);">
<svg viewBox="0 0 24 24" width="17" height="17" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M4 19.5A2.5 2.5 0 0 1 6.5 17H20M4 19.5A2.5 2.5 0 0 0 6.5 22H20V2H6.5A2.5 2.5 0 0 0 4 4.5z"></path></svg>
База знаний
</button>
<button type="button" data-target="nav-settings" onClick="{{ goSettings }}" style="{{ navSettingsStyle }}">
<svg viewBox="0 0 24 24" width="17" height="17" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><line x1="21" y1="6" x2="9" y2="6"></line><line x1="3" y1="6" x2="5" y2="6"></line><circle cx="7" cy="6" r="2"></circle><line x1="21" y1="12" x2="13" y2="12"></line><line x1="3" y1="12" x2="9" y2="12"></line><circle cx="11" cy="12" r="2"></circle><line x1="21" y1="18" x2="15" y2="18"></line><line x1="3" y1="18" x2="11" y2="18"></line><circle cx="13" cy="18" r="2"></circle></svg>
Настройки
</button>
</nav>
<button type="button" style="position: relative; width: 100%; display: flex; align-items: center; gap: 10px; padding: 12px 14px; color: var(--n-3); background: transparent; border: none; border-top: 1px solid var(--n-8); text-align: left;" style-hover="background: var(--n-9);">
<span style="position: relative; flex: none; display: inline-flex;">
<span style="width: 34px; height: 34px; display: inline-flex; align-items: center; justify-content: center; flex: none; color: #ffffff; background: var(--primary); border-radius: 50%; font-size: 13px; font-weight: 600;">ИП</span>
<i style="position: absolute; right: -1px; bottom: -1px; width: 10px; height: 10px; border: 2px solid var(--surface-sidebar); border-radius: 50%; background: var(--success);"></i>
</span>
<span style="min-width: 0; flex: 1;">
<strong style="display: block; overflow: hidden; color: var(--n-1); font-size: 13px; font-weight: 600; line-height: 1.3; text-overflow: ellipsis; white-space: nowrap;">Игорь Петров</strong>
<small style="display: block; overflow: hidden; color: var(--n-4); font-size: 11.5px; line-height: 1.3; text-overflow: ellipsis; white-space: nowrap;">igor@romashka.ru</small>
</span>
<span style="flex: none; display: inline-flex; align-items: center; justify-content: center; width: 28px; height: 28px; color: var(--n-4); border-radius: 7px;">
<svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="5" r="1"></circle><circle cx="12" cy="12" r="1"></circle><circle cx="12" cy="19" r="1"></circle></svg>
</span>
</button>
</aside>
<div style="min-width: 0; height: 100%; display: flex; flex: 1; flex-direction: column;">
<sc-if value="{{ onChat }}" hint-placeholder-val="{{ true }}">
<main style="min-height: 0; flex: 1; padding: 0; overflow: hidden;">
<div style="height: 100%; min-width: 0; min-height: 0; display: flex; overflow: hidden;">
<section data-target="chat-list" style="position: relative; width: 323px; min-height: 0; display: flex; flex: none; flex-direction: column; background: var(--surface-card); border-right: 1px solid var(--n-8);">
<div style="flex: none; padding: 13px 16px 10px; border-bottom: 1px solid var(--n-8);">
<div style="display: flex; align-items: center; justify-content: space-between; gap: 8px; margin-bottom: 10px;">
<h2 style="display: flex; align-items: center; gap: 6px; margin: 0; color: var(--n-1); font-size: 16px; font-weight: 700; white-space: nowrap;">Диалоги<small style="color: var(--n-4); font-size: 12px; font-weight: 500;">0</small></h2>
<span style="display: flex; flex: none; gap: 4px;">
<button type="button" style="width: 28px; height: 28px; display: inline-flex; align-items: center; justify-content: center; border: 0; border-radius: 7px; color: var(--n-4); background: transparent;" style-hover="background: var(--n-9); color: var(--n-2);"><svg viewBox="0 0 24 24" width="15" height="15" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M11 5h10"></path><path d="M11 9h7"></path><path d="M11 13h4"></path><path d="M3 17l3 3 3-3"></path><path d="M6 18V4"></path></svg></button>
<button type="button" style="width: 28px; height: 28px; display: inline-flex; align-items: center; justify-content: center; border: 0; border-radius: 7px; color: var(--n-4); background: transparent;" style-hover="background: var(--n-9); color: var(--n-2);"><svg viewBox="0 0 24 24" width="15" height="15" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="4" width="18" height="16" rx="2"></rect><path d="M9 4v16"></path><path d="m15 10-2 2 2 2"></path></svg></button>
</span>
</div>
<label style="display: flex; align-items: center; gap: 8px; height: 34px; padding: 0 11px; border: 1px solid var(--n-7); border-radius: 8px; background: var(--n-10); color: var(--n-5);">
<svg viewBox="0 0 24 24" width="15" height="15" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" style="flex: none;"><circle cx="11" cy="11" r="8"></circle><line x1="21" y1="21" x2="16.65" y2="16.65"></line></svg>
<input placeholder="Поиск по контакту, сообщению…" style="width: 100%; min-width: 0; padding: 0; border: 0; outline: 0; background: transparent; color: var(--n-2); font-family: inherit; font-size: 13px;" />
<kbd style="flex: none; padding: 1px 5px; border: 1px solid var(--n-7); border-radius: 4px; background: var(--surface-card); color: var(--n-4); font-size: 10.5px; line-height: 1.4;">/</kbd>
</label>
</div>
<div style="display: flex; flex: none; gap: 2px; padding: 0 10px; border-bottom: 1px solid var(--n-8);">
<button type="button" style="flex: none; display: inline-flex; align-items: center; gap: 5px; padding: 9px 8px 8px; color: var(--primary-text); background: transparent; border: 0; border-bottom: 2px solid var(--primary-text); margin-bottom: -1px; font-size: 12.5px; font-weight: 600; line-height: 14px;">Все</button>
<button type="button" style="flex: none; display: inline-flex; align-items: center; gap: 5px; padding: 9px 8px 8px; color: var(--n-4); background: transparent; border: 0; border-bottom: 2px solid transparent; margin-bottom: -1px; font-size: 12.5px; font-weight: 500; line-height: 14px;">Мои</button>
<button type="button" style="flex: none; display: inline-flex; align-items: center; gap: 5px; padding: 9px 8px 8px; color: var(--n-4); background: transparent; border: 0; border-bottom: 2px solid transparent; margin-bottom: -1px; font-size: 12.5px; font-weight: 500; line-height: 14px;">Ждут оператора</button>
</div>
<div style="flex: 1; min-height: 0; overflow-y: auto;">
<div style="padding: 64px 28px; text-align: center;">
<span style="width: 44px; height: 44px; display: inline-flex; align-items: center; justify-content: center; margin-bottom: 12px; color: var(--n-5); background: var(--n-10); border-radius: 50%;"><svg viewBox="0 0 24 24" width="20" height="20" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2Z"></path></svg></span>
<p style="margin: 0; color: var(--n-3); font-size: 13.5px; font-weight: 600; line-height: 1.45;">Диалоги появятся, когда клиенты напишут</p>
</div>
</div>
</section>
<section style="position: relative; min-width: 320px; min-height: 0; display: flex; flex: 1; flex-direction: column; background: var(--surface-feed);">
<div style="flex: 1; display: flex; align-items: center; justify-content: center; color: var(--n-5); font-size: 13px;">Выберите диалог</div>
</section>
</div>
</main>
</sc-if>
<sc-if value="{{ onSettings }}">
<main style="min-height: 0; flex: 1; padding: 0; overflow: hidden;">
<div style="height: 100%; min-width: 0; min-height: 0; display: flex; overflow: hidden;">
<nav style="width: 240px; flex: none; display: flex; flex-direction: column; min-height: 0; background: var(--surface-card); border-right: 1px solid var(--n-8);">
<div style="flex: none; padding: 17px 18px 10px;"><h2 style="margin: 0; color: var(--n-1); font-size: 16px; font-weight: 700;">Настройки</h2></div>
<div style="flex: 1; min-height: 0; display: flex; flex-direction: column; gap: 1px; padding: 4px 10px; overflow-y: auto;">
<button type="button" style="{{ secOrgStyle }}"><svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round"><path d="M3 21h18M5 21V7l7-4 7 4v14M9 21v-6h6v6M9 10h.01M15 10h.01"></path></svg><span style="flex: 1; min-width: 0;">Организация</span></button>
<button type="button" style="{{ secGroupsStyle }}"><svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round"><path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2M13 7a4 4 0 1 1-8 0 4 4 0 0 1 8 0zM23 21v-2a4 4 0 0 0-3-3.87M16 3.13a4 4 0 0 1 0 7.75"></path></svg><span style="flex: 1; min-width: 0;">Группы</span></button>
<button type="button" data-target="settings-ai" style="{{ secAiStyle }}"><svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round"><path d="M12 3l1.8 4.6L18.4 9.4 13.8 11.2 12 15.8 10.2 11.2 5.6 9.4 10.2 7.6z"></path><path d="M19 16l.8 2.2L22 19l-2.2.8L19 22l-.8-2.2L16 19l2.2-.8z"></path><path d="M5 14l.6 1.6L7.2 16.2 5.6 16.8 5 18.4l-.6-1.6L2.8 16.2l1.6-.6z"></path></svg><span style="flex: 1; min-width: 0;">AI-провайдер</span></button>
<button type="button" data-target="settings-integrations" style="{{ secIntStyle }}"><svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round"><path d="M12 22v-5M9 8V2M15 8V2M18 8v5a6 6 0 0 1-12 0V8z"></path></svg><span style="flex: 1; min-width: 0;">Интеграции</span></button>
<button type="button" style="{{ secCommStyle }}"><svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round"><path d="M12 2a3 3 0 0 0-3 3v7a3 3 0 0 0 6 0V5a3 3 0 0 0-3-3ZM19 10v2a7 7 0 0 1-14 0v-2M12 19v3"></path></svg><span style="flex: 1; min-width: 0;">Голосовые и звонки</span></button>
<button type="button" style="{{ secStorageStyle }}"><svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round"><path d="M4 6c0-1.7 3.6-3 8-3s8 1.3 8 3-3.6 3-8 3-8-1.3-8-3z"></path><path d="M4 6v12c0 1.7 3.6 3 8 3s8-1.3 8-3V6"></path><path d="M4 12c0 1.7 3.6 3 8 3s8-1.3 8-3"></path></svg><span style="flex: 1; min-width: 0;">Хранилище файлов</span></button>
<button type="button" data-target="settings-platform" style="{{ secPlatformStyle }}"><svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round"><path d="M12 22a10 10 0 1 0 0-20 10 10 0 0 0 0 20zM2 12h20M12 2a14.5 14.5 0 0 0 0 20 14.5 14.5 0 0 0 0-20"></path></svg><span style="flex: 1; min-width: 0;">Платформа</span></button>
<button type="button" style="{{ secDemoStyle }}"><svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round"><path d="M12 2v4M12 18v4M4.9 4.9l2.8 2.8M16.3 16.3l2.8 2.8M2 12h4M18 12h4M4.9 19.1l2.8-2.8M16.3 7.7l2.8-2.8"></path></svg><span style="flex: 1; min-width: 0;">Демо-данные</span></button>
<div style="height: 1px; margin: 8px 6px; background: var(--n-8);"></div>
<div style="padding: 2px 10px 6px; color: var(--n-4); font-size: 11px; font-weight: 600; letter-spacing: .04em; text-transform: uppercase;">Отдельные экраны</div>
<button type="button" style="display: flex; align-items: center; gap: 10px; min-height: 34px; padding: 0 10px; border: 0; border-radius: 8px; background: transparent; color: var(--n-2); font-size: 13px; font-weight: 500; text-align: left;" style-hover="background: var(--n-9);">
<svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round"><path d="M8 6h13M8 12h13M8 18h13M3 6h.01M3 12h.01M3 18h.01"></path></svg><span style="flex: 1; min-width: 0;">Аудит действий</span>
<svg viewBox="0 0 24 24" width="13" height="13" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="color: var(--n-5);"><path d="M7 17 17 7M8 7h9v9"></path></svg>
</button>
<div style="height: 1px; margin: 10px 6px 8px; background: var(--n-8);"></div>
<button type="button" data-target="settings-onboarding" onClick="{{ reopen }}" style="display: flex; align-items: center; gap: 10px; min-height: 38px; padding: 0 11px; border: 1px solid var(--primary-border); border-radius: 9px; background: var(--primary-bg); color: var(--primary-text); font-size: 13px; font-weight: 600; text-align: left;" style-hover="background: color-mix(in srgb, var(--accent) 16%, var(--surface-card));">
<svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round"><path d="M12 3l1.8 4.6L18.4 9.4 13.8 11.2 12 15.8 10.2 11.2 5.6 9.4 10.2 7.6z"></path><path d="M19 16l.8 2.2L22 19l-2.2.8L19 22l-.8-2.2L16 19l2.2-.8z"></path><path d="M5 14l.6 1.6L7.2 16.2 5.6 16.8 5 18.4l-.6-1.6L2.8 16.2l1.6-.6z"></path></svg>
<span style="flex: 1; min-width: 0;">Начало работы</span>
<small style="flex: none; font-size: 11.5px; font-weight: 700;">{{ doneLabel }}</small>
</button>
<p style="margin: 7px 11px 2px; color: var(--n-4); font-size: 11.5px; line-height: 1.45;">Пошаговая настройка установки. Открывается в любой момент.</p>
</div>
</nav>
<section style="flex: 1; min-width: 0; min-height: 0; overflow-y: auto; background: var(--surface-feed);">
<div style="max-width: 1000px; margin-inline: auto; padding: 28px 40px 48px;">
<header style="display: flex; align-items: flex-start; justify-content: space-between; gap: 20px; margin-bottom: 22px;">
<div style="min-width: 0;">
<h2 style="margin: 0; color: var(--n-1); font-size: 20px; font-weight: 700; letter-spacing: -.01em;">{{ secHeading }}</h2>
<p style="max-width: 560px; margin: 5px 0 0; color: var(--n-4); font-size: 13px; line-height: 1.5;">{{ secLead }}</p>
</div>
<button type="button" style="height: 34px; flex: none; display: inline-flex; align-items: center; gap: 7px; padding: 0 16px; border: 0; border-radius: 8px; background: var(--primary); color: #fff; font-size: 13px; font-weight: 600;" style-hover="background: var(--primary-hover);">
<svg viewBox="0 0 24 24" width="15" height="15" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><line x1="12" y1="5" x2="12" y2="19"></line><line x1="5" y1="12" x2="19" y2="12"></line></svg>
{{ secAction }}
</button>
</header>
<div style="padding: 22px 24px; background: var(--surface-card); border: 1px solid var(--border-default); border-radius: 12px;">
<div style="display: flex; align-items: center; justify-content: center; min-height: 160px; padding: 0 24px; color: var(--n-3); font-size: 13.5px; text-align: center;">{{ secEmpty }}</div>
</div>
</div>
</section>
</div>
</main>
</sc-if>
</div>
<sc-if value="{{ showWelcome }}">
<div style="position: absolute; inset: 0; z-index: 30; display: flex; align-items: center; justify-content: center; padding: 40px; background: var(--scrim); backdrop-filter: blur(3px); animation: ob-fade 220ms ease-out;">
<div style="width: min(700px, 100%); background: var(--surface-card); border: 1px solid var(--border-default); border-radius: 18px; box-shadow: var(--modal-shadow); overflow: hidden; animation: ob-rise 320ms cubic-bezier(.2,.8,.2,1);">
<div style="position: relative; padding: 44px 48px 34px; background: linear-gradient(180deg, var(--primary-bg), var(--surface-card));">
<button type="button" aria-label="Закрыть" onClick="{{ close }}" style="position: absolute; top: 18px; right: 18px; width: 32px; height: 32px; display: inline-flex; align-items: center; justify-content: center; border: 0; border-radius: 8px; background: transparent; color: var(--n-4);" style-hover="background: var(--n-9); color: var(--n-2);">
<svg viewBox="0 0 24 24" width="17" height="17" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M18 6 6 18M6 6l12 12"></path></svg>
</button>
<span style="width: 46px; height: 46px; display: flex; align-items: center; justify-content: center; margin-bottom: 22px; color: #fff; background: var(--primary); border-radius: 14px; box-shadow: 0 8px 20px color-mix(in srgb, var(--accent) 34%, transparent);">
<svg viewBox="0 0 100 100" width="28" height="28" fill="currentColor" aria-hidden="true"><g mask="url(#cb-logo-mask)"><circle cx="29" cy="50" r="24"></circle><circle cx="71" cy="50" r="24"></circle><rect x="44.2" y="31.4" width="11.6" height="37.2"></rect></g></svg>
</span>
<p style="margin: 0 0 11px; color: var(--primary-text); font-size: 11.5px; font-weight: 700; letter-spacing: .1em; text-transform: uppercase;">Установка готова к настройке</p>
<h1 style="max-width: 520px; margin: 0; color: var(--n-1); font-size: 29px; font-weight: 700; letter-spacing: -.025em; line-height: 1.18; text-wrap: pretty;">Восемь шагов до первого ответа клиенту</h1>
<p style="max-width: 520px; margin: 14px 0 0; color: var(--n-3); font-size: 14.5px; line-height: 1.6; text-wrap: pretty;">Покажем по порядку, что и где настроить: ключ модели, агента, знания, точку входа и команду. На каждом шаге — точный путь в интерфейсе и подсветка нужного элемента на экране.</p>
</div>
<div style="display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 1px; background: var(--border-default); border-top: 1px solid var(--border-default); border-bottom: 1px solid var(--border-default);">
<div style="padding: 17px 20px; background: var(--surface-card);">
<strong style="display: block; color: var(--n-1); font-size: 13.5px; font-weight: 700;">≈ 25 минут</strong>
<small style="display: block; margin-top: 3px; color: var(--n-4); font-size: 12.5px; line-height: 1.45;">Можно прерваться и вернуться</small>
</div>
<div style="padding: 17px 20px; background: var(--surface-card);">
<strong style="display: block; color: var(--n-1); font-size: 13.5px; font-weight: 700;">Свой ключ модели</strong>
<small style="display: block; margin-top: 3px; color: var(--n-4); font-size: 12.5px; line-height: 1.45;">Или демо-провайдер без ключа</small>
</div>
<div style="padding: 17px 20px; background: var(--surface-card);">
<strong style="display: block; color: var(--n-1); font-size: 13.5px; font-weight: 700;">Данные у вас</strong>
<small style="display: block; margin-top: 3px; color: var(--n-4); font-size: 12.5px; line-height: 1.45;">Всё на вашем сервере</small>
</div>
</div>
<div style="display: flex; align-items: center; justify-content: space-between; gap: 16px; padding: 20px 48px 26px;">
<button type="button" onClick="{{ close }}" style="height: 38px; padding: 0 4px; border: 0; background: transparent; color: var(--n-4); font-size: 13px; font-weight: 500;" style-hover="color: var(--n-2);">Пройду позже</button>
<button type="button" onClick="{{ start }}" style="height: 42px; display: inline-flex; align-items: center; gap: 9px; padding: 0 22px; border: 0; border-radius: 10px; background: var(--primary); color: #fff; font-size: 14px; font-weight: 600; box-shadow: 0 6px 16px color-mix(in srgb, var(--accent) 32%, transparent);" style-hover="background: var(--primary-hover);">
Начать настройку
<svg viewBox="0 0 24 24" width="17" height="17" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round"><line x1="5" y1="12" x2="19" y2="12"></line><polyline points="12 5 19 12 12 19"></polyline></svg>
</button>
</div>
</div>
</div>
</sc-if>
<sc-if value="{{ showSteps }}">
<div style="position: absolute; inset: 0; z-index: 30; display: flex; align-items: center; justify-content: center; padding: 34px; background: var(--scrim); backdrop-filter: blur(3px); animation: ob-fade 200ms ease-out;">
<div style="width: min(1020px, 100%); height: min(700px, 100%); display: flex; background: var(--surface-card); border: 1px solid var(--border-default); border-radius: 18px; box-shadow: var(--modal-shadow); overflow: hidden; animation: ob-rise 280ms cubic-bezier(.2,.8,.2,1);">
<div style="width: 300px; flex: none; display: flex; flex-direction: column; background: var(--n-10); border-right: 1px solid var(--border-default);">
<div style="flex: none; padding: 24px 22px 18px;">
<div style="display: flex; align-items: center; gap: 9px; margin-bottom: 18px;">
<span style="width: 24px; height: 24px; flex: none; display: flex; align-items: center; justify-content: center; color: var(--n-1);">
<svg viewBox="0 0 100 100" width="22" height="22" fill="currentColor" aria-hidden="true"><g mask="url(#cb-logo-mask)"><circle cx="29" cy="50" r="24"></circle><circle cx="71" cy="50" r="24"></circle><rect x="44.2" y="31.4" width="11.6" height="37.2"></rect></g></svg>
</span>
<strong style="color: var(--n-1); font-size: 14px; font-weight: 700;">Начало работы</strong>
</div>
<div style="display: flex; align-items: baseline; justify-content: space-between; margin-bottom: 8px;">
<small style="color: var(--n-3); font-size: 12px; font-weight: 600;">{{ progressLabel }}</small>
<small style="color: var(--n-4); font-size: 11.5px; font-weight: 600;">{{ percentLabel }}</small>
</div>
<div style="height: 4px; border-radius: 999px; background: var(--n-8); overflow: hidden;"><i style="{{ progressStyle }}"></i></div>
</div>
<div style="flex: 1; min-height: 0; display: flex; flex-direction: column; gap: 2px; padding: 2px 12px 12px; overflow-y: auto;">
<sc-for list="{{ rail }}" as="item" hint-placeholder-count="8">
<button type="button" onClick="{{ item.onClick }}" style="{{ item.rowStyle }}">
<span style="{{ item.markStyle }}">
<sc-if value="{{ item.isDone }}" hint-placeholder-val="{{ false }}"><svg viewBox="0 0 24 24" width="11" height="11" fill="none" stroke="currentColor" stroke-width="2.6" stroke-linecap="round" stroke-linejoin="round"><path d="M20 6 9 17l-5-5"></path></svg></sc-if>
<sc-if value="{{ item.showNum }}" hint-placeholder-val="{{ true }}">{{ item.num }}</sc-if>
</span>
<span style="{{ item.labelStyle }}">{{ item.label }}</span>
<small style="flex: none; color: var(--n-5); font-size: 11px; font-weight: 500;">{{ item.est }}</small>
</button>
</sc-for>
</div>
<div style="flex: none; padding: 12px 22px 20px; border-top: 1px solid var(--border-default);">
<button type="button" onClick="{{ close }}" style="padding: 0; border: 0; background: transparent; color: var(--n-4); font-size: 12.5px; font-weight: 500;" style-hover="color: var(--n-2);">Закрыть и настроить самому</button>
</div>
</div>
<div style="flex: 1; min-width: 0; display: flex; flex-direction: column;">
<div style="flex: none; display: flex; align-items: center; justify-content: space-between; gap: 12px; padding: 22px 32px 0;">
<span style="display: inline-flex; align-items: center; gap: 8px; height: 24px; padding: 0 10px; border: 1px solid var(--primary-border); border-radius: 999px; background: var(--primary-bg); color: var(--primary-text); font-size: 11.5px; font-weight: 700; letter-spacing: .02em;">{{ eyebrow }}</span>
<button type="button" aria-label="Закрыть" onClick="{{ close }}" style="width: 32px; height: 32px; display: inline-flex; align-items: center; justify-content: center; border: 0; border-radius: 8px; background: transparent; color: var(--n-4);" style-hover="background: var(--n-9); color: var(--n-2);">
<svg viewBox="0 0 24 24" width="17" height="17" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M18 6 6 18M6 6l12 12"></path></svg>
</button>
</div>
<div style="flex: 1; min-height: 0; overflow-y: auto; padding: 14px 32px 26px;">
<h2 style="margin: 0; color: var(--n-1); font-size: 23px; font-weight: 700; letter-spacing: -.022em; line-height: 1.2; text-wrap: pretty;">{{ curTitle }}</h2>
<p style="max-width: 560px; margin: 10px 0 0; color: var(--n-3); font-size: 13.5px; line-height: 1.6; text-wrap: pretty;">{{ curLead }}</p>
<div style="display: flex; align-items: center; flex-wrap: wrap; gap: 6px; margin: 18px 0 0; padding: 8px 11px; border: 1px solid var(--border-default); border-radius: 9px; background: var(--n-10);">
<sc-for list="{{ curPath }}" as="crumb" hint-placeholder-count="3">
<span style="display: inline-flex; align-items: center; gap: 6px; color: var(--n-2); font-family: var(--font-mono); font-size: 11.5px; font-weight: 600;">
{{ crumb.label }}
<sc-if value="{{ crumb.more }}" hint-placeholder-val="{{ true }}"><svg viewBox="0 0 24 24" width="12" height="12" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="color: var(--n-5);"><polyline points="9 6 15 12 9 18"></polyline></svg></sc-if>
</span>
</sc-for>
</div>
<ol style="display: flex; flex-direction: column; gap: 13px; margin: 22px 0 0; padding: 0; list-style: none;">
<sc-for list="{{ curItems }}" as="item" hint-placeholder-count="4">
<li style="display: flex; align-items: flex-start; gap: 12px;">
<span style="width: 21px; height: 21px; flex: none; display: inline-flex; align-items: center; justify-content: center; margin-top: 1px; border: 1px solid var(--primary-border); border-radius: 50%; background: var(--primary-bg); color: var(--primary-text); font-size: 11px; font-weight: 700;">{{ item.n }}</span>
<span style="flex: 1; min-width: 0; color: var(--n-2); font-size: 13.5px; line-height: 1.55; text-wrap: pretty;">{{ item.text }}</span>
</li>
</sc-for>
</ol>
<div style="display: flex; align-items: flex-start; gap: 10px; margin-top: 22px; padding: 12px 14px; border: 1px solid var(--warning-border); border-radius: 10px; background: var(--warning-bg);">
<svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round" style="flex: none; margin-top: 1px; color: var(--warning-text);"><path d="M12 9v4M12 17h.01M10.3 3.9 1.8 18a2 2 0 0 0 1.7 3h17a2 2 0 0 0 1.7-3L13.7 3.9a2 2 0 0 0-3.4 0z"></path></svg>
<p style="margin: 0; flex: 1; color: var(--warning-text); font-size: 12.5px; font-weight: 500; line-height: 1.5; text-wrap: pretty;">{{ curTip }}</p>
</div>
<div style="margin-top: 22px; border: 1px solid var(--border-default); border-radius: 12px; background: var(--n-10); overflow: hidden;">
<div style="display: flex; align-items: center; justify-content: space-between; gap: 10px; padding: 9px 14px; background: var(--surface-card); border-bottom: 1px solid var(--border-default);">
<span style="display: inline-flex; align-items: center; gap: 7px; color: var(--n-4); font-family: var(--font-mono); font-size: 11px; font-weight: 600; letter-spacing: .02em;">
<i style="width: 7px; height: 7px; border-radius: 50%; background: var(--n-6);"></i>
{{ previewCaption }}
</span>
<sc-if value="{{ previewChipOk }}" hint-placeholder-val="{{ true }}">
<span style="display: inline-flex; align-items: center; gap: 5px; height: 21px; padding: 0 9px; border: 1px solid var(--success-border); border-radius: 999px; background: var(--success-bg); color: var(--success-text); font-size: 11px; font-weight: 700;">
<svg viewBox="0 0 24 24" width="11" height="11" fill="none" stroke="currentColor" stroke-width="2.6" stroke-linecap="round" stroke-linejoin="round"><path d="M20 6 9 17l-5-5"></path></svg>
{{ previewChip }}
</span>
</sc-if>
<sc-if value="{{ previewChipWait }}" hint-placeholder-val="{{ false }}">
<span style="display: inline-flex; align-items: center; gap: 5px; height: 21px; padding: 0 9px; border: 1px solid var(--warning-border); border-radius: 999px; background: var(--warning-bg); color: var(--warning-text); font-size: 11px; font-weight: 700;">{{ previewChip }}</span>
</sc-if>
</div>
<div style="display: flex; flex-direction: column; gap: 1px; background: var(--border-default);">
<sc-for list="{{ previewRows }}" as="row" hint-placeholder-count="3">
<div style="display: flex; align-items: center; justify-content: space-between; gap: 16px; padding: 11px 14px; background: var(--n-10);">
<span style="color: var(--n-4); font-size: 12px;">{{ row.label }}</span>
<strong style="min-width: 0; color: var(--n-2); font-size: 12.5px; font-weight: 600; text-align: right;">{{ row.value }}</strong>
</div>
</sc-for>
</div>
<sc-if value="{{ hasCode }}" hint-placeholder-val="{{ false }}">
<div style="padding: 12px 14px; background: var(--surface-card); border-top: 1px solid var(--border-default);">
<code style="display: block; color: var(--n-3); font-family: var(--font-mono); font-size: 11.5px; line-height: 1.6; word-break: break-all;">{{ previewCode }}</code>
</div>
</sc-if>
</div>
</div>
<div style="flex: none; display: flex; align-items: center; justify-content: space-between; gap: 12px; padding: 14px 32px 18px; border-top: 1px solid var(--border-default); background: var(--surface-card);">
<button type="button" onClick="{{ prev }}" style="height: 36px; display: inline-flex; align-items: center; gap: 7px; padding: 0 12px; border: 0; border-radius: 8px; background: transparent; color: var(--n-3); font-size: 13px; font-weight: 500;" style-hover="background: var(--n-9); color: var(--n-2);">
<svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round"><polyline points="15 6 9 12 15 18"></polyline></svg>
Назад
</button>
<div style="display: flex; align-items: center; gap: 8px;">
<button type="button" onClick="{{ showTour }}" style="height: 36px; display: inline-flex; align-items: center; gap: 7px; padding: 0 14px; border: 1px solid var(--n-6); border-radius: 8px; background: var(--surface-card); color: var(--n-3); font-size: 13px; font-weight: 500;" style-hover="background: var(--n-10); color: var(--n-2);">
<svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round"><path d="M2 12s3.6-7 10-7 10 7 10 7-3.6 7-10 7-10-7-10-7zM15 12a3 3 0 1 1-6 0 3 3 0 0 1 6 0z"></path></svg>
Показать где
</button>
<button type="button" onClick="{{ goSection }}" style="height: 36px; display: inline-flex; align-items: center; gap: 7px; padding: 0 14px; border: 1px solid var(--n-6); border-radius: 8px; background: var(--surface-card); color: var(--n-3); font-size: 13px; font-weight: 500;" style-hover="background: var(--n-10); color: var(--n-2);">{{ curGoLabel }}</button>
<button type="button" onClick="{{ next }}" style="height: 36px; display: inline-flex; align-items: center; gap: 8px; padding: 0 18px; border: 0; border-radius: 8px; background: var(--primary); color: #fff; font-size: 13px; font-weight: 600; box-shadow: 0 1px 2px color-mix(in srgb, var(--accent) 30%, transparent);" style-hover="background: var(--primary-hover);">
{{ nextLabel }}
<svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round"><line x1="5" y1="12" x2="19" y2="12"></line><polyline points="12 5 19 12 12 19"></polyline></svg>
</button>
</div>
</div>
</div>
</div>
</div>
</sc-if>
<sc-if value="{{ showDone }}">
<div style="position: absolute; inset: 0; z-index: 30; display: flex; align-items: center; justify-content: center; padding: 40px; background: var(--scrim); backdrop-filter: blur(3px); animation: ob-fade 200ms ease-out;">
<div style="width: min(620px, 100%); background: var(--surface-card); border: 1px solid var(--border-default); border-radius: 18px; box-shadow: var(--modal-shadow); overflow: hidden; animation: ob-rise 280ms cubic-bezier(.2,.8,.2,1);">
<div style="padding: 40px 40px 26px; text-align: left;">
<span style="width: 46px; height: 46px; display: flex; align-items: center; justify-content: center; margin-bottom: 20px; color: #fff; background: var(--success); border-radius: 14px;">
<svg viewBox="0 0 24 24" width="24" height="24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round"><path d="M20 6 9 17l-5-5"></path></svg>
</span>
<h2 style="margin: 0; color: var(--n-1); font-size: 25px; font-weight: 700; letter-spacing: -.022em; line-height: 1.2;">Всё готово — можно принимать клиентов</h2>
<p style="margin: 12px 0 0; color: var(--n-3); font-size: 14px; line-height: 1.6; text-wrap: pretty;">Агент отвечает по вашим знаниям, сложное передаёт команде. Вернуться к шагам можно в «Настройки → Начало работы».</p>
</div>
<div style="display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 1px; background: var(--border-default); border-top: 1px solid var(--border-default); border-bottom: 1px solid var(--border-default);">
<sc-for list="{{ doneSummary }}" as="item" hint-placeholder-count="8">
<div style="display: flex; align-items: center; gap: 9px; padding: 12px 16px; background: var(--surface-card);">
<span style="{{ item.markStyle }}">
<sc-if value="{{ item.isDone }}" hint-placeholder-val="{{ true }}"><svg viewBox="0 0 24 24" width="10" height="10" fill="none" stroke="currentColor" stroke-width="2.8" stroke-linecap="round" stroke-linejoin="round"><path d="M20 6 9 17l-5-5"></path></svg></sc-if>
</span>
<span style="{{ item.labelStyle }}">{{ item.label }}</span>
</div>
</sc-for>
</div>
<div style="display: flex; align-items: center; justify-content: space-between; gap: 16px; padding: 20px 40px 26px;">
<button type="button" onClick="{{ start }}" style="padding: 0; border: 0; background: transparent; color: var(--n-4); font-size: 13px; font-weight: 500;" style-hover="color: var(--n-2);">Пройти заново</button>
<button type="button" onClick="{{ close }}" style="height: 40px; display: inline-flex; align-items: center; gap: 9px; padding: 0 20px; border: 0; border-radius: 10px; background: var(--primary); color: #fff; font-size: 14px; font-weight: 600;" style-hover="background: var(--primary-hover);">
Перейти в чат
<svg viewBox="0 0 24 24" width="17" height="17" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round"><line x1="5" y1="12" x2="19" y2="12"></line><polyline points="12 5 19 12 12 19"></polyline></svg>
</button>
</div>
</div>
</div>
</sc-if>
<sc-if value="{{ showLauncher }}" hint-placeholder-val="{{ false }}">
<button type="button" onClick="{{ reopen }}" style="position: absolute; right: 24px; bottom: 24px; z-index: 20; height: 44px; display: inline-flex; align-items: center; gap: 10px; padding: 0 18px 0 15px; border: 1px solid var(--primary-border); border-radius: 999px; background: var(--surface-card); color: var(--primary-text); font-size: 13px; font-weight: 600; box-shadow: 0 10px 28px -8px rgba(16,18,22,.3);" style-hover="background: var(--primary-bg);">
<svg viewBox="0 0 24 24" width="17" height="17" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round"><path d="M12 3l1.8 4.6L18.4 9.4 13.8 11.2 12 15.8 10.2 11.2 5.6 9.4 10.2 7.6z"></path><path d="M19 16l.8 2.2L22 19l-2.2.8L19 22l-.8-2.2L16 19l2.2-.8z"></path><path d="M5 14l.6 1.6L7.2 16.2 5.6 16.8 5 18.4l-.6-1.6L2.8 16.2l1.6-.6z"></path></svg>
Начало работы
<small style="padding: 1px 7px; border-radius: 999px; background: var(--primary-bg); font-size: 11.5px; font-weight: 700;">{{ doneLabel }}</small>
</button>
</sc-if>
<sc-if value="{{ tourOpen }}" hint-placeholder-val="{{ false }}">
<div>
<button type="button" aria-label="Закрыть подсказку" onClick="{{ closeTour }}" style="position: absolute; inset: 0; z-index: 39; border: 0; padding: 0; background: transparent; cursor: default;"></button>
<div style="{{ scrimTop }}"></div>
<div style="{{ scrimBottom }}"></div>
<div style="{{ scrimLeft }}"></div>
<div style="{{ scrimRight }}"></div>
<div style="{{ spotStyle }}"></div>
<div style="{{ ringStyle }}"></div>
<div style="{{ calloutStyle }}">
<div style="padding: 16px 18px 14px; background: var(--surface-card); border: 1px solid var(--primary-border); border-radius: 13px; box-shadow: 0 20px 48px -12px rgba(16,18,22,.45); animation: ob-rise 220ms cubic-bezier(.2,.8,.2,1);">
<p style="margin: 0 0 8px; color: var(--primary-text); font-size: 11px; font-weight: 700; letter-spacing: .08em; text-transform: uppercase;">{{ tourBadge }}</p>
<strong style="display: block; color: var(--n-1); font-size: 14.5px; font-weight: 700; letter-spacing: -.01em;">{{ tourTitle }}</strong>
<p style="margin: 7px 0 0; color: var(--n-3); font-size: 13px; line-height: 1.55; text-wrap: pretty;">{{ tourText }}</p>
<button type="button" onClick="{{ closeTour }}" style="width: 100%; height: 34px; display: inline-flex; align-items: center; justify-content: center; gap: 7px; margin-top: 14px; border: 0; border-radius: 8px; background: var(--primary); color: #fff; font-size: 13px; font-weight: 600;" style-hover="background: var(--primary-hover);">
Понятно, к шагам
<svg viewBox="0 0 24 24" width="15" height="15" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round"><polyline points="15 6 9 12 15 18"></polyline></svg>
</button>
</div>
</div>
</div>
</sc-if>
</div>
</x-dc>
<script type="text/x-dc" data-dc-script data-props="{&quot;$preview&quot;:{&quot;width&quot;:1440,&quot;height&quot;:900},&quot;theme&quot;:{&quot;editor&quot;:&quot;enum&quot;,&quot;options&quot;:[&quot;light&quot;,&quot;dark&quot;],&quot;default&quot;:&quot;light&quot;,&quot;tsType&quot;:&quot;\&quot;light\&quot; | \&quot;dark\&quot;&quot;,&quot;section&quot;:&quot;Тема&quot;},&quot;accent&quot;:{&quot;editor&quot;:&quot;color&quot;,&quot;options&quot;:[&quot;#1677ff&quot;,&quot;#722ed1&quot;,&quot;#0f9b8e&quot;,&quot;#d4380d&quot;],&quot;default&quot;:&quot;#1677ff&quot;,&quot;tsType&quot;:&quot;string&quot;,&quot;section&quot;:&quot;Тема&quot;}}">
class Component extends DCLogic {
state = { screen: "chat", sec: "ai", open: true, view: "welcome", i: 0, done: {}, tour: null };
steps = [
{
key: "provider", num: 1, est: "3 мин", rail: "AI-провайдер",
title: "Подключите AI-провайдера",
lead: "Без провайдера агент не ответит ни одному клиенту. Ключ ваш: расходы, лимиты и токены считаются в системе.",
path: ["Настройки", "AI-провайдер", "Добавить провайдера"],
items: [
{ n: 1, text: "В сайдбаре откройте «Настройки» и выберите раздел «AI-провайдер»." },
{ n: 2, text: "Нажмите «Добавить провайдера»: OpenRouter, Custom (OpenAI-compatible) или локальная модель." },
{ n: 3, text: "Вставьте API-ключ и укажите модель по умолчанию — например openai/gpt-4o-mini." },
{ n: 4, text: "Нажмите «Проверить»: статус должен стать «Подключено»." }
],
tip: "Ключа ещё нет? Возьмите «Демо-провайдер без ключа» — он отвечает по знаниям агента, чтобы осмотреться, и меняется на настоящий в любой момент.",
preview: { caption: "Настройки → AI-провайдер", rows: [{ label: "Провайдер", value: "OpenRouter" }, { label: "Секрет", value: "sk-or-••••••••" }, { label: "Модель", value: "openai/gpt-4o-mini" }], chip: "Подключено", chipOk: true },
goLabel: "Открыть раздел",
tour: { screen: "settings", sec: "ai", target: "settings-ai", title: "Настройки → AI-провайдер", text: "Раздел в субменю «Настроек». Здесь живут ключи организации: добавление, проверка и статус провайдера." }
},
{
key: "agent", num: 2, est: "4 мин", rail: "AI-агент",
title: "Создайте AI-агента",
lead: "Агент — тот, кто говорит с клиентом: кто он, как отвечает, каким правилам подчиняется и сколько может потратить в день.",
path: ["Агенты", "Создать агента"],
items: [
{ n: 1, text: "Откройте «Агенты» в сайдбаре и нажмите «Создать агента»." },
{ n: 2, text: "Опишите роль и тон: кто он для клиента, что можно обещать, чего делать нельзя." },
{ n: 3, text: "Выберите модель подключённого провайдера и дневной бюджет в долларах." },
{ n: 4, text: "Прикрепите статьи базы знаний — агент отвечает только из них." },
{ n: 5, text: "Переведите статус из «Черновик» в «Активен», иначе агент молчит." }
],
tip: "Если данных для ответа нет, агент честно это скажет и предложит позвать сотрудника — придумывать он не станет.",
preview: { caption: "Агенты → Консультант", rows: [{ label: "Модель", value: "openai/gpt-4o-mini" }, { label: "Дневной бюджет", value: "$5.00" }, { label: "Знания", value: "12 статей" }], chip: "Активен", chipOk: true },
goLabel: "Открыть раздел",
tour: { screen: "chat", target: "nav-agents", title: "Раздел «Агенты»", text: "Список агентов организации. Карточка агента — это его роль, модель, бюджет и прикреплённые знания." }
},
{
key: "knowledge", num: 3, est: "от 10 мин", rail: "База знаний",
title: "Наполните базу знаний",
lead: "Это единственный источник ответов агента. Поиск идёт по смыслу, поэтому пишите так, как спрашивают клиенты.",
path: ["База знаний", "Создать статью"],
items: [
{ n: 1, text: "Откройте «База знаний» и создайте категории под темы обращений." },
{ n: 2, text: "Добавьте статьи: вопрос в заголовке, короткий однозначный ответ в тексте." },
{ n: 3, text: "Готовые материалы залейте через «Импорт» — файлами, без ручного переноса." },
{ n: 4, text: "Проверьте на карточке агента, что статьи к нему прикреплены." }
],
tip: "Правка статьи меняет ответы бота сразу, и ту же статью читает публичный портал поддержки — держать два текста не нужно.",
preview: { caption: "База знаний", rows: [{ label: "Категории", value: "Доставка · Оплата · Возврат" }, { label: "Статей", value: "12" }, { label: "Поиск", value: "Семантический" }], chip: "Прикреплено к агенту", chipOk: true },
goLabel: "Открыть раздел",
tour: { screen: "chat", target: "nav-knowledge", title: "Раздел «База знаний»", text: "Статьи, категории и импорт. Отсюда агент берёт ответы, отсюда же их читает портал поддержки." }
},
{
key: "entry", num: 4, est: "5 мин", rail: "Точка входа",
title: "Подключите точку входа",
lead: "Точка входа — канал, по которому приходит клиент: Telegram, MAX, почта или чат на сайте. Без неё писать некуда.",
path: ["Настройки", "Интеграции", "Добавить подключение"],
items: [
{ n: 1, text: "Настройки → «Интеграции» → «Добавить подключение»." },
{ n: 2, text: "Для бота вставьте токен: Telegram — от BotFather, MAX — из кабинета разработчика." },
{ n: 3, text: "Для почты укажите IMAP и SMTP ящика и пароль приложения." },
{ n: 4, text: "Привяжите подключение к созданному агенту — иначе сообщения некому обрабатывать." },
{ n: 5, text: "Нажмите «Проверить» и убедитесь в статусе «Подключено»." }
],
tip: "Сообщения из Telegram и MAX забирает фоновый рабочий. Если он остановлен, статус останется «Подключено», а переписки не появятся.",
preview: { caption: "Настройки → Интеграции", rows: [{ label: "Подключение", value: "Telegram · @romashka_bot" }, { label: "Агент", value: "Консультант" }, { label: "Секрет", value: "78••••••••" }], chip: "Подключено", chipOk: true },
goLabel: "Открыть раздел",
tour: { screen: "settings", sec: "integrations", target: "settings-integrations", title: "Настройки → Интеграции", text: "Боты, почта и веб-виджет. Красная точка у пункта означает, что какое-то подключение отвалилось." }
},
{
key: "widget", num: 5, est: "3 мин", rail: "Виджет на сайт",
title: "Поставьте чат на сайт",
lead: "Веб-виджет — один тег в вашем шаблоне. Чат открывается в изолированном окне поверх сайта.",
path: ["Настройки", "Интеграции", "Web-виджет"],
items: [
{ n: 1, text: "Создайте подключение «Web-виджет» и оформите его: заголовок, акцент, приветствие." },
{ n: 2, text: "Заполните разрешённые домены — пустой список запрещает все сайты." },
{ n: 3, text: "Скопируйте сниппет и вставьте его перед закрывающим body на своём сайте." },
{ n: 4, text: "Переведите виджет в статус «Опубликован» и обновите страницу сайта." }
],
tip: "Ключ в data-widget-key должен совпадать с ключом из настроек виджета — при несовпадении чат просто не появится.",
preview: {
caption: "Сниппет для сайта",
code: "<" + "script src=\"https://support.romashka.ru/chat-widget.js\" data-widget-key=\"wk_7f3a…\" async><\/" + "script>",
rows: [{ label: "Разрешённые домены", value: "romashka.ru, *.romashka.ru" }],
chip: "Опубликован", chipOk: true
},
goLabel: "Открыть раздел",
tour: { screen: "settings", sec: "integrations", target: "settings-integrations", title: "Настройки → Интеграции", text: "Виджет живёт среди подключений: там его ключ, разрешённые домены и готовый тег для вставки." }
},
{
key: "team", num: 6, est: "4 мин", rail: "Сотрудники",
title: "Пригласите команду",
lead: "Сложные вопросы агент передаёт людям. Заведите тех, кому он будет их отдавать, и решите, кто что видит.",
path: ["Сотрудники", "Пригласить"],
items: [
{ n: 1, text: "Откройте «Сотрудники» и отправьте приглашения по email." },
{ n: 2, text: "Назначьте роли: владелец и администратор видят всё, сотрудник работает только в чате." },
{ n: 3, text: "В Настройки → «Группы» распределите диалоги по группам видимости." },
{ n: 4, text: "Предложите команде включить вход по коду (TOTP) в своём профиле." }
],
tip: "Приглашения уходят почтой. Пока не настроен SMTP (шаг 7), письма не дойдут — пароль придётся задавать вручную.",
preview: { caption: "Сотрудники", rows: [{ label: "Приглашены", value: "3 человека" }, { label: "Группы", value: "Первая линия · Биллинг" }, { label: "Роли", value: "Админ · Сотрудник" }], chip: "Ожидают входа", chipOk: false },
goLabel: "Открыть раздел",
tour: { screen: "chat", target: "nav-employees", title: "Раздел «Сотрудники»", text: "Приглашения, роли и группы. Сотрудник видит диалоги своих групп и те, за которые отвечает." }
},
{
key: "platform", num: 7, est: "5 мин", rail: "Домен и почта",
title: "Укажите домен и почту",
lead: "Пока установка открывается по IP — без HTTPS и без писем. Домен и SMTP превращают её в рабочий сервис.",
path: ["Настройки", "Платформа"],
items: [
{ n: 1, text: "Настройки → «Платформа»: введите домен установки и переключитесь на https." },
{ n: 2, text: "Убедитесь, что DNS домена смотрит на сервер, а порты 80 и 443 открыты снаружи." },
{ n: 3, text: "Заполните исходящую почту: хост, порт, шифрование, логин и пароль приложения." },
{ n: 4, text: "Нажмите «Отправить тестовое письмо» и проверьте, что оно пришло." }
],
tip: "Сертификат выдаётся по первому запросу к домену: откройте его в браузере и подождите несколько секунд.",
preview: { caption: "Настройки → Платформа", rows: [{ label: "Адрес", value: "https://support.romashka.ru" }, { label: "SMTP", value: "smtp.yandex.ru : 465" }, { label: "Сертификат", value: "Let's Encrypt" }], chip: "HTTPS включён", chipOk: true },
goLabel: "Открыть раздел",
tour: { screen: "settings", sec: "platform", target: "settings-platform", title: "Настройки → Платформа", text: "Свойства самой установки: адрес, исходящая почта, обновления. Раздел виден администратору установки." }
},
{
key: "test", num: 8, est: "2 мин", rail: "Проверка",
title: "Напишите боту как клиент",
lead: "Последний шаг — пройти путь клиента целиком: сообщение, ответ агента и передача на человека.",
path: ["Чат", "Все диалоги"],
items: [
{ n: 1, text: "Напишите своему боту или в виджет на сайте вопрос из базы знаний." },
{ n: 2, text: "Откройте «Чат»: диалог появится в списке с каналом, агентом и таймером ожидания." },
{ n: 3, text: "Проверьте ответ агента — он должен опираться на вашу статью." },
{ n: 4, text: "Нажмите «Взять диалог» и ответьте сами: так работает передача на оператора." }
],
tip: "Три режима диалога — отвечает AI, отвечает сотрудник, пауза — переключаются в шапке переписки в любой момент.",
preview: { caption: "Чат → Все диалоги", rows: [{ label: "Диалог", value: "Анна Смирнова · Telegram" }, { label: "Агент", value: "Консультант" }, { label: "Режим", value: "Отвечает AI" }], chip: "Ответ за 4 сек", chipOk: true },
goLabel: "Открыть чат",
tour: { screen: "chat", target: "chat-list", title: "Список диалогов", text: "Все обращения организации в одном окне: канал, агент, время ожидания, метки и группа." }
}
];
componentDidMount() {
this.apply();
window.addEventListener("resize", this.onResize);
}
componentDidUpdate() { this.apply(); }
componentWillUnmount() { window.removeEventListener("resize", this.onResize); }
onResize = () => { if (this.state.tour) this.measureTour(this.state.tour.cfg); };
measureTour(cfg) {
if (!cfg) return;
const el = document.querySelector('[data-target="' + cfg.target + '"]');
const root = el ? el.closest("[data-ob-root]") : null;
if (!el || !root) return;
const r = el.getBoundingClientRect();
const rr = root.getBoundingClientRect();
this.setState({
tour: {
cfg: cfg,
x: r.left - rr.left, y: r.top - rr.top, w: r.width, h: r.height,
rootW: rr.width, rootH: rr.height,
title: cfg.title, text: cfg.text
}
});
}
apply() {
const root = document.documentElement;
root.setAttribute("data-theme", (this.props.theme || "light") === "dark" ? "dark" : "light");
root.style.setProperty("--accent", this.props.accent || "#1677ff");
}
cur() { return this.steps[Math.min(this.state.i, this.steps.length - 1)]; }
goStep = (i) => this.setState({ i: i, view: "steps", tour: null });
next = () => {
const cur = this.cur();
const done = Object.assign({}, this.state.done);
done[cur.key] = true;
if (this.state.i >= this.steps.length - 1) this.setState({ done: done, view: "done", tour: null });
else this.setState({ done: done, i: this.state.i + 1, tour: null });
};
prev = () => {
if (this.state.i === 0) this.setState({ view: "welcome", tour: null });
else this.setState({ i: this.state.i - 1, tour: null });
};
showTour = () => {
const cfg = this.cur().tour;
this.setState({ screen: cfg.screen, sec: cfg.sec || this.state.sec }, () => {
window.requestAnimationFrame(() => this.measureTour(cfg));
});
};
renderVals() {
const st = this.state;
const cur = this.cur();
const total = this.steps.length;
const doneCount = this.steps.filter((s) => st.done[s.key]).length;
const onChat = st.screen === "chat";
const onSettings = st.screen === "settings";
const navItem = (active) => ({
position: "relative", width: "100%", display: "flex", alignItems: "center", flex: "none", gap: "10px",
height: "36px", padding: "0 10px", border: "none", borderRadius: "8px", fontSize: "13.5px", textAlign: "left",
color: active ? "var(--primary-text)" : "var(--n-2)",
background: active ? "var(--primary-bg)" : "transparent",
fontWeight: active ? 600 : 500
});
const secItem = (key) => {
const active = st.sec === key;
return {
display: "flex", alignItems: "center", gap: "10px", minHeight: "34px", padding: "0 10px",
border: 0, borderRadius: "8px", fontSize: "13px", textAlign: "left",
color: active ? "var(--primary-text)" : "var(--n-2)",
background: active ? "var(--primary-bg)" : "transparent",
fontWeight: active ? 600 : 500
};
};
const SEC = {
organization: { heading: "Организация", lead: "Название, логотип и региональные параметры, которые видят клиенты.", action: "Сохранить", empty: "Данные организации" },
groups: { heading: "Группы", lead: "Они распределяют диалоги между сотрудниками.", action: "Добавить группу", empty: "Групп пока нет" },
ai: { heading: "AI-провайдер", lead: "Ключи организации, по которым отвечают агенты.", action: "Добавить провайдера", empty: "Провайдеров пока нет — добавьте OpenRouter, свой OpenAI-совместимый сервис или демо" },
integrations: { heading: "Интеграции", lead: "Боты, почта и веб-виджет — точки входа клиентов.", action: "Добавить подключение", empty: "Подключений пока нет — добавьте бота, почту или веб-виджет" },
communication: { heading: "Голосовые и звонки", lead: "Где клиент и сотрудник могут записать голосовое и позвонить.", action: "Сохранить", empty: "Матрица каналов" },
storage: { heading: "Хранилище файлов", lead: "Вложения, голосовые, фото и логотипы.", action: "Сохранить", empty: "Файлы лежат в локальном томе" },
platform: { heading: "Платформа", lead: "Свойства самой установки: по какому адресу она открывается и как отправляет почту.", action: "Сохранить", empty: "Адрес установки и исходящая почта" },
demo: { heading: "Демо-данные", lead: "Посмотреть систему в работе на вымышленной организации.", action: "Установить", empty: "Демо-данные не установлены" }
};
const sec = SEC[st.sec] || SEC.ai;
const tour = st.tour;
let spotStyle = null;
let ringStyle = null;
let calloutStyle = null;
let scrimTop = null;
let scrimBottom = null;
let scrimLeft = null;
let scrimRight = null;
if (tour) {
const pad = 6;
const box = {
position: "absolute", left: (tour.x - pad) + "px", top: (tour.y - pad) + "px",
width: (tour.w + pad * 2) + "px", height: (tour.h + pad * 2) + "px",
borderRadius: "11px", pointerEvents: "none", zIndex: 41
};
const shadeColor = (this.props.theme || "light") === "dark" ? "rgba(4, 5, 7, 0.68)" : "rgba(16, 18, 22, 0.55)";
const shade = { position: "absolute", backgroundColor: shadeColor, pointerEvents: "none", zIndex: 40 };
const t = Math.max(0, tour.y - pad);
const b = tour.y + tour.h + pad;
const l = Math.max(0, tour.x - pad);
const r = tour.x + tour.w + pad;
scrimTop = Object.assign({}, shade, { left: 0, top: 0, right: 0, height: t + "px" });
scrimBottom = Object.assign({}, shade, { left: 0, top: b + "px", right: 0, bottom: 0 });
scrimLeft = Object.assign({}, shade, { left: 0, top: t + "px", width: l + "px", height: (b - t) + "px" });
scrimRight = Object.assign({}, shade, { left: r + "px", top: t + "px", right: 0, height: (b - t) + "px" });
spotStyle = Object.assign({}, box, { boxShadow: "0 0 0 2px var(--primary)" });
ringStyle = Object.assign({}, box, { border: "2px solid var(--primary)", animation: "ob-pulse 1.9s ease-out infinite" });
const w = 344;
const right = tour.x + tour.w + 20;
const left = right + w > tour.rootW - 16 ? Math.max(16, tour.x - w - 20) : right;
const top = Math.min(Math.max(16, tour.y - 14), Math.max(16, tour.rootH - 250));
calloutStyle = { position: "absolute", left: left + "px", top: top + "px", width: w + "px", zIndex: 42 };
}
const markStyle = (isDone, isCur) => ({
width: "22px", height: "22px", flex: "none", display: "inline-flex", alignItems: "center",
justifyContent: "center", borderRadius: "50%", fontSize: "11.5px", fontWeight: 700,
color: isDone || isCur ? "#fff" : "var(--n-4)",
background: isDone ? "var(--success)" : isCur ? "var(--primary)" : "var(--n-8)",
border: "1px solid " + (isDone ? "var(--success)" : isCur ? "var(--primary)" : "var(--n-7)")
});
return {
onChat: onChat, onSettings: onSettings,
navChatStyle: navItem(onChat), navSettingsStyle: navItem(onSettings),
secOrgStyle: secItem("organization"), secGroupsStyle: secItem("groups"), secAiStyle: secItem("ai"),
secIntStyle: secItem("integrations"), secCommStyle: secItem("communication"),
secStorageStyle: secItem("storage"), secPlatformStyle: secItem("platform"), secDemoStyle: secItem("demo"),
secHeading: sec.heading, secLead: sec.lead, secAction: sec.action, secEmpty: sec.empty,
goChat: () => this.setState({ screen: "chat", tour: null }),
goSettings: () => this.setState({ screen: "settings", tour: null }),
showWelcome: st.open && st.view === "welcome" && !tour,
showSteps: st.open && st.view === "steps" && !tour,
showDone: st.open && st.view === "done" && !tour,
showLauncher: !st.open && !tour && doneCount < total,
close: () => this.setState({ open: false, tour: null }),
reopen: () => this.setState({ open: true, view: st.view === "welcome" ? "steps" : st.view, tour: null }),
start: () => this.setState({ open: true, view: "steps", i: 0 }),
next: this.next, prev: this.prev, showTour: this.showTour,
closeTour: () => this.setState({ tour: null }),
goSection: () => {
const cfg = cur.tour;
this.setState({ screen: cfg.screen, sec: cfg.sec || st.sec, open: false, tour: null });
},
doneLabel: doneCount + "/" + total,
progressLabel: "Готово " + doneCount + " из " + total,
percentLabel: Math.round((doneCount / total) * 100) + "%",
progressStyle: { display: "block", height: "100%", width: Math.round((doneCount / total) * 100) + "%", background: "var(--primary)", borderRadius: "999px", transition: "width 280ms ease" },
eyebrow: "Шаг " + cur.num + " из " + total + " · " + cur.est,
curTitle: cur.title, curLead: cur.lead, curTip: cur.tip, curGoLabel: cur.goLabel,
curItems: cur.items,
curPath: cur.path.map((label, index) => ({ label: label, more: index < cur.path.length - 1 })),
previewCaption: cur.preview.caption,
previewRows: cur.preview.rows,
previewChip: cur.preview.chip,
previewChipOk: Boolean(cur.preview.chipOk),
previewChipWait: !cur.preview.chipOk,
previewCode: cur.preview.code || "",
hasCode: Boolean(cur.preview.code),
nextLabel: st.i === total - 1 ? "Завершить" : "Далее",
rail: this.steps.map((s, index) => {
const isDone = Boolean(st.done[s.key]);
const isCur = index === st.i;
return {
num: s.num, label: s.rail, est: s.est, isDone: isDone, showNum: !isDone,
onClick: () => this.goStep(index),
rowStyle: {
display: "flex", alignItems: "center", gap: "11px", width: "100%", padding: "9px 11px",
border: 0, borderRadius: "9px", textAlign: "left",
background: isCur ? "var(--surface-card)" : "transparent",
boxShadow: isCur ? "var(--shadow-sm)" : "none"
},
markStyle: markStyle(isDone, isCur),
labelStyle: {
flex: 1, minWidth: 0, fontSize: "13px",
color: isCur ? "var(--n-1)" : isDone ? "var(--n-3)" : "var(--n-2)",
fontWeight: isCur ? 700 : 500
}
};
}),
doneSummary: this.steps.map((s) => {
const isDone = Boolean(st.done[s.key]);
return {
label: s.rail, isDone: isDone,
markStyle: {
width: "20px", height: "20px", flex: "none", display: "inline-flex", alignItems: "center",
justifyContent: "center", borderRadius: "50%", color: "#fff",
background: isDone ? "var(--success)" : "var(--n-7)",
border: "1px solid " + (isDone ? "var(--success)" : "var(--n-7)")
},
labelStyle: { flex: 1, minWidth: 0, color: isDone ? "var(--n-2)" : "var(--n-4)", fontSize: "13px", fontWeight: isDone ? 600 : 500 }
};
}),
tourOpen: Boolean(tour),
tourTitle: tour ? tour.title : "",
tourText: tour ? tour.text : "",
tourBadge: tour ? "Шаг " + cur.num + " · где это находится" : "",
spotStyle: spotStyle, ringStyle: ringStyle, calloutStyle: calloutStyle,
scrimTop: scrimTop, scrimBottom: scrimBottom, scrimLeft: scrimLeft, scrimRight: scrimRight
};
}
}
</script>
</body>
</html>
-31
View File
@@ -1,31 +0,0 @@
# Chatballs Design System
Статический справочник текущего frontend UI. React, сборка и установка зависимостей не требуются.
## Открытие в Windows PowerShell
Из корня репозитория:
```powershell
Start-Process .\design\design-system\index.html
```
Для просмотра через локальный HTTP-сервер:
```powershell
python -m http.server 8080 --directory .\design\design-system
Start-Process http://localhost:8080
```
## Состав
- `index.html` — оглавление;
- `foundations.html` — токены;
- `components.html` — базовые controls;
- `data-display.html` — статусы и представление данных;
- `patterns.html` — таблицы, состояния, меню и dialogs;
- `layouts.html` — типовые компоновки экранов;
- `inventory.html` — поисковый реестр;
- `inventory.json` — машиночитаемая инвентаризация.
Справочник автономен и не подключён к production bundle приложения.
-275
View File
@@ -1,275 +0,0 @@
* {
box-sizing: border-box;
}
:root {
--n-1: #1f1f1f;
--n-2: #262626;
--n-3: #595959;
--n-4: #8c8c8c;
--n-5: #bfbfbf;
--n-6: #d9d9d9;
--n-7: #e8e8e8;
--n-8: #f0f0f0;
--n-9: #f5f5f5;
--n-10: #fafafa;
--primary: #1677ff;
--primary-hover: #0958d9;
--primary-bg: #e6f4ff;
--primary-border: #91caff;
--success: #52c41a;
--success-text: #389e0d;
--success-bg: #f6ffed;
--success-border: #b7eb8f;
--warning-text: #d48806;
--warning-bg: #fffbe6;
--warning-bg-strong: #fff7e6;
--warning-border: #ffe58f;
--error-text: #cf1322;
--error-bg: #fff2f0;
--error-border: #ffccc7;
--ai: #722ed1;
--ai-bg: #f9f0ff;
--ai-border: #e4d4fb;
--text-heading: var(--n-1);
--text-body: var(--n-2);
--text-secondary: var(--n-3);
--text-tertiary: var(--n-4);
--text-disabled: var(--n-5);
--border-default: var(--n-8);
--border-input: var(--n-7);
--radius-md: 8px;
--radius-lg: 9px;
--radius-xl: 10px;
--radius-2xl: 12px;
--radius-4xl: 14px;
--radius-pill: 999px;
--shadow-xs: 0 1px 2px rgba(0, 0, 0, 0.03);
--shadow-sm: 0 1px 3px rgba(0, 0, 0, 0.04);
--shadow-lg: 0 6px 20px rgba(0, 0, 0, 0.12);
--font-sans: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
--font-mono: "SF Mono", ui-monospace, Menlo, monospace;
}
html,
body {
min-height: 100%;
margin: 0;
}
body {
color: var(--text-body);
background: #f0f2f5;
font-family: var(--font-sans);
-webkit-font-smoothing: antialiased;
}
button,
input,
select,
textarea {
font: inherit;
}
button {
cursor: pointer;
}
button:focus-visible,
a:focus-visible,
input:focus-visible,
select:focus-visible,
textarea:focus-visible {
outline: 2px solid var(--primary-border);
outline-offset: 2px;
}
button:disabled {
cursor: not-allowed;
opacity: 0.55;
}
svg.icon {
width: 1em;
height: 1em;
fill: none;
stroke: currentColor;
stroke-width: 1.8;
stroke-linecap: round;
stroke-linejoin: round;
flex: none;
}
.ds-shell {
min-height: 100vh;
display: grid;
grid-template-columns: 248px minmax(0, 1fr);
}
.ds-sidebar {
position: sticky;
top: 0;
height: 100vh;
display: flex;
flex-direction: column;
background: #fff;
border-right: 1px solid var(--border-default);
}
.ds-brand {
height: 64px;
display: flex;
align-items: center;
gap: 11px;
padding: 0 18px;
color: inherit;
border-bottom: 1px solid var(--n-9);
text-decoration: none;
}
.ds-brand-mark {
width: 32px;
height: 32px;
display: inline-flex;
align-items: center;
justify-content: center;
color: #fff;
background: var(--primary);
border-radius: var(--radius-md);
font-size: 15px;
font-weight: 700;
}
.ds-brand strong,
.ds-brand small {
display: block;
}
.ds-brand strong {
color: var(--text-heading);
font-size: 14px;
line-height: 1.2;
}
.ds-brand small {
margin-top: 3px;
color: var(--text-tertiary);
font-size: 11px;
}
.ds-nav {
flex: 1;
overflow-y: auto;
padding: 12px;
}
.ds-nav-group {
padding: 14px 12px 6px;
color: var(--text-disabled);
font-size: 11px;
font-weight: 600;
letter-spacing: 0.06em;
text-transform: uppercase;
}
.ds-nav a {
position: relative;
min-height: 38px;
display: flex;
align-items: center;
gap: 10px;
padding: 9px 12px;
margin-bottom: 2px;
color: var(--text-secondary);
border-radius: var(--radius-md);
font-size: 13.5px;
font-weight: 500;
text-decoration: none;
}
.ds-nav a:hover {
color: var(--text-body);
background: var(--n-9);
}
.ds-nav a.is-active {
color: var(--primary-hover);
background: var(--primary-bg);
font-weight: 600;
}
.ds-nav a.is-active::before {
position: absolute;
top: 8px;
bottom: 8px;
left: -12px;
width: 3px;
background: var(--primary);
border-radius: 0 3px 3px 0;
content: "";
}
.ds-sidebar-footer {
padding: 12px 18px;
color: var(--text-tertiary);
border-top: 1px solid var(--n-9);
font-size: 11.5px;
line-height: 1.45;
}
.ds-main {
min-width: 0;
}
.ds-topbar {
position: sticky;
top: 0;
z-index: 20;
height: 64px;
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 28px;
background: rgba(255, 255, 255, 0.96);
border-bottom: 1px solid var(--border-default);
backdrop-filter: blur(8px);
}
.ds-breadcrumbs {
display: flex;
align-items: center;
gap: 8px;
color: var(--text-tertiary);
font-size: 13px;
}
.ds-breadcrumbs strong {
color: var(--text-body);
}
.ds-version {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 5px 10px;
color: var(--success-text);
background: var(--success-bg);
border: 1px solid var(--success-border);
border-radius: 7px;
font-size: 12px;
font-weight: 600;
}
.ds-version::before {
width: 7px;
height: 7px;
background: var(--success);
border-radius: 50%;
content: "";
}
.ds-content {
width: min(1240px, 100%);
margin: 0 auto;
padding: 30px 32px 56px;
}
@@ -1,179 +0,0 @@
.primary-button,
.secondary-button,
.danger-outline,
.ui-action-button {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 7px;
text-decoration: none;
}
.primary-button {
min-height: 38px;
padding: 0 16px;
color: #fff;
background: var(--primary);
border: 0;
border-radius: var(--radius-lg);
box-shadow: 0 1px 2px rgba(22, 119, 255, 0.3);
font-size: 13.5px;
font-weight: 600;
}
.primary-button:hover {
background: var(--primary-hover);
}
.primary-button:disabled {
color: var(--text-disabled);
background: var(--n-8);
box-shadow: none;
}
.secondary-button,
.danger-outline {
height: 38px;
padding: 0 16px;
background: #fff;
border-radius: var(--radius-lg);
font-size: 13px;
font-weight: 500;
}
.secondary-button {
color: var(--text-body);
border: 1px solid var(--n-6);
}
.secondary-button:hover,
.ui-action-button:hover {
color: var(--primary);
border-color: var(--primary);
}
.danger-outline {
color: var(--error-text);
border: 1px solid var(--error-border);
}
.danger-outline:hover {
background: var(--error-bg);
}
.ui-action-button {
height: 36px;
padding: 0 14px;
color: var(--text-body);
background: #fff;
border: 1px solid var(--n-6);
border-radius: var(--radius-md);
font-size: 13px;
font-weight: 500;
}
.ui-icon-button,
.icon-button,
.row-menu-button {
display: inline-flex;
align-items: center;
justify-content: center;
flex: none;
}
.ui-icon-button,
.icon-button {
width: 36px;
height: 36px;
color: var(--text-tertiary);
background: #fff;
border: 1px solid var(--n-6);
border-radius: var(--radius-md);
}
.ui-icon-button:hover,
.icon-button:hover {
color: var(--text-body);
background: var(--n-10);
}
.ui-icon-button.is-bare {
background: transparent;
border-color: transparent;
}
.ui-icon-button.is-danger:hover {
color: var(--error-text);
background: var(--error-bg);
border-color: var(--error-border);
}
.icon-button {
position: relative;
color: var(--text-secondary);
border-color: var(--n-8);
}
.icon-button b {
position: absolute;
top: 5px;
right: 5px;
min-width: 15px;
height: 15px;
display: flex;
align-items: center;
justify-content: center;
padding: 0 4px;
color: #fff;
background: #ff4d4f;
border: 1.5px solid #fff;
border-radius: 8px;
font-size: 9px;
}
.link {
display: inline;
padding: 0;
color: var(--primary);
background: none;
border: 0;
font: inherit;
font-weight: 600;
text-align: left;
text-decoration: none;
}
.link:hover {
color: var(--primary-hover);
}
.link.is-strong {
display: block;
font-size: 14px;
font-weight: 700;
}
.link.is-neutral {
color: var(--text-heading);
}
.link.is-neutral:hover,
.link.is-muted:hover {
color: var(--primary);
}
.link.is-muted {
color: var(--text-tertiary);
font-weight: 500;
}
.link.is-mono {
font-family: var(--font-mono);
font-size: 12.5px;
}
.link.has-icon {
display: inline-flex;
align-items: center;
gap: 6px;
}
@@ -1,225 +0,0 @@
.readonly-field {
display: block;
}
.readonly-field > span {
display: block;
margin-bottom: 6px;
color: var(--text-secondary);
font-size: 12.5px;
font-weight: 600;
line-height: normal;
}
.readonly-field input,
.readonly-field textarea,
.select-like select,
.search-control {
width: 100%;
color: var(--text-body);
background: #fff;
border: 1px solid var(--border-input);
border-radius: var(--radius-md);
outline: none;
font-size: 13px;
}
.search-control {
height: 38px;
padding: 0 12px;
}
/* Поле формы — дизайн-базлайн v2 (кадры G3, N1/N6, P1): 36px на подложке
--n-10, текст 13.5px. Один скин на всё приложение. */
.readonly-field input,
.select-like select {
height: 36px;
padding: 0 12px;
color: var(--n-1);
background: var(--n-10);
font-size: 13.5px;
}
.readonly-field textarea {
min-height: 92px;
padding: 10px 12px;
resize: vertical;
line-height: 1.5;
}
.form-field.is-editable:focus-within input,
.form-field.is-editable:focus-within textarea,
.select-like:focus-within select,
.select-like .select-box:focus-within,
.search-control:focus-within {
border-color: var(--primary);
}
.form-field.is-readonly input,
.readonly-field input:disabled {
color: var(--text-tertiary);
background: var(--n-10);
border-color: var(--n-8);
}
.form-field.is-invalid input {
border-color: #ff7875;
}
.form-field-error {
display: block;
margin-top: 5px;
color: var(--error-text);
font-size: 11.5px;
}
.select-like {
position: relative;
}
.select-like select {
padding-right: 32px;
appearance: none;
}
.select-like > .select-chevron {
position: absolute;
right: 13px;
bottom: 11px;
color: var(--text-tertiary);
pointer-events: none;
}
/* Бокс селекта — для метки перед значением (точка группы на карточке агента) и
для режима без прав правки. Рамку держит бокс, селект внутри без своей. */
.select-like .select-box {
display: flex;
align-items: center;
gap: 7px;
height: 36px;
padding: 0 12px;
color: var(--n-1);
background: var(--n-10);
border: 1px solid var(--border-input);
border-radius: var(--radius-md);
font-size: 13.5px;
}
.select-like .select-box select {
flex: 1;
height: 34px;
padding: 0;
color: inherit;
background: transparent;
border: 0;
font-size: inherit;
}
.select-like .select-box > .select-chevron {
flex: none;
color: var(--text-tertiary);
pointer-events: none;
}
/* Пример метки перед значением: цветная точка группы. */
.select-like .select-dot {
width: 8px;
height: 8px;
flex: none;
border-radius: 50%;
}
.select-like .select-value {
flex: 1;
min-width: 0;
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
}
.select-like.is-invalid select,
.select-like.is-invalid .select-box {
color: var(--error-text);
border-color: var(--error-border);
}
.search-control {
display: flex;
align-items: center;
gap: 8px;
color: var(--text-disabled);
}
.search-control input {
width: 100%;
min-width: 0;
padding: 0;
background: transparent;
border: 0;
outline: 0;
}
.ui-switch {
width: 42px;
height: 24px;
display: flex;
align-items: center;
justify-content: flex-start;
flex: none;
padding: 2px;
background: var(--n-6);
border: 0;
border-radius: 14px;
transition: background 0.15s;
}
.ui-switch.on {
justify-content: flex-end;
background: var(--primary);
}
.ui-switch i {
width: 20px;
height: 20px;
display: block;
background: #fff;
border-radius: 50%;
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.2);
}
.ui-switch.is-large {
width: 46px;
height: 26px;
}
.ui-switch.is-large i {
width: 22px;
height: 22px;
}
.segmented {
display: inline-flex;
align-items: center;
gap: 3px;
padding: 3px;
background: #f0f2f5;
border-radius: var(--radius-md);
}
.segmented button {
height: 30px;
padding: 0 13px;
color: var(--text-secondary);
background: transparent;
border: 0;
border-radius: 6px;
font-size: 12.5px;
font-weight: 500;
}
.segmented button.active {
color: var(--text-heading);
background: #fff;
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.1);
font-weight: 600;
}
@@ -1,2 +0,0 @@
@import "./component-actions.css";
@import "./component-forms.css";
-260
View File
@@ -1,260 +0,0 @@
.ui-underline-tabs {
display: flex;
align-items: center;
gap: 20px;
border-bottom: 1px solid var(--border-default);
}
.ui-underline-tabs button {
position: relative;
min-height: 42px;
display: inline-flex;
align-items: center;
gap: 6px;
padding: 0;
color: var(--text-tertiary);
background: transparent;
border: 0;
white-space: nowrap;
}
.ui-underline-tabs button.active {
color: var(--text-body);
font-weight: 600;
}
.ui-underline-tabs button.active::after {
position: absolute;
right: 0;
bottom: -1px;
left: 0;
height: 2px;
background: var(--primary);
content: "";
}
.ui-underline-tabs b {
min-width: 20px;
padding: 2px 6px;
color: var(--text-tertiary);
background: var(--n-9);
border-radius: 10px;
font-size: 10px;
}
.product-tag,
.product-mark,
.channel-badge,
.status-pill,
.role-badge,
.ui-tone-badge {
display: inline-flex;
align-items: center;
white-space: nowrap;
}
.product-tag {
gap: 6px;
padding: 2px 9px;
border-radius: var(--radius-pill);
font-size: 11.5px;
font-weight: 600;
}
.product-tag i,
.product-mark i {
width: 6px;
height: 6px;
border-radius: 50%;
}
.product-mark {
gap: 5px;
}
.channel-badge {
gap: 4px;
padding: 2px 7px;
border-radius: 5px;
font-size: 10.5px;
font-weight: 600;
}
.channel-badge i {
width: 5px;
height: 5px;
border-radius: 50%;
}
.channel-badge.is-plain {
color: var(--text-secondary);
background: var(--n-9);
}
.role-badge {
padding: 2px 8px;
border-radius: 6px;
font-size: 11.5px;
font-weight: 600;
}
.role-badge.owner {
color: var(--text-secondary);
background: var(--n-8);
}
.role-badge.admin {
color: #ad4e00;
background: var(--warning-bg-strong);
}
.role-badge.employee {
color: var(--primary-hover);
background: var(--primary-bg);
}
.status-pill {
gap: 6px;
padding: 2px 8px;
border: 1px solid transparent;
border-radius: 6px;
font-size: 12.5px;
font-weight: 500;
}
.status-pill i {
width: 7px;
height: 7px;
background: currentColor;
border-radius: 50%;
}
.status-pill.is-active,
.status-pill.is-healthy,
.status-pill.is-published {
color: var(--success-text);
}
.status-pill.is-healthy,
.status-pill.is-published {
background: var(--success-bg);
border-color: var(--success-border);
}
.status-pill.is-error {
color: var(--error-text);
background: var(--error-bg);
border-color: var(--error-border);
}
.status-pill.is-pending {
color: var(--warning-text);
background: var(--warning-bg);
border-color: var(--warning-border);
}
.status-pill.is-archived,
.status-pill.is-draft,
.status-pill.is-unchecked {
color: var(--text-tertiary);
background: var(--n-10);
border-color: var(--n-7);
}
.status-pill.is-blocked {
color: var(--error-text);
}
.status-pill.is-disabled {
color: var(--warning-text);
}
.status-pill.is-invited {
color: var(--primary-hover);
}
.avatar {
width: 32px;
height: 32px;
display: inline-flex;
align-items: center;
justify-content: center;
flex: none;
color: #fff;
background: var(--primary);
border-radius: 50%;
font-size: 13px;
font-weight: 600;
}
.key-value,
.metric-box {
display: grid;
gap: 5px;
}
.key-value span,
.metric-box span {
color: var(--text-tertiary);
font-size: 11.5px;
}
.key-value strong,
.metric-box strong {
color: var(--text-body);
font-size: 13.5px;
}
.metric-box {
min-width: 150px;
padding: 14px 16px;
background: #fff;
border: 1px solid var(--border-default);
border-radius: var(--radius-xl);
}
.metric-box strong {
font-size: 22px;
line-height: 1.2;
}
.ui-table-pagination {
display: flex;
align-items: center;
justify-content: space-between;
gap: 18px;
}
.ui-table-pagination > span {
color: var(--text-tertiary);
font-size: 12.5px;
}
.ui-table-pagination nav {
display: flex;
align-items: center;
gap: 6px;
}
.ui-table-pagination button {
min-width: 30px;
height: 30px;
padding: 0 9px;
color: var(--text-secondary);
background: #fff;
border: 1px solid var(--n-7);
border-radius: 7px;
font-size: 13px;
}
.ui-table-pagination button.active {
color: #fff;
background: var(--primary);
border-color: var(--primary);
font-weight: 600;
}
.ui-table-pagination button:not(:disabled):hover {
color: var(--primary);
border-color: var(--primary);
}
@@ -1,153 +0,0 @@
function installIconSprite() {
if (!window.CHATBALLS_ICON_SPRITE || document.querySelector("[data-chatballs-icon-sprite]")) return;
const container = document.createElement("div");
container.hidden = true;
container.dataset.chatballsIconSprite = "";
container.innerHTML = window.CHATBALLS_ICON_SPRITE;
document.body.prepend(container);
}
const pages = [
["index", "index.html", "Обзор", "grid"],
["foundations", "foundations.html", "Токены", "box"],
["components", "components.html", "Компоненты", "columns"],
["data-display", "data-display.html", "Данные и статусы", "list"],
["patterns", "patterns.html", "Паттерны", "settings"],
["layouts", "layouts.html", "Макеты страниц", "grid"],
["inventory", "inventory.html", "Инвентарь", "search"],
];
function icon(name) {
return `<svg class="icon" aria-hidden="true"><use href="#i-${name}"></use></svg>`;
}
function buildSidebar() {
const activePage = document.body.dataset.page || "index";
const nav = pages.map(([key, href, label, iconName], index) => {
const group = index === 0 ? '<div class="ds-nav-group">Система</div>' : index === 1 ? '<div class="ds-nav-group">Справочник</div>' : "";
const active = key === activePage ? " is-active" : "";
return `${group}<a class="${active.trim()}" href="${href}">${icon(iconName)}<span>${label}</span></a>`;
}).join("");
const sidebar = document.querySelector("[data-ds-sidebar]");
if (!sidebar) return;
sidebar.innerHTML = `
<a class="ds-brand" href="index.html">
<span class="ds-brand-mark">C</span>
<span><strong>Chatballs</strong><small>Design System</small></span>
</a>
<nav class="ds-nav" aria-label="Разделы дизайн-системы">${nav}</nav>
<div class="ds-sidebar-footer">Статический каталог текущего frontend UI.<br>Источник: <code>apps/internal-ui/src</code></div>
`;
}
function activateInteractiveExamples() {
document.querySelectorAll("[data-segmented]").forEach((group) => {
group.addEventListener("click", (event) => {
const button = event.target.closest("button");
if (!button) return;
group.querySelectorAll("button").forEach((item) => item.classList.toggle("active", item === button));
});
});
document.querySelectorAll("[data-tabs]").forEach((group) => {
group.addEventListener("click", (event) => {
const button = event.target.closest("button");
if (!button || button.disabled) return;
group.querySelectorAll("button").forEach((item) => item.classList.toggle("active", item === button));
});
});
document.querySelectorAll(".ui-switch").forEach((switchButton) => {
switchButton.addEventListener("click", () => {
if (switchButton.disabled) return;
const checked = switchButton.classList.toggle("on");
switchButton.setAttribute("aria-checked", String(checked));
});
});
document.querySelectorAll("[data-open-dialog]").forEach((button) => {
button.addEventListener("click", () => {
const dialog = document.getElementById(button.dataset.openDialog);
dialog?.classList.add("is-open");
});
});
document.querySelectorAll("[data-close-dialog]").forEach((button) => {
button.addEventListener("click", () => button.closest(".dialog-backdrop")?.classList.remove("is-open"));
});
document.querySelectorAll(".dialog-backdrop").forEach((backdrop) => {
backdrop.addEventListener("click", (event) => {
if (event.target === backdrop) backdrop.classList.remove("is-open");
});
});
}
function activateInventorySearch() {
const input = document.querySelector("[data-inventory-search]");
const rows = [...document.querySelectorAll("[data-inventory-row]")];
const count = document.querySelector("[data-inventory-count]");
if (!input || rows.length === 0) return;
const update = () => {
const query = input.value.trim().toLowerCase();
let visible = 0;
rows.forEach((row) => {
const matches = row.textContent.toLowerCase().includes(query);
row.hidden = !matches;
if (matches) visible += 1;
});
if (count) count.textContent = `Показано: ${visible}`;
};
input.addEventListener("input", update);
update();
}
function addCopyButtons() {
document.querySelectorAll("[data-copy]").forEach((button) => {
button.addEventListener("click", async () => {
const value = button.dataset.copy;
if (!value) return;
try {
await navigator.clipboard.writeText(value);
const original = button.textContent;
button.textContent = "Скопировано";
setTimeout(() => { button.textContent = original; }, 900);
} catch {
button.title = value;
}
});
});
}
function renderInventory() {
const body = document.querySelector("[data-inventory-body]");
const payload = window.CHATBALLS_INVENTORY;
if (!body || !payload) return;
body.innerHTML = payload.items.map((item) => {
const name = item.documentedAt
? `<a class="link is-neutral" href="${item.documentedAt}">${item.name}</a>`
: item.name;
return `<tr data-inventory-row>
<td><strong>${name}</strong><small style="display:block;margin-top:3px;color:#bfbfbf">${item.visibility}</small></td>
<td>${item.kind}</td>
<td>${item.category}</td>
<td><span class="ds-chip">${item.scope}</span></td>
<td><code class="ds-code">${item.source}</code></td>
<td>${item.variants}</td>
</tr>`;
}).join("");
const total = document.querySelector("[data-inventory-total]");
if (total) total.textContent = String(payload.count);
}
installIconSprite();
buildSidebar();
renderInventory();
activateInteractiveExamples();
activateInventorySearch();
addCopyButtons();
-227
View File
@@ -1,227 +0,0 @@
.ds-page-header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 24px;
margin-bottom: 24px;
}
.ds-page-header h1 {
margin: 0;
color: var(--text-heading);
font-size: 28px;
line-height: 1.2;
letter-spacing: -0.025em;
}
.ds-page-header p {
max-width: 760px;
margin: 8px 0 0;
color: var(--text-tertiary);
font-size: 14px;
line-height: 1.6;
}
.ds-eyebrow {
margin-bottom: 8px;
color: var(--primary);
font-size: 11px;
font-weight: 700;
letter-spacing: 0.08em;
text-transform: uppercase;
}
.ds-section {
margin-top: 28px;
}
.ds-section-heading {
display: flex;
align-items: flex-end;
justify-content: space-between;
gap: 20px;
margin-bottom: 12px;
}
.ds-section-heading h2 {
margin: 0;
color: var(--text-heading);
font-size: 19px;
line-height: 1.25;
}
.ds-section-heading p {
margin: 4px 0 0;
color: var(--text-tertiary);
font-size: 12.5px;
}
.ds-source {
color: var(--text-disabled);
font-family: var(--font-mono);
font-size: 11px;
white-space: nowrap;
}
.ds-grid {
display: grid;
gap: 14px;
}
.ds-grid.cols-2 {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.ds-grid.cols-3 {
grid-template-columns: repeat(3, minmax(0, 1fr));
}
.ds-grid.cols-4 {
grid-template-columns: repeat(4, minmax(0, 1fr));
}
.ds-card,
.ds-specimen {
background: #fff;
border: 1px solid var(--border-default);
border-radius: var(--radius-2xl);
box-shadow: var(--shadow-xs);
}
.ds-card {
padding: 18px;
}
.ds-card h3 {
margin: 0 0 7px;
color: var(--text-heading);
font-size: 14px;
}
.ds-card p {
margin: 0;
color: var(--text-tertiary);
font-size: 12.5px;
line-height: 1.55;
}
.ds-card-link {
min-height: 144px;
display: flex;
flex-direction: column;
color: inherit;
text-decoration: none;
}
.ds-card-link:hover {
border-color: var(--primary-border);
box-shadow: 0 4px 14px rgba(22, 119, 255, 0.08);
}
.ds-card-link .ds-card-icon {
width: 38px;
height: 38px;
display: inline-flex;
align-items: center;
justify-content: center;
margin-bottom: 16px;
color: var(--primary);
background: var(--primary-bg);
border-radius: var(--radius-xl);
font-size: 18px;
}
.ds-card-link .ds-arrow {
margin-top: auto;
padding-top: 18px;
color: var(--primary);
font-size: 12px;
font-weight: 600;
}
.ds-specimen-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
padding: 12px 16px;
border-bottom: 1px solid var(--border-default);
}
.ds-specimen-header strong {
color: var(--text-heading);
font-size: 13px;
}
.ds-specimen-header code,
.ds-code {
color: var(--text-tertiary);
font-family: var(--font-mono);
font-size: 11px;
}
.ds-specimen-body {
min-height: 96px;
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 12px;
padding: 22px;
background: #fff;
border-radius: 0 0 var(--radius-2xl) var(--radius-2xl);
}
.ds-specimen-body.is-column {
align-items: stretch;
flex-direction: column;
}
.ds-note {
padding: 13px 15px;
color: var(--text-secondary);
background: var(--primary-bg);
border: 1px solid var(--primary-border);
border-radius: var(--radius-xl);
font-size: 12.5px;
line-height: 1.55;
}
.ds-note strong {
color: var(--primary-hover);
}
.ds-meta-list {
display: grid;
gap: 8px;
margin: 14px 0 0;
}
.ds-meta-row {
display: grid;
grid-template-columns: 130px minmax(0, 1fr);
gap: 14px;
padding-top: 8px;
border-top: 1px solid var(--n-9);
font-size: 12px;
}
.ds-meta-row span {
color: var(--text-tertiary);
}
.ds-meta-row code {
color: var(--text-secondary);
font-family: var(--font-mono);
}
.ds-chip {
display: inline-flex;
align-items: center;
padding: 4px 8px;
color: var(--text-secondary);
background: var(--n-9);
border: 1px solid var(--n-8);
border-radius: 6px;
font-family: var(--font-mono);
font-size: 10.5px;
}
-106
View File
@@ -1,106 +0,0 @@
.token-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(180px, 1fr));
gap: 10px;
}
.token-card {
overflow: hidden;
background: #fff;
border: 1px solid var(--border-default);
border-radius: var(--radius-xl);
}
.token-swatch {
height: 72px;
border-bottom: 1px solid var(--border-default);
}
.token-info {
padding: 10px 12px;
}
.token-info strong,
.token-info code {
display: block;
}
.token-info strong {
color: var(--text-body);
font-size: 12px;
}
.token-info code {
margin-top: 4px;
color: var(--text-tertiary);
font-family: var(--font-mono);
font-size: 10.5px;
}
.scale-row {
display: grid;
grid-template-columns: 120px minmax(0, 1fr) 90px;
align-items: center;
gap: 14px;
padding: 10px 0;
border-bottom: 1px solid var(--n-9);
}
.scale-row:last-child {
border-bottom: 0;
}
.scale-row code,
.scale-row small {
color: var(--text-tertiary);
font-family: var(--font-mono);
font-size: 11px;
}
.scale-demo {
height: 24px;
display: flex;
align-items: center;
}
.scale-demo i {
height: 8px;
display: block;
background: var(--primary);
border-radius: 4px;
}
.radius-demo,
.shadow-demo {
width: 86px;
height: 56px;
background: #fff;
border: 1px solid var(--n-7);
}
.inventory-toolbar {
display: flex;
align-items: center;
gap: 12px;
margin-bottom: 14px;
}
.inventory-search {
width: min(420px, 100%);
height: 38px;
padding: 0 12px;
color: var(--text-body);
background: #fff;
border: 1px solid var(--border-input);
border-radius: var(--radius-md);
outline: none;
}
.inventory-search:focus {
border-color: var(--primary);
}
.inventory-count {
color: var(--text-tertiary);
font-size: 12px;
}
-2
View File
@@ -1,2 +0,0 @@
@import "./docs-layout.css";
@import "./docs-tokens.css";
@@ -1 +0,0 @@
window.CHATBALLS_ICON_SPRITE="<svg xmlns=\"http://www.w3.org/2000/svg\">\n <symbol id=\"i-grid\" viewBox=\"0 0 24 24\"><rect x=\"3\" y=\"3\" width=\"7\" height=\"9\" rx=\"1.4\"/><rect x=\"14\" y=\"3\" width=\"7\" height=\"5\" rx=\"1.4\"/><rect x=\"14\" y=\"12\" width=\"7\" height=\"9\" rx=\"1.4\"/><rect x=\"3\" y=\"16\" width=\"7\" height=\"5\" rx=\"1.4\"/></symbol>\n <symbol id=\"i-box\" viewBox=\"0 0 24 24\"><path d=\"M21 8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16Z\"/><path d=\"m3.3 7 8.7 5 8.7-5M12 22V12\"/></symbol>\n <symbol id=\"i-list\" viewBox=\"0 0 24 24\"><path d=\"M8 6h10M8 12h10M8 18h10\"/><circle cx=\"4\" cy=\"6\" r=\"1\"/><circle cx=\"4\" cy=\"12\" r=\"1\"/><circle cx=\"4\" cy=\"18\" r=\"1\"/></symbol>\n <symbol id=\"i-columns\" viewBox=\"0 0 24 24\"><rect x=\"3\" y=\"3\" width=\"7\" height=\"7\" rx=\"1\"/><rect x=\"14\" y=\"3\" width=\"7\" height=\"7\" rx=\"1\"/><rect x=\"14\" y=\"14\" width=\"7\" height=\"7\" rx=\"1\"/><rect x=\"3\" y=\"14\" width=\"7\" height=\"7\" rx=\"1\"/></symbol>\n <symbol id=\"i-plus\" viewBox=\"0 0 24 24\"><path d=\"M12 5v14M5 12h14\"/></symbol>\n <symbol id=\"i-search\" viewBox=\"0 0 24 24\"><circle cx=\"11\" cy=\"11\" r=\"8\"/><path d=\"m21 21-4.35-4.35\"/></symbol>\n <symbol id=\"i-more\" viewBox=\"0 0 24 24\"><circle cx=\"12\" cy=\"5\" r=\"1\"/><circle cx=\"12\" cy=\"12\" r=\"1\"/><circle cx=\"12\" cy=\"19\" r=\"1\"/></symbol>\n <symbol id=\"i-chevron\" viewBox=\"0 0 24 24\"><polyline points=\"6 9 12 15 18 9\"/></symbol>\n <symbol id=\"i-bell\" viewBox=\"0 0 24 24\"><path d=\"M6 8a6 6 0 0 1 12 0c0 7 3 9 3 9H3s3-2 3-9M10.3 21a1.94 1.94 0 0 0 3.4 0\"/></symbol>\n <symbol id=\"i-edit\" viewBox=\"0 0 24 24\"><path d=\"M12 20h9M16.5 3.5a2.12 2.12 0 0 1 3 3L7 19l-4 1 1-4Z\"/></symbol>\n <symbol id=\"i-trash\" viewBox=\"0 0 24 24\"><path d=\"M3 6h18M8 6V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6\"/></symbol>\n <symbol id=\"i-warning\" viewBox=\"0 0 24 24\"><path d=\"m21.7 18-8-14a2 2 0 0 0-3.5 0l-8 14A2 2 0 0 0 4 21h16a2 2 0 0 0 1.7-3Z\"/><path d=\"M12 9v4M12 17h.01\"/></symbol>\n <symbol id=\"i-check\" viewBox=\"0 0 24 24\"><path d=\"m20 6-11 11-5-5\"/></symbol>\n <symbol id=\"i-user\" viewBox=\"0 0 24 24\"><path d=\"M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2\"/><circle cx=\"12\" cy=\"7\" r=\"4\"/></symbol>\n <symbol id=\"i-settings\" viewBox=\"0 0 24 24\"><path d=\"M21 6H9M3 6h2M21 12h-8M3 12h6M21 18h-6M3 18h8\"/><circle cx=\"7\" cy=\"6\" r=\"2\"/><circle cx=\"11\" cy=\"12\" r=\"2\"/><circle cx=\"13\" cy=\"18\" r=\"2\"/></symbol>\n <symbol id=\"i-message\" viewBox=\"0 0 24 24\"><path d=\"M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2Z\"/></symbol>\n <symbol id=\"i-copy\" viewBox=\"0 0 24 24\"><rect x=\"9\" y=\"9\" width=\"13\" height=\"13\" rx=\"2\"/><path d=\"M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1\"/></symbol>\n</svg>\n";
-19
View File
@@ -1,19 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg">
<symbol id="i-grid" viewBox="0 0 24 24"><rect x="3" y="3" width="7" height="9" rx="1.4"/><rect x="14" y="3" width="7" height="5" rx="1.4"/><rect x="14" y="12" width="7" height="9" rx="1.4"/><rect x="3" y="16" width="7" height="5" rx="1.4"/></symbol>
<symbol id="i-box" viewBox="0 0 24 24"><path d="M21 8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16Z"/><path d="m3.3 7 8.7 5 8.7-5M12 22V12"/></symbol>
<symbol id="i-list" viewBox="0 0 24 24"><path d="M8 6h10M8 12h10M8 18h10"/><circle cx="4" cy="6" r="1"/><circle cx="4" cy="12" r="1"/><circle cx="4" cy="18" r="1"/></symbol>
<symbol id="i-columns" viewBox="0 0 24 24"><rect x="3" y="3" width="7" height="7" rx="1"/><rect x="14" y="3" width="7" height="7" rx="1"/><rect x="14" y="14" width="7" height="7" rx="1"/><rect x="3" y="14" width="7" height="7" rx="1"/></symbol>
<symbol id="i-plus" viewBox="0 0 24 24"><path d="M12 5v14M5 12h14"/></symbol>
<symbol id="i-search" viewBox="0 0 24 24"><circle cx="11" cy="11" r="8"/><path d="m21 21-4.35-4.35"/></symbol>
<symbol id="i-more" viewBox="0 0 24 24"><circle cx="12" cy="5" r="1"/><circle cx="12" cy="12" r="1"/><circle cx="12" cy="19" r="1"/></symbol>
<symbol id="i-chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></symbol>
<symbol id="i-bell" viewBox="0 0 24 24"><path d="M6 8a6 6 0 0 1 12 0c0 7 3 9 3 9H3s3-2 3-9M10.3 21a1.94 1.94 0 0 0 3.4 0"/></symbol>
<symbol id="i-edit" viewBox="0 0 24 24"><path d="M12 20h9M16.5 3.5a2.12 2.12 0 0 1 3 3L7 19l-4 1 1-4Z"/></symbol>
<symbol id="i-trash" viewBox="0 0 24 24"><path d="M3 6h18M8 6V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6"/></symbol>
<symbol id="i-warning" viewBox="0 0 24 24"><path d="m21.7 18-8-14a2 2 0 0 0-3.5 0l-8 14A2 2 0 0 0 4 21h16a2 2 0 0 0 1.7-3Z"/><path d="M12 9v4M12 17h.01"/></symbol>
<symbol id="i-check" viewBox="0 0 24 24"><path d="m20 6-11 11-5-5"/></symbol>
<symbol id="i-user" viewBox="0 0 24 24"><path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"/><circle cx="12" cy="7" r="4"/></symbol>
<symbol id="i-settings" viewBox="0 0 24 24"><path d="M21 6H9M3 6h2M21 12h-8M3 12h6M21 18h-6M3 18h8"/><circle cx="7" cy="6" r="2"/><circle cx="11" cy="12" r="2"/><circle cx="13" cy="18" r="2"/></symbol>
<symbol id="i-message" viewBox="0 0 24 24"><path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2Z"/></symbol>
<symbol id="i-copy" viewBox="0 0 24 24"><rect x="9" y="9" width="13" height="13" rx="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/></symbol>
</svg>

Before

Width:  |  Height:  |  Size: 2.6 KiB

@@ -1 +0,0 @@
window.CHATBALLS_INVENTORY={"version":"0.1.0","generatedFrom":"apps/internal-ui/src","generatedOn":"2026-07-30","count":315,"items":[{"name":"Link","kind":"CSS component","category":"Actions","scope":"System","visibility":"system","source":"src/shared/links.css","variants":"base, strong, neutral, muted, mono, icon","documentedAt":"components.html"},{"name":"AppDropdown","kind":"CSS pattern","category":"Overlays","scope":"System","visibility":"system","source":"src/shared/menu.css","variants":"default, wide, channels, warning, danger, success","documentedAt":"patterns.html"},{"name":"BaselineTable","kind":"CSS pattern","category":"Tables","scope":"System","visibility":"system","source":"src/shared/table-controls.css","variants":"header, row hover, numeric, actions","documentedAt":"patterns.html"},{"name":"FilterBar","kind":"CSS pattern","category":"Tables","scope":"System","visibility":"system","source":"src/shared/table-controls.css","variants":"default, employees","documentedAt":"patterns.html"},{"name":"TableCard","kind":"CSS pattern","category":"Tables","scope":"System","visibility":"system","source":"src/shared/table-controls.css","variants":"default, scroll, footer","documentedAt":"patterns.html"},{"name":"ApplicationShell","kind":"Layout pattern","category":"Layouts","scope":"System","visibility":"system","source":"src/layout/*.css","variants":"sidebar, topbar, scroll, page","documentedAt":"layouts.html"},{"name":"ConversationWorkspacePattern","kind":"Page pattern","category":"Layouts","scope":"System","visibility":"system","source":"features/conversations","variants":"dialog list, thread, context","documentedAt":"layouts.html"},{"name":"DetailPage","kind":"Page pattern","category":"Layouts","scope":"System","visibility":"system","source":"feature detail pages","variants":"header, main content, rail","documentedAt":"layouts.html"},{"name":"ListPage","kind":"Page pattern","category":"Layouts","scope":"System","visibility":"system","source":"feature list pages","variants":"header, filters, table, pagination","documentedAt":"layouts.html"},{"name":"SettingsPagePattern","kind":"Page pattern","category":"Layouts","scope":"System","visibility":"system","source":"settings/profile/administration","variants":"section cards, forms, local actions","documentedAt":"layouts.html"},{"name":"App","kind":"React component","category":"Application","scope":"Application","visibility":"exported","source":"apps/internal-ui/src/App.tsx","variants":"—","documentedAt":""},{"name":"SurfaceApp","kind":"React component","category":"Application","scope":"Application","visibility":"local","source":"apps/internal-ui/src/main.tsx","variants":"—","documentedAt":""},{"name":"AdministrationPage","kind":"React component","category":"Feature: administration","scope":"Feature","visibility":"exported","source":"apps/internal-ui/src/features/administration/AdministrationPage.tsx","variants":"—","documentedAt":""},{"name":"AuditTable","kind":"React component","category":"Feature: administration","scope":"Feature","visibility":"exported","source":"apps/internal-ui/src/features/administration/AuditTable.tsx","variants":"—","documentedAt":""},{"name":"CloudSubscriptionPanel","kind":"React component","category":"Feature: administration","scope":"Feature","visibility":"exported","source":"apps/internal-ui/src/features/administration/CloudSubscriptionPanel.tsx","variants":"—","documentedAt":""},{"name":"OrganizationLogoField","kind":"React component","category":"Feature: administration","scope":"Feature","visibility":"exported","source":"apps/internal-ui/src/features/administration/OrganizationLogoField.tsx","variants":"—","documentedAt":""},{"name":"OrganizationSettingsForm","kind":"React component","category":"Feature: administration","scope":"Feature","visibility":"exported","source":"apps/internal-ui/src/features/administration/OrganizationSettingsForm.tsx","variants":"—","documentedAt":""},{"name":"AgentEditForm","kind":"React component","category":"Feature: ai","scope":"Feature","visibility":"exported","source":"apps/internal-ui/src/features/ai/detail/AgentEditForm.tsx","variants":"—","documentedAt":""},{"name":"AgentsKpiStrip","kind":"React component","category":"Feature: ai","scope":"Feature","visibility":"exported","source":"apps/internal-ui/src/features/ai/AgentsKpiStrip.tsx","variants":"—","documentedAt":""},{"name":"AgentsTable","kind":"React component","category":"Feature: ai","scope":"Feature","visibility":"exported","source":"apps/internal-ui/src/features/ai/AgentsTable.tsx","variants":"—","documentedAt":""},{"name":"AiAgentCreatePage","kind":"React component","category":"Feature: ai","scope":"Feature","visibility":"exported","source":"apps/internal-ui/src/features/ai/create/AiAgentCreatePage.tsx","variants":"—","documentedAt":""},{"name":"AiAgentDetailHeader","kind":"React component","category":"Feature: ai","scope":"Feature","visibility":"exported","source":"apps/internal-ui/src/features/ai/detail/AiAgentDetLine truncated
-272
View File
@@ -1,272 +0,0 @@
.layout-preview {
overflow: hidden;
background: #f0f2f5;
border: 1px solid var(--border-default);
border-radius: var(--radius-2xl);
box-shadow: var(--shadow-sm);
}
.mini-shell {
height: 480px;
display: grid;
grid-template-columns: 174px minmax(0, 1fr);
}
.mini-sidebar {
display: flex;
flex-direction: column;
background: #fff;
border-right: 1px solid var(--border-default);
}
.mini-brand {
height: 48px;
display: flex;
align-items: center;
gap: 8px;
padding: 0 12px;
border-bottom: 1px solid var(--n-9);
font-size: 11px;
font-weight: 600;
}
.mini-brand i {
width: 24px;
height: 24px;
display: grid;
place-items: center;
color: #fff;
background: var(--primary);
border-radius: 6px;
font-style: normal;
}
.mini-nav {
padding: 8px;
}
.mini-nav span {
display: flex;
align-items: center;
gap: 8px;
padding: 7px 8px;
margin-bottom: 2px;
color: var(--text-secondary);
border-radius: 6px;
font-size: 10.5px;
}
.mini-nav span.active {
color: var(--primary-hover);
background: var(--primary-bg);
font-weight: 600;
}
.mini-main {
min-width: 0;
display: flex;
flex-direction: column;
}
.mini-topbar {
height: 48px;
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 16px;
background: #fff;
border-bottom: 1px solid var(--border-default);
color: var(--text-tertiary);
font-size: 10.5px;
}
.mini-content {
flex: 1;
overflow: hidden;
padding: 16px;
}
.mini-page-header {
display: flex;
justify-content: space-between;
margin-bottom: 12px;
}
.mini-page-header strong {
font-size: 16px;
}
.mini-page-header small {
display: block;
margin-top: 3px;
color: var(--text-tertiary);
}
.mini-button {
height: 28px;
padding: 0 10px;
color: #fff;
background: var(--primary);
border: 0;
border-radius: 6px;
font-size: 9.5px;
}
.mini-filter {
height: 42px;
display: flex;
align-items: center;
gap: 8px;
padding: 0 10px;
margin-bottom: 10px;
background: #fff;
border: 1px solid var(--border-default);
border-radius: 8px;
}
.mini-filter i {
width: 110px;
height: 24px;
background: var(--n-10);
border: 1px solid var(--n-7);
border-radius: 6px;
}
.mini-table {
overflow: hidden;
background: #fff;
border: 1px solid var(--border-default);
border-radius: 8px;
}
.mini-table-row {
display: grid;
grid-template-columns: 1.5fr 1fr 1fr 28px;
align-items: center;
min-height: 42px;
padding: 0 10px;
border-bottom: 1px solid var(--n-9);
font-size: 9.5px;
}
.mini-table-row.header {
min-height: 32px;
color: var(--text-tertiary);
background: var(--n-10);
font-size: 8.5px;
font-weight: 600;
}
.mini-table-row:last-child {
border-bottom: 0;
}
.detail-grid {
display: grid;
grid-template-columns: minmax(0, 1fr) 240px;
gap: 14px;
}
.preview-panel {
min-height: 130px;
padding: 14px;
background: #fff;
border: 1px solid var(--border-default);
border-radius: var(--radius-xl);
}
.preview-panel h4 {
margin: 0 0 12px;
font-size: 11px;
}
.preview-field {
height: 30px;
margin-bottom: 8px;
background: var(--n-10);
border: 1px solid var(--n-7);
border-radius: 6px;
}
.conversation-preview {
height: 440px;
display: grid;
grid-template-columns: 210px minmax(0, 1fr) 220px;
background: #fff;
}
.conversation-list,
.conversation-context {
padding: 12px;
border-right: 1px solid var(--border-default);
}
.conversation-context {
border-right: 0;
border-left: 1px solid var(--border-default);
}
.conversation-item {
padding: 10px;
margin-bottom: 6px;
border-radius: 8px;
font-size: 10px;
}
.conversation-item.active {
background: var(--primary-bg);
}
.conversation-thread {
display: flex;
flex-direction: column;
}
.thread-header {
height: 48px;
display: flex;
align-items: center;
padding: 0 14px;
border-bottom: 1px solid var(--border-default);
font-size: 11px;
font-weight: 600;
}
.thread-messages {
flex: 1;
padding: 14px;
background: var(--n-10);
}
.message-bubble {
width: 70%;
padding: 9px 11px;
margin-bottom: 8px;
background: #fff;
border: 1px solid var(--border-default);
border-radius: 9px;
font-size: 9.5px;
}
.message-bubble.own {
margin-left: auto;
background: var(--primary-bg);
border-color: var(--primary-border);
}
.thread-composer {
height: 56px;
display: flex;
align-items: center;
gap: 8px;
padding: 8px 12px;
border-top: 1px solid var(--border-default);
}
.thread-composer i {
height: 34px;
flex: 1;
background: #fff;
border: 1px solid var(--n-7);
border-radius: 8px;
}
-282
View File
@@ -1,282 +0,0 @@
.page-header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 24px;
margin-bottom: 20px;
}
.page-header h1 {
margin: 0;
color: var(--text-heading);
font-size: 24px;
font-weight: 700;
letter-spacing: -0.02em;
line-height: 1.2;
}
.page-header p {
margin: 6px 0 0;
color: var(--text-tertiary);
font-size: 13.5px;
}
.filter-bar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
padding: 12px 14px;
margin-bottom: 14px;
background: #fff;
border: 1px solid var(--border-default);
border-radius: var(--radius-xl);
}
.filter-group {
display: flex;
align-items: center;
gap: 8px;
}
.filter-group > span {
color: var(--text-tertiary);
font-size: 12px;
font-weight: 500;
}
.table-card {
overflow: hidden;
background: #fff;
border: 1px solid var(--border-default);
border-radius: var(--radius-2xl);
box-shadow: var(--shadow-sm);
}
.table-scroll {
overflow-x: auto;
}
.baseline-table {
width: 100%;
border-collapse: collapse;
font-size: 13px;
}
.baseline-table th {
padding: 11px 12px;
color: var(--text-tertiary);
background: var(--n-10);
border-bottom: 1px solid var(--border-default);
font-size: 11px;
font-weight: 600;
text-align: left;
}
.baseline-table th:first-child,
.baseline-table td:first-child {
padding-left: 16px;
}
.baseline-table td {
padding: 12px;
color: var(--text-body);
border-bottom: 1px solid var(--n-9);
vertical-align: middle;
}
.baseline-table tbody tr:hover {
background: #fafbfc;
}
.person-cell,
.product-cell {
display: flex;
align-items: center;
gap: 11px;
}
.person-cell small,
.product-cell small {
display: block;
margin-top: 3px;
color: var(--text-disabled);
font-family: var(--font-mono);
font-size: 11.5px;
}
.row-menu-button {
width: 30px;
height: 30px;
color: var(--text-tertiary);
background: transparent;
border: 0;
border-radius: 7px;
}
.row-menu-button:hover,
.row-menu-button[aria-expanded="true"] {
color: var(--text-body);
background: var(--n-8);
}
.table-footer {
padding: 12px 16px;
border-top: 1px solid var(--n-9);
}
.content-state {
width: min(372px, 100%);
margin: 0 auto;
padding: 40px 24px;
background: #fff;
border: 1px solid var(--border-default);
border-radius: var(--radius-2xl);
box-shadow: var(--shadow-sm);
text-align: center;
}
.content-state-icon {
width: 46px;
height: 46px;
display: flex;
align-items: center;
justify-content: center;
margin: 0 auto 14px;
color: var(--primary);
background: var(--primary-bg);
border-radius: var(--radius-2xl);
font-size: 21px;
}
.content-state-icon.is-warning {
color: var(--warning-text);
background: var(--warning-bg);
}
.content-state > strong {
display: block;
margin-bottom: 5px;
color: var(--text-body);
font-size: 14px;
}
.content-state > p {
margin: 0 0 16px;
color: var(--text-tertiary);
font-size: 12.5px;
line-height: 1.5;
}
.decision-dialog {
width: min(560px, calc(100vw - 48px));
overflow: hidden;
background: #fff;
border-radius: var(--radius-4xl);
box-shadow: var(--shadow-lg);
}
.decision-dialog.is-danger {
border: 1px solid var(--error-border);
}
.decision-dialog-header {
display: flex;
gap: 14px;
padding: 20px 22px 16px;
border-bottom: 1px solid var(--border-default);
}
.decision-dialog-icon {
width: 40px;
height: 40px;
display: flex;
align-items: center;
justify-content: center;
flex: none;
border-radius: var(--radius-xl);
}
.decision-dialog.is-warning .decision-dialog-icon {
color: var(--warning-text);
background: var(--warning-bg);
}
.decision-dialog.is-danger .decision-dialog-icon {
color: var(--error-text);
background: var(--error-bg);
}
.decision-dialog-header h3 {
margin: 0 0 4px;
color: var(--text-heading);
font-size: 15px;
}
.decision-dialog-header p {
margin: 0;
color: var(--text-secondary);
font-size: 12.5px;
line-height: 1.55;
}
.decision-dialog-body {
padding: 16px 22px;
}
.decision-dialog-footer {
display: flex;
align-items: center;
justify-content: flex-end;
gap: 10px;
padding: 14px 22px;
border-top: 1px solid var(--border-default);
}
.dialog-backdrop {
position: fixed;
inset: 0;
z-index: 100;
display: none;
align-items: center;
justify-content: center;
padding: 24px;
background: rgba(0, 0, 0, 0.36);
}
.dialog-backdrop.is-open {
display: flex;
}
.dropdown-demo {
width: 216px;
padding: 5px;
background: #fff;
border: 1px solid var(--border-default);
border-radius: var(--radius-xl);
box-shadow: var(--shadow-lg);
}
.dropdown-demo button {
width: 100%;
min-height: 32px;
display: flex;
align-items: center;
gap: 9px;
padding: 8px 10px;
color: var(--text-body);
background: transparent;
border: 0;
border-radius: 6px;
font-size: 13px;
font-weight: 500;
text-align: left;
}
.dropdown-demo button:hover {
background: var(--n-9);
}
.dropdown-demo button.warning { color: var(--warning-text); }
.dropdown-demo button.danger { color: var(--error-text); }
.dropdown-demo button.success { color: var(--success-text); }
@@ -1,89 +0,0 @@
@media (max-width: 980px) {
.ds-shell {
grid-template-columns: 210px minmax(0, 1fr);
}
.ds-grid.cols-4,
.ds-grid.cols-3 {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.ds-content {
padding-inline: 22px;
}
.conversation-preview {
grid-template-columns: 180px minmax(0, 1fr);
}
.conversation-context {
display: none;
}
}
@media (max-width: 760px) {
.ds-shell {
display: block;
}
.ds-sidebar {
position: static;
width: 100%;
height: auto;
}
.ds-nav {
display: flex;
gap: 4px;
overflow-x: auto;
}
.ds-nav-group,
.ds-sidebar-footer {
display: none;
}
.ds-nav a {
flex: none;
margin: 0;
}
.ds-nav a.is-active::before {
display: none;
}
.ds-topbar {
display: none;
}
.ds-content {
padding: 22px 16px 40px;
}
.ds-page-header,
.ds-section-heading,
.filter-bar,
.ui-table-pagination {
align-items: stretch;
flex-direction: column;
}
.ds-grid.cols-4,
.ds-grid.cols-3,
.ds-grid.cols-2,
.detail-grid {
grid-template-columns: 1fr;
}
.mini-shell {
grid-template-columns: 130px minmax(0, 1fr);
}
.conversation-preview {
grid-template-columns: 1fr;
}
.conversation-list {
display: none;
}
}
-170
View File
@@ -1,170 +0,0 @@
<!doctype html>
<html lang="ru">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Компоненты · Chatballs Design System</title>
<link rel="stylesheet" href="assets/base.css">
<link rel="stylesheet" href="assets/docs.css">
<link rel="stylesheet" href="assets/components.css">
<link rel="stylesheet" href="assets/data.css">
<link rel="stylesheet" href="assets/patterns.css">
<link rel="stylesheet" href="assets/layouts.css">
<link rel="stylesheet" href="assets/responsive.css">
</head>
<body data-page="components">
<div class="ds-shell">
<aside class="ds-sidebar" data-ds-sidebar></aside>
<div class="ds-main">
<header class="ds-topbar">
<div class="ds-breadcrumbs"><span>Design System</span><i>/</i><strong>Компоненты</strong></div>
<span class="ds-version">Shared UI</span>
</header>
<main class="ds-content">
<header class="ds-page-header">
<div>
<div class="ds-eyebrow">Components</div>
<h1>Базовые компоненты</h1>
<p>Статические примеры повторяют варианты и классы компонентов из <code>src/shared</code>. Интерактивные состояния можно переключать прямо на странице.</p>
</div>
</header>
<section class="ds-section">
<div class="ds-section-heading">
<div><h2>Кнопки</h2><p>Основные действия, вторичные действия, danger и компактные controls.</p></div>
<span class="ds-source">ui-controls.tsx · ui-controls.css</span>
</div>
<div class="ds-grid cols-2">
<article class="ds-specimen">
<div class="ds-specimen-header"><strong>Button variants</strong><code>Button</code></div>
<div class="ds-specimen-body">
<button class="primary-button"><svg class="icon"><use href="#i-plus"></use></svg>Создать</button>
<button class="secondary-button">Отмена</button>
<button class="ui-action-button"><svg class="icon"><use href="#i-edit"></use></svg>Изменить</button>
<button class="danger-outline"><svg class="icon"><use href="#i-trash"></use></svg>Удалить</button>
<button class="primary-button" disabled>Недоступно</button>
</div>
</article>
<article class="ds-specimen">
<div class="ds-specimen-header"><strong>Icon buttons</strong><code>IconButton</code></div>
<div class="ds-specimen-body">
<button class="ui-icon-button" aria-label="Редактировать"><svg class="icon"><use href="#i-edit"></use></svg></button>
<button class="ui-icon-button is-bare" aria-label="Дополнительно"><svg class="icon"><use href="#i-more"></use></svg></button>
<button class="ui-icon-button is-danger" aria-label="Удалить"><svg class="icon"><use href="#i-trash"></use></svg></button>
<button class="icon-button" aria-label="Уведомления"><svg class="icon"><use href="#i-bell"></use></svg><b>3</b></button>
<button class="row-menu-button" aria-label="Меню строки"><svg class="icon"><use href="#i-more"></use></svg></button>
</div>
</article>
</div>
</section>
<section class="ds-section">
<div class="ds-section-heading">
<div><h2>Ссылки</h2><p>Одна база <code>.link</code> и модификаторы по роли.</p></div>
<span class="ds-source">links.css</span>
</div>
<article class="ds-specimen">
<div class="ds-specimen-header"><strong>Link variants</strong><code>.link</code></div>
<div class="ds-specimen-body">
<a class="link" href="#">Открыть</a>
<a class="link is-strong" href="#">ООО «ЭДЕВС»</a>
<a class="link is-neutral" href="#">Андрей Ермолаев</a>
<button class="link is-muted">Назад</button>
<a class="link is-mono" href="#">ORD-2026-0142</a>
<a class="link has-icon" href="#"><svg class="icon"><use href="#i-copy"></use></svg>Скопировать</a>
<button class="link" disabled>Недоступно</button>
</div>
</article>
</section>
<section class="ds-section">
<div class="ds-section-heading">
<div><h2>Поля формы</h2><p>Editable, readonly, disabled, invalid, select и textarea.</p></div>
<span class="ds-source">form-controls.tsx · form-controls.css</span>
</div>
<div class="ds-grid cols-2">
<article class="ds-specimen">
<div class="ds-specimen-header"><strong>FormField</strong><code>FormField</code></div>
<div class="ds-specimen-body is-column">
<label class="readonly-field form-field is-editable"><span>Название</span><input value="Основной канал" aria-label="Название"></label>
<label class="readonly-field form-field is-readonly"><span>Идентификатор</span><input value="channel_primary" disabled aria-label="Идентификатор"></label>
<label class="readonly-field form-field is-editable is-invalid"><span>Email</span><input value="wrong-address" aria-label="Email"><small class="form-field-error">Укажите корректный email</small></label>
</div>
</article>
<article class="ds-specimen">
<div class="ds-specimen-header"><strong>Select & textarea</strong><code>SelectField · TextAreaField</code></div>
<div class="ds-specimen-body is-column">
<label class="readonly-field select-like"><span>Статус</span><select aria-label="Статус"><option>Активен</option><option>Неактивен</option></select><svg class="icon select-chevron"><use href="#i-chevron"></use></svg></label>
<label class="readonly-field select-like"><span>Группа</span><span class="select-box"><i class="select-dot" style="background:#4096ff;"></i><select aria-label="Группа"><option>Поддержка</option><option>Продажи</option></select><svg class="icon select-chevron"><use href="#i-chevron"></use></svg></span></label>
<label class="readonly-field select-like is-invalid"><span>Провайдер</span><select aria-label="Провайдер"><option>Не выбран</option></select><svg class="icon select-chevron"><use href="#i-chevron"></use></svg></label>
<label class="readonly-field select-like is-readonly"><span>Группа без прав правки</span><span class="select-box"><span class="select-value">Без группы</span><svg class="icon select-chevron"><use href="#i-chevron"></use></svg></span></label>
<label class="readonly-field form-field is-editable"><span>Описание</span><textarea aria-label="Описание">Канал обработки обращений клиентов.</textarea></label>
</div>
</article>
</div>
</section>
<section class="ds-section">
<div class="ds-section-heading">
<div><h2>Поиск и переключатели</h2><p>Компактный поиск, switch и segmented control.</p></div>
<span class="ds-source">ui-controls.tsx · table-controls.css</span>
</div>
<div class="ds-grid cols-3">
<article class="ds-specimen">
<div class="ds-specimen-header"><strong>SearchInput</strong><code>SearchInput</code></div>
<div class="ds-specimen-body">
<label class="search-control"><svg class="icon"><use href="#i-search"></use></svg><input placeholder="Поиск" aria-label="Поиск"></label>
</div>
</article>
<article class="ds-specimen">
<div class="ds-specimen-header"><strong>SwitchButton</strong><code>.ui-switch</code></div>
<div class="ds-specimen-body">
<button class="ui-switch" type="button" role="switch" aria-checked="false" aria-label="Выключено"><i></i></button>
<button class="ui-switch on" type="button" role="switch" aria-checked="true" aria-label="Включено"><i></i></button>
<button class="ui-switch is-large on" type="button" role="switch" aria-checked="true" aria-label="Крупный"><i></i></button>
</div>
</article>
<article class="ds-specimen">
<div class="ds-specimen-header"><strong>Segmented</strong><code>Segmented</code></div>
<div class="ds-specimen-body">
<div class="segmented" data-segmented><button class="active">Все</button><button>Активные</button><button>Архив</button></div>
</div>
</article>
</div>
</section>
<section class="ds-section">
<div class="ds-section-heading">
<div><h2>Вкладки и пагинация</h2><p>Навигация внутри раздела и управление страницами таблицы.</p></div>
<span class="ds-source">ui-controls.tsx · ui-controls.css</span>
</div>
<div class="ds-grid cols-2">
<article class="ds-specimen">
<div class="ds-specimen-header"><strong>UnderlineTabs</strong><code>UnderlineTabs</code></div>
<div class="ds-specimen-body is-column">
<div class="ui-underline-tabs" data-tabs>
<button class="active">Обзор</button>
<button>Настройки</button>
<button>История <b>12</b></button>
<button disabled>Удалённые</button>
</div>
</div>
</article>
<article class="ds-specimen">
<div class="ds-specimen-header"><strong>TablePagination</strong><code>TablePagination</code></div>
<div class="ds-specimen-body is-column">
<div class="ui-table-pagination">
<span>Показано 1–20 из 74</span>
<nav aria-label="Пагинация"><button disabled>‹</button><button class="active">1</button><button>2</button><button>3</button><button>4</button><button>›</button></nav>
</div>
</div>
</article>
</div>
</section>
</main>
</div>
</div>
<script src="assets/icons-data.js"></script>
<script src="assets/design-system.js"></script>
</body>
</html>
-169
View File
@@ -1,169 +0,0 @@
<!doctype html>
<html lang="ru">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Данные и статусы · Chatballs Design System</title>
<link rel="stylesheet" href="assets/base.css">
<link rel="stylesheet" href="assets/docs.css">
<link rel="stylesheet" href="assets/components.css">
<link rel="stylesheet" href="assets/data.css">
<link rel="stylesheet" href="assets/patterns.css">
<link rel="stylesheet" href="assets/layouts.css">
<link rel="stylesheet" href="assets/responsive.css">
</head>
<body data-page="data-display">
<div class="ds-shell">
<aside class="ds-sidebar" data-ds-sidebar></aside>
<div class="ds-main">
<header class="ds-topbar">
<div class="ds-breadcrumbs"><span>Design System</span><i>/</i><strong>Данные и статусы</strong></div>
<span class="ds-version">Data display</span>
</header>
<main class="ds-content">
<header class="ds-page-header">
<div>
<div class="ds-eyebrow">Data display</div>
<h1>Представление данных</h1>
<p>Компоненты для статусов, ролей, продуктов, каналов, идентичности и кратких показателей.</p>
</div>
</header>
<section class="ds-section">
<div class="ds-section-heading">
<div><h2>Статусы</h2><p>Полный набор ключей <code>StatusPillKey</code>.</p></div>
<span class="ds-source">src/shared/ui.tsx</span>
</div>
<article class="ds-specimen">
<div class="ds-specimen-header"><strong>StatusPill</strong><code>12 состояний</code></div>
<div class="ds-specimen-body">
<span class="status-pill is-healthy"><i></i>Работает</span>
<span class="status-pill is-active"><i></i>Активен</span>
<span class="status-pill is-published"><i></i>Опубликован</span>
<span class="status-pill is-blocked"><i></i>Заблокирован</span>
<span class="status-pill is-disabled"><i></i>Неактивен</span>
<span class="status-pill is-invited"><i></i>Приглашён</span>
<span class="status-pill is-archived"><i></i>Архивный</span>
<span class="status-pill is-draft"><i></i>Черновик</span>
<span class="status-pill is-error"><i></i>Ошибка</span>
<span class="status-pill is-pending"><i></i>Проверяется</span>
<span class="status-pill is-unchecked"><i></i>Не проверялось</span>
</div>
</article>
</section>
<section class="ds-section">
<div class="ds-section-heading">
<div><h2>Роли</h2><p>Роли сотрудников организации.</p></div>
<span class="ds-source">src/shared/ui.tsx</span>
</div>
<article class="ds-specimen">
<div class="ds-specimen-header"><strong>RoleBadge</strong><code>OWNER · ADMIN · EMPLOYEE</code></div>
<div class="ds-specimen-body">
<span class="role-badge owner">Владелец</span>
<span class="role-badge admin">Администратор</span>
<span class="role-badge employee">Сотрудник</span>
</div>
</article>
</section>
<section class="ds-section">
<div class="ds-section-heading">
<div><h2>Продукты</h2><p>Плотный tag для таблиц и нейтральный mark для текста.</p></div>
<span class="ds-source">ui.tsx · utils.ts</span>
</div>
<div class="ds-grid cols-2">
<article class="ds-specimen">
<div class="ds-specimen-header"><strong>ProductTag</strong><code>productAccent()</code></div>
<div class="ds-specimen-body">
<span class="product-tag" style="color:#722ed1;background:#f9f0ff"><i style="background:#722ed1"></i>FoxRay</span>
<span class="product-tag" style="color:#0958d9;background:#e6f4ff"><i style="background:#0958d9"></i>Chatballs</span>
<span class="product-tag" style="color:#0958d9;background:#e6f4ff"><i style="background:#0958d9"></i>FirePage</span>
</div>
</article>
<article class="ds-specimen">
<div class="ds-specimen-header"><strong>ProductMark</strong><code>ProductMark</code></div>
<div class="ds-specimen-body">
<span class="product-mark"><i style="background:#722ed1"></i>FoxRay</span>
<span class="product-mark"><i style="background:#0958d9"></i>Chatballs</span>
</div>
</article>
</div>
</section>
<section class="ds-section">
<div class="ds-section-heading">
<div><h2>Каналы</h2><p>Короткие метки провайдеров из общего словаря.</p></div>
<span class="ds-source">badges.tsx · providers.ts</span>
</div>
<article class="ds-specimen">
<div class="ds-specimen-header"><strong>ChannelBadge</strong><code>EMAIL · MAX · TG · WEB</code></div>
<div class="ds-specimen-body">
<span class="channel-badge" style="color:#d48806;background:#fff7e6"><i style="background:#d48806"></i>Email</span>
<span class="channel-badge" style="color:#6b5be0;background:#f2f0ff"><i style="background:#6b5be0"></i>MAX</span>
<span class="channel-badge" style="color:#2f8fd0;background:#eaf6fd"><i style="background:#2f8fd0"></i>TG</span>
<span class="channel-badge" style="color:#0f9b8e;background:#e8f7f4"><i style="background:#0f9b8e"></i>Web</span>
<span class="channel-badge is-plain">Unknown</span>
</div>
</article>
</section>
<section class="ds-section">
<div class="ds-section-heading">
<div><h2>Идентичность и показатели</h2><p>Avatar, KeyValue и MetricBox.</p></div>
<span class="ds-source">ui.tsx · form-controls.tsx</span>
</div>
<div class="ds-grid cols-3">
<article class="ds-specimen">
<div class="ds-specimen-header"><strong>Avatar</strong><code>initials()</code></div>
<div class="ds-specimen-body">
<span class="avatar">АЕ</span>
<span class="avatar">ИК</span>
<span class="avatar">EH</span>
</div>
</article>
<article class="ds-specimen">
<div class="ds-specimen-header"><strong>KeyValue</strong><code>KeyValue</code></div>
<div class="ds-specimen-body is-column">
<div class="key-value"><span>Организация</span><strong>ООО «ЭДЕВС»</strong></div>
<div class="key-value"><span>Владелец</span><strong>Андрей Ермолаев</strong></div>
</div>
</article>
<article class="ds-specimen">
<div class="ds-specimen-header"><strong>MetricBox</strong><code>MetricBox</code></div>
<div class="ds-specimen-body">
<div class="metric-box"><span>Активные агенты</span><strong>6</strong></div>
</div>
</article>
</div>
</section>
<section class="ds-section">
<div class="ds-section-heading">
<div><h2>Иконки</h2><p>Набор <code>Icon</code> из shared. Здесь показаны наиболее частые роли.</p></div>
<span class="ds-source">src/shared/icons.tsx</span>
</div>
<article class="ds-card">
<div class="ds-grid cols-4">
<div class="ds-card" style="box-shadow:none"><svg class="icon" style="font-size:20px"><use href="#i-grid"></use></svg><h3 style="margin-top:10px">grid</h3></div>
<div class="ds-card" style="box-shadow:none"><svg class="icon" style="font-size:20px"><use href="#i-box"></use></svg><h3 style="margin-top:10px">box</h3></div>
<div class="ds-card" style="box-shadow:none"><svg class="icon" style="font-size:20px"><use href="#i-list"></use></svg><h3 style="margin-top:10px">list</h3></div>
<div class="ds-card" style="box-shadow:none"><svg class="icon" style="font-size:20px"><use href="#i-columns"></use></svg><h3 style="margin-top:10px">columns</h3></div>
<div class="ds-card" style="box-shadow:none"><svg class="icon" style="font-size:20px"><use href="#i-search"></use></svg><h3 style="margin-top:10px">search</h3></div>
<div class="ds-card" style="box-shadow:none"><svg class="icon" style="font-size:20px"><use href="#i-more"></use></svg><h3 style="margin-top:10px">more</h3></div>
<div class="ds-card" style="box-shadow:none"><svg class="icon" style="font-size:20px"><use href="#i-edit"></use></svg><h3 style="margin-top:10px">edit</h3></div>
<div class="ds-card" style="box-shadow:none"><svg class="icon" style="font-size:20px"><use href="#i-trash"></use></svg><h3 style="margin-top:10px">trash</h3></div>
<div class="ds-card" style="box-shadow:none"><svg class="icon" style="font-size:20px"><use href="#i-warning"></use></svg><h3 style="margin-top:10px">warning</h3></div>
<div class="ds-card" style="box-shadow:none"><svg class="icon" style="font-size:20px"><use href="#i-bell"></use></svg><h3 style="margin-top:10px">bell</h3></div>
<div class="ds-card" style="box-shadow:none"><svg class="icon" style="font-size:20px"><use href="#i-user"></use></svg><h3 style="margin-top:10px">user</h3></div>
<div class="ds-card" style="box-shadow:none"><svg class="icon" style="font-size:20px"><use href="#i-message"></use></svg><h3 style="margin-top:10px">message</h3></div>
</div>
</article>
</section>
</main>
</div>
</div>
<script src="assets/icons-data.js"></script>
<script src="assets/design-system.js"></script>
</body>
</html>
-178
View File
@@ -1,178 +0,0 @@
<!doctype html>
<html lang="ru">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Токены · Chatballs Design System</title>
<link rel="stylesheet" href="assets/base.css">
<link rel="stylesheet" href="assets/docs.css">
<link rel="stylesheet" href="assets/components.css">
<link rel="stylesheet" href="assets/data.css">
<link rel="stylesheet" href="assets/patterns.css">
<link rel="stylesheet" href="assets/layouts.css">
<link rel="stylesheet" href="assets/responsive.css">
</head>
<body data-page="foundations">
<div class="ds-shell">
<aside class="ds-sidebar" data-ds-sidebar></aside>
<div class="ds-main">
<header class="ds-topbar">
<div class="ds-breadcrumbs"><span>Design System</span><i>/</i><strong>Токены</strong></div>
<span class="ds-version">Foundations</span>
</header>
<main class="ds-content">
<header class="ds-page-header">
<div>
<div class="ds-eyebrow">Foundations</div>
<h1>Токены интерфейса</h1>
<p>Значения собраны из текущих общих CSS-файлов и темы UI. Названия отражают фактическое назначение значений во фронтенде.</p>
</div>
</header>
<section class="ds-section">
<div class="ds-section-heading">
<div><h2>Нейтральная палитра</h2><p>Текст, границы, фоны и disabled-состояния.</p></div>
<span class="ds-source">src/styles/base.css</span>
</div>
<div class="token-grid">
<article class="token-card"><div class="token-swatch" style="background:#1f1f1f"></div><div class="token-info"><strong>neutral.1</strong><code>--n-1 · #1f1f1f</code></div></article>
<article class="token-card"><div class="token-swatch" style="background:#262626"></div><div class="token-info"><strong>neutral.2</strong><code>--n-2 · #262626</code></div></article>
<article class="token-card"><div class="token-swatch" style="background:#595959"></div><div class="token-info"><strong>neutral.3</strong><code>--n-3 · #595959</code></div></article>
<article class="token-card"><div class="token-swatch" style="background:#8c8c8c"></div><div class="token-info"><strong>neutral.4</strong><code>--n-4 · #8c8c8c</code></div></article>
<article class="token-card"><div class="token-swatch" style="background:#bfbfbf"></div><div class="token-info"><strong>neutral.5</strong><code>--n-5 · #bfbfbf</code></div></article>
<article class="token-card"><div class="token-swatch" style="background:#d9d9d9"></div><div class="token-info"><strong>neutral.6</strong><code>--n-6 · #d9d9d9</code></div></article>
<article class="token-card"><div class="token-swatch" style="background:#e8e8e8"></div><div class="token-info"><strong>neutral.7</strong><code>--n-7 · #e8e8e8</code></div></article>
<article class="token-card"><div class="token-swatch" style="background:#f0f0f0"></div><div class="token-info"><strong>neutral.8</strong><code>--n-8 · #f0f0f0</code></div></article>
<article class="token-card"><div class="token-swatch" style="background:#f5f5f5"></div><div class="token-info"><strong>neutral.9</strong><code>--n-9 · #f5f5f5</code></div></article>
<article class="token-card"><div class="token-swatch" style="background:#fafafa"></div><div class="token-info"><strong>neutral.10</strong><code>--n-10 · #fafafa</code></div></article>
</div>
</section>
<section class="ds-section">
<div class="ds-section-heading">
<div><h2>Основные и семантические цвета</h2><p>Интерактивные элементы, статусы и AI-акцент.</p></div>
<span class="ds-source">base.css · theme.ts</span>
</div>
<div class="token-grid">
<article class="token-card"><div class="token-swatch" style="background:#1677ff"></div><div class="token-info"><strong>color.primary</strong><code>--primary · #1677ff</code></div></article>
<article class="token-card"><div class="token-swatch" style="background:#0958d9"></div><div class="token-info"><strong>color.primary.hover</strong><code>--primary-hover · #0958d9</code></div></article>
<article class="token-card"><div class="token-swatch" style="background:#e6f4ff"></div><div class="token-info"><strong>color.primary.bg</strong><code>--primary-bg · #e6f4ff</code></div></article>
<article class="token-card"><div class="token-swatch" style="background:#91caff"></div><div class="token-info"><strong>color.primary.border</strong><code>--primary-border · #91caff</code></div></article>
<article class="token-card"><div class="token-swatch" style="background:#52c41a"></div><div class="token-info"><strong>color.success</strong><code>--success · #52c41a</code></div></article>
<article class="token-card"><div class="token-swatch" style="background:#f6ffed"></div><div class="token-info"><strong>color.success.bg</strong><code>--success-bg · #f6ffed</code></div></article>
<article class="token-card"><div class="token-swatch" style="background:#d48806"></div><div class="token-info"><strong>color.warning.text</strong><code>--warning-text · #d48806</code></div></article>
<article class="token-card"><div class="token-swatch" style="background:#fffbe6"></div><div class="token-info"><strong>color.warning.bg</strong><code>--warning-bg · #fffbe6</code></div></article>
<article class="token-card"><div class="token-swatch" style="background:#cf1322"></div><div class="token-info"><strong>color.error.text</strong><code>--error-text · #cf1322</code></div></article>
<article class="token-card"><div class="token-swatch" style="background:#fff2f0"></div><div class="token-info"><strong>color.error.bg</strong><code>--error-bg · #fff2f0</code></div></article>
<article class="token-card"><div class="token-swatch" style="background:#722ed1"></div><div class="token-info"><strong>color.ai</strong><code>--ai · #722ed1</code></div></article>
<article class="token-card"><div class="token-swatch" style="background:#f9f0ff"></div><div class="token-info"><strong>color.ai.bg</strong><code>--ai-bg · #f9f0ff</code></div></article>
</div>
</section>
<section class="ds-section">
<div class="ds-section-heading">
<div><h2>Каналы</h2><p>Акцентные пары из общего словаря провайдеров.</p></div>
<span class="ds-source">src/shared/providers.ts</span>
</div>
<div class="token-grid">
<article class="token-card"><div class="token-swatch" style="background:#fff7e6"></div><div class="token-info"><strong>provider.email.bg</strong><code>#fff7e6 · text #d48806</code></div></article>
<article class="token-card"><div class="token-swatch" style="background:#f2f0ff"></div><div class="token-info"><strong>provider.max.bg</strong><code>#f2f0ff · text #6b5be0</code></div></article>
<article class="token-card"><div class="token-swatch" style="background:#eaf6fd"></div><div class="token-info"><strong>provider.telegram.bg</strong><code>#eaf6fd · text #2f8fd0</code></div></article>
<article class="token-card"><div class="token-swatch" style="background:#e8f7f4"></div><div class="token-info"><strong>provider.web.bg</strong><code>#e8f7f4 · text #0f9b8e</code></div></article>
</div>
</section>
<section class="ds-section">
<div class="ds-section-heading">
<div><h2>Типографика</h2><p>Системный sans-serif и моноширинный стек для идентификаторов.</p></div>
<span class="ds-source">base.css · page.css</span>
</div>
<div class="ds-grid cols-2">
<article class="ds-specimen">
<div class="ds-specimen-header"><strong>Основной шрифт</strong><code>--font-sans</code></div>
<div class="ds-specimen-body is-column">
<div style="font-size:24px;font-weight:700;letter-spacing:-.02em">Заголовок страницы · 24 / 700</div>
<div style="font-size:14px;font-weight:600">Заголовок компонента · 14 / 600</div>
<div style="font-size:13.5px">Основной текст интерфейса · 13.5 / 400</div>
<div style="font-size:12px;color:#8c8c8c">Подпись поля · 12 / 500</div>
</div>
</article>
<article class="ds-specimen">
<div class="ds-specimen-header"><strong>Моноширинный</strong><code>--font-mono</code></div>
<div class="ds-specimen-body is-column" style="font-family:var(--font-mono)">
<div style="font-size:13px">org_01J9R7Q6ZWPB</div>
<div style="font-size:12px;color:#595959">connection_telegram_primary</div>
<div style="font-size:11px;color:#8c8c8c">2026-07-30T14:13:00Z</div>
</div>
</article>
</div>
</section>
<section class="ds-section">
<div class="ds-section-heading">
<div><h2>Радиусы</h2><p>Корневые токены и используемые малые значения controls.</p></div>
<span class="ds-source">base.css · shared/*.css</span>
</div>
<article class="ds-card">
<div class="scale-row"><code>radius.4</code><div class="radius-demo" style="border-radius:4px"></div><small>4px · focus</small></div>
<div class="scale-row"><code>radius.6</code><div class="radius-demo" style="border-radius:6px"></div><small>6px · badges</small></div>
<div class="scale-row"><code>radius.md</code><div class="radius-demo" style="border-radius:8px"></div><small>8px · controls</small></div>
<div class="scale-row"><code>radius.lg</code><div class="radius-demo" style="border-radius:9px"></div><small>9px · buttons</small></div>
<div class="scale-row"><code>radius.xl</code><div class="radius-demo" style="border-radius:10px"></div><small>10px · cards</small></div>
<div class="scale-row"><code>radius.2xl</code><div class="radius-demo" style="border-radius:12px"></div><small>12px · panels</small></div>
<div class="scale-row"><code>radius.4xl</code><div class="radius-demo" style="border-radius:14px"></div><small>14px · dialogs</small></div>
<div class="scale-row"><code>radius.pill</code><div class="radius-demo" style="border-radius:999px"></div><small>999px · tags</small></div>
</article>
</section>
<section class="ds-section">
<div class="ds-section-heading">
<div><h2>Интервалы и размеры controls</h2><p>Повторяющиеся значения общих компонентов.</p></div>
<span class="ds-source">shared/*.css · layout/*.css</span>
</div>
<div class="ds-grid cols-2">
<article class="ds-card">
<h3>Spacing scale</h3>
<div class="scale-row"><code>space.1</code><div class="scale-demo"><i style="width:4px"></i></div><small>4px</small></div>
<div class="scale-row"><code>space.2</code><div class="scale-demo"><i style="width:6px"></i></div><small>6px</small></div>
<div class="scale-row"><code>space.3</code><div class="scale-demo"><i style="width:8px"></i></div><small>8px</small></div>
<div class="scale-row"><code>space.4</code><div class="scale-demo"><i style="width:12px"></i></div><small>12px</small></div>
<div class="scale-row"><code>space.5</code><div class="scale-demo"><i style="width:14px"></i></div><small>14px</small></div>
<div class="scale-row"><code>space.6</code><div class="scale-demo"><i style="width:16px"></i></div><small>16px</small></div>
<div class="scale-row"><code>space.7</code><div class="scale-demo"><i style="width:20px"></i></div><small>20px</small></div>
<div class="scale-row"><code>space.8</code><div class="scale-demo"><i style="width:24px"></i></div><small>24px</small></div>
<div class="scale-row"><code>space.9</code><div class="scale-demo"><i style="width:28px"></i></div><small>28px</small></div>
</article>
<article class="ds-card">
<h3>Control heights</h3>
<div class="scale-row"><code>control.xs</code><div class="scale-demo"><i style="width:24px;height:24px"></i></div><small>24px</small></div>
<div class="scale-row"><code>control.sm</code><div class="scale-demo"><i style="width:30px;height:30px"></i></div><small>30px</small></div>
<div class="scale-row"><code>control.compact</code><div class="scale-demo"><i style="width:34px;height:34px"></i></div><small>34px</small></div>
<div class="scale-row"><code>control.action</code><div class="scale-demo"><i style="width:36px;height:36px"></i></div><small>36px</small></div>
<div class="scale-row"><code>control.md</code><div class="scale-demo"><i style="width:38px;height:38px"></i></div><small>38px</small></div>
<div class="scale-row"><code>switch.md</code><div class="scale-demo"><i style="width:42px;height:24px"></i></div><small>42×24</small></div>
<div class="scale-row"><code>switch.lg</code><div class="scale-demo"><i style="width:46px;height:26px"></i></div><small>46×26</small></div>
<div class="scale-row"><code>shell.bar</code><div class="scale-demo"><i style="width:56px;height:20px"></i></div><small>56px</small></div>
</article>
</div>
</section>
<section class="ds-section">
<div class="ds-section-heading">
<div><h2>Тени</h2><p>Общие elevation-уровни и характерные component shadows.</p></div>
<span class="ds-source">base.css · shared/*.css</span>
</div>
<article class="ds-card">
<div class="scale-row"><code>shadow.xs</code><div class="shadow-demo" style="box-shadow:var(--shadow-xs)"></div><small>cards</small></div>
<div class="scale-row"><code>shadow.sm</code><div class="shadow-demo" style="box-shadow:var(--shadow-sm)"></div><small>panels</small></div>
<div class="scale-row"><code>shadow.button</code><div class="shadow-demo" style="box-shadow:0 1px 2px rgba(22,119,255,.3)"></div><small>primary</small></div>
<div class="scale-row"><code>shadow.lg</code><div class="shadow-demo" style="box-shadow:var(--shadow-lg)"></div><small>dialogs</small></div>
</article>
</section>
</main>
</div>
</div>
<script src="assets/icons-data.js"></script>
<script src="assets/design-system.js"></script>
</body>
</html>
-113
View File
@@ -1,113 +0,0 @@
<!doctype html>
<html lang="ru">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Chatballs Design System</title>
<link rel="stylesheet" href="assets/base.css">
<link rel="stylesheet" href="assets/docs.css">
<link rel="stylesheet" href="assets/components.css">
<link rel="stylesheet" href="assets/data.css">
<link rel="stylesheet" href="assets/patterns.css">
<link rel="stylesheet" href="assets/layouts.css">
<link rel="stylesheet" href="assets/responsive.css">
</head>
<body data-page="index">
<div class="ds-shell">
<aside class="ds-sidebar" data-ds-sidebar></aside>
<div class="ds-main">
<header class="ds-topbar">
<div class="ds-breadcrumbs"><span>Design System</span><i>/</i><strong>Обзор</strong></div>
<span class="ds-version">Текущий frontend</span>
</header>
<main class="ds-content">
<header class="ds-page-header">
<div>
<div class="ds-eyebrow">Chatballs UI inventory</div>
<h1>Дизайн-система текущего фронтенда</h1>
<p>Статический справочник существующих токенов, компонентов, паттернов и компоновок Chatballs. Примеры повторяют действующие CSS-классы и визуальные решения из <code>apps/internal-ui/src</code>.</p>
</div>
</header>
<div class="ds-note">
<strong>Назначение:</strong> перед разработкой нового интерфейса открыть нужный раздел, найти существующий элемент и использовать его как эталон. Каталог ничего не меняет в приложении и не требует React.
</div>
<section class="ds-section">
<div class="ds-section-heading">
<div><h2>Разделы</h2><p>От базовых значений до компоновки целых экранов.</p></div>
</div>
<div class="ds-grid cols-3">
<a class="ds-card ds-card-link" href="foundations.html">
<span class="ds-card-icon"><svg class="icon"><use href="#i-box"></use></svg></span>
<h3>Токены</h3>
<p>Цвета, типографика, радиусы, тени, интервалы и размеры controls.</p>
<span class="ds-arrow">Открыть foundations →</span>
</a>
<a class="ds-card ds-card-link" href="components.html">
<span class="ds-card-icon"><svg class="icon"><use href="#i-columns"></use></svg></span>
<h3>Компоненты</h3>
<p>Кнопки, ссылки, поля, селекты, переключатели, вкладки и пагинация.</p>
<span class="ds-arrow">Открыть components →</span>
</a>
<a class="ds-card ds-card-link" href="data-display.html">
<span class="ds-card-icon"><svg class="icon"><use href="#i-list"></use></svg></span>
<h3>Данные и статусы</h3>
<p>Бейджи, статусы, продукты, каналы, аватары, метрики и иконки.</p>
<span class="ds-arrow">Открыть data display →</span>
</a>
<a class="ds-card ds-card-link" href="patterns.html">
<span class="ds-card-icon"><svg class="icon"><use href="#i-settings"></use></svg></span>
<h3>Паттерны</h3>
<p>Таблицы, фильтры, карточки, состояния, меню и decision dialogs.</p>
<span class="ds-arrow">Открыть patterns →</span>
</a>
<a class="ds-card ds-card-link" href="layouts.html">
<span class="ds-card-icon"><svg class="icon"><use href="#i-grid"></use></svg></span>
<h3>Макеты страниц</h3>
<p>Shell, list page, detail page, settings и conversation workspace.</p>
<span class="ds-arrow">Открыть layouts →</span>
</a>
<a class="ds-card ds-card-link" href="inventory.html">
<span class="ds-card-icon"><svg class="icon"><use href="#i-search"></use></svg></span>
<h3>Инвентарь</h3>
<p>Поисковый реестр компонентов, CSS-паттернов, токенов и исходников.</p>
<span class="ds-arrow">Открыть inventory →</span>
</a>
</div>
</section>
<section class="ds-section">
<div class="ds-section-heading">
<div><h2>Уровни системы</h2><p>Как читать и использовать каталог.</p></div>
</div>
<div class="ds-grid cols-4">
<article class="ds-card"><h3>1. Foundations</h3><p>Фактические значения, на которых построен интерфейс: цвет, размер, радиус, тень и типографика.</p></article>
<article class="ds-card"><h3>2. Components</h3><p>Переиспользуемые элементы из <code>src/shared</code> и их состояния.</p></article>
<article class="ds-card"><h3>3. Patterns</h3><p>Устоявшиеся композиции: таблица с фильтрами, карточка, форма, диалог.</p></article>
<article class="ds-card"><h3>4. Layouts</h3><p>Каркас целой страницы или рабочего пространства из существующих частей.</p></article>
</div>
</section>
<section class="ds-section">
<div class="ds-section-heading">
<div><h2>Основные источники</h2><p>Файлы фронтенда, по которым собран этот справочник.</p></div>
</div>
<article class="ds-card">
<div class="ds-meta-list">
<div class="ds-meta-row"><span>Токены</span><code>src/styles/base.css · packages/ui/src/theme.ts</code></div>
<div class="ds-meta-row"><span>Controls</span><code>src/shared/ui-controls.* · form-controls.*</code></div>
<div class="ds-meta-row"><span>Data display</span><code>src/shared/ui.tsx · badges.tsx · design-components.css</code></div>
<div class="ds-meta-row"><span>Таблицы</span><code>src/shared/table-controls.css</code></div>
<div class="ds-meta-row"><span>Layout</span><code>src/layout/styles.css · topbar.css · page.css</code></div>
<div class="ds-meta-row"><span>Иконки</span><code>src/shared/icons.tsx</code></div>
</div>
</article>
</section>
</main>
</div>
</div>
<script src="assets/icons-data.js"></script>
<script src="assets/design-system.js"></script>
</body>
</html>
-60
View File
@@ -1,60 +0,0 @@
<!doctype html>
<html lang="ru">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Инвентарь · Chatballs Design System</title>
<link rel="stylesheet" href="assets/base.css">
<link rel="stylesheet" href="assets/docs.css">
<link rel="stylesheet" href="assets/components.css">
<link rel="stylesheet" href="assets/data.css">
<link rel="stylesheet" href="assets/patterns.css">
<link rel="stylesheet" href="assets/layouts.css">
<link rel="stylesheet" href="assets/responsive.css">
</head>
<body data-page="inventory">
<div class="ds-shell">
<aside class="ds-sidebar" data-ds-sidebar></aside>
<div class="ds-main">
<header class="ds-topbar">
<div class="ds-breadcrumbs"><span>Design System</span><i>/</i><strong>Инвентарь</strong></div>
<span class="ds-version">Inventory</span>
</header>
<main class="ds-content">
<header class="ds-page-header">
<div>
<div class="ds-eyebrow">Inventory</div>
<h1>Инвентарь фронтенда</h1>
<p>Реестр включает PascalCase-компоненты из TSX-файлов, системные CSS-компоненты, дизайн-токены и основные layout-паттерны. Всего записей: <strong data-inventory-total>—</strong>.</p>
</div>
</header>
<div class="ds-note">
<strong>Область:</strong> в таблице отдельно помечены системные, layout-, application- и feature-компоненты. Это инвентаризация существующего состава без оценки и без проверки соблюдения дизайн-системы.
</div>
<section class="ds-section">
<div class="inventory-toolbar">
<input class="inventory-search" type="search" placeholder="Название, категория, исходник или вариант" data-inventory-search aria-label="Поиск по инвентарю">
<span class="inventory-count" data-inventory-count></span>
<a class="link is-muted" href="inventory.json">Открыть JSON</a>
</div>
<div class="table-card">
<div class="table-scroll">
<table class="baseline-table">
<thead>
<tr><th>Название</th><th>Тип</th><th>Категория</th><th>Область</th><th>Исходник</th><th>Варианты / роль</th></tr>
</thead>
<tbody data-inventory-body></tbody>
</table>
</div>
</div>
</section>
</main>
</div>
</div>
<script src="assets/inventory-data.js"></script>
<script src="assets/icons-data.js"></script>
<script src="assets/design-system.js"></script>
</body>
</html>
-1
View File
@@ -1 +0,0 @@
{"version":"0.1.0","generatedFrom":"apps/internal-ui/src","generatedOn":"2026-07-30","count":315,"items":[{"name":"Link","kind":"CSS component","category":"Actions","scope":"System","visibility":"system","source":"src/shared/links.css","variants":"base, strong, neutral, muted, mono, icon","documentedAt":"components.html"},{"name":"AppDropdown","kind":"CSS pattern","category":"Overlays","scope":"System","visibility":"system","source":"src/shared/menu.css","variants":"default, wide, channels, warning, danger, success","documentedAt":"patterns.html"},{"name":"BaselineTable","kind":"CSS pattern","category":"Tables","scope":"System","visibility":"system","source":"src/shared/table-controls.css","variants":"header, row hover, numeric, actions","documentedAt":"patterns.html"},{"name":"FilterBar","kind":"CSS pattern","category":"Tables","scope":"System","visibility":"system","source":"src/shared/table-controls.css","variants":"default, employees","documentedAt":"patterns.html"},{"name":"TableCard","kind":"CSS pattern","category":"Tables","scope":"System","visibility":"system","source":"src/shared/table-controls.css","variants":"default, scroll, footer","documentedAt":"patterns.html"},{"name":"ApplicationShell","kind":"Layout pattern","category":"Layouts","scope":"System","visibility":"system","source":"src/layout/*.css","variants":"sidebar, topbar, scroll, page","documentedAt":"layouts.html"},{"name":"ConversationWorkspacePattern","kind":"Page pattern","category":"Layouts","scope":"System","visibility":"system","source":"features/conversations","variants":"dialog list, thread, context","documentedAt":"layouts.html"},{"name":"DetailPage","kind":"Page pattern","category":"Layouts","scope":"System","visibility":"system","source":"feature detail pages","variants":"header, main content, rail","documentedAt":"layouts.html"},{"name":"ListPage","kind":"Page pattern","category":"Layouts","scope":"System","visibility":"system","source":"feature list pages","variants":"header, filters, table, pagination","documentedAt":"layouts.html"},{"name":"SettingsPagePattern","kind":"Page pattern","category":"Layouts","scope":"System","visibility":"system","source":"settings/profile/administration","variants":"section cards, forms, local actions","documentedAt":"layouts.html"},{"name":"App","kind":"React component","category":"Application","scope":"Application","visibility":"exported","source":"apps/internal-ui/src/App.tsx","variants":"—","documentedAt":""},{"name":"SurfaceApp","kind":"React component","category":"Application","scope":"Application","visibility":"local","source":"apps/internal-ui/src/main.tsx","variants":"—","documentedAt":""},{"name":"AdministrationPage","kind":"React component","category":"Feature: administration","scope":"Feature","visibility":"exported","source":"apps/internal-ui/src/features/administration/AdministrationPage.tsx","variants":"—","documentedAt":""},{"name":"AuditTable","kind":"React component","category":"Feature: administration","scope":"Feature","visibility":"exported","source":"apps/internal-ui/src/features/administration/AuditTable.tsx","variants":"—","documentedAt":""},{"name":"CloudSubscriptionPanel","kind":"React component","category":"Feature: administration","scope":"Feature","visibility":"exported","source":"apps/internal-ui/src/features/administration/CloudSubscriptionPanel.tsx","variants":"—","documentedAt":""},{"name":"OrganizationLogoField","kind":"React component","category":"Feature: administration","scope":"Feature","visibility":"exported","source":"apps/internal-ui/src/features/administration/OrganizationLogoField.tsx","variants":"—","documentedAt":""},{"name":"OrganizationSettingsForm","kind":"React component","category":"Feature: administration","scope":"Feature","visibility":"exported","source":"apps/internal-ui/src/features/administration/OrganizationSettingsForm.tsx","variants":"—","documentedAt":""},{"name":"AgentEditForm","kind":"React component","category":"Feature: ai","scope":"Feature","visibility":"exported","source":"apps/internal-ui/src/features/ai/detail/AgentEditForm.tsx","variants":"—","documentedAt":""},{"name":"AgentsKpiStrip","kind":"React component","category":"Feature: ai","scope":"Feature","visibility":"exported","source":"apps/internal-ui/src/features/ai/AgentsKpiStrip.tsx","variants":"—","documentedAt":""},{"name":"AgentsTable","kind":"React component","category":"Feature: ai","scope":"Feature","visibility":"exported","source":"apps/internal-ui/src/features/ai/AgentsTable.tsx","variants":"—","documentedAt":""},{"name":"AiAgentCreatePage","kind":"React component","category":"Feature: ai","scope":"Feature","visibility":"exported","source":"apps/internal-ui/src/features/ai/create/AiAgentCreatePage.tsx","variants":"—","documentedAt":""},{"name":"AiAgentDetailHeader","kind":"React component","category":"Feature: ai","scope":"Feature","visibility":"exported","source":"apps/internal-ui/src/features/ai/detail/AiAgentDetailHeader.tsx","variants":"Line truncated
-134
View File
@@ -1,134 +0,0 @@
<!doctype html>
<html lang="ru">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Макеты страниц · Chatballs Design System</title>
<link rel="stylesheet" href="assets/base.css">
<link rel="stylesheet" href="assets/docs.css">
<link rel="stylesheet" href="assets/components.css">
<link rel="stylesheet" href="assets/data.css">
<link rel="stylesheet" href="assets/patterns.css">
<link rel="stylesheet" href="assets/layouts.css">
<link rel="stylesheet" href="assets/responsive.css">
</head>
<body data-page="layouts">
<div class="ds-shell">
<aside class="ds-sidebar" data-ds-sidebar></aside>
<div class="ds-main">
<header class="ds-topbar">
<div class="ds-breadcrumbs"><span>Design System</span><i>/</i><strong>Макеты страниц</strong></div>
<span class="ds-version">Layouts</span>
</header>
<main class="ds-content">
<header class="ds-page-header">
<div>
<div class="ds-eyebrow">Layouts</div>
<h1>Компоновка экранов</h1>
<p>Наглядные схемы существующих типов экранов. Они показывают состав и иерархию, сохраняя текущий shell, интервалы, поверхности и controls.</p>
</div>
</header>
<section class="ds-section">
<div class="ds-section-heading">
<div><h2>Application shell + list page</h2><p>Sidebar, topbar, page header, filter bar и table card.</p></div>
<span class="ds-source">layout/* · list features</span>
</div>
<div class="layout-preview mini-shell">
<aside class="mini-sidebar">
<div class="mini-brand"><i>C</i>Chatballs</div>
<nav class="mini-nav">
<span><svg class="icon"><use href="#i-grid"></use></svg>Обзор</span>
<span><svg class="icon"><use href="#i-message"></use></svg>Диалоги</span>
<span class="active"><svg class="icon"><use href="#i-list"></use></svg>Сотрудники</span>
<span><svg class="icon"><use href="#i-box"></use></svg>Продукты</span>
<span><svg class="icon"><use href="#i-settings"></use></svg>Настройки</span>
</nav>
</aside>
<div class="mini-main">
<header class="mini-topbar"><span>Организация / Сотрудники</span><span>Рабочий контур</span></header>
<div class="mini-content">
<div class="mini-page-header"><div><strong>Сотрудники</strong><small>Управление доступом и ролями</small></div><button class="mini-button">+ Добавить</button></div>
<div class="mini-filter"><i></i><span class="ds-chip">Все</span><span class="ds-chip">Активные</span></div>
<div class="mini-table">
<div class="mini-table-row header"><span>Сотрудник</span><span>Роль</span><span>Статус</span><span></span></div>
<div class="mini-table-row"><span>Андрей Ермолаев</span><span>Владелец</span><span>Активен</span><span>•••</span></div>
<div class="mini-table-row"><span>Ирина Ковалева</span><span>Администратор</span><span>Приглашён</span><span>•••</span></div>
<div class="mini-table-row"><span>Максим Серов</span><span>Сотрудник</span><span>Неактивен</span><span>•••</span></div>
<div class="mini-table-row"><span>Елена Миронова</span><span>Сотрудник</span><span>Активен</span><span>•••</span></div>
</div>
</div>
</div>
</div>
</section>
<section class="ds-section">
<div class="ds-section-heading">
<div><h2>Detail page</h2><p>Основная колонка, боковой rail и секции редактирования.</p></div>
<span class="ds-source">employee/channel detail features</span>
</div>
<article class="ds-specimen">
<div class="ds-specimen-header"><strong>Entity detail</strong><code>header + content + rail</code></div>
<div class="ds-specimen-body is-column" style="background:#f0f2f5">
<div class="page-header">
<div><h1>Telegram · Продажи</h1><p>Канал обработки обращений отдела продаж.</p></div>
<div style="display:flex;gap:8px"><button class="secondary-button">Назад</button><button class="primary-button">Сохранить</button></div>
</div>
<div class="detail-grid">
<div class="ds-grid">
<section class="preview-panel"><h4>Основные параметры</h4><div class="preview-field"></div><div class="preview-field"></div><div class="preview-field"></div></section>
<section class="preview-panel"><h4>Назначение и политика</h4><div class="preview-field"></div><div class="preview-field"></div></section>
</div>
<aside class="ds-grid">
<section class="preview-panel"><h4>Статус</h4><span class="status-pill is-healthy"><i></i>Работает</span></section>
<section class="preview-panel"><h4>Подключение</h4><div class="key-value"><span>Провайдер</span><strong>Telegram</strong></div></section>
</aside>
</div>
</div>
</article>
</section>
<section class="ds-section">
<div class="ds-section-heading">
<div><h2>Settings page</h2><p>Последовательность тематических секций с локальными действиями.</p></div>
<span class="ds-source">settings · profile · administration</span>
</div>
<article class="ds-specimen">
<div class="ds-specimen-header"><strong>Settings composition</strong><code>page header + section cards</code></div>
<div class="ds-specimen-body is-column" style="background:#f0f2f5">
<div class="page-header"><div><h1>Настройки организации</h1><p>Основные параметры, безопасность и уведомления.</p></div></div>
<div class="ds-grid cols-2">
<section class="preview-panel"><h4>Организация</h4><div class="preview-field"></div><div class="preview-field"></div><button class="primary-button" style="margin-top:4px">Сохранить</button></section>
<section class="preview-panel"><h4>Безопасность</h4><div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:12px"><span style="font-size:12px">Двухфакторная аутентификация</span><button class="ui-switch on" role="switch" aria-checked="true"><i></i></button></div><div style="display:flex;align-items:center;justify-content:space-between"><span style="font-size:12px">Уведомления о входе</span><button class="ui-switch" role="switch" aria-checked="false"><i></i></button></div></section>
</div>
</div>
</article>
</section>
<section class="ds-section">
<div class="ds-section-heading">
<div><h2>Conversation workspace</h2><p>Список диалогов, центральный thread и контекст клиента.</p></div>
<span class="ds-source">features/conversations</span>
</div>
<div class="layout-preview conversation-preview">
<aside class="conversation-list">
<label class="search-control" style="height:32px;margin-bottom:10px"><svg class="icon"><use href="#i-search"></use></svg><input placeholder="Поиск" aria-label="Поиск"></label>
<div class="conversation-item active"><strong>Иван Петров</strong><br><span style="color:#8c8c8c">Вопрос по тарифу</span></div>
<div class="conversation-item"><strong>ООО «Вектор»</strong><br><span style="color:#8c8c8c">Новая заявка</span></div>
<div class="conversation-item"><strong>Мария Орлова</strong><br><span style="color:#8c8c8c">Настройка интеграции</span></div>
</aside>
<section class="conversation-thread">
<header class="thread-header">Иван Петров · Telegram</header>
<div class="thread-messages"><div class="message-bubble">Здравствуйте! Подскажите, какой тариф подходит для двух агентов?</div><div class="message-bubble own">Для двух активных ИИ-агентов подойдёт тариф Startup.</div></div>
<footer class="thread-composer"><i></i><button class="ui-icon-button" aria-label="Отправить"><svg class="icon"><use href="#i-message"></use></svg></button></footer>
</section>
<aside class="conversation-context"><h4 style="margin:0 0 14px;font-size:11px">Контекст</h4><div class="key-value" style="margin-bottom:12px"><span>Продукт</span><strong>Chatballs</strong></div><div class="key-value" style="margin-bottom:12px"><span>Канал</span><strong>Telegram</strong></div><div class="key-value"><span>Статус</span><strong>Открыт</strong></div></aside>
</div>
</section>
</main>
</div>
</div>
<script src="assets/icons-data.js"></script>
<script src="assets/design-system.js"></script>
</body>
</html>
-181
View File
@@ -1,181 +0,0 @@
<!doctype html>
<html lang="ru">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Паттерны · Chatballs Design System</title>
<link rel="stylesheet" href="assets/base.css">
<link rel="stylesheet" href="assets/docs.css">
<link rel="stylesheet" href="assets/components.css">
<link rel="stylesheet" href="assets/data.css">
<link rel="stylesheet" href="assets/patterns.css">
<link rel="stylesheet" href="assets/layouts.css">
<link rel="stylesheet" href="assets/responsive.css">
</head>
<body data-page="patterns">
<div class="ds-shell">
<aside class="ds-sidebar" data-ds-sidebar></aside>
<div class="ds-main">
<header class="ds-topbar">
<div class="ds-breadcrumbs"><span>Design System</span><i>/</i><strong>Паттерны</strong></div>
<span class="ds-version">Compositions</span>
</header>
<main class="ds-content">
<header class="ds-page-header">
<div>
<div class="ds-eyebrow">Patterns</div>
<h1>Композиции компонентов</h1>
<p>Устоявшиеся конструкции текущего интерфейса: заголовок страницы, фильтры, таблица, состояния, меню и диалоги решений.</p>
</div>
</header>
<section class="ds-section">
<div class="ds-section-heading">
<div><h2>Заголовок страницы</h2><p>Название, пояснение и основное действие справа.</p></div>
<span class="ds-source">PageHeader · layout/page.css</span>
</div>
<article class="ds-specimen">
<div class="ds-specimen-header"><strong>PageHeader</strong><code>.page-header</code></div>
<div class="ds-specimen-body is-column" style="background:#f0f2f5">
<div class="page-header">
<div><h1>Сотрудники</h1><p>Управление доступом и ролями сотрудников организации.</p></div>
<button class="primary-button"><svg class="icon"><use href="#i-plus"></use></svg>Добавить сотрудника</button>
</div>
</div>
</article>
</section>
<section class="ds-section">
<div class="ds-section-heading">
<div><h2>Таблица со служебной панелью</h2><p>Filter bar, стандартная таблица, row actions и пагинация.</p></div>
<span class="ds-source">table-controls.css · feature tables</span>
</div>
<article class="ds-specimen">
<div class="ds-specimen-header"><strong>List table pattern</strong><code>.filter-bar · .baseline-table</code></div>
<div class="ds-specimen-body is-column" style="background:#f0f2f5">
<div class="filter-bar">
<label class="search-control" style="width:260px"><svg class="icon"><use href="#i-search"></use></svg><input placeholder="Поиск сотрудников" aria-label="Поиск сотрудников"></label>
<div class="filter-group"><span>Статус</span><div class="segmented" data-segmented><button class="active">Все</button><button>Активные</button><button>Архив</button></div></div>
</div>
<div class="table-card">
<div class="table-scroll">
<table class="baseline-table">
<thead><tr><th>Сотрудник</th><th>Роль</th><th>Статус</th><th>Продукты</th><th></th></tr></thead>
<tbody>
<tr>
<td><div class="person-cell"><span class="avatar">АЕ</span><div><a class="link is-neutral is-strong" href="#">Андрей Ермолаев</a><small>andrey@example.com</small></div></div></td>
<td><span class="role-badge owner">Владелец</span></td>
<td><span class="status-pill is-active"><i></i>Активен</span></td>
<td><span class="product-mark"><i style="background:#0958d9"></i>Chatballs</span></td>
<td><button class="row-menu-button" aria-label="Меню"><svg class="icon"><use href="#i-more"></use></svg></button></td>
</tr>
<tr>
<td><div class="person-cell"><span class="avatar">ИК</span><div><a class="link is-neutral is-strong" href="#">Ирина Ковалева</a><small>irina@example.ru</small></div></div></td>
<td><span class="role-badge admin">Администратор</span></td>
<td><span class="status-pill is-invited"><i></i>Приглашён</span></td>
<td><span class="product-mark"><i style="background:#722ed1"></i>FoxRay</span></td>
<td><button class="row-menu-button" aria-label="Меню"><svg class="icon"><use href="#i-more"></use></svg></button></td>
</tr>
<tr>
<td><div class="person-cell"><span class="avatar">МС</span><div><a class="link is-neutral is-strong" href="#">Максим Серов</a><small>max@example.ru</small></div></div></td>
<td><span class="role-badge employee">Сотрудник</span></td>
<td><span class="status-pill is-disabled"><i></i>Неактивен</span></td>
<td><span class="product-mark"><i style="background:#0958d9"></i>FirePage</span></td>
<td><button class="row-menu-button" aria-label="Меню"><svg class="icon"><use href="#i-more"></use></svg></button></td>
</tr>
</tbody>
</table>
</div>
<div class="table-footer">
<div class="ui-table-pagination"><span>Показано 1–3 из 18</span><nav><button disabled>‹</button><button class="active">1</button><button>2</button><button>3</button><button>›</button></nav></div>
</div>
</div>
</div>
</article>
</section>
<section class="ds-section">
<div class="ds-section-heading">
<div><h2>Состояния контента</h2><p>Пустой раздел, предупреждение и загрузка.</p></div>
<span class="ds-source">ContentState · EmptyState · LoadingState</span>
</div>
<div class="ds-grid cols-3">
<article class="ds-specimen">
<div class="ds-specimen-header"><strong>Primary state</strong><code>ContentState</code></div>
<div class="ds-specimen-body" style="background:#f0f2f5">
<div class="content-state"><span class="content-state-icon"><svg class="icon"><use href="#i-list"></use></svg></span><strong>Данных пока нет</strong><p>Создайте первый элемент, чтобы начать работу.</p><button class="primary-button">Создать</button></div>
</div>
</article>
<article class="ds-specimen">
<div class="ds-specimen-header"><strong>Warning state</strong><code>tone="warning"</code></div>
<div class="ds-specimen-body" style="background:#f0f2f5">
<div class="content-state"><span class="content-state-icon is-warning"><svg class="icon"><use href="#i-warning"></use></svg></span><strong>Подключение требует внимания</strong><p>Проверьте параметры интеграции.</p><button class="secondary-button">Открыть настройки</button></div>
</div>
</article>
<article class="ds-specimen">
<div class="ds-specimen-header"><strong>Inline states</strong><code>EmptyState · LoadingState</code></div>
<div class="ds-specimen-body is-column">
<div style="min-height:72px;display:grid;place-items:center;color:#595959"><strong style="font-size:13px">Загрузка…</strong></div>
<div style="min-height:72px;display:grid;place-items:center;color:#595959;border-top:1px solid #f0f0f0"><strong style="font-size:13px">Нет результатов</strong></div>
</div>
</article>
</div>
</section>
<section class="ds-section">
<div class="ds-section-heading">
<div><h2>Выпадающее меню</h2><p>Единая структура пунктов действий и семантических состояний.</p></div>
<span class="ds-source">menu.css</span>
</div>
<article class="ds-specimen">
<div class="ds-specimen-header"><strong>App dropdown</strong><code>.app-dropdown</code></div>
<div class="ds-specimen-body">
<div class="dropdown-demo">
<button><svg class="icon"><use href="#i-edit"></use></svg>Редактировать</button>
<button class="success"><svg class="icon"><use href="#i-check"></use></svg>Активировать</button>
<button class="warning"><svg class="icon"><use href="#i-warning"></use></svg>Приостановить</button>
<button class="danger"><svg class="icon"><use href="#i-trash"></use></svg>Удалить</button>
</div>
</div>
</article>
</section>
<section class="ds-section">
<div class="ds-section-heading">
<div><h2>Decision dialogs</h2><p>Предупреждающее и опасное подтверждение.</p></div>
<span class="ds-source">DecisionDialog.tsx · design-components.css</span>
</div>
<div class="ds-grid cols-2">
<article class="ds-specimen">
<div class="ds-specimen-header"><strong>Warning</strong><code>tone="warning"</code></div>
<div class="ds-specimen-body"><button class="secondary-button" data-open-dialog="warning-dialog">Открыть предупреждение</button></div>
</article>
<article class="ds-specimen">
<div class="ds-specimen-header"><strong>Danger</strong><code>tone="danger"</code></div>
<div class="ds-specimen-body"><button class="danger-outline" data-open-dialog="danger-dialog">Открыть удаление</button></div>
</article>
</div>
</section>
</main>
</div>
</div>
<div class="dialog-backdrop" id="warning-dialog">
<section class="decision-dialog is-warning" role="dialog" aria-modal="true">
<header class="decision-dialog-header"><span class="decision-dialog-icon"><svg class="icon"><use href="#i-warning"></use></svg></span><div><h3>Приостановить канал?</h3><p>Новые обращения перестанут поступать в этот канал до повторной активации.</p></div></header>
<div class="decision-dialog-body"><div class="key-value"><span>Канал</span><strong>Telegram · Продажи</strong></div></div>
<footer class="decision-dialog-footer"><button class="secondary-button" data-close-dialog>Отмена</button><button class="primary-button" data-close-dialog>Приостановить</button></footer>
</section>
</div>
<div class="dialog-backdrop" id="danger-dialog">
<section class="decision-dialog is-danger" role="dialog" aria-modal="true">
<header class="decision-dialog-header"><span class="decision-dialog-icon"><svg class="icon"><use href="#i-trash"></use></svg></span><div><h3>Удалить подключение?</h3><p>Подключение и связанные с ним настройки будут удалены.</p></div></header>
<div class="decision-dialog-body"><div class="key-value"><span>Подключение</span><strong>Email · support@company.ru</strong></div></div>
<footer class="decision-dialog-footer"><button class="secondary-button" data-close-dialog>Отмена</button><button class="danger-outline" data-close-dialog>Удалить</button></footer>
</section>
</div>
<script src="assets/icons-data.js"></script>
<script src="assets/design-system.js"></script>
</body>
</html>
+240 -9
View File
@@ -1,4 +1,4 @@
import { expect, type Page, test } from "@playwright/test";
import { expect, type Locator, type Page, test } from "@playwright/test";
const ORGANIZATION_PUBLIC_ID = "123e4567-e89b-12d3-a456-426614174000";
const SECOND_ORGANIZATION_PUBLIC_ID = "223e4567-e89b-12d3-a456-426614174000";
@@ -18,6 +18,36 @@ const MANAGER_NAV = ["Чат", "Контакты", "Агенты", "Сотруд
// интерфейс: именно их ждали прежние редакции этих тестов.
const REMOVED_FROM_UI = ["Командный центр", "Отделы", "Продажи", "Каналы", "Подключения", "Продукты"];
// Состояние онбординга «Начало работы». По умолчанию сценарии получают
// пройденный визард — иначе он перекрывал бы проверяемый экран; тесты самого
// онбординга подставляют «ещё не открывал».
const ONBOARDING_STEPS_DONE = {
providerConnected: true,
agentActive: true,
knowledgeFilled: true,
connectionBound: true,
widgetPublished: true,
employeeInvited: true,
platformConfigured: true,
firstConversation: true,
};
type OnboardingMock = { steps: Record<string, boolean>; dismissedAt: string | null; completedAt: string | null };
const ONBOARDING_DISMISSED: OnboardingMock = {
steps: ONBOARDING_STEPS_DONE,
dismissedAt: "2026-01-01T00:00:00Z",
completedAt: "2026-01-01T00:00:00Z",
};
// Человек в системе давно, но онбординга ещё не видел: признаков нет, часть
// шагов уже выполнена по факту.
const ONBOARDING_FRESH: OnboardingMock = {
steps: { ...ONBOARDING_STEPS_DONE, widgetPublished: false, platformConfigured: false, firstConversation: false },
dismissedAt: null,
completedAt: null,
};
const GROUPS = [
{ id: 1, name: "Операторы", color: "#1677ff", memberCount: 1, memberIds: [7], createdAt: "2026-02-02T10:00:00Z" },
{ id: 2, name: "Поддержка", color: "#2aa876", memberCount: 0, memberIds: [], createdAt: "2026-02-02T10:00:00Z" },
@@ -86,6 +116,9 @@ function identityFor(role: Role, memberships = [membershipFor(role)]) {
}
const OWNER_IDENTITY = identityFor("OWNER");
// Администратор организации, но не установки: разделы и шаги про саму
// установку («Платформа», «Домен и почта») ему не показываются.
const ADMIN_IDENTITY = identityFor("ADMIN");
const EMPLOYEE_IDENTITY = identityFor("EMPLOYEE");
const EMPLOYEE_PERMISSIONS = {
@@ -161,7 +194,7 @@ const INVITATION = {
expiresAt: "2026-09-17T10:00:00Z",
};
async function mockInstance(page: Page) {
async function mockInstance(page: Page, onboarding: OnboardingMock = ONBOARDING_DISMISSED, onboardingActions: string[] = []) {
await page.route("**/api/v1/setup/", (route) => route.fulfill({ json: { needsSetup: false } }));
await page.route("**/api/v1/instance/**", (route) => route.fulfill({ json: {} }));
await page.route("**/api/v1/organizations/*/conversations/**", (route) => {
@@ -177,8 +210,20 @@ async function mockInstance(page: Page) {
});
await page.route("**/api/v1/organizations/*/company/**", (route) => {
const path = new URL(route.request().url()).pathname;
if (path.endsWith("/company/launch-checklist/")) {
return route.fulfill({ json: { agentCreated: true, connectionBound: true, employeeInvited: true, done: true } });
if (path.endsWith("/company/onboarding/")) {
if (route.request().method() === "POST") {
const action = JSON.parse(route.request().postData() ?? "{}").action;
onboardingActions.push(action);
const now = "2026-03-01T10:00:00Z";
return route.fulfill({
json: {
steps: onboarding.steps,
dismissedAt: action === "restart" ? null : now,
completedAt: action === "complete" ? now : null,
},
});
}
return route.fulfill({ json: onboarding });
}
return route.fulfill({ json: { items: GROUPS } });
});
@@ -223,9 +268,9 @@ async function mockSession(page: Page, user: object | null) {
);
}
async function login(page: Page, user: object) {
async function login(page: Page, user: object, onboarding: OnboardingMock = ONBOARDING_DISMISSED, onboardingActions: string[] = []) {
await mockSession(page, null);
await mockInstance(page);
await mockInstance(page, onboarding, onboardingActions);
await mockEmployees(page);
await page.route("**/api/v1/auth/login/", (route) => route.fulfill({ json: { authenticated: true, user } }));
await page.goto("/");
@@ -561,10 +606,196 @@ test("экран сотрудников: список, создание, кар
await expect(transferDialog).toBeVisible();
// Кандидатами могут быть только активные администраторы: сотрудника в списке
// быть не должно.
const candidates = transferDialog.locator("select");
await expect(candidates.locator("option")).toHaveCount(1);
await expect(candidates.locator("option")).toContainText("Анна Ким");
await transferDialog.getByRole("button", { name: "Новый владелец" }).click();
const candidates = page.locator(".app-dropdown.is-field .ant-dropdown-menu-item");
await expect(candidates).toHaveCount(1);
await expect(candidates).toContainText("Анна Ким");
await page.keyboard.press("Escape");
await page.screenshot({ path: "test-results/employees-ownership.png", fullPage: true });
await transferDialog.getByRole("button", { name: "Отмена" }).click();
await expect(transferDialog).toHaveCount(0);
});
// Онбординг «Начало работы» (дизайн-базлайн, handoff 2026-09-13). Требование
// заказчика: визард показывается владельцу и администратору, пока он его не
// закроет, — в том числе тем, кто работает в установке давно. Поэтому признак
// «закрыл» живёт на сервере, а не в localStorage, и мок отдаёт его пустым.
/** Ждёт конца ob-rise: до него карточка полупрозрачна и снимок выходит блёклым. */
const settled = (locator: Locator) => expect(locator).toHaveCSS("opacity", "1");
test("онбординг открывается владельцу, который его ещё не закрывал", async ({ page }) => {
const actions: string[] = [];
await login(page, OWNER_IDENTITY, ONBOARDING_FRESH, actions);
const welcome = page.locator(".ob-welcome");
await expect(welcome).toBeVisible();
// Число в заголовке — словом, как в макете, а не цифрой.
await expect(welcome.getByRole("heading")).toHaveText("Восемь шагов до первого ответа клиенту");
// Карточка выезжает 320ms: снимок до конца анимации ловит полупрозрачность.
await settled(welcome);
await page.screenshot({ path: "test-results/onboarding-welcome.png" });
await welcome.getByRole("button", { name: "Начать настройку" }).click();
const wizard = page.locator(".ob-steps");
await expect(wizard).toBeVisible();
// Владелец здесь ещё и администратор установки — значит все восемь шагов.
await expect(wizard.locator(".ob-rail-step")).toHaveCount(8);
await expect(wizard.locator(".ob-chip.is-accent")).toContainText("Шаг 1 из 8");
await expect(wizard.getByRole("heading")).toHaveText("Подключите AI-провайдера");
// Прогресс приходит фактами с сервера, а не нажатиями «Далее».
await expect(wizard.locator(".ob-rail-progress-row span")).toHaveText("Готово 5 из 8");
await settled(wizard);
await page.screenshot({ path: "test-results/onboarding-steps.png" });
await wizard.getByRole("button", { name: "Далее" }).click();
await expect(wizard.getByRole("heading")).toHaveText("Создайте AI-агента");
await wizard.getByRole("button", { name: "Назад" }).click();
await expect(wizard.getByRole("heading")).toHaveText("Подключите AI-провайдера");
// «Закрыть и настроить самому» записывает отказ на сервер, а не в браузер.
await wizard.getByRole("button", { name: "Закрыть и настроить самому" }).click();
await expect(wizard).toHaveCount(0);
await expect.poll(() => actions).toEqual(["dismiss"]);
// Закрытый визард оставляет пилюлю возврата: настроено ещё не всё.
const launcher = page.locator(".ob-launcher");
await expect(launcher).toBeVisible();
await expect(launcher).toContainText("5/8");
await page.screenshot({ path: "test-results/onboarding-launcher.png" });
});
test("администратору организации шаг про домен и почту не показывают", async ({ page }) => {
await login(page, ADMIN_IDENTITY, ONBOARDING_FRESH);
await page.locator(".ob-welcome").getByRole("button", { name: "Начать настройку" }).click();
const wizard = page.locator(".ob-steps");
// Раздел «Платформа» — свойство установки, а не организации: шагов семь, и
// счётчик считает от семи.
await expect(wizard.locator(".ob-rail-step")).toHaveCount(7);
await expect(wizard.locator(".ob-rail-step")).not.toContainText(["Домен и почта"]);
await expect(wizard.locator(".ob-rail-progress-row span")).toHaveText("Готово 5 из 7");
await expect(page.locator(".ob-steps")).toBeVisible();
});
test("у семи шагов заголовок приветствия тоже числом-словом", async ({ page }) => {
await login(page, ADMIN_IDENTITY, ONBOARDING_FRESH);
await expect(page.locator(".ob-welcome").getByRole("heading")).toHaveText("Семь шагов до первого ответа клиенту");
});
test("«Показать где» затемняет приложение и подсвечивает пункт сайдбара", async ({ page }) => {
await login(page, OWNER_IDENTITY, ONBOARDING_FRESH);
const wizard = page.locator(".ob-steps");
await page.locator(".ob-welcome").getByRole("button", { name: "Начать настройку" }).click();
// Второй шаг рассказывает про «Агентов» — их пункт тур и подсвечивает.
await wizard.getByRole("button", { name: "Далее" }).click();
await wizard.getByRole("button", { name: "Показать где" }).click();
await expect(wizard).toHaveCount(0);
const callout = page.locator(".ob-tour-callout");
await expect(callout).toBeVisible();
await expect(callout).toContainText("Раздел «Агенты»");
await expect(page.locator(".ob-tour-dim")).toHaveCount(4);
await settled(callout);
await page.screenshot({ path: "test-results/onboarding-tour.png" });
await callout.getByRole("button", { name: "Понятно, к шагам" }).click();
await expect(page.locator(".ob-tour-callout")).toHaveCount(0);
await expect(wizard.getByRole("heading")).toHaveText("Создайте AI-агента");
});
test("закрытый онбординг открывается заново ссылкой внизу субменю «Настроек»", async ({ page }) => {
await login(page, OWNER_IDENTITY, ONBOARDING_DISMISSED);
// Визард закрыт и всё настроено: ни окна, ни пилюли.
await expect(page.locator(".ob-overlay")).toHaveCount(0);
await expect(page.locator(".ob-launcher")).toHaveCount(0);
// Переход внутри приложения: перезагрузка страницы вернула бы форму входа —
// сессия в моках живёт только до неё.
await page.locator("nav.hub-nav").getByRole("button", { name: "Настройки", exact: true }).click();
const link = page.locator(".settings-subnav-onboarding");
await expect(link).toBeVisible();
await expect(link).toContainText("Начало работы");
await page.screenshot({ path: "test-results/onboarding-settings-link.png" });
await link.click();
await expect(page.locator(".ob-steps")).toBeVisible();
});
test("сотрудник онбординга не видит", async ({ page }) => {
await login(page, EMPLOYEE_IDENTITY, ONBOARDING_FRESH);
await expect(page.locator(".ob-overlay")).toHaveCount(0);
await expect(page.locator(".ob-launcher")).toHaveCount(0);
});
test("последний шаг ведёт на финальный экран и записывает «пройден»", async ({ page }) => {
const actions: string[] = [];
await login(page, OWNER_IDENTITY, ONBOARDING_FRESH, actions);
const wizard = page.locator(".ob-steps");
await page.locator(".ob-welcome").getByRole("button", { name: "Начать настройку" }).click();
// Навигация по рейке свободная: до последнего шага можно дойти одним кликом.
await wizard.locator(".ob-rail-step").last().click();
await expect(wizard.getByRole("heading")).toHaveText("Напишите боту как клиент");
await wizard.getByRole("button", { name: "Завершить" }).click();
const done = page.locator(".ob-done");
await expect(done.getByRole("heading")).toHaveText("Всё готово — можно принимать клиентов");
// Сводка честно показывает, что три шага так и не выполнены по факту.
await expect(done.locator(".ob-summary-item")).toHaveCount(8);
await expect(done.locator(".ob-summary-item.is-done")).toHaveCount(5);
await settled(done);
await page.screenshot({ path: "test-results/onboarding-done.png" });
await done.getByRole("button", { name: "Перейти в чат" }).click();
await expect(page.locator(".ob-overlay")).toHaveCount(0);
await expect(page).toHaveURL(/\/chat$/);
await expect.poll(() => actions).toEqual(["complete"]);
});
test("подсветка тура встаёт ровно по элементу после смены экрана", async ({ page }) => {
await login(page, OWNER_IDENTITY, ONBOARDING_FRESH);
const wizard = page.locator(".ob-steps");
await page.locator(".ob-welcome").getByRole("button", { name: "Начать настройку" }).click();
// Первый шаг ведёт в «Настройки → AI-провайдер». Новый маршрут въезжает
// анимацией `surface-enter` (translateY 6px): замер на первом кадре поставил
// бы рамку на 6px мимо строки, поэтому тур доводит его до остановки.
await wizard.getByRole("button", { name: "Показать где" }).click();
const callout = page.locator(".ob-tour-callout");
await expect(callout).toContainText("Настройки → AI-провайдер");
await settled(callout);
const geometry = await page.evaluate(() => {
const box = (selector: string) => {
const rect = document.querySelector(selector)!.getBoundingClientRect();
return [Math.round(rect.x), Math.round(rect.y), Math.round(rect.width), Math.round(rect.height)];
};
const root = box(".hub-shell");
const target = box('[data-onboarding-target="settings-ai"]');
const dim = [...document.querySelectorAll(".ob-tour-dim")].map((el) => {
const rect = el.getBoundingClientRect();
return [Math.round(rect.x), Math.round(rect.y), Math.round(rect.width), Math.round(rect.height)];
});
return { root, target, spot: box(".ob-tour-spot"), dim };
});
const [x, y, w, h] = geometry.target;
const [, , rootW, rootH] = geometry.root;
// Рамка — цель с отступом 6px со всех сторон.
expect(geometry.spot).toEqual([x - 6, y - 6, w + 12, h + 12]);
// Четыре затемнения смыкаются вокруг неё, не оставляя ни щели, ни просвета.
expect(geometry.dim).toEqual([
[0, 0, rootW, y - 6],
[0, y + h + 6, rootW, rootH - (y + h + 6)],
[0, y - 6, x - 6, h + 12],
[x + w + 6, y - 6, rootW - (x + w + 6), h + 12],
]);
await page.screenshot({ path: "test-results/onboarding-tour-settings.png" });
});