✨ feat(calls): add audio calls alongside video calls

Introduce a call kind (AUDIO default, VIDEO) so dialogs support both audio
and video calls. Video path is unchanged; audio is a separate full-screen
component built 1:1 on the «Аудиозвонок.dc.html» baseline.

Backend
- CallSession.kind (CallKind AUDIO|VIDEO, default AUDIO) + migration 0005
- create_call_request(kind), CallCreateView validates kind from request body
- serializers expose kind in call_payload, public_invite_payload,
  public_call_state_payload (drop stale hardcoded capabilities)
- timeline system message reflects kind («аудио-/видеозвонок»)
- tests updated for the kind field and new message wording

@edevs/ui
- useCallRtcSession gains videoEnabled (default true; video unchanged);
  audio requests audio-only getUserMedia, no camera fallback
- AudioCallView, AudioCallWaveform (live AnalyserNode oscilloscope),
  AudioCallIcons (mic/speaker/phone/status), audioCallStates, audio-call.css
- exported via index.ts

internal-ui
- two call buttons in the conversation header (audio + video)
- CallOverlay becomes a kind dispatcher: VideoCallOverlay (unchanged) /
  AudioCallOverlay; kind threaded through model/hook/workspace

web-chat
- CallInfo.kind; invite banner shows audio vs video title and icon
- CallApp dispatches to AudioCallStage for audio; audio resolve/build in model

Infra
- compose.yaml: widen Coturn relay UDP range to 49160-49999 (~420 relay
  calls) with explanatory comment
This commit is contained in:
Andrey committed 2026-07-31 01:35:29 +03:00
1 parent 1a66659c30
commit e78eb9f17d
43 files changed
+9497 -167

No files matched your search

@@ -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,
),
),
]
@@ -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)
@@ -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,
+7 -2
View File
@@ -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: приглашение забирает виджет поллингом, внешней отправки нет.
@@ -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))
@@ -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,
)
+6 -3
View File
@@ -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(
@@ -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 (
<Modal open={props.open} onCancel={endAndClose} footer={null} closable={false} width={428} className="call-modal audio-call-modal" destroyOnHidden>
<AudioCallView
mode={mode}
status={status ?? undefined}
peerName={props.dialog.name}
peerInitials={props.dialog.initials}
channelLabel={channel.label}
showChannel
micOn={rtc.micOn}
speakerOn
remoteStream={rtc.remoteStream}
remoteMicOn={rtc.remoteMicOn}
elapsedSeconds={elapsed}
durationSeconds={call?.durationSeconds ?? null}
mediaIssue={rtc.mediaIssue}
statusLabel={statusLabel(mode)}
subCaption={subCaption}
onToggleMic={rtc.toggleMic}
onToggleSpeaker={toggleSpeaker}
onAccept={onAccept}
onDecline={props.onCancel}
onCancel={mode === "ringing" || mode === "connecting" ? props.onCancel : endAndClose}
onEnd={onEnd}
onClose={endAndClose}
onCallAgain={props.onRetry}
onRetry={onRetry}
/>
</Modal>
);
}
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;
}
@@ -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, { icon: CallViewStatus["icon"]; title: string; caption: (name: string) => 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 (
<Modal open={props.open} onCancel={endAndClose} footer={null} closable={false} width={428} className="call-modal" destroyOnHidden>
<CallView
mode={mode}
peerName={props.dialog.name}
peerInitials={props.dialog.initials}
peerAvatarColor={props.dialog.avatarBg}
subtitle={subtitle}
channelBadge={<em className="call-channel" style={{ background: channel.bg, color: channel.color }}>{channel.label}</em>}
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}
/>
</Modal>
);
}
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 <VideoCallOverlay {...props} />;
return <AudioCallOverlay {...props} />;
}
@@ -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<boolean> }) {
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<boolean> }) {
const timelineRef = useRef<HTMLDivElement>(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" && <button className="sales-claim-button" onClick={onClaim}><Icon name="check" size={15} />Забрать</button>}
{controlMode === "ai" && <button className="sales-ai-button" onClick={onClaim}>Перехватить AI</button>}
{detail?.lifecycle === "OPEN" && dialog.channel !== "EMAIL" && (
<button className="sales-more-button" aria-label="Запросить онлайн-звонок" title="Запросить онлайн-звонок" onClick={onCall}><Icon name="phone" size={17} /></button>
<>
<button className="sales-more-button" aria-label="Запросить аудиозвонок" title="Запросить аудиозвонок" onClick={() => onCall("AUDIO")}><Icon name="phone" size={17} /></button>
<button className="sales-more-button" aria-label="Запросить видеозвонок" title="Запросить видеозвонок" onClick={() => onCall("VIDEO")}><Icon name="video" size={17} /></button>
</>
)}
<ConversationActions open={detail?.lifecycle === "OPEN"} onClose={onClose} onSpam={onSpam} />
</div>
@@ -153,7 +153,7 @@ export function ConversationWorkspace({ department, listTitle, searchPlaceholder
setSelectedId={setSelectedId}
/>
<section className="sales-conversation">
<ConversationThread controlMode={controlMode} dialog={selectedDialog} detail={detail} onClaim={onClaim} onCall={() => void callController.start()} onClose={onClose} onSpam={onSpam} />
<ConversationThread controlMode={controlMode} dialog={selectedDialog} detail={detail} onClaim={onClaim} onCall={(kind) => void callController.start(kind)} onClose={onClose} onSpam={onSpam} />
{(detailError || actionError) && <div className="sales-conversation-error">{detailError || actionError}</div>}
<CallOverlay
open={callController.open}
@@ -0,0 +1,162 @@
// Видеозвонок (оператор): antd Modal + CallView. Путь видео не меняется — вынесен
// из CallOverlay при добавлении аудиозвонка, чтобы CallOverlay остался тонким
// диспетчером по call.kind (NO GOD / separation of concerns).
import { CallView, type CallViewMode, type CallViewStatus, useCallRtcSession, useLoopingAudio } from "@edevs/ui";
import { Modal } from "antd";
import { useEffect, useMemo, useState } from "react";
import { providerMeta } from "../../shared/providers";
import type { ApiCall, CallAccess } from "./model";
import type { ConversationListItem } from "./types";
import "./call.css";
const TERMINAL: Record<string, { icon: CallViewStatus["icon"]; title: string; caption: (name: string) => 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 (
<Modal open={props.open} onCancel={endAndClose} footer={null} closable={false} width={428} className="call-modal" destroyOnHidden>
<CallView
mode={mode}
peerName={props.dialog.name}
peerInitials={props.dialog.initials}
peerAvatarColor={props.dialog.avatarBg}
subtitle={subtitle}
channelBadge={<em className="call-channel" style={{ background: channel.bg, color: channel.color }}>{channel.label}</em>}
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}
/>
</Modal>
);
}
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")}`;
}
@@ -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);
@@ -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<CallAccess | null>(null);
const [errorText, setErrorText] = useState("");
const [busy, setBusy] = useState(false);
const lastKind = useRef<CallKind>("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();
+3 -1
View File
@@ -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<typeof name, ReactNode> = {
grid: <><rect x="3" y="3" width="7" height="9" rx="1.4" /><rect x="14" y="3" width="7" height="5" rx="1.4" /><rect x="14" y="12" width="7" height="9" rx="1.4" /><rect x="3" y="16" width="7" height="5" rx="1.4" /></>,
@@ -73,6 +73,8 @@ export function Icon({ name, size = 17 }: { name: "grid" | "building" | "team" |
paperclip: <path d="m21.44 11.05-9.19 9.19a6 6 0 0 1-8.49-8.49l8.57-8.57A4 4 0 1 1 18 8.84l-8.59 8.57a2 2 0 0 1-2.83-2.83l8.49-8.48" />,
send: <><line x1="22" y1="2" x2="11" y2="13" /><polygon points="22 2 15 22 11 13 2 9 22 2" /></>,
phone: <path d="M22 16.92v3a2 2 0 0 1-2.18 2 19.79 19.79 0 0 1-8.63-3.07 19.5 19.5 0 0 1-6-6 19.79 19.79 0 0 1-3.07-8.67A2 2 0 0 1 4.11 2h3a2 2 0 0 1 2 1.72c.13.96.36 1.9.7 2.81a2 2 0 0 1-.45 2.11L8.09 9.91a16 16 0 0 0 6 6l1.27-1.27a2 2 0 0 1 2.11-.45c.91.34 1.85.57 2.81.7A2 2 0 0 1 22 16.92Z" />,
// Tabler ti-video: видеокамера — видеозвонок.
video: <><path d="M15 10l4.553-2.276A1 1 0 0 1 21 8.618v6.764a1 1 0 0 1-1.447.894L15 14" /><path d="M3 6a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v12a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z" /></>,
refresh: <><path d="M3 12a9 9 0 0 1 15-6.7L21 8" /><path d="M21 3v5h-5" /><path d="M21 12a9 9 0 0 1-15 6.7L3 16" /><path d="M3 21v-5h5" /></>,
transfer: <><path d="M17 1l4 4-4 4" /><path d="M3 11V9a4 4 0 0 1 4-4h14" /><path d="M7 23l-4-4 4-4" /><path d="M21 13v2a4 4 0 0 1-4 4H3" /></>,
warning: <><path d="m21.7 18-8-14a2 2 0 0 0-3.5 0l-8 14A2 2 0 0 0 4 21h16a2 2 0 0 0 1.7-3Z" /><line x1="12" y1="9" x2="12" y2="13" /><line x1="12" y1="17" x2="12.01" y2="17" /></>,
+6 -1
View File
@@ -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) => <svg viewBox="0 0 24 24" width="20" height="20" fill="none" stroke="#fff" strokeWidth="1.9" strokeLinecap="round" strokeLinejoin="round" style={rotated ? { transform: "rotate(135deg)" } : undefined}><path d="M22 16.92v3a2 2 0 0 1-2.18 2 19.79 19.79 0 0 1-8.63-3.07 19.5 19.5 0 0 1-6-6 19.79 19.79 0 0 1-3.07-8.67A2 2 0 0 1 4.11 2h3a2 2 0 0 1 2 1.72c.13.81.36 1.6.7 2.34a2 2 0 0 1-.45 2.11L8.09 9.91a16 16 0 0 0 6 6l1.74-1.27a2 2 0 0 1 2.11-.45c.74.34 1.53.57 2.34.7A2 2 0 0 1 22 16.92z" /></svg>;
return <div style={{ flex: "none", background: "#fff", borderTop: "1px solid #f0f0f0", padding: "12px 14px", display: "flex", alignItems: "center", gap: 12 }}><div style={{ width: 40, height: 40, borderRadius: "50%", background: accent, display: "flex", alignItems: "center", justifyContent: "center", flex: "none" }}><svg viewBox="0 0 24 24" width="20" height="20" fill="none" stroke="#fff" strokeWidth="1.9" strokeLinecap="round" strokeLinejoin="round"><path d="M23 7l-7 5 7 5V7z" /><rect x="1" y="5" width="15" height="14" rx="2.5" /></svg></div><div style={{ flex: 1, minWidth: 0 }}><div style={{ fontSize: 13.5, fontWeight: 600, color: "#1f1f1f" }}>Входящий видеозвонок</div><div style={{ fontSize: 12, color: "#8c8c8c", marginTop: 2, whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis" }}>{call.staffName || "Оператор"} приглашает вас на онлайн-звонок</div></div><button onClick={onDecline} aria-label="Отклонить" style={{ width: 42, height: 42, borderRadius: "50%", border: "none", background: "#ff4d4f", display: "flex", alignItems: "center", justifyContent: "center", cursor: "pointer", flex: "none", boxShadow: "0 2px 8px rgba(255,77,79,.35)" }}>{phoneIcon(true)}</button><button onClick={onAccept} aria-label="Принять" style={{ width: 42, height: 42, borderRadius: "50%", border: "none", background: "#52c41a", display: "flex", alignItems: "center", justifyContent: "center", cursor: "pointer", flex: "none", boxShadow: "0 2px 8px rgba(82,196,26,.35)" }}>{phoneIcon(false)}</button></div>;
const isVideo = call.kind !== "AUDIO";
const inviteIcon = isVideo
? <svg viewBox="0 0 24 24" width="20" height="20" fill="none" stroke="#fff" strokeWidth="1.9" strokeLinecap="round" strokeLinejoin="round"><path d="M23 7l-7 5 7 5V7z" /><rect x="1" y="5" width="15" height="14" rx="2.5" /></svg>
: <svg viewBox="0 0 24 24" width="20" height="20" fill="none" stroke="#fff" strokeWidth="1.9" strokeLinecap="round" strokeLinejoin="round"><path d="M22 16.92v3a2 2 0 0 1-2.18 2 19.79 19.79 0 0 1-8.63-3.07 19.5 19.5 0 0 1-6-6 19.79 19.79 0 0 1-3.07-8.67A2 2 0 0 1 4.11 2h3a2 2 0 0 1 2 1.72c.13.81.36 1.6.7 2.34a2 2 0 0 1-.45 2.11L8.09 9.91a16 16 0 0 0 6 6l1.74-1.27a2 2 0 0 1 2.11-.45c.74.34 1.53.57 2.34.7A2 2 0 0 1 22 16.92z" /></svg>;
const title = isVideo ? "Входящий видеозвонок" : "Входящий аудиозвонок";
return <div style={{ flex: "none", background: "#fff", borderTop: "1px solid #f0f0f0", padding: "12px 14px", display: "flex", alignItems: "center", gap: 12 }}><div style={{ width: 40, height: 40, borderRadius: "50%", background: accent, display: "flex", alignItems: "center", justifyContent: "center", flex: "none" }}>{inviteIcon}</div><div style={{ flex: 1, minWidth: 0 }}><div style={{ fontSize: 13.5, fontWeight: 600, color: "#1f1f1f" }}>{title}</div><div style={{ fontSize: 12, color: "#8c8c8c", marginTop: 2, whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis" }}>{call.staffName || "Оператор"} приглашает вас на онлайн-звонок</div></div><button onClick={onDecline} aria-label="Отклонить" style={{ width: 42, height: 42, borderRadius: "50%", border: "none", background: "#ff4d4f", display: "flex", alignItems: "center", justifyContent: "center", cursor: "pointer", flex: "none", boxShadow: "0 2px 8px rgba(255,77,79,.35)" }}>{phoneIcon(true)}</button><button onClick={onAccept} aria-label="Принять" style={{ width: 42, height: 42, borderRadius: "50%", border: "none", background: "#52c41a", display: "flex", alignItems: "center", justifyContent: "center", cursor: "pointer", flex: "none", boxShadow: "0 2px 8px rgba(82,196,26,.35)" }}>{phoneIcon(false)}</button></div>;
}
function Bubble({ author, text, accent, pendingState }: { author: "client" | "ai" | "operator"; text: string; accent: string; pendingState?: boolean }) {
+2
View File
@@ -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;
+125
View File
@@ -0,0 +1,125 @@
// Аудиозвонок (клиент): audio-сцена страницы /calls/<invite>. Не делает 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 (
<main className="public-call-page">
<AudioCallView
mode={mode}
status={status}
peerName={peerName}
peerInitials={initials}
micOn={rtc.micOn}
speakerOn
remoteStream={rtc.remoteStream}
remoteMicOn={rtc.remoteMicOn}
elapsedSeconds={elapsed}
durationSeconds={call?.durationSeconds ?? null}
mediaIssue={rtc.mediaIssue}
statusLabel={audioCallStatusLabel(mode, status)}
subCaption={mode === "active" ? (rtc.micOn ? "Говорите" : "Ваш микрофон выключен") : undefined}
onToggleMic={rtc.toggleMic}
onAccept={() => void join()}
onCancel={() => void onCancel()}
onEnd={rtc.end}
onClose={close}
/>
</main>
);
}
function useConnectionTimer(active: boolean) {
const [startedAt, setStartedAt] = useState<number | null>(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;
}
+8
View File
@@ -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 <AudioCallStage call={call} accessToken={accessToken} iceServers={iceServers} loading={loading} invalid={invalid} onCall={setCall} />;
}
const applyBootstrap = useCallback((value: CallBootstrap) => {
sessionStorage.setItem(storageKey(), value.accessToken);
setAccessToken(value.accessToken);
+34 -1
View File
@@ -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")}`;
}
+5 -1
View File
@@ -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
Binary file not shown.

After

Width:  |  Height:  |  Size: 5.5 KiB

@@ -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": "<ActorBadge> doesn't accept that prop. Declared props: actor, initials, color."
},
{
"selector": "JSXOpeningElement[name.name='ActorBadge'] > JSXAttribute[name.name='actor'] > Literal[value!=/^(?:ai|human)$/]",
"message": "<ActorBadge> actor must be one of 'ai' | 'human'."
},
{
"selector": "JSXOpeningElement[name.name='AttentionStatus'] > JSXAttribute > JSXIdentifier[name!=/^(?:level|label|key|ref|className|style|children)$/]",
"message": "<AttentionStatus> doesn't accept that prop. Declared props: level, label."
},
{
"selector": "JSXOpeningElement[name.name='AttentionStatus'] > JSXAttribute[name.name='level'] > Literal[value!=/^(?:ok|attention|critical)$/]",
"message": "<AttentionStatus> 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": "<Avatar> 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": "<Button> 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": "<Button> variant must be one of 'primary' | 'secondary' | 'ghost' | 'danger'."
},
{
"selector": "JSXOpeningElement[name.name='Button'] > JSXAttribute[name.name='size'] > Literal[value!=/^(?:sm|md|lg)$/]",
"message": "<Button> size must be one of 'sm' | 'md' | 'lg'."
},
{
"selector": "JSXOpeningElement[name.name='Button'] > JSXAttribute[name.name='type'] > Literal[value!=/^(?:button|submit)$/]",
"message": "<Button> type must be one of 'button' | 'submit'."
},
{
"selector": "JSXOpeningElement[name.name='ChannelBadge'] > JSXAttribute > JSXIdentifier[name!=/^(?:channel|title|key|ref|className|style|children)$/]",
"message": "<ChannelBadge> doesn't accept that prop. Declared props: channel, title."
},
{
"selector": "JSXOpeningElement[name.name='ChannelBadge'] > JSXAttribute[name.name='channel'] > Literal[value!=/^(?:max|telegram|webchat)$/]",
"message": "<ChannelBadge> 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": "<Checkbox> 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": "<Checkbox> size must be one of 'sm' | 'md'."
},
{
"selector": "JSXOpeningElement[name.name='CommerceStatusTimeline'] > JSXAttribute > JSXIdentifier[name!=/^(?:steps|key|ref|className|style|children)$/]",
"message": "<CommerceStatusTimeline> 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": "<ConversationInboxItem> 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": "<ConversationInboxItem> channel must be one of 'max' | 'telegram' | 'webchat'."
},
{
"selector": "JSXOpeningElement[name.name='ConversationInboxItem'] > JSXAttribute[name.name='lastActor'] > Literal[value!=/^(?:ai|human)$/]",
"message": "<ConversationInboxItem> 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": "<DepartmentCard> 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": "<DepartmentCard> 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": "<Icon> 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": "<Icon> 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": "<IconButton> 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": "<Input> 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": "<Input> 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": "<IntegrationHealthCard> 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": "<IntegrationHealthCard> 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": "<KnowledgeDocumentEditor> 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": "<MetricCard> 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": "<ProductAIReleaseCard> 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": "<Tag> 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": "<Tag> 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": []
}
}
@@ -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 ChLine truncated
@@ -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 `<svg>` 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.
@@ -0,0 +1,4 @@
@import "./tokens/colors.css";
@import "./tokens/typography.css";
@import "./tokens/spacing.css";
@import "./tokens/effects.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);
}
@@ -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; }
}
@@ -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;
}
@@ -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;
}
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,352 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<script src="./support.js"></script>
</head>
<body>
<x-dc>
<helmet>
<link rel="stylesheet" href="_ds/edevs-hub-design-system-e4c9df12-82c8-42b3-b819-63d92dfb8702/tokens/colors.css">
<link rel="stylesheet" href="_ds/edevs-hub-design-system-e4c9df12-82c8-42b3-b819-63d92dfb8702/tokens/typography.css">
<link rel="stylesheet" href="_ds/edevs-hub-design-system-e4c9df12-82c8-42b3-b819-63d92dfb8702/tokens/spacing.css">
<link rel="stylesheet" href="_ds/edevs-hub-design-system-e4c9df12-82c8-42b3-b819-63d92dfb8702/tokens/effects.css">
<link rel="stylesheet" href="_ds/edevs-hub-design-system-e4c9df12-82c8-42b3-b819-63d92dfb8702/styles.css">
<script src="_ds/edevs-hub-design-system-e4c9df12-82c8-42b3-b819-63d92dfb8702/_ds_bundle.js"></script>
<style>
body { margin: 0; background: #0b0b12; }
@keyframes edv-ring2 { 0% { transform: scale(1); opacity: .5; } 100% { transform: scale(1.9); opacity: 0; } }
@keyframes edv-drift { 0% { transform: translate(0,0) scale(1); } 50% { transform: translate(4%,-3%) scale(1.08); } 100% { transform: translate(0,0) scale(1); } }
@keyframes edv-drift2 { 0% { transform: translate(0,0) scale(1.05); } 50% { transform: translate(-5%,4%) scale(1); } 100% { transform: translate(0,0) scale(1.05); } }
@keyframes edv-spin { to { transform: rotate(360deg); } }
</style>
</helmet>
<div style="min-height:100vh; box-sizing:border-box; display:flex; flex-direction:column; align-items:center; gap:18px; padding:28px 20px 48px; font-family:var(--font-sans);">
<div style="display:flex; flex-wrap:wrap; gap:6px; background:#16161f; border:1px solid rgba(255,255,255,.08); padding:5px; border-radius:var(--radius-lg);">
<sc-for list="{{ tabs }}" as="t" hint-placeholder-count="4">
<button style="{{ t.style }}" onClick="{{ t.onClick }}">{{ t.label }}</button>
</sc-for>
</div>
<div style="position:relative; width:100%; max-width:400px; aspect-ratio:9/16; max-height:760px; border-radius:var(--radius-4xl); overflow:hidden; box-shadow:0 24px 70px rgba(0,0,0,.55); background:#0d0d16;">
<div style="position:absolute; inset:0; z-index:0; overflow:hidden;">
<div style="position:absolute; inset:0; background:linear-gradient(150deg,#0958d9 0%,#722ed1 52%,#c41d7f 100%); opacity:{{ bgOpacity }}; transition:opacity .6s;"></div>
<div style="position:absolute; width:75%; height:55%; left:-12%; top:-10%; border-radius:50%; background:radial-gradient(circle,#597ef7,transparent 68%); filter:blur(18px); opacity:.75; animation:edv-drift 9s ease-in-out infinite;"></div>
<div style="position:absolute; width:70%; height:52%; right:-14%; bottom:-8%; border-radius:50%; background:radial-gradient(circle,#c41d7f,transparent 68%); filter:blur(20px); opacity:.6; animation:edv-drift2 11s ease-in-out infinite;"></div>
<div style="position:absolute; inset:0; background:linear-gradient(180deg,rgba(11,11,18,.15) 0%,rgba(11,11,18,.05) 45%,rgba(11,11,18,.55) 100%);"></div>
</div>
<div style="position:relative; z-index:2; height:100%; box-sizing:border-box; display:flex; flex-direction:column; padding:26px 24px 30px; color:#fff;">
<div style="display:flex; align-items:center; gap:9px;">
<span style="font-size:13px; font-weight:600; letter-spacing:.02em; color:rgba(255,255,255,.9);">{{ statusLabel }}</span>
<sc-if value="{{ showChannel }}" hint-placeholder-val="{{ true }}">
<span style="display:inline-flex; align-items:center; gap:5px; padding:3px 9px; border-radius:var(--radius-pill); background:rgba(255,255,255,.16); backdrop-filter:blur(6px); font-size:11.5px; font-weight:500; color:#fff;">{{ channelLabel }}</span>
</sc-if>
<div style="flex:1;"></div>
<sc-if value="{{ isActive }}" hint-placeholder-val="{{ false }}">
<span style="font-size:14px; font-weight:600; font-family:ui-monospace,SFMono-Regular,Menlo,monospace; color:#fff; letter-spacing:.04em;">{{ timer }}</span>
</sc-if>
</div>
<div style="flex:1; display:flex; flex-direction:column; align-items:center; justify-content:center; gap:22px;">
<sc-if value="{{ showAvatarCenter }}" hint-placeholder-val="{{ true }}">
<div style="position:relative; display:flex; align-items:center; justify-content:center;">
<sc-if value="{{ ringsVisible }}" hint-placeholder-val="{{ true }}">
<span style="position:absolute; width:126px; height:126px; border-radius:50%; border:2px solid rgba(255,255,255,.45); animation:edv-ring2 2s ease-out infinite;"></span>
<span style="position:absolute; width:126px; height:126px; border-radius:50%; border:2px solid rgba(255,255,255,.45); animation:edv-ring2 2s ease-out infinite; animation-delay:1s;"></span>
</sc-if>
<div style="width:126px; height:126px; border-radius:50%; background:rgba(255,255,255,.14); backdrop-filter:blur(10px); border:1px solid rgba(255,255,255,.28); display:flex; align-items:center; justify-content:center; font-size:42px; font-weight:600; color:#fff; box-shadow:0 8px 30px rgba(0,0,0,.3);">{{ initials }}</div>
</div>
<div style="text-align:center;">
<div style="font-size:25px; font-weight:600; letter-spacing:-0.01em; color:#fff;">{{ peerName }}</div>
<div style="font-size:14px; color:rgba(255,255,255,.72); margin-top:6px;">{{ subCaption }}</div>
</div>
<div style="height:56px; width:100%; max-width:260px; display:flex; align-items:center; justify-content:center;">
<sc-if value="{{ showWave }}" hint-placeholder-val="{{ false }}">
<canvas ref="{{ waveRef }}" width="520" height="112" style="width:100%; height:56px;"></canvas>
</sc-if>
<sc-if value="{{ showEndSummary }}" hint-placeholder-val="{{ false }}">
<div style="display:flex; align-items:center; gap:8px; padding:8px 16px; border-radius:var(--radius-pill); background:rgba(255,255,255,.12); font-size:13.5px; color:#fff;">
<svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="9"></circle><path d="M12 7v5l3 2"></path></svg>
Длительность {{ finalDuration }}
</div>
</sc-if>
</div>
</sc-if>
<sc-if value="{{ showStatusCenter }}" hint-placeholder-val="{{ false }}">
<div style="display:flex; flex-direction:column; align-items:center; justify-content:center; gap:18px; text-align:center;">
<div style="{{ statusIconTile }}">{{ statusIcon }}</div>
<div>
<div style="font-size:23px; font-weight:600; letter-spacing:-0.01em; color:#fff; line-height:1.25;">{{ statusTitle }}</div>
<div style="font-size:14px; color:rgba(255,255,255,.72); margin-top:8px; line-height:1.5; max-width:280px;">{{ statusCaption }}</div>
</div>
</div>
</sc-if>
</div>
<sc-if value="{{ barIncoming }}" hint-placeholder-val="{{ true }}">
<div style="display:flex; justify-content:center; gap:64px;">
<div style="display:flex; flex-direction:column; align-items:center; gap:9px;">
<button onClick="{{ decline }}" aria-label="Отклонить" style="width:64px; height:64px; border-radius:50%; border:none; background:var(--error); color:#fff; display:flex; align-items:center; justify-content:center; cursor:pointer; box-shadow:0 6px 20px rgba(255,77,79,.45);">
<svg viewBox="0 0 24 24" width="26" height="26" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round" style="transform:rotate(135deg);"><path d="M22 16.92v3a2 2 0 0 1-2.18 2 19.79 19.79 0 0 1-8.63-3.07 19.5 19.5 0 0 1-6-6 19.79 19.79 0 0 1-3.07-8.67A2 2 0 0 1 4.11 2h3a2 2 0 0 1 2 1.72c.13.81.36 1.6.7 2.34a2 2 0 0 1-.45 2.11L8.09 9.91a16 16 0 0 0 6 6l1.74-1.27a2 2 0 0 1 2.11-.45c.74.34 1.53.57 2.34.7A2 2 0 0 1 22 16.92z"></path></svg>
</button>
<span style="font-size:12.5px; color:rgba(255,255,255,.75);">Отклонить</span>
</div>
<div style="display:flex; flex-direction:column; align-items:center; gap:9px;">
<button onClick="{{ accept }}" aria-label="Принять" style="width:64px; height:64px; border-radius:50%; border:none; background:var(--success); color:#fff; display:flex; align-items:center; justify-content:center; cursor:pointer; box-shadow:0 6px 20px rgba(82,196,26,.45);">
<svg viewBox="0 0 24 24" width="26" height="26" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round"><path d="M22 16.92v3a2 2 0 0 1-2.18 2 19.79 19.79 0 0 1-8.63-3.07 19.5 19.5 0 0 1-6-6 19.79 19.79 0 0 1-3.07-8.67A2 2 0 0 1 4.11 2h3a2 2 0 0 1 2 1.72c.13.81.36 1.6.7 2.34a2 2 0 0 1-.45 2.11L8.09 9.91a16 16 0 0 0 6 6l1.74-1.27a2 2 0 0 1 2.11-.45c.74.34 1.53.57 2.34.7A2 2 0 0 1 22 16.92z"></path></svg>
</button>
<span style="font-size:12.5px; color:rgba(255,255,255,.75);">Принять</span>
</div>
</div>
</sc-if>
<sc-if value="{{ barActive }}" hint-placeholder-val="{{ false }}">
<div style="display:flex; flex-direction:column; gap:18px;">
<div style="display:flex; justify-content:center; gap:40px;">
<div style="display:flex; flex-direction:column; align-items:center; gap:8px;">
<button onClick="{{ toggleMic }}" style="{{ micStyle }}" aria-label="Микрофон">{{ micIcon }}</button>
<span style="font-size:11.5px; color:rgba(255,255,255,.72);">Микрофон</span>
</div>
<div style="display:flex; flex-direction:column; align-items:center; gap:8px;">
<button onClick="{{ toggleSpk }}" style="{{ spkStyle }}" aria-label="Динамик">{{ spkIcon }}</button>
<span style="font-size:11.5px; color:rgba(255,255,255,.72);">Динамик</span>
</div>
</div>
<div style="display:flex; justify-content:center;">
<button onClick="{{ end }}" aria-label="Завершить" style="width:64px; height:64px; border-radius:50%; border:none; background:var(--error); color:#fff; display:flex; align-items:center; justify-content:center; cursor:pointer; box-shadow:0 6px 20px rgba(255,77,79,.45);">
<svg viewBox="0 0 24 24" width="26" height="26" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round" style="transform:rotate(135deg);"><path d="M22 16.92v3a2 2 0 0 1-2.18 2 19.79 19.79 0 0 1-8.63-3.07 19.5 19.5 0 0 1-6-6 19.79 19.79 0 0 1-3.07-8.67A2 2 0 0 1 4.11 2h3a2 2 0 0 1 2 1.72c.13.81.36 1.6.7 2.34a2 2 0 0 1-.45 2.11L8.09 9.91a16 16 0 0 0 6 6l1.74-1.27a2 2 0 0 1 2.11-.45c.74.34 1.53.57 2.34.7A2 2 0 0 1 22 16.92z"></path></svg>
</button>
</div>
</div>
</sc-if>
<sc-if value="{{ barRinging }}" hint-placeholder-val="{{ false }}">
<div style="display:flex; flex-direction:column; align-items:center; gap:9px;">
<button onClick="{{ end }}" aria-label="Отменить" style="width:64px; height:64px; border-radius:50%; border:none; background:var(--error); color:#fff; display:flex; align-items:center; justify-content:center; cursor:pointer; box-shadow:0 6px 20px rgba(255,77,79,.45);">
<svg viewBox="0 0 24 24" width="26" height="26" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round" style="transform:rotate(135deg);"><path d="M22 16.92v3a2 2 0 0 1-2.18 2 19.79 19.79 0 0 1-8.63-3.07 19.5 19.5 0 0 1-6-6 19.79 19.79 0 0 1-3.07-8.67A2 2 0 0 1 4.11 2h3a2 2 0 0 1 2 1.72c.13.81.36 1.6.7 2.34a2 2 0 0 1-.45 2.11L8.09 9.91a16 16 0 0 0 6 6l1.74-1.27a2 2 0 0 1 2.11-.45c.74.34 1.53.57 2.34.7A2 2 0 0 1 22 16.92z"></path></svg>
</button>
<span style="font-size:12.5px; color:rgba(255,255,255,.75);">Отменить</span>
</div>
</sc-if>
<sc-if value="{{ barEnded }}" hint-placeholder-val="{{ false }}">
<div style="display:flex; gap:12px;">
<button onClick="{{ toIncoming }}" style="flex:1; height:48px; border-radius:var(--radius-lg); border:1px solid rgba(255,255,255,.3); background:rgba(255,255,255,.08); color:#fff; font-size:14px; font-weight:600; font-family:var(--font-sans); cursor:pointer;">Закрыть</button>
<button onClick="{{ callAgain }}" style="flex:1; height:48px; border-radius:var(--radius-lg); border:none; background:#fff; color:#141414; font-size:14px; font-weight:600; font-family:var(--font-sans); cursor:pointer;">Позвонить снова</button>
</div>
</sc-if>
<sc-if value="{{ barRetrySingle }}" hint-placeholder-val="{{ false }}">
<div style="display:flex; justify-content:center;">
<button onClick="{{ callAgain }}" style="flex:1; height:48px; border-radius:var(--radius-lg); border:none; background:#fff; color:#141414; font-size:14px; font-weight:600; font-family:var(--font-sans); cursor:pointer;">Позвонить снова</button>
</div>
</sc-if>
<sc-if value="{{ barRetryClose }}" hint-placeholder-val="{{ false }}">
<div style="display:flex; gap:12px;">
<button onClick="{{ toIncoming }}" style="flex:1; height:48px; border-radius:var(--radius-lg); border:1px solid rgba(255,255,255,.3); background:rgba(255,255,255,.08); color:#fff; font-size:14px; font-weight:600; font-family:var(--font-sans); cursor:pointer;">Закрыть</button>
<button onClick="{{ retry }}" style="flex:1; height:48px; border-radius:var(--radius-lg); border:none; background:#fff; color:#141414; font-size:14px; font-weight:600; font-family:var(--font-sans); cursor:pointer;">Повторить</button>
</div>
</sc-if>
<sc-if value="{{ barReconnect }}" hint-placeholder-val="{{ false }}">
<div style="position:relative;">
<div style="position:absolute; bottom:calc(100% + 14px); left:50%; transform:translateX(-50%); display:flex; align-items:center; gap:9px; padding:9px 16px; border-radius:var(--radius-pill); background:rgba(255,255,255,.12); backdrop-filter:blur(8px); border:1px solid rgba(255,255,255,.22); font-size:13px; font-weight:500; color:#fff; white-space:nowrap;">
<svg viewBox="0 0 24 24" width="15" height="15" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" style="animation:edv-spin .9s linear infinite;"><path d="M21 12a9 9 0 1 1-6.219-8.56"></path></svg>
Восстанавливаем связь…
</div>
<div style="display:flex; justify-content:center;">
<button onClick="{{ toIncoming }}" aria-label="Завершить" style="width:64px; height:64px; border-radius:50%; border:none; background:var(--error); color:#fff; display:flex; align-items:center; justify-content:center; cursor:pointer; box-shadow:0 6px 20px rgba(255,77,79,.45);">
<svg viewBox="0 0 24 24" width="26" height="26" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round" style="transform:rotate(135deg);"><path d="M22 16.92v3a2 2 0 0 1-2.18 2 19.79 19.79 0 0 1-8.63-3.07 19.5 19.5 0 0 1-6-6 19.79 19.79 0 0 1-3.07-8.67A2 2 0 0 1 4.11 2h3a2 2 0 0 1 2 1.72c.13.81.36 1.6.7 2.34a2 2 0 0 1-.45 2.11L8.09 9.91a16 16 0 0 0 6 6l1.74-1.27a2 2 0 0 1 2.11-.45c.74.34 1.53.57 2.34.7A2 2 0 0 1 22 16.92z"></path></svg>
</button>
</div>
</div>
</sc-if>
<sc-if value="{{ barRetryCheck }}" hint-placeholder-val="{{ false }}">
<div style="display:flex; gap:12px;">
<button onClick="{{ toIncoming }}" style="flex:1; height:48px; border-radius:var(--radius-lg); border:1px solid rgba(255,255,255,.3); background:rgba(255,255,255,.08); color:#fff; font-size:14px; font-weight:600; font-family:var(--font-sans); cursor:pointer;">Закрыть</button>
<button onClick="{{ retry }}" style="flex:1; height:48px; border-radius:var(--radius-lg); border:none; background:#fff; color:#141414; font-size:14px; font-weight:600; font-family:var(--font-sans); cursor:pointer;">Повторить проверку</button>
</div>
</sc-if>
<sc-if value="{{ barConnecting }}" hint-placeholder-val="{{ false }}">
<div style="display:flex; flex-direction:column; align-items:center; gap:9px;">
<button onClick="{{ toIncoming }}" aria-label="Отменить" style="width:64px; height:64px; border-radius:50%; border:none; background:var(--error); color:#fff; display:flex; align-items:center; justify-content:center; cursor:pointer; box-shadow:0 6px 20px rgba(255,77,79,.45);">
<svg viewBox="0 0 24 24" width="26" height="26" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round" style="transform:rotate(135deg);"><path d="M22 16.92v3a2 2 0 0 1-2.18 2 19.79 19.79 0 0 1-8.63-3.07 19.5 19.5 0 0 1-6-6 19.79 19.79 0 0 1-3.07-8.67A2 2 0 0 1 4.11 2h3a2 2 0 0 1 2 1.72c.13.81.36 1.6.7 2.34a2 2 0 0 1-.45 2.11L8.09 9.91a16 16 0 0 0 6 6l1.74-1.27a2 2 0 0 1 2.11-.45c.74.34 1.53.57 2.34.7A2 2 0 0 1 22 16.92z"></path></svg>
</button>
<span style="font-size:12.5px; color:rgba(255,255,255,.75);">Отменить</span>
</div>
</sc-if>
</div>
</div>
<div style="font-size:12.5px; color:var(--text-tertiary); text-align:center; max-width:380px; line-height:1.5;">Полноэкранный аудиозвонок · тёмная тема, живая осциллограмма собеседника. Переключайте состояния выше.</div>
</div>
</x-dc>
<script type="text/x-dc" data-dc-script data-props="{&quot;$preview&quot;:{&quot;width&quot;:440,&quot;height&quot;:900},&quot;clientName&quot;:{&quot;editor&quot;:&quot;text&quot;,&quot;default&quot;:&quot;Анна Ковалёва&quot;,&quot;tsType&quot;:&quot;string&quot;,&quot;section&quot;:&quot;Контент&quot;},&quot;channel&quot;:{&quot;editor&quot;:&quot;enum&quot;,&quot;default&quot;:&quot;webchat&quot;,&quot;options&quot;:[&quot;webchat&quot;,&quot;telegram&quot;,&quot;max&quot;],&quot;tsType&quot;:&quot;string&quot;,&quot;section&quot;:&quot;Контент&quot;}}">
class Component extends DCLogic {
state = { stateName: 'incoming', micOn: true, spkOn: true, seconds: 0, endedAt: 0 };
componentDidMount() {
this._waveRef = React.createRef();
this._t = setInterval(() => {
if (this.state.stateName === 'active') this.setState(s => ({ seconds: s.seconds + 1 }));
}, 1000);
this._raf = requestAnimationFrame(this._draw);
}
componentWillUnmount() { clearInterval(this._t); cancelAnimationFrame(this._raf); }
_draw = (ts) => {
this._raf = requestAnimationFrame(this._draw);
const cv = this._waveRef && this._waveRef.current;
if (!cv) return;
const ctx = cv.getContext('2d');
const W = cv.width, H = cv.height, mid = H / 2;
ctx.clearRect(0, 0, W, H);
const active = this.state.stateName === 'active';
const speaking = active && this.state.spkOn;
const t = ts / 1000;
ctx.lineWidth = 3;
ctx.lineJoin = 'round';
ctx.strokeStyle = 'rgba(255,255,255,0.92)';
ctx.beginPath();
for (let x = 0; x <= W; x += 4) {
const p = x / W;
const env = Math.sin(p * Math.PI); // taper ends
const base = speaking ? 0.5 : 0.12;
const amp = env * H * 0.36 * base * (0.6 + 0.4 * Math.sin(t * 2.1 + p * 3));
const y = mid + Math.sin(p * 22 + t * 6) * amp + Math.sin(p * 9 - t * 3.3) * amp * 0.5;
if (x === 0) ctx.moveTo(x, y); else ctx.lineTo(x, y);
}
ctx.stroke();
};
goto(st) {
this.setState(s => {
const next = { stateName: st };
if (st === 'active') next.seconds = 0;
if (st === 'ended' || st === 'declined' || st === 'missed' || st === 'expired' || st === 'failed') next.endedAt = s.seconds || 184;
return next;
});
}
statusIcon(name) {
const h = React.createElement;
const svg = (children, extra) => h('svg', Object.assign({ viewBox: '0 0 24 24', width: 34, height: 34, fill: 'none', stroke: 'currentColor', strokeWidth: 1.9, strokeLinecap: 'round', strokeLinejoin: 'round' }, extra || {}), children);
switch (name) {
case 'spinner': return svg([h('path', { key: 1, d: 'M21 12a9 9 0 1 1-6.219-8.56' })], { strokeWidth: 2, style: { animation: 'edv-spin .9s linear infinite' } });
case 'declined': return svg([h('path', { key: 1, transform: 'rotate(135 12 12)', d: 'M22 16.92v3a2 2 0 0 1-2.18 2 19.79 19.79 0 0 1-8.63-3.07 19.5 19.5 0 0 1-6-6 19.79 19.79 0 0 1-3.07-8.67A2 2 0 0 1 4.11 2h3a2 2 0 0 1 2 1.72c.13.81.36 1.6.7 2.34a2 2 0 0 1-.45 2.11L8.09 9.91a16 16 0 0 0 6 6l1.74-1.27a2 2 0 0 1 2.11-.45c.74.34 1.53.57 2.34.7A2 2 0 0 1 22 16.92z' })]);
case 'missed': return svg([h('path', { key: 1, d: 'M23 7l-8 8-4-4-9 9' }), h('polyline', { key: 2, points: '17 7 23 7 23 13' })]);
case 'clock': return svg([h('circle', { key: 1, cx: 12, cy: 12, r: 9 }), h('path', { key: 2, d: 'M12 7v5l3 2' })]);
case 'alert': return svg([h('path', { key: 1, d: 'M10.29 3.86 1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z' }), h('line', { key: 2, x1: 12, y1: 9, x2: 12, y2: 13 }), h('line', { key: 3, x1: 12, y1: 17, x2: 12.01, y2: 17 })]);
case 'nodevice': return svg([h('line', { key: 1, x1: 2, y1: 2, x2: 22, y2: 22 }), h('path', { key: 2, d: 'M16 16v1a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V7a2 2 0 0 1 2-2h2' }), h('path', { key: 3, d: 'M10 5h4a2 2 0 0 1 2 2v3l4-3v9' })]);
case 'unsupported': return svg([h('rect', { key: 1, x: 2, y: 3, width: 20, height: 14, rx: 2 }), h('line', { key: 2, x1: 8, y1: 21, x2: 16, y2: 21 }), h('line', { key: 3, x1: 12, y1: 17, x2: 12, y2: 21 }), h('line', { key: 4, x1: 9, y1: 8, x2: 15, y2: 14 }), h('line', { key: 5, x1: 15, y1: 8, x2: 9, y2: 14 })]);
default: return null;
}
}
fmt(sec) { return String(Math.floor(sec / 60)).padStart(2, '0') + ':' + String(sec % 60).padStart(2, '0'); }
micIcon() {
const h = React.createElement;
const svg = (c) => h('svg', { viewBox: '0 0 24 24', width: 23, height: 23, fill: 'none', stroke: 'currentColor', strokeWidth: 1.85, strokeLinecap: 'round', strokeLinejoin: 'round' }, c);
return this.state.micOn
? svg([h('rect', { key: 1, x: 9, y: 2, width: 6, height: 12, rx: 3 }), h('path', { key: 2, d: 'M19 10v1a7 7 0 0 1-14 0v-1' }), h('line', { key: 3, x1: 12, y1: 18, x2: 12, y2: 22 })])
: svg([h('line', { key: 1, x1: 3, y1: 3, x2: 21, y2: 21 }), h('path', { key: 2, d: 'M9 9v2a3 3 0 0 0 5.1 2.1' }), h('path', { key: 3, d: 'M15 9.3V5a3 3 0 0 0-5.9-.7' }), h('path', { key: 4, d: 'M19 10v1a7 7 0 0 1-1.4 4.2M12 18a7 7 0 0 1-7-7v-1' }), h('line', { key: 5, x1: 12, y1: 18, x2: 12, y2: 22 })]);
}
spkIcon() {
const h = React.createElement;
const svg = (c) => h('svg', { viewBox: '0 0 24 24', width: 23, height: 23, fill: 'none', stroke: 'currentColor', strokeWidth: 1.85, strokeLinecap: 'round', strokeLinejoin: 'round' }, c);
return this.state.spkOn
? svg([h('polygon', { key: 1, points: '11 5 6 9 2 9 2 15 6 15 11 19 11 5' }), h('path', { key: 2, d: 'M15.5 8.5a5 5 0 0 1 0 7' }), h('path', { key: 3, d: 'M19 5a9 9 0 0 1 0 14' })])
: svg([h('polygon', { key: 1, points: '11 5 6 9 2 9 2 15 6 15 11 19 11 5' }), h('line', { key: 2, x1: 22, y1: 9, x2: 16, y2: 15 }), h('line', { key: 3, x1: 16, y1: 9, x2: 22, y2: 15 })]);
}
renderVals() {
const s = this.state;
const st = s.stateName;
const clientName = this.props.clientName ?? 'Анна Ковалёва';
const channel = this.props.channel ?? 'webchat';
const initials = (clientName.trim()[0] || 'К').toUpperCase() + ((clientName.split(' ')[1] || '')[0] || '').toUpperCase();
const channelLabel = channel === 'webchat' ? 'Веб-виджет' : channel === 'telegram' ? 'Telegram' : 'MAX';
const isIncoming = st === 'incoming', isRinging = st === 'ringing', isActive = st === 'active', isEnded = st === 'ended', isConnecting = st === 'connecting';
const isReconnecting = st === 'reconnecting', isDeclined = st === 'declined', isMissed = st === 'missed', isExpired = st === 'expired', isFailed = st === 'failed', isNoDevice = st === 'nodevice', isUnsupported = st === 'unsupported';
const statusLabel = isIncoming ? 'Входящий звонок' : isRinging ? 'Исходящий звонок' : isActive ? 'Аудиозвонок' : isConnecting ? 'Соединение' : isReconnecting ? 'Переподключение' : isDeclined ? 'Отклонён' : isMissed ? 'Пропущенный' : isExpired ? 'Истёк' : isFailed ? 'Ошибка' : isNoDevice ? 'Нет микрофона' : isUnsupported ? 'Не поддерживается' : 'Звонок завершён';
const subCaption = isIncoming ? 'Аудиозвонок · ' + channelLabel : isRinging ? 'Вызываем…' : isActive ? (s.micOn ? 'Говорите' : 'Ваш микрофон выключен') : isEnded ? 'Звонок завершён' : '';
// Status-center для процессов/ошибок/терминалов (вместо аватара/волны).
const msg = {
connecting: { icon: 'spinner', title: 'Соединяем', caption: 'Устанавливаем защищённое соединение…' },
reconnecting: { icon: 'spinner', title: 'Связь прервана', caption: 'Восстанавливаем соединение…' },
declined: { icon: 'declined', title: 'Звонок отклонён', caption: clientName + ' отклонил(а) вызов.' },
missed: { icon: 'missed', title: 'Пропущенный звонок', caption: clientName + ' не ответил(а) на вызов.' },
expired: { icon: 'clock', title: 'Время ожидания истекло', caption: 'Никто не ответил вовремя. Попробуйте позвонить снова.' },
failed: { icon: 'alert', title: 'Не удалось соединиться', caption: 'Проверьте интернет-соединение и попробуйте снова.' },
nodevice: { icon: 'nodevice', title: 'Нет доступа к микрофону', caption: 'Разрешите доступ к микрофону в настройках браузера и повторите.' },
unsupported: { icon: 'unsupported', title: 'Аудиозвонки не поддерживаются', caption: 'Обновите браузер или откройте ссылку в Chrome, Safari или Edge.' },
};
const cur = msg[st];
const showStatusCenter = !!cur;
const showAvatarCenter = !showStatusCenter;
const toneColor = { spinner: 'rgba(255,255,255,.16)', declined: 'rgba(255,255,255,.12)', missed: 'rgba(250,173,20,.18)', clock: 'rgba(250,173,20,.18)', alert: 'rgba(255,77,79,.2)', nodevice: 'rgba(255,77,79,.2)', unsupported: 'rgba(255,77,79,.2)' };
const toneFg = { spinner: '#fff', declined: '#fff', missed: '#ffc53d', clock: '#ffc53d', alert: '#ff7875', nodevice: '#ff7875', unsupported: '#ff7875' };
const tc = cur ? (toneColor[cur.icon] || 'rgba(255,255,255,.16)') : 'rgba(255,255,255,.16)';
const tfg = cur ? (toneFg[cur.icon] || '#fff') : '#fff';
const statusIconTile = { width: 92, height: 92, borderRadius: '50%', display: 'flex', alignItems: 'center', justifyContent: 'center', background: tc, color: tfg, border: '1px solid rgba(255,255,255,.22)', backdropFilter: 'blur(10px)', boxShadow: '0 8px 30px rgba(0,0,0,.3)' };
const round = (on, accent) => ({ width: 60, height: 60, borderRadius: '50%', display: 'flex', alignItems: 'center', justifyContent: 'center', cursor: 'pointer', flex: 'none', transition: 'all .15s', border: '1px solid rgba(255,255,255,.28)', background: on ? 'rgba(255,255,255,.16)' : '#fff', color: on ? '#fff' : '#141414', backdropFilter: 'blur(8px)' });
const tabBase = { padding: '7px 14px', borderRadius: 'var(--radius-md)', border: 'none', cursor: 'pointer', fontSize: 13, fontFamily: 'var(--font-sans)', whiteSpace: 'nowrap' };
const tabAct = { ...tabBase, fontWeight: 600, background: 'rgba(255,255,255,.14)', color: '#fff' };
const tabIdle = { ...tabBase, fontWeight: 500, background: 'transparent', color: 'rgba(255,255,255,.55)' };
const tabs = [['incoming', 'Входящий'], ['ringing', 'Дозвон'], ['connecting', 'Соединение'], ['active', 'Активный'], ['reconnecting', 'Связь'], ['declined', 'Отклонён'], ['missed', 'Пропущен'], ['expired', 'Истёк'], ['failed', 'Ошибка'], ['nodevice', 'Нет микрофона'], ['unsupported', 'Не поддерж.'], ['ended', 'Завершение']].map(([id, label]) => ({ label, style: st === id ? tabAct : tabIdle, onClick: () => this.goto(id) }));
// Длительность показываем для состояний, где звонок реально состоялся.
const dur = (st === 'ended' || st === 'failed') && s.endedAt ? ('Длительность ' + this.fmt(s.endedAt) + '. ') : '';
const statusCaption = cur ? (dur + cur.caption) : '';
return {
tabs, initials, peerName: clientName, channelLabel, showChannel: !isEnded,
statusLabel, subCaption,
isActive, ringsVisible: isIncoming || isRinging,
timer: this.fmt(s.seconds),
showWave: isActive, showEndSummary: isEnded,
showAvatarCenter, showStatusCenter,
statusIcon: cur ? this.statusIcon(cur.icon) : null, statusIconTile,
statusTitle: cur ? cur.title : '', statusCaption,
finalDuration: this.fmt(s.endedAt),
waveRef: this._waveRef,
micIcon: this.micIcon(), spkIcon: this.spkIcon(),
micStyle: round(s.micOn), spkStyle: round(s.spkOn),
bgOpacity: isEnded ? 0.55 : isFailed ? 0.5 : isReconnecting ? 0.5 : isActive ? 0.85 : 1,
barIncoming: isIncoming, barRinging: isRinging, barActive: isActive, barEnded: isEnded, barConnecting: isConnecting,
// Перезвонить (без «Закрыть») — пропущенный/истёк.
barRetrySingle: isMissed || isExpired,
// Повторить + Закрыть — ошибка соединения.
barRetryClose: isFailed,
// Переподключение: нижняя кнопка завершения + плавающий статус сверху.
barReconnect: isReconnecting,
// Повторить проверку + Закрыть — нет микрофона / не поддерживается.
barRetryCheck: isNoDevice || isUnsupported,
accept: () => this.goto('active'), decline: () => this.goto('incoming'),
end: () => this.goto('ended'), toIncoming: () => this.goto('incoming'), callAgain: () => this.goto('ringing'), retry: () => this.goto('reconnecting'),
toggleMic: () => this.setState(x => ({ micOn: !x.micOn })),
toggleSpk: () => this.setState(x => ({ spkOn: !x.spkOn })),
};
}
}
</script>
</body>
</html>
@@ -0,0 +1,135 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<script src="./support.js"></script>
</head>
<body>
<x-dc>
<helmet>
<link rel="stylesheet" href="_ds/edevs-hub-design-system-e4c9df12-82c8-42b3-b819-63d92dfb8702/tokens/colors.css">
<link rel="stylesheet" href="_ds/edevs-hub-design-system-e4c9df12-82c8-42b3-b819-63d92dfb8702/tokens/typography.css">
<link rel="stylesheet" href="_ds/edevs-hub-design-system-e4c9df12-82c8-42b3-b819-63d92dfb8702/tokens/spacing.css">
<link rel="stylesheet" href="_ds/edevs-hub-design-system-e4c9df12-82c8-42b3-b819-63d92dfb8702/tokens/effects.css">
<link rel="stylesheet" href="_ds/edevs-hub-design-system-e4c9df12-82c8-42b3-b819-63d92dfb8702/styles.css">
<script src="_ds/edevs-hub-design-system-e4c9df12-82c8-42b3-b819-63d92dfb8702/_ds_bundle.js"></script>
<style>
body { margin: 0; background: var(--surface-page); }
a { color: var(--primary); text-decoration: none; }
a:hover { color: var(--primary-hover); }
@keyframes edv-ring3 { 0% { transform: scale(1); opacity: .45; } 100% { transform: scale(1.7); opacity: 0; } }
</style>
</helmet>
<div style="min-height:100vh; box-sizing:border-box; display:flex; flex-direction:column; font-family:var(--font-sans); color:var(--text-body); background:var(--surface-page);">
<header style="display:flex; align-items:center; gap:10px; padding:18px 28px; border-bottom:1px solid var(--border-default);">
<div style="width:30px; height:30px; border-radius:8px; background:var(--primary); color:#fff; display:flex; align-items:center; justify-content:center; font-weight:700; font-size:16px;">E</div>
<span style="font-size:15px; font-weight:600; color:var(--text-heading);">Edevs</span>
<div style="flex:1;"></div>
<span style="font-size:12.5px; color:var(--text-tertiary);">Защищённое соединение</span>
</header>
<div style="flex:1; display:flex; align-items:center; justify-content:center; padding:40px 24px;">
<div style="width:100%; max-width:460px; background:var(--surface-card); border:1px solid var(--border-default); border-radius:var(--radius-4xl); box-shadow:var(--shadow-lg); overflow:hidden;">
<div style="position:relative; padding:40px 32px 30px; text-align:center; background:linear-gradient(155deg,#0958d9 0%,#722ed1 60%,#c41d7f 100%); color:#fff; overflow:hidden;">
<div style="position:absolute; width:70%; height:90%; left:-10%; top:-20%; border-radius:50%; background:radial-gradient(circle,rgba(89,126,247,.6),transparent 65%); filter:blur(16px);"></div>
<div style="position:relative; display:inline-flex; align-items:center; justify-content:center; margin-bottom:18px;">
<span style="position:absolute; width:96px; height:96px; border-radius:50%; border:2px solid rgba(255,255,255,.5); animation:edv-ring3 2.1s ease-out infinite;"></span>
<span style="position:absolute; width:96px; height:96px; border-radius:50%; border:2px solid rgba(255,255,255,.5); animation:edv-ring3 2.1s ease-out infinite; animation-delay:1.05s;"></span>
<div style="width:96px; height:96px; border-radius:50%; background:rgba(255,255,255,.16); backdrop-filter:blur(8px); border:1px solid rgba(255,255,255,.3); display:flex; align-items:center; justify-content:center; font-size:34px; font-weight:600;">{{ initials }}</div>
</div>
<div style="position:relative; font-size:22px; font-weight:600; letter-spacing:-0.01em;">{{ orgName }}</div>
<div style="position:relative; font-size:14px; color:rgba(255,255,255,.78); margin-top:6px;">приглашает вас на видеозвонок</div>
</div>
<div style="padding:26px 32px 32px;">
<div style="display:flex; align-items:center; gap:12px; padding:13px 15px; border:1px solid var(--border-default); border-radius:var(--radius-xl); background:var(--surface-subtle); margin-bottom:22px;">
<div style="width:40px; height:40px; border-radius:50%; background:#1677ff; color:#fff; display:flex; align-items:center; justify-content:center; font-size:15px; font-weight:600; flex:none;">{{ agentInitials }}</div>
<div style="min-width:0;">
<div style="font-size:14px; font-weight:600; color:var(--text-heading);">{{ agentName }}</div>
<div style="font-size:12.5px; color:var(--text-secondary);">{{ agentRole }} · {{ orgName }}</div>
</div>
<div style="flex:1;"></div>
<span style="display:inline-flex; align-items:center; gap:6px; padding:5px 10px; border-radius:var(--radius-pill); background:var(--success-bg); color:var(--success-text); font-size:12px; font-weight:600;"><span style="width:6px; height:6px; border-radius:50%; background:var(--success);"></span>Онлайн</span>
</div>
<div style="text-align:center; margin-bottom:16px;">
<div style="font-size:15px; font-weight:600; color:var(--text-heading);">Готовы присоединиться?</div>
<div style="font-size:13px; color:var(--text-secondary); margin-top:5px; line-height:1.5;">Проверьте камеру и микрофон перед входом. Мы не записываем видео без предупреждения.</div>
</div>
<div style="display:flex; justify-content:center; gap:14px; margin-bottom:22px;">
<div style="display:flex; flex-direction:column; align-items:center; gap:7px;">
<button onClick="{{ toggleMic }}" style="{{ micStyle }}" aria-label="Микрофон">{{ micIcon }}</button>
<span style="font-size:11.5px; color:var(--text-tertiary);">Микрофон</span>
</div>
<div style="display:flex; flex-direction:column; align-items:center; gap:7px;">
<button onClick="{{ toggleCam }}" style="{{ camStyle }}" aria-label="Камера">{{ camIcon }}</button>
<span style="font-size:11.5px; color:var(--text-tertiary);">Камера</span>
</div>
</div>
<div style="display:flex; flex-direction:column; gap:11px;">
<input value="{{ nameValue }}" onChange="{{ onName }}" placeholder="Ваше имя" style="height:46px; padding:0 15px; border:1px solid var(--border-strong); border-radius:var(--radius-lg); font-size:14px; font-family:var(--font-sans); color:var(--text-body); background:var(--surface-card); box-sizing:border-box; outline:none;" style-focus="border-color:var(--primary);" />
<button onClick="{{ join }}" style="height:50px; border-radius:var(--radius-lg); border:none; background:var(--primary); color:#fff; font-size:15px; font-weight:600; font-family:var(--font-sans); cursor:pointer; box-shadow:var(--shadow-primary);" style-hover="background:var(--primary-hover);">Присоединиться к звонку</button>
</div>
<div style="display:flex; align-items:center; justify-content:center; gap:6px; margin-top:18px; font-size:12px; color:var(--text-tertiary);">
<svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="11" width="18" height="11" rx="2"></rect><path d="M7 11V7a5 5 0 0 1 10 0v4"></path></svg>
Звонок защищён сквозным шифрованием
</div>
</div>
</div>
</div>
<footer style="text-align:center; padding:18px; font-size:12px; color:var(--text-tertiary);">Работает на платформе Edevs Hub</footer>
</div>
</x-dc>
<script type="text/x-dc" data-dc-script data-props="{&quot;$preview&quot;:{&quot;width&quot;:900,&quot;height&quot;:820},&quot;orgName&quot;:{&quot;editor&quot;:&quot;text&quot;,&quot;default&quot;:&quot;Магазин «Северный»&quot;,&quot;tsType&quot;:&quot;string&quot;,&quot;section&quot;:&quot;Контент&quot;},&quot;agentName&quot;:{&quot;editor&quot;:&quot;text&quot;,&quot;default&quot;:&quot;Иван Петров&quot;,&quot;tsType&quot;:&quot;string&quot;,&quot;section&quot;:&quot;Контент&quot;},&quot;agentRole&quot;:{&quot;editor&quot;:&quot;text&quot;,&quot;default&quot;:&quot;Менеджер поддержки&quot;,&quot;tsType&quot;:&quot;string&quot;,&quot;section&quot;:&quot;Контент&quot;}}">
class Component extends DCLogic {
state = { micOn: true, camOn: true, name: '' };
micIcon() {
const h = React.createElement;
const svg = (c) => h('svg', { viewBox: '0 0 24 24', width: 22, height: 22, fill: 'none', stroke: 'currentColor', strokeWidth: 1.85, strokeLinecap: 'round', strokeLinejoin: 'round' }, c);
return this.state.micOn
? svg([h('rect', { key: 1, x: 9, y: 2, width: 6, height: 12, rx: 3 }), h('path', { key: 2, d: 'M19 10v1a7 7 0 0 1-14 0v-1' }), h('line', { key: 3, x1: 12, y1: 18, x2: 12, y2: 22 })])
: svg([h('line', { key: 1, x1: 3, y1: 3, x2: 21, y2: 21 }), h('path', { key: 2, d: 'M9 9v2a3 3 0 0 0 5.1 2.1' }), h('path', { key: 3, d: 'M15 9.3V5a3 3 0 0 0-5.9-.7' }), h('path', { key: 4, d: 'M19 10v1a7 7 0 0 1-1.4 4.2M12 18a7 7 0 0 1-7-7v-1' }), h('line', { key: 5, x1: 12, y1: 18, x2: 12, y2: 22 })]);
}
camIcon() {
const h = React.createElement;
const svg = (c) => h('svg', { viewBox: '0 0 24 24', width: 22, height: 22, fill: 'none', stroke: 'currentColor', strokeWidth: 1.85, strokeLinecap: 'round', strokeLinejoin: 'round' }, c);
return this.state.camOn
? svg([h('path', { key: 1, d: 'M23 7l-7 5 7 5V7z' }), h('rect', { key: 2, x: 1, y: 5, width: 15, height: 14, rx: 2.5 })])
: svg([h('line', { key: 1, x1: 2, y1: 2, x2: 22, y2: 22 }), h('path', { key: 2, d: 'M16 16v1a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V7a2 2 0 0 1 2-2h2' }), h('path', { key: 3, d: 'M10 5h4a2 2 0 0 1 2 2v3l4-3v9' })]);
}
renderVals() {
const s = this.state;
const orgName = this.props.orgName ?? 'Магазин «Северный»';
const agentName = this.props.agentName ?? 'Иван Петров';
const agentRole = this.props.agentRole ?? 'Менеджер поддержки';
const initials = (orgName.replace(/[«»"]/g, '').trim()[0] || 'E').toUpperCase();
const agentInitials = (agentName.trim()[0] || 'A').toUpperCase() + ((agentName.split(' ')[1] || '')[0] || '').toUpperCase();
const ctrl = (on) => ({ width: 52, height: 52, borderRadius: '50%', display: 'flex', alignItems: 'center', justifyContent: 'center', cursor: 'pointer', flex: 'none', transition: 'all .15s', border: '1px solid ' + (on ? 'var(--border-strong)' : 'var(--error-border)'), background: on ? 'var(--surface-card)' : 'var(--error-bg)', color: on ? 'var(--text-body)' : 'var(--error-text)' });
return {
orgName, agentName, agentRole, initials, agentInitials,
micIcon: this.micIcon(), camIcon: this.camIcon(),
micStyle: ctrl(s.micOn), camStyle: ctrl(s.camOn),
nameValue: s.name,
onName: (e) => this.setState({ name: e.target.value }),
toggleMic: () => this.setState(x => ({ micOn: !x.micOn })),
toggleCam: () => this.setState(x => ({ camOn: !x.camOn })),
join: () => {},
};
}
}
</script>
</body>
</html>
@@ -0,0 +1,410 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<script src="./support.js"></script>
</head>
<body>
<x-dc>
<helmet>
<link rel="stylesheet" href="_ds/edevs-hub-design-system-e4c9df12-82c8-42b3-b819-63d92dfb8702/tokens/colors.css">
<link rel="stylesheet" href="_ds/edevs-hub-design-system-e4c9df12-82c8-42b3-b819-63d92dfb8702/tokens/typography.css">
<link rel="stylesheet" href="_ds/edevs-hub-design-system-e4c9df12-82c8-42b3-b819-63d92dfb8702/tokens/spacing.css">
<link rel="stylesheet" href="_ds/edevs-hub-design-system-e4c9df12-82c8-42b3-b819-63d92dfb8702/tokens/effects.css">
<link rel="stylesheet" href="_ds/edevs-hub-design-system-e4c9df12-82c8-42b3-b819-63d92dfb8702/styles.css">
<script src="_ds/edevs-hub-design-system-e4c9df12-82c8-42b3-b819-63d92dfb8702/_ds_bundle.js"></script>
<style>
body { margin: 0; background: var(--surface-page); }
a { color: var(--primary); text-decoration: none; }
a:hover { color: var(--primary-hover); }
@keyframes edv-ring { 0% { transform: scale(1); opacity: .55; } 100% { transform: scale(2.3); opacity: 0; } }
@keyframes edv-spin { to { transform: rotate(360deg); } }
</style>
</helmet>
<div style="min-height:100vh; box-sizing:border-box; display:flex; flex-direction:column; align-items:center; gap:20px; padding:36px 24px 60px; font-family:var(--font-sans); color:var(--text-body);">
<div style="text-align:center; max-width:560px;">
<div style="font-size:20px; font-weight:600; letter-spacing:-0.01em; color:var(--text-heading);">Экран звонка</div>
<div style="font-size:13.5px; color:var(--text-secondary); margin-top:5px; line-height:1.5;">Встраиваемый компонент видеозвонка оператора и клиента — для модалки или страницы Hub. Полный жизненный цикл вызова.</div>
</div>
<div style="display:flex; flex-direction:column; gap:12px; background:var(--surface-card); border:1px solid var(--border-default); border-radius:var(--radius-2xl); padding:14px 18px; box-shadow:var(--shadow-sm); max-width:640px;">
<div style="display:flex; flex-wrap:wrap; gap:20px; align-items:flex-start;">
<div style="display:flex; flex-direction:column; gap:7px;">
<span style="font-size:10.5px; font-weight:600; letter-spacing:0.05em; text-transform:uppercase; color:var(--text-tertiary);">Сторона</span>
<div style="display:flex; gap:3px; background:var(--n-9); padding:3px; border-radius:var(--radius-md);">
<button style="{{ segOp }}" onClick="{{ setOp }}">Оператор</button>
<button style="{{ segCl }}" onClick="{{ setCl }}">Клиент</button>
</div>
</div>
<div style="display:flex; flex-direction:column; gap:7px;">
<span style="font-size:10.5px; font-weight:600; letter-spacing:0.05em; text-transform:uppercase; color:var(--text-tertiary);">Основные состояния</span>
<div style="display:flex; flex-wrap:wrap; gap:6px;">
<sc-for list="{{ mainStates }}" as="st" hint-placeholder-count="5">
<button style="{{ st.style }}" onClick="{{ st.onClick }}">{{ st.label }}</button>
</sc-for>
</div>
</div>
</div>
<div style="display:flex; flex-direction:column; gap:7px;">
<span style="font-size:10.5px; font-weight:600; letter-spacing:0.05em; text-transform:uppercase; color:var(--text-tertiary);">Ошибки и завершение</span>
<div style="display:flex; flex-wrap:wrap; gap:6px;">
<sc-for list="{{ errorStates }}" as="st" hint-placeholder-count="7">
<button style="{{ st.style }}" onClick="{{ st.onClick }}">{{ st.label }}</button>
</sc-for>
</div>
</div>
<sc-if value="{{ liveCall }}" hint-placeholder-val="{{ false }}">
<div style="display:flex; flex-wrap:wrap; gap:14px; align-items:center; padding-top:11px; border-top:1px solid var(--border-subtle);">
<span style="font-size:10.5px; font-weight:600; letter-spacing:0.05em; text-transform:uppercase; color:var(--text-tertiary);">Собеседник</span>
<button style="{{ remoteMicChip }}" onClick="{{ toggleRemoteMic }}">Микрофон: {{ remoteMicLabel }}</button>
<button style="{{ remoteCamChip }}" onClick="{{ toggleRemoteCam }}">Камера: {{ remoteCamLabel }}</button>
</div>
</sc-if>
</div>
<div style="width:100%; max-width:428px; background:var(--surface-card); border:1px solid var(--border-default); border-radius:var(--radius-4xl); box-shadow:var(--shadow-lg); overflow:hidden;">
<div style="display:flex; align-items:center; gap:11px; padding:12px 15px; border-bottom:1px solid var(--border-default);">
<div style="{{ smallAvatarStyle }}">{{ peerInitials }}</div>
<div style="display:flex; flex-direction:column; gap:2px; min-width:0;">
<div style="display:flex; align-items:center; gap:7px;">
<span style="font-size:14px; font-weight:600; color:var(--text-heading); white-space:nowrap; overflow:hidden; text-overflow:ellipsis;">{{ peerName }}</span>
<sc-if value="{{ showChannel }}" hint-placeholder-val="{{ true }}">
<x-import component-from-global-scope="EdevsHubDesignSystem_e4c9df.ChannelBadge" channel="{{ channel }}" hint-size="auto,16px"></x-import>
</sc-if>
</div>
<span style="font-size:12px; color:var(--text-secondary);">{{ subtitle }}</span>
</div>
<div style="flex:1;"></div>
<sc-if value="{{ showTimer }}" hint-placeholder-val="{{ false }}">
<div style="{{ timerStyle }}">
<span style="{{ timerDot }}"></span>
{{ timer }}
</div>
</sc-if>
<button onClick="{{ close }}" aria-label="Закрыть" style="width:34px; height:34px; border-radius:var(--radius-md); border:1px solid var(--border-default); background:var(--surface-card); color:var(--text-secondary); display:flex; align-items:center; justify-content:center; cursor:pointer; flex:none;" style-hover="background:var(--n-9); color:var(--text-body);">
<svg viewBox="0 0 24 24" width="17" height="17" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round"><line x1="18" y1="6" x2="6" y2="18"></line><line x1="6" y1="6" x2="18" y2="18"></line></svg>
</button>
</div>
<div style="position:relative; aspect-ratio:16/9; background:#141414; overflow:hidden;">
<sc-if value="{{ videoVisible }}" hint-placeholder-val="{{ false }}">
<x-import component-from-global-scope="image-slot" from="./image-slot.js" id="edevs-call-main" placeholder="Кадр с камеры" shape="rect" style="position:absolute; inset:0; width:100%; height:100%;" hint-size="100%,100%"></x-import>
</sc-if>
<sc-if value="{{ showMainAvatar }}" hint-placeholder-val="{{ true }}">
<div style="position:absolute; inset:0; display:flex; flex-direction:column; align-items:center; justify-content:center; gap:16px; z-index:3;">
<div style="position:relative; display:flex; align-items:center; justify-content:center;">
<sc-if value="{{ ringsVisible }}" hint-placeholder-val="{{ true }}">
<span style="position:absolute; width:88px; height:88px; border-radius:50%; border:2px solid rgba(255,255,255,.6); animation:edv-ring 1.9s ease-out infinite;"></span>
<span style="position:absolute; width:88px; height:88px; border-radius:50%; border:2px solid rgba(255,255,255,.6); animation:edv-ring 1.9s ease-out infinite; animation-delay:.95s;"></span>
</sc-if>
<div style="{{ bigAvatarStyle }}">{{ mainInitials }}</div>
</div>
<div style="text-align:center;">
<div style="font-size:15px; font-weight:600; color:#fff;">{{ mainName }}</div>
<div style="font-size:12.5px; color:rgba(255,255,255,.6); margin-top:3px;">{{ mainCaption }}</div>
</div>
</div>
</sc-if>
<sc-if value="{{ showReconnBanner }}" hint-placeholder-val="{{ false }}">
<div style="position:absolute; top:0; left:0; right:0; display:flex; align-items:center; justify-content:center; gap:9px; padding:9px 12px; background:rgba(20,20,20,.82); color:#fff; font-size:12.5px; font-weight:500; z-index:6;">
<svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" style="animation:edv-spin .9s linear infinite;"><path d="M21 12a9 9 0 1 1-6.219-8.56"></path></svg>
Связь прервана · восстанавливаем соединение…
</div>
</sc-if>
<sc-if value="{{ showStatus }}" hint-placeholder-val="{{ false }}">
<div style="position:absolute; inset:0; display:flex; flex-direction:column; align-items:center; justify-content:center; gap:14px; padding:22px 26px; text-align:center; background:#1a1a1a; z-index:7;">
<div style="{{ statusIconTile }}">{{ statusIcon }}</div>
<div>
<div style="font-size:15.5px; font-weight:600; color:#fff; line-height:1.3;">{{ statusTitle }}</div>
<div style="font-size:12.5px; color:rgba(255,255,255,.6); margin-top:6px; line-height:1.5; max-width:280px;">{{ statusCaption }}</div>
</div>
<sc-if value="{{ hasStatusActions }}" hint-placeholder-val="{{ false }}">
<div style="display:flex; gap:9px; margin-top:2px;">
<sc-for list="{{ statusActions }}" as="act" hint-placeholder-count="2">
<button style="{{ act.style }}" onClick="{{ act.onClick }}">{{ act.label }}</button>
</sc-for>
</div>
</sc-if>
</div>
</sc-if>
<sc-if value="{{ showNameLabel }}" hint-placeholder-val="{{ false }}">
<div style="position:absolute; left:12px; bottom:12px; display:flex; align-items:center; gap:6px; padding:4px 10px; border-radius:var(--radius-md); background:rgba(0,0,0,.5); z-index:5; pointer-events:none;">
<sc-if value="{{ remoteMuted }}" hint-placeholder-val="{{ false }}">
<svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="#ff7875" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="3" y1="3" x2="21" y2="21"></line><path d="M9 9v2a3 3 0 0 0 5.1 2.1"></path><path d="M15 9.3V5a3 3 0 0 0-5.9-.7"></path><path d="M19 10v1a7 7 0 0 1-1.4 4.2M12 18a7 7 0 0 1-7-7v-1"></path><line x1="12" y1="18" x2="12" y2="22"></line></svg>
</sc-if>
<span style="color:#fff; font-size:12px; font-weight:500;">{{ peerName }}</span>
</div>
</sc-if>
<sc-if value="{{ showPip }}" hint-placeholder-val="{{ false }}">
<div style="position:absolute; right:12px; bottom:12px; width:100px; height:66px; border-radius:10px; overflow:hidden; background:#1f1f1f; border:2px solid rgba(255,255,255,.9); box-shadow:0 4px 14px rgba(0,0,0,.4); z-index:4;">
<sc-if value="{{ camOn }}" hint-placeholder-val="{{ true }}">
<x-import component-from-global-scope="image-slot" from="./image-slot.js" id="edevs-call-pip" placeholder="Вы" shape="rect" style="position:absolute; inset:0; width:100%; height:100%;" hint-size="100%,100%"></x-import>
</sc-if>
<sc-if value="{{ camOff }}" hint-placeholder-val="{{ false }}">
<div style="position:absolute; inset:0; display:flex; align-items:center; justify-content:center;">
<div style="width:34px; height:34px; border-radius:50%; background:var(--n-3); color:#fff; display:flex; align-items:center; justify-content:center; font-size:12px; font-weight:600;">ВЫ</div>
</div>
</sc-if>
<span style="position:absolute; left:5px; bottom:4px; font-size:10px; font-weight:600; color:#fff; text-shadow:0 1px 2px rgba(0,0,0,.6); pointer-events:none;">Вы</span>
</div>
</sc-if>
</div>
<sc-if value="{{ barIncoming }}" hint-placeholder-val="{{ true }}">
<div style="display:flex; justify-content:center; gap:52px; padding:18px 16px 22px;">
<div style="display:flex; flex-direction:column; align-items:center; gap:9px;">
<button onClick="{{ decline }}" aria-label="Отклонить" style="width:58px; height:58px; border-radius:50%; border:none; background:var(--error); color:#fff; display:flex; align-items:center; justify-content:center; cursor:pointer; box-shadow:0 2px 8px rgba(255,77,79,.35);">
<svg viewBox="0 0 24 24" width="24" height="24" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round" style="transform:rotate(135deg);"><path d="M22 16.92v3a2 2 0 0 1-2.18 2 19.79 19.79 0 0 1-8.63-3.07 19.5 19.5 0 0 1-6-6 19.79 19.79 0 0 1-3.07-8.67A2 2 0 0 1 4.11 2h3a2 2 0 0 1 2 1.72c.13.81.36 1.6.7 2.34a2 2 0 0 1-.45 2.11L8.09 9.91a16 16 0 0 0 6 6l1.74-1.27a2 2 0 0 1 2.11-.45c.74.34 1.53.57 2.34.7A2 2 0 0 1 22 16.92z"></path></svg>
</button>
<span style="font-size:12.5px; color:var(--text-secondary);">Отклонить</span>
</div>
<div style="display:flex; flex-direction:column; align-items:center; gap:9px;">
<button onClick="{{ accept }}" aria-label="Принять" style="width:58px; height:58px; border-radius:50%; border:none; background:var(--success); color:#fff; display:flex; align-items:center; justify-content:center; cursor:pointer; box-shadow:0 2px 8px rgba(82,196,26,.35);">
<svg viewBox="0 0 24 24" width="24" height="24" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round"><path d="M22 16.92v3a2 2 0 0 1-2.18 2 19.79 19.79 0 0 1-8.63-3.07 19.5 19.5 0 0 1-6-6 19.79 19.79 0 0 1-3.07-8.67A2 2 0 0 1 4.11 2h3a2 2 0 0 1 2 1.72c.13.81.36 1.6.7 2.34a2 2 0 0 1-.45 2.11L8.09 9.91a16 16 0 0 0 6 6l1.74-1.27a2 2 0 0 1 2.11-.45c.74.34 1.53.57 2.34.7A2 2 0 0 1 22 16.92z"></path></svg>
</button>
<span style="font-size:12.5px; color:var(--text-secondary);">Принять</span>
</div>
</div>
</sc-if>
<sc-if value="{{ barRinging }}" hint-placeholder-val="{{ false }}">
<div style="display:flex; flex-direction:column; align-items:center; gap:9px; padding:18px 16px 22px;">
<button onClick="{{ close }}" aria-label="Отменить вызов" style="width:58px; height:58px; border-radius:50%; border:none; background:var(--error); color:#fff; display:flex; align-items:center; justify-content:center; cursor:pointer; box-shadow:0 2px 8px rgba(255,77,79,.35);">
<svg viewBox="0 0 24 24" width="24" height="24" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round" style="transform:rotate(135deg);"><path d="M22 16.92v3a2 2 0 0 1-2.18 2 19.79 19.79 0 0 1-8.63-3.07 19.5 19.5 0 0 1-6-6 19.79 19.79 0 0 1-3.07-8.67A2 2 0 0 1 4.11 2h3a2 2 0 0 1 2 1.72c.13.81.36 1.6.7 2.34a2 2 0 0 1-.45 2.11L8.09 9.91a16 16 0 0 0 6 6l1.74-1.27a2 2 0 0 1 2.11-.45c.74.34 1.53.57 2.34.7A2 2 0 0 1 22 16.92z"></path></svg>
</button>
<span style="font-size:12.5px; color:var(--text-secondary);">Отменить</span>
</div>
</sc-if>
<sc-if value="{{ barPrep }}" hint-placeholder-val="{{ false }}">
<div style="display:flex; flex-direction:column; gap:15px; padding:16px 16px 18px;">
<div style="display:flex; justify-content:center; gap:32px;">
<div style="display:flex; flex-direction:column; align-items:center; gap:8px;">
<button onClick="{{ toggleMic }}" style="{{ micStyle }}" aria-label="Микрофон">{{ micIcon }}</button>
<span style="font-size:12px; color:var(--text-secondary);">Микрофон</span>
</div>
<div style="display:flex; flex-direction:column; align-items:center; gap:8px;">
<button onClick="{{ toggleCam }}" style="{{ camStyle }}" aria-label="Камера">{{ camIcon }}</button>
<span style="font-size:12px; color:var(--text-secondary);">Камера</span>
</div>
</div>
<div style="display:flex; gap:10px;">
<button onClick="{{ close }}" style="flex:1; height:44px; border-radius:var(--radius-lg); border:1px solid var(--n-6); background:var(--surface-card); color:var(--text-body); font-size:14px; font-weight:600; font-family:var(--font-sans); cursor:pointer;" style-hover="border-color:var(--primary); color:var(--primary);">Отмена</button>
<button onClick="{{ join }}" style="flex:1; height:44px; border-radius:var(--radius-lg); border:none; background:var(--primary); color:#fff; font-size:14px; font-weight:600; font-family:var(--font-sans); cursor:pointer; box-shadow:var(--shadow-primary);" style-hover="background:var(--primary-hover);">Присоединиться</button>
</div>
</div>
</sc-if>
<sc-if value="{{ barActive }}" hint-placeholder-val="{{ false }}">
<div style="display:flex; justify-content:center; align-items:center; gap:14px; padding:16px;">
<button onClick="{{ toggleMic }}" style="{{ micStyle }}" aria-label="Микрофон">{{ micIcon }}</button>
<button onClick="{{ toggleCam }}" style="{{ camStyle }}" aria-label="Камера">{{ camIcon }}</button>
<button aria-label="Полный экран" style="width:54px; height:54px; border-radius:50%; border:1px solid var(--n-7); background:var(--n-9); color:var(--n-3); display:flex; align-items:center; justify-content:center; cursor:pointer;">
<svg viewBox="0 0 24 24" width="21" height="21" fill="none" stroke="currentColor" stroke-width="1.85" stroke-linecap="round" stroke-linejoin="round"><path d="M8 3H5a2 2 0 0 0-2 2v3"></path><path d="M21 8V5a2 2 0 0 0-2-2h-3"></path><path d="M3 16v3a2 2 0 0 0 2 2h3"></path><path d="M16 21h3a2 2 0 0 0 2-2v-3"></path></svg>
</button>
<button onClick="{{ end }}" aria-label="Завершить" style="width:60px; height:54px; border-radius:27px; border:none; background:var(--error); color:#fff; display:flex; align-items:center; justify-content:center; cursor:pointer; box-shadow:0 2px 8px rgba(255,77,79,.35);">
<svg viewBox="0 0 24 24" width="24" height="24" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round" style="transform:rotate(135deg);"><path d="M22 16.92v3a2 2 0 0 1-2.18 2 19.79 19.79 0 0 1-8.63-3.07 19.5 19.5 0 0 1-6-6 19.79 19.79 0 0 1-3.07-8.67A2 2 0 0 1 4.11 2h3a2 2 0 0 1 2 1.72c.13.81.36 1.6.7 2.34a2 2 0 0 1-.45 2.11L8.09 9.91a16 16 0 0 0 6 6l1.74-1.27a2 2 0 0 1 2.11-.45c.74.34 1.53.57 2.34.7A2 2 0 0 1 22 16.92z"></path></svg>
</button>
</div>
</sc-if>
</div>
<div style="font-size:12.5px; color:var(--text-tertiary); text-align:center; max-width:420px; line-height:1.5;">Переключайте сторону и состояние выше. В активном звонке доступны тумблеры «Собеседник», чтобы проверить индикаторы удалённого микрофона и камеры.</div>
</div>
</x-dc>
<script type="text/x-dc" data-dc-script data-props="{&quot;$preview&quot;:{&quot;width&quot;:700,&quot;height&quot;:820},&quot;clientName&quot;:{&quot;editor&quot;:&quot;text&quot;,&quot;default&quot;:&quot;Анна Ковалёва&quot;,&quot;tsType&quot;:&quot;string&quot;,&quot;section&quot;:&quot;Контент&quot;},&quot;channel&quot;:{&quot;editor&quot;:&quot;enum&quot;,&quot;default&quot;:&quot;webchat&quot;,&quot;options&quot;:[&quot;webchat&quot;,&quot;telegram&quot;,&quot;max&quot;],&quot;tsType&quot;:&quot;string&quot;,&quot;section&quot;:&quot;Контент&quot;}}">
class Component extends DCLogic {
state = { perspective: 'operator', stateName: 'incoming', micOn: true, camOn: true, remoteMicOn: true, remoteCamOn: true, seconds: 0 };
componentDidMount() {
this._t = setInterval(() => {
if (this.state.stateName === 'active') this.setState(s => ({ seconds: s.seconds + 1 }));
}, 1000);
}
componentWillUnmount() { clearInterval(this._t); }
goto(st) { if (st) this.setState(s => ({ stateName: st, seconds: (st === 'active' && s.stateName !== 'reconnecting') ? 0 : s.seconds })); }
icon(name) {
const h = React.createElement;
const svg = (children, extra) => h('svg', Object.assign({ viewBox: '0 0 24 24', width: 26, height: 26, fill: 'none', stroke: 'currentColor', strokeWidth: 1.9, strokeLinecap: 'round', strokeLinejoin: 'round' }, extra || {}), children);
switch (name) {
case 'spinner': return svg([h('path', { key: 1, d: 'M21 12a9 9 0 1 1-6.219-8.56' })], { strokeWidth: 2, style: { animation: 'edv-spin .9s linear infinite' } });
case 'alert': return svg([h('path', { key: 1, d: 'M10.29 3.86 1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z' }), h('line', { key: 2, x1: 12, y1: 9, x2: 12, y2: 13 }), h('line', { key: 3, x1: 12, y1: 17, x2: 12.01, y2: 17 })]);
case 'declined': return svg([h('path', { key: 1, transform: 'rotate(135 12 12)', d: 'M22 16.92v3a2 2 0 0 1-2.18 2 19.79 19.79 0 0 1-8.63-3.07 19.5 19.5 0 0 1-6-6 19.79 19.79 0 0 1-3.07-8.67A2 2 0 0 1 4.11 2h3a2 2 0 0 1 2 1.72c.13.81.36 1.6.7 2.34a2 2 0 0 1-.45 2.11L8.09 9.91a16 16 0 0 0 6 6l1.74-1.27a2 2 0 0 1 2.11-.45c.74.34 1.53.57 2.34.7A2 2 0 0 1 22 16.92z' })]);
case 'missed': return svg([h('path', { key: 1, d: 'M23 7l-8 8-4-4-9 9' }), h('polyline', { key: 2, points: '17 7 23 7 23 13' })]);
case 'clock': return svg([h('circle', { key: 1, cx: 12, cy: 12, r: 9 }), h('path', { key: 2, d: 'M12 7v5l3 2' })]);
case 'nodevice': return svg([h('line', { key: 1, x1: 2, y1: 2, x2: 22, y2: 22 }), h('path', { key: 2, d: 'M16 16v1a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V7a2 2 0 0 1 2-2h2' }), h('path', { key: 3, d: 'M10 5h4a2 2 0 0 1 2 2v3l4-3v9' })]);
case 'unsupported': return svg([h('rect', { key: 1, x: 2, y: 3, width: 20, height: 14, rx: 2 }), h('line', { key: 2, x1: 8, y1: 21, x2: 16, y2: 21 }), h('line', { key: 3, x1: 12, y1: 17, x2: 12, y2: 21 }), h('line', { key: 4, x1: 9, y1: 8, x2: 15, y2: 14 }), h('line', { key: 5, x1: 15, y1: 8, x2: 9, y2: 14 })]);
default: return null;
}
}
micIcon() {
const h = React.createElement;
const svg = (c) => h('svg', { viewBox: '0 0 24 24', width: 22, height: 22, fill: 'none', stroke: 'currentColor', strokeWidth: 1.85, strokeLinecap: 'round', strokeLinejoin: 'round' }, c);
return this.state.micOn
? svg([h('rect', { key: 1, x: 9, y: 2, width: 6, height: 12, rx: 3 }), h('path', { key: 2, d: 'M19 10v1a7 7 0 0 1-14 0v-1' }), h('line', { key: 3, x1: 12, y1: 18, x2: 12, y2: 22 })])
: svg([h('line', { key: 1, x1: 3, y1: 3, x2: 21, y2: 21 }), h('path', { key: 2, d: 'M9 9v2a3 3 0 0 0 5.1 2.1' }), h('path', { key: 3, d: 'M15 9.3V5a3 3 0 0 0-5.9-.7' }), h('path', { key: 4, d: 'M19 10v1a7 7 0 0 1-1.4 4.2M12 18a7 7 0 0 1-7-7v-1' }), h('line', { key: 5, x1: 12, y1: 18, x2: 12, y2: 22 })]);
}
camIcon() {
const h = React.createElement;
const svg = (c) => h('svg', { viewBox: '0 0 24 24', width: 22, height: 22, fill: 'none', stroke: 'currentColor', strokeWidth: 1.85, strokeLinecap: 'round', strokeLinejoin: 'round' }, c);
return this.state.camOn
? svg([h('path', { key: 1, d: 'M23 7l-7 5 7 5V7z' }), h('rect', { key: 2, x: 1, y: 5, width: 15, height: 14, rx: 2.5 })])
: svg([h('line', { key: 1, x1: 2, y1: 2, x2: 22, y2: 22 }), h('path', { key: 2, d: 'M16 16v1a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V7a2 2 0 0 1 2-2h2' }), h('path', { key: 3, d: 'M10 5h4a2 2 0 0 1 2 2v3l4-3v9' })]);
}
renderVals() {
const s = this.state;
const st = s.stateName;
const isOperator = s.perspective === 'operator';
const clientName = this.props.clientName ?? 'Анна Ковалёва';
const channel = this.props.channel ?? 'webchat';
const isIncoming = st === 'incoming';
const isRinging = st === 'ringing';
const isPrep = st === 'prep';
const isActive = st === 'active';
const isReconnecting = st === 'reconnecting';
const liveCall = isActive || isReconnecting;
const peer = isOperator
? { name: clientName, initials: (clientName.trim()[0] || 'К').toUpperCase() + ((clientName.split(' ')[1] || '')[0] || '').toUpperCase(), color: '#722ed1' }
: { name: 'Оператор Edevs', initials: 'ОЭ', color: '#1677ff' };
const self = { name: 'Вы', initials: 'ВЫ', color: '#595959' };
const P = peer.name;
const mm = String(Math.floor(s.seconds / 60)).padStart(2, '0');
const ss = String(s.seconds % 60).padStart(2, '0');
// ---- status message states ----
const chanLabel = channel === 'webchat' ? 'Веб-виджет' : channel === 'telegram' ? 'Telegram' : 'MAX';
const msg = {
connecting: { icon: 'spinner', tone: 'neutral', title: 'Соединяем звонок', caption: 'Устанавливаем защищённое соединение…', actions: [{ label: 'Отменить', kind: 'ghost', to: 'incoming' }] },
failed: { icon: 'alert', tone: 'error', title: 'Не удалось соединиться', caption: 'Проверьте интернет-соединение и попробуйте снова.', actions: [{ label: 'Повторить', kind: 'primary', to: 'connecting' }, { label: 'Закрыть', kind: 'secondary', to: 'incoming' }] },
declined: { icon: 'declined', tone: 'neutral', title: 'Звонок отклонён', caption: P + ' отклонил(а) вызов.', actions: [{ label: 'Позвонить снова', kind: 'primary', to: 'ringing' }, { label: 'Закрыть', kind: 'secondary', to: 'incoming' }] },
missed: { icon: 'missed', tone: 'warn', title: 'Пропущенный звонок', caption: P + ' не ответил(а) на вызов.', actions: [{ label: 'Перезвонить', kind: 'primary', to: 'ringing' }, { label: 'Закрыть', kind: 'secondary', to: 'incoming' }] },
expired: { icon: 'clock', tone: 'warn', title: 'Время ожидания истекло', caption: 'Никто не ответил вовремя. Попробуйте позвонить снова.', actions: [{ label: 'Позвонить снова', kind: 'primary', to: 'ringing' }, { label: 'Закрыть', kind: 'secondary', to: 'incoming' }] },
nodevice: { icon: 'nodevice', tone: 'error', title: 'Нет доступа к камере и микрофону', caption: 'Разрешите доступ к устройствам в настройках браузера и повторите.', actions: [{ label: 'Повторить проверку', kind: 'primary', to: 'prep' }, { label: 'Без видео', kind: 'secondary', to: 'connecting' }] },
unsupported: { icon: 'unsupported', tone: 'error', title: 'Видеозвонки не поддерживаются', caption: 'Обновите браузер или откройте ссылку в Chrome, Safari или Edge.', actions: [{ label: 'Скопировать ссылку', kind: 'secondary', to: null }] },
};
const cur = msg[st];
const showStatus = !!cur;
const toneColor = { neutral: { bg: 'rgba(255,255,255,.08)', fg: '#fff' }, error: { bg: 'rgba(255,77,79,.16)', fg: '#ff7875' }, warn: { bg: 'rgba(250,173,20,.16)', fg: '#ffc53d' } };
const tc = cur ? toneColor[cur.tone] : toneColor.neutral;
const statusIconTile = { width: 56, height: 56, borderRadius: '50%', display: 'flex', alignItems: 'center', justifyContent: 'center', background: tc.bg, color: tc.fg };
const actBtn = (kind) => {
const base = { height: 38, padding: '0 16px', borderRadius: 'var(--radius-lg)', fontSize: 13.5, fontWeight: 600, fontFamily: 'var(--font-sans)', cursor: 'pointer' };
if (kind === 'primary') return { ...base, border: 'none', background: 'var(--primary)', color: '#fff' };
if (kind === 'secondary') return { ...base, border: '1px solid rgba(255,255,255,.28)', background: 'transparent', color: '#fff' };
return { ...base, border: 'none', background: 'transparent', color: 'rgba(255,255,255,.7)' };
};
const statusActions = (cur ? cur.actions : []).map(a => ({ label: a.label, style: actBtn(a.kind), onClick: a.to ? () => this.goto(a.to) : () => {} }));
// ---- main preview subject / caption ----
const remoteCamOff = liveCall && !s.remoteCamOn;
const mainSubject = isPrep ? self : peer;
let mainCaption;
if (isIncoming) mainCaption = isOperator ? ('Входящий · ' + chanLabel) : 'Входящий видеозвонок';
else if (isRinging) mainCaption = 'Вызываем…';
else if (isPrep) mainCaption = 'Камера выключена';
else mainCaption = 'Камера собеседника выключена';
let subtitle;
if (isIncoming) subtitle = isOperator ? 'Входящий звонок' : 'Входящий видеозвонок';
else if (isRinging) subtitle = 'Ожидание ответа';
else if (isPrep) subtitle = 'Проверьте камеру и микрофон';
else if (isReconnecting) subtitle = 'Переподключение';
else if (isActive) subtitle = 'На связи';
else if (cur) subtitle = cur.title;
else subtitle = '';
// ---- control styles ----
const roundBase = { width: 54, height: 54, borderRadius: '50%', display: 'flex', alignItems: 'center', justifyContent: 'center', cursor: 'pointer', flex: 'none', transition: 'background .15s, border-color .15s, color .15s' };
const neutralRound = { ...roundBase, border: '1px solid var(--n-7)', background: 'var(--n-9)', color: 'var(--n-3)' };
const mutedRound = { ...roundBase, border: '1px solid var(--error-border)', background: 'var(--error-bg)', color: 'var(--error-text)' };
const smallAvatarStyle = { width: 38, height: 38, borderRadius: '50%', background: peer.color, color: '#fff', display: 'flex', alignItems: 'center', justifyContent: 'center', fontSize: 14, fontWeight: 600, flex: 'none' };
const bigAvatarStyle = { width: 88, height: 88, borderRadius: '50%', background: mainSubject.color, color: '#fff', display: 'flex', alignItems: 'center', justifyContent: 'center', fontSize: 30, fontWeight: 600, position: 'relative', zIndex: 2, boxShadow: '0 4px 18px rgba(0,0,0,.35)' };
// ---- selectors ----
const segBase = { display: 'flex', alignItems: 'center', padding: '7px 13px', borderRadius: 'var(--radius-md)', border: 'none', cursor: 'pointer', fontSize: 13, fontFamily: 'var(--font-sans)', whiteSpace: 'nowrap' };
const segActive = { ...segBase, fontWeight: 600, background: 'var(--surface-card)', color: 'var(--primary)', boxShadow: '0 1px 2px rgba(0,0,0,.09)' };
const segIdle = { ...segBase, fontWeight: 500, background: 'transparent', color: 'var(--text-secondary)' };
const chip = (active) => ({ padding: '6px 12px', borderRadius: 'var(--radius-pill)', border: '1px solid ' + (active ? 'var(--primary)' : 'var(--border-strong)'), background: active ? 'var(--primary-bg)' : 'var(--surface-card)', color: active ? 'var(--primary)' : 'var(--text-secondary)', fontSize: 12.5, fontWeight: active ? 600 : 500, fontFamily: 'var(--font-sans)', cursor: 'pointer', whiteSpace: 'nowrap' });
const mkStates = (arr) => arr.map(([id, label]) => ({ label, style: chip(st === id), onClick: () => this.goto(id) }));
const mainStates = mkStates([['incoming', 'Входящий'], ['ringing', 'Ожидание'], ['prep', 'Подготовка'], ['connecting', 'Соединение'], ['active', 'Активный']]);
const errorStates = mkStates([['reconnecting', 'Переподключение'], ['declined', 'Отклонён'], ['missed', 'Пропущен'], ['expired', 'Истёк'], ['failed', 'Ошибка'], ['nodevice', 'Нет устройств'], ['unsupported', 'Не поддерж.']]);
const remoteChip = (on) => ({ padding: '5px 11px', borderRadius: 'var(--radius-pill)', border: '1px solid ' + (on ? 'var(--border-strong)' : 'var(--error-border)'), background: on ? 'var(--surface-card)' : 'var(--error-bg)', color: on ? 'var(--text-secondary)' : 'var(--error-text)', fontSize: 12, fontWeight: 500, fontFamily: 'var(--font-sans)', cursor: 'pointer' });
const showTimer = liveCall;
const timerBg = isReconnecting ? { bg: 'var(--warning-bg)', fg: 'var(--warning-text)', dot: 'var(--warning)' } : { bg: 'var(--error-bg)', fg: 'var(--error-text)', dot: 'var(--error)' };
return {
isOperator, channel, showChannel: isOperator, liveCall,
peerName: peer.name, peerInitials: peer.initials, subtitle,
mainName: mainSubject.name, mainInitials: mainSubject.initials, mainCaption,
timer: mm + ':' + ss, showTimer,
timerStyle: { display: 'flex', alignItems: 'center', gap: 6, padding: '4px 9px', borderRadius: 'var(--radius-pill)', background: timerBg.bg, color: timerBg.fg, fontSize: 12.5, fontWeight: 600, fontFamily: 'ui-monospace,SFMono-Regular,Menlo,monospace' },
timerDot: { width: 6, height: 6, borderRadius: '50%', background: timerBg.dot, animation: 'hub-pulse 1.6s ease-in-out infinite' },
videoVisible: (liveCall && s.remoteCamOn) || (isPrep && s.camOn),
showMainAvatar: isIncoming || isRinging || (isPrep && !s.camOn) || remoteCamOff,
ringsVisible: isIncoming || isRinging,
showReconnBanner: isReconnecting,
showStatus, statusIcon: cur ? this.icon(cur.icon) : null, statusIconTile,
statusTitle: cur ? cur.title : '', statusCaption: cur ? cur.caption : '',
hasStatusActions: statusActions.length > 0, statusActions,
showNameLabel: liveCall && !showStatus,
remoteMuted: liveCall && !s.remoteMicOn,
showPip: liveCall && !showStatus,
micOn: s.micOn, camOn: s.camOn, camOff: !s.camOn,
micIcon: this.micIcon(), camIcon: this.camIcon(),
micStyle: s.micOn ? neutralRound : mutedRound,
camStyle: s.camOn ? neutralRound : mutedRound,
smallAvatarStyle, bigAvatarStyle,
barIncoming: isIncoming, barRinging: isRinging, barPrep: isPrep, barActive: liveCall && !showStatus,
mainStates, errorStates,
segOp: isOperator ? segActive : segIdle, segCl: !isOperator ? segActive : segIdle,
setOp: () => this.setState({ perspective: 'operator' }),
setCl: () => this.setState({ perspective: 'client' }),
remoteMicChip: remoteChip(s.remoteMicOn), remoteCamChip: remoteChip(s.remoteCamOn),
remoteMicLabel: s.remoteMicOn ? 'вкл' : 'выкл', remoteCamLabel: s.remoteCamOn ? 'вкл' : 'выкл',
toggleRemoteMic: () => this.setState(x => ({ remoteMicOn: !x.remoteMicOn })),
toggleRemoteCam: () => this.setState(x => ({ remoteCamOn: !x.remoteCamOn })),
close: () => this.goto('incoming'),
accept: () => this.goto('active'),
decline: () => this.goto('incoming'),
join: () => this.goto('active'),
end: () => this.goto('incoming'),
toggleMic: () => this.setState(x => ({ micOn: !x.micOn })),
toggleCam: () => this.setState(x => ({ camOn: !x.camOn })),
};
}
}
</script>
</body>
</html>
+70
View File
@@ -0,0 +1,70 @@
// Иконки аудиозвонка (baseline «Аудиозвонок.dc.html»): stroke=currentColor,
// 1.85, round caps. Микрофон/динамик (аудио не имеет камеры), завершение и
// status-набор для процессов/ошибок/терминалов.
import type { ReactNode } from "react";
export type DeviceName = "mic" | "speaker";
const svg =
(width = 23) =>
(children: ReactNode) =>
(
<svg viewBox="0 0 24 24" width={width} height={width} fill="none" stroke="currentColor" strokeWidth={1.85} strokeLinecap="round" strokeLinejoin="round">
{children}
</svg>
);
export function MicIcon({ on }: { on: boolean }) {
const s = svg(23);
return on
? s(<><rect x="9" y="2" width="6" height="12" rx="3" /><path d="M19 10v1a7 7 0 0 1-14 0v-1" /><line x1="12" y1="18" x2="12" y2="22" /></>)
: s(<><line x1="3" y1="3" x2="21" y2="21" /><path d="M9 9v2a3 3 0 0 0 5.1 2.1" /><path d="M15 9.3V5a3 3 0 0 0-5.9-.7" /><path d="M19 10v1a7 7 0 0 1-1.4 4.2M12 18a7 7 0 0 1-7-7v-1" /><line x1="12" y1="18" x2="12" y2="22" /></>);
}
export function SpeakerIcon({ on }: { on: boolean }) {
const s = svg(23);
return on
? s(<><polygon points="11 5 6 9 2 9 2 15 6 15 11 19 11 5" /><path d="M15.5 8.5a5 5 0 0 1 0 7" /><path d="M19 5a9 9 0 0 1 0 14" /></>)
: s(<><polygon points="11 5 6 9 2 9 2 15 6 15 11 19 11 5" /><line x1="22" y1="9" x2="16" y2="15" /><line x1="16" y1="9" x2="22" y2="15" /></>);
}
// Трубка; rotated=true → повёрнута на 135° (отклонить/завершить/отменить).
export function PhoneIcon({ rotated = false, width = 26 }: { rotated?: boolean; width?: number }) {
const s = svg(width);
return s(
<path
style={rotated ? { transform: "rotate(135deg)", transformOrigin: "center" } : undefined}
d="M22 16.92v3a2 2 0 0 1-2.18 2 19.79 19.79 0 0 1-8.63-3.07 19.5 19.5 0 0 1-6-6 19.79 19.79 0 0 1-3.07-8.67A2 2 0 0 1 4.11 2h3a2 2 0 0 1 2 1.72c.13.81.36 1.6.7 2.34a2 2 0 0 1-.45 2.11L8.09 9.91a16 16 0 0 0 6 6l1.74-1.27a2 2 0 0 1 2.11-.45c.74.34 1.53.57 2.34.7A2 2 0 0 1 22 16.92z"
/>
);
}
export function SpinnerIcon({ width = 34 }: { width?: number }) {
const s = svg(width);
return s(<path style={{ animation: "hub-audio-spin .9s linear infinite" }} d="M21 12a9 9 0 1 1-6.219-8.56" strokeWidth={2} />);
}
export type AudioStatusIconName = "spinner" | "declined" | "missed" | "clock" | "alert" | "nodevice" | "unsupported";
export function AudioStatusIcon({ name }: { name: AudioStatusIconName }) {
const s = svg(34);
switch (name) {
case "spinner":
return <SpinnerIcon />;
case "declined":
return s(<path transform="rotate(135 12 12)" d="M22 16.92v3a2 2 0 0 1-2.18 2 19.79 19.79 0 0 1-8.63-3.07 19.5 19.5 0 0 1-6-6 19.79 19.79 0 0 1-3.07-8.67A2 2 0 0 1 4.11 2h3a2 2 0 0 1 2 1.72c.13.81.36 1.6.7 2.34a2 2 0 0 1-.45 2.11L8.09 9.91a16 16 0 0 0 6 6l1.74-1.27a2 2 0 0 1 2.11-.45c.74.34 1.53.57 2.34.7A2 2 0 0 1 22 16.92z" />);
case "missed":
return s(<><path d="M23 7l-8 8-4-4-9 9" /><polyline points="17 7 23 7 23 13" /></>);
case "clock":
return s(<><circle cx="12" cy="12" r="9" /><path d="M12 7v5l3 2" /></>);
case "alert":
return s(<><path d="M10.29 3.86 1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z" /><line x1="12" y1="9" x2="12" y2="13" /><line x1="12" y1="17" x2="12.01" y2="17" /></>);
case "nodevice":
return s(<><line x1="2" y1="2" x2="22" y2="22" /><path d="M16 16v1a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V7a2 2 0 0 1 2-2h2" /><path d="M10 5h4a2 2 0 0 1 2 2v3l4-3v9" /></>);
case "unsupported":
return s(<><rect x="2" y="3" width="20" height="14" rx="2" /><line x1="8" y1="21" x2="16" y2="21" /><line x1="12" y1="17" x2="12" y2="21" /><line x1="9" y1="8" x2="15" y2="14" /><line x1="15" y1="8" x2="9" y2="14" /></>);
default:
return null;
}
}
+240
View File
@@ -0,0 +1,240 @@
// Аудиозвонок — полноэкранный портретный UI (baseline «Аудиозвонок.dc.html»).
// Тёмный градиент 150deg #0958d9→#722ed1→#c41d7f + два дрейфующих блоба,
// glass-аватар 126px с двумя волнами edv-ring2, живая осциллограмма собеседника.
// Никакой камеры/видео/PiP — это честный аудио-режим.
import { AudioCallWaveform } from "./AudioCallWaveform";
import { AudioStatusIcon } from "./AudioCallIcons";
import { MicIcon, PhoneIcon, SpeakerIcon } from "./AudioCallIcons";
import { type AudioCallMode, type AudioCallStatus, formatDuration } from "./audioCallStates";
import "./audio-call.css";
type Props = {
mode: AudioCallMode;
status?: AudioCallStatus;
peerName: string;
peerInitials: string;
channelLabel?: string;
showChannel?: boolean;
micOn?: boolean;
speakerOn?: boolean;
remoteStream?: MediaStream | null;
remoteMicOn?: boolean;
elapsedSeconds?: number;
durationSeconds?: number | null;
mediaIssue?: string;
statusLabel?: string;
subCaption?: string;
onToggleMic?: () => void;
onToggleSpeaker?: () => void;
onAccept?: () => void;
onDecline?: () => void;
onCancel?: () => void;
onEnd?: () => void;
onClose?: () => void;
onCallAgain?: () => void;
onRetry?: () => void;
};
export function AudioCallView(props: Props) {
const status = props.status;
const isStatus = props.mode === "status" && !!status;
const showAvatarCenter = !isStatus;
const ringsVisible = props.mode === "incoming" || props.mode === "ringing";
const isActive = props.mode === "active";
const timer = formatDuration(props.elapsedSeconds ?? 0);
return (
<div className="hub-audio-view" data-bg={bgState(props.mode)}>
<div className="hub-audio-bg" aria-hidden>
<div className="hub-audio-grad" />
<div className="hub-audio-blob a" />
<div className="hub-audio-blob b" />
<div className="hub-audio-veil" />
</div>
<div className="hub-audio-body">
<div className="hub-audio-top">
<span className="hub-audio-status-label">{props.statusLabel ?? defaultStatusLabel(props.mode, status)}</span>
{props.showChannel !== false && <span className="hub-audio-channel">{props.channelLabel ?? ""}</span>}
<div className="hub-audio-top-spacer" />
{isActive && <span className="hub-audio-timer">{timer}</span>}
</div>
<div className="hub-audio-center">
{showAvatarCenter && (
<>
<div className="hub-audio-avatar-wrap">
{ringsVisible && (
<>
<i className="hub-audio-ring" />
<i className="hub-audio-ring delay" />
</>
)}
<div className="hub-audio-avatar">{props.peerInitials}</div>
</div>
<div className="hub-audio-name-block">
<div className="hub-audio-name">{props.peerName}</div>
<div className="hub-audio-sub">{props.subCaption ?? defaultSubCaption(props.mode, props.micOn)}</div>
</div>
<div className="hub-audio-wave-slot">
{isActive && <AudioCallWaveform stream={props.remoteStream} micOn={props.remoteMicOn !== false} active />}
{!isActive && durationPill(props.durationSeconds)}
</div>
</>
)}
{isStatus && status && (
<StatusCenter status={status} />
)}
</div>
<div className="hub-audio-bottom">
{props.mode === "incoming" && (
<PairBar left={{ label: "Отклонить", tone: "decline", icon: <PhoneIcon rotated /> }} right={{ label: "Принять", tone: "accept", icon: <PhoneIcon /> }} onLeft={props.onDecline} onRight={props.onAccept} />
)}
{props.mode === "ringing" && (
<SingleRoundBar label="Отменить" tone="decline" icon={<PhoneIcon rotated />} onClick={props.onCancel ?? props.onEnd} />
)}
{props.mode === "connecting" && (
<SingleRoundBar label="Отменить" tone="decline" icon={<PhoneIcon rotated />} onClick={props.onCancel ?? props.onEnd} />
)}
{isActive && (
<div className="hub-audio-active">
<div className="hub-audio-device-row">
<DeviceControl label="Микрофон" icon={<MicIcon on={props.micOn !== false} />} on={props.micOn !== false} onClick={props.onToggleMic} />
<DeviceControl label="Динамик" icon={<SpeakerIcon on={props.speakerOn !== false} />} on={props.speakerOn !== false} onClick={props.onToggleSpeaker} />
</div>
<SingleRoundBar label="Завершить" tone="decline" icon={<PhoneIcon rotated />} onClick={props.onEnd} />
</div>
)}
{isStatus && status && (
<StatusBar status={status} onClose={props.onClose} onCallAgain={props.onCallAgain} onRetry={props.onRetry} onEnd={props.onEnd} />
)}
</div>
</div>
</div>
);
}
function StatusCenter({ status }: { status: AudioCallStatus }) {
return (
<div className="hub-audio-status-center">
<span className={`hub-audio-status-tile ${status.tone}`}><AudioStatusIcon name={status.icon} /></span>
<div>
<div className="hub-audio-status-title">{status.title}</div>
<div className="hub-audio-status-caption">{status.caption}</div>
</div>
</div>
);
}
function DeviceControl({ label, icon, on, onClick }: { label: string; icon: React.ReactNode; on: boolean; onClick?: () => void }) {
return (
<div className="hub-audio-device-control">
<button className={`hub-audio-device${on ? "" : " off"}`} onClick={onClick} aria-label={label}>{icon}</button>
<span>{label}</span>
</div>
);
}
function SingleRoundBar({ label, tone, icon, onClick }: { label: string; tone: "decline"; icon: React.ReactNode; onClick?: () => void }) {
return (
<div className="hub-audio-round-stack">
<button className={`hub-audio-round ${tone}`} onClick={onClick} aria-label={label}>{icon}</button>
<span className="hub-audio-round-label">{label}</span>
</div>
);
}
function PairBar({ left, right, onLeft, onRight }: { left: BarEnd; right: BarEnd; onLeft?: () => void; onRight?: () => void }) {
return (
<div className="hub-audio-pair">
<EndStack end={left} onClick={onLeft} />
<EndStack end={right} onClick={onRight} />
</div>
);
}
type BarEnd = { label: string; tone: "decline" | "accept"; icon: React.ReactNode };
function EndStack({ end, onClick }: { end: BarEnd; onClick?: () => void }) {
return (
<div className="hub-audio-round-stack">
<button className={`hub-audio-round ${end.tone}`} onClick={onClick} aria-label={end.label}>{end.icon}</button>
<span className="hub-audio-round-label">{end.label}</span>
</div>
);
}
// Бар для терминальных/ошибочных статусов: кнопки 48px, как в дизайне ended.
function StatusBar({ status, onClose, onCallAgain, onRetry, onEnd }: { status: AudioCallStatus; onClose?: () => void; onCallAgain?: () => void; onRetry?: () => void; onEnd?: () => void }) {
const bar = status.bar;
if (bar === "reconnect") {
return <SingleRoundBar label="Завершить" tone="decline" icon={<PhoneIcon rotated />} onClick={onEnd} />;
}
if (bar === "retrySingle") {
return (
<div className="hub-audio-pill-bar">
<PillButton kind="solid" onClick={onCallAgain}>Позвонить снова</PillButton>
</div>
);
}
if (bar === "retryClose") {
return (
<div className="hub-audio-pill-bar two">
<PillButton kind="ghost" onClick={onClose}>Закрыть</PillButton>
<PillButton kind="solid" onClick={onRetry}>Повторить</PillButton>
</div>
);
}
if (bar === "retryCheck") {
return (
<div className="hub-audio-pill-bar two">
<PillButton kind="ghost" onClick={onClose}>Закрыть</PillButton>
<PillButton kind="solid" onClick={onRetry}>Повторить проверку</PillButton>
</div>
);
}
// ended / connecting
return (
<div className="hub-audio-pill-bar two">
<PillButton kind="ghost" onClick={onClose}>Закрыть</PillButton>
<PillButton kind="solid" onClick={onCallAgain}>Позвонить снова</PillButton>
</div>
);
}
function PillButton({ kind, onClick, children }: { kind: "solid" | "ghost"; onClick?: () => void; children: React.ReactNode }) {
return <button className={`hub-audio-pill ${kind}`} onClick={onClick}>{children}</button>;
}
function durationPill(duration?: number | null) {
if (duration == null || duration <= 0) return null;
return (
<div className="hub-audio-duration-pill">
<svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><circle cx="12" cy="12" r="9" /><path d="M12 7v5l3 2" /></svg>
Длительность {formatDuration(duration)}
</div>
);
}
function bgState(mode: AudioCallMode): string {
if (mode === "active") return "active";
if (mode === "status" || mode === "reconnecting") return "dim";
return "full";
}
function defaultStatusLabel(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 defaultSubCaption(mode: AudioCallMode, micOn?: boolean): string {
if (mode === "active") return micOn ? "Говорите" : "Ваш микрофон выключен";
if (mode === "incoming" || mode === "ringing") return "";
return "";
}
@@ -0,0 +1,96 @@
// Живая осциллограмма аудиозвонка (baseline «Аудиозвонок.dc.html»).
// Белая линия rgba(255,255,255,.92), lineWidth 3, конусное затухание по краям.
// Амплитуда берётся из AnalyserNode удалённого потока — волна реагирует на
// голос собеседника. Нет потока/тишина → мягкая анимированная idle-волна.
import { useEffect, useRef } from "react";
type Props = {
stream?: MediaStream | null;
micOn?: boolean;
active: boolean;
};
const IDLE_AMP = 0.12;
const SPEAK_AMP = 0.5;
export function AudioCallWaveform({ stream, micOn = true, active }: Props) {
const canvasRef = useRef<HTMLCanvasElement>(null);
const streamRef = useRef<MediaStream | null>(stream);
streamRef.current = stream;
useEffect(() => {
const canvas = canvasRef.current;
if (!canvas) return;
const ctx = canvas.getContext("2d");
if (!ctx) return;
const W = canvas.width;
const H = canvas.height;
const mid = H / 2;
// AnalyserNode на удалённом аудио, чтобы измерять громкость собеседника.
let audioCtx: AudioContext | null = null;
let analyser: AnalyserNode | null = null;
let source: MediaStreamAudioSourceNode | null = null;
let levels: Uint8Array<ArrayBuffer> | null = null;
try {
const target = streamRef.current;
if (target && target.getAudioTracks().length > 0) {
audioCtx = new (window.AudioContext || (window as unknown as { webkitAudioContext: typeof AudioContext }).webkitAudioContext)();
analyser = audioCtx.createAnalyser();
analyser.fftSize = 1024;
source = audioCtx.createMediaStreamSource(target);
source.connect(analyser);
levels = new Uint8Array(new ArrayBuffer(analyser.frequencyBinCount));
}
} catch {
// AudioContext недоступен (нет аудио/нет прав) — работаем idle-волной.
}
let raf = 0;
const draw = (ts: number) => {
raf = requestAnimationFrame(draw);
ctx.clearRect(0, 0, W, H);
// Уровень громкости собеседника 0..1 (RMS по bins).
let level = 0;
if (active && micOn && analyser && levels) {
analyser.getByteTimeDomainData(levels);
let sum = 0;
for (let i = 0; i < levels.length; i++) {
const v = (levels[i] - 128) / 128;
sum += v * v;
}
level = Math.min(1, Math.sqrt(sum / levels.length) * 2.4);
}
const speaking = active && micOn;
const t = ts / 1000;
const base = speaking ? SPEAK_AMP : IDLE_AMP;
const ampScale = speaking ? (0.35 + 0.65 * level) : 1;
ctx.lineWidth = 3;
ctx.lineJoin = "round";
ctx.strokeStyle = "rgba(255,255,255,0.92)";
ctx.beginPath();
for (let x = 0; x <= W; x += 4) {
const p = x / W;
const env = Math.sin(p * Math.PI); // taper ends
const amp = env * H * 0.36 * base * ampScale * (0.6 + 0.4 * Math.sin(t * 2.1 + p * 3));
const y = mid + Math.sin(p * 22 + t * 6) * amp + Math.sin(p * 9 - t * 3.3) * amp * 0.5;
if (x === 0) ctx.moveTo(x, y);
else ctx.lineTo(x, y);
}
ctx.stroke();
};
raf = requestAnimationFrame(draw);
return () => {
cancelAnimationFrame(raf);
source?.disconnect();
analyser?.disconnect();
void audioCtx?.close();
};
}, [active, micOn]);
return <canvas ref={canvasRef} width={520} height={112} className="hub-audio-wave" />;
}
+149
View File
@@ -0,0 +1,149 @@
/* Аудиозвонок (baseline «Аудиозвонок.dc.html»). Тёмная портретная сцена с
живым градиентом и glass-элементами. Токены совпадают с макетом 1-в-1. */
@keyframes hub-audio-ring2 { 0% { transform: scale(1); opacity: .5; } 100% { transform: scale(1.9); opacity: 0; } }
@keyframes hub-audio-drift { 0% { transform: translate(0,0) scale(1); } 50% { transform: translate(4%,-3%) scale(1.08); } 100% { transform: translate(0,0) scale(1); } }
@keyframes hub-audio-drift2 { 0% { transform: translate(0,0) scale(1.05); } 50% { transform: translate(-5%,4%) scale(1); } 100% { transform: translate(0,0) scale(1.05); } }
@keyframes hub-audio-spin { to { transform: rotate(360deg); } }
.hub-audio-view {
position: relative;
width: 100%;
max-width: 400px;
aspect-ratio: 9 / 16;
max-height: 760px;
margin: 0 auto;
border-radius: var(--radius-4xl, 28px);
overflow: hidden;
background: #0d0d16;
box-shadow: 0 24px 70px rgba(0, 0, 0, 0.55);
}
.hub-audio-bg { position: absolute; inset: 0; z-index: 0; overflow: hidden; }
.hub-audio-grad {
position: absolute; inset: 0;
background: linear-gradient(150deg, #0958d9 0%, #722ed1 52%, #c41d7f 100%);
opacity: 1; transition: opacity .6s;
}
[data-bg="active"] .hub-audio-grad { opacity: .85; }
[data-bg="dim"] .hub-audio-grad { opacity: .5; }
.hub-audio-blob { position: absolute; border-radius: 50%; }
.hub-audio-blob.a {
width: 75%; height: 55%; left: -12%; top: -10%;
background: radial-gradient(circle, #597ef7, transparent 68%);
filter: blur(18px); opacity: .75;
animation: hub-audio-drift 9s ease-in-out infinite;
}
.hub-audio-blob.b {
width: 70%; height: 52%; right: -14%; bottom: -8%;
background: radial-gradient(circle, #c41d7f, transparent 68%);
filter: blur(20px); opacity: .6;
animation: hub-audio-drift2 11s ease-in-out infinite;
}
.hub-audio-veil {
position: absolute; inset: 0;
background: linear-gradient(180deg, rgba(11, 11, 18, .15) 0%, rgba(11, 11, 18, .05) 45%, rgba(11, 11, 18, .55) 100%);
}
.hub-audio-body {
position: relative; z-index: 2; height: 100%;
box-sizing: border-box; display: flex; flex-direction: column;
padding: 26px 24px 30px; color: #fff;
}
.hub-audio-top { display: flex; align-items: center; gap: 9px; }
.hub-audio-status-label { font-size: 13px; font-weight: 600; letter-spacing: .02em; color: rgba(255, 255, 255, .9); }
.hub-audio-channel {
display: inline-flex; align-items: center; gap: 5px;
padding: 3px 9px; border-radius: var(--radius-pill, 999px);
background: rgba(255, 255, 255, .16); backdrop-filter: blur(6px);
font-size: 11.5px; font-weight: 500; color: #fff;
}
.hub-audio-top-spacer { flex: 1; }
.hub-audio-timer {
font-size: 14px; font-weight: 600; letter-spacing: .04em;
font-family: ui-monospace, SFMono-Regular, Menlo, monospace; color: #fff;
}
.hub-audio-center {
flex: 1; display: flex; flex-direction: column;
align-items: center; justify-content: center; gap: 22px;
}
.hub-audio-avatar-wrap { position: relative; display: flex; align-items: center; justify-content: center; }
.hub-audio-ring {
position: absolute; width: 126px; height: 126px; border-radius: 50%;
border: 2px solid rgba(255, 255, 255, .45);
animation: hub-audio-ring2 2s ease-out infinite;
}
.hub-audio-ring.delay { animation-delay: 1s; }
.hub-audio-avatar {
width: 126px; height: 126px; border-radius: 50%;
background: rgba(255, 255, 255, .14); backdrop-filter: blur(10px);
border: 1px solid rgba(255, 255, 255, .28);
display: flex; align-items: center; justify-content: center;
font-size: 42px; font-weight: 600; color: #fff;
box-shadow: 0 8px 30px rgba(0, 0, 0, .3);
}
.hub-audio-name-block { text-align: center; }
.hub-audio-name { font-size: 25px; font-weight: 600; letter-spacing: -0.01em; color: #fff; }
.hub-audio-sub { font-size: 14px; color: rgba(255, 255, 255, .72); margin-top: 6px; }
.hub-audio-wave-slot { height: 56px; width: 100%; max-width: 260px; display: flex; align-items: center; justify-content: center; }
.hub-audio-wave { width: 100%; height: 56px; }
.hub-audio-status-center { display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 18px; text-align: center; }
.hub-audio-status-tile {
width: 92px; height: 92px; border-radius: 50%;
display: flex; align-items: center; justify-content: center;
border: 1px solid rgba(255, 255, 255, .22); backdrop-filter: blur(10px);
box-shadow: 0 8px 30px rgba(0, 0, 0, .3);
}
.hub-audio-status-tile.neutral { background: rgba(255, 255, 255, .14); color: #fff; }
.hub-audio-status-tile.warn { background: rgba(250, 173, 20, .18); color: #ffc53d; }
.hub-audio-status-tile.error { background: rgba(255, 77, 79, .2); color: #ff7875; }
.hub-audio-status-title { font-size: 23px; font-weight: 600; letter-spacing: -0.01em; color: #fff; line-height: 1.25; }
.hub-audio-status-caption { font-size: 14px; color: rgba(255, 255, 255, .72); margin-top: 8px; line-height: 1.5; max-width: 280px; }
.hub-audio-duration-pill {
display: inline-flex; align-items: center; gap: 8px;
padding: 8px 16px; border-radius: var(--radius-pill, 999px);
background: rgba(255, 255, 255, .12); font-size: 13.5px; color: #fff;
}
.hub-audio-bottom { display: flex; flex-direction: column; gap: 18px; }
.hub-audio-pair { display: flex; justify-content: center; gap: 64px; }
.hub-audio-round-stack { display: flex; flex-direction: column; align-items: center; gap: 9px; }
.hub-audio-round {
width: 64px; height: 64px; border-radius: 50%; border: none; cursor: pointer;
display: flex; align-items: center; justify-content: center; color: #fff;
}
.hub-audio-round.decline { background: var(--error, #ff4d4f); box-shadow: 0 6px 20px rgba(255, 77, 79, .45); }
.hub-audio-round.accept { background: var(--success, #52c41a); box-shadow: 0 6px 20px rgba(82, 196, 26, .45); }
.hub-audio-round-label { font-size: 12.5px; color: rgba(255, 255, 255, .75); }
.hub-audio-active { display: flex; flex-direction: column; gap: 18px; }
.hub-audio-device-row { display: flex; justify-content: center; gap: 40px; }
.hub-audio-device-control { display: flex; flex-direction: column; align-items: center; gap: 8px; }
.hub-audio-device {
width: 60px; height: 60px; border-radius: 50%; cursor: pointer;
border: 1px solid rgba(255, 255, 255, .28); color: #fff;
background: rgba(255, 255, 255, .16); backdrop-filter: blur(8px);
display: flex; align-items: center; justify-content: center; transition: background .15s, color .15s;
}
.hub-audio-device.off { background: #fff; color: #141414; }
.hub-audio-device-control span { font-size: 11.5px; color: rgba(255, 255, 255, .72); }
.hub-audio-pill-bar { display: flex; gap: 12px; }
.hub-audio-pill-bar.two > * { flex: 1; }
.hub-audio-pill {
height: 48px; border-radius: var(--radius-lg, 12px); cursor: pointer;
font-size: 14px; font-weight: 600; font-family: var(--font-sans, inherit);
}
.hub-audio-pill.solid { flex: 1; border: none; background: #fff; color: #141414; }
.hub-audio-pill.ghost { flex: 1; border: 1px solid rgba(255, 255, 255, .3); background: rgba(255, 255, 255, .08); color: #fff; }
@media (max-width: 480px) {
.hub-audio-view { max-width: 100%; max-height: none; aspect-ratio: auto; height: 100%; border-radius: 0; }
}
+75
View File
@@ -0,0 +1,75 @@
// Состояния аудиозвонка и сопутствующие данные (baseline «Аудиозвонок.dc.html»).
// Тексты/иконки/тоны вынесены отдельно, чтобы AudioCallView оставался
// composition-only и не смешивал разметку со справочником состояний.
import type { AudioStatusIconName } from "./AudioCallIcons";
export type AudioCallMode = "incoming" | "ringing" | "connecting" | "active" | "reconnecting" | "status";
export type AudioCallActionKind = "close" | "callAgain" | "retry" | "retryCheck" | "end";
export type AudioCallAction = { label: string; kind: AudioCallActionKind };
export type AudioCallStatus = {
icon: AudioStatusIconName;
tone: "neutral" | "warn" | "error";
title: string;
caption: string;
bar: AudioBarKind;
};
// Какой нижний бар рисовать для текущего состояния (вне active/incoming/ringing/connecting).
export type AudioBarKind = "ended" | "retrySingle" | "retryClose" | "reconnect" | "retryCheck";
export type AudioStatusEntry = AudioCallStatus;
// Терминальные состояния, приходящие из домена звонков (TERMINAL_CALL_STATUSES).
export const AUDIO_TERMINAL_STATUSES = new Set([
"DECLINED",
"CANCELLED",
"MISSED",
"ENDED",
"FAILED",
"EXPIRED",
]);
export const isAudioTerminal = (status?: string) => Boolean(status && AUDIO_TERMINAL_STATUSES.has(status));
function dur(prefix: string, duration?: number | null) {
return duration != null && duration > 0 ? `${prefix}Длительность ${formatDuration(duration)}. ` : prefix;
}
// Справочник статусов-центров: процессы + ошибки + терминалы.
// `duration` подставляется только там, где разговор реально состоялся.
export function buildAudioStatus(status: string, peerName: string, duration?: number | null): AudioStatusEntry | null {
const p = `${peerName} `;
switch (status) {
case "connecting":
case "CONNECTING":
return { icon: "spinner", tone: "neutral", title: "Соединяем", caption: "Устанавливаем защищённое соединение…", bar: "ended" };
case "reconnecting":
return { icon: "spinner", tone: "neutral", title: "Связь прервана", caption: "Восстанавливаем соединение…", bar: "reconnect" };
case "DECLINED":
return { icon: "declined", tone: "neutral", title: "Звонок отклонён", caption: `${p}отклонил(а) вызов.`, bar: "retrySingle" };
case "MISSED":
return { icon: "missed", tone: "warn", title: "Пропущенный звонок", caption: `${p}не ответил(а) на вызов.`, bar: "retrySingle" };
case "EXPIRED":
return { icon: "clock", tone: "warn", title: "Время ожидания истекло", caption: "Никто не ответил вовремя. Попробуйте позвонить снова.", bar: "retrySingle" };
case "CANCELLED":
return { icon: "declined", tone: "neutral", title: "Звонок отменён", caption: "Приглашение отменено.", bar: "ended" };
case "FAILED":
return { icon: "alert", tone: "error", title: "Не удалось соединиться", caption: `${dur("", duration)}Проверьте интернет-соединение и попробуйте снова.`, bar: "retryClose" };
case "ENDED":
return { icon: "clock", tone: "neutral", title: "Звонок завершён", caption: `${dur("", duration)}Разговор завершён.`, bar: "ended" };
case "nodevice":
return { icon: "nodevice", tone: "error", title: "Нет доступа к микрофону", caption: "Разрешите доступ к микрофону в настройках браузера и повторите.", bar: "retryCheck" };
case "unsupported":
return { icon: "unsupported", tone: "error", title: "Аудиозвонки не поддерживаются", caption: "Обновите браузер или откройте ссылку в Chrome, Safari или Edge.", bar: "retryCheck" };
default:
return null;
}
}
export function formatDuration(seconds: number) {
return `${String(Math.floor(seconds / 60)).padStart(2, "0")}:${String(seconds % 60).padStart(2, "0")}`;
}
+18 -5
View File
@@ -9,6 +9,7 @@ type Options = {
accessToken: string;
side: CallSide;
iceServers: RTCIceServer[];
videoEnabled?: boolean;
onCallState: (call: PublicCallState) => void;
};
@@ -27,6 +28,8 @@ export function useCallRtcSession(options: Options) {
const localRef = useRef<MediaStream | null>(null);
const clientRef = useRef<CallRtcClient | null>(null);
const preparingRef = useRef(false);
const videoEnabledRef = useRef(options.videoEnabled ?? true);
videoEnabledRef.current = options.videoEnabled ?? true;
const onCallStateRef = useRef(options.onCallState);
onCallStateRef.current = options.onCallState;
@@ -47,14 +50,24 @@ export function useCallRtcSession(options: Options) {
preparingRef.current = true;
setPreparing(true);
try {
// Аудиозвонок: видео не запрашивается и не откатывается — это честный
// аудио-режим. Видеозвонок: video+audio, при отказе камеры — audio-only.
const constraints: MediaStreamConstraints = videoEnabledRef.current
? { audio: true, video: true }
: { audio: true, video: false };
let stream: MediaStream;
try {
stream = await navigator.mediaDevices.getUserMedia({ audio: true, video: true });
stream = await navigator.mediaDevices.getUserMedia(constraints);
setMediaIssue("none");
if (!videoEnabledRef.current) setCamOn(false);
} catch {
stream = await navigator.mediaDevices.getUserMedia({ audio: true, video: false });
setCamOn(false);
setMediaIssue("video");
if (videoEnabledRef.current) {
stream = await navigator.mediaDevices.getUserMedia({ audio: true, video: false });
setCamOn(false);
setMediaIssue("video");
} else {
throw new Error("audio devices unavailable");
}
}
localRef.current = stream;
setLocalStream(stream);
@@ -71,7 +84,7 @@ export function useCallRtcSession(options: Options) {
useEffect(() => {
stop();
setMicOn(true);
setCamOn(true);
setCamOn(videoEnabledRef.current);
setRemoteMicOn(true);
setRemoteCamOn(true);
setMediaIssue("none");
+2
View File
@@ -1,4 +1,6 @@
export { edevsHubTheme } from "./theme";
export { CallView, type CallViewMode, type CallViewStatus } from "./call/CallView";
export { AudioCallView } from "./call/AudioCallView";
export { type AudioCallMode, type AudioCallStatus, buildAudioStatus, isAudioTerminal, formatDuration as formatAudioDuration } from "./call/audioCallStates";
export { useCallRtcSession, type CallMediaIssue } from "./call/useCallRtcSession";
export { useAudioCue, useLoopingAudio } from "./audio/useAudio";