mirror of
https://github.com/dartdavros/chatballs.git
synced 2026-10-05 01:14:58 +03:00
✨ 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:
1 parent
d8420e4379
commit
1cff9e2183
69 files changed
+6017
-3727
No files matched your search
@@ -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;
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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,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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
/* Единый переключатель приложения. Раньше в проекте было четыре его копии
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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";
|
||||
@@ -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="{"$preview":{"width":1440,"height":900},"theme":{"editor":"enum","options":["light","dark"],"default":"light","tsType":"\"light\" | \"dark\"","section":"Тема"},"accent":{"editor":"color","options":["#1677ff","#722ed1","#0f9b8e","#d4380d"],"default":"#1677ff","tsType":"string","section":"Тема"}}">
|
||||
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>
|
||||
@@ -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 приложения.
|
||||
@@ -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";
|
||||
@@ -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();
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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";
|
||||
@@ -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
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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 +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
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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" });
|
||||
});
|
||||
Reference in new issue
Block a user