diff --git a/apps/backend/hub_platform/calls/migrations/0005_callsession_kind.py b/apps/backend/hub_platform/calls/migrations/0005_callsession_kind.py new file mode 100644 index 0000000..e7b23ca --- /dev/null +++ b/apps/backend/hub_platform/calls/migrations/0005_callsession_kind.py @@ -0,0 +1,21 @@ +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('calls', '0004_alter_callinvite_organization_and_more'), + ] + + operations = [ + migrations.AddField( + model_name='callsession', + name='kind', + field=models.CharField( + choices=[('AUDIO', 'Аудиозвонок'), ('VIDEO', 'Видеозвонок')], + db_index=True, + default='AUDIO', + max_length=8, + ), + ), + ] diff --git a/apps/backend/hub_platform/calls/models.py b/apps/backend/hub_platform/calls/models.py index 3a190a8..74644b2 100644 --- a/apps/backend/hub_platform/calls/models.py +++ b/apps/backend/hub_platform/calls/models.py @@ -70,12 +70,18 @@ class CallConnectionType(models.TextChoices): UNKNOWN = "UNKNOWN", "Неизвестно" +class CallKind(models.TextChoices): + AUDIO = "AUDIO", "Аудиозвонок" + VIDEO = "VIDEO", "Видеозвонок" + + class CallSession(models.Model): id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False) organization = models.ForeignKey("identity.Organization", on_delete=models.PROTECT, related_name="call_sessions") conversation = models.ForeignKey("conversations.Conversation", on_delete=models.PROTECT, related_name="call_sessions") initiated_by = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.PROTECT, related_name="initiated_calls") status = models.CharField(max_length=16, choices=CallStatus.choices, default=CallStatus.REQUESTED, db_index=True) + kind = models.CharField(max_length=8, choices=CallKind.choices, default=CallKind.AUDIO, db_index=True) requested_at = models.DateTimeField(auto_now_add=True) accepted_at = models.DateTimeField(null=True, blank=True) connected_at = models.DateTimeField(null=True, blank=True) diff --git a/apps/backend/hub_platform/calls/serializers.py b/apps/backend/hub_platform/calls/serializers.py index 9730e25..a6923af 100644 --- a/apps/backend/hub_platform/calls/serializers.py +++ b/apps/backend/hub_platform/calls/serializers.py @@ -32,6 +32,7 @@ def call_payload(call: CallSession) -> dict: "id": str(call.id), "conversationId": call.conversation_id, "status": call.status, + "kind": call.kind, "initiatedByUserId": call.initiated_by_id, "deliveryConnectionId": call.delivery_connection_id, "requestedAt": _iso(call.requested_at), @@ -50,9 +51,9 @@ def public_invite_payload(call: CallSession, expires_at) -> dict: return { "callId": str(call.id), "status": call.status, + "kind": call.kind, "expiresAt": expires_at.isoformat(), "staffName": _staff_label(call), - "capabilities": {"audio": True, "video": True}, } @@ -84,6 +85,7 @@ def public_call_state_payload(call: CallSession) -> dict: return { "callId": str(call.id), "status": call.status, + "kind": call.kind, "staffName": _staff_label(call), "endedBy": call.ended_by or None, "durationSeconds": call.duration_seconds, diff --git a/apps/backend/hub_platform/calls/services.py b/apps/backend/hub_platform/calls/services.py index 2a95232..81abcc6 100644 --- a/apps/backend/hub_platform/calls/services.py +++ b/apps/backend/hub_platform/calls/services.py @@ -20,6 +20,7 @@ from hub_platform.calls.models import ( UNFINISHED_CALL_STATUSES, CallEndedBy, CallInvite, + CallKind, CallParticipant, CallSession, CallStatus, @@ -113,7 +114,9 @@ def _check_call_creation_conflicts(*, conversation: Conversation, initiator) -> @transaction.atomic -def create_call_request(*, context: TenantContext, conversation_id: int) -> CreatedCall: +def create_call_request( + *, context: TenantContext, conversation_id: int, kind: str = CallKind.AUDIO +) -> CreatedCall: initiator = context.actor_user if initiator is None or context.membership is None: raise CallAccessDenied("Для звонка требуется контекст сотрудника") @@ -140,6 +143,7 @@ def create_call_request(*, context: TenantContext, conversation_id: int) -> Crea conversation=conversation, initiated_by=initiator, delivery_connection_id=conversation.connection_id, + kind=kind, ) except IntegrityError as error: raise CallConflict("Не удалось создать второй незавершённый звонок") from error @@ -180,10 +184,11 @@ def create_call_request(*, context: TenantContext, conversation_id: int) -> Crea aggregate_id=str(call.id), ) initiator_label = getattr(initiator, "full_name", "") or initiator.email + call_word = "аудиозвонок" if call.kind == CallKind.AUDIO else "видеозвонок" Message.objects.create( conversation=conversation, author_type=MessageAuthor.SYSTEM, - text=f"Оператор {initiator_label} запросил онлайн-звонок", + text=f"Оператор {initiator_label} запросил {call_word}", ) if conversation.connection.provider == IntegrationProvider.WEB: # Web Chat: приглашение забирает виджет поллингом, внешней отправки нет. diff --git a/apps/backend/hub_platform/calls/tests/test_api.py b/apps/backend/hub_platform/calls/tests/test_api.py index 50bb924..e067704 100644 --- a/apps/backend/hub_platform/calls/tests/test_api.py +++ b/apps/backend/hub_platform/calls/tests/test_api.py @@ -70,7 +70,7 @@ class PublicInviteApiTests(CallTestCase): self.assertEqual(response["Cache-Control"], "no-store") self.assertEqual( set(payload["call"]), - {"callId", "status", "expiresAt", "staffName", "capabilities"}, + {"callId", "status", "kind", "expiresAt", "staffName"}, ) self.assertNotIn("conversation", json.dumps(payload)) self.assertNotIn(created.invite_token, json.dumps(payload)) diff --git a/apps/backend/hub_platform/calls/tests/test_flow.py b/apps/backend/hub_platform/calls/tests/test_flow.py index 402b916..6f01161 100644 --- a/apps/backend/hub_platform/calls/tests/test_flow.py +++ b/apps/backend/hub_platform/calls/tests/test_flow.py @@ -135,7 +135,7 @@ class CustomerAccessApiTests(CallTestCase): self.assertIn("iceServers", body) payload = body["call"] self.assertEqual(payload["status"], CallStatus.CANCELLED) - self.assertEqual(set(payload), {"callId", "status", "staffName", "endedBy", "durationSeconds"}) + self.assertEqual(set(payload), {"callId", "status", "kind", "staffName", "endedBy", "durationSeconds"}) def test_accept_after_cancel_is_rejected(self) -> None: token = self._customer_token() @@ -325,7 +325,7 @@ class TimelineEventTests(CallTestCase): create_call_request(conversation_id=self.conversation.id, initiator=self.owner) self.assertEqual( Message.objects.filter( - conversation=self.conversation, text__icontains="запросил онлайн-звонок" + conversation=self.conversation, text__icontains="запросил аудиозвонок" ).count(), 1, ) diff --git a/apps/backend/hub_platform/calls/views.py b/apps/backend/hub_platform/calls/views.py index 6dcd5f8..c401836 100644 --- a/apps/backend/hub_platform/calls/views.py +++ b/apps/backend/hub_platform/calls/views.py @@ -6,7 +6,7 @@ from rest_framework.views import APIView from hub_platform.api.permissions import HasEntitlement from hub_platform.calls.errors import CallAccessDenied, CallConflict, CallTokenError -from hub_platform.calls.models import CallSession +from hub_platform.calls.models import CallKind, CallSession from hub_platform.calls.permissions import ensure_call_access, ensure_conversation_call_access from hub_platform.calls.serializers import ( call_payload, @@ -48,9 +48,12 @@ 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 CallKind.values: + return Response({"detail": "Недопустимый тип звонка"}, status=400) try: created = create_call_request( - context=request.tenant_context, conversation_id=conversation_id + context=request.tenant_context, conversation_id=conversation_id, kind=kind ) except Conversation.DoesNotExist: return Response({"detail": "Диалог не найден"}, status=404) @@ -68,7 +71,7 @@ class CallCreateView(APIView): organization=call.organization, object_type="CallSession", object_id=str(call.id), - payload={"conversation_id": call.conversation_id}, + payload={"conversation_id": call.conversation_id, "kind": call.kind}, request=request, ) return _token_response( diff --git a/apps/internal-ui/src/features/conversations/AudioCallOverlay.tsx b/apps/internal-ui/src/features/conversations/AudioCallOverlay.tsx new file mode 100644 index 0000000..128eeda --- /dev/null +++ b/apps/internal-ui/src/features/conversations/AudioCallOverlay.tsx @@ -0,0 +1,143 @@ +// Аудиозвонок (оператор): antd Modal + AudioCallView (baseline «Аудиозвонок.dc.html»). +// Видео не запрашивается. Флоу: incoming/ringing → connecting → active → терминал, +// без pre-call (нет этапа проверки камеры). Параллель VideoCallOverlay, но под аудио. + +import { AudioCallView, type AudioCallMode, buildAudioStatus, useCallRtcSession, useLoopingAudio } from "@edevs/ui"; +import { Modal } from "antd"; +import { useEffect, useState } from "react"; + +import { providerMeta } from "../../shared/providers"; +import type { ApiCall, CallAccess } from "./model"; +import type { ConversationListItem } from "./types"; +import "./call.css"; + +type Props = { + open: boolean; + dialog: ConversationListItem | null; + call: ApiCall | null; + access: CallAccess | null; + errorText: string; + onCallChange: (call: ApiCall) => void; + onCancel: () => void; + onRetry: () => void; + onClose: () => void; +}; + +export function AudioCallOverlay(props: Props) { + const call = props.call; + const rtc = useCallRtcSession({ + resetKey: call?.id ?? "", + previewEnabled: false, + accessToken: props.access?.accessToken ?? "", + side: "STAFF", + iceServers: props.access?.iceServers ?? [], + videoEnabled: false, + onCallState: (state) => { + if (call) props.onCallChange({ ...call, status: state.status as ApiCall["status"], endedBy: state.endedBy ?? null, durationSeconds: state.durationSeconds ?? null }); + }, + }); + const elapsed = useElapsed(call?.connectedAt ?? null, call?.status === "ACTIVE"); + const mode = resolveAudioMode(call, props.errorText, rtc.connectionPhase, rtc.mediaIssue); + useLoopingAudio("/audio/ringtone.mp3", props.open && (mode === "incoming" || mode === "ringing"), 0.5); + + 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 onAccept = () => { void rtc.start(); }; + const onEnd = () => { rtc.end(); }; + const endAndClose = () => { + if (mode === "active" || mode === "reconnecting" || mode === "connecting") rtc.end(); + props.onClose(); + }; + const toggleSpeaker = () => { /* громкость/динамик управляется браузером; индикация переключается без реального мьюта */ }; + // retry для статус-экрана: при проблемах с устройствами/браузером — перепроверка, + // иначе — пересоздание звонка (props.onRetry) или рестарт соединения. + const onRetry = () => { + if (rtc.mediaIssue === "devices" || rtc.mediaIssue === "unsupported") void rtc.prepare(); + else if (rtc.connectionPhase === "failed") void rtc.restart(); + else props.onRetry(); + }; + + return ( + + + + ); +} + +function resolveAudioMode(call: ApiCall | null, errorText: string, connection: string, mediaIssue: string): AudioCallMode { + if (errorText || !call) return "status"; + if (mediaIssue === "devices" || mediaIssue === "unsupported") return "status"; + if (call.status === "REQUESTED" || call.status === "RINGING") return call.status === "REQUESTED" ? "ringing" : "ringing"; + if (call.status === "ACCEPTED") return connection === "connected" ? "active" : "connecting"; + if (connection === "reconnecting") return "reconnecting"; + if (connection === "failed") return "status"; + if (call.status === "ACTIVE" || connection === "connected") return "active"; + if (isTerminal(call.status)) return "status"; + return "connecting"; +} + +function isTerminal(status: string) { + return ["DECLINED", "CANCELLED", "MISSED", "ENDED", "FAILED", "EXPIRED"].includes(status); +} + +// Ключ статуса, который buildAudioStatus умеет превратить в статус-центр. +function audioStatusKey(mode: AudioCallMode, call: ApiCall | null, errorText: string, connection: string, mediaIssue: string): string { + if (errorText) return "FAILED"; + if (mediaIssue === "unsupported") return "unsupported"; + if (mediaIssue === "devices") return "nodevice"; + if (mode === "status") { + if (connection === "failed") return "FAILED"; + return call?.status ?? "ENDED"; + } + if (mode === "connecting" || mode === "reconnecting") return mode; + return call?.status ?? "ENDED"; +} + +function statusLabel(mode: AudioCallMode): string { + if (mode === "incoming") return "Входящий звонок"; + if (mode === "ringing") return "Исходящий звонок"; + if (mode === "active") return "Аудиозвонок"; + if (mode === "connecting") return "Соединение"; + if (mode === "reconnecting") return "Переподключение"; + return ""; +} + +function useElapsed(connectedAt: string | null, active: boolean) { + const [seconds, setSeconds] = useState(0); + useEffect(() => { + if (!connectedAt || !active) return; + const update = () => setSeconds(Math.max(0, Math.floor((Date.now() - new Date(connectedAt).getTime()) / 1000))); + update(); + const timer = setInterval(update, 1000); + return () => clearInterval(timer); + }, [active, connectedAt]); + return seconds; +} diff --git a/apps/internal-ui/src/features/conversations/CallOverlay.tsx b/apps/internal-ui/src/features/conversations/CallOverlay.tsx index cc16ca9..5ecce8a 100644 --- a/apps/internal-ui/src/features/conversations/CallOverlay.tsx +++ b/apps/internal-ui/src/features/conversations/CallOverlay.tsx @@ -1,20 +1,11 @@ -import { CallView, type CallViewMode, type CallViewStatus, useCallRtcSession, useLoopingAudio } from "@edevs/ui"; -import { Modal } from "antd"; -import { useEffect, useMemo, useState } from "react"; +// Диспетчер звонка по типу (call.kind): VIDEO → видеозвонок (VideoCallOverlay, +// существующий путь), AUDIO → аудиозвонок (AudioCallOverlay, baseline +// «Аудиозвонок.dc.html»). Сам по себе ничего не рендерит — composition-only. -import { providerMeta } from "../../shared/providers"; +import { AudioCallOverlay } from "./AudioCallOverlay"; +import { VideoCallOverlay } from "./VideoCallOverlay"; import type { ApiCall, CallAccess } from "./model"; import type { ConversationListItem } from "./types"; -import "./call.css"; - -const TERMINAL: Record string; tone: "neutral" | "error" | "warn"; retry?: string }> = { - DECLINED: { icon: "declined", title: "Звонок отклонён", caption: (name) => `${name} отклонил(а) вызов.`, tone: "neutral", retry: "Позвонить снова" }, - MISSED: { icon: "missed", title: "Пропущенный звонок", caption: (name) => `${name} не ответил(а) на вызов.`, tone: "warn", retry: "Перезвонить" }, - EXPIRED: { icon: "clock", title: "Время ожидания истекло", caption: () => "Никто не ответил вовремя. Попробуйте позвонить снова.", tone: "warn", retry: "Позвонить снова" }, - CANCELLED: { icon: "declined", title: "Звонок отменён", caption: () => "Приглашение отменено.", tone: "neutral" }, - FAILED: { icon: "alert", title: "Не удалось соединиться", caption: () => "Проверьте интернет-соединение и попробуйте снова.", tone: "error", retry: "Повторить" }, - ENDED: { icon: "declined", title: "Звонок завершён", caption: () => "Разговор завершён.", tone: "neutral" }, -}; type Props = { open: boolean; @@ -29,130 +20,8 @@ type Props = { }; export function CallOverlay(props: Props) { - const call = props.call; - const rtc = useCallRtcSession({ - resetKey: call?.id ?? "", - previewEnabled: props.open && call?.status === "ACCEPTED", - accessToken: props.access?.accessToken ?? "", - side: "STAFF", - iceServers: props.access?.iceServers ?? [], - onCallState: (state) => { - if (call) props.onCallChange({ ...call, status: state.status as ApiCall["status"], endedBy: state.endedBy ?? null, durationSeconds: state.durationSeconds ?? null }); - }, - }); - const elapsed = useElapsed(call?.connectedAt ?? null, call?.status === "ACTIVE"); - const mode = resolveMode(call, props.errorText, rtc.connectionPhase, rtc.mediaIssue); - useLoopingAudio("/audio/ringtone.mp3", props.open && mode === "ringing", 0.5); - const status = useMemo( - () => buildStatus(props, rtc.connectionPhase, rtc.mediaIssue, rtc.restart, rtc.prepare, rtc.start), - [props, rtc.connectionPhase, rtc.mediaIssue, rtc.restart, rtc.prepare, rtc.start], - ); - - if (!props.dialog) return null; - 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(); - props.onClose(); - }; - - return ( - - {channel.label}} - localStream={rtc.localStream} - remoteStream={rtc.remoteStream} - micOn={rtc.micOn} - camOn={rtc.camOn} - remoteMicOn={rtc.remoteMicOn} - remoteCamOn={rtc.remoteCamOn} - mediaCaption={mediaCaption} - elapsedSeconds={elapsed} - status={status} - joining={rtc.preparing || rtc.connectionPhase === "connecting"} - onToggleMic={rtc.toggleMic} - onToggleCam={rtc.toggleCam} - onJoin={rtc.start} - onCancel={mode === "ringing" ? props.onCancel : endAndClose} - onEnd={rtc.end} - onClose={endAndClose} - /> - - ); -} - -function resolveMode(call: ApiCall | null, errorText: string, connection: string, mediaIssue: string): CallViewMode { - if (errorText || !call || TERMINAL[call.status] || connection === "failed" || mediaIssue === "devices" || mediaIssue === "unsupported") return "status"; - if (call.status === "REQUESTED" || call.status === "RINGING") return "ringing"; - if (call.status === "ACCEPTED" && connection === "idle") return "precall"; - if (connection === "reconnecting") return "reconnecting"; - if (call.status === "ACTIVE" || connection === "connected") return "active"; - return "connecting"; -} - -function buildStatus(props: Props, connection: string, mediaIssue: string, onReconnect: () => void, onPrepare: () => void, onJoin: () => void): CallViewStatus | undefined { - if (props.errorText) return { icon: "alert", tone: "error", title: "Не удалось запросить звонок", caption: props.errorText }; - if (mediaIssue === "unsupported") return { icon: "unsupported", tone: "error", title: "Видеозвонки не поддерживаются", caption: "Обновите браузер или откройте ссылку в Chrome, Safari или Edge." }; - if (mediaIssue === "devices") return { - icon: "alert", - tone: "error", - title: "Нет доступа к камере и микрофону", - caption: "Разрешите доступ к устройствам в настройках браузера и повторите.", - actions: [ - { label: "Повторить проверку", kind: "primary", onClick: onPrepare }, - { label: "Без видео", kind: "secondary", onClick: onJoin }, - ], - }; - if (connection === "failed") return { - icon: "alert", - tone: "error", - title: "Не удалось соединиться", - caption: "Проверьте интернет-соединение и попробуйте снова.", - actions: [{ label: "Повторить", kind: "primary", onClick: onReconnect }], - }; - if (!props.call) return undefined; - if (props.call.status === "ACCEPTED" || props.call.status === "CONNECTING") return { icon: "spinner", tone: "neutral", title: "Соединяем звонок", caption: "Устанавливаем защищённое соединение…" }; - const terminal = TERMINAL[props.call.status]; - if (!terminal) return undefined; - const duration = props.call.status === "ENDED" && props.call.durationSeconds != null ? `Длительность ${formatDuration(props.call.durationSeconds)}. ` : ""; - return { - icon: terminal.icon, - tone: terminal.tone, - title: terminal.title, - caption: `${duration}${terminal.caption(props.dialog?.name ?? "Клиент")}`, - actions: [ - ...(terminal.retry ? [{ label: terminal.retry, kind: "primary" as const, onClick: props.onRetry }] : []), - { label: "Закрыть", kind: "secondary" as const, onClick: props.onClose }, - ], - }; -} - -function subtitleFor(mode: CallViewMode, call: ApiCall | null, status?: CallViewStatus) { - if (mode === "ringing") return call?.status === "REQUESTED" ? "Отправляем приглашение" : "Ожидание ответа"; - if (mode === "precall") return "Проверьте камеру и микрофон"; - if (mode === "active") return "Активный звонок"; - if (mode === "reconnecting") return "Переподключение"; - return status?.title ?? "Соединение"; -} - -function useElapsed(connectedAt: string | null, active: boolean) { - const [seconds, setSeconds] = useState(0); - useEffect(() => { - if (!connectedAt || !active) return; - const update = () => setSeconds(Math.max(0, Math.floor((Date.now() - new Date(connectedAt).getTime()) / 1000))); - update(); - const timer = setInterval(update, 1000); - return () => clearInterval(timer); - }, [active, connectedAt]); - return seconds; -} - -function formatDuration(seconds: number) { - return `${String(Math.floor(seconds / 60)).padStart(2, "0")}:${String(seconds % 60).padStart(2, "0")}`; + // Активный/существующий звонок возвращается из API с kind; новый запрос + // создан под выбранный тип. Дефолт — аудио (как и requestCall / модели). + if (props.call?.kind === "VIDEO") return ; + return ; } diff --git a/apps/internal-ui/src/features/conversations/ConversationThread.tsx b/apps/internal-ui/src/features/conversations/ConversationThread.tsx index 3f3c063..9c9c9ed 100644 --- a/apps/internal-ui/src/features/conversations/ConversationThread.tsx +++ b/apps/internal-ui/src/features/conversations/ConversationThread.tsx @@ -13,7 +13,7 @@ function fmtTime(value: string): string { return new Date(value).toLocaleTimeString("ru-RU", { hour: "2-digit", minute: "2-digit" }); } -export function ConversationThread({ controlMode, dialog, detail, onClaim, onCall, onClose, onSpam }: { controlMode: ControlMode; dialog: ConversationListItem | null; detail: ApiConversation | null; onClaim: () => void; onCall: () => void; onClose: () => void; onSpam: () => Promise }) { +export function ConversationThread({ controlMode, dialog, detail, onClaim, onCall, onClose, onSpam }: { controlMode: ControlMode; dialog: ConversationListItem | null; detail: ApiConversation | null; onClaim: () => void; onCall: (kind: "AUDIO" | "VIDEO") => void; onClose: () => void; onSpam: () => Promise }) { const timelineRef = useRef(null); const messages = detail?.messages ?? []; const lastMessageId = messages.length ? messages[messages.length - 1].id : 0; @@ -43,7 +43,10 @@ export function ConversationThread({ controlMode, dialog, detail, onClaim, onCal {controlMode === "waiting" && Забрать} {controlMode === "ai" && Перехватить AI} {detail?.lifecycle === "OPEN" && dialog.channel !== "EMAIL" && ( - + <> + onCall("AUDIO")}> + onCall("VIDEO")}> + > )} diff --git a/apps/internal-ui/src/features/conversations/ConversationWorkspace.tsx b/apps/internal-ui/src/features/conversations/ConversationWorkspace.tsx index 6e654e4..b1b8327 100644 --- a/apps/internal-ui/src/features/conversations/ConversationWorkspace.tsx +++ b/apps/internal-ui/src/features/conversations/ConversationWorkspace.tsx @@ -153,7 +153,7 @@ export function ConversationWorkspace({ department, listTitle, searchPlaceholder setSelectedId={setSelectedId} /> - void callController.start()} onClose={onClose} onSpam={onSpam} /> + void callController.start(kind)} onClose={onClose} onSpam={onSpam} /> {(detailError || actionError) && {detailError || actionError}} string; tone: "neutral" | "error" | "warn"; retry?: string }> = { + DECLINED: { icon: "declined", title: "Звонок отклонён", caption: (name) => `${name} отклонил(а) вызов.`, tone: "neutral", retry: "Позвонить снова" }, + MISSED: { icon: "missed", title: "Пропущенный звонок", caption: (name) => `${name} не ответил(а) на вызов.`, tone: "warn", retry: "Перезвонить" }, + EXPIRED: { icon: "clock", title: "Время ожидания истекло", caption: () => "Никто не ответил вовремя. Попробуйте позвонить снова.", tone: "warn", retry: "Позвонить снова" }, + CANCELLED: { icon: "declined", title: "Звонок отменён", caption: () => "Приглашение отменено.", tone: "neutral" }, + FAILED: { icon: "alert", title: "Не удалось соединиться", caption: () => "Проверьте интернет-соединение и попробуйте снова.", tone: "error", retry: "Повторить" }, + ENDED: { icon: "declined", title: "Звонок завершён", caption: () => "Разговор завершён.", tone: "neutral" }, +}; + +type Props = { + open: boolean; + dialog: ConversationListItem | null; + call: ApiCall | null; + access: CallAccess | null; + errorText: string; + onCallChange: (call: ApiCall) => void; + onCancel: () => void; + onRetry: () => void; + onClose: () => void; +}; + +export function VideoCallOverlay(props: Props) { + const call = props.call; + const rtc = useCallRtcSession({ + resetKey: call?.id ?? "", + previewEnabled: props.open && call?.status === "ACCEPTED", + accessToken: props.access?.accessToken ?? "", + side: "STAFF", + iceServers: props.access?.iceServers ?? [], + onCallState: (state) => { + if (call) props.onCallChange({ ...call, status: state.status as ApiCall["status"], endedBy: state.endedBy ?? null, durationSeconds: state.durationSeconds ?? null }); + }, + }); + const elapsed = useElapsed(call?.connectedAt ?? null, call?.status === "ACTIVE"); + const mode = resolveMode(call, props.errorText, rtc.connectionPhase, rtc.mediaIssue); + useLoopingAudio("/audio/ringtone.mp3", props.open && mode === "ringing", 0.5); + const status = useMemo( + () => buildStatus(props, rtc.connectionPhase, rtc.mediaIssue, rtc.restart, rtc.prepare, rtc.start), + [props, rtc.connectionPhase, rtc.mediaIssue, rtc.restart, rtc.prepare, rtc.start], + ); + + if (!props.dialog) return null; + 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(); + props.onClose(); + }; + + return ( + + {channel.label}} + localStream={rtc.localStream} + remoteStream={rtc.remoteStream} + micOn={rtc.micOn} + camOn={rtc.camOn} + remoteMicOn={rtc.remoteMicOn} + remoteCamOn={rtc.remoteCamOn} + mediaCaption={mediaCaption} + elapsedSeconds={elapsed} + status={status} + joining={rtc.preparing || rtc.connectionPhase === "connecting"} + onToggleMic={rtc.toggleMic} + onToggleCam={rtc.toggleCam} + onJoin={rtc.start} + onCancel={mode === "ringing" ? props.onCancel : endAndClose} + onEnd={rtc.end} + onClose={endAndClose} + /> + + ); +} + +function resolveMode(call: ApiCall | null, errorText: string, connection: string, mediaIssue: string): CallViewMode { + if (errorText || !call || TERMINAL[call.status] || connection === "failed" || mediaIssue === "devices" || mediaIssue === "unsupported") return "status"; + if (call.status === "REQUESTED" || call.status === "RINGING") return "ringing"; + if (call.status === "ACCEPTED" && connection === "idle") return "precall"; + if (connection === "reconnecting") return "reconnecting"; + if (call.status === "ACTIVE" || connection === "connected") return "active"; + return "connecting"; +} + +function buildStatus(props: Props, connection: string, mediaIssue: string, onReconnect: () => void, onPrepare: () => void, onJoin: () => void): CallViewStatus | undefined { + if (props.errorText) return { icon: "alert", tone: "error", title: "Не удалось запросить звонок", caption: props.errorText }; + if (mediaIssue === "unsupported") return { icon: "unsupported", tone: "error", title: "Видеозвонки не поддерживаются", caption: "Обновите браузер или откройте ссылку в Chrome, Safari или Edge." }; + if (mediaIssue === "devices") return { + icon: "alert", + tone: "error", + title: "Нет доступа к камере и микрофону", + caption: "Разрешите доступ к устройствам в настройках браузера и повторите.", + actions: [ + { label: "Повторить проверку", kind: "primary", onClick: onPrepare }, + { label: "Без видео", kind: "secondary", onClick: onJoin }, + ], + }; + if (connection === "failed") return { + icon: "alert", + tone: "error", + title: "Не удалось соединиться", + caption: "Проверьте интернет-соединение и попробуйте снова.", + actions: [{ label: "Повторить", kind: "primary", onClick: onReconnect }], + }; + if (!props.call) return undefined; + if (props.call.status === "ACCEPTED" || props.call.status === "CONNECTING") return { icon: "spinner", tone: "neutral", title: "Соединяем звонок", caption: "Устанавливаем защищённое соединение…" }; + const terminal = TERMINAL[props.call.status]; + if (!terminal) return undefined; + const duration = props.call.status === "ENDED" && props.call.durationSeconds != null ? `Длительность ${formatDuration(props.call.durationSeconds)}. ` : ""; + return { + icon: terminal.icon, + tone: terminal.tone, + title: terminal.title, + caption: `${duration}${terminal.caption(props.dialog?.name ?? "Клиент")}`, + actions: [ + ...(terminal.retry ? [{ label: terminal.retry, kind: "primary" as const, onClick: props.onRetry }] : []), + { label: "Закрыть", kind: "secondary" as const, onClick: props.onClose }, + ], + }; +} + +function subtitleFor(mode: CallViewMode, call: ApiCall | null, status?: CallViewStatus) { + if (mode === "ringing") return call?.status === "REQUESTED" ? "Отправляем приглашение" : "Ожидание ответа"; + if (mode === "precall") return "Проверьте камеру и микрофон"; + if (mode === "active") return "Активный звонок"; + if (mode === "reconnecting") return "Переподключение"; + return status?.title ?? "Соединение"; +} + +function useElapsed(connectedAt: string | null, active: boolean) { + const [seconds, setSeconds] = useState(0); + useEffect(() => { + if (!connectedAt || !active) return; + const update = () => setSeconds(Math.max(0, Math.floor((Date.now() - new Date(connectedAt).getTime()) / 1000))); + update(); + const timer = setInterval(update, 1000); + return () => clearInterval(timer); + }, [active, connectedAt]); + return seconds; +} + +function formatDuration(seconds: number) { + return `${String(Math.floor(seconds / 60)).padStart(2, "0")}:${String(seconds % 60).padStart(2, "0")}`; +} diff --git a/apps/internal-ui/src/features/conversations/model.ts b/apps/internal-ui/src/features/conversations/model.ts index d4d533f..0a6a7ba 100644 --- a/apps/internal-ui/src/features/conversations/model.ts +++ b/apps/internal-ui/src/features/conversations/model.ts @@ -137,10 +137,13 @@ export const fetchWaitingCount = () => api<{ waiting: number }>("/api/v1/convers // --- Онлайн-звонки (SPEC-HUB-0013): запрос из диалога, ожидание, отмена --- +export type CallKind = "AUDIO" | "VIDEO"; + export type ApiCall = { id: string; conversationId: number; status: "REQUESTED" | "RINGING" | "ACCEPTED" | "CONNECTING" | "ACTIVE" | "DECLINED" | "CANCELLED" | "MISSED" | "ENDED" | "FAILED" | "EXPIRED"; + kind: CallKind; requestedAt: string; acceptedAt: string | null; connectedAt: string | null; @@ -154,8 +157,8 @@ export type CallAccess = { accessToken: string; iceServers: RTCIceServer[] }; export type CreatedCall = { call: ApiCall; access: CallAccess }; // Запрос звонка: при режиме AI backend атомарно выполняет takeover (§6). -export const requestCall = (conversationId: number) => - api<{ call: ApiCall; staffAccessToken: string; iceServers: RTCIceServer[] }>(`/api/v1/calls/conversations/${conversationId}/`, { method: "POST" }) +export const requestCall = (conversationId: number, kind: CallKind = "AUDIO") => + api<{ call: ApiCall; staffAccessToken: string; iceServers: RTCIceServer[] }>(`/api/v1/calls/conversations/${conversationId}/`, { method: "POST", body: JSON.stringify({ kind }) }) .then((r): CreatedCall => ({ call: r.call, access: { accessToken: r.staffAccessToken, iceServers: r.iceServers } })); export const fetchActiveCall = (conversationId: number) => api<{ call: ApiCall | null }>(`/api/v1/calls/conversations/${conversationId}/active/`).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 b1c0d92..6a9ee0f 100644 --- a/apps/internal-ui/src/features/conversations/useConversationCall.ts +++ b/apps/internal-ui/src/features/conversations/useConversationCall.ts @@ -1,4 +1,4 @@ -import { useCallback, useEffect, useState } from "react"; +import { useCallback, useEffect, useRef, useState } from "react"; import { cancelCall, @@ -8,6 +8,7 @@ import { requestCall, type ApiCall, type CallAccess, + type CallKind, } from "./model"; const TERMINAL = new Set(["DECLINED", "CANCELLED", "MISSED", "ENDED", "FAILED", "EXPIRED"]); @@ -23,6 +24,7 @@ export function useConversationCall({ conversationId, onConversationChanged }: O const [access, setAccess] = useState(null); const [errorText, setErrorText] = useState(""); const [busy, setBusy] = useState(false); + const lastKind = useRef("AUDIO"); const reset = useCallback(() => { setOpen(false); @@ -47,8 +49,9 @@ export function useConversationCall({ conversationId, onConversationChanged }: O return next; }, []); - const start = useCallback(async () => { + const start = useCallback(async (kind: CallKind = "AUDIO") => { if (conversationId == null || busy) return; + lastKind.current = kind; setBusy(true); setErrorText(""); try { @@ -57,7 +60,7 @@ export function useConversationCall({ conversationId, onConversationChanged }: O setCall(active); await ensureAccess(active.id); } else { - const created = await requestCall(conversationId); + const created = await requestCall(conversationId, kind); setCall(created.call); setAccess(created.access); } @@ -87,7 +90,7 @@ export function useConversationCall({ conversationId, onConversationChanged }: O setAccess(null); setErrorText(""); try { - const created = await requestCall(conversationId); + const created = await requestCall(conversationId, lastKind.current); setCall(created.call); setAccess(created.access); onConversationChanged(); diff --git a/apps/internal-ui/src/shared/icons.tsx b/apps/internal-ui/src/shared/icons.tsx index d0b2a81..2f35b36 100644 --- a/apps/internal-ui/src/shared/icons.tsx +++ b/apps/internal-ui/src/shared/icons.tsx @@ -35,7 +35,7 @@ export function MaxLogo({ size = 24 }: { size?: number }) { ); } -export function Icon({ name, size = 17 }: { name: "grid" | "building" | "team" | "box" | "robot" | "plug" | "settings" | "bell" | "chevron" | "plus" | "arrow" | "paperclip" | "send" | "phone" | "lock" | "mail" | "eye" | "eyeOff" | "logout" | "user" | "refresh" | "transfer" | "warning" | "bolt" | "shop" | "search" | "more" | "external" | "key" | "pause" | "percent" | "save" | "check" | "copy" | "clock" | "message" | "cart" | "columns" | "download" | "list" | "split" | "gitBranch" | "route" | "expand" | "edit" | "trash" | "wrench" | "xCircle" | "folder" | "grip"; size?: number }) { +export function Icon({ name, size = 17 }: { name: "grid" | "building" | "team" | "box" | "robot" | "plug" | "settings" | "bell" | "chevron" | "plus" | "arrow" | "paperclip" | "send" | "phone" | "video" | "lock" | "mail" | "eye" | "eyeOff" | "logout" | "user" | "refresh" | "transfer" | "warning" | "bolt" | "shop" | "search" | "more" | "external" | "key" | "pause" | "percent" | "save" | "check" | "copy" | "clock" | "message" | "cart" | "columns" | "download" | "list" | "split" | "gitBranch" | "route" | "expand" | "edit" | "trash" | "wrench" | "xCircle" | "folder" | "grip"; size?: number }) { const common = { width: size, height: size, fill: "none", stroke: "currentColor", strokeWidth: 1.8, strokeLinecap: "round", strokeLinejoin: "round" } as const; const paths: Record = { grid: <>>, @@ -73,6 +73,8 @@ export function Icon({ name, size = 17 }: { name: "grid" | "building" | "team" | paperclip: , send: <>>, phone: , + // Tabler ti-video: видеокамера — видеозвонок. + video: <>>, refresh: <>>, transfer: <>>, warning: <>>, diff --git a/apps/web-chat/src/ChatView.tsx b/apps/web-chat/src/ChatView.tsx index 4ad7b0b..de500ff 100644 --- a/apps/web-chat/src/ChatView.tsx +++ b/apps/web-chat/src/ChatView.tsx @@ -85,7 +85,12 @@ export function ChatComposer({ accent, state, quickReplies, pendingCount, messag export function CallInviteBanner({ call, accent, onAccept, onDecline }: { call: CallInfo; accent: string; onAccept: () => void; onDecline: () => void }) { const phoneIcon = (rotated: boolean) => ; - return Входящий видеозвонок{call.staffName || "Оператор"} приглашает вас на онлайн-звонок{phoneIcon(true)}{phoneIcon(false)}; + const isVideo = call.kind !== "AUDIO"; + const inviteIcon = isVideo + ? + : ; + const title = isVideo ? "Входящий видеозвонок" : "Входящий аудиозвонок"; + return {inviteIcon}{title}{call.staffName || "Оператор"} приглашает вас на онлайн-звонок{phoneIcon(true)}{phoneIcon(false)}; } function Bubble({ author, text, accent, pendingState }: { author: "client" | "ai" | "operator"; text: string; accent: string; pendingState?: boolean }) { diff --git a/apps/web-chat/src/api.ts b/apps/web-chat/src/api.ts index 319b3b5..fdfcd53 100644 --- a/apps/web-chat/src/api.ts +++ b/apps/web-chat/src/api.ts @@ -16,9 +16,11 @@ export type WebConfig = { export type WebMessage = { id: number; author: "client" | "ai" | "operator" | "system"; kind?: string; text: string; createdAt: string }; // Приглашение/состояние онлайн-звонка (SPEC-HUB-0013). +export type CallKind = "AUDIO" | "VIDEO"; export type CallInfo = { callId: string; status: string; + kind?: CallKind; expiresAt?: string; staffName?: string; endedBy?: string | null; diff --git a/apps/web-chat/src/call/AudioCallStage.tsx b/apps/web-chat/src/call/AudioCallStage.tsx new file mode 100644 index 0000000..978943a --- /dev/null +++ b/apps/web-chat/src/call/AudioCallStage.tsx @@ -0,0 +1,125 @@ +// Аудиозвонок (клиент): audio-сцена страницы /calls/. Не делает bootstrap +// (он выполнен в CallApp) — переиспользует загруженные call/accessToken/iceServers, +// держит свой аудио-RTC (videoEnabled:false) и поллинг состояния до терминала. + +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 { audioCallStatusLabel, buildAudioCallViewStatus, isTerminalCall, resolveAudioCallViewMode } from "./model"; + +type Props = { + call: CallInfo | null; + accessToken: string; + iceServers: RTCIceServer[]; + loading: boolean; + invalid: boolean; + onCall: (call: CallInfo) => void; +}; + +export function AudioCallStage({ call, accessToken, iceServers, loading, invalid, onCall }: Props) { + const [started, setStarted] = useState(false); + const [joining, setJoining] = useState(false); + + const rtc = useCallRtcSession({ + resetKey: call?.callId ?? "", + previewEnabled: false, + accessToken, + side: "CUSTOMER", + iceServers, + videoEnabled: false, + onCallState: (state) => { if (call) onCall({ ...call, ...state }); }, + }); + + // Поллинг состояния до терминала. + useEffect(() => { + if (!accessToken || !call || isTerminalCall(call.status)) return; + const timer = setInterval(async () => { + const result = await fetchCallState(accessToken); + if (result) onCall(result.call); + }, 2000); + return () => clearInterval(timer); + }, [accessToken, call?.status, onCall]); + + useEffect(() => { + if (isTerminalCall(call?.status)) rtc.stop(); + }, [call?.status, rtc.stop]); + + const close = useCallback(() => { + if (started) rtc.end(); + if (history.length > 1) history.back(); else window.close(); + }, [rtc.end, started]); + + 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); + } + 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; + } + setStarted(true); + if (rtc.start()) rtc.end(); + }, [accessToken, call?.status, onCall, rtc]); + + const mode: AudioCallMode = resolveAudioCallViewMode({ loading, invalid, call, started, connection: rtc.connectionPhase, mediaIssue: rtc.mediaIssue }); + 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], + ); + 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 ( + + void join()} + onCancel={() => void onCancel()} + onEnd={rtc.end} + onClose={close} + /> + + ); +} + +function useConnectionTimer(active: boolean) { + const [startedAt, setStartedAt] = useState(null); + const [seconds, setSeconds] = useState(0); + useEffect(() => { + if (!active) return; + const origin = startedAt ?? Date.now(); + if (startedAt == null) setStartedAt(origin); + const update = () => setSeconds(Math.floor((Date.now() - origin) / 1000)); + update(); + const timer = setInterval(update, 1000); + return () => clearInterval(timer); + }, [active, startedAt]); + return seconds; +} diff --git a/apps/web-chat/src/call/CallApp.tsx b/apps/web-chat/src/call/CallApp.tsx index 2a39e70..47bb691 100644 --- a/apps/web-chat/src/call/CallApp.tsx +++ b/apps/web-chat/src/call/CallApp.tsx @@ -9,6 +9,7 @@ import { type CallBootstrap, type CallInfo, } from "../api"; +import { AudioCallStage } from "./AudioCallStage"; import { buildCallViewStatus, callViewSubtitle, isTerminalCall, resolveCallViewMode } from "./model"; function storageKey() { return `edevs-call:${location.pathname}`; } @@ -37,6 +38,13 @@ export function CallApp() { if (history.length > 1) history.back(); else window.close(); }, [rtc.end, started]); + // Тип звонка определяется из приглашения/состояния. Все хуки выше вызваны до + // условного return, поэтому их количество постоянно (rules of hooks). Аудио + // рендерится AudioCallStage (своим RTC); видеопуть — ниже без изменений. + if (call?.kind !== "VIDEO") { + return ; + } + const applyBootstrap = useCallback((value: CallBootstrap) => { sessionStorage.setItem(storageKey(), value.accessToken); setAccessToken(value.accessToken); diff --git a/apps/web-chat/src/call/model.ts b/apps/web-chat/src/call/model.ts index d4fba3a..fcfca92 100644 --- a/apps/web-chat/src/call/model.ts +++ b/apps/web-chat/src/call/model.ts @@ -1,4 +1,4 @@ -import type { CallViewMode, CallViewStatus } from "@edevs/ui"; +import { type AudioCallMode, type AudioCallStatus, type CallViewMode, type CallViewStatus, buildAudioStatus } from "@edevs/ui"; import type { CallInfo } from "../api"; @@ -42,6 +42,39 @@ export function callViewSubtitle(mode: CallViewMode, status?: CallViewStatus) { return status?.title ?? "Соединение"; } +// --- Аудиозвонок (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"; + if (state.mediaIssue === "devices" || state.mediaIssue === "unsupported") return "status"; + if (isTerminalCall(state.call.status) || state.connection === "failed") return "status"; + if (!state.started) return state.call.status === "RINGING" || state.call.status === "REQUESTED" ? "ringing" : "incoming"; + if (state.connection === "reconnecting") return "reconnecting"; + if (state.connection === "connected" || state.call.status === "ACTIVE") return "active"; + return "connecting"; +} + +export function buildAudioCallViewStatus(state: { loading: boolean; invalid: boolean; call: CallInfo | null; connection: string; mediaIssue: 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.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; + const key = state.call.status === "ACCEPTED" || state.call.status === "CONNECTING" ? "connecting" : state.call.status; + return buildAudioStatus(key, state.call.staffName || "Оператор", state.call.durationSeconds ?? undefined) ?? undefined; +} + +export function audioCallStatusLabel(mode: AudioCallMode, status?: AudioCallStatus): string { + if (status) return status.title; + if (mode === "incoming") return "Входящий звонок"; + if (mode === "ringing") return "Соединение"; + if (mode === "active") return "Аудиозвонок"; + if (mode === "connecting") return "Соединение"; + if (mode === "reconnecting") return "Переподключение"; + return "Звонок завершён"; +} + function formatDuration(seconds: number) { return `${String(Math.floor(seconds / 60)).padStart(2, "0")}:${String(seconds % 60).padStart(2, "0")}`; } diff --git a/compose.yaml b/compose.yaml index 74aa2f4..bff4cbd 100644 --- a/compose.yaml +++ b/compose.yaml @@ -242,7 +242,11 @@ services: - --cert=/etc/coturn/certs/fullchain.pem - --pkey=/etc/coturn/certs/privkey.pem - --min-port=${CUS_TURN_MIN_PORT:-49160} - - --max-port=${CUS_TURN_MAX_PORT:-49200} + # Relay-диапазон UDP (1 порт на media-endpoint; ~2 порта на звонок). + # 49160-49999 = 840 портов ≈ 420 одновременных TURN-звонков на хосте. + # Расширяется через CUS_TURN_MIN_PORT/CUS_TURN_MAX_PORT; все порты диапазона + # должны быть открыты на firewall и не пересекаться с ephemeral-диапазоном ОС. + - --max-port=${CUS_TURN_MAX_PORT:-49999} # Запрет анонимного и внутрисетевого relay (SPEC §11: без пересечения с хостом). - --no-multicast-peers - --no-tcp-relay diff --git a/design/baseline/# UI для онлайн звонка/.thumbnail b/design/baseline/# UI для онлайн звонка/.thumbnail new file mode 100644 index 0000000..0914090 Binary files /dev/null and b/design/baseline/# UI для онлайн звонка/.thumbnail differ diff --git a/design/baseline/# UI для онлайн звонка/_ds/edevs-hub-design-system-e4c9df12-82c8-42b3-b819-63d92dfb8702/_adherence.oxlintrc.json b/design/baseline/# UI для онлайн звонка/_ds/edevs-hub-design-system-e4c9df12-82c8-42b3-b819-63d92dfb8702/_adherence.oxlintrc.json new file mode 100644 index 0000000..8419c5c --- /dev/null +++ b/design/baseline/# UI для онлайн звонка/_ds/edevs-hub-design-system-e4c9df12-82c8-42b3-b819-63d92dfb8702/_adherence.oxlintrc.json @@ -0,0 +1,508 @@ +{ + "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": " doesn't accept that prop. Declared props: children, variant, size, disabled, onClick, type." + }, + { + "selector": "JSXOpeningElement[name.name='Button'] > JSXAttribute[name.name='variant'] > Literal[value!=/^(?:primary|secondary|ghost|danger)$/]", + "message": " variant must be one of 'primary' | 'secondary' | 'ghost' | 'danger'." + }, + { + "selector": "JSXOpeningElement[name.name='Button'] > JSXAttribute[name.name='size'] > Literal[value!=/^(?:sm|md|lg)$/]", + "message": " size must be one of 'sm' | 'md' | 'lg'." + }, + { + "selector": "JSXOpeningElement[name.name='Button'] > JSXAttribute[name.name='type'] > Literal[value!=/^(?:button|submit)$/]", + "message": " type must be one of 'button' | 'submit'." + }, + { + "selector": "JSXOpeningElement[name.name='ChannelBadge'] > JSXAttribute > JSXIdentifier[name!=/^(?:channel|title|key|ref|className|style|children)$/]", + "message": " doesn't accept that prop. Declared props: channel, title." + }, + { + "selector": "JSXOpeningElement[name.name='ChannelBadge'] > JSXAttribute[name.name='channel'] > Literal[value!=/^(?:max|telegram|webchat)$/]", + "message": " channel must be one of 'max' | 'telegram' | 'webchat'." + }, + { + "selector": "JSXOpeningElement[name.name='Checkbox'] > JSXAttribute > JSXIdentifier[name!=/^(?:checked|onChange|label|size|key|ref|className|style|children)$/]", + "message": " doesn't accept that prop. Declared props: checked, onChange, label, size." + }, + { + "selector": "JSXOpeningElement[name.name='Checkbox'] > JSXAttribute[name.name='size'] > Literal[value!=/^(?:sm|md)$/]", + "message": " size must be one of 'sm' | 'md'." + }, + { + "selector": "JSXOpeningElement[name.name='CommerceStatusTimeline'] > JSXAttribute > JSXIdentifier[name!=/^(?:steps|key|ref|className|style|children)$/]", + "message": " doesn't accept that prop. Declared props: steps." + }, + { + "selector": "JSXOpeningElement[name.name='ConversationInboxItem'] > JSXAttribute > JSXIdentifier[name!=/^(?:name|preview|channel|lastActor|initials|color|time|waiting|active|onClick|key|ref|className|style|children)$/]", + "message": " doesn't accept that prop. Declared props: name, preview, channel, lastActor, initials, color, time, waiting, active, onClick." + }, + { + "selector": "JSXOpeningElement[name.name='ConversationInboxItem'] > JSXAttribute[name.name='channel'] > Literal[value!=/^(?:max|telegram|webchat)$/]", + "message": " channel must be one of 'max' | 'telegram' | 'webchat'." + }, + { + "selector": "JSXOpeningElement[name.name='ConversationInboxItem'] > JSXAttribute[name.name='lastActor'] > Literal[value!=/^(?:ai|human)$/]", + "message": " lastActor must be one of 'ai' | 'human'." + }, + { + "selector": "JSXOpeningElement[name.name='DepartmentCard'] > JSXAttribute > JSXIdentifier[name!=/^(?:name|meta|level|summary|icon|openLabel|onOpen|children|key|ref|className|style|children)$/]", + "message": " doesn't accept that prop. Declared props: name, meta, level, summary, icon, openLabel, onOpen, children." + }, + { + "selector": "JSXOpeningElement[name.name='DepartmentCard'] > JSXAttribute[name.name='level'] > Literal[value!=/^(?:ok|attention|critical)$/]", + "message": " level must be one of 'ok' | 'attention' | 'critical'." + }, + { + "selector": "JSXOpeningElement[name.name='Icon'] > JSXAttribute > JSXIdentifier[name!=/^(?:name|size|color|strokeWidth|key|ref|className|style|children)$/]", + "message": " doesn't accept that prop. Declared props: name, size, color, strokeWidth." + }, + { + "selector": "JSXOpeningElement[name.name='Icon'] > JSXAttribute[name.name='name'] > Literal[value!=/^(?: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)$/]", + "message": " name must be one of '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'." + }, + { + "selector": "JSXOpeningElement[name.name='IconButton'] > JSXAttribute > JSXIdentifier[name!=/^(?:children|size|badge|bare|label|onClick|key|ref|className|style|children)$/]", + "message": " doesn't accept that prop. Declared props: children, size, badge, bare, label, onClick." + }, + { + "selector": "JSXOpeningElement[name.name='Input'] > JSXAttribute > JSXIdentifier[name!=/^(?:value|onChange|placeholder|label|required|type|disabled|error|hint|icon|suffix|fullWidth|key|ref|className|style|children)$/]", + "message": " doesn't accept that prop. Declared props: value, onChange, placeholder, label, required, type, disabled, error, hint, icon, suffix, fullWidth." + }, + { + "selector": "JSXOpeningElement[name.name='Input'] > JSXAttribute[name.name='type'] > Literal[value!=/^(?:text|email|password|tel)$/]", + "message": " type must be one of 'text' | 'email' | 'password' | 'tel'." + }, + { + "selector": "JSXOpeningElement[name.name='IntegrationHealthCard'] > JSXAttribute > JSXIdentifier[name!=/^(?:name|group|status|key|ref|className|style|children)$/]", + "message": " doesn't accept that prop. Declared props: name, group, status." + }, + { + "selector": "JSXOpeningElement[name.name='IntegrationHealthCard'] > JSXAttribute[name.name='status'] > Literal[value!=/^(?:connected|degraded|error)$/]", + "message": " status must be one of 'connected' | 'degraded' | 'error'." + }, + { + "selector": "JSXOpeningElement[name.name='KnowledgeDocumentEditor'] > JSXAttribute > JSXIdentifier[name!=/^(?:title|onTitleChange|docId|body|onBodyChange|rows|savedLabel|key|ref|className|style|children)$/]", + "message": " doesn't accept that prop. Declared props: title, onTitleChange, docId, body, onBodyChange, rows, savedLabel." + }, + { + "selector": "JSXOpeningElement[name.name='MetricCard'] > JSXAttribute > JSXIdentifier[name!=/^(?:label|value|valueColor|dotColor|key|ref|className|style|children)$/]", + "message": " doesn't accept that prop. Declared props: label, value, valueColor, dotColor." + }, + { + "selector": "JSXOpeningElement[name.name='ProductAIReleaseCard'] > JSXAttribute > JSXIdentifier[name!=/^(?:productName|productInitial|releaseId|live|iconBg|iconColor|stats|key|ref|className|style|children)$/]", + "message": " doesn't accept that prop. Declared props: productName, productInitial, releaseId, live, iconBg, iconColor, stats." + }, + { + "selector": "JSXOpeningElement[name.name='Tag'] > JSXAttribute > JSXIdentifier[name!=/^(?:children|tone|pill|dot|dotColor|key|ref|className|style|children)$/]", + "message": " doesn't accept that prop. Declared props: children, tone, pill, dot, dotColor." + }, + { + "selector": "JSXOpeningElement[name.name='Tag'] > JSXAttribute[name.name='tone'] > Literal[value!=/^(?:neutral|primary|success|warning|error|ai)$/]", + "message": " tone must be one of 'neutral' | 'primary' | 'success' | 'warning' | 'error' | 'ai'." + } + ] + }, + "overrides": [ + { + "files": [ + "**/index.js" + ], + "rules": { + "no-restricted-imports": "off" + } + } + ], + "x-omelette": { + "components": { + "ActorBadge": { + "replaces": [] + }, + "AttentionStatus": { + "replaces": [] + }, + "Avatar": { + "replaces": [] + }, + "Button": { + "replaces": [] + }, + "ChannelBadge": { + "replaces": [] + }, + "Checkbox": { + "replaces": [] + }, + "CommerceStatusTimeline": { + "replaces": [] + }, + "ConversationInboxItem": { + "replaces": [] + }, + "DepartmentCard": { + "replaces": [] + }, + "Icon": { + "replaces": [] + }, + "IconButton": { + "replaces": [] + }, + "Input": { + "replaces": [] + }, + "IntegrationHealthCard": { + "replaces": [] + }, + "KnowledgeDocumentEditor": { + "replaces": [] + }, + "MetricCard": { + "replaces": [] + }, + "ProductAIReleaseCard": { + "replaces": [] + }, + "Tag": { + "replaces": [] + } + }, + "tokens": [ + "--ai", + "--ai-bg", + "--ai-light", + "--ai-lighter", + "--border-default", + "--border-input", + "--border-strong", + "--channel-max", + "--channel-max-bg", + "--channel-telegram", + "--channel-telegram-bg", + "--channel-webchat", + "--channel-webchat-bg", + "--content-max", + "--duration-base", + "--duration-fast", + "--ease-standard", + "--error", + "--error-bg", + "--error-border", + "--error-text", + "--font-mono", + "--font-sans", + "--h-control", + "--h-control-2xl", + "--h-control-lg", + "--h-control-md", + "--h-control-sm", + "--h-control-xl", + "--h-topbar", + "--human", + "--info", + "--leading-loose", + "--leading-normal", + "--leading-relaxed", + "--leading-snug", + "--leading-tight", + "--n-1", + "--n-10", + "--n-11", + "--n-2", + "--n-3", + "--n-4", + "--n-5", + "--n-6", + "--n-7", + "--n-8", + "--n-9", + "--n-white", + "--primary", + "--primary-bg", + "--primary-border", + "--primary-hover", + "--primary-shadow", + "--product-firepage", + "--product-firepage-bg", + "--product-foxray", + "--product-foxray-bg", + "--radius-2xl", + "--radius-3xl", + "--radius-4xl", + "--radius-circle", + "--radius-lg", + "--radius-md", + "--radius-pill", + "--radius-sm", + "--radius-xl", + "--radius-xs", + "--shadow-lg", + "--shadow-md", + "--shadow-primary", + "--shadow-sm", + "--shadow-sticky", + "--shadow-xs", + "--sidebar-width", + "--space-1", + "--space-10", + "--space-11", + "--space-12", + "--space-13", + "--space-14", + "--space-15", + "--space-16", + "--space-17", + "--space-2", + "--space-3", + "--space-4", + "--space-5", + "--space-6", + "--space-7", + "--space-8", + "--space-9", + "--success", + "--success-bg", + "--success-bg-strong", + "--success-border", + "--success-text", + "--surface-card", + "--surface-chip", + "--surface-page", + "--surface-sunken", + "--text-2xl", + "--text-2xs", + "--text-2xs-tight", + "--text-base", + "--text-base-lg", + "--text-body", + "--text-disabled", + "--text-heading", + "--text-inverse", + "--text-lg", + "--text-link", + "--text-md", + "--text-secondary", + "--text-sm", + "--text-sm-lg", + "--text-sm-tight", + "--text-tertiary", + "--text-xl", + "--text-xs", + "--text-xs-tight", + "--tracking-tight", + "--tracking-tight-sm", + "--tracking-wide", + "--tracking-wider", + "--warning", + "--warning-bg", + "--warning-bg-strong", + "--warning-border", + "--warning-text", + "--weight-black", + "--weight-bold", + "--weight-medium", + "--weight-regular", + "--weight-semibold" + ], + "tokenKinds": { + "--n-1": "color", + "--n-2": "color", + "--n-3": "color", + "--n-4": "color", + "--n-5": "color", + "--n-6": "color", + "--n-7": "color", + "--n-8": "color", + "--n-9": "color", + "--n-10": "color", + "--n-11": "color", + "--n-white": "color", + "--primary": "color", + "--primary-hover": "color", + "--primary-bg": "color", + "--primary-border": "color", + "--primary-shadow": "shadow", + "--success": "color", + "--success-text": "font", + "--success-bg": "color", + "--success-bg-strong": "color", + "--success-border": "color", + "--warning": "color", + "--warning-text": "font", + "--warning-bg": "color", + "--warning-bg-strong": "color", + "--warning-border": "color", + "--error": "color", + "--error-text": "font", + "--error-bg": "color", + "--error-border": "color", + "--info": "color", + "--ai": "color", + "--ai-light": "color", + "--ai-lighter": "color", + "--ai-bg": "color", + "--human": "color", + "--channel-max": "color", + "--channel-max-bg": "color", + "--channel-telegram": "color", + "--channel-telegram-bg": "color", + "--channel-webchat": "color", + "--channel-webchat-bg": "color", + "--product-firepage": "color", + "--product-firepage-bg": "color", + "--product-foxray": "color", + "--product-foxray-bg": "color", + "--surface-page": "color", + "--surface-card": "color", + "--surface-sunken": "color", + "--surface-chip": "color", + "--text-heading": "font", + "--text-body": "font", + "--text-secondary": "font", + "--text-tertiary": "font", + "--text-disabled": "font", + "--text-inverse": "font", + "--text-link": "font", + "--border-default": "color", + "--border-input": "color", + "--border-strong": "color", + "--font-sans": "font", + "--font-mono": "font", + "--text-2xl": "font", + "--text-xl": "font", + "--text-lg": "font", + "--text-md": "font", + "--text-base-lg": "font", + "--text-base": "font", + "--text-sm-lg": "font", + "--text-sm": "font", + "--text-sm-tight": "font", + "--text-xs": "font", + "--text-xs-tight": "font", + "--text-2xs": "font", + "--text-2xs-tight": "font", + "--weight-regular": "font", + "--weight-medium": "font", + "--weight-semibold": "font", + "--weight-bold": "font", + "--weight-black": "font", + "--tracking-tight": "font", + "--tracking-tight-sm": "font", + "--tracking-wide": "font", + "--tracking-wider": "font", + "--leading-tight": "font", + "--leading-snug": "font", + "--leading-normal": "font", + "--leading-relaxed": "font", + "--leading-loose": "font", + "--space-1": "spacing", + "--space-2": "spacing", + "--space-3": "spacing", + "--space-4": "spacing", + "--space-5": "spacing", + "--space-6": "spacing", + "--space-7": "spacing", + "--space-8": "spacing", + "--space-9": "spacing", + "--space-10": "spacing", + "--space-11": "spacing", + "--space-12": "spacing", + "--space-13": "spacing", + "--space-14": "spacing", + "--space-15": "spacing", + "--space-16": "spacing", + "--space-17": "spacing", + "--h-control-sm": "spacing", + "--h-control-md": "spacing", + "--h-control": "spacing", + "--h-control-lg": "spacing", + "--h-control-xl": "spacing", + "--h-control-2xl": "spacing", + "--h-topbar": "spacing", + "--sidebar-width": "spacing", + "--content-max": "spacing", + "--radius-xs": "radius", + "--radius-sm": "radius", + "--radius-md": "radius", + "--radius-lg": "radius", + "--radius-xl": "radius", + "--radius-2xl": "radius", + "--radius-3xl": "radius", + "--radius-4xl": "radius", + "--radius-pill": "radius", + "--radius-circle": "radius", + "--shadow-xs": "shadow", + "--shadow-sm": "shadow", + "--shadow-md": "shadow", + "--shadow-lg": "shadow", + "--shadow-sticky": "shadow", + "--shadow-primary": "shadow", + "--ease-standard": "other", + "--duration-fast": "other", + "--duration-base": "other" + }, + "fontFamilies": [] + } +} \ No newline at end of file diff --git a/design/baseline/# UI для онлайн звонка/_ds/edevs-hub-design-system-e4c9df12-82c8-42b3-b819-63d92dfb8702/_ds_bundle.js b/design/baseline/# UI для онлайн звонка/_ds/edevs-hub-design-system-e4c9df12-82c8-42b3-b819-63d92dfb8702/_ds_bundle.js new file mode 100644 index 0000000..c815d2b --- /dev/null +++ b/design/baseline/# UI для онлайн звонка/_ds/edevs-hub-design-system-e4c9df12-82c8-42b3-b819-63d92dfb8702/_ds_bundle.js @@ -0,0 +1,3489 @@ +/* @ds-bundle: {"format":4,"namespace":"EdevsHubDesignSystem_e4c9df","components":[{"name":"CommerceStatusTimeline","sourcePath":"components/cards/CommerceStatusTimeline.jsx"},{"name":"DepartmentCard","sourcePath":"components/cards/DepartmentCard.jsx"},{"name":"IntegrationHealthCard","sourcePath":"components/cards/IntegrationHealthCard.jsx"},{"name":"MetricCard","sourcePath":"components/cards/MetricCard.jsx"},{"name":"ProductAIReleaseCard","sourcePath":"components/cards/ProductAIReleaseCard.jsx"},{"name":"Avatar","sourcePath":"components/core/Avatar.jsx"},{"name":"Button","sourcePath":"components/core/Button.jsx"},{"name":"Checkbox","sourcePath":"components/core/Checkbox.jsx"},{"name":"Icon","sourcePath":"components/core/Icon.jsx"},{"name":"ICON_NAMES","sourcePath":"components/core/Icon.jsx"},{"name":"IconButton","sourcePath":"components/core/IconButton.jsx"},{"name":"Input","sourcePath":"components/core/Input.jsx"},{"name":"Tag","sourcePath":"components/core/Tag.jsx"},{"name":"ConversationInbox","sourcePath":"components/inbox/ConversationInbox.jsx"},{"name":"KnowledgeDocumentEditor","sourcePath":"components/inbox/KnowledgeDocumentEditor.jsx"},{"name":"ActorBadge","sourcePath":"components/status/ActorBadge.jsx"},{"name":"AttentionStatus","sourcePath":"components/status/AttentionStatus.jsx"},{"name":"ChannelBadge","sourcePath":"components/status/ChannelBadge.jsx"}],"sourceHashes":{"components/cards/CommerceStatusTimeline.jsx":"a71f119a6bcb","components/cards/DepartmentCard.jsx":"743b4f9632ec","components/cards/IntegrationHealthCard.jsx":"4888c60e9532","components/cards/MetricCard.jsx":"4edb7590ee04","components/cards/ProductAIReleaseCard.jsx":"53de80d598b8","components/core/Avatar.jsx":"e963633b7162","components/core/Button.jsx":"52b57248ad92","components/core/Checkbox.jsx":"412f119cdbb2","components/core/Icon.jsx":"f6c5a199fcf5","components/core/IconButton.jsx":"e8c066b43714","components/core/Input.jsx":"4561efdc9997","components/core/Tag.jsx":"48a8a3543145","components/inbox/ConversationInbox.jsx":"ea2ad8f6a755","components/inbox/KnowledgeDocumentEditor.jsx":"f97e9057fb25","components/status/ActorBadge.jsx":"11e6274f6875","components/status/AttentionStatus.jsx":"a22cbb5b37d2","components/status/ChannelBadge.jsx":"e15a0c45d45f","ui_kits/checkout/CheckoutWidget.jsx":"6d563825a7e0","ui_kits/internal-hub/AuthLoginPage.jsx":"b3ee3d8ea9ab","ui_kits/internal-hub/ClientsPage.jsx":"0a9fb9f5b11d","ui_kits/internal-hub/CommandCenterPage.jsx":"870dd4cd0c22","ui_kits/internal-hub/HubShell.jsx":"ce0eb4d2dc70","ui_kits/web-chat/WebChatWidget.jsx":"e4099f49f9c0"},"inlinedExternals":[],"unexposedExports":[]} */ + +(() => { + +const __ds_ns = (window.EdevsHubDesignSystem_e4c9df = window.EdevsHubDesignSystem_e4c9df || {}); + +const __ds_scope = {}; + +(__ds_ns.__errors = __ds_ns.__errors || []); + +// components/cards/CommerceStatusTimeline.jsx +try { (() => { +const TONE = { + done: "#3f8f3f", + active: "var(--primary)", + pending: "var(--n-6)" +}; + +/* CommerceStatusTimeline — separates "payment confirmed" from "product + delivered" as distinct steps (SPEC-HUB-0007: payment and delivery states + must never be conflated). */ +function CommerceStatusTimeline(props) { + return React.createElement("div", { + style: { + display: "flex", + flexDirection: "column", + gap: 11 + } + }, (props.steps ?? []).map((s, i) => React.createElement("div", { + key: i, + style: { + display: "flex", + alignItems: "center", + gap: 11 + } + }, s.state === "done" ? React.createElement("div", { + style: { + width: 22, + height: 22, + borderRadius: "50%", + background: "var(--success-bg-strong)", + display: "flex", + alignItems: "center", + justifyContent: "center", + flex: "none" + } + }, React.createElement("svg", { + viewBox: "0 0 24 24", + width: 13, + height: 13, + fill: "none", + stroke: TONE.done, + strokeWidth: 2.6, + strokeLinecap: "round", + strokeLinejoin: "round" + }, React.createElement("path", { + d: "M20 6 9 17l-5-5" + }))) : s.state === "active" ? React.createElement("div", { + style: { + width: 22, + height: 22, + borderRadius: "50%", + border: `2px solid ${TONE.active}`, + display: "flex", + alignItems: "center", + justifyContent: "center", + flex: "none", + animation: "hub-pulse 1.6s ease-in-out infinite" + } + }, React.createElement("span", { + style: { + width: 7, + height: 7, + borderRadius: "50%", + background: TONE.active + } + })) : React.createElement("div", { + style: { + width: 22, + height: 22, + borderRadius: "50%", + border: `2px solid ${TONE.pending}`, + flex: "none" + } + }), React.createElement("span", { + style: { + fontSize: 12.5, + color: s.state === "active" ? "var(--text-body)" : "var(--text-secondary)", + fontWeight: s.state === "active" ? 600 : 400 + } + }, s.label)))); +} +Object.assign(__ds_scope, { CommerceStatusTimeline }); +})(); } catch (e) { __ds_ns.__errors.push({ path: "components/cards/CommerceStatusTimeline.jsx", error: String((e && e.message) || e) }); } + +// components/cards/IntegrationHealthCard.jsx +try { (() => { +const STATUS = { + connected: { + dot: "#52c41a", + label: "Подключено", + color: "var(--success-text)" + }, + degraded: { + dot: "#faad14", + label: "Деградация", + color: "var(--warning-text)" + }, + error: { + dot: "#ff4d4f", + label: "Ошибка", + color: "var(--error-text)" + } +}; + +/* IntegrationHealthCard — one row per external integration (payments, AI + provider, channels, fulfillment) inside the Command Center right rail. */ +function IntegrationHealthCard(props) { + const s = STATUS[props.status ?? "connected"]; + return React.createElement("div", { + style: { + display: "flex", + alignItems: "center", + gap: 11, + padding: "10px 18px", + borderTop: "1px solid var(--n-9)" + } + }, React.createElement("span", { + style: { + width: 8, + height: 8, + borderRadius: "50%", + background: s.dot, + flex: "none" + } + }), React.createElement("span", { + style: { + flex: 1, + minWidth: 0 + } + }, React.createElement("span", { + style: { + display: "block", + fontSize: 13, + fontWeight: 500, + color: "var(--text-body)", + lineHeight: 1.25 + } + }, props.name), React.createElement("span", { + style: { + display: "block", + fontSize: 11, + color: "var(--text-disabled)" + } + }, props.group)), React.createElement("span", { + style: { + fontSize: 12, + fontWeight: 500, + color: s.color, + flex: "none" + } + }, s.label)); +} +Object.assign(__ds_scope, { IntegrationHealthCard }); +})(); } catch (e) { __ds_ns.__errors.push({ path: "components/cards/IntegrationHealthCard.jsx", error: String((e && e.message) || e) }); } + +// components/cards/MetricCard.jsx +try { (() => { +/* MetricCard — a single KPI cell: value + label only, per ADR-HUB-0013 + ("карточки метрик должны содержать только показатель, контекст, изменение и состояние"). */ +function MetricCard(props) { + return React.createElement("div", { + style: { + background: "var(--surface-card)", + padding: "13px 15px" + } + }, React.createElement("div", { + style: { + display: "flex", + alignItems: "center", + gap: 5, + marginBottom: 7 + } + }, props.dotColor ? React.createElement("span", { + style: { + width: 7, + height: 7, + borderRadius: "50%", + background: props.dotColor + } + }) : null, React.createElement("span", { + style: { + fontSize: 12, + color: "var(--text-tertiary)" + } + }, props.label)), React.createElement("div", { + style: { + fontSize: 23, + fontWeight: 700, + color: props.valueColor ?? "var(--text-body)", + lineHeight: 1 + } + }, props.value)); +} +Object.assign(__ds_scope, { MetricCard }); +})(); } catch (e) { __ds_ns.__errors.push({ path: "components/cards/MetricCard.jsx", error: String((e && e.message) || e) }); } + +// components/core/Avatar.jsx +try { (() => { +function Avatar(props) { + const size = props.size ?? 32; + return React.createElement("div", { + style: { + width: size, + height: size, + borderRadius: "50%", + background: props.color ?? "var(--primary)", + color: "#fff", + display: "flex", + alignItems: "center", + justifyContent: "center", + fontSize: Math.round(size * 0.38), + fontWeight: 600, + flex: "none" + } + }, props.initials); +} +Object.assign(__ds_scope, { Avatar }); +})(); } catch (e) { __ds_ns.__errors.push({ path: "components/core/Avatar.jsx", error: String((e && e.message) || e) }); } + +// components/core/Button.jsx +try { (() => { +const SIZES = { + sm: { + h: 30, + padX: 12, + font: "13px" + }, + md: { + h: 36, + padX: 14, + font: "13px" + }, + lg: { + h: 44, + padX: 22, + font: "14.5px" + } +}; +function Button(props) { + const variant = props.variant ?? "primary"; + const size = props.size ?? "md"; + const disabled = !!props.disabled; + const s = SIZES[size] ?? SIZES.md; + const [hover, setHover] = React.useState(false); + const base = { + display: "inline-flex", + alignItems: "center", + justifyContent: "center", + gap: 7, + height: s.h, + padding: `0 ${s.padX}px`, + borderRadius: "var(--radius-lg)", + fontSize: s.font, + fontWeight: 600, + fontFamily: "var(--font-sans)", + cursor: disabled ? "not-allowed" : "pointer", + border: "1px solid transparent", + transition: "background var(--duration-fast), border-color var(--duration-fast), color var(--duration-fast)", + opacity: disabled ? 0.55 : 1, + whiteSpace: "nowrap" + }; + const byVariant = { + primary: { + background: hover && !disabled ? "var(--primary-hover)" : "var(--primary)", + color: "var(--text-inverse)", + boxShadow: "var(--shadow-primary)" + }, + secondary: { + background: "var(--surface-card)", + color: hover && !disabled ? "var(--primary)" : "var(--text-body)", + borderColor: hover && !disabled ? "var(--primary)" : "var(--n-6)" + }, + ghost: { + background: hover && !disabled ? "var(--n-9)" : "transparent", + color: "var(--text-body)" + }, + danger: { + background: hover && !disabled ? "#d9363e" : "var(--error)", + color: "var(--text-inverse)" + } + }; + return React.createElement("button", { + style: { + ...base, + ...byVariant[variant] + }, + disabled, + onClick: props.onClick, + onMouseEnter: () => setHover(true), + onMouseLeave: () => setHover(false), + type: props.type ?? "button" + }, props.children); +} +Object.assign(__ds_scope, { Button }); +})(); } catch (e) { __ds_ns.__errors.push({ path: "components/core/Button.jsx", error: String((e && e.message) || e) }); } + +// components/core/Checkbox.jsx +try { (() => { +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); +} +Object.assign(__ds_scope, { Checkbox }); +})(); } catch (e) { __ds_ns.__errors.push({ path: "components/core/Checkbox.jsx", error: String((e && e.message) || e) }); } + +// components/core/Icon.jsx +try { (() => { +/* 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: '' +}; +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 + } + }); +} +const ICON_NAMES = Object.keys(PATHS); +Object.assign(__ds_scope, { Icon, ICON_NAMES }); +})(); } catch (e) { __ds_ns.__errors.push({ path: "components/core/Icon.jsx", error: String((e && e.message) || e) }); } + +// components/core/IconButton.jsx +try { (() => { +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); +} +Object.assign(__ds_scope, { IconButton }); +})(); } catch (e) { __ds_ns.__errors.push({ path: "components/core/IconButton.jsx", error: String((e && e.message) || e) }); } + +// components/core/Input.jsx +try { (() => { +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); +} +Object.assign(__ds_scope, { Input }); +})(); } catch (e) { __ds_ns.__errors.push({ path: "components/core/Input.jsx", error: String((e && e.message) || e) }); } + +// components/core/Tag.jsx +try { (() => { +const TONES = { + neutral: { + bg: "var(--n-9)", + color: "var(--text-secondary)", + border: "transparent" + }, + primary: { + bg: "var(--primary-bg)", + color: "#0958d9", + border: "transparent" + }, + success: { + bg: "var(--success-bg)", + color: "var(--success-text)", + border: "var(--success-border)" + }, + warning: { + bg: "var(--warning-bg)", + color: "var(--warning-text)", + border: "var(--warning-border)" + }, + error: { + bg: "var(--error-bg)", + color: "var(--error-text)", + border: "var(--error-border)" + }, + ai: { + bg: "var(--ai-bg)", + color: "var(--ai)", + border: "transparent" + } +}; +function Tag(props) { + const t = TONES[props.tone ?? "neutral"]; + return React.createElement("span", { + style: { + display: "inline-flex", + alignItems: "center", + gap: 6, + padding: props.pill ? "3px 10px" : "2px 8px", + borderRadius: props.pill ? "var(--radius-pill)" : "var(--radius-xs)", + background: t.bg, + border: t.border !== "transparent" ? `1px solid ${t.border}` : "none", + fontSize: props.pill ? 12 : 11, + fontWeight: props.pill ? 600 : 500, + color: t.color, + whiteSpace: "nowrap" + } + }, props.dot ? React.createElement("span", { + style: { + width: 6, + height: 6, + borderRadius: "50%", + background: props.dotColor ?? t.color, + flex: "none" + } + }) : null, props.children); +} +Object.assign(__ds_scope, { Tag }); +})(); } catch (e) { __ds_ns.__errors.push({ path: "components/core/Tag.jsx", error: String((e && e.message) || e) }); } + +// components/cards/ProductAIReleaseCard.jsx +try { (() => { +/* ProductAIReleaseCard — a snapshot of one AI knowledge/prompt release for a + product (e.g. Foxray, FirePage) inside the AI section. */ +function ProductAIReleaseCard(props) { + return React.createElement("div", { + style: { + background: "var(--surface-card)", + border: "1px solid var(--n-8)", + borderRadius: "var(--radius-2xl)", + padding: "16px 18px", + boxShadow: "var(--shadow-sm)" + } + }, React.createElement("div", { + style: { + display: "flex", + alignItems: "center", + justifyContent: "space-between", + marginBottom: 10 + } + }, React.createElement("div", { + style: { + display: "flex", + alignItems: "center", + gap: 10 + } + }, React.createElement("div", { + style: { + width: 34, + height: 34, + borderRadius: 9, + background: props.iconBg ?? "var(--ai-bg)", + color: props.iconColor ?? "var(--ai)", + display: "flex", + alignItems: "center", + justifyContent: "center", + fontSize: 14, + fontWeight: 700, + flex: "none" + } + }, props.productInitial), React.createElement("div", null, React.createElement("div", { + style: { + fontSize: 14.5, + fontWeight: 700, + color: "var(--text-heading)" + } + }, props.productName), React.createElement("div", { + style: { + fontSize: 11, + color: "var(--text-disabled)", + fontFamily: "var(--font-mono)" + } + }, props.releaseId))), React.createElement(__ds_scope.Tag, { + pill: true, + tone: props.live ? "success" : "neutral", + dot: true + }, props.live ? "Активен" : "Черновик")), React.createElement("div", { + style: { + display: "flex", + gap: 1, + background: "var(--n-8)", + border: "1px solid var(--n-8)", + borderRadius: "var(--radius-lg)", + overflow: "hidden" + } + }, (props.stats ?? []).map((s, i) => React.createElement("div", { + key: i, + style: { + flex: 1, + background: "#fff", + padding: "9px 11px" + } + }, React.createElement("div", { + style: { + fontSize: 11, + color: "var(--text-tertiary)", + marginBottom: 3 + } + }, s.label), React.createElement("div", { + style: { + fontSize: 14, + fontWeight: 600, + color: "var(--text-body)" + } + }, s.value))))); +} +Object.assign(__ds_scope, { ProductAIReleaseCard }); +})(); } catch (e) { __ds_ns.__errors.push({ path: "components/cards/ProductAIReleaseCard.jsx", error: String((e && e.message) || e) }); } + +// components/inbox/KnowledgeDocumentEditor.jsx +try { (() => { +/* 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. */ +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)" + } + })); +} +Object.assign(__ds_scope, { KnowledgeDocumentEditor }); +})(); } catch (e) { __ds_ns.__errors.push({ path: "components/inbox/KnowledgeDocumentEditor.jsx", error: String((e && e.message) || e) }); } + +// components/status/ActorBadge.jsx +try { (() => { +/* ActorBadge — distinguishes an AI participant from a human operator in a + conversation timeline (ADR-HUB-0013: "ai"/"human" semantic tokens). */ +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); +} +Object.assign(__ds_scope, { ActorBadge }); +})(); } catch (e) { __ds_ns.__errors.push({ path: "components/status/ActorBadge.jsx", error: String((e && e.message) || e) }); } + +// components/status/AttentionStatus.jsx +try { (() => { +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. */ +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)); +} +Object.assign(__ds_scope, { AttentionStatus }); +})(); } catch (e) { __ds_ns.__errors.push({ path: "components/status/AttentionStatus.jsx", error: String((e && e.message) || e) }); } + +// components/cards/DepartmentCard.jsx +try { (() => { +/* DepartmentCard — the Command Center's per-department summary + (ADR-HUB-0013 local component). */ +function DepartmentCard(props) { + return React.createElement("div", { + style: { + background: "var(--surface-card)", + border: "1px solid var(--n-8)", + borderRadius: "var(--radius-2xl)", + padding: "20px 22px", + boxShadow: "var(--shadow-sm)" + } + }, React.createElement("div", { + style: { + display: "flex", + alignItems: "flex-start", + gap: 14 + } + }, React.createElement("div", { + style: { + width: 46, + height: 46, + borderRadius: 11, + background: "var(--primary-bg)", + display: "flex", + alignItems: "center", + justifyContent: "center", + flex: "none" + } + }, React.createElement(__ds_scope.Icon, { + name: props.icon ?? "departments", + size: 23, + color: "var(--primary)" + })), React.createElement("div", { + style: { + flex: 1, + minWidth: 0 + } + }, React.createElement("div", { + style: { + display: "flex", + alignItems: "center", + gap: 10 + } + }, React.createElement("h3", { + style: { + margin: 0, + fontSize: 18, + fontWeight: 700, + color: "var(--text-heading)" + } + }, props.name), React.createElement(__ds_scope.AttentionStatus, { + level: props.level + })), React.createElement("div", { + style: { + marginTop: 4, + fontSize: 12.5, + color: "var(--text-tertiary)" + } + }, props.meta)), React.createElement(__ds_scope.Button, { + variant: "primary", + onClick: props.onOpen + }, props.openLabel ?? "Открыть отдел", React.createElement(__ds_scope.Icon, { + name: "arrowRight", + size: 16 + }))), props.summary ? React.createElement("div", { + style: { + margin: "16px 0 0", + padding: "10px 14px", + background: "var(--surface-sunken)", + borderRadius: "var(--radius-md)", + fontSize: 13, + color: "var(--text-secondary)", + lineHeight: 1.5 + } + }, props.summary) : null, props.children); +} +Object.assign(__ds_scope, { DepartmentCard }); +})(); } catch (e) { __ds_ns.__errors.push({ path: "components/cards/DepartmentCard.jsx", error: String((e && e.message) || e) }); } + +// components/status/ChannelBadge.jsx +try { (() => { +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). */ +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)); +} +Object.assign(__ds_scope, { ChannelBadge }); +})(); } catch (e) { __ds_ns.__errors.push({ path: "components/status/ChannelBadge.jsx", error: String((e && e.message) || e) }); } + +// components/inbox/ConversationInbox.jsx +try { (() => { +/* ConversationInbox — the realtime inbox list (ADR-HUB-0013 local component): + one row per conversation with channel, last actor, wait state and time. */ +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(__ds_scope.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(__ds_scope.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)))); +} +Object.assign(__ds_scope, { ConversationInbox }); +})(); } catch (e) { __ds_ns.__errors.push({ path: "components/inbox/ConversationInbox.jsx", error: String((e && e.message) || e) }); } + +// ui_kits/checkout/CheckoutWidget.jsx +try { (() => { +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 /*#__PURE__*/React.createElement("div", { + style: { + display: "flex", + flexDirection: "column", + height: "100%", + width: "100%", + background: "#f4f5f7", + fontFamily: "var(--font-sans)", + color: "#1f1f1f", + overflow: "hidden" + } + }, /*#__PURE__*/React.createElement("div", { + style: { + flex: "none", + background: "#fff", + borderBottom: "1px solid #ececec", + padding: "13px 18px", + display: "flex", + alignItems: "center", + gap: 10 + } + }, /*#__PURE__*/React.createElement("div", { + style: { + width: 30, + height: 30, + borderRadius: 8, + background: "#1f1f1f", + display: "flex", + alignItems: "center", + justifyContent: "center", + color: "#fff", + fontSize: 14, + fontWeight: 800, + flex: "none" + } + }, "F"), /*#__PURE__*/React.createElement("div", { + style: { + flex: 1, + minWidth: 0 + } + }, /*#__PURE__*/React.createElement("div", { + style: { + fontSize: 14, + fontWeight: 700, + lineHeight: 1.1 + } + }, "Foxray"), /*#__PURE__*/React.createElement("div", { + style: { + fontSize: 11, + color: "#9a9a9a", + marginTop: 1 + } + }, "\u041E\u0444\u043E\u0440\u043C\u043B\u0435\u043D\u0438\u0435 \u043F\u043E\u043A\u0443\u043F\u043A\u0438")), /*#__PURE__*/React.createElement("div", { + style: { + display: "flex", + alignItems: "center", + gap: 5, + padding: "4px 9px", + borderRadius: 7, + background: "#f1f6ec", + border: "1px solid #d7ead0", + flex: "none" + } + }, /*#__PURE__*/React.createElement(Icon, { + name: "shield", + size: 12, + color: "#3f8f3f", + strokeWidth: 2.2 + }), /*#__PURE__*/React.createElement("span", { + style: { + fontSize: 10.5, + fontWeight: 600, + color: "#3f8f3f" + } + }, "\u0417\u0430\u0449\u0438\u0449\u0435\u043D\u043E")), /*#__PURE__*/React.createElement("select", { + value: st, + onChange: e => setSt(e.target.value), + style: { + marginLeft: 8, + fontSize: 10.5, + border: "1px solid #ececec", + borderRadius: 6, + background: "#fafafa" + } + }, ["checkout", "preparing", "pending", "paid", "delivering", "done", "error", "expired"].map(s => /*#__PURE__*/React.createElement("option", { + key: s, + value: s + }, s)))), /*#__PURE__*/React.createElement("div", { + style: { + flex: 1, + minHeight: 0, + overflowY: "auto" + } + }, st === "expired" && /*#__PURE__*/React.createElement(StateScreen, { + icon: "alertCircle", + tone: "#8c8c8c", + title: "\u0421\u0441\u044B\u043B\u043A\u0430 \u0431\u043E\u043B\u044C\u0448\u0435 \u043D\u0435 \u0434\u0435\u0439\u0441\u0442\u0432\u0443\u0435\u0442", + body: "\u0421\u0435\u0441\u0441\u0438\u044F \u043E\u0444\u043E\u0440\u043C\u043B\u0435\u043D\u0438\u044F \u0438\u0441\u0442\u0435\u043A\u043B\u0430 \u0438\u043B\u0438 \u0431\u044B\u043B\u0430 \u043E\u0442\u043C\u0435\u043D\u0435\u043D\u0430. \u0412\u0435\u0440\u043D\u0438\u0442\u0435\u0441\u044C \u043D\u0430 \u0441\u0430\u0439\u0442 \u043F\u0440\u043E\u0434\u0443\u043A\u0442\u0430.", + cta: "\u0412\u0435\u0440\u043D\u0443\u0442\u044C\u0441\u044F \u043D\u0430 foxray.pro" + }), st === "preparing" && /*#__PURE__*/React.createElement(StateScreen, { + spin: true, + title: "\u0413\u043E\u0442\u043E\u0432\u0438\u043C \u043E\u043F\u043B\u0430\u0442\u0443", + body: "\u041F\u0435\u0440\u0435\u043D\u0430\u043F\u0440\u0430\u0432\u043B\u044F\u0435\u043C \u043D\u0430 \u0437\u0430\u0449\u0438\u0449\u0451\u043D\u043D\u0443\u044E \u0441\u0442\u0440\u0430\u043D\u0438\u0446\u0443 \u0411\u0430\u043D\u043A\u0430 \u0422\u043E\u0447\u043A\u0430. \u041D\u0435 \u0437\u0430\u043A\u0440\u044B\u0432\u0430\u0439\u0442\u0435 \u043E\u043A\u043D\u043E." + }), st === "pending" && /*#__PURE__*/React.createElement(StateScreen, { + icon: "clock", + tone: "var(--warning-text)", + toneBg: "var(--warning-bg-strong)", + title: "\u041F\u0440\u043E\u0432\u0435\u0440\u044F\u0435\u043C \u043E\u043F\u043B\u0430\u0442\u0443", + body: "\u0411\u0430\u043D\u043A \u0435\u0449\u0451 \u043F\u043E\u0434\u0442\u0432\u0435\u0440\u0436\u0434\u0430\u0435\u0442 \u043E\u043F\u0435\u0440\u0430\u0446\u0438\u044E. \u041C\u044B \u043D\u0435 \u0441\u043E\u0437\u0434\u0430\u0451\u043C \u043D\u043E\u0432\u044B\u0439 \u0437\u0430\u043A\u0430\u0437 \u0430\u0432\u0442\u043E\u043C\u0430\u0442\u0438\u0447\u0435\u0441\u043A\u0438.", + cta: "\u041F\u0440\u043E\u0432\u0435\u0440\u0438\u0442\u044C \u0435\u0449\u0451 \u0440\u0430\u0437" + }), st === "error" && /*#__PURE__*/React.createElement(StateScreen, { + icon: "xCircle", + tone: "var(--error-text)", + toneBg: "var(--error-bg)", + title: "\u041F\u043B\u0430\u0442\u0451\u0436 \u043D\u0435 \u043F\u0440\u043E\u0448\u0451\u043B", + body: "\u0411\u0430\u043D\u043A \u043E\u0442\u043A\u043B\u043E\u043D\u0438\u043B \u043E\u043F\u0435\u0440\u0430\u0446\u0438\u044E. \u0414\u0435\u043D\u044C\u0433\u0438 \u043D\u0435 \u0441\u043F\u0438\u0441\u0430\u043D\u044B. \u041C\u043E\u0436\u043D\u043E \u043F\u043E\u0432\u0442\u043E\u0440\u0438\u0442\u044C \u043E\u043F\u043B\u0430\u0442\u0443 \u0442\u043E\u0433\u043E \u0436\u0435 \u0437\u0430\u043A\u0430\u0437\u0430.", + cta: "\u041F\u043E\u0432\u0442\u043E\u0440\u0438\u0442\u044C \u043E\u043F\u043B\u0430\u0442\u0443" + }), st === "paid" && /*#__PURE__*/React.createElement("div", { + style: { + padding: "26px 16px 20px" + } + }, /*#__PURE__*/React.createElement("div", { + style: { + display: "flex", + flexDirection: "column", + alignItems: "center", + textAlign: "center", + marginBottom: 18 + } + }, /*#__PURE__*/React.createElement("div", { + style: { + width: 56, + height: 56, + borderRadius: "50%", + background: "var(--success-bg-strong)", + display: "flex", + alignItems: "center", + justifyContent: "center", + marginBottom: 14 + } + }, /*#__PURE__*/React.createElement(Icon, { + name: "check", + size: 28, + color: "#3f8f3f", + strokeWidth: 2.2 + })), /*#__PURE__*/React.createElement("h3", { + style: { + margin: 0, + fontSize: 17, + fontWeight: 700 + } + }, "\u041E\u043F\u043B\u0430\u0442\u0430 \u043F\u043E\u043B\u0443\u0447\u0435\u043D\u0430"), /*#__PURE__*/React.createElement("p", { + style: { + margin: "7px 0 0", + fontSize: 13, + color: "#6b6b6b" + } + }, "\u0427\u0435\u043A \u043E\u0442\u043F\u0440\u0430\u0432\u043B\u0435\u043D \u043D\u0430 ", /*#__PURE__*/React.createElement("b", { + style: { + color: "#262626" + } + }, "a.kovalenko@northwind.ru"))), /*#__PURE__*/React.createElement("div", { + style: { + background: "#fff", + border: "1px solid #ececec", + borderRadius: 13, + padding: "14px 16px", + display: "flex", + flexDirection: "column", + gap: 9 + } + }, /*#__PURE__*/React.createElement(SummaryRow, { + k: "\u0417\u0430\u043A\u0430\u0437", + v: "HUB-2026-004187", + mono: true + }), /*#__PURE__*/React.createElement(SummaryRow, { + k: "\u041F\u0440\u043E\u0434\u0443\u043A\u0442", + v: "Foxray Max \xB7 6 \u043C\u0435\u0441\u0442" + }), /*#__PURE__*/React.createElement(SummaryRow, { + k: "\u0421\u0443\u043C\u043C\u0430", + v: "\u20BD95 040", + bold: true + }))), st === "delivering" && /*#__PURE__*/React.createElement("div", { + style: { + padding: "26px 16px 20px" + } + }, /*#__PURE__*/React.createElement("div", { + style: { + display: "flex", + flexDirection: "column", + alignItems: "center", + textAlign: "center", + marginBottom: 16 + } + }, /*#__PURE__*/React.createElement("div", { + style: { + width: 56, + height: 56, + borderRadius: "50%", + background: "var(--primary-bg)", + display: "flex", + alignItems: "center", + justifyContent: "center", + marginBottom: 14 + } + }, /*#__PURE__*/React.createElement(Icon, { + name: "package2", + size: 27, + color: "var(--primary)" + })), /*#__PURE__*/React.createElement("h3", { + style: { + margin: 0, + fontSize: 16, + fontWeight: 700 + } + }, "\u0413\u043E\u0442\u043E\u0432\u0438\u043C \u0434\u043E\u0441\u0442\u0443\u043F \u043A \u043F\u0440\u043E\u0434\u0443\u043A\u0442\u0443"), /*#__PURE__*/React.createElement("p", { + style: { + margin: "8px 0 0", + fontSize: 13, + color: "#6b6b6b" + } + }, "\u041E\u043F\u043B\u0430\u0442\u0430 \u043F\u0440\u043E\u0448\u043B\u0430. \u041F\u043B\u0430\u0442\u0438\u0442\u044C \u043F\u043E\u0432\u0442\u043E\u0440\u043D\u043E \u043D\u0435 \u043D\u0443\u0436\u043D\u043E.")), /*#__PURE__*/React.createElement("div", { + style: { + background: "#fff", + border: "1px solid #ececec", + borderRadius: 13, + padding: "14px 16px" + } + }, /*#__PURE__*/React.createElement(CommerceStatusTimeline, { + steps: [{ + label: "Оплата подтверждена · ₽95 040", + state: "done" + }, { + label: "Подготовка рабочих мест", + state: "active" + }] + }))), st === "done" && /*#__PURE__*/React.createElement("div", { + style: { + padding: "26px 16px 20px" + } + }, /*#__PURE__*/React.createElement("div", { + style: { + display: "flex", + flexDirection: "column", + alignItems: "center", + textAlign: "center", + marginBottom: 18 + } + }, /*#__PURE__*/React.createElement("div", { + style: { + width: 56, + height: 56, + borderRadius: "50%", + background: "var(--success-bg-strong)", + display: "flex", + alignItems: "center", + justifyContent: "center", + marginBottom: 14 + } + }, /*#__PURE__*/React.createElement(Icon, { + name: "checkCircle", + size: 28, + color: "#3f8f3f", + strokeWidth: 2.2 + })), /*#__PURE__*/React.createElement("h3", { + style: { + margin: 0, + fontSize: 17, + fontWeight: 700 + } + }, "\u0413\u043E\u0442\u043E\u0432\u043E \u2014 \u0434\u043E\u0441\u0442\u0443\u043F \u043E\u0442\u043A\u0440\u044B\u0442"), /*#__PURE__*/React.createElement("p", { + style: { + margin: "7px 0 0", + fontSize: 13, + color: "#6b6b6b" + } + }, "Foxray Max \u0430\u043A\u0442\u0438\u0432\u0438\u0440\u043E\u0432\u0430\u043D \u043D\u0430 6 \u043C\u0435\u0441\u0442.")), /*#__PURE__*/React.createElement("a", { + href: "#", + style: { + display: "flex", + alignItems: "center", + justifyContent: "center", + gap: 8, + height: 46, + borderRadius: 10, + background: "var(--primary)", + color: "#fff", + fontSize: 14, + fontWeight: 600, + textDecoration: "none" + } + }, "\u041F\u0435\u0440\u0435\u0439\u0442\u0438 \u0432 Foxray", /*#__PURE__*/React.createElement(Icon, { + name: "arrowRight", + size: 16 + }))), st === "checkout" && /*#__PURE__*/React.createElement("div", { + style: { + padding: "16px 16px 24px", + maxWidth: 520, + margin: "0 auto" + } + }, /*#__PURE__*/React.createElement("div", { + style: { + marginBottom: 14 + } + }, /*#__PURE__*/React.createElement("div", { + style: { + fontSize: 11, + fontWeight: 600, + letterSpacing: "0.06em", + color: "#9a9a9a", + textTransform: "uppercase", + marginBottom: 6 + } + }, "\u041E\u0444\u043E\u0440\u043C\u043B\u0435\u043D\u0438\u0435 \u043F\u043E\u0434\u043F\u0438\u0441\u043A\u0438"), /*#__PURE__*/React.createElement("div", { + style: { + fontSize: 19, + fontWeight: 800, + letterSpacing: "-0.01em" + } + }, "Foxray Max"), /*#__PURE__*/React.createElement("div", { + style: { + fontSize: 13, + color: "#6b6b6b", + marginTop: 3 + } + }, "\u0413\u043E\u0434\u043E\u0432\u0430\u044F \u043F\u043E\u0434\u043F\u0438\u0441\u043A\u0430 \xB7 6 \u0440\u0430\u0431\u043E\u0447\u0438\u0445 \u043C\u0435\u0441\u0442")), /*#__PURE__*/React.createElement("div", { + style: { + background: "#fff", + border: "1px solid #ececec", + borderRadius: 13, + overflow: "hidden", + marginBottom: 14 + } + }, /*#__PURE__*/React.createElement("div", { + style: { + padding: "14px 16px 4px" + } + }, /*#__PURE__*/React.createElement("div", { + style: { + display: "flex", + justifyContent: "space-between", + gap: 12, + marginBottom: 10 + } + }, /*#__PURE__*/React.createElement("div", null, /*#__PURE__*/React.createElement("div", { + style: { + fontSize: 13, + fontWeight: 600 + } + }, "Foxray Max"), /*#__PURE__*/React.createElement("div", { + style: { + fontSize: 11.5, + color: "#9a9a9a", + marginTop: 2 + } + }, "6 \u043C\u0435\u0441\u0442 \xD7 \u20BD1 320 / \u043C\u0435\u0441 \xB7 12 \u043C\u0435\u0441")), /*#__PURE__*/React.createElement("div", { + style: { + fontSize: 13, + fontWeight: 600, + whiteSpace: "nowrap" + } + }, "\u20BD118 800")), /*#__PURE__*/React.createElement("div", { + style: { + display: "flex", + justifyContent: "space-between", + marginBottom: 10 + } + }, /*#__PURE__*/React.createElement("span", { + style: { + fontSize: 12.5, + color: "#3f8f3f" + } + }, "\u0421\u043A\u0438\u0434\u043A\u0430 \u0437\u0430 \u0433\u043E\u0434\u043E\u0432\u0443\u044E \u043E\u043F\u043B\u0430\u0442\u0443 \xB7 \u221220%"), /*#__PURE__*/React.createElement("span", { + style: { + fontSize: 12.5, + fontWeight: 600, + color: "#3f8f3f" + } + }, "\u2212\u20BD23 760"))), /*#__PURE__*/React.createElement("div", { + style: { + borderTop: "1px dashed #e8e8e8", + padding: "12px 16px", + display: "flex", + justifyContent: "space-between", + alignItems: "flex-end" + } + }, /*#__PURE__*/React.createElement("div", null, /*#__PURE__*/React.createElement("div", { + style: { + fontSize: 13, + fontWeight: 700 + } + }, "\u041F\u0435\u0440\u0432\u044B\u0439 \u043F\u043B\u0430\u0442\u0451\u0436"), /*#__PURE__*/React.createElement("div", { + style: { + fontSize: 11, + color: "#9a9a9a", + marginTop: 2 + } + }, "\u0411\u0435\u0437 \u041D\u0414\u0421 \xB7 \u0432\u0430\u043B\u044E\u0442\u0430 \u20BD (RUB)")), /*#__PURE__*/React.createElement("div", { + style: { + fontSize: 22, + fontWeight: 800 + } + }, "\u20BD95 040"))), /*#__PURE__*/React.createElement("div", { + style: { + fontSize: 12.5, + fontWeight: 700, + margin: "0 2px 9px" + } + }, "\u041F\u043E\u043A\u0443\u043F\u0430\u0442\u0435\u043B\u044C"), /*#__PURE__*/React.createElement("div", { + style: { + display: "flex", + gap: 6, + background: "#fff", + border: "1px solid #ececec", + borderRadius: 11, + padding: 4, + marginBottom: 12 + } + }, /*#__PURE__*/React.createElement("button", { + style: seg("person"), + onClick: () => setBuyer("person") + }, "\u0424\u0438\u0437\u043B\u0438\u0446\u043E"), /*#__PURE__*/React.createElement("button", { + style: seg("ip"), + onClick: () => setBuyer("ip") + }, "\u0418\u041F"), /*#__PURE__*/React.createElement("button", { + style: seg("company"), + onClick: () => setBuyer("company") + }, "\u042E\u0440\u043B\u0438\u0446\u043E")), /*#__PURE__*/React.createElement("div", { + style: { + fontSize: 12.5, + fontWeight: 700, + margin: "0 2px 9px" + } + }, "\u0421\u043F\u043E\u0441\u043E\u0431 \u043E\u043F\u043B\u0430\u0442\u044B"), /*#__PURE__*/React.createElement("div", { + style: { + display: "flex", + flexDirection: "column", + gap: 9, + marginBottom: 14 + } + }, /*#__PURE__*/React.createElement("button", { + style: cardBox(pay === "card"), + onClick: () => setPay("card") + }, /*#__PURE__*/React.createElement("div", { + style: { + width: 38, + height: 38, + borderRadius: 9, + background: "var(--primary-bg)", + display: "flex", + alignItems: "center", + justifyContent: "center", + flex: "none" + } + }, /*#__PURE__*/React.createElement(Icon, { + name: "card", + size: 19, + color: "var(--primary)" + })), /*#__PURE__*/React.createElement("div", { + style: { + flex: 1, + textAlign: "left" + } + }, /*#__PURE__*/React.createElement("div", { + style: { + fontSize: 13.5, + fontWeight: 600 + } + }, "\u0411\u0430\u043D\u043A\u043E\u0432\u0441\u043A\u0430\u044F \u043A\u0430\u0440\u0442\u0430"), /*#__PURE__*/React.createElement("div", { + style: { + fontSize: 11.5, + color: "#9a9a9a" + } + }, "\u0410\u0432\u0442\u043E\u043F\u0440\u043E\u0434\u043B\u0435\u043D\u0438\u0435 \u043F\u043E\u0434\u043F\u0438\u0441\u043A\u0438")), /*#__PURE__*/React.createElement("span", { + style: dot(pay === "card") + })), /*#__PURE__*/React.createElement("button", { + style: cardBox(pay === "sbp"), + onClick: () => setPay("sbp") + }, /*#__PURE__*/React.createElement("div", { + style: { + width: 38, + height: 38, + borderRadius: 9, + background: "var(--ai-bg)", + display: "flex", + alignItems: "center", + justifyContent: "center", + flex: "none" + } + }, /*#__PURE__*/React.createElement(Icon, { + name: "sbp", + size: 19, + color: "var(--ai)" + })), /*#__PURE__*/React.createElement("div", { + style: { + flex: 1, + textAlign: "left" + } + }, /*#__PURE__*/React.createElement("div", { + style: { + fontSize: 13.5, + fontWeight: 600 + } + }, "\u0421\u0411\u041F"), /*#__PURE__*/React.createElement("div", { + style: { + fontSize: 11.5, + color: "#9a9a9a" + } + }, "\u0421\u0438\u0441\u0442\u0435\u043C\u0430 \u0431\u044B\u0441\u0442\u0440\u044B\u0445 \u043F\u043B\u0430\u0442\u0435\u0436\u0435\u0439")), /*#__PURE__*/React.createElement("span", { + style: dot(pay === "sbp") + }))), /*#__PURE__*/React.createElement("div", { + style: { + background: "#fff", + border: "1px solid #ececec", + borderRadius: 13, + padding: "14px 16px", + display: "flex", + flexDirection: "column", + gap: 13, + marginBottom: 90 + } + }, /*#__PURE__*/React.createElement(Checkbox, { + checked: a1, + onChange: () => setA1(!a1), + label: /*#__PURE__*/React.createElement(React.Fragment, null, "\u041F\u0440\u0438\u043D\u0438\u043C\u0430\u044E \u0443\u0441\u043B\u043E\u0432\u0438\u044F \u043F\u043E\u043A\u0443\u043F\u043A\u0438 \u0438 \u043E\u0444\u0435\u0440\u0442\u0443, \u0441\u043E\u0433\u043B\u0430\u0441\u0435\u043D \u0441 \u043F\u043E\u043B\u0438\u0442\u0438\u043A\u043E\u0439 \u043E\u0431\u0440\u0430\u0431\u043E\u0442\u043A\u0438 \u0434\u0430\u043D\u043D\u044B\u0445") + }), /*#__PURE__*/React.createElement(Checkbox, { + checked: a2, + onChange: () => setA2(!a2), + label: /*#__PURE__*/React.createElement(React.Fragment, null, "\u0421\u043E\u0433\u043B\u0430\u0441\u0435\u043D \u043D\u0430 \u0440\u0435\u0433\u0443\u043B\u044F\u0440\u043D\u044B\u0435 \u0441\u043F\u0438\u0441\u0430\u043D\u0438\u044F ", /*#__PURE__*/React.createElement("b", null, "\u20BD95 040 / \u0433\u043E\u0434"), " \u0441 \u0430\u0432\u0442\u043E\u043F\u0440\u043E\u0434\u043B\u0435\u043D\u0438\u0435\u043C \u0434\u043E \u043E\u0442\u043C\u0435\u043D\u044B") + })))), st === "checkout" && /*#__PURE__*/React.createElement("div", { + style: { + flex: "none", + background: "#fff", + borderTop: "1px solid #ececec", + padding: "11px 16px 14px", + boxShadow: "var(--shadow-sticky)" + } + }, /*#__PURE__*/React.createElement("div", { + style: { + display: "flex", + justifyContent: "space-between", + marginBottom: 9 + } + }, /*#__PURE__*/React.createElement("span", { + style: { + fontSize: 12.5, + color: "#8c8c8c" + } + }, "\u041A \u043E\u043F\u043B\u0430\u0442\u0435 \u0441\u0435\u0433\u043E\u0434\u043D\u044F"), /*#__PURE__*/React.createElement("span", { + style: { + fontSize: 17, + fontWeight: 800 + } + }, "\u20BD95 040")), /*#__PURE__*/React.createElement("button", { + disabled: !ready, + style: { + display: "flex", + alignItems: "center", + justifyContent: "center", + gap: 8, + width: "100%", + height: 48, + borderRadius: 11, + border: "none", + fontSize: 14.5, + fontWeight: 700, + cursor: ready ? "pointer" : "not-allowed", + background: ready ? "var(--primary)" : "#e6e6e6", + color: ready ? "#fff" : "#bfbfbf" + } + }, ready ? "Перейти к оплате" : "Примите условия выше", /*#__PURE__*/React.createElement(Icon, { + name: "arrowRight", + size: 16 + })))); +} +function StateScreen({ + icon, + tone = "var(--primary)", + toneBg = "#f4f5f7", + title, + body, + cta, + spin +}) { + return /*#__PURE__*/React.createElement("div", { + style: { + height: "100%", + display: "flex", + flexDirection: "column", + alignItems: "center", + justifyContent: "center", + textAlign: "center", + padding: "40px 28px" + } + }, spin ? /*#__PURE__*/React.createElement("div", { + style: { + width: 38, + height: 38, + borderRadius: "50%", + border: "3px solid #e6e6e6", + borderTopColor: "var(--primary)", + animation: "hub-spin .8s linear infinite", + marginBottom: 20 + } + }) : /*#__PURE__*/React.createElement("div", { + style: { + width: 54, + height: 54, + borderRadius: "50%", + background: toneBg, + display: "flex", + alignItems: "center", + justifyContent: "center", + marginBottom: 18 + } + }, /*#__PURE__*/React.createElement(Icon, { + name: icon, + size: 26, + color: tone + })), /*#__PURE__*/React.createElement("h3", { + style: { + margin: 0, + fontSize: 16, + fontWeight: 700 + } + }, title), /*#__PURE__*/React.createElement("p", { + style: { + margin: "9px 0 0", + fontSize: 13, + color: "#6b6b6b", + lineHeight: 1.55, + maxWidth: 300 + } + }, body), cta && /*#__PURE__*/React.createElement("button", { + style: { + display: "inline-flex", + alignItems: "center", + justifyContent: "center", + gap: 8, + height: 44, + padding: "0 22px", + marginTop: 22, + borderRadius: 10, + background: "var(--primary)", + color: "#fff", + fontSize: 13.5, + fontWeight: 600, + border: "none", + cursor: "pointer" + } + }, cta)); +} +function SummaryRow({ + k, + v, + mono, + bold +}) { + return /*#__PURE__*/React.createElement("div", { + style: { + display: "flex", + justifyContent: "space-between", + alignItems: "center" + } + }, /*#__PURE__*/React.createElement("span", { + style: { + fontSize: 12.5, + color: "#8c8c8c" + } + }, k), /*#__PURE__*/React.createElement("span", { + style: { + fontSize: bold ? 14 : 12.5, + fontWeight: 600, + color: "#262626", + fontFamily: mono ? "var(--font-mono)" : "inherit" + } + }, v)); +} +})(); } catch (e) { __ds_ns.__errors.push({ path: "ui_kits/checkout/CheckoutWidget.jsx", error: String((e && e.message) || e) }); } + +// ui_kits/internal-hub/AuthLoginPage.jsx +try { (() => { +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 /*#__PURE__*/React.createElement("div", { + style: { + minHeight: "100%", + minWidth: 1024, + display: "flex", + alignItems: "center", + justifyContent: "center", + background: "var(--surface-page)", + fontFamily: "var(--font-sans)", + padding: 24 + } + }, /*#__PURE__*/React.createElement("div", { + style: { + width: 400, + maxWidth: "100%" + } + }, /*#__PURE__*/React.createElement("div", { + style: { + display: "flex", + flexDirection: "column", + alignItems: "center", + marginBottom: 24 + } + }, /*#__PURE__*/React.createElement("img", { + src: "../../assets/logo-mark.svg", + style: { + width: 48, + height: 48, + borderRadius: 13, + marginBottom: 14, + boxShadow: "0 4px 12px var(--primary-shadow)" + } + }), /*#__PURE__*/React.createElement("h1", { + style: { + margin: 0, + fontSize: 20, + fontWeight: 700, + letterSpacing: "-0.01em" + } + }, "Edevs Hub"), /*#__PURE__*/React.createElement("p", { + style: { + margin: "5px 0 0", + fontSize: 13, + color: "var(--text-tertiary)" + } + }, "\u0412\u0445\u043E\u0434 \u0432\u043E \u0432\u043D\u0443\u0442\u0440\u0435\u043D\u043D\u0438\u0439 \u043A\u0430\u0431\u0438\u043D\u0435\u0442")), /*#__PURE__*/React.createElement("div", { + style: { + background: "#fff", + border: "1px solid var(--n-8)", + borderRadius: 14, + boxShadow: "var(--shadow-md)", + padding: "26px 26px 24px" + } + }, error && /*#__PURE__*/React.createElement("div", { + style: { + display: "flex", + gap: 10, + padding: "11px 13px", + background: "var(--error-bg)", + border: "1px solid var(--error-border)", + borderRadius: 9, + marginBottom: 18 + } + }, /*#__PURE__*/React.createElement(Icon, { + name: "alertCircle", + size: 16, + color: "var(--error-text)" + }), /*#__PURE__*/React.createElement("span", { + style: { + fontSize: 12.5, + color: "var(--error-text)", + lineHeight: 1.45 + } + }, "\u041D\u0435\u0432\u0435\u0440\u043D\u044B\u0439 email \u0438\u043B\u0438 \u043F\u0430\u0440\u043E\u043B\u044C. \u041F\u0440\u043E\u0432\u0435\u0440\u044C\u0442\u0435 \u0434\u0430\u043D\u043D\u044B\u0435 \u0438 \u043F\u043E\u043F\u0440\u043E\u0431\u0443\u0439\u0442\u0435 \u0441\u043D\u043E\u0432\u0430.")), /*#__PURE__*/React.createElement("div", { + style: { + marginBottom: 16 + } + }, /*#__PURE__*/React.createElement(Input, { + label: "Email", + icon: /*#__PURE__*/React.createElement(Icon, { + name: "mail", + size: 16, + color: "var(--n-5)" + }), + value: email, + onChange: e => setEmail(e.target.value), + placeholder: "you@edevs.tech" + })), /*#__PURE__*/React.createElement("div", { + style: { + marginBottom: 22 + } + }, /*#__PURE__*/React.createElement(Input, { + label: "\u041F\u0430\u0440\u043E\u043B\u044C", + type: show ? "text" : "password", + icon: /*#__PURE__*/React.createElement(Icon, { + name: "lock", + size: 16, + color: "var(--n-5)" + }), + suffix: /*#__PURE__*/React.createElement("button", { + onClick: () => setShow(!show), + style: { + border: "none", + background: "none", + cursor: "pointer", + color: "var(--n-5)", + display: "flex" + } + }, /*#__PURE__*/React.createElement(Icon, { + name: show ? "eye" : "eyeOff", + size: 16 + })), + value: pwd, + onChange: e => setPwd(e.target.value), + placeholder: "\u041F\u0430\u0440\u043E\u043B\u044C" + })), /*#__PURE__*/React.createElement(Button, { + variant: "primary", + size: "lg", + onClick: () => email && pwd ? onSuccess() : setError(true) + }, "\u0412\u043E\u0439\u0442\u0438", /*#__PURE__*/React.createElement(Icon, { + name: "arrowRight", + size: 16 + }))), /*#__PURE__*/React.createElement("p", { + style: { + textAlign: "center", + margin: "18px 0 0", + fontSize: 12, + color: "var(--n-5)" + } + }, "\u0414\u043E\u0441\u0442\u0443\u043F \u0442\u043E\u043B\u044C\u043A\u043E \u0434\u043B\u044F \u0441\u043E\u0442\u0440\u0443\u0434\u043D\u0438\u043A\u043E\u0432 Edevs \xB7 \u0437\u0430\u0449\u0438\u0449\u0451\u043D\u043D\u043E\u0435 \u0441\u043E\u0435\u0434\u0438\u043D\u0435\u043D\u0438\u0435"))); +} +})(); } catch (e) { __ds_ns.__errors.push({ path: "ui_kits/internal-hub/AuthLoginPage.jsx", error: String((e && e.message) || e) }); } + +// ui_kits/internal-hub/ClientsPage.jsx +try { (() => { +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 /*#__PURE__*/React.createElement("div", null, /*#__PURE__*/React.createElement("div", { + style: { + display: "flex", + alignItems: "flex-start", + justifyContent: "space-between", + marginBottom: 18 + } + }, /*#__PURE__*/React.createElement("div", null, /*#__PURE__*/React.createElement("h1", { + style: { + margin: 0, + fontSize: 24, + fontWeight: 700, + letterSpacing: "-0.02em", + color: "var(--text-heading)" + } + }, "\u041A\u043B\u0438\u0435\u043D\u0442\u044B"), /*#__PURE__*/React.createElement("p", { + style: { + margin: "6px 0 0", + fontSize: 13.5, + color: "var(--text-tertiary)" + } + }, "\u041A\u043E\u043D\u0442\u0430\u043A\u0442\u044B \u043E\u0442\u0434\u0435\u043B\u0430 \u043F\u0440\u043E\u0434\u0430\u0436 \xB7 \u043F\u043E\u043A\u0430\u0437\u0430\u043D\u043E ", rows.length, " \u0438\u0437 248"))), /*#__PURE__*/React.createElement("div", { + style: { + display: "flex", + alignItems: "center", + gap: 12, + background: "#fff", + border: "1px solid var(--n-8)", + borderRadius: 11, + padding: "11px 14px", + marginBottom: 16, + boxShadow: "var(--shadow-xs)" + } + }, /*#__PURE__*/React.createElement("div", { + style: { + display: "flex", + alignItems: "center", + gap: 8, + height: 34, + padding: "0 11px", + border: "1px solid var(--n-7)", + borderRadius: 8, + background: "var(--surface-sunken)", + width: 280 + } + }, /*#__PURE__*/React.createElement(Icon, { + name: "search", + size: 15, + color: "var(--n-5)" + }), /*#__PURE__*/React.createElement("input", { + value: q, + onChange: e => setQ(e.target.value), + placeholder: "\u041F\u043E\u0438\u0441\u043A \u043F\u043E \u0438\u043C\u0435\u043D\u0438 \u0438\u043B\u0438 email\u2026", + style: { + border: "none", + outline: "none", + background: "transparent", + fontSize: 13, + width: "100%", + fontFamily: "inherit" + } + }))), /*#__PURE__*/React.createElement("div", { + style: { + background: "#fff", + border: "1px solid var(--n-8)", + borderRadius: 12, + boxShadow: "var(--shadow-sm)", + overflow: "hidden" + } + }, /*#__PURE__*/React.createElement("table", { + style: { + width: "100%", + borderCollapse: "collapse", + fontSize: 13 + } + }, /*#__PURE__*/React.createElement("thead", null, /*#__PURE__*/React.createElement("tr", { + style: { + background: "var(--n-10)" + } + }, ["КЛИЕНТ", "КАНАЛЫ", "ПРОДУКТЫ", "ПОСЛ. ДИАЛОГ", "ОТКР.", "ЗАКАЗЫ", "СУММА ПОКУПОК"].map((h, i) => /*#__PURE__*/React.createElement("th", { + key: i, + style: { + textAlign: i >= 4 ? "right" : "left", + padding: "11px 12px", + fontSize: 11, + fontWeight: 600, + color: "var(--text-tertiary)", + borderBottom: "1px solid var(--n-8)" + } + }, h)))), /*#__PURE__*/React.createElement("tbody", null, rows.map((r, i) => /*#__PURE__*/React.createElement("tr", { + key: i, + style: { + borderBottom: "1px solid var(--n-9)" + } + }, /*#__PURE__*/React.createElement("td", { + style: { + padding: "12px 16px" + } + }, /*#__PURE__*/React.createElement("div", { + style: { + display: "flex", + alignItems: "center", + gap: 11 + } + }, /*#__PURE__*/React.createElement(Avatar, { + initials: r.initials, + color: r.color, + size: 34 + }), /*#__PURE__*/React.createElement("div", null, /*#__PURE__*/React.createElement("a", { + href: "#", + style: { + fontWeight: 600, + color: "var(--primary)", + textDecoration: "none", + fontSize: 13.5 + } + }, r.name), /*#__PURE__*/React.createElement("div", { + style: { + fontSize: 11, + color: "var(--n-5)", + fontFamily: "var(--font-mono)" + } + }, r.cid)))), /*#__PURE__*/React.createElement("td", { + style: { + padding: "12px" + } + }, /*#__PURE__*/React.createElement("div", { + style: { + display: "flex", + gap: 5 + } + }, r.ch.map(c => /*#__PURE__*/React.createElement("span", { + key: c, + style: { + display: "inline-flex", + alignItems: "center", + gap: 4, + padding: "2px 7px", + borderRadius: 5, + background: CH[c].bg + } + }, /*#__PURE__*/React.createElement("span", { + style: { + width: 5, + height: 5, + borderRadius: "50%", + background: CH[c].c + } + }), /*#__PURE__*/React.createElement("span", { + style: { + fontSize: 10.5, + fontWeight: 600, + color: CH[c].c + } + }, CH[c].l))))), /*#__PURE__*/React.createElement("td", { + style: { + padding: "12px" + } + }, /*#__PURE__*/React.createElement("div", { + style: { + display: "flex", + gap: 5 + } + }, r.pr.map(p => /*#__PURE__*/React.createElement(Tag, { + key: p, + tone: PR[p].tone + }, PR[p].n)))), /*#__PURE__*/React.createElement("td", { + style: { + padding: "12px", + color: "var(--text-secondary)" + } + }, r.last), /*#__PURE__*/React.createElement("td", { + style: { + padding: "12px", + textAlign: "right", + fontWeight: 600, + color: r.open > 0 ? "var(--warning-text)" : "var(--n-5)" + } + }, r.open), /*#__PURE__*/React.createElement("td", { + style: { + padding: "12px", + textAlign: "right", + color: "var(--text-secondary)" + } + }, r.orders), /*#__PURE__*/React.createElement("td", { + style: { + padding: "12px", + textAlign: "right", + fontWeight: 600 + } + }, r.total))))))); +} +})(); } catch (e) { __ds_ns.__errors.push({ path: "ui_kits/internal-hub/ClientsPage.jsx", error: String((e && e.message) || e) }); } + +// ui_kits/internal-hub/CommandCenterPage.jsx +try { (() => { +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 /*#__PURE__*/React.createElement("div", null, /*#__PURE__*/React.createElement("div", { + style: { + display: "flex", + alignItems: "flex-start", + justifyContent: "space-between", + marginBottom: 20 + } + }, /*#__PURE__*/React.createElement("div", null, /*#__PURE__*/React.createElement("h1", { + style: { + margin: 0, + fontSize: 26, + fontWeight: 700, + letterSpacing: "-0.02em", + color: "var(--text-heading)" + } + }, "\u041A\u043E\u043C\u0430\u043D\u0434\u043D\u044B\u0439 \u0446\u0435\u043D\u0442\u0440"), /*#__PURE__*/React.createElement("p", { + style: { + margin: "6px 0 0", + fontSize: 13.5, + color: "var(--text-tertiary)" + } + }, "\u0421\u043E\u0441\u0442\u043E\u044F\u043D\u0438\u0435 \u043A\u043E\u043C\u043F\u0430\u043D\u0438\u0438 \u043E\u0434\u043D\u0438\u043C \u0432\u0437\u0433\u043B\u044F\u0434\u043E\u043C \xB7 \u043E\u0431\u043D\u043E\u0432\u043B\u0435\u043D\u043E \u0442\u043E\u043B\u044C\u043A\u043E \u0447\u0442\u043E")), /*#__PURE__*/React.createElement("select", { + value: scenario, + onChange: e => setScenario(e.target.value), + style: { + height: 36, + borderRadius: 8, + border: "1px solid var(--n-6)", + padding: "0 10px", + fontSize: 13 + } + }, /*#__PURE__*/React.createElement("option", { + value: "NORMAL" + }, "\u0421\u0446\u0435\u043D\u0430\u0440\u0438\u0439: \u041D\u043E\u0440\u043C\u0430\u043B\u044C\u043D\u043E"), /*#__PURE__*/React.createElement("option", { + value: "ATTENTION" + }, "\u0421\u0446\u0435\u043D\u0430\u0440\u0438\u0439: \u0412\u043D\u0438\u043C\u0430\u043D\u0438\u0435"), /*#__PURE__*/React.createElement("option", { + value: "CRITICAL" + }, "\u0421\u0446\u0435\u043D\u0430\u0440\u0438\u0439: \u041A\u0440\u0438\u0442\u0438\u0447\u043D\u043E"))), /*#__PURE__*/React.createElement("div", { + style: { + display: "flex", + alignItems: "center", + gap: 16, + background: "#fff", + border: "1px solid var(--n-8)", + borderLeft: `3px solid ${scenario === "NORMAL" ? "#52c41a" : scenario === "ATTENTION" ? "#faad14" : "#ff4d4f"}`, + borderRadius: 10, + padding: "16px 20px", + marginBottom: 20, + boxShadow: "var(--shadow-xs)" + } + }, /*#__PURE__*/React.createElement("div", { + style: { + flex: 1 + } + }, /*#__PURE__*/React.createElement("div", { + style: { + display: "flex", + alignItems: "center", + gap: 10 + } + }, /*#__PURE__*/React.createElement("span", { + style: { + fontSize: 16, + fontWeight: 600, + color: "var(--text-heading)" + } + }, "\u041A\u043E\u043C\u043F\u0430\u043D\u0438\u044F:"), /*#__PURE__*/React.createElement(AttentionStatus, { + level: S.level + })), /*#__PURE__*/React.createElement("p", { + style: { + margin: "3px 0 0", + fontSize: 13.5, + color: "var(--text-secondary)" + } + }, S.summary)), /*#__PURE__*/React.createElement("div", { + style: { + display: "flex", + gap: 28 + } + }, /*#__PURE__*/React.createElement("div", { + style: { + textAlign: "right" + } + }, /*#__PURE__*/React.createElement("div", { + style: { + fontSize: 12, + color: "var(--text-tertiary)" + } + }, "\u041E\u0442\u0434\u0435\u043B\u044B"), /*#__PURE__*/React.createElement("div", { + style: { + fontSize: 18, + fontWeight: 600 + } + }, "1")), /*#__PURE__*/React.createElement("div", { + style: { + textAlign: "right" + } + }, /*#__PURE__*/React.createElement("div", { + style: { + fontSize: 12, + color: "var(--text-tertiary)" + } + }, "\u0412\u044B\u0440\u0443\u0447\u043A\u0430 \xB7 \u0441\u0435\u0433\u043E\u0434\u043D\u044F"), /*#__PURE__*/React.createElement("div", { + style: { + fontSize: 18, + fontWeight: 600, + color: "var(--success-text)" + } + }, "\u20BD146 200")))), /*#__PURE__*/React.createElement("div", { + style: { + display: "flex", + gap: 20, + alignItems: "flex-start" + } + }, /*#__PURE__*/React.createElement("div", { + style: { + flex: 1, + minWidth: 0 + } + }, /*#__PURE__*/React.createElement("div", { + style: { + fontSize: 14, + fontWeight: 600, + color: "var(--text-secondary)", + marginBottom: 12 + } + }, "\u041E\u0442\u0434\u0435\u043B\u044B"), /*#__PURE__*/React.createElement(DepartmentCard, { + name: "\u041F\u0440\u043E\u0434\u0430\u0436\u0438", + meta: "\u041E\u0442\u0432\u0435\u0442\u0441\u0442\u0432\u0435\u043D\u043D\u044B\u0439: \u0410\u043D\u043D\u0430 \u041A\u043E\u0442\u043E\u0432\u0430 \xB7 4 \u0441\u043E\u0442\u0440\u0443\u0434\u043D\u0438\u043A\u0430 \xB7 1 AI-\u0430\u0433\u0435\u043D\u0442", + level: S.level, + summary: S.summary, + onOpen: onOpenDepartment + }, /*#__PURE__*/React.createElement("div", { + style: { + fontSize: 11, + fontWeight: 600, + letterSpacing: "0.05em", + color: "var(--n-5)", + margin: "16px 0 8px" + } + }, "\u0414\u0418\u0410\u041B\u041E\u0413\u0418 \u2014 \u0421\u0415\u0419\u0427\u0410\u0421"), /*#__PURE__*/React.createElement("div", { + style: { + display: "grid", + gridTemplateColumns: "repeat(4,1fr)", + gap: 1, + background: "var(--n-8)", + border: "1px solid var(--n-8)", + borderRadius: 9, + overflow: "hidden" + } + }, /*#__PURE__*/React.createElement(MetricCard, { + label: "\u041E\u0442\u043A\u0440\u044B\u0442\u044B\u0435 \u0434\u0438\u0430\u043B\u043E\u0433\u0438", + value: S.open + }), /*#__PURE__*/React.createElement(MetricCard, { + label: "\u0410\u043A\u0442\u0438\u0432\u043D\u044B \u0437\u0430 15 \u043C\u0438\u043D", + value: S.active + }), /*#__PURE__*/React.createElement(MetricCard, { + label: "\u041D\u0430 AI", + value: S.ai, + dotColor: "var(--ai)" + }), /*#__PURE__*/React.createElement(MetricCard, { + label: "\u041E\u0436\u0438\u0434\u0430\u044E\u0442 \u043E\u043F\u0435\u0440\u0430\u0442\u043E\u0440\u0430", + value: S.wait, + valueColor: S.wait > 0 ? "var(--warning-text)" : undefined + })))), /*#__PURE__*/React.createElement("div", { + style: { + width: 320, + flex: "none", + display: "flex", + flexDirection: "column", + gap: 16 + } + }, /*#__PURE__*/React.createElement("div", { + style: { + background: "#fff", + border: "1px solid var(--n-8)", + borderRadius: 12, + boxShadow: "var(--shadow-sm)", + overflow: "hidden" + } + }, /*#__PURE__*/React.createElement("div", { + style: { + padding: "15px 18px 12px", + fontSize: 14, + fontWeight: 600 + } + }, "\u0421\u043E\u0441\u0442\u043E\u044F\u043D\u0438\u0435 \u0438\u043D\u0442\u0435\u0433\u0440\u0430\u0446\u0438\u0439"), /*#__PURE__*/React.createElement(IntegrationHealthCard, { + name: "OpenRouter", + group: "AI-\u043F\u0440\u043E\u0432\u0430\u0439\u0434\u0435\u0440", + status: "connected" + }), /*#__PURE__*/React.createElement(IntegrationHealthCard, { + name: "\u0422\u043E\u0447\u043A\u0430", + group: "\u041F\u043B\u0430\u0442\u0435\u0436\u0438 \u0438 \u0444\u0438\u0441\u043A\u0430\u043B\u0438\u0437\u0430\u0446\u0438\u044F", + status: scenario === "NORMAL" ? "connected" : "degraded" + }), /*#__PURE__*/React.createElement(IntegrationHealthCard, { + name: "Fulfillment \xB7 FirePage", + group: "\u0418\u0441\u043F\u043E\u043B\u043D\u0435\u043D\u0438\u0435", + status: scenario === "CRITICAL" ? "error" : "connected" + }))))); +} +})(); } catch (e) { __ds_ns.__errors.push({ path: "ui_kits/internal-hub/CommandCenterPage.jsx", error: String((e && e.message) || e) }); } + +// ui_kits/internal-hub/HubShell.jsx +try { (() => { +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 /*#__PURE__*/React.createElement("div", { + style: { + display: "flex", + height: "100%", + minWidth: 1024, + overflow: "hidden", + background: "var(--surface-page)", + fontFamily: "var(--font-sans)", + color: "var(--text-body)" + } + }, /*#__PURE__*/React.createElement("aside", { + style: { + width: "var(--sidebar-width)", + flex: "none", + background: "#fff", + borderRight: "1px solid var(--n-8)", + display: "flex", + flexDirection: "column" + } + }, /*#__PURE__*/React.createElement("div", { + style: { + height: "var(--h-topbar)", + flex: "none", + display: "flex", + alignItems: "center", + gap: 11, + padding: "0 20px", + borderBottom: "1px solid var(--n-9)" + } + }, /*#__PURE__*/React.createElement("img", { + src: "../../assets/logo-mark.svg", + style: { + width: 30, + height: 30, + borderRadius: 8 + } + }), /*#__PURE__*/React.createElement("div", { + style: { + display: "flex", + flexDirection: "column", + lineHeight: 1.15 + } + }, /*#__PURE__*/React.createElement("span", { + style: { + fontSize: 14, + fontWeight: 600, + color: "var(--text-heading)", + letterSpacing: "-0.01em" + } + }, "Edevs Hub"), /*#__PURE__*/React.createElement("span", { + style: { + fontSize: 11, + color: "var(--text-tertiary)" + } + }, "\u0423\u0440\u043E\u0432\u0435\u043D\u044C \u043A\u043E\u043C\u043F\u0430\u043D\u0438\u0438"))), /*#__PURE__*/React.createElement("nav", { + style: { + flex: 1, + overflowY: "auto", + padding: "12px 12px 8px" + } + }, NAV.map((item, i) => item.section ? /*#__PURE__*/React.createElement("div", { + key: i, + style: { + fontSize: 11, + fontWeight: 600, + letterSpacing: "0.06em", + color: "var(--n-5)", + padding: "14px 12px 6px" + } + }, item.section) : /*#__PURE__*/React.createElement("a", { + key: i, + href: "#", + onClick: e => { + e.preventDefault(); + onNavigate(item.key); + }, + style: { + display: "flex", + alignItems: "center", + gap: 11, + padding: "9px 12px", + borderRadius: 8, + textDecoration: "none", + marginBottom: 2, + position: "relative", + fontSize: 13.5, + fontWeight: page === item.key ? 600 : 500, + background: page === item.key ? "var(--primary-bg)" : "transparent", + color: page === item.key ? "#0958d9" : "var(--text-secondary)" + } + }, page === item.key && /*#__PURE__*/React.createElement("span", { + style: { + position: "absolute", + left: 0, + top: 8, + bottom: 8, + width: 3, + borderRadius: "0 3px 3px 0", + background: "var(--primary)" + } + }), /*#__PURE__*/React.createElement(Icon, { + name: item.icon, + size: 17 + }), item.label))), /*#__PURE__*/React.createElement("div", { + style: { + flex: "none", + borderTop: "1px solid var(--n-9)", + padding: "10px 12px" + } + }, /*#__PURE__*/React.createElement("div", { + style: { + display: "flex", + alignItems: "center", + gap: 11, + padding: "8px 10px", + borderRadius: 8 + } + }, /*#__PURE__*/React.createElement(Avatar, { + initials: "\u0418\u041F" + }), /*#__PURE__*/React.createElement("div", { + style: { + display: "flex", + flexDirection: "column", + lineHeight: 1.2 + } + }, /*#__PURE__*/React.createElement("span", { + style: { + fontSize: 13, + fontWeight: 600, + color: "var(--text-body)" + } + }, "\u0418\u0432\u0430\u043D \u041F\u0435\u0442\u0440\u043E\u0432"), /*#__PURE__*/React.createElement("span", { + style: { + fontSize: 11, + color: "var(--text-tertiary)" + } + }, "OWNER"))))), /*#__PURE__*/React.createElement("div", { + style: { + flex: 1, + minWidth: 0, + display: "flex", + flexDirection: "column" + } + }, /*#__PURE__*/React.createElement("header", { + style: { + height: "var(--h-topbar)", + flex: "none", + background: "#fff", + borderBottom: "1px solid var(--n-8)", + display: "flex", + alignItems: "center", + justifyContent: "space-between", + padding: "0 24px" + } + }, /*#__PURE__*/React.createElement("div", { + style: { + display: "flex", + alignItems: "center", + gap: 8, + fontSize: 13, + color: "var(--text-tertiary)" + } + }, /*#__PURE__*/React.createElement("span", { + style: { + fontWeight: 500, + color: "var(--text-secondary)" + } + }, "Edevs"), /*#__PURE__*/React.createElement("span", { + style: { + color: "var(--n-6)" + } + }, "/"), /*#__PURE__*/React.createElement("span", { + style: { + fontWeight: 600, + color: "var(--text-body)" + } + }, crumb)), /*#__PURE__*/React.createElement("div", { + style: { + display: "flex", + alignItems: "center", + gap: 14 + } + }, /*#__PURE__*/React.createElement("button", { + style: { + position: "relative", + width: 36, + height: 36, + borderRadius: 8, + border: "1px solid var(--n-8)", + background: "#fff", + display: "flex", + alignItems: "center", + justifyContent: "center", + cursor: "pointer", + color: "var(--text-secondary)" + } + }, /*#__PURE__*/React.createElement(Icon, { + name: "bell", + size: 18 + }), /*#__PURE__*/React.createElement("span", { + style: { + position: "absolute", + top: 6, + right: 7, + width: 8, + height: 8, + borderRadius: "50%", + background: "var(--error)", + border: "1.5px solid #fff" + } + })), /*#__PURE__*/React.createElement("div", { + style: { + width: 1, + height: 24, + background: "var(--n-8)" + } + }), /*#__PURE__*/React.createElement(Avatar, { + initials: "\u0418\u041F", + size: 28 + }))), /*#__PURE__*/React.createElement("main", { + style: { + flex: 1, + overflowY: "auto", + padding: "24px 28px 40px" + } + }, /*#__PURE__*/React.createElement("div", { + style: { + maxWidth: "var(--content-max)", + margin: "0 auto" + } + }, children)))); +} +})(); } catch (e) { __ds_ns.__errors.push({ path: "ui_kits/internal-hub/HubShell.jsx", error: String((e && e.message) || e) }); } + +// ui_kits/web-chat/WebChatWidget.jsx +try { (() => { +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 /*#__PURE__*/React.createElement("div", { + style: { + display: "flex", + flexDirection: "column", + height: "100%", + width: "100%", + background: "#fff", + fontFamily: "var(--font-sans)", + color: "#1f1f1f", + overflow: "hidden" + } + }, /*#__PURE__*/React.createElement("div", { + style: { + flex: "none", + background: "var(--primary)", + padding: "14px 16px", + display: "flex", + alignItems: "center", + gap: 11 + } + }, /*#__PURE__*/React.createElement("div", { + style: { + width: 36, + height: 36, + borderRadius: 10, + background: "rgba(255,255,255,0.18)", + display: "flex", + alignItems: "center", + justifyContent: "center", + flex: "none", + fontSize: 15, + fontWeight: 700, + color: "#fff" + } + }, "F"), /*#__PURE__*/React.createElement("div", { + style: { + flex: 1, + minWidth: 0 + } + }, /*#__PURE__*/React.createElement("div", { + style: { + fontSize: 14.5, + fontWeight: 600, + color: "#fff", + lineHeight: 1.2 + } + }, "Foxray"), /*#__PURE__*/React.createElement("div", { + style: { + display: "flex", + alignItems: "center", + gap: 6, + marginTop: 2 + } + }, /*#__PURE__*/React.createElement("span", { + style: { + width: 7, + height: 7, + borderRadius: "50%", + background: status.dot, + flex: "none" + } + }), /*#__PURE__*/React.createElement("span", { + style: { + fontSize: 11.5, + color: "rgba(255,255,255,0.92)" + } + }, status.label))), /*#__PURE__*/React.createElement("select", { + value: state, + onChange: e => setState(e.target.value), + style: { + background: "rgba(255,255,255,0.14)", + color: "#fff", + border: "none", + borderRadius: 8, + fontSize: 11, + padding: "5px 6px" + } + }, /*#__PURE__*/React.createElement("option", { + value: "welcome" + }, "welcome"), /*#__PURE__*/React.createElement("option", { + value: "ai" + }, "ai"), /*#__PURE__*/React.createElement("option", { + value: "operator" + }, "operator"), /*#__PURE__*/React.createElement("option", { + value: "checkout" + }, "checkout"), /*#__PURE__*/React.createElement("option", { + value: "unavailable" + }, "unavailable"))), /*#__PURE__*/React.createElement("div", { + style: { + flex: 1, + minHeight: 0, + overflowY: "auto", + background: "#f7f8fa", + padding: "16px 14px" + } + }, isWelcome && /*#__PURE__*/React.createElement(React.Fragment, null, /*#__PURE__*/React.createElement("div", { + style: { + display: "flex", + flexDirection: "column", + alignItems: "center", + textAlign: "center", + padding: "20px 12px 8px" + } + }, /*#__PURE__*/React.createElement("div", { + style: { + width: 56, + height: 56, + borderRadius: 16, + background: "var(--primary-bg)", + display: "flex", + alignItems: "center", + justifyContent: "center", + marginBottom: 14 + } + }, /*#__PURE__*/React.createElement("span", { + style: { + fontSize: 24, + fontWeight: 700, + color: "var(--primary)" + } + }, "F")), /*#__PURE__*/React.createElement("h3", { + style: { + margin: 0, + fontSize: 17, + fontWeight: 700 + } + }, "\u0427\u0430\u0442 Foxray"), /*#__PURE__*/React.createElement("p", { + style: { + margin: "8px 0 0", + fontSize: 13, + color: "#595959", + lineHeight: 1.5, + maxWidth: 280 + } + }, "\u0417\u0434\u0440\u0430\u0432\u0441\u0442\u0432\u0443\u0439\u0442\u0435! \u041F\u043E\u043C\u043E\u0433\u0443 \u043F\u043E\u0434\u043E\u0431\u0440\u0430\u0442\u044C \u0442\u0430\u0440\u0438\u0444 \u0438 \u043E\u0442\u0432\u0435\u0442\u0438\u0442\u044C \u043D\u0430 \u0432\u043E\u043F\u0440\u043E\u0441\u044B \u043F\u043E Foxray. \u0427\u0435\u043C \u043C\u043E\u0436\u0435\u043C \u043F\u043E\u043C\u043E\u0447\u044C?")), /*#__PURE__*/React.createElement("div", { + style: { + marginTop: 18, + background: "#fff", + border: "1px solid #f0f0f0", + borderRadius: 12, + padding: 14 + } + }, /*#__PURE__*/React.createElement("div", { + style: { + fontSize: 12, + color: "#595959", + lineHeight: 1.5 + } + }, "\u041F\u0440\u043E\u0434\u043E\u043B\u0436\u0430\u044F, \u0432\u044B \u0441\u043E\u0433\u043B\u0430\u0448\u0430\u0435\u0442\u0435\u0441\u044C \u043D\u0430 \u043E\u0431\u0440\u0430\u0431\u043E\u0442\u043A\u0443 \u0441\u043E\u043E\u0431\u0449\u0435\u043D\u0438\u0439 \u0434\u043B\u044F \u043E\u0442\u0432\u0435\u0442\u0430 \u043D\u0430 \u043E\u0431\u0440\u0430\u0449\u0435\u043D\u0438\u0435. ", /*#__PURE__*/React.createElement("a", { + href: "#", + style: { + color: "var(--primary)" + } + }, "\u041F\u043E\u043B\u0438\u0442\u0438\u043A\u0430 \u043E\u0431\u0440\u0430\u0431\u043E\u0442\u043A\u0438 \u0434\u0430\u043D\u043D\u044B\u0445"), " \xB7 \u0440\u0435\u0434. v3."))), isUnavailable && /*#__PURE__*/React.createElement("div", { + style: { + display: "flex", + flexDirection: "column", + alignItems: "center", + textAlign: "center", + padding: "26px 14px" + } + }, /*#__PURE__*/React.createElement("div", { + style: { + width: 52, + height: 52, + borderRadius: "50%", + background: "var(--warning-bg-strong)", + display: "flex", + alignItems: "center", + justifyContent: "center", + marginBottom: 14 + } + }, /*#__PURE__*/React.createElement(Icon, { + name: "alertTriangle", + size: 26, + color: "var(--warning-text)" + })), /*#__PURE__*/React.createElement("h3", { + style: { + margin: 0, + fontSize: 15, + fontWeight: 700 + } + }, "\u0427\u0430\u0442 \u0432\u0440\u0435\u043C\u0435\u043D\u043D\u043E \u043D\u0435\u0434\u043E\u0441\u0442\u0443\u043F\u0435\u043D"), /*#__PURE__*/React.createElement("p", { + style: { + margin: "8px 0 0", + fontSize: 13, + color: "#595959", + lineHeight: 1.5, + maxWidth: 280 + } + }, "\u041C\u044B \u043D\u0435 \u043C\u043E\u0436\u0435\u043C \u043F\u0440\u0438\u043D\u044F\u0442\u044C \u0441\u043E\u043E\u0431\u0449\u0435\u043D\u0438\u0435 \u043F\u0440\u044F\u043C\u043E \u0441\u0435\u0439\u0447\u0430\u0441. \u041D\u0430\u043F\u0438\u0448\u0438\u0442\u0435 \u043D\u0430\u043C \u0432 \u0434\u0440\u0443\u0433\u043E\u043C \u043A\u0430\u043D\u0430\u043B\u0435."), /*#__PURE__*/React.createElement("div", { + style: { + display: "flex", + flexDirection: "column", + gap: 9, + width: "100%", + maxWidth: 280, + marginTop: 18 + } + }, /*#__PURE__*/React.createElement("a", { + href: "#", + style: { + display: "flex", + alignItems: "center", + gap: 10, + padding: "11px 13px", + border: "1px solid #efdbff", + background: "#fff", + borderRadius: 10, + textDecoration: "none" + } + }, /*#__PURE__*/React.createElement("span", { + style: { + width: 26, + height: 26, + borderRadius: 7, + background: "#f2f0ff", + display: "flex", + alignItems: "center", + justifyContent: "center", + flex: "none" + } + }, /*#__PURE__*/React.createElement("span", { + style: { + width: 9, + height: 9, + borderRadius: "50%", + background: "#6b5be0" + } + })), /*#__PURE__*/React.createElement("span", { + style: { + fontSize: 13, + fontWeight: 500, + color: "#262626", + flex: 1, + textAlign: "left" + } + }, "\u041D\u0430\u043F\u0438\u0441\u0430\u0442\u044C \u0432 MAX")), /*#__PURE__*/React.createElement("a", { + href: "#", + style: { + display: "flex", + alignItems: "center", + gap: 10, + padding: "11px 13px", + border: "1px solid #d6ebfa", + background: "#fff", + borderRadius: 10, + textDecoration: "none" + } + }, /*#__PURE__*/React.createElement("span", { + style: { + width: 26, + height: 26, + borderRadius: 7, + background: "#eaf6fd", + display: "flex", + alignItems: "center", + justifyContent: "center", + flex: "none" + } + }, /*#__PURE__*/React.createElement("span", { + style: { + width: 9, + height: 9, + borderRadius: "50%", + background: "#2f8fd0" + } + })), /*#__PURE__*/React.createElement("span", { + style: { + fontSize: 13, + fontWeight: 500, + color: "#262626", + flex: 1, + textAlign: "left" + } + }, "\u041D\u0430\u043F\u0438\u0441\u0430\u0442\u044C \u0432 Telegram")))), isConversation && /*#__PURE__*/React.createElement(React.Fragment, null, /*#__PURE__*/React.createElement("div", { + style: { + textAlign: "center", + marginBottom: 14 + } + }, /*#__PURE__*/React.createElement("span", { + style: { + display: "inline-block", + padding: "3px 11px", + borderRadius: 20, + background: "#eef0f2", + fontSize: 11, + color: "#8c8c8c" + } + }, "\u0421\u0435\u0433\u043E\u0434\u043D\u044F")), /*#__PURE__*/React.createElement(Bubble, { + mine: true, + text: "\u0417\u0434\u0440\u0430\u0432\u0441\u0442\u0432\u0443\u0439\u0442\u0435! \u0427\u0435\u043C Max \u043E\u0442\u043B\u0438\u0447\u0430\u0435\u0442\u0441\u044F \u043E\u0442 Pro?", + time: "14:01 \xB7 \u0434\u043E\u0441\u0442\u0430\u0432\u043B\u0435\u043D\u043E" + }), /*#__PURE__*/React.createElement(AgentBubble, { + actor: "ai", + text: "Pro \u2014 \u0434\u043B\u044F \u043E\u0434\u043D\u043E\u0433\u043E \u043F\u043E\u043B\u044C\u0437\u043E\u0432\u0430\u0442\u0435\u043B\u044F (\u20BD4 900/\u043C\u0435\u0441), Max \u2014 \u043A\u043E\u043C\u0430\u043D\u0434\u043D\u044B\u0439 \u0441 \u0440\u0430\u0441\u0448\u0438\u0440\u0435\u043D\u043D\u044B\u043C\u0438 \u043B\u0438\u043C\u0438\u0442\u0430\u043C\u0438 \u0438 \u043F\u0440\u0438\u043E\u0440\u0438\u0442\u0435\u0442\u043D\u043E\u0439 \u043F\u043E\u0434\u0434\u0435\u0440\u0436\u043A\u043E\u0439 (\u20BD9 900/\u043C\u0435\u0441).", + meta: "\u041E\u043F\u0435\u0440\u0430\u0442\u043E\u0440 \xB7 14:01" + }), /*#__PURE__*/React.createElement(Bubble, { + mine: true, + text: "\u041D\u0430\u0441 6 \u0447\u0435\u043B\u043E\u0432\u0435\u043A, \u0431\u0435\u0440\u0451\u043C Max \u043D\u0430 \u0433\u043E\u0434.", + time: "14:02 \xB7 \u0434\u043E\u0441\u0442\u0430\u0432\u043B\u0435\u043D\u043E" + }), isOperator && /*#__PURE__*/React.createElement(React.Fragment, null, /*#__PURE__*/React.createElement("div", { + style: { + textAlign: "center", + margin: "14px 0 12px" + } + }, /*#__PURE__*/React.createElement("span", { + style: { + display: "inline-flex", + alignItems: "center", + gap: 6, + padding: "5px 13px", + borderRadius: 20, + background: "var(--primary-bg)", + border: "1px solid var(--primary-border)", + fontSize: 11.5, + color: "#0958d9", + fontWeight: 500 + } + }, "\u041F\u043E\u0434\u043A\u043B\u044E\u0447\u0438\u043B\u0441\u044F \u0441\u043F\u0435\u0446\u0438\u0430\u043B\u0438\u0441\u0442 \xB7 \u0438\u0441\u0442\u043E\u0440\u0438\u044F \u0443\u0436\u0435 \u0443 \u043D\u0435\u0433\u043E")), /*#__PURE__*/React.createElement(AgentBubble, { + actor: "human", + initials: "\u0410\u041A", + text: "\u0417\u0434\u0440\u0430\u0432\u0441\u0442\u0432\u0443\u0439\u0442\u0435! \u0410\u043D\u043D\u0430, Foxray. \u041F\u043E\u043C\u043E\u0433\u0443 \u0441 \u043E\u0444\u043E\u0440\u043C\u043B\u0435\u043D\u0438\u0435\u043C Max \u043D\u0430 6 \u043C\u0435\u0441\u0442.", + meta: "\u0410\u043D\u043D\u0430 \xB7 \u0441\u043F\u0435\u0446\u0438\u0430\u043B\u0438\u0441\u0442 \xB7 14:03" + })), isAI && /*#__PURE__*/React.createElement("div", { + style: { + display: "flex", + gap: 8, + marginBottom: 4 + } + }, /*#__PURE__*/React.createElement(ActorBadge, { + actor: "ai" + }), /*#__PURE__*/React.createElement("div", { + style: { + background: "#fff", + border: "1px solid #eee", + borderRadius: "14px 14px 14px 4px", + padding: "12px 14px", + display: "flex", + alignItems: "center", + gap: 5 + } + }, /*#__PURE__*/React.createElement("span", { + style: { + width: 6, + height: 6, + borderRadius: "50%", + background: "var(--ai-lighter)", + animation: "hub-typing 1.2s infinite ease-in-out" + } + }), /*#__PURE__*/React.createElement("span", { + style: { + width: 6, + height: 6, + borderRadius: "50%", + background: "var(--ai-lighter)", + animation: "hub-typing 1.2s infinite ease-in-out .2s" + } + }), /*#__PURE__*/React.createElement("span", { + style: { + width: 6, + height: 6, + borderRadius: "50%", + background: "var(--ai-lighter)", + animation: "hub-typing 1.2s infinite ease-in-out .4s" + } + }))), isCheckout && /*#__PURE__*/React.createElement("div", { + style: { + display: "flex", + gap: 8, + marginBottom: 6 + } + }, /*#__PURE__*/React.createElement(ActorBadge, { + actor: "human", + initials: "\u0410\u041A", + color: "var(--primary)" + }), /*#__PURE__*/React.createElement("div", { + style: { + maxWidth: "84%", + width: "100%" + } + }, /*#__PURE__*/React.createElement("div", { + style: { + background: "#fff", + border: "1px solid #e8e8e8", + borderRadius: 14, + overflow: "hidden", + boxShadow: "var(--shadow-sm)" + } + }, /*#__PURE__*/React.createElement("div", { + style: { + padding: "13px 15px 11px", + borderBottom: "1px solid #f5f5f5" + } + }, /*#__PURE__*/React.createElement("div", { + style: { + fontSize: 11, + fontWeight: 600, + letterSpacing: "0.04em", + color: "var(--ai)", + marginBottom: 6 + } + }, "\u041E\u0424\u041E\u0420\u041C\u041B\u0415\u041D\u0418\u0415 \u041F\u041E\u041A\u0423\u041F\u041A\u0418"), /*#__PURE__*/React.createElement("div", { + style: { + fontSize: 15, + fontWeight: 700 + } + }, "Foxray Max \xB7 6 \u043C\u0435\u0441\u0442"), /*#__PURE__*/React.createElement("div", { + style: { + display: "flex", + alignItems: "baseline", + gap: 7, + marginTop: 7 + } + }, /*#__PURE__*/React.createElement("span", { + style: { + fontSize: 21, + fontWeight: 700 + } + }, "\u20BD95 040"), /*#__PURE__*/React.createElement("span", { + style: { + fontSize: 12, + color: "#8c8c8c" + } + }, "/ \u0433\u043E\u0434 \xB7 \u221220%"))), /*#__PURE__*/React.createElement("div", { + style: { + padding: "11px 15px 13px" + } + }, /*#__PURE__*/React.createElement("a", { + href: "#", + style: { + display: "flex", + alignItems: "center", + justifyContent: "center", + gap: 7, + height: 40, + borderRadius: 9, + background: "var(--primary)", + color: "#fff", + fontSize: 13.5, + fontWeight: 600, + textDecoration: "none" + } + }, "\u041F\u0435\u0440\u0435\u0439\u0442\u0438 \u043A \u043F\u043E\u043A\u0443\u043F\u043A\u0435", /*#__PURE__*/React.createElement(Icon, { + name: "arrowRight", + size: 15 + })))))))), isConversation && /*#__PURE__*/React.createElement("div", { + style: { + flex: "none", + background: "#fff", + borderTop: "1px solid #f0f0f0", + padding: "10px 12px 12px" + } + }, isAI && /*#__PURE__*/React.createElement("div", { + style: { + display: "flex", + gap: 7, + flexWrap: "wrap", + marginBottom: 9 + } + }, /*#__PURE__*/React.createElement(QuickReply, null, "\u0421\u0440\u0430\u0432\u043D\u0438\u0442\u044C \u0442\u0430\u0440\u0438\u0444\u044B"), /*#__PURE__*/React.createElement(QuickReply, null, "\u0415\u0441\u0442\u044C \u043B\u0438 \u043F\u0440\u043E\u0431\u043D\u044B\u0439 \u043F\u0435\u0440\u0438\u043E\u0434?")), /*#__PURE__*/React.createElement("div", { + style: { + display: "flex", + alignItems: "flex-end", + gap: 8, + border: "1px solid #e8e8e8", + borderRadius: 12, + padding: "6px 6px 6px 12px" + } + }, /*#__PURE__*/React.createElement("textarea", { + rows: 1, + placeholder: "\u041D\u0430\u043F\u0438\u0448\u0438\u0442\u0435 \u0441\u043E\u043E\u0431\u0449\u0435\u043D\u0438\u0435\u2026", + style: { + flex: 1, + border: "none", + outline: "none", + resize: "none", + fontSize: 13.5, + lineHeight: 1.5, + color: "#262626", + fontFamily: "inherit", + padding: "6px 0" + } + }), /*#__PURE__*/React.createElement("button", { + style: { + width: 34, + height: 34, + borderRadius: 8, + border: "none", + background: "var(--primary)", + display: "flex", + alignItems: "center", + justifyContent: "center", + cursor: "pointer", + flex: "none" + } + }, /*#__PURE__*/React.createElement("svg", { + viewBox: "0 0 24 24", + width: 16, + height: 16, + fill: "none", + stroke: "#fff", + strokeWidth: 2, + strokeLinecap: "round", + strokeLinejoin: "round" + }, /*#__PURE__*/React.createElement("line", { + x1: "22", + y1: "2", + x2: "11", + y2: "13" + }), /*#__PURE__*/React.createElement("polygon", { + points: "22 2 15 22 11 13 2 9 22 2" + }))))), isWelcome && /*#__PURE__*/React.createElement("div", { + style: { + flex: "none", + background: "#fff", + borderTop: "1px solid #f0f0f0", + padding: "12px 14px 14px" + } + }, /*#__PURE__*/React.createElement("button", { + onClick: () => setState("ai"), + style: { + width: "100%", + height: 44, + borderRadius: 10, + border: "none", + background: "var(--primary)", + color: "#fff", + fontSize: 14, + fontWeight: 600, + cursor: "pointer" + } + }, "\u041F\u0440\u0438\u043D\u044F\u0442\u044C \u0438 \u043D\u0430\u0447\u0430\u0442\u044C \u0447\u0430\u0442")), isUnavailable && /*#__PURE__*/React.createElement("div", { + style: { + flex: "none", + background: "#fafafa", + borderTop: "1px solid #f0f0f0", + padding: "12px 14px", + display: "flex", + alignItems: "center", + gap: 9, + justifyContent: "center" + } + }, /*#__PURE__*/React.createElement("span", { + style: { + fontSize: 12, + color: "#8c8c8c" + } + }, "\u041E\u0442\u043F\u0440\u0430\u0432\u043A\u0430 \u0441\u043E\u043E\u0431\u0449\u0435\u043D\u0438\u0439 \u043D\u0435\u0434\u043E\u0441\u0442\u0443\u043F\u043D\u0430"))); +} +function Bubble({ + text, + time +}) { + return /*#__PURE__*/React.createElement("div", { + style: { + display: "flex", + justifyContent: "flex-end", + marginBottom: 10 + } + }, /*#__PURE__*/React.createElement("div", { + style: { + maxWidth: "78%" + } + }, /*#__PURE__*/React.createElement("div", { + style: { + background: "var(--primary)", + color: "#fff", + borderRadius: "14px 14px 4px 14px", + padding: "9px 13px", + fontSize: 13.5, + lineHeight: 1.45 + } + }, text), /*#__PURE__*/React.createElement("div", { + style: { + fontSize: 10.5, + color: "#bfbfbf", + margin: "3px 4px 0 0", + textAlign: "right" + } + }, time))); +} +function AgentBubble({ + actor, + initials, + text, + meta +}) { + return /*#__PURE__*/React.createElement("div", { + style: { + display: "flex", + gap: 8, + marginBottom: 10 + } + }, /*#__PURE__*/React.createElement(ActorBadge, { + actor: actor, + initials: initials, + color: "var(--primary)" + }), /*#__PURE__*/React.createElement("div", { + style: { + maxWidth: "78%" + } + }, /*#__PURE__*/React.createElement("div", { + style: { + background: "#fff", + border: "1px solid #eee", + borderRadius: "14px 14px 14px 4px", + padding: "9px 13px", + fontSize: 13.5, + lineHeight: 1.45, + color: "#262626" + } + }, text), /*#__PURE__*/React.createElement("div", { + style: { + fontSize: 10.5, + color: "#bfbfbf", + margin: "3px 0 0 4px" + } + }, meta))); +} +function QuickReply({ + children +}) { + return /*#__PURE__*/React.createElement("button", { + style: { + padding: "6px 12px", + borderRadius: 16, + border: "1px solid var(--primary-border)", + background: "#f0f7ff", + color: "#0958d9", + fontSize: 12, + fontWeight: 500, + cursor: "pointer" + } + }, children); +} +})(); } catch (e) { __ds_ns.__errors.push({ path: "ui_kits/web-chat/WebChatWidget.jsx", error: String((e && e.message) || e) }); } + +__ds_ns.CommerceStatusTimeline = __ds_scope.CommerceStatusTimeline; + +__ds_ns.DepartmentCard = __ds_scope.DepartmentCard; + +__ds_ns.IntegrationHealthCard = __ds_scope.IntegrationHealthCard; + +__ds_ns.MetricCard = __ds_scope.MetricCard; + +__ds_ns.ProductAIReleaseCard = __ds_scope.ProductAIReleaseCard; + +__ds_ns.Avatar = __ds_scope.Avatar; + +__ds_ns.Button = __ds_scope.Button; + +__ds_ns.Checkbox = __ds_scope.Checkbox; + +__ds_ns.Icon = __ds_scope.Icon; + +__ds_ns.ICON_NAMES = __ds_scope.ICON_NAMES; + +__ds_ns.IconButton = __ds_scope.IconButton; + +__ds_ns.Input = __ds_scope.Input; + +__ds_ns.Tag = __ds_scope.Tag; + +__ds_ns.ConversationInbox = __ds_scope.ConversationInbox; + +__ds_ns.KnowledgeDocumentEditor = __ds_scope.KnowledgeDocumentEditor; + +__ds_ns.ActorBadge = __ds_scope.ActorBadge; + +__ds_ns.AttentionStatus = __ds_scope.AttentionStatus; + +__ds_ns.ChannelBadge = __ds_scope.ChannelBadge; + +})(); diff --git a/design/baseline/# UI для онлайн звонка/_ds/edevs-hub-design-system-e4c9df12-82c8-42b3-b819-63d92dfb8702/_ds_manifest.json b/design/baseline/# UI для онлайн звонка/_ds/edevs-hub-design-system-e4c9df12-82c8-42b3-b819-63d92dfb8702/_ds_manifest.json new file mode 100644 index 0000000..6611105 --- /dev/null +++ b/design/baseline/# UI для онлайн звонка/_ds/edevs-hub-design-system-e4c9df12-82c8-42b3-b819-63d92dfb8702/_ds_manifest.json @@ -0,0 +1 @@ +{"namespace":"EdevsHubDesignSystem_e4c9df","components":[{"name":"CommerceStatusTimeline","sourcePath":"components/cards/CommerceStatusTimeline.jsx"},{"name":"DepartmentCard","sourcePath":"components/cards/DepartmentCard.jsx"},{"name":"IntegrationHealthCard","sourcePath":"components/cards/IntegrationHealthCard.jsx"},{"name":"MetricCard","sourcePath":"components/cards/MetricCard.jsx"},{"name":"ProductAIReleaseCard","sourcePath":"components/cards/ProductAIReleaseCard.jsx"},{"name":"Avatar","sourcePath":"components/core/Avatar.jsx"},{"name":"Button","sourcePath":"components/core/Button.jsx"},{"name":"Checkbox","sourcePath":"components/core/Checkbox.jsx"},{"name":"Icon","sourcePath":"components/core/Icon.jsx"},{"name":"ICON_NAMES","sourcePath":"components/core/Icon.jsx"},{"name":"IconButton","sourcePath":"components/core/IconButton.jsx"},{"name":"Input","sourcePath":"components/core/Input.jsx"},{"name":"Tag","sourcePath":"components/core/Tag.jsx"},{"name":"ConversationInbox","sourcePath":"components/inbox/ConversationInbox.jsx"},{"name":"KnowledgeDocumentEditor","sourcePath":"components/inbox/KnowledgeDocumentEditor.jsx"},{"name":"ActorBadge","sourcePath":"components/status/ActorBadge.jsx"},{"name":"AttentionStatus","sourcePath":"components/status/AttentionStatus.jsx"},{"name":"ChannelBadge","sourcePath":"components/status/ChannelBadge.jsx"}],"startingPoints":[{"name":"CommerceStatusTimeline","path":"components/cards/CommerceStatusTimeline.jsx","previewPath":"components/cards/cards.card.html","kind":"component","section":"Cards","subtitle":"Payment vs delivery step timeline for checkout/orders","viewport":"700x160"},{"name":"DepartmentCard","path":"components/cards/DepartmentCard.jsx","previewPath":"components/cards/cards.card.html","kind":"component","section":"Cards","subtitle":"Command Center department summary card","viewport":"900x360"},{"name":"IntegrationHealthCard","path":"components/cards/IntegrationHealthCard.jsx","previewPath":"components/cards/cards.card.html","kind":"component","section":"Cards","subtitle":"Single integration row (connected/degraded/error)","viewport":"700x90"},{"name":"MetricCard","path":"components/cards/MetricCard.jsx","previewPath":"components/cards/cards.card.html","kind":"component","section":"Cards","subtitle":"Single KPI cell for metric grids","viewport":"700x120"},{"name":"ProductAIReleaseCard","path":"components/cards/ProductAIReleaseCard.jsx","previewPath":"components/cards/cards.card.html","kind":"component","section":"Cards","subtitle":"AI knowledge/prompt release snapshot for one product","viewport":"700x180"},{"name":"Button","path":"components/core/Button.jsx","previewPath":"components/core/core.card.html","kind":"component","section":"Core","subtitle":"Primary/secondary/ghost/danger button","viewport":"700x220"},{"name":"IconButton","path":"components/core/IconButton.jsx","previewPath":"components/core/core.card.html","kind":"component","section":"Core","subtitle":"Square icon-only button with optional badge","viewport":"700x150"},{"name":"Input","path":"components/core/Input.jsx","previewPath":"components/core/core.card.html","kind":"component","section":"Core","subtitle":"Text field with label, icon and error state","viewport":"700x260"},{"name":"Tag","path":"components/core/Tag.jsx","previewPath":"components/core/core.card.html","kind":"component","section":"Core","subtitle":"Status pill and rectangular label tag","viewport":"700x150"},{"name":"KnowledgeDocumentEditor","path":"components/inbox/KnowledgeDocumentEditor.jsx","previewPath":"components/inbox/inbox.card.html","kind":"component","section":"Inbox","subtitle":"Plain-text knowledge base document editor","viewport":"700x380"},{"name":"ActorBadge","path":"components/status/ActorBadge.jsx","previewPath":"components/status/status.card.html","kind":"component","section":"Status","subtitle":"AI vs human avatar for conversation timelines","viewport":"700x110"},{"name":"AttentionStatus","path":"components/status/AttentionStatus.jsx","previewPath":"components/status/status.card.html","kind":"component","section":"Status","subtitle":"Company/department health chip (ok/attention/critical)","viewport":"700x120"},{"name":"ChannelBadge","path":"components/status/ChannelBadge.jsx","previewPath":"components/status/status.card.html","kind":"component","section":"Status","subtitle":"MAX / Telegram / Web Chat channel chip","viewport":"700x100"}],"cards":[{"path":"guidelines/logo-mark.card.html","group":"Brand","viewport":"700x160","subtitle":"Only mark found in the codebase — a blue rounded square with a bar-chart glyph","name":"Logo Mark"},{"path":"guidelines/radii-shadows.card.html","group":"Brand","viewport":"700x180","subtitle":"Soft, low-opacity shadows only — no glassmorphism","name":"Radii & Shadows"},{"path":"ui_kits/checkout/index.html","group":"Checkout","viewport":"440x760"},{"path":"guidelines/colors-ai-channels.card.html","group":"Colors","viewport":"700x160","subtitle":"AI/human split, MAX/Telegram/Web Chat, FirePage/Foxray","name":"AI, Channels & Products"},{"path":"guidelines/colors-neutrals.card.html","group":"Colors","viewport":"700x200","subtitle":"Text/border/surface neutral scale","name":"Neutrals"},{"path":"guidelines/colors-primary.card.html","group":"Colors","viewport":"700x160","subtitle":"Single accent — blue-6/blue-7, per ADR-HUB-0013","name":"Primary"},{"path":"guidelines/colors-semantic.card.html","group":"Colors","viewport":"700x160","subtitle":"success / warning / error / info","name":"Semantic"},{"path":"components/cards/cards.card.html","group":"Components","viewport":"900x520","subtitle":"MetricCard, DepartmentCard, IntegrationHealthCard, ProductAIReleaseCard, CommerceStatusTimeline","name":"Composite Cards"},{"path":"components/core/core.card.html","group":"Components","viewport":"700x560","subtitle":"Button, IconButton, Input, Checkbox, Tag, Avatar, Icon","name":"Core Primitives"},{"path":"components/inbox/inbox.card.html","group":"Components","viewport":"700x480","subtitle":"ConversationInbox, KnowledgeDocumentEditor","name":"Inbox & Knowledge"},{"path":"components/status/status.card.html","group":"Components","viewport":"700x420","subtitle":"AttentionStatus, ActorBadge, ChannelBadge","name":"Status & Badges"},{"path":"ui_kits/internal-hub/index.html","group":"Internal Hub","viewport":"1440x900"},{"path":"guidelines/spacing-metric-grid.card.html","group":"Spacing","viewport":"700x220","subtitle":"1px hairline-divided grid pattern","name":"Metric Grid In Use"},{"path":"guidelines/spacing-scale.card.html","group":"Spacing","viewport":"700x180","subtitle":"Compact-enterprise density — exact px, not a 4/8 grid","name":"Spacing Scale"},{"path":"guidelines/type-mono.card.html","group":"Type","viewport":"700x140","subtitle":"Order, release, event IDs and masked data","name":"Monospace / Technical IDs"},{"path":"guidelines/type-scale.card.html","group":"Type","viewport":"700x300","subtitle":"System font stack — no bespoke webfont","name":"Display & Body Scale"},{"path":"ui_kits/web-chat/index.html","group":"Web Chat","viewport":"420x680"}],"templates":[],"hasThumbnailHtml":false,"globalCssPaths":["tokens/colors.css","tokens/typography.css","tokens/spacing.css","tokens/effects.css","styles.css"],"tokens":[{"name":"--n-1","value":"#1f1f1f","kind":"color","definedIn":"tokens/colors.css"},{"name":"--n-2","value":"#262626","kind":"color","definedIn":"tokens/colors.css"},{"name":"--n-3","value":"#595959","kind":"color","definedIn":"tokens/colors.css"},{"name":"--n-4","value":"#8c8c8c","kind":"color","definedIn":"tokens/colors.css"},{"name":"--n-5","value":"#bfbfbf","kind":"color","definedIn":"tokens/colors.css"},{"name":"--n-6","value":"#d9d9d9","kind":"color","definedIn":"tokens/colors.css"},{"name":"--n-7","value":"#e8e8e8","kind":"color","definedIn":"tokens/colors.css"},{"name":"--n-8","value":"#f0f0f0","kind":"color","definedIn":"tokens/colors.css"},{"name":"--n-9","value":"#f5f5f5","kind":"color","definedIn":"tokens/colors.css"},{"name":"--n-10","value":"#fafafa","kind":"color","definedIn":"tokens/colors.css"},{"name":"--n-11","value":"#f0f2f5","kind":"color","definedIn":"tokens/colors.css"},{"name":"--n-white","value":"#ffffff","kind":"color","definedIn":"tokens/colors.css"},{"name":"--primary","value":"#1677ff","kind":"color","definedIn":"tokens/colors.css"},{"name":"--primary-hover","value":"#0958d9","kind":"color","definedIn":"tokens/colors.css"},{"name":"--primary-bg","value":"#e6f4ff","kind":"color","definedIn":"tokens/colors.css"},{"name":"--primary-border","value":"#91caff","kind":"color","definedIn":"tokens/colors.css"},{"name":"--primary-shadow","value":"rgba(22, 119, 255, 0.3)","kind":"shadow","definedIn":"tokens/colors.css"},{"name":"--success","value":"#52c41a","kind":"color","definedIn":"tokens/colors.css"},{"name":"--success-text","value":"#389e0d","kind":"font","definedIn":"tokens/colors.css"},{"name":"--success-bg","value":"#f6ffed","kind":"color","definedIn":"tokens/colors.css"},{"name":"--success-bg-strong","value":"#f1f9ec","kind":"color","definedIn":"tokens/colors.css"},{"name":"--success-border","value":"#b7eb8f","kind":"color","definedIn":"tokens/colors.css"},{"name":"--warning","value":"#faad14","kind":"color","definedIn":"tokens/colors.css"},{"name":"--warning-text","value":"#d48806","kind":"font","definedIn":"tokens/colors.css"},{"name":"--warning-bg","value":"#fffbe6","kind":"color","definedIn":"tokens/colors.css"},{"name":"--warning-bg-strong","value":"#fff7e6","kind":"color","definedIn":"tokens/colors.css"},{"name":"--warning-border","value":"#ffe58f","kind":"color","definedIn":"tokens/colors.css"},{"name":"--error","value":"#ff4d4f","kind":"color","definedIn":"tokens/colors.css"},{"name":"--error-text","value":"#cf1322","kind":"font","definedIn":"tokens/colors.css"},{"name":"--error-bg","value":"#fff2f0","kind":"color","definedIn":"tokens/colors.css"},{"name":"--error-border","value":"#ffccc7","kind":"color","definedIn":"tokens/colors.css"},{"name":"--info","value":"#1677ff","kind":"color","definedIn":"tokens/colors.css"},{"name":"--ai","value":"#722ed1","kind":"color","definedIn":"tokens/colors.css"},{"name":"--ai-light","value":"#9254de","kind":"color","definedIn":"tokens/colors.css"},{"name":"--ai-lighter","value":"#b37feb","kind":"color","definedIn":"tokens/colors.css"},{"name":"--ai-bg","value":"#f9f0ff","kind":"color","definedIn":"tokens/colors.css"},{"name":"--human","value":"#1677ff","kind":"color","definedIn":"tokens/colors.css"},{"name":"--channel-max","value":"#6b5be0","kind":"color","definedIn":"tokens/colors.css"},{"name":"--channel-max-bg","value":"#f2f0ff","kind":"color","definedIn":"tokens/colors.css"},{"name":"--channel-telegram","value":"#2f8fd0","kind":"color","definedIn":"tokens/colors.css"},{"name":"--channel-telegram-bg","value":"#eaf6fd","kind":"color","definedIn":"tokens/colors.css"},{"name":"--channel-webchat","value":"#0f9b8e","kind":"color","definedIn":"tokens/colors.css"},{"name":"--channel-webchat-bg","value":"#e8f7f4","kind":"color","definedIn":"tokens/colors.css"},{"name":"--product-firepage","value":"#0958d9","kind":"color","definedIn":"tokens/colors.css"},{"name":"--product-firepage-bg","value":"#e6f4ff","kind":"color","definedIn":"tokens/colors.css"},{"name":"--product-foxray","value":"#722ed1","kind":"color","definedIn":"tokens/colors.css"},{"name":"--product-foxray-bg","value":"#f9f0ff","kind":"color","definedIn":"tokens/colors.css"},{"name":"--surface-page","value":"var(--n-11)","kind":"color","definedIn":"tokens/colors.css"},{"name":"--surface-card","value":"var(--n-white)","kind":"color","definedIn":"tokens/colors.css"},{"name":"--surface-sunken","value":"#fafafa","kind":"color","definedIn":"tokens/colors.css"},{"name":"--surface-chip","value":"#f7f8fa","kind":"color","definedIn":"tokens/colors.css"},{"name":"--text-heading","value":"var(--n-1)","kind":"font","definedIn":"tokens/colors.css"},{"name":"--text-body","value":"var(--n-2)","kind":"font","definedIn":"tokens/colors.css"},{"name":"--text-secondary","value":"var(--n-3)","kind":"font","definedIn":"tokens/colors.css"},{"name":"--text-tertiary","value":"var(--n-4)","kind":"font","definedIn":"tokens/colors.css"},{"name":"--text-disabled","value":"var(--n-5)","kind":"font","definedIn":"tokens/colors.css"},{"name":"--text-inverse","value":"var(--n-white)","kind":"font","definedIn":"tokens/colors.css"},{"name":"--text-link","value":"var(--primary)","kind":"font","definedIn":"tokens/colors.css"},{"name":"--border-default","value":"var(--n-8)","kind":"color","definedIn":"tokens/colors.css"},{"name":"--border-input","value":"var(--n-7)","kind":"color","definedIn":"tokens/colors.css"},{"name":"--border-strong","value":"var(--n-6)","kind":"color","definedIn":"tokens/colors.css"},{"name":"--font-sans","value":"-apple-system, BlinkMacSystemFont, \"Segoe UI\", Roboto, \"Helvetica Neue\", Arial, sans-serif","kind":"font","definedIn":"tokens/typography.css"},{"name":"--font-mono","value":"\"SF Mono\", ui-monospace, Menlo, Consolas, monospace","kind":"font","definedIn":"tokens/typography.css"},{"name":"--text-2xl","value":"26px","kind":"font","definedIn":"tokens/typography.css"},{"name":"--text-xl","value":"24px","kind":"font","definedIn":"tokens/typography.css"},{"name":"--text-lg","value":"20px","kind":"font","definedIn":"tokens/typography.css"},{"name":"--text-md","value":"18px","kind":"font","definedIn":"tokens/typography.css"},{"name":"--text-base-lg","value":"16px","kind":"font","definedIn":"tokens/typography.css"},{"name":"--text-base","value":"14.5px","kind":"font","definedIn":"tokens/typography.css"},{"name":"--text-sm-lg","value":"13.5px","kind":"font","definedIn":"tokens/typography.css"},{"name":"--text-sm","value":"13px","kind":"font","definedIn":"tokens/typography.css"},{"name":"--text-sm-tight","value":"12.5px","kind":"font","definedIn":"tokens/typography.css"},{"name":"--text-xs","value":"12px","kind":"font","definedIn":"tokens/typography.css"},{"name":"--text-xs-tight","value":"11.5px","kind":"font","definedIn":"tokens/typography.css"},{"name":"--text-2xs","value":"11px","kind":"font","definedIn":"tokens/typography.css"},{"name":"--text-2xs-tight","value":"10.5px","kind":"font","definedIn":"tokens/typography.css"},{"name":"--weight-regular","value":"400","kind":"font","definedIn":"tokens/typography.css"},{"name":"--weight-medium","value":"500","kind":"font","definedIn":"tokens/typography.css"},{"name":"--weight-semibold","value":"600","kind":"font","definedIn":"tokens/typography.css"},{"name":"--weight-bold","value":"700","kind":"font","definedIn":"tokens/typography.css"},{"name":"--weight-black","value":"800","kind":"font","definedIn":"tokens/typography.css"},{"name":"--tracking-tight","value":"-0.02em","kind":"font","definedIn":"tokens/typography.css"},{"name":"--tracking-tight-sm","value":"-0.01em","kind":"font","definedIn":"tokens/typography.css"},{"name":"--tracking-wide","value":"0.03em","kind":"font","definedIn":"tokens/typography.css"},{"name":"--tracking-wider","value":"0.06em","kind":"font","definedIn":"tokens/typography.css"},{"name":"--leading-tight","value":"1.15","kind":"font","definedIn":"tokens/typography.css"},{"name":"--leading-snug","value":"1.2","kind":"font","definedIn":"tokens/typography.css"},{"name":"--leading-normal","value":"1.4","kind":"font","definedIn":"tokens/typography.css"},{"name":"--leading-relaxed","value":"1.5","kind":"font","definedIn":"tokens/typography.css"},{"name":"--leading-loose","value":"1.55","kind":"font","definedIn":"tokens/typography.css"},{"name":"--space-1","value":"2px","kind":"spacing","definedIn":"tokens/spacing.css"},{"name":"--space-2","value":"4px","kind":"spacing","definedIn":"tokens/spacing.css"},{"name":"--space-3","value":"6px","kind":"spacing","definedIn":"tokens/spacing.css"},{"name":"--space-4","value":"8px","kind":"spacing","definedIn":"tokens/spacing.css"},{"name":"--space-5","value":"9px","kind":"spacing","definedIn":"tokens/spacing.css"},{"name":"--space-6","value":"10px","kind":"spacing","definedIn":"tokens/spacing.css"},{"name":"--space-7","value":"11px","kind":"spacing","definedIn":"tokens/spacing.css"},{"name":"--space-8","value":"12px","kind":"spacing","definedIn":"tokens/spacing.css"},{"name":"--space-9","value":"14px","kind":"spacing","definedIn":"tokens/spacing.css"},{"name":"--space-10","value":"16px","kind":"spacing","definedIn":"tokens/spacing.css"},{"name":"--space-11","value":"18px","kind":"spacing","definedIn":"tokens/spacing.css"},{"name":"--space-12","value":"20px","kind":"spacing","definedIn":"tokens/spacing.css"},{"name":"--space-13","value":"22px","kind":"spacing","definedIn":"tokens/spacing.css"},{"name":"--space-14","value":"24px","kind":"spacing","definedIn":"tokens/spacing.css"},{"name":"--space-15","value":"28px","kind":"spacing","definedIn":"tokens/spacing.css"},{"name":"--space-16","value":"32px","kind":"spacing","definedIn":"tokens/spacing.css"},{"name":"--space-17","value":"36px","kind":"spacing","definedIn":"tokens/spacing.css"},{"name":"--h-control-sm","value":"30px","kind":"spacing","definedIn":"tokens/spacing.css"},{"name":"--h-control-md","value":"34px","kind":"spacing","definedIn":"tokens/spacing.css"},{"name":"--h-control","value":"36px","kind":"spacing","definedIn":"tokens/spacing.css"},{"name":"--h-control-lg","value":"42px","kind":"spacing","definedIn":"tokens/spacing.css"},{"name":"--h-control-xl","value":"44px","kind":"spacing","definedIn":"tokens/spacing.css"},{"name":"--h-control-2xl","value":"48px","kind":"spacing","definedIn":"tokens/spacing.css"},{"name":"--h-topbar","value":"56px","kind":"spacing","definedIn":"tokens/spacing.css"},{"name":"--sidebar-width","value":"248px","kind":"spacing","definedIn":"tokens/spacing.css"},{"name":"--content-max","value":"1340px","kind":"spacing","definedIn":"tokens/spacing.css"},{"name":"--radius-xs","value":"5px","kind":"radius","definedIn":"tokens/effects.css"},{"name":"--radius-sm","value":"7px","kind":"radius","definedIn":"tokens/effects.css"},{"name":"--radius-md","value":"8px","kind":"radius","definedIn":"tokens/effects.css"},{"name":"--radius-lg","value":"9px","kind":"radius","definedIn":"tokens/effects.css"},{"name":"--radius-xl","value":"10px","kind":"radius","definedIn":"tokens/effects.css"},{"name":"--radius-2xl","value":"12px","kind":"radius","definedIn":"tokens/effects.css"},{"name":"--radius-3xl","value":"13px","kind":"radius","definedIn":"tokens/effects.css"},{"name":"--radius-4xl","value":"14px","kind":"radius","definedIn":"tokens/effects.css"},{"name":"--radius-pill","value":"999px","kind":"radius","definedIn":"tokens/effects.css"},{"name":"--radius-circle","value":"50%","kind":"radius","definedIn":"tokens/effects.css"},{"name":"--shadow-xs","value":"0 1px 2px rgba(0, 0, 0, 0.03)","kind":"shadow","definedIn":"tokens/effects.css"},{"name":"--shadow-sm","value":"0 1px 3px rgba(0, 0, 0, 0.04)","kind":"shadow","definedIn":"tokens/effects.css"},{"name":"--shadow-md","value":"0 2px 12px rgba(0, 0, 0, 0.05)","kind":"shadow","definedIn":"tokens/effects.css"},{"name":"--shadow-lg","value":"0 6px 20px rgba(0, 0, 0, 0.12)","kind":"shadow","definedIn":"tokens/effects.css"},{"name":"--shadow-sticky","value":"0 -4px 16px rgba(0, 0, 0, 0.04)","kind":"shadow","definedIn":"tokens/effects.css"},{"name":"--shadow-primary","value":"0 1px 2px rgba(22, 119, 255, 0.3)","kind":"shadow","definedIn":"tokens/effects.css"},{"name":"--ease-standard","value":"cubic-bezier(0.4, 0, 0.2, 1)","kind":"other","definedIn":"tokens/effects.css","annotation":"other"},{"name":"--duration-fast","value":"0.15s","kind":"other","definedIn":"tokens/effects.css","annotation":"other"},{"name":"--duration-base","value":"0.2s","kind":"other","definedIn":"tokens/effects.css","annotation":"other"}],"themes":[],"fonts":[],"brandFonts":[],"source":"spa"} \ No newline at end of file diff --git a/design/baseline/# UI для онлайн звонка/_ds/edevs-hub-design-system-e4c9df12-82c8-42b3-b819-63d92dfb8702/readme.md b/design/baseline/# UI для онлайн звонка/_ds/edevs-hub-design-system-e4c9df12-82c8-42b3-b819-63d92dfb8702/readme.md new file mode 100644 index 0000000..6a6b2a3 --- /dev/null +++ b/design/baseline/# UI для онлайн звонка/_ds/edevs-hub-design-system-e4c9df12-82c8-42b3-b819-63d92dfb8702/readme.md @@ -0,0 +1,216 @@ +# 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/baseline/# UI для онлайн звонка/_ds/edevs-hub-design-system-e4c9df12-82c8-42b3-b819-63d92dfb8702/styles.css b/design/baseline/# UI для онлайн звонка/_ds/edevs-hub-design-system-e4c9df12-82c8-42b3-b819-63d92dfb8702/styles.css new file mode 100644 index 0000000..0cd17b1 --- /dev/null +++ b/design/baseline/# UI для онлайн звонка/_ds/edevs-hub-design-system-e4c9df12-82c8-42b3-b819-63d92dfb8702/styles.css @@ -0,0 +1,4 @@ +@import "./tokens/colors.css"; +@import "./tokens/typography.css"; +@import "./tokens/spacing.css"; +@import "./tokens/effects.css"; diff --git a/design/baseline/# UI для онлайн звонка/_ds/edevs-hub-design-system-e4c9df12-82c8-42b3-b819-63d92dfb8702/tokens/colors.css b/design/baseline/# UI для онлайн звонка/_ds/edevs-hub-design-system-e4c9df12-82c8-42b3-b819-63d92dfb8702/tokens/colors.css new file mode 100644 index 0000000..e20679d --- /dev/null +++ b/design/baseline/# UI для онлайн звонка/_ds/edevs-hub-design-system-e4c9df12-82c8-42b3-b819-63d92dfb8702/tokens/colors.css @@ -0,0 +1,88 @@ +/* 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/baseline/# UI для онлайн звонка/_ds/edevs-hub-design-system-e4c9df12-82c8-42b3-b819-63d92dfb8702/tokens/effects.css b/design/baseline/# UI для онлайн звонка/_ds/edevs-hub-design-system-e4c9df12-82c8-42b3-b819-63d92dfb8702/tokens/effects.css new file mode 100644 index 0000000..4fa2bb5 --- /dev/null +++ b/design/baseline/# UI для онлайн звонка/_ds/edevs-hub-design-system-e4c9df12-82c8-42b3-b819-63d92dfb8702/tokens/effects.css @@ -0,0 +1,40 @@ +/* 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/baseline/# UI для онлайн звонка/_ds/edevs-hub-design-system-e4c9df12-82c8-42b3-b819-63d92dfb8702/tokens/spacing.css b/design/baseline/# UI для онлайн звонка/_ds/edevs-hub-design-system-e4c9df12-82c8-42b3-b819-63d92dfb8702/tokens/spacing.css new file mode 100644 index 0000000..5c15ebc --- /dev/null +++ b/design/baseline/# UI для онлайн звонка/_ds/edevs-hub-design-system-e4c9df12-82c8-42b3-b819-63d92dfb8702/tokens/spacing.css @@ -0,0 +1,36 @@ +/* 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/baseline/# UI для онлайн звонка/_ds/edevs-hub-design-system-e4c9df12-82c8-42b3-b819-63d92dfb8702/tokens/typography.css b/design/baseline/# UI для онлайн звонка/_ds/edevs-hub-design-system-e4c9df12-82c8-42b3-b819-63d92dfb8702/tokens/typography.css new file mode 100644 index 0000000..0d43222 --- /dev/null +++ b/design/baseline/# UI для онлайн звонка/_ds/edevs-hub-design-system-e4c9df12-82c8-42b3-b819-63d92dfb8702/tokens/typography.css @@ -0,0 +1,42 @@ +/* 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/baseline/# UI для онлайн звонка/image-slot.js b/design/baseline/# UI для онлайн звонка/image-slot.js new file mode 100644 index 0000000..f27a9ba --- /dev/null +++ b/design/baseline/# UI для онлайн звонка/image-slot.js @@ -0,0 +1,1197 @@ +// @ds-adherence-ignore -- omelette starter scaffold (raw elements/hex/px by design) +// Copied omelette starter. Re-running copy_starter_component with this kind overwrites this file with the latest version (page content is unaffected). +/* BEGIN USAGE */ +/** + * — user-fillable image placeholder. + * + * Drop this into a deck, mockup, or page wherever a design needs an image. + * You control the slot's shape; it sizes to its container by default. When the search_stock_photos tool + * is available, prefill the slot by default — write the photo's URL into + * src (with credit/credit-href); the user can still fill or replace it + * by dragging an image file onto it (or clicking to browse). The dropped + * image persists across reloads via a .image-slots.state.json sidecar — + * same read-via-fetch / write-via-window.omelette pattern as + * design_canvas.jsx, so the filled slot shows on share links, downloaded + * zips, and PPTX export. Outside the omelette runtime the slot is read-only. + * + * The sidecar is a SIBLING of the HTML file that uses this component: the + * read is a document-relative fetch, and the host resolves the bridge's + * sidecar writes into the previewed file's directory to match (same + * contract as design_canvas.jsx). Pages in the same directory share one + * sidecar; keep slot ids distinct across them. + * + * Attributes: + * id Persistence key. REQUIRED for the drop to survive reload — + * every slot on the page needs a distinct id. + * shape 'rect' | 'rounded' | 'circle' | 'pill' (default 'rounded') + * 'circle' applies 50% border-radius; on a non-square slot + * that's an ellipse — set equal width and height for a true + * circle. + * radius Corner radius in px for 'rounded'. (default 12) + * mask Any CSS clip-path value. Overrides `shape` — use this for + * hexagons, blobs, arbitrary polygons. + * fit Initial framing baseline: cover | contain. (default 'cover') + * cover starts the image filling the frame (overflow cropped); + * contain starts it fully visible (letterboxed). Either way the + * user can always pan/scale from there — double-click, or the + * Edit control, enters reframe mode (drag to move, scroll or + * corner-handles to scale; Escape / click-out commits). The + * crop persists alongside the image in the sidecar. + * placeholder Empty-state caption. (default 'Drop an image') + * src Optional initial/fallback image URL. Prefill it with a real + * photo via search_stock_photos when that tool is available + * (set credit/credit-href from the result). A user drop + * overrides it; clearing the drop reveals src again. + * credit Attribution text shown as a small overlay at the + * bottom-left of the filled slot. REQUIRED whenever src + * points at any Unsplash host (images.unsplash.com, + * plus.unsplash.com, …): an Unsplash src with no credit + * renders an error tile INSTEAD of the photo (Unsplash + * terms forbid showing their photos unattributed). Use the + * exact form 'Photo by {photographer name} on Unsplash' — + * the overlay then links the name to credit-href and + * 'Unsplash' to the Unsplash homepage, and links back to + * unsplash.com automatically get the required utm referral + * params appended at render time. The credit belongs to + * the src image, so it only shows while src is what's + * displayed — a user-dropped image hides it. + * credit-href Link for the photographer's name in the credit overlay + * (their Unsplash profile URL from the stock-photo search + * results). http(s) URLs only — anything else renders the + * name as plain text. + * + * Sizing: the slot fills its container by default (width/height 100%). + * Put it in a sized wrapper — absolutely positioned, a grid cell, a fixed + * frame — and it takes exactly that box. When the parent's height is + * indefinite (ordinary flow), it falls back to full width at a 3:2 aspect + * ratio instead of collapsing. In a shrink-to-fit parent (a float, + * width:max-content, an unsized absolute wrapper), percentages have + * nothing to resolve against — size the slot or its wrapper explicitly + * there. For a fixed-size slot, set + * width/height on the element itself (inline style), which overrides the + * default. When + * layering content above a slot (full-bleed layouts), make the overlay + * click-through — pointer-events: none on scrims/text plates, re-enabled + * on interactive children — so the slot's hover controls stay reachable. + * Keep the slot's bottom-left corner visually clear as well: the credit + * overlay renders there, and a dark fade or text plate covering it hides + * the attribution Unsplash's terms require — end the fade above that + * corner, or keep it nearly transparent where the credit sits. + * + * Usage: + * + * + * + * + * + * + */ +/* END USAGE */ + +(() => { + const STATE_FILE = '.image-slots.state.json'; + + // Unsplash terms require visible attribution wherever their photos + // display, and every link back to unsplash.com must carry utm referral + // params. Two render-time rules enforce that here: + // - an Unsplash-src slot with NO credit attribute renders an error + // tile INSTEAD of the photo (an uncredited Unsplash photo on screen + // is itself the terms violation, so it never renders bare); + // - rendered credit links pointing at unsplash.com get the referral + // params appended when absent (credit-href values live in page + // content that can't be edited after the fact). + // Keep the utm_source value in sync with UTM_SOURCE in + // platform/web-agent/unsplash.ts — this file is a project-local + // artifact and cannot import it (equality is pinned by tests). + const UNSPLASH_HOMEPAGE_HREF = + 'https://unsplash.com/?utm_source=claude_design&utm_medium=referral'; + // Host rule mirrors the hotlink validator that admits Unsplash srcs into + // pages in the first place (cdn$ in unsplash.ts: apex or any subdomain) + // — Unsplash+ results serve from plus.unsplash.com, not just images.*, + // and an admitted-but-uncredited photo must error whatever unsplash + // host it rides on. + // Trailing-dot FQDNs (images.unsplash.com.) are the same host to the + // browser but would miss the regex — strip one dot so the check fails + // CLOSED (unrecognized-but-real Unsplash srcs must error, not render). + const isUnsplashHost = (u) => { + try { + return /(^|\.)unsplash\.com$/.test( + new URL(u, document.baseURI).hostname.replace(/\.$/, '') + ); + } catch { + return false; + } + }; + // Render-time referral normalization for links back to Unsplash: + // appends utm_source/utm_medium when absent, preserves every existing + // query param, never overwrites an existing utm_source, and passes + // non-Unsplash URLs through untouched. Input is an ABSOLUTE validated + // http(s) URL (the credit render funnel resolves + validates first). + const withReferral = (href) => { + try { + const u = new URL(href); + if (!/(^|\.)unsplash\.com$/.test(u.hostname.replace(/\.$/, ''))) { + return href; + } + if (!u.searchParams.has('utm_source')) { + u.searchParams.set('utm_source', 'claude_design'); + } + if (!u.searchParams.has('utm_medium')) { + u.searchParams.set('utm_medium', 'referral'); + } + return u.toString(); + } catch (e) { + return href; + } + }; + // 2× a ~600px slot in a 1920-wide deck — retina-sharp without making the + // sidecar enormous. A 1200px WebP at q=0.85 is ~150-300KB. + const MAX_DIM = 1200; + // Raster formats only. SVG is excluded (can carry script; createImageBitmap + // on SVG blobs is inconsistent). GIF is excluded because the canvas + // re-encode keeps only the first frame, so an animated GIF would silently + // go still — better to reject than surprise. + const ACCEPT = ['image/png', 'image/jpeg', 'image/webp', 'image/avif']; + + // ── Shared sidecar store ──────────────────────────────────────────────── + // One fetch + immediate write-on-change for every on the + // page. Reads via fetch() so viewing works anywhere the HTML and sidecar + // are served together; writes go through window.omelette.writeFile, which + // the host allowlists to *.state.json basenames only. + const subs = new Set(); + let slots = {}; + // ids explicitly cleared before the sidecar fetch resolved — otherwise + // the merge below can't tell "never set" from "just deleted" and would + // resurrect the sidecar's stale value. + const tombstones = new Set(); + let loaded = false; + let loadP = null; + + function load() { + if (loadP) return loadP; + loadP = fetch(STATE_FILE) + .then((r) => (r.ok ? r.json() : null)) + .then((j) => { + // Merge: sidecar loses to any in-memory change that raced ahead of + // the fetch (drop or clear) so neither is clobbered by hydration. + if (j && typeof j === 'object') { + const merged = Object.assign({}, j, slots); + // A framing-only write that raced ahead of hydration must not + // drop a user image that's only on disk — inherit u from the + // sidecar for any in-memory entry that lacks one. + for (const k in slots) { + if (merged[k] && !merged[k].u && j[k]) { + merged[k].u = typeof j[k] === 'string' ? j[k] : j[k].u; + } + } + for (const id of tombstones) delete merged[id]; + slots = merged; + } + tombstones.clear(); + }) + .catch(() => {}) + .then(() => { loaded = true; subs.forEach((fn) => fn()); }); + return loadP; + } + + // Serialize writes so two near-simultaneous drops on different slots + // can't reorder at the backend and leave the sidecar with only the + // first. A save requested mid-flight just marks dirty and re-fires on + // completion with the then-current slots. + let saving = false; + let saveDirty = false; + // Unload-time flush: save()'s serialization defers a mid-RTT re-fire to a + // .then that never runs in an unloading document, silently dropping a + // pagehide commit. Post the current slots immediately instead — content + // is a superset snapshot of any in-flight save's, the write is a + // whole-file last-writer-wins replace, and postMessage FIFO delivers it + // to the host after the in-flight one, so a backend-side reorder at + // worst reproduces the dropped-commit outcome this flush improves on. + // Guarded on the initial sidecar read: pre-hydration slots can miss + // other slots' persisted entries, and flushing it would clobber them — + // that narrow case stays best-effort (the in-memory merge in load() + // cannot happen in an unloading document anyway). + function flushNow() { + if (!loaded) return; + const w = window.omelette && window.omelette.writeFile; + if (!w) return; + try { Promise.resolve(w(STATE_FILE, JSON.stringify(slots))).catch(() => {}); } catch (e) {} + } + function save() { + if (saving) { saveDirty = true; return; } + const w = window.omelette && window.omelette.writeFile; + if (!w) return; + saving = true; + Promise.resolve(w(STATE_FILE, JSON.stringify(slots))) + .catch(() => {}) + .then(() => { saving = false; if (saveDirty) { saveDirty = false; save(); } }); + } + + const S_MAX = 5; + const clampS = (s) => Math.max(1, Math.min(S_MAX, s)); + + // Normalize a stored slot value. Pre-reframe sidecars stored a bare + // data-URL string; newer ones store {u, s, x, y}. Either shape is valid. + function getSlot(id) { + const v = slots[id]; + if (!v) return null; + return typeof v === 'string' ? { u: v, s: 1, x: 0, y: 0 } : v; + } + + function setSlot(id, val) { + if (!id) return; + if (val) { slots[id] = val; tombstones.delete(id); } + else { delete slots[id]; if (!loaded) tombstones.add(id); } + subs.forEach((fn) => fn()); + // A drop is rare + high-value — write immediately so nav-away can't lose + // it. Gate on the initial read so we don't overwrite a sidecar we haven't + // merged yet; the merge in load() keeps this change once the read lands. + if (loaded) save(); else load().then(save); + } + + // ── Image downscale ───────────────────────────────────────────────────── + // Encode through a canvas so the sidecar carries resized bytes, not the + // raw upload. Longest side is capped at 2× the slot's rendered width + // (retina) and at MAX_DIM. WebP keeps alpha and is ~10× smaller than PNG + // for photos, so there's no need for per-image format picking. + async function toDataUrl(file, targetW) { + const bitmap = await createImageBitmap(file); + try { + const cap = Math.min(MAX_DIM, Math.max(1, Math.round(targetW * 2)) || MAX_DIM); + const scale = Math.min(1, cap / Math.max(bitmap.width, bitmap.height)); + const w = Math.max(1, Math.round(bitmap.width * scale)); + const h = Math.max(1, Math.round(bitmap.height * scale)); + const canvas = document.createElement('canvas'); + canvas.width = w; canvas.height = h; + canvas.getContext('2d').drawImage(bitmap, 0, 0, w, h); + return canvas.toDataURL('image/webp', 0.85); + } finally { + bitmap.close && bitmap.close(); + } + } + + // ── Custom element ────────────────────────────────────────────────────── + const stylesheet = + // Fill the container by default: slots are usually placed inside a + // sized wrapper (a hero frame, a grid cell, an inset:0 layer) and are + // expected to take that box — a fixed intrinsic size would render as + // a small tile in the corner of a full-bleed wrapper instead. + // aspect-ratio is the companion fallback that keeps a bare slot + // visible when the parent's height is indefinite: height:100% + // resolves to auto there, and the ratio then derives height from + // width instead of letting the slot collapse to zero height. + // Explicit width/height on the element override all of this. + ':host{display:block;position:relative;' + + ' font:13px/1.3 system-ui,-apple-system,sans-serif;color:rgba(0,0,0,.55);' + + ' width:100%;height:100%;aspect-ratio:3/2}' + + '.frame{position:absolute;inset:0;overflow:hidden;background:rgba(0,0,0,.04)}' + + // .frame img (clipped) and .spill (unclipped ghost + handles) share the + // same left/top/width/height in frame-%, computed by _applyView(), so the + // inside-mask crop and the outside-mask spill stay pixel-aligned. + '.frame img{position:absolute;max-width:none;transform:translate(-50%,-50%);' + + ' -webkit-user-drag:none;user-select:none;touch-action:none}' + + // Reframe mode (double-click): the full image spills past the mask. The + // spill layer is sized to the IMAGE bounds so its corners are where the + // resize handles belong. The ghost inside is translucent; the real + // clipped underneath shows the opaque in-mask crop. + // popover=manual promotes the spill to the top layer on reframe, so it is + // not clipped by any overflow:hidden / clip-path / scroll-container + // ancestor (a plain z-index can't escape overflow clipping). UA popover + // defaults (inset:0;margin:auto) are reset; _applyView sets viewport px. + '.spill{position:fixed;margin:0;inset:auto;border:0;padding:0;background:transparent;' + + ' overflow:visible;transform:translate(-50%,-50%);z-index:1;cursor:grab;touch-action:none}' + + ':host([data-panning]) .spill{cursor:grabbing}' + + '.spill .ghost{position:absolute;inset:0;width:100%;height:100%;opacity:.35;' + + ' pointer-events:none;-webkit-user-drag:none;user-select:none;' + + ' box-shadow:0 0 0 1px rgba(0,0,0,.2),0 12px 32px rgba(0,0,0,.2)}' + + '.spill .handle{position:absolute;width:12px;height:12px;border-radius:50%;' + + ' background:#fff;box-shadow:0 0 0 1.5px #c96442,0 1px 3px rgba(0,0,0,.3);' + + ' transform:translate(-50%,-50%)}' + + '.spill .handle[data-c=nw]{left:0;top:0;cursor:nwse-resize}' + + '.spill .handle[data-c=ne]{left:100%;top:0;cursor:nesw-resize}' + + '.spill .handle[data-c=sw]{left:0;top:100%;cursor:nesw-resize}' + + '.spill .handle[data-c=se]{left:100%;top:100%;cursor:nwse-resize}' + + ':host([data-reframe]){z-index:10}' + + ':host([data-reframe]) .frame{box-shadow:0 0 0 2px #c96442}' + + '.empty{position:absolute;inset:0;display:flex;flex-direction:column;align-items:center;' + + ' justify-content:center;gap:6px;text-align:center;padding:12px;box-sizing:border-box;' + + ' cursor:pointer;user-select:none}' + + '.empty svg{opacity:.45}' + + '.empty .cap{max-width:90%;font-weight:500;letter-spacing:.01em}' + + '.empty .sub{font-size:11px}' + + '.empty .sub u{text-underline-offset:2px;text-decoration-color:rgba(0,0,0,.25)}' + + '.empty:hover .sub u{color:rgba(0,0,0,.75);text-decoration-color:currentColor}' + + ':host([data-over]) .frame{outline:2px solid #c96442;outline-offset:-2px;' + + ' background:rgba(201,100,66,.10)}' + + '.ring{position:absolute;inset:0;pointer-events:none;border:1.5px dashed rgba(0,0,0,.25);' + + ' transition:border-color .12s}' + + ':host([data-over]) .ring{border-color:#c96442}' + + ':host([data-filled]) .ring{display:none}' + + // Controls overlay INSIDE the frame, pinned to the top-right corner, so + // a full-bleed slot in an overflow:hidden container still shows them + // (the old below-mask placement got clipped). Credit sits bottom-left, + // so top-right avoids collision. The blurred pill background keeps them + // legible over the image. + // The UA [popover] base rule styles the element in EVERY state (only + // display:none is gated on :not(:popover-open), and the display:flex + // below overrides that) — so the UA resets live HERE, like .spill's, + // or the ordinary hover-state strip renders as a bordered Canvas box + // centered by margin:auto. inset:auto precedes top/right (shorthand). + '.ctl{position:absolute;inset:auto;top:8px;right:8px;margin:0;border:0;padding:0;' + + ' background:transparent;overflow:visible;' + + ' display:flex;gap:6px;opacity:0;pointer-events:none;transition:opacity .12s;z-index:2;' + + ' white-space:nowrap}' + + // While reframing, the spill owns the top layer and would swallow every + // click on the in-frame controls. Promoting .ctl into the top layer + // ABOVE the spill (shown after it — later popovers stack higher) keeps + // Edit-as-toggle and Replace clickable mid-reframe. _applyView pins it + // to the frame's top-right in viewport px (translateX(-100%) + // right-aligns against the computed left edge); inset:auto clears the + // base rule's top/right so the inline left/top position it alone. + '.ctl:popover-open{position:fixed;inset:auto;transform:translateX(-100%)}' + + ':host([data-filled][data-editable]:hover) .ctl,:host([data-reframe]) .ctl' + + ' {opacity:1;pointer-events:auto}' + + '.ctl button{appearance:none;border:0;border-radius:6px;padding:5px 10px;cursor:pointer;' + + ' background:rgba(0,0,0,.65);color:#fff;font:11px/1 system-ui,-apple-system,sans-serif;' + + ' backdrop-filter:blur(6px)}' + + '.ctl button:hover{background:rgba(0,0,0,.8)}' + + '.err{position:absolute;left:8px;bottom:8px;right:8px;color:#b3261e;font-size:11px;' + + ' background:rgba(255,255,255,.85);padding:4px 6px;border-radius:5px;pointer-events:none}' + + // Replacement in flight: after a src swap the browser keeps painting + // the PREVIOUS image until the new one decodes, so a Replace would + // flash the old photo and then pop. Hide the stale frame (visibility, + // not display — _applyView geometry still applies) and spin until the + // new image reports in (load/error clears data-swapping). + ':host([data-swapping]) .frame img{visibility:hidden}' + + '.loading{position:absolute;inset:0;display:none;align-items:center;' + + ' justify-content:center;pointer-events:none}' + + ':host([data-swapping]) .loading{display:flex}' + + '.loading::after{content:"";width:22px;height:22px;border-radius:50%;' + + ' border:2px solid rgba(0,0,0,.12);border-top-color:rgba(0,0,0,.45);' + + ' animation:om-slot-spin .7s linear infinite}' + + '@keyframes om-slot-spin{to{transform:rotate(360deg)}}' + + // Reduced motion: the static two-tone ring still reads as "working". + '@media (prefers-reduced-motion:reduce){.loading::after{animation:none}}' + + '.credit{position:absolute;left:6px;bottom:6px;max-width:calc(100% - 12px);display:none;' + + ' padding:3px 7px;border-radius:5px;background:rgba(0,0,0,.55);color:#fff;' + + ' font:10px/1.2 system-ui,-apple-system,sans-serif;text-decoration:none;' + + ' white-space:nowrap;overflow:hidden;text-overflow:ellipsis;backdrop-filter:blur(6px)}' + + // The credit is a SPAN holding one or two s (Unsplash's prescribed + // form links the photographer AND Unsplash) — anchors style inline so + // the overlay reads as one line of text. + '.credit a{color:inherit;text-decoration:none}' + + '.credit a:hover,.credit a:focus-visible{text-decoration:underline}' + + ':host([data-filled][data-credit]) .credit{display:block}' + + // Exports must ship JUST the image — no hover controls, no credit chip + // (the host marks for the capture window; the + // page-level hide script can't reach shadow DOM, this rule can). + ':host-context([data-om-exporting]) .ctl,' + + ':host-context([data-om-exporting]) .credit{display:none !important}' + + // No export-window mask rules here on purpose: the export capture + // releases the replacement mask by REMOVING data-swapping (the + // shadow-root pass in pages/export/shared.ts HIDE_EXPORT_CHROME_SCRIPT) + // — attribute removal works in every engine (:host-context is + // Chromium-only), is scoped by construction to slots actually + // mid-swap, and hides the spinner through the same gate. A masked img + // would otherwise be silently dropped from PPTX decks (the capture + // walk skips visibility:hidden imgs). + // Attribution error tile: REPLACES the photo when an Unsplash src has + // no credit attribute — rendering the photo uncredited is the terms + // violation, so the photo must not appear at all. + // Calm and neutral on purpose (review feedback): the tile informs the + // user; the fix instructions are machine-facing (usage docblock, tool + // description, and the turn-end scan's bounce copy name the attributes + // for the agent). + '.attr-error{position:absolute;inset:0;display:none;flex-direction:column;align-items:center;' + + ' justify-content:center;gap:6px;text-align:center;padding:12px;box-sizing:border-box;' + + ' background:#f2f1ef;color:#6e6c66;user-select:none;' + + ' font:13px/1.45 system-ui,-apple-system,sans-serif}' + + '.attr-error svg{opacity:.55}' + + '.attr-error .cap{max-width:92%;font-weight:500;letter-spacing:.01em}' + + ':host([data-attribution-error]) .attr-error{display:flex}' + + ':host([data-attribution-error]) .ring{display:none}'; + + const icon = + '' + + '' + + ''; + + const warnIcon = + '' + + '' + + ''; + + class ImageSlot extends HTMLElement { + static get observedAttributes() { + return ['shape', 'radius', 'mask', 'fit', 'placeholder', 'src', 'id', 'credit', 'credit-href']; + } + + /** Duplicate-slide hook (called by deck-stage, see its + * _remintDuplicateIds): copy this id's stored image, if any, under a + * freshly minted key and return that key — so a duplicated slide's + * slot keeps its dropped photo instead of reverting to the + * placeholder. 'isFree' is the caller's uniqueness check (document + * ids); candidates must ALSO be unused in the sidecar, which can + * hold keys from other pages sharing the project root. (An EMPTY + * slot on another page leaves no sidecar entry, so its id is not + * detectable here — a minted key can collide with it and that slot + * would show this photo. Same blast radius as two pages reusing an + * id by hand, which the shared sidecar already permits.) Returns null + * when no id could be minted (caller strips the id, today's + * behavior). */ + static cloneSlot(fromId, isFree) { + if (typeof fromId !== 'string' || !fromId) return null; + // Pre-hydration the store can't veto candidates or source the copy + // — degrade to the strip (today's behavior) rather than mint + // against keys we can't see yet. Any rendered (= droppable) slot + // means load() has already settled. + if (!loaded) return null; + const stem = fromId.replace(/-\d+$/, '') || fromId; + for (let n = 2; n < 100; n++) { + const toId = stem + '-' + n; + if (toId === fromId) continue; + if (slots[toId] !== undefined) { + // Reuse a key holding this exact value (bytes AND crop) if no + // live element here owns it — a duplicate op the host refused + // after minting leaves such a key behind, and reusing keeps + // refused retries from accumulating one orphaned copy per + // attempt. Full equality (not just bytes) so a byte-identical + // key another PAGE owns with its own crop is stepped past, not + // adopted or rewritten. (Entries without .u never match.) + const prev = getSlot(toId); + const cur = getSlot(fromId); + if (!(prev && cur && prev.u && prev.u === cur.u && + prev.s === cur.s && prev.x === cur.x && prev.y === cur.y && + (typeof isFree !== 'function' || isFree(toId)))) continue; + return toId; + } + if (typeof isFree === 'function' && !isFree(toId)) continue; + const v = getSlot(fromId); + if (v) setSlot(toId, Object.assign({}, v)); + return toId; + } + return null; + } + + constructor() { + super(); + // clonable: rail thumbnails deep-clone slides and carry this shadow + // along; reuse an already-cloned root so upgrade-after-clone works. + // (Deliberately NOT serializable — a getHTML consumer would embed + // multi-MB sidecar data-URLs into serialized page HTML.) + const root = this.shadowRoot || + this.attachShadow({ mode: 'open', clonable: true }); + // .spill and .ctl sit OUTSIDE .frame so overflow:hidden + border-radius + // on the frame (circle, pill, rounded) can't clip them. + root.innerHTML = + '' + + '' + + ' ' + + ' ' + icon + + ' ' + + ' or browse files' + + ' ' + warnIcon + + ' This photo needs attribution' + + ' ' + + ' ' + + '' + + // Outside .frame, like .spill/.ctl — the frame's overflow:hidden + + // border-radius/clip-path would cut the credit off on circle/pill/mask. + // A SPAN, not an : the prescribed Unsplash credit holds two links + // (photographer + Unsplash), built per-render in _render(). + '' + + '' + + ' ' + + ' ' + + ' ' + + '' + + // data-dc-edit-transparent: the DC editor's edit-mode picker lets + // clicks through for chrome marked with it (EDIT_TRANSPARENT_SEL) + // — without it, Replace/Edit clicks in Edit mode are swallowed by + // element selection and the controls look dead. + 'Replace' + + ' Edit' + + ''; + this._frame = root.querySelector('.frame'); + this._ring = root.querySelector('.ring'); + this._img = root.querySelector('.frame img'); + this._empty = root.querySelector('.empty'); + this._cap = root.querySelector('.cap'); + this._sub = root.querySelector('.sub'); + this._spill = root.querySelector('.spill'); + this._ctl = root.querySelector('.ctl'); + this._credit = root.querySelector('.credit'); + this._attrError = root.querySelector('.attr-error'); + // Credit clicks open the link, not browse/reframe. + this._credit.addEventListener('click', (e) => e.stopPropagation()); + this._credit.addEventListener('dblclick', (e) => e.stopPropagation()); + this._ghost = root.querySelector('.ghost'); + this._err = null; + this._input = root.querySelector('input'); + this._depth = 0; + this._gen = 0; + // Encode-in-flight marker (the owning _ingest generation): while set, + // the same-src "nothing in flight" clear in _render must not fire — + // the stored value still points at the OLD image until the encode + // lands, so that clear would unmask the stale image mid-replace. + this._swapGen = 0; + // Render-owned swap in flight: set when _render assigns a new src, + // cleared only by the img's own load/error (or the empty branch). + // img.complete CANNOT stand in for this — setting src only QUEUES + // the current-request swap (a microtask), so synchronously after an + // assignment, complete still reports the OLD settled request. The + // pick path does exactly that: the host sets src, credit, and + // credit-href back-to-back in one task, and renders #2/#3 would + // read the stale complete === true and drop the mask one render + // after it was set. + this._loadPending = false; + // See _render's empty branch: a transient attribution-error wipe of a + // showing image must make the follow-up render a replacement (spinner), + // not a first fill (blank frame). + this._hidShowing = false; + this._view = { s: 1, x: 0, y: 0 }; + this._subFn = () => this._render(); + // Shadow-DOM listeners live with the shadow DOM — bound once here so + // disconnect/reconnect (e.g. React remount) doesn't stack handlers. + this._empty.addEventListener('click', () => this._input.click()); + root.addEventListener('click', (e) => { + const act = e.target && e.target.getAttribute && e.target.getAttribute('data-act'); + if (!act) return; + // The hidden controls are opacity-0 but still tabbable — without + // this gate a keyboard user could drive them on a read-only share + // link (mirrors the dblclick handler's editable gate). + if (!this.hasAttribute('data-editable')) return; + if (act === 'replace') { + this._exitReframe(true); + // Host-owned picker (Unsplash modal; it also offers local import). + this.dispatchEvent(new CustomEvent('image-slot:pick', { + bubbles: true, composed: true, detail: { id: this.id || null } + })); + } + if (act === 'edit') { + if (!this._reframes()) return; + if (this.hasAttribute('data-reframe')) this._exitReframe(true); + else this._enterReframe(); + } + }); + this._input.addEventListener('change', () => { + const f = this._input.files && this._input.files[0]; + if (f) this._ingest(f); + this._input.value = ''; + }); + // naturalWidth/Height aren't known until load — re-apply so the cover + // baseline is computed from real dimensions, not the 100%×100% fallback. + // load/error also release the replacement-in-flight mask (via the + // single discipline in _releaseMask): the swap is only revealed once + // the new image can actually paint (on error the frame shows its + // background, same as a fresh slot with a broken src). + this._img.addEventListener('load', () => { + this._loadPending = false; + this._releaseMask(true); + this._applyView(); + }); + this._img.addEventListener('error', () => { + this._loadPending = false; + this._releaseMask(true); + }); + // Gated only on editable — any filled slot can be repositioned/scaled, + // regardless of fit. Share links (no writeFile) stay static. + this.addEventListener('dblclick', (e) => { + if (!this.hasAttribute('data-editable') || !this._reframes()) return; + e.preventDefault(); + if (this.hasAttribute('data-reframe')) this._exitReframe(true); + else this._enterReframe(); + }); + // Pan + resize both originate on the spill layer. A handle pointerdown + // drives an aspect-locked resize anchored at the opposite corner; any + // other pointerdown on the spill pans. Offsets are frame-% so a + // reframed slot survives responsive resize / PPTX export. + this._spill.addEventListener('pointerdown', (e) => { + if (e.button !== 0 || !this.hasAttribute('data-reframe')) return; + e.preventDefault(); + e.stopPropagation(); + this._spill.setPointerCapture(e.pointerId); + const rect = this.getBoundingClientRect(); + const fw = rect.width || 1, fh = rect.height || 1; + const corner = e.target.getAttribute && e.target.getAttribute('data-c'); + let move; + if (corner) { + // Resize about the OPPOSITE corner. Viewport-px throughout (rect + // fw/fh, not clientWidth) so the math survives a transform:scale() + // ancestor — deck_stage renders slides scaled-to-fit. + const iw = this._img.naturalWidth || 1, ih = this._img.naturalHeight || 1; + const contain = (this.getAttribute('fit') || 'cover').toLowerCase() === 'contain'; + const base = contain ? Math.min(fw / iw, fh / ih) : Math.max(fw / iw, fh / ih); + const sx = corner.includes('e') ? 1 : -1; + const sy = corner.includes('s') ? 1 : -1; + const s0 = this._view.s; + const w0 = iw * base * s0, h0 = ih * base * s0; + const cx0 = (50 + this._view.x) / 100 * fw; + const cy0 = (50 + this._view.y) / 100 * fh; + const ox = cx0 - sx * w0 / 2, oy = cy0 - sy * h0 / 2; + const diag0 = Math.hypot(w0, h0); + const ux = sx * w0 / diag0, uy = sy * h0 / diag0; + move = (ev) => { + const proj = (ev.clientX - rect.left - ox) * ux + + (ev.clientY - rect.top - oy) * uy; + const s = clampS(s0 * proj / diag0); + const d = diag0 * s / s0; + this._view.s = s; + this._view.x = (ox + ux * d / 2) / fw * 100 - 50; + this._view.y = (oy + uy * d / 2) / fh * 100 - 50; + this._clampView(); + this._applyView(); + }; + } else { + this.setAttribute('data-panning', ''); + const start = { px: e.clientX, py: e.clientY, x: this._view.x, y: this._view.y }; + move = (ev) => { + this._view.x = start.x + (ev.clientX - start.px) / fw * 100; + this._view.y = start.y + (ev.clientY - start.py) / fh * 100; + this._clampView(); + this._applyView(); + }; + } + const up = () => { + try { this._spill.releasePointerCapture(e.pointerId); } catch {} + this._spill.removeEventListener('pointermove', move); + this._spill.removeEventListener('pointerup', up); + this._spill.removeEventListener('pointercancel', up); + this.removeAttribute('data-panning'); + this._dragUp = null; + }; + // Stashed so _exitReframe (Escape / outside-click mid-drag) can + // tear the capture + listeners down synchronously. + this._dragUp = up; + this._spill.addEventListener('pointermove', move); + this._spill.addEventListener('pointerup', up); + this._spill.addEventListener('pointercancel', up); + }); + // Wheel zoom stays available inside reframe mode as a trackpad nicety — + // zooms toward the cursor (offset' = cursor·(1-k) + offset·k). + this.addEventListener('wheel', (e) => { + if (!this.hasAttribute('data-reframe')) return; + e.preventDefault(); + const r = this.getBoundingClientRect(); + const cx = (e.clientX - r.left) / r.width * 100 - 50; + const cy = (e.clientY - r.top) / r.height * 100 - 50; + const prev = this._view.s; + const next = clampS(prev * Math.pow(1.0015, -e.deltaY)); + if (next === prev) return; + const k = next / prev; + this._view.s = next; + this._view.x = cx * (1 - k) + this._view.x * k; + this._view.y = cy * (1 - k) + this._view.y * k; + this._clampView(); + this._applyView(); + }, { passive: false }); + } + + connectedCallback() { + // Warn once per page — an id-less slot works for the session but + // cannot persist, and two id-less slots would share nothing. + if (!this.id && !ImageSlot._warned) { + ImageSlot._warned = true; + console.warn(' without an id will not persist its dropped image.'); + } + this.addEventListener('dragenter', this); + this.addEventListener('dragover', this); + this.addEventListener('dragleave', this); + this.addEventListener('drop', this); + subs.add(this._subFn); + // The host may inject window.omelette.writeFile AFTER the first render; + // re-render on hover so the editable-gated controls reliably appear. + this.addEventListener('pointerenter', this._subFn); + // width%/height% in _applyView encode the frame aspect at call time — + // a host resize (responsive grid, pane divider) would stretch the + // image until the next _render. Re-render on size change: _render() + // re-seeds _view from stored before clamp/apply, so a shrink→grow + // cycle round-trips instead of ratcheting x/y toward the narrower + // frame's clamp range. + this._ro = new ResizeObserver(() => this._render()); + this._ro.observe(this); + load(); + this._render(); + } + + disconnectedCallback() { + subs.delete(this._subFn); + this.removeEventListener('pointerenter', this._subFn); + this.removeEventListener('dragenter', this); + this.removeEventListener('dragover', this); + this.removeEventListener('dragleave', this); + this.removeEventListener('drop', this); + if (this._ro) { this._ro.disconnect(); this._ro = null; } + // commit=false: a disconnect is not a user intent — committing here + // would persist whatever half-finished drag a React remount or DOM + // splice happened to interrupt. Deliberate exits commit on their own + // paths (Escape/click-out/toggle), and unloads commit via pagehide. + this._exitReframe(false); + } + + _enterReframe() { + if (this.hasAttribute('data-reframe')) return; + this.setAttribute('data-reframe', ''); + this._signalReframe(true); + // Best-effort commit when the document unloads mid-reframe (a host + // navigation racing the enter signal, a manual reload, tab close): + // the sidecar write rides the host bridge, which outlives this + // document, so the crop survives even though the mode dies with the + // DOM. Held on the instance so _exitReframe detaches exactly what + // was attached. + this._pagehide = () => { this._exitReframe(true); flushNow(); }; + window.addEventListener('pagehide', this._pagehide); + // Promote spill to the top layer, then keep it pinned over the frame: + // scroll/resize cover the common cases, and a per-frame rect check + // catches layout shifts that fire neither (an image above finishing + // load, streamed DOM pushing the slot down, an ancestor transform + // change) so the overlay can't detach from the frame. + try { this._spill.showPopover(); } catch {} + // After the spill, so the controls stack above it in the top layer. + try { this._ctl.showPopover(); } catch {} + this._reposition = () => { if (this.hasAttribute('data-reframe')) this._applyView(); }; + window.addEventListener('scroll', this._reposition, true); + window.addEventListener('resize', this._reposition); + this._lastRect = ''; + this._watch = () => { + if (!this.hasAttribute('data-reframe')) return; + const r = this.getBoundingClientRect(); + const key = r.left + ',' + r.top + ',' + r.width + ',' + r.height; + if (key !== this._lastRect) { this._lastRect = key; this._applyView(); } + this._watchId = requestAnimationFrame(this._watch); + }; + this._watchId = requestAnimationFrame(this._watch); + this._applyView(); + // Close on click outside (the spill handler stopPropagation()s so + // in-image drags don't reach this) and on Escape. Listeners are held + // on the instance so _exitReframe / disconnectedCallback can detach + // exactly what was attached. + this._outside = (e) => { + if (e.composedPath && e.composedPath().includes(this)) return; + this._exitReframe(true); + }; + this._esc = (e) => { if (e.key === 'Escape') this._exitReframe(true); }; + document.addEventListener('pointerdown', this._outside, true); + document.addEventListener('keydown', this._esc, true); + } + + _exitReframe(commit) { + if (!this.hasAttribute('data-reframe')) return; + if (this._dragUp) this._dragUp(); + this.removeAttribute('data-reframe'); + this.removeAttribute('data-panning'); + if (this._outside) document.removeEventListener('pointerdown', this._outside, true); + if (this._esc) document.removeEventListener('keydown', this._esc, true); + this._outside = this._esc = null; + if (this._reposition) { + window.removeEventListener('scroll', this._reposition, true); + window.removeEventListener('resize', this._reposition); + this._reposition = null; + } + if (this._watchId) { cancelAnimationFrame(this._watchId); this._watchId = 0; } + if (this._pagehide) { + window.removeEventListener('pagehide', this._pagehide); + this._pagehide = null; + } + try { this._spill.hidePopover(); } catch {} + try { this._ctl.hidePopover(); } catch {} + this._ctl.style.left = ''; this._ctl.style.top = ''; + if (commit) this._commitView(); + this._signalReframe(false); + } + + // Reframe state lives only in this DOM until commit, invisible to the + // host's dirty signals — announce enter/exit so the host can hold + // auto-reloads for exactly the gesture (the guest bundle forwards + // image-slot:reframe to the host as imageSlotReframe). Dispatched on + // the element (composed, so it escapes shadow roots) while connected; + // a disconnected exit (disconnectedCallback) falls back to document so + // the host still hears it. + _signalReframe(active) { + const target = this.isConnected ? this : document; + target.dispatchEvent(new CustomEvent('image-slot:reframe', { + bubbles: true, composed: true, + detail: { active: active, id: this.id || null } + })); + } + + // Public: host's "Import from computer" calls this to run local browse. + openFilePicker() { this._exitReframe(true); this._input.click(); } + + attributeChangedCallback() { if (this.shadowRoot) this._render(); } + + // handleEvent — one listener object for all four drag events keeps the + // add/remove symmetric and the depth counter correct. + handleEvent(e) { + if (e.type === 'dragenter' || e.type === 'dragover') { + // Without preventDefault the browser never fires 'drop'. + e.preventDefault(); + e.stopPropagation(); + if (e.dataTransfer) e.dataTransfer.dropEffect = 'copy'; + if (e.type === 'dragenter') this._depth++; + this.setAttribute('data-over', ''); + } else if (e.type === 'dragleave') { + // dragenter/leave fire for every descendant crossing — count depth + // so hovering the icon inside the empty state doesn't flicker. + if (--this._depth <= 0) { this._depth = 0; this.removeAttribute('data-over'); } + } else if (e.type === 'drop') { + e.preventDefault(); + e.stopPropagation(); + this._depth = 0; + this.removeAttribute('data-over'); + const f = e.dataTransfer && e.dataTransfer.files && e.dataTransfer.files[0]; + if (f) this._ingest(f); + } + } + + async _ingest(file) { + this._setError(null); + if (!file || ACCEPT.indexOf(file.type) < 0) { + this._setError('Drop a PNG, JPEG, WebP, or AVIF image.'); + return; + } + // toDataUrl can take hundreds of ms on a large photo. A Clear or a + // newer drop during that window would be clobbered when this await + // resumes — bump + capture a generation so stale encodes bail. + const gen = ++this._gen; + // Replacing a shown image: surface the swap through the encode too, + // not just the decode — otherwise the old photo sits there with no + // feedback while the canvas re-encode runs. An empty slot keeps its + // placeholder (no spinner) until the encode lands, as before. + // _swapGen guards the mask against re-renders DURING the encode + // (pointerenter, ResizeObserver, another slot's store write): the + // stored value still resolves to the old image there, so _render's + // same-src clear would otherwise unmask it mid-replace. + if (this.hasAttribute('data-filled')) { + this.setAttribute('data-swapping', ''); + this._swapGen = gen; + } + try { + const w = this.clientWidth || this.offsetWidth || MAX_DIM; + const url = await toDataUrl(file, w); + if (gen !== this._gen) return; + // Only exit reframe once the new image is in hand — a rejected type + // or decode failure leaves the in-progress crop untouched. + this._exitReframe(false); + // Clear BEFORE setSlot: its synchronous re-render must see no + // pending encode, so a byte-identical re-upload (same data URL, no + // load event coming) still clears the mask via the complete branch. + this._swapGen = 0; + const val = { u: url, s: 1, x: 0, y: 0 }; + setSlot(this.id || '', val); + // Keep a session-local copy for id-less slots so the drop still + // shows, even though it cannot persist. + if (!this.id) { this._local = val; this._render(); } + } catch (err) { + if (gen !== this._gen) return; + this._swapGen = 0; + // Reveal the kept old image — unless another replacement (a + // remote pick's src swap) is still in flight, in which case the + // mask stays until THAT image settles (its load/error releases). + this._releaseMask(); + this._setError('Could not read that image.'); + console.warn(' ingest failed:', err); + } + } + + _setError(msg) { + if (this._err) { this._err.remove(); this._err = null; } + if (!msg) return; + const d = document.createElement('div'); + d.className = 'err'; d.textContent = msg; + this.shadowRoot.appendChild(d); + this._err = d; + setTimeout(() => { if (this._err === d) { d.remove(); this._err = null; } }, 3000); + } + + // Reframing (pan/resize) is available on any filled slot — the user can + // always reposition/scale. `fit` only sets the initial baseline (see + // _geom): contain starts fully-visible, cover starts frame-filling. + _reframes() { + return this.hasAttribute('data-filled'); + } + + // The single release discipline for the replacement-in-flight mask + // (data-swapping). The mask comes off only when BOTH hold: + // - no encode is pending (_swapGen) — mid-encode the stored value + // still resolves to the old image, so any reveal paints it; + // - the frame img has settled on its current src — an unsettled src + // means some replacement is still in flight (e.g. a remote pick), + // whoever started it, and revealing would paint the previous + // frame. The load/error listeners pass settled=true (the event IS + // the settlement signal, per spec complete is true by then); + // other callers rely on the complete flag (covers loaded AND + // failed). + // Every release path funnels through here EXCEPT _render's empty + // branch (the img is being cleared — nothing will ever settle). + _releaseMask(settled) { + if ( + !this._swapGen && + !this._loadPending && + (settled || this._img.complete) + ) { + this.removeAttribute('data-swapping'); + } + } + + // Baseline geometry, shared by clamp/apply/resize. `base` is the scale at + // view-scale s=1: cover = fill the frame (overflow on the looser axis), + // contain = fit fully inside (letterboxed). Zooming a contain image past + // s where it overflows naturally becomes a crop. Null until the img has + // loaded (naturalWidth is 0 before that) or when the slot has no layout + // box — ResizeObserver fires with a 0×0 rect under display:none, and + // clamping against a degenerate 1×1 frame would silently pull the stored + // pan toward zero. + _geom() { + const iw = this._img.naturalWidth, ih = this._img.naturalHeight; + const fw = this.clientWidth, fh = this.clientHeight; + if (!iw || !ih || !fw || !fh) return null; + const contain = (this.getAttribute('fit') || 'cover').toLowerCase() === 'contain'; + const base = contain + ? Math.min(fw / iw, fh / ih) + : Math.max(fw / iw, fh / ih); + return { iw, ih, fw, fh, base }; + } + + _clampView() { + // Pan range on each axis is half the overflow past the frame edge. + const g = this._geom(); + if (!g) return; + const mx = Math.max(0, (g.iw * g.base * this._view.s / g.fw - 1) * 50); + const my = Math.max(0, (g.ih * g.base * this._view.s / g.fh - 1) * 50); + this._view.x = Math.max(-mx, Math.min(mx, this._view.x)); + this._view.y = Math.max(-my, Math.min(my, this._view.y)); + } + + _applyView() { + const g = this._geom(); + // Top-layer controls: pin to the frame's top-right in viewport px + // (the same 8px inset as the in-frame layout; unscaled — top-layer UI + // reads as chrome, not page content). BEFORE the geometry branch: + // placement needs only the frame rect, and a not-yet-loaded or broken + // src must not leave the promoted strip floating unpositioned. Gated + // on the popover actually being open: without the Popover API, + // showPopover() threw (swallowed in _enterReframe), .ctl stays in + // its in-frame absolute layout, and viewport-px coordinates would + // shove it off-frame — and matches(':popover-open') itself throws + // there (unknown pseudo-class), hence the try/catch. + if (this.hasAttribute('data-reframe')) { + let onTop = false; + try { onTop = this._ctl.matches(':popover-open'); } catch {} + if (onTop) { + const r = this.getBoundingClientRect(); + this._ctl.style.left = (r.right - 8) + 'px'; + this._ctl.style.top = (r.top + 8) + 'px'; + } + } + if (!g) { + // Dimensions not known yet (before img load) — centered fit so there + // is no flash of an unpositioned image before the geometry lands. + const contain = (this.getAttribute('fit') || 'cover').toLowerCase() === 'contain'; + this._img.style.width = '100%'; + this._img.style.height = '100%'; + this._img.style.left = '50%'; + this._img.style.top = '50%'; + this._img.style.objectFit = contain ? 'contain' : 'cover'; + return; + } + // Baseline (cover-fill or contain-fit) × view scale. Width/height and + // left/top are all frame-% — depends only on the frame aspect ratio, so + // a responsive resize keeps the same crop. The spill layer mirrors the + // same box so its corners = image corners. + const k = g.base * this._view.s; + const w = (g.iw * k / g.fw * 100) + '%'; + const h = (g.ih * k / g.fh * 100) + '%'; + const l = (50 + this._view.x) + '%'; + const t = (50 + this._view.y) + '%'; + this._img.style.width = w; this._img.style.height = h; + this._img.style.left = l; this._img.style.top = t; + this._img.style.objectFit = ''; + if (this.hasAttribute('data-reframe')) { + // Top-layer spill: position in viewport px over the frame. The top + // layer escapes ancestor transforms entirely, so EVERY term must be + // in viewport units: getBoundingClientRect gives the frame's scaled + // origin AND size, and the rect/layout ratio rescales the ghost — + // sizing from layout px alone renders it 1/scale too large under a + // scaled deck slide. Inner ghost + handles stay box-relative. + const r = this.getBoundingClientRect(); + const sx = g.fw ? r.width / g.fw : 1; + const sy = g.fh ? r.height / g.fh : 1; + this._spill.style.width = (g.iw * k * sx) + 'px'; + this._spill.style.height = (g.ih * k * sy) + 'px'; + this._spill.style.left = (r.left + (50 + this._view.x) / 100 * r.width) + 'px'; + this._spill.style.top = (r.top + (50 + this._view.y) / 100 * r.height) + 'px'; + } + } + + _commitView() { + const v = { s: this._view.s, x: this._view.x, y: this._view.y }; + if (this._userUrl) v.u = this._userUrl; + // Framing-only (no u) persists too so an author-src slot remembers its + // crop; clearing the sidecar still falls through to src=. + if (this.id) setSlot(this.id, v); + else { this._local = v; } + } + + _render() { + // Shape / mask. Presets use border-radius so the dashed ring can + // follow the rounded outline; clip-path is only applied for an + // explicit `mask` (the ring is hidden there since a rectangle + // dashed border chopped by an arbitrary polygon looks broken). + const mask = this.getAttribute('mask'); + const shape = (this.getAttribute('shape') || 'rounded').toLowerCase(); + let radius = ''; + if (shape === 'circle') radius = '50%'; + else if (shape === 'pill') radius = '9999px'; + else if (shape === 'rounded') { + const n = parseFloat(this.getAttribute('radius')); + radius = (Number.isFinite(n) ? n : 12) + 'px'; + } + this._frame.style.borderRadius = mask ? '' : radius; + this._frame.style.clipPath = mask || ''; + this._ring.style.borderRadius = mask ? '' : radius; + this._ring.style.display = mask ? 'none' : ''; + + // Controls and reframe entry gate on this so share links stay read-only. + const editable = !!(window.omelette && window.omelette.writeFile); + this.toggleAttribute('data-editable', editable); + this._sub.style.display = editable ? '' : 'none'; + + // Content. The sidecar is also writable by the agent's write_file + // tool, so its value isn't guaranteed canvas-originated — only accept + // data:image/ URLs from it. The `src` attribute is author-controlled + // (Claude wrote it into the HTML) so it passes through unchanged. + let stored = this.id ? getSlot(this.id) : this._local; + if (stored && stored.u && !/^data:image\//i.test(stored.u)) stored = null; + const srcAttr = this.getAttribute('src') || ''; + this._userUrl = (stored && stored.u) || null; + const url = this._userUrl || srcAttr; + // Don't clobber an in-flight reframe with a store-triggered re-render. + if (!this.hasAttribute('data-reframe')) { + this._view = { + s: stored && Number.isFinite(stored.s) ? clampS(stored.s) : 1, + x: stored && Number.isFinite(stored.x) ? stored.x : 0, + y: stored && Number.isFinite(stored.y) ? stored.y : 0, + }; + } + this._cap.textContent = this.getAttribute('placeholder') || 'Drop an image'; + // Toggle via style.display — the [hidden] attribute alone loses to + // the display:flex / display:block rules in the stylesheet above. + // An Unsplash src with no credit attribute must NOT render — showing + // the photo uncredited is the Unsplash-terms violation itself. The + // error tile replaces the photo until the credit is written. A + // user-dropped image is the user's own content and always renders. + // Trimmed: credit is agent/user-editable content, and a whitespace- + // only value must count as missing — otherwise it would suppress the + // error tile AND render an empty credit box (no text, no links), + // exactly the unattributed state this gate exists to prevent. + const credit = (this.getAttribute('credit') || '').trim(); + const attrError = !!( + !credit && !this._userUrl && srcAttr && isUnsplashHost(srcAttr) + ); + this.toggleAttribute('data-attribution-error', attrError); + if (url && !attrError) { + const prev = this._img.getAttribute('src'); + if (prev !== url) { + // Replacing an already-shown image: mark the swap BEFORE setting + // src so the stale frame is never revealed (see the data-swapping + // stylesheet rules). First fill (prev empty) keeps the existing + // placeholder-until-load behavior — no spinner. _hidShowing + // covers the pick path's transient attribution-error wipe: prev + // is gone, but an image WAS showing, so this is a replacement. + if (prev || this._hidShowing) this.setAttribute('data-swapping', ''); + // Mark the swap BEFORE assigning src: complete keeps reporting + // the old settled request until the browser's + // update-the-image-data microtask runs, so same-task re-renders + // (the pick path's credit/credit-href setAttributes) need this + // flag, not complete, to know a load is in flight. + this._loadPending = true; + this._img.src = url; + this._ghost.src = url; + } else { + // Same-src re-render — release if settled, so an ingest-set + // spinner can't stick after a byte-identical re-upload (same + // data URL, no further load event ever fires). + this._releaseMask(); + } + this._hidShowing = false; + this._img.style.display = 'block'; + this._empty.style.display = 'none'; + this.setAttribute('data-filled', ''); + this._clampView(); + this._applyView(); + } else { + this.removeAttribute('data-swapping'); + // The src is being removed — no load/error will ever fire for it. + this._loadPending = false; + // A transient attribution-error wipe of a showing image happens on + // the pick path: the host sets src one setAttribute before credit, + // so render N hides the old image (attrError) and render N+1 + // restores a URL. Remember the wipe so that restore renders as a + // replacement (spinner), not a first fill (blank frame). + this._hidShowing = attrError && !!this._img.getAttribute('src'); + this._img.style.display = 'none'; + this._img.removeAttribute('src'); + this._ghost.removeAttribute('src'); + // The error tile owns the blocked-photo state; .empty stays for + // the genuinely-empty slot. + this._empty.style.display = attrError ? 'none' : 'flex'; + this.removeAttribute('data-filled'); + } + + // Credit belongs to the author src, so a user drop hides it. + // textContent + the http(s)-only funnel keep external strings inert. + const showCredit = !!(url && credit && !this._userUrl && !attrError); + this._credit.textContent = ''; + if (showCredit) { + // Validate once (resolved against the document, http(s) only), + // then append the terms-required utm referral params to links + // that point back at unsplash.com. + let href = ''; + const rawHref = this.getAttribute('credit-href') || ''; + if (rawHref) { + try { + const u = new URL(rawHref, document.baseURI); + if (u.protocol === 'http:' || u.protocol === 'https:') { + href = withReferral(u.href); + } + } catch {} + } + const mkLink = (text, linkHref) => { + const a = document.createElement('a'); + a.setAttribute('target', '_blank'); + a.setAttribute('rel', 'noopener noreferrer'); + a.setAttribute('href', linkHref); + a.textContent = text; + return a; + }; + // Unsplash's prescribed credit is TWO links — the photographer's + // name to their profile (credit-href) and 'Unsplash' to the + // homepage. Render that split whenever the text has the canonical + // shape; other text keeps the legacy single-link rendering. + const m = /^Photo by (.+) on Unsplash$/.exec(credit); + if (m) { + this._credit.appendChild(document.createTextNode('Photo by ')); + this._credit.appendChild( + href ? mkLink(m[1], href) : document.createTextNode(m[1]) + ); + this._credit.appendChild(document.createTextNode(' on ')); + this._credit.appendChild(mkLink('Unsplash', UNSPLASH_HOMEPAGE_HREF)); + } else if (href) { + this._credit.appendChild(mkLink(credit, href)); + } else { + this._credit.textContent = credit; + } + } + this.toggleAttribute('data-credit', showCredit); + } + } + + if (!customElements.get('image-slot')) { + customElements.define('image-slot', ImageSlot); + } +})(); diff --git a/design/baseline/# UI для онлайн звонка/support.js b/design/baseline/# UI для онлайн звонка/support.js new file mode 100644 index 0000000..6ca00e3 --- /dev/null +++ b/design/baseline/# UI для онлайн звонка/support.js @@ -0,0 +1,1768 @@ +// GENERATED from dc-runtime/src/*.ts — do not edit. Rebuild with `cd dc-runtime && bun run build`. +"use strict"; +(() => { + var __defProp = Object.defineProperty; + var __defNormalProp = (obj, key, value) => key in obj ? __defProp(obj, key, { enumerable: true, configurable: true, writable: true, value }) : obj[key] = value; + var __publicField = (obj, key, value) => __defNormalProp(obj, typeof key !== "symbol" ? key + "" : key, value); + + // src/react.ts + function getReact() { + const R = window.React; + if (!R) throw new Error("dc-runtime: window.React is not available yet"); + return R; + } + function getReactDOM() { + const RD = window.ReactDOM; + if (!RD) throw new Error("dc-runtime: window.ReactDOM is not available yet"); + return RD; + } + var h = ((...args) => getReact().createElement( + ...args + )); + + // src/parse.ts + function parseDcDocument(doc) { + const dc = doc.querySelector("x-dc"); + if (!dc) return null; + const scriptEl = doc.querySelector("script[data-dc-script]"); + const { props, preview } = parseDataProps( + scriptEl?.getAttribute("data-props") ?? null + ); + return { + template: dc.innerHTML, + js: scriptEl ? scriptEl.textContent || "" : "", + props, + preview + }; + } + function parseDcText(src) { + const openMatch = /]*)?>/.exec(src); + if (!openMatch) return null; + const close = src.lastIndexOf(""); + if (close === -1 || close < openMatch.index) return null; + const template = src.slice(openMatch.index + openMatch[0].length, close); + const doc = new DOMParser().parseFromString(src, "text/html"); + const scriptEl = doc.querySelector("script[data-dc-script]"); + const { props, preview } = parseDataProps( + scriptEl?.getAttribute("data-props") ?? null + ); + return { + template, + js: scriptEl ? scriptEl.textContent || "" : "", + props, + preview + }; + } + function parseDataProps(raw) { + if (!raw) return { props: null, preview: null }; + let parsed; + try { + parsed = JSON.parse(raw); + } catch { + return { props: null, preview: null }; + } + if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) { + return { props: null, preview: null }; + } + const obj = parsed; + const preview = obj.$preview && typeof obj.$preview === "object" ? obj.$preview : null; + const rest = {}; + for (const k of Object.keys(obj)) { + if (k[0] !== "$") rest[k] = obj[k]; + } + return { props: Object.keys(rest).length ? rest : null, preview }; + } + function dcNameFromPath(pathname) { + let p = pathname || ""; + try { + p = decodeURIComponent(p); + } catch { + } + const base = p.split("/").pop() || "Root"; + return base.replace(/\.dc\.html$/, "").replace(/\.html?$/, "") || "Root"; + } + + // src/boot.ts + var BASE_CSS = ` + .sc-placeholder{background:color-mix(in srgb,currentColor 8%,transparent); + border:1px solid color-mix(in srgb,currentColor 50%,transparent); + border-radius:2px;box-sizing:border-box;overflow:hidden} + @keyframes sc-shine{0%{background-position:100% 50%}100%{background-position:0% 50%}} + html.sc-dc-streaming .sc-placeholder, + html.sc-dc-streaming .sc-interp.sc-missing{position:relative; + background:color-mix(in srgb,currentColor 5%,transparent); + border-color:transparent} + html.sc-dc-streaming .sc-placeholder::before, + html.sc-dc-streaming .sc-interp.sc-missing::before{content:''; + position:absolute;inset:0;pointer-events:none; + background:linear-gradient(90deg,rgba(217,119,87,0) 25%,rgba(247,225,211,.95) 37%,rgba(217,119,87,0) 63%); + background-size:400% 100%;animation:sc-shine 1.4s ease infinite} + html.sc-dc-streaming .sc-placeholder:nth-child(n+9 of .sc-placeholder)::before, + html.sc-dc-streaming .sc-interp.sc-missing:nth-child(n+9 of .sc-interp.sc-missing)::before{animation:none; + background:color-mix(in srgb,currentColor 8%,transparent)} + .sc-placeholder-error{padding:4px 8px;font:11px/1.4 ui-monospace,monospace; + color:color-mix(in srgb,currentColor 70%,transparent);word-break:break-word} + .sc-interp.sc-missing{display:inline-block;width:2em;height:1em;overflow:hidden; + vertical-align:text-bottom;background:rgba(255,255,255,.3);border:1px solid rgba(0,0,0,.5); + border-radius:2px;box-sizing:border-box;color:transparent; + user-select:none} + .sc-interp.sc-unresolved{font-family:ui-monospace,monospace;font-size:.85em; + color:color-mix(in srgb,currentColor 50%,transparent); + background:color-mix(in srgb,currentColor 10%,transparent);border-radius:3px; + padding:0 3px} + .sc-host.sc-has-error{position:relative} + .sc-logic-error{position:absolute;top:8px;left:8px;z-index:2147483647;max-width:60ch; + padding:6px 10px;background:#b00020;color:#fff;font:12px/1.4 ui-monospace,monospace; + border-radius:4px;white-space:pre-wrap;pointer-events:none} + /* Mirrors PRINT_BASELINE_CSS in apps/web deck-stage-export.ts \u2014 keep both + in sync until dc-runtime regains a build step. */ + @media print { + @page { margin: 0.5cm; } + figure, table { break-inside: avoid; } + #dc-root, #dc-root > .sc-host { height: auto; } + *, *::before, *::after { + print-color-adjust: exact; -webkit-print-color-adjust: exact; + backdrop-filter: none !important; -webkit-backdrop-filter: none !important; + animation-delay: -99s !important; animation-duration: .001s !important; + animation-iteration-count: 1 !important; animation-fill-mode: both !important; + animation-play-state: running !important; transition-duration: 0s !important; + } + } + `; + var FULL_PAGE_CSS = "html,body{height:100%;margin:0}#dc-root,#dc-root>.sc-host{height:100%}"; + function rootNameForDocument(doc, loc) { + let bootPath = loc.pathname || ""; + if (!/\.dc\.html?$/i.test(safeDecode(bootPath))) { + try { + bootPath = new URL(doc.baseURI || "/").pathname; + } catch { + } + } + return dcNameFromPath(bootPath); + } + function safeDecode(s) { + try { + return decodeURIComponent(s); + } catch { + return s; + } + } + function boot(runtime, doc = document) { + const parsed = parseDcDocument(doc); + if (!parsed) return null; + const React = getReact(); + const rootName = rootNameForDocument(doc, location); + runtime.markFetched(rootName); + runtime.setRootName(rootName); + runtime.adoptParsed(rootName, parsed); + if (!window.__resources) { + fetch(location.href).then((res) => res.ok ? res.text() : "").then((t) => { + const raw = t ? parseDcText(t) : null; + if (raw?.template) runtime.updateHtml(rootName, raw.template); + }).catch(() => { + }); + } + const dc = doc.querySelector("x-dc"); + const hostEl = doc.createElement("div"); + hostEl.id = "dc-root"; + dc.replaceWith(hostEl); + if (!parsed.preview) { + const s = doc.createElement("style"); + s.textContent = FULL_PAGE_CSS; + doc.head.appendChild(s); + } + const Root = runtime.getDC(rootName); + const entry = runtime.registry.get(rootName); + function StandaloneRoot() { + const [, setTick] = React.useState(0); + React.useEffect(() => { + const sub = () => setTick((n) => n + 1); + entry.subs.add(sub); + return () => { + entry.subs.delete(sub); + }; + }, []); + const defaults = React.useMemo(() => { + const d = {}; + for (const k in entry.propsMeta || {}) { + const v = entry.propsMeta?.[k]?.default; + if (v !== void 0) d[k] = v; + } + return d; + }, [entry.propsMeta]); + return h(Root, { ...defaults, ...entry.propOverrides || {} }); + } + const ReactDOM = getReactDOM(); + if (ReactDOM.createRoot) + ReactDOM.createRoot(hostEl).render(h(StandaloneRoot)); + else ReactDOM.render(h(StandaloneRoot), hostEl); + return rootName; + } + + // src/expr.ts + var IDENT_RE = /^[A-Za-z_$][A-Za-z0-9_$]*/; + var NUMBER_RE = /^-?\d+(\.\d+)?$/; + function resolve(vals, src) { + const expr = String(src).trim(); + if (!expr) return void 0; + if (expr[0] === "(" && expr[expr.length - 1] === ")" && parensWrapWhole(expr)) { + return resolve(vals, expr.slice(1, -1)); + } + const eq = findTopLevelEquality(expr); + if (eq) { + const lv = resolve(vals, expr.slice(0, eq.index)); + const rv = resolve(vals, expr.slice(eq.index + eq.op.length)); + switch (eq.op) { + case "===": + return lv === rv; + case "!==": + return lv !== rv; + case "==": + return lv == rv; + default: + return lv != rv; + } + } + if (expr[0] === "!") return !resolve(vals, expr.slice(1)); + if (expr === "true") return true; + if (expr === "false") return false; + if (expr === "null") return null; + if (expr === "undefined") return void 0; + if (NUMBER_RE.test(expr)) return Number(expr); + if (expr.length >= 2 && (expr[0] === '"' || expr[0] === "'") && expr[expr.length - 1] === expr[0]) { + return expr.slice(1, -1); + } + return resolvePath(vals, expr); + } + function parensWrapWhole(expr) { + let depth = 0; + for (let i = 0; i < expr.length - 1; i++) { + if (expr[i] === "(") depth++; + else if (expr[i] === ")") { + depth--; + if (depth === 0) return false; + } + } + return true; + } + function findTopLevelEquality(expr) { + let depth = 0; + for (let i = 0; i < expr.length; i++) { + const c = expr[i]; + if (c === "[" || c === "(") depth++; + else if (c === "]" || c === ")") depth--; + else if (depth === 0 && (c === "=" || c === "!") && expr[i + 1] === "=") { + if (i > 0 && (expr[i - 1] === "=" || expr[i - 1] === "!")) continue; + if (!expr.slice(0, i).trim()) continue; + const op = expr[i + 2] === "=" ? c + "==" : c + "="; + return { index: i, op }; + } + } + return null; + } + function resolvePath(vals, expr) { + const head = expr.match(IDENT_RE); + if (!head) return void 0; + let cur = vals == null ? void 0 : vals[head[0]]; + let i = head[0].length; + while (i < expr.length) { + if (expr[i] === ".") { + const m = expr.slice(i + 1).match(IDENT_RE) || expr.slice(i + 1).match(/^\d+/); + if (!m) return void 0; + cur = cur == null ? void 0 : cur[m[0]]; + i += 1 + m[0].length; + } else if (expr[i] === "[") { + let depth = 1; + let j = i + 1; + while (j < expr.length && depth > 0) { + if (expr[j] === "[") depth++; + else if (expr[j] === "]") { + depth--; + if (depth === 0) break; + } + j++; + } + if (depth !== 0) return void 0; + const key = resolve(vals, expr.slice(i + 1, j)); + cur = cur == null ? void 0 : cur[key]; + i = j + 1; + } else { + return void 0; + } + } + return cur; + } + + // src/encode.ts + var CAMEL_ATTR = "sc-camel-"; + var INLINE_TEXT_TAGS = new Set( + "a abbr b bdi bdo br cite code del dfn em i ins kbd mark q s samp small span strike strong sub sup u var wbr".split( + " " + ) + ); + var RAW_WRAP = { + select: "sc-raw-select", + table: "sc-raw-table", + tbody: "sc-raw-tbody", + thead: "sc-raw-thead", + tfoot: "sc-raw-tfoot", + tr: "sc-raw-tr", + td: "sc-raw-td", + th: "sc-raw-th", + caption: "sc-raw-caption" + }; + var RAW_UNWRAP = Object.fromEntries( + Object.entries(RAW_WRAP).map(([k, v]) => [v, k]) + ); + var EVENT_MAP = { + onclick: "onClick", + onchange: "onChange", + oninput: "onInput", + onsubmit: "onSubmit", + onkeydown: "onKeyDown", + onkeyup: "onKeyUp", + onkeypress: "onKeyPress", + onmousedown: "onMouseDown", + onmouseup: "onMouseUp", + onmouseenter: "onMouseEnter", + onmouseleave: "onMouseLeave", + onfocus: "onFocus", + onblur: "onBlur", + ondoubleclick: "onDoubleClick", + oncontextmenu: "onContextMenu", + onmousemove: "onMouseMove", + onmouseover: "onMouseOver", + onmouseout: "onMouseOut", + onpointerdown: "onPointerDown", + onpointerup: "onPointerUp", + onpointermove: "onPointerMove", + onpointerenter: "onPointerEnter", + onpointerleave: "onPointerLeave", + onpointercancel: "onPointerCancel", + onpointerover: "onPointerOver", + onpointerout: "onPointerOut", + ongotpointercapture: "onGotPointerCapture", + onlostpointercapture: "onLostPointerCapture", + ontouchstart: "onTouchStart", + ontouchend: "onTouchEnd", + ontouchmove: "onTouchMove", + ontouchcancel: "onTouchCancel", + ondragstart: "onDragStart", + ondragend: "onDragEnd", + ondragenter: "onDragEnter", + ondragleave: "onDragLeave", + ondragover: "onDragOver", + onanimationstart: "onAnimationStart", + onanimationend: "onAnimationEnd", + onanimationiteration: "onAnimationIteration", + ontransitionend: "onTransitionEnd" + }; + var ATTRS = `(?:[^>"']|"[^"]*"|'[^']*')*`; + var IMPORT_SELF_CLOSE_RE = new RegExp( + "<(x-import|dc-import)(" + ATTRS + ")/>", + "gi" + ); + var CAMEL_ATTR_RE = /(\s)([a-z]+[A-Z][A-Za-z0-9]*)(\s*=)/g; + function encodeCamelAttrs(html) { + return html.replace( + CAMEL_ATTR_RE, + (_, sp, name, eq) => sp + CAMEL_ATTR + name.replace(/[A-Z]/g, (c) => "-" + c.toLowerCase()) + eq + ); + } + function encodeCase(html) { + html = html.replace( + IMPORT_SELF_CLOSE_RE, + (_, t, a) => "<" + t + a + ">" + t + ">" + ); + html = html.replace(/)/gi, "/gi, ""); + html = encodeCamelAttrs(html); + for (const [real, alias] of Object.entries(RAW_WRAP)) { + html = html.replace( + new RegExp("(?)" + real + "(?=[\\s>])", "gi"), + "$1" + alias + ); + } + return html; + } + function kebabToCamel(s) { + return s.replace(/-([a-z])/g, (_, c) => c.toUpperCase()); + } + function cssToObj(css) { + const o = {}; + for (const decl of css.split(";")) { + const i = decl.indexOf(":"); + if (i < 0) continue; + const prop = decl.slice(0, i).trim(); + o[prop.startsWith("--") ? prop : kebabToCamel(prop)] = decl.slice(i + 1).trim(); + } + return o; + } + function compileAttr(raw) { + const whole = raw.match(/^\s*\{\{([\s\S]+?)\}\}\s*$/); + if (whole) { + const path = whole[1]; + return (vals) => resolve(vals, path); + } + if (raw.includes("{{")) { + const parts = raw.split(/\{\{([\s\S]+?)\}\}/g); + return (vals) => parts.map((s, i) => i & 1 ? resolve(vals, s) ?? "" : s).join(""); + } + return () => raw; + } + + // src/compile.ts + function collectProps(node, kind, host) { + const propGetters = []; + const pseudoClasses = []; + let hintSize = null; + for (const { name, value } of [...node.attributes]) { + if (name === "sc-name" || name === "data-dc-tpl") continue; + let key = name; + if (key.startsWith(CAMEL_ATTR)) + key = kebabToCamel(key.slice(CAMEL_ATTR.length)); + if (key === "hint-size") { + hintSize = value; + continue; + } + if (key.startsWith("style-")) { + pseudoClasses.push(host.pseudoClass(key.slice(6), value)); + continue; + } + if (kind !== "dom") { + if (key.includes("-") && !(kind === "x-import" && (key.startsWith("aria-") || key.startsWith("data-")))) + key = kebabToCamel(key); + } else { + if (key === "class") key = "className"; + else if (key === "for") key = "htmlFor"; + else if (key.startsWith("on")) + key = EVENT_MAP[key] || "on" + key[2].toUpperCase() + key.slice(3); + } + propGetters.push([key, compileAttr(value)]); + } + return { propGetters, pseudoClasses, hintSize }; + } + var HOST_STYLE_PROPS = /* @__PURE__ */ new Set([ + "position", + "left", + "right", + "top", + "bottom", + "inset", + "width", + "height", + "z-index", + "transform" + ]); + function hostPositionStyle(style) { + const all = typeof style === "string" ? cssToObj(style) : style != null && typeof style === "object" ? style : null; + if (!all) return void 0; + const out = {}; + for (const [k, v] of Object.entries(all)) { + const kebab = k.replace(/[A-Z]/g, (c) => "-" + c.toLowerCase()); + if (HOST_STYLE_PROPS.has(kebab)) out[k] = v; + } + return Object.keys(out).length ? out : void 0; + } + function compileTemplate(html, host) { + const tpl = document.createElement("template"); + //! nosemgrep: direct-inner-html-assignment + tpl.innerHTML = encodeCase(html); + let tplN = 0; + (function stamp(node) { + if (node.nodeType === Node.ELEMENT_NODE) { + node.setAttribute("data-dc-tpl", String(tplN++)); + } + for (const c of node.childNodes) stamp(c); + })(tpl.content); + const builders = walkChildren(tpl.content, host); + const render = ((vals, ctx) => builders.map((b, i) => b(vals || {}, ctx, i))); + render.__annotated = tpl.innerHTML; + return render; + } + function walkChildren(node, host) { + return [...node.childNodes].map((c) => walk(c, host)).filter((b) => b != null); + } + function walk(node, host) { + if (node.nodeType === Node.TEXT_NODE) return walkText(node); + if (node.nodeType !== Node.ELEMENT_NODE) return null; + const el = node; + const tag = el.tagName.toLowerCase(); + if (tag === "sc-for") return walkFor(el, host); + if (tag === "sc-if") return walkIf(el, host); + if (tag === "x-import") return walkXImport(el, host); + if (tag === "sc-helmet") return host.helmet(el); + if (tag === "dc-import") return walkComponent(el, host); + return walkElement(el, host); + } + var warnedHoles = /* @__PURE__ */ new Set(); + function warnUnresolved(ctx, what) { + const key = (ctx?.__name || "?") + "\0" + what; + if (warnedHoles.has(key)) return; + warnedHoles.add(key); + console.warn("[dc-runtime] " + (ctx?.__name || "template") + ": " + what); + } + function walkText(node) { + const txt = node.nodeValue ?? ""; + if (!txt.includes("{{")) { + if (!txt.trim() && !txt.includes(" ")) return null; + return () => txt; + } + const parts = txt.split(/\{\{([\s\S]+?)\}\}/g); + return (vals, ctx, key) => h( + getReact().Fragment, + { key }, + ...parts.map((p, i) => { + if (!(i & 1)) return p; + const v = resolve(vals, p); + if (v === void 0) { + if (!ctx?.__streamingNow) { + if (document.body?.hasAttribute("data-dc-editor-on")) { + return h( + "span", + { key: i, className: "sc-interp sc-unresolved" }, + "{{ " + p.trim() + " }}" + ); + } + warnUnresolved( + ctx, + "{{ " + p.trim() + " }} never resolved \u2014 rendered as empty" + ); + return null; + } + return h( + "span", + { key: i, className: "sc-interp sc-missing" }, + p.trim() + ); + } + if (getReact().isValidElement(v) || Array.isArray(v)) { + return h(getReact().Fragment, { key: i }, v); + } + if (v === null || typeof v === "boolean") return null; + return h("span", { key: i, className: "sc-interp" }, String(v)); + }) + ); + } + function walkFor(el, host) { + const listGet = compileAttr(el.getAttribute("list") || ""); + const asName = el.getAttribute("as") || "item"; + const hintN = parseInt(el.getAttribute("hint-placeholder-count") || "0", 10); + const kids = walkChildren(el, host); + const listSrc = el.getAttribute("list") || ""; + return (vals, ctx, key) => { + let list = listGet(vals); + if (!Array.isArray(list)) { + if (!ctx?.__streamingNow) { + if (list !== void 0 && list !== null) { + warnUnresolved( + ctx, + 'sc-for list="' + listSrc + '" is not an array (' + typeof list + ")" + ); + } + list = []; + } else { + list = hintN > 0 ? Array(hintN).fill(void 0) : []; + } + } + return h( + getReact().Fragment, + { key }, + list.map((item, i) => { + const sub = { ...vals, [asName]: item, $index: i }; + return h( + getReact().Fragment, + { key: i }, + kids.map((b, j) => b(sub, ctx, j)) + ); + }) + ); + }; + } + function walkIf(el, host) { + const valGet = compileAttr(el.getAttribute("value") || ""); + const hintRaw = el.getAttribute("hint-placeholder-val"); + const hintGet = hintRaw != null ? compileAttr(hintRaw) : null; + const kids = walkChildren(el, host); + return (vals, ctx, key) => { + let v = valGet(vals); + if (v === void 0 && hintGet && ctx?.__streamingNow) v = hintGet(vals); + return v ? h( + getReact().Fragment, + { key }, + kids.map((b, j) => b(vals, ctx, j)) + ) : null; + }; + } + function walkComponent(el, host) { + const name = el.getAttribute("name") || el.getAttribute("component") || ""; + el.removeAttribute("name"); + el.removeAttribute("component"); + const tplId = el.getAttribute("data-dc-tpl"); + const styleRaw = el.getAttribute("style"); + el.removeAttribute("style"); + const styleGet = styleRaw != null ? compileAttr(styleRaw) : null; + const { propGetters, hintSize } = collectProps(el, "dc-import", host); + const kids = walkChildren(el, host); + return (vals, ctx, key) => { + const props = { + key, + __hintSize: hintSize, + __tplId: tplId, + __hostStyle: styleGet ? hostPositionStyle(styleGet(vals)) : void 0 + }; + for (const [k, g] of propGetters) { + const v = g(vals); + if (k === "dcProps") { + if (v && typeof v === "object") Object.assign(props, v); + continue; + } + props[k] = v; + } + if (kids.length) props.children = kids.map((b, j) => b(vals, ctx, j)); + return h(host.component(name), props); + }; + } + function walkXImport(el, host) { + const globalNameGet = compileAttr( + el.getAttribute("component-from-global-scope") || "" + ); + const exportNameGet = compileAttr( + el.getAttribute("component") || el.getAttribute("name") || "" + ); + const fromRaw = el.getAttribute("from") || (el.getAttribute("component-from-global-scope") ? "" : el.getAttribute("src") || el.getAttribute("import") || ""); + const urls = fromRaw.trim() ? fromRaw.trim().split(/\s+/) : []; + const url = urls.length ? urls[urls.length - 1] : ""; + const kindOf = (u) => /\.(jsx|tsx)(\?|#|$)/i.test(u) ? "jsx" : "js"; + const tplId = el.getAttribute("data-dc-tpl"); + const styleRaw = el.getAttribute("style"); + el.removeAttribute("style"); + const styleGet = styleRaw != null ? compileAttr(styleRaw) : null; + const wrap = tplId != null || styleGet != null; + const { propGetters, hintSize } = collectProps(el, "x-import", host); + const hasContent = el.children.length > 0 || !!(el.textContent || "").trim(); + const kids = hasContent ? walkChildren(el, host) : []; + const urlBindable = fromRaw.includes("{{"); + if (urls.length && !urlBindable) { + let prev; + for (const u of urls) prev = host.loadExternal(kindOf(u), u, prev); + } + const evalName = (g, vals) => { + const v = g(vals); + const s = v == null ? "" : String(v); + return s.includes("{{") ? "" : s; + }; + return (vals, ctx, key) => { + const globalName = evalName(globalNameGet, vals); + const name = globalName || evalName(exportNameGet, vals); + const C = !name || urlBindable ? null : globalName ? host.resolveExternalGlobal(url, globalName) : host.resolveExternal(url, name); + const hostStyle = styleGet ? hostPositionStyle(styleGet(vals)) : void 0; + const wrapper = wrap ? { + key, + className: "sc-host-x", + "data-dc-tpl": tplId, + style: hostStyle || { display: "contents" } + } : null; + if (!C) { + const error = urlBindable ? "x-import `from` cannot contain {{ \u2026 }} \u2014 module URLs are resolved at parse time; use a literal URL" : host.resolveExternalError(url, name); + const ph = host.placeholder({ + key: wrapper ? void 0 : key, + name, + hintSize, + error + }); + return wrapper ? h("div", wrapper, ph) : ph; + } + const props = wrapper ? {} : { key }; + let unresolvedHole = false; + for (const [k, g] of propGetters) { + if (k === "component" || k === "componentFromGlobalScope" || k === "from") { + continue; + } + const v = g(vals); + if (v === void 0) unresolvedHole = true; + if (k === "dcProps") { + if (v && typeof v === "object") Object.assign(props, v); + continue; + } + props[k] = v; + } + if (unresolvedHole && ctx?.__htmlStreamingNow) { + const ph = host.placeholder({ + key: wrapper ? void 0 : key, + name, + hintSize, + error: null + }); + return wrapper ? h("div", wrapper, ph) : ph; + } + if (kids.length) props.children = kids.map((b, j) => b(vals, ctx, j)); + return wrapper ? h("div", wrapper, h(C, props)) : h(C, props); + }; + } + function contentKey(el) { + const clone = el.cloneNode(true); + for (const d of clone.querySelectorAll("*")) { + while (d.attributes.length) d.removeAttribute(d.attributes[0].name); + } + const s = clone.innerHTML; + let h2 = 5381; + for (let i = 0; i < s.length; i++) h2 = (h2 << 5) + h2 + s.charCodeAt(i) | 0; + return s.length + "." + (h2 >>> 0).toString(36); + } + var NEVER_CONTENT_KEYED = new Set( + "script style textarea option title select canvas iframe video audio".split( + " " + ) + ); + var NOT_INLINE_SELECTOR = ":not(" + [...INLINE_TEXT_TAGS].join(",") + ")"; + function walkElement(el, host) { + const realTag = RAW_UNWRAP[el.localName] || el.localName; + const tplId = el.getAttribute("data-dc-tpl"); + const inlineOnly = el.childNodes.length > 0 && !NEVER_CONTENT_KEYED.has(realTag) && el.querySelector(NOT_INLINE_SELECTOR) === null; + const keySuffix = inlineOnly ? "|" + contentKey(el) : ""; + const { propGetters, pseudoClasses } = collectProps(el, "dom", host); + const kids = walkChildren(el, host); + return (vals, ctx, key) => { + const props = { + key: key + keySuffix, + "data-dc-tpl": tplId + }; + for (const [k, g] of propGetters) { + let v = g(vals); + if (k === "style" && typeof v === "string") v = cssToObj(v); + if ((k === "value" || k === "checked") && v === void 0) { + v = k === "checked" ? false : ""; + } + props[k] = v; + } + if (pseudoClasses.length) { + props.className = [props.className, ...pseudoClasses].filter(Boolean).join(" "); + } + return h(realTag, props, ...kids.map((b, j) => b(vals, ctx, j))); + }; + } + + // src/logic.ts + var StreamableLogic = class { + constructor(props) { + __publicField(this, "props"); + __publicField(this, "state", {}); + /** Back-pointer to the wrapper component, installed after construction. */ + __publicField(this, "__host"); + this.props = props || {}; + } + setState(update, cb) { + this.__host && this.__host.__setLogicState(update, cb); + } + forceUpdate() { + this.__host && this.__host.forceUpdate(); + } + componentDidMount() { + } + componentDidUpdate(_prevProps) { + } + componentWillUnmount() { + } + /** The flat object the template renders against (merged over props). */ + renderVals() { + return {}; + } + }; + function evalDcLogic(src) { + //! nosemgrep: eval-and-function-constructor + const fn = new Function( + "DCLogic", + "StreamableLogic", + "React", + src + '\n;return (typeof Component!=="undefined"&&Component)||undefined;' + ); + return fn(StreamableLogic, StreamableLogic, getReact()); + } + + // src/component.ts + function shallowEqual(a, b) { + if (!b) return false; + const ak = Object.keys(a).filter((k) => k !== "children"); + const bk = Object.keys(b).filter((k) => k !== "children"); + if (ak.length !== bk.length) return false; + for (const k of ak) if (a[k] !== b[k]) return false; + return true; + } + function Placeholder({ + name, + hintSize, + streaming, + error + }) { + const [w, hgt] = (hintSize || "100%,60px").split(","); + return h( + "div", + { + className: "sc-placeholder" + (streaming ? " sc-streaming" : ""), + style: { width: w.trim(), height: hgt && hgt.trim() }, + title: name + }, + error ? h( + "div", + { className: "sc-placeholder-error" }, + (name ? name + ": " : "") + error + ) : null + ); + } + function hintToMin(hint) { + if (!hint) return void 0; + const [w, hgt] = hint.split(","); + return { minWidth: w.trim(), minHeight: hgt && hgt.trim() }; + } + function createComponentFactory(registry, ensureFetched) { + const React = getReact(); + const AncestorContext = React.createContext([]); + class StreamableComponent extends React.Component { + constructor(props) { + super(props); + __publicField(this, "__name"); + __publicField(this, "__sub"); + __publicField(this, "__needsDidMount", false); + /** Snapshot of the registry's streaming flags taken at render time — + * builders read it off the RenderCtx (this) to pick placeholder vs + * render-nothing for unresolved values. */ + __publicField(this, "__streamingNow", false); + __publicField(this, "__htmlStreamingNow", false); + /** When a construct throws, remember the (class, registry.ver, props) + * triple so render-time reconcile doesn't re-attempt it on every parent + * re-render. A registry bump (new class, template, external module + * resolving via bumpAll) changes `ver` and breaks the memo so an + * env-dependent constructor can self-heal. */ + __publicField(this, "__failedLogic", null); + __publicField(this, "__failedUserProps", null); + __publicField(this, "__failedVer", -1); + /** Per-instance constructor error — kept here (not on the registry entry) + * so one instance's successful construct can't hide a sibling's failure, + * and a construct can never wipe an eval error `updateJs` recorded on + * `r.logicError`. */ + __publicField(this, "__ctorError", null); + __publicField(this, "logic"); + this.__name = props.__name; + this.state = { __v: 0, __err: null }; + this.__sub = () => { + if (this.state.__err) this.setState({ __err: null }); + this.forceUpdate(); + }; + this.__makeLogic(registry.get(this.__name).Logic, null); + ensureFetched(this.__name); + } + /** Error-boundary hook: a render crash anywhere in this DC's subtree + * (its own template, an x-import'd component, a child DC without its + * own deeper boundary) lands here instead of unmounting the page. */ + static getDerivedStateFromError(e) { + return { __err: e instanceof Error && e.message ? e.message : String(e) }; + } + componentDidCatch(e, info) { + console.error( + "[dc-runtime] render error in <" + this.__name + ">:", + e, + info?.componentStack || "" + ); + } + /** Instantiate the logic class (or the no-op base) and adopt `prevState` + * over its initial state — used both at mount and on hot-swap. */ + __makeLogic(Logic, prevState) { + const L = Logic || StreamableLogic; + try { + this.logic = new L(this.__userProps()); + this.__failedLogic = null; + this.__failedUserProps = null; + this.__ctorError = null; + } catch (e) { + console.error(e); + this.__failedLogic = Logic; + this.__failedUserProps = this.__userProps(); + this.__failedVer = registry.get(this.__name).ver; + this.__ctorError = this.__name + ": " + (e instanceof Error && e.message ? e.message : String(e)); + this.logic = new StreamableLogic( + this.__userProps() + ); + } + this.logic.__host = this; + if (prevState) + this.logic.state = { ...this.logic.state || {}, ...prevState }; + } + /** The props the author's logic + template see — internal __-prefixed + * wiring stripped. */ + __userProps() { + const { __name, __hintSize, __tplId, __hostStyle, ...rest } = this.props; + return rest; + } + __setLogicState(update, cb) { + const prev = this.logic.state; + const patch = typeof update === "function" ? update(prev) : update; + this.logic.state = { ...prev, ...patch }; + this.setState((s) => ({ __v: s.__v + 1 }), cb); + } + /** Swap the logic instance when the registry's Logic class changed + * (streaming completion, hot reload). State carries over; didMount + * re-fires after the swap commits so refs exist. */ + __reconcileLogic() { + const r = registry.get(this.__name); + const Next = r.Logic; + const Cur = this.logic.constructor; + if (Next === Cur || !Next && Cur === StreamableLogic || Next === this.__failedLogic && r.ver === this.__failedVer && shallowEqual(this.__userProps(), this.__failedUserProps)) { + return; + } + if (!this.__needsDidMount) { + try { + this.logic.componentWillUnmount(); + } catch (e) { + console.error(e); + } + } + this.__makeLogic(Next, this.logic.state); + this.__needsDidMount = true; + } + componentDidMount() { + registry.get(this.__name).subs.add(this.__sub); + try { + this.logic.componentDidMount(); + } catch (e) { + console.error(e); + } + } + componentDidUpdate(prevProps) { + this.logic.props = this.__userProps(); + if (this.__needsDidMount) { + if (this.state.__err || !registry.get(this.__name).tpl) return; + this.__needsDidMount = false; + try { + this.logic.componentDidMount(); + } catch (e) { + console.error(e); + } + } else { + try { + this.logic.componentDidUpdate(prevProps); + } catch (e) { + console.error(e); + } + } + } + componentWillUnmount() { + registry.get(this.__name).subs.delete(this.__sub); + if (!this.__needsDidMount) { + try { + this.logic.componentWillUnmount(); + } catch (e) { + console.error(e); + } + } + } + render() { + const r = registry.get(this.__name); + const cls = "sc-host" + (r.htmlStreaming ? " sc-streaming-html" : "") + (r.jsStreaming ? " sc-streaming-js" : ""); + const hintStyle = r.htmlStreaming ? hintToMin(this.props.__hintSize) : void 0; + const hostStyle = this.props.__hostStyle || hintStyle ? { ...hintStyle || {}, ...this.props.__hostStyle || {} } : void 0; + const hostBase = { + className: cls, + style: hostStyle, + "data-sc-name": this.__name, + "data-dc-tpl": this.props.__tplId + }; + const chain = Array.isArray(this.context) ? this.context : []; + if (chain.includes(this.__name)) { + const cycle = [ + ...chain.slice(chain.indexOf(this.__name)), + this.__name + ].join(" \u2192 "); + return h( + "div", + { ...hostBase, className: cls + " sc-has-error" }, + h(Placeholder, { + name: this.__name, + hintSize: this.props.__hintSize, + error: "circular import: " + cycle + }) + ); + } + if (this.state.__err) { + return h( + "div", + { ...hostBase, className: cls + " sc-has-error" }, + h( + "div", + { className: "sc-logic-error", "data-omelette-chrome": "" }, + this.__name + ": " + this.state.__err + ), + h(Placeholder, { + name: this.__name, + hintSize: this.props.__hintSize, + error: this.state.__err + }) + ); + } + this.__reconcileLogic(); + if (!r.tpl) { + return h( + "div", + hostBase, + h(Placeholder, { name: this.__name, hintSize: this.props.__hintSize }) + ); + } + const userProps = this.__userProps(); + this.logic.props = userProps; + let vals = userProps; + let renderErr = r.logicError || this.__ctorError; + try { + vals = { ...userProps, ...this.logic.renderVals() || {} }; + } catch (e) { + console.error(e); + renderErr = this.__name + ".renderVals(): " + (e instanceof Error && e.message ? e.message : String(e)); + } + this.__streamingNow = !!(r.htmlStreaming || r.jsStreaming); + this.__htmlStreamingNow = !!r.htmlStreaming; + return h( + "div", + { ...hostBase, className: cls + (renderErr ? " sc-has-error" : "") }, + renderErr && h( + "div", + { className: "sc-logic-error", "data-omelette-chrome": "" }, + renderErr + ), + h( + AncestorContext.Provider, + { value: [...chain, this.__name] }, + r.tpl(vals, this) + ) + ); + } + } + __publicField(StreamableComponent, "contextType", AncestorContext); + const named = /* @__PURE__ */ new Map(); + function getDC(name) { + const hit = named.get(name); + if (hit) return hit; + function Dispatcher(p) { + const [, setTick] = React.useState(0); + React.useEffect(() => { + const sub = () => setTick((n) => n + 1); + registry.get(name).subs.add(sub); + return () => { + registry.get(name).subs.delete(sub); + }; + }, []); + ensureFetched(name); + return h(StreamableComponent, { ...p, __name: name }); + } + Dispatcher.displayName = name; + named.set(name, Dispatcher); + return Dispatcher; + } + return { + getDC, + StreamableComponent + }; + } + + // src/bundled.ts + function bundledBlob(url) { + const blobs = window.__resourceBlobs; + const b = blobs ? blobs[url.split("#")[0]] : void 0; + return b instanceof Blob ? b : null; + } + + // src/cdn.ts + var REACT_URL = "https://unpkg.com/react@18.3.1/umd/react.production.min.js"; + var REACT_SRI = "sha384-DGyLxAyjq0f9SPpVevD6IgztCFlnMF6oW/XQGmfe+IsZ8TqEiDrcHkMLKI6fiB/Z"; + var REACT_DOM_URL = "https://unpkg.com/react-dom@18.3.1/umd/react-dom.production.min.js"; + var REACT_DOM_SRI = "sha384-gTGxhz21lVGYNMcdJOyq01Edg0jhn/c22nsx0kyqP0TxaV5WVdsSH1fSDUf5YJj1"; + var BABEL_URL = "https://unpkg.com/@babel/standalone@7.29.0/babel.min.js"; + var BABEL_SRI = "sha384-m08KidiNqLdpJqLq95G/LEi8Qvjl/xUYll3QILypMoQ65QorJ9Lvtp2RXYGBFj1y"; + function cdnScriptFor(url, sri) { + const res = window.__resources; + const v = res ? res[url] : void 0; + return typeof v === "string" && v ? { src: v } : { src: url, integrity: sri }; + } + + // src/external.ts + var isCustomElementName = (n) => !n.includes(".") && n.includes("-"); + function isRenderableType(g) { + if (typeof g === "function") return !isElementClass(g); + return typeof g === "object" && g !== null && typeof g.$$typeof === "symbol"; + } + function resolveDottedPath(root, name) { + let cur = root; + for (const seg of name.split(".")) { + if (cur == null) return void 0; + cur = cur[seg]; + } + return cur; + } + var GLOBAL_POLL_INTERVAL_MS = 50; + var GLOBAL_POLL_TIMEOUT_MS = 3e4; + function createExternalModules(onResolved) { + const cache = /* @__PURE__ */ new Map(); + let babelLoading = null; + const reportedMissing = /* @__PURE__ */ new Map(); + const polling = /* @__PURE__ */ new Set(); + function ensureBabel() { + if (window.Babel) return Promise.resolve(); + if (babelLoading) return babelLoading; + const babel = cdnScriptFor(BABEL_URL, BABEL_SRI); + babelLoading = new Promise((res, rej) => { + const s = document.createElement("script"); + s.src = babel.src; + if (babel.integrity) { + s.integrity = babel.integrity; + s.crossOrigin = "anonymous"; + } + s.onload = () => res(); + s.onerror = rej; + document.head.appendChild(s); + }); + return babelLoading; + } + const pending = /* @__PURE__ */ new Map(); + function load(kind, url, after) { + const existing = pending.get(url); + if (existing) return existing; + cache.set(url, null); + console.info("[dc-runtime] x-import: loading", url, "(" + kind + ")"); + const ready = Promise.all([ + kind === "jsx" ? ensureBabel() : Promise.resolve(), + after ?? Promise.resolve() + ]); + const p = ready.then(() => { + const pre = bundledBlob(url); + if (pre) return pre.text(); + return fetch(url).then((r) => { + if (!r.ok) throw new Error("HTTP " + r.status); + return r.text(); + }); + }).then((src) => { + const code = kind === "jsx" ? window.Babel.transform(src, { + filename: url, + presets: ["react", "typescript"] + }).code : src; + const module = { exports: {} }; + const before = new Set(Object.keys(window)); + //! nosemgrep: eval-and-function-constructor + new Function("React", "module", "exports", "require", code)( + getReact(), + module, + module.exports, + () => ({}) + ); + const globals = {}; + for (const k of Object.keys(window)) { + if (!before.has(k) && typeof window[k] === "function") { + globals[k] = window[k]; + } + } + cache.set(url, { mod: module.exports, globals }); + console.info( + "[dc-runtime] x-import: loaded", + url, + "\u2014 exports:", + Object.keys(module.exports), + "window globals:", + Object.keys(globals) + ); + onResolved(); + }).catch((e) => { + cache.set(url, { + mod: {}, + globals: {}, + error: "failed to load: " + (e instanceof Error && e.message ? e.message : String(e)) + }); + console.error( + "[dc-runtime] x-import: FAILED to load", + url, + "(" + kind + ")", + e + ); + onResolved(); + }); + pending.set(url, p); + return p; + } + function resolve2(url, name) { + const entry = cache.get(url); + if (!entry) return null; + const { mod, globals } = entry; + const C = mod && mod[name] || globals && globals[name] || typeof window !== "undefined" && window[name] || mod && mod.default; + if (typeof C === "function") return C; + const key = url + "\0" + name; + if (!reportedMissing.has(key)) { + reportedMissing.set( + key, + entry.error || 'no export named "' + name + '" (has: ' + Object.keys(mod).join(", ") + ")" + ); + console.error( + "[dc-runtime] x-import: module", + url, + "loaded but has no component named", + JSON.stringify(name), + "\u2014 available exports:", + Object.keys(mod), + "window globals:", + Object.keys(globals), + ". The module must `module.exports = {" + name + "}` or set `window." + name + "`." + ); + } + return null; + } + function waitForGlobal(name) { + if (polling.has(name)) return; + polling.add(name); + const started = Date.now(); + const isCE = isCustomElementName(name); + const tick = () => { + const found = isCE ? customElements.get(name) : isRenderableType(resolveDottedPath(window, name)); + if (found) { + polling.delete(name); + onResolved(); + return; + } + if (Date.now() - started >= GLOBAL_POLL_TIMEOUT_MS) { + console.warn( + "[dc-runtime] x-import: global", + JSON.stringify(name), + "never appeared on window after " + GLOBAL_POLL_TIMEOUT_MS + "ms" + ); + return; + } + setTimeout(tick, GLOBAL_POLL_INTERVAL_MS); + }; + setTimeout(tick, GLOBAL_POLL_INTERVAL_MS); + } + function resolveGlobal(url, name) { + const isCE = isCustomElementName(name); + if (!url) { + if (isCE) { + if (customElements.get(name)) return name; + waitForGlobal(name); + return null; + } + const g2 = resolveDottedPath(window, name); + if (isRenderableType(g2)) return g2; + waitForGlobal(name); + return null; + } + const entry = cache.get(url); + if (!entry) return null; + if (isCE && customElements.get(name)) return name; + const g = entry.globals[name] ?? resolveDottedPath(window, name); + if (isRenderableType(g)) return g; + if (name.includes(".")) return null; + const key = url + "\0global\0" + name; + if (!reportedMissing.has(key)) { + reportedMissing.set(key, null); + if (isCE && !customElements.get(name)) { + console.warn( + "[dc-runtime] x-import:", + url, + "loaded but no custom element", + JSON.stringify(name), + "is registered and window." + name + " is not a function \u2014 rendering <" + name + "> as an unknown element." + ); + } + } + return name; + } + function getError(url, name) { + const entry = cache.get(url); + if (entry?.error) return entry.error; + return reportedMissing.get(url + "\0" + name) || null; + } + return { load, resolve: resolve2, resolveGlobal, getError }; + } + function isElementClass(g) { + try { + return typeof g === "function" && typeof HTMLElement !== "undefined" && g.prototype instanceof HTMLElement; + } catch { + return false; + } + } + + // src/atomics.ts + var ATOMIC_CSS = ( + // layout + ".fx{display:flex}.col{display:flex;flex-direction:column}.grid{display:grid}.ac{align-items:center}.jc{justify-content:center}.jb{justify-content:space-between}.f1{flex:1}.noshrink{flex-shrink:0}.wrap{flex-wrap:wrap}.fw5{font-weight:500}.fw6{font-weight:600}.fw7{font-weight:700}.fw8{font-weight:800}.fs11{font-size:11px}.fs12{font-size:12px}.fs13{font-size:13px}.fs14{font-size:14px}.fs15{font-size:15px}.fs16{font-size:16px}.fs20{font-size:20px}.fs22{font-size:22px}.upper{text-transform:uppercase}.tc{text-align:center}.nowrap{white-space:nowrap}.gap8{gap:8px}.gap10{gap:10px}.gap12{gap:12px}.gap16{gap:16px}.gap24{gap:24px}.m0{margin:0}.mt8{margin-top:8px}.mt12{margin-top:12px}.mt16{margin-top:16px}.mb8{margin-bottom:8px}.mb12{margin-bottom:12px}.mb16{margin-bottom:16px}.posrel{position:relative}.posabs{position:absolute}.round{border-radius:50%}.ohide{overflow:hidden}.bbox{box-sizing:border-box}.pointer{cursor:pointer}.w100{width:100%}.b0{border:none}" + ); + + // src/helmet.ts + var DESIGN_DOC_MODE_RE = /]*\bname\s*=\s*["']design_doc_mode["'][^>]*\b(?:content|value)\s*=\s*["'](\w+)["']/i; + var CANVAS_BG_LIGHT = "#f0eee6"; + var CANVAS_BG_DARK = "#2e2c26"; + function createHelmetManager(doc, isStreaming) { + const mounted = /* @__PURE__ */ new Set(); + const live = /* @__PURE__ */ new Map(); + let designDocMode = null; + let canvasStyleEl = null; + let appTheme = "light"; + try { + const ds = doc.documentElement.dataset.theme; + appTheme = ds === "dark" || ds === "light" ? ds : new URLSearchParams(doc.defaultView?.location.search ?? "").get( + "theme" + ) === "dark" ? "dark" : "light"; + } catch { + } + function applyCanvasBg() { + if (!canvasStyleEl) return; + const bg = appTheme === "dark" ? CANVAS_BG_DARK : CANVAS_BG_LIGHT; + canvasStyleEl.textContent = `html,body{background:${bg}}#dc-root>.sc-host{position:relative}`; + } + function postDesignMode(mode) { + if (window.parent === window) return; + try { + window.parent.postMessage({ type: "__dc_design_mode", mode }, "*"); + } catch { + } + } + function setDesignDocMode(mode) { + if (mode === designDocMode) return; + designDocMode = mode; + postDesignMode(mode); + if (mode === "canvas") { + doc.documentElement.setAttribute("data-dc-canvas", ""); + canvasStyleEl = doc.createElement("style"); + canvasStyleEl.setAttribute("data-dc-canvas", ""); + applyCanvasBg(); + doc.head.appendChild(canvasStyleEl); + } else { + doc.documentElement.removeAttribute("data-dc-canvas"); + canvasStyleEl?.remove(); + canvasStyleEl = null; + } + } + window.addEventListener("message", (e) => { + const type = e.data && e.data.type; + if (type === "__dc_theme") { + const t = e.data.theme; + if (t === "light" || t === "dark") { + appTheme = t; + applyCanvasBg(); + } + return; + } + if (!designDocMode || type !== "__dc_probe") return; + postDesignMode(designDocMode); + }); + function compile(node) { + const raw = [...node.children]; + const helmetClosed = node.nextSibling != null || node.parentNode?.nextSibling != null; + if (node.hasAttribute("data-dc-atomics") && !mounted.has("__dc-atomics")) { + mounted.add("__dc-atomics"); + const el = doc.createElement("style"); + el.id = "__dc-atomics"; + el.textContent = ATOMIC_CSS; + doc.head.appendChild(el); + } + return (_vals, ctx) => { + const name = ctx && ctx.__name || ""; + const streaming = !!(name && isStreaming(name)); + for (let i = 0; i < raw.length; i++) { + const child = raw[i]; + const tag = child.tagName; + const mayBePartial = streaming && !helmetClosed && i === raw.length - 1; + if (tag === "SCRIPT") { + if (mayBePartial) continue; + const key = "SCRIPT|" + (child.getAttribute("src") || child.textContent || ""); + if (mounted.has(key)) continue; + mounted.add(key); + const el = doc.createElement("script"); + for (const { name: an, value } of [...child.attributes]) + el.setAttribute(an, value); + if (child.textContent) el.textContent = child.textContent; + doc.head.appendChild(el); + } else if (tag === "LINK" || tag === "META") { + if (mayBePartial) continue; + const key = tag + "|" + (child.getAttribute("href") || child.getAttribute("src") || child.outerHTML); + if (mounted.has(key)) continue; + mounted.add(key); + if (tag === "LINK") { + const rel = (child.getAttribute("rel") || "").toLowerCase().split(/\s+/); + const href = (child.getAttribute("href") || "").trim(); + const res = window.__resources; + const pre = res && rel.includes("stylesheet") && !rel.includes("alternate") ? res[href] : void 0; + const blob = typeof pre === "string" && pre ? bundledBlob(pre) : null; + if (blob) { + const el = doc.createElement("style"); + if (child.hasAttribute("disabled")) { + el.setAttribute("media", "not all"); + } else if (child.getAttribute("media")) { + el.setAttribute("media", child.getAttribute("media")); + } + if (child.getAttribute("title")) + el.setAttribute("title", child.getAttribute("title")); + void blob.text().then((css) => { + el.textContent = css; + }); + doc.head.appendChild(el); + continue; + } + } + doc.head.appendChild(child.cloneNode(true)); + } else { + const key = name + "|" + i; + let el = live.get(key); + if (!el || el.tagName !== tag) { + if (el) el.remove(); + el = doc.createElement(tag.toLowerCase()); + live.set(key, el); + doc.head.appendChild(el); + } + for (const { name: an, value } of [...child.attributes]) { + if (el.getAttribute(an) !== value) el.setAttribute(an, value); + } + if (el.textContent !== child.textContent) + el.textContent = child.textContent; + } + } + return null; + }; + } + return { compile, setDesignDocMode }; + } + + // src/pseudo.ts + function createPseudoSheet(doc) { + let el = null; + const cache = /* @__PURE__ */ new Map(); + let n = 0; + return (pseudo, css) => { + const k = pseudo + "|" + css; + const hit = cache.get(k); + if (hit) return hit; + if (!el) { + el = doc.createElement("style"); + doc.head.appendChild(el); + } + const cls = "scp" + (n++).toString(36); + const sel = pseudo === "before" || pseudo === "after" ? "." + cls + "::" + pseudo : "." + cls + ":" + pseudo; + el.sheet.insertRule(sel + "{" + css + "}", el.sheet.cssRules.length); + cache.set(k, cls); + return cls; + }; + } + + // src/registry.ts + function createRegistry() { + const entries = /* @__PURE__ */ Object.create(null); + function get(name) { + return entries[name] || (entries[name] = { + html: "", + tpl: null, + Logic: null, + jsStreaming: false, + htmlStreaming: false, + ver: 0, + subs: /* @__PURE__ */ new Set(), + fetched: false + }); + } + function bump(name) { + const r = get(name); + r.ver++; + for (const fn of r.subs) fn(); + } + return { + entries, + get, + bump, + bumpAll() { + for (const n in entries) bump(n); + } + }; + } + + // src/runtime.ts + var COMPONENT_DIR = "."; + function createRuntime(doc = document) { + const registry = createRegistry(); + const pseudoClass = createPseudoSheet(doc); + const helmet = createHelmetManager( + doc, + (name) => registry.get(name).htmlStreaming + ); + const external = createExternalModules(() => registry.bumpAll()); + const factory = createComponentFactory(registry, ensureFetched); + const host = { + component: (name) => factory.getDC(name), + placeholder: (props) => h(Placeholder, props), + helmet: (node) => helmet.compile(node), + loadExternal: (kind, url, after) => external.load(kind, url, after), + resolveExternal: (url, name) => external.resolve(url, name), + resolveExternalGlobal: (url, name) => external.resolveGlobal(url, name), + resolveExternalError: (url, name) => external.getError(url, name), + pseudoClass + }; + function ensureFetched(name) { + const r = registry.get(name); + if (r.fetched) return; + r.fetched = true; + const url = COMPONENT_DIR + "/" + encodeURIComponent(name) + ".dc.html"; + const res = window.__resources; + const pre = res ? res[url] : void 0; + const target = typeof pre === "string" && pre ? pre : url; + const blob = bundledBlob(target); + (blob ? blob.text() : fetch(target).then((res2) => { + if (!res2.ok) { + console.error( + '[dc-runtime] sibling fetch for "' + name + '" failed:', + url, + "returned", + res2.status, + "\u2014 the reference renders as an empty placeholder." + ); + return ""; + } + return res2.text(); + })).then((t) => { + if (!t) return; + const parsed = parseDcText(t); + if (!parsed) { + console.error( + '[dc-runtime] sibling fetch for "' + name + '":', + url, + "has no block \u2014 not a Design Component." + ); + return; + } + if (parsed.props) r.propsMeta = parsed.props; + if (parsed.preview) r.preview = parsed.preview; + if (parsed.template && !r.html) updateHtml(name, parsed.template); + if (parsed.js && !r.Logic) updateJs(name, parsed.js); + }).catch( + (e) => console.error( + '[dc-runtime] sibling fetch for "' + name + '" threw:', + url, + e + ) + ); + } + let rootName = null; + function updateHtml(name, html) { + const r = registry.get(name); + r.html = html; + if (name === rootName) { + const mode = DESIGN_DOC_MODE_RE.exec(html)?.[1] ?? null; + if (mode || !r.htmlStreaming) helmet.setDesignDocMode(mode); + } + try { + r.tpl = compileTemplate(html, host); + } catch (e) { + console.error("[dc-runtime] template compile FAILED for", name, e); + } + registry.bump(name); + } + function updateJs(name, src) { + const r = registry.get(name); + const seq = r.jsSeq = (r.jsSeq || 0) + 1; + try { + const Cls = evalDcLogic(src); + if (r.jsSeq !== seq) return; + if (typeof Cls !== "function") { + r.logicError = name + ".dc.html: + + + + + + + + + + + + + + + + + + {{ t.label }} + + + + + + + + + + + + + + + + {{ statusLabel }} + + {{ channelLabel }} + + + + {{ timer }} + + + + + + + + + + + {{ initials }} + + + {{ peerName }} + {{ subCaption }} + + + + + + + + + Длительность {{ finalDuration }} + + + + + + + + {{ statusIcon }} + + {{ statusTitle }} + {{ statusCaption }} + + + + + + + + + + + + Отклонить + + + + + + Принять + + + + + + + + + {{ micIcon }} + Микрофон + + + {{ spkIcon }} + Динамик + + + + + + + + + + + + + + + + Отменить + + + + + + Закрыть + Позвонить снова + + + + + + Позвонить снова + + + + + + Закрыть + Повторить + + + + + + + + Восстанавливаем связь… + + + + + + + + + + + + Закрыть + Повторить проверку + + + + + + + + + Отменить + + + + + + + Полноэкранный аудиозвонок · тёмная тема, живая осциллограмма собеседника. Переключайте состояния выше. + + + + + +