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.
@@ -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.15",
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
- {/* ── Floating Button ── */}
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
- <svg width="20" height="20" viewBox="0 0 24 24" fill="none">
499
- <path d="M21 15a2 2 0 01-2 2H7l-4 4V5a2 2 0 012-2h14a2 2 0 012 2z"
500
- stroke={theme.buttonTextColor} strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" />
501
- </svg>
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 — shown outside chat/call screens */}
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
- export const HomeScreen: React.FC<HomeScreenProps> = ({ config, onNavigate, onOpenTicket, tickets }) => {
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 — burger left */}
95
+ {/* Top bar — menu + presence status */}
60
96
  <div
61
97
  style={{
62
98
  flexShrink: 0,
63
- padding: '14px 16px 10px',
99
+ padding: '12px 14px 12px',
64
100
  display: 'flex',
65
101
  alignItems: 'center',
66
- gap: 12,
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
- <p style={{ margin: 0, fontSize: 14, color: '#16a34a', fontWeight: 600 }}>
83
- Your request was sent. We will review it shortly.
84
- </p>
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: 'vertical',
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';