From bb7ed5e8f9a8deb39fffad1c1b9c75b7ad3ed2aa Mon Sep 17 00:00:00 2001 From: Andrey Date: Sat, 1 Aug 2026 00:37:24 +0300 Subject: [PATCH] fix(call): fix audio calls --- .../hub_platform/calls/event_handlers.py | 9 +- apps/backend/hub_platform/calls/lifecycle.py | 34 + .../hub_platform/calls/public_access.py | 8 +- .../backend/hub_platform/calls/public_urls.py | 1 + apps/backend/hub_platform/calls/services.py | 2 + apps/backend/hub_platform/calls/signaling.py | 32 +- .../hub_platform/calls/tests/test_api.py | 34 +- .../hub_platform/calls/tests/test_flow.py | 34 +- apps/backend/hub_platform/calls/views.py | 14 +- .../conversations/AudioCallOverlay.tsx | 37 +- .../features/conversations/CallOverlay.tsx | 11 +- .../conversations/ConversationWorkspace.tsx | 1 + .../conversations/VideoCallOverlay.tsx | 23 +- .../src/features/conversations/model.ts | 2 + .../conversations/useConversationCall.ts | 15 +- apps/web-chat/src/App.tsx | 2 +- apps/web-chat/src/api.ts | 18 +- apps/web-chat/src/call/AudioCallStage.tsx | 76 +- apps/web-chat/src/call/CallApp.tsx | 20 +- apps/web-chat/src/call/VideoCallStage.tsx | 65 +- apps/web-chat/src/call/model.test.ts | 39 +- apps/web-chat/src/call/model.ts | 14 +- apps/web-chat/src/styles.css | 5 + deploy/nginx/local.conf | 4 +- .../Edevs Hub Internal UI (Standalone).html | 212 - design/Edevs Hub Design System/SKILL.md | 9 - .../_adherence.oxlintrc.json | 508 --- design/Edevs Hub Design System/_ds_bundle.js | 3489 ----------------- .../Edevs Hub Design System/_ds_manifest.json | 1 - .../assets/favicon.svg | 4 - .../assets/logo-mark.svg | 4 - .../cards/CommerceStatusTimeline.d.ts | 8 - .../cards/CommerceStatusTimeline.jsx | 30 - .../cards/CommerceStatusTimeline.prompt.md | 8 - .../components/cards/DepartmentCard.d.ts | 18 - .../components/cards/DepartmentCard.jsx | 38 - .../components/cards/DepartmentCard.prompt.md | 9 - .../cards/IntegrationHealthCard.d.ts | 10 - .../cards/IntegrationHealthCard.jsx | 25 - .../cards/IntegrationHealthCard.prompt.md | 5 - .../components/cards/MetricCard.d.ts | 13 - .../components/cards/MetricCard.jsx | 15 - .../components/cards/MetricCard.prompt.md | 10 - .../cards/ProductAIReleaseCard.d.ts | 17 - .../components/cards/ProductAIReleaseCard.jsx | 39 - .../cards/ProductAIReleaseCard.prompt.md | 6 - .../components/cards/cards.card.html | 46 - .../components/core/Avatar.d.ts | 9 - .../components/core/Avatar.jsx | 24 - .../components/core/Avatar.prompt.md | 5 - .../components/core/Button.d.ts | 16 - .../components/core/Button.jsx | 67 - .../components/core/Button.prompt.md | 8 - .../components/core/Checkbox.d.ts | 8 - .../components/core/Checkbox.jsx | 38 - .../components/core/Checkbox.prompt.md | 5 - .../components/core/Icon.d.ts | 16 - .../components/core/Icon.jsx | 70 - .../components/core/Icon.prompt.md | 7 - .../components/core/IconButton.d.ts | 17 - .../components/core/IconButton.jsx | 56 - .../components/core/IconButton.prompt.md | 8 - .../components/core/Input.d.ts | 24 - .../components/core/Input.jsx | 56 - .../components/core/Input.prompt.md | 7 - .../components/core/Tag.d.ts | 15 - .../components/core/Tag.jsx | 34 - .../components/core/Tag.prompt.md | 6 - .../components/core/core.card.html | 77 - .../components/inbox/ConversationInbox.d.ts | 21 - .../components/inbox/ConversationInbox.jsx | 46 - .../inbox/ConversationInbox.prompt.md | 7 - .../inbox/KnowledgeDocumentEditor.d.ts | 16 - .../inbox/KnowledgeDocumentEditor.jsx | 34 - .../inbox/KnowledgeDocumentEditor.prompt.md | 5 - .../components/inbox/inbox.card.html | 33 - .../components/status/ActorBadge.d.ts | 11 - .../components/status/ActorBadge.jsx | 24 - .../components/status/ActorBadge.prompt.md | 6 - .../components/status/AttentionStatus.d.ts | 10 - .../components/status/AttentionStatus.jsx | 29 - .../status/AttentionStatus.prompt.md | 7 - .../components/status/ChannelBadge.d.ts | 9 - .../components/status/ChannelBadge.jsx | 19 - .../components/status/ChannelBadge.prompt.md | 5 - .../components/status/status.card.html | 48 - .../guidelines/colors-ai-channels.card.html | 14 - .../guidelines/colors-neutrals.card.html | 19 - .../guidelines/colors-primary.card.html | 19 - .../guidelines/colors-semantic.card.html | 11 - .../guidelines/logo-mark.card.html | 11 - .../guidelines/radii-shadows.card.html | 12 - .../guidelines/spacing-metric-grid.card.html | 12 - .../guidelines/spacing-scale.card.html | 15 - .../guidelines/type-mono.card.html | 10 - .../guidelines/type-scale.card.html | 12 - design/Edevs Hub Design System/readme.md | 216 - design/Edevs Hub Design System/styles.css | 4 - .../Edevs Hub Design System/tokens/colors.css | 88 - .../tokens/effects.css | 40 - .../tokens/spacing.css | 36 - .../tokens/typography.css | 42 - .../ui_kits/checkout/CheckoutWidget.jsx | 159 - .../ui_kits/checkout/index.html | 22 - .../ui_kits/internal-hub/AuthLoginPage.jsx | 39 - .../ui_kits/internal-hub/ClientsPage.jsx | 63 - .../internal-hub/CommandCenterPage.jsx | 64 - .../ui_kits/internal-hub/HubShell.jsx | 78 - .../ui_kits/internal-hub/index.html | 35 - .../ui_kits/web-chat/WebChatWidget.jsx | 180 - .../ui_kits/web-chat/index.html | 22 - design/baseline/Звонки/.thumbnail | Bin 6602 -> 0 bytes design/baseline/Звонки/image-slot.js | 1197 ------ design/baseline/Звонки/support.js | 1768 --------- design/baseline/Звонки/Экран звонка.dc.html | 410 -- design/design-system/README.md | 31 + design/design-system/assets/base.css | 275 ++ .../assets/component-actions.css | 179 + .../design-system/assets/component-forms.css | 161 + design/design-system/assets/components.css | 2 + design/design-system/assets/data.css | 260 ++ design/design-system/assets/design-system.js | 153 + design/design-system/assets/docs-layout.css | 227 ++ design/design-system/assets/docs-tokens.css | 106 + design/design-system/assets/docs.css | 2 + design/design-system/assets/icons-data.js | 1 + design/design-system/assets/icons.svg | 19 + design/design-system/assets/inventory-data.js | 1 + design/design-system/assets/layouts.css | 272 ++ design/design-system/assets/patterns.css | 282 ++ design/design-system/assets/responsive.css | 89 + design/design-system/components.html | 167 + design/design-system/data-display.html | 169 + design/design-system/foundations.html | 178 + design/design-system/index.html | 113 + design/design-system/inventory.html | 60 + design/design-system/inventory.json | 1 + design/design-system/layouts.html | 134 + design/design-system/patterns.html | 181 + packages/ui/src/call/AudioCallView.tsx | 14 + packages/ui/src/call/AudioCallWaveform.tsx | 2 +- packages/ui/src/call/audio-call.css | 3 + packages/ui/src/call/useCallRtcSession.ts | 147 +- 143 files changed, 3536 insertions(+), 10218 deletions(-) delete mode 100644 design/Edevs Hub Design System/Edevs Hub Internal UI (Standalone).html delete mode 100644 design/Edevs Hub Design System/SKILL.md delete mode 100644 design/Edevs Hub Design System/_adherence.oxlintrc.json delete mode 100644 design/Edevs Hub Design System/_ds_bundle.js delete mode 100644 design/Edevs Hub Design System/_ds_manifest.json delete mode 100644 design/Edevs Hub Design System/assets/favicon.svg delete mode 100644 design/Edevs Hub Design System/assets/logo-mark.svg delete mode 100644 design/Edevs Hub Design System/components/cards/CommerceStatusTimeline.d.ts delete mode 100644 design/Edevs Hub Design System/components/cards/CommerceStatusTimeline.jsx delete mode 100644 design/Edevs Hub Design System/components/cards/CommerceStatusTimeline.prompt.md delete mode 100644 design/Edevs Hub Design System/components/cards/DepartmentCard.d.ts delete mode 100644 design/Edevs Hub Design System/components/cards/DepartmentCard.jsx delete mode 100644 design/Edevs Hub Design System/components/cards/DepartmentCard.prompt.md delete mode 100644 design/Edevs Hub Design System/components/cards/IntegrationHealthCard.d.ts delete mode 100644 design/Edevs Hub Design System/components/cards/IntegrationHealthCard.jsx delete mode 100644 design/Edevs Hub Design System/components/cards/IntegrationHealthCard.prompt.md delete mode 100644 design/Edevs Hub Design System/components/cards/MetricCard.d.ts delete mode 100644 design/Edevs Hub Design System/components/cards/MetricCard.jsx delete mode 100644 design/Edevs Hub Design System/components/cards/MetricCard.prompt.md delete mode 100644 design/Edevs Hub Design System/components/cards/ProductAIReleaseCard.d.ts delete mode 100644 design/Edevs Hub Design System/components/cards/ProductAIReleaseCard.jsx delete mode 100644 design/Edevs Hub Design System/components/cards/ProductAIReleaseCard.prompt.md delete mode 100644 design/Edevs Hub Design System/components/cards/cards.card.html delete mode 100644 design/Edevs Hub Design System/components/core/Avatar.d.ts delete mode 100644 design/Edevs Hub Design System/components/core/Avatar.jsx delete mode 100644 design/Edevs Hub Design System/components/core/Avatar.prompt.md delete mode 100644 design/Edevs Hub Design System/components/core/Button.d.ts delete mode 100644 design/Edevs Hub Design System/components/core/Button.jsx delete mode 100644 design/Edevs Hub Design System/components/core/Button.prompt.md delete mode 100644 design/Edevs Hub Design System/components/core/Checkbox.d.ts delete mode 100644 design/Edevs Hub Design System/components/core/Checkbox.jsx delete mode 100644 design/Edevs Hub Design System/components/core/Checkbox.prompt.md delete mode 100644 design/Edevs Hub Design System/components/core/Icon.d.ts delete mode 100644 design/Edevs Hub Design System/components/core/Icon.jsx delete mode 100644 design/Edevs Hub Design System/components/core/Icon.prompt.md delete mode 100644 design/Edevs Hub Design System/components/core/IconButton.d.ts delete mode 100644 design/Edevs Hub Design System/components/core/IconButton.jsx delete mode 100644 design/Edevs Hub Design System/components/core/IconButton.prompt.md delete mode 100644 design/Edevs Hub Design System/components/core/Input.d.ts delete mode 100644 design/Edevs Hub Design System/components/core/Input.jsx delete mode 100644 design/Edevs Hub Design System/components/core/Input.prompt.md delete mode 100644 design/Edevs Hub Design System/components/core/Tag.d.ts delete mode 100644 design/Edevs Hub Design System/components/core/Tag.jsx delete mode 100644 design/Edevs Hub Design System/components/core/Tag.prompt.md delete mode 100644 design/Edevs Hub Design System/components/core/core.card.html delete mode 100644 design/Edevs Hub Design System/components/inbox/ConversationInbox.d.ts delete mode 100644 design/Edevs Hub Design System/components/inbox/ConversationInbox.jsx delete mode 100644 design/Edevs Hub Design System/components/inbox/ConversationInbox.prompt.md delete mode 100644 design/Edevs Hub Design System/components/inbox/KnowledgeDocumentEditor.d.ts delete mode 100644 design/Edevs Hub Design System/components/inbox/KnowledgeDocumentEditor.jsx delete mode 100644 design/Edevs Hub Design System/components/inbox/KnowledgeDocumentEditor.prompt.md delete mode 100644 design/Edevs Hub Design System/components/inbox/inbox.card.html delete mode 100644 design/Edevs Hub Design System/components/status/ActorBadge.d.ts delete mode 100644 design/Edevs Hub Design System/components/status/ActorBadge.jsx delete mode 100644 design/Edevs Hub Design System/components/status/ActorBadge.prompt.md delete mode 100644 design/Edevs Hub Design System/components/status/AttentionStatus.d.ts delete mode 100644 design/Edevs Hub Design System/components/status/AttentionStatus.jsx delete mode 100644 design/Edevs Hub Design System/components/status/AttentionStatus.prompt.md delete mode 100644 design/Edevs Hub Design System/components/status/ChannelBadge.d.ts delete mode 100644 design/Edevs Hub Design System/components/status/ChannelBadge.jsx delete mode 100644 design/Edevs Hub Design System/components/status/ChannelBadge.prompt.md delete mode 100644 design/Edevs Hub Design System/components/status/status.card.html delete mode 100644 design/Edevs Hub Design System/guidelines/colors-ai-channels.card.html delete mode 100644 design/Edevs Hub Design System/guidelines/colors-neutrals.card.html delete mode 100644 design/Edevs Hub Design System/guidelines/colors-primary.card.html delete mode 100644 design/Edevs Hub Design System/guidelines/colors-semantic.card.html delete mode 100644 design/Edevs Hub Design System/guidelines/logo-mark.card.html delete mode 100644 design/Edevs Hub Design System/guidelines/radii-shadows.card.html delete mode 100644 design/Edevs Hub Design System/guidelines/spacing-metric-grid.card.html delete mode 100644 design/Edevs Hub Design System/guidelines/spacing-scale.card.html delete mode 100644 design/Edevs Hub Design System/guidelines/type-mono.card.html delete mode 100644 design/Edevs Hub Design System/guidelines/type-scale.card.html delete mode 100644 design/Edevs Hub Design System/readme.md delete mode 100644 design/Edevs Hub Design System/styles.css delete mode 100644 design/Edevs Hub Design System/tokens/colors.css delete mode 100644 design/Edevs Hub Design System/tokens/effects.css delete mode 100644 design/Edevs Hub Design System/tokens/spacing.css delete mode 100644 design/Edevs Hub Design System/tokens/typography.css delete mode 100644 design/Edevs Hub Design System/ui_kits/checkout/CheckoutWidget.jsx delete mode 100644 design/Edevs Hub Design System/ui_kits/checkout/index.html delete mode 100644 design/Edevs Hub Design System/ui_kits/internal-hub/AuthLoginPage.jsx delete mode 100644 design/Edevs Hub Design System/ui_kits/internal-hub/ClientsPage.jsx delete mode 100644 design/Edevs Hub Design System/ui_kits/internal-hub/CommandCenterPage.jsx delete mode 100644 design/Edevs Hub Design System/ui_kits/internal-hub/HubShell.jsx delete mode 100644 design/Edevs Hub Design System/ui_kits/internal-hub/index.html delete mode 100644 design/Edevs Hub Design System/ui_kits/web-chat/WebChatWidget.jsx delete mode 100644 design/Edevs Hub Design System/ui_kits/web-chat/index.html delete mode 100644 design/baseline/Звонки/.thumbnail delete mode 100644 design/baseline/Звонки/image-slot.js delete mode 100644 design/baseline/Звонки/support.js delete mode 100644 design/baseline/Звонки/Экран звонка.dc.html create mode 100644 design/design-system/README.md create mode 100644 design/design-system/assets/base.css create mode 100644 design/design-system/assets/component-actions.css create mode 100644 design/design-system/assets/component-forms.css create mode 100644 design/design-system/assets/components.css create mode 100644 design/design-system/assets/data.css create mode 100644 design/design-system/assets/design-system.js create mode 100644 design/design-system/assets/docs-layout.css create mode 100644 design/design-system/assets/docs-tokens.css create mode 100644 design/design-system/assets/docs.css create mode 100644 design/design-system/assets/icons-data.js create mode 100644 design/design-system/assets/icons.svg create mode 100644 design/design-system/assets/inventory-data.js create mode 100644 design/design-system/assets/layouts.css create mode 100644 design/design-system/assets/patterns.css create mode 100644 design/design-system/assets/responsive.css create mode 100644 design/design-system/components.html create mode 100644 design/design-system/data-display.html create mode 100644 design/design-system/foundations.html create mode 100644 design/design-system/index.html create mode 100644 design/design-system/inventory.html create mode 100644 design/design-system/inventory.json create mode 100644 design/design-system/layouts.html create mode 100644 design/design-system/patterns.html diff --git a/apps/backend/hub_platform/calls/event_handlers.py b/apps/backend/hub_platform/calls/event_handlers.py index ddf1eb2..b3b0a97 100644 --- a/apps/backend/hub_platform/calls/event_handlers.py +++ b/apps/backend/hub_platform/calls/event_handlers.py @@ -20,9 +20,6 @@ from hub_platform.tenancy.context import TenantContext logger = logging.getLogger(__name__) -INVITE_MESSAGE_TEXT = "Приглашаем вас на онлайн-звонок. Нажмите кнопку, чтобы перейти к звонку." - - class CallInviteDeliveryError(Exception): pass @@ -52,12 +49,14 @@ def handle_call_invite_send(payload: dict, context: TenantContext | None) -> Non token, token_hash = issue_invite_token() invite.token_hash = token_hash invite.save(update_fields=["token_hash"]) - url = f"{settings.CUS_PUBLIC_BASE_URL.rstrip('/')}/calls/{token}" + call_label = "аудиозвонок" if call.kind == "AUDIO" else "видеозвонок" + invite_text = f"Приглашаем вас на {call_label}. Нажмите кнопку, чтобы перейти к звонку." + url = f"{settings.CUS_PUBLIC_BASE_URL.rstrip('/')}/calls/{token}?kind={call.kind}" sent = transports.send_call_invite( call.delivery_connection, chat_id=call.conversation.external_chat_id, user_id=invite.connection_identity.external_user_id, - text=INVITE_MESSAGE_TEXT, + text=invite_text, url=url, ) if not sent: diff --git a/apps/backend/hub_platform/calls/lifecycle.py b/apps/backend/hub_platform/calls/lifecycle.py index cbc19eb..a567943 100644 --- a/apps/backend/hub_platform/calls/lifecycle.py +++ b/apps/backend/hub_platform/calls/lifecycle.py @@ -9,6 +9,7 @@ from hub_platform.calls.models import ( CallEndedBy, CallSession, CallStatus, + ParticipantSide, ) from hub_platform.conversations.models import Message, MessageAuthor from hub_platform.subscriptions.reservation_service import release_usage @@ -119,3 +120,36 @@ def transition_call( text=timeline_text, ) return call + + +@transaction.atomic +def finish_call(*, call_session_id, side: str) -> CallSession: + """Надёжно завершает звонок из любой незавершённой фазы. + + Используется и REST access endpoint'ом, и WebSocket signaling, чтобы закрытие + вкладки/модального окна не оставляло ACCEPTED/CONNECTING звонок зависшим. + """ + # Блокировка не позволяет конкурентному signaling-событию перевести звонок + # между чтением статуса и завершающим переходом. + call = CallSession.objects.select_for_update().get(id=call_session_id) + if call.status in TERMINAL_CALL_STATUSES: + return call + ended_by = CallEndedBy.STAFF if side == ParticipantSide.STAFF else CallEndedBy.CUSTOMER + if call.status == CallStatus.ACTIVE: + target_status = CallStatus.ENDED + failure_code = "" + elif call.status in {CallStatus.ACCEPTED, CallStatus.CONNECTING}: + target_status = CallStatus.FAILED + failure_code = "ABORTED_BEFORE_CONNECT" + elif side == ParticipantSide.STAFF: + target_status = CallStatus.CANCELLED + failure_code = "" + else: + target_status = CallStatus.DECLINED + failure_code = "" + return transition_call( + call_session_id=call.id, + target_status=target_status, + ended_by=ended_by, + failure_code=failure_code, + ) diff --git a/apps/backend/hub_platform/calls/public_access.py b/apps/backend/hub_platform/calls/public_access.py index c0f931a..4fb22df 100644 --- a/apps/backend/hub_platform/calls/public_access.py +++ b/apps/backend/hub_platform/calls/public_access.py @@ -10,7 +10,7 @@ from hub_platform.calls.errors import ( CallInvalidTransition, CallTokenError, ) -from hub_platform.calls.lifecycle import transition_call +from hub_platform.calls.lifecycle import finish_call, transition_call from hub_platform.calls.models import ( TERMINAL_CALL_STATUSES, CallEndedBy, @@ -208,6 +208,12 @@ def decline_call_by_access_token(*, token: str) -> CallSession: raise CallConflict("Приглашение уже нельзя отклонить") from error +def end_call_by_access_token(*, token: str) -> CallSession: + claims, call, context = _authorize_call_access(token=token, allow_terminal=True) + with tenant_atomic(context): + return finish_call(call_session_id=call.id, side=claims.side) + + def call_state_by_access_token(*, token: str) -> CallSession: _claims, call, context = _authorize_call_access(token=token, allow_terminal=True) with tenant_atomic(context): diff --git a/apps/backend/hub_platform/calls/public_urls.py b/apps/backend/hub_platform/calls/public_urls.py index 62f93d9..9eb1605 100644 --- a/apps/backend/hub_platform/calls/public_urls.py +++ b/apps/backend/hub_platform/calls/public_urls.py @@ -7,4 +7,5 @@ urlpatterns = [ path("access/state/", views.CallAccessStateView.as_view(), name="call-access-state"), path("access/accept/", views.CallAccessAcceptView.as_view(), name="call-access-accept"), path("access/decline/", views.CallAccessDeclineView.as_view(), name="call-access-decline"), + path("access/end/", views.CallAccessEndView.as_view(), name="call-access-end"), ] diff --git a/apps/backend/hub_platform/calls/services.py b/apps/backend/hub_platform/calls/services.py index 81abcc6..ed68249 100644 --- a/apps/backend/hub_platform/calls/services.py +++ b/apps/backend/hub_platform/calls/services.py @@ -35,6 +35,7 @@ from hub_platform.calls.public_access import ( authorize_call_access_token, call_state_by_access_token, decline_call_by_access_token, + end_call_by_access_token, resolve_invite, ) from hub_platform.calls.tokens import ( @@ -62,6 +63,7 @@ __all__ = ( "authorize_call_access_token", "call_state_by_access_token", "decline_call_by_access_token", + "end_call_by_access_token", "resolve_invite", "record_call_metric", ) diff --git a/apps/backend/hub_platform/calls/signaling.py b/apps/backend/hub_platform/calls/signaling.py index 57b448e..218ab67 100644 --- a/apps/backend/hub_platform/calls/signaling.py +++ b/apps/backend/hub_platform/calls/signaling.py @@ -8,26 +8,18 @@ PostgreSQL; Redis (channel layer) — только fan-out и presence. from django.utils import timezone from hub_platform.calls.errors import CallInvalidTransition -from hub_platform.calls.lifecycle import transition_call +from hub_platform.calls.lifecycle import finish_call, transition_call from hub_platform.calls.models import ( - CallEndedBy, CallParticipant, CallSession, CallStatus, ParticipantConnectionState, - ParticipantSide, TERMINAL_CALL_STATUSES, ) from hub_platform.calls.serializers import public_call_state_payload from hub_platform.calls.services import record_call_metric from hub_platform.tenancy.context import TenantContext -SIDE_TO_ENDED_BY = { - ParticipantSide.STAFF: CallEndedBy.STAFF, - ParticipantSide.CUSTOMER: CallEndedBy.CUSTOMER, -} - - def _call(context: TenantContext, call_id) -> CallSession: return CallSession.objects.select_related("initiated_by").get( id=call_id, organization=context.organization @@ -164,24 +156,6 @@ def record_metric(context: TenantContext, call_id, side: str, content: dict) -> def end_from_signaling(context: TenantContext, call_id, side: str) -> dict: """Завершение звонка стороной: идемпотентно, целевой статус — по фазе.""" - ended_by = SIDE_TO_ENDED_BY.get(side, CallEndedBy.SYSTEM) - call = _call(context, call_id) - if call.status in TERMINAL_CALL_STATUSES: - return public_call_state_payload(call) - try: - if call.status == CallStatus.ACTIVE: - call = transition_call(call_session_id=call_id, target_status=CallStatus.ENDED, ended_by=ended_by) - elif call.status in {CallStatus.ACCEPTED, CallStatus.CONNECTING}: - call = transition_call( - call_session_id=call_id, - target_status=CallStatus.FAILED, - ended_by=ended_by, - failure_code="ABORTED_BEFORE_CONNECT", - ) - elif side == ParticipantSide.STAFF: - call = transition_call(call_session_id=call_id, target_status=CallStatus.CANCELLED, ended_by=ended_by) - else: - call = transition_call(call_session_id=call_id, target_status=CallStatus.DECLINED, ended_by=ended_by) - except CallInvalidTransition: - call = _call(context, call_id) + _call(context, call_id) + call = finish_call(call_session_id=call_id, side=side) return public_call_state_payload(call) diff --git a/apps/backend/hub_platform/calls/tests/test_api.py b/apps/backend/hub_platform/calls/tests/test_api.py index e067704..d129032 100644 --- a/apps/backend/hub_platform/calls/tests/test_api.py +++ b/apps/backend/hub_platform/calls/tests/test_api.py @@ -2,7 +2,7 @@ import json from hub_platform.testing import TenantAPIClient as APIClient -from hub_platform.calls.models import CallSession, CallStatus, ParticipantSide +from hub_platform.calls.models import CallKind, CallSession, CallStatus, ParticipantSide from hub_platform.calls.tests.helpers import CallTestCase, create_call_request from hub_platform.calls.tokens import verify_call_access_token from hub_platform.conversations.models import ControlMode @@ -15,12 +15,17 @@ class InternalCallApiTests(CallTestCase): self.client.login(username="owner@edevs.tech", password="temporary-password") def test_create_call_returns_staff_token_but_not_invite_token(self) -> None: - response = self.client.post(f"/api/v1/calls/conversations/{self.conversation.id}/") + response = self.client.post( + f"/api/v1/calls/conversations/{self.conversation.id}/", + data=json.dumps({"kind": "AUDIO"}), + content_type="application/json", + ) self.assertEqual(response.status_code, 201) payload = response.json() # WEB-подключение: доставка поллингом виджета, звонок сразу RINGING. self.assertEqual(payload["call"]["status"], CallStatus.RINGING) + self.assertEqual(payload["call"]["kind"], CallKind.AUDIO) self.assertNotIn("inviteToken", payload) self.assertNotIn("tokenHash", json.dumps(payload)) self.assertEqual(response["Cache-Control"], "no-store") @@ -29,8 +34,16 @@ class InternalCallApiTests(CallTestCase): self.assertEqual(CallSession.objects.count(), 1) def test_create_call_conflict_returns_409(self) -> None: - first = self.client.post(f"/api/v1/calls/conversations/{self.conversation.id}/") - second = self.client.post(f"/api/v1/calls/conversations/{self.conversation.id}/") + first = self.client.post( + f"/api/v1/calls/conversations/{self.conversation.id}/", + data=json.dumps({"kind": "VIDEO"}), + content_type="application/json", + ) + second = self.client.post( + f"/api/v1/calls/conversations/{self.conversation.id}/", + data=json.dumps({"kind": "VIDEO"}), + content_type="application/json", + ) self.assertEqual(first.status_code, 201) self.assertEqual(second.status_code, 409) self.assertEqual(set(second.json()), {"detail"}) @@ -38,12 +51,23 @@ class InternalCallApiTests(CallTestCase): def test_other_department_operator_gets_403_without_takeover(self) -> None: support_operator = self.create_support_operator() self.client.force_authenticate(user=support_operator) - response = self.client.post(f"/api/v1/calls/conversations/{self.conversation.id}/") + response = self.client.post( + f"/api/v1/calls/conversations/{self.conversation.id}/", + data=json.dumps({"kind": "VIDEO"}), + content_type="application/json", + ) self.assertEqual(response.status_code, 403) self.assertFalse(CallSession.objects.exists()) self.conversation.refresh_from_db() self.assertEqual(self.conversation.control_mode, ControlMode.AI) + def test_create_call_requires_explicit_kind(self) -> None: + response = self.client.post(f"/api/v1/calls/conversations/{self.conversation.id}/") + + self.assertEqual(response.status_code, 400) + self.assertEqual(response.json(), {"detail": "Укажите тип звонка"}) + self.assertFalse(CallSession.objects.exists()) + def test_staff_participant_can_refresh_access_token(self) -> None: created = create_call_request(conversation_id=self.conversation.id, initiator=self.owner) response = self.client.post(f"/api/v1/calls/{created.call_session.id}/access-token/") diff --git a/apps/backend/hub_platform/calls/tests/test_flow.py b/apps/backend/hub_platform/calls/tests/test_flow.py index 6f01161..0bc79d2 100644 --- a/apps/backend/hub_platform/calls/tests/test_flow.py +++ b/apps/backend/hub_platform/calls/tests/test_flow.py @@ -3,6 +3,7 @@ TG/MAX через outbox, истечение и системные событи import json from datetime import timedelta +from urllib.parse import parse_qs, urlparse from unittest import mock from django.utils import timezone @@ -120,6 +121,34 @@ class CustomerAccessApiTests(CallTestCase): self.assertEqual(call.ended_by, CallEndedBy.CUSTOMER) self.assertIsNotNone(call.invite.responded_at) + def test_customer_ends_accepted_call_without_websocket(self) -> None: + token = self._customer_token() + self._post("/api/v1/calls/access/accept/", token) + + response = self._post("/api/v1/calls/access/end/", token) + + self.assertEqual(response.status_code, 200) + self.assertEqual(response.json()["call"]["status"], CallStatus.FAILED) + call = CallSession.objects.get() + self.assertEqual(call.failure_code, "ABORTED_BEFORE_CONNECT") + self.assertEqual(call.ended_by, CallEndedBy.CUSTOMER) + + def test_staff_ends_accepted_call_without_websocket(self) -> None: + created = create_call_request( + conversation_id=self.conversation.id, + initiator=self.owner, + ) + customer_token = open_call_for_identity(identity=self.identity).customer_access_token + self._post("/api/v1/calls/access/accept/", customer_token) + + response = self._post("/api/v1/calls/access/end/", created.staff_access_token) + + self.assertEqual(response.status_code, 200) + self.assertEqual(response.json()["call"]["status"], CallStatus.FAILED) + call = CallSession.objects.get() + self.assertEqual(call.failure_code, "ABORTED_BEFORE_CONNECT") + self.assertEqual(call.ended_by, CallEndedBy.STAFF) + def test_state_visible_after_cancellation(self) -> None: token = self._customer_token() call = CallSession.objects.get() @@ -228,9 +257,12 @@ class MessengerDeliveryTests(CallTestCase): self.assertEqual(invite.delivery_status, InviteDeliveryStatus.SENT) self.assertEqual(sent_kwargs["chat_id"], "chat-42") # Ссылка содержит свежий token; в БД хранится только его hash. - url_token = sent_kwargs["url"].rsplit("/", 1)[1] + parsed_url = urlparse(sent_kwargs["url"]) + url_token = parsed_url.path.rsplit("/", 1)[1] self.assertEqual(hash_invite_token(url_token), invite.token_hash) self.assertNotEqual(url_token, created.invite_token) + self.assertEqual(parse_qs(parsed_url.query), {"kind": ["AUDIO"]}) + self.assertIn("аудиозвонок", sent_kwargs["text"]) def test_failed_delivery_keeps_call_requested_for_retry(self) -> None: created = create_call_request(conversation_id=self.conversation.id, initiator=self.owner) diff --git a/apps/backend/hub_platform/calls/views.py b/apps/backend/hub_platform/calls/views.py index c401836..13d7912 100644 --- a/apps/backend/hub_platform/calls/views.py +++ b/apps/backend/hub_platform/calls/views.py @@ -21,6 +21,7 @@ from hub_platform.calls.services import ( cancel_call, create_call_request, decline_call_by_access_token, + end_call_by_access_token, issue_staff_access_token, resolve_invite, ) @@ -48,7 +49,9 @@ class CallCreateView(APIView): required_entitlement = "p2p_calls" def post(self, request: Request, conversation_id: int) -> Response: - kind = str(request.data.get("kind", CallKind.AUDIO)) + if "kind" not in request.data: + return Response({"detail": "Укажите тип звонка"}, status=400) + kind = str(request.data.get("kind", "")) if kind not in CallKind.values: return Response({"detail": "Недопустимый тип звонка"}, status=400) try: @@ -238,3 +241,12 @@ class CallAccessDeclineView(_CallAccessView): except CallConflict as error: return Response({"detail": str(error)}, status=409) return _token_response({"call": public_call_state_payload(call)}) + + +class CallAccessEndView(_CallAccessView): + def post(self, request: Request) -> Response: + try: + call = end_call_by_access_token(token=_bearer_token(request)) + except CallTokenError: + return Response({"detail": "Недействительный или истёкший call access token"}, status=404) + return _token_response({"call": public_call_state_payload(call)}) diff --git a/apps/internal-ui/src/features/conversations/AudioCallOverlay.tsx b/apps/internal-ui/src/features/conversations/AudioCallOverlay.tsx index 128eeda..019165b 100644 --- a/apps/internal-ui/src/features/conversations/AudioCallOverlay.tsx +++ b/apps/internal-ui/src/features/conversations/AudioCallOverlay.tsx @@ -7,7 +7,7 @@ import { Modal } from "antd"; import { useEffect, useState } from "react"; import { providerMeta } from "../../shared/providers"; -import type { ApiCall, CallAccess } from "./model"; +import { endCallByAccess, type ApiCall, type CallAccess } from "./model"; import type { ConversationListItem } from "./types"; import "./call.css"; @@ -15,6 +15,7 @@ type Props = { open: boolean; dialog: ConversationListItem | null; call: ApiCall | null; + requestedKind?: ApiCall["kind"] | null; access: CallAccess | null; errorText: string; onCallChange: (call: ApiCall) => void; @@ -25,6 +26,7 @@ type Props = { export function AudioCallOverlay(props: Props) { const call = props.call; + const [speakerOn, setSpeakerOn] = useState(true); const rtc = useCallRtcSession({ resetKey: call?.id ?? "", previewEnabled: false, @@ -40,18 +42,31 @@ export function AudioCallOverlay(props: Props) { const mode = resolveAudioMode(call, props.errorText, rtc.connectionPhase, rtc.mediaIssue); useLoopingAudio("/audio/ringtone.mp3", props.open && (mode === "incoming" || mode === "ringing"), 0.5); + useEffect(() => { + if (!props.open || !props.access || !call || rtc.connectionPhase !== "idle") return; + if (call.status === "ACCEPTED" || call.status === "CONNECTING") void rtc.start(); + }, [props.open, props.access, call?.status, rtc.connectionPhase, rtc.start]); + if (!props.dialog) return null; const channel = providerMeta[props.dialog.channel]; const subCaption = mode === "active" ? (rtc.micOn ? "Говорите" : "Ваш микрофон выключен") : undefined; - const status = buildAudioStatus(audioStatusKey(mode, call, props.errorText, rtc.connectionPhase, rtc.mediaIssue), props.dialog.name, call?.durationSeconds ?? undefined); + const builtStatus = buildAudioStatus(audioStatusKey(mode, call, props.errorText, rtc.connectionPhase, rtc.mediaIssue), props.dialog.name, call?.durationSeconds ?? undefined); + const status = props.errorText && builtStatus + ? { ...builtStatus, caption: props.errorText } + : builtStatus; const onAccept = () => { void rtc.start(); }; - const onEnd = () => { rtc.end(); }; - const endAndClose = () => { - if (mode === "active" || mode === "reconnecting" || mode === "connecting") rtc.end(); + const finish = async () => { + const token = props.access?.accessToken; + if (!token || !call || isTerminal(call.status)) return; + try { props.onCallChange(await endCallByAccess(token)); } + finally { rtc.stop(); } + }; + const onEnd = () => { void finish(); }; + const endAndClose = async () => { + if (mode === "active" || mode === "reconnecting" || mode === "connecting") await finish(); props.onClose(); }; - const toggleSpeaker = () => { /* громкость/динамик управляется браузером; индикация переключается без реального мьюта */ }; // retry для статус-экрана: при проблемах с устройствами/браузером — перепроверка, // иначе — пересоздание звонка (props.onRetry) или рестарт соединения. const onRetry = () => { @@ -61,7 +76,7 @@ export function AudioCallOverlay(props: Props) { }; return ( - + void endAndClose()} footer={null} closable={false} width={428} className="call-modal audio-call-modal" destroyOnHidden> setSpeakerOn((current) => !current)} onAccept={onAccept} onDecline={props.onCancel} - onCancel={mode === "ringing" || mode === "connecting" ? props.onCancel : endAndClose} + onCancel={mode === "ringing" ? props.onCancel : () => void endAndClose()} onEnd={onEnd} - onClose={endAndClose} + onClose={() => void endAndClose()} onCallAgain={props.onRetry} onRetry={onRetry} /> diff --git a/apps/internal-ui/src/features/conversations/CallOverlay.tsx b/apps/internal-ui/src/features/conversations/CallOverlay.tsx index 5ecce8a..55673dd 100644 --- a/apps/internal-ui/src/features/conversations/CallOverlay.tsx +++ b/apps/internal-ui/src/features/conversations/CallOverlay.tsx @@ -11,6 +11,7 @@ type Props = { open: boolean; dialog: ConversationListItem | null; call: ApiCall | null; + requestedKind: ApiCall["kind"] | null; access: CallAccess | null; errorText: string; onCallChange: (call: ApiCall) => void; @@ -20,8 +21,10 @@ type Props = { }; export function CallOverlay(props: Props) { - // Активный/существующий звонок возвращается из API с kind; новый запрос - // создан под выбранный тип. Дефолт — аудио (как и requestCall / модели). - if (props.call?.kind === "VIDEO") return ; - return ; + // Активный/существующий звонок возвращается из API с kind; пока запрос + // выполняется, используем явно выбранный тип. Неизвестный kind не подменяем. + const kind = props.call?.kind ?? props.requestedKind; + if (kind === "VIDEO") return ; + if (kind === "AUDIO") return ; + return null; } diff --git a/apps/internal-ui/src/features/conversations/ConversationWorkspace.tsx b/apps/internal-ui/src/features/conversations/ConversationWorkspace.tsx index f9b8635..eeef0eb 100644 --- a/apps/internal-ui/src/features/conversations/ConversationWorkspace.tsx +++ b/apps/internal-ui/src/features/conversations/ConversationWorkspace.tsx @@ -160,6 +160,7 @@ export function ConversationWorkspace({ department, isOwner = false, listTitle, open={callController.open} dialog={selectedDialog} call={callController.call} + requestedKind={callController.requestedKind} access={callController.access} errorText={callController.errorText} onCallChange={callController.setCall} diff --git a/apps/internal-ui/src/features/conversations/VideoCallOverlay.tsx b/apps/internal-ui/src/features/conversations/VideoCallOverlay.tsx index db52272..0520c1a 100644 --- a/apps/internal-ui/src/features/conversations/VideoCallOverlay.tsx +++ b/apps/internal-ui/src/features/conversations/VideoCallOverlay.tsx @@ -7,7 +7,7 @@ import { Modal } from "antd"; import { useEffect, useMemo, useState } from "react"; import { providerMeta } from "../../shared/providers"; -import type { ApiCall, CallAccess } from "./model"; +import { endCallByAccess, type ApiCall, type CallAccess } from "./model"; import type { ConversationListItem } from "./types"; import "./call.css"; @@ -24,6 +24,7 @@ type Props = { open: boolean; dialog: ConversationListItem | null; call: ApiCall | null; + requestedKind?: ApiCall["kind"] | null; access: CallAccess | null; errorText: string; onCallChange: (call: ApiCall) => void; @@ -56,13 +57,19 @@ export function VideoCallOverlay(props: Props) { const channel = providerMeta[props.dialog.channel]; const subtitle = subtitleFor(mode, call, status); const mediaCaption = rtc.mediaIssue === "devices" ? "Нет доступа к камере и микрофону" : rtc.mediaIssue === "video" ? "Камера недоступна" : "Камера выключена"; - const endAndClose = () => { - if (mode === "active" || mode === "reconnecting" || mode === "connecting") rtc.end(); + const finish = async () => { + const token = props.access?.accessToken; + if (!token || !call || TERMINAL[call.status]) return; + try { props.onCallChange(await endCallByAccess(token)); } + finally { rtc.stop(); } + }; + const endAndClose = async () => { + if (mode === "active" || mode === "reconnecting" || mode === "connecting" || mode === "precall") await finish(); props.onClose(); }; return ( - + void endAndClose()} footer={null} closable={false} width={428} className="call-modal" destroyOnHidden> void rtc.start()} + onCancel={mode === "ringing" ? props.onCancel : () => void endAndClose()} + onEnd={() => void finish()} + onClose={() => void endAndClose()} /> ); diff --git a/apps/internal-ui/src/features/conversations/model.ts b/apps/internal-ui/src/features/conversations/model.ts index 0a6a7ba..ded3d93 100644 --- a/apps/internal-ui/src/features/conversations/model.ts +++ b/apps/internal-ui/src/features/conversations/model.ts @@ -166,3 +166,5 @@ export const fetchCall = (callId: string) => api<{ call: ApiCall }>(`/api/v1/cal export const cancelCall = (callId: string) => api<{ call: ApiCall }>(`/api/v1/calls/${callId}/cancel/`, { method: "POST" }).then((r) => r.call); export const fetchStaffCallAccess = (callId: string) => api<{ accessToken: string; iceServers: RTCIceServer[] }>(`/api/v1/calls/${callId}/access-token/`, { method: "POST" }); +export const endCallByAccess = (accessToken: string) => + api<{ call: ApiCall }>("/api/v1/calls/access/end/", { method: "POST", headers: { Authorization: `Bearer ${accessToken}` } }).then((r) => r.call); diff --git a/apps/internal-ui/src/features/conversations/useConversationCall.ts b/apps/internal-ui/src/features/conversations/useConversationCall.ts index 6a9ee0f..2d4390e 100644 --- a/apps/internal-ui/src/features/conversations/useConversationCall.ts +++ b/apps/internal-ui/src/features/conversations/useConversationCall.ts @@ -24,6 +24,7 @@ export function useConversationCall({ conversationId, onConversationChanged }: O const [access, setAccess] = useState(null); const [errorText, setErrorText] = useState(""); const [busy, setBusy] = useState(false); + const [requestedKind, setRequestedKind] = useState(null); const lastKind = useRef("AUDIO"); const reset = useCallback(() => { @@ -31,6 +32,7 @@ export function useConversationCall({ conversationId, onConversationChanged }: O setCall(null); setAccess(null); setErrorText(""); + setRequestedKind(null); }, []); useEffect(() => reset(), [conversationId, reset]); @@ -52,11 +54,16 @@ export function useConversationCall({ conversationId, onConversationChanged }: O const start = useCallback(async (kind: CallKind = "AUDIO") => { if (conversationId == null || busy) return; lastKind.current = kind; + setRequestedKind(kind); setBusy(true); setErrorText(""); try { const active = await fetchActiveCall(conversationId); if (active) { + if (active.kind !== kind) { + const activeLabel = active.kind === "AUDIO" ? "аудиозвонок" : "видеозвонок"; + throw new Error(`Сначала завершите текущий ${activeLabel}`); + } setCall(active); await ensureAccess(active.id); } else { @@ -80,8 +87,10 @@ export function useConversationCall({ conversationId, onConversationChanged }: O try { setCall(await cancelCall(call.id)); onConversationChanged(); - } catch { /* поллинг подтянет фактическое состояние */ } - setOpen(false); + setOpen(false); + } catch (error) { + setErrorText(error instanceof Error ? error.message : "Не удалось отменить звонок"); + } }, [call, onConversationChanged]); const retry = useCallback(async () => { @@ -89,6 +98,7 @@ export function useConversationCall({ conversationId, onConversationChanged }: O setCall(null); setAccess(null); setErrorText(""); + setRequestedKind(lastKind.current); try { const created = await requestCall(conversationId, lastKind.current); setCall(created.call); @@ -105,6 +115,7 @@ export function useConversationCall({ conversationId, onConversationChanged }: O access, errorText, busy, + requestedKind, start, cancel, retry, diff --git a/apps/web-chat/src/App.tsx b/apps/web-chat/src/App.tsx index f72b1c9..4c728c3 100644 --- a/apps/web-chat/src/App.tsx +++ b/apps/web-chat/src/App.tsx @@ -123,7 +123,7 @@ export function App() { if (!opened) { setCall(null); return; } openedCallId.current = opened.call.callId; setCall(null); - window.open(`/calls/${opened.call.callId}#${opened.accessToken}`, "_blank", "noopener"); + window.open(`/calls/${opened.call.callId}?kind=${opened.call.kind}#${opened.accessToken}`, "_blank", "noopener"); } async function declineCallInvite() { diff --git a/apps/web-chat/src/api.ts b/apps/web-chat/src/api.ts index 3a0776d..8c9e508 100644 --- a/apps/web-chat/src/api.ts +++ b/apps/web-chat/src/api.ts @@ -20,16 +20,18 @@ export type CallKind = "AUDIO" | "VIDEO"; export type CallInfo = { callId: string; status: string; - kind?: CallKind; + kind: CallKind; expiresAt?: string; staffName?: string; endedBy?: string | null; durationSeconds?: number | null; }; -// Единая проверка типа звонка (default — AUDIO). Используется и баннером виджета, -// и страницей звонка, чтобы они не расходились при неопределённом kind. -export const isVideoCall = (call: CallInfo | null | undefined): boolean => call?.kind === "VIDEO"; +export function callKindOf(call: CallInfo | null | undefined): CallKind | null { + return call?.kind === "AUDIO" || call?.kind === "VIDEO" ? call.kind : null; +} + +export const isVideoCall = (call: CallInfo | null | undefined): boolean => callKindOf(call) === "VIDEO"; export type CallBootstrap = { call: CallInfo; accessToken: string; iceServers: RTCIceServer[] }; export type CallStateEnvelope = { call: CallInfo; iceServers: RTCIceServer[] }; @@ -108,12 +110,15 @@ export async function resolveCallInvite(inviteToken: string): Promise { +async function callAccessAction(action: "accept" | "decline" | "end", accessToken: string): Promise { const r = await fetch(`${CALLS_API}/access/${action}/`, { method: "POST", headers: { "Content-Type": "application/json", Authorization: `Bearer ${accessToken}` }, }); - if (!r.ok) return null; + if (!r.ok) { + const payload = await r.json().catch(() => ({ detail: "Не удалось выполнить действие со звонком" })) as { detail?: string }; + throw new Error(payload.detail || "Не удалось выполнить действие со звонком"); + } return (await r.json()).call as CallInfo; } @@ -127,6 +132,7 @@ export async function fetchCallState(accessToken: string): Promise callAccessAction("accept", accessToken); export const declineCall = (accessToken: string) => callAccessAction("decline", accessToken); +export const endCall = (accessToken: string) => callAccessAction("end", accessToken); // --- Support mode (SPEC-HUB-0010 §7): authenticated in-product chat --- diff --git a/apps/web-chat/src/call/AudioCallStage.tsx b/apps/web-chat/src/call/AudioCallStage.tsx index 61f86a7..8615b10 100644 --- a/apps/web-chat/src/call/AudioCallStage.tsx +++ b/apps/web-chat/src/call/AudioCallStage.tsx @@ -5,7 +5,7 @@ import { AudioCallView, type AudioCallMode, type AudioCallStatus, useCallRtcSession, useLoopingAudio } from "@edevs/ui"; import { useCallback, useEffect, useMemo, useState } from "react"; -import { acceptCall, declineCall, fetchCallState, type CallInfo } from "../api"; +import { acceptCall, declineCall, endCall, fetchCallState, type CallInfo } from "../api"; import { audioCallStatusLabel, buildAudioCallViewStatus, isTerminalCall, resolveAudioCallViewMode } from "./model"; import { useConnectionTimer } from "./useConnectionTimer"; @@ -21,6 +21,8 @@ type Props = { export function AudioCallStage({ call, accessToken, iceServers, loading, invalid, onCall }: Props) { const [started, setStarted] = useState(false); const [joining, setJoining] = useState(false); + const [speakerOn, setSpeakerOn] = useState(true); + const [errorText, setErrorText] = useState(""); const rtc = useCallRtcSession({ resetKey: call?.callId ?? "", @@ -46,53 +48,77 @@ export function AudioCallStage({ call, accessToken, iceServers, loading, invalid if (isTerminalCall(call?.status)) rtc.stop(); }, [call?.status, rtc.stop]); - const close = useCallback(() => { - if (started) rtc.end(); + const close = useCallback(async () => { + if (accessToken && call && !isTerminalCall(call.status) && call.status !== "REQUESTED" && call.status !== "RINGING") { + try { onCall(await endCall(accessToken)); } catch { /* terminal polling remains authoritative */ } + } + rtc.stop(); if (history.length > 1) history.back(); else window.close(); - }, [rtc.end, started]); + }, [accessToken, call, onCall, rtc.stop]); async function join() { if (!accessToken || joining) return; setJoining(true); - if (call?.status === "REQUESTED" || call?.status === "RINGING") { - const accepted = await acceptCall(accessToken); - if (accepted) onCall(accepted); + setErrorText(""); + try { + const stream = await rtc.prepare(); + if (!stream) return; + if (call?.status === "REQUESTED" || call?.status === "RINGING") { + onCall(await acceptCall(accessToken)); + } + setStarted(true); + await rtc.start(); + } catch (error) { + setErrorText(error instanceof Error ? error.message : "Не удалось принять звонок"); + } finally { + setJoining(false); } - setStarted(true); - rtc.start(); - setJoining(false); } const onCancel = useCallback(async () => { if (!accessToken) return; - if (call?.status === "REQUESTED" || call?.status === "RINGING") { - const declined = await declineCall(accessToken); - if (declined) onCall(declined); - return; + setErrorText(""); + try { + const ended = call?.status === "REQUESTED" || call?.status === "RINGING" + ? await declineCall(accessToken) + : await endCall(accessToken); + onCall(ended); + rtc.stop(); + } catch (error) { + setErrorText(error instanceof Error ? error.message : "Не удалось завершить звонок"); } - setStarted(true); - if (rtc.start()) rtc.end(); - }, [accessToken, call?.status, onCall, rtc]); + }, [accessToken, call?.status, onCall, rtc.stop]); - const mode: AudioCallMode = resolveAudioCallViewMode({ loading, invalid, call, started, connection: rtc.connectionPhase, mediaIssue: rtc.mediaIssue }); + const onEnd = useCallback(async () => { + if (!accessToken) return; + try { + onCall(await endCall(accessToken)); + } catch (error) { + setErrorText(error instanceof Error ? error.message : "Не удалось завершить звонок"); + } finally { + rtc.stop(); + } + }, [accessToken, onCall, rtc.stop]); + + const mode: AudioCallMode = resolveAudioCallViewMode({ loading, invalid, call, started, connection: rtc.connectionPhase, mediaIssue: rtc.mediaIssue, errorText }); useLoopingAudio("/chat/audio/ringtone.mp3", !started && (call?.status === "REQUESTED" || call?.status === "RINGING")); const status: AudioCallStatus | undefined = useMemo( - () => buildAudioCallViewStatus({ loading, invalid, call, connection: rtc.connectionPhase, mediaIssue: rtc.mediaIssue, close }), - [loading, invalid, call, rtc.connectionPhase, rtc.mediaIssue, close], + () => buildAudioCallViewStatus({ loading, invalid, call, connection: rtc.connectionPhase, mediaIssue: rtc.mediaIssue, errorText, close }), + [loading, invalid, call, rtc.connectionPhase, rtc.mediaIssue, errorText, close], ); const elapsed = useConnectionTimer(rtc.connectionPhase === "connected"); const peerName = call?.staffName || "Оператор"; const initials = peerName.trim().split(/\s+/).map((part) => part[0]).slice(0, 2).join("").toUpperCase() || "ОП"; return ( -
+
setSpeakerOn((current) => !current)} onAccept={() => void join()} onDecline={() => void onCancel()} onCancel={() => void onCancel()} - onEnd={rtc.end} - onClose={close} + onEnd={() => void onEnd()} + onClose={() => void close()} + onRetry={() => { setErrorText(""); void rtc.prepare(); }} />
); diff --git a/apps/web-chat/src/call/CallApp.tsx b/apps/web-chat/src/call/CallApp.tsx index e8ec95c..5e0ea96 100644 --- a/apps/web-chat/src/call/CallApp.tsx +++ b/apps/web-chat/src/call/CallApp.tsx @@ -1,6 +1,7 @@ // Страница звонка /calls/: bootstrap (invite token / access token из // ссылок TG/MAX или session-storage) и поллинг состояния, затем диспетчер по типу -// звонка — VIDEO → VideoCallStage, иначе → AudioCallStage (default AUDIO). +// звонка — VIDEO → VideoCallStage, AUDIO → AudioCallStage. Неизвестный kind не +// подменяется аудиозвонком: ждём валидный ответ API либо показываем ошибку ссылки. // // Composition-only: RTC, UI-сцена и таймер живут в Stage-компонентах. Здесь только // загрузка данных звонка и роутинг по kind. Все хуки вызваны безусловно — правила @@ -10,10 +11,11 @@ import { useCallback, useEffect, useState } from "react"; import { fetchCallState, - isVideoCall, + callKindOf, resolveCallInvite, type CallBootstrap, type CallInfo, + type CallKind, } from "../api"; import { AudioCallStage } from "./AudioCallStage"; import { VideoCallStage } from "./VideoCallStage"; @@ -21,6 +23,10 @@ import { VideoCallStage } from "./VideoCallStage"; function storageKey() { return `edevs-call:${location.pathname}`; } function inviteTokenFromPath() { return location.pathname.match(/\/calls\/([^/]+)/)?.[1] ?? ""; } function accessTokenFromHash() { return location.hash.startsWith("#") ? location.hash.slice(1) : ""; } +function kindFromQuery(): CallKind | null { + const kind = new URLSearchParams(location.search).get("kind"); + return kind === "AUDIO" || kind === "VIDEO" ? kind : null; +} export function CallApp() { const [loading, setLoading] = useState(true); @@ -28,8 +34,10 @@ export function CallApp() { const [call, setCall] = useState(null); const [accessToken, setAccessToken] = useState(""); const [iceServers, setIceServers] = useState([]); + const [hintedKind] = useState(kindFromQuery); const applyBootstrap = useCallback((value: CallBootstrap) => { + if (!callKindOf(value.call)) { setInvalid(true); setLoading(false); return; } sessionStorage.setItem(storageKey(), value.accessToken); setAccessToken(value.accessToken); setIceServers(value.iceServers ?? []); @@ -46,6 +54,7 @@ export function CallApp() { setAccessToken(token); void fetchCallState(token).then((result) => { if (!result) { setInvalid(true); setLoading(false); return; } + if (!callKindOf(result.call)) { setInvalid(true); setLoading(false); return; } setCall(result.call); setIceServers(result.iceServers ?? []); setLoading(false); @@ -60,7 +69,8 @@ export function CallApp() { }); }, [applyBootstrap]); - return isVideoCall(call) - ? - : ; + const kind = callKindOf(call) ?? hintedKind; + if (kind === "VIDEO") return ; + if (kind === "AUDIO") return ; + return
; } diff --git a/apps/web-chat/src/call/VideoCallStage.tsx b/apps/web-chat/src/call/VideoCallStage.tsx index fbd9225..568f9e9 100644 --- a/apps/web-chat/src/call/VideoCallStage.tsx +++ b/apps/web-chat/src/call/VideoCallStage.tsx @@ -5,7 +5,7 @@ import { CallView, useCallRtcSession, useLoopingAudio } from "@edevs/ui"; import { useCallback, useEffect, useMemo, useState } from "react"; -import { acceptCall, declineCall, fetchCallState, type CallInfo } from "../api"; +import { acceptCall, declineCall, endCall, fetchCallState, type CallInfo } from "../api"; import { buildCallViewStatus, callViewSubtitle, isTerminalCall, resolveCallViewMode } from "./model"; import { useConnectionTimer } from "./useConnectionTimer"; @@ -21,6 +21,7 @@ type Props = { export function VideoCallStage({ call, accessToken, iceServers, loading, invalid, onCall }: Props) { const [started, setStarted] = useState(false); const [joining, setJoining] = useState(false); + const [errorText, setErrorText] = useState(""); const rtc = useCallRtcSession({ resetKey: call?.callId ?? "", @@ -28,12 +29,15 @@ export function VideoCallStage({ call, accessToken, iceServers, loading, invalid accessToken, side: "CUSTOMER", iceServers, - onCallState: (state) => { if (call) onCall({ ...call, ...state }); else onCall(state); }, + onCallState: (state) => { if (call) onCall({ ...call, ...state }); }, }); - const close = useCallback(() => { - if (started) rtc.end(); + const close = useCallback(async () => { + if (accessToken && call && !isTerminalCall(call.status) && call.status !== "REQUESTED" && call.status !== "RINGING") { + try { onCall(await endCall(accessToken)); } catch { /* terminal polling remains authoritative */ } + } + rtc.stop(); if (history.length > 1) history.back(); else window.close(); - }, [rtc.end, started]); + }, [accessToken, call, onCall, rtc.stop]); // Поллинг состояния до терминала. useEffect(() => { @@ -52,31 +56,48 @@ export function VideoCallStage({ call, accessToken, iceServers, loading, invalid async function join() { if (!accessToken || joining) return; setJoining(true); - if (call?.status === "REQUESTED" || call?.status === "RINGING") { - const accepted = await acceptCall(accessToken); - if (accepted) onCall(accepted); + setErrorText(""); + try { + const stream = await rtc.prepare(); + if (!stream) return; + if (call?.status === "REQUESTED" || call?.status === "RINGING") { + onCall(await acceptCall(accessToken)); + } + setStarted(true); + await rtc.start(); + } catch (error) { + setErrorText(error instanceof Error ? error.message : "Не удалось принять звонок"); + } finally { + setJoining(false); } - setStarted(true); - rtc.start(); - setJoining(false); } async function cancelPrecall() { if (!accessToken) return; - if (call?.status === "REQUESTED" || call?.status === "RINGING") { - const declined = await declineCall(accessToken); - if (declined) onCall(declined); - return; + setErrorText(""); + try { + const ended = call?.status === "REQUESTED" || call?.status === "RINGING" + ? await declineCall(accessToken) + : await endCall(accessToken); + onCall(ended); + rtc.stop(); + } catch (error) { + setErrorText(error instanceof Error ? error.message : "Не удалось завершить звонок"); } - setStarted(true); - if (rtc.start()) rtc.end(); } - const mode = resolveCallViewMode({ loading, invalid, call, started, connection: rtc.connectionPhase, mediaIssue: rtc.mediaIssue }); + async function end() { + if (!accessToken) return; + try { onCall(await endCall(accessToken)); } + catch (error) { setErrorText(error instanceof Error ? error.message : "Не удалось завершить звонок"); } + finally { rtc.stop(); } + } + + const mode = resolveCallViewMode({ loading, invalid, call, started, connection: rtc.connectionPhase, mediaIssue: rtc.mediaIssue, errorText }); useLoopingAudio("/chat/audio/ringtone.mp3", !started && (call?.status === "REQUESTED" || call?.status === "RINGING")); const status = useMemo( - () => buildCallViewStatus({ loading, invalid, call, connection: rtc.connectionPhase, mediaIssue: rtc.mediaIssue, close, retry: rtc.restart, prepare: rtc.prepare, join: () => void join() }), - [loading, invalid, call, joining, rtc.connectionPhase, rtc.mediaIssue, close, rtc.restart, rtc.prepare], + () => buildCallViewStatus({ loading, invalid, call, connection: rtc.connectionPhase, mediaIssue: rtc.mediaIssue, errorText, close: () => void close(), retry: rtc.restart, prepare: rtc.prepare, join: () => void join() }), + [loading, invalid, call, joining, rtc.connectionPhase, rtc.mediaIssue, errorText, close, rtc.restart, rtc.prepare], ); const elapsed = useConnectionTimer(rtc.connectionPhase === "connected"); const peerName = call?.staffName || "Оператор"; @@ -105,8 +126,8 @@ export function VideoCallStage({ call, accessToken, iceServers, loading, invalid onToggleCam={rtc.toggleCam} onJoin={() => void join()} onCancel={() => void cancelPrecall()} - onEnd={rtc.end} - onClose={close} + onEnd={() => void end()} + onClose={() => void close()} />
); diff --git a/apps/web-chat/src/call/model.test.ts b/apps/web-chat/src/call/model.test.ts index 109b192..48d9a56 100644 --- a/apps/web-chat/src/call/model.test.ts +++ b/apps/web-chat/src/call/model.test.ts @@ -1,9 +1,15 @@ import { describe, expect, it, vi } from "vitest"; import type { CallInfo } from "../api"; -import { buildCallViewStatus, isTerminalCall, resolveCallViewMode } from "./model"; +import { + buildAudioCallViewStatus, + buildCallViewStatus, + isTerminalCall, + resolveAudioCallViewMode, + resolveCallViewMode, +} from "./model"; -const call: CallInfo = { callId: "call-1", status: "ACCEPTED", staffName: "Оператор" }; +const call: CallInfo = { callId: "call-1", status: "ACCEPTED", kind: "VIDEO", staffName: "Оператор" }; const action = vi.fn(); describe("call view model", () => { @@ -21,4 +27,33 @@ describe("call view model", () => { expect(status?.actions?.map((item) => item.label)).toEqual(["Повторить проверку", "Без видео"]); expect(isTerminalCall("ENDED")).toBe(true); }); + + it("keeps an audio invitation incoming until the customer accepts it", () => { + expect(resolveAudioCallViewMode({ + loading: false, + invalid: false, + call: { ...call, kind: "AUDIO", status: "RINGING" }, + started: false, + connection: "idle", + mediaIssue: "none", + })).toBe("incoming"); + }); + + it("turns an audio action failure into a classified retry state", () => { + const status = buildAudioCallViewStatus({ + loading: false, + invalid: false, + call: { ...call, kind: "AUDIO" }, + connection: "idle", + mediaIssue: "none", + errorText: "Приглашение уже нельзя принять", + close: action, + }); + expect(status).toMatchObject({ + icon: "alert", + tone: "error", + caption: "Приглашение уже нельзя принять", + bar: "retryClose", + }); + }); }); diff --git a/apps/web-chat/src/call/model.ts b/apps/web-chat/src/call/model.ts index 0cc9ca2..1c02960 100644 --- a/apps/web-chat/src/call/model.ts +++ b/apps/web-chat/src/call/model.ts @@ -13,17 +13,18 @@ const TERMINAL: Record Boolean(status && TERMINAL[status]); -export function resolveCallViewMode(state: { loading: boolean; invalid: boolean; call: CallInfo | null; started: boolean; connection: string; mediaIssue: string }): CallViewMode { - if (state.loading || state.invalid || !state.call || isTerminalCall(state.call.status) || state.connection === "failed" || state.mediaIssue === "devices" || state.mediaIssue === "unsupported") return "status"; +export function resolveCallViewMode(state: { loading: boolean; invalid: boolean; call: CallInfo | null; started: boolean; connection: string; mediaIssue: string; errorText?: string }): CallViewMode { + if (state.loading || state.invalid || state.errorText || !state.call || isTerminalCall(state.call.status) || state.connection === "failed" || state.mediaIssue === "devices" || state.mediaIssue === "unsupported") return "status"; if (!state.started) return "precall"; if (state.connection === "reconnecting") return "reconnecting"; if (state.connection === "connected" || state.call.status === "ACTIVE") return "active"; return "connecting"; } -export function buildCallViewStatus(state: { loading: boolean; invalid: boolean; call: CallInfo | null; connection: string; mediaIssue: string; close: () => void; retry: () => void; prepare: () => void; join: () => void }): CallViewStatus | undefined { +export function buildCallViewStatus(state: { loading: boolean; invalid: boolean; call: CallInfo | null; connection: string; mediaIssue: string; errorText?: string; close: () => void; retry: () => void; prepare: () => void; join: () => void }): CallViewStatus | undefined { if (state.loading) return { icon: "spinner", tone: "neutral", title: "Проверяем приглашение", caption: "Секунду…" }; if (state.invalid || !state.call) return { icon: "clock", tone: "warn", title: "Приглашение недействительно", caption: "Ссылка устарела или уже была использована. Запросите новое приглашение в чате." }; + if (state.errorText) return { icon: "alert", tone: "error", title: "Не удалось выполнить действие", caption: state.errorText, actions: [{ label: "Закрыть", kind: "secondary", onClick: state.close }] }; if (state.mediaIssue === "unsupported") return { icon: "unsupported", tone: "error", title: "Видеозвонки не поддерживаются", caption: "Обновите браузер или откройте ссылку в Chrome, Safari или Edge." }; if (state.mediaIssue === "devices") return { icon: "alert", tone: "error", title: "Нет доступа к камере и микрофону", caption: "Разрешите доступ к устройствам в настройках браузера и повторите.", actions: [{ label: "Повторить проверку", kind: "primary", onClick: state.prepare }, { label: "Без видео", kind: "secondary", onClick: state.join }] }; if (state.connection === "failed") return { icon: "alert", tone: "error", title: "Не удалось соединиться", caption: "Проверьте интернет-соединение и попробуйте снова.", actions: [{ label: "Повторить", kind: "primary", onClick: state.retry }] }; @@ -45,8 +46,8 @@ export function callViewSubtitle(mode: CallViewMode, status?: CallViewStatus) { // --- Аудиозвонок (baseline «Аудиозвонок.dc.html»): incoming → active → терминал, // без pre-call. Статус-центр — через общий buildAudioStatus из @edevs/ui. --- -export function resolveAudioCallViewMode(state: { loading: boolean; invalid: boolean; call: CallInfo | null; started: boolean; connection: string; mediaIssue: string }): AudioCallMode { - if (state.loading || state.invalid || !state.call) return "status"; +export function resolveAudioCallViewMode(state: { loading: boolean; invalid: boolean; call: CallInfo | null; started: boolean; connection: string; mediaIssue: string; errorText?: string }): AudioCallMode { + if (state.loading || state.invalid || state.errorText || !state.call) return "status"; if (state.mediaIssue === "devices" || state.mediaIssue === "unsupported") return "status"; if (isTerminalCall(state.call.status) || state.connection === "failed") return "status"; // Приглашение для клиента — входящий звонок: он решает принять или отклонить @@ -58,9 +59,10 @@ export function resolveAudioCallViewMode(state: { loading: boolean; invalid: boo return "connecting"; } -export function buildAudioCallViewStatus(state: { loading: boolean; invalid: boolean; call: CallInfo | null; connection: string; mediaIssue: string; close: () => void }): AudioCallStatus | undefined { +export function buildAudioCallViewStatus(state: { loading: boolean; invalid: boolean; call: CallInfo | null; connection: string; mediaIssue: string; errorText?: string; close: () => void }): AudioCallStatus | undefined { if (state.loading) return { icon: "clock", tone: "warn", title: "Проверяем приглашение", caption: "Секунду…", bar: "ended" }; if (state.invalid || !state.call) return { icon: "clock", tone: "warn", title: "Приглашение недействительно", caption: "Ссылка устарела или уже была использована. Запросите новое приглашение в чате.", bar: "ended" }; + if (state.errorText) return { icon: "alert", tone: "error", title: "Не удалось выполнить действие", caption: state.errorText, bar: "retryClose" }; if (state.mediaIssue === "unsupported") return buildAudioStatus("unsupported", state.call.staffName || "Оператор") ?? undefined; if (state.mediaIssue === "devices") return buildAudioStatus("nodevice", state.call.staffName || "Оператор") ?? undefined; if (state.connection === "failed") return buildAudioStatus("FAILED", state.call.staffName || "Оператор") ?? undefined; diff --git a/apps/web-chat/src/styles.css b/apps/web-chat/src/styles.css index fd3aecf..de22336 100644 --- a/apps/web-chat/src/styles.css +++ b/apps/web-chat/src/styles.css @@ -52,6 +52,11 @@ body, background: #f5f6f8; } +.public-call-page.is-audio { + background: #0b0b12; + font-family: var(--font-sans); +} + @media (max-width: 480px) { .public-call-page { padding: 0; diff --git a/deploy/nginx/local.conf b/deploy/nginx/local.conf index 4782dda..0c37154 100644 --- a/deploy/nginx/local.conf +++ b/deploy/nginx/local.conf @@ -41,7 +41,9 @@ server { } location /calls/ { - add_header Content-Security-Policy "default-src 'self'; connect-src 'self' ws: wss:; img-src 'self' data:; media-src 'self' blob:; style-src 'self' 'unsafe-inline'; script-src 'self' 'unsafe-eval'; object-src 'none'; base-uri 'self'; frame-ancestors 'none'" always; + # Vite injects the React Refresh preamble inline in development. Production + # uses the strict policy from frontend.production.conf. + add_header Content-Security-Policy "default-src 'self'; connect-src 'self' ws: wss:; img-src 'self' data:; media-src 'self' blob:; style-src 'self' 'unsafe-inline'; script-src 'self' 'unsafe-inline' 'unsafe-eval'; object-src 'none'; base-uri 'self'; frame-ancestors 'none'" always; add_header Permissions-Policy "camera=(self), microphone=(self)" always; rewrite ^ /chat/call.html break; proxy_pass http://web-chat:5175; diff --git a/design/Edevs Hub Design System/Edevs Hub Internal UI (Standalone).html b/design/Edevs Hub Design System/Edevs Hub Internal UI (Standalone).html deleted file mode 100644 index 7aa119b..0000000 --- a/design/Edevs Hub Design System/Edevs Hub Internal UI (Standalone).html +++ /dev/null @@ -1,212 +0,0 @@ - - - - - Edevs Hub — Internal UI - - - - -
- - - - - -
-
Unpacking...
- - - - - - - - - - \ No newline at end of file diff --git a/design/Edevs Hub Design System/SKILL.md b/design/Edevs Hub Design System/SKILL.md deleted file mode 100644 index c83952e..0000000 --- a/design/Edevs Hub Design System/SKILL.md +++ /dev/null @@ -1,9 +0,0 @@ ---- -name: edevs-hub-design -description: Use this skill to generate well-branded interfaces and assets for Edevs Hub (the internal platform behind Edevs' products FoxRay and FirePage), either for production or throwaway prototypes/mocks/etc. Contains essential design guidelines, colors, type, fonts, assets, and UI kit components for prototyping. -user-invocable: true ---- - -Read the README.md file within this skill, and explore the other available files. -If creating visual artifacts (slides, mocks, throwaway prototypes, etc), copy assets out and create static HTML files for the user to view. If working on production code, you can copy assets and read the rules here to become an expert in designing with this brand. -If the user invokes this skill without any other guidance, ask them what they want to build or design, ask some questions, and act as an expert designer who outputs HTML artifacts _or_ production code, depending on the need. diff --git a/design/Edevs Hub Design System/_adherence.oxlintrc.json b/design/Edevs Hub Design System/_adherence.oxlintrc.json deleted file mode 100644 index 8419c5c..0000000 --- a/design/Edevs Hub Design System/_adherence.oxlintrc.json +++ /dev/null @@ -1,508 +0,0 @@ -{ - "plugins": [ - "react", - "import" - ], - "rules": { - "react/forbid-elements": [ - "warn", - { - "forbid": [] - } - ], - "no-restricted-imports": [ - "warn", - { - "patterns": [ - { - "group": [ - "components/cards/**", - "components/core/**", - "components/inbox/**", - "components/status/**", - "ui_kits/checkout/**", - "ui_kits/internal-hub/**", - "ui_kits/web-chat/**" - ], - "message": "Import design-system components from 'index.js', not component internals." - } - ] - } - ], - "no-restricted-syntax": [ - "warn", - { - "selector": "Literal[value=/#[0-9a-fA-F]{3,8}\\b/]", - "message": "Raw hex color — use a design-system color token via var()." - }, - { - "selector": "Literal[value=/\\b\\d+px\\b/]", - "message": "Raw px value — use a design-system spacing token via var()." - }, - { - "selector": "JSXOpeningElement[name.name='ActorBadge'] > JSXAttribute > JSXIdentifier[name!=/^(?:actor|initials|color|key|ref|className|style|children)$/]", - "message": " doesn't accept that prop. Declared props: actor, initials, color." - }, - { - "selector": "JSXOpeningElement[name.name='ActorBadge'] > JSXAttribute[name.name='actor'] > Literal[value!=/^(?:ai|human)$/]", - "message": " actor must be one of 'ai' | 'human'." - }, - { - "selector": "JSXOpeningElement[name.name='AttentionStatus'] > JSXAttribute > JSXIdentifier[name!=/^(?:level|label|key|ref|className|style|children)$/]", - "message": " doesn't accept that prop. Declared props: level, label." - }, - { - "selector": "JSXOpeningElement[name.name='AttentionStatus'] > JSXAttribute[name.name='level'] > Literal[value!=/^(?:ok|attention|critical)$/]", - "message": " level must be one of 'ok' | 'attention' | 'critical'." - }, - { - "selector": "JSXOpeningElement[name.name='Avatar'] > JSXAttribute > JSXIdentifier[name!=/^(?:initials|color|size|key|ref|className|style|children)$/]", - "message": " doesn't accept that prop. Declared props: initials, color, size." - }, - { - "selector": "JSXOpeningElement[name.name='Button'] > JSXAttribute > JSXIdentifier[name!=/^(?:children|variant|size|disabled|onClick|type|key|ref|className|style|children)$/]", - "message": " - -``` - -Variants: `primary` (solid `--primary`, colored shadow — the one CTA per context), `secondary` (white + border, blue on hover), `ghost` (borderless, gray hover fill), `danger` (destructive red, requires confirmation per ADR-HUB-0013 accessibility rules). Sizes: `sm` 30px, `md` 36px (default), `lg` 44px (auth/checkout primary actions). diff --git a/design/Edevs Hub Design System/components/core/Checkbox.d.ts b/design/Edevs Hub Design System/components/core/Checkbox.d.ts deleted file mode 100644 index 75000a2..0000000 --- a/design/Edevs Hub Design System/components/core/Checkbox.d.ts +++ /dev/null @@ -1,8 +0,0 @@ -export interface CheckboxProps { - checked?: boolean; - onChange?: () => void; - label?: string | JSX.Element; - size?: "sm" | "md"; -} - -export function Checkbox(props: CheckboxProps): JSX.Element; diff --git a/design/Edevs Hub Design System/components/core/Checkbox.jsx b/design/Edevs Hub Design System/components/core/Checkbox.jsx deleted file mode 100644 index 98f82ef..0000000 --- a/design/Edevs Hub Design System/components/core/Checkbox.jsx +++ /dev/null @@ -1,38 +0,0 @@ -import React from "react"; - -export function Checkbox(props) { - const on = !!props.checked; - return React.createElement( - "button", - { - type: "button", - onClick: props.onChange, - style: { display: "flex", alignItems: "flex-start", gap: 10, background: "none", border: "none", padding: 0, cursor: "pointer", textAlign: "left" }, - }, - React.createElement( - "span", - { - style: { - width: props.size === "sm" ? 17 : 20, - height: props.size === "sm" ? 17 : 20, - borderRadius: props.size === "sm" ? 5 : 6, - flex: "none", - display: "flex", - alignItems: "center", - justifyContent: "center", - border: on ? "none" : "1.5px solid var(--n-6)", - background: on ? "var(--primary)" : "var(--surface-card)", - marginTop: props.label ? 1 : 0, - }, - }, - on - ? React.createElement( - "svg", - { viewBox: "0 0 24 24", width: props.size === "sm" ? 12 : 13, height: props.size === "sm" ? 12 : 13, fill: "none", stroke: "#fff", strokeWidth: 3, strokeLinecap: "round", strokeLinejoin: "round" }, - React.createElement("path", { d: "M20 6 9 17l-5-5" }) - ) - : null - ), - props.label ? React.createElement("span", { style: { fontSize: 12, color: "var(--text-secondary)", lineHeight: 1.45 } }, props.label) : null - ); -} diff --git a/design/Edevs Hub Design System/components/core/Checkbox.prompt.md b/design/Edevs Hub Design System/components/core/Checkbox.prompt.md deleted file mode 100644 index b5cd628..0000000 --- a/design/Edevs Hub Design System/components/core/Checkbox.prompt.md +++ /dev/null @@ -1,5 +0,0 @@ -Custom checkbox (rounded square, solid blue fill when checked) — used for legal-consent rows in Checkout and multi-select filters in tables. - -```jsx -setAgree(!agree)} label={<>Принимаю условия} /> -``` diff --git a/design/Edevs Hub Design System/components/core/Icon.d.ts b/design/Edevs Hub Design System/components/core/Icon.d.ts deleted file mode 100644 index 86560af..0000000 --- a/design/Edevs Hub Design System/components/core/Icon.d.ts +++ /dev/null @@ -1,16 +0,0 @@ -export interface IconProps { - /** Which glyph to render — see ICON_NAMES for the full curated set copied from the baseline. */ - name: - | "dashboard" | "departments" | "employees" | "products" | "ai" | "integrations" | "settings" - | "search" | "chevronDown" | "chevronRight" | "moreVertical" | "check" | "checkCircle" - | "alertTriangle" | "alertCircle" | "xCircle" | "clock" | "refresh" | "arrowRight" | "arrowLeft" - | "mail" | "lock" | "eye" | "eyeOff" | "card" | "sbp" | "shield" | "download" | "columns" - | "bell" | "trash" | "edit" | "merge" | "bolt" | "cart" | "history" | "team" | "package2"; - /** Pixel size (square). Default 17. */ - size?: number; - /** Stroke color. Default currentColor. */ - color?: string; - strokeWidth?: number; -} - -export function Icon(props: IconProps): JSX.Element | null; diff --git a/design/Edevs Hub Design System/components/core/Icon.jsx b/design/Edevs Hub Design System/components/core/Icon.jsx deleted file mode 100644 index b052dab..0000000 --- a/design/Edevs Hub Design System/components/core/Icon.jsx +++ /dev/null @@ -1,70 +0,0 @@ -import React from "react"; - -/* Stroke icon set copied verbatim (path data) from the approved HTML baseline - (design/baseline/*.dc.html) — a Feather/Lucide-style outline set used - throughout Hub, Web Chat and Checkout. Not a bundled icon font/sprite in the - source repo: each screen inlines its own , so this component collects - the recurring ones into one reusable primitive. */ -const PATHS = { - dashboard: '', - departments: '', - employees: '', - products: '', - ai: '', - integrations: '', - settings: '', - search: '', - chevronDown: '', - chevronRight: '', - moreVertical: '', - check: '', - checkCircle: '', - alertTriangle: '', - alertCircle: '', - xCircle: '', - clock: '', - refresh: '', - arrowRight: '', - arrowLeft: '', - mail: '', - lock: '', - eye: '', - eyeOff: '', - card: '', - sbp: '', - shield: '', - download: '', - columns: '', - bell: '', - trash: '', - edit: '', - merge: '', - bolt: '', - cart: '', - history: '', - team: '', - package2: '', -}; - -export function Icon(props) { - const name = props.name; - const size = props.size ?? 17; - const color = props.color ?? "currentColor"; - const strokeWidth = props.strokeWidth ?? 1.8; - const d = PATHS[name]; - if (!d) return null; - return React.createElement("svg", { - viewBox: "0 0 24 24", - width: size, - height: size, - fill: "none", - stroke: color, - strokeWidth: strokeWidth, - strokeLinecap: "round", - strokeLinejoin: "round", - style: props.style, - dangerouslySetInnerHTML: { __html: d }, - }); -} - -export const ICON_NAMES = Object.keys(PATHS); diff --git a/design/Edevs Hub Design System/components/core/Icon.prompt.md b/design/Edevs Hub Design System/components/core/Icon.prompt.md deleted file mode 100644 index d7f910b..0000000 --- a/design/Edevs Hub Design System/components/core/Icon.prompt.md +++ /dev/null @@ -1,7 +0,0 @@ -A single reusable stroke-icon glyph, drawn from the exact icon paths used across the Hub/Web Chat/Checkout baseline (Feather/Lucide-style outline, 1.8 stroke). - -```jsx - -``` - -Notable: `strokeWidth` defaults to 1.8 to match nav icons; bump to 2–2.2 for small/bold contexts (buttons, close icons). Unknown `name` renders nothing — check `ICON_NAMES` for the full set. diff --git a/design/Edevs Hub Design System/components/core/IconButton.d.ts b/design/Edevs Hub Design System/components/core/IconButton.d.ts deleted file mode 100644 index 4489056..0000000 --- a/design/Edevs Hub Design System/components/core/IconButton.d.ts +++ /dev/null @@ -1,17 +0,0 @@ -import React from "react"; - -/** - * @startingPoint section="Core" subtitle="Square icon-only button with optional badge" viewport="700x150" - */ -export interface IconButtonProps { - children?: React.ReactNode; - size?: number; - /** Small numeric/text badge in the top-right corner (e.g. notification count). */ - badge?: string | number; - /** Borderless variant (used inline in tables/menus). */ - bare?: boolean; - label?: string; - onClick?: (e: React.MouseEvent) => void; -} - -export function IconButton(props: IconButtonProps): JSX.Element; diff --git a/design/Edevs Hub Design System/components/core/IconButton.jsx b/design/Edevs Hub Design System/components/core/IconButton.jsx deleted file mode 100644 index cce2134..0000000 --- a/design/Edevs Hub Design System/components/core/IconButton.jsx +++ /dev/null @@ -1,56 +0,0 @@ -import React from "react"; - -export function IconButton(props) { - const size = props.size ?? 36; - const [hover, setHover] = React.useState(false); - return React.createElement( - "button", - { - style: { - position: "relative", - width: size, - height: size, - borderRadius: "var(--radius-md)", - border: props.bare ? "none" : "1px solid var(--n-8)", - background: hover ? "var(--n-9)" : "var(--surface-card)", - display: "flex", - alignItems: "center", - justifyContent: "center", - cursor: "pointer", - color: hover ? "var(--text-body)" : "var(--text-secondary)", - flex: "none", - }, - onClick: props.onClick, - onMouseEnter: () => setHover(true), - onMouseLeave: () => setHover(false), - "aria-label": props.label, - title: props.label, - }, - props.children, - props.badge - ? React.createElement( - "span", - { - style: { - position: "absolute", - top: 6, - right: 7, - minWidth: 15, - height: 15, - padding: "0 4px", - borderRadius: 8, - background: "var(--error)", - color: "#fff", - fontSize: 10, - fontWeight: 600, - display: "flex", - alignItems: "center", - justifyContent: "center", - border: "1.5px solid var(--surface-card)", - }, - }, - props.badge - ) - : null - ); -} diff --git a/design/Edevs Hub Design System/components/core/IconButton.prompt.md b/design/Edevs Hub Design System/components/core/IconButton.prompt.md deleted file mode 100644 index 433a789..0000000 --- a/design/Edevs Hub Design System/components/core/IconButton.prompt.md +++ /dev/null @@ -1,8 +0,0 @@ -A square icon-only button — topbar actions (notifications, refresh), table row menus. - -```jsx - - -``` - -Always pass `label` — it becomes both the tooltip and the accessible name (icon-only buttons need a text alternative per ADR-HUB-0013 accessibility rules). `bare` drops the border for menu/table contexts. diff --git a/design/Edevs Hub Design System/components/core/Input.d.ts b/design/Edevs Hub Design System/components/core/Input.d.ts deleted file mode 100644 index eaedda0..0000000 --- a/design/Edevs Hub Design System/components/core/Input.d.ts +++ /dev/null @@ -1,24 +0,0 @@ -import React from "react"; - -/** - * @startingPoint section="Core" subtitle="Text field with label, icon and error state" viewport="700x260" - */ -export interface InputProps { - value?: string; - onChange?: (e: React.ChangeEvent) => void; - placeholder?: string; - label?: string; - required?: boolean; - type?: "text" | "email" | "password" | "tel"; - disabled?: boolean; - error?: boolean; - /** Small helper/error line under the field. */ - hint?: string; - /** Leading icon element, e.g. . */ - icon?: React.ReactNode; - /** Trailing element, e.g. a show/hide password toggle. */ - suffix?: React.ReactNode; - fullWidth?: boolean; -} - -export function Input(props: InputProps): JSX.Element; diff --git a/design/Edevs Hub Design System/components/core/Input.jsx b/design/Edevs Hub Design System/components/core/Input.jsx deleted file mode 100644 index 10b6cad..0000000 --- a/design/Edevs Hub Design System/components/core/Input.jsx +++ /dev/null @@ -1,56 +0,0 @@ -import React from "react"; - -export function Input(props) { - const [focused, setFocused] = React.useState(false); - const hasError = !!props.error; - const borderColor = hasError ? "var(--error-border)" : focused ? "var(--primary)" : "var(--border-input)"; - return React.createElement( - "div", - { style: { display: "flex", flexDirection: "column", gap: 5, width: props.fullWidth === false ? "auto" : "100%" } }, - props.label - ? React.createElement( - "label", - { style: { fontSize: 12.5, fontWeight: 500, color: "var(--text-secondary)" } }, - props.label, - props.required ? React.createElement("span", { style: { color: "var(--error-text)" } }, " *") : null - ) - : null, - React.createElement( - "div", - { - style: { - display: "flex", - alignItems: "center", - gap: 9, - height: "var(--h-control-lg)", - padding: "0 13px", - border: `1px solid ${borderColor}`, - borderRadius: "var(--radius-lg)", - background: props.disabled ? "var(--n-9)" : "var(--surface-card)", - }, - }, - props.icon, - React.createElement("input", { - type: props.type ?? "text", - value: props.value, - onChange: props.onChange, - placeholder: props.placeholder, - disabled: props.disabled, - onFocus: () => setFocused(true), - onBlur: () => setFocused(false), - style: { - flex: 1, - border: "none", - outline: "none", - background: "transparent", - fontSize: 14, - color: "var(--text-body)", - fontFamily: "var(--font-sans)", - width: "100%", - }, - }), - props.suffix - ), - props.hint ? React.createElement("div", { style: { fontSize: 11, color: hasError ? "var(--error-text)" : "var(--text-tertiary)" } }, props.hint) : null - ); -} diff --git a/design/Edevs Hub Design System/components/core/Input.prompt.md b/design/Edevs Hub Design System/components/core/Input.prompt.md deleted file mode 100644 index 4162e3f..0000000 --- a/design/Edevs Hub Design System/components/core/Input.prompt.md +++ /dev/null @@ -1,7 +0,0 @@ -Labeled text field matching the auth/checkout/filter-bar inputs — 42px tall, 9px radius, blue focus ring via border color only (no glow). - -```jsx - - - - - - - - - -
- - - diff --git a/design/Edevs Hub Design System/components/inbox/ConversationInbox.d.ts b/design/Edevs Hub Design System/components/inbox/ConversationInbox.d.ts deleted file mode 100644 index 0650c9b..0000000 --- a/design/Edevs Hub Design System/components/inbox/ConversationInbox.d.ts +++ /dev/null @@ -1,21 +0,0 @@ -export interface ConversationInboxItem { - name: string; - preview: string; - channel: "max" | "telegram" | "webchat"; - lastActor: "ai" | "human"; - initials?: string; - color?: string; - time: string; - waiting?: boolean; - active?: boolean; - onClick?: () => void; -} - -/** - * @startingPoint section="Inbox" subtitle="Realtime conversation list (channel + actor + wait state)" viewport="700x360" - */ -export interface ConversationInboxProps { - items: ConversationInboxItem[]; -} - -export function ConversationInbox(props: ConversationInboxProps): JSX.Element; diff --git a/design/Edevs Hub Design System/components/inbox/ConversationInbox.jsx b/design/Edevs Hub Design System/components/inbox/ConversationInbox.jsx deleted file mode 100644 index 091f9d7..0000000 --- a/design/Edevs Hub Design System/components/inbox/ConversationInbox.jsx +++ /dev/null @@ -1,46 +0,0 @@ -import React from "react"; -import { ActorBadge } from "../status/ActorBadge.jsx"; -import { ChannelBadge } from "../status/ChannelBadge.jsx"; - -/* ConversationInbox — the realtime inbox list (ADR-HUB-0013 local component): - one row per conversation with channel, last actor, wait state and time. */ -export function ConversationInbox(props) { - return React.createElement( - "div", - { style: { background: "var(--surface-card)", border: "1px solid var(--n-8)", borderRadius: "var(--radius-2xl)", overflow: "hidden" } }, - (props.items ?? []).map((it, i) => - React.createElement( - "div", - { - key: i, - onClick: it.onClick, - style: { - display: "flex", - alignItems: "center", - gap: 12, - padding: "12px 16px", - borderTop: i === 0 ? "none" : "1px solid var(--n-9)", - cursor: it.onClick ? "pointer" : "default", - background: it.active ? "var(--primary-bg)" : "transparent", - }, - }, - React.createElement(ActorBadge, { actor: it.lastActor, initials: it.initials, color: it.color }), - React.createElement( - "div", - { style: { flex: 1, minWidth: 0 } }, - React.createElement("div", { style: { display: "flex", alignItems: "center", gap: 8 } }, - React.createElement("span", { style: { fontSize: 13.5, fontWeight: 600, color: "var(--text-heading)" } }, it.name), - React.createElement(ChannelBadge, { channel: it.channel }) - ), - React.createElement("div", { style: { fontSize: 12, color: "var(--text-tertiary)", marginTop: 2, whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis" } }, it.preview) - ), - React.createElement( - "div", - { style: { display: "flex", flexDirection: "column", alignItems: "flex-end", gap: 4, flex: "none" } }, - React.createElement("span", { style: { fontSize: 11, color: "var(--text-disabled)" } }, it.time), - it.waiting ? React.createElement("span", { style: { width: 7, height: 7, borderRadius: "50%", background: "var(--warning)" } }) : null - ) - ) - ) - ); -} diff --git a/design/Edevs Hub Design System/components/inbox/ConversationInbox.prompt.md b/design/Edevs Hub Design System/components/inbox/ConversationInbox.prompt.md deleted file mode 100644 index a527d5f..0000000 --- a/design/Edevs Hub Design System/components/inbox/ConversationInbox.prompt.md +++ /dev/null @@ -1,7 +0,0 @@ -The dense realtime inbox list used in Продажи · Диалоги — one row per conversation, showing who's currently in it (AI vs operator), which channel, and whether it's waiting for a human. - -```jsx - -``` diff --git a/design/Edevs Hub Design System/components/inbox/KnowledgeDocumentEditor.d.ts b/design/Edevs Hub Design System/components/inbox/KnowledgeDocumentEditor.d.ts deleted file mode 100644 index a930425..0000000 --- a/design/Edevs Hub Design System/components/inbox/KnowledgeDocumentEditor.d.ts +++ /dev/null @@ -1,16 +0,0 @@ -import React from "react"; - -/** - * @startingPoint section="Inbox" subtitle="Plain-text knowledge base document editor" viewport="700x380" - */ -export interface KnowledgeDocumentEditorProps { - title: string; - onTitleChange?: (e: React.ChangeEvent) => void; - docId?: string; - body: string; - onBodyChange?: (e: React.ChangeEvent) => void; - rows?: number; - savedLabel?: string; -} - -export function KnowledgeDocumentEditor(props: KnowledgeDocumentEditorProps): JSX.Element; diff --git a/design/Edevs Hub Design System/components/inbox/KnowledgeDocumentEditor.jsx b/design/Edevs Hub Design System/components/inbox/KnowledgeDocumentEditor.jsx deleted file mode 100644 index 229cd34..0000000 --- a/design/Edevs Hub Design System/components/inbox/KnowledgeDocumentEditor.jsx +++ /dev/null @@ -1,34 +0,0 @@ -import React from "react"; - -/* KnowledgeDocumentEditor — a minimal document editor shell for AI knowledge - base entries (ADR-HUB-0013 local component): title, meta, and a plain - textarea body — no rich-text chrome. */ -export function KnowledgeDocumentEditor(props) { - return React.createElement( - "div", - { style: { background: "var(--surface-card)", border: "1px solid var(--n-8)", borderRadius: "var(--radius-2xl)", boxShadow: "var(--shadow-sm)", display: "flex", flexDirection: "column" } }, - React.createElement( - "div", - { style: { padding: "14px 18px", borderBottom: "1px solid var(--n-9)", display: "flex", alignItems: "center", justifyContent: "space-between" } }, - React.createElement( - "div", - null, - React.createElement("input", { - value: props.title, - onChange: props.onTitleChange, - placeholder: "Название документа", - style: { border: "none", outline: "none", fontSize: 15, fontWeight: 700, color: "var(--text-heading)", fontFamily: "var(--font-sans)", width: "100%" }, - }), - React.createElement("div", { style: { fontSize: 11, color: "var(--text-disabled)", marginTop: 3, fontFamily: "var(--font-mono)" } }, props.docId) - ), - React.createElement("span", { style: { fontSize: 11.5, color: "var(--text-tertiary)" } }, props.savedLabel ?? "Сохранено") - ), - React.createElement("textarea", { - value: props.body, - onChange: props.onBodyChange, - placeholder: "Текст базы знаний…", - rows: props.rows ?? 10, - style: { border: "none", outline: "none", resize: "vertical", padding: "16px 18px", fontSize: 13.5, lineHeight: 1.6, color: "var(--text-body)", fontFamily: "var(--font-sans)" }, - }) - ); -} diff --git a/design/Edevs Hub Design System/components/inbox/KnowledgeDocumentEditor.prompt.md b/design/Edevs Hub Design System/components/inbox/KnowledgeDocumentEditor.prompt.md deleted file mode 100644 index 0cb0f7e..0000000 --- a/design/Edevs Hub Design System/components/inbox/KnowledgeDocumentEditor.prompt.md +++ /dev/null @@ -1,5 +0,0 @@ -A deliberately plain document editor (title + textarea, no rich-text toolbar) for AI knowledge-base entries and prompts — matches Hub's "no reinventing standard patterns" rule by staying as unadorned as possible. - -```jsx -setT(e.target.value)} docId="KB-0091" body={b} onBodyChange={e=>setB(e.target.value)} /> -``` diff --git a/design/Edevs Hub Design System/components/inbox/inbox.card.html b/design/Edevs Hub Design System/components/inbox/inbox.card.html deleted file mode 100644 index 61014eb..0000000 --- a/design/Edevs Hub Design System/components/inbox/inbox.card.html +++ /dev/null @@ -1,33 +0,0 @@ - - - - - - - - - - - - -
- - - diff --git a/design/Edevs Hub Design System/components/status/ActorBadge.d.ts b/design/Edevs Hub Design System/components/status/ActorBadge.d.ts deleted file mode 100644 index 15bde7d..0000000 --- a/design/Edevs Hub Design System/components/status/ActorBadge.d.ts +++ /dev/null @@ -1,11 +0,0 @@ -/** - * @startingPoint section="Status" subtitle="AI vs human avatar for conversation timelines" viewport="700x110" - */ -export interface ActorBadgeProps { - actor: "ai" | "human"; - /** Required when actor="human" — 1-2 letter initials. */ - initials?: string; - color?: string; -} - -export function ActorBadge(props: ActorBadgeProps): JSX.Element; diff --git a/design/Edevs Hub Design System/components/status/ActorBadge.jsx b/design/Edevs Hub Design System/components/status/ActorBadge.jsx deleted file mode 100644 index 56248ce..0000000 --- a/design/Edevs Hub Design System/components/status/ActorBadge.jsx +++ /dev/null @@ -1,24 +0,0 @@ -import React from "react"; - -/* ActorBadge — distinguishes an AI participant from a human operator in a - conversation timeline (ADR-HUB-0013: "ai"/"human" semantic tokens). */ -export function ActorBadge(props) { - const isAI = props.actor === "ai"; - if (isAI) { - return React.createElement( - "div", - { style: { width: 28, height: 28, borderRadius: "50%", background: "#eef0f2", border: "1px solid #e3e6ea", display: "flex", alignItems: "center", justifyContent: "center", flex: "none" } }, - React.createElement( - "svg", - { viewBox: "0 0 24 24", width: 15, height: 15, fill: "none", stroke: "#8c8c8c", strokeWidth: 1.8, strokeLinecap: "round", strokeLinejoin: "round" }, - React.createElement("circle", { cx: 12, cy: 8, r: 3.2 }), - React.createElement("path", { d: "M5.5 20a6.5 6.5 0 0 1 13 0" }) - ) - ); - } - return React.createElement( - "div", - { style: { width: 28, height: 28, borderRadius: "50%", background: props.color ?? "var(--primary)", color: "#fff", display: "flex", alignItems: "center", justifyContent: "center", fontSize: 11, fontWeight: 600, flex: "none" } }, - props.initials - ); -} diff --git a/design/Edevs Hub Design System/components/status/ActorBadge.prompt.md b/design/Edevs Hub Design System/components/status/ActorBadge.prompt.md deleted file mode 100644 index 6438fbf..0000000 --- a/design/Edevs Hub Design System/components/status/ActorBadge.prompt.md +++ /dev/null @@ -1,6 +0,0 @@ -Small round avatar used at the start of every chat bubble, distinguishing the AI agent (neutral gray, bot glyph) from a human operator (colored initials). - -```jsx - - -``` diff --git a/design/Edevs Hub Design System/components/status/AttentionStatus.d.ts b/design/Edevs Hub Design System/components/status/AttentionStatus.d.ts deleted file mode 100644 index 035a322..0000000 --- a/design/Edevs Hub Design System/components/status/AttentionStatus.d.ts +++ /dev/null @@ -1,10 +0,0 @@ -/** - * @startingPoint section="Status" subtitle="Company/department health chip (ok/attention/critical)" viewport="700x120" - */ -export interface AttentionStatusProps { - level?: "ok" | "attention" | "critical"; - /** Override the default Russian label for this level. */ - label?: string; -} - -export function AttentionStatus(props: AttentionStatusProps): JSX.Element; diff --git a/design/Edevs Hub Design System/components/status/AttentionStatus.jsx b/design/Edevs Hub Design System/components/status/AttentionStatus.jsx deleted file mode 100644 index cbacd8f..0000000 --- a/design/Edevs Hub Design System/components/status/AttentionStatus.jsx +++ /dev/null @@ -1,29 +0,0 @@ -import React from "react"; - -const TONE = { - ok: { dot: "#52c41a", color: "var(--success-text)", bg: "var(--success-bg)", border: "var(--success-border)", label: "Нормально" }, - attention: { dot: "#faad14", color: "var(--warning-text)", bg: "var(--warning-bg)", border: "var(--warning-border)", label: "Требует внимания" }, - critical: { dot: "#ff4d4f", color: "var(--error-text)", bg: "var(--error-bg)", border: "var(--error-border)", label: "Критично" }, -}; - -/* AttentionStatus — the company/department health chip (ADR-HUB-0013 local component). - Always pairs a colored dot with a text label so color is never the only signal. */ -export function AttentionStatus(props) { - const t = TONE[props.level ?? "ok"]; - return React.createElement( - "span", - { - style: { - display: "inline-flex", - alignItems: "center", - gap: 7, - padding: "3px 11px", - borderRadius: "var(--radius-pill)", - background: t.bg, - border: `1px solid ${t.border}`, - }, - }, - React.createElement("span", { style: { width: 7, height: 7, borderRadius: "50%", background: t.dot, flex: "none" } }), - React.createElement("span", { style: { fontSize: 12.5, fontWeight: 600, color: t.color } }, props.label ?? t.label) - ); -} diff --git a/design/Edevs Hub Design System/components/status/AttentionStatus.prompt.md b/design/Edevs Hub Design System/components/status/AttentionStatus.prompt.md deleted file mode 100644 index b239466..0000000 --- a/design/Edevs Hub Design System/components/status/AttentionStatus.prompt.md +++ /dev/null @@ -1,7 +0,0 @@ -Health/status pill for company and department state — drives the Command Center banner border color, department card status, and page-level scenario switches. - -```jsx - -``` - -Levels map 1:1 to the semantic tokens: `ok` → success, `attention` → warning, `critical` → error. Never used as the sole indicator of a destructive action — always paired with a text summary nearby. diff --git a/design/Edevs Hub Design System/components/status/ChannelBadge.d.ts b/design/Edevs Hub Design System/components/status/ChannelBadge.d.ts deleted file mode 100644 index ebe5cdd..0000000 --- a/design/Edevs Hub Design System/components/status/ChannelBadge.d.ts +++ /dev/null @@ -1,9 +0,0 @@ -/** - * @startingPoint section="Status" subtitle="MAX / Telegram / Web Chat channel chip" viewport="700x100" - */ -export interface ChannelBadgeProps { - channel: "max" | "telegram" | "webchat"; - title?: string; -} - -export function ChannelBadge(props: ChannelBadgeProps): JSX.Element; diff --git a/design/Edevs Hub Design System/components/status/ChannelBadge.jsx b/design/Edevs Hub Design System/components/status/ChannelBadge.jsx deleted file mode 100644 index ecb3050..0000000 --- a/design/Edevs Hub Design System/components/status/ChannelBadge.jsx +++ /dev/null @@ -1,19 +0,0 @@ -import React from "react"; - -const CHANNELS = { - max: { label: "MAX", color: "var(--channel-max)", bg: "var(--channel-max-bg)" }, - telegram: { label: "TG", color: "var(--channel-telegram)", bg: "var(--channel-telegram-bg)" }, - webchat: { label: "Web", color: "var(--channel-webchat)", bg: "var(--channel-webchat-bg)" }, -}; - -/* ChannelBadge — differentiates MAX / Telegram / Web Chat without turning the - UI into a colorful mosaic (ADR-HUB-0013: "channel-*" tokens). */ -export function ChannelBadge(props) { - const c = CHANNELS[props.channel]; - return React.createElement( - "span", - { title: props.title ?? c.label, style: { display: "inline-flex", alignItems: "center", gap: 4, padding: "2px 7px", borderRadius: 5, background: c.bg } }, - React.createElement("span", { style: { width: 5, height: 5, borderRadius: "50%", background: c.color } }), - React.createElement("span", { style: { fontSize: 10.5, fontWeight: 600, color: c.color } }, c.label) - ); -} diff --git a/design/Edevs Hub Design System/components/status/ChannelBadge.prompt.md b/design/Edevs Hub Design System/components/status/ChannelBadge.prompt.md deleted file mode 100644 index 4100872..0000000 --- a/design/Edevs Hub Design System/components/status/ChannelBadge.prompt.md +++ /dev/null @@ -1,5 +0,0 @@ -Tiny dot+label chip identifying which messaging channel a client/conversation came in on. - -```jsx - -``` diff --git a/design/Edevs Hub Design System/components/status/status.card.html b/design/Edevs Hub Design System/components/status/status.card.html deleted file mode 100644 index 448ca57..0000000 --- a/design/Edevs Hub Design System/components/status/status.card.html +++ /dev/null @@ -1,48 +0,0 @@ - - - - - - - - - - - - -
- - - diff --git a/design/Edevs Hub Design System/guidelines/colors-ai-channels.card.html b/design/Edevs Hub Design System/guidelines/colors-ai-channels.card.html deleted file mode 100644 index 4e8b6bf..0000000 --- a/design/Edevs Hub Design System/guidelines/colors-ai-channels.card.html +++ /dev/null @@ -1,14 +0,0 @@ - - - - -
- AI · #722ed1 - Human · #1677ff - MAX · #6b5be0 - Telegram · #2f8fd0 - Web Chat · #0f9b8e - FirePage · #0958d9 - Foxray · #722ed1 -
- diff --git a/design/Edevs Hub Design System/guidelines/colors-neutrals.card.html b/design/Edevs Hub Design System/guidelines/colors-neutrals.card.html deleted file mode 100644 index 3444974..0000000 --- a/design/Edevs Hub Design System/guidelines/colors-neutrals.card.html +++ /dev/null @@ -1,19 +0,0 @@ - - - - -
-
n-1 heading
-
n-2 body
-
n-3 secondary
-
n-4 tertiary
-
n-5 disabled
-
n-6 border
-
n-7 input
-
n-8 hairline
-
n-9 hover
-
n-10 table hd
-
n-11 page bg
-
white surface
-
- diff --git a/design/Edevs Hub Design System/guidelines/colors-primary.card.html b/design/Edevs Hub Design System/guidelines/colors-primary.card.html deleted file mode 100644 index 39fe033..0000000 --- a/design/Edevs Hub Design System/guidelines/colors-primary.card.html +++ /dev/null @@ -1,19 +0,0 @@ - - - - -
-
-
-
--primary #1677ff
-
-
-
-
--primary-hover #0958d9
-
-
-
-
--primary-bg #e6f4ff
-
-
- diff --git a/design/Edevs Hub Design System/guidelines/colors-semantic.card.html b/design/Edevs Hub Design System/guidelines/colors-semantic.card.html deleted file mode 100644 index 6526ab5..0000000 --- a/design/Edevs Hub Design System/guidelines/colors-semantic.card.html +++ /dev/null @@ -1,11 +0,0 @@ - - - - -
-
Success
#389e0d
-
Warning
#d48806
-
Error
#cf1322
-
Info
#1677ff
-
- diff --git a/design/Edevs Hub Design System/guidelines/logo-mark.card.html b/design/Edevs Hub Design System/guidelines/logo-mark.card.html deleted file mode 100644 index 7e6acfc..0000000 --- a/design/Edevs Hub Design System/guidelines/logo-mark.card.html +++ /dev/null @@ -1,11 +0,0 @@ - - - - - -
- - Edevs Hub -
-
No separate FirePage/Foxray logos were found in the codebase — plain type stands in for their marks.
- diff --git a/design/Edevs Hub Design System/guidelines/radii-shadows.card.html b/design/Edevs Hub Design System/guidelines/radii-shadows.card.html deleted file mode 100644 index af05def..0000000 --- a/design/Edevs Hub Design System/guidelines/radii-shadows.card.html +++ /dev/null @@ -1,12 +0,0 @@ - - - - -
-
-
-
-
-
-
radius: 7 (control) · 12 (card) · 14 (modal) · pill (chip) — shadow: xs (hairline) · sm (card) · md (modal) · lg (dropdown)
- diff --git a/design/Edevs Hub Design System/guidelines/spacing-metric-grid.card.html b/design/Edevs Hub Design System/guidelines/spacing-metric-grid.card.html deleted file mode 100644 index 460d301..0000000 --- a/design/Edevs Hub Design System/guidelines/spacing-metric-grid.card.html +++ /dev/null @@ -1,12 +0,0 @@ - - - - -
ДИАЛОГИ — СЕЙЧАС
-
-
Открытые диалоги
42
-
Активны за 15 мин
9
-
На AI
35
-
Ожидают
0
-
- diff --git a/design/Edevs Hub Design System/guidelines/spacing-scale.card.html b/design/Edevs Hub Design System/guidelines/spacing-scale.card.html deleted file mode 100644 index c4c8af6..0000000 --- a/design/Edevs Hub Design System/guidelines/spacing-scale.card.html +++ /dev/null @@ -1,15 +0,0 @@ - - - - -
-
8
-
10
-
12
-
16
-
20
-
24
-
32
-
-
Control heights: sm 30 · md 34 · default 36 · lg 42 · xl 44 · 2xl 48 · topbar 56
- diff --git a/design/Edevs Hub Design System/guidelines/type-mono.card.html b/design/Edevs Hub Design System/guidelines/type-mono.card.html deleted file mode 100644 index e264913..0000000 --- a/design/Edevs Hub Design System/guidelines/type-mono.card.html +++ /dev/null @@ -1,10 +0,0 @@ - - - - -
-
HUB-2026-004187 — order id
-
CHK-9F2A-7C10 — support reference
-
m.sokolova@workmail.ru — contact email
-
- diff --git a/design/Edevs Hub Design System/guidelines/type-scale.card.html b/design/Edevs Hub Design System/guidelines/type-scale.card.html deleted file mode 100644 index 789a92f..0000000 --- a/design/Edevs Hub Design System/guidelines/type-scale.card.html +++ /dev/null @@ -1,12 +0,0 @@ - - - - -
Командный центр 26/700
-
Клиенты 24/700
-
Продажи · заголовок карточки 18/700
-
Основной текст / кнопки 14.5/600
-
Табличный текст 13/400
-
Вспомогательный текст 12.5/400 — #8c8c8c
-
ЗАГОЛОВОК ТАБЛИЦЫ 11/600 · +0.03em
- diff --git a/design/Edevs Hub Design System/readme.md b/design/Edevs Hub Design System/readme.md deleted file mode 100644 index 6a6b2a3..0000000 --- a/design/Edevs Hub Design System/readme.md +++ /dev/null @@ -1,216 +0,0 @@ -# Edevs Hub — Design System - -Design system for **Edevs**, a Russian product IT company (`edevs.tech`) that builds applied -digital products for narrow professional niches — currently **FoxRay** (cephalometric/TRG -analysis for orthodontists) and **FirePage** (ready-made niche websites on a proprietary CMS, -sold as a one-time license). This design system, however, documents **Edevs Hub** -(`hub.edevs.tech`) — the internal company platform that runs sales, support, AI agents, and -commerce for those products, plus the two small public-facing surfaces it exposes (Web Chat -widget and Checkout). - -## Sources - -Everything here was extracted from the attached codebase, mounted read-only at -`hub.edevs.tech/`: - -- `hub.edevs.tech/design/baseline/*.dc.html` — the team's own **approved HTML visual baseline** - (explicitly the canonical design source per `SPEC-HUB-0007`, section 3: *"Утверждённые - дизайн-файлы размещаются только в едином каноническом каталоге: - `code/hub.edevs.tech/design/baseline/`"*). ~35 screens covering Command Center, Sales - (overview/clients/dialogs/orders), Departments, Employees, Products, AI agents (list/create/ - detail/knowledge), Integrations, Settings, Auth, the Web Chat widget, and the Checkout flow. -- `hub.edevs.tech/design/baseline/uploads/ADR-HUB-0013-Internal-UI-Design-System.md` — the - Internal Hub UI design-system decision record (Ant Design + Ant Design X + "Edevs Hub Theme"). -- `hub.edevs.tech/design/baseline/uploads/SPEC-HUB-0007-Checkout-UI.md` — the public checkout - interface spec (`pay.hub.edevs.tech`). -- `hub.edevs.tech/design/baseline/uploads/SPEC-HUB-0003-Web-Chat.md`, - `SPEC-HUB-0004-Internal-Hub-UI.md`, `SPEC-HUB-0005-Implemented-Internal-UI-Baseline.md`, - `SPEC-HUB-0001-First-Iteration.md`, `ADR-HUB-0017-AI-Agent-Configuration-UX.md` — supporting - specs referenced but not individually quoted below. -- `hub.edevs.tech/packages/ui/src/theme.ts` — the actual Ant Design `ThemeConfig` ("Edevs Hub - Theme") used by the Internal UI: primary color, radii, per-component overrides. - `hub.edevs.tech/apps/internal-ui/src/styles/base.css` — global resets. -- `hub.edevs.tech/apps/internal-ui/public/favicon.svg` — the one brand mark present in the repo. -- `hub.edevs.tech/content/ai-content-company-filled.md` — the AI agent's system/persona prompts - for the main company site, used for the Content Fundamentals section below. -- `hub.edevs.tech/apps/checkout` and `hub.edevs.tech/apps/web-chat` — app shells for the public - surfaces (the checkout app has no implementation yet; the design baseline is ground truth for - both). - -No Figma file or link was provided for this project — the HTML baseline above is the sole -design source, and per the team's own spec it *is* the canonical one. - -## Products in scope - -1. **Internal Hub** (`hub.edevs.tech`, `apps/internal-ui`) — the OWNER/OPERATOR back office: - Command Center, Departments, Employees, Products, Sales workspace (overview, clients, - dialogs, orders), AI agents & knowledge, Integrations, Settings, Auth. Built on Ant Design + - Ant Design X, desktop-first (1024px+, designed for 1440px). -2. **Web Chat** (`apps/web-chat`) — the embeddable public support/sales chat widget for - products like FoxRay, handling AI, human handoff, and inline checkout cards. -3. **Checkout** (`apps/checkout`, `pay.hub.edevs.tech`) — the public, mobile-first purchase flow - that hands off to Bank Tochka for payment and separately tracks payment vs. product delivery. - -## Index - -- `styles.css` — root stylesheet, imports everything under `tokens/`. -- `tokens/colors.css`, `typography.css`, `spacing.css`, `effects.css` — design tokens. -- `assets/logo-mark.svg`, `favicon.svg` — the one brand mark found in the codebase. -- `components/core/` — Button, IconButton, Input, Checkbox, Tag, Avatar, Icon (intentional - additions — see below). -- `components/status/` — AttentionStatus, ActorBadge, ChannelBadge. -- `components/cards/` — MetricCard, DepartmentCard, IntegrationHealthCard, - ProductAIReleaseCard, CommerceStatusTimeline. -- `components/inbox/` — ConversationInbox, KnowledgeDocumentEditor. -- `guidelines/` — foundation specimen cards (Colors, Type, Spacing, Brand). -- `ui_kits/internal-hub/` — Auth → Command Center → Sales · Clients, clickable. -- `ui_kits/web-chat/` — the embeddable chat widget, all 5 states. -- `ui_kits/checkout/` — the public checkout flow, all 8 states. - -## Components - -Full list (16), grouped by directory: - -- **Core** (intentional additions, see below): `Button`, `IconButton`, `Input`, `Checkbox`, - `Tag`, `Avatar`, `Icon`. -- **Status** (from ADR-HUB-0013's local-component list): `AttentionStatus`, `ActorBadge`, - `ChannelBadge`. -- **Cards** (from ADR-HUB-0013): `MetricCard`, `DepartmentCard`, `IntegrationHealthCard`, - `ProductAIReleaseCard`, `CommerceStatusTimeline`. -- **Inbox** (from ADR-HUB-0013): `ConversationInbox`, `KnowledgeDocumentEditor`. - -### Intentional additions - -ADR-HUB-0013 states the Internal Hub is built on **Ant Design + Ant Design X**, with only ten -named local composite components on top (listed under "Status/Cards/Inbox" above — built -exactly as named). Ant Design itself isn't installable in this static-HTML environment, so to -make the UI kits and composite components actually renderable, this system also includes a -small set of Ant-Design-styled primitives that the ADR assumes but doesn't name: -`Button`, `IconButton`, `Input`, `Checkbox`, `Tag`, `Avatar`, `Icon`. Their visuals (radii, -heights, colors) are copied exactly from the approved HTML baseline, not invented — treat them -as a faithful stand-in for the real Ant Design instance, not a design decision of their own. - -## Content fundamentals - -Drawn from the AI agent system prompts (`content/ai-content-company-filled.md`) and the copy -throughout the baseline screens (Russian-language product, primary audience is Russian). - -- **Language & register:** Russian, informal-but-respectful "вы" register — direct, calm, - no hard-sell. The agent prompt is explicit: "Пиши как нормальный человек в мессенджере. Не как объявление, не как пресс-релиз и не как робот, который сразу вываливает всю информацию." -- **Tone:** short, human paragraphs, answer the actual question first, then suggest a next - step: "Отвечай по сути вопроса, короткими живыми абзацами." and "Не дави на покупку. Главная задача общего агента — правильно понять потребность и направить человека туда, где он быстрее получит пользу." -- **Formatting discipline:** no markdown inside chat replies — "Не используй markdown-разметку внутри ответа: не ставь заголовки, списки с маркерами, жирный текст, таблицы и разделители." UI copy elsewhere (Hub, checkout) mirrors this: plain sentences, no bullet-heavy microcopy. -- **Emoji:** rare and situational, never decorative — "Уместные смайлики допустимы, но редко и по ситуации." No emoji appear anywhere in the Hub, Web Chat or Checkout baseline screens. -- **Cultural register:** "Учитывай российский контекст общения: уважительно, прямо, без давления и без чрезмерной рекламности." -- **Honesty/scope discipline:** never invent facts, prices, or promises outside the knowledge - base — "Если данных не хватает, не выдумывай. Скажи, что этот момент лучше уточнить у специалиста, и передай оператору." and pricing/legal specifics always come from the server, never the client: see Checkout spec §16 ("не доверять цене и составу из состояния браузера"). -- **Handoff phrasing:** warm, not robotic, when passing to a human — "При передаче не отвечай сухо. Напиши по-человечески: понял задачу, здесь лучше подключить специалиста, передаю вопрос." -- **UI microcopy style (from the baseline):** short declarative status lines ("Готовим оплату", - "Оплата получена", "Требует внимания"), technical IDs always in monospace, money always as - `₽95 040` (₽ prefix, space thousands separator, no decimals for round sums). -- **Domains:** only `edevs.tech` is the confirmed public site; `edevs.ru` is explicitly - forbidden in agent replies. - -## Visual foundations - -- **Overall direction (ADR-HUB-0013):** *"светлая тема; премиальный минимализм; высокая, но - контролируемая информационная плотность; спокойная нейтральная основа; один основной - акцентный цвет; цвет используется прежде всего для статусов и действий; минимум декоративных - элементов; отсутствие градиентов, стекломорфизма, неоновых эффектов и визуального шума; - desktop-first."* (light theme, premium minimalism, one accent color, color reserved for - status/action, no gradients/glassmorphism/neon, desktop-first for the internal Hub). -- **Color:** one accent, `#1677ff` (Ant Design blue-6) with a `#0958d9` hover/active shade. - Semantic tokens are mandatory and separate from the accent: success/warning/error/info, plus - two Hub-specific roles — `ai` (purple, `#722ed1`) vs `human` (blue, same as primary) — and a - `channel-*` family (MAX purple, Telegram blue, Web Chat teal) used sparingly so multi-channel - screens don't turn into "a colorful mosaic" (ADR wording). Status color is never the sole - signal — always paired with text and, in tables, an icon or label. -- **Type:** system font stack only — no bespoke webfont is loaded - (`-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif`). - Technical identifiers (order/release/event IDs, emails, obscured phone numbers) switch to a - monospace stack. Headings use tight letter-spacing (−0.01 to −0.02em); table headers use wide - spacing (+0.03 to +0.06em) with all-caps and a muted gray. - Sizes are exact pixel values pulled from the baseline (26/24/20/18/16/14.5/13.5/13/12.5/12/ - 11/10.5px) — not snapped to a 4/8 grid. -- **Spacing / density:** "compact enterprise" density is an explicit rule — *"большие пустые - поля и oversized-карточки запрещены"*. Padding values are odd/precise (9px, 11px, 13px, 14px, - 18px…), not rounded. Control heights: 30/34/36/42/44/48px; topbar is a fixed 56px. -- **Backgrounds:** flat solid colors only. Page background `#f0f2f5`, card surfaces pure white. - No photography, no illustrations, no repeating patterns/textures, no gradients anywhere - (buttons, cards, banners are all flat fills). The one "textured" pattern in the system is the - 1px-gap metric grid (white cells on a `#f0f0f0` background, forming hairline dividers) — see - the Spacing → "Metric Grid In Use" card. -- **Corners:** 7–9px on controls/inputs, 8–9px on buttons, 10–14px on cards/modals, full pill - on chips/segmented controls, circle on avatars. -- **Cards:** white surface, 1px `#f0f0f0` border, soft shadow (`0 1px 3px rgba(0,0,0,.04)`), - 12px radius as the default; no colored left-border accent except the Command Center's status - banner (a deliberate, singular exception tied to company health, not a general card pattern). -- **Shadows:** shallow and near-neutral throughout — hairline (`0 1px 2px rgba(0,0,0,.03)`), - card (`0 1px 3px rgba(0,0,0,.04)`), modal (`0 2px 12px rgba(0,0,0,.05)`), dropdown - (`0 6px 20px rgba(0,0,0,.12)`). The one colored shadow is the primary button's blue-tinted - `0 1px 2px rgba(22,119,255,.3)`, used to make the single CTA per screen pop slightly. -- **Animation:** minimal and functional only — a 3-dot typing indicator (`hub-typing`, opacity/ - translateY loop), a spinner (`hub-spin`, rotate) for loading states, and a slow opacity pulse - (`hub-pulse`) for "in progress" states (e.g. delivering a purchased product). No entrance - animations, bounces, or parallax anywhere in the baseline. -- **Hover states:** buttons/links darken (primary `#1677ff → #0958d9`); neutral surfaces get a - light gray fill (`transparent → #f5f5f5`) on hover; secondary buttons swap border+text to the - accent color on hover. No lightening-on-hover pattern is used. - **Press/active states:** not distinctly styled in the baseline (no scale/shrink pattern - observed) — treat hover as the primary interactive affordance; focus-visible gets a solid - 2px accent outline (see `apps/internal-ui/src/styles/base.css`). - **Disabled:** 55% opacity + `cursor: not-allowed`. - **Focus:** border-color changes to the accent (inputs) or a visible 2px outline (buttons) — - never a glow/box-shadow ring. -- **Borders:** hairline `#f0f0f0` almost everywhere (card edges, table row dividers, header - separators); slightly stronger `#d9d9d9`/`#e8e8e8` on interactive control outlines - (buttons, inputs) so they read as tappable against the flat background. -- **Transparency & blur:** no blur/glassmorphism anywhere. The only translucent surfaces are - the Web Chat header's icon buttons (`rgba(255,255,255,0.14–0.26)` over the solid blue header) - and a full-viewport `position:fixed` transparent scrim used to close open dropdowns/menus. -- **Imagery:** none — the product is data/status-dense enterprise software and transactional - flows, not marketing pages. No photography, no hand-drawn illustrations, no generic stock - imagery anywhere in the baseline; nothing to copy in for that reason. -- **Layout rules:** Internal Hub is a fixed sidebar (≈248px) + topbar (56px) + scrollable - content shell, min-width 1024–1180px depending on screen, content capped at 1200–1340px and - centered. Web Chat and Checkout are separate, chrome-free, mobile-first single-column flows - with no sidebar/topbar — explicitly not governed by the Internal Hub's layout rules - (SPEC-HUB-0007 §4). - -## Iconography - -- **System:** hand-authored inline SVG, Feather/Lucide-style stroke icons (round caps/joins, - 1.7–2.2px stroke, 24×24 viewBox) — every baseline screen inlines its own `` rather than - referencing a shared font or sprite sheet, so there is no icon font or `.svg` sprite file in - the repo to copy wholesale. This design system's `Icon` component (`components/core/Icon.jsx`) - collects the ~35 recurring glyphs actually used across the baseline (nav icons, status icons, - form icons, payment icons) into one reusable primitive with the *exact* path data copied from - the source screens — nothing was redrawn from imagination. -- **Emoji:** never used anywhere in the product UI (only "rarely, situationally" in AI chat - replies per the content guidelines above). - **Unicode-as-icon:** limited to pagination chevrons (`‹ › …`) in table footers. -- **Color:** icons are almost always `currentColor`/neutral gray (`#8c8c8c`/`#595959`), tinted - to the semantic/accent color only when they sit inside a status chip or colored icon tile - (e.g. a purple AI bolt icon on a light-purple tile). - -## Caveats & where I need your help - -1. **No Figma was attached** — everything above comes from the approved HTML baseline in - `design/baseline/`, which the team's own spec names as canonical. If a Figma file exists, - attach it and I can cross-check/tighten values. -2. **No FoxRay/FirePage brand assets** — this system only covers Edevs Hub (the internal - platform + its two public surfaces). FoxRay/Foxray and FirePage are referenced only as - *product tags* inside Hub (colored labels, no logos). If you want their actual product UI - or marks in this system, please attach their codebases/design files. -3. **No logo beyond the one favicon mark** — I did not draw one. If Edevs has a real wordmark - or icon set beyond `favicon.svg`, please attach it and I'll swap it in everywhere. -4. **Ant Design isn't literally bundled** — see "Intentional additions" above; I built - faithful lightweight stand-ins instead of pulling in the real library, since only static - HTML/JS/CSS work in this environment. -5. **Internal Hub UI kit covers 2 of many screens** (Command Center + Sales · Clients, behind a - working Auth gate) rather than all ~35 baseline screens — tell me which screens matter most - (Departments, AI agents, Orders, Settings…) and I'll extend the kit. - -**Ask:** tell me which of the ~35 baseline screens to turn into additional UI-kit pages next, -and whether the Ant-Design-styled primitives above should stay as lightweight stand-ins or be -swapped for real `antd` components if this ever moves into a real build environment. diff --git a/design/Edevs Hub Design System/styles.css b/design/Edevs Hub Design System/styles.css deleted file mode 100644 index 0cd17b1..0000000 --- a/design/Edevs Hub Design System/styles.css +++ /dev/null @@ -1,4 +0,0 @@ -@import "./tokens/colors.css"; -@import "./tokens/typography.css"; -@import "./tokens/spacing.css"; -@import "./tokens/effects.css"; diff --git a/design/Edevs Hub Design System/tokens/colors.css b/design/Edevs Hub Design System/tokens/colors.css deleted file mode 100644 index e20679d..0000000 --- a/design/Edevs Hub Design System/tokens/colors.css +++ /dev/null @@ -1,88 +0,0 @@ -/* Edevs Hub — color tokens - Source: packages/ui/src/theme.ts (Ant Design ThemeConfig), apps/internal-ui/src/styles/base.css, - and design/baseline/*.dc.html (approved HTML baseline). Ant Design's default light palette, - customized with a single accent (#1677ff) per ADR-HUB-0013. */ - -:root { - /* ---- base neutrals (Ant Design neutral scale as used in the baseline) ---- */ - --n-1: #1f1f1f; /* page/heading titles */ - --n-2: #262626; /* primary body text */ - --n-3: #595959; /* secondary text */ - --n-4: #8c8c8c; /* tertiary text / captions */ - --n-5: #bfbfbf; /* disabled / placeholder / faint */ - --n-6: #d9d9d9; /* strong border, control border */ - --n-7: #e8e8e8; /* input border */ - --n-8: #f0f0f0; /* hairline border */ - --n-9: #f5f5f5; /* hover surface */ - --n-10: #fafafa; /* table header surface */ - --n-11: #f0f2f5; /* app/page background */ - --n-white: #ffffff; - - /* ---- primary (blue-6 / blue-7) ---- */ - --primary: #1677ff; - --primary-hover: #0958d9; - --primary-bg: #e6f4ff; - --primary-border: #91caff; - --primary-shadow: rgba(22, 119, 255, 0.3); - - /* ---- semantic: success / warning / error / info ---- */ - --success: #52c41a; - --success-text: #389e0d; - --success-bg: #f6ffed; - --success-bg-strong: #f1f9ec; - --success-border: #b7eb8f; - - --warning: #faad14; - --warning-text: #d48806; - --warning-bg: #fffbe6; - --warning-bg-strong: #fff7e6; - --warning-border: #ffe58f; - - --error: #ff4d4f; - --error-text: #cf1322; - --error-bg: #fff2f0; - --error-border: #ffccc7; - - --info: #1677ff; - - /* ---- role accents: AI vs human ---- */ - --ai: #722ed1; - --ai-light: #9254de; - --ai-lighter: #b37feb; - --ai-bg: #f9f0ff; - --human: #1677ff; - - /* ---- channel accents (MAX / Telegram / Web Chat) ---- */ - --channel-max: #6b5be0; - --channel-max-bg: #f2f0ff; - --channel-telegram: #2f8fd0; - --channel-telegram-bg: #eaf6fd; - --channel-webchat: #0f9b8e; - --channel-webchat-bg: #e8f7f4; - - /* ---- product accents (as tagged in tables/lists) ---- */ - --product-firepage: #0958d9; - --product-firepage-bg: #e6f4ff; - --product-foxray: #722ed1; - --product-foxray-bg: #f9f0ff; - - /* ---- surfaces ---- */ - --surface-page: var(--n-11); - --surface-card: var(--n-white); - --surface-sunken: #fafafa; - --surface-chip: #f7f8fa; - - /* ---- text aliases ---- */ - --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); - --text-inverse: var(--n-white); - --text-link: var(--primary); - - /* ---- borders ---- */ - --border-default: var(--n-8); - --border-input: var(--n-7); - --border-strong: var(--n-6); -} diff --git a/design/Edevs Hub Design System/tokens/effects.css b/design/Edevs Hub Design System/tokens/effects.css deleted file mode 100644 index 4fa2bb5..0000000 --- a/design/Edevs Hub Design System/tokens/effects.css +++ /dev/null @@ -1,40 +0,0 @@ -/* Edevs Hub — radii, shadows, motion. "Премиальный минимализм": no gradients, - no glassmorphism/blur, no neon — shadows stay soft and near-black at low opacity. */ - -:root { - --radius-xs: 5px; - --radius-sm: 7px; - --radius-md: 8px; - --radius-lg: 9px; - --radius-xl: 10px; - --radius-2xl: 12px; - --radius-3xl: 13px; - --radius-4xl: 14px; - --radius-pill: 999px; - --radius-circle: 50%; - - --shadow-xs: 0 1px 2px rgba(0, 0, 0, 0.03); - --shadow-sm: 0 1px 3px rgba(0, 0, 0, 0.04); - --shadow-md: 0 2px 12px rgba(0, 0, 0, 0.05); - --shadow-lg: 0 6px 20px rgba(0, 0, 0, 0.12); - --shadow-sticky: 0 -4px 16px rgba(0, 0, 0, 0.04); - --shadow-primary: 0 1px 2px rgba(22, 119, 255, 0.3); - - --ease-standard: cubic-bezier(0.4, 0, 0.2, 1); /* @kind other */ - --duration-fast: 0.15s; /* @kind other */ - --duration-base: 0.2s; /* @kind other */ -} - -@keyframes hub-typing { - 0%, 80%, 100% { opacity: 0.25; transform: translateY(0); } - 40% { opacity: 1; transform: translateY(-2px); } -} - -@keyframes hub-spin { - to { transform: rotate(360deg); } -} - -@keyframes hub-pulse { - 0%, 100% { opacity: 1; } - 50% { opacity: 0.5; } -} diff --git a/design/Edevs Hub Design System/tokens/spacing.css b/design/Edevs Hub Design System/tokens/spacing.css deleted file mode 100644 index 5c15ebc..0000000 --- a/design/Edevs Hub Design System/tokens/spacing.css +++ /dev/null @@ -1,36 +0,0 @@ -/* Edevs Hub — spacing tokens. Compact-enterprise density (ADR-HUB-0013): - "большие пустые поля и oversized-карточки запрещены". Values are the exact - paddings/gaps observed in the baseline, not snapped to a rounder grid. */ - -:root { - --space-1: 2px; - --space-2: 4px; - --space-3: 6px; - --space-4: 8px; - --space-5: 9px; - --space-6: 10px; - --space-7: 11px; - --space-8: 12px; - --space-9: 14px; - --space-10: 16px; - --space-11: 18px; - --space-12: 20px; - --space-13: 22px; - --space-14: 24px; - --space-15: 28px; - --space-16: 32px; - --space-17: 36px; - - /* control heights */ - --h-control-sm: 30px; - --h-control-md: 34px; - --h-control: 36px; - --h-control-lg: 42px; - --h-control-xl: 44px; - --h-control-2xl: 48px; - --h-topbar: 56px; - - /* layout */ - --sidebar-width: 248px; - --content-max: 1340px; -} diff --git a/design/Edevs Hub Design System/tokens/typography.css b/design/Edevs Hub Design System/tokens/typography.css deleted file mode 100644 index 0d43222..0000000 --- a/design/Edevs Hub Design System/tokens/typography.css +++ /dev/null @@ -1,42 +0,0 @@ -/* Edevs Hub — typography tokens - The internal Hub uses the Ant Design default system font stack — no bespoke webfont - is loaded (see ADR-HUB-0013: "используется системный font stack Ant Design без - обязательной внешней зависимости от загружаемого шрифта"). Technical identifiers - (order/release/event IDs, emails) use a monospace stack. */ - -:root { - --font-sans: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif; - --font-mono: "SF Mono", ui-monospace, Menlo, Consolas, monospace; - - /* type scale, taken verbatim from the baseline (px, not snapped to a 4/8 grid) */ - --text-2xl: 26px; /* page hero title (Command Center) */ - --text-xl: 24px; /* page title */ - --text-lg: 20px; /* auth title */ - --text-md: 18px; /* card title */ - --text-base-lg: 16px; - --text-base: 14.5px; /* buttons, primary body */ - --text-sm-lg: 13.5px; - --text-sm: 13px; /* table body */ - --text-sm-tight: 12.5px; - --text-xs: 12px; /* meta */ - --text-xs-tight: 11.5px; - --text-2xs: 11px; /* table header / eyebrow */ - --text-2xs-tight: 10.5px; - - --weight-regular: 400; - --weight-medium: 500; - --weight-semibold: 600; - --weight-bold: 700; - --weight-black: 800; - - --tracking-tight: -0.02em; /* large headings */ - --tracking-tight-sm: -0.01em; - --tracking-wide: 0.03em; /* table header eyebrows */ - --tracking-wider: 0.06em; /* nav section labels */ - - --leading-tight: 1.15; - --leading-snug: 1.2; - --leading-normal: 1.4; - --leading-relaxed: 1.5; - --leading-loose: 1.55; -} diff --git a/design/Edevs Hub Design System/ui_kits/checkout/CheckoutWidget.jsx b/design/Edevs Hub Design System/ui_kits/checkout/CheckoutWidget.jsx deleted file mode 100644 index 367db12..0000000 --- a/design/Edevs Hub Design System/ui_kits/checkout/CheckoutWidget.jsx +++ /dev/null @@ -1,159 +0,0 @@ -const { CommerceStatusTimeline, Icon, Checkbox } = window.EdevsHubDesignSystem_e4c9df; - -/* Recreation of design/baseline/CheckoutPanel.dc.html — the public - pay.hub.edevs.tech checkout flow (SPEC-HUB-0007). Mobile-first, no Hub chrome. */ -function CheckoutWidget() { - const [st, setSt] = React.useState("checkout"); - const [buyer, setBuyer] = React.useState("person"); - const [pay, setPay] = React.useState("card"); - const [a1, setA1] = React.useState(false); - const [a2, setA2] = React.useState(false); - const ready = a1 && a2; - - const seg = (k) => ({ - flex: 1, height: 36, borderRadius: 8, border: "none", fontSize: 12.5, fontWeight: 600, cursor: "pointer", - background: buyer === k ? "#1f1f1f" : "transparent", color: buyer === k ? "#fff" : "#8c8c8c", - }); - const cardBox = (sel) => ({ display: "flex", alignItems: "center", gap: 12, width: "100%", padding: "11px 13px", borderRadius: 12, background: "#fff", cursor: "pointer", textAlign: "left", border: `1.5px solid ${sel ? "var(--primary)" : "#ececec"}`, boxShadow: sel ? "0 0 0 3px rgba(22,119,255,0.08)" : "none" }); - const dot = (sel) => ({ width: 18, height: 18, borderRadius: "50%", flex: "none", border: sel ? "5px solid var(--primary)" : "2px solid #d9d9d9", background: "#fff" }); - - return ( -
-
-
F
-
-
Foxray
-
Оформление покупки
-
-
- Защищено -
- -
- -
- {st === "expired" && } - {st === "preparing" && } - {st === "pending" && } - {st === "error" && } - - {st === "paid" && ( -
-
-
-

Оплата получена

-

Чек отправлен на a.kovalenko@northwind.ru

-
-
- -
-
- )} - - {st === "delivering" && ( -
-
-
-

Готовим доступ к продукту

-

Оплата прошла. Платить повторно не нужно.

-
-
- -
-
- )} - - {st === "done" && ( -
-
-
-

Готово — доступ открыт

-

Foxray Max активирован на 6 мест.

-
- Перейти в Foxray -
- )} - - {st === "checkout" && ( -
-
-
Оформление подписки
-
Foxray Max
-
Годовая подписка · 6 рабочих мест
-
- -
-
-
-
Foxray Max
6 мест × ₽1 320 / мес · 12 мес
-
₽118 800
-
-
Скидка за годовую оплату · −20%−₽23 760
-
-
-
Первый платёж
Без НДС · валюта ₽ (RUB)
-
₽95 040
-
-
- -
Покупатель
-
- - - -
- -
Способ оплаты
-
- - -
- -
- setA1(!a1)} label={<>Принимаю условия покупки и оферту, согласен с политикой обработки данных} /> - setA2(!a2)} label={<>Согласен на регулярные списания ₽95 040 / год с автопродлением до отмены} /> -
-
- )} -
- - {st === "checkout" && ( -
-
К оплате сегодня₽95 040
- -
- )} -
- ); -} - -function StateScreen({ icon, tone = "var(--primary)", toneBg = "#f4f5f7", title, body, cta, spin }) { - return ( -
- {spin ? ( -
- ) : ( -
- )} -

{title}

-

{body}

- {cta && } -
- ); -} - -function SummaryRow({ k, v, mono, bold }) { - return
{k}{v}
; -} diff --git a/design/Edevs Hub Design System/ui_kits/checkout/index.html b/design/Edevs Hub Design System/ui_kits/checkout/index.html deleted file mode 100644 index d81a3a4..0000000 --- a/design/Edevs Hub Design System/ui_kits/checkout/index.html +++ /dev/null @@ -1,22 +0,0 @@ - - - - - -Edevs Hub — Checkout - - - - - - - -
-
-
- - - - diff --git a/design/Edevs Hub Design System/ui_kits/internal-hub/AuthLoginPage.jsx b/design/Edevs Hub Design System/ui_kits/internal-hub/AuthLoginPage.jsx deleted file mode 100644 index 23e35a8..0000000 --- a/design/Edevs Hub Design System/ui_kits/internal-hub/AuthLoginPage.jsx +++ /dev/null @@ -1,39 +0,0 @@ -const { Input, Button, Icon } = window.EdevsHubDesignSystem_e4c9df; - -/* Recreation of design/baseline/AUTH · Вход.dc.html */ -function AuthLoginPage({ onSuccess }) { - const [email, setEmail] = React.useState(""); - const [pwd, setPwd] = React.useState(""); - const [show, setShow] = React.useState(false); - const [error, setError] = React.useState(false); - - return ( -
-
-
- -

Edevs Hub

-

Вход во внутренний кабинет

-
-
- {error && ( -
- - Неверный email или пароль. Проверьте данные и попробуйте снова. -
- )} -
- } value={email} onChange={(e) => setEmail(e.target.value)} placeholder="you@edevs.tech" /> -
-
- } - suffix={} - value={pwd} onChange={(e) => setPwd(e.target.value)} placeholder="Пароль" /> -
- -
-

Доступ только для сотрудников Edevs · защищённое соединение

-
-
- ); -} diff --git a/design/Edevs Hub Design System/ui_kits/internal-hub/ClientsPage.jsx b/design/Edevs Hub Design System/ui_kits/internal-hub/ClientsPage.jsx deleted file mode 100644 index 7aeb700..0000000 --- a/design/Edevs Hub Design System/ui_kits/internal-hub/ClientsPage.jsx +++ /dev/null @@ -1,63 +0,0 @@ -const { Avatar, Tag, Icon } = window.EdevsHubDesignSystem_e4c9df; - -const DATA = [ - { name: "Мария Соколова", initials: "МС", color: "#eb6f4b", cid: "CUS-4821", email: "m.sokolova@workmail.ru", ch: ["MAX"], pr: ["FP"], last: "5 мин назад", open: 1, orders: 0, total: "—" }, - { name: "Дмитрий Орлов", initials: "ДО", color: "#3b82c4", cid: "CUS-4789", email: "d.orlov@gmail.com", ch: ["TG"], pr: ["FX"], last: "8 мин назад", open: 1, orders: 2, total: "₽4 980" }, - { name: "Елена Кузнецова", initials: "ЕК", color: "#9254de", cid: "CUS-4702", email: "e.kuznetsova@corp.ru", ch: ["MAX", "WEB"], pr: ["FX", "FP"], last: "18 мин назад", open: 1, orders: 3, total: "₽12 470" }, - { name: "Сергей Волков", initials: "СВ", color: "#13a8a8", cid: "CUS-4655", email: "s.volkov@mail.ru", ch: ["TG"], pr: ["FP"], last: "26 мин назад", open: 1, orders: 1, total: "₽2 490" }, - { name: "Павел Новиков", initials: "ПН", color: "#52a838", cid: "CUS-4410", email: "p.novikov@firm.io", ch: ["MAX"], pr: ["FP"], last: "1 ч назад", open: 0, orders: 4, total: "₽19 600" }, -]; -const CH = { MAX: { l: "MAX", c: "var(--channel-max)", bg: "var(--channel-max-bg)" }, TG: { l: "TG", c: "var(--channel-telegram)", bg: "var(--channel-telegram-bg)" }, WEB: { l: "Web", c: "var(--channel-webchat)", bg: "var(--channel-webchat-bg)" } }; -const PR = { FP: { n: "FirePage", tone: "primary" }, FX: { n: "Foxray", tone: "ai" } }; - -function ClientsPage() { - const [q, setQ] = React.useState(""); - const rows = DATA.filter((d) => !q || d.name.toLowerCase().includes(q.toLowerCase())); - return ( -
-
-
-

Клиенты

-

Контакты отдела продаж · показано {rows.length} из 248

-
-
- -
-
- - setQ(e.target.value)} placeholder="Поиск по имени или email…" style={{ border: "none", outline: "none", background: "transparent", fontSize: 13, width: "100%", fontFamily: "inherit" }} /> -
-
- -
- - - - {["КЛИЕНТ", "КАНАЛЫ", "ПРОДУКТЫ", "ПОСЛ. ДИАЛОГ", "ОТКР.", "ЗАКАЗЫ", "СУММА ПОКУПОК"].map((h, i) => ( - - ))} - - - - {rows.map((r, i) => ( - - - - - - - - - - ))} - -
= 4 ? "right" : "left", padding: "11px 12px", fontSize: 11, fontWeight: 600, color: "var(--text-tertiary)", borderBottom: "1px solid var(--n-8)" }}>{h}
-
- -
{r.name}
{r.cid}
-
-
{r.ch.map((c) => {CH[c].l})}
{r.pr.map((p) => {PR[p].n})}
{r.last} 0 ? "var(--warning-text)" : "var(--n-5)" }}>{r.open}{r.orders}{r.total}
-
-
- ); -} diff --git a/design/Edevs Hub Design System/ui_kits/internal-hub/CommandCenterPage.jsx b/design/Edevs Hub Design System/ui_kits/internal-hub/CommandCenterPage.jsx deleted file mode 100644 index 0291028..0000000 --- a/design/Edevs Hub Design System/ui_kits/internal-hub/CommandCenterPage.jsx +++ /dev/null @@ -1,64 +0,0 @@ -const { DepartmentCard, MetricCard, IntegrationHealthCard, AttentionStatus } = window.EdevsHubDesignSystem_e4c9df; - -/* Recreation of design/baseline/Командный центр.dc.html */ -function CommandCenterPage({ onOpenDepartment }) { - const [scenario, setScenario] = React.useState("NORMAL"); - const S = { - NORMAL: { open: 42, active: 9, ai: 35, op: 7, wait: 0, level: "ok", summary: "AI ведёт большинство диалогов. Очередь оператора пуста." }, - ATTENTION: { open: 58, active: 14, ai: 41, op: 13, wait: 4, level: "attention", summary: "Очередь оператора растёт, есть незавершённые платежи." }, - CRITICAL: { open: 73, active: 22, ai: 29, op: 35, wait: 9, level: "critical", summary: "Сбой исполнения заказов и переполненная очередь операторов." }, - }[scenario]; - - return ( -
-
-
-

Командный центр

-

Состояние компании одним взглядом · обновлено только что

-
- -
- -
-
-
- Компания: - -
-

{S.summary}

-
-
-
Отделы
1
-
Выручка · сегодня
₽146 200
-
-
- -
-
-
Отделы
- -
ДИАЛОГИ — СЕЙЧАС
-
- - - - 0 ? "var(--warning-text)" : undefined} /> -
-
-
-
-
-
Состояние интеграций
- - - -
-
-
-
- ); -} diff --git a/design/Edevs Hub Design System/ui_kits/internal-hub/HubShell.jsx b/design/Edevs Hub Design System/ui_kits/internal-hub/HubShell.jsx deleted file mode 100644 index 8f0e581..0000000 --- a/design/Edevs Hub Design System/ui_kits/internal-hub/HubShell.jsx +++ /dev/null @@ -1,78 +0,0 @@ -const { Icon, Avatar } = window.EdevsHubDesignSystem_e4c9df; - -const NAV = [ - { key: "command", label: "Командный центр", icon: "dashboard" }, - { section: "КОМПАНИЯ" }, - { key: "departments", label: "Отделы", icon: "departments" }, - { key: "employees", label: "Сотрудники", icon: "employees" }, - { key: "products", label: "Продукты", icon: "products" }, - { section: "ПЛАТФОРМА" }, - { key: "ai", label: "AI", icon: "ai" }, - { key: "integrations", label: "Интеграции", icon: "integrations" }, - { key: "settings", label: "Настройки", icon: "settings" }, -]; - -function HubShell({ page, onNavigate, crumb, children }) { - return ( - - ); -} diff --git a/design/Edevs Hub Design System/ui_kits/internal-hub/index.html b/design/Edevs Hub Design System/ui_kits/internal-hub/index.html deleted file mode 100644 index e4d57d5..0000000 --- a/design/Edevs Hub Design System/ui_kits/internal-hub/index.html +++ /dev/null @@ -1,35 +0,0 @@ - - - - - -Edevs Hub — Internal UI - - - - - - - -
- - - - - - - diff --git a/design/Edevs Hub Design System/ui_kits/web-chat/WebChatWidget.jsx b/design/Edevs Hub Design System/ui_kits/web-chat/WebChatWidget.jsx deleted file mode 100644 index 5bac665..0000000 --- a/design/Edevs Hub Design System/ui_kits/web-chat/WebChatWidget.jsx +++ /dev/null @@ -1,180 +0,0 @@ -const { ActorBadge, Icon } = window.EdevsHubDesignSystem_e4c9df; - -/* Recreation of design/baseline/ChatPanel.dc.html — Foxray's embedded web-chat - widget. States: welcome (consent) / ai / operator handoff / checkout card / unavailable. */ -function WebChatWidget() { - const [state, setState] = React.useState("ai"); - const isWelcome = state === "welcome"; - const isAI = state === "ai"; - const isOperator = state === "operator"; - const isCheckout = state === "checkout"; - const isUnavailable = state === "unavailable"; - const isConversation = isAI || isOperator || isCheckout; - - const status = isOperator || isCheckout - ? { label: "Отвечает специалист", dot: "#52c41a" } - : isUnavailable - ? { label: "Временно недоступен", dot: "#faad14" } - : isWelcome - ? { label: "Обычно отвечаем за пару минут", dot: "#52c41a" } - : { label: "Оператор · на связи", dot: "#52c41a" }; - - return ( -
-
-
F
-
-
Foxray
-
- - {status.label} -
-
- -
- -
- {isWelcome && ( - <> -
-
- F -
-

Чат Foxray

-

Здравствуйте! Помогу подобрать тариф и ответить на вопросы по Foxray. Чем можем помочь?

-
-
-
Продолжая, вы соглашаетесь на обработку сообщений для ответа на обращение. Политика обработки данных · ред. v3.
-
- - )} - - {isUnavailable && ( -
-
- -
-

Чат временно недоступен

-

Мы не можем принять сообщение прямо сейчас. Напишите нам в другом канале.

- -
- )} - - {isConversation && ( - <> -
Сегодня
- - - - {isOperator && ( - <> -
- Подключился специалист · история уже у него -
- - - )} - {isAI && ( -
- -
- - - -
-
- )} - {isCheckout && ( -
- -
-
-
-
ОФОРМЛЕНИЕ ПОКУПКИ
-
Foxray Max · 6 мест
-
- ₽95 040/ год · −20% -
-
- -
-
-
- )} - - )} -
- - {isConversation && ( -
- {isAI && ( -
- Сравнить тарифы - Есть ли пробный период? -
- )} -
- +
+ +
+ + +
+
+

Поиск и переключатели

Компактный поиск, switch и segmented control.

+ ui-controls.tsx · table-controls.css +
+
+
+
SearchInputSearchInput
+
+ +
+
+
+
SwitchButton.ui-switch
+
+ + + +
+
+
+
SegmentedSegmented
+
+
+
+
+
+
+ +
+
+

Вкладки и пагинация

Навигация внутри раздела и управление страницами таблицы.

+ ui-controls.tsx · ui-controls.css +
+
+
+
UnderlineTabsUnderlineTabs
+
+
+ + + + +
+
+
+
+
TablePaginationTablePagination
+
+
+ Показано 1–20 из 74 + +
+
+
+
+
+
+ + + + + + diff --git a/design/design-system/data-display.html b/design/design-system/data-display.html new file mode 100644 index 0000000..32231ba --- /dev/null +++ b/design/design-system/data-display.html @@ -0,0 +1,169 @@ + + + + + + Данные и статусы · CustoCRM Design System + + + + + + + + + +
+ +
+
+
Design System/Данные и статусы
+ Data display +
+
+
+
+
Data display
+

Представление данных

+

Компоненты для статусов, ролей, продуктов, каналов, идентичности и кратких показателей.

+
+
+ +
+
+

Статусы

Полный набор ключей StatusPillKey.

+ src/shared/ui.tsx +
+
+
StatusPill12 состояний
+
+ Работает + Активен + Опубликован + Заблокирован + Неактивен + Приглашён + Архивный + Черновик + Ошибка + Проверяется + Не проверялось +
+
+
+ +
+
+

Роли

Роли сотрудников организации.

+ src/shared/ui.tsx +
+
+
RoleBadgeOWNER · ADMIN · EMPLOYEE
+
+ Владелец + Администратор + Сотрудник +
+
+
+ +
+
+

Продукты

Плотный tag для таблиц и нейтральный mark для текста.

+ ui.tsx · utils.ts +
+
+
+
ProductTagproductAccent()
+
+ FoxRay + CustoCRM + FirePage +
+
+
+
ProductMarkProductMark
+
+ FoxRay + CustoCRM +
+
+
+
+ +
+
+

Каналы

Короткие метки провайдеров из общего словаря.

+ badges.tsx · providers.ts +
+
+
ChannelBadgeEMAIL · MAX · TG · WEB
+
+ Email + MAX + TG + Web + Unknown +
+
+
+ +
+
+

Идентичность и показатели

Avatar, KeyValue и MetricBox.

+ ui.tsx · form-controls.tsx +
+
+
+
Avatarinitials()
+
+ АЕ + ИК + EH +
+
+
+
KeyValueKeyValue
+
+
ОрганизацияООО «ЭДЕВС»
+
ВладелецАндрей Ермолаев
+
+
+
+
MetricBoxMetricBox
+
+
Активные агенты6
+
+
+
+
+ +
+
+

Иконки

Набор Icon из shared. Здесь показаны наиболее частые роли.

+ src/shared/icons.tsx +
+
+
+

grid

+

box

+

list

+

columns

+

search

+

more

+

edit

+

trash

+

warning

+

bell

+

user

+

message

+
+
+
+
+
+
+ + + + diff --git a/design/design-system/foundations.html b/design/design-system/foundations.html new file mode 100644 index 0000000..8aa7dfa --- /dev/null +++ b/design/design-system/foundations.html @@ -0,0 +1,178 @@ + + + + + + Токены · CustoCRM Design System + + + + + + + + + +
+ +
+
+
Design System/Токены
+ Foundations +
+
+
+
+
Foundations
+

Токены интерфейса

+

Значения собраны из текущих общих CSS-файлов и темы UI. Названия отражают фактическое назначение значений во фронтенде.

+
+
+ +
+
+

Нейтральная палитра

Текст, границы, фоны и disabled-состояния.

+ src/styles/base.css +
+
+
neutral.1--n-1 · #1f1f1f
+
neutral.2--n-2 · #262626
+
neutral.3--n-3 · #595959
+
neutral.4--n-4 · #8c8c8c
+
neutral.5--n-5 · #bfbfbf
+
neutral.6--n-6 · #d9d9d9
+
neutral.7--n-7 · #e8e8e8
+
neutral.8--n-8 · #f0f0f0
+
neutral.9--n-9 · #f5f5f5
+
neutral.10--n-10 · #fafafa
+
+
+ +
+
+

Основные и семантические цвета

Интерактивные элементы, статусы и AI-акцент.

+ base.css · theme.ts +
+
+
color.primary--primary · #1677ff
+
color.primary.hover--primary-hover · #0958d9
+
color.primary.bg--primary-bg · #e6f4ff
+
color.primary.border--primary-border · #91caff
+
color.success--success · #52c41a
+
color.success.bg--success-bg · #f6ffed
+
color.warning.text--warning-text · #d48806
+
color.warning.bg--warning-bg · #fffbe6
+
color.error.text--error-text · #cf1322
+
color.error.bg--error-bg · #fff2f0
+
color.ai--ai · #722ed1
+
color.ai.bg--ai-bg · #f9f0ff
+
+
+ +
+
+

Каналы

Акцентные пары из общего словаря провайдеров.

+ src/shared/providers.ts +
+
+
provider.email.bg#fff7e6 · text #d48806
+
provider.max.bg#f2f0ff · text #6b5be0
+
provider.telegram.bg#eaf6fd · text #2f8fd0
+
provider.web.bg#e8f7f4 · text #0f9b8e
+
+
+ +
+
+

Типографика

Системный sans-serif и моноширинный стек для идентификаторов.

+ base.css · page.css +
+
+
+
Основной шрифт--font-sans
+
+
Заголовок страницы · 24 / 700
+
Заголовок компонента · 14 / 600
+
Основной текст интерфейса · 13.5 / 400
+
Подпись поля · 12 / 500
+
+
+
+
Моноширинный--font-mono
+
+
org_01J9R7Q6ZWPB
+
connection_telegram_primary
+
2026-07-30T14:13:00Z
+
+
+
+
+ +
+
+

Радиусы

Корневые токены и используемые малые значения controls.

+ base.css · shared/*.css +
+
+
radius.4
4px · focus
+
radius.6
6px · badges
+
radius.md
8px · controls
+
radius.lg
9px · buttons
+
radius.xl
10px · cards
+
radius.2xl
12px · panels
+
radius.4xl
14px · dialogs
+
radius.pill
999px · tags
+
+
+ +
+
+

Интервалы и размеры controls

Повторяющиеся значения общих компонентов.

+ shared/*.css · layout/*.css +
+
+
+

Spacing scale

+
space.1
4px
+
space.2
6px
+
space.3
8px
+
space.4
12px
+
space.5
14px
+
space.6
16px
+
space.7
20px
+
space.8
24px
+
space.9
28px
+
+
+

Control heights

+
control.xs
24px
+
control.sm
30px
+
control.compact
34px
+
control.action
36px
+
control.md
38px
+
switch.md
42×24
+
switch.lg
46×26
+
shell.bar
56px
+
+
+
+ +
+
+

Тени

Общие elevation-уровни и характерные component shadows.

+ base.css · shared/*.css +
+
+
shadow.xs
cards
+
shadow.sm
panels
+
shadow.button
primary
+
shadow.lg
dialogs
+
+
+
+
+
+ + + + diff --git a/design/design-system/index.html b/design/design-system/index.html new file mode 100644 index 0000000..44a5911 --- /dev/null +++ b/design/design-system/index.html @@ -0,0 +1,113 @@ + + + + + + CustoCRM Design System + + + + + + + + + +
+ +
+
+
Design System/Обзор
+ Текущий frontend +
+
+
+
+
CustoCRM UI inventory
+

Дизайн-система текущего фронтенда

+

Статический справочник существующих токенов, компонентов, паттернов и компоновок CustoCRM. Примеры повторяют действующие CSS-классы и визуальные решения из apps/internal-ui/src.

+
+
+ +
+ Назначение: перед разработкой нового интерфейса открыть нужный раздел, найти существующий элемент и использовать его как эталон. Каталог ничего не меняет в приложении и не требует React. +
+ +
+
+

Разделы

От базовых значений до компоновки целых экранов.

+
+ +
+ +
+
+

Уровни системы

Как читать и использовать каталог.

+
+
+

1. Foundations

Фактические значения, на которых построен интерфейс: цвет, размер, радиус, тень и типографика.

+

2. Components

Переиспользуемые элементы из src/shared и их состояния.

+

3. Patterns

Устоявшиеся композиции: таблица с фильтрами, карточка, форма, диалог.

+

4. Layouts

Каркас целой страницы или рабочего пространства из существующих частей.

+
+
+ +
+
+

Основные источники

Файлы фронтенда, по которым собран этот справочник.

+
+
+
+
Токеныsrc/styles/base.css · packages/ui/src/theme.ts
+
Controlssrc/shared/ui-controls.* · form-controls.*
+
Data displaysrc/shared/ui.tsx · badges.tsx · design-components.css
+
Таблицыsrc/shared/table-controls.css
+
Layoutsrc/layout/styles.css · topbar.css · page.css
+
Иконкиsrc/shared/icons.tsx
+
+
+
+
+
+
+ + + + diff --git a/design/design-system/inventory.html b/design/design-system/inventory.html new file mode 100644 index 0000000..2246dbc --- /dev/null +++ b/design/design-system/inventory.html @@ -0,0 +1,60 @@ + + + + + + Инвентарь · CustoCRM Design System + + + + + + + + + +
+ +
+
+
Design System/Инвентарь
+ Inventory +
+
+
+
+
Inventory
+

Инвентарь фронтенда

+

Реестр включает PascalCase-компоненты из TSX-файлов, системные CSS-компоненты, дизайн-токены и основные layout-паттерны. Всего записей: —.

+
+
+ +
+ Область: в таблице отдельно помечены системные, layout-, application- и feature-компоненты. Это инвентаризация существующего состава без оценки и без проверки соблюдения дизайн-системы. +
+ +
+ +
+
+ + + + + +
НазваниеТипКатегорияОбластьИсходникВарианты / роль
+
+
+
+
+
+
+ + + + + diff --git a/design/design-system/inventory.json b/design/design-system/inventory.json new file mode 100644 index 0000000..026f43f --- /dev/null +++ b/design/design-system/inventory.json @@ -0,0 +1 @@ +{"version":"0.1.0","generatedFrom":"apps/internal-ui/src","generatedOn":"2026-07-30","count":315,"items":[{"name":"Link","kind":"CSS component","category":"Actions","scope":"System","visibility":"system","source":"src/shared/links.css","variants":"base, strong, neutral, muted, mono, icon","documentedAt":"components.html"},{"name":"AppDropdown","kind":"CSS pattern","category":"Overlays","scope":"System","visibility":"system","source":"src/shared/menu.css","variants":"default, wide, channels, warning, danger, success","documentedAt":"patterns.html"},{"name":"BaselineTable","kind":"CSS pattern","category":"Tables","scope":"System","visibility":"system","source":"src/shared/table-controls.css","variants":"header, row hover, numeric, actions","documentedAt":"patterns.html"},{"name":"FilterBar","kind":"CSS pattern","category":"Tables","scope":"System","visibility":"system","source":"src/shared/table-controls.css","variants":"default, employees","documentedAt":"patterns.html"},{"name":"TableCard","kind":"CSS pattern","category":"Tables","scope":"System","visibility":"system","source":"src/shared/table-controls.css","variants":"default, scroll, footer","documentedAt":"patterns.html"},{"name":"ApplicationShell","kind":"Layout pattern","category":"Layouts","scope":"System","visibility":"system","source":"src/layout/*.css","variants":"sidebar, topbar, scroll, page","documentedAt":"layouts.html"},{"name":"ConversationWorkspacePattern","kind":"Page pattern","category":"Layouts","scope":"System","visibility":"system","source":"features/conversations","variants":"dialog list, thread, context","documentedAt":"layouts.html"},{"name":"DetailPage","kind":"Page pattern","category":"Layouts","scope":"System","visibility":"system","source":"feature detail pages","variants":"header, main content, rail","documentedAt":"layouts.html"},{"name":"ListPage","kind":"Page pattern","category":"Layouts","scope":"System","visibility":"system","source":"feature list pages","variants":"header, filters, table, pagination","documentedAt":"layouts.html"},{"name":"SettingsPagePattern","kind":"Page pattern","category":"Layouts","scope":"System","visibility":"system","source":"settings/profile/administration","variants":"section cards, forms, local actions","documentedAt":"layouts.html"},{"name":"App","kind":"React component","category":"Application","scope":"Application","visibility":"exported","source":"apps/internal-ui/src/App.tsx","variants":"—","documentedAt":""},{"name":"SurfaceApp","kind":"React component","category":"Application","scope":"Application","visibility":"local","source":"apps/internal-ui/src/main.tsx","variants":"—","documentedAt":""},{"name":"AdministrationPage","kind":"React component","category":"Feature: administration","scope":"Feature","visibility":"exported","source":"apps/internal-ui/src/features/administration/AdministrationPage.tsx","variants":"—","documentedAt":""},{"name":"AuditTable","kind":"React component","category":"Feature: administration","scope":"Feature","visibility":"exported","source":"apps/internal-ui/src/features/administration/AuditTable.tsx","variants":"—","documentedAt":""},{"name":"CloudSubscriptionPanel","kind":"React component","category":"Feature: administration","scope":"Feature","visibility":"exported","source":"apps/internal-ui/src/features/administration/CloudSubscriptionPanel.tsx","variants":"—","documentedAt":""},{"name":"OrganizationLogoField","kind":"React component","category":"Feature: administration","scope":"Feature","visibility":"exported","source":"apps/internal-ui/src/features/administration/OrganizationLogoField.tsx","variants":"—","documentedAt":""},{"name":"OrganizationSettingsForm","kind":"React component","category":"Feature: administration","scope":"Feature","visibility":"exported","source":"apps/internal-ui/src/features/administration/OrganizationSettingsForm.tsx","variants":"—","documentedAt":""},{"name":"AgentEditForm","kind":"React component","category":"Feature: ai","scope":"Feature","visibility":"exported","source":"apps/internal-ui/src/features/ai/detail/AgentEditForm.tsx","variants":"—","documentedAt":""},{"name":"AgentsKpiStrip","kind":"React component","category":"Feature: ai","scope":"Feature","visibility":"exported","source":"apps/internal-ui/src/features/ai/AgentsKpiStrip.tsx","variants":"—","documentedAt":""},{"name":"AgentsTable","kind":"React component","category":"Feature: ai","scope":"Feature","visibility":"exported","source":"apps/internal-ui/src/features/ai/AgentsTable.tsx","variants":"—","documentedAt":""},{"name":"AiAgentCreatePage","kind":"React component","category":"Feature: ai","scope":"Feature","visibility":"exported","source":"apps/internal-ui/src/features/ai/create/AiAgentCreatePage.tsx","variants":"—","documentedAt":""},{"name":"AiAgentDetailHeader","kind":"React component","category":"Feature: ai","scope":"Feature","visibility":"exported","source":"apps/internal-ui/src/features/ai/detail/AiAgentDetailHeader.tsx","variants":"—","documentedAt":""},{"name":"AiAgentDetailPage","kind":"React component","category":"Feature: ai","scope":"Feature","visibility":"exported","source":"apps/internal-ui/src/features/ai/detail/AiAgentDetailPage.tsx","variants":"—","documentedAt":""},{"name":"AiAgentInstructionsTab","kind":"React component","category":"Feature: ai","scope":"Feature","visibility":"exported","source":"apps/internal-ui/src/features/ai/detail/AiAgentInstructionsTab.tsx","variants":"—","documentedAt":""},{"name":"AiAgentKnowledgeTab","kind":"React component","category":"Feature: ai","scope":"Feature","visibility":"exported","source":"apps/internal-ui/src/features/ai/detail/AiAgentKnowledgeTab.tsx","variants":"—","documentedAt":""},{"name":"AiAgentMetricsTab","kind":"React component","category":"Feature: ai","scope":"Feature","visibility":"exported","source":"apps/internal-ui/src/features/ai/detail/AiAgentMetricsTab.tsx","variants":"—","documentedAt":""},{"name":"AiAgentOverviewTab","kind":"React component","category":"Feature: ai","scope":"Feature","visibility":"exported","source":"apps/internal-ui/src/features/ai/detail/AiAgentOverviewTab.tsx","variants":"—","documentedAt":""},{"name":"AiAgentsPage","kind":"React component","category":"Feature: ai","scope":"Feature","visibility":"exported","source":"apps/internal-ui/src/features/ai/AiAgentsPage.tsx","variants":"—","documentedAt":""},{"name":"CategoryManagement","kind":"React component","category":"Feature: ai","scope":"Feature","visibility":"exported","source":"apps/internal-ui/src/features/ai/knowledge/CategoryManagement.tsx","variants":"—","documentedAt":""},{"name":"CreateAgentFooter","kind":"React component","category":"Feature: ai","scope":"Feature","visibility":"exported","source":"apps/internal-ui/src/features/ai/create/CreateAgentFooter.tsx","variants":"—","documentedAt":""},{"name":"CreateAgentStepCard","kind":"React component","category":"Feature: ai","scope":"Feature","visibility":"exported","source":"apps/internal-ui/src/features/ai/create/CreateAgentStepCard.tsx","variants":"—","documentedAt":""},{"name":"KnowledgeAccessNotice","kind":"React component","category":"Feature: ai","scope":"Feature","visibility":"local","source":"apps/internal-ui/src/features/ai/knowledge/KnowledgePage.tsx","variants":"—","documentedAt":""},{"name":"KnowledgeAttachmentsCard","kind":"React component","category":"Feature: ai","scope":"Feature","visibility":"exported","source":"apps/internal-ui/src/features/ai/knowledge/KnowledgeAttachmentsCard.tsx","variants":"—","documentedAt":""},{"name":"KnowledgeBulkActions","kind":"React component","category":"Feature: ai","scope":"Feature","visibility":"exported","source":"apps/internal-ui/src/features/ai/knowledge/KnowledgeBulkActions.tsx","variants":"—","documentedAt":""},{"name":"KnowledgeBulkDialog","kind":"React component","category":"Feature: ai","scope":"Feature","visibility":"exported","source":"apps/internal-ui/src/features/ai/knowledge/KnowledgeBulkDialog.tsx","variants":"—","documentedAt":""},{"name":"KnowledgeCategoryTree","kind":"React component","category":"Feature: ai","scope":"Feature","visibility":"exported","source":"apps/internal-ui/src/features/ai/knowledge/KnowledgeCategoryTree.tsx","variants":"—","documentedAt":""},{"name":"KnowledgeCreatePage","kind":"React component","category":"Feature: ai","scope":"Feature","visibility":"exported","source":"apps/internal-ui/src/features/ai/knowledge/KnowledgeCreatePage.tsx","variants":"—","documentedAt":""},{"name":"KnowledgeDepartmentPicker","kind":"React component","category":"Feature: ai","scope":"Feature","visibility":"exported","source":"apps/internal-ui/src/features/ai/knowledge/KnowledgeDepartmentPicker.tsx","variants":"—","documentedAt":""},{"name":"KnowledgeDetailPage","kind":"React component","category":"Feature: ai","scope":"Feature","visibility":"exported","source":"apps/internal-ui/src/features/ai/knowledge/KnowledgeDetailPage.tsx","variants":"—","documentedAt":""},{"name":"KnowledgeDetailSidebar","kind":"React component","category":"Feature: ai","scope":"Feature","visibility":"exported","source":"apps/internal-ui/src/features/ai/knowledge/KnowledgeDetailSidebar.tsx","variants":"—","documentedAt":""},{"name":"KnowledgeEditorBreadcrumb","kind":"React component","category":"Feature: ai","scope":"Feature","visibility":"exported","source":"apps/internal-ui/src/features/ai/knowledge/KnowledgeEditorBreadcrumb.tsx","variants":"—","documentedAt":""},{"name":"KnowledgeEditorCard","kind":"React component","category":"Feature: ai","scope":"Feature","visibility":"exported","source":"apps/internal-ui/src/features/ai/knowledge/KnowledgeEditorCard.tsx","variants":"—","documentedAt":""},{"name":"KnowledgeEditorFields","kind":"React component","category":"Feature: ai","scope":"Feature","visibility":"exported","source":"apps/internal-ui/src/features/ai/knowledge/KnowledgeEditorFields.tsx","variants":"—","documentedAt":""},{"name":"KnowledgeImportModal","kind":"React component","category":"Feature: ai","scope":"Feature","visibility":"exported","source":"apps/internal-ui/src/features/ai/knowledge/KnowledgeImportModal.tsx","variants":"—","documentedAt":""},{"name":"KnowledgePage","kind":"React component","category":"Feature: ai","scope":"Feature","visibility":"exported","source":"apps/internal-ui/src/features/ai/knowledge/KnowledgePage.tsx","variants":"—","documentedAt":""},{"name":"KnowledgePageHeader","kind":"React component","category":"Feature: ai","scope":"Feature","visibility":"exported","source":"apps/internal-ui/src/features/ai/knowledge/KnowledgePageHeader.tsx","variants":"—","documentedAt":""},{"name":"KnowledgeScopeConflictModal","kind":"React component","category":"Feature: ai","scope":"Feature","visibility":"exported","source":"apps/internal-ui/src/features/ai/knowledge/KnowledgeScopeConflictModal.tsx","variants":"—","documentedAt":""},{"name":"KnowledgeStep","kind":"React component","category":"Feature: ai","scope":"Feature","visibility":"exported","source":"apps/internal-ui/src/features/ai/create/KnowledgeStep.tsx","variants":"—","documentedAt":""},{"name":"KnowledgeTable","kind":"React component","category":"Feature: ai","scope":"Feature","visibility":"exported","source":"apps/internal-ui/src/features/ai/knowledge/KnowledgeTable.tsx","variants":"—","documentedAt":""},{"name":"KnowledgeToolbar","kind":"React component","category":"Feature: ai","scope":"Feature","visibility":"exported","source":"apps/internal-ui/src/features/ai/knowledge/KnowledgeToolbar.tsx","variants":"—","documentedAt":""},{"name":"ModelStep","kind":"React component","category":"Feature: ai","scope":"Feature","visibility":"exported","source":"apps/internal-ui/src/features/ai/create/ModelStep.tsx","variants":"—","documentedAt":""},{"name":"ProductChoiceStep","kind":"React component","category":"Feature: ai","scope":"Feature","visibility":"exported","source":"apps/internal-ui/src/features/ai/create/ProductChoiceStep.tsx","variants":"—","documentedAt":""},{"name":"PromptStep","kind":"React component","category":"Feature: ai","scope":"Feature","visibility":"exported","source":"apps/internal-ui/src/features/ai/create/PromptStep.tsx","variants":"—","documentedAt":""},{"name":"AuthChangePassword","kind":"React component","category":"Feature: auth","scope":"Feature","visibility":"exported","source":"apps/internal-ui/src/features/auth/AuthChangePassword.tsx","variants":"—","documentedAt":""},{"name":"AuthCodeInput","kind":"React component","category":"Feature: auth","scope":"Feature","visibility":"exported","source":"apps/internal-ui/src/features/auth/AuthCodeInput.tsx","variants":"—","documentedAt":""},{"name":"AuthField","kind":"React component","category":"Feature: auth","scope":"Feature","visibility":"exported","source":"apps/internal-ui/src/features/auth/AuthField.tsx","variants":"—","documentedAt":""},{"name":"AuthFrame","kind":"React component","category":"Feature: auth","scope":"Feature","visibility":"exported","source":"apps/internal-ui/src/features/auth/AuthFrame.tsx","variants":"—","documentedAt":""},{"name":"AuthLogin","kind":"React component","category":"Feature: auth","scope":"Feature","visibility":"exported","source":"apps/internal-ui/src/features/auth/AuthLogin.tsx","variants":"—","documentedAt":""},{"name":"AuthPasswordRecovery","kind":"React component","category":"Feature: auth","scope":"Feature","visibility":"exported","source":"apps/internal-ui/src/features/auth/AuthPasswordRecovery.tsx","variants":"—","documentedAt":""},{"name":"AuthResetPassword","kind":"React component","category":"Feature: auth","scope":"Feature","visibility":"exported","source":"apps/internal-ui/src/features/auth/AuthResetPassword.tsx","variants":"—","documentedAt":""},{"name":"AuthTotpCode","kind":"React component","category":"Feature: auth","scope":"Feature","visibility":"exported","source":"apps/internal-ui/src/features/auth/AuthTotpCode.tsx","variants":"—","documentedAt":""},{"name":"AuthTotpSetup","kind":"React component","category":"Feature: auth","scope":"Feature","visibility":"exported","source":"apps/internal-ui/src/features/auth/AuthTotpSetup.tsx","variants":"—","documentedAt":""},{"name":"TotpQr","kind":"React component","category":"Feature: auth","scope":"Feature","visibility":"exported","source":"apps/internal-ui/src/features/auth/TotpQr.tsx","variants":"—","documentedAt":""},{"name":"AgentCell","kind":"React component","category":"Feature: channels","scope":"Feature","visibility":"local","source":"apps/internal-ui/src/features/channels/ChannelRow.tsx","variants":"—","documentedAt":""},{"name":"ChannelAgentSection","kind":"React component","category":"Feature: channels","scope":"Feature","visibility":"exported","source":"apps/internal-ui/src/features/channels/ChannelAgentSection.tsx","variants":"—","documentedAt":""},{"name":"ChannelArchivedNotice","kind":"React component","category":"Feature: channels","scope":"Feature","visibility":"exported","source":"apps/internal-ui/src/features/channels/ChannelArchivedNotice.tsx","variants":"—","documentedAt":""},{"name":"ChannelAssignmentSection","kind":"React component","category":"Feature: channels","scope":"Feature","visibility":"exported","source":"apps/internal-ui/src/features/channels/ChannelAssignmentSection.tsx","variants":"—","documentedAt":""},{"name":"ChannelConnectionsSection","kind":"React component","category":"Feature: channels","scope":"Feature","visibility":"exported","source":"apps/internal-ui/src/features/channels/ChannelConnectionsSection.tsx","variants":"—","documentedAt":""},{"name":"ChannelConnectionsTable","kind":"React component","category":"Feature: channels","scope":"Feature","visibility":"exported","source":"apps/internal-ui/src/features/channels/ChannelConnectionsTable.tsx","variants":"—","documentedAt":""},{"name":"ChannelCountersSection","kind":"React component","category":"Feature: channels","scope":"Feature","visibility":"exported","source":"apps/internal-ui/src/features/channels/ChannelCountersSection.tsx","variants":"—","documentedAt":""},{"name":"ChannelCreateWizard","kind":"React component","category":"Feature: channels","scope":"Feature","visibility":"exported","source":"apps/internal-ui/src/features/channels/ChannelCreateWizard.tsx","variants":"—","documentedAt":""},{"name":"ChannelDangerActions","kind":"React component","category":"Feature: channels","scope":"Feature","visibility":"exported","source":"apps/internal-ui/src/features/channels/ChannelDangerActions.tsx","variants":"—","documentedAt":""},{"name":"ChannelDeactivationDialog","kind":"React component","category":"Feature: channels","scope":"Feature","visibility":"exported","source":"apps/internal-ui/src/features/channels/ChannelDeactivationDialog.tsx","variants":"—","documentedAt":""},{"name":"ChannelDeleteDialog","kind":"React component","category":"Feature: channels","scope":"Feature","visibility":"exported","source":"apps/internal-ui/src/features/channels/ChannelDeleteDialog.tsx","variants":"—","documentedAt":""},{"name":"ChannelDetailHeader","kind":"React component","category":"Feature: channels","scope":"Feature","visibility":"exported","source":"apps/internal-ui/src/features/channels/ChannelDetailHeader.tsx","variants":"—","documentedAt":""},{"name":"ChannelDetailPage","kind":"React component","category":"Feature: channels","scope":"Feature","visibility":"exported","source":"apps/internal-ui/src/features/channels/ChannelDetailPage.tsx","variants":"—","documentedAt":""},{"name":"ChannelPolicySection","kind":"React component","category":"Feature: channels","scope":"Feature","visibility":"exported","source":"apps/internal-ui/src/features/channels/ChannelPolicySection.tsx","variants":"—","documentedAt":""},{"name":"ChannelRow","kind":"React component","category":"Feature: channels","scope":"Feature","visibility":"exported","source":"apps/internal-ui/src/features/channels/ChannelRow.tsx","variants":"—","documentedAt":""},{"name":"ChannelWizardAssignmentStep","kind":"React component","category":"Feature: channels","scope":"Feature","visibility":"exported","source":"apps/internal-ui/src/features/channels/ChannelWizardAssignmentStep.tsx","variants":"—","documentedAt":""},{"name":"ChannelWizardChannelStep","kind":"React component","category":"Feature: channels","scope":"Feature","visibility":"exported","source":"apps/internal-ui/src/features/channels/ChannelWizardChannelStep.tsx","variants":"—","documentedAt":""},{"name":"ChannelWizardSummary","kind":"React component","category":"Feature: channels","scope":"Feature","visibility":"exported","source":"apps/internal-ui/src/features/channels/ChannelWizardSummary.tsx","variants":"—","documentedAt":""},{"name":"ChannelsPage","kind":"React component","category":"Feature: channels","scope":"Feature","visibility":"exported","source":"apps/internal-ui/src/features/channels/ChannelsPage.tsx","variants":"—","documentedAt":""},{"name":"ChannelsTable","kind":"React component","category":"Feature: channels","scope":"Feature","visibility":"exported","source":"apps/internal-ui/src/features/channels/ChannelsTable.tsx","variants":"—","documentedAt":""},{"name":"DestinationCard","kind":"React component","category":"Feature: channels","scope":"Feature","visibility":"local","source":"apps/internal-ui/src/features/channels/ChannelWizardSummary.tsx","variants":"—","documentedAt":""},{"name":"PolicyRows","kind":"React component","category":"Feature: channels","scope":"Feature","visibility":"local","source":"apps/internal-ui/src/features/channels/ChannelPolicySection.tsx","variants":"—","documentedAt":""},{"name":"CommandCenter","kind":"React component","category":"Feature: command","scope":"Feature","visibility":"exported","source":"apps/internal-ui/src/features/command/center/CommandCenterPage.tsx","variants":"—","documentedAt":""},{"name":"CommandHeader","kind":"React component","category":"Feature: command","scope":"Feature","visibility":"exported","source":"apps/internal-ui/src/features/command/center/CommandHeader.tsx","variants":"—","documentedAt":""},{"name":"CommandRail","kind":"React component","category":"Feature: command","scope":"Feature","visibility":"exported","source":"apps/internal-ui/src/features/command/center/CommandRail.tsx","variants":"—","documentedAt":""},{"name":"CompanyStatusBanner","kind":"React component","category":"Feature: command","scope":"Feature","visibility":"exported","source":"apps/internal-ui/src/features/command/center/CompanyStatusBanner.tsx","variants":"—","documentedAt":""},{"name":"DepartmentCard","kind":"React component","category":"Feature: command","scope":"Feature","visibility":"local","source":"apps/internal-ui/src/features/command/center/SalesDepartmentCard.tsx","variants":"—","documentedAt":""},{"name":"DepartmentsColumn","kind":"React component","category":"Feature: command","scope":"Feature","visibility":"exported","source":"apps/internal-ui/src/features/command/center/SalesDepartmentCard.tsx","variants":"—","documentedAt":""},{"name":"MetricGroup","kind":"React component","category":"Feature: command","scope":"Feature","visibility":"exported","source":"apps/internal-ui/src/features/command/center/MetricGroup.tsx","variants":"—","documentedAt":""},{"name":"RailCard","kind":"React component","category":"Feature: command","scope":"Feature","visibility":"exported","source":"apps/internal-ui/src/features/command/center/RailCard.tsx","variants":"—","documentedAt":""},{"name":"SmallAiMetric","kind":"React component","category":"Feature: command","scope":"Feature","visibility":"local","source":"apps/internal-ui/src/features/command/center/CommandRail.tsx","variants":"—","documentedAt":""},{"name":"SmallMetric","kind":"React component","category":"Feature: command","scope":"Feature","visibility":"local","source":"apps/internal-ui/src/features/command/center/CompanyStatusBanner.tsx","variants":"—","documentedAt":""},{"name":"StatusLabel","kind":"React component","category":"Feature: command","scope":"Feature","visibility":"exported","source":"apps/internal-ui/src/features/command/center/StatusLabel.tsx","variants":"—","documentedAt":""},{"name":"CallOverlay","kind":"React component","category":"Feature: conversations","scope":"Feature","visibility":"exported","source":"apps/internal-ui/src/features/conversations/CallOverlay.tsx","variants":"—","documentedAt":""},{"name":"Composer","kind":"React component","category":"Feature: conversations","scope":"Feature","visibility":"exported","source":"apps/internal-ui/src/features/conversations/Composer.tsx","variants":"—","documentedAt":""},{"name":"ContactAvatar","kind":"React component","category":"Feature: conversations","scope":"Feature","visibility":"exported","source":"apps/internal-ui/src/features/conversations/ContactAvatar.tsx","variants":"—","documentedAt":""},{"name":"ContextSection","kind":"React component","category":"Feature: conversations","scope":"Feature","visibility":"exported","source":"apps/internal-ui/src/features/conversations/ContextSection.tsx","variants":"—","documentedAt":""},{"name":"ConversationActions","kind":"React component","category":"Feature: conversations","scope":"Feature","visibility":"exported","source":"apps/internal-ui/src/features/conversations/ConversationActions.tsx","variants":"—","documentedAt":""},{"name":"ConversationThread","kind":"React component","category":"Feature: conversations","scope":"Feature","visibility":"exported","source":"apps/internal-ui/src/features/conversations/ConversationThread.tsx","variants":"—","documentedAt":""},{"name":"ConversationWorkspace","kind":"React component","category":"Feature: conversations","scope":"Feature","visibility":"exported","source":"apps/internal-ui/src/features/conversations/ConversationWorkspace.tsx","variants":"—","documentedAt":"layouts.html"},{"name":"DialogList","kind":"React component","category":"Feature: conversations","scope":"Feature","visibility":"exported","source":"apps/internal-ui/src/features/conversations/DialogList.tsx","variants":"—","documentedAt":""},{"name":"DialogListItem","kind":"React component","category":"Feature: conversations","scope":"Feature","visibility":"local","source":"apps/internal-ui/src/features/conversations/DialogList.tsx","variants":"—","documentedAt":""},{"name":"DialogTab","kind":"React component","category":"Feature: conversations","scope":"Feature","visibility":"local","source":"apps/internal-ui/src/features/conversations/DialogList.tsx","variants":"—","documentedAt":""},{"name":"EmailMessageBody","kind":"React component","category":"Feature: conversations","scope":"Feature","visibility":"exported","source":"apps/internal-ui/src/features/conversations/EmailMessageBody.tsx","variants":"—","documentedAt":""},{"name":"FieldRow","kind":"React component","category":"Feature: conversations","scope":"Feature","visibility":"exported","source":"apps/internal-ui/src/features/conversations/FieldRow.tsx","variants":"—","documentedAt":""},{"name":"Message","kind":"React component","category":"Feature: conversations","scope":"Feature","visibility":"local","source":"apps/internal-ui/src/features/conversations/ConversationThread.tsx","variants":"—","documentedAt":""},{"name":"MessageRow","kind":"React component","category":"Feature: conversations","scope":"Feature","visibility":"local","source":"apps/internal-ui/src/features/conversations/ConversationThread.tsx","variants":"—","documentedAt":""},{"name":"StatusBadge","kind":"React component","category":"Feature: conversations","scope":"Feature","visibility":"local","source":"apps/internal-ui/src/features/conversations/ConversationThread.tsx","variants":"—","documentedAt":""},{"name":"DepartmentCard","kind":"React component","category":"Feature: departments","scope":"Feature","visibility":"exported","source":"apps/internal-ui/src/features/departments/DepartmentCard.tsx","variants":"—","documentedAt":""},{"name":"DepartmentsPage","kind":"React component","category":"Feature: departments","scope":"Feature","visibility":"exported","source":"apps/internal-ui/src/features/departments/DepartmentsPage.tsx","variants":"—","documentedAt":""},{"name":"AccessProfileEditor","kind":"React component","category":"Feature: employees","scope":"Feature","visibility":"exported","source":"apps/internal-ui/src/features/employees/AccessProfileEditor.tsx","variants":"—","documentedAt":""},{"name":"AccessProfileList","kind":"React component","category":"Feature: employees","scope":"Feature","visibility":"exported","source":"apps/internal-ui/src/features/employees/AccessProfileList.tsx","variants":"—","documentedAt":""},{"name":"AccessProfilesPage","kind":"React component","category":"Feature: employees","scope":"Feature","visibility":"exported","source":"apps/internal-ui/src/features/employees/AccessProfilesPage.tsx","variants":"—","documentedAt":""},{"name":"CreateField","kind":"React component","category":"Feature: employees","scope":"Feature","visibility":"local","source":"apps/internal-ui/src/features/employees/EmployeeCreateDrawer.tsx","variants":"—","documentedAt":""},{"name":"CreateStep","kind":"React component","category":"Feature: employees","scope":"Feature","visibility":"local","source":"apps/internal-ui/src/features/employees/EmployeeCreateDrawer.tsx","variants":"—","documentedAt":""},{"name":"EmployeeAccessSection","kind":"React component","category":"Feature: employees","scope":"Feature","visibility":"exported","source":"apps/internal-ui/src/features/employees/EmployeeAccessSection.tsx","variants":"—","documentedAt":""},{"name":"EmployeeCreateDrawer","kind":"React component","category":"Feature: employees","scope":"Feature","visibility":"exported","source":"apps/internal-ui/src/features/employees/EmployeeCreateDrawer.tsx","variants":"—","documentedAt":""},{"name":"EmployeeDetailHeader","kind":"React component","category":"Feature: employees","scope":"Feature","visibility":"exported","source":"apps/internal-ui/src/features/employees/EmployeeDetailHeader.tsx","variants":"—","documentedAt":""},{"name":"EmployeeDetailPage","kind":"React component","category":"Feature: employees","scope":"Feature","visibility":"exported","source":"apps/internal-ui/src/features/employees/EmployeeDetailPage.tsx","variants":"—","documentedAt":""},{"name":"EmployeeDetailRail","kind":"React component","category":"Feature: employees","scope":"Feature","visibility":"exported","source":"apps/internal-ui/src/features/employees/EmployeeDetailRail.tsx","variants":"—","documentedAt":""},{"name":"EmployeeDetailSections","kind":"React component","category":"Feature: employees","scope":"Feature","visibility":"exported","source":"apps/internal-ui/src/features/employees/EmployeeDetailSections.tsx","variants":"—","documentedAt":""},{"name":"EmployeeIdentitySections","kind":"React component","category":"Feature: employees","scope":"Feature","visibility":"exported","source":"apps/internal-ui/src/features/employees/EmployeeIdentitySections.tsx","variants":"—","documentedAt":""},{"name":"EmployeeRow","kind":"React component","category":"Feature: employees","scope":"Feature","visibility":"local","source":"apps/internal-ui/src/features/employees/EmployeeTable.tsx","variants":"—","documentedAt":""},{"name":"EmployeeSecuritySections","kind":"React component","category":"Feature: employees","scope":"Feature","visibility":"exported","source":"apps/internal-ui/src/features/employees/EmployeeSecuritySections.tsx","variants":"—","documentedAt":""},{"name":"EmployeeTable","kind":"React component","category":"Feature: employees","scope":"Feature","visibility":"exported","source":"apps/internal-ui/src/features/employees/EmployeeTable.tsx","variants":"—","documentedAt":""},{"name":"EmployeesFilters","kind":"React component","category":"Feature: employees","scope":"Feature","visibility":"exported","source":"apps/internal-ui/src/features/employees/EmployeesFilters.tsx","variants":"—","documentedAt":""},{"name":"EmployeesPage","kind":"React component","category":"Feature: employees","scope":"Feature","visibility":"exported","source":"apps/internal-ui/src/features/employees/EmployeesListPage.tsx","variants":"—","documentedAt":""},{"name":"OwnershipTransferModal","kind":"React component","category":"Feature: employees","scope":"Feature","visibility":"exported","source":"apps/internal-ui/src/features/employees/OwnershipTransferModal.tsx","variants":"—","documentedAt":""},{"name":"RoleOption","kind":"React component","category":"Feature: employees","scope":"Feature","visibility":"local","source":"apps/internal-ui/src/features/employees/EmployeeCreateDrawer.tsx","variants":"—","documentedAt":""},{"name":"RoleOption","kind":"React component","category":"Feature: employees","scope":"Feature","visibility":"local","source":"apps/internal-ui/src/features/employees/OwnershipTransferModal.tsx","variants":"—","documentedAt":""},{"name":"Heading","kind":"React component","category":"Feature: help-center","scope":"Feature","visibility":"local","source":"apps/internal-ui/src/features/help-center/MarkdownContent.tsx","variants":"—","documentedAt":""},{"name":"HelpArrowIcon","kind":"React component","category":"Feature: help-center","scope":"Feature","visibility":"exported","source":"apps/internal-ui/src/features/help-center/HelpIcons.tsx","variants":"—","documentedAt":""},{"name":"HelpArticle","kind":"React component","category":"Feature: help-center","scope":"Feature","visibility":"exported","source":"apps/internal-ui/src/features/help-center/HelpArticle.tsx","variants":"—","documentedAt":""},{"name":"HelpCenterApp","kind":"React component","category":"Feature: help-center","scope":"Feature","visibility":"exported","source":"apps/internal-ui/src/features/help-center/HelpCenterApp.tsx","variants":"—","documentedAt":""},{"name":"HelpChevronIcon","kind":"React component","category":"Feature: help-center","scope":"Feature","visibility":"exported","source":"apps/internal-ui/src/features/help-center/HelpIcons.tsx","variants":"—","documentedAt":""},{"name":"HelpFolderIcon","kind":"React component","category":"Feature: help-center","scope":"Feature","visibility":"exported","source":"apps/internal-ui/src/features/help-center/HelpIcons.tsx","variants":"—","documentedAt":""},{"name":"HelpHome","kind":"React component","category":"Feature: help-center","scope":"Feature","visibility":"exported","source":"apps/internal-ui/src/features/help-center/HelpHome.tsx","variants":"—","documentedAt":""},{"name":"HelpLayout","kind":"React component","category":"Feature: help-center","scope":"Feature","visibility":"exported","source":"apps/internal-ui/src/features/help-center/HelpLayout.tsx","variants":"—","documentedAt":""},{"name":"HelpMessageIcon","kind":"React component","category":"Feature: help-center","scope":"Feature","visibility":"exported","source":"apps/internal-ui/src/features/help-center/HelpIcons.tsx","variants":"—","documentedAt":""},{"name":"HelpSearch","kind":"React component","category":"Feature: help-center","scope":"Feature","visibility":"exported","source":"apps/internal-ui/src/features/help-center/HelpSearch.tsx","variants":"—","documentedAt":""},{"name":"HelpSearchIcon","kind":"React component","category":"Feature: help-center","scope":"Feature","visibility":"exported","source":"apps/internal-ui/src/features/help-center/HelpIcons.tsx","variants":"—","documentedAt":""},{"name":"HelpThumbIcon","kind":"React component","category":"Feature: help-center","scope":"Feature","visibility":"exported","source":"apps/internal-ui/src/features/help-center/HelpIcons.tsx","variants":"—","documentedAt":""},{"name":"MarkdownContent","kind":"React component","category":"Feature: help-center","scope":"Feature","visibility":"exported","source":"apps/internal-ui/src/features/help-center/MarkdownContent.tsx","variants":"—","documentedAt":""},{"name":"PortalWebWidget","kind":"React component","category":"Feature: help-center","scope":"Feature","visibility":"exported","source":"apps/internal-ui/src/features/help-center/PortalWebWidget.tsx","variants":"—","documentedAt":""},{"name":"SupportLauncher","kind":"React component","category":"Feature: help-center","scope":"Feature","visibility":"exported","source":"apps/internal-ui/src/features/help-center/SupportLauncher.tsx","variants":"—","documentedAt":""},{"name":"ConnectionIcon","kind":"React component","category":"Feature: integrations","scope":"Feature","visibility":"exported","source":"apps/internal-ui/src/features/integrations/rows.tsx","variants":"—","documentedAt":""},{"name":"ConnectionsTable","kind":"React component","category":"Feature: integrations","scope":"Feature","visibility":"exported","source":"apps/internal-ui/src/features/integrations/ConnectionsTable.tsx","variants":"—","documentedAt":""},{"name":"EmailFields","kind":"React component","category":"Feature: integrations","scope":"Feature","visibility":"exported","source":"apps/internal-ui/src/features/integrations/EmailFields.tsx","variants":"—","documentedAt":""},{"name":"IntegrationForm","kind":"React component","category":"Feature: integrations","scope":"Feature","visibility":"exported","source":"apps/internal-ui/src/features/integrations/IntegrationForm.tsx","variants":"—","documentedAt":""},{"name":"IntegrationsPage","kind":"React component","category":"Feature: integrations","scope":"Feature","visibility":"exported","source":"apps/internal-ui/src/features/integrations/IntegrationsPage.tsx","variants":"—","documentedAt":""},{"name":"MailboxSection","kind":"React component","category":"Feature: integrations","scope":"Feature","visibility":"local","source":"apps/internal-ui/src/features/integrations/EmailFields.tsx","variants":"—","documentedAt":""},{"name":"ProvidersTable","kind":"React component","category":"Feature: integrations","scope":"Feature","visibility":"exported","source":"apps/internal-ui/src/features/integrations/ProvidersTable.tsx","variants":"—","documentedAt":""},{"name":"RowActions","kind":"React component","category":"Feature: integrations","scope":"Feature","visibility":"exported","source":"apps/internal-ui/src/features/integrations/rows.tsx","variants":"—","documentedAt":""},{"name":"StatusCell","kind":"React component","category":"Feature: integrations","scope":"Feature","visibility":"exported","source":"apps/internal-ui/src/features/integrations/rows.tsx","variants":"—","documentedAt":""},{"name":"NotificationDrawer","kind":"React component","category":"Feature: notifications","scope":"Feature","visibility":"exported","source":"apps/internal-ui/src/features/notifications/NotificationDrawer.tsx","variants":"—","documentedAt":""},{"name":"Section","kind":"React component","category":"Feature: notifications","scope":"Feature","visibility":"local","source":"apps/internal-ui/src/features/notifications/NotificationDrawer.tsx","variants":"—","documentedAt":""},{"name":"OfferFormModal","kind":"React component","category":"Feature: products","scope":"Feature","visibility":"exported","source":"apps/internal-ui/src/features/products/detail/OfferFormModal.tsx","variants":"—","documentedAt":""},{"name":"PriceFormModal","kind":"React component","category":"Feature: products","scope":"Feature","visibility":"exported","source":"apps/internal-ui/src/features/products/detail/PriceFormModal.tsx","variants":"—","documentedAt":""},{"name":"ProductAccessTab","kind":"React component","category":"Feature: products","scope":"Feature","visibility":"exported","source":"apps/internal-ui/src/features/products/detail/ProductAccessTab.tsx","variants":"—","documentedAt":""},{"name":"ProductAgentCard","kind":"React component","category":"Feature: products","scope":"Feature","visibility":"exported","source":"apps/internal-ui/src/features/products/detail/ProductAgentCard.tsx","variants":"—","documentedAt":""},{"name":"ProductChannelsTab","kind":"React component","category":"Feature: products","scope":"Feature","visibility":"exported","source":"apps/internal-ui/src/features/products/detail/ProductChannelsTab.tsx","variants":"—","documentedAt":""},{"name":"ProductDetailHeader","kind":"React component","category":"Feature: products","scope":"Feature","visibility":"exported","source":"apps/internal-ui/src/features/products/detail/ProductDetailHeader.tsx","variants":"—","documentedAt":""},{"name":"ProductDetailPage","kind":"React component","category":"Feature: products","scope":"Feature","visibility":"exported","source":"apps/internal-ui/src/features/products/detail/ProductDetailPage.tsx","variants":"—","documentedAt":""},{"name":"ProductEmptyTab","kind":"React component","category":"Feature: products","scope":"Feature","visibility":"exported","source":"apps/internal-ui/src/features/products/detail/ProductEmptyTab.tsx","variants":"—","documentedAt":""},{"name":"ProductFormModal","kind":"React component","category":"Feature: products","scope":"Feature","visibility":"exported","source":"apps/internal-ui/src/features/products/ProductFormModal.tsx","variants":"—","documentedAt":""},{"name":"ProductOffersTab","kind":"React component","category":"Feature: products","scope":"Feature","visibility":"exported","source":"apps/internal-ui/src/features/products/detail/ProductOffersTab.tsx","variants":"—","documentedAt":""},{"name":"ProductOverviewTab","kind":"React component","category":"Feature: products","scope":"Feature","visibility":"exported","source":"apps/internal-ui/src/features/products/detail/ProductOverviewTab.tsx","variants":"—","documentedAt":""},{"name":"ProductRow","kind":"React component","category":"Feature: products","scope":"Feature","visibility":"exported","source":"apps/internal-ui/src/features/products/ProductRow.tsx","variants":"—","documentedAt":""},{"name":"ProductsHeaderActions","kind":"React component","category":"Feature: products","scope":"Feature","visibility":"exported","source":"apps/internal-ui/src/features/products/ProductsHeaderActions.tsx","variants":"—","documentedAt":""},{"name":"ProductsPage","kind":"React component","category":"Feature: products","scope":"Feature","visibility":"exported","source":"apps/internal-ui/src/features/products/ProductsPage.tsx","variants":"—","documentedAt":""},{"name":"ProductsTable","kind":"React component","category":"Feature: products","scope":"Feature","visibility":"exported","source":"apps/internal-ui/src/features/products/ProductsTable.tsx","variants":"—","documentedAt":""},{"name":"ProfileField","kind":"React component","category":"Feature: profile","scope":"Feature","visibility":"exported","source":"apps/internal-ui/src/features/profile/ProfileField.tsx","variants":"—","documentedAt":""},{"name":"ProfileHeader","kind":"React component","category":"Feature: profile","scope":"Feature","visibility":"exported","source":"apps/internal-ui/src/features/profile/ProfileHeader.tsx","variants":"—","documentedAt":""},{"name":"ProfileInfoForm","kind":"React component","category":"Feature: profile","scope":"Feature","visibility":"exported","source":"apps/internal-ui/src/features/profile/ProfileInfoForm.tsx","variants":"—","documentedAt":""},{"name":"ProfileNotificationsCard","kind":"React component","category":"Feature: profile","scope":"Feature","visibility":"exported","source":"apps/internal-ui/src/features/profile/ProfileNotificationsCard.tsx","variants":"—","documentedAt":""},{"name":"ProfilePage","kind":"React component","category":"Feature: profile","scope":"Feature","visibility":"exported","source":"apps/internal-ui/src/features/profile/ProfilePage.tsx","variants":"—","documentedAt":""},{"name":"ProfilePasswordForm","kind":"React component","category":"Feature: profile","scope":"Feature","visibility":"exported","source":"apps/internal-ui/src/features/profile/ProfilePasswordForm.tsx","variants":"—","documentedAt":""},{"name":"ProfileSessionsCard","kind":"React component","category":"Feature: profile","scope":"Feature","visibility":"exported","source":"apps/internal-ui/src/features/profile/ProfileSessionsCard.tsx","variants":"—","documentedAt":""},{"name":"ProfileTotpCard","kind":"React component","category":"Feature: profile","scope":"Feature","visibility":"exported","source":"apps/internal-ui/src/features/profile/ProfileTotpCard.tsx","variants":"—","documentedAt":""},{"name":"ActivityList","kind":"React component","category":"Feature: sales","scope":"Feature","visibility":"local","source":"apps/internal-ui/src/features/sales/client-detail/SalesClientOverviewTab.tsx","variants":"—","documentedAt":""},{"name":"AddSaleDialog","kind":"React component","category":"Feature: sales","scope":"Feature","visibility":"exported","source":"apps/internal-ui/src/features/sales/registry/AddSaleDialog.tsx","variants":"—","documentedAt":""},{"name":"ClientContext","kind":"React component","category":"Feature: sales","scope":"Feature","visibility":"exported","source":"apps/internal-ui/src/features/sales/dialogs/context/ClientContext.tsx","variants":"—","documentedAt":""},{"name":"FilterSelect","kind":"React component","category":"Feature: sales","scope":"Feature","visibility":"local","source":"apps/internal-ui/src/features/sales/registry/SalesRegistryFilters.tsx","variants":"—","documentedAt":""},{"name":"FulfillmentTable","kind":"React component","category":"Feature: sales","scope":"Feature","visibility":"exported","source":"apps/internal-ui/src/features/sales/orders/tables/FulfillmentTable.tsx","variants":"—","documentedAt":""},{"name":"HistoryContext","kind":"React component","category":"Feature: sales","scope":"Feature","visibility":"exported","source":"apps/internal-ui/src/features/sales/dialogs/context/HistoryContext.tsx","variants":"—","documentedAt":""},{"name":"OrdersTable","kind":"React component","category":"Feature: sales","scope":"Feature","visibility":"exported","source":"apps/internal-ui/src/features/sales/orders/tables/OrdersTable.tsx","variants":"—","documentedAt":""},{"name":"PaymentsTable","kind":"React component","category":"Feature: sales","scope":"Feature","visibility":"exported","source":"apps/internal-ui/src/features/sales/orders/tables/PaymentsTable.tsx","variants":"—","documentedAt":""},{"name":"ProductContext","kind":"React component","category":"Feature: sales","scope":"Feature","visibility":"exported","source":"apps/internal-ui/src/features/sales/dialogs/context/ProductContext.tsx","variants":"—","documentedAt":""},{"name":"RightTabButton","kind":"React component","category":"Feature: sales","scope":"Feature","visibility":"local","source":"apps/internal-ui/src/features/sales/dialogs/context/SalesContextTabs.tsx","variants":"—","documentedAt":""},{"name":"SaleActionPanel","kind":"React component","category":"Feature: sales","scope":"Feature","visibility":"exported","source":"apps/internal-ui/src/features/sales/sale-detail/SaleActionPanel.tsx","variants":"—","documentedAt":""},{"name":"SaleDetailPage","kind":"React component","category":"Feature: sales","scope":"Feature","visibility":"exported","source":"apps/internal-ui/src/features/sales/sale-detail/SaleDetailPage.tsx","variants":"—","documentedAt":""},{"name":"SaleEventTimeline","kind":"React component","category":"Feature: sales","scope":"Feature","visibility":"exported","source":"apps/internal-ui/src/features/sales/sale-detail/SaleEventTimeline.tsx","variants":"—","documentedAt":""},{"name":"SalesActor","kind":"React component","category":"Feature: sales","scope":"Feature","visibility":"local","source":"apps/internal-ui/src/features/sales/overview/SalesActorsCard.tsx","variants":"—","documentedAt":""},{"name":"SalesActorsCard","kind":"React component","category":"Feature: sales","scope":"Feature","visibility":"exported","source":"apps/internal-ui/src/features/sales/overview/SalesActorsCard.tsx","variants":"—","documentedAt":""},{"name":"SalesChannelsCard","kind":"React component","category":"Feature: sales","scope":"Feature","visibility":"exported","source":"apps/internal-ui/src/features/sales/overview/SalesChannelsCard.tsx","variants":"—","documentedAt":""},{"name":"SalesChart","kind":"React component","category":"Feature: sales","scope":"Feature","visibility":"exported","source":"apps/internal-ui/src/features/sales/overview/SalesChart.tsx","variants":"—","documentedAt":""},{"name":"SalesClientAuditTab","kind":"React component","category":"Feature: sales","scope":"Feature","visibility":"exported","source":"apps/internal-ui/src/features/sales/client-detail/SalesClientAuditTab.tsx","variants":"—","documentedAt":""},{"name":"SalesClientConsentTab","kind":"React component","category":"Feature: sales","scope":"Feature","visibility":"exported","source":"apps/internal-ui/src/features/sales/client-detail/SalesClientConsentTab.tsx","variants":"—","documentedAt":""},{"name":"SalesClientDetailPage","kind":"React component","category":"Feature: sales","scope":"Feature","visibility":"exported","source":"apps/internal-ui/src/features/sales/client-detail/SalesClientDetailPage.tsx","variants":"—","documentedAt":""},{"name":"SalesClientDialogsTab","kind":"React component","category":"Feature: sales","scope":"Feature","visibility":"exported","source":"apps/internal-ui/src/features/sales/client-detail/SalesClientDialogsTab.tsx","variants":"—","documentedAt":""},{"name":"SalesClientHeader","kind":"React component","category":"Feature: sales","scope":"Feature","visibility":"exported","source":"apps/internal-ui/src/features/sales/client-detail/SalesClientHeader.tsx","variants":"—","documentedAt":""},{"name":"SalesClientIdentitiesTab","kind":"React component","category":"Feature: sales","scope":"Feature","visibility":"exported","source":"apps/internal-ui/src/features/sales/client-detail/SalesClientIdentitiesTab.tsx","variants":"—","documentedAt":""},{"name":"SalesClientOverviewTab","kind":"React component","category":"Feature: sales","scope":"Feature","visibility":"exported","source":"apps/internal-ui/src/features/sales/client-detail/SalesClientOverviewTab.tsx","variants":"—","documentedAt":""},{"name":"SalesClientRow","kind":"React component","category":"Feature: sales","scope":"Feature","visibility":"exported","source":"apps/internal-ui/src/features/sales/clients/SalesClientRow.tsx","variants":"—","documentedAt":""},{"name":"SalesClientSalesTab","kind":"React component","category":"Feature: sales","scope":"Feature","visibility":"exported","source":"apps/internal-ui/src/features/sales/client-detail/SalesClientSalesTab.tsx","variants":"—","documentedAt":""},{"name":"SalesClientTabs","kind":"React component","category":"Feature: sales","scope":"Feature","visibility":"exported","source":"apps/internal-ui/src/features/sales/client-detail/SalesClientTabs.tsx","variants":"—","documentedAt":""},{"name":"SalesClientsEmpty","kind":"React component","category":"Feature: sales","scope":"Feature","visibility":"local","source":"apps/internal-ui/src/features/sales/clients/SalesClientsTable.tsx","variants":"—","documentedAt":""},{"name":"SalesClientsFilters","kind":"React component","category":"Feature: sales","scope":"Feature","visibility":"exported","source":"apps/internal-ui/src/features/sales/clients/SalesClientsFilters.tsx","variants":"—","documentedAt":""},{"name":"SalesClientsHeader","kind":"React component","category":"Feature: sales","scope":"Feature","visibility":"exported","source":"apps/internal-ui/src/features/sales/clients/SalesClientsHeader.tsx","variants":"—","documentedAt":""},{"name":"SalesClientsPage","kind":"React component","category":"Feature: sales","scope":"Feature","visibility":"exported","source":"apps/internal-ui/src/features/sales/SalesClientsPage.tsx","variants":"—","documentedAt":""},{"name":"SalesClientsPagination","kind":"React component","category":"Feature: sales","scope":"Feature","visibility":"exported","source":"apps/internal-ui/src/features/sales/clients/SalesClientsPagination.tsx","variants":"—","documentedAt":""},{"name":"SalesClientsTable","kind":"React component","category":"Feature: sales","scope":"Feature","visibility":"exported","source":"apps/internal-ui/src/features/sales/clients/SalesClientsTable.tsx","variants":"—","documentedAt":""},{"name":"SalesContextPanel","kind":"React component","category":"Feature: sales","scope":"Feature","visibility":"exported","source":"apps/internal-ui/src/features/sales/dialogs/SalesContextPanel.tsx","variants":"—","documentedAt":""},{"name":"SalesContextTabs","kind":"React component","category":"Feature: sales","scope":"Feature","visibility":"exported","source":"apps/internal-ui/src/features/sales/dialogs/context/SalesContextTabs.tsx","variants":"—","documentedAt":""},{"name":"SalesDialogsPage","kind":"React component","category":"Feature: sales","scope":"Feature","visibility":"exported","source":"apps/internal-ui/src/features/sales/SalesDialogsPage.tsx","variants":"—","documentedAt":""},{"name":"SalesFilterDropdown","kind":"React component","category":"Feature: sales","scope":"Feature","visibility":"local","source":"apps/internal-ui/src/features/sales/clients/SalesClientsFilters.tsx","variants":"—","documentedAt":""},{"name":"SalesKpiCard","kind":"React component","category":"Feature: sales","scope":"Feature","visibility":"local","source":"apps/internal-ui/src/features/sales/overview/SalesKpiSection.tsx","variants":"—","documentedAt":""},{"name":"SalesKpiSection","kind":"React component","category":"Feature: sales","scope":"Feature","visibility":"exported","source":"apps/internal-ui/src/features/sales/overview/SalesKpiSection.tsx","variants":"—","documentedAt":""},{"name":"SalesListCard","kind":"React component","category":"Feature: sales","scope":"Feature","visibility":"exported","source":"apps/internal-ui/src/features/sales/overview/SalesListCard.tsx","variants":"—","documentedAt":""},{"name":"SalesOrderDetailPage","kind":"React component","category":"Feature: sales","scope":"Feature","visibility":"exported","source":"apps/internal-ui/src/features/sales/order-detail/SalesOrderDetailPage.tsx","variants":"—","documentedAt":""},{"name":"SalesOrdersEmpty","kind":"React component","category":"Feature: sales","scope":"Feature","visibility":"local","source":"apps/internal-ui/src/features/sales/orders/SalesOrdersTableCard.tsx","variants":"—","documentedAt":""},{"name":"SalesOrdersHeader","kind":"React component","category":"Feature: sales","scope":"Feature","visibility":"exported","source":"apps/internal-ui/src/features/sales/orders/SalesOrdersHeader.tsx","variants":"—","documentedAt":""},{"name":"SalesOrdersPage","kind":"React component","category":"Feature: sales","scope":"Feature","visibility":"exported","source":"apps/internal-ui/src/features/sales/orders/SalesOrdersPage.tsx","variants":"—","documentedAt":""},{"name":"SalesOrdersPagination","kind":"React component","category":"Feature: sales","scope":"Feature","visibility":"exported","source":"apps/internal-ui/src/features/sales/orders/SalesOrdersPagination.tsx","variants":"—","documentedAt":""},{"name":"SalesOrdersTableCard","kind":"React component","category":"Feature: sales","scope":"Feature","visibility":"exported","source":"apps/internal-ui/src/features/sales/orders/SalesOrdersTableCard.tsx","variants":"—","documentedAt":""},{"name":"SalesOrdersTabs","kind":"React component","category":"Feature: sales","scope":"Feature","visibility":"exported","source":"apps/internal-ui/src/features/sales/orders/SalesOrdersTabs.tsx","variants":"—","documentedAt":""},{"name":"SalesOverviewGrid","kind":"React component","category":"Feature: sales","scope":"Feature","visibility":"exported","source":"apps/internal-ui/src/features/sales/overview/SalesOverviewGrid.tsx","variants":"—","documentedAt":""},{"name":"SalesOverviewHeader","kind":"React component","category":"Feature: sales","scope":"Feature","visibility":"exported","source":"apps/internal-ui/src/features/sales/overview/SalesOverviewHeader.tsx","variants":"—","documentedAt":""},{"name":"SalesOverviewPage","kind":"React component","category":"Feature: sales","scope":"Feature","visibility":"exported","source":"apps/internal-ui/src/features/sales/SalesOverviewPage.tsx","variants":"—","documentedAt":""},{"name":"SalesProductsTable","kind":"React component","category":"Feature: sales","scope":"Feature","visibility":"exported","source":"apps/internal-ui/src/features/sales/overview/SalesProductsTable.tsx","variants":"—","documentedAt":""},{"name":"SalesRegistryFilters","kind":"React component","category":"Feature: sales","scope":"Feature","visibility":"exported","source":"apps/internal-ui/src/features/sales/registry/SalesRegistryFilters.tsx","variants":"—","documentedAt":""},{"name":"SalesRegistryPage","kind":"React component","category":"Feature: sales","scope":"Feature","visibility":"exported","source":"apps/internal-ui/src/features/sales/registry/SalesRegistryPage.tsx","variants":"—","documentedAt":""},{"name":"SalesRegistryTable","kind":"React component","category":"Feature: sales","scope":"Feature","visibility":"exported","source":"apps/internal-ui/src/features/sales/registry/SalesRegistryTable.tsx","variants":"—","documentedAt":""},{"name":"SortableTh","kind":"React component","category":"Feature: sales","scope":"Feature","visibility":"local","source":"apps/internal-ui/src/features/sales/clients/SalesClientsTable.tsx","variants":"—","documentedAt":""},{"name":"StatusBadge","kind":"React component","category":"Feature: sales","scope":"Feature","visibility":"exported","source":"apps/internal-ui/src/features/sales/orders/StatusBadge.tsx","variants":"—","documentedAt":""},{"name":"SummaryGrid","kind":"React component","category":"Feature: sales","scope":"Feature","visibility":"local","source":"apps/internal-ui/src/features/sales/client-detail/SalesClientOverviewTab.tsx","variants":"—","documentedAt":""},{"name":"SettingsPage","kind":"React component","category":"Feature: settings","scope":"Feature","visibility":"exported","source":"apps/internal-ui/src/features/settings/SettingsPage.tsx","variants":"—","documentedAt":"layouts.html"},{"name":"OperatorCardSection","kind":"React component","category":"Feature: support","scope":"Feature","visibility":"local","source":"apps/internal-ui/src/features/support/context/OperatorCards.tsx","variants":"—","documentedAt":""},{"name":"OperatorCards","kind":"React component","category":"Feature: support","scope":"Feature","visibility":"exported","source":"apps/internal-ui/src/features/support/context/OperatorCards.tsx","variants":"—","documentedAt":""},{"name":"OperatorField","kind":"React component","category":"Feature: support","scope":"Feature","visibility":"local","source":"apps/internal-ui/src/features/support/context/OperatorCards.tsx","variants":"—","documentedAt":""},{"name":"RightTabButton","kind":"React component","category":"Feature: support","scope":"Feature","visibility":"local","source":"apps/internal-ui/src/features/support/context/SupportContextPanel.tsx","variants":"—","documentedAt":""},{"name":"SupportContextPanel","kind":"React component","category":"Feature: support","scope":"Feature","visibility":"exported","source":"apps/internal-ui/src/features/support/context/SupportContextPanel.tsx","variants":"—","documentedAt":""},{"name":"SupportDialogsPage","kind":"React component","category":"Feature: support","scope":"Feature","visibility":"exported","source":"apps/internal-ui/src/features/support/SupportDialogsPage.tsx","variants":"—","documentedAt":""},{"name":"SupportHistory","kind":"React component","category":"Feature: support","scope":"Feature","visibility":"exported","source":"apps/internal-ui/src/features/support/context/SupportHistory.tsx","variants":"—","documentedAt":""},{"name":"SupportOverviewPage","kind":"React component","category":"Feature: support","scope":"Feature","visibility":"exported","source":"apps/internal-ui/src/features/support/SupportOverviewPage.tsx","variants":"—","documentedAt":""},{"name":"PortalArticleEditor","kind":"React component","category":"Feature: support-portals","scope":"Feature","visibility":"exported","source":"apps/internal-ui/src/features/support-portals/PortalArticleEditor.tsx","variants":"—","documentedAt":""},{"name":"PortalArticleTable","kind":"React component","category":"Feature: support-portals","scope":"Feature","visibility":"exported","source":"apps/internal-ui/src/features/support-portals/PortalArticleTable.tsx","variants":"—","documentedAt":""},{"name":"PortalCategoryManagement","kind":"React component","category":"Feature: support-portals","scope":"Feature","visibility":"exported","source":"apps/internal-ui/src/features/support-portals/PortalCategoryManagement.tsx","variants":"—","documentedAt":""},{"name":"PortalContent","kind":"React component","category":"Feature: support-portals","scope":"Feature","visibility":"exported","source":"apps/internal-ui/src/features/support-portals/PortalContent.tsx","variants":"—","documentedAt":""},{"name":"PortalCreateDialog","kind":"React component","category":"Feature: support-portals","scope":"Feature","visibility":"exported","source":"apps/internal-ui/src/features/support-portals/PortalCreateDialog.tsx","variants":"—","documentedAt":""},{"name":"PortalDomainSettings","kind":"React component","category":"Feature: support-portals","scope":"Feature","visibility":"exported","source":"apps/internal-ui/src/features/support-portals/PortalDomainSettings.tsx","variants":"—","documentedAt":""},{"name":"PortalProductSettings","kind":"React component","category":"Feature: support-portals","scope":"Feature","visibility":"exported","source":"apps/internal-ui/src/features/support-portals/PortalProductSettings.tsx","variants":"—","documentedAt":""},{"name":"PortalSettings","kind":"React component","category":"Feature: support-portals","scope":"Feature","visibility":"exported","source":"apps/internal-ui/src/features/support-portals/PortalSettings.tsx","variants":"—","documentedAt":""},{"name":"PortalWidgetSettings","kind":"React component","category":"Feature: support-portals","scope":"Feature","visibility":"exported","source":"apps/internal-ui/src/features/support-portals/PortalWidgetSettings.tsx","variants":"—","documentedAt":""},{"name":"SupportPortalDetailPage","kind":"React component","category":"Feature: support-portals","scope":"Feature","visibility":"exported","source":"apps/internal-ui/src/features/support-portals/SupportPortalDetailPage.tsx","variants":"—","documentedAt":""},{"name":"SupportPortalsPage","kind":"React component","category":"Feature: support-portals","scope":"Feature","visibility":"exported","source":"apps/internal-ui/src/features/support-portals/SupportPortalsPage.tsx","variants":"—","documentedAt":""},{"name":"Shell","kind":"React component","category":"Layout","scope":"System","visibility":"exported","source":"apps/internal-ui/src/layout/Shell.tsx","variants":"—","documentedAt":"layouts.html"},{"name":"ShellRouteContent","kind":"React component","category":"Layout","scope":"System","visibility":"exported","source":"apps/internal-ui/src/layout/ShellRouteContent.tsx","variants":"—","documentedAt":""},{"name":"Sidebar","kind":"React component","category":"Layout","scope":"System","visibility":"exported","source":"apps/internal-ui/src/layout/Sidebar.tsx","variants":"—","documentedAt":""},{"name":"SidebarLink","kind":"React component","category":"Layout","scope":"System","visibility":"local","source":"apps/internal-ui/src/layout/Sidebar.tsx","variants":"—","documentedAt":""},{"name":"SidebarNavSection","kind":"React component","category":"Layout","scope":"System","visibility":"exported","source":"apps/internal-ui/src/layout/SidebarNavSection.tsx","variants":"—","documentedAt":""},{"name":"SidebarUserMenu","kind":"React component","category":"Layout","scope":"System","visibility":"exported","source":"apps/internal-ui/src/layout/SidebarUserMenu.tsx","variants":"—","documentedAt":""},{"name":"TopBar","kind":"React component","category":"Layout","scope":"System","visibility":"exported","source":"apps/internal-ui/src/layout/TopBar.tsx","variants":"—","documentedAt":""},{"name":"ActionButton","kind":"React component","category":"Shared UI","scope":"System","visibility":"exported","source":"apps/internal-ui/src/shared/ui-controls.tsx","variants":"—","documentedAt":"components.html"},{"name":"Avatar","kind":"React component","category":"Shared UI","scope":"System","visibility":"exported","source":"apps/internal-ui/src/shared/ui.tsx","variants":"—","documentedAt":"data-display.html"},{"name":"Button","kind":"React component","category":"Shared UI","scope":"System","visibility":"exported","source":"apps/internal-ui/src/shared/ui-controls.tsx","variants":"primary, secondary, action, danger-outline","documentedAt":"components.html"},{"name":"CategoryManagementDialog","kind":"React component","category":"Shared UI","scope":"System","visibility":"exported","source":"apps/internal-ui/src/shared/content-library/CategoryManagementDialog.tsx","variants":"—","documentedAt":""},{"name":"CategoryManagementRow","kind":"React component","category":"Shared UI","scope":"System","visibility":"exported","source":"apps/internal-ui/src/shared/content-library/CategoryManagementRow.tsx","variants":"—","documentedAt":""},{"name":"CategoryTree","kind":"React component","category":"Shared UI","scope":"System","visibility":"exported","source":"apps/internal-ui/src/shared/content-library/CategoryTree.tsx","variants":"—","documentedAt":""},{"name":"ChannelBadge","kind":"React component","category":"Shared UI","scope":"System","visibility":"exported","source":"apps/internal-ui/src/shared/badges.tsx","variants":"email, max, telegram, web, plain","documentedAt":"data-display.html"},{"name":"ContentEditorBreadcrumb","kind":"React component","category":"Shared UI","scope":"System","visibility":"exported","source":"apps/internal-ui/src/shared/content-library/ContentEditorBreadcrumb.tsx","variants":"—","documentedAt":""},{"name":"ContentEditorCard","kind":"React component","category":"Shared UI","scope":"System","visibility":"exported","source":"apps/internal-ui/src/shared/content-library/ContentEditorCard.tsx","variants":"—","documentedAt":""},{"name":"ContentLibraryTable","kind":"React component","category":"Shared UI","scope":"System","visibility":"exported","source":"apps/internal-ui/src/shared/content-library/ContentLibraryTable.tsx","variants":"—","documentedAt":""},{"name":"ContentLibraryToolbar","kind":"React component","category":"Shared UI","scope":"System","visibility":"exported","source":"apps/internal-ui/src/shared/content-library/ContentLibraryToolbar.tsx","variants":"—","documentedAt":""},{"name":"ContentState","kind":"React component","category":"Shared UI","scope":"System","visibility":"exported","source":"apps/internal-ui/src/shared/ui.tsx","variants":"primary, warning","documentedAt":"patterns.html"},{"name":"DecisionDialog","kind":"React component","category":"Shared UI","scope":"System","visibility":"exported","source":"apps/internal-ui/src/shared/DecisionDialog.tsx","variants":"warning, danger","documentedAt":"patterns.html"},{"name":"EmptyState","kind":"React component","category":"Shared UI","scope":"System","visibility":"exported","source":"apps/internal-ui/src/shared/ui.tsx","variants":"—","documentedAt":"patterns.html"},{"name":"ErrorScreen","kind":"React component","category":"Shared UI","scope":"System","visibility":"exported","source":"apps/internal-ui/src/shared/ui.tsx","variants":"—","documentedAt":""},{"name":"FormField","kind":"React component","category":"Shared UI","scope":"System","visibility":"exported","source":"apps/internal-ui/src/shared/form-controls.tsx","variants":"editable, readonly, disabled, invalid, mono, wide","documentedAt":"components.html"},{"name":"Icon","kind":"React component","category":"Shared UI","scope":"System","visibility":"exported","source":"apps/internal-ui/src/shared/icons.tsx","variants":"—","documentedAt":"data-display.html"},{"name":"IconButton","kind":"React component","category":"Shared UI","scope":"System","visibility":"exported","source":"apps/internal-ui/src/shared/ui-controls.tsx","variants":"bordered, bare, danger","documentedAt":"components.html"},{"name":"KeyValue","kind":"React component","category":"Shared UI","scope":"System","visibility":"exported","source":"apps/internal-ui/src/shared/form-controls.tsx","variants":"—","documentedAt":"data-display.html"},{"name":"LoadingScreen","kind":"React component","category":"Shared UI","scope":"System","visibility":"exported","source":"apps/internal-ui/src/shared/ui.tsx","variants":"—","documentedAt":""},{"name":"LoadingState","kind":"React component","category":"Shared UI","scope":"System","visibility":"exported","source":"apps/internal-ui/src/shared/ui.tsx","variants":"page, inline","documentedAt":"patterns.html"},{"name":"LogoIcon","kind":"React component","category":"Shared UI","scope":"System","visibility":"exported","source":"apps/internal-ui/src/shared/icons.tsx","variants":"—","documentedAt":""},{"name":"MaxLogo","kind":"React component","category":"Shared UI","scope":"System","visibility":"exported","source":"apps/internal-ui/src/shared/icons.tsx","variants":"—","documentedAt":""},{"name":"MetricBox","kind":"React component","category":"Shared UI","scope":"System","visibility":"exported","source":"apps/internal-ui/src/shared/form-controls.tsx","variants":"—","documentedAt":"data-display.html"},{"name":"PageHeader","kind":"React component","category":"Shared UI","scope":"System","visibility":"exported","source":"apps/internal-ui/src/shared/ui.tsx","variants":"—","documentedAt":"patterns.html"},{"name":"PasswordField","kind":"React component","category":"Shared UI","scope":"System","visibility":"exported","source":"apps/internal-ui/src/shared/ui.tsx","variants":"—","documentedAt":""},{"name":"PermissionScreen","kind":"React component","category":"Shared UI","scope":"System","visibility":"exported","source":"apps/internal-ui/src/shared/ui.tsx","variants":"—","documentedAt":""},{"name":"ProductMark","kind":"React component","category":"Shared UI","scope":"System","visibility":"exported","source":"apps/internal-ui/src/shared/ui.tsx","variants":"—","documentedAt":"data-display.html"},{"name":"ProductTag","kind":"React component","category":"Shared UI","scope":"System","visibility":"exported","source":"apps/internal-ui/src/shared/ui.tsx","variants":"foxray, default product accent","documentedAt":"data-display.html"},{"name":"ReadOnlyField","kind":"React component","category":"Shared UI","scope":"System","visibility":"exported","source":"apps/internal-ui/src/shared/ui.tsx","variants":"—","documentedAt":""},{"name":"RoleBadge","kind":"React component","category":"Shared UI","scope":"System","visibility":"exported","source":"apps/internal-ui/src/shared/ui.tsx","variants":"owner, admin, employee","documentedAt":"data-display.html"},{"name":"Rows","kind":"React component","category":"Shared UI","scope":"System","visibility":"local","source":"apps/internal-ui/src/shared/content-library/CategoryTree.tsx","variants":"—","documentedAt":""},{"name":"SearchInput","kind":"React component","category":"Shared UI","scope":"System","visibility":"exported","source":"apps/internal-ui/src/shared/ui-controls.tsx","variants":"—","documentedAt":"components.html"},{"name":"SelectField","kind":"React component","category":"Shared UI","scope":"System","visibility":"exported","source":"apps/internal-ui/src/shared/form-controls.tsx","variants":"—","documentedAt":"components.html"},{"name":"ShieldIcon","kind":"React component","category":"Shared UI","scope":"System","visibility":"exported","source":"apps/internal-ui/src/shared/icons.tsx","variants":"—","documentedAt":""},{"name":"StatusPill","kind":"React component","category":"Shared UI","scope":"System","visibility":"exported","source":"apps/internal-ui/src/shared/ui.tsx","variants":"12 status keys","documentedAt":"data-display.html"},{"name":"SwitchButton","kind":"React component","category":"Shared UI","scope":"System","visibility":"exported","source":"apps/internal-ui/src/shared/form-controls.tsx","variants":"off, on, disabled, large","documentedAt":"components.html"},{"name":"TablePagination","kind":"React component","category":"Shared UI","scope":"System","visibility":"exported","source":"apps/internal-ui/src/shared/ui-controls.tsx","variants":"default, active, disabled, ellipsis","documentedAt":"components.html"},{"name":"TelegramLogo","kind":"React component","category":"Shared UI","scope":"System","visibility":"exported","source":"apps/internal-ui/src/shared/icons.tsx","variants":"—","documentedAt":""},{"name":"TextAreaField","kind":"React component","category":"Shared UI","scope":"System","visibility":"exported","source":"apps/internal-ui/src/shared/form-controls.tsx","variants":"—","documentedAt":"components.html"},{"name":"ToneBadge","kind":"React component","category":"Shared UI","scope":"System","visibility":"exported","source":"apps/internal-ui/src/shared/ui-controls.tsx","variants":"—","documentedAt":""},{"name":"Control sizes","kind":"Token set","category":"Foundations","scope":"System","visibility":"system","source":"src/shared/*.css","variants":"24, 30, 34, 36, 38, 42×24, 46×26, 56","documentedAt":"foundations.html"},{"name":"Neutral palette","kind":"Token set","category":"Foundations","scope":"System","visibility":"system","source":"src/styles/base.css","variants":"10 neutral values","documentedAt":"foundations.html"},{"name":"Provider colors","kind":"Token set","category":"Foundations","scope":"System","visibility":"system","source":"src/shared/providers.ts","variants":"email, max, telegram, web","documentedAt":"foundations.html"},{"name":"Radius scale","kind":"Token set","category":"Foundations","scope":"System","visibility":"system","source":"src/styles/base.css","variants":"4, 6, 8, 9, 10, 12, 14, pill","documentedAt":"foundations.html"},{"name":"Semantic colors","kind":"Token set","category":"Foundations","scope":"System","visibility":"system","source":"src/styles/base.css","variants":"primary, success, warning, error, ai","documentedAt":"foundations.html"},{"name":"Shadow scale","kind":"Token set","category":"Foundations","scope":"System","visibility":"system","source":"src/styles/base.css","variants":"xs, sm, button, lg","documentedAt":"foundations.html"},{"name":"Spacing scale","kind":"Token set","category":"Foundations","scope":"System","visibility":"system","source":"src/shared/*.css","variants":"4–28 px recurring values","documentedAt":"foundations.html"},{"name":"Typography","kind":"Token set","category":"Foundations","scope":"System","visibility":"system","source":"src/styles/base.css","variants":"sans, mono, page heading, body, caption","documentedAt":"foundations.html"}]} diff --git a/design/design-system/layouts.html b/design/design-system/layouts.html new file mode 100644 index 0000000..00bc543 --- /dev/null +++ b/design/design-system/layouts.html @@ -0,0 +1,134 @@ + + + + + + Макеты страниц · CustoCRM Design System + + + + + + + + + +
+ +
+
+
Design System/Макеты страниц
+ Layouts +
+
+
+
+
Layouts
+

Компоновка экранов

+

Наглядные схемы существующих типов экранов. Они показывают состав и иерархию, сохраняя текущий shell, интервалы, поверхности и controls.

+
+
+ +
+
+

Application shell + list page

Sidebar, topbar, page header, filter bar и table card.

+ layout/* · list features +
+
+ +
+
Организация / СотрудникиРабочий контур
+
+
СотрудникиУправление доступом и ролями
+
ВсеАктивные
+
+
СотрудникРольСтатус
+
Андрей ЕрмолаевВладелецАктивен•••
+
Ирина КовалеваАдминистраторПриглашён•••
+
Максим СеровСотрудникНеактивен•••
+
Елена МироноваСотрудникАктивен•••
+
+
+
+
+
+ +
+
+

Detail page

Основная колонка, боковой rail и секции редактирования.

+ employee/channel detail features +
+
+
Entity detailheader + content + rail
+
+ +
+
+

Основные параметры

+

Назначение и политика

+
+ +
+
+
+
+ +
+
+

Settings page

Последовательность тематических секций с локальными действиями.

+ settings · profile · administration +
+
+
Settings compositionpage header + section cards
+
+ +
+

Организация

+

Безопасность

Двухфакторная аутентификация
Уведомления о входе
+
+
+
+
+ +
+
+

Conversation workspace

Список диалогов, центральный thread и контекст клиента.

+ features/conversations +
+
+ +
+
Иван Петров · Telegram
+
Здравствуйте! Подскажите, какой тариф подходит для двух агентов?
Для двух активных ИИ-агентов подойдёт тариф Startup.
+
+
+ +
+
+
+
+
+ + + + diff --git a/design/design-system/patterns.html b/design/design-system/patterns.html new file mode 100644 index 0000000..4d59f27 --- /dev/null +++ b/design/design-system/patterns.html @@ -0,0 +1,181 @@ + + + + + + Паттерны · CustoCRM Design System + + + + + + + + + +
+ +
+
+
Design System/Паттерны
+ Compositions +
+
+
+
+
Patterns
+

Композиции компонентов

+

Устоявшиеся конструкции текущего интерфейса: заголовок страницы, фильтры, таблица, состояния, меню и диалоги решений.

+
+
+ +
+
+

Заголовок страницы

Название, пояснение и основное действие справа.

+ PageHeader · layout/page.css +
+
+
PageHeader.page-header
+
+ +
+
+
+ +
+
+

Таблица со служебной панелью

Filter bar, стандартная таблица, row actions и пагинация.

+ table-controls.css · feature tables +
+
+
List table pattern.filter-bar · .baseline-table
+
+
+ +
Статус
+
+
+
+ + + + + + + + + + + + + + + + + + + + + + + + + +
СотрудникРольСтатусПродукты
АЕ
ВладелецАктивенCustoCRM
ИК
АдминистраторПриглашёнFoxRay
МС
СотрудникНеактивенFirePage
+
+ +
+
+
+
+ +
+
+

Состояния контента

Пустой раздел, предупреждение и загрузка.

+ ContentState · EmptyState · LoadingState +
+
+
+
Primary stateContentState
+
+
Данных пока нет

Создайте первый элемент, чтобы начать работу.

+
+
+
+
Warning statetone="warning"
+
+
Подключение требует внимания

Проверьте параметры интеграции.

+
+
+
+
Inline statesEmptyState · LoadingState
+
+
Загрузка…
+
Нет результатов
+
+
+
+
+ +
+
+

Выпадающее меню

Единая структура пунктов действий и семантических состояний.

+ menu.css +
+
+
App dropdown.app-dropdown
+
+ +
+
+
+ +
+
+

Decision dialogs

Предупреждающее и опасное подтверждение.

+ DecisionDialog.tsx · design-components.css +
+
+
+
Warningtone="warning"
+
+
+
+
Dangertone="danger"
+
+
+
+
+
+
+
+ +
+ +
+ +
+ +
+ + + + diff --git a/packages/ui/src/call/AudioCallView.tsx b/packages/ui/src/call/AudioCallView.tsx index d9ccbf3..14fbeb6 100644 --- a/packages/ui/src/call/AudioCallView.tsx +++ b/packages/ui/src/call/AudioCallView.tsx @@ -3,6 +3,8 @@ // glass-аватар 126px с двумя волнами edv-ring2, живая осциллограмма собеседника. // Никакой камеры/видео/PiP — это честный аудио-режим. +import { useEffect, useRef } from "react"; + import { AudioCallWaveform } from "./AudioCallWaveform"; import { AudioStatusIcon } from "./AudioCallIcons"; import { MicIcon, PhoneIcon, SpeakerIcon } from "./AudioCallIcons"; @@ -46,6 +48,7 @@ export function AudioCallView(props: Props) { return (
+
@@ -116,6 +119,17 @@ export function AudioCallView(props: Props) { ); } +function RemoteAudio({ stream, muted }: { stream?: MediaStream | null; muted: boolean }) { + const ref = useRef(null); + useEffect(() => { + const audio = ref.current; + if (!audio) return; + audio.srcObject = stream ?? null; + if (stream) void audio.play().catch(() => undefined); + }, [stream]); + return