Compare commits

..
8 Commits
Author SHA1 Message Date
AndreyandClaude Opus 5 4ab26a1bcd 🐛 fix(updates): запускать установщик, а не второй цикл ожидания
Входная точка образа updater'а — сценарий ожидания запросов, поэтому
`docker run ... chatballs-updater-apply.sh` не запускал установку, а
передавал имя сценария аргументом в тот же цикл. Помощник поднимался без
compose-меток, уходил в вечный sleep и никогда не дописывал статус:
интерфейс крутил установку без конца, а новые запросы отбрасывались как
«уже идёт». Та же ошибка была во втором вызове — копировании compose.yaml
на хост.

Обе команды теперь подменяют входную точку. Плюс страховка в цикле: если
помощник исчез, не дописав результат, установка помечается неудачной.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-13 09:16:19 +03:00
AndreyandClaude Opus 5 09fb0177b1 🔥 refactor(ai): убрать учёт расхода на модель и валюту организации
Учёт стоимости вызовов остался без потребителя, когда сняли лимиты, и сам
по себе не работал: цену присылает только OpenRouter, для остальных
провайдеров была прайс-таблица из двух моделей и ноль для всех прочих.
Ни одна цифра расхода в интерфейсе не показывалась. Удалены ai/pricing.py,
ai/limits.py, поля LlmInvocation.cost_micros и currency, статус BLOCKED
вместе со строками журнала, ChatResult.cost_micros и чтение usage.cost,
настройки CHATBALLS_AI_PRICING и CHATBALLS_AI_GLOBAL_DAILY_COST_LIMIT_MICROS,
поля aiCostMicros/aiCostPrevMicros в сводке и стоимость в демо-данных.
Обработка входящих ловит теперь только сбой провайдера — лимиту неоткуда
сработать.

Валюта организации удалена целиком: поле принимало единственное значение
RUB, не читалось нигде и ни одна сумма в продукте в ней не считалась.
Убраны колонка с миграцией, валидация «только рубль», payload настроек и
платформенной выдачи, обязательное поле currency в API провижининга,
типы и транспорт на фронте.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-13 09:02:01 +03:00
AndreyandClaude Opus 5 b76871caf0 🐛 fix(chat): вернуть свёрнутый список диалогов, зелёный акцент по умолчанию
Кнопка «Показать список» жила только в шапке переписки. При пустом списке
или просто без выбранного диалога переписки нет — свёрнутый список
оказывался не вернуть ничем. Теперь кнопка есть и в пустой области, на том
же месте, что и в шапке. Сценарий закрыт e2e-тестом.

Цвет продукта по умолчанию — зелёный #0f9b8e: он же первый в палитре
акцентов профиля. Прежний синий остался вторым пресетом.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-13 08:20:14 +03:00
AndreyandClaude Opus 5 8580b480e7 💄 fix(organization): язык интерфейса селектом, одна вёрстка у обеих форм
После удаления валюты в ряду полей осталась пустая половина. Теперь ряд
занимают часовой пояс и язык интерфейса: язык был сегментом во всю ширину
карточки, стал таким же селектом, как соседнее поле. Подпись под ним
убрана вместе с ключом словаря.

Форма создания организации и «Настройки → Организация» — одна форма по
смыслу, поэтому у них теперь одна ширина колонки и одни отступы.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-13 08:12:22 +03:00
AndreyandClaude Opus 5 eff6d7ac0c 🔥 refactor(ai): убрать дневной лимит агента и выбор валюты организации
Дневной бюджет агента (limits.dailyCostUsd) снят вместе с полем. Проверка
работала, а учёт — нет: расход брался из ответа провайдера, а его шлёт
только OpenRouter; для Custom и локальной модели оставалась прайс-таблица
из двух моделей, и на любой другой стоимость записывалась нулём. То есть
на всех провайдерах, кроме OpenRouter, лимит не срабатывал никогда и давал
ложное чувство защиты. Общий лимит установки из переменной окружения
CHATBALLS_AI_GLOBAL_DAILY_COST_LIMIT_MICROS остаётся.

Выбор валюты убран из настроек организации и из формы её создания. Сервер
принимал только RUB, то есть в списке был один вариант, а само поле не
читается нигде: ни одна сумма в продукте не считается в валюте
организации. Колонка в базе остаётся, интерфейс её больше не спрашивает —
сервер проставляет значение сам.

Второй шаг онбординга больше не велит задавать дневной бюджет.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-13 07:54:14 +03:00
AndreyandClaude Opus 5 1cff9e2183 ✨ 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>
2026-09-13 07:22:35 +03:00
AndreyandClaude Fable 5.1 d8420e4379 🔖 release: 1.6.1
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-13 04:30:31 +03:00
AndreyandClaude Fable 5.1 21b8100598 ⚡ fix(updates): проверка канала релизов раз в 15 минут вместо шести часов
Обновление видно на установке в пределах четверти часа после публикации; кнопка «Проверить» по-прежнему делает запрос сразу. Интервал переопределяется CHATBALLS_UPDATE_CHECK_INTERVAL_SECONDS.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-13 04:29:18 +03:00
117 changed files with 6184 additions and 4171 deletions

No files matched your search

+1 -1
View File
@@ -16,7 +16,7 @@ Production deployment / миграция:
- Перед любым изменяющим действием в production сначала представить владельцу точный план миграции и получить его явное согласование. Разрешение на диагностику или общая просьба «исправить» не являются разрешением самостоятельно выбирать архитектуру миграции.
UI / дизайн:
- Никакой отсебятины в UI: не добавлять экраны, блоки, карточки, иконки, тексты, анимации, цвета, layout-решения и состояния, которых нет в утвержденной документации или design-system.
- Никакой отсебятины в UI: не добавлять экраны, блоки, карточки, иконки, тексты, анимации, цвета, layout-решения и состояния, которых нет в утверждённом дизайн-макете (`design/baseline/<фича>/*.dc.html`). Макет — источник истины, README рядом с ним лишь пересказ.
- Если UI-этап еще не наступил, UI не считается реализованным и не должен маскироваться под готовый продуктовый интерфейс.
- Для построения UI использовать существующие компоненты и их стили, если они уже реализованы; если подходящего компонента нет, создавать переиспользуемый компонент в рамках существующей системы.
- Не упрощать UI, анимации, иконки, состояния или поведение по своему усмотрению. Любое отклонение от baseline требует явного согласования до правок.
+1 -1
View File
@@ -1 +1 @@
1.6.0
1.6.1
-6
View File
@@ -128,7 +128,6 @@ def agent_card_payload(channel: Channel, *, knowledge_total: int | None = None)
"model": agent.model,
"providerIntegrationId": agent.provider_integration_id,
"modelParams": agent.model_params,
"limits": agent.limits,
"answerLanguage": agent.answer_language,
"persona": agent.persona,
"tone": agent.tone,
@@ -232,7 +231,6 @@ def update_agent_card(
ai_fields = {
"providerIntegrationId",
"modelParams",
"limits",
"persona",
"tone",
"instructions",
@@ -249,11 +247,8 @@ def update_agent_card(
):
raise ValidationError({"knowledgeIds": t("api.list_of_ids_required")})
model_params = body.get("modelParams", agent.model_params)
limits = body.get("limits", agent.limits)
if not isinstance(model_params, dict):
raise ValidationError({"modelParams": t("api.object_required")})
if not isinstance(limits, dict):
raise ValidationError({"limits": t("api.object_required")})
provider_integration_id = body.get(
"providerIntegrationId", agent.provider_integration_id
)
@@ -270,7 +265,6 @@ def update_agent_card(
provider_integration_id=provider_integration_id,
model_params=model_params,
allowed_tools=agent.allowed_tools,
limits=limits,
persona=str(body.get("persona", agent.persona)),
tone=str(body.get("tone", agent.tone)),
instructions=str(body.get("instructions", agent.instructions)),
+1 -28
View File
@@ -2,7 +2,6 @@ import time
from django.conf import settings
from chatballs.ai import limits, pricing
from chatballs.ai.models import LlmInvocation, LlmInvocationStatus
from chatballs.ai.pii import redact
from chatballs.ai.provider import routing
@@ -19,18 +18,6 @@ from chatballs.ai.provider.resilience import CircuitBreaker, call_with_resilienc
_breaker = CircuitBreaker()
def _record_blocked(*, channel, purpose: str, model: str, error: Exception) -> None:
LlmInvocation.objects.create(
organization=channel.organization,
channel=channel,
purpose=purpose,
operation="chat",
model=model,
status=LlmInvocationStatus.BLOCKED,
error=str(error),
)
def _prepare_invocation(*, channel, requested_model: str | None) -> tuple[LLMProvider, str]:
# BYOK — единственный режим (ADR-CHATBALLS-0042 §3): модель берётся из интеграции
# организации с fallback на модель агента. Без интеграции модель остаётся
@@ -52,18 +39,7 @@ def invoke_chat(
params: dict | None = None,
used_fragment_ids: list | None = None,
) -> ChatResult:
fallback_model = model or channel.ai_agent.model
try:
provider, model = _prepare_invocation(channel=channel, requested_model=model)
limits.assert_within_limits(channel, channel.ai_agent)
except limits.LimitExceeded as error:
_record_blocked(
channel=channel,
purpose=purpose,
model=fallback_model,
error=error,
)
raise
provider, model = _prepare_invocation(channel=channel, requested_model=model)
safe_messages = [ChatMessage(role=item.role, content=redact(item.content)) for item in messages]
started = time.monotonic()
@@ -96,8 +72,6 @@ def invoke_chat(
prompt_tokens=result.prompt_tokens,
completion_tokens=result.completion_tokens,
total_tokens=result.total_tokens,
cost_micros=result.cost_micros
or pricing.cost_micros(result.model, result.prompt_tokens, result.completion_tokens),
latency_ms=int((time.monotonic() - started) * 1000),
status=LlmInvocationStatus.SUCCESS,
used_fragment_ids=used_fragment_ids or [],
@@ -128,7 +102,6 @@ def embed_texts(
model=model,
prompt_tokens=tokens,
total_tokens=tokens,
cost_micros=pricing.cost_micros(model, tokens, 0),
status=LlmInvocationStatus.SUCCESS,
)
return results
-32
View File
@@ -1,32 +0,0 @@
from django.conf import settings
from django.db.models import Sum
from django.utils import timezone
from chatballs.ai.models import LlmInvocation, LlmInvocationStatus
class LimitExceeded(Exception):
pass
def _day_start():
now = timezone.localtime()
return now.replace(hour=0, minute=0, second=0, microsecond=0)
def daily_cost_micros(channel=None) -> int:
queryset = LlmInvocation.objects.filter(created_at__gte=_day_start(), status=LlmInvocationStatus.SUCCESS)
if channel is not None:
queryset = queryset.filter(channel=channel)
return queryset.aggregate(total=Sum("cost_micros"))["total"] or 0
def assert_within_limits(channel, agent) -> None:
global_limit = settings.CHATBALLS_AI_GLOBAL_DAILY_COST_LIMIT_MICROS
if global_limit and daily_cost_micros() >= global_limit:
raise LimitExceeded("Global daily AI cost limit reached")
# Канальный лимит хранится в целых центах USD (dailyCostUsd); расход учитывается
# в micro-USD. 1 цент = 10 000 micro-USD.
channel_limit = (agent.limits or {}).get("dailyCostUsd")
if channel_limit and daily_cost_micros(channel) >= int(channel_limit) * 10_000:
raise LimitExceeded("Channel daily AI cost limit reached")
@@ -0,0 +1,18 @@
# Дневной бюджет агента снят вместе с полем `limits`: расход считался по
# прайс-таблице из двух моделей, а для всех остальных оставался нулевым — лимит
# не срабатывал никогда. Единственный оставшийся предохранитель — общий лимит
# установки из переменной окружения (CHATBALLS_AI_GLOBAL_DAILY_COST_LIMIT_MICROS).
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
("ai", "0017_agent_answer_language"),
]
operations = [
migrations.RemoveField(
model_name="aiagent",
name="limits",
),
]
@@ -0,0 +1,34 @@
# Учёт стоимости вызовов удалён вместе с лимитами. Считать было нечем: цена
# бралась из ответа провайдера, а его присылает только OpenRouter; на остальных
# оставалась прайс-таблица из двух моделей и ноль для всех прочих. Ни одна
# цифра расхода в продукте не показывалась.
#
# Статус BLOCKED уходит вместе с лимитами — блокировать вызовы больше нечему.
from django.db import migrations, models
def drop_blocked_rows(apps, schema_editor):
"""Строк со снятым статусом в журнале остаться не должно."""
apps.get_model("ai", "LlmInvocation").objects.filter(status="BLOCKED").delete()
class Migration(migrations.Migration):
dependencies = [
("ai", "0018_remove_agent_limits"),
]
operations = [
migrations.RunPython(drop_blocked_rows, migrations.RunPython.noop),
migrations.RemoveField(model_name="llminvocation", name="cost_micros"),
migrations.RemoveField(model_name="llminvocation", name="currency"),
migrations.AlterField(
model_name="llminvocation",
name="status",
field=models.CharField(
choices=[("SUCCESS", "Успех"), ("ERROR", "Ошибка")],
default="SUCCESS",
max_length=16,
),
),
]
-10
View File
@@ -417,10 +417,6 @@ class AIAgent(TenantRelationModel):
allowed_tools = models.JSONField(default=list, blank=True)
# Единственный поддерживаемый лимит — дневной бюджет dailyCostUsd (центы USD).
limits = models.JSONField(default=dict, blank=True)
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
@@ -463,8 +459,6 @@ class LlmInvocationStatus(models.TextChoices):
ERROR = "ERROR", "Ошибка"
BLOCKED = "BLOCKED", "Заблокировано лимитом"
@@ -489,10 +483,6 @@ class LlmInvocation(TenantRelationModel):
total_tokens = models.PositiveIntegerField(default=0)
cost_micros = models.PositiveBigIntegerField(default=0)
currency = models.CharField(max_length=3, default="USD")
latency_ms = models.PositiveIntegerField(default=0)
status = models.CharField(max_length=16, choices=LlmInvocationStatus.choices, default=LlmInvocationStatus.SUCCESS)
-17
View File
@@ -1,17 +0,0 @@
from django.conf import settings
# micro-USD за токен (1 USD = 1_000_000 micro); значение = цена в USD за 1M токенов.
# Fallback на случай, если провайдер не вернул фактическую стоимость (usage.cost).
# Реальные/уточнённые цены задаются через CHATBALLS_AI_PRICING.
DEFAULT_PRICING = {
"openai/gpt-4o-mini": {"prompt": 0.15, "completion": 0.60},
"anthropic/claude-sonnet-4.6": {"prompt": 3.0, "completion": 15.0},
}
def cost_micros(model: str, prompt_tokens: int, completion_tokens: int) -> int:
table = {**DEFAULT_PRICING, **getattr(settings, "CHATBALLS_AI_PRICING", {})}
price = table.get(model)
if not price:
return 0
return round(prompt_tokens * price["prompt"] + completion_tokens * price["completion"])
@@ -18,9 +18,6 @@ class ChatResult:
model: str
prompt_tokens: int
completion_tokens: int
# Фактическая стоимость, сообщённая провайдером (micro-USD). 0 — провайдер не
# вернул цену, тогда считаем по прайс-таблице (ai/pricing.py).
cost_micros: int = 0
@property
def total_tokens(self) -> int:
@@ -12,7 +12,7 @@ speak the same Chat Completions shape:
- POST /chat/completions with {model, messages, ...}; response has
choices[0].message.content and usage (optionally usage.cost in USD).
choices[0].message.content and usage (prompt/completion tokens).
- POST /embeddings with {model, input}; response has data[].embedding and usage.
@@ -24,7 +24,7 @@ This module owns the HTTP transport and response parsing so the three adapters
do not duplicate it. Adapters stay responsible for their own product semantics
(name, cost handling, catalog). Stdlib only — no third-party HTTP client.
(name, catalog). Stdlib only — no third-party HTTP client.
"""
@@ -116,21 +116,9 @@ def get_json(*, base_url: str, path: str, api_key: str, timeout: float, proxy_ur
def chat_completions(*, base_url: str, api_key: str, messages: list[ChatMessage], model: str,
timeout: float, proxy_url: str = "", params: dict | None = None,
timeout: float, proxy_url: str = "", params: dict | None = None) -> ChatResult:
include_cost: bool = False) -> ChatResult:
"""POST /chat/completions and parse the OpenAI-shaped response.
`include_cost=True` requests the OpenRouter-style usage.include flag and reads
usage.cost (USD, converted to micros). Providers that do not report cost
(Custom, CustoAI) leave cost_micros=0; ai/pricing.py computes a fallback.
"""
"""POST /chat/completions and parse the OpenAI-shaped response."""
payload: dict = {
@@ -142,10 +130,6 @@ def chat_completions(*, base_url: str, api_key: str, messages: list[ChatMessage]
}
if include_cost:
payload["usage"] = {"include": True}
data = post_json(base_url=base_url, path="/chat/completions", api_key=api_key,
payload=payload, timeout=timeout, proxy_url=proxy_url)
@@ -160,8 +144,6 @@ def chat_completions(*, base_url: str, api_key: str, messages: list[ChatMessage]
usage = data.get("usage") or {}
cost = usage.get("cost")
return ChatResult(
text=text,
@@ -172,8 +154,6 @@ def chat_completions(*, base_url: str, api_key: str, messages: list[ChatMessage]
completion_tokens=int(usage.get("completion_tokens", 0)),
cost_micros=round(float(cost) * 1_000_000) if cost is not None else 0,
)
@@ -22,10 +22,9 @@ class OpenRouterProvider(LLMProvider):
self.proxy_url = proxy_url or ""
def chat(self, *, messages: list[ChatMessage], model: str, params: dict | None = None) -> ChatResult:
# usage.include=true — OpenRouter возвращает фактическую стоимость в usage.cost (USD).
return openai_http.chat_completions(
base_url=self.base_url, api_key=self.api_key, messages=messages, model=model,
timeout=self.timeout, proxy_url=self.proxy_url, params=params, include_cost=True,
timeout=self.timeout, proxy_url=self.proxy_url, params=params,
)
def embed(self, *, texts: list[str], model: str) -> list[EmbeddingResult]:
-17
View File
@@ -21,7 +21,6 @@ class AgentInput:
provider_integration_id: int | None
model_params: dict
allowed_tools: list
limits: dict
persona: str
tone: str
instructions: str
@@ -39,20 +38,6 @@ class AgentCreateInput:
knowledge_ids: list[int]
# Единственный поддерживаемый лимит агента — дневной бюджет в целых центах USD
# (dailyCostUsd). Прочие исторические ключи (dailyCostMicros, dailyBudgetRub,
# dailyDialogs, maxMessagesPerDialog) бэкендом не используются и отбрасываются.
def _normalize_limits(raw: dict | None) -> dict:
if not isinstance(raw, dict):
return {}
value = raw.get("dailyCostUsd")
try:
cents = int(value)
except (TypeError, ValueError):
return {}
return {"dailyCostUsd": cents} if cents > 0 else {}
def knowledge_for_agent_ids(
*, context: TenantContext, channel: Channel, knowledge_ids: list[int]
) -> list[Knowledge]:
@@ -146,7 +131,6 @@ def update_agent(*, context: TenantContext, agent: AIAgent, data: AgentInput) ->
locked.provider_integration = selection.integration
locked.model_params = data.model_params
locked.allowed_tools = data.allowed_tools
locked.limits = _normalize_limits(data.limits)
locked.persona = data.persona
locked.tone = data.tone
locked.instructions = data.instructions
@@ -158,7 +142,6 @@ def update_agent(*, context: TenantContext, agent: AIAgent, data: AgentInput) ->
"provider_integration",
"model_params",
"allowed_tools",
"limits",
"persona",
"tone",
"instructions",
-27
View File
@@ -343,15 +343,6 @@ class ChatInvocationTests(TestCase):
invocation = LlmInvocation.objects.get(channel=self.channel, operation="chat")
self.assertEqual(invocation.status, LlmInvocationStatus.SUCCESS)
self.assertGreater(invocation.total_tokens, 0)
# Технический учёт стоимости (ADR-CHATBALLS-0042 §2): считается по прайсу модели.
from chatballs.ai import pricing
self.assertEqual(
invocation.cost_micros,
pricing.cost_micros(
invocation.model, invocation.prompt_tokens, invocation.completion_tokens
),
)
def test_pii_is_redacted_before_reaching_provider(self) -> None:
from unittest import mock
@@ -378,24 +369,6 @@ class ChatInvocationTests(TestCase):
self.assertNotIn("a@b.com", captured["messages"][0].content)
def test_limit_blocks_and_records(self) -> None:
from chatballs.ai import limits as ai_limits
from chatballs.ai.invocation import invoke_chat
from chatballs.ai.models import LlmInvocation, LlmInvocationStatus
from chatballs.ai.provider.base import ChatMessage
self.agent.limits = {"dailyCostUsd": 1}
self.agent.save(update_fields=["limits"])
# 1 цент = 10 000 micro-USD; лимит превышен расходом в 10_001 micros.
LlmInvocation.objects.create(
channel=self.channel, purpose="seed", operation="chat", model="x", cost_micros=10_001,
status=LlmInvocationStatus.SUCCESS,
)
with self.assertRaises(ai_limits.LimitExceeded):
invoke_chat(channel=self.channel, messages=[ChatMessage(role="user", content="hi")], purpose="agent_chat")
self.assertTrue(LlmInvocation.objects.filter(channel=self.channel, status=LlmInvocationStatus.BLOCKED).exists())
def test_invocation_records_used_fragment_ids(self) -> None:
from chatballs.ai.invocation import invoke_chat
from chatballs.ai.models import LlmInvocation
@@ -13,7 +13,6 @@ from dataclasses import dataclass
from django.db import IntegrityError, transaction
from django.utils import timezone
from chatballs.ai.limits import LimitExceeded
from chatballs.ai.provider.base import ProviderError
from chatballs.ai.runtime import HANDOFF_TOKEN
from chatballs.channels.runtime import run_channel_turn
@@ -382,10 +381,9 @@ def ingest_inbound(integration, inbound: InboundMessage) -> None:
try:
result = run_channel_turn(channel=channel, message=ai_input, history=_history(conversation))
except (ProviderError, LimitExceeded) as error:
# Сбой AI (провайдер недоступен) или срабатывание лимита стоимости не должны
# «терять» сообщение: переводим диалог в очередь к оператору, уведомляем и
# отвечаем клиенту понятным fallback.
except ProviderError as error:
# Сбой AI не должен «терять» сообщение: переводим диалог в очередь к
# оператору, уведомляем и отвечаем клиенту понятным fallback.
logger.warning("AI turn failed for conversation %s: %s", conversation.id, error)
conversation.control_mode = ControlMode.PAUSED
conversation.expected_responder = ExpectedResponder.OPERATOR
+1 -15
View File
@@ -8,11 +8,10 @@ from __future__ import annotations
from datetime import datetime, timedelta
from django.db.models import Count, Sum
from django.db.models import Count
from django.db.models.functions import TruncDate, TruncHour
from django.utils import timezone
from chatballs.ai.models import LlmInvocation
from chatballs.channels.selectors import channels_in_organization
from chatballs.conversations.models import (
ControlMode,
@@ -64,17 +63,6 @@ def _chart(
return {"values": values, "labels": labels}
def _ai_cost(
org_id: int,
start: datetime,
end: datetime | None = None,
) -> int:
qs = LlmInvocation.objects.filter(channel__organization_id=org_id, created_at__gte=start)
if end is not None:
qs = qs.filter(created_at__lt=end)
return qs.aggregate(total=Sum("cost_micros"))["total"] or 0
def sales_overview_stats(context, period: str) -> dict:
organization_id = context.organization_id
now = timezone.now()
@@ -105,8 +93,6 @@ def sales_overview_stats(context, period: str) -> dict:
conversation__organization_id=organization_id,
created_at__gte=start,
).count(),
"aiCostMicros": _ai_cost(organization_id, start),
"aiCostPrevMicros": _ai_cost(organization_id, prev_start, start),
}
open_by_channel = dict(open_qs.values_list("channel_id").annotate(c=Count("id")))
@@ -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)
@@ -3,7 +3,6 @@ from unittest import mock
from django.test import TestCase, override_settings
from chatballs.ai.limits import LimitExceeded
from chatballs.ai.models import AIAgent, AIAgentStatus
from chatballs.ai.provider.base import ProviderError
from chatballs.channels.models import Channel
@@ -42,10 +41,9 @@ def _messenger_connection(channel):
)
class IngestLimitHandlingTests(TestCase):
"""При срабатывании дневного лимита стоимости (LimitExceeded) диалог не должен
«зависать»: его передают оператору с fallback-ответом клиенту (как при сбое
провайдера). См. ingest.ingest_inbound.
class IngestProviderFailureTests(TestCase):
"""При сбое провайдера диалог не должен «зависать»: его передают оператору с
fallback-ответом клиенту. См. ingest.ingest_inbound.
"""
def setUp(self) -> None:
@@ -63,13 +61,13 @@ class IngestLimitHandlingTests(TestCase):
external_id="ext-1", user_id="user-1", chat_id="chat-1", text="Здравствуйте", display_name="Гость"
)
def test_limit_exceeded_hands_off_to_operator(self) -> None:
def test_provider_failure_hands_off_to_operator(self) -> None:
from chatballs.conversations.ingest import ingest_inbound
with (
mock.patch(
"chatballs.conversations.ingest.run_channel_turn",
side_effect=LimitExceeded("Channel daily AI cost limit reached"),
side_effect=ProviderError("provider is down"),
),
mock.patch("chatballs.conversations.ingest.transports.send_reply", return_value=True) as send,
):
+3 -2
View File
@@ -4,7 +4,6 @@ from __future__ import annotations
MESSAGES: dict[str, object] = {
"admin.choose_logo_file": "Choose a logo file",
"admin.currency_rub_only": "Only the Russian rouble (RUB) is supported",
"admin.employee_not_found": "Operator not found",
"admin.group_name_taken": "A group with this name already exists",
"admin.group_not_found": "Group not found",
@@ -98,6 +97,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",
@@ -111,7 +112,7 @@ MESSAGES: dict[str, object] = {
"portals.invalid_section": "Invalid section",
"portals.not_found": "Portal not found",
"portals.section_not_found": "Section not found",
"profile.accent_hex": "The accent is a HEX colour like #1677ff",
"profile.accent_hex": "The accent is a HEX colour like #0f9b8e",
"profile.choose_photo_file": "Choose a photo file",
"profile.link_not_found": "Link not found",
"profile.notification_bot_not_found": "Notification bot not found",
+3 -2
View File
@@ -8,7 +8,6 @@ from __future__ import annotations
MESSAGES: dict[str, object] = {
"admin.choose_logo_file": "Выберите файл логотипа",
"admin.currency_rub_only": "Поддерживается только российский рубль (RUB)",
"admin.employee_not_found": "Сотрудник не найден",
"admin.group_name_taken": "Группа с таким именем уже есть",
"admin.group_not_found": "Группа не найдена",
@@ -102,6 +101,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": "Файл не найден",
@@ -115,7 +116,7 @@ MESSAGES: dict[str, object] = {
"portals.invalid_section": "Некорректный раздел",
"portals.not_found": "Портал не найден",
"portals.section_not_found": "Раздел не найден",
"profile.accent_hex": "Акцент — HEX-цвет вида #1677ff",
"profile.accent_hex": "Акцент — HEX-цвет вида #0f9b8e",
"profile.choose_photo_file": "Выберите файл фото",
"profile.link_not_found": "Привязка не найдена",
"profile.notification_bot_not_found": "Бот уведомлений не найден",
+1 -1
View File
@@ -35,7 +35,7 @@ class HumanUserAdmin(UserAdmin):
@admin.register(Organization)
class OrganizationAdmin(admin.ModelAdmin):
list_display = ["slug", "name", "currency", "timezone"]
list_display = ["slug", "name", "timezone"]
search_fields = ["slug", "name"]
@@ -43,7 +43,6 @@ def organization_settings_payload(organization: Organization) -> dict[str, objec
return {
"name": organization.name,
"timezone": organization.timezone,
"currency": organization.currency,
# Пустая строка доезжает до интерфейса как есть: там это отдельный
# пункт «Как в установке», а не отсутствие значения.
"language": organization.language,
@@ -28,7 +28,6 @@ MAX_LOGO_BYTES = 2 * 1024 * 1024
class OrganizationSettingsInput:
name: str
timezone: str
currency: str
# Пустая строка — «как в установке»: организация не обязана выбирать язык,
# и владелец, который его не трогал, не должен получить жёсткий русский
# после того, как язык установки сменили.
@@ -36,12 +35,11 @@ class OrganizationSettingsInput:
def validate_organization_settings(data: OrganizationSettingsInput) -> OrganizationSettingsInput:
"""Имя, часовой пояс, валюта и язык организации — одни правила для
«Настроек» и для страницы создания организации."""
"""Имя, часовой пояс и язык организации — одни правила для «Настроек» и
для страницы создания организации."""
name = data.name.strip()
timezone = data.timezone.strip()
currency = data.currency.strip().upper()
language = normalize_language(data.language)
if data.language.strip() and not language:
raise ValidationError({"language": t("settings.language_unsupported")})
@@ -55,13 +53,7 @@ def validate_organization_settings(data: OrganizationSettingsInput) -> Organizat
raise ValidationError(
{"timezone": t("admin.invalid_timezone")}
) from error
if currency != "RUB":
raise ValidationError(
{"currency": t("admin.currency_rub_only")}
)
return OrganizationSettingsInput(
name=name, timezone=timezone, currency=currency, language=language
)
return OrganizationSettingsInput(name=name, timezone=timezone, language=language)
@transaction.atomic
@@ -76,9 +68,8 @@ def update_organization_settings(
)
organization.name = clean.name
organization.timezone = clean.timezone
organization.currency = clean.currency
organization.language = clean.language
organization.save(update_fields=["name", "timezone", "currency", "language"])
organization.save(update_fields=["name", "timezone", "language"])
return organization
@@ -74,7 +74,6 @@ class OrganizationSettingsView(APIView):
data=OrganizationSettingsInput(
name=str(body.get("name", organization.name)),
timezone=str(body.get("timezone", organization.timezone)),
currency=str(body.get("currency", organization.currency)),
language=str(body.get("language", organization.language)),
),
)
@@ -293,37 +292,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(),
}
@@ -28,7 +28,6 @@ def bootstrap_owner(*, email: str, password: str, full_name: str = "") -> Bootst
defaults={
"name": "Demo",
"timezone": "Europe/Moscow",
"currency": "RUB",
},
)
from chatballs.ai.knowledge_categories import ensure_uncategorized_category
@@ -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"),
@@ -30,9 +30,6 @@
"how-to-measure",
"lead-times"
],
"limits": {
"dailyCostUsd": 300
},
"createdDaysAgo": 41
},
{
@@ -53,7 +50,6 @@
"returns-and-exchanges",
"your-account"
],
"limits": {},
"createdDaysAgo": 30,
"policy": {
"allow_anonymous_sessions": true,
@@ -78,7 +74,6 @@
"your-account",
"returns-and-exchanges"
],
"limits": {},
"createdDaysAgo": 28,
"policy": {
"allow_anonymous_sessions": true,
@@ -96,7 +91,6 @@
"instructions": "",
"knowledge": [],
"portalArticles": [],
"limits": {},
"createdDaysAgo": 3
},
{
@@ -113,7 +107,6 @@
"pricing"
],
"portalArticles": [],
"limits": {},
"createdDaysAgo": 180
}
],
@@ -378,7 +371,6 @@
80,
260
],
"costMicrosPerToken": 0.6,
"failuresTotal": 3
}
}
@@ -30,9 +30,6 @@
"kak-snyat-merki",
"sroki-dostavki"
],
"limits": {
"dailyCostUsd": 300
},
"createdDaysAgo": 41
},
{
@@ -53,7 +50,6 @@
"vozvrat-i-obmen",
"lichnyj-kabinet"
],
"limits": {},
"createdDaysAgo": 30,
"policy": {
"allow_anonymous_sessions": true,
@@ -78,7 +74,6 @@
"lichnyj-kabinet",
"vozvrat-i-obmen"
],
"limits": {},
"createdDaysAgo": 28,
"policy": {
"allow_anonymous_sessions": true,
@@ -96,7 +91,6 @@
"instructions": "",
"knowledge": [],
"portalArticles": [],
"limits": {},
"createdDaysAgo": 3
},
{
@@ -113,7 +107,6 @@
"pricing"
],
"portalArticles": [],
"limits": {},
"createdDaysAgo": 180
}
],
@@ -378,7 +371,6 @@
80,
260
],
"costMicrosPerToken": 0.6,
"failuresTotal": 3
}
}
@@ -82,7 +82,6 @@ def _ensure_agent(context: TenantContext, refs: DemoRefs, item: dict, llm: Integ
agent.persona = item.get("persona", "")
agent.tone = item.get("tone", "")
agent.instructions = item.get("instructions", "")
agent.limits = item.get("limits", {})
if agent.status in ("ACTIVE", "DISABLED"):
agent.provider_integration = llm
agent.model = (llm.config or {}).get("default_model", "demo")
@@ -256,7 +255,6 @@ def _generate_usage(refs: DemoRefs, spec: dict | None, current) -> None:
prompt_tokens=prompt,
completion_tokens=0 if failed else completion,
total_tokens=prompt + (0 if failed else completion),
cost_micros=0 if failed else int((prompt + completion) * spec["costMicrosPerToken"]),
latency_ms=random.randint(900, 4200),
status=LlmInvocationStatus.ERROR if failed else LlmInvocationStatus.SUCCESS,
error="Provider timeout after 30s" if failed else "",
@@ -274,7 +272,6 @@ def _generate_usage(refs: DemoRefs, spec: dict | None, current) -> None:
prompt_tokens=tokens,
completion_tokens=0,
total_tokens=tokens,
cost_micros=int(tokens * 0.02),
latency_ms=random.randint(200, 900),
status=LlmInvocationStatus.SUCCESS,
)
@@ -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),
),
]
@@ -0,0 +1,18 @@
# Валюта организации удалена: поле принимало только RUB, не читалось нигде и
# ни одна сумма в продукте в ней не считалась. Осталось от эпохи CRM и пережило
# пивот в поддержку. Расход на модель — единственные деньги в системе — тоже
# снят вместе с лимитами.
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
("identity", "0038_membership_onboarding_dismissed_at"),
]
operations = [
migrations.RemoveField(
model_name="organization",
name="currency",
),
]
+9 -2
View File
@@ -75,7 +75,7 @@ class HumanUser(AbstractUser):
# без этой отметки принимался бы второй раз ещё полторы минуты.
totp_last_counter = models.BigIntegerField(default=0)
# Внешний вид — глобальная настройка пользователя (не membership):
# тема и акцентный HEX-цвет; пустой акцент — дефолтный синий #1677ff.
# тема и акцентный HEX-цвет; пустой акцент — цвет продукта #0f9b8e.
ui_theme = models.CharField(max_length=8, choices=UiTheme.choices, default=UiTheme.SYSTEM)
ui_accent = models.CharField(max_length=9, blank=True)
# Язык интерфейса — тоже глобальная настройка пользователя, а не
@@ -125,7 +125,6 @@ class Organization(models.Model):
default=OrganizationStatus.ACTIVE,
)
timezone = models.CharField(max_length=64, default="Europe/Moscow")
currency = models.CharField(max_length=3, default="RUB")
# Язык рабочего места по умолчанию: на нём организация открывается всем,
# кто не выбрал свой в профиле. Стоит рядом с часовым поясом и валютой —
# это такой же региональный параметр организации, и в «Настройках» они
@@ -184,6 +183,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:
@@ -106,7 +106,6 @@ def create_organization(
slug=unique_organization_slug(clean.name),
status=OrganizationStatus.ACTIVE,
timezone=clean.timezone,
currency=clean.currency,
language=clean.language,
)
organization.save(force_insert=True)
@@ -60,7 +60,6 @@ class OrganizationCreateView(APIView):
data=OrganizationSettingsInput(
name=str(body.get("name", "")),
timezone=str(body.get("timezone", "") or "Europe/Moscow"),
currency=str(body.get("currency", "") or "RUB"),
language=str(body.get("language", "")),
),
owner=request.user,
@@ -28,7 +28,6 @@ class AdministrationApiTests(TestCase):
name="Example",
slug="administration",
timezone="Europe/Moscow",
currency="RUB",
)
self.owner = HumanUser.objects.create_user(
email="owner@administration.test",
@@ -60,7 +59,6 @@ class AdministrationApiTests(TestCase):
{
"name": "Example",
"timezone": "Europe/Moscow",
"currency": "RUB",
# Пустой язык — «как в установке»: организация своего не выбрала.
"language": "",
"logoUrl": None,
@@ -78,7 +76,6 @@ class AdministrationApiTests(TestCase):
{
"name": "Новая компания",
"timezone": "Asia/Yekaterinburg",
"currency": "rub",
},
format="json",
)
@@ -87,7 +84,6 @@ class AdministrationApiTests(TestCase):
self.organization.refresh_from_db()
self.assertEqual(self.organization.name, "Новая компания")
self.assertEqual(self.organization.timezone, "Asia/Yekaterinburg")
self.assertEqual(self.organization.currency, "RUB")
def test_invalid_timezone_is_rejected(self) -> None:
response = self.client.patch(
@@ -99,16 +95,6 @@ class AdministrationApiTests(TestCase):
self.assertEqual(response.status_code, 400)
self.assertIn("timezone", response.json()["errors"])
def test_only_ruble_currency_is_accepted(self) -> None:
response = self.client.patch(
"/api/v1/company/administration/",
{"currency": "USD"},
format="json",
)
self.assertEqual(response.status_code, 400)
self.assertIn("российский рубль", response.json()["errors"]["currency"])
def test_logo_upload_download_and_delete(self) -> None:
png = b"\x89PNG\r\n\x1a\n\x00\x00\x00\rIHDR\x00\x00\x00\x01\x00\x00\x00\x01"
uploaded = self.client.post(
@@ -24,7 +24,6 @@ def provisioning_result_payload(
"slug": organization.slug,
"status": organization.status,
"timezone": organization.timezone,
"currency": organization.currency,
},
"provisioning": {
"status": provisioning.status,
@@ -21,7 +21,6 @@ class ProvisioningCommand:
source: str
idempotency_key: str
timezone: str = "Europe/Moscow"
currency: str = "RUB"
locale: str = ""
legal_name: str = ""
tax_profile: dict[str, Any] | None = None
@@ -158,7 +158,6 @@ def _create_organization(
slug=command.organization_slug.strip(),
status=OrganizationStatus.ACTIVE if active else OrganizationStatus.PENDING_OWNER,
timezone=command.timezone,
currency=command.currency.upper(),
)
except ValidationError as error:
raise ProvisioningValidation(str(error), code="organization_invalid") from error
@@ -28,7 +28,6 @@ class PlatformAuthTests(TestCase):
"slug": "acme",
"owner_email": "owner-acme@example.test",
"timezone": "Europe/Moscow",
"currency": "RUB",
},
format="json",
HTTP_IDEMPOTENCY_KEY="idem-api-1",
@@ -70,7 +69,6 @@ class PlatformCapabilityGateTests(TestCase):
"slug": "acme",
"owner_email": "owner-acme@example.test",
"timezone": "Europe/Moscow",
"currency": "RUB",
},
format="json",
HTTP_IDEMPOTENCY_KEY="idem-api-2",
@@ -41,8 +41,7 @@ class PlatformRoleProvisioningTests(TransactionTestCase):
"slug": slug,
"owner_email": owner_email,
"timezone": "Europe/Moscow",
"currency": "RUB",
},
},
format="json",
HTTP_IDEMPOTENCY_KEY=key,
)
@@ -10,7 +10,6 @@ _REQUIRED_FIELDS = (
"slug",
"owner_email",
"timezone",
"currency",
)
@@ -32,7 +31,6 @@ def parse_provisioning_body(
source=source,
idempotency_key=idempotency_key,
timezone=str(body.get("timezone", "Europe/Moscow")),
currency=str(body.get("currency", "RUB")),
locale=str(body.get("locale", "")),
legal_name=str(body.get("legal_name", "")),
tax_profile=body.get("tax_profile") if isinstance(body.get("tax_profile"), dict) else None,
@@ -42,7 +42,7 @@ CHATBALLS_VERSION = os.environ.get("CHATBALLS_VERSION", "dev").strip() or "dev"
CHATBALLS_UPDATE_REPO = os.environ.get("CHATBALLS_UPDATE_REPO", "dartdavros/chatballs").strip()
CHATBALLS_UPDATES_DIR = os.environ.get("CHATBALLS_UPDATES_DIR", "/run/chatballs/updates")
CHATBALLS_UPDATE_CHECK_INTERVAL_SECONDS = int(
os.environ.get("CHATBALLS_UPDATE_CHECK_INTERVAL_SECONDS", str(6 * 3600))
os.environ.get("CHATBALLS_UPDATE_CHECK_INTERVAL_SECONDS", str(15 * 60))
)
ALLOWED_HOSTS = env_list("CHATBALLS_ALLOWED_HOSTS", ["localhost", "127.0.0.1"])
if TESTING:
@@ -196,10 +196,6 @@ if TESTING and not CHATBALLS_AI_PROVIDER:
CHATBALLS_OPENROUTER_BASE_URL = os.environ.get("CHATBALLS_OPENROUTER_BASE_URL", "https://openrouter.ai/api/v1")
CHATBALLS_AI_REQUEST_TIMEOUT = float(os.environ.get("CHATBALLS_AI_REQUEST_TIMEOUT", "30"))
CHATBALLS_AI_MAX_RETRIES = int(os.environ.get("CHATBALLS_AI_MAX_RETRIES", "2"))
CHATBALLS_AI_GLOBAL_DAILY_COST_LIMIT_MICROS = int(
os.environ.get("CHATBALLS_AI_GLOBAL_DAILY_COST_LIMIT_MICROS", "0")
) # 0 = без лимита
CHATBALLS_AI_PRICING: dict = {} # переопределение цен micro-USD/токен по модели
CHATBALLS_AI_EMBEDDING_MODEL = os.environ.get("CHATBALLS_AI_EMBEDDING_MODEL", "openai/text-embedding-3-small")
# Модель расшифровки голосовых (OpenAI-совместимый /audio/transcriptions).
@@ -72,43 +72,13 @@ export function OrganizationSettingsForm({
options={timezones.map((timezone) => [timezone, timezoneLabel(timezone)])}
/>
<SelectField
label={t("admin.currency")}
value={organization.currency}
label={t("settings.language")}
value={organization.language}
disabled={!canManage}
onChange={(currency) => onChange({ ...organization, currency })}
options={[["RUB", t("admin.russian_rouble_rub")]]}
onChange={(language) => onChange({ ...organization, language })}
options={[["", t("settings.language_as_installation")], ...languages.map((item): [string, string] => [item.code, item.label])]}
/>
</div>
{/* Язык — сегментом, как «Тема» в профиле и схема в «Платформе»: вариантов
три, и выбор лучше видеть целиком, чем разворачивать список. В сетку
полей он не встаёт — она выравнивает поля по нижнему краю, и строка с
подписью ломала бы ряд. */}
<div className="appearance-row administration-language">
<span>{t("settings.language")}</span>
<div className="appearance-theme-options">
<button
className={organization.language === "" ? "active" : ""}
disabled={!canManage}
type="button"
onClick={() => onChange({ ...organization, language: "" })}
>
{t("settings.language_as_installation")}
</button>
{languages.map((item) => (
<button
className={organization.language === item.code ? "active" : ""}
disabled={!canManage}
key={item.code}
lang={item.code}
type="button"
onClick={() => onChange({ ...organization, language: item.code })}
>
{item.label}
</button>
))}
</div>
</div>
<p className="settings-section-note">{t("settings.language_org_hint")}</p>
{error && <div className="administration-message error" role="alert">{error}</div>}
{canManage && (
<div className="administration-actions">
@@ -25,7 +25,6 @@ export async function saveOrganizationSettings(
body: JSON.stringify({
name: settings.name,
timezone: settings.timezone,
currency: settings.currency,
language: settings.language,
}),
});
@@ -9,7 +9,6 @@ export type AdministrationSection = "organization";
export type OrganizationSettings = {
name: string;
timezone: string;
currency: string;
// Пустая строка — «как в установке»: организация не обязана выбирать язык.
language: string;
logoUrl: string | null;
@@ -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;
@@ -22,8 +22,6 @@ import {
connectionStatusMeta,
connectionSubtitle,
createdLabel,
dailyCostCents,
dailyCostInput,
deleteAgent,
fetchAgent,
knowledgeLine,
@@ -534,17 +532,9 @@ function ModelCard({ card, providers, canManage, busy, apply }: {
busy: boolean;
apply: (patch: AgentPatch) => Promise<boolean>;
}) {
const [limit, setLimit] = useState(dailyCostInput(card.limits));
useEffect(() => setLimit(dailyCostInput(card.limits)), [card.limits]);
const missingProvider = card.providerIntegrationId === null;
const providerName = providers.find((item) => item.id === card.providerIntegrationId)?.name ?? "";
function saveLimit() {
const cents = dailyCostCents(limit);
if (cents === Number(card.limits.dailyCostUsd ?? 0)) return;
void apply({ limits: { ...card.limits, dailyCostUsd: cents } });
}
return (
<section className="agent-card is-side">
<h3>{t("common.model")}</h3>
@@ -567,15 +557,6 @@ function ModelCard({ card, providers, canManage, busy, apply }: {
<Icon name="search" size={14} strokeWidth={1.8} />
</span>
</label>
<div className="agent-limit">
<span>{t("ai.daily_limit")}</span>
<span>
{canManage
? <input value={limit} inputMode="decimal" placeholder="0.00" onChange={(event) => setLimit(event.target.value)} onBlur={saveLimit} />
: <span className="agent-limit-value">{limit || "0.00"}</span>}
<small>USD</small>
</span>
</div>
</div>
</section>
);
@@ -37,7 +37,6 @@ export type AgentCard = {
model: string;
providerIntegrationId: number | null;
modelParams: Record<string, unknown>;
limits: Record<string, unknown>;
// Режим языка ответов: MIRROR, ORGANIZATION или код языка.
answerLanguage: string;
persona: string;
@@ -63,7 +62,6 @@ export type AgentPatch = Partial<{
tone: string;
instructions: string;
knowledgeIds: number[];
limits: Record<string, unknown>;
}>;
// Страница списка агентов (кадр G1): группа, поиск и страница — на сервере.
@@ -255,15 +253,3 @@ export function knowledgeLine(card: AgentCard): string {
return `${head} · ${parts.join(" + ")}`;
}
// Дневной бюджет агента хранится в целых центах USD (limits.dailyCostUsd,
// ADR-CHATBALLS-0023); на экране — доллары с двумя знаками.
export function dailyCostInput(limits: Record<string, unknown>): string {
const cents = Number(limits.dailyCostUsd ?? 0);
return Number.isFinite(cents) && cents > 0 ? (cents / 100).toFixed(2) : "";
}
export function dailyCostCents(value: string): number {
const amount = Number(value.replace(",", ".").trim());
if (!Number.isFinite(amount) || amount <= 0) return 0;
return Math.round(amount * 100);
}
@@ -841,15 +841,14 @@
.agent-field textarea:focus,
.agent-field input:focus,
.agent-create-modal .form-field.is-editable input:focus,
.agent-create-modal .select-like select:focus {
.agent-create-modal .select-box:focus-visible {
border-color: var(--primary);
background: var(--surface-card);
box-shadow: 0 0 0 3px var(--primary-bg);
}
/* Тот же фокус для общего селекта карточки: с боксом (группа) и без (провайдер). */
.agent-side-fields .select-like select:focus,
.agent-side-fields .select-box:focus-within {
/* Тот же фокус для общего селекта карточки. */
.agent-side-fields .select-box:focus-visible {
border-color: var(--primary);
background: var(--surface-card);
box-shadow: 0 0 0 3px var(--primary-bg);
@@ -1045,54 +1044,6 @@
color: var(--n-5);
}
.agent-limit {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding-top: 6px;
}
.agent-limit > span:first-child {
color: var(--n-3);
font-size: 12.5px;
font-weight: 600;
}
.agent-limit > span:last-child {
display: inline-flex;
align-items: center;
gap: 6px;
color: var(--n-1);
font-size: 13px;
}
.agent-limit input,
.agent-limit-value {
display: inline-flex;
width: 56px;
height: 30px;
align-items: center;
padding: 0 10px;
border: 1px solid var(--n-7);
border-radius: 7px;
outline: none;
background: var(--n-10);
color: var(--n-1);
font-family: var(--font-mono);
font-size: 12.5px;
}
.agent-limit input:focus {
border-color: var(--primary);
background: var(--surface-card);
box-shadow: 0 0 0 3px var(--primary-bg);
}
.agent-limit small {
color: var(--n-4);
}
/* Подключения. */
.agent-connection {
@@ -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;
@@ -4,6 +4,7 @@ import { CallOverlay } from "./CallOverlay";
import { Composer } from "./Composer";
import { ConversationThread } from "./ConversationThread";
import { DialogList } from "./DialogList";
import { IconButton } from "../../shared/ui-controls";
import {
claimConversation,
closeConversation,
@@ -227,7 +228,16 @@ export function ConversationWorkspace({ isOwner = false, viewerId = null, listTi
hint={hint}
/>
{!selectedDialog && (
<section className="sales-conversation"><div className="sales-conversation-empty">{t("conversations.pick_conversation")}</div></section>
<section className="sales-conversation">
{/* Свёрнутый список без выбранного диалога: вернуть его больше неоткуда —
шапки переписки, где живёт та же кнопка, здесь нет. */}
{listCollapsed && (
<div className="sales-conversation-head">
<IconButton bare icon="collapseLeft" iconSize={17} label={t("conversations.show_list")} className="list-expand" onClick={() => setListCollapsed(false)} />
</div>
)}
<div className="sales-conversation-empty">{t("conversations.pick_conversation")}</div>
</section>
)}
{selectedDialog && (
<section className="sales-conversation enter-surface" key={selectedDialog.id}>
@@ -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;
}
@@ -33,7 +33,6 @@ export function OrganizationCreatePage({ user, onCreated, onBack }: {
// Новая организация наследует региональные параметры текущей: чаще всего
// человек заводит вторую компанию там же, где первую.
timezone: "Europe/Moscow",
currency: "RUB",
language: "",
});
const [logo, setLogo] = useState<File | null>(null);
@@ -124,37 +123,13 @@ export function OrganizationCreatePage({ user, onCreated, onBack }: {
options={timezones.map((timezone) => [timezone, timezoneLabel(timezone)])}
/>
<SelectField
label={t("admin.currency")}
value={draft.currency}
onChange={(currency) => setDraft({ ...draft, currency })}
options={[["RUB", t("admin.russian_rouble_rub")]]}
label={t("settings.language")}
value={draft.language}
onChange={(language) => setDraft({ ...draft, language })}
options={[["", t("settings.language_as_installation")], ...languages.map((item): [string, string] => [item.code, item.label])]}
/>
</div>
<div className="appearance-row administration-language">
<span>{t("settings.language")}</span>
<div className="appearance-theme-options">
<button
className={draft.language === "" ? "active" : ""}
type="button"
onClick={() => setDraft({ ...draft, language: "" })}
>
{t("settings.language_as_installation")}
</button>
{languages.map((item) => (
<button
className={draft.language === item.code ? "active" : ""}
key={item.code}
lang={item.code}
type="button"
onClick={() => setDraft({ ...draft, language: item.code })}
>
{item.label}
</button>
))}
</div>
</div>
<p className="settings-section-note">{t("settings.language_org_hint")}</p>
{error && <div className="administration-message error" role="alert">{error}</div>}
{error && <div className="administration-message error" role="alert">{error}</div>}
<div className="administration-actions">
<Button variant="secondary" disabled={submitting} onClick={onBack}>{t("common.cancel")}</Button>
<Button type="submit" variant="primary" icon="plus" disabled={!valid}>
@@ -8,13 +8,13 @@ const BASE = "/api/v1/organizations/";
export type OrganizationCreateOptions = {
timezones: string[];
languages: Array<{ code: string; label: string }>;
currencies: string[];
};
// Валюты в черновике нет: в продукте нет ни одной суммы в валюте организации,
// сервер проставляет её сам.
export type OrganizationDraft = {
name: string;
timezone: string;
currency: string;
// Пустая строка — «как в установке».
language: string;
};
@@ -3,9 +3,9 @@
ей заголовок, подводку и ширину раздела настроек. */
.organization-create {
width: 100%;
max-width: 720px;
margin: 0 auto;
padding: 24px 28px 40px;
max-width: 1000px;
margin-inline: auto;
padding: 28px 40px 48px;
}
.organization-create-head {
@@ -94,7 +94,7 @@ export function ProfileAppearanceCard({ user, onUserUpdated }: { user: SessionUs
<input
disabled={saving}
maxLength={7}
placeholder="#1677ff"
placeholder="#0f9b8e"
value={customAccent}
onChange={(event) => setCustomAccent(event.target.value)}
onKeyDown={(event) => {
@@ -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;
+189 -5
View File
@@ -59,7 +59,6 @@ export const en: Record<MessageKey, Message> = {
"admin.creation_failed": "Creation failed",
"admin.credentials": "Credentials",
"admin.credentials_role_groups": "Credentials · role · groups",
"admin.currency": "Currency",
"admin.danger_zone": "Danger zone",
"admin.e_g_operator": "e.g. Operator",
"admin.earlier": "Earlier",
@@ -170,7 +169,6 @@ export const en: Record<MessageKey, Message> = {
"admin.result": "Result",
"admin.roles_groups_showing": "Roles and groups · showing",
"admin.roles_groups_shown": "Roles and groups · showing {shown} of {total}",
"admin.russian_rouble_rub": "Russian rouble (RUB)",
"admin.save_sign": "Save and sign in",
"admin.scan_qr_code": "Scan the QR code",
"admin.scan_qr_code_authenticator_app": "Scan the QR code in your authenticator app, then confirm with a code",
@@ -308,7 +306,6 @@ export const en: Record<MessageKey, Message> = {
"ai.created_on": "created {date}",
"ai.creating": "Creating…",
"ai.customers_reach_agent_through_these": "Customers reach the agent through these. One connection, one agent.",
"ai.daily_limit": "Daily limit",
"ai.delete_agent": "Delete the agent",
"ai.delete_agent_2": "Delete the agent?",
"ai.delete_attachment": "Delete the attachment",
@@ -1010,6 +1007,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 and what rules they follow.",
"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.",
"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": "Knowledge",
"onboarding.s2.row2_value": "12 articles",
"onboarding.s2.row3_label": "Answer language",
"onboarding.s2.row3_value": "Same as the customer",
"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 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.",
@@ -1041,7 +1226,7 @@ export const en: Record<MessageKey, Message> = {
"profile.create_agent": "Create an agent",
"profile.current": "current",
"profile.current_password": "Current password",
"profile.custom_colour_hex_value_like": "A custom colour is a HEX value like #1677ff",
"profile.custom_colour_hex_value_like": "A custom colour is a HEX value like #0f9b8e",
"profile.dark": "Dark",
"profile.disconnect": "Disconnect",
"profile.drag_resize_double_click_reset": "Drag to resize, double-click to reset",
@@ -1297,7 +1482,6 @@ export const en: Record<MessageKey, Message> = {
"settings.language_as_installation": "Match the installation",
"settings.language_instance": "Installation language",
"settings.language_instance_hint": "The language of screens where there is no organization yet: sign-in, password reset, the first-run wizard. It is also the default for organizations that have not picked their own.",
"settings.language_org_hint": "The language the workspace opens in for this organization's operators. Anyone can pick their own in their profile.",
"settings.leave_empty_keep_unchanged": "leave empty to keep it unchanged",
"settings.lime": "Lime",
"settings.migrate_files_from_disk": "Migrate the files from disk",
+189 -5
View File
@@ -60,7 +60,6 @@ export const ru = {
"admin.creation_failed": "Ошибка создания",
"admin.credentials": "Учётные данные",
"admin.credentials_role_groups": "Учётные данные · роль · группы",
"admin.currency": "Валюта",
"admin.danger_zone": "Опасная зона",
"admin.e_g_operator": "напр. Оператор",
"admin.earlier": "Ранее",
@@ -171,7 +170,6 @@ export const ru = {
"admin.result": "Результат",
"admin.roles_groups_showing": "Роли и группы · показано",
"admin.roles_groups_shown": "Роли и группы · показано {shown} из {total}",
"admin.russian_rouble_rub": "Российский рубль (RUB)",
"admin.save_sign": "Сохранить и войти",
"admin.scan_qr_code": "Отсканируйте QR-код",
"admin.scan_qr_code_authenticator_app": "Отсканируйте QR-код в приложении-аутентификаторе, затем подтвердите кодом",
@@ -309,7 +307,6 @@ export const ru = {
"ai.created_on": "создан {date}",
"ai.creating": "Создание…",
"ai.customers_reach_agent_through_these": "Через них клиенты попадают к агенту. Одно подключение — один агент.",
"ai.daily_limit": "Лимит в день",
"ai.delete_agent": "Удалить агента",
"ai.delete_agent_2": "Удалить агента?",
"ai.delete_attachment": "Удалить вложение",
@@ -1011,6 +1008,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": "12 статей",
"onboarding.s2.row3_label": "Язык ответов",
"onboarding.s2.row3_value": "Как у клиента",
"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": "Вы станете владельцем новой организации и сразу в неё переключитесь. Название, часовой пояс и язык потом можно поменять в «Настройках».",
@@ -1042,7 +1227,7 @@ export const ru = {
"profile.create_agent": "Создать агента",
"profile.current": "текущая",
"profile.current_password": "Текущий пароль",
"profile.custom_colour_hex_value_like": "Свой цвет — HEX вида #1677ff",
"profile.custom_colour_hex_value_like": "Свой цвет — HEX вида #0f9b8e",
"profile.dark": "Тёмная",
"profile.disconnect": "Отключить",
"profile.drag_resize_double_click_reset": "Потяните, двойной клик — сбросить",
@@ -1298,7 +1483,6 @@ export const ru = {
"settings.language_as_installation": "Как в установке",
"settings.language_instance": "Язык установки",
"settings.language_instance_hint": "Язык экранов, где организации ещё нет: вход, сброс пароля, мастер первого запуска. Он же — умолчание для организаций, которые своего языка не выбрали.",
"settings.language_org_hint": "Язык, на котором рабочее место открывается сотрудникам организации. Каждый может выбрать свой в профиле.",
"settings.leave_empty_keep_unchanged": "оставьте пустым, чтобы не менять",
"settings.lime": "Салатовый",
"settings.migrate_files_from_disk": "Перенести файлы с диска",
@@ -1,107 +0,0 @@
import { useEffect, useState } from "react";
import { api } from "../api/client";
import { Icon } from "../shared/icons";
import type { RouteKey, SessionUser } from "../types";
import type { SettingsSectionKey } from "../features/settings/sections";
import { t } from "../i18n";
// Блок «Запуск» в сайдбаре (SPEC-CHATBALLS-0031 §5, дизайн-базлайн v2 A1): три шага
// с автоотметкой по факту, прогресс и primary-действие текущего шага.
// «Скрыть» — предпочтение клиента (localStorage); при полном прохождении блок
// исчезает сам.
type Checklist = {
agentCreated: boolean;
connectionBound: boolean;
employeeInvited: boolean;
done: boolean;
};
type Step = {
key: keyof Omit<Checklist, "done">;
label: string;
action: string;
route: RouteKey;
// Раздел «Настроек», если шаг ведёт в субменю (кадры N1–N7).
section?: SettingsSectionKey;
icon: Parameters<typeof Icon>[0]["name"];
};
const STEPS: Step[] = [
{ key: "agentCreated", label: t("profile.create_agent"), action: t("profile.create_agent"), route: "agents", icon: "robot" },
{ key: "connectionBound", label: t("profile.connect_entry_point"), action: t("profile.connect"), route: "settings", section: "integrations", icon: "plug" },
{ key: "employeeInvited", label: t("profile.invite_operators"), action: t("profile.invite"), route: "employees", icon: "team" },
];
function storageKey(user: SessionUser): string {
return `chatballs.launch.${user.organizationPublicId}.hidden`;
}
function isHidden(user: SessionUser): boolean {
try {
return localStorage.getItem(storageKey(user)) === "1";
} catch {
return false;
}
}
export function LaunchChecklist({ user, setRoute, openSettings }: { user: SessionUser; setRoute: (route: RouteKey) => void; openSettings: (section: SettingsSectionKey) => void }) {
const [checklist, setChecklist] = useState<Checklist | null>(null);
const [hidden, setHidden] = useState(() => isHidden(user));
useEffect(() => {
if (hidden) return;
let cancelled = false;
api<Checklist>("/api/v1/company/launch-checklist/")
.then((payload) => {
if (!cancelled) setChecklist(payload);
})
.catch(() => undefined);
return () => {
cancelled = true;
};
}, [hidden]);
if (hidden || !checklist || checklist.done) return null;
const doneCount = STEPS.filter((step) => checklist[step.key]).length;
const current = STEPS.find((step) => !checklist[step.key]);
function hide() {
try {
localStorage.setItem(storageKey(user), "1");
} catch {
// приватный режим — просто скрываем до перезагрузки
}
setHidden(true);
}
return (
<div className="launch-checklist">
<div className="launch-checklist-head">
<div><strong>{t("profile.getting_started")}</strong><small>{t("profile.getting_started_progress", { done: doneCount, total: STEPS.length })}</small></div>
<button aria-label={t("common.hide")} title={t("common.hide")} type="button" onClick={hide}>×</button>
</div>
<div className="launch-checklist-progress"><i style={{ width: `${Math.round((doneCount / STEPS.length) * 100)}%` }} /></div>
<div className="launch-checklist-steps">
{STEPS.map((step) => {
const stepDone = checklist[step.key];
const isCurrent = current?.key === step.key;
return (
<div className={`launch-checklist-step ${stepDone ? "is-done" : ""} ${isCurrent ? "is-current" : ""}`} key={step.key}>
<span className="launch-checklist-mark">{stepDone && <Icon name="check" size={10} />}</span>
<span>{step.label}</span>
</div>
);
})}
</div>
{current && (
<button className="launch-checklist-action" type="button" onClick={() => (current.section ? openSettings(current.section) : setRoute(current.route))}>
<Icon name={current.icon} size={13} />
{current.action}
</button>
)}
</div>
);
}
+34 -21
View File
@@ -11,6 +11,10 @@ import { useChatScope } from "../features/chat/useChatScope";
import { isManager } from "../auth/access";
import { DemoInstallBanner } from "../features/settings/DemoInstallBanner";
import { Sidebar } from "./Sidebar";
import { OnboardingLauncher } from "../features/onboarding/OnboardingLauncher";
import { OnboardingOverlay } from "../features/onboarding/OnboardingOverlay";
import { OnboardingTour } from "../features/onboarding/OnboardingTour";
import { OnboardingProvider, useOnboardingState } from "../features/onboarding/useOnboarding";
import { UpdateBanner } from "../features/updates/UpdateBanner";
import { ShellRouteContent } from "./ShellRouteContent";
@@ -27,6 +31,10 @@ export function Shell({ route, setRoute, settingsSection, openSettingsRoute, sel
// Охват чата живёт здесь: сотрудницкий сайдбар и страница чата делят одно
// состояние (дизайн-базлайн v2 §4.1).
const chatScope = useChatScope(true);
// Онбординг «Начало работы»: окно, тур и пилюля возврата. Состояние живёт
// здесь, потому что ссылка «Начало работы» в субменю «Настроек» читает тот
// же прогресс.
const onboarding = useOnboardingState({ user, setRoute, openSettings: openSettingsRoute });
const loadWaitingCount = useCallback(async () => {
try {
@@ -112,27 +120,32 @@ export function Shell({ route, setRoute, settingsSection, openSettingsRoute, sel
// «Порталы» — своя лента на --surface-feed и полноэкранные сплиты (кадры PT1–PT8).
const isPortals = route === "supportPortals" || route === "supportPortalDetail" || route === "supportPortalSettings";
return (
<div className={`hub-shell ${isDialogsWorkspace ? "is-chat-route" : ""} ${isSettings ? "is-settings-route" : ""} ${isProfile ? "is-profile-route" : ""} ${isContacts ? "is-contacts-route" : ""}`}>
<Sidebar route={route} user={user} setRoute={setRoute} openSettings={openSettingsRoute} onLogout={onLogout} onSwitchOrganization={onSwitchOrganization} waitingCount={waitingCount} chatScope={chatScope.scope} setChatScope={chatScope.setScope} chatCounters={chatScope.counters} unreadCount={unreadCount} onOpenNotifications={() => { setNotifOpen(true); void loadNotifications(); }} expanded={sidebarExpanded} setExpanded={setSidebarExpanded} />
<div className="hub-main">
{manager && <DemoInstallBanner reload={reload} />}
<UpdateBanner enabled={user.isInstanceAdmin} />
{/* Верхней панели нет ни у одной роли (дизайн-базлайн v2): заголовок и
«назад» живут в самой странице, уведомления — в меню профиля сайдбара. */}
<main className={`hub-scroll ${isDialogsWorkspace ? "sales-dialogs-scroll" : ""} ${isAiFullWidth ? "ai-fullwidth-scroll" : ""} ${isSettings ? "settings-scroll" : ""} ${isProfile ? "profile-scroll" : ""} ${isContacts ? "contacts-scroll" : ""} ${isAgents ? "agents-scroll" : ""} ${isEmployees ? "employees-scroll" : ""} ${isAudit ? "audit-scroll" : ""} ${isPortals ? "portals-scroll" : ""} ${isKnowledge || isKnowledgeEditor ? "knowledge-scroll" : ""}`}>
<div key={route} className={`hub-page enter-surface ${isSalesWorkspace || isSupportWorkspace ? "sales-workspace-page" : ""} ${isDialogsWorkspace ? "sales-dialogs-page" : ""} ${isAiFullWidth ? "ai-fullwidth-page" : ""} ${isSettings ? "settings-page" : ""} ${isProfile ? "profile-page-shell" : ""} ${isContacts ? "contacts-page-shell" : ""} ${isAgents ? "agents-page-shell" : ""} ${isEmployees ? "employees-page-shell" : ""} ${isAudit ? "audit-page-shell" : ""} ${isKnowledge ? "knowledge-page-shell" : ""} ${isKnowledgeEditor ? "knowledge-editor-shell" : ""} ${isPortals ? "portals-page-shell" : ""}`}>
<ShellRouteContent settingsSection={settingsSection} openSettings={openSettingsRoute} chatScope={chatScope.scope} setChatScope={chatScope.setScope} chatCounters={chatScope.counters} chatScopeSwitcher={manager} route={route} data={data} selectedEmployeeId={selectedEmployeeId} selectedAgentId={selectedAgentId} selectedKnowledgeId={selectedKnowledgeId} selectedConversationId={selectedConversationId} selectedClientId={selectedClientId} openClient={openClientRoute} selectedChannelId={selectedChannelId} openChannel={openChannelRoute} selectedSupportPortalId={selectedSupportPortalId} portalSettingsSection={portalSettingsSection} openSupportPortal={openSupportPortalRoute} openPortalSettings={openPortalSettingsRoute} openConversation={openConversationRoute} openEmployee={openEmployee} openAgent={openAgentRoute} openKnowledge={openKnowledgeRoute} openKnowledgeEditor={openKnowledgeEditorRoute} onAgentLoaded={() => undefined} onChannelLoaded={() => undefined} reload={reload} setRoute={setRoute} user={user} onUserUpdated={onUserUpdated} onLogout={onLogout} onOpenSidebar={() => setSidebarExpanded(true)} onOrganizationCreated={onOrganizationCreated} />
</div>
</main>
<OnboardingProvider value={onboarding}>
<div className={`hub-shell ${isDialogsWorkspace ? "is-chat-route" : ""} ${isSettings ? "is-settings-route" : ""} ${isProfile ? "is-profile-route" : ""} ${isContacts ? "is-contacts-route" : ""}`}>
<Sidebar route={route} user={user} setRoute={setRoute} onLogout={onLogout} onSwitchOrganization={onSwitchOrganization} waitingCount={waitingCount} chatScope={chatScope.scope} setChatScope={chatScope.setScope} chatCounters={chatScope.counters} unreadCount={unreadCount} onOpenNotifications={() => { setNotifOpen(true); void loadNotifications(); }} expanded={sidebarExpanded} setExpanded={setSidebarExpanded} />
<div className="hub-main">
{manager && <DemoInstallBanner reload={reload} />}
<UpdateBanner enabled={user.isInstanceAdmin} />
{/* Верхней панели нет ни у одной роли (дизайн-базлайн v2): заголовок и
«назад» живут в самой странице, уведомления — в меню профиля сайдбара. */}
<main className={`hub-scroll ${isDialogsWorkspace ? "sales-dialogs-scroll" : ""} ${isAiFullWidth ? "ai-fullwidth-scroll" : ""} ${isSettings ? "settings-scroll" : ""} ${isProfile ? "profile-scroll" : ""} ${isContacts ? "contacts-scroll" : ""} ${isAgents ? "agents-scroll" : ""} ${isEmployees ? "employees-scroll" : ""} ${isAudit ? "audit-scroll" : ""} ${isPortals ? "portals-scroll" : ""} ${isKnowledge || isKnowledgeEditor ? "knowledge-scroll" : ""}`}>
<div key={route} className={`hub-page enter-surface ${isSalesWorkspace || isSupportWorkspace ? "sales-workspace-page" : ""} ${isDialogsWorkspace ? "sales-dialogs-page" : ""} ${isAiFullWidth ? "ai-fullwidth-page" : ""} ${isSettings ? "settings-page" : ""} ${isProfile ? "profile-page-shell" : ""} ${isContacts ? "contacts-page-shell" : ""} ${isAgents ? "agents-page-shell" : ""} ${isEmployees ? "employees-page-shell" : ""} ${isAudit ? "audit-page-shell" : ""} ${isKnowledge ? "knowledge-page-shell" : ""} ${isKnowledgeEditor ? "knowledge-editor-shell" : ""} ${isPortals ? "portals-page-shell" : ""}`}>
<ShellRouteContent settingsSection={settingsSection} openSettings={openSettingsRoute} chatScope={chatScope.scope} setChatScope={chatScope.setScope} chatCounters={chatScope.counters} chatScopeSwitcher={manager} route={route} data={data} selectedEmployeeId={selectedEmployeeId} selectedAgentId={selectedAgentId} selectedKnowledgeId={selectedKnowledgeId} selectedConversationId={selectedConversationId} selectedClientId={selectedClientId} openClient={openClientRoute} selectedChannelId={selectedChannelId} openChannel={openChannelRoute} selectedSupportPortalId={selectedSupportPortalId} portalSettingsSection={portalSettingsSection} openSupportPortal={openSupportPortalRoute} openPortalSettings={openPortalSettingsRoute} openConversation={openConversationRoute} openEmployee={openEmployee} openAgent={openAgentRoute} openKnowledge={openKnowledgeRoute} openKnowledgeEditor={openKnowledgeEditorRoute} onAgentLoaded={() => undefined} onChannelLoaded={() => undefined} reload={reload} setRoute={setRoute} user={user} onUserUpdated={onUserUpdated} onLogout={onLogout} onOpenSidebar={() => setSidebarExpanded(true)} onOrganizationCreated={onOrganizationCreated} />
</div>
</main>
</div>
<NotificationDrawer
open={notifOpen}
items={notifications}
unreadCount={unreadCount}
onClose={() => setNotifOpen(false)}
onItemClick={onNotificationClick}
onMarkAll={onMarkAll}
/>
<OnboardingOverlay />
<OnboardingTour />
<OnboardingLauncher />
</div>
<NotificationDrawer
open={notifOpen}
items={notifications}
unreadCount={unreadCount}
onClose={() => setNotifOpen(false)}
onItemClick={onNotificationClick}
onMarkAll={onMarkAll}
/>
</div>
</OnboardingProvider>
);
}
+8 -9
View File
@@ -2,13 +2,12 @@ import { Dropdown } from "antd";
import type { CSSProperties } from "react";
import type { RouteKey, SessionUser } from "../types";
import type { SettingsSectionKey } from "../features/settings/sections";
import { useResizableWidth } from "../shared/useResizableWidth";
import { Icon, LogoIcon } from "../shared/icons";
import { canCreateOrganization, defaultRoute, isManager } from "../auth/access";
import type { TourTarget } from "../features/onboarding/steps";
import type { DialogScope } from "../features/conversations/ConversationWorkspace";
import { agentColorOf, groupColorOf, type ConversationCounters } from "../features/conversations/model";
import { LaunchChecklist } from "./LaunchChecklist";
import { SidebarUserMenu } from "./SidebarUserMenu";
import { t } from "../i18n";
@@ -24,14 +23,17 @@ type SidebarLinkProps = {
route: RouteKey;
routeKey: RouteKey;
setRoute: (route: RouteKey) => void;
// Имя цели для тура онбординга: по нему «Показать где» находит пункт на экране.
target?: TourTarget;
};
function SidebarLink({ activeRoutes, badge, icon, label, route, routeKey, setRoute }: SidebarLinkProps) {
function SidebarLink({ activeRoutes, badge, icon, label, route, routeKey, setRoute, target }: SidebarLinkProps) {
const active = activeRoutes?.includes(route) ?? routeKey === route;
return (
<button
className={`hub-nav-item ${active ? "is-active" : ""}`}
type="button"
data-onboarding-target={target}
onClick={() => setRoute(routeKey)}
>
<Icon name={icon} />
@@ -45,7 +47,6 @@ export function Sidebar({
route,
user,
setRoute,
openSettings,
onLogout,
onSwitchOrganization,
waitingCount = 0,
@@ -60,7 +61,6 @@ export function Sidebar({
route: RouteKey;
user: SessionUser;
setRoute: (route: RouteKey) => void;
openSettings: (section: SettingsSectionKey | null) => void;
onLogout: () => void;
onSwitchOrganization: (organizationPublicId: string) => void;
waitingCount?: number;
@@ -170,10 +170,10 @@ export function Sidebar({
<nav className="hub-nav">
<SidebarLink icon="message" label={t("common.chat")} badge={waitingCount > 0 ? String(waitingCount) : undefined} route={route} routeKey="chat" setRoute={setRoute} />
<SidebarLink activeRoutes={["salesClients", "salesClientDetail"]} icon="user" label={t("common.contacts")} route={route} routeKey="salesClients" setRoute={setRoute} />
<SidebarLink activeRoutes={["agents", "agentDetail"]} icon="robot" label={t("common.agents")} route={route} routeKey="agents" setRoute={setRoute} />
<SidebarLink activeRoutes={["employees", "employeeDetail"]} icon="team" label={t("common.operators")} route={route} routeKey="employees" setRoute={setRoute} />
<SidebarLink activeRoutes={["agents", "agentDetail"]} icon="robot" label={t("common.agents")} route={route} routeKey="agents" setRoute={setRoute} target="nav-agents" />
<SidebarLink activeRoutes={["employees", "employeeDetail"]} icon="team" label={t("common.operators")} route={route} routeKey="employees" setRoute={setRoute} target="nav-employees" />
<SidebarLink activeRoutes={["supportPortals", "supportPortalDetail", "supportPortalSettings"]} icon="globe" label={t("common.portals")} route={route} routeKey="supportPortals" setRoute={setRoute} />
<SidebarLink activeRoutes={["knowledge", "knowledgeDetail", "knowledgeCreate", "knowledgeEdit", "knowledgeCategories", "knowledgeImport"]} icon="book" label={t("common.knowledge_base")} route={route} routeKey="knowledge" setRoute={setRoute} />
<SidebarLink activeRoutes={["knowledge", "knowledgeDetail", "knowledgeCreate", "knowledgeEdit", "knowledgeCategories", "knowledgeImport"]} icon="book" label={t("common.knowledge_base")} route={route} routeKey="knowledge" setRoute={setRoute} target="nav-knowledge" />
<SidebarLink activeRoutes={["settings", "administrationAudit"]} icon="settings" label={t("common.settings")} route={route} routeKey="settings" setRoute={setRoute} />
</nav>
) : route === "profile" ? (
@@ -191,7 +191,6 @@ export function Sidebar({
}}
/>
)}
{manager && <LaunchChecklist user={user} setRoute={setRoute} openSettings={openSettings} />}
<SidebarUserMenu user={user} route={route} setRoute={setRoute} onLogout={onLogout} unreadCount={unreadCount} onOpenNotifications={onOpenNotifications} />
</div>
</aside>
+1 -125
View File
@@ -1,4 +1,5 @@
.hub-shell {
position: relative;
height: 100vh;
display: flex;
overflow: hidden;
@@ -670,131 +671,6 @@
}
/* --- Блок «Запуск» (SPEC-CHATBALLS-0031 §5, дизайн-базлайн v2 A1) --- */
.launch-checklist {
flex: none;
margin: 6px 10px 8px;
padding: 12px 12px 10px;
border: 1px solid var(--border-default);
border-radius: 10px;
background: var(--surface-card);
}
.launch-checklist-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
margin-bottom: 10px;
}
.launch-checklist-head strong {
color: var(--text-heading);
font-size: 13px;
font-weight: 700;
}
.launch-checklist-head small {
margin-left: 7px;
color: var(--text-tertiary);
font-size: 11.5px;
font-weight: 500;
}
.launch-checklist-head button {
width: 22px;
height: 22px;
border: none;
border-radius: 6px;
background: transparent;
color: var(--text-disabled);
font-size: 15px;
line-height: 1;
cursor: pointer;
}
.launch-checklist-head button:hover {
background: var(--n-9);
color: var(--text-body);
}
.launch-checklist-progress {
height: 3px;
border-radius: 2px;
background: var(--n-8);
margin-bottom: 12px;
overflow: hidden;
}
.launch-checklist-progress i {
display: block;
height: 100%;
background: var(--primary);
}
.launch-checklist-steps {
display: flex;
flex-direction: column;
gap: 8px;
}
.launch-checklist-step {
display: flex;
align-items: center;
gap: 9px;
color: var(--text-tertiary);
font-size: 12.5px;
}
.launch-checklist-step.is-current {
color: var(--text-heading);
font-weight: 600;
}
.launch-checklist-step.is-done span:last-child {
text-decoration: line-through;
}
.launch-checklist-mark {
width: 16px;
height: 16px;
flex: none;
border: 1.5px solid var(--border-strong);
border-radius: 50%;
display: inline-flex;
align-items: center;
justify-content: center;
color: #fff;
}
.launch-checklist-step.is-done .launch-checklist-mark {
border-color: var(--primary);
background: var(--primary);
}
.launch-checklist-action {
margin-top: 11px;
width: 100%;
height: 30px;
border: none;
border-radius: 7px;
background: var(--primary);
color: #fff;
font-size: 12.5px;
font-weight: 600;
cursor: pointer;
font-family: inherit;
display: inline-flex;
align-items: center;
justify-content: center;
gap: 6px;
}
.launch-checklist-action:hover {
background: var(--primary-hover);
}
/* Полоса установки демо-данных: пока worker наполняет организацию, человек
видит причину пустых разделов, а не гадает. */
.demo-install-banner {
+3 -3
View File
@@ -7,13 +7,13 @@ import { t } from "../i18n";
export type UiTheme = "LIGHT" | "DARK" | "SYSTEM";
export const DEFAULT_ACCENT = "#1677ff";
export const DEFAULT_ACCENT = "#0f9b8e";
// Пресеты акцента из дизайн-базлайна v2 (Tweaks макета).
// Пресеты акцента; первый — цвет продукта по умолчанию.
export const ACCENT_PRESETS: Array<[string, string]> = [
["#0f9b8e", t("shared.teal")],
["#1677ff", t("shared.blue")],
["#6d5dfc", t("shared.indigo")],
["#0f9b8e", t("shared.teal")],
["#e8590c", t("shared.orange")],
];
@@ -1,6 +1,7 @@
import { Modal } from "antd";
import { useState } from "react";
import { SelectField } from "../form-controls";
import { Button } from "../ui-controls";
import { t, tn, type MessageKey } from "../../i18n";
@@ -69,19 +70,16 @@ export function AgentLinkDialog({
<button className={action === "detach" ? "active" : ""} disabled={busy} type="button" onClick={() => setAction("detach")}>{t("common.detach")}</button>
</div>
</div>
<label className="content-dialog-field content-dialog-select">
<span>{t("common.agent")}</span>
<div>
<select disabled={busy || agents.length === 0} value={agentId ?? ""} onChange={(event) => setAgentId(event.target.value ? Number(event.target.value) : null)}>
<option value="">{t("shared.pick_agent")}</option>
{agents.map((agent) => (
<option value={agent.id} key={agent.id}>
{agent.name}{agent.groupName ? ` · ${agent.groupName}` : ""}
</option>
))}
</select>
</div>
</label>
<SelectField
label={t("common.agent")}
disabled={busy || agents.length === 0}
value={agentId === null ? "" : String(agentId)}
onChange={(value) => setAgentId(value ? Number(value) : null)}
options={[
["", t("shared.pick_agent")],
...agents.map((agent): [string, string] => [String(agent.id), `${agent.name}${agent.groupName ? ` · ${agent.groupName}` : ""}`]),
]}
/>
{agents.length === 0 && <div className="content-dialog-error">{t("shared.there_no_agents_attach_material")}</div>}
</>
)}
@@ -143,29 +143,6 @@
font-weight: 500;
}
.content-dialog-select > div {
display: flex;
height: 42px;
align-items: center;
gap: 8px;
padding: 0 13px;
border: 1px solid var(--n-7);
border-radius: 9px;
color: var(--n-4);
background: var(--surface-card);
}
.content-dialog-select select {
min-width: 0;
flex: 1;
border: 0;
outline: 0;
color: var(--n-2);
background: transparent;
font: inherit;
font-size: 14px;
}
.content-dialog-segmented {
display: inline-flex;
gap: 3px;
+22 -51
View File
@@ -38,8 +38,7 @@
.form-field.is-editable input:focus,
.form-field.is-editable:focus-within textarea,
.form-field.is-editable textarea:focus,
.select-like:focus-within select,
.select-like .select-box:focus-within {
.select-like .select-box:focus-visible {
border-color: var(--primary);
}
@@ -68,58 +67,40 @@
border-color: var(--n-8);
}
.select-like {
position: relative;
}
.select-like select {
width: 100%;
height: 36px;
padding: 0 32px 0 12px;
cursor: pointer;
color: var(--n-1);
background: var(--n-10);
border: 1px solid var(--n-7);
border-radius: 8px;
outline: none;
appearance: none;
font-size: 13.5px;
line-height: normal;
}
/* Шеврон по центру поля: 36px бокса минус 14px иконки. Отступ справа — те же
12px внутреннего поля плюс 1px рамки, что и у шеврона внутри `.select-box`. */
.select-like > svg {
position: absolute;
right: 13px;
bottom: 11px;
color: var(--n-4);
pointer-events: none;
}
/* Бокс селекта — для метки перед значением (точка группы на карточке агента) и
для режима без прав правки. Рамку держит бокс, селект внутри без своей. */
/* Бокс селекта — он же кнопка, открывающая меню приложения. Нативного
`<select>` в проекте нет: список вариантов рисует `SelectMenu`, тот же, что
у фильтров списков. */
.select-like .select-box {
display: flex;
align-items: center;
gap: 7px;
width: 100%;
height: 36px;
padding: 0 12px;
color: var(--n-1);
background: var(--n-10);
border: 1px solid var(--n-7);
border-radius: 8px;
outline: none;
font-family: inherit;
font-size: 13.5px;
line-height: normal;
text-align: left;
}
.select-like .select-box select {
flex: 1;
height: 34px;
padding: 0;
color: inherit;
background: transparent;
border: 0;
font-size: inherit;
button.select-box {
cursor: pointer;
}
button.select-box:hover:not(:disabled) {
border-color: var(--n-6);
}
button.select-box:disabled {
color: var(--n-4);
background: var(--n-9);
border-color: var(--n-8);
cursor: default;
}
.select-like .select-box > svg {
@@ -136,7 +117,6 @@
text-overflow: ellipsis;
}
.select-like.is-invalid select,
.select-like.is-invalid .select-box {
color: var(--error-text);
border-color: var(--error-border);
@@ -146,19 +126,10 @@
текст 15px. Это тоже лежало копиями в профиле и настройках. */
@media (max-width: 900px) {
.readonly-field input,
.select-like select,
.select-like .select-box {
height: 44px;
font-size: 15px;
}
.select-like .select-box select {
height: 42px;
}
.select-like > svg {
bottom: 15px;
}
}
/* Единый переключатель приложения. Раньше в проекте было четыре его копии
+49 -16
View File
@@ -1,6 +1,7 @@
import type { ReactNode } from "react";
import { useRef, useState, type ReactNode } from "react";
import { Icon } from "./icons";
import { SelectMenu } from "./ui-controls";
type FormFieldProps = {
disabled?: boolean;
@@ -61,26 +62,58 @@ type SelectFieldProps = {
wide?: boolean;
};
/** Селект приложения — один на всё: подпись, бокс поля и шеврон. Точку перед
* значением и режим без прав правки держит `.select-box`: рамка тогда на нём,
* а сам селект внутри без рамки и фона. */
/** Селект приложения — один на всё: подпись, бокс поля и шеврон. Список
* вариантов рисует общий `SelectMenu`, тот же, что у фильтров списков:
* нативного `<select>` в проекте нет нигде, иначе вместо меню приложения
* открывался бы список операционной системы. */
export function SelectField({ adornment, disabled = false, invalid = false, label, onChange, options, readOnly = false, readOnlyText, value, wide = false }: SelectFieldProps) {
const className = ["readonly-field", "select-like", invalid ? "is-invalid" : "", readOnly ? "is-readonly" : "", wide ? "wide" : ""].filter(Boolean).join(" ");
const control = readOnly
? <span className="select-value">{readOnlyText ?? options.find(([optionValue]) => optionValue === value)?.[1] ?? ""}</span>
: (
<select disabled={disabled} value={value} onChange={(event) => onChange(event.target.value)}>
{options.map(([optionValue, labelText]) => <option value={optionValue} key={optionValue}>{labelText}</option>)}
</select>
);
const [open, setOpen] = useState(false);
const [menuWidth, setMenuWidth] = useState<number>();
const trigger = useRef<HTMLButtonElement>(null);
const className = ["readonly-field", "select-like", invalid ? "is-invalid" : "", readOnly ? "is-readonly" : "", disabled ? "is-disabled" : "", wide ? "wide" : ""].filter(Boolean).join(" ");
const current = options.find(([optionValue]) => optionValue === value)?.[1] ?? "";
const chevron = <Icon name="chevron" size={14} strokeWidth={2.2} />;
const boxed = Boolean(adornment) || readOnly;
if (readOnly) {
return (
<div className={className}>
<span>{label}</span>
<span className="select-box">{adornment}<span className="select-value">{readOnlyText ?? current}</span>{chevron}</span>
</div>
);
}
return (
<label className={className}>
<div className={className}>
<span>{label}</span>
{boxed ? <span className="select-box">{adornment}{control}{chevron}</span> : <>{control}{chevron}</>}
</label>
<SelectMenu
disabled={disabled}
onOpenChange={setOpen}
onSelect={(next) => {
onChange(next);
setOpen(false);
}}
open={open}
options={options.map(([optionValue, optionLabel]) => ({ value: optionValue, label: optionLabel }))}
// Меню селекта равно ширине поля, а не фиксированным 216px фильтра.
overlayClassName="app-dropdown is-field"
overlayStyle={menuWidth ? { width: menuWidth } : undefined}
selected={value ? [value] : []}
>
<button
className="select-box"
ref={trigger}
type="button"
aria-label={label}
disabled={disabled}
onClick={() => setMenuWidth(trigger.current?.offsetWidth)}
>
{adornment}
<span className="select-value">{current}</span>
{chevron}
</button>
</SelectMenu>
</div>
);
}
+13
View File
@@ -25,6 +25,19 @@
width: 216px;
}
/* Меню селекта в форме: ширину задаёт поле, ширина приходит инлайном на
оверлей — фиксированные 216px фильтра здесь были бы уже или шире поля. */
.app-dropdown.is-field .ant-dropdown-menu {
width: 100%;
}
/* Текущее значение в одиночном селекте: подсветка вместо галочки. */
.app-dropdown .ant-dropdown-menu-item button.is-selected {
color: var(--primary-text);
background: var(--primary-bg);
font-weight: 600;
}
.app-dropdown.is-channels-menu .ant-dropdown-menu {
width: 246px;
}
+52 -16
View File
@@ -1,5 +1,5 @@
import { Dropdown } from "antd";
import { useEffect, useRef, useState, type ButtonHTMLAttributes, type ReactNode, type RefObject } from "react";
import { useEffect, useRef, useState, type ButtonHTMLAttributes, type CSSProperties, type ReactElement, type ReactNode, type RefObject } from "react";
import { Icon } from "./icons";
import { t } from "../i18n";
@@ -132,28 +132,34 @@ export function SearchInput({ className = "", placeholder, value, onChange, inpu
);
}
export type FilterOption = { value: string; label: string; dot?: string };
export type SelectOption = { value: string; label: string; dot?: string };
/** Фильтр-селект списка — один на всё приложение: кнопка с текущим значением и
* шевроном, меню — общий `app-dropdown`. `multiple` включает галочки и счётчик
* выбранных (кадры K1 «Контакты», E1 «Сотрудники»). */
export function FilterDropdown({ caption, className = "", icon, label, options, selected, multiple = false, open, onOpenChange, onSelect }: {
/** Приглушённая подпись перед значением: «Статус: Все» (кадры PT1/PT3). */
caption?: string;
className?: string;
icon?: IconName;
label: string;
options: FilterOption[];
selected: string[];
/** Совместимое имя: фильтры списков звали вариант `FilterOption`. */
export type FilterOption = SelectOption;
/** Выпадающий список приложения — один на все селекты: и на фильтры списков, и
* на поля форм. Своей разметки у него нет, только меню в общем `app-dropdown`
* и триггер, который передают снаружи: у фильтра это кнопка-пилюля, у поля
* формы — бокс на всю ширину. Галочки включает `multiple`. Нативных `<select>`
* в проекте нет — список вариантов всегда рисуем сами, иначе на месте меню
* оказывается список операционной системы. */
export function SelectMenu({ children, disabled = false, multiple = false, onOpenChange, onSelect, open, options, overlayClassName = "app-dropdown is-wide", overlayStyle, selected }: {
children: ReactElement;
disabled?: boolean;
multiple?: boolean;
open: boolean;
onOpenChange: (open: boolean) => void;
onSelect: (value: string) => void;
open: boolean;
options: SelectOption[];
overlayClassName?: string;
overlayStyle?: CSSProperties;
selected: string[];
}) {
const items = options.map((option) => ({
key: option.value,
label: (
<button
className={!multiple && selected.includes(option.value) ? "is-selected" : ""}
type="button"
onClick={(event) => {
if (multiple) event.stopPropagation();
@@ -170,9 +176,39 @@ export function FilterDropdown({ caption, className = "", icon, label, options,
</button>
),
}));
return (
<Dropdown
menu={{ items }}
open={disabled ? false : open}
onOpenChange={onOpenChange}
trigger={["click"]}
disabled={disabled}
overlayClassName={overlayClassName}
overlayStyle={overlayStyle}
>
{children}
</Dropdown>
);
}
/** Фильтр-селект списка: кнопка с текущим значением и шевроном. `multiple`
* включает галочки и счётчик выбранных (кадры K1 «Контакты», E1 «Сотрудники»). */
export function FilterDropdown({ caption, className = "", icon, label, options, selected, multiple = false, open, onOpenChange, onSelect }: {
/** Приглушённая подпись перед значением: «Статус: Все» (кадры PT1/PT3). */
caption?: string;
className?: string;
icon?: IconName;
label: string;
options: SelectOption[];
selected: string[];
multiple?: boolean;
open: boolean;
onOpenChange: (open: boolean) => void;
onSelect: (value: string) => void;
}) {
const active = selected.length > 0;
return (
<Dropdown menu={{ items }} open={open} onOpenChange={onOpenChange} trigger={["click"]} overlayClassName="app-dropdown is-wide">
<SelectMenu multiple={multiple} onOpenChange={onOpenChange} onSelect={onSelect} open={open} options={options} selected={selected}>
<button className={`ui-filter-button ${active ? "is-active" : ""} ${className}`.trim()} type="button">
{icon && <Icon name={icon} size={14} strokeWidth={2} />}
{caption && <i className="ui-filter-caption">{caption}</i>}
@@ -180,7 +216,7 @@ export function FilterDropdown({ caption, className = "", icon, label, options,
{multiple && selected.length > 0 && <span>{selected.length}</span>}
<Icon name="chevron" size={13} strokeWidth={2.2} />
</button>
</Dropdown>
</SelectMenu>
);
}
+1
View File
@@ -26,5 +26,6 @@
@import "./features/administration/styles.css";
@import "./features/organizations/styles.css";
@import "./features/settings/styles.css";
@import "./features/onboarding/styles.css";
@import "./features/notifications/styles.css";
@import "./shared/state.css";
+7 -1
View File
@@ -29,7 +29,7 @@
--primary — акцент на экране: в тёмной теме он приглушён подложкой
карточки, как в дизайн-базлайне v2 (accentMuted). Производные оттенки
считаются от сырого акцента, а не от приглушённого. */
--accent: #1677ff;
--accent: #0f9b8e;
--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));
@@ -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,
+5 -2
View File
@@ -80,8 +80,11 @@ fi
# Файл на хосте должен соответствовать работающей версии: иначе следующий
# ручной `docker compose up` откатил бы установку.
if [ -n "$WORKDIR" ]; then
if docker run --rm -v "$WORKDIR:/host" -v "$VOLUME:/updates" "$SELF_IMAGE" \
sh -c "cp /updates/compose.$VERSION.yaml /host/compose.yaml" >>"$LOG" 2>&1; then
# Входную точку образа подменяем: иначе вместо копирования запустится цикл
# ожидания запросов, и контейнер никогда не завершится.
if docker run --rm --entrypoint /bin/sh \
-v "$WORKDIR:/host" -v "$VOLUME:/updates" "$SELF_IMAGE" \
-c "cp /updates/compose.$VERSION.yaml /host/compose.yaml" >>"$LOG" 2>&1; then
log "compose.yaml в $WORKDIR обновлён"
else
log "предупреждение: не удалось обновить compose.yaml в $WORKDIR"
+17 -2
View File
@@ -61,8 +61,20 @@ if [ -z "$PROJECT" ] || [ -z "$UPDATES_VOLUME" ] || [ -z "$SELF_IMAGE" ]; then
fi
log "готов: проект $PROJECT, каталог ${WORKDIR:-?}, том $UPDATES_VOLUME"
helper_running() {
docker ps -q --filter "name=^chatballs-updater-apply$" | grep -q .
}
while true; do
touch "$HEARTBEAT"
# Помощник мог исчезнуть, не дописав статус (падение, kill, нехватка памяти).
# Без этой проверки статус остался бы «running» навсегда: интерфейс крутил бы
# установку, а новые запросы отбрасывались бы как «уже идёт».
if [ -f "$STATUS" ] && [ "$(json_field "$STATUS" status)" = "running" ] && ! helper_running; then
stale_version="$(json_field "$STATUS" version)"
log "помощник завершился, не дописав результат установки $stale_version"
write_status failed "$stale_version" "installer stopped without result"
fi
if [ -f "$REQUEST" ]; then
version="$(json_field "$REQUEST" version)"
compose_url="$(json_field "$REQUEST" compose_url)"
@@ -76,7 +88,7 @@ while true; do
write_status failed "$version" "compose.yaml address is not the release page"
sleep "$POLL_SECONDS"; continue
fi
if docker ps -q --filter "name=^chatballs-updater-apply$" | grep -q .; then
if helper_running; then
log "установка уже идёт — запрос $version пропущен"
sleep "$POLL_SECONDS"; continue
fi
@@ -84,7 +96,10 @@ while true; do
write_status running "$version" "starting"
# Помощник вне проекта: свой образ, docker.sock и тот же том; переживает
# пересоздание этого сервиса и дописывает статус до конца.
# Входная точка образа — этот же сценарий, поэтому её обязательно подменить:
# иначе помощник вместо установки запустит второй цикл ожидания запросов.
if ! docker run -d --rm --name chatballs-updater-apply \
--entrypoint /bin/sh \
-e CHATBALLS_UPDATE_REPO="$REPO" \
-e CHATBALLS_PROJECT="$PROJECT" \
-e CHATBALLS_WORKDIR="$WORKDIR" \
@@ -92,7 +107,7 @@ while true; do
-e CHATBALLS_UPDATER_IMAGE="$SELF_IMAGE" \
-v "$SOCKET:$SOCKET" \
-v "$UPDATES_VOLUME:$DIR" \
"$SELF_IMAGE" chatballs-updater-apply.sh "$version" "$expected" >/dev/null 2>"$DIR/apply.err"; then
"$SELF_IMAGE" /usr/local/bin/chatballs-updater-apply.sh "$version" "$expected" >/dev/null 2>"$DIR/apply.err"; then
write_status failed "$version" "could not start helper: $(cat "$DIR/apply.err" 2>/dev/null | tail -c 400)"
fi
fi
@@ -0,0 +1,272 @@
# Handoff: новый онбординг Chatballs (первый запуск)
## Overview
Замена текущего блока «Запуск» (`apps/internal-ui/src/layout/LaunchChecklist.tsx`) на полноценный онбординг:
1. **Модальное окно при входе** — приветствие + визард из 8 шагов, который доскональнo показывает, что и где настроить, чтобы установка заработала целиком.
2. **Тур по интерфейсу** — кнопка «Показать где» затемняет приложение и подсвечивает реальный элемент (пункт сайдбара, строку субменю «Настроек», список диалогов) с колаутом-объяснением.
3. **Точки возврата** — ссылка «Начало работы» внизу субменю «Настроек» и плавающая пилюля с прогрессом в правом нижнем углу рабочей области.
4. Старый блок «Запуск» удаляется полностью.
Требование заказчика: онбординг **должен показаться всем, кто его ещё не прошёл и не закрыл, включая уже существующих пользователей**. Значит признак «пройден/закрыт» хранится на сервере, а не в `localStorage` (как сейчас в `LaunchChecklist`).
## About the Design Files
Файл `Онбординг Chatballs.dc.html` в этом архиве — **дизайн-референс, написанный на HTML**. Это прототип: он показывает задуманный вид, тексты и поведение. Это **не продакшн-код, который нужно копировать**.
Задача — воспроизвести этот дизайн в существующей среде репозитория: `apps/internal-ui` (React 18 + TypeScript + Vite, antd для оверлеев/дропдаунов, собственные CSS-файлы с токенами в `src/styles/base.css`, i18n через `src/i18n`). Все значения ниже уже совпадают с токенами репозитория — новых цветов и шрифтов не вводилось.
Прототип содержит и **воссозданное окружение** (сайдбар, экран «Чат», экран «Настройки»), чтобы было видно онбординг в контексте. Воссозданное окружение реализовывать не нужно — оно уже есть в коде. Реализуется только онбординг, тур, ссылка в «Настройках» и пилюля.
## Fidelity
**High-fidelity.** Цвета, типографика, отступы, радиусы, тени, тексты и переходы — финальные. Верстать пиксель-в-пиксель, но через существующие примитивы репозитория (`shared/icons.tsx`, `shared/ui-controls.tsx`, токены `base.css`), а не через копирование инлайновых стилей прототипа.
Темы: светлая и тёмная (в прототипе переключается тумблером `theme`). Всё выражено через существующие CSS-переменные, поэтому тёмная тема получается сама — своих хексов в компоненте быть не должно, кроме затемнения-скрима (см. «Design Tokens»).
---
## Screens / Views
### 1. Приветственное окно (`view = "welcome"`)
**Purpose:** объяснить владельцу, что установка поднялась и что дальше 8 шагов; дать войти в визард или отложить.
**Layout:** оверлей `position: absolute; inset: 0` (внутри `.hub-shell`), `z-index: 30`, фон-скрим + `backdrop-filter: blur(3px)`, контент по центру, внешний отступ 40px. Карточка: `width: min(700px, 100%)`, `border-radius: 18px`, `border: 1px solid var(--border-default)`, `background: var(--surface-card)`, тень `--modal-shadow`, `overflow: hidden`. Появление: `ob-rise 320ms cubic-bezier(.2,.8,.2,1)`, оверлей — `ob-fade 220ms ease-out`.
**Components (сверху вниз):**
| Элемент | Спецификация |
|---|---|
| Шапка-градиент | `padding: 44px 48px 34px`, `background: linear-gradient(180deg, var(--primary-bg), var(--surface-card))` |
| Кнопка «×» | `position: absolute; top: 18px; right: 18px`, 32×32, `border-radius: 8px`, иконка `Icon name="close"` 17px, цвет `var(--n-4)`; hover — фон `var(--n-9)`, цвет `var(--n-2)` |
| Плитка логотипа | 46×46, `border-radius: 14px`, фон `var(--primary)`, внутри `LogoIcon` 28px белым, тень `0 8px 20px color-mix(in srgb, var(--accent) 34%, transparent)`, `margin-bottom: 22px` |
| Надзаголовок | «УСТАНОВКА ГОТОВА К НАСТРОЙКЕ» — 11.5px/700, `letter-spacing: .1em`, `text-transform: uppercase`, цвет `var(--primary-text)`, `margin-bottom: 11px` |
| Заголовок | «Восемь шагов до первого ответа клиенту» — 29px/700, `letter-spacing: -.025em`, `line-height: 1.18`, `max-width: 520px`, цвет `var(--n-1)` |
| Лид | «Покажем по порядку, что и где настроить: ключ модели, агента, знания, точку входа и команду. На каждом шаге — точный путь в интерфейсе и подсветка нужного элемента на экране.» — 14.5px/1.6, `var(--n-3)`, `max-width: 520px`, `margin-top: 14px` |
| Три факта | `display: grid; grid-template-columns: repeat(3, minmax(0,1fr)); gap: 1px`, фон грида `var(--border-default)` (даёт разделители в 1px), рамки сверху и снизу 1px. Ячейка: `padding: 17px 20px`, фон `var(--surface-card)`; `strong` 13.5px/700 `var(--n-1)`, `small` 12.5px/1.45 `var(--n-4)`, отступ 3px. Тексты: «≈ 25 минут / Можно прерваться и вернуться», «Свой ключ модели / Или демо-провайдер без ключа», «Данные у вас / Всё на вашем сервере» |
| Футер | `padding: 20px 48px 26px`, `space-between`. Слева текст-кнопка «Пройду позже» 13px/500 `var(--n-4)` (hover `var(--n-2)`). Справа primary: высота 42, `padding: 0 22px`, `border-radius: 10px`, фон `var(--primary)`, белый текст 14px/600, тень `0 6px 16px color-mix(in srgb, var(--accent) 32%, transparent)`, иконка `Icon name="arrow"` 17px справа, подпись «Начать настройку» |
### 2. Визард шагов (`view = "steps"`)
**Purpose:** провести по 8 шагам настройки, на каждом — точный путь, инструкции, предупреждение и превью результата.
**Layout:** тот же оверлей (внешний отступ 34px). Карточка: `width: min(1020px, 100%)`, `height: min(700px, 100%)`, `display: flex`, `border-radius: 18px`, тень `--modal-shadow`, `overflow: hidden`. Появление `ob-rise 280ms`, оверлей `ob-fade 200ms`.
**Левая рейка — 300px, `flex: none`, `background: var(--n-10)`, `border-right: 1px solid var(--border-default)`:**
- Шапка `padding: 24px 22px 18px`: `LogoIcon` 22px + «Начало работы» 14px/700 `var(--n-1)`, зазор 9px, `margin-bottom: 18px` под строкой.
- Строка прогресса: слева «Готово N из 8» 12px/600 `var(--n-3)`, справа «NN%» 11.5px/600 `var(--n-4)`, `margin-bottom: 8px`. Полоса: высота 4, `border-radius: 999px`, трек `var(--n-8)`, заполнение `var(--primary)`, `transition: width 280ms ease`.
- Список шагов: `padding: 2px 12px 12px`, `gap: 2px`, прокручиваемый. Строка-кнопка: `padding: 9px 11px`, `border-radius: 9px`, `gap: 11px`; текущая — фон `var(--surface-card)` + тень `var(--shadow-sm)`, остальные прозрачные.
- Маркер 22×22, круг, 11.5px/700: пройденный — фон и рамка `var(--success)`, белая галочка (`Icon name="check"`, 11px, `stroke-width: 2.6`); текущий — фон и рамка `var(--primary)`, белая цифра; остальные — фон `var(--n-8)`, рамка `var(--n-7)`, цифра `var(--n-4)`.
- Подпись 13px: текущая `var(--n-1)`/700, пройденная `var(--n-3)`/500, остальные `var(--n-2)`/500. Справа оценка времени 11px/500 `var(--n-5)`.
- Футер рейки: `padding: 12px 22px 20px`, `border-top: 1px solid var(--border-default)`, текст-кнопка «Закрыть и настроить самому» 12.5px/500 `var(--n-4)`.
**Правая часть (`flex: 1`), три полосы:**
1. Шапка `padding: 22px 32px 0`: слева чип «Шаг N из 8 · 3 мин» — высота 24, `padding: 0 10px`, `border-radius: 999px`, фон `var(--primary-bg)`, рамка `var(--primary-border)`, текст `var(--primary-text)` 11.5px/700. Справа «×» 32×32 (как в приветствии).
2. Тело, прокручиваемое, `padding: 14px 32px 26px`:
- Заголовок шага — 23px/700, `letter-spacing: -.022em`, `line-height: 1.2`, `var(--n-1)`.
- Лид — 13.5px/1.6, `var(--n-3)`, `max-width: 560px`, `margin-top: 10px`.
- **Путь (хлебные крошки)** — контейнер `margin-top: 18px`, `padding: 8px 11px`, `border: 1px solid var(--border-default)`, `border-radius: 9px`, фон `var(--n-10)`, `flex-wrap`, `gap: 6px`. Крошка: `var(--font-mono)` 11.5px/600 `var(--n-2)`, между крошками `Icon name="chevronRight"` 12px `var(--n-5)`.
- **Инструкции** — `<ol>` без маркеров, `gap: 13px`, `margin-top: 22px`. Пункт: круг 21×21 (`border: 1px solid var(--primary-border)`, фон `var(--primary-bg)`, цифра `var(--primary-text)` 11px/700) + текст 13.5px/1.55 `var(--n-2)`, зазор 12px.
- **Предупреждение** — `margin-top: 22px`, `padding: 12px 14px`, `border: 1px solid var(--warning-border)`, `border-radius: 10px`, фон `var(--warning-bg)`; иконка `Icon name="alert"` 16px + текст 12.5px/500/1.5, всё цветом `var(--warning-text)`.
- **Превью результата** — `margin-top: 22px`, `border: 1px solid var(--border-default)`, `border-radius: 12px`, фон `var(--n-10)`, `overflow: hidden`.
- Шапка превью: `padding: 9px 14px`, фон `var(--surface-card)`, нижняя рамка 1px. Слева точка 7×7 `var(--n-6)` + подпись `var(--font-mono)` 11px/600 `var(--n-4)`. Справа чип статуса: «успех» — рамка `var(--success-border)`, фон `var(--success-bg)`, текст `var(--success-text)`, галочка 11px; «ожидание» — рамка `var(--warning-border)`, фон `var(--warning-bg)`, текст `var(--warning-text)`. Чип: высота 21, `padding: 0 9px`, `border-radius: 999px`, 11px/700.
- Строки: `gap: 1px` на фоне `var(--border-default)`; строка `padding: 11px 14px`, фон `var(--n-10)`, слева подпись 12px `var(--n-4)`, справа значение 12.5px/600 `var(--n-2)`.
- Блок кода (только шаг 5): `padding: 12px 14px`, фон `var(--surface-card)`, верхняя рамка 1px, `code` — `var(--font-mono)` 11.5px/1.6 `var(--n-3)`, `word-break: break-all`.
3. Футер `padding: 14px 32px 18px`, `border-top: 1px solid var(--border-default)`, фон `var(--surface-card)`, `space-between`:
- Слева «Назад» — высота 36, `padding: 0 12px`, прозрачная, `var(--n-3)` 13px/500, иконка `chevronLeft` 16px; hover фон `var(--n-9)`.
- Справа три кнопки, `gap: 8px`: «Показать где» (вторичная: высота 36, `padding: 0 14px`, `border: 1px solid var(--n-6)`, фон `var(--surface-card)`, `var(--n-3)` 13px/500, иконка `eye` 16px), «Открыть раздел» (такая же вторичная, без иконки), «Далее»/«Завершить» (primary: высота 36, `padding: 0 18px`, `border-radius: 8px`, фон `var(--primary)`, белый 13px/600, тень `0 1px 2px color-mix(in srgb, var(--accent) 30%, transparent)`, иконка `arrow` 16px).
### 3. Тур «Показать где» (`tour != null`)
**Purpose:** показать физическое место элемента в интерфейсе.
Модальное окно скрывается, приложение остаётся видимым. Слои внутри корневого контейнера (`.hub-shell`, он `position: relative`) — **только `position: absolute`, координаты относительно корня**, иначе подсветка уезжает при прокрутке:
- Прозрачная кнопка-подложка `inset: 0`, `z-index: 39` — клик закрывает тур.
- Четыре прямоугольника затемнения (сверху/снизу/слева/справа от цели), `z-index: 40`, цвет: светлая тема `rgba(16,18,22,.55)`, тёмная `rgba(4,5,7,.68)`. Четыре прямоугольника вместо `box-shadow: 0 0 0 9999px` — чтобы корректно рендерилось при экспорте и в скриншотах.
- Рамка цели: прямоугольник цели + `padding: 6px` со всех сторон, `border-radius: 11px`, `box-shadow: 0 0 0 2px var(--primary)`, `z-index: 41`; поверх — второй такой же с `border: 2px solid var(--primary)` и анимацией `ob-pulse 1.9s ease-out infinite`. Оба `pointer-events: none`.
- Колаут, `z-index: 42`, ширина 344: по умолчанию справа от цели (`x + w + 20`), если не влезает — слева (`x - 344 - 20`); по вертикали `y - 14`, зажат в границах корня (мин. 16, макс. `rootH - 250`). Карточка: `padding: 16px 18px 14px`, `border: 1px solid var(--primary-border)`, `border-radius: 13px`, фон `var(--surface-card)`, тень `0 20px 48px -12px rgba(16,18,22,.45)`, анимация `ob-rise 220ms`.
- Надзаголовок «ШАГ N · ГДЕ ЭТО НАХОДИТСЯ» 11px/700 uppercase `letter-spacing: .08em` `var(--primary-text)`.
- Заголовок 14.5px/700 `var(--n-1)`, текст 13px/1.55 `var(--n-3)` (`margin-top: 7px`).
- Кнопка на всю ширину «Понятно, к шагам»: высота 34, `border-radius: 8px`, фон `var(--primary)`, белый 13px/600, иконка `chevronLeft` 15px.
Пересчёт координат — на `resize` и при смене экрана. Реализация замера: взять `getBoundingClientRect()` цели и корня, вычесть.
### 4. Финальный экран (`view = "done"`)
Карточка `min(620px, 100%)`. Плитка 46×46 `border-radius: 14px` фон `var(--success)` с белой галочкой 24px. Заголовок «Всё готово — можно принимать клиентов» 25px/700 `letter-spacing: -.022em`. Лид «Агент отвечает по вашим знаниям, сложное передаёт команде. Вернуться к шагам можно в «Настройки → Начало работы».» 14px/1.6 `var(--n-3)`. Ниже сводка: `grid-template-columns: repeat(2, minmax(0,1fr))`, `gap: 1px` на `var(--border-default)`, ячейка `padding: 12px 16px` фон `var(--surface-card)`: круг 20×20 (`var(--success)` с галочкой 10px, если пройден; иначе `var(--n-7)` пустой) + подпись 13px (пройден — `var(--n-2)`/600, иначе `var(--n-4)`/500). Футер: «Пройти заново» (текст-кнопка) и primary «Перейти в чат» (высота 40, `padding: 0 20px`, `border-radius: 10px`, иконка `arrow` 17px).
### 5. Ссылка в субменю «Настроек»
В `SettingsPage.tsx`, в блоке `links` (после «Аудит действий»): разделитель `height: 1px; margin: 10px 6px 8px; background: var(--n-8)`, затем кнопка:
`min-height: 38px`, `padding: 0 11px`, `border: 1px solid var(--primary-border)`, `border-radius: 9px`, фон `var(--primary-bg)`, цвет `var(--primary-text)` 13px/600; иконка `Icon name="sparkles"` 16px, подпись «Начало работы», справа `small` с прогрессом «3/8» 11.5px/700. Hover: `color-mix(in srgb, var(--accent) 16%, var(--surface-card))`. Под кнопкой пояснение `margin: 7px 11px 2px`, 11.5px/1.45 `var(--n-4)`: «Пошаговая настройка установки. Открывается в любой момент.»
### 6. Плавающая пилюля возврата
Показывается, когда онбординг закрыт, тур не активен и пройдены не все шаги. `position: absolute; right: 24px; bottom: 24px; z-index: 20`, высота 44, `padding: 0 18px 0 15px`, `border-radius: 999px`, фон `var(--surface-card)`, `border: 1px solid var(--primary-border)`, цвет `var(--primary-text)` 13px/600, тень `0 10px 28px -8px rgba(16,18,22,.3)`; иконка `sparkles` 17px, подпись «Начало работы», счётчик в `small` (`padding: 1px 7px`, `border-radius: 999px`, фон `var(--primary-bg)`, 11.5px/700). Hover: фон `var(--primary-bg)`.
---
## Содержание 8 шагов (финальные тексты, RU)
Ниже — данные для таблицы шагов. `tour.target` — элемент, который подсвечивает тур; `tour.screen` / `tour.section` — куда предварительно переключиться.
### Шаг 1 — «AI-провайдер» · 3 мин
- **Заголовок:** Подключите AI-провайдера
- **Лид:** Без провайдера агент не ответит ни одному клиенту. Ключ ваш: расходы, лимиты и токены считаются в системе.
- **Путь:** Настройки → AI-провайдер → Добавить провайдера
- **Инструкции:** 1) В сайдбаре откройте «Настройки» и выберите раздел «AI-провайдер». 2) Нажмите «Добавить провайдера»: OpenRouter, Custom (OpenAI-compatible) или локальная модель. 3) Вставьте API-ключ и укажите модель по умолчанию — например openai/gpt-4o-mini. 4) Нажмите «Проверить»: статус должен стать «Подключено».
- **Предупреждение:** Ключа ещё нет? Возьмите «Демо-провайдер без ключа» — он отвечает по знаниям агента, чтобы осмотреться, и меняется на настоящий в любой момент.
- **Превью:** «Настройки → AI-провайдер»; Провайдер — OpenRouter; Секрет — `sk-or-••••••••`; Модель — openai/gpt-4o-mini; чип «Подключено» (успех)
- **Тур:** `screen: settings`, `section: ai`, `target: settings-ai`; «Настройки → AI-провайдер» — «Раздел в субменю «Настроек». Здесь живут ключи организации: добавление, проверка и статус провайдера.»
### Шаг 2 — «AI-агент» · 4 мин
- **Заголовок:** Создайте AI-агента
- **Лид:** Агент — тот, кто говорит с клиентом: кто он, как отвечает, каким правилам подчиняется и сколько может потратить в день.
- **Путь:** Агенты → Создать агента
- **Инструкции:** 1) Откройте «Агенты» в сайдбаре и нажмите «Создать агента». 2) Опишите роль и тон: кто он для клиента, что можно обещать, чего делать нельзя. 3) Выберите модель подключённого провайдера и дневной бюджет в долларах. 4) Прикрепите статьи базы знаний — агент отвечает только из них. 5) Переведите статус из «Черновик» в «Активен», иначе агент молчит.
- **Предупреждение:** Если данных для ответа нет, агент честно это скажет и предложит позвать сотрудника — придумывать он не станет.
- **Превью:** «Агенты → Консультант»; Модель — openai/gpt-4o-mini; Дневной бюджет — $5.00; Знания — 12 статей; чип «Активен» (успех)
- **Тур:** `screen: chat`, `target: nav-agents`; «Раздел «Агенты»» — «Список агентов организации. Карточка агента — это его роль, модель, бюджет и прикреплённые знания.»
### Шаг 3 — «База знаний» · от 10 мин
- **Заголовок:** Наполните базу знаний
- **Лид:** Это единственный источник ответов агента. Поиск идёт по смыслу, поэтому пишите так, как спрашивают клиенты.
- **Путь:** База знаний → Создать статью
- **Инструкции:** 1) Откройте «База знаний» и создайте категории под темы обращений. 2) Добавьте статьи: вопрос в заголовке, короткий однозначный ответ в тексте. 3) Готовые материалы залейте через «Импорт» — файлами, без ручного переноса. 4) Проверьте на карточке агента, что статьи к нему прикреплены.
- **Предупреждение:** Правка статьи меняет ответы бота сразу, и ту же статью читает публичный портал поддержки — держать два текста не нужно.
- **Превью:** «База знаний»; Категории — Доставка · Оплата · Возврат; Статей — 12; Поиск — Семантический; чип «Прикреплено к агенту» (успех)
- **Тур:** `screen: chat`, `target: nav-knowledge`; «Раздел «База знаний»» — «Статьи, категории и импорт. Отсюда агент берёт ответы, отсюда же их читает портал поддержки.»
### Шаг 4 — «Точка входа» · 5 мин
- **Заголовок:** Подключите точку входа
- **Лид:** Точка входа — канал, по которому приходит клиент: Telegram, MAX, почта или чат на сайте. Без неё писать некуда.
- **Путь:** Настройки → Интеграции → Добавить подключение
- **Инструкции:** 1) Настройки → «Интеграции» → «Добавить подключение». 2) Для бота вставьте токен: Telegram — от BotFather, MAX — из кабинета разработчика. 3) Для почты укажите IMAP и SMTP ящика и пароль приложения. 4) Привяжите подключение к созданному агенту — иначе сообщения некому обрабатывать. 5) Нажмите «Проверить» и убедитесь в статусе «Подключено».
- **Предупреждение:** Сообщения из Telegram и MAX забирает фоновый рабочий. Если он остановлен, статус останется «Подключено», а переписки не появятся.
- **Превью:** «Настройки → Интеграции»; Подключение — Telegram · @romashka_bot; Агент — Консультант; Секрет — `78••••••••`; чип «Подключено» (успех)
- **Тур:** `screen: settings`, `section: integrations`, `target: settings-integrations`; «Настройки → Интеграции» — «Боты, почта и веб-виджет. Красная точка у пункта означает, что какое-то подключение отвалилось.»
### Шаг 5 — «Виджет на сайт» · 3 мин
- **Заголовок:** Поставьте чат на сайт
- **Лид:** Веб-виджет — один тег в вашем шаблоне. Чат открывается в изолированном окне поверх сайта.
- **Путь:** Настройки → Интеграции → Web-виджет
- **Инструкции:** 1) Создайте подключение «Web-виджет» и оформите его: заголовок, акцент, приветствие. 2) Заполните разрешённые домены — пустой список запрещает все сайты. 3) Скопируйте сниппет и вставьте его перед закрывающим body на своём сайте. 4) Переведите виджет в статус «Опубликован» и обновите страницу сайта.
- **Предупреждение:** Ключ в data-widget-key должен совпадать с ключом из настроек виджета — при несовпадении чат просто не появится.
- **Превью:** «Сниппет для сайта»; Разрешённые домены — romashka.ru, *.romashka.ru; чип «Опубликован» (успех); блок кода — тег `chat-widget.js` с `data-widget-key` (брать из `webWidgetSnippet()` в `features/integrations/model.ts`)
- **Тур:** `screen: settings`, `section: integrations`, `target: settings-integrations`; «Настройки → Интеграции» — «Виджет живёт среди подключений: там его ключ, разрешённые домены и готовый тег для вставки.»
### Шаг 6 — «Сотрудники» · 4 мин
- **Заголовок:** Пригласите команду
- **Лид:** Сложные вопросы агент передаёт людям. Заведите тех, кому он будет их отдавать, и решите, кто что видит.
- **Путь:** Сотрудники → Пригласить
- **Инструкции:** 1) Откройте «Сотрудники» и отправьте приглашения по email. 2) Назначьте роли: владелец и администратор видят всё, сотрудник работает только в чате. 3) В Настройки → «Группы» распределите диалоги по группам видимости. 4) Предложите команде включить вход по коду (TOTP) в своём профиле.
- **Предупреждение:** Приглашения уходят почтой. Пока не настроен SMTP (шаг 7), письма не дойдут — пароль придётся задавать вручную.
- **Превью:** «Сотрудники»; Приглашены — 3 человека; Группы — Первая линия · Биллинг; Роли — Админ · Сотрудник; чип «Ожидают входа» (**ожидание**, жёлтый)
- **Тур:** `screen: chat`, `target: nav-employees`; «Раздел «Сотрудники»» — «Приглашения, роли и группы. Сотрудник видит диалоги своих групп и те, за которые отвечает.»
### Шаг 7 — «Домен и почта» · 5 мин
- **Заголовок:** Укажите домен и почту
- **Лид:** Пока установка открывается по IP — без HTTPS и без писем. Домен и SMTP превращают её в рабочий сервис.
- **Путь:** Настройки → Платформа
- **Инструкции:** 1) Настройки → «Платформа»: введите домен установки и переключитесь на https. 2) Убедитесь, что DNS домена смотрит на сервер, а порты 80 и 443 открыты снаружи. 3) Заполните исходящую почту: хост, порт, шифрование, логин и пароль приложения. 4) Нажмите «Отправить тестовое письмо» и проверьте, что оно пришло.
- **Предупреждение:** Сертификат выдаётся по первому запросу к домену: откройте его в браузере и подождите несколько секунд.
- **Превью:** «Настройки → Платформа»; Адрес — https://support.romashka.ru; SMTP — smtp.yandex.ru : 465; Сертификат — Let's Encrypt; чип «HTTPS включён» (успех)
- **Тур:** `screen: settings`, `section: platform`, `target: settings-platform`; «Настройки → Платформа» — «Свойства самой установки: адрес, исходящая почта, обновления. Раздел виден администратору установки.»
- **Важно:** раздел «Платформа» есть только у администратора установки (`user.isInstanceAdmin`, см. `visibleSettingsSections`). Для остальных шаг скрыть и считать общее число шагов динамически (7 вместо 8).
### Шаг 8 — «Проверка» · 2 мин
- **Заголовок:** Напишите боту как клиент
- **Лид:** Последний шаг — пройти путь клиента целиком: сообщение, ответ агента и передача на человека.
- **Путь:** Чат → Все диалоги
- **Инструкции:** 1) Напишите своему боту или в виджет на сайте вопрос из базы знаний. 2) Откройте «Чат»: диалог появится в списке с каналом, агентом и таймером ожидания. 3) Проверьте ответ агента — он должен опираться на вашу статью. 4) Нажмите «Взять диалог» и ответьте сами: так работает передача на оператора.
- **Предупреждение:** Три режима диалога — отвечает AI, отвечает сотрудник, пауза — переключаются в шапке переписки в любой момент.
- **Превью:** «Чат → Все диалоги»; Диалог — Анна Смирнова · Telegram; Агент — Консультант; Режим — Отвечает AI; чип «Ответ за 4 сек» (успех)
- **Тур:** `screen: chat`, `target: chat-list`; «Список диалогов» — «Все обращения организации в одном окне: канал, агент, время ожидания, метки и группа.»
---
## Interactions & Behavior
- **Показ при загрузке:** после успешного входа менеджера (`isManager(user)`) и получения состояния онбординга — если он не пройден и не закрыт, открыть в режиме `welcome`. Сотрудникам (`EMPLOYEE`) не показывать.
- **«Начать настройку»** → `view: "steps"`, шаг 0.
- **«Далее»** → отмечает текущий шаг как пройденный и переходит к следующему; на последнем — `view: "done"`.
- **«Назад»** → предыдущий шаг; с первого — обратно в `welcome`.
- **Клик по строке рейки** → переход на этот шаг (свободная навигация, без блокировок).
- **«Показать где»** → переключить экран/раздел за окном, скрыть модалку, замерить цель, показать тур. «Понятно, к шагам» или клик по подложке → вернуться к модалке на том же шаге.
- **«Открыть раздел»** → закрыть модалку и перейти на нужный экран/раздел настроек. Возврат — пилюля в правом нижнем углу или ссылка в «Настройках».
- **«×» / «Пройду позже» / «Закрыть и настроить самому»** → закрыть и записать «закрыто» на сервере. Онбординг больше не открывается автоматически; открывается только по ссылке/пилюле.
- **Прогресс шагов** должен подхватываться автоматически по факту (как сейчас `/api/v1/company/launch-checklist/`), а не только по нажатию «Далее»: провайдер создан, агент активен, статьи есть, подключение привязано, виджет опубликован, сотрудник приглашён, домен и SMTP заданы, входящий диалог получен. Ручная отметка «Далее» — дополнение, не замена.
- **Анимации:** `ob-fade` 200–220ms ease-out (оверлей), `ob-rise` 280–320ms `cubic-bezier(.2,.8,.2,1)` (карточка, `translateY(14px) scale(.985)` → норма), `ob-pulse` 1.9s ease-out infinite (рамка тура), прогресс `width 280ms ease`. Уважать `prefers-reduced-motion` — в репозитории уже есть такой блок в `styles/base.css`.
- **Клавиатура:** Esc закрывает тур, затем модалку; ←/→ переключают шаги; фокус-ловушка внутри модалки; `aria-modal="true"`, `role="dialog"`, `aria-labelledby` на заголовок шага.
- **Responsive:** экраны вне чата держат `min-width: 1024px` (см. `.hub-shell:not(.is-chat-route)`), поэтому визард рассчитан на десктоп. На ≤900px: рейку скрыть, оставив строку «Шаг N из 8» и полосу прогресса; карточку растянуть на `calc(100% - 32px)`; кнопки футера переносить в два ряда.
## State Management
Клиентское состояние (один хук `useOnboarding`):
| Поле | Тип | Назначение |
|---|---|---|
| `view` | `"welcome" \| "steps" \| "done" \| null` | что показано; `null` — закрыто |
| `index` | `number` | текущий шаг |
| `progress` | `Record<StepKey, boolean>` | серверный факт выполнения |
| `tour` | `{ cfg, x, y, w, h, rootW, rootH } \| null` | активная подсветка, координаты **относительно корня** |
Серверное состояние — расширить существующий эндпоинт `/api/v1/company/launch-checklist/` (`apps/backend/chatballs/...`) или добавить `/api/v1/company/onboarding/`:
- `GET` → `{ steps: { providerConnected, agentActive, knowledgeFilled, connectionBound, widgetPublished, employeeInvited, platformConfigured, firstConversation }, dismissedAt, completedAt }`
- `POST /dismiss` → ставит `dismissedAt = now()` для текущего пользователя в текущей организации.
- Миграция: `onboarding_dismissed_at TIMESTAMPTZ NULL` на членстве пользователя в организации (не на организации — иначе один админ закроет для всех). `NULL` по умолчанию, поэтому **существующие пользователи увидят онбординг при следующем входе** — это и требовалось.
- Старый ключ `localStorage` `chatballs.launch.<orgId>.hidden` больше не используется; можно один раз прочитать его при миграции состояния, но проще игнорировать.
## Design Tokens
Всё из `apps/internal-ui/src/styles/base.css`, новых значений не добавлялось:
- Поверхности: `--surface-card`, `--surface-sidebar`, `--surface-feed`, `--surface-page`, `--n-10`, `--n-11`
- Нейтрали: `--n-1 … --n-11` (текст `--n-1/--n-2/--n-3/--n-4/--n-5`, рамки `--n-6/--n-7/--n-8`)
- Акцент: `--accent` (по умолчанию `#1677ff`), `--primary`, `--primary-hover`, `--primary-bg`, `--primary-border`, `--primary-text`
- Статусы: `--success`, `--success-bg`, `--success-border`, `--success-text`; `--warning`, `--warning-bg`, `--warning-border`, `--warning-text`
- Рамки/радиусы: `--border-default`, `--radius-md: 8px`, `--radius-2xl: 12px`, `--radius-pill: 999px`; в онбординге дополнительно 9px, 10px, 11px, 13px, 14px, 18px
- Тени: `--shadow-sm`, `--shadow-lg`; **новые две переменные для модалки** (добавить в `base.css` рядом с остальными):
- `--modal-shadow: 0 32px 80px -12px rgba(16,18,22,.38), 0 8px 24px rgba(16,18,22,.12)`; тёмная тема — `0 32px 80px -12px rgba(0,0,0,.7), 0 8px 24px rgba(0,0,0,.4)`
- `--scrim: rgba(16,18,22,.55)`; тёмная тема — `rgba(4,5,7,.68)`
- Моно: `--font-mono`
- Типографика: системный стек из `body`; размеры 11 / 11.5 / 12 / 12.5 / 13 / 13.5 / 14 / 14.5 / 23 / 25 / 29px, веса 500 / 600 / 700, `letter-spacing` −0.022em…−0.025em на заголовках, `.08em`/`.1em` на надзаголовках
- Отступы: шаг 4px (4, 6, 7, 8, 9, 10, 11, 12, 14, 18, 20, 22, 24, 26, 32, 34, 40, 44, 48)
## Assets
Только существующие: `LogoIcon` и `Icon` из `apps/internal-ui/src/shared/icons.tsx` — используются `close`, `check`, `arrow`, `chevronLeft`, `chevronRight`, `eye`, `alert`, `sparkles`, `plug`, `robot`, `book`, `team`, `globe`, `message`, `settings`, `building`, `mic`, `database`, `sun`, `list`, `external`, `plus`, `search`, `sort`, `collapseLeft`, `more`, `user`. Своих SVG не рисовать. Картинок нет.
## Файлы, которые затрагивает реализация
Удалить:
- `apps/internal-ui/src/layout/LaunchChecklist.tsx`
- блок стилей `.launch-checklist*` в `apps/internal-ui/src/layout/styles.css`
- импорт и рендер `<LaunchChecklist />` в `apps/internal-ui/src/layout/Sidebar.tsx`
Добавить:
- `apps/internal-ui/src/features/onboarding/OnboardingOverlay.tsx` (welcome / steps / done)
- `apps/internal-ui/src/features/onboarding/OnboardingTour.tsx` (скрим, рамка, колаут, замер)
- `apps/internal-ui/src/features/onboarding/OnboardingLauncher.tsx` (пилюля)
- `apps/internal-ui/src/features/onboarding/steps.ts` (таблица шагов из этого README, тексты через `t()`)
- `apps/internal-ui/src/features/onboarding/useOnboarding.ts`, `api.ts`, `styles.css` (+ `@import` в `src/styles.css`)
Изменить:
- `apps/internal-ui/src/layout/Shell.tsx` — монтировать оверлей, тур и пилюлю для `isManager(user)`
- `apps/internal-ui/src/layout/Sidebar.tsx` — `data-target="nav-chat|nav-agents|nav-employees|nav-knowledge|nav-settings"` на пунктах
- `apps/internal-ui/src/features/settings/SettingsPage.tsx` — `data-target="settings-ai|settings-integrations|settings-platform"` на пунктах субменю + ссылка «Начало работы» в блоке `links`
- `apps/internal-ui/src/features/conversations/DialogList.tsx` — `data-target="chat-list"` на корневой секции
- `apps/internal-ui/src/styles/base.css` — `--modal-shadow`, `--scrim` (обе темы)
- `apps/internal-ui/src/i18n/ru.ts` и `en.ts` — ключи `onboarding.*` (парность проверяет `i18n/catalog.test.ts`); RU-тексты выше, EN — перевод
- backend: эндпоинт состояния онбординга + миграция `onboarding_dismissed_at`
## Files in this bundle
- `Онбординг Chatballs.dc.html` — дизайн-референс: приветствие, 8 шагов, тур, финальный экран, ссылка в «Настройках», пилюля. Открывается в браузере. Внутри — переключатели темы (`theme: light|dark`) и акцента (`accent`).
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,815 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<script src="./support.js"></script>
</head>
<body>
<x-dc>
<helmet>
<style>
:root {
--n-1: #1f1f1f; --n-2: #262626; --n-3: #595959; --n-4: #8c8c8c; --n-5: #bfbfbf;
--n-6: #d9d9d9; --n-7: #e8e8e8; --n-8: #f0f0f0; --n-9: #f5f5f5; --n-10: #fafafa; --n-11: #f0f2f5;
--surface-page: var(--n-11); --surface-card: #ffffff; --surface-sidebar: #fafafa; --surface-feed: #f5f6f8;
--accent: #1677ff;
--primary: var(--accent);
--primary-hover: color-mix(in srgb, var(--accent) 88%, black);
--primary-bg: color-mix(in srgb, var(--accent) 10%, var(--surface-card));
--primary-border: color-mix(in srgb, var(--accent) 40%, var(--surface-card));
--primary-text: color-mix(in srgb, var(--accent) 82%, black);
--warning-text: #d48806;
--warning-bg: color-mix(in srgb, #faad14 11%, var(--surface-card));
--warning-border: color-mix(in srgb, #faad14 36%, var(--surface-card));
--success: #52c41a; --success-text: #237a3f;
--success-bg: color-mix(in srgb, #52c41a 11%, var(--surface-card));
--success-border: color-mix(in srgb, #52c41a 31%, var(--surface-card));
--text-heading: var(--n-1); --text-body: var(--n-2); --text-tertiary: var(--n-4);
--border-default: var(--n-8);
--shadow-sm: 0 1px 3px rgba(0,0,0,.04); --shadow-lg: 0 6px 20px rgba(0,0,0,.12);
--font-mono: "SF Mono", ui-monospace, Menlo, monospace;
--scrim: rgba(16, 18, 22, .55);
--modal-shadow: 0 32px 80px -12px rgba(16,18,22,.38), 0 8px 24px rgba(16,18,22,.12);
}
:root[data-theme="dark"] {
--n-1: #d6d7db; --n-2: #c3c5ca; --n-3: #9a9ca3; --n-4: #767881; --n-5: #5b5d66;
--n-6: #45474f; --n-7: #37393f; --n-8: #2d2f35; --n-9: #27292e; --n-10: #212327; --n-11: #101113;
--surface-page: #101113; --surface-card: #1b1c1f; --surface-sidebar: #181a1d; --surface-feed: #15161a;
--primary: color-mix(in srgb, var(--accent) 84%, var(--surface-card));
--primary-hover: color-mix(in srgb, var(--accent) 92%, white);
--primary-bg: color-mix(in srgb, var(--accent) 16%, var(--surface-card));
--primary-border: color-mix(in srgb, var(--accent) 40%, var(--surface-card));
--primary-text: color-mix(in srgb, var(--accent) 62%, white);
--warning-text: #d9a441;
--warning-bg: color-mix(in srgb, #faad14 12%, var(--surface-card));
--success-text: #5fbf7a;
--success-bg: color-mix(in srgb, #52c41a 12%, var(--surface-card));
--success-border: color-mix(in srgb, #52c41a 32%, var(--surface-card));
--shadow-lg: 0 6px 20px rgba(0,0,0,.5);
--scrim: rgba(4, 5, 7, .68);
--modal-shadow: 0 32px 80px -12px rgba(0,0,0,.7), 0 8px 24px rgba(0,0,0,.4);
}
* { box-sizing: border-box; }
html, body { margin: 0; min-height: 100%; }
body {
background: var(--surface-page);
color: var(--text-body);
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
-webkit-font-smoothing: antialiased;
}
button { cursor: pointer; font: inherit; }
button:focus { outline: none; }
a { color: var(--primary-text); text-decoration: none; }
a:hover { color: var(--primary); text-decoration: underline; }
@keyframes ob-rise { from { opacity: 0; transform: translateY(14px) scale(.985); } to { opacity: 1; transform: none; } }
@keyframes ob-fade { from { opacity: 0; } to { opacity: 1; } }
@keyframes ob-pulse { 0%, 100% { opacity: .5; transform: scale(1); } 50% { opacity: 0; transform: scale(1.45); } }
</style>
</helmet>
<div data-ob-root="true" style="position: relative; width: 100%; height: 900px; min-width: 1024px; display: flex; overflow: hidden; background: var(--n-11);">
<aside style="position: relative; width: 220px; height: 100%; flex: none; display: flex; flex-direction: column; background: var(--surface-sidebar); border-right: 1px solid var(--n-8);">
<div style="width: 100%; height: 56px; flex: none; display: flex; align-items: center; gap: 10px; padding: 14px 14px 10px;">
<span style="width: 28px; height: 28px; flex: none; display: flex; align-items: center; justify-content: center; color: var(--n-1);">
<svg viewBox="0 0 100 100" width="26" height="26" fill="currentColor" aria-hidden="true"><defs><mask id="cb-logo-mask"><rect width="100" height="100" fill="white"></rect><circle cx="50" cy="24.54" r="9" fill="black"></circle><circle cx="50" cy="75.46" r="9" fill="black"></circle></mask></defs><g mask="url(#cb-logo-mask)"><circle cx="29" cy="50" r="24"></circle><circle cx="71" cy="50" r="24"></circle><rect x="44.2" y="31.4" width="11.6" height="37.2"></rect></g></svg>
</span>
<button type="button" style="flex: 1; min-width: 0; height: 32px; display: flex; align-items: center; justify-content: space-between; gap: 6px; padding: 0 6px 0 8px; border: 0; border-radius: 8px; background: transparent; color: var(--n-1); font-size: 14px; font-weight: 700; text-align: left;" style-hover="background: var(--n-9);">
<span style="min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;">Ромашка</span>
<svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" style="flex: none; color: var(--n-4);"><polyline points="6 9 12 15 18 9"></polyline></svg>
</button>
</div>
<nav style="flex: 1; display: flex; flex-direction: column; gap: 2px; overflow-y: auto; padding: 4px 10px 8px;">
<button type="button" data-target="nav-chat" onClick="{{ goChat }}" style="{{ navChatStyle }}">
<svg viewBox="0 0 24 24" width="17" height="17" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2Z"></path></svg>
Чат
</button>
<button type="button" style="position: relative; width: 100%; display: flex; align-items: center; flex: none; gap: 10px; height: 36px; padding: 0 10px; color: var(--n-2); background: transparent; border: none; border-radius: 8px; font-size: 13.5px; font-weight: 500; text-align: left;" style-hover="background: var(--n-9);">
<svg viewBox="0 0 24 24" width="17" height="17" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"></path><circle cx="12" cy="7" r="4"></circle></svg>
Контакты
</button>
<button type="button" data-target="nav-agents" style="position: relative; width: 100%; display: flex; align-items: center; flex: none; gap: 10px; height: 36px; padding: 0 10px; color: var(--n-2); background: transparent; border: none; border-radius: 8px; font-size: 13.5px; font-weight: 500; text-align: left;" style-hover="background: var(--n-9);">
<svg viewBox="0 0 24 24" width="17" height="17" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M6 6a2 2 0 0 1 2-2h8a2 2 0 0 1 2 2v4a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2zM12 2v2M9 12v9M15 12v9M9 18h6M10 8v.01M14 8v.01"></path></svg>
Агенты
</button>
<button type="button" data-target="nav-employees" style="position: relative; width: 100%; display: flex; align-items: center; flex: none; gap: 10px; height: 36px; padding: 0 10px; color: var(--n-2); background: transparent; border: none; border-radius: 8px; font-size: 13.5px; font-weight: 500; text-align: left;" style-hover="background: var(--n-9);">
<svg viewBox="0 0 24 24" width="17" height="17" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2M13 7a4 4 0 1 1-8 0 4 4 0 0 1 8 0zM23 21v-2a4 4 0 0 0-3-3.87M16 3.13a4 4 0 0 1 0 7.75"></path></svg>
Сотрудники
</button>
<button type="button" style="position: relative; width: 100%; display: flex; align-items: center; flex: none; gap: 10px; height: 36px; padding: 0 10px; color: var(--n-2); background: transparent; border: none; border-radius: 8px; font-size: 13.5px; font-weight: 500; text-align: left;" style-hover="background: var(--n-9);">
<svg viewBox="0 0 24 24" width="17" height="17" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M12 22a10 10 0 1 0 0-20 10 10 0 0 0 0 20zM2 12h20M12 2a14.5 14.5 0 0 0 0 20 14.5 14.5 0 0 0 0-20"></path></svg>
Порталы
</button>
<button type="button" data-target="nav-knowledge" style="position: relative; width: 100%; display: flex; align-items: center; flex: none; gap: 10px; height: 36px; padding: 0 10px; color: var(--n-2); background: transparent; border: none; border-radius: 8px; font-size: 13.5px; font-weight: 500; text-align: left;" style-hover="background: var(--n-9);">
<svg viewBox="0 0 24 24" width="17" height="17" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M4 19.5A2.5 2.5 0 0 1 6.5 17H20M4 19.5A2.5 2.5 0 0 0 6.5 22H20V2H6.5A2.5 2.5 0 0 0 4 4.5z"></path></svg>
База знаний
</button>
<button type="button" data-target="nav-settings" onClick="{{ goSettings }}" style="{{ navSettingsStyle }}">
<svg viewBox="0 0 24 24" width="17" height="17" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><line x1="21" y1="6" x2="9" y2="6"></line><line x1="3" y1="6" x2="5" y2="6"></line><circle cx="7" cy="6" r="2"></circle><line x1="21" y1="12" x2="13" y2="12"></line><line x1="3" y1="12" x2="9" y2="12"></line><circle cx="11" cy="12" r="2"></circle><line x1="21" y1="18" x2="15" y2="18"></line><line x1="3" y1="18" x2="11" y2="18"></line><circle cx="13" cy="18" r="2"></circle></svg>
Настройки
</button>
</nav>
<button type="button" style="position: relative; width: 100%; display: flex; align-items: center; gap: 10px; padding: 12px 14px; color: var(--n-3); background: transparent; border: none; border-top: 1px solid var(--n-8); text-align: left;" style-hover="background: var(--n-9);">
<span style="position: relative; flex: none; display: inline-flex;">
<span style="width: 34px; height: 34px; display: inline-flex; align-items: center; justify-content: center; flex: none; color: #ffffff; background: var(--primary); border-radius: 50%; font-size: 13px; font-weight: 600;">ИП</span>
<i style="position: absolute; right: -1px; bottom: -1px; width: 10px; height: 10px; border: 2px solid var(--surface-sidebar); border-radius: 50%; background: var(--success);"></i>
</span>
<span style="min-width: 0; flex: 1;">
<strong style="display: block; overflow: hidden; color: var(--n-1); font-size: 13px; font-weight: 600; line-height: 1.3; text-overflow: ellipsis; white-space: nowrap;">Игорь Петров</strong>
<small style="display: block; overflow: hidden; color: var(--n-4); font-size: 11.5px; line-height: 1.3; text-overflow: ellipsis; white-space: nowrap;">igor@romashka.ru</small>
</span>
<span style="flex: none; display: inline-flex; align-items: center; justify-content: center; width: 28px; height: 28px; color: var(--n-4); border-radius: 7px;">
<svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="5" r="1"></circle><circle cx="12" cy="12" r="1"></circle><circle cx="12" cy="19" r="1"></circle></svg>
</span>
</button>
</aside>
<div style="min-width: 0; height: 100%; display: flex; flex: 1; flex-direction: column;">
<sc-if value="{{ onChat }}" hint-placeholder-val="{{ true }}">
<main style="min-height: 0; flex: 1; padding: 0; overflow: hidden;">
<div style="height: 100%; min-width: 0; min-height: 0; display: flex; overflow: hidden;">
<section data-target="chat-list" style="position: relative; width: 323px; min-height: 0; display: flex; flex: none; flex-direction: column; background: var(--surface-card); border-right: 1px solid var(--n-8);">
<div style="flex: none; padding: 13px 16px 10px; border-bottom: 1px solid var(--n-8);">
<div style="display: flex; align-items: center; justify-content: space-between; gap: 8px; margin-bottom: 10px;">
<h2 style="display: flex; align-items: center; gap: 6px; margin: 0; color: var(--n-1); font-size: 16px; font-weight: 700; white-space: nowrap;">Диалоги<small style="color: var(--n-4); font-size: 12px; font-weight: 500;">0</small></h2>
<span style="display: flex; flex: none; gap: 4px;">
<button type="button" style="width: 28px; height: 28px; display: inline-flex; align-items: center; justify-content: center; border: 0; border-radius: 7px; color: var(--n-4); background: transparent;" style-hover="background: var(--n-9); color: var(--n-2);"><svg viewBox="0 0 24 24" width="15" height="15" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M11 5h10"></path><path d="M11 9h7"></path><path d="M11 13h4"></path><path d="M3 17l3 3 3-3"></path><path d="M6 18V4"></path></svg></button>
<button type="button" style="width: 28px; height: 28px; display: inline-flex; align-items: center; justify-content: center; border: 0; border-radius: 7px; color: var(--n-4); background: transparent;" style-hover="background: var(--n-9); color: var(--n-2);"><svg viewBox="0 0 24 24" width="15" height="15" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="4" width="18" height="16" rx="2"></rect><path d="M9 4v16"></path><path d="m15 10-2 2 2 2"></path></svg></button>
</span>
</div>
<label style="display: flex; align-items: center; gap: 8px; height: 34px; padding: 0 11px; border: 1px solid var(--n-7); border-radius: 8px; background: var(--n-10); color: var(--n-5);">
<svg viewBox="0 0 24 24" width="15" height="15" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" style="flex: none;"><circle cx="11" cy="11" r="8"></circle><line x1="21" y1="21" x2="16.65" y2="16.65"></line></svg>
<input placeholder="Поиск по контакту, сообщению…" style="width: 100%; min-width: 0; padding: 0; border: 0; outline: 0; background: transparent; color: var(--n-2); font-family: inherit; font-size: 13px;" />
<kbd style="flex: none; padding: 1px 5px; border: 1px solid var(--n-7); border-radius: 4px; background: var(--surface-card); color: var(--n-4); font-size: 10.5px; line-height: 1.4;">/</kbd>
</label>
</div>
<div style="display: flex; flex: none; gap: 2px; padding: 0 10px; border-bottom: 1px solid var(--n-8);">
<button type="button" style="flex: none; display: inline-flex; align-items: center; gap: 5px; padding: 9px 8px 8px; color: var(--primary-text); background: transparent; border: 0; border-bottom: 2px solid var(--primary-text); margin-bottom: -1px; font-size: 12.5px; font-weight: 600; line-height: 14px;">Все</button>
<button type="button" style="flex: none; display: inline-flex; align-items: center; gap: 5px; padding: 9px 8px 8px; color: var(--n-4); background: transparent; border: 0; border-bottom: 2px solid transparent; margin-bottom: -1px; font-size: 12.5px; font-weight: 500; line-height: 14px;">Мои</button>
<button type="button" style="flex: none; display: inline-flex; align-items: center; gap: 5px; padding: 9px 8px 8px; color: var(--n-4); background: transparent; border: 0; border-bottom: 2px solid transparent; margin-bottom: -1px; font-size: 12.5px; font-weight: 500; line-height: 14px;">Ждут оператора</button>
</div>
<div style="flex: 1; min-height: 0; overflow-y: auto;">
<div style="padding: 64px 28px; text-align: center;">
<span style="width: 44px; height: 44px; display: inline-flex; align-items: center; justify-content: center; margin-bottom: 12px; color: var(--n-5); background: var(--n-10); border-radius: 50%;"><svg viewBox="0 0 24 24" width="20" height="20" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2Z"></path></svg></span>
<p style="margin: 0; color: var(--n-3); font-size: 13.5px; font-weight: 600; line-height: 1.45;">Диалоги появятся, когда клиенты напишут</p>
</div>
</div>
</section>
<section style="position: relative; min-width: 320px; min-height: 0; display: flex; flex: 1; flex-direction: column; background: var(--surface-feed);">
<div style="flex: 1; display: flex; align-items: center; justify-content: center; color: var(--n-5); font-size: 13px;">Выберите диалог</div>
</section>
</div>
</main>
</sc-if>
<sc-if value="{{ onSettings }}">
<main style="min-height: 0; flex: 1; padding: 0; overflow: hidden;">
<div style="height: 100%; min-width: 0; min-height: 0; display: flex; overflow: hidden;">
<nav style="width: 240px; flex: none; display: flex; flex-direction: column; min-height: 0; background: var(--surface-card); border-right: 1px solid var(--n-8);">
<div style="flex: none; padding: 17px 18px 10px;"><h2 style="margin: 0; color: var(--n-1); font-size: 16px; font-weight: 700;">Настройки</h2></div>
<div style="flex: 1; min-height: 0; display: flex; flex-direction: column; gap: 1px; padding: 4px 10px; overflow-y: auto;">
<button type="button" style="{{ secOrgStyle }}"><svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round"><path d="M3 21h18M5 21V7l7-4 7 4v14M9 21v-6h6v6M9 10h.01M15 10h.01"></path></svg><span style="flex: 1; min-width: 0;">Организация</span></button>
<button type="button" style="{{ secGroupsStyle }}"><svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round"><path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2M13 7a4 4 0 1 1-8 0 4 4 0 0 1 8 0zM23 21v-2a4 4 0 0 0-3-3.87M16 3.13a4 4 0 0 1 0 7.75"></path></svg><span style="flex: 1; min-width: 0;">Группы</span></button>
<button type="button" data-target="settings-ai" style="{{ secAiStyle }}"><svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round"><path d="M12 3l1.8 4.6L18.4 9.4 13.8 11.2 12 15.8 10.2 11.2 5.6 9.4 10.2 7.6z"></path><path d="M19 16l.8 2.2L22 19l-2.2.8L19 22l-.8-2.2L16 19l2.2-.8z"></path><path d="M5 14l.6 1.6L7.2 16.2 5.6 16.8 5 18.4l-.6-1.6L2.8 16.2l1.6-.6z"></path></svg><span style="flex: 1; min-width: 0;">AI-провайдер</span></button>
<button type="button" data-target="settings-integrations" style="{{ secIntStyle }}"><svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round"><path d="M12 22v-5M9 8V2M15 8V2M18 8v5a6 6 0 0 1-12 0V8z"></path></svg><span style="flex: 1; min-width: 0;">Интеграции</span></button>
<button type="button" style="{{ secCommStyle }}"><svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round"><path d="M12 2a3 3 0 0 0-3 3v7a3 3 0 0 0 6 0V5a3 3 0 0 0-3-3ZM19 10v2a7 7 0 0 1-14 0v-2M12 19v3"></path></svg><span style="flex: 1; min-width: 0;">Голосовые и звонки</span></button>
<button type="button" style="{{ secStorageStyle }}"><svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round"><path d="M4 6c0-1.7 3.6-3 8-3s8 1.3 8 3-3.6 3-8 3-8-1.3-8-3z"></path><path d="M4 6v12c0 1.7 3.6 3 8 3s8-1.3 8-3V6"></path><path d="M4 12c0 1.7 3.6 3 8 3s8-1.3 8-3"></path></svg><span style="flex: 1; min-width: 0;">Хранилище файлов</span></button>
<button type="button" data-target="settings-platform" style="{{ secPlatformStyle }}"><svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round"><path d="M12 22a10 10 0 1 0 0-20 10 10 0 0 0 0 20zM2 12h20M12 2a14.5 14.5 0 0 0 0 20 14.5 14.5 0 0 0 0-20"></path></svg><span style="flex: 1; min-width: 0;">Платформа</span></button>
<button type="button" style="{{ secDemoStyle }}"><svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round"><path d="M12 2v4M12 18v4M4.9 4.9l2.8 2.8M16.3 16.3l2.8 2.8M2 12h4M18 12h4M4.9 19.1l2.8-2.8M16.3 7.7l2.8-2.8"></path></svg><span style="flex: 1; min-width: 0;">Демо-данные</span></button>
<div style="height: 1px; margin: 8px 6px; background: var(--n-8);"></div>
<div style="padding: 2px 10px 6px; color: var(--n-4); font-size: 11px; font-weight: 600; letter-spacing: .04em; text-transform: uppercase;">Отдельные экраны</div>
<button type="button" style="display: flex; align-items: center; gap: 10px; min-height: 34px; padding: 0 10px; border: 0; border-radius: 8px; background: transparent; color: var(--n-2); font-size: 13px; font-weight: 500; text-align: left;" style-hover="background: var(--n-9);">
<svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round"><path d="M8 6h13M8 12h13M8 18h13M3 6h.01M3 12h.01M3 18h.01"></path></svg><span style="flex: 1; min-width: 0;">Аудит действий</span>
<svg viewBox="0 0 24 24" width="13" height="13" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="color: var(--n-5);"><path d="M7 17 17 7M8 7h9v9"></path></svg>
</button>
<div style="height: 1px; margin: 10px 6px 8px; background: var(--n-8);"></div>
<button type="button" data-target="settings-onboarding" onClick="{{ reopen }}" style="display: flex; align-items: center; gap: 10px; min-height: 38px; padding: 0 11px; border: 1px solid var(--primary-border); border-radius: 9px; background: var(--primary-bg); color: var(--primary-text); font-size: 13px; font-weight: 600; text-align: left;" style-hover="background: color-mix(in srgb, var(--accent) 16%, var(--surface-card));">
<svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round"><path d="M12 3l1.8 4.6L18.4 9.4 13.8 11.2 12 15.8 10.2 11.2 5.6 9.4 10.2 7.6z"></path><path d="M19 16l.8 2.2L22 19l-2.2.8L19 22l-.8-2.2L16 19l2.2-.8z"></path><path d="M5 14l.6 1.6L7.2 16.2 5.6 16.8 5 18.4l-.6-1.6L2.8 16.2l1.6-.6z"></path></svg>
<span style="flex: 1; min-width: 0;">Начало работы</span>
<small style="flex: none; font-size: 11.5px; font-weight: 700;">{{ doneLabel }}</small>
</button>
<p style="margin: 7px 11px 2px; color: var(--n-4); font-size: 11.5px; line-height: 1.45;">Пошаговая настройка установки. Открывается в любой момент.</p>
</div>
</nav>
<section style="flex: 1; min-width: 0; min-height: 0; overflow-y: auto; background: var(--surface-feed);">
<div style="max-width: 1000px; margin-inline: auto; padding: 28px 40px 48px;">
<header style="display: flex; align-items: flex-start; justify-content: space-between; gap: 20px; margin-bottom: 22px;">
<div style="min-width: 0;">
<h2 style="margin: 0; color: var(--n-1); font-size: 20px; font-weight: 700; letter-spacing: -.01em;">{{ secHeading }}</h2>
<p style="max-width: 560px; margin: 5px 0 0; color: var(--n-4); font-size: 13px; line-height: 1.5;">{{ secLead }}</p>
</div>
<button type="button" style="height: 34px; flex: none; display: inline-flex; align-items: center; gap: 7px; padding: 0 16px; border: 0; border-radius: 8px; background: var(--primary); color: #fff; font-size: 13px; font-weight: 600;" style-hover="background: var(--primary-hover);">
<svg viewBox="0 0 24 24" width="15" height="15" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><line x1="12" y1="5" x2="12" y2="19"></line><line x1="5" y1="12" x2="19" y2="12"></line></svg>
{{ secAction }}
</button>
</header>
<div style="padding: 22px 24px; background: var(--surface-card); border: 1px solid var(--border-default); border-radius: 12px;">
<div style="display: flex; align-items: center; justify-content: center; min-height: 160px; padding: 0 24px; color: var(--n-3); font-size: 13.5px; text-align: center;">{{ secEmpty }}</div>
</div>
</div>
</section>
</div>
</main>
</sc-if>
</div>
<sc-if value="{{ showWelcome }}">
<div style="position: absolute; inset: 0; z-index: 30; display: flex; align-items: center; justify-content: center; padding: 40px; background: var(--scrim); backdrop-filter: blur(3px); animation: ob-fade 220ms ease-out;">
<div style="width: min(700px, 100%); background: var(--surface-card); border: 1px solid var(--border-default); border-radius: 18px; box-shadow: var(--modal-shadow); overflow: hidden; animation: ob-rise 320ms cubic-bezier(.2,.8,.2,1);">
<div style="position: relative; padding: 44px 48px 34px; background: linear-gradient(180deg, var(--primary-bg), var(--surface-card));">
<button type="button" aria-label="Закрыть" onClick="{{ close }}" style="position: absolute; top: 18px; right: 18px; width: 32px; height: 32px; display: inline-flex; align-items: center; justify-content: center; border: 0; border-radius: 8px; background: transparent; color: var(--n-4);" style-hover="background: var(--n-9); color: var(--n-2);">
<svg viewBox="0 0 24 24" width="17" height="17" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M18 6 6 18M6 6l12 12"></path></svg>
</button>
<span style="width: 46px; height: 46px; display: flex; align-items: center; justify-content: center; margin-bottom: 22px; color: #fff; background: var(--primary); border-radius: 14px; box-shadow: 0 8px 20px color-mix(in srgb, var(--accent) 34%, transparent);">
<svg viewBox="0 0 100 100" width="28" height="28" fill="currentColor" aria-hidden="true"><g mask="url(#cb-logo-mask)"><circle cx="29" cy="50" r="24"></circle><circle cx="71" cy="50" r="24"></circle><rect x="44.2" y="31.4" width="11.6" height="37.2"></rect></g></svg>
</span>
<p style="margin: 0 0 11px; color: var(--primary-text); font-size: 11.5px; font-weight: 700; letter-spacing: .1em; text-transform: uppercase;">Установка готова к настройке</p>
<h1 style="max-width: 520px; margin: 0; color: var(--n-1); font-size: 29px; font-weight: 700; letter-spacing: -.025em; line-height: 1.18; text-wrap: pretty;">Восемь шагов до первого ответа клиенту</h1>
<p style="max-width: 520px; margin: 14px 0 0; color: var(--n-3); font-size: 14.5px; line-height: 1.6; text-wrap: pretty;">Покажем по порядку, что и где настроить: ключ модели, агента, знания, точку входа и команду. На каждом шаге — точный путь в интерфейсе и подсветка нужного элемента на экране.</p>
</div>
<div style="display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 1px; background: var(--border-default); border-top: 1px solid var(--border-default); border-bottom: 1px solid var(--border-default);">
<div style="padding: 17px 20px; background: var(--surface-card);">
<strong style="display: block; color: var(--n-1); font-size: 13.5px; font-weight: 700;">≈ 25 минут</strong>
<small style="display: block; margin-top: 3px; color: var(--n-4); font-size: 12.5px; line-height: 1.45;">Можно прерваться и вернуться</small>
</div>
<div style="padding: 17px 20px; background: var(--surface-card);">
<strong style="display: block; color: var(--n-1); font-size: 13.5px; font-weight: 700;">Свой ключ модели</strong>
<small style="display: block; margin-top: 3px; color: var(--n-4); font-size: 12.5px; line-height: 1.45;">Или демо-провайдер без ключа</small>
</div>
<div style="padding: 17px 20px; background: var(--surface-card);">
<strong style="display: block; color: var(--n-1); font-size: 13.5px; font-weight: 700;">Данные у вас</strong>
<small style="display: block; margin-top: 3px; color: var(--n-4); font-size: 12.5px; line-height: 1.45;">Всё на вашем сервере</small>
</div>
</div>
<div style="display: flex; align-items: center; justify-content: space-between; gap: 16px; padding: 20px 48px 26px;">
<button type="button" onClick="{{ close }}" style="height: 38px; padding: 0 4px; border: 0; background: transparent; color: var(--n-4); font-size: 13px; font-weight: 500;" style-hover="color: var(--n-2);">Пройду позже</button>
<button type="button" onClick="{{ start }}" style="height: 42px; display: inline-flex; align-items: center; gap: 9px; padding: 0 22px; border: 0; border-radius: 10px; background: var(--primary); color: #fff; font-size: 14px; font-weight: 600; box-shadow: 0 6px 16px color-mix(in srgb, var(--accent) 32%, transparent);" style-hover="background: var(--primary-hover);">
Начать настройку
<svg viewBox="0 0 24 24" width="17" height="17" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round"><line x1="5" y1="12" x2="19" y2="12"></line><polyline points="12 5 19 12 12 19"></polyline></svg>
</button>
</div>
</div>
</div>
</sc-if>
<sc-if value="{{ showSteps }}">
<div style="position: absolute; inset: 0; z-index: 30; display: flex; align-items: center; justify-content: center; padding: 34px; background: var(--scrim); backdrop-filter: blur(3px); animation: ob-fade 200ms ease-out;">
<div style="width: min(1020px, 100%); height: min(700px, 100%); display: flex; background: var(--surface-card); border: 1px solid var(--border-default); border-radius: 18px; box-shadow: var(--modal-shadow); overflow: hidden; animation: ob-rise 280ms cubic-bezier(.2,.8,.2,1);">
<div style="width: 300px; flex: none; display: flex; flex-direction: column; background: var(--n-10); border-right: 1px solid var(--border-default);">
<div style="flex: none; padding: 24px 22px 18px;">
<div style="display: flex; align-items: center; gap: 9px; margin-bottom: 18px;">
<span style="width: 24px; height: 24px; flex: none; display: flex; align-items: center; justify-content: center; color: var(--n-1);">
<svg viewBox="0 0 100 100" width="22" height="22" fill="currentColor" aria-hidden="true"><g mask="url(#cb-logo-mask)"><circle cx="29" cy="50" r="24"></circle><circle cx="71" cy="50" r="24"></circle><rect x="44.2" y="31.4" width="11.6" height="37.2"></rect></g></svg>
</span>
<strong style="color: var(--n-1); font-size: 14px; font-weight: 700;">Начало работы</strong>
</div>
<div style="display: flex; align-items: baseline; justify-content: space-between; margin-bottom: 8px;">
<small style="color: var(--n-3); font-size: 12px; font-weight: 600;">{{ progressLabel }}</small>
<small style="color: var(--n-4); font-size: 11.5px; font-weight: 600;">{{ percentLabel }}</small>
</div>
<div style="height: 4px; border-radius: 999px; background: var(--n-8); overflow: hidden;"><i style="{{ progressStyle }}"></i></div>
</div>
<div style="flex: 1; min-height: 0; display: flex; flex-direction: column; gap: 2px; padding: 2px 12px 12px; overflow-y: auto;">
<sc-for list="{{ rail }}" as="item" hint-placeholder-count="8">
<button type="button" onClick="{{ item.onClick }}" style="{{ item.rowStyle }}">
<span style="{{ item.markStyle }}">
<sc-if value="{{ item.isDone }}" hint-placeholder-val="{{ false }}"><svg viewBox="0 0 24 24" width="11" height="11" fill="none" stroke="currentColor" stroke-width="2.6" stroke-linecap="round" stroke-linejoin="round"><path d="M20 6 9 17l-5-5"></path></svg></sc-if>
<sc-if value="{{ item.showNum }}" hint-placeholder-val="{{ true }}">{{ item.num }}</sc-if>
</span>
<span style="{{ item.labelStyle }}">{{ item.label }}</span>
<small style="flex: none; color: var(--n-5); font-size: 11px; font-weight: 500;">{{ item.est }}</small>
</button>
</sc-for>
</div>
<div style="flex: none; padding: 12px 22px 20px; border-top: 1px solid var(--border-default);">
<button type="button" onClick="{{ close }}" style="padding: 0; border: 0; background: transparent; color: var(--n-4); font-size: 12.5px; font-weight: 500;" style-hover="color: var(--n-2);">Закрыть и настроить самому</button>
</div>
</div>
<div style="flex: 1; min-width: 0; display: flex; flex-direction: column;">
<div style="flex: none; display: flex; align-items: center; justify-content: space-between; gap: 12px; padding: 22px 32px 0;">
<span style="display: inline-flex; align-items: center; gap: 8px; height: 24px; padding: 0 10px; border: 1px solid var(--primary-border); border-radius: 999px; background: var(--primary-bg); color: var(--primary-text); font-size: 11.5px; font-weight: 700; letter-spacing: .02em;">{{ eyebrow }}</span>
<button type="button" aria-label="Закрыть" onClick="{{ close }}" style="width: 32px; height: 32px; display: inline-flex; align-items: center; justify-content: center; border: 0; border-radius: 8px; background: transparent; color: var(--n-4);" style-hover="background: var(--n-9); color: var(--n-2);">
<svg viewBox="0 0 24 24" width="17" height="17" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M18 6 6 18M6 6l12 12"></path></svg>
</button>
</div>
<div style="flex: 1; min-height: 0; overflow-y: auto; padding: 14px 32px 26px;">
<h2 style="margin: 0; color: var(--n-1); font-size: 23px; font-weight: 700; letter-spacing: -.022em; line-height: 1.2; text-wrap: pretty;">{{ curTitle }}</h2>
<p style="max-width: 560px; margin: 10px 0 0; color: var(--n-3); font-size: 13.5px; line-height: 1.6; text-wrap: pretty;">{{ curLead }}</p>
<div style="display: flex; align-items: center; flex-wrap: wrap; gap: 6px; margin: 18px 0 0; padding: 8px 11px; border: 1px solid var(--border-default); border-radius: 9px; background: var(--n-10);">
<sc-for list="{{ curPath }}" as="crumb" hint-placeholder-count="3">
<span style="display: inline-flex; align-items: center; gap: 6px; color: var(--n-2); font-family: var(--font-mono); font-size: 11.5px; font-weight: 600;">
{{ crumb.label }}
<sc-if value="{{ crumb.more }}" hint-placeholder-val="{{ true }}"><svg viewBox="0 0 24 24" width="12" height="12" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="color: var(--n-5);"><polyline points="9 6 15 12 9 18"></polyline></svg></sc-if>
</span>
</sc-for>
</div>
<ol style="display: flex; flex-direction: column; gap: 13px; margin: 22px 0 0; padding: 0; list-style: none;">
<sc-for list="{{ curItems }}" as="item" hint-placeholder-count="4">
<li style="display: flex; align-items: flex-start; gap: 12px;">
<span style="width: 21px; height: 21px; flex: none; display: inline-flex; align-items: center; justify-content: center; margin-top: 1px; border: 1px solid var(--primary-border); border-radius: 50%; background: var(--primary-bg); color: var(--primary-text); font-size: 11px; font-weight: 700;">{{ item.n }}</span>
<span style="flex: 1; min-width: 0; color: var(--n-2); font-size: 13.5px; line-height: 1.55; text-wrap: pretty;">{{ item.text }}</span>
</li>
</sc-for>
</ol>
<div style="display: flex; align-items: flex-start; gap: 10px; margin-top: 22px; padding: 12px 14px; border: 1px solid var(--warning-border); border-radius: 10px; background: var(--warning-bg);">
<svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round" style="flex: none; margin-top: 1px; color: var(--warning-text);"><path d="M12 9v4M12 17h.01M10.3 3.9 1.8 18a2 2 0 0 0 1.7 3h17a2 2 0 0 0 1.7-3L13.7 3.9a2 2 0 0 0-3.4 0z"></path></svg>
<p style="margin: 0; flex: 1; color: var(--warning-text); font-size: 12.5px; font-weight: 500; line-height: 1.5; text-wrap: pretty;">{{ curTip }}</p>
</div>
<div style="margin-top: 22px; border: 1px solid var(--border-default); border-radius: 12px; background: var(--n-10); overflow: hidden;">
<div style="display: flex; align-items: center; justify-content: space-between; gap: 10px; padding: 9px 14px; background: var(--surface-card); border-bottom: 1px solid var(--border-default);">
<span style="display: inline-flex; align-items: center; gap: 7px; color: var(--n-4); font-family: var(--font-mono); font-size: 11px; font-weight: 600; letter-spacing: .02em;">
<i style="width: 7px; height: 7px; border-radius: 50%; background: var(--n-6);"></i>
{{ previewCaption }}
</span>
<sc-if value="{{ previewChipOk }}" hint-placeholder-val="{{ true }}">
<span style="display: inline-flex; align-items: center; gap: 5px; height: 21px; padding: 0 9px; border: 1px solid var(--success-border); border-radius: 999px; background: var(--success-bg); color: var(--success-text); font-size: 11px; font-weight: 700;">
<svg viewBox="0 0 24 24" width="11" height="11" fill="none" stroke="currentColor" stroke-width="2.6" stroke-linecap="round" stroke-linejoin="round"><path d="M20 6 9 17l-5-5"></path></svg>
{{ previewChip }}
</span>
</sc-if>
<sc-if value="{{ previewChipWait }}" hint-placeholder-val="{{ false }}">
<span style="display: inline-flex; align-items: center; gap: 5px; height: 21px; padding: 0 9px; border: 1px solid var(--warning-border); border-radius: 999px; background: var(--warning-bg); color: var(--warning-text); font-size: 11px; font-weight: 700;">{{ previewChip }}</span>
</sc-if>
</div>
<div style="display: flex; flex-direction: column; gap: 1px; background: var(--border-default);">
<sc-for list="{{ previewRows }}" as="row" hint-placeholder-count="3">
<div style="display: flex; align-items: center; justify-content: space-between; gap: 16px; padding: 11px 14px; background: var(--n-10);">
<span style="color: var(--n-4); font-size: 12px;">{{ row.label }}</span>
<strong style="min-width: 0; color: var(--n-2); font-size: 12.5px; font-weight: 600; text-align: right;">{{ row.value }}</strong>
</div>
</sc-for>
</div>
<sc-if value="{{ hasCode }}" hint-placeholder-val="{{ false }}">
<div style="padding: 12px 14px; background: var(--surface-card); border-top: 1px solid var(--border-default);">
<code style="display: block; color: var(--n-3); font-family: var(--font-mono); font-size: 11.5px; line-height: 1.6; word-break: break-all;">{{ previewCode }}</code>
</div>
</sc-if>
</div>
</div>
<div style="flex: none; display: flex; align-items: center; justify-content: space-between; gap: 12px; padding: 14px 32px 18px; border-top: 1px solid var(--border-default); background: var(--surface-card);">
<button type="button" onClick="{{ prev }}" style="height: 36px; display: inline-flex; align-items: center; gap: 7px; padding: 0 12px; border: 0; border-radius: 8px; background: transparent; color: var(--n-3); font-size: 13px; font-weight: 500;" style-hover="background: var(--n-9); color: var(--n-2);">
<svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round"><polyline points="15 6 9 12 15 18"></polyline></svg>
Назад
</button>
<div style="display: flex; align-items: center; gap: 8px;">
<button type="button" onClick="{{ showTour }}" style="height: 36px; display: inline-flex; align-items: center; gap: 7px; padding: 0 14px; border: 1px solid var(--n-6); border-radius: 8px; background: var(--surface-card); color: var(--n-3); font-size: 13px; font-weight: 500;" style-hover="background: var(--n-10); color: var(--n-2);">
<svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round"><path d="M2 12s3.6-7 10-7 10 7 10 7-3.6 7-10 7-10-7-10-7zM15 12a3 3 0 1 1-6 0 3 3 0 0 1 6 0z"></path></svg>
Показать где
</button>
<button type="button" onClick="{{ goSection }}" style="height: 36px; display: inline-flex; align-items: center; gap: 7px; padding: 0 14px; border: 1px solid var(--n-6); border-radius: 8px; background: var(--surface-card); color: var(--n-3); font-size: 13px; font-weight: 500;" style-hover="background: var(--n-10); color: var(--n-2);">{{ curGoLabel }}</button>
<button type="button" onClick="{{ next }}" style="height: 36px; display: inline-flex; align-items: center; gap: 8px; padding: 0 18px; border: 0; border-radius: 8px; background: var(--primary); color: #fff; font-size: 13px; font-weight: 600; box-shadow: 0 1px 2px color-mix(in srgb, var(--accent) 30%, transparent);" style-hover="background: var(--primary-hover);">
{{ nextLabel }}
<svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round"><line x1="5" y1="12" x2="19" y2="12"></line><polyline points="12 5 19 12 12 19"></polyline></svg>
</button>
</div>
</div>
</div>
</div>
</div>
</sc-if>
<sc-if value="{{ showDone }}">
<div style="position: absolute; inset: 0; z-index: 30; display: flex; align-items: center; justify-content: center; padding: 40px; background: var(--scrim); backdrop-filter: blur(3px); animation: ob-fade 200ms ease-out;">
<div style="width: min(620px, 100%); background: var(--surface-card); border: 1px solid var(--border-default); border-radius: 18px; box-shadow: var(--modal-shadow); overflow: hidden; animation: ob-rise 280ms cubic-bezier(.2,.8,.2,1);">
<div style="padding: 40px 40px 26px; text-align: left;">
<span style="width: 46px; height: 46px; display: flex; align-items: center; justify-content: center; margin-bottom: 20px; color: #fff; background: var(--success); border-radius: 14px;">
<svg viewBox="0 0 24 24" width="24" height="24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round"><path d="M20 6 9 17l-5-5"></path></svg>
</span>
<h2 style="margin: 0; color: var(--n-1); font-size: 25px; font-weight: 700; letter-spacing: -.022em; line-height: 1.2;">Всё готово — можно принимать клиентов</h2>
<p style="margin: 12px 0 0; color: var(--n-3); font-size: 14px; line-height: 1.6; text-wrap: pretty;">Агент отвечает по вашим знаниям, сложное передаёт команде. Вернуться к шагам можно в «Настройки → Начало работы».</p>
</div>
<div style="display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 1px; background: var(--border-default); border-top: 1px solid var(--border-default); border-bottom: 1px solid var(--border-default);">
<sc-for list="{{ doneSummary }}" as="item" hint-placeholder-count="8">
<div style="display: flex; align-items: center; gap: 9px; padding: 12px 16px; background: var(--surface-card);">
<span style="{{ item.markStyle }}">
<sc-if value="{{ item.isDone }}" hint-placeholder-val="{{ true }}"><svg viewBox="0 0 24 24" width="10" height="10" fill="none" stroke="currentColor" stroke-width="2.8" stroke-linecap="round" stroke-linejoin="round"><path d="M20 6 9 17l-5-5"></path></svg></sc-if>
</span>
<span style="{{ item.labelStyle }}">{{ item.label }}</span>
</div>
</sc-for>
</div>
<div style="display: flex; align-items: center; justify-content: space-between; gap: 16px; padding: 20px 40px 26px;">
<button type="button" onClick="{{ start }}" style="padding: 0; border: 0; background: transparent; color: var(--n-4); font-size: 13px; font-weight: 500;" style-hover="color: var(--n-2);">Пройти заново</button>
<button type="button" onClick="{{ close }}" style="height: 40px; display: inline-flex; align-items: center; gap: 9px; padding: 0 20px; border: 0; border-radius: 10px; background: var(--primary); color: #fff; font-size: 14px; font-weight: 600;" style-hover="background: var(--primary-hover);">
Перейти в чат
<svg viewBox="0 0 24 24" width="17" height="17" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round"><line x1="5" y1="12" x2="19" y2="12"></line><polyline points="12 5 19 12 12 19"></polyline></svg>
</button>
</div>
</div>
</div>
</sc-if>
<sc-if value="{{ showLauncher }}" hint-placeholder-val="{{ false }}">
<button type="button" onClick="{{ reopen }}" style="position: absolute; right: 24px; bottom: 24px; z-index: 20; height: 44px; display: inline-flex; align-items: center; gap: 10px; padding: 0 18px 0 15px; border: 1px solid var(--primary-border); border-radius: 999px; background: var(--surface-card); color: var(--primary-text); font-size: 13px; font-weight: 600; box-shadow: 0 10px 28px -8px rgba(16,18,22,.3);" style-hover="background: var(--primary-bg);">
<svg viewBox="0 0 24 24" width="17" height="17" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round"><path d="M12 3l1.8 4.6L18.4 9.4 13.8 11.2 12 15.8 10.2 11.2 5.6 9.4 10.2 7.6z"></path><path d="M19 16l.8 2.2L22 19l-2.2.8L19 22l-.8-2.2L16 19l2.2-.8z"></path><path d="M5 14l.6 1.6L7.2 16.2 5.6 16.8 5 18.4l-.6-1.6L2.8 16.2l1.6-.6z"></path></svg>
Начало работы
<small style="padding: 1px 7px; border-radius: 999px; background: var(--primary-bg); font-size: 11.5px; font-weight: 700;">{{ doneLabel }}</small>
</button>
</sc-if>
<sc-if value="{{ tourOpen }}" hint-placeholder-val="{{ false }}">
<div>
<button type="button" aria-label="Закрыть подсказку" onClick="{{ closeTour }}" style="position: absolute; inset: 0; z-index: 39; border: 0; padding: 0; background: transparent; cursor: default;"></button>
<div style="{{ scrimTop }}"></div>
<div style="{{ scrimBottom }}"></div>
<div style="{{ scrimLeft }}"></div>
<div style="{{ scrimRight }}"></div>
<div style="{{ spotStyle }}"></div>
<div style="{{ ringStyle }}"></div>
<div style="{{ calloutStyle }}">
<div style="padding: 16px 18px 14px; background: var(--surface-card); border: 1px solid var(--primary-border); border-radius: 13px; box-shadow: 0 20px 48px -12px rgba(16,18,22,.45); animation: ob-rise 220ms cubic-bezier(.2,.8,.2,1);">
<p style="margin: 0 0 8px; color: var(--primary-text); font-size: 11px; font-weight: 700; letter-spacing: .08em; text-transform: uppercase;">{{ tourBadge }}</p>
<strong style="display: block; color: var(--n-1); font-size: 14.5px; font-weight: 700; letter-spacing: -.01em;">{{ tourTitle }}</strong>
<p style="margin: 7px 0 0; color: var(--n-3); font-size: 13px; line-height: 1.55; text-wrap: pretty;">{{ tourText }}</p>
<button type="button" onClick="{{ closeTour }}" style="width: 100%; height: 34px; display: inline-flex; align-items: center; justify-content: center; gap: 7px; margin-top: 14px; border: 0; border-radius: 8px; background: var(--primary); color: #fff; font-size: 13px; font-weight: 600;" style-hover="background: var(--primary-hover);">
Понятно, к шагам
<svg viewBox="0 0 24 24" width="15" height="15" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round"><polyline points="15 6 9 12 15 18"></polyline></svg>
</button>
</div>
</div>
</div>
</sc-if>
</div>
</x-dc>
<script type="text/x-dc" data-dc-script data-props="{&quot;$preview&quot;:{&quot;width&quot;:1440,&quot;height&quot;:900},&quot;theme&quot;:{&quot;editor&quot;:&quot;enum&quot;,&quot;options&quot;:[&quot;light&quot;,&quot;dark&quot;],&quot;default&quot;:&quot;light&quot;,&quot;tsType&quot;:&quot;\&quot;light\&quot; | \&quot;dark\&quot;&quot;,&quot;section&quot;:&quot;Тема&quot;},&quot;accent&quot;:{&quot;editor&quot;:&quot;color&quot;,&quot;options&quot;:[&quot;#1677ff&quot;,&quot;#722ed1&quot;,&quot;#0f9b8e&quot;,&quot;#d4380d&quot;],&quot;default&quot;:&quot;#1677ff&quot;,&quot;tsType&quot;:&quot;string&quot;,&quot;section&quot;:&quot;Тема&quot;}}">
class Component extends DCLogic {
state = { screen: "chat", sec: "ai", open: true, view: "welcome", i: 0, done: {}, tour: null };
steps = [
{
key: "provider", num: 1, est: "3 мин", rail: "AI-провайдер",
title: "Подключите AI-провайдера",
lead: "Без провайдера агент не ответит ни одному клиенту. Ключ ваш: расходы, лимиты и токены считаются в системе.",
path: ["Настройки", "AI-провайдер", "Добавить провайдера"],
items: [
{ n: 1, text: "В сайдбаре откройте «Настройки» и выберите раздел «AI-провайдер»." },
{ n: 2, text: "Нажмите «Добавить провайдера»: OpenRouter, Custom (OpenAI-compatible) или локальная модель." },
{ n: 3, text: "Вставьте API-ключ и укажите модель по умолчанию — например openai/gpt-4o-mini." },
{ n: 4, text: "Нажмите «Проверить»: статус должен стать «Подключено»." }
],
tip: "Ключа ещё нет? Возьмите «Демо-провайдер без ключа» — он отвечает по знаниям агента, чтобы осмотреться, и меняется на настоящий в любой момент.",
preview: { caption: "Настройки → AI-провайдер", rows: [{ label: "Провайдер", value: "OpenRouter" }, { label: "Секрет", value: "sk-or-••••••••" }, { label: "Модель", value: "openai/gpt-4o-mini" }], chip: "Подключено", chipOk: true },
goLabel: "Открыть раздел",
tour: { screen: "settings", sec: "ai", target: "settings-ai", title: "Настройки → AI-провайдер", text: "Раздел в субменю «Настроек». Здесь живут ключи организации: добавление, проверка и статус провайдера." }
},
{
key: "agent", num: 2, est: "4 мин", rail: "AI-агент",
title: "Создайте AI-агента",
lead: "Агент — тот, кто говорит с клиентом: кто он, как отвечает, каким правилам подчиняется и сколько может потратить в день.",
path: ["Агенты", "Создать агента"],
items: [
{ n: 1, text: "Откройте «Агенты» в сайдбаре и нажмите «Создать агента»." },
{ n: 2, text: "Опишите роль и тон: кто он для клиента, что можно обещать, чего делать нельзя." },
{ n: 3, text: "Выберите модель подключённого провайдера и дневной бюджет в долларах." },
{ n: 4, text: "Прикрепите статьи базы знаний — агент отвечает только из них." },
{ n: 5, text: "Переведите статус из «Черновик» в «Активен», иначе агент молчит." }
],
tip: "Если данных для ответа нет, агент честно это скажет и предложит позвать сотрудника — придумывать он не станет.",
preview: { caption: "Агенты → Консультант", rows: [{ label: "Модель", value: "openai/gpt-4o-mini" }, { label: "Дневной бюджет", value: "$5.00" }, { label: "Знания", value: "12 статей" }], chip: "Активен", chipOk: true },
goLabel: "Открыть раздел",
tour: { screen: "chat", target: "nav-agents", title: "Раздел «Агенты»", text: "Список агентов организации. Карточка агента — это его роль, модель, бюджет и прикреплённые знания." }
},
{
key: "knowledge", num: 3, est: "от 10 мин", rail: "База знаний",
title: "Наполните базу знаний",
lead: "Это единственный источник ответов агента. Поиск идёт по смыслу, поэтому пишите так, как спрашивают клиенты.",
path: ["База знаний", "Создать статью"],
items: [
{ n: 1, text: "Откройте «База знаний» и создайте категории под темы обращений." },
{ n: 2, text: "Добавьте статьи: вопрос в заголовке, короткий однозначный ответ в тексте." },
{ n: 3, text: "Готовые материалы залейте через «Импорт» — файлами, без ручного переноса." },
{ n: 4, text: "Проверьте на карточке агента, что статьи к нему прикреплены." }
],
tip: "Правка статьи меняет ответы бота сразу, и ту же статью читает публичный портал поддержки — держать два текста не нужно.",
preview: { caption: "База знаний", rows: [{ label: "Категории", value: "Доставка · Оплата · Возврат" }, { label: "Статей", value: "12" }, { label: "Поиск", value: "Семантический" }], chip: "Прикреплено к агенту", chipOk: true },
goLabel: "Открыть раздел",
tour: { screen: "chat", target: "nav-knowledge", title: "Раздел «База знаний»", text: "Статьи, категории и импорт. Отсюда агент берёт ответы, отсюда же их читает портал поддержки." }
},
{
key: "entry", num: 4, est: "5 мин", rail: "Точка входа",
title: "Подключите точку входа",
lead: "Точка входа — канал, по которому приходит клиент: Telegram, MAX, почта или чат на сайте. Без неё писать некуда.",
path: ["Настройки", "Интеграции", "Добавить подключение"],
items: [
{ n: 1, text: "Настройки → «Интеграции» → «Добавить подключение»." },
{ n: 2, text: "Для бота вставьте токен: Telegram — от BotFather, MAX — из кабинета разработчика." },
{ n: 3, text: "Для почты укажите IMAP и SMTP ящика и пароль приложения." },
{ n: 4, text: "Привяжите подключение к созданному агенту — иначе сообщения некому обрабатывать." },
{ n: 5, text: "Нажмите «Проверить» и убедитесь в статусе «Подключено»." }
],
tip: "Сообщения из Telegram и MAX забирает фоновый рабочий. Если он остановлен, статус останется «Подключено», а переписки не появятся.",
preview: { caption: "Настройки → Интеграции", rows: [{ label: "Подключение", value: "Telegram · @romashka_bot" }, { label: "Агент", value: "Консультант" }, { label: "Секрет", value: "78••••••••" }], chip: "Подключено", chipOk: true },
goLabel: "Открыть раздел",
tour: { screen: "settings", sec: "integrations", target: "settings-integrations", title: "Настройки → Интеграции", text: "Боты, почта и веб-виджет. Красная точка у пункта означает, что какое-то подключение отвалилось." }
},
{
key: "widget", num: 5, est: "3 мин", rail: "Виджет на сайт",
title: "Поставьте чат на сайт",
lead: "Веб-виджет — один тег в вашем шаблоне. Чат открывается в изолированном окне поверх сайта.",
path: ["Настройки", "Интеграции", "Web-виджет"],
items: [
{ n: 1, text: "Создайте подключение «Web-виджет» и оформите его: заголовок, акцент, приветствие." },
{ n: 2, text: "Заполните разрешённые домены — пустой список запрещает все сайты." },
{ n: 3, text: "Скопируйте сниппет и вставьте его перед закрывающим body на своём сайте." },
{ n: 4, text: "Переведите виджет в статус «Опубликован» и обновите страницу сайта." }
],
tip: "Ключ в data-widget-key должен совпадать с ключом из настроек виджета — при несовпадении чат просто не появится.",
preview: {
caption: "Сниппет для сайта",
code: "<" + "script src=\"https://support.romashka.ru/chat-widget.js\" data-widget-key=\"wk_7f3a…\" async><\/" + "script>",
rows: [{ label: "Разрешённые домены", value: "romashka.ru, *.romashka.ru" }],
chip: "Опубликован", chipOk: true
},
goLabel: "Открыть раздел",
tour: { screen: "settings", sec: "integrations", target: "settings-integrations", title: "Настройки → Интеграции", text: "Виджет живёт среди подключений: там его ключ, разрешённые домены и готовый тег для вставки." }
},
{
key: "team", num: 6, est: "4 мин", rail: "Сотрудники",
title: "Пригласите команду",
lead: "Сложные вопросы агент передаёт людям. Заведите тех, кому он будет их отдавать, и решите, кто что видит.",
path: ["Сотрудники", "Пригласить"],
items: [
{ n: 1, text: "Откройте «Сотрудники» и отправьте приглашения по email." },
{ n: 2, text: "Назначьте роли: владелец и администратор видят всё, сотрудник работает только в чате." },
{ n: 3, text: "В Настройки → «Группы» распределите диалоги по группам видимости." },
{ n: 4, text: "Предложите команде включить вход по коду (TOTP) в своём профиле." }
],
tip: "Приглашения уходят почтой. Пока не настроен SMTP (шаг 7), письма не дойдут — пароль придётся задавать вручную.",
preview: { caption: "Сотрудники", rows: [{ label: "Приглашены", value: "3 человека" }, { label: "Группы", value: "Первая линия · Биллинг" }, { label: "Роли", value: "Админ · Сотрудник" }], chip: "Ожидают входа", chipOk: false },
goLabel: "Открыть раздел",
tour: { screen: "chat", target: "nav-employees", title: "Раздел «Сотрудники»", text: "Приглашения, роли и группы. Сотрудник видит диалоги своих групп и те, за которые отвечает." }
},
{
key: "platform", num: 7, est: "5 мин", rail: "Домен и почта",
title: "Укажите домен и почту",
lead: "Пока установка открывается по IP — без HTTPS и без писем. Домен и SMTP превращают её в рабочий сервис.",
path: ["Настройки", "Платформа"],
items: [
{ n: 1, text: "Настройки → «Платформа»: введите домен установки и переключитесь на https." },
{ n: 2, text: "Убедитесь, что DNS домена смотрит на сервер, а порты 80 и 443 открыты снаружи." },
{ n: 3, text: "Заполните исходящую почту: хост, порт, шифрование, логин и пароль приложения." },
{ n: 4, text: "Нажмите «Отправить тестовое письмо» и проверьте, что оно пришло." }
],
tip: "Сертификат выдаётся по первому запросу к домену: откройте его в браузере и подождите несколько секунд.",
preview: { caption: "Настройки → Платформа", rows: [{ label: "Адрес", value: "https://support.romashka.ru" }, { label: "SMTP", value: "smtp.yandex.ru : 465" }, { label: "Сертификат", value: "Let's Encrypt" }], chip: "HTTPS включён", chipOk: true },
goLabel: "Открыть раздел",
tour: { screen: "settings", sec: "platform", target: "settings-platform", title: "Настройки → Платформа", text: "Свойства самой установки: адрес, исходящая почта, обновления. Раздел виден администратору установки." }
},
{
key: "test", num: 8, est: "2 мин", rail: "Проверка",
title: "Напишите боту как клиент",
lead: "Последний шаг — пройти путь клиента целиком: сообщение, ответ агента и передача на человека.",
path: ["Чат", "Все диалоги"],
items: [
{ n: 1, text: "Напишите своему боту или в виджет на сайте вопрос из базы знаний." },
{ n: 2, text: "Откройте «Чат»: диалог появится в списке с каналом, агентом и таймером ожидания." },
{ n: 3, text: "Проверьте ответ агента — он должен опираться на вашу статью." },
{ n: 4, text: "Нажмите «Взять диалог» и ответьте сами: так работает передача на оператора." }
],
tip: "Три режима диалога — отвечает AI, отвечает сотрудник, пауза — переключаются в шапке переписки в любой момент.",
preview: { caption: "Чат → Все диалоги", rows: [{ label: "Диалог", value: "Анна Смирнова · Telegram" }, { label: "Агент", value: "Консультант" }, { label: "Режим", value: "Отвечает AI" }], chip: "Ответ за 4 сек", chipOk: true },
goLabel: "Открыть чат",
tour: { screen: "chat", target: "chat-list", title: "Список диалогов", text: "Все обращения организации в одном окне: канал, агент, время ожидания, метки и группа." }
}
];
componentDidMount() {
this.apply();
window.addEventListener("resize", this.onResize);
}
componentDidUpdate() { this.apply(); }
componentWillUnmount() { window.removeEventListener("resize", this.onResize); }
onResize = () => { if (this.state.tour) this.measureTour(this.state.tour.cfg); };
measureTour(cfg) {
if (!cfg) return;
const el = document.querySelector('[data-target="' + cfg.target + '"]');
const root = el ? el.closest("[data-ob-root]") : null;
if (!el || !root) return;
const r = el.getBoundingClientRect();
const rr = root.getBoundingClientRect();
this.setState({
tour: {
cfg: cfg,
x: r.left - rr.left, y: r.top - rr.top, w: r.width, h: r.height,
rootW: rr.width, rootH: rr.height,
title: cfg.title, text: cfg.text
}
});
}
apply() {
const root = document.documentElement;
root.setAttribute("data-theme", (this.props.theme || "light") === "dark" ? "dark" : "light");
root.style.setProperty("--accent", this.props.accent || "#1677ff");
}
cur() { return this.steps[Math.min(this.state.i, this.steps.length - 1)]; }
goStep = (i) => this.setState({ i: i, view: "steps", tour: null });
next = () => {
const cur = this.cur();
const done = Object.assign({}, this.state.done);
done[cur.key] = true;
if (this.state.i >= this.steps.length - 1) this.setState({ done: done, view: "done", tour: null });
else this.setState({ done: done, i: this.state.i + 1, tour: null });
};
prev = () => {
if (this.state.i === 0) this.setState({ view: "welcome", tour: null });
else this.setState({ i: this.state.i - 1, tour: null });
};
showTour = () => {
const cfg = this.cur().tour;
this.setState({ screen: cfg.screen, sec: cfg.sec || this.state.sec }, () => {
window.requestAnimationFrame(() => this.measureTour(cfg));
});
};
renderVals() {
const st = this.state;
const cur = this.cur();
const total = this.steps.length;
const doneCount = this.steps.filter((s) => st.done[s.key]).length;
const onChat = st.screen === "chat";
const onSettings = st.screen === "settings";
const navItem = (active) => ({
position: "relative", width: "100%", display: "flex", alignItems: "center", flex: "none", gap: "10px",
height: "36px", padding: "0 10px", border: "none", borderRadius: "8px", fontSize: "13.5px", textAlign: "left",
color: active ? "var(--primary-text)" : "var(--n-2)",
background: active ? "var(--primary-bg)" : "transparent",
fontWeight: active ? 600 : 500
});
const secItem = (key) => {
const active = st.sec === key;
return {
display: "flex", alignItems: "center", gap: "10px", minHeight: "34px", padding: "0 10px",
border: 0, borderRadius: "8px", fontSize: "13px", textAlign: "left",
color: active ? "var(--primary-text)" : "var(--n-2)",
background: active ? "var(--primary-bg)" : "transparent",
fontWeight: active ? 600 : 500
};
};
const SEC = {
organization: { heading: "Организация", lead: "Название, логотип и региональные параметры, которые видят клиенты.", action: "Сохранить", empty: "Данные организации" },
groups: { heading: "Группы", lead: "Они распределяют диалоги между сотрудниками.", action: "Добавить группу", empty: "Групп пока нет" },
ai: { heading: "AI-провайдер", lead: "Ключи организации, по которым отвечают агенты.", action: "Добавить провайдера", empty: "Провайдеров пока нет — добавьте OpenRouter, свой OpenAI-совместимый сервис или демо" },
integrations: { heading: "Интеграции", lead: "Боты, почта и веб-виджет — точки входа клиентов.", action: "Добавить подключение", empty: "Подключений пока нет — добавьте бота, почту или веб-виджет" },
communication: { heading: "Голосовые и звонки", lead: "Где клиент и сотрудник могут записать голосовое и позвонить.", action: "Сохранить", empty: "Матрица каналов" },
storage: { heading: "Хранилище файлов", lead: "Вложения, голосовые, фото и логотипы.", action: "Сохранить", empty: "Файлы лежат в локальном томе" },
platform: { heading: "Платформа", lead: "Свойства самой установки: по какому адресу она открывается и как отправляет почту.", action: "Сохранить", empty: "Адрес установки и исходящая почта" },
demo: { heading: "Демо-данные", lead: "Посмотреть систему в работе на вымышленной организации.", action: "Установить", empty: "Демо-данные не установлены" }
};
const sec = SEC[st.sec] || SEC.ai;
const tour = st.tour;
let spotStyle = null;
let ringStyle = null;
let calloutStyle = null;
let scrimTop = null;
let scrimBottom = null;
let scrimLeft = null;
let scrimRight = null;
if (tour) {
const pad = 6;
const box = {
position: "absolute", left: (tour.x - pad) + "px", top: (tour.y - pad) + "px",
width: (tour.w + pad * 2) + "px", height: (tour.h + pad * 2) + "px",
borderRadius: "11px", pointerEvents: "none", zIndex: 41
};
const shadeColor = (this.props.theme || "light") === "dark" ? "rgba(4, 5, 7, 0.68)" : "rgba(16, 18, 22, 0.55)";
const shade = { position: "absolute", backgroundColor: shadeColor, pointerEvents: "none", zIndex: 40 };
const t = Math.max(0, tour.y - pad);
const b = tour.y + tour.h + pad;
const l = Math.max(0, tour.x - pad);
const r = tour.x + tour.w + pad;
scrimTop = Object.assign({}, shade, { left: 0, top: 0, right: 0, height: t + "px" });
scrimBottom = Object.assign({}, shade, { left: 0, top: b + "px", right: 0, bottom: 0 });
scrimLeft = Object.assign({}, shade, { left: 0, top: t + "px", width: l + "px", height: (b - t) + "px" });
scrimRight = Object.assign({}, shade, { left: r + "px", top: t + "px", right: 0, height: (b - t) + "px" });
spotStyle = Object.assign({}, box, { boxShadow: "0 0 0 2px var(--primary)" });
ringStyle = Object.assign({}, box, { border: "2px solid var(--primary)", animation: "ob-pulse 1.9s ease-out infinite" });
const w = 344;
const right = tour.x + tour.w + 20;
const left = right + w > tour.rootW - 16 ? Math.max(16, tour.x - w - 20) : right;
const top = Math.min(Math.max(16, tour.y - 14), Math.max(16, tour.rootH - 250));
calloutStyle = { position: "absolute", left: left + "px", top: top + "px", width: w + "px", zIndex: 42 };
}
const markStyle = (isDone, isCur) => ({
width: "22px", height: "22px", flex: "none", display: "inline-flex", alignItems: "center",
justifyContent: "center", borderRadius: "50%", fontSize: "11.5px", fontWeight: 700,
color: isDone || isCur ? "#fff" : "var(--n-4)",
background: isDone ? "var(--success)" : isCur ? "var(--primary)" : "var(--n-8)",
border: "1px solid " + (isDone ? "var(--success)" : isCur ? "var(--primary)" : "var(--n-7)")
});
return {
onChat: onChat, onSettings: onSettings,
navChatStyle: navItem(onChat), navSettingsStyle: navItem(onSettings),
secOrgStyle: secItem("organization"), secGroupsStyle: secItem("groups"), secAiStyle: secItem("ai"),
secIntStyle: secItem("integrations"), secCommStyle: secItem("communication"),
secStorageStyle: secItem("storage"), secPlatformStyle: secItem("platform"), secDemoStyle: secItem("demo"),
secHeading: sec.heading, secLead: sec.lead, secAction: sec.action, secEmpty: sec.empty,
goChat: () => this.setState({ screen: "chat", tour: null }),
goSettings: () => this.setState({ screen: "settings", tour: null }),
showWelcome: st.open && st.view === "welcome" && !tour,
showSteps: st.open && st.view === "steps" && !tour,
showDone: st.open && st.view === "done" && !tour,
showLauncher: !st.open && !tour && doneCount < total,
close: () => this.setState({ open: false, tour: null }),
reopen: () => this.setState({ open: true, view: st.view === "welcome" ? "steps" : st.view, tour: null }),
start: () => this.setState({ open: true, view: "steps", i: 0 }),
next: this.next, prev: this.prev, showTour: this.showTour,
closeTour: () => this.setState({ tour: null }),
goSection: () => {
const cfg = cur.tour;
this.setState({ screen: cfg.screen, sec: cfg.sec || st.sec, open: false, tour: null });
},
doneLabel: doneCount + "/" + total,
progressLabel: "Готово " + doneCount + " из " + total,
percentLabel: Math.round((doneCount / total) * 100) + "%",
progressStyle: { display: "block", height: "100%", width: Math.round((doneCount / total) * 100) + "%", background: "var(--primary)", borderRadius: "999px", transition: "width 280ms ease" },
eyebrow: "Шаг " + cur.num + " из " + total + " · " + cur.est,
curTitle: cur.title, curLead: cur.lead, curTip: cur.tip, curGoLabel: cur.goLabel,
curItems: cur.items,
curPath: cur.path.map((label, index) => ({ label: label, more: index < cur.path.length - 1 })),
previewCaption: cur.preview.caption,
previewRows: cur.preview.rows,
previewChip: cur.preview.chip,
previewChipOk: Boolean(cur.preview.chipOk),
previewChipWait: !cur.preview.chipOk,
previewCode: cur.preview.code || "",
hasCode: Boolean(cur.preview.code),
nextLabel: st.i === total - 1 ? "Завершить" : "Далее",
rail: this.steps.map((s, index) => {
const isDone = Boolean(st.done[s.key]);
const isCur = index === st.i;
return {
num: s.num, label: s.rail, est: s.est, isDone: isDone, showNum: !isDone,
onClick: () => this.goStep(index),
rowStyle: {
display: "flex", alignItems: "center", gap: "11px", width: "100%", padding: "9px 11px",
border: 0, borderRadius: "9px", textAlign: "left",
background: isCur ? "var(--surface-card)" : "transparent",
boxShadow: isCur ? "var(--shadow-sm)" : "none"
},
markStyle: markStyle(isDone, isCur),
labelStyle: {
flex: 1, minWidth: 0, fontSize: "13px",
color: isCur ? "var(--n-1)" : isDone ? "var(--n-3)" : "var(--n-2)",
fontWeight: isCur ? 700 : 500
}
};
}),
doneSummary: this.steps.map((s) => {
const isDone = Boolean(st.done[s.key]);
return {
label: s.rail, isDone: isDone,
markStyle: {
width: "20px", height: "20px", flex: "none", display: "inline-flex", alignItems: "center",
justifyContent: "center", borderRadius: "50%", color: "#fff",
background: isDone ? "var(--success)" : "var(--n-7)",
border: "1px solid " + (isDone ? "var(--success)" : "var(--n-7)")
},
labelStyle: { flex: 1, minWidth: 0, color: isDone ? "var(--n-2)" : "var(--n-4)", fontSize: "13px", fontWeight: isDone ? 600 : 500 }
};
}),
tourOpen: Boolean(tour),
tourTitle: tour ? tour.title : "",
tourText: tour ? tour.text : "",
tourBadge: tour ? "Шаг " + cur.num + " · где это находится" : "",
spotStyle: spotStyle, ringStyle: ringStyle, calloutStyle: calloutStyle,
scrimTop: scrimTop, scrimBottom: scrimBottom, scrimLeft: scrimLeft, scrimRight: scrimRight
};
}
}
</script>
</body>
</html>
-31
View File
@@ -1,31 +0,0 @@
# Chatballs Design System
Статический справочник текущего frontend UI. React, сборка и установка зависимостей не требуются.
## Открытие в Windows PowerShell
Из корня репозитория:
```powershell
Start-Process .\design\design-system\index.html
```
Для просмотра через локальный HTTP-сервер:
```powershell
python -m http.server 8080 --directory .\design\design-system
Start-Process http://localhost:8080
```
## Состав
- `index.html` — оглавление;
- `foundations.html` — токены;
- `components.html` — базовые controls;
- `data-display.html` — статусы и представление данных;
- `patterns.html` — таблицы, состояния, меню и dialogs;
- `layouts.html` — типовые компоновки экранов;
- `inventory.html` — поисковый реестр;
- `inventory.json` — машиночитаемая инвентаризация.
Справочник автономен и не подключён к production bundle приложения.
-275
View File
@@ -1,275 +0,0 @@
* {
box-sizing: border-box;
}
:root {
--n-1: #1f1f1f;
--n-2: #262626;
--n-3: #595959;
--n-4: #8c8c8c;
--n-5: #bfbfbf;
--n-6: #d9d9d9;
--n-7: #e8e8e8;
--n-8: #f0f0f0;
--n-9: #f5f5f5;
--n-10: #fafafa;
--primary: #1677ff;
--primary-hover: #0958d9;
--primary-bg: #e6f4ff;
--primary-border: #91caff;
--success: #52c41a;
--success-text: #389e0d;
--success-bg: #f6ffed;
--success-border: #b7eb8f;
--warning-text: #d48806;
--warning-bg: #fffbe6;
--warning-bg-strong: #fff7e6;
--warning-border: #ffe58f;
--error-text: #cf1322;
--error-bg: #fff2f0;
--error-border: #ffccc7;
--ai: #722ed1;
--ai-bg: #f9f0ff;
--ai-border: #e4d4fb;
--text-heading: var(--n-1);
--text-body: var(--n-2);
--text-secondary: var(--n-3);
--text-tertiary: var(--n-4);
--text-disabled: var(--n-5);
--border-default: var(--n-8);
--border-input: var(--n-7);
--radius-md: 8px;
--radius-lg: 9px;
--radius-xl: 10px;
--radius-2xl: 12px;
--radius-4xl: 14px;
--radius-pill: 999px;
--shadow-xs: 0 1px 2px rgba(0, 0, 0, 0.03);
--shadow-sm: 0 1px 3px rgba(0, 0, 0, 0.04);
--shadow-lg: 0 6px 20px rgba(0, 0, 0, 0.12);
--font-sans: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
--font-mono: "SF Mono", ui-monospace, Menlo, monospace;
}
html,
body {
min-height: 100%;
margin: 0;
}
body {
color: var(--text-body);
background: #f0f2f5;
font-family: var(--font-sans);
-webkit-font-smoothing: antialiased;
}
button,
input,
select,
textarea {
font: inherit;
}
button {
cursor: pointer;
}
button:focus-visible,
a:focus-visible,
input:focus-visible,
select:focus-visible,
textarea:focus-visible {
outline: 2px solid var(--primary-border);
outline-offset: 2px;
}
button:disabled {
cursor: not-allowed;
opacity: 0.55;
}
svg.icon {
width: 1em;
height: 1em;
fill: none;
stroke: currentColor;
stroke-width: 1.8;
stroke-linecap: round;
stroke-linejoin: round;
flex: none;
}
.ds-shell {
min-height: 100vh;
display: grid;
grid-template-columns: 248px minmax(0, 1fr);
}
.ds-sidebar {
position: sticky;
top: 0;
height: 100vh;
display: flex;
flex-direction: column;
background: #fff;
border-right: 1px solid var(--border-default);
}
.ds-brand {
height: 64px;
display: flex;
align-items: center;
gap: 11px;
padding: 0 18px;
color: inherit;
border-bottom: 1px solid var(--n-9);
text-decoration: none;
}
.ds-brand-mark {
width: 32px;
height: 32px;
display: inline-flex;
align-items: center;
justify-content: center;
color: #fff;
background: var(--primary);
border-radius: var(--radius-md);
font-size: 15px;
font-weight: 700;
}
.ds-brand strong,
.ds-brand small {
display: block;
}
.ds-brand strong {
color: var(--text-heading);
font-size: 14px;
line-height: 1.2;
}
.ds-brand small {
margin-top: 3px;
color: var(--text-tertiary);
font-size: 11px;
}
.ds-nav {
flex: 1;
overflow-y: auto;
padding: 12px;
}
.ds-nav-group {
padding: 14px 12px 6px;
color: var(--text-disabled);
font-size: 11px;
font-weight: 600;
letter-spacing: 0.06em;
text-transform: uppercase;
}
.ds-nav a {
position: relative;
min-height: 38px;
display: flex;
align-items: center;
gap: 10px;
padding: 9px 12px;
margin-bottom: 2px;
color: var(--text-secondary);
border-radius: var(--radius-md);
font-size: 13.5px;
font-weight: 500;
text-decoration: none;
}
.ds-nav a:hover {
color: var(--text-body);
background: var(--n-9);
}
.ds-nav a.is-active {
color: var(--primary-hover);
background: var(--primary-bg);
font-weight: 600;
}
.ds-nav a.is-active::before {
position: absolute;
top: 8px;
bottom: 8px;
left: -12px;
width: 3px;
background: var(--primary);
border-radius: 0 3px 3px 0;
content: "";
}
.ds-sidebar-footer {
padding: 12px 18px;
color: var(--text-tertiary);
border-top: 1px solid var(--n-9);
font-size: 11.5px;
line-height: 1.45;
}
.ds-main {
min-width: 0;
}
.ds-topbar {
position: sticky;
top: 0;
z-index: 20;
height: 64px;
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 28px;
background: rgba(255, 255, 255, 0.96);
border-bottom: 1px solid var(--border-default);
backdrop-filter: blur(8px);
}
.ds-breadcrumbs {
display: flex;
align-items: center;
gap: 8px;
color: var(--text-tertiary);
font-size: 13px;
}
.ds-breadcrumbs strong {
color: var(--text-body);
}
.ds-version {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 5px 10px;
color: var(--success-text);
background: var(--success-bg);
border: 1px solid var(--success-border);
border-radius: 7px;
font-size: 12px;
font-weight: 600;
}
.ds-version::before {
width: 7px;
height: 7px;
background: var(--success);
border-radius: 50%;
content: "";
}
.ds-content {
width: min(1240px, 100%);
margin: 0 auto;
padding: 30px 32px 56px;
}
@@ -1,179 +0,0 @@
.primary-button,
.secondary-button,
.danger-outline,
.ui-action-button {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 7px;
text-decoration: none;
}
.primary-button {
min-height: 38px;
padding: 0 16px;
color: #fff;
background: var(--primary);
border: 0;
border-radius: var(--radius-lg);
box-shadow: 0 1px 2px rgba(22, 119, 255, 0.3);
font-size: 13.5px;
font-weight: 600;
}
.primary-button:hover {
background: var(--primary-hover);
}
.primary-button:disabled {
color: var(--text-disabled);
background: var(--n-8);
box-shadow: none;
}
.secondary-button,
.danger-outline {
height: 38px;
padding: 0 16px;
background: #fff;
border-radius: var(--radius-lg);
font-size: 13px;
font-weight: 500;
}
.secondary-button {
color: var(--text-body);
border: 1px solid var(--n-6);
}
.secondary-button:hover,
.ui-action-button:hover {
color: var(--primary);
border-color: var(--primary);
}
.danger-outline {
color: var(--error-text);
border: 1px solid var(--error-border);
}
.danger-outline:hover {
background: var(--error-bg);
}
.ui-action-button {
height: 36px;
padding: 0 14px;
color: var(--text-body);
background: #fff;
border: 1px solid var(--n-6);
border-radius: var(--radius-md);
font-size: 13px;
font-weight: 500;
}
.ui-icon-button,
.icon-button,
.row-menu-button {
display: inline-flex;
align-items: center;
justify-content: center;
flex: none;
}
.ui-icon-button,
.icon-button {
width: 36px;
height: 36px;
color: var(--text-tertiary);
background: #fff;
border: 1px solid var(--n-6);
border-radius: var(--radius-md);
}
.ui-icon-button:hover,
.icon-button:hover {
color: var(--text-body);
background: var(--n-10);
}
.ui-icon-button.is-bare {
background: transparent;
border-color: transparent;
}
.ui-icon-button.is-danger:hover {
color: var(--error-text);
background: var(--error-bg);
border-color: var(--error-border);
}
.icon-button {
position: relative;
color: var(--text-secondary);
border-color: var(--n-8);
}
.icon-button b {
position: absolute;
top: 5px;
right: 5px;
min-width: 15px;
height: 15px;
display: flex;
align-items: center;
justify-content: center;
padding: 0 4px;
color: #fff;
background: #ff4d4f;
border: 1.5px solid #fff;
border-radius: 8px;
font-size: 9px;
}
.link {
display: inline;
padding: 0;
color: var(--primary);
background: none;
border: 0;
font: inherit;
font-weight: 600;
text-align: left;
text-decoration: none;
}
.link:hover {
color: var(--primary-hover);
}
.link.is-strong {
display: block;
font-size: 14px;
font-weight: 700;
}
.link.is-neutral {
color: var(--text-heading);
}
.link.is-neutral:hover,
.link.is-muted:hover {
color: var(--primary);
}
.link.is-muted {
color: var(--text-tertiary);
font-weight: 500;
}
.link.is-mono {
font-family: var(--font-mono);
font-size: 12.5px;
}
.link.has-icon {
display: inline-flex;
align-items: center;
gap: 6px;
}
@@ -1,225 +0,0 @@
.readonly-field {
display: block;
}
.readonly-field > span {
display: block;
margin-bottom: 6px;
color: var(--text-secondary);
font-size: 12.5px;
font-weight: 600;
line-height: normal;
}
.readonly-field input,
.readonly-field textarea,
.select-like select,
.search-control {
width: 100%;
color: var(--text-body);
background: #fff;
border: 1px solid var(--border-input);
border-radius: var(--radius-md);
outline: none;
font-size: 13px;
}
.search-control {
height: 38px;
padding: 0 12px;
}
/* Поле формы — дизайн-базлайн v2 (кадры G3, N1/N6, P1): 36px на подложке
--n-10, текст 13.5px. Один скин на всё приложение. */
.readonly-field input,
.select-like select {
height: 36px;
padding: 0 12px;
color: var(--n-1);
background: var(--n-10);
font-size: 13.5px;
}
.readonly-field textarea {
min-height: 92px;
padding: 10px 12px;
resize: vertical;
line-height: 1.5;
}
.form-field.is-editable:focus-within input,
.form-field.is-editable:focus-within textarea,
.select-like:focus-within select,
.select-like .select-box:focus-within,
.search-control:focus-within {
border-color: var(--primary);
}
.form-field.is-readonly input,
.readonly-field input:disabled {
color: var(--text-tertiary);
background: var(--n-10);
border-color: var(--n-8);
}
.form-field.is-invalid input {
border-color: #ff7875;
}
.form-field-error {
display: block;
margin-top: 5px;
color: var(--error-text);
font-size: 11.5px;
}
.select-like {
position: relative;
}
.select-like select {
padding-right: 32px;
appearance: none;
}
.select-like > .select-chevron {
position: absolute;
right: 13px;
bottom: 11px;
color: var(--text-tertiary);
pointer-events: none;
}
/* Бокс селекта — для метки перед значением (точка группы на карточке агента) и
для режима без прав правки. Рамку держит бокс, селект внутри без своей. */
.select-like .select-box {
display: flex;
align-items: center;
gap: 7px;
height: 36px;
padding: 0 12px;
color: var(--n-1);
background: var(--n-10);
border: 1px solid var(--border-input);
border-radius: var(--radius-md);
font-size: 13.5px;
}
.select-like .select-box select {
flex: 1;
height: 34px;
padding: 0;
color: inherit;
background: transparent;
border: 0;
font-size: inherit;
}
.select-like .select-box > .select-chevron {
flex: none;
color: var(--text-tertiary);
pointer-events: none;
}
/* Пример метки перед значением: цветная точка группы. */
.select-like .select-dot {
width: 8px;
height: 8px;
flex: none;
border-radius: 50%;
}
.select-like .select-value {
flex: 1;
min-width: 0;
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
}
.select-like.is-invalid select,
.select-like.is-invalid .select-box {
color: var(--error-text);
border-color: var(--error-border);
}
.search-control {
display: flex;
align-items: center;
gap: 8px;
color: var(--text-disabled);
}
.search-control input {
width: 100%;
min-width: 0;
padding: 0;
background: transparent;
border: 0;
outline: 0;
}
.ui-switch {
width: 42px;
height: 24px;
display: flex;
align-items: center;
justify-content: flex-start;
flex: none;
padding: 2px;
background: var(--n-6);
border: 0;
border-radius: 14px;
transition: background 0.15s;
}
.ui-switch.on {
justify-content: flex-end;
background: var(--primary);
}
.ui-switch i {
width: 20px;
height: 20px;
display: block;
background: #fff;
border-radius: 50%;
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.2);
}
.ui-switch.is-large {
width: 46px;
height: 26px;
}
.ui-switch.is-large i {
width: 22px;
height: 22px;
}
.segmented {
display: inline-flex;
align-items: center;
gap: 3px;
padding: 3px;
background: #f0f2f5;
border-radius: var(--radius-md);
}
.segmented button {
height: 30px;
padding: 0 13px;
color: var(--text-secondary);
background: transparent;
border: 0;
border-radius: 6px;
font-size: 12.5px;
font-weight: 500;
}
.segmented button.active {
color: var(--text-heading);
background: #fff;
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.1);
font-weight: 600;
}
@@ -1,2 +0,0 @@
@import "./component-actions.css";
@import "./component-forms.css";
-260
View File
@@ -1,260 +0,0 @@
.ui-underline-tabs {
display: flex;
align-items: center;
gap: 20px;
border-bottom: 1px solid var(--border-default);
}
.ui-underline-tabs button {
position: relative;
min-height: 42px;
display: inline-flex;
align-items: center;
gap: 6px;
padding: 0;
color: var(--text-tertiary);
background: transparent;
border: 0;
white-space: nowrap;
}
.ui-underline-tabs button.active {
color: var(--text-body);
font-weight: 600;
}
.ui-underline-tabs button.active::after {
position: absolute;
right: 0;
bottom: -1px;
left: 0;
height: 2px;
background: var(--primary);
content: "";
}
.ui-underline-tabs b {
min-width: 20px;
padding: 2px 6px;
color: var(--text-tertiary);
background: var(--n-9);
border-radius: 10px;
font-size: 10px;
}
.product-tag,
.product-mark,
.channel-badge,
.status-pill,
.role-badge,
.ui-tone-badge {
display: inline-flex;
align-items: center;
white-space: nowrap;
}
.product-tag {
gap: 6px;
padding: 2px 9px;
border-radius: var(--radius-pill);
font-size: 11.5px;
font-weight: 600;
}
.product-tag i,
.product-mark i {
width: 6px;
height: 6px;
border-radius: 50%;
}
.product-mark {
gap: 5px;
}
.channel-badge {
gap: 4px;
padding: 2px 7px;
border-radius: 5px;
font-size: 10.5px;
font-weight: 600;
}
.channel-badge i {
width: 5px;
height: 5px;
border-radius: 50%;
}
.channel-badge.is-plain {
color: var(--text-secondary);
background: var(--n-9);
}
.role-badge {
padding: 2px 8px;
border-radius: 6px;
font-size: 11.5px;
font-weight: 600;
}
.role-badge.owner {
color: var(--text-secondary);
background: var(--n-8);
}
.role-badge.admin {
color: #ad4e00;
background: var(--warning-bg-strong);
}
.role-badge.employee {
color: var(--primary-hover);
background: var(--primary-bg);
}
.status-pill {
gap: 6px;
padding: 2px 8px;
border: 1px solid transparent;
border-radius: 6px;
font-size: 12.5px;
font-weight: 500;
}
.status-pill i {
width: 7px;
height: 7px;
background: currentColor;
border-radius: 50%;
}
.status-pill.is-active,
.status-pill.is-healthy,
.status-pill.is-published {
color: var(--success-text);
}
.status-pill.is-healthy,
.status-pill.is-published {
background: var(--success-bg);
border-color: var(--success-border);
}
.status-pill.is-error {
color: var(--error-text);
background: var(--error-bg);
border-color: var(--error-border);
}
.status-pill.is-pending {
color: var(--warning-text);
background: var(--warning-bg);
border-color: var(--warning-border);
}
.status-pill.is-archived,
.status-pill.is-draft,
.status-pill.is-unchecked {
color: var(--text-tertiary);
background: var(--n-10);
border-color: var(--n-7);
}
.status-pill.is-blocked {
color: var(--error-text);
}
.status-pill.is-disabled {
color: var(--warning-text);
}
.status-pill.is-invited {
color: var(--primary-hover);
}
.avatar {
width: 32px;
height: 32px;
display: inline-flex;
align-items: center;
justify-content: center;
flex: none;
color: #fff;
background: var(--primary);
border-radius: 50%;
font-size: 13px;
font-weight: 600;
}
.key-value,
.metric-box {
display: grid;
gap: 5px;
}
.key-value span,
.metric-box span {
color: var(--text-tertiary);
font-size: 11.5px;
}
.key-value strong,
.metric-box strong {
color: var(--text-body);
font-size: 13.5px;
}
.metric-box {
min-width: 150px;
padding: 14px 16px;
background: #fff;
border: 1px solid var(--border-default);
border-radius: var(--radius-xl);
}
.metric-box strong {
font-size: 22px;
line-height: 1.2;
}
.ui-table-pagination {
display: flex;
align-items: center;
justify-content: space-between;
gap: 18px;
}
.ui-table-pagination > span {
color: var(--text-tertiary);
font-size: 12.5px;
}
.ui-table-pagination nav {
display: flex;
align-items: center;
gap: 6px;
}
.ui-table-pagination button {
min-width: 30px;
height: 30px;
padding: 0 9px;
color: var(--text-secondary);
background: #fff;
border: 1px solid var(--n-7);
border-radius: 7px;
font-size: 13px;
}
.ui-table-pagination button.active {
color: #fff;
background: var(--primary);
border-color: var(--primary);
font-weight: 600;
}
.ui-table-pagination button:not(:disabled):hover {
color: var(--primary);
border-color: var(--primary);
}
@@ -1,153 +0,0 @@
function installIconSprite() {
if (!window.CHATBALLS_ICON_SPRITE || document.querySelector("[data-chatballs-icon-sprite]")) return;
const container = document.createElement("div");
container.hidden = true;
container.dataset.chatballsIconSprite = "";
container.innerHTML = window.CHATBALLS_ICON_SPRITE;
document.body.prepend(container);
}
const pages = [
["index", "index.html", "Обзор", "grid"],
["foundations", "foundations.html", "Токены", "box"],
["components", "components.html", "Компоненты", "columns"],
["data-display", "data-display.html", "Данные и статусы", "list"],
["patterns", "patterns.html", "Паттерны", "settings"],
["layouts", "layouts.html", "Макеты страниц", "grid"],
["inventory", "inventory.html", "Инвентарь", "search"],
];
function icon(name) {
return `<svg class="icon" aria-hidden="true"><use href="#i-${name}"></use></svg>`;
}
function buildSidebar() {
const activePage = document.body.dataset.page || "index";
const nav = pages.map(([key, href, label, iconName], index) => {
const group = index === 0 ? '<div class="ds-nav-group">Система</div>' : index === 1 ? '<div class="ds-nav-group">Справочник</div>' : "";
const active = key === activePage ? " is-active" : "";
return `${group}<a class="${active.trim()}" href="${href}">${icon(iconName)}<span>${label}</span></a>`;
}).join("");
const sidebar = document.querySelector("[data-ds-sidebar]");
if (!sidebar) return;
sidebar.innerHTML = `
<a class="ds-brand" href="index.html">
<span class="ds-brand-mark">C</span>
<span><strong>Chatballs</strong><small>Design System</small></span>
</a>
<nav class="ds-nav" aria-label="Разделы дизайн-системы">${nav}</nav>
<div class="ds-sidebar-footer">Статический каталог текущего frontend UI.<br>Источник: <code>apps/internal-ui/src</code></div>
`;
}
function activateInteractiveExamples() {
document.querySelectorAll("[data-segmented]").forEach((group) => {
group.addEventListener("click", (event) => {
const button = event.target.closest("button");
if (!button) return;
group.querySelectorAll("button").forEach((item) => item.classList.toggle("active", item === button));
});
});
document.querySelectorAll("[data-tabs]").forEach((group) => {
group.addEventListener("click", (event) => {
const button = event.target.closest("button");
if (!button || button.disabled) return;
group.querySelectorAll("button").forEach((item) => item.classList.toggle("active", item === button));
});
});
document.querySelectorAll(".ui-switch").forEach((switchButton) => {
switchButton.addEventListener("click", () => {
if (switchButton.disabled) return;
const checked = switchButton.classList.toggle("on");
switchButton.setAttribute("aria-checked", String(checked));
});
});
document.querySelectorAll("[data-open-dialog]").forEach((button) => {
button.addEventListener("click", () => {
const dialog = document.getElementById(button.dataset.openDialog);
dialog?.classList.add("is-open");
});
});
document.querySelectorAll("[data-close-dialog]").forEach((button) => {
button.addEventListener("click", () => button.closest(".dialog-backdrop")?.classList.remove("is-open"));
});
document.querySelectorAll(".dialog-backdrop").forEach((backdrop) => {
backdrop.addEventListener("click", (event) => {
if (event.target === backdrop) backdrop.classList.remove("is-open");
});
});
}
function activateInventorySearch() {
const input = document.querySelector("[data-inventory-search]");
const rows = [...document.querySelectorAll("[data-inventory-row]")];
const count = document.querySelector("[data-inventory-count]");
if (!input || rows.length === 0) return;
const update = () => {
const query = input.value.trim().toLowerCase();
let visible = 0;
rows.forEach((row) => {
const matches = row.textContent.toLowerCase().includes(query);
row.hidden = !matches;
if (matches) visible += 1;
});
if (count) count.textContent = `Показано: ${visible}`;
};
input.addEventListener("input", update);
update();
}
function addCopyButtons() {
document.querySelectorAll("[data-copy]").forEach((button) => {
button.addEventListener("click", async () => {
const value = button.dataset.copy;
if (!value) return;
try {
await navigator.clipboard.writeText(value);
const original = button.textContent;
button.textContent = "Скопировано";
setTimeout(() => { button.textContent = original; }, 900);
} catch {
button.title = value;
}
});
});
}
function renderInventory() {
const body = document.querySelector("[data-inventory-body]");
const payload = window.CHATBALLS_INVENTORY;
if (!body || !payload) return;
body.innerHTML = payload.items.map((item) => {
const name = item.documentedAt
? `<a class="link is-neutral" href="${item.documentedAt}">${item.name}</a>`
: item.name;
return `<tr data-inventory-row>
<td><strong>${name}</strong><small style="display:block;margin-top:3px;color:#bfbfbf">${item.visibility}</small></td>
<td>${item.kind}</td>
<td>${item.category}</td>
<td><span class="ds-chip">${item.scope}</span></td>
<td><code class="ds-code">${item.source}</code></td>
<td>${item.variants}</td>
</tr>`;
}).join("");
const total = document.querySelector("[data-inventory-total]");
if (total) total.textContent = String(payload.count);
}
installIconSprite();
buildSidebar();
renderInventory();
activateInteractiveExamples();
activateInventorySearch();
addCopyButtons();
-227
View File
@@ -1,227 +0,0 @@
.ds-page-header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 24px;
margin-bottom: 24px;
}
.ds-page-header h1 {
margin: 0;
color: var(--text-heading);
font-size: 28px;
line-height: 1.2;
letter-spacing: -0.025em;
}
.ds-page-header p {
max-width: 760px;
margin: 8px 0 0;
color: var(--text-tertiary);
font-size: 14px;
line-height: 1.6;
}
.ds-eyebrow {
margin-bottom: 8px;
color: var(--primary);
font-size: 11px;
font-weight: 700;
letter-spacing: 0.08em;
text-transform: uppercase;
}
.ds-section {
margin-top: 28px;
}
.ds-section-heading {
display: flex;
align-items: flex-end;
justify-content: space-between;
gap: 20px;
margin-bottom: 12px;
}
.ds-section-heading h2 {
margin: 0;
color: var(--text-heading);
font-size: 19px;
line-height: 1.25;
}
.ds-section-heading p {
margin: 4px 0 0;
color: var(--text-tertiary);
font-size: 12.5px;
}
.ds-source {
color: var(--text-disabled);
font-family: var(--font-mono);
font-size: 11px;
white-space: nowrap;
}
.ds-grid {
display: grid;
gap: 14px;
}
.ds-grid.cols-2 {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.ds-grid.cols-3 {
grid-template-columns: repeat(3, minmax(0, 1fr));
}
.ds-grid.cols-4 {
grid-template-columns: repeat(4, minmax(0, 1fr));
}
.ds-card,
.ds-specimen {
background: #fff;
border: 1px solid var(--border-default);
border-radius: var(--radius-2xl);
box-shadow: var(--shadow-xs);
}
.ds-card {
padding: 18px;
}
.ds-card h3 {
margin: 0 0 7px;
color: var(--text-heading);
font-size: 14px;
}
.ds-card p {
margin: 0;
color: var(--text-tertiary);
font-size: 12.5px;
line-height: 1.55;
}
.ds-card-link {
min-height: 144px;
display: flex;
flex-direction: column;
color: inherit;
text-decoration: none;
}
.ds-card-link:hover {
border-color: var(--primary-border);
box-shadow: 0 4px 14px rgba(22, 119, 255, 0.08);
}
.ds-card-link .ds-card-icon {
width: 38px;
height: 38px;
display: inline-flex;
align-items: center;
justify-content: center;
margin-bottom: 16px;
color: var(--primary);
background: var(--primary-bg);
border-radius: var(--radius-xl);
font-size: 18px;
}
.ds-card-link .ds-arrow {
margin-top: auto;
padding-top: 18px;
color: var(--primary);
font-size: 12px;
font-weight: 600;
}
.ds-specimen-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
padding: 12px 16px;
border-bottom: 1px solid var(--border-default);
}
.ds-specimen-header strong {
color: var(--text-heading);
font-size: 13px;
}
.ds-specimen-header code,
.ds-code {
color: var(--text-tertiary);
font-family: var(--font-mono);
font-size: 11px;
}
.ds-specimen-body {
min-height: 96px;
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 12px;
padding: 22px;
background: #fff;
border-radius: 0 0 var(--radius-2xl) var(--radius-2xl);
}
.ds-specimen-body.is-column {
align-items: stretch;
flex-direction: column;
}
.ds-note {
padding: 13px 15px;
color: var(--text-secondary);
background: var(--primary-bg);
border: 1px solid var(--primary-border);
border-radius: var(--radius-xl);
font-size: 12.5px;
line-height: 1.55;
}
.ds-note strong {
color: var(--primary-hover);
}
.ds-meta-list {
display: grid;
gap: 8px;
margin: 14px 0 0;
}
.ds-meta-row {
display: grid;
grid-template-columns: 130px minmax(0, 1fr);
gap: 14px;
padding-top: 8px;
border-top: 1px solid var(--n-9);
font-size: 12px;
}
.ds-meta-row span {
color: var(--text-tertiary);
}
.ds-meta-row code {
color: var(--text-secondary);
font-family: var(--font-mono);
}
.ds-chip {
display: inline-flex;
align-items: center;
padding: 4px 8px;
color: var(--text-secondary);
background: var(--n-9);
border: 1px solid var(--n-8);
border-radius: 6px;
font-family: var(--font-mono);
font-size: 10.5px;
}
Loaded 100 of 117 files, more files were not shown because too many files have changed in this diff. Show more