mirror of
https://github.com/dartdavros/chatballs.git
synced 2026-10-05 17:14:59 +03:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4ab26a1bcd | ||
|
|
09fb0177b1 | ||
|
|
b76871caf0 | ||
|
|
8580b480e7 | ||
|
|
eff6d7ac0c | ||
|
|
1cff9e2183 | ||
|
|
d8420e4379 | ||
|
|
21b8100598 |
No files matched your search
@@ -16,7 +16,7 @@ Production deployment / миграция:
|
||||
- Перед любым изменяющим действием в production сначала представить владельцу точный план миграции и получить его явное согласование. Разрешение на диагностику или общая просьба «исправить» не являются разрешением самостоятельно выбирать архитектуру миграции.
|
||||
|
||||
UI / дизайн:
|
||||
- Никакой отсебятины в UI: не добавлять экраны, блоки, карточки, иконки, тексты, анимации, цвета, layout-решения и состояния, которых нет в утвержденной документации или design-system.
|
||||
- Никакой отсебятины в UI: не добавлять экраны, блоки, карточки, иконки, тексты, анимации, цвета, layout-решения и состояния, которых нет в утверждённом дизайн-макете (`design/baseline/<фича>/*.dc.html`). Макет — источник истины, README рядом с ним лишь пересказ.
|
||||
- Если UI-этап еще не наступил, UI не считается реализованным и не должен маскироваться под готовый продуктовый интерфейс.
|
||||
- Для построения UI использовать существующие компоненты и их стили, если они уже реализованы; если подходящего компонента нет, создавать переиспользуемый компонент в рамках существующей системы.
|
||||
- Не упрощать UI, анимации, иконки, состояния или поведение по своему усмотрению. Любое отклонение от baseline требует явного согласования до правок.
|
||||
|
||||
@@ -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)),
|
||||
|
||||
@@ -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
|
||||
@@ -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,
|
||||
),
|
||||
),
|
||||
]
|
||||
@@ -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)
|
||||
|
||||
@@ -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]:
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
):
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "Бот уведомлений не найден",
|
||||
|
||||
@@ -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",
|
||||
),
|
||||
]
|
||||
@@ -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;
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -11,6 +11,10 @@ import { useChatScope } from "../features/chat/useChatScope";
|
||||
import { isManager } from "../auth/access";
|
||||
import { DemoInstallBanner } from "../features/settings/DemoInstallBanner";
|
||||
import { Sidebar } from "./Sidebar";
|
||||
import { OnboardingLauncher } from "../features/onboarding/OnboardingLauncher";
|
||||
import { OnboardingOverlay } from "../features/onboarding/OnboardingOverlay";
|
||||
import { OnboardingTour } from "../features/onboarding/OnboardingTour";
|
||||
import { OnboardingProvider, useOnboardingState } from "../features/onboarding/useOnboarding";
|
||||
import { UpdateBanner } from "../features/updates/UpdateBanner";
|
||||
import { ShellRouteContent } from "./ShellRouteContent";
|
||||
|
||||
@@ -27,6 +31,10 @@ export function Shell({ route, setRoute, settingsSection, openSettingsRoute, sel
|
||||
// Охват чата живёт здесь: сотрудницкий сайдбар и страница чата делят одно
|
||||
// состояние (дизайн-базлайн v2 §4.1).
|
||||
const chatScope = useChatScope(true);
|
||||
// Онбординг «Начало работы»: окно, тур и пилюля возврата. Состояние живёт
|
||||
// здесь, потому что ссылка «Начало работы» в субменю «Настроек» читает тот
|
||||
// же прогресс.
|
||||
const onboarding = useOnboardingState({ user, setRoute, openSettings: openSettingsRoute });
|
||||
|
||||
const loadWaitingCount = useCallback(async () => {
|
||||
try {
|
||||
@@ -112,27 +120,32 @@ export function Shell({ route, setRoute, settingsSection, openSettingsRoute, sel
|
||||
// «Порталы» — своя лента на --surface-feed и полноэкранные сплиты (кадры PT1–PT8).
|
||||
const isPortals = route === "supportPortals" || route === "supportPortalDetail" || route === "supportPortalSettings";
|
||||
return (
|
||||
<div className={`hub-shell ${isDialogsWorkspace ? "is-chat-route" : ""} ${isSettings ? "is-settings-route" : ""} ${isProfile ? "is-profile-route" : ""} ${isContacts ? "is-contacts-route" : ""}`}>
|
||||
<Sidebar route={route} user={user} setRoute={setRoute} openSettings={openSettingsRoute} onLogout={onLogout} onSwitchOrganization={onSwitchOrganization} waitingCount={waitingCount} chatScope={chatScope.scope} setChatScope={chatScope.setScope} chatCounters={chatScope.counters} unreadCount={unreadCount} onOpenNotifications={() => { setNotifOpen(true); void loadNotifications(); }} expanded={sidebarExpanded} setExpanded={setSidebarExpanded} />
|
||||
<div className="hub-main">
|
||||
{manager && <DemoInstallBanner reload={reload} />}
|
||||
<UpdateBanner enabled={user.isInstanceAdmin} />
|
||||
{/* Верхней панели нет ни у одной роли (дизайн-базлайн v2): заголовок и
|
||||
«назад» живут в самой странице, уведомления — в меню профиля сайдбара. */}
|
||||
<main className={`hub-scroll ${isDialogsWorkspace ? "sales-dialogs-scroll" : ""} ${isAiFullWidth ? "ai-fullwidth-scroll" : ""} ${isSettings ? "settings-scroll" : ""} ${isProfile ? "profile-scroll" : ""} ${isContacts ? "contacts-scroll" : ""} ${isAgents ? "agents-scroll" : ""} ${isEmployees ? "employees-scroll" : ""} ${isAudit ? "audit-scroll" : ""} ${isPortals ? "portals-scroll" : ""} ${isKnowledge || isKnowledgeEditor ? "knowledge-scroll" : ""}`}>
|
||||
<div key={route} className={`hub-page enter-surface ${isSalesWorkspace || isSupportWorkspace ? "sales-workspace-page" : ""} ${isDialogsWorkspace ? "sales-dialogs-page" : ""} ${isAiFullWidth ? "ai-fullwidth-page" : ""} ${isSettings ? "settings-page" : ""} ${isProfile ? "profile-page-shell" : ""} ${isContacts ? "contacts-page-shell" : ""} ${isAgents ? "agents-page-shell" : ""} ${isEmployees ? "employees-page-shell" : ""} ${isAudit ? "audit-page-shell" : ""} ${isKnowledge ? "knowledge-page-shell" : ""} ${isKnowledgeEditor ? "knowledge-editor-shell" : ""} ${isPortals ? "portals-page-shell" : ""}`}>
|
||||
<ShellRouteContent settingsSection={settingsSection} openSettings={openSettingsRoute} chatScope={chatScope.scope} setChatScope={chatScope.setScope} chatCounters={chatScope.counters} chatScopeSwitcher={manager} route={route} data={data} selectedEmployeeId={selectedEmployeeId} selectedAgentId={selectedAgentId} selectedKnowledgeId={selectedKnowledgeId} selectedConversationId={selectedConversationId} selectedClientId={selectedClientId} openClient={openClientRoute} selectedChannelId={selectedChannelId} openChannel={openChannelRoute} selectedSupportPortalId={selectedSupportPortalId} portalSettingsSection={portalSettingsSection} openSupportPortal={openSupportPortalRoute} openPortalSettings={openPortalSettingsRoute} openConversation={openConversationRoute} openEmployee={openEmployee} openAgent={openAgentRoute} openKnowledge={openKnowledgeRoute} openKnowledgeEditor={openKnowledgeEditorRoute} onAgentLoaded={() => undefined} onChannelLoaded={() => undefined} reload={reload} setRoute={setRoute} user={user} onUserUpdated={onUserUpdated} onLogout={onLogout} onOpenSidebar={() => setSidebarExpanded(true)} onOrganizationCreated={onOrganizationCreated} />
|
||||
</div>
|
||||
</main>
|
||||
<OnboardingProvider value={onboarding}>
|
||||
<div className={`hub-shell ${isDialogsWorkspace ? "is-chat-route" : ""} ${isSettings ? "is-settings-route" : ""} ${isProfile ? "is-profile-route" : ""} ${isContacts ? "is-contacts-route" : ""}`}>
|
||||
<Sidebar route={route} user={user} setRoute={setRoute} onLogout={onLogout} onSwitchOrganization={onSwitchOrganization} waitingCount={waitingCount} chatScope={chatScope.scope} setChatScope={chatScope.setScope} chatCounters={chatScope.counters} unreadCount={unreadCount} onOpenNotifications={() => { setNotifOpen(true); void loadNotifications(); }} expanded={sidebarExpanded} setExpanded={setSidebarExpanded} />
|
||||
<div className="hub-main">
|
||||
{manager && <DemoInstallBanner reload={reload} />}
|
||||
<UpdateBanner enabled={user.isInstanceAdmin} />
|
||||
{/* Верхней панели нет ни у одной роли (дизайн-базлайн v2): заголовок и
|
||||
«назад» живут в самой странице, уведомления — в меню профиля сайдбара. */}
|
||||
<main className={`hub-scroll ${isDialogsWorkspace ? "sales-dialogs-scroll" : ""} ${isAiFullWidth ? "ai-fullwidth-scroll" : ""} ${isSettings ? "settings-scroll" : ""} ${isProfile ? "profile-scroll" : ""} ${isContacts ? "contacts-scroll" : ""} ${isAgents ? "agents-scroll" : ""} ${isEmployees ? "employees-scroll" : ""} ${isAudit ? "audit-scroll" : ""} ${isPortals ? "portals-scroll" : ""} ${isKnowledge || isKnowledgeEditor ? "knowledge-scroll" : ""}`}>
|
||||
<div key={route} className={`hub-page enter-surface ${isSalesWorkspace || isSupportWorkspace ? "sales-workspace-page" : ""} ${isDialogsWorkspace ? "sales-dialogs-page" : ""} ${isAiFullWidth ? "ai-fullwidth-page" : ""} ${isSettings ? "settings-page" : ""} ${isProfile ? "profile-page-shell" : ""} ${isContacts ? "contacts-page-shell" : ""} ${isAgents ? "agents-page-shell" : ""} ${isEmployees ? "employees-page-shell" : ""} ${isAudit ? "audit-page-shell" : ""} ${isKnowledge ? "knowledge-page-shell" : ""} ${isKnowledgeEditor ? "knowledge-editor-shell" : ""} ${isPortals ? "portals-page-shell" : ""}`}>
|
||||
<ShellRouteContent settingsSection={settingsSection} openSettings={openSettingsRoute} chatScope={chatScope.scope} setChatScope={chatScope.setScope} chatCounters={chatScope.counters} chatScopeSwitcher={manager} route={route} data={data} selectedEmployeeId={selectedEmployeeId} selectedAgentId={selectedAgentId} selectedKnowledgeId={selectedKnowledgeId} selectedConversationId={selectedConversationId} selectedClientId={selectedClientId} openClient={openClientRoute} selectedChannelId={selectedChannelId} openChannel={openChannelRoute} selectedSupportPortalId={selectedSupportPortalId} portalSettingsSection={portalSettingsSection} openSupportPortal={openSupportPortalRoute} openPortalSettings={openPortalSettingsRoute} openConversation={openConversationRoute} openEmployee={openEmployee} openAgent={openAgentRoute} openKnowledge={openKnowledgeRoute} openKnowledgeEditor={openKnowledgeEditorRoute} onAgentLoaded={() => undefined} onChannelLoaded={() => undefined} reload={reload} setRoute={setRoute} user={user} onUserUpdated={onUserUpdated} onLogout={onLogout} onOpenSidebar={() => setSidebarExpanded(true)} onOrganizationCreated={onOrganizationCreated} />
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
<NotificationDrawer
|
||||
open={notifOpen}
|
||||
items={notifications}
|
||||
unreadCount={unreadCount}
|
||||
onClose={() => setNotifOpen(false)}
|
||||
onItemClick={onNotificationClick}
|
||||
onMarkAll={onMarkAll}
|
||||
/>
|
||||
<OnboardingOverlay />
|
||||
<OnboardingTour />
|
||||
<OnboardingLauncher />
|
||||
</div>
|
||||
<NotificationDrawer
|
||||
open={notifOpen}
|
||||
items={notifications}
|
||||
unreadCount={unreadCount}
|
||||
onClose={() => setNotifOpen(false)}
|
||||
onItemClick={onNotificationClick}
|
||||
onMarkAll={onMarkAll}
|
||||
/>
|
||||
</div>
|
||||
</OnboardingProvider>
|
||||
);
|
||||
}
|
||||
@@ -2,13 +2,12 @@ import { Dropdown } from "antd";
|
||||
import type { CSSProperties } from "react";
|
||||
|
||||
import type { RouteKey, SessionUser } from "../types";
|
||||
import type { SettingsSectionKey } from "../features/settings/sections";
|
||||
import { useResizableWidth } from "../shared/useResizableWidth";
|
||||
import { Icon, LogoIcon } from "../shared/icons";
|
||||
import { canCreateOrganization, defaultRoute, isManager } from "../auth/access";
|
||||
import type { TourTarget } from "../features/onboarding/steps";
|
||||
import type { DialogScope } from "../features/conversations/ConversationWorkspace";
|
||||
import { agentColorOf, groupColorOf, type ConversationCounters } from "../features/conversations/model";
|
||||
import { LaunchChecklist } from "./LaunchChecklist";
|
||||
import { SidebarUserMenu } from "./SidebarUserMenu";
|
||||
import { t } from "../i18n";
|
||||
|
||||
@@ -24,14 +23,17 @@ type SidebarLinkProps = {
|
||||
route: RouteKey;
|
||||
routeKey: RouteKey;
|
||||
setRoute: (route: RouteKey) => void;
|
||||
// Имя цели для тура онбординга: по нему «Показать где» находит пункт на экране.
|
||||
target?: TourTarget;
|
||||
};
|
||||
|
||||
function SidebarLink({ activeRoutes, badge, icon, label, route, routeKey, setRoute }: SidebarLinkProps) {
|
||||
function SidebarLink({ activeRoutes, badge, icon, label, route, routeKey, setRoute, target }: SidebarLinkProps) {
|
||||
const active = activeRoutes?.includes(route) ?? routeKey === route;
|
||||
return (
|
||||
<button
|
||||
className={`hub-nav-item ${active ? "is-active" : ""}`}
|
||||
type="button"
|
||||
data-onboarding-target={target}
|
||||
onClick={() => setRoute(routeKey)}
|
||||
>
|
||||
<Icon name={icon} />
|
||||
@@ -45,7 +47,6 @@ export function Sidebar({
|
||||
route,
|
||||
user,
|
||||
setRoute,
|
||||
openSettings,
|
||||
onLogout,
|
||||
onSwitchOrganization,
|
||||
waitingCount = 0,
|
||||
@@ -60,7 +61,6 @@ export function Sidebar({
|
||||
route: RouteKey;
|
||||
user: SessionUser;
|
||||
setRoute: (route: RouteKey) => void;
|
||||
openSettings: (section: SettingsSectionKey | null) => void;
|
||||
onLogout: () => void;
|
||||
onSwitchOrganization: (organizationPublicId: string) => void;
|
||||
waitingCount?: number;
|
||||
@@ -170,10 +170,10 @@ export function Sidebar({
|
||||
<nav className="hub-nav">
|
||||
<SidebarLink icon="message" label={t("common.chat")} badge={waitingCount > 0 ? String(waitingCount) : undefined} route={route} routeKey="chat" setRoute={setRoute} />
|
||||
<SidebarLink activeRoutes={["salesClients", "salesClientDetail"]} icon="user" label={t("common.contacts")} route={route} routeKey="salesClients" setRoute={setRoute} />
|
||||
<SidebarLink activeRoutes={["agents", "agentDetail"]} icon="robot" label={t("common.agents")} route={route} routeKey="agents" setRoute={setRoute} />
|
||||
<SidebarLink activeRoutes={["employees", "employeeDetail"]} icon="team" label={t("common.operators")} route={route} routeKey="employees" setRoute={setRoute} />
|
||||
<SidebarLink activeRoutes={["agents", "agentDetail"]} icon="robot" label={t("common.agents")} route={route} routeKey="agents" setRoute={setRoute} target="nav-agents" />
|
||||
<SidebarLink activeRoutes={["employees", "employeeDetail"]} icon="team" label={t("common.operators")} route={route} routeKey="employees" setRoute={setRoute} target="nav-employees" />
|
||||
<SidebarLink activeRoutes={["supportPortals", "supportPortalDetail", "supportPortalSettings"]} icon="globe" label={t("common.portals")} route={route} routeKey="supportPortals" setRoute={setRoute} />
|
||||
<SidebarLink activeRoutes={["knowledge", "knowledgeDetail", "knowledgeCreate", "knowledgeEdit", "knowledgeCategories", "knowledgeImport"]} icon="book" label={t("common.knowledge_base")} route={route} routeKey="knowledge" setRoute={setRoute} />
|
||||
<SidebarLink activeRoutes={["knowledge", "knowledgeDetail", "knowledgeCreate", "knowledgeEdit", "knowledgeCategories", "knowledgeImport"]} icon="book" label={t("common.knowledge_base")} route={route} routeKey="knowledge" setRoute={setRoute} target="nav-knowledge" />
|
||||
<SidebarLink activeRoutes={["settings", "administrationAudit"]} icon="settings" label={t("common.settings")} route={route} routeKey="settings" setRoute={setRoute} />
|
||||
</nav>
|
||||
) : route === "profile" ? (
|
||||
@@ -191,7 +191,6 @@ export function Sidebar({
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{manager && <LaunchChecklist user={user} setRoute={setRoute} openSettings={openSettings} />}
|
||||
<SidebarUserMenu user={user} route={route} setRoute={setRoute} onLogout={onLogout} unreadCount={unreadCount} onOpenNotifications={onOpenNotifications} />
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
.hub-shell {
|
||||
position: relative;
|
||||
height: 100vh;
|
||||
display: flex;
|
||||
overflow: hidden;
|
||||
@@ -670,131 +671,6 @@
|
||||
}
|
||||
|
||||
|
||||
/* --- Блок «Запуск» (SPEC-CHATBALLS-0031 §5, дизайн-базлайн v2 A1) --- */
|
||||
|
||||
.launch-checklist {
|
||||
flex: none;
|
||||
margin: 6px 10px 8px;
|
||||
padding: 12px 12px 10px;
|
||||
border: 1px solid var(--border-default);
|
||||
border-radius: 10px;
|
||||
background: var(--surface-card);
|
||||
}
|
||||
|
||||
.launch-checklist-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.launch-checklist-head strong {
|
||||
color: var(--text-heading);
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.launch-checklist-head small {
|
||||
margin-left: 7px;
|
||||
color: var(--text-tertiary);
|
||||
font-size: 11.5px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.launch-checklist-head button {
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
background: transparent;
|
||||
color: var(--text-disabled);
|
||||
font-size: 15px;
|
||||
line-height: 1;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.launch-checklist-head button:hover {
|
||||
background: var(--n-9);
|
||||
color: var(--text-body);
|
||||
}
|
||||
|
||||
.launch-checklist-progress {
|
||||
height: 3px;
|
||||
border-radius: 2px;
|
||||
background: var(--n-8);
|
||||
margin-bottom: 12px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.launch-checklist-progress i {
|
||||
display: block;
|
||||
height: 100%;
|
||||
background: var(--primary);
|
||||
}
|
||||
|
||||
.launch-checklist-steps {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.launch-checklist-step {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 9px;
|
||||
color: var(--text-tertiary);
|
||||
font-size: 12.5px;
|
||||
}
|
||||
|
||||
.launch-checklist-step.is-current {
|
||||
color: var(--text-heading);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.launch-checklist-step.is-done span:last-child {
|
||||
text-decoration: line-through;
|
||||
}
|
||||
|
||||
.launch-checklist-mark {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
flex: none;
|
||||
border: 1.5px solid var(--border-strong);
|
||||
border-radius: 50%;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.launch-checklist-step.is-done .launch-checklist-mark {
|
||||
border-color: var(--primary);
|
||||
background: var(--primary);
|
||||
}
|
||||
|
||||
.launch-checklist-action {
|
||||
margin-top: 11px;
|
||||
width: 100%;
|
||||
height: 30px;
|
||||
border: none;
|
||||
border-radius: 7px;
|
||||
background: var(--primary);
|
||||
color: #fff;
|
||||
font-size: 12.5px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
font-family: inherit;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.launch-checklist-action:hover {
|
||||
background: var(--primary-hover);
|
||||
}
|
||||
|
||||
/* Полоса установки демо-данных: пока worker наполняет организацию, человек
|
||||
видит причину пустых разделов, а не гадает. */
|
||||
.demo-install-banner {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -38,8 +38,7 @@
|
||||
.form-field.is-editable input:focus,
|
||||
.form-field.is-editable:focus-within textarea,
|
||||
.form-field.is-editable textarea:focus,
|
||||
.select-like:focus-within select,
|
||||
.select-like .select-box:focus-within {
|
||||
.select-like .select-box:focus-visible {
|
||||
border-color: var(--primary);
|
||||
}
|
||||
|
||||
@@ -68,58 +67,40 @@
|
||||
border-color: var(--n-8);
|
||||
}
|
||||
|
||||
.select-like {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.select-like select {
|
||||
width: 100%;
|
||||
height: 36px;
|
||||
padding: 0 32px 0 12px;
|
||||
cursor: pointer;
|
||||
color: var(--n-1);
|
||||
background: var(--n-10);
|
||||
border: 1px solid var(--n-7);
|
||||
border-radius: 8px;
|
||||
outline: none;
|
||||
appearance: none;
|
||||
font-size: 13.5px;
|
||||
line-height: normal;
|
||||
}
|
||||
|
||||
/* Шеврон по центру поля: 36px бокса минус 14px иконки. Отступ справа — те же
|
||||
12px внутреннего поля плюс 1px рамки, что и у шеврона внутри `.select-box`. */
|
||||
.select-like > svg {
|
||||
position: absolute;
|
||||
right: 13px;
|
||||
bottom: 11px;
|
||||
color: var(--n-4);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
/* Бокс селекта — для метки перед значением (точка группы на карточке агента) и
|
||||
для режима без прав правки. Рамку держит бокс, селект внутри без своей. */
|
||||
/* Бокс селекта — он же кнопка, открывающая меню приложения. Нативного
|
||||
`<select>` в проекте нет: список вариантов рисует `SelectMenu`, тот же, что
|
||||
у фильтров списков. */
|
||||
.select-like .select-box {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 7px;
|
||||
width: 100%;
|
||||
height: 36px;
|
||||
padding: 0 12px;
|
||||
color: var(--n-1);
|
||||
background: var(--n-10);
|
||||
border: 1px solid var(--n-7);
|
||||
border-radius: 8px;
|
||||
outline: none;
|
||||
font-family: inherit;
|
||||
font-size: 13.5px;
|
||||
line-height: normal;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.select-like .select-box select {
|
||||
flex: 1;
|
||||
height: 34px;
|
||||
padding: 0;
|
||||
color: inherit;
|
||||
background: transparent;
|
||||
border: 0;
|
||||
font-size: inherit;
|
||||
button.select-box {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
button.select-box:hover:not(:disabled) {
|
||||
border-color: var(--n-6);
|
||||
}
|
||||
|
||||
button.select-box:disabled {
|
||||
color: var(--n-4);
|
||||
background: var(--n-9);
|
||||
border-color: var(--n-8);
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.select-like .select-box > svg {
|
||||
@@ -136,7 +117,6 @@
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.select-like.is-invalid select,
|
||||
.select-like.is-invalid .select-box {
|
||||
color: var(--error-text);
|
||||
border-color: var(--error-border);
|
||||
@@ -146,19 +126,10 @@
|
||||
текст 15px. Это тоже лежало копиями в профиле и настройках. */
|
||||
@media (max-width: 900px) {
|
||||
.readonly-field input,
|
||||
.select-like select,
|
||||
.select-like .select-box {
|
||||
height: 44px;
|
||||
font-size: 15px;
|
||||
}
|
||||
|
||||
.select-like .select-box select {
|
||||
height: 42px;
|
||||
}
|
||||
|
||||
.select-like > svg {
|
||||
bottom: 15px;
|
||||
}
|
||||
}
|
||||
|
||||
/* Единый переключатель приложения. Раньше в проекте было четыре его копии
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { ReactNode } from "react";
|
||||
import { useRef, useState, type ReactNode } from "react";
|
||||
|
||||
import { Icon } from "./icons";
|
||||
import { SelectMenu } from "./ui-controls";
|
||||
|
||||
type FormFieldProps = {
|
||||
disabled?: boolean;
|
||||
@@ -61,26 +62,58 @@ type SelectFieldProps = {
|
||||
wide?: boolean;
|
||||
};
|
||||
|
||||
/** Селект приложения — один на всё: подпись, бокс поля и шеврон. Точку перед
|
||||
* значением и режим без прав правки держит `.select-box`: рамка тогда на нём,
|
||||
* а сам селект внутри без рамки и фона. */
|
||||
/** Селект приложения — один на всё: подпись, бокс поля и шеврон. Список
|
||||
* вариантов рисует общий `SelectMenu`, тот же, что у фильтров списков:
|
||||
* нативного `<select>` в проекте нет нигде, иначе вместо меню приложения
|
||||
* открывался бы список операционной системы. */
|
||||
export function SelectField({ adornment, disabled = false, invalid = false, label, onChange, options, readOnly = false, readOnlyText, value, wide = false }: SelectFieldProps) {
|
||||
const className = ["readonly-field", "select-like", invalid ? "is-invalid" : "", readOnly ? "is-readonly" : "", wide ? "wide" : ""].filter(Boolean).join(" ");
|
||||
const control = readOnly
|
||||
? <span className="select-value">{readOnlyText ?? options.find(([optionValue]) => optionValue === value)?.[1] ?? ""}</span>
|
||||
: (
|
||||
<select disabled={disabled} value={value} onChange={(event) => onChange(event.target.value)}>
|
||||
{options.map(([optionValue, labelText]) => <option value={optionValue} key={optionValue}>{labelText}</option>)}
|
||||
</select>
|
||||
);
|
||||
const [open, setOpen] = useState(false);
|
||||
const [menuWidth, setMenuWidth] = useState<number>();
|
||||
const trigger = useRef<HTMLButtonElement>(null);
|
||||
const className = ["readonly-field", "select-like", invalid ? "is-invalid" : "", readOnly ? "is-readonly" : "", disabled ? "is-disabled" : "", wide ? "wide" : ""].filter(Boolean).join(" ");
|
||||
const current = options.find(([optionValue]) => optionValue === value)?.[1] ?? "";
|
||||
const chevron = <Icon name="chevron" size={14} strokeWidth={2.2} />;
|
||||
const boxed = Boolean(adornment) || readOnly;
|
||||
|
||||
if (readOnly) {
|
||||
return (
|
||||
<div className={className}>
|
||||
<span>{label}</span>
|
||||
<span className="select-box">{adornment}<span className="select-value">{readOnlyText ?? current}</span>{chevron}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<label className={className}>
|
||||
<div className={className}>
|
||||
<span>{label}</span>
|
||||
{boxed ? <span className="select-box">{adornment}{control}{chevron}</span> : <>{control}{chevron}</>}
|
||||
</label>
|
||||
<SelectMenu
|
||||
disabled={disabled}
|
||||
onOpenChange={setOpen}
|
||||
onSelect={(next) => {
|
||||
onChange(next);
|
||||
setOpen(false);
|
||||
}}
|
||||
open={open}
|
||||
options={options.map(([optionValue, optionLabel]) => ({ value: optionValue, label: optionLabel }))}
|
||||
// Меню селекта равно ширине поля, а не фиксированным 216px фильтра.
|
||||
overlayClassName="app-dropdown is-field"
|
||||
overlayStyle={menuWidth ? { width: menuWidth } : undefined}
|
||||
selected={value ? [value] : []}
|
||||
>
|
||||
<button
|
||||
className="select-box"
|
||||
ref={trigger}
|
||||
type="button"
|
||||
aria-label={label}
|
||||
disabled={disabled}
|
||||
onClick={() => setMenuWidth(trigger.current?.offsetWidth)}
|
||||
>
|
||||
{adornment}
|
||||
<span className="select-value">{current}</span>
|
||||
{chevron}
|
||||
</button>
|
||||
</SelectMenu>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -25,6 +25,19 @@
|
||||
width: 216px;
|
||||
}
|
||||
|
||||
/* Меню селекта в форме: ширину задаёт поле, ширина приходит инлайном на
|
||||
оверлей — фиксированные 216px фильтра здесь были бы уже или шире поля. */
|
||||
.app-dropdown.is-field .ant-dropdown-menu {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
/* Текущее значение в одиночном селекте: подсветка вместо галочки. */
|
||||
.app-dropdown .ant-dropdown-menu-item button.is-selected {
|
||||
color: var(--primary-text);
|
||||
background: var(--primary-bg);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.app-dropdown.is-channels-menu .ant-dropdown-menu {
|
||||
width: 246px;
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Dropdown } from "antd";
|
||||
import { useEffect, useRef, useState, type ButtonHTMLAttributes, type ReactNode, type RefObject } from "react";
|
||||
import { useEffect, useRef, useState, type ButtonHTMLAttributes, type CSSProperties, type ReactElement, type ReactNode, type RefObject } from "react";
|
||||
|
||||
import { Icon } from "./icons";
|
||||
import { t } from "../i18n";
|
||||
@@ -132,28 +132,34 @@ export function SearchInput({ className = "", placeholder, value, onChange, inpu
|
||||
);
|
||||
}
|
||||
|
||||
export type FilterOption = { value: string; label: string; dot?: string };
|
||||
export type SelectOption = { value: string; label: string; dot?: string };
|
||||
|
||||
/** Фильтр-селект списка — один на всё приложение: кнопка с текущим значением и
|
||||
* шевроном, меню — общий `app-dropdown`. `multiple` включает галочки и счётчик
|
||||
* выбранных (кадры K1 «Контакты», E1 «Сотрудники»). */
|
||||
export function FilterDropdown({ caption, className = "", icon, label, options, selected, multiple = false, open, onOpenChange, onSelect }: {
|
||||
/** Приглушённая подпись перед значением: «Статус: Все» (кадры PT1/PT3). */
|
||||
caption?: string;
|
||||
className?: string;
|
||||
icon?: IconName;
|
||||
label: string;
|
||||
options: FilterOption[];
|
||||
selected: string[];
|
||||
/** Совместимое имя: фильтры списков звали вариант `FilterOption`. */
|
||||
export type FilterOption = SelectOption;
|
||||
|
||||
/** Выпадающий список приложения — один на все селекты: и на фильтры списков, и
|
||||
* на поля форм. Своей разметки у него нет, только меню в общем `app-dropdown`
|
||||
* и триггер, который передают снаружи: у фильтра это кнопка-пилюля, у поля
|
||||
* формы — бокс на всю ширину. Галочки включает `multiple`. Нативных `<select>`
|
||||
* в проекте нет — список вариантов всегда рисуем сами, иначе на месте меню
|
||||
* оказывается список операционной системы. */
|
||||
export function SelectMenu({ children, disabled = false, multiple = false, onOpenChange, onSelect, open, options, overlayClassName = "app-dropdown is-wide", overlayStyle, selected }: {
|
||||
children: ReactElement;
|
||||
disabled?: boolean;
|
||||
multiple?: boolean;
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
onSelect: (value: string) => void;
|
||||
open: boolean;
|
||||
options: SelectOption[];
|
||||
overlayClassName?: string;
|
||||
overlayStyle?: CSSProperties;
|
||||
selected: string[];
|
||||
}) {
|
||||
const items = options.map((option) => ({
|
||||
key: option.value,
|
||||
label: (
|
||||
<button
|
||||
className={!multiple && selected.includes(option.value) ? "is-selected" : ""}
|
||||
type="button"
|
||||
onClick={(event) => {
|
||||
if (multiple) event.stopPropagation();
|
||||
@@ -170,9 +176,39 @@ export function FilterDropdown({ caption, className = "", icon, label, options,
|
||||
</button>
|
||||
),
|
||||
}));
|
||||
return (
|
||||
<Dropdown
|
||||
menu={{ items }}
|
||||
open={disabled ? false : open}
|
||||
onOpenChange={onOpenChange}
|
||||
trigger={["click"]}
|
||||
disabled={disabled}
|
||||
overlayClassName={overlayClassName}
|
||||
overlayStyle={overlayStyle}
|
||||
>
|
||||
{children}
|
||||
</Dropdown>
|
||||
);
|
||||
}
|
||||
|
||||
/** Фильтр-селект списка: кнопка с текущим значением и шевроном. `multiple`
|
||||
* включает галочки и счётчик выбранных (кадры K1 «Контакты», E1 «Сотрудники»). */
|
||||
export function FilterDropdown({ caption, className = "", icon, label, options, selected, multiple = false, open, onOpenChange, onSelect }: {
|
||||
/** Приглушённая подпись перед значением: «Статус: Все» (кадры PT1/PT3). */
|
||||
caption?: string;
|
||||
className?: string;
|
||||
icon?: IconName;
|
||||
label: string;
|
||||
options: SelectOption[];
|
||||
selected: string[];
|
||||
multiple?: boolean;
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
onSelect: (value: string) => void;
|
||||
}) {
|
||||
const active = selected.length > 0;
|
||||
return (
|
||||
<Dropdown menu={{ items }} open={open} onOpenChange={onOpenChange} trigger={["click"]} overlayClassName="app-dropdown is-wide">
|
||||
<SelectMenu multiple={multiple} onOpenChange={onOpenChange} onSelect={onSelect} open={open} options={options} selected={selected}>
|
||||
<button className={`ui-filter-button ${active ? "is-active" : ""} ${className}`.trim()} type="button">
|
||||
{icon && <Icon name={icon} size={14} strokeWidth={2} />}
|
||||
{caption && <i className="ui-filter-caption">{caption}</i>}
|
||||
@@ -180,7 +216,7 @@ export function FilterDropdown({ caption, className = "", icon, label, options,
|
||||
{multiple && selected.length > 0 && <span>{selected.length}</span>}
|
||||
<Icon name="chevron" size={13} strokeWidth={2.2} />
|
||||
</button>
|
||||
</Dropdown>
|
||||
</SelectMenu>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -26,5 +26,6 @@
|
||||
@import "./features/administration/styles.css";
|
||||
@import "./features/organizations/styles.css";
|
||||
@import "./features/settings/styles.css";
|
||||
@import "./features/onboarding/styles.css";
|
||||
@import "./features/notifications/styles.css";
|
||||
@import "./shared/state.css";
|
||||
@@ -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,
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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="{"$preview":{"width":1440,"height":900},"theme":{"editor":"enum","options":["light","dark"],"default":"light","tsType":"\"light\" | \"dark\"","section":"Тема"},"accent":{"editor":"color","options":["#1677ff","#722ed1","#0f9b8e","#d4380d"],"default":"#1677ff","tsType":"string","section":"Тема"}}">
|
||||
class Component extends DCLogic {
|
||||
state = { screen: "chat", sec: "ai", open: true, view: "welcome", i: 0, done: {}, tour: null };
|
||||
|
||||
steps = [
|
||||
{
|
||||
key: "provider", num: 1, est: "3 мин", rail: "AI-провайдер",
|
||||
title: "Подключите AI-провайдера",
|
||||
lead: "Без провайдера агент не ответит ни одному клиенту. Ключ ваш: расходы, лимиты и токены считаются в системе.",
|
||||
path: ["Настройки", "AI-провайдер", "Добавить провайдера"],
|
||||
items: [
|
||||
{ n: 1, text: "В сайдбаре откройте «Настройки» и выберите раздел «AI-провайдер»." },
|
||||
{ n: 2, text: "Нажмите «Добавить провайдера»: OpenRouter, Custom (OpenAI-compatible) или локальная модель." },
|
||||
{ n: 3, text: "Вставьте API-ключ и укажите модель по умолчанию — например openai/gpt-4o-mini." },
|
||||
{ n: 4, text: "Нажмите «Проверить»: статус должен стать «Подключено»." }
|
||||
],
|
||||
tip: "Ключа ещё нет? Возьмите «Демо-провайдер без ключа» — он отвечает по знаниям агента, чтобы осмотреться, и меняется на настоящий в любой момент.",
|
||||
preview: { caption: "Настройки → AI-провайдер", rows: [{ label: "Провайдер", value: "OpenRouter" }, { label: "Секрет", value: "sk-or-••••••••" }, { label: "Модель", value: "openai/gpt-4o-mini" }], chip: "Подключено", chipOk: true },
|
||||
goLabel: "Открыть раздел",
|
||||
tour: { screen: "settings", sec: "ai", target: "settings-ai", title: "Настройки → AI-провайдер", text: "Раздел в субменю «Настроек». Здесь живут ключи организации: добавление, проверка и статус провайдера." }
|
||||
},
|
||||
{
|
||||
key: "agent", num: 2, est: "4 мин", rail: "AI-агент",
|
||||
title: "Создайте AI-агента",
|
||||
lead: "Агент — тот, кто говорит с клиентом: кто он, как отвечает, каким правилам подчиняется и сколько может потратить в день.",
|
||||
path: ["Агенты", "Создать агента"],
|
||||
items: [
|
||||
{ n: 1, text: "Откройте «Агенты» в сайдбаре и нажмите «Создать агента»." },
|
||||
{ n: 2, text: "Опишите роль и тон: кто он для клиента, что можно обещать, чего делать нельзя." },
|
||||
{ n: 3, text: "Выберите модель подключённого провайдера и дневной бюджет в долларах." },
|
||||
{ n: 4, text: "Прикрепите статьи базы знаний — агент отвечает только из них." },
|
||||
{ n: 5, text: "Переведите статус из «Черновик» в «Активен», иначе агент молчит." }
|
||||
],
|
||||
tip: "Если данных для ответа нет, агент честно это скажет и предложит позвать сотрудника — придумывать он не станет.",
|
||||
preview: { caption: "Агенты → Консультант", rows: [{ label: "Модель", value: "openai/gpt-4o-mini" }, { label: "Дневной бюджет", value: "$5.00" }, { label: "Знания", value: "12 статей" }], chip: "Активен", chipOk: true },
|
||||
goLabel: "Открыть раздел",
|
||||
tour: { screen: "chat", target: "nav-agents", title: "Раздел «Агенты»", text: "Список агентов организации. Карточка агента — это его роль, модель, бюджет и прикреплённые знания." }
|
||||
},
|
||||
{
|
||||
key: "knowledge", num: 3, est: "от 10 мин", rail: "База знаний",
|
||||
title: "Наполните базу знаний",
|
||||
lead: "Это единственный источник ответов агента. Поиск идёт по смыслу, поэтому пишите так, как спрашивают клиенты.",
|
||||
path: ["База знаний", "Создать статью"],
|
||||
items: [
|
||||
{ n: 1, text: "Откройте «База знаний» и создайте категории под темы обращений." },
|
||||
{ n: 2, text: "Добавьте статьи: вопрос в заголовке, короткий однозначный ответ в тексте." },
|
||||
{ n: 3, text: "Готовые материалы залейте через «Импорт» — файлами, без ручного переноса." },
|
||||
{ n: 4, text: "Проверьте на карточке агента, что статьи к нему прикреплены." }
|
||||
],
|
||||
tip: "Правка статьи меняет ответы бота сразу, и ту же статью читает публичный портал поддержки — держать два текста не нужно.",
|
||||
preview: { caption: "База знаний", rows: [{ label: "Категории", value: "Доставка · Оплата · Возврат" }, { label: "Статей", value: "12" }, { label: "Поиск", value: "Семантический" }], chip: "Прикреплено к агенту", chipOk: true },
|
||||
goLabel: "Открыть раздел",
|
||||
tour: { screen: "chat", target: "nav-knowledge", title: "Раздел «База знаний»", text: "Статьи, категории и импорт. Отсюда агент берёт ответы, отсюда же их читает портал поддержки." }
|
||||
},
|
||||
{
|
||||
key: "entry", num: 4, est: "5 мин", rail: "Точка входа",
|
||||
title: "Подключите точку входа",
|
||||
lead: "Точка входа — канал, по которому приходит клиент: Telegram, MAX, почта или чат на сайте. Без неё писать некуда.",
|
||||
path: ["Настройки", "Интеграции", "Добавить подключение"],
|
||||
items: [
|
||||
{ n: 1, text: "Настройки → «Интеграции» → «Добавить подключение»." },
|
||||
{ n: 2, text: "Для бота вставьте токен: Telegram — от BotFather, MAX — из кабинета разработчика." },
|
||||
{ n: 3, text: "Для почты укажите IMAP и SMTP ящика и пароль приложения." },
|
||||
{ n: 4, text: "Привяжите подключение к созданному агенту — иначе сообщения некому обрабатывать." },
|
||||
{ n: 5, text: "Нажмите «Проверить» и убедитесь в статусе «Подключено»." }
|
||||
],
|
||||
tip: "Сообщения из Telegram и MAX забирает фоновый рабочий. Если он остановлен, статус останется «Подключено», а переписки не появятся.",
|
||||
preview: { caption: "Настройки → Интеграции", rows: [{ label: "Подключение", value: "Telegram · @romashka_bot" }, { label: "Агент", value: "Консультант" }, { label: "Секрет", value: "78••••••••" }], chip: "Подключено", chipOk: true },
|
||||
goLabel: "Открыть раздел",
|
||||
tour: { screen: "settings", sec: "integrations", target: "settings-integrations", title: "Настройки → Интеграции", text: "Боты, почта и веб-виджет. Красная точка у пункта означает, что какое-то подключение отвалилось." }
|
||||
},
|
||||
{
|
||||
key: "widget", num: 5, est: "3 мин", rail: "Виджет на сайт",
|
||||
title: "Поставьте чат на сайт",
|
||||
lead: "Веб-виджет — один тег в вашем шаблоне. Чат открывается в изолированном окне поверх сайта.",
|
||||
path: ["Настройки", "Интеграции", "Web-виджет"],
|
||||
items: [
|
||||
{ n: 1, text: "Создайте подключение «Web-виджет» и оформите его: заголовок, акцент, приветствие." },
|
||||
{ n: 2, text: "Заполните разрешённые домены — пустой список запрещает все сайты." },
|
||||
{ n: 3, text: "Скопируйте сниппет и вставьте его перед закрывающим body на своём сайте." },
|
||||
{ n: 4, text: "Переведите виджет в статус «Опубликован» и обновите страницу сайта." }
|
||||
],
|
||||
tip: "Ключ в data-widget-key должен совпадать с ключом из настроек виджета — при несовпадении чат просто не появится.",
|
||||
preview: {
|
||||
caption: "Сниппет для сайта",
|
||||
code: "<" + "script src=\"https://support.romashka.ru/chat-widget.js\" data-widget-key=\"wk_7f3a…\" async><\/" + "script>",
|
||||
rows: [{ label: "Разрешённые домены", value: "romashka.ru, *.romashka.ru" }],
|
||||
chip: "Опубликован", chipOk: true
|
||||
},
|
||||
goLabel: "Открыть раздел",
|
||||
tour: { screen: "settings", sec: "integrations", target: "settings-integrations", title: "Настройки → Интеграции", text: "Виджет живёт среди подключений: там его ключ, разрешённые домены и готовый тег для вставки." }
|
||||
},
|
||||
{
|
||||
key: "team", num: 6, est: "4 мин", rail: "Сотрудники",
|
||||
title: "Пригласите команду",
|
||||
lead: "Сложные вопросы агент передаёт людям. Заведите тех, кому он будет их отдавать, и решите, кто что видит.",
|
||||
path: ["Сотрудники", "Пригласить"],
|
||||
items: [
|
||||
{ n: 1, text: "Откройте «Сотрудники» и отправьте приглашения по email." },
|
||||
{ n: 2, text: "Назначьте роли: владелец и администратор видят всё, сотрудник работает только в чате." },
|
||||
{ n: 3, text: "В Настройки → «Группы» распределите диалоги по группам видимости." },
|
||||
{ n: 4, text: "Предложите команде включить вход по коду (TOTP) в своём профиле." }
|
||||
],
|
||||
tip: "Приглашения уходят почтой. Пока не настроен SMTP (шаг 7), письма не дойдут — пароль придётся задавать вручную.",
|
||||
preview: { caption: "Сотрудники", rows: [{ label: "Приглашены", value: "3 человека" }, { label: "Группы", value: "Первая линия · Биллинг" }, { label: "Роли", value: "Админ · Сотрудник" }], chip: "Ожидают входа", chipOk: false },
|
||||
goLabel: "Открыть раздел",
|
||||
tour: { screen: "chat", target: "nav-employees", title: "Раздел «Сотрудники»", text: "Приглашения, роли и группы. Сотрудник видит диалоги своих групп и те, за которые отвечает." }
|
||||
},
|
||||
{
|
||||
key: "platform", num: 7, est: "5 мин", rail: "Домен и почта",
|
||||
title: "Укажите домен и почту",
|
||||
lead: "Пока установка открывается по IP — без HTTPS и без писем. Домен и SMTP превращают её в рабочий сервис.",
|
||||
path: ["Настройки", "Платформа"],
|
||||
items: [
|
||||
{ n: 1, text: "Настройки → «Платформа»: введите домен установки и переключитесь на https." },
|
||||
{ n: 2, text: "Убедитесь, что DNS домена смотрит на сервер, а порты 80 и 443 открыты снаружи." },
|
||||
{ n: 3, text: "Заполните исходящую почту: хост, порт, шифрование, логин и пароль приложения." },
|
||||
{ n: 4, text: "Нажмите «Отправить тестовое письмо» и проверьте, что оно пришло." }
|
||||
],
|
||||
tip: "Сертификат выдаётся по первому запросу к домену: откройте его в браузере и подождите несколько секунд.",
|
||||
preview: { caption: "Настройки → Платформа", rows: [{ label: "Адрес", value: "https://support.romashka.ru" }, { label: "SMTP", value: "smtp.yandex.ru : 465" }, { label: "Сертификат", value: "Let's Encrypt" }], chip: "HTTPS включён", chipOk: true },
|
||||
goLabel: "Открыть раздел",
|
||||
tour: { screen: "settings", sec: "platform", target: "settings-platform", title: "Настройки → Платформа", text: "Свойства самой установки: адрес, исходящая почта, обновления. Раздел виден администратору установки." }
|
||||
},
|
||||
{
|
||||
key: "test", num: 8, est: "2 мин", rail: "Проверка",
|
||||
title: "Напишите боту как клиент",
|
||||
lead: "Последний шаг — пройти путь клиента целиком: сообщение, ответ агента и передача на человека.",
|
||||
path: ["Чат", "Все диалоги"],
|
||||
items: [
|
||||
{ n: 1, text: "Напишите своему боту или в виджет на сайте вопрос из базы знаний." },
|
||||
{ n: 2, text: "Откройте «Чат»: диалог появится в списке с каналом, агентом и таймером ожидания." },
|
||||
{ n: 3, text: "Проверьте ответ агента — он должен опираться на вашу статью." },
|
||||
{ n: 4, text: "Нажмите «Взять диалог» и ответьте сами: так работает передача на оператора." }
|
||||
],
|
||||
tip: "Три режима диалога — отвечает AI, отвечает сотрудник, пауза — переключаются в шапке переписки в любой момент.",
|
||||
preview: { caption: "Чат → Все диалоги", rows: [{ label: "Диалог", value: "Анна Смирнова · Telegram" }, { label: "Агент", value: "Консультант" }, { label: "Режим", value: "Отвечает AI" }], chip: "Ответ за 4 сек", chipOk: true },
|
||||
goLabel: "Открыть чат",
|
||||
tour: { screen: "chat", target: "chat-list", title: "Список диалогов", text: "Все обращения организации в одном окне: канал, агент, время ожидания, метки и группа." }
|
||||
}
|
||||
];
|
||||
|
||||
componentDidMount() {
|
||||
this.apply();
|
||||
window.addEventListener("resize", this.onResize);
|
||||
}
|
||||
|
||||
componentDidUpdate() { this.apply(); }
|
||||
|
||||
componentWillUnmount() { window.removeEventListener("resize", this.onResize); }
|
||||
|
||||
onResize = () => { if (this.state.tour) this.measureTour(this.state.tour.cfg); };
|
||||
|
||||
measureTour(cfg) {
|
||||
if (!cfg) return;
|
||||
const el = document.querySelector('[data-target="' + cfg.target + '"]');
|
||||
const root = el ? el.closest("[data-ob-root]") : null;
|
||||
if (!el || !root) return;
|
||||
const r = el.getBoundingClientRect();
|
||||
const rr = root.getBoundingClientRect();
|
||||
this.setState({
|
||||
tour: {
|
||||
cfg: cfg,
|
||||
x: r.left - rr.left, y: r.top - rr.top, w: r.width, h: r.height,
|
||||
rootW: rr.width, rootH: rr.height,
|
||||
title: cfg.title, text: cfg.text
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
apply() {
|
||||
const root = document.documentElement;
|
||||
root.setAttribute("data-theme", (this.props.theme || "light") === "dark" ? "dark" : "light");
|
||||
root.style.setProperty("--accent", this.props.accent || "#1677ff");
|
||||
}
|
||||
|
||||
cur() { return this.steps[Math.min(this.state.i, this.steps.length - 1)]; }
|
||||
|
||||
goStep = (i) => this.setState({ i: i, view: "steps", tour: null });
|
||||
|
||||
next = () => {
|
||||
const cur = this.cur();
|
||||
const done = Object.assign({}, this.state.done);
|
||||
done[cur.key] = true;
|
||||
if (this.state.i >= this.steps.length - 1) this.setState({ done: done, view: "done", tour: null });
|
||||
else this.setState({ done: done, i: this.state.i + 1, tour: null });
|
||||
};
|
||||
|
||||
prev = () => {
|
||||
if (this.state.i === 0) this.setState({ view: "welcome", tour: null });
|
||||
else this.setState({ i: this.state.i - 1, tour: null });
|
||||
};
|
||||
|
||||
showTour = () => {
|
||||
const cfg = this.cur().tour;
|
||||
this.setState({ screen: cfg.screen, sec: cfg.sec || this.state.sec }, () => {
|
||||
window.requestAnimationFrame(() => this.measureTour(cfg));
|
||||
});
|
||||
};
|
||||
|
||||
renderVals() {
|
||||
const st = this.state;
|
||||
const cur = this.cur();
|
||||
const total = this.steps.length;
|
||||
const doneCount = this.steps.filter((s) => st.done[s.key]).length;
|
||||
const onChat = st.screen === "chat";
|
||||
const onSettings = st.screen === "settings";
|
||||
|
||||
const navItem = (active) => ({
|
||||
position: "relative", width: "100%", display: "flex", alignItems: "center", flex: "none", gap: "10px",
|
||||
height: "36px", padding: "0 10px", border: "none", borderRadius: "8px", fontSize: "13.5px", textAlign: "left",
|
||||
color: active ? "var(--primary-text)" : "var(--n-2)",
|
||||
background: active ? "var(--primary-bg)" : "transparent",
|
||||
fontWeight: active ? 600 : 500
|
||||
});
|
||||
const secItem = (key) => {
|
||||
const active = st.sec === key;
|
||||
return {
|
||||
display: "flex", alignItems: "center", gap: "10px", minHeight: "34px", padding: "0 10px",
|
||||
border: 0, borderRadius: "8px", fontSize: "13px", textAlign: "left",
|
||||
color: active ? "var(--primary-text)" : "var(--n-2)",
|
||||
background: active ? "var(--primary-bg)" : "transparent",
|
||||
fontWeight: active ? 600 : 500
|
||||
};
|
||||
};
|
||||
|
||||
const SEC = {
|
||||
organization: { heading: "Организация", lead: "Название, логотип и региональные параметры, которые видят клиенты.", action: "Сохранить", empty: "Данные организации" },
|
||||
groups: { heading: "Группы", lead: "Они распределяют диалоги между сотрудниками.", action: "Добавить группу", empty: "Групп пока нет" },
|
||||
ai: { heading: "AI-провайдер", lead: "Ключи организации, по которым отвечают агенты.", action: "Добавить провайдера", empty: "Провайдеров пока нет — добавьте OpenRouter, свой OpenAI-совместимый сервис или демо" },
|
||||
integrations: { heading: "Интеграции", lead: "Боты, почта и веб-виджет — точки входа клиентов.", action: "Добавить подключение", empty: "Подключений пока нет — добавьте бота, почту или веб-виджет" },
|
||||
communication: { heading: "Голосовые и звонки", lead: "Где клиент и сотрудник могут записать голосовое и позвонить.", action: "Сохранить", empty: "Матрица каналов" },
|
||||
storage: { heading: "Хранилище файлов", lead: "Вложения, голосовые, фото и логотипы.", action: "Сохранить", empty: "Файлы лежат в локальном томе" },
|
||||
platform: { heading: "Платформа", lead: "Свойства самой установки: по какому адресу она открывается и как отправляет почту.", action: "Сохранить", empty: "Адрес установки и исходящая почта" },
|
||||
demo: { heading: "Демо-данные", lead: "Посмотреть систему в работе на вымышленной организации.", action: "Установить", empty: "Демо-данные не установлены" }
|
||||
};
|
||||
const sec = SEC[st.sec] || SEC.ai;
|
||||
|
||||
const tour = st.tour;
|
||||
let spotStyle = null;
|
||||
let ringStyle = null;
|
||||
let calloutStyle = null;
|
||||
let scrimTop = null;
|
||||
let scrimBottom = null;
|
||||
let scrimLeft = null;
|
||||
let scrimRight = null;
|
||||
if (tour) {
|
||||
const pad = 6;
|
||||
const box = {
|
||||
position: "absolute", left: (tour.x - pad) + "px", top: (tour.y - pad) + "px",
|
||||
width: (tour.w + pad * 2) + "px", height: (tour.h + pad * 2) + "px",
|
||||
borderRadius: "11px", pointerEvents: "none", zIndex: 41
|
||||
};
|
||||
const shadeColor = (this.props.theme || "light") === "dark" ? "rgba(4, 5, 7, 0.68)" : "rgba(16, 18, 22, 0.55)";
|
||||
const shade = { position: "absolute", backgroundColor: shadeColor, pointerEvents: "none", zIndex: 40 };
|
||||
const t = Math.max(0, tour.y - pad);
|
||||
const b = tour.y + tour.h + pad;
|
||||
const l = Math.max(0, tour.x - pad);
|
||||
const r = tour.x + tour.w + pad;
|
||||
scrimTop = Object.assign({}, shade, { left: 0, top: 0, right: 0, height: t + "px" });
|
||||
scrimBottom = Object.assign({}, shade, { left: 0, top: b + "px", right: 0, bottom: 0 });
|
||||
scrimLeft = Object.assign({}, shade, { left: 0, top: t + "px", width: l + "px", height: (b - t) + "px" });
|
||||
scrimRight = Object.assign({}, shade, { left: r + "px", top: t + "px", right: 0, height: (b - t) + "px" });
|
||||
spotStyle = Object.assign({}, box, { boxShadow: "0 0 0 2px var(--primary)" });
|
||||
ringStyle = Object.assign({}, box, { border: "2px solid var(--primary)", animation: "ob-pulse 1.9s ease-out infinite" });
|
||||
const w = 344;
|
||||
const right = tour.x + tour.w + 20;
|
||||
const left = right + w > tour.rootW - 16 ? Math.max(16, tour.x - w - 20) : right;
|
||||
const top = Math.min(Math.max(16, tour.y - 14), Math.max(16, tour.rootH - 250));
|
||||
calloutStyle = { position: "absolute", left: left + "px", top: top + "px", width: w + "px", zIndex: 42 };
|
||||
}
|
||||
|
||||
const markStyle = (isDone, isCur) => ({
|
||||
width: "22px", height: "22px", flex: "none", display: "inline-flex", alignItems: "center",
|
||||
justifyContent: "center", borderRadius: "50%", fontSize: "11.5px", fontWeight: 700,
|
||||
color: isDone || isCur ? "#fff" : "var(--n-4)",
|
||||
background: isDone ? "var(--success)" : isCur ? "var(--primary)" : "var(--n-8)",
|
||||
border: "1px solid " + (isDone ? "var(--success)" : isCur ? "var(--primary)" : "var(--n-7)")
|
||||
});
|
||||
|
||||
return {
|
||||
onChat: onChat, onSettings: onSettings,
|
||||
navChatStyle: navItem(onChat), navSettingsStyle: navItem(onSettings),
|
||||
secOrgStyle: secItem("organization"), secGroupsStyle: secItem("groups"), secAiStyle: secItem("ai"),
|
||||
secIntStyle: secItem("integrations"), secCommStyle: secItem("communication"),
|
||||
secStorageStyle: secItem("storage"), secPlatformStyle: secItem("platform"), secDemoStyle: secItem("demo"),
|
||||
secHeading: sec.heading, secLead: sec.lead, secAction: sec.action, secEmpty: sec.empty,
|
||||
goChat: () => this.setState({ screen: "chat", tour: null }),
|
||||
goSettings: () => this.setState({ screen: "settings", tour: null }),
|
||||
|
||||
showWelcome: st.open && st.view === "welcome" && !tour,
|
||||
showSteps: st.open && st.view === "steps" && !tour,
|
||||
showDone: st.open && st.view === "done" && !tour,
|
||||
showLauncher: !st.open && !tour && doneCount < total,
|
||||
close: () => this.setState({ open: false, tour: null }),
|
||||
reopen: () => this.setState({ open: true, view: st.view === "welcome" ? "steps" : st.view, tour: null }),
|
||||
start: () => this.setState({ open: true, view: "steps", i: 0 }),
|
||||
next: this.next, prev: this.prev, showTour: this.showTour,
|
||||
closeTour: () => this.setState({ tour: null }),
|
||||
goSection: () => {
|
||||
const cfg = cur.tour;
|
||||
this.setState({ screen: cfg.screen, sec: cfg.sec || st.sec, open: false, tour: null });
|
||||
},
|
||||
|
||||
doneLabel: doneCount + "/" + total,
|
||||
progressLabel: "Готово " + doneCount + " из " + total,
|
||||
percentLabel: Math.round((doneCount / total) * 100) + "%",
|
||||
progressStyle: { display: "block", height: "100%", width: Math.round((doneCount / total) * 100) + "%", background: "var(--primary)", borderRadius: "999px", transition: "width 280ms ease" },
|
||||
|
||||
eyebrow: "Шаг " + cur.num + " из " + total + " · " + cur.est,
|
||||
curTitle: cur.title, curLead: cur.lead, curTip: cur.tip, curGoLabel: cur.goLabel,
|
||||
curItems: cur.items,
|
||||
curPath: cur.path.map((label, index) => ({ label: label, more: index < cur.path.length - 1 })),
|
||||
previewCaption: cur.preview.caption,
|
||||
previewRows: cur.preview.rows,
|
||||
previewChip: cur.preview.chip,
|
||||
previewChipOk: Boolean(cur.preview.chipOk),
|
||||
previewChipWait: !cur.preview.chipOk,
|
||||
previewCode: cur.preview.code || "",
|
||||
hasCode: Boolean(cur.preview.code),
|
||||
nextLabel: st.i === total - 1 ? "Завершить" : "Далее",
|
||||
|
||||
rail: this.steps.map((s, index) => {
|
||||
const isDone = Boolean(st.done[s.key]);
|
||||
const isCur = index === st.i;
|
||||
return {
|
||||
num: s.num, label: s.rail, est: s.est, isDone: isDone, showNum: !isDone,
|
||||
onClick: () => this.goStep(index),
|
||||
rowStyle: {
|
||||
display: "flex", alignItems: "center", gap: "11px", width: "100%", padding: "9px 11px",
|
||||
border: 0, borderRadius: "9px", textAlign: "left",
|
||||
background: isCur ? "var(--surface-card)" : "transparent",
|
||||
boxShadow: isCur ? "var(--shadow-sm)" : "none"
|
||||
},
|
||||
markStyle: markStyle(isDone, isCur),
|
||||
labelStyle: {
|
||||
flex: 1, minWidth: 0, fontSize: "13px",
|
||||
color: isCur ? "var(--n-1)" : isDone ? "var(--n-3)" : "var(--n-2)",
|
||||
fontWeight: isCur ? 700 : 500
|
||||
}
|
||||
};
|
||||
}),
|
||||
|
||||
doneSummary: this.steps.map((s) => {
|
||||
const isDone = Boolean(st.done[s.key]);
|
||||
return {
|
||||
label: s.rail, isDone: isDone,
|
||||
markStyle: {
|
||||
width: "20px", height: "20px", flex: "none", display: "inline-flex", alignItems: "center",
|
||||
justifyContent: "center", borderRadius: "50%", color: "#fff",
|
||||
background: isDone ? "var(--success)" : "var(--n-7)",
|
||||
border: "1px solid " + (isDone ? "var(--success)" : "var(--n-7)")
|
||||
},
|
||||
labelStyle: { flex: 1, minWidth: 0, color: isDone ? "var(--n-2)" : "var(--n-4)", fontSize: "13px", fontWeight: isDone ? 600 : 500 }
|
||||
};
|
||||
}),
|
||||
|
||||
tourOpen: Boolean(tour),
|
||||
tourTitle: tour ? tour.title : "",
|
||||
tourText: tour ? tour.text : "",
|
||||
tourBadge: tour ? "Шаг " + cur.num + " · где это находится" : "",
|
||||
spotStyle: spotStyle, ringStyle: ringStyle, calloutStyle: calloutStyle,
|
||||
scrimTop: scrimTop, scrimBottom: scrimBottom, scrimLeft: scrimLeft, scrimRight: scrimRight
|
||||
};
|
||||
}
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,31 +0,0 @@
|
||||
# Chatballs Design System
|
||||
|
||||
Статический справочник текущего frontend UI. React, сборка и установка зависимостей не требуются.
|
||||
|
||||
## Открытие в Windows PowerShell
|
||||
|
||||
Из корня репозитория:
|
||||
|
||||
```powershell
|
||||
Start-Process .\design\design-system\index.html
|
||||
```
|
||||
|
||||
Для просмотра через локальный HTTP-сервер:
|
||||
|
||||
```powershell
|
||||
python -m http.server 8080 --directory .\design\design-system
|
||||
Start-Process http://localhost:8080
|
||||
```
|
||||
|
||||
## Состав
|
||||
|
||||
- `index.html` — оглавление;
|
||||
- `foundations.html` — токены;
|
||||
- `components.html` — базовые controls;
|
||||
- `data-display.html` — статусы и представление данных;
|
||||
- `patterns.html` — таблицы, состояния, меню и dialogs;
|
||||
- `layouts.html` — типовые компоновки экранов;
|
||||
- `inventory.html` — поисковый реестр;
|
||||
- `inventory.json` — машиночитаемая инвентаризация.
|
||||
|
||||
Справочник автономен и не подключён к production bundle приложения.
|
||||
@@ -1,275 +0,0 @@
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
:root {
|
||||
--n-1: #1f1f1f;
|
||||
--n-2: #262626;
|
||||
--n-3: #595959;
|
||||
--n-4: #8c8c8c;
|
||||
--n-5: #bfbfbf;
|
||||
--n-6: #d9d9d9;
|
||||
--n-7: #e8e8e8;
|
||||
--n-8: #f0f0f0;
|
||||
--n-9: #f5f5f5;
|
||||
--n-10: #fafafa;
|
||||
--primary: #1677ff;
|
||||
--primary-hover: #0958d9;
|
||||
--primary-bg: #e6f4ff;
|
||||
--primary-border: #91caff;
|
||||
--success: #52c41a;
|
||||
--success-text: #389e0d;
|
||||
--success-bg: #f6ffed;
|
||||
--success-border: #b7eb8f;
|
||||
--warning-text: #d48806;
|
||||
--warning-bg: #fffbe6;
|
||||
--warning-bg-strong: #fff7e6;
|
||||
--warning-border: #ffe58f;
|
||||
--error-text: #cf1322;
|
||||
--error-bg: #fff2f0;
|
||||
--error-border: #ffccc7;
|
||||
--ai: #722ed1;
|
||||
--ai-bg: #f9f0ff;
|
||||
--ai-border: #e4d4fb;
|
||||
--text-heading: var(--n-1);
|
||||
--text-body: var(--n-2);
|
||||
--text-secondary: var(--n-3);
|
||||
--text-tertiary: var(--n-4);
|
||||
--text-disabled: var(--n-5);
|
||||
--border-default: var(--n-8);
|
||||
--border-input: var(--n-7);
|
||||
--radius-md: 8px;
|
||||
--radius-lg: 9px;
|
||||
--radius-xl: 10px;
|
||||
--radius-2xl: 12px;
|
||||
--radius-4xl: 14px;
|
||||
--radius-pill: 999px;
|
||||
--shadow-xs: 0 1px 2px rgba(0, 0, 0, 0.03);
|
||||
--shadow-sm: 0 1px 3px rgba(0, 0, 0, 0.04);
|
||||
--shadow-lg: 0 6px 20px rgba(0, 0, 0, 0.12);
|
||||
--font-sans: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
|
||||
--font-mono: "SF Mono", ui-monospace, Menlo, monospace;
|
||||
}
|
||||
|
||||
html,
|
||||
body {
|
||||
min-height: 100%;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
body {
|
||||
color: var(--text-body);
|
||||
background: #f0f2f5;
|
||||
font-family: var(--font-sans);
|
||||
-webkit-font-smoothing: antialiased;
|
||||
}
|
||||
|
||||
button,
|
||||
input,
|
||||
select,
|
||||
textarea {
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
button {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
button:focus-visible,
|
||||
a:focus-visible,
|
||||
input:focus-visible,
|
||||
select:focus-visible,
|
||||
textarea:focus-visible {
|
||||
outline: 2px solid var(--primary-border);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
button:disabled {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.55;
|
||||
}
|
||||
|
||||
svg.icon {
|
||||
width: 1em;
|
||||
height: 1em;
|
||||
fill: none;
|
||||
stroke: currentColor;
|
||||
stroke-width: 1.8;
|
||||
stroke-linecap: round;
|
||||
stroke-linejoin: round;
|
||||
flex: none;
|
||||
}
|
||||
|
||||
.ds-shell {
|
||||
min-height: 100vh;
|
||||
display: grid;
|
||||
grid-template-columns: 248px minmax(0, 1fr);
|
||||
}
|
||||
|
||||
.ds-sidebar {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: #fff;
|
||||
border-right: 1px solid var(--border-default);
|
||||
}
|
||||
|
||||
.ds-brand {
|
||||
height: 64px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 11px;
|
||||
padding: 0 18px;
|
||||
color: inherit;
|
||||
border-bottom: 1px solid var(--n-9);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.ds-brand-mark {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: #fff;
|
||||
background: var(--primary);
|
||||
border-radius: var(--radius-md);
|
||||
font-size: 15px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.ds-brand strong,
|
||||
.ds-brand small {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.ds-brand strong {
|
||||
color: var(--text-heading);
|
||||
font-size: 14px;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.ds-brand small {
|
||||
margin-top: 3px;
|
||||
color: var(--text-tertiary);
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.ds-nav {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
.ds-nav-group {
|
||||
padding: 14px 12px 6px;
|
||||
color: var(--text-disabled);
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.06em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.ds-nav a {
|
||||
position: relative;
|
||||
min-height: 38px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 9px 12px;
|
||||
margin-bottom: 2px;
|
||||
color: var(--text-secondary);
|
||||
border-radius: var(--radius-md);
|
||||
font-size: 13.5px;
|
||||
font-weight: 500;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.ds-nav a:hover {
|
||||
color: var(--text-body);
|
||||
background: var(--n-9);
|
||||
}
|
||||
|
||||
.ds-nav a.is-active {
|
||||
color: var(--primary-hover);
|
||||
background: var(--primary-bg);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.ds-nav a.is-active::before {
|
||||
position: absolute;
|
||||
top: 8px;
|
||||
bottom: 8px;
|
||||
left: -12px;
|
||||
width: 3px;
|
||||
background: var(--primary);
|
||||
border-radius: 0 3px 3px 0;
|
||||
content: "";
|
||||
}
|
||||
|
||||
.ds-sidebar-footer {
|
||||
padding: 12px 18px;
|
||||
color: var(--text-tertiary);
|
||||
border-top: 1px solid var(--n-9);
|
||||
font-size: 11.5px;
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
.ds-main {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.ds-topbar {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 20;
|
||||
height: 64px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 0 28px;
|
||||
background: rgba(255, 255, 255, 0.96);
|
||||
border-bottom: 1px solid var(--border-default);
|
||||
backdrop-filter: blur(8px);
|
||||
}
|
||||
|
||||
.ds-breadcrumbs {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
color: var(--text-tertiary);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.ds-breadcrumbs strong {
|
||||
color: var(--text-body);
|
||||
}
|
||||
|
||||
.ds-version {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 5px 10px;
|
||||
color: var(--success-text);
|
||||
background: var(--success-bg);
|
||||
border: 1px solid var(--success-border);
|
||||
border-radius: 7px;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.ds-version::before {
|
||||
width: 7px;
|
||||
height: 7px;
|
||||
background: var(--success);
|
||||
border-radius: 50%;
|
||||
content: "";
|
||||
}
|
||||
|
||||
.ds-content {
|
||||
width: min(1240px, 100%);
|
||||
margin: 0 auto;
|
||||
padding: 30px 32px 56px;
|
||||
}
|
||||
@@ -1,179 +0,0 @@
|
||||
.primary-button,
|
||||
.secondary-button,
|
||||
.danger-outline,
|
||||
.ui-action-button {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 7px;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.primary-button {
|
||||
min-height: 38px;
|
||||
padding: 0 16px;
|
||||
color: #fff;
|
||||
background: var(--primary);
|
||||
border: 0;
|
||||
border-radius: var(--radius-lg);
|
||||
box-shadow: 0 1px 2px rgba(22, 119, 255, 0.3);
|
||||
font-size: 13.5px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.primary-button:hover {
|
||||
background: var(--primary-hover);
|
||||
}
|
||||
|
||||
.primary-button:disabled {
|
||||
color: var(--text-disabled);
|
||||
background: var(--n-8);
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.secondary-button,
|
||||
.danger-outline {
|
||||
height: 38px;
|
||||
padding: 0 16px;
|
||||
background: #fff;
|
||||
border-radius: var(--radius-lg);
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.secondary-button {
|
||||
color: var(--text-body);
|
||||
border: 1px solid var(--n-6);
|
||||
}
|
||||
|
||||
.secondary-button:hover,
|
||||
.ui-action-button:hover {
|
||||
color: var(--primary);
|
||||
border-color: var(--primary);
|
||||
}
|
||||
|
||||
.danger-outline {
|
||||
color: var(--error-text);
|
||||
border: 1px solid var(--error-border);
|
||||
}
|
||||
|
||||
.danger-outline:hover {
|
||||
background: var(--error-bg);
|
||||
}
|
||||
|
||||
.ui-action-button {
|
||||
height: 36px;
|
||||
padding: 0 14px;
|
||||
color: var(--text-body);
|
||||
background: #fff;
|
||||
border: 1px solid var(--n-6);
|
||||
border-radius: var(--radius-md);
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.ui-icon-button,
|
||||
.icon-button,
|
||||
.row-menu-button {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex: none;
|
||||
}
|
||||
|
||||
.ui-icon-button,
|
||||
.icon-button {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
color: var(--text-tertiary);
|
||||
background: #fff;
|
||||
border: 1px solid var(--n-6);
|
||||
border-radius: var(--radius-md);
|
||||
}
|
||||
|
||||
.ui-icon-button:hover,
|
||||
.icon-button:hover {
|
||||
color: var(--text-body);
|
||||
background: var(--n-10);
|
||||
}
|
||||
|
||||
.ui-icon-button.is-bare {
|
||||
background: transparent;
|
||||
border-color: transparent;
|
||||
}
|
||||
|
||||
.ui-icon-button.is-danger:hover {
|
||||
color: var(--error-text);
|
||||
background: var(--error-bg);
|
||||
border-color: var(--error-border);
|
||||
}
|
||||
|
||||
.icon-button {
|
||||
position: relative;
|
||||
color: var(--text-secondary);
|
||||
border-color: var(--n-8);
|
||||
}
|
||||
|
||||
.icon-button b {
|
||||
position: absolute;
|
||||
top: 5px;
|
||||
right: 5px;
|
||||
min-width: 15px;
|
||||
height: 15px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 0 4px;
|
||||
color: #fff;
|
||||
background: #ff4d4f;
|
||||
border: 1.5px solid #fff;
|
||||
border-radius: 8px;
|
||||
font-size: 9px;
|
||||
}
|
||||
|
||||
.link {
|
||||
display: inline;
|
||||
padding: 0;
|
||||
color: var(--primary);
|
||||
background: none;
|
||||
border: 0;
|
||||
font: inherit;
|
||||
font-weight: 600;
|
||||
text-align: left;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.link:hover {
|
||||
color: var(--primary-hover);
|
||||
}
|
||||
|
||||
.link.is-strong {
|
||||
display: block;
|
||||
font-size: 14px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.link.is-neutral {
|
||||
color: var(--text-heading);
|
||||
}
|
||||
|
||||
.link.is-neutral:hover,
|
||||
.link.is-muted:hover {
|
||||
color: var(--primary);
|
||||
}
|
||||
|
||||
.link.is-muted {
|
||||
color: var(--text-tertiary);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.link.is-mono {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 12.5px;
|
||||
}
|
||||
|
||||
.link.has-icon {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
@@ -1,225 +0,0 @@
|
||||
.readonly-field {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.readonly-field > span {
|
||||
display: block;
|
||||
margin-bottom: 6px;
|
||||
color: var(--text-secondary);
|
||||
font-size: 12.5px;
|
||||
font-weight: 600;
|
||||
line-height: normal;
|
||||
}
|
||||
|
||||
.readonly-field input,
|
||||
.readonly-field textarea,
|
||||
.select-like select,
|
||||
.search-control {
|
||||
width: 100%;
|
||||
color: var(--text-body);
|
||||
background: #fff;
|
||||
border: 1px solid var(--border-input);
|
||||
border-radius: var(--radius-md);
|
||||
outline: none;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.search-control {
|
||||
height: 38px;
|
||||
padding: 0 12px;
|
||||
}
|
||||
|
||||
/* Поле формы — дизайн-базлайн v2 (кадры G3, N1/N6, P1): 36px на подложке
|
||||
--n-10, текст 13.5px. Один скин на всё приложение. */
|
||||
.readonly-field input,
|
||||
.select-like select {
|
||||
height: 36px;
|
||||
padding: 0 12px;
|
||||
color: var(--n-1);
|
||||
background: var(--n-10);
|
||||
font-size: 13.5px;
|
||||
}
|
||||
|
||||
.readonly-field textarea {
|
||||
min-height: 92px;
|
||||
padding: 10px 12px;
|
||||
resize: vertical;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.form-field.is-editable:focus-within input,
|
||||
.form-field.is-editable:focus-within textarea,
|
||||
.select-like:focus-within select,
|
||||
.select-like .select-box:focus-within,
|
||||
.search-control:focus-within {
|
||||
border-color: var(--primary);
|
||||
}
|
||||
|
||||
.form-field.is-readonly input,
|
||||
.readonly-field input:disabled {
|
||||
color: var(--text-tertiary);
|
||||
background: var(--n-10);
|
||||
border-color: var(--n-8);
|
||||
}
|
||||
|
||||
.form-field.is-invalid input {
|
||||
border-color: #ff7875;
|
||||
}
|
||||
|
||||
.form-field-error {
|
||||
display: block;
|
||||
margin-top: 5px;
|
||||
color: var(--error-text);
|
||||
font-size: 11.5px;
|
||||
}
|
||||
|
||||
.select-like {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.select-like select {
|
||||
padding-right: 32px;
|
||||
appearance: none;
|
||||
}
|
||||
|
||||
.select-like > .select-chevron {
|
||||
position: absolute;
|
||||
right: 13px;
|
||||
bottom: 11px;
|
||||
color: var(--text-tertiary);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
/* Бокс селекта — для метки перед значением (точка группы на карточке агента) и
|
||||
для режима без прав правки. Рамку держит бокс, селект внутри без своей. */
|
||||
.select-like .select-box {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 7px;
|
||||
height: 36px;
|
||||
padding: 0 12px;
|
||||
color: var(--n-1);
|
||||
background: var(--n-10);
|
||||
border: 1px solid var(--border-input);
|
||||
border-radius: var(--radius-md);
|
||||
font-size: 13.5px;
|
||||
}
|
||||
|
||||
.select-like .select-box select {
|
||||
flex: 1;
|
||||
height: 34px;
|
||||
padding: 0;
|
||||
color: inherit;
|
||||
background: transparent;
|
||||
border: 0;
|
||||
font-size: inherit;
|
||||
}
|
||||
|
||||
.select-like .select-box > .select-chevron {
|
||||
flex: none;
|
||||
color: var(--text-tertiary);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
/* Пример метки перед значением: цветная точка группы. */
|
||||
.select-like .select-dot {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
flex: none;
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
.select-like .select-value {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
white-space: nowrap;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.select-like.is-invalid select,
|
||||
.select-like.is-invalid .select-box {
|
||||
color: var(--error-text);
|
||||
border-color: var(--error-border);
|
||||
}
|
||||
|
||||
.search-control {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
color: var(--text-disabled);
|
||||
}
|
||||
|
||||
.search-control input {
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
padding: 0;
|
||||
background: transparent;
|
||||
border: 0;
|
||||
outline: 0;
|
||||
}
|
||||
|
||||
.ui-switch {
|
||||
width: 42px;
|
||||
height: 24px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-start;
|
||||
flex: none;
|
||||
padding: 2px;
|
||||
background: var(--n-6);
|
||||
border: 0;
|
||||
border-radius: 14px;
|
||||
transition: background 0.15s;
|
||||
}
|
||||
|
||||
.ui-switch.on {
|
||||
justify-content: flex-end;
|
||||
background: var(--primary);
|
||||
}
|
||||
|
||||
.ui-switch i {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
display: block;
|
||||
background: #fff;
|
||||
border-radius: 50%;
|
||||
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
|
||||
.ui-switch.is-large {
|
||||
width: 46px;
|
||||
height: 26px;
|
||||
}
|
||||
|
||||
.ui-switch.is-large i {
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
}
|
||||
|
||||
.segmented {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 3px;
|
||||
padding: 3px;
|
||||
background: #f0f2f5;
|
||||
border-radius: var(--radius-md);
|
||||
}
|
||||
|
||||
.segmented button {
|
||||
height: 30px;
|
||||
padding: 0 13px;
|
||||
color: var(--text-secondary);
|
||||
background: transparent;
|
||||
border: 0;
|
||||
border-radius: 6px;
|
||||
font-size: 12.5px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.segmented button.active {
|
||||
color: var(--text-heading);
|
||||
background: #fff;
|
||||
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.1);
|
||||
font-weight: 600;
|
||||
}
|
||||
@@ -1,2 +0,0 @@
|
||||
@import "./component-actions.css";
|
||||
@import "./component-forms.css";
|
||||
@@ -1,260 +0,0 @@
|
||||
.ui-underline-tabs {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 20px;
|
||||
border-bottom: 1px solid var(--border-default);
|
||||
}
|
||||
|
||||
.ui-underline-tabs button {
|
||||
position: relative;
|
||||
min-height: 42px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 0;
|
||||
color: var(--text-tertiary);
|
||||
background: transparent;
|
||||
border: 0;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.ui-underline-tabs button.active {
|
||||
color: var(--text-body);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.ui-underline-tabs button.active::after {
|
||||
position: absolute;
|
||||
right: 0;
|
||||
bottom: -1px;
|
||||
left: 0;
|
||||
height: 2px;
|
||||
background: var(--primary);
|
||||
content: "";
|
||||
}
|
||||
|
||||
.ui-underline-tabs b {
|
||||
min-width: 20px;
|
||||
padding: 2px 6px;
|
||||
color: var(--text-tertiary);
|
||||
background: var(--n-9);
|
||||
border-radius: 10px;
|
||||
font-size: 10px;
|
||||
}
|
||||
|
||||
.product-tag,
|
||||
.product-mark,
|
||||
.channel-badge,
|
||||
.status-pill,
|
||||
.role-badge,
|
||||
.ui-tone-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.product-tag {
|
||||
gap: 6px;
|
||||
padding: 2px 9px;
|
||||
border-radius: var(--radius-pill);
|
||||
font-size: 11.5px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.product-tag i,
|
||||
.product-mark i {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
.product-mark {
|
||||
gap: 5px;
|
||||
}
|
||||
|
||||
.channel-badge {
|
||||
gap: 4px;
|
||||
padding: 2px 7px;
|
||||
border-radius: 5px;
|
||||
font-size: 10.5px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.channel-badge i {
|
||||
width: 5px;
|
||||
height: 5px;
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
.channel-badge.is-plain {
|
||||
color: var(--text-secondary);
|
||||
background: var(--n-9);
|
||||
}
|
||||
|
||||
.role-badge {
|
||||
padding: 2px 8px;
|
||||
border-radius: 6px;
|
||||
font-size: 11.5px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.role-badge.owner {
|
||||
color: var(--text-secondary);
|
||||
background: var(--n-8);
|
||||
}
|
||||
|
||||
.role-badge.admin {
|
||||
color: #ad4e00;
|
||||
background: var(--warning-bg-strong);
|
||||
}
|
||||
|
||||
.role-badge.employee {
|
||||
color: var(--primary-hover);
|
||||
background: var(--primary-bg);
|
||||
}
|
||||
|
||||
.status-pill {
|
||||
gap: 6px;
|
||||
padding: 2px 8px;
|
||||
border: 1px solid transparent;
|
||||
border-radius: 6px;
|
||||
font-size: 12.5px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.status-pill i {
|
||||
width: 7px;
|
||||
height: 7px;
|
||||
background: currentColor;
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
.status-pill.is-active,
|
||||
.status-pill.is-healthy,
|
||||
.status-pill.is-published {
|
||||
color: var(--success-text);
|
||||
}
|
||||
|
||||
.status-pill.is-healthy,
|
||||
.status-pill.is-published {
|
||||
background: var(--success-bg);
|
||||
border-color: var(--success-border);
|
||||
}
|
||||
|
||||
.status-pill.is-error {
|
||||
color: var(--error-text);
|
||||
background: var(--error-bg);
|
||||
border-color: var(--error-border);
|
||||
}
|
||||
|
||||
.status-pill.is-pending {
|
||||
color: var(--warning-text);
|
||||
background: var(--warning-bg);
|
||||
border-color: var(--warning-border);
|
||||
}
|
||||
|
||||
.status-pill.is-archived,
|
||||
.status-pill.is-draft,
|
||||
.status-pill.is-unchecked {
|
||||
color: var(--text-tertiary);
|
||||
background: var(--n-10);
|
||||
border-color: var(--n-7);
|
||||
}
|
||||
|
||||
.status-pill.is-blocked {
|
||||
color: var(--error-text);
|
||||
}
|
||||
|
||||
.status-pill.is-disabled {
|
||||
color: var(--warning-text);
|
||||
}
|
||||
|
||||
.status-pill.is-invited {
|
||||
color: var(--primary-hover);
|
||||
}
|
||||
|
||||
.avatar {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex: none;
|
||||
color: #fff;
|
||||
background: var(--primary);
|
||||
border-radius: 50%;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.key-value,
|
||||
.metric-box {
|
||||
display: grid;
|
||||
gap: 5px;
|
||||
}
|
||||
|
||||
.key-value span,
|
||||
.metric-box span {
|
||||
color: var(--text-tertiary);
|
||||
font-size: 11.5px;
|
||||
}
|
||||
|
||||
.key-value strong,
|
||||
.metric-box strong {
|
||||
color: var(--text-body);
|
||||
font-size: 13.5px;
|
||||
}
|
||||
|
||||
.metric-box {
|
||||
min-width: 150px;
|
||||
padding: 14px 16px;
|
||||
background: #fff;
|
||||
border: 1px solid var(--border-default);
|
||||
border-radius: var(--radius-xl);
|
||||
}
|
||||
|
||||
.metric-box strong {
|
||||
font-size: 22px;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.ui-table-pagination {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 18px;
|
||||
}
|
||||
|
||||
.ui-table-pagination > span {
|
||||
color: var(--text-tertiary);
|
||||
font-size: 12.5px;
|
||||
}
|
||||
|
||||
.ui-table-pagination nav {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.ui-table-pagination button {
|
||||
min-width: 30px;
|
||||
height: 30px;
|
||||
padding: 0 9px;
|
||||
color: var(--text-secondary);
|
||||
background: #fff;
|
||||
border: 1px solid var(--n-7);
|
||||
border-radius: 7px;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.ui-table-pagination button.active {
|
||||
color: #fff;
|
||||
background: var(--primary);
|
||||
border-color: var(--primary);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.ui-table-pagination button:not(:disabled):hover {
|
||||
color: var(--primary);
|
||||
border-color: var(--primary);
|
||||
}
|
||||
@@ -1,153 +0,0 @@
|
||||
function installIconSprite() {
|
||||
if (!window.CHATBALLS_ICON_SPRITE || document.querySelector("[data-chatballs-icon-sprite]")) return;
|
||||
const container = document.createElement("div");
|
||||
container.hidden = true;
|
||||
container.dataset.chatballsIconSprite = "";
|
||||
container.innerHTML = window.CHATBALLS_ICON_SPRITE;
|
||||
document.body.prepend(container);
|
||||
}
|
||||
|
||||
const pages = [
|
||||
["index", "index.html", "Обзор", "grid"],
|
||||
["foundations", "foundations.html", "Токены", "box"],
|
||||
["components", "components.html", "Компоненты", "columns"],
|
||||
["data-display", "data-display.html", "Данные и статусы", "list"],
|
||||
["patterns", "patterns.html", "Паттерны", "settings"],
|
||||
["layouts", "layouts.html", "Макеты страниц", "grid"],
|
||||
["inventory", "inventory.html", "Инвентарь", "search"],
|
||||
];
|
||||
|
||||
function icon(name) {
|
||||
return `<svg class="icon" aria-hidden="true"><use href="#i-${name}"></use></svg>`;
|
||||
}
|
||||
|
||||
function buildSidebar() {
|
||||
const activePage = document.body.dataset.page || "index";
|
||||
const nav = pages.map(([key, href, label, iconName], index) => {
|
||||
const group = index === 0 ? '<div class="ds-nav-group">Система</div>' : index === 1 ? '<div class="ds-nav-group">Справочник</div>' : "";
|
||||
const active = key === activePage ? " is-active" : "";
|
||||
return `${group}<a class="${active.trim()}" href="${href}">${icon(iconName)}<span>${label}</span></a>`;
|
||||
}).join("");
|
||||
|
||||
const sidebar = document.querySelector("[data-ds-sidebar]");
|
||||
if (!sidebar) return;
|
||||
sidebar.innerHTML = `
|
||||
<a class="ds-brand" href="index.html">
|
||||
<span class="ds-brand-mark">C</span>
|
||||
<span><strong>Chatballs</strong><small>Design System</small></span>
|
||||
</a>
|
||||
<nav class="ds-nav" aria-label="Разделы дизайн-системы">${nav}</nav>
|
||||
<div class="ds-sidebar-footer">Статический каталог текущего frontend UI.<br>Источник: <code>apps/internal-ui/src</code></div>
|
||||
`;
|
||||
}
|
||||
|
||||
function activateInteractiveExamples() {
|
||||
document.querySelectorAll("[data-segmented]").forEach((group) => {
|
||||
group.addEventListener("click", (event) => {
|
||||
const button = event.target.closest("button");
|
||||
if (!button) return;
|
||||
group.querySelectorAll("button").forEach((item) => item.classList.toggle("active", item === button));
|
||||
});
|
||||
});
|
||||
|
||||
document.querySelectorAll("[data-tabs]").forEach((group) => {
|
||||
group.addEventListener("click", (event) => {
|
||||
const button = event.target.closest("button");
|
||||
if (!button || button.disabled) return;
|
||||
group.querySelectorAll("button").forEach((item) => item.classList.toggle("active", item === button));
|
||||
});
|
||||
});
|
||||
|
||||
document.querySelectorAll(".ui-switch").forEach((switchButton) => {
|
||||
switchButton.addEventListener("click", () => {
|
||||
if (switchButton.disabled) return;
|
||||
const checked = switchButton.classList.toggle("on");
|
||||
switchButton.setAttribute("aria-checked", String(checked));
|
||||
});
|
||||
});
|
||||
|
||||
document.querySelectorAll("[data-open-dialog]").forEach((button) => {
|
||||
button.addEventListener("click", () => {
|
||||
const dialog = document.getElementById(button.dataset.openDialog);
|
||||
dialog?.classList.add("is-open");
|
||||
});
|
||||
});
|
||||
|
||||
document.querySelectorAll("[data-close-dialog]").forEach((button) => {
|
||||
button.addEventListener("click", () => button.closest(".dialog-backdrop")?.classList.remove("is-open"));
|
||||
});
|
||||
|
||||
document.querySelectorAll(".dialog-backdrop").forEach((backdrop) => {
|
||||
backdrop.addEventListener("click", (event) => {
|
||||
if (event.target === backdrop) backdrop.classList.remove("is-open");
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function activateInventorySearch() {
|
||||
const input = document.querySelector("[data-inventory-search]");
|
||||
const rows = [...document.querySelectorAll("[data-inventory-row]")];
|
||||
const count = document.querySelector("[data-inventory-count]");
|
||||
if (!input || rows.length === 0) return;
|
||||
|
||||
const update = () => {
|
||||
const query = input.value.trim().toLowerCase();
|
||||
let visible = 0;
|
||||
rows.forEach((row) => {
|
||||
const matches = row.textContent.toLowerCase().includes(query);
|
||||
row.hidden = !matches;
|
||||
if (matches) visible += 1;
|
||||
});
|
||||
if (count) count.textContent = `Показано: ${visible}`;
|
||||
};
|
||||
|
||||
input.addEventListener("input", update);
|
||||
update();
|
||||
}
|
||||
|
||||
function addCopyButtons() {
|
||||
document.querySelectorAll("[data-copy]").forEach((button) => {
|
||||
button.addEventListener("click", async () => {
|
||||
const value = button.dataset.copy;
|
||||
if (!value) return;
|
||||
try {
|
||||
await navigator.clipboard.writeText(value);
|
||||
const original = button.textContent;
|
||||
button.textContent = "Скопировано";
|
||||
setTimeout(() => { button.textContent = original; }, 900);
|
||||
} catch {
|
||||
button.title = value;
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function renderInventory() {
|
||||
const body = document.querySelector("[data-inventory-body]");
|
||||
const payload = window.CHATBALLS_INVENTORY;
|
||||
if (!body || !payload) return;
|
||||
|
||||
body.innerHTML = payload.items.map((item) => {
|
||||
const name = item.documentedAt
|
||||
? `<a class="link is-neutral" href="${item.documentedAt}">${item.name}</a>`
|
||||
: item.name;
|
||||
return `<tr data-inventory-row>
|
||||
<td><strong>${name}</strong><small style="display:block;margin-top:3px;color:#bfbfbf">${item.visibility}</small></td>
|
||||
<td>${item.kind}</td>
|
||||
<td>${item.category}</td>
|
||||
<td><span class="ds-chip">${item.scope}</span></td>
|
||||
<td><code class="ds-code">${item.source}</code></td>
|
||||
<td>${item.variants}</td>
|
||||
</tr>`;
|
||||
}).join("");
|
||||
|
||||
const total = document.querySelector("[data-inventory-total]");
|
||||
if (total) total.textContent = String(payload.count);
|
||||
}
|
||||
|
||||
installIconSprite();
|
||||
buildSidebar();
|
||||
renderInventory();
|
||||
activateInteractiveExamples();
|
||||
activateInventorySearch();
|
||||
addCopyButtons();
|
||||
@@ -1,227 +0,0 @@
|
||||
.ds-page-header {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 24px;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.ds-page-header h1 {
|
||||
margin: 0;
|
||||
color: var(--text-heading);
|
||||
font-size: 28px;
|
||||
line-height: 1.2;
|
||||
letter-spacing: -0.025em;
|
||||
}
|
||||
|
||||
.ds-page-header p {
|
||||
max-width: 760px;
|
||||
margin: 8px 0 0;
|
||||
color: var(--text-tertiary);
|
||||
font-size: 14px;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.ds-eyebrow {
|
||||
margin-bottom: 8px;
|
||||
color: var(--primary);
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.ds-section {
|
||||
margin-top: 28px;
|
||||
}
|
||||
|
||||
.ds-section-heading {
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
justify-content: space-between;
|
||||
gap: 20px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.ds-section-heading h2 {
|
||||
margin: 0;
|
||||
color: var(--text-heading);
|
||||
font-size: 19px;
|
||||
line-height: 1.25;
|
||||
}
|
||||
|
||||
.ds-section-heading p {
|
||||
margin: 4px 0 0;
|
||||
color: var(--text-tertiary);
|
||||
font-size: 12.5px;
|
||||
}
|
||||
|
||||
.ds-source {
|
||||
color: var(--text-disabled);
|
||||
font-family: var(--font-mono);
|
||||
font-size: 11px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.ds-grid {
|
||||
display: grid;
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.ds-grid.cols-2 {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.ds-grid.cols-3 {
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.ds-grid.cols-4 {
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.ds-card,
|
||||
.ds-specimen {
|
||||
background: #fff;
|
||||
border: 1px solid var(--border-default);
|
||||
border-radius: var(--radius-2xl);
|
||||
box-shadow: var(--shadow-xs);
|
||||
}
|
||||
|
||||
.ds-card {
|
||||
padding: 18px;
|
||||
}
|
||||
|
||||
.ds-card h3 {
|
||||
margin: 0 0 7px;
|
||||
color: var(--text-heading);
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.ds-card p {
|
||||
margin: 0;
|
||||
color: var(--text-tertiary);
|
||||
font-size: 12.5px;
|
||||
line-height: 1.55;
|
||||
}
|
||||
|
||||
.ds-card-link {
|
||||
min-height: 144px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
color: inherit;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.ds-card-link:hover {
|
||||
border-color: var(--primary-border);
|
||||
box-shadow: 0 4px 14px rgba(22, 119, 255, 0.08);
|
||||
}
|
||||
|
||||
.ds-card-link .ds-card-icon {
|
||||
width: 38px;
|
||||
height: 38px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin-bottom: 16px;
|
||||
color: var(--primary);
|
||||
background: var(--primary-bg);
|
||||
border-radius: var(--radius-xl);
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
.ds-card-link .ds-arrow {
|
||||
margin-top: auto;
|
||||
padding-top: 18px;
|
||||
color: var(--primary);
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.ds-specimen-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
padding: 12px 16px;
|
||||
border-bottom: 1px solid var(--border-default);
|
||||
}
|
||||
|
||||
.ds-specimen-header strong {
|
||||
color: var(--text-heading);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.ds-specimen-header code,
|
||||
.ds-code {
|
||||
color: var(--text-tertiary);
|
||||
font-family: var(--font-mono);
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.ds-specimen-body {
|
||||
min-height: 96px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 12px;
|
||||
padding: 22px;
|
||||
background: #fff;
|
||||
border-radius: 0 0 var(--radius-2xl) var(--radius-2xl);
|
||||
}
|
||||
|
||||
.ds-specimen-body.is-column {
|
||||
align-items: stretch;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.ds-note {
|
||||
padding: 13px 15px;
|
||||
color: var(--text-secondary);
|
||||
background: var(--primary-bg);
|
||||
border: 1px solid var(--primary-border);
|
||||
border-radius: var(--radius-xl);
|
||||
font-size: 12.5px;
|
||||
line-height: 1.55;
|
||||
}
|
||||
|
||||
.ds-note strong {
|
||||
color: var(--primary-hover);
|
||||
}
|
||||
|
||||
.ds-meta-list {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
margin: 14px 0 0;
|
||||
}
|
||||
|
||||
.ds-meta-row {
|
||||
display: grid;
|
||||
grid-template-columns: 130px minmax(0, 1fr);
|
||||
gap: 14px;
|
||||
padding-top: 8px;
|
||||
border-top: 1px solid var(--n-9);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.ds-meta-row span {
|
||||
color: var(--text-tertiary);
|
||||
}
|
||||
|
||||
.ds-meta-row code {
|
||||
color: var(--text-secondary);
|
||||
font-family: var(--font-mono);
|
||||
}
|
||||
|
||||
.ds-chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 4px 8px;
|
||||
color: var(--text-secondary);
|
||||
background: var(--n-9);
|
||||
border: 1px solid var(--n-8);
|
||||
border-radius: 6px;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 10.5px;
|
||||
}
|
||||
Loaded 100 of 117 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user