mirror of
https://github.com/dartdavros/chatballs.git
synced 2026-10-05 09:14:58 +03:00
✨ 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:
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,
|
||||
|
||||
@@ -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,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();
|
||||
|
||||
@@ -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" /></>,
|
||||
|
||||
@@ -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 }) {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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
@@ -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 |
+508
@@ -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": []
|
||||
}
|
||||
}
|
||||
+3489
File diff suppressed because it is too large.
Load diff
+1
@@ -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
|
||||
+216
@@ -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.
|
||||
+4
@@ -0,0 +1,4 @@
|
||||
@import "./tokens/colors.css";
|
||||
@import "./tokens/typography.css";
|
||||
@import "./tokens/spacing.css";
|
||||
@import "./tokens/effects.css";
|
||||
+88
@@ -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);
|
||||
}
|
||||
+40
@@ -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; }
|
||||
}
|
||||
+36
@@ -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;
|
||||
}
|
||||
+42
@@ -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="{"$preview":{"width":440,"height":900},"clientName":{"editor":"text","default":"Анна Ковалёва","tsType":"string","section":"Контент"},"channel":{"editor":"enum","default":"webchat","options":["webchat","telegram","max"],"tsType":"string","section":"Контент"}}">
|
||||
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="{"$preview":{"width":900,"height":820},"orgName":{"editor":"text","default":"Магазин «Северный»","tsType":"string","section":"Контент"},"agentName":{"editor":"text","default":"Иван Петров","tsType":"string","section":"Контент"},"agentRole":{"editor":"text","default":"Менеджер поддержки","tsType":"string","section":"Контент"}}">
|
||||
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="{"$preview":{"width":700,"height":820},"clientName":{"editor":"text","default":"Анна Ковалёва","tsType":"string","section":"Контент"},"channel":{"editor":"enum","default":"webchat","options":["webchat","telegram","max"],"tsType":"string","section":"Контент"}}">
|
||||
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>
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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" />;
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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")}`;
|
||||
}
|
||||
@@ -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");
|
||||
|
||||
@@ -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";
|
||||
Reference in new issue
Block a user