ajaxter-chat 3.0.15 → 3.0.17
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +0 -0
- package/dist/components/CallScreen/index.d.ts +2 -0
- package/dist/components/CallScreen/index.js +12 -2
- package/dist/components/ChatScreen/index.js +20 -20
- package/dist/components/ChatWidget.js +35 -4
- package/dist/components/HomeScreen/index.d.ts +2 -0
- package/dist/components/HomeScreen/index.js +75 -19
- package/dist/components/MiniCallBar/index.d.ts +14 -0
- package/dist/components/MiniCallBar/index.js +68 -0
- package/dist/components/ViewerBlockedScreen/index.d.ts +1 -0
- package/dist/components/ViewerBlockedScreen/index.js +29 -5
- package/dist/index.d.ts +3 -1
- package/dist/index.js +1 -0
- package/dist/types/index.d.ts +12 -0
- package/dist/utils/presenceStatus.d.ts +13 -0
- package/dist/utils/presenceStatus.js +45 -0
- package/package.json +1 -1
- package/src/components/CallScreen/index.tsx +23 -1
- package/src/components/ChatScreen/index.tsx +2 -1
- package/src/components/ChatWidget.tsx +71 -9
- package/src/components/HomeScreen/index.tsx +94 -5
- package/src/components/MiniCallBar/index.tsx +150 -0
- package/src/components/ViewerBlockedScreen/index.tsx +48 -5
- package/src/index.ts +3 -0
- package/src/types/index.ts +13 -0
- package/src/utils/presenceStatus.ts +56 -0
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
const key = (widgetId) => `ajaxter_presence_${widgetId}`;
|
|
2
|
+
export function loadPresenceStatus(widgetId) {
|
|
3
|
+
if (typeof sessionStorage === 'undefined')
|
|
4
|
+
return 'ACTIVE';
|
|
5
|
+
try {
|
|
6
|
+
const v = sessionStorage.getItem(key(widgetId));
|
|
7
|
+
if (v === 'ACTIVE' || v === 'AWAY' || v === 'DND')
|
|
8
|
+
return v;
|
|
9
|
+
}
|
|
10
|
+
catch (_a) {
|
|
11
|
+
/* */
|
|
12
|
+
}
|
|
13
|
+
return 'ACTIVE';
|
|
14
|
+
}
|
|
15
|
+
export function savePresenceStatus(widgetId, status) {
|
|
16
|
+
try {
|
|
17
|
+
sessionStorage.setItem(key(widgetId), status);
|
|
18
|
+
}
|
|
19
|
+
catch (_a) {
|
|
20
|
+
/* quota */
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
/** Prefer server value from DB when the host includes it in config */
|
|
24
|
+
export function resolveInitialPresence(widgetId, serverStatus) {
|
|
25
|
+
if (serverStatus === 'ACTIVE' || serverStatus === 'AWAY' || serverStatus === 'DND')
|
|
26
|
+
return serverStatus;
|
|
27
|
+
return loadPresenceStatus(widgetId);
|
|
28
|
+
}
|
|
29
|
+
/** Call your backend to persist presence (production DB). */
|
|
30
|
+
export async function syncPresenceToServer(url, payload) {
|
|
31
|
+
const res = await fetch(url, {
|
|
32
|
+
method: 'POST',
|
|
33
|
+
headers: {
|
|
34
|
+
Accept: 'application/json',
|
|
35
|
+
'Content-Type': 'application/json',
|
|
36
|
+
},
|
|
37
|
+
body: JSON.stringify(payload),
|
|
38
|
+
mode: 'cors',
|
|
39
|
+
credentials: 'omit',
|
|
40
|
+
});
|
|
41
|
+
if (!res.ok) {
|
|
42
|
+
const t = await res.text().catch(() => '');
|
|
43
|
+
throw new Error(t || `Presence sync failed (${res.status})`);
|
|
44
|
+
}
|
|
45
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "ajaxter-chat",
|
|
3
|
-
"version": "3.0.
|
|
3
|
+
"version": "3.0.17",
|
|
4
4
|
"description": "Drawer-based chat widget with support chat, tickets, WebRTC calling, voice messages, block list, and transcript download.",
|
|
5
5
|
"main": "dist/index.js",
|
|
6
6
|
"types": "dist/index.d.ts",
|
|
@@ -10,11 +10,13 @@ interface CallScreenProps {
|
|
|
10
10
|
onToggleMute: () => void;
|
|
11
11
|
onToggleCamera: () => void;
|
|
12
12
|
primaryColor: string;
|
|
13
|
+
/** Collapse the drawer while keeping the call active (mic/cam stay on). */
|
|
14
|
+
onMinimize?: () => void;
|
|
13
15
|
}
|
|
14
16
|
|
|
15
17
|
export const CallScreen: React.FC<CallScreenProps> = ({
|
|
16
18
|
session, localVideoRef, remoteVideoRef,
|
|
17
|
-
onEnd, onToggleMute, onToggleCamera, primaryColor,
|
|
19
|
+
onEnd, onToggleMute, onToggleCamera, primaryColor, onMinimize,
|
|
18
20
|
}) => {
|
|
19
21
|
const [duration, setDuration] = useState(0);
|
|
20
22
|
const peer = session.peer as ChatUser;
|
|
@@ -64,6 +66,26 @@ export const CallScreen: React.FC<CallScreenProps> = ({
|
|
|
64
66
|
{session.state === 'ended' && 'Call Ended'}
|
|
65
67
|
</div>
|
|
66
68
|
</div>
|
|
69
|
+
{(session.state === 'calling' || session.state === 'connected') && onMinimize && (
|
|
70
|
+
<button
|
|
71
|
+
type="button"
|
|
72
|
+
onClick={onMinimize}
|
|
73
|
+
title="Minimize — keep call while you use the page"
|
|
74
|
+
style={{
|
|
75
|
+
padding: '8px 12px',
|
|
76
|
+
borderRadius: 10,
|
|
77
|
+
border: '1px solid rgba(255,255,255,0.35)',
|
|
78
|
+
background: 'rgba(0,0,0,0.25)',
|
|
79
|
+
color: '#fff',
|
|
80
|
+
fontSize: 13,
|
|
81
|
+
fontWeight: 600,
|
|
82
|
+
cursor: 'pointer',
|
|
83
|
+
flexShrink: 0,
|
|
84
|
+
}}
|
|
85
|
+
>
|
|
86
|
+
Minimize
|
|
87
|
+
</button>
|
|
88
|
+
)}
|
|
67
89
|
</div>
|
|
68
90
|
|
|
69
91
|
{/* Center: avatar + name */}
|
|
@@ -314,12 +314,13 @@ export const ChatScreen: React.FC<ChatScreenProps> = ({
|
|
|
314
314
|
marginLeft: 4,
|
|
315
315
|
}}
|
|
316
316
|
>
|
|
317
|
-
<span style={{ fontSize: 10, color: 'rgba(255,255,255,0.85)', fontWeight: 600 }}>Sound</span>
|
|
318
317
|
<button
|
|
319
318
|
type="button"
|
|
320
319
|
role="switch"
|
|
321
320
|
aria-checked={messageSoundEnabled}
|
|
322
321
|
onClick={() => onToggleMessageSound(!messageSoundEnabled)}
|
|
322
|
+
aria-label="Toggle message sound"
|
|
323
|
+
title="Toggle message sound"
|
|
323
324
|
style={{
|
|
324
325
|
width: 36,
|
|
325
326
|
height: 20,
|
|
@@ -19,6 +19,7 @@ import { TicketDetailScreen } from './TicketDetailScreen';
|
|
|
19
19
|
import { TicketFormScreen } from './TicketFormScreen';
|
|
20
20
|
import { BlockListScreen } from './BlockList';
|
|
21
21
|
import { CallScreen } from './CallScreen';
|
|
22
|
+
import { MiniCallBar } from './MiniCallBar';
|
|
22
23
|
import { MaintenanceView } from './MaintenanceView';
|
|
23
24
|
import { BottomTabs } from './Tabs/BottomTabs';
|
|
24
25
|
import { ViewerBlockedScreen } from './ViewerBlockedScreen';
|
|
@@ -45,6 +46,8 @@ export const ChatWidget: React.FC<ChatWidgetProps> = ({ theme: localTheme, viewe
|
|
|
45
46
|
/* Drawer open state */
|
|
46
47
|
const [isOpen, setIsOpen] = useState(false);
|
|
47
48
|
const [closing, setClosing] = useState(false); // for slide-out animation
|
|
49
|
+
/** True when user hid the drawer during ringing/connected call; WebRTC session stays active. */
|
|
50
|
+
const [callMinimized, setCallMinimized] = useState(false);
|
|
48
51
|
|
|
49
52
|
/* Navigation */
|
|
50
53
|
const [activeTab, setActiveTab] = useState<BottomTab>('home');
|
|
@@ -98,10 +101,18 @@ export const ChatWidget: React.FC<ChatWidgetProps> = ({ theme: localTheme, viewe
|
|
|
98
101
|
/* WebRTC hook */
|
|
99
102
|
const { session: callSession, localVideoRef, remoteVideoRef, startCall, endCall, toggleMute, toggleCamera } = useWebRTC();
|
|
100
103
|
|
|
104
|
+
const callInProgress =
|
|
105
|
+
callSession.state === 'calling' || callSession.state === 'connected';
|
|
106
|
+
|
|
107
|
+
useEffect(() => {
|
|
108
|
+
if (!callInProgress) setCallMinimized(false);
|
|
109
|
+
}, [callInProgress]);
|
|
110
|
+
|
|
101
111
|
/* ── Drawer open/close with slide animation ───────────────────────────── */
|
|
102
112
|
const openDrawer = () => {
|
|
103
113
|
setClosing(false);
|
|
104
114
|
setIsOpen(true);
|
|
115
|
+
setCallMinimized(false);
|
|
105
116
|
};
|
|
106
117
|
|
|
107
118
|
const persistWidgetState = useCallback(() => {
|
|
@@ -333,9 +344,15 @@ export const ChatWidget: React.FC<ChatWidgetProps> = ({ theme: localTheme, viewe
|
|
|
333
344
|
|
|
334
345
|
const handleEndCall = useCallback(() => {
|
|
335
346
|
endCall();
|
|
347
|
+
setCallMinimized(false);
|
|
336
348
|
setScreen('chat');
|
|
337
349
|
}, [endCall]);
|
|
338
350
|
|
|
351
|
+
const minimizeCall = useCallback(() => {
|
|
352
|
+
setCallMinimized(true);
|
|
353
|
+
closeDrawer();
|
|
354
|
+
}, [closeDrawer]);
|
|
355
|
+
|
|
339
356
|
/* ── Derived ─────────────────────────────────────────────────────────── */
|
|
340
357
|
const isBlocked = activeUser ? blockedUids.includes(activeUser.uid) : false;
|
|
341
358
|
|
|
@@ -394,6 +411,11 @@ export const ChatWidget: React.FC<ChatWidgetProps> = ({ theme: localTheme, viewe
|
|
|
394
411
|
);
|
|
395
412
|
const blockedUsers = allUsers.filter(u => blockedUids.includes(u.uid));
|
|
396
413
|
|
|
414
|
+
const totalUnread = useMemo(
|
|
415
|
+
() => recentChats.reduce((sum, c) => sum + Math.max(0, c.unread ?? 0), 0),
|
|
416
|
+
[recentChats],
|
|
417
|
+
);
|
|
418
|
+
|
|
397
419
|
const handleTransferToDeveloper = useCallback((dev: ChatUser) => {
|
|
398
420
|
if (!activeUser || !widgetConfig) return;
|
|
399
421
|
const agent = widgetConfig.viewerName?.trim() || 'Agent';
|
|
@@ -467,12 +489,25 @@ export const ChatWidget: React.FC<ChatWidgetProps> = ({ theme: localTheme, viewe
|
|
|
467
489
|
}
|
|
468
490
|
`}</style>
|
|
469
491
|
|
|
470
|
-
{/* ──
|
|
492
|
+
{/* ── Minimized call bar (drawer closed, call still active) ── */}
|
|
493
|
+
{!isOpen && callMinimized && callInProgress && callSession.peer && (
|
|
494
|
+
<MiniCallBar
|
|
495
|
+
session={callSession}
|
|
496
|
+
primaryColor={primaryColor}
|
|
497
|
+
buttonPosition={theme.buttonPosition}
|
|
498
|
+
onExpand={openDrawer}
|
|
499
|
+
onEnd={handleEndCall}
|
|
500
|
+
/>
|
|
501
|
+
)}
|
|
502
|
+
|
|
503
|
+
{/* ── Floating Button (unread badge + tooltip when closed) ── */}
|
|
471
504
|
{!isOpen && (
|
|
472
505
|
<button
|
|
473
506
|
className="cw-root"
|
|
507
|
+
type="button"
|
|
474
508
|
onClick={openDrawer}
|
|
475
|
-
aria-label={theme.buttonLabel}
|
|
509
|
+
aria-label={totalUnread > 0 ? `${theme.buttonLabel}, ${totalUnread} unread` : theme.buttonLabel}
|
|
510
|
+
title={totalUnread > 0 ? `${totalUnread} unread message${totalUnread === 1 ? '' : 's'}` : theme.buttonLabel}
|
|
476
511
|
style={{
|
|
477
512
|
position: 'fixed', bottom: 24, zIndex: 9999,
|
|
478
513
|
...posStyle,
|
|
@@ -495,10 +530,35 @@ export const ChatWidget: React.FC<ChatWidgetProps> = ({ theme: localTheme, viewe
|
|
|
495
530
|
(e.currentTarget as HTMLElement).style.boxShadow = `0 8px 28px ${theme.buttonColor}55`;
|
|
496
531
|
}}
|
|
497
532
|
>
|
|
498
|
-
<
|
|
499
|
-
<
|
|
500
|
-
|
|
501
|
-
|
|
533
|
+
<span style={{ position: 'relative', display: 'inline-flex', alignItems: 'center', justifyContent: 'center' }}>
|
|
534
|
+
<svg width="20" height="20" viewBox="0 0 24 24" fill="none">
|
|
535
|
+
<path d="M21 15a2 2 0 01-2 2H7l-4 4V5a2 2 0 012-2h14a2 2 0 012 2z"
|
|
536
|
+
stroke={theme.buttonTextColor} strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" />
|
|
537
|
+
</svg>
|
|
538
|
+
{totalUnread > 0 && (
|
|
539
|
+
<span
|
|
540
|
+
style={{
|
|
541
|
+
position: 'absolute',
|
|
542
|
+
top: -8,
|
|
543
|
+
right: -10,
|
|
544
|
+
minWidth: 20,
|
|
545
|
+
height: 20,
|
|
546
|
+
padding: '0 5px',
|
|
547
|
+
borderRadius: 999,
|
|
548
|
+
background: '#ef4444',
|
|
549
|
+
color: '#fff',
|
|
550
|
+
fontSize: 11,
|
|
551
|
+
fontWeight: 800,
|
|
552
|
+
lineHeight: '20px',
|
|
553
|
+
textAlign: 'center',
|
|
554
|
+
border: '2px solid #fff',
|
|
555
|
+
boxSizing: 'border-box',
|
|
556
|
+
}}
|
|
557
|
+
>
|
|
558
|
+
{totalUnread > 99 ? '99+' : totalUnread}
|
|
559
|
+
</span>
|
|
560
|
+
)}
|
|
561
|
+
</span>
|
|
502
562
|
<span>{theme.buttonLabel}</span>
|
|
503
563
|
</button>
|
|
504
564
|
)}
|
|
@@ -562,8 +622,8 @@ export const ChatWidget: React.FC<ChatWidgetProps> = ({ theme: localTheme, viewe
|
|
|
562
622
|
{/* ── Main content ── */}
|
|
563
623
|
{!cfgLoading && !cfgError && widgetConfig && (
|
|
564
624
|
<>
|
|
565
|
-
{/* Resize + Close controls —
|
|
566
|
-
{screen !== 'chat' && screen !== 'call' && (
|
|
625
|
+
{/* Resize + Close controls — hidden on blocked screen (Close is in-panel) */}
|
|
626
|
+
{screen !== 'chat' && screen !== 'call' && !effectiveViewerBlocked && (
|
|
567
627
|
<div style={{
|
|
568
628
|
position: 'absolute', top: 12,
|
|
569
629
|
right: theme.buttonPosition === 'bottom-left' ? 'auto' : 12,
|
|
@@ -594,7 +654,7 @@ export const ChatWidget: React.FC<ChatWidgetProps> = ({ theme: localTheme, viewe
|
|
|
594
654
|
|
|
595
655
|
{/* ── ACTIVE: viewer spam-blocked (no chat/tickets UI) ── */}
|
|
596
656
|
{widgetConfig.status === 'ACTIVE' && effectiveViewerBlocked && (
|
|
597
|
-
<ViewerBlockedScreen config={widgetConfig} apiKey={apiKey} />
|
|
657
|
+
<ViewerBlockedScreen config={widgetConfig} apiKey={apiKey} onClose={closeDrawer} />
|
|
598
658
|
)}
|
|
599
659
|
|
|
600
660
|
{/* ── ACTIVE: microphone, location, screen share required ── */}
|
|
@@ -613,6 +673,7 @@ export const ChatWidget: React.FC<ChatWidgetProps> = ({ theme: localTheme, viewe
|
|
|
613
673
|
{screen === 'home' && (
|
|
614
674
|
<HomeScreen
|
|
615
675
|
config={widgetConfig}
|
|
676
|
+
apiKey={apiKey}
|
|
616
677
|
onNavigate={handleCardClick}
|
|
617
678
|
onOpenTicket={handleOpenTicket}
|
|
618
679
|
tickets={tickets}
|
|
@@ -665,6 +726,7 @@ export const ChatWidget: React.FC<ChatWidgetProps> = ({ theme: localTheme, viewe
|
|
|
665
726
|
onToggleMute={toggleMute}
|
|
666
727
|
onToggleCamera={toggleCamera}
|
|
667
728
|
primaryColor={primaryColor}
|
|
729
|
+
onMinimize={minimizeCall}
|
|
668
730
|
/>
|
|
669
731
|
)}
|
|
670
732
|
|
|
@@ -1,7 +1,13 @@
|
|
|
1
|
-
import React, { useState, useMemo } from 'react';
|
|
1
|
+
import React, { useState, useMemo, useEffect } from 'react';
|
|
2
2
|
import { WidgetConfig, UserListContext, Ticket } from '../../types';
|
|
3
3
|
import { SlideNavMenu } from '../SlideNavMenu';
|
|
4
4
|
import { truncateWords } from '../../utils/chat';
|
|
5
|
+
import type { PresenceStatus } from '../../types';
|
|
6
|
+
import {
|
|
7
|
+
resolveInitialPresence,
|
|
8
|
+
savePresenceStatus,
|
|
9
|
+
syncPresenceToServer,
|
|
10
|
+
} from '../../utils/presenceStatus';
|
|
5
11
|
|
|
6
12
|
export interface HomeNavigateOptions {
|
|
7
13
|
/** When true, list screens play stagger animation (home burger menu only) */
|
|
@@ -10,14 +16,44 @@ export interface HomeNavigateOptions {
|
|
|
10
16
|
|
|
11
17
|
interface HomeScreenProps {
|
|
12
18
|
config: WidgetConfig;
|
|
19
|
+
/** Same as env / chatData — required to POST presence in production */
|
|
20
|
+
apiKey: string;
|
|
13
21
|
onNavigate: (ctx: UserListContext | 'ticket', options?: HomeNavigateOptions) => void;
|
|
14
22
|
/** Open a specific pending ticket (full detail) */
|
|
15
23
|
onOpenTicket: (ticketId: string) => void;
|
|
16
24
|
tickets: Ticket[];
|
|
17
25
|
}
|
|
18
26
|
|
|
19
|
-
|
|
27
|
+
const STATUS_OPTIONS: { value: PresenceStatus; label: string }[] = [
|
|
28
|
+
{ value: 'ACTIVE', label: 'Active' },
|
|
29
|
+
{ value: 'AWAY', label: 'Away' },
|
|
30
|
+
{ value: 'DND', label: 'DND' },
|
|
31
|
+
];
|
|
32
|
+
|
|
33
|
+
export const HomeScreen: React.FC<HomeScreenProps> = ({ config, apiKey, onNavigate, onOpenTicket, tickets }) => {
|
|
20
34
|
const [menuOpen, setMenuOpen] = useState(false);
|
|
35
|
+
const [presence, setPresence] = useState<PresenceStatus>(() =>
|
|
36
|
+
resolveInitialPresence(config.id, config.presenceStatus),
|
|
37
|
+
);
|
|
38
|
+
|
|
39
|
+
useEffect(() => {
|
|
40
|
+
setPresence(resolveInitialPresence(config.id, config.presenceStatus));
|
|
41
|
+
}, [config.id, config.presenceStatus]);
|
|
42
|
+
|
|
43
|
+
const setPresenceAndSave = (s: PresenceStatus) => {
|
|
44
|
+
setPresence(s);
|
|
45
|
+
savePresenceStatus(config.id, s);
|
|
46
|
+
const url = config.presenceUpdateUrl?.trim();
|
|
47
|
+
if (!url) return;
|
|
48
|
+
void syncPresenceToServer(url, {
|
|
49
|
+
widgetId: config.id,
|
|
50
|
+
apiKey,
|
|
51
|
+
viewerUid: config.viewerUid?.trim() || undefined,
|
|
52
|
+
status: s,
|
|
53
|
+
}).catch(err => {
|
|
54
|
+
console.error('[ajaxter-chat] presence sync failed', err);
|
|
55
|
+
});
|
|
56
|
+
};
|
|
21
57
|
const showSupport = config.chatType === 'SUPPORT' || config.chatType === 'BOTH';
|
|
22
58
|
const showChat = config.chatType === 'CHAT' || config.chatType === 'BOTH';
|
|
23
59
|
const viewerIsDev = config.viewerType === 'developer';
|
|
@@ -56,14 +92,14 @@ export const HomeScreen: React.FC<HomeScreenProps> = ({ config, onNavigate, onOp
|
|
|
56
92
|
}}
|
|
57
93
|
/>
|
|
58
94
|
|
|
59
|
-
{/* Top bar —
|
|
95
|
+
{/* Top bar — menu + presence status */}
|
|
60
96
|
<div
|
|
61
97
|
style={{
|
|
62
98
|
flexShrink: 0,
|
|
63
|
-
padding: '14px
|
|
99
|
+
padding: '12px 14px 12px',
|
|
64
100
|
display: 'flex',
|
|
65
101
|
alignItems: 'center',
|
|
66
|
-
gap:
|
|
102
|
+
gap: 10,
|
|
67
103
|
background: '#fff',
|
|
68
104
|
borderBottom: '1px solid #eef0f5',
|
|
69
105
|
}}
|
|
@@ -91,6 +127,59 @@ export const HomeScreen: React.FC<HomeScreenProps> = ({ config, onNavigate, onOp
|
|
|
91
127
|
<span style={{ width: 18, height: 2, background: '#334155', borderRadius: 1 }} />
|
|
92
128
|
<span style={{ width: 18, height: 2, background: '#334155', borderRadius: 1 }} />
|
|
93
129
|
</button>
|
|
130
|
+
|
|
131
|
+
<div style={{ flex: 1, minWidth: 0 }} />
|
|
132
|
+
|
|
133
|
+
<div
|
|
134
|
+
style={{
|
|
135
|
+
display: 'flex',
|
|
136
|
+
alignItems: 'center',
|
|
137
|
+
gap: 6,
|
|
138
|
+
flexShrink: 0,
|
|
139
|
+
flexWrap: 'wrap',
|
|
140
|
+
justifyContent: 'flex-end',
|
|
141
|
+
}}
|
|
142
|
+
>
|
|
143
|
+
<div
|
|
144
|
+
role="group"
|
|
145
|
+
aria-label="Your status"
|
|
146
|
+
style={{
|
|
147
|
+
display: 'flex',
|
|
148
|
+
borderRadius: 10,
|
|
149
|
+
padding: 3,
|
|
150
|
+
background: '#f1f5f9',
|
|
151
|
+
gap: 2,
|
|
152
|
+
}}
|
|
153
|
+
>
|
|
154
|
+
{STATUS_OPTIONS.map(({ value, label }) => {
|
|
155
|
+
const isOn = presence === value;
|
|
156
|
+
return (
|
|
157
|
+
<button
|
|
158
|
+
key={value}
|
|
159
|
+
type="button"
|
|
160
|
+
onClick={() => setPresenceAndSave(value)}
|
|
161
|
+
style={{
|
|
162
|
+
border: 'none',
|
|
163
|
+
borderRadius: 8,
|
|
164
|
+
padding: '7px 10px',
|
|
165
|
+
fontSize: 11,
|
|
166
|
+
fontWeight: 700,
|
|
167
|
+
letterSpacing: '0.04em',
|
|
168
|
+
cursor: 'pointer',
|
|
169
|
+
fontFamily: 'inherit',
|
|
170
|
+
textTransform: 'uppercase',
|
|
171
|
+
background: isOn ? config.primaryColor : 'transparent',
|
|
172
|
+
color: isOn ? '#fff' : '#64748b',
|
|
173
|
+
boxShadow: isOn ? `0 2px 8px ${config.primaryColor}55` : 'none',
|
|
174
|
+
transition: 'background 0.15s, color 0.15s',
|
|
175
|
+
}}
|
|
176
|
+
>
|
|
177
|
+
{label}
|
|
178
|
+
</button>
|
|
179
|
+
);
|
|
180
|
+
})}
|
|
181
|
+
</div>
|
|
182
|
+
</div>
|
|
94
183
|
</div>
|
|
95
184
|
|
|
96
185
|
<div className="cw-scroll" style={{ flex: 1, overflowY: 'auto', padding: '20px 18px 28px' }}>
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import React, { useEffect, useState } from 'react';
|
|
4
|
+
import { CallSession, ChatUser } from '../../types';
|
|
5
|
+
import { avatarColor, initials } from '../../utils/chat';
|
|
6
|
+
|
|
7
|
+
export interface MiniCallBarProps {
|
|
8
|
+
session: CallSession;
|
|
9
|
+
primaryColor: string;
|
|
10
|
+
buttonPosition: 'bottom-left' | 'bottom-right';
|
|
11
|
+
onExpand: () => void;
|
|
12
|
+
onEnd: () => void;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Shown when the user minimizes the widget during an active call (ringing or connected).
|
|
17
|
+
* Sits above the main launcher button so the user can work on the page and return to the call.
|
|
18
|
+
*/
|
|
19
|
+
export const MiniCallBar: React.FC<MiniCallBarProps> = ({
|
|
20
|
+
session,
|
|
21
|
+
primaryColor,
|
|
22
|
+
buttonPosition,
|
|
23
|
+
onExpand,
|
|
24
|
+
onEnd,
|
|
25
|
+
}) => {
|
|
26
|
+
const peer = session.peer as ChatUser | null;
|
|
27
|
+
const [duration, setDuration] = useState(0);
|
|
28
|
+
|
|
29
|
+
useEffect(() => {
|
|
30
|
+
if (session.state !== 'connected' || !session.startedAt) return;
|
|
31
|
+
const t = setInterval(() => {
|
|
32
|
+
setDuration(Math.floor((Date.now() - session.startedAt!.getTime()) / 1000));
|
|
33
|
+
}, 1000);
|
|
34
|
+
return () => clearInterval(t);
|
|
35
|
+
}, [session.state, session.startedAt]);
|
|
36
|
+
|
|
37
|
+
const mins = String(Math.floor(duration / 60)).padStart(2, '0');
|
|
38
|
+
const secs = String(duration % 60).padStart(2, '0');
|
|
39
|
+
|
|
40
|
+
const pos: React.CSSProperties =
|
|
41
|
+
buttonPosition === 'bottom-left'
|
|
42
|
+
? { left: 24, right: 'auto' }
|
|
43
|
+
: { right: 24, left: 'auto' };
|
|
44
|
+
|
|
45
|
+
return (
|
|
46
|
+
<div
|
|
47
|
+
role="toolbar"
|
|
48
|
+
aria-label="Call in progress"
|
|
49
|
+
style={{
|
|
50
|
+
position: 'fixed',
|
|
51
|
+
bottom: 88,
|
|
52
|
+
zIndex: 10000,
|
|
53
|
+
...pos,
|
|
54
|
+
display: 'flex',
|
|
55
|
+
alignItems: 'center',
|
|
56
|
+
gap: 10,
|
|
57
|
+
padding: '10px 14px',
|
|
58
|
+
maxWidth: 'min(360px, calc(100vw - 48px))',
|
|
59
|
+
borderRadius: 14,
|
|
60
|
+
background: `linear-gradient(135deg, ${primaryColor}ee, #0f172a)`,
|
|
61
|
+
color: '#fff',
|
|
62
|
+
boxShadow: '0 10px 32px rgba(0,0,0,0.28)',
|
|
63
|
+
animation: 'cw-miniBarIn 0.28s cubic-bezier(0.22,1,0.36,1)',
|
|
64
|
+
cursor: 'default',
|
|
65
|
+
}}
|
|
66
|
+
>
|
|
67
|
+
<style>{`
|
|
68
|
+
@keyframes cw-miniBarIn {
|
|
69
|
+
from { opacity: 0; transform: translateY(12px); }
|
|
70
|
+
to { opacity: 1; transform: translateY(0); }
|
|
71
|
+
}
|
|
72
|
+
`}</style>
|
|
73
|
+
|
|
74
|
+
<button
|
|
75
|
+
type="button"
|
|
76
|
+
onClick={onExpand}
|
|
77
|
+
title="Open call"
|
|
78
|
+
style={{
|
|
79
|
+
display: 'flex',
|
|
80
|
+
alignItems: 'center',
|
|
81
|
+
gap: 10,
|
|
82
|
+
flex: 1,
|
|
83
|
+
minWidth: 0,
|
|
84
|
+
padding: 0,
|
|
85
|
+
border: 'none',
|
|
86
|
+
background: 'transparent',
|
|
87
|
+
color: 'inherit',
|
|
88
|
+
cursor: 'pointer',
|
|
89
|
+
textAlign: 'left',
|
|
90
|
+
}}
|
|
91
|
+
>
|
|
92
|
+
{peer && (
|
|
93
|
+
<div
|
|
94
|
+
style={{
|
|
95
|
+
width: 40,
|
|
96
|
+
height: 40,
|
|
97
|
+
borderRadius: '50%',
|
|
98
|
+
backgroundColor: avatarColor(peer.name),
|
|
99
|
+
display: 'flex',
|
|
100
|
+
alignItems: 'center',
|
|
101
|
+
justifyContent: 'center',
|
|
102
|
+
fontSize: 14,
|
|
103
|
+
fontWeight: 700,
|
|
104
|
+
flexShrink: 0,
|
|
105
|
+
animation: session.state === 'calling' ? 'cw-pulse 1.5s ease infinite' : 'none',
|
|
106
|
+
}}
|
|
107
|
+
>
|
|
108
|
+
{initials(peer.name)}
|
|
109
|
+
</div>
|
|
110
|
+
)}
|
|
111
|
+
<div style={{ minWidth: 0, flex: 1 }}>
|
|
112
|
+
<div style={{ fontWeight: 700, fontSize: 14, whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>
|
|
113
|
+
{peer?.name ?? 'Call'}
|
|
114
|
+
</div>
|
|
115
|
+
<div style={{ fontSize: 12, opacity: 0.9, marginTop: 2 }}>
|
|
116
|
+
{session.state === 'calling' && 'Calling…'}
|
|
117
|
+
{session.state === 'connected' && `${mins}:${secs}`}
|
|
118
|
+
</div>
|
|
119
|
+
</div>
|
|
120
|
+
</button>
|
|
121
|
+
|
|
122
|
+
<button
|
|
123
|
+
type="button"
|
|
124
|
+
onClick={onEnd}
|
|
125
|
+
title="End call"
|
|
126
|
+
style={{
|
|
127
|
+
width: 40,
|
|
128
|
+
height: 40,
|
|
129
|
+
borderRadius: '50%',
|
|
130
|
+
border: 'none',
|
|
131
|
+
background: '#ef4444',
|
|
132
|
+
cursor: 'pointer',
|
|
133
|
+
display: 'flex',
|
|
134
|
+
alignItems: 'center',
|
|
135
|
+
justifyContent: 'center',
|
|
136
|
+
flexShrink: 0,
|
|
137
|
+
boxShadow: '0 2px 10px rgba(239,68,68,0.45)',
|
|
138
|
+
}}
|
|
139
|
+
>
|
|
140
|
+
<svg width="18" height="18" viewBox="0 0 24 24" fill="none">
|
|
141
|
+
<path
|
|
142
|
+
d="M22 16.92v3a2 2 0 01-2.18 2 19.79 19.79 0 01-8.63-3.07A19.5 19.5 0 013.07 10.8a19.79 19.79 0 01-3.07-8.68A2 2 0 012 0h3a2 2 0 012 1.72c.127.96.361 1.903.7 2.81a2 2 0 01-.45 2.11L6.09 7.91a16 16 0 006 6l1.27-1.27a2 2 0 012.11-.45c.907.339 1.85.573 2.81.7A2 2 0 0122 14.92v2z"
|
|
143
|
+
fill="#fff"
|
|
144
|
+
transform="rotate(135 12 12)"
|
|
145
|
+
/>
|
|
146
|
+
</svg>
|
|
147
|
+
</button>
|
|
148
|
+
</div>
|
|
149
|
+
);
|
|
150
|
+
};
|
|
@@ -10,9 +10,10 @@ const DEFAULT_MESSAGE =
|
|
|
10
10
|
interface ViewerBlockedScreenProps {
|
|
11
11
|
config: WidgetConfig;
|
|
12
12
|
apiKey: string;
|
|
13
|
+
onClose: () => void;
|
|
13
14
|
}
|
|
14
15
|
|
|
15
|
-
export const ViewerBlockedScreen: React.FC<ViewerBlockedScreenProps> = ({ config, apiKey }) => {
|
|
16
|
+
export const ViewerBlockedScreen: React.FC<ViewerBlockedScreenProps> = ({ config, apiKey, onClose }) => {
|
|
16
17
|
const [text, setText] = useState('');
|
|
17
18
|
const [status, setStatus] = useState<'idle' | 'sending' | 'sent' | 'error'>('idle');
|
|
18
19
|
const [error, setError] = useState<string | null>(null);
|
|
@@ -79,9 +80,29 @@ export const ViewerBlockedScreen: React.FC<ViewerBlockedScreenProps> = ({ config
|
|
|
79
80
|
</p>
|
|
80
81
|
|
|
81
82
|
{status === 'sent' ? (
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
83
|
+
<>
|
|
84
|
+
<p style={{ margin: '0 0 16px', fontSize: 14, color: '#16a34a', fontWeight: 600 }}>
|
|
85
|
+
Your request was sent. We will review it shortly.
|
|
86
|
+
</p>
|
|
87
|
+
<button
|
|
88
|
+
type="button"
|
|
89
|
+
onClick={onClose}
|
|
90
|
+
style={{
|
|
91
|
+
width: '100%',
|
|
92
|
+
padding: '12px 16px',
|
|
93
|
+
borderRadius: 12,
|
|
94
|
+
border: '2px solid #ef4444',
|
|
95
|
+
background: '#fff',
|
|
96
|
+
color: '#ef4444',
|
|
97
|
+
fontWeight: 700,
|
|
98
|
+
fontSize: 15,
|
|
99
|
+
cursor: 'pointer',
|
|
100
|
+
fontFamily: 'inherit',
|
|
101
|
+
}}
|
|
102
|
+
>
|
|
103
|
+
Close
|
|
104
|
+
</button>
|
|
105
|
+
</>
|
|
85
106
|
) : (
|
|
86
107
|
<>
|
|
87
108
|
<label
|
|
@@ -96,6 +117,8 @@ export const ViewerBlockedScreen: React.FC<ViewerBlockedScreenProps> = ({ config
|
|
|
96
117
|
onChange={e => { setText(e.target.value); setError(null); setStatus('idle'); }}
|
|
97
118
|
placeholder="Explain briefly why your access should be restored…"
|
|
98
119
|
rows={4}
|
|
120
|
+
maxLength={500}
|
|
121
|
+
minLength={50}
|
|
99
122
|
disabled={status === 'sending'}
|
|
100
123
|
style={{
|
|
101
124
|
width: '100%',
|
|
@@ -106,8 +129,9 @@ export const ViewerBlockedScreen: React.FC<ViewerBlockedScreenProps> = ({ config
|
|
|
106
129
|
fontSize: 14,
|
|
107
130
|
fontFamily: 'inherit',
|
|
108
131
|
color: '#1e293b',
|
|
109
|
-
resize: '
|
|
132
|
+
resize: 'none',
|
|
110
133
|
minHeight: 100,
|
|
134
|
+
maxHeight: 250,
|
|
111
135
|
marginBottom: 14,
|
|
112
136
|
outline: 'none',
|
|
113
137
|
}}
|
|
@@ -130,6 +154,25 @@ export const ViewerBlockedScreen: React.FC<ViewerBlockedScreenProps> = ({ config
|
|
|
130
154
|
>
|
|
131
155
|
{status === 'sending' ? 'Sending…' : 'Submit request'}
|
|
132
156
|
</button>
|
|
157
|
+
<button
|
|
158
|
+
type="button"
|
|
159
|
+
onClick={onClose}
|
|
160
|
+
style={{
|
|
161
|
+
width: '100%',
|
|
162
|
+
marginTop: 12,
|
|
163
|
+
padding: '12px 16px',
|
|
164
|
+
borderRadius: 12,
|
|
165
|
+
border: '2px solid #ef4444',
|
|
166
|
+
background: '#fff',
|
|
167
|
+
color: '#ef4444',
|
|
168
|
+
fontWeight: 700,
|
|
169
|
+
fontSize: 15,
|
|
170
|
+
cursor: 'pointer',
|
|
171
|
+
fontFamily: 'inherit',
|
|
172
|
+
}}
|
|
173
|
+
>
|
|
174
|
+
Close
|
|
175
|
+
</button>
|
|
133
176
|
{error && (
|
|
134
177
|
<p style={{ margin: '12px 0 0', fontSize: 13, color: '#dc2626', lineHeight: 1.45 }}>
|
|
135
178
|
{error}
|
package/src/index.ts
CHANGED
|
@@ -20,6 +20,8 @@ export { submitReenableRequest } from './utils/reenableRequest';
|
|
|
20
20
|
export type { ReenableRequestPayload } from './utils/reenableRequest';
|
|
21
21
|
export { loadLocalConfig, fetchRemoteChatData } from './config';
|
|
22
22
|
export { mergeTheme, darken } from './utils/theme';
|
|
23
|
+
export { loadPresenceStatus, savePresenceStatus, resolveInitialPresence, syncPresenceToServer } from './utils/presenceStatus';
|
|
24
|
+
export type { PresenceSyncPayload } from './utils/presenceStatus';
|
|
23
25
|
export { avatarColor, initials, formatTime, formatDate, generateTranscript, downloadText, truncateWords } from './utils/chat';
|
|
24
26
|
|
|
25
27
|
export type {
|
|
@@ -30,4 +32,5 @@ export type {
|
|
|
30
32
|
ChatStatus, ChatType, UserType, OnlineStatus,
|
|
31
33
|
Screen, BottomTab, UserListContext, MessageType,
|
|
32
34
|
LocalEnvConfig,
|
|
35
|
+
PresenceStatus,
|
|
33
36
|
} from './types';
|